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)
|