Help command, marking late, better async, more stability, more modular

This commit is contained in:
Jacob Henry 2018-11-19 20:15:12 -05:00
parent ec1d678c5c
commit 61b21f6ef9
10 changed files with 231 additions and 156 deletions

View File

@ -5,14 +5,13 @@ 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
# Enable to do single-threaded and have better exceptions
DEBUG_MODE = False
@ -25,10 +24,7 @@ class ClientWrapper(object):
def __init__(self):
# Init slack
if DEBUG_MODE:
self.slack = FakeClient()
else:
self.slack = SlackClient(SLACK_API)
self.slack = SlackClient(SLACK_API)
# Hooks go regex -> callback on (slack, msg, match)
self.hooks: List[slack_util.AbsHook] = []
@ -53,7 +49,9 @@ class ClientWrapper(object):
"""
Asynchronous tasks that eternally reads and responds to messages.
"""
async for _ in self.spool_tasks():
async for t in self.spool_tasks():
if DEBUG_MODE:
await t
print("Handling a message...!")
async def spool_tasks(self) -> AsyncGenerator[asyncio.Task, Any]:

View File

@ -1,29 +0,0 @@
import time
import channel_util
roll_msg_1 = {
"type": "message",
"channel": channel_util.SLAVES_TO_THE_MACHINE_ID,
"user": "U0Q1PKL92",
"text": "flaked 3 rolled 2 washed 1",
"ts": "1355517523.000005"
}
dump_roll_msg = {
"type": "message",
"channel": channel_util.COMMAND_CENTER_ID,
"user": "U0Q1PKL92",
"text": "dump towel data",
"ts": "1355517523.000005"
}
class FakeClient(object):
def rtm_send_message(self, channel=None, message="", thread=None, to_channel=None):
print("Sent \"{}\" to channel {}".format(message, channel))
def rtm_connect(self, with_team_state=None, auto_reconnect=None):
return True
def rtm_read(self):
time.sleep(4)
return [dump_roll_msg]

View File

@ -88,7 +88,7 @@ def strip_all(l: List[str]) -> List[str]:
return [x.strip() for x in l]
def import_assignments() -> List[Optional[JobAssignment]]:
async def import_assignments() -> List[Optional[JobAssignment]]:
"""
Imports Jobs and JobAssignments from the sheet. 1:1 row correspondence.
"""
@ -152,8 +152,8 @@ def import_assignments() -> List[Optional[JobAssignment]]:
# 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:
assignee = await scroll_util.find_by_name(assignee, SHEET_LOOKUP_THRESHOLD)
except scroll_util.BrotherNotFound:
# If we can't get one close enough, make a dummy
assignee = scroll_util.Brother(assignee, scroll_util.MISSINGBRO_SCROLL)
@ -163,7 +163,7 @@ def import_assignments() -> List[Optional[JobAssignment]]:
signer = None
else:
signer = scroll_util.find_by_name(signer)
except scroll_util.BadName:
except scroll_util.BrotherNotFound:
# If we can't figure out the name
signer = None
@ -183,7 +183,7 @@ def import_assignments() -> List[Optional[JobAssignment]]:
return assignments
def export_assignments(assigns: List[Optional[JobAssignment]]) -> None:
async def export_assignments(assigns: List[Optional[JobAssignment]]) -> None:
# Smash to rows
rows = []
for v in assigns:
@ -196,7 +196,7 @@ def export_assignments(assigns: List[Optional[JobAssignment]]) -> None:
google_api.set_sheet_range(SHEET_ID, job_range, rows)
def import_points() -> (List[str], List[PointStatus]):
async def import_points() -> (List[str], List[PointStatus]):
# Figure out how many things there are in a point status
field_count = len(dataclasses.fields(PointStatus))
@ -208,7 +208,7 @@ def import_points() -> (List[str], List[PointStatus]):
point_rows = point_rows[1:]
# Tidy rows up
def converter(row: List[Any]) -> Optional[PointStatus]:
async 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
@ -225,14 +225,14 @@ def import_points() -> (List[str], List[PointStatus]):
row[i] = x
# Get the brother for the last item
real_brother = scroll_util.find_by_name(row[0], recent_only=True)
real_brother = await scroll_util.find_by_name(row[0])
# 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]
point_statuses = [await converter(row) for row in point_rows]
return headers, point_statuses

