package threadsapi import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" ) const ( // userThreadsListFields lists only fields supported on GET /{user-id}/threads. userThreadsListFields = "id,text,permalink,timestamp" ) type MediaItem struct { ID string `json:"id"` Text string `json:"text"` Permalink string `json:"permalink"` Timestamp string `json:"timestamp"` LikeCount int `json:"like_count"` ReplyCount int `json:"reply_count"` } type ProfileLookupResult struct { Username string `json:"username"` Name string `json:"name"` } type MentionItem struct { ID string `json:"id"` Text string `json:"text"` Username string `json:"username"` Permalink string `json:"permalink"` Timestamp string `json:"timestamp"` } 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 } // 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") 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 } // UserMentions lists posts mentioning the user (threads_manage_mentions). func (c *Client) UserMentions(ctx context.Context, userID string, limit int) ([]MentionItem, 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") params.Set("limit", fmt.Sprintf("%d", limit)) 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 []MentionItem `json:"data"` } if err := json.Unmarshal(body, &payload); err != nil { return nil, fmt.Errorf("parse mentions: %w", err) } return payload.Data, 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 }