Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for multi-post messages #45

Merged
merged 8 commits into from
Nov 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ jobs:
env:
SLACK_BOT_TOKEN: "fake"
SIGNING_SECRET: "also_fake"
TZ: "US/Eastern"

runs-on: ubuntu-latest
steps:
Expand Down
218 changes: 143 additions & 75 deletions src/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@

import asyncio
import datetime
import logging
import os
import sqlite3
import traceback
from collections import defaultdict

import aiohttp
import pytz
from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler
from slack_bolt.async_app import AsyncApp

import database
from event import Event
from error import UnsafeMessageSpilloverError
from message_builder import build_event_blocks, chunk_messages

# configure app
APP = AsyncApp(
Expand All @@ -21,101 +24,166 @@
APP_HANDLER = AsyncSlackRequestHandler(APP)


async def post_or_update_messages(week, blocks, text):
async def is_unsafe_to_spillover(
existing_messages_length: int,
new_messages_length: int,
week: datetime.datetime,
slack_channel_id: str,
) -> bool:
"""
Determines if it is safe to update the messages that list out events for a week for a given
Slack channel.

If enough new events have been added since an initial post has gone out to warrant additional
messages needing to be posted due to us reaching character limits then we need to first check
if the next week's messages have already been posted. If the next week's messages have already
been posted then we cannot add messages to the current week (as things stand).
"""
if new_messages_length > existing_messages_length > 0:
latest_message_for_channel = await database.get_most_recent_message_for_channel(
slack_channel_id
)
latest_message_week = datetime.datetime.strptime(
latest_message_for_channel["week"], "%Y-%m-%d %H:%M:%S%z"
)

# If the latest message is for a more recent week then it is unsafe
# to add new messages. We cannot place new messages before older, existing
# ones. Instead we'll log an error and skip updating messages for
# this Slack channel.
return latest_message_week.date() > week.date()

return False


async def post_new_message(slack_channel_id: str, msg_blocks: list, msg_text: str):
"""Posts a message to Slack"""
return await APP.client.chat_postMessage(
channel=slack_channel_id,
blocks=msg_blocks,
text=msg_text,
unfurl_links=False,
unfurl_media=False,
)


async def post_or_update_messages(week, messages):
"""Posts or updates a message in a slack channel for a week"""
channels = await database.get_slack_channel_ids()
messages = await database.get_messages(week)
existing_messages = await database.get_messages(week)

# used to lookup the message id and message for a particular
# channel
message_details = {
message["slack_channel_id"]: {
"timestamp": message["message_timestamp"],
"message": message["message"],
}
for message in messages
}
message_details = defaultdict(list)
for existing_message in existing_messages:
message_details[existing_message["slack_channel_id"]].append(
{
"timestamp": existing_message["message_timestamp"],
"message": existing_message["message"],
}
)

# used to quickly lookup if a message has been posted for a
# particular channel
posted_channels_set = set(message["slack_channel_id"] for message in messages)
posted_channels_set = set(message_details.keys())

for slack_channel_id in channels:
if (
slack_channel_id in posted_channels_set
and text == message_details[slack_channel_id]["message"]
):
print(
f"Week of {week.strftime('%B %-d')} in {slack_channel_id} "
"hasn't changed, not updating"
)

elif slack_channel_id in posted_channels_set:
print(f"updating week {week.strftime('%B %-d')} " f"in {slack_channel_id}")

timestamp = message_details[slack_channel_id]["timestamp"]
slack_response = await APP.client.chat_update(
ts=timestamp, channel=slack_channel_id, blocks=blocks, text=text
)

await database.update_message(week, text, timestamp, slack_channel_id)

else:
print(f"posting week {week.strftime('%B %-d')} " f"in {slack_channel_id}")

slack_response = await APP.client.chat_postMessage(
channel=slack_channel_id,
blocks=blocks,
text=text,
unfurl_links=False,
unfurl_media=False,
try:
for msg_idx, msg in enumerate(messages):
msg_text = msg["text"]
msg_blocks = msg["blocks"]

# If new events now warrant additional messages being posted.
if msg_idx > len(message_details[slack_channel_id]) - 1:
if await is_unsafe_to_spillover(
len(existing_messages), len(messages), week, slack_channel_id
):
raise UnsafeMessageSpilloverError

print(
f"Posting an additional message for week {week.strftime('%B %-d')} "
f"in {slack_channel_id}"
)

slack_response = await post_new_message(
slack_channel_id, msg_blocks, msg_text
)

await database.create_message(
week, msg_text, slack_response["ts"], slack_channel_id, msg_idx
)
elif (
slack_channel_id in posted_channels_set
and msg_text
== message_details[slack_channel_id][msg_idx]["message"]
):
print(
f"Message {msg_idx + 1} for week of "
f"{week.strftime('%B %-d')} in {slack_channel_id} "
"hasn't changed, not updating"
)
elif slack_channel_id in posted_channels_set:
if await is_unsafe_to_spillover(
len(existing_messages), len(messages), week, slack_channel_id
):
raise UnsafeMessageSpilloverError

print(
f"Updating message {msg_idx + 1} for week {week.strftime('%B %-d')} "
f"in {slack_channel_id}"
)

timestamp = message_details[slack_channel_id][msg_idx]["timestamp"]
slack_response = await APP.client.chat_update(
ts=timestamp,
channel=slack_channel_id,
blocks=msg_blocks,
text=msg_text,
)

await database.update_message(
week, msg_text, timestamp, slack_channel_id
)

else:
print(
f"Posting message {msg_idx + 1} for week {week.strftime('%B %-d')} "
f"in {slack_channel_id}"
)

slack_response = await post_new_message(
slack_channel_id, msg_blocks, msg_text
)

await database.create_message(
week, msg_text, slack_response["ts"], slack_channel_id, msg_idx
)
except UnsafeMessageSpilloverError:
# Log error and skip this Slack channel
logging.error(
"Cannot update messages for %s for channel %s. "
"New events have caused the number of messages needed to increase, "
"but the next week's post has already been sent. Cannot resize. "
"Existing message count: %s --- New message count: %s.",
week.strftime("%m/%d/%Y"),
slack_channel_id,
len(existing_messages),
len(messages),
)

await database.create_message(
week, text, slack_response["ts"], slack_channel_id
)
continue


async def parse_events_for_week(probe_date, resp):
"""Parses events for the week containing the probe date"""
week_start = probe_date - datetime.timedelta(days=(probe_date.weekday() % 7) + 1)
week_end = week_start + datetime.timedelta(days=7)

blocks = [
{
"type": "header",
"text": {
"type": "plain_text",
"text": (
"HackGreenville Events for the week of "
f"{week_start.strftime('%B %-d')}"
),
},
},
{"type": "divider"},
]

text = (
f"HackGreenville Events for the week of {week_start.strftime('%B %-d')}"
"\n\n===\n\n"
)

for event_data in await resp.json():
event = Event.from_event_json(event_data)

# ignore event if it's not in the current week
if event.time < week_start or event.time > week_end:
continue

# ignore event if it has a non-supported status
if event.status not in ["cancelled", "upcoming", "past"]:
print(f"Couldn't parse event {event.uuid} " f"with status: {event.status}")
continue
event_blocks = await build_event_blocks(resp, week_start, week_end)

blocks += event.generate_blocks() + [{"type": "divider"}]
text += f"{event.generate_text()}\n\n"
chunked_messages = await chunk_messages(event_blocks, week_start)

await post_or_update_messages(week_start, blocks, text)
await post_or_update_messages(week_start, chunked_messages)


async def check_api():
Expand Down
62 changes: 53 additions & 9 deletions src/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def create_tables():
week DATE NOT NULL,
message_timestamp TEXT NOT NULL,
message TEXT NOT NULL,
sequence_position INTEGER DEFAULT 0 NOT NULL,
channel_id INTEGER NOT NULL,
CONSTRAINT fk_channel_id
FOREIGN KEY(channel_id) REFERENCES channels(id)
Expand Down Expand Up @@ -71,7 +72,9 @@ def create_tables():
)


async def create_message(week, message, message_timestamp, slack_channel_id):
async def create_message(
week, message, message_timestamp, slack_channel_id, sequence_position: int
):
"""Create a record of a message sent in slack for a week"""
for conn in get_connection(commit=True):
cur = conn.cursor()
Expand All @@ -82,9 +85,11 @@ async def create_message(week, message, message_timestamp, slack_channel_id):
channel_id = cur.fetchone()[0]

cur.execute(
"""INSERT INTO messages (week, message, message_timestamp, channel_id)
VALUES (?, ?, ?, ?)""",
[week, message, message_timestamp, channel_id],
"""INSERT INTO messages (
week, message, message_timestamp, channel_id, sequence_position
)
VALUES (?, ?, ?, ?, ?)""",
[week, message, message_timestamp, channel_id, sequence_position],
)


Expand All @@ -106,30 +111,69 @@ async def update_message(week, message, message_timestamp, slack_channel_id):
)


