mirror of
https://github.com/System-End/nephthys.git
synced 2026-04-19 15:18:21 +00:00
AI-powered tickets categorization and a daily fulfillment reminder for open tickets (#153)
* Add ai-based ticket categorization and you can add tags in the bot homepage * made the CategoryTag model and refactored the code to support it * Implemented the fulfillment reminder to send all open tickets in the past 24h for amber to check them :) * removed the ability to manage categories from the UI/fixed stuff * Fix type error * removed question tags * Re-add question tags to DB schema This is only because we don't have migrations rn so we can't actually delete stuff from the DB --------- Co-authored-by: MMK21Hub <50421330+MMK21Hub@users.noreply.github.com>
This commit is contained in:
parent
bb4673a398
commit
367dd5a2a1
16 changed files with 297 additions and 177 deletions
|
|
@ -11,6 +11,6 @@ SLACK_USER_GROUP="S..."
|
|||
SLACK_MAINTAINER_ID="U..."
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/nephthys"
|
||||
APP_TITLE="helper heidi"
|
||||
PROGRAM="summer_of_making"
|
||||
PROGRAM="flavortown"
|
||||
HACK_CLUB_AI_API_KEY="sk-hc-v1-..."
|
||||
BASE_URL="https://..."
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
# Nephthys
|
||||
|
||||
Nephthys is the bot powering #summer-of-making-help and #identity-help in the Hack Club Slack! Below is a guide to set her up for developing and here's a list of some of her features :)
|
||||
Nephthys is the bot powering many support channels in the Hack Club Slack such as #flavortown-help and #identity-help! Below is a guide to set her up for developing and here's a list of some of her features :)
|
||||
|
||||
## Features
|
||||
|
||||
### Question tags
|
||||
|
||||
Quite often in help channels, the same question gets asked again and again. Helpers can associate tickets with a "question tag", which are pre-defined questions/issue such as "What's the payout range?", "How to top up grant?", or "404 on /shop". We can then keep track of how these questions trend over time, to provide a feedback loop for people building the event/platform/YSWS.
|
||||
### Category tags
|
||||
|
||||
They can be added to tickets in the private tickets channel.
|
||||
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.
|
||||
|
||||
Helpers can reassign these tags in the private tickets channel if the AI suggestion is incorrect.
|
||||
|
||||
### Team tags
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ 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
|
||||
from nephthys.utils.delete_thread import process_queue
|
||||
from nephthys.utils.env import env
|
||||
|
|
@ -46,6 +47,15 @@ async def main(_app: Starlette):
|
|||
scheduler = AsyncIOScheduler(timezone="Europe/London")
|
||||
if env.daily_summary:
|
||||
scheduler.add_job(send_daily_stats, "cron", hour=0, minute=0)
|
||||
|
||||
scheduler.add_job(
|
||||
send_fulfillment_reminder,
|
||||
"cron",
|
||||
hour=14,
|
||||
minute=0,
|
||||
timezone="Europe/London",
|
||||
)
|
||||
|
||||
scheduler.add_job(
|
||||
close_stale_tickets,
|
||||
"interval",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from nephthys.events.message.send_backend_message import backend_message_fallbac
|
|||
from nephthys.utils.env import env
|
||||
|
||||
|
||||
async def assign_question_tag_callback(
|
||||
async def assign_category_tag_callback(
|
||||
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
|
||||
):
|
||||
await ack()
|
||||
|
|
@ -30,7 +30,7 @@ async def assign_question_tag_callback(
|
|||
user = await env.db.user.find_unique(where={"slackId": user_id})
|
||||
if not user or not user.helper:
|
||||
logging.warning(
|
||||
f"Unauthorized user attempted to assign question tag user_id={user_id}"
|
||||
f"Unauthorized user attempted to assign category tag user_id={user_id}"
|
||||
)
|
||||
await client.chat_postEphemeral(
|
||||
channel=channel_id,
|
||||
|
|
@ -42,7 +42,7 @@ async def assign_question_tag_callback(
|
|||
ticket = await env.db.ticket.update(
|
||||
where={"ticketTs": ts},
|
||||
data={
|
||||
"questionTag": (
|
||||
"categoryTag": (
|
||||
{"connect": {"id": tag_id}}
|
||||
if tag_id is not None
|
||||
else {"disconnect": True}
|
||||
|
|
@ -52,7 +52,7 @@ async def assign_question_tag_callback(
|
|||
)
|
||||
if not ticket:
|
||||
logging.error(
|
||||
f"Failed to find corresponding ticket to update question tag ticket_ts={ts}"
|
||||
f"Failed to find corresponding ticket to update category tag ticket_ts={ts}"
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -78,11 +78,11 @@ async def assign_question_tag_callback(
|
|||
author_user_id=ticket.openedBy.slackId,
|
||||
msg_ts=ticket.msgTs,
|
||||
past_tickets=other_tickets,
|
||||
current_question_tag_id=tag_id,
|
||||
current_category_tag_id=tag_id,
|
||||
reopened_by=ticket.reopenedBy,
|
||||
),
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"Updated question tag on ticket ticket_id={ticket.id} tag_id={tag_id}"
|
||||
f"Updated category tag on ticket ticket_id={ticket.id} tag_id={tag_id}"
|
||||
)
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
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_question_tag import get_create_question_tag_modal
|
||||
|
||||
|
||||
async def create_question_tag_view_callback(
|
||||
ack: AsyncAck, body: dict, client: AsyncWebClient
|
||||
):
|
||||
"""
|
||||
Callback for the create question tag view submission
|
||||
"""
|
||||
await ack()
|
||||
user_id = body["user"]["id"]
|
||||
|
||||
user = await env.db.user.find_unique(where={"slackId": user_id})
|
||||
if not user or not user.helper:
|
||||
await send_heartbeat(
|
||||
f"Attempted to create question tag by non-helper <@{user_id}>"
|
||||
)
|
||||
return
|
||||
|
||||
label = body["view"]["state"]["values"]["tag_label"]["tag_label"]["value"]
|
||||
await env.db.questiontag.create(data={"label": label})
|
||||
|
||||
# await open_app_home("question-tags", client, user_id)
|
||||
|
||||
|
||||
async def create_question_tag_btn_callback(
|
||||
ack: AsyncAck, body: dict, client: AsyncWebClient
|
||||
):
|
||||
"""
|
||||
Open modal to create a question tag
|
||||
"""
|
||||
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.helper:
|
||||
await send_heartbeat(
|
||||
f"Attempted to open create-question-tag modal by non-helper <@{user_id}>"
|
||||
)
|
||||
return
|
||||
|
||||
view = get_create_question_tag_modal()
|
||||
await client.views_open(trigger_id=trigger_id, view=view, user_id=user_id)
|
||||
|
|
@ -8,62 +8,46 @@ async def backend_message_blocks(
|
|||
author_user_id: str,
|
||||
msg_ts: str,
|
||||
past_tickets: int,
|
||||
current_question_tag_id: int | None = None,
|
||||
current_category_tag_id: int | None = None,
|
||||
reopened_by: User | None = None,
|
||||
) -> list[dict]:
|
||||
thread_url = f"https://hackclub.slack.com/archives/{env.slack_help_channel}/p{msg_ts.replace('.', '')}"
|
||||
if current_question_tag_id is not None:
|
||||
if current_category_tag_id is not None:
|
||||
options = [
|
||||
{
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": tag.label,
|
||||
"text": tag.name,
|
||||
},
|
||||
"value": f"{tag.id}",
|
||||
}
|
||||
for tag in await env.db.questiontag.find_many()
|
||||
for tag in await env.db.categorytag.find_many()
|
||||
]
|
||||
initial_option = [
|
||||
option
|
||||
for option in options
|
||||
if option["value"] == f"{current_question_tag_id}"
|
||||
if option["value"] == f"{current_category_tag_id}"
|
||||
][0]
|
||||
else:
|
||||
initial_option = None
|
||||
question_tags_dropdown = {
|
||||
category_tags_dropdown = {
|
||||
"type": "input",
|
||||
"label": {"type": "plain_text", "text": "Question tag", "emoji": True},
|
||||
"label": {"type": "plain_text", "text": "Category tag", "emoji": True},
|
||||
"element": {
|
||||
"type": "external_select",
|
||||
"action_id": "question-tag-list",
|
||||
"action_id": "category-tag-list",
|
||||
"placeholder": {
|
||||
"type": "plain_text",
|
||||
"text": "Which question tag fits?",
|
||||
"text": "Which category tag fits?",
|
||||
},
|
||||
"min_query_length": 0,
|
||||
},
|
||||
}
|
||||
if initial_option:
|
||||
question_tags_dropdown["element"]["initial_option"] = initial_option
|
||||
category_tags_dropdown["element"]["initial_option"] = initial_option
|
||||
|
||||
return [
|
||||
question_tags_dropdown,
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "If none of the existing tags fit :point_right:",
|
||||
},
|
||||
"accessory": {
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": ":wrench: new question tag",
|
||||
"emoji": True,
|
||||
},
|
||||
"action_id": "create-question-tag",
|
||||
},
|
||||
},
|
||||
category_tags_dropdown,
|
||||
{
|
||||
"type": "input",
|
||||
"label": {"type": "plain_text", "text": "Team tags", "emoji": True},
|
||||
|
|
@ -108,7 +92,7 @@ async def send_backend_message(
|
|||
description: str,
|
||||
past_tickets: int,
|
||||
client: AsyncWebClient,
|
||||
current_question_tag_id: int | None = None,
|
||||
current_category_tag_id: int | None = None,
|
||||
reopened_by: User | None = None,
|
||||
display_name: str | None = None,
|
||||
profile_pic: str | None = None,
|
||||
|
|
@ -119,7 +103,7 @@ async def send_backend_message(
|
|||
channel=env.slack_ticket_channel,
|
||||
text=backend_message_fallback_text(author_user_id, description, reopened_by),
|
||||
blocks=await backend_message_blocks(
|
||||
author_user_id, msg_ts, past_tickets, current_question_tag_id, reopened_by
|
||||
author_user_id, msg_ts, past_tickets, current_category_tag_id, reopened_by
|
||||
),
|
||||
username=display_name,
|
||||
icon_url=profile_pic,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import logging
|
||||
import string
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
|
|
@ -8,6 +9,8 @@ from prometheus_client import Histogram
|
|||
from slack_sdk.errors import SlackApiError
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
|
||||
from nephthys.events.message.send_backend_message import backend_message_blocks
|
||||
from nephthys.events.message.send_backend_message import backend_message_fallback_text
|
||||
from nephthys.events.message.send_backend_message import send_backend_message
|
||||
from nephthys.macros import run_macro
|
||||
from nephthys.utils.env import env
|
||||
|
|
@ -18,6 +21,7 @@ from nephthys.utils.ticket_methods import delete_and_clean_up_ticket
|
|||
from prisma.enums import TicketStatus
|
||||
from prisma.enums import UserType
|
||||
from prisma.models import User
|
||||
from prisma.types import TicketCreateInput
|
||||
|
||||
# Message subtypes that should be handled by on_message (messages with no subtype are always handled)
|
||||
ALLOWED_SUBTYPES = ["file_share", "me_message", "thread_broadcast"]
|
||||
|
|
@ -28,6 +32,12 @@ TICKET_TITLE_GENERATION_DURATION = Histogram(
|
|||
)
|
||||
|
||||
|
||||
TICKET_CATEGORY_GENERATION_DURATION = Histogram(
|
||||
"nephthys_ticket_category_generation_duration_seconds",
|
||||
"How long it takes to generate a category tag using AI",
|
||||
)
|
||||
|
||||
|
||||
async def handle_message_sent_to_channel(event: Dict[str, Any], client: AsyncWebClient):
|
||||
"""Tell a non-helper off because they sent a thread message with the 'send to channel' box checked."""
|
||||
await client.chat_delete(
|
||||
|
|
@ -173,27 +183,55 @@ async def handle_new_question(
|
|||
):
|
||||
title = await generate_ticket_title(text)
|
||||
|
||||
async with perf_timer(
|
||||
"AI category tag generation", TICKET_CATEGORY_GENERATION_DURATION
|
||||
):
|
||||
category_tag_id = await generate_category_tag(text)
|
||||
|
||||
if category_tag_id:
|
||||
blocks = await backend_message_blocks(
|
||||
author_user_id=author_id,
|
||||
msg_ts=event["ts"],
|
||||
past_tickets=past_tickets,
|
||||
current_category_tag_id=category_tag_id,
|
||||
)
|
||||
|
||||
await client.chat_update(
|
||||
channel=env.slack_ticket_channel,
|
||||
ts=ticket_message_ts,
|
||||
text=backend_message_fallback_text(author_id, text),
|
||||
blocks=blocks,
|
||||
)
|
||||
|
||||
user_facing_message_ts = user_facing_message["ts"]
|
||||
if not user_facing_message_ts:
|
||||
logging.error(f"User-facing message has no ts: {user_facing_message}")
|
||||
return
|
||||
|
||||
async with perf_timer("Creating ticket in DB"):
|
||||
ticket = await env.db.ticket.create(
|
||||
{
|
||||
"title": title,
|
||||
"description": text,
|
||||
"msgTs": event["ts"],
|
||||
"ticketTs": ticket_message_ts,
|
||||
"openedBy": {"connect": {"id": db_user.id}},
|
||||
"userFacingMsgs": {
|
||||
"create": {
|
||||
"channelId": event["channel"],
|
||||
"ts": user_facing_message_ts,
|
||||
}
|
||||
},
|
||||
ticket_data: TicketCreateInput = {
|
||||
"title": title,
|
||||
"description": text,
|
||||
"msgTs": event["ts"],
|
||||
"ticketTs": ticket_message_ts,
|
||||
"openedBy": {"connect": {"id": db_user.id}},
|
||||
"userFacingMsgs": {
|
||||
"create": {
|
||||
"channelId": event["channel"],
|
||||
"ts": user_facing_message_ts,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if category_tag_id:
|
||||
ticket_data["categoryTag"] = {"connect": {"id": category_tag_id}}
|
||||
|
||||
ticket = await env.db.ticket.create(ticket_data)
|
||||
|
||||
if not category_tag_id:
|
||||
logging.warning(
|
||||
f"Failed to generate category tag for ticket_id={ticket.id}"
|
||||
)
|
||||
|
||||
try:
|
||||
await client.reactions_add(
|
||||
|
|
@ -341,3 +379,56 @@ async def generate_ticket_title(text: str):
|
|||
# Capitalise first letter
|
||||
title = title[0].upper() + title[1:] if len(title) > 1 else title.upper()
|
||||
return title
|
||||
|
||||
|
||||
async def generate_category_tag(text: str) -> int | None:
|
||||
category_tags = await env.db.categorytag.find_many()
|
||||
|
||||
if not category_tags:
|
||||
return None
|
||||
|
||||
tag_options = ", ".join([tag.name for tag in category_tags])
|
||||
tag_map = {tag.name.lower(): tag for tag in category_tags}
|
||||
|
||||
if not env.ai_client:
|
||||
return None
|
||||
|
||||
model = "google/gemini-3-flash-preview"
|
||||
try:
|
||||
response = await env.ai_client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a helpful assistant that categorizes support tickets! "
|
||||
f"Choose the best tag from this list: [{tag_options}]. "
|
||||
"Return ONLY the exact tag name. If none fit, return 'None'."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"Ticket content: {text}",
|
||||
},
|
||||
],
|
||||
)
|
||||
except OpenAIError as e:
|
||||
await send_heartbeat(f"Failed to get AI response for tag generation: {e}")
|
||||
return None
|
||||
|
||||
if not (len(response.choices) and response.choices[0].message.content):
|
||||
return None
|
||||
|
||||
suggested_tag_label = response.choices[0].message.content.strip()
|
||||
|
||||
suggested_clean = suggested_tag_label.strip(string.punctuation)
|
||||
|
||||
original_label = tag_map.get(suggested_clean.lower())
|
||||
|
||||
if not original_label:
|
||||
original_label = tag_map.get(suggested_tag_label.lower())
|
||||
|
||||
if original_label:
|
||||
return original_label.id
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from nephthys.macros.shipcertqueue import ShipCertQueue
|
|||
from nephthys.macros.shipwrights import Shipwrights
|
||||
from nephthys.macros.thread import Thread
|
||||
from nephthys.macros.trigger_daily_stats import DailyStats
|
||||
from nephthys.macros.trigger_fulfillment_reminder import FulfillmentReminder
|
||||
from nephthys.macros.types import Macro
|
||||
from nephthys.utils.env import env
|
||||
from nephthys.utils.logging import send_heartbeat
|
||||
|
|
@ -28,6 +29,7 @@ macro_list: list[type[Macro]] = [
|
|||
Thread,
|
||||
Reopen,
|
||||
DailyStats,
|
||||
FulfillmentReminder,
|
||||
Shipwrights,
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class Reopen(Macro):
|
|||
msg_ts=ticket.msgTs,
|
||||
past_tickets=other_tickets,
|
||||
client=env.slack_client,
|
||||
current_question_tag_id=ticket.questionTagId,
|
||||
current_category_tag_id=ticket.categoryTagId,
|
||||
reopened_by=helper,
|
||||
display_name=author.display_name(),
|
||||
profile_pic=author.profile_pic_512x(),
|
||||
|
|
|
|||
20
nephthys/macros/trigger_fulfillment_reminder.py
Normal file
20
nephthys/macros/trigger_fulfillment_reminder.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from nephthys.macros.types import Macro
|
||||
from nephthys.tasks import fulfillment_reminder
|
||||
from nephthys.utils.env import env
|
||||
|
||||
|
||||
class FulfillmentReminder(Macro):
|
||||
name = "fulfillment_reminder"
|
||||
can_run_on_closed = True
|
||||
|
||||
async def run(self, ticket, helper, **kwargs):
|
||||
"""Development-only macro to manually trigger the fulfillment reminder message"""
|
||||
if not env.environment == "development":
|
||||
await env.slack_client.chat_postEphemeral(
|
||||
channel=env.slack_help_channel,
|
||||
thread_ts=ticket.msgTs,
|
||||
user=helper.slackId,
|
||||
text="The `fulfillment_reminder` macro can only be run in development environments.",
|
||||
)
|
||||
return
|
||||
await fulfillment_reminder.send_fulfillment_reminder()
|
||||
|
|
@ -6,14 +6,14 @@ from thefuzz import process
|
|||
from nephthys.utils.env import env
|
||||
|
||||
|
||||
async def get_question_tags(payload: dict) -> list[dict[str, dict[str, str] | str]]:
|
||||
tags = await env.db.questiontag.find_many()
|
||||
async def get_category_tags(payload: dict) -> list[dict[str, dict[str, str] | str]]:
|
||||
tags = await env.db.categorytag.find_many()
|
||||
if not tags:
|
||||
return []
|
||||
|
||||
keyword = payload.get("value")
|
||||
if keyword:
|
||||
tag_names = [tag.label for tag in tags]
|
||||
tag_names = [tag.name for tag in tags]
|
||||
scores = process.extract(keyword, tag_names, scorer=fuzz.ratio, limit=100)
|
||||
matching_tags = [tags[tag_names.index(score[0])] for score in scores]
|
||||
else:
|
||||
|
|
@ -21,7 +21,7 @@ async def get_question_tags(payload: dict) -> list[dict[str, dict[str, str] | st
|
|||
|
||||
res = [
|
||||
{
|
||||
"text": {"type": "plain_text", "text": f"{tag.label}"},
|
||||
"text": {"type": "plain_text", "text": f"{tag.name}"},
|
||||
"value": str(tag.id),
|
||||
}
|
||||
for tag in matching_tags
|
||||
105
nephthys/tasks/fulfillment_reminder.py
Normal file
105
nephthys/tasks/fulfillment_reminder.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from nephthys.utils.env import env
|
||||
from nephthys.utils.logging import send_heartbeat
|
||||
from nephthys.utils.ticket_methods import get_question_message_link
|
||||
from prisma.enums import TicketStatus
|
||||
|
||||
|
||||
def slack_timestamp(dt: datetime, format: str = "date_short") -> str:
|
||||
fallback = dt.isoformat().replace("T", " ")
|
||||
return f"<!date^{int(dt.timestamp())}^{{{format}}}|{fallback}>"
|
||||
|
||||
|
||||
async def send_fulfillment_reminder():
|
||||
"""
|
||||
Checks for 'Shop/fulfillment query' tag and sends a reminder with open tickets for the fulfillment team/amber
|
||||
Run daily at 2 PM (London Time)
|
||||
"""
|
||||
|
||||
target_tag_name = "Shop/fulfillment query"
|
||||
target_slack_id = "U054VC2KM9P"
|
||||
|
||||
logging.info("Running fulfillment team reminder task")
|
||||
|
||||
try:
|
||||
tag = await env.db.categorytag.find_unique(where={"name": target_tag_name})
|
||||
|
||||
if not tag:
|
||||
logging.info(
|
||||
f"Tag '{target_tag_name}' not found. Skipping fulfillment reminder."
|
||||
)
|
||||
return
|
||||
|
||||
now = datetime.now()
|
||||
twenty_four_hours_ago = now - timedelta(hours=24)
|
||||
|
||||
tickets = await env.db.ticket.find_many(
|
||||
where={
|
||||
"categoryTagId": tag.id,
|
||||
"status": {"in": [TicketStatus.OPEN, TicketStatus.IN_PROGRESS]},
|
||||
"createdAt": {"gte": twenty_four_hours_ago},
|
||||
},
|
||||
include={"openedBy": True, "tagsOnTickets": {"include": {"tag": True}}},
|
||||
)
|
||||
|
||||
msg_header = f"oh hi <@{target_slack_id}>! i found some fulfillment tickets for you! :rac_cute:"
|
||||
|
||||
if not tickets:
|
||||
logging.info("No open fulfillment tickets found. Skipping Slack reminder.")
|
||||
return
|
||||
|
||||
else:
|
||||
msg_lines = [
|
||||
f":rac_shy: *tickets needing attention ({len(tickets)})*",
|
||||
"here are the open tickets from the last 24 hours:",
|
||||
]
|
||||
|
||||
for i, ticket in enumerate(tickets):
|
||||
label = (
|
||||
ticket.title or ticket.description[:100] or f"Ticket #{ticket.id}"
|
||||
)
|
||||
|
||||
link = get_question_message_link(ticket)
|
||||
created_ts = slack_timestamp(ticket.createdAt, format="date_short")
|
||||
|
||||
tags = ticket.tagsOnTickets
|
||||
tags_string = (
|
||||
" (" + ", ".join(f"*{t.tag.name}*" for t in tags if t.tag) + ")"
|
||||
if tags
|
||||
else ""
|
||||
)
|
||||
|
||||
msg_lines.append(
|
||||
f"{i + 1}. <{link}|{label}>{tags_string} (created {created_ts})"
|
||||
)
|
||||
|
||||
msg_body = "\n".join(msg_lines)
|
||||
|
||||
full_msg = f"""
|
||||
{msg_header}
|
||||
|
||||
{msg_body}
|
||||
"""
|
||||
|
||||
await env.slack_client.chat_postMessage(
|
||||
channel=env.slack_bts_channel,
|
||||
text=f"Reminder for <@{target_slack_id}>",
|
||||
blocks=[{"type": "section", "text": {"type": "mrkdwn", "text": full_msg}}],
|
||||
)
|
||||
|
||||
logging.info("Fulfillment reminder sent successfully.")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send fulfillment reminder: {e}", exc_info=True)
|
||||
try:
|
||||
await send_heartbeat(
|
||||
"Failed to send fulfillment reminder",
|
||||
messages=[str(e)],
|
||||
)
|
||||
except Exception as slack_e:
|
||||
logging.error(
|
||||
f"Could not send error notification to Slack maintainer: {slack_e}"
|
||||
)
|
||||
|
|
@ -6,10 +6,8 @@ from slack_bolt.async_app import AsyncApp
|
|||
from slack_bolt.context.ack.async_ack import AsyncAck
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
|
||||
from nephthys.actions.assign_question_tag import assign_question_tag_callback
|
||||
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_question_tag import create_question_tag_btn_callback
|
||||
from nephthys.actions.create_question_tag import create_question_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
|
||||
|
|
@ -21,7 +19,7 @@ from nephthys.events.channel_join import channel_join
|
|||
from nephthys.events.channel_left import channel_left
|
||||
from nephthys.events.message_creation import on_message
|
||||
from nephthys.events.message_deletion import on_message_deletion
|
||||
from nephthys.options.question_tags import get_question_tags
|
||||
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
|
||||
|
|
@ -62,9 +60,9 @@ async def handle_team_tag_list_options(ack: AsyncAck, payload: dict):
|
|||
await ack(options=tags)
|
||||
|
||||
|
||||
@app.options("question-tag-list")
|
||||
async def handle_question_tag_list_options(ack: AsyncAck, payload: dict):
|
||||
tags = await get_question_tags(payload)
|
||||
@app.options("category-tag-list")
|
||||
async def handle_category_tag_list_options(ack: AsyncAck, payload: dict):
|
||||
tags = await get_category_tags(payload)
|
||||
await ack(options=tags)
|
||||
|
||||
|
||||
|
|
@ -100,13 +98,6 @@ async def create_team_tag(ack: AsyncAck, body: Dict[str, Any], client: AsyncWebC
|
|||
await create_team_tag_btn_callback(ack, body, client)
|
||||
|
||||
|
||||
@app.action("create-question-tag")
|
||||
async def create_question_tag(
|
||||
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
|
||||
):
|
||||
await create_question_tag_btn_callback(ack, body, client)
|
||||
|
||||
|
||||
@app.view("create_team_tag")
|
||||
async def create_team_tag_view(
|
||||
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
|
||||
|
|
@ -114,13 +105,6 @@ async def create_team_tag_view(
|
|||
await create_team_tag_view_callback(ack, body, client)
|
||||
|
||||
|
||||
@app.view("create_question_tag")
|
||||
async def create_question_tag_view(
|
||||
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
|
||||
):
|
||||
await create_question_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)
|
||||
|
|
@ -132,11 +116,11 @@ async def assign_team_tag(ack: AsyncAck, body: Dict[str, Any], client: AsyncWebC
|
|||
await assign_team_tag_callback(ack, body, client)
|
||||
|
||||
|
||||
@app.action("question-tag-list")
|
||||
async def assign_question_tag(
|
||||
@app.action("category-tag-list")
|
||||
async def assign_category_tag(
|
||||
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
|
||||
):
|
||||
await assign_question_tag_callback(ack, body, client)
|
||||
await assign_category_tag_callback(ack, body, client)
|
||||
|
||||
|
||||
@app.command("/dm-magic-link")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from nephthys.utils.env import env
|
|||
from prisma.models import User
|
||||
|
||||
|
||||
def header_buttons(current_view: str):
|
||||
def header_buttons(current_view: str, user: User | None):
|
||||
buttons = []
|
||||
|
||||
buttons.append(
|
||||
|
|
@ -59,6 +59,6 @@ def title_line():
|
|||
def get_header(user: User | None, current: str = "dashboard") -> list[dict]:
|
||||
return [
|
||||
title_line(),
|
||||
header_buttons(current),
|
||||
header_buttons(current, user),
|
||||
{"type": "divider"},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
def get_create_question_tag_modal():
|
||||
return {
|
||||
"type": "modal",
|
||||
"callback_id": "create_question_tag",
|
||||
"title": {
|
||||
"type": "plain_text",
|
||||
"text": "Create question tag",
|
||||
"emoji": True,
|
||||
},
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": """
|
||||
Question tags are used to keep track of how often specific questions are being asked, so tag names should be brief but specific enough to uniquely identify the question.
|
||||
|
||||
Examples:
|
||||
• Missing CSS on site
|
||||
• What is Flavortown?
|
||||
• "You're not eligible" when trying to ship project
|
||||
""",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"block_id": "tag_label",
|
||||
"label": {
|
||||
"type": "plain_text",
|
||||
"text": "Question label",
|
||||
},
|
||||
"element": {
|
||||
"type": "plain_text_input",
|
||||
"action_id": "tag_label",
|
||||
},
|
||||
},
|
||||
],
|
||||
"submit": {
|
||||
"type": "plain_text",
|
||||
"text": ":rac_question: Create!",
|
||||
"emoji": True,
|
||||
},
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ model User {
|
|||
reopenedTickets Ticket[] @relation("ReopenedTickets")
|
||||
|
||||
tagSubscriptions UserTagSubscription[]
|
||||
createdCategoryTags CategoryTag[] @relation("CreatedCategoryTags")
|
||||
helper Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
|
@ -74,6 +75,9 @@ model Ticket {
|
|||
questionTag QuestionTag? @relation("QuestionTagTickets", fields: [questionTagId], references: [id])
|
||||
questionTagId Int?
|
||||
|
||||
categoryTag CategoryTag? @relation("CategoryTagTickets", fields: [categoryTagId], references: [id])
|
||||
categoryTagId Int?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
|
|
@ -128,3 +132,14 @@ model BotMessage {
|
|||
|
||||
@@unique([ts, channelId])
|
||||
}
|
||||
|
||||
model CategoryTag {
|
||||
id Int @id @unique @default(autoincrement())
|
||||
name String @unique
|
||||
tickets Ticket[] @relation("CategoryTagTickets")
|
||||
|
||||
createdBy User? @relation("CreatedCategoryTags", fields: [createdById], references: [id])
|
||||
createdById Int?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue