]> git.giacomolli.uk - onechat/blob - botapi.go
Limit the time of each Telegram send call
[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 or sendChatAction call can
25         // take. A connection that stops without a close, such as after a
26         // network change, then fails the call, and the sender tries again,
27         // instead of a wait until TCP gives up. The client has no timeout of
28         // its own, because the long poll of getUpdates needs a longer limit.
29         sendTimeout = 30 * time.Second
30
31         // botAnswerBytes is the most bytes of one answer of the Bot API.
32         botAnswerBytes = 16 << 20
33 )
34
35 // errBotNetwork tells that a Bot API call got no valid answer, such as after a
36 // network error. A later call can work.
37 var errBotNetwork = errors.New("no valid answer from the Bot API")
38
39 // botAPI calls the Telegram Bot API. The bot token is a part of each URL, so no
40 // error of botAPI holds a URL, a net/http error, or the token. SendTimeout
41 // limits each sendMessage and sendChatAction call.
42 type botAPI struct {
43         base        string
44         token       string
45         http        *http.Client
46         sendTimeout time.Duration
47 }
48
49 // botError is an answer of the Bot API that tells of a failure. RetryAfter is
50 // the wait that the Bot API asks for after HTTP 429.
51 type botError struct {
52         Method      string
53         Status      int
54         Description string
55         RetryAfter  time.Duration
56 }
57
58 // botAnswer is the envelope of each answer of the Bot API.
59 type botAnswer struct {
60         OK          bool            `json:"ok"`
61         Result      json.RawMessage `json:"result"`
62         ErrorCode   int             `json:"error_code"`
63         Description string          `json:"description"`
64         Parameters  struct {
65                 RetryAfter int `json:"retry_after"`
66         } `json:"parameters"`
67 }
68
69 // botUpdate is one update of getUpdates. Message is nil for the kinds of
70 // updates that are not a new message.
71 type botUpdate struct {
72         UpdateID int64       `json:"update_id"`
73         Message  *botMessage `json:"message"`
74 }
75
76 // botMessage is a message to the bot. Text is empty for a message that is not
77 // text, such as a photo.
78 type botMessage struct {
79         Chat botChat  `json:"chat"`
80         From *botUser `json:"from"`
81         Text string   `json:"text"`
82 }
83
84 // botChat is the chat of a message. Type is private for a chat with one user.
85 type botChat struct {
86         ID   int64  `json:"id"`
87         Type string `json:"type"`
88 }
89
90 // botUser is the sender of a message.
91 type botUser struct {
92         ID int64 `json:"id"`
93 }
94
95 // newBotAPI returns the client of the Bot API at base with token.
96 func newBotAPI(base, token string) *botAPI {
97         return &botAPI{
98                 base:        base,
99                 token:       token,
100                 http:        &http.Client{},
101                 sendTimeout: sendTimeout,
102         }
103 }
104
105 // GetUpdates waits for the updates from offset on, up to pollSeconds, and
106 // returns them. Telegram forgets each update before offset. Only new messages
107 // are asked for.
108 func (b *botAPI) GetUpdates(ctx context.Context, offset int64) ([]botUpdate, error) {
109         ctx, cancel := context.WithTimeout(ctx, pollSeconds*time.Second+pollSlack)
110         defer cancel()
111
112         var updates []botUpdate
113         err := b.call(ctx, "getUpdates", map[string]any{
114                 "offset":          offset,
115                 "timeout":         pollSeconds,
116                 "allowed_updates": []string{"message"},
117         }, &updates)
118
119         return updates, err
120 }
121
122 // SendMessage sends text to chat as plain text, with no parse mode. A call that
123 // takes longer than sendTimeout fails with errBotNetwork.
124 func (b *botAPI) SendMessage(ctx context.Context, chat int64, text string) error {
125         ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
126         defer cancel()
127
128         return b.call(ctx, "sendMessage", map[string]any{
129                 "chat_id": chat,
130                 "text":    text,
131         }, nil)
132 }
133
134 // SendTyping shows the typing action in chat for about five seconds. A call
135 // that takes longer than sendTimeout fails with errBotNetwork.
136 func (b *botAPI) SendTyping(ctx context.Context, chat int64) error {
137         ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
138         defer cancel()
139
140         return b.call(ctx, "sendChatAction", map[string]any{
141                 "chat_id": chat,
142                 "action":  "typing",
143         }, nil)
144 }
145
146 // call sends one call of method with the JSON params and decodes its result
147 // into result, when result is not nil.
148 func (b *botAPI) call(ctx context.Context, method string, params, result any) error {
149         body, err := json.Marshal(params)
150         if err != nil {
151                 return fmt.Errorf("%s: encode request: %w", method, err)
152         }
153
154         request, err := http.NewRequestWithContext(ctx, http.MethodPost, b.base+"/bot"+b.token+"/"+method, bytes.NewReader(body))
155         if err != nil {
156                 return fmt.Errorf("%s: make request: %s", method, b.redact(err))
157         }
158
159         request.Header.Set("Content-Type", "application/json")
160         response, err := b.http.Do(request)
161         if err != nil {
162                 return fmt.Errorf("%s: %w: %s", method, errBotNetwork, b.redact(err))
163         }
164
165         data, err := io.ReadAll(io.LimitReader(response.Body, botAnswerBytes))
166         err = errors.Join(err, response.Body.Close())
167         if err != nil {
168                 return fmt.Errorf("%s: %w: read answer: %s", method, errBotNetwork, b.redact(err))
169         }
170
171         var answer botAnswer
172         err = json.Unmarshal(data, &answer)
173         if err != nil && response.StatusCode != http.StatusOK {
174                 return &botError{
175                         Method:      method,
176                         Status:      response.StatusCode,
177                         Description: http.StatusText(response.StatusCode),
178                 }
179         }
180
181         if err != nil {
182                 return fmt.Errorf("%s: %w: decode answer: %s", method, errBotNetwork, b.redact(err))
183         }
184
185         if !answer.OK {
186                 return &botError{
187                         Method:      method,
188                         Status:      response.StatusCode,
189                         Description: b.redact(errors.New(answer.Description)),
190                         RetryAfter:  time.Duration(answer.Parameters.RetryAfter) * time.Second,
191                 }
192         }
193
194         if result == nil {
195                 return nil
196         }
197
198         err = json.Unmarshal(answer.Result, result)
199         if err != nil {
200                 return fmt.Errorf("%s: %w: decode result: %s", method, errBotNetwork, b.redact(err))
201         }
202
203         return nil
204 }
205
206 // redact returns the text of err without a URL and without the token. A
207 // *url.Error holds the URL of the call, which holds the token, so only its
208 // cause is kept.
209 func (b *botAPI) redact(err error) string {
210         var urlError *url.Error
211         if errors.As(err, &urlError) {
212                 err = urlError.Err
213         }
214
215         return strings.ReplaceAll(err.Error(), b.token, "TOKEN")
216 }
217
218 func (e *botError) Error() string {
219         return fmt.Sprintf("%s: Bot API answered %d: %s", e.Method, e.Status, e.Description)
220 }
221
222 // retryable tells if a later try of a failed Bot API call can work: after a
223 // network error, HTTP 429, or a server error. It returns the wait that the Bot
224 // API asks for, or 0.
225 func retryable(err error) (bool, time.Duration) {
226         if errors.Is(err, errBotNetwork) {
227                 return true, 0
228         }
229
230         var answer *botError
231         if errors.As(err, &answer) {
232                 return answer.Status == http.StatusTooManyRequests || answer.Status >= 500, answer.RetryAfter
233         }
234
235         return false, 0
236 }