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