Add telegram and the desk as channels of their own, each judging nothing and holding only its own secret (hq ADR 0259)
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user