package main import ( "database/sql" "encoding/json" "fmt" "log" "os" "os/signal" "strconv" "strings" "sync" "syscall" "time" "github.com/bwmarrin/discordgo" _ "modernc.org/sqlite" // SQLite3-Treiber importieren ) // ===== Defaults / Names (pro Guild per Commands konfigurierbar) ===== var ( pollInterval = 15 * time.Second defaultLobbyName = "➕ Erstelle privaten Raum" defaultCategory = "Private Räume" ) // Pfad zur SQLite-Datenbank var dbPath = func() string { return GetENV("DB_PATH", "guild_config.db") }() // ===== Per-Guild Config (in-memory) ===== type GuildConfig struct { LobbyName string `json:"lobby_name"` CategoryName string `json:"category_name"` TimeoutMin int `json:"timeout_min"` Language string `json:"language"` // Neue Eigenschaft für Sprache } type LobbyRule struct { LobbyName string CategoryName string TimeoutMin int } func getLobbyRules(guildID string) ([]LobbyRule, error) { rows, err := db.Query(`SELECT lobby_name, category_name, timeout_min FROM lobby_rule WHERE guild_id = ?`, guildID) if err != nil { return nil, err } defer rows.Close() var rules []LobbyRule for rows.Next() { var r LobbyRule if err := rows.Scan(&r.LobbyName, &r.CategoryName, &r.TimeoutMin); err != nil { return nil, err } rules = append(rules, r) } return rules, rows.Err() } func upsertLobbyRule(guildID, lobby, category string, timeout int) error { if timeout < 1 { timeout = 1 } _, err := db.Exec(` INSERT INTO lobby_rule (guild_id, lobby_name, category_name, timeout_min) VALUES (?, ?, ?, ?) ON CONFLICT(guild_id, lobby_name) DO UPDATE SET category_name=excluded.category_name, timeout_min=excluded.timeout_min `, guildID, lobby, category, timeout) return err } func deleteLobbyRule(guildID, lobby string) (bool, error) { res, err := db.Exec(`DELETE FROM lobby_rule WHERE guild_id=? AND lobby_name=?`, guildID, lobby) if err != nil { return false, err } n, _ := res.RowsAffected() return n > 0, nil } var db *sql.DB // ===== Global variables for sync.Map ===== var guildCfgs sync.Map // Für Guild-Konfigurationen var createdCmds sync.Map // Für erstellte Commands // Sprache für eine Guild setzen func setLanguage(guildID, language string) error { cfg := getCfg(guildID) cfg.Language = language return saveGuildCfgs() } // Sprachabruf func getLanguage(guildID string) string { cfg := getCfg(guildID) fmt.Println("Debug: Language:", cfg.Language) return cfg.Language } // neue Tabelle für mehrere Lobby-Regeln pro Guild func ensureLobbyRuleTable() { const create = ` CREATE TABLE IF NOT EXISTS lobby_rule ( id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id TEXT NOT NULL, lobby_name TEXT NOT NULL, category_name TEXT NOT NULL, timeout_min INTEGER NOT NULL DEFAULT 60, UNIQUE (guild_id, lobby_name) ); CREATE INDEX IF NOT EXISTS idx_lobby_rule_guild ON lobby_rule(guild_id); ` _, err := db.Exec(create) if err != nil { log.Fatalf("Fehler beim Erstellen lobby_rule: %v", err) } } // Funktion zum Hinzufügen der 'language' Spalte, falls sie nicht existiert func addLanguageColumnIfNotExists() { // Überprüfen, ob die Tabelle 'guild_config' existiert var tableExists bool err := db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='guild_config'").Scan(&tableExists) if err != nil || !tableExists { log.Printf("Tabelle 'guild_config' existiert nicht: %v", err) } // Überprüfen, ob die Spalte 'language' existiert _, err = db.Exec("ALTER TABLE guild_config ADD COLUMN language TEXT DEFAULT 'de'") if err != nil && !isColumnAlreadyExistsError(err) { log.Printf("Fehler beim Hinzufügen der 'language' Spalte: %v", err) } else { log.Println("Spalte 'language' wurde hinzugefügt oder existiert bereits.") } } // Hilfsfunktion zur Fehlerbehandlung, um zu überprüfen, ob der Fehler auf eine bereits vorhandene Spalte hinweist func isColumnAlreadyExistsError(err error) bool { // Fehlerbehandlung für 'duplicate column name' return err != nil && err.Error() == "SQLITE_ERROR: duplicate column name: language" } // Initialize DB func initDB() { var err error db, err = sql.Open("sqlite", dbPath) if err != nil { log.Fatalf("Datenbank-Fehler: %v", err) } // Tabelle erstellen, falls sie noch nicht existiert createTableSQL := `CREATE TABLE IF NOT EXISTS guild_config ( guild_id TEXT PRIMARY KEY, lobby_name TEXT, category_name TEXT, timeout_min INTEGER, language TEXT DEFAULT 'de' -- Neue Spalte für Sprache hinzufügen );` _, err = db.Exec(createTableSQL) if err != nil { log.Fatalf("Fehler beim Erstellen der Tabelle: %v", err) } // Neue Spalte für die Sprache hinzufügen, wenn sie noch nicht existiert addLanguageColumnIfNotExists() ensureLobbyRuleTable() } // Close DB connection func closeDB() { if err := db.Close(); err != nil { log.Fatalf("Fehler beim Schließen der DB: %v", err) } } // Laden der Guild-Konfiguration mit Sprache func loadGuildCfgs() error { rows, err := db.Query("SELECT guild_id, lobby_name, category_name, timeout_min, language FROM guild_config") if err != nil { return err } defer rows.Close() for rows.Next() { var guildID, lobbyName, categoryName, language string var timeoutMin int if err := rows.Scan(&guildID, &lobbyName, &categoryName, &timeoutMin, &language); err != nil { return err } guildCfgs.Store(guildID, &GuildConfig{ LobbyName: lobbyName, CategoryName: categoryName, TimeoutMin: timeoutMin, Language: language, // Sprache laden }) } return nil } // Speichern der Guild-Konfiguration mit Sprache func saveGuildCfgs() error { tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() guildCfgs.Range(func(key, value interface{}) bool { guildID := key.(string) cfg := value.(*GuildConfig) _, err := tx.Exec(` INSERT INTO guild_config (guild_id, lobby_name, category_name, timeout_min, language) VALUES (?, ?, ?, ?, ?) ON CONFLICT(guild_id) DO UPDATE SET lobby_name = excluded.lobby_name, category_name = excluded.category_name, timeout_min = excluded.timeout_min, language = excluded.language -- Sprache speichern `, guildID, cfg.LobbyName, cfg.CategoryName, cfg.TimeoutMin, cfg.Language) if err != nil { log.Printf("Fehler beim Speichern der Guild-Konfiguration: %v", err) } return true }) // Transaktion abschließen return tx.Commit() } // Guild-Konfiguration laden oder Standardwerte zurückgeben func getCfg(guildID string) *GuildConfig { if cfg, ok := guildCfgs.Load(guildID); ok { return cfg.(*GuildConfig) } var guildCfg GuildConfig err := db.QueryRow("SELECT lobby_name, category_name, timeout_min, language FROM guild_config WHERE guild_id = ?", guildID).Scan(&guildCfg.LobbyName, &guildCfg.CategoryName, &guildCfg.TimeoutMin, &guildCfg.Language) if err != nil { if err == sql.ErrNoRows { log.Printf("Guild-Konfiguration für %s nicht gefunden, verwenden der Standardwerte", guildID) } else { log.Printf("Fehler beim Abrufen der Guild-Konfiguration für %s: %v", guildID, err) } // Standardwerte verwenden guildCfg = GuildConfig{ LobbyName: defaultLobbyName, CategoryName: defaultCategory, TimeoutMin: envTimeoutDefault(1), Language: "de", // Standard-Sprache ist Deutsch } } guildCfgs.Store(guildID, &guildCfg) return &guildCfg } func envTimeoutDefault(def int) int { if v := os.Getenv("TIMEOUT_MIN"); v != "" { var x int if _, err := fmt.Sscanf(v, "%d", &x); err == nil && x > 0 { return x } } return def } // Optional: Cache-Invalidierung bei Channel-Events (empfohlen) func hookCategoryCacheInvalidation(s *discordgo.Session) { s.AddHandler(func(_ *discordgo.Session, e *discordgo.ChannelDelete) { if e.Channel != nil && e.Channel.Type == discordgo.ChannelTypeGuildCategory { // alle Keys mit dieser ID rauswerfen catCache.Range(func(k, v interface{}) bool { if v.(cacheEntry).id == e.ID { catCache.Delete(k) } return true }) } }) s.AddHandler(func(_ *discordgo.Session, e *discordgo.ChannelUpdate) { if e.Channel != nil && e.Channel.Type == discordgo.ChannelTypeGuildCategory { // Bei Rename nicht perfekt zu erkennen -> TTL sorgt für Refresh // Optional: hart löschen: catCache.Range(func(k, v interface{}) bool { if v.(cacheEntry).id == e.Channel.ID { catCache.Delete(k) } return true }) } }) } // ===== Helpers: Channel/Kategorie finden (mit Cache) ===== func findOrCreateCategoryID(s *discordgo.Session, guildID, name string) (string, error) { key := guildID + "|" + name // 0) Cache if v, ok := catCache.Load(key); ok { ce := v.(cacheEntry) if time.Since(ce.t) < cacheTTL { return ce.id, nil } } // 1) State (schnell) – vorausgesetzt: s.State.TrackChannels = true (vor s.Open() setzen!) if g, err := s.State.Guild(guildID); err == nil { for _, ch := range g.Channels { if ch.Type == discordgo.ChannelTypeGuildCategory && ch.Name == name { catCache.Store(key, cacheEntry{ch.ID, time.Now()}) return ch.ID, nil } } } // 2) REST (Fallback) chans, err := s.GuildChannels(guildID) if err == nil { for _, ch := range chans { if ch.Type == discordgo.ChannelTypeGuildCategory && ch.Name == name { catCache.Store(key, cacheEntry{ch.ID, time.Now()}) return ch.ID, nil } } } else { // wenn selbst Channels holen fehlschlägt, gib den Fehler zurück return "", err } // 3) Nicht gefunden -> anlegen cat, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{ Name: name, Type: discordgo.ChannelTypeGuildCategory, }) if err != nil { return "", err } catCache.Store(key, cacheEntry{cat.ID, time.Now()}) return cat.ID, nil } var ( vcCache sync.Map // key: guildID+"|"+name → cacheEntry catCache sync.Map // key: guildID+"|"+name -> cacheEntry cacheTTL = 12 * time.Hour ) type cacheEntry struct { id string t time.Time } // Schneller: erst State, dann REST, plus kleiner TTL-Cache func findVoiceChannelIDByName(s *discordgo.Session, guildID, name string) string { key := guildID + "|" + name if v, ok := vcCache.Load(key); ok { ce := v.(cacheEntry) if time.Since(ce.t) < cacheTTL { return ce.id } } // 1) State (sehr schnell) if g, err := s.State.Guild(guildID); err == nil { for _, ch := range g.Channels { if ch.Type == discordgo.ChannelTypeGuildVoice && ch.Name == name { vcCache.Store(key, cacheEntry{ch.ID, time.Now()}) return ch.ID } } } // 2) REST (Fallback, teuer) chans, err := s.GuildChannels(guildID) if err != nil { return "" } for _, ch := range chans { if ch.Type == discordgo.ChannelTypeGuildVoice && ch.Name == name { vcCache.Store(key, cacheEntry{ch.ID, time.Now()}) return ch.ID } } return "" } // Noch schneller: gezielt VoiceState aus dem State ziehen func findUserVoiceChannelID(s *discordgo.Session, guildID, userID string) string { // 1) Direkt per VoiceState (O(1), wenn im State vorhanden) if vs, err := s.State.VoiceState(guildID, userID); err == nil && vs != nil && vs.ChannelID != "" { return vs.ChannelID } // 2) State.Guild als Fallback (O(n), aber ohne REST) if g, err := s.State.Guild(guildID); err == nil { for _, vs := range g.VoiceStates { if vs.UserID == userID && vs.ChannelID != "" { return vs.ChannelID } } } // 3) REST (letzter Ausweg) g, err := s.Guild(guildID) if err != nil { return "" } for _, vs := range g.VoiceStates { if vs.UserID == userID && vs.ChannelID != "" { return vs.ChannelID } } return "" } // ===== Anzeige-Helper ===== func safeDisplayName(m *discordgo.Member) string { if m == nil { return "privat" } if m.Nick != "" { return m.Nick } if m.User.GlobalName != "" { return m.User.GlobalName } return m.User.Username } func displayName(i *discordgo.InteractionCreate) string { if i.Member != nil && i.Member.Nick != "" { return i.Member.Nick } if i.Member != nil && i.Member.User.GlobalName != "" { return i.Member.User.GlobalName } if i.Member != nil { return i.Member.User.Username } if i.User != nil && i.User.GlobalName != "" { return i.User.GlobalName } if i.User != nil { return i.User.Username } return "privat" } // ===== Unified Create + Move + Cleanup ===== func createPrivateVCAndMove( s *discordgo.Session, guildID, requesterID, disp, categoryID string, userLimit, timeoutMin int, srcChannelID string, // Quelle für Move (Lobby/aktueller VC); leer = kein Move ) (*discordgo.Channel, error) { botID := s.State.User.ID name := "🔒 " + disp allowOwner := int64( discordgo.PermissionViewChannel | discordgo.PermissionVoiceConnect | discordgo.PermissionVoiceSpeak | discordgo.PermissionVoiceUseVAD | discordgo.PermissionVoiceStreamVideo | discordgo.PermissionManageChannels, ) denyEveryone := int64( discordgo.PermissionViewChannel | discordgo.PermissionVoiceConnect, ) allowBot := int64( discordgo.PermissionViewChannel | discordgo.PermissionVoiceConnect | discordgo.PermissionVoiceMoveMembers | discordgo.PermissionManageChannels, ) newChan, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{ Name: name, Type: discordgo.ChannelTypeGuildVoice, ParentID: categoryID, UserLimit: userLimit, Bitrate: 64000, PermissionOverwrites: []*discordgo.PermissionOverwrite{ {ID: guildID, Type: discordgo.PermissionOverwriteTypeRole, Deny: denyEveryone}, {ID: requesterID, Type: discordgo.PermissionOverwriteTypeMember, Allow: allowOwner}, {ID: botID, Type: discordgo.PermissionOverwriteTypeMember, Allow: allowBot}, }, }) if err != nil { return nil, fmt.Errorf("VC-Anlage fehlgeschlagen: %w", err) } // optionaler Move if srcChannelID != "" { go func() { time.Sleep(200 * time.Millisecond) // Best-effort Perm Checks if perms, err := s.UserChannelPermissions(botID, srcChannelID); err == nil { need := int64(discordgo.PermissionVoiceMoveMembers) if perms&need == 0 { log.Printf("WARN: Bot hat im Quell-Channel keine MoveMembers-Permission") } } if perms, err := s.UserChannelPermissions(botID, newChan.ID); err == nil { need := int64(discordgo.PermissionVoiceMoveMembers) if perms&need == 0 { log.Printf("WARN: Bot hat im Ziel-Channel keine MoveMembers-Permission") } } var moveErr error for attempt := 0; attempt < 5; attempt++ { moveErr = s.GuildMemberMove(guildID, requesterID, &newChan.ID) if moveErr == nil { break } wait := time.Duration(150*(attempt+1)) * time.Millisecond log.Printf("Move fehlgeschlagen (Versuch %d): %v - retry in %s", attempt+1, moveErr, wait) time.Sleep(wait) } if moveErr != nil { log.Printf("Move endgültig fehlgeschlagen: %v", moveErr) } }() } go watchAndCleanup(s, guildID, newChan.ID, time.Duration(timeoutMin)*time.Minute) log.Println("➕ Added channel for guildID: " + guildID + " with new ID: " + newChan.ID) return newChan, nil } // ===== Auto-Cleanup ===== func watchAndCleanup(s *discordgo.Session, guildID, channelID string, timeout time.Duration) { lastActive := time.Now() ticker := time.NewTicker(pollInterval) defer ticker.Stop() for range ticker.C { g, err := s.State.Guild(guildID) if err != nil { g, err = s.Guild(guildID) if err != nil { continue } } occupied := false for _, vs := range g.VoiceStates { if vs.ChannelID == channelID { occupied = true break } } if occupied { lastActive = time.Now() continue } if time.Since(lastActive) >= timeout { _, _ = s.ChannelDelete(channelID) log.Println("➖ Deleted channel for guildID: " + guildID + " with ID: " + channelID) return } } } // ===== Handlers ===== // Lobby-Join → VC erstellen & move (per Namen aus GuildConfig) func onVoiceStateUpdate(s *discordgo.Session, e *discordgo.VoiceStateUpdate) { if e.UserID == "" { return } // 1) Versuch: Regeln aus DB rules, err := getLobbyRules(e.GuildID) if err != nil { log.Printf("getLobbyRules: %v", err) } if len(rules) > 0 { // checke, ob der Join in eine der konfigurierten Lobbys erfolgte var matched *LobbyRule for idx := range rules { lobbyID := findVoiceChannelIDByName(s, e.GuildID, rules[idx].LobbyName) if lobbyID != "" && e.ChannelID == lobbyID && (e.BeforeUpdate == nil || e.BeforeUpdate.ChannelID != lobbyID) { matched = &rules[idx] break } } if matched == nil { return } m, _ := s.GuildMember(e.GuildID, e.UserID) if m != nil && m.User.Bot { return } catID, err := findOrCreateCategoryID(s, e.GuildID, matched.CategoryName) if err != nil { log.Printf("Kategorie: %v", err) return } _, err = createPrivateVCAndMove(s, e.GuildID, e.UserID, safeDisplayName(m), catID, 0, matched.TimeoutMin, e.ChannelID) if err != nil { log.Printf("VC/Move: %v", err) } return } // 2) Fallback: alte Single-Config (deine bisherige Logik) cfg := getCfg(e.GuildID) lobby := findVoiceChannelIDByName(s, e.GuildID, cfg.LobbyName) if lobby == "" || e.ChannelID != lobby || (e.BeforeUpdate != nil && e.BeforeUpdate.ChannelID == lobby) { return } m, err := s.GuildMember(e.GuildID, e.UserID) if err == nil && m.User.Bot { return } catID, err := findOrCreateCategoryID(s, e.GuildID, cfg.CategoryName) if err != nil { log.Printf("Kategorie-Auflösung fehlgeschlagen: %v", err) return } _, err = createPrivateVCAndMove(s, e.GuildID, e.UserID, safeDisplayName(m), catID, 0, cfg.TimeoutMin, lobby) if err != nil { log.Printf("VC-Erstellung/Move fehlgeschlagen: %v", err) } } // ===== Permissions Helper ===== func isGuildAdmin(s *discordgo.Session, guildID string, user *discordgo.User, member *discordgo.Member) bool { if user == nil && member == nil { return false } if member == nil { m, err := s.GuildMember(guildID, user.ID) if err != nil { return false } member = m } g, err := s.State.Guild(guildID) if err == nil && g.OwnerID == member.User.ID { return true } roles, err := s.GuildRoles(guildID) if err != nil { return false } rolePerm := int64(0) for _, r := range roles { for _, mr := range member.Roles { if r.ID == mr { rolePerm |= r.Permissions } } } if rolePerm&int64(discordgo.PermissionAdministrator) != 0 { return true } if rolePerm&int64(discordgo.PermissionManageGuild) != 0 { // Fallback: Manage Server reicht auch return true } return false } // Slash-Commands: makevc / setlobby / setcategory / settimeout (Admin-geschützt) func onInteractionCreate(_ string) func(s *discordgo.Session, i *discordgo.InteractionCreate) { return func(s *discordgo.Session, i *discordgo.InteractionCreate) { if i.Type != discordgo.InteractionApplicationCommand { return } data := i.ApplicationCommandData() guildID := i.GuildID switch data.Name { case "makevc": cfg := getCfg(guildID) user := i.User if user == nil && i.Member != nil { user = i.Member.User } if user == nil { return } userLimit := 0 timeoutMin := cfg.TimeoutMin var nameOpt string for _, o := range data.Options { switch o.Name { case "name": nameOpt = o.StringValue() case "user_limit": userLimit = int(o.IntValue()) case "timeout_min": timeoutMin = int(o.IntValue()) } } display := displayName(i) if nameOpt != "" { display = nameOpt } catID, err := findOrCreateCategoryID(s, guildID, cfg.CategoryName) if err != nil { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "Konnte Kategorie nicht finden/erstellen.", Flags: discordgo.MessageFlagsEphemeral}}) return } srcVC := findUserVoiceChannelID(s, guildID, user.ID) newChan, err := createPrivateVCAndMove(s, guildID, user.ID, display, catID, userLimit, timeoutMin, srcVC) if err != nil { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "Konnte Voice-Channel nicht erstellen. Fehlen dem Bot Rechte (Manage Channels/Move Members)?", Flags: discordgo.MessageFlagsEphemeral}}) return } msg := fmt.Sprintf(translations[getLanguage(guildID)]["response.voicechannel.created"], newChan.Name) if srcVC == "" { msg += translations[getLanguage(guildID)]["response.voicechannel.created.notinvoice"] } else { msg += translations[getLanguage(guildID)]["response.voicechannel.created.move"] } _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: msg, Flags: discordgo.MessageFlagsEphemeral}}) case "setlobby": // Admin-Check zuerst if !isGuildAdmin(s, guildID, i.User, i.Member) { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: translations[getLanguage(guildID)]["response.setlobby.onlyadmins"], Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Option "name" lesen var name string for _, o := range data.Options { if o.Name == "name" { name = o.StringValue() break } } if name == "" { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: translations[getLanguage(guildID)]["response.setlobby.pleaseentername"], Flags: discordgo.MessageFlagsEphemeral, }, }) return } // speichern getCfg(guildID).LobbyName = name if err := saveGuildCfgs(); err != nil { log.Printf("saveGuildCfgs failed: %v", err) _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: translations[getLanguage(guildID)]["response.setlobby.error.saving"], Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Antwort <= 3s msg := fmt.Sprintf(translations[getLanguage(guildID)]["response.setlobby.success"], name) _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: msg, Flags: discordgo.MessageFlagsEphemeral, }, }) case "setcategory": // Admin-Check zuerst if !isGuildAdmin(s, guildID, i.User, i.Member) { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "❌ Nur Administratoren dürfen das.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Option "name" lesen var name string for _, o := range data.Options { if o.Name == "name" { name = o.StringValue() break } } if name == "" { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Bitte gib einen Namen an.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } // speichern getCfg(guildID).CategoryName = name if err := saveGuildCfgs(); err != nil { log.Printf("saveGuildCfgs failed: %v", err) _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "⚠️ Kategorie-Name gesetzt, aber Speichern fehlgeschlagen. Schau die Logs.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Antwort <= 3s _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "✅ Kategorie-Name aktualisiert auf: " + name, Flags: discordgo.MessageFlagsEphemeral, }, }) case "settimeout": if !isGuildAdmin(s, guildID, i.User, i.Member) { log.Println("User is not an admin") _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "❌ Nur Administratoren dürfen das.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } minutes := int64(getCfg(guildID).TimeoutMin) for _, o := range data.Options { if o.Name == "minutes" { minutes = o.IntValue() } } if minutes < 1 { minutes = 1 } log.Printf("Setting timeout for %d minutes", minutes) // speichern getCfg(guildID).TimeoutMin = int(minutes) if err := saveGuildCfgs(); err != nil { log.Printf("saveGuildCfgs failed: %v", err) _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "⚠️ Timeout gesetzt, aber Speichern fehlgeschlagen. Schau die Logs.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Antwort <= 3s _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("✅ Timeout auf %d Minuten gesetzt.", minutes), Flags: discordgo.MessageFlagsEphemeral, }, }) case "adduser": // wer ruft auf? requester := i.User if requester == nil && i.Member != nil { requester = i.Member.User } if requester == nil { return } // Ziel-User aus den Optionen holen var target *discordgo.User for _, o := range data.Options { if o.Name == "user" { // UserValue braucht die Session u := o.UserValue(s) if u != nil { target = u } } } if target == nil { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Bitte gib ein gültiges Mitglied an.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } // In welchem VC ist der Aufrufer gerade? srcVC := findUserVoiceChannelID(s, guildID, requester.ID) if srcVC == "" { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Du bist aktuell in keinem Voice-Channel. Betritt zuerst deinen privaten Channel.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Sicherheits-Check: darf der Aufrufer diesen Channel verwalten? // (Der Owner bekommt in deinem Code ManageChannels → das ist unser Indikator) perms, err := s.UserChannelPermissions(requester.ID, srcVC) if err != nil || perms&int64(discordgo.PermissionManageChannels) == 0 { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "❌ Du bist nicht der Besitzer dieses Channels oder dir fehlen Rechte (Manage Channel).", Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Overwrite setzen: Ziel-User darf sehen & beitreten (& sprechen/Stream/VAD) allow := int64( discordgo.PermissionViewChannel | discordgo.PermissionVoiceConnect | discordgo.PermissionVoiceSpeak | discordgo.PermissionVoiceUseVAD | discordgo.PermissionVoiceStreamVideo, ) if err := s.ChannelPermissionSet( srcVC, target.ID, discordgo.PermissionOverwriteTypeMember, allow, 0, ); err != nil { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("Konnte Berechtigung nicht setzen: %v", err), Flags: discordgo.MessageFlagsEphemeral, }, }) return } _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("✅ %s hat jetzt Zugriff auf deinen Voice-Channel.", target.Username), Flags: discordgo.MessageFlagsEphemeral, }, }) case "setlanguage": // Admin-Check zuerst if !isGuildAdmin(s, guildID, i.User, i.Member) { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "❌ Nur Administratoren dürfen das.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Option "language" lesen var language string for _, o := range data.Options { if o.Name == "language" { language = o.StringValue() break } } if language == "" { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Bitte gib eine gültige Sprache an (z.B. 'de', 'en').", Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Speichern if err := setLanguage(guildID, language); err != nil { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "⚠️ Fehler beim Speichern der Sprache.", Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Antwort <= 3s _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: fmt.Sprintf("✅ Sprache auf '%s' gesetzt.", language), Flags: discordgo.MessageFlagsEphemeral, }, }) case "addlobby": if !isGuildAdmin(s, guildID, i.User, i.Member) { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "❌ Admins only.", Flags: discordgo.MessageFlagsEphemeral}, }) return } var lobby, category string timeout := int64(envTimeoutDefault(60)) for _, o := range data.Options { switch o.Name { case "lobby": lobby = o.StringValue() case "category": category = o.StringValue() case "timeout": timeout = o.IntValue() } } if lobby == "" || category == "" { /* antworten mit Fehler */ } if err := upsertLobbyRule(guildID, lobby, category, int(timeout)); err != nil { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("⚠️ Failed: %v", err), Flags: discordgo.MessageFlagsEphemeral}, }) return } _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("✅ Rule saved: lobby \"%s\" → category \"%s\" (timeout %d min).", lobby, category, timeout), Flags: discordgo.MessageFlagsEphemeral}, }) case "removelobby": if !isGuildAdmin(s, guildID, i.User, i.Member) { /* gleiche Admin-Antwort */ return } var lobby string for _, o := range data.Options { if o.Name == "lobby" { lobby = o.StringValue() } } if lobby == "" { /* Fehlerantwort */ return } removed, err := deleteLobbyRule(guildID, lobby) if err != nil { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("⚠️ Failed: %v", err), Flags: discordgo.MessageFlagsEphemeral}, }) return } msg := "ℹ️ No rule found." if removed { msg = "✅ Rule removed." } _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: msg, Flags: discordgo.MessageFlagsEphemeral}, }) case "listlobbies": if !isGuildAdmin(s, guildID, i.User, i.Member) { /* Admin-Antwort */ return } rules, err := getLobbyRules(guildID) if err != nil { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("⚠️ Failed: %v", err), Flags: discordgo.MessageFlagsEphemeral}, }) return } if len(rules) == 0 { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "ℹ️ No lobby rules configured (fallback to single-config).", Flags: discordgo.MessageFlagsEphemeral}, }) return } // kompakte Ausgabe var b strings.Builder b.WriteString("**Configured lobby rules:**\n") for _, r := range rules { fmt.Fprintf(&b, "• Lobby: `%s` → Category: `%s`, Timeout: `%d min`\n", r.LobbyName, r.CategoryName, r.TimeoutMin) } _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: b.String(), Flags: discordgo.MessageFlagsEphemeral}, }) } } } // Handler: Commands registrieren, wenn Bot auf neuen Server kommt func onGuildCreate(s *discordgo.Session, g *discordgo.GuildCreate) { appID := s.State.User.ID log.Printf("Registriere Commands in neuer Guild: %s (%s)", g.Name, g.ID) for _, cmd := range slashCommands { c, err := s.ApplicationCommandCreate(appID, g.ID, cmd) if err != nil { log.Printf("Command-Registrierung in %s fehlgeschlagen: %v", g.Name, err) continue } // Commands für die Guild speichern // Wir verwenden sync.Map, also müssen wir die 'Load' und 'Store' Methoden verwenden. commands, _ := createdCmds.LoadOrStore(g.ID, []*discordgo.ApplicationCommand{}) commands = append(commands.([]*discordgo.ApplicationCommand), c) createdCmds.Store(g.ID, commands) log.Printf("Registriere Command %s in Guild %s", cmd.Name, g.Name) } } // ===== Commands Definition ===== var ( adminPerm = int64(discordgo.PermissionAdministrator) slashCommands = []*discordgo.ApplicationCommand{ { Name: "makevc", Description: "Erstellt einen privaten Voice-Channel (Auto-Cleanup)", Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "Name des Channels", Required: false}, {Type: discordgo.ApplicationCommandOptionInteger, Name: "user_limit", Description: "Max. Nutzer (0=unbegrenzt)", Required: false, MaxValue: 99}, {Type: discordgo.ApplicationCommandOptionInteger, Name: "timeout_min", Description: "Löschen nach X Minuten Inaktivität", Required: false, MaxValue: 480}, }, }, { Name: "setlobby", Description: "Setzt den Namen des Lobby-Voice-Channels", DefaultMemberPermissions: &adminPerm, Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "Neuer Lobby-Name", Required: true}, }, }, { Name: "setcategory", Description: "Setzt den Namen der Kategorie für private Räume", DefaultMemberPermissions: &adminPerm, Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "Neue Kategorie-Bezeichnung", Required: true}, }, }, { Name: "settimeout", Description: "Setzt das Auto-Lösch-Timeout (Minuten)", DefaultMemberPermissions: &adminPerm, Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionInteger, Name: "minutes", Description: "Minuten (>=1)", Required: true, MaxValue: 480}, }, }, { Name: "adduser", Description: "Gibt einem Mitglied Zugriff auf deinen aktuellen privaten Voice-Channel", Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Mitglied, das Zugriff bekommen soll", Required: true, }, }, }, { Name: "setlanguage", Description: "Setzt die Sprache für diese Guild.", DefaultMemberPermissions: &adminPerm, Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionString, Name: "language", Description: "Die Sprache, die gesetzt werden soll (z.B. 'de', 'en').", Required: true, }, }, }, { Name: "addlobby", Description: "Adds/updates a lobby rule (lobby → category → timeout)", DefaultMemberPermissions: &adminPerm, Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "lobby", Description: "Lobby voice channel name", Required: true}, {Type: discordgo.ApplicationCommandOptionString, Name: "category", Description: "Category for private rooms", Required: true}, {Type: discordgo.ApplicationCommandOptionInteger, Name: "timeout", Description: "Timeout in minutes (>=1)", Required: false, MaxValue: 480}, }, }, { Name: "removelobby", Description: "Removes a lobby rule by lobby name", DefaultMemberPermissions: &adminPerm, Options: []*discordgo.ApplicationCommandOption{ {Type: discordgo.ApplicationCommandOptionString, Name: "lobby", Description: "Lobby voice channel name", Required: true}, }, }, { Name: "listlobbies", Description: "Lists all lobby rules for this guild", DefaultMemberPermissions: &adminPerm, }, } ) func GetENV(k, d string) string { if v := os.Getenv(k); v != "" { return v } return d } func Enabled(k string, def bool) bool { b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k))) if err != nil { return def } return b } // ===== main: Multi-Guild, pro Guild registrieren ===== func main() { initDB() translations, _ = loadTranslationsFromFile(GetENV("TRANSLATIONS_FILE", "/tempsrc/language.json")) token := GetENV("DISCORD_TOKEN", "") if token == "" { log.Fatal("Bitte setze DISCORD_TOKEN") } // Persistente Konfiguration laden (optional) if err := loadGuildCfgs(); err != nil { log.Printf("Hinweis: Konnte %s nicht laden (%v). Starte mit Defaults.", dbPath, err) } s, err := discordgo.New("Bot " + token) if err != nil { log.Fatalf("Session fehlgeschlagen: %v", err) } s.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildVoiceStates s.AddHandler(onVoiceStateUpdate) s.AddHandler(onGuildCreate) s.AddHandler(onInteractionCreate("")) s.AddHandler(func(_ *discordgo.Session, e *discordgo.ChannelDelete) { vcCache.Range(func(k, v interface{}) bool { if v.(cacheEntry).id == e.ID { vcCache.Delete(k) } return true }) }) if err := s.Open(); err != nil { log.Fatalf("Gateway-Start fehlgeschlagen: %v", err) } log.Println("Bot online. Ctrl+C zum Beenden.") stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop log.Println("Fahre herunter…") appID := s.State.User.ID createdCmds.Range(func(key, value interface{}) bool { for _, c := range value.([]*discordgo.ApplicationCommand) { if delErr := s.ApplicationCommandDelete(appID, key.(string), c.ID); delErr != nil { log.Printf("Cmd-Delete (%s / %s) fehlgeschlagen: %v", key, c.Name, delErr) } else { log.Printf("Cmd-Delete (%s / %s) ok", key, c.Name) } } return true }) if err := saveGuildCfgs(); err != nil { log.Printf("Speichern zum Shutdown fehlgeschlagen: %v", err) } closeDB() _ = s.Close() } // Die Struktur für die Übersetzungen. type TranslationsStruct struct { Language string `json:"language"` Messages map[string]string `json:"messages"` } // Temporäre Struktur zur Deserialisierung. type TranslationFile struct { Translations []TranslationsStruct `json:"translations"` } // Globale Variable für die Übersetzungen var translations map[string]map[string]string // Funktion zur Deserialisierung und Umwandlung in map[string]map[string]string func loadTranslations(jsonData string) (map[string]map[string]string, error) { var translationFile TranslationFile // Deserialisierung des JSON in die Struktur if err := json.Unmarshal([]byte(jsonData), &translationFile); err != nil { return nil, err } // Umwandlung der Struktur in eine Map result := make(map[string]map[string]string) for _, trans := range translationFile.Translations { result[trans.Language] = trans.Messages } return result, nil } // Funktion zur Deserialisierung und Umwandlung in map[string]map[string]string func loadTranslationsFromFile(filename string) (map[string]map[string]string, error) { // Dateiinhalt lesen fileData, err := os.ReadFile(filename) if err != nil { return nil, fmt.Errorf("fehler beim lesen der datei: %w", err) } var translationFile TranslationFile // Deserialisierung des JSON in die Struktur if err := json.Unmarshal(fileData, &translationFile); err != nil { return nil, fmt.Errorf("fehler beim deserialisieren der json-daten: %w", err) } // Umwandlung der Struktur in eine Map result := make(map[string]map[string]string) for _, trans := range translationFile.Translations { result[trans.Language] = trans.Messages } return result, nil }