From b5322db38973661b08be27185420163291a1766a Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 17 Aug 2016 15:52:43 -0600 Subject: Commit updates that i haven't commited yet --- README.org | 12 ++- bin/dbolla | 2 +- warmachine/addons/standup.py | 190 +++++++++++++++++++++++----------------- warmachine/connections/base.py | 14 +-- warmachine/connections/irc.py | 5 +- warmachine/connections/slack.py | 81 +++++++++++------ 6 files changed, 183 insertions(+), 121 deletions(-) diff --git a/README.org b/README.org index 238e929..a968cbf 100644 --- a/README.org +++ b/README.org @@ -24,7 +24,7 @@ Simply run the command: * Writing a Connection To write a new connection protocol you must inherit from ~warmachine.connections.base.Connection~. This class defines an interface you -must implement to support the plugins plugins. +must implement to support the plugins. ** ~__config_prefix__~ This global is used to decide which connection to use when it is found in the config file. E.g. IRC uses ~'irc'~ and Slack uses ~'slack'~. It should be @@ -47,9 +47,12 @@ return value should be formatted in the following format: 'message': 'The message that was received', } #+END_SRC +** ~say(message, destination)~ +This method is used by plugins to send a message to a channel or user. ** ~id~ -This should return a unique id used to identify this particular connection. As -an example, the IRC connection uses something like this: +This should return a unique id used to identify this particular connection. This +is used by plugins when saving state. As an example, the IRC connection uses +something like this: #+BEGIN_SRC python @property @@ -60,3 +63,6 @@ def id(self): value = '{}-{}'.format(self.host, self.nick) return md5(value.encode()).hexdigest() #+END_SRC +** ~get_users_by_channel(channel)~ +This method should return a list of all users (including the bot) for the +connection. diff --git a/bin/dbolla b/bin/dbolla index 6c0b573..e00143b 100755 --- a/bin/dbolla +++ b/bin/dbolla @@ -106,7 +106,7 @@ class Bot(object): if hasattr(mod, cls_name): cls = getattr(mod, cls_name)() - self.loaded_plugins.append(cls) + self.loaded_plugins.append(cls) def reload_plugin(self, path): """ diff --git a/warmachine/addons/standup.py b/warmachine/addons/standup.py index dfcb6ab..0d46408 100644 --- a/warmachine/addons/standup.py +++ b/warmachine/addons/standup.py @@ -32,9 +32,10 @@ class StandUpPlugin(WarMachinePlugin): # 'DM_CHANNEL': { # 'user': 'UID', - # 'for_channel': 'CHID', + # 'for_channels': ['CHID',], # } self.users_awaiting_reply = {} + self.log.info('Loaded standup plugin') def on_connect(self, connection): self.load_schedule(connection) @@ -48,40 +49,52 @@ class StandUpPlugin(WarMachinePlugin): connection (Connection): the warmachine connection object message (dict): the warmachine formatted message """ - if not message['message'].startswith('!standup'): - if message['channel'] in self.users_awaiting_reply: - self.log.debug("Probable reply recvd from {}: {}".format( - message['channel'], - message['message'] - )) - data = self.users_awaiting_reply[message['channel']] - for_channel = data['for_channel'] - - try: - user_nick = connection.user_map[data['user']]['name'] - except KeyError: - user_nick = data['user'] - - if 'pester_task' in data: - self.log.debug('Stopping pester for {}'.format(user_nick)) - data['pester_task'].cancel() - - announce_message = '{}: {}'.format( - user_nick, - message['message'] - ) - - await connection.say( - announce_message, - for_channel) - - del data - del self.users_awaiting_reply[message['channel']] + if not message['message'].startswith('!standup') \ + and not message['channel'] \ + and message['sender'] in self.users_awaiting_reply: + self.log.debug("Probable standup reply recvd from {}: {}".format( + message['sender'], message['message'])) + + user_nick = message['sender'] + + data = self.users_awaiting_reply[user_nick] + + for_channels = data['for_channels'] + + if 'pester_task' in data: + self.log.debug('Stopping pester for {}'.format(user_nick)) + data['pester_task'].cancel() + data['pester_task'] = None + + announce_message = '{}: {}'.format( + user_nick, + message['message'] + ) + + self.users_awaiting_reply[user_nick]['standup_msg'] = \ + message['message'] + + f = self._loop.call_later( + 16*(60*60), # 16 hours + self.clear_old_standup_message_schedule_func, user_nick + ) + + self.users_awaiting_reply[user_nick]['clear_standup_msg_f'] = f + + for i in range(0, len(for_channels)): + c = self.users_awaiting_reply[user_nick]['for_channels'].pop() + await connection.say(announce_message, c) + + del data + # del self.users_awaiting_reply[user_nick] return + # Otherwise parse for the commands: + cmd = message['message'].split(' ')[0] parts = message['message'].split(' ')[1:] channel = message['channel'] + user_nick = message['sender'] # ====================================================================== # !standup-add <24h time> @@ -89,7 +102,7 @@ class StandUpPlugin(WarMachinePlugin): # Add (or update if one exists) a schedule for standup at the given 24h # time M-F # ====================================================================== - if cmd == '!standup-add' and not channel.startswith('D'): + if cmd == '!standup-add' and channel: # If there is already a schedule, kill the task for the old one. if channel in self.standup_schedules: self.standup_schedules[channel]['future'].cancel() @@ -100,12 +113,13 @@ class StandUpPlugin(WarMachinePlugin): self.schedule_standup(connection, channel, parts[0]) self.save_schedule(connection) + # ====================================================================== # !standup-remove # # Remove an existing schedule from the channel # ====================================================================== - elif cmd == '!standup-remove' and not channel.startswith('D'): + elif cmd == '!standup-remove' and channel: if channel in self.standup_schedules: self.standup_schedules[channel]['future'].cancel() del self.standup_schedules[channel] @@ -120,15 +134,17 @@ class StandUpPlugin(WarMachinePlugin): # questions. # If no users are provided, display the users currently being ignored # ====================================================================== - elif cmd == '!standup-ignore' and not channel.startswith('D') \ + elif cmd == '!standup-ignore' and channel \ and channel in self.standup_schedules: if parts: - users = ''.join(parts).split(',') - for u in users: + users_to_ignore = ''.join(parts).split(',') + for u in users_to_ignore: if u not in self.standup_schedules[channel]['ignoring']: self.log.info('Ignoring {} in channel {}'.format( u, channel)) self.standup_schedules[channel]['ignoring'].append(u) + + # Save the new users to ignore for this channel self.save_schedule(connection) ignoring = ', '.join( @@ -138,22 +154,26 @@ class StandUpPlugin(WarMachinePlugin): await connection.say('Currently ignoring {}'.format(ignoring), channel) + elif cmd == '!standup-unignore' and channel \ + and channel in self.standup_schedules: + if not parts: + return # ====================================================================== # !standup-schedules # # Report the current standup schedule dict to the requesting user # ====================================================================== - elif channel.startswith('D') and cmd == '!standup-schedules': - self.log.info('Reporting standup schedules to DM {}'.format( - channel)) - await connection.say('Standup Schedules', channel) - await connection.say('-----------------', channel) + elif not channel and cmd == '!standup-schedules': + self.log.info('Reporting standup schedules to {}'.format( + user_nick)) + await connection.say('Standup Schedules', user_nick) + await connection.say('-----------------', user_nick) await connection.say( - 'Current Loop Time: {}'.format(self._loop.time()), channel) + 'Current Loop Time: {}'.format(self._loop.time()), user_nick) await connection.say( - 'Current Time: {}'.format(datetime.now()), channel) - await connection.say(pformat(self.standup_schedules), channel) + 'Current Time: {}'.format(datetime.now()), user_nick) + await connection.say(pformat(self.standup_schedules), user_nick) # ====================================================================== # !standup-waiting_replies @@ -161,14 +181,13 @@ class StandUpPlugin(WarMachinePlugin): # Report the data struct of users we are waiting on a reply from to the # requesting user. # ====================================================================== - elif channel.startswith('D') and \ - cmd == '!standup-waiting_replies': - self.log.info('Reporting who we are waiting on replies for to DM ' - ' {}'.format(channel)) - await connection.say('Waiting for Replies From', channel) - await connection.say('------------------------', channel) + elif not channel and cmd == '!standup-waiting_replies': + self.log.info('Reporting who we are waiting on replies for to ' + ' {}'.format(user_nick)) + await connection.say('Waiting for Replies From', user_nick) + await connection.say('------------------------', user_nick) await connection.say( - pformat(self.users_awaiting_reply), channel) + pformat(self.users_awaiting_reply), user_nick) def schedule_standup(self, connection, channel, time24h): """ @@ -191,9 +210,7 @@ class StandUpPlugin(WarMachinePlugin): } self.log.info('New schedule added to channel {} for {}'.format( - connection.channel_map[channel]['name'], - time24h - )) + channel, time24h)) def standup_schedule_func(self, connection, channel): """ @@ -201,75 +218,88 @@ class StandUpPlugin(WarMachinePlugin): See :meth:`start_standup` """ - self.log.info('Executing standup for channel {}'.format( - connection.channel_map[channel]['name'] - )) + self.log.info('Executing standup for channel {}'.format(channel)) asyncio.ensure_future(self.start_standup(connection, channel)) - def pester_schedule_func(self, connection, user_id, channel, pester): + def pester_schedule_func(self, connection, user, channel, pester): """ Non-async function used to schedule pesters for a user. See :meth:`standup_priv_msg` """ self.log.info('Pestering user {} to give a standup for channel ' - '{} (interval: {}s)'.format( - connection.user_map[user_id]['name'], - connection.channel_map[channel]['name'], - pester)) + '{} (interval: {}s)'.format(user, channel, pester)) asyncio.ensure_future(self.standup_priv_msg( - connection, user_id, channel, pester)) + connection, user, channel, pester)) + + def clear_old_standup_message_schedule_func(self, user): + """ + This function is scheduled to remove old standup messages so that the + user is asked about standup the following day. + """ + del self.users_awaiting_reply[user]['clear_standup_msg_f'] + del self.users_awaiting_reply[user]['standup_msg'] async def start_standup(self, connection, channel): """ Notify the channel that the standup is about to begin, then loop through all the users in the channel asking them report their standup. """ - await connection.say('@channel Time for standup', channel) users = connection.get_users_by_channel(channel) + if not users: + self.log.error('Unable to get_users_by_channel for channel ' + '{}. Skipping standup.'.format(channel)) + return + await connection.say('@channel Time for standup', channel) for u in users: - if u == connection.my_id or \ + if u == connection.nick or \ u in self.standup_schedules[channel]['ignoring']: continue - await self.standup_priv_msg(connection, u, channel) + if u in self.users_awaiting_reply and \ + 'standup_msg' in self.users_awaiting_reply[u]: + await connection.say('{}: {}'.format( + u, self.users_awaiting_reply[u]['standup_msg']), channel) + else: + await self.standup_priv_msg(connection, u, channel) - async def standup_priv_msg(self, connection, user_id, channel, pester=600): + async def standup_priv_msg(self, connection, user, channel, pester=600): """ - Send a private message to ``user_id`` asking for their standup update. + Send a private message to ``user`` asking for their standup update. Args: connection (:class:`warmachine.base.Connection'): Connection object to use. - user_id (str): User name or id to send the message to. + user (str): User to send the message to. channel (str): The channel the standup is for pester (int): Number of seconds to wait until asking the user again. Use 0 to disable """ - dm_id = connection.get_dm_id_by_user(user_id) + self.log.debug('Messaging user: {}'.format(user)) - self.log.debug('Messaging user: {} ({})'.format( - connection.user_map[user_id], user_id)) + if user in self.users_awaiting_reply: + self.users_awaiting_reply[user]['for_channels'].append(channel) - self.users_awaiting_reply[dm_id] = { - 'for_channel': channel, - 'user': user_id - } + self.log.debug('Adding to list of users waiting on a reply for: ' + '{}'.format( + self.users_awaiting_reply[user])) + else: + self.users_awaiting_reply[user] = { + 'for_channels': [channel, ], + } - self.log.debug('Adding to list of users waiting on a reply for: ' - '{}'.format(pformat(self.users_awaiting_reply[dm_id]))) await connection.say('What did you do yesterday? What will you ' 'do today? do you have any blockers? ' - '(standup for:{})'.format(channel), dm_id) + '(standup for:{})'.format(channel), user) if pester > 0: f = self._loop.call_later( pester, functools.partial( - self.pester_schedule_func, connection, user_id, channel, + self.pester_schedule_func, connection, user, channel, pester)) - self.users_awaiting_reply[dm_id]['pester_task'] = f + self.users_awaiting_reply[user]['pester_task'] = f @classmethod diff --git a/warmachine/connections/base.py b/warmachine/connections/base.py index 4df5996..e787e35 100644 --- a/warmachine/connections/base.py +++ b/warmachine/connections/base.py @@ -26,13 +26,6 @@ class Connection(object): raise NotImplementedError('{} must implement `read` method'.format( self.__class__.__name__)) - def get_users_by_channel(self, channel): - """ - Return a list of users who are in the provided channel - """ - raise NotImplementedError('{} must implement `get_users_by_channel` ' - 'method'.format(self.__class__.__name__)) - def id(self): """ Unique ID for this connection. Since there can be more than one @@ -45,3 +38,10 @@ class Connection(object): """ raise NotImplementedError('{} must implement `id` method'.format( self.__class__.__name__)) + + def say(self, message, destination): + """ + Async method that a plugin can use to send a message to a channel or user. + """ + raise NotImplementedError('{} must implement `say` method'.format( + self.__class__.__name__)) diff --git a/warmachine/connections/irc.py b/warmachine/connections/irc.py index f7f682c..86068d7 100644 --- a/warmachine/connections/irc.py +++ b/warmachine/connections/irc.py @@ -36,12 +36,15 @@ class AioIRC(Connection): self.writer.write('USER {} 8 * :War Machine\r\n'.format( self.user).encode()) + self.status = CONNECTED + return True @asyncio.coroutine def read(self): if self.reader.at_eof(): raise Exception('eof') + if self.reader: message = yield from self.reader.readline() @@ -61,4 +64,4 @@ class AioIRC(Connection): @property @memoize def id(self): - return 'asdfasdf' + from hashlib import md5 diff --git a/warmachine/connections/slack.py b/warmachine/connections/slack.py index 8ae52da..384a1f3 100644 --- a/warmachine/connections/slack.py +++ b/warmachine/connections/slack.py @@ -27,6 +27,7 @@ class SlackWS(Connection): self.reconnect_url = '' self.channel_map = {} # channel and im info keyed by the slack id + self.channel_name_to_id = {} # slack channel/group name mapped to the id self.user_map = {} # user info keyed by their slack id self.user_nick_to_id = {} # slack user id mapped to the (nick)name @@ -46,21 +47,26 @@ class SlackWS(Connection): self.host = self.authenticate() self.log.info('Connecting to {}'.format(self.host)) self.ws = await websockets.connect(self.host) + self.STATUS = CONNECTED + + return True async def read(self): if self.ws: message = json.loads(await self.ws.recv()) # Slack is acknowledging a message was sent. Do nothing - if 'type' not in message and 'reply_to' in message: + if 'reply_to' in message: # {'ok': True, # 'reply_to': 1, # 'text': "['!whois', 'synic']", # 'ts': '1469743355.000150'} + self.log.debug('Ignoring reply_to message: {}'.format( + pformat(message))) return - self.log.debug('new message parsed: {}'.format(message)) - # Handle actual messages + self.log.debug('new slack message: {}'.format(pformat(message))) if message['type'] == 'message' and 'subtype' not in message: + # Handle text messages from users return await self.process_message(message) else: if 'subtype' in message: @@ -69,6 +75,8 @@ class SlackWS(Connection): msgtype = '{}_{}'.format( message['type'], message['subtype']) else: + # This is a non-message event from slack. + # https://api.slack.com/events msgtype = message['type'] # Look for on_{type} methods to pass the dictionary to for @@ -80,18 +88,21 @@ class SlackWS(Connection): self.log.debug('{} does not exist for message: {}'.format( func_name, message)) - async def say(self, message, destination_id): + async def say(self, message, destination): """ Say something in the provided channel or IM by id """ # If the destination is a user, figure out the DM channel id - if destination_id.startswith('U'): - destination_id = self.get_dm_id_by_user(destination_id) + if destination and destination.startswith('#'): + destination = self.channel_name_to_id[destination.replace('#','')] + else: + _user = self.user_nick_to_id[destination] + destination = self.get_dm_id_by_user(_user) message = { 'id': 1, # TODO: this should be a get_msgid call or something 'type': 'message', - 'channel': destination_id, + 'channel': destination, 'text': str(message) } self.log.debug("Saying {}".format(message)) @@ -124,25 +135,24 @@ class SlackWS(Connection): raise Exception('Slack Error: {}'.format( self._info.get('error', 'Unknown Error'))) - self.process_connect_info() + # Slack returns a huge json struct with a bunch of information + self.process_connect_info(self._info) self.log.debug('Got websocket url: {}'.format(self._info.get('url'))) return self._info.get('url') - def process_connect_info(self): + def process_connect_info(self, info): """ Processes the connection info provided by slack """ - if not self._info: + # If there is nothing to process then return + if not info: return - with open('slack_info.json', 'w') as f: - f.write(pformat(self._info)) - - self.status = CONNECTED # Save the bot's id try: self.my_id = self._info['self'].get('id', '000') + self.nick = self._info['self'].get('name', None) except KeyError: self.log.error('Unable to read self section of connect info') @@ -158,26 +168,35 @@ class SlackWS(Connection): # Map Channels for c in self._info.get('channels', []): self.channel_map[c['id']] = c + self.channel_name_to_id[c['name']] = c['id'] for g in self._info.get('groups', []): self.channel_map[g['id']] = g + self.channel_name_to_id[g['name']] = g['id'] async def process_message(self, msg): - # Built-in !whois action if 'text' not in msg: raise Exception(msg) + + # Built-in !whois command. Return information about a particular user. if msg['text'].startswith('!whois'): nicknames = msg['text'].split(' ')[1:] for n in nicknames: await self.say(pformat(self.user_map[self.user_nick_to_id[n]]), msg['channel']) return - elif msg['text'].startswith('!looptime'): - await self.say(self._loop.time(), msg['channel']) + + # Map the slack ids to usernames and channels/groups names + user_nickname = self.user_map[msg['user']]['name'] + if msg['channel'].startswith('D'): + # This is a private message + channel = None + else: + channel = '#{}'.format(self.channel_map[msg['channel']]['name']) retval = { - 'sender': msg['user'], - 'channel': msg['channel'], + 'sender': user_nickname, + 'channel': channel, 'message': msg['text'] } return retval @@ -191,16 +210,15 @@ class SlackWS(Connection): https://api.slack.com/events/user_change """ user_info = msg['user'] + + self.user_map[user_info['id']] = user_info + + # Update the nick mapping if the user changed their nickname try: old_nick = self.user_map[user_info['id']]['nick'] except KeyError as e: old_nick = None - self.log.exception('KeyError: {}'.format(e)) - self.log.exception('{}'.format(msg)) - - self.user_map[user_info['id']] = user_info - # Update the nick mapping if the user changed their nickname if old_nick and old_nick != user_info['nick']: del self.user_nick_to_id[old_nick] self.user_nick_to_id[user_info['nick']] = user_info['id'] @@ -225,6 +243,7 @@ class SlackWS(Connection): )) self.user_map[msg['user']]['presence'] = msg['presence'] + @memoize # the dm id should never change def get_dm_id_by_user(self, user_id): """ Return the channel id for a direct message to a specific user. @@ -251,8 +270,9 @@ class SlackWS(Connection): return data['channel']['id'] - def get_users_by_channel(self, channel): + channel = self.channel_name_to_id[channel.replace('#', '')] + if channel.startswith('G'): key = 'group' elif channel.startswith('C'): @@ -267,14 +287,17 @@ class SlackWS(Connection): 'channel': channel, })) - self.log.debug(url) + self.log.debug('Gathering list of users for channel {} from: {}'.format( + channel, url)) req = urllib.request.Request(url) r = json.loads(urllib.request.urlopen(req).read().decode('utf-8')) - self.log.debug(r) + users = [] + for u_id in r[key]['members']: + users.append(self.user_map[u_id]['name']) - self.log.debug(pformat(r[key]['members'])) - return r[key]['members'] + self.log.debug(pformat(users)) + return users async def on_group_join(self, channel): """ -- cgit v1.2.1