Files
Sarteambot Admin 0fde6d4a0b feat: 初始化 ChatGPT Team 管理机器人
核心功能:
- 实现基于 Telegram Inline Button 交互的后台面板与用户端
- 支持通过账密登录和 RT (Refresh Token) 方式添加 ChatGPT Team 账号
- 支持管理、拉取和删除待处理邀请,支持一键清空多余邀请
- 支持按剩余容量自动生成邀请兑换码,支持分页查看与一键清空未使用兑换码
- 随机邀请功能:成功拉人后自动核销兑换码
- 定时检测 Token 状态,实现自动续订/刷新并拦截封禁账号 (处理 401/402 错误)

系统与配置:
- 使用 PostgreSQL 数据库管理账号、邀请和兑换记录
- 支持在端内动态添加、移除管理员
- 完善 Docker 部署配置与 .gitignore 规则
2026-03-04 20:08:34 +08:00

234 lines
6.2 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 chatgpt
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// OAuthSession holds PKCE session data for an in-progress OAuth login.
type OAuthSession struct {
CodeVerifier string
State string
CreatedAt time.Time
}
// OAuthResult holds the tokens and account info after a successful OAuth exchange.
type OAuthResult struct {
AccessToken string
RefreshToken string
Email string
AccountID string
Name string
PlanType string
}
// OAuthManager manages PKCE sessions for the manual URL-paste OAuth flow.
type OAuthManager struct {
client *Client
sessions map[string]*OAuthSession // state -> session
mu sync.Mutex
}
// NewOAuthManager creates a new OAuth manager.
func NewOAuthManager(client *Client) *OAuthManager {
return &OAuthManager{
client: client,
sessions: make(map[string]*OAuthSession),
}
}
// GenerateAuthURL creates an OpenAI OAuth authorization URL with PKCE.
// The redirect_uri points to localhost which won't load — user copies the URL from browser.
func (m *OAuthManager) GenerateAuthURL() (authURL, state string, err error) {
// Generate PKCE code verifier.
verifierBytes := make([]byte, 64)
if _, err := rand.Read(verifierBytes); err != nil {
return "", "", fmt.Errorf("生成 PKCE 失败: %w", err)
}
codeVerifier := hex.EncodeToString(verifierBytes)
hash := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
// Generate state.
stateBytes := make([]byte, 32)
if _, err := rand.Read(stateBytes); err != nil {
return "", "", fmt.Errorf("生成 state 失败: %w", err)
}
state = hex.EncodeToString(stateBytes)
// Store session.
m.mu.Lock()
m.sessions[state] = &OAuthSession{
CodeVerifier: codeVerifier,
State: state,
CreatedAt: time.Now(),
}
m.mu.Unlock()
// Build auth URL — redirect to localhost, user will copy the URL.
redirectURI := "http://localhost:1455/auth/callback"
params := url.Values{}
params.Set("response_type", "code")
params.Set("client_id", openaiClientID)
params.Set("redirect_uri", redirectURI)
params.Set("scope", "openid profile email offline_access")
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
params.Set("state", state)
params.Set("id_token_add_organizations", "true")
params.Set("codex_cli_simplified_flow", "true")
authURL = fmt.Sprintf("https://auth.openai.com/oauth/authorize?%s", params.Encode())
return authURL, state, nil
}
// ExchangeCallbackURL parses the pasted callback URL to extract code and state,
// then exchanges the authorization code for tokens.
func (m *OAuthManager) ExchangeCallbackURL(callbackURL string) (*OAuthResult, error) {
callbackURL = strings.TrimSpace(callbackURL)
parsed, err := url.Parse(callbackURL)
if err != nil {
return nil, fmt.Errorf("URL 格式错误: %w", err)
}
code := parsed.Query().Get("code")
state := parsed.Query().Get("state")
if code == "" {
errMsg := parsed.Query().Get("error_description")
if errMsg == "" {
errMsg = parsed.Query().Get("error")
}
if errMsg == "" {
errMsg = "回调 URL 中未找到 code 参数"
}
return nil, fmt.Errorf("%s", errMsg)
}
if state == "" {
return nil, fmt.Errorf("回调 URL 中未找到 state 参数")
}
// Look up the session.
m.mu.Lock()
session, ok := m.sessions[state]
if ok {
delete(m.sessions, state)
}
m.mu.Unlock()
if !ok {
return nil, fmt.Errorf("会话已过期或无效,请重新使用 /login")
}
// Check if session is expired (10 minutes).
if time.Since(session.CreatedAt) > 10*time.Minute {
return nil, fmt.Errorf("登录会话已过期超过10分钟请重新使用 /login")
}
// Exchange code for tokens.
return m.exchangeCode(code, session.CodeVerifier)
}
func (m *OAuthManager) exchangeCode(code, codeVerifier string) (*OAuthResult, error) {
redirectURI := "http://localhost:1455/auth/callback"
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
form.Set("client_id", openaiClientID)
form.Set("code_verifier", codeVerifier)
req, err := http.NewRequest("POST", "https://auth.openai.com/oauth/token", strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := m.client.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("网络错误: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("交换授权码失败 (HTTP %d): %s", resp.StatusCode, truncate(string(body), 300))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("解析 token 响应失败: %w", err)
}
if tokenResp.AccessToken == "" {
return nil, fmt.Errorf("未返回有效的 access token")
}
result := &OAuthResult{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
}
// Decode ID token for user info.
if tokenResp.IDToken != "" {
claims, err := decodeJWTPayload(tokenResp.IDToken)
if err == nil {
result.Email, _ = claims["email"].(string)
result.Name, _ = claims["name"].(string)
if authClaims, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
result.AccountID, _ = authClaims["chatgpt_account_id"].(string)
result.PlanType, _ = authClaims["chatgpt_plan_type"].(string)
}
}
}
return result, nil
}
func decodeJWTPayload(token string) (map[string]interface{}, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format")
}
payload := parts[1]
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
decoded, err = base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, err
}
}
var claims map[string]interface{}
if err := json.Unmarshal(decoded, &claims); err != nil {
return nil, err
}
return claims, nil
}