This commit is contained in:
2025-08-09 23:47:03 +02:00
parent 3df782561d
commit eb0f3e0ccd
5 changed files with 433 additions and 0 deletions

324
main.go Normal file
View File

@@ -0,0 +1,324 @@
package main
import (
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/bwmarrin/discordgo"
)
const (
// optional: Voice-Channels unter dieser Kategorie anlegen (leer lassen = keine Kategorie)
envCategoryID = "CATEGORY_ID"
// wie oft wir prüfen, ob der Channel leer ist
pollInterval = 15 * time.Second
)
var lobbyID = os.Getenv("LOBBY_CHANNEL_ID")
var discordToken = os.Getenv("DISCORD_TOKEN")
// /makevc Optionen:
// - name (string, optional) → Name des Voice-Channels
// - user_limit (int, optional) → Max. User (0 = unbegrenzt)
// - timeout_min (int, optional) → Auto-Delete wenn so lange leer (Default 60)
var slashCommands = []*discordgo.ApplicationCommand{
{
Name: "makevc",
Description: "Erstellt einen privaten Voice-Channel nur für dich (Auto-Cleanup)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "name",
Description: "Name des Channels (z.B. 'Mein Raum')",
Required: false,
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "user_limit",
Description: "Max. Nutzer (0=unbegrenzt)",
Required: false,
MaxValue: 99, // statt ptrF(99)
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "timeout_min",
Description: "Löschen nach X Minuten Inaktivität",
Required: false,
MaxValue: 480,
},
},
},
}
func onVoiceStateUpdate(s *discordgo.Session, e *discordgo.VoiceStateUpdate) {
// nur Join-Events in die Lobby interessieren
if e.UserID == "" || e.ChannelID != lobbyID || e.BeforeUpdate != nil && e.BeforeUpdate.ChannelID == lobbyID {
return
}
// Bots ignorieren
m, err := s.GuildMember(e.GuildID, e.UserID)
if err == nil && m.User.Bot {
return
}
// Channel anlegen wie im /makevc-Handler:
categoryID := os.Getenv(envCategoryID)
name := "🔒 " + safeDisplayName(m)
userLimit := 0
timeoutMin := 60
if v := os.Getenv("TIMEOUT_MIN"); v != "" {
fmt.Sscanf(v, "%d", &timeoutMin)
}
everyoneID := e.GuildID
allowOwner := int64(discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect |
discordgo.PermissionVoiceSpeak |
discordgo.PermissionVoiceStreamVideo |
discordgo.PermissionVoiceUseVAD)
denyEveryone := int64(discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect)
newChan, err := s.GuildChannelCreateComplex(e.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: e.UserID, Type: discordgo.PermissionOverwriteTypeMember, Allow: allowOwner},
},
})
if err != nil {
log.Printf("VC-Anlage fehlgeschlagen: %v", err)
return
}
// User rüber bewegen
if moveErr := s.GuildMemberMove(e.GuildID, e.UserID, &newChan.ID); moveErr != nil {
log.Printf("Move fehlgeschlagen: %v", moveErr)
}
// Auto-Cleanup starten
go watchAndCleanup(s, e.GuildID, newChan.ID, time.Duration(timeoutMin)*time.Minute)
}
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 main() {
token := discordToken
if token == "" {
log.Fatal("Bitte setze DISCORD_TOKEN")
}
guildID := os.Getenv("GUILD_ID") // optional: für schnelle Command-Registrierung
categoryID := os.Getenv(envCategoryID)
s, err := discordgo.New("Bot " + token)
if err != nil {
log.Fatalf("Session fehlgeschlagen: %v", err)
}
// Intents für Slash + Voice-State-Events
s.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildVoiceStates
s.AddHandler(onVoiceStateUpdate) // ← neu
// Handlers
s.AddHandler(func(_ *discordgo.Session, r *discordgo.Ready) {
log.Printf("Eingeloggt als %s", r.User.Username)
})
s.AddHandler(onInteractionCreate(categoryID))
if err := s.Open(); err != nil {
log.Fatalf("Gateway-Start fehlgeschlagen: %v", err)
}
log.Println("Bot online. Ctrl+C zum Beenden.")
// Slash-Commands registrieren (Guild-spezifisch = sofort sichtbar)
appID := s.State.User.ID
created := make([]*discordgo.ApplicationCommand, 0, len(slashCommands))
for _, cmd := range slashCommands {
c, err := s.ApplicationCommandCreate(appID, guildID, cmd)
if err != nil {
log.Fatalf("Command-Registrierung fehlgeschlagen: %v", err)
}
created = append(created, c)
}
// Shutdown
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("Fahre herunter…")
for _, c := range created {
_ = s.ApplicationCommandDelete(appID, guildID, c.ID)
}
_ = s.Close()
}
func onInteractionCreate(categoryID string) func(s *discordgo.Session, i *discordgo.InteractionCreate) {
return func(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Type != discordgo.InteractionApplicationCommand {
return
}
if i.ApplicationCommandData().Name != "makevc" {
return
}
// Options lesen
var name string
userLimit := 0
timeoutMin := 60
for _, o := range i.ApplicationCommandData().Options {
switch o.Name {
case "name":
name = o.StringValue()
case "user_limit":
userLimit = int(o.IntValue())
case "timeout_min":
timeoutMin = int(o.IntValue())
}
}
user := i.User
if user == nil && i.Member != nil {
user = i.Member.User
}
if name == "" {
name = fmt.Sprintf("🔒 %s", displayName(i))
}
// Permission Overwrites: @everyone deny, Owner allow
guildID := i.GuildID
everyoneID := guildID // @everyone Rolle hat die ID der Guild
allowOwner := int64(discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect |
discordgo.PermissionVoiceSpeak |
discordgo.PermissionVoiceStreamVideo |
discordgo.PermissionVoiceUseVAD)
denyEveryone := int64(discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect)
newChan, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
Name: name,
Type: discordgo.ChannelTypeGuildVoice,
// optional unter Kategorie
ParentID: categoryID,
// Userlimit (0 = unlimited)
UserLimit: userLimit,
// Bitrate (optional): 64kbps ist sicher für die meisten Server
Bitrate: 64000,
PermissionOverwrites: []*discordgo.PermissionOverwrite{
{
ID: everyoneID,
Type: discordgo.PermissionOverwriteTypeRole,
Deny: denyEveryone,
Allow: 0,
},
{
ID: user.ID,
Type: discordgo.PermissionOverwriteTypeMember,
Allow: allowOwner,
Deny: 0,
},
},
})
if err != nil {
errMsg := "Konnte Voice-Channel nicht erstellen. Fehlen dem Bot die Rechte **Manage Channels**?"
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: errMsg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Sofort antworten (ephemeral)
msg := fmt.Sprintf("✅ Voice-Channel **%s** erstellt. Ich lösche ihn, wenn er leer ist (Timeout: %d min).", newChan.Name, timeoutMin)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
// Auto-Cleanup starten
go watchAndCleanup(s, i.GuildID, newChan.ID, time.Duration(timeoutMin)*time.Minute)
}
}
func displayName(i *discordgo.InteractionCreate) string {
if i.Member != nil && i.Member.Nick != "" {
return i.Member.Nick
}
if i.Member != nil && i.Member.User.GlobalName != "" {
return i.Member.User.GlobalName
}
if i.Member != nil {
return i.Member.User.Username
}
if i.User != nil && i.User.GlobalName != "" {
return i.User.GlobalName
}
if i.User != nil {
return i.User.Username
}
return "privat"
}
// Prüft regelmäßig, ob der Channel leer ist. Wenn er für `timeout` leer bleibt, wird er gelöscht.
// Sobald wieder jemand drin ist, wird der Timer zurückgesetzt.
func watchAndCleanup(s *discordgo.Session, guildID, channelID string, timeout time.Duration) {
lastActive := time.Now()
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for range ticker.C {
// Guild-State holen (State Cache kann fehlen → REST-Fallback)
g, err := s.State.Guild(guildID)
if err != nil {
// als Fallback Guild live ziehen
g, err = s.Guild(guildID)
if err != nil {
// Wenn wir die Guild nicht bekommen, versuche später erneut
continue
}
}
occupied := false
for _, vs := range g.VoiceStates {
if vs.ChannelID == channelID {
occupied = true
break
}
}
if occupied {
lastActive = time.Now()
continue
}
// keiner im Channel
if time.Since(lastActive) >= timeout {
_, _ = s.ChannelDelete(channelID)
return
}
}
}