Phase 2a — add 'http' handler (sync forwarder)
All checks were successful
Docker Build / build-and-push-image (push) Successful in 53s

The http handler POSTs the Telegram Update JSON to a configurable
internal URL and expects a JSON {text} reply, which it sends back via
sendMessage. Sync : the webhook ack waits for the upstream answer
(timeout default 5s, capped at 30s — Telegram itself closes around 60s).

For slow / unreliable backends use the Phase 3 async handlers once the
queue is in place.

YAML config :

  bots:
    webappbot:
      handler: http
      http:
        url: http://webapp.webapp.svc.cluster.local:8080/telegram/update
        timeout: 5s

Refs ~/.claude/plans/pour-les-notifications-on-inherited-seal.md § Phase 2.
This commit is contained in:
2026-05-09 14:27:58 +02:00
parent 515b407db4
commit 8001460f14
3 changed files with 140 additions and 3 deletions

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strings"
"time"
"gopkg.in/yaml.v3"
)
@@ -19,10 +20,22 @@ type BotConfig struct {
// `requireAuth: false` is required to expose a bot publicly.
// Forced to false for handler=auth (chicken-and-egg).
RequireAuth *bool `yaml:"requireAuth,omitempty"`
// HTTP is the per-bot config for the `http` handler. Required when
// handler=http. See handler_http.go.
HTTP *HTTPConfig `yaml:"http,omitempty"`
Token string `yaml:"-"`
Secret string `yaml:"-"`
}
// HTTPConfig drives the `http` handler : the gateway POSTs the Telegram
// Update JSON to URL, awaits a JSON `{text}` response within Timeout,
// then sends Text back to the user via sendMessage.
type HTTPConfig struct {
URL string `yaml:"url"`
Timeout time.Duration `yaml:"timeout,omitempty"`
}
// LoadConfig reads the YAML routing config and merges per-bot secrets pulled
// from the process environment. Per-bot env keys are derived from the bot
// slug uppercased: BOT_<UPPER_SLUG>_TOKEN, BOT_<UPPER_SLUG>_SECRET.

115
handler_http.go Normal file
View File

@@ -0,0 +1,115 @@
// Phase 2 — sync HTTP forwarder. Voir
// ~/.claude/plans/pour-les-notifications-on-inherited-seal.md § Phase 2.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// HTTPHandler forwards an incoming Telegram Update to a configured internal
// service URL (HTTP POST, JSON body = the Update verbatim) and expects a
// JSON response of the shape {"text": "<reply>"}. The reply is then sent
// to the user via Bot API sendMessage.
//
// Sync : the handler blocks the webhook ack until the upstream answers or
// the timeout fires. Keep `timeout` small (≤ 5 s) since Telegram itself
// may close the webhook around 60 s. For slow / unreliable backends use
// the Phase 3 async handlers (`shell`, `script`, `ollama`) once the queue
// is in place.
type HTTPHandler struct {
tg *TelegramClient
cli *http.Client
url string
timeout time.Duration
}
const (
defaultHTTPTimeout = 5 * time.Second
maxHTTPTimeout = 30 * time.Second
)
func NewHTTPHandler(tg *TelegramClient, cfg HTTPConfig) (*HTTPHandler, error) {
url := strings.TrimSpace(cfg.URL)
if url == "" {
return nil, fmt.Errorf("http handler: url is required")
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultHTTPTimeout
}
if timeout > maxHTTPTimeout {
// Cap to avoid Telegram-side webhook timeouts.
timeout = maxHTTPTimeout
}
return &HTTPHandler{
tg: tg,
cli: &http.Client{Timeout: timeout},
url: url,
timeout: timeout,
}, nil
}
type httpHandlerReply struct {
Text string `json:"text"`
}
func (h *HTTPHandler) Handle(ctx context.Context, update Update, bot Bot) error {
chatID, hasChat := update.ChatID()
if !hasChat {
// Nothing to reply to — still forward so the upstream sees the event.
}
body, err := json.Marshal(update)
if err != nil {
return fmt.Errorf("marshal update: %w", err)
}
reqCtx, cancel := context.WithTimeout(ctx, h.timeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, h.url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Bot-Slug", bot.Slug)
resp, err := h.cli.Do(req)
if err != nil {
return fmt.Errorf("upstream call: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("upstream non-2xx (%d): %s", resp.StatusCode, truncate(string(respBody), 200))
}
// Empty body is acceptable : the upstream chose not to reply.
if len(bytes.TrimSpace(respBody)) == 0 {
return nil
}
var reply httpHandlerReply
if err := json.Unmarshal(respBody, &reply); err != nil {
return fmt.Errorf("decode upstream reply: %w (body=%q)", err, truncate(string(respBody), 200))
}
if reply.Text == "" || !hasChat {
return nil
}
return h.tg.SendMessage(ctx, bot.Token, SendMessageParams{ChatID: chatID, Text: reply.Text})
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}

View File

@@ -73,6 +73,15 @@ func NewRegistry(cfg *Config, tg *TelegramClient, auth *Auth) (*Registry, error)
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: