/
client.go
216 lines (174 loc) · 4.84 KB
/
client.go
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
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/ratelimit"
"go.uber.org/zap"
)
const (
// In case of an error, the Client will retry sending the message.
retryInterval = 30 * time.Second
// Telegram API limits.
msgsPerMinute = 20
)
var (
errBadResponse = errors.New("bad response")
errAPIError = errors.New("API error")
errRateLimited = errors.New("rate limited")
)
type ClientConfig struct {
// APIURL is the Telegram API URL. Optional.
APIURL string
// Token is the Telegram Bot API token.
Token string
// ChatID is the Telegram chat ID.
ChatID string
// Retries is the number of retries to call the Telegram API.
Retries int
// Timeout is the timeout for the HTTP Client.
// Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
Timeout time.Duration
}
func (c ClientConfig) Validate() error {
var errs []error
if c.APIURL == "" {
errs = append(errs, errors.New("API URL is required"))
}
if c.Token == "" {
errs = append(errs, errors.New("token is required"))
}
if c.ChatID == "" {
errs = append(errs, errors.New("chat ID is required"))
}
return errors.Join(errs...)
}
// clientResponse is the Telegram API response.
type clientResponse struct {
Ok bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
Parameters *struct {
RetryAfter int `json:"retry_after"`
} `json:"parameters"`
}
// Client is a Telegram client.
// It is used to send messages to a Telegram chat.
type Client struct {
httpClient *http.Client
baseURL *url.URL
cfg ClientConfig
limiter ratelimit.Limiter
logger *zap.Logger
// sleep is used to mock time.Sleep in tests.
sleep func(time.Duration)
}
// NewClient creates a new Telegram client.
func NewClient(logger *zap.Logger, cfg ClientConfig, limiterOpts ...ratelimit.Option) (*Client, error) {
baseURL, err := url.Parse(cfg.APIURL)
if err != nil {
return nil, fmt.Errorf("failed to parse API URL: %w", err)
}
limiterOpts = append(limiterOpts, ratelimit.Per(time.Minute), ratelimit.WithoutSlack)
limiter := ratelimit.New(msgsPerMinute, limiterOpts...)
return &Client{
httpClient: &http.Client{
Timeout: time.Second * 10,
},
baseURL: baseURL,
cfg: cfg,
limiter: limiter,
logger: logger,
sleep: time.Sleep,
}, nil
}
// SendMessage sends a message to a Telegram chat.
func (c *Client) SendMessage(text string) error {
c.limiter.Take()
u := c.baseURL
u.Path = fmt.Sprintf("/bot%s/sendMessage", c.cfg.Token)
// Values for form data.
values := url.Values{
"chat_id": {c.cfg.ChatID},
"text": {text},
}
var lastErr error
for attempt := 0; attempt < c.cfg.Retries+1; attempt++ {
resp, err := c.sendMessage(u.String(), values)
if err != nil {
if errors.Is(err, errBadResponse) {
lastErr = err
continue
}
return fmt.Errorf("failed to send message: %w", err)
}
if resp.Ok {
return nil
}
switch resp.ErrorCode {
case http.StatusTooManyRequests:
lastErr = fmt.Errorf("%s: %w", resp.Description, errRateLimited)
retryAfter := retryInterval
if resp.Parameters != nil {
retryAfter = time.Duration(resp.Parameters.RetryAfter) * time.Second
}
c.logger.Debug("Telegram API rate limit exceeded",
zap.Duration("retry_after", retryAfter),
)
if attempt < c.cfg.Retries {
c.sleep(retryAfter)
}
default:
return fmt.Errorf("%s: %w", resp.Description, errAPIError)
}
}
return fmt.Errorf("max attempts exceeded: %w", lastErr)
}
func (c *Client) Ping() error {
u := c.baseURL
u.Path = fmt.Sprintf("/bot%s/getChat", c.cfg.Token)
values := url.Values{
"chat_id": {c.cfg.ChatID},
}
var lastErr error
for attempt := 0; attempt < c.cfg.Retries+1; attempt++ {
resp, err := c.sendMessage(u.String(), values)
if err != nil {
if errors.Is(err, errBadResponse) {
lastErr = err
continue
}
return fmt.Errorf("failed to send message: %w", err)
}
if resp.Ok {
return nil
}
lastErr = fmt.Errorf("%s: %w", resp.Description, errAPIError)
}
return fmt.Errorf("max attempts exceeded: %w", lastErr)
}
func (c *Client) sendMessage(url string, values url.Values) (*clientResponse, error) {
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(values.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
var body clientResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", errors.Join(err, errBadResponse))
}
return &body, nil
}