mesh/merge-gate pass: builds messenger, telegram → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The review found that a double tap while the phone's spinner turned would be answered 'That answer was not taken', reading as if the approval failed; it is now 'You already chose Approve at 13:30: recorded.' and does nothing more. The fallback toast no longer promises a change that may not come (a stranger's tap), the wait outlasts the router's 10 s wait for the controller's root word, the channel's own refusals stay in the chat too, and the store's error words stay in the journal.
291 lines
9.1 KiB
Go
291 lines
9.1 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 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, "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 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,
|
|
"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
|
|
}
|