Files
discord-auto-voice/main.go
groot 4a1c76baca
All checks were successful
release-tag / release-image (push) Successful in 1m39s
main.go aktualisiert
2025-08-09 22:59:03 +00:00

477 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
)
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),
},
},
})
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
}
}
}