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:
13
config.go
13
config.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -19,10 +20,22 @@ type BotConfig struct {
|
|||||||
// `requireAuth: false` is required to expose a bot publicly.
|
// `requireAuth: false` is required to expose a bot publicly.
|
||||||
// Forced to false for handler=auth (chicken-and-egg).
|
// Forced to false for handler=auth (chicken-and-egg).
|
||||||
RequireAuth *bool `yaml:"requireAuth,omitempty"`
|
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:"-"`
|
Token string `yaml:"-"`
|
||||||
Secret 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
|
// 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
|
// from the process environment. Per-bot env keys are derived from the bot
|
||||||
// slug uppercased: BOT_<UPPER_SLUG>_TOKEN, BOT_<UPPER_SLUG>_SECRET.
|
// slug uppercased: BOT_<UPPER_SLUG>_TOKEN, BOT_<UPPER_SLUG>_SECRET.
|
||||||
|
|||||||
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)
|
return nil, fmt.Errorf("bot %s uses handler=auth but AUTH_SECRET is unset", slug)
|
||||||
}
|
}
|
||||||
h = &AuthHandler{tg: tg, auth: auth}
|
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 "":
|
case "":
|
||||||
return nil, fmt.Errorf("bot %s: handler missing", slug)
|
return nil, fmt.Errorf("bot %s: handler missing", slug)
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in New Issue
Block a user