Files
discord-auto-voice/main.go
jbergner c1505d52cd
Some checks failed
release-tag / release-image (push) Has been cancelled
Log Updates
2025-08-10 15:30:48 +02:00

707 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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 (
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/bwmarrin/discordgo"
)
// ===== Defaults / Names (pro Guild per Commands konfigurierbar) =====
const (
pollInterval = 15 * time.Second
defaultLobbyName = " Erstelle privaten Raum"
defaultCategory = "Private Räume"
)
// Pfad zur Persistenz-Datei (überschreibbar via CONFIG_PATH)
var configPath = func() string {
if v := os.Getenv("CONFIG_PATH"); v != "" {
return v
}
return "guild_config.json"
}()
// Persistenz der Guild-Konfiguration
func loadGuildCfgs() error {
f, err := os.Open(configPath)
if err != nil {
return err
}
defer f.Close()
var m map[string]*GuildConfig
if err := json.NewDecoder(f).Decode(&m); err != nil {
return err
}
cfgMu.Lock()
for k, v := range m {
guildCfgs[k] = v
}
cfgMu.Unlock()
return nil
}
func saveGuildCfgs() error {
tmp := configPath + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
cfgMu.RLock()
err = enc.Encode(guildCfgs)
cfgMu.RUnlock()
_ = f.Close()
if err != nil {
return err
}
return os.Rename(tmp, configPath)
}
// ===== Per-Guild Config (in-memory) =====
type GuildConfig struct {
LobbyName string `json:"lobby_name"`
CategoryName string `json:"category_name"`
TimeoutMin int `json:"timeout_min"`
}
var (
cfgMu sync.RWMutex
guildCfgs = map[string]*GuildConfig{}
createdCmds = map[string][]*discordgo.ApplicationCommand{} // guildID -> cmds
registerOnce sync.Once
)
func getCfg(guildID string) *GuildConfig {
cfgMu.RLock()
c, ok := guildCfgs[guildID]
cfgMu.RUnlock()
if ok {
return c
}
cfgMu.Lock()
defer cfgMu.Unlock()
c = &GuildConfig{
LobbyName: defaultLobbyName,
CategoryName: defaultCategory,
TimeoutMin: envTimeoutDefault(60),
}
guildCfgs[guildID] = c
return c
}
func envTimeoutDefault(def int) int {
if v := os.Getenv("TIMEOUT_MIN"); v != "" {
var x int
if _, err := fmt.Sscanf(v, "%d", &x); err == nil && x > 0 {
return x
}
}
return def
}
// ===== Helpers: Channel/Kategorie finden =====
func findOrCreateCategoryID(s *discordgo.Session, guildID, name string) (string, error) {
chans, err := s.GuildChannels(guildID)
if err != nil {
return "", err
}
for _, ch := range chans {
if ch.Type == discordgo.ChannelTypeGuildCategory && ch.Name == name {
return ch.ID, nil
}
}
cat, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
Name: name,
Type: discordgo.ChannelTypeGuildCategory,
})
if err != nil {
return "", err
}
return cat.ID, nil
}
func findVoiceChannelIDByName(s *discordgo.Session, guildID, name string) string {
chans, err := s.GuildChannels(guildID)
if err != nil {
return ""
}
for _, ch := range chans {
if ch.Type == discordgo.ChannelTypeGuildVoice && ch.Name == name {
return ch.ID
}
}
return ""
}
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":
if !isGuildAdmin(s, guildID, i.User, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "❌ Nur Administratoren dürfen das.", Flags: discordgo.MessageFlagsEphemeral}})
return
}
var name string
for _, o := range data.Options {
if o.Name == "name" {
name = o.StringValue()
}
}
if name == "" {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "Bitte gib einen Namen an.", Flags: discordgo.MessageFlagsEphemeral}})
return
}
cfgMu.Lock()
getCfg(guildID).LobbyName = name
cfgMu.Unlock()
if err := saveGuildCfgs(); err != nil {
log.Printf("Speichern fehlgeschlagen: %v", err)
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "✅ Lobby-Name aktualisiert auf: " + name, Flags: discordgo.MessageFlagsEphemeral}})
case "setcategory":
if !isGuildAdmin(s, guildID, i.User, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "❌ Nur Administratoren dürfen das.", Flags: discordgo.MessageFlagsEphemeral}})
return
}
var name string
for _, o := range data.Options {
if o.Name == "name" {
name = o.StringValue()
}
}
if name == "" {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "Bitte gib einen Namen an.", Flags: discordgo.MessageFlagsEphemeral}})
return
}
cfgMu.Lock()
getCfg(guildID).CategoryName = name
cfgMu.Unlock()
if err := saveGuildCfgs(); err != nil {
log.Printf("Speichern fehlgeschlagen: %v", err)
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "✅ Kategorie-Name aktualisiert auf: " + name, Flags: discordgo.MessageFlagsEphemeral}})
case "settimeout":
if !isGuildAdmin(s, guildID, i.User, i.Member) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: "❌ Nur Administratoren dürfen das.", Flags: discordgo.MessageFlagsEphemeral}})
return
}
var minutes int64 = int64(getCfg(guildID).TimeoutMin)
for _, o := range data.Options {
if o.Name == "minutes" {
minutes = o.IntValue()
}
}
if minutes < 1 {
minutes = 1
}
cfgMu.Lock()
getCfg(guildID).TimeoutMin = int(minutes)
cfgMu.Unlock()
if err := saveGuildCfgs(); err != nil {
log.Printf("Speichern fehlgeschlagen: %v", err)
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: fmt.Sprintf("✅ Timeout auf %d Minuten gesetzt.", minutes), Flags: discordgo.MessageFlagsEphemeral}})
case "adduser":
// wer ruft auf?
requester := i.User
if requester == nil && i.Member != nil {
requester = i.Member.User
}
if requester == nil {
return
}
// Ziel-User aus den Optionen holen
var target *discordgo.User
for _, o := range data.Options {
if o.Name == "user" {
// UserValue braucht die Session
u := o.UserValue(s)
if u != nil {
target = u
}
}
}
if target == nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Bitte gib ein gültiges Mitglied an.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// In welchem VC ist der Aufrufer gerade?
srcVC := findUserVoiceChannelID(s, guildID, requester.ID)
if srcVC == "" {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Du bist aktuell in keinem Voice-Channel. Betritt zuerst deinen privaten Channel.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Sicherheits-Check: darf der Aufrufer diesen Channel verwalten?
// (Der Owner bekommt in deinem Code ManageChannels → das ist unser Indikator)
perms, err := s.UserChannelPermissions(requester.ID, srcVC)
if err != nil || perms&int64(discordgo.PermissionManageChannels) == 0 {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "❌ Du bist nicht der Besitzer dieses Channels oder dir fehlen Rechte (Manage Channel).",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Overwrite setzen: Ziel-User darf sehen & beitreten (& sprechen/Stream/VAD)
allow := int64(
discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect |
discordgo.PermissionVoiceSpeak |
discordgo.PermissionVoiceUseVAD |
discordgo.PermissionVoiceStreamVideo,
)
if err := s.ChannelPermissionSet(
srcVC,
target.ID,
discordgo.PermissionOverwriteTypeMember,
allow,
0,
); err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: fmt.Sprintf("Konnte Berechtigung nicht setzen: %v", err),
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: fmt.Sprintf("✅ %s hat jetzt Zugriff auf deinen Voice-Channel.", target.Username),
Flags: discordgo.MessageFlagsEphemeral,
},
})
}
}
}
// ===== Commands Definition =====
var (
adminPerm = int64(discordgo.PermissionAdministrator)
slashCommands = []*discordgo.ApplicationCommand{
{
Name: "makevc",
Description: "Erstellt einen privaten Voice-Channel (Auto-Cleanup)",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "Name des Channels", Required: false},
{Type: discordgo.ApplicationCommandOptionInteger, Name: "user_limit", Description: "Max. Nutzer (0=unbegrenzt)", Required: false, MaxValue: 99},
{Type: discordgo.ApplicationCommandOptionInteger, Name: "timeout_min", Description: "Löschen nach X Minuten Inaktivität", Required: false, MaxValue: 480},
},
},
{
Name: "setlobby",
Description: "Setzt den Namen des Lobby-Voice-Channels",
DefaultMemberPermissions: &adminPerm,
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "Neuer Lobby-Name", Required: true},
},
},
{
Name: "setcategory",
Description: "Setzt den Namen der Kategorie für private Räume",
DefaultMemberPermissions: &adminPerm,
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "Neue Kategorie-Bezeichnung", Required: true},
},
},
{
Name: "settimeout",
Description: "Setzt das Auto-Lösch-Timeout (Minuten)",
DefaultMemberPermissions: &adminPerm,
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionInteger, Name: "minutes", Description: "Minuten (>=1)", Required: true, MaxValue: 480},
},
},
{
Name: "adduser",
Description: "Gibt einem Mitglied Zugriff auf deinen aktuellen privaten Voice-Channel",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionUser,
Name: "user",
Description: "Mitglied, das Zugriff bekommen soll",
Required: true,
},
},
},
}
)
// ===== main: Multi-Guild, pro Guild registrieren =====
func main() {
token := os.Getenv("DISCORD_TOKEN")
if token == "" {
log.Fatal("Bitte setze DISCORD_TOKEN")
}
// Persistente Konfiguration laden (optional)
if err := loadGuildCfgs(); err != nil {
log.Printf("Hinweis: Konnte %s nicht laden (%v). Starte mit Defaults.", configPath, err)
}
s, err := discordgo.New("Bot " + token)
if err != nil {
log.Fatalf("Session fehlgeschlagen: %v", err)
}
s.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildVoiceStates
s.AddHandler(onVoiceStateUpdate)
s.AddHandler(onInteractionCreate(""))
s.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) {
log.Printf("Eingeloggt als %s", r.User.Username)
registerOnce.Do(func() {
appID := s.State.User.ID
for _, g := range s.State.Guilds {
log.Printf("Registriere Commands in Guild: %s (%s)", g.Name, g.ID)
for _, cmd := range slashCommands {
c, err := s.ApplicationCommandCreate(appID, g.ID, cmd)
if err != nil {
log.Printf("Command-Registrierung in %s fehlgeschlagen: %v", g.Name, err)
continue
}
createdCmds[g.ID] = append(createdCmds[g.ID], c)
}
}
log.Printf("Defaults → Lobby: %q | Kategorie: %q | Timeout: %d min", defaultLobbyName, defaultCategory, envTimeoutDefault(60))
})
})
if err := s.Open(); err != nil {
log.Fatalf("Gateway-Start fehlgeschlagen: %v", err)
}
log.Println("Bot online. Ctrl+C zum Beenden.")
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("Fahre herunter…")
appID := s.State.User.ID
for guildID, cmds := range createdCmds {
for _, c := range cmds {
if delErr := s.ApplicationCommandDelete(appID, guildID, c.ID); delErr != nil {
log.Printf("Cmd-Delete (%s/%s) fehlgeschlagen: %v", guildID, c.Name, delErr)
}
}
}
if err := saveGuildCfgs(); err != nil {
log.Printf("Speichern zum Shutdown fehlgeschlagen: %v", err)
}
_ = s.Close()
}