Semi-stable rework of signoff procedure

This commit is contained in:
Jacob Henry 2018-11-12 12:30:10 -05:00
parent 5ae139fde0
commit 716ec52a98
10 changed files with 657 additions and 456 deletions

115
client_wrapper.py Normal file
View File

@ -0,0 +1,115 @@
import asyncio
from typing import List, Any, AsyncGenerator
from slackclient import SlackClient # Obvious
import channel_util
import slack_util
from dummy import FakeClient
# Read the API token
api_file = open("apitoken.txt", 'r')
SLACK_API = next(api_file).strip()
api_file.close()
# Enable to use dummy
DEBUG_MODE = False
class ClientWrapper(object):
"""
Essentially the main state object.
We only ever expect one of these.
Holds a slack client, and handles messsages.
"""
def __init__(self):
# Init slack
if DEBUG_MODE:
self.slack = FakeClient()
else:
self.slack = SlackClient(SLACK_API)
# Hooks go regex -> callback on (slack, msg, match)
self.hooks: List[slack_util.AbsHook] = []
# Periodicals are just wrappers around an iterable, basically
self.passives: List[slack_util.Passive] = []
# Scheduled events handling
def add_passive(self, per: slack_util.Passive) -> None:
self.passives.append(per)
async def run_passives(self) -> None:
# Make a task to repeatedly spawn each event
awaitables = [p.run(self.slack) for p in self.passives]
await asyncio.gather(*awaitables)
# Message handling
def add_hook(self, hook: slack_util.AbsHook) -> None:
self.hooks.append(hook)
async def respond_messages(self) -> None:
"""
Asynchronous tasks that eternally reads and responds to messages.
"""
async for _ in self.spool_tasks():
print("Handling a message...!")
async def spool_tasks(self) -> AsyncGenerator[asyncio.Task, Any]:
async for msg in self.async_message_feed():
# Preprocess msg
# We only care about standard messages, not subtypes, as those usually just channel activity
if msg.get("subtype") is not None:
continue
# Never deal with general, EVER!
if msg.get("channel") == channel_util.GENERAL:
continue
# Strip garbage
msg['text'] = msg['text'].strip()
print("Recv: \"{}\"".format(msg['text']))
# Msg is good
# Find which hook, if any, satisfies
for hook in self.hooks:
# Try invoking each
try:
# Try to make a coroutine handling the message
coro = hook.try_apply(self.slack, msg)
# If we get a coro back, then task it up and set consumption appropriately
if coro is not None:
print("Spawned task")
yield asyncio.create_task(coro)
if hook.consumes:
break
except slack_util.DeadHook:
# If a hook wants to die, let it.
self.hooks.remove(hook)
print("Done spawning tasks")
async def async_message_feed(self) -> AsyncGenerator[dict, None]:
"""
Async wrapper around the message feed.
Yields messages awaitably forever.
"""
# Create the msg feed
feed = slack_util.message_stream(self.slack)
# Create a simple callable that gets one message from the feed
def get_one():
return next(feed)
# Continuously yield async threaded tasks that poll the feed
while True:
yield await asyncio.get_running_loop().run_in_executor(None, get_one)
_singleton = ClientWrapper()
def get_client_wrapper() -> ClientWrapper:
return _singleton

270
house_management.py Normal file
View File

