From: Anderson A. Giacomolli Date: Sat, 7 Nov 2026 13:18:47 +0000 (-0300) Subject: Call the Telegram Bot API X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/d206de3a2e839ffbda9cbdb10a3ed93c4915fce3?ds=inline;hp=b2873a2a0bf8b0ba648aebac51759e5fed89a2e2 Call the Telegram Bot API A small client of the Bot API, on the standard library only, waits for new messages with getUpdates long polling, sends plain text with no parse mode, and shows the typing action. It tells a failure that a later try can fix, such as a network error, HTTP 429 with its wait, or a server error, from one that it cannot. The bot token is a part of each URL, and a net/http error holds the URL. So no error of the client wraps a net/http error: it keeps only the text of the cause, with the token removed. A test checks the error text of each kind of failure for the token. Issue: a143bd8 --- diff --git a/botapi.go b/botapi.go new file mode 100644 index 0000000..9086fdc --- /dev/null +++ b/botapi.go @@ -0,0 +1,218 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + // pollSeconds is how long one getUpdates call waits for an update. + pollSeconds = 50 + + // pollSlack is how much longer than pollSeconds a getUpdates call can + // take before the bot gives it up. + pollSlack = 20 * time.Second + + // botAnswerBytes is the most bytes of one answer of the Bot API. + botAnswerBytes = 16 << 20 +) + +// errBotNetwork tells that a Bot API call got no valid answer, such as after a +// network error. A later call can work. +var errBotNetwork = errors.New("no valid answer from the Bot API") + +// botAPI calls the Telegram Bot API. The bot token is a part of each URL, so no +// error of botAPI holds a URL, a net/http error, or the token. +type botAPI struct { + base string + token string + http *http.Client +} + +// botError is an answer of the Bot API that tells of a failure. RetryAfter is +// the wait that the Bot API asks for after HTTP 429. +type botError struct { + Method string + Status int + Description string + RetryAfter time.Duration +} + +// botAnswer is the envelope of each answer of the Bot API. +type botAnswer struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result"` + ErrorCode int `json:"error_code"` + Description string `json:"description"` + Parameters struct { + RetryAfter int `json:"retry_after"` + } `json:"parameters"` +} + +// botUpdate is one update of getUpdates. Message is nil for the kinds of +// updates that are not a new message. +type botUpdate struct { + UpdateID int64 `json:"update_id"` + Message *botMessage `json:"message"` +} + +// botMessage is a message to the bot. Text is empty for a message that is not +// text, such as a photo. +type botMessage struct { + Chat botChat `json:"chat"` + From *botUser `json:"from"` + Text string `json:"text"` +} + +// botChat is the chat of a message. Type is private for a chat with one user. +type botChat struct { + ID int64 `json:"id"` + Type string `json:"type"` +} + +// botUser is the sender of a message. +type botUser struct { + ID int64 `json:"id"` +} + +// newBotAPI returns the client of the Bot API at base with token. +func newBotAPI(base, token string) *botAPI { + return &botAPI{ + base: base, + token: token, + http: &http.Client{}, + } +} + +// GetUpdates waits for the updates from offset on, up to pollSeconds, and +// returns them. Telegram forgets each update before offset. Only new messages +// are asked for. +func (b *botAPI) GetUpdates(ctx context.Context, offset int64) ([]botUpdate, error) { + ctx, cancel := context.WithTimeout(ctx, pollSeconds*time.Second+pollSlack) + defer cancel() + + var updates []botUpdate + err := b.call(ctx, "getUpdates", map[string]any{ + "offset": offset, + "timeout": pollSeconds, + "allowed_updates": []string{"message"}, + }, &updates) + + return updates, err +} + +// SendMessage sends text to chat as plain text, with no parse mode. +func (b *botAPI) SendMessage(ctx context.Context, chat int64, text string) error { + return b.call(ctx, "sendMessage", map[string]any{ + "chat_id": chat, + "text": text, + }, nil) +} + +// SendTyping shows the typing action in chat for about five seconds. +func (b *botAPI) SendTyping(ctx context.Context, chat int64) error { + return b.call(ctx, "sendChatAction", map[string]any{ + "chat_id": chat, + "action": "typing", + }, nil) +} + +// call sends one call of method with the JSON params and decodes its result +// into result, when result is not nil. +func (b *botAPI) call(ctx context.Context, method string, params, result any) error { + body, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("%s: encode request: %w", method, err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, b.base+"/bot"+b.token+"/"+method, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("%s: make request: %s", method, b.redact(err)) + } + + request.Header.Set("Content-Type", "application/json") + response, err := b.http.Do(request) + if err != nil { + return fmt.Errorf("%s: %w: %s", method, errBotNetwork, b.redact(err)) + } + + data, err := io.ReadAll(io.LimitReader(response.Body, botAnswerBytes)) + err = errors.Join(err, response.Body.Close()) + if err != nil { + return fmt.Errorf("%s: %w: read answer: %s", method, errBotNetwork, b.redact(err)) + } + + var answer botAnswer + err = json.Unmarshal(data, &answer) + if err != nil && response.StatusCode != http.StatusOK { + return &botError{ + Method: method, + Status: response.StatusCode, + Description: http.StatusText(response.StatusCode), + } + } + + if err != nil { + return fmt.Errorf("%s: %w: decode answer: %s", method, errBotNetwork, b.redact(err)) + } + + if !answer.OK { + return &botError{ + Method: method, + Status: response.StatusCode, + Description: b.redact(errors.New(answer.Description)), + RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second, + } + } + + if result == nil { + return nil + } + + err = json.Unmarshal(answer.Result, result) + if err != nil { + return fmt.Errorf("%s: %w: decode result: %s", method, errBotNetwork, b.redact(err)) + } + + return nil +} + +// redact returns the text of err without a URL and without the token. A +// *url.Error holds the URL of the call, which holds the token, so only its +// cause is kept. +func (b *botAPI) redact(err error) string { + var urlError *url.Error + if errors.As(err, &urlError) { + err = urlError.Err + } + + return strings.ReplaceAll(err.Error(), b.token, "TOKEN") +} + +func (e *botError) Error() string { + return fmt.Sprintf("%s: Bot API answered %d: %s", e.Method, e.Status, e.Description) +} + +// retryable tells if a later try of a failed Bot API call can work: after a +// network error, HTTP 429, or a server error. It returns the wait that the Bot +// API asks for, or 0. +func retryable(err error) (bool, time.Duration) { + if errors.Is(err, errBotNetwork) { + return true, 0 + } + + var answer *botError + if errors.As(err, &answer) { + return answer.Status == http.StatusTooManyRequests || answer.Status >= 500, answer.RetryAfter + } + + return false, 0 +} diff --git a/botapi_test.go b/botapi_test.go new file mode 100644 index 0000000..565f27d --- /dev/null +++ b/botapi_test.go @@ -0,0 +1,310 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + // testBotToken is the bot token of the tests. It has the form of a real + // token. + testBotToken = "123456:TEST-secret_token" + + // testOwner is the user ID of the owner of the test bot. + testOwner = 4242 +) + +func TestGetUpdatesAsksForMessagesFromTheOffset(t *testing.T) { + server := newFakeBot(t) + server.Add(botUpdate{ + UpdateID: 7, + Message: ownerMessage("hello"), + }) + + updates, err := newBotAPI(server.URL, testBotToken).GetUpdates(t.Context(), 7) + require.NoError(t, err) + require.Len(t, updates, 1) + require.Equal(t, int64(7), updates[0].UpdateID) + require.Equal(t, "hello", updates[0].Message.Text) + + call := server.Calls()[0] + require.Equal(t, "getUpdates", call.method) + require.Equal(t, map[string]any{ + "offset": float64(7), + "timeout": float64(pollSeconds), + "allowed_updates": []any{"message"}, + }, call.params) +} + +func TestSendMessageSendsPlainText(t *testing.T) { + server := newFakeBot(t) + err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "*not bold*") + require.NoError(t, err) + + require.Equal(t, map[string]any{ + "chat_id": float64(testOwner), + "text": "*not bold*", + }, server.Calls()[0].params) +} + +func TestBotErrorsDoNotHoldTheToken(t *testing.T) { + testCases := []struct { + name string + reply func(w http.ResponseWriter) + retry bool + wait time.Duration + errorText string + }{ + { + name: "refused connection is retried", + reply: func(w http.ResponseWriter) { + conn, _, err := http.NewResponseController(w).Hijack() + if err == nil { + _ = conn.Close() + } + }, + retry: true, + errorText: "no valid answer", + }, + { + name: "rate limit waits as asked", + reply: func(w http.ResponseWriter) { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(w, `{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 3","parameters":{"retry_after":3}}`) + }, + retry: true, + wait: 3 * time.Second, + errorText: "answered 429", + }, + { + name: "server error page is retried", + reply: func(w http.ResponseWriter) { + w.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(w, "bad gateway") + }, + retry: true, + errorText: "answered 502: Bad Gateway", + }, + { + name: "token echoed in a description is hidden", + reply: func(w http.ResponseWriter) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"ok":false,"error_code":401,"description":"Unauthorized: bot`+testBotToken+`"}`) + }, + retry: false, + errorText: "Unauthorized: botTOKEN", + }, + { + name: "broken answer is retried", + reply: func(w http.ResponseWriter) { + _, _ = io.WriteString(w, `{"ok":`) + }, + retry: true, + errorText: "decode answer", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tc.reply(w) + })) + t.Cleanup(server.Close) + + err := newBotAPI(server.URL, testBotToken).SendMessage(t.Context(), testOwner, "hi") + require.ErrorContains(t, err, tc.errorText) + require.NotContains(t, err.Error(), testBotToken) + require.NotContains(t, err.Error(), server.URL) + + retry, wait := retryable(err) + require.Equal(t, tc.retry, retry) + require.Equal(t, tc.wait, wait) + }) + } + + server := httptest.NewServer(http.NotFoundHandler()) + server.Close() + + _, err := newBotAPI(server.URL, testBotToken).GetUpdates(t.Context(), 0) + require.ErrorIs(t, err, errBotNetwork) + require.NotContains(t, err.Error(), testBotToken) +} + +// fakeBot is a fake Bot API server for testBotToken. getUpdates returns the +// added updates from the offset on, or waits a short time when there are none. +// The other methods succeed, unless fail gives an error answer for a call. +type fakeBot struct { + *httptest.Server + + mutex sync.Mutex + updates []botUpdate + calls []fakeBotCall + fail func(call fakeBotCall) (int, string) + added chan struct{} +} + +// fakeBotCall is one call of the fake Bot API. OK tells if the call got a +// success answer. +type fakeBotCall struct { + method string + params map[string]any + ok bool +} + +// newFakeBot starts a fake Bot API server that stops at the end of the test. +func newFakeBot(t *testing.T) *fakeBot { + t.Helper() + + f := &fakeBot{ + added: make(chan struct{}, 1), + } + + f.Server = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.Close) + + return f +} + +// Add makes update available to getUpdates. +func (f *fakeBot) Add(update botUpdate) { + f.mutex.Lock() + f.updates = append(f.updates, update) + f.mutex.Unlock() + + select { + case f.added <- struct{}{}: + default: + } +} + +// Calls returns the calls so far. +func (f *fakeBot) Calls() []fakeBotCall { + f.mutex.Lock() + defer f.mutex.Unlock() + + return append([]fakeBotCall(nil), f.calls...) +} + +// Sent returns the texts of the sendMessage calls that succeeded. +func (f *fakeBot) Sent() []string { + var texts []string + for _, call := range f.Calls() { + if call.method == "sendMessage" && call.ok { + texts = append(texts, call.params["text"].(string)) + } + } + + return texts +} + +func (f *fakeBot) serve(w http.ResponseWriter, r *http.Request) { + method, found := strings.CutPrefix(r.URL.Path, "/bot"+testBotToken+"/") + if !found { + http.Error(w, `{"ok":false,"error_code":404,"description":"Not Found"}`, http.StatusNotFound) + return + } + + var params map[string]any + err := json.NewDecoder(r.Body).Decode(¶ms) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + call := fakeBotCall{ + method: method, + params: params, + } + + f.mutex.Lock() + fail := f.fail + f.mutex.Unlock() + + status, description := 0, "" + if fail != nil { + status, description = fail(call) + } + + if status != 0 { + f.record(call) + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":1}}`, status, description) + + return + } + + call.ok = true + f.record(call) + if method != "getUpdates" { + _, _ = io.WriteString(w, `{"ok":true,"result":true}`) + return + } + + offset := int64(params["offset"].(float64)) + updates := f.from(offset) + if len(updates) == 0 { + select { + case <-f.added: + case <-time.After(50 * time.Millisecond): + case <-r.Context().Done(): + } + + updates = f.from(offset) + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "result": updates, + }) +} + +// record keeps call. +func (f *fakeBot) record(call fakeBotCall) { + f.mutex.Lock() + defer f.mutex.Unlock() + + f.calls = append(f.calls, call) +} + +// from returns the updates from offset on, and forgets the ones before it, as +// Telegram does. +func (f *fakeBot) from(offset int64) []botUpdate { + f.mutex.Lock() + defer f.mutex.Unlock() + + var updates []botUpdate + kept := f.updates[:0] + for _, update := range f.updates { + if update.UpdateID >= offset { + updates = append(updates, update) + kept = append(kept, update) + } + } + + f.updates = kept + + return updates +} + +// ownerMessage returns a text message of the owner in the private chat. +func ownerMessage(text string) *botMessage { + return &botMessage{ + Chat: botChat{ + ID: testOwner, + Type: "private", + }, + From: &botUser{ + ID: testOwner, + }, + Text: text, + } +}