Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ secrets:
- *Optional*
- Available values:
- `webex`
- `slack`
- `office365`
- `google`
- Default value: `webex,office365`
Expand All @@ -163,6 +164,11 @@ If specificed, requires at least one of the available options. This will control
- `meeting`
- `pending`
- `presenting`
- Slack
- `active`
- `inactive`
- `donotdisturb`
- `meeting`
- Office 365
- `free`
- `tentative`
Expand All @@ -176,19 +182,19 @@ If specificed, requires at least one of the available options. This will control
#### `AVAILABLE_STATUS`

- Default value: `active`
- By default, denotes that there is no ongoing Webex call or meeting, and no calendar meetings scheduled within the next `5` minutes.
- By default, denotes that there is no ongoing collaboration call or meeting, and no calendar meetings scheduled within the next `5` minutes.
- This is the default *not busy* state. See [`OFF_STATUS`](#off_status) for an explanation of why the calendar `free` status is not included in this list by default, and why you may want to change that.

#### `SCHEDULED_STATUS`

- Default value: `busy,tentative`
- By default, denotes that there is no ongoing Webex call or meeting, but a calendar meeting, that was either accepted or tentatively accepted, is scheduled within the next `5` minutes.
- By default, denotes that there is no ongoing collaboration call or meeting, but a calendar meeting, that was either accepted or tentatively accepted, is scheduled within the next `5` minutes.
- This is the default *about to be busy* state.

#### `BUSY_STATUS`

- Default value: `call,donotdisturb,meeting,presenting,pending`
- By default, denotes that there is an ongoing Webex call or meeting, or (in the case of `donotdisturb` or `presenting`) some other reason why the user could be considered busy.
- By default, denotes that there is an ongoing collaboration call or meeting, or (in the case of `donotdisturb` or `presenting`) some other reason why the user could be considered busy.
- This is the default *busy* state.
- If the Webex "Show when in a calendar meeting" option is selected, and `webex` is present in [`SOURCES`](#sources), Webex will return a `meeting` status for any connected calendars.

Expand All @@ -202,20 +208,25 @@ If specificed, requires at least one of the available options. This will control
- In the case of `free`, there are a few reasons why it's in `OFF_STATUS` by default.
- Typically, if the user is asking for both collaboration and calendar statuses, the user will be `active` (from collaboration) and `free` (from calendar) simultaneously, so `active` will always win.
- Status-Light makes a determination of `free`/`busy`/`tentative` by checking the user's availability within the next `5` minutes. There is typically no 'off-hours' status in calendaring applications, which means, at the end of the working day, the user is technically `free`. In that instance, the light would be on during off hours, showing the selected [`AVAIALBLE_COLOR`](#available_color). Again, this is a personal preference; I don't want the light on while I'm not at work, and I am using Webex to handle [`AVAILABLE_STATUS`](#available_status).
- In the case that `webex` is not present in [`SOURCES`](#sources), it is recommended to move `free` to [`AVAILABLE_STATUS`](#available_status), but the caveat above will apply in that scenario: the light may stay on all the time.
- In the case that no collaboration sources are present in [`SOURCES`](#sources), it is recommended to move `free` to [`AVAILABLE_STATUS`](#available_status), but the caveat above will apply in that scenario: the light may stay on all the time.

**Note 1:** Status-Light makes no attempt to handle invalid values in a list. In the case of an error, Status-Light will simply revert to the default value for that list.

**Note 2:** Status-Light makes no attempt to ensure that any given status is present in only a single list. In the case of a status in multiple lists, the order of precedence below applies as well.

#### **Status Precedence**

Since the "most-busy" status should win when selecting a color, typically the Webex status will take precedence over calendars. For example, if your calendar status is `busy` (you're scheduled to be in a meeting), and your collaboration status is `meeting` (you're actively in the meeting), the collaboration status would take precedence, given the default values listed above. Generally, precedence is [`BUSY_STATUS`](#busy_status), then [`SCHEDULED_STATUS`](#scheduled_status), followed by [`AVAILABLE_STATUS`](#available_status), and finally [`OFF_STATUS`](#off_status). In more specific terms, the way Status-Light handles precedence is:
Since the "most-busy" status should win when selecting a color, typically the collaboration status will take precedence over calendars. For example, if your calendar status is `busy` (you're scheduled to be in a meeting), and your collaboration status is `meeting` (you're actively in the meeting), the collaboration status would take precedence, given the default values listed above. Generally, precedence is [`BUSY_STATUS`](#busy_status), then [`SCHEDULED_STATUS`](#scheduled_status), followed by [`AVAILABLE_STATUS`](#available_status), and finally [`OFF_STATUS`](#off_status). In more specific terms, the way Status-Light handles precedence is:

``` python
# Webex status always wins except in specific scenarios
# Collaboration status always wins except in specific scenarios
# Webex currently takes precendence over Slack
currentStatus = webexStatus
if (webexStatus in availableStatus or webexStatus in offStatus) and (officeStatus not in offStatus or googleStatus not in offStatus):
if webexStatus == const.Status.unknown or webexStatus in offStatus:
# Fall through to Slack
currentStatus = slackStatus

if (currentStatus in availableStatus or currentStatus in offStatus) and (officeStatus not in offStatus or googleStatus not in offStatus):
# Office 365 currently takes precedence over Google
if (officeStatus != const.Status.unknown):
currentStatus = officeStatus
Expand Down Expand Up @@ -315,6 +326,33 @@ To retrieve your `WEBEX_PERSONID` and `WEBEX_BOTID` creds, see below:

**Docker Secrets:** These variables can instead be specified in secrets files, using the `WEBEX_PERSONID_FILE` and `WEBEX_BOTID_FILE` variables.

### **Slack**

#### `SLACK_USER_ID`

- *Required if `slack` is present in [`SOURCES`](#sources)*
- The ID of the user presence to monitor.
- Retrieve by navigating to the user's profile, then selecting `More` and `Copy member ID`

#### `SLACK_BOT_TOKEN`

- *Required if `slack` is present in [`SOURCES`](#sources)*

To retrieve your `SLACK_BOT_TOKEN`, see below:

- Easy: Slack App Tutorial
- <https://github.com/slackapi/python-slack-sdk/tree/main/tutorial>
- Follow Step 1 and assign the following Scopes to the Bot Token
- `users:read`
- Advanced: Create a Slack App by hand
- <https://developer.webex.com/my-apps/new/bot>
- Assign the following Scopes to the Bot Token
- `users:read`

**Docker Secrets:** This variable can instead be specified in a secrets file, using the `SLACK_BOT_TOKEN_FILE` variable.

**Note:** The `SLACK_BOT_TOKEN` is Workspace-specific, meaning you will need to create a new bot for each Slack Workspace.

### **Office 365**

#### `O365_APPID`
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ tuyaface==v1.1.7
O365
google-api-python-client
google-auth-httplib2
google-auth-oauthlib
google-auth-oauthlib
slack-sdk
69 changes: 69 additions & 0 deletions status-light/sources/collaboration/slack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# https://github.com/portableprogrammer/Status-Light/

# Standard imports
import logging

# 3rd-Party imports
from slack_sdk.web import WebClient
from slack_sdk.errors import SlackApiError

# Project imports
from utility import const

logger = logging.getLogger(__name__)

class SlackAPI:
user_id = None
bot_token = None

def get_client(self):
return WebClient(token=self.bot_token)

def get_user_info(self, client:WebClient = None):
response = None
if client is None:
client = self.get_client()
try:
response = client.users_info(user=self.user_id) # pylint: disable=no-value-for-parameter

return response.data['user'] # pylint: disable=no-member
except (SystemExit, KeyboardInterrupt):
pass
except SlackApiError as ex:
logger.warning('Exception during get_user_info: %s', ex.response['error'])
return None
except BaseException as ex: # pylint: disable=broad-except
logger.warning('Exception during get_user_info: %s', ex)
return None

def get_user_presence(self, check_dnd:bool=True, check_huddle:bool=True):
client = self.get_client()
response = None
user_info = None
return_value = const.Status.unknown
try:
# If we want to check for DnD or Huddle (busy or call),
if check_dnd or check_huddle:
# Get the latest user info
user_info = self.get_user_info(client)
if user_info['profile']['status_emoji'] == ':headphones:' \
and user_info['profile']['status_text'].startsWith('In a huddle'):

return_value = const.Status.meeting

if return_value is const.Status.unknown:
response = client.users_getPresence(user=self.user_id)
match response.data['presence']: # pylint: disable=no-member
case "active":
return_value = const.Status.active
case "away":
return_value = const.Status.inactive
return return_value
except (SystemExit, KeyboardInterrupt):
pass
except SlackApiError as ex:
logger.warning('Exception during get_user_info: %s', ex.response['error'])
return const.Status.unknown
except BaseException as ex: # pylint: disable=broad-except
logger.warning('Exception during get_user_presence: %s', ex)
return const.Status.unknown
32 changes: 28 additions & 4 deletions status-light/status-light.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

# Project imports
from sources.collaboration import webex
# 48 - Add Slack support
from sources.collaboration import slack
from sources.calendar import office365
# 47 - Add Google support
from sources.calendar import google
Expand Down Expand Up @@ -76,6 +78,17 @@ def receive_terminate(signal_number, frame):
logger.warning('Requested Webex, but could not find all environment variables!')
sys.exit(1)

slack_api = None
if const.StatusSource.slack in localEnv.selected_sources:
if localEnv.get_slack():
logger.info('Requested Slack,')
slack_api = slack.SlackAPI()
slack_api.user_id = localEnv.slack_user_id
slack_api.bot_token= localEnv.slack_bot_token
else:
logger.warning('Requested Slack, but could not find all environment variables!')
sys.exit(1)

office_api = None
if const.StatusSource.office365 in localEnv.selected_sources:
if localEnv.get_office():
Expand Down Expand Up @@ -113,13 +126,18 @@ def receive_terminate(signal_number, frame):
while shouldContinue:
try:
webexStatus = const.Status.unknown
slackStatus = const.Status.unknown
officeStatus = const.Status.unknown
googleStatus = const.Status.unknown

# Webex Status
if const.StatusSource.webex in localEnv.selected_sources:
webexStatus = webex_api.get_person_status(localEnv.webex_person_id)

# Slack Status
if const.StatusSource.slack in localEnv.selected_sources:
slackStatus = slack_api.get_user_presence()

# O365 Status (based on calendar)
if const.StatusSource.office365 in localEnv.selected_sources:
officeStatus = office_api.get_current_status()
Expand All @@ -131,11 +149,17 @@ def receive_terminate(signal_number, frame):
# TODO: Now that we have more than one calendar-based status source,
# build a real precedence module for these
# Compare statii and pick a winner
logger.debug('Webex: %s | Office: %s | Google: %s', webexStatus, officeStatus, googleStatus)
# Webex status always wins except in specific scenarios
logger.debug('Webex: %s | Slack: %s | Office: %s | Google: %s', webexStatus, slackStatus, officeStatus, googleStatus)
# Collaboration status always wins except in specific scenarios
# Webex currently takes precendence over Slack
currentStatus = webexStatus
if (webexStatus in localEnv.available_status or webexStatus in localEnv.off_status) \
and (officeStatus not in localEnv.off_status or googleStatus not in localEnv.off_status):
if webexStatus == const.Status.unknown or webexStatus in localEnv.off_status:
# Fall through to Slack
currentStatus = slackStatus

if (currentStatus in localEnv.available_status or currentStatus in localEnv.off_status) \
and (officeStatus not in localEnv.off_status
or googleStatus not in localEnv.off_status):

logger.debug('Using calendar-based status')
# Office should take precedence over Google for now
Expand Down
12 changes: 7 additions & 5 deletions status-light/utility/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
class Status(enum.Enum):
unknown = 0

# Collaboration (Webex)
# Collaboration (Webex, Slack)
active = 1
call = 2
donotdisturb = 3
Expand All @@ -19,7 +19,7 @@ class Status(enum.Enum):
outofoffice = 11
workingelsewhere = 12

def _missing_(self, value):
def _missing_(self, value): # pylint: disable=arguments-differ
return self.unknown

class Color(enum.Enum):
Expand All @@ -31,16 +31,18 @@ class Color(enum.Enum):
green = '00ff00'
blue = '0000f'

def _missing_(self, value):
def _missing_(self, value): # pylint: disable=arguments-differ
return self.unknown

class StatusSource(enum.Enum):
unknown = 0

webex = 1
office365 = 2
#47: Add Google support
# 47 - Add Google support
google = 3
# 48 - Add Slack suport
slack = 4

def _missing(self, value):
def _missing(self, value): # pylint: disable=arguments-differ
return self.unknown
11 changes: 11 additions & 0 deletions status-light/utility/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ class Environment:
webex_person_id = None
webex_bot_id = None

# 48 - Add Slack Support
slack_user_email = None
slack_bot_token = None

office_app_id = None
office_app_secret = None
office_token_store = '~'
Expand Down Expand Up @@ -86,6 +90,13 @@ def get_webex(self):
self.webex_bot_id = self._get_env_or_secret('WEBEX_BOTID', None)
return (None not in [self.webex_person_id, self.webex_bot_id])

def get_slack(self):
# 30: This variable could contain secrets
self.slack_user_id = self._get_env_or_secret('SLACK_USER_ID', None)
# 30: This variable could contain secrets
self.slack_bot_token = self._get_env_or_secret('SLACK_BOT_TOKEN', None)
return (None not in [self.slack_user_id, self.slack_bot_token])

def get_office(self):
# 30: This variable could contain secrets
self.office_app_id = self._get_env_or_secret('O365_APPID', None)
Expand Down