@ -0,0 +1,270 @@
import dataclasses
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Tuple, List, Optional, Any
import google_api
import scroll_util
SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM"
# Note: These ranges use named range feature of google sheets.
# To edit range of jobs, edit the named range in Data -> Named Ranges
job_range = "AllJobs" # Note that the first row is headers
point_range = "PointRange"
# How tolerant of spelling errors in names to be
SHEET_LOOKUP_THRESHOLD = 0.9
JOB_VAL = 1
LATE_VAL = 0.5
MISS_VAL = -1
SIGNOFF_VAL = 0.1
# What to put for a non-signed-off job
SIGNOFF_PLACEHOLDER = "E-SIGNOFF"
@dataclass
class Job(object):
"""
Represents a job in a more internally meaningful way.
"""
name: str
house: str
day_of_week: str
# Extra stuff, interpreted
day: Optional[date]
@dataclass
class JobAssignment(object):
"""
Tracks a job's assignment and completion
"""
job: Job
assignee: scroll_util.Brother
signer: Optional[scroll_util.Brother]
late: bool
def to_raw(self) -> Tuple[str, str, str, str, str, str]:
# Converts this back into a spreadsheet row
signer_name = self.signer.name if self.signer is not None else SIGNOFF_PLACEHOLDER
late = "y" if self.late else "n"
return self.job.name, self.job.house, self.job.day_of_week, self.assignee.name, signer_name, late
@dataclass
class PointStatus(object):
"""
Tracks a brothers points
"""
brother_raw: str
brother: scroll_util.Brother # Interpereted
job_points: float = 0
signoff_points: float = 0
towel_points: float = 0
work_party_points: float = 0
bonus_points: float = 0
def to_raw(self) -> Tuple[str, str, str, str, str, str]:
# Convert to a row. Also, do some rounding while we're at it
fstring = "{:.2f}"
return (self.brother_raw,
fstring.format(self.job_points),
fstring.format(self.signoff_points),
fstring.format(self.towel_points),
fstring.format(self.work_party_points),
fstring.format(self.bonus_points),
)
def strip_all(l: List[str]) -> List[str]:
return [x.strip() for x in l]
def import_assignments() -> List[Optional[JobAssignment]]:
"""
Imports Jobs and JobAssignments from the sheet. 1:1 row correspondence.
"""
# Get the raw data
job_rows = google_api.get_sheet_range(SHEET_ID, job_range)
# None-out invalid rows (length not at least 4, which includes the 4 most important features)
def fixer(row):
if len(row) == 4:
return strip_all(row + [SIGNOFF_PLACEHOLDER, "n"])
elif len(row) == 5:
return strip_all(row + "n")
elif len(row) == 6:
return strip_all(row)
else:
return None
# Apply the fix
job_rows = [fixer(row) for row in job_rows]
# Now, create jobs
assignments = []
for row in job_rows:
if row is None:
assignments.append(None)
else:
# Breakout list
job_name, location, day, assignee, signer, late = row
# Figure out when the day actually is, in terms of the date class
day_rank = {
"monday": 0,
"tuesday": 1,
"wednesday": 2,
"thursday": 3,
"friday": 4,
"saturday": 5,
"sunday": 6
}.get(day.lower(), None)
if day_rank is not None:
# Figure out current date day of week, and extrapolate the jobs day of week from there
today = date.today()
today_rank = today.weekday()
days_till = day_rank - today_rank
if days_till <= 0:
days_till += 7
# Now we know what day it is!
job_day = today + timedelta(days=days_till)
else:
# Can't win 'em all
job_day = None
# Create the job
job = Job(name=job_name, house=location, day_of_week=day, day=job_day)
# Now make an assignment for the job
# Find the brother it is assigned to
try:
assignee = scroll_util.find_by_name(assignee, SHEET_LOOKUP_THRESHOLD)
except scroll_util.BadName:
# If we can't get one close enough, make a dummy
assignee = scroll_util.Brother(assignee, scroll_util.MISSINGBRO_SCROLL)
# Find the brother who is currently listed as having signed it off
try:
if signer == SIGNOFF_PLACEHOLDER:
signer = None
else:
signer = scroll_util.find_by_name(signer)
except scroll_util.BadName:
# If we can't figure out the name
signer = None
# Make late a bool
late = late == "y"
# Create the assignment
assignment = JobAssignment(job=job, assignee=assignee, signer=signer, late=late)
# Append to job/assignment lists
assignments.append(assignment)
# Git 'em gone
return assignments
def export_assignments(assigns: List[Optional[JobAssignment]]) -> None:
# Smash to rows
rows = []
for v in assigns:
if v is None:
rows.append([""] * 6)
else:
rows.append(list(v.to_raw()))
# Send to google
google_api.set_sheet_range(SHEET_ID, job_range, rows)
def import_points() -> (List[str], List[PointStatus]):
# Figure out how many things there are in a point status
field_count = len(dataclasses.fields(PointStatus))
# Get the raw data
point_rows = google_api.get_sheet_range(SHEET_ID, point_range)
# Get the headers
headers = point_rows[0]
point_rows = point_rows[1:]
# Tidy rows up
def converter(row: List[Any]) -> Optional[PointStatus]:
# If its too long, or empty already, ignore
if len(row) == 0 or len(row) > field_count:
return None
# Ensure its the proper length
row: List[Any] = row + ([0] * (field_count - len(row) - 1))
# Ensure all past the first column are float. If can't convert, make 0
for i in range(1, len(row)):
try:
x = float(row[i])
except ValueError:
x = 0
row[i] = x
# Get the brother for the last item
real_brother = scroll_util.find_by_name(row[0], recent_only=True)
# Ok! Now, we just map it directly to a PointStatus
status = PointStatus(row[0], real_brother, *(row[1:]))
return status
# Perform conversion and return
point_statuses = [converter(row) for row in point_rows]
return headers, point_statuses
def export_points(headers: List[str], points: List[PointStatus]) -> None:
# Smash to rows
rows = [list(point_status.to_raw()) for point_status in points]
rows = [headers] + rows
# Send to google
google_api.set_sheet_range(SHEET_ID, point_range, rows)
def apply_house_points(points: List[PointStatus], assigns: List[Optional[JobAssignment]]):
"""
Modifies the points list to reflect job assignment scores.
Destroys existing values in the column
"""
# First, eliminate all house points
for p in points:
p.job_points = 0
# Then, apply each assign
for a in assigns:
# Ignore null assigns
if a is None:
continue
# What modifier should this have?
if a.signer is None:
score = MISS_VAL
else:
if a.late:
score = LATE_VAL
else:
score = JOB_VAL
# Find the corr bro in points
for p in points:
# If we find, add the score and stop looking
if p.brother == a.assignee:
p.job_points += score
break
if p.brother == a.signer:
p.signoff_points += SIGNOFF_VAL

