This commit is contained in:
507
internal/discordbot/bot.go
Normal file
507
internal/discordbot/bot.go
Normal 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user