package telegram import ( "bytes" "context" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "os" "path/filepath" "strconv" "strings" "time" ) const ( apiBase = "https://api.telegram.org/bot" msgLimit = 4000 pollTimeout = 30 // seconds for long polling ) // ─── Types ──────────────────────────────────────────────────────────────────── type Bot struct { Token string ChatID string Client *http.Client offset int // persistent across WaitFor* calls — avoids re-delivering consumed updates } type InlineButton struct { Text string CallbackData string } type MediaFile struct { Path string Caption string } // ─── Constructor ────────────────────────────────────────────────────────────── func NewBot(token, chatID string) *Bot { return &Bot{ Token: token, ChatID: chatID, Client: &http.Client{Timeout: 35 * time.Second}, } } // ─── Sending ────────────────────────────────────────────────────────────────── // SendMessage sends text with HTML parse mode. // Long messages are split at newlines. func (b *Bot) SendMessage(text string) (int, error) { chunks := splitText(text, msgLimit) var lastID int for _, chunk := range chunks { id, err := b.sendText(chunk, nil) if err != nil { return 0, err } lastID = id } return lastID, nil } // SendMessageWithKeyboard sends HTML text with an inline keyboard. func (b *Bot) SendMessageWithKeyboard(text string, buttons [][]InlineButton) (int, error) { var rows [][]tgButton for _, row := range buttons { var tgRow []tgButton for _, btn := range row { tgRow = append(tgRow, tgButton{Text: btn.Text, CallbackData: btn.CallbackData}) } rows = append(rows, tgRow) } return b.sendText(text, &inlineKeyboard{InlineKeyboard: rows}) } // SendPhoto sends a single image file with optional caption. func (b *Bot) SendPhoto(filePath, caption string) error { f, err := os.Open(filePath) if err != nil { return fmt.Errorf("abrir imagem: %w", err) } defer f.Close() var body bytes.Buffer w := multipart.NewWriter(&body) _ = w.WriteField("chat_id", b.ChatID) if caption != "" { _ = w.WriteField("caption", caption) } fw, err := w.CreateFormFile("photo", filepath.Base(filePath)) if err != nil { return err } if _, err := io.Copy(fw, f); err != nil { return err } w.Close() return b.postMultipart("sendPhoto", w.FormDataContentType(), &body) } // SendMediaGroup sends up to 10 images as an album. func (b *Bot) SendMediaGroup(files []MediaFile) error { switch len(files) { case 0: return nil case 1: return b.SendPhoto(files[0].Path, files[0].Caption) } var body bytes.Buffer w := multipart.NewWriter(&body) _ = w.WriteField("chat_id", b.ChatID) type inputMedia struct { Type string `json:"type"` Media string `json:"media"` Caption string `json:"caption,omitempty"` } var mediaJSON []inputMedia for i, mf := range files { fieldName := fmt.Sprintf("file%d", i) f, err := os.Open(mf.Path) if err != nil { return fmt.Errorf("abrir %s: %w", mf.Path, err) } fw, err := w.CreateFormFile(fieldName, filepath.Base(mf.Path)) if err != nil { f.Close() return err } _, err = io.Copy(fw, f) f.Close() if err != nil { return err } mediaJSON = append(mediaJSON, inputMedia{ Type: "photo", Media: "attach://" + fieldName, Caption: mf.Caption, }) } mediaBytes, _ := json.Marshal(mediaJSON) _ = w.WriteField("media", string(mediaBytes)) w.Close() return b.postMultipart("sendMediaGroup", w.FormDataContentType(), &body) } // ─── Polling ────────────────────────────────────────────────────────────────── // WaitForCallback polls getUpdates until one of validCallbacks arrives. // timeout=0 means wait indefinitely. // Uses b.offset so consumed updates are not re-delivered to subsequent WaitFor* calls. func (b *Bot) WaitForCallback(validCallbacks []string, timeout time.Duration) (string, error) { valid := make(map[string]bool, len(validCallbacks)) for _, c := range validCallbacks { valid[c] = true } var ctx context.Context var cancel context.CancelFunc if timeout > 0 { ctx, cancel = context.WithTimeout(context.Background(), timeout) } else { ctx, cancel = context.WithCancel(context.Background()) } defer cancel() for { updates, err := b.getUpdates(ctx, b.offset) if err != nil { if ctx.Err() != nil { return "", fmt.Errorf("timeout aguardando callback: %w", ctx.Err()) } time.Sleep(2 * time.Second) continue } for _, u := range updates { b.offset = u.UpdateID + 1 if u.CallbackQuery == nil { continue } b.answerCallback(u.CallbackQuery.ID) if valid[u.CallbackQuery.Data] { return u.CallbackQuery.Data, nil } } } } // ─── Extended polling ───────────────────────────────────────────────────────── // Event represents either a Telegram callback or a free-text message. type Event struct { IsCallback bool Text string // CallbackData or message text } // WaitForAny polls until either a CallbackQuery or a text Message arrives. // Uses b.offset so consumed updates are not re-delivered to subsequent WaitFor* calls. func (b *Bot) WaitForAny(timeout time.Duration) (*Event, error) { var ctx context.Context var cancel context.CancelFunc if timeout > 0 { ctx, cancel = context.WithTimeout(context.Background(), timeout) } else { ctx, cancel = context.WithCancel(context.Background()) } defer cancel() for { updates, err := b.getUpdates(ctx, b.offset) if err != nil { if ctx.Err() != nil { return nil, fmt.Errorf("timeout: %w", ctx.Err()) } time.Sleep(2 * time.Second) continue } for _, u := range updates { b.offset = u.UpdateID + 1 if u.CallbackQuery != nil { b.answerCallback(u.CallbackQuery.ID) return &Event{IsCallback: true, Text: u.CallbackQuery.Data}, nil } if u.Message != nil && u.Message.Text != "" { return &Event{IsCallback: false, Text: u.Message.Text}, nil } } } } // ─── EscapeMarkdown ─────────────────────────────────────────────────────────── // EscapeMarkdown escapes all MarkdownV2 special characters: // _ * [ ] ( ) ~ ` > # + - = | { } . ! func EscapeMarkdown(s string) string { special := `\_*[]()~` + "`" + `>#+-=|{}.!` var b strings.Builder b.Grow(len(s) + 16) for _, r := range s { if strings.ContainsRune(special, r) { b.WriteRune('\\') } b.WriteRune(r) } return b.String() } // ─── Internal types ─────────────────────────────────────────────────────────── type tgButton struct { Text string `json:"text"` CallbackData string `json:"callback_data"` } type inlineKeyboard struct { InlineKeyboard [][]tgButton `json:"inline_keyboard"` } type tgUpdate struct { UpdateID int `json:"update_id"` Message *tgMessage `json:"message,omitempty"` CallbackQuery *tgCallbackQuery `json:"callback_query,omitempty"` } type tgMessage struct { MessageID int `json:"message_id"` Text string `json:"text"` } type tgCallbackQuery struct { ID string `json:"id"` Data string `json:"data"` Message *tgMessage `json:"message,omitempty"` } // ─── Internal helpers ───────────────────────────────────────────────────────── func (b *Bot) sendText(text string, markup *inlineKeyboard) (int, error) { chatIDInt, err := strconv.ParseInt(b.ChatID, 10, 64) if err != nil { return 0, fmt.Errorf("chat_id inválido %q: %w", b.ChatID, err) } reqBody := map[string]any{ "chat_id": chatIDInt, "text": text, "parse_mode": "HTML", } if markup != nil { reqBody["reply_markup"] = markup } data, _ := json.Marshal(reqBody) resp, err := b.call("sendMessage", data) if err != nil { return 0, err } var result struct { OK bool `json:"ok"` Description string `json:"description,omitempty"` Result struct { MessageID int `json:"message_id"` } `json:"result"` } if err := json.Unmarshal(resp, &result); err != nil { return 0, fmt.Errorf("parsear sendMessage: %w", err) } if !result.OK { return 0, fmt.Errorf("telegram sendMessage: %s", result.Description) } return result.Result.MessageID, nil } func (b *Bot) postMultipart(method, contentType string, body *bytes.Buffer) error { url := apiBase + b.Token + "/" + method resp, err := http.Post(url, contentType, body) if err != nil { return fmt.Errorf("%s: %w", method, err) } defer resp.Body.Close() if resp.StatusCode >= 300 { rb, _ := io.ReadAll(resp.Body) return fmt.Errorf("%s status %d: %s", method, resp.StatusCode, string(rb)) } return nil } func (b *Bot) getUpdates(ctx context.Context, offset int) ([]tgUpdate, error) { reqBody := map[string]any{"offset": offset, "timeout": pollTimeout} data, _ := json.Marshal(reqBody) url := apiBase + b.Token + "/getUpdates" req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") longClient := &http.Client{Timeout: time.Duration(pollTimeout+10) * time.Second} resp, err := longClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var result struct { OK bool `json:"ok"` Result []tgUpdate `json:"result"` } if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("parsear getUpdates: %w", err) } return result.Result, nil } func (b *Bot) answerCallback(callbackID string) { data, _ := json.Marshal(map[string]string{"callback_query_id": callbackID}) _, _ = b.call("answerCallbackQuery", data) } func (b *Bot) call(method string, body []byte) ([]byte, error) { url := apiBase + b.Token + "/" + method resp, err := b.Client.Post(url, "application/json", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("telegram %s: %w", method, err) } defer resp.Body.Close() return io.ReadAll(resp.Body) } func splitText(text string, limit int) []string { if len(text) <= limit { return []string{text} } var chunks []string for len(text) > limit { cut := limit if idx := strings.LastIndex(text[:limit], "\n"); idx > 0 { cut = idx } chunks = append(chunks, text[:cut]) text = text[cut:] } return append(chunks, text) }