diff --git a/main.go b/main.go index 5a4905e..e01a641 100644 --- a/main.go +++ b/main.go @@ -12,214 +12,56 @@ import ( "github.com/bwmarrin/discordgo" ) +// ===== Defaults / Names (pro Guild per Commands konfigurierbar) ===== const ( - // optional: Voice-Channels unter dieser Kategorie anlegen (leer lassen = keine Kategorie) - // wie oft wir prüfen, ob der Channel leer ist - pollInterval = 15 * time.Second - lobbyName = "➕ Erstelle privaten Raum" - categoryName = "Private Räume" + pollInterval = 15 * time.Second + defaultLobbyName = "➕ Erstelle privaten Raum" + defaultCategory = "Private Räume" ) -// ===== Neu: gemeinsame Helper ===== - -func createPrivateVCAndMove( - s *discordgo.Session, - guildID, requesterID, displayName, categoryID string, - userLimit, timeoutMin int, - srcChannelID string, // aus welchem Channel wir verschieben (Lobby oder aktueller VC). Leer = kein Move. -) (*discordgo.Channel, error) { - - everyoneID := guildID - allowOwner := int64( - discordgo.PermissionViewChannel | - discordgo.PermissionVoiceConnect | - discordgo.PermissionVoiceSpeak | - discordgo.PermissionVoiceUseVAD | - discordgo.PermissionVoiceStreamVideo | - discordgo.PermissionManageChannels, - ) - denyEveryone := int64( - discordgo.PermissionViewChannel | - discordgo.PermissionVoiceConnect, - ) - - botID := s.State.User.ID - name := "🔒 " + displayName - newChan, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{ - Name: name, - Type: discordgo.ChannelTypeGuildVoice, - ParentID: categoryID, - UserLimit: userLimit, - Bitrate: 64000, - PermissionOverwrites: []*discordgo.PermissionOverwrite{ - {ID: everyoneID, Type: discordgo.PermissionOverwriteTypeRole, Deny: denyEveryone}, - {ID: requesterID, Type: discordgo.PermissionOverwriteTypeMember, Allow: allowOwner}, - { // <<< NEU: Bot-Overwrite, damit Move klappt - ID: botID, - Type: discordgo.PermissionOverwriteTypeMember, - Allow: int64(discordgo.PermissionViewChannel | - discordgo.PermissionVoiceConnect | - discordgo.PermissionVoiceMoveMembers | - discordgo.PermissionManageChannels), - }, - }, - }) - if err != nil { - return nil, fmt.Errorf("VC-Anlage fehlgeschlagen: %w", err) - } - - // optionaler Move (nur wenn wir eine Quelle haben) - if srcChannelID != "" { - go func() { - time.Sleep(200 * time.Millisecond) // kurze Wartezeit nach Channel-Create - - // (best-effort) Permissions prüfen - if perms, err := s.UserChannelPermissions(s.State.User.ID, 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(s.State.User.ID, newChan.ID); err == nil { - need := int64(discordgo.PermissionVoiceMoveMembers) - if perms&need == 0 { - log.Printf("WARN: Bot hat im Ziel-Channel keine MoveMembers-Permission") - } - } - - // Retry mit Backoff - 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) - } - }() - } - - // Auto-Cleanup - go watchAndCleanup(s, guildID, newChan.ID, time.Duration(timeoutMin)*time.Minute) - return newChan, nil +// ===== Per-Guild Config (in-memory) ===== +type GuildConfig struct { + LobbyName string + CategoryName string + TimeoutMin int } -// Aktuellen Voice-Channel des Users finden (für Slash-Command) -func findUserVoiceChannelID(s *discordgo.Session, guildID, userID string) string { - g, err := s.State.Guild(guildID) - if err != nil { - g, err = s.Guild(guildID) - if err != nil { - return "" - } +var ( + cfgMu sync.RWMutex + guildCfgs = map[string]*GuildConfig{} + createdCmds = map[string][]*discordgo.ApplicationCommand{} // guildID -> cmds + registerOnce sync.Once +) + +func getCfg(guildID string) *GuildConfig { + cfgMu.RLock() + c, ok := guildCfgs[guildID] + cfgMu.RUnlock() + if ok { + return c } - for _, vs := range g.VoiceStates { - if vs.UserID == userID && vs.ChannelID != "" { - return vs.ChannelID - } + cfgMu.Lock() + defer cfgMu.Unlock() + c = &GuildConfig{ + LobbyName: defaultLobbyName, + CategoryName: defaultCategory, + TimeoutMin: envTimeoutDefault(60), } - return "" + guildCfgs[guildID] = c + return c } -// /makevc Optionen: -// - name (string, optional) → Name des Voice-Channels -// - user_limit (int, optional) → Max. User (0 = unbegrenzt) -// - timeout_min (int, optional) → Auto-Delete wenn so lange leer (Default 60) -var slashCommands = []*discordgo.ApplicationCommand{ - { - Name: "makevc", - Description: "Erstellt einen privaten Voice-Channel nur für dich (Auto-Cleanup)", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: "name", - Description: "Name des Channels (z.B. 'Mein Raum')", - Required: false, - }, - { - Type: discordgo.ApplicationCommandOptionInteger, - Name: "user_limit", - Description: "Max. Nutzer (0=unbegrenzt)", - Required: false, - MaxValue: 99, // statt ptrF(99) - }, - { - Type: discordgo.ApplicationCommandOptionInteger, - Name: "timeout_min", - Description: "Löschen nach X Minuten Inaktivität", - Required: false, - MaxValue: 480, - }, - }, - }, -} - -// Triggert beim Joinen in die Lobby und erstellt den VC + Move via Helper -func onVoiceStateUpdate(s *discordgo.Session, e *discordgo.VoiceStateUpdate) { - if e.UserID == "" { - return - } - // Nur Join in die definierte Lobby - lobby := findVoiceChannelIDByName(s, e.GuildID, lobbyName) - if lobby == "" || e.ChannelID != lobby || (e.BeforeUpdate != nil && e.BeforeUpdate.ChannelID == lobby) { - return - } - - // Bots ignorieren - m, err := s.GuildMember(e.GuildID, e.UserID) - if err == nil && m.User.Bot { - return - } - - // Kategorie per Name holen/erstellen - catID, err := findOrCreateCategoryID(s, e.GuildID, categoryName) - if err != nil { - log.Printf("Kategorie-Auflösung fehlgeschlagen: %v", err) - return - } - - // Parameter - userLimit := 0 - timeoutMin := 60 +func envTimeoutDefault(def int) int { if v := os.Getenv("TIMEOUT_MIN"); v != "" { - fmt.Sscanf(v, "%d", &timeoutMin) - } - - // Einheitliche Erstellung + Move (Quelle = Lobby) - _, err = createPrivateVCAndMove( - s, - e.GuildID, - e.UserID, - safeDisplayName(m), - catID, - userLimit, - timeoutMin, - lobby, - ) - if err != nil { - log.Printf("VC-Erstellung/Move fehlgeschlagen: %v", err) + var x int + if _, err := fmt.Sscanf(v, "%d", &x); err == nil && x > 0 { + return x + } } + return def } -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 -} - -// Holt oder erstellt eine Kategorie nach Namen und gibt die ID zurück. +// ===== Helpers: Channel/Kategorie finden ===== func findOrCreateCategoryID(s *discordgo.Session, guildID, name string) (string, error) { chans, err := s.GuildChannels(guildID) if err != nil { @@ -240,7 +82,6 @@ func findOrCreateCategoryID(s *discordgo.Session, guildID, name string) (string, return cat.ID, nil } -// Sucht die ID eines Voice-Channels nach Name. Leerer String, wenn nicht gefunden. func findVoiceChannelIDByName(s *discordgo.Session, guildID, name string) string { chans, err := s.GuildChannels(guildID) if err != nil { @@ -254,168 +95,34 @@ func findVoiceChannelIDByName(s *discordgo.Session, guildID, name string) string return "" } -var ( - createdCmds = map[string][]*discordgo.ApplicationCommand{} // guildID -> cmds - registerOnce sync.Once -) - -func main() { - token := os.Getenv("DISCORD_TOKEN") - if token == "" { - log.Fatal("Bitte setze DISCORD_TOKEN") - } - - s, err := discordgo.New("Bot " + token) +func findUserVoiceChannelID(s *discordgo.Session, guildID, userID string) string { + g, err := s.State.Guild(guildID) if err != nil { - log.Fatalf("Session fehlgeschlagen: %v", err) - } - - // Nur was wir brauchen - s.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildVoiceStates - - // Handlers - s.AddHandler(onVoiceStateUpdate) - s.AddHandler(onInteractionCreate("")) // parameter wird nicht mehr genutzt - s.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { - log.Printf("Eingeloggt als %s", r.User.Username) - - // Ready kann bei Reconnect mehrfach feuern → nur einmal registrieren - registerOnce.Do(func() { - appID := s.State.User.ID - for _, g := range s.State.Guilds { - log.Printf("Registriere Commands in 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 - } - createdCmds[g.ID] = append(createdCmds[g.ID], c) - } - } - log.Printf("Lobby-Name: %q | Kategorie-Name: %q", lobbyName, categoryName) - }) - }) - - // Start - if err := s.Open(); err != nil { - log.Fatalf("Gateway-Start fehlgeschlagen: %v", err) - } - log.Println("Bot online. Ctrl+C zum Beenden.") - - // Shutdown warten - stop := make(chan os.Signal, 1) - signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - <-stop - log.Println("Fahre herunter…") - - // Commands wieder entfernen (best effort) - appID := s.State.User.ID - for guildID, cmds := range createdCmds { - for _, c := range cmds { - if delErr := s.ApplicationCommandDelete(appID, guildID, c.ID); delErr != nil { - log.Printf("Cmd-Delete (%s/%s) fehlgeschlagen: %v", guildID, c.Name, delErr) - } + g, err = s.Guild(guildID) + if err != nil { + return "" } } - - _ = s.Close() + for _, vs := range g.VoiceStates { + if vs.UserID == userID && vs.ChannelID != "" { + return vs.ChannelID + } + } + return "" } -// Slash-Command-Variante, nutzt dieselbe Helper-Logik und moved falls der User bereits in einem VC ist -func onInteractionCreate(_ string) func(s *discordgo.Session, i *discordgo.InteractionCreate) { - return func(s *discordgo.Session, i *discordgo.InteractionCreate) { - if i.Type != discordgo.InteractionApplicationCommand { - return - } - if i.ApplicationCommandData().Name != "makevc" { - return - } - - // Optionen lesen - var nameOpt string - userLimit := 0 - timeoutMin := 60 - if v := os.Getenv("TIMEOUT_MIN"); v != "" { - fmt.Sscanf(v, "%d", &timeoutMin) - } - for _, o := range i.ApplicationCommandData().Options { - switch o.Name { - case "name": - nameOpt = o.StringValue() - case "user_limit": - userLimit = int(o.IntValue()) - case "timeout_min": - timeoutMin = int(o.IntValue()) - } - } - - // Aufrufer - user := i.User - if user == nil && i.Member != nil { - user = i.Member.User - } - if user == nil { - return - } - - // Kategorie per Name holen/erstellen - catID, err := findOrCreateCategoryID(s, i.GuildID, 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 - } - - // Display-Name bestimmen (Option > Fallback auf displayName()) - display := displayName(i) - if nameOpt != "" { - display = nameOpt - } - - // Falls User bereits in einem Voice-Channel ist: automatisch verschieben - srcVC := findUserVoiceChannelID(s, i.GuildID, user.ID) // "" wenn nicht in VC - - newChan, err := createPrivateVCAndMove( - s, - i.GuildID, - user.ID, - display, - catID, - userLimit, - timeoutMin, - srcVC, // nur wenn nicht leer wird moved - ) - 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("✅ Voice-Channel **%s** erstellt.", newChan.Name) - if srcVC == "" { - msg += " Du bist aktuell in keinem Voice-Channel; join bitte manuell in deinen neuen Raum." - } else { - msg += " Ich verschiebe dich jetzt dort hinein." - } - _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: msg, - Flags: discordgo.MessageFlagsEphemeral, - }, - }) +// ===== 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 { @@ -437,25 +144,101 @@ func displayName(i *discordgo.InteractionCreate) string { return "privat" } -// Prüft regelmäßig, ob der Channel leer ist. Wenn er für `timeout` leer bleibt, wird er gelöscht. -// Sobald wieder jemand drin ist, wird der Timer zurückgesetzt. +// ===== 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) + 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 { - // Guild-State holen (State Cache kann fehlen → REST-Fallback) g, err := s.State.Guild(guildID) if err != nil { - // als Fallback Guild live ziehen g, err = s.Guild(guildID) if err != nil { - // Wenn wir die Guild nicht bekommen, versuche später erneut continue } } - occupied := false for _, vs := range g.VoiceStates { if vs.ChannelID == channelID { @@ -463,16 +246,236 @@ func watchAndCleanup(s *discordgo.Session, guildID, channelID string, timeout ti break } } - if occupied { lastActive = time.Now() continue } - - // keiner im Channel if time.Since(lastActive) >= timeout { _, _ = s.ChannelDelete(channelID) return } } } + +// ===== Handlers ===== + +// Lobby-Join → VC erstellen & move (per Namen aus GuildConfig) +func onVoiceStateUpdate(s *discordgo.Session, e *discordgo.VoiceStateUpdate) { + if e.UserID == "" { + return + } + 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("✅ Voice-Channel **%s** erstellt.", newChan.Name) + if srcVC == "" { msg += " Du bist aktuell in keinem Voice-Channel; join bitte manuell in deinen neuen Raum." } else { msg += " Ich verschiebe dich jetzt dort hinein." } + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: msg, Flags: discordgo.MessageFlagsEphemeral}}) + + case "setlobby": + 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 + } + var name string + for _, o := range data.Options { if o.Name == "name" { name = o.StringValue() } } + if name == "" { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "Bitte gib einen Namen an.", Flags: discordgo.MessageFlagsEphemeral}}); return } + cfgMu.Lock(); getCfg(guildID).LobbyName = name; cfgMu.Unlock() + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "✅ Lobby-Name aktualisiert auf: " + name, Flags: discordgo.MessageFlagsEphemeral}}) + + case "setcategory": + 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 + } + var name string + for _, o := range data.Options { if o.Name == "name" { name = o.StringValue() } } + if name == "" { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "Bitte gib einen Namen an.", Flags: discordgo.MessageFlagsEphemeral}}); return } + cfgMu.Lock(); getCfg(guildID).CategoryName = name; cfgMu.Unlock() + _ = 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) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "❌ Nur Administratoren dürfen das.", Flags: discordgo.MessageFlagsEphemeral}}) + return + } + var minutes int64 = int64(getCfg(guildID).TimeoutMin) + for _, o := range data.Options { if o.Name == "minutes" { minutes = o.IntValue() } } + if minutes < 1 { minutes = 1 } + cfgMu.Lock(); getCfg(guildID).TimeoutMin = int(minutes); cfgMu.Unlock() + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("✅ Timeout auf %d Minuten gesetzt.", minutes), Flags: discordgo.MessageFlagsEphemeral}}) + } + } +} + +// ===== 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}, + }, + }, + } +) + +// ===== main: Multi-Guild, pro Guild registrieren ===== +func main() { + token := os.Getenv("DISCORD_TOKEN") + if token == "" { log.Fatal("Bitte setze DISCORD_TOKEN") } + + 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(onInteractionCreate("")) + s.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { + log.Printf("Eingeloggt als %s", r.User.Username) + registerOnce.Do(func() { + appID := s.State.User.ID + for _, g := range s.State.Guilds { + log.Printf("Registriere Commands in 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 + } + createdCmds[g.ID] = append(createdCmds[g.ID], c) + } + } + log.Printf("Defaults → Lobby: %q | Kategorie: %q | Timeout: %d min", defaultLobbyName, defaultCategory, envTimeoutDefault(60)) + }) + }) + + 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 + for guildID, cmds := range createdCmds { + for _, c := range cmds { + if delErr := s.ApplicationCommandDelete(appID, guildID, c.ID); delErr != nil { + log.Printf("Cmd-Delete (%s/%s) fehlgeschlagen: %v", guildID, c.Name, delErr) + } + } + } + _ = s.Close() +}