2026-06-27 16:03:28 +00:00
|
|
|
|
package threadsapi
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/url"
|
2026-07-06 06:13:14 +00:00
|
|
|
|
"strings"
|
2026-06-27 16:03:28 +00:00
|
|
|
|
"time"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-06 06:13:14 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-27 16:03:28 +00:00
|
|
|
|
type MediaStats struct {
|
|
|
|
|
|
LikeCount int `json:"like_count"`
|
|
|
|
|
|
ReplyCount int `json:"reply_count"`
|
|
|
|
|
|
RepostCount int `json:"repost_count"`
|
|
|
|
|
|
QuoteCount int `json:"quote_count"`
|
2026-07-06 06:13:14 +00:00
|
|
|
|
Views int `json:"views,omitempty"`
|
|
|
|
|
|
Shares int `json:"shares,omitempty"`
|
2026-06-27 16:03:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 06:13:14 +00:00
|
|
|
|
// 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).
|
2026-06-27 16:03:28 +00:00
|
|
|
|
func GetMediaStats(ctx context.Context, accessToken, mediaID string) (*MediaStats, error) {
|
2026-07-06 06:13:14 +00:00
|
|
|
|
return NewClient(accessToken).GetMediaStats(ctx, mediaID)
|
|
|
|
|
|
}
|
2026-06-27 16:03:28 +00:00
|
|
|
|
|
2026-07-06 06:13:14 +00:00
|
|
|
|
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"})
|
2026-06-27 16:03:28 +00:00
|
|
|
|
if err != nil {
|
2026-07-06 06:13:14 +00:00
|
|
|
|
if IsPermissionError(err) {
|
|
|
|
|
|
return nil, fmt.Errorf("%w(貼文互動數需 threads_manage_insights,請確認 OAuth 已授權)", err)
|
|
|
|
|
|
}
|
2026-06-27 16:03:28 +00:00
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
2026-07-06 06:13:14 +00:00
|
|
|
|
return mapInsightMetrics(metrics), nil
|
|
|
|
|
|
}
|
2026-06-27 16:03:28 +00:00
|
|
|
|
|
2026-07-06 06:13:14 +00:00
|
|
|
|
func mapInsightMetrics(metrics map[string]int) *MediaStats {
|
|
|
|
|
|
if metrics == nil {
|
|
|
|
|
|
metrics = map[string]int{}
|
2026-06-27 16:03:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
return &MediaStats{
|
2026-07-06 06:13:14 +00:00
|
|
|
|
LikeCount: metrics["likes"],
|
|
|
|
|
|
ReplyCount: metrics["replies"],
|
|
|
|
|
|
RepostCount: metrics["reposts"],
|
|
|
|
|
|
QuoteCount: metrics["quotes"],
|
|
|
|
|
|
Views: metrics["views"],
|
|
|
|
|
|
Shares: metrics["shares"],
|
|
|
|
|
|
}
|
2026-06-27 16:03:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|