mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
Unify Telegram bot retention rules
This commit is contained in:
@@ -197,11 +197,14 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
|
||||
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
|
||||
if err != nil || firstAdmin == nil {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range users {
|
||||
if users[i].ID == firstAdmin.ID {
|
||||
if service.UserIsProtectedAccount(ctx, svc.Repo, &users[i]) {
|
||||
users[i].IsProtected = true
|
||||
}
|
||||
if firstAdmin != nil && users[i].ID == firstAdmin.ID {
|
||||
users[i].IsDefaultAdmin = true
|
||||
users[i].IsProtected = true
|
||||
users[i].Role = "admin"
|
||||
|
||||
@@ -319,6 +319,27 @@ func TestBotAdminCommandsManageDevicePolicy(t *testing.T) {
|
||||
t.Fatalf("cleanup rule not added; reply=%q rules=%+v", reply.Text, cfg.AccountCleanupRules)
|
||||
}
|
||||
|
||||
reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule edit recent_login login_7d 十四天内登录 14")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "已更新规则") {
|
||||
t.Fatalf("expected cleanup rule update reply, got %q", reply.Text)
|
||||
}
|
||||
cfg = loadBotConfig(ctx, repos)
|
||||
matches := 0
|
||||
for _, rule := range cfg.AccountCleanupRules {
|
||||
if rule.ID == "login_7d" {
|
||||
matches++
|
||||
if rule.Type != "recent_login" || rule.WindowDaysMax != 14 || rule.Name != "十四天内登录" {
|
||||
t.Fatalf("cleanup rule should be updated in place, got %+v", rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches != 1 {
|
||||
t.Fatalf("cleanup rule update should not create duplicates, got %d rules=%+v", matches, cfg.AccountCleanupRules)
|
||||
}
|
||||
|
||||
reply, err = bot.executeCommand(ctx, channel, msg, "/cleanup_rule add account_age_grace new_7d 7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
@@ -18,7 +20,7 @@ var (
|
||||
)
|
||||
|
||||
// Bot / 设备管控相关的设置键。全部存储在 settings 表,由管理员通过
|
||||
// Telegram Bot 命令调整。带安全默认值:所有"自动删号"策略默认关闭。
|
||||
// Telegram Bot 命令调整。带安全默认值:所有自动处理策略默认关闭。
|
||||
const (
|
||||
// 开放注册(开注名额)。
|
||||
SettingOpenRegEnabled = "telegram.openreg_enabled" // 是否开放注册
|
||||
@@ -29,23 +31,17 @@ const (
|
||||
SettingAntiShareEnabled = "device.antishare_enabled" // 总开关(默认关)
|
||||
SettingMaxConcurrentPlay = "device.max_concurrent_play" // 最大并发播放设备
|
||||
SettingMaxLoggedClients = "device.max_logged_clients" // 最大同时登录客户端
|
||||
SettingWarnThreshold = "device.warn_threshold" // 警告几次后删号
|
||||
SettingWarnThreshold = "device.warn_threshold" // 警告几次后禁用
|
||||
SettingPlayWindowSeconds = "device.play_window_seconds" // 并发播放判定窗口(秒)
|
||||
SettingClientActiveDays = "device.client_active_days" // 登录设备活跃天数窗口
|
||||
|
||||
// 不活跃清理(独立开关)。
|
||||
SettingInactiveEnabled = "device.inactive_enabled" // 总开关(默认关)
|
||||
SettingInactiveMinHours = "device.inactive_min_hours" // 窗口内最低观看小时
|
||||
SettingInactiveWindowMin = "device.inactive_window_days_min" // 随机窗口下限(天)
|
||||
SettingInactiveWindowMax = "device.inactive_window_days_max" // 随机窗口上限(天)
|
||||
SettingInactiveGraceDays = "device.inactive_grace_days" // 新号宽限期(天)
|
||||
|
||||
// 自定义删号/保号规则。规则默认关闭;开启后按 KeepMode 计算用户
|
||||
// 是否满足足够的保号条件,未满足才会删号。
|
||||
// Sakura 风格保号规则。规则默认关闭;开启后按 KeepMode 计算用户
|
||||
// 是否满足足够的保号条件,未满足才会被清理。
|
||||
SettingAccountCleanupEnabled = "device.account_cleanup_enabled"
|
||||
SettingAccountCleanupKeepMode = "device.account_cleanup_keep_mode" // any / all / count
|
||||
SettingAccountCleanupRequiredCount = "device.account_cleanup_required_count" // keep_mode=count 时需要满足几条
|
||||
SettingAccountCleanupRules = "device.account_cleanup_rules" // JSON []accountCleanupRule
|
||||
SettingProtectedUserIDs = "device.protected_user_ids" // comma separated user IDs; Sakura /prouser
|
||||
)
|
||||
|
||||
// botConfig 是设备管控的已解析配置(含默认值)。
|
||||
@@ -57,12 +53,6 @@ type botConfig struct {
|
||||
PlayWindowSeconds int
|
||||
ClientActiveDays int
|
||||
|
||||
InactiveEnabled bool
|
||||
InactiveMinHours int
|
||||
InactiveWindowMin int
|
||||
InactiveWindowMax int
|
||||
InactiveGraceDays int
|
||||
|
||||
AccountCleanupEnabled bool
|
||||
AccountCleanupKeepMode string
|
||||
AccountCleanupRequiredCount int
|
||||
@@ -92,19 +82,13 @@ type accountCleanupRule struct {
|
||||
// defaultBotConfig returns the safe defaults requested by the operator.
|
||||
func defaultBotConfig() botConfig {
|
||||
return botConfig{
|
||||
AntiShareEnabled: false, // 自动删号默认关闭,需管理员显式开启
|
||||
AntiShareEnabled: false, // 防共享默认关闭,需管理员显式开启
|
||||
MaxConcurrentPlay: 3,
|
||||
MaxLoggedClients: 3,
|
||||
WarnThreshold: 2, // 两次警告后再犯删号
|
||||
WarnThreshold: 2, // 两次警告后再犯禁用
|
||||
PlayWindowSeconds: 90,
|
||||
ClientActiveDays: 30,
|
||||
|
||||
InactiveEnabled: false, // 默认关闭
|
||||
InactiveMinHours: 6,
|
||||
InactiveWindowMin: 3,
|
||||
InactiveWindowMax: 5,
|
||||
InactiveGraceDays: 7,
|
||||
|
||||
AccountCleanupEnabled: false,
|
||||
AccountCleanupKeepMode: "any",
|
||||
AccountCleanupRequiredCount: 1,
|
||||
@@ -134,16 +118,11 @@ func loadBotConfig(ctx context.Context, repo *repository.Container) botConfig {
|
||||
return v
|
||||
}
|
||||
cfg.AntiShareEnabled = parseBoolSetting(get(SettingAntiShareEnabled), cfg.AntiShareEnabled)
|
||||
cfg.InactiveEnabled = parseBoolSetting(get(SettingInactiveEnabled), cfg.InactiveEnabled)
|
||||
cfg.MaxConcurrentPlay = parseIntSettingDefault(get(SettingMaxConcurrentPlay), cfg.MaxConcurrentPlay)
|
||||
cfg.MaxLoggedClients = parseIntSettingDefault(get(SettingMaxLoggedClients), cfg.MaxLoggedClients)
|
||||
cfg.WarnThreshold = parseIntSettingDefault(get(SettingWarnThreshold), cfg.WarnThreshold)
|
||||
cfg.PlayWindowSeconds = parseIntSettingDefault(get(SettingPlayWindowSeconds), cfg.PlayWindowSeconds)
|
||||
cfg.ClientActiveDays = parseIntSettingDefault(get(SettingClientActiveDays), cfg.ClientActiveDays)
|
||||
cfg.InactiveMinHours = parseIntSettingDefault(get(SettingInactiveMinHours), cfg.InactiveMinHours)
|
||||
cfg.InactiveWindowMin = parseIntSettingDefault(get(SettingInactiveWindowMin), cfg.InactiveWindowMin)
|
||||
cfg.InactiveWindowMax = parseIntSettingDefault(get(SettingInactiveWindowMax), cfg.InactiveWindowMax)
|
||||
cfg.InactiveGraceDays = parseIntSettingDefault(get(SettingInactiveGraceDays), cfg.InactiveGraceDays)
|
||||
cfg.AccountCleanupEnabled = parseBoolSetting(get(SettingAccountCleanupEnabled), cfg.AccountCleanupEnabled)
|
||||
cfg.AccountCleanupKeepMode = normalizeCleanupKeepMode(get(SettingAccountCleanupKeepMode), cfg.AccountCleanupKeepMode)
|
||||
cfg.AccountCleanupRequiredCount = parseIntSettingDefault(get(SettingAccountCleanupRequiredCount), cfg.AccountCleanupRequiredCount)
|
||||
@@ -153,15 +132,65 @@ func loadBotConfig(ctx context.Context, repo *repository.Container) botConfig {
|
||||
cfg.AccountCleanupRules = normalizeCleanupRules(rules)
|
||||
}
|
||||
}
|
||||
if cfg.InactiveWindowMax < cfg.InactiveWindowMin {
|
||||
cfg.InactiveWindowMax = cfg.InactiveWindowMin
|
||||
}
|
||||
if cfg.AccountCleanupRequiredCount < 1 {
|
||||
cfg.AccountCleanupRequiredCount = 1
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ProtectedUserIDSet returns the explicit Sakura-compatible protected user list.
|
||||
// Admins and the default admin are protected separately by UserIsProtectedAccount.
|
||||
func ProtectedUserIDSet(ctx context.Context, repo *repository.Container) map[string]struct{} {
|
||||
out := make(map[string]struct{})
|
||||
if repo == nil || repo.Setting == nil {
|
||||
return out
|
||||
}
|
||||
raw, err := repo.Setting.Get(ctx, SettingProtectedUserIDs)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, value := range strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t'
|
||||
}) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
out[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SaveProtectedUserIDSet persists the explicit protected user list.
|
||||
func SaveProtectedUserIDSet(ctx context.Context, repo *repository.Container, ids map[string]struct{}) error {
|
||||
if repo == nil || repo.Setting == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(ids))
|
||||
for id := range ids {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
values = append(values, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(values)
|
||||
return repo.Setting.Set(ctx, SettingProtectedUserIDs, strings.Join(values, ","))
|
||||
}
|
||||
|
||||
// UserIsProtectedAccount reports whether a user is protected from destructive
|
||||
// Bot/device-policy operations.
|
||||
func UserIsProtectedAccount(ctx context.Context, repo *repository.Container, user *model.User) bool {
|
||||
if repo == nil || user == nil {
|
||||
return true
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
if first, err := repo.User.FirstAdmin(ctx); err == nil && first != nil && first.ID == user.ID {
|
||||
return true
|
||||
}
|
||||
_, ok := ProtectedUserIDSet(ctx, repo)[user.ID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// parseIntSettingDefault parses an int setting, returning fallback on error.
|
||||
func parseIntSettingDefault(value string, fallback int) int {
|
||||
n, err := strconv.Atoi(value)
|
||||
@@ -195,7 +224,9 @@ func normalizeCleanupRules(rules []accountCleanupRule) []accountCleanupRule {
|
||||
if r.Name == "" {
|
||||
r.Name = r.ID
|
||||
}
|
||||
inferCleanupRuleValuesFromID(&r)
|
||||
if shouldInferCleanupRuleValues(r) {
|
||||
inferCleanupRuleValuesFromID(&r)
|
||||
}
|
||||
if r.WindowDaysMin < 1 {
|
||||
r.WindowDaysMin = 1
|
||||
}
|
||||
@@ -216,6 +247,11 @@ func normalizeCleanupRules(rules []accountCleanupRule) []accountCleanupRule {
|
||||
return out
|
||||
}
|
||||
|
||||
func shouldInferCleanupRuleValues(rule accountCleanupRule) bool {
|
||||
name := strings.TrimSpace(rule.Name)
|
||||
return name == "" || strings.EqualFold(name, strings.TrimSpace(rule.ID))
|
||||
}
|
||||
|
||||
func inferCleanupRuleValuesFromID(rule *accountCleanupRule) {
|
||||
if rule == nil {
|
||||
return
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// ① 防共享: too many concurrent playbacks / logged-in clients disables the
|
||||
// account immediately; fingerprint mismatch is warning-based and disables
|
||||
// the account after the configured warning threshold.
|
||||
// ② 自定义删号/保号规则: admins define one or more keep rules; a sweep deletes
|
||||
// ② Sakura 保号规则: admins define one or more keep rules; a sweep deletes
|
||||
// accounts that do not satisfy the configured any/all/count rule set.
|
||||
//
|
||||
// Safeguards: admin / protected accounts are never auto disabled or deleted;
|
||||
@@ -56,16 +56,7 @@ func fingerprint(client, deviceName string) string {
|
||||
// isProtected reports whether a user must never be auto disabled/deleted.
|
||||
// Admins are always protected; the earliest admin (default admin) too.
|
||||
func (s *DeviceService) isProtected(ctx context.Context, u *model.User) bool {
|
||||
if u == nil {
|
||||
return true
|
||||
}
|
||||
if u.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
if first, err := s.repo.User.FirstAdmin(ctx); err == nil && first != nil && first.ID == u.ID {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return UserIsProtectedAccount(ctx, s.repo, u)
|
||||
}
|
||||
|
||||
// RecordLogin records (or refreshes) a device session at authentication time
|
||||
|
||||
@@ -138,6 +138,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
deviceSvc := NewDeviceService(log, repos)
|
||||
telegramBot := NewTelegramBotService(log, repos, crypto, authSvc)
|
||||
telegramBot.SetDeviceService(deviceSvc)
|
||||
telegramBot.SetBackupService(backup)
|
||||
// Allow the device-enforcement service to DM users (warnings / deletions)
|
||||
// through their Telegram binding before any destructive action.
|
||||
deviceSvc.SetNotifier(telegramBot.NotifyUserByID)
|
||||
@@ -271,7 +272,7 @@ func (c *Container) Boot() {
|
||||
// 自动扫描云盘媒体库,使内容对所有用户立即可见
|
||||
c.BootCloudLibraries(c.stopCtx)
|
||||
|
||||
// 账号删号/保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||
// Sakura 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
||||
if c.Device != nil {
|
||||
go c.runInactivitySweeper(c.stopCtx)
|
||||
|
||||
@@ -77,6 +77,7 @@ type TelegramBotService struct {
|
||||
crypto *CryptoService
|
||||
auth *AuthService
|
||||
device *DeviceService
|
||||
backup *BackupService
|
||||
|
||||
pollingMu sync.Mutex
|
||||
pollingCancel map[string]context.CancelFunc // bot_token -> cancel
|
||||
@@ -96,6 +97,9 @@ type pendingInput struct {
|
||||
// menu (list / kick) and enforcement notifications.
|
||||
func (s *TelegramBotService) SetDeviceService(d *DeviceService) { s.device = d }
|
||||
|
||||
// SetBackupService wires database backup/restore commands.
|
||||
func (s *TelegramBotService) SetBackupService(b *BackupService) { s.backup = b }
|
||||
|
||||
// NotifyUserByID sends a Telegram message to the local user identified by
|
||||
// userID, resolved through their Telegram binding. Used by enforcement to warn
|
||||
// users before destructive actions. No-op when the user has no binding.
|
||||
@@ -519,9 +523,9 @@ func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage)
|
||||
"<b>/unbind 用户1 用户2</b> — 批量解绑 Telegram 绑定(管理员)\n" +
|
||||
"<b>/unbind_duplicates</b> / <b>/unbind_inactive 天数</b> — 清理重复/无效绑定或久未登录绑定(管理员)\n" +
|
||||
"<b>/antishare on play=3 login=3 warn=2</b> — 防共享策略(管理员)\n" +
|
||||
"<b>/cleanup on|off|run</b> — 删号规则开关/巡检(管理员)\n" +
|
||||
"<b>/cleanup on|off|run</b> — 保号规则开关/巡检(管理员)\n" +
|
||||
"<b>/cleanup_mode any|all|count 2</b> — 保号模式(管理员)\n" +
|
||||
"<b>/cleanup_rule list|add|del|enable|disable</b> — 保号规则(管理员)\n" +
|
||||
"<b>/cleanup_rule list|add|edit|del|enable|disable</b> — Sakura 保号规则(管理员)\n" +
|
||||
"<b>/ban 用户名</b> / <b>/unban 用户名</b> — 禁用/解禁用户(管理员)\n" +
|
||||
"<b>/hideadult on|off</b> — 隐藏/显示当前绑定账号的成人目录\n" +
|
||||
"<b>/status</b> — 系统运行状态\n" +
|
||||
|
||||
@@ -56,7 +56,7 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha
|
||||
|
||||
{Aliases: []string{"/registration", "/reg_switch", "/openreg"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRegistrationToggle(ctx, args), nil }},
|
||||
{Aliases: []string{"/capacity"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyCapacity(ctx), nil }},
|
||||
{Aliases: []string{"/users"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyUserList(ctx), nil }},
|
||||
{Aliases: []string{"/users", "/kk"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.replyUserList(ctx), nil }},
|
||||
{Aliases: []string{"/gencode"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdGenCode(ctx, msg, args), nil }},
|
||||
{Aliases: []string{"/renew_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserRenew(ctx, args), nil }},
|
||||
{Aliases: []string{"/delete_user"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserDelete(ctx, args), nil }},
|
||||
@@ -95,8 +95,11 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha
|
||||
{Aliases: []string{"/renewall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraRenewAll(ctx, args), nil }},
|
||||
{Aliases: []string{"/callall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraCallAll(ctx, channel, args), nil }},
|
||||
{Aliases: []string{"/syncunbound"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraSyncUnbound(ctx, args), nil }},
|
||||
{Aliases: []string{"/syncgroupm", "/kick_not_emby"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
return s.cmdSakuraUnsupported("群成员全量同步", "<code>/syncunbound</code> 检查未绑定 Bot 的站内账号"), nil
|
||||
{Aliases: []string{"/syncgroupm"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
return s.cmdSakuraSyncGroup(ctx, channel, args), nil
|
||||
}},
|
||||
{Aliases: []string{"/kick_not_emby"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
return s.cmdSakuraUnsupported("群内无号用户清理", "<code>/syncgroupm</code> 可检查已绑定账号是否仍在群内;Telegram Bot API 无法枚举全部群成员,因此不能可靠找出“在群但无号”的用户。"), nil
|
||||
}},
|
||||
{Aliases: []string{"/scan_embyname"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraScanNames(ctx), nil }},
|
||||
{Aliases: []string{"/check_ex"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraCheckExpired(ctx, args), nil }},
|
||||
@@ -123,7 +126,15 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha
|
||||
{Aliases: []string{"/revadmin"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
return s.cmdSakuraBotAdmin(ctx, channel, args, false), nil
|
||||
}},
|
||||
{Aliases: []string{"/backup_db", "/restore_from_db", "/restart", "/update_bot", "/paolu", "/bindall_id", "/sync_favorites", "/coins", "/score", "/coinsall", "/coinsclear", "/red", "/srank", "/prouser", "/revuser", "/white_channel", "/rev_white_channel", "/unban_channel"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
{Aliases: []string{"/backup_db"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraBackupDB(ctx), nil }},
|
||||
{Aliases: []string{"/restore_from_db"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraRestoreDB(ctx, args), nil }},
|
||||
{Aliases: []string{"/prouser"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
return s.cmdSakuraProtectedUser(ctx, args, true), nil
|
||||
}},
|
||||
{Aliases: []string{"/revuser"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
return s.cmdSakuraProtectedUser(ctx, args, false), nil
|
||||
}},
|
||||
{Aliases: []string{"/restart", "/update_bot", "/paolu", "/bindall_id", "/sync_favorites", "/coins", "/score", "/coinsall", "/coinsclear", "/red", "/srank", "/white_channel", "/rev_white_channel", "/unban_channel", "/config"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) {
|
||||
return s.cmdSakuraUnsupported("Sakura 专属命令", "这些命令涉及外部 Bot 自身运维/积分红包/皮套人管理,已在 MediaStationGo 中由权限、通知渠道、设备策略替代。"), nil
|
||||
}},
|
||||
}
|
||||
@@ -184,7 +195,7 @@ var telegramSupportedCommandSet = map[string]struct{}{
|
||||
"/account": {}, "/me": {}, "/myinfo": {}, "/count": {}, "/signin": {}, "/checkin": {}, "/devices": {}, "/kick": {}, "/setname": {}, "/rename": {}, "/setpass": {}, "/passwd": {}, "/password": {},
|
||||
"/redeem": {}, "/redeem_register": {}, "/redeem_renew": {},
|
||||
"/register": {}, "/reg": {}, "/signup": {}, "/registration": {}, "/reg_switch": {}, "/openreg": {},
|
||||
"/capacity": {}, "/users": {}, "/gencode": {}, "/renew_user": {}, "/delete_user": {}, "/unbind": {}, "/unbind_duplicates": {}, "/unbind_inactive": {},
|
||||
"/capacity": {}, "/users": {}, "/kk": {}, "/gencode": {}, "/renew_user": {}, "/delete_user": {}, "/unbind": {}, "/unbind_duplicates": {}, "/unbind_inactive": {},
|
||||
"/devicepolicy": {}, "/policy": {}, "/antishare": {}, "/cleanup": {}, "/cleanup_mode": {}, "/cleanup_rule": {},
|
||||
"/ban": {}, "/unban": {}, "/status": {}, "/search": {}, "/downloads": {}, "/stats": {},
|
||||
"/renew": {}, "/ucr": {}, "/uinfo": {}, "/rmemby": {}, "/urm": {}, "/only_rm_emby": {}, "/only_rm_record": {},
|
||||
@@ -193,7 +204,7 @@ var telegramSupportedCommandSet = map[string]struct{}{
|
||||
"/check_ex": {}, "/deleted": {}, "/low_activity": {}, "/uranks": {}, "/days_ranks": {}, "/week_ranks": {},
|
||||
"/embyadmin": {}, "/unbanall": {}, "/banall": {}, "/embylibs_unblockall": {}, "/embylibs_blockall": {},
|
||||
"/extraembylibs_unblockall": {}, "/extraembylibs_blockall": {}, "/proadmin": {}, "/revadmin": {},
|
||||
"/backup_db": {}, "/restore_from_db": {}, "/restart": {}, "/update_bot": {}, "/paolu": {}, "/bindall_id": {}, "/sync_favorites": {},
|
||||
"/backup_db": {}, "/restore_from_db": {}, "/restart": {}, "/update_bot": {}, "/paolu": {}, "/bindall_id": {}, "/sync_favorites": {}, "/config": {},
|
||||
"/coins": {}, "/score": {}, "/coinsall": {}, "/coinsclear": {}, "/red": {}, "/srank": {}, "/prouser": {}, "/revuser": {},
|
||||
"/white_channel": {}, "/rev_white_channel": {}, "/unban_channel": {},
|
||||
}
|
||||
@@ -243,8 +254,8 @@ func telegramAdminBotCommandMenu() []telegramBotCommand {
|
||||
telegramBotCommand{Command: "downloads", Description: "下载列表(管理员)"},
|
||||
telegramBotCommand{Command: "stats", Description: "媒体库统计(管理员)"},
|
||||
telegramBotCommand{Command: "users", Description: "用户管理(管理员)"},
|
||||
telegramBotCommand{Command: "cleanup", Description: "删号规则巡检(管理员)"},
|
||||
telegramBotCommand{Command: "cleanup_rule", Description: "保号规则管理(管理员)"},
|
||||
telegramBotCommand{Command: "cleanup", Description: "保号规则开关/巡检(管理员)"},
|
||||
telegramBotCommand{Command: "cleanup_rule", Description: "Sakura保号规则管理(管理员)"},
|
||||
)
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -788,10 +788,7 @@ func (s *TelegramBotService) replyUserActions(ctx context.Context, userID string
|
||||
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
|
||||
}
|
||||
protected := UserIsProtectedAccount(ctx, s.repo, u)
|
||||
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 {
|
||||
@@ -1107,7 +1104,7 @@ func formatShortList(items []string, limit int) string {
|
||||
}
|
||||
|
||||
// protectReason returns a non-empty message when a user must not be
|
||||
// disabled/deleted (admins and the default admin are protected).
|
||||
// disabled/deleted (admins, default admin and protected-list users).
|
||||
func (s *TelegramBotService) protectReason(ctx context.Context, userID string) string {
|
||||
u, err := s.repo.User.FindByID(ctx, userID)
|
||||
if err != nil || u == nil {
|
||||
@@ -1119,20 +1116,23 @@ func (s *TelegramBotService) protectReason(ctx context.Context, userID string) s
|
||||
if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == u.ID {
|
||||
return "默认管理员账号受保护,不可禁用/删除。"
|
||||
}
|
||||
if _, ok := ProtectedUserIDSet(ctx, s.repo)[u.ID]; ok {
|
||||
return "该账号在 Bot 保护名单中,不可禁用/删除。"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramCommandReply {
|
||||
cfg := loadBotConfig(ctx, s.repo)
|
||||
text := fmt.Sprintf(
|
||||
"<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 通知用户;管理员/受保护账号永不自动处理。",
|
||||
"<b>设备策略</b>\n\n① 防共享:<b>%s</b>\n 并发播放上限 %d / 登录客户端上限 %d;超限会禁用账号,管理员可解禁。\n 设备指纹异常警告 %d 次后禁用账号。\n\n② Sakura 保号规则:<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|edit|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: toggleLabel("保号规则", cfg.AccountCleanupEnabled), Data: "dp_toggle:cleanup"}},
|
||||
{{Text: "⬅️ 返回菜单", Data: "menu_main"}},
|
||||
},
|
||||
}
|
||||
@@ -1206,7 +1206,7 @@ func (s *TelegramBotService) cmdCleanup(ctx context.Context, args []string) tele
|
||||
if err != nil {
|
||||
return telegramCommandReply{Text: "巡检失败:" + err.Error()}
|
||||
}
|
||||
return telegramCommandReply{Text: fmt.Sprintf("删号规则巡检完成,清理 <b>%d</b> 个账号。", removed)}
|
||||
return telegramCommandReply{Text: fmt.Sprintf("保号规则巡检完成,清理 <b>%d</b> 个账号。", removed)}
|
||||
default:
|
||||
return telegramCommandReply{Text: "用法:<code>/cleanup on|off|run</code>"}
|
||||
}
|
||||
@@ -1280,21 +1280,31 @@ func (s *TelegramBotService) cmdCleanupRule(ctx context.Context, args []string)
|
||||
return telegramCommandReply{Text: "保存失败:" + err.Error()}
|
||||
}
|
||||
return telegramCommandReply{Text: "已更新规则状态。\n\n" + formatCleanupRules(rules)}
|
||||
case "add":
|
||||
case "add", "set", "edit", "update":
|
||||
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。"}
|
||||
updated := false
|
||||
for i := range rules {
|
||||
if rules[i].ID == rule.ID {
|
||||
rules[i] = rule
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
rules = normalizeCleanupRules(append(rules, rule))
|
||||
if !updated {
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
rules = normalizeCleanupRules(rules)
|
||||
if err := s.saveCleanupRules(ctx, rules); err != nil {
|
||||
return telegramCommandReply{Text: "保存失败:" + err.Error()}
|
||||
}
|
||||
return telegramCommandReply{Text: "已新增规则。\n\n" + formatCleanupRules(rules)}
|
||||
actionText := "已新增规则。"
|
||||
if updated {
|
||||
actionText = "已更新规则。"
|
||||
}
|
||||
return telegramCommandReply{Text: actionText + "\n\n" + formatCleanupRules(rules)}
|
||||
default:
|
||||
return telegramCommandReply{Text: cleanupRuleHelp()}
|
||||
}
|
||||
@@ -1373,18 +1383,31 @@ func parseCleanupRuleCommand(args []string) (accountCleanupRule, error) {
|
||||
rule.WindowDaysMin, _ = strconv.Atoi(values[0])
|
||||
rule.WindowDaysMax, _ = strconv.Atoi(values[1])
|
||||
rule.MinHours, _ = strconv.ParseFloat(values[2], 64)
|
||||
if rule.Name == "" {
|
||||
rule.Name = fmt.Sprintf("%d~%d 天观看满 %s 小时", rule.WindowDaysMin, rule.WindowDaysMax, formatRuleHours(rule.MinHours))
|
||||
}
|
||||
}
|
||||
case "recent_login":
|
||||
name, values := cleanupRuleNameAndValues(args[2:], 1)
|
||||
rule.Name = name
|
||||
if len(values) >= 1 {
|
||||
rule.WindowDaysMax, _ = strconv.Atoi(values[0])
|
||||
if rule.Name == "" {
|
||||
rule.Name = fmt.Sprintf("%d 天内登录", rule.WindowDaysMax)
|
||||
}
|
||||
}
|
||||
case "signin_streak", "account_age_grace":
|
||||
name, values := cleanupRuleNameAndValues(args[2:], 1)
|
||||
rule.Name = name
|
||||
if len(values) >= 1 {
|
||||
rule.MinCount, _ = strconv.Atoi(values[0])
|
||||
if rule.Name == "" {
|
||||
if rule.Type == "signin_streak" {
|
||||
rule.Name = fmt.Sprintf("连续签到 %d 天", rule.MinCount)
|
||||
} else {
|
||||
rule.Name = fmt.Sprintf("新号宽限 %d 天", rule.MinCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return accountCleanupRule{}, fmt.Errorf("不支持的规则类型:%s", rule.Type)
|
||||
@@ -1488,12 +1511,13 @@ func cleanupRuleTypeLabel(t string) string {
|
||||
}
|
||||
|
||||
func cleanupRuleHelp() string {
|
||||
return "<b>删号/保号规则命令</b>\n\n" +
|
||||
return "<b>Sakura 保号规则命令</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 edit 规则类型 规则ID 名称 参数...</code> — 修改同 ID 规则\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>"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -212,7 +213,10 @@ func (s *TelegramBotService) cmdSakuraBanAll(ctx context.Context, active bool, a
|
||||
}
|
||||
var count int
|
||||
for _, user := range users {
|
||||
if user.Role == "admin" {
|
||||
if !active && UserIsProtectedAccount(ctx, s.repo, &user) {
|
||||
continue
|
||||
}
|
||||
if active && user.Role == "admin" {
|
||||
continue
|
||||
}
|
||||
updates := map[string]any{"is_active": active}
|
||||
@@ -266,6 +270,9 @@ func (s *TelegramBotService) cmdSakuraSyncUnbound(ctx context.Context, args []st
|
||||
if len(args) >= 2 && strings.EqualFold(args[0], "delete") && strings.EqualFold(args[1], "confirm") {
|
||||
deleted := 0
|
||||
for _, user := range users {
|
||||
if UserIsProtectedAccount(ctx, s.repo, &user) {
|
||||
continue
|
||||
}
|
||||
_ = s.repo.UserDevice.DeleteByUser(ctx, user.ID)
|
||||
if err := s.repo.User.Delete(ctx, user.ID); err == nil {
|
||||
deleted++
|
||||
@@ -295,7 +302,7 @@ func (s *TelegramBotService) cmdSakuraCheckExpired(ctx context.Context, args []s
|
||||
if len(args) >= 2 && strings.EqualFold(args[0], "disable") && strings.EqualFold(args[1], "confirm") {
|
||||
disabled := 0
|
||||
for _, user := range users {
|
||||
if user.Role == "admin" {
|
||||
if UserIsProtectedAccount(ctx, s.repo, &user) {
|
||||
continue
|
||||
}
|
||||
if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"is_active": false}); err == nil {
|
||||
@@ -482,6 +489,169 @@ func (s *TelegramBotService) cmdSakuraBotAdmin(ctx context.Context, channel *mod
|
||||
return telegramCommandReply{Text: "已移除 Bot 管理员:<code>" + tgID + "</code>"}
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) cmdSakuraProtectedUser(ctx context.Context, args []string, protect bool) telegramCommandReply {
|
||||
if len(args) == 0 || strings.EqualFold(args[0], "list") {
|
||||
return s.cmdSakuraProtectedUserList(ctx)
|
||||
}
|
||||
user := s.findSakuraUser(ctx, args[0])
|
||||
if user == nil {
|
||||
return telegramCommandReply{Text: "未找到用户。"}
|
||||
}
|
||||
ids := ProtectedUserIDSet(ctx, s.repo)
|
||||
if protect {
|
||||
ids[user.ID] = struct{}{}
|
||||
if err := SaveProtectedUserIDSet(ctx, s.repo, ids); err != nil {
|
||||
return telegramCommandReply{Text: "保存保护名单失败:" + err.Error()}
|
||||
}
|
||||
return telegramCommandReply{Text: fmt.Sprintf("已加入保护名单:<b>%s</b>。\n该用户不会被 Bot 自动清理、批量禁用或删除。", user.Username)}
|
||||
}
|
||||
delete(ids, user.ID)
|
||||
if err := SaveProtectedUserIDSet(ctx, s.repo, ids); err != nil {
|
||||
return telegramCommandReply{Text: "保存保护名单失败:" + err.Error()}
|
||||
}
|
||||
return telegramCommandReply{Text: fmt.Sprintf("已移出保护名单:<b>%s</b>。", user.Username)}
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) cmdSakuraProtectedUserList(ctx context.Context) telegramCommandReply {
|
||||
ids := ProtectedUserIDSet(ctx, s.repo)
|
||||
if len(ids) == 0 {
|
||||
return telegramCommandReply{Text: "保护名单为空。管理员和默认管理员始终自动保护。"}
|
||||
}
|
||||
names := make([]string, 0, len(ids))
|
||||
for id := range ids {
|
||||
if user, _ := s.repo.User.FindByID(ctx, id); user != nil {
|
||||
names = append(names, user.Username)
|
||||
} else {
|
||||
names = append(names, id+"(用户不存在)")
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return telegramCommandReply{Text: fmt.Sprintf("保护名单:<b>%d</b> 个。\n%s", len(names), telegramInlineCodeList(names))}
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) cmdSakuraBackupDB(ctx context.Context) telegramCommandReply {
|
||||
if s.backup == nil {
|
||||
return telegramCommandReply{Text: "备份服务暂不可用。"}
|
||||
}
|
||||
info, err := s.backup.Create(ctx)
|
||||
if err != nil {
|
||||
return telegramCommandReply{Text: "数据库备份失败:" + err.Error()}
|
||||
}
|
||||
return telegramCommandReply{Text: fmt.Sprintf("数据库备份完成:<code>%s</code>\n大小:<b>%d</b> bytes", info.Filename, info.Size)}
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) cmdSakuraRestoreDB(ctx context.Context, args []string) telegramCommandReply {
|
||||
if s.backup == nil {
|
||||
return telegramCommandReply{Text: "备份服务暂不可用。"}
|
||||
}
|
||||
if len(args) == 0 || strings.EqualFold(args[0], "list") {
|
||||
items, err := s.backup.List()
|
||||
if err != nil {
|
||||
return telegramCommandReply{Text: "读取备份列表失败:" + err.Error()}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return telegramCommandReply{Text: "暂无数据库备份。可先使用 <code>/backup_db</code> 创建。"}
|
||||
}
|
||||
lines := make([]string, 0, minInt(len(items), 10))
|
||||
for i, item := range items {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s(%d bytes)", item.Filename, item.Size))
|
||||
}
|
||||
return telegramCommandReply{Text: "可恢复备份:\n" + telegramInlineCodeList(lines) + "\n\n恢复需要确认:<code>/restore_from_db 文件名 confirm</code>"}
|
||||
}
|
||||
if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") {
|
||||
return telegramCommandReply{Text: "恢复数据库会覆盖当前数据,需要确认:<code>/restore_from_db 文件名 confirm</code>"}
|
||||
}
|
||||
filename := strings.TrimSpace(args[0])
|
||||
if err := s.backup.Restore(ctx, filename); err != nil {
|
||||
return telegramCommandReply{Text: "恢复失败:" + err.Error()}
|
||||
}
|
||||
return telegramCommandReply{Text: "数据库已从备份恢复,请重启 MediaStationGo 后生效。"}
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) cmdSakuraSyncGroup(ctx context.Context, channel *model.NotifyChannel, args []string) telegramCommandReply {
|
||||
chatIDs := s.telegramMembershipChatIDs(channel)
|
||||
if len(chatIDs) == 0 {
|
||||
return telegramCommandReply{Text: "未配置可校验成员的群组/频道 ID。请在 Telegram 通知渠道设置 group_chat_id 或 channel_chat_id。"}
|
||||
}
|
||||
if strings.TrimSpace(s.telegramChannelConfig(channel)["bot_token"]) == "" {
|
||||
return telegramCommandReply{Text: "当前 Telegram 渠道未配置 bot_token,无法校验群成员。"}
|
||||
}
|
||||
var bindings []model.TelegramBinding
|
||||
if err := s.repo.DB.WithContext(ctx).Find(&bindings).Error; err != nil {
|
||||
return telegramCommandReply{Text: "读取绑定失败:" + err.Error()}
|
||||
}
|
||||
type staleBinding struct {
|
||||
User model.User
|
||||
Binding model.TelegramBinding
|
||||
}
|
||||
var stale []staleBinding
|
||||
for _, binding := range bindings {
|
||||
if binding.TelegramUserID == 0 || binding.UserID == "" {
|
||||
continue
|
||||
}
|
||||
user, _ := s.repo.User.FindByID(ctx, binding.UserID)
|
||||
if user == nil || UserIsProtectedAccount(ctx, s.repo, user) {
|
||||
continue
|
||||
}
|
||||
member := false
|
||||
for _, chatID := range chatIDs {
|
||||
if s.telegramUserIsChatMember(ctx, channel, chatID, int(binding.TelegramUserID)) {
|
||||
member = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !member {
|
||||
stale = append(stale, staleBinding{User: *user, Binding: binding})
|
||||
}
|
||||
}
|
||||
if len(stale) == 0 {
|
||||
return telegramCommandReply{Text: "所有已绑定账号都仍在配置的群组/频道中。"}
|
||||
}
|
||||
if len(args) >= 2 && strings.EqualFold(args[0], "delete") && strings.EqualFold(args[1], "confirm") {
|
||||
deleted := 0
|
||||
for _, item := range stale {
|
||||
_ = s.repo.UserDevice.DeleteByUser(ctx, item.User.ID)
|
||||
if err := s.repo.User.Delete(ctx, item.User.ID); err == nil {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return telegramCommandReply{Text: fmt.Sprintf("已删除不在群组/频道中的普通账号:<b>%d</b> 个。", deleted)}
|
||||
}
|
||||
names := make([]string, 0, minInt(len(stale), 20))
|
||||
for i, item := range stale {
|
||||
if i >= 20 {
|
||||
break
|
||||
}
|
||||
names = append(names, fmt.Sprintf("%s(tg:%d)", item.User.Username, item.Binding.TelegramUserID))
|
||||
}
|
||||
return telegramCommandReply{Text: fmt.Sprintf("不在配置群组/频道中的绑定账号:<b>%d</b> 个。\n%s\n\n删除需确认:<code>/syncgroupm delete confirm</code>", len(stale), telegramInlineCodeList(names))}
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) telegramMembershipChatIDs(channel *model.NotifyChannel) []string {
|
||||
cfg := s.telegramChannelConfig(channel)
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
for _, key := range []string{"group_chat_id", "channel_chat_id"} {
|
||||
value := strings.TrimSpace(cfg[key])
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; !ok {
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
if value := strings.TrimSpace(cfg["chat_id"]); strings.HasPrefix(value, "-") {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *TelegramBotService) cmdSakuraUnsupported(name, replacement string) telegramCommandReply {
|
||||
text := fmt.Sprintf("<b>%s</b> 已识别,但当前 Telegram Bot API 无法完整复刻该行为。", name)
|
||||
if replacement != "" {
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
@@ -172,3 +175,104 @@ func TestSakuraSyncExpiryAndBotAdminCommands(t *testing.T) {
|
||||
t.Fatalf("expected admin ids to include 9602, got %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSakuraProtectedUsersAndBackupCommands(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
repos, bot := newBotTestService(t)
|
||||
cfg := &config.Config{}
|
||||
cfg.App.DataDir = t.TempDir()
|
||||
bot.SetBackupService(NewBackupService(cfg, zap.NewNop(), repos.DB))
|
||||
|
||||
channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9701"}`}
|
||||
msg := &TelegramMessage{From: TelegramUser{ID: 9701, Username: "admin"}, Chat: TelegramChat{ID: 9701, Type: "private"}}
|
||||
users := []*model.User{
|
||||
{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true},
|
||||
{Username: "safe", PasswordHash: "x", Role: "user", IsActive: true},
|
||||
{Username: "normal", PasswordHash: "x", Role: "user", IsActive: true},
|
||||
}
|
||||
for _, user := range users {
|
||||
if err := repos.User.Create(ctx, user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
reply, err := bot.executeCommand(ctx, channel, msg, "/prouser safe")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "已加入保护名单") {
|
||||
t.Fatalf("expected protect success, got %q", reply.Text)
|
||||
}
|
||||
if reason := bot.protectReason(ctx, users[1].ID); !strings.Contains(reason, "保护名单") {
|
||||
t.Fatalf("protected user should have protect reason, got %q", reason)
|
||||
}
|
||||
reply, err = bot.executeCommand(ctx, channel, msg, "/banall confirm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "已禁用普通用户") {
|
||||
t.Fatalf("expected banall success, got %q", reply.Text)
|
||||
}
|
||||
protected, _ := repos.User.FindByUsername(ctx, "safe")
|
||||
normal, _ := repos.User.FindByUsername(ctx, "normal")
|
||||
if !protected.IsActive {
|
||||
t.Fatal("protected user should not be disabled by banall")
|
||||
}
|
||||
if normal.IsActive {
|
||||
t.Fatal("normal user should be disabled by banall")
|
||||
}
|
||||
reply, err = bot.executeCommand(ctx, channel, msg, "/revuser safe")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "已移出保护名单") {
|
||||
t.Fatalf("expected unprotect success, got %q", reply.Text)
|
||||
}
|
||||
|
||||
reply, err = bot.executeCommand(ctx, channel, msg, "/backup_db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "数据库备份完成") {
|
||||
t.Fatalf("backup_db should create backup, got %q", reply.Text)
|
||||
}
|
||||
reply, err = bot.executeCommand(ctx, channel, msg, "/restore_from_db list")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "mediastation_") {
|
||||
t.Fatalf("restore list should show backup, got %q", reply.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSakuraAliasesAndSyncGroupGuards(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
repos, bot := newBotTestService(t)
|
||||
if err := repos.User.Create(ctx, &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9801"}`}
|
||||
msg := &TelegramMessage{From: TelegramUser{ID: 9801, Username: "admin"}, Chat: TelegramChat{ID: 9801, Type: "private"}}
|
||||
|
||||
reply, err := bot.executeCommand(ctx, channel, msg, "/kk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "用户管理") {
|
||||
t.Fatalf("/kk should map to user management, got %q", reply.Text)
|
||||
}
|
||||
reply, err = bot.executeCommand(ctx, channel, msg, "/syncgroupm")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "未配置可校验成员") {
|
||||
t.Fatalf("syncgroupm should explain missing group config, got %q", reply.Text)
|
||||
}
|
||||
reply, err = bot.executeCommand(ctx, channel, msg, "/kick_not_emby")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reply.Text, "无法枚举全部群成员") {
|
||||
t.Fatalf("kick_not_emby should explain Telegram limitation, got %q", reply.Text)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user