View File

@ -3,12 +3,12 @@ Allows users to register their user account as a specific scroll
"""
import shelve
from typing import Optional, List, Match
from typing import List, Match
from slackclient import SlackClient
import slack_util
import scroll_util
import slack_util
# The following db maps SLACK_USER_ID -> SCROLL_INTEGER
DB_NAME = "user_scrolls"
@ -97,18 +97,20 @@ async def name_callback(slack, msg, match):
slack_util.reply(slack, msg, result)
def lookup_msg_brother(msg: dict) -> Optional[scroll_util.Brother]:
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 lookup_slackid_brother(msg.get("user"))
return await lookup_slackid_brother(msg.get("user"))
def lookup_slackid_brother(slack_id: str) -> Optional[scroll_util.Brother]:
async def lookup_slackid_brother(slack_id: str) -> scroll_util.Brother:
"""
Gets whatever brother the userid is registered to
:raises BrotherNotFound:
:return: Brother object or None
"""
with shelve.open(DB_NAME) as db:
@ -116,7 +118,7 @@ def lookup_slackid_brother(slack_id: str) -> Optional[scroll_util.Brother]:
scroll = db[slack_id]
return scroll_util.find_by_scroll(scroll)
except ValueError:
return None
raise scroll_util.BrotherNotFound("Slack id {} not tied to brother".format(slack_id))
def lookup_brother_userids(brother: scroll_util.Brother) -> List[str]:

View File

@ -1,3 +1,4 @@
from dataclasses import dataclass
from typing import List, Match, Callable, TypeVar, Optional, Iterable
from fuzzywuzzy import fuzz
@ -32,100 +33,100 @@ def alert_user(slack: SlackClient, brother: scroll_util.Brother, saywhat: str) -
T = TypeVar("T")
def tiemax(items: Iterable[T], key: Callable[[T], float], min_score: Optional[float] = None) -> List[T]:
def tiemax(items: Iterable[T], key: Callable[[T], Optional[float]]) -> List[T]:
best = []
best_score = min_score
best_score = None
for elt in items:
# Compute the score
score = key(elt)
if best_score is None or score > best_score:
# Ignore blank scores
if score is None:
continue
# Check if its the new best, wiping old if so
elif best_score is None or score > best_score:
best_score = score
best = [elt]
# Check if its same as last best
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.pretty_fmt()))
alert_user(slack, on_assign.assignee, "{} signed you off for {}.".format(on_assign.signer.name,
on_assign.job.pretty_fmt()))
@dataclass
class _ModJobContext:
signer: scroll_util.Brother # The brother invoking the command
assign: house_management.JobAssignment # The job assignment to modify
async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None:
async def _mod_jobs(slack: SlackClient,
msg: dict,
relevance_scorer: Callable[[house_management.JobAssignment], Optional[float]],
modifier: Callable[[_ModJobContext], None],
no_job_msg: str = None
) -> None:
"""
Callback to signoff a user.
Stub function that handles various tasks relating to modifying jobs
:param relevance_scorer: Function scores job assignments on relevance. Determines which gets modified
:param modifier: Callback function to modify a job. Only called on a successful operation, and only on one job
"""
# Find out who this is
signee_name = match.group(1)
# Make an error wrapper
verb = slack_util.VerboseWrapper(slack, msg)
# 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)
# Who invoked this command?
signer = await verb(identifier.lookup_msg_brother(msg))
# Get all of the assignments
assigns = house_management.import_assignments()
assigns = await verb(house_management.import_assignments())
# Find closest assignment to what we're after
def scorer(a: Optional[house_management.JobAssignment]) -> float:
# Find closest assignment to what we're after. This just wraps relevance_scorer to handle nones.
def none_scorer(a: Optional[house_management.JobAssignment]) -> Optional[float]:
if a is None:
return 0
return None
else:
return fuzz.ratio(signee.name, a.assignee.name)
return relevance_scorer(a)
closest_assigns = tiemax(assigns, key=scorer)
closest_assigns = tiemax(assigns, key=none_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]
# This is what we do on success. It will or won't be called immediately based on what's in closest_assigns
async def success_callback(targ_assign: house_management.JobAssignment) -> None:
# First get the most up to date version of the jobs
fresh_assigns = await verb(house_management.import_assignments())
# Find the one that matches what we had before
fresh_targ_assign = fresh_assigns[fresh_assigns.index(targ_assign)]
# Create the context
context = _ModJobContext(signer, fresh_targ_assign)
# Modify it
modifier(context)
# Re-upload
await house_management.export_assignments(fresh_assigns)
# Also import and update points
headers, points = await house_management.import_points()
house_management.apply_house_points(points, fresh_assigns)
house_management.export_points(headers, points)
# 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))
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)
# 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)
await success_callback(closest_assigns[0])
# If theres multiple jobs, we need to get a follow up!
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 sign off options dectected for brother {}.\n"
"Please enter the number corresponding to the job you wish to "
"sign off:\n{}\nIf you do not respond within 60 seconds, the signoff will "
"expire.".format(signee_name, job_list))
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))
# Establish a follow up command pattern
pattern = r"\d+"
@ -138,19 +139,68 @@ async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None:
# Check that its valid
if 0 <= index < len(closest_assigns):
# We now know what we're trying to sign off!
specific_targ_assign = closest_assigns[index]
specific_targ_assign_index = assigns.index(specific_targ_assign)
do_signoff(_slack, _msg, specific_targ_assign_index, signer)
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. Start over from the "
"signoff step.")
slack_util.reply(_slack, _msg, "Invalid job index / job unable to be found.")
# Make a listener hook
new_hook = slack_util.ReplyWaiter(foc, pattern, msg["ts"], 60)
new_hook = slack_util.ReplyWaiter(foc, pattern, msg["ts"], 120)
# Register it
client_wrapper.get_client_wrapper().add_hook(new_hook)
async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None:
verb = slack_util.VerboseWrapper(slack, msg)
# Find out who we are trying to sign off is
signee_name = match.group(1)
signee = await verb(scroll_util.find_by_name(signee_name, MIN_RATIO))
# Score by name similarity, only accepting non-assigned jobs
def scorer(assign: house_management.JobAssignment):
if assign.signer is None:
return fuzz.ratio(signee.name, assign.assignee.name)
# Set the assigner, and notify
def modifier(context: _ModJobContext):
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()))
# Fire it off
await _mod_jobs(slack, msg, scorer, modifier)
async def late_callback(slack: SlackClient, msg: dict, match: Match) -> None:
verb = slack_util.VerboseWrapper(slack, msg)
# Find out who we are trying to sign off is
signee_name = match.group(1)
signee = await verb(scroll_util.find_by_name(signee_name, MIN_RATIO))
# Score by name similarity. Don't care if signed off or not
def scorer(assign: house_management.JobAssignment):
return fuzz.ratio(signee.name, assign.assignee.name)
# Just set the assigner
def modifier(context: _ModJobContext):
context.assign.late = not context.assign.late
# Say we did it
slack_util.reply(slack, msg, "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)
# noinspection PyUnusedLocal
async def reset_callback(slack: SlackClient, msg: dict, match: Match) -> None:
"""
@ -167,11 +217,11 @@ async def reset_callback(slack: SlackClient, msg: dict, match: Match) -> None:
house_management.export_points(headers, points)
# Now unsign everything
assigns = house_management.import_assignments()
assigns = await house_management.import_assignments()
for a in assigns:
if a is not None:
a.signer = None
house_management.export_assignments(assigns)
await house_management.export_assignments(assigns)
slack_util.reply(slack, msg, "Reset scores and signoffs")
@ -181,7 +231,7 @@ async def nag_callback(slack, msg, match):
day = match.group(1).lower().strip()
# Get the assigns
assigns = house_management.import_assignments()
assigns = await house_management.import_assignments()
# Filter to day
assigns = [assign for assign in assigns if assign is not None and assign.job.day_of_week.lower() == day]
@ -219,6 +269,10 @@ signoff_hook = slack_util.Hook(signoff_callback,
pattern=r"signoff\s+(.*)",
channel_whitelist=[channel_util.HOUSEJOBS])
late_hook = slack_util.Hook(late_callback,
pattern=r"marklate\s+(.*)",
channel_whitelist=[channel_util.HOUSEJOBS])
reset_hook = slack_util.Hook(reset_callback,
pattern=r"reset signoffs",
channel_whitelist=[channel_util.COMMAND_CENTER_ID])