async def get_messages(week):
async def get_messages(week) -> list:
"""Get all messages sent in slack for a week"""
for conn in get_connection():
cur = conn.cursor()
cur.execute(
"""SELECT m.message, m.message_timestamp, c.slack_channel_id
"""SELECT m.message, m.message_timestamp, c.slack_channel_id, m.sequence_position
FROM messages m
JOIN channels c ON m.channel_id = c.id
WHERE m.week = ?""",
WHERE m.week = ?
ORDER BY m.sequence_position ASC""",
[week],
)
return [
{"message": x[0], "message_timestamp": x[1], "slack_channel_id": x[2]}
{
"message": x[0],
"message_timestamp": x[1],
"slack_channel_id": x[2],
"sequence_position": x[3],
}
for x in cur.fetchall()
]

return []

async def get_slack_channel_ids():

async def get_most_recent_message_for_channel(slack_channel_id) -> dict:
"""Get the most recently posted message for a subscribed Slack channel"""
for conn in get_connection():
cur = conn.cursor()
cur.execute(
"""SELECT m.week, m.message, m.message_timestamp
FROM messages m
JOIN channels c ON m.channel_id = c.id
WHERE c.slack_channel_id = ?
ORDER BY
m.week DESC,
m.message_timestamp DESC
LIMIT 1
""",
[slack_channel_id],
)

most_recent_message = cur.fetchone()

if most_recent_message:
return {
"week": most_recent_message[0],
"message": most_recent_message[1],
"message_timestamp": most_recent_message[2],
}

return {}


async def get_slack_channel_ids() -> list:
"""Get all slack channels that the bot is configured for"""
for conn in get_connection():
cur = conn.cursor()
cur.execute("SELECT slack_channel_id FROM channels")
return [x[0] for x in cur.fetchall()]

return []


async def add_channel(slack_channel_id):
"""Add a slack channel to post in for the bot"""
Expand Down
10 changes: 10 additions & 0 deletions src/error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""
Custom errors for use in this application.
"""


class UnsafeMessageSpilloverError(Exception):
"""
Raised whenever the number of messages needed to contain a week's events
increases after the next week's messages have begun to be posted to a channel.
"""
Loading