]> git.giacomolli.uk - onechat/blob - botapi.go
f48465c3174674173cb1b8d8337932cdae5c05ba
[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 // botEntity is a message entity of sendMessage: a part of the text with a
91 // format, such as bold or a link. Offset and Length count UTF-16 code units.
92 // URL is the target of a text_link, and Language the language of a pre.
93 type botEntity struct {
94 Type string `json:"type"`
95 Offset int `json:"offset"`
96 Length int `json:"length"`
97 URL string `json:"url,omitempty"`
98 Language string `json:"language,omitempty"`
99 }
100
101 // botUser is the sender of a message.
102 type botUser struct {
103 ID int64 `json:"id"`
104 }
105
106 // newBotAPI returns the client of the Bot API at base with token.
107 func newBotAPI(base, token string) *botAPI {
108 return &botAPI{
109 base: base,
110 token: token,
111 http: &http.Client{},
112 sendTimeout: sendTimeout,
113 }
114 }
115
116 // GetUpdates waits for the updates from offset on, up to pollSeconds, and
117 // returns them. Telegram forgets each update before offset. Only new messages
118 // are asked for.
119 func (b *botAPI) GetUpdates(ctx context.Context, offset int64) ([]botUpdate, error) {
120 ctx, cancel := context.WithTimeout(ctx, pollSeconds*time.Second+pollSlack)
121 defer cancel()
122
123 var updates []botUpdate
124 err := b.call(ctx, "getUpdates", map[string]any{
125 "offset": offset,
126 "timeout": pollSeconds,
127 "allowed_updates": []string{"message"},
128 }, &updates)
129
130 return updates, err
131 }
132
133 // SendMessage sends text to chat with the format of entities, with no parse
134 // mode, so the text needs no escaping. With no entities, the text is plain. A
135 // call that takes longer than sendTimeout fails with errBotNetwork.
136 func (b *botAPI) SendMessage(ctx context.Context, chat int64, text string, entities []botEntity) error {
137 ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
138 defer cancel()
139
140 params := map[string]any{
141 "chat_id": chat,
142 "text": text,
143 }
144
145 if len(entities) > 0 {
146 params["entities"] = entities
147 }
148
149 return b.call(ctx, "sendMessage", params, nil)
150 }
151
152 // SendTyping shows the typing action in chat for about five seconds. A call
153 // that takes longer than sendTimeout fails with errBotNetwork.
154 func (b *botAPI) SendTyping(ctx context.Context, chat int64) error {
155 ctx, cancel := context.WithTimeout(ctx, b.sendTimeout)
156 defer cancel()
157
158 return b.call(ctx, "sendChatAction", map[string]any{
159 "chat_id": chat,
160 "action": "typing",
161 }, nil)
162 }
163
164 // call sends one call of method with the JSON params and decodes its result
165 // into result, when result is not nil.
166 func (b *botAPI) call(ctx context.Context, method string, params, result any) error {
167 body, err := json.Marshal(params)
168 if err != nil {
169 return fmt.Errorf("%s: encode request: %w", method, err)
170 }
171
172 request, err := http.NewRequestWithContext(ctx, http.MethodPost, b.base+"/bot"+b.token+"/"+method, bytes.NewReader(body))
173 if err != nil {
174 return fmt.Errorf("%s: make request: %s", method, b.redact(err))
175 }
176
177 request.Header.Set("Content-Type", "application/json")
178 response, err := b.http.Do(request)
179 if err != nil {
180 return fmt.Errorf("%s: %w: %s", method, errBotNetwork, b.redact(err))
181 }
182
183 data, err := io.ReadAll(io.LimitReader(response.Body, botAnswerBytes))
184 err = errors.Join(err, response.Body.Close())
185 if err != nil {
186 return fmt.Errorf("%s: %w: read answer: %s", method, errBotNetwork, b.redact(err))
187 }
188
189 var answer botAnswer
190 err = json.Unmarshal(data, &answer)
191 if err != nil && response.StatusCode != http.StatusOK {
192 return &botError{
193 Method: method,
194 Status: response.StatusCode,
195 Description: http.StatusText(response.StatusCode),
196 }
197 }
198
199 if err != nil {
200 return fmt.Errorf("%s: %w: decode answer: %s", method, errBotNetwork, b.redact(err))
201 }
202
203 if !answer.OK {
204 return &botError{
205 Method: method,
206 Status: response.StatusCode,
207 Description: b.redact(errors.New(answer.Description)),
208 RetryAfter: time.Duration(answer.Parameters.RetryAfter) * time.Second,
209 }
210 }
211
212 if result == nil {
213 return nil
214 }
215
216 err = json.Unmarshal(answer.Result, result)
217 if err != nil {
218 return fmt.Errorf("%s: %w: decode result: %s", method, errBotNetwork, b.redact(err))
219 }
220
221 return nil
222 }
223
224 // redact returns the text of err without a URL and without the token. A
225 // *url.Error holds the URL of the call, which holds the token, so only its
226 // cause is kept.
227 func (b *botAPI) redact(err error) string {
228 var urlError *url.Error
229 if errors.As(err, &urlError) {
230 err = urlError.Err
231 }
232
233 return strings.ReplaceAll(err.Error(), b.token, "TOKEN")
234 }
235
236 func (e *botError) Error() string {
237 return fmt.Sprintf("%s: Bot API answered %d: %s", e.Method, e.Status, e.Description)
238 }
239
240 // retryable tells if a later try of a failed Bot API call can work: after a
241 // network error, HTTP 429, or a server error. It returns the wait that the Bot
242 // API asks for, or 0.
243 func retryable(err error) (bool, time.Duration) {
244 if errors.Is(err, errBotNetwork) {
245 return true, 0
246 }
247
248 var answer *botError
249 if errors.As(err, &answer) {
250 return answer.Status == http.StatusTooManyRequests || answer.Status >= 500, answer.RetryAfter
251 }
252
253 return false, 0
254 }