// 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.
// 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
}
// 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.
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 {
Status: response.StatusCode,
Description: b.redact(errors.New(answer.Description)),
RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second,
+ Refused: true,
}
}
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)
+}
}, 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) {
reply func(w http.ResponseWriter)
retry bool
wait time.Duration
+ rejected bool
errorText string
}{
{
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) {
retry, wait := retryable(err)
require.Equal(t, tc.retry, retry)
require.Equal(t, tc.wait, wait)
+ require.Equal(t, tc.rejected, rejected(err))
})
}