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 ", 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( "🚀 Post publicado!\n\n%s\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) } }