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