waitonbot/plugins/job_commands.py

429 lines
16 KiB
Python

import logging
from dataclasses import dataclass
from typing import List, Match, Callable, TypeVar, Optional, Iterable, Any, Coroutine
from fuzzywuzzy import fuzz
import hooks
from plugins import identifier, house_management, scroll_util
import client
import slack_util
SHEET_ID = "1f9p4H7TWPm8rAM4v_qr2Vc6lBiFNEmR-quTY9UtxEBI"
MIN_RATIO = 80.0
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 await identifier.lookup_brother_userids(brother):
client.get_slack().send_message(saywhat, slack_id)
succ = True
# Warn if we never find
if not succ:
logging.warning("Unable to find dm conversation for brother {}".format(brother))
# Generic type
T = TypeVar("T")
def tiemax(items: Iterable[T], key: Callable[[T], Optional[float]]) -> List[T]:
best = []
best_score = None
for elt in items:
# Compute the score
score = key(elt)
# 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
@dataclass
class _ModJobContext:
signer: scroll_util.Brother # The brother invoking the command
assign: house_management.JobAssignment # The job assignment to modify
async def _mod_jobs(event: slack_util.Event,
relevance_scorer: Callable[[house_management.JobAssignment], Optional[float]],
modifier: Callable[[_ModJobContext], Coroutine[Any, Any, None]],
no_job_msg: str = None
) -> None:
"""
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
"""
# Make an error wrapper
verb = slack_util.VerboseWrapper(event)
# Who invoked this command?
signer = await verb(event.user.as_user().get_brother())
# Get all of the assignments
assigns = await verb(house_management.import_assignments())
# 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 None
else:
return relevance_scorer(a)
closest_assigns = tiemax(assigns, key=none_scorer)
# 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
await 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:
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."
client.get_slack().reply(event, no_job_msg)
# If theres only one job, sign it off
elif len(closest_assigns) == 1:
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))
client.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(_event: slack_util.Event, _match: Match) -> None:
# Get the number out
index = int(_match.group(0))
# Check that its valid
if 0 <= index < len(closest_assigns):
# We now know what we're trying to sign off!
await success_callback(closest_assigns[index])
else:
# They gave a bad index, or we were unable to find the assignment again.
client.get_slack().reply(_event, "Invalid job index / job unable to be found.")
# Make a listener hook
new_hook = hooks.ReplyWaiter(foc, pattern, event.message.ts, 120)
# Register it
client.get_slack().add_hook(new_hook)
async def signoff_callback(event: slack_util.Event, match: Match) -> None:
verb = slack_util.VerboseWrapper(event)
print("Receving a signoff request!!!")
# 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.assignee is not None:
r = fuzz.ratio(signee.name, assign.assignee.name)
if assign.signer is None and r > MIN_RATIO:
return r
# Set the assigner, and notify
async def modifier(context: _ModJobContext):
context.assign.signer = context.signer
# Say we did it wooo!
client.get_slack().reply(event, "Signed off {} for {}".format(context.assign.assignee.name,
context.assign.job.name))
await 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(event, scorer, modifier)
async def undo_callback(event: slack_util.Event, match: Match) -> None:
verb = slack_util.VerboseWrapper(event)
print("Receving an unsignoff request!!!")
# 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 jobs that are signed off
def scorer(assign: house_management.JobAssignment):
if assign.assignee is not None:
r = fuzz.ratio(signee.name, assign.assignee.name)
if assign.signer is not None and r > MIN_RATIO:
return r
# Set the assigner to be None, and notify
async def modifier(context: _ModJobContext):
context.assign.signer = None
# Say we did it wooo!
client.get_slack().reply(event, "Undid signoff of {} for {}".format(context.assign.assignee.name,
context.assign.job.name))
await alert_user(context.assign.assignee, "{} undid your signoff off for {}.\n")
# Fire it off
await _mod_jobs(event, scorer, modifier)
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)
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):
if assign.assignee is not None:
r = fuzz.ratio(signee.name, assign.assignee.name)
if r > MIN_RATIO:
return r
# Just set the assigner
async def modifier(context: _ModJobContext):
context.assign.late = not context.assign.late
# Say we did it
client.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(event, scorer, modifier)
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()
to_name = match.group(2).strip()
# Get them as brothers
from_bro = await verb(scroll_util.find_by_name(from_name, MIN_RATIO))
to_bro = await verb(scroll_util.find_by_name(to_name, MIN_RATIO))
# Score by name similarity to the first brother. Don't care if signed off or not,
# as we want to be able to transfer even after signoffs (why not, amirite?)
def scorer(assign: house_management.JobAssignment):
if assign.assignee is not None:
r = fuzz.ratio(from_bro.name, assign.assignee.name)
if r > MIN_RATIO:
return r
# Change the assignee
async def modifier(context: _ModJobContext):
context.assign.assignee = to_bro
# Say we did it
reassign_msg = "Job {} reassigned from {} to {}".format(context.assign.job.pretty_fmt(),
from_bro,
to_bro)
client.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)
await alert_user(from_bro, reassign_msg)
await alert_user(to_bro, reassign_msg)
# Fire it off
await _mod_jobs(event, scorer, modifier)
# noinspection PyUnusedLocal
async def reset_callback(event: slack_util.Event, match: Match) -> None:
"""
Resets the scores.
"""
# Unassign everything
assigns = await house_management.import_assignments()
for a in assigns:
if a is not None:
a.signer = None
await house_management.export_assignments(assigns)
# Now wipe points
headers, points = await house_management.import_points()
# Set to 0/default
for i in range(len(points)):
new = house_management.PointStatus(brother=points[i].brother)
points[i] = new
house_management.apply_house_points(points, await house_management.import_assignments())
house_management.export_points(headers, points)
client.get_slack().reply(event, "Reset scores and signoffs")
# noinspection PyUnusedLocal
async def refresh_callback(event: slack_util.Event, match: Match) -> None:
print("Received request to reset points")
headers, points = await house_management.import_points()
print("Received request to reset points 1")
house_management.apply_house_points(points, await house_management.import_assignments())
print("Received request to reset points 2")
house_management.export_points(headers, points)
print("Received request to reset points 3")
client.get_slack().reply(event, "Force updated point values")
async def nag_callback(event: slack_util.Event, match: Match) -> None:
# Get the day
day = match.group(1).lower().strip()
if not await nag_jobs(day):
client.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)
# Wrapper so we can auto-call this as well
async def nag_jobs(day_of_week: str) -> bool:
# Get the assigns
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_of_week]
# Filter signed off
assigns = [assign for assign in assigns if assign.signer is None]
# If no jobs found, somethings up. Probably mispelled day. Return failure
if not assigns:
return False
# Nag each
response = "Do yer jerbs! They are as follows:\n"
for assign in assigns:
# Make the row template
if assign.assignee is None:
continue
response += "({}) {} -- {} ".format(assign.job.house, assign.job.name, assign.assignee.name)
# Find the people to @
brother_slack_ids = await 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"
general_id = client.get_slack().get_conversation_by_name("#general").id
client.get_slack().send_message(response, general_id)
return True
signoff_hook = hooks.ChannelHook(signoff_callback,
patterns=[
r"signoff\s+(.*)",
r"sign off\s+(.*)",
],
channel_whitelist=["#housejobs"])
undo_hook = hooks.ChannelHook(undo_callback,
patterns=[
r"unsignoff\s+(.*)",
r"undosignoff\s+(.*)",
r"undo signoff\s+(.*)",
],
channel_whitelist=["#housejobs"])
late_hook = hooks.ChannelHook(late_callback,
patterns=[
r"marklate\s+(.*)",
r"mark late\s+(.*)",
],
channel_whitelist=["#housejobs"])
reset_hook = hooks.ChannelHook(reset_callback,
patterns=[
r"reset signoffs",
r"reset sign offs",
],
channel_whitelist=["#command-center"])
nag_hook = hooks.ChannelHook(nag_callback,
patterns=[
r"nagjobs\s+(.*)",
r"nag jobs\s+(.*)"
],
channel_whitelist=["#command-center"])
reassign_hook = hooks.ChannelHook(reassign_callback,
patterns=r"reassign\s+(.*?)-&gt;\s+(.+)",
channel_whitelist=["#housejobs"])
refresh_hook = hooks.ChannelHook(refresh_callback,
patterns=[
"refresh points",
"update points"
],
channel_whitelist=["#command-center"])
block_action = """
[
{
"type": "actions",
"block_id": "test_block_id",
"elements": [
{
"type": "button",
"action_id": "test_action_id",
"text": {
"type": "plain_text",
"text": "Send payload",
"emoji": false
}
}
]
}
]
"""