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
This commit is contained in:
Mish 2026-02-16 23:19:21 +00:00 committed by GitHub
parent ea03248efd
commit 2fe9460e31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 191 additions and 138 deletions

View file

@ -4,7 +4,6 @@ Nephthys is the bot powering many support channels in the Hack Club Slack such a
## Features ## Features
### Category tags ### 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. 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 ### 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 ### 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. 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 ### Scripts
The codebase contains some scripts in the `nephthys/scripts/` directory to help with development and testing. They are documented below. The codebase contains some scripts in the `nephthys/scripts/` directory to help with development and testing. They are documented below.

38
docs/contributing.md Normal file
View file

@ -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.

View file

@ -10,8 +10,8 @@ from nephthys.utils.env import env
from nephthys.utils.logging import send_heartbeat from nephthys.utils.logging import send_heartbeat
from nephthys.utils.performance import perf_timer from nephthys.utils.performance import perf_timer
from nephthys.views.home.assigned import get_assigned_tickets_view 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.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.loading import get_loading_view
from nephthys.views.home.stats import get_stats_view from nephthys.views.home.stats import get_stats_view
from nephthys.views.home.team_tags import get_team_tags_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: match home_type:
case "dashboard": 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": case "assigned-tickets":
view = await get_assigned_tickets_view(user) view = await get_assigned_tickets_view(user)
case "team-tags": case "team-tags":

View file

@ -9,7 +9,9 @@ from nephthys.utils.logging import send_heartbeat
from nephthys.utils.old_tickets import get_unanswered_tickets from nephthys.utils.old_tickets import get_unanswered_tickets
from nephthys.utils.stats import calculate_daily_stats from nephthys.utils.stats import calculate_daily_stats
from nephthys.utils.ticket_methods import get_question_message_link 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 from prisma.models import Ticket
@ -93,8 +95,8 @@ async def send_daily_stats():
since=today_midnight_london - timedelta(days=5) since=today_midnight_london - timedelta(days=5)
) )
pie_chart = await get_ticket_status_pie_chart( pie_chart = await generate_ticket_status_pie_image(
raw=True, tz=timezone(now_london.utcoffset() or timedelta(0)) tz=timezone(now_london.utcoffset() or timedelta(0))
) )
msg = f""" msg = f"""

View file

@ -23,6 +23,7 @@ from nephthys.options.category_tags import get_category_tags
from nephthys.options.team_tags import get_team_tags from nephthys.options.team_tags import get_team_tags
from nephthys.utils.env import env from nephthys.utils.env import env
from nephthys.utils.performance import perf_timer 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) 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) 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): async def manage_home_switcher(ack: AsyncAck, body, client: AsyncWebClient):
await ack() await ack()
user_id = body["user"]["id"] 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) 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") @app.event("member_joined_channel")
async def handle_member_joined_channel(event: Dict[str, Any], client: AsyncWebClient): async def handle_member_joined_channel(event: Dict[str, Any], client: AsyncWebClient):
await channel_join(ack=AsyncAck(), event=event, client=client) await channel_join(ack=AsyncAck(), event=event, client=client)

View file

@ -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"),
]

View file

