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 {