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)
207 lines
6.4 KiB
Go
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
|
|
}
|