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