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// SendDocument uploads a local file as a document attachment.
146func (c *Client) SendDocument(ctx context.Context, chatID int64, path, caption string) error {
147 f, err := openFile(path)
148 if err != nil {
149 return err
150 }
151 defer f.Close()
152
153 var buf bytes.Buffer
154 w := multipart.NewWriter(&buf)
155 _ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
156 if caption != "" {
157 _ = w.WriteField("caption", caption)
158 }
159 part, err := w.CreateFormFile("document", lastPathElem(path))
160 if err != nil {
161 return err
162 }
163 if _, err := io.Copy(part, f); err != nil {
164 return err
165 }
166 w.Close()
167
168 req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/sendDocument", &buf)
169 if err != nil {
170 return err
171 }
172 req.Header.Set("content-type", w.FormDataContentType())
173 resp, err := c.http.Do(req)
174 if err != nil {
175 return err
176 }
177 defer resp.Body.Close()
178 if resp.StatusCode >= 400 {
179 body, _ := io.ReadAll(resp.Body)
180 return fmt.Errorf("sendDocument http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
181 }
182 return nil
183}
184
185// GetFile resolves a file_id to a downloadable path.
186func (c *Client) GetFile(ctx context.Context, fileID string) (*File, error) {
187 q := url.Values{}
188 q.Set("file_id", fileID)
189 var resp apiResponse[File]
190 if err := c.call(ctx, "getFile?"+q.Encode(), nil, &resp); err != nil {
191 return nil, err
192 }
193 if !resp.OK {
194 return nil, fmt.Errorf("getFile: %s", resp.Description)
195 }
196 return &resp.Result, nil
197}
198
199// DownloadFile downloads the file at filePath (from GetFile) into memory.
200func (c *Client) DownloadFile(ctx context.Context, filePath string) ([]byte, error) {
201 u := "https://api.telegram.org/file/bot" + c.token + "/" + filePath
202 req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
203 if err != nil {
204 return nil, err
205 }
206 resp, err := c.http.Do(req)
207 if err != nil {
208 return nil, err
209 }
210 defer resp.Body.Close()
211 if resp.StatusCode != 200 {
212 return nil, fmt.Errorf("download %s: http %d", filePath, resp.StatusCode)
213 }
214 return io.ReadAll(resp.Body)
215}
216
217// call issues a GET or POST request depending on whether body is nil.
218func (c *Client) call(ctx context.Context, endpoint string, body map[string]any, out any) error {
219 var req *http.Request
220 var err error
221 if body == nil {
222 req, err = http.NewRequestWithContext(ctx, "GET", c.baseURL()+"/"+endpoint, nil)
223 } else {
224 b, _ := json.Marshal(body)
225 req, err = http.NewRequestWithContext(ctx, "POST", c.baseURL()+"/"+endpoint, bytes.NewReader(b))
226 if err == nil {
227 req.Header.Set("content-type", "application/json")
228 }
229 }
230 if err != nil {
231 return err
232 }
233 resp, err := c.http.Do(req)
234 if err != nil {
235 return err
236 }
237 defer resp.Body.Close()
238 // Telegram returns 200 with ok=false for logical errors; read either way.
239 respBody, _ := io.ReadAll(resp.Body)
240 if len(respBody) == 0 {
241 return fmt.Errorf("%s: empty response (status %d)", endpoint, resp.StatusCode)
242 }
243 return json.Unmarshal(respBody, out)
244}
245
246// small helpers kept here so api.go has no other deps.
247func lastPathElem(p string) string {
248 for i := len(p) - 1; i >= 0; i-- {
249 if p[i] == '/' || p[i] == '\\' {
250 return p[i+1:]
251 }
252 }
253 return p
254}
255
256func openFile(path string) (io.ReadCloser, error) {
257 return osOpen(path)
258}
259
260// overridden in tests.
261var osOpen = defaultOpen
262
263// Sleep is a package-level hook so tests can stub it.
264var sleep = func(d time.Duration) { time.Sleep(d) }