This commit is contained in:
End 2026-03-28 07:07:04 -07:00
parent b34bc61f73
commit 79e065bd2a
No known key found for this signature in database
10 changed files with 32 additions and 303 deletions

View file

@ -98,21 +98,15 @@ Note: These steps have to be done by a Workspace Admin (otherwise it will be una
LOG_LEVEL_STDERR="WARNING"
# Override the log level for OpenTelemetry output
LOG_LEVEL_OTEL="WARNING"
# Optional: Enable stale ticket auto-close
# Tickets inactive for this many days will be automatically closed
# Leave unset to disable
STALE_TICKET_DAYS="7"
```
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 open the settings, set the number of days before tickets are auto-closed, and enable or disable the feature
- When the feature is enabled, a **Disable** button will appear that you can use to turn it 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

@ -1,125 +0,0 @@
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,7 +14,6 @@ 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
@ -60,8 +59,6 @@ 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

@ -12,14 +12,7 @@ from nephthys.utils.logging import send_heartbeat
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
async def get_is_stale(ts: str, stale_ticket_days: int, max_retries: int = 3) -> bool:
for attempt in range(max_retries):
try:
replies = await env.slack_client.conversations_replies(
@ -91,15 +84,14 @@ async def close_stale_tickets():
"""
Closes tickets that have been inactive for more than the configured number of days,
based on the timestamp of the last message in the ticket's Slack thread.
The number of days is configured in the database settings (key: stale_ticket_days).
This task is intended to be run periodically.
Configure via the STALE_TICKET_DAYS environment variable.
This task is intended to be run periodically (e.g., hourly).
"""
stale_ticket_days = await env.get_stale_ticket_days()
stale_ticket_days = env.stale_ticket_days
if not stale_ticket_days:
logging.info(
"Stale ticket auto-close is disabled (no stale_ticket_days setting)"
)
logging.info("Stale ticket auto-close is disabled (STALE_TICKET_DAYS not set)")
return
logging.info(f"Closing stale tickets (threshold: {stale_ticket_days} days)...")
@ -125,7 +117,7 @@ async def close_stale_tickets():
for ticket in batch:
await asyncio.sleep(1.2) # Rate limiting delay
if await get_is_stale(ticket.msgTs):
if await get_is_stale(ticket.msgTs, stale_ticket_days):
stale += 1
resolver_user = (
ticket.assignedTo if ticket.assignedTo else ticket.openedBy

View file

@ -63,6 +63,25 @@ class Environment:
self.slack_heartbeat_channel = os.environ.get("SLACK_HEARTBEAT_CHANNEL")
# Stale ticket auto-close: number of days of inactivity before closing
# Set to a positive integer to enable, leave unset to disable
stale_days_str = os.environ.get("STALE_TICKET_DAYS")
if stale_days_str:
try:
stale_days = int(stale_days_str)
self.stale_ticket_days = stale_days if stale_days > 0 else None
if stale_days <= 0:
logging.warning(
f"STALE_TICKET_DAYS must be positive, got {stale_days}. Disabling."
)
except ValueError:
logging.warning(
f"Invalid STALE_TICKET_DAYS value: {stale_days_str}. Disabling."
)
self.stale_ticket_days = None
else:
self.stale_ticket_days = None
unset = [key for key, value in self.__dict__.items() if value == "unset"]
if unset:
@ -121,20 +140,5 @@ 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,9 +13,6 @@ 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
@ -149,24 +146,3 @@ 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,6 +14,5 @@ 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

@ -1,72 +0,0 @@
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

@ -1,30 +0,0 @@
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

@ -144,10 +144,4 @@ model CategoryTag {
createdAt DateTime @default(now())
}
model Settings {
id Int @id @unique @default(autoincrement())
key String @unique
value String?
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}