Files
memby/server/internal/integrations/integrations.go
T

402 lines
13 KiB
Go
Raw Normal View History

2026-08-14 09:40:03 +12:00
// Package integrations delivers administrative events to external services.
//
// It is a *subscriber* on the event bus, not a call made from the code that produces the
// events. handleLogin knows nothing about Discord; it publishes a sign-in, and whether
// that becomes a message in a channel is decided here, from the operator's configuration,
// on a goroutine of its own. That is the whole design: adding a second destination is a
// new Transport in this package and nothing else in the gateway changes.
package integrations
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/store"
)
// queueDepth bounds the work waiting to be delivered.
//
// Deliberately lossy at the limit. An outbound webhook is a network call to somebody
// else's service, and the worst failure mode available here is one slow endpoint holding
// up the gateway — so a full queue drops the event and says so, rather than applying back
// pressure to a television signing in.
const queueDepth = 256
// settingsTTL is how long a configuration read is reused. Every event would otherwise be
// a Postgres read on the delivery path, and an operator's change reaching the dispatcher
// within a few seconds is soon enough for something whose own delivery is best-effort.
const settingsTTL = 15 * time.Second
// requestTimeout bounds one delivery.
const requestTimeout = 10 * time.Second
// Transport is one kind of destination. A new integration implements this and registers
// itself in transports below; nothing else in the package has a case per kind.
type Transport interface {
// Kind is the stored discriminator, e.g. "discord".
Kind() string
// Deliver posts the event and reports the HTTP status it got, where there was one.
Deliver(ctx context.Context, client *http.Client, integration store.Integration, event adminevents.Event) (int, error)
}
type job struct {
event adminevents.Event
// only is set by a test delivery: one destination, regardless of its event
// selection, because the operator has just pressed Test on that row.
only string
}
// Dispatcher is the bus sink. One instance, created at start-up and registered with
// [adminevents.Bus.AddSink].
type Dispatcher struct {
store *store.Store
log *slog.Logger
client *http.Client
events *adminevents.Bus
transports map[string]Transport
queue chan job
2026-08-17 07:34:23 +12:00
paused func() bool
2026-08-14 09:40:03 +12:00
mu sync.Mutex
cached store.IntegrationSettings
cachedAt time.Time
// dropped counts events the queue could not take. It is reported on the console's
// integrations page: a dispatcher quietly losing events is otherwise indistinguishable
// from a household in which nothing has happened.
dropped int64
}
2026-08-17 07:34:23 +12:00
// SetPaused installs the server-wide quiet-time gate before Start is called.
func (d *Dispatcher) SetPaused(paused func() bool) { d.paused = paused }
2026-08-14 09:40:03 +12:00
func New(st *store.Store, log *slog.Logger, events *adminevents.Bus) *Dispatcher {
dispatcher := &Dispatcher{
store: st, log: log.With("component", "integrations"), events: events,
client: &http.Client{Timeout: requestTimeout},
transports: map[string]Transport{},
queue: make(chan job, queueDepth),
}
dispatcher.register(discordTransport{})
return dispatcher
}
func (d *Dispatcher) register(transport Transport) {
d.transports[transport.Kind()] = transport
}
// Start begins the delivery worker. One worker, not a pool: household volume is a handful
// of events a minute, and serialising them means a destination cannot be hit with four
// concurrent posts by a burst.
func (d *Dispatcher) Start(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done():
return
case work := <-d.queue:
2026-08-17 07:34:23 +12:00
for d.paused != nil && d.paused() {
timer := time.NewTimer(30 * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
2026-08-14 09:40:03 +12:00
d.deliver(ctx, work)
}
}
}()
}
// DeliverAdminEvent implements adminevents.Sink. It only enqueues: the bus calls this
// while a sign-in is in progress, so nothing here may touch a network.
func (d *Dispatcher) DeliverAdminEvent(_ context.Context, event adminevents.Event) {
select {
case d.queue <- job{event: event}:
default:
d.mu.Lock()
d.dropped++
dropped := d.dropped
d.mu.Unlock()
d.log.Warn("integration event dropped", "type", event.Type, "dropped_total", dropped)
}
}
// Test delivers a synthetic event to one destination and reports what happened, so the
// operator gets the answer in the response to the button they pressed rather than having
// to go and read a delivery history.
//
// It is deliberately not queued: a test is a question, and an answer that arrives some
// time later on another page is not one.
func (d *Dispatcher) Test(ctx context.Context, integrationID string) error {
settings, err := d.settings(ctx, true)
if err != nil {
return err
}
for _, integration := range settings.Integrations {
if integration.ID != integrationID {
continue
}
if strings.TrimSpace(integration.URL) == "" {
return errors.New("this integration has no webhook address")
}
event := adminevents.Event{
OccurredAt: time.Now(),
Type: "integration.test",
Severity: adminevents.SeverityInfo,
Title: "Test message from Memby",
Summary: "If you can read this, " + integration.Name + " is configured correctly.",
Actor: "admin console",
Target: integration.Name,
}
return d.post(ctx, integration, event)
}
return errors.New("no such integration")
}
func (d *Dispatcher) deliver(ctx context.Context, work job) {
settings, err := d.settings(ctx, false)
if err != nil {
d.log.Warn("integration settings unavailable", "error", err)
return
}
for _, integration := range settings.Integrations {
if work.only != "" && integration.ID != work.only {
continue
}
if !integration.Enabled || !wants(integration, work.event.Type) {
continue
}
if err := d.post(ctx, integration, work.event); err != nil {
d.announceFailure(ctx, integration, work.event, err)
}
}
}
// wants is the event selection rule, kept apart from the loop because it is the one thing
// here worth being sure about: a destination sends what it was ticked for and nothing
// else. An empty selection sends nothing — see store.Integration.
func wants(integration store.Integration, eventType string) bool {
for _, selected := range integration.Events {
if selected == eventType {
return true
}
}
return false
}
func (d *Dispatcher) post(ctx context.Context, integration store.Integration, event adminevents.Event) error {
transport, ok := d.transports[integration.Kind]
if !ok {
return fmt.Errorf("no transport for %q", integration.Kind)
}
started := time.Now()
postCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), requestTimeout)
status, err := transport.Deliver(postCtx, d.client, integration, event)
cancel()
message := ""
if err != nil {
message = err.Error()
}
if d.store != nil {
record := store.IntegrationDelivery{
IntegrationID: integration.ID, EventType: event.Type,
Success: err == nil, StatusCode: status,
DurationMS: time.Since(started).Milliseconds(), Error: message,
}
if writeErr := d.store.RecordIntegrationDelivery(
context.WithoutCancel(ctx), record,
); writeErr != nil {
d.log.Warn("delivery not recorded", "integration", integration.ID, "error", writeErr)
}
}
return err
}
// announceFailure puts a failed delivery back into the feed the operator is reading.
//
// It publishes a *different* type from the event that failed, and integration.failed is
// the one type a destination cannot usefully be subscribed to — a webhook that is down
// cannot be told that it is down, and a second destination being told about the first is
// the case this exists to serve.
func (d *Dispatcher) announceFailure(
ctx context.Context, integration store.Integration,
event adminevents.Event, err error,
) {
d.log.Warn("integration delivery failed",
"integration", integration.ID, "name", integration.Name,
"event", event.Type, "error", err)
if event.Type == adminevents.TypeIntegrationFailed {
// A failure announcing a failure would loop for as long as the endpoint is down.
return
}
d.events.Publish(ctx, adminevents.Event{
Type: adminevents.TypeIntegrationFailed,
Severity: adminevents.SeverityWarning,
Title: integration.Name + " could not be reached",
Summary: err.Error(),
Actor: "integrations",
Target: integration.Name,
Link: "/admin/integrations",
Metadata: adminevents.Meta(map[string]any{
"integrationId": integration.ID, "eventType": event.Type,
}),
})
}
func (d *Dispatcher) settings(ctx context.Context, fresh bool) (store.IntegrationSettings, error) {
d.mu.Lock()
if !fresh && time.Since(d.cachedAt) < settingsTTL {
cached := d.cached
d.mu.Unlock()
return cached, nil
}
d.mu.Unlock()
if d.store == nil {
return store.IntegrationSettings{}, nil
}
settings, err := d.store.Integrations(ctx)
if err != nil {
return store.IntegrationSettings{}, err
}
d.mu.Lock()
d.cached, d.cachedAt = settings, time.Now()
d.mu.Unlock()
return settings, nil
}
// Invalidate drops the cached configuration, so an operator's save takes effect on the
// next event rather than up to settingsTTL later. Called from the admin handler that
// writes it.
func (d *Dispatcher) Invalidate() {
if d == nil {
return
}
d.mu.Lock()
d.cachedAt = time.Time{}
d.mu.Unlock()
}
// Dropped reports how many events the queue could not take.
func (d *Dispatcher) Dropped() int64 {
if d == nil {
return 0
}
d.mu.Lock()
defer d.mu.Unlock()
return d.dropped
}
// --- Discord ----------------------------------------------------------------------
type discordTransport struct{}
func (discordTransport) Kind() string { return store.IntegrationDiscord }
// discordEmbed is the subset of Discord's webhook payload this uses. An embed rather than
// plain content because the colour down its left edge is the one thing that makes a
// failure distinguishable from an ordinary sign-in at a glance in a busy channel.
type discordEmbed struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Color int `json:"color,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Fields []discordEmbedField `json:"fields,omitempty"`
Footer *discordFooter `json:"footer,omitempty"`
}
type discordEmbedField struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline"`
}
type discordFooter struct {
Text string `json:"text"`
}
type discordPayload struct {
Username string `json:"username,omitempty"`
Embeds []discordEmbed `json:"embeds"`
}
// severityColour matches the console's own tones: green is a verdict, amber is look at
// this, red is wrong. Same three colours in the channel as on the page, so an operator
// reading one is not learning a second vocabulary.
func severityColour(severity string) int {
switch severity {
case adminevents.SeverityError:
return 0xE5484D
case adminevents.SeverityWarning:
return 0xF5A524
default:
return 0x3FB950
}
}
func (discordTransport) Deliver(
ctx context.Context, client *http.Client,
integration store.Integration, event adminevents.Event,
) (int, error) {
embed := discordEmbed{
Title: strings.TrimSpace(event.Title),
Description: strings.TrimSpace(event.Summary),
Color: severityColour(event.Severity),
Timestamp: event.OccurredAt.UTC().Format(time.RFC3339),
Footer: &discordFooter{Text: "Memby · " + event.Type},
}
if embed.Title == "" {
embed.Title = event.Type
}
if event.Actor != "" {
embed.Fields = append(embed.Fields,
discordEmbedField{Name: "Actor", Value: event.Actor, Inline: true})
}
if event.Target != "" {
embed.Fields = append(embed.Fields,
discordEmbedField{Name: "Target", Value: event.Target, Inline: true})
}
body, err := json.Marshal(discordPayload{
Username: "Memby", Embeds: []discordEmbed{embed},
})
if err != nil {
return 0, err
}
request, err := http.NewRequestWithContext(
ctx, http.MethodPost, integration.URL, bytes.NewReader(body))
if err != nil {
return 0, err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
// Never include the request URL in the error: it is the webhook token, and this
// string is stored in the delivery history and shown in the console.
return 0, errors.New("could not reach the webhook")
}
defer response.Body.Close()
// Discord answers 204 with no body on success. Anything else is reported by status
// alone — its error bodies are not worth surfacing to an operator and a 401 body has
// been known to echo request details back.
if response.StatusCode < 200 || response.StatusCode >= 300 {
return response.StatusCode, fmt.Errorf("webhook returned %d", response.StatusCode)
}
return response.StatusCode, nil
}