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>
600 lines
20 KiB
Go
600 lines
20 KiB
Go
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/spf13/cobra"
|
||
"ldpost/shared/config"
|
||
"ldpost/shared/history"
|
||
"ldpost/shared/state"
|
||
"ldpost/shared/telegram"
|
||
"ldpost/shared/workspace"
|
||
)
|
||
|
||
const linkedInAPIBase = "https://api.linkedin.com/v2"
|
||
|
||
// ─── LinkedIn types ───────────────────────────────────────────────────────────
|
||
|
||
type ugcPost struct {
|
||
Author string `json:"author"`
|
||
LifecycleState string `json:"lifecycleState"`
|
||
SpecificContent specificContent `json:"specificContent"`
|
||
Visibility visibility `json:"visibility"`
|
||
}
|
||
|
||
type specificContent struct {
|
||
ShareContent shareContent `json:"com.linkedin.ugc.ShareContent"`
|
||
}
|
||
|
||
type shareContent struct {
|
||
ShareCommentary shareCommentary `json:"shareCommentary"`
|
||
ShareMediaCategory string `json:"shareMediaCategory"`
|
||
Media []ugcMedia `json:"media,omitempty"`
|
||
}
|
||
|
||
type shareCommentary struct {
|
||
Text string `json:"text"`
|
||
}
|
||
|
||
type ugcMedia struct {
|
||
Status string `json:"status"`
|
||
Description localText `json:"description"`
|
||
Media string `json:"media,omitempty"`
|
||
Title localText `json:"title"`
|
||
}
|
||
|
||
type localText struct {
|
||
Text string `json:"text"`
|
||
}
|
||
|
||
type visibility struct {
|
||
MemberNetworkVisibility string `json:"com.linkedin.ugc.MemberNetworkVisibility"`
|
||
}
|
||
|
||
// ─── LinkedIn helpers ─────────────────────────────────────────────────────────
|
||
|
||
// checkToken returns (personID, nil) on 200, ("", err) on failure or 401.
|
||
// Uses /v2/userinfo (OpenID Connect) — requires openid+profile scopes.
|
||
// The "sub" field contains the member ID used for urn:li:person:{id}.
|
||
func checkToken(token string) (string, error) {
|
||
req, _ := http.NewRequest("GET", linkedInAPIBase+"/userinfo", nil)
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("GET /userinfo: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode == 401 {
|
||
return "", fmt.Errorf("token inválido ou expirado (401)")
|
||
}
|
||
|
||
body, _ := io.ReadAll(resp.Body)
|
||
var result struct {
|
||
Sub string `json:"sub"` // OpenID Connect member ID
|
||
}
|
||
if err := json.Unmarshal(body, &result); err != nil || result.Sub == "" {
|
||
return "", fmt.Errorf("LinkedIn /userinfo inválido: %s", string(body))
|
||
}
|
||
return result.Sub, nil
|
||
}
|
||
|
||
func uploadImage(token, personURN, imgPath, slideLabel string) (string, error) {
|
||
regReq := map[string]any{
|
||
"registerUploadRequest": map[string]any{
|
||
"recipes": []string{"urn:li:digitalmediaRecipe:feedshare-document"},
|
||
"owner": personURN,
|
||
"serviceRelationships": []map[string]string{
|
||
{"relationshipType": "OWNER", "identifier": "urn:li:userGeneratedContent"},
|
||
},
|
||
},
|
||
}
|
||
regData, _ := json.Marshal(regReq)
|
||
|
||
req, _ := http.NewRequest("POST", linkedInAPIBase+"/assets?action=registerUpload", bytes.NewReader(regData))
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("X-Restli-Protocol-Version", "2.0.0")
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("registerUpload %s: %w", slideLabel, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
body, _ := io.ReadAll(resp.Body)
|
||
|
||
var regResp struct {
|
||
Value struct {
|
||
UploadMechanism struct {
|
||
Req struct {
|
||
UploadURL string `json:"uploadUrl"`
|
||
Headers map[string]string `json:"headers"`
|
||
} `json:"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"`
|
||
} `json:"uploadMechanism"`
|
||
Asset string `json:"asset"`
|
||
} `json:"value"`
|
||
}
|
||
if err := json.Unmarshal(body, ®Resp); err != nil {
|
||
return "", fmt.Errorf("parsear registerUpload %s: %w (body: %s)", slideLabel, err, string(body))
|
||
}
|
||
uploadURL := regResp.Value.UploadMechanism.Req.UploadURL
|
||
assetURN := regResp.Value.Asset
|
||
if uploadURL == "" {
|
||
return "", fmt.Errorf("uploadURL vazia para %s: %s", slideLabel, string(body))
|
||
}
|
||
|
||
imgData, err := os.ReadFile(imgPath)
|
||
if err != nil {
|
||
return "", fmt.Errorf("ler %s: %w", slideLabel, err)
|
||
}
|
||
|
||
putReq, _ := http.NewRequest("PUT", uploadURL, bytes.NewReader(imgData))
|
||
for k, v := range regResp.Value.UploadMechanism.Req.Headers {
|
||
putReq.Header.Set(k, v)
|
||
}
|
||
putReq.Header.Set("Content-Type", "application/octet-stream")
|
||
|
||
putResp, err := http.DefaultClient.Do(putReq)
|
||
if err != nil {
|
||
return "", fmt.Errorf("PUT %s: %w", slideLabel, err)
|
||
}
|
||
defer putResp.Body.Close()
|
||
if putResp.StatusCode >= 300 {
|
||
b, _ := io.ReadAll(putResp.Body)
|
||
return "", fmt.Errorf("upload %s status %d: %s", slideLabel, putResp.StatusCode, string(b))
|
||
}
|
||
|
||
log.Printf("[INFO] %s uploaded → %s", slideLabel, assetURN)
|
||
return assetURN, nil
|
||
}
|
||
|
||
func publishPost(token, personURN, text string, assetURNs []string) (string, error) {
|
||
var mediaList []ugcMedia
|
||
for i, urn := range assetURNs {
|
||
label := fmt.Sprintf("Slide %d", i+1)
|
||
mediaList = append(mediaList, ugcMedia{
|
||
Status: "READY",
|
||
Description: localText{label},
|
||
Media: urn,
|
||
Title: localText{label},
|
||
})
|
||
}
|
||
|
||
cat := "NONE"
|
||
if len(mediaList) > 0 {
|
||
cat = "IMAGE"
|
||
}
|
||
|
||
post := ugcPost{
|
||
Author: personURN,
|
||
LifecycleState: "PUBLISHED",
|
||
SpecificContent: specificContent{
|
||
ShareContent: shareContent{
|
||
ShareCommentary: shareCommentary{Text: text},
|
||
ShareMediaCategory: cat,
|
||
Media: mediaList,
|
||
},
|
||
},
|
||
Visibility: visibility{MemberNetworkVisibility: "PUBLIC"},
|
||
}
|
||
|
||
postData, _ := json.Marshal(post)
|
||
req, _ := http.NewRequest("POST", linkedInAPIBase+"/ugcPosts", bytes.NewReader(postData))
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("X-Restli-Protocol-Version", "2.0.0")
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("POST /ugcPosts: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
body, _ := io.ReadAll(resp.Body)
|
||
if resp.StatusCode >= 300 {
|
||
return "", fmt.Errorf("ugcPosts status %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
|
||
var result struct {
|
||
ID string `json:"id"`
|
||
}
|
||
if err := json.Unmarshal(body, &result); err != nil || result.ID == "" {
|
||
return "", fmt.Errorf("parsear ugcPosts: %w (body: %s)", err, string(body))
|
||
}
|
||
return result.ID, nil
|
||
}
|
||
|
||
// ─── Markdown cleanup ─────────────────────────────────────────────────────────
|
||
|
||
var headerRe = regexp.MustCompile(`(?m)^#{1,6}\s+`)
|
||
|
||
func cleanMarkdown(text string) string {
|
||
// Remove header markers
|
||
text = headerRe.ReplaceAllString(text, "")
|
||
// Remove bold/italic markers
|
||
text = strings.ReplaceAll(text, "**", "")
|
||
text = strings.ReplaceAll(text, "__", "")
|
||
// Remove YAML front-matter if present
|
||
if strings.HasPrefix(text, "---") {
|
||
if end := strings.Index(text[3:], "---"); end >= 0 {
|
||
text = text[end+6:]
|
||
}
|
||
}
|
||
return strings.TrimSpace(text)
|
||
}
|
||
|
||
// ─── OAuth2 flow ──────────────────────────────────────────────────────────────
|
||
|
||
func runOAuthFlow(cfg *config.Config) error {
|
||
if cfg.LinkedInClientID == "" || cfg.LinkedInClientSecret == "" {
|
||
return fmt.Errorf("LINKEDIN_CLIENT_ID e LINKEDIN_CLIENT_SECRET são obrigatórios para --auth")
|
||
}
|
||
|
||
redirectURI := "http://localhost:8080/callback"
|
||
authURL := fmt.Sprintf(
|
||
"https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=%s&redirect_uri=%s&scope=%s",
|
||
cfg.LinkedInClientID,
|
||
url.QueryEscape(redirectURI),
|
||
url.QueryEscape("w_member_social openid profile"),
|
||
)
|
||
|
||
fmt.Println("Token LinkedIn não encontrado. Configurando OAuth2.")
|
||
fmt.Println()
|
||
fmt.Println("1. Abra esta URL no navegador:")
|
||
fmt.Println(" ", authURL)
|
||
fmt.Println()
|
||
fmt.Println("2. Aguardando callback em", redirectURI, "...")
|
||
|
||
codeCh := make(chan string, 1)
|
||
errCh := make(chan error, 1)
|
||
|
||
srv := &http.Server{Addr: ":8080"}
|
||
http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||
code := r.URL.Query().Get("code")
|
||
if code == "" {
|
||
errCh <- fmt.Errorf("código ausente no callback: %s", r.URL.RawQuery)
|
||
fmt.Fprintf(w, "Erro: código ausente.")
|
||
return
|
||
}
|
||
codeCh <- code
|
||
fmt.Fprintf(w, "Autorização recebida! Pode fechar esta janela.")
|
||
})
|
||
|
||
ln, err := net.Listen("tcp", ":8080")
|
||
if err != nil {
|
||
return fmt.Errorf("abrir porta 8080: %w", err)
|
||
}
|
||
go srv.Serve(ln)
|
||
|
||
var code string
|
||
select {
|
||
case code = <-codeCh:
|
||
case err = <-errCh:
|
||
srv.Shutdown(context.Background())
|
||
return err
|
||
case <-time.After(5 * time.Minute):
|
||
srv.Shutdown(context.Background())
|
||
return fmt.Errorf("timeout aguardando autorização (5 min)")
|
||
}
|
||
srv.Shutdown(context.Background())
|
||
|
||
// Exchange code for token
|
||
params := url.Values{
|
||
"grant_type": {"authorization_code"},
|
||
"code": {code},
|
||
"redirect_uri": {redirectURI},
|
||
"client_id": {cfg.LinkedInClientID},
|
||
"client_secret": {cfg.LinkedInClientSecret},
|
||
}
|
||
resp, err := http.PostForm("https://www.linkedin.com/oauth/v2/accessToken", params)
|
||
if err != nil {
|
||
return fmt.Errorf("trocar código por token: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
body, _ := io.ReadAll(resp.Body)
|
||
|
||
var tokenResp struct {
|
||
AccessToken string `json:"access_token"`
|
||
}
|
||
if err := json.Unmarshal(body, &tokenResp); err != nil || tokenResp.AccessToken == "" {
|
||
return fmt.Errorf("parsear token response: %w (body: %s)", err, string(body))
|
||
}
|
||
|
||
// Append to .env
|
||
envEntry := fmt.Sprintf("\nLINKEDIN_ACCESS_TOKEN=%s\n", tokenResp.AccessToken)
|
||
f, err := os.OpenFile(".env", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||
if err != nil {
|
||
fmt.Printf("Token obtido! Adicione manualmente ao .env:\nLINKEDIN_ACCESS_TOKEN=%s\n", tokenResp.AccessToken)
|
||
return nil
|
||
}
|
||
f.WriteString(envEntry)
|
||
f.Close()
|
||
fmt.Println("✅ Token salvo em .env — rode ldpost-publisher novamente.")
|
||
return nil
|
||
}
|
||
|
||
// ─── Manual mode ─────────────────────────────────────────────────────────────
|
||
|
||
func runManualMode(postPath string, s *state.PostState, postText string, outputImgs []string, reason string) error {
|
||
sep := strings.Repeat("═", 51)
|
||
fmt.Println(sep)
|
||
fmt.Println("ldpost-publisher | MODO MANUAL")
|
||
fmt.Println(sep)
|
||
fmt.Println()
|
||
fmt.Printf("⚠️ %s\n Modo manual ativado — você vai postar manualmente.\n\n", reason)
|
||
fmt.Println("TEXTO DO POST (copie tudo abaixo da linha):")
|
||
fmt.Println(strings.Repeat("─", 51))
|
||
fmt.Println(postText)
|
||
fmt.Println(strings.Repeat("─", 51))
|
||
fmt.Println()
|
||
if len(outputImgs) > 0 {
|
||
fmt.Println("IMAGENS:")
|
||
for i, img := range outputImgs {
|
||
fmt.Printf(" Slide %d: %s\n", i+1, img)
|
||
}
|
||
fmt.Println()
|
||
}
|
||
fmt.Println("INSTRUÇÕES:")
|
||
fmt.Println(" 1. Abra o LinkedIn no navegador")
|
||
fmt.Println(" 2. Clique em \"Criar post\" → \"Adicionar fotos\"")
|
||
fmt.Println(" 3. Selecione slide1.png e slide2.png (nessa ordem)")
|
||
fmt.Println(" 4. Cole o texto acima na área de texto")
|
||
fmt.Println(" 5. Clique em \"Publicar\"")
|
||
fmt.Println()
|
||
fmt.Print("Após publicar, cole o link do post aqui para registrar:\nURL do post: ")
|
||
|
||
scanner := bufio.NewScanner(os.Stdin)
|
||
if !scanner.Scan() {
|
||
return fmt.Errorf("leitura cancelada")
|
||
}
|
||
postURL := strings.TrimSpace(scanner.Text())
|
||
if postURL == "" {
|
||
log.Printf("[WARN] URL não fornecida — state não atualizado")
|
||
return nil
|
||
}
|
||
|
||
// Extract post ID from URL
|
||
postID := extractPostID(postURL)
|
||
return finalizePublished(postPath, s, postID, postURL)
|
||
}
|
||
|
||
// extractPostID attempts to pull the URN from a LinkedIn post URL.
|
||
func extractPostID(rawURL string) string {
|
||
// e.g. https://www.linkedin.com/feed/update/urn:li:ugcPost:123/
|
||
parts := strings.Split(rawURL, "/")
|
||
for _, p := range parts {
|
||
if strings.HasPrefix(p, "urn:li:") {
|
||
return p
|
||
}
|
||
}
|
||
return rawURL // fallback: store the URL itself as ID
|
||
}
|
||
|
||
// ─── State finalization ───────────────────────────────────────────────────────
|
||
|
||
func finalizePublished(postPath string, s *state.PostState, postID, postURL string) error {
|
||
now := time.Now()
|
||
s.Status = state.StatusPublished
|
||
s.SetEtapa("publisher", state.EtapaDone)
|
||
s.LinkedInPostID = postID
|
||
s.PublishedAt = &now
|
||
if err := state.SaveState(postPath, s); err != nil {
|
||
return fmt.Errorf("state: %w", err)
|
||
}
|
||
|
||
fmt.Println()
|
||
fmt.Println("✅ POST PUBLICADO!")
|
||
fmt.Println()
|
||
fmt.Printf("LinkedIn: %s\n", postURL)
|
||
fmt.Printf("Slug: %s\n", s.Slug)
|
||
fmt.Printf("Publicado em: %s\n", now.Format("02/01/2006 15:04"))
|
||
fmt.Println()
|
||
fmt.Println("Parabéns. Agora atualize as métricas em 48h para o evaluator.")
|
||
return nil
|
||
}
|
||
|
||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||
|
||
func main() {
|
||
log.SetFlags(log.Ltime)
|
||
|
||
var (
|
||
flagPost string
|
||
flagWorkspace string
|
||
flagDryRun bool
|
||
flagManual bool
|
||
flagAuth bool
|
||
)
|
||
|
||
root := &cobra.Command{
|
||
Use: "ldpost-publisher --post <slug>",
|
||
Short: "Publica o post aprovado no LinkedIn",
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
cfg := config.Load()
|
||
if flagWorkspace != "" {
|
||
cfg.Workspace = flagWorkspace
|
||
}
|
||
|
||
// ── --auth: OAuth2 setup flow ─────────────────────────────────
|
||
if flagAuth {
|
||
return runOAuthFlow(cfg)
|
||
}
|
||
|
||
if flagPost == "" {
|
||
return fmt.Errorf("--post é obrigatório")
|
||
}
|
||
|
||
// ── 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)
|
||
}
|
||
if !s.IsStatus(state.StatusWaitingPublisher) {
|
||
log.Printf("[ERROR] status atual: %q — esperado: %q", s.Status, state.StatusWaitingPublisher)
|
||
return fmt.Errorf("estado incorreto — rode ldpost-director --post %s primeiro", flagPost)
|
||
}
|
||
if !s.Aprovacoes.Final.Aprovado && !flagDryRun && !flagManual {
|
||
return fmt.Errorf("post sem aprovação final — rode ldpost-director --post %s", flagPost)
|
||
}
|
||
|
||
// ── 2. Ler conteúdo ───────────────────────────────────────────
|
||
postText := ""
|
||
if data, err := os.ReadFile(workspace.OutputPostPath(postPath)); err == nil {
|
||
postText = cleanMarkdown(string(data))
|
||
} else if data, err := os.ReadFile(filepath.Join(workspace.WorkPath(postPath), "editor-final.md")); err == nil {
|
||
postText = cleanMarkdown(string(data))
|
||
} else {
|
||
return fmt.Errorf("output/post.md e work/editor-final.md não encontrados")
|
||
}
|
||
|
||
slide1 := filepath.Join(workspace.OutputPath(postPath), "slide1.png")
|
||
slide2 := filepath.Join(workspace.OutputPath(postPath), "slide2.png")
|
||
outputImgs := []string{}
|
||
for _, p := range []string{slide1, slide2} {
|
||
if _, err := os.Stat(p); err == nil {
|
||
outputImgs = append(outputImgs, p)
|
||
}
|
||
}
|
||
|
||
log.Printf("[INFO] post=%s chars=%d imagens=%d", s.Slug, len(postText), len(outputImgs))
|
||
|
||
// ── 3. Dry-run ────────────────────────────────────────────────
|
||
if flagDryRun {
|
||
fmt.Println("⚠️ DRY-RUN — NENHUMA CHAMADA À API LINKEDIN SERÁ FEITA")
|
||
fmt.Println(strings.Repeat("═", 51))
|
||
fmt.Printf("Post: %s | Chars: %d | Imagens: %d\n\n", flagPost, len(postText), len(outputImgs))
|
||
fmt.Println("── TEXTO LIMPO ──────────────────────────────────────")
|
||
fmt.Println(postText)
|
||
fmt.Println(strings.Repeat("─", 51))
|
||
fmt.Printf("Aprovado: %v\n", s.Aprovacoes.Final.Aprovado)
|
||
return nil
|
||
}
|
||
|
||
// ── 4. Verificar token / decidir modo ─────────────────────────
|
||
var personID string
|
||
manualReason := ""
|
||
|
||
if flagManual {
|
||
manualReason = "Modo manual solicitado via --manual."
|
||
} else if cfg.LinkedInAccessToken == "" {
|
||
manualReason = "Token LinkedIn não configurado."
|
||
if cfg.LinkedInClientID != "" {
|
||
manualReason += " Configure com: ldpost-publisher --auth"
|
||
}
|
||
} else {
|
||
id, err := checkToken(cfg.LinkedInAccessToken)
|
||
if err != nil {
|
||
manualReason = fmt.Sprintf("Token LinkedIn inválido: %v", err)
|
||
} else {
|
||
personID = id
|
||
}
|
||
}
|
||
|
||
if manualReason != "" {
|
||
return runManualMode(postPath, s, postText, outputImgs, manualReason)
|
||
}
|
||
|
||
personURN := "urn:li:person:" + personID
|
||
log.Printf("[INFO] person URN: %s", personURN)
|
||
|
||
// ── 5. Confirmação antes de publicar ──────────────────────────
|
||
fmt.Printf("\n⚠️ PUBLICAÇÃO IRREVERSÍVEL\n")
|
||
fmt.Printf("Post: %s | Formato: %s\n", s.Slug, s.Formato)
|
||
fmt.Printf("Confirmar publicação? (s/N): ")
|
||
scanner := bufio.NewScanner(os.Stdin)
|
||
if !scanner.Scan() || strings.ToLower(strings.TrimSpace(scanner.Text())) != "s" {
|
||
fmt.Println("Publicação cancelada.")
|
||
return nil
|
||
}
|
||
|
||
// ── 6. Upload imagens ─────────────────────────────────────────
|
||
var assetURNs []string
|
||
for i, imgPath := range outputImgs {
|
||
label := fmt.Sprintf("slide%d", i+1)
|
||
log.Printf("[INFO] upload %s...", label)
|
||
urn, err := uploadImage(cfg.LinkedInAccessToken, personURN, imgPath, label)
|
||
if err != nil {
|
||
log.Printf("[ERROR] upload %s: %v", label, err)
|
||
if len(assetURNs) > 0 {
|
||
log.Printf("[INFO] asset URNs já obtidos (para retry): %v", assetURNs)
|
||
}
|
||
return fmt.Errorf("falha no upload de %s: %w", label, err)
|
||
}
|
||
assetURNs = append(assetURNs, urn)
|
||
}
|
||
|
||
// ── 7. Publicar post ──────────────────────────────────────────
|
||
log.Printf("[INFO] publicando post no LinkedIn...")
|
||
postID, err := publishPost(cfg.LinkedInAccessToken, personURN, postText, assetURNs)
|
||
if err != nil {
|
||
log.Printf("[INFO] asset URNs para retry: %v", assetURNs)
|
||
return fmt.Errorf("publicar: %w", err)
|
||
}
|
||
|
||
postURL := fmt.Sprintf("https://www.linkedin.com/feed/update/%s/", postID)
|
||
log.Printf("[INFO] publicado: %s", postURL)
|
||
|
||
// ── 8. Finalizar state e histórico ────────────────────────────
|
||
if err := finalizePublished(postPath, s, postID, postURL); err != nil {
|
||
return err
|
||
}
|
||
|
||
hist, err := history.LoadHistory(cfg.Workspace)
|
||
if err != nil {
|
||
hist = &history.FormatHistory{}
|
||
}
|
||
hist.AddEntry(s.Slug, s.Formato)
|
||
if err := history.SaveHistory(cfg.Workspace, hist); err != nil {
|
||
log.Printf("[WARN] format-history: %v", err)
|
||
}
|
||
|
||
// Ensure output/post.md exists with clean text
|
||
if _, err := os.Stat(workspace.OutputPostPath(postPath)); os.IsNotExist(err) {
|
||
os.WriteFile(workspace.OutputPostPath(postPath), []byte(postText), 0644)
|
||
}
|
||
|
||
// ── 9. Notificação Telegram ───────────────────────────────────
|
||
if cfg.TelegramBotToken != "" && cfg.TelegramChatID != "" {
|
||
bot := telegram.NewBot(cfg.TelegramBotToken, cfg.TelegramChatID)
|
||
msg := fmt.Sprintf(
|
||
"🚀 <b>Post publicado!</b>\n\n<code>%s</code>\n🔗 %s\n📅 %s",
|
||
s.Slug, postURL, time.Now().Format("02/01/2006 15:04"),
|
||
)
|
||
if _, err := bot.SendMessage(msg); err != nil {
|
||
log.Printf("[WARN] Telegram: %v", err)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
},
|
||
}
|
||
|
||
root.Flags().StringVar(&flagPost, "post", "", "Slug do post (obrigatório, exceto com --auth)")
|
||
root.Flags().StringVar(&flagWorkspace, "workspace", "", "Override de LDPOST_WORKSPACE")
|
||
root.Flags().BoolVar(&flagDryRun, "dry-run", false, "Prepara tudo sem chamar LinkedIn API")
|
||
root.Flags().BoolVar(&flagManual, "manual", false, "Força modo manual mesmo com token configurado")
|
||
root.Flags().BoolVar(&flagAuth, "auth", false, "Inicia fluxo OAuth2 para configurar token")
|
||
|
||
if err := root.Execute(); err != nil {
|
||
os.Exit(1)
|
||
}
|
||
}
|