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>
400 lines
11 KiB
Go
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)
|
|
}
|