Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# (c) Stefan Countryman, 2019
3"""
4Utilities for interacting with slack. Used for slack upload filehandlers as
5well as various slack-based logging utilities.
6"""
8import sys
9import logging
10import traceback
11from llama.utils import tbhighlight
12from llama.classes import optional_env_var
13from llama import detectors
15LOGGER = logging.getLogger(__name__)
16SLACK_CHANNELS = {
17 "LLAMA": (
18 "llama-uploads",
19 ),
20 "IceCube": (
21 "roc",
22 "realtime",
23 ),
24}
25assert set(SLACK_CHANNELS).issubset(dir(detectors))
27SLACK_TOKENS = {} # this will be populated below
28# do not load SLACK_TOKENS during ``pytest`` unit tests
29if 'pytest' not in sys.modules:
30 for org in SLACK_CHANNELS:
31 SLACK_TOKENS[org] = optional_env_var(
32 ["SLACK_API_TOKEN_" + org.upper()],
33 """
34 Please add your slack API tokens for organizations {},
35 available at https://api.slack.com/apps/AHVDFGM18/oauth or from
36 collaboration slack sysadmins, to ~/.bashrc or some other
37 startup file using this environmental variable. The line of
38 code should look like:
40 export SLACK_API_TOKEN_ORG=PLACEHOLDER
42 where 'PLACEHOLDER' is obviously replaced by your API token and
43 'ORG' is replaced with the name of the relevant organization
44 (as mentioned above and in the keys of SLACK_CHANNELS).
45 """,
46 )[0]
47 MAINTAINERS = optional_env_var(['LLAMA_SLACK_MAINTAINERS'],
48 ("Specify the comma-delimited real names, "
49 "user names, or user IDs for Slack users "
50 "who should be notified of errors."))[0]
51 MAINTAINERS = tuple(MAINTAINERS.split(',') if MAINTAINERS else [])
54def client(token):
55 """Return a ``SlackClient`` instance for interacting with Slack's API. Will
56 default to the LLAMA slack organization (which will fail if you have not
57 configured an auth token via environmental variables)."""
58 from slack import WebClient
59 if token is None:
60 raise ValueError("Must provide a valid authentication token.")
61 return WebClient(token)
64def get_users(organization):
65 """Get list of users for an organization. Particularly useful because each
66 user's ``id`` value can be used to tag users, as is done in ``tag_user``.
67 Returns the API dict straight from ``slack.WebClient``."""
68 res = client(SLACK_TOKENS[organization]).users_list()
69 if not (res.get('ok') and res.status_code == 200):
70 msg = (f"Error while fetching results. status: {res.status_code}, "
71 f"response: {res.data}")
72 LOGGER.error(msg)
73 from slack.errors import SlackApiError
74 raise SlackApiError(msg, res)
75 return res.data
78def search_users(organization, queries):
79 """Fuzzy search for Slack users.
81 Parameters
82 ----------
83 organization : str
84 The Slack organization name to search for users.
85 queries : list
86 A list of string queries, each of which should match one unique user.
87 Matches existing users for ``organization`` based on display names,
88 real names, or Slack IDs that match the values of ``queries``.
89 Case-insensitive for every field except the ID. Will only return users
90 who are not ``deleted``.
92 Returns
93 -------
94 users : list
95 A list of Slack API user dictionaries matching ``queries``, which can
96 be used to tag them in messages using the values corresponding to their
97 ``id`` keys.
99 Raises
100 ------
101 slack.errors.SlackApiError
102 If you are not authenticated or else there is some other error while
103 calling ``get_users``.
104 ValueError
105 If your query returns multiple users or no users, or if some of the
106 queries correspond to the same user.
107 """
108 members = get_users(organization)['members']
109 matches = [
110 [
111 u for u in members if (
112 not u['deleted'] and (
113 u['id'] == query or
114 any(
115 u['profile'][k].lower() == query.lower() for k in [
116 'real_name',
117 'real_name_normalized',
118 'display_name',
119 'display_name_normalized',
120 ]
121 )
122 )
123 )
124 ] for query in queries
125 ]
126 for match in matches:
127 if len(match) != 1:
128 if not match:
129 msg = ("No matching users found in organization "
130 f"{organization} for queries '{queries}'")
131 else:
132 msg = ("Multiple matching users found in organization "
133 f"{organization} for queries '{queries}': {matches}")
134 LOGGER.error(msg)
135 raise ValueError(msg)
136 single_matches = [m[0] for m in matches]
137 if len(single_matches) != len(queries):
138 msg = ("Overlapping user matches found in organization "
139 f"{organization} for queries '{queries}': {matches}")
140 LOGGER.error(msg)
141 raise ValueError(msg)
142 return single_matches
145def tag_users(organization, queries, recover=True):
146 """Get a string of text that can be used to tag ``users`` for a specific
147 Slack ``organization``. Stick this text somewhere in your message body to
148 tag people and alert them. Same interface as ``search_users``, but you can
149 optionally recover from a failure to find matching users by specifying
150 ``recover=True``.
151 """
152 from slack.errors import SlackApiError
153 try:
154 userids = [u['id'] for u in search_users(organization, queries)]
155 tags = ('<@{}> '*len(userids)).format(*userids)
156 except (SlackApiError, ValueError) as err:
157 LOGGER.error("Error while trying to tag users; could not "
158 "identify specified users %s. Error: %s", queries, err)
159 if not recover:
160 raise
161 LOGGER.error("Recovering; proceeding without tagging users.")
162 tags = f"*ERROR TAGGING USERS: {queries}*"
163 return tags
166def alert_maintainers(msg, desc=None, recover=True):
167 """Shortcut to send a message to the LLAMA maintainer of LLAMA
168 functionality on the default LLAMA channel, tagging ``MAINTAINERS`` using
169 ``tag_user`` and sending the message with ``send_message``. Import
170 this in other parts of the code to have a Slack-based logging/alert tool.
171 Optionally provide the name of the calling module (or some other
172 description) as ``desc``. Since this is meant to be used elsewhere in the
173 code (including parts of the code that may run on non-production servers),
174 it will fail if no ``SLACK_TOKENS['LLAMA']`` is defined. If
175 ``recover=True``, suppress and log errors due to message sending failures;
176 otherwise, raise errors as usual."""
177 message = desc+' '+msg if desc is not None else msg
178 LOGGER.info('Alerting maintainers via slack.')
179 return send_message(
180 organization='LLAMA',
181 message=tag_users('LLAMA', MAINTAINERS, recover=recover)+message,
182 channel='llama-notifications',
183 recover=recover,
184 )
187def send_message(organization, message, channel=None, recover=False):
188 """Send a simple text ``message`` to ``channel`` (default: first channel
189 registered for ``organization``) in ``organization``. Returns the response
190 ``dict`` from slack. If ``recover=True``, suppress and log errors due to
191 message sending failures; otherwise, raise errors as usual."""
192 if organization not in SLACK_TOKENS:
193 msg = ("Could not send Slack message due to missing auth token: "
194 f"{organization} not in SLACK_TOKENS: {SLACK_TOKENS}")
195 LOGGER.error(msg)
196 if not recover:
197 res = {'ok': False, 'note': msg}
198 from slack.errors import SlackApiError
199 raise SlackApiError(msg, res)
200 return res
201 channel = SLACK_CHANNELS[organization][0] if channel is None else channel
202 try:
203 res = client(SLACK_TOKENS[organization]).chat_postMessage(
204 channel=channel,
205 text=message,
206 )
207 if (res.get('ok') and res.status_code == 200):
208 LOGGER.debug('Sent message. Slack response: %s', res.data)
209 else:
210 msg = (f"Error while sending message. status: {res.status_code}, "
211 f"response: {res.data}")
212 LOGGER.error(msg)
213 if not recover:
214 from slack.errors import SlackApiError
215 raise SlackApiError(msg, res)
216 LOGGER.error("Recovering.")
217 return res.data
218 except Exception:
219 LOGGER.error("Failure while sending message.")
220 LOGGER.error("stack trace for that:")
221 trace = traceback.format_exc()
222 LOGGER.error(tbhighlight(trace))
223 if not recover:
224 raise
225 return {'ok': False, 'note': trace}