Files
Maven/internal/delivery/telegramsink/telegramsink.go
T
claude 35c6ff5a71 Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
2026-08-13 02:50:59 +04:00

304 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package telegramsink implements delivery.Sink for the telegram push channel.
//
// telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm
// at 2am routes to telegram, repeat til ack." the message body is the
// delivery.AwayMessage — the minimal-body rule from the spec ("disk low on
// homesrv," not detail; no shoulder-surf exfil through the relay). the
// dispatcher already strips detail off away sendables; the sink uses the same
// helper so it can't leak the body on its own either. additionally,
// protect_content=true is passed on every send so the message can't be
// forwarded out of the chat — locks the minimal body further.
//
// telegram's bot API is region-restricted for this homesrv — direct egress to
// api.telegram.org is unreliable. the spec's "away channels leave the box —
// through your relay" maps here to a Proxy config field (HTTP/HTTPS/SOCKS5).
// stdlib net/http Transport.Proxy supports all three; the daemon wires the
// relay URL from config. the bot token is a delivery-config secret (not a db
// key — a popped telegram sink can push spam to your chat, nothing else;
// matches the module key-isolation invariant: the sink never holds the
// sqlcipher key).
//
// repeat-til-ack is driven by the dispatcher + AckTracker (delivery/ack.go +
// dispatcher.RepeatUnacked), NOT by the sink. the sink is fire-and-forget per
// call — telegram has no priority/insistence field analogous to ntfy's 15;
// the repeat mechanism IS the insistence, re-sending on the dispatcher's tick
// until MarkAcked.
package telegramsink
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/kami/maven/internal/delivery"
)
// DefaultBaseURL — telegram bot API. overridable in Config (e.g. for a
// self-hosted bridge that mirrors the API shape and handles the region cut
// itself); the proxy is the normal path, the BaseURL override is the fallback.
const DefaultBaseURL = "https://api.telegram.org"
const DefaultTimeout = 10 * time.Second
// Config — telegram bot API publish config. the daemon wires this from its
// config file; the bot token + chat id live in the daemon's config (or a
// systemd credential), never in the binary.
type Config struct {
// Disabled keeps a written Telegram block explicitly dark. A present block
// is otherwise live, so an expanded-empty credential is a configuration
// error rather than an implicit opt-out.
Disabled bool `json:"disabled,omitempty"`
// BotToken — the telegram bot token from BotFather. required. sent in the
// URL path (the only place telegram accepts it), not in the body.
BotToken string `json:"bot_token"`
// ChatID — destination chat. may be a numeric user/group id (sent as a
// JSON number) or @channelusername (sent as a JSON string). required.
ChatID string `json:"chat_id"`
// BaseURL — telegram API base. empty = DefaultBaseURL. override to point
// at a self-hosted API bridge if the proxy path isn't used.
BaseURL string `json:"base_url,omitempty"`
// Proxy — URL of an HTTP/HTTPS/SOCKS5 relay used to reach the telegram
// API (region-restricted direct egress). empty = direct (won't work from
// the homesrv without a relay; kept configurable for tests + future
// topology change).
Proxy string `json:"proxy,omitempty"`
// Timeout — per-request; 0 = DefaultTimeout. a dead relay can't hang the
// tick loop.
Timeout time.Duration
// Intake — read the chat as well as write to it (V-637). Off by default,
// like the search and weather blocks: a bot that only pushes cannot be
// talked into anything, and turning that off has to stay a deletion. When
// set, a message from ChatID becomes a turn and its reply carries the
// correction gesture. ChatID is the only accepted sender.
Intake bool `json:"intake,omitempty"`
}
// Sink — implements delivery.Sink via the telegram bot sendMessage API. one
// POST per Send; no retry (the dispatcher + daemon decide retry policy). the
// repeat-til-ack clock is driven by the dispatcher calling Send again each
// interval — the sink itself is stateless.
type Sink struct {
cfg Config
hc *http.Client
base string // resolved BaseURL, no trailing slash
}
// Validate checks one configuration without constructing a client. A disabled
// block is valid and intentionally carries no credentials; every live block
// must carry both secrets and valid endpoint URLs.
func Validate(cfg Config) error {
if cfg.Disabled {
return nil
}
if strings.TrimSpace(cfg.BotToken) == "" {
return fmt.Errorf("telegramsink: BotToken is required while enabled")
}
if strings.TrimSpace(cfg.ChatID) == "" {
return fmt.Errorf("telegramsink: ChatID is required while enabled")
}
base := cfg.BaseURL
if base == "" {
base = DefaultBaseURL
}
if _, err := url.Parse(base); err != nil {
return fmt.Errorf("telegramsink: bad BaseURL: %w", err)
}
if cfg.Proxy != "" {
if _, err := url.Parse(cfg.Proxy); err != nil {
return fmt.Errorf("telegramsink: bad Proxy: %w", err)
}
}
return nil
}
// New validates the config and builds the sink. Disabled configs belong at the
// wiring boundary and cannot accidentally become live sinks.
func New(cfg Config) (*Sink, error) {
if cfg.Disabled {
return nil, fmt.Errorf("telegramsink: config is disabled")
}
if err := Validate(cfg); err != nil {
return nil, err
}
base := cfg.BaseURL
if base == "" {
base = DefaultBaseURL
}
to := cfg.Timeout
if to == 0 {
to = DefaultTimeout
}
transport := http.DefaultTransport.(*http.Transport).Clone()
if cfg.Proxy != "" {
pu, _ := url.Parse(cfg.Proxy) // parsed+checked above
transport.Proxy = http.ProxyURL(pu)
}
return &Sink{
cfg: cfg,
base: strings.TrimRight(base, "/"),
hc: &http.Client{Timeout: to, Transport: transport},
}, nil
}
// sendMessageReq — the subset of sendMessage params maven uses. JSON-encoded
// as the request body. chat_id accepts number or string; go json tags keep
// both shapes (channel usernames are strings, user ids are numbers — send
// whatever ChatID was configured as).
type sendMessageReq struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
DisableNotification bool `json:"disable_notification"` // false = ring (always — these are alarms)
ProtectContent bool `json:"protect_content"` // true = no forwarding out of chat
// ReplyMarkup — the inline keyboard, used only by the intake half (V-637):
// a reply to a turn he typed carries the correction gesture. nil on every
// push the sink sends, and omitted from the wire when nil.
ReplyMarkup *inlineKeyboard `json:"reply_markup,omitempty"`
}
// telegramResp — the shape telegram returns. ok=false on logical error with
// error_code + description; ok=true with result on success (result contents
// not needed by the sink).
type telegramResp struct {
Ok bool `json:"ok"`
ErrorCode int `json:"error_code,omitempty"`
Description string `json:"description,omitempty"`
}
// Send publishes one message to the configured telegram chat. the body is the
// minimal away message (never the full body). protect_content=true so even
// that can't be forwarded onward by the user or a chat observer — locks the
// minimal-body rule at the channel's own last mile.
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
// never fall back to d.Body: telegram leaves the box, so an empty summary
// gets a generic line instead of the full detail.
body := delivery.AwayMessage(d)
payload := sendMessageReq{
ChatID: s.cfg.ChatID,
Text: body,
DisableNotification: false, // maven sends to telegram only on sev4/sev3/reminder — all want to ring
ProtectContent: true, // no forwarding out — locks minimal body at the channel last mile
}
pb, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("telegramsink: marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.sendMessageURL(), bytes.NewReader(pb))
if err != nil {
return fmt.Errorf("telegramsink: build request: %w", s.redact(err))
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.hc.Do(req)
if err != nil {
return fmt.Errorf("telegramsink: sendMessage: %w", s.redact(err))
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, maxRespBytes))
// telegram returns 200 with ok=true on success; non-2xx with ok=false +
// error_code + description on failure. parse the body either way so a 200
// with ok=false (shouldn't happen, but the API reserves that) still surfaces.
var tr telegramResp
jsonErr := json.Unmarshal(rb, &tr)
if jsonErr == nil && !tr.Ok {
err := fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
if tr.ErrorCode == http.StatusUnauthorized || tr.ErrorCode == http.StatusForbidden {
return fmt.Errorf("%w: %v", delivery.ErrPermanent, err)
}
return err
}
if resp.StatusCode/100 != 2 {
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, snippet(rb))
}
// A 2xx whose body is not the bot API's envelope did not come from the bot
// API. The normal path here is the relay: this box reaches telegram through
// an HTTP/SOCKS5 proxy, and a proxy that is up but cannot reach
// api.telegram.org answers 200 with an HTML page of its own. Reading that as
// a delivered message is the worst outcome the sink has — the dispatcher
// writes a 'sent' outbox row, MarkSent restarts the repeat clock, and the
// sev4 alarm that never arrived goes quiet for a whole interval. Only
// ok=true is a send.
if jsonErr != nil {
return fmt.Errorf("telegramsink: telegram returned %d with a body that is not the bot API envelope (not a confirmed send): %s", resp.StatusCode, snippet(rb))
}
return nil
}
// maxRespBytes caps the response read — the body is wire-controlled and the
// relay in front of it is not telegram. It is far above any sendMessage
// envelope (a few hundred bytes; the result echoes one short away message),
// because a truncated body no longer parses and now reads as a failed send.
const maxRespBytes = 64 << 10
// snippet trims a response body down to something an error line can carry. A
// relay's HTML page is measured in kilobytes and none of it belongs in the log.
func snippet(rb []byte) string {
s := strings.TrimSpace(string(rb))
if len(s) > 200 {
return s[:200] + "…"
}
return s
}
// sendMessageURL — the bot API path. the token is in the URL path
// (https://api.telegram.org/bot<token>/sendMessage); telegram does not accept
// it anywhere else. the URL is built per-send from the resolved base and never
// stored, but it does end up inside transport errors — see redact.
func (s *Sink) sendMessageURL() string {
return s.base + "/bot" + s.cfg.BotToken + "/sendMessage"
}
// tokenPlaceholder — what a redacted token reads as in an error. Recognisable
// on sight, so nobody reads a redacted URL as a malformed one.
const tokenPlaceholder = "<redacted>"
// redact strips the bot token out of a transport error before it becomes a
// returned error, and from there a log line.
//
// This is not hypothetical. net/http wraps every transport failure in
// *url.Error, whose Error() prints the full request URL, and the token is IN
// that URL because telegram accepts it nowhere else. On 2026-08-01 homesrv
// could not reach api.telegram.org, so the retry wrote the whole bot token
// into the daemon log once a minute for as long as the network stayed down.
// The token lives in deploy/telegram.env specifically to stay out of the repo;
// putting it in `docker compose logs` undoes that.
//
// The structural case rewrites url.Error.URL and keeps the error's type, so
// callers matching on *url.Error still work. Anything else falls back to
// scrubbing the rendered message, which loses the type but cannot leak.
//
// There is deliberately no minimum-length guard. A one-character token would
// make this replace every occurrence of that character in the message, which
// is ugly; leaking a short token is worse. New already refuses an empty one.
func (s *Sink) redact(err error) error {
if err == nil {
return err
}
var ue *url.Error
if errors.As(err, &ue) {
clean := *ue
clean.URL = strings.ReplaceAll(clean.URL, s.cfg.BotToken, tokenPlaceholder)
err = &clean
}
if msg := strings.ReplaceAll(err.Error(), s.cfg.BotToken, tokenPlaceholder); msg != err.Error() {
return errors.New(msg)
}
return err
}