From 2fe9460e31e99fd9ae0bfa7a4608f294646f6df0 Mon Sep 17 00:00:00 2001 From: Mish Date: Mon, 16 Feb 2026 23:19:21 +0000 Subject: [PATCH] Begin migration to blockkit (#164) * Add blockkit * Use blockkit for header components * Add get_header_components() * Add a APP_HOME_VIEWS list to reduce repetition * Re-add `buttons = Actions()` bc that merged wrongly somehow * Remove Question Tags view button * Switch to blockkit for dashboard view Also splits get_ticket_status_pie_chart into two functions! * Add some form of codebase contributing documentation * Rename helper.py => dashboard.py * Fix caption for fallback pie image --- README.md | 9 +- docs/contributing.md | 38 ++++++++ nephthys/events/app_home_opened.py | 4 +- nephthys/tasks/daily_stats.py | 8 +- nephthys/utils/slack.py | 9 +- nephthys/views/home/__init__.py | 17 ++++ nephthys/views/home/components/header.py | 86 ++++++++----------- .../views/home/components/leaderboards.py | 64 +++++++------- .../home/components/ticket_status_pie.py | 44 +++++----- .../views/home/{helper.py => dashboard.py} | 38 ++++---- pyproject.toml | 1 + uv.lock | 11 +++ 12 files changed, 191 insertions(+), 138 deletions(-) create mode 100644 docs/contributing.md create mode 100644 nephthys/views/home/__init__.py rename nephthys/views/home/{helper.py => dashboard.py} (58%) diff --git a/README.md b/README.md index 0c8c960..ab3c00e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ Nephthys is the bot powering many support channels in the Hack Club Slack such a ## Features - ### Category tags Category tags are used to classify tickets into broader categories such as "Fulfillment", "Identity", or "Platform Issues". When a new ticket is created, AI analyzes the message content and automatically assigns the most relevant category tag. @@ -41,7 +40,9 @@ Stale ticket handling is not working at the moment, but more features for dealin ### Leaderboard -At midnight UK time each day, you get to see the stats for the day in the team channel! Helpers can also see more detailed stats at any time on the app home for the bot! +At midnight UK time each day, you get to see the stats for the day in the team channel, as well as a summary of any old tickets that have been waiting for a helper response for over 5 days. + +Helpers can see more detailed stats at any time on the app home for the bot! ### Assigned Tickets @@ -132,6 +133,10 @@ Your Slack app should now be running and connected to your Slack workspace! Contributions are welcome! Please feel free to submit a Pull Request. +A work-in progress document with some codebase conventions can be found at [docs/contributing.md](docs/contributing.md). + +The [#nephthys-dev](https://hackclub.enterprise.slack.com/archives/C09QR2BH3GE) channel in the Slack is available for technical discussion or questions. + ### Scripts The codebase contains some scripts in the `nephthys/scripts/` directory to help with development and testing. They are documented below. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..56546f3 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,38 @@ +# Nephthys contributing guide + +This is not a full guide by any means, but it documents some bits about the codebase that are useful to know. + +## Getting started + +See the [README](../README.md#prerequisites) for instructions on setting up a development environment for the bot! + +### Pre-commit hooks + +It's recommended to install the pre-commit hooks (for code formatting and import sorting): + +```bash +uv run pre-commit install +``` + +However, if you aren't able to do that, you can run them manually (after making your changes) with: + +```bash +uv run pre-commit run --all-files +``` + +## Branches, PRs, and commits + +See the [Hack Club Contribution Guidelines](https://github.com/hackclub/.github/blob/main/CONTRIBUTING.md) for information about the GitHub Flow, how to name commits, and similar things. + +## File structure + +All the Python code lives in `nephthys/`. Take a look around to get a feel for what all the subfolders are. + +Tip: You can ignore a lot of of the subfolders most of the time, and just look at the ones relevant to your feature/change. + +We prefer splitting code up into many Python files over having large files with a lot of code. + +## Slack Block Kit + +We now have the [`blockkit` library](https://blockkit.botsignals.co/) (!!) for building fancy Slack messages and views with buttons, dropdowns, etc. +All new code should use `blockkit`, but note that existing code likely still uses raw JSON objects. diff --git a/nephthys/events/app_home_opened.py b/nephthys/events/app_home_opened.py index df807aa..dcaf924 100644 --- a/nephthys/events/app_home_opened.py +++ b/nephthys/events/app_home_opened.py @@ -10,8 +10,8 @@ from nephthys.utils.env import env from nephthys.utils.logging import send_heartbeat from nephthys.utils.performance import perf_timer from nephthys.views.home.assigned import get_assigned_tickets_view +from nephthys.views.home.dashboard import get_dashboard_view from nephthys.views.home.error import get_error_view -from nephthys.views.home.helper import get_helper_view from nephthys.views.home.loading import get_loading_view from nephthys.views.home.stats import get_stats_view from nephthys.views.home.team_tags import get_team_tags_view @@ -51,7 +51,7 @@ async def open_app_home(home_type: str, client: AsyncWebClient, user_id: str): ): match home_type: case "dashboard": - view = await get_helper_view(slack_user=user_id, db_user=user) + view = await get_dashboard_view(slack_user=user_id, db_user=user) case "assigned-tickets": view = await get_assigned_tickets_view(user) case "team-tags": diff --git a/nephthys/tasks/daily_stats.py b/nephthys/tasks/daily_stats.py index b514384..81bd1ed 100644 --- a/nephthys/tasks/daily_stats.py +++ b/nephthys/tasks/daily_stats.py @@ -9,7 +9,9 @@ from nephthys.utils.logging import send_heartbeat from nephthys.utils.old_tickets import get_unanswered_tickets from nephthys.utils.stats import calculate_daily_stats from nephthys.utils.ticket_methods import get_question_message_link -from nephthys.views.home.components.ticket_status_pie import get_ticket_status_pie_chart +from nephthys.views.home.components.ticket_status_pie import ( + generate_ticket_status_pie_image, +) from prisma.models import Ticket @@ -93,8 +95,8 @@ async def send_daily_stats(): since=today_midnight_london - timedelta(days=5) ) - pie_chart = await get_ticket_status_pie_chart( - raw=True, tz=timezone(now_london.utcoffset() or timedelta(0)) + pie_chart = await generate_ticket_status_pie_image( + tz=timezone(now_london.utcoffset() or timedelta(0)) ) msg = f""" diff --git a/nephthys/utils/slack.py b/nephthys/utils/slack.py index bf8b46e..78d4a91 100644 --- a/nephthys/utils/slack.py +++ b/nephthys/utils/slack.py @@ -23,6 +23,7 @@ from nephthys.options.category_tags import get_category_tags from nephthys.options.team_tags import get_team_tags from nephthys.utils.env import env from nephthys.utils.performance import perf_timer +from nephthys.views.home import APP_HOME_VIEWS app = AsyncApp(token=env.slack_bot_token, signing_secret=env.slack_signing_secret) @@ -71,10 +72,6 @@ async def app_home_opened_handler(event: dict[str, Any], client: AsyncWebClient) await on_app_home_opened(event, client) -@app.action("dashboard") -@app.action("assigned-tickets") -@app.action("team-tags") -@app.action("my-stats") async def manage_home_switcher(ack: AsyncAck, body, client: AsyncWebClient): await ack() user_id = body["user"]["id"] @@ -83,6 +80,10 @@ async def manage_home_switcher(ack: AsyncAck, body, client: AsyncWebClient): await open_app_home(action_id, client, user_id) +for view in APP_HOME_VIEWS: + app.action(view.id)(manage_home_switcher) + + @app.event("member_joined_channel") async def handle_member_joined_channel(event: Dict[str, Any], client: AsyncWebClient): await channel_join(ack=AsyncAck(), event=event, client=client) diff --git a/nephthys/views/home/__init__.py b/nephthys/views/home/__init__.py new file mode 100644 index 0000000..facad31 --- /dev/null +++ b/nephthys/views/home/__init__.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass + + +@dataclass +class View: + name: str + id: str + # Not today + # render: Callable[[User | None], dict] + + +APP_HOME_VIEWS: list[View] = [ + View("Dashboard", "dashboard"), + View("Assigned Tickets", "assigned-tickets"), + View("Team Tags", "team-tags"), + View("My Stats", "my-stats"), +] diff --git a/nephthys/views/home/components/header.py b/nephthys/views/home/components/header.py index dfcb138..9e83b54 100644 --- a/nephthys/views/home/components/header.py +++ b/nephthys/views/home/components/header.py @@ -1,64 +1,52 @@ +from blockkit import Actions +from blockkit import Button +from blockkit import Divider +from blockkit import Header +from blockkit.core import ModalBlock + from nephthys.utils.env import env +from nephthys.views.home import APP_HOME_VIEWS from prisma.models import User -def header_buttons(current_view: str, user: User | None): - buttons = [] +def header_buttons(current_view: str): + buttons = Actions() - buttons.append( - { - "type": "button", - "text": {"type": "plain_text", "text": "Dashboard", "emoji": True}, - "action_id": "dashboard", - **({"style": "primary"} if current_view != "dashboard" else {}), - } - ) + for view in APP_HOME_VIEWS: + style = Button.PRIMARY if view.id == current_view else None + buttons.add_element( + Button( + text=view.name, + action_id=view.id, + style=style, + ) + ) - buttons.append( - { - "type": "button", - "text": {"type": "plain_text", "text": "Assigned Tickets", "emoji": True}, - "action_id": "assigned-tickets", - **({"style": "primary"} if current_view != "assigned-tickets" else {}), - } - ) - - buttons.append( - { - "type": "button", - "text": {"type": "plain_text", "text": "Team Tags", "emoji": True}, - "action_id": "team-tags", - **({"style": "primary"} if current_view != "team-tags" else {}), - } - ) - - buttons.append( - { - "type": "button", - "text": {"type": "plain_text", "text": "My Stats", "emoji": True}, - "action_id": "my-stats", - **({"style": "primary"} if current_view != "my-stats" else {}), - } - ) - - blocks = {"type": "actions", "elements": buttons} - return blocks + return buttons def title_line(): - return { - "type": "header", - "text": { - "type": "plain_text", - "text": f":rac_cute: {env.app_title}", - "emoji": True, - }, - } + return Header(f":rac_cute: {env.app_title}") def get_header(user: User | None, current: str = "dashboard") -> list[dict]: + """Returns the app home header in Slack API JSON format + + Deprecated over using blockkit and `get_header_components()` + """ return [ - title_line(), - header_buttons(current, user), + title_line().build(), + header_buttons(current).build(), {"type": "divider"}, ] + + +def get_header_components( + user: User | None, current: str = "dashboard" +) -> list[ModalBlock]: + """Returns the app home header as blockkit components""" + return [ + title_line(), + header_buttons(current), + Divider(), + ] diff --git a/nephthys/views/home/components/leaderboards.py b/nephthys/views/home/components/leaderboards.py index fc3b253..9ebd3cf 100644 --- a/nephthys/views/home/components/leaderboards.py +++ b/nephthys/views/home/components/leaderboards.py @@ -1,11 +1,15 @@ from datetime import datetime from datetime import timedelta +from blockkit import Header +from blockkit import Section +from blockkit import Text + from nephthys.utils.stats import calculate_daily_stats from nephthys.utils.stats import calculate_overall_stats -async def get_leaderboard_view(): +async def get_leaderboard_components(): stats = await calculate_overall_stats() overall_leaderboard_lines = [ f"{i + 1}. <@{entry['user'].slackId}> - {entry['count']} closed" @@ -41,38 +45,28 @@ async def get_leaderboard_view(): ) return [ - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": f"*Total Tickets*\nTotal: {stats.tickets_total}, Open: {stats.tickets_open}, In Progress: {stats.tickets_in_progress}, Closed: {stats.tickets_closed}\nHang time: {avg_hang_time_str}", - }, - { - "type": "mrkdwn", - "text": f"*Past 24 Hours*\nTotal: {prev_day.new_tickets_total}, Open: {prev_day.new_tickets_still_open}, In Progress: {prev_day.assigned_today_in_progress}, Closed: {prev_day.closed_today}, Closed Today: {prev_day.closed_today_from_today}\nHang time: {avg_prev_day_hang_time_str}", - }, - ], - }, - { - "type": "header", - "text": { - "type": "plain_text", - "text": ":rac_lfg: leaderboard", - "emoji": True, - }, - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": f"*:summer25: overall*\n{overall_leaderboard_str}", - }, - { - "type": "mrkdwn", - "text": f"*:mc-clock: past 24 hours*\n{prev_day_leaderboard_str}", - }, - ], - }, + Section() + .add_field( + Text( + f"*Total Tickets*\nTotal: {stats.tickets_total}, " + f"Open: {stats.tickets_open}, " + f"In Progress: {stats.tickets_in_progress}, " + f"Closed: {stats.tickets_closed}\n" + f"Hang time: {avg_hang_time_str}", + ) + ) + .add_field( + Text( + f"*Past 24 Hours*\nTotal: {prev_day.new_tickets_total}, " + f"Open: {prev_day.new_tickets_still_open}, " + f"In Progress: {prev_day.assigned_today_in_progress}, " + f"Closed: {prev_day.closed_today}, " + f"Closed Today: {prev_day.closed_today_from_today}\n" + f"Hang time: {avg_prev_day_hang_time_str}", + ) + ), + Header(":rac_lfg: leaderboard"), + Section() + .add_field(Text(f"*:summer25: overall*\n{overall_leaderboard_str}")) + .add_field(Text(f"*:mc-clock: past 24 hours*\n{prev_day_leaderboard_str}")), ] diff --git a/nephthys/views/home/components/ticket_status_pie.py b/nephthys/views/home/components/ticket_status_pie.py index ce775e5..471360c 100644 --- a/nephthys/views/home/components/ticket_status_pie.py +++ b/nephthys/views/home/components/ticket_status_pie.py @@ -4,6 +4,7 @@ from datetime import timezone from io import BytesIO import numpy as np +from blockkit import Image from nephthys.utils.bucky import upload_file from nephthys.utils.env import env @@ -12,10 +13,12 @@ from nephthys.utils.performance import perf_timer from nephthys.utils.time import is_day from prisma.enums import TicketStatus +LAST_DAYS = 7 -async def get_ticket_status_pie_chart( - tz: timezone | None = None, raw: bool = False -) -> dict | bytes: + +async def generate_ticket_status_pie_image(tz: timezone | None = None) -> bytes: + """Generates a pie chart showing percentages of open/closed/in progress + tickets over the last 7 days, renders it as a PNG and returns it as bytes.""" is_daytime = is_day(tz) if tz else True if is_daytime: @@ -26,7 +29,7 @@ async def get_ticket_status_pie_chart( bg_colour = "#181A1E" now = datetime.now(timezone.utc) - one_week_ago = now - timedelta(days=7) + one_week_ago = now - timedelta(days=LAST_DAYS) async with perf_timer("Fetching ticket counts from DB"): recently_closed_tickets = await env.db.ticket.count( @@ -76,29 +79,28 @@ async def get_ticket_status_pie_chart( format="png", ) - if raw: - return b.getvalue() + return b.getvalue() + + +async def ticket_status_pie_chart_component(tz: timezone | None = None): + pie_chart_image = await generate_ticket_status_pie_image(tz) async with perf_timer("Uploading pie chart"): url = await upload_file( - file=b.getvalue(), + file=pie_chart_image, filename="ticket_status.png", content_type="image/png", ) - caption = "Ticket stats" - if not url: - url = f"{env.base_url}/public/binoculars.png" - caption = "looks like heidi's scrounging around for tickets in the trash" + return Image( + image_url=f"{env.base_url}/public/binoculars.png", + alt_text="Heidi looking for tickets with binoculars", + title="looks like heidi's scrounging around for tickets in the trash", + ) - return { - "type": "image", - "title": { - "type": "plain_text", - "text": caption, - "emoji": True, - }, - "image_url": url, - "alt_text": "Ticket Stats", - } + return Image( + image_url=url, + alt_text="Ticket Stats", + title=f"Ticket stats (last {LAST_DAYS} days)", + ) diff --git a/nephthys/views/home/helper.py b/nephthys/views/home/dashboard.py similarity index 58% rename from nephthys/views/home/helper.py rename to nephthys/views/home/dashboard.py index bb8a9c7..7314372 100644 --- a/nephthys/views/home/helper.py +++ b/nephthys/views/home/dashboard.py @@ -1,17 +1,21 @@ import logging import pytz +from blockkit import Header +from blockkit import Home from nephthys.utils.env import env from nephthys.utils.performance import perf_timer -from nephthys.views.home.components.header import get_header -from nephthys.views.home.components.leaderboards import get_leaderboard_view -from nephthys.views.home.components.ticket_status_pie import get_ticket_status_pie_chart +from nephthys.views.home.components.header import get_header_components +from nephthys.views.home.components.leaderboards import get_leaderboard_components +from nephthys.views.home.components.ticket_status_pie import ( + ticket_status_pie_chart_component, +) from nephthys.views.home.error import get_error_view from prisma.models import User -async def get_helper_view(slack_user: str, db_user: User | None): +async def get_dashboard_view(slack_user: str, db_user: User | None): async with perf_timer("Fetching user info"): user_info_response = await env.slack_client.users_info(user=slack_user) user_info = user_info_response.get("user") @@ -27,26 +31,16 @@ async def get_helper_view(slack_user: str, db_user: User | None): tz = pytz.timezone(tz_string) async with perf_timer("Rendering pie chart (total time)"): - pie_chart = await get_ticket_status_pie_chart(tz) + pie_chart = await ticket_status_pie_chart_component(tz) # type: ignore (it works) async with perf_timer("Generating leaderboard"): - leaderboard = await get_leaderboard_view() + leaderboard = await get_leaderboard_components() - header = get_header(db_user, "dashboard") - - return { - "type": "home", - "blocks": [ - *header, - { - "type": "header", - "text": { - "type": "plain_text", - "text": ":rac_graph: funny circle and line things", - "emoji": True, - }, - }, + return Home( + [ + *get_header_components(db_user, "dashboard"), + Header(":rac_graph: funny circle and line things"), pie_chart, *leaderboard, - ], - } + ] + ).build() diff --git a/pyproject.toml b/pyproject.toml index b318f01..0971c65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "aiohttp>=3.11.14", "apscheduler>=3.10.4", "astral>=3.2", + "blockkit>=2.1.2", "matplotlib>=3.10.3", "numpy>=2.3.1", "openai>=2.8.0", diff --git a/uv.lock b/uv.lock index 11efc0a..30aaf44 100644 --- a/uv.lock +++ b/uv.lock @@ -112,6 +112,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] +[[package]] +name = "blockkit" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/21/ad8c9c05251d4a56d6c4a4b95db73e3385e33754e0034f65f1361156c57a/blockkit-2.1.2.tar.gz", hash = "sha256:65788c6ac924a432deae1f2790f716a311176e1947070068f5bb646d98c8186a", size = 67151, upload-time = "2025-09-28T17:37:37.48Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/77/44c5c471b08a600196751465665f1115f28806553f328a5a9ae31e935646/blockkit-2.1.2-py3-none-any.whl", hash = "sha256:f8e6bcffc73b2f168b2b5a7c5e9303961e9b5c167759eeda4811910072bfc706", size = 20131, upload-time = "2025-09-28T17:37:36.092Z" }, +] + [[package]] name = "certifi" version = "2025.6.15" @@ -640,6 +649,7 @@ dependencies = [ { name = "aiohttp" }, { name = "apscheduler" }, { name = "astral" }, + { name = "blockkit" }, { name = "matplotlib" }, { name = "numpy" }, { name = "openai" }, @@ -669,6 +679,7 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.11.14" }, { name = "apscheduler", specifier = ">=3.10.4" }, { name = "astral", specifier = ">=3.2" }, + { name = "blockkit", specifier = ">=2.1.2" }, { name = "matplotlib", specifier = ">=3.10.3" }, { name = "numpy", specifier = ">=2.3.1" }, { name = "openai", specifier = ">=2.8.0" },