Compare commits
8 Commits
2152544d09
...
email-divi
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fcffe8d33 | |||
| 2ccdbf72aa | |||
| 3e20f9cf86 | |||
| 2fab3b97bf | |||
| a388d99fdf | |||
| 3212004fc9 | |||
| a565450e2d | |||
| 837fd10984 |
11
.env.example
11
.env.example
@@ -60,8 +60,10 @@ SUPPORT_NAME=Support
|
|||||||
LOGO_URL= # URL of logo shown in embeds (optional)
|
LOGO_URL= # URL of logo shown in embeds (optional)
|
||||||
EMAIL_SIGNATURE= # HTML signature for outgoing emails (use \n for line breaks)
|
EMAIL_SIGNATURE= # HTML signature for outgoing emails (use \n for line breaks)
|
||||||
TICKET_CLOSE_SUBJECT_PREFIX=[Resolved]
|
TICKET_CLOSE_SUBJECT_PREFIX=[Resolved]
|
||||||
# Email tickets only (closure email body):
|
# Email tickets only (closure email body). Placeholders: {closer_name}; \n for line breaks.
|
||||||
TICKET_CLOSE_MESSAGE= # Body of closure email to customer
|
TICKET_CLOSE_MESSAGE= # Body of closure email to customer
|
||||||
|
# Email tickets only (escalation notification email body). Placeholders: {escalator_name}, {tier}; \n for line breaks.
|
||||||
|
TICKET_ESCALATION_EMAIL_MESSAGE= # Body of escalation email to customer
|
||||||
TICKET_CLOSE_SIGNATURE= # Signature on closure email
|
TICKET_CLOSE_SIGNATURE= # Signature on closure email
|
||||||
# Discord ticket closure (in-channel before transcript, transcript post, and auto-close):
|
# Discord ticket closure (in-channel before transcript, transcript post, and auto-close):
|
||||||
DISCORD_CLOSE_MESSAGE= # Message in ticket channel before transcript (e.g. ... If you still need assistance, please open a new ticket.)
|
DISCORD_CLOSE_MESSAGE= # Message in ticket channel before transcript (e.g. ... If you still need assistance, please open a new ticket.)
|
||||||
@@ -103,6 +105,13 @@ ALLOW_CLAIM_OVERWRITE=false
|
|||||||
ADMIN_ID= # Discord user ID of the bot admin (for Gmail OAuth failure DMs)
|
ADMIN_ID= # Discord user ID of the bot admin (for Gmail OAuth failure DMs)
|
||||||
FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60)
|
FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60)
|
||||||
GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30)
|
GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30)
|
||||||
|
GMAIL_POLL_ENABLED= # Inbound email flow master switch; "false" disables polling (default on). Toggle at runtime with /email on|off
|
||||||
|
GMAIL_LABEL_TRIAGE= # Gmail label for newly created tickets (default "Triage"); auto-created if missing
|
||||||
|
GMAIL_LABEL_ESCALATED= # Gmail label for escalated tickets (default "Escalated")
|
||||||
|
GMAIL_LABEL_RESOLVED= # Gmail label for resolved/closed tickets (default "Resolved")
|
||||||
|
GMAIL_LABEL_FOR_JAKE= # /folder option (default "For Jake")
|
||||||
|
GMAIL_LABEL_DASHBOARD_ERRORS= # /folder option (default "Dashboard Errors")
|
||||||
|
GMAIL_LABEL_PARTNERSHIP_OFFERS= # /folder option (default "Partnership Offers")
|
||||||
GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs
|
GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs
|
||||||
AUTOMATION_LOG_CHANNEL_ID= # Channel for auto-close/auto-unclaim/reminder logs
|
AUTOMATION_LOG_CHANNEL_ID= # Channel for auto-close/auto-unclaim/reminder logs
|
||||||
RENAME_LOG_CHANNEL_ID= # Channel for channel rename queue logs
|
RENAME_LOG_CHANNEL_ID= # Channel for channel rename queue logs
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Broccolini Bot is a single Node.js process. It does three things at once:
|
Broccolini Bot is a single Node.js process. It does three things at once:
|
||||||
|
|
||||||
1. **Listens to Discord** — slash commands, button clicks, modals, ticket-channel messages.
|
1. **Listens to Discord** — slash commands, button clicks, modals, ticket-channel messages.
|
||||||
2. **Polls Gmail** — every N seconds, pulls unread `category:primary` mail and turns each thread into a Discord ticket channel.
|
2. **Polls Gmail** — every N seconds, pulls unread `in:inbox` mail and turns each thread into a Discord ticket channel.
|
||||||
3. **Serves a couple of HTTP endpoints** — a public healthcheck and an internal config/control API.
|
3. **Serves a couple of HTTP endpoints** — a public healthcheck and an internal config/control API.
|
||||||
|
|
||||||
State lives in MongoDB via Mongoose. There is no queue/worker tier and no public REST API.
|
State lives in MongoDB via Mongoose. There is no queue/worker tier and no public REST API.
|
||||||
@@ -27,7 +27,7 @@ Every `setInterval` in the `ready` block is wrapped through `trackInterval(...)`
|
|||||||
|
|
||||||
`gmail-poll.js`:
|
`gmail-poll.js`:
|
||||||
|
|
||||||
1. Lists unread messages in `category:primary`.
|
1. Lists unread messages in `in:inbox` (excludes Spam, Trash, and anything filter-skipped from the inbox).
|
||||||
2. For each thread, looks up an existing `Ticket` by `gmailThreadId`. If none, creates a Discord channel under `TICKET_CATEGORY_ID` (or an overflow category if the main is full at Discord's 50-channel limit) and inserts a `Ticket` document.
|
2. For each thread, looks up an existing `Ticket` by `gmailThreadId`. If none, creates a Discord channel under `TICKET_CATEGORY_ID` (or an overflow category if the main is full at Discord's 50-channel limit) and inserts a `Ticket` document.
|
||||||
3. Posts a welcome embed + action row (Close / Claim / Escalate) into the channel and pings `ROLE_ID_TO_PING`.
|
3. Posts a welcome embed + action row (Close / Claim / Escalate) into the channel and pings `ROLE_ID_TO_PING`.
|
||||||
4. On subsequent emails in the same thread, just appends the new message to the existing channel.
|
4. On subsequent emails in the same thread, just appends the new message to the existing channel.
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ const client = new Client({
|
|||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
GatewayIntentBits.GuildMembers,
|
GatewayIntentBits.GuildMembers
|
||||||
GatewayIntentBits.GuildPresences // Required for staff presence detection; enable in Discord Developer Portal
|
|
||||||
],
|
],
|
||||||
partials: [Partials.Channel]
|
partials: [Partials.Channel]
|
||||||
});
|
});
|
||||||
@@ -208,8 +207,12 @@ client.once('ready', async () => {
|
|||||||
|
|
||||||
registerCommands().catch(console.error);
|
registerCommands().catch(console.error);
|
||||||
|
|
||||||
gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS));
|
if (CONFIG.GMAIL_POLL_ENABLED) {
|
||||||
poll(client);
|
gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS));
|
||||||
|
poll(client);
|
||||||
|
} else {
|
||||||
|
console.log('Gmail poll disabled by config (GMAIL_POLL_ENABLED=false) — inbox will not be polled. Enable with /email on.');
|
||||||
|
}
|
||||||
|
|
||||||
if (CONFIG.AUTO_CLOSE_ENABLED) {
|
if (CONFIG.AUTO_CLOSE_ENABLED) {
|
||||||
trackInterval(setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000));
|
trackInterval(setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000));
|
||||||
@@ -238,15 +241,6 @@ client.once('ready', async () => {
|
|||||||
client.login(CONFIG.DISCORD_TOKEN);
|
client.login(CONFIG.DISCORD_TOKEN);
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
|
||||||
// Reject API traffic with 503 until ready event has fired and routes are mounted.
|
|
||||||
// (appReady is declared at module top so the ready callback can flip it.)
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
if (!appReady && req.path.startsWith('/api')) {
|
|
||||||
return res.status(503).json({ error: 'Bot is starting; API not ready yet.' });
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.get('/', (req, res) => res.send(appReady ? 'Active' : 'Starting'));
|
app.get('/', (req, res) => res.send(appReady ? 'Active' : 'Starting'));
|
||||||
// app.listen is called inside client.once('ready') after MongoDB connects and routes mount.
|
// app.listen is called inside client.once('ready') after MongoDB connects and routes mount.
|
||||||
|
|
||||||
|
|||||||
@@ -368,6 +368,41 @@ async function registerCommands() {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('email')
|
||||||
|
.setDescription('Turn the inbound email flow (Gmail polling) on or off, or check its status')
|
||||||
|
.setContexts([InteractionContextType.Guild])
|
||||||
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName('on').setDescription('Start polling the inbox and creating tickets from email')
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName('off').setDescription('Stop polling the inbox (outbound emails still send)')
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub.setName('status').setDescription('Show whether the inbound email flow is on or off')
|
||||||
|
),
|
||||||
|
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('folder')
|
||||||
|
.setDescription("Move this ticket's email thread into a Gmail folder")
|
||||||
|
.setContexts([InteractionContextType.Guild])
|
||||||
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||||
|
.addStringOption(opt =>
|
||||||
|
opt
|
||||||
|
.setName('destination')
|
||||||
|
.setDescription('Target folder')
|
||||||
|
.setRequired(true)
|
||||||
|
.addChoices(
|
||||||
|
{ name: 'For Jake', value: 'FOR_JAKE' },
|
||||||
|
{ name: 'Spam', value: 'SPAM' },
|
||||||
|
{ name: 'Dashboard Errors', value: 'DASHBOARD_ERRORS' },
|
||||||
|
{ name: 'Partnership Offers', value: 'PARTNERSHIP_OFFERS' }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('cancel-close')
|
.setName('cancel-close')
|
||||||
.setDescription('Cancel a pending force-close countdown')
|
.setDescription('Cancel a pending force-close countdown')
|
||||||
|
|||||||
23
config.js
23
config.js
@@ -17,7 +17,6 @@ const CONFIG = {
|
|||||||
TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets',
|
TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets',
|
||||||
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
|
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
|
||||||
ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING,
|
ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING,
|
||||||
ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
|
|
||||||
TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID,
|
TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID,
|
||||||
LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID,
|
LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID,
|
||||||
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
|
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
|
||||||
@@ -30,7 +29,6 @@ const CONFIG = {
|
|||||||
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
|
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
|
||||||
PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000),
|
PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000),
|
||||||
HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only
|
HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only
|
||||||
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'),
|
|
||||||
GAME_LIST: process.env.GAME_LIST || '',
|
GAME_LIST: process.env.GAME_LIST || '',
|
||||||
// Tier 2/3 escalation: category IDs where ticket channels are placed (env uses *_CHANNEL_* for legacy naming).
|
// Tier 2/3 escalation: category IDs where ticket channels are placed (env uses *_CHANNEL_* for legacy naming).
|
||||||
EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null,
|
EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null,
|
||||||
@@ -38,9 +36,11 @@ const CONFIG = {
|
|||||||
EMAIL_ESCALATED3_CHANNEL_ID: process.env.EMAIL_ESCALATED3_CHANNEL_ID || null,
|
EMAIL_ESCALATED3_CHANNEL_ID: process.env.EMAIL_ESCALATED3_CHANNEL_ID || null,
|
||||||
DISCORD_ESCALATED3_CHANNEL_ID: process.env.DISCORD_ESCALATED3_CHANNEL_ID || null,
|
DISCORD_ESCALATED3_CHANNEL_ID: process.env.DISCORD_ESCALATED3_CHANNEL_ID || null,
|
||||||
ESCALATION_MESSAGE: process.env.ESCALATION_MESSAGE || 'Your ticket has been escalated.\n\nA senior {support_name} will be here to assist as soon as possible.',
|
ESCALATION_MESSAGE: process.env.ESCALATION_MESSAGE || 'Your ticket has been escalated.\n\nA senior {support_name} will be here to assist as soon as possible.',
|
||||||
|
// Email tickets only (escalation notification email body). Placeholders: {escalator_name}, {tier}.
|
||||||
|
TICKET_ESCALATION_EMAIL_MESSAGE: process.env.TICKET_ESCALATION_EMAIL_MESSAGE || '{escalator_name} escalated this ticket to {tier}.',
|
||||||
TICKET_CLOSE_SUBJECT_PREFIX: process.env.TICKET_CLOSE_SUBJECT_PREFIX || '[Resolved]',
|
TICKET_CLOSE_SUBJECT_PREFIX: process.env.TICKET_CLOSE_SUBJECT_PREFIX || '[Resolved]',
|
||||||
// Email tickets only (closure email body):
|
// Email tickets only (closure email body):
|
||||||
TICKET_CLOSE_MESSAGE: process.env.TICKET_CLOSE_MESSAGE || 'This ticket has been marked as resolved. If you would like to re-open this issue, please reply to this email.',
|
TICKET_CLOSE_MESSAGE: process.env.TICKET_CLOSE_MESSAGE || '{closer_name} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.',
|
||||||
TICKET_CLOSE_SIGNATURE: process.env.TICKET_CLOSE_SIGNATURE || 'Thank you for using Indifferent Broccoli.',
|
TICKET_CLOSE_SIGNATURE: process.env.TICKET_CLOSE_SIGNATURE || 'Thank you for using Indifferent Broccoli.',
|
||||||
// Discord ticket closure (in-channel and transcript):
|
// Discord ticket closure (in-channel and transcript):
|
||||||
DISCORD_CLOSE_MESSAGE: process.env.DISCORD_CLOSE_MESSAGE || 'This ticket has been closed. A transcript has been saved. If you still need assistance, please open a new ticket.',
|
DISCORD_CLOSE_MESSAGE: process.env.DISCORD_CLOSE_MESSAGE || 'This ticket has been closed. A transcript has been saved. If you still need assistance, please open a new ticket.',
|
||||||
@@ -56,9 +56,6 @@ const CONFIG = {
|
|||||||
TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.",
|
TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.",
|
||||||
TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀',
|
TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀',
|
||||||
TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️',
|
TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️',
|
||||||
REMINDER_ENABLED: process.env.REMINDER_ENABLED === 'true',
|
|
||||||
REMINDER_AFTER_HOURS: toInt(process.env.REMINDER_AFTER_HOURS, 24),
|
|
||||||
REMINDER_MESSAGE: process.env.REMINDER_MESSAGE || 'Hey {ping}! This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.',
|
|
||||||
PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true',
|
PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true',
|
||||||
DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal',
|
DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal',
|
||||||
PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴',
|
PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴',
|
||||||
@@ -80,7 +77,16 @@ const CONFIG = {
|
|||||||
ADMIN_ID: process.env.ADMIN_ID || null,
|
ADMIN_ID: process.env.ADMIN_ID || null,
|
||||||
FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60),
|
FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60),
|
||||||
GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000,
|
GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000,
|
||||||
RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null,
|
// Inbound email flow master switch. Absent/anything-but-"false" => on, so
|
||||||
|
// existing deployments keep polling with no .env change. Toggle via /email.
|
||||||
|
GMAIL_POLL_ENABLED: process.env.GMAIL_POLL_ENABLED !== 'false',
|
||||||
|
// Gmail "folder" (label) names for ticket-lifecycle routing — see services/gmailLabels.js.
|
||||||
|
GMAIL_LABEL_TRIAGE: process.env.GMAIL_LABEL_TRIAGE || 'Triage',
|
||||||
|
GMAIL_LABEL_ESCALATED: process.env.GMAIL_LABEL_ESCALATED || 'Escalated',
|
||||||
|
GMAIL_LABEL_RESOLVED: process.env.GMAIL_LABEL_RESOLVED || 'Resolved',
|
||||||
|
GMAIL_LABEL_FOR_JAKE: process.env.GMAIL_LABEL_FOR_JAKE || 'For Jake',
|
||||||
|
GMAIL_LABEL_DASHBOARD_ERRORS: process.env.GMAIL_LABEL_DASHBOARD_ERRORS || 'Dashboard Errors',
|
||||||
|
GMAIL_LABEL_PARTNERSHIP_OFFERS: process.env.GMAIL_LABEL_PARTNERSHIP_OFFERS || 'Partnership Offers',
|
||||||
STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true',
|
STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true',
|
||||||
STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion',
|
STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion',
|
||||||
STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true',
|
STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true',
|
||||||
@@ -89,9 +95,6 @@ const CONFIG = {
|
|||||||
PIN_ESCALATION_MESSAGE_ENABLED: process.env.PIN_ESCALATION_MESSAGE_ENABLED === 'true',
|
PIN_ESCALATION_MESSAGE_ENABLED: process.env.PIN_ESCALATION_MESSAGE_ENABLED === 'true',
|
||||||
TRANSCRIPT_DM_TO_CREATOR: process.env.TRANSCRIPT_DM_TO_CREATOR === 'true',
|
TRANSCRIPT_DM_TO_CREATOR: process.env.TRANSCRIPT_DM_TO_CREATOR === 'true',
|
||||||
PIN_SUPPRESS_SYSTEM_MESSAGE: process.env.PIN_SUPPRESS_SYSTEM_MESSAGE === 'true',
|
PIN_SUPPRESS_SYSTEM_MESSAGE: process.env.PIN_SUPPRESS_SYSTEM_MESSAGE === 'true',
|
||||||
SETTINGS_PORT: toInt(process.env.SETTINGS_PORT, 12752),
|
|
||||||
SETTINGS_ADMIN_PASSWORD: process.env.SETTINGS_ADMIN_PASSWORD || null,
|
|
||||||
SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com',
|
|
||||||
INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753),
|
INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753),
|
||||||
INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null
|
INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null
|
||||||
};
|
};
|
||||||
|
|||||||
124
docs/superpowers/specs/2026-06-03-email-flow-toggle-design.md
Normal file
124
docs/superpowers/specs/2026-06-03-email-flow-toggle-design.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Email Flow Toggle — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-03
|
||||||
|
**Status:** Approved (design); pending implementation plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A staff slash command to turn the **inbound** email flow on and off at runtime, with the state surviving container restarts.
|
||||||
|
|
||||||
|
- **ON** — Gmail polling runs as today: reads the inbox, creates/append ticket channels, runs all downstream features.
|
||||||
|
- **OFF** — Polling stops entirely. The mailbox is **never read** (inbox untouched). No new tickets are created from email.
|
||||||
|
- **Outbound is unaffected** in both states — ticket-close emails, Gmail replies, and notification emails still send when staff act on existing tickets. (Decision: "off" scopes to inbound polling only.)
|
||||||
|
- **Persists across restarts** — the off-state is honored on the next boot. (Decision: persisted, not runtime-only.)
|
||||||
|
|
||||||
|
Discord-originated tickets are independent of email polling and are unaffected by this toggle.
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|----------|--------|
|
||||||
|
| Scope of OFF | Inbound polling only; outbound email still sends |
|
||||||
|
| Persistence | Persist to `.env` via existing config-persistence path; honored on boot |
|
||||||
|
| Command shape | New dedicated `/email on\|off\|status` command (Approach A) |
|
||||||
|
| Existing `/gmailpoll` | Guarded so it cannot silently re-enable polling while flow is OFF |
|
||||||
|
|
||||||
|
Rejected: folding into `/gmailpoll` subcommands (needless churn to a working command); sentinel interval `/gmailpoll 0` (poor discoverability).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Single source of truth: a new boolean config flag `GMAIL_POLL_ENABLED` (default **true**). The live poll timer (`gmailPollInterval` in `broccolini-discord.js`) is started/stopped to match the flag.
|
||||||
|
|
||||||
|
### 1. Config flag — `GMAIL_POLL_ENABLED`
|
||||||
|
|
||||||
|
- **`config.js`** — add:
|
||||||
|
```js
|
||||||
|
GMAIL_POLL_ENABLED: process.env.GMAIL_POLL_ENABLED !== 'false',
|
||||||
|
```
|
||||||
|
Undefined/absent → `true`, so existing deployments keep polling with no `.env` change required.
|
||||||
|
- **`services/configSchema.js`** — add `'GMAIL_POLL_ENABLED'` to `ALLOWED_CONFIG_KEYS`. The existing `/ENABLED$/` rule in `inferType()` already classifies it as a boolean validator, so the settings site can also toggle it (bonus, no extra work).
|
||||||
|
|
||||||
|
### 2. Boot gate
|
||||||
|
|
||||||
|
In `broccolini-discord.js` (`client.once('ready')`, currently ~lines 210-211), only start the poll when enabled:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (CONFIG.GMAIL_POLL_ENABLED) {
|
||||||
|
gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS));
|
||||||
|
poll(client);
|
||||||
|
} else {
|
||||||
|
console.log('Gmail poll disabled by config (GMAIL_POLL_ENABLED=false)');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When disabled, `gmailPollInterval` stays `null` — no timer is registered in `activeIntervals`, nothing reads the inbox.
|
||||||
|
|
||||||
|
### 3. `/email` command
|
||||||
|
|
||||||
|
- **Registration** — `commands/register.js`: a `SlashCommandBuilder` named `email` with three subcommands (`on`, `off`, `status`), `setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)` to match sibling commands.
|
||||||
|
- **Dispatch** — add `email: handleEmail` to `COMMAND_HANDLERS` in `handlers/commands/index.js`. Staff-gated automatically via `requireStaffRole()` at the dispatcher entry.
|
||||||
|
- **Handler** — `handleEmail(interaction)`:
|
||||||
|
- `on`:
|
||||||
|
1. `applyConfigUpdates({ GMAIL_POLL_ENABLED: true })` (updates runtime `CONFIG` **and** writes `.env`).
|
||||||
|
2. Clear the auth-suspend latch via `require('../../gmail-poll').setPollSuspended(false)` so a prior `invalid_grant` suspend doesn't keep polling dead. If auth is still broken, the next cycle re-suspends and DMs admin, exactly as today.
|
||||||
|
3. `setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS)` to start the live timer.
|
||||||
|
4. Reply (ephemeral): "Email flow is now **on**."
|
||||||
|
- `off`:
|
||||||
|
1. `applyConfigUpdates({ GMAIL_POLL_ENABLED: false })`.
|
||||||
|
2. `clearGmailPollInterval()`.
|
||||||
|
3. Reply (ephemeral): "Email flow is now **off** — the inbox will not be polled. Outbound emails still send."
|
||||||
|
- `status`:
|
||||||
|
- Report `CONFIG.GMAIL_POLL_ENABLED`, the current interval (`CONFIG.GMAIL_POLL_INTERVAL_MS / 1000`s), and whether polling is currently suspended by an auth error.
|
||||||
|
- On `on`/`off`, fire `logTicketEvent('Email flow toggled', [...], interaction).catch(() => {})` — fire-and-forget, matching `/gmailpoll`.
|
||||||
|
|
||||||
|
`applyConfigUpdates` is called in-process (the command runs inside the bot), reusing the same path the internal API uses — no HTTP round-trip.
|
||||||
|
|
||||||
|
### 4. Guard `/gmailpoll` against silent re-enable
|
||||||
|
|
||||||
|
`handleGmailPoll` currently calls `setGmailPollInterval(ms)`, which *starts* the timer. While flow is OFF that would silently re-enable polling. Change it so that when `CONFIG.GMAIL_POLL_ENABLED` is false:
|
||||||
|
- update the interval in memory only (`CONFIG.GMAIL_POLL_INTERVAL_MS = ms`) — matching `/gmailpoll`'s existing runtime-only model (it has never persisted to `.env`), but
|
||||||
|
- do **not** start the live timer, and
|
||||||
|
- reply: "Interval saved (`<n>`s), but the inbound email flow is currently **off** — it will apply when you run `/email on`."
|
||||||
|
|
||||||
|
When flow is ON, `/gmailpoll` behaves exactly as today.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
/email off ──> applyConfigUpdates({GMAIL_POLL_ENABLED:false}) ──> CONFIG updated + .env written
|
||||||
|
└─> clearGmailPollInterval() ──> live timer stopped, gmailPollInterval=null
|
||||||
|
(no inbox reads)
|
||||||
|
|
||||||
|
restart ──> config.js reads GMAIL_POLL_ENABLED=false ──> ready gate skips poll start ──> stays off
|
||||||
|
|
||||||
|
/email on ──> applyConfigUpdates({GMAIL_POLL_ENABLED:true}) ──> CONFIG updated + .env written
|
||||||
|
├─> setPollSuspended(false) ──> clear prior auth-suspend latch
|
||||||
|
└─> setGmailPollInterval(interval) ──> live timer started, immediate poll
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Command runs through `runHandler`, so any throw is logged and the user gets an ephemeral "Something went wrong."
|
||||||
|
- `applyConfigUpdates` returns `{ applied, errors }`; if `GMAIL_POLL_ENABLED` lands in `errors` (should not, given the boolean validator), reply with the error rather than claiming success, and do not flip the live timer.
|
||||||
|
- Logging stays fire-and-forget (`.catch(() => {})`), per Hard Rule 4.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `config.js` | Add `GMAIL_POLL_ENABLED` (default true) |
|
||||||
|
| `services/configSchema.js` | Add `'GMAIL_POLL_ENABLED'` to `ALLOWED_CONFIG_KEYS` |
|
||||||
|
| `broccolini-discord.js` | Gate poll start in `ready` on the flag |
|
||||||
|
| `commands/register.js` | Register `/email on\|off\|status` |
|
||||||
|
| `handlers/commands/index.js` | Add `handleEmail`; guard `handleGmailPoll` |
|
||||||
|
| `.env.example` | Document `GMAIL_POLL_ENABLED` (optional, default true) |
|
||||||
|
|
||||||
|
No DB schema changes. No destructive data ops. The mailbox is never read while OFF and is never written by this feature.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `/email off` → logs show no further poll cycles; `.env` contains `GMAIL_POLL_ENABLED=false`.
|
||||||
|
- Restart container → no polling on boot; `/email status` reports off.
|
||||||
|
- `/email on` → poll resumes (immediate cycle), `.env` flips to `true`.
|
||||||
|
- While OFF, `/gmailpoll 60` → interval saved, no polling starts.
|
||||||
|
- `npm test` (covers `services/configSchema.js`); `node --check` on every edited file.
|
||||||
217
docs/superpowers/specs/2026-06-03-gmail-folder-routing-design.md
Normal file
217
docs/superpowers/specs/2026-06-03-gmail-folder-routing-design.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# Gmail Folder Routing — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-03
|
||||||
|
**Status:** Approved (design); pending implementation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Route a ticket's Gmail thread into Gmail "folders" (labels) as the ticket moves
|
||||||
|
through its lifecycle, plus a manual `/folder` command for ad-hoc filing.
|
||||||
|
|
||||||
|
- On **ticket creation**, the source email thread goes into a **Triage** folder
|
||||||
|
(instead of the current plain archive).
|
||||||
|
- On **escalation**, the thread moves to an **Escalated** folder.
|
||||||
|
- On **resolution** (close), the thread moves to a **Resolved** folder.
|
||||||
|
- A **`/folder`** slash command lets staff move the current ticket's thread to one
|
||||||
|
of four manual folders: **For Jake**, **Spam**, **Dashboard Errors**,
|
||||||
|
**Partnership Offers**.
|
||||||
|
|
||||||
|
Discord-originated tickets (`gmailThreadId` prefixed `discord-`) have no Gmail
|
||||||
|
thread and are untouched by all of the above.
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|----------|--------|
|
||||||
|
| Folder semantics | **Exclusive** — moving to a folder removes every other managed label and drops the thread out of the Inbox. A thread lives in exactly one managed folder. |
|
||||||
|
| "Spam" target | Gmail's **built-in system `SPAM`** label (trains the filter, hides from normal views). |
|
||||||
|
| Label names | **Configurable via `.env`**, defaulting to the names above. |
|
||||||
|
| Missing labels | **Auto-created** on first use (idempotent, cached). The system `SPAM` label is never created. |
|
||||||
|
| `/folder` options | Exactly the **4 manual folders**. Triage/Escalated/Resolved are lifecycle-driven only, not manually selectable. |
|
||||||
|
| De-escalation | **Leaves the folder as Escalated** — no auto-move back. |
|
||||||
|
|
||||||
|
Gmail labels are additive by nature; "exclusive folder" behavior is synthesized by
|
||||||
|
always removing the other managed labels on every move (removing an absent label is
|
||||||
|
a no-op, so this is safe and idempotent).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. New module — `services/gmailLabels.js`
|
||||||
|
|
||||||
|
Single home for all label logic. Folders defined by logical key:
|
||||||
|
|
||||||
|
| Key | Source | Default name |
|
||||||
|
|-----|--------|--------------|
|
||||||
|
| `TRIAGE` | `CONFIG.GMAIL_LABEL_TRIAGE` (`.env GMAIL_LABEL_TRIAGE`) | `Triage` |
|
||||||
|
| `ESCALATED` | `CONFIG.GMAIL_LABEL_ESCALATED` | `Escalated` |
|
||||||
|
| `RESOLVED` | `CONFIG.GMAIL_LABEL_RESOLVED` | `Resolved` |
|
||||||
|
| `FOR_JAKE` | `CONFIG.GMAIL_LABEL_FOR_JAKE` | `For Jake` |
|
||||||
|
| `DASHBOARD_ERRORS` | `CONFIG.GMAIL_LABEL_DASHBOARD_ERRORS` | `Dashboard Errors` |
|
||||||
|
| `PARTNERSHIP_OFFERS` | `CONFIG.GMAIL_LABEL_PARTNERSHIP_OFFERS` | `Partnership Offers` |
|
||||||
|
| `SPAM` | built-in system label `SPAM` | (not configurable) |
|
||||||
|
|
||||||
|
`MANAGED_USER_KEYS` = all keys except `SPAM` (these are the user labels whose IDs
|
||||||
|
get resolved/created and which participate in the remove-others set).
|
||||||
|
|
||||||
|
**Exports:**
|
||||||
|
|
||||||
|
- `moveThreadToFolder(threadId, folderKey, gmail = getGmailClient())` — the one
|
||||||
|
operation everything calls.
|
||||||
|
1. Resolve the target label ID (`resolveLabelId`), and the IDs of all managed
|
||||||
|
user labels (to build the remove set).
|
||||||
|
2. `addLabelIds = [targetId]`.
|
||||||
|
3. `removeLabelIds = [all managed user-label IDs except target] + ['INBOX', 'UNREAD']`
|
||||||
|
(computed by `computeLabelMutation`). For `SPAM` target, the user labels are
|
||||||
|
all removed and `SPAM` is added; `INBOX`/`UNREAD` removed as usual.
|
||||||
|
4. `await gmail.users.threads.modify({ userId: 'me', id: threadId, requestBody: { addLabelIds, removeLabelIds } })`.
|
||||||
|
- On a `400` "invalid label" (stale cached ID for a label deleted in Gmail),
|
||||||
|
clear the cache and retry once.
|
||||||
|
|
||||||
|
- `resolveLabelId(gmail, key)` — returns the Gmail label ID for a key.
|
||||||
|
- `SPAM` short-circuits to `'SPAM'`.
|
||||||
|
- Otherwise: check the module-scoped name→ID cache; on miss, `users.labels.list`
|
||||||
|
and match by name (case-sensitive, Gmail's behavior); if still absent,
|
||||||
|
`users.labels.create` it (`labelListVisibility: 'labelShow'`,
|
||||||
|
`messageListVisibility: 'show'`) and cache the new ID.
|
||||||
|
|
||||||
|
- `computeLabelMutation(targetKey, idByKey)` — **pure** function returning
|
||||||
|
`{ addLabelIds, removeLabelIds }`. Split out for unit testing without the network.
|
||||||
|
|
||||||
|
**Caching:** module-scoped `Map` of label-name → ID, populated lazily. Cleared and
|
||||||
|
re-fetched on a stale-label error.
|
||||||
|
|
||||||
|
**Client:** `getGmailClient` is required from `services/gmail.js` (acyclic —
|
||||||
|
`gmail.js` does not depend on `gmailLabels.js`). Callers that already hold a client
|
||||||
|
(the poll loop) pass it in; others let the default create one.
|
||||||
|
|
||||||
|
### 2. Triage on ticket creation — `gmail-poll.js`
|
||||||
|
|
||||||
|
Today every processed message hits `markGmailMessageRead` (strips `INBOX`+`UNREAD`)
|
||||||
|
at the shared bottom of the per-message loop (~line 397). Restructure so the
|
||||||
|
archive action is branch-specific:
|
||||||
|
|
||||||
|
- **New ticket created** (and the **reopened** closed→open case, which runs in the
|
||||||
|
create branch) → `await moveThreadToFolder(parsed.threadId, 'TRIAGE', gmail)`.
|
||||||
|
- **Follow-up to an existing open ticket** (the `if (ticketChan)` append branch) →
|
||||||
|
keep `markGmailMessageRead(gmail, msgRef)`. A reply on a thread already filed
|
||||||
|
under "For Jake"/"Resolved" should not be dragged back to Triage automatically.
|
||||||
|
- **Self / limit-exceeded / create-failure** early-`continue` paths → unchanged
|
||||||
|
plain archive (they already call `markGmailMessageRead` before `continue`).
|
||||||
|
|
||||||
|
The shared bottom `markGmailMessageRead` call is removed; the two surviving paths
|
||||||
|
(append, create) each archive/move explicitly.
|
||||||
|
|
||||||
|
`moveThreadToFolder` on creation is awaited inside the existing try/catch; a failure
|
||||||
|
is logged via the poll's existing error handling and does not abort the loop.
|
||||||
|
|
||||||
|
### 3. Escalated hook — `handlers/commands/escalation.js`
|
||||||
|
|
||||||
|
`runEscalation` is shared by the `/escalate` slash command and the tier-pick
|
||||||
|
buttons (single hook site). Inside the existing
|
||||||
|
`if (!isDiscordTicket && ticket.gmailThreadId)` block (where the escalation
|
||||||
|
notification email is already sent), add:
|
||||||
|
|
||||||
|
```js
|
||||||
|
moveThreadToFolder(ticket.gmailThreadId, 'ESCALATED')
|
||||||
|
.catch(err => logError('gmailLabels: escalate move', err).catch(() => {}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-fatal — a label failure never blocks the escalation. De-escalation
|
||||||
|
(`runDeescalation`) is **not** modified.
|
||||||
|
|
||||||
|
### 4. Resolved hook — two close finalizers
|
||||||
|
|
||||||
|
Both finalizers set `status: 'closed'` and remain separate:
|
||||||
|
- `handlers/commands/close.js` → `finalizeForceClose`
|
||||||
|
- `handlers/buttons.js` → `runFinalClose`
|
||||||
|
|
||||||
|
In each, for non-Discord tickets (`!ticket.gmailThreadId.startsWith('discord-')`),
|
||||||
|
after the status update, add a non-fatal:
|
||||||
|
|
||||||
|
```js
|
||||||
|
moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED')
|
||||||
|
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
|
||||||
|
```
|
||||||
|
|
||||||
|
One added line per finalizer. The move runs regardless of whether a close email is
|
||||||
|
sent (so close-without-email still files the thread under Resolved).
|
||||||
|
|
||||||
|
### 5. `/folder` command
|
||||||
|
|
||||||
|
- **Registration** (`commands/register.js`): `SlashCommandBuilder` named `folder`,
|
||||||
|
`setDefaultMemberPermissions(ManageMessages)`, Guild context / GuildInstall, with
|
||||||
|
a required string option `destination` and choices:
|
||||||
|
- `For Jake` → `FOR_JAKE`
|
||||||
|
- `Spam` → `SPAM`
|
||||||
|
- `Dashboard Errors` → `DASHBOARD_ERRORS`
|
||||||
|
- `Partnership Offers` → `PARTNERSHIP_OFFERS`
|
||||||
|
- **Dispatch** (`handlers/commands/index.js`): add `folder: handleFolder` to
|
||||||
|
`COMMAND_HANDLERS`; add a `/folder` line to `/help`.
|
||||||
|
- **Handler** `handleFolder(interaction)`:
|
||||||
|
1. `findTicketForChannel(interaction)`; bail if none.
|
||||||
|
2. If `ticket.gmailThreadId.startsWith('discord-')` → ephemeral
|
||||||
|
"This ticket has no email thread, so it can't be moved to a Gmail folder."
|
||||||
|
3. Otherwise `await moveThreadToFolder(ticket.gmailThreadId, folderKey)`.
|
||||||
|
4. Ephemeral reply: "Moved this ticket's email thread to **<label>**."
|
||||||
|
5. `logTicketEvent('Email thread filed', [...], interaction).catch(() => {})`.
|
||||||
|
6. On error, ephemeral "Failed to move the email thread: <reason>."
|
||||||
|
|
||||||
|
### 6. Config & docs
|
||||||
|
|
||||||
|
- `config.js`: add the six `GMAIL_LABEL_*` keys with the default names above.
|
||||||
|
- `.env.example`: document the six vars (default-on naming).
|
||||||
|
- Not added to `ALLOWED_CONFIG_KEYS` — settings-site contract unchanged.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
inbound email (poll, flow ON)
|
||||||
|
└─ new ticket ──> moveThreadToFolder(thread, TRIAGE) [add Triage; remove others+INBOX+UNREAD]
|
||||||
|
└─ follow-up ──> markGmailMessageRead(msg) [remove INBOX+UNREAD on the new msg only]
|
||||||
|
|
||||||
|
/escalate or tier button ──> runEscalation ──> moveThreadToFolder(thread, ESCALATED)
|
||||||
|
close (slash or button) ──> finalize ──> moveThreadToFolder(thread, RESOLVED)
|
||||||
|
/folder <dest> ──> handleFolder ──> moveThreadToFolder(thread, <dest|SPAM>)
|
||||||
|
```
|
||||||
|
|
||||||
|
Every `moveThreadToFolder` resolves IDs (creating missing user labels), then one
|
||||||
|
`threads.modify` enforcing exclusive-folder semantics.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Lifecycle hooks (Triage/Escalated/Resolved) are non-fatal `.catch` — Gmail
|
||||||
|
problems never block ticket flow. Errors logged via `logError`.
|
||||||
|
- `/folder` surfaces failures to the invoking staffer ephemerally.
|
||||||
|
- Stale cached label ID → one cache-clear + retry inside `moveThreadToFolder`.
|
||||||
|
- Label operations are independent of `CONFIG.GMAIL_POLL_ENABLED` (the `/email`
|
||||||
|
toggle): they are explicit staff/lifecycle actions, not polling. Triage-on-create
|
||||||
|
only fires during polling, so it is naturally inert while the flow is off.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `services/gmailLabels.js` | **New** — folder defs, `moveThreadToFolder`, `resolveLabelId`, `computeLabelMutation`, cache |
|
||||||
|
| `tests/gmailLabels.test.js` | **New** — unit tests for mutation logic + label resolution |
|
||||||
|
| `config.js` | Add six `GMAIL_LABEL_*` config keys (defaults) |
|
||||||
|
| `.env.example` | Document the six label-name vars |
|
||||||
|
| `gmail-poll.js` | Triage on create/reopen; keep plain archive for follow-ups & non-ticket paths |
|
||||||
|
| `handlers/commands/escalation.js` | `runEscalation`: move thread to Escalated (non-fatal) |
|
||||||
|
| `handlers/commands/close.js` | `finalizeForceClose`: move thread to Resolved (non-fatal) |
|
||||||
|
| `handlers/buttons.js` | `runFinalClose`: move thread to Resolved (non-fatal) |
|
||||||
|
| `commands/register.js` | Register `/folder` with 4 choices |
|
||||||
|
| `handlers/commands/index.js` | `handleFolder` + dispatch entry + `/help` line |
|
||||||
|
|
||||||
|
No DB schema changes. No destructive data ops — `threads.modify` only relabels;
|
||||||
|
nothing is deleted or trashed. (Moving to `SPAM` is reversible from Gmail.)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npm test` — existing suite plus new `tests/gmailLabels.test.js`
|
||||||
|
(`computeLabelMutation` exclusivity; `resolveLabelId` cache-hit / create-on-miss /
|
||||||
|
SPAM short-circuit, with a fake gmail client).
|
||||||
|
- `node --check` on every edited file.
|
||||||
|
- Manual (post-deploy): create an email ticket → its thread lands in Triage and
|
||||||
|
leaves the inbox; `/escalate` → Escalated; `/folder For Jake` → For Jake (and out
|
||||||
|
of Escalated); close → Resolved. Discord ticket → `/folder` reports no email
|
||||||
|
thread.
|
||||||
176
docs/superpowers/specs/2026-06-04-per-staff-metrics-design.md
Normal file
176
docs/superpowers/specs/2026-06-04-per-staff-metrics-design.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Per-Staff Metrics & Ticket Analytics — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Approved (design); implementation pending
|
||||||
|
**Component:** broccolini-bot
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Capture per-staff and per-ticket activity as a durable, event-sourced log, and
|
||||||
|
expose a useful subset via a `/stats` command. The event log is the foundation
|
||||||
|
for a future analytics dashboard on the tickets website (graphs, timing
|
||||||
|
analyses, busiest-time heatmaps, per-game and per-emailer reporting).
|
||||||
|
**Principle: collect rich data now** (history cannot be backfilled); `/stats` v1
|
||||||
|
surfaces the count metrics; timing/temporal metrics are collected but displayed
|
||||||
|
on the website later.
|
||||||
|
|
||||||
|
Because it is event-sourced, interpretation questions (does a reopen revoke
|
||||||
|
resolution credit? does time-open reset?) do **not** need a single baked-in
|
||||||
|
answer — events are recorded with timestamps and the query layer decides.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
### Counts (shown in `/stats` v1, per staff member)
|
||||||
|
- **Claims**; **claims while escalated, by tier** (`claim` events with `tier > 0`)
|
||||||
|
- **Closes** performed by the staff member
|
||||||
|
- **Resolved (credit)** — closes where the staff member was the **claimer**
|
||||||
|
- **Escalations** / **De-escalations**, by tier
|
||||||
|
- **Unclaimed-at-close** — closes where the ticket had no claimer
|
||||||
|
- **Transfers** (initiated / received); **Reopens** (rate)
|
||||||
|
- Every count sliceable **email vs discord** and by **priority**
|
||||||
|
|
||||||
|
### Ticket volume & temporal (derived from `Ticket` collection)
|
||||||
|
- Total tickets, **email vs discord**, over a window.
|
||||||
|
- **Busiest times** — distribution of `createdAt` by hour-of-day and day-of-week
|
||||||
|
(and `closedAt` similarly). Stored as full UTC datetimes; website buckets/TZ-adjusts.
|
||||||
|
|
||||||
|
### Timing (collected now; website-only display in v1)
|
||||||
|
Derived from event timestamps + `ticket.createdAt` / `closedAt`:
|
||||||
|
- time to first staff response; time to claim; time open (created → closed)
|
||||||
|
- time to escalation; time to first response & to claim after escalation
|
||||||
|
- time to close after escalation; total time open across reopen cycles
|
||||||
|
|
||||||
|
## Ticket source & tier
|
||||||
|
|
||||||
|
- **Source:** `discord` if `gmailThreadId` starts with `discord-`/`discord-msg-`, else `email`. Denormalized as `ticketType` on every event.
|
||||||
|
- **Requester:** email tickets are attributed to `senderEmail`; discord tickets to the creator's `creatorId`. Both denormalized onto events so per-emailer (email) and per-user (discord) reporting work, sliceable by source.
|
||||||
|
- **Tier** (existing convention): `0` normal, `1` → "Tier 2 Support", `2` → "Tier 3 Support". Events store raw numeric `tier`; `/stats` mirrors the labels.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### New model `StaffAction` (event log, in `models.js`)
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
staffId: String, // actor's Discord user ID ('system' for automated)
|
||||||
|
type: String, // 'claim'|'response'|'escalate'|'deescalate'|'close'|'reopen'|'transfer'
|
||||||
|
tier: Number, // ticket escalationTier at the moment (0 if none)
|
||||||
|
ticketType: String, // 'email' | 'discord'
|
||||||
|
priority: String, // ticket priority at the moment
|
||||||
|
game: String, // detected game (denormalized)
|
||||||
|
senderEmail: String, // requester for EMAIL tickets (denormalized)
|
||||||
|
creatorId: String, // requester for DISCORD tickets — creator's user ID (denormalized)
|
||||||
|
gmailThreadId: String, // ticket linkage / per-ticket timeline join key
|
||||||
|
guildId: String,
|
||||||
|
createdAt: Date, // default: Date.now (function reference)
|
||||||
|
|
||||||
|
// close-only:
|
||||||
|
closerType: String, // 'staff' | 'user' | 'system'
|
||||||
|
resolverId: String, // ticket.claimerId at close (credit); null if unclaimed
|
||||||
|
wasClaimed: Boolean, // false → unclaimed-at-close
|
||||||
|
|
||||||
|
// transfer-only:
|
||||||
|
fromId: String, // previous claimer
|
||||||
|
toId: String // new claimer
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For `close`, `staffId` = the closer, `resolverId` = the claimer credited.
|
||||||
|
For `transfer`, `staffId` = who ran `/transfer`.
|
||||||
|
For `reopen`, `staffId` = `'system'` (customer email reply re-opens the thread).
|
||||||
|
|
||||||
|
Indexes: `{ staffId: 1, createdAt: -1 }` and `{ gmailThreadId: 1, createdAt: 1 }`.
|
||||||
|
|
||||||
|
### `Ticket` schema changes
|
||||||
|
- `game: String` — set at creation from existing `detectGame(subject, rawBody)`.
|
||||||
|
- `closedAt: Date` — set when a ticket is closed (robust source for time-open /
|
||||||
|
busiest-close-times even if events are pruned).
|
||||||
|
|
||||||
|
## Recording
|
||||||
|
|
||||||
|
New `services/staffStats.js` → `recordAction(staffId, type, payload)`,
|
||||||
|
fire-and-forget (`.catch(() => {})`), never blocking the action. `ticketType`,
|
||||||
|
`priority`, `game`, `senderEmail` read from the ticket being acted on.
|
||||||
|
|
||||||
|
**Idempotency (correctness requirement):** record an event **only on a successful
|
||||||
|
state transition** — a claim that actually set the claimer, a close that actually
|
||||||
|
closed, an escalate that changed tier. No-op / rejected / double-click
|
||||||
|
interactions must not produce events.
|
||||||
|
|
||||||
|
| Event | Site | Notes |
|
||||||
|
|--------------|-------------------------------------------------------|-------|
|
||||||
|
| `claim` | `handlers/buttons.js` `handleClaimButton` | only if claim succeeds; `tier` = current |
|
||||||
|
| `response` | `handlers/messages.js` `handleDiscordReply` | only when author `isStaff`; email & discord; before the email-only early return |
|
||||||
|
| `escalate` | `handlers/commands/escalation.js` `runEscalation` | `tier` = new tier |
|
||||||
|
| `deescalate` | `handlers/commands/escalation.js` `runDeescalation` | `tier` = new tier |
|
||||||
|
| `transfer` | `handlers/commands/index.js` `handleTransfer` | `fromId` = old claimer, `toId` = target, actor = runner |
|
||||||
|
| `close` | `handlers/buttons.js` `runFinalClose` | sets `closerType`/`resolverId`/`wasClaimed`; also set `ticket.closedAt` |
|
||||||
|
| `close` | `handlers/commands/close.js` `finalizeForceClose` | capture closer ID via `pendingCloses` (store `interaction.user.id` at countdown start) |
|
||||||
|
| `reopen` | `gmail-poll.js` reopen path (`existing.status === 'closed'`) | `staffId='system'`, customer-driven |
|
||||||
|
|
||||||
|
Auto-close (`services/tickets.js`) and orphan-channel reconcile / auto-unclaim
|
||||||
|
closes → `close` with `closerType: 'system'`, `staffId: 'system'`, preserving
|
||||||
|
`resolverId`/`wasClaimed` so resolution credit and unclaimed-at-close still count
|
||||||
|
without attributing a human.
|
||||||
|
|
||||||
|
`response` volume: record each staff reply (low volume); "first response" =
|
||||||
|
`min(createdAt)`; "after escalation" = first `response` after the `escalate` event.
|
||||||
|
|
||||||
|
## `/stats` command
|
||||||
|
|
||||||
|
Registered in `commands/register.js`; handler in the commands layer.
|
||||||
|
|
||||||
|
- `period` — string, **autocomplete + free text**, default **30 days**. Suggestions:
|
||||||
|
`7 days`, `30 days`, `3 months`, `6 months`, `1 year`. Parses `<n>d/w/m/mo/y` and
|
||||||
|
bare number = days; unparseable → 30 days.
|
||||||
|
- `member` — optional user.
|
||||||
|
- `source` — optional choice: **all** (default) / **email** / **discord**. Filters
|
||||||
|
every metric by `ticketType`, so the same command shows combined stats or a
|
||||||
|
single channel's stats.
|
||||||
|
|
||||||
|
Gating: `setDefaultMemberPermissions(ManageMessages)` + `requireStaffRole`,
|
||||||
|
ephemeral. No `member` → caller's own; `member` set → only if caller ∈
|
||||||
|
`STATS_ADMIN_IDS`, else "You can only view your own stats."
|
||||||
|
|
||||||
|
The command shows the **full** count metric set (not a trimmed subset); `source`
|
||||||
|
toggles between combined and per-channel views. Aggregation: MongoDB pipeline over
|
||||||
|
`StaffAction` (+ `Ticket` for volume) filtered by actor, `createdAt >= now - window`,
|
||||||
|
and optional `ticketType`, grouped by `type`/`tier`/`ticketType`/`priority`.
|
||||||
|
|
||||||
|
### Display (embed, v1 — counts)
|
||||||
|
```
|
||||||
|
📊 Stats — @member · last 30 days (email 30 · discord 12)
|
||||||
|
Claimed: 42 (while escalated — Tier 2: 5 · Tier 3: 1)
|
||||||
|
Closed: 38 (unclaimed: 4)
|
||||||
|
Resolved (credited): 35
|
||||||
|
Escalated: Tier 2: 4 · Tier 3: 1
|
||||||
|
De-escalated: Tier 2: 1 · Tier 3: 0
|
||||||
|
Transfers: in 2 · out 3 Reopens (their resolved tickets): 1
|
||||||
|
```
|
||||||
|
Timing/busiest-times not shown in v1 — collected for the website.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
```
|
||||||
|
STATS_ADMIN_IDS=321754640431710226,691678135527276614,224692549225283584
|
||||||
|
```
|
||||||
|
(chicken, broccoli, ketchup) — comma-separated; users allowed to view others' stats.
|
||||||
|
|
||||||
|
## Non-goals (v1)
|
||||||
|
- Tag / Gmail-folder / canned-response usage tracking (dropped).
|
||||||
|
- Unclaim-action tracking (we track unclaimed-*at-close*).
|
||||||
|
- Multi-staff leaderboard (per-member view only).
|
||||||
|
- Website dashboard, graphs, timing/busiest-time displays, per-game/per-emailer
|
||||||
|
reports, internal-API stats endpoint. **Deferred** — data captured now.
|
||||||
|
- SLA / business-hours adjustment of timings (website computes from raw UTC).
|
||||||
|
- Event-log pruning (volume is low; revisit if needed).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Unit: period parser; aggregation shaping (counts by type/tier/ticketType/priority;
|
||||||
|
resolved-credit; unclaimed-at-close; transfer in/out; reopen).
|
||||||
|
- Unit: idempotency — no event on no-op claim/close.
|
||||||
|
- Manual: run claim/respond/escalate/deescalate/transfer/close/reopen across a test
|
||||||
|
email ticket and a test discord ticket; confirm `StaffAction` docs and fields,
|
||||||
|
`ticket.game`/`closedAt`; confirm per-ticket timeline yields timing deltas;
|
||||||
|
`/stats` counts + email/discord split; non-admin can't view others, admin can.
|
||||||
|
Deploy `docker compose up --build -d`; confirm bot logs ready.
|
||||||
|
```
|
||||||
@@ -21,6 +21,7 @@ const {
|
|||||||
sanitizeEmbedText
|
sanitizeEmbedText
|
||||||
} = require('./utils');
|
} = require('./utils');
|
||||||
const { getGmailClient } = require('./services/gmail');
|
const { getGmailClient } = require('./services/gmail');
|
||||||
|
const { moveThreadToFolder } = require('./services/gmailLabels');
|
||||||
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||||||
const { logError } = require('./services/debugLog');
|
const { logError } = require('./services/debugLog');
|
||||||
const { enqueueSend } = require('./services/channelQueue');
|
const { enqueueSend } = require('./services/channelQueue');
|
||||||
@@ -37,7 +38,6 @@ function setPollSuspended(val) {
|
|||||||
pollSuspended = !!val;
|
pollSuspended = !!val;
|
||||||
if (!pollSuspended) authErrorNotified = false;
|
if (!pollSuspended) authErrorNotified = false;
|
||||||
}
|
}
|
||||||
function isPollSuspended() { return pollSuspended; }
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Helpers (extracted from the original 309-line poll()).
|
// Helpers (extracted from the original 309-line poll()).
|
||||||
@@ -73,6 +73,16 @@ function locateGuild(client) {
|
|||||||
* - followupBody: defensive — strip quotes but fall back to raw text if
|
* - followupBody: defensive — strip quotes but fall back to raw text if
|
||||||
* stripping leaves nothing. Used for follow-up posts on an existing thread.
|
* stripping leaves nothing. Used for follow-up posts on an existing thread.
|
||||||
*/
|
*/
|
||||||
|
// Shared final cleanup for both the first-message and follow-up body paths:
|
||||||
|
// drop the "Get Outlook for ..." mobile-signature line, strip a dangling
|
||||||
|
// trailing "<" left by truncated HTML, and trim.
|
||||||
|
function finalizeBody(text) {
|
||||||
|
return text
|
||||||
|
.replace(/Get Outlook for [^\n]+/gi, '')
|
||||||
|
.replace(/<\s*$/gm, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function parseGmailMessage(email) {
|
function parseGmailMessage(email) {
|
||||||
const headers = email.data.payload.headers;
|
const headers = email.data.payload.headers;
|
||||||
const from = headers.find(h => h.name === 'From')?.value || '';
|
const from = headers.find(h => h.name === 'From')?.value || '';
|
||||||
@@ -92,10 +102,7 @@ function parseGmailMessage(email) {
|
|||||||
firstBody = stripMobileFooter(firstBody);
|
firstBody = stripMobileFooter(firstBody);
|
||||||
firstBody = firstBody.replace(/^\s*\n+/g, '');
|
firstBody = firstBody.replace(/^\s*\n+/g, '');
|
||||||
firstBody = firstBody.replace(/\n{3,}/g, '\n\n');
|
firstBody = firstBody.replace(/\n{3,}/g, '\n\n');
|
||||||
firstBody = firstBody
|
firstBody = finalizeBody(firstBody);
|
||||||
.replace(/Get Outlook for [^\n]+/gi, '')
|
|
||||||
.replace(/<\s*$/gm, '')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const rawText = rawBody.replace(/\r\n/g, '\n');
|
const rawText = rawBody.replace(/\r\n/g, '\n');
|
||||||
let followupBody = stripEmailQuotes(rawText);
|
let followupBody = stripEmailQuotes(rawText);
|
||||||
@@ -103,10 +110,7 @@ function parseGmailMessage(email) {
|
|||||||
followupBody = followupBody.replace(/^\s*\n*/, '\n');
|
followupBody = followupBody.replace(/^\s*\n*/, '\n');
|
||||||
followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
|
followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
|
||||||
followupBody = stripMobileFooter(followupBody);
|
followupBody = stripMobileFooter(followupBody);
|
||||||
followupBody = followupBody
|
followupBody = finalizeBody(followupBody);
|
||||||
.replace(/Get Outlook for [^\n]+/gi, '')
|
|
||||||
.replace(/<\s*$/gm, '')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isSelf,
|
isSelf,
|
||||||
@@ -150,22 +154,11 @@ async function findOrCreateTicketChannel(guild, parsed, number) {
|
|||||||
const channel = await guild.channels.create({
|
const channel = await guild.channels.create({
|
||||||
name: chanName,
|
name: chanName,
|
||||||
type: ChannelType.GuildText,
|
type: ChannelType.GuildText,
|
||||||
parent: parentCategoryId,
|
parent: parentCategoryId
|
||||||
// Email tickets have no Discord creator — the customer is reachable
|
// Permissions are inherited from the ticket category — configure that
|
||||||
// only by email. So the only per-channel allow is the staff role; we
|
// category to deny @everyone View Channel and allow the staff role, so
|
||||||
// still explicitly deny @everyone in case the category permissions
|
// tickets stay staff-only. Inheriting (rather than setting per-channel
|
||||||
// are ever misconfigured to grant View Channel server-wide.
|
// overwrites here) means the bot does not need the Manage Roles permission.
|
||||||
permissionOverwrites: [
|
|
||||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
|
||||||
...(CONFIG.ROLE_ID_TO_PING ? [{
|
|
||||||
id: CONFIG.ROLE_ID_TO_PING,
|
|
||||||
allow: [
|
|
||||||
PermissionFlagsBits.ViewChannel,
|
|
||||||
PermissionFlagsBits.SendMessages,
|
|
||||||
PermissionFlagsBits.ReadMessageHistory
|
|
||||||
]
|
|
||||||
}] : [])
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
return { channel, parentCategoryId };
|
return { channel, parentCategoryId };
|
||||||
} catch (createErr) {
|
} catch (createErr) {
|
||||||
@@ -274,7 +267,7 @@ async function poll(client) {
|
|||||||
const gmail = getGmailClient();
|
const gmail = getGmailClient();
|
||||||
const list = await gmail.users.messages.list({
|
const list = await gmail.users.messages.list({
|
||||||
userId: 'me',
|
userId: 'me',
|
||||||
q: 'is:unread category:primary'
|
q: 'is:unread in:inbox'
|
||||||
});
|
});
|
||||||
if (!list.data.messages) return;
|
if (!list.data.messages) return;
|
||||||
|
|
||||||
@@ -307,11 +300,15 @@ async function poll(client) {
|
|||||||
if (ticketChan) {
|
if (ticketChan) {
|
||||||
// Append follow-up to existing channel.
|
// Append follow-up to existing channel.
|
||||||
const truncatedFollowup = parsed.followupBody.slice(0, 1800);
|
const truncatedFollowup = parsed.followupBody.slice(0, 1800);
|
||||||
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
|
// No staff role ping; body is attacker-controlled email content — suppress all mentions.
|
||||||
await enqueueSend(ticketChan, {
|
await enqueueSend(ticketChan, {
|
||||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
|
content: `**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
|
||||||
allowedMentions: { parse: ['roles'] }
|
allowedMentions: { parse: [] }
|
||||||
});
|
});
|
||||||
|
// Follow-up on an existing thread: archive the new message only. Leave
|
||||||
|
// whatever managed folder staff filed this thread under untouched.
|
||||||
|
console.log('Archiving/reading Gmail message', msgRef.id);
|
||||||
|
await markGmailMessageRead(gmail, msgRef);
|
||||||
} else {
|
} else {
|
||||||
// Create a new ticket channel.
|
// Create a new ticket channel.
|
||||||
const limitCheck = await checkTicketLimits(parsed.senderEmail);
|
const limitCheck = await checkTicketLimits(parsed.senderEmail);
|
||||||
@@ -342,10 +339,9 @@ async function poll(client) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const welcomeMsg = await enqueueSend(ticketChan, {
|
const welcomeMsg = await enqueueSend(ticketChan, {
|
||||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
|
|
||||||
embeds: [ticketInfoEmbed],
|
embeds: [ticketInfoEmbed],
|
||||||
components: [buttons],
|
components: [buttons],
|
||||||
allowedMentions: { parse: ['roles'] }
|
allowedMentions: { parse: [] }
|
||||||
});
|
});
|
||||||
|
|
||||||
const { createStaffThread } = require('./services/staffThread');
|
const { createStaffThread } = require('./services/staffThread');
|
||||||
@@ -389,10 +385,13 @@ async function poll(client) {
|
|||||||
},
|
},
|
||||||
{ upsert: true, new: true }
|
{ upsert: true, new: true }
|
||||||
));
|
));
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Archiving/reading Gmail message', msgRef.id);
|
// New (or reopened) ticket: file the email thread into Triage — out of
|
||||||
await markGmailMessageRead(gmail, msgRef);
|
// the inbox, marked read, awaiting staff action. The threads.modify also
|
||||||
|
// clears UNREAD, so a success archives it like markGmailMessageRead did.
|
||||||
|
console.log('Filing Gmail thread into Triage', parsed.threadId);
|
||||||
|
await moveThreadToFolder(parsed.threadId, 'TRIAGE', gmail);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
authErrorNotified = false;
|
authErrorNotified = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -405,4 +404,4 @@ async function poll(client) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { poll, setPollSuspended, isPollSuspended };
|
module.exports = { poll, setPollSuspended };
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const {
|
|||||||
AttachmentBuilder,
|
AttachmentBuilder,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
MessageFlags,
|
MessageFlags,
|
||||||
PermissionFlagsBits,
|
|
||||||
ModalBuilder,
|
ModalBuilder,
|
||||||
TextInputBuilder,
|
TextInputBuilder,
|
||||||
TextInputStyle
|
TextInputStyle
|
||||||
@@ -25,10 +24,12 @@ const { mongoose } = require('../db-connection');
|
|||||||
const { CONFIG } = require('../config');
|
const { CONFIG } = require('../config');
|
||||||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets');
|
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets');
|
||||||
const { sendTicketClosedEmail } = require('../services/gmail');
|
const { sendTicketClosedEmail } = require('../services/gmail');
|
||||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
const { moveThreadToFolder } = require('../services/gmailLabels');
|
||||||
|
const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents');
|
||||||
|
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../services/transcript');
|
||||||
const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils');
|
const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils');
|
||||||
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
|
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
|
||||||
const { runEscalation, runDeescalation } = require('./commands');
|
const { runEscalation, runDeescalation, resolveEscalationCategoryId } = require('./commands');
|
||||||
const { pendingCloses } = require('./pendingCloses');
|
const { pendingCloses } = require('./pendingCloses');
|
||||||
const { addMemberToStaffThread, createStaffThread } = require('../services/staffThread');
|
const { addMemberToStaffThread, createStaffThread } = require('../services/staffThread');
|
||||||
const { pinMessage } = require('../services/pinMessage');
|
const { pinMessage } = require('../services/pinMessage');
|
||||||
@@ -309,7 +310,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
|
|||||||
await runFinalClose(interaction, freshTicket, effectiveSendEmail);
|
await runFinalClose(interaction, freshTicket, effectiveSendEmail);
|
||||||
}, timerSeconds * 1000));
|
}, timerSeconds * 1000));
|
||||||
|
|
||||||
pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail });
|
pendingCloses.set(channelId, { timeout: timerId, username: userTag, sendEmail });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancelCloseRequest(interaction) {
|
async function handleCancelCloseRequest(interaction) {
|
||||||
@@ -358,10 +359,7 @@ async function handleEscalateButton(interaction, ticket) {
|
|||||||
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral });
|
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
const categoryId = resolveEscalationCategoryId(ticket, tier);
|
||||||
const categoryId = tier === 1
|
|
||||||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
|
||||||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
|
||||||
|
|
||||||
if (!categoryId && !interaction.channel.isThread()) {
|
if (!categoryId && !interaction.channel.isThread()) {
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
@@ -370,7 +368,7 @@ async function handleEscalateButton(interaction, ticket) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await runDeferred(interaction, 'escalate', () => runEscalation(interaction, ticket, tier, null));
|
await runDeferred(interaction, 'escalate', () => runEscalation(interaction, ticket, tier));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeescalateButton(interaction, ticket) {
|
async function handleDeescalateButton(interaction, ticket) {
|
||||||
@@ -425,7 +423,8 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
|
|||||||
if (transcriptChan) {
|
if (transcriptChan) {
|
||||||
transcriptMsg = await enqueueSend(transcriptChan, {
|
transcriptMsg = await enqueueSend(transcriptChan, {
|
||||||
content: transcriptContent,
|
content: transcriptContent,
|
||||||
files: [file]
|
files: [file],
|
||||||
|
allowedMentions: { parse: [] }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,6 +449,12 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
|
|||||||
{ $set: { discordThreadId: null, status: 'closed' }, $unset: { welcomeMessageId: '' } }
|
{ $set: { discordThreadId: null, status: 'closed' }, $unset: { welcomeMessageId: '' } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// File the email thread into the Resolved folder — non-fatal, email tickets only.
|
||||||
|
if (!ticket.gmailThreadId?.startsWith('discord-')) {
|
||||||
|
moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED')
|
||||||
|
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
|
||||||
|
}
|
||||||
|
|
||||||
if (transcriptMsg?.id) {
|
if (transcriptMsg?.id) {
|
||||||
await Transcript.create({
|
await Transcript.create({
|
||||||
gmailThreadId: ticket.gmailThreadId,
|
gmailThreadId: ticket.gmailThreadId,
|
||||||
@@ -474,33 +479,6 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render the last 100 messages of a channel as a plaintext transcript. */
|
|
||||||
async function buildTranscriptText(channel, ticket) {
|
|
||||||
const messages = await channel.messages.fetch({ limit: 100 });
|
|
||||||
return `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
|
|
||||||
messages
|
|
||||||
.reverse()
|
|
||||||
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateForTranscript(d) {
|
|
||||||
return new Date(d).toLocaleString('en-US', {
|
|
||||||
month: '2-digit', day: '2-digit', year: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
||||||
hour12: true, timeZoneName: 'short'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr) {
|
|
||||||
return CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
|
||||||
.replace(/\{channel_name\}/g, channelName)
|
|
||||||
.replace(/\{email\}/g, senderEmail || '')
|
|
||||||
.replace(/\{date_opened\}/g, openedStr)
|
|
||||||
.replace(/\{date_closed\}/g, closedStr)
|
|
||||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {
|
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {
|
||||||
// Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for
|
// Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for
|
||||||
// pre-creatorId modal tickets only — split-pop returns the wrong value for
|
// pre-creatorId modal tickets only — split-pop returns the wrong value for
|
||||||
@@ -549,7 +527,7 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
|
|||||||
} else {
|
} else {
|
||||||
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
|
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
|
||||||
}
|
}
|
||||||
await enqueueSend(logChan, logMsg);
|
await enqueueSend(logChan, { content: logMsg, allowedMentions: { parse: [] } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -601,17 +579,7 @@ async function handleTicketModal(interaction) {
|
|||||||
name: unclaimedName,
|
name: unclaimedName,
|
||||||
type: ChannelType.GuildText,
|
type: ChannelType.GuildText,
|
||||||
parent: parentCategoryIdForTicket,
|
parent: parentCategoryIdForTicket,
|
||||||
permissionOverwrites: [
|
permissionOverwrites: ticketChannelOverwrites(guild, interaction.user.id)
|
||||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
|
||||||
{
|
|
||||||
id: interaction.user.id,
|
|
||||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: CONFIG.ROLE_ID_TO_PING,
|
|
||||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('guild.channels.create (ticket modal):', err);
|
console.error('guild.channels.create (ticket modal):', err);
|
||||||
@@ -647,7 +615,7 @@ async function handleTicketModal(interaction) {
|
|||||||
if (CONFIG.LOGGING_CHANNEL_ID) {
|
if (CONFIG.LOGGING_CHANNEL_ID) {
|
||||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||||
if (logChan) {
|
if (logChan) {
|
||||||
await enqueueSend(logChan, `📝 ${channel.name} created by ${interaction.user.tag}`);
|
await enqueueSend(logChan, { content: `📝 ${channel.name} created by ${interaction.user.tag}`, allowedMentions: { parse: [] } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -732,6 +700,20 @@ const TICKET_BUTTON_HANDLERS = {
|
|||||||
deescalate_ticket: handleDeescalateButton
|
deescalate_ticket: handleDeescalateButton
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TICKET_BUTTON_HANDLERS entries that any user with channel access may
|
||||||
|
* invoke — not just staff. Ticket creators and /add'd users get to close
|
||||||
|
* their own ticket (with the 60s countdown still in place) and cancel a
|
||||||
|
* pending close. Claim/escalate/de-escalate stay staff-only.
|
||||||
|
*/
|
||||||
|
const PUBLIC_TICKET_BUTTONS = new Set([
|
||||||
|
'close_ticket',
|
||||||
|
'confirm_close',
|
||||||
|
'confirm_close_with_email',
|
||||||
|
'confirm_close_no_email',
|
||||||
|
'cancel_close'
|
||||||
|
]);
|
||||||
|
|
||||||
async function handleButton(interaction) {
|
async function handleButton(interaction) {
|
||||||
const { customId } = interaction;
|
const { customId } = interaction;
|
||||||
|
|
||||||
@@ -757,13 +739,12 @@ async function handleButton(interaction) {
|
|||||||
const ticketHandler = TICKET_BUTTON_HANDLERS[customId];
|
const ticketHandler = TICKET_BUTTON_HANDLERS[customId];
|
||||||
if (!ticketHandler) return;
|
if (!ticketHandler) return;
|
||||||
|
|
||||||
// Every TICKET_BUTTON_HANDLERS entry mutates ticket state
|
// Claim / escalate / de-escalate mutate staff-owned ticket state and stay
|
||||||
// (claim/close/confirm_close*/cancel_close/escalate*/deescalate). The slash
|
// staff-only. Close-related buttons (close_ticket, confirm_close*,
|
||||||
// command dispatcher in handlers/commands/index.js gates these via
|
// cancel_close) are public so a ticket creator can close their own ticket;
|
||||||
// requireStaffRole; the button dispatcher must do the same — non-staff
|
// the 60s force-close countdown still applies, and the cancel button is
|
||||||
// members with view access to the ticket channel (creator, /add'd users)
|
// intentionally visible to anyone in the channel so any party can abort.
|
||||||
// could otherwise click Claim, Escalate, Close, etc.
|
if (!PUBLIC_TICKET_BUTTONS.has(customId) && (await requireStaffRole(interaction))) return;
|
||||||
if (await requireStaffRole(interaction)) return;
|
|
||||||
|
|
||||||
const ticket = await findTicketForChannel(
|
const ticket = await findTicketForChannel(
|
||||||
interaction,
|
interaction,
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ const { AttachmentBuilder, MessageFlags } = require('discord.js');
|
|||||||
const { mongoose } = require('../../db-connection');
|
const { mongoose } = require('../../db-connection');
|
||||||
const { CONFIG } = require('../../config');
|
const { CONFIG } = require('../../config');
|
||||||
const { enqueueSend } = require('../../services/channelQueue');
|
const { enqueueSend } = require('../../services/channelQueue');
|
||||||
const { logTicketEvent } = require('../../services/debugLog');
|
const { logTicketEvent, logError } = require('../../services/debugLog');
|
||||||
|
const { moveThreadToFolder } = require('../../services/gmailLabels');
|
||||||
const { pendingCloses } = require('../pendingCloses');
|
const { pendingCloses } = require('../pendingCloses');
|
||||||
const { findTicketForChannel } = require('../sharedHelpers');
|
const { findTicketForChannel } = require('../sharedHelpers');
|
||||||
|
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript');
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ async function handleForceClose(interaction) {
|
|||||||
const channelRef = interaction.channel;
|
const channelRef = interaction.channel;
|
||||||
const clientRef = interaction.client;
|
const clientRef = interaction.client;
|
||||||
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
|
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
|
||||||
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
|
pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Performs the actual force-close work after the countdown elapses. */
|
/** Performs the actual force-close work after the countdown elapses. */
|
||||||
@@ -73,6 +75,12 @@ async function finalizeForceClose(channelRef, clientRef) {
|
|||||||
{ $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } }
|
{ $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// File the email thread into the Resolved folder — non-fatal, email tickets only.
|
||||||
|
if (!freshTicket.gmailThreadId.startsWith('discord-')) {
|
||||||
|
moveThreadToFolder(freshTicket.gmailThreadId, 'RESOLVED')
|
||||||
|
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
|
||||||
|
}
|
||||||
|
|
||||||
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
|
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
|
||||||
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
|
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
|
||||||
console.error('Transcript error (force-close):', tErr)
|
console.error('Transcript error (force-close):', tErr)
|
||||||
@@ -92,14 +100,7 @@ async function finalizeForceClose(channelRef, clientRef) {
|
|||||||
async function postTranscript(channelRef, clientRef, freshTicket) {
|
async function postTranscript(channelRef, clientRef, freshTicket) {
|
||||||
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
|
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
|
||||||
|
|
||||||
const messages = await channelRef.messages.fetch({ limit: 100 });
|
const log = await buildTranscriptText(channelRef, freshTicket);
|
||||||
const log =
|
|
||||||
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
|
|
||||||
messages
|
|
||||||
.reverse()
|
|
||||||
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
const file = new AttachmentBuilder(Buffer.from(log), {
|
const file = new AttachmentBuilder(Buffer.from(log), {
|
||||||
name: `transcript-${channelRef.name}.txt`
|
name: `transcript-${channelRef.name}.txt`
|
||||||
});
|
});
|
||||||
@@ -109,20 +110,10 @@ async function postTranscript(channelRef, clientRef, freshTicket) {
|
|||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
if (!transcriptChan) return;
|
if (!transcriptChan) return;
|
||||||
|
|
||||||
const fmt = (d) => new Date(d).toLocaleString('en-US', {
|
const openedStr = formatDateForTranscript(freshTicket.createdAt);
|
||||||
month: '2-digit', day: '2-digit', year: 'numeric',
|
const closedStr = formatDateForTranscript(new Date());
|
||||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
const transcriptContent = renderTranscriptHeader(channelRef.name, freshTicket.senderEmail, openedStr, closedStr);
|
||||||
hour12: true, timeZoneName: 'short'
|
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file], allowedMentions: { parse: [] } });
|
||||||
});
|
|
||||||
const openedStr = fmt(freshTicket.createdAt);
|
|
||||||
const closedStr = fmt(new Date());
|
|
||||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
|
||||||
.replace(/\{channel_name\}/g, channelRef.name)
|
|
||||||
.replace(/\{email\}/g, freshTicket.senderEmail || '')
|
|
||||||
.replace(/\{date_opened\}/g, openedStr)
|
|
||||||
.replace(/\{date_closed\}/g, closedStr)
|
|
||||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
|
||||||
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose };
|
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose };
|
||||||
|
|||||||
@@ -6,14 +6,13 @@
|
|||||||
const {
|
const {
|
||||||
ChannelType,
|
ChannelType,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
MessageFlags,
|
MessageFlags
|
||||||
PermissionFlagsBits
|
|
||||||
} = require('discord.js');
|
} = require('discord.js');
|
||||||
const { mongoose } = require('../../db-connection');
|
const { mongoose } = require('../../db-connection');
|
||||||
const { CONFIG } = require('../../config');
|
const { CONFIG } = require('../../config');
|
||||||
const { getPriorityEmoji } = require('../../utils');
|
const { getPriorityEmoji } = require('../../utils');
|
||||||
const { checkTicketCreationRateLimit, getOrCreateTicketCategory } = require('../../services/tickets');
|
const { checkTicketCreationRateLimit, getOrCreateTicketCategory, makeTicketName } = require('../../services/tickets');
|
||||||
const { getTicketActionRow } = require('../../utils/ticketComponents');
|
const { getTicketActionRow, ticketChannelOverwrites } = require('../../utils/ticketComponents');
|
||||||
const { enqueueSend } = require('../../services/channelQueue');
|
const { enqueueSend } = require('../../services/channelQueue');
|
||||||
const { logError } = require('../../services/debugLog');
|
const { logError } = require('../../services/debugLog');
|
||||||
|
|
||||||
@@ -36,6 +35,8 @@ async function handleCreateTicketFromMessage(interaction) {
|
|||||||
const guild = interaction.guild;
|
const guild = interaction.guild;
|
||||||
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
||||||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||||||
|
const creatorNickname = message.member?.displayName || message.author.username;
|
||||||
|
const unclaimedName = makeTicketName('unclaimed', { ticketNumber }, creatorNickname);
|
||||||
|
|
||||||
let parentCategoryIdForTicket;
|
let parentCategoryIdForTicket;
|
||||||
try {
|
try {
|
||||||
@@ -52,20 +53,10 @@ async function handleCreateTicketFromMessage(interaction) {
|
|||||||
let channel;
|
let channel;
|
||||||
try {
|
try {
|
||||||
channel = await guild.channels.create({
|
channel = await guild.channels.create({
|
||||||
name: `ticket-${ticketNumber}`,
|
name: unclaimedName,
|
||||||
type: ChannelType.GuildText,
|
type: ChannelType.GuildText,
|
||||||
parent: parentCategoryIdForTicket,
|
parent: parentCategoryIdForTicket,
|
||||||
permissionOverwrites: [
|
permissionOverwrites: ticketChannelOverwrites(guild, message.author.id)
|
||||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
|
||||||
{
|
|
||||||
id: message.author.id,
|
|
||||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: CONFIG.ROLE_ID_TO_PING,
|
|
||||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('guild.channels.create (context menu ticket):', err);
|
console.error('guild.channels.create (context menu ticket):', err);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const { mongoose } = require('../../db-connection');
|
|||||||
const { CONFIG } = require('../../config');
|
const { CONFIG } = require('../../config');
|
||||||
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
||||||
const { sendTicketNotificationEmail } = require('../../services/gmail');
|
const { sendTicketNotificationEmail } = require('../../services/gmail');
|
||||||
|
const { moveThreadToFolder } = require('../../services/gmailLabels');
|
||||||
const { getTicketActionRow } = require('../../utils/ticketComponents');
|
const { getTicketActionRow } = require('../../utils/ticketComponents');
|
||||||
const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue');
|
const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue');
|
||||||
const { pinMessage } = require('../../services/pinMessage');
|
const { pinMessage } = require('../../services/pinMessage');
|
||||||
@@ -19,15 +20,26 @@ const { fetchLoggingChannel } = require('./helpers');
|
|||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the destination category for an escalation target tier
|
||||||
|
* (nextTier 1 = tier 2, 2 = tier 3), picking the Discord vs email category set
|
||||||
|
* by ticket origin. Returns null/undefined when the relevant category is unset.
|
||||||
|
*/
|
||||||
|
function resolveEscalationCategoryId(ticket, nextTier) {
|
||||||
|
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||||
|
if (nextTier === 1) {
|
||||||
|
return isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
|
||||||
|
}
|
||||||
|
return isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
|
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
|
||||||
* validate ticket and currentTier < nextTier, and have already deferred.
|
* validate ticket and currentTier < nextTier, and have already deferred.
|
||||||
*/
|
*/
|
||||||
async function runEscalation(interaction, ticket, nextTier, reason) {
|
async function runEscalation(interaction, ticket, nextTier) {
|
||||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||||
const categoryId = nextTier === 1
|
const categoryId = resolveEscalationCategoryId(ticket, nextTier);
|
||||||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
|
||||||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
|
||||||
|
|
||||||
// Clear claim on escalation
|
// Clear claim on escalation
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
@@ -87,11 +99,20 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
|||||||
try {
|
try {
|
||||||
const escalatorName = interaction.member?.displayName || interaction.user.username;
|
const escalatorName = interaction.member?.displayName || interaction.user.username;
|
||||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||||
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.${reason ? `\n\nReason: ${reason}` : ''}`;
|
// Editable via TICKET_ESCALATION_EMAIL_MESSAGE in .env. Placeholders:
|
||||||
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id);
|
// {escalator_name}, {tier}; \n for line breaks.
|
||||||
|
const emailBody = (CONFIG.TICKET_ESCALATION_EMAIL_MESSAGE || '')
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
.replace(/\{escalator_name\}/g, escalatorName)
|
||||||
|
.replace(/\{tier\}/g, tierLabel);
|
||||||
|
await sendTicketNotificationEmail(ticket, emailBody, interaction.user.id);
|
||||||
} catch (emailErr) {
|
} catch (emailErr) {
|
||||||
console.error('Escalation email failed (non-fatal):', emailErr.message);
|
console.error('Escalation email failed (non-fatal):', emailErr.message);
|
||||||
}
|
}
|
||||||
|
// File the email thread into the Escalated folder — non-fatal, never blocks
|
||||||
|
// the escalation.
|
||||||
|
moveThreadToFolder(ticket.gmailThreadId, 'ESCALATED')
|
||||||
|
.catch(err => logError('gmailLabels: escalate move', err).catch(() => {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextTier === 2 && ticket.welcomeMessageId) {
|
if (nextTier === 2 && ticket.welcomeMessageId) {
|
||||||
@@ -107,9 +128,10 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
|||||||
if (logChan) {
|
if (logChan) {
|
||||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||||
await enqueueSend(logChan,
|
await enqueueSend(logChan, {
|
||||||
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.\nReason: ${reason}`
|
content: `${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.`,
|
||||||
);
|
allowedMentions: { parse: [] }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,14 +179,14 @@ async function runDeescalation(interaction, ticket) {
|
|||||||
const logChan = await fetchLoggingChannel(interaction.client);
|
const logChan = await fetchLoggingChannel(interaction.client);
|
||||||
if (logChan) {
|
if (logChan) {
|
||||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||||
await enqueueSend(logChan,
|
await enqueueSend(logChan, {
|
||||||
`${ticketType} ticket ${interaction.channel} de‑escalated to ${tierLabel} by ${interaction.user.tag}.`
|
content: `${ticketType} ticket ${interaction.channel} de‑escalated to ${tierLabel} by ${interaction.user.tag}.`,
|
||||||
);
|
allowedMentions: { parse: [] }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEscalate(interaction) {
|
async function handleEscalate(interaction) {
|
||||||
const reason = null;
|
|
||||||
const level = interaction.options.getString('level');
|
const level = interaction.options.getString('level');
|
||||||
const nextTier = level === '3' ? 2 : 1;
|
const nextTier = level === '3' ? 2 : 1;
|
||||||
|
|
||||||
@@ -180,9 +202,7 @@ async function handleEscalate(interaction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||||
const categoryId = nextTier === 1
|
const categoryId = resolveEscalationCategoryId(ticket, nextTier);
|
||||||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
|
||||||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
|
||||||
const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3';
|
const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3';
|
||||||
if (!categoryId && !interaction.channel.isThread()) {
|
if (!categoryId && !interaction.channel.isThread()) {
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
@@ -192,7 +212,7 @@ async function handleEscalate(interaction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await runDeferred(interaction, 'escalate', () =>
|
await runDeferred(interaction, 'escalate', () =>
|
||||||
runEscalation(interaction, ticket, nextTier, reason)
|
runEscalation(interaction, ticket, nextTier)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,4 +231,4 @@ async function handleDeescalate(interaction) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate };
|
module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId };
|
||||||
|
|||||||
@@ -17,13 +17,17 @@
|
|||||||
const { EmbedBuilder, MessageFlags } = require('discord.js');
|
const { EmbedBuilder, MessageFlags } = require('discord.js');
|
||||||
const { mongoose } = require('../../db-connection');
|
const { mongoose } = require('../../db-connection');
|
||||||
const { CONFIG } = require('../../config');
|
const { CONFIG } = require('../../config');
|
||||||
|
const { isStaff } = require('../../utils');
|
||||||
const { setNotifyDm } = require('../../services/staffSettings');
|
const { setNotifyDm } = require('../../services/staffSettings');
|
||||||
const { enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
|
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
||||||
const { logTicketEvent } = require('../../services/debugLog');
|
const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
|
||||||
|
const { logError, logTicketEvent } = require('../../services/debugLog');
|
||||||
|
const { applyConfigUpdates } = require('../../services/configPersistence');
|
||||||
|
const { moveThreadToFolder, folderDisplayName } = require('../../services/gmailLabels');
|
||||||
const { findTicketForChannel } = require('../sharedHelpers');
|
const { findTicketForChannel } = require('../sharedHelpers');
|
||||||
|
|
||||||
const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
|
const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
|
||||||
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate } = require('./escalation');
|
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId } = require('./escalation');
|
||||||
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
|
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
|
||||||
const { handleResponse, handleAutocomplete } = require('./response');
|
const { handleResponse, handleAutocomplete } = require('./response');
|
||||||
const { handlePanel, handleSignature } = require('./panel');
|
const { handlePanel, handleSignature } = require('./panel');
|
||||||
@@ -54,16 +58,20 @@ async function handleAdd(interaction) {
|
|||||||
const ticket = await findTicketForChannel(interaction);
|
const ticket = await findTicketForChannel(interaction);
|
||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
|
|
||||||
|
// Defer up front: enqueueOverwrite serializes behind any pending rename/move
|
||||||
|
// on this channel and can exceed Discord's 3s interaction-token window.
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await enqueueOverwrite(interaction.channel, user.id, {
|
await enqueueOverwrite(interaction.channel, user.id, {
|
||||||
ViewChannel: true,
|
ViewChannel: true,
|
||||||
SendMessages: true,
|
SendMessages: true,
|
||||||
ReadMessageHistory: true
|
ReadMessageHistory: true
|
||||||
});
|
});
|
||||||
await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
|
await interaction.editReply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Add user error:', err);
|
console.error('Add user error:', err);
|
||||||
await interaction.reply({ content: 'Failed to add user.', flags: MessageFlags.Ephemeral });
|
await interaction.editReply({ content: 'Failed to add user.' }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,12 +80,15 @@ async function handleRemove(interaction) {
|
|||||||
const ticket = await findTicketForChannel(interaction);
|
const ticket = await findTicketForChannel(interaction);
|
||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
|
|
||||||
|
// Defer up front — same reason as handleAdd.
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
|
await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
|
||||||
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
await interaction.editReply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Remove user error:', err);
|
console.error('Remove user error:', err);
|
||||||
await interaction.reply({ content: 'Failed to remove user.', flags: MessageFlags.Ephemeral });
|
await interaction.editReply({ content: 'Failed to remove user.' }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,23 +98,54 @@ async function handleTransfer(interaction) {
|
|||||||
const ticket = await findTicketForChannel(interaction);
|
const ticket = await findTicketForChannel(interaction);
|
||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
|
|
||||||
const staffRoleId = CONFIG.ROLE_TO_PING_ID;
|
// Cache-first member resolution; falls back to a fetch if not in cache.
|
||||||
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null);
|
// GuildMembers intent keeps the cache warm in normal operation.
|
||||||
|
const guildMember = interaction.guild.members.cache.get(member.id)
|
||||||
|
|| await interaction.guild.members.fetch(member.id).catch(() => null);
|
||||||
|
|
||||||
if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) {
|
// Reject self-transfers and bots; require the target to satisfy isStaff(),
|
||||||
return interaction.reply({ content: 'The target member must have the staff role.', flags: MessageFlags.Ephemeral });
|
// which covers ROLE_ID_TO_PING + ADDITIONAL_STAFF_ROLES — the same staff
|
||||||
|
// definition used by every other gate in the bot. The previous check only
|
||||||
|
// looked at ROLE_TO_PING_ID, missing additional staff roles.
|
||||||
|
if (!guildMember || guildMember.user.bot || !isStaff(guildMember)) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'The target member must have the staff role.',
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
if (guildMember.id === interaction.user.id) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: 'You cannot transfer the ticket to yourself.',
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defer before the DB write + rename so the interaction token survives.
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const claimerLabel = guildMember.displayName || guildMember.user.username;
|
const claimerLabel = guildMember.displayName || guildMember.user.username;
|
||||||
|
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
{ $set: { claimedBy: claimerLabel } }
|
{ $set: { claimedBy: claimerLabel, claimerId: guildMember.id } }
|
||||||
);
|
);
|
||||||
|
ticket.claimedBy = claimerLabel;
|
||||||
|
ticket.claimerId = guildMember.id;
|
||||||
|
|
||||||
|
// Rename the channel to reflect the new claimer — mirrors the /claim
|
||||||
|
// button flow (applyClaim in handlers/buttons.js). Picks the new
|
||||||
|
// claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed
|
||||||
|
// variant when tier >= 1.
|
||||||
|
const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||||||
|
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||||
|
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
|
const state = tier >= 1 ? 'escalated-claimed' : 'claimed';
|
||||||
|
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji))
|
||||||
|
.catch(err => logError('rename', err).catch(() => {}));
|
||||||
|
|
||||||
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
|
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
|
||||||
await interaction.reply({
|
await interaction.editReply({
|
||||||
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
|
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
|
||||||
allowedMentions: { parse: ['users'] }
|
allowedMentions: { parse: ['users'] }
|
||||||
});
|
});
|
||||||
@@ -112,12 +154,12 @@ async function handleTransfer(interaction) {
|
|||||||
if (logChan) {
|
if (logChan) {
|
||||||
await enqueueSend(logChan, {
|
await enqueueSend(logChan, {
|
||||||
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
|
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
|
||||||
allowedMentions: { parse: ['users'] }
|
allowedMentions: { parse: [] }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Transfer error:', err);
|
console.error('Transfer error:', err);
|
||||||
await interaction.reply({ content: 'Failed to transfer ticket.', flags: MessageFlags.Ephemeral });
|
await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,19 +168,24 @@ async function handleMove(interaction) {
|
|||||||
const ticket = await findTicketForChannel(interaction);
|
const ticket = await findTicketForChannel(interaction);
|
||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
|
|
||||||
|
// Defer up front — enqueueMove serializes behind any pending rename and
|
||||||
|
// setParent itself can take a moment on busy channels.
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await enqueueMove(interaction.channel, category.id);
|
await enqueueMove(interaction.channel, category.id);
|
||||||
await interaction.reply(`Moved ticket to **${category.name}**.`);
|
await interaction.editReply(`Moved ticket to **${category.name}**.`);
|
||||||
|
|
||||||
const logChan = await fetchLoggingChannel(interaction.client);
|
const logChan = await fetchLoggingChannel(interaction.client);
|
||||||
if (logChan) {
|
if (logChan) {
|
||||||
await enqueueSend(logChan,
|
await enqueueSend(logChan, {
|
||||||
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`
|
content: `Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`,
|
||||||
);
|
allowedMentions: { parse: [] }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Move error:', err);
|
console.error('Move error:', err);
|
||||||
await interaction.reply({ content: 'Failed to move ticket.', flags: MessageFlags.Ephemeral });
|
await interaction.editReply({ content: 'Failed to move ticket.' }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,12 +194,15 @@ async function handleTopic(interaction) {
|
|||||||
const ticket = await findTicketForChannel(interaction);
|
const ticket = await findTicketForChannel(interaction);
|
||||||
if (!ticket) return;
|
if (!ticket) return;
|
||||||
|
|
||||||
|
// Defer up front — enqueueTopic serializes behind any pending rename/move.
|
||||||
|
await interaction.deferReply();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await enqueueTopic(interaction.channel, text);
|
await enqueueTopic(interaction.channel, text);
|
||||||
await interaction.reply('Topic updated successfully.');
|
await interaction.editReply('Topic updated successfully.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Topic error:', err);
|
console.error('Topic error:', err);
|
||||||
await interaction.reply({ content: 'Failed to update topic.', flags: MessageFlags.Ephemeral });
|
await interaction.editReply({ content: 'Failed to update topic.' }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +248,16 @@ async function handleGmailPoll(interaction) {
|
|||||||
// drop below 30s and trip Gmail's per-user quota under sustained load.
|
// drop below 30s and trip Gmail's per-user quota under sustained load.
|
||||||
const ms = Math.max(30000, requested * 1000);
|
const ms = Math.max(30000, requested * 1000);
|
||||||
const seconds = ms / 1000;
|
const seconds = ms / 1000;
|
||||||
|
// While the inbound email flow is off, setting an interval must NOT silently
|
||||||
|
// restart polling. Record it for this session (matches /gmailpoll's existing
|
||||||
|
// runtime-only model) so it applies the next time someone runs /email on.
|
||||||
|
if (!CONFIG.GMAIL_POLL_ENABLED) {
|
||||||
|
CONFIG.GMAIL_POLL_INTERVAL_MS = ms;
|
||||||
|
return interaction.reply({
|
||||||
|
content: `Interval saved (${seconds}s), but the inbound email flow is currently **off** — it will apply when you run \`/email on\`.`,
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
|
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
|
||||||
const { setGmailPollInterval } = require('../../broccolini-discord');
|
const { setGmailPollInterval } = require('../../broccolini-discord');
|
||||||
setGmailPollInterval(ms);
|
setGmailPollInterval(ms);
|
||||||
@@ -208,6 +268,82 @@ async function handleGmailPoll(interaction) {
|
|||||||
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
|
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleEmail(interaction) {
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
|
||||||
|
if (sub === 'status') {
|
||||||
|
const intervalSec = Math.round(CONFIG.GMAIL_POLL_INTERVAL_MS / 1000);
|
||||||
|
return interaction.reply({
|
||||||
|
content: `Inbound email flow is **${CONFIG.GMAIL_POLL_ENABLED ? 'on' : 'off'}**.\nPoll interval: ${intervalSec}s.`,
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const enable = sub === 'on';
|
||||||
|
// applyConfigUpdates writes both CONFIG and .env so the state survives restart.
|
||||||
|
const { applied, errors } = applyConfigUpdates({ GMAIL_POLL_ENABLED: enable });
|
||||||
|
if (!applied.includes('GMAIL_POLL_ENABLED')) {
|
||||||
|
const reason = (errors.find(e => e.key === 'GMAIL_POLL_ENABLED') || {}).error || 'unknown error';
|
||||||
|
return interaction.reply({
|
||||||
|
content: `Failed to turn email flow ${enable ? 'on' : 'off'}: ${reason}`,
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy require — broccolini-discord re-exports these and we'd otherwise cycle.
|
||||||
|
const { setGmailPollInterval, clearGmailPollInterval } = require('../../broccolini-discord');
|
||||||
|
if (enable) {
|
||||||
|
// Clear any auth-suspend latch so a prior invalid_grant doesn't keep polling
|
||||||
|
// dead. If auth is still broken, the next cycle re-suspends and DMs admin.
|
||||||
|
try { require('../../gmail-poll').setPollSuspended(false); } catch (_) {}
|
||||||
|
setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS);
|
||||||
|
} else {
|
||||||
|
clearGmailPollInterval();
|
||||||
|
}
|
||||||
|
|
||||||
|
logTicketEvent('Email flow toggled', [
|
||||||
|
{ name: 'State', value: enable ? 'on' : 'off' },
|
||||||
|
{ name: 'Set by', value: interaction.user.tag }
|
||||||
|
], interaction).catch(() => {});
|
||||||
|
|
||||||
|
return interaction.reply({
|
||||||
|
content: enable
|
||||||
|
? 'Inbound email flow is now **on** — the inbox will be polled.'
|
||||||
|
: 'Inbound email flow is now **off** — the inbox will not be polled. Outbound emails still send.',
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFolder(interaction) {
|
||||||
|
const folderKey = interaction.options.getString('destination');
|
||||||
|
const ticket = await findTicketForChannel(interaction);
|
||||||
|
if (!ticket) return;
|
||||||
|
|
||||||
|
// Discord-origin tickets have no Gmail thread to file.
|
||||||
|
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: "This ticket has no email thread, so it can't be moved to a Gmail folder.",
|
||||||
|
flags: MessageFlags.Ephemeral
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = folderDisplayName(folderKey) || 'Spam';
|
||||||
|
// Defer: resolving/creating labels + threads.modify can exceed the 3s window.
|
||||||
|
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await moveThreadToFolder(ticket.gmailThreadId, folderKey);
|
||||||
|
logTicketEvent('Email thread filed', [
|
||||||
|
{ name: 'Folder', value: label },
|
||||||
|
{ name: 'Filed by', value: interaction.user.tag }
|
||||||
|
], interaction).catch(() => {});
|
||||||
|
return interaction.editReply({ content: `Moved this ticket's email thread to **${label}**.` });
|
||||||
|
} catch (err) {
|
||||||
|
logError('handleFolder', err, interaction).catch(() => {});
|
||||||
|
return interaction.editReply({ content: `Failed to move the email thread: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleHelp(interaction) {
|
async function handleHelp(interaction) {
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
.setTitle('Ticket System - Commands')
|
.setTitle('Ticket System - Commands')
|
||||||
@@ -219,7 +355,7 @@ async function handleHelp(interaction) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Ticket Management',
|
name: 'Ticket Management',
|
||||||
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description'
|
value: '`/transfer @staff [reason]` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/cancel-close` - Abort a pending force-close countdown\n`/topic <text>` - Set ticket topic/description\n`/folder <destination>` - File this ticket\'s email into a Gmail folder'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Saved Responses',
|
name: 'Saved Responses',
|
||||||
@@ -235,10 +371,18 @@ async function handleHelp(interaction) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Escalation',
|
name: 'Escalation',
|
||||||
value: '`/escalate [reason] [tier]` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)'
|
value: '`/escalate <level>` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Staff Configuration',
|
||||||
|
value: '`/notifydm` - Toggle DM notifications for your claimed tickets\n`/signature` - Set your email signature\n`/closetimer <seconds>` - Set the force-close countdown\n`/staffthread` - Toggle/configure per-ticket staff threads\n`/pinmessages` - Toggle auto-pinning of ticket messages\n`/gmailpoll <interval>` - Set the Gmail poll interval\n`/email on|off|status` - Turn the inbound email flow on/off'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Right-click (Apps menu)',
|
||||||
|
value: '`Create Ticket From Message` - Turn a message into a ticket\n`View User Tickets` - Show a user\'s recent tickets'
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
.setFooter({ text: 'Click buttons on ticket messages to claim/close' });
|
.setFooter({ text: 'Click buttons on ticket messages to claim/close. Config changes via slash commands apply until the next restart.' });
|
||||||
|
|
||||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
@@ -258,6 +402,8 @@ const COMMAND_HANDLERS = {
|
|||||||
staffthread: handleStaffThread,
|
staffthread: handleStaffThread,
|
||||||
pinmessages: handlePinMessages,
|
pinmessages: handlePinMessages,
|
||||||
gmailpoll: handleGmailPoll,
|
gmailpoll: handleGmailPoll,
|
||||||
|
email: handleEmail,
|
||||||
|
folder: handleFolder,
|
||||||
closetimer: handleCloseTimer,
|
closetimer: handleCloseTimer,
|
||||||
'cancel-close': handleCancelClose,
|
'cancel-close': handleCancelClose,
|
||||||
'force-close': handleForceClose,
|
'force-close': handleForceClose,
|
||||||
@@ -295,5 +441,6 @@ module.exports = {
|
|||||||
handleContextMenu,
|
handleContextMenu,
|
||||||
handleAutocomplete,
|
handleAutocomplete,
|
||||||
runEscalation,
|
runEscalation,
|
||||||
runDeescalation
|
runDeescalation,
|
||||||
|
resolveEscalationCategoryId
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
const { mongoose } = require('../db-connection');
|
const { mongoose } = require('../db-connection');
|
||||||
const { CONFIG } = require('../config');
|
const { CONFIG } = require('../config');
|
||||||
const { extractRawEmail, isStaff } = require('../utils');
|
const { extractRawEmail, isStaff, getCleanBody } = require('../utils');
|
||||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||||
const { updateTicketActivity } = require('../services/tickets');
|
|
||||||
const { getNotifyDm } = require('../services/staffSettings');
|
const { getNotifyDm } = require('../services/staffSettings');
|
||||||
const { logError } = require('../services/debugLog');
|
const { logError } = require('../services/debugLog');
|
||||||
|
|
||||||
@@ -78,6 +77,10 @@ async function handleDiscordReply(m) {
|
|||||||
'Support';
|
'Support';
|
||||||
const msgId =
|
const msgId =
|
||||||
last.payload.headers.find(h => h.name === 'Message-ID')?.value;
|
last.payload.headers.find(h => h.name === 'Message-ID')?.value;
|
||||||
|
const origDate =
|
||||||
|
last.payload.headers.find(h => h.name === 'Date')?.value || '';
|
||||||
|
const origFrom =
|
||||||
|
last.payload.headers.find(h => h.name === 'From')?.value || recipient;
|
||||||
|
|
||||||
const recipientEmail = extractRawEmail(recipient).toLowerCase();
|
const recipientEmail = extractRawEmail(recipient).toLowerCase();
|
||||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) {
|
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) {
|
||||||
@@ -85,16 +88,18 @@ async function handleDiscordReply(m) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quote the customer's latest inbound message beneath the staff reply.
|
||||||
|
const quote = { from: origFrom, date: origDate, body: getCleanBody(last.payload) };
|
||||||
|
|
||||||
await sendGmailReply(
|
await sendGmailReply(
|
||||||
ticket.gmailThreadId,
|
ticket.gmailThreadId,
|
||||||
m.content,
|
m.content,
|
||||||
recipientEmail,
|
recipientEmail,
|
||||||
subject,
|
subject,
|
||||||
msgId,
|
msgId,
|
||||||
m.author.id
|
m.author.id,
|
||||||
|
quote
|
||||||
);
|
);
|
||||||
|
|
||||||
await updateTicketActivity(ticket.gmailThreadId);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('REPLY ERROR:', e);
|
console.error('REPLY ERROR:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Shared pending-close timer map.
|
* Shared pending-close timer map.
|
||||||
* Keyed by channel.id → { timeout, userId, username }.
|
* Keyed by channel.id → { timeout, username, sendEmail }.
|
||||||
* Used by buttons.js (sets timers) and commands.js (cancel-close clears them).
|
* Used by buttons.js (sets timers) and commands/ (cancel-close clears them).
|
||||||
*/
|
*/
|
||||||
const pendingCloses = new Map();
|
const pendingCloses = new Map();
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const ticketSchema = new mongoose.Schema({
|
|||||||
escalationTier: { type: Number, default: 0 },
|
escalationTier: { type: Number, default: 0 },
|
||||||
ticketNumber: Number,
|
ticketNumber: Number,
|
||||||
priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] },
|
priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] },
|
||||||
ticketTag: String,
|
|
||||||
lastActivity: Date,
|
lastActivity: Date,
|
||||||
welcomeMessageId: String,
|
welcomeMessageId: String,
|
||||||
claimerId: String,
|
claimerId: String,
|
||||||
|
|||||||
@@ -193,8 +193,4 @@ router.post('/gmail/reload', express.json(), async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expose the allowlist for the Phase 8 schema smoke test. Attached to the
|
|
||||||
// router function object; doesn't show up as a route.
|
|
||||||
router._allowedKeys = Array.from(ALLOWED_CONFIG_KEYS);
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -24,29 +24,28 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
|||||||
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
|
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
|
||||||
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
|
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
|
||||||
// Roles and staff
|
// Roles and staff
|
||||||
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
|
'ROLE_ID_TO_PING', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
|
||||||
'ADMIN_ID',
|
'ADMIN_ID',
|
||||||
// Channel IDs
|
// Channel IDs
|
||||||
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
|
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
|
||||||
'RENAME_LOG_CHANNEL_ID',
|
|
||||||
// Messages and labels
|
// Messages and labels
|
||||||
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
|
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
|
||||||
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
|
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
|
||||||
'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
|
'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
|
||||||
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
|
'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
|
||||||
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
|
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
|
||||||
// Branding
|
// Branding
|
||||||
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
|
'LOGO_URL', 'SUPPORT_NAME', 'GAME_LIST',
|
||||||
// Toggles
|
// Toggles
|
||||||
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
|
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
|
||||||
'ALLOW_CLAIM_OVERWRITE',
|
'ALLOW_CLAIM_OVERWRITE', 'TRANSCRIPT_DM_TO_CREATOR',
|
||||||
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
|
'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
|
||||||
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
|
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
|
||||||
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
|
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
|
||||||
// Limits and thresholds
|
// Limits and thresholds
|
||||||
'GLOBAL_TICKET_LIMIT',
|
'GLOBAL_TICKET_LIMIT',
|
||||||
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
|
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
|
||||||
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
|
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS', 'GMAIL_POLL_ENABLED',
|
||||||
// Embed colors
|
// Embed colors
|
||||||
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
||||||
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
|
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
|
||||||
@@ -140,26 +139,6 @@ const VALIDATORS = {
|
|||||||
return { ok: true, coerced: parts.join(',') };
|
return { ok: true, coerced: parts.join(',') };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
json: {
|
|
||||||
type: 'json',
|
|
||||||
validate(value) {
|
|
||||||
if (isEmptyInput(value)) return { ok: true, coerced: '' };
|
|
||||||
const str = String(value);
|
|
||||||
try {
|
|
||||||
JSON.parse(str);
|
|
||||||
return { ok: true, coerced: str };
|
|
||||||
} catch (_) {
|
|
||||||
return { ok: false, error: 'must be valid JSON' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
string_or_json: {
|
|
||||||
type: 'string_or_json',
|
|
||||||
validate(value) {
|
|
||||||
if (value === null || value === undefined) return { ok: false, error: 'cannot be null' };
|
|
||||||
return { ok: true, coerced: String(value) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Fallback. Preserves legacy coercion so CONFIG.* values keep their types
|
// Fallback. Preserves legacy coercion so CONFIG.* values keep their types
|
||||||
// for consumers that compare with === true / === 5 (see old applyConfigUpdates).
|
// for consumers that compare with === true / === 5 (see old applyConfigUpdates).
|
||||||
string: {
|
string: {
|
||||||
@@ -184,6 +163,8 @@ function inferType(key) {
|
|||||||
if (key.includes('COLOR')) return 'hex_color';
|
if (key.includes('COLOR')) return 'hex_color';
|
||||||
// ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it.
|
// ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it.
|
||||||
if (key === 'ROLE_ID_TO_PING') return 'discord_id';
|
if (key === 'ROLE_ID_TO_PING') return 'discord_id';
|
||||||
|
// Boolean toggle whose name doesn't match the ENABLED/_ON pattern.
|
||||||
|
if (key === 'TRANSCRIPT_DM_TO_CREATOR') return 'boolean';
|
||||||
|
|
||||||
// 2. Name patterns
|
// 2. Name patterns
|
||||||
if (/ENABLED$|^USE_|_ON$/.test(key)) return 'boolean';
|
if (/ENABLED$|^USE_|_ON$/.test(key)) return 'boolean';
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function sendToChannel(channelId, embed, overrideClient) {
|
|||||||
if (!c || !channelId) return;
|
if (!c || !channelId) return;
|
||||||
try {
|
try {
|
||||||
const channel = await c.channels.fetch(channelId);
|
const channel = await c.channels.fetch(channelId);
|
||||||
if (channel) await channel.send({ embeds: [embed] });
|
if (channel) await channel.send({ embeds: [embed], allowedMentions: { parse: [] } });
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// ignore send failures
|
// ignore send failures
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,8 @@ async function logError(context, error, interaction = null, overrideClient = nul
|
|||||||
const message = redactPII(error.message || String(error));
|
const message = redactPII(error.message || String(error));
|
||||||
const stack = redactPII(error.stack || error.message || String(error)).slice(0, 1500);
|
const stack = redactPII(error.stack || error.message || String(error)).slice(0, 1500);
|
||||||
await channel.send({
|
await channel.send({
|
||||||
content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``
|
content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``,
|
||||||
|
allowedMentions: { parse: [] }
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// ignore send failures
|
// ignore send failures
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ function buildCompanySigHtml() {
|
|||||||
Indifferent Broccoli Support<br>
|
Indifferent Broccoli Support<br>
|
||||||
<a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br>
|
<a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br>
|
||||||
Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br>
|
Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br>
|
||||||
<br>
|
|
||||||
<em>Host your own game server. Or not... we don't care.</em>
|
<em>Host your own game server. Or not... we don't care.</em>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -35,7 +34,6 @@ function buildCompanySigText() {
|
|||||||
'Indifferent Broccoli Support',
|
'Indifferent Broccoli Support',
|
||||||
'https://indifferentbroccoli.com/',
|
'https://indifferentbroccoli.com/',
|
||||||
'Join us on Discord: https://discord.gg/2vmfrrtvJY',
|
'Join us on Discord: https://discord.gg/2vmfrrtvJY',
|
||||||
'',
|
|
||||||
"Host your own game server. Or not... we don't care."
|
"Host your own game server. Or not... we don't care."
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
@@ -96,23 +94,169 @@ function encodeReplySubject(baseSubject) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compose and send a multipart/alternative reply on an existing Gmail thread.
|
// Compose and send a multipart/alternative reply on an existing Gmail thread.
|
||||||
async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId }) {
|
// Build the "On <date>, <sender> wrote:" attribution line for a quoted reply.
|
||||||
|
function formatQuoteAttribution(quote) {
|
||||||
|
const who = (quote.from || '').trim() || 'the sender';
|
||||||
|
const when = (quote.date || '').trim();
|
||||||
|
return when ? `On ${when}, ${who} wrote:` : `${who} wrote:`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain-text quoted block: attribution + each original line prefixed with "> ".
|
||||||
|
// Returns null when there is nothing to quote.
|
||||||
|
function buildQuoteText(quote) {
|
||||||
|
if (!quote || !(quote.body || '').trim()) return null;
|
||||||
|
const quoted = quote.body.replace(/\r\n/g, '\n').split('\n').map(l => `> ${l}`).join('\n');
|
||||||
|
return `${formatQuoteAttribution(quote)}\n${quoted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML quoted block. Mirrors Gmail's own reply markup (gmail_quote / gmail_attr
|
||||||
|
// classes + the standard blockquote styling) so receiving clients recognize it
|
||||||
|
// as quoted content and collapse it behind the "•••" toggle. Body is
|
||||||
|
// attacker-controlled email content — escapeHtml it.
|
||||||
|
function buildQuoteHtml(quote) {
|
||||||
|
if (!quote || !(quote.body || '').trim()) return '';
|
||||||
|
const attribution = escapeHtml(formatQuoteAttribution(quote));
|
||||||
|
const quotedHtml = escapeHtml(quote.body.replace(/\r\n/g, '\n')).replace(/\n/g, '<br>');
|
||||||
|
return `<div class="gmail_quote">` +
|
||||||
|
`<div dir="ltr" class="gmail_attr">${attribution}<br></div>` +
|
||||||
|
`<blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex;">${quotedHtml}</blockquote>` +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord custom emoji token: <:name:id> (static) or <a:name:id> (animated).
|
||||||
|
const DISCORD_EMOJI_RE = /<(a?):(\w+):(\d+)>/g;
|
||||||
|
// Same token after escapeHtml has turned the angle brackets into entities.
|
||||||
|
const DISCORD_EMOJI_RE_ESCAPED = /<(a?):(\w+):(\d+)>/g;
|
||||||
|
|
||||||
|
// Plain-text: collapse a custom-emoji token to its :name: shortcode.
|
||||||
|
function discordEmojiToText(s) {
|
||||||
|
return (s || '').replace(DISCORD_EMOJI_RE, (_m, _anim, name) => `:${name}:`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the distinct custom emoji referenced in a message.
|
||||||
|
function collectDiscordEmojis(s) {
|
||||||
|
const seen = new Map();
|
||||||
|
for (const m of (s || '').matchAll(DISCORD_EMOJI_RE)) {
|
||||||
|
const [, anim, name, id] = m;
|
||||||
|
if (!seen.has(id)) seen.set(id, { id, name, ext: anim ? 'gif' : 'png' });
|
||||||
|
}
|
||||||
|
return [...seen.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch one emoji's bytes from Discord's CDN for inline (cid:) embedding.
|
||||||
|
// Returns null on any failure so the caller can fall back to a remote <img>.
|
||||||
|
async function fetchEmojiInline(emoji) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.ext}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const base64 = Buffer.from(await res.arrayBuffer()).toString('base64');
|
||||||
|
return { ...emoji, base64, cid: `emoji-${emoji.id}@broccolini` };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML: escape first (body is staff-authored but treated as untrusted), then
|
||||||
|
// swap the now-escaped emoji tokens for an inline <img>. Prefer a cid: reference
|
||||||
|
// (embedded part, always renders); fall back to Discord's CDN when not embedded.
|
||||||
|
// The id is digits-only and name is \w+, so neither can break out of the attribute.
|
||||||
|
function messageTextToHtml(s, cidById = {}) {
|
||||||
|
return escapeHtml(s || '')
|
||||||
|
.replace(DISCORD_EMOJI_RE_ESCAPED, (_m, anim, name, id) => {
|
||||||
|
const ext = anim ? 'gif' : 'png';
|
||||||
|
const src = cidById[id] ? `cid:${cidById[id]}` : `https://cdn.discordapp.com/emojis/${id}.${ext}`;
|
||||||
|
return `<img src="${src}" alt=":${name}:" ` +
|
||||||
|
`width="20" height="20" style="vertical-align: middle;">`;
|
||||||
|
})
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip Discord role mentions (<@&id>) — internal staff pings like @broccolini
|
||||||
|
// that mean nothing to an email recipient. Collapse the whitespace left behind.
|
||||||
|
function stripRoleMentions(s) {
|
||||||
|
return (s || '')
|
||||||
|
.replace(/<@&\d+>/g, '')
|
||||||
|
.replace(/[^\S\r\n]{2,}/g, ' ')
|
||||||
|
.replace(/[^\S\r\n]+\n/g, '\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId, quote = null }) {
|
||||||
const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' };
|
const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' };
|
||||||
const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '<br>') : '';
|
const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '<br>') : '';
|
||||||
const safeStaffSigText = sigBlocks.text;
|
const safeStaffSigText = sigBlocks.text;
|
||||||
|
|
||||||
|
const cleanText = stripRoleMentions(messageText);
|
||||||
|
|
||||||
|
// Embed any custom emoji inline (cid:) so they render without the recipient
|
||||||
|
// having to load remote images. Failed fetches fall back to a remote <img>.
|
||||||
|
const inlineEmojis = (await Promise.all(collectDiscordEmojis(cleanText).map(fetchEmojiInline))).filter(Boolean);
|
||||||
|
const cidById = {};
|
||||||
|
for (const e of inlineEmojis) cidById[e.id] = e.cid;
|
||||||
|
|
||||||
|
const quoteHtml = buildQuoteHtml(quote);
|
||||||
const htmlBody = `
|
const htmlBody = `
|
||||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||||
<p>${escapeHtml(messageText || '').replace(/\n/g, '<br>')}</p>
|
<p>${messageTextToHtml(cleanText, cidById)}</p>
|
||||||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||||||
${buildCompanySigHtml()}
|
${buildCompanySigHtml()}
|
||||||
|
${quoteHtml ? `<br><br>${quoteHtml}` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const plainBody = [messageText || ''];
|
const plainBody = [discordEmojiToText(cleanText)];
|
||||||
if (safeStaffSigText) plainBody.push('', safeStaffSigText);
|
if (safeStaffSigText) plainBody.push('', safeStaffSigText);
|
||||||
plainBody.push('', ...buildCompanySigText().split('\n'));
|
plainBody.push('', ...buildCompanySigText().split('\n'));
|
||||||
|
const quoteText = buildQuoteText(quote);
|
||||||
|
if (quoteText) plainBody.push('', '', quoteText);
|
||||||
|
|
||||||
|
const stamp = Date.now().toString(16);
|
||||||
|
const altBoundary = 'alt_' + stamp;
|
||||||
|
const altPart = [
|
||||||
|
`--${altBoundary}`,
|
||||||
|
'Content-Type: text/plain; charset="UTF-8"',
|
||||||
|
'',
|
||||||
|
...plainBody,
|
||||||
|
'',
|
||||||
|
`--${altBoundary}`,
|
||||||
|
'Content-Type: text/html; charset="UTF-8"',
|
||||||
|
'',
|
||||||
|
htmlBody,
|
||||||
|
'',
|
||||||
|
`--${altBoundary}--`
|
||||||
|
];
|
||||||
|
|
||||||
|
// With no inline images the message stays a plain multipart/alternative.
|
||||||
|
// With them, wrap the alternative + image parts in a multipart/related.
|
||||||
|
let topContentType;
|
||||||
|
let bodyLines;
|
||||||
|
if (inlineEmojis.length) {
|
||||||
|
const relBoundary = 'rel_' + stamp;
|
||||||
|
topContentType = `multipart/related; boundary="${relBoundary}"`;
|
||||||
|
bodyLines = [
|
||||||
|
`--${relBoundary}`,
|
||||||
|
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
|
||||||
|
'',
|
||||||
|
...altPart,
|
||||||
|
''
|
||||||
|
];
|
||||||
|
for (const e of inlineEmojis) {
|
||||||
|
bodyLines.push(
|
||||||
|
`--${relBoundary}`,
|
||||||
|
`Content-Type: image/${e.ext === 'gif' ? 'gif' : 'png'}`,
|
||||||
|
'Content-Transfer-Encoding: base64',
|
||||||
|
`Content-ID: <${e.cid}>`,
|
||||||
|
`Content-Disposition: inline; filename="${e.name}.${e.ext}"`,
|
||||||
|
'',
|
||||||
|
...(e.base64.match(/.{1,76}/g) || []),
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
bodyLines.push(`--${relBoundary}--`);
|
||||||
|
} else {
|
||||||
|
topContentType = `multipart/alternative; boundary="${altBoundary}"`;
|
||||||
|
bodyLines = altPart;
|
||||||
|
}
|
||||||
|
|
||||||
const boundary = '000000000000' + Date.now().toString(16);
|
|
||||||
const headers = [
|
const headers = [
|
||||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||||
`To: ${recipient}`,
|
`To: ${recipient}`,
|
||||||
@@ -120,24 +264,10 @@ async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, m
|
|||||||
msgId && `In-Reply-To: ${msgId}`,
|
msgId && `In-Reply-To: ${msgId}`,
|
||||||
msgId && `References: ${msgId}`,
|
msgId && `References: ${msgId}`,
|
||||||
'MIME-Version: 1.0',
|
'MIME-Version: 1.0',
|
||||||
`Content-Type: multipart/alternative; boundary="${boundary}"`
|
`Content-Type: ${topContentType}`
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
const raw = Buffer.from([
|
const raw = Buffer.from([...headers, '', ...bodyLines].join('\r\n'))
|
||||||
...headers,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: text/plain; charset="UTF-8"',
|
|
||||||
'',
|
|
||||||
...plainBody,
|
|
||||||
'',
|
|
||||||
`--${boundary}`,
|
|
||||||
'Content-Type: text/html; charset="UTF-8"',
|
|
||||||
'',
|
|
||||||
htmlBody,
|
|
||||||
'',
|
|
||||||
`--${boundary}--`
|
|
||||||
].join('\r\n'))
|
|
||||||
.toString('base64')
|
.toString('base64')
|
||||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
|
||||||
@@ -164,15 +294,20 @@ async function sendTicketClosedEmail(ticket, closerName, userId = null) {
|
|||||||
const gmail = getGmailClient();
|
const gmail = getGmailClient();
|
||||||
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
|
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
|
||||||
const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support');
|
const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support');
|
||||||
const messageText = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`;
|
// Editable via TICKET_CLOSE_MESSAGE in .env. Supports a {closer_name}
|
||||||
|
// placeholder and \n for line breaks.
|
||||||
|
const messageText = (CONFIG.TICKET_CLOSE_MESSAGE || '')
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
.replace(/\{closer_name\}/g, closerName);
|
||||||
|
|
||||||
|
// Closing emails intentionally omit the staff signature (userId left out)
|
||||||
|
// — only the resolution message and the company signature go out.
|
||||||
await sendThreadedEmail(gmail, {
|
await sendThreadedEmail(gmail, {
|
||||||
threadId: ticket.gmailThreadId,
|
threadId: ticket.gmailThreadId,
|
||||||
recipient,
|
recipient,
|
||||||
encodedSubject,
|
encodedSubject,
|
||||||
msgId,
|
msgId,
|
||||||
messageText,
|
messageText
|
||||||
userId
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Ticket closed email error:', err);
|
console.error('Ticket closed email error:', err);
|
||||||
@@ -182,18 +317,17 @@ async function sendTicketClosedEmail(ticket, closerName, userId = null) {
|
|||||||
/**
|
/**
|
||||||
* Send a notification email in the ticket thread (e.g. escalation, high-priority).
|
* Send a notification email in the ticket thread (e.g. escalation, high-priority).
|
||||||
* @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject
|
* @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject
|
||||||
* @param {string} subjectLine - Fallback subject if the thread can't be queried
|
|
||||||
* @param {string} messageBody - Plain or HTML message body
|
* @param {string} messageBody - Plain or HTML message body
|
||||||
* @param {string} [userId] - Discord user ID for signature (optional)
|
* @param {string} [userId] - Discord user ID for signature (optional)
|
||||||
*/
|
*/
|
||||||
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, userId = null) {
|
async function sendTicketNotificationEmail(ticket, messageBody, userId = null) {
|
||||||
try {
|
try {
|
||||||
const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail');
|
const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail');
|
||||||
if (!recipient) return;
|
if (!recipient) return;
|
||||||
|
|
||||||
const gmail = getGmailClient();
|
const gmail = getGmailClient();
|
||||||
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
|
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
|
||||||
const encodedSubject = encodeReplySubject(subject || subjectLine || ticket.subject || 'Support');
|
const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support');
|
||||||
|
|
||||||
await sendThreadedEmail(gmail, {
|
await sendThreadedEmail(gmail, {
|
||||||
threadId: ticket.gmailThreadId,
|
threadId: ticket.gmailThreadId,
|
||||||
@@ -212,7 +346,7 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, use
|
|||||||
* Send a Gmail reply on an existing thread. Caller supplies subject + messageId
|
* Send a Gmail reply on an existing thread. Caller supplies subject + messageId
|
||||||
* (typically pulled from the latest non-self message in the thread).
|
* (typically pulled from the latest non-self message in the thread).
|
||||||
*/
|
*/
|
||||||
async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null) {
|
async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null, quote = null) {
|
||||||
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
|
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
|
||||||
if (!EMAIL_RE.test(safeRecipient)) {
|
if (!EMAIL_RE.test(safeRecipient)) {
|
||||||
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
|
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
|
||||||
@@ -226,7 +360,8 @@ async function sendGmailReply(threadId, replyText, recipientEmail, subject, mess
|
|||||||
encodedSubject: encodeReplySubject(subject || 'Support'),
|
encodedSubject: encodeReplySubject(subject || 'Support'),
|
||||||
msgId: sanitizeHeaderValue(messageId) || null,
|
msgId: sanitizeHeaderValue(messageId) || null,
|
||||||
messageText: replyText,
|
messageText: replyText,
|
||||||
userId
|
userId,
|
||||||
|
quote
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
152
services/gmailLabels.js
Normal file
152
services/gmailLabels.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Gmail "folder" routing — map a ticket's Gmail thread into a managed set of
|
||||||
|
* labels with exclusive-folder semantics.
|
||||||
|
*
|
||||||
|
* Gmail labels are additive; we synthesize folders by, on every move, adding the
|
||||||
|
* target label and removing every *other* managed label plus INBOX + UNREAD
|
||||||
|
* (removing an absent label is a no-op, so this is idempotent). "Spam" maps to
|
||||||
|
* the built-in system SPAM label, which is never created.
|
||||||
|
*
|
||||||
|
* Acyclic require graph: this module depends on services/gmail (getGmailClient);
|
||||||
|
* gmail.js does not depend back on this file.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { CONFIG } = require('../config');
|
||||||
|
const { getGmailClient } = require('./gmail');
|
||||||
|
|
||||||
|
// Logical folder key -> how to resolve its label. User folders read their display
|
||||||
|
// name from CONFIG (env-configurable); SPAM is the Gmail system label.
|
||||||
|
const FOLDER_DEFS = {
|
||||||
|
TRIAGE: { configKey: 'GMAIL_LABEL_TRIAGE' },
|
||||||
|
ESCALATED: { configKey: 'GMAIL_LABEL_ESCALATED' },
|
||||||
|
RESOLVED: { configKey: 'GMAIL_LABEL_RESOLVED' },
|
||||||
|
FOR_JAKE: { configKey: 'GMAIL_LABEL_FOR_JAKE' },
|
||||||
|
DASHBOARD_ERRORS: { configKey: 'GMAIL_LABEL_DASHBOARD_ERRORS' },
|
||||||
|
PARTNERSHIP_OFFERS: { configKey: 'GMAIL_LABEL_PARTNERSHIP_OFFERS' },
|
||||||
|
SPAM: { system: 'SPAM' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// User-managed folder keys (everything but the system SPAM label).
|
||||||
|
const MANAGED_USER_KEYS = Object.keys(FOLDER_DEFS).filter(k => !FOLDER_DEFS[k].system);
|
||||||
|
|
||||||
|
// Always stripped on a move so the thread leaves the inbox and is marked read.
|
||||||
|
const ALWAYS_REMOVE = ['INBOX', 'UNREAD'];
|
||||||
|
|
||||||
|
// Cache: Gmail label display name -> label ID. Populated lazily; cleared on a
|
||||||
|
// stale-label error so a label recreated in Gmail is re-resolved.
|
||||||
|
const labelIdByName = new Map();
|
||||||
|
|
||||||
|
/** Display name for a user folder key (null for the system SPAM label). */
|
||||||
|
function folderDisplayName(key) {
|
||||||
|
const def = FOLDER_DEFS[key];
|
||||||
|
if (!def) throw new Error(`Unknown folder key: ${key}`);
|
||||||
|
if (def.system) return null;
|
||||||
|
return CONFIG[def.configKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLabelCache(gmail) {
|
||||||
|
if (labelIdByName.size > 0) return;
|
||||||
|
const res = await gmail.users.labels.list({ userId: 'me' });
|
||||||
|
for (const label of res.data.labels || []) {
|
||||||
|
labelIdByName.set(label.name, label.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a folder key to a Gmail label ID, creating a missing *user* label.
|
||||||
|
* SPAM short-circuits to the system id and is never created.
|
||||||
|
*/
|
||||||
|
async function resolveLabelId(gmail, key) {
|
||||||
|
const def = FOLDER_DEFS[key];
|
||||||
|
if (!def) throw new Error(`Unknown folder key: ${key}`);
|
||||||
|
if (def.system) return def.system;
|
||||||
|
|
||||||
|
const name = folderDisplayName(key);
|
||||||
|
await ensureLabelCache(gmail);
|
||||||
|
if (labelIdByName.has(name)) return labelIdByName.get(name);
|
||||||
|
|
||||||
|
const created = await gmail.users.labels.create({
|
||||||
|
userId: 'me',
|
||||||
|
requestBody: { name, labelListVisibility: 'labelShow', messageListVisibility: 'show' }
|
||||||
|
});
|
||||||
|
labelIdByName.set(name, created.data.id);
|
||||||
|
return created.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure: given the target key and a key->id map of every managed label, build the
|
||||||
|
* add/remove sets for an exclusive-folder move. The target label is added; every
|
||||||
|
* other managed label plus INBOX + UNREAD is removed.
|
||||||
|
*/
|
||||||
|
function computeLabelMutation(targetKey, idByKey) {
|
||||||
|
const targetId = idByKey[targetKey];
|
||||||
|
if (!targetId) throw new Error(`Missing resolved id for target folder: ${targetKey}`);
|
||||||
|
|
||||||
|
const removeLabelIds = [];
|
||||||
|
for (const key of Object.keys(idByKey)) {
|
||||||
|
if (key === targetKey) continue;
|
||||||
|
const id = idByKey[key];
|
||||||
|
if (id) removeLabelIds.push(id);
|
||||||
|
}
|
||||||
|
for (const sys of ALWAYS_REMOVE) removeLabelIds.push(sys);
|
||||||
|
|
||||||
|
return { addLabelIds: [targetId], removeLabelIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInvalidLabelError(err) {
|
||||||
|
const status = err && ((err.response && err.response.status) || err.code);
|
||||||
|
const msg = (err && err.message) || '';
|
||||||
|
return status === 400 || /invalid label|labelId not found/i.test(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a Gmail thread into a managed folder with exclusive-folder semantics.
|
||||||
|
* Resolves (and creates) every managed label, then issues one threads.modify.
|
||||||
|
* On a stale cached label id (400 invalid label), clears the cache and retries
|
||||||
|
* once.
|
||||||
|
*
|
||||||
|
* @param {string} threadId Gmail thread id (ticket.gmailThreadId)
|
||||||
|
* @param {string} targetKey one of FOLDER_DEFS keys
|
||||||
|
* @param {object} [gmail] optional Gmail client (poll loop passes its own)
|
||||||
|
*/
|
||||||
|
async function moveThreadToFolder(threadId, targetKey, gmail = getGmailClient()) {
|
||||||
|
if (!threadId) throw new Error('moveThreadToFolder: threadId required');
|
||||||
|
if (!FOLDER_DEFS[targetKey]) throw new Error(`Unknown folder key: ${targetKey}`);
|
||||||
|
|
||||||
|
const applyOnce = async () => {
|
||||||
|
const idByKey = {};
|
||||||
|
for (const key of Object.keys(FOLDER_DEFS)) {
|
||||||
|
idByKey[key] = await resolveLabelId(gmail, key);
|
||||||
|
}
|
||||||
|
const mutation = computeLabelMutation(targetKey, idByKey);
|
||||||
|
await gmail.users.threads.modify({
|
||||||
|
userId: 'me',
|
||||||
|
id: threadId,
|
||||||
|
requestBody: mutation
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await applyOnce();
|
||||||
|
} catch (err) {
|
||||||
|
if (isInvalidLabelError(err)) {
|
||||||
|
labelIdByName.clear();
|
||||||
|
await applyOnce();
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
FOLDER_DEFS,
|
||||||
|
MANAGED_USER_KEYS,
|
||||||
|
ALWAYS_REMOVE,
|
||||||
|
folderDisplayName,
|
||||||
|
resolveLabelId,
|
||||||
|
computeLabelMutation,
|
||||||
|
moveThreadToFolder,
|
||||||
|
// test seam: clear the name->id cache between cases
|
||||||
|
__clearLabelCache: () => labelIdByName.clear()
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Ticket database helpers – counters, rename, limits, auto-close,
|
* Ticket database helpers – counters, rename, limits, auto-close,
|
||||||
* reminders, auto-unclaim, channel creation.
|
* auto-unclaim, channel creation.
|
||||||
*/
|
*/
|
||||||
const { ChannelType } = require('discord.js');
|
const { ChannelType } = require('discord.js');
|
||||||
const { mongoose, withRetry } = require('../db-connection');
|
const { mongoose, withRetry } = require('../db-connection');
|
||||||
@@ -269,15 +269,6 @@ async function checkTicketLimits(senderEmail) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ACTIVITY ---
|
|
||||||
|
|
||||||
async function updateTicketActivity(gmailThreadId) {
|
|
||||||
await Ticket.updateOne(
|
|
||||||
{ gmailThreadId },
|
|
||||||
{ $set: { lastActivity: new Date() } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- SCHEDULED CHECKS ---
|
// --- SCHEDULED CHECKS ---
|
||||||
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
|
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
|
||||||
|
|
||||||
@@ -291,11 +282,11 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
|||||||
lastActivity: { $lt: cutoffTime, $ne: null }
|
lastActivity: { $lt: cutoffTime, $ne: null }
|
||||||
}).sort({ createdAt: 1 }).limit(500).lean());
|
}).sort({ createdAt: 1 }).limit(500).lean());
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.first();
|
||||||
|
if (!guild) return;
|
||||||
|
|
||||||
for (const ticket of staleTickets) {
|
for (const ticket of staleTickets) {
|
||||||
try {
|
try {
|
||||||
const guild = client.guilds.cache.first();
|
|
||||||
if (!guild) continue;
|
|
||||||
|
|
||||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
|
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
|
||||||
@@ -337,11 +328,11 @@ async function checkAutoUnclaim(client) {
|
|||||||
lastActivity: { $lt: unclaimTime, $ne: null }
|
lastActivity: { $lt: unclaimTime, $ne: null }
|
||||||
}).lean());
|
}).lean());
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.first();
|
||||||
|
if (!guild) return;
|
||||||
|
|
||||||
for (const ticket of staleClaimedTickets) {
|
for (const ticket of staleClaimedTickets) {
|
||||||
try {
|
try {
|
||||||
const guild = client.guilds.cache.first();
|
|
||||||
if (!guild) continue;
|
|
||||||
|
|
||||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
await withRetry(() => Ticket.updateOne(
|
await withRetry(() => Ticket.updateOne(
|
||||||
@@ -426,7 +417,6 @@ module.exports = {
|
|||||||
makeTicketName,
|
makeTicketName,
|
||||||
checkTicketCreationRateLimit,
|
checkTicketCreationRateLimit,
|
||||||
checkTicketLimits,
|
checkTicketLimits,
|
||||||
updateTicketActivity,
|
|
||||||
checkAutoClose,
|
checkAutoClose,
|
||||||
checkAutoUnclaim,
|
checkAutoUnclaim,
|
||||||
reconcileDeletedTicketChannels,
|
reconcileDeletedTicketChannels,
|
||||||
|
|||||||
37
services/transcript.js
Normal file
37
services/transcript.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Shared transcript rendering for the two close paths
|
||||||
|
* (handlers/buttons.js runFinalClose and handlers/commands/close.js postTranscript).
|
||||||
|
* Pure formatting only — each caller owns its own posting / DB / email side effects.
|
||||||
|
*/
|
||||||
|
const { CONFIG } = require('../config');
|
||||||
|
|
||||||
|
/** Render the last 100 messages of a channel as a plaintext transcript. */
|
||||||
|
async function buildTranscriptText(channel, ticket) {
|
||||||
|
const messages = await channel.messages.fetch({ limit: 100 });
|
||||||
|
return `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
|
||||||
|
messages
|
||||||
|
.reverse()
|
||||||
|
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a date for the transcript header (US locale, 12h, with time zone). */
|
||||||
|
function formatDateForTranscript(d) {
|
||||||
|
return new Date(d).toLocaleString('en-US', {
|
||||||
|
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
hour12: true, timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the transcript-channel message body from DISCORD_TRANSCRIPT_MESSAGE. */
|
||||||
|
function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr) {
|
||||||
|
return CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||||
|
.replace(/\{channel_name\}/g, channelName)
|
||||||
|
.replace(/\{email\}/g, senderEmail || '')
|
||||||
|
.replace(/\{date_opened\}/g, openedStr)
|
||||||
|
.replace(/\{date_closed\}/g, closedStr)
|
||||||
|
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader };
|
||||||
@@ -84,7 +84,6 @@
|
|||||||
<div class="field"><label>Debugging Channel</label><input type="text" data-key="DEBUGGING_CHANNEL_ID" data-smart="channel"></div>
|
<div class="field"><label>Debugging Channel</label><input type="text" data-key="DEBUGGING_CHANNEL_ID" data-smart="channel"></div>
|
||||||
<div class="field"><label>Gmail Log Channel</label><input type="text" data-key="GMAIL_LOG_CHANNEL_ID" data-smart="channel"></div>
|
<div class="field"><label>Gmail Log Channel</label><input type="text" data-key="GMAIL_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||||
<div class="field"><label>Automation Log Channel</label><input type="text" data-key="AUTOMATION_LOG_CHANNEL_ID" data-smart="channel"></div>
|
<div class="field"><label>Automation Log Channel</label><input type="text" data-key="AUTOMATION_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||||
<div class="field"><label>Rename Log Channel</label><input type="text" data-key="RENAME_LOG_CHANNEL_ID" data-smart="channel"></div>
|
|
||||||
<div class="field"><label>Security Log Channel</label><input type="text" data-key="SECURITY_LOG_CHANNEL_ID" data-smart="channel"></div>
|
<div class="field"><label>Security Log Channel</label><input type="text" data-key="SECURITY_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||||
<div class="field"><label>System Log Channel</label><input type="text" data-key="SYSTEM_LOG_CHANNEL_ID" data-smart="channel"></div>
|
<div class="field"><label>System Log Channel</label><input type="text" data-key="SYSTEM_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||||
</div></div>
|
</div></div>
|
||||||
@@ -123,8 +122,6 @@
|
|||||||
<div class="section-body"><div class="field-grid">
|
<div class="section-body"><div class="field-grid">
|
||||||
<div class="field"><label>Auto-Close</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_CLOSE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
<div class="field"><label>Auto-Close</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_CLOSE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
<div class="field"><label>Auto-Close Hours</label><input type="number" data-key="AUTO_CLOSE_AFTER_HOURS"></div>
|
<div class="field"><label>Auto-Close Hours</label><input type="number" data-key="AUTO_CLOSE_AFTER_HOURS"></div>
|
||||||
<div class="field"><label>Reminders</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="REMINDER_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
|
||||||
<div class="field"><label>Reminder Hours</label><input type="number" data-key="REMINDER_AFTER_HOURS"></div>
|
|
||||||
<div class="field"><label>Auto-Unclaim</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_UNCLAIM_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
<div class="field"><label>Auto-Unclaim</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_UNCLAIM_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
<div class="field"><label>Auto-Unclaim Hours</label><input type="number" data-key="AUTO_UNCLAIM_AFTER_HOURS"></div>
|
<div class="field"><label>Auto-Unclaim Hours</label><input type="number" data-key="AUTO_UNCLAIM_AFTER_HOURS"></div>
|
||||||
<div class="field"><label>Allow Claim Overwrite</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="ALLOW_CLAIM_OVERWRITE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
<div class="field"><label>Allow Claim Overwrite</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="ALLOW_CLAIM_OVERWRITE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||||
@@ -136,7 +133,6 @@
|
|||||||
<div class="field full-width"><label>Claimed Message</label><textarea data-key="TICKET_CLAIMED_MESSAGE" rows="2"></textarea><div class="hint">Variables: {staff_mention}, {staff_name}</div></div>
|
<div class="field full-width"><label>Claimed Message</label><textarea data-key="TICKET_CLAIMED_MESSAGE" rows="2"></textarea><div class="hint">Variables: {staff_mention}, {staff_name}</div></div>
|
||||||
<div class="field full-width"><label>Unclaimed Message</label><textarea data-key="TICKET_UNCLAIMED_MESSAGE" rows="2"></textarea></div>
|
<div class="field full-width"><label>Unclaimed Message</label><textarea data-key="TICKET_UNCLAIMED_MESSAGE" rows="2"></textarea></div>
|
||||||
<div class="field full-width"><label>Escalation Message</label><textarea data-key="ESCALATION_MESSAGE" rows="3"></textarea><div class="hint">Variables: {support_name}</div></div>
|
<div class="field full-width"><label>Escalation Message</label><textarea data-key="ESCALATION_MESSAGE" rows="3"></textarea><div class="hint">Variables: {support_name}</div></div>
|
||||||
<div class="field full-width"><label>Reminder Message</label><textarea data-key="REMINDER_MESSAGE" rows="2"></textarea><div class="hint">Variables: {ping}, {hours}</div></div>
|
|
||||||
</div></div>
|
</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
152
tests/gmailLabels.test.js
Normal file
152
tests/gmailLabels.test.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
computeLabelMutation,
|
||||||
|
resolveLabelId,
|
||||||
|
moveThreadToFolder,
|
||||||
|
folderDisplayName,
|
||||||
|
__clearLabelCache
|
||||||
|
} from '../services/gmailLabels.js';
|
||||||
|
|
||||||
|
const FULL_IDS = {
|
||||||
|
TRIAGE: 'L_TRIAGE',
|
||||||
|
ESCALATED: 'L_ESC',
|
||||||
|
RESOLVED: 'L_RES',
|
||||||
|
FOR_JAKE: 'L_FJ',
|
||||||
|
DASHBOARD_ERRORS: 'L_DE',
|
||||||
|
PARTNERSHIP_OFFERS: 'L_PO',
|
||||||
|
SPAM: 'SPAM'
|
||||||
|
};
|
||||||
|
|
||||||
|
const FULL_LABELS = [
|
||||||
|
{ name: 'Triage', id: 'L_TRIAGE' },
|
||||||
|
{ name: 'Escalated', id: 'L_ESC' },
|
||||||
|
{ name: 'Resolved', id: 'L_RES' },
|
||||||
|
{ name: 'For Jake', id: 'L_FJ' },
|
||||||
|
{ name: 'Dashboard Errors', id: 'L_DE' },
|
||||||
|
{ name: 'Partnership Offers', id: 'L_PO' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('computeLabelMutation', () => {
|
||||||
|
it('adds the target, removes every other managed label plus INBOX/UNREAD', () => {
|
||||||
|
const { addLabelIds, removeLabelIds } = computeLabelMutation('FOR_JAKE', FULL_IDS);
|
||||||
|
expect(addLabelIds).toEqual(['L_FJ']);
|
||||||
|
expect(removeLabelIds).toContain('INBOX');
|
||||||
|
expect(removeLabelIds).toContain('UNREAD');
|
||||||
|
expect(removeLabelIds).toContain('SPAM');
|
||||||
|
expect(removeLabelIds).toContain('L_TRIAGE');
|
||||||
|
expect(removeLabelIds).not.toContain('L_FJ'); // target is never removed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moving to SPAM adds SPAM and removes all user labels but not SPAM itself', () => {
|
||||||
|
const { addLabelIds, removeLabelIds } = computeLabelMutation('SPAM', FULL_IDS);
|
||||||
|
expect(addLabelIds).toEqual(['SPAM']);
|
||||||
|
expect(removeLabelIds).not.toContain('SPAM');
|
||||||
|
expect(removeLabelIds).toContain('L_TRIAGE');
|
||||||
|
expect(removeLabelIds).toContain('INBOX');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the target id is missing', () => {
|
||||||
|
expect(() => computeLabelMutation('FOR_JAKE', { TRIAGE: 'x' })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('folderDisplayName', () => {
|
||||||
|
it('returns null for the system SPAM folder', () => {
|
||||||
|
expect(folderDisplayName('SPAM')).toBeNull();
|
||||||
|
});
|
||||||
|
it('returns the configured/default name for a user folder', () => {
|
||||||
|
expect(folderDisplayName('FOR_JAKE')).toBe('For Jake');
|
||||||
|
});
|
||||||
|
it('throws on an unknown key', () => {
|
||||||
|
expect(() => folderDisplayName('NOPE')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveLabelId', () => {
|
||||||
|
beforeEach(() => __clearLabelCache());
|
||||||
|
|
||||||
|
it('short-circuits SPAM to the system id without any API call', async () => {
|
||||||
|
let called = false;
|
||||||
|
const gmail = { users: { labels: { list: async () => { called = true; return { data: {} }; } } } };
|
||||||
|
expect(await resolveLabelId(gmail, 'SPAM')).toBe('SPAM');
|
||||||
|
expect(called).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an existing label id matched by name', async () => {
|
||||||
|
const gmail = {
|
||||||
|
users: { labels: {
|
||||||
|
list: async () => ({ data: { labels: [{ name: 'For Jake', id: 'L_EXISTING' }] } }),
|
||||||
|
create: async () => { throw new Error('should not create'); }
|
||||||
|
} }
|
||||||
|
};
|
||||||
|
expect(await resolveLabelId(gmail, 'FOR_JAKE')).toBe('L_EXISTING');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a missing user label under its configured name and caches it', async () => {
|
||||||
|
let createdName = null;
|
||||||
|
const gmail = {
|
||||||
|
users: { labels: {
|
||||||
|
list: async () => ({ data: { labels: [] } }),
|
||||||
|
create: async ({ requestBody }) => { createdName = requestBody.name; return { data: { id: 'L_NEW' } }; }
|
||||||
|
} }
|
||||||
|
};
|
||||||
|
expect(await resolveLabelId(gmail, 'FOR_JAKE')).toBe('L_NEW');
|
||||||
|
expect(createdName).toBe('For Jake');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moveThreadToFolder', () => {
|
||||||
|
beforeEach(() => __clearLabelCache());
|
||||||
|
|
||||||
|
it('resolves labels then issues one threads.modify with exclusive sets', async () => {
|
||||||
|
let modifyArgs = null;
|
||||||
|
const gmail = {
|
||||||
|
users: {
|
||||||
|
labels: {
|
||||||
|
list: async () => ({ data: { labels: FULL_LABELS } }),
|
||||||
|
create: async () => { throw new Error('no create expected'); }
|
||||||
|
},
|
||||||
|
threads: { modify: async (args) => { modifyArgs = args; return { data: {} }; } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await moveThreadToFolder('thread123', 'ESCALATED', gmail);
|
||||||
|
expect(modifyArgs.id).toBe('thread123');
|
||||||
|
expect(modifyArgs.requestBody.addLabelIds).toEqual(['L_ESC']);
|
||||||
|
expect(modifyArgs.requestBody.removeLabelIds).toContain('L_TRIAGE');
|
||||||
|
expect(modifyArgs.requestBody.removeLabelIds).toContain('INBOX');
|
||||||
|
expect(modifyArgs.requestBody.removeLabelIds).not.toContain('L_ESC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the cache and retries once on an invalid-label error', async () => {
|
||||||
|
let modifyCalls = 0;
|
||||||
|
let listCalls = 0;
|
||||||
|
const gmail = {
|
||||||
|
users: {
|
||||||
|
labels: {
|
||||||
|
list: async () => { listCalls++; return { data: { labels: FULL_LABELS } }; },
|
||||||
|
create: async () => ({ data: { id: 'X' } })
|
||||||
|
},
|
||||||
|
threads: {
|
||||||
|
modify: async () => {
|
||||||
|
modifyCalls++;
|
||||||
|
if (modifyCalls === 1) { const e = new Error('invalid label'); e.code = 400; throw e; }
|
||||||
|
return { data: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await moveThreadToFolder('t1', 'TRIAGE', gmail);
|
||||||
|
expect(modifyCalls).toBe(2);
|
||||||
|
expect(listCalls).toBe(2); // cache was cleared and labels re-listed
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown folder key before touching the API', async () => {
|
||||||
|
const gmail = {
|
||||||
|
users: {
|
||||||
|
labels: { list: async () => ({ data: { labels: [] } }) },
|
||||||
|
threads: { modify: async () => ({}) }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await expect(moveThreadToFolder('t', 'BOGUS', gmail)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
26
utils.js
26
utils.js
@@ -22,9 +22,6 @@ function isStaff(member) {
|
|||||||
|
|
||||||
// --- TEXT PROCESSING ---
|
// --- TEXT PROCESSING ---
|
||||||
|
|
||||||
const BLOCK_TAG_REGEX =
|
|
||||||
/<\/(p|div|li|h[1-6]|tr|table|section|article|blockquote)>/gi;
|
|
||||||
|
|
||||||
function escapeRegex(str) {
|
function escapeRegex(str) {
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
@@ -40,28 +37,6 @@ function escapeHtml(str) {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeHtmlEntities(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return str
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
.replace(/ /g, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function htmlToTextWithBlocks(html) {
|
|
||||||
return decodeHtmlEntities(
|
|
||||||
html
|
|
||||||
.replace(/\r\n/g, '\n')
|
|
||||||
.replace(/<br\s*\/?>/gi, '\n')
|
|
||||||
.replace(BLOCK_TAG_REGEX, '\n\n')
|
|
||||||
.replace(/<(ul|ol)[^>]*>/gi, '\n')
|
|
||||||
.replace(/<[^>]*>?/gm, '')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- EMAIL BODY EXTRACTION ---
|
// --- EMAIL BODY EXTRACTION ---
|
||||||
|
|
||||||
function decodeGmailData(p) {
|
function decodeGmailData(p) {
|
||||||
@@ -277,7 +252,6 @@ module.exports = {
|
|||||||
escapeHtml,
|
escapeHtml,
|
||||||
safeEqual,
|
safeEqual,
|
||||||
isStaff,
|
isStaff,
|
||||||
htmlToTextWithBlocks,
|
|
||||||
getCleanBody,
|
getCleanBody,
|
||||||
stripEmailQuotes,
|
stripEmailQuotes,
|
||||||
stripMobileFooter,
|
stripMobileFooter,
|
||||||
|
|||||||
@@ -2,16 +2,36 @@
|
|||||||
* Ticket action row builder – Close, Claim, Escalate (if tier < 3), Deescalate (if tier >= 2).
|
* Ticket action row builder – Close, Claim, Escalate (if tier < 3), Deescalate (if tier >= 2).
|
||||||
* Used by handlers/buttons.js and handlers/commands.js.
|
* Used by handlers/buttons.js and handlers/commands.js.
|
||||||
*/
|
*/
|
||||||
const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
|
const { ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits } = require('discord.js');
|
||||||
const { CONFIG } = require('../config');
|
const { CONFIG } = require('../config');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* permissionOverwrites for a Discord-originated ticket channel: deny @everyone,
|
||||||
|
* allow the creating user and the staff ping role. Used by the button and
|
||||||
|
* context-menu creation paths (the email/gmail path differs — no Discord
|
||||||
|
* creator — and builds its own overwrites).
|
||||||
|
* @param {import('discord.js').Guild} guild
|
||||||
|
* @param {string} creatorId - Discord user ID of the ticket creator
|
||||||
|
*/
|
||||||
|
function ticketChannelOverwrites(guild, creatorId) {
|
||||||
|
const allow = [
|
||||||
|
PermissionFlagsBits.ViewChannel,
|
||||||
|
PermissionFlagsBits.SendMessages,
|
||||||
|
PermissionFlagsBits.ReadMessageHistory
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||||
|
{ id: creatorId, allow },
|
||||||
|
{ id: CONFIG.ROLE_ID_TO_PING, allow }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the standard ticket action row (Close, Claim, optionally Escalate, optionally Deescalate).
|
* Build the standard ticket action row (Close, Claim, optionally Escalate, optionally Deescalate).
|
||||||
* @param {Object} ticket - Ticket with escalationTier (0, 1, 2) and optionally escalated
|
* @param {Object} ticket - Ticket with escalationTier (0, 1, 2) and optionally escalated
|
||||||
* @param {Object} [options] - { unclaimLabel, unclaimEmoji } for claim button when ticket is claimed
|
|
||||||
* @returns {ActionRowBuilder}
|
* @returns {ActionRowBuilder}
|
||||||
*/
|
*/
|
||||||
function getTicketActionRow(ticket, options = {}) {
|
function getTicketActionRow(ticket) {
|
||||||
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||||
const row = new ActionRowBuilder();
|
const row = new ActionRowBuilder();
|
||||||
|
|
||||||
@@ -23,8 +43,8 @@ function getTicketActionRow(ticket, options = {}) {
|
|||||||
.setStyle(ButtonStyle.Secondary),
|
.setStyle(ButtonStyle.Secondary),
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId('claim_ticket')
|
.setCustomId('claim_ticket')
|
||||||
.setLabel(options.unclaimLabel ?? CONFIG.BUTTON_LABEL_CLAIM)
|
.setLabel(CONFIG.BUTTON_LABEL_CLAIM)
|
||||||
.setEmoji(options.unclaimEmoji ?? CONFIG.BUTTON_EMOJI_CLAIM)
|
.setEmoji(CONFIG.BUTTON_EMOJI_CLAIM)
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -48,4 +68,4 @@ function getTicketActionRow(ticket, options = {}) {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getTicketActionRow };
|
module.exports = { getTicketActionRow, ticketChannelOverwrites };
|
||||||
|
|||||||
Reference in New Issue
Block a user