258 lines
7.1 KiB
Go
258 lines
7.1 KiB
Go
package threadsapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
threadsAuthorizeURL = "https://threads.net/oauth/authorize"
|
|
threadsShortTokenURL = "https://graph.threads.net/oauth/access_token"
|
|
threadsLongLivedTokenURL = "https://graph.threads.net/access_token"
|
|
)
|
|
|
|
// flexString unmarshals JSON string or number (Meta often returns user_id as a number).
|
|
type flexString string
|
|
|
|
func (s *flexString) UnmarshalJSON(data []byte) error {
|
|
if len(data) == 0 || string(data) == "null" {
|
|
*s = ""
|
|
return nil
|
|
}
|
|
if data[0] == '"' {
|
|
var str string
|
|
if err := json.Unmarshal(data, &str); err != nil {
|
|
return err
|
|
}
|
|
*s = flexString(str)
|
|
return nil
|
|
}
|
|
var num json.Number
|
|
if err := json.Unmarshal(data, &num); err != nil {
|
|
return err
|
|
}
|
|
*s = flexString(num.String())
|
|
return nil
|
|
}
|
|
|
|
func (s flexString) String() string {
|
|
return strings.TrimSpace(string(s))
|
|
}
|
|
|
|
type OAuthConfig struct {
|
|
AppID string
|
|
AppSecret string
|
|
RedirectURI string
|
|
SuccessRedirect string
|
|
}
|
|
|
|
func (c OAuthConfig) Enabled() bool {
|
|
return strings.TrimSpace(c.AppID) != "" &&
|
|
strings.TrimSpace(c.AppSecret) != "" &&
|
|
strings.TrimSpace(c.RedirectURI) != ""
|
|
}
|
|
|
|
type TokenExchangeResult struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
UserID flexString `json:"user_id"`
|
|
}
|
|
|
|
type UserProfile struct {
|
|
ID flexString `json:"id"`
|
|
Username string `json:"username"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func BuildAuthorizeURL(cfg OAuthConfig, state string) (string, error) {
|
|
if !cfg.Enabled() {
|
|
return "", fmt.Errorf("threads oauth is not configured")
|
|
}
|
|
state = strings.TrimSpace(state)
|
|
if state == "" {
|
|
return "", fmt.Errorf("oauth state is required")
|
|
}
|
|
params := url.Values{}
|
|
params.Set("client_id", strings.TrimSpace(cfg.AppID))
|
|
params.Set("redirect_uri", strings.TrimSpace(cfg.RedirectURI))
|
|
params.Set("scope", OAuthScopeString())
|
|
params.Set("response_type", "code")
|
|
params.Set("state", state)
|
|
return threadsAuthorizeURL + "?" + params.Encode(), nil
|
|
}
|
|
|
|
func ExchangeCodeForToken(ctx context.Context, cfg OAuthConfig, code string) (*TokenExchangeResult, error) {
|
|
if !cfg.Enabled() {
|
|
return nil, fmt.Errorf("threads oauth is not configured")
|
|
}
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
return nil, fmt.Errorf("authorization code is required")
|
|
}
|
|
// Meta may append #_ to the redirect code; strip suffix if present.
|
|
if idx := strings.Index(code, "#_"); idx >= 0 {
|
|
code = code[:idx]
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("client_id", strings.TrimSpace(cfg.AppID))
|
|
params.Set("client_secret", strings.TrimSpace(cfg.AppSecret))
|
|
params.Set("grant_type", "authorization_code")
|
|
params.Set("redirect_uri", strings.TrimSpace(cfg.RedirectURI))
|
|
params.Set("code", code)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, threadsShortTokenURL+"?"+params.Encode(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
body, status, err := oauthDoRequest(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, parseAPIError(body, status)
|
|
}
|
|
var result TokenExchangeResult
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return nil, fmt.Errorf("parse threads token response: %w", err)
|
|
}
|
|
if strings.TrimSpace(result.AccessToken) == "" {
|
|
return nil, fmt.Errorf("threads token exchange returned empty access_token")
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func ExchangeLongLivedToken(ctx context.Context, cfg OAuthConfig, shortLivedToken string) (*TokenExchangeResult, error) {
|
|
if !cfg.Enabled() {
|
|
return nil, fmt.Errorf("threads oauth is not configured")
|
|
}
|
|
shortLivedToken = strings.TrimSpace(shortLivedToken)
|
|
if shortLivedToken == "" {
|
|
return nil, fmt.Errorf("short-lived access token is required")
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("grant_type", "th_exchange_token")
|
|
params.Set("client_secret", strings.TrimSpace(cfg.AppSecret))
|
|
params.Set("access_token", shortLivedToken)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, threadsLongLivedTokenURL+"?"+params.Encode(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body, status, err := oauthDoRequest(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, parseAPIError(body, status)
|
|
}
|
|
var result TokenExchangeResult
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return nil, fmt.Errorf("parse long-lived token response: %w", err)
|
|
}
|
|
if strings.TrimSpace(result.AccessToken) == "" {
|
|
return nil, fmt.Errorf("long-lived token exchange returned empty access_token")
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func GetUserProfile(ctx context.Context, accessToken, userID string) (*UserProfile, error) {
|
|
accessToken = strings.TrimSpace(accessToken)
|
|
userID = strings.TrimSpace(userID)
|
|
if accessToken == "" {
|
|
return nil, fmt.Errorf("access token is required")
|
|
}
|
|
if userID == "" {
|
|
return GetMeProfile(ctx, accessToken)
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("access_token", accessToken)
|
|
params.Set("fields", "id,username,name")
|
|
|
|
endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "?" + params.Encode()
|
|
return fetchUserProfile(ctx, endpoint)
|
|
}
|
|
|
|
// GetMeProfile loads the authenticated Threads user via /me (preferred after OAuth).
|
|
func GetMeProfile(ctx context.Context, accessToken string) (*UserProfile, error) {
|
|
accessToken = strings.TrimSpace(accessToken)
|
|
if accessToken == "" {
|
|
return nil, fmt.Errorf("access token is required")
|
|
}
|
|
params := url.Values{}
|
|
params.Set("access_token", accessToken)
|
|
params.Set("fields", "id,username,name")
|
|
endpoint := graphBaseURL + "/me?" + params.Encode()
|
|
return fetchUserProfile(ctx, endpoint)
|
|
}
|
|
|
|
// ThreadsUserIDFromToken picks user_id returned by the OAuth token exchange.
|
|
func ThreadsUserIDFromToken(short, long *TokenExchangeResult) string {
|
|
if short != nil {
|
|
if id := short.UserID.String(); id != "" {
|
|
return id
|
|
}
|
|
}
|
|
if long != nil {
|
|
return long.UserID.String()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IsPermissionError reports Meta permission / app-review / tester-list errors.
|
|
func IsPermissionError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
return strings.Contains(msg, "threads_basic") ||
|
|
strings.Contains(msg, "app review") ||
|
|
strings.Contains(msg, "testers")
|
|
}
|
|
|
|
func fetchUserProfile(ctx context.Context, endpoint string) (*UserProfile, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body, status, err := oauthDoRequest(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, parseAPIError(body, status)
|
|
}
|
|
var profile UserProfile
|
|
if err := json.Unmarshal(body, &profile); err != nil {
|
|
return nil, fmt.Errorf("parse threads user profile: %w", err)
|
|
}
|
|
if profile.ID.String() == "" {
|
|
return nil, fmt.Errorf("threads user profile missing id")
|
|
}
|
|
return &profile, nil
|
|
}
|
|
|
|
func oauthDoRequest(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
|
|
}
|