1package telegram
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "mime/multipart"
10 "net/http"
11 "net/url"
12 "strconv"
13 "strings"
14 "time"
15)
16
17// Telegram Bot API types used by the bridge. Only the subset we need.
18
19type User struct {
20 ID int64 `json:"id"`
21 IsBot bool `json:"is_bot"`
22 FirstName string `json:"first_name"`
23 Username string `json:"username"`
24}
25
26type Chat struct {
27 ID int64 `json:"id"`
28 Type string `json:"type"` // "private" | "group" | ...
29}
30
31type PhotoSize struct {
32 FileID string `json:"file_id"`
33 FileSize int `json:"file_size,omitempty"`
34 Width int `json:"width,omitempty"`
35 Height int `json:"height,omitempty"`
36}
37
38type Document struct {
39 FileID string `json:"file_id"`
40 FileName string `json:"file_name,omitempty"`
41 MimeType string `json:"mime_type,omitempty"`
42 FileSize int `json:"file_size,omitempty"`
43}
44
45type Message struct {
46 MessageID int `json:"message_id"`
47 From *User `json:"from"`
48 Chat Chat `json:"chat"`
49 Date int64 `json:"date"`
50 Text string `json:"text,omitempty"`
51 Caption string `json:"caption,omitempty"`
52 Photo []PhotoSize `json:"photo,omitempty"`
53 Document *Document `json:"document,omitempty"`
54}
55
56type Update struct {
57 UpdateID int64 `json:"update_id"`
58 Message *Message `json:"message"`
59 Edited *Message `json:"edited_message"`
60}
61
62type File struct {
63 FileID string `json:"file_id"`
64 FilePath string `json:"file_path"`
65 FileSize int `json:"file_size,omitempty"`
66}
67
68// apiResponse is Telegram's envelope.
69type apiResponse[T any] struct {
70 OK bool `json:"ok"`
71 Description string `json:"description"`
72 Result T `json:"result"`
73}
74
75// Client is a minimal Telegram Bot API client.
76type Client struct {
77 token string
78 http *http.Client
79}
80
81func NewClient(token string) *Client {
82 return &Client{token: token, http: &http.Client{Timeout: 0}}
83}
84
85func (c *Client) baseURL() string { return "https://api.telegram.org/bot" + c.token }
86
87// GetMe verifies the token and returns the bot's own User.
88func (c *Client) GetMe(ctx context.Context) (*User, error) {
89 var resp apiResponse[User]
90 if err := c.call(ctx, "getMe", nil, &resp); err != nil {
91 return nil, err
92 }
93 if !resp.OK {
94 return nil, fmt.Errorf("getMe: %s", resp.Description)
95 }
96 return &resp.Result, nil
97}
98
99// DeleteWebhook removes any webhook so the bot can receive updates through
100// long polling. Pending updates are preserved unless dropPendingUpdates is true.
101func (c *Client) DeleteWebhook(ctx context.Context, dropPendingUpdates bool) error {
102 body := map[string]any{"drop_pending_updates": dropPendingUpdates}
103 var resp apiResponse[bool]
104 if err := c.call(ctx, "deleteWebhook", body, &resp); err != nil {
105 return err
106 }
107 if !resp.OK {
108 return fmt.Errorf("deleteWebhook: %s", resp.Description)
109 }
110 if !resp.Result {
111 return fmt.Errorf("deleteWebhook: telegram did not remove the webhook")
112 }
113 return nil
114}
115
116// GetUpdates polls for new updates since offset with a long-poll timeout.
117func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSec int) ([]Update, error) {
118 q := url.Values{}
119 if offset != 0 {
120 q.Set("offset", strconv.FormatInt(offset, 10))
121 }
122 q.Set("timeout", strconv.Itoa(timeoutSec))
123 q.Set("allowed_updates", `["message","edited_message"]`)
124
125 var resp apiResponse[[]Update]
126 if err := c.call(ctx, "getUpdates?"+q.Encode(), nil, &resp); err != nil {
127 return nil, err
128 }
129 if !resp.OK {
130 return nil, fmt.Errorf("getUpdates: %s", resp.Description)
131 }
132 return resp.Result, nil
133}
134
135// SendMessage sends a plain-text reply.
136func (c *Client) SendMessage(ctx context.Context, chatID int64, text string, replyTo int) error {
137 body := map[string]any{
138 "chat_id": chatID,
139 "text": text,
140 }
141 if replyTo > 0 {
142 body["reply_to_message_id"] = replyTo
143 }
144 var resp apiResponse[json.RawMessage]
145 if err := c.call(ctx, "sendMessage", body, &resp); err != nil {
146 return err
147 }
148 if !resp.OK {
149 return fmt.Errorf("sendMessage: %s", resp.Description)
150 }
151 return nil
152}
153
154// SendChatAction keeps the "typing..." indicator alive. Call every ~4s.
155func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string) error {
156 body := map[string]any{"chat_id": chatID, "action": action}
157 var resp apiResponse[json.RawMessage]
158 _ = c.call(ctx, "sendChatAction", body, &resp) // ignore errors; it's advisory
159 return nil
160}
161
162// SendPhoto uploads a local image file as a Telegram photo. Telegram
163// re-encodes / scales photos for inline preview; use SendDocument
164// when the recipient needs the original bytes.
165func (c *Client) SendPhoto(ctx context.Context, chatID int64, path, caption string) error {
166 f, err := openFile(path)
167 if err != nil {
168 return err
169 }
170 defer f.Close()
171
172 var buf bytes.Buffer
173 w := multipart.NewWriter(&buf)
174 _ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
175 if caption != "" {
176 _ = w.WriteField("caption", caption)
177 }
178 part, err := w.CreateFormFile("photo", lastPathElem(path))
179 if err != nil {
180 return err
181 }
182 if _, err := io.Copy(part, f); err != nil {
183 return err
184 }
185 w.Close()
186
187 req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendPhoto", &buf)
188 if err != nil {
189 return err
190 }
191 req.Header.Set("content-type", w.FormDataContentType())
192 resp, err := c.http.Do(req)
193 if err != nil {
194 return err
195 }
196 defer resp.Body.Close()
197 if resp.StatusCode >= 400 {
198 body, _ := io.ReadAll(resp.Body)
199 return fmt.Errorf("sendPhoto http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
200 }
201 return nil
202}
203
204// SendDocument uploads a local file as a document attachment.
205func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error {
206 f, err := openFile(path)
207 if err != nil {
208 return err
209 }
210 defer f.Close()
211
212 var buf bytes.Buffer
213 w := multipart.NewWriter(&buf)
214 _ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
215 if caption != "" {
216 _ = w.WriteField("caption", caption)
217 }
218 part, err := w.CreateFormFile("document", lastPathElem(path))
219 if err != nil {
220 return err
221 }
222 if _, err := io.Copy(part, f); err != nil {
223 return err
224 }
225 w.Close()
226
227 req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendDocument", &buf)
228 if err != nil {
229 return err
230 }
231 req.Header.Set("content-type", w.FormDataContentType())
232 resp, err := c.http.Do(req)
233 if err != nil {
234 return err
235 }
236 defer resp.Body.Close()
237 if resp.StatusCode >= 400 {
238 body, _ := io.ReadAll(resp.Body)
239 return fmt.Errorf("sendDocument http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
240 }
241 return nil
242}
243
244// GetFile resolves a file_id to a downloadable path.
245func (c *Client) GetFile(ctx context.Context, fileID string) (*File, error) {
246 q := url.Values{}
247 q.Set("file_id", fileID)
248 var resp apiResponse[File]
249 if err := c.call(ctx, "getFile?"+q.Encode(), nil, &resp); err != nil {
250 return nil, err
251 }
252 if !resp.OK {
253 return nil, fmt.Errorf("getFile: %s", resp.Description)
254 }
255 return &resp.Result, nil
256}
257
258// DownloadFile downloads the file at filePath (from GetFile) into memory.
259func (c *Client) DownloadFile(ctx context.Context, filePath string) ([]byte, error) {
260 u := "https://api.telegram.org/file/bot" + c.token + "/" + filePath
261 req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
262 if err != nil {
263 return nil, err
264 }
265 resp, err := c.http.Do(req)
266 if err != nil {
267 return nil, err
268 }
269 defer resp.Body.Close()
270 if resp.StatusCode != 200 {
271 return nil, fmt.Errorf("download %s: http %d", filePath, resp.StatusCode)
272 }
273 return io.ReadAll(resp.Body)
274}
275
276// call issues a GET or POST request depending on whether body is nil.
277func (c *Client) call(ctx context.Context, endpoint string, body map[string]any, out any) error {
278 var req *http.Request
279 var err error
280 if body == nil {
281 req, err = http.NewRequestWithContext(ctx, "GET", c.baseURL()+"/"+endpoint, nil)
282 } else {
283 b, _ := json.Marshal(body)
284 req, err = http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/"+endpoint, bytes.NewReader(b))
285 if err == nil {
286 req.Header.Set("content-type", "application/json")
287 }
288 }
289 if err != nil {
290 return err
291 }
292 resp, err := c.http.Do(req)
293 if err != nil {
294 return err
295 }
296 defer resp.Body.Close()
297 // Telegram returns 200 with ok=false for logical errors; read either way.
298 respBody, _ := io.ReadAll(resp.Body)
299 if len(respBody) == 0 {
300 return fmt.Errorf("%s: empty response (status %d)", endpoint, resp.StatusCode)
301 }
302 return json.Unmarshal(respBody, out)
303}
304
305// small helpers kept here so api.go has no other deps.
306func lastPathElem(p string) string {
307 for i := len(p) - 1; i >= 0; i-- {
308 if p[i] == '/' || p[i] == '\\' {
309 return p[i+1:]
310 }
311 }
312 return p
313}
314
315func openFile(path string) (io.ReadCloser, error) {
316 return osOpen(path)
317}
318
319// overridden in tests.
320var osOpen = defaultOpen
321
322// Sleep is a package-level hook so tests can stub it.
323var sleep = func(d time.Duration) { time.Sleep(d) }