package main import ( "fmt" "log" "os" "os/signal" "syscall" "time" "github.com/bwmarrin/discordgo" ) const ( // optional: Voice-Channels unter dieser Kategorie anlegen (leer lassen = keine Kategorie) envCategoryID = "CATEGORY_ID" // wie oft wir prüfen, ob der Channel leer ist pollInterval = 15 * time.Second ) var lobbyID = os.Getenv("LOBBY_CHANNEL_ID") var discordToken = os.Getenv("DISCORD_TOKEN") // /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, }, }, }, } func onVoiceStateUpdate(s *discordgo.Session, e *discordgo.VoiceStateUpdate) { // nur Join-Events in die Lobby interessieren if e.UserID == "" || e.ChannelID != lobbyID || e.BeforeUpdate != nil && e.BeforeUpdate.ChannelID == lobbyID { return } // Bots ignorieren m, err := s.GuildMember(e.GuildID, e.UserID) if err == nil && m.User.Bot { return } // Channel anlegen wie im /makevc-Handler: categoryID := os.Getenv(envCategoryID) name := "🔒 " + safeDisplayName(m) userLimit := 0 timeoutMin := 60 if v := os.Getenv("TIMEOUT_MIN"); v != "" { fmt.Sscanf(v, "%d", &timeoutMin) } everyoneID := e.GuildID allowOwner := int64(discordgo.PermissionViewChannel | discordgo.PermissionVoiceConnect | discordgo.PermissionVoiceSpeak | discordgo.PermissionVoiceStreamVideo | discordgo.PermissionVoiceUseVAD) denyEveryone := int64(discordgo.PermissionViewChannel | discordgo.PermissionVoiceConnect) newChan, err := s.GuildChannelCreateComplex(e.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: e.UserID, Type: discordgo.PermissionOverwriteTypeMember, Allow: allowOwner}, }, }) if err != nil { log.Printf("VC-Anlage fehlgeschlagen: %v", err) return } // User rüber bewegen if moveErr := s.GuildMemberMove(e.GuildID, e.UserID, &newChan.ID); moveErr != nil { log.Printf("Move fehlgeschlagen: %v", moveErr) } // Auto-Cleanup starten go watchAndCleanup(s, e.GuildID, newChan.ID, time.Duration(timeoutMin)*time.Minute) } 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 main() { token := discordToken if token == "" { log.Fatal("Bitte setze DISCORD_TOKEN") } guildID := os.Getenv("GUILD_ID") // optional: für schnelle Command-Registrierung categoryID := os.Getenv(envCategoryID) s, err := discordgo.New("Bot " + token) if err != nil { log.Fatalf("Session fehlgeschlagen: %v", err) } // Intents für Slash + Voice-State-Events s.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildVoiceStates s.AddHandler(onVoiceStateUpdate) // ← neu // Handlers s.AddHandler(func(_ *discordgo.Session, r *discordgo.Ready) { log.Printf("Eingeloggt als %s", r.User.Username) }) s.AddHandler(onInteractionCreate(categoryID)) if err := s.Open(); err != nil { log.Fatalf("Gateway-Start fehlgeschlagen: %v", err) } log.Println("Bot online. Ctrl+C zum Beenden.") // Slash-Commands registrieren (Guild-spezifisch = sofort sichtbar) appID := s.State.User.ID created := make([]*discordgo.ApplicationCommand, 0, len(slashCommands)) for _, cmd := range slashCommands { c, err := s.ApplicationCommandCreate(appID, guildID, cmd) if err != nil { log.Fatalf("Command-Registrierung fehlgeschlagen: %v", err) } created = append(created, c) } // Shutdown stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop log.Println("Fahre herunter…") for _, c := range created { _ = s.ApplicationCommandDelete(appID, guildID, c.ID) } _ = s.Close() } func onInteractionCreate(categoryID 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 } // Options lesen var name string userLimit := 0 timeoutMin := 60 for _, o := range i.ApplicationCommandData().Options { switch o.Name { case "name": name = o.StringValue() case "user_limit": userLimit = int(o.IntValue()) case "timeout_min": timeoutMin = int(o.IntValue()) } } user := i.User if user == nil && i.Member != nil { user = i.Member.User } if name == "" { name = fmt.Sprintf("🔒 %s", displayName(i)) } // Permission Overwrites: @everyone deny, Owner allow guildID := i.GuildID everyoneID := guildID // @everyone Rolle hat die ID der Guild allowOwner := int64(discordgo.PermissionViewChannel | discordgo.PermissionVoiceConnect | discordgo.PermissionVoiceSpeak | discordgo.PermissionVoiceStreamVideo | discordgo.PermissionVoiceUseVAD) denyEveryone := int64(discordgo.PermissionViewChannel | discordgo.PermissionVoiceConnect) newChan, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{ Name: name, Type: discordgo.ChannelTypeGuildVoice, // optional unter Kategorie ParentID: categoryID, // Userlimit (0 = unlimited) UserLimit: userLimit, // Bitrate (optional): 64kbps ist sicher für die meisten Server Bitrate: 64000, PermissionOverwrites: []*discordgo.PermissionOverwrite{ { ID: everyoneID, Type: discordgo.PermissionOverwriteTypeRole, Deny: denyEveryone, Allow: 0, }, { ID: user.ID, Type: discordgo.PermissionOverwriteTypeMember, Allow: allowOwner, Deny: 0, }, }, }) if err != nil { errMsg := "Konnte Voice-Channel nicht erstellen. Fehlen dem Bot die Rechte **Manage Channels**?" _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: errMsg, Flags: discordgo.MessageFlagsEphemeral, }, }) return } // Sofort antworten (ephemeral) msg := fmt.Sprintf("✅ Voice-Channel **%s** erstellt. Ich lösche ihn, wenn er leer ist (Timeout: %d min).", newChan.Name, timeoutMin) _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: msg, Flags: discordgo.MessageFlagsEphemeral, }, }) // Auto-Cleanup starten go watchAndCleanup(s, i.GuildID, newChan.ID, time.Duration(timeoutMin)*time.Minute) } } 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 } } }