Reorganized. Message handling now in theory works

This commit is contained in:
Jacob Henry
2019-03-03 00:44:37 -05:00
parent b6f08df6eb
commit 8191231649
13 changed files with 688 additions and 661 deletions
View File
+301
View File
@@ -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
+141
View File
@@ -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")
+423
View File
@@ -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+(.*?)-&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
}
}
]
}
]
"""
+27
View File
@@ -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"])
+137
View File
@@ -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)
+111
View File
@@ -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+(.*)")
+97
View File
@@ -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)