diff options
Diffstat (limited to 'warmachine/connections/slack.py')
| -rw-r--r-- | warmachine/connections/slack.py | 281 |
1 files changed, 281 insertions, 0 deletions
diff --git a/warmachine/connections/slack.py b/warmachine/connections/slack.py new file mode 100644 index 0000000..b1a5390 --- /dev/null +++ b/warmachine/connections/slack.py | |||
| @@ -0,0 +1,281 @@ | |||
| 1 | import asyncio | ||
| 2 | import json | ||
| 3 | import logging | ||
| 4 | from pprint import pformat | ||
| 5 | from urllib.parse import urlencode | ||
| 6 | |||
| 7 | import websockets | ||
| 8 | |||
| 9 | from .base import Connection, INITALIZED, CONNECTED | ||
| 10 | |||
| 11 | |||
| 12 | #: Define slack as a config section prefix | ||
| 13 | __config_prefix__ = 'slack' | ||
| 14 | |||
| 15 | |||
| 16 | class SlackWS(Connection): | ||
| 17 | def __init__(self, options, *args, **kwargs): | ||
| 18 | super().__init__(*args, **kwargs) | ||
| 19 | |||
| 20 | self._loop = asyncio.get_event_loop() | ||
| 21 | self.log = logging.getLogger(self.__class__.__name__) | ||
| 22 | self.host = None | ||
| 23 | self.token = options['token'] | ||
| 24 | |||
| 25 | self._info = None | ||
| 26 | self.reconnect_url = '' | ||
| 27 | |||
| 28 | self.channel_map = {} # channel and im info keyed by the slack id | ||
| 29 | self.user_map = {} # user info keyed by their slack id | ||
| 30 | self.user_nick_to_id = {} # slack user id mapped to the (nick)name | ||
| 31 | |||
| 32 | self.ws = None | ||
| 33 | |||
| 34 | self.status = INITALIZED | ||
| 35 | |||
| 36 | async def connect(self): | ||
| 37 | self.host = self.authenticate() | ||
| 38 | self.log.info('Connecting to {}'.format(self.host)) | ||
| 39 | self.ws = await websockets.connect(self.host) | ||
| 40 | |||
| 41 | async def read(self): | ||
| 42 | if self.ws: | ||
| 43 | message = json.loads(await self.ws.recv()) | ||
| 44 | |||
| 45 | # Slack is acknowledging a message was sent. Do nothing | ||
| 46 | if 'type' not in message and 'reply_to' in message: | ||
| 47 | # {'ok': True, | ||
| 48 | # 'reply_to': 1, | ||
| 49 | # 'text': "['!whois', 'synic']", | ||
| 50 | # 'ts': '1469743355.000150'} | ||
| 51 | return | ||
| 52 | |||
| 53 | # Handle actual messages | ||
| 54 | elif message['type'] == 'message' and 'subtype' not in message: | ||
| 55 | return await self.process_message(message) | ||
| 56 | else: | ||
| 57 | if 'subtype' in message: | ||
| 58 | # This is a message with a subtype and should be processed | ||
| 59 | # differently | ||
| 60 | msgtype = '{}_{}'.format( | ||
| 61 | message['type'], message['subtype']) | ||
| 62 | else: | ||
| 63 | msgtype = message['type'] | ||
| 64 | |||
| 65 | # Look for on_{type} methods to pass the dictionary to for | ||
| 66 | # additional processing | ||
| 67 | func_name = 'on_{}'.format(msgtype) | ||
| 68 | if hasattr(self, func_name): | ||
| 69 | getattr(self, func_name)(message) | ||
| 70 | else: | ||
| 71 | self.log.debug('{} does not exist for message: {}'.format( | ||
| 72 | func_name, message)) | ||
| 73 | |||
| 74 | async def say(self, message, destination_id): | ||
| 75 | """ | ||
| 76 | Say something in the provided channel or IM by id | ||
| 77 | """ | ||
| 78 | await self._send(json.dumps({ | ||
| 79 | 'id': 1, # TODO: this should be a get_msgid call or something | ||
| 80 | 'type': 'message', | ||
| 81 | 'channel': destination_id, | ||
| 82 | 'text': message | ||
| 83 | })) | ||
| 84 | |||
| 85 | async def _send(self, message): | ||
| 86 | """ | ||
| 87 | Send ``message`` to the connected slack server | ||
| 88 | """ | ||
| 89 | await self.ws.send(message) | ||
| 90 | |||
| 91 | def authenticate(self): | ||
| 92 | """ | ||
| 93 | Populate ``self._info`` | ||
| 94 | |||
| 95 | Returns: | ||
| 96 | str: websocket url to connect to | ||
| 97 | """ | ||
| 98 | import urllib.request | ||
| 99 | url = 'https://slack.com/api/rtm.start?{}'.format( | ||
| 100 | urlencode( | ||
| 101 | {'token': | ||
| 102 | self.token})) | ||
| 103 | self.log.debug('Connecting to {}'.format(url)) | ||
| 104 | req = urllib.request.Request(url) | ||
| 105 | |||
| 106 | r = urllib.request.urlopen(req).read().decode('utf-8') | ||
| 107 | self._info = json.loads(r) | ||
| 108 | |||
| 109 | if not self._info.get('ok', True): | ||
| 110 | raise Exception('Slack Error: {}'.format( | ||
| 111 | self._info.get('error', 'Unknown Error'))) | ||
| 112 | |||
| 113 | self.process_connect_info() | ||
| 114 | |||
| 115 | self.log.debug('Got websocket url: {}'.format(self._info.get('url'))) | ||
| 116 | return self._info.get('url') | ||
| 117 | |||
| 118 | def process_connect_info(self): | ||
| 119 | """ | ||
| 120 | Processes the connection info provided by slack | ||
| 121 | """ | ||
| 122 | if not self._info: | ||
| 123 | return | ||
| 124 | |||
| 125 | self.status = CONNECTED | ||
| 126 | |||
| 127 | # Map users | ||
| 128 | for u in self._info.get('users', []): | ||
| 129 | self.user_map[u['id']] = u | ||
| 130 | self.user_nick_to_id[u['name']] = u['id'] | ||
| 131 | |||
| 132 | # Map IM | ||
| 133 | for i in self._info.get('ims', []): | ||
| 134 | self.channel_map[i['id']] = i | ||
| 135 | |||
| 136 | # Map Channels | ||
| 137 | for c in self._info.get('channels', []): | ||
| 138 | self.channel_map[c['id']] = c | ||
| 139 | |||
| 140 | async def process_message(self, msg): | ||
| 141 | # Built-in !whois action | ||
| 142 | if 'text' not in msg: | ||
| 143 | raise Exception(msg) | ||
| 144 | if msg['text'].startswith('!whois'): | ||
| 145 | nicknames = msg['text'].split(' ')[1:] | ||
| 146 | for n in nicknames: | ||
| 147 | await self.say(pformat(self.user_map[self.user_nick_to_id[n]]), | ||
| 148 | msg['channel']) | ||
| 149 | return | ||
| 150 | |||
| 151 | retval = { | ||
| 152 | 'sender': msg['user'], | ||
| 153 | 'channel': msg['channel'], | ||
| 154 | 'message': msg['text'] | ||
| 155 | } | ||
| 156 | return retval | ||
| 157 | |||
| 158 | def on_user_change(self, msg): | ||
| 159 | """ | ||
| 160 | The user_change event is sent to all connections for a team when a team | ||
| 161 | member updates their profile or data. Clients can use this to update | ||
| 162 | their local cache of team members. | ||
| 163 | |||
| 164 | https://api.slack.com/events/user_change | ||
| 165 | """ | ||
| 166 | user_info = msg['user'] | ||
| 167 | old_nick = self.user_map[user_info['id']]['nick'] | ||
| 168 | |||
| 169 | self.user_map[user_info['id']] = user_info | ||
| 170 | |||
| 171 | # Update the nick mapping if the user changed their nickname | ||
| 172 | if old_nick != user_info['nick']: | ||
| 173 | del self.user_nick_to_id[old_nick] | ||
| 174 | self.user_nick_to_id[user_info['nick']] = user_info['id'] | ||
| 175 | |||
| 176 | def on_reconnect_url(self, msg): | ||
| 177 | """ | ||
| 178 | The reconnect_url event is currently unsupported and experimental. | ||
| 179 | |||
| 180 | https://api.slack.com/events/reconnect_url | ||
| 181 | """ | ||
| 182 | # self.reconnect_url = msg['url'] | ||
| 183 | # self.log.debug('updated_reconnect_url: {}'.format(self.reconnect_url)) | ||
| 184 | |||
| 185 | def on_presence_change(self, msg): | ||
| 186 | self.log.debug('updated_presence: {} ({}) was: {} is_now: {}'.format( | ||
| 187 | msg['user'], self.user_map[msg['user']]['name'], | ||
| 188 | self.user_map[msg['user']]['presence'], | ||
| 189 | msg['presence'] | ||
| 190 | )) | ||
| 191 | self.user_map[msg['user']]['presence'] = msg['presence'] | ||
| 192 | |||
| 193 | async def on_group_join(self, channel): | ||
| 194 | """ | ||
| 195 | The group_joined event is sent to all connections for a user when that | ||
| 196 | user joins a private channel. In addition to this message, all existing | ||
| 197 | members of the private channel will receive a group_join message event. | ||
| 198 | |||
| 199 | https://api.slack.com/events/group_joined | ||
| 200 | """ | ||
| 201 | # { | ||
| 202 | # 'channel': { | ||
| 203 | # 'members': ['U0286NL58', 'U1U05AF5J'], | ||
| 204 | # 'id': 'G1W837CGP', | ||
| 205 | # 'is_group': True, | ||
| 206 | # 'is_archived': False, | ||
| 207 | # 'latest': { | ||
| 208 | # 'user': 'U0286NL58', | ||
| 209 | # 'subtype': 'group_join', | ||
| 210 | # 'ts': '1469746594 .000002', | ||
| 211 | # 'type': 'message', | ||
| 212 | # 'text': '<@U0286NL58|jason> has joined the group' | ||
| 213 | # }, | ||
| 214 | # 'is_mpim': False, | ||
| 215 | # 'unread_count': 0, | ||
| 216 | # 'purpose': { | ||
| 217 | # 'creator': '', | ||
| 218 | # 'value': '', | ||
| 219 | # 'last_set': 0 | ||
| 220 | # }, | ||
| 221 | # 'is_open': True, | ||
| 222 | # 'topic': { | ||
| 223 | # 'creator': '', | ||
| 224 | # 'value': '', | ||
| 225 | # 'last_set': 0 | ||
| 226 | # }, | ||
| 227 | # 'creator': 'U0286NL58', | ||
| 228 | # 'unread_count_display': 0, | ||
| 229 | # 'name': 'wm-test', | ||
| 230 | # 'last_read': '1469746594.000002', | ||
| 231 | # 'created': 1469746594 | ||
| 232 | # }, | ||
| 233 | # 'type': 'group_joined' | ||
| 234 | # } | ||
| 235 | |||
| 236 | def on_message_message_changed(self, msg): | ||
| 237 | """ | ||
| 238 | A message_changed message is sent when a message in a channel is edited | ||
| 239 | using the chat.update method. The message property contains the updated | ||
| 240 | message object. | ||
| 241 | |||
| 242 | When clients receive this message type, they should look for an | ||
| 243 | existing message with the same message.ts in that channel. If they | ||
| 244 | find one the existing message should be replaced with the new one. | ||
| 245 | |||
| 246 | https://api.slack.com/events/message/message_changed | ||
| 247 | """ | ||
| 248 | # { | ||
| 249 | # 'hidden': True, | ||
| 250 | # 'event_ts': '1469748743.218081', | ||
| 251 | # 'subtype': 'message_changed', | ||
| 252 | # 'message': { | ||
| 253 | # 'attachments': [{ | ||
| 254 | # 'id': 1, | ||
| 255 | # 'image_width': 800, | ||
| 256 | # 'fallback': '800x450px image', | ||
| 257 | # 'from_url': | ||
| 258 | # 'http://media1.giphy.com/media/3o85fPE3Irg8Wazl9S/giphy.gif', | ||
| 259 | # 'image_bytes': 4847496, | ||
| 260 | # 'image_url': | ||
| 261 | # 'http://media1.giphy.com/media/3o85fPE3Irg8Wazl9S/giphy.gif', | ||
| 262 | # 'image_height': 450, | ||
| 263 | # 'is_animated': True | ||
| 264 | # }], | ||
| 265 | # 'type': 'message', | ||
| 266 | # 'ts': '1469748743.000019', | ||
| 267 | # 'text': | ||
| 268 | # '<http://media1.giphy.com/media/3o85fPE3Irg8Wazl9S/giphy.gif>', | ||
| 269 | # 'user': 'U1U05AF5J' | ||
| 270 | # }, | ||
| 271 | # 'channel': 'G1W837CGP', | ||
| 272 | # 'ts': '1469748743.000020', | ||
| 273 | # 'type': 'message', | ||
| 274 | # 'previous_message': { | ||
| 275 | # 'type': 'message', | ||
| 276 | # 'ts': '1469748743.000019', | ||
| 277 | # 'text': | ||
| 278 | # '<http://media1.giphy.com/media/3o85fPE3Irg8Wazl9S/giphy.gif>', | ||
| 279 | # 'user': 'U1U05AF5J' | ||
| 280 | # } | ||
| 281 | # } | ||