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 }