199 lines
7.7 KiB
Go
199 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
|
||
// 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<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"
|
||
} |