203
job_commands.py Normal file
View File

@ -0,0 +1,203 @@
from typing import List, Match, Callable, TypeVar, Optional, Iterable
from fuzzywuzzy import fuzz
from slackclient import SlackClient
import channel_util
import house_management
import identifier
import scroll_util
import slack_util
SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM"
MIN_RATIO = 0.9
def alert_user(slack: SlackClient, brother: scroll_util.Brother, saywhat: str) -> None:
"""
DM a brother saying something
"""
# We do this as a for loop just in case multiple people reg. to same scroll for some reason (e.g. dup accounts)
for slack_id in identifier.lookup_brother_userids(brother):
dm_id = slack_util.im_channel_for_id(slack, slack_id)
if dm_id:
# Give a dummy msg dict, since we won't actually be using anything in it
slack_util.send_message(slack, saywhat, dm_id)
else:
print("Warning: unable to find dm for brother {}".format(brother))
T = TypeVar("T")
def tiemax(items: Iterable[T], key: Callable[[T], float], min_score: Optional[float] = None) -> List[T]:
best = []
best_score = min_score
for elt in items:
score = key(elt)
if best_score is None or score > best_score:
best_score = score
best = [elt]
elif score == best_score:
best.append(elt)
return best
def do_signoff(slack: SlackClient, msg: dict, on_assign_index: int, by_brother: scroll_util.Brother) -> None:
# First things first: Get the signoffs
assignments = house_management.import_assignments()
# Get the one we want
on_assign = assignments[on_assign_index]
# Modify it
on_assign.signer = by_brother
# Put them all back
house_management.export_assignments(assignments)
# Then we update points for house jobs
headers, points = house_management.import_points()
house_management.apply_house_points(points, assignments)
house_management.export_points(headers, points)
# Then we respond cool!
slack_util.reply(slack, msg, "{} signed off {} for {} {} {}".format(on_assign.signer.name,
on_assign.assignee.name,
on_assign.job.house,
on_assign.job.day_of_week,
on_assign.job.name))
alert_user(slack, on_assign.assignee, "Your house job was signed off")
async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None:
"""
Callback to signoff a user.
"""
# Find out who this is
signee_name = match.group(1)
# Fix with a quick lookup
try:
signee = scroll_util.find_by_name(signee_name, MIN_RATIO, recent_only=True)
except scroll_util.BadName as e:
slack_util.reply(slack, msg, e.as_response())
return
# Also, who just signed us off?
signer = identifier.lookup_msg_brother(msg)
# Get all of the assignments
assigns = house_management.import_assignments()
# Find closest assignment to what we're after
def scorer(a: Optional[house_management.JobAssignment]) -> float:
if a is None:
return 0
else:
return fuzz.ratio(signee.name, a.assignee.name)
closest_assigns = tiemax(assigns, key=scorer)
# Remove those that are already signed off
closest_assigns = [c for c in closest_assigns if c is not None]
closest_assigns = [c for c in closest_assigns if c.signer is None]
# If there aren't any jobs, say so
if len(closest_assigns) == 0:
slack_util.reply(slack, msg, "Unable to find any jobs assigned to brother {} "
"(identified as {}).".format(signee_name, signee.name))
return
# If theres only one job, sign it off
elif len(closest_assigns) == 1:
targ_assign = closest_assigns[0]
# Where is it?
targ_assign_index = assigns.index(targ_assign)
do_signoff(slack, msg, targ_assign_index, signer)
return
# If theres multiple jobs, we need to get a follow up!
else:
slack_util.reply(slack, msg, "Dunno how to handle multiple jobs yet")
return
# noinspection PyUnusedLocal
async def reset_callback(slack: SlackClient, msg: dict, match: Match) -> None:
"""
Resets the scores.
"""
# Get curr rows
headers, points = house_management.import_points()
# Set to 0/default
for i in range(len(points)):
new = house_management.PointStatus(brother_raw=points[i].brother_raw, brother=points[i].brother)
points[i] = new
house_management.export_points(headers, points)
# Now unsign everything
assigns = house_management.import_assignments()
for a in assigns:
if a is not None:
a.signer = None
house_management.export_assignments(assigns)
slack_util.reply(slack, msg, "Reset scores and signoffs")
async def nag_callback(slack, msg, match):
# Get the day
day = match.group(1).lower().strip()
# Get the assigns
assigns = house_management.import_assignments()
# Filter to day
assigns = [assign for assign in assigns if assign.job.day_of_week.lower() == day]
# Filter signed off
assigns = [assign for assign in assigns if assign.signer is None]
# If no jobs found, somethings up. Probably mispelled day.
if not assigns:
slack_util.reply(slack, msg, "No jobs found. Check that the day is spelled correctly, with no extra symbols.\n"
"It is possible that all jobs have been signed off, as well.",
in_thread=True)
return
# Nag each
response = "Do yer jerbs! They are as follows:\n"
for assign in assigns:
# Make the row template
response += "({}) {} -- {} ".format(assign.job.house, assign.job.name, assign.assignee.name)
# Find the people to @
brother_slack_ids = identifier.lookup_brother_userids(assign.assignee)
if brother_slack_ids:
for slack_id in brother_slack_ids:
response += "<@{}> ".format(slack_id)
else:
response += "(scroll missing. Please register for @ pings!)"
response += "\n"
slack_util.reply(slack, msg, response, in_thread=False, to_channel=channel_util.BOTZONE)
signoff_hook = slack_util.Hook(signoff_callback,
pattern=r"testsignoff\s+(.*)",
channel_whitelist=[channel_util.HOUSEJOBS])
reset_hook = slack_util.Hook(reset_callback,
pattern=r"testreset signoffs",
channel_whitelist=[channel_util.COMMAND_CENTER_ID]) # COMMAND_CENTER_ID
nag_hook = slack_util.Hook(nag_callback,
pattern=r"nagjobs\s*(.*)",
channel_whitelist=[channel_util.COMMAND_CENTER_ID])

View File

@ -1,86 +0,0 @@
import identifier
import scroll_util
import slack_util
import google_api
import channel_util
SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM"
# Note: These ranges use named range feature of google sheets.
# To edit range of jobs, edit the named range in Data -> Named Ranges
eight_job_range = "EightJobs" # Format: Job Day Bro
fiftythree_job_range = "FiftyThreeJobs"
class Job(object):
"""
Object representing a house job
"""
def __init__(self, house: str, job_name: str, day: str, brother_name: str):
self.house, self.job_name, self.day, self.brother_name = house, job_name, day, brother_name
self.day = self.day.lower().strip()
def get_jobs(day=None):
"""
Retrieves the house jobs for a given day.
If no day is provided, returns all house jobs for the week.
:param day: Day to compare the day column to. If equal, keep. If Not, discard. If day==None, ignore this filter
:return: list of Job objects
"""
# Get the spreadsheet section
eight_jobs = google_api.get_sheet_range(SHEET_ID, eight_job_range)
ft_jobs = google_api.get_sheet_range(SHEET_ID, fiftythree_job_range)
# Turn to job objects
def valid_row(x):
try:
return len(x) == 3
except (AttributeError, TypeError):
return False
eight_jobs = [Job("8", *r) for r in eight_jobs if valid_row(r)]
ft_jobs = [Job("53", *r) for r in ft_jobs if valid_row(r)]
jobs = eight_jobs + ft_jobs
# Filter to day
if day:
jobs = [j for j in jobs if j.day == day]
return jobs
async def nag_callback(slack, msg, match):
# Get the day
day = match.group(1).lower().strip()
jobs = get_jobs(day)
# If no jobs found, somethings up. Probably mispelled day.
if not jobs:
slack_util.reply(slack, msg, "No jobs found. Check that the day is spelled correctly, with no extra symbols",
in_thread=True)
return
# Nag each
response = "Do yer jerbs! They are as follows:\n"
for job in jobs:
# Make the row template
response += "({}) {} -- ".format(job.house, job.job_name)
# Find the people to @
brother = scroll_util.find_by_name(job.brother_name)
brother_slack_ids = identifier.lookup_brother_userids(brother)
if brother_slack_ids:
for slack_id in brother_slack_ids:
response += "<@{}> ".format(slack_id)
else:
response += "{} (scroll missing. Please register for @ pings!)".format(job.brother_name)
response += "\n"
slack_util.reply(slack, msg, response, in_thread=False, to_channel=channel_util.GENERAL)
nag_hook = slack_util.Hook(nag_callback, pattern=r"nagjobs\s*(.*)", channel_whitelist=[channel_util.COMMAND_CENTER_ID])

