196 lines
5.1 KiB
Go
196 lines
5.1 KiB
Go
|
|
package threadsapi
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const maxPublishChars = 500
|
||
|
|
|
||
|
|
type PublishResult struct {
|
||
|
|
MediaID string
|
||
|
|
Permalink string
|
||
|
|
}
|
||
|
|
|
||
|
|
type PublishReplyInput struct {
|
||
|
|
ThreadsUserID string
|
||
|
|
AccessToken string
|
||
|
|
ReplyToID string
|
||
|
|
Text string
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 len([]rune(text)) > maxPublishChars {
|
||
|
|
return nil, fmt.Errorf("reply exceeds %d characters", maxPublishChars)
|
||
|
|
}
|
||
|
|
|
||
|
|
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 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
|
||
|
|
}
|