Compare commits

...

3 Commits

Author SHA1 Message Date
c1505d52cd Log Updates
Some checks failed
release-tag / release-image (push) Has been cancelled
2025-08-10 15:30:48 +02:00
8e50210267 Major Updates and Fixes 2025-08-10 15:16:49 +02:00
32357545fd läuft 2025-08-10 10:18:35 +02:00
2 changed files with 607 additions and 289 deletions

View File

@@ -28,6 +28,7 @@ EXPOSE 8080
ENV LOBBY_CHANNEL_ID=0 \
DISCORD_TOKEN=0 \
GUILD_ID=0 \
CATEGORY_ID=0 \
TIMEOUT_MIN=1

895
main.go
View File

@@ -1,106 +1,149 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/bwmarrin/discordgo"
)
// ===== Defaults / Names (pro Guild per Commands konfigurierbar) =====
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
pollInterval = 15 * time.Second
defaultLobbyName = " Erstelle privaten Raum"
defaultCategory = "Private Räume"
)
var lobbyID = os.Getenv("LOBBY_CHANNEL_ID")
var discordToken = os.Getenv("DISCORD_TOKEN")
// Pfad zur Persistenz-Datei (überschreibbar via CONFIG_PATH)
var configPath = func() string {
if v := os.Getenv("CONFIG_PATH"); v != "" {
return v
}
return "guild_config.json"
}()
// ===== 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},
},
})
// Persistenz der Guild-Konfiguration
func loadGuildCfgs() error {
f, err := os.Open(configPath)
if err != nil {
return nil, fmt.Errorf("VC-Anlage fehlgeschlagen: %w", err)
return err
}
defer f.Close()
var m map[string]*GuildConfig
if err := json.NewDecoder(f).Decode(&m); err != nil {
return 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)
}
}()
cfgMu.Lock()
for k, v := range m {
guildCfgs[k] = v
}
// Auto-Cleanup
go watchAndCleanup(s, guildID, newChan.ID, time.Duration(timeoutMin)*time.Minute)
return newChan, nil
cfgMu.Unlock()
return nil
}
func saveGuildCfgs() error {
tmp := configPath + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
cfgMu.RLock()
err = enc.Encode(guildCfgs)
cfgMu.RUnlock()
_ = f.Close()
if err != nil {
return err
}
return os.Rename(tmp, configPath)
}
// ===== Per-Guild Config (in-memory) =====
type GuildConfig struct {
LobbyName string `json:"lobby_name"`
CategoryName string `json:"category_name"`
TimeoutMin int `json:"timeout_min"`
}
var (
cfgMu sync.RWMutex
guildCfgs = map[string]*GuildConfig{}
createdCmds = map[string][]*discordgo.ApplicationCommand{} // guildID -> cmds
registerOnce sync.Once
)
func getCfg(guildID string) *GuildConfig {
cfgMu.RLock()
c, ok := guildCfgs[guildID]
cfgMu.RUnlock()
if ok {
return c
}
cfgMu.Lock()
defer cfgMu.Unlock()
c = &GuildConfig{
LobbyName: defaultLobbyName,
CategoryName: defaultCategory,
TimeoutMin: envTimeoutDefault(60),
}
guildCfgs[guildID] = c
return c
}
func envTimeoutDefault(def int) int {
if v := os.Getenv("TIMEOUT_MIN"); v != "" {
var x int
if _, err := fmt.Sscanf(v, "%d", &x); err == nil && x > 0 {
return x
}
}
return def
}
// ===== Helpers: Channel/Kategorie finden =====
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
}
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 ""
}
// 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 {
@@ -117,75 +160,7 @@ func findUserVoiceChannelID(s *discordgo.Session, guildID, userID string) string
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)
}
}
// ===== Anzeige-Helper =====
func safeDisplayName(m *discordgo.Member) string {
if m == nil {
return "privat"
@@ -199,136 +174,6 @@ func safeDisplayName(m *discordgo.Member) string {
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
@@ -348,25 +193,102 @@ func displayName(i *discordgo.InteractionCreate) string {
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.
// ===== Unified Create + Move + Cleanup =====
func createPrivateVCAndMove(
s *discordgo.Session,
guildID, requesterID, disp, categoryID string,
userLimit, timeoutMin int,
srcChannelID string, // Quelle für Move (Lobby/aktueller VC); leer = kein Move
) (*discordgo.Channel, error) {
botID := s.State.User.ID
name := "🔒 " + disp
allowOwner := int64(
discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect |
discordgo.PermissionVoiceSpeak |
discordgo.PermissionVoiceUseVAD |
discordgo.PermissionVoiceStreamVideo |
discordgo.PermissionManageChannels,
)
denyEveryone := int64(
discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect,
)
allowBot := int64(
discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect |
discordgo.PermissionVoiceMoveMembers |
discordgo.PermissionManageChannels,
)
newChan, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
Name: name,
Type: discordgo.ChannelTypeGuildVoice,
ParentID: categoryID,
UserLimit: userLimit,
Bitrate: 64000,
PermissionOverwrites: []*discordgo.PermissionOverwrite{
{ID: guildID, Type: discordgo.PermissionOverwriteTypeRole, Deny: denyEveryone},
{ID: requesterID, Type: discordgo.PermissionOverwriteTypeMember, Allow: allowOwner},
{ID: botID, Type: discordgo.PermissionOverwriteTypeMember, Allow: allowBot},
},
})
if err != nil {
return nil, fmt.Errorf("VC-Anlage fehlgeschlagen: %w", err)
}
// optionaler Move
if srcChannelID != "" {
go func() {
time.Sleep(200 * time.Millisecond)
// Best-effort Perm Checks
if perms, err := s.UserChannelPermissions(botID, 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(botID, newChan.ID); err == nil {
need := int64(discordgo.PermissionVoiceMoveMembers)
if perms&need == 0 {
log.Printf("WARN: Bot hat im Ziel-Channel keine MoveMembers-Permission")
}
}
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)
}
}()
}
go watchAndCleanup(s, guildID, newChan.ID, time.Duration(timeoutMin)*time.Minute)
log.Println(" Added channel for guildID: " + guildID + " with new ID: " + newChan.ID)
return newChan, nil
}
// ===== Auto-Cleanup =====
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 {
@@ -374,16 +296,411 @@ func watchAndCleanup(s *discordgo.Session, guildID, channelID string, timeout ti
break
}
}
if occupied {
lastActive = time.Now()
continue
}
// keiner im Channel
if time.Since(lastActive) >= timeout {
_, _ = s.ChannelDelete(channelID)
log.Println(" Deleted channel for guildID: " + guildID + " with ID: " + channelID)
return
}
}
}
// ===== Handlers =====
// Lobby-Join → VC erstellen & move (per Namen aus GuildConfig)
func onVoiceStateUpdate(s *discordgo.Session, e *discordgo.VoiceStateUpdate) {
if e.UserID == "" {
return
}
cfg := getCfg(e.GuildID)
lobby := findVoiceChannelIDByName(s, e.GuildID, cfg.LobbyName)
if lobby == "" || e.ChannelID != lobby || (e.BeforeUpdate != nil && e.BeforeUpdate.ChannelID == lobby) {
return
}
m, err := s.GuildMember(e.GuildID, e.UserID)
if err == nil && m.User.Bot {
return
}
catID, err := findOrCreateCategoryID(s, e.GuildID, cfg.CategoryName)
if err != nil {
log.Printf("Kategorie-Auflösung fehlgeschlagen: %v", err)
return
}
_, err = createPrivateVCAndMove(s, e.GuildID, e.UserID, safeDisplayName(m), catID, 0, cfg.TimeoutMin, lobby)
if err != nil {
log.Printf("VC-Erstellung/Move fehlgeschlagen: %v", err)
}
}
// ===== Permissions Helper =====
func isGuildAdmin(s *discordgo.Session, guildID string, user *discordgo.User, member *discordgo.Member) bool {
if user == nil && member == nil {
return false
}
if member == nil {
m, err := s.GuildMember(guildID, user.ID)
if err != nil {
return false
}
member = m
}
g, err := s.State.Guild(guildID)
if err == nil && g.OwnerID == member.User.ID {
return true
}
roles, err := s.GuildRoles(guildID)
if err != nil {
return false
}
rolePerm := int64(0)
for _, r := range roles {
for _, mr := range member.Roles {
if r.ID == mr {
rolePerm |= r.Permissions
}
}
}
if rolePerm&int64(discordgo.PermissionAdministrator) != 0 {
return true
}
if rolePerm&int64(discordgo.PermissionManageGuild) != 0 { // Fallback: Manage Server reicht auch
return true
}
return false
}
// Slash-Commands: makevc / setlobby / setcategory / settimeout (Admin-geschützt)
func onInteractionCreate(_ string) func(s *discordgo.Session, i *discordgo.InteractionCreate) {
return func(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Type != discordgo.InteractionApplicationCommand {
return
}
data := i.ApplicationCommandData()
guildID := i.GuildID
switch data.Name {
case "makevc":
cfg := getCfg(guildID)
user := i.User
if user == nil && i.Member != nil {
user = i.Member.User
}
if user == nil {
return
}
userLimit := 0
timeoutMin := cfg.TimeoutMin
var nameOpt string
for _, o := range data.Options {
switch o.Name {
case "name":
nameOpt = o.StringValue()
case "user_limit":
userLimit = int(o.IntValue())
case "timeout_min":
timeoutMin = int(o.IntValue())
}
}
display := displayName(i)
if nameOpt != "" {
display = nameOpt
}
catID, err := findOrCreateCategoryID(s, guildID, cfg.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
}
srcVC := findUserVoiceChannelID(s, guildID, user.ID)
newChan, err := createPrivateVCAndMove(s, guildID, user.ID, display, catID, userLimit, timeoutMin, srcVC)
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}})
case "setlobby":
if !isGuildAdmin(s, guildID, i.User, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "❌ Nur Administratoren dürfen das.", Flags: discordgo.MessageFlagsEphemeral}})
return
}
var name string
for _, o := range data.Options {
if o.Name == "name" {
name = o.StringValue()
}
}
if name == "" {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "Bitte gib einen Namen an.", Flags: discordgo.MessageFlagsEphemeral}})
return
}
cfgMu.Lock()
getCfg(guildID).LobbyName = name
cfgMu.Unlock()
if err := saveGuildCfgs(); err != nil {
log.Printf("Speichern fehlgeschlagen: %v", err)
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "✅ Lobby-Name aktualisiert auf: " + name, Flags: discordgo.MessageFlagsEphemeral}})
case "setcategory":
if !isGuildAdmin(s, guildID, i.User, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "❌ Nur Administratoren dürfen das.", Flags: discordgo.MessageFlagsEphemeral}})
return
}
var name string
for _, o := range data.Options {
if o.Name == "name" {
name = o.StringValue()
}
}
if name == "" {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "Bitte gib einen Namen an.", Flags: discordgo.MessageFlagsEphemeral}})
return
}
cfgMu.Lock()
getCfg(guildID).CategoryName = name
cfgMu.Unlock()
if err := saveGuildCfgs(); err != nil {
log.Printf("Speichern fehlgeschlagen: %v", err)
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "✅ Kategorie-Name aktualisiert auf: " + name, Flags: discordgo.MessageFlagsEphemeral}})
case "settimeout":
if !isGuildAdmin(s, guildID, i.User, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "❌ Nur Administratoren dürfen das.", Flags: discordgo.MessageFlagsEphemeral}})
return
}
var minutes int64 = int64(getCfg(guildID).TimeoutMin)
for _, o := range data.Options {
if o.Name == "minutes" {
minutes = o.IntValue()
}
}
if minutes < 1 {
minutes = 1
}
cfgMu.Lock()
getCfg(guildID).TimeoutMin = int(minutes)
cfgMu.Unlock()
if err := saveGuildCfgs(); err != nil {
log.Printf("Speichern fehlgeschlagen: %v", err)
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("✅ Timeout auf %d Minuten gesetzt.", minutes), Flags: discordgo.MessageFlagsEphemeral}})
case "adduser":
// wer ruft auf?
requester := i.User
if requester == nil && i.Member != nil {
requester = i.Member.User
}
if requester == nil {
return
}
// Ziel-User aus den Optionen holen
var target *discordgo.User
for _, o := range data.Options {
if o.Name == "user" {
// UserValue braucht die Session
u := o.UserValue(s)
if u != nil {
target = u
}
}
}
if target == nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Bitte gib ein gültiges Mitglied an.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// In welchem VC ist der Aufrufer gerade?
srcVC := findUserVoiceChannelID(s, guildID, requester.ID)
if srcVC == "" {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Du bist aktuell in keinem Voice-Channel. Betritt zuerst deinen privaten Channel.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Sicherheits-Check: darf der Aufrufer diesen Channel verwalten?
// (Der Owner bekommt in deinem Code ManageChannels → das ist unser Indikator)
perms, err := s.UserChannelPermissions(requester.ID, srcVC)
if err != nil || perms&int64(discordgo.PermissionManageChannels) == 0 {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "❌ Du bist nicht der Besitzer dieses Channels oder dir fehlen Rechte (Manage Channel).",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Overwrite setzen: Ziel-User darf sehen & beitreten (& sprechen/Stream/VAD)
allow := int64(
discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect |
discordgo.PermissionVoiceSpeak |
discordgo.PermissionVoiceUseVAD |
discordgo.PermissionVoiceStreamVideo,
)
if err := s.ChannelPermissionSet(
srcVC,
target.ID,
discordgo.PermissionOverwriteTypeMember,
allow,
0,
); err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: fmt.Sprintf("Konnte Berechtigung nicht setzen: %v", err),
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: fmt.Sprintf("✅ %s hat jetzt Zugriff auf deinen Voice-Channel.", target.Username),
Flags: discordgo.MessageFlagsEphemeral,
},
})
}
}
}
// ===== Commands Definition =====
var (
adminPerm = int64(discordgo.PermissionAdministrator)
slashCommands = []*discordgo.ApplicationCommand{
{
Name: "makevc",
Description: "Erstellt einen privaten Voice-Channel (Auto-Cleanup)",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "Name des Channels", Required: false},
{Type: discordgo.ApplicationCommandOptionInteger, Name: "user_limit", Description: "Max. Nutzer (0=unbegrenzt)", Required: false, MaxValue: 99},
{Type: discordgo.ApplicationCommandOptionInteger, Name: "timeout_min", Description: "Löschen nach X Minuten Inaktivität", Required: false, MaxValue: 480},
},
},
{
Name: "setlobby",
Description: "Setzt den Namen des Lobby-Voice-Channels",
DefaultMemberPermissions: &adminPerm,
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "Neuer Lobby-Name", Required: true},
},
},
{
Name: "setcategory",
Description: "Setzt den Namen der Kategorie für private Räume",
DefaultMemberPermissions: &adminPerm,
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "Neue Kategorie-Bezeichnung", Required: true},
},
},
{
Name: "settimeout",
Description: "Setzt das Auto-Lösch-Timeout (Minuten)",
DefaultMemberPermissions: &adminPerm,
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionInteger, Name: "minutes", Description: "Minuten (>=1)", Required: true, MaxValue: 480},
},
},
{
Name: "adduser",
Description: "Gibt einem Mitglied Zugriff auf deinen aktuellen privaten Voice-Channel",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "Mitglied, das Zugriff bekommen soll",
Required: true,
},
},
},
}
)
// ===== main: Multi-Guild, pro Guild registrieren =====
func main() {
token := os.Getenv("DISCORD_TOKEN")
if token == "" {
log.Fatal("Bitte setze DISCORD_TOKEN")
}
// Persistente Konfiguration laden (optional)
if err := loadGuildCfgs(); err != nil {
log.Printf("Hinweis: Konnte %s nicht laden (%v). Starte mit Defaults.", configPath, err)
}
s, err := discordgo.New("Bot " + token)
if err != nil {
log.Fatalf("Session fehlgeschlagen: %v", err)
}
s.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildVoiceStates
s.AddHandler(onVoiceStateUpdate)
s.AddHandler(onInteractionCreate(""))
s.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) {
log.Printf("Eingeloggt als %s", r.User.Username)
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("Defaults → Lobby: %q | Kategorie: %q | Timeout: %d min", defaultLobbyName, defaultCategory, envTimeoutDefault(60))
})
})
if err := s.Open(); err != nil {
log.Fatalf("Gateway-Start fehlgeschlagen: %v", err)
}
log.Println("Bot online. Ctrl+C zum Beenden.")
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("Fahre herunter…")
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)
}
}
}
if err := saveGuildCfgs(); err != nil {
log.Printf("Speichern zum Shutdown fehlgeschlagen: %v", err)
}
_ = s.Close()
}