Files
silo-server/internal/notifications/discord_dm.go
T
bcf0253c09 feat(notifications): notify requesters of request status changes (#143)
Requests previously only notified the community server channels for
submitted/approved/declined and the requester personally for fulfilled.
This closes the gap and makes request posts addressable:

- New request.approved / request.declined delivery types ride the
  operational dispatch path to the requesting profile: inbox, websocket
  toast, email, Discord DM, personal webhooks (gated by the existing
  notify_requests flag), and web push. Submitted stays broadcast-only
  (the requester performed the action themselves). Title/year/decline
  reason travel in reason_flags since no catalog item exists yet.
- Request status notices are transactional: digest-mode recipients get
  an off-schedule early send (watermark-durable, last_digest_at left
  alone) instead of waiting for the digest hour. Per-episode recipients
  were already immediate via the dispatch nudge.
- At-most-once per (profile, request, type) via a partial unique index
  (migration 20260612100000), mirroring the fulfilled dedupe.
- Server-channel Discord request posts can @mention the requester via
  their OAuth-linked identity (notifications.server_channels.
  mention_requesters, default off). Resolved lazily in the sweep worker
  only when a Discord destination is about to receive the event; the
  ping uses content-level mention with pinned allowed_mentions, and the
  Discord identity never leaks into generic webhook payloads.

Android/Apple clients render the new inbox types with their generic
fallback until they add them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:20:55 -04:00

279 lines
10 KiB
Go

package notifications
import (
"context"
"errors"
"fmt"
"time"
"github.com/Silo-Server/silo-server/internal/discord"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// discordDMBlockedMessage is the link_failure text surfaced in the settings
// UI when Discord refuses the DM (error 50007).
const discordDMBlockedMessage = "Discord rejected the direct message. " +
"Make sure you share a server with the bot and allow direct messages from server members."
// discordChannel implements accountChannel over the Discord bot REST API,
// keyed by login account: the linked identity is account-level, so one DM
// collapses cross-profile duplicates. The bot token is read live from
// settings on every pass, so admin changes apply without a restart (same
// pattern as the SMTP sender).
type discordChannel struct {
prefs *DiscordPrefsRepository
deliveries *DeliveryRepository
settings *Settings
client *discord.Client
// posterURL picks the artwork URL DM embeds may carry. Wired by
// NewSystem after construction; nil renders embeds without images.
posterURL func(ctx context.Context, posterPath, posterSourcePath string) string
}
// The assertion also keeps staticcheck's unused-analysis aware that the
// adapter methods are consumed through the generic engine interface.
var _ accountChannel[int] = (*discordChannel)(nil)
// newDiscordWorker assembles the Discord DM channel on the shared
// account-channel engine, returning the channel too so NewSystem can wire
// post-construction hooks (posterURL) on it.
func newDiscordWorker(
pool *pgxpool.Pool,
deliveries *DeliveryRepository,
prefs *DiscordPrefsRepository,
settings *Settings,
client *discord.Client,
) (*accountChannelWorker[int], *discordChannel) {
channel := &discordChannel{
prefs: prefs,
deliveries: deliveries,
settings: settings,
client: client,
}
return newAccountChannelWorker(pool, channel), channel
}
func (c *discordChannel) name() string { return "discord" }
func (c *discordChannel) enabled(ctx context.Context) bool {
return c.settings.DiscordEnabled(ctx) && c.settings.DiscordBotToken(ctx) != ""
}
func (c *discordChannel) allowPerEpisode(ctx context.Context) bool {
return c.settings.DiscordAllowPerEpisode(ctx)
}
func (c *discordChannel) digestHour(ctx context.Context) int {
return c.settings.DiscordDigestHour(ctx)
}
func (c *discordChannel) listRecipients(ctx context.Context) ([]accountRecipient[int], error) {
return c.prefs.ListActiveRecipients(ctx)
}
func (c *discordChannel) hasPendingSince(ctx context.Context, userID int, since Cursor) (bool, error) {
return c.deliveries.HasForUserSince(ctx, userID, since)
}
func (c *discordChannel) hasTransactionalPendingSince(ctx context.Context, userID int, since Cursor) (bool, error) {
return c.deliveries.HasTransactionalForUserSince(ctx, userID, since)
}
func (c *discordChannel) listSince(ctx context.Context, tx pgx.Tx, userID int, since Cursor, until time.Time, limit int) ([]DeliveryRow, error) {
return c.deliveries.ListForUserSince(ctx, tx, userID, since, until, limit)
}
func (c *discordChannel) claim(ctx context.Context, tx pgx.Tx, userID int) (*accountRecipient[int], error) {
return c.prefs.claimForUpdate(ctx, tx, userID)
}
func (c *discordChannel) markSent(ctx context.Context, tx pgx.Tx, userID int, watermark Cursor, digestAt *time.Time) error {
return c.prefs.markSent(ctx, tx, userID, watermark, digestAt)
}
func (c *discordChannel) markFailure(ctx context.Context, tx pgx.Tx, userID int, sendErr error) error {
message := "Discord delivery failed"
switch {
case errors.Is(sendErr, discord.ErrDMBlocked):
message = discordDMBlockedMessage
case sendErr != nil:
message = truncateWithEllipsis("Discord delivery failed: "+sendErr.Error(), 300)
}
return c.prefs.markFailure(ctx, tx, userID, message)
}
// send delivers one account's pending rows as a single bot DM. Failures that
// indicate a global problem (missing/rejected bot token, rate limiting) wrap
// errChannelUnavailable so the pass aborts without penalizing the account;
// everything else (notably 50007 DM-blocked) backs off per account and is
// surfaced as link health.
func (c *discordChannel) send(ctx context.Context, tx pgx.Tx, userID int, _ string, rows []DeliveryRow) error {
botToken := c.settings.DiscordBotToken(ctx)
if botToken == "" {
return fmt.Errorf("bot token not configured: %w", errChannelUnavailable)
}
discordUserID, dmChannelID, err := c.prefs.identityForSend(ctx, tx, userID)
if err != nil {
return err
}
if discordUserID == "" {
return fmt.Errorf("account %d has no linked discord identity", userID)
}
if dmChannelID == "" {
dmChannelID, err = c.client.OpenDMChannel(ctx, botToken, discordUserID)
if err != nil {
return classifyDiscordSendError(err)
}
if err := c.prefs.cacheDMChannel(ctx, tx, userID, dmChannelID); err != nil {
return err
}
}
if c.posterURL != nil {
for i := range rows {
rows[i].PosterURL = c.posterURL(ctx, rows[i].PosterPath, rows[i].PosterSourcePath)
}
}
payload, err := BuildDiscordDMPayload(rows)
if err != nil {
return fmt.Errorf("build discord dm payload: %w", err)
}
if err := c.client.SendDM(ctx, botToken, dmChannelID, payload); err != nil {
return classifyDiscordSendError(err)
}
return nil
}
// classifyDiscordSendError separates global transport problems (which abort
// the pass) from per-account failures (which back off and surface as link
// health).
func classifyDiscordSendError(err error) error {
if errors.Is(err, discord.ErrUnauthorized) || errors.Is(err, discord.ErrRateLimited) {
return fmt.Errorf("%w: %w", err, errChannelUnavailable)
}
return err
}
// Errors surfaced by the Discord System methods for the API layer to map to
// 4xx responses.
var (
ErrDiscordModeInvalid = errors.New("invalid discord notification mode")
ErrDiscordModeNotAllowed = errors.New("per-episode discord DMs are disabled by the administrator")
ErrDiscordNotLinked = errors.New("no linked discord account")
ErrDiscordNotConfigured = errors.New("discord integration is not configured")
)
// discordLinkStateTTL bounds how long a started link flow stays redeemable.
const discordLinkStateTTL = 10 * time.Minute
// DiscordConfigured reports whether the admin has supplied the full Discord
// application credential set (linking needs client ID + secret; DM delivery
// needs the bot token).
func (s *System) DiscordConfigured(ctx context.Context) bool {
return s != nil && s.DiscordPrefs != nil &&
s.Settings.DiscordClientID(ctx) != "" &&
s.Settings.DiscordClientSecret(ctx) != "" &&
s.Settings.DiscordBotToken(ctx) != ""
}
// DiscordAvailable reports whether the Discord DM channel can deliver right
// now: fully configured and the kill switch is on.
func (s *System) DiscordAvailable(ctx context.Context) bool {
return s.DiscordConfigured(ctx) && s.Settings.DiscordEnabled(ctx)
}
// DiscordPrefsFor returns the account's Discord link + mode state.
func (s *System) DiscordPrefsFor(ctx context.Context, userID int) (DiscordPrefs, error) {
if s == nil || s.DiscordPrefs == nil {
return DiscordPrefs{UserID: userID, Mode: ChannelModeOff}, nil
}
return s.DiscordPrefs.Get(ctx, userID)
}
// SetDiscordMode validates and stores the account's Discord DM mode.
// Enabling requires a linked Discord account and, for per-episode, the admin
// allowance.
func (s *System) SetDiscordMode(ctx context.Context, userID int, mode string) error {
if s == nil || s.DiscordPrefs == nil {
return ErrDiscordModeInvalid
}
if !ValidChannelMode(mode) {
return ErrDiscordModeInvalid
}
if ModeIncludesPerEpisode(mode) && !s.Settings.DiscordAllowPerEpisode(ctx) {
return ErrDiscordModeNotAllowed
}
return s.DiscordPrefs.SetMode(ctx, userID, mode)
}
// BeginDiscordLink records a one-time state row for a link flow started by
// userID.
func (s *System) BeginDiscordLink(ctx context.Context, state string, userID int) error {
if s == nil || s.DiscordPrefs == nil {
return ErrDiscordNotConfigured
}
return s.DiscordPrefs.CreateLinkState(ctx, state, userID, time.Now().UTC().Add(discordLinkStateTTL))
}
// ConsumeDiscordLinkState redeems a one-time link state, returning the user
// who started the flow. ok is false for unknown, used, or expired states.
func (s *System) ConsumeDiscordLinkState(ctx context.Context, state string) (int, bool, error) {
if s == nil || s.DiscordPrefs == nil {
return 0, false, ErrDiscordNotConfigured
}
return s.DiscordPrefs.ConsumeLinkState(ctx, state)
}
// CompleteDiscordLink exchanges the OAuth authorization code, resolves the
// Discord identity behind it, and links it to the account.
func (s *System) CompleteDiscordLink(ctx context.Context, userID int, code, redirectURI string) (discord.User, error) {
if s == nil || s.discordClient == nil || s.DiscordPrefs == nil {
return discord.User{}, ErrDiscordNotConfigured
}
clientID := s.Settings.DiscordClientID(ctx)
clientSecret := s.Settings.DiscordClientSecret(ctx)
if clientID == "" || clientSecret == "" {
return discord.User{}, ErrDiscordNotConfigured
}
accessToken, err := s.discordClient.ExchangeCode(ctx, clientID, clientSecret, code, redirectURI)
if err != nil {
return discord.User{}, err
}
user, err := s.discordClient.GetUser(ctx, accessToken)
if err != nil {
return discord.User{}, err
}
if err := s.DiscordPrefs.SetIdentity(ctx, userID, user.ID, user.Username); err != nil {
return discord.User{}, err
}
s.logger.Info("discord account linked", "user_id", userID, "discord_user_id", user.ID)
return user, nil
}
// UnlinkDiscord removes the account's Discord identity and switches the
// channel off.
func (s *System) UnlinkDiscord(ctx context.Context, userID int) error {
if s == nil || s.DiscordPrefs == nil {
return nil
}
return s.DiscordPrefs.ClearIdentity(ctx, userID)
}
// TestDiscordBot verifies the configured bot token by fetching the bot's own
// identity. Used by the admin test endpoint.
func (s *System) TestDiscordBot(ctx context.Context) (discord.User, error) {
if s == nil || s.discordClient == nil {
return discord.User{}, ErrDiscordNotConfigured
}
// The admin typically tests seconds after saving; don't let the read
// cache report a stale "not configured".
s.Settings.Invalidate(SettingDiscordBotToken)
botToken := s.Settings.DiscordBotToken(ctx)
if botToken == "" {
return discord.User{}, ErrDiscordNotConfigured
}
return s.discordClient.GetBotUser(ctx, botToken)
}