package main import ( "fmt" "log" "os" "path/filepath" "strings" "time" "context" "github.com/spf13/cobra" "ldpost/shared/config" "ldpost/shared/gemini" "ldpost/shared/groq" "ldpost/shared/state" "ldpost/shared/telegram" "ldpost/shared/workspace" ) // ─── Validation ─────────────────────────────────────────────────────────────── type requiredFile struct { label string path string } func collectRequiredFiles(postPath string) []requiredFile { return []requiredFile{ {"input/texto.md", workspace.InputTextoPath(postPath)}, {"work/editor-final.md", filepath.Join(workspace.WorkPath(postPath), "editor-final.md")}, {"output/slide1.png", filepath.Join(workspace.OutputPath(postPath), "slide1.png")}, {"output/slide2.png", filepath.Join(workspace.OutputPath(postPath), "slide2.png")}, } } func validateFiles(files []requiredFile) []string { var missing []string for _, f := range files { if _, err := os.Stat(f.path); err != nil { missing = append(missing, f.label) } } return missing } // ─── Text helpers ───────────────────────────────────────────────────────────── func wordCount(s string) int { return len(strings.Fields(s)) } func truncate(s string, maxChars int) string { if len(s) <= maxChars { return s } return s[:maxChars] + "\n... (truncado — ver arquivo completo)" } // ─── Groq helpers ───────────────────────────────────────────────────────────── const systemSugestoes = `Você é um consultor de conteúdo LinkedIn especializado em tech. Analise o post abaixo e sugira 3 melhorias específicas e acionáveis. Cada sugestão deve: - Ser específica (não "melhore o gancho" — diga exatamente o que mudar e para o quê) - Ter no máximo 2 linhas - Focar em impacto no engagement (compartilhamento, comentário, salvamento) Retorne APENAS as 3 sugestões numeradas. Sem introdução.` func getSugestoes(gc *groq.GroqClient, postText string) (string, error) { return gc.Chat(groq.TextModel, groq.TextMessages(systemSugestoes, postText), 0.7, 400) } func applySugestao(gc *gemini.Client, postText, sugestao string) (string, error) { system := `Você é editor especializado em conteúdo LinkedIn para Ricardo, Tech Lead brasileiro direto e técnico. O input do usuário pode ser de dois tipos — identifique e aja conforme: TIPO A — Instrução de edição direta: Exemplos: "mude o gancho", "remova hashtags", "encurta o terceiro parágrafo", "torna mais direto" Ação: aplique cirurgicamente, mantendo TUDO o mais intacto possível. TIPO B — Informação/contexto adicional do autor: Exemplos: parágrafos descritivos, números reais, detalhes da experiência, correções de fato Ação: reescreva as partes relevantes do post integrando essas informações de forma fluida. CRÍTICO para Tipo B: - NÃO cole o texto bruto do usuário — integre com a voz do Ricardo - Preserve TODAS as distinções que o usuário fez (ex: "ainda faço X, eliminei Y") - Preserve TODOS os números exatos (percentuais, dias, minutos) - Preserve TODOS os tipos/termos específicos mencionados (ex: "tarefas, risco, protótipo") - Se o usuário corrigiu um detalhe, use a versão correta — não resuma REGRAS SEMPRE ATIVAS: - Parágrafos máximo 3 linhas, linha em branco entre blocos - Mantenha as hashtags do post original a menos que o usuário peça para mudar - Mantenha a estrutura geral do formato (gancho → problema → solução → resultado → CTA) - Nunca invente fatos além do que foi fornecido - Nunca use: "mergulho profundo", "no cenário atual", "é importante destacar", "robusto", "abrangente" - Retorne APENAS o post modificado. Sem explicação, sem cabeçalho, sem "aqui está".` user := fmt.Sprintf("Input do usuário:\n%s\n\nPost atual:\n%s", sugestao, postText) return gc.Chat(context.Background(), gemini.TextModel, system, user) } // parseSugestoes extracts numbered lines from LLM suggestion output. func parseSugestoes(raw string) [3]string { var result [3]string lines := strings.Split(strings.TrimSpace(raw), "\n") idx := 0 for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } // Strip leading "1. " "2. " etc. for _, prefix := range []string{"1. ", "2. ", "3. ", "1) ", "2) ", "3) "} { if strings.HasPrefix(line, prefix) { line = strings.TrimPrefix(line, prefix) break } } if idx < 3 { result[idx] = line idx++ } } return result } // ─── Telegram senders ───────────────────────────────────────────────────────── func sendPackage(bot *telegram.Bot, s *state.PostState, slug, postText, originalText string, inputImgs, outputImgs []string) (msgID int, err error) { // Msg 1 — header header := fmt.Sprintf( "📋 ldpost-director | Aprovação Final\n📅 %s\n📝 Post: %s\n🎯 Formato: %s | Funil: %s\n🔄 Ciclos de revisão: %d", time.Now().Format("02/01/2006"), slug, s.Formato, s.FunilTag, s.Aprovacoes.Texto.Ciclos, ) if _, err := bot.SendMessage(header); err != nil { log.Printf("[WARN] header: %v", err) } // Msg 2 — original text orig := "📄 TEXTO ORIGINAL (seu resumo)\n─────────────────────────────\n" + truncate(originalText, 800) if _, err := bot.SendMessage(orig); err != nil { log.Printf("[WARN] texto original: %v", err) } // Msg 3 — final post (split if needed) finalHeader := "✍️ TEXTO FINAL (post LinkedIn)\n─────────────────────────────\n" if len(finalHeader)+len(postText) > 3000 { mid := len(postText) / 2 // Split at nearest newline if idx := strings.LastIndex(postText[:mid], "\n"); idx > 0 { mid = idx } if _, err := bot.SendMessage(finalHeader + postText[:mid]); err != nil { log.Printf("[WARN] texto final 1: %v", err) } if _, err := bot.SendMessage(postText[mid:]); err != nil { log.Printf("[WARN] texto final 2: %v", err) } } else { if _, err := bot.SendMessage(finalHeader + postText); err != nil { log.Printf("[WARN] texto final: %v", err) } } // Msg 4 — input images (optional) if len(inputImgs) > 0 { var mf []telegram.MediaFile for i, p := range inputImgs { cap := "" if i == 0 { cap = "🖼️ Imagens originais (seu input)" } mf = append(mf, telegram.MediaFile{Path: p, Caption: cap}) } if err := bot.SendMediaGroup(mf); err != nil { log.Printf("[WARN] imagens input: %v", err) } } // Msg 5 — output images if len(outputImgs) > 0 { var mf []telegram.MediaFile for i, p := range outputImgs { cap := "" if i == 0 { cap = "🎨 Imagens finais (geradas pelo squad)" } mf = append(mf, telegram.MediaFile{Path: p, Caption: cap}) } if err := bot.SendMediaGroup(mf); err != nil { log.Printf("[WARN] imagens output: %v", err) } } // Msg 6 — decision buttons buttons := [][]telegram.InlineButton{ {{Text: "✅ Aprovar e publicar", CallbackData: "aprovar"}}, {{Text: "❌ Reprovar tudo", CallbackData: "reprovar"}}, {{Text: "🔄 Revisar texto", CallbackData: "revisar_texto"}, {Text: "🎨 Revisar imagens", CallbackData: "revisar_arte"}}, {{Text: "✏️ Editar via chat", CallbackData: "editar_chat"}, {Text: "💡 Sugerir alternativas", CallbackData: "sugestoes"}}, } msgID, err = bot.SendMessageWithKeyboard("⬇️ Decisão final:", buttons) return } func sendSugestoes(bot *telegram.Bot, sug [3]string) error { text := fmt.Sprintf("💡 Sugestões do squad:\n\n1. %s\n\n2. %s\n\n3. %s", sug[0], sug[1], sug[2]) buttons := [][]telegram.InlineButton{ {{Text: "Aplicar sugestão 1", CallbackData: "aplicar_1"}}, {{Text: "Aplicar sugestão 2", CallbackData: "aplicar_2"}}, {{Text: "Aplicar sugestão 3", CallbackData: "aplicar_3"}}, {{Text: "Ignorar e aprovar mesmo assim", CallbackData: "aprovar"}, {Text: "Ignorar e reprovar", CallbackData: "reprovar"}}, } _, err := bot.SendMessageWithKeyboard(text, buttons) return err } // ─── Decision loop ──────────────────────────────────────────────────────────── type dirResult struct { action string // "aprovar", "reprovar", "revisar_texto", "revisar_arte", "sugestao_aplicada" newText string // filled when sugestão applied } var mainCallbacks = []string{"aprovar", "reprovar", "revisar_texto", "revisar_arte", "sugestoes", "editar_chat"} var sugCallbacks = []string{"aplicar_1", "aplicar_2", "aplicar_3", "aprovar", "reprovar"} var chatCallbacks = []string{"chat_aprovar", "chat_editar_mais", "chat_cancelar"} func waitDecision(bot *telegram.Bot, gc *groq.GroqClient, gem *gemini.Client, postText string) dirResult { for { cb, err := bot.WaitForCallback(mainCallbacks, 24*time.Hour) if err != nil { log.Printf("[WARN] timeout aguardando decisão — retomando polling") continue } switch cb { case "aprovar", "reprovar", "revisar_texto", "revisar_arte": return dirResult{action: cb} case "editar_chat": result := runChatEdit(bot, gem, postText) if result.action == "continuar" { // User cancelled chat — stay in main loop with (possibly) updated text postText = result.newText continue } return result case "sugestoes": log.Printf("[INFO] gerando sugestões via Groq...") raw, err := getSugestoes(gc, postText) if err != nil { bot.SendMessage(fmt.Sprintf("⚠️ Erro ao gerar sugestões: %v", err)) continue } sug := parseSugestoes(raw) if err := sendSugestoes(bot, sug); err != nil { log.Printf("[WARN] enviar sugestões: %v", err) } cb2, err := bot.WaitForCallback(sugCallbacks, 24*time.Hour) if err != nil { log.Printf("[WARN] timeout aguardando sugestão — retomando") continue } switch cb2 { case "aprovar", "reprovar": return dirResult{action: cb2} case "aplicar_1", "aplicar_2", "aplicar_3": idx := int(cb2[len(cb2)-1] - '1') chosen := sug[idx] log.Printf("[INFO] aplicando sugestão %d: %s", idx+1, chosen) updated, err := applySugestao(gem, postText, chosen) if err != nil { bot.SendMessage(fmt.Sprintf("⚠️ Erro ao aplicar sugestão: %v", err)) continue } return dirResult{action: "sugestao_aplicada", newText: strings.TrimSpace(updated)} } } } } // runChatEdit runs a free-text editing loop with the user via Telegram. // The user types instructions in plain text; the LLM applies them to the post. // Returns: // - action="sugestao_aplicada" + newText → user approved a version // - action="reprovar" → user rejected in chat // - action="continuar" + newText → user cancelled chat (back to main menu) func runChatEdit(bot *telegram.Bot, gem *gemini.Client, postText string) dirResult { original := postText current := postText bot.SendMessage("✏️ Modo edição via chat\n\nDigite o que quer mudar no texto.\nExemplos: \"gancho mais direto\", \"remove hashtags de vendas\", \"encurta o terceiro parágrafo\"") for { // Wait for free text from user event, err := bot.WaitForAny(24 * time.Hour) if err != nil { log.Printf("[WARN] chat edit timeout: %v", err) return dirResult{action: "continuar", newText: current} } // If user sent a callback (tapped an old button), ignore if event.IsCallback { bot.SendMessage("💬 Estamos no modo chat. Digite sua instrução de edição como texto, ou use os botões abaixo quando estiver pronto.") continue } instrucao := strings.TrimSpace(event.Text) if instrucao == "" { continue } log.Printf("[INFO] chat edit: %q", instrucao) bot.SendMessage("⏳ Aplicando sua instrução...") updated, err := applySugestao(gem, current, instrucao) if err != nil { bot.SendMessage(fmt.Sprintf("⚠️ Erro ao aplicar edição: %v\n\nTente novamente.", err)) continue } current = strings.TrimSpace(updated) // Show updated post preview := "✍️ Texto atualizado:\n─────────────────────────────\n" + truncate(current, 2500) bot.SendMessage(preview) // Show chat action buttons buttons := [][]telegram.InlineButton{ {{Text: "✅ Aprovar esta versão", CallbackData: "chat_aprovar"}}, {{Text: "✏️ Editar mais", CallbackData: "chat_editar_mais"}}, {{Text: "↩️ Cancelar e voltar ao menu", CallbackData: "chat_cancelar"}}, } if _, err := bot.SendMessageWithKeyboard("O que fazemos?", buttons); err != nil { log.Printf("[WARN] botões chat: %v", err) } // Wait for button response cb, err := bot.WaitForCallback(chatCallbacks, 24*time.Hour) if err != nil { log.Printf("[WARN] chat button timeout: %v", err) return dirResult{action: "continuar", newText: current} } switch cb { case "chat_aprovar": return dirResult{action: "sugestao_aplicada", newText: current} case "chat_editar_mais": // Restore original text if user wants to restart, or keep current? // Keep current — user refines incrementally bot.SendMessage("✏️ Ok, continue editando. Digite a próxima instrução:") // Loop continues — next iteration reads another free-text message case "chat_cancelar": if current != original { // Ask if they want to keep or discard changes keepButtons := [][]telegram.InlineButton{ {{Text: "✅ Manter edições e voltar ao menu", CallbackData: "chat_cancelar_manter"}}, {{Text: "🗑️ Descartar edições e voltar ao menu", CallbackData: "chat_cancelar_descartar"}}, } bot.SendMessageWithKeyboard("Você fez edições. O que prefere?", keepButtons) cb2, err := bot.WaitForCallback([]string{"chat_cancelar_manter", "chat_cancelar_descartar"}, 24*time.Hour) if err != nil || cb2 == "chat_cancelar_descartar" { return dirResult{action: "continuar", newText: original} } return dirResult{action: "continuar", newText: current} } return dirResult{action: "continuar", newText: current} } } } // ─── Dry-run printer ────────────────────────────────────────────────────────── func printDryRun(s *state.PostState, slug string, postText, originalText string, inputImgs, outputImgs []string) { sep := strings.Repeat("═", 60) fmt.Println(sep) fmt.Printf("ldpost-director | DRY-RUN | Post: %s\n", slug) fmt.Println(sep) fmt.Printf("Data: %s\n", time.Now().Format("02/01/2006")) fmt.Printf("Formato: %s | Funil: %s\n", s.Formato, s.FunilTag) fmt.Printf("Ciclos: %d\n", s.Aprovacoes.Texto.Ciclos) fmt.Printf("Palavras post final: ~%d\n", wordCount(postText)) fmt.Println() fmt.Println("── TEXTO ORIGINAL ──────────────────────────────────────") fmt.Println(truncate(originalText, 800)) fmt.Println() fmt.Println("── TEXTO FINAL ─────────────────────────────────────────") fmt.Println(postText) fmt.Println() if len(inputImgs) > 0 { fmt.Printf("── IMAGENS INPUT (%d) ──────────────────────────────────\n", len(inputImgs)) for _, p := range inputImgs { fmt.Println(" ", p) } fmt.Println() } fmt.Printf("── IMAGENS OUTPUT (%d) ─────────────────────────────────\n", len(outputImgs)) for _, p := range outputImgs { fmt.Println(" ", p) } fmt.Println(sep) } // ─── Main ───────────────────────────────────────────────────────────────────── func main() { log.SetFlags(log.Ltime) var ( flagPost string flagWorkspace string flagDryRun bool flagResume bool flagSkipImages bool ) root := &cobra.Command{ Use: "ldpost-director --post ", Short: "Aprovação final via Telegram antes de publicar", RunE: func(cmd *cobra.Command, args []string) error { if flagPost == "" { return fmt.Errorf("--post é obrigatório") } cfg := config.Load() if flagWorkspace != "" { cfg.Workspace = flagWorkspace } // ── 1. Encontrar post e validar state ───────────────────────── postPath, err := workspace.FindPostBySlug(cfg.Workspace, flagPost) if err != nil { return fmt.Errorf("post %q: %w", flagPost, err) } s, err := state.LoadState(postPath) if err != nil { return fmt.Errorf("ler state: %w", err) } validStatuses := []string{state.StatusWaitingDirector} if flagSkipImages { validStatuses = append(validStatuses, state.StatusWaitingArt) } ok := false for _, vs := range validStatuses { if s.IsStatus(vs) { ok = true break } } if !ok { log.Printf("[ERROR] status atual: %q — esperado: %v", s.Status, validStatuses) return fmt.Errorf("estado incorreto — rode o agente correto para o status atual") } // ── 2. Validar arquivos ──────────────────────────────────────── required := collectRequiredFiles(postPath) if flagSkipImages { // Only require text files var textOnly []requiredFile for _, f := range required { if f.label != "output/slide1.png" && f.label != "output/slide2.png" { textOnly = append(textOnly, f) } } required = textOnly } if missing := validateFiles(required); len(missing) > 0 { for _, m := range missing { log.Printf("[ERROR] arquivo ausente: %s", m) } return fmt.Errorf("%d arquivo(s) ausente(s) — verifique os agentes anteriores", len(missing)) } // ── 3. Ler conteúdos ────────────────────────────────────────── finalPath := filepath.Join(workspace.WorkPath(postPath), "editor-final.md") postData, err := os.ReadFile(finalPath) if err != nil { return fmt.Errorf("ler editor-final.md: %w", err) } postText := strings.TrimSpace(string(postData)) originalData, err := os.ReadFile(workspace.InputTextoPath(postPath)) if err != nil { return fmt.Errorf("ler input/texto.md: %w", err) } originalText := strings.TrimSpace(string(originalData)) var outputImgs []string if !flagSkipImages { outputImgs = []string{ filepath.Join(workspace.OutputPath(postPath), "slide1.png"), filepath.Join(workspace.OutputPath(postPath), "slide2.png"), } } // Collect input images if they exist var inputImgs []string for _, name := range []string{"imagem1.png", "imagem1.jpg", "imagem2.png", "imagem2.jpg"} { p := filepath.Join(workspace.InputPath(postPath), name) if _, err := os.Stat(p); err == nil { inputImgs = append(inputImgs, p) break // only one needed per slide for display } } // look for imagem2 separately for _, name := range []string{"imagem2.png", "imagem2.jpg"} { p := filepath.Join(workspace.InputPath(postPath), name) if _, err := os.Stat(p); err == nil { inputImgs = append(inputImgs, p) break } } log.Printf("[INFO] post=%s formato=%s palavras=%d", s.Slug, s.Formato, wordCount(postText)) // ── 4. Dry-run ──────────────────────────────────────────────── if flagDryRun { printDryRun(s, flagPost, postText, originalText, inputImgs, outputImgs) return nil } // ── 5. Telegram ─────────────────────────────────────────────── if cfg.TelegramBotToken == "" || cfg.TelegramChatID == "" { log.Printf("[INFO] Telegram não configurado — aprovação automática local") return finalizeAprovado(postPath, s, flagPost) } if !flagDryRun { if err := cfg.Validate("groq"); err != nil { return err } } bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID) gc := groq.NewGroqClient(cfg.GroqAPIKey) gem := gemini.New(cfg.GeminiAPIKey) // Mark polling active s.PollingActive = true if err := state.SaveState(postPath, s); err != nil { log.Printf("[WARN] state polling_active: %v", err) } for { if !flagResume { log.Printf("[INFO] enviando pacote ao Telegram...") msgID, err := sendPackage(bot, s, flagPost, postText, originalText, inputImgs, outputImgs) if err != nil { log.Printf("[WARN] sendPackage: %v", err) } else { s.DirectorMessageID = msgID state.SaveState(postPath, s) } } flagResume = false // only skip send on first iteration if --resume log.Printf("[INFO] aguardando decisão no Telegram (timeout 24h)...") result := waitDecision(bot, gc, gem, postText) switch result.action { case "aprovar": bot.SendMessage(fmt.Sprintf( "✅ Aprovado!\n\nExecute: ldpost-publisher --post %s", flagPost)) s.PollingActive = false return finalizeAprovado(postPath, s, flagPost) case "reprovar": bot.SendMessage(fmt.Sprintf( "❌ Post reprovado e arquivado.\nOs arquivos de work/ e output/ foram mantidos para referência.\nPara recomeçar do zero: ldpost-evaluator --force-slug %s", flagPost)) s.Status = state.StatusRejected s.PollingActive = false s.SetEtapa("director", state.EtapaDone) state.SaveState(postPath, s) return fmt.Errorf("post reprovado pelo operador") case "revisar_texto": bot.SendMessage(fmt.Sprintf( "🔄 Voltando para o editor.\nExecute: ldpost-editor --post %s", flagPost)) s.Status = state.StatusWaitingEditor s.PollingActive = false s.SetEtapa("director", state.EtapaPending) s.SetEtapa("editor", state.EtapaWaiting) state.SaveState(postPath, s) fmt.Printf("De volta ao editor. Rode: ldpost-editor --post %s\n", flagPost) return nil case "revisar_arte": bot.SendMessage(fmt.Sprintf( "🎨 Voltando para o art.\nExecute: ldpost-art --post %s", flagPost)) s.Status = state.StatusWaitingArt s.PollingActive = false s.SetEtapa("director", state.EtapaPending) s.SetEtapa("art", state.EtapaWaiting) state.SaveState(postPath, s) fmt.Printf("De volta ao art. Rode: ldpost-art --post %s\n", flagPost) return nil case "sugestao_aplicada", "continuar": // Save updated text and loop back to re-send package if result.newText == postText { // No change (chat cancelled with no edits) — just re-send menu continue } postText = result.newText finalPath := filepath.Join(workspace.WorkPath(postPath), "editor-final.md") if err := os.WriteFile(finalPath, []byte(postText), 0644); err != nil { log.Printf("[WARN] salvar editor-final.md: %v", err) } if err := os.WriteFile(workspace.OutputPostPath(postPath), []byte(postText), 0644); err != nil { log.Printf("[WARN] salvar output/post.md: %v", err) } _, lastN := workspace.LatestVersionFile(postPath, "editor") vp := workspace.VersionedFile(postPath, "editor", lastN+1) os.WriteFile(vp, []byte(postText), 0644) log.Printf("[INFO] texto atualizado salvo em %s — reenviando pacote", filepath.Base(vp)) if result.action == "sugestao_aplicada" { bot.SendMessage("✅ Sugestão aplicada. Reenviando pacote atualizado...") } else { bot.SendMessage("↩️ Voltando ao menu com texto editado...") } continue } } }, } root.Flags().StringVar(&flagPost, "post", "", "Slug do post (obrigatório)") root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE") root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Exibe pacote no terminal sem enviar Telegram") root.Flags().BoolVar(&flagResume, "resume", false, "Retoma director aguardando callback") root.Flags().BoolVar(&flagSkipImages, "skip-images", false, "Pula validação e envio de imagens (teste de texto)") if err := root.Execute(); err != nil { os.Exit(1) } } // ─── State helpers ──────────────────────────────────────────────────────────── func finalizeAprovado(postPath string, s *state.PostState, slug string) error { s.Status = state.StatusWaitingPublisher s.SetEtapa("director", state.EtapaDone) s.SetEtapa("publisher", state.EtapaWaiting) s.Aprovacoes.Final.Aprovado = true s.Aprovacoes.Final.Timestamp = time.Now() if err := state.SaveState(postPath, s); err != nil { return fmt.Errorf("state: %w", err) } fmt.Printf("Aprovado. Rode: ldpost-publisher --post %s\n", slug) return nil }