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

220 lines
6.6 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 ""
}