Files
mesh-catalog/modules/telegram/cmd/telegram/bot.go
T

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, at once.
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
}