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, 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
|
|
}
|