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 := strings.TrimSpace(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 := u.Role == "admin"
if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == u.ID {
protected = true
}
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 and the default admin are protected).
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 "默认管理员账号受保护,不可禁用/删除。"
}
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② 自定义删号规则:%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|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 {
if len(args) == 0 {
return telegramCommandReply{Text: cleanupRuleHelp()}
}
rules := s.currentCleanupRules(ctx)
action := strings.ToLower(strings.TrimSpace(args[0]))
switch action {
case "list", "ls", "status":
return telegramCommandReply{Text: formatCleanupRules(rules)}
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":
rule, err := parseCleanupRuleCommand(args[1:])
if err != nil {
return telegramCommandReply{Text: err.Error() + "\n\n" + cleanupRuleHelp()}
}
for _, r := range rules {
if r.ID == rule.ID {
return telegramCommandReply{Text: "规则 ID 已存在,请换一个 ID。"}
}
}
rules = normalizeCleanupRules(append(rules, rule))
if err := s.saveCleanupRules(ctx, rules); err != nil {
return telegramCommandReply{Text: "保存失败:" + err.Error()}
}
return telegramCommandReply{Text: "已新增规则。\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)
}
case "recent_login":
name, values := cleanupRuleNameAndValues(args[2:], 1)
rule.Name = name
if len(values) >= 1 {
rule.WindowDaysMax, _ = strconv.Atoi(values[0])
}
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])
}
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 "删号/保号规则命令\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 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
}