package threadsapi import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" ) type MediaDetail 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"` } func (c *Client) GetMediaDetail(ctx context.Context, mediaID string) (*MediaDetail, error) { if !c.Enabled() { return nil, fmt.Errorf("threads api access token is required") } mediaID = strings.TrimSpace(mediaID) if mediaID == "" { return nil, fmt.Errorf("threads media id is required") } params := url.Values{} params.Set("access_token", c.accessToken) params.Set("fields", "id,text,username,permalink,timestamp,media_type,is_reply") endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "?" + params.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } body, status, err := doRequest(req) if err != nil { return nil, err } if status != http.StatusOK { return nil, parseAPIError(body, status) } var out MediaDetail if err := json.Unmarshal(body, &out); err != nil { return nil, fmt.Errorf("parse media detail: %w", err) } out.ID = strings.TrimSpace(out.ID) if out.ID == "" { return nil, fmt.Errorf("threads media detail missing id") } return &out, nil } type MediaStats struct { LikeCount int `json:"like_count"` ReplyCount int `json:"reply_count"` RepostCount int `json:"repost_count"` QuoteCount int `json:"quote_count"` Views int `json:"views,omitempty"` Shares int `json:"shares,omitempty"` } // GetMediaStats loads engagement counts via GET /{media-id}/insights. // Meta does not expose like_count on the media object itself (requesting those fields returns API errors). func GetMediaStats(ctx context.Context, accessToken, mediaID string) (*MediaStats, error) { return NewClient(accessToken).GetMediaStats(ctx, mediaID) } func (c *Client) GetMediaStats(ctx context.Context, mediaID string) (*MediaStats, error) { if !c.Enabled() { return nil, fmt.Errorf("threads api access token is required") } mediaID = strings.TrimSpace(mediaID) if mediaID == "" { return nil, fmt.Errorf("threads media id is required") } metrics, err := c.MediaInsights(ctx, mediaID, []string{"likes", "replies", "reposts", "quotes", "views", "shares"}) if err != nil { if IsPermissionError(err) { return nil, fmt.Errorf("%w(貼文互動數需 threads_manage_insights,請確認 OAuth 已授權)", err) } return nil, err } return mapInsightMetrics(metrics), nil } func mapInsightMetrics(metrics map[string]int) *MediaStats { if metrics == nil { metrics = map[string]int{} } return &MediaStats{ LikeCount: metrics["likes"], ReplyCount: metrics["replies"], RepostCount: metrics["reposts"], QuoteCount: metrics["quotes"], Views: metrics["views"], Shares: metrics["shares"], } } type TokenRefreshResult struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } func RefreshAccessToken(ctx context.Context, currentToken string) (*TokenRefreshResult, error) { u := fmt.Sprintf("%s/refresh_access_token", graphBaseURL) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } q := url.Values{} q.Set("grant_type", "th_refresh_token") q.Set("access_token", currentToken) req.URL.RawQuery = q.Encode() resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("threads api token refresh error: %s", resp.Status) } var result TokenRefreshResult if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, err } if result.AccessToken == "" { return nil, fmt.Errorf("threads api token refresh returned empty token") } return &result, nil } func NsFromNow(seconds int) int64 { return time.Now().UnixNano() + int64(seconds)*1e9 }