package service
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// pendingTTL bounds how long a button-initiated text prompt stays valid.
const pendingTTL = 5 * time.Minute
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)
user := s.boundUser(ctx, msg.From.ID)
var rows [][]telegramInlineButton
var header string
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)
switch {
case data == "noop":
return telegramCommandReply{}, true
case data == "menu_main":
return s.mainMenu(ctx, channel, msg), true
case data == "act_bind":
return telegramCommandReply{Text: "请发送:/start 用户名 密码 绑定已有账号。"}, true
case data == "act_register":
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":
s.setPending(int64(msg.From.ID), "redeem_register")
return telegramCommandReply{Text: "请发送你的注册兑换码,例如:ABCD2345EFGH\n(兑换后会要求设置用户名密码)"}, true
case data == "act_redeem_renew":
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":
s.setPending(int64(msg.From.ID), "setname")
return telegramCommandReply{Text: "请发送新的用户名。"}, true
case data == "act_setpass":
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 !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) == 0 {
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) == 0 {
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, newName string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号。"}
}
newName = strings.TrimSpace(newName)
if len(newName) < 2 || strings.ContainsAny(newName, " \t\n") {
return telegramCommandReply{Text: "用户名至少 2 位且不能含空格,请重试。"}
}
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, newPass string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号。"}
}
newPass = strings.TrimSpace(newPass)
if s.auth == nil {
return telegramCommandReply{Text: "服务暂不可用。"}
}
if err := s.auth.ResetPassword(ctx, user.ID, newPass); err != nil {
return telegramCommandReply{Text: "修改失败:" + err.Error()}
}
if s.device != nil {
_ = s.device.KickAllDevices(ctx, user.ID)
}
return telegramCommandReply{Text: "密码已修改,请用新密码重新登录第三方客户端。"}
}
// ── 兑换码流程 ───────────────────────────────────────────────────────────────
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)}
}
}
// Generate a memorable default account from the code; users can rename via
//「改用户名/改密码」afterwards. We avoid asking for two more text turns here.
username := "u" + strings.ToLower(rc.Code[:8])
password := randomCode(10)
user, _, err := s.auth.Register(ctx, username, password)
if err != nil {
return telegramCommandReply{Text: "注册失败:" + err.Error()}
}
if err := s.repo.RegCode.MarkUsed(ctx, rc.ID, user.ID); err != nil {
// Code was raced; roll back the just-created account to avoid free signups.
_ = s.repo.User.Delete(ctx, user.ID)
return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"}
}
if rc.DurationDays > 0 {
_ = s.applyRenewal(ctx, user.ID, rc.DurationDays)
}
_ = s.upsertTelegramBinding(ctx, msg, user.ID)
return telegramCommandReply{
Text: fmt.Sprintf("兑换成功并已创建账号:\n用户名:%s\n密码:%s\n到期:%s\n\n请尽快用「改用户名/改密码」修改为你自己的凭据。",
username, password, formatExpiry(s.userExpiry(ctx, user.ID))),
Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}},
}
}
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)
}
// 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]),
Name: strings.TrimSpace(args[1]),
Enabled: true,
WindowDaysMin: 3,
WindowDaysMax: 5,
MinHours: 6,
MinCount: 1,
}
if len(args) > 2 {
rule.Name = strings.TrimSpace(args[2])
}
switch rule.Type {
case "watch_hours":
if len(args) >= 6 {
rule.WindowDaysMin, _ = strconv.Atoi(args[3])
rule.WindowDaysMax, _ = strconv.Atoi(args[4])
rule.MinHours, _ = strconv.ParseFloat(args[5], 64)
}
case "recent_login":
if len(args) >= 4 {
rule.WindowDaysMax, _ = strconv.Atoi(args[3])
}
case "signin_streak", "account_age_grace":
if len(args) >= 4 {
rule.MinCount, _ = strconv.Atoi(args[3])
}
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 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]
sb.WriteString(fmt.Sprintf("\n%d. %s · %s · %s · %s", i+1, r.ID, r.Name, cleanupRuleTypeLabel(r.Type), state))
switch r.Type {
case "watch_hours":
sb.WriteString(fmt.Sprintf(" · %d~%d 天 %.1f 小时", r.WindowDaysMin, r.WindowDaysMax, r.MinHours))
case "recent_login":
sb.WriteString(fmt.Sprintf(" · %d 天内登录", r.WindowDaysMax))
case "signin_streak":
sb.WriteString(fmt.Sprintf(" · 连续签到 %d 天", r.MinCount))
case "account_age_grace":
sb.WriteString(fmt.Sprintf(" · 新号宽限 %d 天", r.MinCount))
}
}
return sb.String()
}
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
}