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\": \"\"})") } 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 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 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, "") 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 }