From 8001460f14c20a328ffcc19d1da8acd6808bf61a Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sat, 9 May 2026 14:27:58 +0200 Subject: [PATCH] =?UTF-8?q?Phase=202a=20=E2=80=94=20add=20'http'=20handler?= =?UTF-8?q?=20(sync=20forwarder)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- config.go | 19 ++++++-- handler_http.go | 115 ++++++++++++++++++++++++++++++++++++++++++++++++ handlers.go | 9 ++++ 3 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 handler_http.go diff --git a/config.go b/config.go index 7274579..020c9d1 100644 --- a/config.go +++ b/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "time" "gopkg.in/yaml.v3" ) @@ -18,9 +19,21 @@ type BotConfig struct { // (nil) the registry treats it as true — secure by default. Explicit // `requireAuth: false` is required to expose a bot publicly. // Forced to false for handler=auth (chicken-and-egg). - RequireAuth *bool `yaml:"requireAuth,omitempty"` - Token string `yaml:"-"` - Secret string `yaml:"-"` + 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 diff --git a/handler_http.go b/handler_http.go new file mode 100644 index 0000000..035d96a --- /dev/null +++ b/handler_http.go @@ -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": ""}. 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] + "…" +} diff --git a/handlers.go b/handlers.go index 3cf1aca..974f342 100644 --- a/handlers.go +++ b/handlers.go @@ -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: