package features import ( "bytes" "fmt" "io" "log/slog" "net/http" "strings" "github.com/disgoorg/disgo/discord" "github.com/disgoorg/disgo/events" "github.com/disgoorg/disgo/rest" "github.com/disgoorg/disgo/webhook" "github.com/disgoorg/snowflake/v2" "github.com/elisiei/unbot/db" ) type Starboard struct { db *db.DB config StarboardConfig webhook *webhook.Client } type StarboardConfig struct { Enabled bool WebhookURL string ChannelID uint64 Threshold int } func NewStarboard(database *db.DB, webhookURL string, channelID uint64, threshold int, enabled bool) (*Starboard, error) { var wh *webhook.Client if webhookURL != "" { var err error wh, err = webhook.NewWithURL(webhookURL) if err != nil { return nil, fmt.Errorf("failed to create webhook: %w", err) } } return &Starboard{ db: database, webhook: wh, config: StarboardConfig{ Enabled: enabled, WebhookURL: webhookURL, ChannelID: channelID, Threshold: threshold, }, }, nil } func (s *Starboard) HandleReactionAdd(e *events.GuildMessageReactionAdd) { if !s.config.Enabled || s.webhook == nil { return } if e.ChannelID == snowflake.ID(s.config.ChannelID) { return } originalMsgID := e.MessageID.String() msg, err := e.Client().Rest.GetMessage(e.ChannelID, e.MessageID) if err != nil { slog.Error("failed to get message for starboard", slog.Any("err", err)) return } totalReactions := 0 for _, r := range msg.Reactions { totalReactions += r.Count } starboardMsgIDStr, err := s.db.GetStarboardMessageID(originalMsgID) if err != nil { slog.Error("failed to check starboard entry", slog.Any("err", err)) return } if starboardMsgIDStr != "" { if totalReactions >= s.config.Threshold { s.updateStarboardEntry(starboardMsgIDStr, msg, e.ChannelID, e.GuildID, e.MessageID) } else { s.removeStarboardEntry(starboardMsgIDStr, originalMsgID) } return } if totalReactions < s.config.Threshold { return } content, embeds, files := s.buildStarboardMessage(msg, e.ChannelID, e.GuildID, e.MessageID) msgCreate := discord.WebhookMessageCreate{ Content: content, Embeds: embeds, Files: files, Username: msg.Author.EffectiveName(), AvatarURL: msg.Author.EffectiveAvatarURL(), } webhookMsg, err := s.webhook.CreateMessage(msgCreate, rest.CreateWebhookMessageParams{Wait: true}) if err != nil { slog.Error("failed to send starboard webhook", slog.Any("err", err)) return } if webhookMsg == nil { slog.Error("starboard webhook returned nil message") return } if err := s.db.AddStarboardEntry(originalMsgID, webhookMsg.ID.String()); err != nil { slog.Error("failed to save starboard entry", slog.Any("err", err)) } } func (s *Starboard) HandleReactionRemove(e *events.GuildMessageReactionRemove) { if !s.config.Enabled || s.webhook == nil { return } if e.ChannelID == snowflake.ID(s.config.ChannelID) { return } originalMsgID := e.MessageID.String() starboardMsgIDStr, err := s.db.GetStarboardMessageID(originalMsgID) if err != nil { slog.Error("failed to check starboard entry", slog.Any("err", err)) return } if starboardMsgIDStr == "" { return } msg, err := e.Client().Rest.GetMessage(e.ChannelID, e.MessageID) if err != nil { slog.Warn("original message gone, removing starboard entry", slog.Any("err", err)) s.removeStarboardEntry(starboardMsgIDStr, originalMsgID) return } totalReactions := 0 for _, r := range msg.Reactions { totalReactions += r.Count } if totalReactions >= s.config.Threshold { s.updateStarboardEntry(starboardMsgIDStr, msg, e.ChannelID, e.GuildID, e.MessageID) } else { s.removeStarboardEntry(starboardMsgIDStr, originalMsgID) } } func (s *Starboard) buildStarboardMessage(msg *discord.Message, channelID, guildID, messageID snowflake.ID) (content string, embeds []discord.Embed, files []*discord.File) { content = msg.Content if content == "" { content = "*(no text content)*" } if len(content) > 2000 { content = content[:1997] + "..." } jumpURL := fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildID, channelID, messageID) for _, a := range msg.Attachments { resp, err := http.Get(a.URL) if err != nil { slog.Warn("failed to download attachment for starboard", slog.Any("err", err), slog.String("url", a.URL)) continue } data, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { slog.Warn("failed to read attachment for starboard", slog.Any("err", err), slog.String("url", a.URL)) continue } files = append(files, discord.NewFile(a.Filename, "", bytes.NewReader(data))) } if len(msg.Reactions) > 0 { var parts []string for _, r := range msg.Reactions { var e string if r.Emoji.ID == 0 { e = r.Emoji.Name } else if r.Emoji.Animated { e = fmt.Sprintf("", r.Emoji.Name, r.Emoji.ID) } else { e = fmt.Sprintf("<:%s:%d>", r.Emoji.Name, r.Emoji.ID) } parts = append(parts, fmt.Sprintf("%s %d", e, r.Count)) } parts = append(parts, fmt.Sprintf("\n[[ jump to message ]](%s)", jumpURL)) embeds = append(embeds, discord.Embed{ Description: strings.Join(parts, " "), }) } return } func (s *Starboard) updateStarboardEntry(starboardMsgIDStr string, msg *discord.Message, channelID, guildID, messageID snowflake.ID) { content, embeds, files := s.buildStarboardMessage(msg, channelID, guildID, messageID) starboardMsgID, err := snowflake.Parse(starboardMsgIDStr) if err != nil { slog.Error("failed to parse starboard message ID", slog.Any("err", err)) return } emptyAttachments := []discord.AttachmentUpdate{} update := discord.WebhookMessageUpdate{ Content: &content, Embeds: &embeds, Attachments: &emptyAttachments, Files: files, } if _, err := s.webhook.UpdateMessage(starboardMsgID, update, rest.UpdateWebhookMessageParams{}); err != nil { slog.Error("failed to update starboard webhook", slog.Any("err", err)) } } func (s *Starboard) removeStarboardEntry(starboardMsgIDStr, originalMsgID string) { starboardMsgID, err := snowflake.Parse(starboardMsgIDStr) if err == nil { if err := s.webhook.Rest.DeleteWebhookMessage(s.webhook.ID, s.webhook.Token, starboardMsgID, 0); err != nil { slog.Error("failed to delete starboard webhook message", slog.Any("err", err)) } } else { slog.Error("failed to parse starboard message ID for deletion", slog.Any("err", err)) } if err := s.db.RemoveStarboardEntry(originalMsgID); err != nil { slog.Error("failed to remove starboard entry", slog.Any("err", err)) } }