Jobmaker-LdPost/shared/telegram/bot.go
Ricardo Carneiro ea532659b0 feat: pipeline inicial ldpost-squad (6 agentes)
Pipeline completo de publicação no LinkedIn:
evaluator → redator → editor → art → director → publisher

- Seed com 37 posts em _sugestoes.md
- Sorteio de formato com N=3 bloqueados (format-history)
- Reciclagem mensal de posts com rotação de formato
- Revisão via Telegram com chat livre (Gemini 2.5 Flash)
- Publicação via LinkedIn API (OAuth2)
- Makefile com targets para Windows/Linux/ARM64

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 18:55:39 -03:00

400 lines
11 KiB
Go

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)
}