372 lines
10 KiB
Go
372 lines
10 KiB
Go
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
|
|
}
|