package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"ldpost/shared/config"
"ldpost/shared/formats"
"ldpost/shared/groq"
"ldpost/shared/history"
"ldpost/shared/state"
"ldpost/shared/telegram"
"ldpost/shared/workspace"
)
// ─── Domain types ─────────────────────────────────────────────────────────────
type Trend struct {
Tema string `json:"tema"`
Descricao string `json:"descricao"`
Mencoes int `json:"mencoes"`
Momentum string `json:"momentum"` // crescendo|estavel|caindo
RelevanciaProfile string `json:"relevancia_perfil"` // alta|media|baixa
CategoriaSugerida string `json:"categoria_sugerida"` // Codigo|Entregavel|Bastidor|Geral
}
type TrendResponse struct {
Trends []Trend `json:"trends"`
}
type SugestaoPost struct {
Slug string
Tema string
Categoria string
Formato string
Funil string
Resumo string
Imagem1 string
Imagem2 string
// Reciclagem
IsRecycled bool
BaseSlug string // slug original do seed
PreviousFormato string // formato da última publicação
DaysSincePublished int
}
// ─── Formato rotation ─────────────────────────────────────────────────────────
// formatoCycle defines the fixed rotation order for recycled posts.
var formatoCycle = []string{"como", "porque", "erro", "comparacao", "checklist", "bastidor"}
// nextFormato returns the next format in the rotation after current.
func nextFormato(current string) string {
for i, f := range formatoCycle {
if f == current {
return formatoCycle[(i+1)%len(formatoCycle)]
}
}
return formatoCycle[0]
}
// recycleSlug builds the new slug for a recycled post: baseSlug--newFormato.
// Strips any existing --format suffix before appending.
func recycleSlug(baseSlug, newFormato string) string {
base := baseSlug
if idx := strings.Index(baseSlug, "--"); idx >= 0 {
base = baseSlug[:idx]
}
return base + "--" + newFormato
}
type PendingCallback struct {
Trends []Trend `json:"trends"`
NextPost *SugestaoPost `json:"next_post"`
FormatoSorteado string `json:"formato_sorteado"`
FormatosBloqueados []string `json:"formatos_bloqueados"`
CreatedAt time.Time `json:"created_at"`
}
// ─── Trend fetching ───────────────────────────────────────────────────────────
type hnItem struct {
Title string `json:"title"`
Points int `json:"points"`
URL string `json:"url"`
CreatedAt string `json:"created_at"`
}
func fetchHN() ([]string, error) {
sevenDaysAgo := time.Now().AddDate(0, 0, -7).Unix()
url := fmt.Sprintf(
"https://hn.algolia.com/api/v1/search?query=AI+LLM+agent&tags=story&numericFilters=points>100,created_at_i>%d&hitsPerPage=10",
sevenDaysAgo,
)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("HN fetch: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
Hits []hnItem `json:"hits"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("HN parse: %w", err)
}
var titles []string
for _, h := range result.Hits {
titles = append(titles, fmt.Sprintf("[HN %d pts] %s", h.Points, h.Title))
}
return titles, nil
}
func fetchReddit(subreddit string) ([]string, error) {
url := fmt.Sprintf("https://www.reddit.com/r/%s/top.json?t=week&limit=10", subreddit)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", "ldpost-evaluator/1.0")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("Reddit %s fetch: %w", subreddit, err)
}
defer resp.Body.Close()
if resp.StatusCode == 429 {
return nil, fmt.Errorf("rate_limited")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Reddit %s status %d", subreddit, resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var result struct {
Data struct {
Children []struct {
Data struct {
Title string `json:"title"`
Score int `json:"score"`
CreatedUTC float64 `json:"created_utc"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("Reddit %s parse: %w", subreddit, err)
}
var titles []string
for _, c := range result.Data.Children {
titles = append(titles, fmt.Sprintf("[Reddit r/%s %d pts] %s", subreddit, c.Data.Score, c.Data.Title))
}
return titles, nil
}
// ─── Consolidation via LLM ────────────────────────────────────────────────────
const systemPromptTrends = `Você é um analista de tendências de tecnologia especializado em IA, desenvolvimento de software e arquitetura de sistemas. Seu trabalho é identificar os 3 temas mais relevantes para um Tech Lead sênior brasileiro (22+ anos de experiência em C#/.NET, especialista em Clean Architecture, IA local, agentes, SAP BTP).
Retorne APENAS um JSON válido, sem markdown, sem explicação, no formato:
{
"trends": [
{
"tema": "string curta (máx 8 palavras)",
"descricao": "string de 1 frase explicando o trend",
"mencoes": número estimado de posts/threads sobre o tema,
"momentum": "crescendo|estavel|caindo",
"relevancia_perfil": "alta|media|baixa",
"categoria_sugerida": "Codigo|Entregavel|Bastidor|Geral"
}
]
}
Retorne exatamente 3 trends, priorizando os de relevancia_perfil=alta.`
func consolidateTrends(gc *groq.GroqClient, titles []string) ([]Trend, error) {
user := strings.Join(titles, "\n---\n")
var resp TrendResponse
msgs := groq.TextMessages(systemPromptTrends, user)
if err := gc.ChatJSON(groq.TextModel, msgs, 0.3, &resp); err != nil {
return nil, err
}
if len(resp.Trends) == 0 {
return nil, fmt.Errorf("LLM retornou trends vazio")
}
return resp.Trends, nil
}
// Fallback: se Groq falhar, criar trends simples a partir dos títulos brutos
func buildFallbackTrends(titles []string) []Trend {
var trends []Trend
for i, t := range titles {
if i >= 3 {
break
}
trends = append(trends, Trend{
Tema: truncate(t, 60),
Descricao: "Tema identificado nas fontes (sem consolidação LLM)",
Mencoes: 1,
Momentum: "estavel",
RelevanciaProfile: "media",
CategoriaSugerida: "Entregavel",
})
}
return trends
}
// ─── _sugestoes.md parser ─────────────────────────────────────────────────────
func parseSugestoes(path string) ([]SugestaoPost, error) {
f, err := os.Open(path)
if os.IsNotExist(err) {
return nil, fmt.Errorf("_sugestoes.md não encontrado em %s\n→ Crie o arquivo com o seed de 30 posts", path)
}
if err != nil {
return nil, err
}
defer f.Close()
var posts []SugestaoPost
var cur *SugestaoPost
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "## ") {
if cur != nil {
posts = append(posts, *cur)
}
rawSlug := strings.TrimSpace(strings.TrimPrefix(line, "## "))
cur = &SugestaoPost{Slug: workspace.SlugFromTitle(rawSlug)}
} else if cur != nil {
k, v := parseKV(line)
switch k {
case "Categoria":
cur.Categoria = v
case "Tema":
cur.Tema = v
case "Formato":
cur.Formato = v
case "Funil":
cur.Funil = v
case "Resumo":
cur.Resumo = v
case "Imagem 1", "Imagem1":
cur.Imagem1 = v
case "Imagem 2", "Imagem2":
cur.Imagem2 = v
}
}
}
if cur != nil {
posts = append(posts, *cur)
}
return posts, scanner.Err()
}
func parseKV(line string) (string, string) {
line = strings.TrimSpace(line)
line = strings.TrimPrefix(line, "**")
parts := strings.SplitN(line, ":**", 2)
if len(parts) != 2 {
parts = strings.SplitN(line, ": ", 2)
if len(parts) != 2 {
return "", ""
}
}
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
// findNextInQueue scans the seed queue and returns the next eligible post.
//
// Priority order for each slug:
// 1. Never created (no work/ folder) → return as new post.
// 2. In pipeline (exists, not published, not rejected) → skip (don't interrupt).
// 3. Published < 30 days ago → skip (too soon).
// 4. Published >= 30 days ago → return recycled with next format in rotation.
// 5. Rejected → skip entirely.
func findNextInQueue(ws string, posts []SugestaoPost) *SugestaoPost {
const recycleDays = 30
for i := range posts {
p := &posts[i]
if p.Slug == "" {
continue
}
variants := workspace.FindVariantsByBase(ws, p.Slug)
if len(variants) == 0 {
// Never created → new post
return p
}
// Find the most recently published variant
var latestFormato string
var latestPublishedAt time.Time
hasPublished := false
for _, vPath := range variants {
s, err := state.LoadState(vPath)
if err != nil {
continue
}
if s.IsStatus(state.StatusPublished) && s.PublishedAt != nil {
if !hasPublished || s.PublishedAt.After(latestPublishedAt) {
latestPublishedAt = *s.PublishedAt
latestFormato = s.Formato
hasPublished = true
}
}
}
if !hasPublished {
// Exists but not yet published → in pipeline or rejected → skip
continue
}
days := int(time.Since(latestPublishedAt).Hours() / 24)
if days < recycleDays {
// Published too recently
continue
}
// Ready for recycle: advance to next format
newFmt := nextFormato(latestFormato)
newSlug := recycleSlug(p.Slug, newFmt)
recycled := *p
recycled.Slug = newSlug
recycled.Formato = newFmt
recycled.IsRecycled = true
recycled.BaseSlug = p.Slug
recycled.PreviousFormato = latestFormato
recycled.DaysSincePublished = days
return &recycled
}
return nil
}
// ─── Pending callback persistence ────────────────────────────────────────────
func pendingPath(ws string) string {
return filepath.Join(ws, "_inbox", "_pending_callback.json")
}
func savePending(ws string, p *PendingCallback) error {
dir := filepath.Join(ws, "_inbox")
os.MkdirAll(dir, 0755)
data, _ := json.MarshalIndent(p, "", " ")
return os.WriteFile(pendingPath(ws), data, 0644)
}
func loadPending(ws string) (*PendingCallback, error) {
data, err := os.ReadFile(pendingPath(ws))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var p PendingCallback
if err := json.Unmarshal(data, &p); err != nil {
return nil, err
}
return &p, nil
}
func deletePending(ws string) {
os.Remove(pendingPath(ws))
}
// ─── Telegram messages ────────────────────────────────────────────────────────
func momentumEmoji(m string) string {
switch m {
case "crescendo":
return "📈"
case "estavel":
return "➡️"
case "caindo":
return "📉"
default:
return "📊"
}
}
func sendTrendMessages(bot *telegram.Bot, trends []Trend, next *SugestaoPost, sorteado string, bloqueados []string) error {
// Mensagem 1: Trends
var sb strings.Builder
sb.WriteString("🔍 ldpost-evaluator\n\n")
sb.WriteString("📊 Trends desta semana:\n\n")
labels := []string{"A", "B", "C"}
for i, t := range trends {
if i >= 3 {
break
}
fmt.Fprintf(&sb, "%s) %s\n", labels[i], t.Tema)
fmt.Fprintf(&sb, " %s\n", t.Descricao)
fmt.Fprintf(&sb, " %s ~%d menções | %s | relevância: %s\n\n",
momentumEmoji(t.Momentum), t.Mencoes, t.Momentum, t.RelevanciaProfile)
}
if _, err := bot.SendMessage(sb.String()); err != nil {
return fmt.Errorf("msg 1 trends: %w", err)
}
// Mensagem 2: Fila + formato
var sb2 strings.Builder
if next != nil {
if next.IsRecycled {
sb2.WriteString("♻️ Reciclagem da sua fila:\n")
fmt.Fprintf(&sb2, "D) %s\n", next.Tema)
fmt.Fprintf(&sb2, "Publicado há %d dias como %s → novo ângulo: %s\n",
next.DaysSincePublished,
formats.FormatLabel(next.PreviousFormato),
formats.FormatLabel(next.Formato))
fmt.Fprintf(&sb2, "%s → %s\n", next.BaseSlug, next.Slug)
} else {
sb2.WriteString("📝 Próximo da sua fila:\n")
fmt.Fprintf(&sb2, "D) %s\n", next.Tema)
if next.Formato != "" {
fmt.Fprintf(&sb2, "Formato: %s\n", formats.FormatLabel(next.Formato))
}
}
sb2.WriteString("\n")
} else {
sb2.WriteString("📝 Fila vazia — nenhum post pendente em _sugestoes.md\n\n")
}
fmt.Fprintf(&sb2, "🎲 Sorteio sugere: %s (%s)\n", sorteado, formats.FormatLabel(sorteado))
if len(bloqueados) > 0 {
fmt.Fprintf(&sb2, "bloqueados: %s\n", strings.Join(bloqueados, ", "))
}
if _, err := bot.SendMessage(sb2.String()); err != nil {
return fmt.Errorf("msg 2 fila: %w", err)
}
// Mensagem 3: Botões
buttons := [][]telegram.InlineButton{
{{Text: "A — " + truncate(trends[0].Tema, 30), CallbackData: "trend_a"}},
}
if len(trends) > 1 {
buttons = append(buttons, []telegram.InlineButton{
{Text: "B — " + truncate(trends[1].Tema, 30), CallbackData: "trend_b"},
})
}
if len(trends) > 2 {
buttons = append(buttons, []telegram.InlineButton{
{Text: "C — " + truncate(trends[2].Tema, 30), CallbackData: "trend_c"},
})
}
if next != nil {
if next.Formato != "" {
var labelD string
if next.IsRecycled {
labelD = "D — Reciclar (" + formats.FormatLabel(next.PreviousFormato) + " → " + formats.FormatLabel(next.Formato) + ")"
} else {
labelD = "D — Fila (formato: " + formats.FormatLabel(next.Formato) + ")"
}
buttons = append(buttons, []telegram.InlineButton{
{Text: labelD, CallbackData: "fila_original"},
})
}
buttons = append(buttons, []telegram.InlineButton{
{Text: "E — Fila (sorteado: " + formats.FormatLabel(sorteado) + ")", CallbackData: "fila_sorteado"},
})
}
if _, err := bot.SendMessageWithKeyboard("Qual seguimos?", buttons); err != nil {
return fmt.Errorf("msg 3 botões: %w", err)
}
return nil
}
// ─── Workspace creation ───────────────────────────────────────────────────────
func buildTextoMD(slug, categoria, formato, funil, trendRef, resumo, img1, img2 string) string {
var sb strings.Builder
sb.WriteString("---\n")
fmt.Fprintf(&sb, "slug: %s\n", slug)
fmt.Fprintf(&sb, "categoria: %s\n", categoria)
fmt.Fprintf(&sb, "formato: %s\n", formato)
if funil != "" {
fmt.Fprintf(&sb, "funil: %s\n", funil)
}
if trendRef != "" {
fmt.Fprintf(&sb, "trend_referencia: %s\n", trendRef)
}
sb.WriteString("---\n\n")
if resumo != "" {
sb.WriteString("## O que fiz\n")
sb.WriteString(resumo)
sb.WriteString("\n\n")
}
if img1 != "" {
sb.WriteString("## Imagem 1\n")
sb.WriteString(img1)
sb.WriteString("\n\n")
}
if img2 != "" {
sb.WriteString("## Imagem 2\n")
sb.WriteString(img2)
sb.WriteString("\n")
}
return sb.String()
}
func createPostWorkspace(cfg *config.Config, slug, categoria, formato, funil, trendRef, resumo, img1, img2 string) (string, error) {
postPath := workspace.PostPath(cfg.Workspace, categoria, slug)
if err := workspace.EnsureDirs(postPath); err != nil {
return "", fmt.Errorf("criar dirs: %w", err)
}
// Escrever input/texto.md
textoMD := buildTextoMD(slug, categoria, formato, funil, trendRef, resumo, img1, img2)
if err := os.WriteFile(workspace.InputTextoPath(postPath), []byte(textoMD), 0644); err != nil {
return "", fmt.Errorf("escrever texto.md: %w", err)
}
// Criar state.json
s := state.NewPostState(slug, categoria, formato, resumo, trendRef)
s.Status = state.StatusWaitingRedator
s.SetEtapa("evaluator", state.EtapaDone)
s.SetEtapa("redator", state.EtapaWaiting)
if trendRef != "" {
s.Aprovacoes.Tema.Aprovado = true
s.Aprovacoes.Tema.Timestamp = time.Now()
}
if err := state.SaveState(postPath, s); err != nil {
return "", fmt.Errorf("salvar state: %w", err)
}
return postPath, nil
}
// ─── Callback processing ──────────────────────────────────────────────────────
func processCallback(cfg *config.Config, cb string, pending *PendingCallback) (slug string, err error) {
var (
tema string
categoria string
formato string
funil string
trendRef string
resumo string
img1 string
img2 string
)
switch cb {
case "trend_a", "trend_b", "trend_c":
idx := map[string]int{"trend_a": 0, "trend_b": 1, "trend_c": 2}[cb]
if idx >= len(pending.Trends) {
return "", fmt.Errorf("trend index %d fora do range", idx)
}
t := pending.Trends[idx]
tema = t.Tema
categoria = t.CategoriaSugerida
if categoria == "" {
categoria = "Entregavel"
}
formato = pending.FormatoSorteado
trendRef = fmt.Sprintf("%s — %s", t.Tema, t.Descricao)
slug = workspace.SlugFromTitle(tema)
case "fila_original", "fila_sorteado":
if pending.NextPost == nil {
return "", fmt.Errorf("fila vazia — sem post para processar")
}
p := pending.NextPost
tema = p.Tema
categoria = p.Categoria
if categoria == "" {
categoria = "Geral"
}
funil = p.Funil
resumo = p.Resumo
img1 = p.Imagem1
img2 = p.Imagem2
slug = p.Slug
if cb == "fila_original" && p.Formato != "" {
formato = p.Formato
} else {
formato = pending.FormatoSorteado
}
default:
return "", fmt.Errorf("callback desconhecido: %q", cb)
}
if slug == "" {
slug = workspace.SlugFromTitle(tema)
}
postPath, err := createPostWorkspace(cfg, slug, categoria, formato, funil, trendRef, resumo, img1, img2)
if err != nil {
return "", err
}
log.Printf("[INFO] workspace criado: %s", postPath)
// Atualizar format-history
hist, _ := history.LoadHistory(cfg.Workspace)
if hist == nil {
hist = &history.FormatHistory{}
}
hist.AddEntry(slug, formato)
history.SaveHistory(cfg.Workspace, hist)
return slug, nil
}
// ─── Main ─────────────────────────────────────────────────────────────────────
func main() {
log.SetFlags(log.Ltime)
var (
flagDryRun bool
flagNoReddit bool
flagForceSlug string
flagWorkspace string
)
root := &cobra.Command{
Use: "ldpost-evaluator",
Short: "Busca trends, identifica fila, sorteia formato e envia para Telegram",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := config.Load()
if flagWorkspace != "" {
cfg.Workspace = flagWorkspace
}
// ── Verificar se há callback pendente ─────────────────────────
if !flagDryRun {
if pending, err := loadPending(cfg.Workspace); err == nil && pending != nil {
age := time.Since(pending.CreatedAt)
log.Printf("[INFO] retomando callback pendente (criado há %s)", age.Round(time.Minute))
return resumeCallback(cfg, pending)
}
}
// ── 1. Buscar trends ──────────────────────────────────────────
var allTitles []string
log.Printf("[INFO] buscando trends no Hacker News...")
if !flagDryRun {
hnTitles, err := fetchHN()
if err != nil {
log.Printf("[WARN] HN: %v", err)
} else {
allTitles = append(allTitles, hnTitles...)
log.Printf("[INFO] HN: %d títulos", len(hnTitles))
}
}
if !flagNoReddit && !flagDryRun {
for _, sub := range []string{"LocalLLaMA", "MachineLearning"} {
log.Printf("[INFO] buscando Reddit r/%s...", sub)
rdTitles, err := fetchReddit(sub)
if err != nil {
if strings.Contains(err.Error(), "rate_limited") {
log.Printf("[WARN] Reddit rate limited, continuing without Reddit")
} else {
log.Printf("[WARN] Reddit r/%s: %v", sub, err)
}
} else {
allTitles = append(allTitles, rdTitles...)
log.Printf("[INFO] Reddit r/%s: %d títulos", sub, len(rdTitles))
}
}
}
if flagDryRun {
allTitles = []string{
"[HN 450 pts] LLM agents are replacing traditional automation",
"[HN 320 pts] Local AI with llama.cpp — production guide",
"[Reddit r/LocalLLaMA 890 pts] Running RAG locally without cloud",
"[Reddit r/MachineLearning 540 pts] New efficient fine-tuning approach",
"[HN 210 pts] Clean Architecture for AI systems",
}
}
// ── 2. Ler fila local ─────────────────────────────────────────
sugestoesPath := workspace.InboxSugestoes(cfg.Workspace)
allPosts, err := parseSugestoes(sugestoesPath)
if err != nil {
log.Printf("[WARN] %v", err)
} else {
log.Printf("[INFO] _sugestoes.md: %d posts", len(allPosts))
// Adicionar títulos da fila às fontes de trend
for _, p := range allPosts {
if p.Tema != "" {
allTitles = append(allTitles, "[Fila local] "+p.Tema)
}
}
}
// Identificar próximo da fila
var nextPost *SugestaoPost
if flagForceSlug != "" {
for i := range allPosts {
if allPosts[i].Slug == flagForceSlug {
nextPost = &allPosts[i]
break
}
}
if nextPost == nil {
return fmt.Errorf("--force-slug %q não encontrado em _sugestoes.md", flagForceSlug)
}
} else {
nextPost = findNextInQueue(cfg.Workspace, allPosts)
}
if nextPost != nil {
log.Printf("[INFO] próximo da fila: %s — %s", nextPost.Slug, nextPost.Tema)
} else {
log.Printf("[INFO] fila vazia")
}
// ── 3. Consolidar trends via LLM ──────────────────────────────
var trends []Trend
if !flagDryRun && cfg.GroqAPIKey != "" {
log.Printf("[INFO] consolidando %d títulos via Groq...", len(allTitles))
gc := groq.NewGroqClient(cfg.GroqAPIKey)
var groqErr error
for attempt := 1; attempt <= 3; attempt++ {
trends, groqErr = consolidateTrends(gc, allTitles)
if groqErr == nil {
break
}
log.Printf("[WARN] Groq retry %d/3: %v", attempt, groqErr)
time.Sleep(time.Duration(attempt) * 2 * time.Second)
}
if groqErr != nil {
log.Printf("[WARN] Groq falhou após 3 tentativas, usando títulos brutos")
trends = buildFallbackTrends(allTitles)
}
} else {
trends = buildFallbackTrends(allTitles)
}
// Garantir 3 trends (preencher com vazios se necessário)
for len(trends) < 3 {
trends = append(trends, Trend{
Tema: fmt.Sprintf("Trend %d (sem dados)", len(trends)+1),
Descricao: "Dados insuficientes",
Mencoes: 0,
Momentum: "estavel",
RelevanciaProfile: "baixa",
CategoriaSugerida: "Geral",
})
}
// ── 4. Sortear formato ────────────────────────────────────────
hist, _ := history.LoadHistory(cfg.Workspace)
if hist == nil {
hist = &history.FormatHistory{}
}
bloqueados := hist.LastN(3)
sorteado := formats.SortearFormato(bloqueados)
log.Printf("[INFO] formato sorteado: %s (bloqueados: %v)", sorteado, bloqueados)
// ── 5. Dry-run ────────────────────────────────────────────────
if flagDryRun {
fmt.Printf("=== DRY-RUN — ldpost-evaluator ===\n\n")
fmt.Printf("TRENDS:\n")
for i, t := range trends[:3] {
fmt.Printf(" %s) %s\n %s | %s | %s\n",
[]string{"A", "B", "C"}[i], t.Tema, t.Descricao, t.Momentum, t.RelevanciaProfile)
}
if nextPost != nil {
fmt.Printf("\nFILA PRÓXIMO: %s — %s (formato: %s)\n", nextPost.Slug, nextPost.Tema, nextPost.Formato)
} else {
fmt.Printf("\nFILA: vazia\n")
}
fmt.Printf("FORMATO SORTEADO: %s (bloqueados: %v)\n", sorteado, bloqueados)
return nil
}
// ── 6. Validar Telegram ───────────────────────────────────────
if err := cfg.Validate("telegram"); err != nil {
return fmt.Errorf("Telegram não configurado: %w\nUse --dry-run para testar sem Telegram", err)
}
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
// ── 7. Enviar mensagens Telegram ──────────────────────────────
log.Printf("[INFO] enviando mensagens Telegram...")
if err := sendTrendMessages(bot, trends, nextPost, sorteado, bloqueados); err != nil {
return fmt.Errorf("Telegram: %w", err)
}
// ── 8. Salvar pending callback ────────────────────────────────
pending := &PendingCallback{
Trends: trends,
NextPost: nextPost,
FormatoSorteado: sorteado,
FormatosBloqueados: bloqueados,
CreatedAt: time.Now(),
}
if err := savePending(cfg.Workspace, pending); err != nil {
log.Printf("[WARN] salvar pending: %v", err)
}
log.Printf("[INFO] aguardando callback no Telegram (sem timeout)...")
// ── 9. Aguardar callback ──────────────────────────────────────
return resumeCallback(cfg, pending)
},
}
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Executa sem chamar APIs nem escrever no disco")
root.Flags().BoolVar(&flagNoReddit, "no-reddit", false, "Pula Reddit (útil se rate limited)")
root.Flags().StringVar(&flagForceSlug, "force-slug", "", "Força slug específico da fila (pula seleção de tema)")
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
// resumeCallback waits for one of the 5 valid callbacks then processes it.
func resumeCallback(cfg *config.Config, pending *PendingCallback) error {
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
valid := []string{"trend_a", "trend_b", "trend_c"}
if pending.NextPost != nil {
if pending.NextPost.Formato != "" {
valid = append(valid, "fila_original")
}
valid = append(valid, "fila_sorteado")
}
log.Printf("[INFO] esperando callbacks válidos: %v", valid)
cb, err := bot.WaitForCallback(valid, 0) // 0 = indefinido
if err != nil {
return fmt.Errorf("callback: %w", err)
}
log.Printf("[INFO] callback recebido: %s", cb)
slug, err := processCallback(cfg, cb, pending)
if err != nil {
bot.SendMessage(fmt.Sprintf("❌ Erro ao processar escolha: %s", err.Error()))
return err
}
// Confirmar no Telegram
var titulo string
if pending.NextPost != nil && (cb == "fila_original" || cb == "fila_sorteado") {
titulo = pending.NextPost.Tema
} else {
idx := map[string]int{"trend_a": 0, "trend_b": 1, "trend_c": 2}[cb]
if idx < len(pending.Trends) {
titulo = pending.Trends[idx].Tema
}
}
confirmMsg := fmt.Sprintf(
"✅ Escolha registrada!\n\nPost: %s\nSlug: %s\n\nPróximo passo:\nldpost-redator --post %s",
titulo, slug, slug,
)
if _, err := bot.SendMessage(confirmMsg); err != nil {
log.Printf("[WARN] confirmação Telegram: %v", err)
}
// Limpar pending
deletePending(cfg.Workspace)
log.Printf("[INFO] done — post slug: %s", slug)
fmt.Println(slug)
return nil
}
// ─── Utilities ────────────────────────────────────────────────────────────────
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}