2026-03-06 00:51:07 +01:00
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 ,
2026-03-06 01:13:46 +01:00
generateDependencyReport ,
2026-03-06 00:51:07 +01:00
type VoiceConnection , type AudioResource ,
} from '@discordjs/voice' ;
2026-03-06 01:13:46 +01:00
import sodium from 'libsodium-wrappers' ;
import nacl from 'tweetnacl' ;
2026-03-06 00:51:07 +01:00
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 < string , number > ;
plays : Record < string , number > ;
totalPlays : number ;
categories? : Category [ ] ;
fileCategories? : Record < string , string [ ] > ;
fileBadges? : Record < string , string [ ] > ;
selectedChannels? : Record < string , string > ;
entranceSounds? : Record < string , string > ;
exitSounds? : Record < string , string > ;
} ;
type ListedSound = { fileName : string ; name : string ; folder : string ; relativePath : string } ;
type GuildAudioState = {
connection : VoiceConnection ;
player : ReturnType < typeof createAudioPlayer > ;
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 < typeof setTimeout > | 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 < string > {
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 < string , Buffer > ( ) ;
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 < void > {
if ( ! NORMALIZE_ENABLE ) return ;
const t0 = Date . now ( ) ;
const allSounds = listAllSounds ( ) ;
const expectedKeys = new Set < string > ( ) ;
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 < void > {
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 < string , GuildAudioState > ( ) ;
const partyTimers = new Map < string , NodeJS.Timeout > ( ) ;
const partyActive = new Set < string > ( ) ;
const nowPlaying = new Map < string , string > ( ) ;
const connectedSince = new Map < string , string > ( ) ;
// ── Voice Lifecycle ──
async function ensureConnectionReady ( connection : VoiceConnection , channelId : string , guildId : string , guild : any ) : Promise < VoiceConnection > {
try { await entersState ( connection , VoiceConnectionStatus . Ready , 15 _000 ) ; return connection ; } catch { }
try { connection . rejoin ( { channelId , selfDeaf : false , selfMute : false } ) ; await entersState ( connection , VoiceConnectionStatus . Ready , 15 _000 ) ; return connection ; } catch { }
try { connection . destroy ( ) ; } catch { }
guildAudioState . delete ( guildId ) ;
const newConn = joinVoiceChannel ( { channelId , guildId , adapterCreator : guild.voiceAdapterCreator as any , selfMute : false , selfDeaf : false } ) ;
try { await entersState ( newConn , VoiceConnectionStatus . Ready , 15 _000 ) ; return newConn ; }
catch { 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 ) = > {
if ( newS . status === VoiceConnectionStatus . Ready ) {
reconnectAttempts = 0 ; isReconnecting = false ;
if ( ! connectedSince . has ( state . guildId ) ) connectedSince . set ( state . guildId , new Date ( ) . toISOString ( ) ) ;
return ;
}
if ( isReconnecting ) return ;
try {
if ( newS . status === VoiceConnectionStatus . Disconnected ) {
try { await Promise . race ( [ entersState ( connection , VoiceConnectionStatus . Signalling , 5 _000 ) , entersState ( connection , VoiceConnectionStatus . Connecting , 5 _000 ) ] ) ; }
catch {
if ( reconnectAttempts < MAX_RECONNECT_ATTEMPTS ) { reconnectAttempts ++ ; connection . rejoin ( { channelId : state.channelId , selfDeaf : false , selfMute : false } ) ; }
else { reconnectAttempts = 0 ; try { connection . destroy ( ) ; } catch { }
const nc = joinVoiceChannel ( { channelId : state.channelId , guildId : state.guildId , adapterCreator : guild.voiceAdapterCreator as any , selfMute : false , selfDeaf : false } ) ;
state . connection = nc ; nc . subscribe ( state . player ) ; attachVoiceLifecycle ( state , guild ) ; }
}
} else if ( newS . status === VoiceConnectionStatus . Destroyed ) {
connectedSince . delete ( state . guildId ) ;
const nc = joinVoiceChannel ( { channelId : state.channelId , guildId : state.guildId , adapterCreator : guild.voiceAdapterCreator as any , selfMute : false , selfDeaf : false } ) ;
state . connection = nc ; nc . subscribe ( state . player ) ; attachVoiceLifecycle ( state , guild ) ;
} else if ( newS . status === VoiceConnectionStatus . Connecting || newS . status === VoiceConnectionStatus . Signalling ) {
isReconnecting = true ;
try { await entersState ( connection , VoiceConnectionStatus . Ready , 15 _000 ) ; }
catch {
reconnectAttempts ++ ;
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 ; try { connection . destroy ( ) ; } catch { }
const nc = joinVoiceChannel ( { channelId : state.channelId , guildId : state.guildId , adapterCreator : guild.voiceAdapterCreator as any , selfMute : false , selfDeaf : false } ) ;
state . connection = nc ; nc . subscribe ( state . player ) ; attachVoiceLifecycle ( state , guild ) ; }
}
}
} catch { isReconnecting = false ; }
} ) ;
( connection as any ) . __lifecycleAttached = true ;
}
// ── Playback ──
let _pluginCtx : PluginContext | null = null ;
async function playFilePath ( guildId : string , channelId : string , filePath : string , volume? : number , relativeKey? : string ) : Promise < void > {
const ctx = _pluginCtx ! ;
const guild = ctx . client . guilds . cache . get ( guildId ) ;
if ( ! guild ) throw new Error ( 'Guild nicht gefunden' ) ;
let state = guildAudioState . get ( guildId ) ;
if ( ! state ) {
const connection = joinVoiceChannel ( { channelId , guildId , adapterCreator : guild.voiceAdapterCreator as any , selfMute : false , selfDeaf : false } ) ;
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 ) ;
}
// Channel-Wechsel
try {
const current = getVoiceConnection ( guildId ) ;
if ( current && current . joinConfig ? . channelId !== channelId ) {
current . destroy ( ) ;
const connection = joinVoiceChannel ( { channelId , guildId , adapterCreator : guild.voiceAdapterCreator as any , selfMute : false , selfDeaf : false } ) ;
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 ) ) {
const connection = joinVoiceChannel ( { channelId , guildId , adapterCreator : guild.voiceAdapterCreator as any , selfMute : false , selfDeaf : false } ) ;
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 ( ) ;
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 ) ;
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 <file> | remove\n?exit <file> | 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 } <datei.mp3|datei.wav> | remove ` ) ; return ; }
if ( /^(remove|clear|delete)$/i . test ( fileName ) ) {
persistedState [ map ] = persistedState [ map ] ? ? { } ;
delete ( persistedState [ map ] as Record < string , string > ) [ 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 < string , string > ) [ 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 ( ) ;
2026-03-06 01:13:46 +01:00
// Voice encryption libs must be initialized before first voice connection
await sodium . ready ;
void nacl . randomBytes ( 1 ) ;
console . log ( generateDependencyReport ( ) ) ;
2026-03-06 00:51:07 +01:00
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 < string , number > ( ) ;
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 < string , ListedSound > ( ) ;
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 ? ? { } ;
if ( ! soundName || ! guildId || ! channelId ) { 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 ) ) { res . status ( 404 ) . json ( { error : 'Sound nicht gefunden' } ) ; return ; }
const relKey = relativePath || ( folder ? ` ${ folder } / ${ soundName } ` : soundName ) ;
await playFilePath ( guildId , channelId , filePath , volume , relKey ) ;
res . json ( { ok : true } ) ;
} catch ( e : any ) { 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 < string , any > = { } ;
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 ;