// 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 // Sendable's Summary — the minimal-body rule from the spec ("disk low on // homesrv," not detail; no shoulder-surf exfil through the relay). voice gets // Body; away channels get Summary, enforced at the sink so a phraser bug can't // exfil. 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" "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 { // 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 // 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 // BaseURL — telegram API base. empty = DefaultBaseURL. override to point // at a self-hosted API bridge if the proxy path isn't used. BaseURL string // 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 // Timeout — per-request; 0 = DefaultTimeout. a dead relay can't hang the // tick loop. Timeout time.Duration } // 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 } // New validates the config and builds the sink. BotToken and ChatID are // required; Proxy and BaseURL are optional. func New(cfg Config) (*Sink, error) { if cfg.BotToken == "" { return nil, fmt.Errorf("telegramsink: BotToken is required") } if cfg.ChatID == "" { return nil, fmt.Errorf("telegramsink: ChatID is required") } base := cfg.BaseURL if base == "" { base = DefaultBaseURL } if _, err := url.Parse(base); err != nil { return nil, fmt.Errorf("telegramsink: bad BaseURL: %w", err) } if cfg.Proxy != "" { if _, err := url.Parse(cfg.Proxy); err != nil { return nil, fmt.Errorf("telegramsink: bad Proxy: %w", err) } } 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 } // 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 // Sendable's Summary (minimal body); empty Summary falls back to Body (terse // full message beats no message). protect_content=true so a phraser bug (Body // leaking detail through Summary) 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 { body := d.Summary if body == "" { body = d.Body } if body == "" { return fmt.Errorf("telegramsink: empty message for %s", d.Channel) } 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", err) } req.Header.Set("Content-Type", "application/json") resp, err := s.hc.Do(req) if err != nil { return fmt.Errorf("telegramsink: sendMessage: %w", err) } defer resp.Body.Close() rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) // 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 if jsonErr := json.Unmarshal(rb, &tr); jsonErr == nil && !tr.Ok { return fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description)) } if resp.StatusCode/100 != 2 { return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb))) } return nil } // 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 — the // token never leaves the sink, no logging. func (s *Sink) sendMessageURL() string { return s.base + "/bot" + s.cfg.BotToken + "/sendMessage" }