feat: 初始化 ChatGPT Team 管理机器人

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

系统与配置:
- 使用 PostgreSQL 数据库管理账号、邀请和兑换记录
- 支持在端内动态添加、移除管理员
- 完善 Docker 部署配置与 .gitignore 规则
This commit is contained in:
Sarteambot Admin
2026-03-04 20:08:34 +08:00
commit 0fde6d4a0b
19 changed files with 3893 additions and 0 deletions

233
internal/chatgpt/oauth.go Normal file
View File

@@ -0,0 +1,233 @@
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
}