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] + "…" }