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>
667 lines
25 KiB
Go
667 lines
25 KiB
Go
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(
|
|
"📋 <b>ldpost-director</b> | Aprovação Final\n📅 %s\n📝 Post: <code>%s</code>\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 := "📄 <b>TEXTO ORIGINAL (seu resumo)</b>\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 := "✍️ <b>TEXTO FINAL (post LinkedIn)</b>\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("⬇️ <b>Decisão final:</b>", buttons)
|
|
return
|
|
}
|
|
|
|
func sendSugestoes(bot *telegram.Bot, sug [3]string) error {
|
|
text := fmt.Sprintf("💡 <b>Sugestões do squad:</b>\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("✏️ <b>Modo edição via chat</b>\n\nDigite o que quer mudar no texto.\n<i>Exemplos: \"gancho mais direto\", \"remove hashtags de vendas\", \"encurta o terceiro parágrafo\"</i>")
|
|
|
|
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 := "✍️ <b>Texto atualizado:</b>\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 <slug>",
|
|
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(
|
|
"✅ <b>Aprovado!</b>\n\nExecute: <code>ldpost-publisher --post %s</code>", 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: <code>ldpost-evaluator --force-slug %s</code>", 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: <code>ldpost-editor --post %s</code>", 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: <code>ldpost-art --post %s</code>", 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
|
|
}
|