package discordbot import ( "context" "errors" "fmt" "log" "log/slog" "strings" "unicode" "github.com/bwmarrin/discordgo" "releasewatcher/internal/store" ) type Config struct { GuildID string CategoryID string CategoryName string ReleaseMention string SendDMs bool } type Services struct { Store *store.FileStore DG *discordgo.Session Log *slog.Logger Config Config } func NewDiscordSession(token string) (*discordgo.Session, error) { dg, err := discordgo.New("Bot " + token) if err != nil { return nil, err } dg.Identify.Intents = 0 return dg, nil } func NewServices(st *store.FileStore, dg *discordgo.Session, log *slog.Logger, cfg Config) *Services { if cfg.CategoryName == "" { cfg.CategoryName = "ReleaseWatcher" } return &Services{Store: st, DG: dg, Log: log, Config: cfg} } func (s *Services) AddSubscriber(ctx context.Context, userID, username string) error { if s.Store == nil { return fmt.Errorf("store is nil") } return s.Store.UpsertDiscordSubscriber(userID, username) } func (s *Services) RemoveSubscriber(ctx context.Context, userID string) error { if s.Store == nil { return fmt.Errorf("store is nil") } return s.Store.RemoveDiscordSubscriber(userID) } func (s *Services) SendDM(ctx context.Context, userID, message string) error { if s.DG == nil { return fmt.Errorf("discord session is nil") } ch, err := s.DG.UserChannelCreate(userID) if err != nil { return err } _, err = s.DG.ChannelMessageSend(ch.ID, message) return err } func (s *Services) EnsureSoftwareChannel(ctx context.Context, software store.Software, manufacturer store.Manufacturer) (string, error) { if s.DG == nil { return "", nil } if s.Config.GuildID == "" { return "", fmt.Errorf("RW_DISCORD_GUILD_ID fehlt; Software-Kanal kann nicht erstellt werden") } if strings.TrimSpace(software.DiscordChannelID) != "" { return software.DiscordChannelID, nil } categoryID, err := s.ensureCategory(ctx) if err != nil { return "", err } channelName := channelName(manufacturer.Name, software.Name) if channelName == "" { channelName = "release-" + software.ID[:min(len(software.ID), 8)] } chs, err := s.DG.GuildChannels(s.Config.GuildID) if err != nil { return "", err } for _, ch := range chs { if ch.Type == discordgo.ChannelTypeGuildText && ch.Name == channelName && ch.ParentID == categoryID { return ch.ID, nil } } created, err := s.DG.GuildChannelCreateComplex(s.Config.GuildID, discordgo.GuildChannelCreateData{ Name: channelName, Type: discordgo.ChannelTypeGuildText, ParentID: categoryID, }) if err != nil { return "", err } if s.Log != nil { s.Log.InfoContext(ctx, "discord software channel created", "software", software.Name, "manufacturer", manufacturer.Name, "channel_id", created.ID, "channel_name", channelName) } _, _ = s.DG.ChannelMessageSend(created.ID, fmt.Sprintf("📦 Release-Kanal für **%s - %s** wurde angelegt.", manufacturer.Name, software.Name)) return created.ID, nil } func (s *Services) ensureCategory(ctx context.Context) (string, error) { if s.Config.CategoryID != "" { return s.Config.CategoryID, nil } chs, err := s.DG.GuildChannels(s.Config.GuildID) if err != nil { return "", err } for _, ch := range chs { if ch.Type == discordgo.ChannelTypeGuildCategory && strings.EqualFold(ch.Name, s.Config.CategoryName) { return ch.ID, nil } } created, err := s.DG.GuildChannelCreateComplex(s.Config.GuildID, discordgo.GuildChannelCreateData{ Name: s.Config.CategoryName, Type: discordgo.ChannelTypeGuildCategory, }) if err != nil { return "", err } if s.Log != nil { s.Log.InfoContext(ctx, "discord category created", "category_id", created.ID, "category_name", s.Config.CategoryName) } return created.ID, nil } func (s *Services) NotifyReleaseCreated(ctx context.Context, release store.Release, software store.Software) error { if s.Store == nil || s.DG == nil { return nil } var errs []error manufacturer, _ := s.Store.FindManufacturer(software.ManufacturerID) if software.DiscordChannelID == "" && s.Config.GuildID != "" { chID, err := s.EnsureSoftwareChannel(ctx, software, manufacturer) if err != nil { errs = append(errs, fmt.Errorf("software channel: %w", err)) } else if chID != "" { updated, err := s.Store.SetSoftwareDiscordChannelID(software.ID, chID, "discord") if err != nil { errs = append(errs, fmt.Errorf("store channel id: %w", err)) } else { software = updated } } } if software.DiscordChannelID != "" { if err := s.SendReleaseToChannel(ctx, software.DiscordChannelID, release, software, manufacturer); err != nil { errs = append(errs, fmt.Errorf("channel ping: %w", err)) } } if s.Config.SendDMs { subscribers := s.Store.ListDiscordSubscribers() msg := releaseMessage(release, software, manufacturer) var failed int for _, sub := range subscribers { if err := s.SendDM(ctx, sub.UserID, msg); err != nil { failed++ if s.Log != nil { s.Log.WarnContext(ctx, "discord dm failed", "discord_user_id", sub.UserID, "username", sub.Username, "error", err) } } } if failed > 0 { errs = append(errs, fmt.Errorf("%d von %d Discord-DMs konnten nicht gesendet werden", failed, len(subscribers))) } } return errors.Join(errs...) } func (s *Services) SendReleaseToChannel(ctx context.Context, channelID string, release store.Release, software store.Software, manufacturer store.Manufacturer) error { if s.DG == nil || channelID == "" { return nil } content := strings.TrimSpace(s.Config.ReleaseMention) embed := releaseEmbed(release, software, manufacturer) _, err := s.DG.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ Content: content, Embed: embed, }) if err != nil { return err } if s.Log != nil { s.Log.InfoContext(ctx, "discord release channel ping sent", "software", software.Name, "version", release.Version, "channel_id", channelID) } return nil } func releaseEmbed(release store.Release, software store.Software, manufacturer store.Manufacturer) *discordgo.MessageEmbed { title := fmt.Sprintf("🚀 Neues Release: %s %s", software.Name, release.Version) if manufacturer.Name != "" { title = fmt.Sprintf("🚀 Neues Release: %s - %s %s", manufacturer.Name, software.Name, release.Version) } fields := []*discordgo.MessageEmbedField{} addField := func(name, value string, inline bool) { value = strings.TrimSpace(value) if value != "" { fields = append(fields, &discordgo.MessageEmbedField{Name: name, Value: value, Inline: inline}) } } addField("Channel", codeOrDash(release.Channel), true) addField("Architektur", codeOrDash(release.Architecture), true) addField("Release-Datum", emptyDash(release.ReleaseDate), true) if len(release.Vulnerabilities) > 0 { addField("Schwachstellen", vulnerabilitySummary(release.Vulnerabilities), false) } if release.ReleaseURL != "" { addField("Infos", release.ReleaseURL, false) } if release.DownloadURL != "" { addField("Download", release.DownloadURL, false) } return &discordgo.MessageEmbed{ Title: title, Description: strings.TrimSpace(release.Info), Color: 0x57F287, Fields: fields, } } func releaseMessage(release store.Release, software store.Software, manufacturer store.Manufacturer) string { var b strings.Builder prefix := software.Name if manufacturer.Name != "" { prefix = manufacturer.Name + " - " + software.Name } fmt.Fprintf(&b, "🚀 Neues Release veröffentlicht: **%s %s**\n", prefix, release.Version) if release.Channel != "" { fmt.Fprintf(&b, "Channel: `%s`\n", release.Channel) } if release.Architecture != "" { fmt.Fprintf(&b, "Architektur: `%s`\n", release.Architecture) } if release.ReleaseDate != "" { fmt.Fprintf(&b, "Release-Datum: %s\n", release.ReleaseDate) } if release.Info != "" { fmt.Fprintf(&b, "\n%s\n", release.Info) } if len(release.Vulnerabilities) > 0 { b.WriteString("\nSchwachstellen:\n") for _, v := range release.Vulnerabilities { label := strings.TrimSpace(v.CVE) if label == "" { label = "Eintrag" } if v.Severity != "" { label += " (" + v.Severity + ")" } fmt.Fprintf(&b, "• %s", label) if v.Reference != "" { fmt.Fprintf(&b, " – %s", v.Reference) } b.WriteString("\n") } } if release.ReleaseURL != "" { fmt.Fprintf(&b, "\nInfos: %s", release.ReleaseURL) } if release.DownloadURL != "" { fmt.Fprintf(&b, "\nDownload: %s", release.DownloadURL) } return b.String() } func vulnerabilitySummary(vulns []store.Vulnerability) string { var lines []string for _, v := range vulns { label := strings.TrimSpace(v.CVE) if label == "" { label = "Eintrag" } if v.Severity != "" { label += " (" + v.Severity + ")" } if v.Reference != "" { label += " – " + v.Reference } lines = append(lines, "• "+label) } out := strings.Join(lines, "\n") if len(out) > 1000 { out = out[:997] + "..." } return out } func codeOrDash(v string) string { v = strings.TrimSpace(v) if v == "" { return "—" } return "`" + v + "`" } func emptyDash(v string) string { v = strings.TrimSpace(v) if v == "" { return "—" } return v } func channelName(manufacturer, software string) string { raw := strings.TrimSpace(manufacturer + "-" + software) repl := strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss", "Ä", "ae", "Ö", "oe", "Ü", "ue") raw = repl.Replace(raw) var b strings.Builder lastDash := false for _, r := range strings.ToLower(raw) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) lastDash = false case unicode.IsSpace(r), r == '-', r == '_', r == '.', r == '/', r == '\\': if !lastDash && b.Len() > 0 { b.WriteRune('-') lastDash = true } default: if !lastDash && b.Len() > 0 { b.WriteRune('-') lastDash = true } } } out := strings.Trim(b.String(), "-") if len(out) > 90 { out = strings.Trim(out[:90], "-") } return out } func AttachDiscordHandlers(dg *discordgo.Session, svc *Services) { dg.AddHandler(func(_ *discordgo.Session, g *discordgo.GuildCreate) { chID := findWritableTextChannel(dg, g.Guild) if chID == "" { return } embed := &discordgo.MessageEmbed{ Title: "👋 Willkommen!", Description: "Ich kann dir Updates per DM schicken.\n\n• Tippe `/subscribe` oder\n• klicke den Button unten, um eine DM mit mir zu starten.", Color: 0x5865F2, } components := []discordgo.MessageComponent{ discordgo.ActionsRow{Components: []discordgo.MessageComponent{ discordgo.Button{CustomID: "start_dm", Label: "DM starten", Style: discordgo.PrimaryButton}, }}, } _, _ = dg.ChannelMessageSendComplex(chID, &discordgo.MessageSend{Embed: embed, Components: components}) }) dg.AddHandler(func(_ *discordgo.Session, i *discordgo.InteractionCreate) { if i.Type == discordgo.InteractionMessageComponent && i.MessageComponentData().CustomID == "start_dm" { u := actor(i) if u == nil { respondEphemeral(dg, i, "Konnte dich nicht ermitteln.") return } if err := svc.SendDM(context.Background(), u.ID, "Hey! Schön, dass du da bist. Ab jetzt kann ich dir DMs schicken. ✨"); err != nil { respondEphemeral(dg, i, "DM konnte nicht zugestellt werden (Privacy-Einstellungen?).") return } _ = svc.AddSubscriber(context.Background(), u.ID, userLabel(u)) respondEphemeral(dg, i, "Ich habe dir eine DM geschickt und dich zu den Empfängern hinzugefügt. 👍") return } if i.Type != discordgo.InteractionApplicationCommand { return } cmd := i.ApplicationCommandData() switch cmd.CommandType { case discordgo.UserApplicationCommand: targetID := cmd.TargetID if targetID == "" { respondEphemeral(dg, i, "Kein Zielbenutzer erhalten.") return } username := "" if cmd.Resolved != nil { if u, ok := cmd.Resolved.Users[targetID]; ok && u != nil { username = userLabel(u) } } if err := svc.AddSubscriber(context.Background(), targetID, username); err != nil { respondEphemeral(dg, i, "Fehler: "+err.Error()) return } respondEphemeral(dg, i, "✅ Nutzer wurde zu den Empfängern hinzugefügt.") case discordgo.ChatApplicationCommand: switch cmd.Name { case "subscribe": u := actor(i) if u == nil { respondEphemeral(dg, i, "Konnte deinen Benutzer nicht ermitteln.") return } if err := svc.AddSubscriber(context.Background(), u.ID, userLabel(u)); err != nil { respondEphemeral(dg, i, "Fehler beim Subscribe: "+err.Error()) return } respondEphemeral(dg, i, "✅ Du erhältst nun DMs. Mit `/unsubscribe` meldest du dich ab. Beachte: dieser Bot liest keine eingehenden Nachrichten.") case "unsubscribe": u := actor(i) if u == nil { respondEphemeral(dg, i, "Konnte deinen Benutzer nicht ermitteln.") return } if err := svc.RemoveSubscriber(context.Background(), u.ID); err != nil { respondEphemeral(dg, i, "Fehler beim Unsubscribe: "+err.Error()) return } respondEphemeral(dg, i, "✅ Du erhältst keine DMs mehr.") } default: log.Printf("Unbekannter CommandType: %v (%q)", cmd.CommandType, cmd.Name) respondEphemeral(dg, i, "Unbekannter Command-Typ.") } }) } func UpsertCommands(s *discordgo.Session, appID string) error { perms := int64(discordgo.PermissionManageGuild) _, err := s.ApplicationCommandCreate(appID, "", &discordgo.ApplicationCommand{ Name: "Zu Empfängern hinzufügen", Type: discordgo.UserApplicationCommand, DefaultMemberPermissions: &perms, DMPermission: ptrBool(false), }) if err != nil { return err } _, err = s.ApplicationCommandCreate(appID, "", &discordgo.ApplicationCommand{ Name: "subscribe", Description: "Opt-in: Nachrichten per DM erhalten", Type: discordgo.ChatApplicationCommand, }) if err != nil { return err } _, err = s.ApplicationCommandCreate(appID, "", &discordgo.ApplicationCommand{ Name: "unsubscribe", Description: "Opt-out: Keine DMs mehr erhalten", Type: discordgo.ChatApplicationCommand, }) return err } func ptrBool(b bool) *bool { return &b } func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, content string) { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{Content: content, Flags: discordgo.MessageFlagsEphemeral}, }) } func userLabel(u *discordgo.User) string { if u == nil { return "" } if u.Discriminator != "" && u.Discriminator != "0" { return u.Username + "#" + u.Discriminator } return u.Username } func actor(i *discordgo.InteractionCreate) *discordgo.User { if i.Member != nil && i.Member.User != nil { return i.Member.User } return i.User } func findWritableTextChannel(s *discordgo.Session, g *discordgo.Guild) string { chs, err := s.GuildChannels(g.ID) if err != nil { return "" } for _, c := range chs { if c.Type == discordgo.ChannelTypeGuildText { perms, err := s.State.UserChannelPermissions(s.State.User.ID, c.ID) if err == nil && (perms&discordgo.PermissionSendMessages) != 0 { return c.ID } } } return "" }