344 lines
13 KiB
Go
344 lines
13 KiB
Go
// Package notify is the one door every outbound notification leaves Memby through.
|
|
//
|
|
// Before it, each feature both decided to notify somebody and performed the delivery
|
|
// itself: the Sonarr lifecycle scanner wrote a row into user_notifications, the library
|
|
// ingester pushed a banner into Redis, the integrations dispatcher posted to Discord. Each
|
|
// knew how to deliver and none knew that the others existed, so the only way to answer
|
|
// "what did Memby send, to whom, and did it work" was to read three subsystems' log lines
|
|
// and hope every one of them had logged.
|
|
//
|
|
// The flow is now
|
|
//
|
|
// feature/event → notify.Service → Deliverer → notification log
|
|
//
|
|
// and the audit trail is a property of the door rather than something each feature
|
|
// remembers to do. A feature says *what* it wants said and to whom; which provider carries
|
|
// it, and the record of what happened, belong here.
|
|
//
|
|
// Two rules hold the package up:
|
|
//
|
|
// - **Logging never blocks delivery.** The record is written after the provider has
|
|
// already answered, on a context detached from the caller's, and a write that fails is
|
|
// logged and swallowed. A notification history that could suppress a notification would
|
|
// be worse than no history.
|
|
// - **Nothing secret is ever recorded.** A webhook's address is its credential, and an
|
|
// Emby token is a live upstream session; neither has any business in a table the
|
|
// console renders. Notification carries a Target — a destination's *name* — never its
|
|
// address, and Redact is the belt-and-braces pass before anything is stored.
|
|
package notify
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Channel is how a notification reaches somebody. It is stored, so it is part of the
|
|
// console's vocabulary: adding one means adding a Deliverer and nothing else.
|
|
type Channel string
|
|
|
|
const (
|
|
// ChannelInApp is a stored notification in one viewer's own list — My Alerts on the
|
|
// television. It follows the person to whichever set they sign into.
|
|
ChannelInApp Channel = "in-app"
|
|
// ChannelBroadcast is a service alert: the bar every signed-in television draws off
|
|
// the /v1/status poll. It has no recipient, because the recipient is the household.
|
|
ChannelBroadcast Channel = "broadcast"
|
|
// ChannelWebhook is an outbound HTTP delivery to somebody else's service — Discord
|
|
// today, and whatever the integrations package learns to speak next.
|
|
ChannelWebhook Channel = "webhook"
|
|
)
|
|
|
|
// Status is what became of one notification.
|
|
//
|
|
// Sent and Delivered are deliberately different answers. Most of Memby's channels are
|
|
// stores rather than transports — writing a row into somebody's list is done the moment it
|
|
// returns, and there is nobody to acknowledge it — so those report Sent. Delivered is
|
|
// reserved for a provider that actually confirmed receipt, which today means a webhook
|
|
// that answered 2xx. Collapsing the two would make the console claim an acknowledgement
|
|
// that nothing ever gave.
|
|
type Status string
|
|
|
|
const (
|
|
StatusSent Status = "sent"
|
|
StatusDelivered Status = "delivered"
|
|
StatusFailed Status = "failed"
|
|
StatusPending Status = "pending"
|
|
// StatusSkipped is a notification that was deliberately not delivered, and it is the
|
|
// most useful row on the page: a viewer's preferences declined it, a duplicate was
|
|
// suppressed by its source key, or an operator has the window switched off. Without
|
|
// it, "Memby never told me" and "Memby decided not to tell you" are the same silence.
|
|
StatusSkipped Status = "skipped"
|
|
)
|
|
|
|
// Notification is what a feature asks for. It describes the news, never the transport.
|
|
type Notification struct {
|
|
// Channel selects the provider.
|
|
Channel Channel
|
|
// Kind is the notification type: "show-return", "watch-time-week", "sonarr-import".
|
|
// It is the client's vocabulary too, so it is passed through rather than translated.
|
|
Kind string
|
|
// Source names the service that decided to send this — "sonarr-lifecycle",
|
|
// "watch-time-digest", "library-ingest". It answers "why did this arrive", which the
|
|
// kind alone often cannot: two features can legitimately produce the same kind.
|
|
Source string
|
|
// UserID and Username identify the recipient. Both empty is a household broadcast,
|
|
// which is a real answer rather than a missing one.
|
|
UserID string
|
|
Username string
|
|
Title string
|
|
Body string
|
|
// ItemID links the notification to a title, where there is one.
|
|
ItemID string
|
|
// Target names a destination that is not a person — an integration's name, for a
|
|
// webhook. Never its address: see the package comment.
|
|
Target string
|
|
// SourceKey is the caller's idempotency key where it has one. It is what lets the
|
|
// console explain a skipped row as "already sent" rather than as an unexplained gap.
|
|
SourceKey string
|
|
// EventAt is when the news happened, where that differs from when it was sent — an
|
|
// episode's broadcast time, a digest's period end.
|
|
EventAt *time.Time
|
|
// Metadata is free-form context for the detail view. Keep it small and keep it
|
|
// non-secret; Redact drops anything whose key looks like a credential.
|
|
Metadata map[string]any
|
|
}
|
|
|
|
// Outcome is what the provider reported.
|
|
type Outcome struct {
|
|
Status Status
|
|
// Detail is the failure or the reason, and it is the whole value of the detail view:
|
|
// "429 Too Many Requests", "the viewer has summaries switched off", "already sent".
|
|
Detail string
|
|
// Err is the delivery error where there was one, returned to the caller so a feature
|
|
// that wants to react to a failure still can. It is never itself the audit trail.
|
|
Err error
|
|
}
|
|
|
|
// Sent is the ordinary success for a store-shaped channel.
|
|
func Sent() Outcome { return Outcome{Status: StatusSent} }
|
|
|
|
// Delivered is for a provider that confirmed receipt.
|
|
func Delivered(detail string) Outcome { return Outcome{Status: StatusDelivered, Detail: detail} }
|
|
|
|
// Failed records a delivery that was attempted and did not work.
|
|
func Failed(err error) Outcome {
|
|
if err == nil {
|
|
return Outcome{Status: StatusFailed, Detail: "delivery failed"}
|
|
}
|
|
return Outcome{Status: StatusFailed, Detail: err.Error(), Err: err}
|
|
}
|
|
|
|
// Skipped records a notification deliberately not delivered, with the reason.
|
|
func Skipped(reason string) Outcome { return Outcome{Status: StatusSkipped, Detail: reason} }
|
|
|
|
// Deliverer is one channel's provider. A new channel is a type implementing this and a
|
|
// Register call; nothing else in the package has a case per channel.
|
|
type Deliverer interface {
|
|
Channel() Channel
|
|
Deliver(ctx context.Context, n Notification) Outcome
|
|
}
|
|
|
|
// DelivererFunc adapts a plain function, which is what every provider in the gateway is:
|
|
// a small closure over an existing subsystem.
|
|
type DelivererFunc struct {
|
|
Name Channel
|
|
Fn func(ctx context.Context, n Notification) Outcome
|
|
}
|
|
|
|
func (d DelivererFunc) Channel() Channel { return d.Name }
|
|
|
|
func (d DelivererFunc) Deliver(ctx context.Context, n Notification) Outcome {
|
|
return d.Fn(ctx, n)
|
|
}
|
|
|
|
// Record is one row of the audit trail — the notification as it was asked for, plus what
|
|
// happened to it.
|
|
type Record struct {
|
|
OccurredAt time.Time
|
|
Channel Channel
|
|
Kind string
|
|
Source string
|
|
UserID string
|
|
Username string
|
|
Title string
|
|
Body string
|
|
ItemID string
|
|
Target string
|
|
SourceKey string
|
|
Status Status
|
|
Detail string
|
|
DurationMS int64
|
|
EventAt *time.Time
|
|
Metadata json.RawMessage
|
|
}
|
|
|
|
// Recorder is the audit trail's storage. An interface rather than *store.Store so the
|
|
// package can be tested without a database, and so a Service built with no recorder — every
|
|
// unit test of a feature that notifies — still delivers.
|
|
type Recorder interface {
|
|
RecordNotification(ctx context.Context, record Record) error
|
|
}
|
|
|
|
// recordTimeout bounds the audit write. It is short on purpose: the notification has
|
|
// already been delivered by the time this runs, so a slow database must cost the history
|
|
// rather than hold up the feature that produced the news.
|
|
const recordTimeout = 5 * time.Second
|
|
|
|
// Service is the door. One instance, created at start-up.
|
|
type Service struct {
|
|
recorder Recorder
|
|
log *slog.Logger
|
|
deliverers map[Channel]Deliverer
|
|
now func() time.Time
|
|
}
|
|
|
|
func New(recorder Recorder, log *slog.Logger) *Service {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return &Service{
|
|
recorder: recorder,
|
|
log: log.With("component", "notify"),
|
|
deliverers: map[Channel]Deliverer{},
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
// Register installs a provider. Called at start-up only; the map is not guarded because
|
|
// nothing registers after the first request is served.
|
|
func (s *Service) Register(deliverers ...Deliverer) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
for _, deliverer := range deliverers {
|
|
if deliverer != nil {
|
|
s.deliverers[deliverer.Channel()] = deliverer
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send delivers a notification and records what happened.
|
|
//
|
|
// The order is the design: deliver, then record. A history written first would be a claim
|
|
// rather than a record, and one written inside the delivery path would be able to fail the
|
|
// delivery. A Service that is nil, or has no provider for the channel, still answers — a
|
|
// feature must never have to nil-check the notification layer.
|
|
func (s *Service) Send(ctx context.Context, n Notification) Outcome {
|
|
if s == nil {
|
|
return Skipped("notifications are not configured")
|
|
}
|
|
n = Redact(n)
|
|
started := s.now()
|
|
deliverer, ok := s.deliverers[n.Channel]
|
|
var outcome Outcome
|
|
if !ok {
|
|
// A missing provider is a configuration fault, not a delivery failure, and it is
|
|
// worth a row: a console showing every "show-return" as failed on a gateway with
|
|
// no in-app provider is what would send somebody looking in the right place.
|
|
outcome = Failed(errNoDeliverer{channel: n.Channel})
|
|
} else {
|
|
outcome = deliverer.Deliver(ctx, n)
|
|
}
|
|
s.record(ctx, n, outcome, s.now().Sub(started))
|
|
return outcome
|
|
}
|
|
|
|
// Log records a notification that some other code path delivered.
|
|
//
|
|
// It exists for the one producer that cannot reasonably be inverted: the integrations
|
|
// dispatcher is a subscriber on the admin event bus with its own queue, pacing and
|
|
// transport registry, and routing its deliveries back out through Send would make the
|
|
// audit trail the thing that decides what Discord receives. It posts, then says what
|
|
// happened. Prefer Send everywhere a feature is the one deciding to notify.
|
|
func (s *Service) Log(ctx context.Context, n Notification, outcome Outcome, took time.Duration) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.record(ctx, Redact(n), outcome, took)
|
|
}
|
|
|
|
func (s *Service) record(ctx context.Context, n Notification, outcome Outcome, took time.Duration) {
|
|
if s.recorder == nil {
|
|
return
|
|
}
|
|
record := Record{
|
|
OccurredAt: s.now().UTC(),
|
|
Channel: n.Channel,
|
|
Kind: n.Kind,
|
|
Source: n.Source,
|
|
UserID: n.UserID,
|
|
Username: n.Username,
|
|
Title: n.Title,
|
|
Body: n.Body,
|
|
ItemID: n.ItemID,
|
|
Target: n.Target,
|
|
SourceKey: n.SourceKey,
|
|
Status: outcome.Status,
|
|
Detail: outcome.Detail,
|
|
DurationMS: took.Milliseconds(),
|
|
EventAt: n.EventAt,
|
|
}
|
|
if len(n.Metadata) > 0 {
|
|
if raw, err := json.Marshal(n.Metadata); err == nil {
|
|
record.Metadata = raw
|
|
}
|
|
}
|
|
// Detached from the caller's context, for the reason the search recorder is: a
|
|
// television that navigated away, or a request that timed out, still sent this.
|
|
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), recordTimeout)
|
|
defer cancel()
|
|
if err := s.recorder.RecordNotification(writeCtx, record); err != nil {
|
|
s.log.Warn("notification not recorded",
|
|
"channel", n.Channel, "kind", n.Kind, "status", outcome.Status, "error", err)
|
|
}
|
|
}
|
|
|
|
type errNoDeliverer struct{ channel Channel }
|
|
|
|
func (e errNoDeliverer) Error() string {
|
|
return "no delivery provider for the " + string(e.channel) + " channel"
|
|
}
|
|
|
|
// secretish matches a metadata key whose value must never be stored.
|
|
//
|
|
// The rule is a substring match on the key rather than an inspection of the value,
|
|
// deliberately: a token is not recognisable by looking at it, and the one thing a caller
|
|
// reliably gets right is what they called the field.
|
|
var secretish = []string{
|
|
"token", "secret", "password", "apikey", "api_key", "credential",
|
|
"webhook", "url", "authorization",
|
|
}
|
|
|
|
// Redact is the last thing between a notification and the audit trail.
|
|
//
|
|
// Callers are already expected not to put a credential in a Notification — Target is a
|
|
// destination's name and never its address — and this is what makes that a property of the
|
|
// package rather than of every caller's diligence.
|
|
func Redact(n Notification) Notification {
|
|
if len(n.Metadata) == 0 {
|
|
return n
|
|
}
|
|
cleaned := make(map[string]any, len(n.Metadata))
|
|
for key, value := range n.Metadata {
|
|
if isSecretKey(key) {
|
|
continue
|
|
}
|
|
cleaned[key] = value
|
|
}
|
|
n.Metadata = cleaned
|
|
return n
|
|
}
|
|
|
|
func isSecretKey(key string) bool {
|
|
lowered := strings.ToLower(key)
|
|
for _, needle := range secretish {
|
|
if strings.Contains(lowered, needle) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|