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

261 lines
6.9 KiB
Go
Raw Permalink 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"
"haixun-backend/internal/library/threadspost"
)
type PublishResult struct {
MediaID string
Permalink string
}
type PublishTextInput struct {
ThreadsUserID string
AccessToken string
Text string
}
type PublishReplyInput struct {
ThreadsUserID string
AccessToken string
ReplyToID string
Text string
}
// PublishText posts a new text thread via Graph API.
func PublishText(ctx context.Context, in PublishTextInput) (*PublishResult, error) {
userID := strings.TrimSpace(in.ThreadsUserID)
token := strings.TrimSpace(in.AccessToken)
text := strings.TrimSpace(in.Text)
if userID == "" || token == "" {
return nil, fmt.Errorf("threads api credentials incomplete")
}
if text == "" {
return nil, fmt.Errorf("post text is required")
}
if err := threadspost.ValidatePublish(text); err != nil {
return nil, err
}
containerID, err := createTextContainer(ctx, userID, token, text)
if err != nil {
return nil, err
}
if err := waitForContainerReady(ctx, containerID, token); 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
}
// PublishReply posts a text reply to an existing Threads media via Graph API.
func PublishReply(ctx context.Context, in PublishReplyInput) (*PublishResult, error) {
userID := strings.TrimSpace(in.ThreadsUserID)
token := strings.TrimSpace(in.AccessToken)
replyTo := strings.TrimSpace(in.ReplyToID)
text := strings.TrimSpace(in.Text)
if userID == "" || token == "" {
return nil, fmt.Errorf("threads api credentials incomplete")
}
if replyTo == "" {
return nil, fmt.Errorf("reply_to_id is required")
}
if text == "" {
return nil, fmt.Errorf("reply text is required")
}
if err := threadspost.ValidateReply(text); err != nil {
return nil, err
}
containerID, err := createReplyContainer(ctx, userID, token, replyTo, text)
if err != nil {
return nil, err
}
if err := waitForContainerReady(ctx, containerID, token); 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 createTextContainer(ctx context.Context, userID, token, text string) (string, error) {
params := url.Values{}
params.Set("access_token", token)
params.Set("media_type", "TEXT")
params.Set("text", text)
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 createReplyContainer(ctx context.Context, userID, token, replyTo, text string) (string, error) {
params := url.Values{}
params.Set("access_token", token)
params.Set("media_type", "TEXT")
params.Set("text", text)
params.Set("reply_to_id", replyTo)
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 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) error {
for attempt := 0; attempt < 20; attempt++ {
if attempt > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(3 * time.Second):
}
}
params := url.Values{}
params.Set("access_token", token)
params.Set("fields", "status")
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"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return err
}
switch strings.ToUpper(strings.TrimSpace(payload.Status)) {
case "FINISHED":
return nil
case "ERROR", "EXPIRED":
return fmt.Errorf("threads container status %s", payload.Status)
}
}
return fmt.Errorf("threads container not ready in time")
}
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
}