mirror of
https://github.com/System-End/nephthys.git
synced 2026-04-19 22:05:12 +00:00
stale tickets config
This commit is contained in:
parent
575fa89192
commit
9b7f536bf4
11 changed files with 328 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
125
nephthys/actions/settings.py
Normal file
125
nephthys/actions/settings.py
Normal 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)
|
||||
|
|
@ -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 _:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
72
nephthys/views/home/settings.py
Normal file
72
nephthys/views/home/settings.py
Normal 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()
|
||||
30
nephthys/views/modals/configure_stale_days.py
Normal file
30
nephthys/views/modals/configure_stale_days.py
Normal 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()
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue