Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,46 @@
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);
// 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);
});

/**
* 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);
Expand Down
3 changes: 2 additions & 1 deletion locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion locales/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion locales/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions scripts/migrate-db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
12 changes: 11 additions & 1 deletion src/database/drivers/postgres.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
Expand Down
5 changes: 4 additions & 1 deletion src/database/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand Down
18 changes: 14 additions & 4 deletions src/database/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ const path = require('path');
// __dirname = <project>/src/database β†’ ../../data = <project>/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:
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -113,4 +123,4 @@ function parseDatabaseUrl(urlStr) {
);
}

module.exports = { parseDatabaseUrl, DEFAULT_SQLITE_PATH };
module.exports = { parseDatabaseUrl, redactUrl, DEFAULT_SQLITE_PATH };
16 changes: 16 additions & 0 deletions src/events/channelDelete.js
Original file line number Diff line number Diff line change
@@ -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);
},
};
24 changes: 20 additions & 4 deletions src/events/ready.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion src/handlers/eventHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading