Jobmaker-LdPost/director/main.go
Ricardo Carneiro ea532659b0 feat: pipeline inicial ldpost-squad (6 agentes)
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>
2026-05-03 18:55:39 -03:00

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
}