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

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] + "…"
}