View File

@ -1,199 +0,0 @@
from typing import List, Any, Match, Tuple
from fuzzywuzzy import process
from slackclient import SlackClient
import channel_util
import google_api
import identifier
import job_nagger
import slack_util
import scroll_util
SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM"
# Note: These ranges use named range feature of google sheets.
# To edit range of jobs, edit the named range in Data -> Named Ranges in the Goole Sheets page
output_range = "JobScoreTrackerV2"
MIN_RATIO = 0.9
SIGNOFF_REWARD = 0.1
def get_curr_points() -> List[Tuple[str, float, str]]:
"""
:return: The current contents of the output range
"""
# Get the current stuff
curr_output = google_api.get_sheet_range(SHEET_ID, output_range)
all_jobs = job_nagger.get_jobs()
# Each element: force length of 3. Fmt name, score, job
def row_fixer(r: List[Any]) -> Tuple[str, float, str]:
if len(r) == 0:
return "", 0, "No Job"
else:
# Get the appropriate score
if len(r) > 1:
try:
curr_score = float(r[1])
except ValueError:
curr_score = 0
else:
curr_score = 0
# Find the current job
job = "No Job"
for j in all_jobs:
if j.brother_name == r[0]:
job = "{} ({} - {})".format(j.job_name, j.day, j.house)
break
return r[0], curr_score, job
# Fix each row
curr_output = [row_fixer(x) for x in curr_output]
# Cut off the chaff - doesn't seem to be necessary.
return curr_output
def put_points(vals: List[Tuple[str, float, str]]) -> None:
# Turn the tuples to lists
vals = [list(row) for row in vals]
google_api.set_sheet_range(SHEET_ID, output_range, vals)
def alert_user(slack: SlackClient, name: str, saywhat: str) -> None:
"""
DM a brother saying something
"""
brother_dict = scroll_util.find_by_name(name)
# We do this as a for loop just in case multiple people reg. to same scroll for some reason (e.g. dup accounts)
for slack_id in identifier.lookup_brother_userids(brother_dict):
dm_id = slack_util.im_channel_for_id(slack, slack_id)
if dm_id:
# Give a dummy msg dict, since we won't actually be using anything in it
slack_util.reply(slack, {}, saywhat, to_channel=dm_id, in_thread=False)
else:
print("Warning: unable to find dm for brother {}".format(brother_dict))
async def signoff_callback(slack: SlackClient, msg: dict, match: Match) -> None:
"""
Callback to signoff a user.
"""
# Find the index of our person.
name = match.group(1)
# Also, who just signed us off?
signer = identifier.lookup_msg_brother(msg).name
# Try giving the person a point
try:
(bro_name, bro_total, bro_job), (ass_name, ass_total, _) = adjust_scores((name, 1), (signer, SIGNOFF_REWARD))
slack_util.reply(slack, msg, "Gave {} one housejob point for job {}.\n"
"They now have {} for this period.\n"
"You ({}) were credited with the signoff".format(bro_name, bro_job, bro_total, ass_name))
alert_user(slack, bro_name,
"You, who we believe to be {}, just had your house job signed off by {}!".format(bro_name, ass_name))
except scroll_util.BadName as e:
# We didn't find a name - no action was performed.
slack_util.reply(slack, msg, e.as_response())
async def punish_callback(slack: SlackClient, msg: dict, match: Match) -> None:
"""
Undoes a signoff. Maybe should rename
"""
# Find the index of our person.
name = match.group(2)
# Also, who just signed us off?
signer = identifier.lookup_msg_brother(msg).name
# Try giving the person a point
try:
(bro_name, bro_total, _), (ass_name, ass_total, _) = adjust_scores((name, -1), (signer, -SIGNOFF_REWARD))
slack_util.reply(slack, msg, "Took one housejob point from {}.\n"
"They now have {} for this period.\n"
"Under the assumption that this was to undo a mistake, we have deducted the "
"usual signoff reward from you, ({}).\n "
"You can easily earn it back by signing off the right person ;).".format(bro_name,
bro_total,
ass_name))
alert_user(slack, bro_name,
"You, who we believe to be {}, just had your house job UN-signed off by {}.\n"
"Perhaps the asshoman made a mistake when they first signed you off.\n"
"If you believe that they undid the signoff accidentally, go talk to them".format(bro_name, signer))
except scroll_util.BadName as e:
# We didn't find a name - no action was performed.
slack_util.reply(slack, msg, e.as_response())
# noinspection PyUnusedLocal
async def reset_callback(slack: SlackClient, msg: dict, match: Match) -> None:
"""
Resets the scores.
"""
# Get curr rows
points = get_curr_points()
new_points = [(a, 0, c) for a, _, c in points]
put_points(new_points)
slack_util.reply(slack, msg, "Reset scores")
def adjust_scores(*name_delta_tuples: Tuple[str, float]) -> List[Tuple[str, float, str]]:
"""
Helper that uses a sequence of tuples in the format (name, delta) to adjust each (name) to have +delta score.
Operation performed as a batch.
:param name_delta_tuples: The name + score deltas
:return: The updated tuples rows.
"""
# Get the current stuff
points = get_curr_points()
names = [p[0] for p in points]
modified_user_indexes = []
for name, delta in name_delta_tuples:
# Find our guy
target_name, ratio = process.extractOne(name, names)
ratio = ratio / 100.0
# If bad ratio, error
if ratio < MIN_RATIO:
raise scroll_util.BadName(target_name, ratio, MIN_RATIO)
# Where is he in the list?
target_index = names.index(target_name)
# Get his current score
curr_score = points[target_index][1]
curr_job = points[target_index][2]
# target should be in the form index, (name, score)
target_new = target_name, curr_score + delta, curr_job
# Put it back
points[target_index] = target_new
# Record where we edited
modified_user_indexes.append(target_index)
# Push all to sheets if exit loop without error
put_points(points)
# Conver indexes to rows, then return the adjusted name/score_tuples
return [points[i] for i in modified_user_indexes]
signoff_hook = slack_util.Hook(signoff_callback,
pattern=r"signoff\s+(.*)",
channel_whitelist=[channel_util.HOUSEJOBS])
undosignoff_hook = slack_util.Hook(punish_callback,
pattern=r"(unsignoff|undosignoff|undo)\s+(.*)",
channel_whitelist=[channel_util.HOUSEJOBS])
reset_hook = slack_util.Hook(reset_callback,
pattern=r"reset signoffs",
channel_whitelist=[channel_util.COMMAND_CENTER_ID])

