package threadsapi import ( "context" "fmt" "strings" "haixun-backend/internal/library/threadspost" ) const maxCarouselItems = 20 type publishMediaInput struct { ThreadsUserID string AccessToken string Text string TopicTag string ReplyToID string ImageURLs []string } func normalizeImageURLs(urls []string) []string { out := make([]string, 0, len(urls)) seen := make(map[string]struct{}, len(urls)) for _, raw := range urls { url := strings.TrimSpace(raw) if url == "" { continue } if _, ok := seen[url]; ok { continue } seen[url] = struct{}{} out = append(out, url) if len(out) >= maxCarouselItems { break } } return out } func publishMedia(ctx context.Context, in publishMediaInput) (*PublishResult, error) { userID := strings.TrimSpace(in.ThreadsUserID) token := strings.TrimSpace(in.AccessToken) text := strings.TrimSpace(in.Text) replyTo := strings.TrimSpace(in.ReplyToID) imageURLs := normalizeImageURLs(in.ImageURLs) if userID == "" || token == "" { return nil, fmt.Errorf("threads api credentials incomplete") } if replyTo == "" && text == "" && len(imageURLs) == 0 { return nil, fmt.Errorf("post text or image is required") } if replyTo != "" && text == "" && len(imageURLs) == 0 { return nil, fmt.Errorf("reply text or image is required") } if replyTo != "" && len(imageURLs) > 1 { return nil, fmt.Errorf("Threads API 目前不支援多圖回覆,請只附一張圖") } if replyTo != "" { if err := verifyReplyTarget(ctx, replyTo, token); err != nil { return nil, err } } if replyTo == "" { if err := threadspost.ValidatePublishContent(text, len(imageURLs) > 0); err != nil { return nil, err } } else if err := threadspost.ValidateReplyContent(text, len(imageURLs) > 0); err != nil { return nil, err } var containerID string var err error switch len(imageURLs) { case 0: if replyTo != "" { containerID, err = createReplyContainer(ctx, userID, token, replyTo, text) } else { containerID, err = createTextContainer(ctx, userID, token, text, in.TopicTag) } case 1: if replyTo == "" { containerID, err = createImageContainer(ctx, userID, token, text, imageURLs[0], in.TopicTag) } else { containerID, err = createImageReplyContainer(ctx, userID, token, replyTo, text, imageURLs[0]) } default: containerID, err = createCarouselContainer(ctx, userID, token, text, in.TopicTag, replyTo, imageURLs) } if err != nil { return nil, err } waitAttempts := containerWaitAttempts(len(imageURLs)) if err := waitForContainerReady(ctx, containerID, token, waitAttempts); err != nil { return nil, err } mediaID, err := publishContainer(ctx, userID, token, containerID) if err != nil { return nil, err } permalink, _ := fetchPermalink(ctx, mediaID, token) return &PublishResult{MediaID: mediaID, Permalink: permalink}, nil } func containerWaitAttempts(imageCount int) int { if imageCount == 0 { return 12 } if imageCount == 1 { return 30 } return 30 + imageCount*2 } func createCarouselContainer(ctx context.Context, userID, token, text, topicTag, replyTo string, imageURLs []string) (string, error) { if len(imageURLs) < 2 { return "", fmt.Errorf("carousel requires at least two images") } childIDs := make([]string, 0, len(imageURLs)) for _, imageURL := range imageURLs { childID, err := createCarouselItemContainer(ctx, userID, token, imageURL) if err != nil { return "", err } if err := waitForContainerReady(ctx, childID, token, 30); err != nil { return "", err } childIDs = append(childIDs, childID) } params := urlValues(token) params.Set("media_type", "CAROUSEL") params.Set("children", strings.Join(childIDs, ",")) if text != "" { params.Set("text", text) } if tag := normalizeTopicTag(topicTag); tag != "" { params.Set("topic_tag", tag) } if replyTo != "" { params.Set("reply_to_id", replyTo) } return postThreadsContainer(ctx, userID, params) } func createCarouselItemContainer(ctx context.Context, userID, token, imageURL string) (string, error) { params := urlValues(token) params.Set("is_carousel_item", "true") params.Set("media_type", "IMAGE") params.Set("image_url", imageURL) return postThreadsContainer(ctx, userID, params) }