From 9b9f8eae023d3a701a3357958caf69f78b353551 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Mon, 15 Jun 2026 01:21:45 +0800 Subject: [PATCH] Adapt Sakura-style bot management commands --- internal/service/telegram_commands.go | 61 +++ internal/service/telegram_sakura.go | 576 +++++++++++++++++++++++ internal/service/telegram_sakura_test.go | 174 +++++++ 3 files changed, 811 insertions(+) create mode 100644 internal/service/telegram_sakura.go create mode 100644 internal/service/telegram_sakura_test.go diff --git a/internal/service/telegram_commands.go b/internal/service/telegram_commands.go index 6a46a76..acfea37 100644 --- a/internal/service/telegram_commands.go +++ b/internal/service/telegram_commands.go @@ -74,6 +74,58 @@ func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, cha {Aliases: []string{"/search"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSearch(ctx, args) }}, {Aliases: []string{"/downloads"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdDownloads(ctx) }}, {Aliases: []string{"/stats"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdStats(ctx) }}, + {Aliases: []string{"/renew"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdUserRenew(ctx, args), nil }}, + {Aliases: []string{"/ucr"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraCreateUser(ctx, args), nil }}, + {Aliases: []string{"/uinfo"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraUserInfo(ctx, args), nil }}, + {Aliases: []string{"/rmemby", "/urm", "/only_rm_emby"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraDeleteUser(ctx, args), nil }}, + {Aliases: []string{"/only_rm_record"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraOnlyRemoveRecord(ctx, args), nil }}, + {Aliases: []string{"/userip"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraUserIP(ctx, args), nil }}, + {Aliases: []string{"/udeviceid"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { + return s.cmdSakuraAuditDevices(ctx, "udeviceid", args), nil + }}, + {Aliases: []string{"/auditip"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { + return s.cmdSakuraAuditDevices(ctx, "auditip", args), nil + }}, + {Aliases: []string{"/auditdevice"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { + return s.cmdSakuraAuditDevices(ctx, "auditdevice", args), nil + }}, + {Aliases: []string{"/auditclient"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { + return s.cmdSakuraAuditDevices(ctx, "auditclient", args), nil + }}, + {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("群成员全量同步", "/syncunbound 检查未绑定 Bot 的站内账号"), 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 }}, + {Aliases: []string{"/deleted", "/low_activity"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdCleanup(ctx, []string{"run"}), nil }}, + {Aliases: []string{"/uranks"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraRanks(ctx, 0, true), nil }}, + {Aliases: []string{"/days_ranks"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { + return s.cmdSakuraRanks(ctx, 24*time.Hour, false), nil + }}, + {Aliases: []string{"/week_ranks"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { + return s.cmdSakuraRanks(ctx, 7*24*time.Hour, false), nil + }}, + {Aliases: []string{"/embyadmin"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraAdminRole(ctx, args), nil }}, + {Aliases: []string{"/unbanall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraBanAll(ctx, true, args), nil }}, + {Aliases: []string{"/banall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSakuraBanAll(ctx, false, args), nil }}, + {Aliases: []string{"/embylibs_unblockall", "/extraembylibs_unblockall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { + return s.cmdSakuraMediaAccessAll(ctx, true), nil + }}, + {Aliases: []string{"/embylibs_blockall", "/extraembylibs_blockall"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { + return s.cmdSakuraMediaAccessAll(ctx, false), nil + }}, + {Aliases: []string{"/proadmin"}, AdminOnly: true, AdminOnlyText: adminOnly, Handle: func(args []string) (telegramCommandReply, error) { + return s.cmdSakuraBotAdmin(ctx, channel, args, true), nil + }}, + {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) { + return s.cmdSakuraUnsupported("Sakura 专属命令", "这些命令涉及外部 Bot 自身运维/积分红包/皮套人管理,已在 MediaStationGo 中由权限、通知渠道、设备策略替代。"), nil + }}, } } @@ -135,6 +187,15 @@ var telegramSupportedCommandSet = map[string]struct{}{ "/capacity": {}, "/users": {}, "/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": {}, + "/userip": {}, "/udeviceid": {}, "/auditip": {}, "/auditdevice": {}, "/auditclient": {}, + "/renewall": {}, "/callall": {}, "/syncunbound": {}, "/syncgroupm": {}, "/kick_not_emby": {}, "/scan_embyname": {}, + "/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": {}, + "/coins": {}, "/score": {}, "/coinsall": {}, "/coinsclear": {}, "/red": {}, "/srank": {}, "/prouser": {}, "/revuser": {}, + "/white_channel": {}, "/rev_white_channel": {}, "/unban_channel": {}, } type telegramBotCommand struct { diff --git a/internal/service/telegram_sakura.go b/internal/service/telegram_sakura.go new file mode 100644 index 0000000..0747492 --- /dev/null +++ b/internal/service/telegram_sakura.go @@ -0,0 +1,576 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func (s *TelegramBotService) cmdSakuraCreateUser(ctx context.Context, args []string) telegramCommandReply { + if len(args) < 2 { + return telegramCommandReply{Text: "用法:/ucr 用户名 密码 [天数],天数 0 表示永久。"} + } + if s.auth == nil { + return telegramCommandReply{Text: "注册服务暂不可用。"} + } + user, _, err := s.auth.Register(ctx, args[0], args[1]) + if err != nil { + return telegramCommandReply{Text: "创建失败:" + err.Error()} + } + days := 0 + if len(args) >= 3 { + parsed, err := strconv.Atoi(args[2]) + if err != nil || parsed < 0 { + return telegramCommandReply{Text: "账号已创建,但天数无效。请用 /renew 用户名 天数 调整。"} + } + days = parsed + if err := s.applyRenewal(ctx, user.ID, days); err != nil { + return telegramCommandReply{Text: "账号已创建,但续期失败:" + err.Error()} + } + } + return telegramCommandReply{Text: fmt.Sprintf("已创建用户:%s\n到期:%s", user.Username, formatExpiry(s.userExpiry(ctx, user.ID)))} +} + +func (s *TelegramBotService) cmdSakuraUserInfo(ctx context.Context, args []string) telegramCommandReply { + if len(args) == 0 { + return telegramCommandReply{Text: "用法:/uinfo 用户名"} + } + user := s.findSakuraUser(ctx, args[0]) + if user == nil { + return telegramCommandReply{Text: "未找到用户。"} + } + devices, _ := s.repo.UserDevice.ListByUser(ctx, user.ID) + var historyCount int64 + _ = s.repo.DB.WithContext(ctx).Model(&model.PlaybackHistory{}).Where("user_id = ?", user.ID).Count(&historyCount).Error + var binding model.TelegramBinding + tg := "未绑定" + if err := s.repo.DB.WithContext(ctx).Where("user_id = ?", user.ID).First(&binding).Error; err == nil { + tg = fmt.Sprintf("tg:%d", binding.TelegramUserID) + if binding.TelegramName != "" { + tg += " " + binding.TelegramName + } + } + return telegramCommandReply{Text: fmt.Sprintf( + "用户信息\n\n用户名:%s\n角色:%s\n状态:%s\n到期:%s\nTelegram:%s\n设备:%d\n播放记录:%d\n最后登录:%s", + user.Username, user.Role, activeLabel(user), formatExpiry(user.ExpiredAt), tg, len(devices), historyCount, formatOptionalTime(user.LastLoginAt), + )} +} + +func (s *TelegramBotService) cmdSakuraDeleteUser(ctx context.Context, args []string) telegramCommandReply { + if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") { + return telegramCommandReply{Text: "删除用户需要确认:/rmemby 用户名 confirm 或 /urm 用户名 confirm"} + } + user := s.findSakuraUser(ctx, args[0]) + if user == nil { + return telegramCommandReply{Text: "未找到用户。"} + } + if reason := s.protectReason(ctx, user.ID); reason != "" { + return telegramCommandReply{Text: reason} + } + _ = s.repo.UserDevice.DeleteByUser(ctx, user.ID) + if err := s.repo.User.Delete(ctx, user.ID); err != nil { + return telegramCommandReply{Text: "删除失败:" + err.Error()} + } + return telegramCommandReply{Text: fmt.Sprintf("已删除用户 %s。", user.Username)} +} + +func (s *TelegramBotService) cmdSakuraOnlyRemoveRecord(ctx context.Context, args []string) telegramCommandReply { + if len(args) == 0 { + return telegramCommandReply{Text: "用法:/only_rm_record tg:123456 或 /only_rm_record 用户名,只删除 Telegram 绑定记录。"} + } + target := strings.TrimSpace(args[0]) + var removed int64 + if raw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok { + tgID, err := strconv.ParseInt(raw, 10, 64) + if err != nil || tgID == 0 { + return telegramCommandReply{Text: "Telegram ID 无效。"} + } + removed, err = s.deleteTelegramBindings(ctx, "telegram_user_id = ?", tgID) + if err != nil { + return telegramCommandReply{Text: "删除绑定失败:" + err.Error()} + } + } else { + user := s.findSakuraUser(ctx, target) + if user == nil { + return telegramCommandReply{Text: "未找到用户。"} + } + n, err := s.deleteTelegramBindings(ctx, "user_id = ?", user.ID) + if err != nil { + return telegramCommandReply{Text: "删除绑定失败:" + err.Error()} + } + removed = n + } + return telegramCommandReply{Text: fmt.Sprintf("已删除 Telegram 绑定记录:%d 条。", removed)} +} + +func (s *TelegramBotService) cmdSakuraUserIP(ctx context.Context, args []string) telegramCommandReply { + if len(args) == 0 { + return telegramCommandReply{Text: "用法:/userip 用户名"} + } + user := s.findSakuraUser(ctx, args[0]) + if user == nil { + return telegramCommandReply{Text: "未找到用户。"} + } + devices, err := s.repo.UserDevice.ListByUser(ctx, user.ID) + if err != nil { + return telegramCommandReply{Text: "查询失败:" + err.Error()} + } + if len(devices) == 0 { + return telegramCommandReply{Text: "该用户暂无设备/IP记录。"} + } + var out []string + for i, d := range devices { + if i >= 20 { + break + } + out = append(out, fmt.Sprintf("%d. %s / %s / %s / %s", i+1, blankDash(d.LastIP), blankDash(d.DeviceName), blankDash(d.Client), d.LastSeenAt.Format("2006-01-02 15:04"))) + } + return telegramCommandReply{Text: "" + user.Username + " 的设备/IP\n\n" + strings.Join(out, "\n") + ""} +} + +func (s *TelegramBotService) cmdSakuraAuditDevices(ctx context.Context, mode string, args []string) telegramCommandReply { + if len(args) == 0 { + return telegramCommandReply{Text: fmt.Sprintf("用法:/%s 关键词", mode)} + } + keyword := strings.TrimSpace(strings.Join(args, " ")) + var rows []struct { + Username string + DeviceID string + DeviceName string + Client string + LastIP string + LastSeenAt time.Time + } + q := s.repo.DB.WithContext(ctx).Table("user_devices"). + Select("users.username, user_devices.device_id, user_devices.device_name, user_devices.client, user_devices.last_ip, user_devices.last_seen_at"). + Joins("JOIN users ON users.id = user_devices.user_id"). + Order("user_devices.last_seen_at desc"). + Limit(20) + switch mode { + case "auditip": + q = q.Where("user_devices.last_ip LIKE ?", "%"+keyword+"%") + case "auditdevice": + q = q.Where("user_devices.device_name LIKE ? OR user_devices.device_id LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + case "auditclient": + q = q.Where("user_devices.client LIKE ?", "%"+keyword+"%") + case "udeviceid": + q = q.Where("user_devices.device_id LIKE ?", "%"+keyword+"%") + } + if err := q.Scan(&rows).Error; err != nil { + return telegramCommandReply{Text: "查询失败:" + err.Error()} + } + if len(rows) == 0 { + return telegramCommandReply{Text: "没有匹配记录。"} + } + var out []string + for i, r := range rows { + out = append(out, fmt.Sprintf("%d. %s / %s / %s / %s / %s", i+1, r.Username, blankDash(r.LastIP), blankDash(r.DeviceName), blankDash(r.Client), r.LastSeenAt.Format("2006-01-02 15:04"))) + } + return telegramCommandReply{Text: "审计结果\n\n" + strings.Join(out, "\n") + ""} +} + +func (s *TelegramBotService) cmdSakuraRenewAll(ctx context.Context, args []string) telegramCommandReply { + if len(args) < 2 || !strings.EqualFold(args[len(args)-1], "confirm") { + return telegramCommandReply{Text: "批量续期需要确认:/renewall 天数 confirm"} + } + days, err := strconv.Atoi(args[0]) + if err != nil || days < 0 { + return telegramCommandReply{Text: "天数必须是非负整数,0 表示永久。"} + } + users, err := s.repo.User.List(ctx) + if err != nil { + return telegramCommandReply{Text: "读取用户失败:" + err.Error()} + } + var count int + for _, user := range users { + if user.Role == "admin" { + continue + } + if err := s.applyRenewal(ctx, user.ID, days); err == nil { + count++ + } + } + return telegramCommandReply{Text: fmt.Sprintf("批量续期完成:%d 个普通用户。", count)} +} + +func (s *TelegramBotService) cmdSakuraBanAll(ctx context.Context, active bool, args []string) telegramCommandReply { + if len(args) == 0 || !strings.EqualFold(args[len(args)-1], "confirm") { + action := "banall" + if active { + action = "unbanall" + } + return telegramCommandReply{Text: fmt.Sprintf("批量操作需要确认:/%s confirm", action)} + } + users, err := s.repo.User.List(ctx) + if err != nil { + return telegramCommandReply{Text: "读取用户失败:" + err.Error()} + } + var count int + for _, user := range users { + if user.Role == "admin" { + continue + } + updates := map[string]any{"is_active": active} + if active { + updates["share_warnings"] = 0 + updates["last_share_warn_at"] = nil + } + if err := s.repo.User.UpdateFields(ctx, user.ID, updates); err == nil { + _ = s.repo.UserDevice.SetKickedByUser(ctx, user.ID, !active) + count++ + } + } + if active { + return telegramCommandReply{Text: fmt.Sprintf("已解禁普通用户:%d 个。", count)} + } + return telegramCommandReply{Text: fmt.Sprintf("已禁用普通用户:%d 个。", count)} +} + +func (s *TelegramBotService) cmdSakuraCallAll(ctx context.Context, channel *model.NotifyChannel, args []string) telegramCommandReply { + message := strings.TrimSpace(strings.Join(args, " ")) + if message == "" { + return telegramCommandReply{Text: "用法:/callall 消息内容"} + } + 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()} + } + sent := 0 + for _, binding := range bindings { + if binding.ChatID == 0 { + continue + } + if err := s.reply(ctx, channel, int(binding.ChatID), telegramCommandReply{Text: message}); err == nil { + sent++ + } + } + return telegramCommandReply{Text: fmt.Sprintf("群发完成:成功发送 %d 个绑定用户。", sent)} +} + +func (s *TelegramBotService) cmdSakuraSyncUnbound(ctx context.Context, args []string) telegramCommandReply { + var users []model.User + if err := s.repo.DB.WithContext(ctx). + Where("role <> ?", "admin"). + Where("NOT EXISTS (SELECT 1 FROM telegram_bindings WHERE telegram_bindings.user_id = users.id AND telegram_bindings.deleted_at IS NULL)"). + Order("created_at asc").Find(&users).Error; err != nil { + return telegramCommandReply{Text: "查询失败:" + err.Error()} + } + if len(args) >= 2 && strings.EqualFold(args[0], "delete") && strings.EqualFold(args[1], "confirm") { + deleted := 0 + for _, user := range users { + _ = s.repo.UserDevice.DeleteByUser(ctx, user.ID) + if err := s.repo.User.Delete(ctx, user.ID); err == nil { + deleted++ + } + } + return telegramCommandReply{Text: fmt.Sprintf("已删除未绑定 Bot 的普通用户:%d 个。", deleted)} + } + if len(users) == 0 { + return telegramCommandReply{Text: "没有未绑定 Bot 的普通用户。"} + } + names := make([]string, 0, minInt(len(users), 20)) + for i, user := range users { + if i >= 20 { + break + } + names = append(names, user.Username) + } + return telegramCommandReply{Text: fmt.Sprintf("未绑定 Bot 的普通用户:%d 个。\n%s\n\n如需删除:/syncunbound delete confirm", len(users), telegramInlineCodeList(names))} +} + +func (s *TelegramBotService) cmdSakuraCheckExpired(ctx context.Context, args []string) telegramCommandReply { + now := time.Now() + var users []model.User + if err := s.repo.DB.WithContext(ctx).Where("expired_at IS NOT NULL AND expired_at < ?", now).Order("expired_at asc").Find(&users).Error; err != nil { + return telegramCommandReply{Text: "查询失败:" + err.Error()} + } + if len(args) >= 2 && strings.EqualFold(args[0], "disable") && strings.EqualFold(args[1], "confirm") { + disabled := 0 + for _, user := range users { + if user.Role == "admin" { + continue + } + if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"is_active": false}); err == nil { + disabled++ + } + } + return telegramCommandReply{Text: fmt.Sprintf("已禁用过期普通用户:%d 个。", disabled)} + } + if len(users) == 0 { + return telegramCommandReply{Text: "没有过期用户。"} + } + lines := make([]string, 0, minInt(len(users), 20)) + for i, user := range users { + if i >= 20 { + break + } + lines = append(lines, fmt.Sprintf("%s(%s)", user.Username, formatExpiry(user.ExpiredAt))) + } + return telegramCommandReply{Text: fmt.Sprintf("过期用户:%d 个。\n%s\n\n如需禁用:/check_ex disable confirm", len(users), telegramInlineCodeList(lines))} +} + +func (s *TelegramBotService) cmdSakuraScanNames(ctx context.Context) telegramCommandReply { + var rows []struct { + Username string + Count int64 + } + if err := s.repo.DB.WithContext(ctx).Table("users"). + Select("LOWER(username) AS username, COUNT(*) AS count"). + Group("LOWER(username)").Having("COUNT(*) > 1").Scan(&rows).Error; err != nil { + return telegramCommandReply{Text: "扫描失败:" + err.Error()} + } + if len(rows) == 0 { + return telegramCommandReply{Text: "未发现同名用户记录。"} + } + var out []string + for _, row := range rows { + out = append(out, fmt.Sprintf("%s x%d", row.Username, row.Count)) + } + return telegramCommandReply{Text: "同名用户记录\n" + telegramInlineCodeList(out)} +} + +func (s *TelegramBotService) cmdSakuraRanks(ctx context.Context, window time.Duration, byDuration bool) telegramCommandReply { + since := time.Now().Add(-window) + title := "播放次数排行" + selectExpr := "COUNT(*) AS score" + if byDuration { + title = "观影时长排行" + selectExpr = "COALESCE(SUM(position_ms), 0) AS score" + } + q := s.repo.DB.WithContext(ctx).Table("playback_histories"). + Select("users.username, " + selectExpr). + Joins("JOIN users ON users.id = playback_histories.user_id"). + Group("users.username"). + Order("score DESC"). + Limit(10) + if window > 0 { + q = q.Where("playback_histories.watched_at >= ?", since) + } + var rows []struct { + Username string + Score int64 + } + if err := q.Scan(&rows).Error; err != nil { + return telegramCommandReply{Text: "排行查询失败:" + err.Error()} + } + if len(rows) == 0 { + return telegramCommandReply{Text: "暂无排行数据。"} + } + var out []string + for i, row := range rows { + score := fmt.Sprintf("%d 次", row.Score) + if byDuration { + score = humanDurationFromMillis(row.Score) + } + out = append(out, fmt.Sprintf("%d. %s — %s", i+1, row.Username, score)) + } + return telegramCommandReply{Text: "" + title + "\n\n" + strings.Join(out, "\n") + ""} +} + +func (s *TelegramBotService) cmdSakuraAdminRole(ctx context.Context, args []string) telegramCommandReply { + if len(args) < 2 { + return telegramCommandReply{Text: "用法:/embyadmin 用户名 on|off"} + } + user := s.findSakuraUser(ctx, args[0]) + if user == nil { + return telegramCommandReply{Text: "未找到用户。"} + } + enable := parseOnOff(args[1]) + if enable == nil { + return telegramCommandReply{Text: "第二个参数请使用 on/off。"} + } + if !*enable { + if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == user.ID { + return telegramCommandReply{Text: "默认管理员不可降级。"} + } + } + role := "user" + if *enable { + role = "admin" + } + if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"role": role}); err != nil { + return telegramCommandReply{Text: "更新失败:" + err.Error()} + } + return telegramCommandReply{Text: fmt.Sprintf("已将 %s 角色设置为 %s。", user.Username, role)} +} + +func (s *TelegramBotService) cmdSakuraMediaAccessAll(ctx context.Context, allow bool) telegramCommandReply { + users, err := s.repo.User.List(ctx) + if err != nil { + return telegramCommandReply{Text: "读取用户失败:" + err.Error()} + } + updated := 0 + for _, user := range users { + if user.Role == "admin" { + continue + } + perm, err := s.repo.Permission.FindByUserID(ctx, user.ID) + if err != nil { + continue + } + if perm == nil { + perm = DefaultPermissions(user.ID) + perm.CanPlayMedia = allow + if err := s.repo.Permission.Create(ctx, perm); err != nil { + continue + } + } + if err := s.repo.DB.WithContext(ctx).Model(&model.UserPermission{}). + Where("user_id = ?", user.ID). + Update("can_play_media", allow).Error; err == nil { + updated++ + } + } + state := "关闭" + if allow { + state = "开启" + } + return telegramCommandReply{Text: fmt.Sprintf("已为普通用户%s媒体播放权限:%d 个。", state, updated)} +} + +func (s *TelegramBotService) cmdSakuraBotAdmin(ctx context.Context, channel *model.NotifyChannel, args []string, add bool) telegramCommandReply { + if channel == nil { + return telegramCommandReply{Text: "Telegram 渠道不存在。"} + } + if len(args) == 0 { + return telegramCommandReply{Text: "用法:/proadmin TelegramID 或 /revadmin TelegramID"} + } + tgID := strings.TrimPrefix(strings.TrimSpace(args[0]), "tg:") + if _, err := strconv.ParseInt(tgID, 10, 64); err != nil { + return telegramCommandReply{Text: "TelegramID 必须是数字。"} + } + cfg := s.telegramChannelConfig(channel) + ids := telegramConfiguredUserIDs(cfg["admin_user_ids"]) + seen := make(map[string]bool, len(ids)+1) + var next []string + for _, id := range ids { + if id == tgID { + seen[id] = true + if add { + next = append(next, id) + } + continue + } + if id != "" { + next = append(next, id) + } + } + if add && !seen[tgID] { + next = append(next, tgID) + } + cfg["admin_user_ids"] = strings.Join(next, ",") + raw, _ := json.Marshal(cfg) + updated := *channel + updated.Config = string(raw) + if s.crypto != nil { + updated.Config = s.crypto.Encrypt(updated.Config) + } + if err := s.repo.NotifyChannel.Update(ctx, &updated); err != nil { + return telegramCommandReply{Text: "更新管理员列表失败:" + err.Error()} + } + if add { + return telegramCommandReply{Text: "已添加 Bot 管理员:" + tgID + ""} + } + return telegramCommandReply{Text: "已移除 Bot 管理员:" + tgID + ""} +} + +func (s *TelegramBotService) cmdSakuraUnsupported(name, replacement string) telegramCommandReply { + text := fmt.Sprintf("%s 已识别,但当前 Telegram Bot API 无法完整复刻该行为。", name) + if replacement != "" { + text += "\n请使用:" + replacement + } + return telegramCommandReply{Text: text} +} + +func (s *TelegramBotService) findSakuraUser(ctx context.Context, target string) *model.User { + target = strings.TrimSpace(strings.TrimPrefix(target, "@")) + if target == "" { + return nil + } + if user, _ := s.repo.User.FindByUsername(ctx, target); user != nil { + return user + } + if user, _ := s.repo.User.FindByID(ctx, target); user != nil { + return user + } + if tgRaw, ok := strings.CutPrefix(strings.ToLower(target), "tg:"); ok { + if tgID, err := strconv.ParseInt(tgRaw, 10, 64); err == nil { + var binding model.TelegramBinding + if err := s.repo.DB.WithContext(ctx).Where("telegram_user_id = ?", tgID).First(&binding).Error; err == nil { + user, _ := s.repo.User.FindByID(ctx, binding.UserID) + return user + } + } + } + return nil +} + +func activeLabel(user *model.User) string { + if user == nil { + return "未知" + } + if !user.IsActive { + return "已禁用" + } + if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) { + return "已过期" + } + return "正常" +} + +func formatOptionalTime(t *time.Time) string { + if t == nil { + return "-" + } + return t.Format("2006-01-02 15:04") +} + +func blankDash(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "-" + } + return value +} + +func telegramInlineCodeList(items []string) string { + if len(items) == 0 { + return "" + } + return "" + strings.Join(items, "、") + "" +} + +func parseOnOff(raw string) *bool { + value := strings.ToLower(strings.TrimSpace(raw)) + switch value { + case "on", "true", "1", "yes", "enable", "enabled", "开启", "开": + v := true + return &v + case "off", "false", "0", "no", "disable", "disabled", "关闭", "关": + v := false + return &v + default: + return nil + } +} + +func humanDurationFromMillis(ms int64) string { + if ms <= 0 { + return "0 分钟" + } + totalMinutes := ms / 1000 / 60 + hours := totalMinutes / 60 + minutes := totalMinutes % 60 + if hours == 0 { + return fmt.Sprintf("%d 分钟", minutes) + } + return fmt.Sprintf("%d 小时 %d 分钟", hours, minutes) +} diff --git a/internal/service/telegram_sakura_test.go b/internal/service/telegram_sakura_test.go new file mode 100644 index 0000000..f799f6d --- /dev/null +++ b/internal/service/telegram_sakura_test.go @@ -0,0 +1,174 @@ +package service + +import ( + "strings" + "testing" + "time" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func TestSakuraUserManagementAndAuditCommands(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":"9401"}`} + msg := &TelegramMessage{From: TelegramUser{ID: 9401, Username: "admin"}, Chat: TelegramChat{ID: 9401, Type: "private"}} + + reply, err := bot.executeCommand(ctx, channel, msg, "/ucr viewer secret-pass 30") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "已创建用户") { + t.Fatalf("expected user creation, got %q", reply.Text) + } + viewer, err := repos.User.FindByUsername(ctx, "viewer") + if err != nil || viewer == nil { + t.Fatalf("viewer should exist: %v", err) + } + if err := repos.UserDevice.Create(ctx, &model.UserDevice{ + UserID: viewer.ID, + DeviceID: "dev-abc", + DeviceName: "Windows PC", + Client: "Infuse", + LastIP: "1.2.3.4", + FirstSeenAt: time.Now(), + LastSeenAt: time.Now(), + }); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + command string + want string + }{ + {"/uinfo viewer", "用户信息"}, + {"/userip viewer", "1.2.3.4"}, + {"/auditip 1.2.3", "viewer"}, + {"/auditdevice Windows", "viewer"}, + {"/auditclient Infuse", "viewer"}, + {"/udeviceid dev-abc", "viewer"}, + } { + t.Run(tc.command, func(t *testing.T) { + reply, err := bot.executeCommand(ctx, channel, msg, tc.command) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, tc.want) { + t.Fatalf("%s expected %q in %q", tc.command, tc.want, reply.Text) + } + }) + } +} + +func TestSakuraBatchAndPermissionCommands(t *testing.T) { + ctx := t.Context() + repos, bot := newBotTestService(t) + channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9501"}`} + msg := &TelegramMessage{From: TelegramUser{ID: 9501, Username: "admin"}, Chat: TelegramChat{ID: 9501, Type: "private"}} + users := []*model.User{ + {Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}, + {Username: "viewer1", PasswordHash: "x", Role: "user", IsActive: true}, + {Username: "viewer2", PasswordHash: "x", Role: "user", IsActive: true}, + {Username: "viewer3", PasswordHash: "x", Role: "user", IsActive: true}, + } + for _, user := range users { + if err := repos.User.Create(ctx, user); err != nil { + t.Fatal(err) + } + if user.Role != "admin" && user.Username != "viewer3" { + if err := repos.Permission.Create(ctx, DefaultPermissions(user.ID)); err != nil { + t.Fatal(err) + } + } + } + + reply, err := bot.executeCommand(ctx, channel, msg, "/renewall 7 confirm") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "批量续期完成") { + t.Fatalf("expected renewall success, got %q", reply.Text) + } + renewed, _ := repos.User.FindByUsername(ctx, "viewer1") + if renewed.ExpiredAt == nil { + t.Fatal("renewall should set expiry for normal users") + } + + if reply, err = bot.executeCommand(ctx, channel, msg, "/embylibs_blockall"); err != nil || !strings.Contains(reply.Text, "关闭媒体播放权限") { + t.Fatalf("expected blockall success, reply=%q err=%v", reply.Text, err) + } + perm, _ := repos.Permission.FindByUserID(ctx, users[1].ID) + if perm == nil || perm.CanPlayMedia { + t.Fatal("embylibs_blockall should disable media playback for normal users") + } + perm, _ = repos.Permission.FindByUserID(ctx, users[3].ID) + if perm == nil || perm.CanPlayMedia { + t.Fatal("embylibs_blockall should create disabled media playback permissions when missing") + } + if reply, err = bot.executeCommand(ctx, channel, msg, "/embylibs_unblockall"); err != nil || !strings.Contains(reply.Text, "开启媒体播放权限") { + t.Fatalf("expected unblockall success, reply=%q err=%v", reply.Text, err) + } + perm, _ = repos.Permission.FindByUserID(ctx, users[1].ID) + if perm == nil || !perm.CanPlayMedia { + t.Fatal("embylibs_unblockall should enable media playback for normal users") + } + + if reply, err = bot.executeCommand(ctx, channel, msg, "/banall confirm"); err != nil || !strings.Contains(reply.Text, "已禁用普通用户") { + t.Fatalf("expected banall success, reply=%q err=%v", reply.Text, err) + } + banned, _ := repos.User.FindByUsername(ctx, "viewer2") + if banned.IsActive { + t.Fatal("banall should disable normal users") + } + if reply, err = bot.executeCommand(ctx, channel, msg, "/unbanall confirm"); err != nil || !strings.Contains(reply.Text, "已解禁普通用户") { + t.Fatalf("expected unbanall success, reply=%q err=%v", reply.Text, err) + } + unbanned, _ := repos.User.FindByUsername(ctx, "viewer2") + if !unbanned.IsActive { + t.Fatal("unbanall should re-enable normal users") + } +} + +func TestSakuraSyncExpiryAndBotAdminCommands(t *testing.T) { + ctx := t.Context() + repos, bot := newBotTestService(t) + channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"admin_user_ids":"9601"}`} + if err := repos.NotifyChannel.Create(ctx, channel); err != nil { + t.Fatal(err) + } + msg := &TelegramMessage{From: TelegramUser{ID: 9601, Username: "admin"}, Chat: TelegramChat{ID: 9601, Type: "private"}} + past := time.Now().Add(-24 * time.Hour) + if err := repos.User.Create(ctx, &model.User{Username: "expired", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &past}); err != nil { + t.Fatal(err) + } + + reply, err := bot.executeCommand(ctx, channel, msg, "/syncunbound") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "expired") { + t.Fatalf("syncunbound should list unbound users, got %q", reply.Text) + } + reply, err = bot.executeCommand(ctx, channel, msg, "/check_ex") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "expired") { + t.Fatalf("check_ex should list expired users, got %q", reply.Text) + } + reply, err = bot.executeCommand(ctx, channel, msg, "/proadmin 9602") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(reply.Text, "已添加") { + t.Fatalf("proadmin should update channel config, got %q", reply.Text) + } + updated, _ := repos.NotifyChannel.FindByID(ctx, channel.ID) + cfg := bot.telegramChannelConfig(updated) + if !strings.Contains(cfg["admin_user_ids"], "9602") { + t.Fatalf("expected admin ids to include 9602, got %#v", cfg) + } +}