Add notification channels and a provider abstraction
Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// Package notify is how Terdut reaches a person: a Provider per way of doing it
|
||||
// (ntfy today), and a Registry of the ones this server has been configured with.
|
||||
//
|
||||
// The package knows nothing about incidents. The notifier renders a Message from
|
||||
// an incident and hands it to whichever Provider the recipient's channel names,
|
||||
// so a new way of paging someone is one new Provider and nothing else.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Message kinds. Providers may use them to shape a message (ntfy sends a
|
||||
// "resolved" quietly), but a Provider that ignores them is correct.
|
||||
const (
|
||||
KindTriggered = "triggered"
|
||||
KindReminder = "reminder"
|
||||
KindEscalated = "escalated"
|
||||
KindResolved = "resolved"
|
||||
KindTest = "test"
|
||||
)
|
||||
|
||||
// Message is one notification, in no provider's format.
|
||||
type Message struct {
|
||||
// Address is where this provider delivers it: an ntfy topic, later an email
|
||||
// address. Opaque to everything except the Provider that owns it.
|
||||
Address string
|
||||
|
||||
Kind string
|
||||
Title string
|
||||
Body string
|
||||
|
||||
// Level is the incident's severity on a 0-4 scale: 0 unknown, 1 info,
|
||||
// 2 warning, 3 error, 4 critical. A provider with a notion of urgency maps it.
|
||||
Level int
|
||||
|
||||
// ClickURL opens the incident in the web UI. Empty when the server does not
|
||||
// know its own public URL.
|
||||
ClickURL string
|
||||
|
||||
// AckURL acknowledges the incident when POSTed. Only set for a provider whose
|
||||
// Capabilities say it can render an action; for the rest the notifier puts
|
||||
// the link in Body instead.
|
||||
AckURL string
|
||||
}
|
||||
|
||||
// Capabilities says what a Provider can render, so the notifier does not have to
|
||||
// know which provider it is talking to.
|
||||
type Capabilities struct {
|
||||
// ActionButtons is whether the provider can show a button that POSTs to a URL
|
||||
// without opening anything, which is what the Acknowledge button is.
|
||||
ActionButtons bool
|
||||
}
|
||||
|
||||
// Provider is one way of delivering a Message.
|
||||
type Provider interface {
|
||||
// Type is the stable identifier stored in a channel row and shown in the
|
||||
// API: "ntfy". Lower case, never changes once released.
|
||||
Type() string
|
||||
|
||||
// Label is the name a person sees: "ntfy".
|
||||
Label() string
|
||||
|
||||
// AddressLabel and AddressHint describe the address field for the UI: a
|
||||
// "Topic" labelled field with a sentence on what to put in it.
|
||||
AddressLabel() string
|
||||
AddressHint() string
|
||||
|
||||
Capabilities() Capabilities
|
||||
|
||||
// Validate checks an address a user typed and returns it normalised. An error
|
||||
// is shown to that user as is.
|
||||
Validate(address string) (string, error)
|
||||
|
||||
// Send delivers one message. It must honour ctx, and an error means the
|
||||
// message did not land, so the notifier will retry.
|
||||
Send(ctx context.Context, msg Message) error
|
||||
}
|
||||
|
||||
// Target names a provider and an address without belonging to a user: the
|
||||
// fallback for an incident nobody is on call for.
|
||||
type Target struct {
|
||||
Type string `json:"type"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// IsZero reports whether no target is set.
|
||||
func (t Target) IsZero() bool { return t.Type == "" && t.Address == "" }
|
||||
|
||||
// Registry is the providers a server was configured with. The zero value and
|
||||
// nil are both an empty registry, so a server with nothing configured needs no
|
||||
// special case at its call sites.
|
||||
type Registry struct {
|
||||
byType map[string]Provider
|
||||
}
|
||||
|
||||
// NewRegistry registers providers by Type. A later provider of the same type
|
||||
// replaces the earlier one.
|
||||
func NewRegistry(providers ...Provider) *Registry {
|
||||
r := &Registry{byType: make(map[string]Provider, len(providers))}
|
||||
for _, p := range providers {
|
||||
r.byType[p.Type()] = p
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Get returns the provider for a type.
|
||||
func (r *Registry) Get(typ string) (Provider, bool) {
|
||||
if r == nil {
|
||||
return nil, false
|
||||
}
|
||||
p, ok := r.byType[typ]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Has reports whether a provider of this type is configured.
|
||||
func (r *Registry) Has(typ string) bool {
|
||||
_, ok := r.Get(typ)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Empty reports whether no provider is configured, which disables notifications.
|
||||
func (r *Registry) Empty() bool { return r == nil || len(r.byType) == 0 }
|
||||
|
||||
// List returns the providers ordered by type, so the UI and the tests see them
|
||||
// in a stable order.
|
||||
func (r *Registry) List() []Provider {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Provider, 0, len(r.byType))
|
||||
for _, p := range r.byType {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Type() < out[j].Type() })
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user