package provider import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "apps/backend/internal/module/threads/domain" ) // MetaProvider — Meta Threads OAuth (when AppId/Secret configured). type MetaProvider struct { AppID string AppSecret string HTTPClient *http.Client AuthBase string // default https://threads.net GraphBase string // default https://graph.threads.net } func NewMeta(appID, appSecret string) *MetaProvider { return &MetaProvider{ AppID: appID, AppSecret: appSecret, HTTPClient: &http.Client{Timeout: 20 * time.Second}, AuthBase: "https://threads.net", GraphBase: "https://graph.threads.net", } } func (m *MetaProvider) Name() string { return "meta" } func (m *MetaProvider) AuthorizeURL(state, redirectURI string) string { q := url.Values{} q.Set("client_id", m.AppID) q.Set("redirect_uri", redirectURI) // basic + 發文 + 回覆/對話 + insights + 提及 + 公開人設探索(對標帳號 profile_posts) q.Set("scope", "threads_basic,threads_content_publish,threads_manage_replies,threads_manage_insights,threads_manage_mentions,threads_profile_discovery") q.Set("response_type", "code") q.Set("state", state) return strings.TrimRight(m.AuthBase, "/") + "/oauth/authorize?" + q.Encode() } func (m *MetaProvider) ExchangeCode(ctx context.Context, code, redirectURI string) (*domain.TokenPair, error) { // 1) code → short-lived token form := url.Values{} form.Set("client_id", m.AppID) form.Set("client_secret", m.AppSecret) form.Set("grant_type", "authorization_code") form.Set("redirect_uri", redirectURI) form.Set("code", code) endpoint := strings.TrimRight(m.GraphBase, "/") + "/oauth/access_token" req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := m.HTTPClient.Do(req) if err != nil { return nil, domain.ErrOAuthExchange } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return nil, fmt.Errorf("%w: %s", domain.ErrOAuthExchange, string(body)) } var tok struct { AccessToken string `json:"access_token"` UserID int64 `json:"user_id"` ExpiresIn int64 `json:"expires_in"` } if err := json.Unmarshal(body, &tok); err != nil || tok.AccessToken == "" { return nil, domain.ErrOAuthExchange } // Meta short-lived 常不回 expires_in(約 1h) expSec := tok.ExpiresIn if expSec <= 0 { expSec = 3600 } pair := &domain.TokenPair{ AccessToken: tok.AccessToken, ExpiresIn: expSec, UserID: fmt.Sprintf("%d", tok.UserID), } // 2) short-lived → long-lived(之後「延長 token」才能用 th_refresh_token,無需重走授權頁) if long, lerr := m.exchangeLongLived(ctx, pair.AccessToken); lerr == nil && long != nil { pair.AccessToken = long.AccessToken pair.RefreshToken = long.AccessToken // 長效 token 本身就是 refresh 把手 if long.ExpiresIn > 0 { pair.ExpiresIn = long.ExpiresIn } else { pair.ExpiresIn = 60 * 24 * 3600 // ~60d } } else { // 長效失敗仍回短效,延長 token 可能失敗 → 使用者需重連 pair.RefreshToken = pair.AccessToken } _ = m.fillProfile(ctx, pair) return pair, nil } // exchangeLongLived: GET /access_token?grant_type=th_exchange_token&client_secret=…&access_token=… func (m *MetaProvider) exchangeLongLived(ctx context.Context, shortLived string) (*domain.TokenPair, error) { q := url.Values{} q.Set("grant_type", "th_exchange_token") q.Set("client_secret", m.AppSecret) q.Set("access_token", shortLived) u := strings.TrimRight(m.GraphBase, "/") + "/access_token?" + q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } resp, err := m.HTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return nil, fmt.Errorf("long-lived exchange failed: %s", string(body)) } var tok struct { AccessToken string `json:"access_token"` ExpiresIn int64 `json:"expires_in"` TokenType string `json:"token_type"` } if err := json.Unmarshal(body, &tok); err != nil || tok.AccessToken == "" { return nil, fmt.Errorf("long-lived exchange parse: %s", string(body)) } return &domain.TokenPair{ AccessToken: tok.AccessToken, RefreshToken: tok.AccessToken, ExpiresIn: tok.ExpiresIn, }, nil } // Refresh uses the stored long-lived token (not browser re-auth). // GET /refresh_access_token?grant_type=th_refresh_token&access_token={long-lived} func (m *MetaProvider) Refresh(ctx context.Context, longLivedToken string) (*domain.TokenPair, error) { if strings.TrimSpace(longLivedToken) == "" { return nil, domain.ErrRefreshFailed } q := url.Values{} q.Set("grant_type", "th_refresh_token") q.Set("access_token", longLivedToken) u := strings.TrimRight(m.GraphBase, "/") + "/refresh_access_token?" + q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } resp, err := m.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("%w: network", domain.ErrRefreshFailed) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return nil, fmt.Errorf("%w: %s", domain.ErrRefreshFailed, truncateBody(string(body), 240)) } var tok struct { AccessToken string `json:"access_token"` ExpiresIn int64 `json:"expires_in"` } if err := json.Unmarshal(body, &tok); err != nil || tok.AccessToken == "" { return nil, fmt.Errorf("%w: empty token", domain.ErrRefreshFailed) } // 新長效 token 同時當 access 與下次 refresh 把手 exp := tok.ExpiresIn if exp <= 0 { exp = 60 * 24 * 3600 } return &domain.TokenPair{ AccessToken: tok.AccessToken, RefreshToken: tok.AccessToken, ExpiresIn: exp, }, nil } func truncateBody(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" } func (m *MetaProvider) fillProfile(ctx context.Context, pair *domain.TokenPair) error { u := strings.TrimRight(m.GraphBase, "/") + "/me?fields=id,username,name,threads_profile_picture_url&access_token=" + url.QueryEscape(pair.AccessToken) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return err } resp, err := m.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var p struct { ID string `json:"id"` Username string `json:"username"` Name string `json:"name"` Pic string `json:"threads_profile_picture_url"` } if err := json.Unmarshal(body, &p); err != nil { return err } if p.ID != "" { pair.UserID = p.ID } pair.Username = p.Username pair.DisplayName = p.Name if pair.DisplayName == "" { pair.DisplayName = p.Username } pair.AvatarURL = p.Pic return nil } var _ domain.Provider = (*MetaProvider)(nil)