package main import ( "encoding/json" "fmt" "log" "os" "os/signal" "sync" "syscall" "time" "github.com/bwmarrin/discordgo" ) // ===== Defaults / Names (pro Guild per Commands konfigurierbar) ===== const ( pollInterval = 15 * time.Second defaultLobbyName = "➕ Erstelle privaten Raum" defaultCategory = "Private Räume" ) // Pfad zur Persistenz-Datei (überschreibbar via CONFIG_PATH) var configPath = func() string { if v := os.Getenv("CONFIG_PATH"); v != "" { return v } return "guild_config.json" }() // Persistenz der Guild-Konfiguration func loadGuildCfgs() error { f, err := os.Open(configPath) if err != nil { return err } defer f.Close() var m map[string]*GuildConfig if err := json.NewDecoder(f).Decode(&m); err != nil { return err } cfgMu.Lock() for k, v := range m { guildCfgs[k] = v } cfgMu.Unlock() return nil } func saveGuildCfgs() error { tmp := configPath + ".tmp" f, err := os.Create(tmp) if err != nil { return err } enc := json.NewEncoder(f) enc.SetIndent("", " ") cfgMu.RLock() err = enc.Encode(guildCfgs) cfgMu.RUnlock() _ = f.Close() if err != nil { return err } return os.Rename(tmp, configPath) } // ===== Per-Guild Config (in-memory) ===== type GuildConfig struct { LobbyName string `json:"lobby_name"` CategoryName string `json:"category_name"` TimeoutMin int `json:"timeout_min"` } 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 } cfgMu.Lock() defer cfgMu.Unlock() c = &GuildConfig{ LobbyName: defaultLobbyName, CategoryName: defaultCategory, TimeoutMin: envTimeoutDefault(60), } guildCfgs[guildID] = c return c } 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 } // ===== Helpers: Channel/Kategorie finden ===== func findOrCreateCategoryID(s *discordgo.Session, guildID, name string) (string, error) { chans, err := s.GuildChannels(guildID) if err != nil { return "", err } for _, ch := range chans { if ch.Type == discordgo.ChannelTypeGuildCategory && ch.Name == name { return ch.ID, nil } } cat, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{ Name: name, Type: discordgo.ChannelTypeGuildCategory, }) if err != nil { return "", err } return cat.ID, nil } func findVoiceChannelIDByName(s *discordgo.Session, guildID, name string) string { chans, err := s.GuildChannels(guildID) if err != nil { return "" } for _, ch := range chans { if ch.Type == discordgo.ChannelTypeGuildVoice && ch.Name == name { return ch.ID } } return "" } 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 "" } } 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 } 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() if err := saveGuildCfgs(); err != nil { log.Printf("Speichern fehlgeschlagen: %v", err) } _ = 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() if err := saveGuildCfgs(); err != nil { log.Printf("Speichern fehlgeschlagen: %v", err) } _ = 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() if err := saveGuildCfgs(); err != nil { log.Printf("Speichern fehlgeschlagen: %v", err) } _ = 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, }, }) } } } // ===== 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, }, }, }, } ) // ===== main: Multi-Guild, pro Guild registrieren ===== func main() { token := os.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.", configPath, 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(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) } } } if err := saveGuildCfgs(); err != nil { log.Printf("Speichern zum Shutdown fehlgeschlagen: %v", err) } _ = s.Close() }