stale tickets config

This commit is contained in:
End 2026-03-11 23:25:19 -07:00
parent 575fa89192
commit 9b7f536bf4
No known key found for this signature in database
11 changed files with 328 additions and 19 deletions

View file

@ -102,6 +102,17 @@ Note: These steps have to be done by a Workspace Admin (otherwise it will be una
4. Don't forget to click **Save All Environment Variables**
## Configuring Bot Settings
After deployment, you can configure bot settings (like stale ticket auto-close) from the Slack App Home:
1. Open the bot's App Home in Slack
2. Navigate to the **Settings** tab
3. Configure the stale ticket auto-close feature:
- Click **Configure** to set the number of days before tickets are auto-closed
- Click **Enable/Disable** to turn the feature on or off
- Settings are stored in the database and take effect immediately
## Some final pre-requisites
1. Add the bot to the BTS channel, help channel, tickets channel, and heartbeat channel

View file

@ -54,13 +54,16 @@ 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(),
# )
from nephthys.tasks.close_stale import close_stale_tickets
from datetime import datetime
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,125 @@
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.configure_stale_days import get_configure_stale_days_modal
async def configure_stale_days_btn_callback(
ack: AsyncAck, body: dict, client: AsyncWebClient
):
"""Opens the configure stale days modal when the configure button is clicked."""
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 configure stale days modal by non-admin user <@{user_id}>"
)
return
# Fetch current value
stale_days_setting = await env.db.settings.find_unique(
where={"key": "stale_ticket_days"}
)
current_value = stale_days_setting.value if stale_days_setting else None
view = get_configure_stale_days_modal(current_value)
await client.views_open(trigger_id=trigger_id, view=view, user_id=user_id)
async def configure_stale_days_view_callback(
ack: AsyncAck, body: dict, client: AsyncWebClient
):
"""Handles the submission of the configure stale days modal."""
user_id = body["user"]["id"]
raw_days = body["view"]["state"]["values"]["stale_days"]["stale_days"]["value"]
days = raw_days.strip() if raw_days else None
errors = {}
# Validate that it's a positive integer if provided
if days:
try:
days_int = int(days)
if days_int <= 0:
errors["stale_days"] = "Must be a positive number."
except ValueError:
errors["stale_days"] = "Must be a valid number."
if errors:
await ack(response_action="errors", errors=errors)
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 configure stale days by non-admin user <@{user_id}>"
)
return
if days:
await env.db.settings.upsert(
where={"key": "stale_ticket_days"},
data={
"create": {"key": "stale_ticket_days", "value": days},
"update": {"value": days},
},
)
logging.info(f"Stale ticket days updated to {days} by <@{user_id}>")
await send_heartbeat(
f"Stale ticket days updated to {days} days by <@{user_id}>"
)
else:
await env.db.settings.delete_many(where={"key": "stale_ticket_days"})
logging.info(f"Stale ticket auto-close disabled by <@{user_id}>")
await send_heartbeat(f"Stale ticket auto-close disabled by <@{user_id}>")
await ack()
from nephthys.events.app_home_opened import open_app_home
await open_app_home("settings", client, user_id)
async def toggle_stale_feature_callback(
ack: AsyncAck, body: dict, client: AsyncWebClient
):
"""Handles toggling the stale feature on/off.
Disable: deletes the setting.
Enable: opens the configure modal so the user must pick a value.
"""
user_id = body["user"]["id"]
action = body["actions"][0]["value"]
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 toggle stale feature by non-admin user <@{user_id}>"
)
return
if action == "disable":
await ack()
await env.db.settings.delete_many(where={"key": "stale_ticket_days"})
logging.info(f"Stale ticket auto-close disabled by <@{user_id}>")
await send_heartbeat(f"Stale ticket auto-close disabled by <@{user_id}>")
from nephthys.events.app_home_opened import open_app_home
await open_app_home("settings", client, user_id)
else:
# Open the configure modal to set time
await ack()
trigger_id = body["trigger_id"]
view = get_configure_stale_days_modal()
await client.views_open(trigger_id=trigger_id, view=view, user_id=user_id)

View file

