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>
338 lines
11 KiB
Go
338 lines
11 KiB
Go
package notifications
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Discord embed accent colors per reason (decimal RGB), in reason precedence
|
|
// order: favorite, watchlist, continue_watching, next_up.
|
|
const (
|
|
discordColorFavorite = 5814783
|
|
discordColorWatchlist = 3066993
|
|
discordColorContinueWatching = 15844367
|
|
discordColorNextUp = 15158332
|
|
)
|
|
|
|
// Discord embed limits (enforced by the builder).
|
|
const (
|
|
discordTitleLimit = 256
|
|
discordDescriptionLimit = 4096
|
|
discordFieldValueLimit = 1024
|
|
discordFooterLimit = 2048
|
|
discordTotalLimit = 6000
|
|
)
|
|
|
|
type discordEmbedField struct {
|
|
Name string `json:"name"`
|
|
Value string `json:"value"`
|
|
Inline bool `json:"inline"`
|
|
}
|
|
|
|
type discordEmbedFooter struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type discordEmbedAuthor struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type discordEmbedMedia struct {
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// discordEmbed names external origins under the admin's poster mode
|
|
// (System.discordPosterURL): public provider services (themoviedb.org,
|
|
// imdb.com, thetvdb.com and their image CDNs) by default, plus presigned
|
|
// server-storage URLs only under the explicit "server" opt-in — Discord
|
|
// fetches thumbnail URLs and the raw payload is visible to channel members,
|
|
// so a self-hosted URL reveals the server's address (docs/superpowers/plans/
|
|
// notifications/04, "Server URL leakage"). Builders never derive artwork
|
|
// URLs themselves; they render the PosterURL the sender layer resolved.
|
|
type discordEmbed struct {
|
|
Title string `json:"title"`
|
|
URL string `json:"url,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Color int `json:"color"`
|
|
Author *discordEmbedAuthor `json:"author,omitempty"`
|
|
Thumbnail *discordEmbedMedia `json:"thumbnail,omitempty"`
|
|
Footer *discordEmbedFooter `json:"footer,omitempty"`
|
|
Timestamp string `json:"timestamp,omitempty"`
|
|
Fields []discordEmbedField `json:"fields,omitempty"`
|
|
}
|
|
|
|
// discordAllowedMentions whitelists exactly what a message's content may
|
|
// ping. Parse always serializes (an empty list disables Discord's implicit
|
|
// mention parsing), so content assembled from user-derived text can never
|
|
// ping roles or @everyone.
|
|
type discordAllowedMentions struct {
|
|
Parse []string `json:"parse"`
|
|
Users []string `json:"users,omitempty"`
|
|
}
|
|
|
|
type discordWebhookBody struct {
|
|
Content string `json:"content,omitempty"`
|
|
Embeds []discordEmbed `json:"embeds"`
|
|
Username string `json:"username"`
|
|
AllowedMentions *discordAllowedMentions `json:"allowed_mentions,omitempty"`
|
|
}
|
|
|
|
// BuildDiscordWebhookPayload renders a delivery as a Discord webhook body.
|
|
// Pure function; enforces Discord's embed limits with the spec's truncation
|
|
// policy (description first, then drop fields right-to-left).
|
|
func BuildDiscordWebhookPayload(row DeliveryRow, test bool) ([]byte, error) {
|
|
return json.Marshal(discordWebhookBody{
|
|
Embeds: []discordEmbed{buildDiscordEmbed(row, test)},
|
|
Username: siloSenderName,
|
|
})
|
|
}
|
|
|
|
// discordDMMaxEmbeds is Discord's per-message embed cap.
|
|
const discordDMMaxEmbeds = 10
|
|
|
|
// discordDMBody is a bot channel-message body. Unlike webhook bodies it has
|
|
// no username override: a bot message always carries the bot's own identity.
|
|
type discordDMBody struct {
|
|
Content string `json:"content,omitempty"`
|
|
Embeds []discordEmbed `json:"embeds"`
|
|
}
|
|
|
|
// BuildDiscordDMPayload renders one account's pending deliveries as a single
|
|
// bot DM, one embed per item up to Discord's 10-embed cap. Overflow keeps the
|
|
// newest items and points at the Silo inbox for the rest, mirroring the email
|
|
// digest's rendering cap.
|
|
func BuildDiscordDMPayload(rows []DeliveryRow) ([]byte, error) {
|
|
overflow := 0
|
|
if len(rows) > discordDMMaxEmbeds {
|
|
overflow = len(rows) - discordDMMaxEmbeds
|
|
rows = rows[len(rows)-discordDMMaxEmbeds:]
|
|
}
|
|
embeds := make([]discordEmbed, 0, len(rows))
|
|
for _, row := range rows {
|
|
embeds = append(embeds, buildDiscordEmbed(row, false))
|
|
}
|
|
body := discordDMBody{Embeds: embeds}
|
|
if overflow > 0 {
|
|
body.Content = fmt.Sprintf("…and %d more in your Silo inbox", overflow)
|
|
}
|
|
return json.Marshal(body)
|
|
}
|
|
|
|
// discordEmbedAuthorLine renders the small "what happened" line above the
|
|
// embed title.
|
|
func discordEmbedAuthorLine(deliveryType string) string {
|
|
switch deliveryType {
|
|
case DeliveryTypeEpisodeAvailable:
|
|
return "New episode on Silo"
|
|
case DeliveryTypeRequestFulfilled:
|
|
return "Your request is now available on Silo"
|
|
case DeliveryTypeRequestApproved:
|
|
return "Your request was approved on Silo"
|
|
case DeliveryTypeRequestDeclined:
|
|
return "Your request was declined on Silo"
|
|
default:
|
|
return genericNotificationTitle
|
|
}
|
|
}
|
|
|
|
// discordEmbedFooterText renders the footer: the sender brand, plus the
|
|
// content rating when known so the advisory rides along unobtrusively.
|
|
func discordEmbedFooterText(contentRating string, test bool) string {
|
|
if test {
|
|
return "Silo test notification"
|
|
}
|
|
if contentRating != "" {
|
|
return siloSenderName + " • " + truncateWithEllipsis(contentRating, 32)
|
|
}
|
|
return siloSenderName
|
|
}
|
|
|
|
// buildDiscordEmbed renders one delivery as a Discord embed within all of
|
|
// Discord's per-embed limits: poster thumbnail, overview teaser, provider
|
|
// links, rating, and genres on top of the title/reason basics. The
|
|
// season/episode code lives in the title, so no dedicated fields repeat it.
|
|
func buildDiscordEmbed(row DeliveryRow, test bool) discordEmbed {
|
|
flags := parseReasonFlags(row.ReasonFlags)
|
|
|
|
// Titles assembled from catalog metadata virtually never approach the
|
|
// limit, but Discord hard-rejects oversized embeds, so clip as a last
|
|
// resort even though the truncation policy prefers other fields.
|
|
title := truncateWithEllipsis(discordEmbedTitle(row), discordTitleLimit)
|
|
|
|
overview := row.SeriesOverview
|
|
if row.Type == DeliveryTypeEpisodeAvailable && row.EpisodeOverview != "" {
|
|
overview = row.EpisodeOverview
|
|
}
|
|
isRequestType := row.Type == DeliveryTypeRequestFulfilled || isRequestLifecycleType(row.Type)
|
|
var requestFlags RequestFlags
|
|
if isRequestType {
|
|
requestFlags = parseRequestFlags(row.ReasonFlags)
|
|
}
|
|
ids := providerIDs{MediaType: row.MediaType, IMDB: row.IMDBID, TMDB: row.TMDBID, TVDB: row.TVDBID}
|
|
if isRequestLifecycleType(row.Type) {
|
|
// No catalog join; derive the provider link from the request flags.
|
|
ids = providerIDs{MediaType: requestFlags.MediaType}
|
|
if requestFlags.TMDBID > 0 {
|
|
ids.TMDB = fmt.Sprintf("%d", requestFlags.TMDBID)
|
|
}
|
|
}
|
|
|
|
fields := make([]discordEmbedField, 0, 3)
|
|
if labels := reasonLabelList(flags); len(labels) > 0 {
|
|
fields = append(fields, discordEmbedField{
|
|
Name: "Reason",
|
|
Value: truncateWithEllipsis(strings.Join(labels, " & "), discordFieldValueLimit),
|
|
Inline: true,
|
|
})
|
|
}
|
|
if isRequestType {
|
|
if mediaType := mediaTypeLabel(requestFlags.MediaType); mediaType != "" {
|
|
fields = append(fields, discordEmbedField{Name: "Type", Value: mediaType, Inline: true})
|
|
}
|
|
}
|
|
if row.Type == DeliveryTypeRequestDeclined && requestFlags.Reason != "" {
|
|
fields = append(fields, discordEmbedField{
|
|
Name: "Reason",
|
|
Value: truncateWithEllipsis(requestFlags.Reason, discordFieldValueLimit),
|
|
})
|
|
}
|
|
if rating := ratingLabel(row.RatingIMDB, row.RatingTMDB); rating != "" {
|
|
fields = append(fields, discordEmbedField{Name: "Rating", Value: rating, Inline: true})
|
|
}
|
|
if genres := genresLabel(row.Genres); genres != "" {
|
|
fields = append(fields, discordEmbedField{Name: "Genres", Value: genres, Inline: true})
|
|
}
|
|
|
|
color := discordEmbedColor(flags)
|
|
switch row.Type {
|
|
case DeliveryTypeRequestApproved:
|
|
color = serverChannelColorApproved
|
|
case DeliveryTypeRequestDeclined:
|
|
color = serverChannelColorDeclined
|
|
}
|
|
embed := discordEmbed{
|
|
Title: title,
|
|
URL: ids.titleURL(),
|
|
Description: embedDescription(overview, ids),
|
|
Color: color,
|
|
Author: &discordEmbedAuthor{Name: discordEmbedAuthorLine(row.Type)},
|
|
Footer: &discordEmbedFooter{Text: discordEmbedFooterText(row.ContentRating, test)},
|
|
Fields: fields,
|
|
}
|
|
// The poster decision (provider CDN vs presigned vs none) is the sender
|
|
// layer's: builders render whatever PosterURL carries.
|
|
if row.PosterURL != "" {
|
|
embed.Thumbnail = &discordEmbedMedia{URL: row.PosterURL}
|
|
}
|
|
if !row.CreatedAt.IsZero() {
|
|
embed.Timestamp = row.CreatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
enforceDiscordTotalLimit(&embed)
|
|
return embed
|
|
}
|
|
|
|
func discordEmbedTitle(row DeliveryRow) string {
|
|
switch row.Type {
|
|
case DeliveryTypeRequestFulfilled:
|
|
if row.SeriesTitle != "" {
|
|
return titleWithYear(row.SeriesTitle, row.Year)
|
|
}
|
|
return "Request fulfilled"
|
|
case DeliveryTypeRequestApproved, DeliveryTypeRequestDeclined:
|
|
// No catalog join exists for an unfulfilled request; the title rides
|
|
// in the reason flags.
|
|
flags := parseRequestFlags(row.ReasonFlags)
|
|
if flags.Title != "" {
|
|
return titleWithYear(flags.Title, flags.Year)
|
|
}
|
|
return "Media request"
|
|
case DeliveryTypeEpisodeAvailable:
|
|
// Falls out of the switch into the episode title assembly below.
|
|
default:
|
|
return genericNotificationTitle
|
|
}
|
|
series := row.SeriesTitle
|
|
if series == "" {
|
|
series = genericEpisodeTitle
|
|
}
|
|
var code string
|
|
if row.SeasonNumber != nil && row.EpisodeNumber != nil {
|
|
code = fmt.Sprintf("S%d E%d", *row.SeasonNumber, *row.EpisodeNumber)
|
|
}
|
|
switch {
|
|
case code != "" && row.EpisodeTitle != "":
|
|
return fmt.Sprintf("%s — %s: %s", series, code, row.EpisodeTitle)
|
|
case code != "":
|
|
return fmt.Sprintf("%s — %s", series, code)
|
|
default:
|
|
return series
|
|
}
|
|
}
|
|
|
|
func discordEmbedColor(flags ReasonFlags) int {
|
|
switch {
|
|
case flags.Favorite:
|
|
return discordColorFavorite
|
|
case flags.Watchlist:
|
|
return discordColorWatchlist
|
|
case flags.ContinueWatching:
|
|
return discordColorContinueWatching
|
|
case flags.NextUp:
|
|
return discordColorNextUp
|
|
default:
|
|
return discordColorFavorite
|
|
}
|
|
}
|
|
|
|
func discordEmbedTotal(embed *discordEmbed) int {
|
|
total := len(embed.Title) + len(embed.Description)
|
|
if embed.Author != nil {
|
|
total += len(embed.Author.Name)
|
|
}
|
|
if embed.Footer != nil {
|
|
total += len(embed.Footer.Text)
|
|
}
|
|
for _, field := range embed.Fields {
|
|
total += len(field.Name) + len(field.Value)
|
|
}
|
|
return total
|
|
}
|
|
|
|
// enforceDiscordTotalLimit applies the 6,000-char total cap: truncate the
|
|
// description first, then drop fields right-to-left.
|
|
func enforceDiscordTotalLimit(embed *discordEmbed) {
|
|
embed.Description = truncateWithEllipsis(embed.Description, discordDescriptionLimit)
|
|
if discordEmbedTotal(embed) <= discordTotalLimit {
|
|
return
|
|
}
|
|
overflow := discordEmbedTotal(embed) - discordTotalLimit
|
|
if keep := len(embed.Description) - overflow; keep > 0 {
|
|
embed.Description = truncateWithEllipsis(embed.Description, keep)
|
|
} else {
|
|
embed.Description = ""
|
|
}
|
|
for discordEmbedTotal(embed) > discordTotalLimit && len(embed.Fields) > 0 {
|
|
embed.Fields = embed.Fields[:len(embed.Fields)-1]
|
|
}
|
|
}
|
|
|
|
func truncateWithEllipsis(value string, limit int) string {
|
|
const ellipsis = "…" // 3 bytes in UTF-8; limits are byte counts
|
|
if limit <= 0 {
|
|
return ""
|
|
}
|
|
if len(value) <= limit {
|
|
return value
|
|
}
|
|
if limit <= len(ellipsis) {
|
|
return value[:0]
|
|
}
|
|
runes := []rune(value)
|
|
for len(runes) > 0 && len(string(runes))+len(ellipsis) > limit {
|
|
runes = runes[:len(runes)-1]
|
|
}
|
|
return string(runes) + ellipsis
|
|
}
|