From 36b488d9d3782ab45c46eddeed0d82c4bdda335c Mon Sep 17 00:00:00 2001 From: transcental Date: Mon, 14 Jul 2025 16:31:55 +0100 Subject: [PATCH] add stale closing --- nephthys/__main__.py | 9 ++++ nephthys/actions/resolve.py | 6 ++- nephthys/tasks/close_stale.py | 77 ++++++++++++++++++++++++++++++ nephthys/transcripts/transcript.py | 9 ++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 nephthys/tasks/close_stale.py diff --git a/nephthys/__main__.py b/nephthys/__main__.py index 4768fb1..1cf66ff 100644 --- a/nephthys/__main__.py +++ b/nephthys/__main__.py @@ -1,6 +1,7 @@ import asyncio import contextlib import logging +from datetime import datetime import uvicorn from aiohttp import ClientSession @@ -8,6 +9,7 @@ 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.update_helpers import update_helpers from nephthys.utils.delete_thread import process_queue @@ -35,6 +37,13 @@ async def main(_app: Starlette): scheduler = AsyncIOScheduler(timezone="Europe/London") scheduler.add_job(send_daily_stats, "cron", hour=0, minute=0) + 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/resolve.py b/nephthys/actions/resolve.py index 7f8ae12..65b6be5 100644 --- a/nephthys/actions/resolve.py +++ b/nephthys/actions/resolve.py @@ -9,7 +9,7 @@ from nephthys.utils.permissions import can_resolve from prisma.enums import TicketStatus -async def resolve(ts: str, resolver: str, client: AsyncWebClient): +async def resolve(ts: str, resolver: str, client: AsyncWebClient, stale: bool = False): resolving_user = await env.db.user.find_unique(where={"slackId": resolver}) if not resolving_user: await send_heartbeat( @@ -57,7 +57,9 @@ async def resolve(ts: str, resolver: str, client: AsyncWebClient): await client.chat_postMessage( channel=env.slack_help_channel, - text=env.transcript.ticket_resolve.format(user_id=resolver), + text=env.transcript.ticket_resolve.format(user_id=resolver) + if not stale + else env.transcript.ticket_resolve_stale.format(user_id=resolver), thread_ts=ts, ) diff --git a/nephthys/tasks/close_stale.py b/nephthys/tasks/close_stale.py new file mode 100644 index 0000000..8dec0d9 --- /dev/null +++ b/nephthys/tasks/close_stale.py @@ -0,0 +1,77 @@ +import asyncio +import logging +from datetime import datetime +from datetime import timedelta +from datetime import timezone + +from slack_sdk.errors import SlackApiError + +from nephthys.actions.resolve import resolve +from nephthys.utils.env import env +from nephthys.utils.logging import send_heartbeat +from prisma.enums import TicketStatus + + +async def get_is_stale(ts: str) -> bool: + try: + replies = await env.slack_client.conversations_replies( + channel=env.slack_help_channel, ts=ts, limit=1000 + ) + last_reply = ( + replies.get("messages", [])[-1] if replies.get("messages") else None + ) + if not last_reply: + logging.error("No replies found - this should never happen") + await send_heartbeat(f"No replies found for ticket {ts}") + return False + return ( + datetime.now(tz=timezone.utc) + - datetime.fromtimestamp(float(ts), tz=timezone.utc) + ) > timedelta(days=3) + except SlackApiError as e: + if e.response["error"] == "ratelimited": + retry_after = int(e.response.headers.get("Retry-After", 1)) + logging.warning( + f"Rate limited while fetching replies for ticket {ts}. Retrying after {retry_after} seconds." + ) + await asyncio.sleep(retry_after) + return await get_is_stale(ts) + else: + logging.error( + f"Error fetching replies for ticket {ts}: {e.response['error']}" + ) + await send_heartbeat( + f"Error fetching replies for ticket {ts}: {e.response['error']}" + ) + return False + + +async def close_stale_tickets(): + """ + Closes tickets that have been open for more than 7 days. + This task is intended to be run periodically. + """ + + logging.info("Closing stale tickets...") + await send_heartbeat("Closing stale tickets...") + + try: + tickets = await env.db.ticket.find_many( + where={"NOT": [{"status": TicketStatus.CLOSED}]}, + include={ + "openedBy": True, + }, + ) + stale_tickets = [ + ticket for ticket in tickets if await get_is_stale(ticket.msgTs) + ] + + for ticket in stale_tickets: + await resolve(ticket.msgTs, ticket.openedBy.slackId, env.slack_client) # type: ignore (this is valid - see include above) + + await send_heartbeat(f"Closed {len(stale_tickets)} stale tickets.") + + logging.info(f"Closed {len(stale_tickets)} stale tickets.") + except Exception as e: + logging.error(f"Error closing stale tickets: {e}") + await send_heartbeat(f"Error closing stale tickets: {e}") diff --git a/nephthys/transcripts/transcript.py b/nephthys/transcripts/transcript.py index cfb39d0..f343c6a 100644 --- a/nephthys/transcripts/transcript.py +++ b/nephthys/transcripts/transcript.py @@ -59,6 +59,11 @@ class Transcript(BaseModel): default="", description="Message when ticket is resolved" ) + ticket_resolve_stale: str = Field( + default="", + description="Message when ticket is resolved due to being stale", + ) + thread_broadcast_delete: str = Field( default="hey! please keep your messages *all in one thread* to make it easier to read! i've gone ahead and removed that message from the channel for ya :D", ) @@ -116,6 +121,10 @@ if your question has been answered, please hit the button below to mark it as re self.ticket_resolve = 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 <#{self.help_channel}> and someone'll be happy to help you out! not me though, i'm just a silly racoon ^-^ """ + if not self.ticket_resolve_stale: + self.ticket_resolve_stale = f""":rac_nooo: it looks like this post is a bit old! if you still need help, please make a new post in <#{self.help_channel}> and someone'll be happy to help you out! ^~^ + """ + if not self.home_unknown_user_text: self.home_unknown_user_text = f"heyyyy, heidi here! it looks like i'm not allowed to show ya this. sorry! if you think this is a mistake, please reach out to <@{self.program_owner}> and she'll lmk what to do!"