diff --git a/main.go b/main.go index c0e632e..4579bd4 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,102 @@ const ( var lobbyID = os.Getenv("LOBBY_CHANNEL_ID") var discordToken = os.Getenv("DISCORD_TOKEN") +// ===== 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, + ) + denyEveryone := int64( + discordgo.PermissionViewChannel | + discordgo.PermissionVoiceConnect, + ) + + 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}, + }, + }) + 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) @@ -54,9 +150,10 @@ var slashCommands = []*discordgo.ApplicationCommand{ }, } +// Triggert beim Joinen in die Lobby und erstellt den VC + Move via Helper 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 { + if e.UserID == "" || e.ChannelID != lobbyID || (e.BeforeUpdate != nil && e.BeforeUpdate.ChannelID == lobbyID) { return } // Bots ignorieren @@ -65,47 +162,28 @@ func onVoiceStateUpdate(s *discordgo.Session, e *discordgo.VoiceStateUpdate) { return } - // Channel anlegen wie im /makevc-Handler: + // Parameter laden 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}, - }, - }) + // Einheitliche Erstellung + Move (Quelle = Lobby) + _, err = createPrivateVCAndMove( + s, + e.GuildID, + e.UserID, + safeDisplayName(m), + categoryID, + userLimit, + timeoutMin, + e.ChannelID, // Quell-Channel: Lobby + ) if err != nil { - log.Printf("VC-Anlage fehlgeschlagen: %v", err) - return + log.Printf("VC-Erstellung/Move fehlgeschlagen: %v", err) } - - // 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 { @@ -171,6 +249,7 @@ func main() { _ = s.Close() } +// Slash-Command-Variante, nutzt dieselbe Helper-Logik und moved falls der User bereits in einem VC ist func onInteractionCreate(categoryID string) func(s *discordgo.Session, i *discordgo.InteractionCreate) { return func(s *discordgo.Session, i *discordgo.InteractionCreate) { if i.Type != discordgo.InteractionApplicationCommand { @@ -180,10 +259,13 @@ func onInteractionCreate(categoryID string) func(s *discordgo.Session, i *discor return } - // Options lesen - var name string + // Optionen lesen + var name string // (wird nur für die Anzeige verwendet – der Helper baut "🔒 " selbst) 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": @@ -194,63 +276,49 @@ func onInteractionCreate(categoryID string) func(s *discordgo.Session, i *discor timeoutMin = int(o.IntValue()) } } + + // Aufrufer bestimmen user := i.User if user == nil && i.Member != nil { user = i.Member.User } - if name == "" { - name = fmt.Sprintf("🔒 %s", displayName(i)) + display := displayName(i) + if name != "" { + // Falls Name explizit gesetzt wurde, nimm ihn als Display-Basis + display = name } - // 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) + // Wenn der User bereits in einem VC ist, verschieben wir ihn automatisch + srcVC := findUserVoiceChannelID(s, i.GuildID, user.ID) // "" wenn nicht in VC - 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, - }, - }, - }) + newChan, err := createPrivateVCAndMove( + s, + i.GuildID, + user.ID, + display, + categoryID, + userLimit, + timeoutMin, + srcVC, // nur wenn nicht leer wird moved + ) 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, + Content: "Konnte Voice-Channel nicht erstellen. Fehlen dem Bot Rechte (Manage Channels/Move Members)?", 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) + 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{ @@ -258,9 +326,6 @@ func onInteractionCreate(categoryID string) func(s *discordgo.Session, i *discor Flags: discordgo.MessageFlagsEphemeral, }, }) - - // Auto-Cleanup starten - go watchAndCleanup(s, i.GuildID, newChan.ID, time.Duration(timeoutMin)*time.Minute) } }