From 4c0da6d82252e096269ff840160618318a457d53 Mon Sep 17 00:00:00 2001 From: "Anderson A. Giacomolli" Date: Sat, 7 Nov 2026 12:18:08 -0300 Subject: [PATCH] Call sendRichMessage of the Bot API The Bot API client sends Markdown as a rich message with sendRichMessage, which Telegram renders itself, tables included. The call has the same send timeout as sendMessage, so a stalled connection fails with a network error that the sender can try again. A botError now tells if the answer is a Bot API answer with ok false, and rejected tells if such an answer has HTTP 400 or 404. Then the call did nothing, so the sender can send the same content another way with no risk to repeat it. An error page of a proxy with HTTP 404 is not a Bot API answer, so it is not a refusal. Issue: fec23f9 --- botapi.go | 43 ++++++++++++++++--- botapi_test.go | 113 +++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 127 insertions(+), 29 deletions(-) diff --git a/botapi.go b/botapi.go index f48465c..2da945e 100644 --- a/botapi.go +++ b/botapi.go @@ -21,11 +21,12 @@ const ( // take before the bot gives it up. pollSlack = 20 * time.Second - // sendTimeout is how long a sendMessage or sendChatAction call can - // take. A connection that stops without a close, such as after a - // network change, then fails the call, and the sender tries again, - // instead of a wait until TCP gives up. The client has no timeout of - // its own, because the long poll of getUpdates needs a longer limit. + // sendTimeout is how long a sendMessage, sendRichMessage, or + // sendChatAction call can take. A connection that stops without a + // close, such as after a network change, then fails the call, and the + // sender tries again, instead of a wait until TCP gives up. The client + // has no timeout of its own, because the long poll of getUpdates needs + // a longer limit. sendTimeout = 30 * time.Second // botAnswerBytes is the most bytes of one answer of the Bot API. @@ -38,7 +39,7 @@ 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. SendTimeout -// limits each sendMessage and sendChatAction call. +// limits each sendMessage, sendRichMessage, and sendChatAction call. type botAPI struct { base string token string @@ -47,12 +48,15 @@ type botAPI struct { } // 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. +// the wait that the Bot API asks for after HTTP 429. Refused tells that the +// answer is a Bot API answer with ok false, not a page of a proxy or of a +// server error. type botError struct { Method string Status int Description string RetryAfter time.Duration + Refused bool } // botAnswer is the envelope of each answer of the Bot API. @@ -149,6 +153,21 @@ func (b *botAPI) SendMessage(ctx context.Context, chat int64, text string, entit return b.call(ctx, "sendMessage", params, nil) } +// SendRichMessage sends markdown to chat as a rich message, which Telegram +// renders itself, tables included. A call that takes longer than sendTimeout +// fails with errBotNetwork. +func (b *botAPI) SendRichMessage(ctx context.Context, chat int64, markdown string) error { + ctx, cancel := context.WithTimeout(ctx, b.sendTimeout) + defer cancel() + + return b.call(ctx, "sendRichMessage", map[string]any{ + "chat_id": chat, + "rich_message": map[string]any{ + "markdown": markdown, + }, + }, nil) +} + // SendTyping shows the typing action in chat for about five seconds. A call // that takes longer than sendTimeout fails with errBotNetwork. func (b *botAPI) SendTyping(ctx context.Context, chat int64) error { @@ -206,6 +225,7 @@ func (b *botAPI) call(ctx context.Context, method string, params, result any) er Status: response.StatusCode, Description: b.redact(errors.New(answer.Description)), RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second, + Refused: true, } } @@ -252,3 +272,12 @@ func retryable(err error) (bool, time.Duration) { return false, 0 } + +// rejected tells if the Bot API refused a call with ok false and HTTP 400 or +// 404. Then the call did nothing: 400 refuses the request, and 404 tells that +// the method is not there. An error page with no Bot API answer does not tell +// this, so it is not a refusal. +func rejected(err error) bool { + var answer *botError + return errors.As(err, &answer) && answer.Refused && (answer.Status == http.StatusBadRequest || answer.Status == http.StatusNotFound) +} diff --git a/botapi_test.go b/botapi_test.go index 9c4db54..9dfbcad 100644 --- a/botapi_test.go +++ b/botapi_test.go @@ -79,31 +79,69 @@ func TestSendMessageSendsTheEntities(t *testing.T) { }, server.Calls()[0].params["entities"]) } -func TestSendMessageTimesOutOnAStalledConnection(t *testing.T) { +func TestSendRichMessageSendsTheMarkdownUnchanged(t *testing.T) { server := newFakeBot(t) - server.stall = func(call fakeBotCall) bool { - return call.method == "sendMessage" + markdown := "| a | b |\n|---|---|\n| **1** | `2` |" + err := newBotAPI(server.URL, testBotToken).SendRichMessage(t.Context(), testOwner, markdown) + require.NoError(t, err) + + call := server.Calls()[0] + require.Equal(t, "sendRichMessage", call.method) + require.Equal(t, map[string]any{ + "chat_id": float64(testOwner), + "rich_message": map[string]any{ + "markdown": markdown, + }, + }, call.params) +} + +func TestSendCallsTimeOutOnAStalledConnection(t *testing.T) { + testCases := []struct { + method string + send func(ctx context.Context, api *botAPI) error + }{ + { + method: "sendMessage", + send: func(ctx context.Context, api *botAPI) error { + return api.SendMessage(ctx, testOwner, "hi", nil) + }, + }, + { + method: "sendRichMessage", + send: func(ctx context.Context, api *botAPI) error { + return api.SendRichMessage(ctx, testOwner, "hi") + }, + }, } - api := newBotAPI(server.URL, testBotToken) - api.sendTimeout = 50 * time.Millisecond - start := time.Now() - err := api.SendMessage(t.Context(), testOwner, "hi", nil) - require.ErrorIs(t, err, errBotNetwork) - require.Less(t, time.Since(start), 5*time.Second) - require.NotContains(t, err.Error(), testBotToken) - require.NotContains(t, err.Error(), server.URL) - - retry, _ := retryable(err) - require.True(t, retry) - - api.sendTimeout = time.Hour - ctx, cancel := context.WithCancel(t.Context()) - time.AfterFunc(50*time.Millisecond, cancel) - start = time.Now() - err = api.SendMessage(ctx, testOwner, "hi", nil) - require.Error(t, err) - require.Less(t, time.Since(start), 5*time.Second) + for _, tc := range testCases { + t.Run(tc.method, func(t *testing.T) { + server := newFakeBot(t) + server.stall = func(call fakeBotCall) bool { + return call.method == tc.method + } + + api := newBotAPI(server.URL, testBotToken) + api.sendTimeout = 50 * time.Millisecond + start := time.Now() + err := tc.send(t.Context(), api) + require.ErrorIs(t, err, errBotNetwork) + require.Less(t, time.Since(start), 5*time.Second) + require.NotContains(t, err.Error(), testBotToken) + require.NotContains(t, err.Error(), server.URL) + + retry, _ := retryable(err) + require.True(t, retry) + + api.sendTimeout = time.Hour + ctx, cancel := context.WithCancel(t.Context()) + time.AfterFunc(50*time.Millisecond, cancel) + start = time.Now() + err = tc.send(ctx, api) + require.Error(t, err) + require.Less(t, time.Since(start), 5*time.Second) + }) + } } func TestBotErrorsDoNotHoldTheToken(t *testing.T) { @@ -112,6 +150,7 @@ func TestBotErrorsDoNotHoldTheToken(t *testing.T) { reply func(w http.ResponseWriter) retry bool wait time.Duration + rejected bool errorText string }{ { @@ -153,6 +192,35 @@ func TestBotErrorsDoNotHoldTheToken(t *testing.T) { retry: false, errorText: "Unauthorized: botTOKEN", }, + { + name: "bad request is a refusal", + reply: func(w http.ResponseWriter) { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"ok":false,"error_code":400,"description":"Bad Request: can't parse"}`) + }, + retry: false, + rejected: true, + errorText: "answered 400: Bad Request: can't parse", + }, + { + name: "unknown method is a refusal", + reply: func(w http.ResponseWriter) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"ok":false,"error_code":404,"description":"Not Found"}`) + }, + retry: false, + rejected: true, + errorText: "answered 404: Not Found", + }, + { + name: "not found page of a proxy is not a refusal", + reply: func(w http.ResponseWriter) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, "not found") + }, + retry: false, + errorText: "answered 404: Not Found", + }, { name: "broken answer is retried", reply: func(w http.ResponseWriter) { @@ -178,6 +246,7 @@ func TestBotErrorsDoNotHoldTheToken(t *testing.T) { retry, wait := retryable(err) require.Equal(t, tc.retry, retry) require.Equal(t, tc.wait, wait) + require.Equal(t, tc.rejected, rejected(err)) }) } -- 2.53.0