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 }