]> git.giacomolli.uk - onechat/blob - telegram_api.go
Keep time limits with their operations
[onechat] / telegram_api.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 "path"
13 "strings"
14 "time"
15 )
16
17 const (
18 // telegramPollSeconds is how long one getUpdates call waits for an
19 // update.
20 telegramPollSeconds = 50
21
22 // telegramPollSlack is how much longer than telegramPollSeconds a
23 // getUpdates call can take before the bot gives it up.
24 telegramPollSlack = 20 * time.Second
25
26 // telegramPollTimeout is how long one getUpdates call can take.
27 telegramPollTimeout = telegramPollSeconds*time.Second + telegramPollSlack
28
29 // telegramSendTimeout is how long a sendMessage, sendRichMessage, or
30 // sendChatAction call can take. A connection that stops without a
31 // close, such as after a network change, then fails the call, and the
32 // sender tries again, instead of a wait until TCP gives up. The client
33 // has no timeout of its own, because the long poll of getUpdates needs
34 // a longer limit.
35 telegramSendTimeout = 30 * time.Second
36
37 // telegramAnswerBytes is the most bytes of one answer of the Bot API.
38 telegramAnswerBytes = 16 << 20
39 )
40
41 // newTelegramAPI returns the client of the Bot API at base with token.
42 func newTelegramAPI(base, token string) *TelegramAPI {
43 return &TelegramAPI{
44 base: base,
45 token: token,
46 http: &http.Client{},
47 }
48 }
49
50 // telegramRetryable tells if a later try of a failed Bot API call can work:
51 // after a network error, HTTP 429, or a server error. It returns the wait that
52 // the Bot API asks for, or 0.
53 func telegramRetryable(err error) (bool, time.Duration) {
54 var network *TelegramNetworkError
55
56 if errors.As(err, &network) {
57 return true, 0
58 }
59
60 var answer *TelegramError
61
62 if errors.As(err, &answer) {
63 return answer.Status == http.StatusTooManyRequests || answer.Status >= 500, answer.RetryAfter
64 }
65
66 return false, 0
67 }
68
69 // telegramRejected tells if the Bot API refused a call with ok false and HTTP
70 // 400 or 404. Then the call did nothing: 400 refuses the request, and 404 tells
71 // that the method is not there. An error page with no Bot API answer does not
72 // tell this, so it is not a refusal.
73 func telegramRejected(err error) bool {
74 var answer *TelegramError
75
76 return errors.As(err, &answer) && answer.Refused && (answer.Status == http.StatusBadRequest || answer.Status == http.StatusNotFound)
77 }
78
79 // TelegramAPI calls the Telegram Bot API. The bot token is a part of each URL,
80 // so no error of TelegramAPI holds a URL, a net/http error, or the token.
81 type TelegramAPI struct {
82 base string
83 token string
84 http *http.Client
85 }
86
87 // GetUpdates waits for the updates from offset on, up to telegramPollSeconds,
88 // and returns them. Telegram forgets each update before offset. Only new
89 // messages are asked for.
90 func (b *TelegramAPI) GetUpdates(ctx context.Context, offset int64) ([]TelegramUpdate, error) {
91 var updates []TelegramUpdate
92
93 err := b.call(ctx, telegramPollTimeout, "getUpdates", map[string]any{
94 "offset": offset,
95 "timeout": telegramPollSeconds,
96 "allowed_updates": []string{"message"},
97 }, &updates)
98
99 return updates, err
100 }
101
102 // GetImage downloads a file through the authenticated file endpoint. Errors
103 // never include its token or URL, and the body is limited before storage.
104 func (b *TelegramAPI) GetImage(ctx context.Context, id string) (data []byte, err error) {
105 var file struct {
106 Path string `json:"file_path"`
107 Size int64 `json:"file_size"`
108 }
109
110 err = b.call(ctx, telegramSendTimeout, "getFile", map[string]any{"file_id": id}, &file)
111 if err != nil {
112 return nil, fmt.Errorf("get image file: %w", err)
113 }
114
115 if file.Size > imageMaxBytes {
116 return nil, errImageSize
117 }
118
119 if file.Path == "" || path.Clean(file.Path) != file.Path || strings.HasPrefix(file.Path, "/") || strings.HasPrefix(file.Path, "../") || strings.ContainsAny(file.Path, "?%#\\") {
120 return nil, errors.New("invalid image file path")
121 }
122
123 ctx, cancel := context.WithTimeout(ctx, telegramSendTimeout)
124 defer cancel()
125
126 request, err := http.NewRequestWithContext(ctx, http.MethodGet, b.base+"/file/bot"+b.token+"/"+file.Path, nil)
127 if err != nil {
128 return nil, fmt.Errorf("make image request: %s", b.redact(err))
129 }
130
131 response, err := b.http.Do(request)
132 if err != nil {
133 return nil, b.networkError("image download failed", err, telegramSendTimeout)
134 }
135
136 defer func() {
137 closeErr := response.Body.Close()
138 if closeErr != nil {
139 err = errors.Join(err, b.networkError("close image download", closeErr, telegramSendTimeout))
140 }
141 }()
142
143 if response.StatusCode != http.StatusOK {
144 return nil, &TelegramError{
145 Status: response.StatusCode,
146 Description: http.StatusText(response.StatusCode),
147 }
148 }
149
150 data, err = io.ReadAll(io.LimitReader(response.Body, imageMaxBytes+1))
151 if err != nil {
152 return nil, b.networkError("image cut off", err, telegramSendTimeout)
153 }
154
155 if len(data) > imageMaxBytes {
156 return nil, errImageSize
157 }
158
159 return data, nil
160 }
161
162 // SendMessage sends text to chat with the format of entities, with no parse
163 // mode, so the text needs no escaping. With no entities, the text is plain. A
164 // call that takes longer than telegramSendTimeout fails with a
165 // TelegramNetworkError.
166 func (b *TelegramAPI) SendMessage(ctx context.Context, chat int64, text string, entities []TelegramEntity) error {
167 params := map[string]any{
168 "chat_id": chat,
169 "text": text,
170 }
171
172 if len(entities) > 0 {
173 params["entities"] = entities
174 }
175
176 return b.call(ctx, telegramSendTimeout, "sendMessage", params, nil)
177 }
178
179 // SendRichMessage sends markdown to chat as a rich message, which Telegram
180 // renders itself, tables included. A call that takes longer than
181 // telegramSendTimeout fails with a TelegramNetworkError.
182 func (b *TelegramAPI) SendRichMessage(ctx context.Context, chat int64, markdown string) error {
183 return b.call(ctx, telegramSendTimeout, "sendRichMessage", map[string]any{
184 "chat_id": chat,
185 "rich_message": map[string]any{
186 "markdown": markdown,
187 },
188 }, nil)
189 }
190
191 // SendTyping shows the typing action in chat for about five seconds. A call
192 // that takes longer than telegramSendTimeout fails with a TelegramNetworkError.
193 func (b *TelegramAPI) SendTyping(ctx context.Context, chat int64) error {
194 return b.call(ctx, telegramSendTimeout, "sendChatAction", map[string]any{
195 "chat_id": chat,
196 "action": "typing",
197 }, nil)
198 }
199
200 // call sends one call of method with the JSON params and decodes its result
201 // into result, when result is not nil. The call can take up to timeout. Its
202 // errors tell the cause in plain words, with no method name, so that a log line
203 // can say first what the call was for.
204 func (b *TelegramAPI) call(ctx context.Context, timeout time.Duration, method string, params, result any) error {
205 ctx, cancel := context.WithTimeout(ctx, timeout)
206 defer cancel()
207
208 body, err := json.Marshal(params)
209 if err != nil {
210 return fmt.Errorf("%s: encode request: %w", method, err)
211 }
212
213 request, err := http.NewRequestWithContext(ctx, http.MethodPost, b.base+"/bot"+b.token+"/"+method, bytes.NewReader(body))
214 if err != nil {
215 return fmt.Errorf("%s: make request: %s", method, b.redact(err))
216 }
217
218 request.Header.Set("Content-Type", "application/json")
219
220 response, err := b.http.Do(request)
221 if err != nil {
222 return b.networkError("no answer", err, timeout)
223 }
224
225 data, err := io.ReadAll(io.LimitReader(response.Body, telegramAnswerBytes))
226 err = errors.Join(err, response.Body.Close())
227 if err != nil {
228 return b.networkError("answer cut off", err, timeout)
229 }
230
231 var answer TelegramAnswer
232
233 err = json.Unmarshal(data, &answer)
234 if err != nil && response.StatusCode != http.StatusOK {
235 return &TelegramError{
236 Status: response.StatusCode,
237 Description: http.StatusText(response.StatusCode),
238 }
239 }
240
241 if err != nil {
242 return b.networkError("invalid JSON", err, timeout)
243 }
244
245 if !answer.OK {
246 return &TelegramError{
247 Status: response.StatusCode,
248 Description: b.redact(errors.New(answer.Description)),
249 RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second,
250 Refused: true,
251 }
252 }
253
254 if result == nil {
255 return nil
256 }
257
258 err = json.Unmarshal(answer.Result, result)
259 if err != nil {
260 return b.networkError("invalid result", err, timeout)
261 }
262
263 return nil
264 }
265
266 // networkError returns the TelegramNetworkError of err, which happened at the
267 // step that what names. When the call used all of its time timeout, the cause
268 // is "no answer in TIMEOUT", because the Go text of a deadline does not tell
269 // how long the wait was.
270 func (b *TelegramAPI) networkError(what string, err error, timeout time.Duration) error {
271 if errors.Is(err, context.DeadlineExceeded) {
272 return &TelegramNetworkError{
273 Cause: "no answer in " + timeout.String(),
274 }
275 }
276
277 return &TelegramNetworkError{
278 Cause: what + ": " + b.redact(err),
279 }
280 }
281
282 // redact returns the text of err without a URL and without the token. A
283 // *url.Error holds the URL of the call, which holds the token, so only its
284 // cause is kept.
285 func (b *TelegramAPI) redact(err error) string {
286 var urlError *url.Error
287
288 if errors.As(err, &urlError) {
289 err = urlError.Err
290 }
291
292 return strings.ReplaceAll(err.Error(), b.token, "TOKEN")
293 }
294
295 // TelegramNetworkError tells that a Bot API call got no valid answer, such as
296 // after a network error. A later call can work. Cause tells why in plain words,
297 // with no URL and no token.
298 type TelegramNetworkError struct {
299 Cause string
300 }
301
302 func (e *TelegramNetworkError) Error() string {
303 return e.Cause
304 }
305
306 // TelegramError is an answer of the Bot API that tells of a failure. Its text
307 // gives the HTTP status and the description, such as "502 Bad Gateway".
308 // RetryAfter is the wait that the Bot API asks for after HTTP 429. Refused
309 // tells that the answer is a Bot API answer with ok false, not a page of a
310 // proxy or of a server error.
311 type TelegramError struct {
312 Status int
313 Description string
314 RetryAfter time.Duration
315 Refused bool
316 }
317
318 func (e *TelegramError) Error() string {
319 return fmt.Sprintf("%d %s", e.Status, e.Description)
320 }
321
322 // TelegramUpdate is one update of getUpdates. Message is nil for the kinds of
323 // updates that are not a new message.
324 type TelegramUpdate struct {
325 UpdateID int64 `json:"update_id"`
326 Message *TelegramMessage `json:"message"`
327 }
328
329 // TelegramEntity is a message entity of sendMessage: a part of the text with a
330 // format, such as bold or a link. Offset and Length count UTF-16 code units.
331 // URL is the target of a text_link, and Language the language of a pre.
332 type TelegramEntity struct {
333 Type string `json:"type"`
334 Offset int `json:"offset"`
335 Length int `json:"length"`
336 URL string `json:"url,omitempty"`
337 Language string `json:"language,omitempty"`
338 }
339
340 // TelegramAnswer is the envelope of each answer of the Bot API.
341 type TelegramAnswer struct {
342 OK bool `json:"ok"`
343 Result json.RawMessage `json:"result"`
344 ErrorCode int `json:"error_code"`
345 Description string `json:"description"`
346 Parameters struct {
347 RetryAfter int `json:"retry_after"`
348 } `json:"parameters"`
349 }
350
351 // TelegramMessage holds the text, caption, and image sizes of a bot message.
352 type TelegramMessage struct {
353 Chat TelegramChat `json:"chat"`
354 From *TelegramUser `json:"from"`
355 Text string `json:"text"`
356 Caption string `json:"caption"`
357 Photo []TelegramPhoto `json:"photo"`
358 Document *TelegramDocument `json:"document"`
359 }
360
361 // TelegramPhoto is one supplied size of a photo.
362 type TelegramPhoto struct {
363 FileID string `json:"file_id"`
364 Width int `json:"width"`
365 Height int `json:"height"`
366 FileSize int64 `json:"file_size"`
367 }
368
369 // TelegramDocument is a file sent without photo compression.
370 type TelegramDocument struct {
371 FileID string `json:"file_id"`
372 MimeType string `json:"mime_type"`
373 FileSize int64 `json:"file_size"`
374 }
375
376 // TelegramChat is the chat of a message. Type is private for a chat with one
377 // user.
378 type TelegramChat struct {
379 ID int64 `json:"id"`
380 Type string `json:"type"`
381 }
382
383 // TelegramUser is the sender of a message.
384 type TelegramUser struct {
385 ID int64 `json:"id"`
386 }