Phase 2a — add 'http' handler (sync forwarder)
All checks were successful
Docker Build / build-and-push-image (push) Successful in 53s
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:
19
config.go
19
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
|
||||
|
||||
115
handler_http.go
Normal file
115
handler_http.go
Normal 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] + "…"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user