Misc. updates.

This commit is contained in:
arkerekon 2023-01-10 09:10:47 -05:00 committed by Reily Siegel
parent e813a5d72d
commit e3321000cb
Signed by untrusted user: 1114
GPG Key ID: 508A5AD0A50F88AF
19 changed files with 3375 additions and 3303 deletions

24
.gitignore vendored
View File

@ -1,13 +1,13 @@
apitoken.txt apitoken.txt
killswitch.txt killswitch.txt
sheets_credentials.json sheets_credentials.json
sheets_token.json sheets_token.json
*.bak *.bak
*.dat *.dat
*.dir *.dir
*.pyc *.pyc
*.swp *.swp
*.log *.log
script_log.txt script_log.txt
towels_rolled towels_rolled
user_scrolls user_scrolls

9
Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM python:3.8-slim
WORKDIR /app
COPY . .
RUN pip3 install --user -r dependencies.txt
CMD python3 -u main.py

854
client.py
View File

@ -1,427 +1,427 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json import json
import pprint import pprint
import sys import sys
import traceback import traceback
import logging import logging
from pprint import pformat from pprint import pformat
from typing import List, Any, AsyncGenerator, Dict, Coroutine, TypeVar from typing import List, Any, AsyncGenerator, Dict, Coroutine, TypeVar
from typing import Optional from typing import Optional
from aiohttp import web from aiohttp import web
from slackclient import SlackClient from slackclient import SlackClient
import hooks import hooks
import settings import settings
import slack_util import slack_util
""" """
Objects to wrap slack connections Objects to wrap slack connections
""" """
# Read the API token # Read the API token
api_file = open("apitoken.txt", 'r') api_file = open("apitoken.txt", 'r')
SLACK_API = next(api_file).strip() SLACK_API = next(api_file).strip()
api_file.close() api_file.close()
class ClientWrapper(object): class ClientWrapper(object):
""" """
Essentially the main state object. Essentially the main state object.
We only ever expect one of these per api token. We only ever expect one of these per api token.
Holds a slack client, and handles messsages. Holds a slack client, and handles messsages.
""" """
def __init__(self, api_token): def __init__(self, api_token):
# Init slack # Init slack
self.slack = SlackClient(api_token) self.slack = SlackClient(api_token)
# Hooks go regex -> callback on (slack, msg, match) # Hooks go regex -> callback on (slack, msg, match)
self.hooks: List[hooks.AbsHook] = [] self.hooks: List[hooks.AbsHook] = []
# Periodicals are just wrappers around an iterable, basically # Periodicals are just wrappers around an iterable, basically
self.passives: List[hooks.Passive] = [] self.passives: List[hooks.Passive] = []
# Cache users and channels # Cache users and channels
self.users: Dict[str, slack_util.User] = {} self.users: Dict[str, slack_util.User] = {}
self.conversations: Dict[str, slack_util.Conversation] = {} self.conversations: Dict[str, slack_util.Conversation] = {}
# Scheduled/passive events handling # Scheduled/passive events handling
def add_passive(self, per: hooks.Passive) -> None: def add_passive(self, per: hooks.Passive) -> None:
self.passives.append(per) self.passives.append(per)
async def run_passives(self) -> None: async def run_passives(self) -> None:
""" """
Run all currently added passives Run all currently added passives
""" """
awaitables = [p.run() for p in self.passives] awaitables = [p.run() for p in self.passives]
await asyncio.gather(*awaitables) await asyncio.gather(*awaitables)
# Incoming slack hook handling # Incoming slack hook handling
def add_hook(self, hook: hooks.AbsHook) -> None: def add_hook(self, hook: hooks.AbsHook) -> None:
self.hooks.append(hook) self.hooks.append(hook)
async def handle_events(self) -> None: async def handle_events(self) -> None:
""" """
Asynchronous tasks that eternally reads and responds to messages. Asynchronous tasks that eternally reads and responds to messages.
""" """
# Create a queue # Create a queue
queue = asyncio.Queue() queue = asyncio.Queue()
# Create a task to put rtm events to the queue # Create a task to put rtm events to the queue
rtm_task = asyncio.create_task(self.rtm_event_feed(queue)) rtm_task = asyncio.create_task(self.rtm_event_feed(queue))
# Create a task to put http events to the queue # Create a task to put http events to the queue
http_task = asyncio.create_task(self.http_event_feed(queue)) http_task = asyncio.create_task(self.http_event_feed(queue))
# Create a task to handle all other tasks # Create a task to handle all other tasks
async def handle_task_loop(): async def handle_task_loop():
async for t3 in self.spool_tasks(queue): async for t3 in self.spool_tasks(queue):
sys.stdout.flush() sys.stdout.flush()
if settings.SINGLE_THREAD_TASKS: if settings.SINGLE_THREAD_TASKS:
await t3 await t3
# Handle them all # Handle them all
await asyncio.gather(rtm_task, http_task, handle_task_loop()) await asyncio.gather(rtm_task, http_task, handle_task_loop())
async def rtm_event_feed(self, msg_queue: asyncio.Queue) -> None: async def rtm_event_feed(self, msg_queue: asyncio.Queue) -> None:
""" """
Async wrapper around the message feed. Async wrapper around the message feed.
Yields messages awaitably forever. Yields messages awaitably forever.
""" """
# Create the msg feed # Create the msg feed
feed = slack_util.message_stream(self.slack) feed = slack_util.message_stream(self.slack)
# Create a simple callable that gets one message from the feed # Create a simple callable that gets one message from the feed
def get_one(): def get_one():
return next(feed) return next(feed)
# Continuously yield async threaded tasks that poll the feed # Continuously yield async threaded tasks that poll the feed
while True: while True:
next_event = await asyncio.get_running_loop().run_in_executor(None, get_one) next_event = await asyncio.get_running_loop().run_in_executor(None, get_one)
await msg_queue.put(next_event) await msg_queue.put(next_event)
async def http_event_feed(self, event_queue: asyncio.Queue) -> None: async def http_event_feed(self, event_queue: asyncio.Queue) -> None:
# Create a callback to convert requests to events # Create a callback to convert requests to events
async def interr(request: web.Request): async def interr(request: web.Request):
if request.can_read_body: if request.can_read_body:
# Get the payload # Get the payload
post_params = await request.post() post_params = await request.post()
payload = json.loads(post_params["payload"]) payload = json.loads(post_params["payload"])
logging.info("\nInteraction Event received:") logging.info("\nInteraction Event received:")
logging.debug(pformat(payload)) logging.debug(pformat(payload))
# Handle each action separately # Handle each action separately
if "actions" in payload: if "actions" in payload:
for action in payload["actions"]: for action in payload["actions"]:
# Start building the event # Start building the event
ev = slack_util.Event() ev = slack_util.Event()
# Get the user who clicked the button # Get the user who clicked the button
ev.user = slack_util.UserContext(payload["user"]["id"]) ev.user = slack_util.UserContext(payload["user"]["id"])
# Get the message that they clicked # Get the message that they clicked
ev.message = slack_util.RelatedMessageContext(payload["message"]["ts"], payload["message"]["text"]) ev.message = slack_util.RelatedMessageContext(payload["message"]["ts"], payload["message"]["text"])
# Get the channel it was clicked in # Get the channel it was clicked in
ev.conversation = slack_util.ConversationContext(payload["channel"]["id"]) ev.conversation = slack_util.ConversationContext(payload["channel"]["id"])
# Get the message this button/action was attached to # Get the message this button/action was attached to
ev.interaction = slack_util.InteractionContext(payload["response_url"], ev.interaction = slack_util.InteractionContext(payload["response_url"],
payload["trigger_id"], payload["trigger_id"],
action["block_id"], action["block_id"],
action["action_id"], action["action_id"],
action.get("value")) action.get("value"))
# Put it in the queue # Put it in the queue
await event_queue.put(ev) await event_queue.put(ev)
# Respond that everything is fine # Respond that everything is fine
return web.Response(status=200) return web.Response(status=200)
else: else:
logging.error("\nMalformed event received.") logging.error("\nMalformed event received.")
# If we can't read it, get mad # If we can't read it, get mad
return web.Response(status=400) return web.Response(status=400)
# Create the server # Create the server
app = web.Application() app = web.Application()
app.add_routes([web.post('/bothttpcallback', interr)]) app.add_routes([web.post('/bothttpcallback', interr)])
# Asynchronously serve that boy up # Asynchronously serve that boy up
runner = web.AppRunner(app) runner = web.AppRunner(app)
await runner.setup() await runner.setup()
site = web.TCPSite(runner, port=31019) site = web.TCPSite(runner, port=31019)
await site.start() await site.start()
logging.info("Server up") logging.info("Server up")
# while True: # while True:
# await asyncio.sleep(30) # await asyncio.sleep(30)
async def spool_tasks(self, event_queue: asyncio.Queue) -> AsyncGenerator[asyncio.Task, Any]: async def spool_tasks(self, event_queue: asyncio.Queue) -> AsyncGenerator[asyncio.Task, Any]:
""" """
Read in from async event feed, and spool them out as async tasks Read in from async event feed, and spool them out as async tasks
""" """
while True: while True:
event: slack_util.Event = await event_queue.get() event: slack_util.Event = await event_queue.get()
# Find which hook, if any, satisfies # Find which hook, if any, satisfies
for hook in list(self.hooks): # Note that we do list(self.hooks) to avoid edit-while-iterating issues for hook in list(self.hooks): # Note that we do list(self.hooks) to avoid edit-while-iterating issues
# Try invoking each # Try invoking each
try: try:
# Try to make a coroutine handling the message # Try to make a coroutine handling the message
coro = hook.try_apply(event) coro = hook.try_apply(event)
# If we get a coro back, then task it up and set consumption appropriately # If we get a coro back, then task it up and set consumption appropriately
if coro is not None: if coro is not None:
logging.debug("Spawned task. Now {} running total.".format(len(asyncio.all_tasks()))) logging.debug("Spawned task. Now {} running total.".format(len(asyncio.all_tasks())))
yield asyncio.create_task(_exception_printing_task(coro)) yield asyncio.create_task(_exception_printing_task(coro))
if hook.consumes: if hook.consumes:
break break
except hooks.HookDeath: except hooks.HookDeath:
# If a hook wants to die, let it. # If a hook wants to die, let it.
self.hooks.remove(hook) self.hooks.remove(hook)
# Data getting/sending # Data getting/sending
def get_conversation(self, conversation_id: str) -> Optional[slack_util.Conversation]: def get_conversation(self, conversation_id: str) -> Optional[slack_util.Conversation]:
return self.conversations.get(conversation_id) return self.conversations.get(conversation_id)
def get_conversation_by_name(self, conversation_identifier: str) -> Optional[slack_util.Conversation]: def get_conversation_by_name(self, conversation_identifier: str) -> Optional[slack_util.Conversation]:
# If looking for a direct message, first lookup user, then fetch # If looking for a direct message, first lookup user, then fetch
if conversation_identifier[0] == "@": if conversation_identifier[0] == "@":
# user_name = conversation_identifier # user_name = conversation_identifier
# Find the user by their name # Find the user by their name
raise NotImplementedError("There wasn't a clear use case for this yet, so we've opted to just not use it") raise NotImplementedError("There wasn't a clear use case for this yet, so we've opted to just not use it")
# If looking for a channel, just lookup normally # If looking for a channel, just lookup normally
elif conversation_identifier[0] == "#": elif conversation_identifier[0] == "#":
channel_name = conversation_identifier channel_name = conversation_identifier
# Find the channel in the dict # Find the channel in the dict
for channel in self.conversations.values(): for channel in self.conversations.values():
if channel.name == channel_name: if channel.name == channel_name:
return channel return channel
# If it doesn't fit the above, we don't know how to process # If it doesn't fit the above, we don't know how to process
else: else:
raise ValueError("Please give either an #channel-name or @user-name") raise ValueError("Please give either an #channel-name or @user-name")
# If we haven't returned already, give up and return None # If we haven't returned already, give up and return None
return None return None
def get_user(self, user_id: str) -> Optional[slack_util.User]: def get_user(self, user_id: str) -> Optional[slack_util.User]:
return self.users.get(user_id) return self.users.get(user_id)
def get_user_by_name(self, user_name: str) -> Optional[slack_util.User]: def get_user_by_name(self, user_name: str) -> Optional[slack_util.User]:
raise NotImplementedError() raise NotImplementedError()
def api_call(self, api_method, **kwargs): def api_call(self, api_method, **kwargs):
return self.slack.api_call(api_method, **kwargs) return self.slack.api_call(api_method, **kwargs)
# Simpler wrappers around message sending/replying # Simpler wrappers around message sending/replying
def reply(self, event: slack_util.Event, text: str, in_thread: bool = True) -> dict: def reply(self, event: slack_util.Event, text: str, in_thread: bool = True) -> dict:
""" """
Replies to a message. Replies to a message.
Message must have a channel and message context. Message must have a channel and message context.
Returns the JSON response. Returns the JSON response.
""" """
# Ensure we're actually replying to a valid message # Ensure we're actually replying to a valid message
assert (event.conversation and event.message) is not None assert (event.conversation and event.message) is not None
# Send in a thread by default # Send in a thread by default
if in_thread: if in_thread:
# Figure otu what thread to send it to # Figure otu what thread to send it to
thread = event.message.ts thread = event.message.ts
if event.thread: if event.thread:
thread = event.thread.thread_ts thread = event.thread.thread_ts
return self.send_message(text, event.conversation.conversation_id, thread=thread) return self.send_message(text, event.conversation.conversation_id, thread=thread)
else: else:
return self.send_message(text, event.conversation.conversation_id) return self.send_message(text, event.conversation.conversation_id)
def _send_core(self, api_method: str, text: str, channel_id: str, thread: str, broadcast: bool, def _send_core(self, api_method: str, text: str, channel_id: str, thread: str, broadcast: bool,
blocks: Optional[List[dict]]) -> dict: blocks: Optional[List[dict]]) -> dict:
""" """
Copy of the internal send message function of slack, with some helpful options. Copy of the internal send message function of slack, with some helpful options.
Returns the JSON response. Returns the JSON response.
""" """
# Check that text exists if there are no blocks # Check that text exists if there are no blocks
if blocks is None and text is None: if blocks is None and text is None:
raise ValueError("Must provide blocks or texts or both.") raise ValueError("Must provide blocks or texts or both.")
elif text is None: elif text is None:
text = "_Block message. Open slack client to view_" text = "_Block message. Open slack client to view_"
# Begin constructing kwargs with fields that _must_ exist # Begin constructing kwargs with fields that _must_ exist
kwargs = {"channel": channel_id, "text": text, "as_user": True, "parse": True} kwargs = {"channel": channel_id, "text": text, "as_user": True, "parse": True}
# Deduce thread stuff # Deduce thread stuff
if thread: if thread:
kwargs["thread_ts"] = thread kwargs["thread_ts"] = thread
if broadcast: if broadcast:
kwargs["reply_broadcast"] = True kwargs["reply_broadcast"] = True
elif broadcast: elif broadcast:
raise ValueError("Can't broadcast a non-threaded message. Try again.") raise ValueError("Can't broadcast a non-threaded message. Try again.")
# Set blocks iff provided. # Set blocks iff provided.
if blocks is not None: if blocks is not None:
kwargs["blocks"] = blocks kwargs["blocks"] = blocks
result = self.api_call(api_method, **kwargs) result = self.api_call(api_method, **kwargs)
logging.info("Tried to send message \"{}\". Got response:\n {}".format(text, pprint.pformat(result))) logging.info("Tried to send message \"{}\". Got response:\n {}".format(text, pprint.pformat(result)))
return result return result
def send_message(self, def send_message(self,
text: Optional[str], text: Optional[str],
channel_id: str, channel_id: str,
thread: str = None, thread: str = None,
broadcast: bool = False, broadcast: bool = False,
blocks: Optional[List[dict]] = None) -> dict: blocks: Optional[List[dict]] = None) -> dict:
""" """
Wraps _send_core for normal messages Wraps _send_core for normal messages
""" """
return self._send_core("chat.postMessage", text, channel_id, thread, broadcast, blocks) return self._send_core("chat.postMessage", text, channel_id, thread, broadcast, blocks)
def send_ephemeral(self, def send_ephemeral(self,
text: Optional[str], text: Optional[str],
channel_id: str, channel_id: str,
thread: str = None, thread: str = None,
blocks: Optional[List[dict]] = None) -> dict: blocks: Optional[List[dict]] = None) -> dict:
""" """
Wraps _send_core for ephemeral messages Wraps _send_core for ephemeral messages
""" """
return self._send_core("chat.postEphemeral", text, channel_id, thread, False, blocks) return self._send_core("chat.postEphemeral", text, channel_id, thread, False, blocks)
def edit_message(self, text: Optional[str], channel_id: str, message_ts: str, blocks: Optional[List[dict]] = None): def edit_message(self, text: Optional[str], channel_id: str, message_ts: str, blocks: Optional[List[dict]] = None):
""" """
Edits a message. Edits a message.
""" """
# Check that text exists if there are no blocks # Check that text exists if there are no blocks
if blocks is None and text is None: if blocks is None and text is None:
raise ValueError("Must provide blocks or texts or both.") raise ValueError("Must provide blocks or texts or both.")
elif text is None: elif text is None:
text = "_Block message. Open slack client to view_" text = "_Block message. Open slack client to view_"
# Set the default args # Set the default args
kwargs = {"ts": message_ts, "channel": channel_id, "text": text, "as_user": True} kwargs = {"ts": message_ts, "channel": channel_id, "text": text, "as_user": True}
# Set blocks if provided # Set blocks if provided
if blocks is not None: if blocks is not None:
kwargs["blocks"] = blocks kwargs["blocks"] = blocks
return self.api_call("chat.update", **kwargs) return self.api_call("chat.update", **kwargs)
# Update slack data # Update slack data
def update_channels(self): def update_channels(self):
""" """
Queries the slack API for all current channels Queries the slack API for all current channels
""" """
# Necessary because of pagination # Necessary because of pagination
cursor = None cursor = None
# Make a new dict to use # Make a new dict to use
new_dict = {} new_dict = {}
# Iterate over results # Iterate over results
while True: while True:
# Set args depending on if a cursor exists # Set args depending on if a cursor exists
args = {"limit": 1000, "types": "public_channel,private_channel,mpim,im"} args = {"limit": 1000, "types": "public_channel,private_channel,mpim,im"}
if cursor: if cursor:
args["cursor"] = cursor args["cursor"] = cursor
channel_dicts = self.api_call("conversations.list", **args) channel_dicts = self.api_call("conversations.list", **args)
# If the response is good, put its results to the dict # If the response is good, put its results to the dict
if channel_dicts["ok"]: if channel_dicts["ok"]:
for channel_dict in channel_dicts["channels"]: for channel_dict in channel_dicts["channels"]:
if channel_dict["is_im"]: if channel_dict["is_im"]:
new_channel = slack_util.DirectMessage(id=channel_dict["id"], new_channel = slack_util.DirectMessage(id=channel_dict["id"],
user_id="@" + channel_dict["user"]) user_id="@" + channel_dict["user"])
else: else:
new_channel = slack_util.Channel(id=channel_dict["id"], new_channel = slack_util.Channel(id=channel_dict["id"],
name="#" + channel_dict["name"]) name="#" + channel_dict["name"])
new_dict[new_channel.id] = new_channel new_dict[new_channel.id] = new_channel
# Fetch the cursor # Fetch the cursor
cursor = channel_dicts.get("response_metadata").get("next_cursor") cursor = channel_dicts.get("response_metadata").get("next_cursor")
# If cursor is blank, we're done new channels, just give it up # If cursor is blank, we're done new channels, just give it up
if cursor == "": if cursor == "":
break break
else: else:
logging.warning("Failed to retrieve channels. Message: {}".format(channel_dicts)) logging.warning("Failed to retrieve channels. Message: {}".format(channel_dicts))
break break
self.conversations = new_dict self.conversations = new_dict
def update_users(self): def update_users(self):
""" """
Queries the slack API for all current users Queries the slack API for all current users
""" """
# Necessary because of pagination # Necessary because of pagination
cursor = None cursor = None
while True: while True:
# Set args depending on if a cursor exists # Set args depending on if a cursor exists
args = {"limit": 1000} args = {"limit": 1000}
if cursor: if cursor:
args["cursor"] = cursor args["cursor"] = cursor
user_dicts = self.api_call("users.list", **args) user_dicts = self.api_call("users.list", **args)
# Make a new dict to use # Make a new dict to use
new_dict = {} new_dict = {}
# If the response is good: # If the response is good:
if user_dicts["ok"]: if user_dicts["ok"]:
for user_dict in user_dicts["members"]: for user_dict in user_dicts["members"]:
new_user = slack_util.User(id=user_dict.get("id"), new_user = slack_util.User(id=user_dict.get("id"),
name=user_dict.get("name"), name=user_dict.get("name"),
real_name=user_dict.get("real_name"), real_name=user_dict.get("real_name"),
email=user_dict.get("profile").get("email")) email=user_dict.get("profile").get("email"))
new_dict[new_user.id] = new_user new_dict[new_user.id] = new_user
# Fetch the cursor # Fetch the cursor
cursor = user_dicts.get("response_metadata").get("next_cursor") cursor = user_dicts.get("response_metadata").get("next_cursor")
# If cursor is blank, we're done new channels, just give it up # If cursor is blank, we're done new channels, just give it up
if cursor == "": if cursor == "":
break break
else: else:
logging.warning("Warning: failed to retrieve users") logging.warning("Warning: failed to retrieve users")
break break
self.users = new_dict self.users = new_dict
# Create a single instance of the client wrapper # Create a single instance of the client wrapper
_singleton = ClientWrapper(SLACK_API) _singleton = ClientWrapper(SLACK_API)
def get_slack() -> ClientWrapper: def get_slack() -> ClientWrapper:
return _singleton return _singleton
""" """
Miscellania Miscellania
""" """
A = TypeVar("A") A = TypeVar("A")
B = TypeVar("B") B = TypeVar("B")
C = TypeVar("C") C = TypeVar("C")
# Prints exceptions instead of silently dropping them in async tasks # Prints exceptions instead of silently dropping them in async tasks
async def _exception_printing_task(c: Coroutine[A, B, C]) -> Coroutine[A, B, C]: async def _exception_printing_task(c: Coroutine[A, B, C]) -> Coroutine[A, B, C]:
# Print exceptions as they pass through # Print exceptions as they pass through
try: try:
return await c return await c
except Exception: except Exception:
output = traceback.format_exc() output = traceback.format_exc()
print(output) print(output)
get_slack().send_message(output, "#botzone") get_slack().send_message(output, "#botzone")
raise raise

