package service import ( "context" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "github.com/ShukeBta/MediaStationGo/internal/model" "gorm.io/gorm" ) // pendingTTL bounds how long a button-initiated text prompt stays valid. const pendingTTL = 5 * time.Minute var ( errRegistrationCodeAlreadyUsed = errors.New("registration code already used") errRegistrationCodeExpired = errors.New("registration code expired") ) func (s *TelegramBotService) setPending(userID int64, kind string) { s.pendingMu.Lock() s.pending[userID] = pendingInput{Kind: kind, CreatedAt: time.Now()} s.pendingMu.Unlock() } func (s *TelegramBotService) takePending(userID int64) (pendingInput, bool) { s.pendingMu.Lock() defer s.pendingMu.Unlock() p, ok := s.pending[userID] if ok { delete(s.pending, userID) } if ok && time.Since(p.CreatedAt) > pendingTTL { return pendingInput{}, false } return p, ok } // boundUser resolves the local user bound to a Telegram account, or nil. func (s *TelegramBotService) boundUser(ctx context.Context, telegramUserID int) *model.User { binding := s.telegramBinding(ctx, telegramUserID) if binding == nil { return nil } u, _ := s.repo.User.FindByID(ctx, binding.UserID) return u } // mainMenu builds the button-based menu, tailored to the user's binding and // admin status. Ordinary users only see self-service actions; admins get an // extra management section. func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) telegramCommandReply { isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID) isGroup := telegramIsGroupChat(msg.Chat.Type) user := s.boundUser(ctx, msg.From.ID) var rows [][]telegramInlineButton var header string if isGroup { if user == nil { header = "MediaStationGo 群组自助菜单\n\n你还没有绑定媒体中心账号。绑定、注册、兑换等包含敏感信息的操作请私聊 Bot。" } else { adult := map[bool]string{true: "已隐藏", false: "已显示"}[user.HideAdult] header = fmt.Sprintf("MediaStationGo 群组自助菜单\n\n账号:%s\n到期:%s\n成人目录:%s", user.Username, formatExpiry(user.ExpiredAt), adult) rows = append(rows, []telegramInlineButton{ {Text: "👤 我的账号", Data: "act_account"}, {Text: "📅 签到", Data: "act_signin"}, }, []telegramInlineButton{ {Text: "📱 我的设备", Data: "act_devices"}, {Text: map[bool]string{true: "🔞 显示成人目录", false: "🔞 隐藏成人目录"}[user.HideAdult], Data: "adult_toggle"}, }, ) } if isAdmin { header += "\n\n" + telegramGroupPrivateAdminHint() } return telegramCommandReply{Text: header, Buttons: rows} } if user == nil { header = "MediaStationGo\n\n你还没有绑定媒体中心账号。" rows = append(rows, []telegramInlineButton{{Text: "🔗 绑定账号", Data: "act_bind"}}) if s.openRegEnabled(ctx) { rows = append(rows, []telegramInlineButton{{Text: "📝 注册新账号", Data: "act_register"}}) } rows = append(rows, []telegramInlineButton{{Text: "🎟 兑换码注册", Data: "act_redeem_register"}}) } else { adult := map[bool]string{true: "已隐藏", false: "已显示"}[user.HideAdult] header = fmt.Sprintf("MediaStationGo\n\n账号:%s\n到期:%s\n成人目录:%s", user.Username, formatExpiry(user.ExpiredAt), adult) rows = append(rows, []telegramInlineButton{ {Text: "👤 我的账号", Data: "act_account"}, {Text: "📅 签到", Data: "act_signin"}, }, []telegramInlineButton{ {Text: "📱 我的设备", Data: "act_devices"}, {Text: map[bool]string{true: "🔞 显示成人目录", false: "🔞 隐藏成人目录"}[user.HideAdult], Data: "adult_toggle"}, }, []telegramInlineButton{ {Text: "✏️ 改用户名", Data: "act_setname"}, {Text: "🔑 改密码", Data: "act_setpass"}, }, []telegramInlineButton{{Text: "🎟 兑换码续期", Data: "act_redeem_renew"}}, ) } if isAdmin { rows = append(rows, []telegramInlineButton{{Text: "—— 管理员 ——", Data: "noop"}}, []telegramInlineButton{ {Text: "📊 容量/状态", Data: "adm_capacity"}, {Text: "👥 用户管理", Data: "adm_users"}, }, []telegramInlineButton{ {Text: "🔓 开注设置", Data: "adm_openreg"}, {Text: "🎟 生成兑换码", Data: "adm_gencode"}, }, []telegramInlineButton{{Text: "⚙️ 设备策略", Data: "adm_devicepolicy"}}, ) } return telegramCommandReply{Text: header, Buttons: rows} } // handleMenuCallback routes inline-button taps. Returns (reply, handled). func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, data string) (telegramCommandReply, bool) { isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID) isGroup := telegramIsGroupChat(msg.Chat.Type) switch { case data == "noop": return telegramCommandReply{}, true case data == "menu_main": return s.mainMenu(ctx, channel, msg), true case data == "act_bind": if isGroup { return telegramCommandReply{Text: telegramGroupPrivateUserHint("绑定账号")}, true } return telegramCommandReply{Text: "请发送:/start 用户名 密码 绑定已有账号。"}, true case data == "act_register": if isGroup { return telegramCommandReply{Text: telegramGroupPrivateUserHint("注册账号")}, true } if !s.openRegEnabled(ctx) { return telegramCommandReply{Text: "注册功能未开放,请联系管理员。"}, true } s.setPending(int64(msg.From.ID), "register") return telegramCommandReply{Text: "请发送新账号的 用户名 密码(空格分隔),例如:alice mypass123"}, true case data == "act_redeem_register": if isGroup { return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码注册")}, true } s.setPending(int64(msg.From.ID), "redeem_register") return telegramCommandReply{Text: "请发送你的注册兑换码,例如:ABCD2345EFGH\n(兑换后会要求设置用户名密码)"}, true case data == "act_redeem_renew": if isGroup { return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码续期")}, true } s.setPending(int64(msg.From.ID), "redeem_renew") return telegramCommandReply{Text: "请发送你的续期兑换码,将为当前绑定账号续期。"}, true case data == "act_account": return s.replyAccount(ctx, msg), true case data == "act_signin": return s.replySignIn(ctx, msg), true case data == "act_devices": return s.replyDevices(ctx, msg), true case data == "act_setname": if isGroup { return telegramCommandReply{Text: telegramGroupPrivateUserHint("修改用户名")}, true } s.setPending(int64(msg.From.ID), "setname") return telegramCommandReply{Text: "请发送:当前密码 新用户名。"}, true case data == "act_setpass": if isGroup { return telegramCommandReply{Text: telegramGroupPrivateUserHint("修改密码")}, true } s.setPending(int64(msg.From.ID), "setpass") return telegramCommandReply{Text: "请发送:当前密码 新密码(新密码至少 6 位)。"}, true case strings.HasPrefix(data, "kick:"): return s.replyKick(ctx, msg, strings.TrimPrefix(data, "kick:")), true } // ── 管理员专属 ── if isGroup { if isAdmin { return telegramCommandReply{Text: telegramGroupPrivateAdminHint()}, true } return telegramCommandReply{}, true } if !isAdmin { return telegramCommandReply{Text: "此功能仅管理员可用。"}, true } switch { case data == "adm_capacity": return s.replyCapacity(ctx), true case data == "adm_openreg": return s.replyOpenRegMenu(ctx), true case data == "adm_openreg_close": _ = s.closeRegistration(ctx) return telegramCommandReply{Text: "已关闭注册。"}, true case strings.HasPrefix(data, "adm_openreg_set:"): n, _ := strconv.Atoi(strings.TrimPrefix(data, "adm_openreg_set:")) if err := s.openRegistration(ctx, n); err != nil { return telegramCommandReply{Text: "开注失败:" + err.Error()}, true } label := "不限" if n > 0 { label = fmt.Sprintf("%d 个名额", n) } return telegramCommandReply{Text: "已开放注册:" + label + "。"}, true case data == "adm_gencode": return s.replyGenCodeMenu(), true case strings.HasPrefix(data, "gc:"): return s.replyGenCode(ctx, msg, data), true case data == "adm_users": return s.replyUserList(ctx), true case strings.HasPrefix(data, "usr:"): return s.replyUserActions(ctx, strings.TrimPrefix(data, "usr:")), true case strings.HasPrefix(data, "uban:"): return s.replyUserBan(ctx, strings.TrimPrefix(data, "uban:"), false), true case strings.HasPrefix(data, "uunban:"): return s.replyUserBan(ctx, strings.TrimPrefix(data, "uunban:"), true), true case strings.HasPrefix(data, "udel:"): return s.replyUserDelete(ctx, strings.TrimPrefix(data, "udel:")), true case strings.HasPrefix(data, "urenew:"): return s.replyUserRenew(ctx, strings.TrimPrefix(data, "urenew:")), true case data == "adm_devicepolicy": return s.replyDevicePolicy(ctx), true case strings.HasPrefix(data, "dp_toggle:"): return s.replyDevicePolicyToggle(ctx, strings.TrimPrefix(data, "dp_toggle:")), true } return telegramCommandReply{}, false } // handlePendingText consumes a button-initiated text prompt. Returns (reply, // handled). handled=false means there was no pending prompt for this user. func (s *TelegramBotService) handlePendingText(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, bool) { p, ok := s.takePending(int64(msg.From.ID)) if !ok { return telegramCommandReply{}, false } switch p.Kind { case "register": return s.cmdRegister(ctx, channel, msg, strings.Fields(text)), true case "redeem_register": return s.redeemRegisterFlow(ctx, channel, msg, text), true case "redeem_renew": return s.redeemRenewFlow(ctx, msg, text), true case "setname": return s.selfSetName(ctx, msg, text), true case "setpass": return s.selfSetPass(ctx, msg, text), true case "openreg_limit": n, err := strconv.Atoi(strings.TrimSpace(text)) if err != nil || n < 0 { return telegramCommandReply{Text: "请输入有效的非负整数。"}, true } if err := s.openRegistration(ctx, n); err != nil { return telegramCommandReply{Text: "开注失败:" + err.Error()}, true } return telegramCommandReply{Text: fmt.Sprintf("已开放注册:%d 个名额。", n)}, true } return telegramCommandReply{}, false } // ── 用户自助 ────────────────────────────────────────────────────────────── func (s *TelegramBotService) cmdKick(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { user := s.boundUser(ctx, msg.From.ID) if user == nil { return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"} } if len(args) == 0 { return telegramCommandReply{Text: "请指定要踢下线的设备:/kick all 或 /kick 设备编号。先用 /devices 查看编号。"} } target := strings.TrimSpace(args[0]) if strings.EqualFold(target, "all") || target == "全部" { if s.device != nil { if err := s.device.KickAllDevices(ctx, user.ID); err != nil { return telegramCommandReply{Text: "踢下线失败:" + err.Error()} } } else if err := s.repo.UserDevice.SetKickedByUser(ctx, user.ID, true); err != nil { return telegramCommandReply{Text: "踢下线失败:" + err.Error()} } return telegramCommandReply{Text: "已踢下线此账号的全部设备。"} } devices, _ := s.repo.UserDevice.ListByUser(ctx, user.ID) if len(devices) == 0 { return telegramCommandReply{Text: "当前没有记录到登录设备。"} } var chosen *model.UserDevice if n, err := strconv.Atoi(target); err == nil && n >= 1 && n <= len(devices) { chosen = &devices[n-1] } else { for i := range devices { if devices[i].ID == target || devices[i].DeviceID == target { chosen = &devices[i] break } } } if chosen == nil { return telegramCommandReply{Text: "未找到该设备。请用 /devices 查看设备编号后重试。"} } if err := s.repo.UserDevice.SetKicked(ctx, chosen.ID, true); err != nil { return telegramCommandReply{Text: "踢下线失败:" + err.Error()} } return telegramCommandReply{Text: fmt.Sprintf("已踢下线:%s。", deviceLabel(chosen.DeviceName, chosen.Client))} } func (s *TelegramBotService) cmdSetName(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { if len(args) < 2 { return telegramCommandReply{Text: "请发送:/setname 当前密码 新用户名"} } return s.selfSetName(ctx, msg, strings.Join(args, " ")) } func (s *TelegramBotService) cmdSetPass(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply { if len(args) < 2 { return telegramCommandReply{Text: "请发送:/setpass 当前密码 新密码"} } 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 { return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"} } streak := 0 if rec, _ := s.repo.SignIn.Get(ctx, user.ID); rec != nil { streak = rec.StreakDays } devices, _ := s.repo.UserDevice.ListByUser(ctx, user.ID) text := fmt.Sprintf("我的账号\n\n用户名:%s\n状态:%s\n到期:%s\n连续签到:%d 天\n登录设备:%d 台", user.Username, map[bool]string{true: "正常", false: "已禁用"}[user.IsActive], formatExpiry(user.ExpiredAt), streak, len(devices)) return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}} } func (s *TelegramBotService) replySignIn(ctx context.Context, msg *TelegramMessage) telegramCommandReply { user := s.boundUser(ctx, msg.From.ID) if user == nil { return telegramCommandReply{Text: "请先绑定账号后再签到。"} } res, err := s.signIn(ctx, user.ID) if err != nil { return telegramCommandReply{Text: "签到失败:" + err.Error()} } if res.AlreadySigned { return telegramCommandReply{Text: fmt.Sprintf("今天已经签到过啦~\n连续签到 %d 天,累计 %d 天。", res.Streak, res.Total)} } return telegramCommandReply{Text: fmt.Sprintf("签到成功 ✅\n连续签到 %d 天,累计 %d 天。", res.Streak, res.Total)} } func (s *TelegramBotService) replyDevices(ctx context.Context, msg *TelegramMessage) telegramCommandReply { user := s.boundUser(ctx, msg.From.ID) if user == nil { return telegramCommandReply{Text: "请先绑定账号。"} } devices, _ := s.repo.UserDevice.ListByUser(ctx, user.ID) if len(devices) == 0 { return telegramCommandReply{Text: "当前没有记录到登录设备。"} } var sb strings.Builder sb.WriteString("我的登录设备\n点击下方按钮可一键踢下线:\n") var rows [][]telegramInlineButton for i, d := range devices { status := "" if d.Kicked { status = "(已踢下线)" } sb.WriteString(fmt.Sprintf("\n%d. %s%s\n 最近活跃:%s", i+1, deviceLabel(d.DeviceName, d.Client), status, d.LastSeenAt.Format("01-02 15:04"))) if !d.Kicked { rows = append(rows, []telegramInlineButton{{Text: "🚫 踢下线:" + deviceLabel(d.DeviceName, d.Client), Data: "kick:" + d.ID}}) } } rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}}) return telegramCommandReply{Text: sb.String(), Buttons: rows} } func (s *TelegramBotService) replyKick(ctx context.Context, msg *TelegramMessage, deviceRowID string) telegramCommandReply { user := s.boundUser(ctx, msg.From.ID) if user == nil { return telegramCommandReply{Text: "请先绑定账号。"} } // Verify the device belongs to this user before kicking. var d model.UserDevice if err := s.repo.DB.WithContext(ctx).Where("id = ? AND user_id = ?", deviceRowID, user.ID).First(&d).Error; err != nil { return telegramCommandReply{Text: "未找到该设备。"} } if err := s.repo.UserDevice.SetKicked(ctx, d.ID, true); err != nil { return telegramCommandReply{Text: "操作失败:" + err.Error()} } return s.replyDevices(ctx, msg) } func (s *TelegramBotService) selfSetName(ctx context.Context, msg *TelegramMessage, input string) telegramCommandReply { user := s.boundUser(ctx, msg.From.ID) if user == nil { return telegramCommandReply{Text: "请先绑定账号。"} } currentPassword, newName := splitCurrentPasswordAndValue(input) if currentPassword == "" || newName == "" { return telegramCommandReply{Text: "请发送:当前密码 新用户名。"} } newName = strings.TrimSpace(newName) if len(newName) < 2 || strings.ContainsAny(newName, " \t\n") { return telegramCommandReply{Text: "用户名至少 2 位且不能含空格,请重试。"} } if reply, ok := s.verifyTelegramSelfPassword(ctx, msg, user, currentPassword); !ok { return reply } if existing, _ := s.repo.User.FindByUsername(ctx, newName); existing != nil && existing.ID != user.ID { return telegramCommandReply{Text: "该用户名已被占用,请换一个。"} } if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"username": newName}); err != nil { return telegramCommandReply{Text: "修改失败:" + err.Error()} } return telegramCommandReply{Text: fmt.Sprintf("用户名已修改为 %s。请用新用户名登录。", newName)} } func (s *TelegramBotService) selfSetPass(ctx context.Context, msg *TelegramMessage, input string) telegramCommandReply { user := s.boundUser(ctx, msg.From.ID) if user == nil { return telegramCommandReply{Text: "请先绑定账号。"} } currentPassword, newPass := splitCurrentPasswordAndValue(input) if currentPassword == "" || newPass == "" { return telegramCommandReply{Text: "请发送:当前密码 新密码。"} } newPass = strings.TrimSpace(newPass) if s.auth == nil { return telegramCommandReply{Text: "服务暂不可用。"} } if err := s.auth.ChangePassword(ctx, user.ID, currentPassword, newPass); err != nil { if errors.Is(err, ErrInvalidCredentials) { _ = s.unbindTelegramUser(ctx, msg.From.ID) return telegramCommandReply{Text: "当前密码验证失败,绑定已自动解绑。请用新密码重新绑定账号。"} } return telegramCommandReply{Text: "修改失败:" + err.Error()} } if s.device != nil { _ = s.device.KickAllDevices(ctx, user.ID) } return telegramCommandReply{Text: "密码已修改,请用新密码重新登录第三方客户端。"} } func splitCurrentPasswordAndValue(input string) (string, string) { fields := strings.Fields(strings.TrimSpace(input)) if len(fields) < 2 { return "", "" } return fields[0], strings.TrimSpace(strings.Join(fields[1:], " ")) } func (s *TelegramBotService) verifyTelegramSelfPassword(ctx context.Context, msg *TelegramMessage, user *model.User, currentPassword string) (telegramCommandReply, bool) { if s.auth == nil { return telegramCommandReply{Text: "服务暂不可用。"}, false } if err := s.auth.VerifyPassword(ctx, user.ID, currentPassword); err != nil { if errors.Is(err, ErrInvalidCredentials) { _ = s.unbindTelegramUser(ctx, msg.From.ID) return telegramCommandReply{Text: "当前密码验证失败,绑定已自动解绑。请用新密码重新绑定账号。"}, false } return telegramCommandReply{Text: "验证失败:" + err.Error()}, false } return telegramCommandReply{}, true } // ── 兑换码流程 ─────────────────────────────────────────────────────────────── 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} } if s.auth == nil { return telegramCommandReply{Text: "注册服务暂不可用。"} } if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { if u, _ := s.repo.User.FindByID(ctx, binding.UserID); u != nil { return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号 %s,无需再用注册码。", u.Username)} } } user, password, claimedCode, err := s.createUserFromRegistrationCode(ctx, rc.Code) if err != nil { if errors.Is(err, errRegistrationCodeAlreadyUsed) { return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"} } if errors.Is(err, errRegistrationCodeExpired) { return telegramCommandReply{Text: "兑换码已过期。"} } if errors.Is(err, ErrUserLimitReached) { return telegramCommandReply{Text: "注册失败:用户数量已达授权上限。"} } return telegramCommandReply{Text: "注册失败:" + err.Error()} } if claimedCode == nil { return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"} } _ = s.upsertTelegramBinding(ctx, msg, user.ID) return telegramCommandReply{ Text: fmt.Sprintf("兑换成功并已创建账号:\n用户名:%s\n密码:%s\n到期:%s\n\n请尽快用「改用户名/改密码」修改为你自己的凭据。", user.Username, password, formatExpiry(s.userExpiry(ctx, user.ID))), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}, } } func (s *TelegramBotService) createUserFromRegistrationCode(ctx context.Context, rawCode string) (*model.User, string, *model.RegistrationCode, error) { code := normalizeRedemptionCode(rawCode) if code == "" { return nil, "", nil, errRegistrationCodeAlreadyUsed } password := randomCode(10) var created model.User var claimed model.RegistrationCode err := s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("code = ? AND kind = ? AND used_at IS NULL", code, model.RegistrationCodeRegister). First(&claimed).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errRegistrationCodeAlreadyUsed } return err } if claimed.IsExpired() { return errRegistrationCodeExpired } var count int64 if err := tx.Model(&model.User{}).Count(&count).Error; err != nil { return err } if count >= LicensedMaxUsers(ctx, s.repo) { return ErrUserLimitReached } hash, err := hashPassword(password) if err != nil { return err } codePrefix := strings.ToLower(claimed.Code) if len(codePrefix) > 8 { codePrefix = codePrefix[:8] } created = model.User{ Username: "u" + codePrefix, PasswordHash: hash, Role: "user", Tier: "free", HideAdult: true, ExpiredAt: renewExpiry(nil, claimed.DurationDays), } if err := tx.Create(&created).Error; err != nil { return err } if err := tx.Create(DefaultPermissions(created.ID)).Error; err != nil { return err } now := time.Now() res := tx.Model(&model.RegistrationCode{}). Where("id = ? AND used_at IS NULL", claimed.ID). Updates(map[string]any{"used_by_user_id": created.ID, "used_at": &now}) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return errRegistrationCodeAlreadyUsed } claimed.UsedByUserID = created.ID claimed.UsedAt = &now return nil }) if err != nil { return nil, "", nil, err } return &created, password, &claimed, nil } func (s *TelegramBotService) redeemRenewFlow(ctx context.Context, msg *TelegramMessage, raw string) telegramCommandReply { user := s.boundUser(ctx, msg.From.ID) if user == nil { return telegramCommandReply{Text: "请先绑定账号再续期。"} } rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRenew) if rc == nil { return telegramCommandReply{Text: errMsg} } if err := s.repo.RegCode.MarkUsed(ctx, rc.ID, user.ID); err != nil { return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"} } if err := s.applyRenewal(ctx, user.ID, rc.DurationDays); err != nil { return telegramCommandReply{Text: "续期失败:" + err.Error()} } return telegramCommandReply{Text: fmt.Sprintf("续期成功 ✅ 当前到期:%s", formatExpiry(s.userExpiry(ctx, user.ID)))} } func (s *TelegramBotService) userExpiry(ctx context.Context, userID string) *time.Time { if u, _ := s.repo.User.FindByID(ctx, userID); u != nil { return u.ExpiredAt } return nil } // ── 管理员:容量 / 开注 / 兑换码 / 用户管理 / 设备策略 ───────────────────────── func (s *TelegramBotService) replyCapacity(ctx context.Context) telegramCommandReply { c := s.loadCapacity(ctx) quota := "未开放" if c.OpenRegOn { if c.OpenRegLimit > 0 { quota = fmt.Sprintf("已开放(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit) } else { quota = "已开放(不限名额,受授权上限约束)" } } text := fmt.Sprintf("容量 / 状态\n\n授权上限:%d 人(随凭证授权实时变化)\n已用:%d 人\n剩余可注册:%d 人\n开注状态:%s", c.MaxUsers, c.UsedUsers, c.Remaining(), quota) return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}} } func (s *TelegramBotService) replyOpenRegMenu(ctx context.Context) telegramCommandReply { c := s.loadCapacity(ctx) state := "未开放" if c.OpenRegOn { state = fmt.Sprintf("已开放(%d/%d)", c.OpenRegUsed, c.OpenRegLimit) } return telegramCommandReply{ Text: "开注设置\n当前:" + state + "\n选择要开放的名额:", Buttons: [][]telegramInlineButton{ {{Text: "5 个", Data: "adm_openreg_set:5"}, {Text: "10 个", Data: "adm_openreg_set:10"}, {Text: "20 个", Data: "adm_openreg_set:20"}}, {{Text: "不限名额", Data: "adm_openreg_set:0"}, {Text: "关闭注册", Data: "adm_openreg_close"}}, {{Text: "⬅️ 返回菜单", Data: "menu_main"}}, }, } } func (s *TelegramBotService) replyGenCodeMenu() telegramCommandReply { return telegramCommandReply{ Text: "生成兑换码\n选择类型与时长:", Buttons: [][]telegramInlineButton{ {{Text: "注册码·30天", Data: "gc:register:30"}, {Text: "注册码·永久", Data: "gc:register:0"}}, {{Text: "续期码·30天", Data: "gc:renew:30"}, {Text: "续期码·90天", Data: "gc:renew:90"}}, {{Text: "⬅️ 返回菜单", Data: "menu_main"}}, }, } } func (s *TelegramBotService) replyGenCode(ctx context.Context, msg *TelegramMessage, data string) telegramCommandReply { parts := strings.Split(data, ":") // gc:: if len(parts) != 3 { return telegramCommandReply{Text: "参数错误。"} } kind := parts[1] days, _ := strconv.Atoi(parts[2]) createdBy := "" if u := s.boundUser(ctx, msg.From.ID); u != nil { createdBy = u.ID } code, err := s.generateCode(ctx, kind, days, 0, 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) } return telegramCommandReply{ Text: fmt.Sprintf("已生成%s(%s):\n\n%s\n\n发给用户在 Bot 中兑换即可。", kindLabel, dur, code.Code), Buttons: [][]telegramInlineButton{{{Text: "再生成一个", Data: "adm_gencode"}, {Text: "⬅️ 返回菜单", Data: "menu_main"}}}, } } 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 { return telegramCommandReply{Text: "读取用户失败:" + err.Error()} } if len(users) == 0 { return telegramCommandReply{Text: "暂无用户。"} } var rows [][]telegramInlineButton limit := len(users) if limit > 12 { limit = 12 } for i := 0; i < limit; i++ { u := users[i] flag := "" if !u.IsActive { flag = "🚫" } if u.Role == "admin" { flag = "👑" } rows = append(rows, []telegramInlineButton{{Text: flag + " " + u.Username, Data: "usr:" + u.ID}}) } rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}}) return telegramCommandReply{Text: fmt.Sprintf("用户管理(共 %d 人,显示前 %d)\n点击用户进行操作:", len(users), limit), Buttons: rows} } func (s *TelegramBotService) replyUserActions(ctx context.Context, userID string) telegramCommandReply { u, err := s.repo.User.FindByID(ctx, userID) if err != nil || u == nil { return telegramCommandReply{Text: "用户不存在。"} } protected := UserIsProtectedAccount(ctx, s.repo, u) text := fmt.Sprintf("%s\n角色:%s\n状态:%s\n到期:%s\n防共享警告:%d 次", u.Username, u.Role, map[bool]string{true: "正常", false: "已禁用"}[u.IsActive], formatExpiry(u.ExpiredAt), u.ShareWarnings) if protected { return telegramCommandReply{Text: text + "\n\n(受保护账号,不可禁用/删除)", Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}} } banBtn := telegramInlineButton{Text: "🚫 禁用", Data: "uban:" + u.ID} if !u.IsActive { banBtn = telegramInlineButton{Text: "✅ 解禁", Data: "uunban:" + u.ID} } return telegramCommandReply{ Text: text, Buttons: [][]telegramInlineButton{ {banBtn, {Text: "⏳ 续期30天", Data: "urenew:" + u.ID + ":30"}}, {{Text: "🗑 删除用户", Data: "udel:" + u.ID}}, {{Text: "⬅️ 返回", Data: "adm_users"}}, }, } } func (s *TelegramBotService) replyUserBan(ctx context.Context, userID string, unban bool) telegramCommandReply { if !unban { if reason := s.protectReason(ctx, userID); reason != "" { return telegramCommandReply{Text: reason} } } updates := map[string]any{"is_active": unban} if unban { updates["share_warnings"] = 0 updates["last_share_warn_at"] = nil } if err := s.repo.User.UpdateFields(ctx, userID, updates); err != nil { return telegramCommandReply{Text: "操作失败:" + err.Error()} } if unban { _ = s.repo.UserDevice.SetKickedByUser(ctx, userID, false) } return s.replyUserActions(ctx, userID) } func (s *TelegramBotService) replyUserDelete(ctx context.Context, userID string) telegramCommandReply { if reason := s.protectReason(ctx, userID); reason != "" { return telegramCommandReply{Text: reason} } u, _ := s.repo.User.FindByID(ctx, userID) _ = s.repo.UserDevice.DeleteByUser(ctx, userID) if err := s.repo.User.Delete(ctx, userID); err != nil { return telegramCommandReply{Text: "删除失败:" + err.Error()} } name := userID if u != nil { name = u.Username } return telegramCommandReply{Text: fmt.Sprintf("已删除用户 %s。", name), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}} } func (s *TelegramBotService) replyUserRenew(ctx context.Context, payload string) telegramCommandReply { parts := strings.Split(payload, ":") // : if len(parts) != 2 { return telegramCommandReply{Text: "参数错误。"} } days, _ := strconv.Atoi(parts[1]) if err := s.applyRenewal(ctx, parts[0], days); err != nil { return telegramCommandReply{Text: "续期失败:" + err.Error()} } 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) } func (s *TelegramBotService) cmdUnbind(ctx context.Context, args []string) telegramCommandReply { targets := parseTelegramUnbindTargets(args) if len(targets) == 0 { return telegramCommandReply{Text: "用法:/unbind 用户名1 用户名2\n也支持逗号分隔,或使用 tg:TelegramID 按 Telegram ID 解绑。此命令只解绑 Bot,不删除媒体账号。"} } var removed int64 var done []string var skipped []string var missing []string for _, target := range targets { if tgIDRaw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok { tgID, err := strconv.ParseInt(tgIDRaw, 10, 64) if err != nil || tgID == 0 { missing = append(missing, target) continue } n, err := s.deleteTelegramBindings(ctx, "telegram_user_id = ?", tgID) if err != nil { return telegramCommandReply{Text: "解绑失败:" + err.Error()} } if n == 0 { missing = append(missing, target) continue } removed += n done = append(done, target) continue } user, _ := s.repo.User.FindByUsername(ctx, target) if user == nil { user, _ = s.repo.User.FindByID(ctx, target) } if user == nil { missing = append(missing, target) continue } if user.Role == "admin" { skipped = append(skipped, user.Username+"(管理员)") continue } n, err := s.deleteTelegramBindings(ctx, "user_id = ?", user.ID) if err != nil { return telegramCommandReply{Text: "解绑失败:" + err.Error()} } if n == 0 { missing = append(missing, user.Username+"(未绑定)") continue } removed += n done = append(done, user.Username) } return formatUnbindResult("批量解绑完成", removed, done, skipped, missing) } func (s *TelegramBotService) cmdUnbindDuplicates(ctx context.Context) telegramCommandReply { if s == nil || s.repo == nil || s.repo.DB == nil { return telegramCommandReply{Text: "仓库不可用。"} } var bindings []model.TelegramBinding if err := s.repo.DB.WithContext(ctx).Order("updated_at desc, created_at desc").Find(&bindings).Error; err != nil { return telegramCommandReply{Text: "读取绑定失败:" + err.Error()} } seenTelegram := make(map[int64]string) seenUser := make(map[string]string) var removeIDs []string var removedLabels []string for _, binding := range bindings { remove := false if binding.UserID == "" || binding.TelegramUserID == 0 { remove = true } else if user, _ := s.repo.User.FindByID(ctx, binding.UserID); user == nil { remove = true } else if _, ok := seenTelegram[binding.TelegramUserID]; ok { remove = true } else if _, ok := seenUser[binding.UserID]; ok { remove = true } if remove { removeIDs = append(removeIDs, binding.ID) removedLabels = append(removedLabels, fmt.Sprintf("tg:%d", binding.TelegramUserID)) continue } seenTelegram[binding.TelegramUserID] = binding.ID seenUser[binding.UserID] = binding.ID } if len(removeIDs) == 0 { return telegramCommandReply{Text: "未发现重复或无效绑定。"} } n, err := s.deleteTelegramBindings(ctx, "id IN ?", removeIDs) if err != nil { return telegramCommandReply{Text: "清理失败:" + err.Error()} } return formatUnbindResult("重复/无效绑定清理完成", n, removedLabels, nil, nil) } func (s *TelegramBotService) cmdUnbindInactive(ctx context.Context, args []string) telegramCommandReply { if len(args) == 0 { return telegramCommandReply{Text: "用法:/unbind_inactive 天数\n例如 /unbind_inactive 30 会解绑 30 天未登录的普通用户 Bot 绑定,不删除账号。"} } days, err := strconv.Atoi(strings.TrimSpace(args[0])) if err != nil || days < 1 { return telegramCommandReply{Text: "天数必须是大于 0 的整数。"} } users, err := s.repo.User.List(ctx) if err != nil { return telegramCommandReply{Text: "读取用户失败:" + err.Error()} } cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour) var userIDs []string var done []string for _, user := range users { if user.Role == "admin" { continue } lastActive := user.CreatedAt if user.LastLoginAt != nil { lastActive = *user.LastLoginAt } if lastActive.IsZero() || lastActive.After(cutoff) { continue } var count int64 _ = s.repo.DB.WithContext(ctx).Model(&model.TelegramBinding{}).Where("user_id = ?", user.ID).Count(&count).Error if count == 0 { continue } userIDs = append(userIDs, user.ID) done = append(done, user.Username) } if len(userIDs) == 0 { return telegramCommandReply{Text: fmt.Sprintf("未发现 %d 天未登录且已绑定 Bot 的普通用户。", days)} } n, err := s.deleteTelegramBindings(ctx, "user_id IN ?", userIDs) if err != nil { return telegramCommandReply{Text: "解绑失败:" + err.Error()} } return formatUnbindResult(fmt.Sprintf("已解绑 %d 天未登录用户", days), n, done, nil, nil) } func parseTelegramUnbindTargets(args []string) []string { seen := make(map[string]struct{}) var targets []string for _, arg := range args { for _, part := range strings.FieldsFunc(arg, func(r rune) bool { return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\t' }) { part = strings.TrimSpace(part) if part == "" { continue } key := strings.ToLower(part) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} targets = append(targets, part) } } return targets } func (s *TelegramBotService) deleteTelegramBindings(ctx context.Context, query string, args ...interface{}) (int64, error) { if s == nil || s.repo == nil || s.repo.DB == nil { return 0, nil } tx := s.repo.DB.WithContext(ctx).Unscoped().Where(query, args...).Delete(&model.TelegramBinding{}) return tx.RowsAffected, tx.Error } func formatUnbindResult(title string, removed int64, done, skipped, missing []string) telegramCommandReply { var sb strings.Builder sb.WriteString("") sb.WriteString(title) sb.WriteString("\n\n") sb.WriteString(fmt.Sprintf("已解绑:%d 条绑定", removed)) if len(done) > 0 { sb.WriteString("\n目标:") sb.WriteString(formatShortList(done, 12)) } if len(skipped) > 0 { sb.WriteString("\n跳过:") sb.WriteString(formatShortList(skipped, 8)) } if len(missing) > 0 { sb.WriteString("\n未找到/未绑定:") sb.WriteString(formatShortList(missing, 8)) } return telegramCommandReply{Text: sb.String()} } func formatShortList(items []string, limit int) string { if len(items) == 0 { return "" } if limit < 1 { limit = 1 } out := items if len(out) > limit { out = out[:limit] } text := "" + strings.Join(out, "、") + "" if len(items) > limit { text += fmt.Sprintf(" 等 %d 项", len(items)) } return text } // protectReason returns a non-empty message when a user must not be // disabled/deleted (admins, default admin and protected-list users). func (s *TelegramBotService) protectReason(ctx context.Context, userID string) string { u, err := s.repo.User.FindByID(ctx, userID) if err != nil || u == nil { return "用户不存在。" } if u.Role == "admin" { return "管理员账号受保护,不可禁用/删除。" } if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == u.ID { return "默认管理员账号受保护,不可禁用/删除。" } if _, ok := ProtectedUserIDSet(ctx, s.repo)[u.ID]; ok { return "该账号在 Bot 保护名单中,不可禁用/删除。" } return "" } func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramCommandReply { cfg := loadBotConfig(ctx, s.repo) text := fmt.Sprintf( "设备策略\n\n① 防共享:%s\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Mgo 保号规则:%s\n 保号模式:%s;需要满足 %d 条;启用规则 %d 条。\n\n命令:\n/antishare on play=3 login=3 warn=2\n/cleanup on|off|run\n/cleanup_mode any|all|count 2\n/cleanup_rule list|add|edit|修改|del|enable|disable\n\n策略默认关闭;清理前会先通过 Bot 通知用户;管理员/受保护账号永不自动处理。", onOff(cfg.AntiShareEnabled), cfg.MaxConcurrentPlay, cfg.MaxLoggedClients, cfg.WarnThreshold, onOff(cfg.AccountCleanupEnabled), cleanupModeLabel(cfg.AccountCleanupKeepMode), cfg.AccountCleanupRequiredCount, countEnabledCleanupRules(cfg.AccountCleanupRules)) return telegramCommandReply{ Text: text, Buttons: [][]telegramInlineButton{ {{Text: toggleLabel("防共享", cfg.AntiShareEnabled), Data: "dp_toggle:antishare"}}, {{Text: toggleLabel("保号规则", cfg.AccountCleanupEnabled), Data: "dp_toggle:cleanup"}}, {{Text: "⬅️ 返回菜单", Data: "menu_main"}}, }, } } func (s *TelegramBotService) cmdDevicePolicy(ctx context.Context, args []string) telegramCommandReply { if len(args) == 0 || strings.EqualFold(args[0], "status") { return s.replyDevicePolicy(ctx) } switch strings.ToLower(strings.TrimSpace(args[0])) { case "run", "sweep": return s.cmdCleanup(ctx, []string{"run"}) default: return telegramCommandReply{Text: "用法:/devicepolicy 查看策略,或使用 /antishare、/cleanup、/cleanup_rule 管理。"} } } func (s *TelegramBotService) cmdAntiShare(ctx context.Context, args []string) telegramCommandReply { if len(args) == 0 || strings.EqualFold(args[0], "status") { return s.replyDevicePolicy(ctx) } enabled, ok := parseCommandBool(args[0]) if !ok { return telegramCommandReply{Text: "用法:/antishare on|off [play=3] [login=3] [warn=2]"} } if err := s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(enabled)); err != nil { return telegramCommandReply{Text: "更新失败:" + err.Error()} } for _, arg := range args[1:] { key, value, ok := strings.Cut(arg, "=") if !ok { continue } n, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || n < 1 { continue } switch strings.ToLower(strings.TrimSpace(key)) { case "play", "maxplay", "播放": _ = s.repo.Setting.Set(ctx, SettingMaxConcurrentPlay, strconv.Itoa(n)) case "login", "client", "clients", "登录": _ = s.repo.Setting.Set(ctx, SettingMaxLoggedClients, strconv.Itoa(n)) case "warn", "warnings", "警告": _ = s.repo.Setting.Set(ctx, SettingWarnThreshold, strconv.Itoa(n)) } } return s.replyDevicePolicy(ctx) } func (s *TelegramBotService) cmdCleanup(ctx context.Context, args []string) telegramCommandReply { if len(args) == 0 || strings.EqualFold(args[0], "status") { return s.replyDevicePolicy(ctx) } switch strings.ToLower(strings.TrimSpace(args[0])) { case "on", "true", "1", "开启", "enable": if err := s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, "true"); err != nil { return telegramCommandReply{Text: "开启失败:" + err.Error()} } return s.replyDevicePolicy(ctx) case "off", "false", "0", "关闭", "disable": if err := s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, "false"); err != nil { return telegramCommandReply{Text: "关闭失败:" + err.Error()} } return s.replyDevicePolicy(ctx) case "run", "sweep", "巡检": device := s.device if device == nil { device = NewDeviceService(s.log, s.repo) } removed, err := device.SweepAccountCleanup(ctx) if err != nil { return telegramCommandReply{Text: "巡检失败:" + err.Error()} } return telegramCommandReply{Text: fmt.Sprintf("保号规则巡检完成,清理 %d 个账号。", removed)} default: return telegramCommandReply{Text: "用法:/cleanup on|off|run"} } } func (s *TelegramBotService) cmdCleanupMode(ctx context.Context, args []string) telegramCommandReply { if len(args) == 0 { return telegramCommandReply{Text: "用法:/cleanup_mode any、/cleanup_mode all 或 /cleanup_mode count 2"} } mode := strings.ToLower(strings.TrimSpace(args[0])) if mode != "any" && mode != "all" && mode != "count" { return telegramCommandReply{Text: "保号模式无效,只支持 any / all / count。"} } if err := s.repo.Setting.Set(ctx, SettingAccountCleanupKeepMode, mode); err != nil { return telegramCommandReply{Text: "更新失败:" + err.Error()} } if mode == "count" && len(args) > 1 { n, err := strconv.Atoi(args[1]) if err == nil && n > 0 { _ = s.repo.Setting.Set(ctx, SettingAccountCleanupRequiredCount, strconv.Itoa(n)) } } return s.replyDevicePolicy(ctx) } func (s *TelegramBotService) cmdCleanupRule(ctx context.Context, args []string) telegramCommandReply { rules := s.currentCleanupRules(ctx) if len(args) == 0 { return telegramCommandReply{Text: formatCleanupRules(rules)} } action := strings.ToLower(strings.TrimSpace(args[0])) switch action { case "list", "ls", "status": return telegramCommandReply{Text: formatCleanupRules(rules)} case "help", "?", "帮助": return telegramCommandReply{Text: cleanupRuleHelp()} case "del", "delete", "rm": if len(args) < 2 { return telegramCommandReply{Text: "用法:/cleanup_rule del 规则ID"} } next := make([]accountCleanupRule, 0, len(rules)) removed := false for _, r := range rules { if r.ID == args[1] { removed = true continue } next = append(next, r) } if !removed { return telegramCommandReply{Text: "未找到该规则。"} } if err := s.saveCleanupRules(ctx, next); err != nil { return telegramCommandReply{Text: "保存失败:" + err.Error()} } return telegramCommandReply{Text: "已删除规则。\n\n" + formatCleanupRules(next)} case "enable", "on", "disable", "off": if len(args) < 2 { return telegramCommandReply{Text: "用法:/cleanup_rule enable|disable 规则ID"} } enable := action == "enable" || action == "on" changed := false for i := range rules { if rules[i].ID == args[1] { rules[i].Enabled = enable changed = true } } if !changed { return telegramCommandReply{Text: "未找到该规则。"} } if err := s.saveCleanupRules(ctx, rules); err != nil { return telegramCommandReply{Text: "保存失败:" + err.Error()} } return telegramCommandReply{Text: "已更新规则状态。\n\n" + formatCleanupRules(rules)} case "add", "set", "edit", "update", "修改", "更新", "改": rule, err := parseCleanupRuleCommand(args[1:]) if err != nil { return telegramCommandReply{Text: err.Error() + "\n\n" + cleanupRuleHelp()} } updated := false for i := range rules { if rules[i].ID == rule.ID { rules[i] = rule updated = true break } } if !updated { rules = append(rules, rule) } rules = normalizeCleanupRules(rules) if err := s.saveCleanupRules(ctx, rules); err != nil { return telegramCommandReply{Text: "保存失败:" + err.Error()} } actionText := "已新增规则。" if updated { actionText = "已更新规则。" } return telegramCommandReply{Text: actionText + "\n\n" + formatCleanupRules(rules)} default: return telegramCommandReply{Text: cleanupRuleHelp()} } } func (s *TelegramBotService) replyDevicePolicyToggle(ctx context.Context, which string) telegramCommandReply { cfg := loadBotConfig(ctx, s.repo) switch which { case "antishare": _ = s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(!cfg.AntiShareEnabled)) case "cleanup": _ = s.repo.Setting.Set(ctx, SettingAccountCleanupEnabled, strconv.FormatBool(!cfg.AccountCleanupEnabled)) } return s.replyDevicePolicy(ctx) } func (s *TelegramBotService) cmdUserBan(ctx context.Context, args []string, unban bool) telegramCommandReply { if len(args) == 0 { if unban { return telegramCommandReply{Text: "用法:/unban 用户名"} } return telegramCommandReply{Text: "用法:/ban 用户名"} } 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.replyUserBan(ctx, user.ID, unban) } func (s *TelegramBotService) currentCleanupRules(ctx context.Context) []accountCleanupRule { cfg := loadBotConfig(ctx, s.repo) return cfg.AccountCleanupRules } func (s *TelegramBotService) saveCleanupRules(ctx context.Context, rules []accountCleanupRule) error { raw, err := json.Marshal(normalizeCleanupRules(rules)) if err != nil { return err } return s.repo.Setting.Set(ctx, SettingAccountCleanupRules, string(raw)) } func parseCommandBool(value string) (bool, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case "on", "true", "1", "yes", "enable", "enabled", "开启", "开": return true, true case "off", "false", "0", "no", "disable", "disabled", "关闭", "关": return false, true default: return false, false } } func parseCleanupRuleCommand(args []string) (accountCleanupRule, error) { if len(args) < 2 { return accountCleanupRule{}, fmt.Errorf("新增规则参数不足") } rule := accountCleanupRule{ Type: strings.ToLower(strings.TrimSpace(args[0])), ID: strings.TrimSpace(args[1]), Enabled: true, WindowDaysMin: 3, WindowDaysMax: 5, MinHours: 6, MinCount: 1, } switch rule.Type { case "watch_hours": name, values := cleanupRuleNameAndValues(args[2:], 3) rule.Name = name if len(values) >= 3 { rule.WindowDaysMin, _ = strconv.Atoi(values[0]) rule.WindowDaysMax, _ = strconv.Atoi(values[1]) rule.MinHours, _ = strconv.ParseFloat(values[2], 64) if rule.Name == "" { rule.Name = fmt.Sprintf("%d~%d 天观看满 %s 小时", rule.WindowDaysMin, rule.WindowDaysMax, formatRuleHours(rule.MinHours)) } } case "recent_login": name, values := cleanupRuleNameAndValues(args[2:], 1) rule.Name = name if len(values) >= 1 { rule.WindowDaysMax, _ = strconv.Atoi(values[0]) if rule.Name == "" { rule.Name = fmt.Sprintf("%d 天内登录", rule.WindowDaysMax) } } case "signin_streak", "account_age_grace": name, values := cleanupRuleNameAndValues(args[2:], 1) rule.Name = name if len(values) >= 1 { rule.MinCount, _ = strconv.Atoi(values[0]) if rule.Name == "" { if rule.Type == "signin_streak" { rule.Name = fmt.Sprintf("连续签到 %d 天", rule.MinCount) } else { rule.Name = fmt.Sprintf("新号宽限 %d 天", rule.MinCount) } } } default: return accountCleanupRule{}, fmt.Errorf("不支持的规则类型:%s", rule.Type) } normalized := normalizeCleanupRules([]accountCleanupRule{rule}) if len(normalized) == 0 { return accountCleanupRule{}, fmt.Errorf("规则无效") } return normalized[0], nil } func cleanupRuleNameAndValues(args []string, numericCount int) (string, []string) { if len(args) == 0 { return "", nil } if len(args) >= numericCount && cleanupRuleValuesAreNumeric(args[:numericCount]) { return "", args } return strings.TrimSpace(args[0]), args[1:] } func cleanupRuleValuesAreNumeric(values []string) bool { for _, value := range values { if _, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err != nil { return false } } return true } func formatCleanupRules(rules []accountCleanupRule) string { if len(rules) == 0 { return "保号规则\n\n暂无规则。" } var sb strings.Builder sb.WriteString("保号规则\n") for i, r := range rules { state := map[bool]string{true: "启用", false: "停用"}[r.Enabled] detail := cleanupRuleDetail(r) parts := []string{ fmt.Sprintf("\n%d. %s", i+1, r.ID), } if shouldShowCleanupRuleName(r, detail) { parts = append(parts, r.Name) } parts = append(parts, cleanupRuleTypeLabel(r.Type), state) if detail != "" { parts = append(parts, detail) } sb.WriteString(strings.Join(parts, " · ")) } return sb.String() } func shouldShowCleanupRuleName(r accountCleanupRule, detail string) bool { name := strings.TrimSpace(r.Name) if name == "" || strings.EqualFold(name, r.ID) { return false } if detail != "" && strings.EqualFold(name, detail) { return false } return true } func cleanupRuleDetail(r accountCleanupRule) string { switch r.Type { case "watch_hours": return fmt.Sprintf("%d~%d 天 %s 小时", r.WindowDaysMin, r.WindowDaysMax, formatRuleHours(r.MinHours)) case "recent_login": return fmt.Sprintf("%d 天内登录", r.WindowDaysMax) case "signin_streak": return fmt.Sprintf("连续签到 %d 天", r.MinCount) case "account_age_grace": return fmt.Sprintf("新号宽限 %d 天", r.MinCount) default: return "" } } func formatRuleHours(hours float64) string { if hours == float64(int(hours)) { return strconv.Itoa(int(hours)) } return fmt.Sprintf("%.1f", hours) } func cleanupRuleTypeLabel(t string) string { switch t { case "watch_hours": return "观看时长" case "recent_login": return "最近登录" case "signin_streak": return "连续签到" case "account_age_grace": return "新号宽限" default: return t } } func cleanupRuleHelp() string { return "Mgo 保号规则命令\n\n" + "/cleanup_rule list — 查看规则\n" + "/cleanup_rule add watch_hours watch_3_5d_6h 观看3到5天满6小时 3 5 6\n" + "/cleanup_rule add recent_login login_7d 七天内登录 7\n" + "/cleanup_rule add signin_streak sign_3 连续签到3天 3\n" + "/cleanup_rule add account_age_grace new_7d 新号宽限7天 7\n" + "/cleanup_rule edit 规则类型 规则ID 名称 参数... — 修改同 ID 规则\n" + "/cleanup_rule 修改 规则类型 规则ID 名称 参数... — 中文修改入口\n" + "/cleanup_rule enable 规则ID / disable 规则ID\n" + "/cleanup_rule del 规则ID\n\n" + "保号模式:/cleanup_mode any|all|count 2" } func onOff(b bool) string { return map[bool]string{true: "已开启", false: "已关闭"}[b] } func toggleLabel(name string, enabled bool) string { if enabled { return "关闭" + name } return "开启" + name } func cleanupModeLabel(mode string) string { switch mode { case "all": return "满足全部规则" case "count": return "满足指定数量" default: return "满足任意一条" } } func countEnabledCleanupRules(rules []accountCleanupRule) int { n := 0 for _, r := range rules { if r.Enabled { n++ } } return n }