核心功能: - 实现基于 Telegram Inline Button 交互的后台面板与用户端 - 支持通过账密登录和 RT (Refresh Token) 方式添加 ChatGPT Team 账号 - 支持管理、拉取和删除待处理邀请,支持一键清空多余邀请 - 支持按剩余容量自动生成邀请兑换码,支持分页查看与一键清空未使用兑换码 - 随机邀请功能:成功拉人后自动核销兑换码 - 定时检测 Token 状态,实现自动续订/刷新并拦截封禁账号 (处理 401/402 错误) 系统与配置: - 使用 PostgreSQL 数据库管理账号、邀请和兑换记录 - 支持在端内动态添加、移除管理员 - 完善 Docker 部署配置与 .gitignore 规则
2078 lines
62 KiB
Go
2078 lines
62 KiB
Go
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:<id>, ref:<id>, codes_page:<n>, deladmin:<id>, pending_invite:<id>, delinv:<acc_id>:<email>, act_page:<action>:<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 `<refresh_token>`")
|
||
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: <acc_id>:<email>
|
||
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 <TG用户ID>` 或者在面板中输入有效ID", &kb)
|
||
} else {
|
||
b.sendAutoDelete(chatID, "❌ 用法: /add\\_admin `<TG用户ID>`")
|
||
}
|
||
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
|
||
}
|