thread-master/backend/internal/model/threads_account/usecase/api_playground.go

264 lines
7.3 KiB
Go
Raw Normal View History

2026-06-30 07:09:44 +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)
}
2026-07-01 08:42:51 +00:00
case "media_replies", "media_conversation":
2026-06-30 07:09:44 +00:00
mid := strings.TrimSpace(req.MediaID)
if mid == "" {
runErr = fmt.Errorf("media_id 為必填")
} else {
2026-07-01 08:42:51 +00:00
if limit < 30 {
limit = 30
}
payload, runErr = client.MediaConversation(ctx, mid, limit)
2026-06-30 07:09:44 +00:00
}
case "media_stats":
mid := strings.TrimSpace(req.MediaID)
if mid == "" {
runErr = fmt.Errorf("media_id 為必填")
} else {
payload, runErr = client.GetMediaStats(ctx, mid)
}
2026-07-01 08:42:51 +00:00
case "media_detail":
mid := strings.TrimSpace(req.MediaID)
if mid == "" {
runErr = fmt.Errorf("media_id 為必填")
} else {
payload, runErr = client.GetMediaDetail(ctx, mid)
}
2026-06-30 07:09:44 +00:00
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)
if userID == "" || text == "" {
runErr = fmt.Errorf("threads_user_id 與 text 為必填")
} else {
payload, runErr = libthreads.PublishText(ctx, libthreads.PublishTextInput{
ThreadsUserID: userID, AccessToken: token, Text: text,
})
}
2026-07-01 08:42:51 +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}
}
}
2026-06-30 07:09:44 +00:00
case "publish_reply":
text := strings.TrimSpace(req.Text)
replyTo := strings.TrimSpace(req.ReplyToID)
if userID == "" || text == "" || replyTo == "" {
runErr = fmt.Errorf("threads_user_id、reply_to_id、text 為必填")
} else {
2026-07-01 08:42:51 +00:00
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,
})
}
2026-06-30 07:09:44 +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
}