130
main.py
View File

@ -1,26 +1,15 @@
import asyncio
from typing import List, Any, AsyncGenerator
from slackclient import SlackClient # Obvious
import channel_util
import identifier
import job_nagger
import job_signoff
import job_commands
import management_commands
import periodicals
import scroll_util
import slack_util
import slavestothemachine
from dummy import FakeClient
# Read api token from file
api_file = open("apitoken.txt", 'r')
SLACK_API = next(api_file).strip()
api_file.close()
# Enable to use dummy
DEBUG_MODE = False
from client_wrapper import ClientWrapper
def main() -> None:
@ -39,7 +28,7 @@ def main() -> None:
wrap.add_hook(channel_util.channel_check_hook)
# Add nagging functionality
wrap.add_hook(job_nagger.nag_hook)
wrap.add_hook(job_commands.nag_hook)
# Add kill switch
wrap.add_hook(management_commands.reboot_hook)
@ -49,127 +38,24 @@ def main() -> None:
wrap.add_hook(slavestothemachine.dump_work_hook)
# Add signoffs
wrap.add_hook(job_signoff.signoff_hook)
wrap.add_hook(job_signoff.undosignoff_hook)
wrap.add_hook(job_signoff.reset_hook)
wrap.add_hook(job_commands.signoff_hook)
wrap.add_hook(job_commands.reset_hook)
# Add help
help_callback = management_commands.list_hooks_callback_gen(wrap.hooks)
wrap.add_hook(slack_util.Hook(help_callback, pattern=management_commands.bot_help_pattern))
# help_callback = management_commands.list_hooks_callback_gen(wrap.hooks)
# wrap.add_hook(slack_util.Hook(help_callback, pattern=management_commands.bot_help_pattern))
# Add boozebot
wrap.add_passive(periodicals.ItsTenPM())
event_loop = asyncio.get_event_loop()
event_loop.set_debug(True)
message_handling = wrap.respond_messages()
passive_handling = wrap.run_passives()
both = asyncio.gather(message_handling, passive_handling)
event_loop.run_until_complete(both)
class ClientWrapper(object):
"""
Essentially the main state object.
We only ever expect one of these.
Holds a slack client, and handles messsages.
"""
def __init__(self):
# Init slack
if DEBUG_MODE:
self.slack = FakeClient()
else:
self.slack = SlackClient(SLACK_API)
# For overriding output channel
self.debug_slack = slack_util.SlackDebugCondom(self.slack)
# Hooks go regex -> callback on (slack, msg, match)
self.hooks: List[slack_util.Hook] = []
# Periodicals are just wrappers around an iterable, basically
self.passives: List[slack_util.Passive] = []
# Scheduled events handling
def add_passive(self, per: slack_util.Passive) -> None:
self.passives.append(per)
async def run_passives(self) -> None:
# Make a task to repeatedly spawn each event
awaitables = [p.run(self.slack) for p in self.passives]
await asyncio.gather(*awaitables)
# Message handling
def add_hook(self, hook: slack_util.Hook) -> None:
self.hooks.append(hook)
async def respond_messages(self) -> None:
"""
Asynchronous tasks that eternally reads and responds to messages.
"""
async for _ in self.spool_tasks():
print("Handling a message...!")
async def spool_tasks(self) -> AsyncGenerator[asyncio.Task, Any]:
async for msg in self.async_message_feed():
# Preprocess msg
# We only care about standard messages, not subtypes, as those usually just channel activity
if msg.get("subtype") is not None:
continue
# Never deal with general, EVER!
if msg.get("channel") == channel_util.GENERAL:
continue
# Strip garbage
msg['text'] = msg['text'].strip()
print("Recv: \"{}\"".format(msg['text']))
# Handle debug
if msg['text'][:6] == "DEBUG ":
slack_to_use = self.debug_slack
msg['text'] = msg['text'][6:]
print("Debug handling \"{}\"".format(msg['text']))
else:
slack_to_use = self.slack
# Msg is good
# Find which hook, if any, satisfies
sat_hook = None
sat_match = None
for hook in self.hooks:
match = hook.check(msg)
if match is not None:
sat_match = match
sat_hook = hook
break
# If no hooks, continue
if not sat_hook:
continue
# Throw up as a task, otherwise
coro = sat_hook.invoke(slack_to_use, msg, sat_match)
task = asyncio.create_task(coro)
yield task
async def async_message_feed(self) -> AsyncGenerator[dict, None]:
"""
Async wrapper around the message feed.
Yields messages awaitably forever.
"""
# Create the msg feed
feed = slack_util.message_stream(self.slack)
# Create a simple callable that gets one message from the feed
def get_one():
return next(feed)
# Continuously yield async threaded tasks that poll the feed
while True:
yield await asyncio.get_running_loop().run_in_executor(None, get_one)
# run main
if __name__ == '__main__':
main()