View File

@ -1,4 +1,7 @@
slackclient slackclient==1.3.2
python-Levenshtein python-Levenshtein
fuzzywuzzy fuzzywuzzy
httplib2 httplib2
aiohttp
google-api-python-client
oauth2client

View File

@ -1,85 +1,85 @@
""" """
Examples provided by google for using their api. Examples provided by google for using their api.
Very slightly modified by me to easily just get credentials Very slightly modified by me to easily just get credentials
""" """
from googleapiclient.discovery import build from googleapiclient.discovery import build
from httplib2 import Http from httplib2 import Http
from oauth2client import file, client, tools from oauth2client import file, client, tools
# If modifying these scopes, delete your previously saved credentials # If modifying these scopes, delete your previously saved credentials
# at ~/.credentials/sheets.googleapis.com-python-quickstart.json # at ~/.credentials/sheets.googleapis.com-python-quickstart.json
SCOPES = 'https://www.googleapis.com/auth/spreadsheets' SCOPES = 'https://www.googleapis.com/auth/spreadsheets'
APPLICATION_NAME = 'SlickSlacker' APPLICATION_NAME = 'SlickSlacker'
def _init_sheets_service(): def _init_sheets_service():
store = file.Storage('sheets_token.json') store = file.Storage('sheets_token.json')
creds = store.get() creds = store.get()
if not creds or creds.invalid: if not creds or creds.invalid:
flow = client.flow_from_clientsecrets('sheets_credentials.json', SCOPES) flow = client.flow_from_clientsecrets('sheets_credentials.json', SCOPES)
creds = tools.run_flow(flow, store) creds = tools.run_flow(flow, store)
service = build('sheets', 'v4', http=creds.authorize(Http())) service = build('sheets', 'v4', http=creds.authorize(Http()))
return service return service
_global_sheet_service = _init_sheets_service() _global_sheet_service = _init_sheets_service()
# range should be of format 'SHEET NAME!A1:Z9' # range should be of format 'SHEET NAME!A1:Z9'
def get_sheet_range(spreadsheet_id, sheet_range): def get_sheet_range(spreadsheet_id, sheet_range):
""" """
Gets an array of the desired table Gets an array of the desired table
""" """
result = _global_sheet_service.spreadsheets().values().get(spreadsheetId=spreadsheet_id, result = _global_sheet_service.spreadsheets().values().get(spreadsheetId=spreadsheet_id,
range=sheet_range).execute() range=sheet_range).execute()
values = result.get('values', []) values = result.get('values', [])
if not values: if not values:
return [] return []
else: else:
return values return values
def set_sheet_range(spreadsheet_id, sheet_range, values): def set_sheet_range(spreadsheet_id, sheet_range, values):
""" """
Set an array in the desired table Set an array in the desired table
""" """
body = { body = {
"values": values "values": values
} }
result = _global_sheet_service.spreadsheets().values().update(spreadsheetId=spreadsheet_id, result = _global_sheet_service.spreadsheets().values().update(spreadsheetId=spreadsheet_id,
range=sheet_range, range=sheet_range,
valueInputOption="RAW", valueInputOption="RAW",
body=body).execute() body=body).execute()
return result return result
def get_calendar_credentials(): def get_calendar_credentials():
"""Gets valid user credentials from storage. """Gets valid user credentials from storage.
If nothing has been stored, or if the stored credentials are invalid, If nothing has been stored, or if the stored credentials are invalid,
the OAuth2 flow is completed to obtain the new credentials. the OAuth2 flow is completed to obtain the new credentials.
Returns: Returns:
Credentials, the obtained credential. Credentials, the obtained credential.
""" """
""" """
home_dir = os.path.expanduser('~') home_dir = os.path.expanduser('~')
credential_dir = os.path.join(home_dir, '.credentials') credential_dir = os.path.join(home_dir, '.credentials')
if not os.path.exists(credential_dir): if not os.path.exists(credential_dir):
os.makedirs(credential_dir) os.makedirs(credential_dir)
credential_path = os.path.join(credential_dir, 'calendar-python-quickstart.json') credential_path = os.path.join(credential_dir, 'calendar-python-quickstart.json')
store = oauth2client.file.Storage(credential_path) store = oauth2client.file.Storage(credential_path)
credentials = store.get() credentials = store.get()
if not credentials or credentials.invalid: if not credentials or credentials.invalid:
flow = client.flow_from_clientsecrets(CLIENT_SECRET_FILE, SCOPES) flow = client.flow_from_clientsecrets(CLIENT_SECRET_FILE, SCOPES)
flow.user_agent = APPLICATION_NAME flow.user_agent = APPLICATION_NAME
if flags: if flags:
credentials = tools.run_flow(flow, store, flags) credentials = tools.run_flow(flow, store, flags)
else: # Needed only for compatibility with Python 2.6 else: # Needed only for compatibility with Python 2.6
credentials = tools.run(flow, store) credentials = tools.run(flow, store)
print('Storing credentials to ' + credential_path) print('Storing credentials to ' + credential_path)
return credentials return credentials
""" """
raise NotImplementedError("This isn't going to work") raise NotImplementedError("This isn't going to work")

