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 }