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

305 lines
8.2 KiB
Go
Raw Normal View History

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 {
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
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)
}
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()
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
}