446
hooks.py
View File

@ -1,223 +1,223 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re import re
from time import time from time import time
from typing import Match, Any, Coroutine, Callable, Optional, Union, List, TypeVar, Dict from typing import Match, Any, Coroutine, Callable, Optional, Union, List, TypeVar, Dict
import slack_util import slack_util
# Return type of an event callback # Return type of an event callback
MsgAction = Coroutine[Any, Any, None] MsgAction = Coroutine[Any, Any, None]
# Type signature of a message event callback function, for convenience # Type signature of a message event callback function, for convenience
MsgCallback = Callable[[slack_util.Event, Match], MsgAction] MsgCallback = Callable[[slack_util.Event, Match], MsgAction]
""" """
Hooks Hooks
""" """
# Signal exception to be raised when a hook has died # Signal exception to be raised when a hook has died
class HookDeath(Exception): class HookDeath(Exception):
pass pass
# Abstract hook parent class # Abstract hook parent class
class AbsHook(object): class AbsHook(object):
def __init__(self, consumes_applicable: bool): def __init__(self, consumes_applicable: bool):
# Whether or not messages that yield a coroutine should not be checked further # Whether or not messages that yield a coroutine should not be checked further
self.consumes = consumes_applicable self.consumes = consumes_applicable
def try_apply(self, event: slack_util.Event) -> Optional[MsgAction]: def try_apply(self, event: slack_util.Event) -> Optional[MsgAction]:
raise NotImplementedError() raise NotImplementedError()
class ChannelHook(AbsHook): class ChannelHook(AbsHook):
""" """
Hook that handles messages in a variety of channels. Hook that handles messages in a variety of channels.
Guarantees prescence of Post, Related, Conversation, and User Guarantees prescence of Post, Related, Conversation, and User
""" """
def __init__(self, def __init__(self,
callback: MsgCallback, callback: MsgCallback,
patterns: Union[str, List[str]], patterns: Union[str, List[str]],
channel_whitelist: Optional[List[str]] = None, channel_whitelist: Optional[List[str]] = None,
channel_blacklist: Optional[List[str]] = None, channel_blacklist: Optional[List[str]] = None,
consumer: bool = True, consumer: bool = True,
allow_dms: bool = True): allow_dms: bool = True):
super(ChannelHook, self).__init__(consumer) super(ChannelHook, self).__init__(consumer)
# Save all # Save all
if not isinstance(patterns, list): if not isinstance(patterns, list):
patterns = [patterns] patterns = [patterns]
self.patterns = patterns self.patterns = patterns
self.channel_whitelist = channel_whitelist self.channel_whitelist = channel_whitelist
self.channel_blacklist = channel_blacklist self.channel_blacklist = channel_blacklist
self.callback = callback self.callback = callback
self.allows_dms = allow_dms self.allows_dms = allow_dms
# Remedy some sensible defaults # Remedy some sensible defaults
if self.channel_blacklist is None: if self.channel_blacklist is None:
self.channel_blacklist = ["#general"] self.channel_blacklist = ["#general"]
elif self.channel_whitelist is None: elif self.channel_whitelist is None:
pass # We leave as none to show no whitelisting in effect pass # We leave as none to show no whitelisting in effect
else: else:
raise ValueError("Cannot whitelist and blacklist") raise ValueError("Cannot whitelist and blacklist")
def try_apply(self, event: slack_util.Event) -> Optional[MsgAction]: def try_apply(self, event: slack_util.Event) -> Optional[MsgAction]:
""" """
Returns whether a message should be handled by this dict, returning a Match if so, or None Returns whether a message should be handled by this dict, returning a Match if so, or None
""" """
# Ensure that this is an event in a specific channel, with a text component # Ensure that this is an event in a specific channel, with a text component
if not (event.conversation and event.was_post and event.message and event.user): if not (event.conversation and event.was_post and event.message and event.user):
return None return None
# Fail if pattern invalid # Fail if pattern invalid
match = None match = None
for p in self.patterns: for p in self.patterns:
match = re.match(p, event.message.text.strip(), flags=re.IGNORECASE) match = re.match(p, event.message.text.strip(), flags=re.IGNORECASE)
if match is not None: if match is not None:
break break
if match is None: if match is None:
return None return None
# Get the channel name # Get the channel name
if isinstance(event.conversation.get_conversation(), slack_util.Channel): if isinstance(event.conversation.get_conversation(), slack_util.Channel):
channel_name = event.conversation.get_conversation().name channel_name = event.conversation.get_conversation().name
elif self.allows_dms: elif self.allows_dms:
channel_name = "DIRECT_MSG" channel_name = "DIRECT_MSG"
else: else:
return None return None
# Fail if whitelist defined, and we aren't there # Fail if whitelist defined, and we aren't there
if self.channel_whitelist is not None and channel_name not in self.channel_whitelist: if self.channel_whitelist is not None and channel_name not in self.channel_whitelist:
return None return None
# Fail if blacklist defined, and we are there # Fail if blacklist defined, and we are there
if self.channel_blacklist is not None and channel_name in self.channel_blacklist: if self.channel_blacklist is not None and channel_name in self.channel_blacklist:
return None return None
return self.callback(event, match) return self.callback(event, match)
class ReplyWaiter(AbsHook): class ReplyWaiter(AbsHook):
""" """
A special hook that only cares about replies to a given message. A special hook that only cares about replies to a given message.
Guarantees presence of Post, Message, Thread, User, and Conversation. Guarantees presence of Post, Message, Thread, User, and Conversation.
As such, it ignores bots. As such, it ignores bots.
""" """
def __init__(self, callback: MsgCallback, pattern: str, thread_ts: str, lifetime: float): def __init__(self, callback: MsgCallback, pattern: str, thread_ts: str, lifetime: float):
super().__init__(True) super().__init__(True)
self.callback = callback self.callback = callback
self.pattern = pattern self.pattern = pattern
self.thread_ts = thread_ts self.thread_ts = thread_ts
self.lifetime = lifetime self.lifetime = lifetime
self.start_time = time() self.start_time = time()
self.dead = False self.dead = False
def try_apply(self, event: slack_util.Event) -> Optional[MsgAction]: def try_apply(self, event: slack_util.Event) -> Optional[MsgAction]:
# First check: are we dead of age yet? # First check: are we dead of age yet?
time_alive = time() - self.start_time time_alive = time() - self.start_time
should_expire = time_alive > self.lifetime should_expire = time_alive > self.lifetime
# If so, give up the ghost # If so, give up the ghost
if self.dead or should_expire: if self.dead or should_expire:
raise HookDeath() raise HookDeath()
# Next make sure we're actually a message # Next make sure we're actually a message
if not (event.was_post and event.was_post and event.thread and event.conversation and event.user): if not (event.was_post and event.was_post and event.thread and event.conversation and event.user):
return None return None
# Otherwise proceed normally # Otherwise proceed normally
# Is the msg the one we care about? If not, ignore # Is the msg the one we care about? If not, ignore
if event.thread.thread_ts != self.thread_ts: if event.thread.thread_ts != self.thread_ts:
return None return None
# Does it match the regex? if not, ignore # Does it match the regex? if not, ignore
match = re.match(self.pattern, event.message.text.strip(), flags=re.IGNORECASE) match = re.match(self.pattern, event.message.text.strip(), flags=re.IGNORECASE)
if match: if match:
self.dead = True self.dead = True
return self.callback(event, match) return self.callback(event, match)
else: else:
return None return None
# The associated generic type of a button - value mapping # The associated generic type of a button - value mapping
ActionVal = TypeVar("ActionVal") ActionVal = TypeVar("ActionVal")
class InteractionListener(AbsHook): class InteractionListener(AbsHook):
""" """
Listens for replies on buttons. Listens for replies on buttons.
Guarantees Interaction, Message, User, and Conversation Guarantees Interaction, Message, User, and Conversation
For fields that don't have a value of their own (such as buttons), For fields that don't have a value of their own (such as buttons),
one can provide a mapping of action_ids to values. one can provide a mapping of action_ids to values.
In either case, the value is fed as a parameter to the callback. In either case, the value is fed as a parameter to the callback.
The hook dies after successfully invoking callback. The hook dies after successfully invoking callback.
""" """
def __init__(self, def __init__(self,
callback: Callable[[slack_util.Event, Union[ActionVal, str]], MsgAction], callback: Callable[[slack_util.Event, Union[ActionVal, str]], MsgAction],
action_bindings: Optional[Dict[str, ActionVal]], # The action_id -> value mapping. Value fed to callback action_bindings: Optional[Dict[str, ActionVal]], # The action_id -> value mapping. Value fed to callback
conversation: slack_util.Conversation, # Where the message is posted conversation: slack_util.Conversation, # Where the message is posted
message_ts: str, # Which message contains the block we care about message_ts: str, # Which message contains the block we care about
lifetime: float, # How long to keep listening lifetime: float, # How long to keep listening
on_expire: Optional[Callable[[], None]] # Function to call on death by timeout. For instance, if you want to delete the message, or edit it to say "timed out" on_expire: Optional[Callable[[], None]] # Function to call on death by timeout. For instance, if you want to delete the message, or edit it to say "timed out"
): ):
super().__init__(True) super().__init__(True)
self.callback = callback self.callback = callback
self.bindings = action_bindings self.bindings = action_bindings
self.conversation = conversation self.conversation = conversation
self.message_ts = message_ts self.message_ts = message_ts
self.lifetime = lifetime self.lifetime = lifetime
self.start_time = time() self.start_time = time()
self.on_expire = on_expire self.on_expire = on_expire
self.dead = False self.dead = False
def try_apply(self, event: slack_util.Event) -> Optional[MsgAction]: def try_apply(self, event: slack_util.Event) -> Optional[MsgAction]:
# First check: are we dead of age yet? # First check: are we dead of age yet?
time_alive = time() - self.start_time time_alive = time() - self.start_time
should_expire = time_alive > self.lifetime should_expire = time_alive > self.lifetime
# If so, give up the ghost # If so, give up the ghost
if self.dead or should_expire: if self.dead or should_expire:
if self.on_expire and should_expire: # Call on_expire callback if we expired and it exists if self.on_expire and should_expire: # Call on_expire callback if we expired and it exists
self.on_expire() self.on_expire()
raise HookDeath() raise HookDeath()
# Next make sure we've got an interaction # Next make sure we've got an interaction
if not (event.interaction and event.message and event.user and event.conversation): if not (event.interaction and event.message and event.user and event.conversation):
return None return None
# Otherwise proceed normally # Otherwise proceed normally
# Is the msg the one we care about? If not, ignore # Is the msg the one we care about? If not, ignore
if event.message.ts != self.message_ts or event.conversation.get_conversation() != self.conversation: if event.message.ts != self.message_ts or event.conversation.get_conversation() != self.conversation:
return None return None
# Lookup the binding if we can/need to # Lookup the binding if we can/need to
value = event.interaction.action_value value = event.interaction.action_value
if value is None and self.bindings is not None: if value is None and self.bindings is not None:
value = self.bindings.get(event.interaction.action_id) value = self.bindings.get(event.interaction.action_id)
# If the value is still none, we have an issue! # If the value is still none, we have an issue!
if value is None: if value is None:
logging.error("Couldn't find an appropriate value for interaction {}".format(event.interaction)) logging.error("Couldn't find an appropriate value for interaction {}".format(event.interaction))
return None return None
# Call the callback # Call the callback
self.dead = True self.dead = True
return self.callback(event, value) return self.callback(event, value)
class Passive(object): class Passive(object):
""" """
Base class for Periodical tasks, such as reminders and stuff Base class for Periodical tasks, such as reminders and stuff
""" """
async def run(self) -> None: async def run(self) -> None:
# Run this passive routed through the specified slack client. # Run this passive routed through the specified slack client.
raise NotImplementedError() raise NotImplementedError()

0
maifest.scm Normal file
View File

208
main.py
View File