View File

@ -24,7 +24,7 @@ class ItsTenPM(slack_util.Passive):
await asyncio.sleep(delay)
# Crow like a rooster
slack_util.reply(slack, {}, "IT'S 10 PM!", in_thread=False, to_channel=channel_util.RANDOM)
slack_util.send_message(slack, "IT'S 10 PM!", channel_util.RANDOM)
# Wait a while before trying it again, to prevent duplicates
await asyncio.sleep(60)

View File

@ -4,6 +4,7 @@ Only really kept separate for neatness sake.
"""
import re
from dataclasses import dataclass
from typing import List, Optional, Match
from fuzzywuzzy import process
@ -11,17 +12,17 @@ from slackclient import SlackClient
import slack_util
# Use this if we can't figure out who a brother actually is
MISSINGBRO_SCROLL = 0
@dataclass
class Brother(object):
"""
Represents a brother.
"""
def __init__(self, name: str, scroll: int):
self.name = name
self.scroll = scroll
def __repr__(self):
return "<Brother {}-{}>".format(self.name, self.scroll)
name: str
scroll: int
# load the family tree
@ -32,7 +33,7 @@ brother_match = re.compile(r"([0-9]*)~(.*)")
brothers_matches = [brother_match.match(line) for line in familyfile]
brothers_matches = [m for m in brothers_matches if m]
brothers: List[Brother] = [Brother(m.group(2), int(m.group(1))) for m in brothers_matches]
recent_brothers: List[Brother] = brothers[700:]
async def scroll_callback(slack: SlackClient, msg: dict, match: Match) -> None:
"""
@ -81,23 +82,32 @@ class BadName(Exception):
"match ratio {}. Please type name better.".format(self.name, self.score, self.threshold)
def find_by_name(name: str, threshold: Optional[float] = None) -> Brother:
def find_by_name(name: str, threshold: Optional[float] = None, recent_only: bool=False) -> Brother:
"""
Looks up a brother by name. Raises exception if threshold provided and not met.
:param threshold: Minimum match ratio to accept. Can be none.
:param name: The name to look up, with a fuzzy search
:param recent_only: Whether or not to only search more recent undergrad brothers.
:return: The best-match brother
"""
# Really quikly mog name into Brother, so the processor function works fine
name_bro = Brother(name, -1)
# Pick a list
if recent_only:
bros_to_use = recent_brothers
else:
bros_to_use = brothers
# Get all of the names
all_names = [b.name for b in bros_to_use]
# Do fuzzy match
found = process.extractOne(name_bro, brothers, processor=lambda b: b.name)
if (not threshold) or found[1] > threshold:
return found[0]
found, score = process.extractOne(name, all_names)
score = score / 100.0
if (not threshold) or score > threshold:
found_index = all_names.index(found)
return bros_to_use[found_index]
else:
raise BadName(name, found[1], threshold)
raise BadName(found, score, threshold)
scroll_hook = slack_util.Hook(scroll_callback, pattern=r"scroll\s+(.*)")

