import type express from 'express'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import child_process from 'node:child_process'; import { PassThrough, Readable } from 'node:stream'; import multer from 'multer'; import { joinVoiceChannel, createAudioPlayer, createAudioResource, AudioPlayerStatus, NoSubscriberBehavior, getVoiceConnection, VoiceConnectionStatus, StreamType, entersState, generateDependencyReport, type VoiceConnection, type AudioResource, } from '@discordjs/voice'; import sodium from 'libsodium-wrappers'; import nacl from 'tweetnacl'; import { ChannelType, Events, type VoiceState, type Message } from 'discord.js'; import type { Plugin, PluginContext } from '../../core/plugin.js'; import { sseBroadcast } from '../../core/sse.js'; // ── Config (env) ── const SOUNDS_DIR = process.env.SOUNDS_DIR ?? '/data/sounds'; const NORMALIZE_ENABLE = String(process.env.NORMALIZE_ENABLE ?? 'true').toLowerCase() !== 'false'; const NORMALIZE_I = String(process.env.NORMALIZE_I ?? '-16'); const NORMALIZE_LRA = String(process.env.NORMALIZE_LRA ?? '11'); const NORMALIZE_TP = String(process.env.NORMALIZE_TP ?? '-1.5'); const NORM_CONCURRENCY = Math.max(1, Number(process.env.NORM_CONCURRENCY ?? 2)); const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512'); const PCM_PER_FILE_MAX_MB = 50; // ── Types ── type Category = { id: string; name: string; color?: string; sort?: number }; type PersistedState = { volumes: Record; plays: Record; totalPlays: number; categories?: Category[]; fileCategories?: Record; fileBadges?: Record; selectedChannels?: Record; entranceSounds?: Record; exitSounds?: Record; }; type ListedSound = { fileName: string; name: string; folder: string; relativePath: string }; type GuildAudioState = { connection: VoiceConnection; player: ReturnType; guildId: string; channelId: string; currentResource?: AudioResource; currentVolume: number; }; // ── Persisted State ── const STATE_FILE = path.join(SOUNDS_DIR, 'state.json'); const STATE_FILE_OLD = path.join(path.resolve(SOUNDS_DIR, '..'), 'state.json'); function readPersistedState(): PersistedState { try { if (fs.existsSync(STATE_FILE)) { const p = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); return { volumes: p.volumes ?? {}, plays: p.plays ?? {}, totalPlays: p.totalPlays ?? 0, categories: Array.isArray(p.categories) ? p.categories : [], fileCategories: p.fileCategories ?? {}, fileBadges: p.fileBadges ?? {}, selectedChannels: p.selectedChannels ?? {}, entranceSounds: p.entranceSounds ?? {}, exitSounds: p.exitSounds ?? {} }; } if (fs.existsSync(STATE_FILE_OLD)) { const p = JSON.parse(fs.readFileSync(STATE_FILE_OLD, 'utf8')); const m: PersistedState = { volumes: p.volumes ?? {}, plays: p.plays ?? {}, totalPlays: p.totalPlays ?? 0, categories: Array.isArray(p.categories) ? p.categories : [], fileCategories: p.fileCategories ?? {}, fileBadges: p.fileBadges ?? {}, selectedChannels: p.selectedChannels ?? {}, entranceSounds: p.entranceSounds ?? {}, exitSounds: p.exitSounds ?? {} }; try { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(m, null, 2), 'utf8'); } catch {} return m; } } catch {} return { volumes: {}, plays: {}, totalPlays: 0 }; } let persistedState: PersistedState; let _writeTimer: ReturnType | null = null; function writeState(): void { try { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(persistedState, null, 2), 'utf8'); } catch (e) { console.warn('[Soundboard] state write error:', e); } } function writeStateDebounced(): void { if (_writeTimer) return; _writeTimer = setTimeout(() => { _writeTimer = null; writeState(); }, 2000); } function getPersistedVolume(guildId: string): number { const v = persistedState.volumes[guildId]; return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1; } function safeSoundsPath(rel: string): string | null { const resolved = path.resolve(SOUNDS_DIR, rel); if (!resolved.startsWith(path.resolve(SOUNDS_DIR) + path.sep) && resolved !== path.resolve(SOUNDS_DIR)) return null; return resolved; } function incrementPlaysFor(relativePath: string) { try { const key = relativePath.replace(/\\/g, '/'); persistedState.plays[key] = (persistedState.plays[key] ?? 0) + 1; persistedState.totalPlays = (persistedState.totalPlays ?? 0) + 1; writeStateDebounced(); } catch {} } // ── Loudnorm Cache ── const NORM_CACHE_DIR = path.join(SOUNDS_DIR, '.norm-cache'); function normCacheKey(filePath: string): string { const rel = path.relative(SOUNDS_DIR, filePath).replace(/\\/g, '/'); return rel.replace(/[/\\:*?"<>|]/g, '_') + '.pcm'; } function getNormCachePath(filePath: string): string | null { const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath)); if (!fs.existsSync(cacheFile)) return null; try { const srcMtime = fs.statSync(filePath).mtimeMs; const cacheMtime = fs.statSync(cacheFile).mtimeMs; if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} invalidatePcmMemory(cacheFile); return null; } } catch { return null; } return cacheFile; } function normalizeToCache(filePath: string): Promise { const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath)); return new Promise((resolve, reject) => { const ff = child_process.spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-i', filePath, '-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`, '-f', 's16le', '-ar', '48000', '-ac', '2', cacheFile]); ff.on('error', reject); ff.on('close', (code) => { if (code === 0) resolve(cacheFile); else reject(new Error(`ffmpeg exit ${code}`)); }); }); } // ── PCM Memory Cache ── const pcmMemoryCache = new Map(); let pcmMemoryCacheBytes = 0; function getPcmFromMemory(cachedPath: string): Buffer | null { const buf = pcmMemoryCache.get(cachedPath); if (buf) return buf; try { const stat = fs.statSync(cachedPath); if (stat.size > PCM_PER_FILE_MAX_MB * 1024 * 1024) return null; if (pcmMemoryCacheBytes + stat.size > PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) return null; const data = fs.readFileSync(cachedPath); pcmMemoryCache.set(cachedPath, data); pcmMemoryCacheBytes += data.byteLength; return data; } catch { return null; } } function invalidatePcmMemory(cachedPath: string): void { const buf = pcmMemoryCache.get(cachedPath); if (buf) { pcmMemoryCacheBytes -= buf.byteLength; pcmMemoryCache.delete(cachedPath); } } // ── Sound listing ── function listAllSounds(): ListedSound[] { const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true }); const rootFiles: ListedSound[] = rootEntries .filter(d => d.isFile() && /\.(mp3|wav)$/i.test(d.name)) .map(d => ({ fileName: d.name, name: path.parse(d.name).name, folder: '', relativePath: d.name })); const folderItems: ListedSound[] = []; for (const dirent of rootEntries.filter(d => d.isDirectory() && d.name !== '.norm-cache')) { const folderPath = path.join(SOUNDS_DIR, dirent.name); for (const e of fs.readdirSync(folderPath, { withFileTypes: true })) { if (!e.isFile() || !/\.(mp3|wav)$/i.test(e.name)) continue; folderItems.push({ fileName: e.name, name: path.parse(e.name).name, folder: dirent.name, relativePath: path.join(dirent.name, e.name) }); } } return [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name)); } // ── Norm cache sync ── async function syncNormCache(): Promise { if (!NORMALIZE_ENABLE) return; const t0 = Date.now(); const allSounds = listAllSounds(); const expectedKeys = new Set(); const toProcess: string[] = []; for (const s of allSounds) { const fp = path.join(SOUNDS_DIR, s.relativePath); const key = normCacheKey(fp); expectedKeys.add(key); if (!fs.existsSync(fp)) continue; if (getNormCachePath(fp)) continue; toProcess.push(fp); } let created = 0, failed = 0; const skipped = allSounds.length - toProcess.length; const queue = [...toProcess]; async function worker(): Promise { while (queue.length > 0) { const fp = queue.shift()!; try { await normalizeToCache(fp); created++; } catch (e) { failed++; console.warn(`[Soundboard] norm-cache failed: ${path.relative(SOUNDS_DIR, fp)}`, e); } } } await Promise.all(Array.from({ length: Math.min(NORM_CONCURRENCY, toProcess.length || 1) }, worker)); let cleaned = 0; try { for (const f of fs.readdirSync(NORM_CACHE_DIR)) { if (!expectedKeys.has(f)) { try { fs.unlinkSync(path.join(NORM_CACHE_DIR, f)); cleaned++; } catch {} } } } catch {} console.log(`[Soundboard] Norm-cache sync (${((Date.now() - t0) / 1000).toFixed(1)}s): ${created} new, ${skipped} cached, ${failed} failed, ${cleaned} orphans`); } // ── Audio State ── const guildAudioState = new Map(); const partyTimers = new Map(); const partyActive = new Set(); const nowPlaying = new Map(); const connectedSince = new Map(); // ── Logging helper ── const SB = '[Soundboard]'; // ── Voice Lifecycle ── async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise { console.log(`${SB} ensureConnectionReady: guild=${guildId} channel=${channelId} status=${connection.state.status}`); try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (attempt 1)`); return connection; } catch (e) { console.warn(`${SB} Attempt 1 failed: ${(e as Error)?.message ?? e}`); } try { connection.rejoin({ channelId, selfDeaf: false, selfMute: false }); await entersState(connection, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (rejoin)`); return connection; } catch (e) { console.warn(`${SB} Rejoin failed: ${(e as Error)?.message ?? e}`); } try { connection.destroy(); } catch {} guildAudioState.delete(guildId); console.log(`${SB} Creating fresh connection (attempt 3)...`); const newConn = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); newConn.on('stateChange', (o: any, n: any) => console.log(`${SB} [fresh-conn] ${o.status} → ${n.status}`)); newConn.on('error', (err: any) => console.error(`${SB} [fresh-conn] ERROR: ${err?.message ?? err}`)); try { await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); console.log(`${SB} Connection ready (fresh)`); return newConn; } catch (e) { console.error(`${SB} All 3 connection attempts failed: ${(e as Error)?.message ?? e}`); try { newConn.destroy(); } catch {} guildAudioState.delete(guildId); throw new Error('Voice connection failed after 3 attempts'); } } function attachVoiceLifecycle(state: GuildAudioState, guild: any) { const { connection } = state; if ((connection as any).__lifecycleAttached) return; try { (connection as any).setMaxListeners?.(0); } catch {} let reconnectAttempts = 0; const MAX_RECONNECT_ATTEMPTS = 3; let isReconnecting = false; connection.on('stateChange', async (oldS: any, newS: any) => { console.log(`${SB} Voice state: ${oldS.status} → ${newS.status} (guild=${state.guildId})`); if (newS.status === VoiceConnectionStatus.Ready) { reconnectAttempts = 0; isReconnecting = false; if (!connectedSince.has(state.guildId)) connectedSince.set(state.guildId, new Date().toISOString()); console.log(`${SB} Voice READY for guild=${state.guildId}`); return; } if (isReconnecting) { console.log(`${SB} Already reconnecting, skipping ${newS.status}`); return; } try { if (newS.status === VoiceConnectionStatus.Disconnected) { console.warn(`${SB} Disconnected – waiting for Signalling/Connecting...`); try { await Promise.race([entersState(connection, VoiceConnectionStatus.Signalling, 5_000), entersState(connection, VoiceConnectionStatus.Connecting, 5_000)]); } catch { if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { reconnectAttempts++; console.log(`${SB} Rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); } else { reconnectAttempts = 0; console.log(`${SB} Max reconnect attempts reached, creating fresh connection`); try { connection.destroy(); } catch {} const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); } } } else if (newS.status === VoiceConnectionStatus.Destroyed) { console.warn(`${SB} Connection destroyed, recreating...`); connectedSince.delete(state.guildId); const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); } else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) { isReconnecting = true; console.log(`${SB} Waiting for Ready from ${newS.status}...`); try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); } catch (e) { reconnectAttempts++; console.warn(`${SB} Timeout waiting for Ready from ${newS.status} (attempt ${reconnectAttempts}): ${(e as Error)?.message ?? e}`); if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { await new Promise(r => setTimeout(r, reconnectAttempts * 2000)); isReconnecting = false; connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); } else { reconnectAttempts = 0; isReconnecting = false; console.error(`${SB} Max attempts from ${newS.status}, fresh connection`); try { connection.destroy(); } catch {} const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); } } } } catch (e) { console.error(`${SB} Lifecycle error: ${(e as Error)?.message ?? e}`); isReconnecting = false; } }); (connection as any).__lifecycleAttached = true; } // ── Debug adapter wrapper ── // voiceAdapterCreator(libraryMethods) → { sendPayload, destroy } // libraryMethods = { onVoiceServerUpdate, onVoiceStateUpdate, destroy } // returned adapter = { sendPayload(payload) → boolean, destroy() } function debugAdapterCreator(guild: any): any { const original = guild.voiceAdapterCreator; return (libraryMethods: any) => { // Wrap library methods to log when Discord gateway events arrive const wrappedLibraryMethods = { ...libraryMethods, onVoiceServerUpdate(data: any) { console.log(`${SB} ← onVoiceServerUpdate: token=${data?.token ? 'yes' : 'no'} endpoint=${data?.endpoint ?? 'none'}`); return libraryMethods.onVoiceServerUpdate(data); }, onVoiceStateUpdate(data: any) { console.log(`${SB} ← onVoiceStateUpdate: session_id=${data?.session_id ? 'yes' : 'no'} channel_id=${data?.channel_id ?? 'none'}`); return libraryMethods.onVoiceStateUpdate(data); }, }; // Call original adapter creator with our wrapped library methods const adapter = original(wrappedLibraryMethods); // Wrap the adapter's sendPayload to log outgoing gateway commands const origSend = adapter.sendPayload.bind(adapter); adapter.sendPayload = (payload: any) => { const result = origSend(payload); console.log(`${SB} → sendPayload op=${payload?.op ?? '?'} guild=${payload?.d?.guild_id ?? '?'} channel=${payload?.d?.channel_id ?? '?'} → ${result}`); return result; }; return adapter; }; } // ── Playback ── let _pluginCtx: PluginContext | null = null; async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { console.log(`${SB} playFilePath: guild=${guildId} channel=${channelId} file=${path.basename(filePath)} vol=${volume ?? 'default'}`); const ctx = _pluginCtx!; const guild = ctx.client.guilds.cache.get(guildId); if (!guild) { console.error(`${SB} Guild ${guildId} not found in cache (cached: ${ctx.client.guilds.cache.map(g => g.id).join(', ')})`); throw new Error('Guild nicht gefunden'); } let state = guildAudioState.get(guildId); if (!state) { console.log(`${SB} No existing audio state, creating new connection...`); const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); // Debug: catch ALL state transitions and errors from the start connection.on('stateChange', (o: any, n: any) => { console.log(`${SB} [conn] ${o.status} → ${n.status}`); // Log networking info if available if (n.networking) console.log(`${SB} [conn] networking state: ${n.networking?.state?.code ?? 'unknown'}`); }); connection.on('error', (err: any) => console.error(`${SB} [conn] ERROR: ${err?.message ?? err}`)); console.log(`${SB} Connection created, initial status=${connection.state.status}, state keys=${Object.keys(connection.state).join(',')}`); const player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); connection.subscribe(player); state = { connection, player, guildId, channelId, currentVolume: getPersistedVolume(guildId) }; guildAudioState.set(guildId, state); state.connection = await ensureConnectionReady(connection, channelId, guildId, guild); attachVoiceLifecycle(state, guild); console.log(`${SB} New voice connection established`); } else { console.log(`${SB} Existing audio state found, connection status=${state.connection.state.status}`); } // Channel-Wechsel try { const current = getVoiceConnection(guildId, 'soundboard'); if (current && current.joinConfig?.channelId !== channelId) { current.destroy(); const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); const player = state.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); connection.subscribe(player); state = { connection, player, guildId, channelId, currentVolume: state.currentVolume ?? getPersistedVolume(guildId) }; guildAudioState.set(guildId, state); state.connection = await ensureConnectionReady(connection, channelId, guildId, guild); attachVoiceLifecycle(state, guild); } } catch {} if (!getVoiceConnection(guildId, 'soundboard')) { const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: debugAdapterCreator(guild), selfMute: false, selfDeaf: false, group: 'soundboard' }); const player = state?.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); connection.subscribe(player); state = { connection, player, guildId, channelId, currentVolume: state?.currentVolume ?? getPersistedVolume(guildId) }; guildAudioState.set(guildId, state); state.connection = await ensureConnectionReady(connection, channelId, guildId, guild); attachVoiceLifecycle(state, guild); } const useVolume = typeof volume === 'number' && Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : (state.currentVolume ?? 1); let resource: AudioResource; if (NORMALIZE_ENABLE) { const cachedPath = getNormCachePath(filePath); if (cachedPath) { const pcmBuf = getPcmFromMemory(cachedPath); if (pcmBuf) { resource = createAudioResource(Readable.from(pcmBuf), { inlineVolume: useVolume !== 1, inputType: StreamType.Raw }); } else { resource = createAudioResource(fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }), { inlineVolume: true, inputType: StreamType.Raw }); } } else { const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath)); const ff = child_process.spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', filePath, '-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`, '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1']); const playerStream = new PassThrough(); const cacheWrite = fs.createWriteStream(cacheFile); ff.stdout.on('data', (chunk: Buffer) => { playerStream.write(chunk); cacheWrite.write(chunk); }); ff.stdout.on('end', () => { playerStream.end(); cacheWrite.end(); try { const buf = fs.readFileSync(cacheFile); if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { pcmMemoryCache.set(cacheFile, buf); pcmMemoryCacheBytes += buf.byteLength; } } catch {} }); ff.on('error', () => { try { fs.unlinkSync(cacheFile); } catch {} }); ff.on('close', (code) => { if (code !== 0) { try { fs.unlinkSync(cacheFile); } catch {} } }); resource = createAudioResource(playerStream, { inlineVolume: true, inputType: StreamType.Raw }); } } else { resource = createAudioResource(filePath, { inlineVolume: true }); } if (resource.volume) resource.volume.setVolume(useVolume); state.player.stop(); console.log(`${SB} Playing resource: vol=${useVolume} normalized=${NORMALIZE_ENABLE} connStatus=${state.connection.state.status}`); // Log player errors state.player.removeAllListeners('error'); state.player.on('error', (err: any) => { console.error(`${SB} AudioPlayer error: ${err?.message ?? err}`); if (err?.resource?.metadata) console.error(`${SB} resource metadata:`, err.resource.metadata); }); state.player.on('stateChange', (oldS: any, newS: any) => { if (oldS.status !== newS.status) console.log(`${SB} Player state: ${oldS.status} → ${newS.status}`); }); state.player.play(resource); state.currentResource = resource; state.currentVolume = useVolume; const soundLabel = relativeKey ? path.parse(relativeKey).name : path.parse(filePath).name; nowPlaying.set(guildId, soundLabel); console.log(`${SB} Now playing: "${soundLabel}" in guild=${guildId}`); sseBroadcast({ type: 'soundboard_nowplaying', plugin: 'soundboard', guildId, name: soundLabel }); if (relativeKey) incrementPlaysFor(relativeKey); } // ── Admin Auth (JWT-like with HMAC) ── type AdminPayload = { iat: number; exp: number }; function b64url(input: Buffer | string): string { return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); } function signAdminToken(adminPwd: string, payload: AdminPayload): string { const body = b64url(JSON.stringify(payload)); const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); return `${body}.${sig}`; } function verifyAdminToken(adminPwd: string, token: string | undefined): boolean { if (!token || !adminPwd) return false; const [body, sig] = token.split('.'); if (!body || !sig) return false; const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); if (expected !== sig) return false; try { const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as AdminPayload; return typeof payload.exp === 'number' && Date.now() < payload.exp; } catch { return false; } } function readCookie(req: express.Request, key: string): string | undefined { const c = req.headers.cookie; if (!c) return undefined; for (const part of c.split(';')) { const [k, v] = part.trim().split('='); if (k === key) return decodeURIComponent(v || ''); } return undefined; } // ── Party Mode ── function schedulePartyPlayback(guildId: string, channelId: string) { const doPlay = async () => { try { const all = listAllSounds(); if (all.length === 0) return; const pick = all[Math.floor(Math.random() * all.length)]; await playFilePath(guildId, channelId, path.join(SOUNDS_DIR, pick.relativePath)); } catch (e) { console.error('[Soundboard] party play error:', e); } }; const loop = async () => { if (!partyActive.has(guildId)) return; await doPlay(); if (!partyActive.has(guildId)) return; const delay = 30_000 + Math.floor(Math.random() * 60_000); partyTimers.set(guildId, setTimeout(loop, delay)); }; partyActive.add(guildId); void loop(); sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: true, channelId }); } // ── Discord Commands (DM) ── async function handleCommand(message: Message, content: string) { const reply = async (txt: string) => { try { await message.author.send?.(txt); } catch { await message.reply(txt); } }; const parts = content.split(/\s+/); const cmd = parts[0].toLowerCase(); if (cmd === '?help') { await reply('Soundboard Commands:\n?help - Hilfe\n?list - Sounds\n?entrance | remove\n?exit | remove'); return; } if (cmd === '?list') { const files = listAllSounds().map(s => s.relativePath); await reply(files.length ? files.join('\n') : 'Keine Dateien.'); return; } if (cmd === '?entrance' || cmd === '?exit') { const isEntrance = cmd === '?entrance'; const map = isEntrance ? 'entranceSounds' : 'exitSounds'; const [, fileNameRaw] = parts; const userId = message.author?.id ?? ''; if (!userId) { await reply('Kein Benutzer erkannt.'); return; } const fileName = fileNameRaw?.trim(); if (!fileName) { await reply(`Verwendung: ${cmd} | remove`); return; } if (/^(remove|clear|delete)$/i.test(fileName)) { persistedState[map] = persistedState[map] ?? {}; delete (persistedState[map] as Record)[userId]; writeState(); await reply(`${isEntrance ? 'Entrance' : 'Exit'}-Sound entfernt.`); return; } if (!/\.(mp3|wav)$/i.test(fileName)) { await reply('Nur .mp3 oder .wav'); return; } const resolve = (() => { try { if (fs.existsSync(path.join(SOUNDS_DIR, fileName))) return fileName; for (const d of fs.readdirSync(SOUNDS_DIR, { withFileTypes: true })) { if (!d.isDirectory()) continue; if (fs.existsSync(path.join(SOUNDS_DIR, d.name, fileName))) return `${d.name}/${fileName}`; } return ''; } catch { return ''; } })(); if (!resolve) { await reply('Datei nicht gefunden. Nutze ?list.'); return; } persistedState[map] = persistedState[map] ?? {}; (persistedState[map] as Record)[userId] = resolve; writeState(); await reply(`${isEntrance ? 'Entrance' : 'Exit'}-Sound gesetzt: ${resolve}`); return; } await reply('Unbekannter Command. Nutze ?help.'); } // ── The Plugin ── const soundboardPlugin: Plugin = { name: 'soundboard', version: '1.0.0', description: 'Discord Soundboard – MP3/WAV Sounds im Voice-Channel abspielen', async init(ctx) { _pluginCtx = ctx; fs.mkdirSync(SOUNDS_DIR, { recursive: true }); fs.mkdirSync(NORM_CACHE_DIR, { recursive: true }); persistedState = readPersistedState(); // Voice encryption libs must be initialized before first voice connection await sodium.ready; void nacl.randomBytes(1); console.log(generateDependencyReport()); console.log(`[Soundboard] ${listAllSounds().length} sounds, ${persistedState.totalPlays ?? 0} total plays`); }, async onReady(ctx) { // Entrance/Exit Sounds ctx.client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => { try { const userId = (newState.id || oldState.id) as string; if (!userId || userId === ctx.client.user?.id) return; const guildId = (newState.guild?.id || oldState.guild?.id) as string; if (!guildId) return; const before = oldState.channelId; const after = newState.channelId; if (after && before !== after) { const file = persistedState.entranceSounds?.[userId]; if (file) { const abs = path.join(SOUNDS_DIR, file.replace(/\\/g, '/')); if (fs.existsSync(abs)) { try { await playFilePath(guildId, after, abs, undefined, file); } catch {} } } } if (before && !after) { const file = persistedState.exitSounds?.[userId]; if (file) { const abs = path.join(SOUNDS_DIR, file.replace(/\\/g, '/')); if (fs.existsSync(abs)) { try { await playFilePath(guildId, before, abs, undefined, file); } catch {} } } } } catch {} }); // DM Commands ctx.client.on(Events.MessageCreate, async (message: Message) => { try { if (message.author?.bot) return; const content = (message.content || '').trim(); if (content.startsWith('?')) { await handleCommand(message, content); return; } if (!message.channel?.isDMBased?.()) return; if (message.attachments.size === 0) return; for (const [, attachment] of message.attachments) { const name = attachment.name ?? 'upload'; if (!/\.(mp3|wav)$/i.test(name)) continue; const safeName = name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); let targetPath = path.join(SOUNDS_DIR, safeName); let i = 2; while (fs.existsSync(targetPath)) { const { name: n, ext } = path.parse(safeName); targetPath = path.join(SOUNDS_DIR, `${n}-${i}${ext}`); i++; } const res = await fetch(attachment.url); if (!res.ok) continue; fs.writeFileSync(targetPath, Buffer.from(await res.arrayBuffer())); if (NORMALIZE_ENABLE) normalizeToCache(targetPath).catch(() => {}); await message.author.send?.(`Sound gespeichert: ${path.basename(targetPath)}`); } } catch {} }); // Norm-Cache Sync syncNormCache(); // Voice stats broadcast setInterval(() => { if (guildAudioState.size === 0) return; for (const [gId, st] of guildAudioState) { const status = st.connection.state?.status ?? 'unknown'; if (status === 'ready' && !connectedSince.has(gId)) connectedSince.set(gId, new Date().toISOString()); const ch = ctx.client.channels.cache.get(st.channelId); sseBroadcast({ type: 'soundboard_voicestats', plugin: 'soundboard', guildId: gId, voicePing: (st.connection.ping as any)?.ws ?? null, gatewayPing: ctx.client.ws.ping, status, channelName: ch && 'name' in ch ? (ch as any).name : null, connectedSince: connectedSince.get(gId) ?? null }); } }, 5_000); }, registerRoutes(app: express.Application, ctx: PluginContext) { const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => { if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; } next(); }; // ── Admin Auth ── app.post('/api/soundboard/admin/login', (req, res) => { if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } const { password } = req.body ?? {}; if (!password || password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; } const token = signAdminToken(ctx.adminPwd, { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600 * 1000 }); res.setHeader('Set-Cookie', `admin=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`); res.json({ ok: true }); }); app.post('/api/soundboard/admin/logout', (_req, res) => { res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax'); res.json({ ok: true }); }); app.get('/api/soundboard/admin/status', (req, res) => { res.json({ authenticated: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) }); }); // ── Sounds ── app.get('/api/soundboard/sounds', (req, res) => { const q = String(req.query.q ?? '').toLowerCase(); const folderFilter = typeof req.query.folder === 'string' ? req.query.folder : '__all__'; const categoryFilter = typeof req.query.categoryId === 'string' ? String(req.query.categoryId) : undefined; const useFuzzy = String(req.query.fuzzy ?? '0') === '1'; const allItems = listAllSounds(); const folderCounts = new Map(); for (const it of allItems) { if (it.folder) folderCounts.set(it.folder, (folderCounts.get(it.folder) ?? 0) + 1); } const folders: Array<{ key: string; name: string; count: number }> = []; for (const [key, count] of folderCounts) folders.push({ key, name: key, count }); const allWithTime = allItems.map(it => { try { return { ...it, mtimeMs: fs.statSync(path.join(SOUNDS_DIR, it.relativePath)).mtimeMs }; } catch { return { ...it, mtimeMs: 0 }; } }); const sortedByNewest = [...allWithTime].sort((a, b) => b.mtimeMs - a.mtimeMs); const recentTop10 = sortedByNewest.slice(0, 10); const recentTop5Set = new Set(recentTop10.slice(0, 5).map(x => x.relativePath)); let itemsByFolder = allItems as ListedSound[]; if (folderFilter !== '__all__') { if (folderFilter === '__recent__') itemsByFolder = recentTop10; else itemsByFolder = allItems.filter(it => folderFilter === '' ? it.folder === '' : it.folder === folderFilter); } function fuzzyScore(text: string, pattern: string): number { if (!pattern) return 1; if (text === pattern) return 2000; const idx = text.indexOf(pattern); if (idx !== -1) return 1000 + (idx === 0 ? 200 : 0) - idx * 2; let tI = 0, pI = 0, score = 0, last = -1, gaps = 0, first = -1; while (tI < text.length && pI < pattern.length) { if (text[tI] === pattern[pI]) { if (first === -1) first = tI; if (last === tI - 1) score += 5; last = tI; pI++; } else if (first !== -1) gaps++; tI++; } if (pI !== pattern.length) return 0; return score + Math.max(0, 300 - first * 2) + Math.max(0, 100 - gaps * 10); } let filteredItems = itemsByFolder; if (q) { if (useFuzzy) { filteredItems = itemsByFolder.map(it => ({ it, score: fuzzyScore(it.name.toLowerCase(), q) })) .filter(x => x.score > 0).sort((a, b) => b.score - a.score || a.it.name.localeCompare(b.it.name)) .map(x => x.it); } else { filteredItems = itemsByFolder.filter(s => s.name.toLowerCase().includes(q)); } } const playsEntries = Object.entries(persistedState.plays || {}); const top3 = playsEntries.sort((a, b) => (b[1] as number) - (a[1] as number)).slice(0, 3) .map(([rel, count]) => { const it = allItems.find(i => i.relativePath === rel || i.fileName === rel); return it ? { key: `__top__:${rel}`, name: `${it.name} (${count})`, count: 1 } : null; }) .filter(Boolean) as Array<{ key: string; name: string; count: number }>; const foldersOut = [ { key: '__all__', name: 'Alle', count: allItems.length }, { key: '__recent__', name: 'Neu', count: Math.min(10, allItems.length) }, ...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []), ...folders ]; let result = filteredItems; if (categoryFilter) { const fc = persistedState.fileCategories ?? {}; result = result.filter(it => (fc[it.relativePath ?? it.fileName] ?? []).includes(categoryFilter)); } if (folderFilter === '__top3__') { const keys = new Set(top3.map(t => t.key.split(':')[1])); result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName)); } const top3Set = new Set(top3.map(t => t.key.split(':')[1])); const customBadges = persistedState.fileBadges ?? {}; const withBadges = result.map(it => { const key = it.relativePath ?? it.fileName; const badges: string[] = []; if (recentTop5Set.has(key)) badges.push('new'); if (top3Set.has(key)) badges.push('rocket'); for (const b of (customBadges[key] ?? [])) badges.push(b); return { ...it, isRecent: recentTop5Set.has(key), badges }; }); res.json({ items: withBadges, total: allItems.length, folders: foldersOut, categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} }); }); // ── Analytics ── app.get('/api/soundboard/analytics', (_req, res) => { const allItems = listAllSounds(); const byKey = new Map(); for (const it of allItems) { byKey.set(it.relativePath, it); if (!byKey.has(it.fileName)) byKey.set(it.fileName, it); } const mostPlayed = Object.entries(persistedState.plays ?? {}) .map(([rel, count]) => { const it = byKey.get(rel); return it ? { name: it.name, relativePath: it.relativePath, count: Number(count) || 0 } : null; }) .filter((x): x is { name: string; relativePath: string; count: number } => !!x) .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)).slice(0, 10); res.json({ totalSounds: allItems.length, totalPlays: persistedState.totalPlays ?? 0, mostPlayed }); }); // ── Channels ── app.get('/api/soundboard/channels', (_req, res) => { if (!ctx.client.isReady()) { res.status(503).json({ error: 'Bot noch nicht bereit' }); return; } const allowed = new Set(ctx.allowedGuildIds); const result: any[] = []; for (const [, guild] of ctx.client.guilds.cache) { if (allowed.size > 0 && !allowed.has(guild.id)) continue; for (const [, ch] of guild.channels.cache) { if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) { const sel = persistedState.selectedChannels?.[guild.id]; result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); } } } result.sort((a: any, b: any) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName)); res.json(result); }); app.get('/api/soundboard/selected-channels', (_req, res) => { res.json({ selected: persistedState.selectedChannels ?? {} }); }); app.post('/api/soundboard/selected-channel', async (req, res) => { const { guildId, channelId } = req.body ?? {}; if (!guildId || !channelId) { res.status(400).json({ error: 'guildId und channelId erforderlich' }); return; } const guild = ctx.client.guilds.cache.get(guildId); if (!guild) { res.status(404).json({ error: 'Guild nicht gefunden' }); return; } const ch = guild.channels.cache.get(channelId); if (!ch || (ch.type !== ChannelType.GuildVoice && ch.type !== ChannelType.GuildStageVoice)) { res.status(400).json({ error: 'Ungültiger Voice-Channel' }); return; } if (!persistedState.selectedChannels) persistedState.selectedChannels = {}; persistedState.selectedChannels[guildId] = channelId; writeState(); sseBroadcast({ type: 'soundboard_channel', plugin: 'soundboard', guildId, channelId }); res.json({ ok: true }); }); // ── Play ── app.post('/api/soundboard/play', async (req, res) => { try { const { soundName, guildId, channelId, volume, folder, relativePath } = req.body ?? {}; console.log(`${SB} POST /play: sound=${soundName} guild=${guildId} channel=${channelId} folder=${folder ?? '-'} relPath=${relativePath ?? '-'}`); if (!soundName || !guildId || !channelId) { console.warn(`${SB} /play missing params`); res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); return; } let filePath: string; if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); else if (folder) { const mp3 = path.join(SOUNDS_DIR, folder, `${soundName}.mp3`); filePath = fs.existsSync(mp3) ? mp3 : path.join(SOUNDS_DIR, folder, `${soundName}.wav`); } else { const mp3 = path.join(SOUNDS_DIR, `${soundName}.mp3`); filePath = fs.existsSync(mp3) ? mp3 : path.join(SOUNDS_DIR, `${soundName}.wav`); } if (!fs.existsSync(filePath)) { console.warn(`${SB} Sound file not found: ${filePath}`); res.status(404).json({ error: 'Sound nicht gefunden' }); return; } console.log(`${SB} Resolved file: ${filePath}`); const relKey = relativePath || (folder ? `${folder}/${soundName}` : soundName); await playFilePath(guildId, channelId, filePath, volume, relKey); res.json({ ok: true }); } catch (e: any) { console.error(`${SB} /play error: ${e?.message ?? e}`); res.status(500).json({ error: e?.message ?? 'Fehler' }); } }); app.post('/api/soundboard/play-url', async (req, res) => { try { const { url, guildId, channelId, volume } = req.body ?? {}; if (!url || !guildId || !channelId) { res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; } let parsed: URL; try { parsed = new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; } if (!parsed.pathname.toLowerCase().endsWith('.mp3')) { res.status(400).json({ error: 'Nur MP3-Links' }); return; } const dest = path.join(SOUNDS_DIR, path.basename(parsed.pathname)); const r = await fetch(url); if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; } fs.writeFileSync(dest, Buffer.from(await r.arrayBuffer())); if (NORMALIZE_ENABLE) { try { await normalizeToCache(dest); } catch {} } try { await playFilePath(guildId, channelId, dest, volume, path.basename(dest)); } catch { res.status(500).json({ error: 'Abspielen fehlgeschlagen' }); return; } res.json({ ok: true, saved: path.basename(dest) }); } catch (e: any) { res.status(500).json({ error: e?.message ?? 'Fehler' }); } }); // ── Volume ── app.post('/api/soundboard/volume', (req, res) => { const { guildId, volume } = req.body ?? {}; if (!guildId || typeof volume !== 'number') { res.status(400).json({ error: 'guildId und volume erforderlich' }); return; } const safeVol = Math.max(0, Math.min(1, volume)); const state = guildAudioState.get(guildId); if (state) { state.currentVolume = safeVol; if (state.currentResource?.volume) state.currentResource.volume.setVolume(safeVol); } persistedState.volumes[guildId] = safeVol; writeState(); sseBroadcast({ type: 'soundboard_volume', plugin: 'soundboard', guildId, volume: safeVol }); res.json({ ok: true, volume: safeVol }); }); app.get('/api/soundboard/volume', (req, res) => { const guildId = String(req.query.guildId ?? ''); if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; } const state = guildAudioState.get(guildId); res.json({ volume: state?.currentVolume ?? getPersistedVolume(guildId) }); }); // ── Stop ── app.post('/api/soundboard/stop', (req, res) => { const guildId = String((req.query.guildId || req.body?.guildId) ?? ''); if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; } const state = guildAudioState.get(guildId); if (!state) { res.status(404).json({ error: 'Kein aktiver Player' }); return; } state.player.stop(true); nowPlaying.delete(guildId); sseBroadcast({ type: 'soundboard_nowplaying', plugin: 'soundboard', guildId, name: '' }); const t = partyTimers.get(guildId); if (t) clearTimeout(t); partyTimers.delete(guildId); partyActive.delete(guildId); sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: false }); res.json({ ok: true }); }); // ── Party ── app.post('/api/soundboard/party/start', (req, res) => { const { guildId, channelId } = req.body ?? {}; if (!guildId || !channelId) { res.status(400).json({ error: 'guildId und channelId erforderlich' }); return; } const old = partyTimers.get(guildId); if (old) clearTimeout(old); partyTimers.delete(guildId); schedulePartyPlayback(guildId, channelId); res.json({ ok: true }); }); app.post('/api/soundboard/party/stop', (req, res) => { const guildId = String(req.body?.guildId ?? ''); if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; } const t = partyTimers.get(guildId); if (t) clearTimeout(t); partyTimers.delete(guildId); partyActive.delete(guildId); sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: false }); res.json({ ok: true }); }); // ── Categories ── app.get('/api/soundboard/categories', (_req, res) => { res.json({ categories: persistedState.categories ?? [] }); }); app.post('/api/soundboard/categories', requireAdmin, (req, res) => { const { name, color, sort } = req.body ?? {}; if (!name?.trim()) { res.status(400).json({ error: 'name erforderlich' }); return; } const cat = { id: crypto.randomUUID(), name: name.trim(), color, sort }; persistedState.categories = [...(persistedState.categories ?? []), cat]; writeState(); res.json({ ok: true, category: cat }); }); app.patch('/api/soundboard/categories/:id', requireAdmin, (req, res) => { const cats = persistedState.categories ?? []; const idx = cats.findIndex(c => c.id === req.params.id); if (idx === -1) { res.status(404).json({ error: 'Nicht gefunden' }); return; } const { name, color, sort } = req.body ?? {}; if (typeof name === 'string') cats[idx].name = name; if (typeof color === 'string') cats[idx].color = color; if (typeof sort === 'number') cats[idx].sort = sort; writeState(); res.json({ ok: true, category: cats[idx] }); }); app.delete('/api/soundboard/categories/:id', requireAdmin, (req, res) => { const cats = persistedState.categories ?? []; if (!cats.find(c => c.id === req.params.id)) { res.status(404).json({ error: 'Nicht gefunden' }); return; } persistedState.categories = cats.filter(c => c.id !== req.params.id); const fc = persistedState.fileCategories ?? {}; for (const k of Object.keys(fc)) fc[k] = (fc[k] ?? []).filter(x => x !== req.params.id); writeState(); res.json({ ok: true }); }); app.post('/api/soundboard/categories/assign', requireAdmin, (req, res) => { const { files, add, remove } = req.body ?? {}; if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; } const validCats = new Set((persistedState.categories ?? []).map(c => c.id)); const fc = persistedState.fileCategories ?? {}; for (const rel of files) { const old = new Set(fc[rel] ?? []); for (const a of (add ?? []).filter((id: string) => validCats.has(id))) old.add(a); for (const r of (remove ?? []).filter((id: string) => validCats.has(id))) old.delete(r); fc[rel] = Array.from(old); } persistedState.fileCategories = fc; writeState(); res.json({ ok: true, fileCategories: fc }); }); // ── Badges ── app.post('/api/soundboard/badges/assign', requireAdmin, (req, res) => { const { files, add, remove } = req.body ?? {}; if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; } const fb = persistedState.fileBadges ?? {}; for (const rel of files) { const old = new Set(fb[rel] ?? []); for (const a of (add ?? [])) old.add(a); for (const r of (remove ?? [])) old.delete(r); fb[rel] = Array.from(old); } persistedState.fileBadges = fb; writeState(); res.json({ ok: true, fileBadges: fb }); }); app.post('/api/soundboard/badges/clear', requireAdmin, (req, res) => { const { files } = req.body ?? {}; if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; } const fb = persistedState.fileBadges ?? {}; for (const rel of files) delete fb[rel]; persistedState.fileBadges = fb; writeState(); res.json({ ok: true, fileBadges: fb }); }); // ── Admin: Delete & Rename ── app.post('/api/soundboard/admin/sounds/delete', requireAdmin, (req, res) => { const { paths: pathsList } = req.body ?? {}; if (!Array.isArray(pathsList) || !pathsList.length) { res.status(400).json({ error: 'paths[] erforderlich' }); return; } const results: any[] = []; for (const rel of pathsList) { const full = safeSoundsPath(rel); if (!full) { results.push({ path: rel, ok: false, error: 'Ungültig' }); continue; } try { if (fs.existsSync(full) && fs.statSync(full).isFile()) { fs.unlinkSync(full); try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(full)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {} results.push({ path: rel, ok: true }); } else results.push({ path: rel, ok: false, error: 'nicht gefunden' }); } catch (e: any) { results.push({ path: rel, ok: false, error: e?.message }); } } res.json({ ok: true, results }); }); app.post('/api/soundboard/admin/sounds/rename', requireAdmin, (req, res) => { const { from, to } = req.body ?? {}; if (!from || !to) { res.status(400).json({ error: 'from und to erforderlich' }); return; } const src = safeSoundsPath(from); if (!src) { res.status(400).json({ error: 'Ungültiger Quellpfad' }); return; } const parsed = path.parse(from); const sanitizedName = to.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); const dstRel = path.join(parsed.dir || '', `${sanitizedName}.mp3`); const dst = safeSoundsPath(dstRel); if (!dst) { res.status(400).json({ error: 'Ungültiger Zielpfad' }); return; } if (!fs.existsSync(src)) { res.status(404).json({ error: 'Quelle nicht gefunden' }); return; } if (fs.existsSync(dst)) { res.status(409).json({ error: 'Ziel existiert bereits' }); return; } fs.renameSync(src, dst); try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(src)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {} res.json({ ok: true, from, to: dstRel }); }); // ── Upload ── const uploadStorage = multer.diskStorage({ destination: (_req: any, _file: any, cb: (e: null, dest: string) => void) => cb(null, SOUNDS_DIR), filename: (_req: any, file: { originalname: string }, cb: (e: null, name: string) => void) => { const safe = file.originalname.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); const { name, ext } = path.parse(safe); let finalName = safe; let i = 2; while (fs.existsSync(path.join(SOUNDS_DIR, finalName))) { finalName = `${name}-${i}${ext}`; i++; } cb(null, finalName); }, }); const uploadMulter = multer({ storage: uploadStorage, fileFilter: (_req: any, file: { originalname: string }, cb: (e: null, ok: boolean) => void) => { cb(null, /\.(mp3|wav)$/i.test(file.originalname)); }, limits: { fileSize: 50 * 1024 * 1024, files: 20 } }); app.post('/api/soundboard/upload', requireAdmin, (req, res) => { uploadMulter.array('files', 20)(req, res, async (err: any) => { if (err) { res.status(400).json({ error: err?.message ?? 'Upload fehlgeschlagen' }); return; } const files = (req as any).files as any[] | undefined; if (!files?.length) { res.status(400).json({ error: 'Keine gültigen Dateien' }); return; } if (NORMALIZE_ENABLE) { for (const f of files) normalizeToCache(f.path).catch(() => {}); } res.json({ ok: true, files: files.map(f => ({ name: f.filename, size: f.size })) }); }); }); // ── Health ── app.get('/api/soundboard/health', (_req, res) => { res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length, sounds: listAllSounds().length }); }); // ── SSE Events (soundboard-specific data in main SSE stream) ── // The main hub SSE already exists at /api/events, snapshot data is provided via getSnapshot() }, getSnapshot() { const statsSnap: Record = {}; for (const [gId, st] of guildAudioState) { const status = st.connection.state?.status ?? 'unknown'; if (status === 'ready' && !connectedSince.has(gId)) connectedSince.set(gId, new Date().toISOString()); const ch = _pluginCtx?.client.channels.cache.get(st.channelId); statsSnap[gId] = { voicePing: (st.connection.ping as any)?.ws ?? null, gatewayPing: _pluginCtx?.client.ws.ping, status, channelName: ch && 'name' in ch ? (ch as any).name : null, connectedSince: connectedSince.get(gId) ?? null }; } return { soundboard: { party: Array.from(partyActive), selected: persistedState?.selectedChannels ?? {}, volumes: persistedState?.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying), voicestats: statsSnap, }, }; }, async destroy() { for (const t of partyTimers.values()) clearTimeout(t); partyTimers.clear(); partyActive.clear(); for (const [gId, state] of guildAudioState) { try { state.player.stop(true); } catch {} try { state.connection.destroy(); } catch {} } guildAudioState.clear(); if (_writeTimer) { clearTimeout(_writeTimer); writeState(); } console.log('[Soundboard] Destroyed'); }, }; export default soundboardPlugin;