commit 0fde6d4a0b9de6375a0a5b4afcfd9bde12d7a26e Author: Sarteambot Admin Date: Wed Mar 4 20:08:34 2026 +0800 feat: 初始化 ChatGPT Team 管理机器人 核心功能: - 实现基于 Telegram Inline Button 交互的后台面板与用户端 - 支持通过账密登录和 RT (Refresh Token) 方式添加 ChatGPT Team 账号 - 支持管理、拉取和删除待处理邀请,支持一键清空多余邀请 - 支持按剩余容量自动生成邀请兑换码,支持分页查看与一键清空未使用兑换码 - 随机邀请功能:成功拉人后自动核销兑换码 - 定时检测 Token 状态,实现自动续订/刷新并拦截封禁账号 (处理 401/402 错误) 系统与配置: - 使用 PostgreSQL 数据库管理账号、邀请和兑换记录 - 支持在端内动态添加、移除管理员 - 完善 Docker 部署配置与 .gitignore 规则 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ceaa6f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# PostgreSQL 连接串 +DATABASE_URL=postgres://postgres:postgres@localhost:5432/teamhelper?sslmode=disable + +# Telegram Bot Token(从 @BotFather 获取) +TELEGRAM_BOT_TOKEN=your-bot-token-here + +# 管理员 Telegram 用户 ID(逗号分隔) +TELEGRAM_ADMIN_IDS=123456789 + +# 可选:代理地址(支持 http/socks5) +# PROXY_URL=socks5://127.0.0.1:1080 + +# Token 定时检测间隔(分钟) +TOKEN_CHECK_INTERVAL=30 + +# Team 容量上限 +TEAM_CAPACITY=5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06df220 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +bin/ + +# Test binary +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env +.env.* +!.env.example + +# environment variables +*.env + +# sqlite data +data/ +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + +# IDE and editor configurations +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Temporary files or config files specific to this app +config.yaml +config.json +logs/ +.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..57beadc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Build stage +FROM golang:1.21-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/go-helper ./cmd + +# Run stage +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata +ENV TZ=Asia/Shanghai + +WORKDIR /app +COPY --from=builder /app/go-helper . +COPY .env.example .env + +ENTRYPOINT ["./go-helper"] diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..bfa1a83 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "log" + + "go-helper/internal/bot" + "go-helper/internal/chatgpt" + "go-helper/internal/config" + "go-helper/internal/database" + "go-helper/internal/scheduler" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + + // Load configuration. + cfg := config.Load() + + // Initialise database. + db := database.New(cfg.DatabaseURL) + defer db.Close() + + // Create ChatGPT API client. + client := chatgpt.NewClient(cfg.ProxyURL) + + // Create OAuth manager (no server needed, uses URL-paste flow). + oauth := chatgpt.NewOAuthManager(client) + + // Start scheduled token checker. + scheduler.StartTokenChecker(db, client, cfg.TokenCheckInterval) + + // Start Telegram bot (blocking). + log.Println("[Main] 启动 Telegram Bot...") + bot.Start(db, cfg, client, oauth) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7bdd499 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module go-helper + +go 1.25.0 + +require ( + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.11.2 + golang.org/x/net v0.51.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8b05789 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= +github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= diff --git a/image.png b/image.png new file mode 100644 index 0000000..80ed137 Binary files /dev/null and b/image.png differ diff --git a/internal/bot/telegram.go b/internal/bot/telegram.go new file mode 100644 index 0000000..91f55b4 --- /dev/null +++ b/internal/bot/telegram.go @@ -0,0 +1,2077 @@ +package bot + +import ( + "fmt" + "log" + "strconv" + "strings" + "sync" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + + "go-helper/internal/chatgpt" + "go-helper/internal/config" + "go-helper/internal/database" + "go-helper/internal/model" + "go-helper/internal/redeem" +) + +// chatSession tracks a two-step flow state per user (redeem or login). +type chatSession struct { + flowType string // "redeem" or "login" + code string // redemption code (for redeem flow) + loginMsgID int // message ID of the login link message (for login flow) +} + +// Bot wraps the Telegram bot with application dependencies. +type Bot struct { + api *tgbotapi.BotAPI + db *database.DB + cfg *config.Config + client *chatgpt.Client + oauth *chatgpt.OAuthManager + sessions map[int64]*chatSession // chatID -> session + panelMsgs map[int64]int // chatID -> last panel message ID + mu sync.Mutex +} + +// Start initialises and runs the Telegram bot (blocking). +func Start(db *database.DB, cfg *config.Config, client *chatgpt.Client, oauth *chatgpt.OAuthManager) { + api, err := tgbotapi.NewBotAPI(cfg.TelegramBotToken) + if err != nil { + log.Fatalf("[Bot] 启动失败: %v", err) + } + log.Printf("[Bot] 已登录: @%s", api.Self.UserName) + + b := &Bot{ + api: api, + db: db, + cfg: cfg, + client: client, + oauth: oauth, + sessions: make(map[int64]*chatSession), + panelMsgs: make(map[int64]int), + } + + // Register commands with Telegram. + b.registerCommands() + + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + updates := api.GetUpdatesChan(u) + + for update := range updates { + if update.CallbackQuery != nil { + go b.handleCallbackQuery(update.CallbackQuery) + continue + } + if update.Message == nil { + continue + } + go b.handleMessage(update.Message) + } +} + +// isAdmin checks both config and database for admin rights. +func (b *Bot) isAdmin(userID int64) bool { + if b.cfg.IsAdmin(userID) { + return true + } + isAdmin, _ := b.db.IsAdmin(userID) + return isAdmin +} + +func (b *Bot) registerCommands() { + // Clear old admin-scoped commands first. + for _, adminID := range b.cfg.TelegramAdminIDs { + delCfg := tgbotapi.NewDeleteMyCommandsWithScope( + tgbotapi.NewBotCommandScopeChat(adminID), + ) + b.api.Request(delCfg) + } + + // Only register /start for all users. + cmds := []tgbotapi.BotCommand{ + {Command: "start", Description: "打开控制面板"}, + } + + cfg := tgbotapi.NewSetMyCommands(cmds...) + if _, err := b.api.Request(cfg); err != nil { + log.Printf("[Bot] 注册命令失败: %v", err) + } + + log.Printf("[Bot] 命令注册完成") +} + +func (b *Bot) handleCallbackQuery(cq *tgbotapi.CallbackQuery) { + chatID := cq.Message.Chat.ID + userID := cq.From.ID + msgID := cq.Message.MessageID + + // Answer the callback to remove the loading indicator. + b.api.Request(tgbotapi.NewCallback(cq.ID, "")) + + switch cq.Data { + case "cmd:back": + b.sendMainMenu(chatID, userID, msgID) + return + case "cmd:cancel": + b.mu.Lock() + _, had := b.sessions[chatID] + delete(b.sessions, chatID) + b.mu.Unlock() + if had { + b.sendMainMenu(chatID, userID, msgID) + } + return + case "cmd:cancel_to_list": + b.mu.Lock() + _, had := b.sessions[chatID] + delete(b.sessions, chatID) + b.mu.Unlock() + if had { + b.callbackListAccounts(chatID, msgID) + } + return + case "cmd:cancel_to_admin": + b.mu.Lock() + _, had := b.sessions[chatID] + delete(b.sessions, chatID) + b.mu.Unlock() + if had { + b.callbackAdminMenu(chatID, msgID, userID) + } + return + case "cmd:stock": + b.callbackStock(chatID, msgID) + return + case "cmd:redeem": + // Start redeem flow: ask for code. + b.mu.Lock() + b.sessions[chatID] = &chatSession{flowType: "redeem_code", loginMsgID: msgID} + b.mu.Unlock() + kb := cancelKeyboard() + b.editMsgWithKeyboard(chatID, msgID, "🎫 *使用兑换码*\n\n请输入兑换码:", &kb) + return + case "cmd:random_invite": + if !b.isAdmin(userID) { + return + } + b.mu.Lock() + b.sessions[chatID] = &chatSession{flowType: "random_invite", loginMsgID: msgID} + b.mu.Unlock() + kb := cancelKeyboard() + b.editMsgWithKeyboard(chatID, msgID, "🎲 *随机拉人*\n\n请输入要邀请的用户邮箱:", &kb) + return + case "cmd:add_admin": + // Only "super admins" (in config) can add admins dynamically. + if !b.cfg.IsAdmin(userID) { + b.sendAutoDelete(chatID, "❌ 权限不足:只有配置文件中的超级管理员才能添加新管理员。") + return + } + b.mu.Lock() + b.sessions[chatID] = &chatSession{flowType: "add_admin", loginMsgID: msgID} + b.mu.Unlock() + kb := cancelToAdminKeyboard() + b.editMsgWithKeyboard(chatID, msgID, "👑 *添加管理员*\n\n请输入要添加的 Telegram 用户 ID:", &kb) + return + } + + // All other buttons are admin-only. + if !b.isAdmin(userID) { + return + } + + switch cq.Data { + case "cmd:admin_menu": + b.callbackAdminMenu(chatID, msgID, userID) + case "cmd:list_accounts": + b.callbackListAccounts(chatID, msgID) + case "cmd:status": + b.callbackStatus(chatID, msgID) + case "cmd:refresh_all": + b.callbackRefresh(chatID, msgID) + case "cmd:check_sub_all": + b.callbackCheckSub(chatID, msgID) + case "cmd:list_invites_all": + b.callbackListInvites(chatID, msgID) + case "cmd:codes_menu": + b.callbackCodesMenu(chatID, msgID) + case "cmd:clear_unused_codes": + count, err := b.db.DeleteUnusedCodes() + msg := "" + if err != nil { + msg = fmt.Sprintf("❌ 清空失败: %v", err) + } else { + msg = fmt.Sprintf("✅ 已成功清空 %d 个未使用的兑换码", count) + } + kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "cmd:codes_menu"))) + b.editMsgWithKeyboard(chatID, msgID, msg, &kb) + case "cmd:list_codes_all": + b.callbackListCodes(chatID, msgID, 0) + case "cmd:gen_codes": + b.mu.Lock() + b.sessions[chatID] = &chatSession{flowType: "gen_codes_accounts", loginMsgID: msgID} + b.mu.Unlock() + kb := cancelKeyboard() + b.editMsgWithKeyboard(chatID, msgID, "➕ *生成兑换码*\n\n请输入要生成的账号数量:", &kb) + case "cmd:login": + b.handleLogin(chatID, msgID) + case "cmd:add_rt": + b.mu.Lock() + b.sessions[chatID] = &chatSession{flowType: "add_rt", loginMsgID: msgID} + b.mu.Unlock() + kb := cancelToListKeyboard() + b.editMsgWithKeyboard(chatID, msgID, "🔑 *RT添加账号*\n\n请输入 Refresh Token:", &kb) + case "act:del_pick": + b.callbackActionPick(chatID, msgID, "del", userID, 0) + case "act:ref_pick": + b.callbackActionPick(chatID, msgID, "ref", userID, 0) + case "act:pending_invites_pick": + b.callbackActionPick(chatID, msgID, "pending_invite", userID, 0) + case "act:remove_admin_pick": + // Only super admins can remove admins + if !b.cfg.IsAdmin(userID) { + b.sendAutoDelete(chatID, "❌ 权限不足:只有超级管理员才能移除管理员。") + return + } + b.callbackActionPick(chatID, msgID, "deladmin", userID, 0) + default: + // Dynamic callbacks: del:, ref:, codes_page:, deladmin:, pending_invite:, delinv::, act_page:: + if strings.HasPrefix(cq.Data, "act_page:") { + parts := strings.SplitN(strings.TrimPrefix(cq.Data, "act_page:"), ":", 2) + if len(parts) == 2 { + action := parts[0] + page, _ := strconv.Atoi(parts[1]) + b.callbackActionPick(chatID, msgID, action, userID, page) + } + } else if strings.HasPrefix(cq.Data, "del:") { + b.callbackDelAccount(chatID, msgID, strings.TrimPrefix(cq.Data, "del:")) + } else if strings.HasPrefix(cq.Data, "ref:") { + b.callbackRefAccount(chatID, msgID, strings.TrimPrefix(cq.Data, "ref:")) + } else if strings.HasPrefix(cq.Data, "deladmin:") { + if !b.cfg.IsAdmin(userID) { + return + } + b.callbackDelAdmin(chatID, msgID, strings.TrimPrefix(cq.Data, "deladmin:")) + } else if strings.HasPrefix(cq.Data, "codes_page:") { + page, _ := strconv.Atoi(strings.TrimPrefix(cq.Data, "codes_page:")) + b.callbackListCodes(chatID, msgID, page) + } else if strings.HasPrefix(cq.Data, "pending_invite:") { + b.callbackListPendingInvites(chatID, msgID, strings.TrimPrefix(cq.Data, "pending_invite:")) + } else if strings.HasPrefix(cq.Data, "delinv:") { + b.callbackDelInvite(chatID, msgID, strings.TrimPrefix(cq.Data, "delinv:")) + } else if strings.HasPrefix(cq.Data, "delcode:") { + parts := strings.SplitN(strings.TrimPrefix(cq.Data, "delcode:"), ":", 2) + if len(parts) == 2 { + page, _ := strconv.Atoi(parts[0]) + codeStr := parts[1] + b.callbackDelCode(chatID, msgID, page, codeStr) + } + } + } +} + +// sendMainMenu sends or edits the main interactive panel. +func (b *Bot) callbackAdminMenu(chatID int64, msgID int, userID int64) { + admins, err := b.db.GetAllAdmins() + if err != nil { + kb := backButton() + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 查询失败: %v", err), &kb) + return + } + + text := "👑 *管理员管理*\n\n" + if len(admins) == 0 { + text += "暂无数据库记录的管理员 (仅配置项生效)。" + } else { + for i, a := range admins { + text += fmt.Sprintf("*%d.* ID: `%d` (由 `%d` 于 %s 添加)\n", i+1, a.UserID, a.AddedBy, formatDate(a.CreatedAt.Format(time.RFC3339))) + } + } + + var kbrows [][]tgbotapi.InlineKeyboardButton + + // If the user is a super admin (in config), they get the add/remove options. + if b.cfg.IsAdmin(userID) { + kbrows = append(kbrows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("➕ 添加管理员", "cmd:add_admin"), + tgbotapi.NewInlineKeyboardButtonData("➖ 移除管理员", "act:remove_admin_pick"), + )) + } + + // Everyone gets the back button. + kbrows = append(kbrows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "cmd:back"), + )) + + kb := tgbotapi.InlineKeyboardMarkup{InlineKeyboard: kbrows} + b.editMsgWithKeyboard(chatID, msgID, text, &kb) +} + +func (b *Bot) callbackDelAdmin(chatID int64, msgID int, idStr string) { + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return + } + + if err := b.db.RemoveAdmin(id); err != nil { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回列表", "cmd:admin_menu"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 移除失败: %v", err), &kb) + return + } + + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回列表", "cmd:admin_menu"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("✅ 已移除管理员: `%d`", id), &kb) +} + +func (b *Bot) sendMainMenu(chatID int64, userID int64, editMsgID int) { + caption := "🤖 *ChatGPT Team Helper*\n_Unleashing Team Collaboration Powered by AI_" + + var keyboard tgbotapi.InlineKeyboardMarkup + if b.isAdmin(userID) { + // Build status summary for admin caption. + accounts, _ := b.db.GetAllAccounts() + totalAccounts := len(accounts) + openAccounts, bannedAccounts, totalUsers, totalInvites := 0, 0, 0, 0 + for _, a := range accounts { + if a.IsOpen && !a.IsBanned { + openAccounts++ + } + if a.IsBanned { + bannedAccounts++ + } + totalUsers += a.UserCount + totalInvites += a.InviteCount + } + codeCount, _ := b.db.CountAvailableCodes() + + caption += fmt.Sprintf("\n\n⚙️ *系统状态*\n"+ + "👥 账号: *%d* (开放 %d / 封号 %d)\n"+ + "🎫 可用兑换码: *%d*", + totalAccounts, openAccounts, bannedAccounts, + codeCount) + + keyboard = tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("📝 账号列表", "cmd:list_accounts"), + tgbotapi.NewInlineKeyboardButtonData("📩 待进入邀请", "act:pending_invites_pick"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🎫 兑换码", "cmd:codes_menu"), + tgbotapi.NewInlineKeyboardButtonData("🎲 随机拉人", "cmd:random_invite"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("👑 管理员管理", "cmd:admin_menu"), + ), + ) + } else { + keyboard = tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🎫 使用兑换码", "cmd:redeem"), + ), + ) + } + + if editMsgID != 0 { + b.editMsgWithKeyboard(chatID, editMsgID, caption, &keyboard) + b.mu.Lock() + b.panelMsgs[chatID] = editMsgID + b.mu.Unlock() + } else { + // Send image.png as photo with buttons. + photo := tgbotapi.NewPhoto(chatID, tgbotapi.FilePath("image.png")) + photo.Caption = caption + photo.ParseMode = "Markdown" + photo.ReplyMarkup = keyboard + if sent, err := b.api.Send(photo); err != nil { + // Fallback to text if image not found. + msg := tgbotapi.NewMessage(chatID, caption) + msg.ParseMode = "Markdown" + msg.ReplyMarkup = keyboard + if sent2, err2 := b.api.Send(msg); err2 == nil { + b.mu.Lock() + b.panelMsgs[chatID] = sent2.MessageID + b.mu.Unlock() + } + } else { + b.mu.Lock() + b.panelMsgs[chatID] = sent.MessageID + b.mu.Unlock() + } + } +} + +func backButton() tgbotapi.InlineKeyboardMarkup { + return tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "cmd:back"), + ), + ) +} + +func cancelKeyboard() tgbotapi.InlineKeyboardMarkup { + return tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("❌ 取消", "cmd:cancel"), + ), + ) +} + +func cancelToListKeyboard() tgbotapi.InlineKeyboardMarkup { + return tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("❌ 取消", "cmd:cancel_to_list"), + ), + ) +} + +func cancelToAdminKeyboard() tgbotapi.InlineKeyboardMarkup { + return tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("❌ 取消", "cmd:cancel_to_admin"), + ), + ) +} + +func (b *Bot) callbackStock(chatID int64, msgID int) { + count, err := b.db.CountAvailableCodes() + if err != nil { + kb := backButton() + b.editMsgWithKeyboard(chatID, msgID, "❌ 查询库存失败", &kb) + return + } + kb := backButton() + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("📦 当前可用兑换码: *%d* 个", count), &kb) +} + +func (b *Bot) callbackListAccounts(chatID int64, msgID int) { + accounts, err := b.db.GetAllAccounts() + if err != nil { + kb := backButton() + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 查询失败: %v", err), &kb) + return + } + if len(accounts) == 0 { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🔗 登录添加", "cmd:login"), + tgbotapi.NewInlineKeyboardButtonData("🔑 RT添加", "cmd:add_rt"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, "📋 暂无账号\n\n请添加账号:", &kb) + return + } + + // Limit to first 10. + shown := accounts + if len(shown) > 10 { + shown = shown[:10] + } + + var lines []string + for _, a := range shown { + status := "✅" + if a.IsBanned { + status = "🚫" + } else if !a.IsOpen { + status = "⏸" + } + codeCount, _ := b.db.CountAvailableCodesByAccount(a.Email) + expInfo := "" + if a.ExpireAt != "" { + expInfo = fmt.Sprintf(" | 📅 %s", formatDate(a.ExpireAt)) + } + invInfo := "" + if a.InviteCount > 0 { + invInfo = fmt.Sprintf(" (+%d待入)", a.InviteCount) + } + accIDShort := a.ChatgptAccountID + if len(accIDShort) > 8 { + accIDShort = accIDShort[:8] + } + lines = append(lines, fmt.Sprintf( + "%s `%s` | 👥 %d%s | 🎫 %d码%s", + status, a.Email, a.UserCount, invInfo, + codeCount, expInfo)) + } + + text := fmt.Sprintf("📋 *账号列表* (%d 个):\n\n%s", len(accounts), strings.Join(lines, "\n")) + if len(accounts) > 10 { + text += fmt.Sprintf("\n\n_仅显示前 10 个,共 %d 个_", len(accounts)) + } + + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🗑 删除账号", "act:del_pick"), + tgbotapi.NewInlineKeyboardButtonData("🔄 刷新账号", "act:ref_pick"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🔄 刷新全部", "cmd:refresh_all"), + tgbotapi.NewInlineKeyboardButtonData("📅 查订阅", "cmd:check_sub_all"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🔗 登录添加", "cmd:login"), + tgbotapi.NewInlineKeyboardButtonData("🔑 RT添加", "cmd:add_rt"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, text, &kb) +} + +// callbackActionPick shows numbered buttons for selecting an account for delete or refresh. +func (b *Bot) callbackActionPick(chatID int64, msgID int, action string, userID int64, page int) { + accounts, err := b.db.GetAllAccounts() + if err != nil || len(accounts) == 0 { + kb := backButton() + b.editMsgWithKeyboard(chatID, msgID, "❌ 暂无账号", &kb) + return + } + + label := "📧 选择要删除的账号" + var backCallback string + + // deladmin has its own simple logic without pagination + if action == "deladmin" { + label = "👑 选择要移除的管理员" + backCallback = "cmd:admin_menu" + + admins, err := b.db.GetAllAdmins() + if err != nil || len(admins) == 0 { + kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "cmd:admin_menu"))) + b.editMsgWithKeyboard(chatID, msgID, "❌ 暂无动态添加的管理员", &kb) + return + } + + shownAdmins := admins + if len(shownAdmins) > 10 { + shownAdmins = shownAdmins[:10] + } + + var lns []string + var rws [][]tgbotapi.InlineKeyboardButton + var rBtns []tgbotapi.InlineKeyboardButton + for i, a := range shownAdmins { + lns = append(lns, fmt.Sprintf("*%d.* ID: `%d`", i+1, a.UserID)) + rBtns = append(rBtns, tgbotapi.NewInlineKeyboardButtonData( + fmt.Sprintf("%d", i+1), + fmt.Sprintf("%s:%d", action, a.UserID), + )) + if len(rBtns) == 5 { + rws = append(rws, tgbotapi.NewInlineKeyboardRow(rBtns...)) + rBtns = nil + } + } + if len(rBtns) > 0 { + rws = append(rws, tgbotapi.NewInlineKeyboardRow(rBtns...)) + } + rws = append(rws, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回列表", backCallback), + )) + + kba := tgbotapi.InlineKeyboardMarkup{InlineKeyboard: rws} + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("%s:\n\n%s", label, strings.Join(lns, "\n")), &kba) + return + } + + backCallback = "cmd:list_accounts" + prefix := "📧" + + if action == "del" { + label = "🗑️ 选择要删除的账号" + prefix = "🗑" + } else if action == "ref" { + label = "🔄 选择要刷新的账号" + prefix = "🔄" + } else if action == "pending_invite" { + label = "📩 选择管理账户" + prefix = "📧" + backCallback = "cmd:back" + } + + // Pagination parameters + pageSize := 8 + total := len(accounts) + totalPages := (total + pageSize - 1) / pageSize + if page < 0 { + page = 0 + } + if page >= totalPages && totalPages > 0 { + page = totalPages - 1 + } + + start := page * pageSize + end := start + pageSize + if end > total { + end = total + } + shown := accounts[start:end] + + var rows [][]tgbotapi.InlineKeyboardButton + + // Create a button map + for _, a := range shown { + btnAction := fmt.Sprintf("%s:%d", action, a.ID) + + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData( + fmt.Sprintf("%s %s", prefix, a.Email), + btnAction, + ), + )) + } + + // Pagination buttons + var navRow []tgbotapi.InlineKeyboardButton + if page > 0 { + navRow = append(navRow, tgbotapi.NewInlineKeyboardButtonData("⬅️ 上一页", fmt.Sprintf("act_page:%s:%d", action, page-1))) + } + if page < totalPages-1 { + navRow = append(navRow, tgbotapi.NewInlineKeyboardButtonData("下一页 ➡️", fmt.Sprintf("act_page:%s:%d", action, page+1))) + } + if len(navRow) > 0 { + rows = append(rows, tgbotapi.NewInlineKeyboardRow(navRow...)) + } + + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", backCallback), + )) + + kb := tgbotapi.InlineKeyboardMarkup{InlineKeyboard: rows} + + pageInfo := "" + if totalPages > 1 { + pageInfo = fmt.Sprintf("\n(第 %d/%d 页)", page+1, totalPages) + } + + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("%s%s:", label, pageInfo), &kb) +} + +func (b *Bot) callbackDelAccount(chatID int64, msgID int, idStr string) { + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return + } + + acct, err := b.db.GetAccountByID(id) + if err != nil { + kb := backButton() + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 账号不存在: %v", err), &kb) + return + } + + if err := b.db.DeleteAccount(id); err != nil { + kb := backButton() + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 删除失败: %v", err), &kb) + return + } + + // Show result then go back to list. + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回列表", "cmd:list_accounts"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("✅ 已删除账号 ID=%d (%s)\n关联的兑换码也已清除", id, acct.Email), &kb) +} + +func (b *Bot) callbackRefAccount(chatID int64, msgID int, idStr string) { + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return + } + + acc, err := b.db.GetAccountByID(id) + if err != nil { + kb := backButton() + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 账号不存在: %v", err), &kb) + return + } + if acc.RefreshToken == "" { + kb := backButton() + b.editMsgWithKeyboard(chatID, msgID, "❌ 该账号未配置 Refresh Token", &kb) + return + } + + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("⏳ 正在刷新 ID=%d...", id), nil) + + result, err := b.client.RefreshAccessToken(acc.RefreshToken) + if err != nil { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回列表", "cmd:list_accounts"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 刷新失败: %s", err.Error()), &kb) + return + } + + _ = b.db.UpdateAccountTokens(id, result.AccessToken, result.RefreshToken) + + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回列表", "cmd:list_accounts"), + ), + ) + + msg := fmt.Sprintf("✅ 已刷新账号 ID=%d (%s)", id, acc.Email) + b.editMsgWithKeyboard(chatID, msgID, msg, &kb) +} + +func (b *Bot) callbackStatus(chatID int64, msgID int) { + accounts, err := b.db.GetAllAccounts() + if err != nil { + kb := backButton() + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 查询失败: %v", err), &kb) + return + } + + totalAccounts := len(accounts) + openAccounts := 0 + bannedAccounts := 0 + totalUsers := 0 + totalInvites := 0 + for _, a := range accounts { + if a.IsOpen && !a.IsBanned { + openAccounts++ + } + if a.IsBanned { + bannedAccounts++ + } + totalUsers += a.UserCount + totalInvites += a.InviteCount + } + + codeCount, _ := b.db.CountAvailableCodes() + + text := fmt.Sprintf("📊 *系统状态:*\n\n"+ + "👥 账号总数: *%d* (开放 %d / 封号 %d)\n"+ + "👤 总用户数: *%d* / 总邀请: *%d*\n"+ + "🎫 可用兑换码: *%d*", + totalAccounts, openAccounts, bannedAccounts, + totalUsers, totalInvites, codeCount) + + kb := backButton() + b.editMsgWithKeyboard(chatID, msgID, text, &kb) +} + +func (b *Bot) callbackRefresh(chatID int64, msgID int) { + kb := backButton() + b.editMsgWithKeyboard(chatID, msgID, "⏳ 正在刷新全部 Token...", nil) + + accs, err := b.db.GetAccountsWithRT() + if err != nil || len(accs) == 0 { + b.editMsgWithKeyboard(chatID, msgID, "❌ 没有可刷新的账号", &kb) + return + } + + ok, fail := 0, 0 + for _, a := range accs { + result, err := b.client.RefreshAccessToken(a.RefreshToken) + if err != nil { + fail++ + continue + } + if err := b.db.UpdateAccountTokens(a.ID, result.AccessToken, result.RefreshToken); err != nil { + fail++ + continue + } + // Sync member counts after token refresh. + a.Token = result.AccessToken + if userTotal, _, err2 := b.client.GetUsers(&a); err2 == nil { + invTotal := a.InviteCount + if inv, _, err3 := b.client.GetInvites(&a); err3 == nil { + invTotal = inv + } + _ = b.db.UpdateAccountCounts(a.ID, userTotal, invTotal) + } + ok++ + } + + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("✅ 刷新完成: 成功 %d, 失败 %d", ok, fail), &kb) +} + +func (b *Bot) callbackCheckSub(chatID int64, msgID int) { + kb := backButton() + accs, err := b.db.GetAllAccounts() + if err != nil || len(accs) == 0 { + b.editMsgWithKeyboard(chatID, msgID, "📋 暂无账号", &kb) + return + } + + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("⏳ 正在查询 %d 个账号的订阅信息...", len(accs)), nil) + + var lines []string + for _, acc := range accs { + if acc.Token == "" { + lines = append(lines, fmt.Sprintf("⚠️ ID=%d %s: 无 Token", acc.ID, acc.Email)) + continue + } + infos, err := b.client.FetchAccountInfo(acc.Token) + if err != nil { + lines = append(lines, fmt.Sprintf("❌ ID=%d %s: %s", acc.ID, acc.Email, err.Error())) + continue + } + for _, info := range infos { + subStatus := "❌ 无有效订阅" + if info.HasActiveSubscription { + subStatus = "✅ 订阅有效" + } + expiry := formatDate(info.ExpiresAt) + if info.ExpiresAt == "" { + expiry = "未知" + } + lines = append(lines, fmt.Sprintf( + "`%d` %s\n %s | 📅 到期: %s | %s", + acc.ID, acc.Email, info.Name, expiry, subStatus)) + if info.ExpiresAt != "" && info.AccountID == acc.ChatgptAccountID { + _ = b.db.UpdateAccountInfo(acc.ID, acc.ChatgptAccountID, info.ExpiresAt) + } + } + } + + b.editMsgWithKeyboard(chatID, msgID, "📋 *订阅信息:*\n\n"+strings.Join(lines, "\n\n"), &kb) +} + +func (b *Bot) callbackListInvites(chatID int64, msgID int) { + kb := backButton() + accs, err := b.db.GetAllAccounts() + if err != nil || len(accs) == 0 { + b.editMsgWithKeyboard(chatID, msgID, "📋 暂无账号", &kb) + return + } + + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("⏳ 正在查询 %d 个账号的待进入邀请...", len(accs)), nil) + + var lines []string + totalInvites := 0 + for _, acc := range accs { + if acc.Token == "" { + continue + } + _, invites, err := b.client.GetInvites(&acc) + if err != nil { + lines = append(lines, fmt.Sprintf("❌ ID=%d %s: %s", acc.ID, acc.Email, err.Error())) + continue + } + if len(invites) == 0 { + continue + } + totalInvites += len(invites) + var inviteLines []string + for _, inv := range invites { + inviteLines = append(inviteLines, fmt.Sprintf(" 📧 `%s` (%s)", inv.EmailAddress, inv.Role)) + } + lines = append(lines, fmt.Sprintf("*ID=%d* %s (%d 个):\n%s", + acc.ID, acc.Email, len(invites), strings.Join(inviteLines, "\n"))) + } + + if totalInvites == 0 { + b.editMsgWithKeyboard(chatID, msgID, "📭 暂无待进入的邀请", &kb) + return + } + + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("📧 *待进入邀请* (%d 个):\n\n%s", + totalInvites, strings.Join(lines, "\n\n")), &kb) +} + +func (b *Bot) callbackListCodes(chatID int64, msgID int, page int) { + const pageSize = 10 + + codes, err := b.db.GetAllCodes() + if err != nil { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "cmd:codes_menu"), + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 查询失败: %v", err), &kb) + return + } + if len(codes) == 0 { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "cmd:codes_menu"), + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, "📭 暂无兑换码", &kb) + return + } + + totalPages := (len(codes) + pageSize - 1) / pageSize + if page < 0 { + page = 0 + } + if page >= totalPages { + page = totalPages - 1 + } + + start := page * pageSize + end := start + pageSize + if end > len(codes) { + end = len(codes) + } + + var lines []string + var codeRows [][]tgbotapi.InlineKeyboardButton + var codeBtns []tgbotapi.InlineKeyboardButton + + for i, c := range codes[start:end] { + status := "🟢" + extra := "" + if c.IsRedeemed { + status = "🔴" + by := "" + if c.RedeemedBy != nil && *c.RedeemedBy != "" { + by = *c.RedeemedBy + } + extra = fmt.Sprintf(" → %s", by) + } else { + codeBtns = append(codeBtns, tgbotapi.NewInlineKeyboardButtonData( + fmt.Sprintf("🗑️ %d", i+1), + fmt.Sprintf("delcode:%d:%s", page, c.Code), + )) + if len(codeBtns) == 5 { + codeRows = append(codeRows, tgbotapi.NewInlineKeyboardRow(codeBtns...)) + codeBtns = nil + } + } + lines = append(lines, fmt.Sprintf("%d. %s `%s` (%s)%s", i+1, status, c.Code, c.AccountEmail, extra)) + } + if len(codeBtns) > 0 { + codeRows = append(codeRows, tgbotapi.NewInlineKeyboardRow(codeBtns...)) + } + + // Build keyboard with pagination buttons. + var rows [][]tgbotapi.InlineKeyboardButton + rows = append(rows, codeRows...) + + if totalPages > 1 { + var navBtns []tgbotapi.InlineKeyboardButton + if page > 0 { + navBtns = append(navBtns, tgbotapi.NewInlineKeyboardButtonData("⬅️ 上一页", fmt.Sprintf("codes_page:%d", page-1))) + } + navBtns = append(navBtns, tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("%d/%d", page+1, totalPages), "noop")) + if page < totalPages-1 { + navBtns = append(navBtns, tgbotapi.NewInlineKeyboardButtonData("➡️ 下一页", fmt.Sprintf("codes_page:%d", page+1))) + } + rows = append(rows, navBtns) + } + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "cmd:codes_menu"), + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + )) + kb := tgbotapi.InlineKeyboardMarkup{InlineKeyboard: rows} + + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("🎫 *兑换码列表* (%d 个):\n\n%s", len(codes), strings.Join(lines, "\n")), &kb) +} + +func (b *Bot) callbackDelCode(chatID int64, msgID int, page int, code string) { + _ = b.db.DeleteCode(code) + b.callbackListCodes(chatID, msgID, page) +} + +func (b *Bot) callbackCodesMenu(chatID int64, msgID int) { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("📋 查看兑换码", "cmd:list_codes_all"), + tgbotapi.NewInlineKeyboardButtonData("➕ 生成兑换码", "cmd:gen_codes"), + ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🗑️ 一键清空未使用", "cmd:clear_unused_codes"), + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, "🎫 *兑换码管理*\n\n请选择操作:", &kb) +} + +func (b *Bot) handleMessage(msg *tgbotapi.Message) { + chatID := msg.Chat.ID + userID := msg.From.ID + text := strings.TrimSpace(msg.Text) + + // Check two-step session flows (redeem or login). + b.mu.Lock() + sess, hasSess := b.sessions[chatID] + b.mu.Unlock() + + if hasSess && !strings.HasPrefix(text, "/") { + switch sess.flowType { + case "redeem_code": + b.handleRedeemCode(chatID, text) + case "redeem": + b.handleRedeemEmail(chatID, sess, text) + case "login": + b.handleLoginCallback(chatID, msg.MessageID, text) + case "random_invite": + b.mu.Lock() + panelMsgID := sess.loginMsgID + delete(b.sessions, chatID) + b.mu.Unlock() + b.deleteMsg(chatID, msg.MessageID) + // Inline random invite logic. + email := strings.ToLower(strings.TrimSpace(text)) + if email == "" { + return + } + accounts, err := b.db.GetOpenAccounts(b.cfg.TeamCapacity) + if err != nil || len(accounts) == 0 { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, panelMsgID, "❌ 暂无可用的空位账号", &kb) + return + } + account := &accounts[0] + b.editMsgWithKeyboard(chatID, panelMsgID, fmt.Sprintf("⏳ 正在邀请到账号 ID=%d (%s)...", account.ID, account.Email), nil) + if err := b.client.InviteUser(email, account); err != nil { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, panelMsgID, fmt.Sprintf("❌ 邀请失败: %s", err.Error()), &kb) + return + } + time.Sleep(time.Second) + syncCounts(b.db, b.client, account) + // Consume one unused redemption code for this account. + if codes, err := b.db.GetCodesByAccount(account.Email); err == nil { + for _, c := range codes { + if !c.IsRedeemed { + _ = b.db.RedeemCode(c.ID, email) + break + } + } + } + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, panelMsgID, fmt.Sprintf("✅ 已成功邀请 `%s` 到账号 ID=%d (%s)", email, account.ID, account.Email), &kb) + case "gen_codes_accounts": + n, err := strconv.Atoi(strings.TrimSpace(text)) + if err != nil || n <= 0 || n > 20 { + b.sendAutoDelete(chatID, "❌ 请输入1-20的整数") + return + } + b.mu.Lock() + panelMsgID := sess.loginMsgID + delete(b.sessions, chatID) + b.mu.Unlock() + b.deleteMsg(chatID, msg.MessageID) + b.handleGenCodes(chatID, panelMsgID, strconv.Itoa(n)) + case "add_rt": + rt := strings.TrimSpace(text) + b.mu.Lock() + panelMsgID := sess.loginMsgID + delete(b.sessions, chatID) + b.mu.Unlock() + b.deleteMsg(chatID, msg.MessageID) + b.handleAddAccount(chatID, panelMsgID, rt) + case "add_admin": + idStr := strings.TrimSpace(text) + b.mu.Lock() + panelMsgID := sess.loginMsgID + delete(b.sessions, chatID) + b.mu.Unlock() + b.deleteMsg(chatID, msg.MessageID) + b.handleAddAdminMsg(chatID, panelMsgID, idStr, userID) + } + return + } + + if !msg.IsCommand() { + return + } + + cmd := msg.Command() + args := strings.TrimSpace(msg.CommandArguments()) + + // Handle /cancel for any active session. + if cmd == "cancel" { + b.deleteMsg(chatID, msg.MessageID) // delete user's /cancel immediately + b.mu.Lock() + sess, had := b.sessions[chatID] + var panelMsgID int + if had { + panelMsgID = sess.loginMsgID + } + delete(b.sessions, chatID) + b.mu.Unlock() + if had { + if panelMsgID != 0 { + b.sendMainMenu(chatID, userID, panelMsgID) + } else { + replyID := b.sendAndGetID(chatID, "❎ 已取消操作") + go func() { + time.Sleep(20 * time.Second) + b.deleteMsg(chatID, replyID) + }() + } + } + return + } + + switch cmd { + case "start": + b.sendMainMenu(chatID, userID, 0) + case "redeem": + b.handleRedeemStart(chatID, args) + case "add_account": + b.requireAdmin(chatID, userID, func() { b.handleAddAccount(chatID, 0, args) }) + case "gen_codes": + b.requireAdmin(chatID, userID, func() { b.handleGenCodes(chatID, 0, args) }) + case "add_admin": // Super admin only + if b.cfg.IsAdmin(userID) { + b.handleAddAdmin(chatID, userID, args) + } else { + b.sendAutoDelete(chatID, "❌ 权限不足:只有配置文件中的超级管理员才能添加新管理员。") + } + case "login": + b.requireAdmin(chatID, userID, func() { b.handleLogin(chatID, 0) }) + } +} + +// ─── User Commands ────────────────────────────────────────── + +func (b *Bot) handleRedeemStart(chatID int64, args string) { + code := strings.TrimSpace(strings.ToUpper(args)) + if code == "" { + // No code provided: start interactive flow. + b.mu.Lock() + b.sessions[chatID] = &chatSession{flowType: "redeem_code"} + b.mu.Unlock() + b.send(chatID, "🎫 请输入兑换码:") + return + } + + // Code provided directly: validate and proceed. + b.handleRedeemCode(chatID, code) +} + +func (b *Bot) handleRedeemCode(chatID int64, input string) { + code := strings.TrimSpace(strings.ToUpper(input)) + + // Get the panel message ID from the session. + b.mu.Lock() + panelMsgID := 0 + if s, ok := b.sessions[chatID]; ok { + panelMsgID = s.loginMsgID + } + b.mu.Unlock() + + backKb := backButton() + + // Validate code exists and is unused before asking for email. + rc, err := b.db.GetCodeByCode(code) + if err != nil { + b.mu.Lock() + delete(b.sessions, chatID) + b.mu.Unlock() + if panelMsgID != 0 { + b.editMsgWithKeyboard(chatID, panelMsgID, "❌ 兑换码不存在或已失效", &backKb) + } else { + b.sendAutoDelete(chatID, "❌ 兑换码不存在或已失效") + } + return + } + if rc.IsRedeemed { + b.mu.Lock() + delete(b.sessions, chatID) + b.mu.Unlock() + if panelMsgID != 0 { + b.editMsgWithKeyboard(chatID, panelMsgID, "❌ 该兑换码已被使用", &backKb) + } else { + b.sendAutoDelete(chatID, "❌ 该兑换码已被使用") + } + return + } + + b.mu.Lock() + b.sessions[chatID] = &chatSession{flowType: "redeem", code: code, loginMsgID: panelMsgID} + b.mu.Unlock() + + if panelMsgID != 0 { + kb := cancelKeyboard() + b.editMsgWithKeyboard(chatID, panelMsgID, "✅ 兑换码有效!请输入您的邮箱地址:", &kb) + } else { + b.send(chatID, "✅ 兑换码有效!请输入您的邮箱地址:") + } +} + +func (b *Bot) handleRedeemEmail(chatID int64, sess *chatSession, email string) { + panelMsgID := sess.loginMsgID + b.mu.Lock() + delete(b.sessions, chatID) + b.mu.Unlock() + + backKb := backButton() + + if panelMsgID != 0 { + b.editMsgWithKeyboard(chatID, panelMsgID, "⏳ 正在处理兑换,请稍候...", nil) + + result, err := redeem.Redeem(b.db, b.client, sess.code, email, b.cfg.TeamCapacity) + if err != nil { + b.editMsgWithKeyboard(chatID, panelMsgID, fmt.Sprintf("❌ 兑换失败: %s", err.Error()), &backKb) + return + } + b.editMsgWithKeyboard(chatID, panelMsgID, fmt.Sprintf("🎉 %s", result.Message), &backKb) + } else { + msgID := b.sendAndGetID(chatID, "⏳ 正在处理兑换,请稍候...") + + result, err := redeem.Redeem(b.db, b.client, sess.code, email, b.cfg.TeamCapacity) + if err != nil { + b.editMsg(chatID, msgID, fmt.Sprintf("❌ 兑换失败: %s", err.Error())) + return + } + b.editMsg(chatID, msgID, fmt.Sprintf("🎉 %s", result.Message)) + } +} + +func (b *Bot) handleStock(chatID int64) { + count, err := b.db.CountAvailableCodes() + if err != nil { + b.sendAutoDelete(chatID, "❌ 查询库存失败") + return + } + b.send(chatID, fmt.Sprintf("📦 当前可用兑换码: *%d* 个", count)) +} + +// ─── Admin Commands ───────────────────────────────────────── + +func (b *Bot) handleAddAccount(chatID int64, panelMsgID int, args string) { + rt := strings.TrimSpace(args) + if rt == "" { + b.sendAutoDelete(chatID, "❌ 用法: /add\\_account ``") + return + } + + // If panelMsgID is provided, show progress on the panel; otherwise send a new message. + msgID := panelMsgID + if msgID == 0 { + msgID = b.sendAndGetID(chatID, "⏳ 正在刷新 Token 并获取账号信息...") + } else { + b.editMsgWithKeyboard(chatID, msgID, "⏳ 正在刷新 Token 并获取账号信息...", nil) + } + + // 1. Refresh to get access token. + tokenResult, err := b.client.RefreshAccessToken(rt) + if err != nil { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回列表", "cmd:list_accounts"), + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + if panelMsgID != 0 { + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ Refresh Token 无效: %s", err.Error()), &kb) + } else { + b.editMsg(chatID, msgID, fmt.Sprintf("❌ Refresh Token 无效: %s", err.Error())) + } + return + } + + // 2. Fetch account info. + infos, err := b.client.FetchAccountInfo(tokenResult.AccessToken) + if err != nil { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回列表", "cmd:list_accounts"), + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + if panelMsgID != 0 { + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 获取账号信息失败: %s", err.Error()), &kb) + } else { + b.editMsg(chatID, msgID, fmt.Sprintf("❌ 获取账号信息失败: %s", err.Error())) + } + return + } + + email := tokenResult.GetEmail() + + // 3. Create DB records for each team account found. + var lines []string + for _, info := range infos { + accountEmail := email + if accountEmail == "" { + accountEmail = info.Name // Fallback + } + + // Check for duplicate by OpenAI Account ID (most reliable) + if existing, err2 := b.db.GetAccountByChatGPTAccountID(info.AccountID); err2 == nil && existing != nil { + lines = append(lines, fmt.Sprintf("⚠️ %s (%s): 已存在 (ID=%d),跳过", info.Name, accountEmail, existing.ID)) + continue + } + + id, err := b.db.CreateAccount(&model.GptAccount{ + Email: accountEmail, + Token: tokenResult.AccessToken, + RefreshToken: tokenResult.RefreshToken, + ChatgptAccountID: info.AccountID, + ExpireAt: info.ExpiresAt, + IsOpen: true, + }) + if err != nil { + lines = append(lines, fmt.Sprintf("❌ %s (%s): 创建失败 - %v", info.Name, accountEmail, err)) + continue + } + + // Generate codes based on remaining capacity: 6 - (current user count + pending invites). + newAcct, _ := b.db.GetAccountByID(id) + codeCount := 6 + if newAcct != nil { + var userTotal, inviteTotal int + if ut, _, err2 := b.client.GetUsers(newAcct); err2 == nil { + userTotal = ut + } + if it, _, err3 := b.client.GetInvites(newAcct); err3 == nil { + inviteTotal = it + } + codeCount = 6 - (userTotal + inviteTotal) + _ = b.db.UpdateAccountCounts(id, userTotal, inviteTotal) + } + if codeCount < 0 { + codeCount = 0 + } + codes := redeem.GenerateCodes(codeCount) + if err := b.db.CreateCodes(accountEmail, codes); err != nil { + lines = append(lines, fmt.Sprintf("⚠️ ID=%d %s: 账号已创建但生成兑换码失败", id, accountEmail)) + continue + } + + subInfo := "" + if info.ExpiresAt != "" { + subInfo = fmt.Sprintf("\n 📅 订阅到期: %s", formatDate(info.ExpiresAt)) + } + lines = append(lines, fmt.Sprintf("✅ %s | %s%s\n 🎫 已生成 %d 个兑换码", + accountEmail, info.AccountID, subInfo, len(codes))) + } + + resultText := "📋 *添加结果:*\n\n" + strings.Join(lines, "\n") + if panelMsgID != 0 { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回列表", "cmd:list_accounts"), + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, resultText, &kb) + } else { + b.editMsg(chatID, msgID, resultText) + } +} + +func (b *Bot) callbackListPendingInvites(chatID int64, msgID int, accountIDStr string) { + accountID, err := strconv.ParseInt(accountIDStr, 10, 64) + if err != nil { + return + } + + account, err := b.db.GetAccountByID(accountID) + if err != nil { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "act:pending_invites_pick"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 账号不存在: %v", err), &kb) + return + } + + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("⏳ 正在查询账号 ID=%d 的待进入邀请...", accountID), nil) + + _, invites, err := b.client.GetInvites(account) + if err != nil { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "act:pending_invites_pick"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 查询失败: %v", err), &kb) + return + } + + // Sync invite count to DB so the main page status is accurate. + _ = b.db.UpdateAccountCounts(account.ID, account.UserCount, len(invites)) + + var rows [][]tgbotapi.InlineKeyboardButton + if len(invites) == 0 { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "act:pending_invites_pick"), + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("📭 账号 ID=%d (%s) 暂无待进入的邀请", accountID, account.Email), &kb) + return + } + + text := fmt.Sprintf("📩 *待进入邀请* — ID=%d (%s)\n共 %d 个\n\n点击可删除对应邀请:", accountID, account.Email, len(invites)) + + for _, inv := range invites { + cbData := fmt.Sprintf("delinv:%d:%s", accountID, inv.EmailAddress) + // Telegram callback data has a 64-byte limit. + if len(cbData) <= 64 { + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("❌ %s", inv.EmailAddress), cbData), + )) + } + } + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "act:pending_invites_pick"), + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + )) + + kb := tgbotapi.InlineKeyboardMarkup{InlineKeyboard: rows} + b.editMsgWithKeyboard(chatID, msgID, text, &kb) +} + +func (b *Bot) callbackDelInvite(chatID int64, msgID int, data string) { + // data format: : + idx := strings.Index(data, ":") + if idx < 0 { + return + } + accIDStr := data[:idx] + email := data[idx+1:] + + accountID, err := strconv.ParseInt(accIDStr, 10, 64) + if err != nil { + return + } + + account, err := b.db.GetAccountByID(accountID) + if err != nil { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "act:pending_invites_pick"), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 账号不存在: %v", err), &kb) + return + } + + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("⏳ 正在删除邀请 %s...", email), nil) + + if err := b.client.DeleteInvite(account, email); err != nil { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", fmt.Sprintf("pending_invite:%d", accountID)), + ), + ) + b.editMsgWithKeyboard(chatID, msgID, fmt.Sprintf("❌ 删除邀请失败: %s", err.Error()), &kb) + return + } + + time.Sleep(2 * time.Second) + syncCounts(b.db, b.client, account) + + // Re-trigger the list to show updated invites. + b.callbackListPendingInvites(chatID, msgID, accIDStr) +} + +func (b *Bot) handleGenCodes(chatID int64, panelMsgID int, args string) { + n, err := strconv.Atoi(strings.TrimSpace(args)) + if err != nil || n <= 0 { + b.sendAutoDelete(chatID, "❌ 参数错误") + return + } + + accounts, err := b.db.GetAllAccounts() + if err != nil || len(accounts) == 0 { + b.sendAutoDelete(chatID, "❌ 暂无账号") + return + } + + // Actually, let's look at the remaining capacity of the account to see how many we CAN generate. + // We'll calculate the available capacity below per account based on TeamCapacity and UserCount. + var eligible []model.GptAccount + for _, acc := range accounts { + if acc.IsBanned || !acc.IsOpen { + continue // Skip banned or closed accounts + } + + // We want to generate codes for this account up to the allowed capacity. + existing, _ := b.db.CountAvailableCodesByAccount(acc.Email) + + maxCodes := b.cfg.TeamCapacity - acc.UserCount - acc.InviteCount + if maxCodes < 0 { + maxCodes = 0 + } + + if existing <= maxCodes { + eligible = append(eligible, acc) + } + } + + if len(eligible) == 0 { + if panelMsgID != 0 { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "cmd:codes_menu"), + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, panelMsgID, "✅ 所有账号均已满员或已生成充足兑换码,无需继续生成", &kb) + } else { + b.send(chatID, "✅ 所有账号均已满员或已生成充足兑换码,无需继续生成") + } + return + } + + if n > len(eligible) { + n = len(eligible) + } + + var allLines []string + for i := 0; i < n; i++ { + email := eligible[i].Email + existing, _ := b.db.CountAvailableCodesByAccount(email) + + maxCodes := b.cfg.TeamCapacity - eligible[i].UserCount - eligible[i].InviteCount + if maxCodes < 0 { + maxCodes = 0 + } + + needed := maxCodes - existing + if needed <= 0 { + continue + } + codes := redeem.GenerateCodes(needed) + if err := b.db.CreateCodes(email, codes); err != nil { + allLines = append(allLines, fmt.Sprintf("❌ `%s`: %v", email, err)) + continue + } + codeList := make([]string, len(codes)) + for j, c := range codes { + codeList[j] = fmt.Sprintf("`%s`", c) + } + allLines = append(allLines, fmt.Sprintf("✅ `%s` (+%d个):\n%s", + email, needed, strings.Join(codeList, "\n"))) + } + + availLeft := len(eligible) - n + suffix := "" + if availLeft > 0 { + suffix = fmt.Sprintf("\n⚠️ 还有 %d 个账号可继续生成", availLeft) + } + + result := fmt.Sprintf("✅ 已处理 %d 个账号,补充兑换码至名额上限:\n\n%s%s", + n, strings.Join(allLines, "\n\n"), suffix) + + if panelMsgID != 0 { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 返回", "cmd:codes_menu"), + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, panelMsgID, result, &kb) + } else { + b.send(chatID, result) + } +} + +func (b *Bot) handleStatus(chatID int64) { + accounts, err := b.db.GetAllAccounts() + if err != nil { + b.sendAutoDelete(chatID, fmt.Sprintf("❌ 查询失败: %v", err)) + return + } + + total := len(accounts) + open, banned := 0, 0 + totalUsers, totalInvites := 0, 0 + for _, a := range accounts { + if a.IsBanned { + banned++ + } else if a.IsOpen { + open++ + } + totalUsers += a.UserCount + totalInvites += a.InviteCount + } + + codeCount, _ := b.db.CountAvailableCodes() + + b.send(chatID, fmt.Sprintf( + "📊 *系统状态*\n\n"+ + "📁 账号总数: *%d*\n"+ + " ├ 开放中: *%d*\n"+ + " ├ 已封号: *%d*\n"+ + " └ 已关闭: *%d*\n\n"+ + "👥 总用户数: *%d* | 待邀请: *%d*\n"+ + "🎫 可用兑换码: *%d*", + total, open, banned, total-open-banned, + totalUsers, totalInvites, codeCount)) +} + +func (b *Bot) handleAddAdmin(chatID int64, addedBy int64, args string) { + b.handleAddAdminMsg(chatID, 0, args, addedBy) +} + +func (b *Bot) handleAddAdminMsg(chatID int64, panelMsgID int, args string, addedBy int64) { + idStr := strings.TrimSpace(args) + if idStr == "" { + if panelMsgID != 0 { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, panelMsgID, "❌ 用法: `/add_admin ` 或者在面板中输入有效ID", &kb) + } else { + b.sendAutoDelete(chatID, "❌ 用法: /add\\_admin ``") + } + return + } + newID, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + if panelMsgID != 0 { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("🏠 主界面", "cmd:back"), + ), + ) + b.editMsgWithKeyboard(chatID, panelMsgID, "❌ 用户ID格式错误,请输入数字", &kb) + } else { + b.sendAutoDelete(chatID, "❌ 用户ID格式错误,请输入数字") + } + return + } + if b.isAdmin(newID) { + if panelMsgID != 0 { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 列表后台", "cmd:admin_menu"), + ), + ) + b.editMsgWithKeyboard(chatID, panelMsgID, fmt.Sprintf("ℹ️ 用户 `%d` 已经是管理员", newID), &kb) + } else { + b.send(chatID, fmt.Sprintf("ℹ️ 用户 `%d` 已经是管理员", newID)) + } + return + } + + if err := b.db.AddAdmin(newID, addedBy); err != nil { + if panelMsgID != 0 { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 列表后台", "cmd:admin_menu"), + ), + ) + b.editMsgWithKeyboard(chatID, panelMsgID, fmt.Sprintf("❌ 添加失败: %v", err), &kb) + } else { + b.send(chatID, fmt.Sprintf("❌ 添加失败: %v", err)) + } + return + } + + msg := fmt.Sprintf("✅ 已成功将用户 `%d` 添加为管理员!", newID) + if panelMsgID != 0 { + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("⬅️ 列表后台", "cmd:admin_menu"), + ), + ) + b.editMsgWithKeyboard(chatID, panelMsgID, msg, &kb) + } else { + b.send(chatID, msg) + } +} + +func (b *Bot) handleLogin(chatID int64, editMsgID int) { + authURL, _, err := b.oauth.GenerateAuthURL() + if err != nil { + b.sendAutoDelete(chatID, fmt.Sprintf("❌ 生成登录链接失败: %s", err.Error())) + return + } + + text := fmt.Sprintf( + "🔗 *OpenAI 登录链接*\n\n"+ + "[点击这里登录](%s)\n\n"+ + "ℹ️ 登录完成后,浏览器会跳转到一个无法访问的页面,这是正常的\n"+ + "👉 请复制浏览器地址栏中的 *完整 URL* 粘贴回这里", + authURL) + + kb := cancelToListKeyboard() + + var loginMsgID int + if editMsgID != 0 { + b.editMsgWithKeyboard(chatID, editMsgID, text, &kb) + loginMsgID = editMsgID + } else { + loginMsgID = b.sendAndGetID(chatID, text) + } + + b.mu.Lock() + b.sessions[chatID] = &chatSession{flowType: "login", loginMsgID: loginMsgID} + b.mu.Unlock() + + // Auto-delete login link after 2 minutes if session is still active. + go func() { + time.Sleep(2 * time.Minute) + b.mu.Lock() + sess, exists := b.sessions[chatID] + if exists && sess.flowType == "login" && sess.loginMsgID == loginMsgID { + delete(b.sessions, chatID) + b.mu.Unlock() + if editMsgID == 0 { + b.deleteMsg(chatID, loginMsgID) + } + } else { + b.mu.Unlock() + } + }() +} + +func (b *Bot) handleLoginCallback(chatID int64, pastedMsgID int, callbackURL string) { + // Get session and clean up. + b.mu.Lock() + sess := b.sessions[chatID] + loginMsgID := 0 + if sess != nil { + loginMsgID = sess.loginMsgID + } + delete(b.sessions, chatID) + b.mu.Unlock() + + // Delete the pasted URL message (contains sensitive code). + b.deleteMsg(chatID, pastedMsgID) + + // Edit the original login message to show progress. + b.editMsg(chatID, loginMsgID, "⏳ 正在处理登录信息...") + + result, err := b.oauth.ExchangeCallbackURL(callbackURL) + if err != nil { + b.editMsg(chatID, loginMsgID, fmt.Sprintf("❌ 登录失败: %s", err.Error())) + return + } + + b.editMsg(chatID, loginMsgID, "✅ Token 获取成功,正在获取账号信息...") + + infos, err := b.client.FetchAccountInfo(result.AccessToken) + if err != nil { + id, dbErr := b.db.CreateAccount(&model.GptAccount{ + Email: result.Email, + Token: result.AccessToken, + RefreshToken: result.RefreshToken, + ChatgptAccountID: result.AccountID, + IsOpen: true, + }) + if dbErr != nil { + b.editMsg(chatID, loginMsgID, fmt.Sprintf("❌ 创建账号失败: %v", dbErr)) + return + } + b.editMsg(chatID, loginMsgID, fmt.Sprintf("⚠️ 账号已创建 (ID=%d),但获取详情失败: %s\nRT 已保存。", id, err.Error())) + return + } + + var lines []string + for _, info := range infos { + accountEmail := result.Email + if accountEmail == "" { + accountEmail = info.Name // Fallback + } + + // Check for duplicate by OpenAI Account ID (most reliable) + if existing, err2 := b.db.GetAccountByChatGPTAccountID(info.AccountID); err2 == nil && existing != nil { + lines = append(lines, fmt.Sprintf("⚠️ %s (%s): 已存在 (ID=%d),跳过", info.Name, accountEmail, existing.ID)) + continue + } + + id, dbErr := b.db.CreateAccount(&model.GptAccount{ + Email: accountEmail, + Token: result.AccessToken, + RefreshToken: result.RefreshToken, + ChatgptAccountID: info.AccountID, + ExpireAt: info.ExpiresAt, + IsOpen: true, + }) + if dbErr != nil { + lines = append(lines, fmt.Sprintf("❌ %s (%s): 创建失败 - %v", info.Name, accountEmail, dbErr)) + continue + } + + subInfo := "" + if info.ExpiresAt != "" { + subInfo = fmt.Sprintf(" | 📅 %s", formatDate(info.ExpiresAt)) + } + lines = append(lines, fmt.Sprintf("✅ ID=%d %s%s", + id, info.Name, subInfo)) + } + + b.editMsg(chatID, loginMsgID, "📋 *登录添加结果:*\n\n"+strings.Join(lines, "\n")) +} + +func (b *Bot) handleListCodes(chatID int64, args string) { + target := strings.TrimSpace(args) + if target == "" { + b.sendAutoDelete(chatID, "❌ 用法: /list\\_codes `<账号邮箱|all>`") + return + } + + var codes []model.RedemptionCode + var err error + if strings.EqualFold(target, "all") { + codes, err = b.db.GetAllCodes() + } else { + codes, err = b.db.GetCodesByAccount(target) + } + if err != nil { + b.sendAutoDelete(chatID, fmt.Sprintf("❌ 查询失败: %v", err)) + return + } + if len(codes) == 0 { + b.send(chatID, "📭 暂无兑换码") + return + } + + var lines []string + for _, c := range codes { + status := "🟢" + extra := "" + if c.IsRedeemed { + status = "🔴" + by := "" + if c.RedeemedBy != nil && *c.RedeemedBy != "" { + by = *c.RedeemedBy + } + extra = fmt.Sprintf(" → %s", by) + } + lines = append(lines, fmt.Sprintf("%s `%s` (%s)%s", status, c.Code, c.AccountEmail, extra)) + } + + header := fmt.Sprintf("🎫 *兑换码列表* (%d 个):\n\n", len(codes)) + b.send(chatID, header+strings.Join(lines, "\n")) +} + +func (b *Bot) handleListInvites(chatID int64, args string) { + target := strings.TrimSpace(args) + if target == "" { + b.sendAutoDelete(chatID, "❌ 用法: /list\\_invites `<账号ID|all>`") + return + } + + var accountList []model.GptAccount + + if strings.EqualFold(target, "all") { + accs, err := b.db.GetAllAccounts() + if err != nil { + b.sendAutoDelete(chatID, fmt.Sprintf("❌ 查询失败: %v", err)) + return + } + accountList = accs + } else { + id, err := strconv.ParseInt(target, 10, 64) + if err != nil { + b.sendAutoDelete(chatID, "❌ 账号ID格式错误") + return + } + acc, err := b.db.GetAccountByID(id) + if err != nil { + b.sendAutoDelete(chatID, fmt.Sprintf("❌ 账号不存在: %v", err)) + return + } + accountList = append(accountList, *acc) + } + + if len(accountList) == 0 { + b.send(chatID, "📋 暂无账号") + return + } + + msgID := b.sendAndGetID(chatID, fmt.Sprintf("⏳ 正在查询 %d 个账号的待进入邀请...", len(accountList))) + + var lines []string + totalInvites := 0 + for _, acc := range accountList { + if acc.Token == "" { + continue + } + _, invites, err := b.client.GetInvites(&acc) + if err != nil { + lines = append(lines, fmt.Sprintf("❌ ID=%d %s: %s", acc.ID, acc.Email, err.Error())) + continue + } + if len(invites) == 0 { + continue + } + totalInvites += len(invites) + var inviteLines []string + for _, inv := range invites { + inviteLines = append(inviteLines, fmt.Sprintf(" 📧 `%s` (%s)", inv.EmailAddress, inv.Role)) + } + lines = append(lines, fmt.Sprintf("*ID=%d* %s (%d 个):\n%s", + acc.ID, acc.Email, len(invites), strings.Join(inviteLines, "\n"))) + } + + if totalInvites == 0 { + b.editMsg(chatID, msgID, "📭 暂无待进入的邀请") + return + } + + b.editMsg(chatID, msgID, fmt.Sprintf("📋 *待进入邀请* (%d 个):\n\n%s\n\n💡 使用 /kick `<账号ID>` `<邮箱>` 可取消邀请", + totalInvites, strings.Join(lines, "\n\n"))) +} + +func (b *Bot) handleDelAccount(chatID int64, args string) { + idStr := strings.TrimSpace(args) + if idStr == "" { + b.sendAutoDelete(chatID, "❌ 用法: /del\\_account `<账号ID>`") + return + } + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + b.sendAutoDelete(chatID, "❌ 账号ID格式错误,请输入数字") + return + } + + // Get account info first for display. + acct, err := b.db.GetAccountByID(id) + if err != nil { + b.sendAutoDelete(chatID, fmt.Sprintf("❌ 账号不存在: %v", err)) + return + } + + if err := b.db.DeleteAccount(id); err != nil { + b.sendAutoDelete(chatID, fmt.Sprintf("❌ 删除失败: %v", err)) + return + } + + b.send(chatID, fmt.Sprintf("✅ 已删除账号 ID=%d (%s)\n关联的兑换码也已清除", id, acct.Email)) +} + +// ─── Helpers ──────────────────────────────────────────────── + +func (b *Bot) send(chatID int64, text string) { + msg := tgbotapi.NewMessage(chatID, text) + msg.ParseMode = "Markdown" + msg.DisableWebPagePreview = true + if _, err := b.api.Send(msg); err != nil { + log.Printf("[Bot] 发送消息失败: %v", err) + } +} + +// sendAutoDelete sends a message and auto-deletes it after 1 minute. +func (b *Bot) sendAutoDelete(chatID int64, text string) { + msgID := b.sendAndGetID(chatID, text) + if msgID != 0 { + go func() { + time.Sleep(time.Minute) + b.deleteMsg(chatID, msgID) + }() + } +} + +func (b *Bot) sendAndGetID(chatID int64, text string) int { + msg := tgbotapi.NewMessage(chatID, text) + msg.ParseMode = "Markdown" + msg.DisableWebPagePreview = true + sentMsg, err := b.api.Send(msg) + if err != nil { + log.Printf("[Bot] 发送消息失败: %v", err) + return 0 + } + return sentMsg.MessageID +} + +func (b *Bot) editMsg(chatID int64, msgID int, text string) { + if msgID == 0 { + b.send(chatID, text) + return + } + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ParseMode = "Markdown" + edit.DisableWebPagePreview = true + if _, err := b.api.Send(edit); err != nil { + // Fallback: try editing as caption (for photo messages). + editCaption := tgbotapi.NewEditMessageCaption(chatID, msgID, text) + editCaption.ParseMode = "Markdown" + if _, err2 := b.api.Send(editCaption); err2 != nil { + log.Printf("[Bot] 编辑消息失败: %v", err2) + } + } +} + +func (b *Bot) editMsgWithKeyboard(chatID int64, msgID int, text string, keyboard *tgbotapi.InlineKeyboardMarkup) { + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ParseMode = "Markdown" + edit.DisableWebPagePreview = true + if keyboard != nil { + edit.ReplyMarkup = keyboard + } + if _, err := b.api.Send(edit); err != nil { + // Fallback: try editing as caption (for photo messages). + editCaption := tgbotapi.NewEditMessageCaption(chatID, msgID, text) + editCaption.ParseMode = "Markdown" + if keyboard != nil { + editCaption.ReplyMarkup = keyboard + } + if _, err2 := b.api.Send(editCaption); err2 != nil { + log.Printf("[Bot] 编辑消息失败: %v", err2) + } + } +} + +func (b *Bot) deleteMsg(chatID int64, msgID int) { + if msgID == 0 { + return + } + del := tgbotapi.NewDeleteMessage(chatID, msgID) + if _, err := b.api.Request(del); err != nil { + log.Printf("[Bot] 删除消息失败: %v", err) + } +} + +func (b *Bot) requireAdmin(chatID int64, userID int64, fn func()) { + if !b.cfg.IsAdmin(userID) { + b.send(chatID, "🚫 该命令仅限管理员使用") + return + } + fn() +} + +func syncCounts(db *database.DB, client *chatgpt.Client, account *model.GptAccount) { + userTotal, _, err := client.GetUsers(account) + if err != nil { + return + } + inviteTotal, _, err := client.GetInvites(account) + if err != nil { + return + } + _ = db.UpdateAccountCounts(account.ID, userTotal, inviteTotal) +} + +// formatDate extracts the date part (YYYY-MM-DD) from an ISO datetime string. +func formatDate(s string) string { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t.Format("2006-01-02") + } + if len(s) >= 10 { + return s[:10] + } + return s +} diff --git a/internal/chatgpt/account.go b/internal/chatgpt/account.go new file mode 100644 index 0000000..fc9e3b1 --- /dev/null +++ b/internal/chatgpt/account.go @@ -0,0 +1,111 @@ +package chatgpt + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "go-helper/internal/model" +) + +// FetchAccountInfo queries the ChatGPT accounts check API and returns team accounts +// with subscription expiry information. +func (c *Client) FetchAccountInfo(accessToken string) ([]model.TeamAccountInfo, error) { + token := strings.TrimPrefix(strings.TrimSpace(accessToken), "Bearer ") + if token == "" { + return nil, fmt.Errorf("缺少 access token") + } + + apiURL := "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27" + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "*/*") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Set("Oai-Client-Version", oaiClientVersion) + req.Header.Set("Oai-Language", "zh-CN") + req.Header.Set("User-Agent", userAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求 ChatGPT API 失败: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == 401 || resp.StatusCode == 402 { + return nil, fmt.Errorf("Token 已过期或被封禁") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("ChatGPT API 错误 %d: %s", resp.StatusCode, truncate(string(body), 300)) + } + + var data struct { + Accounts map[string]json.RawMessage `json:"accounts"` + AccountOrdering []string `json:"account_ordering"` + } + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("解析响应失败: %w", err) + } + + var results []model.TeamAccountInfo + + // Determine order. + seen := make(map[string]bool) + var orderedIDs []string + for _, id := range data.AccountOrdering { + if _, ok := data.Accounts[id]; ok && !seen[id] { + orderedIDs = append(orderedIDs, id) + seen[id] = true + } + } + for id := range data.Accounts { + if !seen[id] && id != "default" { + orderedIDs = append(orderedIDs, id) + } + } + + for _, id := range orderedIDs { + raw := data.Accounts[id] + var acc struct { + Account struct { + Name string `json:"name"` + PlanType string `json:"plan_type"` + } `json:"account"` + Entitlement struct { + ExpiresAt string `json:"expires_at"` + HasActiveSubscription bool `json:"has_active_subscription"` + } `json:"entitlement"` + } + if err := json.Unmarshal(raw, &acc); err != nil { + continue + } + if acc.Account.PlanType != "team" { + continue + } + results = append(results, model.TeamAccountInfo{ + AccountID: id, + Name: acc.Account.Name, + PlanType: acc.Account.PlanType, + ExpiresAt: acc.Entitlement.ExpiresAt, + HasActiveSubscription: acc.Entitlement.HasActiveSubscription, + }) + } + + if len(results) == 0 { + return nil, fmt.Errorf("未找到 Team 类型的账号") + } + return results, nil +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/internal/chatgpt/client.go b/internal/chatgpt/client.go new file mode 100644 index 0000000..8fa4dd9 --- /dev/null +++ b/internal/chatgpt/client.go @@ -0,0 +1,88 @@ +package chatgpt + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "golang.org/x/net/proxy" +) + +const ( + oaiClientVersion = "prod-eddc2f6ff65fee2d0d6439e379eab94fe3047f72" + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36" + openaiClientID = "app_EMoamEEZ73f0CkXaXp7hrann" +) + +// Client wraps HTTP operations with common headers and optional proxy. +type Client struct { + httpClient *http.Client + proxyURL string +} + +// NewClient creates a ChatGPT API client with optional proxy support. +func NewClient(proxyURL string) *Client { + c := &Client{proxyURL: proxyURL} + c.httpClient = c.buildHTTPClient() + return c +} + +func (c *Client) buildHTTPClient() *http.Client { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + IdleConnTimeout: 90 * time.Second, + } + + if c.proxyURL != "" { + parsed, err := url.Parse(c.proxyURL) + if err == nil { + scheme := parsed.Scheme + if scheme == "socks5" || scheme == "socks5h" { + var auth *proxy.Auth + if parsed.User != nil { + pass, _ := parsed.User.Password() + auth = &proxy.Auth{ + User: parsed.User.Username(), + Password: pass, + } + } + dialer, dErr := proxy.SOCKS5("tcp", parsed.Host, auth, proxy.Direct) + if dErr == nil { + if ctxDialer, ok := dialer.(proxy.ContextDialer); ok { + transport.DialContext = ctxDialer.DialContext + } else { + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) + } + } + } + } else { + transport.Proxy = http.ProxyURL(parsed) + } + } + } + + return &http.Client{ + Transport: transport, + Timeout: 60 * time.Second, + } +} + +// buildHeaders returns common headers for ChatGPT backend API calls. +func buildHeaders(token, chatgptAccountID string) http.Header { + h := http.Header{} + h.Set("Accept", "*/*") + h.Set("Accept-Language", "zh-CN,zh;q=0.9") + h.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + h.Set("Chatgpt-Account-Id", chatgptAccountID) + h.Set("Oai-Client-Version", oaiClientVersion) + h.Set("Oai-Language", "zh-CN") + h.Set("User-Agent", userAgent) + h.Set("Origin", "https://chatgpt.com") + h.Set("Referer", "https://chatgpt.com/admin/members") + return h +} diff --git a/internal/chatgpt/invite.go b/internal/chatgpt/invite.go new file mode 100644 index 0000000..201b770 --- /dev/null +++ b/internal/chatgpt/invite.go @@ -0,0 +1,93 @@ +package chatgpt + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "math" + "net/http" + "strings" + "time" + + "go-helper/internal/model" +) + +const ( + maxInviteAttempts = 3 + retryBaseDelay = 800 * time.Millisecond + retryMaxDelay = 5 * time.Second +) + +// InviteUser sends an invitation to the given email on the specified team account. +func (c *Client) InviteUser(email string, account *model.GptAccount) error { + email = strings.TrimSpace(strings.ToLower(email)) + if email == "" { + return fmt.Errorf("缺少邀请邮箱") + } + if account.Token == "" || account.ChatgptAccountID == "" { + return fmt.Errorf("账号配置不完整") + } + + apiURL := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/invites", account.ChatgptAccountID) + payload := map[string]interface{}{ + "email_addresses": []string{email}, + "role": "standard-user", + "resend_emails": true, + } + bodyBytes, _ := json.Marshal(payload) + + var lastErr error + for attempt := 1; attempt <= maxInviteAttempts; attempt++ { + req, err := http.NewRequest("POST", apiURL, bytes.NewReader(bodyBytes)) + if err != nil { + return err + } + headers := buildHeaders(account.Token, account.ChatgptAccountID) + req.Header = headers + req.Header.Set("Content-Type", "application/json") + if account.OaiDeviceID != "" { + req.Header.Set("Oai-Device-Id", account.OaiDeviceID) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = fmt.Errorf("网络错误: %w", err) + log.Printf("[Invite] 尝试 %d/%d 网络错误: %v", attempt, maxInviteAttempts, err) + if attempt < maxInviteAttempts { + sleepRetry(attempt) + } + continue + } + + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + log.Printf("[Invite] 成功邀请 %s 到账号 %s", email, account.ChatgptAccountID) + return nil + } + + lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(respBody), 300)) + log.Printf("[Invite] 尝试 %d/%d 失败: %v", attempt, maxInviteAttempts, lastErr) + + if !isRetryableStatus(resp.StatusCode) || attempt >= maxInviteAttempts { + break + } + sleepRetry(attempt) + } + return fmt.Errorf("邀请失败(已尝试 %d 次): %v", maxInviteAttempts, lastErr) +} + +func sleepRetry(attempt int) { + delay := float64(retryBaseDelay) * math.Pow(2, float64(attempt-1)) + if delay > float64(retryMaxDelay) { + delay = float64(retryMaxDelay) + } + time.Sleep(time.Duration(delay)) +} + +func isRetryableStatus(status int) bool { + return status == 408 || status == 429 || (status >= 500 && status <= 599) +} diff --git a/internal/chatgpt/member.go b/internal/chatgpt/member.go new file mode 100644 index 0000000..5f5b6a6 --- /dev/null +++ b/internal/chatgpt/member.go @@ -0,0 +1,195 @@ +package chatgpt + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + + "go-helper/internal/model" +) + +// GetUsers fetches the list of members for a team account. +func (c *Client) GetUsers(account *model.GptAccount) (int, []model.ChatGPTUser, error) { + apiURL := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/users?offset=0&limit=100&query=", + account.ChatgptAccountID) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return 0, nil, err + } + req.Header = buildHeaders(account.Token, account.ChatgptAccountID) + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("获取成员列表网络错误: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if err := checkAPIError(resp.StatusCode, body, "获取成员"); err != nil { + return 0, nil, err + } + + var data struct { + Total int `json:"total"` + Items []struct { + ID string `json:"id"` + AccountUserID string `json:"account_user_id"` + Email string `json:"email"` + Role string `json:"role"` + Name string `json:"name"` + } `json:"items"` + } + if err := json.Unmarshal(body, &data); err != nil { + return 0, nil, fmt.Errorf("解析成员数据失败: %w", err) + } + + var users []model.ChatGPTUser + for _, item := range data.Items { + users = append(users, model.ChatGPTUser{ + ID: item.ID, + AccountUserID: item.AccountUserID, + Email: item.Email, + Role: item.Role, + Name: item.Name, + }) + } + return data.Total, users, nil +} + +// DeleteUser removes a user from the team account. +func (c *Client) DeleteUser(account *model.GptAccount, userID string) error { + normalizedID := userID + if !strings.HasPrefix(normalizedID, "user-") { + normalizedID = "user-" + normalizedID + } + + apiURL := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/users/%s", + account.ChatgptAccountID, normalizedID) + + req, err := http.NewRequest("DELETE", apiURL, nil) + if err != nil { + return err + } + req.Header = buildHeaders(account.Token, account.ChatgptAccountID) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("删除用户网络错误: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if err := checkAPIError(resp.StatusCode, body, "删除用户"); err != nil { + return err + } + + log.Printf("[Member] 成功删除用户 %s (账号 %s)", normalizedID, account.ChatgptAccountID) + return nil +} + +// GetInvites fetches pending invitations for a team account. +func (c *Client) GetInvites(account *model.GptAccount) (int, []model.ChatGPTInvite, error) { + apiURL := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/invites?offset=0&limit=100&query=", + account.ChatgptAccountID) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return 0, nil, err + } + req.Header = buildHeaders(account.Token, account.ChatgptAccountID) + req.Header.Set("Referer", "https://chatgpt.com/admin/members?tab=invites") + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("获取邀请列表网络错误: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if err := checkAPIError(resp.StatusCode, body, "获取邀请列表"); err != nil { + return 0, nil, err + } + + var data struct { + Total int `json:"total"` + Items []struct { + ID string `json:"id"` + EmailAddress string `json:"email_address"` + Role string `json:"role"` + } `json:"items"` + } + if err := json.Unmarshal(body, &data); err != nil { + return 0, nil, fmt.Errorf("解析邀请数据失败: %w", err) + } + + var invites []model.ChatGPTInvite + for _, item := range data.Items { + invites = append(invites, model.ChatGPTInvite{ + ID: item.ID, + EmailAddress: item.EmailAddress, + Role: item.Role, + }) + } + return data.Total, invites, nil +} + +// DeleteInvite cancels a pending invitation by email on the team account. +func (c *Client) DeleteInvite(account *model.GptAccount, email string) error { + apiURL := fmt.Sprintf("https://chatgpt.com/backend-api/accounts/%s/invites", + account.ChatgptAccountID) + + payload := map[string]string{"email_address": strings.TrimSpace(strings.ToLower(email))} + bodyBytes, _ := json.Marshal(payload) + + req, err := http.NewRequest("DELETE", apiURL, bytes.NewReader(bodyBytes)) + if err != nil { + return err + } + req.Header = buildHeaders(account.Token, account.ChatgptAccountID) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Referer", "https://chatgpt.com/admin/members?tab=invites") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("删除邀请网络错误: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if err := checkAPIError(resp.StatusCode, body, "删除邀请"); err != nil { + return err + } + + log.Printf("[Member] 成功删除邀请 %s (账号 %s)", email, account.ChatgptAccountID) + return nil +} + +// checkAPIError inspects the HTTP status code and returns a descriptive error. +func checkAPIError(statusCode int, body []byte, label string) error { + if statusCode >= 200 && statusCode < 300 { + return nil + } + + bodyStr := truncate(string(body), 500) + + // Check for account_deactivated. + if strings.Contains(bodyStr, "account_deactivated") { + return fmt.Errorf("账号已停用 (account_deactivated)") + } + + switch statusCode { + case 401, 402: + return fmt.Errorf("Token 已过期或被封禁") + case 403: + return fmt.Errorf("资源不存在或无权访问") + case 429: + return fmt.Errorf("API 请求过于频繁,请稍后重试") + default: + return fmt.Errorf("%s失败 (HTTP %d): %s", label, statusCode, bodyStr) + } +} diff --git a/internal/chatgpt/oauth.go b/internal/chatgpt/oauth.go new file mode 100644 index 0000000..f8f04d8 --- /dev/null +++ b/internal/chatgpt/oauth.go @@ -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 +} diff --git a/internal/chatgpt/token.go b/internal/chatgpt/token.go new file mode 100644 index 0000000..6b173d0 --- /dev/null +++ b/internal/chatgpt/token.go @@ -0,0 +1,109 @@ +package chatgpt + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "encoding/json" +) + +// TokenResult holds the result of a token refresh. +type TokenResult struct { + AccessToken string + RefreshToken string + IDToken string + ExpiresIn int +} + +// GetEmail extracts the user's email from the ID token or Access token. +func (tr *TokenResult) GetEmail() string { + if tr.IDToken != "" { + claims, err := decodeJWTPayload(tr.IDToken) + if err == nil { + if email, ok := claims["email"].(string); ok && email != "" { + return email + } + } + } + if tr.AccessToken != "" { + claims, err := decodeJWTPayload(tr.AccessToken) + if err == nil { + if email, ok := claims["email"].(string); ok && email != "" { + return email + } + } + } + return "" +} + +// RefreshAccessToken exchanges a refresh token for a new access token. +func (c *Client) RefreshAccessToken(refreshToken string) (*TokenResult, error) { + rt := strings.TrimSpace(refreshToken) + if rt == "" { + return nil, fmt.Errorf("refresh token 为空") + } + + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("client_id", openaiClientID) + form.Set("refresh_token", rt) + form.Set("scope", "openid profile email") + + 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 := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("刷新 token 网络错误: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + var errData struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + _ = json.Unmarshal(body, &errData) + msg := errData.ErrorDescription + if msg == "" { + msg = errData.Error + } + if msg == "" { + msg = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + return nil, fmt.Errorf("刷新 token 失败: %s", msg) + } + + var result struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析刷新结果失败: %w", err) + } + if result.AccessToken == "" { + return nil, fmt.Errorf("刷新 token 失败: 未返回有效凭证") + } + + newRT := result.RefreshToken + if newRT == "" { + newRT = rt + } + + return &TokenResult{ + AccessToken: result.AccessToken, + RefreshToken: newRT, + IDToken: result.IDToken, + ExpiresIn: result.ExpiresIn, + }, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a43d29e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,86 @@ +package config + +import ( + "log" + "os" + "strconv" + "strings" + + "github.com/joho/godotenv" +) + +// Config holds all application configuration. +type Config struct { + DatabaseURL string + TelegramBotToken string + TelegramAdminIDs []int64 + ProxyURL string + TokenCheckInterval int // minutes + TeamCapacity int + OAuthCallbackPort int +} + +// Load reads configuration from environment variables / .env file. +func Load() *Config { + _ = godotenv.Load() + + cfg := &Config{ + DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/teamhelper?sslmode=disable"), + TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""), + ProxyURL: getEnv("PROXY_URL", ""), + TokenCheckInterval: getEnvInt("TOKEN_CHECK_INTERVAL", 30), + TeamCapacity: getEnvInt("TEAM_CAPACITY", 6), + OAuthCallbackPort: getEnvInt("OAUTH_CALLBACK_PORT", 1455), + } + + raw := getEnv("TELEGRAM_ADMIN_IDS", "") + if raw != "" { + for _, s := range strings.Split(raw, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + id, err := strconv.ParseInt(s, 10, 64) + if err != nil { + log.Printf("[Config] 无法解析管理员ID '%s': %v", s, err) + continue + } + cfg.TelegramAdminIDs = append(cfg.TelegramAdminIDs, id) + } + } + + if cfg.TelegramBotToken == "" { + log.Fatal("[Config] TELEGRAM_BOT_TOKEN 未配置") + } + + return cfg +} + +// IsAdmin checks if the given Telegram user ID is an admin. +func (c *Config) IsAdmin(userID int64) bool { + for _, id := range c.TelegramAdminIDs { + if id == userID { + return true + } + } + return false +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + i, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return i +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..c456d89 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,459 @@ +package database + +import ( + "database/sql" + "fmt" + "log" + "time" + + _ "github.com/lib/pq" + + "go-helper/internal/model" +) + +// DB wraps the sql.DB connection and provides typed query methods. +type DB struct { + *sql.DB +} + +// New opens a PostgreSQL connection and auto-creates tables. +func New(databaseURL string) *DB { + conn, err := sql.Open("postgres", databaseURL) + if err != nil { + log.Fatalf("[DB] 无法连接数据库: %v", err) + } + if err := conn.Ping(); err != nil { + log.Fatalf("[DB] 数据库连接测试失败: %v", err) + } + + conn.SetMaxOpenConns(10) + conn.SetMaxIdleConns(5) + conn.SetConnMaxLifetime(5 * time.Minute) + + d := &DB{conn} + d.migrate() + log.Println("[DB] 数据库初始化完成") + return d +} + +func (d *DB) migrate() { + queries := []string{ + `CREATE TABLE IF NOT EXISTS gpt_accounts ( + id BIGSERIAL PRIMARY KEY, + email TEXT NOT NULL, + token TEXT NOT NULL, + refresh_token TEXT DEFAULT '', + user_count INT DEFAULT 0, + invite_count INT DEFAULT 0, + chatgpt_account_id TEXT DEFAULT '', + oai_device_id TEXT DEFAULT '', + expire_at TEXT DEFAULT '', + is_open BOOLEAN DEFAULT TRUE, + is_banned BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS redemption_codes ( + id BIGSERIAL PRIMARY KEY, + code TEXT UNIQUE NOT NULL, + is_redeemed BOOLEAN DEFAULT FALSE, + redeemed_at TEXT, + redeemed_by TEXT, + account_email TEXT DEFAULT '', + channel TEXT DEFAULT 'common', + created_at TIMESTAMP DEFAULT NOW() + )`, + `CREATE TABLE IF NOT EXISTS telegram_admins ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT UNIQUE NOT NULL, + added_by BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + )`, + } + for _, q := range queries { + if _, err := d.Exec(q); err != nil { + log.Fatalf("[DB] 建表失败: %v", err) + } + } +} + +// --------------- GptAccount CRUD --------------- + +// GetAllAccounts returns all accounts ordered by creation time desc. +func (d *DB) GetAllAccounts() ([]model.GptAccount, error) { + rows, err := d.Query(` + SELECT id, email, token, refresh_token, user_count, invite_count, + chatgpt_account_id, oai_device_id, expire_at, is_open, is_banned, + created_at, updated_at + FROM gpt_accounts ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAccounts(rows) +} + +// GetOpenAccounts returns non-banned, open accounts that still have capacity. +func (d *DB) GetOpenAccounts(capacity int) ([]model.GptAccount, error) { + rows, err := d.Query(` + SELECT id, email, token, refresh_token, user_count, invite_count, + chatgpt_account_id, oai_device_id, expire_at, is_open, is_banned, + created_at, updated_at + FROM gpt_accounts + WHERE is_open = TRUE AND is_banned = FALSE + AND (user_count + invite_count) < $1 + AND token != '' AND chatgpt_account_id != '' + ORDER BY (user_count + invite_count) ASC, RANDOM()`, capacity) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAccounts(rows) +} + +// GetAccountByID fetches a single account by its ID. +func (d *DB) GetAccountByID(id int64) (*model.GptAccount, error) { + row := d.QueryRow(` + SELECT id, email, token, refresh_token, user_count, invite_count, + chatgpt_account_id, oai_device_id, expire_at, is_open, is_banned, + created_at, updated_at + FROM gpt_accounts WHERE id = $1 LIMIT 1`, id) + return scanAccount(row) +} + +// GetAccountByChatGPTAccountID fetches a single account by its OpenAI account ID. +func (d *DB) GetAccountByChatGPTAccountID(accountID string) (*model.GptAccount, error) { + row := d.QueryRow(` + SELECT id, email, token, refresh_token, user_count, invite_count, + chatgpt_account_id, oai_device_id, expire_at, is_open, is_banned, + created_at, updated_at + FROM gpt_accounts WHERE chatgpt_account_id = $1 LIMIT 1`, accountID) + return scanAccount(row) +} + +// GetAccountByEmail fetches a single account by email (case-insensitive). +func (d *DB) GetAccountByEmail(email string) (*model.GptAccount, error) { + row := d.QueryRow(` + SELECT id, email, token, refresh_token, user_count, invite_count, + chatgpt_account_id, oai_device_id, expire_at, is_open, is_banned, + created_at, updated_at + FROM gpt_accounts WHERE lower(email) = lower($1) LIMIT 1`, email) + return scanAccount(row) +} + +// GetAccountsWithRT returns accounts that have a refresh token and are not banned. +func (d *DB) GetAccountsWithRT() ([]model.GptAccount, error) { + rows, err := d.Query(` + SELECT id, email, token, refresh_token, user_count, invite_count, + chatgpt_account_id, oai_device_id, expire_at, is_open, is_banned, + created_at, updated_at + FROM gpt_accounts + WHERE refresh_token != '' AND is_banned = FALSE + ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAccounts(rows) +} + +// CreateAccount inserts a new account and returns its ID. +func (d *DB) CreateAccount(a *model.GptAccount) (int64, error) { + var id int64 + err := d.QueryRow(` + INSERT INTO gpt_accounts (email, token, refresh_token, user_count, invite_count, + chatgpt_account_id, oai_device_id, expire_at, is_open, is_banned) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id`, + a.Email, a.Token, a.RefreshToken, a.UserCount, a.InviteCount, + a.ChatgptAccountID, a.OaiDeviceID, a.ExpireAt, a.IsOpen, a.IsBanned, + ).Scan(&id) + return id, err +} + +// UpdateAccountTokens updates the access token and refresh token. +func (d *DB) UpdateAccountTokens(id int64, accessToken, refreshToken string) error { + _, err := d.Exec(` + UPDATE gpt_accounts + SET token = $1, refresh_token = $2, updated_at = NOW() + WHERE id = $3`, accessToken, refreshToken, id) + return err +} + +// UpdateAccountEmail updates the email of an account and its associated codes. +func (d *DB) UpdateAccountEmail(id int64, oldEmail, newEmail string) error { + tx, err := d.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + _, err = tx.Exec(` + UPDATE gpt_accounts + SET email = $1, updated_at = NOW() + WHERE id = $2`, newEmail, id) + if err != nil { + return err + } + + _, err = tx.Exec(` + UPDATE redemption_codes + SET account_email = $1 + WHERE account_email = $2`, newEmail, oldEmail) + if err != nil { + return err + } + + return tx.Commit() +} + +// UpdateAccountCounts updates user_count and invite_count. +func (d *DB) UpdateAccountCounts(id int64, userCount, inviteCount int) error { + _, err := d.Exec(` + UPDATE gpt_accounts + SET user_count = $1, invite_count = $2, updated_at = NOW() + WHERE id = $3`, userCount, inviteCount, id) + return err +} + +// UpdateAccountInfo updates chatgpt_account_id, expire_at, etc. from API data. +func (d *DB) UpdateAccountInfo(id int64, chatgptAccountID, expireAt string) error { + _, err := d.Exec(` + UPDATE gpt_accounts + SET chatgpt_account_id = $1, expire_at = $2, updated_at = NOW() + WHERE id = $3`, chatgptAccountID, expireAt, id) + return err +} + +// BanAccount marks an account as banned and not open. +func (d *DB) BanAccount(id int64) error { + _, err := d.Exec(` + UPDATE gpt_accounts + SET is_banned = TRUE, is_open = FALSE, updated_at = NOW() + WHERE id = $1`, id) + return err +} + +// --------------- RedemptionCode CRUD --------------- + +// GetCodeByCode fetches a redemption code by its code string. +func (d *DB) GetCodeByCode(code string) (*model.RedemptionCode, error) { + row := d.QueryRow(` + SELECT id, code, is_redeemed, redeemed_at, redeemed_by, account_email, channel, created_at + FROM redemption_codes WHERE code = $1`, code) + var rc model.RedemptionCode + err := row.Scan(&rc.ID, &rc.Code, &rc.IsRedeemed, &rc.RedeemedAt, &rc.RedeemedBy, + &rc.AccountEmail, &rc.Channel, &rc.CreatedAt) + if err != nil { + return nil, err + } + return &rc, nil +} + +// CreateCode inserts a batch of redemption codes for a given account email. +func (d *DB) CreateCodes(accountEmail string, codes []string) error { + tx, err := d.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(` + INSERT INTO redemption_codes (code, account_email) VALUES ($1, $2) + ON CONFLICT (code) DO NOTHING`) + if err != nil { + return err + } + defer stmt.Close() + + for _, c := range codes { + if _, err := stmt.Exec(c, accountEmail); err != nil { + return err + } + } + return tx.Commit() +} + +// RedeemCode marks a code as redeemed. +func (d *DB) RedeemCode(codeID int64, redeemedBy string) error { + _, err := d.Exec(` + UPDATE redemption_codes + SET is_redeemed = TRUE, redeemed_at = NOW()::TEXT, redeemed_by = $1 + WHERE id = $2`, redeemedBy, codeID) + return err +} + +// CountAvailableCodes returns the number of unredeemed codes. +func (d *DB) CountAvailableCodes() (int, error) { + var count int + err := d.QueryRow(`SELECT COUNT(*) FROM redemption_codes WHERE is_redeemed = FALSE`).Scan(&count) + return count, err +} + +// CountAvailableCodesByAccount returns the number of unredeemed codes for a specific account. +func (d *DB) CountAvailableCodesByAccount(accountEmail string) (int, error) { + var count int + err := d.QueryRow(` + SELECT COUNT(*) FROM redemption_codes + WHERE is_redeemed = FALSE AND account_email = $1`, accountEmail).Scan(&count) + return count, err +} + +// GetCodesByAccount returns all codes for an account email. +func (d *DB) GetCodesByAccount(accountEmail string) ([]model.RedemptionCode, error) { + rows, err := d.Query(` + SELECT id, code, is_redeemed, redeemed_at, redeemed_by, account_email, channel, created_at + FROM redemption_codes WHERE account_email = $1 ORDER BY id`, accountEmail) + if err != nil { + return nil, err + } + defer rows.Close() + var list []model.RedemptionCode + for rows.Next() { + var rc model.RedemptionCode + if err := rows.Scan(&rc.ID, &rc.Code, &rc.IsRedeemed, &rc.RedeemedAt, &rc.RedeemedBy, + &rc.AccountEmail, &rc.Channel, &rc.CreatedAt); err != nil { + return nil, err + } + list = append(list, rc) + } + return list, rows.Err() +} + +// GetAllCodes returns all redemption codes. +func (d *DB) GetAllCodes() ([]model.RedemptionCode, error) { + rows, err := d.Query(` + SELECT id, code, is_redeemed, redeemed_at, redeemed_by, account_email, channel, created_at + FROM redemption_codes ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var list []model.RedemptionCode + for rows.Next() { + var rc model.RedemptionCode + if err := rows.Scan(&rc.ID, &rc.Code, &rc.IsRedeemed, &rc.RedeemedAt, &rc.RedeemedBy, + &rc.AccountEmail, &rc.Channel, &rc.CreatedAt); err != nil { + return nil, err + } + list = append(list, rc) + } + return list, rows.Err() +} + +// DeleteCode deletes a specific redemption code. +func (d *DB) DeleteCode(code string) error { + _, err := d.Exec(`DELETE FROM redemption_codes WHERE code = $1`, code) + return err +} + +// DeleteUnusedCodes deletes all redemption codes that haven't been redeemed. +// Returns the number of codes deleted. +func (d *DB) DeleteUnusedCodes() (int64, error) { + res, err := d.Exec(`DELETE FROM redemption_codes WHERE is_redeemed = false`) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// DeleteAccount removes an account and its associated codes. +func (d *DB) DeleteAccount(id int64) error { + // Get account email first. + acct, err := d.GetAccountByID(id) + if err != nil { + return err + } + // Delete associated redemption codes (using the current email). + _, _ = d.Exec(`DELETE FROM redemption_codes WHERE account_email = $1`, acct.Email) + + // Delete the account. + _, err = d.Exec(`DELETE FROM gpt_accounts WHERE id = $1`, id) + + // Also clean up any orphaned codes that don't match any existing account email. + // This fixes issues where codes were generated under a team name before the email was updated. + _, _ = d.Exec(`DELETE FROM redemption_codes WHERE account_email NOT IN (SELECT email FROM gpt_accounts)`) + + return err +} + +// --------------- TelegramAdmin CRUD --------------- + +// AddAdmin inserts a new admin into the database. +func (d *DB) AddAdmin(userID int64, addedBy int64) error { + _, err := d.Exec(` + INSERT INTO telegram_admins (user_id, added_by) + VALUES ($1, $2) + ON CONFLICT (user_id) DO NOTHING`, userID, addedBy) + return err +} + +// RemoveAdmin deletes an admin from the database by user ID. +func (d *DB) RemoveAdmin(userID int64) error { + _, err := d.Exec(`DELETE FROM telegram_admins WHERE user_id = $1`, userID) + return err +} + +// GetAllAdmins returns a list of all admins stored in the database. +func (d *DB) GetAllAdmins() ([]model.TelegramAdmin, error) { + rows, err := d.Query(`SELECT id, user_id, added_by, created_at FROM telegram_admins ORDER BY created_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var list []model.TelegramAdmin + for rows.Next() { + var a model.TelegramAdmin + if err := rows.Scan(&a.ID, &a.UserID, &a.AddedBy, &a.CreatedAt); err != nil { + return nil, err + } + list = append(list, a) + } + return list, nil +} + +// IsAdmin checks if a specific user ID exists in the admin table. +func (d *DB) IsAdmin(userID int64) (bool, error) { + var count int + err := d.QueryRow(`SELECT count(*) FROM telegram_admins WHERE user_id = $1`, userID).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// --------------- helpers --------------- + +func scanAccounts(rows *sql.Rows) ([]model.GptAccount, error) { + var list []model.GptAccount + for rows.Next() { + a, err := scanAccountFromRows(rows) + if err != nil { + return nil, err + } + list = append(list, *a) + } + return list, rows.Err() +} + +func scanAccountFromRows(rows *sql.Rows) (*model.GptAccount, error) { + var a model.GptAccount + err := rows.Scan(&a.ID, &a.Email, &a.Token, &a.RefreshToken, + &a.UserCount, &a.InviteCount, &a.ChatgptAccountID, &a.OaiDeviceID, + &a.ExpireAt, &a.IsOpen, &a.IsBanned, &a.CreatedAt, &a.UpdatedAt) + if err != nil { + return nil, err + } + return &a, nil +} + +func scanAccount(row *sql.Row) (*model.GptAccount, error) { + var a model.GptAccount + err := row.Scan(&a.ID, &a.Email, &a.Token, &a.RefreshToken, + &a.UserCount, &a.InviteCount, &a.ChatgptAccountID, &a.OaiDeviceID, + &a.ExpireAt, &a.IsOpen, &a.IsBanned, &a.CreatedAt, &a.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("账号不存在: %w", err) + } + return &a, nil +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..b75bb99 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,65 @@ +package model + +import "time" + +// GptAccount represents a ChatGPT Team account stored in the database. +type GptAccount struct { + ID int64 `json:"id"` + Email string `json:"email"` + Token string `json:"token"` + RefreshToken string `json:"refresh_token"` + UserCount int `json:"user_count"` + InviteCount int `json:"invite_count"` + ChatgptAccountID string `json:"chatgpt_account_id"` + OaiDeviceID string `json:"oai_device_id"` + ExpireAt string `json:"expire_at"` + IsOpen bool `json:"is_open"` + IsBanned bool `json:"is_banned"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RedemptionCode represents a redemption code stored in the database. +type RedemptionCode struct { + ID int64 `json:"id"` + Code string `json:"code"` + IsRedeemed bool `json:"is_redeemed"` + RedeemedAt *string `json:"redeemed_at"` + RedeemedBy *string `json:"redeemed_by"` + AccountEmail string `json:"account_email"` + Channel string `json:"channel"` + CreatedAt time.Time `json:"created_at"` +} + +// TeamAccountInfo is the data returned from the ChatGPT accounts/check API. +type TeamAccountInfo struct { + AccountID string `json:"account_id"` + Name string `json:"name"` + PlanType string `json:"plan_type"` + ExpiresAt string `json:"expires_at"` + HasActiveSubscription bool `json:"has_active_subscription"` +} + +// ChatGPTUser represents a team member returned from the users API. +type ChatGPTUser struct { + ID string `json:"id"` + AccountUserID string `json:"account_user_id"` + Email string `json:"email"` + Role string `json:"role"` + Name string `json:"name"` +} + +// ChatGPTInvite represents a pending invite returned from the invites API. +type ChatGPTInvite struct { + ID string `json:"id"` + EmailAddress string `json:"email_address"` + Role string `json:"role"` +} + +// TelegramAdmin represents a Telegram admin user stored in the database. +type TelegramAdmin struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + AddedBy int64 `json:"added_by"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/redeem/redeem.go b/internal/redeem/redeem.go new file mode 100644 index 0000000..80bf480 --- /dev/null +++ b/internal/redeem/redeem.go @@ -0,0 +1,139 @@ +package redeem + +import ( + "fmt" + "math/rand" + "regexp" + "strings" + + "go-helper/internal/chatgpt" + "go-helper/internal/database" + "go-helper/internal/model" +) + +var ( + emailRegex = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + codeRegex = regexp.MustCompile(`^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`) + codeChars = []byte("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") // exclude confusable chars +) + +// RedeemResult contains information about a successful redemption. +type RedeemResult struct { + AccountEmail string + InviteOK bool + Message string +} + +// Redeem validates the code, finds an available account, and sends an invite. +func Redeem(db *database.DB, client *chatgpt.Client, code, email string, capacity int) (*RedeemResult, error) { + email = strings.TrimSpace(strings.ToLower(email)) + code = strings.TrimSpace(strings.ToUpper(code)) + + if email == "" { + return nil, fmt.Errorf("请输入邮箱地址") + } + if !emailRegex.MatchString(email) { + return nil, fmt.Errorf("邮箱格式不正确") + } + if code == "" { + return nil, fmt.Errorf("请输入兑换码") + } + if !codeRegex.MatchString(code) { + return nil, fmt.Errorf("兑换码格式不正确(格式:XXXX-XXXX-XXXX)") + } + + // 1. Look up the code. + rc, err := db.GetCodeByCode(code) + if err != nil { + return nil, fmt.Errorf("兑换码不存在或已失效") + } + if rc.IsRedeemed { + return nil, fmt.Errorf("该兑换码已被使用") + } + + // 2. Find a usable account. + var account *model.GptAccount + + if rc.AccountEmail != "" { + // Code is bound to a specific account. + accounts, err := db.GetOpenAccounts(capacity + 100) // get all open + if err != nil { + return nil, fmt.Errorf("查找账号失败: %v", err) + } + for i := range accounts { + if strings.EqualFold(accounts[i].Email, rc.AccountEmail) { + if accounts[i].UserCount+accounts[i].InviteCount < capacity { + account = &accounts[i] + } + break + } + } + if account == nil { + return nil, fmt.Errorf("该兑换码绑定的账号不可用或已满") + } + } else { + // Find any open account with capacity. + accounts, err := db.GetOpenAccounts(capacity) + if err != nil || len(accounts) == 0 { + return nil, fmt.Errorf("暂无可用账号,请稍后再试") + } + account = &accounts[0] + } + + // 3. Send invite. + inviteErr := client.InviteUser(email, account) + + // 4. Mark code as redeemed regardless of invite outcome. + if err := db.RedeemCode(rc.ID, email); err != nil { + return nil, fmt.Errorf("更新兑换码状态失败: %v", err) + } + + // 5. Sync counts. + syncCounts(db, client, account) + + result := &RedeemResult{AccountEmail: account.Email} + if inviteErr != nil { + result.InviteOK = false + result.Message = fmt.Sprintf("兑换成功,但邀请发送失败: %v\n请联系管理员手动添加", inviteErr) + } else { + result.InviteOK = true + result.Message = "兑换成功!邀请邮件已发送到您的邮箱,请查收。" + } + return result, nil +} + +// GenerateCode creates a random code in XXXX-XXXX-XXXX format. +func GenerateCode() string { + parts := make([]byte, 12) + for i := range parts { + parts[i] = codeChars[rand.Intn(len(codeChars))] + } + return fmt.Sprintf("%s-%s-%s", string(parts[0:4]), string(parts[4:8]), string(parts[8:12])) +} + +// GenerateCodes creates n unique codes. +func GenerateCodes(n int) []string { + seen := make(map[string]bool) + var codes []string + for len(codes) < n { + c := GenerateCode() + if !seen[c] { + seen[c] = true + codes = append(codes, c) + } + } + return codes +} + +// syncCounts updates user_count and invite_count from the ChatGPT API. +func syncCounts(db *database.DB, client *chatgpt.Client, account *model.GptAccount) { + userTotal, _, err := client.GetUsers(account) + if err != nil { + return + } + inviteTotal, _, err := client.GetInvites(account) + if err != nil { + return + } + _ = db.UpdateAccountCounts(account.ID, userTotal, inviteTotal) +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..296e2a3 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,96 @@ +package scheduler + +import ( + "log" + "strings" + "time" + + "go-helper/internal/chatgpt" + "go-helper/internal/database" +) + +// StartTokenChecker runs a periodic loop that refreshes access tokens +// for all accounts that have a refresh token. +func StartTokenChecker(db *database.DB, client *chatgpt.Client, intervalMinutes int) { + if intervalMinutes <= 0 { + intervalMinutes = 30 + } + interval := time.Duration(intervalMinutes) * time.Minute + log.Printf("[Scheduler] Token 定时检测已启动,间隔 %d 分钟", intervalMinutes) + + go func() { + // Run once immediately at startup. + checkAndRefreshAll(db, client) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + checkAndRefreshAll(db, client) + } + }() +} + +func checkAndRefreshAll(db *database.DB, client *chatgpt.Client) { + accounts, err := db.GetAccountsWithRT() + if err != nil { + log.Printf("[Scheduler] 获取账号列表失败: %v", err) + return + } + + if len(accounts) == 0 { + return + } + + log.Printf("[Scheduler] 开始检查 %d 个账号的 Token 状态", len(accounts)) + + refreshed, failed, banned := 0, 0, 0 + for _, acc := range accounts { + result, err := client.RefreshAccessToken(acc.RefreshToken) + if err != nil { + log.Printf("[Scheduler] 刷新失败 [ID=%d %s]: %v", acc.ID, acc.Email, err) + failed++ + + // Check if the error indicates token is completely invalid. + errMsg := strings.ToLower(err.Error()) + if strings.Contains(errMsg, "invalid_grant") || + strings.Contains(errMsg, "token has been revoked") || + strings.Contains(errMsg, "unauthorized") { + log.Printf("[Scheduler] RT 无效,标记封号 [ID=%d %s]", acc.ID, acc.Email) + _ = db.BanAccount(acc.ID) + banned++ + } + continue + } + + if err := db.UpdateAccountTokens(acc.ID, result.AccessToken, result.RefreshToken); err != nil { + log.Printf("[Scheduler] 更新 Token 失败 [ID=%d]: %v", acc.ID, err) + failed++ + continue + } + + // Also try to fetch account info to update subscription expiry. + infos, err := client.FetchAccountInfo(result.AccessToken) + if err != nil { + // Token works but account info fetch failed — might be account_deactivated. + errMsg := strings.ToLower(err.Error()) + if strings.Contains(errMsg, "account_deactivated") || strings.Contains(errMsg, "已停用") { + log.Printf("[Scheduler] 账号已停用,标记封号 [ID=%d %s]", acc.ID, acc.Email) + _ = db.BanAccount(acc.ID) + banned++ + continue + } + } else if len(infos) > 0 { + // Update expire_at from subscription info. + for _, info := range infos { + if info.AccountID == acc.ChatgptAccountID && info.ExpiresAt != "" { + _ = db.UpdateAccountInfo(acc.ID, acc.ChatgptAccountID, info.ExpiresAt) + break + } + } + } + + refreshed++ + } + + log.Printf("[Scheduler] 检查完成: 刷新成功 %d, 失败 %d, 封号 %d", refreshed, failed, banned) +}