@ -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.utils.env import env
from nephthys.views.home import APP_HOME_VIEWS
from prisma.models import User from prisma.models import User
def header_buttons(current_view: str, user: User | None): def header_buttons(current_view: str):
buttons = [] buttons = Actions()
buttons.append( for view in APP_HOME_VIEWS:
{ style = Button.PRIMARY if view.id == current_view else None
"type": "button", buttons.add_element(
"text": {"type": "plain_text", "text": "Dashboard", "emoji": True}, Button(
"action_id": "dashboard", text=view.name,
**({"style": "primary"} if current_view != "dashboard" else {}), action_id=view.id,
} style=style,
) )
)
buttons.append( return buttons
{
"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
def title_line(): def title_line():
return { return Header(f":rac_cute: {env.app_title}")
"type": "header",
"text": {
"type": "plain_text",
"text": f":rac_cute: {env.app_title}",
"emoji": True,
},
}
def get_header(user: User | None, current: str = "dashboard") -> list[dict]: 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 [ return [
title_line(), title_line().build(),
header_buttons(current, user), header_buttons(current).build(),
{"type": "divider"}, {"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(),
]

View file

@ -1,11 +1,15 @@
from datetime import datetime from datetime import datetime
from datetime import timedelta 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_daily_stats
from nephthys.utils.stats import calculate_overall_stats from nephthys.utils.stats import calculate_overall_stats
async def get_leaderboard_view(): async def get_leaderboard_components():
stats = await calculate_overall_stats() stats = await calculate_overall_stats()
overall_leaderboard_lines = [ overall_leaderboard_lines = [
f"{i + 1}. <@{entry['user'].slackId}> - {entry['count']} closed" f"{i + 1}. <@{entry['user'].slackId}> - {entry['count']} closed"
@ -41,38 +45,28 @@ async def get_leaderboard_view():
) )
return [ return [
{ Section()
"type": "section", .add_field(
"fields": [ Text(
{ f"*Total Tickets*\nTotal: {stats.tickets_total}, "
"type": "mrkdwn", f"Open: {stats.tickets_open}, "
"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}", f"In Progress: {stats.tickets_in_progress}, "
}, f"Closed: {stats.tickets_closed}\n"
{ f"Hang 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}", )
}, .add_field(
], Text(
}, f"*Past 24 Hours*\nTotal: {prev_day.new_tickets_total}, "
{ f"Open: {prev_day.new_tickets_still_open}, "
"type": "header", f"In Progress: {prev_day.assigned_today_in_progress}, "
"text": { f"Closed: {prev_day.closed_today}, "
"type": "plain_text", f"Closed Today: {prev_day.closed_today_from_today}\n"
"text": ":rac_lfg: leaderboard", f"Hang time: {avg_prev_day_hang_time_str}",
"emoji": True, )
}, ),
}, Header(":rac_lfg: leaderboard"),
{ Section()
"type": "section", .add_field(Text(f"*:summer25: overall*\n{overall_leaderboard_str}"))
"fields": [ .add_field(Text(f"*:mc-clock: past 24 hours*\n{prev_day_leaderboard_str}")),
{
"type": "mrkdwn",
"text": f"*:summer25: overall*\n{overall_leaderboard_str}",
},
{
"type": "mrkdwn",
"text": f"*:mc-clock: past 24 hours*\n{prev_day_leaderboard_str}",
},
],
},
] ]

View file

@ -4,6 +4,7 @@ from datetime import timezone
from io import BytesIO from io import BytesIO
import numpy as np import numpy as np
from blockkit import Image
from nephthys.utils.bucky import upload_file from nephthys.utils.bucky import upload_file
from nephthys.utils.env import env 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 nephthys.utils.time import is_day
from prisma.enums import TicketStatus from prisma.enums import TicketStatus
LAST_DAYS = 7
async def get_ticket_status_pie_chart(
tz: timezone | None = None, raw: bool = False async def generate_ticket_status_pie_image(tz: timezone | None = None) -> bytes:
) -> dict | 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 is_daytime = is_day(tz) if tz else True
if is_daytime: if is_daytime:
@ -26,7 +29,7 @@ async def get_ticket_status_pie_chart(
bg_colour = "#181A1E" bg_colour = "#181A1E"
now = datetime.now(timezone.utc) 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"): async with perf_timer("Fetching ticket counts from DB"):
recently_closed_tickets = await env.db.ticket.count( recently_closed_tickets = await env.db.ticket.count(
@ -76,29 +79,28 @@ async def get_ticket_status_pie_chart(
format="png", 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"): async with perf_timer("Uploading pie chart"):
url = await upload_file( url = await upload_file(
file=b.getvalue(), file=pie_chart_image,
filename="ticket_status.png", filename="ticket_status.png",
content_type="image/png", content_type="image/png",
) )
caption = "Ticket stats"
if not url: if not url:
url = f"{env.base_url}/public/binoculars.png" return Image(
caption = "looks like heidi's scrounging around for tickets in the trash" 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 { return Image(
"type": "image", image_url=url,
"title": { alt_text="Ticket Stats",
"type": "plain_text", title=f"Ticket stats (last {LAST_DAYS} days)",
"text": caption, )
"emoji": True,
},
"image_url": url,
"alt_text": "Ticket Stats",
}

View file

@ -1,17 +1,21 @@
import logging import logging
import pytz import pytz
from blockkit import Header
from blockkit import Home
from nephthys.utils.env import env from nephthys.utils.env import env
from nephthys.utils.performance import perf_timer from nephthys.utils.performance import perf_timer
from nephthys.views.home.components.header import get_header from nephthys.views.home.components.header import get_header_components
from nephthys.views.home.components.leaderboards import get_leaderboard_view from nephthys.views.home.components.leaderboards import get_leaderboard_components
from nephthys.views.home.components.ticket_status_pie import get_ticket_status_pie_chart from nephthys.views.home.components.ticket_status_pie import (
ticket_status_pie_chart_component,
)
from nephthys.views.home.error import get_error_view from nephthys.views.home.error import get_error_view
from prisma.models import User 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"): async with perf_timer("Fetching user info"):
user_info_response = await env.slack_client.users_info(user=slack_user) user_info_response = await env.slack_client.users_info(user=slack_user)
user_info = user_info_response.get("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) tz = pytz.timezone(tz_string)
async with perf_timer("Rendering pie chart (total time)"): 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"): async with perf_timer("Generating leaderboard"):
leaderboard = await get_leaderboard_view() leaderboard = await get_leaderboard_components()
header = get_header(db_user, "dashboard") return Home(
[
return { *get_header_components(db_user, "dashboard"),
"type": "home", Header(":rac_graph: funny circle and line things"),
"blocks": [
*header,
{
"type": "header",
"text": {
"type": "plain_text",
"text": ":rac_graph: funny circle and line things",
"emoji": True,
},
},
pie_chart, pie_chart,
*leaderboard, *leaderboard,
], ]
} ).build()

View file

@ -8,6 +8,7 @@ dependencies = [
"aiohttp>=3.11.14", "aiohttp>=3.11.14",
"apscheduler>=3.10.4", "apscheduler>=3.10.4",
"astral>=3.2", "astral>=3.2",
"blockkit>=2.1.2",
"matplotlib>=3.10.3", "matplotlib>=3.10.3",
"numpy>=2.3.1", "numpy>=2.3.1",
"openai>=2.8.0", "openai>=2.8.0",

11
uv.lock generated
View file

@ -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" }, { 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]] [[package]]
name = "certifi" name = "certifi"
version = "2025.6.15" version = "2025.6.15"
@ -640,6 +649,7 @@ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "apscheduler" }, { name = "apscheduler" },
{ name = "astral" }, { name = "astral" },
{ name = "blockkit" },
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "numpy" }, { name = "numpy" },
{ name = "openai" }, { name = "openai" },
@ -669,6 +679,7 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.11.14" }, { name = "aiohttp", specifier = ">=3.11.14" },
{ name = "apscheduler", specifier = ">=3.10.4" }, { name = "apscheduler", specifier = ">=3.10.4" },
{ name = "astral", specifier = ">=3.2" }, { name = "astral", specifier = ">=3.2" },
{ name = "blockkit", specifier = ">=2.1.2" },
{ name = "matplotlib", specifier = ">=3.10.3" }, { name = "matplotlib", specifier = ">=3.10.3" },
{ name = "numpy", specifier = ">=2.3.1" }, { name = "numpy", specifier = ">=2.3.1" },
{ name = "openai", specifier = ">=2.8.0" }, { name = "openai", specifier = ">=2.8.0" },