35c6ff5a71
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.
304 lines
12 KiB
Go
304 lines
12 KiB
Go
// 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<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
|
||
}
|