Hide keyboard shortcuts

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 

2 

3""" 

4Utilities for interacting with slack. Used for slack upload filehandlers as 

5well as various slack-based logging utilities. 

6""" 

7 

8import sys 

9import logging 

10import traceback 

11from llama.utils import tbhighlight 

12from llama.classes import optional_env_var 

13from llama import detectors 

14 

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)) 

26 

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: 

39 

40 export SLACK_API_TOKEN_ORG=PLACEHOLDER 

41 

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 []) 

52 

53 

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) 

62 

63 

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 

76 

77 

78def search_users(organization, queries): 

79 """Fuzzy search for Slack users. 

80 

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``. 

91 

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. 

98 

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 

143 

144 

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 

164 

165 

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 ) 

185 

186 

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}