From 7f6c362ba426bde90565a0a3bc59abe133509a70 Mon Sep 17 00:00:00 2001 From: Shane Date: Sat, 10 Oct 2026 00:04:25 -0500 Subject: [PATCH 1/6] fix: survive database outages and shut down gracefully - PostgreSQL pool: handle the idle-client 'error' event (it crashed the process on a Postgres restart) and set a 10 s connectionTimeoutMillis. - Event handlers are awaited inside a try/catch, so a failing event such as a DB error in messageCreate no longer becomes an unhandled rejection. - index.js: log unhandled rejections instead of exiting, exit cleanly on an uncaught exception, and on SIGTERM/SIGINT let an in-flight ticket close finish (up to 7 s) before destroying the client and closing the DB pool. - Listen for the client 'error' event. Claude-Session: https://claude.ai/code/session_01Kp1SLbxxAbXw3f42C1qThH --- index.js | 36 ++++++++++++++++++++++++++++++++ src/client.js | 5 +++++ src/database/drivers/postgres.js | 12 ++++++++++- src/handlers/eventHandler.js | 11 +++++++++- src/utils/ticketActions.js | 12 +++++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 5c2d130..5250aa2 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,43 @@ require('dotenv').config({ quiet: true }); const { TicketClient } = require('./src/client'); +const { closeDatabase } = require('./src/database'); +const { waitForPendingCloses } = require('./src/utils/ticketActions'); const client = new TicketClient(); + +// A stray rejection (a Discord API error in a fire-and-forget call, a DB blip in +// a timer) must not take the bot down; Node 24 exits on unhandled rejections by +// default. Log it and carry on. +process.on('unhandledRejection', (err) => { + client.logger.error('[Process] Unhandled promise rejection:', err); +}); + +// A synchronous throw that reached the top leaves the process in an unknown +// state: log it and exit so the service manager restarts a clean process. +process.on('uncaughtException', (err) => { + client.logger.error('[Process] Uncaught exception — exiting:', err); + process.exit(1); +}); + +/** + * Graceful stop for SIGTERM (systemd stop/restart, the dashboard supervisor) and + * SIGINT (Ctrl+C). Lets a ticket close that is mid-flight finish its DB write, + * so the channel and the DB row do not disagree, then disconnects cleanly. + * The supervisor SIGKILLs after 10 s, so the wait stays well below that. + */ +let shuttingDown = false; +async function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + client.logger.info(`[Process] ${signal} received — shutting down…`); + try { await waitForPendingCloses(7_000); } catch { /* best effort */ } + try { await client.destroy(); } catch { /* already disconnected */ } + try { await closeDatabase(); } catch { /* already closed */ } + process.exit(0); +} +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + client.start().catch(err => { console.error('[FATAL] Bot failed to start:', err); process.exit(1); diff --git a/src/client.js b/src/client.js index c459885..087e105 100644 --- a/src/client.js +++ b/src/client.js @@ -112,6 +112,11 @@ class TicketClient extends Client { * bot is up but the ticket flow is closed — see start() and * events/interactionCreate.js. */ this.configPending = []; + + // An 'error' event without a listener is thrown by EventEmitter and would + // crash the process (e.g. a gateway/WebSocket error). discord.js reconnects + // on its own, so logging is enough. + this.on('error', (err) => this.logger.error('[Client] Discord client error:', err)); } async start() { diff --git a/src/database/drivers/postgres.js b/src/database/drivers/postgres.js index 1b61558..cfb23b4 100644 --- a/src/database/drivers/postgres.js +++ b/src/database/drivers/postgres.js @@ -20,7 +20,17 @@ function createPostgresDriver(descriptor) { family: 'standard', async connect() { - pool = new Pool({ ...descriptor.options, max: 10 }); + // connectionTimeoutMillis: without it a connect to an unreachable host can + // hang indefinitely instead of failing the query. + pool = new Pool({ connectionTimeoutMillis: 10_000, ...descriptor.options, max: 10 }); + + // An idle client that loses its connection (Postgres restart, network blip) + // emits 'error' on the pool. Unhandled, that event crashes the process; the + // pool already discards the broken client and reconnects on the next query. + pool.on('error', (err) => { + console.error(`[Database] Idle PostgreSQL client error (pool will reconnect): ${err.message}`); + }); + const client = await pool.connect(); // fail fast on bad credentials/host client.release(); }, diff --git a/src/handlers/eventHandler.js b/src/handlers/eventHandler.js index 28d4a98..1448706 100644 --- a/src/handlers/eventHandler.js +++ b/src/handlers/eventHandler.js @@ -18,7 +18,16 @@ async function loadEvents(client) { continue; } - const handler = (...args) => event.execute(client, ...args); + // discord.js does not await listeners, so a rejected execute() would be an + // unhandled rejection — which terminates the process on Node 24. A failing + // event (e.g. a DB outage during messageCreate) must cost one event, not the bot. + const handler = async (...args) => { + try { + await event.execute(client, ...args); + } catch (err) { + client.logger.error(`[Events] ${event.name} handler failed:`, err); + } + }; if (event.once) { client.once(event.name, handler); } else { diff --git a/src/utils/ticketActions.js b/src/utils/ticketActions.js index 97582da..a420e82 100644 --- a/src/utils/ticketActions.js +++ b/src/utils/ticketActions.js @@ -326,6 +326,17 @@ async function openTicket(client, guild, user, ticketType, answers = []) { // two rating requests). const closingChannels = new Set(); +/** + * Resolve once no close is in progress, or after timeoutMs. Used on shutdown so + * a SIGTERM does not cut a close off between the Discord changes and the DB write. + */ +async function waitForPendingCloses(timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (closingChannels.size > 0 && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 100)); + } +} + async function performClose(client, channel, ticket, closer, reason) { const channelId = ticket?.channel_id ?? channel?.id ?? null; @@ -929,6 +940,7 @@ module.exports = { performClaim, performUnclaim, captureFinalTranscript, + waitForPendingCloses, buildTicketButtons, buildClosedButtons, refreshTicketMessage, From 0903f7fca10a4f1294ef97aefdc18381bb132159 Mon Sep 17 00:00:00 2001 From: Shane Date: Sat, 10 Oct 2026 00:06:12 -0500 Subject: [PATCH 2/6] fix: close ticket rows whose channel is gone A ticket stayed "open" in the DB when its channel was deleted with transcripts disabled, deleted by hand in Discord, or deleted while the bot was offline. Those rows counted against maxTicketOpened forever (locking the user out) and were skipped silently by auto-close and the staff reminder. - captureFinalTranscript closes the row even when createTranscript is off. - New channelDelete event closes the ticket of a deleted channel. - Auto-close and staff-reminder loops close a ticket when Discord answers Unknown Channel (10003); transient fetch errors leave it alone. - On startup, open tickets are reconciled against existing channels. - New locale key messages.orphanedTicketReason (all languages). Claude-Session: https://claude.ai/code/session_01Kp1SLbxxAbXw3f42C1qThH --- locales/de.json | 3 +- locales/en.json | 3 +- locales/es.json | 3 +- locales/fr.json | 3 +- locales/hu.json | 3 +- locales/main.json | 3 +- locales/pl.json | 3 +- locales/pt.json | 3 +- src/events/channelDelete.js | 16 +++++++ src/events/ready.js | 24 ++++++++-- src/utils/ticketActions.js | 44 +++++++++++++++++- tests/orphaned-tickets.test.js | 81 ++++++++++++++++++++++++++++++++++ 12 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 src/events/channelDelete.js create mode 100644 tests/orphaned-tickets.test.js diff --git a/locales/de.json b/locales/de.json index 58eec3a..08d0215 100644 --- a/locales/de.json +++ b/locales/de.json @@ -253,7 +253,8 @@ "feedbackCommentEmpty": "❌ Dein Kommentar darf nicht leer sein.", "feedbackCommentFull": "❌ Diese Bewertung hat bereits die maximale Anzahl an Kommentaren.", "feedbackCommentFailed": "❌ Die Bewertungs-Nachricht konnte nicht aktualisiert werden.", - "feedbackCommentSaved": "✅ Dein Kommentar wurde hinzugefügt." + "feedbackCommentSaved": "✅ Dein Kommentar wurde hinzugefügt.", + "orphanedTicketReason": "Ticket-Kanal existiert nicht mehr" }, "priorities": { "low": "🟢 Niedrig", diff --git a/locales/en.json b/locales/en.json index 1375d76..68843d3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -253,7 +253,8 @@ "feedbackCommentEmpty": "❌ Your comment cannot be empty.", "feedbackCommentFull": "❌ This rating already carries the maximum number of comments.", "feedbackCommentFailed": "❌ The rating message could not be updated.", - "feedbackCommentSaved": "✅ Your comment has been added." + "feedbackCommentSaved": "✅ Your comment has been added.", + "orphanedTicketReason": "Ticket channel no longer exists" }, "priorities": { "low": "🟢 Low", diff --git a/locales/es.json b/locales/es.json index f964efb..e15aa24 100644 --- a/locales/es.json +++ b/locales/es.json @@ -253,7 +253,8 @@ "feedbackCommentEmpty": "❌ Tu comentario no puede estar vacío.", "feedbackCommentFull": "❌ Esta valoración ya tiene el número máximo de comentarios.", "feedbackCommentFailed": "❌ No se pudo actualizar el mensaje de la valoración.", - "feedbackCommentSaved": "✅ Tu comentario se ha añadido." + "feedbackCommentSaved": "✅ Tu comentario se ha añadido.", + "orphanedTicketReason": "El canal del ticket ya no existe" }, "priorities": { "low": "🟢 Baja", diff --git a/locales/fr.json b/locales/fr.json index 2f2ee3d..e8cbf64 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -253,7 +253,8 @@ "feedbackCommentEmpty": "❌ Votre commentaire ne peut pas etre vide.", "feedbackCommentFull": "❌ Cet avis a déjà atteint le nombre maximal de commentaires.", "feedbackCommentFailed": "❌ Le message d'avis n'a pas pu être mis à jour.", - "feedbackCommentSaved": "✅ Votre commentaire a été ajouté." + "feedbackCommentSaved": "✅ Votre commentaire a été ajouté.", + "orphanedTicketReason": "Le salon du ticket n'existe plus" }, "priorities": { "low": "🟢 Basse", diff --git a/locales/hu.json b/locales/hu.json index 17be013..593e21c 100644 --- a/locales/hu.json +++ b/locales/hu.json @@ -253,7 +253,8 @@ "feedbackCommentEmpty": "❌ A megjegyzés nem lehet üres.", "feedbackCommentFull": "❌ Ez az értékelés már elérte a megjegyzések maximális számát.", "feedbackCommentFailed": "❌ Az értékelés üzenetét nem sikerült frissíteni.", - "feedbackCommentSaved": "✅ A megjegyzésed hozzáadva." + "feedbackCommentSaved": "✅ A megjegyzésed hozzáadva.", + "orphanedTicketReason": "A ticket csatornája már nem létezik" }, "priorities": { "low": "🟢 Alacsony", diff --git a/locales/main.json b/locales/main.json index 2d3e0f8..daf32ff 100644 --- a/locales/main.json +++ b/locales/main.json @@ -253,7 +253,8 @@ "feedbackCommentEmpty": "❌ Your comment cannot be empty.", "feedbackCommentFull": "❌ This rating already carries the maximum number of comments.", "feedbackCommentFailed": "❌ The rating message could not be updated.", - "feedbackCommentSaved": "✅ Your comment has been added." + "feedbackCommentSaved": "✅ Your comment has been added.", + "orphanedTicketReason": "Ticket channel no longer exists" }, "priorities": { "low": "🟢 Low", diff --git a/locales/pl.json b/locales/pl.json index 12d2a22..6529a3a 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -253,7 +253,8 @@ "feedbackCommentEmpty": "❌ Twój komentarz nie może być pusty.", "feedbackCommentFull": "❌ Ta opinia ma już maksymalną liczbę komentarzy.", "feedbackCommentFailed": "❌ Nie udało się zaktualizować wiadomości z opinią.", - "feedbackCommentSaved": "✅ Twój komentarz został dodany." + "feedbackCommentSaved": "✅ Twój komentarz został dodany.", + "orphanedTicketReason": "Kanał zgłoszenia już nie istnieje" }, "priorities": { "low": "🟢 Niski", diff --git a/locales/pt.json b/locales/pt.json index b5bc374..9a8c458 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -253,7 +253,8 @@ "feedbackCommentEmpty": "❌ O teu comentário não pode estar vazio.", "feedbackCommentFull": "❌ Esta avaliação já tem o número máximo de comentários.", "feedbackCommentFailed": "❌ Não foi possível atualizar a mensagem da avaliação.", - "feedbackCommentSaved": "✅ O teu comentario foi adicionado." + "feedbackCommentSaved": "✅ O teu comentario foi adicionado.", + "orphanedTicketReason": "O canal do ticket já não existe" }, "priorities": { "low": "🟢 Baixa", diff --git a/src/events/channelDelete.js b/src/events/channelDelete.js new file mode 100644 index 0000000..096f654 --- /dev/null +++ b/src/events/channelDelete.js @@ -0,0 +1,16 @@ +const { closeOrphanedTicket } = require('../utils/ticketActions'); + +/** + * A ticket channel deleted directly in Discord (not via the Delete button) + * leaves its DB row "open". That row would count against the creator's + * maxTicketOpened forever and be skipped silently by auto-close and the staff + * reminder, so close it here. + */ +module.exports = { + name: 'channelDelete', + + async execute(client, channel) { + if (!client.db || !channel?.guild) return; + await closeOrphanedTicket(client, channel.id); + }, +}; diff --git a/src/events/ready.js b/src/events/ready.js index dab05e8..f1be5c2 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -1,7 +1,7 @@ const { ActivityType } = require('discord.js'); -const { getInactiveTickets, getTicketsNeedingStaffReminder, setStaffReminded, getStats, getPanelMessage, deletePanelMessage } = require('../database'); +const { getInactiveTickets, getTicketsNeedingStaffReminder, setStaffReminded, getStats, getPanelMessage, deletePanelMessage, getAllOpenTickets } = require('../database'); const { buildTicketPanel } = require('../utils/panel'); -const { performClose } = require('../utils/ticketActions'); +const { performClose, fetchTicketChannel } = require('../utils/ticketActions'); const { registerBotBridge } = require('../dashboard/botBridge'); const { checkBotPermissions } = require('../utils/permissionCheck'); const { startUpdateNotifier } = require('../utils/updateNotice'); @@ -57,6 +57,10 @@ module.exports = { } } + // ── Orphaned tickets ────────────────────────────────────────────────────── + // Channels deleted while the bot was offline never fired channelDelete. + reconcileOpenTickets(client); + // ── Auto-close loop ─────────────────────────────────────────────────────── const autoCfg = client.config.autoClose; if (autoCfg?.enabled) { @@ -139,6 +143,18 @@ async function refreshTicketPanel(client) { .catch(err => client.logger.warn(`[Panel] Failed to refresh panel: ${err.message}`)); } +// ─── Orphaned tickets ───────────────────────────────────────────────────────── + +/** Close every "open" ticket whose channel no longer exists (see fetchTicketChannel). */ +async function reconcileOpenTickets(client) { + try { + const tickets = await getAllOpenTickets(process.env.GUILD_ID); + for (const ticket of tickets) await fetchTicketChannel(client, ticket.channel_id); + } catch (err) { + client.logger.error('[Tickets] Open-ticket reconciliation failed:', err); + } +} + // ─── Auto-close ─────────────────────────────────────────────────────────────── async function runAutoClose(client, thresholdMs, warnMs, excludeClaimed) { @@ -160,7 +176,7 @@ async function runAutoClose(client, thresholdMs, warnMs, excludeClaimed) { for (const ticket of tickets) { try { - const channel = await client.channels.fetch(ticket.channel_id).catch(() => null); + const channel = await fetchTicketChannel(client, ticket.channel_id); if (!channel) continue; const idleMs = Date.now() - ticket.last_activity; @@ -242,7 +258,7 @@ async function runStaffReminder(client, reminderMs) { for (const ticket of tickets) { try { - const channel = await client.channels.fetch(ticket.channel_id).catch(() => null); + const channel = await fetchTicketChannel(client, ticket.channel_id); if (!channel) continue; const hoursIdle = Math.floor((Date.now() - ticket.last_activity) / 3_600_000); diff --git a/src/utils/ticketActions.js b/src/utils/ticketActions.js index a420e82..f8d31a9 100644 --- a/src/utils/ticketActions.js +++ b/src/utils/ticketActions.js @@ -822,7 +822,12 @@ async function collectAttachments(channel, client) { */ async function captureFinalTranscript(client, channel, ticket, deleter) { const cfg = client.config.closeOption ?? {}; - if (!cfg.createTranscript) return null; + if (!cfg.createTranscript) { + // No transcript wanted, but the row must still be closed: an "open" row for a + // deleted channel counts against the creator's maxTicketOpened forever. + await db.closeTicket(channel.id, deleter?.id ?? client.user.id, ticket.close_reason ?? null, null); + return null; + } // Treat the delete as the close moment (the ticket is still open here), so the // transcript header shows the closer/time. @@ -873,6 +878,41 @@ async function captureFinalTranscript(client, channel, ticket, deleter) { return transcriptUrl; } +// Discord API error code for a channel that no longer exists. +const UNKNOWN_CHANNEL = 10003; + +/** + * Close the DB row of a ticket whose channel is gone (deleted in Discord, or + * while the bot was offline). No-op for unknown or already closed tickets. + * + * @returns {Promise} true if a row was closed + */ +async function closeOrphanedTicket(client, channelId) { + const ticket = await db.getTicketByChannel(channelId); + if (!ticket || ticket.status !== 'open') return false; + + await db.closeTicket(channelId, client.user.id, client.t('messages.orphanedTicketReason'), null); + client.autoCloseWarned?.delete(channelId); + client.logger.info(`[Tickets] Ticket #${ticket.id} closed: its channel no longer exists.`); + return true; +} + +/** + * Fetch a ticket's channel for the background loops. If Discord says the channel + * does not exist, the ticket is closed as orphaned; any other failure (network, + * rate limit) is treated as transient and leaves the ticket alone. + * + * @returns {Promise} + */ +async function fetchTicketChannel(client, channelId) { + try { + return await client.channels.fetch(channelId); + } catch (err) { + if (err?.code === UNKNOWN_CHANNEL) await closeOrphanedTicket(client, channelId); + return null; + } +} + function buildRatingRow(ticketId) { return new ActionRowBuilder().addComponents( [1, 2, 3, 4, 5].map(n => @@ -941,6 +981,8 @@ module.exports = { performUnclaim, captureFinalTranscript, waitForPendingCloses, + closeOrphanedTicket, + fetchTicketChannel, buildTicketButtons, buildClosedButtons, refreshTicketMessage, diff --git a/tests/orphaned-tickets.test.js b/tests/orphaned-tickets.test.js new file mode 100644 index 0000000..319ef88 --- /dev/null +++ b/tests/orphaned-tickets.test.js @@ -0,0 +1,81 @@ +/** + * Tickets whose channel is gone must not stay "open" in the DB: an open row + * counts against maxTicketOpened forever and is skipped by the background loops. + */ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +process.env.DATABASE_URL = 'sqlite::memory:'; +const db = require('../src/database'); +const { + closeOrphanedTicket, + fetchTicketChannel, + captureFinalTranscript, +} = require('../src/utils/ticketActions'); + +const GUILD = 'g1'; +let nextChannel = 1; + +function fakeClient(overrides = {}) { + return { + user: { id: 'bot' }, + config: { closeOption: { createTranscript: false } }, + t: (key) => key, + logger: { info() {}, warn() {}, error() {} }, + ...overrides, + }; +} + +async function newOpenTicket() { + const channelId = `c${nextChannel++}`; + await db.createTicket({ channelId, guildId: GUILD, creatorId: 'u1', type: 'support' }); + return channelId; +} + +test.before(async () => { await db.initDatabase(); }); +test.after(async () => { await db.closeDatabase(); }); + +test('closeOrphanedTicket closes an open ticket and frees the user slot', async () => { + const channelId = await newOpenTicket(); + assert.equal((await db.getOpenTicketsByUser('u1', GUILD)).some(t => t.channel_id === channelId), true); + + assert.equal(await closeOrphanedTicket(fakeClient(), channelId), true); + + const row = await db.getTicketByChannel(channelId); + assert.equal(row.status, 'closed'); + assert.equal(row.closed_by, 'bot'); + assert.equal((await db.getOpenTicketsByUser('u1', GUILD)).some(t => t.channel_id === channelId), false); +}); + +test('closeOrphanedTicket ignores unknown and already closed channels', async () => { + assert.equal(await closeOrphanedTicket(fakeClient(), 'does-not-exist'), false); + const channelId = await newOpenTicket(); + await closeOrphanedTicket(fakeClient(), channelId); + assert.equal(await closeOrphanedTicket(fakeClient(), channelId), false); +}); + +test('fetchTicketChannel closes the ticket only when Discord reports Unknown Channel', async () => { + const gone = await newOpenTicket(); + const flaky = await newOpenTicket(); + + const unknown = fakeClient({ channels: { fetch: async () => { throw Object.assign(new Error('Unknown Channel'), { code: 10003 }); } } }); + const transient = fakeClient({ channels: { fetch: async () => { throw Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }); } } }); + + assert.equal(await fetchTicketChannel(unknown, gone), null); + assert.equal(await fetchTicketChannel(transient, flaky), null); + + assert.equal((await db.getTicketByChannel(gone)).status, 'closed'); + assert.equal((await db.getTicketByChannel(flaky)).status, 'open'); +}); + +test('captureFinalTranscript closes the row even when transcripts are disabled', async () => { + const channelId = await newOpenTicket(); + const ticket = await db.getTicketByChannel(channelId); + + const url = await captureFinalTranscript(fakeClient(), { id: channelId }, ticket, { id: 'staff1' }); + + assert.equal(url, null); + const row = await db.getTicketByChannel(channelId); + assert.equal(row.status, 'closed'); + assert.equal(row.closed_by, 'staff1'); +}); From a26adbaa82b6ddc31f8d0c75ae34182811a067e6 Mon Sep 17 00:00:00 2001 From: Shane Date: Sat, 10 Oct 2026 00:22:26 -0500 Subject: [PATCH 3/6] fix: keep an earlier transcript when a ticket is closed without a new one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closeTicket() overwrote the transcript column with NULL whenever no new transcript was passed. A ticket closed (transcript stored), reopened and then orphaned — channel deleted by hand or while the bot was offline — lost its original transcript, including in the dashboard. The column is now only replaced when a new transcript is given. Claude-Session: https://claude.ai/code/session_01Kp1SLbxxAbXw3f42C1qThH --- src/database/index.js | 5 ++++- tests/orphaned-tickets.test.js | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/database/index.js b/src/database/index.js index 9d9fd4f..37468d9 100644 --- a/src/database/index.js +++ b/src/database/index.js @@ -219,9 +219,12 @@ async function getOpenTicketsByUser(userId, guildId) { async function closeTicket(channelId, closedBy, reason, transcript) { const now = Date.now(); + // COALESCE: a close without a new transcript (transcripts disabled, or the + // channel is already gone) keeps the one stored by an earlier close instead of + // wiping it — reopenTicket() leaves that column untouched. return activeDriver.run( `UPDATE tickets - SET status = 'closed', closed_by = ?, closed_at = ?, close_reason = ?, transcript = ? + SET status = 'closed', closed_by = ?, closed_at = ?, close_reason = ?, transcript = COALESCE(?, transcript) WHERE channel_id = ?`, [closedBy, now, reason, transcript, channelId], ); diff --git a/tests/orphaned-tickets.test.js b/tests/orphaned-tickets.test.js index 319ef88..74cfd0e 100644 --- a/tests/orphaned-tickets.test.js +++ b/tests/orphaned-tickets.test.js @@ -79,3 +79,15 @@ test('captureFinalTranscript closes the row even when transcripts are disabled', assert.equal(row.status, 'closed'); assert.equal(row.closed_by, 'staff1'); }); + +test('closing an orphan keeps the transcript of an earlier close', async () => { + const channelId = await newOpenTicket(); + await db.closeTicket(channelId, 'staff1', 'done', 'first close'); + await db.reopenTicket(channelId); + + await closeOrphanedTicket(fakeClient(), channelId); + + const row = await db.getTicketByChannel(channelId); + assert.equal(row.status, 'closed'); + assert.equal(row.transcript, 'first close'); +}); From 86ccfbe0ba4bf41bf5c1892de1d27868079802e1 Mon Sep 17 00:00:00 2001 From: Shane Date: Sat, 10 Oct 2026 00:26:59 -0500 Subject: [PATCH 4/6] fix: mask database passwords in logs and flush the crash log line - Database URLs are logged with the password masked: malformed-URL errors and the migrate script printed them verbatim. Masking runs up to the last "@", so an unencoded "@" in the password does not leak its tail. - uncaughtException sets the exit code and exits after 200 ms, so the log line can drain from a piped stdout (dashboard supervisor). Claude-Session: https://claude.ai/code/session_01Kp1SLbxxAbXw3f42C1qThH --- index.js | 5 ++++- scripts/migrate-db.js | 6 +++--- src/database/url.js | 18 ++++++++++++++---- tests/url-redaction.test.js | 25 +++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 tests/url-redaction.test.js diff --git a/index.js b/index.js index 5250aa2..09d33d2 100644 --- a/index.js +++ b/index.js @@ -16,7 +16,10 @@ process.on('unhandledRejection', (err) => { // state: log it and exit so the service manager restarts a clean process. process.on('uncaughtException', (err) => { client.logger.error('[Process] Uncaught exception — exiting:', err); - process.exit(1); + // stdout is a pipe under the dashboard supervisor (async on Linux): give the + // log line a moment to drain instead of exiting on the spot. + process.exitCode = 1; + setTimeout(() => process.exit(1), 200); }); /** diff --git a/scripts/migrate-db.js b/scripts/migrate-db.js index 378f77d..9d61aed 100644 --- a/scripts/migrate-db.js +++ b/scripts/migrate-db.js @@ -17,7 +17,7 @@ require('dotenv').config({ quiet: true }); const path = require('path'); const { openDatabase } = require('../src/database'); -const { DEFAULT_SQLITE_PATH } = require('../src/database/url'); +const { DEFAULT_SQLITE_PATH, redactUrl } = require('../src/database/url'); // ── Args ──────────────────────────────────────────────────────────────────── function parseArgs(argv) { @@ -66,8 +66,8 @@ async function rowCount(driver, table) { process.exit(1); } - console.log(`[Migrate] Source: ${sourceUrl}`); - console.log(`[Migrate] Target: ${targetUrl}`); + console.log(`[Migrate] Source: ${redactUrl(sourceUrl)}`); + console.log(`[Migrate] Target: ${redactUrl(targetUrl)}`); let source, target; try { diff --git a/src/database/url.js b/src/database/url.js index 8db311f..1a7fa80 100644 --- a/src/database/url.js +++ b/src/database/url.js @@ -23,6 +23,16 @@ const DEFAULT_SQLITE_PATH = path.resolve(__dirname, '../../data/tickets.db'); * | { client: 'postgres', options: object }} * @throws {Error} on an unsupported scheme or an unparseable URL */ +/** + * The URL with its password masked, for error messages and logs: a malformed + * DATABASE_URL must not end up in the journal with the credentials in it. + */ +function redactUrl(urlStr) { + // Greedy up to the LAST "@": an unencoded "@" inside the password must not + // leave its tail visible. Over-masking a stray "@" later in the URL is fine. + return String(urlStr ?? '').replace(/(\/\/[^:/@]*:).*@/, '$1***@'); +} + function parseDatabaseUrl(urlStr) { const raw = (urlStr ?? '').trim(); @@ -35,7 +45,7 @@ function parseDatabaseUrl(urlStr) { if (!schemeMatch) { throw new Error( `DATABASE_URL is set but has no scheme. Expected e.g. "postgres://…", ` - + `"mysql://…" or "sqlite:./data/tickets.db". Got: "${raw}".` + + `"mysql://…" or "sqlite:./data/tickets.db". Got: "${redactUrl(raw)}".` ); } const scheme = schemeMatch[1].toLowerCase(); @@ -64,12 +74,12 @@ function parseDatabaseUrl(urlStr) { try { parsed = new URL(raw); } catch { - throw new Error(`DATABASE_URL could not be parsed as a URL: "${raw}".`); + throw new Error(`DATABASE_URL could not be parsed as a URL: "${redactUrl(raw)}".`); } const database = decodeURIComponent(parsed.pathname.replace(/^\//, '')); if (!database) { - throw new Error(`DATABASE_URL is missing the database name (the path after the host): "${raw}".`); + throw new Error(`DATABASE_URL is missing the database name (the path after the host): "${redactUrl(raw)}".`); } const sslRequested = @@ -113,4 +123,4 @@ function parseDatabaseUrl(urlStr) { ); } -module.exports = { parseDatabaseUrl, DEFAULT_SQLITE_PATH }; +module.exports = { parseDatabaseUrl, redactUrl, DEFAULT_SQLITE_PATH }; diff --git a/tests/url-redaction.test.js b/tests/url-redaction.test.js new file mode 100644 index 0000000..dd30bf0 --- /dev/null +++ b/tests/url-redaction.test.js @@ -0,0 +1,25 @@ +/** + * Database URLs never reach logs or error messages with their password. + */ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { redactUrl, parseDatabaseUrl } = require('../src/database/url'); + +test('redactUrl masks the password and nothing else', () => { + assert.equal(redactUrl('postgres://bot:s3cr%40t@127.0.0.1:5432/tickets'), 'postgres://bot:***@127.0.0.1:5432/tickets'); + assert.equal(redactUrl('postgres://bot:pa/ss@host/db'), 'postgres://bot:***@host/db'); + assert.equal(redactUrl('postgres://host:5432/db'), 'postgres://host:5432/db'); + assert.equal(redactUrl('sqlite:./data/tickets.db'), 'sqlite:./data/tickets.db'); +}); + +test('redactUrl masks a password containing an unencoded @', () => { + assert.equal(redactUrl('postgres://bot:p@ss@host/db'), 'postgres://bot:***@host/db'); +}); + +test('a malformed DATABASE_URL error does not contain the password', () => { + assert.throws( + () => parseDatabaseUrl('postgres://bot:hunter2@'), + (err) => !err.message.includes('hunter2'), + ); +}); From 28aa2562230245448f43c33de8368946f2110de0 Mon Sep 17 00:00:00 2001 From: Shane Date: Sat, 10 Oct 2026 00:27:15 -0500 Subject: [PATCH 5/6] docs: changelog entry for the robustness fixes Claude-Session: https://claude.ai/code/session_01Kp1SLbxxAbXw3f42C1qThH --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3136fc1..c9bef59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > is automatically lifted to the top of the GitHub Release notes by > `.github/workflows/release.yml`. Keep this file up to date before tagging. +## [Unreleased] + +### Fixed + +- **A PostgreSQL restart no longer crashes the bot.** The pool now handles the + idle-client `error` event and has a 10 s `connectionTimeoutMillis`. +- **A failing event handler no longer takes the process down.** Event handlers + are awaited inside a try/catch, unhandled rejections are logged instead of + ending the process, and the client `error` event has a listener. +- **Graceful shutdown.** On SIGTERM/SIGINT the bot lets an in-flight ticket close + finish (up to 7 s), then destroys the client and closes the database pool. +- **Tickets no longer stay "open" when their channel is gone.** Deleting a + ticket with transcripts disabled, deleting its channel by hand, or deleting it + while the bot was offline left the row open, which counted against + `maxTicketOpened` forever. The row is now closed on delete, on `channelDelete`, + when the background loops get *Unknown Channel*, and by a check at startup. +- **Closing without a new transcript keeps the stored one** instead of + overwriting it with nothing. +- **Database passwords are masked** in malformed-URL errors and in the + `db:migrate` output. + +### Added + +- Locale key `messages.orphanedTicketReason` in every language. + ## [2.19.3] - 2026-10-23 ### Security From 51d734dd55a4dbdca7959b42fdda49eb9567e56f Mon Sep 17 00:00:00 2001 From: Shane Date: Fri, 23 Oct 2026 13:24:15 -0500 Subject: [PATCH 6/6] style: move redactUrl above the parseDatabaseUrl doc comment Review feedback: the JSDoc block belonged to parseDatabaseUrl but ended up attached to redactUrl. Claude-Session: https://claude.ai/code/session_01HZPZxD3edzJNFFsiWsHYY1 --- src/database/url.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/database/url.js b/src/database/url.js index 1a7fa80..8839a30 100644 --- a/src/database/url.js +++ b/src/database/url.js @@ -3,6 +3,16 @@ const path = require('path'); // __dirname = /src/database → ../../data = /data ✓ const DEFAULT_SQLITE_PATH = path.resolve(__dirname, '../../data/tickets.db'); +/** + * The URL with its password masked, for error messages and logs: a malformed + * DATABASE_URL must not end up in the journal with the credentials in it. + */ +function redactUrl(urlStr) { + // Greedy up to the LAST "@": an unencoded "@" inside the password must not + // leave its tail visible. Over-masking a stray "@" later in the URL is fine. + return String(urlStr ?? '').replace(/(\/\/[^:/@]*:).*@/, '$1***@'); +} + /** * Parse the DATABASE_URL environment variable into a normalized connection * descriptor. The bot supports three engines: @@ -23,16 +33,6 @@ const DEFAULT_SQLITE_PATH = path.resolve(__dirname, '../../data/tickets.db'); * | { client: 'postgres', options: object }} * @throws {Error} on an unsupported scheme or an unparseable URL */ -/** - * The URL with its password masked, for error messages and logs: a malformed - * DATABASE_URL must not end up in the journal with the credentials in it. - */ -function redactUrl(urlStr) { - // Greedy up to the LAST "@": an unencoded "@" inside the password must not - // leave its tail visible. Over-masking a stray "@" later in the URL is fine. - return String(urlStr ?? '').replace(/(\/\/[^:/@]*:).*@/, '$1***@'); -} - function parseDatabaseUrl(urlStr) { const raw = (urlStr ?? '').trim();