thread-master/backend/internal/library/threadsapi/extended.go

372 lines
10 KiB
Go
Raw Normal View History

2026-06-30 07:09:44 +00:00
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
}