523 lines
16 KiB
Go
523 lines
16 KiB
Go
package threadsapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
// userThreadsListFields lists fields supported on GET /{user-id}/threads.
|
|
userThreadsListFields = "id,text,permalink,timestamp,media_type,media_url,thumbnail_url,topic_tag,shortcode"
|
|
)
|
|
|
|
type MediaItem struct {
|
|
ID string `json:"id"`
|
|
Shortcode string `json:"shortcode"`
|
|
Text string `json:"text"`
|
|
Permalink string `json:"permalink"`
|
|
Timestamp string `json:"timestamp"`
|
|
MediaType string `json:"media_type"`
|
|
MediaURL string `json:"media_url"`
|
|
ThumbnailURL string `json:"thumbnail_url"`
|
|
TopicTag string `json:"topic_tag"`
|
|
LikeCount int `json:"like_count"`
|
|
ReplyCount int `json:"reply_count"`
|
|
}
|
|
|
|
type ProfileLookupResult struct {
|
|
Username string `json:"username"`
|
|
Name string `json:"name"`
|
|
ProfilePictureURL string `json:"profile_picture_url,omitempty"`
|
|
Biography string `json:"biography,omitempty"`
|
|
FollowerCount int `json:"follower_count,omitempty"`
|
|
LikesCount int `json:"likes_count,omitempty"`
|
|
QuotesCount int `json:"quotes_count,omitempty"`
|
|
RepostsCount int `json:"reposts_count,omitempty"`
|
|
ViewsCount int `json:"views_count,omitempty"`
|
|
IsVerified bool `json:"is_verified,omitempty"`
|
|
}
|
|
|
|
type MentionItem 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"`
|
|
RootPostID string `json:"root_post_id,omitempty"`
|
|
ParentID string `json:"parent_id,omitempty"`
|
|
}
|
|
|
|
type LocationItem struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type InsightValue struct {
|
|
Name string `json:"name"`
|
|
Values []struct {
|
|
Value int `json:"value"`
|
|
} `json:"values"`
|
|
}
|
|
|
|
// UserThreads lists the authenticated user's own posts (threads_basic).
|
|
func (c *Client) UserThreads(ctx context.Context, userID string, limit int) ([]MediaItem, error) {
|
|
if !c.Enabled() {
|
|
return nil, fmt.Errorf("threads api access token is required")
|
|
}
|
|
userID = strings.TrimSpace(userID)
|
|
if userID == "" {
|
|
return nil, fmt.Errorf("threads user id is required")
|
|
}
|
|
if limit <= 0 {
|
|
limit = 25
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
params := url.Values{}
|
|
params.Set("access_token", c.accessToken)
|
|
params.Set("fields", userThreadsListFields)
|
|
params.Set("limit", fmt.Sprintf("%d", limit))
|
|
endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/threads?" + params.Encode()
|
|
return c.getMediaList(ctx, endpoint)
|
|
}
|
|
|
|
// DeleteMedia deletes a Threads post (threads_delete).
|
|
func (c *Client) DeleteMedia(ctx context.Context, mediaID string) error {
|
|
if !c.Enabled() {
|
|
return fmt.Errorf("threads api access token is required")
|
|
}
|
|
mediaID = strings.TrimSpace(mediaID)
|
|
if mediaID == "" {
|
|
return fmt.Errorf("threads media id is required")
|
|
}
|
|
params := url.Values{}
|
|
params.Set("access_token", c.accessToken)
|
|
endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "?" + params.Encode()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, status, err := doRequest(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status != http.StatusOK {
|
|
return parseAPIError(body, status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ProfilePosts lists recent public posts for a username (threads_profile_discovery).
|
|
func (c *Client) ProfilePosts(ctx context.Context, username string, limit int, after string) ([]MediaItem, string, error) {
|
|
if !c.Enabled() {
|
|
return nil, "", fmt.Errorf("threads api access token is required")
|
|
}
|
|
username = strings.TrimPrefix(strings.TrimSpace(username), "@")
|
|
if username == "" {
|
|
return nil, "", fmt.Errorf("username is required")
|
|
}
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
params := url.Values{}
|
|
params.Set("access_token", c.accessToken)
|
|
params.Set("username", username)
|
|
params.Set("fields", "id,shortcode,permalink,text,timestamp")
|
|
params.Set("limit", fmt.Sprintf("%d", limit))
|
|
if after = strings.TrimSpace(after); after != "" {
|
|
params.Set("after", after)
|
|
}
|
|
endpoint := graphBaseURL + "/profile_posts?" + 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 payload struct {
|
|
Data []MediaItem `json:"data"`
|
|
Paging struct {
|
|
Cursors struct {
|
|
After string `json:"after"`
|
|
} `json:"cursors"`
|
|
} `json:"paging"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, "", fmt.Errorf("parse profile_posts: %w", err)
|
|
}
|
|
return payload.Data, strings.TrimSpace(payload.Paging.Cursors.After), nil
|
|
}
|
|
|
|
// FindMediaIDByAuthorShortcode scans profile_posts pages until shortcode is found.
|
|
func (c *Client) FindMediaIDByAuthorShortcode(ctx context.Context, username, shortcode string) (string, error) {
|
|
username = strings.TrimPrefix(strings.TrimSpace(username), "@")
|
|
shortcode = strings.TrimSpace(shortcode)
|
|
if username == "" || shortcode == "" {
|
|
return "", fmt.Errorf("username and shortcode are required")
|
|
}
|
|
wantSuffix := "/post/" + shortcode
|
|
after := ""
|
|
for page := 0; page < 3; page++ {
|
|
items, next, err := c.ProfilePosts(ctx, username, 50, after)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, item := range items {
|
|
id := strings.TrimSpace(item.ID)
|
|
if !IsNumericMediaID(id) {
|
|
continue
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(item.Shortcode), shortcode) {
|
|
return id, nil
|
|
}
|
|
link := strings.TrimSpace(item.Permalink)
|
|
if link != "" && strings.HasSuffix(strings.Split(link, "?")[0], wantSuffix) {
|
|
return id, nil
|
|
}
|
|
}
|
|
if next == "" || len(items) == 0 {
|
|
break
|
|
}
|
|
after = next
|
|
}
|
|
return "", fmt.Errorf("profile_posts did not include @%s post %s", username, shortcode)
|
|
}
|
|
|
|
// ProfileLookup fetches a public profile by username (threads_profile_discovery).
|
|
func (c *Client) ProfileLookup(ctx context.Context, username string) (*ProfileLookupResult, error) {
|
|
if !c.Enabled() {
|
|
return nil, fmt.Errorf("threads api access token is required")
|
|
}
|
|
username = strings.TrimPrefix(strings.TrimSpace(username), "@")
|
|
if username == "" {
|
|
return nil, fmt.Errorf("username is required")
|
|
}
|
|
params := url.Values{}
|
|
params.Set("access_token", c.accessToken)
|
|
params.Set("username", username)
|
|
params.Set("fields", "username,name,profile_picture_url,biography,follower_count,likes_count,quotes_count,reposts_count,views_count,is_verified")
|
|
endpoint := graphBaseURL + "/profile_lookup?" + 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 ProfileLookupResult
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return nil, fmt.Errorf("parse profile_lookup: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
type mentionItemRaw 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"`
|
|
RootPost json.RawMessage `json:"root_post"`
|
|
ParentID string `json:"parent_id"`
|
|
}
|
|
|
|
func normalizeMentionItem(raw mentionItemRaw) MentionItem {
|
|
return MentionItem{
|
|
ID: strings.TrimSpace(raw.ID),
|
|
Text: strings.TrimSpace(raw.Text),
|
|
Username: strings.TrimSpace(raw.Username),
|
|
Permalink: strings.TrimSpace(raw.Permalink),
|
|
Timestamp: strings.TrimSpace(raw.Timestamp),
|
|
MediaType: strings.TrimSpace(raw.MediaType),
|
|
IsReply: raw.IsReply,
|
|
IsQuotePost: raw.IsQuotePost,
|
|
HasReplies: raw.HasReplies,
|
|
RootPostID: parseMediaRefID(raw.RootPost),
|
|
ParentID: strings.TrimSpace(raw.ParentID),
|
|
}
|
|
}
|
|
|
|
// UserMentions lists the first page of posts mentioning the user (threads_manage_mentions).
|
|
func (c *Client) UserMentions(ctx context.Context, userID string, limit int) ([]MentionItem, error) {
|
|
items, _, err := c.UserMentionsPage(ctx, userID, limit, "")
|
|
return items, err
|
|
}
|
|
|
|
// UserMentionsPage lists one cursor page of mentions. nextAfter is empty when there is no next page.
|
|
func (c *Client) UserMentionsPage(ctx context.Context, userID string, limit int, after string) ([]MentionItem, string, error) {
|
|
if !c.Enabled() {
|
|
return nil, "", fmt.Errorf("threads api access token is required")
|
|
}
|
|
userID = strings.TrimSpace(userID)
|
|
if userID == "" {
|
|
return nil, "", fmt.Errorf("threads user id is required")
|
|
}
|
|
if limit <= 0 {
|
|
limit = 25
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
params := url.Values{}
|
|
params.Set("access_token", c.accessToken)
|
|
params.Set("fields", "id,text,username,permalink,timestamp,media_type,is_reply,is_quote_post,has_replies,root_post,parent_id")
|
|
params.Set("limit", fmt.Sprintf("%d", limit))
|
|
if after = strings.TrimSpace(after); after != "" {
|
|
params.Set("after", after)
|
|
}
|
|
endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/mentions?" + 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 payload struct {
|
|
Data []mentionItemRaw `json:"data"`
|
|
Paging struct {
|
|
Cursors struct {
|
|
After string `json:"after"`
|
|
} `json:"cursors"`
|
|
} `json:"paging"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, "", fmt.Errorf("parse mentions: %w", err)
|
|
}
|
|
out := make([]MentionItem, 0, len(payload.Data))
|
|
for _, raw := range payload.Data {
|
|
out = append(out, normalizeMentionItem(raw))
|
|
}
|
|
return out, strings.TrimSpace(payload.Paging.Cursors.After), nil
|
|
}
|
|
|
|
// LocationSearch searches public locations (threads_location_tagging).
|
|
func (c *Client) LocationSearch(ctx context.Context, query string, limit int) ([]LocationItem, error) {
|
|
if !c.Enabled() {
|
|
return nil, fmt.Errorf("threads api access token is required")
|
|
}
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return nil, fmt.Errorf("location query is required")
|
|
}
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
if limit > 50 {
|
|
limit = 50
|
|
}
|
|
params := url.Values{}
|
|
params.Set("access_token", c.accessToken)
|
|
params.Set("q", query)
|
|
params.Set("fields", "id,name")
|
|
params.Set("limit", fmt.Sprintf("%d", limit))
|
|
endpoint := graphBaseURL + "/location_search?" + 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 payload struct {
|
|
Data []LocationItem `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, fmt.Errorf("parse location_search: %w", err)
|
|
}
|
|
return payload.Data, nil
|
|
}
|
|
|
|
// ShareToInstagram cross-posts a Threads media to linked Instagram (threads_share_to_instagram).
|
|
func (c *Client) ShareToInstagram(ctx context.Context, mediaID string) error {
|
|
if !c.Enabled() {
|
|
return fmt.Errorf("threads api access token is required")
|
|
}
|
|
mediaID = strings.TrimSpace(mediaID)
|
|
if mediaID == "" {
|
|
return fmt.Errorf("threads media id is required")
|
|
}
|
|
params := url.Values{}
|
|
params.Set("access_token", c.accessToken)
|
|
endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "/crosspost?" + params.Encode()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, status, err := doRequest(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status != http.StatusOK {
|
|
return parseAPIError(body, status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HideReply hides a reply on the user's thread (threads_manage_replies).
|
|
func (c *Client) HideReply(ctx context.Context, replyID string, hide bool) error {
|
|
if !c.Enabled() {
|
|
return fmt.Errorf("threads api access token is required")
|
|
}
|
|
replyID = strings.TrimSpace(replyID)
|
|
if replyID == "" {
|
|
return fmt.Errorf("reply id is required")
|
|
}
|
|
params := url.Values{}
|
|
params.Set("access_token", c.accessToken)
|
|
params.Set("hide", fmt.Sprintf("%t", hide))
|
|
endpoint := graphBaseURL + "/" + url.PathEscape(replyID) + "?" + params.Encode()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, status, err := doRequest(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status != http.StatusOK {
|
|
return parseAPIError(body, status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MediaInsights fetches per-post lifetime metrics (threads_manage_insights).
|
|
func (c *Client) MediaInsights(ctx context.Context, mediaID string, metrics []string) (map[string]int, 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")
|
|
}
|
|
if len(metrics) == 0 {
|
|
metrics = []string{"views", "likes", "replies", "reposts", "quotes", "shares"}
|
|
}
|
|
params := url.Values{}
|
|
params.Set("access_token", c.accessToken)
|
|
params.Set("metric", strings.Join(metrics, ","))
|
|
endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "/insights?" + 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 payload struct {
|
|
Data []InsightValue `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, fmt.Errorf("parse media insights: %w", err)
|
|
}
|
|
out := make(map[string]int, len(payload.Data))
|
|
for _, item := range payload.Data {
|
|
name := strings.TrimSpace(strings.ToLower(item.Name))
|
|
if name == "" || len(item.Values) == 0 {
|
|
continue
|
|
}
|
|
out[name] = item.Values[0].Value
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// UserInsights fetches profile-level insights (threads_manage_insights).
|
|
func (c *Client) UserInsights(ctx context.Context, userID string, metrics []string) ([]InsightValue, error) {
|
|
if !c.Enabled() {
|
|
return nil, fmt.Errorf("threads api access token is required")
|
|
}
|
|
userID = strings.TrimSpace(userID)
|
|
if userID == "" {
|
|
return nil, fmt.Errorf("threads user id is required")
|
|
}
|
|
if len(metrics) == 0 {
|
|
metrics = []string{"views", "likes", "replies", "reposts", "quotes", "followers_count"}
|
|
}
|
|
params := url.Values{}
|
|
params.Set("access_token", c.accessToken)
|
|
params.Set("metric", strings.Join(metrics, ","))
|
|
endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/threads_insights?" + 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 payload struct {
|
|
Data []InsightValue `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, fmt.Errorf("parse threads_insights: %w", err)
|
|
}
|
|
return payload.Data, nil
|
|
}
|
|
|
|
func (c *Client) getMediaList(ctx context.Context, endpoint string) ([]MediaItem, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if res.StatusCode != http.StatusOK {
|
|
return nil, parseAPIError(body, res.StatusCode)
|
|
}
|
|
var payload struct {
|
|
Data []MediaItem `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, fmt.Errorf("parse threads media list: %w", err)
|
|
}
|
|
return payload.Data, nil
|
|
}
|