e9ff2c4912
The away sinks fell back to the whole Body when Summary was empty. ntfy and telegram leave the box, and the 0.8B phraser drops fields regularly, so that fallback could push full detail off the machine. The dispatcher already strips detail from away sendables. This exports that one rule as delivery.AwayMessage and has both sinks use it, so a sink can't leak the body on its own either: empty Summary means a generic line plus the rule name, never the body. The two sink tests named TestSendFallsBackToBodyWhenSummaryEmpty asserted the old, wrong behaviour, so they are rewritten to assert the generic line. TestSendRejectsEmptyMessage is likewise replaced: an away message can no longer be empty, so the sink has nothing left to reject. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
196 lines
7.7 KiB
Go
196 lines
7.7 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"
|
||
"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 `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
|
||
}
|
||
|
||
// 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
|
||
// 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", 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<token>/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"
|
||
}
|