2026-07-06 06:13:14 +00:00
|
|
|
|
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)
|
2026-07-09 03:45:28 +00:00
|
|
|
|
imageURLs := req.ImageURLs
|
|
|
|
|
|
if userID == "" || (text == "" && len(imageURLs) == 0) {
|
|
|
|
|
|
runErr = fmt.Errorf("threads_user_id 與 text 或 image 為必填")
|
2026-07-06 06:13:14 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
payload, runErr = libthreads.PublishText(ctx, libthreads.PublishTextInput{
|
2026-07-09 03:45:28 +00:00
|
|
|
|
ThreadsUserID: userID, AccessToken: token, Text: text, ImageURLs: imageURLs,
|
2026-07-06 06:13:14 +00:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
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)
|
2026-07-09 03:45:28 +00:00
|
|
|
|
imageURLs := req.ImageURLs
|
2026-07-06 06:13:14 +00:00
|
|
|
|
replyTo := strings.TrimSpace(req.ReplyToID)
|
2026-07-09 03:45:28 +00:00
|
|
|
|
if userID == "" || replyTo == "" || (text == "" && len(imageURLs) == 0) {
|
|
|
|
|
|
runErr = fmt.Errorf("threads_user_id、reply_to_id、text 或 image 為必填")
|
2026-07-06 06:13:14 +00:00
|
|
|
|
} 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{
|
2026-07-09 03:45:28 +00:00
|
|
|
|
ThreadsUserID: userID, AccessToken: token, ReplyToID: replyTo, Text: text, ImageURLs: imageURLs,
|
2026-07-06 06:13:14 +00:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|