221 lines
6.7 KiB
Go
221 lines
6.7 KiB
Go
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 ""
|
|
}
|