@ -1,103 +1,105 @@
import asyncio import asyncio
import textwrap import textwrap
from typing import Match from typing import Match
import hooks import hooks
import settings import settings
from plugins import identifier, job_commands, management_commands, periodicals, scroll_util, slavestothemachine from plugins import identifier, job_commands, management_commands, periodicals, scroll_util, slavestothemachine
import client import client
import slack_util import slack_util
import logging import logging
logging.basicConfig(filename=settings.LOGFILE, filemode="w", level=logging.DEBUG, format='#!# %(levelname)s - %(asctime)s \n%(message)s \n', datefmt='%m/%d/%Y %I:%M:%S %p') logging.basicConfig(filename=settings.LOGFILE, filemode="w", level=logging.DEBUG, format='#!# %(levelname)s - %(asctime)s \n%(message)s \n', datefmt='%m/%d/%Y %I:%M:%S %p')
def main() -> None: def main() -> None:
wrap = client.get_slack() wrap = client.get_slack()
# Add scroll handling # Add scroll handling
wrap.add_hook(scroll_util.scroll_hook) wrap.add_hook(scroll_util.scroll_hook)
# Add id handling # Add id handling
wrap.add_hook(identifier.check_hook) wrap.add_hook(identifier.check_hook)
wrap.add_hook(identifier.identify_hook) wrap.add_hook(identifier.identify_hook)
wrap.add_hook(identifier.identify_other_hook) wrap.add_hook(identifier.identify_other_hook)
wrap.add_hook(identifier.name_hook) wrap.add_hook(identifier.name_hook)
# Add kill switch # Add kill switch
wrap.add_hook(management_commands.reboot_hook) wrap.add_hook(management_commands.reboot_hook)
wrap.add_hook(management_commands.log_hook) wrap.add_hook(management_commands.log_hook)
# Add towel rolling # Add towel rolling
wrap.add_hook(slavestothemachine.count_work_hook) wrap.add_hook(slavestothemachine.count_work_hook)
# wrap.add_hook(slavestothemachine.dump_work_hook) # wrap.add_hook(slavestothemachine.dump_work_hook)
# Add job management # Add job management
wrap.add_hook(job_commands.signoff_hook) wrap.add_hook(job_commands.signoff_hook)
wrap.add_hook(job_commands.late_hook) wrap.add_hook(job_commands.undo_hook)
wrap.add_hook(job_commands.reset_hook) wrap.add_hook(job_commands.late_hook)
wrap.add_hook(job_commands.nag_hook) wrap.add_hook(job_commands.reset_hook)
wrap.add_hook(job_commands.reassign_hook) wrap.add_hook(job_commands.nag_hook)
wrap.add_hook(job_commands.refresh_hook) wrap.add_hook(job_commands.reassign_hook)
wrap.add_hook(job_commands.refresh_hook)
# Add help
wrap.add_hook(hooks.ChannelHook(help_callback, patterns=[r"help", r"bot\s+help"])) # Add help
wrap.add_hook(hooks.ChannelHook(help_callback, patterns=[r"help", r"bot\s+help"]))
# Add boozebot
# wrap.add_passive(periodicals.ItsTenPM()) # Add boozebot
# wrap.add_passive(periodicals.ItsTenPM())
# Add automatic updating of users
wrap.add_passive(periodicals.Updatinator(wrap, 120)) # Add automatic updating of users
wrap.add_passive(periodicals.Updatinator(wrap, 120))
# Do test.
wrap.add_passive(periodicals.TestPassive()) # Do test.
wrap.add_passive(periodicals.TestPassive())
# Add nagloop
wrap.add_passive(periodicals.NotifyJobs()) # Add nagloop
wrap.add_passive(periodicals.RemindJobs()) wrap.add_passive(periodicals.NotifyJobs())
wrap.add_passive(periodicals.RemindJobs())
event_loop = asyncio.get_event_loop()
event_loop.set_debug(settings.USE_ASYNC_DEBUG_MODE) event_loop = asyncio.get_event_loop()
event_handling = wrap.handle_events() event_loop.set_debug(settings.USE_ASYNC_DEBUG_MODE)
passive_handling = wrap.run_passives() event_handling = wrap.handle_events()
both = asyncio.gather(event_handling, passive_handling) passive_handling = wrap.run_passives()
both = asyncio.gather(event_handling, passive_handling)
event_loop.run_until_complete(both)
event_loop.run_until_complete(both)
# noinspection PyUnusedLocal
async def help_callback(event: slack_util.Event, match: Match) -> None: # noinspection PyUnusedLocal
client.get_slack().reply(event, textwrap.dedent(""" async def help_callback(event: slack_util.Event, match: Match) -> None:
Commands are as follows. Note that some only work in certain channels. client.get_slack().reply(event, textwrap.dedent("""
"my scroll is number" : Registers your slack account to have a certain scroll, for the purpose of automatic dm's. Commands are as follows. Note that some only work in certain channels.
"@person has scroll number" : same as above, but for other users. Helpful if they are being obstinate. "my scroll is number" : Registers your slack account to have a certain scroll, for the purpose of automatic dm's.
"what is my scroll" : Echos back what the bot thinks your scroll is. Largely for debugging. "@person has scroll number" : same as above, but for other users. Helpful if they are being obstinate.
"what is my name" : Echos back what the bot thinks your name is. Largely for debugging. If you want to change this, "what is my scroll" : Echos back what the bot thinks your scroll is. Largely for debugging.
you'll need to fix the "Sorted family tree" file that the bot reads. Sorry. "what is my name" : Echos back what the bot thinks your name is. Largely for debugging. If you want to change this,
"channel id #wherever" : Debug command to get a slack channels full ID you'll need to fix the "Sorted family tree" file that the bot reads. Sorry.
"reboot" : Restarts the server. "channel id #wherever" : Debug command to get a slack channels full ID
"signoff John Doe" : Sign off a brother's house job. Will prompt for more information if needed. "reboot" : Restarts the server.
"marklate John Doe" : Same as above, but to mark a job as being completed but having been done late. "signoff John Doe" : Sign off a brother's house job. Will prompt for more information if needed.
"reassign John Doe -> James Deer" : Reassign a house job. "marklate John Doe" : Same as above, but to mark a job as being completed but having been done late.
"undo signoff John Doe" : Marks a brother's house job as incomplete. Useful if you fucked up. "reassign John Doe -> James Deer" : Reassign a house job.
"nagjobs day" : Notify in general the house jobs for the week. "undo signoff John Doe" : Marks a brother's house job as incomplete. Useful if you fucked up.
"reset signoffs" : Clear points for the week, and undo all signoffs. Not frequently useful, admin only. "reset signoffs" : Clear points for the week, and undo all signoffs. Not frequently useful, admin only.
"refresh points" : Updates house job / signoff points for the week, after manual edits to the sheet. Admin only. "refresh points" : Updates house job / signoff points for the week, after manual edits to the sheet. Admin only.
"help" : You're reading it. This is all it does. What do you want from me? "help" : You're reading it. This is all it does. What do you want from me?
--- ---
Also of note is that in #slavestothemachine, any wording of the format "replaced <number>", or similarly with Also of note is that in #slavestothemachine, any wording of the format "replaced <number>", or similarly with
"washed", "dried", "rolled", or "flaked", will track your effort for the week. "washed", "dried", "rolled", or "flaked", will track your effort for the week.
Github is https://github.com/TheVillageIdiot2/waitonbot Github is https://github.com/TheVillageIdiot2/waitonbot
Man in charge is Jacob Henry, but nothing lasts forever. Man in charge is Jacob Henry, but nothing lasts forever.
"""))
# Do not let my efforts fall to waste. Its a pitious legacy but its something, at least, to maybe tide the Edit January 2023: Patched by Andrew Kerekon, 1126. Contact arkerekon@wpi.edu if anything breaks and I'll do my best to fix it!
# unending flow of work for poor Niko. """))
# Do not let my efforts fall to waste. Its a pitious legacy but its something, at least, to maybe tide the
# unending flow of work for poor Niko.
# run main
if __name__ == '__main__':
main() # run main
if __name__ == '__main__':
main()

View File

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

View File

@ -1,142 +1,142 @@
""" """
Allows users to register their user account as a specific scroll Allows users to register their user account as a specific scroll
""" """
import asyncio import asyncio
import shelve import shelve
from typing import List, Match from typing import List, Match
import hooks import hooks
from plugins import scroll_util from plugins import scroll_util
import client import client
import slack_util import slack_util
# The following db maps SLACK_USER_ID -> SCROLL_INTEGER # The following db maps SLACK_USER_ID -> SCROLL_INTEGER
DB_NAME = "user_scrolls" DB_NAME = "user_scrolls"
DB_LOCK = asyncio.Lock() DB_LOCK = asyncio.Lock()
# Initialize the hooks # Initialize the hooks
NON_REG_MSG = ("You currently have no scroll registered. To register, type\n" NON_REG_MSG = ("You currently have no scroll registered. To register, type\n"
"i am 666\n" "i am 666\n"
"except with your scroll instead of 666") "except with your scroll instead of 666")
async def identify_callback(event: slack_util.Event, match: Match): async def identify_callback(event: slack_util.Event, match: Match):
""" """
Sets the users scroll Sets the users scroll
""" """
async with DB_LOCK: async with DB_LOCK:
with shelve.open(DB_NAME) as db: with shelve.open(DB_NAME) as db:
# Get the query # Get the query
query = match.group(1).strip() query = match.group(1).strip()
try: try:
user = event.user.user_id user = event.user.user_id
scroll = int(query) scroll = int(query)
db[user] = scroll db[user] = scroll
result = "Updated user {} to have scroll {}".format(user, scroll) result = "Updated user {} to have scroll {}".format(user, scroll)
except ValueError: except ValueError:
result = "Bad scroll: {}".format(query) result = "Bad scroll: {}".format(query)
# Respond # Respond
client.get_slack().reply(event, result) client.get_slack().reply(event, result)
async def identify_other_callback(event: slack_util.Event, match: Match): async def identify_other_callback(event: slack_util.Event, match: Match):
""" """
Sets another users scroll Sets another users scroll
""" """
async with DB_LOCK: async with DB_LOCK:
with shelve.open(DB_NAME) as db: with shelve.open(DB_NAME) as db:
# Get the query # Get the query
user = match.group(1).strip() user = match.group(1).strip()
scroll_txt = match.group(2).strip() scroll_txt = match.group(2).strip()
try: try:
scroll = int(scroll_txt) scroll = int(scroll_txt)
if user in db: if user in db:
result = "To prevent trolling, once a users id has been set only they can change it" result = "To prevent trolling, once a users id has been set only they can change it"
else: else:
db[user] = scroll db[user] = scroll
result = "Updated user {} to have scroll {}".format(user, scroll) result = "Updated user {} to have scroll {}".format(user, scroll)
except ValueError: except ValueError:
result = "Bad scroll: {}".format(scroll_txt) result = "Bad scroll: {}".format(scroll_txt)
# Respond # Respond
client.get_slack().reply(event, result) client.get_slack().reply(event, result)
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
async def check_callback(event: slack_util.Event, match: Match): async def check_callback(event: slack_util.Event, match: Match):
""" """
Replies with the users current scroll assignment Replies with the users current scroll assignment
""" """
async with DB_LOCK: async with DB_LOCK:
# Tells the user their current scroll # Tells the user their current scroll
with shelve.open(DB_NAME) as db: with shelve.open(DB_NAME) as db:
try: try:
scroll = db[event.user.user_id] scroll = db[event.user.user_id]
result = "You are currently registered with scroll {}".format(scroll) result = "You are currently registered with scroll {}".format(scroll)
except KeyError: except KeyError:
result = NON_REG_MSG result = NON_REG_MSG
client.get_slack().reply(event, result) client.get_slack().reply(event, result)
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
async def name_callback(event: slack_util.Event, match: Match): async def name_callback(event: slack_util.Event, match: Match):
""" """
Tells the user what it thinks the calling users name is. Tells the user what it thinks the calling users name is.
""" """
async with DB_LOCK: async with DB_LOCK:
with shelve.open(DB_NAME) as db: with shelve.open(DB_NAME) as db:
try: try:
scroll = db[event.user.user_id] scroll = db[event.user.user_id]
brother = scroll_util.find_by_scroll(scroll) brother = scroll_util.find_by_scroll(scroll)
if brother: if brother:
result = "The bot thinks your name is {}".format(brother.name) result = "The bot thinks your name is {}".format(brother.name)
else: else:
result = "The bot couldn't find a name for scroll {}".format(scroll) result = "The bot couldn't find a name for scroll {}".format(scroll)
except (KeyError, ValueError): except (KeyError, ValueError):
result = NON_REG_MSG result = NON_REG_MSG
# Respond # Respond
client.get_slack().reply(event, result) client.get_slack().reply(event, result)
async def lookup_slackid_brother(slack_id: str) -> scroll_util.Brother: async def lookup_slackid_brother(slack_id: str) -> scroll_util.Brother:
""" """
Gets whatever brother the userid is registered to Gets whatever brother the userid is registered to
:raises BrotherNotFound: :raises BrotherNotFound:
:return: Brother object or None :return: Brother object or None
""" """
async with DB_LOCK: async with DB_LOCK:
with shelve.open(DB_NAME) as db: with shelve.open(DB_NAME) as db:
try: try:
scroll = db[slack_id] scroll = db[slack_id]
return scroll_util.find_by_scroll(scroll) return scroll_util.find_by_scroll(scroll)
except (KeyError, ValueError): except (KeyError, ValueError):
raise scroll_util.BrotherNotFound("Slack id {} not tied to brother".format(slack_id)) raise scroll_util.BrotherNotFound("Slack id {} not tied to brother".format(slack_id))
raise scroll_util.BrotherNotFound("Couldn't find an appropriate brother for slackid {}.".format(slack_id)) raise scroll_util.BrotherNotFound("Couldn't find an appropriate brother for slackid {}.".format(slack_id))
async def lookup_brother_userids(brother: scroll_util.Brother) -> List[str]: async def lookup_brother_userids(brother: scroll_util.Brother) -> List[str]:
""" """
Returns a list of all userids associated with the given brother. Returns a list of all userids associated with the given brother.
:param brother: Brother to lookup scrolls for :param brother: Brother to lookup scrolls for
:return: List of user id strings (may be empty) :return: List of user id strings (may be empty)
""" """
async with DB_LOCK: async with DB_LOCK:
with shelve.open(DB_NAME) as db: with shelve.open(DB_NAME) as db:
keys = db.keys() keys = db.keys()
result = [] result = []
for user_id in keys: for user_id in keys:
if db[user_id] == brother.scroll: if db[user_id] == brother.scroll:
result.append(user_id) result.append(user_id)
return result return result
identify_hook = hooks.ChannelHook(identify_callback, patterns=r"my scroll is (.*)") 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+(.*)") 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") check_hook = hooks.ChannelHook(check_callback, patterns=r"what is my scroll")
name_hook = hooks.ChannelHook(name_callback, patterns=r"what is my name") name_hook = hooks.ChannelHook(name_callback, patterns=r"what is my name")

View File

