Misc. updates.
This commit is contained in:
parent
e813a5d72d
commit
e3321000cb
|
|
@ -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
|
||||||
|
|
@ -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
854
client.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
slackclient
|
slackclient==1.3.2
|
||||||
python-Levenshtein
|
python-Levenshtein
|
||||||
fuzzywuzzy
|
fuzzywuzzy
|
||||||
httplib2
|
httplib2
|
||||||
|
aiohttp
|
||||||
|
google-api-python-client
|
||||||
|
oauth2client
|
||||||
|
|
|
||||||
170
google_api.py
170
google_api.py
|
|
@ -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
446
hooks.py
|
|
@ -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()
|
||||||
|
|
|
||||||
208
main.py
208
main.py
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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+(.*?)->\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+(.*?)->\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
|
||||||
]
|
}
|
||||||
"""
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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+(.*)")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
28
runloop.sh
28
runloop.sh
|
|
@ -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
|
||||||
|
|
|
||||||
24
settings.py
24
settings.py
|
|
@ -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"
|
||||||
442
slack_util.py
442
slack_util.py
|
|
@ -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
|
||||||
|
|
|
||||||
1704
sortedfamilytree.txt
1704
sortedfamilytree.txt
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue