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