Files
telegram-gateway/handlers.go
Gabriel Radureau 799e10dcc2
Some checks failed
Docker Build / build-and-push-image (push) Has been cancelled
Phase 2b — durable Postgres queue + worker (gated on DATABASE_URL)
Adds the async dispatch infrastructure :

- Postgres pool + embedded migration (CREATE TABLE/INDEX IF NOT EXISTS
  gateway_jobs). Auto-applied at boot. lib/pq driver (matches webapp
  convention).
- queue.go : Enqueue (idempotent on UNIQUE(bot_slug, update_id) — handles
  Telegram redelivery), Pop with FOR UPDATE SKIP LOCKED, MarkDone,
  MarkFailed with exponential backoff (30s → 2m → 10m → 1h → dead at 5).
- worker.go : goroutine that drains the queue, dispatches via the same
  Handler interface as sync, schedules retries on failure, notifies the
  user once when a job goes to dead.
- BotConfig gains `async: bool`. Registry refuses bots with async=true
  if DATABASE_URL is unset (queue=nil).
- Server : when bot.Async, the webhook ack is immediate ; the update
  payload is enqueued for the worker.

When DATABASE_URL is unset (current default), queue/worker stay disabled
and only sync handlers (echo, http, auth) work — no breaking change to
the running cluster.

Refs ~/.claude/plans/pour-les-notifications-on-inherited-seal.md § Phase 2.
2026-05-09 14:38:41 +02:00

130 lines
3.5 KiB
Go

package main
import (
"context"
"fmt"
"log"
)
type Handler interface {
Handle(ctx context.Context, update Update, bot Bot) error
}
type Bot struct {
Slug string
Token string
Secret string
HandlerLabel string // raw 'handler:' value (echo|auth|http|…) — used by queue rows for diagnostics
RequireAuth bool
Async bool
Handler Handler
}
// HandlerType returns the textual handler label for queue rows / logs.
func (b Bot) HandlerType() string { return b.HandlerLabel }
type Registry struct {
bots map[string]Bot
}
// NewRegistry builds the bot routing map from the parsed YAML config + the
// per-bot secrets (already merged into BotConfig from env). It receives the
// shared TelegramClient, Auth and Queue so per-handler instances can use
// them. `auth` may be nil (no AUTH_SECRET set) ; in that case any bot
// configured with `handler: auth` or `requireAuth: true` is a fatal config
// error. `queue` may be nil (no DATABASE_URL set) ; in that case any bot
// with `async: true` is a fatal config error.
func NewRegistry(cfg *Config, tg *TelegramClient, auth *Auth, queue Queue) (*Registry, error) {
if len(cfg.Bots) == 0 {
return nil, fmt.Errorf("no bots configured")
}
bots := make(map[string]Bot, len(cfg.Bots))
for slug, b := range cfg.Bots {
token := b.Token
secret := b.Secret
if token == "" {
return nil, fmt.Errorf("bot %s: token missing", slug)
}
if secret == "" {
return nil, fmt.Errorf("bot %s: secret missing", slug)
}
// Default requireAuth = true (secure by default). Explicit
// `requireAuth: false` is required to expose a bot publicly.
requireAuth := true
if b.RequireAuth != nil {
requireAuth = *b.RequireAuth
}
// Chicken-and-egg : the auth handler itself can't be gated, otherwise
// no one could ever authenticate. Force off and warn loudly.
if b.Handler == "auth" {
if requireAuth {
log.Printf("bot %s: handler=auth implies requireAuth=false (otherwise unreachable) — overriding", slug)
}
requireAuth = false
}
if requireAuth && auth == nil {
return nil, fmt.Errorf("bot %s has requireAuth: true (default) but AUTH_SECRET is unset; either set AUTH_SECRET or add `requireAuth: false` to the bot config", slug)
}
if b.Async && queue == nil {
return nil, fmt.Errorf("bot %s has async: true but DATABASE_URL is unset (no queue available)", slug)
}
var h Handler
switch b.Handler {
case "echo":
h = &EchoHandler{tg: tg}
case "auth":
if auth == nil {
return nil, fmt.Errorf("bot %s uses handler=auth but AUTH_SECRET is unset", slug)
}
h = &AuthHandler{tg: tg, auth: auth}
case "http":
if b.HTTP == nil {
return nil, fmt.Errorf("bot %s uses handler=http but no `http:` config block was provided", slug)
}
hh, err := NewHTTPHandler(tg, *b.HTTP)
if err != nil {
return nil, fmt.Errorf("bot %s: %w", slug, err)
}
h = hh
case "":
return nil, fmt.Errorf("bot %s: handler missing", slug)
default:
return nil, fmt.Errorf("bot %s: unknown handler %q", slug, b.Handler)
}
bots[slug] = Bot{
Slug: slug,
Token: token,
Secret: secret,
HandlerLabel: b.Handler,
RequireAuth: requireAuth,
Async: b.Async,
Handler: h,
}
}
return &Registry{bots: bots}, nil
}
func (r *Registry) Get(slug string) (Bot, bool) {
b, ok := r.bots[slug]
return b, ok
}
func (r *Registry) Count() int { return len(r.bots) }
func (r *Registry) Slugs() []string {
out := make([]string, 0, len(r.bots))
for s := range r.bots {
out = append(out, s)
}
return out
}