Files
mesh-catalog/modules/messenger/cmd/messenger/telegram.go
T
jochen a4b92c1306 messenger: never say history as news — read the open conditions first, coalesce bursts
A consumer made on 2026-10-06 was handed three hours of raised-and-cleared
conditions at once and the holder said each as new: 20 desktop notifications
in a second. What is said is now decided by the controller's open set and by
an event's own time, never by its arrival; bursts are one message, the
desktop gets warnings at most every 15 min, the cap is said once, and the
first minute after start says only the urgent conditions still open.
Replays of that morning's 96 events are tests. Also drops two committed
binaries. (novox/hq issue 271)
2026-10-06 12:03:19 +02:00

207 lines
6.4 KiB
Go

package main
// The Telegram channel: a bot to the operator's chat (novox/hq to-be 45 §5, research 028/02). Sending
// needs only outbound HTTPS. The bot token is this module's own secret, accepted from the operator; the
// chat id is a setting. Neither is ever said: an error from the HTTP client carries the URL, and the
// URL carries the token, so every error is rebuilt here from its kind before it leaves this file.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
// TelegramAPI is where the bot API answers; a test points it elsewhere.
var TelegramAPI = "https://api.telegram.org"
var (
tokenShape = regexp.MustCompile(`^\d{5,}:[A-Za-z0-9_-]{30,}$`)
chatIDShape = regexp.MustCompile(`^(-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{4,})$`)
)
// TelegramConfig is where the token is and what the chat is; read each time it is used, so a secret
// accepted or a setting changed takes effect at the next message without a restart.
type TelegramConfig struct {
TokenFile string
ChatID func() string
}
// Telegram sends to one chat.
type Telegram struct {
Config TelegramConfig
Client *http.Client
}
func NewTelegram(cfg TelegramConfig) *Telegram {
return &Telegram{Config: cfg, Client: &http.Client{Timeout: 20 * time.Second}}
}
func (t *Telegram) Name() string { return "telegram" }
// Ready says whether the channel can send, in words.
func (t *Telegram) Ready() error {
_, _, err := t.ready()
return err
}
// ready says whether the channel can send, and when not, what the operator must give. The words name
// the setting and the secret, never a value.
func (t *Telegram) ready() (string, string, error) {
token, err := t.token()
if err != nil {
return "", "", err
}
chat := strings.TrimSpace(t.Config.ChatID())
if chat == "" {
return "", "", errors.New("no chat to send to: the setting telegram-chat-id is not given " +
"(`settings` for messenger with {\"telegram-chat-id\": \"<the chat's id>\"})")
}
if !chatIDShape.MatchString(chat) {
return "", "", errors.New("the setting telegram-chat-id is not a chat id (a number, or @name of a channel)")
}
return token, chat, nil
}
func (t *Telegram) token() (string, error) {
if t.Config.TokenFile == "" {
return "", errors.New("no bot token: this module was started without a token file")
}
raw, err := os.ReadFile(t.Config.TokenFile)
if errors.Is(err, os.ErrNotExist) {
return "", errors.New("no bot token: the own secret telegram-token is not on this machine yet " +
"(`secret accept <machine> messenger telegram-token`, then push the machine)")
}
if err != nil {
return "", errors.New("the own secret telegram-token cannot be read: " + plainError(err))
}
token := strings.TrimSpace(string(raw))
if token == "" {
return "", errors.New("no bot token: the own secret telegram-token is empty")
}
if !tokenShape.MatchString(token) {
// The mesh mints a random value for an own secret nobody accepted; that is not a bot token.
return "", errors.New("the own secret telegram-token is not a bot token (digits, a colon, then the key " +
"BotFather gave) — most likely the mesh made it because none was accepted: " +
"`secret accept <machine> messenger telegram-token`, then push the machine")
}
return token, nil
}
// Send posts a message and answers its message id.
func (t *Telegram) Send(m Message) (string, error) {
text := m.Text()
token, chat, err := t.ready()
if err != nil {
return "", err
}
var out struct {
MessageID int64 `json:"message_id"`
}
if err := t.call(token, "sendMessage", map[string]any{
"chat_id": chat, "text": text, "disable_web_page_preview": true,
}, &out); err != nil {
return "", err
}
return fmt.Sprint(out.MessageID), nil
}
// Edit replaces the text of a message sent before: how a cleared condition is said (to-be 45 §5).
func (t *Telegram) Edit(id string, m Message) error {
text := m.Text()
token, chat, err := t.ready()
if err != nil {
return err
}
return t.call(token, "editMessageText", map[string]any{
"chat_id": chat, "message_id": json.Number(id), "text": text, "disable_web_page_preview": true,
}, nil)
}
// CanEdit: Telegram edits a message in place.
func (t *Telegram) CanEdit() bool { return true }
// SilentEdit: an edited Telegram message notifies nobody.
func (t *Telegram) SilentEdit() bool { return true }
// Who asks the bot API who it is: the check that the token works, with no message sent.
func (t *Telegram) Who() (string, error) {
token, _, err := t.ready()
if err != nil {
return "", err
}
var me struct {
Username string `json:"username"`
}
if err := t.call(token, "getMe", map[string]any{}, &me); err != nil {
return "", err
}
return me.Username, nil
}
func (t *Telegram) call(token, method string, body map[string]any, into any) error {
raw, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, TelegramAPI+"/bot"+token+"/"+method, bytes.NewReader(raw))
if err != nil {
return errors.New("telegram " + method + ": the request could not be made")
}
req.Header.Set("Content-Type", "application/json")
resp, err := t.Client.Do(req)
if err != nil {
return fmt.Errorf("telegram %s: %s", method, plainError(err))
}
defer resp.Body.Close()
var answer struct {
OK bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
Result json.RawMessage `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&answer); err != nil {
return fmt.Errorf("telegram %s: HTTP %d with an answer that is not the bot API's", method, resp.StatusCode)
}
if !answer.OK {
d := strings.ReplaceAll(answer.Description, token, "<token>")
return fmt.Errorf("telegram %s refused: %d %s", method, answer.ErrorCode, d)
}
if into != nil && len(answer.Result) > 0 {
_ = json.Unmarshal(answer.Result, into)
}
return nil
}
// plainError is an error in words, without the URL a transport error carries.
func plainError(err error) string {
var ue *url.Error
if errors.As(err, &ue) {
err = ue.Err
}
var ne net.Error
switch {
case errors.As(err, &ne) && ne.Timeout():
return "no answer in time"
case errors.Is(err, os.ErrPermission):
return "permission denied"
}
var dns *net.DNSError
if errors.As(err, &dns) {
return "the bot API's name does not resolve"
}
var op *net.OpError
if errors.As(err, &op) {
return "cannot connect (" + op.Op + ")"
}
s := err.Error()
if strings.Contains(s, "/bot") || strings.Contains(s, "://") {
return "the request failed"
}
return s
}