@ -1,424 +1,428 @@
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Match, Callable, TypeVar, Optional, Iterable, Any, Coroutine from typing import List, Match, Callable, TypeVar, Optional, Iterable, Any, Coroutine
from fuzzywuzzy import fuzz from fuzzywuzzy import fuzz
import hooks import hooks
from plugins import identifier, house_management, scroll_util from plugins import identifier, house_management, scroll_util
import client import client
import slack_util import slack_util
SHEET_ID = "1lPj9GjB00BuIq9GelOWh5GmiGsheLlowPnHLnWBvMOM" SHEET_ID = "1f9p4H7TWPm8rAM4v_qr2Vc6lBiFNEmR-quTY9UtxEBI"
MIN_RATIO = 80.0 MIN_RATIO = 80.0
async def alert_user(brother: scroll_util.Brother, saywhat: str) -> None: async def alert_user(brother: scroll_util.Brother, saywhat: str) -> None:
""" """
DM a brother saying something. Wrapper around several simpler methods 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) # 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 succ = False
for slack_id in await identifier.lookup_brother_userids(brother): for slack_id in await identifier.lookup_brother_userids(brother):
client.get_slack().send_message(saywhat, slack_id) client.get_slack().send_message(saywhat, slack_id)
succ = True succ = True
# Warn if we never find # Warn if we never find
if not succ: if not succ:
logging.warning("Unable to find dm conversation for brother {}".format(brother)) logging.warning("Unable to find dm conversation for brother {}".format(brother))
# Generic type # Generic type
T = TypeVar("T") T = TypeVar("T")
def tiemax(items: Iterable[T], key: Callable[[T], Optional[float]]) -> List[T]: def tiemax(items: Iterable[T], key: Callable[[T], Optional[float]]) -> List[T]:
best = [] best = []
best_score = None best_score = None
for elt in items: for elt in items:
# Compute the score # Compute the score
score = key(elt) score = key(elt)
# Ignore blank scores # Ignore blank scores
if score is None: if score is None:
continue continue
# Check if its the new best, wiping old if so # Check if its the new best, wiping old if so
elif best_score is None or score > best_score: elif best_score is None or score > best_score:
best_score = score best_score = score
best = [elt] best = [elt]
# Check if its same as last best # Check if its same as last best
elif score == best_score: elif score == best_score:
best.append(elt) best.append(elt)
return best return best
@dataclass @dataclass
class _ModJobContext: class _ModJobContext:
signer: scroll_util.Brother # The brother invoking the command signer: scroll_util.Brother # The brother invoking the command
assign: house_management.JobAssignment # The job assignment to modify assign: house_management.JobAssignment # The job assignment to modify
async def _mod_jobs(event: slack_util.Event, async def _mod_jobs(event: slack_util.Event,
relevance_scorer: Callable[[house_management.JobAssignment], Optional[float]], relevance_scorer: Callable[[house_management.JobAssignment], Optional[float]],
modifier: Callable[[_ModJobContext], Coroutine[Any, Any, None]], modifier: Callable[[_ModJobContext], Coroutine[Any, Any, None]],
no_job_msg: str = None no_job_msg: str = None
) -> None: ) -> None:
""" """
Stub function that handles various tasks relating to modifying jobs Stub function that handles various tasks relating to modifying jobs
:param relevance_scorer: Function scores job assignments on relevance. Determines which gets modified :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 :param modifier: Callback function to modify a job. Only called on a successful operation, and only on one job
""" """
# Make an error wrapper # Make an error wrapper
verb = slack_util.VerboseWrapper(event) verb = slack_util.VerboseWrapper(event)
# Who invoked this command? # Who invoked this command?
signer = await verb(event.user.as_user().get_brother()) signer = await verb(event.user.as_user().get_brother())
# Get all of the assignments # Get all of the assignments
assigns = await verb(house_management.import_assignments()) assigns = await verb(house_management.import_assignments())
# Find closest assignment to what we're after. This just wraps relevance_scorer to handle nones. # 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]: def none_scorer(a: Optional[house_management.JobAssignment]) -> Optional[float]:
if a is None: if a is None:
return None return None
else: else:
return relevance_scorer(a) return relevance_scorer(a)
closest_assigns = tiemax(assigns, key=none_scorer) 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 # 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: async def success_callback(targ_assign: house_management.JobAssignment) -> None:
# First get the most up to date version of the jobs # First get the most up to date version of the jobs
fresh_assigns = await verb(house_management.import_assignments()) fresh_assigns = await verb(house_management.import_assignments())
# Find the one that matches what we had before # Find the one that matches what we had before
fresh_targ_assign = fresh_assigns[fresh_assigns.index(targ_assign)] fresh_targ_assign = fresh_assigns[fresh_assigns.index(targ_assign)]
# Create the context # Create the context
context = _ModJobContext(signer, fresh_targ_assign) context = _ModJobContext(signer, fresh_targ_assign)
# Modify it # Modify it
await modifier(context) await modifier(context)
# Re-upload # Re-upload
await house_management.export_assignments(fresh_assigns) await house_management.export_assignments(fresh_assigns)
# Also import and update points # Also import and update points
headers, points = await house_management.import_points() headers, points = await house_management.import_points()
house_management.apply_house_points(points, fresh_assigns) house_management.apply_house_points(points, fresh_assigns)
house_management.export_points(headers, points) house_management.export_points(headers, points)
# If there aren't any jobs, say so # If there aren't any jobs, say so
if len(closest_assigns) == 0: if len(closest_assigns) == 0:
if no_job_msg is None: 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." 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) client.get_slack().reply(event, no_job_msg)
# If theres only one job, sign it off # If theres only one job, sign it off
elif len(closest_assigns) == 1: elif len(closest_assigns) == 1:
await success_callback(closest_assigns[0]) await success_callback(closest_assigns[0])
# If theres multiple jobs, we need to get a follow up! # If theres multiple jobs, we need to get a follow up!
else: else:
# Say we need more info # Say we need more info
job_list = "\n".join("{}: {}".format(i, a.job.pretty_fmt()) for i, a in enumerate(closest_assigns)) 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" client.get_slack().reply(event, "Multiple relevant job listings found.\n"
"Please enter the number corresponding to the job " "Please enter the number corresponding to the job "
"you wish to modify:\n{}".format(job_list)) "you wish to modify:\n{}".format(job_list))
# Establish a follow up command pattern # Establish a follow up command pattern
pattern = r"\d+" pattern = r"\d+"
# Make the follow up callback # Make the follow up callback
async def foc(_event: slack_util.Event, _match: Match) -> None: async def foc(_event: slack_util.Event, _match: Match) -> None:
# Get the number out # Get the number out
index = int(_match.group(0)) index = int(_match.group(0))
# Check that its valid # Check that its valid
if 0 <= index < len(closest_assigns): if 0 <= index < len(closest_assigns):
# We now know what we're trying to sign off! # We now know what we're trying to sign off!
await success_callback(closest_assigns[index]) await success_callback(closest_assigns[index])
else: else:
# They gave a bad index, or we were unable to find the assignment again. # 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.") client.get_slack().reply(_event, "Invalid job index / job unable to be found.")
# Make a listener hook # Make a listener hook
new_hook = hooks.ReplyWaiter(foc, pattern, event.message.ts, 120) new_hook = hooks.ReplyWaiter(foc, pattern, event.message.ts, 120)
# Register it # Register it
client.get_slack().add_hook(new_hook) client.get_slack().add_hook(new_hook)
async def signoff_callback(event: slack_util.Event, match: Match) -> None: async def signoff_callback(event: slack_util.Event, match: Match) -> None:
verb = slack_util.VerboseWrapper(event) verb = slack_util.VerboseWrapper(event)
# Find out who we are trying to sign off is print("Receving a signoff request!!!")
signee_name = match.group(1) # Find out who we are trying to sign off is
signee = await verb(scroll_util.find_by_name(signee_name, MIN_RATIO)) 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): # Score by name similarity, only accepting non-assigned jobs
if assign.assignee is not None: def scorer(assign: house_management.JobAssignment):
r = fuzz.ratio(signee.name, assign.assignee.name) if assign.assignee is not None:
if assign.signer is None and r > MIN_RATIO: r = fuzz.ratio(signee.name, assign.assignee.name)
return r if assign.signer is None and r > MIN_RATIO:
return r
# Set the assigner, and notify
async def modifier(context: _ModJobContext): # Set the assigner, and notify
context.assign.signer = context.signer 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, # Say we did it wooo!
context.assign.job.name)) client.get_slack().reply(event, "Signed off {} for {}".format(context.assign.assignee.name,
await alert_user(context.assign.assignee, "{} signed you off for {}.".format(context.assign.signer.name, context.assign.job.name))
context.assign.job.pretty_fmt())) 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) # 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) 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) print("Receving an unsignoff request!!!")
signee = await verb(scroll_util.find_by_name(signee_name, MIN_RATIO)) # Find out who we are trying to sign off is
signee_name = match.group(1)
# Score by name similarity, only accepting jobs that are signed off signee = await verb(scroll_util.find_by_name(signee_name, MIN_RATIO))
def scorer(assign: house_management.JobAssignment):
if assign.assignee is not None: # Score by name similarity, only accepting jobs that are signed off
r = fuzz.ratio(signee.name, assign.assignee.name) def scorer(assign: house_management.JobAssignment):
if assign.signer is not None and r > MIN_RATIO: if assign.assignee is not None:
return r r = fuzz.ratio(signee.name, assign.assignee.name)
if assign.signer is not None and r > MIN_RATIO:
# Set the assigner to be None, and notify return r
async def modifier(context: _ModJobContext):
context.assign.signer = None # Set the assigner to be None, and notify
async def modifier(context: _ModJobContext):
# Say we did it wooo! context.assign.signer = None
client.get_slack().reply(event, "Undid signoff of {} for {}".format(context.assign.assignee.name,
context.assign.job.name)) # Say we did it wooo!
await alert_user(context.assign.assignee, "{} undid your signoff off for {}.\n" client.get_slack().reply(event, "Undid signoff of {} for {}".format(context.assign.assignee.name,
"Must have been a mistake".format(context.assign.signer.name, context.assign.job.name))
context.assign.job.pretty_fmt())) await alert_user(context.assign.assignee, "{} undid your signoff off for {}.\n")
# Fire it off # Fire it off
await _mod_jobs(event, scorer, modifier) await _mod_jobs(event, scorer, modifier)
async def late_callback(event: slack_util.Event, match: Match) -> None: async def late_callback(event: slack_util.Event, match: Match) -> None:
verb = slack_util.VerboseWrapper(event) verb = slack_util.VerboseWrapper(event)
# Find out who we are trying to sign off is # Find out who we are trying to sign off is
signee_name = match.group(1) signee_name = match.group(1)
signee = await verb(scroll_util.find_by_name(signee_name, MIN_RATIO)) signee = await verb(scroll_util.find_by_name(signee_name, MIN_RATIO))
# Score by name similarity. Don't care if signed off or not # Score by name similarity. Don't care if signed off or not
def scorer(assign: house_management.JobAssignment): def scorer(assign: house_management.JobAssignment):
if assign.assignee is not None: if assign.assignee is not None:
r = fuzz.ratio(signee.name, assign.assignee.name) r = fuzz.ratio(signee.name, assign.assignee.name)
if r > MIN_RATIO: if r > MIN_RATIO:
return r return r
# Just set the assigner # Just set the assigner
async def modifier(context: _ModJobContext): async def modifier(context: _ModJobContext):
context.assign.late = not context.assign.late context.assign.late = not context.assign.late
# Say we did it # Say we did it
client.get_slack().reply(event, "Toggled lateness of {}.\n" client.get_slack().reply(event, "Toggled lateness of {}.\n"
"Now marked as late: {}".format(context.assign.job.pretty_fmt(), "Now marked as late: {}".format(context.assign.job.pretty_fmt(),
context.assign.late)) context.assign.late))
# Fire it off # Fire it off
await _mod_jobs(event, scorer, modifier) await _mod_jobs(event, scorer, modifier)
async def reassign_callback(event: slack_util.Event, match: Match) -> None: async def reassign_callback(event: slack_util.Event, match: Match) -> None:
verb = slack_util.VerboseWrapper(event) verb = slack_util.VerboseWrapper(event)
# Find out our two targets # Find out our two targets
from_name = match.group(1).strip() from_name = match.group(1).strip()
to_name = match.group(2).strip() to_name = match.group(2).strip()
# Get them as brothers # Get them as brothers
from_bro = await verb(scroll_util.find_by_name(from_name, MIN_RATIO)) 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)) 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, # 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?) # as we want to be able to transfer even after signoffs (why not, amirite?)
def scorer(assign: house_management.JobAssignment): def scorer(assign: house_management.JobAssignment):
if assign.assignee is not None: if assign.assignee is not None:
r = fuzz.ratio(from_bro.name, assign.assignee.name) r = fuzz.ratio(from_bro.name, assign.assignee.name)
if r > MIN_RATIO: if r > MIN_RATIO:
return r return r
# Change the assignee # Change the assignee
async def modifier(context: _ModJobContext): async def modifier(context: _ModJobContext):
context.assign.assignee = to_bro context.assign.assignee = to_bro
# Say we did it # Say we did it
reassign_msg = "Job {} reassigned from {} to {}".format(context.assign.job.pretty_fmt(), reassign_msg = "Job {} reassigned from {} to {}".format(context.assign.job.pretty_fmt(),
from_bro, from_bro,
to_bro) to_bro)
client.get_slack().reply(event, reassign_msg) client.get_slack().reply(event, reassign_msg)
# Tell the people # Tell the people
reassign_msg = "Job {} reassigned from {} to {}".format(context.assign.job.pretty_fmt(), reassign_msg = "Job {} reassigned from {} to {}".format(context.assign.job.pretty_fmt(),
from_bro, from_bro,
to_bro) to_bro)
await alert_user(from_bro, reassign_msg) await alert_user(from_bro, reassign_msg)
await alert_user(to_bro, reassign_msg) await alert_user(to_bro, reassign_msg)
# Fire it off # Fire it off
await _mod_jobs(event, scorer, modifier) await _mod_jobs(event, scorer, modifier)
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
async def reset_callback(event: slack_util.Event, match: Match) -> None: async def reset_callback(event: slack_util.Event, match: Match) -> None:
""" """
Resets the scores. Resets the scores.
""" """
# Unassign everything # Unassign everything
assigns = await house_management.import_assignments() assigns = await house_management.import_assignments()
for a in assigns: for a in assigns:
if a is not None: if a is not None:
a.signer = None a.signer = None
await house_management.export_assignments(assigns) await house_management.export_assignments(assigns)
# Now wipe points # Now wipe points
headers, points = house_management.import_points() headers, points = house_management.import_points()
# Set to 0/default # Set to 0/default
for i in range(len(points)): for i in range(len(points)):
new = house_management.PointStatus(brother=points[i].brother) new = house_management.PointStatus(brother=points[i].brother)
points[i] = new points[i] = new
house_management.apply_house_points(points, await house_management.import_assignments()) house_management.apply_house_points(points, await house_management.import_assignments())
house_management.export_points(headers, points) house_management.export_points(headers, points)
client.get_slack().reply(event, "Reset scores and signoffs") client.get_slack().reply(event, "Reset scores and signoffs")
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
async def refresh_callback(event: slack_util.Event, match: Match) -> None: async def refresh_callback(event: slack_util.Event, match: Match) -> None:
headers, points = await house_management.import_points() print("Received request to reset points")
house_management.apply_house_points(points, await house_management.import_assignments()) headers, points = await house_management.import_points()
house_management.export_points(headers, points) print("Received request to reset points 1")
client.get_slack().reply(event, "Force updated point values") house_management.apply_house_points(points, await house_management.import_assignments())
print("Received request to reset points 2")
house_management.export_points(headers, points)
async def nag_callback(event: slack_util.Event, match: Match) -> None: print("Received request to reset points 3")
# Get the day client.get_slack().reply(event, "Force updated point values")
day = match.group(1).lower().strip()
if not await nag_jobs(day):
client.get_slack().reply(event, async def nag_callback(event: slack_util.Event, match: Match) -> None:
"No jobs found. Check that the day is spelled correctly, with no extra symbols.\n" # Get the day
"It is possible that all jobs have been signed off, as well.", day = match.group(1).lower().strip()
in_thread=True) 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"
# Wrapper so we can auto-call this as well "It is possible that all jobs have been signed off, as well.",
async def nag_jobs(day_of_week: str) -> bool: in_thread=True)
# Get the assigns
assigns = await house_management.import_assignments()
# Wrapper so we can auto-call this as well
# Filter to day async def nag_jobs(day_of_week: str) -> bool:
assigns = [assign for assign in assigns if assign is not None and assign.job.day_of_week.lower() == day_of_week] # Get the assigns
assigns = await house_management.import_assignments()
# Filter signed off
assigns = [assign for assign in assigns if assign.signer is None] # Filter to day
assigns = [assign for assign in assigns if assign is not None and assign.job.day_of_week.lower() == day_of_week]
# If no jobs found, somethings up. Probably mispelled day. Return failure
if not assigns: # Filter signed off
return False assigns = [assign for assign in assigns if assign.signer is None]
# Nag each # If no jobs found, somethings up. Probably mispelled day. Return failure
response = "Do yer jerbs! They are as follows:\n" if not assigns:
for assign in assigns: return False
# Make the row template
if assign.assignee is None: # Nag each
continue response = "Do yer jerbs! They are as follows:\n"
response += "({}) {} -- {} ".format(assign.job.house, assign.job.name, assign.assignee.name) for assign in assigns:
# Make the row template
# Find the people to @ if assign.assignee is None:
brother_slack_ids = await identifier.lookup_brother_userids(assign.assignee) continue
response += "({}) {} -- {} ".format(assign.job.house, assign.job.name, assign.assignee.name)
if brother_slack_ids:
for slack_id in brother_slack_ids: # Find the people to @
response += "<@{}> ".format(slack_id) brother_slack_ids = await identifier.lookup_brother_userids(assign.assignee)
else:
response += "(scroll missing. Please register for @ pings!)" if brother_slack_ids:
response += "\n" for slack_id in brother_slack_ids:
response += "<@{}> ".format(slack_id)
general_id = client.get_slack().get_conversation_by_name("#general").id else:
client.get_slack().send_message(response, general_id) response += "(scroll missing. Please register for @ pings!)"
return True response += "\n"
general_id = client.get_slack().get_conversation_by_name("#general").id
signoff_hook = hooks.ChannelHook(signoff_callback, client.get_slack().send_message(response, general_id)
patterns=[ return True
r"signoff\s+(.*)",
r"sign off\s+(.*)",
], signoff_hook = hooks.ChannelHook(signoff_callback,
channel_whitelist=["#housejobs"]) patterns=[
r"signoff\s+(.*)",
undo_hook = hooks.ChannelHook(undo_callback, r"sign off\s+(.*)",
patterns=[ ],
r"unsignoff\s+(.*)", channel_whitelist=["#housejobs"])
r"undosignoff\s+(.*)",
r"undo signoff\s+(.*)", undo_hook = hooks.ChannelHook(undo_callback,
], patterns=[
channel_whitelist=["#housejobs"]) r"unsignoff\s+(.*)",
r"undosignoff\s+(.*)",
late_hook = hooks.ChannelHook(late_callback, r"undo signoff\s+(.*)",
patterns=[ ],
r"marklate\s+(.*)", channel_whitelist=["#housejobs"])
r"mark late\s+(.*)",
], late_hook = hooks.ChannelHook(late_callback,
channel_whitelist=["#housejobs"]) patterns=[
r"marklate\s+(.*)",
reset_hook = hooks.ChannelHook(reset_callback, r"mark late\s+(.*)",
patterns=[ ],
r"reset signoffs", channel_whitelist=["#housejobs"])
r"reset sign offs",
], reset_hook = hooks.ChannelHook(reset_callback,
channel_whitelist=["#command-center"]) patterns=[
r"reset signoffs",
nag_hook = hooks.ChannelHook(nag_callback, r"reset sign offs",
patterns=[ ],
r"nagjobs\s+(.*)", channel_whitelist=["#command-center"])
r"nag jobs\s+(.*)"
], nag_hook = hooks.ChannelHook(nag_callback,
channel_whitelist=["#command-center"]) patterns=[
r"nagjobs\s+(.*)",
reassign_hook = hooks.ChannelHook(reassign_callback, r"nag jobs\s+(.*)"
patterns=r"reassign\s+(.*?)-&gt;\s+(.+)", ],
channel_whitelist=["#housejobs"]) channel_whitelist=["#command-center"])
refresh_hook = hooks.ChannelHook(refresh_callback, reassign_hook = hooks.ChannelHook(reassign_callback,
patterns=[ patterns=r"reassign\s+(.*?)-&gt;\s+(.+)",
"refresh points", channel_whitelist=["#housejobs"])
"update points"
], refresh_hook = hooks.ChannelHook(refresh_callback,
channel_whitelist=["#command-center"]) patterns=[
"refresh points",
block_action = """ "update points"
[ ],
{ channel_whitelist=["#command-center"])
"type": "actions",
"block_id": "test_block_id", block_action = """
"elements": [ [
{ {
"type": "button", "type": "actions",
"action_id": "test_action_id", "block_id": "test_block_id",
"text": { "elements": [
"type": "plain_text", {
"text": "Send payload", "type": "button",
"emoji": false "action_id": "test_action_id",
} "text": {
} "type": "plain_text",
] "text": "Send payload",
} "emoji": false
] }
""" }
]
}
]
"""

View File

@ -1,67 +1,67 @@
from typing import Match from typing import Match
import hooks import hooks
import client import client
import settings import settings
import slack_util import slack_util
# Gracefully reboot to reload code changes # Gracefully reboot to reload code changes
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
async def reboot_callback(event: slack_util.Event, match: Match) -> None: async def reboot_callback(event: slack_util.Event, match: Match) -> None:
response = "Ok. Rebooting..." response = "Ok. Rebooting..."
client.get_slack().reply(event, response) client.get_slack().reply(event, response)
exit(0) exit(0)
async def post_log_callback(event: slack_util.Event, match: Match) -> None: async def post_log_callback(event: slack_util.Event, match: Match) -> None:
# Get the last n lines of log of the specified severity or higher # Get the last n lines of log of the specified severity or higher
count = 100 count = 100
lines = [] lines = []
# numerically rank the debug severity # numerically rank the debug severity
severity_codex = { severity_codex = {
"CRITICAL": 50, "CRITICAL": 50,
"ERROR": 40, "ERROR": 40,
"WARNING": 30, "WARNING": 30,
"INFO": 20, "INFO": 20,
"DEBUG": 10, "DEBUG": 10,
"NOTSET": 0 "NOTSET": 0
} }
curr_rating = 0 curr_rating = 0
# Get the min rating if one exists # Get the min rating if one exists
min_rating = 0 min_rating = 0
rating_str = match.group(1).upper().strip() rating_str = match.group(1).upper().strip()
for severity_name, severity_value in severity_codex.items(): for severity_name, severity_value in severity_codex.items():
if severity_name in rating_str: if severity_name in rating_str:
min_rating = severity_value min_rating = severity_value
break break
with open(settings.LOGFILE, 'r') as f: with open(settings.LOGFILE, 'r') as f:
for line in f: for line in f:
# Update the current rating if necessary # Update the current rating if necessary
if line[:3] == "#!#": if line[:3] == "#!#":
for k, v in severity_codex.items(): for k, v in severity_codex.items():
if k in line: if k in line:
curr_rating = v curr_rating = v
break break
# Add the line if its severity is at or above the required minimum # Add the line if its severity is at or above the required minimum
if curr_rating >= min_rating: if curr_rating >= min_rating:
lines.append(line) lines.append(line)
if len(lines) > count: if len(lines) > count:
del lines[0] del lines[0]
# Spew them out # Spew them out
client.get_slack().reply(event, "```" + ''.join(lines) + "```") client.get_slack().reply(event, "```" + ''.join(lines) + "```")
# Make hooks # Make hooks
reboot_hook = hooks.ChannelHook(reboot_callback, reboot_hook = hooks.ChannelHook(reboot_callback,
patterns=r"reboot", patterns=r"reboot",
channel_whitelist=["#command-center"]) channel_whitelist=["#command-center"])
log_hook = hooks.ChannelHook(post_log_callback, log_hook = hooks.ChannelHook(post_log_callback,
patterns=["post logs(.*)", "logs(.*)", "post_logs(.*)"], patterns=["post logs(.*)", "logs(.*)", "post_logs(.*)"],
channel_whitelist=["#botzone"]) channel_whitelist=["#botzone"])

View File

@ -1,230 +1,232 @@
import asyncio import asyncio
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, List from typing import Optional, List
import hooks import hooks
import slack_util import slack_util
from plugins import identifier, job_commands, house_management from plugins import identifier, job_commands, house_management
import client import client
def seconds_until(target: datetime) -> float: def seconds_until(target: datetime) -> float:
curr = datetime.now() curr = datetime.now()
# Compute seconds betwixt # Compute seconds betwixt
delta = target - curr delta = target - curr
ds = delta.seconds ds = delta.seconds
# Lower bound to 0 # Lower bound to 0
if ds < 0: if ds < 0:
return 0 return 0
else: else:
return delta.seconds return delta.seconds
class ItsTenPM(hooks.Passive): class ItsTenPM(hooks.Passive):
async def run(self) -> None: async def run(self) -> None:
while True: while True:
# Get 10PM # Get 10PM
ten_pm = datetime.now().replace(hour=22, minute=0, second=0) ten_pm = datetime.now().replace(hour=22, minute=0, second=0)
# Find out how long until it, then sleep that long # Find out how long until it, then sleep that long
delay = seconds_until(ten_pm) delay = seconds_until(ten_pm)
await asyncio.sleep(delay) await asyncio.sleep(delay)
# Crow like a rooster # Crow like a rooster
client.get_slack().send_message("IT'S 10 PM!", client client.get_slack().send_message("IT'S 10 PM!", client
.get_slack() .get_slack()
.get_conversation_by_name("#random").id) .get_conversation_by_name("#random").id)
# Wait a while before trying it again, to prevent duplicates # Wait a while before trying it again, to prevent duplicates
await asyncio.sleep(60) await asyncio.sleep(60)
# Shared behaviour # Shared behaviour
class JobNotifier: class JobNotifier:
@staticmethod @staticmethod
def get_day_of_week(time) -> str: def get_day_of_week(time) -> str:
""" """
Gets the current day of week as a str Gets the current day of week as a str
""" """
return ["Monday", return ["Monday",
"Tuesday", "Tuesday",
"Wednesday", "Wednesday",
"Thursday", "Thursday",
"Friday", "Friday",
"Saturday", "Saturday",
"Sunday"][time.weekday()] "Sunday"][time.weekday()]
@staticmethod @staticmethod
def is_job_valid(a: Optional[house_management.JobAssignment]): def is_job_valid(a: Optional[house_management.JobAssignment]):
# If it doesn't exist, it a thot # If it doesn't exist, it a thot
if a is None: if a is None:
return False return False
# If its not today, we shouldn't nag # If its not today, we shouldn't nag
if a.job.day_of_week.lower() != JobNotifier.get_day_of_week(datetime.now()).lower(): if a.job.day_of_week.lower() != JobNotifier.get_day_of_week(datetime.now()).lower():
return False return False
# If it is unassigned, we can't nag # If it is unassigned, we can't nag
if a.assignee is None: if a.assignee is None:
return False return False
# If its been signed off, no need to nag # If its been signed off, no need to nag
if a.signer is not None: if a.signer is not None:
return False return False
# If the brother wasn't recognized, don't try nagging # If the brother wasn't recognized, don't try nagging
if not a.assignee.is_valid(): if not a.assignee.is_valid():
return False return False
return True return True
class NotifyJobs(hooks.Passive, JobNotifier): class NotifyJobs(hooks.Passive, JobNotifier):
# Auto-does the nag jobs thing # Auto-does the nag jobs thing
async def run(self) -> None: async def run(self) -> None:
while True: while True:
# Get the "Start" of the current day (Say, 10AM) # Get the "Start" of the current day (Say, 10AM)
next_remind_time = datetime.now().replace(hour=10, minute=00, second=0) next_remind_time = datetime.now().replace(hour=10, minute=00, second=0)
# If we've accidentally made it in the past somehow, bump it up one date # If we've accidentally made it in the past somehow, bump it up one date
while datetime.now() > next_remind_time: while datetime.now() > next_remind_time:
next_remind_time += timedelta(days=1) next_remind_time += timedelta(days=1)
# Sleep until that time # Sleep until that time
delay = seconds_until(next_remind_time) delay = seconds_until(next_remind_time)
await asyncio.sleep(delay) await asyncio.sleep(delay)
# Now it is that time. Nag the jobs # Now it is that time. Nag the jobs
await job_commands.nag_jobs(self.get_day_of_week(next_remind_time)) await job_commands.nag_jobs(self.get_day_of_week(next_remind_time))
# Sleep for a bit to prevent double shots # Sleep for a bit to prevent double shots
await asyncio.sleep(10) await asyncio.sleep(10)
class RemindJobs(hooks.Passive, JobNotifier): class RemindJobs(hooks.Passive, JobNotifier):
async def run(self) -> None: async def run(self) -> None:
while True: while True:
# Get the end of the current day (Say, 10PM) # Get the end of the current day (Say, 10PM)
next_remind_time = datetime.now().replace(hour=22, minute=00, second=0) next_remind_time = datetime.now().replace(hour=22, minute=00, second=0)
# If we've accidentally made it in the past somehow, bump it up one date # If we've accidentally made it in the past somehow, bump it up one date
while datetime.now() > next_remind_time: while datetime.now() > next_remind_time:
next_remind_time += timedelta(days=1) next_remind_time += timedelta(days=1)
# Sleep until that time # Sleep until that time
delay = seconds_until(next_remind_time) delay = seconds_until(next_remind_time)
await asyncio.sleep(delay) await asyncio.sleep(delay)
# Now it is that time. Get the current jobs # Now it is that time. Get the current jobs
assigns = await house_management.import_assignments() assigns = await house_management.import_assignments()
# Filter to incomplete, and today # Filter to incomplete, and today
assigns: List[house_management.JobAssignment] = [a for a in assigns if self.is_job_valid(a)] 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. # Now, we want to nag each person. If we don't actually know who they are, so be it.
logging.info("Scheduled reminding people who haven't yet done their jobs.") logging.info("Scheduled reminding people who haven't yet done their jobs.")
for a in assigns: for a in assigns:
# Get the relevant slack ids # Get the relevant slack ids
assignee_ids = await identifier.lookup_brother_userids(a.assignee) assignee_ids = await identifier.lookup_brother_userids(a.assignee)
# For each, send them a DM # For each, send them a DM
success = False success = False
for slack_id in assignee_ids: for slack_id in assignee_ids:
msg = "{}, you still need to do {}".format(a.assignee.name, a.job.pretty_fmt()) msg = "{}, you still need to do {}".format(a.assignee.name, a.job.pretty_fmt())
success = True success = True
client.get_slack().send_message(msg, slack_id) client.get_slack().send_message(msg, slack_id)
# Warn on failure # Warn on failure
if not success: if not success:
logging.warning("Tried to nag {} but couldn't find their slack id".format(a.assignee.name)) logging.warning("Tried to nag {} but couldn't find their slack id".format(a.assignee.name))
# Take a break to ensure no double-shots # Take a break to ensure no double-shots
await asyncio.sleep(10) await asyncio.sleep(10)
class Updatinator(hooks.Passive): class Updatinator(hooks.Passive):
""" """
Periodically updates the channels and users in the slack Periodically updates the channels and users in the slack
""" """
def __init__(self, wrapper_to_update: client.ClientWrapper, interval_seconds: int): def __init__(self, wrapper_to_update: client.ClientWrapper, interval_seconds: int):
self.wrapper_target = wrapper_to_update self.wrapper_target = wrapper_to_update
self.interval = interval_seconds self.interval = interval_seconds
async def run(self): async def run(self):
# Give time to warmup # Give time to warmup
while True: while True:
self.wrapper_target.update_channels() self.wrapper_target.update_channels()
self.wrapper_target.update_users() self.wrapper_target.update_users()
await asyncio.sleep(self.interval) await asyncio.sleep(self.interval)
class TestPassive(hooks.Passive): class TestPassive(hooks.Passive):
""" """
Stupid shit Stupid shit
""" """
async def run(self) -> None:
lifespan = 60 async def run(self) -> None:
post_interval = 60 lifespan = 60
post_interval = 60
def make_interactive_msg():
# Send the message and recover the ts def make_interactive_msg():
response = client.get_slack().send_message("Select an option:", "#botzone", blocks=[ # Send the message and recover the ts
{ response = client.get_slack().send_message("Select an option:", "#botzone", blocks=[
"type": "actions", {
"block_id": "button_test", "type": "actions",
"elements": [ "block_id": "button_test",
{ "elements": [
"type": "button", {
"action_id": "alpha_button", "type": "button",
"text": { "action_id": "alpha_button",
"type": "plain_text", "text": {
"text": "Alpha", "type": "plain_text",
"emoji": False "text": "Alpha",
} "emoji": False
}, }
{ },
"type": "button", {
"action_id": "beta_button", "type": "button",
"text": { "action_id": "beta_button",
"type": "plain_text", "text": {
"text": "Beta", "type": "plain_text",
"emoji": False "text": "Beta",
} "emoji": False
} }
] }
} ]
]) }
msg_ts = response["ts"] ])
botzone = client.get_slack().get_conversation_by_name("#botzone") msg_ts = response["ts"]
botzone = client.get_slack().get_conversation_by_name("#botzone")
# Make our mappings
button_responses = { # Make our mappings
"alpha_button": "You clicked alpha. Good work.", button_responses = {
"beta_button": "You clicked beta. You must be so proud." "alpha_button": "You clicked alpha. Good work.",
} "beta_button": "You clicked beta. You must be so proud."
}
# Make our callbacks
async def on_click(event: slack_util.Event, response_str: str): # Make our callbacks
# Edit the message to show the result. async def on_click(event: slack_util.Event, response_str: str):
client.get_slack().edit_message(response_str, event.conversation.conversation_id, event.message.ts, []) # Edit the message to show the result.
client.get_slack().edit_message(response_str, event.conversation.conversation_id, event.message.ts, [])
def on_expire():
# Edit the message to show defeat. def on_expire():
client.get_slack().edit_message("Timed out", botzone.id, msg_ts, []) # Edit the message to show defeat.
client.get_slack().edit_message("Timed out", botzone.id, msg_ts, [])
# Add a listener
listener = hooks.InteractionListener(on_click, # Add a listener
button_responses, listener = hooks.InteractionListener(on_click,
botzone, button_responses,
msg_ts, botzone,
lifespan, msg_ts,
on_expire) lifespan,
client.get_slack().add_hook(listener) on_expire)
client.get_slack().add_hook(listener)
# Iterate editing the message every n seconds, for quite some time
for i in range(10): # Iterate editing the message every n seconds, for quite some time
make_interactive_msg() for i in range(10):
await asyncio.sleep(post_interval) make_interactive_msg()
await asyncio.sleep(post_interval)
def __init__(self):
pass
def __init__(self):
pass

View File

@ -1,111 +1,111 @@
from __future__ import annotations from __future__ import annotations
""" """
This file contains util for scroll polling This file contains util for scroll polling
Only really kept separate for neatness sake. Only really kept separate for neatness sake.
""" """
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional, Match from typing import List, Optional, Match
from fuzzywuzzy import process from fuzzywuzzy import process
import hooks import hooks
import client import client
import slack_util import slack_util
# Use this if we can't figure out who a brother actually is # Use this if we can't figure out who a brother actually is
MISSINGBRO_SCROLL = -1 MISSINGBRO_SCROLL = -1
@dataclass @dataclass
class Brother(object): class Brother(object):
""" """
Represents a brother. Represents a brother.
""" """
name: str name: str
scroll: int scroll: int
def is_valid(self): def is_valid(self):
return self.scroll is not MISSINGBRO_SCROLL return self.scroll is not MISSINGBRO_SCROLL
# load the family tree # load the family tree
familyfile = open("sortedfamilytree.txt", 'r') familyfile = open("sortedfamilytree.txt", 'r')
# Parse out # Parse out
brother_match = re.compile(r"([0-9]*)~(.*)") brother_match = re.compile(r"([0-9]*)~(.*)")
brothers_matches = [brother_match.match(line) for line in familyfile] brothers_matches = [brother_match.match(line) for line in familyfile]
brothers_matches = [m for m in brothers_matches if m] 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] 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: 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. Finds the scroll of a brother, or the brother of a scroll, based on msg text.
""" """
# Get the query # Get the query
query = match.group(1).strip() query = match.group(1).strip()
# Try to get as int or by name # Try to get as int or by name
try: try:
sn = int(query) sn = int(query)
result = find_by_scroll(sn) result = find_by_scroll(sn)
except ValueError: except ValueError:
result = await find_by_name(query) result = await find_by_name(query)
if result: if result:
result = "Brother {} has scroll {}".format(result.name, result.scroll) result = "Brother {} has scroll {}".format(result.name, result.scroll)
else: else:
result = "Couldn't find brother {}".format(query) result = "Couldn't find brother {}".format(query)
# Respond # Respond
client.get_slack().reply(event, result) client.get_slack().reply(event, result)
def find_by_scroll(scroll: int) -> Optional[Brother]: def find_by_scroll(scroll: int) -> Optional[Brother]:
""" """
Lookups a brother in the family list, using their scroll. Lookups a brother in the family list, using their scroll.
:param scroll: The integer scroll to look up :param scroll: The integer scroll to look up
:return: The brother, or None :return: The brother, or None
""" """
for b in brothers: for b in brothers:
if b.scroll == scroll: if b.scroll == scroll:
return b return b
return None return None
# Used to track a sufficiently shitty typed name # Used to track a sufficiently shitty typed name
class BrotherNotFound(Exception): class BrotherNotFound(Exception):
"""Throw when we can't find the desired brother.""" """Throw when we can't find the desired brother."""
pass pass
async def find_by_name(name: str, threshold: Optional[float] = None) -> Brother: async def find_by_name(name: str, threshold: Optional[float] = None) -> Brother:
""" """
Looks up a brother by name. Raises exception if threshold provided and not met. 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 threshold: Minimum match ratio to accept. Can be none.
:param name: The name to look up, with a fuzzy search :param name: The name to look up, with a fuzzy search
:raises BrotherNotFound: :raises BrotherNotFound:
:return: The best-match brother :return: The best-match brother
""" # Get all of the names """ # Get all of the names
all_names = [b.name for b in brothers] all_names = [b.name for b in brothers]
# Do fuzzy match # Do fuzzy match
found, score = process.extractOne(name, all_names) found, score = process.extractOne(name, all_names)
found_index = all_names.index(found) found_index = all_names.index(found)
found_brother = brothers[found_index] found_brother = brothers[found_index]
if (not threshold) or score > threshold: if (not threshold) or score > threshold:
return found_brother return found_brother
else: else:
msg = "Couldn't find brother {}. Best match \"{}\" matched with accuracy {}, falling short of safety minimum " \ 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, "accuracy {}. Please type name more accurately, to prevent misfires.".format(name,
found_brother, found_brother,
score, score,
threshold) threshold)
raise BrotherNotFound(msg) raise BrotherNotFound(msg)
scroll_hook = hooks.ChannelHook(scroll_callback, patterns=r"scroll\s+(.*)") scroll_hook = hooks.ChannelHook(scroll_callback, patterns=r"scroll\s+(.*)")

View File

@ -1,101 +1,101 @@
import re import re
import textwrap import textwrap
from typing import Match from typing import Match
import hooks import hooks
from plugins import house_management from plugins import house_management
import client import client
import slack_util import slack_util
from plugins.scroll_util import Brother from plugins.scroll_util import Brother
counted_data = ["flaked", "rolled", "replaced", "washed", "dried"] counted_data = ["flaked", "rolled", "replaced", "washed", "dried"]
lookup_format = "{}\s+(\d+)" lookup_format = "{}\s+(\d+)"
def fmt_work_dict(work_dict: dict) -> str: def fmt_work_dict(work_dict: dict) -> str:
return ",\n".join(["{} × {}".format(job, count) for job, count in sorted(work_dict.items())]) return ",\n".join(["{} × {}".format(job, count) for job, count in sorted(work_dict.items())])
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
async def count_work_callback(event: slack_util.Event, match: Match) -> None: async def count_work_callback(event: slack_util.Event, match: Match) -> None:
# If no user, continue # If no user, continue
if event.user is None: if event.user is None:
return return
# If bot, continue # If bot, continue
if event.bot is not None: if event.bot is not None:
return return
# Make an error wrapper # Make an error wrapper
verb = slack_util.VerboseWrapper(event) verb = slack_util.VerboseWrapper(event)
# Tidy the text # Tidy the text
text = event.message.text.strip().lower() text = event.message.text.strip().lower()
# Couple things to work through. # Couple things to work through.
# One: Who sent the message? # One: Who sent the message?
who_wrote = await verb(event.user.as_user().get_brother()) who_wrote = await verb(event.user.as_user().get_brother())
who_wrote_label = "{} [{}]".format(who_wrote.name, who_wrote.scroll) who_wrote_label = "{} [{}]".format(who_wrote.name, who_wrote.scroll)
# Two: What work did they do? # Two: What work did they do?
new_work = {} new_work = {}
for job in counted_data: for job in counted_data:
pattern = lookup_format.format(job) pattern = lookup_format.format(job)
match = re.search(pattern, text) match = re.search(pattern, text)
if match: if match:
new_work[job] = int(match.group(1)) new_work[job] = int(match.group(1))
# Three: check if we found anything # Three: check if we found anything
if len(new_work) == 0: if len(new_work) == 0:
if re.search(r'\s\d\s', text) is not None: if re.search(r'\s\d\s', text) is not None:
client.get_slack().reply(event, client.get_slack().reply(event,
"If you were trying to record work, it was not recognized.\n" "If you were trying to record work, it was not recognized.\n"
"Use words {} or work will not be recorded".format(counted_data)) "Use words {} or work will not be recorded".format(counted_data))
return return
# Four: Knowing they did something, record to total work # Four: Knowing they did something, record to total work
contribution_count = sum(new_work.values()) contribution_count = sum(new_work.values())
new_total = await verb(record_towel_contribution(who_wrote, contribution_count)) new_total = await verb(record_towel_contribution(who_wrote, contribution_count))
# Five, congratulate them on their work! # Five, congratulate them on their work!
congrats = textwrap.dedent("""{} recorded work: congrats = textwrap.dedent("""{} recorded work:
{} {}
Net increase in points: {} Net increase in points: {}
Total points since last reset: {}""".format(who_wrote_label, Total points since last reset: {}""".format(who_wrote_label,
fmt_work_dict(new_work), fmt_work_dict(new_work),
contribution_count, contribution_count,
new_total)) new_total))
client.get_slack().reply(event, congrats) client.get_slack().reply(event, congrats)
async def record_towel_contribution(for_brother: Brother, contribution_count: int) -> int: async def record_towel_contribution(for_brother: Brother, contribution_count: int) -> int:
""" """
Grants <count> contribution point to the specified user. Grants <count> contribution point to the specified user.
Returns the new total. Returns the new total.
""" """
# Import house points # Import house points
headers, points = await house_management.import_points() headers, points = await house_management.import_points()
# Find the brother # Find the brother
for p in points: for p in points:
if p is None or p.brother != for_brother: if p is None or p.brother != for_brother:
continue continue
# If found, mog with more points # If found, mog with more points
p.towel_contribution_count += contribution_count p.towel_contribution_count += contribution_count
# Export # Export
house_management.export_points(headers, points) house_management.export_points(headers, points)
# Return the new total # Return the new total
return p.towel_contribution_count return p.towel_contribution_count
# If not found, get mad! # If not found, get mad!
raise KeyError("No score entry found for brother {}".format(for_brother)) raise KeyError("No score entry found for brother {}".format(for_brother))
# Make dem HOOKs # Make dem HOOKs
count_work_hook = hooks.ChannelHook(count_work_callback, count_work_hook = hooks.ChannelHook(count_work_callback,
patterns=".*", patterns=".*",
channel_whitelist=["#slavestothemachine"], channel_whitelist=["#slavestothemachine"],
consumer=False) consumer=False)

