// 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 1–5; // 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/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 = "" // 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 }