1027 lines
31 KiB
Go
1027 lines
31 KiB
Go
package main
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"os/signal"
|
||
"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 {
|
||
if v := os.Getenv("DB_PATH"); v != "" {
|
||
return v
|
||
}
|
||
return "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
|
||
}
|
||
|
||
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)
|
||
return cfg.Language
|
||
}
|
||
|
||
// 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()
|
||
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// ===== 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 ""
|
||
}
|
||
|
||
func findUserVoiceChannelID(s *discordgo.Session, guildID, userID string) string {
|
||
g, err := s.State.Guild(guildID)
|
||
if err != nil {
|
||
g, err = s.Guild(guildID)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
}
|
||
for _, vs := range g.VoiceStates {
|
||
if vs.UserID == userID && vs.ChannelID != "" {
|
||
return vs.ChannelID
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ===== 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
|
||
}
|
||
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":
|
||
// 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 "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: "Bitte gib einen Namen an.",
|
||
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: "⚠️ Lobby-Name gesetzt, aber Speichern fehlgeschlagen. Schau die Logs.",
|
||
Flags: discordgo.MessageFlagsEphemeral,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
// Antwort <= 3s
|
||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||
Data: &discordgo.InteractionResponseData{
|
||
Content: "✅ Lobby-Name aktualisiert auf: " + name,
|
||
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: "❌ Nur Administratoren dürfen das.",
|
||
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: "Bitte gib einen Namen an.",
|
||
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: "⚠️ Kategorie-Name gesetzt, aber Speichern fehlgeschlagen. Schau die Logs.",
|
||
Flags: discordgo.MessageFlagsEphemeral,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
// Antwort <= 3s
|
||
_ = 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) {
|
||
log.Println("User is not an admin")
|
||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||
Data: &discordgo.InteractionResponseData{
|
||
Content: "❌ Nur Administratoren dürfen das.",
|
||
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: "⚠️ Timeout gesetzt, aber Speichern fehlgeschlagen. Schau die Logs.",
|
||
Flags: discordgo.MessageFlagsEphemeral,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
// Antwort <= 3s
|
||
_ = 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,
|
||
},
|
||
})
|
||
|
||
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,
|
||
},
|
||
})
|
||
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
},
|
||
},
|
||
},
|
||
}
|
||
)
|
||
|
||
// ===== main: Multi-Guild, pro Guild registrieren =====
|
||
func main() {
|
||
initDB()
|
||
token := os.Getenv("DISCORD_TOKEN")
|
||
token = "MTQwMzg1MTM5NDQ1MjI5MTU4NA.GVi04l.qjraLIbFdi_N49UcSUv_BqK89ihb6xXY648J7A"
|
||
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.AddHandler(onVoiceStateUpdate)
|
||
s.AddHandler(onGuildCreate)
|
||
s.AddHandler(onInteractionCreate(""))
|
||
|
||
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
|
||
}
|