main.go aktualisiert
All checks were successful
release-tag / release-image (push) Successful in 1m37s
All checks were successful
release-tag / release-image (push) Successful in 1m37s
This commit is contained in:
727
main.go
727
main.go
@@ -12,214 +12,56 @@ import (
|
|||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ===== Defaults / Names (pro Guild per Commands konfigurierbar) =====
|
||||||
const (
|
const (
|
||||||
// optional: Voice-Channels unter dieser Kategorie anlegen (leer lassen = keine Kategorie)
|
pollInterval = 15 * time.Second
|
||||||
// wie oft wir prüfen, ob der Channel leer ist
|
defaultLobbyName = "➕ Erstelle privaten Raum"
|
||||||
pollInterval = 15 * time.Second
|
defaultCategory = "Private Räume"
|
||||||
lobbyName = "➕ Erstelle privaten Raum"
|
|
||||||
categoryName = "Private Räume"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ===== Neu: gemeinsame Helper =====
|
// ===== Per-Guild Config (in-memory) =====
|
||||||
|
type GuildConfig struct {
|
||||||
func createPrivateVCAndMove(
|
LobbyName string
|
||||||
s *discordgo.Session,
|
CategoryName string
|
||||||
guildID, requesterID, displayName, categoryID string,
|
TimeoutMin int
|
||||||
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 |
|
|
||||||
discordgo.PermissionManageChannels,
|
|
||||||
)
|
|
||||||
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 |
|
|
||||||
discordgo.PermissionManageChannels),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
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)
|
var (
|
||||||
func findUserVoiceChannelID(s *discordgo.Session, guildID, userID string) string {
|
cfgMu sync.RWMutex
|
||||||
g, err := s.State.Guild(guildID)
|
guildCfgs = map[string]*GuildConfig{}
|
||||||
if err != nil {
|
createdCmds = map[string][]*discordgo.ApplicationCommand{} // guildID -> cmds
|
||||||
g, err = s.Guild(guildID)
|
registerOnce sync.Once
|
||||||
if err != nil {
|
)
|
||||||
return ""
|
|
||||||
}
|
func getCfg(guildID string) *GuildConfig {
|
||||||
|
cfgMu.RLock()
|
||||||
|
c, ok := guildCfgs[guildID]
|
||||||
|
cfgMu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
for _, vs := range g.VoiceStates {
|
cfgMu.Lock()
|
||||||
if vs.UserID == userID && vs.ChannelID != "" {
|
defer cfgMu.Unlock()
|
||||||
return vs.ChannelID
|
c = &GuildConfig{
|
||||||
}
|
LobbyName: defaultLobbyName,
|
||||||
|
CategoryName: defaultCategory,
|
||||||
|
TimeoutMin: envTimeoutDefault(60),
|
||||||
}
|
}
|
||||||
return ""
|
guildCfgs[guildID] = c
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// /makevc Optionen:
|
func envTimeoutDefault(def int) int {
|
||||||
// - 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 != "" {
|
if v := os.Getenv("TIMEOUT_MIN"); v != "" {
|
||||||
fmt.Sscanf(v, "%d", &timeoutMin)
|
var x int
|
||||||
}
|
if _, err := fmt.Sscanf(v, "%d", &x); err == nil && x > 0 {
|
||||||
|
return x
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeDisplayName(m *discordgo.Member) string {
|
// ===== Helpers: Channel/Kategorie finden =====
|
||||||
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) {
|
func findOrCreateCategoryID(s *discordgo.Session, guildID, name string) (string, error) {
|
||||||
chans, err := s.GuildChannels(guildID)
|
chans, err := s.GuildChannels(guildID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,7 +82,6 @@ func findOrCreateCategoryID(s *discordgo.Session, guildID, name string) (string,
|
|||||||
return cat.ID, nil
|
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 {
|
func findVoiceChannelIDByName(s *discordgo.Session, guildID, name string) string {
|
||||||
chans, err := s.GuildChannels(guildID)
|
chans, err := s.GuildChannels(guildID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -254,168 +95,34 @@ func findVoiceChannelIDByName(s *discordgo.Session, guildID, name string) string
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
func findUserVoiceChannelID(s *discordgo.Session, guildID, userID string) string {
|
||||||
createdCmds = map[string][]*discordgo.ApplicationCommand{} // guildID -> cmds
|
g, err := s.State.Guild(guildID)
|
||||||
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 {
|
if err != nil {
|
||||||
log.Fatalf("Session fehlgeschlagen: %v", err)
|
g, err = s.Guild(guildID)
|
||||||
}
|
if err != nil {
|
||||||
|
return ""
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, vs := range g.VoiceStates {
|
||||||
_ = s.Close()
|
if vs.UserID == userID && vs.ChannelID != "" {
|
||||||
|
return vs.ChannelID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slash-Command-Variante, nutzt dieselbe Helper-Logik und moved falls der User bereits in einem VC ist
|
// ===== Anzeige-Helper =====
|
||||||
func onInteractionCreate(_ string) func(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
func safeDisplayName(m *discordgo.Member) string {
|
||||||
return func(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
if m == nil {
|
||||||
if i.Type != discordgo.InteractionApplicationCommand {
|
return "privat"
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
if m.Nick != "" {
|
||||||
|
return m.Nick
|
||||||
|
}
|
||||||
|
if m.User.GlobalName != "" {
|
||||||
|
return m.User.GlobalName
|
||||||
|
}
|
||||||
|
return m.User.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayName(i *discordgo.InteractionCreate) string {
|
func displayName(i *discordgo.InteractionCreate) string {
|
||||||
@@ -437,25 +144,101 @@ func displayName(i *discordgo.InteractionCreate) string {
|
|||||||
return "privat"
|
return "privat"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüft regelmäßig, ob der Channel leer ist. Wenn er für `timeout` leer bleibt, wird er gelöscht.
|
// ===== Unified Create + Move + Cleanup =====
|
||||||
// Sobald wieder jemand drin ist, wird der Timer zurückgesetzt.
|
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)
|
||||||
|
return newChan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Auto-Cleanup =====
|
||||||
func watchAndCleanup(s *discordgo.Session, guildID, channelID string, timeout time.Duration) {
|
func watchAndCleanup(s *discordgo.Session, guildID, channelID string, timeout time.Duration) {
|
||||||
lastActive := time.Now()
|
lastActive := time.Now()
|
||||||
ticker := time.NewTicker(pollInterval)
|
ticker := time.NewTicker(pollInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
// Guild-State holen (State Cache kann fehlen → REST-Fallback)
|
|
||||||
g, err := s.State.Guild(guildID)
|
g, err := s.State.Guild(guildID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// als Fallback Guild live ziehen
|
|
||||||
g, err = s.Guild(guildID)
|
g, err = s.Guild(guildID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Wenn wir die Guild nicht bekommen, versuche später erneut
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
occupied := false
|
occupied := false
|
||||||
for _, vs := range g.VoiceStates {
|
for _, vs := range g.VoiceStates {
|
||||||
if vs.ChannelID == channelID {
|
if vs.ChannelID == channelID {
|
||||||
@@ -463,16 +246,236 @@ func watchAndCleanup(s *discordgo.Session, guildID, channelID string, timeout ti
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if occupied {
|
if occupied {
|
||||||
lastActive = time.Now()
|
lastActive = time.Now()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// keiner im Channel
|
|
||||||
if time.Since(lastActive) >= timeout {
|
if time.Since(lastActive) >= timeout {
|
||||||
_, _ = s.ChannelDelete(channelID)
|
_, _ = s.ChannelDelete(channelID)
|
||||||
return
|
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()
|
||||||
|
_ = 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()
|
||||||
|
_ = 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()
|
||||||
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("✅ Timeout auf %d Minuten gesetzt.", minutes), 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},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== main: Multi-Guild, pro Guild registrieren =====
|
||||||
|
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) }
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = s.Close()
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user