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 }