package usecase import ( "context" "encoding/json" "fmt" "strings" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" libthreads "haixun-backend/internal/library/threadsapi" domusecase "haixun-backend/internal/model/threads_account/domain/usecase" ) func (u *threadsAccountUseCase) RunAPIPlayground( ctx context.Context, tenantID, ownerUID, accountID string, req domusecase.APIPlaygroundRequest, ) (*domusecase.APIPlaygroundResult, error) { account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } client := libthreads.NewClient(token) if !client.Enabled() { return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線") } action := strings.TrimSpace(strings.ToLower(req.Action)) if action == "" { return nil, app.For(code.ThreadsAccount).InputMissingRequired("action 為必填") } userID := strings.TrimSpace(account.ThreadsUserID) if userID == "" { if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil { userID = profile.ID.String() } } limit := req.Limit if limit <= 0 { limit = 10 } if limit > 50 { limit = 50 } result := &domusecase.APIPlaygroundResult{Action: action} var payload any var runErr error switch action { case "me": payload, runErr = libthreads.GetMeProfile(ctx, token) case "user_threads": if userID == "" { runErr = fmt.Errorf("缺少 threads_user_id") } else { payload, runErr = client.UserThreads(ctx, userID, limit) } case "keyword_search": q := strings.TrimSpace(req.Query) if q == "" { runErr = fmt.Errorf("query 為必填") } else { searchType := strings.TrimSpace(req.SearchType) if searchType == "" { searchType = "TOP" } payload, runErr = client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{ Query: q, Limit: limit, SearchType: searchType, }) } case "profile_lookup": u := strings.TrimPrefix(strings.TrimSpace(req.Username), "@") if u == "" { runErr = fmt.Errorf("username 為必填") } else { payload, runErr = client.ProfileLookup(ctx, u) } case "mentions": if userID == "" { runErr = fmt.Errorf("缺少 threads_user_id") } else { payload, runErr = client.UserMentions(ctx, userID, limit) } case "insights": if userID == "" { runErr = fmt.Errorf("缺少 threads_user_id") } else { payload, runErr = client.UserInsights(ctx, userID, nil) } case "location_search": q := strings.TrimSpace(req.LocationQuery) if q == "" { q = strings.TrimSpace(req.Query) } if q == "" { runErr = fmt.Errorf("location 查詢字串為必填") } else { payload, runErr = client.LocationSearch(ctx, q, limit) } case "media_replies", "media_conversation": mid := strings.TrimSpace(req.MediaID) if mid == "" { runErr = fmt.Errorf("media_id 為必填") } else { if limit < 30 { limit = 30 } payload, runErr = client.MediaConversation(ctx, mid, limit) } case "media_stats": mid := strings.TrimSpace(req.MediaID) if mid == "" { runErr = fmt.Errorf("media_id 為必填") } else { payload, runErr = client.GetMediaStats(ctx, mid) } case "media_detail": mid := strings.TrimSpace(req.MediaID) if mid == "" { runErr = fmt.Errorf("media_id 為必填") } else { payload, runErr = client.GetMediaDetail(ctx, mid) } case "media_insights": mid := strings.TrimSpace(req.MediaID) if mid == "" { runErr = fmt.Errorf("media_id 為必填") } else { payload, runErr = client.MediaInsights(ctx, mid, nil) } case "publish_text": text := strings.TrimSpace(req.Text) imageURLs := req.ImageURLs if userID == "" || (text == "" && len(imageURLs) == 0) { runErr = fmt.Errorf("threads_user_id 與 text 或 image 為必填") } else { payload, runErr = libthreads.PublishText(ctx, libthreads.PublishTextInput{ ThreadsUserID: userID, AccessToken: token, Text: text, ImageURLs: imageURLs, }) } case "prime_reply_target": replyTo := strings.TrimSpace(req.ReplyToID) permalink := strings.TrimSpace(req.Permalink) if replyTo == "" || permalink == "" { runErr = fmt.Errorf("reply_to_id 與 permalink 為必填") } else { hint := strings.TrimSpace(req.HintText) if hint == "" { hint = strings.TrimSpace(req.Query) } if primeErr := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{ ExternalID: replyTo, Permalink: permalink, HintText: hint, }, replyTo); primeErr != nil { runErr = primeErr } else { payload = map[string]string{"primed": replyTo} } } case "publish_reply": text := strings.TrimSpace(req.Text) imageURLs := req.ImageURLs replyTo := strings.TrimSpace(req.ReplyToID) if userID == "" || replyTo == "" || (text == "" && len(imageURLs) == 0) { runErr = fmt.Errorf("threads_user_id、reply_to_id、text 或 image 為必填") } else { permalink := strings.TrimSpace(req.Permalink) if permalink != "" && !req.SkipPrime { hint := strings.TrimSpace(req.HintText) if hint == "" { hint = strings.TrimSpace(req.Query) } if primeErr := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{ ExternalID: replyTo, Permalink: permalink, HintText: hint, }, replyTo); primeErr != nil { runErr = fmt.Errorf("回覆他人貼文前需先透過 keyword_search 註冊目標:%w", primeErr) } } if runErr == nil { payload, runErr = libthreads.PublishReply(ctx, libthreads.PublishReplyInput{ ThreadsUserID: userID, AccessToken: token, ReplyToID: replyTo, Text: text, ImageURLs: imageURLs, }) } } case "delete_media": mid := strings.TrimSpace(req.MediaID) if mid == "" { runErr = fmt.Errorf("media_id 為必填") } else { runErr = client.DeleteMedia(ctx, mid) if runErr == nil { payload = map[string]string{"deleted": mid} } } case "hide_reply": rid := strings.TrimSpace(req.ReplyID) if rid == "" { runErr = fmt.Errorf("reply_id 為必填") } else { runErr = client.HideReply(ctx, rid, req.Hide) if runErr == nil { payload = map[string]any{"reply_id": rid, "hide": req.Hide} } } case "share_to_instagram": mid := strings.TrimSpace(req.MediaID) if mid == "" { runErr = fmt.Errorf("media_id 為必填") } else { runErr = client.ShareToInstagram(ctx, mid) if runErr == nil { payload = map[string]string{"crossposted": mid} } } case "refresh_token": refreshed, rerr := libthreads.RefreshAccessToken(ctx, token) if rerr != nil { runErr = rerr } else { expiresAt := int64(0) if refreshed.ExpiresIn >= 300 { expiresAt = libthreads.NsFromNow(refreshed.ExpiresIn) } if _, serr := u.secretsRepo.SaveAPIAccessToken(ctx, account.ID, refreshed.AccessToken, expiresAt); serr != nil { runErr = serr } else { payload = map[string]any{ "expires_in": refreshed.ExpiresIn, "saved": true, } } } default: return nil, app.For(code.ThreadsAccount).InputInvalidFormat("不支援的 action: " + action) } if runErr != nil { result.Ok = false result.Message = runErr.Error() if libthreads.IsPermissionError(runErr) { result.Message += "(請確認 Meta App 已授權此 scope,且帳號在測試人員名單)" } return result, nil } raw, err := json.Marshal(payload) if err != nil { return nil, err } result.Ok = true result.Message = "ok" result.Data = raw return result, nil }