package usecase import ( "context" "crypto/rand" "encoding/json" "errors" "fmt" "net/url" "strings" "time" "unicode/utf8" "apps/backend/internal/module/ai" fsDomain "apps/backend/internal/module/filestorage/domain" "apps/backend/internal/module/studio/domain" "apps/backend/internal/module/studio/publish" threadsDomain "apps/backend/internal/module/threads/domain" usageDomain "apps/backend/internal/module/usage/domain" usageUC "apps/backend/internal/module/usage/usecase" "github.com/google/uuid" "github.com/zeromicro/go-zero/core/logx" ) // AccountLookup resolves Threads accounts for ownership + token. type AccountLookup interface { Get(ctx context.Context, id string) (*threadsDomain.Account, error) List(ctx context.Context, ownerUID int64) ([]*threadsDomain.Account, error) // DecryptAccess returns plaintext access token for publish (or fake token). AccessToken(ctx context.Context, a *threadsDomain.Account) (string, error) } // CrawlerSessionSource optional Playwright storageState for profile crawl. type CrawlerSessionSource interface { GetCrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error) } // ThreadsMediaSource — 真 Threads Graph:列貼文/insights/對話回覆/提及/公開人設。 // 未設定時 SyncOwnPosts 退回假 seed(測試/無 Meta)。 type ThreadsMediaSource interface { ListThreads(ctx context.Context, accessToken string, limit int) ([]FetchedThread, error) GetInsights(ctx context.Context, accessToken, mediaID string) (FetchedInsights, error) ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]FetchedReply, error) ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]FetchedMention, error) // ListProfilePosts — 公開 @username 貼文(threads_profile_discovery) ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]FetchedThread, error) } // Fetched* 與 provider 解耦,避免 studio 依賴 provider 包。 type FetchedThread struct { ID, Text, MediaType, MediaURL, ThumbnailURL, Permalink, Shortcode, TopicTag, Username string PublishedAt int64 // unix ns } type FetchedInsights struct { Views, Likes, Replies, Reposts, Quotes, Shares int Status, ErrorMsg string } type FetchedReply struct { ID, Text, Username, ParentMediaID string PublishedAt int64 IsMine bool LikeCount int } // FetchedMention — Graph /{user-id}/mentions type FetchedMention struct { ID, Text, Username, Permalink, RootPostID, ParentID string IsReply, IsQuotePost bool PublishedAt int64 } // AIKeySource resolves provider/model/apiKey for a member (settings + platform). type AIKeySource interface { // ResolveAI returns provider, model, apiKey for LLM calls. ResolveAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error) } // PersonaAnalyzeScheduler enqueues durable background analyze jobs (leave-page safe). // When nil (unit tests), AnalyzeFrom* runs synchronously. type PersonaAnalyzeScheduler interface { SchedulePersonaAnalyzeAccount(ctx context.Context, ownerUID int64, personaID, username, lang string) (jobID string, err error) SchedulePersonaAnalyzeText(ctx context.Context, ownerUID int64, personaID, rawText, sourceLabel, lang string) (jobID string, err error) } // Service is the M4 studio facade (personas/plays/outbox/compose/ownposts/mentions). type Service struct { Repo domain.Repository Transport publish.Transport Accounts AccountLookup Usage *usageUC.Service AI ai.Client // tests / fake fallback // AIRegistry real xai / opencode-go clients AIRegistry *ai.Registry // Keys optional; when set, persona analyze uses member AI settings Keys AIKeySource // Crawler optional: Chrome extension sync'd storageState for public profile scrape Crawler CrawlerSessionSource // Jobs optional: when set, analyze APIs only enqueue; worker runs Execute* Jobs PersonaAnalyzeScheduler // Media optional: Meta Threads 拉「我的貼文」 Media ThreadsMediaSource // Storage optional: 發文暫存圖(temp/*)發成功後刪除 Storage fsDomain.Storage // StoragePublicBase — 與 ObjectStorage.PublicBaseURL 對齊,用來從公開 URL 反推 object key StoragePublicBase string // optional key resolver path via Usage.PrepareCall + Static/Settings KeyModeDefault string // if Usage nil, use this for tests OutboxWorkerID string // OutboxLeaseDuration is configurable for focused lease-renewal tests. OutboxLeaseDuration time.Duration } func New(repo domain.Repository, transport publish.Transport) *Service { return &Service{ Repo: repo, Transport: transport, OutboxWorkerID: "studio-" + uuid.NewString(), OutboxLeaseDuration: 3 * time.Minute, } } // ---------- Personas (PE) ---------- func (s *Service) ListPersonas(ctx context.Context, ownerUID int64) ([]*domain.Persona, error) { return s.Repo.ListPersonas(ctx, ownerUID) } func (s *Service) GetPersona(ctx context.Context, ownerUID int64, id string) (*domain.Persona, error) { p, err := s.Repo.GetPersona(ctx, id) if err != nil { return nil, err } if p.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return p, nil } func (s *Service) SavePersona(ctx context.Context, ownerUID int64, p *domain.Persona) (*domain.Persona, error) { if ownerUID <= 0 { return nil, domain.ErrForbidden } now := domain.NowNano() if p.ID == "" { p.ID = "pe_" + uuid.NewString()[:12] p.CreatedAt = now } else { existing, err := s.Repo.GetPersona(ctx, p.ID) if err == nil { if existing.OwnerUID != ownerUID { return nil, domain.ErrForbidden } p.CreatedAt = existing.CreatedAt p.OwnerUID = existing.OwnerUID } else if err != domain.ErrNotFound { return nil, err } else { p.CreatedAt = now } } p.OwnerUID = ownerUID if p.Status == "" { p.Status = domain.PersonaEmpty } if p.Guard.MaxChars == 0 { p.Guard.MaxChars = 500 } p.UpdatedAt = now if err := s.Repo.SavePersona(ctx, p); err != nil { return nil, err } // first persona becomes active if none aid, _ := s.Repo.GetActivePersonaID(ctx, ownerUID) if aid == "" { _ = s.Repo.SetActivePersonaID(ctx, ownerUID, p.ID) } return p, nil } func (s *Service) RemovePersona(ctx context.Context, ownerUID int64, id string) error { p, err := s.GetPersona(ctx, ownerUID, id) if err != nil { return err } if err := s.Repo.DeletePersona(ctx, p.ID); err != nil { return err } aid, _ := s.Repo.GetActivePersonaID(ctx, ownerUID) if aid == id { list, _ := s.Repo.ListPersonas(ctx, ownerUID) next := "" if len(list) > 0 { next = list[0].ID } _ = s.Repo.SetActivePersonaID(ctx, ownerUID, next) } return nil } func (s *Service) GetActivePersonaID(ctx context.Context, ownerUID int64) (string, error) { return s.Repo.GetActivePersonaID(ctx, ownerUID) } func (s *Service) SetActivePersonaID(ctx context.Context, ownerUID int64, id string) error { if id != "" { if _, err := s.GetPersona(ctx, ownerUID, id); err != nil { return err } } return s.Repo.SetActivePersonaID(ctx, ownerUID, id) } // AnalyzeFromText — API 路徑:有 Jobs 則只入列背景任務(離開頁面不中斷);無 Jobs(測試)同步跑完。 func (s *Service) AnalyzeFromText(ctx context.Context, ownerUID int64, id, rawText, sourceLabel string) (*domain.Persona, error) { p, err := s.GetPersona(ctx, ownerUID, id) if err != nil { return nil, err } rawText = strings.TrimSpace(rawText) if rawText == "" { return nil, fmt.Errorf("%w: empty text", domain.ErrValidation) } samples := splitSamples(rawText, 12) if len(samples) < 2 { return nil, fmt.Errorf("%w: 請至少貼 2 段參考文字(可用 --- 分隔),每段至少約 10 字", domain.ErrValidation) } if s.Jobs != nil { lang := ai.ResponseLanguageFrom(ctx) p.Status = domain.PersonaAnalyzing p.UpdatedAt = domain.NowNano() if err := s.Repo.SavePersona(ctx, p); err != nil { return nil, err } if _, err := s.Jobs.SchedulePersonaAnalyzeText(ctx, ownerUID, p.ID, rawText, sourceLabel, lang); err != nil { p.Status = domain.PersonaEmpty p.UpdatedAt = domain.NowNano() _ = s.Repo.SavePersona(ctx, p) return nil, err } return p, nil } return s.ExecuteAnalyzeFromText(ctx, ownerUID, id, rawText, sourceLabel, nil) } // AnalyzeFromAccount — API 路徑:有 Jobs 則入列(爬文+分析在 worker);無 Jobs 同步。 func (s *Service) AnalyzeFromAccount(ctx context.Context, ownerUID int64, id, username string) (*domain.Persona, error) { p, err := s.GetPersona(ctx, ownerUID, id) if err != nil { return nil, err } username = strings.TrimPrefix(strings.TrimSpace(username), "@") if username == "" { return nil, fmt.Errorf("%w: empty username", domain.ErrValidation) } if strings.ContainsAny(username, " /") || strings.Contains(username, "threads.") { return nil, fmt.Errorf("%w: username 請只填帳號,例如 ultralab_tw", domain.ErrValidation) } if s.Jobs != nil { lang := ai.ResponseLanguageFrom(ctx) p.Status = domain.PersonaAnalyzing p.Style.BenchmarkUsername = username p.UpdatedAt = domain.NowNano() if err := s.Repo.SavePersona(ctx, p); err != nil { return nil, err } if _, err := s.Jobs.SchedulePersonaAnalyzeAccount(ctx, ownerUID, p.ID, username, lang); err != nil { p.Status = domain.PersonaEmpty p.UpdatedAt = domain.NowNano() _ = s.Repo.SavePersona(ctx, p) return nil, err } return p, nil } return s.ExecuteAnalyzeFromAccount(ctx, ownerUID, id, username, nil) } // ExecuteAnalyzeFromText — worker/測試:扣費 + LLM 分析 + 存檔 ready。 func (s *Service) ExecuteAnalyzeFromText(ctx context.Context, ownerUID int64, id, rawText, sourceLabel string, onProgress ProgressFn) (*domain.Persona, error) { report := func(pct int, sum string) { if onProgress != nil { onProgress(pct, sum) } } p, err := s.GetPersona(ctx, ownerUID, id) if err != nil { return nil, err } rawText = strings.TrimSpace(rawText) if rawText == "" { return nil, fmt.Errorf("%w: empty text", domain.ErrValidation) } samples := splitSamples(rawText, 12) if len(samples) < 2 { _ = s.markPersonaAnalyzeFailed(ctx, p, "參考文字不足 2 段") return nil, fmt.Errorf("%w: 請至少貼 2 段參考文字(可用 --- 分隔),每段至少約 10 字", domain.ErrValidation) } report(25, "人設分析 · 檢查文字樣本…") if err := s.billAI(ctx, ownerUID, "persona analyze text", "personas.analyzeFromText"); err != nil { _ = s.markPersonaAnalyzeFailed(ctx, p, err.Error()) return nil, err } p.Status = domain.PersonaAnalyzing p.UpdatedAt = domain.NowNano() _ = s.Repo.SavePersona(ctx, p) label := strings.TrimSpace(sourceLabel) if label == "" { label = "手動貼文" } report(55, fmt.Sprintf("人設分析 · AI 分析「%s」中…", label)) if err := s.finishPersonaStyleAnalysis(ctx, ownerUID, p, samples, "manual", "", label); err != nil { _ = s.markPersonaAnalyzeFailed(ctx, p, err.Error()) return nil, err } report(90, "人設分析 · 寫入指紋/範本…") if err := s.Repo.SavePersona(ctx, p); err != nil { return nil, err } return p, nil } // ExecuteAnalyzeFromAccount — worker:爬公開貼文 + LLM + 存檔 ready。 func (s *Service) ExecuteAnalyzeFromAccount(ctx context.Context, ownerUID int64, id, username string, onProgress ProgressFn) (*domain.Persona, error) { report := func(pct int, sum string) { if onProgress != nil { onProgress(pct, sum) } } p, err := s.GetPersona(ctx, ownerUID, id) if err != nil { return nil, err } username = strings.TrimPrefix(strings.TrimSpace(username), "@") if username == "" { return nil, fmt.Errorf("%w: empty username", domain.ErrValidation) } if strings.ContainsAny(username, " /") || strings.Contains(username, "threads.") { return nil, fmt.Errorf("%w: username 請只填帳號,例如 ultralab_tw", domain.ErrValidation) } report(15, fmt.Sprintf("人設分析 · 準備爬取 @%s…", username)) if err := s.billAI(ctx, ownerUID, "persona analyze account", "personas.analyzeFromAccount"); err != nil { _ = s.markPersonaAnalyzeFailed(ctx, p, err.Error()) return nil, err } p.Status = domain.PersonaAnalyzing p.Style.BenchmarkUsername = username p.UpdatedAt = domain.NowNano() _ = s.Repo.SavePersona(ctx, p) // 取樣順序(不強制 Chrome 爬蟲): // 1) Threads Graph profile_posts(已連帳 + threads_profile_discovery) // 2) Playwright 公開頁(可選 extension session 提高成功率) report(28, fmt.Sprintf("人設分析 · 讀取 @%s 公開貼文…", username)) samples, source, scrapeErr := s.collectPersonaSamples(ctx, ownerUID, username, 12) if source != "" { report(45, fmt.Sprintf("人設分析 · 來源 %s,已抓 %d 則…", source, len(samples))) } // 樣本不足 → 明確錯誤(不要再回英文假貼文) if scrapeErr != nil || len(samples) < 2 { hint := "建議:① 到 Crew 連 Threads 帳號(需重新 OAuth 以取得公開人設權限)後再試;② 或改用「貼上參考文字」;③ 可選:設定頁同步 Chrome Session 提高公開頁爬取成功率。" var msg string if scrapeErr != nil { msg = fmt.Sprintf("無法讀取 @%s 公開貼文(%v)。%s", username, scrapeErr, hint) } else { msg = fmt.Sprintf("@%s 可讀貼文不足 2 篇(目前 %d)。%s", username, len(samples), hint) } _ = s.markPersonaAnalyzeFailed(ctx, p, msg) return nil, fmt.Errorf("%w: %s", domain.ErrValidation, msg) } report(60, fmt.Sprintf("人設分析 · 已抓 %d 則,AI 分析中…", len(samples))) if err := s.finishPersonaStyleAnalysis(ctx, ownerUID, p, samples, "benchmark", username, "@"+username); err != nil { _ = s.markPersonaAnalyzeFailed(ctx, p, err.Error()) return nil, err } report(90, "人設分析 · 寫入指紋/範本…") if err := s.Repo.SavePersona(ctx, p); err != nil { return nil, err } return p, nil } func (s *Service) markPersonaAnalyzeFailed(ctx context.Context, p *domain.Persona, msg string) error { if p == nil { return nil } p.Status = domain.PersonaEmpty if msg != "" { p.Notes = "⚠️ 分析失敗:" + truncate(msg, 240) } p.UpdatedAt = domain.NowNano() return s.Repo.SavePersona(ctx, p) } // collectPersonaSamples — Graph API 優先,Playwright 公開頁後備(Chrome session 可選)。 // source: graph | playwright | "" func (s *Service) collectPersonaSamples(ctx context.Context, ownerUID int64, username string, limit int) (samples []string, source string, err error) { // 1) Meta Graph profile_posts(用任一可用連帳 token) if s.Media != nil && s.Accounts != nil { if texts, gerr := s.fetchProfilePostsViaGraph(ctx, ownerUID, username, limit); gerr == nil && len(texts) >= 2 { return texts, "graph", nil } else if gerr != nil { err = gerr // 保留最後錯誤,若 scrape 也失敗再回 } else if len(texts) > 0 && len(texts) < 2 { err = fmt.Errorf("Graph API 只拿到 %d 則", len(texts)) } } // 2) Playwright 公開頁(不必有 Chrome session;有則帶入) storageState := "" if s.Crawler != nil { if tok, cerr := s.Crawler.GetCrawlerSessionToken(ctx, ownerUID); cerr == nil { storageState = strings.TrimSpace(tok) } } texts, serr := fetchProfilePostTexts(ctx, username, storageState, limit) if serr == nil && len(texts) >= 2 { return texts, "playwright", nil } if serr != nil { if err != nil { return nil, "", fmt.Errorf("Graph: %v; 公開頁: %v", err, serr) } return nil, "", serr } if len(texts) > 0 { return texts, "playwright", nil // 可能 <2,上層再判 } if err != nil { return nil, "", err } return nil, "", fmt.Errorf("找不到公開貼文") } func (s *Service) fetchProfilePostsViaGraph(ctx context.Context, ownerUID int64, username string, limit int) ([]string, error) { if s.Media == nil || s.Accounts == nil { return nil, fmt.Errorf("media/accounts not configured") } accs, err := s.Accounts.List(ctx, ownerUID) if err != nil { return nil, err } var lastErr error for _, acc := range accs { if acc == nil || !acc.IsUsable { continue } token, terr := s.Accounts.AccessToken(ctx, acc) if terr != nil || strings.TrimSpace(token) == "" || strings.HasPrefix(token, "fake-") { continue } list, lerr := s.Media.ListProfilePosts(ctx, token, username, limit) if lerr != nil { lastErr = lerr continue } out := make([]string, 0, len(list)) seen := map[string]struct{}{} for _, th := range list { t := strings.TrimSpace(th.Text) if len(t) < 8 { continue } if _, ok := seen[t]; ok { continue } seen[t] = struct{}{} out = append(out, t) } if len(out) > 0 { return out, nil } } if lastErr != nil { return nil, lastErr } return nil, fmt.Errorf("無可用 Threads 帳號 token 或 profile_posts 為空(請重新連帳以取得 threads_profile_discovery)") } // ProgressFn reports job progress from long-running execute (worker → MarkRunningProgress). type ProgressFn func(percent int, summary string) // ExecutePersonaAnalyzeJob parses job payload and runs account/text analyze (worker entry). func (s *Service) ExecutePersonaAnalyzeJob(ctx context.Context, templateType string, ownerUID int64, personaID, payloadJSON string, onProgress ProgressFn) error { report := func(pct int, sum string) { if onProgress != nil { onProgress(pct, sum) } } switch templateType { case "persona_analyze_account": var pl struct { Username string `json:"username"` Lang string `json:"lang,omitempty"` } if err := json.Unmarshal([]byte(payloadJSON), &pl); err != nil { return fmt.Errorf("invalid persona analyze account payload: %w", err) } if pl.Lang != "" { ctx = ai.WithResponseLanguage(ctx, pl.Lang) } _, err := s.ExecuteAnalyzeFromAccount(ctx, ownerUID, personaID, pl.Username, report) return err case "persona_analyze_text": var pl struct { RawText string `json:"raw_text"` SourceLabel string `json:"source_label,omitempty"` Lang string `json:"lang,omitempty"` } if err := json.Unmarshal([]byte(payloadJSON), &pl); err != nil { return fmt.Errorf("invalid persona analyze text payload: %w", err) } if pl.Lang != "" { ctx = ai.WithResponseLanguage(ctx, pl.Lang) } _, err := s.ExecuteAnalyzeFromText(ctx, ownerUID, personaID, pl.RawText, pl.SourceLabel, report) return err default: return fmt.Errorf("unknown persona analyze template: %s", templateType) } } // finishPersonaStyleAnalysis:先規則底稿,再以會員設定的 provider/model 做真 LLM 分析覆寫。 func (s *Service) finishPersonaStyleAnalysis( ctx context.Context, ownerUID int64, p *domain.Persona, samples []string, source, username, sourceLabel string, ) error { // 底稿(LLM 失敗時仍有可用結果) applyStyleFromSamples(p, samples, source, username, sourceLabel) provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) { // 無真實 key:標註非 LLM p.Notes = "⚠️ 規則摘要(非正式 AI)。請到設定填寫 xAI 或 OpenCode Go API Key 後重跑分析。" p.UpdatedAt = domain.NowNano() return nil } raw, aerr := s.completeLLM(ctx, provider, model, apiKey, buildStyleAnalysisPrompt(samples, username, sourceLabel)) if aerr != nil { p.Notes = "⚠️ AI 分析失敗,已保留規則摘要:" + truncate(aerr.Error(), 160) p.UpdatedAt = domain.NowNano() return nil // 不整段失敗;規則結果仍可用 } if !applyAIStyleJSON(p, raw, samples, source, username, sourceLabel) { // AI 回了非 JSON:仍把全文放 notes,draft 用規則 p.Notes = "AI 原文(未解析為結構化 JSON):" + truncate(raw, 500) p.UpdatedAt = domain.NowNano() return nil } p.Notes = fmt.Sprintf("LLM 分析 · %s / %s · 樣本 %d 則", provider, model, len(samples)) p.UpdatedAt = domain.NowNano() return nil } func (s *Service) resolveUserAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error) { if s.Keys != nil { return s.Keys.ResolveAI(ctx, ownerUID) } // tests:FakeClient + any key if s.AI != nil { return "xai", "grok-3", "test-key", nil } return "", "", "", fmt.Errorf("AI not configured") } func (s *Service) completeLLM(ctx context.Context, provider, model, apiKey, prompt string) (string, error) { // system 語言 prompt 由 ai.Client 依 ctx 強制注入(見 openai_compatible.Complete) // real registry first if s.AIRegistry != nil && !isSyntheticAIKey(apiKey) { if c, err := s.AIRegistry.Client(provider); err == nil { return c.Complete(ctx, apiKey, model, prompt) } } if s.AI != nil { return s.AI.Complete(ctx, apiKey, model, prompt) } return "", fmt.Errorf("no AI client") } func isSyntheticAIKey(key string) bool { k := strings.TrimSpace(strings.ToLower(key)) return k == "" || strings.HasPrefix(k, "fake-") || k == "platform-key" || k == "test-key" } // ---------- Plays (PL) ---------- func (s *Service) ListPlays(ctx context.Context, ownerUID int64) ([]*domain.Play, error) { return s.Repo.ListPlays(ctx, ownerUID) } func (s *Service) GetPlay(ctx context.Context, ownerUID int64, id string) (*domain.Play, error) { p, err := s.Repo.GetPlay(ctx, id) if err != nil { return nil, err } if p.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return p, nil } func (s *Service) SavePlay(ctx context.Context, ownerUID int64, p *domain.Play) (*domain.Play, error) { if ownerUID <= 0 { return nil, domain.ErrForbidden } now := domain.NowNano() if p.ID == "" { p.ID = "play_" + uuid.NewString()[:12] p.CreatedAt = now } else { ex, err := s.Repo.GetPlay(ctx, p.ID) if err == nil { if ex.OwnerUID != ownerUID { return nil, domain.ErrForbidden } p.CreatedAt = ex.CreatedAt } else if err != domain.ErrNotFound { return nil, err } else { p.CreatedAt = now } } p.OwnerUID = ownerUID // own vs external mutually exclusive if p.TargetOwnPostID != "" { p.TargetExternal = nil } if p.TargetExternal != nil && p.TargetExternal.URL != "" { p.TargetOwnPostID = "" p.TargetExternal.URL = normalizeURL(p.TargetExternal.URL) } if p.Status == "" { p.Status = domain.PlayDraft } // renumber steps for i := range p.Steps { p.Steps[i].SortOrder = i if p.Steps[i].ID == "" { p.Steps[i].ID = "step_" + uuid.NewString()[:8] } if underPost(p) { p.Steps[i].Kind = domain.StepReply } } p.UpdatedAt = now if err := s.Repo.SavePlay(ctx, p); err != nil { return nil, err } return p, nil } func (s *Service) RemovePlay(ctx context.Context, ownerUID int64, id string) error { if _, err := s.GetPlay(ctx, ownerUID, id); err != nil { return err } // outbox retained (documented strategy) return s.Repo.DeletePlay(ctx, id) } func (s *Service) ListPlaysByPost(ctx context.Context, ownerUID int64, ownPostID string) ([]*domain.Play, error) { all, err := s.Repo.ListPlays(ctx, ownerUID) if err != nil { return nil, err } var out []*domain.Play for _, p := range all { if p.TargetOwnPostID == ownPostID { out = append(out, p) } } return out, nil } func (s *Service) ListPlaysByExternalURL(ctx context.Context, ownerUID int64, raw string) ([]*domain.Play, error) { key := normalizeURL(raw) if key == "" { return nil, nil } all, err := s.Repo.ListPlays(ctx, ownerUID) if err != nil { return nil, err } var out []*domain.Play for _, p := range all { if p.TargetExternal != nil && normalizeURL(p.TargetExternal.URL) == key { out = append(out, p) } } return out, nil } func (s *Service) ResolveExternalLink(_ context.Context, raw string) (*domain.ExternalTarget, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, domain.ErrBadURL } u, err := url.Parse(raw) if err != nil || u.Host == "" { // allow threads-like paths without scheme if !strings.Contains(raw, "threads.net") && !strings.HasPrefix(raw, "http") { return nil, domain.ErrBadURL } u, err = url.Parse("https://" + strings.TrimPrefix(raw, "//")) if err != nil { return nil, domain.ErrBadURL } } host := strings.ToLower(u.Host) if !strings.Contains(host, "threads.net") && !strings.Contains(host, "threads.com") { return nil, domain.ErrBadURL } norm := normalizeURL(u.String()) parts := strings.Split(strings.Trim(u.Path, "/"), "/") shortcode := "" author := "" if len(parts) >= 1 && strings.HasPrefix(parts[0], "@") { author = strings.TrimPrefix(parts[0], "@") } if len(parts) >= 3 && parts[1] == "post" { shortcode = parts[2] } return &domain.ExternalTarget{ URL: norm, RawURL: raw, Shortcode: shortcode, AuthorUsername: author, TextPreview: "External thread preview · " + shortcode, MediaID: "ext_" + shortcode, ResolvedAt: domain.NowNano(), }, nil } func (s *Service) SubmitPlay(ctx context.Context, ownerUID int64, playID string) (*domain.OutboxBundle, error) { play, err := s.GetPlay(ctx, ownerUID, playID) if err != nil { return nil, err } if err := validatePlay(play); err != nil { return nil, err } if err := s.ensureUsableAccounts(ctx, ownerUID, play); err != nil { return nil, err } // 互回/掛文:把目標 Threads media_id 寫進 outbox(否則 Meta 無法 reply_to) replyToMedia := "" if play.TargetExternal != nil { replyToMedia = strings.TrimSpace(play.TargetExternal.MediaID) if replyToMedia == "" || strings.HasPrefix(replyToMedia, "ext_") { return nil, fmt.Errorf("%w: 外部連結尚無法解析真實 media_id,請改用「我的貼文」當目標,或先同步後貼上可回覆的貼文", domain.ErrValidation) } } if play.TargetOwnPostID != "" { post, perr := s.Repo.GetOwnPost(ctx, play.TargetOwnPostID) if perr != nil || post == nil { return nil, fmt.Errorf("%w: 找不到目標貼文,請先到「我的貼文」同步", domain.ErrValidation) } if post.OwnerUID != ownerUID { return nil, domain.ErrForbidden } replyToMedia = strings.TrimSpace(post.MediaID) if replyToMedia == "" { return nil, fmt.Errorf("%w: 目標貼文缺少 media_id,請重新同步「我的貼文」", domain.ErrValidation) } } // 每步要有正文 for _, st := range play.Steps { if strings.TrimSpace(st.Text) == "" { return nil, fmt.Errorf("%w: 劇本步驟不可空白,請先 AI 產文或手寫", domain.ErrValidation) } } bundle := playToOutbox(ownerUID, play) if replyToMedia != "" { bundle.ReplyToMediaID = replyToMedia for i := range bundle.Steps { if bundle.Steps[i].Kind == domain.StepReply { bundle.Steps[i].ReplyTo = replyToMedia } } } if err := s.Repo.SaveOutbox(ctx, bundle); err != nil { return nil, err } play.Status = domain.PlayScheduling play.UpdatedAt = domain.NowNano() _ = s.Repo.SavePlay(ctx, play) return bundle, nil } // GeneratePlayScript — 一次 LLM 產完整劇本並寫回 play.steps(onlyEmpty=true 只填空白步)。 // 回傳填入步數。供 worker job / 同步測試。 func (s *Service) GeneratePlayScript(ctx context.Context, ownerUID int64, playID string, onlyEmpty bool) (int, error) { play, err := s.GetPlay(ctx, ownerUID, playID) if err != nil { return 0, err } if len(play.Steps) == 0 { return 0, fmt.Errorf("%w: play has no steps", domain.ErrValidation) } // 需要產的步 type need struct { idx int id string label string mode string } var needs []need for i, st := range play.Steps { if onlyEmpty && strings.TrimSpace(st.Text) != "" { continue } label := st.AccountID mode := "reply" if st.Kind == domain.StepRoot || (i == 0 && !underPost(play)) { mode = "root" } // 人設標籤 if p := s.loadPersonaForGen(ctx, ownerUID, st.PersonaID); p != nil && p.Name != "" { label = p.Name } needs = append(needs, need{idx: i, id: st.ID, label: label, mode: mode}) } if len(needs) == 0 { return 0, fmt.Errorf("%w: 沒有空白步驟可產(全部已有正文)", domain.ErrValidation) } if err := s.billAI(ctx, ownerUID, "play generate script", "plays.generateScript"); err != nil { return 0, err } // 主上下文:目標貼文/主題 targetCtx := strings.TrimSpace(play.Topic) if play.TargetOwnPostID != "" { if post, e := s.Repo.GetOwnPost(ctx, play.TargetOwnPostID); e == nil && post != nil { if t := strings.TrimSpace(post.Text); t != "" { targetCtx = t } } } // 選一人設當「主指紋」(優先第一空步的 persona,再 active) personaID := "" for _, n := range needs { if pid := strings.TrimSpace(play.Steps[n.idx].PersonaID); pid != "" { personaID = pid break } } persona := s.loadPersonaForGen(ctx, ownerUID, personaID) fp := personaFingerprintBlock(persona) if utf8.RuneCountInString(fp) > 400 { fp = string([]rune(fp)[:400]) + "…" } if utf8.RuneCountInString(targetCtx) > 280 { targetCtx = string([]rune(targetCtx)[:280]) + "…" } // 骨架:已有正文的步當上下文,空步標 [待產] var skeleton strings.Builder for i, st := range play.Steps { skeleton.WriteString(fmt.Sprintf("%d. id=%s kind=%s speaker=%s\n", i+1, st.ID, st.Kind, st.AccountID)) if t := strings.TrimSpace(st.Text); t != "" { if utf8.RuneCountInString(t) > 120 { t = string([]rune(t)[:120]) + "…" } skeleton.WriteString(" 已有:") skeleton.WriteString(t) skeleton.WriteString("\n") } else { skeleton.WriteString(" 待產:是\n") } } prompt := fmt.Sprintf(`你是 Threads 互回編劇。一次寫完下列「待產」步驟的正文。 規則: - 只輸出 JSON(不要 markdown 圍欄):{"steps":[{"id":"步驟id","text":"正文"},...]} - 只輸出待產步驟;id 必須與下方 id 完全一致 - 繁體中文口語、像真人互回;每則 25~100 字 - 接住主文/上一則,不要重複抄全文、不要暴露 AI 【指紋】 %s 【主文/話題】 %s 【步驟表】 %s JSON:`, fp, targetCtx, skeleton.String()) provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) { if s.AI != nil { // 測試 Fake:填固定短句 for _, n := range needs { play.Steps[n.idx].Text = fmt.Sprintf("(測試)步驟 %d 互回", n.idx+1) } play.UpdatedAt = domain.NowNano() if err := s.Repo.SavePlay(ctx, play); err != nil { return 0, err } return len(needs), nil } return 0, fmt.Errorf("%w: 無法產文,請到設定填寫真實 AI Key", domain.ErrValidation) } llmCtx := ctx cancel := func() {} if _, ok := ctx.Deadline(); !ok { llmCtx, cancel = context.WithTimeout(ctx, 95*time.Second) } defer cancel() out, aerr := s.completeLLM(llmCtx, provider, model, apiKey, prompt) if aerr != nil { msg := aerr.Error() if llmCtx.Err() != nil || strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline") { return 0, fmt.Errorf("%w: AI 回應逾時(%s/%s)。請換較快模型", domain.ErrValidation, provider, model) } return 0, fmt.Errorf("%w: AI 產文失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 180)) } filled := applyPlayScriptJSON(play, out) if filled == 0 { // 解析失敗:嘗試整包當一步(不建議);直接報錯 return 0, fmt.Errorf("%w: AI 回傳無法解析成步驟(%s/%s)。請換模型後重試", domain.ErrValidation, provider, model) } play.UpdatedAt = domain.NowNano() if err := s.Repo.SavePlay(ctx, play); err != nil { return 0, err } return filled, nil } // applyPlayScriptJSON 把 {"steps":[{"id","text"}]} 寫入 play func applyPlayScriptJSON(play *domain.Play, raw string) int { s := strings.TrimSpace(raw) s = strings.TrimPrefix(s, "```json") s = strings.TrimPrefix(s, "```") s = strings.TrimSuffix(s, "```") s = strings.TrimSpace(s) // 取 JSON 物件 if i := strings.Index(s, "{"); i >= 0 { if j := strings.LastIndex(s, "}"); j > i { s = s[i : j+1] } } var parsed struct { Steps []struct { ID string `json:"id"` Text string `json:"text"` } `json:"steps"` } if err := json.Unmarshal([]byte(s), &parsed); err != nil || len(parsed.Steps) == 0 { return 0 } byID := map[string]string{} for _, st := range parsed.Steps { id := strings.TrimSpace(st.ID) t := cleanGeneratedTextMax(st.Text, 280) if id == "" || t == "" { continue } byID[id] = t } // 也允許依順序填(若 id 對不上) filled := 0 orderIdx := 0 ordered := make([]string, 0, len(parsed.Steps)) for _, st := range parsed.Steps { if t := cleanGeneratedTextMax(st.Text, 280); t != "" { ordered = append(ordered, t) } } for i := range play.Steps { id := play.Steps[i].ID if t, ok := byID[id]; ok { play.Steps[i].Text = t filled++ continue } // 僅空白步才用順序填 if strings.TrimSpace(play.Steps[i].Text) == "" && orderIdx < len(ordered) { play.Steps[i].Text = ordered[orderIdx] orderIdx++ filled++ } } return filled } // GeneratePlayStep — 互回/串場劇本:依人設真 LLM 產一步正文。 // 注意:與 compose mimic 相同,OpenCode reasoning 模型可能 30~90s 且偶發空白回覆。 func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaID, contextText, topic, speakerLabel string, isLead bool, mode string) (string, error) { contextText = strings.TrimSpace(contextText) topic = strings.TrimSpace(topic) if contextText == "" && topic == "" { return "", fmt.Errorf("%w: empty context", domain.ErrValidation) } if err := s.billAI(ctx, ownerUID, "play generate step", "plays.generateStep"); err != nil { return "", err } persona := s.loadPersonaForGen(ctx, ownerUID, personaID) fp := personaFingerprintBlock(persona) // 指紋過長會拖慢 reasoning 模型(仿寫已踩過) if utf8.RuneCountInString(fp) > 500 { fp = string([]rune(fp)[:500]) + "…" } if utf8.RuneCountInString(contextText) > 400 { contextText = string([]rune(contextText)[:400]) + "…" } mode = strings.ToLower(strings.TrimSpace(mode)) if mode == "" { mode = "reply" } prompt := buildPlayStepPrompt(fp, contextText, topic, speakerLabel, isLead, mode) provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) { // 單元測試 FakeClient if s.AI != nil { if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil { if t := cleanGeneratedTextMax(out, 400); t != "" { return t, nil } } } return "", fmt.Errorf("%w: 無法產文,請到設定填寫真實 AI Key(並選模型)後再試", domain.ErrValidation) } // 低於 gateway 120s;OpenCode 慢模型常 40~90s llmCtx := ctx cancel := func() {} if _, ok := ctx.Deadline(); !ok { llmCtx, cancel = context.WithTimeout(ctx, 95*time.Second) } defer cancel() out, aerr := s.completeLLM(llmCtx, provider, model, apiKey, prompt) if aerr != nil { msg := aerr.Error() if llmCtx.Err() != nil || strings.Contains(msg, "context deadline") || strings.Contains(msg, "Client.Timeout") || strings.Contains(msg, "timeout") { return "", fmt.Errorf("%w: AI 回應逾時(%s / %s)。請換較快的模型,或稍後再試", domain.ErrValidation, provider, model) } return "", fmt.Errorf("%w: AI 產文失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 200)) } text := cleanGeneratedTextMax(out, 400) if text == "" { text = strings.TrimSpace(out) } if text == "" { // 與仿寫相同:reasoning 模型有時只吐 thinking、content 空 return "", fmt.Errorf("%w: AI 回傳空白(%s / %s)。請到設定換較快的模型(勿用過慢的 reasoning),或重試一次", domain.ErrValidation, provider, model) } return text, nil } func buildPlayStepPrompt(fp, contextText, topic, speakerLabel string, isLead bool, mode string) string { // 精簡 prompt:降低 reasoning 模型耗時(仿寫踩過的坑) var b strings.Builder if mode == "root" { b.WriteString("寫一則 Threads 主貼。只輸出正文(繁中口語),80~220字,勿標題/markdown。\n") } else { b.WriteString("寫一則 Threads 互回短回覆。只輸出正文(繁中口語),25~120字,接上下文,勿分析/markdown/暴露AI。\n") } if speakerLabel != "" { b.WriteString("發言者:") b.WriteString(speakerLabel) if isLead { b.WriteString("(主帳)") } b.WriteString("\n") } if topic != "" { b.WriteString("話題:") b.WriteString(topic) b.WriteString("\n") } b.WriteString("【指紋】\n") b.WriteString(fp) b.WriteString("\n") if contextText != "" { b.WriteString("【上下文】\n") b.WriteString(contextText) b.WriteString("\n") } b.WriteString("正文:") return b.String() } // ---------- Outbox (OB) ---------- func (s *Service) ListOutbox(ctx context.Context, ownerUID int64) ([]*domain.OutboxBundle, error) { return s.Repo.ListOutbox(ctx, ownerUID) } func (s *Service) GetOutbox(ctx context.Context, ownerUID int64, id string) (*domain.OutboxBundle, error) { b, err := s.Repo.GetOutbox(ctx, id) if err != nil { return nil, err } if b.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return b, nil } func (s *Service) RemoveOutbox(ctx context.Context, ownerUID int64, id string) error { b, err := s.GetOutbox(ctx, ownerUID, id) if err != nil { return err } // Delete has a repository-level no-publishing guard. Clean up only after it // succeeds, otherwise a publisher may still need these URLs. if err := s.Repo.DeleteOutbox(ctx, id); err != nil { return err } for i := range b.Steps { s.cleanupEphemeralImages(ctx, b.Steps[i].ImageURLs) } return nil } func (s *Service) RetryStep(ctx context.Context, ownerUID int64, bundleID, stepID string) (*domain.OutboxBundle, error) { b, err := s.GetOutbox(ctx, ownerUID, bundleID) if err != nil { return nil, err } idx := -1 for i := range b.Steps { if b.Steps[i].ID == stepID { idx = i break } } if idx < 0 { return nil, domain.ErrNotFound } st := &b.Steps[idx] // only failed (or blocked after root fixed) can retry if st.Status != domain.StepFailed { return nil, domain.ErrIllegalStatus } if st.Kind == domain.StepReply { root := findRoot(b) if root == nil || root.Status != domain.StepPublished { return nil, domain.ErrRootBlocked } } return s.Repo.RetryOutboxStep(ctx, bundleID, stepID, domain.NowNano()) } // ProcessDueSteps is the worker tick — publishes via Transport only. func (s *Service) ProcessDueSteps(ctx context.Context, now int64) (published int, err error) { if now <= 0 { now = domain.NowNano() } all, err := s.Repo.ListAllOutbox(ctx) if err != nil { return 0, err } for _, b := range all { n, e := s.processBundle(ctx, b, now) published += n if e != nil && err == nil { err = e } } return published, err } func (s *Service) processBundle(ctx context.Context, b *domain.OutboxBundle, now int64) (int, error) { count := 0 for i := range b.Steps { st := &b.Steps[i] claimable := st.Status == domain.StepScheduled && st.ScheduledAt <= now stale := st.Status == domain.StepPublishing && st.LeaseExpiresAt <= now if !claimable && !stale { continue } root := findRoot(b) if st.Kind == domain.StepReply { if root != nil && root.Status != domain.StepPublished { continue } } leaseDuration := s.OutboxLeaseDuration if leaseDuration <= 0 { leaseDuration = 3 * time.Minute } claimOwner := s.OutboxWorkerID + ":" + uuid.NewString() claimNow := domain.NowNano() claimed, claimErr := s.Repo.ClaimOutboxStep(ctx, b.ID, st.ID, claimOwner, now, claimNow+int64(leaseDuration)) if claimErr != nil { if claimErr == domain.ErrIllegalStatus || claimErr == domain.ErrNotFound { continue } return count, claimErr } if stale { logx.Infof("outbox reclaimed expired publishing lease step=%s bundle=%s", st.ID, b.ID) } claimedStep := findOutboxStep(claimed, st.ID) if claimedStep == nil { return count, domain.ErrNotFound } result := *claimedStep result.LeaseOwner, result.LeaseExpiresAt = "", 0 fail := func(message string) error { result.Status, result.Error = domain.StepFailed, message finishedAt := domain.NowNano() if err := s.Repo.FinishOutboxStep(ctx, b.ID, result.ID, claimOwner, &result, finishedAt); err != nil { return err } return s.Repo.RecomputeOutboxStatus(ctx, b.ID, finishedAt) } if s.Transport == nil { if err := fail("publish transport not configured"); err != nil { return count, err } continue } token := "fake-token" if s.Accounts != nil { acc, aerr := s.Accounts.Get(ctx, result.AccountID) if aerr != nil || acc == nil || !acc.IsUsable { if err := fail("account not usable"); err != nil { return count, err } continue } if t, terr := s.Accounts.AccessToken(ctx, acc); terr != nil { if err := fail(terr.Error()); err != nil { return count, err } continue } else if t != "" { token = t } } root = findRoot(claimed) replyTo := result.ReplyTo if replyTo == "" && result.Kind == domain.StepReply { if root != nil && root.MediaID != "" { replyTo = root.MediaID } else if claimed.ReplyToMediaID != "" { replyTo = claimed.ReplyToMediaID } } request := domain.PublishRequest{ AccessToken: token, AccountID: result.AccountID, Text: result.Text, ReplyTo: replyTo, ImageURLs: result.ImageURLs, // 僅主貼帶話題標籤 TopicTag: func() string { if result.Kind == domain.StepRoot { return result.TopicTag } return "" }(), } res, perr := s.publishWithLease(ctx, b.ID, result.ID, claimOwner, leaseDuration, request) if perr != nil { if errors.Is(perr, domain.ErrLeaseLost) { return count, perr } if err := fail(perr.Error()); err != nil { return count, err } } else { result.Status, result.Error = domain.StepPublished, "" result.PublishedAt = domain.NowNano() if res != nil { result.MediaID = res.MediaID } images := append([]string(nil), result.ImageURLs...) result.ImageURLs = nil finishedAt := domain.NowNano() if err := s.Repo.FinishOutboxStep(ctx, b.ID, result.ID, claimOwner, &result, finishedAt); err != nil { // The remote result may have succeeded. Never let a stale lease write // final state; a reclaim can duplicate because Meta has no idempotency key. return count, err } if err := s.Repo.RecomputeOutboxStatus(ctx, b.ID, finishedAt); err != nil { return count, err } s.cleanupEphemeralImages(ctx, images) count++ } fresh, getErr := s.Repo.GetOutbox(ctx, b.ID) if getErr != nil { return count, getErr } b = fresh } return count, nil } func (s *Service) publishWithLease(ctx context.Context, bundleID, stepID, leaseOwner string, leaseDuration time.Duration, request domain.PublishRequest) (*domain.PublishResult, error) { publishCtx, cancel := context.WithCancel(ctx) defer cancel() renewEvery := leaseDuration / 3 if renewEvery <= 0 { renewEvery = time.Millisecond } renewErr := make(chan error, 1) done := make(chan struct{}) stopped := make(chan struct{}) go func() { defer close(stopped) ticker := time.NewTicker(renewEvery) defer ticker.Stop() for { select { case <-done: return case <-ticker.C: now := domain.NowNano() if err := s.Repo.RenewOutboxStepLease(publishCtx, bundleID, stepID, leaseOwner, now, now+int64(leaseDuration)); err != nil { renewErr <- err cancel() return } } } }() res, err := s.Transport.Publish(publishCtx, request) close(done) <-stopped select { case leaseErr := <-renewErr: return res, fmt.Errorf("%w: renew failed: %v", domain.ErrLeaseLost, leaseErr) default: return res, err } } // ---------- Compose (CP) ---------- func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, personaID, structureNotes string) (string, error) { sourceText = strings.TrimSpace(sourceText) if sourceText == "" { return "", fmt.Errorf("%w: empty source", domain.ErrValidation) } if err := s.billAI(ctx, ownerUID, "compose mimic", "compose.mimic"); err != nil { return "", err } p := s.loadPersonaForGen(ctx, ownerUID, personaID) fp := personaFingerprintBlock(p) // 指紋過長會拖慢 reasoning 模型 if utf8.RuneCountInString(fp) > 800 { fp = string([]rune(fp)[:800]) + "…" } notes := strings.TrimSpace(structureNotes) // OpenCode reasoning 模型對長「結構分析」極慢;只留骨架提示 if utf8.RuneCountInString(notes) > 200 { notes = string([]rune(notes)[:200]) + "…" } srcRunes := utf8.RuneCountInString(sourceText) minLen := srcRunes * 9 / 10 if minLen < 80 { minLen = 80 } if minLen > 220 { minLen = 220 } maxLen := srcRunes + 30 if maxLen < 140 { maxLen = 140 } if maxLen > 320 { maxLen = 320 } if maxLen < minLen { maxLen = minLen + 20 } notesBlock := "" if notes != "" { notesBlock = fmt.Sprintf("\n\n(節奏提示,勿照抄)\n%s\n", notes) } // 精簡 prompt:單次 LLM 完成(不再二次擴寫,避免 2× 逾時) prompt := strings.TrimSpace(fmt.Sprintf(` 你就是本人在打 Threads,不是助手。 用【說話方式】重寫【參考】的意思與節奏:口語、像滑手機邊打;勿照抄原句;勿列點;勿「首先/總結」;勿 markdown。 約 %d~%d 字、2~4 段。有問句就留好回的小問題。只輸出正文。 【說話方式】 %s 【參考】 %s %s `, minLen, maxLen, fp, sourceText, notesBlock)) provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) { if s.AI != nil && (apiKey == "test-key" || s.Keys == nil) { if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil { if t := cleanGeneratedTextMax(out, 900); t != "" { return t, nil } } } return "", fmt.Errorf("%w: 無法仿寫:請到設定填寫真實 AI Key 後再試", domain.ErrValidation) } // HTTP 同步路徑:沒有外層 deadline 時限 95s(低於 gateway 120s) // Job worker 會帶 4 分鐘 deadline,這裡不覆蓋 llmCtx := ctx cancel := func() {} if _, ok := ctx.Deadline(); !ok { llmCtx, cancel = context.WithTimeout(ctx, 95*time.Second) } defer cancel() out, aerr := s.completeLLM(llmCtx, provider, model, apiKey, prompt) if aerr != nil { msg := aerr.Error() if llmCtx.Err() != nil || strings.Contains(msg, "context deadline") || strings.Contains(msg, "Client.Timeout") || strings.Contains(msg, "timeout") { return "", fmt.Errorf("%w: AI 回應逾時(%s / %s)。請縮短「結構分析」備註後再試,或換較快的模型", domain.ErrValidation, provider, model) } return "", fmt.Errorf("%w: AI 仿寫失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 200)) } text := cleanGeneratedTextMax(out, 900) if text == "" { text = strings.TrimSpace(out) } if text == "" { return "", fmt.Errorf("%w: AI 回傳空白(%s / %s)。請到設定確認模型後再試", domain.ErrValidation, provider, model) } return text, nil } func (s *Service) AnalyzeViral(ctx context.Context, ownerUID int64, text string) (*domain.ViralAnalysis, error) { if err := s.billAI(ctx, ownerUID, "compose analyze viral", "compose.analyzeViral"); err != nil { return nil, err } return s.analyzeViralUnbilled(ctx, ownerUID, text) } // analyzeViralUnbilled — 真 LLM 結構分析(呼叫端自行 billAI,避免雙重計費) func (s *Service) analyzeViralUnbilled(ctx context.Context, ownerUID int64, text string) (*domain.ViralAnalysis, error) { text = strings.TrimSpace(text) if text == "" { return nil, fmt.Errorf("%w: empty text", domain.ErrValidation) } // 規則底稿(LLM 失敗時仍有可用輸出,但會標註) fallback := ruleViralAnalysis(text) provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) { // 測試:FakeClient 可走下面;正式無 key 回規則 + 提示 if s.AI != nil { if out, e := s.AI.Complete(ctx, "test-key", "grok-3", buildViralAnalysisPrompt(text)); e == nil { if va, ok := parseViralAnalysisJSON(out); ok { return va, nil } } } fallback.Summary = fallback.Summary + "(規則摘要:請到設定填 AI Key 以取得真分析)" return fallback, nil } raw, aerr := s.completeLLM(ctx, provider, model, apiKey, buildViralAnalysisPrompt(text)) if aerr != nil { fallback.Summary = fallback.Summary + "(AI 失敗,規則底稿:" + truncate(aerr.Error(), 80) + ")" return fallback, nil } if va, ok := parseViralAnalysisJSON(raw); ok { return va, nil } // 非 JSON:仍把 AI 重點塞進 summary fallback.Summary = "AI 原文:" + truncate(cleanGeneratedText(raw), 400) return fallback, nil } func buildViralAnalysisPrompt(text string) string { return strings.TrimSpace(fmt.Sprintf(` 你是 Threads 內容編輯。請分析下面這則貼文的「可複製結構」,服務創作者改寫/學習。 規則: - 只輸出 JSON(不要 markdown 圍欄、不要前後說明) - 繁體中文(台灣用語) - 字段都要有實質內容,勿空字串、勿敷衍套話 JSON schema: { "hooks": "開場鉤子怎麼抓注意力(1~3 句)", "structure": "段落結構骨架(如:情境→痛點→經驗→提問)", "emotion": "情緒節奏(好奇/共感/緊迫等)", "summary": "為什麼這則有互動潛力(2~4 句)", "cta": "結尾 CTA/互動設計", "copyable": "別人可複製的 2~4 個要點(勿抄原文)", "risks": "改寫時要注意的風險/禁忌" } 【貼文】 %s `, text)) } func parseViralAnalysisJSON(raw string) (*domain.ViralAnalysis, bool) { s := strings.TrimSpace(raw) s = strings.TrimPrefix(s, "```json") s = strings.TrimPrefix(s, "```") s = strings.TrimSuffix(s, "```") s = strings.TrimSpace(s) // 取第一個 { … 最後一個 } if i := strings.Index(s, "{"); i >= 0 { if j := strings.LastIndex(s, "}"); j > i { s = s[i : j+1] } } var parsed struct { Hooks string `json:"hooks"` Structure string `json:"structure"` Emotion string `json:"emotion"` Summary string `json:"summary"` CTA string `json:"cta"` Copyable string `json:"copyable"` Risks string `json:"risks"` } if err := json.Unmarshal([]byte(s), &parsed); err != nil { return nil, false } if strings.TrimSpace(parsed.Hooks) == "" && strings.TrimSpace(parsed.Structure) == "" && strings.TrimSpace(parsed.Summary) == "" { return nil, false } return &domain.ViralAnalysis{ Hooks: strings.TrimSpace(parsed.Hooks), Structure: strings.TrimSpace(parsed.Structure), Emotion: strings.TrimSpace(parsed.Emotion), Summary: strings.TrimSpace(parsed.Summary), CTA: strings.TrimSpace(parsed.CTA), Copyable: strings.TrimSpace(parsed.Copyable), Risks: strings.TrimSpace(parsed.Risks), }, true } func ruleViralAnalysis(text string) *domain.ViralAnalysis { hasQ := strings.ContainsAny(text, "??") hasPain := strings.ContainsAny(text, "卡煩雷痛難") || strings.Contains(text, "怎麼") || strings.Contains(text, "不會") hooks := "開場用生活情境,降低防備" if hasQ { hooks = "用真心疑問收尾/開場,降低回覆門檻" } else if hasPain { hooks = "先丟具體痛點,讀者有代入感" } emotion := "好奇/認同" if hasPain { emotion = "共感 + 一點焦慮釋放" } sum := "情境清楚" if hasPain { sum = "痛點具體" } if hasQ { sum += " + 好回的問題" } return &domain.ViralAnalysis{ Hooks: hooks, Structure: "情境/痛點 → 自己經驗一句 → 邀請補充(或輕 CTA)", Emotion: emotion, Summary: "這則有互動潛力:" + sum + "。改寫時保留結構,換成你的經驗與語氣。", CTA: "軟性提問或請讀者補一句經驗", Copyable: "短段落、一個明確條件、結尾問句;勿整段抄原文", Risks: "勿保證效果、勿硬廣、勿嘲諷讀者", } } func formatViralFormulaDetail(va *domain.ViralAnalysis) string { if va == nil { return "" } var b strings.Builder if va.Summary != "" { b.WriteString("【為什麼可能爆】") b.WriteString(va.Summary) b.WriteString("\n") } if va.Hooks != "" { b.WriteString("【鉤子】") b.WriteString(va.Hooks) b.WriteString("\n") } if va.Structure != "" { b.WriteString("【結構】") b.WriteString(va.Structure) b.WriteString("\n") } if va.Emotion != "" { b.WriteString("【情緒】") b.WriteString(va.Emotion) b.WriteString("\n") } if va.Copyable != "" { b.WriteString("【可複製】") b.WriteString(va.Copyable) b.WriteString("\n") } if va.CTA != "" { b.WriteString("【CTA】") b.WriteString(va.CTA) b.WriteString("\n") } if va.Risks != "" { b.WriteString("【風險】") b.WriteString(va.Risks) } return strings.TrimSpace(b.String()) } func (s *Service) PublishSingle(ctx context.Context, ownerUID int64, accountID, text, title string, imageURLs []string, scheduleStartAt int64, topicTag string) (*domain.OutboxBundle, error) { text = strings.TrimSpace(text) if text == "" { return nil, fmt.Errorf("%w: empty text", domain.ErrValidation) } if accountID == "" { return nil, domain.ErrNoAccount } if s.Accounts != nil { acc, err := s.Accounts.Get(ctx, accountID) if err != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable { return nil, domain.ErrNoAccount } } now := domain.NowNano() start := scheduleStartAt if start <= 0 { start = now } else if start < now { // datetime-local 只有分鐘精度 + 時鐘誤差:超過 1 分鐘才擋 const schedulePastGrace = int64(time.Minute) if now-start > schedulePastGrace { return nil, fmt.Errorf("%w: schedule_start_at is in the past", domain.ErrValidation) } start = now } // 圖必須是 Meta 可抓的 http(s);data URL 請前端先 /media/upload cleanImgs := make([]string, 0, len(imageURLs)) for _, u := range imageURLs { u = strings.TrimSpace(u) if u == "" { continue } if strings.HasPrefix(u, "data:") { return nil, fmt.Errorf("%w: image_urls must be public https URLs (upload first)", domain.ErrValidation) } if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") { cleanImgs = append(cleanImgs, u) } } tag := normalizePublishTopicTag(topicTag) play := &domain.Play{ ID: "play_" + uuid.NewString()[:12], OwnerUID: ownerUID, Title: title, Topic: truncate(text, 80), Status: domain.PlayScheduling, LeadAccountID: accountID, Steps: []domain.PlayStep{{ ID: uuid.NewString()[:8], SortOrder: 0, Kind: domain.StepRoot, AccountID: accountID, Text: text, ImageURLs: cleanImgs, }}, ScheduleStartAt: start, CreatedAt: now, UpdatedAt: now, } if play.Title == "" { play.Title = truncate(text, 24) } if err := s.Repo.SavePlay(ctx, play); err != nil { return nil, err } bundle := playToOutbox(ownerUID, play) // 主貼帶 topic_tag if tag != "" && len(bundle.Steps) > 0 { bundle.Steps[0].TopicTag = tag } if err := s.Repo.SaveOutbox(ctx, bundle); err != nil { return nil, err } return bundle, nil } // QueueExternalReply persists an immediate reply to an external Threads media ID. func (s *Service) QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (*domain.OutboxBundle, error) { replyToMediaID = strings.TrimSpace(replyToMediaID) if replyToMediaID == "" { return nil, fmt.Errorf("%w: reply_to_media_id must be numeric", domain.ErrValidation) } for i := 0; i < len(replyToMediaID); i++ { if replyToMediaID[i] < '0' || replyToMediaID[i] > '9' { return nil, fmt.Errorf("%w: reply_to_media_id must be numeric", domain.ErrValidation) } } if accountID == "" { return nil, domain.ErrNoAccount } if s.Accounts != nil { acc, err := s.Accounts.Get(ctx, accountID) if err != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable { return nil, domain.ErrNoAccount } } now := domain.NowNano() bundle := &domain.OutboxBundle{ ID: "outbox_" + uuid.NewString()[:12], OwnerUID: ownerUID, Title: title, Status: domain.OBScheduling, ReplyToMediaID: replyToMediaID, CreatedAt: now, UpdatedAt: now, Steps: []domain.OutboxStep{{ ID: "obstep_" + uuid.NewString()[:10], SortOrder: 0, Kind: domain.StepReply, AccountID: accountID, Text: text, Status: domain.StepScheduled, ScheduledAt: now, ReplyTo: replyToMediaID, }}, } if err := s.Repo.SaveOutbox(ctx, bundle); err != nil { return nil, err } return bundle, nil } func normalizePublishTopicTag(raw string) string { s := strings.TrimSpace(raw) s = strings.TrimPrefix(s, "#") s = strings.TrimSpace(s) s = strings.ReplaceAll(s, ".", "") s = strings.ReplaceAll(s, "&", "") s = strings.TrimSpace(s) if s == "" { return "" } r := []rune(s) if len(r) > 50 { s = string(r[:50]) } return s } // ---------- OwnPosts (OP) ---------- func (s *Service) ListOwnPosts(ctx context.Context, ownerUID int64, accountID string) ([]*domain.OwnPost, error) { return s.Repo.ListOwnPosts(ctx, ownerUID, accountID) } func (s *Service) LastSyncedAt(ctx context.Context, ownerUID int64) (int64, error) { m, err := s.Repo.GetSyncMeta(ctx, ownerUID) if err != nil { return 0, err } return m.LastSyncedAt, nil } func (s *Service) SyncOwnPosts(ctx context.Context, ownerUID int64, accountID string) ([]*domain.OwnPost, error) { if accountID == "" { return nil, domain.ErrNoAccount } var acc *threadsDomain.Account if s.Accounts != nil { a, err := s.Accounts.Get(ctx, accountID) if err != nil || a == nil { return nil, domain.ErrNotFound } if a.OwnerUID != ownerUID { return nil, domain.ErrForbidden } if !a.IsUsable { return nil, domain.ErrNoAccount } acc = a } now := domain.NowNano() // 真 Threads Graph 同步 if s.Media != nil && s.Accounts != nil && acc != nil { token, terr := s.Accounts.AccessToken(ctx, acc) if terr == nil && strings.TrimSpace(token) != "" && !strings.HasPrefix(token, "fake-") { if err := s.syncOwnPostsFromThreads(ctx, ownerUID, accountID, token); err != nil { return nil, err } _ = s.Repo.SetSyncMeta(ctx, &domain.SyncMeta{OwnerUID: ownerUID, LastSyncedAt: now}) return s.Repo.ListOwnPosts(ctx, ownerUID, accountID) } } // 無 Media/假 token:保留本地假 seed(單元測試) existing, _ := s.Repo.ListOwnPosts(ctx, ownerUID, accountID) if len(existing) == 0 { for i := 0; i < 2; i++ { p := &domain.OwnPost{ ID: "op_" + uuid.NewString()[:10], OwnerUID: ownerUID, AccountID: accountID, MediaID: "media_sync_" + uuid.NewString()[:6], Text: fmt.Sprintf("Synced post #%d from Threads", i+1), MediaType: "TEXT_POST", LikeCount: 3 + i, ReplyCount: 1, ViewCount: 100 + i*20, InsightsStatus: "ok", Replies: []domain.OwnPostReply{{ ID: "or_" + uuid.NewString()[:6], Username: "fan_" + fmt.Sprint(i), Text: "nice!", CreatedAt: now - int64(i)*time.Hour.Nanoseconds(), ReplyStatus: "pending", }}, PublishedAt: now - int64(i+1)*time.Hour.Nanoseconds(), } _ = s.Repo.SaveOwnPost(ctx, p) } } else { for _, p := range existing { p.ViewCount += 5 p.LikeCount++ p.InsightsStatus = "ok" _ = s.Repo.SaveOwnPost(ctx, p) } } _ = s.Repo.SetSyncMeta(ctx, &domain.SyncMeta{OwnerUID: ownerUID, LastSyncedAt: now}) return s.Repo.ListOwnPosts(ctx, ownerUID, accountID) } // syncOwnPostsFromThreads — 拉 me/threads + insights + conversation,以 media_id upsert。 func (s *Service) syncOwnPostsFromThreads(ctx context.Context, ownerUID int64, accountID, accessToken string) error { threads, err := s.Media.ListThreads(ctx, accessToken, 25) if err != nil { return fmt.Errorf("threads list: %w", err) } // 既有本地貼文:保留分析公式欄位 existing, _ := s.Repo.ListOwnPosts(ctx, ownerUID, accountID) byMedia := map[string]*domain.OwnPost{} for _, p := range existing { if p.MediaID != "" { byMedia[p.MediaID] = p } // 清掉早期 mock seed(Synced post #…) if strings.HasPrefix(p.MediaID, "media_sync_") || strings.HasPrefix(p.Text, "Synced post #") { _ = s.Repo.DeleteOwnPost(ctx, p.ID) } } for _, th := range threads { if th.ID == "" { continue } // 同步只拉貼文 + 成效;留言改點開再載(避免 N 則 × conversation 打爆 API) ins, _ := s.Media.GetInsights(ctx, accessToken, th.ID) prev := byMedia[th.ID] id := "op_" + th.ID if prev != nil && prev.ID != "" { id = prev.ID } pubAt := th.PublishedAt if pubAt <= 0 { pubAt = domain.NowNano() } // 保留先前已載入的留言(點開過的不因 re-sync 清掉) var replies []domain.OwnPostReply if prev != nil && len(prev.Replies) > 0 { replies = append([]domain.OwnPostReply(nil), prev.Replies...) } p := &domain.OwnPost{ ID: id, OwnerUID: ownerUID, AccountID: accountID, MediaID: th.ID, Text: th.Text, MediaType: th.MediaType, MediaURL: th.MediaURL, ThumbnailURL: th.ThumbnailURL, Permalink: th.Permalink, Shortcode: th.Shortcode, TopicTag: th.TopicTag, LikeCount: ins.Likes, ReplyCount: ins.Replies, RepostCount: ins.Reposts, QuoteCount: ins.Quotes, ViewCount: ins.Views, ShareCount: ins.Shares, InsightsStatus: ins.Status, Replies: replies, PublishedAt: pubAt, } if p.MediaType == "" { p.MediaType = "TEXT_POST" } // 保留本地分析結果 if prev != nil { p.FormulaSummary = prev.FormulaSummary p.Insight = prev.Insight p.FormulaDetail = prev.FormulaDetail } if ins.Status == "error" && ins.ErrorMsg != "" && p.Insight == "" { // 不寫 error 進 insight 以免覆蓋分析;僅 insights_status } if err := s.Repo.SaveOwnPost(ctx, p); err != nil { return err } } return nil } // LoadOwnPostReplies — 點開貼文時才拉留言(/{media}/replies 或 conversation) func (s *Service) LoadOwnPostReplies(ctx context.Context, ownerUID int64, postID string) (*domain.OwnPost, error) { post, err := s.getOwnPostOwned(ctx, ownerUID, postID) if err != nil { return nil, err } if post.MediaID == "" { return post, nil } if s.Media == nil || s.Accounts == nil { return post, nil } acc, err := s.Accounts.Get(ctx, post.AccountID) if err != nil || acc == nil || acc.OwnerUID != ownerUID { return nil, domain.ErrNoAccount } token, terr := s.Accounts.AccessToken(ctx, acc) if terr != nil || strings.TrimSpace(token) == "" || strings.HasPrefix(token, "fake-") { return post, nil } convo, cerr := s.Media.ListConversation(ctx, token, post.MediaID, 50) if cerr != nil { return nil, fmt.Errorf("load replies: %w", cerr) } replies := mapFetchedRepliesWithRoot(convo, post, post.MediaID) post.Replies = replies // 若 insights 沒給 replies 數,用實際筆數補 if post.ReplyCount < len(replies) { post.ReplyCount = len(replies) } if err := s.Repo.SaveOwnPost(ctx, post); err != nil { return nil, err } return post, nil } func mapFetchedReplies(convo []FetchedReply, prev *domain.OwnPost) []domain.OwnPostReply { root := "" if prev != nil { root = prev.MediaID } return mapFetchedRepliesWithRoot(convo, prev, root) } func mapFetchedRepliesWithRoot(convo []FetchedReply, prev *domain.OwnPost, rootMediaID string) []domain.OwnPostReply { out := make([]domain.OwnPostReply, 0, len(convo)) localStatus := map[string]domain.OwnPostReply{} if prev != nil { for _, r := range prev.Replies { localStatus[r.ID] = r } } for _, c := range convo { id := c.ID if id == "" { continue } created := c.PublishedAt if created <= 0 { created = domain.NowNano() } // parent = 根貼 media id → 第一層(parent 空) parent := c.ParentMediaID if parent == rootMediaID || parent == "" { parent = "" } // 預設 pending;載完後用對話樹標記(不靠 LLM) status := "pending" repliedBy := "" var repliedAt int64 if old, ok := localStatus[id]; ok && old.ReplyStatus == "replied" { // 本機曾送出回覆的標記先保留,稍後仍用樹再算一次 status = old.ReplyStatus repliedBy = old.RepliedBy repliedAt = old.RepliedAt } out = append(out, domain.OwnPostReply{ ID: id, Username: c.Username, Text: c.Text, CreatedAt: created, LikeCount: c.LikeCount, ReplyStatus: status, RepliedBy: repliedBy, RepliedAt: repliedAt, ParentReplyID: parent, IsMine: c.IsMine, }) } // 純資料:若某則留言底下有「我的」子回覆 → 標已回覆 return markRepliedFromTree(out) } // markRepliedFromTree — 不靠 LLM:對話樹裡 parent=該留言 id 且 is_mine → replied func markRepliedFromTree(replies []domain.OwnPostReply) []domain.OwnPostReply { // parentID → 是否有我的子回覆 mineUnder := map[string]domain.OwnPostReply{} for _, r := range replies { if !r.IsMine { continue } pid := strings.TrimSpace(r.ParentReplyID) if pid == "" { continue } // 保留時間最新的一則當 replied_by 參考 if old, ok := mineUnder[pid]; !ok || r.CreatedAt >= old.CreatedAt { mineUnder[pid] = r } } for i := range replies { if replies[i].IsMine { // 自己的留言不進「待回」列表 replies[i].ReplyStatus = "replied" continue } if mine, ok := mineUnder[replies[i].ID]; ok { replies[i].ReplyStatus = "replied" if replies[i].RepliedBy == "" { replies[i].RepliedBy = mine.Username } if replies[i].RepliedAt == 0 { replies[i].RepliedAt = mine.CreatedAt } } else { replies[i].ReplyStatus = "pending" replies[i].RepliedBy = "" replies[i].RepliedAt = 0 } } return replies } func mergeReplyStatus(prev, next []domain.OwnPostReply) []domain.OwnPostReply { if len(prev) == 0 { return next } byID := map[string]domain.OwnPostReply{} for _, r := range prev { byID[r.ID] = r } for i := range next { if old, ok := byID[next[i].ID]; ok && old.ReplyStatus == "replied" { next[i].ReplyStatus = "replied" next[i].RepliedBy = old.RepliedBy next[i].RepliedAt = old.RepliedAt } } return next } func (s *Service) GenerateReply(ctx context.Context, ownerUID int64, postID, replyID, personaID string) (string, error) { post, err := s.getOwnPostOwned(ctx, ownerUID, postID) if err != nil { return "", err } if err := s.billAI(ctx, ownerUID, "own post reply draft", "ownPosts.generateReply"); err != nil { return "", err } // 貼文 + 要回的留言 + 人設指紋 postText := strings.TrimSpace(post.Text) commentText := "" commentUser := "" if replyID != "" { for _, r := range post.Replies { if r.ID == replyID { commentText = strings.TrimSpace(r.Text) commentUser = strings.TrimSpace(r.Username) break } } } persona := s.loadPersonaForGen(ctx, ownerUID, personaID) fp := personaFingerprintBlock(persona) prompt := buildOwnPostReplyPrompt(fp, postText, commentUser, commentText) provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) if kerr == nil && strings.TrimSpace(apiKey) != "" && !isSyntheticAIKey(apiKey) { if out, aerr := s.completeLLM(ctx, provider, model, apiKey, prompt); aerr == nil { if t := cleanGeneratedText(out); t != "" { return t, nil } } } // 測試路徑:FakeClient if s.AI != nil { if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil && out != "" { return cleanGeneratedText(out), nil } } return "", fmt.Errorf("%w: 無法產回覆草稿,請到設定填寫 AI Key 後再試", domain.ErrValidation) } func (s *Service) SendReply(ctx context.Context, ownerUID int64, postID, replyID, text, accountID string, imageURLs []string) (*domain.OwnPost, error) { post, err := s.getOwnPostOwned(ctx, ownerUID, postID) if err != nil { return nil, err } text = strings.TrimSpace(text) if text == "" { return nil, fmt.Errorf("%w: empty reply", domain.ErrValidation) } accID := accountID if accID == "" { accID = post.AccountID } username := "me" token := "fake-token" if s.Accounts != nil { acc, aerr := s.Accounts.Get(ctx, accID) if aerr != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable { return nil, domain.ErrNoAccount } username = acc.Username if t, terr := s.Accounts.AccessToken(ctx, acc); terr == nil && t != "" { token = t } } if s.Transport == nil { return nil, fmt.Errorf("%w: publish transport not configured", domain.ErrValidation) } // reply_to_id:回某則留言用留言 media id;回主貼用主貼 media id replyTo := strings.TrimSpace(post.MediaID) if strings.TrimSpace(replyID) != "" { replyTo = strings.TrimSpace(replyID) } if replyTo == "" { return nil, fmt.Errorf("%w: 貼文缺少 Threads media_id,請先重新同步後再回覆", domain.ErrValidation) } if strings.HasPrefix(token, "fake-") { return nil, fmt.Errorf("%w: 帳號 token 無效,請重新連 Threads", domain.ErrValidation) } res, perr := s.Transport.Publish(ctx, domain.PublishRequest{ AccessToken: token, AccountID: accID, Text: text, ReplyTo: replyTo, }) if perr != nil { // OP-05: do not mark local success;回傳可讀錯誤(Biz 400) return nil, fmt.Errorf("%w: %s", domain.ErrValidation, perr.Error()) } now := domain.NowNano() if replyID != "" { for i := range post.Replies { if post.Replies[i].ID == replyID { post.Replies[i].ReplyStatus = "replied" post.Replies[i].RepliedBy = username post.Replies[i].RepliedAt = now } } } // 本地掛上我的回覆:parent = 被回留言 id(空=掛在主貼下第一層) parent := strings.TrimSpace(replyID) newID := "or_" + uuid.NewString()[:8] if res != nil && res.MediaID != "" { newID = res.MediaID } post.Replies = append(post.Replies, domain.OwnPostReply{ ID: newID, Username: username, Text: text, CreatedAt: now, ReplyStatus: "replied", IsMine: true, ParentReplyID: parent, }) // 重新標記整樹已回覆狀態 post.Replies = markRepliedFromTree(post.Replies) if post.ReplyCount < len(post.Replies) { post.ReplyCount = len(post.Replies) } _ = imageURLs if err := s.Repo.SaveOwnPost(ctx, post); err != nil { return nil, err } return post, nil } func (s *Service) AnalyzePost(ctx context.Context, ownerUID int64, postID string) (*domain.OwnPost, error) { post, err := s.getOwnPostOwned(ctx, ownerUID, postID) if err != nil { return nil, err } if strings.TrimSpace(post.Text) == "" { return nil, fmt.Errorf("%w: 貼文沒有文字可分析(圖片/純媒體貼可改貼上說明再分析)", domain.ErrValidation) } if err := s.billAI(ctx, ownerUID, "own post structure analyze", "ownPosts.analyze"); err != nil { return nil, err } va, err := s.analyzeViralUnbilled(ctx, ownerUID, post.Text) if err != nil { return nil, err } post.FormulaSummary = va.Hooks if post.FormulaSummary == "" { post.FormulaSummary = va.Structure } post.Insight = va.Summary post.FormulaDetail = formatViralFormulaDetail(va) if err := s.Repo.SaveOwnPost(ctx, post); err != nil { return nil, err } return post, nil } // GenerateFromFormula is intentionally removed (OP-11). func (s *Service) GenerateFromFormula(_ context.Context, _, _ string) error { return domain.ErrFormulaRemove } // ---------- Mentions ---------- func (s *Service) ListMentions(ctx context.Context, ownerUID int64, accountID string) ([]*domain.Mention, error) { return s.Repo.ListMentions(ctx, ownerUID, accountID) } // SyncMentions — 從 Threads Graph 拉「@我」提及並 upsert 本地 inbox。 func (s *Service) SyncMentions(ctx context.Context, ownerUID int64, accountID string) ([]*domain.Mention, error) { if accountID == "" { return nil, domain.ErrNoAccount } if s.Accounts == nil { return nil, domain.ErrNoAccount } acc, err := s.Accounts.Get(ctx, accountID) if err != nil || acc == nil { return nil, domain.ErrNotFound } if acc.OwnerUID != ownerUID { return nil, domain.ErrForbidden } if !acc.IsUsable { return nil, domain.ErrNoAccount } token, terr := s.Accounts.AccessToken(ctx, acc) if terr != nil || strings.TrimSpace(token) == "" || strings.HasPrefix(token, "fake-") { return nil, fmt.Errorf("%w: 帳號 token 無效,請重新連 Threads(需含 threads_manage_mentions)", domain.ErrValidation) } if s.Media == nil { return nil, fmt.Errorf("%w: Threads Graph 未設定,無法同步提及", domain.ErrValidation) } userID := strings.TrimSpace(acc.ThreadsUserID) if userID == "" { userID = "me" } hits, err := s.Media.ListMentions(ctx, token, userID, 40) if err != nil { return nil, fmt.Errorf("mentions sync: %w", err) } // 既有:保留 status / draft existing, _ := s.Repo.ListMentions(ctx, ownerUID, accountID) byMedia := map[string]*domain.Mention{} for _, m := range existing { if m.MediaID != "" { byMedia[m.MediaID] = m } } now := domain.NowNano() for _, hit := range hits { mediaID := strings.TrimSpace(hit.ID) if mediaID == "" { continue } prev := byMedia[mediaID] id := "mn_" + mediaID status := domain.MentionPending draft := "" created := hit.PublishedAt if created <= 0 { created = now } if prev != nil { id = prev.ID if prev.Status != "" { status = prev.Status } draft = prev.DraftText if prev.CreatedAt > 0 { created = prev.CreatedAt } } ctxSnippet := mentionContextSnippet(hit) m := &domain.Mention{ ID: id, OwnerUID: ownerUID, AccountID: accountID, MediaID: mediaID, FromUsername: hit.Username, Text: hit.Text, ContextSnippet: ctxSnippet, Permalink: hit.Permalink, RootPostID: hit.RootPostID, ParentID: hit.ParentID, Status: status, DraftText: draft, CreatedAt: created, } if m.FromUsername == "" { m.FromUsername = "unknown" } if err := s.Repo.SaveMention(ctx, m); err != nil { return nil, err } } return s.Repo.ListMentions(ctx, ownerUID, accountID) } func mentionContextSnippet(hit FetchedMention) string { kind := "提及你" if hit.IsQuotePost { kind = "引用你" } else if hit.IsReply { kind = "回覆中 @ 你" } text := strings.TrimSpace(hit.Text) if len([]rune(text)) > 120 { text = string([]rune(text)[:120]) + "…" } if text == "" { return kind } return kind + " · " + text } func (s *Service) GenerateMentionReply(ctx context.Context, ownerUID int64, id, personaID string) (*domain.Mention, error) { m, err := s.getMentionOwned(ctx, ownerUID, id) if err != nil { return nil, err } if err := s.billAI(ctx, ownerUID, "mention reply draft", "mentions.generateReply"); err != nil { return nil, err } persona := s.loadPersonaForGen(ctx, ownerUID, personaID) fp := personaFingerprintBlock(persona) prompt := buildMentionReplyPrompt(fp, m.FromUsername, m.Text, m.ContextSnippet) draft := "" provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) if kerr == nil && strings.TrimSpace(apiKey) != "" && !isSyntheticAIKey(apiKey) { if out, aerr := s.completeLLM(ctx, provider, model, apiKey, prompt); aerr == nil { draft = cleanGeneratedText(out) } } if draft == "" && s.AI != nil { if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil { draft = cleanGeneratedText(out) } } if draft == "" { return nil, fmt.Errorf("%w: 無法產回覆草稿,請到設定填寫 AI Key 後再試", domain.ErrValidation) } m.DraftText = draft if err := s.Repo.SaveMention(ctx, m); err != nil { return nil, err } return m, nil } func (s *Service) loadPersonaForGen(ctx context.Context, ownerUID int64, personaID string) *domain.Persona { if personaID != "" { if p, err := s.GetPersona(ctx, ownerUID, personaID); err == nil { return p } } if aid, _ := s.GetActivePersonaID(ctx, ownerUID); aid != "" { if p, err := s.GetPersona(ctx, ownerUID, aid); err == nil { return p } } return nil } func personaFingerprintBlock(p *domain.Persona) string { if p == nil { return "(未選人設,用自然口語、友善、像朋友聊天)" } fp := strings.TrimSpace(p.Style.DraftText) if fp == "" { fp = serializeDraftText(p.Style.Draft) } var b strings.Builder if n := strings.TrimSpace(p.Name); n != "" { b.WriteString("名稱:") b.WriteString(n) b.WriteString("\n") } if br := strings.TrimSpace(p.Brief); br != "" { b.WriteString("定位:") b.WriteString(br) b.WriteString("\n") } if fp != "" { b.WriteString("【語言指紋】\n") b.WriteString(fp) b.WriteString("\n") } else if t := strings.TrimSpace(p.Style.Draft.Tone); t != "" { b.WriteString("語氣:") b.WriteString(t) b.WriteString("\n") } if len(p.Guard.Avoid) > 0 { b.WriteString("【禁止】") b.WriteString(strings.Join(p.Guard.Avoid, "、")) b.WriteString("\n") } out := strings.TrimSpace(b.String()) if out == "" { return "(人設資料不足,用自然口語)" } return out } func buildOwnPostReplyPrompt(fp, postText, commentUser, commentText string) string { var b strings.Builder b.WriteString(`你正在扮演 Threads 帳號本人回覆(不是客服、不是分析師)。 任務:依人設指紋寫「一則短回覆草稿」。 規則(強制): - 只輸出回覆正文,不要「回覆:」前綴、不要分析、不要 markdown。 - 繁體中文(台灣用語)、口語、像真人滑手機回。 - 嚴格遵守指紋語氣/節奏/禁忌。 - 長度約 30~180 字,可分段空行。 - 若有對方留言,要接對方的話;若只回自己主貼下,像補一句或開場邀互動。 `) b.WriteString("【人設】\n") b.WriteString(fp) b.WriteString("\n\n【我的主貼】\n") b.WriteString(strings.TrimSpace(postText)) b.WriteString("\n") if strings.TrimSpace(commentText) != "" { b.WriteString("\n【要回的留言】") if commentUser != "" { b.WriteString(" @") b.WriteString(commentUser) } b.WriteString("\n") b.WriteString(commentText) b.WriteString("\n") } else { b.WriteString("\n(直接在主貼下回覆/補一句,沒有指定某則留言)\n") } return strings.TrimSpace(b.String()) } func buildMentionReplyPrompt(fp, fromUser, mentionText, contextSnippet string) string { return strings.TrimSpace(fmt.Sprintf(` 你正在扮演 Threads 帳號本人,回覆別人的「提及/@」。 任務:依人設指紋寫一則短回覆草稿。 規則(強制): - 只輸出回覆正文,不要前綴、不要分析。 - 繁體中文、口語;嚴格遵守指紋。 - 約 30~160 字。 【人設】 %s 【對方 @你】 @%s:%s 【上下文】 %s `, fp, fromUser, mentionText, strings.TrimSpace(contextSnippet))) } func (s *Service) MarkMentionReplied(ctx context.Context, ownerUID int64, id, text string, imageURLs []string) (*domain.Mention, error) { m, err := s.getMentionOwned(ctx, ownerUID, id) if err != nil { return nil, err } text = strings.TrimSpace(text) if text == "" { text = strings.TrimSpace(m.DraftText) } if text == "" { return nil, fmt.Errorf("%w: empty text", domain.ErrValidation) } // 真發文:reply_to = 提及 media_id(對方那則貼/回覆) if s.Transport != nil { token := "fake" accID := m.AccountID if s.Accounts != nil { acc, aerr := s.Accounts.Get(ctx, accID) if aerr != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable { return nil, domain.ErrNoAccount } t, terr := s.Accounts.AccessToken(ctx, acc) if terr != nil || strings.TrimSpace(t) == "" { return nil, fmt.Errorf("%w: 帳號 token 無效,請重新連 Threads", domain.ErrValidation) } token = t } replyTo := strings.TrimSpace(m.MediaID) if replyTo == "" { // 單元測試 seed 無 media_id 時允許 FakeTransport;正式路徑必須有 if s.Accounts != nil { return nil, fmt.Errorf("%w: 提及缺少 media_id,請先重新同步提及", domain.ErrValidation) } replyTo = "media_test" } if s.Accounts != nil && (strings.HasPrefix(token, "fake-") || token == "fake") { return nil, fmt.Errorf("%w: 帳號 token 無效,請重新連 Threads", domain.ErrValidation) } _, perr := s.Transport.Publish(ctx, domain.PublishRequest{ AccessToken: token, AccountID: accID, Text: text, ReplyTo: replyTo, }) if perr != nil { return nil, fmt.Errorf("%w: %s", domain.ErrValidation, perr.Error()) } } m.Status = domain.MentionReplied m.DraftText = text _ = imageURLs // 提及回覆不附圖(與我的貼文回覆一致) if err := s.Repo.SaveMention(ctx, m); err != nil { return nil, err } return m, nil } func (s *Service) SkipMention(ctx context.Context, ownerUID int64, id string) (*domain.Mention, error) { m, err := s.getMentionOwned(ctx, ownerUID, id) if err != nil { return nil, err } m.Status = domain.MentionSkipped if err := s.Repo.SaveMention(ctx, m); err != nil { return nil, err } return m, nil } // SeedMention for tests / sync helpers func (s *Service) SeedMention(ctx context.Context, m *domain.Mention) error { if m.ID == "" { m.ID = "mn_" + uuid.NewString()[:10] } if m.Status == "" { m.Status = domain.MentionPending } if m.CreatedAt == 0 { m.CreatedAt = domain.NowNano() } return s.Repo.SaveMention(ctx, m) } // ---------- helpers ---------- func (s *Service) billAI(ctx context.Context, ownerUID int64, label, source string) error { if s.Usage == nil { return nil } mode, err := s.Usage.PrepareCall(ctx, ownerUID, usageDomain.MeterAICopy) if err != nil { return err } _, err = s.Usage.RecordCall(ctx, ownerUID, usageDomain.MeterAICopy, mode, label, source) return err } func (s *Service) getOwnPostOwned(ctx context.Context, ownerUID int64, id string) (*domain.OwnPost, error) { p, err := s.Repo.GetOwnPost(ctx, id) if err != nil { return nil, err } if p.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return p, nil } func (s *Service) getMentionOwned(ctx context.Context, ownerUID int64, id string) (*domain.Mention, error) { m, err := s.Repo.GetMention(ctx, id) if err != nil { return nil, err } if m.OwnerUID != ownerUID { return nil, domain.ErrForbidden } return m, nil } func (s *Service) ensureUsableAccounts(ctx context.Context, ownerUID int64, play *domain.Play) error { if s.Accounts == nil { // test mode without accounts — allow if lead set if play.LeadAccountID == "" && !underPost(play) { return domain.ErrNoAccount } return nil } ids := map[string]struct{}{} if play.LeadAccountID != "" { ids[play.LeadAccountID] = struct{}{} } for _, c := range play.CastAccountIDs { ids[c] = struct{}{} } for _, st := range play.Steps { ids[st.AccountID] = struct{}{} } for id := range ids { if id == "" { continue } acc, err := s.Accounts.Get(ctx, id) if err != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable { return domain.ErrNoAccount } } return nil } func underPost(p *domain.Play) bool { return p.TargetOwnPostID != "" || (p.TargetExternal != nil && p.TargetExternal.URL != "") } func validatePlay(p *domain.Play) error { if underPost(p) { if len(p.Steps) == 0 { return fmt.Errorf("%w: need replies", domain.ErrValidation) } speakers := speakerSet(p) for _, st := range p.Steps { if st.AccountID == "" || !speakers[st.AccountID] { return fmt.Errorf("%w: reply account not in speakers", domain.ErrValidation) } if strings.TrimSpace(st.Text) == "" { return fmt.Errorf("%w: empty step", domain.ErrValidation) } } return nil } if p.LeadAccountID == "" { return fmt.Errorf("%w: need lead", domain.ErrValidation) } if len(p.Steps) == 0 { return fmt.Errorf("%w: need root", domain.ErrValidation) } root := p.Steps[0] for _, st := range p.Steps { if st.Kind == domain.StepRoot { root = st break } } if root.Kind != domain.StepRoot { return fmt.Errorf("%w: first must root", domain.ErrValidation) } if root.AccountID != p.LeadAccountID { return fmt.Errorf("%w: root must lead", domain.ErrValidation) } speakers := speakerSet(p) for _, st := range p.Steps { if st.Kind == domain.StepReply { if !speakers[st.AccountID] { return fmt.Errorf("%w: reply account not in speakers", domain.ErrValidation) } } } return nil } func speakerSet(p *domain.Play) map[string]bool { m := map[string]bool{} if p.LeadAccountID != "" { m[p.LeadAccountID] = true } for _, c := range p.CastAccountIDs { if c != "" { m[c] = true } } return m } func playToOutbox(ownerUID int64, play *domain.Play) *domain.OutboxBundle { now := domain.NowNano() // 第一則:送出 Outbox 後立刻可發(忽略過期/過遠的 schedule_start_at 造成「排程中卡住」)。 // 若明確設了「未來開始時間」且 > now+30s,才把第一則排到那個時間(延後開跑)。 cursor := now if play.ScheduleStartAt > now+int64(30*time.Second) { cursor = play.ScheduleStartAt } steps := make([]domain.OutboxStep, 0, len(play.Steps)) replyTo := "" if play.TargetExternal != nil { replyTo = play.TargetExternal.MediaID } for i, st := range play.Steps { if i > 0 { // 後續:基礎間隔 + 隨機抖動;0 = 與上一則同一排程點(worker 同 tick 依序發) base := st.DelayFromPreviousSec if base < 0 { base = 0 } if base > 0 { cursor += int64(intervalSecWithJitter(base)) * int64(time.Second) } } kind := st.Kind if underPost(play) { kind = domain.StepReply } steps = append(steps, domain.OutboxStep{ ID: "obstep_" + uuid.NewString()[:10], StepID: st.ID, SortOrder: st.SortOrder, Kind: kind, AccountID: st.AccountID, Text: st.Text, ImageURLs: append([]string(nil), st.ImageURLs...), Status: domain.StepScheduled, ScheduledAt: cursor, ReplyTo: replyTo, }) } title := play.Title if title == "" { title = play.Topic } return &domain.OutboxBundle{ ID: "outbox_" + uuid.NewString()[:12], OwnerUID: ownerUID, PlayID: play.ID, Title: title, Status: domain.OBScheduling, Steps: steps, ReplyToMediaID: replyTo, CreatedAt: now, UpdatedAt: now, } } // intervalSecWithJitter — 在 base 秒上加 ±20% 抖動(至少 ±20s、最多 ±3min),且不低於 15s。 // 讓互回/串場節奏像真人,不要剛好每 300 秒一則。 func intervalSecWithJitter(baseSec int) int { if baseSec < 0 { baseSec = 0 } span := baseSec / 5 // 20% if span < 20 { span = 20 } if span > 180 { span = 180 } // [base-span, base+span] delta := 0 if span > 0 { delta = cryptoRandIntn(2*span+1) - span } out := baseSec + delta if out < 15 { out = 15 } return out } // cryptoRandIntn — [0, n) 均勻;失敗時退回 0(不抖動) func cryptoRandIntn(n int) int { if n <= 1 { return 0 } // 用 crypto/rand 避免 math/rand 可預測 var b [8]byte if _, err := rand.Read(b[:]); err != nil { return 0 } // big-endian uint64 v := uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 | uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7]) return int(v % uint64(n)) } func findRoot(b *domain.OutboxBundle) *domain.OutboxStep { for i := range b.Steps { if b.Steps[i].Kind == domain.StepRoot { return &b.Steps[i] } } return nil } func findOutboxStep(b *domain.OutboxBundle, stepID string) *domain.OutboxStep { for i := range b.Steps { if b.Steps[i].ID == stepID { return &b.Steps[i] } } return nil } func recomputeBundle(steps []domain.OutboxStep) string { if len(steps) == 0 { return domain.OBCancelled } allPub := true anyFail := false anyActive := false for _, s := range steps { switch s.Status { case domain.StepPublished: case domain.StepFailed: anyFail = true allPub = false case domain.StepPublishing: anyActive = true allPub = false case domain.StepBlocked, domain.StepCancelled: allPub = false default: allPub = false if s.Status == domain.StepScheduled { // still scheduling } } } if allPub { return domain.OBCompleted } if anyFail { return domain.OBPartial } if anyActive { return domain.OBActive } return domain.OBScheduling } // cleanupEphemeralImages 刪除發文用暫存圖(temp/*;相容 other/* 舊路徑)。 // avatar/* 永不刪。best-effort,失敗只記 log。 func (s *Service) cleanupEphemeralImages(ctx context.Context, urls []string) { if s.Storage == nil || !s.Storage.Enabled() || len(urls) == 0 { return } for _, u := range urls { key := s.objectKeyFromPublicURL(u) if key == "" || !isEphemeralPublishObjectKey(key) { continue } if err := s.Storage.Delete(ctx, key); err != nil { logx.Errorf("studio cleanup image key=%s: %v", key, err) } else { logx.Infof("studio cleaned ephemeral image key=%s", key) } } } func isEphemeralPublishObjectKey(key string) bool { key = strings.TrimPrefix(key, "/") return strings.HasPrefix(key, "temp/") || strings.HasPrefix(key, "other/") } func (s *Service) objectKeyFromPublicURL(raw string) string { u := strings.TrimSpace(raw) if u == "" { return "" } base := strings.TrimRight(strings.TrimSpace(s.StoragePublicBase), "/") if base != "" { prefix := base + "/" if strings.HasPrefix(u, prefix) { return strings.TrimPrefix(u, prefix) } } // 後備:路徑含 /temp/ 或 /other/ 時取之後片段 for _, marker := range []string{"/temp/", "/other/"} { if i := strings.Index(u, marker); i >= 0 { return strings.TrimPrefix(u[i:], "/") } } return "" } func normalizeURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } u, err := url.Parse(raw) if err != nil { return strings.TrimRight(raw, "/") } if u.Scheme == "" { u.Scheme = "https" } u.Host = strings.ToLower(u.Host) u.Fragment = "" // strip query noise u.RawQuery = "" s := u.String() return strings.TrimRight(s, "/") } func truncate(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" }