fix(bot): complete command flow coverage

This commit is contained in:
ShukeBta
2026-06-07 12:42:21 +08:00
parent b38d47a27d
commit a3b4dbf1c1
4 changed files with 312 additions and 17 deletions
+116
View File
@@ -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)
}
}
+5
View File
@@ -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)
}
}
}
+78 -17
View File
@@ -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<code>/register 用户名 密码</code>\n或:<code>/register 用户名-密码</code>"
}
return telegramCommandReply{Text: "<b>欢迎使用 MediaStationGo</b>\n\n普通用户请先绑定账号:\n<code>/start 用户名 密码</code>\n或:<code>/start 用户名-密码</code>\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 注册功能当前<b>%s</b>。\n\n开启:<code>/registration on</code>\n关闭:<code>/registration off</code>", 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 注册功能当前<b>%s</b>。\n剩余可注册:<b>%d</b> 人。\n\n开启:<code>/registration on 10</code>\n不限:<code>/registration on 0</code>\n关闭:<code>/registration off</code>", 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: "参数无效,请使用 <code>/registration on</code> 或 <code>/registration off</code>。"}
return telegramCommandReply{Text: "参数无效,请使用 <code>/registration on [名额]</code> 或 <code>/registration off</code>。"}
}
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 注册功能<b>%s</b>。此设置与系统设置页同步。", 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 = "<b>/register 用户名 密码</b> — 注册新账号\n"
}
return "<b>MediaStationGo 用户命令</b>\n\n" +
register +
"<b>/start 用户名 密码</b> — 绑定账号\n" +
"<b>/account</b> — 查看账号状态\n" +
"<b>/signin</b> — 签到\n" +
"<b>/devices</b> — 查看登录设备\n" +
"<b>/kick all|编号</b> — 踢下线设备\n" +
"<b>/setname 新用户名</b> — 修改用户名\n" +
"<b>/setpass 新密码</b> — 修改密码\n" +
"<b>/redeem 兑换码</b> — 注册或续期兑换\n" +
"<b>/hideadult on|off</b> — 隐藏或显示成人目录\n\n" +
"系统状态、搜索、下载列表与统计命令仅管理员可用。"
}
@@ -522,9 +579,13 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
"<b>/start</b> — 开始使用\n" +
"<b>/help</b> — 帮助信息\n" +
"<b>/account</b> / <b>/devices</b> / <b>/kick all|编号</b> — 用户自助设备管理\n" +
"<b>/signin</b> / <b>/redeem 兑换码</b> — 签到与兑换\n" +
"<b>/setname 新用户名</b> / <b>/setpass 新密码</b> — 用户自助改名改密\n" +
"<b>/register 用户名 密码</b> — 注册新账号(需管理员开启)\n" +
"<b>/registration on|off</b> — 开启/关闭普通用户注册(管理员)\n" +
"<b>/registration on [名额]|off</b> — 开启/关闭普通用户注册(管理员)\n" +
"<b>/capacity</b> / <b>/users</b> — 容量与用户管理(管理员)\n" +
"<b>/gencode register|renew 天数 [有效天数]</b> — 生成兑换码(管理员)\n" +
"<b>/renew_user 用户名 天数</b> / <b>/delete_user 用户名 confirm</b> — 续期/删除用户(管理员)\n" +
"<b>/antishare on play=3 login=3 warn=2</b> — 防共享策略(管理员)\n" +
"<b>/cleanup on|off|run</b> — 删号规则开关/巡检(管理员)\n" +
"<b>/cleanup_mode any|all|count 2</b> — 保号模式(管理员)\n" +
+113
View File
@@ -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: "请发送:<code>/redeem 兑换码</code>\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: "请发送:<code>/redeem_register 注册兑换码</code>"}
}
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: "请发送:<code>/redeem_renew 续期兑换码</code>"}
}
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: "用法:<code>/gencode register|renew 天数 [有效天数]</code>\n示例:<code>/gencode register 30</code>、<code>/gencode renew 90 7</code>"}
}
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<code>%s</code>", 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: "用法:<code>/renew_user 用户名 天数</code>,天数 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: "用法:<code>/delete_user 用户名 confirm</code>\n为避免误删,最后一个参数必须是 confirm。"}
}
if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") {
return telegramCommandReply{Text: "删除用户需要确认:<code>/delete_user 用户名 confirm</code>"}
}
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 {