From ddb6ffac103be9599d80017936fae2c198c5f4f2 Mon Sep 17 00:00:00 2001 From: Jacob Henry Date: Fri, 22 Feb 2019 04:34:25 -0500 Subject: [PATCH] Almost done --- channel_util.py | 31 ---- client_wrapper.py | 127 -------------- identifier.py | 151 ++++++++--------- job_commands.py | 152 +++++++++-------- main.py | 15 +- management_commands.py | 13 +- periodicals.py | 13 +- scroll_util.py | 5 +- slack_util.py | 369 ++++++++++++++++++++++++++++++++--------- slavestothemachine.py | 19 +-- 10 files changed, 460 insertions(+), 435 deletions(-) delete mode 100644 channel_util.py delete mode 100644 client_wrapper.py diff --git a/channel_util.py b/channel_util.py deleted file mode 100644 index 6afdda6..0000000 --- a/channel_util.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Match - -from slackclient import SlackClient - -import slack_util - -# Useful channels -GENERAL = "C0CFHPNEM" -RANDOM = "C0CFDQWUW" -COMMAND_CENTER_ID = "GCR631LQ1" -SLAVES_TO_THE_MACHINE_ID = "C9WUQBYNP" -BOTZONE = "C3BF2MFKM" -HOUSEJOBS = "CDWDDTAT0" - - -# Callback for telling what channel we in -async def channel_check_callback(slack: SlackClient, msg: dict, match: Match) -> None: - # Sets the users scroll - rest_of_msg = match.group(1).strip() - rest_of_msg = rest_of_msg.replace("<", "lcaret") - rest_of_msg = rest_of_msg.replace(">", "rcaret") - - # Respond - response = "" - response += "Channel id: {}\n".format(msg["channel"]) - response += "Escaped message: {}\n".format(rest_of_msg) - slack_util.reply(slack, msg, response) - - -channel_check_hook = slack_util.ChannelHook(channel_check_callback, - patterns=r"channel id\s*(.*)") diff --git a/client_wrapper.py b/client_wrapper.py deleted file mode 100644 index f4d8bb8..0000000 --- a/client_wrapper.py +++ /dev/null @@ -1,127 +0,0 @@ -import asyncio -import traceback -from typing import List, Any, AsyncGenerator, Coroutine, TypeVar - -from slackclient import SlackClient # Obvious - -import channel_util -import sys -import slack_util - -# Read the API token -api_file = open("apitoken.txt", 'r') -SLACK_API = next(api_file).strip() -api_file.close() - -# Enable to do single-threaded and have better exceptions -DEBUG_MODE = False - -A, B, C = TypeVar("A"), TypeVar("B"), TypeVar("C") - - -async def _loud_mouth(c: Coroutine[A, B, C]) -> Coroutine[A, B, C]: - # Print exceptions as they pass through - try: - return await c - except Exception: - traceback.print_exc() - raise - - -class ClientWrapper(object): - """ - Essentially the main state object. - We only ever expect one of these. - Holds a slack client, and handles messsages. - """ - - def __init__(self): - # Init slack - self.slack = SlackClient(SLACK_API) - - # Hooks go regex -> callback on (slack, msg, match) - self.hooks: List[slack_util.AbsHook] = [] - - # Periodicals are just wrappers around an iterable, basically - self.passives: List[slack_util.Passive] = [] - - # Scheduled events handling - def add_passive(self, per: slack_util.Passive) -> None: - self.passives.append(per) - - async def run_passives(self) -> None: - # Make a task to repeatedly spawn each event - awaitables = [p.run(self.slack) for p in self.passives] - await asyncio.gather(*awaitables) - - # Message handling - def add_hook(self, hook: slack_util.AbsHook) -> None: - self.hooks.append(hook) - - async def respond_messages(self) -> None: - """ - Asynchronous tasks that eternally reads and responds to messages. - """ - async for t in self.spool_tasks(): - sys.stdout.flush() - if DEBUG_MODE: - await t - - async def spool_tasks(self) -> AsyncGenerator[asyncio.Task, Any]: - async for msg in self.async_message_feed(): - # Preprocess msg - # We only care about standard messages, not subtypes, as those usually just channel activity - if msg.get("subtype") is not None: - continue - - # Never deal with general, EVER! - if msg.get("channel") == channel_util.GENERAL: - continue - - # Strip garbage - msg['text'] = msg['text'].strip() - print("Recv: \"{}\"".format(msg['text'])) - print(msg) - - # Msg is good - # Find which hook, if any, satisfies - for hook in self.hooks: - # Try invoking each - try: - # Try to make a coroutine handling the message - coro = hook.try_apply(self.slack, msg) - - # If we get a coro back, then task it up and set consumption appropriately - if coro is not None: - print("Spawned task") - yield asyncio.create_task(_loud_mouth(coro)) - if hook.consumes: - break - - except slack_util.DeadHook: - # If a hook wants to die, let it. - self.hooks.remove(hook) - print("Done spawning tasks. Now {} running total.".format(len(asyncio.all_tasks()))) - - async def async_message_feed(self) -> AsyncGenerator[dict, None]: - """ - Async wrapper around the message feed. - Yields messages awaitably forever. - """ - # Create the msg feed - feed = slack_util.message_stream(self.slack) - - # Create a simple callable that gets one message from the feed - def get_one(): - return next(feed) - - # Continuously yield async threaded tasks that poll the feed - while True: - yield await asyncio.get_running_loop().run_in_executor(None, get_one) - - -_singleton = ClientWrapper() - - -def grab() -> ClientWrapper: - return _singleton diff --git a/identifier.py b/identifier.py index ad2c6b3..050fa53 100644 --- a/identifier.py +++ b/identifier.py @@ -1,17 +1,16 @@ """ Allows users to register their user account as a specific scroll """ - +import asyncio import shelve from typing import List, Match -from slackclient import SlackClient - import scroll_util import slack_util # The following db maps SLACK_USER_ID -> SCROLL_INTEGER DB_NAME = "user_scrolls" +DB_LOCK = asyncio.Lock() # Initialize the hooks NON_REG_MSG = ("You currently have no scroll registered. To register, type\n" @@ -19,92 +18,86 @@ NON_REG_MSG = ("You currently have no scroll registered. To register, type\n" "except with your scroll instead of 666") -async def identify_callback(slack, msg, match): +async def identify_callback(event: slack_util.Event, match: Match): """ Sets the users scroll """ - with shelve.open(DB_NAME) as db: - # Get the query - query = match.group(1).strip() + async with DB_LOCK: + with shelve.open(DB_NAME) as db: + # Get the query + query = match.group(1).strip() - try: - user = msg.get("user") - scroll = int(query) - db[user] = scroll - result = "Updated user {} to have scroll {}".format(user, scroll) - except ValueError: - result = "Bad scroll: {}".format(query) + try: + user = event.user.user_id + scroll = int(query) + db[user] = scroll + result = "Updated user {} to have scroll {}".format(user, scroll) + except ValueError: + result = "Bad scroll: {}".format(query) - # Respond - slack_util.reply(slack, msg, result) + # Respond + slack_util.get_slack().reply(event, result) -async def identify_other_callback(slack: SlackClient, msg: dict, match: Match): +async def identify_other_callback(event: slack_util.Event, match: Match): """ Sets another users scroll """ - with shelve.open(DB_NAME) as db: - # Get the query - user = match.group(1).strip() - scroll_txt = match.group(2).strip() + async with DB_LOCK: + with shelve.open(DB_NAME) as db: + # Get the query + user = match.group(1).strip() + scroll_txt = match.group(2).strip() - try: - scroll = int(scroll_txt) - if user in db: - result = "To prevent trolling, once a users id has been set only they can change it" - else: - db[user] = scroll - result = "Updated user {} to have scroll {}".format(user, scroll) - except ValueError: - result = "Bad scroll: {}".format(scroll_txt) + try: + scroll = int(scroll_txt) + if user in db: + result = "To prevent trolling, once a users id has been set only they can change it" + else: + db[user] = scroll + result = "Updated user {} to have scroll {}".format(user, scroll) + except ValueError: + result = "Bad scroll: {}".format(scroll_txt) - # Respond - slack_util.reply(slack, msg, result) + # Respond + slack_util.get_slack().reply(event, result) # noinspection PyUnusedLocal -async def check_callback(slack: SlackClient, msg: dict, match: Match): +async def check_callback(event: slack_util.Event, match: Match): """ Replies with the users current scroll assignment """ - # Tells the user their current scroll - with shelve.open(DB_NAME) as db: - try: - scroll = db[msg.get("user")] - result = "You are currently registered with scroll {}".format(scroll) - except KeyError: - result = NON_REG_MSG - slack_util.reply(slack, msg, result) + async with DB_LOCK: + # Tells the user their current scroll + with shelve.open(DB_NAME) as db: + try: + scroll = db[event.user.user_id] + result = "You are currently registered with scroll {}".format(scroll) + except KeyError: + result = NON_REG_MSG + slack_util.get_slack().reply(event, result) # noinspection PyUnusedLocal -async def name_callback(slack, msg, match): +async def name_callback(event: slack_util.Event, match: Match): """ Tells the user what it thinks the calling users name is. """ - with shelve.open(DB_NAME) as db: - try: - scroll = db[msg.get("user")] - brother = scroll_util.find_by_scroll(scroll) - if brother: - result = "The bot thinks your name is {}".format(brother.name) - else: - result = "The bot couldn't find a name for scroll {}".format(scroll) - except (KeyError, ValueError): - result = NON_REG_MSG + async with DB_LOCK: + with shelve.open(DB_NAME) as db: + try: + scroll = db[event.user.user_id] + brother = scroll_util.find_by_scroll(scroll) + if brother: + result = "The bot thinks your name is {}".format(brother.name) + else: + result = "The bot couldn't find a name for scroll {}".format(scroll) + except (KeyError, ValueError): + result = NON_REG_MSG - # Respond - slack_util.reply(slack, msg, result) - - -async def lookup_msg_brother(msg: dict) -> scroll_util.Brother: - """ - Finds the real-world name of whoever posted msg. - Utilizes their bound-scroll. - :raises BrotherNotFound: - :return: brother dict or None - """ - return await lookup_slackid_brother(msg.get("user")) + # Respond + slack_util.get_slack().reply(event, result) async def lookup_slackid_brother(slack_id: str) -> scroll_util.Brother: @@ -113,29 +106,31 @@ async def lookup_slackid_brother(slack_id: str) -> scroll_util.Brother: :raises BrotherNotFound: :return: Brother object or None """ - with shelve.open(DB_NAME) as db: - try: - scroll = db[slack_id] - return scroll_util.find_by_scroll(scroll) - except (KeyError, ValueError): - raise scroll_util.BrotherNotFound("Slack id {} not tied to brother".format(slack_id)) + async with DB_LOCK: + with shelve.open(DB_NAME) as db: + try: + scroll = db[slack_id] + return scroll_util.find_by_scroll(scroll) + except (KeyError, ValueError): + raise scroll_util.BrotherNotFound("Slack id {} not tied to brother".format(slack_id)) -def lookup_brother_userids(brother: scroll_util.Brother) -> List[str]: +async def lookup_brother_userids(brother: scroll_util.Brother) -> List[str]: """ Returns a list of all userids associated with the given brother. :param brother: Brother to lookup scrolls for :return: List of user id strings (may be empty) """ - with shelve.open(DB_NAME) as db: - keys = db.keys() - result = [] - for user_id in keys: - if db[user_id] == brother.scroll: - result.append(user_id) + async with DB_LOCK: + with shelve.open(DB_NAME) as db: + keys = db.keys() + result = [] + for user_id in keys: + if db[user_id] == brother.scroll: + result.append(user_id) - return result + return result identify_hook = slack_util.ChannelHook(identify_callback, patterns=r"my scroll is (.*)") diff --git a/job_commands.py b/job_commands.py index 5b38d15..4e39256 100644 --- a/job_commands.py +++ b/job_commands.py @@ -4,8 +4,6 @@ from typing import List, Match, Callable, TypeVar, Optional, Iterable from fuzzywuzzy import fuzz from slackclient import SlackClient -import channel_util -import client_wrapper import house_management import identifier import scroll_util @@ -16,14 +14,14 @@ SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM" MIN_RATIO = 80.0 -def alert_user(slack: SlackClient, brother: scroll_util.Brother, saywhat: str) -> None: +async def alert_user(brother: scroll_util.Brother, saywhat: str) -> None: """ DM a brother saying something. Wrapper around several simpler methods """ # We do this as a for loop just in case multiple people reg. to same scroll for some reason (e.g. dup accounts) succ = False - for slack_id in identifier.lookup_brother_userids(brother): - slack_util.send_message(slack, saywhat, slack_id) + for slack_id in await identifier.lookup_brother_userids(brother): + slack_util.get_slack().send_message(saywhat, slack_id) succ = True # Warn if we never find @@ -31,6 +29,7 @@ def alert_user(slack: SlackClient, brother: scroll_util.Brother, saywhat: str) - print("Warning: unable to find dm for brother {}".format(brother)) +# Generic type T = TypeVar("T") @@ -60,8 +59,7 @@ class _ModJobContext: assign: house_management.JobAssignment # The job assignment to modify -async def _mod_jobs(slack: SlackClient, - msg: dict, +async def _mod_jobs(event: slack_util.Event, relevance_scorer: Callable[[house_management.JobAssignment], Optional[float]], modifier: Callable[[_ModJobContext], None], no_job_msg: str = None @@ -72,10 +70,10 @@ async def _mod_jobs(slack: SlackClient, :param modifier: Callback function to modify a job. Only called on a successful operation, and only on one job """ # Make an error wrapper - verb = slack_util.VerboseWrapper(slack, msg) + verb = slack_util.VerboseWrapper(event) # Who invoked this command? - signer = await verb(identifier.lookup_msg_brother(msg)) + signer = await verb(event.user.as_user().get_brother()) # Get all of the assignments assigns = await verb(house_management.import_assignments()) @@ -115,7 +113,7 @@ async def _mod_jobs(slack: SlackClient, if len(closest_assigns) == 0: if no_job_msg is None: no_job_msg = "Unable to find any jobs to apply this command to. Try again with better spelling or whatever." - slack_util.reply(slack, msg, no_job_msg) + slack_util.get_slack().reply(event, no_job_msg) # If theres only one job, sign it off elif len(closest_assigns) == 1: @@ -125,15 +123,15 @@ async def _mod_jobs(slack: SlackClient, else: # Say we need more info job_list = "\n".join("{}: {}".format(i, a.job.pretty_fmt()) for i, a in enumerate(closest_assigns)) - slack_util.reply(slack, msg, "Multiple relevant job listings found.\n" - "Please enter the number corresponding to the job " - "you wish to modify:\n{}".format(job_list)) + slack_util.get_slack().reply(event, "Multiple relevant job listings found.\n" + "Please enter the number corresponding to the job " + "you wish to modify:\n{}".format(job_list)) # Establish a follow up command pattern pattern = r"\d+" # Make the follow up callback - async def foc(_slack: SlackClient, _msg: dict, _match: Match) -> None: + async def foc(_event: slack_util.Event, _match: Match) -> None: # Get the number out index = int(_match.group(0)) @@ -143,17 +141,17 @@ async def _mod_jobs(slack: SlackClient, await success_callback(closest_assigns[index]) else: # They gave a bad index, or we were unable to find the assignment again. - slack_util.reply(_slack, _msg, "Invalid job index / job unable to be found.") + slack_util.get_slack().reply(_event, "Invalid job index / job unable to be found.") # Make a listener hook - new_hook = slack_util.ReplyWaiter(foc, pattern, msg["ts"], 120) + new_hook = slack_util.ReplyWaiter(foc, pattern, event.message.ts, 120) # Register it - client_wrapper.grab().add_hook(new_hook) + slack_util.get_slack().add_hook(new_hook) -async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None: - verb = slack_util.VerboseWrapper(slack, msg) +async def signoff_callback(event: slack_util.Event, match: Match) -> None: + verb = slack_util.VerboseWrapper(event) # Find out who we are trying to sign off is signee_name = match.group(1) @@ -171,17 +169,17 @@ async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None: context.assign.signer = context.signer # Say we did it wooo! - slack_util.reply(slack, msg, "Signed off {} for {}".format(context.assign.assignee.name, - context.assign.job.name)) - alert_user(slack, context.assign.assignee, "{} signed you off for {}.".format(context.assign.signer.name, - context.assign.job.pretty_fmt())) + slack_util.get_slack().reply(event, "Signed off {} for {}".format(context.assign.assignee.name, + context.assign.job.name)) + alert_user(context.assign.assignee, "{} signed you off for {}.".format(context.assign.signer.name, + context.assign.job.pretty_fmt())) # Fire it off - await _mod_jobs(slack, msg, scorer, modifier) + await _mod_jobs(event, scorer, modifier) -async def undo_callback(slack: SlackClient, msg: dict, match: Match) -> None: - verb = slack_util.VerboseWrapper(slack, msg) +async def undo_callback(event: slack_util.Event, match: Match) -> None: + verb = slack_util.VerboseWrapper(event) # Find out who we are trying to sign off is signee_name = match.group(1) @@ -199,18 +197,18 @@ async def undo_callback(slack: SlackClient, msg: dict, match: Match) -> None: context.assign.signer = None # Say we did it wooo! - slack_util.reply(slack, msg, "Undid signoff of {} for {}".format(context.assign.assignee.name, - context.assign.job.name)) - alert_user(slack, context.assign.assignee, "{} undid your signoff off for {}.\n" - "Must have been a mistake".format(context.assign.signer.name, - context.assign.job.pretty_fmt())) + slack_util.get_slack().reply(event, "Undid signoff of {} for {}".format(context.assign.assignee.name, + context.assign.job.name)) + alert_user(context.assign.assignee, "{} undid your signoff off for {}.\n" + "Must have been a mistake".format(context.assign.signer.name, + context.assign.job.pretty_fmt())) # Fire it off - await _mod_jobs(slack, msg, scorer, modifier) + await _mod_jobs(event, scorer, modifier) -async def late_callback(slack: SlackClient, msg: dict, match: Match) -> None: - verb = slack_util.VerboseWrapper(slack, msg) +async def late_callback(event: slack_util.Event, match: Match) -> None: + verb = slack_util.VerboseWrapper(event) # Find out who we are trying to sign off is signee_name = match.group(1) @@ -228,16 +226,16 @@ async def late_callback(slack: SlackClient, msg: dict, match: Match) -> None: context.assign.late = not context.assign.late # Say we did it - slack_util.reply(slack, msg, "Toggled lateness of {}.\n" + slack_util.get_slack().reply(event, "Toggled lateness of {}.\n" "Now marked as late: {}".format(context.assign.job.pretty_fmt(), context.assign.late)) # Fire it off - await _mod_jobs(slack, msg, scorer, modifier) + await _mod_jobs(event, scorer, modifier) -async def reassign_callback(slack: SlackClient, msg: dict, match: Match) -> None: - verb = slack_util.VerboseWrapper(slack, msg) +async def reassign_callback(event: slack_util.Event, match: Match) -> None: + verb = slack_util.VerboseWrapper(event) # Find out our two targets from_name = match.group(1).strip() @@ -263,21 +261,21 @@ async def reassign_callback(slack: SlackClient, msg: dict, match: Match) -> None reassign_msg = "Job {} reassigned from {} to {}".format(context.assign.job.pretty_fmt(), from_bro, to_bro) - slack_util.reply(slack, msg, reassign_msg) + slack_util.get_slack().reply(event, reassign_msg) # Tell the people reassign_msg = "Job {} reassigned from {} to {}".format(context.assign.job.pretty_fmt(), from_bro, to_bro) - alert_user(slack, from_bro, reassign_msg) - alert_user(slack, to_bro, reassign_msg) + alert_user(from_bro, reassign_msg) + alert_user(to_bro, reassign_msg) # Fire it off - await _mod_jobs(slack, msg, scorer, modifier) + await _mod_jobs(event, scorer, modifier) # noinspection PyUnusedLocal -async def reset_callback(slack: SlackClient, msg: dict, match: Match) -> None: +async def reset_callback(event: slack_util.Event, match: Match) -> None: """ Resets the scores. """ @@ -299,18 +297,18 @@ async def reset_callback(slack: SlackClient, msg: dict, match: Match) -> None: house_management.apply_house_points(points, await house_management.import_assignments()) house_management.export_points(headers, points) - slack_util.reply(slack, msg, "Reset scores and signoffs") + slack_util.get_slack().reply(event, "Reset scores and signoffs") # noinspection PyUnusedLocal -async def refresh_callback(slack: SlackClient, msg: dict, match: Match) -> None: +async def refresh_callback(event: slack_util.Event, match: Match) -> None: headers, points = await house_management.import_points() house_management.apply_house_points(points, await house_management.import_assignments()) house_management.export_points(headers, points) - slack_util.reply(slack, msg, "Force updated point values") + slack_util.get_slack().reply(event, "Force updated point values") -async def nag_callback(slack, msg, match): +async def nag_callback(event: slack_util.Event, match: Match) -> None: # Get the day day = match.group(1).lower().strip() @@ -325,7 +323,7 @@ async def nag_callback(slack, msg, match): # If no jobs found, somethings up. Probably mispelled day. if not assigns: - slack_util.reply(slack, msg, "No jobs found. Check that the day is spelled correctly, with no extra symbols.\n" + slack_util.get_slack().reply(event, "No jobs found. Check that the day is spelled correctly, with no extra symbols.\n" "It is possible that all jobs have been signed off, as well.", in_thread=True) return @@ -339,7 +337,7 @@ async def nag_callback(slack, msg, match): response += "({}) {} -- {} ".format(assign.job.house, assign.job.name, assign.assignee.name) # Find the people to @ - brother_slack_ids = identifier.lookup_brother_userids(assign.assignee) + brother_slack_ids = await identifier.lookup_brother_userids(assign.assignee) if brother_slack_ids: for slack_id in brother_slack_ids: @@ -348,53 +346,53 @@ async def nag_callback(slack, msg, match): response += "(scroll missing. Please register for @ pings!)" response += "\n" - slack_util.reply(slack, msg, response, in_thread=False, to_channel=channel_util.GENERAL) + general_id = slack_util.get_slack().get_channel_by_name("#general").id + slack_util.get_slack().reply(event, response, in_thread=False, to_channel=general_id) signoff_hook = slack_util.ChannelHook(signoff_callback, patterns=[ - r"signoff\s+(.*)", - r"sign off\s+(.*)", - ], - channel_whitelist=[channel_util.HOUSEJOBS]) + r"signoff\s+(.*)", + r"sign off\s+(.*)", + ], + channel_whitelist=["#housejobs"]) undo_hook = slack_util.ChannelHook(undo_callback, patterns=[ - r"unsignoff\s+(.*)", - r"undosignoff\s+(.*)", - r"undo signoff\s+(.*)", - ], - channel_whitelist=[channel_util.HOUSEJOBS]) + r"unsignoff\s+(.*)", + r"undosignoff\s+(.*)", + r"undo signoff\s+(.*)", + ], + channel_whitelist=["#housejobs"]) late_hook = slack_util.ChannelHook(late_callback, patterns=[ - r"marklate\s+(.*)", - r"mark late\s+(.*)", - ], - channel_whitelist=[channel_util.HOUSEJOBS]) + r"marklate\s+(.*)", + r"mark late\s+(.*)", + ], + channel_whitelist=["#housejobs"]) reset_hook = slack_util.ChannelHook(reset_callback, patterns=[ - r"reset signoffs", - r"reset sign offs", - ], - channel_whitelist=[channel_util.COMMAND_CENTER_ID]) + r"reset signoffs", + r"reset sign offs", + ], + channel_whitelist=["#command-center"]) nag_hook = slack_util.ChannelHook(nag_callback, patterns=[ - r"nagjobs\s+(.*)", - r"nag jobs\s+(.*)" - ], - channel_whitelist=[channel_util.COMMAND_CENTER_ID]) + r"nagjobs\s+(.*)", + r"nag jobs\s+(.*)" + ], + channel_whitelist=["#command-center"]) reassign_hook = slack_util.ChannelHook(reassign_callback, patterns=r"reassign\s+(.*?)->\s+(.+)", - channel_whitelist=[channel_util.HOUSEJOBS]) + channel_whitelist=["#housejobs"]) refresh_hook = slack_util.ChannelHook(refresh_callback, patterns=[ - "refresh points", - "update points" - ], - channel_whitelist=[channel_util.COMMAND_CENTER_ID] - ) + "refresh points", + "update points" + ], + channel_whitelist=["#command-center"]) diff --git a/main.py b/main.py index de141f9..c25b960 100644 --- a/main.py +++ b/main.py @@ -2,10 +2,6 @@ import asyncio import textwrap from typing import Match -from slackclient import SlackClient - -import channel_util -import client_wrapper import identifier import job_commands import management_commands @@ -16,7 +12,7 @@ import slavestothemachine def main() -> None: - wrap = client_wrapper.grab() + wrap = slack_util.get_slack() # Add scroll handling wrap.add_hook(scroll_util.scroll_hook) @@ -27,9 +23,6 @@ def main() -> None: wrap.add_hook(identifier.identify_other_hook) wrap.add_hook(identifier.name_hook) - # Added channel utility - wrap.add_hook(channel_util.channel_check_hook) - # Add kill switch wrap.add_hook(management_commands.reboot_hook) @@ -55,7 +48,7 @@ def main() -> None: wrap.add_passive(periodicals.RemindJobs()) event_loop = asyncio.get_event_loop() - event_loop.set_debug(client_wrapper.DEBUG_MODE) + event_loop.set_debug(slack_util.DEBUG_MODE) message_handling = wrap.respond_messages() passive_handling = wrap.run_passives() both = asyncio.gather(message_handling, passive_handling) @@ -63,8 +56,8 @@ def main() -> None: # noinspection PyUnusedLocal -async def help_callback(slack: SlackClient, msg: dict, match: Match) -> None: - slack_util.reply(slack, msg, textwrap.dedent(""" +async def help_callback(event: slack_util.Event, match: Match) -> None: + slack_util.get_slack().reply(event, textwrap.dedent(""" Commands are as follows. Note that some only work in certain channels. "my scroll is number" : Registers your slack account to have a certain scroll, for the purpose of automatic dm's. "@person has scroll number" : same as above, but for other users. Helpful if they are being obstinate. diff --git a/management_commands.py b/management_commands.py index 5f8c0f5..1077594 100644 --- a/management_commands.py +++ b/management_commands.py @@ -1,28 +1,25 @@ from typing import Match, List -from slackclient import SlackClient - -import channel_util import slack_util def list_hooks_callback_gen(hooks: List[slack_util.ChannelHook]) -> slack_util.Callback: # noinspection PyUnusedLocal - async def callback(slack, msg, match): - slack_util.reply(slack, msg, "\n".join(hook.patterns for hook in hooks)) + async def callback(event: slack_util.Event, match: Match) -> None: + slack_util.get_slack().reply(event, "\n".join(hook.patterns for hook in hooks)) return callback # Gracefully reboot to reload code changes # noinspection PyUnusedLocal -async def reboot_callback(slack: SlackClient, msg: dict, match: Match) -> None: +async def reboot_callback(event: slack_util.Event, match: Match) -> None: response = "Ok. Rebooting..." - slack_util.reply(slack, msg, response) + slack_util.get_slack().reply(event, response) exit(0) # Make hooks reboot_hook = slack_util.ChannelHook(reboot_callback, patterns=r"reboot", - channel_whitelist=[channel_util.COMMAND_CENTER_ID]) + channel_whitelist=["#command-center"]) diff --git a/periodicals.py b/periodicals.py index e365b9d..35098b5 100644 --- a/periodicals.py +++ b/periodicals.py @@ -2,9 +2,6 @@ import asyncio from datetime import datetime from typing import Optional, List -from slackclient import SlackClient - -import channel_util import house_management import identifier import slack_util @@ -17,7 +14,7 @@ def seconds_until(target: datetime) -> float: class ItsTenPM(slack_util.Passive): - async def run(self, slack: SlackClient) -> None: + async def run(self) -> None: while True: # Get 10PM ten_pm = datetime.now().replace(hour=22, minute=0, second=0) @@ -27,14 +24,14 @@ class ItsTenPM(slack_util.Passive): await asyncio.sleep(delay) # Crow like a rooster - slack_util.send_message(slack, "IT'S 10 PM!", channel_util.RANDOM) + slack_util.get_slack().send_message("IT'S 10 PM!", slack_util.get_slack().get_channel_by_name("#random").id) # Wait a while before trying it again, to prevent duplicates await asyncio.sleep(60) class RemindJobs(slack_util.Passive): - async def run(self, slack: SlackClient) -> None: + async def run(self) -> None: while True: # Get the end of the current day (Say, 10PM) today_remind_time = datetime.now().replace(hour=22, minute=00, second=0) @@ -80,14 +77,14 @@ class RemindJobs(slack_util.Passive): print("Nagging!") for a in assigns: # Get the relevant slack ids - assignee_ids = identifier.lookup_brother_userids(a.assignee) + assignee_ids = await identifier.lookup_brother_userids(a.assignee) # For each, send them a DM success = False for slack_id in assignee_ids: msg = "{}, you still need to do {}".format(a.assignee.name, a.job.pretty_fmt()) success = True - slack_util.send_message(slack, msg, slack_id) + slack_util.get_slack().send_message(msg, slack_id) # Warn on failure if not success: diff --git a/scroll_util.py b/scroll_util.py index 19d5c07..4acb296 100644 --- a/scroll_util.py +++ b/scroll_util.py @@ -8,7 +8,6 @@ from dataclasses import dataclass from typing import List, Optional, Match from fuzzywuzzy import process -from slackclient import SlackClient import slack_util @@ -38,7 +37,7 @@ brothers_matches = [m for m in brothers_matches if m] brothers: List[Brother] = [Brother(m.group(2), int(m.group(1))) for m in brothers_matches] -async def scroll_callback(slack: SlackClient, msg: dict, match: Match) -> None: +async def scroll_callback(event: slack_util.Event, match: Match) -> None: """ Finds the scroll of a brother, or the brother of a scroll, based on msg text. """ @@ -57,7 +56,7 @@ async def scroll_callback(slack: SlackClient, msg: dict, match: Match) -> None: result = "Couldn't find brother {}".format(query) # Respond - slack_util.reply(slack, msg, result) + slack_util.get_slack().reply(event, result) def find_by_scroll(scroll: int) -> Optional[Brother]: diff --git a/slack_util.py b/slack_util.py index ee1d0a6..d206b9c 100644 --- a/slack_util.py +++ b/slack_util.py @@ -1,71 +1,61 @@ from __future__ import annotations + +import asyncio import re +import sys +import traceback from dataclasses import dataclass from time import sleep, time -from typing import Any, Optional, Generator, Match, Callable, List, Coroutine, Union, TypeVar, Awaitable +from typing import List, Any, AsyncGenerator, Coroutine, TypeVar +from typing import Optional, Generator, Match, Callable, Union, Awaitable from slackclient import SlackClient from slackclient.client import SlackNotConnected +# Enable to do single-threaded and have better exceptions +import identifier +import scroll_util + +DEBUG_MODE = False + """ -Slack helpers. Separated for compartmentalization +Objects to represent things within a slack workspace """ -def reply(msg: dict, text: str, in_thread: bool = True, to_channel: str = None) -> dict: - """ - Sends message with "text" as its content to the channel that message came from. - Returns the JSON response. - """ - # If no channel specified, just do same as msg - if to_channel is None: - to_channel = msg['channel'] - - # Send in a thread by default - if in_thread: - thread = (msg.get("thread_ts") # In-thread case - get parent ts - or msg.get("ts")) # Not in-thread case - get msg itself ts - return send_message(slack, text, to_channel, thread=thread) - else: - return send_message(slack, text, to_channel) - - -def send_message(text: str, channel: str, thread: str = None, broadcast: bool = False) -> dict: - """ - Copy of the internal send message function of slack, with some helpful options. - Returns the JSON response. - """ - kwargs = {"channel": channel, "text": text} - if thread: - kwargs["thread_ts"] = thread - if broadcast: - kwargs["reply_broadcast"] = True - - return slack.api_call("chat.postMessage", **kwargs) - - -""" -Objects to represent things -""" +@dataclass class User: - pass + id: str + name: str + real_name: str + email: Optional[str] + async def get_brother(self) -> Optional[scroll_util.Brother]: + """ + Try to find the brother corresponding to this user. + """ + return await identifier.lookup_slackid_brother(self.id) + + +@dataclass class Channel: - @property - def channel_name(self) -> str: - raise NotImplementedError() - - + id: str + name: str + purpose: str + members: List[User] """ -Below we have a modular system that represents possible event contents. +Objects to represent attributes an event may contain """ + + @dataclass class Event: - channel: Optional[ChannelContext] - user: Optional[UserContext] - message: Optional[Message] + channel: Optional[ChannelContext] = None + user: Optional[UserContext] = None + message: Optional[MessageContext] = None + thread: Optional[ThreadContext] = None # If this was posted in a specific channel or conversation @@ -73,6 +63,9 @@ class Event: class ChannelContext: channel_id: str + def get_channel(self) -> Channel: + raise NotImplementedError() + # If there is a specific user associated with this event @dataclass @@ -85,16 +78,28 @@ class UserContext: # Whether or not this is a threadable text message @dataclass -class Message: +class MessageContext: ts: str text: str +@dataclass +class ThreadContext: + thread_ts: str + parent_ts: str + + +# If a file was additionally shared @dataclass class File: pass +""" +Objects for interfacing easily with rtm steams +""" + + def message_stream(slack: SlackClient) -> Generator[Event, None, None]: """ Generator that yields messages from slack. @@ -104,50 +109,199 @@ def message_stream(slack: SlackClient) -> Generator[Event, None, None]: # Do forever while True: try: - if slack.rtm_connect(with_team_state=False, auto_reconnect=True): + if slack.rtm_connect(with_team_state=True, auto_reconnect=True): print("Waiting for messages") while True: sleep(0.1) - update = slack.rtm_read() - for item in update: - if item.get('type') == 'message': - yield item + update_list = slack.rtm_read() + + # Handle each + for update in update_list: + print("Message received: {}".format(update)) + event = Event() + + # Big logic folks + if update["type"] == "message": + event.message = MessageContext(update["ts"], update["text"]) + event.channel = ChannelContext(update["channel"]) + event.user = UserContext(update["user"]) + + # TODO: Handle more types + # We need to + + yield event + except (SlackNotConnected, OSError) as e: print("Error while reading messages:") print(e) - except (ValueError, TypeError): + except (ValueError, TypeError) as e: print("Malformed message... Restarting connection") + print(e) sleep(5) print("Connection failed - retrying") -T = TypeVar("T") +""" +Objects to wrap slack connections +""" +# Read the API token +api_file = open("apitoken.txt", 'r') +SLACK_API = next(api_file).strip() +api_file.close() -class VerboseWrapper(Callable): +class ClientWrapper(object): """ - Generates exception-ready delegates. - Warns of exceptions as they are passed through it, via responding to the given message. + Essentially the main state object. + We only ever expect one of these per api token. + Holds a slack client, and handles messsages. """ - def __init__(self, slack: SlackClient, command_msg: dict): - self.slack = slack - self.command_msg = command_msg - async def __call__(self, awt: Awaitable[T]) -> T: - try: - return await awt - except Exception as e: - reply(self.command_msg, "Error: {}".format(str(e)), True) - raise e + def __init__(self, api_token): + # Init slack + self.slack = SlackClient(api_token) + + # Hooks go regex -> callback on (slack, msg, match) + self.hooks: List[AbsHook] = [] + + # Periodicals are just wrappers around an iterable, basically + self.passives: List[Passive] = [] + + # Cache users and channels + self.users: dict = {} + self.channels: dict = {} + + # Scheduled events handling + def add_passive(self, per: Passive) -> None: + self.passives.append(per) + + async def run_passives(self) -> None: + """ + Run all currently added passives + """ + awaitables = [p.run() for p in self.passives] + await asyncio.gather(*awaitables) + + # Message handling + def add_hook(self, hook: AbsHook) -> None: + self.hooks.append(hook) + + async def respond_messages(self) -> None: + """ + Asynchronous tasks that eternally reads and responds to messages. + """ + async for t in self.spool_tasks(): + sys.stdout.flush() + if DEBUG_MODE: + await t + + async def spool_tasks(self) -> AsyncGenerator[asyncio.Task, Any]: + async for event in self.async_event_feed(): + # Find which hook, if any, satisfies + for hook in list(self.hooks): # Note that we do list(self.hooks) to avoid edit-while-iterating issues + # Try invoking each + try: + # Try to make a coroutine handling the message + coro = hook.try_apply(event) + + # If we get a coro back, then task it up and set consumption appropriately + if coro is not None: + print("Spawned task") + yield asyncio.create_task(_exception_printing_task(coro)) + if hook.consumes: + break + + except DeadHook: + # If a hook wants to die, let it. + self.hooks.remove(hook) + print("Done spawning tasks. Now {} running total.".format(len(asyncio.all_tasks()))) + + async def async_event_feed(self) -> AsyncGenerator[Event, None]: + """ + Async wrapper around the message feed. + Yields messages awaitably forever. + """ + # Create the msg feed + feed = message_stream(self.slack) + + # Create a simple callable that gets one message from the feed + def get_one(): + return next(feed) + + # Continuously yield async threaded tasks that poll the feed + while True: + yield await asyncio.get_running_loop().run_in_executor(None, get_one) + + def get_channel(self, channel_id: str) -> Optional[Channel]: + return self.channels.get(channel_id) + + def get_channel_by_name(self, channel_name: str) -> Optional[Channel]: + # Find the channel in the dict + for v in self.channels.values(): + if v.name == channel_name: + return v + return None + + def get_user(self, user_id: str) -> Optional[Channel]: + return self.users.get(user_id) + + def api_call(self, api_method, **kwargs): + return self.slack.api_call(api_method, **kwargs) + + def reply(self, event: Event, text: str, in_thread: bool = True) -> dict: + """ + Replies to a message. + Message must have a channel and message context. + Returns the JSON response. + """ + # Ensure we're actually replying to a valid message + assert (event.channel and event.message) is not None + + # Send in a thread by default + if in_thread: + # Figure otu what thread to send it to + thread = event.message.ts + if event.thread: + thread = event.thread.thread_ts + return self.send_message(text, event.channel.channel_id, thread=thread) + else: + return self.send_message(text, event.channel.channel_id) + + def send_message(self, text: str, channel_id: str, thread: str = None, broadcast: bool = False) -> dict: + """ + Copy of the internal send message function of slack, with some helpful options. + Returns the JSON response. + """ + kwargs = {"channel": channel_id, "text": text} + if thread: + kwargs["thread_ts"] = thread + if broadcast: + kwargs["reply_broadcast"] = True + + return self.api_call("chat.postMessage", **kwargs) -# The result of a message +# Create a single instance of the client wrapper +_singleton = ClientWrapper(SLACK_API) + + +def get_slack() -> ClientWrapper: + return _singleton + + +# Return type of an event callback MsgAction = Coroutine[Any, Any, None] -# The function called on a message -Callback = Callable[[SlackClient, Message, Match], MsgAction] + +# Type signature of an event callback function +Callback = Callable[[Event, Match], MsgAction] + +""" +Hooks +""" +# Signal exception to be raised when a hook has died class DeadHook(Exception): pass @@ -166,6 +320,7 @@ class ChannelHook(AbsHook): """ Hook that handles messages in a variety of channels """ + def __init__(self, callback: Callback, patterns: Union[str, List[str]], @@ -185,36 +340,42 @@ class ChannelHook(AbsHook): # Remedy some sensible defaults if self.channel_blacklist is None: - import channel_util - self.channel_blacklist = [channel_util.GENERAL] + self.channel_blacklist = ["#general"] elif self.channel_whitelist is None: pass # We leave as none to show no whitelisting in effect else: raise ValueError("Cannot whitelist and blacklist") - def try_apply(self, event: Event) -> Optional[MsgAction]: + def try_apply(self, event: Event) -> Optional[MsgAction]: """ Returns whether a message should be handled by this dict, returning a Match if so, or None """ + # Ensure that this is an event in a specific channel, with a text component + if not (event.channel and event.message): + return None + # Fail if pattern invalid match = None for p in self.patterns: - match = re.match(p, msg['text'], flags=re.IGNORECASE) + match = re.match(p, event.message.text.strip(), flags=re.IGNORECASE) if match is not None: break if match is None: return None + # Get the channel name + channel_name = event.channel.get_channel().name + # Fail if whitelist defined, and we aren't there - if self.channel_whitelist is not None and msg["channel"] not in self.channel_whitelist: + if self.channel_whitelist is not None and channel_name not in self.channel_whitelist: return None # Fail if blacklist defined, and we are there - if self.channel_blacklist is not None and msg["channel"] in self.channel_blacklist: + if self.channel_blacklist is not None and channel_name in self.channel_blacklist: return None - return self.callback(slack, msg, match) + return self.callback(event, match) class ReplyWaiter(AbsHook): @@ -231,26 +392,29 @@ class ReplyWaiter(AbsHook): self.start_time = time() self.dead = False - def try_apply(self, event: Event) -> Optional[MsgAction]: + def try_apply(self, event: Event) -> Optional[MsgAction]: # First check: are we dead of age yet? time_alive = time() - self.start_time should_expire = time_alive > self.lifetime # If so, give up the ghost if self.dead or should_expire: - print("Reply waiter has expired after {} seconds".format(time_alive)) raise DeadHook() + # Next make sure we're actually a message + if not (event.message and event.thread): + return None + # Otherwise proceed normally # Is the msg the one we care about? If not, ignore - if msg.get("thread_ts", None) != self.thread_ts: + if event.thread.thread_ts != self.thread_ts: return None # Does it match the regex? if not, ignore - match = re.match(self.pattern, msg['text'], flags=re.IGNORECASE) + match = re.match(self.pattern, event.message.text.strip(), flags=re.IGNORECASE) if match: self.dead = True - return self.callback(slack, msg, match) + return self.callback(event, match) else: return None @@ -263,3 +427,44 @@ class Passive(object): async def run(self) -> None: # Run this passive routed through the specified slack client. raise NotImplementedError() + + +""" +Methods for easily responding to messages, etc. +""" + +T = TypeVar("T") + + +class VerboseWrapper(Callable): + """ + Generates exception-ready delegates. + Warns of exceptions as they are passed through it, via responding to the given message. + """ + + def __init__(self, event: Event): + self.event = event + + async def __call__(self, awt: Awaitable[T]) -> T: + try: + return await awt + except Exception as e: + get_slack().reply(self.event, "Error: {}".format(str(e)), True) + raise e + + +""" +Miscellania +""" + +A, B, C = TypeVar("A"), TypeVar("B"), TypeVar("C") + + +# Prints exceptions instead of silently dropping them in async tasks +async def _exception_printing_task(c: Coroutine[A, B, C]) -> Coroutine[A, B, C]: + # Print exceptions as they pass through + try: + return await c + except Exception: + traceback.print_exc() + raise diff --git a/slavestothemachine.py b/slavestothemachine.py index b3d0935..5034130 100644 --- a/slavestothemachine.py +++ b/slavestothemachine.py @@ -4,7 +4,6 @@ from typing import Match from slackclient import SlackClient -import channel_util import house_management import identifier import slack_util @@ -19,16 +18,16 @@ def fmt_work_dict(work_dict: dict) -> str: # noinspection PyUnusedLocal -async def count_work_callback(slack: SlackClient, msg: dict, match: Match) -> None: +async def count_work_callback(event: slack_util.Event, match: Match) -> None: # Make an error wrapper - verb = slack_util.VerboseWrapper(slack, msg) + verb = slack_util.VerboseWrapper(event) # Tidy the text - text = msg["text"].lower().strip() + text = event.message.text.strip() # Couple things to work through. # One: Who sent the message? - who_wrote = await verb(identifier.lookup_msg_brother(msg)) + who_wrote = await verb(event.user.as_user().get_brother()) who_wrote_label = "{} [{}]".format(who_wrote.name, who_wrote.scroll) # Two: What work did they do? @@ -42,9 +41,9 @@ async def count_work_callback(slack: SlackClient, msg: dict, match: Match) -> No # Three: check if we found anything if len(new_work) == 0: if re.search(r'\s\d\s', text) is not None: - slack_util.reply(slack, msg, - "If you were trying to record work, it was not recognized.\n" - "Use words {} or work will not be recorded".format(counted_data)) + slack_util.get_slack().reply(event, + "If you were trying to record work, it was not recognized.\n" + "Use words {} or work will not be recorded".format(counted_data)) return # Four: Knowing they did something, record to total work @@ -59,7 +58,7 @@ async def count_work_callback(slack: SlackClient, msg: dict, match: Match) -> No fmt_work_dict(new_work), contribution_count, new_total)) - slack_util.reply(slack, msg, congrats) + slack_util.get_slack().reply(event, congrats) async def record_towel_contribution(for_brother: Brother, contribution_count: int) -> int: @@ -91,5 +90,5 @@ async def record_towel_contribution(for_brother: Brother, contribution_count: in # Make dem HOOKs count_work_hook = slack_util.ChannelHook(count_work_callback, patterns=".*", - channel_whitelist=[channel_util.SLAVES_TO_THE_MACHINE_ID], + channel_whitelist=["#slavestothemachine"], consumer=False)