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"
2026-07-20 06:33:14 +00:00
"sync"
2026-07-13 01:15:30 +00:00
"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-08-18 16:26:10 +00:00
ReplyControl string
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
2026-08-18 16:26:10 +00:00
HideStatus string
}
// ThreadsReplyManager is optional; live Meta bridge implements hide / reply_control.
type ThreadsReplyManager interface {
ManageReply ( ctx context . Context , accessToken , replyID string , hide bool ) error
SetReplyControl ( ctx context . Context , accessToken , mediaID , control string ) error
2026-07-13 01:15:30 +00:00
}
// 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-23 05:56:42 +00:00
// OnStepPublished optional growth-loop outcome hook after real publish.
OnStepPublished func ( ctx context . Context , ownerUID int64 , bundleID , stepID , accountID string )
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。
2026-07-28 06:33:40 +00:00
func ( s * Service ) ExecuteAnalyzeFromText ( ctx context . Context , ownerUID int64 , id , rawText , sourceLabel string , onProgress ProgressFn ) ( _ * domain . Persona , err error ) {
2026-07-13 01:15:30 +00:00
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 , "人設分析 · 檢查文字樣本…" )
2026-07-28 06:33:40 +00:00
charge , err := s . billAI ( ctx , ownerUID , "persona analyze text" , "personas.analyzeFromText" )
if err != nil {
2026-07-13 01:15:30 +00:00
_ = s . markPersonaAnalyzeFailed ( ctx , p , err . Error ( ) )
return nil , err
}
2026-07-28 06:33:40 +00:00
defer charge . Settle ( ctx , & err )
2026-07-13 01:15:30 +00:00
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。
2026-07-28 06:33:40 +00:00
func ( s * Service ) ExecuteAnalyzeFromAccount ( ctx context . Context , ownerUID int64 , id , username string , onProgress ProgressFn ) ( _ * domain . Persona , err error ) {
2026-07-13 01:15:30 +00:00
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 ) )
2026-07-28 06:33:40 +00:00
charge , err := s . billAI ( ctx , ownerUID , "persona analyze account" , "personas.analyzeFromAccount" )
if err != nil {
2026-07-13 01:15:30 +00:00
_ = s . markPersonaAnalyzeFailed ( ctx , p , err . Error ( ) )
return nil , err
}
2026-07-28 06:33:40 +00:00
defer charge . Settle ( ctx , & err )
2026-07-13 01:15:30 +00:00
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 ) )
2026-07-23 05:56:42 +00:00
// growth-loop: 資產複利可見
p . LearningVersion ++
p . LearnedFromPostsCount = len ( samples )
p . LastLearnedAt = domain . NowNano ( )
if p . LearningSummary == "" {
p . LearningSummary = fmt . Sprintf ( "已從 %d 則樣本學到語氣/ 結構指紋( v%d) " , len ( samples ) , p . LearningVersion )
} else {
p . LearningSummary = fmt . Sprintf ( "更新語言指紋 v%d · 樣本 %d 則" , p . LearningVersion , len ( samples ) )
}
2026-07-13 01:15:30 +00:00
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 / 同步測試。
2026-07-28 06:33:40 +00:00
func ( s * Service ) GeneratePlayScript ( ctx context . Context , ownerUID int64 , playID string , onlyEmpty bool ) ( _ int , err error ) {
2026-07-13 01:15:30 +00:00
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 )
}
2026-07-28 06:33:40 +00:00
charge , err := s . billAI ( ctx , ownerUID , "play generate script" , "plays.generateScript" )
if err != nil {
2026-07-13 01:15:30 +00:00
return 0 , err
}
2026-07-28 06:33:40 +00:00
defer charge . Settle ( ctx , & err )
2026-07-13 01:15:30 +00:00
// 主上下文:目標貼文/主題
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 )
2026-07-20 06:33:14 +00:00
fp := personaExpressionFingerprintBlock ( persona )
2026-07-13 01:15:30 +00:00
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 完全一致
2026-07-20 06:33:14 +00:00
- 繁體中文口語 、 像真人互回 ; root 主貼依內容需要完整寫完 , reply 維持 25 ~ 100 字
2026-07-13 01:15:30 +00:00
- 接住主文 / 上一則 , 不要重複抄全文 、 不要暴露 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 )
2026-07-20 06:33:14 +00:00
t := strings . TrimSpace ( st . Text )
2026-07-13 01:15:30 +00:00
if id == "" || t == "" {
continue
}
byID [ id ] = t
}
// 也允許依順序填(若 id 對不上)
filled := 0
orderIdx := 0
ordered := make ( [ ] string , 0 , len ( parsed . Steps ) )
for _ , st := range parsed . Steps {
2026-07-20 06:33:14 +00:00
if t := strings . TrimSpace ( st . Text ) ; t != "" {
2026-07-13 01:15:30 +00:00
ordered = append ( ordered , t )
}
}
for i := range play . Steps {
id := play . Steps [ i ] . ID
if t , ok := byID [ id ] ; ok {
2026-07-20 06:33:14 +00:00
play . Steps [ i ] . Text = cleanPlayGeneratedText ( play . Steps [ i ] . Kind , t )
2026-07-13 01:15:30 +00:00
filled ++
continue
}
// 僅空白步才用順序填
if strings . TrimSpace ( play . Steps [ i ] . Text ) == "" && orderIdx < len ( ordered ) {
2026-07-20 06:33:14 +00:00
play . Steps [ i ] . Text = cleanPlayGeneratedText ( play . Steps [ i ] . Kind , ordered [ orderIdx ] )
2026-07-13 01:15:30 +00:00
orderIdx ++
filled ++
}
}
return filled
}
2026-07-20 06:33:14 +00:00
func cleanPlayGeneratedText ( kind , text string ) string {
if kind == domain . StepRoot {
return cleanGeneratedPostText ( text )
}
return cleanGeneratedTextMax ( text , 280 )
}
2026-07-13 01:15:30 +00:00
// GeneratePlayStep — 互回/串場劇本:依人設真 LLM 產一步正文。
// 注意:與 compose mimic 相同, OpenCode reasoning 模型可能 30~ 90s 且偶發空白回覆。
2026-07-28 06:33:40 +00:00
func ( s * Service ) GeneratePlayStep ( ctx context . Context , ownerUID int64 , personaID , contextText , topic , speakerLabel string , isLead bool , mode string ) ( _ string , err error ) {
2026-07-13 01:15:30 +00:00
contextText = strings . TrimSpace ( contextText )
topic = strings . TrimSpace ( topic )
if contextText == "" && topic == "" {
return "" , fmt . Errorf ( "%w: empty context" , domain . ErrValidation )
}
2026-07-28 06:33:40 +00:00
charge , err := s . billAI ( ctx , ownerUID , "play generate step" , "plays.generateStep" )
if err != nil {
2026-07-13 01:15:30 +00:00
return "" , err
}
2026-07-28 06:33:40 +00:00
defer charge . Settle ( ctx , & err )
2026-07-20 06:33:14 +00:00
mode = strings . ToLower ( strings . TrimSpace ( mode ) )
if mode == "" {
mode = "reply"
}
2026-07-13 01:15:30 +00:00
persona := s . loadPersonaForGen ( ctx , ownerUID , personaID )
fp := personaFingerprintBlock ( persona )
2026-07-20 06:33:14 +00:00
if mode != "root" {
fp = personaExpressionFingerprintBlock ( persona )
}
2026-07-13 01:15:30 +00:00
// 指紋過長會拖慢 reasoning 模型(仿寫已踩過)
if utf8 . RuneCountInString ( fp ) > 500 {
fp = string ( [ ] rune ( fp ) [ : 500 ] ) + "…"
}
if utf8 . RuneCountInString ( contextText ) > 400 {
contextText = string ( [ ] rune ( contextText ) [ : 400 ] ) + "…"
}
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 )
2026-07-20 06:33:14 +00:00
if mode == "root" {
text = cleanGeneratedPostText ( out )
}
2026-07-13 01:15:30 +00:00
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" {
2026-07-20 06:33:14 +00:00
b . WriteString ( "寫一則 Threads 主貼。只輸出正文(繁中口語);依內容需要自然展開,把觀點與情緒完整講完,不設固定字數,勿標題/markdown。\n" )
2026-07-13 01:15:30 +00:00
} else {
2026-07-20 06:33:14 +00:00
b . WriteString ( "寫一則 Threads 互回短回覆。先理解上下文, 再回應其中一個具體內容; 人設只控制表達方式, 不替內容套模板。只輸出正文( 繁中口語) , 15~ 120字, 勿分析/markdown/暴露AI。\n" )
b . WriteString ( "不要預設用建議、共感、感謝或問句開頭;從上下文自然決定切入點,也不必刻意用問句收尾。\n" )
2026-07-13 01:15:30 +00:00
}
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-08-18 16:26:10 +00:00
ReplyControl : func ( ) string {
if result . Kind == domain . StepRoot {
return result . ReplyControl
}
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-23 05:56:42 +00:00
if s . OnStepPublished != nil {
s . OnStepPublished ( ctx , b . OwnerUID , b . ID , result . ID , result . AccountID )
}
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) ----------
2026-07-28 06:33:40 +00:00
func ( s * Service ) Mimic ( ctx context . Context , ownerUID int64 , sourceText , personaID , direction , structureNotes string ) ( _ string , err error ) {
2026-07-13 01:15:30 +00:00
sourceText = strings . TrimSpace ( sourceText )
if sourceText == "" {
return "" , fmt . Errorf ( "%w: empty source" , domain . ErrValidation )
}
2026-07-20 06:33:14 +00:00
personaID = strings . TrimSpace ( personaID )
if personaID == "" {
return "" , fmt . Errorf ( "%w: 請選擇已完成人設分析的人設" , domain . ErrValidation )
}
p , err := s . GetPersona ( ctx , ownerUID , personaID )
if err != nil || p == nil {
return "" , fmt . Errorf ( "%w: 選擇的人設不存在或無法使用" , domain . ErrValidation )
}
if p . Status != domain . PersonaReady {
return "" , fmt . Errorf ( "%w: 選擇的人設尚未完成分析" , domain . ErrValidation )
}
2026-07-28 06:33:40 +00:00
charge , err := s . billAI ( ctx , ownerUID , "compose mimic" , "compose.mimic" )
if err != nil {
return "" , err
}
defer charge . Settle ( ctx , & err )
2026-07-20 06:33:14 +00:00
fp := personaExpressionFingerprintBlock ( p )
2026-07-13 01:15:30 +00:00
// 指紋過長會拖慢 reasoning 模型
if utf8 . RuneCountInString ( fp ) > 800 {
fp = string ( [ ] rune ( fp ) [ : 800 ] ) + "…"
}
notes := strings . TrimSpace ( structureNotes )
2026-07-20 06:33:14 +00:00
if utf8 . RuneCountInString ( notes ) > 800 {
notes = string ( [ ] rune ( notes ) [ : 800 ] ) + "…"
2026-07-13 01:15:30 +00:00
}
2026-07-20 06:33:14 +00:00
direction = strings . TrimSpace ( direction )
if utf8 . RuneCountInString ( direction ) > 300 {
direction = string ( [ ] rune ( direction ) [ : 300 ] ) + "…"
2026-07-13 01:15:30 +00:00
}
notesBlock := ""
if notes != "" {
notesBlock = fmt . Sprintf ( "\n\n( 節奏提示, 勿照抄) \n%s\n" , notes )
}
2026-07-20 06:33:14 +00:00
prompt := buildMimicPrompt ( fp , sourceText , direction , notesBlock )
2026-07-13 01:15:30 +00:00
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 {
2026-07-20 06:33:14 +00:00
if t := cleanGeneratedPostText ( out ) ; t != "" {
2026-07-13 01:15:30 +00:00
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 ) )
}
2026-07-20 06:33:14 +00:00
text := cleanGeneratedPostText ( out )
2026-07-13 01:15:30 +00:00
if text == "" {
text = strings . TrimSpace ( out )
}
if text == "" {
return "" , fmt . Errorf ( "%w: AI 回傳空白(%s / %s) 。請到設定確認模型後再試" , domain . ErrValidation , provider , model )
}
return text , nil
}
2026-07-20 06:33:14 +00:00
func buildMimicPrompt ( fp , sourceText , direction , notesBlock string ) string {
direction = strings . TrimSpace ( direction )
if direction == "" {
direction = "請自行從參考文涵蓋的領域延伸一個相關、具體,但明顯不同的新角度;不可沿用原文事件或結論。"
}
return strings . TrimSpace ( fmt . Sprintf ( `
你就是 【 指定人設 】 本人在寫一篇全新的 Threads 貼文 , 不是助手 , 也不是在改寫原句 。
請在心中依序完成 , 但不要輸出分析 :
1. 綜觀 【 參考文 】 與 【 結構分析 】 , 只抽取可借用的敘事骨架 、 資訊密度 、 轉折方式與情緒曲線 。
2. 依 【 新內容方向 】 重新立題 , 先想清楚這篇新貼文自己的核心觀點 、 素材與情緒 , 不沿用參考文內容 。
3. 用全新的首句 、 例子 、 論述與收尾寫完內容 。
4. 最後套用 【 人設表達軌跡 】 的慣用詞 、 語氣 、 節奏 、 標點與情緒表達 ; 成品必須明顯像這個人說的話 。
規則 :
- 參考文只供學習結構 , 不是內容來源 。 不得沿用其人物 、 事件 、 品牌 、 例子 、 數字 、 觀點句 、 首句或結論 。
- 不是摘要 、 潤稿或同義改寫 ; 新貼文讀起來必須是另一篇文章 。
- 新內容與人設同等重要 : 內容要完整 , 人設聲紋也要清楚 , 但不得套固定鉤子或 CTA 。
- 不要固定從建議 、 共感 、 提問 、 金句或結論開始 ; 重新設計最適合新主題的切入點 。
- 不補空泛雞湯 , 不為了互動硬加問句 , 不捏造具體個人經歷或不可驗證事實 。
- 段落與篇幅跟著內容走 , 不強制列點 、 固定段數或固定字數 ; 把文章完整講完 , 但不要灌水 。
- 只輸出完成後的正文 , 不要標題 、 分析 、 markdown 或任何前綴 。
【 新內容方向 】
% s
【 人設表達軌跡 】
% s
【 參考文 , 只借結構 】
% s
% s
` , direction , fp , sourceText , notesBlock ) )
}
2026-07-28 06:33:40 +00:00
func ( s * Service ) AnalyzeViral ( ctx context . Context , ownerUID int64 , text string ) ( _ * domain . ViralAnalysis , err error ) {
charge , err := s . billAI ( ctx , ownerUID , "compose analyze viral" , "compose.analyzeViral" )
if err != nil {
2026-07-13 01:15:30 +00:00
return nil , err
}
2026-07-28 06:33:40 +00:00
defer charge . Settle ( ctx , & err )
2026-07-13 01:15:30 +00:00
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 ( ) )
}
2026-08-18 16:26:10 +00:00
func ( s * Service ) PublishSingle ( ctx context . Context , ownerUID int64 , accountID , text , title string , imageURLs [ ] string , scheduleStartAt int64 , topicTag , replyControl string ) ( * domain . OutboxBundle , error ) {
2026-07-13 01:15:30 +00:00
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
}
2026-08-18 16:26:10 +00:00
if ctrl := threadsProvNormalizeReplyControl ( replyControl ) ; ctrl != "" && len ( bundle . Steps ) > 0 {
bundle . Steps [ 0 ] . ReplyControl = ctrl
}
2026-07-13 01:15:30 +00:00
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 seed( Synced post #…)
if strings . HasPrefix ( p . MediaID , "media_sync_" ) || strings . HasPrefix ( p . Text , "Synced post #" ) {
_ = s . Repo . DeleteOwnPost ( ctx , p . ID )
}
}
2026-07-20 06:33:14 +00:00
insights := make ( [ ] FetchedInsights , len ( threads ) )
2026-07-28 06:33:40 +00:00
// Each slot starts as "not fetched" so a rate-limited call, a timeout, or a cancelled sync is
// distinguishable from a post that genuinely has zero engagement.
insightFetched := make ( [ ] bool , len ( threads ) )
2026-07-20 06:33:14 +00:00
var wg sync . WaitGroup
limit := make ( chan struct { } , 4 )
for i , th := range threads {
if th . ID == "" {
continue
}
wg . Add ( 1 )
go func ( i int , mediaID string ) {
defer wg . Done ( )
select {
case limit <- struct { } { } :
defer func ( ) { <- limit } ( )
case <- ctx . Done ( ) :
return
}
2026-07-28 06:33:40 +00:00
got , err := s . Media . GetInsights ( ctx , accessToken , mediaID )
if err != nil {
logx . Errorf ( "threads insights media=%s: %v" , mediaID , err )
return
}
insights [ i ] , insightFetched [ i ] = got , true
2026-07-20 06:33:14 +00:00
} ( i , th . ID )
}
wg . Wait ( )
for i , th := range threads {
2026-07-13 01:15:30 +00:00
if th . ID == "" {
continue
}
2026-07-20 06:33:14 +00:00
// 留言仍在點開時才載; insights 已用小型 worker pool 並行取得。
ins := insights [ i ]
2026-07-13 01:15:30 +00:00
prev := byMedia [ th . ID ]
2026-07-28 06:33:40 +00:00
// A failed insights call must keep whatever counts we already had. Writing zeros would
// destroy the real numbers, and they feed insights summary, benchmark and account health.
if ! insightFetched [ i ] {
if prev != nil {
ins . Likes , ins . Replies , ins . Reposts = prev . LikeCount , prev . ReplyCount , prev . RepostCount
ins . Quotes , ins . Views , ins . Shares = prev . QuoteCount , prev . ViewCount , prev . ShareCount
}
ins . Status = "error"
}
2026-07-13 01:15:30 +00:00
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 ... )
}
2026-08-18 16:26:10 +00:00
replyControl := strings . TrimSpace ( th . ReplyControl )
if replyControl == "" && prev != nil {
replyControl = prev . ReplyControl
}
2026-07-13 01:15:30 +00:00
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 ,
2026-08-18 16:26:10 +00:00
ReplyControl : replyControl ,
2026-07-13 01:15:30 +00:00
}
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
}
2026-08-18 16:26:10 +00:00
func ownPostToken ( ctx context . Context , s * Service , ownerUID int64 , accountID string ) ( string , error ) {
if s . Accounts == nil {
return "" , nil
}
acc , err := s . Accounts . Get ( ctx , accountID )
if err != nil || acc == nil || acc . OwnerUID != ownerUID {
return "" , domain . ErrNoAccount
}
token , terr := s . Accounts . AccessToken ( ctx , acc )
if terr != nil {
return "" , terr
}
return strings . TrimSpace ( token ) , nil
}
func isRealThreadsToken ( token string ) bool {
return token != "" && ! strings . HasPrefix ( token , "fake-" )
}
// ManageOwnPostReply hides or unhides a top-level reply via Threads /manage_reply.
func ( s * Service ) ManageOwnPostReply ( ctx context . Context , ownerUID int64 , postID , replyID string , hide bool ) ( * domain . OwnPost , error ) {
post , err := s . getOwnPostOwned ( ctx , ownerUID , postID )
if err != nil {
return nil , err
}
replyID = strings . TrimSpace ( replyID )
if replyID == "" {
return nil , fmt . Errorf ( "%w: reply_id is required" , domain . ErrValidation )
}
found := false
for _ , r := range post . Replies {
if r . ID == replyID {
found = true
if r . IsMine {
return nil , fmt . Errorf ( "%w: cannot hide your own reply" , domain . ErrValidation )
}
if strings . TrimSpace ( r . ParentReplyID ) != "" {
return nil , fmt . Errorf ( "%w: only top-level replies can be hidden" , domain . ErrValidation )
}
break
}
}
if ! found && len ( post . Replies ) > 0 {
return nil , fmt . Errorf ( "%w: reply not found on this post" , domain . ErrValidation )
}
if post . MediaID != "" && replyID == post . MediaID {
return nil , fmt . Errorf ( "%w: 只能隱藏別人留在這則貼文下的回覆,不能隱藏貼文本體" , domain . ErrValidation )
}
token , terr := ownPostToken ( ctx , s , ownerUID , post . AccountID )
if terr != nil {
return nil , terr
}
if mgr , ok := any ( s . Media ) . ( ThreadsReplyManager ) ; ok && isRealThreadsToken ( token ) {
if err := mgr . ManageReply ( ctx , token , replyID , hide ) ; err != nil {
return nil , fmt . Errorf ( "%w: %s" , domain . ErrValidation , humanizeManageReplyError ( err ) )
}
} else if isRealThreadsToken ( token ) && s . Media != nil {
return nil , fmt . Errorf ( "%w: reply management is not configured" , domain . ErrValidation )
}
status := "NOT_HUSHED"
if hide {
status = "HIDDEN"
}
updated := false
for i := range post . Replies {
if post . Replies [ i ] . ID == replyID || ( hide && post . Replies [ i ] . ParentReplyID == replyID ) {
post . Replies [ i ] . HideStatus = status
updated = true
}
}
if ! updated {
post . Replies = append ( post . Replies , domain . OwnPostReply { ID : replyID , HideStatus : status } )
}
if err := s . Repo . SaveOwnPost ( ctx , post ) ; err != nil {
return nil , err
}
return post , nil
}
// SetOwnPostReplyControl updates who can reply (Threads reply_control).
func ( s * Service ) SetOwnPostReplyControl ( ctx context . Context , ownerUID int64 , postID , control string ) ( * domain . OwnPost , error ) {
post , err := s . getOwnPostOwned ( ctx , ownerUID , postID )
if err != nil {
return nil , err
}
control = threadsProvNormalizeReplyControl ( control )
if control == "" {
return nil , fmt . Errorf ( "%w: reply_control must be everyone, accounts_you_follow, mentioned_only, parent_post_author_only, or followers_only" , domain . ErrValidation )
}
token , terr := ownPostToken ( ctx , s , ownerUID , post . AccountID )
if terr != nil {
return nil , terr
}
// Threads 官方只允許發文時帶 reply_control; 對已發布 media POST 會回 code 100。
if s . Media != nil && isRealThreadsToken ( token ) && strings . TrimSpace ( post . MediaID ) != "" {
return nil , fmt . Errorf ( "%w: Threads 只能在發文時設定誰可以回覆,已發布貼文無法用 API 修改。請到創作頁發一則新貼文" , domain . ErrValidation )
}
post . ReplyControl = control
if err := s . Repo . SaveOwnPost ( ctx , post ) ; err != nil {
return nil , err
}
return post , nil
}
func threadsProvNormalizeReplyControl ( raw string ) string {
s := strings . ToLower ( strings . TrimSpace ( raw ) )
switch s {
case "everyone" , "accounts_you_follow" , "mentioned_only" , "parent_post_author_only" , "followers_only" :
return s
default :
return ""
}
}
func humanizeManageReplyError ( err error ) string {
if err == nil {
return ""
}
msg := strings . ToLower ( err . Error ( ) )
if strings . Contains ( msg , "unsupported post request" ) ||
strings . Contains ( msg , "does not support this operation" ) ||
strings . Contains ( msg , "code 100" ) ||
strings . Contains ( msg , "permission" ) {
return "無法隱藏這則回覆。請用別人留在你貼文下的第一層回覆,並到帳號頁重新連 Threads( 需授權 threads_manage_replies) "
}
return err . Error ( )
}
2026-07-13 01:15:30 +00:00
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
2026-08-18 16:26:10 +00:00
if id == "" || ( rootMediaID != "" && id == rootMediaID ) {
2026-07-13 01:15:30 +00:00
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
}
2026-08-18 16:26:10 +00:00
hideStatus := strings . TrimSpace ( c . HideStatus )
if old , ok := localStatus [ id ] ; ok && hideStatus == "" {
hideStatus = old . HideStatus
}
2026-07-13 01:15:30 +00:00
out = append ( out , domain . OwnPostReply {
ID : id , Username : c . Username , Text : c . Text , CreatedAt : created ,
LikeCount : c . LikeCount , ReplyStatus : status , RepliedBy : repliedBy , RepliedAt : repliedAt ,
2026-08-18 16:26:10 +00:00
ParentReplyID : parent , IsMine : c . IsMine , HideStatus : hideStatus ,
2026-07-13 01:15:30 +00:00
} )
}
// 純資料:若某則留言底下有「我的」子回覆 → 標已回覆
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
}
2026-07-28 06:33:40 +00:00
func ( s * Service ) GenerateReply ( ctx context . Context , ownerUID int64 , postID , replyID , personaID string ) ( _ string , err error ) {
2026-07-13 01:15:30 +00:00
post , err := s . getOwnPostOwned ( ctx , ownerUID , postID )
if err != nil {
return "" , err
}
postText := strings . TrimSpace ( post . Text )
commentText := ""
commentUser := ""
if replyID != "" {
2026-07-20 06:33:14 +00:00
found := false
2026-07-13 01:15:30 +00:00
for _ , r := range post . Replies {
if r . ID == replyID {
commentText = strings . TrimSpace ( r . Text )
commentUser = strings . TrimSpace ( r . Username )
2026-07-20 06:33:14 +00:00
found = true
2026-07-13 01:15:30 +00:00
break
}
}
2026-07-20 06:33:14 +00:00
if ! found {
return "" , fmt . Errorf ( "%w: reply not found" , domain . ErrValidation )
}
}
2026-07-28 06:33:40 +00:00
charge , err := s . billAI ( ctx , ownerUID , "own post reply draft" , "ownPosts.generateReply" )
if err != nil {
2026-07-20 06:33:14 +00:00
return "" , err
2026-07-13 01:15:30 +00:00
}
2026-07-28 06:33:40 +00:00
defer charge . Settle ( ctx , & err )
2026-07-13 01:15:30 +00:00
persona := s . loadPersonaForGen ( ctx , ownerUID , personaID )
2026-07-20 06:33:14 +00:00
fp := personaExpressionFingerprintBlock ( persona )
2026-07-13 01:15:30 +00:00
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
}
2026-07-28 06:33:40 +00:00
func ( s * Service ) AnalyzePost ( ctx context . Context , ownerUID int64 , postID string ) ( _ * domain . OwnPost , err error ) {
2026-07-13 01:15:30 +00:00
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 )
}
2026-07-28 06:33:40 +00:00
charge , err := s . billAI ( ctx , ownerUID , "own post structure analyze" , "ownPosts.analyze" )
if err != nil {
2026-07-13 01:15:30 +00:00
return nil , err
}
2026-07-28 06:33:40 +00:00
defer charge . Settle ( ctx , & err )
2026-07-13 01:15:30 +00:00
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
}
2026-07-28 06:33:40 +00:00
func ( s * Service ) GenerateMentionReply ( ctx context . Context , ownerUID int64 , id , personaID string ) ( _ * domain . Mention , err error ) {
2026-07-13 01:15:30 +00:00
m , err := s . getMentionOwned ( ctx , ownerUID , id )
if err != nil {
return nil , err
}
2026-07-28 06:33:40 +00:00
charge , err := s . billAI ( ctx , ownerUID , "mention reply draft" , "mentions.generateReply" )
if err != nil {
2026-07-13 01:15:30 +00:00
return nil , err
}
2026-07-28 06:33:40 +00:00
defer charge . Settle ( ctx , & err )
2026-07-13 01:15:30 +00:00
persona := s . loadPersonaForGen ( ctx , ownerUID , personaID )
2026-07-20 06:33:14 +00:00
fp := personaExpressionFingerprintBlock ( persona )
2026-07-13 01:15:30 +00:00
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
}
2026-07-20 06:33:14 +00:00
func personaExpressionFingerprintBlock ( p * domain . Persona ) string {
if p == nil {
return "(未選人設;依原文內容自然回應,不預設任何開頭或收尾)"
}
draft := p . Style . Draft
var b strings . Builder
write := func ( label , value string ) {
value = strings . TrimSpace ( value )
if value == "" {
return
}
b . WriteString ( label )
b . WriteString ( ": " )
b . WriteString ( value )
b . WriteString ( "\n" )
}
write ( "角色視角" , p . Brief )
write ( "語氣" , draft . Tone )
write ( "慣用語言" , draft . LanguageFingerprint )
write ( "句子節奏" , draft . Rhythm )
write ( "標點習慣" , draft . Punctuation )
write ( "避免方式" , draft . Avoid )
if len ( p . Guard . Avoid ) > 0 {
write ( "禁止" , strings . Join ( p . Guard . Avoid , "、" ) )
}
if len ( p . Style . SamplePreviews ) > 0 {
b . WriteString ( "語感樣本(只觀察語氣與節奏,不複製內容或開頭):\n" )
limit := len ( p . Style . SamplePreviews )
if limit > 3 {
limit = 3
}
for _ , sample := range p . Style . SamplePreviews [ : limit ] {
sample = strings . TrimSpace ( sample )
if sample == "" {
continue
}
if utf8 . RuneCountInString ( sample ) > 120 {
sample = string ( [ ] rune ( sample ) [ : 120 ] ) + "…"
}
b . WriteString ( "- " )
b . WriteString ( sample )
b . WriteString ( "\n" )
}
}
out := strings . TrimSpace ( b . String ( ) )
if out == "" {
return "(人設資料不足;依原文內容自然回應,不預設任何開頭或收尾)"
}
return out
}
2026-07-13 01:15:30 +00:00
func buildOwnPostReplyPrompt ( fp , postText , commentUser , commentText string ) string {
var b strings . Builder
b . WriteString ( ` 你正在扮演 Threads 帳號本人回覆 ( 不是客服 、 不是分析師 ) 。
2026-07-20 06:33:14 +00:00
任務 : 先理解原文和對方真正想表達的內容 , 再用人設的表達軌跡寫一則回覆草稿 。
2026-07-13 01:15:30 +00:00
規則 ( 強制 ) :
- 只輸出回覆正文 , 不要 「 回覆 : 」 前綴 、 不要分析 、 不要 markdown 。
- 繁體中文 ( 台灣用語 ) 、 口語 、 像真人滑手機回 。
2026-07-20 06:33:14 +00:00
- 內容優先 : 回應原文或留言裡至少一個具體資訊 、 情緒或意圖 , 不要只寫泛用套話 。
- 人設只控制用字 、 語氣 、 節奏 、 標點與禁忌 , 不得把指紋當內容模板 。
- 不預設任何開頭 。 不要固定從建議 、 共感 、 感謝 、 稱讚或問句開始 , 依這次內容自然切入 。
- 不必每次提問或邀互動 ; 只有語意真的需要時才問 。
- 不捏造自己沒有根據的經歷 , 也不要重述整篇原文 。
- 長度依內容自然決定 , 通常 15 ~ 140 字 , 可分段空行 。
2026-07-13 01:15:30 +00:00
` )
2026-07-20 06:33:14 +00:00
b . WriteString ( "【人設表達軌跡】\n" )
2026-07-13 01:15:30 +00:00
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 {
2026-07-20 06:33:14 +00:00
b . WriteString ( "\n【任務情境】\n在自己的主貼下自然補充一個相關想法; 不需要刻意邀互動。\n" )
2026-07-13 01:15:30 +00:00
}
return strings . TrimSpace ( b . String ( ) )
}
func buildMentionReplyPrompt ( fp , fromUser , mentionText , contextSnippet string ) string {
return strings . TrimSpace ( fmt . Sprintf ( `
你正在扮演 Threads 帳號本人 , 回覆別人的 「 提及 / @ 」 。
2026-07-20 06:33:14 +00:00
任務 : 先理解對方提及你的具體內容與上下文 , 再用人設的表達軌跡寫一則回覆草稿 。
2026-07-13 01:15:30 +00:00
規則 ( 強制 ) :
- 只輸出回覆正文 , 不要前綴 、 不要分析 。
2026-07-20 06:33:14 +00:00
- 繁體中文 、 口語 , 直接接住對方至少一個具體資訊 、 情緒或意圖 。
- 人設只控制用字 、 語氣 、 節奏 、 標點與禁忌 , 不得把指紋當內容模板 。
- 不預設任何開頭 , 不要固定從建議 、 共感 、 感謝 、 稱讚或問句開始 。
- 不必每次提問或邀互動 ; 不捏造經歷 , 不用與內容無關的泛用套話 。
- 長度依內容自然決定 , 通常 15 ~ 140 字 。
2026-07-13 01:15:30 +00:00
2026-07-20 06:33:14 +00:00
【 人設表達軌跡 】
2026-07-13 01:15:30 +00:00
% 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 ----------
2026-07-28 06:33:40 +00:00
// aiCharge is one reserved AI credit. Quota is reserved up front so an over-limit member is
// stopped before any work starts, but the audit event is only written once the work succeeded —
// the monthly counter is rebuilt from events if it is ever lost, so an event for a call that
// never happened would silently undo the refund.
type aiCharge struct {
svc * Service
ownerUID int64
label string
source string
mode string
settled bool
}
// billAI reserves credit for one AI call. Pair it with `defer charge.Settle(ctx, &err)` on a
// named error return so that every failure path — including ones added later — refunds.
func ( s * Service ) billAI ( ctx context . Context , ownerUID int64 , label , source string ) ( * aiCharge , error ) {
if s == nil || s . Usage == nil {
return & aiCharge { settled : true } , nil
2026-07-13 01:15:30 +00:00
}
mode , err := s . Usage . PrepareCall ( ctx , ownerUID , usageDomain . MeterAICopy )
if err != nil {
2026-07-28 06:33:40 +00:00
return nil , err
}
return & aiCharge { svc : s , ownerUID : ownerUID , label : label , source : source , mode : mode } , nil
}
// Settle commits the charge when the operation succeeded, or refunds it when it failed.
func ( c * aiCharge ) Settle ( ctx context . Context , errp * error ) {
if errp != nil && * errp != nil {
c . Release ( ctx )
return
}
c . Commit ( ctx )
}
// Commit writes the audit event. A failure here is logged rather than surfaced: the member
// already received the AI result, so charging them is correct and losing the event must not
// turn a successful call into an error.
func ( c * aiCharge ) Commit ( ctx context . Context ) {
if c == nil || c . settled {
return
}
c . settled = true
if _ , err := c . svc . Usage . RecordCall ( ctx , c . ownerUID , usageDomain . MeterAICopy , c . mode , c . label , c . source ) ; err != nil {
logx . Errorf ( "usage record uid=%d source=%s mode=%s: %v" , c . ownerUID , c . source , c . mode , err )
}
}
// Release refunds a charge that was never committed. Safe to call more than once.
func ( c * aiCharge ) Release ( ctx context . Context ) {
if c == nil || c . settled {
return
}
c . settled = true
// The failure being compensated for is often a cancelled request, so the refund needs a
// context that outlives it.
rctx , cancel := context . WithTimeout ( context . WithoutCancel ( ctx ) , 5 * time . Second )
defer cancel ( )
if err := c . svc . Usage . ReleaseCall ( rctx , c . ownerUID , usageDomain . MeterAICopy , c . mode ) ; err != nil {
logx . Errorf ( "usage release uid=%d source=%s mode=%s: %v" , c . ownerUID , c . source , c . mode , err )
2026-07-13 01:15:30 +00:00
}
}
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 ] ) + "…"
}