Files
MeBox/internal/service/telegram_menu.go
T

1533 lines
57 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = "<b>MediaStationGo 群组自助菜单</b>\n\n你还没有绑定媒体中心账号。绑定、注册、兑换等包含敏感信息的操作请私聊 Bot。"
} else {
adult := map[bool]string{true: "已隐藏", false: "已显示"}[user.HideAdult]
header = fmt.Sprintf("<b>MediaStationGo 群组自助菜单</b>\n\n账号:<b>%s</b>\n到期:<b>%s</b>\n成人目录:<b>%s</b>",
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 = "<b>MediaStationGo</b>\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("<b>MediaStationGo</b>\n\n账号:<b>%s</b>\n到期:<b>%s</b>\n成人目录:<b>%s</b>",
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: "请发送:<code>/start 用户名 密码</code> 绑定已有账号。"}, 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: "请发送新账号的 <b>用户名 密码</b>(空格分隔),例如:<code>alice mypass123</code>"}, true
case data == "act_redeem_register":
if isGroup {
return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码注册")}, true
}
s.setPending(int64(msg.From.ID), "redeem_register")
return telegramCommandReply{Text: "请发送你的<b>注册兑换码</b>,例如:<code>ABCD2345EFGH</code>\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: "请发送你的<b>续期兑换码</b>,将为当前绑定账号续期。"}, 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: "请发送:<code>当前密码 新用户名</code>。"}, true
case data == "act_setpass":
if isGroup {
return telegramCommandReply{Text: telegramGroupPrivateUserHint("修改密码")}, true
}
s.setPending(int64(msg.From.ID), "setpass")
return telegramCommandReply{Text: "请发送:<code>当前密码 新密码</code>(新密码至少 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: "请先绑定账号:<code>/start 用户名 密码</code>"}
}
if len(args) == 0 {
return telegramCommandReply{Text: "请指定要踢下线的设备:<code>/kick all</code> 或 <code>/kick 设备编号</code>。先用 <code>/devices</code> 查看编号。"}
}
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: "未找到该设备。请用 <code>/devices</code> 查看设备编号后重试。"}
}
if err := s.repo.UserDevice.SetKicked(ctx, chosen.ID, true); err != nil {
return telegramCommandReply{Text: "踢下线失败:" + err.Error()}
}
return telegramCommandReply{Text: fmt.Sprintf("已踢下线:<b>%s</b>。", deviceLabel(chosen.DeviceName, chosen.Client))}
}
func (s *TelegramBotService) cmdSetName(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
if len(args) < 2 {
return telegramCommandReply{Text: "请发送:<code>/setname 当前密码 新用户名</code>"}
}
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: "请发送:<code>/setpass 当前密码 新密码</code>"}
}
return s.selfSetPass(ctx, msg, strings.Join(args, " "))
}
func (s *TelegramBotService) cmdRedeem(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply {
if len(args) == 0 {
return telegramCommandReply{Text: "请发送:<code>/redeem 兑换码</code>\n未绑定账号时自动尝试注册码;已绑定账号时自动尝试续期码。"}
}
code := strings.Join(args, " ")
if s.boundUser(ctx, msg.From.ID) == nil {
return s.redeemRegisterFlow(ctx, channel, msg, code)
}
return s.redeemRenewFlow(ctx, msg, code)
}
func (s *TelegramBotService) cmdRedeemRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply {
if len(args) == 0 {
return telegramCommandReply{Text: "请发送:<code>/redeem_register 注册兑换码</code>"}
}
return s.redeemRegisterFlow(ctx, channel, msg, strings.Join(args, " "))
}
func (s *TelegramBotService) cmdRedeemRenew(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
if len(args) == 0 {
return telegramCommandReply{Text: "请发送:<code>/redeem_renew 续期兑换码</code>"}
}
return s.redeemRenewFlow(ctx, msg, strings.Join(args, " "))
}
func (s *TelegramBotService) replyAccount(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号:<code>/start 用户名 密码</code>"}
}
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("<b>我的账号</b>\n\n用户名:<b>%s</b>\n状态:<b>%s</b>\n到期:<b>%s</b>\n连续签到:<b>%d 天</b>\n登录设备:<b>%d 台</b>",
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连续签到 <b>%d</b> 天,累计 <b>%d</b> 天。", res.Streak, res.Total)}
}
return telegramCommandReply{Text: fmt.Sprintf("签到成功 ✅\n连续签到 <b>%d</b> 天,累计 <b>%d</b> 天。", 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("<b>我的登录设备</b>\n点击下方按钮可一键踢下线:\n")
var rows [][]telegramInlineButton
for i, d := range devices {
status := ""
if d.Kicked {
status = "(已踢下线)"
}
sb.WriteString(fmt.Sprintf("\n%d. <b>%s</b>%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: "请发送:<code>当前密码 新用户名</code>。"}
}
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("用户名已修改为 <b>%s</b>。请用新用户名登录。", 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: "请发送:<code>当前密码 新密码</code>。"}
}
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 已绑定账号 <b>%s</b>,无需再用注册码。", 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用户名:<b>%s</b>\n密码:<b>%s</b>\n到期:<b>%s</b>\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("续期成功 ✅ 当前到期:<b>%s</b>", 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("<b>容量 / 状态</b>\n\n授权上限:<b>%d</b> 人(随凭证授权实时变化)\n已用:<b>%d</b> 人\n剩余可注册:<b>%d</b> 人\n开注状态:<b>%s</b>",
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: "<b>开注设置</b>\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: "<b>生成兑换码</b>\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:<kind>:<days>
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<code>%s</code>\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: "用法:<code>/gencode register|renew 天数 [有效天数]</code>\n示例:<code>/gencode register 30</code>、<code>/gencode renew 90 7</code>"}
}
kind := strings.ToLower(strings.TrimSpace(args[0]))
switch kind {
case "reg", "register", "注册码":
kind = model.RegistrationCodeRegister
case "renew", "续期", "续期码":
kind = model.RegistrationCodeRenew
default:
return telegramCommandReply{Text: "类型无效,只支持 register / renew。"}
}
days, err := strconv.Atoi(args[1])
if err != nil || days < 0 {
return telegramCommandReply{Text: "天数必须是非负整数,0 表示永久。"}
}
validDays := 0
if len(args) > 2 {
validDays, err = strconv.Atoi(args[2])
if err != nil || validDays < 0 {
return telegramCommandReply{Text: "有效天数必须是非负整数。"}
}
}
createdBy := ""
if u := s.boundUser(ctx, msg.From.ID); u != nil {
createdBy = u.ID
}
code, err := s.generateCode(ctx, kind, days, validDays, createdBy)
if err != nil {
return telegramCommandReply{Text: "生成失败:" + err.Error()}
}
kindLabel := map[string]string{model.RegistrationCodeRegister: "注册码", model.RegistrationCodeRenew: "续期码"}[code.Kind]
dur := "永久"
if days > 0 {
dur = fmt.Sprintf("%d 天", days)
}
valid := "长期有效"
if validDays > 0 && code.ExpiresAt != nil {
valid = "有效至 " + code.ExpiresAt.Format("2006-01-02 15:04")
}
return telegramCommandReply{Text: fmt.Sprintf("已生成%s(%s,%s):\n\n<code>%s</code>", kindLabel, dur, valid, code.Code)}
}
func (s *TelegramBotService) replyUserList(ctx context.Context) telegramCommandReply {
users, err := s.repo.User.List(ctx)
if err != nil {
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("<b>用户管理</b>(共 %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("<b>%s</b>\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("已删除用户 <b>%s</b>。", name), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}}
}
func (s *TelegramBotService) replyUserRenew(ctx context.Context, payload string) telegramCommandReply {
parts := strings.Split(payload, ":") // <id>:<days>
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: "用法:<code>/renew_user 用户名 天数</code>,天数 0 表示永久。"}
}
user, _ := s.repo.User.FindByUsername(ctx, args[0])
if user == nil {
user, _ = s.repo.User.FindByID(ctx, args[0])
}
if user == nil {
return telegramCommandReply{Text: "未找到用户。"}
}
days, err := strconv.Atoi(args[1])
if err != nil || days < 0 {
return telegramCommandReply{Text: "天数必须是非负整数。"}
}
if err := s.applyRenewal(ctx, user.ID, days); err != nil {
return telegramCommandReply{Text: "续期失败:" + err.Error()}
}
return s.replyUserActions(ctx, user.ID)
}
func (s *TelegramBotService) cmdUserDelete(ctx context.Context, args []string) telegramCommandReply {
if len(args) == 0 {
return telegramCommandReply{Text: "用法:<code>/delete_user 用户名 confirm</code>\n为避免误删,最后一个参数必须是 confirm。"}
}
if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") {
return telegramCommandReply{Text: "删除用户需要确认:<code>/delete_user 用户名 confirm</code>"}
}
user, _ := s.repo.User.FindByUsername(ctx, args[0])
if user == nil {
user, _ = s.repo.User.FindByID(ctx, args[0])
}
if user == nil {
return telegramCommandReply{Text: "未找到用户。"}
}
return s.replyUserDelete(ctx, user.ID)
}
func (s *TelegramBotService) cmdUnbind(ctx context.Context, args []string) telegramCommandReply {
targets := parseTelegramUnbindTargets(args)
if len(targets) == 0 {
return telegramCommandReply{Text: "用法:<code>/unbind 用户名1 用户名2</code>\n也支持逗号分隔,或使用 <code>tg:TelegramID</code> 按 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: "用法:<code>/unbind_inactive 天数</code>\n例如 <code>/unbind_inactive 30</code> 会解绑 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("<b>")
sb.WriteString(title)
sb.WriteString("</b>\n\n")
sb.WriteString(fmt.Sprintf("已解绑:<b>%d</b> 条绑定", 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 := "<code>" + strings.Join(out, "</code>、<code>") + "</code>"
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(
"<b>设备策略</b>\n\n① 防共享:<b>%s</b>\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② 自定义删号规则:<b>%s</b>\n 保号模式:%s;需要满足 %d 条;启用规则 %d 条。\n\n<b>命令:</b>\n<code>/antishare on play=3 login=3 warn=2</code>\n<code>/cleanup on|off|run</code>\n<code>/cleanup_mode any|all|count 2</code>\n<code>/cleanup_rule list|add|del|enable|disable</code>\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: "用法:<code>/devicepolicy</code> 查看策略,或使用 <code>/antishare</code>、<code>/cleanup</code>、<code>/cleanup_rule</code> 管理。"}
}
}
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: "用法:<code>/antishare on|off [play=3] [login=3] [warn=2]</code>"}
}
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("删号规则巡检完成,清理 <b>%d</b> 个账号。", removed)}
default:
return telegramCommandReply{Text: "用法:<code>/cleanup on|off|run</code>"}
}
}
func (s *TelegramBotService) cmdCleanupMode(ctx context.Context, args []string) telegramCommandReply {
if len(args) == 0 {
return telegramCommandReply{Text: "用法:<code>/cleanup_mode any</code>、<code>/cleanup_mode all</code> 或 <code>/cleanup_mode count 2</code>"}
}
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: "用法:<code>/cleanup_rule del 规则ID</code>"}
}
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: "用法:<code>/cleanup_rule enable|disable 规则ID</code>"}
}
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: "用法:<code>/unban 用户名</code>"}
}
return telegramCommandReply{Text: "用法:<code>/ban 用户名</code>"}
}
user, _ := s.repo.User.FindByUsername(ctx, args[0])
if user == nil {
user, _ = s.repo.User.FindByID(ctx, args[0])
}
if user == nil {
return telegramCommandReply{Text: "未找到用户。"}
}
return s.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 "<b>保号规则</b>\n\n暂无规则。"
}
var sb strings.Builder
sb.WriteString("<b>保号规则</b>\n")
for i, r := range rules {
state := map[bool]string{true: "启用", false: "停用"}[r.Enabled]
detail := cleanupRuleDetail(r)
parts := []string{
fmt.Sprintf("\n%d. <code>%s</code>", 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 "<b>删号/保号规则命令</b>\n\n" +
"<code>/cleanup_rule list</code> — 查看规则\n" +
"<code>/cleanup_rule add watch_hours watch_3_5d_6h 观看3到5天满6小时 3 5 6</code>\n" +
"<code>/cleanup_rule add recent_login login_7d 七天内登录 7</code>\n" +
"<code>/cleanup_rule add signin_streak sign_3 连续签到3天 3</code>\n" +
"<code>/cleanup_rule add account_age_grace new_7d 新号宽限7天 7</code>\n" +
"<code>/cleanup_rule enable 规则ID</code> / <code>disable 规则ID</code>\n" +
"<code>/cleanup_rule del 规则ID</code>\n\n" +
"保号模式:<code>/cleanup_mode any|all|count 2</code>"
}
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
}