@ -14,6 +14,7 @@ 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
from nephthys.views.home.settings import get_settings_view
from nephthys.views.home.stats import get_stats_view
from nephthys.views.home.team_tags import get_team_tags_view
@ -59,6 +60,8 @@ async def open_app_home(home_type: str, client: AsyncWebClient, user_id: str):
view = await get_team_tags_view(user)
case "category-tags":
view = await get_category_tags_view(user)
case "settings":
view = await get_settings_view(user)
case "my-stats":
view = await get_stats_view(user)
case _:

View file

@ -13,6 +13,13 @@ from prisma.enums import TicketStatus
async def get_is_stale(ts: str, max_retries: int = 3) -> bool:
stale_ticket_days = await env.get_stale_ticket_days()
if not stale_ticket_days:
logging.error(
"get_is_stale called but stale_ticket_days not configured in database"
)
return False
for attempt in range(max_retries):
try:
replies = await env.slack_client.conversations_replies(
@ -28,7 +35,7 @@ async def get_is_stale(ts: str, max_retries: int = 3) -> bool:
return (
datetime.now(tz=timezone.utc)
- datetime.fromtimestamp(float(last_reply["ts"]), tz=timezone.utc)
) > timedelta(days=3)
) > timedelta(days=stale_ticket_days)
except SlackApiError as e:
if e.response["error"] == "ratelimited":
retry_after = int(e.response.headers.get("Retry-After", 1))
@ -82,12 +89,22 @@ async def get_is_stale(ts: str, max_retries: int = 3) -> bool:
async def close_stale_tickets():
"""
Closes tickets that have been open for more than 3 days.
Closes tickets that have been open for more than the configured number of days.
The number of days is configured in the database settings (key: stale_ticket_days).
This task is intended to be run periodically.
"""
logging.info("Closing stale tickets...")
await send_heartbeat("Closing stale tickets...")
stale_ticket_days = await env.get_stale_ticket_days()
if not stale_ticket_days:
logging.info(
"Stale ticket auto-close is disabled (no stale_ticket_days setting)"
)
return
logging.info(f"Closing stale tickets (threshold: {stale_ticket_days} days)...")
await send_heartbeat(
f"Closing stale tickets (threshold: {stale_ticket_days} days)..."
)
try:
tickets = await env.db.ticket.find_many(

View file

@ -121,5 +121,20 @@ class Environment:
self._workspace_admin_available = user_info["is_admin"]
return user_info["is_admin"]
async def get_stale_ticket_days(self) -> int | None:
"""Get the number of days before a ticket is considered stale from database settings."""
stale_days_setting = await self.db.settings.find_unique(
where={"key": "stale_ticket_days"}
)
if stale_days_setting and stale_days_setting.value:
try:
return int(stale_days_setting.value)
except ValueError:
logging.warning(
f"Invalid stale_ticket_days value in database: {stale_days_setting.value}"
)
return None
return None
env = Environment()

View file

@ -13,6 +13,9 @@ from nephthys.actions.create_category_tag import create_category_tag_view_callba
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
from nephthys.actions.settings import configure_stale_days_btn_callback
from nephthys.actions.settings import configure_stale_days_view_callback
from nephthys.actions.settings import toggle_stale_feature_callback
from nephthys.actions.tag_subscribe import tag_subscribe_callback
from nephthys.commands.dm_magic_link import dm_magic_link_cmd_callback
from nephthys.events.app_home_opened import on_app_home_opened
@ -146,3 +149,24 @@ async def dm_magic_link(
command, ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
):
await dm_magic_link_cmd_callback(command, ack, body, client)
@app.action("configure-stale-days")
async def configure_stale_days(
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
):
await configure_stale_days_btn_callback(ack, body, client)
@app.view("configure_stale_days")
async def configure_stale_days_view(
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
):
await configure_stale_days_view_callback(ack, body, client)
@app.action("toggle-stale-feature")
async def toggle_stale_feature(
ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient
):
await toggle_stale_feature_callback(ack, body, client)

View file

@ -14,5 +14,6 @@ APP_HOME_VIEWS: list[View] = [
View("Assigned Tickets", "assigned-tickets"),
View("Team Tags", "team-tags"),
View("Category Tags", "category-tags"),
View("Settings", "settings"),
View("My Stats", "my-stats"),
]

View file

@ -0,0 +1,72 @@
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_settings_view(user: User | None) -> dict:
is_admin = bool(user and user.admin)
header = get_header_components(user, "settings")
if not is_admin:
return Home(
[
*header,
Header(":rac_info: Settings"),
Section(":rac_nooo: only admins can manage settings."),
]
).build()
stale_days_setting = await env.db.settings.find_unique(
where={"key": "stale_ticket_days"}
)
current_stale_days = stale_days_setting.value if stale_days_setting else "Not set"
stale_enabled = stale_days_setting and stale_days_setting.value
return Home(
[
*header,
Header(":rac_wrench: Bot Settings"),
Section(":rac_thumbs: configure bot behavior and features"),
Divider(),
Section(
text=f"*Stale Ticket Auto-Close*\n"
f"Automatically close tickets after a period of inactivity.\n"
f"Current setting: *{current_stale_days}* {'day' if current_stale_days == '1' else 'days'} "
f"{'(Enabled)' if stale_enabled else '(Disabled)'}"
),
Actions(
elements=[
Button(
text=":pencil2: Configure",
action_id="configure-stale-days"
if stale_enabled
else "toggle-stale-feature",
value="enable" if not stale_enabled else None,
style=Button.PRIMARY,
),
]
+ (
[
Button(
text=":x: Disable",
action_id="toggle-stale-feature",
value="disable",
style=Button.DANGER,
),
]
if stale_enabled
else []
)
),
Divider(),
]
).build()

View file

@ -0,0 +1,30 @@
from blockkit import Input
from blockkit import Modal
from blockkit import PlainTextInput
def get_configure_stale_days_modal(current_value: str | None = None):
"""
Returns a modal for configuring the number of days before tickets are auto-closed as stale.
Args:
current_value: Current number of days (optional)
"""
return Modal(
title=":wrench: Stale Tickets",
callback_id="configure_stale_days",
submit=":white_check_mark: Save",
blocks=[
Input(
label="Days before auto-close",
block_id="stale_days",
element=PlainTextInput(
action_id="stale_days",
placeholder="e.g. 7",
**({"initial_value": current_value} if current_value else {}),
),
hint="Set the number of days of inactivity before a ticket is automatically closed. Leave empty to disable.",
optional=True,
),
],
).build()

View file

@ -32,9 +32,9 @@ model User {
assignedTickets Ticket[] @relation("AssignedTickets")
reopenedTickets Ticket[] @relation("ReopenedTickets")
tagSubscriptions UserTagSubscription[]
createdCategoryTags CategoryTag[] @relation("CreatedCategoryTags")
helper Boolean @default(false)
tagSubscriptions UserTagSubscription[]
createdCategoryTags CategoryTag[] @relation("CreatedCategoryTags")
helper Boolean @default(false)
createdAt DateTime @default(now())
}
@ -75,7 +75,7 @@ model Ticket {
questionTag QuestionTag? @relation("QuestionTagTickets", fields: [questionTagId], references: [id])
questionTagId Int?
categoryTag CategoryTag? @relation("CategoryTagTickets", fields: [categoryTagId], references: [id])
categoryTag CategoryTag? @relation("CategoryTagTickets", fields: [categoryTagId], references: [id])
categoryTagId Int?
createdAt DateTime @default(now())
@ -134,12 +134,20 @@ model BotMessage {
}
model CategoryTag {
id Int @id @unique @default(autoincrement())
name String @unique
tickets Ticket[] @relation("CategoryTagTickets")
id Int @id @unique @default(autoincrement())
name String @unique
tickets Ticket[] @relation("CategoryTagTickets")
createdBy User? @relation("CreatedCategoryTags", fields: [createdById], references: [id])
createdBy User? @relation("CreatedCategoryTags", fields: [createdById], references: [id])
createdById Int?
createdAt DateTime @default(now())
}
model Settings {
id Int @id @unique @default(autoincrement())
key String @unique
value String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}