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 }