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// GetUpdates polls for new updates since offset with a long-poll timeout.
100func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSec int) ([]Update, error) {
101 q := url.Values{}
102 if offset != 0 {
103 q.Set("offset", strconv.FormatInt(offset, 10))
104 }
105 q.Set("timeout", strconv.Itoa(timeoutSec))
106 q.Set("allowed_updates", `["message","edited_message"]`)
107
108 var resp apiResponse[[]Update]
109 if err := c.call(ctx, "getUpdates?"+q.Encode(), nil, &resp); err != nil {
110 return nil, err
111 }
112 if !resp.OK {
113 return nil, fmt.Errorf("getUpdates: %s", resp.Description)
114 }
115 return resp.Result, nil
116}
117
118// SendMessage sends a plain-text reply.
119func (c *Client) SendMessage(ctx context.Context, chatID int64, text string, replyTo int) error {
120 body := map[string]any{
121 "chat_id": chatID,
122 "text": text,
123 }
124 if replyTo > 0 {
125 body["reply_to_message_id"] = replyTo
126 }
127 var resp apiResponse[json.RawMessage]
128 if err := c.call(ctx, "sendMessage", body, &resp); err != nil {
129 return err
130 }
131 if !resp.OK {
132 return fmt.Errorf("sendMessage: %s", resp.Description)
133 }
134 return nil
135}
136
137// SendChatAction keeps the "typing..." indicator alive. Call every ~4s.
138func (c *Client) SendChatAction(ctx context.Context, chatID int64, action string) error {
139 body := map[string]any{"chat_id": chatID, "action": action}
140 var resp apiResponse[json.RawMessage]
141 _ = c.call(ctx, "sendChatAction", body, &resp) // ignore errors; it's advisory
142 return nil
143}
144
145// SendPhoto uploads a local image file as a Telegram photo. Telegram
146// re-encodes / scales photos for inline preview; use SendDocument
147// when the recipient needs the original bytes.
148func (c *Client) SendPhoto(ctx context.Context, chatID int64, path, caption string) error {
149 f, err := openFile(path)
150 if err != nil {
151 return err
152 }
153 defer f.Close()
154
155 var buf bytes.Buffer
156 w := multipart.NewWriter(&buf)
157 _ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
158 if caption != "" {
159 _ = w.WriteField("caption", caption)
160 }
161 part, err := w.CreateFormFile("photo", lastPathElem(path))
162 if err != nil {
163 return err
164 }
165 if _, err := io.Copy(part, f); err != nil {
166 return err
167 }
168 w.Close()
169
170 req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendPhoto", &buf)
171 if err != nil {
172 return err
173 }
174 req.Header.Set("content-type", w.FormDataContentType())
175 resp, err := c.http.Do(req)
176 if err != nil {
177 return err
178 }
179 defer resp.Body.Close()
180 if resp.StatusCode >= 400 {
181 body, _ := io.ReadAll(resp.Body)
182 return fmt.Errorf("sendPhoto http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
183 }
184 return nil
185}
186
187// SendDocument uploads a local file as a document attachment.
188func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error {
189 f, err := openFile(path)
190 if err != nil {
191 return err
192 }
193 defer f.Close()
194
195 var buf bytes.Buffer
196 w := multipart.NewWriter(&buf)
197 _ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
198 if caption != "" {
199 _ = w.WriteField("caption", caption)
200 }
201 part, err := w.CreateFormFile("document", lastPathElem(path))
202 if err != nil {
203 return err
204 }
205 if _, err := io.Copy(part, f); err != nil {
206 return err
207 }
208 w.Close()
209
210 req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendDocument", &buf)
211 if err != nil {
212 return err
213 }
214 req.Header.Set("content-type", w.FormDataContentType())
215 resp, err := c.http.Do(req)
216 if err != nil {
217 return err
218 }
219 defer resp.Body.Close()
220 if resp.StatusCode >= 400 {
221 body, _ := io.ReadAll(resp.Body)
222 return fmt.Errorf("sendDocument http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
223 }
224 return nil
225}
226
227// GetFile resolves a file_id to a downloadable path.
228func (c *Client) GetFile(ctx context.Context, fileID string) (*File, error) {
229 q := url.Values{}
230 q.Set("file_id", fileID)
231 var resp apiResponse[File]
232 if err := c.call(ctx, "getFile?"+q.Encode(), nil, &resp); err != nil {
233 return nil, err
234 }
235 if !resp.OK {
236 return nil, fmt.Errorf("getFile: %s", resp.Description)
237 }
238 return &resp.Result, nil
239}
240
241// DownloadFile downloads the file at filePath (from GetFile) into memory.
242func (c *Client) DownloadFile(ctx context.Context, filePath string) ([]byte, error) {
243 u := "https://api.telegram.org/file/bot" + c.token + "/" + filePath
244 req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
245 if err != nil {
246 return nil, err
247 }
248 resp, err := c.http.Do(req)
249 if err != nil {
250 return nil, err
251 }
252 defer resp.Body.Close()
253 if resp.StatusCode != 200 {
254 return nil, fmt.Errorf("download %s: http %d", filePath, resp.StatusCode)
255 }
256 return io.ReadAll(resp.Body)
257}
258
259// call issues a GET or POST request depending on whether body is nil.
260func (c *Client) call(ctx context.Context, endpoint string, body map[string]any, out any) error {
261 var req *http.Request
262 var err error
263 if body == nil {
264 req, err = http.NewRequestWithContext(ctx, "GET", c.baseURL()+"/"+endpoint, nil)
265 } else {
266 b, _ := json.Marshal(body)
267 req, err = http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/"+endpoint, bytes.NewReader(b))
268 if err == nil {
269 req.Header.Set("content-type", "application/json")
270 }
271 }
272 if err != nil {
273 return err
274 }
275 resp, err := c.http.Do(req)
276 if err != nil {
277 return err
278 }
279 defer resp.Body.Close()
280 // Telegram returns 200 with ok=false for logical errors; read either way.
281 respBody, _ := io.ReadAll(resp.Body)
282 if len(respBody) == 0 {
283 return fmt.Errorf("%s: empty response (status %d)", endpoint, resp.StatusCode)
284 }
285 return json.Unmarshal(respBody, out)
286}
287
288// small helpers kept here so api.go has no other deps.
289func lastPathElem(p string) string {
290 for i := len(p) - 1; i >= 0; i-- {
291 if p[i] == '/' || p[i] == '\\' {
292 return p[i+1:]
293 }
294 }
295 return p
296}
297
298func openFile(path string) (io.ReadCloser, error) {
299 return osOpen(path)
300}
301
302// overridden in tests.
303var osOpen = defaultOpen
304
305// Sleep is a package-level hook so tests can stub it.
306var sleep = func(d time.Duration) { time.Sleep(d) }