View File

@ -1,14 +1,14 @@
#!/bin/bash #!/bin/bash
#This is just a basic loop to run in the background on my raspi. #This is just a basic loop to run in the background on my raspi.
#Restarts the bot if it dies, and notifies me #Restarts the bot if it dies, and notifies me
while : while :
do do
git pull git pull
echo "Press [CTRL+C] to stop..." echo "Press [CTRL+C] to stop..."
sleep 1 sleep 1
python3 -u main.py python3 -u main.py
sleep 1 sleep 1
echo "Died. Updating and restarting..." echo "Died. Updating and restarting..."
done done

View File

@ -1,13 +1,13 @@
# Use if we want to await tasks one by one # Use if we want to await tasks one by one
# Hint: We usually don't # Hint: We usually don't
SINGLE_THREAD_TASKS = False SINGLE_THREAD_TASKS = False
# If we were interested in performance, this should probably be turned off. However, it's fairly harmless and # If we were interested in performance, this should probably be turned off. However, it's fairly harmless and
# for the most part pretty beneficial to us. # for the most part pretty beneficial to us.
# See https://docs.python.org/3/library/asyncio-dev.html#asyncio-debug-mode # See https://docs.python.org/3/library/asyncio-dev.html#asyncio-debug-mode
# Note that this occasionally will give us warnings if SINGLE_THREAD_TAKS is False (which it usually is) # Note that this occasionally will give us warnings if SINGLE_THREAD_TAKS is False (which it usually is)
# howver, these warnings are harmless, as regardless of if aa task is awaited it still does its job # howver, these warnings are harmless, as regardless of if aa task is awaited it still does its job
USE_ASYNC_DEBUG_MODE = False USE_ASYNC_DEBUG_MODE = False
LOGFILE = "run.log" LOGFILE = "run.log"

