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

223 lines
6.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)