]> git.giacomolli.uk - onechat/commitdiff
Call sendRichMessage of the Bot API
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sat, 7 Nov 2026 15:18:08 +0000 (12:18 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sat, 7 Nov 2026 15:18:08 +0000 (12:18 -0300)
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
botapi_test.go

index f48465c3174674173cb1b8d8337932cdae5c05ba..2da945e9dc1b283d9b3c4dda8b7c54becb6f0419 100644 (file)
--- a/botapi.go
+++ b/botapi.go
@@ -21,11 +21,12 @@ const (
        // take before the bot gives it up.
        pollSlack = 20 * time.Second
 
        // 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.
        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
 
 // 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
 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
 }
 
 // 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
 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.
 }
 
 // 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)
 }
 
        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 {
 // 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,
                        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
 }
 
        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)
+}
index 9c4db5461c2d1434be43d0577ff0753b012f498b..9dfbcad8a11d2734bb2b2ba16ea47ce5c633c846 100644 (file)
@@ -79,31 +79,69 @@ func TestSendMessageSendsTheEntities(t *testing.T) {
        }, server.Calls()[0].params["entities"])
 }
 
        }, server.Calls()[0].params["entities"])
 }
 
-func TestSendMessageTimesOutOnAStalledConnection(t *testing.T) {
+func TestSendRichMessageSendsTheMarkdownUnchanged(t *testing.T) {
        server := newFakeBot(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) {
 }
 
 func TestBotErrorsDoNotHoldTheToken(t *testing.T) {
@@ -112,6 +150,7 @@ func TestBotErrorsDoNotHoldTheToken(t *testing.T) {
                reply     func(w http.ResponseWriter)
                retry     bool
                wait      time.Duration
                reply     func(w http.ResponseWriter)
                retry     bool
                wait      time.Duration
+               rejected  bool
                errorText string
        }{
                {
                errorText string
        }{
                {
@@ -153,6 +192,35 @@ func TestBotErrorsDoNotHoldTheToken(t *testing.T) {
                        retry:     false,
                        errorText: "Unauthorized: botTOKEN",
                },
                        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, "<html>not found</html>")
+                       },
+                       retry:     false,
+                       errorText: "answered 404: Not Found",
+               },
                {
                        name: "broken answer is retried",
                        reply: func(w http.ResponseWriter) {
                {
                        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)
                        retry, wait := retryable(err)
                        require.Equal(t, tc.retry, retry)
                        require.Equal(t, tc.wait, wait)
+                       require.Equal(t, tc.rejected, rejected(err))
                })
        }
 
                })
        }