View File

@ -1,221 +1,221 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from pprint import pformat from pprint import pformat
from time import sleep from time import sleep
from typing import Optional, Generator, Callable, Union, Awaitable from typing import Optional, Generator, Callable, Union, Awaitable
from typing import TypeVar from typing import TypeVar
from slackclient import SlackClient from slackclient import SlackClient
from slackclient.client import SlackNotConnected from slackclient.client import SlackNotConnected
import client import client
import plugins import plugins
""" """
Objects to represent things within a slack workspace Objects to represent things within a slack workspace
""" """
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
@dataclass @dataclass
class User: class User:
id: str id: str
name: str name: str
real_name: Optional[str] real_name: Optional[str]
email: Optional[str] email: Optional[str]
async def get_brother(self) -> Optional[plugins.scroll_util.Brother]: async def get_brother(self) -> Optional[plugins.scroll_util.Brother]:
""" """
Try to find the brother corresponding to this user. Try to find the brother corresponding to this user.
""" """
return await plugins.identifier.lookup_slackid_brother(self.id) return await plugins.identifier.lookup_slackid_brother(self.id)
@dataclass @dataclass
class Channel: class Channel:
id: str id: str
name: str name: str
@dataclass @dataclass
class DirectMessage: class DirectMessage:
id: str id: str
user_id: str user_id: str
def get_user(self) -> Optional[User]: def get_user(self) -> Optional[User]:
""" """
Lookup the user to which this DM corresponds. Lookup the user to which this DM corresponds.
""" """
return client.get_slack().get_user(self.user_id) return client.get_slack().get_user(self.user_id)
Conversation = Union[Channel, DirectMessage] Conversation = Union[Channel, DirectMessage]
""" """
Objects to represent attributes an event may contain Objects to represent attributes an event may contain
""" """
@dataclass @dataclass
class Event: class Event:
# Info on if this event ocurred in a particular conversation/channel, and if so which one # Info on if this event ocurred in a particular conversation/channel, and if so which one
conversation: Optional[ConversationContext] = None conversation: Optional[ConversationContext] = None
# Info on if a particular user caused this event, and if so which one # Info on if a particular user caused this event, and if so which one
user: Optional[UserContext] = None user: Optional[UserContext] = None
# Info on if this event was a someone posting a message. For contents see related # Info on if this event was a someone posting a message. For contents see related
was_post: Optional[PostMessageContext] = None was_post: Optional[PostMessageContext] = None
# The content of the most relevant message to this event # The content of the most relevant message to this event
message: Optional[RelatedMessageContext] = None message: Optional[RelatedMessageContext] = None
# Info if this event was threaded on a parent message, and if so what that message was # Info if this event was threaded on a parent message, and if so what that message was
thread: Optional[ThreadContext] = None thread: Optional[ThreadContext] = None
# Info if this event was an interaction, and if so with what # Info if this event was an interaction, and if so with what
interaction: Optional[InteractionContext] = None interaction: Optional[InteractionContext] = None
# Info about regarding if bot caused this event, and if so which one # Info about regarding if bot caused this event, and if so which one
bot: Optional[BotContext] = None bot: Optional[BotContext] = None
# If this was posted in a specific channel or conversation # If this was posted in a specific channel or conversation
@dataclass @dataclass
class ConversationContext: class ConversationContext:
conversation_id: str conversation_id: str
def get_conversation(self) -> Optional[Conversation]: def get_conversation(self) -> Optional[Conversation]:
return client.get_slack().get_conversation(self.conversation_id) return client.get_slack().get_conversation(self.conversation_id)
# If there is a specific user associated with this event # If there is a specific user associated with this event
@dataclass @dataclass
class UserContext: class UserContext:
user_id: str user_id: str
def as_user(self) -> Optional[User]: def as_user(self) -> Optional[User]:
return client.get_slack().get_user(self.user_id) return client.get_slack().get_user(self.user_id)
# Same but for bots # Same but for bots
@dataclass @dataclass
class BotContext: class BotContext:
bot_id: str bot_id: str
# Whether this was a newly posted message # Whether this was a newly posted message
@dataclass @dataclass
class PostMessageContext: class PostMessageContext:
pass pass
# Whether this event was related to a particular message, but not specifically posting it. # Whether this event was related to a particular message, but not specifically posting it.
# To see if they posted it, check for PostMessageContext # To see if they posted it, check for PostMessageContext
@dataclass @dataclass
class RelatedMessageContext: class RelatedMessageContext:
ts: str ts: str
text: str text: str
# Whether or not this is a threadable text message # Whether or not this is a threadable text message
@dataclass @dataclass
class ThreadContext: class ThreadContext:
thread_ts: str thread_ts: str
@dataclass @dataclass
class InteractionContext: class InteractionContext:
response_url: str # Used to confirm/respond to requests response_url: str # Used to confirm/respond to requests
trigger_id: str # Used to open popups trigger_id: str # Used to open popups
block_id: str # Identifies the block of the interacted component block_id: str # Identifies the block of the interacted component
action_id: str # Identifies the interacted component action_id: str # Identifies the interacted component
action_value: Optional[str] # Identifies the selected value in the component. None for buttons action_value: Optional[str] # Identifies the selected value in the component. None for buttons
# If a file was additionally shared # If a file was additionally shared
@dataclass @dataclass
class File: class File:
pass pass
""" """
Objects for interfacing easily with rtm steams, and handling async events Objects for interfacing easily with rtm steams, and handling async events
""" """
def message_stream(slack: SlackClient) -> Generator[Event, None, None]: def message_stream(slack: SlackClient) -> Generator[Event, None, None]:
""" """
Generator that yields messages from slack. Generator that yields messages from slack.
Messages are in standard api format, look it up. Messages are in standard api format, look it up.
Checks on 2 second intervals (may be changed) Checks on 2 second intervals (may be changed)
""" """
# Do forever # Do forever
while True: while True:
try: try:
if slack.rtm_connect(with_team_state=False, auto_reconnect=True): if slack.rtm_connect(with_team_state=False, auto_reconnect=True):
logging.info("Waiting for messages") logging.info("Waiting for messages")
while True: while True:
sleep(0.1) sleep(0.1)
update_list = slack.rtm_read() update_list = slack.rtm_read()
# Handle each # Handle each
for update in update_list: for update in update_list:
logging.info("RTM Message received") logging.info("RTM Message received")
logging.debug(pformat(update)) logging.debug(pformat(update))
yield message_dict_to_event(update) yield message_dict_to_event(update)
except (SlackNotConnected, OSError) as e: except (SlackNotConnected, OSError) as e:
logging.exception("Error while reading messages.") logging.exception("Error while reading messages.")
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
logging.exception("Malformed message... Restarting connection") logging.exception("Malformed message... Restarting connection")
sleep(5) sleep(5)
logging.warning("Connection failed - retrying") logging.warning("Connection failed - retrying")
def message_dict_to_event(update: dict) -> Event: def message_dict_to_event(update: dict) -> Event:
""" """
Converts a dict update to an actual event. Converts a dict update to an actual event.
""" """
event = Event() event = Event()
# Big logic folks # Big logic folks
if update["type"] == "message": if update["type"] == "message":
# For now we only handle these basic types of messages involving text # For now we only handle these basic types of messages involving text
# TODO: Handle "unwrappeable" messages # TODO: Handle "unwrappeable" messages
if "text" in update and "ts" in update: if "text" in update and "ts" in update:
event.message = RelatedMessageContext(update["ts"], update["text"]) event.message = RelatedMessageContext(update["ts"], update["text"])
event.was_post = PostMessageContext() event.was_post = PostMessageContext()
if "channel" in update: if "channel" in update:
event.conversation = ConversationContext(update["channel"]) event.conversation = ConversationContext(update["channel"])
if "user" in update: if "user" in update:
event.user = UserContext(update["user"]) event.user = UserContext(update["user"])
if "bot_id" in update: if "bot_id" in update:
event.bot = BotContext(update["bot_id"]) event.bot = BotContext(update["bot_id"])
if "thread_ts" in update: if "thread_ts" in update:
event.thread = ThreadContext(update["thread_ts"]) event.thread = ThreadContext(update["thread_ts"])
# TODO: Handle more types of events, including http data etc. # TODO: Handle more types of events, including http data etc.
return event return event
""" """
Methods for easily responding to messages, etc. Methods for easily responding to messages, etc.
""" """
T = TypeVar("T") T = TypeVar("T")
class VerboseWrapper(Callable): class VerboseWrapper(Callable):
""" """
Generates exception-ready delegates. Generates exception-ready delegates.
Warns of exceptions as they are passed through it, via responding to the given message. Warns of exceptions as they are passed through it, via responding to the given message.
""" """
def __init__(self, event: Event): def __init__(self, event: Event):
self.event = event self.event = event
async def __call__(self, awt: Awaitable[T]) -> T: async def __call__(self, awt: Awaitable[T]) -> T:
try: try:
return await awt return await awt
except Exception as e: except Exception as e:
client.get_slack().reply(self.event, "Error: {}".format(str(e)), True) client.get_slack().reply(self.event, "Error: {}".format(str(e)), True)
raise e raise e

File diff suppressed because it is too large Load Diff