]> git.giacomolli.uk - onechat/blobdiff - botapi.go
Send the Telegram replies as rich messages
[onechat] / botapi.go
index 9086fdc45f42b082e4f50fef2d5f74dd84606279..2da945e9dc1b283d9b3c4dda8b7c54becb6f0419 100644 (file)
--- a/botapi.go
+++ b/botapi.go
@@ -21,6 +21,14 @@ const (
        // take before the bot gives it up.
        pollSlack = 20 * time.Second
 
+       // 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.
        botAnswerBytes = 16 << 20
 )
@@ -30,20 +38,25 @@ const (
 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.
+// error of botAPI holds a URL, a net/http error, or the token. SendTimeout
+// limits each sendMessage, sendRichMessage, and sendChatAction call.
 type botAPI struct {
-       base  string
-       token string
-       http  *http.Client
+       base        string
+       token       string
+       http        *http.Client
+       sendTimeout time.Duration
 }
 
 // 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.
@@ -78,6 +91,17 @@ type botChat struct {
        Type string `json:"type"`
 }
 
+// botEntity is a message entity of sendMessage: a part of the text with a
+// format, such as bold or a link. Offset and Length count UTF-16 code units.
+// URL is the target of a text_link, and Language the language of a pre.
+type botEntity struct {
+       Type     string `json:"type"`
+       Offset   int    `json:"offset"`
+       Length   int    `json:"length"`
+       URL      string `json:"url,omitempty"`
+       Language string `json:"language,omitempty"`
+}
+
 // botUser is the sender of a message.
 type botUser struct {
        ID int64 `json:"id"`
@@ -86,9 +110,10 @@ type botUser struct {
 // 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{},
+               base:        base,
+               token:       token,
+               http:        &http.Client{},
+               sendTimeout: sendTimeout,
        }
 }
 
@@ -109,16 +134,46 @@ func (b *botAPI) GetUpdates(ctx context.Context, offset int64) ([]botUpdate, err
        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{
+// SendMessage sends text to chat with the format of entities, with no parse
+// mode, so the text needs no escaping. With no entities, the text is plain. A
+// call that takes longer than sendTimeout fails with errBotNetwork.
+func (b *botAPI) SendMessage(ctx context.Context, chat int64, text string, entities []botEntity) error {
+       ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
+       defer cancel()
+
+       params := map[string]any{
                "chat_id": chat,
                "text":    text,
+       }
+
+       if len(entities) > 0 {
+               params["entities"] = entities
+       }
+
+       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.
+// 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 {
+       ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
+       defer cancel()
+
        return b.call(ctx, "sendChatAction", map[string]any{
                "chat_id": chat,
                "action":  "typing",
@@ -170,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,
                }
        }
 
@@ -216,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)
+}