Files
patchpinglite/internal/discordbot/bot.go
jbergner 270c13af5b
All checks were successful
release-tag / release-image (push) Successful in 2m3s
init
2026-05-04 22:25:50 +02:00

508 lines
15 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 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 ""
}