thread-master/apps/backend/internal/module/studio/usecase/service.go

2869 lines
88 KiB
Go
Raw Permalink Normal View History

2026-07-13 01:15:30 +00:00
package usecase
import (
"context"
"crypto/rand"
"encoding/json"
2026-07-15 15:23:59 +00:00
"errors"
2026-07-13 01:15:30 +00:00
"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
2026-07-15 15:23:59 +00:00
PublishedAt int64 // unix ns
2026-07-13 01:15:30 +00:00
}
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
2026-07-15 15:23:59 +00:00
IsReply, IsQuotePost bool
PublishedAt int64
2026-07-13 01:15:30 +00:00
}
// 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
2026-07-15 15:23:59 +00:00
OutboxWorkerID string
// OutboxLeaseDuration is configurable for focused lease-renewal tests.
OutboxLeaseDuration time.Duration
2026-07-13 01:15:30 +00:00
}
func New(repo domain.Repository, transport publish.Transport) *Service {
2026-07-15 15:23:59 +00:00
return &Service{
Repo: repo, Transport: transport,
OutboxWorkerID: "studio-" + uuid.NewString(), OutboxLeaseDuration: 3 * time.Minute,
}
2026-07-13 01:15:30 +00:00
}
// ---------- 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仍把全文放 notesdraft 用規則
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)
}
// testsFakeClient + 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.stepsonlyEmpty=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 完全一致
- 繁體中文口語像真人互回每則 25100
- 接住主文上一則不要重複抄全文不要暴露 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 模型可能 3090s 且偶發空白回覆。
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 120sOpenCode 慢模型常 4090s
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 主貼。只輸出正文繁中口語80220字勿標題/markdown。\n")
} else {
b.WriteString("寫一則 Threads 互回短回覆。只輸出正文繁中口語25120字接上下文勿分析/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
}
2026-07-15 15:23:59 +00:00
// 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
}
2026-07-13 01:15:30 +00:00
for i := range b.Steps {
s.cleanupEphemeralImages(ctx, b.Steps[i].ImageURLs)
}
2026-07-15 15:23:59 +00:00
return nil
2026-07-13 01:15:30 +00:00
}
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
}
}
2026-07-15 15:23:59 +00:00
return s.Repo.RetryOutboxStep(ctx, bundleID, stepID, domain.NowNano())
2026-07-13 01:15:30 +00:00
}
// 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]
2026-07-15 15:23:59 +00:00
claimable := st.Status == domain.StepScheduled && st.ScheduledAt <= now
stale := st.Status == domain.StepPublishing && st.LeaseExpiresAt <= now
if !claimable && !stale {
2026-07-13 01:15:30 +00:00
continue
}
2026-07-15 15:23:59 +00:00
root := findRoot(b)
2026-07-13 01:15:30 +00:00
if st.Kind == domain.StepReply {
2026-07-15 15:23:59 +00:00
if root != nil && root.Status != domain.StepPublished {
2026-07-13 01:15:30 +00:00
continue
}
2026-07-15 15:23:59 +00:00
}
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 {
2026-07-13 01:15:30 +00:00
continue
}
2026-07-15 15:23:59 +00:00
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)
2026-07-13 01:15:30 +00:00
}
if s.Transport == nil {
2026-07-15 15:23:59 +00:00
if err := fail("publish transport not configured"); err != nil {
return count, err
}
2026-07-13 01:15:30 +00:00
continue
}
token := "fake-token"
if s.Accounts != nil {
2026-07-15 15:23:59 +00:00
acc, aerr := s.Accounts.Get(ctx, result.AccountID)
2026-07-13 01:15:30 +00:00
if aerr != nil || acc == nil || !acc.IsUsable {
2026-07-15 15:23:59 +00:00
if err := fail("account not usable"); err != nil {
return count, err
}
2026-07-13 01:15:30 +00:00
continue
}
2026-07-15 15:23:59 +00:00
if t, terr := s.Accounts.AccessToken(ctx, acc); terr != nil {
if err := fail(terr.Error()); err != nil {
return count, err
}
continue
} else if t != "" {
2026-07-13 01:15:30 +00:00
token = t
}
}
2026-07-15 15:23:59 +00:00
root = findRoot(claimed)
replyTo := result.ReplyTo
if replyTo == "" && result.Kind == domain.StepReply {
2026-07-13 01:15:30 +00:00
if root != nil && root.MediaID != "" {
replyTo = root.MediaID
2026-07-15 15:23:59 +00:00
} else if claimed.ReplyToMediaID != "" {
replyTo = claimed.ReplyToMediaID
2026-07-13 01:15:30 +00:00
}
}
2026-07-15 15:23:59 +00:00
request := domain.PublishRequest{
2026-07-13 01:15:30 +00:00
AccessToken: token,
2026-07-15 15:23:59 +00:00
AccountID: result.AccountID,
Text: result.Text,
2026-07-13 01:15:30 +00:00
ReplyTo: replyTo,
2026-07-15 15:23:59 +00:00
ImageURLs: result.ImageURLs,
2026-07-13 01:15:30 +00:00
// 僅主貼帶話題標籤
TopicTag: func() string {
2026-07-15 15:23:59 +00:00
if result.Kind == domain.StepRoot {
return result.TopicTag
2026-07-13 01:15:30 +00:00
}
return ""
}(),
2026-07-15 15:23:59 +00:00
}
res, perr := s.publishWithLease(ctx, b.ID, result.ID, claimOwner, leaseDuration, request)
2026-07-13 01:15:30 +00:00
if perr != nil {
2026-07-15 15:23:59 +00:00
if errors.Is(perr, domain.ErrLeaseLost) {
return count, perr
}
if err := fail(perr.Error()); err != nil {
return count, err
2026-07-13 01:15:30 +00:00
}
} else {
2026-07-15 15:23:59 +00:00
result.Status, result.Error = domain.StepPublished, ""
result.PublishedAt = domain.NowNano()
2026-07-13 01:15:30 +00:00
if res != nil {
2026-07-15 15:23:59 +00:00
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
2026-07-13 01:15:30 +00:00
}
2026-07-15 15:23:59 +00:00
if err := s.Repo.RecomputeOutboxStatus(ctx, b.ID, finishedAt); err != nil {
return count, err
2026-07-13 01:15:30 +00:00
}
2026-07-15 15:23:59 +00:00
s.cleanupEphemeralImages(ctx, images)
2026-07-13 01:15:30 +00:00
count++
2026-07-15 15:23:59 +00:00
}
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
2026-07-13 01:15:30 +00:00
}
}
}
2026-07-15 15:23:59 +00:00
}()
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
2026-07-13 01:15:30 +00:00
}
}
// ---------- 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 24 有問句就留好回的小問題只輸出正文
說話方式
%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": "開場鉤子怎麼抓注意力13 句)",
"structure": "段落結構骨架(如:情境→痛點→經驗→提問)",
"emotion": "情緒節奏(好奇/共感/緊迫等)",
"summary": "為什麼這則有互動潛力24 句)",
"cta": "結尾 CTA互動設計",
"copyable": "別人可複製的 24 個要點(勿抄原文)",
"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
}
2026-07-13 08:59:13 +00:00
// 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
}
2026-07-13 01:15:30 +00:00
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 seedSynced 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
- 繁體中文台灣用語口語像真人滑手機回
- 嚴格遵守指紋語氣節奏禁忌
- 長度約 30180 可分段空行
- 若有對方留言要接對方的話若只回自己主貼下像補一句或開場邀互動
`)
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 帳號本人回覆別人的提及@
任務依人設指紋寫一則短回覆草稿
規則強制
- 只輸出回覆正文不要前綴不要分析
- 繁體中文口語嚴格遵守指紋
- 30160
人設
%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...),
2026-07-15 15:23:59 +00:00
Status: domain.StepScheduled, ScheduledAt: cursor, ReplyTo: replyTo,
2026-07-13 01:15:30 +00:00
})
}
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
}
2026-07-15 15:23:59 +00:00
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
}
2026-07-13 01:15:30 +00:00
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]) + "…"
}