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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Startup session sweep (`restoreSessionsOnStartup`) — on `clientReady`, the
bot scans Redis for `music:session:*` keys, rejoins the stored voice channel,
and calls `restoreSnapshot` for each valid, fresh snapshot (≤ 30 min old).
Stale snapshots are deleted. Per-guild errors are isolated so one failure
does not abort the rest. Gated by `MUSIC_SESSION_RESTORE_ENABLED` (default
enabled).

## [2.6.17] - 2026-03-15

### Added
Expand Down
6 changes: 5 additions & 1 deletion packages/bot/src/events/clientReady.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import type { Client } from 'discord.js'
import chalk from 'chalk'
import { infoLog } from '@lucky/shared/utils'
import type { CustomClient } from '../types'
import { restoreSessionsOnStartup } from '../utils/music/sessionStartupRestore'

export const name = 'clientReady'
export const once = true

export function execute(client: Client): void {
export async function execute(client: Client): Promise<void> {
infoLog({ message: `Logged in as ${chalk.white(client.user?.tag)}!` })
infoLog({ message: `Bot is active in ${client.guilds.cache.size} guilds` })
infoLog({ message: `Connection status: ${client.ws.status}` })

client.guilds.cache.forEach((guild) => {
infoLog({ message: `Connected to guild: ${guild.name} (${guild.id})` })
})

await restoreSessionsOnStartup(client as CustomClient)
}
177 changes: 177 additions & 0 deletions packages/bot/src/utils/music/sessionStartupRestore.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
import { restoreSessionsOnStartup } from './sessionStartupRestore'

// --- mocks ---

const keysMock = jest.fn()
const getSnapshotMock = jest.fn()
const deleteSnapshotMock = jest.fn()
const restoreSnapshotMock = jest.fn()

jest.mock('@lucky/shared/config', () => ({
ENVIRONMENT_CONFIG: {
MUSIC: { SESSION_RESTORE_ENABLED: true },
},
}))

jest.mock('@lucky/shared/services', () => ({
redisClient: { keys: (...args: unknown[]) => keysMock(...args) },
}))

jest.mock('@lucky/shared/utils', () => ({
infoLog: jest.fn(),
warnLog: jest.fn(),
errorLog: jest.fn(),
}))

jest.mock('./sessionSnapshots', () => ({
musicSessionSnapshotService: {
getSnapshot: (...args: unknown[]) => getSnapshotMock(...args),
deleteSnapshot: (...args: unknown[]) => deleteSnapshotMock(...args),
restoreSnapshot: (...args: unknown[]) => restoreSnapshotMock(...args),
},
}))

// --- helpers ---

const GUILD_ID = 'guild-123'
const VOICE_CHANNEL_ID = 'vc-456'
const FRESH_SAVED_AT = Date.now() - 5 * 60 * 1_000 // 5 min ago
const STALE_SAVED_AT = Date.now() - 35 * 60 * 1_000 // 35 min ago (> 30 min limit)

const makeSnapshot = (overrides: Record<string, unknown> = {}) => ({
sessionSnapshotId: 'snap-1',
guildId: GUILD_ID,
savedAt: FRESH_SAVED_AT,
currentTrack: null,
upcomingTracks: [{ title: 'Track 1', author: 'Artist', url: 'https://yt', duration: '3:00', source: 'youtube' }],
voiceChannelId: VOICE_CHANNEL_ID,
...overrides,
})

const makeVoiceChannel = () => ({
id: VOICE_CHANNEL_ID,
isVoiceBased: () => true,
})

const makeQueue = () => ({
connection: null,
connect: jest.fn<() => Promise<void>>().mockResolvedValue(undefined as void),
})

const makePlayer = (queue: ReturnType<typeof makeQueue>) => ({
nodes: {
create: jest.fn().mockReturnValue(queue),
},
})

const makeClient = (overrides: Record<string, unknown> = {}) => {
const queue = makeQueue()
const player = makePlayer(queue)
const voiceChannel = makeVoiceChannel()
const guild = {
id: GUILD_ID,
channels: { cache: { get: jest.fn().mockReturnValue(voiceChannel) } },
}
return {
guilds: { cache: { get: jest.fn().mockReturnValue(guild) } },
player,
_queue: queue,
_guild: guild,
...overrides,
}
}

describe('restoreSessionsOnStartup', () => {
beforeEach(() => {
jest.clearAllMocks()
keysMock.mockResolvedValue([`music:session:${GUILD_ID}`])
getSnapshotMock.mockResolvedValue(makeSnapshot())
deleteSnapshotMock.mockResolvedValue(undefined)
restoreSnapshotMock.mockResolvedValue({ restoredCount: 1, sessionSnapshotId: 'snap-1' })
})

it('returns early when SESSION_RESTORE_ENABLED is false', async () => {
const { ENVIRONMENT_CONFIG } = await import('@lucky/shared/config')
const original = ENVIRONMENT_CONFIG.MUSIC.SESSION_RESTORE_ENABLED
;(ENVIRONMENT_CONFIG.MUSIC as Record<string, unknown>).SESSION_RESTORE_ENABLED = false
try {
const client = makeClient()
await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient)
expect(keysMock).not.toHaveBeenCalled()
} finally {
;(ENVIRONMENT_CONFIG.MUSIC as Record<string, unknown>).SESSION_RESTORE_ENABLED = original
}
})

it('returns early when no Redis session keys exist', async () => {
keysMock.mockResolvedValue([])
const client = makeClient()
await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient)
expect(getSnapshotMock).not.toHaveBeenCalled()
})

it('skips guild not in cache', async () => {
const client = makeClient()
;(client.guilds.cache.get as jest.Mock).mockReturnValue(undefined)
await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient)
expect(getSnapshotMock).not.toHaveBeenCalled()
})

