From 7dfb4035b708b7052bef29cc635ac64658aafac5 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 30 Aug 2026 14:03:12 +0700 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20persistent=20voice=20auto-reco?= =?UTF-8?q?nnect=20=E2=80=94=20rejoin=20same=20channel=20after=20restart/r?= =?UTF-8?q?eboot=20or=20unexpected=20drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-30_voice-autoreconnect-persistent-spec.md | 66 +++++ .../0020_add_voice_auto_reconnect.sql | 19 ++ .../drizzle/migrations/meta/_journal.json | 7 + services/discord-gateway/src/app/bootstrap.ts | 11 + .../modules/command-handler/voice.handler.ts | 10 +- .../voice-recording/voiceController.ts | 239 +++++++++++++++++- .../src/shared/database/schema.ts | 21 ++ .../shared/database/voiceAutoReconnectRepo.ts | 59 +++++ 8 files changed, 422 insertions(+), 10 deletions(-) create mode 100644 .hermes/plans/2026-08-30_voice-autoreconnect-persistent-spec.md create mode 100644 services/discord-gateway/drizzle/migrations/0020_add_voice_auto_reconnect.sql create mode 100644 services/discord-gateway/src/shared/database/voiceAutoReconnectRepo.ts diff --git a/.hermes/plans/2026-08-30_voice-autoreconnect-persistent-spec.md b/.hermes/plans/2026-08-30_voice-autoreconnect-persistent-spec.md new file mode 100644 index 00000000..c97ee709 --- /dev/null +++ b/.hermes/plans/2026-08-30_voice-autoreconnect-persistent-spec.md @@ -0,0 +1,66 @@ +# Spec: Voice auto-reconnect (persistent state + rejoin on drop) + +## Goal +Setelah `VoiceController.connect()` berhasil, state "sedang merekam di /" +disimpan di Postgres. Kalau gateway restart/reboot, atau koneksi voice drop tidak +disengaja (dikeluarkan/moved/server restart), gateway otomatis join ulang ke channel +yang sama. + +## Requirement mapping (user's ask) +- "autoreconnect ke channel yg sama jika server restart atau reboot" → reconnect on + startup (ready handler) + keep DB record across graceful shutdown. +- "state nya persistent di db" → `voice_auto_reconnect` table. +- "rejoin jika tidak sengaja dikeluarkan" → watchdog on `Disconnected`/`Destroyed` + (kick / moved / voice server restart) → full rejoin with backoff. +- Manual leave (`/voice disconnect`, dashboard disconnect) MUST NOT rejoin. + +## Design decisions +1. **New table** `voice_auto_reconnect` (dedicated, not `ui_state`): + - `guild_id` text PK + - `channel_id` text NOT NULL + - `channel_name` text + - `connected_at` bigint epoch-ms + - `updated_at` bigint epoch-ms + DAO: `voiceAutoReconnectRepo.ts` — `upsert(record)`, `list()`, `delete(guildId)`. +2. **Write on connect**: `VoiceController.connect()` → after `startRecording` success → + `upsert`. IDEMPOTENT (upsert per guild). +3. **Clear on manual leave**: `handleVoiceDisconnect` (all) + `handleVoiceDisconnectGuild` + pass `clearPersisted: true`. Graceful shutdown `disconnect()` keeps the record. +4. **Rejoin on startup**: bootstrap `ready` → `await voiceController.autoReconnect()` + (list persisted → connect each, non-fatal on failure). +5. **Rejoin on unexpected drop**: monitor per-connection; on `Disconnected`/`Destroyed` + with `!intentional` → schedule rejoin `connect(guildId, persistedChannelId)` with + backoff (min 2s, max 30s, max 5 attempts). Track `rejoinAttempts`, reset on success. +6. **Intentional flag**: `disconnectGuild(guildId, { clearPersisted?, intentional? })`. + - shutdown `disconnect()` → `{ intentional: true, clearPersisted: false }`. + - manual `disconnect()` (dashboard) → `{ clearPersisted: true }`, sets intentional. + - manual `disconnectGuild` → `{ clearPersisted: true }`, sets intentional. + The monitor checks `intentional` before rejoin; `clearPersisted` only deletes the row. + +## Files touched +- `services/discord-gateway/src/shared/database/schema.ts` — add `pgVoiceAutoReconnectTable` + + types. +- `services/discord-gateway/src/shared/database/voiceAutoReconnectRepo.ts` (NEW) — DAO. +- `services/discord-gateway/src/modules/voice-recording/voiceController.ts` — upsert on + connect; monitor + rejoin; `autoReconnect()`; `disconnect/disconnectGuild` opts. +- `services/discord-gateway/src/modules/command-handler/voice.handler.ts` — manual + disconnect/disconnectGuild pass `clearPersisted: true`. +- `services/discord-gateway/src/app/bootstrap.ts` — call `voiceController.autoReconnect()` + in `ready`. +- `services/discord-gateway/drizzle/migrations/0020_add_voice_auto_reconnect.sql` + + `meta/_journal.json` entry (apply manually per gmw-ops). + +## Edge cases +- Channel deleted / guild lost while persisted → `connect()` throws (channel not found) + → log + delete persisted row (don't retry forever). +- Rejoin attempts exhausted → keep row (so next restart retries) + log. +- Multiple guilds: per-guild monitor, per-guild persisted row. +- Graceful shutdown order: shutdown sets intentional=true (so no rejoin during teardown) + but keeps row. + +## Verification +- `pnpm typecheck && pnpm build && pnpm lint` in `services/discord-gateway`. +- Apply migration `0020` manually; verify table exists. +- CI `Build & Deploy (Nix)` green; gateway deploy lands. +- Manual: connect via dashboard → check `voice_auto_reconnect` row; simulate drop → + confirm rejoin; manual disconnect → row cleared. \ No newline at end of file diff --git a/services/discord-gateway/drizzle/migrations/0020_add_voice_auto_reconnect.sql b/services/discord-gateway/drizzle/migrations/0020_add_voice_auto_reconnect.sql new file mode 100644 index 00000000..bf9c0092 --- /dev/null +++ b/services/discord-gateway/drizzle/migrations/0020_add_voice_auto_reconnect.sql @@ -0,0 +1,19 @@ +-- Voice auto-reconnect: persist desired voice state per guild. +-- The gateway rejoins the same channel after restart/reboot and after an +-- unexpected voice drop (kick / server move / voice server restart). +-- Idempotent: skips if the table already exists. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'voice_auto_reconnect' + ) THEN + CREATE TABLE "voice_auto_reconnect" ( + "guild_id" text PRIMARY KEY NOT NULL, + "channel_id" text NOT NULL, + "channel_name" text, + "connected_at" bigint NOT NULL, + "updated_at" bigint NOT NULL + ); + END IF; +END $$; \ No newline at end of file diff --git a/services/discord-gateway/drizzle/migrations/meta/_journal.json b/services/discord-gateway/drizzle/migrations/meta/_journal.json index f87d5c5f..6291143e 100644 --- a/services/discord-gateway/drizzle/migrations/meta/_journal.json +++ b/services/discord-gateway/drizzle/migrations/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1787830000000, "tag": "0019_rename_server_name_to_server_nick", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1787860000000, + "tag": "0020_add_voice_auto_reconnect", + "breakpoints": true } ] } \ No newline at end of file diff --git a/services/discord-gateway/src/app/bootstrap.ts b/services/discord-gateway/src/app/bootstrap.ts index 48581d7c..75fc6bf6 100644 --- a/services/discord-gateway/src/app/bootstrap.ts +++ b/services/discord-gateway/src/app/bootstrap.ts @@ -274,6 +274,17 @@ export async function initializeDiscordGateway() { commandHandler.start(client, voiceController); logger.info("Command handler started"); + // Rejoin persisted voice channels (auto-reconnect on restart/reboot). + // Non-fatal: failures are logged inside autoReconnect. + void voiceController + .autoReconnect() + .catch((err) => + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + "Voice auto-reconnect on startup failed", + ), + ); + // Start retention cleanup scheduler startRetentionCleanup(); // Start weekly moderation digest (public, automated) diff --git a/services/discord-gateway/src/modules/command-handler/voice.handler.ts b/services/discord-gateway/src/modules/command-handler/voice.handler.ts index 2bca7674..5ec441d1 100644 --- a/services/discord-gateway/src/modules/command-handler/voice.handler.ts +++ b/services/discord-gateway/src/modules/command-handler/voice.handler.ts @@ -67,7 +67,10 @@ export class VoiceHandler { }; } - const status = await this.voiceController.disconnect(); + const status = await this.voiceController.disconnect({ + clearPersisted: true, + intentional: true, + }); return { id: cmd.id, success: true, data: status }; } @@ -93,7 +96,10 @@ export class VoiceHandler { }; } - await this.voiceController.disconnectGuild(guildId); + await this.voiceController.disconnectGuild(guildId, { + clearPersisted: true, + intentional: true, + }); const status = this.voiceController.getStatus(); return { id: cmd.id, success: true, data: status }; } diff --git a/services/discord-gateway/src/modules/voice-recording/voiceController.ts b/services/discord-gateway/src/modules/voice-recording/voiceController.ts index c876749c..a8649871 100644 --- a/services/discord-gateway/src/modules/voice-recording/voiceController.ts +++ b/services/discord-gateway/src/modules/voice-recording/voiceController.ts @@ -1,5 +1,11 @@ import type { VoiceConnection } from "@discordjs/voice"; +import { VoiceConnectionStatus } from "@discordjs/voice"; import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13"; +import { + deleteVoiceAutoReconnect, + listVoiceAutoReconnects, + upsertVoiceAutoReconnect, +} from "@/shared/database/voiceAutoReconnectRepo.js"; import { AppError } from "@/shared/errors/index"; import { createChildLogger } from "@/shared/logger/index"; import { discordPlayer } from "./player.js"; @@ -26,18 +32,37 @@ export interface VoiceStatus { connections: GuildVoiceState[]; } +export interface DisconnectOptions { + /** Clear the persisted auto-reconnect row (explicit user leave). */ + clearPersisted?: boolean; + /** Mark the disconnect as intentional so the rejoin monitor stops. */ + intentional?: boolean; +} + +const REJOIN_MAX_ATTEMPTS = 5; +const REJOIN_BASE_DELAY_MS = 2_000; +const REJOIN_MAX_DELAY_MS = 30_000; + // ─── VoiceController ───────────────────────────────────────────────────── export class VoiceController { private connections = new Map(); private connecting = new Set(); + /** Guilds whose disconnect was intentional (user leave / shutdown). */ + private intentionalGuilds = new Set(); + /** Last known desired voice state, kept independent of live connection map. */ + private rejoinState = new Map(); + /** Current live connection per guild — for stale-callback invalidation. */ + private liveConnections = new Map(); + /** Per-guild rejoin attempt counters + timers. */ + private rejoinAttempts = new Map(); + private rejoinTimers = new Map(); constructor(private readonly client: Client) {} getStatus(): VoiceStatus { logger.debug("getStatus called"); - // Primary connection (legacy compat — first entry or explicitly set) const primaryGuildId = this.connections.keys().next().value ?? null; const primary = primaryGuildId ? this.connections.get(primaryGuildId) @@ -76,15 +101,21 @@ export class VoiceController { try { // Disconnect existing connection for this guild first if (this.connections.has(guildId)) { - await this.disconnectGuild(guildId); + await this.disconnectGuild(guildId, { intentional: true }); } + // Clear any outstanding rejoin work for this guild — we are (re)joining now. + this.clearRejoin(guildId); + this.intentionalGuilds.delete(guildId); + const guild = this.getGuild(guildId); const channel = guild.channels.cache.get(channelId) ?? (await guild.channels.fetch(channelId).catch(() => null)); if (!channel) { + // Channel no longer exists — drop the persisted row so we don't retry forever. + await deleteVoiceAutoReconnect(guildId).catch(() => {}); throw new AppError( "Voice channel not found", "VOICE_CHANNEL_NOT_FOUND", @@ -124,6 +155,25 @@ export class VoiceController { connectedAt: Date.now(), }; this.connections.set(guildId, state); + this.rejoinState.set(guildId, state); + this.liveConnections.set(guildId, connection as VoiceConnection); + + // Persist desired voice state for auto-reconnect (restart/reboot/kick). + const now = Date.now(); + await upsertVoiceAutoReconnect({ + guild_id: guildId, + channel_id: channelId, + channel_name: channel.name, + connected_at: now, + updated_at: now, + }).catch((err) => { + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + "Failed to persist voice auto-reconnect state", + ); + }); + + this.monitorConnection(guildId, connection); logger.info( { guildId, channelId, channelName: channel.name }, @@ -136,25 +186,198 @@ export class VoiceController { } } - async disconnect(): Promise { - logger.info("disconnect called"); + /** + * Auto-reconnect on startup/restart: rejoin every persisted voice channel. + * Called from the client `ready` handler. Non-fatal per-guild. + */ + async autoReconnect(): Promise { + let states: Array<{ + guild_id: string; + channel_id: string; + channel_name: string | null; + }> = []; + try { + states = await listVoiceAutoReconnects(); + } catch (err) { + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + "Failed to load voice auto-reconnect states", + ); + return; + } + + if (states.length === 0) { + logger.info("No persisted voice state — skipping auto-reconnect"); + return; + } + + logger.info( + { count: states.length }, + "Auto-reconnecting to persisted voice channels", + ); + + // Fire in parallel; each failure is logged but doesn't block the others. + await Promise.allSettled( + states.map(async (s) => { + try { + await this.connect(s.guild_id, s.channel_id); + } catch (err) { + logger.error( + { + guildId: s.guild_id, + channelId: s.channel_id, + channelName: s.channel_name, + err: err instanceof Error ? err.message : String(err), + }, + "Auto-reconnect failed for guild", + ); + } + }), + ); + } + + async disconnect(opts?: DisconnectOptions): Promise { + // Default semantics for graceful shutdown: intentional (no rejoin during + // teardown) but keep the persisted row so we rejoin on next boot. + const effective = opts ?? { intentional: true }; + logger.info({ opts: effective }, "disconnect called"); - // Disconnect all guilds const guildIds = Array.from(this.connections.keys()); for (const gid of guildIds) { - await this.disconnectGuild(gid); + await this.disconnectGuild(gid, effective); } discordPlayer.stop(); return this.getStatus(); } - async disconnectGuild(guildId: string): Promise { - logger.info({ guildId }, "disconnectGuild called"); + async disconnectGuild( + guildId: string, + opts?: DisconnectOptions, + ): Promise { + logger.info({ guildId, opts }, "disconnectGuild called"); + if (opts?.intentional) { + this.intentionalGuilds.add(guildId); + } + if (opts?.clearPersisted) { + await deleteVoiceAutoReconnect(guildId).catch(() => {}); + // Manual leave — forget the desired state entirely. + this.rejoinState.delete(guildId); + this.intentionalGuilds.add(guildId); + } if (this.connections.has(guildId)) { stopRecording(guildId); this.connections.delete(guildId); } + this.liveConnections.delete(guildId); + this.clearRejoin(guildId); + } + + // ─── Rejoin monitor ─────────────────────────────────────────────────── + + /** + * Watch a live connection. If it drops WITHOUT us marking the guild as + * intentional (user leave / shutdown), schedule a full rejoin with backoff. + */ + private monitorConnection( + guildId: string, + connection: VoiceConnection, + ): void { + const onDrop = () => { + // Ignore callbacks from a stale connection that has since been replaced. + if (this.liveConnections.get(guildId) !== connection) { + return; + } + + // This connection object is dead. + this.liveConnections.delete(guildId); + if (this.connections.get(guildId)?.guildId === guildId) { + this.connections.delete(guildId); + } + + if (this.intentionalGuilds.has(guildId)) { + logger.info({ guildId }, "Intentional disconnect — no auto-rejoin"); + return; + } + + logger.warn( + { guildId }, + "Voice dropped unexpectedly — scheduling rejoin", + ); + this.scheduleRejoin(guildId); + }; + + connection.on(VoiceConnectionStatus.Disconnected, onDrop); + connection.on(VoiceConnectionStatus.Destroyed, onDrop); + } + + private scheduleRejoin(guildId: string): void { + if (this.rejoinTimers.has(guildId)) { + return; // already scheduled + } + if (this.intentionalGuilds.has(guildId)) { + return; + } + + const attempt = (this.rejoinAttempts.get(guildId) ?? 0) + 1; + this.rejoinAttempts.set(guildId, attempt); + + const persisted = this.rejoinState.get(guildId); + if (!persisted) { + // Desired state was cleared — nothing to rejoin. + this.clearRejoin(guildId); + return; + } + + if (attempt > REJOIN_MAX_ATTEMPTS) { + logger.error( + { guildId, channelId: persisted.channelId, attempt }, + "Rejoin attempts exhausted — keeping persisted state for next restart", + ); + this.clearRejoin(guildId); + return; + } + + const delay = Math.min( + REJOIN_BASE_DELAY_MS * 2 ** (attempt - 1), + REJOIN_MAX_DELAY_MS, + ); + logger.info( + { guildId, channelId: persisted.channelId, attempt, delay }, + "Scheduling voice rejoin", + ); + + const timer = setTimeout(() => { + this.rejoinTimers.delete(guildId); + this.connect(guildId, persisted.channelId) + .then(() => { + this.rejoinAttempts.delete(guildId); + logger.info({ guildId }, "Auto-rejoin succeeded"); + }) + .catch((err) => { + logger.warn( + { + guildId, + err: err instanceof Error ? err.message : String(err), + attempt, + }, + "Auto-rejoin attempt failed", + ); + // Schedule the next attempt (the monitor is gone, so drive it here). + this.scheduleRejoin(guildId); + }); + }, delay); + + this.rejoinTimers.set(guildId, timer); + } + + private clearRejoin(guildId: string): void { + const timer = this.rejoinTimers.get(guildId); + if (timer) { + clearTimeout(timer); + this.rejoinTimers.delete(guildId); + } + this.rejoinAttempts.delete(guildId); } private getGuild(guildId: string): Guild { diff --git a/services/discord-gateway/src/shared/database/schema.ts b/services/discord-gateway/src/shared/database/schema.ts index 92f549f4..22498031 100644 --- a/services/discord-gateway/src/shared/database/schema.ts +++ b/services/discord-gateway/src/shared/database/schema.ts @@ -277,6 +277,22 @@ export const pgVoiceRecordingsTable = pgTable( export const voiceRecordingsTable = pgVoiceRecordingsTable; +/** + * Voice Auto-Reconnect Table (PostgreSQL) + * Persists the desired voice state per guild so the gateway can rejoin the + * same channel after a restart/reboot, or after an unexpected drop (kick / + * server move / voice server restart). + */ +export const pgVoiceAutoReconnectTable = pgTable("voice_auto_reconnect", { + guild_id: pgText("guild_id").primaryKey(), + channel_id: pgText("channel_id").notNull(), + channel_name: pgText("channel_name"), + connected_at: pgBigint("connected_at", { mode: "number" }).notNull(), + updated_at: pgBigint("updated_at", { mode: "number" }).notNull(), +}); + +export const voiceAutoReconnectTable = pgVoiceAutoReconnectTable; + // ============================================================================= // AI Analysis / Analytics // ============================================================================= @@ -558,6 +574,11 @@ export type CorrectedModerationInsert = export type VoiceRecording = typeof voiceRecordingsTable.$inferSelect; export type VoiceRecordingInsert = typeof voiceRecordingsTable.$inferInsert; +// Voice Auto-Reconnect +export type VoiceAutoReconnect = typeof voiceAutoReconnectTable.$inferSelect; +export type VoiceAutoReconnectInsert = + typeof voiceAutoReconnectTable.$inferInsert; + // AI Analysis Runs export type AIAnalysisRun = typeof aiAnalysisRunsTable.$inferSelect; export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert; diff --git a/services/discord-gateway/src/shared/database/voiceAutoReconnectRepo.ts b/services/discord-gateway/src/shared/database/voiceAutoReconnectRepo.ts new file mode 100644 index 00000000..3cb1e013 --- /dev/null +++ b/services/discord-gateway/src/shared/database/voiceAutoReconnectRepo.ts @@ -0,0 +1,59 @@ +import { eq } from "drizzle-orm"; +import { getDatabase } from "./drizzle.js"; +import { + type VoiceAutoReconnect, + type VoiceAutoReconnectInsert, + voiceAutoReconnectTable, +} from "./schema.js"; + +/** + * Upsert a guild's desired voice state. + * Called after a successful connect so the gateway can rejoin after a + * restart/reboot or unexpected drop. + */ +export async function upsertVoiceAutoReconnect( + record: VoiceAutoReconnectInsert, +): Promise { + const db = getDatabase(); + const [row] = await db + .insert(voiceAutoReconnectTable) + .values(record) + .onConflictDoUpdate({ + target: voiceAutoReconnectTable.guild_id, + set: { + channel_id: record.channel_id, + channel_name: record.channel_name ?? null, + connected_at: record.connected_at, + updated_at: record.updated_at, + }, + }) + .returning(); + return row; +} + +/** List all persisted desired voice states (for startup auto-reconnect). */ +export async function listVoiceAutoReconnects(): Promise { + const db = getDatabase(); + return db.select().from(voiceAutoReconnectTable); +} + +/** Clear a guild's persisted voice state (explicit manual leave). */ +export async function deleteVoiceAutoReconnect(guildId: string): Promise { + const db = getDatabase(); + await db + .delete(voiceAutoReconnectTable) + .where(eq(voiceAutoReconnectTable.guild_id, guildId)); +} + +/** Fetch a single guild's persisted state, if any. */ +export async function getVoiceAutoReconnect( + guildId: string, +): Promise { + const db = getDatabase(); + const rows = await db + .select() + .from(voiceAutoReconnectTable) + .where(eq(voiceAutoReconnectTable.guild_id, guildId)) + .limit(1); + return rows[0]; +}