thread-master/apps/backend/internal/module/threads/provider/media.go

629 lines
18 KiB
Go
Raw Permalink Normal View History

2026-07-13 01:15:30 +00:00
package provider
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// RemoteThread — Graph Threads 列表/單則媒體
type RemoteThread struct {
ID string
Text string
MediaType string
MediaURL string
ThumbnailURL string
Permalink string
Shortcode string
TopicTag string
Username string
Timestamp time.Time
2026-08-18 16:26:10 +00:00
ReplyControl string
2026-07-13 01:15:30 +00:00
}
// RemoteInsights — media insights
type RemoteInsights struct {
Views int
Likes int
Replies int
Reposts int
Quotes int
Shares int
Status string // ok | partial | error | skipped
ErrorMsg string
}
// RemoteReply — conversation / replies under a media
type RemoteReply struct {
ID string
Text string
Username string
Timestamp time.Time
IsMine bool
ParentMediaID string
LikeCount int
2026-08-18 16:26:10 +00:00
HideStatus string
2026-07-13 01:15:30 +00:00
}
// RemoteMention — GET /{user-id}/mentionsthreads_manage_mentions
type RemoteMention struct {
ID string
Text string
Username string
Permalink string
MediaType string
Timestamp time.Time
IsReply bool
IsQuotePost bool
HasReplies bool
RootPostID string
ParentID string
}
// MediaClient lists own threads + insights + conversation (Meta Graph).
type MediaClient interface {
ListThreads(ctx context.Context, accessToken string, limit int) ([]RemoteThread, error)
GetInsights(ctx context.Context, accessToken, mediaID string) (RemoteInsights, error)
ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]RemoteReply, error)
ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]RemoteMention, error)
// ListProfilePosts — 公開帳號貼文threads_profile_discovery / profile_posts
ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]RemoteThread, error)
}
2026-08-18 16:26:10 +00:00
const threadsListFields = "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post,reply_control"
const threadsListFieldsLegacy = "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post"
2026-07-13 01:15:30 +00:00
// ListThreads GET /v1.0/me/threads
func (m *MetaProvider) ListThreads(ctx context.Context, accessToken string, limit int) ([]RemoteThread, error) {
2026-08-18 16:26:10 +00:00
list, err := m.listThreads(ctx, accessToken, limit, threadsListFields)
if err != nil && strings.Contains(strings.ToLower(err.Error()), "reply_control") {
return m.listThreads(ctx, accessToken, limit, threadsListFieldsLegacy)
}
return list, err
}
func (m *MetaProvider) listThreads(ctx context.Context, accessToken string, limit int, fields string) ([]RemoteThread, error) {
2026-07-13 01:15:30 +00:00
if limit <= 0 {
limit = 25
}
if limit > 50 {
limit = 50
}
q := url.Values{}
q.Set("fields", fields)
q.Set("limit", strconv.Itoa(limit))
q.Set("access_token", accessToken)
u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/me/threads?" + q.Encode()
body, err := m.getJSON(ctx, u)
if err != nil {
return nil, err
}
var resp struct {
Data []struct {
ID string `json:"id"`
MediaType string `json:"media_type"`
MediaURL string `json:"media_url"`
Permalink string `json:"permalink"`
Username string `json:"username"`
Text string `json:"text"`
TopicTag string `json:"topic_tag"`
Timestamp string `json:"timestamp"`
Shortcode string `json:"shortcode"`
ThumbnailURL string `json:"thumbnail_url"`
2026-08-18 16:26:10 +00:00
ReplyControl string `json:"reply_control"`
2026-07-13 01:15:30 +00:00
} `json:"data"`
Error *graphErr `json:"error"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("threads list parse: %w", err)
}
if resp.Error != nil {
return nil, resp.Error
}
out := make([]RemoteThread, 0, len(resp.Data))
for _, d := range resp.Data {
out = append(out, RemoteThread{
ID: d.ID, Text: d.Text, MediaType: d.MediaType, MediaURL: d.MediaURL,
ThumbnailURL: d.ThumbnailURL, Permalink: d.Permalink, Shortcode: d.Shortcode,
TopicTag: d.TopicTag, Username: d.Username, Timestamp: parseThreadsTime(d.Timestamp),
2026-08-18 16:26:10 +00:00
ReplyControl: NormalizeReplyControl(d.ReplyControl),
2026-07-13 01:15:30 +00:00
})
}
return out, nil
}
// GetInsights GET /v1.0/{media-id}/insights
func (m *MetaProvider) GetInsights(ctx context.Context, accessToken, mediaID string) (RemoteInsights, error) {
ins := RemoteInsights{Status: "ok"}
if mediaID == "" {
ins.Status = "skipped"
return ins, nil
}
q := url.Values{}
q.Set("metric", "views,likes,replies,reposts,quotes,shares")
q.Set("access_token", accessToken)
u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(mediaID) + "/insights?" + q.Encode()
body, err := m.getJSON(ctx, u)
if err != nil {
ins.Status = "error"
ins.ErrorMsg = err.Error()
return ins, nil // 不整批失敗
}
var resp struct {
Data []struct {
Name string `json:"name"`
Values []struct {
Value json.Number `json:"value"`
} `json:"values"`
TotalValue *struct {
Value json.Number `json:"value"`
} `json:"total_value"`
} `json:"data"`
Error *graphErr `json:"error"`
}
if err := json.Unmarshal(body, &resp); err != nil {
ins.Status = "error"
ins.ErrorMsg = err.Error()
return ins, nil
}
if resp.Error != nil {
ins.Status = "error"
ins.ErrorMsg = resp.Error.Error()
return ins, nil
}
for _, d := range resp.Data {
v := 0
if d.TotalValue != nil {
v, _ = strconv.Atoi(string(d.TotalValue.Value))
} else if len(d.Values) > 0 {
v, _ = strconv.Atoi(string(d.Values[0].Value))
}
switch d.Name {
case "views":
ins.Views = v
case "likes":
ins.Likes = v
case "replies":
ins.Replies = v
case "reposts":
ins.Reposts = v
case "quotes":
ins.Quotes = v
case "shares":
ins.Shares = v
}
}
return ins, nil
}
// ListConversation 先試 /replies第一層留言再試 /conversation含巢狀
// 失敗不整批炸掉,回 empty + 可診斷的 error由上層 log
func (m *MetaProvider) ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]RemoteReply, error) {
if mediaID == "" {
return nil, nil
}
if limit <= 0 {
limit = 30
}
if limit > 50 {
limit = 50
}
// 官方 example 欄位 + username / is_reply_owned_by_me文件有列
fields := "id,text,username,timestamp,media_type,has_replies,root_post,replied_to,is_reply,is_reply_owned_by_me,hide_status"
// 1) top-level replies
replies, err1 := m.fetchReplyEdge(ctx, accessToken, mediaID, "replies", fields, limit)
if len(replies) > 0 {
return normalizeParents(replies, mediaID), nil
}
// 2) full conversation (nested)
convo, err2 := m.fetchReplyEdge(ctx, accessToken, mediaID, "conversation", fields, limit)
if len(convo) > 0 {
return normalizeParents(convo, mediaID), nil
}
// 兩者都空:若有錯誤回傳最後一個,方便 log
if err1 != nil {
return nil, err1
}
if err2 != nil {
return nil, err2
}
return nil, nil
}
func (m *MetaProvider) fetchReplyEdge(ctx context.Context, accessToken, mediaID, edge, fields string, limit int) ([]RemoteReply, error) {
q := url.Values{}
q.Set("fields", fields)
q.Set("limit", strconv.Itoa(limit))
q.Set("reverse", "false")
q.Set("access_token", accessToken)
u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(mediaID) + "/" + edge + "?" + q.Encode()
body, err := m.getJSON(ctx, u)
if err != nil {
return nil, err
}
var resp struct {
Data []struct {
ID string `json:"id"`
Text string `json:"text"`
Username string `json:"username"`
Timestamp string `json:"timestamp"`
IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"`
IsReply bool `json:"is_reply"`
HideStatus string `json:"hide_status"`
RepliedTo *struct {
ID string `json:"id"`
} `json:"replied_to"`
RootPost *struct {
ID string `json:"id"`
} `json:"root_post"`
} `json:"data"`
Error *graphErr `json:"error"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("threads %s parse: %w", edge, err)
}
if resp.Error != nil {
return nil, resp.Error
}
out := make([]RemoteReply, 0, len(resp.Data))
for _, d := range resp.Data {
2026-08-18 16:26:10 +00:00
if strings.TrimSpace(d.ID) == "" || d.ID == mediaID {
2026-07-13 01:15:30 +00:00
continue
}
parent := ""
if d.RepliedTo != nil {
parent = d.RepliedTo.ID
}
out = append(out, RemoteReply{
ID: d.ID, Text: d.Text, Username: d.Username,
Timestamp: parseThreadsTime(d.Timestamp), IsMine: d.IsReplyOwnedByMe,
2026-08-18 16:26:10 +00:00
ParentMediaID: parent, HideStatus: strings.TrimSpace(d.HideStatus),
2026-07-13 01:15:30 +00:00
})
}
return out, nil
}
2026-08-18 16:26:10 +00:00
// normalizeParents — 回覆根貼的 replied_to = root media id對 UI 應視為第一層parent 空)。
// conversation 有時會把主貼自己也列進來,隱藏時不能拿主貼 id 打 /manage_reply。
2026-07-13 01:15:30 +00:00
func normalizeParents(list []RemoteReply, rootMediaID string) []RemoteReply {
2026-08-18 16:26:10 +00:00
out := make([]RemoteReply, 0, len(list))
for _, r := range list {
if r.ID == "" || r.ID == rootMediaID {
continue
}
if r.ParentMediaID == rootMediaID {
r.ParentMediaID = ""
2026-07-13 01:15:30 +00:00
}
2026-08-18 16:26:10 +00:00
out = append(out, r)
2026-07-13 01:15:30 +00:00
}
2026-08-18 16:26:10 +00:00
return out
2026-07-13 01:15:30 +00:00
}
type graphErr struct {
Message string `json:"message"`
Type string `json:"type"`
Code int `json:"code"`
}
func (e *graphErr) Error() string {
if e == nil {
return "graph error"
}
return fmt.Sprintf("threads graph: %s (code %d)", e.Message, e.Code)
}
func (m *MetaProvider) getJSON(ctx context.Context, fullURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return nil, err
}
cli := m.HTTPClient
if cli == nil {
cli = &http.Client{Timeout: 25 * time.Second}
}
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
2026-08-18 16:26:10 +00:00
return nil, err
}
if resp.StatusCode >= 300 {
var ge struct {
Error *graphErr `json:"error"`
}
_ = json.Unmarshal(body, &ge)
if ge.Error != nil {
return nil, ge.Error
}
return nil, fmt.Errorf("threads graph HTTP %d: %s", resp.StatusCode, truncateBody(string(body), 200))
}
return body, nil
}
// ReplyControlEveryone is Threads' default audience.
const ReplyControlEveryone = "everyone"
// ValidReplyControls are the official Threads publishing reply_control values.
var ValidReplyControls = map[string]struct{}{
"everyone": {},
"accounts_you_follow": {},
"mentioned_only": {},
"parent_post_author_only": {},
"followers_only": {},
}
func NormalizeReplyControl(raw string) string {
s := strings.ToLower(strings.TrimSpace(raw))
if s == "" {
return ""
}
if _, ok := ValidReplyControls[s]; ok {
return s
}
return ""
}
// ManageReply POST /{reply-id}/manage_reply hide=true|false
func (m *MetaProvider) ManageReply(ctx context.Context, accessToken, replyID string, hide bool) error {
replyID = strings.TrimSpace(replyID)
if replyID == "" {
return fmt.Errorf("reply id is required")
}
form := url.Values{}
if hide {
form.Set("hide", "true")
} else {
form.Set("hide", "false")
}
form.Set("access_token", accessToken)
u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(replyID) + "/manage_reply"
body, err := m.postForm(ctx, u, form)
if err != nil {
return err
}
var resp struct {
Success bool `json:"success"`
Error *graphErr `json:"error"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("threads manage_reply parse: %w", err)
}
if resp.Error != nil {
return resp.Error
}
if !resp.Success {
return fmt.Errorf("threads manage_reply did not succeed")
}
return nil
}
// SetReplyControl POST /{media-id} reply_control=…
// Official docs set this at publish time; Graph also accepts it on the published media id.
func (m *MetaProvider) SetReplyControl(ctx context.Context, accessToken, mediaID, control string) error {
mediaID = strings.TrimSpace(mediaID)
control = NormalizeReplyControl(control)
if mediaID == "" {
return fmt.Errorf("media id is required")
}
if control == "" {
return fmt.Errorf("invalid reply_control")
}
form := url.Values{}
form.Set("reply_control", control)
form.Set("access_token", accessToken)
u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(mediaID)
body, err := m.postForm(ctx, u, form)
if err != nil {
return err
}
var resp struct {
Success bool `json:"success"`
ID string `json:"id"`
Error *graphErr `json:"error"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("threads reply_control parse: %w", err)
}
if resp.Error != nil {
return resp.Error
}
return nil
}
func (m *MetaProvider) postForm(ctx context.Context, fullURL string, form url.Values) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
cli := m.HTTPClient
if cli == nil {
cli = &http.Client{Timeout: 25 * time.Second}
}
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
2026-07-13 01:15:30 +00:00
return nil, err
}
if resp.StatusCode >= 300 {
var ge struct {
Error *graphErr `json:"error"`
}
_ = json.Unmarshal(body, &ge)
if ge.Error != nil {
return nil, ge.Error
}
return nil, fmt.Errorf("threads graph HTTP %d: %s", resp.StatusCode, truncateBody(string(body), 200))
}
return body, nil
}
func parseThreadsTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
// 2023-10-17T05:42:03+0000
layouts := []string{
"2006-01-02T15:04:05-0700",
"2006-01-02T15:04:05+0000",
time.RFC3339,
}
for _, l := range layouts {
if t, err := time.Parse(l, s); err == nil {
return t.UTC()
}
}
// try fix +0000 → Z
if strings.HasSuffix(s, "+0000") {
if t, err := time.Parse(time.RFC3339, strings.TrimSuffix(s, "+0000")+"Z"); err == nil {
return t.UTC()
}
}
return time.Time{}
}
// ListProfilePosts GET /v1.0/profile_posts?username=… — 公開個人頁貼文threads_profile_discovery
// 用「已連帳」的 access token 即可讀其他公開帳號的貼文,不需 Chrome 爬蟲。
func (m *MetaProvider) ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]RemoteThread, error) {
username = strings.TrimPrefix(strings.TrimSpace(username), "@")
if username == "" {
return nil, fmt.Errorf("username is required")
}
if limit <= 0 {
limit = 12
}
if limit > 50 {
limit = 50
}
fields := "id,media_type,media_url,permalink,username,text,topic_tag,timestamp,shortcode,thumbnail_url,is_quote_post"
q := url.Values{}
q.Set("username", username)
q.Set("fields", fields)
q.Set("limit", strconv.Itoa(limit))
q.Set("access_token", accessToken)
u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/profile_posts?" + q.Encode()
body, err := m.getJSON(ctx, u)
if err != nil {
return nil, err
}
var resp struct {
Data []struct {
ID string `json:"id"`
MediaType string `json:"media_type"`
MediaURL string `json:"media_url"`
Permalink string `json:"permalink"`
Username string `json:"username"`
Text string `json:"text"`
TopicTag string `json:"topic_tag"`
Timestamp string `json:"timestamp"`
Shortcode string `json:"shortcode"`
ThumbnailURL string `json:"thumbnail_url"`
} `json:"data"`
Error *graphErr `json:"error"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("profile_posts parse: %w", err)
}
if resp.Error != nil {
return nil, resp.Error
}
out := make([]RemoteThread, 0, len(resp.Data))
for _, d := range resp.Data {
if strings.TrimSpace(d.ID) == "" && strings.TrimSpace(d.Text) == "" {
continue
}
out = append(out, RemoteThread{
ID: d.ID, Text: d.Text, MediaType: d.MediaType, MediaURL: d.MediaURL,
ThumbnailURL: d.ThumbnailURL, Permalink: d.Permalink, Shortcode: d.Shortcode,
TopicTag: d.TopicTag, Username: d.Username, Timestamp: parseThreadsTime(d.Timestamp),
})
}
return out, nil
}
// ListMentions GET /v1.0/{user-id|me}/mentions — 別人 @ 你的貼文/回覆/引用
func (m *MetaProvider) ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]RemoteMention, error) {
if limit <= 0 {
limit = 25
}
if limit > 100 {
limit = 100
}
uid := strings.TrimSpace(threadsUserID)
if uid == "" {
uid = "me"
}
fields := "id,text,username,permalink,timestamp,media_type,is_reply,is_quote_post,has_replies,root_post,parent_id"
q := url.Values{}
q.Set("fields", fields)
q.Set("limit", strconv.Itoa(limit))
q.Set("access_token", accessToken)
u := strings.TrimRight(m.GraphBase, "/") + "/v1.0/" + url.PathEscape(uid) + "/mentions?" + q.Encode()
body, err := m.getJSON(ctx, u)
if err != nil {
return nil, err
}
var resp struct {
Data []struct {
ID string `json:"id"`
Text string `json:"text"`
Username string `json:"username"`
Permalink string `json:"permalink"`
Timestamp string `json:"timestamp"`
MediaType string `json:"media_type"`
IsReply bool `json:"is_reply"`
IsQuotePost bool `json:"is_quote_post"`
HasReplies bool `json:"has_replies"`
ParentID string `json:"parent_id"`
RootPost *struct {
ID string `json:"id"`
} `json:"root_post"`
} `json:"data"`
Error *graphErr `json:"error"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("mentions parse: %w", err)
}
if resp.Error != nil {
return nil, resp.Error
}
out := make([]RemoteMention, 0, len(resp.Data))
for _, d := range resp.Data {
if strings.TrimSpace(d.ID) == "" {
continue
}
rootID := ""
if d.RootPost != nil {
rootID = strings.TrimSpace(d.RootPost.ID)
}
out = append(out, RemoteMention{
ID: d.ID, Text: d.Text, Username: d.Username, Permalink: d.Permalink,
MediaType: d.MediaType, Timestamp: parseThreadsTime(d.Timestamp),
IsReply: d.IsReply, IsQuotePost: d.IsQuotePost, HasReplies: d.HasReplies,
RootPostID: rootID, ParentID: strings.TrimSpace(d.ParentID),
})
}
return out, nil
}
// Ensure MetaProvider implements MediaClient.
var _ MediaClient = (*MetaProvider)(nil)