it('skips snapshot with missing voiceChannelId', async () => {
getSnapshotMock.mockResolvedValue(makeSnapshot({ voiceChannelId: undefined }))
const client = makeClient()
await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient)
expect(client.player.nodes.create).not.toHaveBeenCalled()
})

it('deletes stale snapshot and skips restore', async () => {
getSnapshotMock.mockResolvedValue(makeSnapshot({ savedAt: STALE_SAVED_AT }))
const client = makeClient()
await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient)
expect(deleteSnapshotMock).toHaveBeenCalledWith(GUILD_ID)
expect(client.player.nodes.create).not.toHaveBeenCalled()
})

it('skips when voice channel is not found or not voice-based', async () => {
const client = makeClient()
;(client._guild.channels.cache.get as jest.Mock).mockReturnValue({
id: VOICE_CHANNEL_ID,
isVoiceBased: () => false,
})
await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient)
expect(client.player.nodes.create).not.toHaveBeenCalled()
})

it('creates queue, connects to voice, and restores snapshot for valid entry', async () => {
const client = makeClient()
await restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient)
expect(client.player.nodes.create).toHaveBeenCalledWith(
client._guild,
expect.objectContaining({ metadata: expect.anything() }),
)
expect(client._queue.connect).toHaveBeenCalled()
expect(restoreSnapshotMock).toHaveBeenCalledWith(
client._queue,
undefined,
{ maxAgeMs: 30 * 60 * 1_000 },
)
})

it('isolates per-guild errors and continues sweep', async () => {
keysMock.mockResolvedValue([
`music:session:guild-fail`,
`music:session:${GUILD_ID}`,
])
const client = makeClient()
const originalGet = (client.guilds.cache.get as jest.Mock).getMockImplementation()
;(client.guilds.cache.get as jest.Mock).mockImplementation((id: string) => {
if (id === 'guild-fail') throw new Error('boom')
return originalGet ? originalGet(id) : undefined
})
await expect(
restoreSessionsOnStartup(client as unknown as import('../../types').CustomClient)
).resolves.not.toThrow()
expect(restoreSnapshotMock).toHaveBeenCalledTimes(1)
})
})
105 changes: 105 additions & 0 deletions packages/bot/src/utils/music/sessionStartupRestore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { CustomClient } from '../../types'
import { ENVIRONMENT_CONFIG } from '@lucky/shared/config'
import { redisClient } from '@lucky/shared/services'
import { errorLog, infoLog, warnLog } from '@lucky/shared/utils'
import { musicSessionSnapshotService } from './sessionSnapshots'

const SESSION_KEY_PREFIX = 'music:session:'
const STARTUP_MAX_AGE_MS = 30 * 60 * 1_000 // 30 minutes

/**
* Scans Redis for guild session snapshots and attempts to restore each one
* by rejoining the stored voice channel and re-queuing tracks.
*
* Called once from clientReady. Per-guild errors are isolated so one failure
* does not abort the entire sweep.
*/
export async function restoreSessionsOnStartup(client: CustomClient): Promise<void> {

Check failure on line 17 in packages/bot/src/utils/music/sessionStartupRestore.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZzx6r_6tHWeJ9ZZPQGz&open=AZzx6r_6tHWeJ9ZZPQGz&pullRequest=260
if (!ENVIRONMENT_CONFIG.MUSIC.SESSION_RESTORE_ENABLED) return

let keys: string[]
try {
keys = await redisClient.keys(`${SESSION_KEY_PREFIX}*`)
} catch (error) {
errorLog({ message: 'Startup session sweep: failed to scan Redis keys', error })
return
}

if (keys.length === 0) return

infoLog({ message: `Startup session sweep: found ${keys.length} snapshot(s)` })

for (const key of keys) {
const guildId = key.slice(SESSION_KEY_PREFIX.length)

try {
const guild = client.guilds.cache.get(guildId)
if (!guild) {
warnLog({
message: 'Startup session sweep: guild not in cache, skipping',
data: { guildId },
})
continue
}

const snapshot = await musicSessionSnapshotService.getSnapshot(guildId)
if (!snapshot) continue

if (!snapshot.voiceChannelId) {
warnLog({
message: 'Startup session sweep: snapshot missing voiceChannelId, skipping',
data: { guildId },
})
continue
}

const ageMs = Date.now() - snapshot.savedAt
if (ageMs > STARTUP_MAX_AGE_MS) {
await musicSessionSnapshotService.deleteSnapshot(guildId)
warnLog({
message: 'Startup session sweep: stale snapshot deleted',
data: { guildId, ageMs, maxAgeMs: STARTUP_MAX_AGE_MS },
})
continue
}

const channel = guild.channels.cache.get(snapshot.voiceChannelId)
if (!channel?.isVoiceBased()) {
warnLog({
message: 'Startup session sweep: voice channel not found or not voice-based',
data: { guildId, voiceChannelId: snapshot.voiceChannelId },
})
continue
}

const queue = client.player.nodes.create(guild, {
metadata: { channel: null, requestedBy: null },
})

if (!queue.connection) {
await queue.connect(channel)
}

const result = await musicSessionSnapshotService.restoreSnapshot(queue, undefined, {
maxAgeMs: STARTUP_MAX_AGE_MS,
})

if (result.restoredCount > 0) {
infoLog({
message: 'Startup session sweep: restored snapshot',
data: {
guildId,
restoredCount: result.restoredCount,
sessionSnapshotId: result.sessionSnapshotId,
},
})
}
} catch (error) {
errorLog({
message: 'Startup session sweep: failed to restore snapshot for guild',
error,
data: { guildId },
})
}
}
}
Loading