Skip to content

Commit ad5334f

Browse files
committed
Phase 1: Telegram core infrastructure — types, bot client, poller, formatter, handler, commands
internal/telegram/ (1,429 LOC across 6 files): - types.go: 21 Telegram Bot API types with json tags + SendOpts - bot.go: HTTP client with 10 API methods (send, receive, upload, callback) - poller.go: Long-poll update fetcher with offset tracking + backoff - format.go: MarkdownV2 converter with 4096-byte chunking, escaping, table→code - handler.go: Message router with allowlists, command/media/callback dispatch - commands.go: 6 slash commands (/start, /help, /new, /stats, /stop, /mode)
1 parent b8bb2be commit ad5334f

6 files changed

Lines changed: 1429 additions & 0 deletions

File tree

internal/telegram/bot.go

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
package telegram
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"mime/multipart"
9+
"net/http"
10+
"os"
11+
"path/filepath"
12+
"time"
13+
)
14+
15+
// Bot represents a Telegram Bot API client.
16+
type Bot struct {
17+
Token string
18+
BaseURL string
19+
Client *http.Client
20+
}
21+
22+
// NewBot creates a new Bot with the given token and a default HTTP client
23+
// with a 30-second timeout.
24+
func NewBot(token string) *Bot {
25+
return &Bot{
26+
Token: token,
27+
BaseURL: fmt.Sprintf("https://api.telegram.org/bot%s", token),
28+
Client: &http.Client{
29+
Timeout: 30 * time.Second,
30+
},
31+
}
32+
}
33+
34+
// url builds the full API endpoint URL for the given method.
35+
func (b *Bot) url(method string) string {
36+
return fmt.Sprintf("%s/%s", b.BaseURL, method)
37+
}
38+
39+
// doJSON marshals the request body, sends a POST request, and unmarshals
40+
// the "result" field of the response into the provided destination.
41+
func (b *Bot) doJSON(method string, body any, dest any) error {
42+
var reqBody []byte
43+
if body != nil {
44+
var err error
45+
reqBody, err = json.Marshal(body)
46+
if err != nil {
47+
return fmt.Errorf("telegram: marshal request: %w", err)
48+
}
49+
}
50+
51+
url := b.url(method)
52+
resp, err := b.Client.Post(url, "application/json", bytes.NewReader(reqBody))
53+
if err != nil {
54+
return fmt.Errorf("telegram: post %s: %w", method, err)
55+
}
56+
defer resp.Body.Close()
57+
58+
respBody, err := io.ReadAll(resp.Body)
59+
if err != nil {
60+
return fmt.Errorf("telegram: read response: %w", err)
61+
}
62+
63+
var apiResp struct {
64+
OK bool `json:"ok"`
65+
Result json.RawMessage `json:"result"`
66+
Description string `json:"description"`
67+
ErrorCode int `json:"error_code"`
68+
}
69+
if err := json.Unmarshal(respBody, &apiResp); err != nil {
70+
return fmt.Errorf("telegram: unmarshal response: %w", err)
71+
}
72+
73+
if !apiResp.OK {
74+
return fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
75+
}
76+
77+
if dest != nil && len(apiResp.Result) > 0 {
78+
if err := json.Unmarshal(apiResp.Result, dest); err != nil {
79+
return fmt.Errorf("telegram: unmarshal result: %w", err)
80+
}
81+
}
82+
83+
return nil
84+
}
85+
86+
// doUpload sends a multipart/form-data POST request with a file and optional parameters.
87+
func (b *Bot) doUpload(method string, field string, path string, params map[string]any, dest any) error {
88+
file, err := os.Open(path)
89+
if err != nil {
90+
return fmt.Errorf("telegram: open file %s: %w", path, err)
91+
}
92+
defer file.Close()
93+
94+
var buf bytes.Buffer
95+
writer := multipart.NewWriter(&buf)
96+
97+
// Write the file part.
98+
part, err := writer.CreateFormFile(field, filepath.Base(path))
99+
if err != nil {
100+
return fmt.Errorf("telegram: create form file: %w", err)
101+
}
102+
if _, err := io.Copy(part, file); err != nil {
103+
return fmt.Errorf("telegram: copy file content: %w", err)
104+
}
105+
106+
// Write extra parameters as JSON parts.
107+
for key, val := range params {
108+
jsonVal, err := json.Marshal(val)
109+
if err != nil {
110+
return fmt.Errorf("telegram: marshal param %s: %w", key, err)
111+
}
112+
if err := writer.WriteField(key, string(jsonVal)); err != nil {
113+
return fmt.Errorf("telegram: write field %s: %w", key, err)
114+
}
115+
}
116+
117+
if err := writer.Close(); err != nil {
118+
return fmt.Errorf("telegram: close multipart writer: %w", err)
119+
}
120+
121+
url := b.url(method)
122+
req, err := http.NewRequest(http.MethodPost, url, &buf)
123+
if err != nil {
124+
return fmt.Errorf("telegram: create request: %w", err)
125+
}
126+
req.Header.Set("Content-Type", writer.FormDataContentType())
127+
128+
resp, err := b.Client.Do(req)
129+
if err != nil {
130+
return fmt.Errorf("telegram: post %s: %w", method, err)
131+
}
132+
defer resp.Body.Close()
133+
134+
respBody, err := io.ReadAll(resp.Body)
135+
if err != nil {
136+
return fmt.Errorf("telegram: read response: %w", err)
137+
}
138+
139+
var apiResp struct {
140+
OK bool `json:"ok"`
141+
Result json.RawMessage `json:"result"`
142+
Description string `json:"description"`
143+
ErrorCode int `json:"error_code"`
144+
}
145+
if err := json.Unmarshal(respBody, &apiResp); err != nil {
146+
return fmt.Errorf("telegram: unmarshal response: %w", err)
147+
}
148+
149+
if !apiResp.OK {
150+
return fmt.Errorf("telegram: %s failed: %s (code %d)", method, apiResp.Description, apiResp.ErrorCode)
151+
}
152+
153+
if dest != nil && len(apiResp.Result) > 0 {
154+
if err := json.Unmarshal(apiResp.Result, dest); err != nil {
155+
return fmt.Errorf("telegram: unmarshal result: %w", err)
156+
}
157+
}
158+
159+
return nil
160+
}
161+
162+
// SendMessage sends a text message to the specified chat.
163+
func (b *Bot) SendMessage(chatID int64, text string, opts *SendOpts) (*Message, error) {
164+
params := map[string]any{
165+
"chat_id": chatID,
166+
"text": text,
167+
}
168+
if opts != nil {
169+
if opts.ParseMode != "" {
170+
params["parse_mode"] = opts.ParseMode
171+
}
172+
if opts.ReplyMarkup != nil {
173+
params["reply_markup"] = opts.ReplyMarkup
174+
}
175+
if opts.DisableWebPagePreview {
176+
params["disable_web_page_preview"] = true
177+
}
178+
if opts.ReplyToMessageID != 0 {
179+
params["reply_to_message_id"] = opts.ReplyToMessageID
180+
}
181+
}
182+
183+
var msg Message
184+
if err := b.doJSON("sendMessage", params, &msg); err != nil {
185+
return nil, err
186+
}
187+
return &msg, nil
188+
}
189+
190+
// SendPhoto sends a photo from a file path to the specified chat.
191+
func (b *Bot) SendPhoto(chatID int64, path string, caption string) (*Message, error) {
192+
params := map[string]any{
193+
"chat_id": chatID,
194+
}
195+
if caption != "" {
196+
params["caption"] = caption
197+
}
198+
199+
var msg Message
200+
if err := b.doUpload("sendPhoto", "photo", path, params, &msg); err != nil {
201+
return nil, err
202+
}
203+
return &msg, nil
204+
}
205+
206+
// SendVoice sends a voice note from a file path to the specified chat.
207+
func (b *Bot) SendVoice(chatID int64, path string, caption string) (*Message, error) {
208+
params := map[string]any{
209+
"chat_id": chatID,
210+
}
211+
if caption != "" {
212+
params["caption"] = caption
213+
}
214+
215+
var msg Message
216+
if err := b.doUpload("sendVoice", "voice", path, params, &msg); err != nil {
217+
return nil, err
218+
}
219+
return &msg, nil
220+
}
221+
222+
// GetUpdates retrieves incoming updates using long polling.
223+
func (b *Bot) GetUpdates(offset int, timeout int) ([]Update, error) {
224+
params := map[string]any{
225+
"offset": offset,
226+
"timeout": timeout,
227+
}
228+
229+
var updates []Update
230+
if err := b.doJSON("getUpdates", params, &updates); err != nil {
231+
return nil, err
232+
}
233+
return updates, nil
234+
}
235+
236+
// GetFile returns basic information about a file and prepares it for downloading.
237+
func (b *Bot) GetFile(fileID string) (*File, error) {
238+
params := map[string]any{
239+
"file_id": fileID,
240+
}
241+
242+
var file File
243+
if err := b.doJSON("getFile", params, &file); err != nil {
244+
return nil, err
245+
}
246+
return &file, nil
247+
}
248+
249+
// DownloadFile downloads a file from Telegram's file server and returns its raw bytes.
250+
func (b *Bot) DownloadFile(filePath string) ([]byte, error) {
251+
url := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", b.Token, filePath)
252+
253+
resp, err := b.Client.Get(url)
254+
if err != nil {
255+
return nil, fmt.Errorf("telegram: download file: %w", err)
256+
}
257+
defer resp.Body.Close()
258+
259+
if resp.StatusCode != http.StatusOK {
260+
return nil, fmt.Errorf("telegram: download file: status %d", resp.StatusCode)
261+
}
262+
263+
data, err := io.ReadAll(resp.Body)
264+
if err != nil {
265+
return nil, fmt.Errorf("telegram: read file data: %w", err)
266+
}
267+
return data, nil
268+
}
269+
270+
// AnswerCallbackQuery sends an answer to a callback query.
271+
func (b *Bot) AnswerCallbackQuery(callbackID string, text string, showAlert bool) error {
272+
params := map[string]any{
273+
"callback_query_id": callbackID,
274+
"text": text,
275+
"show_alert": showAlert,
276+
}
277+
return b.doJSON("answerCallbackQuery", params, nil)
278+
}
279+
280+
// SetMyCommands sets the list of the bot's commands.
281+
func (b *Bot) SetMyCommands(commands []BotCommand) error {
282+
params := map[string]any{
283+
"commands": commands,
284+
}
285+
return b.doJSON("setMyCommands", params, nil)
286+
}
287+
288+
// GetMe returns basic information about the bot (useful as a health check).
289+
func (b *Bot) GetMe() (*User, error) {
290+
var user User
291+
if err := b.doJSON("getMe", nil, &user); err != nil {
292+
return nil, err
293+
}
294+
return &user, nil
295+
}

0 commit comments

Comments
 (0)