From 575fa891927c544a050278e5bd62ba35a4aac7da Mon Sep 17 00:00:00 2001 From: End Date: Tue, 10 Mar 2026 17:26:58 -0700 Subject: [PATCH] ?tag macro (#174) * add lynx * stale thread fix, add ?tag amcro, config categories from menu * copilot actually right * comment out stale ticket logic so doesnt cause havoc * pre commit * ratelimit --- README.md | 4 +- nephthys/__main__.py | 16 ++-- nephthys/actions/create_category_tag.py | 73 +++++++++++++++++ nephthys/events/app_home_opened.py | 3 + nephthys/macros/__init__.py | 3 +- nephthys/macros/team_tag.py | 84 ++++++++++++++++++++ nephthys/tasks/close_stale.py | 19 +++-- nephthys/transcripts/transcripts/lynx.py | 2 +- nephthys/utils/slack.py | 16 ++++ nephthys/views/home/__init__.py | 1 + nephthys/views/home/category_tags.py | 53 ++++++++++++ nephthys/views/modals/create_category_tag.py | 20 +++++ 12 files changed, 272 insertions(+), 22 deletions(-) create mode 100644 nephthys/actions/create_category_tag.py create mode 100644 nephthys/macros/team_tag.py create mode 100644 nephthys/views/home/category_tags.py create mode 100644 nephthys/views/modals/create_category_tag.py diff --git a/README.md b/README.md index ab3c00e..df39feb 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,7 @@ Sometimes it’s nice to be able to do things quickly... Here’s where macros c ### Stale -~~Tickets that have been not had a response for more than 3 days will automatically be closed as stale. The last helper to respond in the thread gets credit for closing them~~ - -Stale ticket handling is not working at the moment, but more features for dealing with stale tickets are planned. +Tickets that have not had a response for more than 3 days will automatically be closed as stale. The last helper to respond in the thread gets credit for closing them ### Leaderboard diff --git a/nephthys/__main__.py b/nephthys/__main__.py index 138f56c..268b533 100644 --- a/nephthys/__main__.py +++ b/nephthys/__main__.py @@ -1,7 +1,6 @@ import asyncio import contextlib import logging -from datetime import datetime import uvicorn from aiohttp import ClientSession @@ -9,7 +8,6 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from dotenv import load_dotenv from starlette.applications import Starlette -from nephthys.tasks.close_stale import close_stale_tickets from nephthys.tasks.daily_stats import send_daily_stats from nephthys.tasks.fulfillment_reminder import send_fulfillment_reminder from nephthys.tasks.update_helpers import update_helpers @@ -56,13 +54,13 @@ async def main(_app: Starlette): timezone="Europe/London", ) - scheduler.add_job( - close_stale_tickets, - "interval", - hours=1, - max_instances=1, - next_run_time=datetime.now(), - ) + # scheduler.add_job( + # close_stale_tickets, + # "interval", + # hours=1, + # max_instances=1, + # next_run_time=datetime.now(), + # ) scheduler.start() delete_msg_task = asyncio.create_task(process_queue()) diff --git a/nephthys/actions/create_category_tag.py b/nephthys/actions/create_category_tag.py new file mode 100644 index 0000000..87aee3e --- /dev/null +++ b/nephthys/actions/create_category_tag.py @@ -0,0 +1,73 @@ +import logging + +from slack_bolt.async_app import AsyncAck +from slack_sdk.web.async_client import AsyncWebClient + +from nephthys.utils.env import env +from nephthys.utils.logging import send_heartbeat +from nephthys.views.modals.create_category_tag import get_create_category_tag_modal +from prisma.errors import UniqueViolationError + + +async def create_category_tag_btn_callback( + ack: AsyncAck, body: dict, client: AsyncWebClient +): + await ack() + user_id = body["user"]["id"] + trigger_id = body["trigger_id"] + + user = await env.db.user.find_unique(where={"slackId": user_id}) + if not user or not user.admin: + await send_heartbeat( + f"Attempted to open create category tag modal by non-admin user <@{user_id}>" + ) + return + + view = get_create_category_tag_modal() + await client.views_open(trigger_id=trigger_id, view=view, user_id=user_id) + + +async def create_category_tag_view_callback( + ack: AsyncAck, body: dict, client: AsyncWebClient +): + user_id = body["user"]["id"] + + raw_name = body["view"]["state"]["values"]["category_tag_name"][ + "category_tag_name" + ]["value"] + name = raw_name.strip() if raw_name else "" + + if not name: + await ack( + response_action="errors", + errors={"category_tag_name": "Category name cannot be empty."}, + ) + return + + user = await env.db.user.find_unique(where={"slackId": user_id}) + if not user or not user.admin: + await ack() + await send_heartbeat( + f"Attempted to create category tag by non-admin user <@{user_id}>" + ) + return + + try: + await env.db.categorytag.create( + data={"name": name, "createdBy": {"connect": {"id": user.id}}} + ) + except UniqueViolationError: + logging.warning(f"Duplicate category tag name: {name}") + await ack( + response_action="errors", + errors={ + "category_tag_name": f"A category tag named '{name}' already exists." + }, + ) + return + + await ack() + + from nephthys.events.app_home_opened import open_app_home + + await open_app_home("category-tags", client, user_id) diff --git a/nephthys/events/app_home_opened.py b/nephthys/events/app_home_opened.py index dcaf924..60738c2 100644 --- a/nephthys/events/app_home_opened.py +++ b/nephthys/events/app_home_opened.py @@ -10,6 +10,7 @@ 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.category_tags import get_category_tags_view from nephthys.views.home.dashboard import get_dashboard_view from nephthys.views.home.error import get_error_view from nephthys.views.home.loading import get_loading_view @@ -56,6 +57,8 @@ async def open_app_home(home_type: str, client: AsyncWebClient, user_id: str): view = await get_assigned_tickets_view(user) case "team-tags": view = await get_team_tags_view(user) + case "category-tags": + view = await get_category_tags_view(user) case "my-stats": view = await get_stats_view(user) case _: diff --git a/nephthys/macros/__init__.py b/nephthys/macros/__init__.py index c1385c2..9a36ce7 100644 --- a/nephthys/macros/__init__.py +++ b/nephthys/macros/__init__.py @@ -8,6 +8,7 @@ from nephthys.macros.reopen import Reopen from nephthys.macros.resolve import Resolve from nephthys.macros.shipcertqueue import ShipCertQueue from nephthys.macros.shipwrights import Shipwrights +from nephthys.macros.team_tag import TeamTag from nephthys.macros.thread import Thread from nephthys.macros.trigger_daily_stats import DailyStats from nephthys.macros.trigger_fulfillment_reminder import FulfillmentReminder @@ -18,7 +19,6 @@ from prisma.enums import TicketStatus from prisma.models import Ticket from prisma.models import User - macro_list: list[type[Macro]] = [ Resolve, HelloWorld, @@ -31,6 +31,7 @@ macro_list: list[type[Macro]] = [ DailyStats, FulfillmentReminder, Shipwrights, + TeamTag, ] macros = [macro() for macro in macro_list] diff --git a/nephthys/macros/team_tag.py b/nephthys/macros/team_tag.py new file mode 100644 index 0000000..032198f --- /dev/null +++ b/nephthys/macros/team_tag.py @@ -0,0 +1,84 @@ +from nephthys.macros.types import Macro +from nephthys.utils.env import env +from nephthys.utils.ticket_methods import get_backend_message_link +from nephthys.utils.ticket_methods import get_question_message_link + + +class TeamTag(Macro): + name = "tag" + + async def run(self, ticket, helper, **kwargs): + text: str = kwargs.get("text", "") + parts = text.split(maxsplit=1) + if len(parts) < 2 or not parts[1].strip(): + await env.slack_client.chat_postEphemeral( + channel=env.slack_help_channel, + thread_ts=ticket.msgTs, + user=helper.slackId, + text="Usage: `?tag `", + ) + return + + tag_name = parts[1].strip() + + all_tags = await env.db.tag.find_many() + + tag = next((t for t in all_tags if t.name == tag_name), None) + if not tag: + matches = [t for t in all_tags if t.name.lower() == tag_name.lower()] + tag = matches[0] if matches else None + + if not tag: + names = ", ".join(f"`{t.name}`" for t in all_tags) + await env.slack_client.chat_postEphemeral( + channel=env.slack_help_channel, + thread_ts=ticket.msgTs, + user=helper.slackId, + text=f"Tag `{tag_name}` not found. Available tags: {names}" + if names + else f"Tag `{tag_name}` not found. No tags exist yet.", + ) + return + + existing = await env.db.tagsontickets.find_first( + where={"ticketId": ticket.id, "tagId": tag.id} + ) + if existing: + await env.slack_client.chat_postEphemeral( + channel=env.slack_help_channel, + thread_ts=ticket.msgTs, + user=helper.slackId, + text=f"Tag `{tag.name}` is already on this ticket.", + ) + return + + await env.db.tagsontickets.create( + data={ + "tag": {"connect": {"id": tag.id}}, + "ticket": {"connect": {"id": ticket.id}}, + } + ) + + subscriptions = await env.db.usertagsubscription.find_many( + where={"tagId": tag.id} + ) + subscriber_ids = [s.userId for s in subscriptions if s.userId != helper.id] + + if subscriber_ids: + subscribers = await env.db.user.find_many( + where={"id": {"in": subscriber_ids}} + ) + url = get_question_message_link(ticket) + ticket_url = get_backend_message_link(ticket) + for user in subscribers: + await env.slack_client.chat_postMessage( + channel=user.slackId, + text=f"New ticket for *{tag.name}*: *{ticket.title}*\n<{url}|ticket> <{ticket_url}|bts ticket>", + ) + + await env.slack_client.chat_postEphemeral( + channel=env.slack_help_channel, + thread_ts=ticket.msgTs, + user=helper.slackId, + text=f"Tagged `{tag.name}`.", + ) diff --git a/nephthys/tasks/close_stale.py b/nephthys/tasks/close_stale.py index 7d8de2c..1d1369f 100644 --- a/nephthys/tasks/close_stale.py +++ b/nephthys/tasks/close_stale.py @@ -42,14 +42,14 @@ async def get_is_stale(ts: str, max_retries: int = 3) -> bool: if attempt == max_retries - 1: logging.error(f"Max retries exceeded for ticket {ts}") return False - if e.response["error"] == "thread_not_found": + continue + elif e.response["error"] == "thread_not_found": logging.warning( f"Thread not found for ticket {ts}. This might be a deleted thread." ) await send_heartbeat(f"Thread not found for ticket {ts}.") maintainer_user = await env.db.user.find_unique( - # where={"slackId": env.slack_maintainer_id} - where={"slackId": "U054VC2KM9P"} + where={"slackId": env.slack_maintainer_id} ) if maintainer_user: await env.db.ticket.update( @@ -109,14 +109,17 @@ async def close_stale_tickets(): if await get_is_stale(ticket.msgTs): stale += 1 - resolver = ( - ticket.assignedToId - if ticket.assignedToId - else ticket.openedById + resolver_user = ( + ticket.assignedTo if ticket.assignedTo else ticket.openedBy ) + if not resolver_user: + logging.warning( + f"Skipping stale ticket {ticket.msgTs}: no assigned or opened user" + ) + continue await resolve( ticket.msgTs, - resolver, # type: ignore (this is explicitly fetched in the db call) + resolver_user.slackId, env.slack_client, stale=True, ) diff --git a/nephthys/transcripts/transcripts/lynx.py b/nephthys/transcripts/transcripts/lynx.py index 38a9dbc..9ca237f 100644 --- a/nephthys/transcripts/transcripts/lynx.py +++ b/nephthys/transcripts/transcripts/lynx.py @@ -20,6 +20,6 @@ if your question has been answered, please hit the button below to mark it as re """ ticket_create: str = f"someone should be along to help you soon but in the meantime i suggest you read the faq <{faq_link}|here> to make sure your question hasn't already been answered. if it has been, please hit the button below to mark it as resolved :D" resolve_ticket_button: str = "i get it now" - ticket_resolve: str = f"oh, oh! it looks like this post has been marked as resolved by <@{{user_id}}>! if you have any more questions, please make a new post in <#{help_channel}> and someone'll be happy to help you out! not me though, i'm just a silly raccoon ^-^" + ticket_resolve: str = f"oh, oh! it looks like this post has been marked as resolved by <@{{user_id}}>! if you have any more questions, please make a new post in <#{help_channel}> and someone'll be happy to help you out! not me though, i'm just a silly capybara ^-^" not_allowed_channel: str = f"heya, it looks like you're not supposed to be in that channel, pls talk to <@{program_owner}> if that's wrong" diff --git a/nephthys/utils/slack.py b/nephthys/utils/slack.py index 78d4a91..edfc29b 100644 --- a/nephthys/utils/slack.py +++ b/nephthys/utils/slack.py @@ -8,6 +8,8 @@ from slack_sdk.web.async_client import AsyncWebClient from nephthys.actions.assign_category_tag import assign_category_tag_callback from nephthys.actions.assign_team_tag import assign_team_tag_callback +from nephthys.actions.create_category_tag import create_category_tag_btn_callback +from nephthys.actions.create_category_tag import create_category_tag_view_callback from nephthys.actions.create_team_tag import create_team_tag_btn_callback from nephthys.actions.create_team_tag import create_team_tag_view_callback from nephthys.actions.resolve import resolve @@ -106,6 +108,20 @@ async def create_team_tag_view( await create_team_tag_view_callback(ack, body, client) +@app.action("create-category-tag") +async def create_category_tag( + ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient +): + await create_category_tag_btn_callback(ack, body, client) + + +@app.view("create_category_tag") +async def create_category_tag_view( + ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient +): + await create_category_tag_view_callback(ack, body, client) + + @app.action("tag-subscribe") async def tag_subscribe(ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient): await tag_subscribe_callback(ack, body, client) diff --git a/nephthys/views/home/__init__.py b/nephthys/views/home/__init__.py index facad31..67188fd 100644 --- a/nephthys/views/home/__init__.py +++ b/nephthys/views/home/__init__.py @@ -13,5 +13,6 @@ APP_HOME_VIEWS: list[View] = [ View("Dashboard", "dashboard"), View("Assigned Tickets", "assigned-tickets"), View("Team Tags", "team-tags"), + View("Category Tags", "category-tags"), View("My Stats", "my-stats"), ] diff --git a/nephthys/views/home/category_tags.py b/nephthys/views/home/category_tags.py new file mode 100644 index 0000000..c95c370 --- /dev/null +++ b/nephthys/views/home/category_tags.py @@ -0,0 +1,53 @@ +from blockkit import Actions +from blockkit import Button +from blockkit import Divider +from blockkit import Header +from blockkit import Home +from blockkit import Section + +from nephthys.utils.env import env +from nephthys.views.home.components.header import get_header_components +from prisma.models import User + + +async def get_category_tags_view(user: User | None) -> dict: + is_admin = bool(user and user.admin) + + header = get_header_components(user, "category-tags") + + if not is_admin: + return Home( + [ + *header, + Header(":rac_info: Category Tags"), + Section(":rac_nooo: only admins can manage category tags."), + ] + ).build() + + category_tags = await env.db.categorytag.find_many(order={"id": "asc"}) + + tag_blocks = [] + if not category_tags: + tag_blocks.append(Section(":rac_nooo: no category tags yet — add one below!")) + else: + for tag in category_tags: + tag_blocks.append(Section(f"*{tag.name}*")) + + return Home( + [ + *header, + Header(":rac_info: Category Tags"), + Section(":rac_thumbs: manage category tags used by the AI classifier"), + Divider(), + *tag_blocks, + Actions( + elements=[ + Button( + text=":rac_cute: add category", + action_id="create-category-tag", + style=Button.PRIMARY, + ) + ] + ), + ] + ).build() diff --git a/nephthys/views/modals/create_category_tag.py b/nephthys/views/modals/create_category_tag.py new file mode 100644 index 0000000..1766f70 --- /dev/null +++ b/nephthys/views/modals/create_category_tag.py @@ -0,0 +1,20 @@ +from blockkit import Input +from blockkit import Modal +from blockkit import PlainTextInput + + +def get_create_category_tag_modal(): + return Modal( + title=":rac_info: new category", + callback_id="create_category_tag", + submit=":rac_question: create", + blocks=[ + Input( + label="Category name", + block_id="category_tag_name", + element=PlainTextInput(action_id="category_tag_name"), + ), + # future: description + # future: slug + ], + ).build()