477 lines
13 KiB
Go
477 lines
13 KiB
Go
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
|
||
}
|
||
}
|
||
}
|