package threadsapi import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" ) const mediaConversationFields = "id,text,username,permalink,timestamp,is_reply,is_reply_owned_by_me,has_replies,replied_to,root_post,parent_id,media_type,media_url,thumbnail_url,gif_url,children{id,media_type,media_url,thumbnail_url}" type ReplyMediaChild struct { ID string `json:"id"` MediaType string `json:"media_type"` MediaURL string `json:"media_url"` ThumbnailURL string `json:"thumbnail_url"` } type ReplyItem struct { ID string `json:"id"` Text string `json:"text"` Username string `json:"username"` Permalink string `json:"permalink"` Timestamp string `json:"timestamp"` LikeCount int `json:"like_count"` ParentID string `json:"parent_id"` RepliedToID string `json:"replied_to_id"` RootPostID string `json:"root_post_id"` IsReply bool `json:"is_reply"` IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"` HasReplies bool `json:"has_replies"` MediaType string `json:"media_type"` MediaURL string `json:"media_url"` ThumbnailURL string `json:"thumbnail_url"` GifURL string `json:"gif_url"` MediaChildren []ReplyMediaChild `json:"media_children,omitempty"` } type replyItemRaw struct { ID string `json:"id"` Text string `json:"text"` Username string `json:"username"` Permalink string `json:"permalink"` Timestamp string `json:"timestamp"` LikeCount int `json:"like_count"` ParentID string `json:"parent_id"` RepliedTo json.RawMessage `json:"replied_to"` RootPost json.RawMessage `json:"root_post"` IsReply bool `json:"is_reply"` IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"` HasReplies bool `json:"has_replies"` MediaType string `json:"media_type"` MediaURL string `json:"media_url"` ThumbnailURL string `json:"thumbnail_url"` GifURL string `json:"gif_url"` Children json.RawMessage `json:"children"` } func (c *Client) MediaReplies(ctx context.Context, mediaID string, limit int) ([]ReplyItem, error) { return c.MediaConversation(ctx, mediaID, limit) } // MediaConversation returns flattened top-level and nested replies for a thread root. func (c *Client) MediaConversation(ctx context.Context, mediaID string, limit int) ([]ReplyItem, 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 limit <= 0 { limit = 10 } if limit > 100 { limit = 100 } out := make([]ReplyItem, 0, limit) nextURL := "" for len(out) < limit { pageLimit := limit - len(out) if pageLimit > 50 { pageLimit = 50 } endpoint := nextURL if endpoint == "" { params := url.Values{} params.Set("access_token", c.accessToken) params.Set("fields", mediaConversationFields) params.Set("limit", fmt.Sprintf("%d", pageLimit)) params.Set("reverse", "false") endpoint = graphBaseURL + "/" + url.PathEscape(mediaID) + "/conversation?" + params.Encode() } 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 } body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) res.Body.Close() if err != nil { return nil, err } if res.StatusCode != http.StatusOK { return nil, parseAPIError(body, res.StatusCode) } var payload struct { Data []replyItemRaw `json:"data"` Paging struct { Next string `json:"next"` } `json:"paging"` } if err := json.Unmarshal(body, &payload); err != nil { return nil, fmt.Errorf("parse threads conversation: %w", err) } for _, raw := range payload.Data { out = append(out, normalizeReplyItem(raw)) if len(out) >= limit { break } } nextURL = strings.TrimSpace(payload.Paging.Next) if nextURL == "" || len(payload.Data) == 0 { break } } return out, nil } func normalizeReplyItem(raw replyItemRaw) ReplyItem { repliedToID := parseMediaRefID(raw.RepliedTo) if repliedToID == "" { repliedToID = strings.TrimSpace(raw.ParentID) } return ReplyItem{ 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), LikeCount: raw.LikeCount, ParentID: strings.TrimSpace(raw.ParentID), RepliedToID: repliedToID, RootPostID: parseMediaRefID(raw.RootPost), IsReply: raw.IsReply, IsReplyOwnedByMe: raw.IsReplyOwnedByMe, HasReplies: raw.HasReplies, MediaType: strings.TrimSpace(raw.MediaType), MediaURL: strings.TrimSpace(raw.MediaURL), ThumbnailURL: strings.TrimSpace(raw.ThumbnailURL), GifURL: strings.TrimSpace(raw.GifURL), MediaChildren: parseMediaChildren(raw.Children), } } func parseMediaChildren(raw json.RawMessage) []ReplyMediaChild { if len(raw) == 0 || string(raw) == "null" { return nil } var asList []ReplyMediaChild if err := json.Unmarshal(raw, &asList); err == nil { return normalizeMediaChildren(asList) } var wrapped struct { Data []ReplyMediaChild `json:"data"` } if err := json.Unmarshal(raw, &wrapped); err == nil && len(wrapped.Data) > 0 { return normalizeMediaChildren(wrapped.Data) } return nil } func normalizeMediaChildren(items []ReplyMediaChild) []ReplyMediaChild { out := make([]ReplyMediaChild, 0, len(items)) for _, item := range items { child := ReplyMediaChild{ ID: strings.TrimSpace(item.ID), MediaType: strings.TrimSpace(item.MediaType), MediaURL: strings.TrimSpace(item.MediaURL), ThumbnailURL: strings.TrimSpace(item.ThumbnailURL), } if child.ID == "" && child.MediaURL == "" && child.ThumbnailURL == "" { continue } out = append(out, child) } if len(out) == 0 { return nil } return out } func parseMediaRefID(raw json.RawMessage) string { if len(raw) == 0 { return "" } var asString string if err := json.Unmarshal(raw, &asString); err == nil { return strings.TrimSpace(asString) } var asObject struct { ID string `json:"id"` } if err := json.Unmarshal(raw, &asObject); err == nil { return strings.TrimSpace(asObject.ID) } return "" }