Files
silo-server/internal/notifications/webhook_payload_generic.go
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

128 lines
4.5 KiB
Go

package notifications
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"time"
)
// genericWebhookBody is the canonical Silo webhook JSON
// (docs/superpowers/plans/notifications/04, "Generic"). The HMAC signature is
// computed over the literal bytes Silo sends; receivers verify against the
// literal bytes they received, so no canonicalization is required on either
// side. No server URL, no absolute artwork URLs, no library name.
type genericWebhookBody struct {
Event string `json:"event"`
DeliveryID string `json:"delivery_id"`
WebhookID string `json:"webhook_id"`
Timestamp string `json:"timestamp"`
Version int `json:"version"`
Test bool `json:"test"`
ProfileID string `json:"profile_id"`
LibraryID *int `json:"library_id,omitempty"`
Type string `json:"type"`
Reasons ReasonFlags `json:"reason_flags"`
Series *genericWebhookSeries `json:"series,omitempty"`
Episode *genericWebhookEpisode `json:"episode,omitempty"`
// Request is present for request.* deliveries. For request.fulfilled the
// catalog item is in Series (movies included — the field carries the
// matched item); approved/declined have no catalog item yet, so the
// request's own title rides here.
Request *genericWebhookRequest `json:"request,omitempty"`
}
type genericWebhookSeries struct {
ID string `json:"id"`
Title string `json:"title"`
}
type genericWebhookEpisode struct {
ID string `json:"id"`
Title string `json:"title"`
SeasonNumber *int `json:"season_number,omitempty"`
EpisodeNumber *int `json:"episode_number,omitempty"`
}
type genericWebhookRequest struct {
ID string `json:"id"`
TMDBID int `json:"tmdb_id,omitempty"`
MediaType string `json:"media_type,omitempty"`
Title string `json:"title,omitempty"`
Year int `json:"year,omitempty"`
Reason string `json:"reason,omitempty"`
}
// BuildGenericWebhookPayload renders a delivery as canonical Silo JSON. Pure
// function.
func BuildGenericWebhookPayload(row DeliveryRow, webhookID string, test bool) ([]byte, error) {
createdAt := row.CreatedAt
if createdAt.IsZero() {
createdAt = time.Now()
}
body := genericWebhookBody{
Event: EventNotificationCreated,
DeliveryID: row.ID,
WebhookID: webhookID,
Timestamp: createdAt.UTC().Format(time.RFC3339),
Version: 1,
Test: test,
ProfileID: row.ProfileID,
LibraryID: row.LibraryID,
Type: row.Type,
Reasons: parseReasonFlags(row.ReasonFlags),
}
if row.SeriesID != nil {
body.Series = &genericWebhookSeries{ID: *row.SeriesID, Title: row.SeriesTitle}
}
if row.EpisodeID != nil {
body.Episode = &genericWebhookEpisode{
ID: *row.EpisodeID,
Title: row.EpisodeTitle,
SeasonNumber: row.SeasonNumber,
EpisodeNumber: row.EpisodeNumber,
}
}
switch row.Type {
case DeliveryTypeRequestFulfilled, DeliveryTypeRequestApproved, DeliveryTypeRequestDeclined:
flags := parseRequestFlags(row.ReasonFlags)
body.Request = &genericWebhookRequest{
ID: flags.RequestID,
TMDBID: flags.TMDBID,
MediaType: flags.MediaType,
Title: flags.Title,
Year: flags.Year,
Reason: flags.Reason,
}
}
return json.Marshal(body)
}
// SignGenericWebhook computes the X-Silo-Signature header value for a body:
// "t=<epoch>,v1=<hex(hmac_sha256(secret, "<epoch>.<body>"))>", following
// Stripe's signing convention so receivers can reuse existing verifiers.
func SignGenericWebhook(secret string, timestamp int64, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(strconv.AppendInt(nil, timestamp, 10))
mac.Write([]byte{'.'})
mac.Write(body)
return fmt.Sprintf("t=%d,v1=%s", timestamp, hex.EncodeToString(mac.Sum(nil)))
}
// genericWebhookHeaders builds the delivery headers for a generic webhook
// POST. The timestamp participating in the HMAC is the Unix-epoch header
// value, not the body's RFC3339 timestamp.
func genericWebhookHeaders(webhookID, deliveryID, secret string, now time.Time, body []byte) map[string]string {
timestamp := now.Unix()
return map[string]string{
"X-Silo-Event": EventNotificationCreated,
"X-Silo-Webhook-Id": webhookID,
"X-Silo-Delivery-Id": deliveryID,
"X-Silo-Timestamp": fmt.Sprintf("%d", timestamp),
"X-Silo-Signature": SignGenericWebhook(secret, timestamp, body),
}
}