Files
discord-auto-voice/main.go
groot ed34da72ea
All checks were successful
release-tag / release-image (push) Successful in 2m4s
main.go aktualisiert
2025-08-12 18:52:49 +00:00

1382 lines
42 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
"database/sql"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/bwmarrin/discordgo"
_ "modernc.org/sqlite" // SQLite3-Treiber importieren
)
// ===== Defaults / Names (pro Guild per Commands konfigurierbar) =====
var (
pollInterval = 15 * time.Second
defaultLobbyName = " Erstelle privaten Raum"
defaultCategory = "Private Räume"
)
// Pfad zur SQLite-Datenbank
var dbPath = func() string {
return GetENV("DB_PATH", "guild_config.db")
}()
// ===== Per-Guild Config (in-memory) =====
type GuildConfig struct {
LobbyName string `json:"lobby_name"`
CategoryName string `json:"category_name"`
TimeoutMin int `json:"timeout_min"`
Language string `json:"language"` // Neue Eigenschaft für Sprache
}
type LobbyRule struct {
LobbyName string
CategoryName string
TimeoutMin int
}
func getLobbyRules(guildID string) ([]LobbyRule, error) {
rows, err := db.Query(`SELECT lobby_name, category_name, timeout_min FROM lobby_rule WHERE guild_id = ?`, guildID)
if err != nil {
return nil, err
}
defer rows.Close()
var rules []LobbyRule
for rows.Next() {
var r LobbyRule
if err := rows.Scan(&r.LobbyName, &r.CategoryName, &r.TimeoutMin); err != nil {
return nil, err
}
rules = append(rules, r)
}
return rules, rows.Err()
}
func upsertLobbyRule(guildID, lobby, category string, timeout int) error {
if timeout < 1 {
timeout = 1
}
_, err := db.Exec(`
INSERT INTO lobby_rule (guild_id, lobby_name, category_name, timeout_min)
VALUES (?, ?, ?, ?)
ON CONFLICT(guild_id, lobby_name) DO UPDATE SET
category_name=excluded.category_name,
timeout_min=excluded.timeout_min
`, guildID, lobby, category, timeout)
return err
}
func deleteLobbyRule(guildID, lobby string) (bool, error) {
res, err := db.Exec(`DELETE FROM lobby_rule WHERE guild_id=? AND lobby_name=?`, guildID, lobby)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
var db *sql.DB
// ===== Global variables for sync.Map =====
var guildCfgs sync.Map // Für Guild-Konfigurationen
var createdCmds sync.Map // Für erstellte Commands
// Sprache für eine Guild setzen
func setLanguage(guildID, language string) error {
cfg := getCfg(guildID)
cfg.Language = language
return saveGuildCfgs()
}
// Sprachabruf
func getLanguage(guildID string) string {
cfg := getCfg(guildID)
fmt.Println("Debug: Language:", cfg.Language)
return cfg.Language
}
// neue Tabelle für mehrere Lobby-Regeln pro Guild
func ensureLobbyRuleTable() {
const create = `
CREATE TABLE IF NOT EXISTS lobby_rule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL,
lobby_name TEXT NOT NULL,
category_name TEXT NOT NULL,
timeout_min INTEGER NOT NULL DEFAULT 60,
UNIQUE (guild_id, lobby_name)
);
CREATE INDEX IF NOT EXISTS idx_lobby_rule_guild ON lobby_rule(guild_id);
`
_, err := db.Exec(create)
if err != nil {
log.Fatalf("Fehler beim Erstellen lobby_rule: %v", err)
}
}
// Funktion zum Hinzufügen der 'language' Spalte, falls sie nicht existiert
func addLanguageColumnIfNotExists() {
// Überprüfen, ob die Tabelle 'guild_config' existiert
var tableExists bool
err := db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='guild_config'").Scan(&tableExists)
if err != nil || !tableExists {
log.Printf("Tabelle 'guild_config' existiert nicht: %v", err)
}
// Überprüfen, ob die Spalte 'language' existiert
_, err = db.Exec("ALTER TABLE guild_config ADD COLUMN language TEXT DEFAULT 'de'")
if err != nil && !isColumnAlreadyExistsError(err) {
log.Printf("Fehler beim Hinzufügen der 'language' Spalte: %v", err)
} else {
log.Println("Spalte 'language' wurde hinzugefügt oder existiert bereits.")
}
}
// Hilfsfunktion zur Fehlerbehandlung, um zu überprüfen, ob der Fehler auf eine bereits vorhandene Spalte hinweist
func isColumnAlreadyExistsError(err error) bool {
// Fehlerbehandlung für 'duplicate column name'
return err != nil && err.Error() == "SQLITE_ERROR: duplicate column name: language"
}
// Initialize DB
func initDB() {
var err error
db, err = sql.Open("sqlite", dbPath)
if err != nil {
log.Fatalf("Datenbank-Fehler: %v", err)
}
// Tabelle erstellen, falls sie noch nicht existiert
createTableSQL := `CREATE TABLE IF NOT EXISTS guild_config (
guild_id TEXT PRIMARY KEY,
lobby_name TEXT,
category_name TEXT,
timeout_min INTEGER,
language TEXT DEFAULT 'de' -- Neue Spalte für Sprache hinzufügen
);`
_, err = db.Exec(createTableSQL)
if err != nil {
log.Fatalf("Fehler beim Erstellen der Tabelle: %v", err)
}
// Neue Spalte für die Sprache hinzufügen, wenn sie noch nicht existiert
addLanguageColumnIfNotExists()
ensureLobbyRuleTable()
}
// Close DB connection
func closeDB() {
if err := db.Close(); err != nil {
log.Fatalf("Fehler beim Schließen der DB: %v", err)
}
}
// Laden der Guild-Konfiguration mit Sprache
func loadGuildCfgs() error {
rows, err := db.Query("SELECT guild_id, lobby_name, category_name, timeout_min, language FROM guild_config")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var guildID, lobbyName, categoryName, language string
var timeoutMin int
if err := rows.Scan(&guildID, &lobbyName, &categoryName, &timeoutMin, &language); err != nil {
return err
}
guildCfgs.Store(guildID, &GuildConfig{
LobbyName: lobbyName,
CategoryName: categoryName,
TimeoutMin: timeoutMin,
Language: language, // Sprache laden
})
}
return nil
}
// Speichern der Guild-Konfiguration mit Sprache
func saveGuildCfgs() error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
guildCfgs.Range(func(key, value interface{}) bool {
guildID := key.(string)
cfg := value.(*GuildConfig)
_, err := tx.Exec(`
INSERT INTO guild_config (guild_id, lobby_name, category_name, timeout_min, language)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(guild_id)
DO UPDATE SET
lobby_name = excluded.lobby_name,
category_name = excluded.category_name,
timeout_min = excluded.timeout_min,
language = excluded.language -- Sprache speichern
`, guildID, cfg.LobbyName, cfg.CategoryName, cfg.TimeoutMin, cfg.Language)
if err != nil {
log.Printf("Fehler beim Speichern der Guild-Konfiguration: %v", err)
}
return true
})
// Transaktion abschließen
return tx.Commit()
}
// Guild-Konfiguration laden oder Standardwerte zurückgeben
func getCfg(guildID string) *GuildConfig {
if cfg, ok := guildCfgs.Load(guildID); ok {
return cfg.(*GuildConfig)
}
var guildCfg GuildConfig
err := db.QueryRow("SELECT lobby_name, category_name, timeout_min, language FROM guild_config WHERE guild_id = ?", guildID).Scan(&guildCfg.LobbyName, &guildCfg.CategoryName, &guildCfg.TimeoutMin, &guildCfg.Language)
if err != nil {
if err == sql.ErrNoRows {
log.Printf("Guild-Konfiguration für %s nicht gefunden, verwenden der Standardwerte", guildID)
} else {
log.Printf("Fehler beim Abrufen der Guild-Konfiguration für %s: %v", guildID, err)
}
// Standardwerte verwenden
guildCfg = GuildConfig{
LobbyName: defaultLobbyName,
CategoryName: defaultCategory,
TimeoutMin: envTimeoutDefault(1),
Language: "de", // Standard-Sprache ist Deutsch
}
}
guildCfgs.Store(guildID, &guildCfg)
return &guildCfg
}
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
}
// Optional: Cache-Invalidierung bei Channel-Events (empfohlen)
func hookCategoryCacheInvalidation(s *discordgo.Session) {
s.AddHandler(func(_ *discordgo.Session, e *discordgo.ChannelDelete) {
if e.Channel != nil && e.Channel.Type == discordgo.ChannelTypeGuildCategory {
// alle Keys mit dieser ID rauswerfen
catCache.Range(func(k, v interface{}) bool {
if v.(cacheEntry).id == e.ID {
catCache.Delete(k)
}
return true
})
}
})
s.AddHandler(func(_ *discordgo.Session, e *discordgo.ChannelUpdate) {
if e.Channel != nil && e.Channel.Type == discordgo.ChannelTypeGuildCategory {
// Bei Rename nicht perfekt zu erkennen -> TTL sorgt für Refresh
// Optional: hart löschen:
catCache.Range(func(k, v interface{}) bool {
if v.(cacheEntry).id == e.Channel.ID {
catCache.Delete(k)
}
return true
})
}
})
}
// ===== Helpers: Channel/Kategorie finden (mit Cache) =====
func findOrCreateCategoryID(s *discordgo.Session, guildID, name string) (string, error) {
key := guildID + "|" + name
// 0) Cache
if v, ok := catCache.Load(key); ok {
ce := v.(cacheEntry)
if time.Since(ce.t) < cacheTTL {
return ce.id, nil
}
}
// 1) State (schnell) vorausgesetzt: s.State.TrackChannels = true (vor s.Open() setzen!)
if g, err := s.State.Guild(guildID); err == nil {
for _, ch := range g.Channels {
if ch.Type == discordgo.ChannelTypeGuildCategory && ch.Name == name {
catCache.Store(key, cacheEntry{ch.ID, time.Now()})
return ch.ID, nil
}
}
}
// 2) REST (Fallback)
chans, err := s.GuildChannels(guildID)
if err == nil {
for _, ch := range chans {
if ch.Type == discordgo.ChannelTypeGuildCategory && ch.Name == name {
catCache.Store(key, cacheEntry{ch.ID, time.Now()})
return ch.ID, nil
}
}
} else {
// wenn selbst Channels holen fehlschlägt, gib den Fehler zurück
return "", err
}
// 3) Nicht gefunden -> anlegen
cat, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
Name: name,
Type: discordgo.ChannelTypeGuildCategory,
})
if err != nil {
return "", err
}
catCache.Store(key, cacheEntry{cat.ID, time.Now()})
return cat.ID, nil
}
var (
vcCache sync.Map // key: guildID+"|"+name → cacheEntry
catCache sync.Map // key: guildID+"|"+name -> cacheEntry
cacheTTL = 12 * time.Hour
)
type cacheEntry struct {
id string
t time.Time
}
// Schneller: erst State, dann REST, plus kleiner TTL-Cache
func findVoiceChannelIDByName(s *discordgo.Session, guildID, name string) string {
key := guildID + "|" + name
if v, ok := vcCache.Load(key); ok {
ce := v.(cacheEntry)
if time.Since(ce.t) < cacheTTL {
return ce.id
}
}
// 1) State (sehr schnell)
if g, err := s.State.Guild(guildID); err == nil {
for _, ch := range g.Channels {
if ch.Type == discordgo.ChannelTypeGuildVoice && ch.Name == name {
vcCache.Store(key, cacheEntry{ch.ID, time.Now()})
return ch.ID
}
}
}
// 2) REST (Fallback, teuer)
chans, err := s.GuildChannels(guildID)
if err != nil {
return ""
}
for _, ch := range chans {
if ch.Type == discordgo.ChannelTypeGuildVoice && ch.Name == name {
vcCache.Store(key, cacheEntry{ch.ID, time.Now()})
return ch.ID
}
}
return ""
}
// Noch schneller: gezielt VoiceState aus dem State ziehen
func findUserVoiceChannelID(s *discordgo.Session, guildID, userID string) string {
// 1) Direkt per VoiceState (O(1), wenn im State vorhanden)
if vs, err := s.State.VoiceState(guildID, userID); err == nil && vs != nil && vs.ChannelID != "" {
return vs.ChannelID
}
// 2) State.Guild als Fallback (O(n), aber ohne REST)
if g, err := s.State.Guild(guildID); err == nil {
for _, vs := range g.VoiceStates {
if vs.UserID == userID && vs.ChannelID != "" {
return vs.ChannelID
}
}
}
// 3) REST (letzter Ausweg)
g, err := s.Guild(guildID)
if err != nil {
return ""
}
for _, vs := range g.VoiceStates {
if vs.UserID == userID && vs.ChannelID != "" {
return vs.ChannelID
}
}
return ""
}
// ===== Anzeige-Helper =====
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 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"
}
// ===== 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 {
g, err := s.State.Guild(guildID)
if err != nil {
g, err = s.Guild(guildID)
if err != nil {
continue
}
}
occupied := false
for _, vs := range g.VoiceStates {
if vs.ChannelID == channelID {
occupied = true
break
}
}
if occupied {
lastActive = time.Now()
continue
}
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
}
// 1) Versuch: Regeln aus DB
rules, err := getLobbyRules(e.GuildID)
if err != nil {
log.Printf("getLobbyRules: %v", err)
}
if len(rules) > 0 {
// checke, ob der Join in eine der konfigurierten Lobbys erfolgte
var matched *LobbyRule
for idx := range rules {
lobbyID := findVoiceChannelIDByName(s, e.GuildID, rules[idx].LobbyName)
if lobbyID != "" && e.ChannelID == lobbyID && (e.BeforeUpdate == nil || e.BeforeUpdate.ChannelID != lobbyID) {
matched = &rules[idx]
break
}
}
if matched == nil {
return
}
m, _ := s.GuildMember(e.GuildID, e.UserID)
if m != nil && m.User.Bot {
return
}
catID, err := findOrCreateCategoryID(s, e.GuildID, matched.CategoryName)
if err != nil {
log.Printf("Kategorie: %v", err)
return
}
_, err = createPrivateVCAndMove(s, e.GuildID, e.UserID, safeDisplayName(m), catID, 0, matched.TimeoutMin, e.ChannelID)
if err != nil {
log.Printf("VC/Move: %v", err)
}
return
}
// 2) Fallback: alte Single-Config (deine bisherige Logik)
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(translations[getLanguage(guildID)]["response.voicechannel.created"], newChan.Name)
if srcVC == "" {
msg += translations[getLanguage(guildID)]["response.voicechannel.created.notinvoice"]
} else {
msg += translations[getLanguage(guildID)]["response.voicechannel.created.move"]
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: msg, Flags: discordgo.MessageFlagsEphemeral}})
case "setlobby":
// Admin-Check zuerst
if !isGuildAdmin(s, guildID, i.User, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: translations[getLanguage(guildID)]["response.setlobby.onlyadmins"],
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Option "name" lesen
var name string
for _, o := range data.Options {
if o.Name == "name" {
name = o.StringValue()
break
}
}
if name == "" {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: translations[getLanguage(guildID)]["response.setlobby.pleaseentername"],
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// speichern
getCfg(guildID).LobbyName = name
if err := saveGuildCfgs(); err != nil {
log.Printf("saveGuildCfgs failed: %v", err)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: translations[getLanguage(guildID)]["response.setlobby.error.saving"],
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Antwort <= 3s
msg := fmt.Sprintf(translations[getLanguage(guildID)]["response.setlobby.success"], name)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
case "setcategory":
// Admin-Check zuerst
if !isGuildAdmin(s, guildID, i.User, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: translations[getLanguage(guildID)]["response.setcategory.onlyadmins"],
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Option "name" lesen
var name string
for _, o := range data.Options {
if o.Name == "name" {
name = o.StringValue()
break
}
}
if name == "" {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: translations[getLanguage(guildID)]["response.setcategory.error.name"],
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// speichern
getCfg(guildID).CategoryName = name
if err := saveGuildCfgs(); err != nil {
log.Printf("saveGuildCfgs failed: %v", err)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: translations[getLanguage(guildID)]["response.setcategory.error.saving"],
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Antwort <= 3s
msg := fmt.Sprintf(translations[getLanguage(guildID)]["response.setcategory.success"], name)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
case "settimeout":
if !isGuildAdmin(s, guildID, i.User, i.Member) {
log.Println("User is not an admin")
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: translations[getLanguage(guildID)]["response.settimeout.onlyadmins"],
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
minutes := int64(getCfg(guildID).TimeoutMin)
for _, o := range data.Options {
if o.Name == "minutes" {
minutes = o.IntValue()
}
}
if minutes < 1 {
minutes = 1
}
log.Printf("Setting timeout for %d minutes", minutes)
// speichern
getCfg(guildID).TimeoutMin = int(minutes)
if err := saveGuildCfgs(); err != nil {
log.Printf("saveGuildCfgs failed: %v", err)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: translations[getLanguage(guildID)]["response.settimeout.error.saving"],
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Antwort <= 3s
msg := fmt.Sprintf(translations[getLanguage(guildID)]["response.settimeout.success"], minutes)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
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: translations[getLanguage(guildID)]["response.adduser.error.user"],
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: translations[getLanguage(guildID)]["response.adduser.error.notinvoice"],
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: translations[getLanguage(guildID)]["response.adduser.error.notowner"],
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 {
msg := fmt.Sprintf(translations[getLanguage(guildID)]["response.adduser.error.setpermissions"], err)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
msg := fmt.Sprintf(translations[getLanguage(guildID)]["response.adduser.success"], target.Username)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
case "setlanguage":
// Admin-Check zuerst
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
}
// Option "language" lesen
var language string
for _, o := range data.Options {
if o.Name == "language" {
language = o.StringValue()
break
}
}
if language == "" {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Bitte gib eine gültige Sprache an (z.B. 'de', 'en').",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Speichern
if err := setLanguage(guildID, language); err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "⚠️ Fehler beim Speichern der Sprache.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Antwort <= 3s
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: fmt.Sprintf("✅ Sprache auf '%s' gesetzt.", language),
Flags: discordgo.MessageFlagsEphemeral,
},
})
case "addlobby":
if !isGuildAdmin(s, guildID, i.User, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: "❌ Admins only.", Flags: discordgo.MessageFlagsEphemeral},
})
return
}
var lobby, category string
timeout := int64(envTimeoutDefault(60))
for _, o := range data.Options {
switch o.Name {
case "lobby":
lobby = o.StringValue()
case "category":
category = o.StringValue()
case "timeout":
timeout = o.IntValue()
}
}
if lobby == "" || category == "" { /* antworten mit Fehler */
}
if err := upsertLobbyRule(guildID, lobby, category, int(timeout)); err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("⚠️ Failed: %v", err), Flags: discordgo.MessageFlagsEphemeral},
})
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("✅ Rule saved: lobby \"%s\" → category \"%s\" (timeout %d min).", lobby, category, timeout), Flags: discordgo.MessageFlagsEphemeral},
})
case "removelobby":
if !isGuildAdmin(s, guildID, i.User, i.Member) { /* gleiche Admin-Antwort */
return
}
var lobby string
for _, o := range data.Options {
if o.Name == "lobby" {
lobby = o.StringValue()
}
}
if lobby == "" { /* Fehlerantwort */
return
}
removed, err := deleteLobbyRule(guildID, lobby)
if err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("⚠️ Failed: %v", err), Flags: discordgo.MessageFlagsEphemeral},
})
return
}
msg := " No rule found."
if removed {
msg = "✅ Rule removed."
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: msg, Flags: discordgo.MessageFlagsEphemeral},
})
case "listlobbies":
if !isGuildAdmin(s, guildID, i.User, i.Member) { /* Admin-Antwort */
return
}
rules, err := getLobbyRules(guildID)
if err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("⚠️ Failed: %v", err), Flags: discordgo.MessageFlagsEphemeral},
})
return
}
if len(rules) == 0 {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: " No lobby rules configured (fallback to single-config).", Flags: discordgo.MessageFlagsEphemeral},
})
return
}
// kompakte Ausgabe
var b strings.Builder
b.WriteString("**Configured lobby rules:**\n")
for _, r := range rules {
fmt.Fprintf(&b, "• Lobby: `%s` → Category: `%s`, Timeout: `%d min`\n", r.LobbyName, r.CategoryName, r.TimeoutMin)
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: b.String(), Flags: discordgo.MessageFlagsEphemeral},
})
}
}
}
// Handler: Commands registrieren, wenn Bot auf neuen Server kommt
func onGuildCreate(s *discordgo.Session, g *discordgo.GuildCreate) {
appID := s.State.User.ID
log.Printf("Registriere Commands in neuer 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
}
// Commands für die Guild speichern
// Wir verwenden sync.Map, also müssen wir die 'Load' und 'Store' Methoden verwenden.
commands, _ := createdCmds.LoadOrStore(g.ID, []*discordgo.ApplicationCommand{})
commands = append(commands.([]*discordgo.ApplicationCommand), c)
createdCmds.Store(g.ID, commands)
log.Printf("Registriere Command %s in Guild %s", cmd.Name, g.Name)
}
}
// ===== 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,
},
},
},
{
Name: "setlanguage",
Description: "Setzt die Sprache für diese Guild.",
DefaultMemberPermissions: &adminPerm,
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "language",
Description: "Die Sprache, die gesetzt werden soll (z.B. 'de', 'en').",
Required: true,
},
},
},
{
Name: "addlobby",
Description: "Adds/updates a lobby rule (lobby → category → timeout)",
DefaultMemberPermissions: &adminPerm,
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "lobby", Description: "Lobby voice channel name", Required: true},
{Type: discordgo.ApplicationCommandOptionString, Name: "category", Description: "Category for private rooms", Required: true},
{Type: discordgo.ApplicationCommandOptionInteger, Name: "timeout", Description: "Timeout in minutes (>=1)", Required: false, MaxValue: 480},
},
},
{
Name: "removelobby",
Description: "Removes a lobby rule by lobby name",
DefaultMemberPermissions: &adminPerm,
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "lobby", Description: "Lobby voice channel name", Required: true},
},
},
{
Name: "listlobbies",
Description: "Lists all lobby rules for this guild",
DefaultMemberPermissions: &adminPerm,
},
}
)
func GetENV(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func Enabled(k string, def bool) bool {
b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k)))
if err != nil {
return def
}
return b
}
// ===== main: Multi-Guild, pro Guild registrieren =====
func main() {
initDB()
translations, _ = loadTranslationsFromFile(GetENV("TRANSLATIONS_FILE", "/tempsrc/language.json"))
token := 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.", dbPath, err)
}
s, err := discordgo.New("Bot " + token)
if err != nil {
log.Fatalf("Session fehlgeschlagen: %v", err)
}
s.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildVoiceStates
_ = s.UpdateGameStatus(0, "creating private VCs")
s.AddHandler(onVoiceStateUpdate)
s.AddHandler(onGuildCreate)
s.AddHandler(onInteractionCreate(""))
s.AddHandler(func(_ *discordgo.Session, e *discordgo.ChannelDelete) {
vcCache.Range(func(k, v interface{}) bool {
if v.(cacheEntry).id == e.ID {
vcCache.Delete(k)
}
return true
})
})
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
createdCmds.Range(func(key, value interface{}) bool {
for _, c := range value.([]*discordgo.ApplicationCommand) {
if delErr := s.ApplicationCommandDelete(appID, key.(string), c.ID); delErr != nil {
log.Printf("Cmd-Delete (%s / %s) fehlgeschlagen: %v", key, c.Name, delErr)
} else {
log.Printf("Cmd-Delete (%s / %s) ok", key, c.Name)
}
}
return true
})
if err := saveGuildCfgs(); err != nil {
log.Printf("Speichern zum Shutdown fehlgeschlagen: %v", err)
}
closeDB()
_ = s.Close()
}
// Die Struktur für die Übersetzungen.
type TranslationsStruct struct {
Language string `json:"language"`
Messages map[string]string `json:"messages"`
}
// Temporäre Struktur zur Deserialisierung.
type TranslationFile struct {
Translations []TranslationsStruct `json:"translations"`
}
// Globale Variable für die Übersetzungen
var translations map[string]map[string]string
// Funktion zur Deserialisierung und Umwandlung in map[string]map[string]string
func loadTranslations(jsonData string) (map[string]map[string]string, error) {
var translationFile TranslationFile
// Deserialisierung des JSON in die Struktur
if err := json.Unmarshal([]byte(jsonData), &translationFile); err != nil {
return nil, err
}
// Umwandlung der Struktur in eine Map
result := make(map[string]map[string]string)
for _, trans := range translationFile.Translations {
result[trans.Language] = trans.Messages
}
return result, nil
}
// Funktion zur Deserialisierung und Umwandlung in map[string]map[string]string
func loadTranslationsFromFile(filename string) (map[string]map[string]string, error) {
// Dateiinhalt lesen
fileData, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("fehler beim lesen der datei: %w", err)
}
var translationFile TranslationFile
// Deserialisierung des JSON in die Struktur
if err := json.Unmarshal(fileData, &translationFile); err != nil {
return nil, fmt.Errorf("fehler beim deserialisieren der json-daten: %w", err)
}
// Umwandlung der Struktur in eine Map
result := make(map[string]map[string]string)
for _, trans := range translationFile.Translations {
result[trans.Language] = trans.Messages
}
return result, nil
}