42
main.py
View File

@ -1,4 +1,8 @@
import asyncio
import textwrap
from typing import Match
from slackclient import SlackClient
import channel_util
import client_wrapper
@ -6,6 +10,7 @@ import identifier
import job_commands
import management_commands
import scroll_util
import slack_util
import slavestothemachine
@ -24,9 +29,6 @@ def main() -> None:
# Added channel utility
wrap.add_hook(channel_util.channel_check_hook)
# Add nagging functionality
wrap.add_hook(job_commands.nag_hook)
# Add kill switch
wrap.add_hook(management_commands.reboot_hook)
@ -34,13 +36,14 @@ def main() -> None:
wrap.add_hook(slavestothemachine.count_work_hook)
wrap.add_hook(slavestothemachine.dump_work_hook)
# Add signoffs
# Add job management
wrap.add_hook(job_commands.signoff_hook)
wrap.add_hook(job_commands.late_hook)
wrap.add_hook(job_commands.reset_hook)
wrap.add_hook(job_commands.nag_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))
wrap.add_hook(slack_util.Hook(help_callback, pattern=management_commands.bot_help_pattern))
# Add boozebot
# wrap.add_passive(periodicals.ItsTenPM())
@ -53,6 +56,33 @@ def main() -> None:
event_loop.run_until_complete(both)
async def help_callback(slack: SlackClient, msg: dict, match: Match) -> None:
slack_util.reply(slack, msg, 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.
"what is my scroll" : Echos back what the bot thinks your scroll is. Largely for debugging.
"what is my name" : Echos back what the bot thinks your name is. Largely for debugging. If you want to change this, you'll need to fix the "Sorted family tree" file that the bot reads. Sorry.
"channel id #wherever" : Debug command to get a slack channels full ID
"reboot" : Restarts the server.
"dump towel data" : Summarize the towel roller work for the week, and reset it.
"signoff <John Doe>" : Sign off a brother's house job. Will prompt for more information if needed.
"marklate <John Doe>" : Same as above, but to mark a job as being completed but having been done late.
IN PROGRESS: "reassign <John Doe> -> <James Deer>" : Reassign a house job.
"nagjobs <day>" : Notify in general the house jobs for the week.
"reset signoffs" : Clear points for the week, and undo all signoffs. Not frequently useful.
"help" : You stupid or something? You're reading it. This is all it does. What do you want from me?
---
Also of note is that in #slavestothemachine, any wording of the format "replaced <number>", or similarly with "washed", "dried", "rolled", or "flaked", will track your effort for the week.
Github is https://github.com/TheVillageIdiot2/waitonbot
Man in charge is Jacob Henry, but nothing lasts forever.
"""))
# Do not let my efforts fall to waste. Its a pitious legacy but its something, at least, to maybe tide the
# unending flow of work for poor Niko.
# run main
if __name__ == '__main__':
main()

View File

@ -23,7 +23,7 @@ async def reboot_callback(slack: SlackClient, msg: dict, match: Match) -> None:
# Make hooks
bot_help_pattern = r"bot help" # Can't init this directly, as it relies on us knowing all other hooks. handle in main
bot_help_pattern = r"help" # Can't init this directly, as it relies on us knowing all other hooks. handle in main
reboot_hook = slack_util.Hook(reboot_callback,
pattern=r"reboot",
channel_whitelist=[channel_util.COMMAND_CENTER_ID])

View File

@ -33,7 +33,6 @@ 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:
@ -44,11 +43,15 @@ async def scroll_callback(slack: SlackClient, msg: dict, match: Match) -> None:
query = match.group(1).strip()
# Try to get as int or by name
result = None
try:
sn = int(query)
result = find_by_scroll(sn)
except ValueError:
result = find_by_name(query)
try:
result = await find_by_name(query)
except BrotherNotFound:
pass
if result:
result = "Brother {} has scroll {}".format(result.name, result.scroll)
else:
@ -72,43 +75,36 @@ def find_by_scroll(scroll: int) -> Optional[Brother]:
# Used to track a sufficiently shitty typed name
class BadName(Exception):
def __init__(self, name: str, score: float, threshold: float):
self.name = name
self.score = score
self.threshold = threshold
def as_response(self) -> str:
return "Unable to perform operation. Best name match {} had a match score of {}, falling short of minimum " \
"match ratio {}. Please type name better.".format(self.name, self.score, self.threshold)
class BrotherNotFound(Exception):
"""Throw when we can't find the desired brother."""
pass
def find_by_name(name: str, threshold: Optional[float] = None, recent_only: bool = False) -> Brother:
async def find_by_name(name: str, threshold: Optional[float] = None) -> 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.
:raises BrotherNotFound:
:return: The best-match brother
"""
# 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]
""" # Get all of the names
all_names = [b.name for b in brothers]
# Do fuzzy match
found, score = process.extractOne(name, all_names)
score = score / 100.0
found_index = all_names.index(found)
found_brother = brothers[found_index]
if (not threshold) or score > threshold:
found_index = all_names.index(found)
return bros_to_use[found_index]
return found_brother
else:
raise BadName(found, score, threshold)
msg = "Couldn't find brother {}. Best match \"{}\" matched with accuracy {}, falling short of safety minimum " \
"accuracy {}. Please type name more accurately, to prevent misfires.".format(name,
found_brother,
score,
threshold)
raise BrotherNotFound(msg)
scroll_hook = slack_util.Hook(scroll_callback, pattern=r"scroll\s+(.*)")

View File

@ -1,4 +1,5 @@
import re
import typing
from time import sleep, time
from typing import Any, Optional, Generator, Match, Callable, List, Coroutine
@ -77,6 +78,26 @@ def message_stream(slack: SlackClient) -> Generator[dict, None, None]:
print("Connection failed - retrying")
T = typing.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, slack: SlackClient, command_msg: dict):
self.slack = slack
self.command_msg = command_msg
async def __call__(self, awt: typing.Awaitable[T]) -> T:
try:
return await awt
except Exception as e:
reply(self.slack, self.command_msg, "Error: {}".format(str(e)), True)
raise e
MsgAction = Coroutine[Any, Any, None]
Callback = Callable[[SlackClient, dict, Match], MsgAction]

View File

@ -8,6 +8,8 @@ import identifier
import re
import shelve
from scroll_util import BrotherNotFound
counted_data = ["flaked", "rolled", "replaced", "washed", "dried"]
lookup_format = "{}\s+(\d+)"
@ -25,7 +27,7 @@ async def count_work_callback(slack: SlackClient, msg: dict, match: Match) -> No
# Couple things to work through.
# One: Who sent the message?
who_wrote = identifier.lookup_msg_brother(msg)
who_wrote = await identifier.lookup_msg_brother(msg)
who_wrote_label = "{} [{}]".format(who_wrote.name, who_wrote.scroll)
# Two: What work did they do?
@ -75,8 +77,9 @@ async def dump_work_callback(slack: SlackClient, msg: dict, match: Match) -> Non
del db[user_id]
# Get the name
brother = identifier.lookup_slackid_brother(user_id)
if brother is None:
try:
brother = await identifier.lookup_slackid_brother(user_id)
except BrotherNotFound:
brother = user_id
else:
brother = brother.name