The mesh noticed 48 core failures in six days and told nobody (ADR 0227). messenger holds the operator-channel seat: it consumes the controller's condition events and sends them to Telegram and the desktop notifier, deduplicated by key, reminded once, edited on clear, capped at 20 an hour with the rest folded, and refusing anything carrying an address, a path or a secret. mesh-watcher, on a machine other than the control node, sends to Telegram directly when the self-check heartbeat or the bus goes silent.
206 lines
6.5 KiB
Go
206 lines
6.5 KiB
Go
package main
|
|
|
|
// Telegram, sent to directly over HTTPS — never through the bus or the control node (novox/hq to-be 45
|
|
// §5): the one sender that does not pass through the control node. The same client as the
|
|
// operator-channel's holder (module messenger), kept here so the watcher depends on nothing it
|
|
// watches. 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 mesh-watcher 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> mesh-watcher 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> mesh-watcher 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 }
|
|
|
|
// 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
|
|
}
|