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.
293 lines
9.3 KiB
Go
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
|
|
}
|