?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
This commit is contained in:
End 2026-03-10 17:26:58 -07:00 committed by GitHub
parent 0d5a810707
commit 575fa89192
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 272 additions and 22 deletions

View file

@ -34,9 +34,7 @@ Sometimes its nice to be able to do things quickly... Heres 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

View file

@ -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())

View file

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

View file

@ -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 _:

View file

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

View file

@ -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 <tag name>`",
)
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}`.",
)

View file

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

View file

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

View file

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

View file

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

View file

@ -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()

View file

@ -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()