init
All checks were successful
release-tag / release-image (push) Successful in 2m3s

This commit is contained in:
2026-05-04 22:25:50 +02:00
parent be81c2bf92
commit 270c13af5b
21 changed files with 1839 additions and 1 deletions

507
internal/discordbot/bot.go Normal file
View File

@@ -0,0 +1,507 @@
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 ""
}