thread-master/apps/backend/internal/module/threads/provider/meta.go

223 lines
6.9 KiB
Go
Raw Permalink Normal View History

2026-07-13 01:15:30 +00:00
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)