]> git.giacomolli.uk - onechat/blob - botapi.go
Call sendRichMessage of the Bot API
[onechat] / botapi.go
1 package main
2
3 import (
4         "bytes"
5         "context"
6         "encoding/json"
7         "errors"
8         "fmt"
9         "io"
10         "net/http"
11         "net/url"
12         "strings"
13         "time"
14 )
15
16 const (
17         // pollSeconds is how long one getUpdates call waits for an update.
18         pollSeconds = 50
19
20         // pollSlack is how much longer than pollSeconds a getUpdates call can
21         // take before the bot gives it up.
22         pollSlack = 20 * time.Second
23
24         // sendTimeout is how long a sendMessage, sendRichMessage, or
25         // sendChatAction call can take. A connection that stops without a
26         // close, such as after a network change, then fails the call, and the
27         // sender tries again, instead of a wait until TCP gives up. The client
28         // has no timeout of its own, because the long poll of getUpdates needs
29         // a longer limit.
30         sendTimeout = 30 * time.Second
31
32         // botAnswerBytes is the most bytes of one answer of the Bot API.
33         botAnswerBytes = 16 << 20
34 )
35
36 // errBotNetwork tells that a Bot API call got no valid answer, such as after a
37 // network error. A later call can work.
38 var errBotNetwork = errors.New("no valid answer from the Bot API")
39
40 // botAPI calls the Telegram Bot API. The bot token is a part of each URL, so no
41 // error of botAPI holds a URL, a net/http error, or the token. SendTimeout
42 // limits each sendMessage, sendRichMessage, and sendChatAction call.
43 type botAPI struct {
44         base        string
45         token       string
46         http        *http.Client
47         sendTimeout time.Duration
48 }
49
50 // botError is an answer of the Bot API that tells of a failure. RetryAfter is
51 // the wait that the Bot API asks for after HTTP 429. Refused tells that the
52 // answer is a Bot API answer with ok false, not a page of a proxy or of a
53 // server error.
54 type botError struct {
55         Method      string
56         Status      int
57         Description string
58         RetryAfter  time.Duration
59         Refused     bool
60 }
61
62 // botAnswer is the envelope of each answer of the Bot API.
63 type botAnswer struct {
64         OK          bool            `json:"ok"`
65         Result      json.RawMessage `json:"result"`
66         ErrorCode   int             `json:"error_code"`
67         Description string          `json:"description"`
68         Parameters  struct {
69                 RetryAfter int `json:"retry_after"`
70         } `json:"parameters"`
71 }
72
73 // botUpdate is one update of getUpdates. Message is nil for the kinds of
74 // updates that are not a new message.
75 type botUpdate struct {
76         UpdateID int64       `json:"update_id"`
77         Message  *botMessage `json:"message"`
78 }
79
80 // botMessage is a message to the bot. Text is empty for a message that is not
81 // text, such as a photo.
82 type botMessage struct {
83         Chat botChat  `json:"chat"`
84         From *botUser `json:"from"`
85         Text string   `json:"text"`
86 }
87
88 // botChat is the chat of a message. Type is private for a chat with one user.
89 type botChat struct {
90         ID   int64  `json:"id"`
91         Type string `json:"type"`
92 }
93
94 // botEntity is a message entity of sendMessage: a part of the text with a
95 // format, such as bold or a link. Offset and Length count UTF-16 code units.
96 // URL is the target of a text_link, and Language the language of a pre.
97 type botEntity struct {
98         Type     string `json:"type"`
99         Offset   int    `json:"offset"`
100         Length   int    `json:"length"`
101         URL      string `json:"url,omitempty"`
102         Language string `json:"language,omitempty"`
103 }
104
105 // botUser is the sender of a message.
106 type botUser struct {
107         ID int64 `json:"id"`
108 }
109
110 // newBotAPI returns the client of the Bot API at base with token.
111 func newBotAPI(base, token string) *botAPI {
112         return &botAPI{
113                 base:        base,
114                 token:       token,
115                 http:        &http.Client{},
116                 sendTimeout: sendTimeout,
117         }
118 }
119
120 // GetUpdates waits for the updates from offset on, up to pollSeconds, and
121 // returns them. Telegram forgets each update before offset. Only new messages
122 // are asked for.
123 func (b *botAPI) GetUpdates(ctx context.Context, offset int64) ([]botUpdate, error) {
124         ctx, cancel := context.WithTimeout(ctx, pollSeconds*time.Second+pollSlack)
125         defer cancel()
126
127         var updates []botUpdate
128         err := b.call(ctx, "getUpdates", map[string]any{
129                 "offset":          offset,
130                 "timeout":         pollSeconds,
131                 "allowed_updates": []string{"message"},
132         }, &updates)
133
134         return updates, err
135 }
136
137 // SendMessage sends text to chat with the format of entities, with no parse
138 // mode, so the text needs no escaping. With no entities, the text is plain. A
139 // call that takes longer than sendTimeout fails with errBotNetwork.
140 func (b *botAPI) SendMessage(ctx context.Context, chat int64, text string, entities []botEntity) error {
141         ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
142         defer cancel()
143
144         params := map[string]any{
145                 "chat_id": chat,
146                 "text":    text,
147         }
148
149         if len(entities) > 0 {
150                 params["entities"] = entities
151         }
152
153         return b.call(ctx, "sendMessage", params, nil)
154 }
155
156 // SendRichMessage sends markdown to chat as a rich message, which Telegram
157 // renders itself, tables included. A call that takes longer than sendTimeout
158 // fails with errBotNetwork.
159 func (b *botAPI) SendRichMessage(ctx context.Context, chat int64, markdown string) error {
160         ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
161         defer cancel()
162
163         return b.call(ctx, "sendRichMessage", map[string]any{
164                 "chat_id": chat,
165                 "rich_message": map[string]any{
166                         "markdown": markdown,
167                 },
168         }, nil)
169 }
170
171 // SendTyping shows the typing action in chat for about five seconds. A call
172 // that takes longer than sendTimeout fails with errBotNetwork.
173 func (b *botAPI) SendTyping(ctx context.Context, chat int64) error {
174         ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
175         defer cancel()
176
177         return b.call(ctx, "sendChatAction", map[string]any{
178                 "chat_id": chat,
179                 "action":  "typing",
180         }, nil)
181 }
182
183 // call sends one call of method with the JSON params and decodes its result
184 // into result, when result is not nil.
185 func (b *botAPI) call(ctx context.Context, method string, params, result any) error {
186         body, err := json.Marshal(params)
187         if err != nil {
188                 return fmt.Errorf("%s: encode request: %w", method, err)
189         }
190
191         request, err := http.NewRequestWithContext(ctx, http.MethodPost, b.base+"/bot"+b.token+"/"+method, bytes.NewReader(body))
192         if err != nil {
193                 return fmt.Errorf("%s: make request: %s", method, b.redact(err))
194         }
195
196         request.Header.Set("Content-Type", "application/json")
197         response, err := b.http.Do(request)
198         if err != nil {
199                 return fmt.Errorf("%s: %w: %s", method, errBotNetwork, b.redact(err))
200         }
201
202         data, err := io.ReadAll(io.LimitReader(response.Body, botAnswerBytes))
203         err = errors.Join(err, response.Body.Close())
204         if err != nil {
205                 return fmt.Errorf("%s: %w: read answer: %s", method, errBotNetwork, b.redact(err))
206         }
207
208         var answer botAnswer
209         err = json.Unmarshal(data, &answer)
210         if err != nil && response.StatusCode != http.StatusOK {
211                 return &botError{
212                         Method:      method,
213                         Status:      response.StatusCode,
214                         Description: http.StatusText(response.StatusCode),
215                 }
216         }
217
218         if err != nil {
219                 return fmt.Errorf("%s: %w: decode answer: %s", method, errBotNetwork, b.redact(err))
220         }
221
222         if !answer.OK {
223                 return &botError{
224                         Method:      method,
225                         Status:      response.StatusCode,
226                         Description: b.redact(errors.New(answer.Description)),
227                         RetryAfter:  time.Duration(answer.Parameters.RetryAfter) * time.Second,
228                         Refused:     true,
229                 }
230         }
231
232         if result == nil {
233                 return nil
234         }
235
236         err = json.Unmarshal(answer.Result, result)
237         if err != nil {
238                 return fmt.Errorf("%s: %w: decode result: %s", method, errBotNetwork, b.redact(err))
239         }
240
241         return nil
242 }
243
244 // redact returns the text of err without a URL and without the token. A
245 // *url.Error holds the URL of the call, which holds the token, so only its
246 // cause is kept.
247 func (b *botAPI) redact(err error) string {
248         var urlError *url.Error
249         if errors.As(err, &urlError) {
250                 err = urlError.Err
251         }
252
253         return strings.ReplaceAll(err.Error(), b.token, "TOKEN")
254 }
255
256 func (e *botError) Error() string {
257         return fmt.Sprintf("%s: Bot API answered %d: %s", e.Method, e.Status, e.Description)
258 }
259
260 // retryable tells if a later try of a failed Bot API call can work: after a
261 // network error, HTTP 429, or a server error. It returns the wait that the Bot
262 // API asks for, or 0.
263 func retryable(err error) (bool, time.Duration) {
264         if errors.Is(err, errBotNetwork) {
265                 return true, 0
266         }
267
268         var answer *botError
269         if errors.As(err, &answer) {
270                 return answer.Status == http.StatusTooManyRequests || answer.Status >= 500, answer.RetryAfter
271         }
272
273         return false, 0
274 }
275
276 // rejected tells if the Bot API refused a call with ok false and HTTP 400 or
277 // 404. Then the call did nothing: 400 refuses the request, and 404 tells that
278 // the method is not there. An error page with no Bot API answer does not tell
279 // this, so it is not a refusal.
280 func rejected(err error) bool {
281         var answer *botError
282         return errors.As(err, &answer) && answer.Refused && (answer.Status == http.StatusBadRequest || answer.Status == http.StatusNotFound)
283 }