Files
Maven/internal/delivery/telegramsink/telegramsink.go
T
2026-07-03 00:32:48 +02:00

199 lines
7.7 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
// 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 15;
// 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<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"
}