-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcapability.go
More file actions
241 lines (228 loc) · 8.19 KB
/
Copy pathcapability.go
File metadata and controls
241 lines (228 loc) · 8.19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
// SPDX-License-Identifier: Apache-2.0
package tg
import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"
)
// A Bot API server does not report its version anywhere a bot can read it:
// getMe says nothing about it, the statistics port carries uptime and memory
// but no version, and a containerized server logs nothing at all. What it does
// report, unambiguously, is whether a method exists:
//
// POST sendMessage {} -> 400 Bad Request: message text is empty
// POST sendRichMessage {} -> 404 Not Found: method not found
//
// So the way to find out whether a server can serve this bot is to ask it for
// the methods the bot cannot work without, before the bot starts working.
// probeSafe lists methods that cannot do anything with an empty body: they all
// fail parameter validation first. Probing is a real call, so a method that
// could succeed on its own must never appear here — getMe would simply run,
// deleteWebhook would drop a webhook, and logOut would detach the bot from the
// server and start a ten-minute cooldown.
var probeSafe = map[string]bool{
"answerCallbackQuery": true,
"answerInlineQuery": true,
"copyMessage": true,
"deleteEphemeralMessage": true,
"deleteMessage": true,
"editEphemeralMessageText": true,
"editMessageCaption": true,
"editMessageMedia": true,
"editMessageReplyMarkup": true,
"editMessageText": true,
"forwardMessage": true,
"getChat": true,
"getChatMember": true,
"getFile": true,
"sendAudio": true,
"sendChatAction": true,
"sendDocument": true,
"sendMediaGroup": true,
"sendMessage": true,
"sendMessageDraft": true,
"sendPhoto": true,
"sendRichMessage": true,
"sendRichMessageDraft": true,
"sendVideo": true,
"sendVoice": true,
"setMessageReaction": true,
}
// MissingMethodsError reports methods the server does not implement. It is
// what a bot should refuse to start on.
type MissingMethodsError struct {
// Methods are the missing method names, sorted.
Methods []string
// APIBase is the server that was asked.
APIBase string
}
func (e *MissingMethodsError) Error() string {
return fmt.Sprintf("telegram server %s does not implement: %s (this bot is written against Bot API %s)",
e.APIBase, strings.Join(e.Methods, ", "), BotAPI)
}
// Probe reports which of the given methods the server does not implement. A
// method is asked for with an empty body: an existing method rejects that on
// its parameters, a missing one answers "method not found".
//
// Every method must be in the safe list, otherwise Probe refuses without
// making any call: probing is not a dry run, and a method that works without
// parameters would actually execute.
func (c *Client) Probe(ctx context.Context, methods ...string) ([]string, error) {
for _, m := range methods {
if !probeSafe[m] {
return nil, fmt.Errorf("refusing to probe %q: probing calls the method for real, and only methods that fail without parameters are safe to ask for", m)
}
}
var missing []string
for _, m := range methods {
err := c.probeOnce(ctx, m)
switch {
case err == nil:
// The method exists and, surprisingly, accepted an empty body.
continue
case IsMethodNotFound(err):
missing = append(missing, m)
case isInconclusive(err):
// The server never got as far as looking at the method, so its
// answer says nothing about whether the method exists. Reporting
// "present" here would defeat the point of asking.
return nil, fmt.Errorf("cannot tell whether %s exists: %w", m, err)
case errors.As(err, new(*APIError)):
// Any other API answer -- a 400 on the empty body, most often --
// means the method is there.
continue
default:
return nil, err
}
}
sort.Strings(missing)
return missing, nil
}
// probeOnce asks for one method, retrying a connection that never answered.
// An ordinary POST is not retried, because a lost answer may still have been
// acted on -- but a probe carries an empty body, so the method cannot have
// done anything, and replaying it is free. Without this, one dropped
// connection at startup reads as "the server lacks this method".
func (c *Client) probeOnce(ctx context.Context, method string) error {
const attempts = 3
ctx = withProbe(ctx)
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
var discard map[string]any
err := c.post(ctx, method, map[string]any{}, &discard)
if err == nil || errors.As(err, new(*APIError)) {
return err
}
lastErr = err
if ctx.Err() != nil || attempt == attempts {
return err
}
if err := c.sleep(ctx, retryDelay(attempt)); err != nil {
return lastErr
}
}
return lastErr
}
// isInconclusive reports an answer that is about the caller rather than the
// method: rejected credentials, or a proxy refusing on the server's behalf.
func isInconclusive(err error) bool {
var apiErr *APIError
if !errors.As(err, &apiErr) {
return false
}
switch apiErr.code() {
case http.StatusUnauthorized, http.StatusForbidden:
return true
}
// A 404 that is not Telegram's "method not found" came from something in
// front of the server, not from the server.
return apiErr.code() == http.StatusNotFound && !IsMethodNotFound(err)
}
// Require is Probe as a single error.
func (c *Client) Require(ctx context.Context, methods ...string) error {
missing, err := c.Probe(ctx, methods...)
if err != nil {
return err
}
if len(missing) > 0 {
return &MissingMethodsError{Methods: missing, APIBase: c.apiBase}
}
return nil
}
// Needs describes what a bot cannot run without.
type Needs struct {
// Methods are Bot API methods whose absence makes the bot useless. They
// must be probe-safe; see [Client.Probe].
Methods []string
// Files requires a readable and writable data directory for this bot on a
// --local server; see [WithLocalFiles].
Files bool
// Wait allows the server to still be starting up: getMe is retried until
// this much time has passed. A self-hosted server shares a lifecycle with
// the bot and often loses the race by a few seconds.
Wait time.Duration
}
// Preflight is the one call a bot makes at startup. It answers the question
// that today's failure modes hide: can the server on the other end actually
// serve this bot? It returns the bot's own identity, which the caller needs
// anyway for @mentions and command scoping.
//
// A bot should treat any error from Preflight as fatal. Failing to start with
// a named cause is the point: the alternative is a bot that polls happily and
// answers nothing.
func (c *Client) Preflight(ctx context.Context, n Needs) (Me, error) {
me, err := c.getMeWaiting(ctx, n.Wait)
if err != nil {
return Me{}, err
}
if len(n.Methods) > 0 {
if err := c.Require(ctx, n.Methods...); err != nil {
return Me{}, err
}
}
if n.Files {
if err := c.VerifyLocalFiles(); err != nil {
return Me{}, err
}
}
return me, nil
}
// getMeWaiting retries getMe until wait has passed. A rejected token will not
// become valid, so an answer like 401 ends it immediately; a server that is
// still starting answers with a connection failure, a 429 or a 5xx, and those
// are exactly what the wait is for.
func (c *Client) getMeWaiting(ctx context.Context, wait time.Duration) (Me, error) {
if wait <= 0 {
return c.GetMe(ctx)
}
// Wait bounds the whole thing, not just the pauses between tries. GetMe
// retries internally and each attempt carries its own timeout, so without
// this a 30-second budget could run for minutes -- and an operator sizes a
// restart policy on the number they set.
waitCtx, cancel := context.WithTimeout(ctx, wait)
defer cancel()
delay := 500 * time.Millisecond
var lastErr error
for attempt := 1; ; attempt++ {
me, err := c.GetMe(waitCtx)
if err == nil {
return me, nil
}
lastErr = err
if !retryableError(err) || waitCtx.Err() != nil {
return Me{}, lastErr
}
c.log.Warn("telegram bot api not ready, retrying getMe",
"attempt", attempt, "retry_in", delay.String(), "error", err)
if waitErr := c.sleep(waitCtx, delay); waitErr != nil {
return Me{}, lastErr
}
if delay < 5*time.Second {
delay = min(2*delay, 5*time.Second)
}
}
}