discord-voice-resume
v0.1.0
Published
Survive redeploys: crash-safe snapshot and restore of a discord.js bot's voice presence and playback sessions, with empty-room detection that works around member-cache gaps.
Maintainers
Readme
discord-voice-resume
Survive redeploys. Crash-safe snapshot and restore of a discord.js bot's voice presence and per-guild session state — so a deploy never kicks your bot out of its voice channels or loses what it was playing.
Zero dependencies. discord.js is not even a peer dependency — the client and channel are structurally typed.
The problem
Every discord.js music bot has the same failure mode: you merge to main, CI redeploys, the process restarts, and the bot vanishes from every voice channel it was in. Whatever was playing is gone; whoever parked it in a channel has to re-invite it. Bots either accept this or hand-roll snapshot/restore logic.
This package is that logic, extracted from a production bot (FM-Hachimi) that redeploys on every merge:
- Crash-safe snapshot file — written synchronously (safe inside a SIGTERM handler), delete-on-read so a crash loop can never replay a poisonous snapshot, TTL so a stale file from last week is discarded, and an optional auto-flush timer so even a hard kill (OOM,
docker kill) leaves a fresh snapshot. The auto-flush defers its first write by one interval — state that crashes the process before surviving one interval is never re-persisted. - Restore orchestration — fetches and validates each voice channel, detects genuinely empty rooms (see below), isolates per-guild failures, and hands your own opaque payload back to you.
- Your state stays yours — the package never inspects the payload. Queue, tracks, seek position, loop mode, announcements, and the actual
@discordjs/voicejoin all happen in youronRestorecallback.
Usage
import { SessionResume } from 'discord-voice-resume';
interface MyGuildState {
tracks: SerializedTrack[];
currentIndex: number;
loopMode: string;
positionSeconds: number;
}
const resume = new SessionResume<MyGuildState>({
enabled: true,
dataFile: './data/resume.json', // on a volume that survives the redeploy
maxAgeMs: 60 * 60 * 1000,
snapshotIntervalMs: 60_000, // optional hard-kill safety net
});On shutdown (inside your SIGTERM handler, before voice connections tear down):
resume.persist(connectedGuildIds, (guildId) => {
const player = players.get(guildId);
if (!player?.voiceChannelId) return null; // not in voice — skip
return {
voiceChannelId: player.voiceChannelId,
textChannelId: player.announceChannelId,
payload: player.isActive()
? { tracks: player.queue.serialize(), currentIndex: player.queue.index, ... }
: null, // null = presence-only: just rejoin
};
});On startup (once the client is ready):
const { restored, skipped } = await resume.restoreAll(client, {
onRestore: async (voiceChannel, payload, { guildId, textChannelId, roomWasEmpty }) => {
const player = players.create(guildId);
if (!(await player.join(voiceChannel))) return false;
// Presence-only snapshot, or nobody listening: rejoin without playback.
if (!payload || roomWasEmpty) return true;
player.queue.load(payload.tracks, payload.currentIndex);
await player.play({ startAtSeconds: payload.positionSeconds });
return true;
},
});On demand — restoreSession is the core primitive with no file I/O attached, so you can reuse the same rejoin dance live (e.g. an anti-disconnect feature that puts the bot back after someone force-disconnects it):
const state = captureGuildNow(guildId); // your own fresh capture
await resume.restoreSession(client, state, { onRestore });restoreSession is deliberately not gated by enabled — that flag only controls the file-based redeploy-resume flow.
Empty-room detection
onRestore receives roomWasEmpty so you can rejoin without blasting audio at nobody. Detecting this correctly is the subtle part, and the reason this isn't just channel.members.size === 0:
Right after startup,
channel.membersonly includes occupants whoseGuildMemberis cached — and without theGuildMembersintent the member cache holds only the bot itself. Every human gets filtered out and every occupied channel looks empty.
The package counts the guild's voice states instead (populated by GUILD_CREATE even when member objects aren't), excluding the bot's own stale pre-restart state and known bots. An occupant with no cached member counts as human, and unavailable voice states count as "not empty" — a rare resume into a bots-only room beats never resuming at all. The package only reports the fact; whether an empty room downgrades to presence-only is your policy in onRestore.
Events
SessionResume is an EventEmitter — wire these into your own logger/metrics:
| Event | Payload |
|---|---|
| persist | { guilds, file } |
| persist:error | { error } |
| restore:success | { guildId, roomWasEmpty, presenceOnly } |
| restore:skipped | { guildId, reason } |
| restore:error | { guildId?, error } |
Nothing throws past the package boundary: missing/corrupt/expired snapshots, unavailable channels, and throwing callbacks all resolve to skip/false with an event.
Options
| Option | Required | |
|---|---|---|
| enabled | yes | Gates the file-based flow (persist, consume, restoreAll, auto-snapshot) |
| dataFile | yes | Snapshot path — put it on storage that survives the redeploy |
| maxAgeMs | yes | Snapshots older than this are discarded on read |
| snapshotIntervalMs | no | Auto-flush interval; omit or 0 to disable |
Provenance
Extracted from FM-Hachimi, where this machinery has carried voice sessions across every production redeploy. Sibling packages from the same bot: ytdlp-cookie-keeper, @bryan-kuang/bilibili-audio-extractor. MIT.
