package main import ( "fmt" "log" "os" "os/signal" "sync" "syscall" "time" "github.com/bwmarrin/discordgo" ) 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" ) // ===== 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 } // 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 "" } } for _, vs := range g.VoiceStates { if vs.UserID == userID && vs.ChannelID != "" { return vs.ChannelID } } return "" } // /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 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) } } 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. 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 } // 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 { return "" } for _, ch := range chans { if ch.Type == discordgo.ChannelTypeGuildVoice && ch.Name == name { return ch.ID } } 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) 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) } } } _ = s.Close() } // 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, }, }) } } 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" } // 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. 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 { occupied = true break } } if occupied { lastActive = time.Now() continue } // keiner im Channel if time.Since(lastActive) >= timeout { _, _ = s.ChannelDelete(channelID) return } } }