Files
discord-auto-voice/main.go
groot f99a7ab018
All checks were successful
release-tag / release-image (push) Successful in 1m46s
main.go aktualisiert
2025-08-09 22:19:44 +00:00

390 lines
11 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"
"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")
// ===== 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)
// - 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) {
// 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
}
// Parameter laden
categoryID := os.Getenv(envCategoryID)
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),
categoryID,
userLimit,
timeoutMin,
e.ChannelID, // Quell-Channel: 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
}
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()
}
// 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 {
return
}
if i.ApplicationCommandData().Name != "makevc" {
return
}
// Optionen lesen
var name string // (wird nur für die Anzeige verwendet der Helper baut "🔒 <display>" 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":
name = o.StringValue()
case "user_limit":
userLimit = int(o.IntValue())
case "timeout_min":
timeoutMin = int(o.IntValue())
}
}
// Aufrufer bestimmen
user := i.User
if user == nil && i.Member != nil {
user = i.Member.User
}
display := displayName(i)
if name != "" {
// Falls Name explizit gesetzt wurde, nimm ihn als Display-Basis
display = name
}
// 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 := createPrivateVCAndMove(
s,
i.GuildID,
user.ID,
display,
categoryID,
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
}
}
}