View File

@ -5,14 +5,12 @@ from typing import Any, Optional, Generator, Match, Callable, List, Coroutine
from slackclient import SlackClient
from slackclient.client import SlackNotConnected
import channel_util
"""
Slack helpers. Separated for compartmentalization
"""
def reply(slack: SlackClient, msg: dict, text: str, in_thread: bool = True, to_channel: str = None) -> None:
def reply(slack: SlackClient, msg: dict, text: str, in_thread: bool = True, to_channel: str = None) -> dict:
"""
Sends message with "text" as its content to the channel that message came from
"""
@ -24,9 +22,22 @@ def reply(slack: SlackClient, msg: dict, text: str, in_thread: bool = True, to_c
if in_thread:
thread = (msg.get("thread_ts") # In-thread case - get parent ts
or msg.get("ts")) # Not in-thread case - get msg itself ts
slack.rtm_send_message(channel=to_channel, message=text, thread=thread)
return send_message(slack, text, to_channel, thread=thread)
else:
slack.rtm_send_message(channel=to_channel, message=text)
return send_message(slack, text, to_channel)
def send_message(slack: SlackClient, text: str, channel: str, thread: str = None, broadcast: bool = False) -> dict:
"""
Copy of the internal send message function of slack
"""
kwargs = {"channel": channel, "text": text}
if thread:
kwargs["thread_ts"] = thread
if broadcast:
kwargs["reply_broadcast"] = True
return slack.api_call("chat.postMessage", **kwargs)
def im_channel_for_id(slack: SlackClient, user_id: str) -> Optional[str]:
@ -39,29 +50,6 @@ def im_channel_for_id(slack: SlackClient, user_id: str) -> Optional[str]:
return None
class SlackDebugCondom(object):
def __init__(self, actual_slack: SlackClient):
self.actual_slack = actual_slack
def __getattribute__(self, name: str) -> Any:
# Specialized behaviour
if name == "rtm_send_message":
# Flub some args
def override_send_message(*args, **kwargs):
print("Overriding: {} {}".format(args, kwargs))
kwargs["channel"] = channel_util.BOTZONE
kwargs["thread"] = None
self.actual_slack.rtm_send_message(*args, **kwargs)
return override_send_message
else:
# Default behaviour. Try to give the self first, elsewise give the child
try:
return super(SlackDebugCondom, self).__getattribute__(name)
except AttributeError:
return self.actual_slack.__getattribute__(name)
def message_stream(slack: SlackClient) -> Generator[dict, None, None]:
"""
Generator that yields messages from slack.
@ -93,12 +81,28 @@ MsgAction = Coroutine[Any, Any, None]
Callback = Callable[[SlackClient, dict, Match], MsgAction]
class Hook(object):
class DeadHook(Exception):
pass
class AbsHook(object):
def __init__(self, consumes_applicable: bool):
# Whether or not messages that yield a coroutine should not be checked further
self.consumes = consumes_applicable
def try_apply(self, slack: SlackClient, msg: dict) -> Optional[Coroutine[None, None, None]]:
raise NotImplementedError()
class Hook(AbsHook):
def __init__(self,
callback: Callback,
pattern: str,
channel_whitelist: Optional[List[str]] = None,
channel_blacklist: Optional[List[str]] = None):
channel_blacklist: Optional[List[str]] = None,
consumer: bool = True):
super(Hook, self).__init__(consumer)
# Save all
self.pattern = pattern
self.channel_whitelist = channel_whitelist
@ -114,7 +118,7 @@ class Hook(object):
else:
raise Exception("Cannot whitelist and blacklist")
def check(self, msg: dict) -> Optional[Match]:
def try_apply(self, slack: SlackClient, msg: dict) -> Optional[Coroutine[None, None, None]]:
"""
Returns whether a message should be handled by this dict, returning a Match if so, or None
"""
@ -134,9 +138,6 @@ class Hook(object):
# print("Hit blacklist")
return None
return match
def invoke(self, slack: SlackClient, msg: dict, match: Match):
return self.callback(slack, msg, match)

View File

@ -1,4 +1,5 @@
scroll~name
0~MISSINGBRO
1~Richard Brodeur
2~Charles T. Kleman
4~Warren Bentley