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

523 lines
16 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 (
2026-07-01 08:42:51 +00:00
// userThreadsListFields lists fields supported on GET /{user-id}/threads.
userThreadsListFields = "id,text,permalink,timestamp,media_type,media_url,thumbnail_url,topic_tag,shortcode"
2026-06-30 07:09:44 +00:00
)
type MediaItem struct {
2026-07-01 08:42:51 +00:00
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"`
2026-06-30 07:09:44 +00:00
}
type ProfileLookupResult struct {
2026-07-01 08:42:51 +00:00
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"`
2026-06-30 07:09:44 +00:00
}
type MentionItem struct {
2026-07-01 08:42:51 +00:00
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"`
2026-06-30 07:09:44 +00:00
}
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
}
2026-07-01 08:42:51 +00:00
// 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)
}
2026-06-30 07:09:44 +00:00
// 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)
2026-07-01 08:42:51 +00:00
params.Set("fields", "username,name,profile_picture_url,biography,follower_count,likes_count,quotes_count,reposts_count,views_count,is_verified")
2026-06-30 07:09:44 +00:00
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
}
2026-07-01 08:42:51 +00:00
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).
2026-06-30 07:09:44 +00:00
func (c *Client) UserMentions(ctx context.Context, userID string, limit int) ([]MentionItem, error) {
2026-07-01 08:42:51 +00:00
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) {
2026-06-30 07:09:44 +00:00
if !c.Enabled() {
2026-07-01 08:42:51 +00:00
return nil, "", fmt.Errorf("threads api access token is required")
2026-06-30 07:09:44 +00:00
}
userID = strings.TrimSpace(userID)
if userID == "" {
2026-07-01 08:42:51 +00:00
return nil, "", fmt.Errorf("threads user id is required")
2026-06-30 07:09:44 +00:00
}
if limit <= 0 {
limit = 25
}
if limit > 100 {
limit = 100
}
params := url.Values{}
params.Set("access_token", c.accessToken)
2026-07-01 08:42:51 +00:00
params.Set("fields", "id,text,username,permalink,timestamp,media_type,is_reply,is_quote_post,has_replies,root_post,parent_id")
2026-06-30 07:09:44 +00:00
params.Set("limit", fmt.Sprintf("%d", limit))
2026-07-01 08:42:51 +00:00
if after = strings.TrimSpace(after); after != "" {
params.Set("after", after)
}
2026-06-30 07:09:44 +00:00
endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/mentions?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
2026-07-01 08:42:51 +00:00
return nil, "", err
2026-06-30 07:09:44 +00:00
}
body, status, err := doRequest(req)
if err != nil {
2026-07-01 08:42:51 +00:00
return nil, "", err
2026-06-30 07:09:44 +00:00
}
if status != http.StatusOK {
2026-07-01 08:42:51 +00:00
return nil, "", parseAPIError(body, status)
2026-06-30 07:09:44 +00:00
}
var payload struct {
2026-07-01 08:42:51 +00:00
Data []mentionItemRaw `json:"data"`
Paging struct {
Cursors struct {
After string `json:"after"`
} `json:"cursors"`
} `json:"paging"`
2026-06-30 07:09:44 +00:00
}
if err := json.Unmarshal(body, &payload); err != nil {
2026-07-01 08:42:51 +00:00
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))
2026-06-30 07:09:44 +00:00
}
2026-07-01 08:42:51 +00:00
return out, strings.TrimSpace(payload.Paging.Cursors.After), nil
2026-06-30 07:09:44 +00:00
}
// 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
}