Reorganized. Message handling now in theory works
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from typing import Tuple, List, Optional, Any
|
||||
|
||||
import google_api
|
||||
from plugins import scroll_util
|
||||
|
||||
SHEET_ID = "1f9p4H7TWPm8rAM4v_qr2Vc6lBiFNEmR-quTY9UtxEBI"
|
||||
|
||||
# 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 = 80.0
|
||||
|
||||
JOB_VAL = 1
|
||||
LATE_VAL = 0.5
|
||||
MISS_VAL = -1
|
||||
SIGNOFF_VAL = 0.1
|
||||
TOWEL_VAL = 0.1
|
||||
|
||||
# What to put for a non-signed-off job
|
||||
SIGNOFF_PLACEHOLDER = "E-SIGNOFF"
|
||||
NOT_ASSIGNED = "N/A"
|
||||
|
||||
|
||||
@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]
|
||||
|
||||
def pretty_fmt(self) -> str:
|
||||
return "{} - {} at {}".format(self.name, self.day_of_week, self.house)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobAssignment(object):
|
||||
"""
|
||||
Tracks a job's assignment and completion
|
||||
"""
|
||||
job: Job
|
||||
assignee: Optional[scroll_util.Brother]
|
||||
signer: Optional[scroll_util.Brother]
|
||||
late: bool
|
||||
bonus: bool
|
||||
|
||||
def to_raw(self) -> Tuple[str, 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"
|
||||
bonus = "y" if self.bonus else "n"
|
||||
assignee = self.assignee.name if self.assignee else NOT_ASSIGNED
|
||||
return self.job.name, self.job.house, self.job.day_of_week, assignee, signer_name, late, bonus
|
||||
|
||||
|
||||
@dataclass
|
||||
class PointStatus(object):
|
||||
"""
|
||||
Tracks a brothers points
|
||||
"""
|
||||
brother: scroll_util.Brother
|
||||
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, float, float, float, float, float]:
|
||||
# Convert to a row. Also, do some rounding while we're at it
|
||||
def fmt(x: float):
|
||||
return round(x, 2)
|
||||
|
||||
return (self.brother.name,
|
||||
fmt(self.job_points),
|
||||
fmt(self.signoff_points),
|
||||
fmt(self.towel_points),
|
||||
fmt(self.work_party_points),
|
||||
fmt(self.bonus_points),
|
||||
)
|
||||
|
||||
@property
|
||||
def towel_contribution_count(self) -> int:
|
||||
return round(self.towel_points / TOWEL_VAL)
|
||||
|
||||
@towel_contribution_count.setter
|
||||
def towel_contribution_count(self, val: int) -> None:
|
||||
self.towel_points = val * TOWEL_VAL
|
||||
|
||||
|
||||
def strip_all(l: List[str]) -> List[str]:
|
||||
return [x.strip() for x in l]
|
||||
|
||||
|
||||
async 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", "n"])
|
||||
elif len(row) == 5:
|
||||
return strip_all(row + ["n", "n"])
|
||||
elif len(row) == 6:
|
||||
return strip_all(row + ["n"])
|
||||
elif len(row) == 7:
|
||||
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, bonus = 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
|
||||
if assignee is not None and assignee != "" and assignee != NOT_ASSIGNED:
|
||||
try:
|
||||
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)
|
||||
else:
|
||||
assignee = None
|
||||
|
||||
# Find the brother who is currently listed as having signed it off
|
||||
try:
|
||||
if signer == SIGNOFF_PLACEHOLDER:
|
||||
signer = None
|
||||
else:
|
||||
signer = await scroll_util.find_by_name(signer)
|
||||
except scroll_util.BrotherNotFound:
|
||||
# If we can't figure out the name
|
||||
signer = None
|
||||
|
||||
# Make late a bool
|
||||
late = late == "y"
|
||||
|
||||
# Ditto for bonus
|
||||
bonus = bonus == "y"
|
||||
|
||||
# Create the assignment
|
||||
assignment = JobAssignment(job=job, assignee=assignee, signer=signer, late=late, bonus=bonus)
|
||||
|
||||
# Append to job/assignment lists
|
||||
assignments.append(assignment)
|
||||
|
||||
# Git 'em gone
|
||||
return assignments
|
||||
|
||||
|
||||
async def export_assignments(assigns: List[Optional[JobAssignment]]) -> None:
|
||||
# Smash to rows
|
||||
rows = []
|
||||
for v in assigns:
|
||||
if v is None:
|
||||
rows.append([""] * 7)
|
||||
else:
|
||||
rows.append(list(v.to_raw()))
|
||||
|
||||
# Send to google
|
||||
google_api.set_sheet_range(SHEET_ID, job_range, rows)
|
||||
|
||||
|
||||
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))
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# Ensure its the proper length
|
||||
while len(row) < field_count:
|
||||
row: List[Any] = row + [0]
|
||||
|
||||
# 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
|
||||
try:
|
||||
brother = await scroll_util.find_by_name(row[0])
|
||||
except scroll_util.BrotherNotFound:
|
||||
brother = scroll_util.Brother(row[0], scroll_util.MISSINGBRO_SCROLL)
|
||||
|
||||
# Ok! Now, we just map it directly to a PointStatus
|
||||
status = PointStatus(brother, *(row[1:]))
|
||||
return status
|
||||
|
||||
# Perform conversion and return
|
||||
point_statuses = [await 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.
|
||||
Should be called each time we re-export, for validations sake.
|
||||
"""
|
||||
# First, eliminate all house points and signoff points
|
||||
for p in points:
|
||||
p.job_points = 0
|
||||
p.signoff_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:
|
||||
job_score = MISS_VAL
|
||||
else:
|
||||
if a.late:
|
||||
job_score = LATE_VAL
|
||||
else:
|
||||
job_score = JOB_VAL
|
||||
|
||||
# Find the corr bro in points
|
||||
for p in points:
|
||||
# If we find assignee, add the score, but don't stop looking since we also need to find signer
|
||||
if p.brother == a.assignee:
|
||||
p.job_points += job_score
|
||||
|
||||
# If we find the signer, add a signoff reward
|
||||
if p.brother == a.signer:
|
||||
p.signoff_points += SIGNOFF_VAL
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Allows users to register their user account as a specific scroll
|
||||
"""
|
||||
import asyncio
|
||||
import shelve
|
||||
from typing import List, Match
|
||||
|
||||
import hooks
|
||||
from plugins import scroll_util
|
||||
import client
|
||||
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"
|
||||
"i am 666\n"
|
||||
"except with your scroll instead of 666")
|
||||
|
||||
|
||||
async def identify_callback(event: slack_util.Event, match: Match):
|
||||
"""
|
||||
Sets the users scroll
|
||||
"""
|
||||
async with DB_LOCK:
|
||||
with shelve.open(DB_NAME) as db:
|
||||
# Get the query
|
||||
query = match.group(1).strip()
|
||||
|
||||
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
|
||||
client.get_slack().reply(event, result)
|
||||
|
||||
|
||||
async def identify_other_callback(event: slack_util.Event, match: Match):
|
||||
"""
|
||||
Sets another users scroll
|
||||
"""
|
||||
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)
|
||||
|
||||
# Respond
|
||||
client.get_slack().reply(event, result)
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
async def check_callback(event: slack_util.Event, match: Match):
|
||||
"""
|
||||
Replies with the users current scroll assignment
|
||||
"""
|
||||
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
|
||||
client.get_slack().reply(event, result)
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
async def name_callback(event: slack_util.Event, match: Match):
|
||||
"""
|
||||
Tells the user what it thinks the calling users name is.
|
||||
"""
|
||||
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
|
||||
client.get_slack().reply(event, result)
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
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))
|
||||
|
||||
|
||||
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)
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
identify_hook = hooks.ChannelHook(identify_callback, patterns=r"my scroll is (.*)")
|
||||
identify_other_hook = hooks.ChannelHook(identify_other_callback, patterns=r"<@(.*)>\s+has scroll\s+(.*)")
|
||||
check_hook = hooks.ChannelHook(check_callback, patterns=r"what is my scroll")
|
||||
name_hook = hooks.ChannelHook(name_callback, patterns=r"what is my name")
|
||||
@@ -0,0 +1,423 @@
|
||||
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 = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM"
|
||||
|
||||
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:
|
||||
print("Warning: unable to find dm 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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"
|
||||
"Must have been a mistake".format(context.assign.signer.name,
|
||||
context.assign.job.pretty_fmt()))
|
||||
|
||||
# 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 = 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:
|
||||
headers, points = await house_management.import_points()
|
||||
house_management.apply_house_points(points, await house_management.import_assignments())
|
||||
house_management.export_points(headers, points)
|
||||
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+(.*?)->\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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"""
|
||||
@@ -0,0 +1,27 @@
|
||||
from typing import Match, List
|
||||
|
||||
import hooks
|
||||
import client
|
||||
import slack_util
|
||||
|
||||
|
||||
def list_hooks_callback_gen(hooks: List[hooks.ChannelHook]) -> hooks.Callback:
|
||||
# noinspection PyUnusedLocal
|
||||
async def callback(event: slack_util.Event, match: Match) -> None:
|
||||
client.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(event: slack_util.Event, match: Match) -> None:
|
||||
response = "Ok. Rebooting..."
|
||||
client.get_slack().reply(event, response)
|
||||
exit(0)
|
||||
|
||||
|
||||
# Make hooks
|
||||
reboot_hook = hooks.ChannelHook(reboot_callback,
|
||||
patterns=r"reboot",
|
||||
channel_whitelist=["#command-center"])
|
||||
@@ -0,0 +1,137 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
import hooks
|
||||
from plugins import identifier, job_commands, house_management
|
||||
import client
|
||||
|
||||
|
||||
def seconds_until(target: datetime) -> float:
|
||||
curr = datetime.now()
|
||||
delta = target - curr
|
||||
return delta.seconds
|
||||
|
||||
|
||||
class ItsTenPM(hooks.Passive):
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
# Get 10PM
|
||||
ten_pm = datetime.now().replace(hour=22, minute=0, second=0)
|
||||
|
||||
# Find out how long until it, then sleep that long
|
||||
delay = seconds_until(ten_pm)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Crow like a rooster
|
||||
client.get_slack().send_message("IT'S 10 PM!", client
|
||||
.get_slack()
|
||||
.get_conversation_by_name("#random").id)
|
||||
|
||||
# Wait a while before trying it again, to prevent duplicates
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
# Shared behaviour
|
||||
class JobNotifier:
|
||||
@staticmethod
|
||||
def get_day_of_week() -> str:
|
||||
"""
|
||||
Gets the current day of week as a str
|
||||
"""
|
||||
return ["Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday"][datetime.now().weekday()]
|
||||
|
||||
@staticmethod
|
||||
def is_job_valid(a: Optional[house_management.JobAssignment]):
|
||||
# If it doesn't exist, it a thot
|
||||
if a is None:
|
||||
return False
|
||||
# If its not today, we shouldn't nag
|
||||
if a.job.day_of_week.lower() != JobNotifier.get_day_of_week().lower():
|
||||
return False
|
||||
# If it is unassigned, we can't nag
|
||||
if a.assignee is None:
|
||||
return False
|
||||
# If its been signed off, no need to nag
|
||||
if a.signer is not None:
|
||||
return False
|
||||
# If the brother wasn't recognized, don't try nagging
|
||||
if not a.assignee.is_valid():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class NotifyJobs(hooks.Passive, JobNotifier):
|
||||
async def run(self) -> None:
|
||||
while True:
|
||||
# Get the "Start" of the current day (Say, 10AM)
|
||||
today_remind_time = datetime.now().replace(hour=10, minute=00, second=0)
|
||||
|
||||
# Sleep until that time
|
||||
delay = seconds_until(today_remind_time)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Now it is that time. Nag the jobs
|
||||
await job_commands.nag_jobs(self.get_day_of_week())
|
||||
|
||||
# Sleep for a bit to prevent double shots
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
class RemindJobs(hooks.Passive, JobNotifier):
|
||||
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)
|
||||
|
||||
# Sleep until that time
|
||||
delay = seconds_until(today_remind_time)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Now it is that time. Get the current jobs
|
||||
assigns = await house_management.import_assignments()
|
||||
|
||||
# Filter to incomplete, and today
|
||||
assigns: List[house_management.JobAssignment] = [a for a in assigns if self.is_job_valid(a)]
|
||||
|
||||
# Now, we want to nag each person. If we don't actually know who they are, so be it.
|
||||
print("Reminding people who haven't yet done their jobs.")
|
||||
for a in assigns:
|
||||
# Get the relevant slack ids
|
||||
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
|
||||
client.get_slack().send_message(msg, slack_id)
|
||||
|
||||
# Warn on failure
|
||||
if not success:
|
||||
print("Tried to nag {} but couldn't find their slack id".format(a.assignee.name))
|
||||
|
||||
# Take a break to ensure no double-shots
|
||||
await asyncio.sleep(10)
|
||||
|
||||
|
||||
class Updatinator(hooks.Passive):
|
||||
"""
|
||||
Periodically updates the channels and users in the slack
|
||||
"""
|
||||
def __init__(self, wrapper_to_update: client.ClientWrapper, interval_seconds: int):
|
||||
self.wrapper_target = wrapper_to_update
|
||||
self.interval = interval_seconds
|
||||
|
||||
async def run(self):
|
||||
# Give time to warmup
|
||||
while True:
|
||||
self.wrapper_target.update_channels()
|
||||
self.wrapper_target.update_users()
|
||||
await asyncio.sleep(self.interval)
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
This file contains util for scroll polling
|
||||
Only really kept separate for neatness sake.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Match
|
||||
|
||||
from fuzzywuzzy import process
|
||||
|
||||
import hooks
|
||||
import client
|
||||
import slack_util
|
||||
|
||||
# Use this if we can't figure out who a brother actually is
|
||||
MISSINGBRO_SCROLL = -1
|
||||
|
||||
|
||||
@dataclass
|
||||
class Brother(object):
|
||||
"""
|
||||
Represents a brother.
|
||||
"""
|
||||
name: str
|
||||
scroll: int
|
||||
|
||||
def is_valid(self):
|
||||
return self.scroll is not MISSINGBRO_SCROLL
|
||||
|
||||
|
||||
# load the family tree
|
||||
familyfile = open("sortedfamilytree.txt", 'r')
|
||||
|
||||
# Parse out
|
||||
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]
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
# Get the query
|
||||
query = match.group(1).strip()
|
||||
|
||||
# Try to get as int or by name
|
||||
try:
|
||||
sn = int(query)
|
||||
result = find_by_scroll(sn)
|
||||
except ValueError:
|
||||
result = await find_by_name(query)
|
||||
if result:
|
||||
result = "Brother {} has scroll {}".format(result.name, result.scroll)
|
||||
else:
|
||||
result = "Couldn't find brother {}".format(query)
|
||||
|
||||
# Respond
|
||||
client.get_slack().reply(event, result)
|
||||
|
||||
|
||||
def find_by_scroll(scroll: int) -> Optional[Brother]:
|
||||
"""
|
||||
Lookups a brother in the family list, using their scroll.
|
||||
|
||||
:param scroll: The integer scroll to look up
|
||||
:return: The brother, or None
|
||||
"""
|
||||
for b in brothers:
|
||||
if b.scroll == scroll:
|
||||
return b
|
||||
return None
|
||||
|
||||
|
||||
# Used to track a sufficiently shitty typed name
|
||||
class BrotherNotFound(Exception):
|
||||
"""Throw when we can't find the desired brother."""
|
||||
pass
|
||||
|
||||
|
||||
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
|
||||
:raises BrotherNotFound:
|
||||
:return: The best-match brother
|
||||
""" # Get all of the names
|
||||
all_names = [b.name for b in brothers]
|
||||
|
||||
# Do fuzzy match
|
||||
found, score = process.extractOne(name, all_names)
|
||||
found_index = all_names.index(found)
|
||||
found_brother = brothers[found_index]
|
||||
if (not threshold) or score > threshold:
|
||||
return found_brother
|
||||
else:
|
||||
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 = hooks.ChannelHook(scroll_callback, patterns=r"scroll\s+(.*)")
|
||||
@@ -0,0 +1,97 @@
|
||||
import re
|
||||
import textwrap
|
||||
from typing import Match
|
||||
|
||||
import hooks
|
||||
from plugins import house_management
|
||||
import client
|
||||
import slack_util
|
||||
from plugins.scroll_util import Brother
|
||||
|
||||
counted_data = ["flaked", "rolled", "replaced", "washed", "dried"]
|
||||
lookup_format = "{}\s+(\d+)"
|
||||
|
||||
|
||||
def fmt_work_dict(work_dict: dict) -> str:
|
||||
return ",\n".join(["{} × {}".format(job, count) for job, count in sorted(work_dict.items())])
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
async def count_work_callback(event: slack_util.Event, match: Match) -> None:
|
||||
# If no user, continue
|
||||
if event.user is None:
|
||||
return
|
||||
|
||||
# Make an error wrapper
|
||||
verb = slack_util.VerboseWrapper(event)
|
||||
|
||||
# Tidy the text
|
||||
text = event.message.text.strip()
|
||||
|
||||
# Couple things to work through.
|
||||
# One: Who sent the message?
|
||||
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?
|
||||
new_work = {}
|
||||
for job in counted_data:
|
||||
pattern = lookup_format.format(job)
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
new_work[job] = int(match.group(1))
|
||||
|
||||
# Three: check if we found anything
|
||||
if len(new_work) == 0:
|
||||
if re.search(r'\s\d\s', text) is not None:
|
||||
client.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
|
||||
contribution_count = sum(new_work.values())
|
||||
new_total = await verb(record_towel_contribution(who_wrote, contribution_count))
|
||||
|
||||
# Five, congratulate them on their work!
|
||||
congrats = textwrap.dedent("""{} recorded work:
|
||||
{}
|
||||
Net increase in points: {}
|
||||
Total points since last reset: {}""".format(who_wrote_label,
|
||||
fmt_work_dict(new_work),
|
||||
contribution_count,
|
||||
new_total))
|
||||
client.get_slack().reply(event, congrats)
|
||||
|
||||
|
||||
async def record_towel_contribution(for_brother: Brother, contribution_count: int) -> int:
|
||||
"""
|
||||
Grants <count> contribution point to the specified user.
|
||||
Returns the new total.
|
||||
"""
|
||||
# Import house points
|
||||
headers, points = await house_management.import_points()
|
||||
|
||||
# Find the brother
|
||||
for p in points:
|
||||
if p is None or p.brother != for_brother:
|
||||
continue
|
||||
|
||||
# If found, mog with more points
|
||||
p.towel_contribution_count += contribution_count
|
||||
|
||||
# Export
|
||||
house_management.export_points(headers, points)
|
||||
|
||||
# Return the new total
|
||||
return p.towel_contribution_count
|
||||
|
||||
# If not found, get mad!
|
||||
raise KeyError("No score entry found for brother {}".format(for_brother))
|
||||
|
||||
|
||||
# Make dem HOOKs
|
||||
count_work_hook = hooks.ChannelHook(count_work_callback,
|
||||
patterns=".*",
|
||||
channel_whitelist=["#slavestothemachine"],
|
||||
consumer=False)
|
||||
Reference in New Issue
Block a user