diff --git a/client_wrapper.py b/client_wrapper.py new file mode 100644 index 0000000..8abf957 --- /dev/null +++ b/client_wrapper.py @@ -0,0 +1,115 @@ +import asyncio +from typing import List, Any, AsyncGenerator + +from slackclient import SlackClient # Obvious + +import channel_util +import slack_util +from dummy import FakeClient + +# Read the API token +api_file = open("apitoken.txt", 'r') +SLACK_API = next(api_file).strip() +api_file.close() + +# Enable to use dummy +DEBUG_MODE = False + + +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 + if DEBUG_MODE: + self.slack = FakeClient() + else: + 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 _ in self.spool_tasks(): + print("Handling a message...!") + + 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'])) + + # 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(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") + + 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 get_client_wrapper() -> ClientWrapper: + return _singleton diff --git a/house_management.py b/house_management.py new file mode 100644 index 0000000..c99fe9d --- /dev/null +++ b/house_management.py @@ -0,0 +1,270 @@ +import dataclasses +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Tuple, List, Optional, Any + +import google_api +import scroll_util + +SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM" + +# Note: These ranges use named range feature of google sheets. +# To edit range of jobs, edit the named range in Data -> Named Ranges +job_range = "AllJobs" # Note that the first row is headers +point_range = "PointRange" + +# How tolerant of spelling errors in names to be +SHEET_LOOKUP_THRESHOLD = 0.9 + +JOB_VAL = 1 +LATE_VAL = 0.5 +MISS_VAL = -1 +SIGNOFF_VAL = 0.1 + +# What to put for a non-signed-off job +SIGNOFF_PLACEHOLDER = "E-SIGNOFF" + + +@dataclass +class Job(object): + """ + Represents a job in a more internally meaningful way. + """ + name: str + house: str + day_of_week: str + # Extra stuff, interpreted + day: Optional[date] + + +@dataclass +class JobAssignment(object): + """ + Tracks a job's assignment and completion + """ + job: Job + assignee: scroll_util.Brother + signer: Optional[scroll_util.Brother] + late: bool + + def to_raw(self) -> Tuple[str, str, str, str, str, str]: + # Converts this back into a spreadsheet row + signer_name = self.signer.name if self.signer is not None else SIGNOFF_PLACEHOLDER + late = "y" if self.late else "n" + return self.job.name, self.job.house, self.job.day_of_week, self.assignee.name, signer_name, late + + +@dataclass +class PointStatus(object): + """ + Tracks a brothers points + """ + brother_raw: str + brother: scroll_util.Brother # Interpereted + job_points: float = 0 + signoff_points: float = 0 + towel_points: float = 0 + work_party_points: float = 0 + bonus_points: float = 0 + + def to_raw(self) -> Tuple[str, str, str, str, str, str]: + # Convert to a row. Also, do some rounding while we're at it + fstring = "{:.2f}" + return (self.brother_raw, + fstring.format(self.job_points), + fstring.format(self.signoff_points), + fstring.format(self.towel_points), + fstring.format(self.work_party_points), + fstring.format(self.bonus_points), + ) + + +def strip_all(l: List[str]) -> List[str]: + return [x.strip() for x in l] + + +def import_assignments() -> List[Optional[JobAssignment]]: + """ + Imports Jobs and JobAssignments from the sheet. 1:1 row correspondence. + """ + # Get the raw data + job_rows = google_api.get_sheet_range(SHEET_ID, job_range) + + # None-out invalid rows (length not at least 4, which includes the 4 most important features) + def fixer(row): + if len(row) == 4: + return strip_all(row + [SIGNOFF_PLACEHOLDER, "n"]) + elif len(row) == 5: + return strip_all(row + "n") + elif len(row) == 6: + return strip_all(row) + else: + return None + + # Apply the fix + job_rows = [fixer(row) for row in job_rows] + + # Now, create jobs + assignments = [] + for row in job_rows: + if row is None: + assignments.append(None) + else: + # Breakout list + job_name, location, day, assignee, signer, late = row + + # Figure out when the day actually is, in terms of the date class + day_rank = { + "monday": 0, + "tuesday": 1, + "wednesday": 2, + "thursday": 3, + "friday": 4, + "saturday": 5, + "sunday": 6 + }.get(day.lower(), None) + + if day_rank is not None: + # Figure out current date day of week, and extrapolate the jobs day of week from there + today = date.today() + today_rank = today.weekday() + + days_till = day_rank - today_rank + if days_till <= 0: + days_till += 7 + + # Now we know what day it is! + job_day = today + timedelta(days=days_till) + else: + # Can't win 'em all + job_day = None + + # Create the job + job = Job(name=job_name, house=location, day_of_week=day, day=job_day) + + # Now make an assignment for the job + # Find the brother it is assigned to + try: + assignee = scroll_util.find_by_name(assignee, SHEET_LOOKUP_THRESHOLD) + except scroll_util.BadName: + # If we can't get one close enough, make a dummy + assignee = scroll_util.Brother(assignee, scroll_util.MISSINGBRO_SCROLL) + + # Find the brother who is currently listed as having signed it off + try: + if signer == SIGNOFF_PLACEHOLDER: + signer = None + else: + signer = scroll_util.find_by_name(signer) + except scroll_util.BadName: + # If we can't figure out the name + signer = None + + # Make late a bool + late = late == "y" + + # Create the assignment + assignment = JobAssignment(job=job, assignee=assignee, signer=signer, late=late) + + # Append to job/assignment lists + assignments.append(assignment) + + # Git 'em gone + return assignments + + +def export_assignments(assigns: List[Optional[JobAssignment]]) -> None: + # Smash to rows + rows = [] + for v in assigns: + if v is None: + rows.append([""] * 6) + else: + rows.append(list(v.to_raw())) + + # Send to google + google_api.set_sheet_range(SHEET_ID, job_range, rows) + + +def import_points() -> (List[str], List[PointStatus]): + # Figure out how many things there are in a point status + field_count = len(dataclasses.fields(PointStatus)) + + # Get the raw data + point_rows = google_api.get_sheet_range(SHEET_ID, point_range) + + # Get the headers + headers = point_rows[0] + point_rows = point_rows[1:] + + # Tidy rows up + def converter(row: List[Any]) -> Optional[PointStatus]: + # If its too long, or empty already, ignore + if len(row) == 0 or len(row) > field_count: + return None + + # Ensure its the proper length + row: List[Any] = row + ([0] * (field_count - len(row) - 1)) + + # Ensure all past the first column are float. If can't convert, make 0 + for i in range(1, len(row)): + try: + x = float(row[i]) + except ValueError: + x = 0 + row[i] = x + + # Get the brother for the last item + real_brother = scroll_util.find_by_name(row[0], recent_only=True) + + # Ok! Now, we just map it directly to a PointStatus + status = PointStatus(row[0], real_brother, *(row[1:])) + return status + + # Perform conversion and return + point_statuses = [converter(row) for row in point_rows] + return headers, point_statuses + + +def export_points(headers: List[str], points: List[PointStatus]) -> None: + # Smash to rows + rows = [list(point_status.to_raw()) for point_status in points] + rows = [headers] + rows + + # Send to google + google_api.set_sheet_range(SHEET_ID, point_range, rows) + + +def apply_house_points(points: List[PointStatus], assigns: List[Optional[JobAssignment]]): + """ + Modifies the points list to reflect job assignment scores. + Destroys existing values in the column + """ + # First, eliminate all house points + for p in points: + p.job_points = 0 + + # Then, apply each assign + for a in assigns: + # Ignore null assigns + if a is None: + continue + + # What modifier should this have? + if a.signer is None: + score = MISS_VAL + else: + if a.late: + score = LATE_VAL + else: + score = JOB_VAL + + # Find the corr bro in points + for p in points: + # If we find, add the score and stop looking + if p.brother == a.assignee: + p.job_points += score + break + + if p.brother == a.signer: + p.signoff_points += SIGNOFF_VAL diff --git a/job_commands.py b/job_commands.py new file mode 100644 index 0000000..043478c --- /dev/null +++ b/job_commands.py @@ -0,0 +1,203 @@ +from typing import List, Match, Callable, TypeVar, Optional, Iterable + +from fuzzywuzzy import fuzz +from slackclient import SlackClient + +import channel_util +import house_management +import identifier +import scroll_util +import slack_util + +SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM" + +MIN_RATIO = 0.9 + + +def alert_user(slack: SlackClient, brother: scroll_util.Brother, saywhat: str) -> None: + """ + DM a brother saying something + """ + # We do this as a for loop just in case multiple people reg. to same scroll for some reason (e.g. dup accounts) + for slack_id in identifier.lookup_brother_userids(brother): + dm_id = slack_util.im_channel_for_id(slack, slack_id) + if dm_id: + # Give a dummy msg dict, since we won't actually be using anything in it + slack_util.send_message(slack, saywhat, dm_id) + else: + print("Warning: unable to find dm for brother {}".format(brother)) + + +T = TypeVar("T") + + +def tiemax(items: Iterable[T], key: Callable[[T], float], min_score: Optional[float] = None) -> List[T]: + best = [] + best_score = min_score + for elt in items: + score = key(elt) + if best_score is None or score > best_score: + best_score = score + best = [elt] + elif score == best_score: + best.append(elt) + return best + + +def do_signoff(slack: SlackClient, msg: dict, on_assign_index: int, by_brother: scroll_util.Brother) -> None: + # First things first: Get the signoffs + assignments = house_management.import_assignments() + + # Get the one we want + on_assign = assignments[on_assign_index] + + # Modify it + on_assign.signer = by_brother + + # Put them all back + house_management.export_assignments(assignments) + + # Then we update points for house jobs + headers, points = house_management.import_points() + house_management.apply_house_points(points, assignments) + house_management.export_points(headers, points) + + # Then we respond cool! + slack_util.reply(slack, msg, "{} signed off {} for {} {} {}".format(on_assign.signer.name, + on_assign.assignee.name, + on_assign.job.house, + on_assign.job.day_of_week, + on_assign.job.name)) + alert_user(slack, on_assign.assignee, "Your house job was signed off") + + +async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None: + """ + Callback to signoff a user. + """ + # Find out who this is + signee_name = match.group(1) + + # Fix with a quick lookup + try: + signee = scroll_util.find_by_name(signee_name, MIN_RATIO, recent_only=True) + except scroll_util.BadName as e: + slack_util.reply(slack, msg, e.as_response()) + return + + # Also, who just signed us off? + signer = identifier.lookup_msg_brother(msg) + + # Get all of the assignments + assigns = house_management.import_assignments() + + # Find closest assignment to what we're after + def scorer(a: Optional[house_management.JobAssignment]) -> float: + if a is None: + return 0 + else: + return fuzz.ratio(signee.name, a.assignee.name) + + closest_assigns = tiemax(assigns, key=scorer) + + # Remove those that are already signed off + closest_assigns = [c for c in closest_assigns if c is not None] + closest_assigns = [c for c in closest_assigns if c.signer is None] + + # If there aren't any jobs, say so + if len(closest_assigns) == 0: + slack_util.reply(slack, msg, "Unable to find any jobs assigned to brother {} " + "(identified as {}).".format(signee_name, signee.name)) + return + + # If theres only one job, sign it off + elif len(closest_assigns) == 1: + targ_assign = closest_assigns[0] + + # Where is it? + targ_assign_index = assigns.index(targ_assign) + + do_signoff(slack, msg, targ_assign_index, signer) + return + + # If theres multiple jobs, we need to get a follow up! + else: + slack_util.reply(slack, msg, "Dunno how to handle multiple jobs yet") + return + + +# noinspection PyUnusedLocal +async def reset_callback(slack: SlackClient, msg: dict, match: Match) -> None: + """ + Resets the scores. + """ + # Get curr rows + headers, points = house_management.import_points() + + # Set to 0/default + for i in range(len(points)): + new = house_management.PointStatus(brother_raw=points[i].brother_raw, brother=points[i].brother) + points[i] = new + + house_management.export_points(headers, points) + + # Now unsign everything + assigns = house_management.import_assignments() + for a in assigns: + if a is not None: + a.signer = None + house_management.export_assignments(assigns) + + slack_util.reply(slack, msg, "Reset scores and signoffs") + + +async def nag_callback(slack, msg, match): + # Get the day + day = match.group(1).lower().strip() + + # Get the assigns + assigns = house_management.import_assignments() + + # Filter to day + assigns = [assign for assign in assigns if assign.job.day_of_week.lower() == day] + + # Filter signed off + assigns = [assign for assign in assigns if assign.signer is None] + + # 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" + "It is possible that all jobs have been signed off, as well.", + in_thread=True) + return + + # Nag each + response = "Do yer jerbs! They are as follows:\n" + for assign in assigns: + # Make the row template + response += "({}) {} -- {} ".format(assign.job.house, assign.job.name, assign.assignee.name) + + # Find the people to @ + brother_slack_ids = identifier.lookup_brother_userids(assign.assignee) + + if brother_slack_ids: + for slack_id in brother_slack_ids: + response += "<@{}> ".format(slack_id) + else: + response += "(scroll missing. Please register for @ pings!)" + response += "\n" + + slack_util.reply(slack, msg, response, in_thread=False, to_channel=channel_util.BOTZONE) + + +signoff_hook = slack_util.Hook(signoff_callback, + pattern=r"testsignoff\s+(.*)", + channel_whitelist=[channel_util.HOUSEJOBS]) + +reset_hook = slack_util.Hook(reset_callback, + pattern=r"testreset signoffs", + channel_whitelist=[channel_util.COMMAND_CENTER_ID]) # COMMAND_CENTER_ID + +nag_hook = slack_util.Hook(nag_callback, + pattern=r"nagjobs\s*(.*)", + channel_whitelist=[channel_util.COMMAND_CENTER_ID]) diff --git a/job_nagger.py b/job_nagger.py deleted file mode 100644 index df789e3..0000000 --- a/job_nagger.py +++ /dev/null @@ -1,86 +0,0 @@ -import identifier -import scroll_util -import slack_util -import google_api -import channel_util - -SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM" - -# Note: These ranges use named range feature of google sheets. -# To edit range of jobs, edit the named range in Data -> Named Ranges -eight_job_range = "EightJobs" # Format: Job Day Bro -fiftythree_job_range = "FiftyThreeJobs" - - -class Job(object): - """ - Object representing a house job - """ - def __init__(self, house: str, job_name: str, day: str, brother_name: str): - self.house, self.job_name, self.day, self.brother_name = house, job_name, day, brother_name - self.day = self.day.lower().strip() - - -def get_jobs(day=None): - """ - Retrieves the house jobs for a given day. - If no day is provided, returns all house jobs for the week. - - :param day: Day to compare the day column to. If equal, keep. If Not, discard. If day==None, ignore this filter - :return: list of Job objects - """ - - # Get the spreadsheet section - eight_jobs = google_api.get_sheet_range(SHEET_ID, eight_job_range) - ft_jobs = google_api.get_sheet_range(SHEET_ID, fiftythree_job_range) - - # Turn to job objects - def valid_row(x): - try: - return len(x) == 3 - except (AttributeError, TypeError): - return False - - eight_jobs = [Job("8", *r) for r in eight_jobs if valid_row(r)] - ft_jobs = [Job("53", *r) for r in ft_jobs if valid_row(r)] - jobs = eight_jobs + ft_jobs - - # Filter to day - if day: - jobs = [j for j in jobs if j.day == day] - - return jobs - - -async def nag_callback(slack, msg, match): - # Get the day - day = match.group(1).lower().strip() - jobs = get_jobs(day) - - # If no jobs found, somethings up. Probably mispelled day. - if not jobs: - slack_util.reply(slack, msg, "No jobs found. Check that the day is spelled correctly, with no extra symbols", - in_thread=True) - return - - # Nag each - response = "Do yer jerbs! They are as follows:\n" - for job in jobs: - # Make the row template - response += "({}) {} -- ".format(job.house, job.job_name) - - # Find the people to @ - brother = scroll_util.find_by_name(job.brother_name) - brother_slack_ids = identifier.lookup_brother_userids(brother) - - if brother_slack_ids: - for slack_id in brother_slack_ids: - response += "<@{}> ".format(slack_id) - else: - response += "{} (scroll missing. Please register for @ pings!)".format(job.brother_name) - response += "\n" - - slack_util.reply(slack, msg, response, in_thread=False, to_channel=channel_util.GENERAL) - - -nag_hook = slack_util.Hook(nag_callback, pattern=r"nagjobs\s*(.*)", channel_whitelist=[channel_util.COMMAND_CENTER_ID]) diff --git a/job_signoff.py b/job_signoff.py deleted file mode 100644 index 78984c0..0000000 --- a/job_signoff.py +++ /dev/null @@ -1,199 +0,0 @@ -from typing import List, Any, Match, Tuple - -from fuzzywuzzy import process -from slackclient import SlackClient - -import channel_util -import google_api -import identifier -import job_nagger -import slack_util -import scroll_util - -SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM" - -# Note: These ranges use named range feature of google sheets. -# To edit range of jobs, edit the named range in Data -> Named Ranges in the Goole Sheets page -output_range = "JobScoreTrackerV2" - -MIN_RATIO = 0.9 -SIGNOFF_REWARD = 0.1 - - -def get_curr_points() -> List[Tuple[str, float, str]]: - """ - :return: The current contents of the output range - """ - # Get the current stuff - curr_output = google_api.get_sheet_range(SHEET_ID, output_range) - all_jobs = job_nagger.get_jobs() - - # Each element: force length of 3. Fmt name, score, job - def row_fixer(r: List[Any]) -> Tuple[str, float, str]: - if len(r) == 0: - return "", 0, "No Job" - else: - # Get the appropriate score - if len(r) > 1: - try: - curr_score = float(r[1]) - except ValueError: - curr_score = 0 - else: - curr_score = 0 - - # Find the current job - job = "No Job" - for j in all_jobs: - if j.brother_name == r[0]: - job = "{} ({} - {})".format(j.job_name, j.day, j.house) - break - return r[0], curr_score, job - - # Fix each row - curr_output = [row_fixer(x) for x in curr_output] - - # Cut off the chaff - doesn't seem to be necessary. - return curr_output - - -def put_points(vals: List[Tuple[str, float, str]]) -> None: - # Turn the tuples to lists - vals = [list(row) for row in vals] - google_api.set_sheet_range(SHEET_ID, output_range, vals) - - -def alert_user(slack: SlackClient, name: str, saywhat: str) -> None: - """ - DM a brother saying something - """ - brother_dict = scroll_util.find_by_name(name) - # We do this as a for loop just in case multiple people reg. to same scroll for some reason (e.g. dup accounts) - for slack_id in identifier.lookup_brother_userids(brother_dict): - dm_id = slack_util.im_channel_for_id(slack, slack_id) - if dm_id: - # Give a dummy msg dict, since we won't actually be using anything in it - slack_util.reply(slack, {}, saywhat, to_channel=dm_id, in_thread=False) - else: - print("Warning: unable to find dm for brother {}".format(brother_dict)) - - -async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None: - """ - Callback to signoff a user. - """ - # Find the index of our person. - name = match.group(1) - - # Also, who just signed us off? - signer = identifier.lookup_msg_brother(msg).name - - # Try giving the person a point - try: - (bro_name, bro_total, bro_job), (ass_name, ass_total, _) = adjust_scores((name, 1), (signer, SIGNOFF_REWARD)) - slack_util.reply(slack, msg, "Gave {} one housejob point for job {}.\n" - "They now have {} for this period.\n" - "You ({}) were credited with the signoff".format(bro_name, bro_job, bro_total, ass_name)) - alert_user(slack, bro_name, - "You, who we believe to be {}, just had your house job signed off by {}!".format(bro_name, ass_name)) - except scroll_util.BadName as e: - # We didn't find a name - no action was performed. - slack_util.reply(slack, msg, e.as_response()) - - -async def punish_callback(slack: SlackClient, msg: dict, match: Match) -> None: - """ - Undoes a signoff. Maybe should rename - """ - # Find the index of our person. - name = match.group(2) - - # Also, who just signed us off? - signer = identifier.lookup_msg_brother(msg).name - - # Try giving the person a point - try: - (bro_name, bro_total, _), (ass_name, ass_total, _) = adjust_scores((name, -1), (signer, -SIGNOFF_REWARD)) - slack_util.reply(slack, msg, "Took one housejob point from {}.\n" - "They now have {} for this period.\n" - "Under the assumption that this was to undo a mistake, we have deducted the " - "usual signoff reward from you, ({}).\n " - "You can easily earn it back by signing off the right person ;).".format(bro_name, - bro_total, - ass_name)) - alert_user(slack, bro_name, - "You, who we believe to be {}, just had your house job UN-signed off by {}.\n" - "Perhaps the asshoman made a mistake when they first signed you off.\n" - "If you believe that they undid the signoff accidentally, go talk to them".format(bro_name, signer)) - - except scroll_util.BadName as e: - # We didn't find a name - no action was performed. - slack_util.reply(slack, msg, e.as_response()) - - -# noinspection PyUnusedLocal -async def reset_callback(slack: SlackClient, msg: dict, match: Match) -> None: - """ - Resets the scores. - """ - # Get curr rows - points = get_curr_points() - - new_points = [(a, 0, c) for a, _, c in points] - put_points(new_points) - slack_util.reply(slack, msg, "Reset scores") - - -def adjust_scores(*name_delta_tuples: Tuple[str, float]) -> List[Tuple[str, float, str]]: - """ - Helper that uses a sequence of tuples in the format (name, delta) to adjust each (name) to have +delta score. - Operation performed as a batch. - :param name_delta_tuples: The name + score deltas - :return: The updated tuples rows. - """ - # Get the current stuff - points = get_curr_points() - names = [p[0] for p in points] - modified_user_indexes = [] - - for name, delta in name_delta_tuples: - # Find our guy - target_name, ratio = process.extractOne(name, names) - ratio = ratio / 100.0 - - # If bad ratio, error - if ratio < MIN_RATIO: - raise scroll_util.BadName(target_name, ratio, MIN_RATIO) - - # Where is he in the list? - target_index = names.index(target_name) - - # Get his current score - curr_score = points[target_index][1] - curr_job = points[target_index][2] - - # target should be in the form index, (name, score) - target_new = target_name, curr_score + delta, curr_job - - # Put it back - points[target_index] = target_new - - # Record where we edited - modified_user_indexes.append(target_index) - - # Push all to sheets if exit loop without error - put_points(points) - - # Conver indexes to rows, then return the adjusted name/score_tuples - return [points[i] for i in modified_user_indexes] - - -signoff_hook = slack_util.Hook(signoff_callback, - pattern=r"signoff\s+(.*)", - channel_whitelist=[channel_util.HOUSEJOBS]) -undosignoff_hook = slack_util.Hook(punish_callback, - pattern=r"(unsignoff|undosignoff|undo)\s+(.*)", - channel_whitelist=[channel_util.HOUSEJOBS]) -reset_hook = slack_util.Hook(reset_callback, - pattern=r"reset signoffs", - channel_whitelist=[channel_util.COMMAND_CENTER_ID]) diff --git a/main.py b/main.py index e6d4605..b6110f3 100644 --- a/main.py +++ b/main.py @@ -1,26 +1,15 @@ import asyncio -from typing import List, Any, AsyncGenerator - -from slackclient import SlackClient # Obvious import channel_util import identifier -import job_nagger -import job_signoff +import job_commands import management_commands import periodicals import scroll_util -import slack_util import slavestothemachine -from dummy import FakeClient # Read api token from file -api_file = open("apitoken.txt", 'r') -SLACK_API = next(api_file).strip() -api_file.close() - -# Enable to use dummy -DEBUG_MODE = False +from client_wrapper import ClientWrapper def main() -> None: @@ -39,7 +28,7 @@ def main() -> None: wrap.add_hook(channel_util.channel_check_hook) # Add nagging functionality - wrap.add_hook(job_nagger.nag_hook) + wrap.add_hook(job_commands.nag_hook) # Add kill switch wrap.add_hook(management_commands.reboot_hook) @@ -49,127 +38,24 @@ def main() -> None: wrap.add_hook(slavestothemachine.dump_work_hook) # Add signoffs - wrap.add_hook(job_signoff.signoff_hook) - wrap.add_hook(job_signoff.undosignoff_hook) - wrap.add_hook(job_signoff.reset_hook) + wrap.add_hook(job_commands.signoff_hook) + wrap.add_hook(job_commands.reset_hook) # Add help - help_callback = management_commands.list_hooks_callback_gen(wrap.hooks) - wrap.add_hook(slack_util.Hook(help_callback, pattern=management_commands.bot_help_pattern)) + # help_callback = management_commands.list_hooks_callback_gen(wrap.hooks) + # wrap.add_hook(slack_util.Hook(help_callback, pattern=management_commands.bot_help_pattern)) # Add boozebot wrap.add_passive(periodicals.ItsTenPM()) event_loop = asyncio.get_event_loop() + event_loop.set_debug(True) message_handling = wrap.respond_messages() passive_handling = wrap.run_passives() both = asyncio.gather(message_handling, passive_handling) event_loop.run_until_complete(both) -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 - if DEBUG_MODE: - self.slack = FakeClient() - else: - self.slack = SlackClient(SLACK_API) - - # For overriding output channel - self.debug_slack = slack_util.SlackDebugCondom(self.slack) - - # Hooks go regex -> callback on (slack, msg, match) - self.hooks: List[slack_util.Hook] = [] - - # 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.Hook) -> None: - self.hooks.append(hook) - - async def respond_messages(self) -> None: - """ - Asynchronous tasks that eternally reads and responds to messages. - """ - async for _ in self.spool_tasks(): - print("Handling a message...!") - - 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'])) - - # Handle debug - if msg['text'][:6] == "DEBUG ": - slack_to_use = self.debug_slack - msg['text'] = msg['text'][6:] - print("Debug handling \"{}\"".format(msg['text'])) - else: - slack_to_use = self.slack - - # Msg is good - # Find which hook, if any, satisfies - sat_hook = None - sat_match = None - for hook in self.hooks: - match = hook.check(msg) - if match is not None: - sat_match = match - sat_hook = hook - break - - # If no hooks, continue - if not sat_hook: - continue - - # Throw up as a task, otherwise - coro = sat_hook.invoke(slack_to_use, msg, sat_match) - task = asyncio.create_task(coro) - yield task - - 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) - - # run main if __name__ == '__main__': main() diff --git a/periodicals.py b/periodicals.py index 651c630..d0d82e8 100644 --- a/periodicals.py +++ b/periodicals.py @@ -24,7 +24,7 @@ class ItsTenPM(slack_util.Passive): await asyncio.sleep(delay) # Crow like a rooster - slack_util.reply(slack, {}, "IT'S 10 PM!", in_thread=False, to_channel=channel_util.RANDOM) + slack_util.send_message(slack, "IT'S 10 PM!", channel_util.RANDOM) # Wait a while before trying it again, to prevent duplicates await asyncio.sleep(60) diff --git a/scroll_util.py b/scroll_util.py index cd99a84..6994ab9 100644 --- a/scroll_util.py +++ b/scroll_util.py @@ -4,6 +4,7 @@ Only really kept separate for neatness sake. """ import re +from dataclasses import dataclass from typing import List, Optional, Match from fuzzywuzzy import process @@ -11,17 +12,17 @@ from slackclient import SlackClient import slack_util +# Use this if we can't figure out who a brother actually is +MISSINGBRO_SCROLL = 0 + +@dataclass class Brother(object): """ Represents a brother. """ - def __init__(self, name: str, scroll: int): - self.name = name - self.scroll = scroll - - def __repr__(self): - return "".format(self.name, self.scroll) + name: str + scroll: int # load the family tree @@ -32,7 +33,7 @@ brother_match = re.compile(r"([0-9]*)~(.*)") brothers_matches = [brother_match.match(line) for line in familyfile] 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] - +recent_brothers: List[Brother] = brothers[700:] async def scroll_callback(slack: SlackClient, msg: dict, match: Match) -> None: """ @@ -81,23 +82,32 @@ class BadName(Exception): "match ratio {}. Please type name better.".format(self.name, self.score, self.threshold) -def find_by_name(name: str, threshold: Optional[float] = None) -> Brother: +def find_by_name(name: str, threshold: Optional[float] = None, recent_only: bool=False) -> Brother: """ Looks up a brother by name. Raises exception if threshold provided and not met. :param threshold: Minimum match ratio to accept. Can be none. :param name: The name to look up, with a fuzzy search + :param recent_only: Whether or not to only search more recent undergrad brothers. :return: The best-match brother """ - # Really quikly mog name into Brother, so the processor function works fine - name_bro = Brother(name, -1) + # Pick a list + if recent_only: + bros_to_use = recent_brothers + else: + bros_to_use = brothers + + # Get all of the names + all_names = [b.name for b in bros_to_use] # Do fuzzy match - found = process.extractOne(name_bro, brothers, processor=lambda b: b.name) - if (not threshold) or found[1] > threshold: - return found[0] + found, score = process.extractOne(name, all_names) + score = score / 100.0 + if (not threshold) or score > threshold: + found_index = all_names.index(found) + return bros_to_use[found_index] else: - raise BadName(name, found[1], threshold) + raise BadName(found, score, threshold) scroll_hook = slack_util.Hook(scroll_callback, pattern=r"scroll\s+(.*)") diff --git a/slack_util.py b/slack_util.py index 2eb8803..38f2d4a 100644 --- a/slack_util.py +++ b/slack_util.py @@ -5,14 +5,12 @@ from typing import Any, Optional, Generator, Match, Callable, List, Coroutine from slackclient import SlackClient from slackclient.client import SlackNotConnected -import channel_util - """ Slack helpers. Separated for compartmentalization """ -def reply(slack: SlackClient, msg: dict, text: str, in_thread: bool = True, to_channel: str = None) -> None: +def reply(slack: SlackClient, 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 """ @@ -24,9 +22,22 @@ def reply(slack: SlackClient, msg: dict, text: str, in_thread: bool = True, to_c 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 - slack.rtm_send_message(channel=to_channel, message=text, thread=thread) + return send_message(slack, text, to_channel, thread=thread) else: - slack.rtm_send_message(channel=to_channel, message=text) + return send_message(slack, text, to_channel) + + +def send_message(slack: SlackClient, text: str, channel: str, thread: str = None, broadcast: bool = False) -> dict: + """ + Copy of the internal send message function of slack + """ + kwargs = {"channel": channel, "text": text} + if thread: + kwargs["thread_ts"] = thread + if broadcast: + kwargs["reply_broadcast"] = True + + return slack.api_call("chat.postMessage", **kwargs) def im_channel_for_id(slack: SlackClient, user_id: str) -> Optional[str]: @@ -39,29 +50,6 @@ def im_channel_for_id(slack: SlackClient, user_id: str) -> Optional[str]: return None -class SlackDebugCondom(object): - def __init__(self, actual_slack: SlackClient): - self.actual_slack = actual_slack - - def __getattribute__(self, name: str) -> Any: - # Specialized behaviour - if name == "rtm_send_message": - # Flub some args - def override_send_message(*args, **kwargs): - print("Overriding: {} {}".format(args, kwargs)) - kwargs["channel"] = channel_util.BOTZONE - kwargs["thread"] = None - self.actual_slack.rtm_send_message(*args, **kwargs) - - return override_send_message - else: - # Default behaviour. Try to give the self first, elsewise give the child - try: - return super(SlackDebugCondom, self).__getattribute__(name) - except AttributeError: - return self.actual_slack.__getattribute__(name) - - def message_stream(slack: SlackClient) -> Generator[dict, None, None]: """ Generator that yields messages from slack. @@ -93,12 +81,28 @@ MsgAction = Coroutine[Any, Any, None] Callback = Callable[[SlackClient, dict, Match], MsgAction] -class Hook(object): +class DeadHook(Exception): + pass + + +class AbsHook(object): + def __init__(self, consumes_applicable: bool): + # Whether or not messages that yield a coroutine should not be checked further + self.consumes = consumes_applicable + + def try_apply(self, slack: SlackClient, msg: dict) -> Optional[Coroutine[None, None, None]]: + raise NotImplementedError() + + +class Hook(AbsHook): def __init__(self, callback: Callback, pattern: str, channel_whitelist: Optional[List[str]] = None, - channel_blacklist: Optional[List[str]] = None): + channel_blacklist: Optional[List[str]] = None, + consumer: bool = True): + super(Hook, self).__init__(consumer) + # Save all self.pattern = pattern self.channel_whitelist = channel_whitelist @@ -114,7 +118,7 @@ class Hook(object): else: raise Exception("Cannot whitelist and blacklist") - def check(self, msg: dict) -> Optional[Match]: + def try_apply(self, slack: SlackClient, msg: dict) -> Optional[Coroutine[None, None, None]]: """ Returns whether a message should be handled by this dict, returning a Match if so, or None """ @@ -134,9 +138,6 @@ class Hook(object): # print("Hit blacklist") return None - return match - - def invoke(self, slack: SlackClient, msg: dict, match: Match): return self.callback(slack, msg, match) diff --git a/sortedfamilytree.txt b/sortedfamilytree.txt index 4a3e894..76fba4d 100644 --- a/sortedfamilytree.txt +++ b/sortedfamilytree.txt @@ -1,4 +1,5 @@ scroll~name +0~MISSINGBRO 1~Richard Brodeur 2~Charles T. Kleman 4~Warren Bentley