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