2026-06-26 08:37:04 +00:00
|
|
|
|
package threadsapi
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/url"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type PublishResult struct {
|
2026-07-06 06:13:14 +00:00
|
|
|
|
MediaID string `json:"media_id"`
|
|
|
|
|
|
Permalink string `json:"permalink"`
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type PublishTextInput struct {
|
|
|
|
|
|
ThreadsUserID string
|
|
|
|
|
|
AccessToken string
|
|
|
|
|
|
Text string
|
2026-07-06 06:13:14 +00:00
|
|
|
|
TopicTag string
|
2026-07-09 03:45:28 +00:00
|
|
|
|
ImageURL string
|
|
|
|
|
|
ImageURLs []string
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type PublishReplyInput struct {
|
|
|
|
|
|
ThreadsUserID string
|
|
|
|
|
|
AccessToken string
|
|
|
|
|
|
ReplyToID string
|
|
|
|
|
|
Text string
|
2026-07-09 03:45:28 +00:00
|
|
|
|
ImageURL string
|
|
|
|
|
|
ImageURLs []string
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 03:45:28 +00:00
|
|
|
|
func collectImageURLs(single string, list []string) []string {
|
|
|
|
|
|
if len(list) > 0 {
|
|
|
|
|
|
return normalizeImageURLs(list)
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
2026-07-09 03:45:28 +00:00
|
|
|
|
single = strings.TrimSpace(single)
|
|
|
|
|
|
if single == "" {
|
|
|
|
|
|
return nil
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
2026-07-09 03:45:28 +00:00
|
|
|
|
return []string{single}
|
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
|
|
2026-07-09 03:45:28 +00:00
|
|
|
|
// 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),
|
|
|
|
|
|
})
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 03:45:28 +00:00
|
|
|
|
// PublishReply posts a text or image reply via Graph API.
|
2026-06-26 08:37:04 +00:00
|
|
|
|
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")
|
|
|
|
|
|
}
|
2026-07-09 03:45:28 +00:00
|
|
|
|
return publishMedia(ctx, publishMediaInput{
|
|
|
|
|
|
ThreadsUserID: in.ThreadsUserID,
|
|
|
|
|
|
AccessToken: in.AccessToken,
|
|
|
|
|
|
Text: in.Text,
|
|
|
|
|
|
ReplyToID: replyTo,
|
|
|
|
|
|
ImageURLs: collectImageURLs(in.ImageURL, in.ImageURLs),
|
|
|
|
|
|
})
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 03:45:28 +00:00
|
|
|
|
func urlValues(token string) url.Values {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
params := url.Values{}
|
|
|
|
|
|
params.Set("access_token", token)
|
2026-07-09 03:45:28 +00:00
|
|
|
|
return params
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func postThreadsContainer(ctx context.Context, userID string, params url.Values) (string, error) {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 03:45:28 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 06:13:14 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 03:45:28 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 08:37:04 +00:00
|
|
|
|
func createReplyContainer(ctx context.Context, userID, token, replyTo, text string) (string, error) {
|
2026-07-09 03:45:28 +00:00
|
|
|
|
params := urlValues(token)
|
2026-06-26 08:37:04 +00:00
|
|
|
|
params.Set("media_type", "TEXT")
|
|
|
|
|
|
params.Set("text", text)
|
|
|
|
|
|
params.Set("reply_to_id", replyTo)
|
2026-07-09 03:45:28 +00:00
|
|
|
|
return postThreadsContainer(ctx, userID, params)
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 03:45:28 +00:00
|
|
|
|
func waitForContainerReady(ctx context.Context, containerID, token string, maxAttempts int) error {
|
|
|
|
|
|
if maxAttempts <= 0 {
|
|
|
|
|
|
maxAttempts = 12
|
|
|
|
|
|
}
|
|
|
|
|
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
2026-06-26 08:37:04 +00:00
|
|
|
|
if attempt > 0 {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
|
return ctx.Err()
|
2026-07-06 06:13:14 +00:00
|
|
|
|
case <-time.After(2 * time.Second):
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
params := url.Values{}
|
|
|
|
|
|
params.Set("access_token", token)
|
2026-07-09 03:45:28 +00:00
|
|
|
|
params.Set("fields", "status,error_message")
|
2026-06-26 08:37:04 +00:00
|
|
|
|
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 {
|
2026-07-09 03:45:28 +00:00
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
|
ErrorMessage string `json:"error_message"`
|
2026-06-26 08:37:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
switch strings.ToUpper(strings.TrimSpace(payload.Status)) {
|
|
|
|
|
|
case "FINISHED":
|
|
|
|
|
|
return nil
|
|
|
|
|
|
case "ERROR", "EXPIRED":
|
2026-07-09 03:45:28 +00:00
|
|
|
|
detail := strings.TrimSpace(payload.ErrorMessage)
|
|
|
|
|
|
if detail != "" {
|
|
|
|
|
|
return fmt.Errorf("threads container %s: %s", strings.ToLower(payload.Status), detail)
|
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
|
return fmt.Errorf("threads container status %s", payload.Status)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Errorf("threads container not ready in time")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-09 03:45:28 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 08:37:04 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|