thread-master/old/backend/internal/library/threadsapi/publish.go

305 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package threadsapi
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type PublishResult struct {
MediaID string `json:"media_id"`
Permalink string `json:"permalink"`
}
type PublishTextInput struct {
ThreadsUserID string
AccessToken string
Text string
TopicTag string
ImageURL string
ImageURLs []string
}
type PublishReplyInput struct {
ThreadsUserID string
AccessToken string
ReplyToID string
Text string
ImageURL string
ImageURLs []string
}
func collectImageURLs(single string, list []string) []string {
if len(list) > 0 {
return normalizeImageURLs(list)
}
single = strings.TrimSpace(single)
if single == "" {
return nil
}
return []string{single}
}
// PublishText posts a new text or image thread via Graph API.
func PublishText(ctx context.Context, in PublishTextInput) (*PublishResult, error) {
return publishMedia(ctx, publishMediaInput{
ThreadsUserID: in.ThreadsUserID,
AccessToken: in.AccessToken,
Text: in.Text,
TopicTag: in.TopicTag,
ImageURLs: collectImageURLs(in.ImageURL, in.ImageURLs),
})
}
// PublishReply posts a text or image reply via Graph API.
func PublishReply(ctx context.Context, in PublishReplyInput) (*PublishResult, error) {
replyTo := strings.TrimSpace(in.ReplyToID)
if replyTo == "" {
return nil, fmt.Errorf("reply_to_id is required")
}
return publishMedia(ctx, publishMediaInput{
ThreadsUserID: in.ThreadsUserID,
AccessToken: in.AccessToken,
Text: in.Text,
ReplyToID: replyTo,
ImageURLs: collectImageURLs(in.ImageURL, in.ImageURLs),
})
}
func urlValues(token string) url.Values {
params := url.Values{}
params.Set("access_token", token)
return params
}
func postThreadsContainer(ctx context.Context, userID string, params url.Values) (string, error) {
endpoint := graphBaseURL + "/" + userID + "/threads?" + 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)
}
var payload struct {
ID string `json:"id"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", err
}
if strings.TrimSpace(payload.ID) == "" {
return "", fmt.Errorf("threads api did not return container id")
}
return payload.ID, nil
}
func createTextContainer(ctx context.Context, userID, token, text, topicTag string) (string, error) {
params := urlValues(token)
params.Set("media_type", "TEXT")
params.Set("text", text)
if tag := normalizeTopicTag(topicTag); tag != "" {
params.Set("topic_tag", tag)
}
return postThreadsContainer(ctx, userID, params)
}
func normalizeTopicTag(value string) string {
tag := strings.TrimSpace(value)
tag = strings.TrimLeft(tag, "#")
tag = strings.TrimSpace(tag)
if len([]rune(tag)) > 40 {
runes := []rune(tag)
tag = string(runes[:40])
}
return tag
}
func createImageContainer(ctx context.Context, userID, token, text, imageURL, topicTag string) (string, error) {
params := urlValues(token)
params.Set("media_type", "IMAGE")
params.Set("image_url", imageURL)
if text != "" {
params.Set("text", text)
}
if tag := normalizeTopicTag(topicTag); tag != "" {
params.Set("topic_tag", tag)
}
return postThreadsContainer(ctx, userID, params)
}
func createImageReplyContainer(ctx context.Context, userID, token, replyTo, text, imageURL string) (string, error) {
params := urlValues(token)
params.Set("media_type", "IMAGE")
params.Set("image_url", imageURL)
if text != "" {
params.Set("text", text)
}
params.Set("reply_to_id", replyTo)
return postThreadsContainer(ctx, userID, params)
}
func createReplyContainer(ctx context.Context, userID, token, replyTo, text string) (string, error) {
params := urlValues(token)
params.Set("media_type", "TEXT")
params.Set("text", text)
params.Set("reply_to_id", replyTo)
return postThreadsContainer(ctx, userID, params)
}
func publishContainer(ctx context.Context, userID, token, containerID string) (string, error) {
params := url.Values{}
params.Set("access_token", token)
params.Set("creation_id", containerID)
endpoint := graphBaseURL + "/" + userID + "/threads_publish?" + 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)
}
var payload struct {
ID string `json:"id"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", err
}
if strings.TrimSpace(payload.ID) == "" {
return "", fmt.Errorf("threads publish did not return media id")
}
return payload.ID, nil
}
func waitForContainerReady(ctx context.Context, containerID, token string, maxAttempts int) error {
if maxAttempts <= 0 {
maxAttempts = 12
}
for attempt := 0; attempt < maxAttempts; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
params := url.Values{}
params.Set("access_token", token)
params.Set("fields", "status,error_message")
endpoint := graphBaseURL + "/" + containerID + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, 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)
}
var payload struct {
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return err
}
switch strings.ToUpper(strings.TrimSpace(payload.Status)) {
case "FINISHED":
return nil
case "ERROR", "EXPIRED":
detail := strings.TrimSpace(payload.ErrorMessage)
if detail != "" {
return fmt.Errorf("threads container %s: %s", strings.ToLower(payload.Status), detail)
}
return fmt.Errorf("threads container status %s", payload.Status)
}
}
return fmt.Errorf("threads container not ready in time")
}
func verifyReplyTarget(ctx context.Context, replyTo, token string) error {
replyTo = strings.TrimSpace(replyTo)
if replyTo == "" {
return fmt.Errorf("reply_to_id is required")
}
if !IsNumericMediaID(replyTo) {
return fmt.Errorf("reply_to_id 必須是 Threads 數字 media id目前為 %q", replyTo)
}
params := url.Values{}
params.Set("access_token", token)
params.Set("fields", "id,is_reply,permalink")
endpoint := graphBaseURL + "/" + url.PathEscape(replyTo) + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
body, status, err := doRequest(req)
if err != nil {
return err
}
if status != http.StatusOK {
return fmt.Errorf("無法讀取回覆目標 %s%w", replyTo, parseAPIError(body, status))
}
var payload struct {
ID string `json:"id"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return fmt.Errorf("parse reply target: %w", err)
}
if strings.TrimSpace(payload.ID) == "" {
return fmt.Errorf("回覆目標 %s 不存在或無權限讀取", replyTo)
}
return nil
}
func fetchPermalink(ctx context.Context, mediaID, token string) (string, error) {
params := url.Values{}
params.Set("access_token", token)
params.Set("fields", "permalink")
endpoint := graphBaseURL + "/" + mediaID + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", err
}
body, status, err := doRequest(req)
if err != nil || status != http.StatusOK {
return "", err
}
var payload struct {
Permalink string `json:"permalink"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", err
}
return strings.TrimSpace(payload.Permalink), nil
}
func doRequest(req *http.Request) ([]byte, int, error) {
client := &http.Client{Timeout: 25 * time.Second}
res, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer res.Body.Close()
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, res.StatusCode, err
}
return body, res.StatusCode, nil
}