223 lines
6.9 KiB
Go
223 lines
6.9 KiB
Go
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)
|