From a3b4dbf1c1bba2258acb63144088203ff8fd32c7 Mon Sep 17 00:00:00 2001 From: ShukeBta Date: Sun, 7 Jun 2026 12:42:21 +0800 Subject: [PATCH] fix(bot): complete command flow coverage --- internal/service/bot_features_test.go | 116 ++++++++++++++++++++++++++ internal/service/telegram_api_test.go | 5 ++ internal/service/telegram_bot.go | 95 +++++++++++++++++---- internal/service/telegram_menu.go | 113 +++++++++++++++++++++++++ 4 files changed, 312 insertions(+), 17 deletions(-) diff --git a/internal/service/bot_features_test.go b/internal/service/bot_features_test.go index 5cec584..72d975d 100644 --- a/internal/service/bot_features_test.go +++ b/internal/service/bot_features_test.go @@ -319,6 +319,40 @@ func TestBotAdminCommandsManageDevicePolicy(t *testing.T) { } } +func TestBotRegistrationCommandUsesOpenRegQuota(t *testing.T) { + ctx := context.Background() + repos, bot := newBotTestService(t) + admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} + if err := repos.User.Create(ctx, admin); err != nil { + t.Fatal(err) + } + if err := repos.Setting.Set(ctx, SettingOpenRegEnabled, "false"); err != nil { + t.Fatal(err) + } + if err := repos.DB.Create(&model.TelegramBinding{ + TelegramUserID: 9051, + TelegramName: "@root", + ChatID: 9051, + UserID: admin.ID, + }).Error; err != nil { + t.Fatal(err) + } + channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9051"}`} + msg := &TelegramMessage{From: TelegramUser{ID: 9051, Username: "root"}, Chat: TelegramChat{ID: 9051, Type: "private"}} + + reply, err := bot.executeCommand(ctx, channel, msg, "/registration on 2") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "2 个名额") { + t.Fatalf("expected quota feedback, got %q", reply.Text) + } + capacity := bot.loadCapacity(ctx) + if !capacity.OpenRegOn || capacity.OpenRegLimit != 2 || capacity.OpenRegUsed != 0 { + t.Fatalf("registration command should open quota-aware registration, got %+v", capacity) + } +} + func TestBotUserCommandsAndAdminGate(t *testing.T) { ctx := context.Background() repos, bot := newBotTestService(t) @@ -373,3 +407,85 @@ func TestBotUserCommandsAndAdminGate(t *testing.T) { t.Fatal("device should be marked kicked") } } + +func TestBotRedeemRegisterRequiresAllowedTelegramUser(t *testing.T) { + ctx := context.Background() + _, bot := newBotTestService(t) + code, err := bot.generateCode(ctx, model.RegistrationCodeRegister, 30, 0, "") + if err != nil { + t.Fatal(err) + } + channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9001"}`} + msg := &TelegramMessage{From: TelegramUser{ID: 9201, Username: "outsider"}, Chat: TelegramChat{ID: 9201, Type: "private"}} + + reply, err := bot.executeCommand(ctx, channel, msg, "/redeem_register "+code.Code) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "不在管理员配置") { + t.Fatalf("outsider should not redeem register code, got %q", reply.Text) + } + + channel.Config = `{"admin_user_ids":"9201"}` + reply, err = bot.executeCommand(ctx, channel, msg, "/redeem_register "+code.Code) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "兑换成功") { + t.Fatalf("allowed user should redeem register code, got %q", reply.Text) + } + if binding := bot.telegramBinding(ctx, 9201); binding == nil { + t.Fatal("redeemed account should be bound to telegram user") + } +} + +func TestBotAdminCodeAndUserCommands(t *testing.T) { + ctx := context.Background() + repos, bot := newBotTestService(t) + admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true} + user := &model.User{Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true} + if err := repos.User.Create(ctx, admin); err != nil { + t.Fatal(err) + } + if err := repos.User.Create(ctx, user); err != nil { + t.Fatal(err) + } + if err := repos.DB.Create(&model.TelegramBinding{ + TelegramUserID: 9301, + TelegramName: "@root", + ChatID: 9301, + UserID: admin.ID, + }).Error; err != nil { + t.Fatal(err) + } + channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9301"}`} + msg := &TelegramMessage{From: TelegramUser{ID: 9301, Username: "root"}, Chat: TelegramChat{ID: 9301, Type: "private"}} + + reply, err := bot.executeCommand(ctx, channel, msg, "/gencode renew 90 7") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "已生成续期码") { + t.Fatalf("expected generated renew code, got %q", reply.Text) + } + + reply, err = bot.executeCommand(ctx, channel, msg, "/renew_user viewer 30") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "viewer") { + t.Fatalf("renew command should return user actions, got %q", reply.Text) + } + updated, _ := repos.User.FindByID(ctx, user.ID) + if updated.ExpiredAt == nil || updated.ExpiredAt.Before(time.Now()) { + t.Fatalf("renew_user should set future expiry, got %v", updated.ExpiredAt) + } + + reply, err = bot.executeCommand(ctx, channel, msg, "/delete_user viewer") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "需要确认") { + t.Fatalf("delete without confirm should be rejected, got %q", reply.Text) + } +} diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go index 8e838ee..d900695 100644 --- a/internal/service/telegram_api_test.go +++ b/internal/service/telegram_api_test.go @@ -110,4 +110,9 @@ func TestTelegramCommandFiltering(t *testing.T) { if telegramSupportedCommand("/签到") { t.Fatal("unrelated group bot command should not be handled") } + for _, cmd := range []string{"/signin", "/redeem", "/gencode", "/users", "/renew_user", "/delete_user", "/cleanup_rule"} { + if !telegramSupportedCommand(cmd) { + t.Fatalf("%s should be supported so group slash commands get feedback", cmd) + } + } } diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 6f3efdb..8c19f70 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -255,6 +255,8 @@ func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model. return s.cmdHideAdult(ctx, msg, args), nil case "/account", "/me": return s.replyAccount(ctx, msg), nil + case "/signin", "/checkin": + return s.replySignIn(ctx, msg), nil case "/devices": return s.replyDevices(ctx, msg), nil case "/kick": @@ -263,6 +265,12 @@ func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model. return s.cmdSetName(ctx, msg, args), nil case "/setpass", "/passwd", "/password": return s.cmdSetPass(ctx, msg, args), nil + case "/redeem": + return s.cmdRedeem(ctx, channel, msg, args), nil + case "/redeem_register": + return s.cmdRedeemRegister(ctx, channel, msg, args), nil + case "/redeem_renew": + return s.cmdRedeemRenew(ctx, msg, args), nil case "/register", "/reg", "/signup": return s.cmdRegister(ctx, channel, msg, args), nil case "/registration", "/reg_switch", "/openreg": @@ -270,6 +278,31 @@ func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model. return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil } return s.cmdRegistrationToggle(ctx, args), nil + case "/capacity": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.replyCapacity(ctx), nil + case "/users": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.replyUserList(ctx), nil + case "/gencode": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.cmdGenCode(ctx, msg, args), nil + case "/renew_user": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.cmdUserRenew(ctx, args), nil + case "/delete_user": + if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil + } + return s.cmdUserDelete(ctx, args), nil case "/devicepolicy", "/policy": if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { return telegramCommandReply{Text: "此命令仅管理员可用。"}, nil @@ -352,8 +385,10 @@ func telegramCommandName(text string) string { func telegramSupportedCommand(cmd string) bool { switch cmd { case "/start", "/menu", "/cancel", "/help", "/hideadult", "/hide_adult", "/adult", - "/account", "/me", "/devices", "/kick", "/setname", "/rename", "/setpass", "/passwd", "/password", + "/account", "/me", "/signin", "/checkin", "/devices", "/kick", "/setname", "/rename", "/setpass", "/passwd", "/password", + "/redeem", "/redeem_register", "/redeem_renew", "/register", "/reg", "/signup", "/registration", "/reg_switch", "/openreg", + "/capacity", "/users", "/gencode", "/renew_user", "/delete_user", "/devicepolicy", "/policy", "/antishare", "/cleanup", "/cleanup_mode", "/cleanup_rule", "/ban", "/unban", "/status", "/search", "/downloads", "/stats": return true @@ -388,7 +423,7 @@ func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, } } hint := "如果没有账号,请联系管理员注册。" - if s.registrationEnabled(ctx) { + if s.openRegEnabled(ctx) { hint = "如果还没有账号,可直接注册:\n/register 用户名 密码\n或:/register 用户名-密码" } return telegramCommandReply{Text: "欢迎使用 MediaStationGo\n\n普通用户请先绑定账号:\n/start 用户名 密码\n或:/start 用户名-密码\n\n" + hint} @@ -476,27 +511,47 @@ func (s *TelegramBotService) cmdRegister(ctx context.Context, channel *model.Not } } -// cmdRegistrationToggle 处理管理员的 /registration on|off|status 命令。 +// cmdRegistrationToggle handles /registration and /openreg. It uses the same +// quota-aware open-registration state as the inline Bot menu. func (s *TelegramBotService) cmdRegistrationToggle(ctx context.Context, args []string) telegramCommandReply { - current := s.registrationEnabled(ctx) if len(args) == 0 || strings.EqualFold(strings.TrimSpace(args[0]), "status") { - state := map[bool]string{true: "已开启", false: "已关闭"}[current] - return telegramCommandReply{Text: fmt.Sprintf("普通用户 Bot 注册功能当前%s。\n\n开启:/registration on\n关闭:/registration off", state)} + c := s.loadCapacity(ctx) + state := "已关闭" + if c.OpenRegOn { + if c.OpenRegLimit > 0 { + state = fmt.Sprintf("已开启(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit) + } else { + state = "已开启(不限名额,受授权上限约束)" + } + } + return telegramCommandReply{Text: fmt.Sprintf("普通用户 Bot 注册功能当前%s。\n剩余可注册:%d 人。\n\n开启:/registration on 10\n不限:/registration on 0\n关闭:/registration off", state, c.Remaining())} } - var next bool switch strings.ToLower(strings.TrimSpace(args[0])) { case "on", "true", "1", "open", "enable", "enabled", "开启", "打开", "开": - next = true + limit := 0 + if len(args) > 1 { + n, err := strconv.Atoi(strings.TrimSpace(args[1])) + if err != nil || n < 0 { + return telegramCommandReply{Text: "名额必须是非负整数,0 表示不限名额。"} + } + limit = n + } + if err := s.openRegistration(ctx, limit); err != nil { + return telegramCommandReply{Text: "开启失败:" + err.Error()} + } + label := "不限名额" + if limit > 0 { + label = fmt.Sprintf("%d 个名额", limit) + } + return telegramCommandReply{Text: "普通用户 Bot 注册功能已开启:" + label + "。"} case "off", "false", "0", "close", "disable", "disabled", "关闭", "关": - next = false + if err := s.closeRegistration(ctx); err != nil { + return telegramCommandReply{Text: "关闭失败:" + err.Error()} + } + return telegramCommandReply{Text: "普通用户 Bot 注册功能已关闭。"} default: - return telegramCommandReply{Text: "参数无效,请使用 /registration on 或 /registration off。"} + return telegramCommandReply{Text: "参数无效,请使用 /registration on [名额] 或 /registration off。"} } - if err := s.setRegistrationEnabled(ctx, next); err != nil { - return telegramCommandReply{Text: "更新失败:" + err.Error()} - } - state := map[bool]string{true: "已开启", false: "已关闭"}[next] - return telegramCommandReply{Text: fmt.Sprintf("普通用户 Bot 注册功能%s。此设置与系统设置页同步。", state)} } // cmdHelp 处理 /help 命令。 @@ -504,17 +559,19 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) channel := s.findChannelForMessage(ctx, msg) if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) { register := "" - if s.registrationEnabled(ctx) { + if s.openRegEnabled(ctx) { register = "/register 用户名 密码 — 注册新账号\n" } return "MediaStationGo 用户命令\n\n" + register + "/start 用户名 密码 — 绑定账号\n" + "/account — 查看账号状态\n" + + "/signin — 签到\n" + "/devices — 查看登录设备\n" + "/kick all|编号 — 踢下线设备\n" + "/setname 新用户名 — 修改用户名\n" + "/setpass 新密码 — 修改密码\n" + + "/redeem 兑换码 — 注册或续期兑换\n" + "/hideadult on|off — 隐藏或显示成人目录\n\n" + "系统状态、搜索、下载列表与统计命令仅管理员可用。" } @@ -522,9 +579,13 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) "/start — 开始使用\n" + "/help — 帮助信息\n" + "/account / /devices / /kick all|编号 — 用户自助设备管理\n" + + "/signin / /redeem 兑换码 — 签到与兑换\n" + "/setname 新用户名 / /setpass 新密码 — 用户自助改名改密\n" + "/register 用户名 密码 — 注册新账号(需管理员开启)\n" + - "/registration on|off — 开启/关闭普通用户注册(管理员)\n" + + "/registration on [名额]|off — 开启/关闭普通用户注册(管理员)\n" + + "/capacity / /users — 容量与用户管理(管理员)\n" + + "/gencode register|renew 天数 [有效天数] — 生成兑换码(管理员)\n" + + "/renew_user 用户名 天数 / /delete_user 用户名 confirm — 续期/删除用户(管理员)\n" + "/antishare on play=3 login=3 warn=2 — 防共享策略(管理员)\n" + "/cleanup on|off|run — 删号规则开关/巡检(管理员)\n" + "/cleanup_mode any|all|count 2 — 保号模式(管理员)\n" + diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go index 3053be0..853d9a4 100644 --- a/internal/service/telegram_menu.go +++ b/internal/service/telegram_menu.go @@ -274,6 +274,31 @@ func (s *TelegramBotService) cmdSetPass(ctx context.Context, msg *TelegramMessag return s.selfSetPass(ctx, msg, strings.Join(args, " ")) } +func (s *TelegramBotService) cmdRedeem(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply { + if len(args) == 0 { + return telegramCommandReply{Text: "请发送:/redeem 兑换码\n未绑定账号时自动尝试注册码;已绑定账号时自动尝试续期码。"} + } + code := strings.Join(args, " ") + if s.boundUser(ctx, msg.From.ID) == nil { + return s.redeemRegisterFlow(ctx, channel, msg, code) + } + return s.redeemRenewFlow(ctx, msg, code) +} + +func (s *TelegramBotService) cmdRedeemRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply { + if len(args) == 0 { + return telegramCommandReply{Text: "请发送:/redeem_register 注册兑换码"} + } + return s.redeemRegisterFlow(ctx, channel, msg, strings.Join(args, " ")) +} + +func (s *TelegramBotService) cmdRedeemRenew(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { + if len(args) == 0 { + return telegramCommandReply{Text: "请发送:/redeem_renew 续期兑换码"} + } + return s.redeemRenewFlow(ctx, msg, strings.Join(args, " ")) +} + func (s *TelegramBotService) replyAccount(ctx context.Context, msg *TelegramMessage) telegramCommandReply { user := s.boundUser(ctx, msg.From.ID) if user == nil { @@ -387,6 +412,12 @@ func (s *TelegramBotService) selfSetPass(ctx context.Context, msg *TelegramMessa // ── 兑换码流程 ─────────────────────────────────────────────────────────────── func (s *TelegramBotService) redeemRegisterFlow(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, raw string) telegramCommandReply { + if channel == nil { + channel = s.findChannelForMessage(ctx, msg) + } + if !s.telegramUserCanBind(ctx, channel, msg.From.ID) { + return telegramCommandReply{Text: "当前 Telegram 账号不在管理员配置的绑定群组/频道中,无法兑换注册账号。请先加入管理员配置的群组或频道;如果尚未配置,请联系管理员。"} + } rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRegister) if rc == nil { return telegramCommandReply{Text: errMsg} @@ -518,6 +549,50 @@ func (s *TelegramBotService) replyGenCode(ctx context.Context, msg *TelegramMess } } +func (s *TelegramBotService) cmdGenCode(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { + if len(args) < 2 { + return telegramCommandReply{Text: "用法:/gencode register|renew 天数 [有效天数]\n示例:/gencode register 30、/gencode renew 90 7"} + } + kind := strings.ToLower(strings.TrimSpace(args[0])) + switch kind { + case "reg", "register", "注册码": + kind = model.RegistrationCodeRegister + case "renew", "续期", "续期码": + kind = model.RegistrationCodeRenew + default: + return telegramCommandReply{Text: "类型无效,只支持 register / renew。"} + } + days, err := strconv.Atoi(args[1]) + if err != nil || days < 0 { + return telegramCommandReply{Text: "天数必须是非负整数,0 表示永久。"} + } + validDays := 0 + if len(args) > 2 { + validDays, err = strconv.Atoi(args[2]) + if err != nil || validDays < 0 { + return telegramCommandReply{Text: "有效天数必须是非负整数。"} + } + } + createdBy := "" + if u := s.boundUser(ctx, msg.From.ID); u != nil { + createdBy = u.ID + } + code, err := s.generateCode(ctx, kind, days, validDays, createdBy) + if err != nil { + return telegramCommandReply{Text: "生成失败:" + err.Error()} + } + kindLabel := map[string]string{model.RegistrationCodeRegister: "注册码", model.RegistrationCodeRenew: "续期码"}[code.Kind] + dur := "永久" + if days > 0 { + dur = fmt.Sprintf("%d 天", days) + } + valid := "长期有效" + if validDays > 0 && code.ExpiresAt != nil { + valid = "有效至 " + code.ExpiresAt.Format("2006-01-02 15:04") + } + return telegramCommandReply{Text: fmt.Sprintf("已生成%s(%s,%s):\n\n%s", kindLabel, dur, valid, code.Code)} +} + func (s *TelegramBotService) replyUserList(ctx context.Context) telegramCommandReply { users, err := s.repo.User.List(ctx) if err != nil { @@ -622,6 +697,44 @@ func (s *TelegramBotService) replyUserRenew(ctx context.Context, payload string) return s.replyUserActions(ctx, parts[0]) } +func (s *TelegramBotService) cmdUserRenew(ctx context.Context, args []string) telegramCommandReply { + if len(args) < 2 { + return telegramCommandReply{Text: "用法:/renew_user 用户名 天数,天数 0 表示永久。"} + } + user, _ := s.repo.User.FindByUsername(ctx, args[0]) + if user == nil { + user, _ = s.repo.User.FindByID(ctx, args[0]) + } + if user == nil { + return telegramCommandReply{Text: "未找到用户。"} + } + days, err := strconv.Atoi(args[1]) + if err != nil || days < 0 { + return telegramCommandReply{Text: "天数必须是非负整数。"} + } + if err := s.applyRenewal(ctx, user.ID, days); err != nil { + return telegramCommandReply{Text: "续期失败:" + err.Error()} + } + return s.replyUserActions(ctx, user.ID) +} + +func (s *TelegramBotService) cmdUserDelete(ctx context.Context, args []string) telegramCommandReply { + if len(args) == 0 { + return telegramCommandReply{Text: "用法:/delete_user 用户名 confirm\n为避免误删,最后一个参数必须是 confirm。"} + } + if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") { + return telegramCommandReply{Text: "删除用户需要确认:/delete_user 用户名 confirm"} + } + user, _ := s.repo.User.FindByUsername(ctx, args[0]) + if user == nil { + user, _ = s.repo.User.FindByID(ctx, args[0]) + } + if user == nil { + return telegramCommandReply{Text: "未找到用户。"} + } + return s.replyUserDelete(ctx, user.ID) +} + // protectReason returns a non-empty message when a user must not be // disabled/deleted (admins and the default admin are protected). func (s *TelegramBotService) protectReason(ctx context.Context, userID string) string {