Files
mesh-catalog/modules/telegram/cmd/telegram/bot.go
T
jochen eadf301f9f An ask reads at a glance on the phone: a headline, a summary and its points, the rest in a note (issues 383 and 639)
The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold,
no summary. A settings proposal was the same wall of text, its values masked so the approval was blind.

- messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each
  answer does, the expiry, where else it is answered and the asker's Details travel as the message's note
  (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram ·
  <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind
  that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone
  (from the earlier work on issue 383, #178).
- telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in
  a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message
  shown before is still compared by the digest it was kept under.
- desk-channel: the note follows the body.
- mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three
  points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is
  composed again as it was asked (form), so its warrant still holds.
- claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the
  module's), its text in the Details; a question's context is at most 300 characters and its recommendation
  one point. Older proposals and questions keep their form.

The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and
asks.Glance.
2026-10-13 17:16:50 +02:00

293 lines
9.3 KiB
Go

package main
// The Bot API, as this channel uses it. The bot token is this module's own secret, read from its file at
// each use, owned by the account this module runs as and by no other (novox/hq ADR 0259 §8). It is never
// 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, and a description the API gives back has the
// token taken out.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
// API is where the Bot API answers; a test points it elsewhere.
var API = "https://api.telegram.org"
var tokenShape = regexp.MustCompile(`^\d{5,}:[A-Za-z0-9_-]{30,}$`)
// Bot calls the Bot API with the token in its file.
type Bot struct {
TokenFile string
Client *http.Client
}
// apiError is the API's refusal: its code, its words, and how long it asks to be left alone.
type apiError struct {
Method string
Code int
Words string
RetryAfter time.Duration
}
func (e *apiError) Error() string {
return fmt.Sprintf("telegram %s refused: %d %s", e.Method, e.Code, e.Words)
}
// permanent says whether trying again cannot help: a refusal of the request itself (400, 403) or of the
// token (401, 404), never a rate limit or the service failing (D6).
func permanent(err error) bool {
var a *apiError
return errors.As(err, &a) && (a.Code == 400 || a.Code == 401 || a.Code == 403 || a.Code == 404)
}
func retryAfter(err error) time.Duration {
var a *apiError
if errors.As(err, &a) && a.Code == 429 {
if a.RetryAfter > 0 {
return a.RetryAfter
}
return time.Second
}
return 0
}
// notModified is an edit to the text it already has: a success, never a reason to send again (D6).
func notModified(err error) bool {
var a *apiError
return errors.As(err, &a) && a.Code == 400 && strings.Contains(a.Words, "message is not modified")
}
func gone(err error) bool {
var a *apiError
return errors.As(err, &a) && a.Code == 400 && (strings.Contains(a.Words, "message to edit not found") ||
strings.Contains(a.Words, "message to delete not found"))
}
// token reads the bot token, and says in words what is missing when it cannot: never the value.
func (b *Bot) token() (string, error) {
if b.TokenFile == "" {
return "", errors.New("no bot token: this module was started without a token file")
}
raw, err := os.ReadFile(b.TokenFile)
if errors.Is(err, os.ErrNotExist) {
return "", errors.New("no bot token: the own secret telegram-token is not on this machine yet; the operator " +
"gives it to the vault (the module's README says how)")
}
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 makes a random value for an own secret nobody gave; 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 given; the operator gives it to the vault")
}
return token, nil
}
// call asks the API one method, decoding its result into `into`.
func (b *Bot) call(method string, body map[string]any, into any) error {
token, err := b.token()
if err != nil {
return err
}
raw, err := json.Marshal(body)
if err != nil {
// Said, never discarded (D8).
return fmt.Errorf("telegram %s: the request could not be written: %v", method, err)
}
req, err := http.NewRequest(http.MethodPost, API+"/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")
client := b.Client
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := 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"`
Parameters struct {
RetryAfter int `json:"retry_after"`
} `json:"parameters"`
}
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 {
return &apiError{Method: method, Code: answer.ErrorCode, Words: strings.ReplaceAll(answer.Description, token, "<token>"),
RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second}
}
if into != nil && len(answer.Result) > 0 {
if err := json.Unmarshal(answer.Result, into); err != nil {
return fmt.Errorf("telegram %s: its answer could not be read: %v", method, err)
}
}
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
}
// User is a Telegram account as the API says it.
type User struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
}
// Display is the name the service shows for an account.
func (u User) Display() string {
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
if u.Username != "" {
if name != "" {
return name + " (@" + u.Username + ")"
}
return "@" + u.Username
}
return name
}
// Chat is where a message was written.
type Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
}
// Msg is a message as the API says it.
type Msg struct {
MessageID int64 `json:"message_id"`
From *User `json:"from"`
Chat Chat `json:"chat"`
Text string `json:"text"`
}
// Callback is a button tapped.
type Callback struct {
ID string `json:"id"`
From User `json:"from"`
Message *Msg `json:"message"`
Data string `json:"data"`
}
// Update is one thing that happened to the bot.
type Update struct {
UpdateID int64 `json:"update_id"`
Message *Msg `json:"message"`
Callback *Callback `json:"callback_query"`
}
// Updates long-polls for what happened since offset: outbound only, so no route into the mesh is needed, and
// a stolen token can read updates (a conflict says so) but never inject one (novox/hq ADR 0234).
func (b *Bot) Updates(offset int64, wait time.Duration) ([]Update, error) {
var out []Update
err := b.call("getUpdates", map[string]any{"offset": offset, "timeout": int(wait.Seconds()),
"allowed_updates": []string{"message", "callback_query"}}, &out)
return out, err
}
// Me is the bot's own account: the check that the token works, with nothing sent.
func (b *Bot) Me() (User, error) {
var me User
return me, b.call("getMe", map[string]any{}, &me)
}
// keyboard is buttons one to a row, each carrying only its ticket.
func keyboard(buttons [][2]string) map[string]any {
rows := [][]map[string]string{}
for _, b := range buttons {
rows = append(rows, []map[string]string{{"text": b[0], "callback_data": b[1]}})
}
return map[string]any{"inline_keyboard": rows}
}
// Send posts a message, its text in ParseMode (rendered or escaped by the caller), and answers its id. silent:
// it arrives without sound (D4).
func (b *Bot) Send(chat int64, text string, buttons [][2]string, silent bool) (int64, error) {
body := map[string]any{"chat_id": chat, "text": text, "parse_mode": ParseMode, "disable_notification": silent,
"link_preview_options": map[string]any{"is_disabled": true}}
if len(buttons) > 0 {
body["reply_markup"] = keyboard(buttons)
}
var out Msg
err := b.call("sendMessage", body, &out)
return out.MessageID, err
}
// Edit replaces a message's text (in ParseMode, rendered or escaped by the caller) and buttons; none given, the
// buttons go.
func (b *Bot) Edit(chat, id int64, text string, buttons [][2]string) error {
err := b.call("editMessageText", map[string]any{"chat_id": chat, "message_id": id, "text": text, "parse_mode": ParseMode,
"reply_markup": keyboard(buttons), "link_preview_options": map[string]any{"is_disabled": true}}, nil)
if notModified(err) {
return nil
}
return err
}
// AnswerTap ends the phone's spinner for a tapped button, showing words over the chat when there are any.
func (b *Bot) AnswerTap(id, text string) error {
body := map[string]any{"callback_query_id": id}
if text != "" {
body["text"] = text
}
return b.call("answerCallbackQuery", body, nil)
}
// Delete removes a message from the chat: a code once read.
func (b *Bot) Delete(chat, id int64) error {
err := b.call("deleteMessage", map[string]any{"chat_id": chat, "message_id": id}, nil)
if gone(err) {
return nil
}
return err
}