From 49da13bfef65c3806d76b1efa787a2a72f3104eb Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Mon, 17 Aug 2026 01:04:50 +0800 Subject: [PATCH 01/22] =?UTF-8?q?=E6=9B=B4=E6=96=B0README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ffcbbd5a..a5f8f5c3 100644 --- a/README.md +++ b/README.md @@ -61,26 +61,6 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 image -## 快速开始 - -### 1) 准备环境 - -- JDK 25 -- Maven -- QQ 机器人应用凭据 或 Napcat服务 -- 一个可访问的 oStella API - -### 2) 配置 `config.yml` - -启动程序时,若不存在`config.yml`则会自动创建。请根据提示编辑 `config.yml`。 -默认配置参见[seira-example-config.yml](/src/main/resources/seira-example-config.yml) - -### 3) 启动 - -```shell -mvn -U clean compile exec:java -``` - ## 常用命令 > 所有命令都以 `/` 开头。指令详情请见[文档](https://docs.seira.top/overview/commands.html)。 @@ -169,3 +149,27 @@ mvn -U clean compile exec:java | `/debug.db ` | 对后端数据库执行指定的SQL语句 | | `/debug.update-user-info` | 更新数据库中所有玩家的信息 | | `/debug.active-message` | 发送一条主动消息 | + +## 自行部署 + +> [!NOTE] +> 此节仅面向想自行部署 Seira 的用户。 +> 若想直接使用,请见[使用指南](https://docs.seira.top/overview/use.html) + +### 1) 准备环境 + +- JDK 25 +- Maven +- QQ 开放平台机器人应用凭据 +- 一个可访问的 oStella API + +### 2) 配置 `config.yml` + +启动程序时,若不存在`config.yml`则会自动创建。请根据提示编辑 `config.yml`。 +默认配置参见[seira-example-config.yml](/src/main/resources/seira-example-config.yml) + +### 3) 启动 + +```shell +mvn -U clean compile exec:java +``` \ No newline at end of file From d6fa7dd5dc629484abce43a92376e101ca9bd537 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 18 Aug 2026 13:31:15 +0800 Subject: [PATCH 02/22] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=B0=B1=E9=9D=A2?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xyz/zcraft/seira/api/APIHelper.java | 28 ++++++++++++- .../handler/BeatmapCommandHandler.java | 41 +++++++++++++++++++ .../seira/command/reply/CommandUsage.java | 1 + .../seira/command/reply/ReplyFactory.java | 13 ++++++ .../zcraft/seira/command/route/Router.java | 6 ++- 5 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index 3476e9cc..1f2e6b43 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -510,6 +510,25 @@ public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget target, Str return createReplayShowcaseTask(target, ids, auth, null); } + public static ReplayTaskInfo createBeatmapPreviewTask(ShortcutTarget target, String mods, String auth, + QqUploadRequest qqUpload) { + long beatmapId = lookupBeatmap(target, auth); + JsonObject body = new JsonObject(); + if (mods != null && !mods.isBlank()) { + body.addProperty("mods", mods); + } + if (qqUpload != null) { + body.add("qqUpload", GSON.toJsonTree(qqUpload)); + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(ENDPOINT + "/replays/renders/preview/" + beatmapId)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + return getReplayTaskInfo(request); + } + public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget target, String[] ids, String auth, QqUploadRequest qqUpload) { ids = ids == null ? new String[0] : ids; @@ -678,8 +697,11 @@ private static ReplayTaskInfo getReplayTaskInfo(HttpRequest request) { Double start = data.has("start") ? data.get("start").getAsDouble() : null; Double end = data.has("end") ? data.get("end").getAsDouble() : null; + String mods = data.has("mods") ? data.get("mods").getAsString() : null; + String selection = data.has("selection") ? data.get("selection").getAsString() : null; - return new ReplayTaskInfo(taskId, status, position, beatmap, data.getAsJsonArray("scores"), start, end); + return new ReplayTaskInfo( + taskId, status, position, beatmap, data.getAsJsonArray("scores"), start, end, mods, selection); } catch (IOException e) { throw requestFailure(e); } catch (InterruptedException e) { @@ -1030,7 +1052,9 @@ public record ReplayTaskInfo( BeatmapExtended beatmap, JsonArray scores, Double start, - Double end + Double end, + String mods, + String selection ) { } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java index 32b67245..5e4895d4 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java @@ -4,6 +4,7 @@ import xyz.zcraft.seira.api.data.Response; import xyz.zcraft.seira.api.data.SearchQuery; import xyz.zcraft.seira.api.data.SearchResultItem; +import xyz.zcraft.seira.api.data.VideoRenderRecord; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.*; import xyz.zcraft.seira.command.parse.Resolver; @@ -20,6 +21,7 @@ public final class BeatmapCommandHandler { private final TargetHistory lastTarget; private final TaskCoordinator taskCoordinator; private final ReplyFactory replyFactory; + private final VideoRenderRecord videoRenderRecord; private final Function accessTokenProvider; public BeatmapCommandHandler( @@ -27,12 +29,14 @@ public BeatmapCommandHandler( TargetHistory targetHistory, TaskCoordinator taskCoordinator, ReplyFactory replyFactory, + VideoRenderRecord videoRenderRecord, Function accessTokenProvider ) { this.resolver = resolver; this.lastTarget = targetHistory; this.taskCoordinator = taskCoordinator; this.replyFactory = replyFactory; + this.videoRenderRecord = videoRenderRecord; this.accessTokenProvider = accessTokenProvider; } @@ -112,6 +116,43 @@ public void handleAp(Context ctx) { ); } + public void handleBpv(Context ctx) { + if (ctx.args().length < 1) { + ctx.sendReply(PendingMessage.ofString(CommandUsage.BPV)); + return; + } + + TargetResolution targetResolution = resolver.resolveTargetWithOptionalMention( + ctx.args(), ctx.senderUserId()); + if (ctx.args().length > targetResolution.consumedArgs() + 1) { + ctx.sendReply(PendingMessage.ofString(CommandUsage.BPV)); + return; + } + + ShortcutTarget target = targetResolution.target(); + if (target.isError()) { + ctx.sendReply(PendingMessage.ofString(target.errorMessage())); + return; + } + + String mods = ctx.args().length == targetResolution.consumedArgs() + 1 + ? ctx.args()[targetResolution.consumedArgs()] + : null; + lastTarget.put(ctx.senderUserId(), target); + + taskCoordinator.runReplayRequest( + ctx, + "Beatmap Preview Render", + qqUpload -> { + APIHelper.ReplayTaskInfo task = APIHelper.createBeatmapPreviewTask( + target, mods, accessTokenProvider.apply(ctx.senderUserId()), qqUpload); + videoRenderRecord.updateRenderTask(ctx.senderUserId(), task.taskId()); + return task; + }, + replyFactory::replayMessage + ); + } + public void handleBgp(Context ctx) { ShortcutTarget target; diff --git a/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java b/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java index 19c24b00..b8d6e7f2 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java @@ -7,6 +7,7 @@ public final class CommandUsage { public static final String RS = "用法:/rs <个数> [玩家ID/用户名/@用户]"; public static final String M = "用法:/m <谱面ID 或 快捷查询> [Mod]"; public static final String AP = "用法:/ap <谱面ID 或 快捷查询>"; + public static final String BPV = "用法:/bpv <谱面ID 或 快捷查询> [Mod]"; public static final String BGP = "用法:/bgp <谱面ID 或 快捷查询>"; public static final String DL = "用法:/dl <谱面集ID 或 快捷查询>"; public static final String S = "用法:/s <成绩ID 或 快捷查询>"; diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 7480df23..8974f3b8 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -324,6 +324,19 @@ static String replayTaskContent(Context ctx, APIHelper.ReplayTaskInfo taskInfo) sb.append("\n"); } + if (taskInfo.mods() != null) { + sb.append("> Mod: ").append(taskInfo.mods()).append("\n"); + } + if (taskInfo.selection() != null) { + String selection = switch (taskInfo.selection()) { + case "kiai" -> "Kiai 段"; + case "high-pressure" -> "高压段"; + case "full-map" -> "完整可用片段"; + default -> taskInfo.selection(); + }; + sb.append("> 选段: ").append(selection).append("\n"); + } + if (taskInfo.scores() != null) { JsonArray scores = taskInfo.scores(); sb.append("> 共 %d 个成绩:".formatted(scores.size())); diff --git a/src/main/java/xyz/zcraft/seira/command/route/Router.java b/src/main/java/xyz/zcraft/seira/command/route/Router.java index 235339f9..887911fa 100644 --- a/src/main/java/xyz/zcraft/seira/command/route/Router.java +++ b/src/main/java/xyz/zcraft/seira/command/route/Router.java @@ -59,6 +59,7 @@ public Router( Resolver resolver = new Resolver(); TargetHistory targetHistory = new TargetHistory(); ReplayResultStore replayResults = new ReplayResultStore(); + VideoRenderRecord videoRenderRecord = new VideoRenderRecord(); this.taskCoordinator = new TaskCoordinator(messageSender, replayResults, discordBridgeService); this.authHelper = new OsuAuthHelper(startupConfig.binding()); BindingCommandHandler bindingCommands = new BindingCommandHandler(startupConfig, replyFactory, bindingService); @@ -66,7 +67,7 @@ public Router( resolver, targetHistory, taskCoordinator, replyFactory ); BeatmapCommandHandler beatmapCommands = new BeatmapCommandHandler( - resolver, targetHistory, taskCoordinator, replyFactory, this::getAccessTokenFor + resolver, targetHistory, taskCoordinator, replyFactory, videoRenderRecord, this::getAccessTokenFor ); SocialCommandHandler socialCommands = new SocialCommandHandler( resolver, authHelper, taskCoordinator, replyFactory, this::getAccessTokenFor @@ -76,7 +77,7 @@ public Router( targetHistory, taskCoordinator, replyFactory, - new VideoRenderRecord(), + videoRenderRecord, replayResults, this::getAccessTokenFor ); @@ -209,6 +210,7 @@ private static CommandRegistry createCommandRegistry( .register(ctx -> scoreCommands.handleRs(ctx, false), "rp") .register(beatmapCommands::handleM, "m") .register(beatmapCommands::handleAp, "ap") + .register(beatmapCommands::handleBpv, "bpv") .register(beatmapCommands::handleBgp, "bgp") .register(ctx -> socialCommands.handleF(ctx, !ctx.inGroup()), "f") .register(ctx -> socialCommands.handleF(ctx, true), "fall") From 6e0433612f627fa15ad6d67e22ec2b520555e371 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 18 Aug 2026 13:58:27 +0800 Subject: [PATCH 03/22] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=88=90=E7=BB=A9?= =?UTF-8?q?=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- .../java/xyz/zcraft/seira/api/APIHelper.java | 27 ++- .../command/handler/ScoreCommandHandler.java | 153 ++++++++--------- .../command/parse/ScoreFilterArguments.java | 159 ++++++++++++++++++ .../seira/command/reply/CommandUsage.java | 5 +- 5 files changed, 257 insertions(+), 95 deletions(-) create mode 100644 src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java diff --git a/README.md b/README.md index a5f8f5c3..16ab8ebc 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,9 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 | `/f` | `/f` | 获取好友列表 | | `/fall` | `/fall` | 获取全部好友列表 | | `/fclear` | `/fclear` | 清除好友记录 | -| `/bo` | `/bo [n] [uid/username/@user]` | 最好n个成绩图,无参时获取最佳成绩详情 | -| `/rs` | `/rs [n] [uid/username/@user]` | 最近n个成绩图,无参时获取最近一个成绩详情 | -| `/rp` | `/rp [n] [uid/username/@user]` | 最近n个通过成绩图,无参时获取最近一个通过成绩详情 | +| `/bo` | `/bo [n] [uid/username/@user] [filters...]` | 最好n个成绩图,可按acc/combo/mod/time/pp等过滤;无参时获取最佳成绩详情 | +| `/rs` | `/rs [n] [uid/username/@user] [filters...]` | 最近n个成绩图,可追加过滤条件;无参时获取最近一个成绩详情 | +| `/rp` | `/rp [n] [uid/username/@user] [filters...]` | 最近n个通过成绩图,可追加过滤条件;无参时获取最近一个通过成绩详情 | | `/m` | `/m [Mod]` | 获取指定谱面信息 | | `/ap` | `/ap ` | 获取指定谱面音频预览 | | `/bgp` | `/bgp ` | 获取指定谱面背景预览 | @@ -172,4 +172,4 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 ```shell mvn -U clean compile exec:java -``` \ No newline at end of file +``` diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index 1f2e6b43..c1540fea 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -99,8 +99,16 @@ public static Response getSelf(String accessToken) { } public static Response getBoNResponse(int n, UserRef userRef) { + return getBoNResponse(n, userRef, List.of()); + } + + public static Response getBoNResponse(int n, UserRef userRef, List filters) { long uid = resolveUid(userRef); - return getBase64BytesResponse("/users/" + uid + "/scores/bestof?" + "n=" + n, "获取最好成绩失败", null); + return getBase64BytesResponse( + "/users/" + uid + "/scores/bestof?n=" + n + encodeScoreFilters(filters), + "获取最好成绩失败", + null + ); } public static Response getGroupLeaderboardResponse(ShortcutTarget target, List uids, String auth) { @@ -187,8 +195,23 @@ public static Response getMultiplayerRoom(String accessToken) { } public static Response getRecentResponse(int n, UserRef userRef, boolean includeFail) { + return getRecentResponse(n, userRef, includeFail, List.of()); + } + + public static Response getRecentResponse(int n, UserRef userRef, boolean includeFail, List filters) { long uid = resolveUid(userRef); - return getBase64BytesResponse("/users/" + uid + "/scores/recent" + "?n=" + n + "&fail=" + includeFail, "获取最近成绩失败", null); + return getBase64BytesResponse( + "/users/" + uid + "/scores/recent?n=" + n + "&fail=" + includeFail + encodeScoreFilters(filters), + "获取最近成绩失败", + null + ); + } + + private static String encodeScoreFilters(List filters) { + if (filters == null || filters.isEmpty()) { + return ""; + } + return "&filters=" + URLEncoder.encode(String.join(",", filters), StandardCharsets.UTF_8); } public static Response getBeatmapResponse(ShortcutTarget target, String mod, String auth) { diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java index 0afe401b..bce063c0 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java @@ -4,6 +4,7 @@ import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.*; import xyz.zcraft.seira.command.parse.Resolver; +import xyz.zcraft.seira.command.parse.ScoreFilterArguments; import xyz.zcraft.seira.command.reply.CommandUsage; import xyz.zcraft.seira.command.reply.ReplyFactory; import xyz.zcraft.seira.command.parse.ShortcutTarget; @@ -30,47 +31,7 @@ public ScoreCommandHandler( } public void handleBo(Context ctx) { - if (ctx.args().length == 2) { - Integer n = resolver.parsePositiveInt(ctx.args()[0]); - if (n == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.BO)); - return; - } - UserRefResolution userRefResolution = resolver.resolveUserRefArgument(ctx.args()[1]); - if (userRefResolution.errorMessage() != null) { - ctx.sendReply(PendingMessage.ofString(userRefResolution.errorMessage())); - return; - } - if (userRefResolution.userRef() == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.BO)); - return; - } - - taskCoordinator.runImageRequest( - ctx, - "Best Scores", - () -> APIHelper.getBoNResponse(n, userRefResolution.userRef()), - replyFactory::boMessage - ); - } else if (ctx.args().length == 1) { - Integer n = resolver.parsePositiveInt(ctx.args()[0]); - if (n == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.BO)); - return; - } - Long uid = resolver.resolveBoundUid(ctx.senderUserId()); - if (uid == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); - return; - } - - taskCoordinator.runImageRequest( - ctx, - "Best Scores", - () -> APIHelper.getBoNResponse(n, new UserRef.ByUid(uid)), - replyFactory::boMessage - ); - } else if (ctx.args().length == 0) { + if (ctx.args().length == 0) { ShortcutTarget target = resolver.parseTarget("bo1", ctx.senderUserId()); if (target.isError()) { ctx.sendReply(PendingMessage.ofString(target.errorMessage())); @@ -83,55 +44,22 @@ public void handleBo(Context ctx) { () -> APIHelper.getScoreResponse(target), replyFactory::scoreMessage ); - } else { - ctx.sendReply(PendingMessage.ofString(CommandUsage.BO)); + return; } - } - public void handleRs(Context ctx, boolean includeFail) { - if (ctx.args().length == 2) { - Integer n = resolver.parsePositiveInt(ctx.args()[0]); - if (n == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.RS)); - return; - } + ScoreListRequest request = parseScoreListRequest(ctx, CommandUsage.BO); + if (request == null) return; - UserRefResolution userRefResolution = resolver.resolveUserRefArgument(ctx.args()[1]); - if (userRefResolution.errorMessage() != null) { - ctx.sendReply(PendingMessage.ofString(userRefResolution.errorMessage())); - return; - } - - if (userRefResolution.userRef() == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.RS)); - return; - } - - taskCoordinator.runImageRequest( - ctx, - "Recent Score", - () -> APIHelper.getRecentResponse(n, userRefResolution.userRef(), includeFail), - replyFactory::rsMessage - ); - } else if (ctx.args().length == 1) { - Integer n = resolver.parsePositiveInt(ctx.args()[0]); - if (n == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.RS)); - return; - } - Long uid = resolver.resolveBoundUid(ctx.senderUserId()); - if (uid == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); - return; - } + taskCoordinator.runImageRequest( + ctx, + "Best Scores", + () -> APIHelper.getBoNResponse(request.count(), request.userRef(), request.filters()), + replyFactory::boMessage + ); + } - taskCoordinator.runImageRequest( - ctx, - "Recent Score", - () -> APIHelper.getRecentResponse(n, new UserRef.ByUid(uid), includeFail), - replyFactory::rsMessage - ); - } else if (ctx.args().length == 0) { + public void handleRs(Context ctx, boolean includeFail) { + if (ctx.args().length == 0) { ShortcutTarget target = resolver.parseTarget(ctx.command() + "1", ctx.senderUserId()); if (target.isError()) { ctx.sendReply(PendingMessage.ofString(target.errorMessage())); @@ -144,9 +72,60 @@ public void handleRs(Context ctx, boolean includeFail) { () -> APIHelper.getScoreResponse(target), replyFactory::scoreMessage ); + return; + } + + ScoreListRequest request = parseScoreListRequest(ctx, CommandUsage.RS); + if (request == null) return; + + taskCoordinator.runImageRequest( + ctx, + "Recent Score", + () -> APIHelper.getRecentResponse(request.count(), request.userRef(), includeFail, request.filters()), + replyFactory::rsMessage + ); + } + + private ScoreListRequest parseScoreListRequest(Context ctx, String usage) { + String[] args = ctx.args(); + Integer count = resolver.parsePositiveInt(args[0]); + if (count == null) { + ctx.sendReply(PendingMessage.ofString(usage)); + return null; + } + + int nextArg = 1; + UserRef userRef; + if (nextArg < args.length && !ScoreFilterArguments.looksLikeFilter(args[nextArg])) { + UserRefResolution resolution = resolver.resolveUserRefArgument(args[nextArg]); + if (resolution.errorMessage() != null) { + ctx.sendReply(PendingMessage.ofString(resolution.errorMessage())); + return null; + } + if (resolution.userRef() == null) { + ctx.sendReply(PendingMessage.ofString(usage)); + return null; + } + userRef = resolution.userRef(); + nextArg++; } else { - ctx.sendReply(PendingMessage.ofString(CommandUsage.RS)); + Long uid = resolver.resolveBoundUid(ctx.senderUserId()); + if (uid == null) { + ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); + return null; + } + userRef = new UserRef.ByUid(uid); } + + ScoreFilterArguments.ParseResult filters = ScoreFilterArguments.parse(args, nextArg); + if (filters.isError()) { + ctx.sendReply(PendingMessage.ofString(filters.errorMessage() + "\n" + CommandUsage.SCORE_FILTERS)); + return null; + } + return new ScoreListRequest(count, userRef, filters.filters()); + } + + private record ScoreListRequest(int count, UserRef userRef, java.util.List filters) { } public void handleS(Context ctx) { diff --git a/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java b/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java new file mode 100644 index 00000000..ed7385c0 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java @@ -0,0 +1,159 @@ +package xyz.zcraft.seira.command.parse; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Parses score-list filters and converts aliases to oStella's compact filter syntax. */ +public final class ScoreFilterArguments { + private static final Pattern FILTER_PATTERN = Pattern.compile( + "(?i)^(acc(?:uracy)?|combo|pp|time|length|len|star|stars|sr|bpm|miss|misses|score|mod|mods|rank)" + + "(大于等于|小于等于|不包含|不等于|包含|等于|大于|小于|>=|<=|!=|!~|>|<|=|~)(.+)$" + ); + private static final Pattern DURATION_PATTERN = Pattern.compile("(?i)^(?:(\\d+)m)?(?:(\\d+(?:\\.\\d+)?)s)?$"); + private static final Set RANKS = Set.of("XH", "X", "SH", "S", "A", "B", "C", "D", "F"); + + private ScoreFilterArguments() { + } + + public static boolean looksLikeFilter(String value) { + if (value == null) return false; + return value.matches(".*(?:>=|<=|!=|!~|>|<|=|~|大于等于|小于等于|不包含|不等于|包含|等于|大于|小于).*"); + } + + public static ParseResult parse(String[] args, int startIndex) { + if (startIndex >= args.length) { + return new ParseResult(List.of(), null); + } + + List filters = new ArrayList<>(); + for (int i = startIndex; i < args.length; i++) { + try { + filters.add(parseOne(args[i])); + } catch (IllegalArgumentException e) { + return new ParseResult(List.of(), "过滤条件错误:" + e.getMessage()); + } + } + return new ParseResult(List.copyOf(filters), null); + } + + private static String parseOne(String token) { + Matcher matcher = FILTER_PATTERN.matcher(token.trim()); + if (!matcher.matches()) { + throw new IllegalArgumentException("无法识别 `" + token + "`"); + } + + String field = normalizeField(matcher.group(1)); + String operator = normalizeOperator(matcher.group(2)); + String value = matcher.group(3).trim(); + + if (field.equals("mod")) { + if (!Set.of("~", "!~", "=", "!=").contains(operator)) { + throw new IllegalArgumentException("mod 仅支持 ~、!~、=、!="); + } + validateMods(value); + } else if (field.equals("rank")) { + if (!Set.of("=", "!=").contains(operator)) { + throw new IllegalArgumentException("rank 仅支持 =、!="); + } + value = value.toUpperCase(Locale.ROOT); + if (!RANKS.contains(value)) { + throw new IllegalArgumentException("rank 必须是 XH/X/SH/S/A/B/C/D/F"); + } + } else { + if (Set.of("~", "!~").contains(operator)) { + throw new IllegalArgumentException(field + " 不支持包含运算符"); + } + double number = field.equals("time") ? parseDuration(value) : parseNumber(value); + if (number < 0) { + throw new IllegalArgumentException(field + " 不能小于 0"); + } + if (field.equals("acc") && number > 100) { + throw new IllegalArgumentException("acc 必须在 0 到 100 之间"); + } + } + + return field + operator + value; + } + + private static String normalizeField(String value) { + return switch (value.toLowerCase(Locale.ROOT)) { + case "acc", "accuracy" -> "acc"; + case "combo" -> "combo"; + case "pp" -> "pp"; + case "time", "length", "len" -> "time"; + case "star", "stars", "sr" -> "star"; + case "bpm" -> "bpm"; + case "miss", "misses" -> "miss"; + case "score" -> "score"; + case "mod", "mods" -> "mod"; + case "rank" -> "rank"; + default -> throw new IllegalArgumentException("未知字段 " + value); + }; + } + + private static String normalizeOperator(String value) { + return switch (value) { + case "大于等于" -> ">="; + case "小于等于" -> "<="; + case "不包含" -> "!~"; + case "不等于" -> "!="; + case "包含" -> "~"; + case "等于" -> "="; + case "大于" -> ">"; + case "小于" -> "<"; + default -> value; + }; + } + + private static void validateMods(String value) { + String normalized = value.toUpperCase(Locale.ROOT).replace("+", ""); + if (normalized.equals("NM")) return; + if (normalized.isEmpty() || normalized.length() % 2 != 0 || !normalized.matches("[A-Z]+")) { + throw new IllegalArgumentException("mod 值应为 NM 或 Mod 缩写组合,例如 HDDT"); + } + } + + private static double parseDuration(String value) { + String normalized = value.toLowerCase(Locale.ROOT); + if (normalized.contains(":")) { + String[] parts = normalized.split(":", -1); + if (parts.length != 2) { + throw new IllegalArgumentException("time 格式应为秒数、m:ss 或 1m30s"); + } + double minutes = parseNumber(parts[0]); + double seconds = parseNumber(parts[1]); + if (minutes < 0 || seconds < 0 || seconds >= 60) { + throw new IllegalArgumentException("time 格式应为秒数、m:ss 或 1m30s"); + } + return minutes * 60 + seconds; + } + + Matcher matcher = DURATION_PATTERN.matcher(normalized); + if (matcher.matches() && (matcher.group(1) != null || matcher.group(2) != null)) { + double minutes = matcher.group(1) == null ? 0 : parseNumber(matcher.group(1)); + double seconds = matcher.group(2) == null ? 0 : parseNumber(matcher.group(2)); + return minutes * 60 + seconds; + } + return parseNumber(normalized); + } + + private static double parseNumber(String value) { + try { + double number = Double.parseDouble(value); + if (!Double.isFinite(number)) throw new NumberFormatException(); + return number; + } catch (NumberFormatException e) { + throw new IllegalArgumentException("数值格式无效:" + value, e); + } + } + + public record ParseResult(List filters, String errorMessage) { + public boolean isError() { + return errorMessage != null; + } + } +} diff --git a/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java b/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java index b8d6e7f2..4bc5df68 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java @@ -1,10 +1,11 @@ package xyz.zcraft.seira.command.reply; public final class CommandUsage { - public static final String BO = "用法:/bo <个数> [玩家ID/用户名/@用户]"; + public static final String BO = "用法:/bo <个数> [玩家ID/用户名/@用户] [过滤条件 ...]"; public static final String NO_BIND = "你还没有绑定玩家ID,请先使用 /bind 绑定"; public static final String REBIND = "由于发生了一个技术问题,使用此功能需要重新绑定。请使用 `/unbind` 解除绑定,再使用 `/bind` 重新绑定~"; - public static final String RS = "用法:/rs <个数> [玩家ID/用户名/@用户]"; + public static final String RS = "用法:/rs <个数> [玩家ID/用户名/@用户] [过滤条件 ...]"; + public static final String SCORE_FILTERS = "过滤示例:acc>=98 combo>=1000 mod~HD mod!~DT time<2:00 pp>=300;多个条件同时生效。"; public static final String M = "用法:/m <谱面ID 或 快捷查询> [Mod]"; public static final String AP = "用法:/ap <谱面ID 或 快捷查询>"; public static final String BPV = "用法:/bpv <谱面ID 或 快捷查询> [Mod]"; From 9eb02b4e2af858217bf5e3f5d4d1a357aebff772 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 18 Aug 2026 14:42:36 +0800 Subject: [PATCH 04/22] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=A4=9A=E4=BA=BA?= =?UTF-8?q?=E6=88=BF=E9=97=B4=E7=9B=91=E8=A7=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- src/main/java/xyz/zcraft/seira/bot/QQBot.java | 14 + .../MultiplayerRoomWatchCommandHandler.java | 130 ++++++++++ .../command/handler/ScoreCommandHandler.java | 6 +- .../seira/command/reply/ReplyFactory.java | 1 + .../zcraft/seira/command/route/Router.java | 7 + .../zcraft/seira/config/RuntimeConfig.java | 4 + .../xyz/zcraft/seira/config/SeiraConfig.java | 8 + .../zcraft/seira/watch/CompletedRoomPlay.java | 14 + .../seira/watch/MultiplayerRoomNotifier.java | 7 + .../seira/watch/MultiplayerRoomWatchApi.java | 7 + .../watch/MultiplayerRoomWatchService.java | 243 ++++++++++++++++++ .../watch/OstellaMultiplayerRoomWatchApi.java | 113 ++++++++ .../watch/QqMultiplayerRoomNotifier.java | 42 +++ .../zcraft/seira/watch/RoomWatchSnapshot.java | 17 ++ .../xyz/zcraft/seira/watch/RoomWatchView.java | 4 + src/main/resources/seira-example-config.yml | 2 + 17 files changed, 620 insertions(+), 5 deletions(-) create mode 100644 src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java create mode 100644 src/main/java/xyz/zcraft/seira/watch/CompletedRoomPlay.java create mode 100644 src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomNotifier.java create mode 100644 src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchApi.java create mode 100644 src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java create mode 100644 src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java create mode 100644 src/main/java/xyz/zcraft/seira/watch/QqMultiplayerRoomNotifier.java create mode 100644 src/main/java/xyz/zcraft/seira/watch/RoomWatchSnapshot.java create mode 100644 src/main/java/xyz/zcraft/seira/watch/RoomWatchView.java diff --git a/README.md b/README.md index 16ab8ebc..c90a4d80 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,9 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 | `/f` | `/f` | 获取好友列表 | | `/fall` | `/fall` | 获取全部好友列表 | | `/fclear` | `/fclear` | 清除好友记录 | -| `/bo` | `/bo [n] [uid/username/@user] [filters...]` | 最好n个成绩图,可按acc/combo/mod/time/pp等过滤;无参时获取最佳成绩详情 | -| `/rs` | `/rs [n] [uid/username/@user] [filters...]` | 最近n个成绩图,可追加过滤条件;无参时获取最近一个成绩详情 | -| `/rp` | `/rp [n] [uid/username/@user] [filters...]` | 最近n个通过成绩图,可追加过滤条件;无参时获取最近一个通过成绩详情 | +| `/bo` | `/bo [n] [uid/username/@user] [filters...]` | 获取最多200条最好成绩后过滤;无参时获取最佳成绩详情 | +| `/rs` | `/rs [n] [uid/username/@user] [filters...]` | 获取最多200条最近成绩后过滤;无参时获取最近一个成绩详情 | +| `/rp` | `/rp [n] [uid/username/@user] [filters...]` | 获取最多200条最近通过成绩后过滤;无参时获取最近一个通过成绩详情 | | `/m` | `/m [Mod]` | 获取指定谱面信息 | | `/ap` | `/ap ` | 获取指定谱面音频预览 | | `/bgp` | `/bgp ` | 获取指定谱面背景预览 | diff --git a/src/main/java/xyz/zcraft/seira/bot/QQBot.java b/src/main/java/xyz/zcraft/seira/bot/QQBot.java index 87a8aaa5..7a3298f1 100644 --- a/src/main/java/xyz/zcraft/seira/bot/QQBot.java +++ b/src/main/java/xyz/zcraft/seira/bot/QQBot.java @@ -24,6 +24,9 @@ import xyz.zcraft.seira.security.AdminRegistry; import xyz.zcraft.seira.util.TokenManager; import xyz.zcraft.seira.watch.OstellaWatchApi; +import xyz.zcraft.seira.watch.OstellaMultiplayerRoomWatchApi; +import xyz.zcraft.seira.watch.MultiplayerRoomWatchService; +import xyz.zcraft.seira.watch.QqMultiplayerRoomNotifier; import xyz.zcraft.seira.watch.SpecificScoreNotifier; import xyz.zcraft.seira.watch.WatchScoreNotifier; import xyz.zcraft.seira.watch.ScoreWatchService; @@ -50,6 +53,7 @@ public class QQBot implements AutoCloseable, ConsoleRuntimeControl { @Getter private final MessageSender sender; private final ScoreWatchService watchService; + private final MultiplayerRoomWatchService multiplayerRoomWatchService; private final DiscordBridgeService discordBridgeService; private final AppConfig startupConfig; private final Router router; @@ -93,6 +97,13 @@ public QQBot( Duration.ofMinutes(config.seira().effectiveWatchIntervalMinutes()) ); + LOG.info("Initializing multiplayer room watch service"); + this.multiplayerRoomWatchService = new MultiplayerRoomWatchService( + new OstellaMultiplayerRoomWatchApi(config.ostella().endpoint()), + new QqMultiplayerRoomNotifier(sender), + Duration.ofSeconds(config.seira().effectiveMultiplayerWatchIntervalSeconds()) + ); + LOG.info("Initializing rank guess service"); RankGuessGameService rankGuessGameService = new RankGuessGameService(); this.attachmentHandler = new AttachmentHandler(executors.attachmentDownloads()); @@ -102,6 +113,7 @@ public QQBot( admins, bindingService, watchService, + multiplayerRoomWatchService, discordBridgeService, rankGuessGameService, executors.commandTasks(), @@ -119,6 +131,7 @@ public void start() { runnerThread = Thread.currentThread(); tokenManager.start(); watchService.start(); + multiplayerRoomWatchService.start(); discordBridgeService.start(); LOG.info("Starting bot connection loop..."); @@ -214,6 +227,7 @@ public void stop() { thread.interrupt(); } watchService.close(); + multiplayerRoomWatchService.close(); discordBridgeService.close(); tokenManager.close(); } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java new file mode 100644 index 00000000..a9d82d93 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java @@ -0,0 +1,130 @@ +package xyz.zcraft.seira.command.handler; + +import xyz.zcraft.seira.bot.data.PendingMessage; +import xyz.zcraft.seira.command.Context; +import xyz.zcraft.seira.command.ResolutionException; +import xyz.zcraft.seira.command.TaskCoordinator; +import xyz.zcraft.seira.watch.MultiplayerRoomWatchService; +import xyz.zcraft.seira.watch.RoomWatchView; + +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class MultiplayerRoomWatchCommandHandler { + private static final String USAGE = + "用法:/mpwatch start <房间ID/房间链接>;/mpwatch stop;/mpwatch status"; + private static final Pattern ROOM_URL = Pattern.compile( + "(?i)^https?://(?:www\\.)?osu\\.ppy\\.sh/multiplayer/rooms/(\\d+)(?:[/?#].*)?$" + ); + + private final TaskCoordinator taskCoordinator; + private final MultiplayerRoomWatchService watchService; + + public MultiplayerRoomWatchCommandHandler( + TaskCoordinator taskCoordinator, + MultiplayerRoomWatchService watchService + ) { + this.taskCoordinator = Objects.requireNonNull(taskCoordinator); + this.watchService = Objects.requireNonNull(watchService); + } + + public void handleMpWatch(Context ctx) { + if (!ctx.inGroup()) { + ctx.sendReply(PendingMessage.ofString("/mpwatch 仅支持群聊使用。")); + return; + } + if (ctx.argumentCount() == 0) { + usage(ctx); + return; + } + + switch (ctx.argument(0).toLowerCase(Locale.ROOT)) { + case "start" -> handleStart(ctx); + case "stop" -> handleStop(ctx); + case "status", "list" -> handleStatus(ctx); + default -> usage(ctx); + } + } + + private void handleStart(Context ctx) { + if (ctx.argumentCount() != 2) { + usage(ctx); + return; + } + Long roomId = parseRoomId(ctx.argument(1)); + if (roomId == null) { + ctx.sendReply(PendingMessage.ofString("房间 ID 或链接格式不正确。\n" + USAGE)); + return; + } + + taskCoordinator.runApiRequest(ctx, "Start Multiplayer Room Watch", () -> { + if (!ctx.sendMessage(PendingMessage.ofString("正在尝试启动多人房间监视……"))) { + ctx.sendReply(PendingMessage.ofString( + "由于缺少主动消息权限,无法启动监视!权限配置请见:https://docs.seira.top/overview/use.html#extra-permission" + )); + return; + } + try { + RoomWatchView view = watchService.watch(ctx.groupId(), roomId); + ctx.sendReply(PendingMessage.ofString( + "已开始监视多人房间“" + view.roomName() + "” (#" + view.roomId() + ")。" + + "之后完成的每张图都会自动推送结果。" + )); + } catch (IllegalArgumentException | IllegalStateException e) { + throw new ResolutionException(e.getMessage()); + } + }); + } + + private void handleStop(Context ctx) { + if (ctx.argumentCount() != 1) { + usage(ctx); + return; + } + RoomWatchView stopped = watchService.stop(ctx.groupId()); + ctx.sendReply(PendingMessage.ofString( + stopped == null + ? "当前群聊没有多人房间监视。" + : "已停止监视多人房间“" + stopped.roomName() + "” (#" + stopped.roomId() + ")。" + )); + } + + private void handleStatus(Context ctx) { + if (ctx.argumentCount() != 1) { + usage(ctx); + return; + } + RoomWatchView view = watchService.get(ctx.groupId()); + ctx.sendReply(PendingMessage.ofString( + view == null + ? "当前群聊没有多人房间监视。" + : "当前正在监视多人房间“" + view.roomName() + "” (#" + view.roomId() + ")。" + )); + } + + static Long parseRoomId(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim(); + String numeric = normalized; + Matcher matcher = ROOM_URL.matcher(normalized); + if (matcher.matches()) { + numeric = matcher.group(1); + } else if (!normalized.matches("\\d+")) { + return null; + } + try { + long roomId = Long.parseLong(numeric); + return roomId > 0 ? roomId : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + private static void usage(Context ctx) { + ctx.sendReply(PendingMessage.ofString(USAGE)); + } +} diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java index bce063c0..c29e6909 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java @@ -13,6 +13,8 @@ import xyz.zcraft.seira.data.UserRef; public final class ScoreCommandHandler { + private static final int MAX_SCORE_LIST_COUNT = 200; + private final Resolver resolver; private final TargetHistory targetHistory; private final TaskCoordinator taskCoordinator; @@ -89,8 +91,8 @@ public void handleRs(Context ctx, boolean includeFail) { private ScoreListRequest parseScoreListRequest(Context ctx, String usage) { String[] args = ctx.args(); Integer count = resolver.parsePositiveInt(args[0]); - if (count == null) { - ctx.sendReply(PendingMessage.ofString(usage)); + if (count == null || count > MAX_SCORE_LIST_COUNT) { + ctx.sendReply(PendingMessage.ofString(usage + "\n数量必须在 1 到 " + MAX_SCORE_LIST_COUNT + " 之间。")); return null; } diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 8974f3b8..86ba2401 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -581,6 +581,7 @@ public static String helpContent(Context ctx) { > /lb <谱面ID> [玩家ID列表] - 获取指定谱面排行榜 > /watch add <玩家ID/用户名/@用户> [分钟] - 监视群友的新成绩 > /wx start <谱面ID列表> - 监视指定玩家在指定谱面的成绩 + > /mpwatch start <房间ID/链接> - 监视多人房间的逐图结果 > /f - 获取好友列表 详细指令列表请在 [这里](https://docs.seira.top/overview/commands.html) 查看 diff --git a/src/main/java/xyz/zcraft/seira/command/route/Router.java b/src/main/java/xyz/zcraft/seira/command/route/Router.java index 887911fa..bb59e1cc 100644 --- a/src/main/java/xyz/zcraft/seira/command/route/Router.java +++ b/src/main/java/xyz/zcraft/seira/command/route/Router.java @@ -20,6 +20,7 @@ import xyz.zcraft.seira.security.AdminRegistry; import xyz.zcraft.seira.util.OsuAuthHelper; import xyz.zcraft.seira.watch.ScoreWatchService; +import xyz.zcraft.seira.watch.MultiplayerRoomWatchService; import java.util.Optional; import java.util.Set; @@ -46,6 +47,7 @@ public Router( AdminRegistry admins, BindingService bindingService, ScoreWatchService watchService, + MultiplayerRoomWatchService multiplayerRoomWatchService, DiscordBridgeService discordBridgeService, RankGuessGameService rankGuessGameService, Executor commandExecutor, @@ -87,6 +89,8 @@ public Router( WatchCommandHandler watchCommands = new WatchCommandHandler(resolver, taskCoordinator, watchService, admins::isAdmin); SpecificScoreWatchCommandHandler specificScoreWatchCommands = new SpecificScoreWatchCommandHandler(taskCoordinator, watchService); + MultiplayerRoomWatchCommandHandler multiplayerRoomWatchCommands = + new MultiplayerRoomWatchCommandHandler(taskCoordinator, multiplayerRoomWatchService); DcsCommandHandler dcsCommands = new DcsCommandHandler(discordBridgeService); RankGuessCommandHandler rankGuessCommands = new RankGuessCommandHandler( taskCoordinator, replyFactory, rankGuessGameService, admins::isAdmin @@ -102,6 +106,7 @@ public Router( generalCommands, watchCommands, specificScoreWatchCommands, + multiplayerRoomWatchCommands, dcsCommands, rankGuessCommands ); @@ -196,6 +201,7 @@ private static CommandRegistry createCommandRegistry( GeneralCommandHandler generalCommands, WatchCommandHandler watchCommands, SpecificScoreWatchCommandHandler specificScoreWatchCommands, + MultiplayerRoomWatchCommandHandler multiplayerRoomWatchCommands, DcsCommandHandler dcsCommands, RankGuessCommandHandler rankGuessCommands ) { @@ -233,6 +239,7 @@ private static CommandRegistry createCommandRegistry( .register(generalCommands::handleFaq, "faq") .register(watchCommands::handleWatch, "watch") .register(specificScoreWatchCommands::handleWx, "wx") + .register(multiplayerRoomWatchCommands::handleMpWatch, "mpwatch") .register(dcsCommands::handleDcs, "dcs") .register(rankGuessCommands::handleRankGuess, "rg") .build(); diff --git a/src/main/java/xyz/zcraft/seira/config/RuntimeConfig.java b/src/main/java/xyz/zcraft/seira/config/RuntimeConfig.java index 296a3927..7e6edbc7 100644 --- a/src/main/java/xyz/zcraft/seira/config/RuntimeConfig.java +++ b/src/main/java/xyz/zcraft/seira/config/RuntimeConfig.java @@ -80,6 +80,7 @@ private static AppConfig mergeReloadable(AppConfig previous, AppConfig loaded) { newSeira.directUrl(), newSeira.queueMessageInGroup(), oldSeira.watchIntervalMinutes(), + oldSeira.multiplayerWatchIntervalSeconds(), newSeira.debugMode(), newSeira.adminIds() == null ? List.of() : List.copyOf(newSeira.adminIds()) ); @@ -109,6 +110,9 @@ private static List restartRequiredChanges(AppConfig previous, AppConfig addIfChanged(changed, "seira.sqlitePath", previous.seira().sqlitePath(), loaded.seira().sqlitePath()); addIfChanged(changed, "seira.watchIntervalMinutes", previous.seira().watchIntervalMinutes(), loaded.seira().watchIntervalMinutes()); + addIfChanged(changed, "seira.multiplayerWatchIntervalSeconds", + previous.seira().multiplayerWatchIntervalSeconds(), + loaded.seira().multiplayerWatchIntervalSeconds()); addIfChanged(changed, "ostella", previous.ostella(), loaded.ostella()); addIfChanged(changed, "binding", previous.binding(), loaded.binding()); addIfChanged(changed, "qq", previous.qq(), loaded.qq()); diff --git a/src/main/java/xyz/zcraft/seira/config/SeiraConfig.java b/src/main/java/xyz/zcraft/seira/config/SeiraConfig.java index 771173e2..f55a4179 100644 --- a/src/main/java/xyz/zcraft/seira/config/SeiraConfig.java +++ b/src/main/java/xyz/zcraft/seira/config/SeiraConfig.java @@ -5,14 +5,22 @@ public record SeiraConfig( String directUrl, boolean queueMessageInGroup, Integer watchIntervalMinutes, + Integer multiplayerWatchIntervalSeconds, boolean debugMode, java.util.List adminIds ) { private static final int DEFAULT_WATCH_INTERVAL_MINUTES = 5; + private static final int DEFAULT_MULTIPLAYER_WATCH_INTERVAL_SECONDS = 30; public int effectiveWatchIntervalMinutes() { return watchIntervalMinutes == null || watchIntervalMinutes <= 0 ? DEFAULT_WATCH_INTERVAL_MINUTES : watchIntervalMinutes; } + + public int effectiveMultiplayerWatchIntervalSeconds() { + return multiplayerWatchIntervalSeconds == null || multiplayerWatchIntervalSeconds <= 0 + ? DEFAULT_MULTIPLAYER_WATCH_INTERVAL_SECONDS + : multiplayerWatchIntervalSeconds; + } } diff --git a/src/main/java/xyz/zcraft/seira/watch/CompletedRoomPlay.java b/src/main/java/xyz/zcraft/seira/watch/CompletedRoomPlay.java new file mode 100644 index 00000000..fe51d356 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/watch/CompletedRoomPlay.java @@ -0,0 +1,14 @@ +package xyz.zcraft.seira.watch; + +import com.google.gson.annotations.SerializedName; + +public record CompletedRoomPlay( + @SerializedName("playlist_item_id") long playlistItemId, + @SerializedName("played_at") String playedAt +) { + public CompletedRoomPlay { + if (playlistItemId <= 0) { + throw new IllegalArgumentException("playlistItemId must be positive"); + } + } +} diff --git a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomNotifier.java b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomNotifier.java new file mode 100644 index 00000000..81394d5c --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomNotifier.java @@ -0,0 +1,7 @@ +package xyz.zcraft.seira.watch; + +public interface MultiplayerRoomNotifier { + boolean sendResult(String groupId, byte[] imageBytes); + + boolean sendRoomEnded(String groupId, RoomWatchSnapshot snapshot); +} diff --git a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchApi.java b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchApi.java new file mode 100644 index 00000000..028b379c --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchApi.java @@ -0,0 +1,7 @@ +package xyz.zcraft.seira.watch; + +public interface MultiplayerRoomWatchApi { + RoomWatchSnapshot getSnapshot(long roomId); + + byte[] renderResult(long roomId, long playlistItemId); +} diff --git a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java new file mode 100644 index 00000000..85593596 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java @@ -0,0 +1,243 @@ +package xyz.zcraft.seira.watch; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class MultiplayerRoomWatchService implements AutoCloseable { + private static final Logger LOG = LogManager.getLogger(MultiplayerRoomWatchService.class); + + private final Object lock = new Object(); + private final Map watchesByGroup = new LinkedHashMap<>(); + private final MultiplayerRoomWatchApi api; + private final MultiplayerRoomNotifier notifier; + private final Duration pollInterval; + private final ScheduledExecutorService scheduler; + private final AtomicBoolean started = new AtomicBoolean(); + private final AtomicBoolean closed = new AtomicBoolean(); + + public MultiplayerRoomWatchService( + MultiplayerRoomWatchApi api, + MultiplayerRoomNotifier notifier, + Duration pollInterval + ) { + this.api = Objects.requireNonNull(api); + this.notifier = Objects.requireNonNull(notifier); + this.pollInterval = requirePositive(pollInterval); + this.scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "seira-multiplayer-room-watch"); + thread.setDaemon(true); + return thread; + }); + } + + public void start() { + if (closed.get()) { + throw new IllegalStateException("Multiplayer room watch service has been closed"); + } + if (!started.compareAndSet(false, true)) { + return; + } + scheduler.scheduleWithFixedDelay( + this::pollSafely, + pollInterval.toMillis(), + pollInterval.toMillis(), + TimeUnit.MILLISECONDS + ); + } + + public RoomWatchView watch(String groupId, long roomId) { + Objects.requireNonNull(groupId); + if (roomId <= 0) { + throw new IllegalArgumentException("房间 ID 必须为正整数。"); + } + RoomWatchSnapshot snapshot = api.getSnapshot(roomId); + if (!snapshot.active()) { + throw new IllegalStateException("该多人房间已经结束,无法开始监视。"); + } + + Set baseline = new LinkedHashSet<>(); + snapshot.completedPlays().forEach(play -> baseline.add(play.playlistItemId())); + WatchEntry entry = new WatchEntry(snapshot.roomId(), snapshot.roomName(), baseline); + synchronized (lock) { + watchesByGroup.put(groupId, entry); + } + return view(entry); + } + + public RoomWatchView stop(String groupId) { + synchronized (lock) { + WatchEntry removed = watchesByGroup.remove(groupId); + return removed == null ? null : view(removed); + } + } + + public RoomWatchView get(String groupId) { + synchronized (lock) { + WatchEntry entry = watchesByGroup.get(groupId); + return entry == null ? null : view(entry); + } + } + + public void pollNow() { + Map> watchesByRoom = snapshotByRoom(); + for (Map.Entry> room : watchesByRoom.entrySet()) { + try { + processRoom(room.getKey(), room.getValue()); + } catch (RuntimeException e) { + LOG.error("Failed to poll multiplayer room {}", room.getKey(), e); + } + } + } + + private void processRoom(long roomId, List watches) { + RoomWatchSnapshot snapshot = api.getSnapshot(roomId); + List completed = snapshot.completedPlays().stream() + .sorted(Comparator + .comparing(CompletedRoomPlay::playedAt, Comparator.nullsLast(String::compareTo)) + .thenComparingLong(CompletedRoomPlay::playlistItemId)) + .toList(); + Map rendered = new HashMap<>(); + + for (WatchRef watch : watches) { + if (!isCurrent(watch)) { + continue; + } + boolean allResultsSent = sendPendingResults(watch, completed, rendered); + if (!snapshot.active() && allResultsSent && isCurrent(watch)) { + if (notifier.sendRoomEnded(watch.groupId(), snapshot)) { + removeIfCurrent(watch); + } else { + LOG.warn("Failed to send room-ended notice for room {} to group {}", roomId, watch.groupId()); + } + } + } + } + + private boolean sendPendingResults( + WatchRef watch, + List completed, + Map rendered + ) { + for (CompletedRoomPlay play : completed) { + if (wasSent(watch, play.playlistItemId())) { + continue; + } + try { + byte[] image = rendered.computeIfAbsent( + play.playlistItemId(), + itemId -> api.renderResult(watch.entry().roomId, itemId) + ); + if (!notifier.sendResult(watch.groupId(), image)) { + LOG.warn( + "Failed to send room {} playlist item {} to group {}", + watch.entry().roomId, play.playlistItemId(), watch.groupId() + ); + return false; + } + markSent(watch, play.playlistItemId()); + } catch (RuntimeException e) { + LOG.error( + "Failed to render/send room {} playlist item {} to group {}", + watch.entry().roomId, play.playlistItemId(), watch.groupId(), e + ); + return false; + } + } + return true; + } + + private Map> snapshotByRoom() { + synchronized (lock) { + Map> result = new LinkedHashMap<>(); + watchesByGroup.forEach((groupId, entry) -> result + .computeIfAbsent(entry.roomId, ignored -> new ArrayList<>()) + .add(new WatchRef(groupId, entry))); + return result; + } + } + + private boolean isCurrent(WatchRef watch) { + synchronized (lock) { + return watchesByGroup.get(watch.groupId()) == watch.entry(); + } + } + + private boolean wasSent(WatchRef watch, long playlistItemId) { + synchronized (lock) { + return watchesByGroup.get(watch.groupId()) != watch.entry() + || watch.entry().sentPlaylistItemIds.contains(playlistItemId); + } + } + + private void markSent(WatchRef watch, long playlistItemId) { + synchronized (lock) { + if (watchesByGroup.get(watch.groupId()) == watch.entry()) { + watch.entry().sentPlaylistItemIds.add(playlistItemId); + } + } + } + + private void removeIfCurrent(WatchRef watch) { + synchronized (lock) { + if (watchesByGroup.get(watch.groupId()) == watch.entry()) { + watchesByGroup.remove(watch.groupId()); + } + } + } + + private void pollSafely() { + try { + pollNow(); + } catch (RuntimeException e) { + LOG.error("Failed to poll multiplayer room watches", e); + } + } + + private static Duration requirePositive(Duration duration) { + if (duration == null || duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException("pollInterval must be positive"); + } + return duration; + } + + private static RoomWatchView view(WatchEntry entry) { + return new RoomWatchView(entry.roomId, entry.roomName); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + scheduler.shutdownNow(); + } + } + + private static final class WatchEntry { + private final long roomId; + private final String roomName; + private final Set sentPlaylistItemIds; + + private WatchEntry(long roomId, String roomName, Set sentPlaylistItemIds) { + this.roomId = roomId; + this.roomName = roomName; + this.sentPlaylistItemIds = new LinkedHashSet<>(sentPlaylistItemIds); + } + } + + private record WatchRef(String groupId, WatchEntry entry) { + } +} diff --git a/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java b/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java new file mode 100644 index 00000000..107270e5 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java @@ -0,0 +1,113 @@ +package xyz.zcraft.seira.watch; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +public final class OstellaMultiplayerRoomWatchApi implements MultiplayerRoomWatchApi { + private final String endpoint; + private final HttpClient client; + private final Gson gson; + + public OstellaMultiplayerRoomWatchApi(String endpoint) { + this(endpoint, HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(), new Gson()); + } + + OstellaMultiplayerRoomWatchApi(String endpoint, HttpClient client, Gson gson) { + this.endpoint = endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; + this.client = client; + this.gson = gson; + } + + @Override + public RoomWatchSnapshot getSnapshot(long roomId) { + HttpResponse response = get( + "/multiplayer/rooms/" + requirePositive(roomId, "roomId") + "/watch", + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8) + ); + if (response.statusCode() == 404) { + throw new IllegalStateException("未找到指定的多人房间。"); + } + ensureSuccessfulStatus(response.statusCode(), response.body(), "获取多人房间"); + JsonObject root = gson.fromJson(response.body(), JsonObject.class); + JsonElement data = successfulData(root, "获取多人房间"); + RoomWatchSnapshot snapshot = gson.fromJson(data, RoomWatchSnapshot.class); + if (snapshot == null || snapshot.roomId() <= 0) { + throw new IllegalStateException("获取多人房间失败: 响应缺少房间数据"); + } + return snapshot; + } + + @Override + public byte[] renderResult(long roomId, long playlistItemId) { + HttpResponse response = get( + "/multiplayer/rooms/" + requirePositive(roomId, "roomId") + + "/playlist/" + requirePositive(playlistItemId, "playlistItemId") + "/result", + HttpResponse.BodyHandlers.ofByteArray() + ); + ensureSuccessfulStatus(response.statusCode(), response.body(), "生成多人房间结果图片"); + if (response.body().length == 0) { + throw new IllegalStateException("生成多人房间结果图片失败: 响应为空"); + } + return response.body(); + } + + private HttpResponse get(String path, HttpResponse.BodyHandler handler) { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(endpoint + path)) + .timeout(Duration.ofMinutes(2)) + .header("Accept", "application/json, image/*") + .GET() + .build(); + try { + return client.send(request, handler); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("oStella 请求被中断", e); + } catch (IOException e) { + throw new RuntimeException("无法连接 oStella", e); + } + } + + private static JsonElement successfulData(JsonObject root, String action) { + if (root == null || !root.has("success") || !root.get("success").getAsBoolean()) { + String message = root != null && root.has("message") && !root.get("message").isJsonNull() + ? root.get("message").getAsString() + : "未知错误"; + throw new IllegalStateException(action + "失败: " + message); + } + JsonElement data = root.get("data"); + if (data == null || data.isJsonNull()) { + throw new IllegalStateException(action + "失败: 响应缺少 data"); + } + return data; + } + + private static void ensureSuccessfulStatus(int statusCode, Object body, String action) { + if (statusCode >= 200 && statusCode < 300) { + return; + } + String detail = body instanceof byte[] bytes + ? new String(bytes, StandardCharsets.UTF_8) + : String.valueOf(body); + if (detail.length() > 500) { + detail = detail.substring(0, 500); + } + throw new IllegalStateException(action + "失败: HTTP " + statusCode + " " + detail); + } + + private static long requirePositive(long value, String name) { + if (value <= 0) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } +} diff --git a/src/main/java/xyz/zcraft/seira/watch/QqMultiplayerRoomNotifier.java b/src/main/java/xyz/zcraft/seira/watch/QqMultiplayerRoomNotifier.java new file mode 100644 index 00000000..255af82e --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/watch/QqMultiplayerRoomNotifier.java @@ -0,0 +1,42 @@ +package xyz.zcraft.seira.watch; + +import xyz.zcraft.seira.bot.MessageSender; +import xyz.zcraft.seira.bot.data.FileInfo; +import xyz.zcraft.seira.bot.data.Message; +import xyz.zcraft.seira.bot.data.PendingMessage; + +import java.util.Base64; +import java.util.Objects; + +public final class QqMultiplayerRoomNotifier implements MultiplayerRoomNotifier { + private final MessageSender messageSender; + + public QqMultiplayerRoomNotifier(MessageSender messageSender) { + this.messageSender = Objects.requireNonNull(messageSender); + } + + @Override + public boolean sendResult(String groupId, byte[] imageBytes) { + FileInfo media = messageSender.uploadGroupMediaBase64( + groupId, + PendingMessage.FILE_TYPE_IMAGE, + Base64.getEncoder().encodeToString(imageBytes) + ); + if (media == null) { + return false; + } + + Message message = new Message(); + message.setMsgType(PendingMessage.MSG_TYPE_MEDIA); + message.setMedia(media); + return messageSender.sendGroupMessage(groupId, message); + } + + @Override + public boolean sendRoomEnded(String groupId, RoomWatchSnapshot snapshot) { + return messageSender.sendGroupText( + groupId, + "多人房间“" + snapshot.roomName() + "” (#" + snapshot.roomId() + ") 已结束,监视已自动停止。" + ); + } +} diff --git a/src/main/java/xyz/zcraft/seira/watch/RoomWatchSnapshot.java b/src/main/java/xyz/zcraft/seira/watch/RoomWatchSnapshot.java new file mode 100644 index 00000000..5def041a --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/watch/RoomWatchSnapshot.java @@ -0,0 +1,17 @@ +package xyz.zcraft.seira.watch; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; + +public record RoomWatchSnapshot( + @SerializedName("room_id") long roomId, + @SerializedName("room_name") String roomName, + boolean active, + @SerializedName("completed_plays") List completedPlays +) { + public RoomWatchSnapshot { + roomName = roomName == null || roomName.isBlank() ? "多人房间 #" + roomId : roomName; + completedPlays = completedPlays == null ? List.of() : List.copyOf(completedPlays); + } +} diff --git a/src/main/java/xyz/zcraft/seira/watch/RoomWatchView.java b/src/main/java/xyz/zcraft/seira/watch/RoomWatchView.java new file mode 100644 index 00000000..d6aacc32 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/watch/RoomWatchView.java @@ -0,0 +1,4 @@ +package xyz.zcraft.seira.watch; + +public record RoomWatchView(long roomId, String roomName) { +} diff --git a/src/main/resources/seira-example-config.yml b/src/main/resources/seira-example-config.yml index 5ce659a3..c83dd5a8 100644 --- a/src/main/resources/seira-example-config.yml +++ b/src/main/resources/seira-example-config.yml @@ -12,6 +12,8 @@ seira: adminIds: [ ] # 新成绩监视的批量轮询间隔(分钟) watchIntervalMinutes: 5 + # 多人房间结果监视的轮询间隔(秒) + multiplayerWatchIntervalSeconds: 30 # === 用户绑定配置 === binding: From 7944d7bc0d468be4641d2859ca9d7b907bd3e7fe Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 18 Aug 2026 14:58:34 +0800 Subject: [PATCH 05/22] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=9B=9E=E6=94=BE?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xyz/zcraft/seira/api/APIHelper.java | 19 ++++++++++--------- .../seira/api/ReplayRenderException.java | 14 ++++++++++++++ .../xyz/zcraft/seira/api/data/RenderStat.java | 1 + .../zcraft/seira/command/TaskCoordinator.java | 6 +++++- .../seira/command/reply/ReplyFactory.java | 3 +++ 5 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index c1540fea..df40b699 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -581,13 +581,9 @@ public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget target, Str } public static ReplayRenderResult waitReplayVideo(String taskId) { - try { - FileInfo qqFile = waitReplayDone(taskId); - return new ReplayRenderResult( - ENDPOINT + "/replays/" + taskId + "/video/replay.mp4", taskId, qqFile); - } catch (RuntimeException _) { - return null; - } + FileInfo qqFile = waitReplayDone(taskId); + return new ReplayRenderResult( + ENDPOINT + "/replays/" + taskId + "/video/replay.mp4", taskId, qqFile); } private static ReplayTaskInfo createReplayTask(ShortcutTarget target, @@ -742,8 +738,13 @@ private static FileInfo waitReplayDone(String taskId) { ? GSON.fromJson(statusData.getAsJsonObject("qqFile"), FileInfo.class) : null; } - if ("failed".equalsIgnoreCase(status) || "canceled".equalsIgnoreCase(status)) { - throw new RuntimeException("回放渲染失败,状态:" + status); + if ("failed".equalsIgnoreCase(status) + || "timeout".equalsIgnoreCase(status) + || "canceled".equalsIgnoreCase(status)) { + String error = statusData.has("error") && !statusData.get("error").isJsonNull() + ? statusData.get("error").getAsString() + : null; + throw new ReplayRenderException(status, error); } try { Thread.sleep(REPLAY_POLL_INTERVAL_MS); diff --git a/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java b/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java new file mode 100644 index 00000000..38f99d01 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java @@ -0,0 +1,14 @@ +package xyz.zcraft.seira.api; + +public final class ReplayRenderException extends RuntimeException { + public ReplayRenderException(String status, String error) { + super(formatMessage(status, error)); + } + + static String formatMessage(String status, String error) { + if (error != null && !error.isBlank()) { + return "回放渲染失败:" + error.trim(); + } + return "回放渲染失败,状态:" + (status == null || status.isBlank() ? "failed" : status); + } +} diff --git a/src/main/java/xyz/zcraft/seira/api/data/RenderStat.java b/src/main/java/xyz/zcraft/seira/api/data/RenderStat.java index 1a54c8e6..48f11cb8 100644 --- a/src/main/java/xyz/zcraft/seira/api/data/RenderStat.java +++ b/src/main/java/xyz/zcraft/seira/api/data/RenderStat.java @@ -9,4 +9,5 @@ public class RenderStat { private String progress; private String speed; private String eta; + private String error; } diff --git a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java index 5887e761..9244ffd2 100644 --- a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java +++ b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java @@ -5,6 +5,7 @@ import org.apache.logging.log4j.Logger; import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.ApiRequestException; +import xyz.zcraft.seira.api.ReplayRenderException; import xyz.zcraft.seira.api.data.Base64Bytes; import xyz.zcraft.seira.api.data.QqUploadRequest; import xyz.zcraft.seira.api.data.Response; @@ -289,7 +290,7 @@ public synchronized boolean sendQueueNotice(PendingMessage message) { } - private String resolveErrorMessage(Exception exception) { + static String resolveErrorMessage(Exception exception) { Throwable cursor = exception; while (cursor != null) { switch (cursor) { @@ -302,6 +303,9 @@ private String resolveErrorMessage(Exception exception) { case ResolutionException e -> { return e.getMessage(); } + case ReplayRenderException e -> { + return e.getMessage(); + } default -> { } } diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 86ba2401..f9bc3a51 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -400,6 +400,9 @@ static String replayStatContent(Context ctx, RenderStat renderStat, String jobId sb.append("速度: ").append(renderStat.getSpeed() == null ? "未知" : renderStat.getSpeed()).append("\n"); sb.append("预计时间: ").append(renderStat.getEta() == null ? "未知" : renderStat.getEta()).append("\n"); } + if (renderStat.getError() != null && !renderStat.getError().isBlank()) { + sb.append("原因: ").append(renderStat.getError()).append("\n"); + } return sb.toString().trim(); } From ba9ec7fb97b71154dcc19af3515a483ba2d3afcf Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 18 Aug 2026 15:35:23 +0800 Subject: [PATCH 06/22] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dbp=E5=92=8Crs=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zcraft/seira/command/handler/ScoreCommandHandler.java | 2 +- src/main/java/xyz/zcraft/seira/command/parse/Resolver.java | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java index c29e6909..3819da1b 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java @@ -98,7 +98,7 @@ private ScoreListRequest parseScoreListRequest(Context ctx, String usage) { int nextArg = 1; UserRef userRef; - if (nextArg < args.length && !ScoreFilterArguments.looksLikeFilter(args[nextArg])) { + if (nextArg < args.length && resolver.looksLikeMention(args[nextArg])) { UserRefResolution resolution = resolver.resolveUserRefArgument(args[nextArg]); if (resolution.errorMessage() != null) { ctx.sendReply(PendingMessage.ofString(resolution.errorMessage())); diff --git a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java index 3b84c3be..cc90f4f7 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java @@ -233,9 +233,11 @@ private boolean isUserMacro(String arg) { return Patterns.USER_MACRO_PATTERN.matcher(arg.trim()).matches(); } - private boolean looksLikeMention(String token) { + public boolean looksLikeMention(String token) { String trimmed = token == null ? "" : token.trim(); - return trimmed.startsWith("@") || trimmed.startsWith("[CQ:at,"); + return trimmed.startsWith("@") + || trimmed.startsWith("[CQ:at,") + || (trimmed.startsWith("<@") && trimmed.endsWith(">")); } public String extractMentionedUserId(String token) { From 88d1576de824d30dc847d470c2a322791cd10fa6 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 18 Aug 2026 15:38:40 +0800 Subject: [PATCH 07/22] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a12c6680..93b65a1c 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira SeiraCore - 1.10.1 + 1.11.0 25 From aa3ca901f6a1286ac28c927e51006bb262057ebf Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 19 Aug 2026 00:25:53 +0800 Subject: [PATCH 08/22] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=83=A8=E5=88=86?= =?UTF-8?q?=E6=8C=87=E4=BB=A4=E7=9A=84=E8=BF=87=E6=BB=A4=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++-- .../java/xyz/zcraft/seira/api/APIHelper.java | 12 ++++++-- .../command/handler/ScoreCommandHandler.java | 30 +++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c90a4d80..2f1d89df 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,9 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 | `/f` | `/f` | 获取好友列表 | | `/fall` | `/fall` | 获取全部好友列表 | | `/fclear` | `/fclear` | 清除好友记录 | -| `/bo` | `/bo [n] [uid/username/@user] [filters...]` | 获取最多200条最好成绩后过滤;无参时获取最佳成绩详情 | -| `/rs` | `/rs [n] [uid/username/@user] [filters...]` | 获取最多200条最近成绩后过滤;无参时获取最近一个成绩详情 | -| `/rp` | `/rp [n] [uid/username/@user] [filters...]` | 获取最多200条最近通过成绩后过滤;无参时获取最近一个通过成绩详情 | +| `/bo` | `/bo [n] [uid/username/@user] [filters...]` | 获取最多200条最好成绩后过滤;省略n时返回第一个匹配成绩详情 | +| `/rs` | `/rs [n] [uid/username/@user] [filters...]` | 获取最多200条最近成绩后过滤;省略n时返回第一个匹配成绩详情 | +| `/rp` | `/rp [n] [uid/username/@user] [filters...]` | 获取最多200条最近通过成绩后过滤;省略n时返回第一个匹配成绩详情 | | `/m` | `/m [Mod]` | 获取指定谱面信息 | | `/ap` | `/ap ` | 获取指定谱面音频预览 | | `/bgp` | `/bgp ` | 获取指定谱面背景预览 | diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index df40b699..4785db5f 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -356,7 +356,11 @@ private static String getBeatmapsetQuery(ShortcutTarget target) { } public static Response getScoreResponse(ShortcutTarget target) { - String scoreId = lookupScoreId(target); + return getScoreResponse(target, List.of()); + } + + public static Response getScoreResponse(ShortcutTarget target, List filters) { + String scoreId = lookupScoreId(target, filters); return getBase64BytesResponse("/scores/" + scoreId, "获取成绩失败", null); } @@ -635,6 +639,10 @@ private static TimeDurationParser.TimeRange getScoreHighlight(String scoreId, in } private static String lookupScoreId(ShortcutTarget target) { + return lookupScoreId(target, List.of()); + } + + private static String lookupScoreId(ShortcutTarget target, List filters) { String scoreId; if (target.isLocalScore()) { scoreId = target.localScoreId(); @@ -642,7 +650,7 @@ private static String lookupScoreId(ShortcutTarget target) { scoreId = String.valueOf(target.explicitId()); } else { try { - final String query = getScoreQuery(target); + final String query = getScoreQuery(target) + encodeScoreFilters(filters); HttpRequest localRequest = HttpRequest.newBuilder() .uri(URI.create(ENDPOINT + query)) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java index 3819da1b..8d471a0c 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java @@ -48,6 +48,10 @@ public void handleBo(Context ctx) { ); return; } + if (ScoreFilterArguments.looksLikeFilter(ctx.args()[0])) { + handleFilteredSingleScore(ctx, "bo"); + return; + } ScoreListRequest request = parseScoreListRequest(ctx, CommandUsage.BO); if (request == null) return; @@ -76,6 +80,10 @@ public void handleRs(Context ctx, boolean includeFail) { ); return; } + if (ScoreFilterArguments.looksLikeFilter(ctx.args()[0])) { + handleFilteredSingleScore(ctx, ctx.command()); + return; + } ScoreListRequest request = parseScoreListRequest(ctx, CommandUsage.RS); if (request == null) return; @@ -88,6 +96,28 @@ public void handleRs(Context ctx, boolean includeFail) { ); } + private void handleFilteredSingleScore(Context ctx, String macroType) { + ScoreFilterArguments.ParseResult filters = ScoreFilterArguments.parse(ctx.args(), 0); + if (filters.isError()) { + ctx.sendReply(PendingMessage.ofString(filters.errorMessage() + "\n" + CommandUsage.SCORE_FILTERS)); + return; + } + + Long uid = resolver.resolveBoundUid(ctx.senderUserId()); + if (uid == null) { + ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); + return; + } + + ShortcutTarget target = new ShortcutTarget(null, new UserRef.ByUid(uid), macroType, 1L, null); + taskCoordinator.runImageRequest( + ctx, + "Score", + () -> APIHelper.getScoreResponse(target, filters.filters()), + replyFactory::scoreMessage + ); + } + private ScoreListRequest parseScoreListRequest(Context ctx, String usage) { String[] args = ctx.args(); Integer count = resolver.parsePositiveInt(args[0]); From ef56c0a685acd9b0c993eb386fdba529c8f942a3 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 19 Aug 2026 00:30:51 +0800 Subject: [PATCH 09/22] =?UTF-8?q?=E6=B7=BB=E5=8A=A0/mpwatch=E7=AE=80?= =?UTF-8?q?=E7=89=88=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xyz/zcraft/seira/command/Context.java | 4 +++ .../MultiplayerRoomWatchCommandHandler.java | 30 +++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/Context.java b/src/main/java/xyz/zcraft/seira/command/Context.java index c4b5b6d5..6306d29d 100644 --- a/src/main/java/xyz/zcraft/seira/command/Context.java +++ b/src/main/java/xyz/zcraft/seira/command/Context.java @@ -64,6 +64,10 @@ public boolean sendReply(PendingMessage message) { return requireReplies().sendReply(Objects.requireNonNull(message, "message")); } + public boolean sendReply(String message) { + return requireReplies().sendReply(PendingMessage.ofString(message)); + } + /** Sends an active message to the same user or group, without an inbound message reference. */ public boolean sendMessage(PendingMessage message) { return requireReplies().sendProactive(Objects.requireNonNull(message, "message")); diff --git a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java index a9d82d93..25da909d 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java @@ -1,5 +1,10 @@ package xyz.zcraft.seira.command.handler; +import xyz.zcraft.osu.model.MultiplayerRoom; +import xyz.zcraft.seira.api.APIHelper; +import xyz.zcraft.seira.api.data.OsuToken; +import xyz.zcraft.seira.api.data.Response; +import xyz.zcraft.seira.binding.UserDataStore; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.ResolutionException; @@ -35,25 +40,40 @@ public void handleMpWatch(Context ctx) { ctx.sendReply(PendingMessage.ofString("/mpwatch 仅支持群聊使用。")); return; } + if (ctx.argumentCount() == 0) { - usage(ctx); + handleStart(ctx, true); return; } switch (ctx.argument(0).toLowerCase(Locale.ROOT)) { - case "start" -> handleStart(ctx); + case "start" -> handleStart(ctx, false); case "stop" -> handleStop(ctx); case "status", "list" -> handleStatus(ctx); default -> usage(ctx); } } - private void handleStart(Context ctx) { - if (ctx.argumentCount() != 2) { + private void handleStart(Context ctx, boolean current) { + if (ctx.argumentCount() != 2 && !current) { usage(ctx); return; } - Long roomId = parseRoomId(ctx.argument(1)); + + Long roomId; + + if (!current) { + roomId = parseRoomId(ctx.argument(1)); + } else { + final OsuToken osuToken = UserDataStore.findOsuToken(ctx.senderUserId()); + if (osuToken == null) { + ctx.sendReply("由于未绑定账户,无法获取当前房间~"); + return; + } + final Response multiplayerRoom = APIHelper.getMultiplayerRoom(osuToken.accessToken()); + roomId = multiplayerRoom.getContent().getId(); + } + if (roomId == null) { ctx.sendReply(PendingMessage.ofString("房间 ID 或链接格式不正确。\n" + USAGE)); return; From ee2aa229119b74baf312af8bad42f01c48e42ae2 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 19 Aug 2026 00:32:14 +0800 Subject: [PATCH 10/22] =?UTF-8?q?=E6=B7=BB=E5=8A=A0/mpwatch=E7=AE=80?= =?UTF-8?q?=E7=89=88=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/MultiplayerRoomWatchCommandHandler.java | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java index 25da909d..4ec45fc4 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java @@ -55,19 +55,14 @@ public void handleMpWatch(Context ctx) { } private void handleStart(Context ctx, boolean current) { - if (ctx.argumentCount() != 2 && !current) { - usage(ctx); - return; - } - Long roomId; - if (!current) { + if (!current || ctx.argumentCount() != 2) { roomId = parseRoomId(ctx.argument(1)); } else { final OsuToken osuToken = UserDataStore.findOsuToken(ctx.senderUserId()); if (osuToken == null) { - ctx.sendReply("由于未绑定账户,无法获取当前房间~"); + ctx.sendReply("由于未绑定账户,无法获取当前房间,请手动提供ID~"); return; } final Response multiplayerRoom = APIHelper.getMultiplayerRoom(osuToken.accessToken()); From 8b4f293a9ca103d172aed69c3760375e8113aadb Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 19 Aug 2026 00:33:31 +0800 Subject: [PATCH 11/22] =?UTF-8?q?=E6=9B=B4=E6=96=B0README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2f1d89df..53684d85 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 | `/luck` | `/luck` | 今日人品 | | `/mp` | `/mp` | 多人房间列表 | | `/watch` | `/watch add/del/list [目标]` | 添加/删除/列出监视任务 | +| `/mpwatch` | `/watch start/stop/status [目标]` | 添加/删除/列出多人房间监视 | | `/wx` | `/wx start <谱面ID列表>` / `/wx stop` | 监视指定玩家在指定谱面取得的成绩,重启后自动恢复 | | `/dcs` | `/dcs start .` / `/dcs stop` | 开启或解除当前 QQ 群与 Discord 频道的双向消息同步 | | `/stat` | `/stat` | 服务状态和统计信息文本 | From aa7438683fea9ce5ca2ab250155a41a797ac94d1 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 19 Aug 2026 18:18:36 +0800 Subject: [PATCH 12/22] =?UTF-8?q?=E6=B7=BB=E5=8A=A0/tb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + panel.json | 7 +++- .../java/xyz/zcraft/seira/api/APIHelper.java | 9 +++++ .../command/handler/ScoreCommandHandler.java | 36 +++++++++++++++++++ .../seira/command/reply/CommandUsage.java | 1 + .../seira/command/reply/ReplyFactory.java | 10 ++++++ .../zcraft/seira/command/route/Router.java | 1 + 7 files changed, 64 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 53684d85..ac9d88aa 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 | `/bo` | `/bo [n] [uid/username/@user] [filters...]` | 获取最多200条最好成绩后过滤;省略n时返回第一个匹配成绩详情 | | `/rs` | `/rs [n] [uid/username/@user] [filters...]` | 获取最多200条最近成绩后过滤;省略n时返回第一个匹配成绩详情 | | `/rp` | `/rp [n] [uid/username/@user] [filters...]` | 获取最多200条最近通过成绩后过滤;省略n时返回第一个匹配成绩详情 | +| `/tb` | `/tb [uid/username/@user]` | 获取最近200条通过成绩与全部200条BP的交集,并保留全BP排名编号 | | `/m` | `/m [Mod]` | 获取指定谱面信息 | | `/ap` | `/ap ` | 获取指定谱面音频预览 | | `/bgp` | `/bgp ` | 获取指定谱面背景预览 | diff --git a/panel.json b/panel.json index 918d09e8..8e4e12fa 100644 --- a/panel.json +++ b/panel.json @@ -35,6 +35,11 @@ "name": "/bp", "desc": "查询最好成绩" }, + { + "type": "command", + "name": "/tb", + "desc": "查询今日新增BP" + }, { "type": "command", "name": "/s", @@ -97,4 +102,4 @@ } ], "remark": "v1.10.0" -} \ No newline at end of file +} diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index 4785db5f..6bf2a676 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -111,6 +111,15 @@ public static Response getBoNResponse(int n, UserRef userRef, List< ); } + public static Response getTodayBestResponse(UserRef userRef) { + long uid = resolveUid(userRef); + return getBase64BytesResponse( + "/users/" + uid + "/scores/today-best", + "获取今日BP失败", + null + ); + } + public static Response getGroupLeaderboardResponse(ShortcutTarget target, List uids, String auth) { final long beatmapId = lookupBeatmap(target, auth); return getBase64BytesResponse( diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java index 8d471a0c..9ba75f33 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java @@ -96,6 +96,42 @@ public void handleRs(Context ctx, boolean includeFail) { ); } + public void handleTb(Context ctx) { + if (ctx.args().length > 1) { + ctx.sendReply(PendingMessage.ofString(CommandUsage.TB)); + return; + } + + UserRef userRef; + if (ctx.args().length == 1) { + UserRefResolution resolution = resolver.resolveUserRefArgument(ctx.args()[0]); + if (resolution.errorMessage() != null) { + ctx.sendReply(PendingMessage.ofString(resolution.errorMessage())); + return; + } + if (resolution.userRef() == null) { + ctx.sendReply(PendingMessage.ofString(CommandUsage.TB)); + return; + } + userRef = resolution.userRef(); + } else { + Long uid = resolver.resolveBoundUid(ctx.senderUserId()); + if (uid == null) { + ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); + return; + } + userRef = new UserRef.ByUid(uid); + } + + UserRef target = userRef; + taskCoordinator.runImageRequest( + ctx, + "Today's Best Scores", + () -> APIHelper.getTodayBestResponse(target), + replyFactory::tbMessage + ); + } + private void handleFilteredSingleScore(Context ctx, String macroType) { ScoreFilterArguments.ParseResult filters = ScoreFilterArguments.parse(ctx.args(), 0); if (filters.isError()) { diff --git a/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java b/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java index 4bc5df68..8d989e33 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java @@ -5,6 +5,7 @@ public final class CommandUsage { public static final String NO_BIND = "你还没有绑定玩家ID,请先使用 /bind 绑定"; public static final String REBIND = "由于发生了一个技术问题,使用此功能需要重新绑定。请使用 `/unbind` 解除绑定,再使用 `/bind` 重新绑定~"; public static final String RS = "用法:/rs <个数> [玩家ID/用户名/@用户] [过滤条件 ...]"; + public static final String TB = "用法:/tb [玩家ID/用户名/@用户]"; public static final String SCORE_FILTERS = "过滤示例:acc>=98 combo>=1000 mod~HD mod!~DT time<2:00 pp>=300;多个条件同时生效。"; public static final String M = "用法:/m <谱面ID 或 快捷查询> [Mod]"; public static final String AP = "用法:/ap <谱面ID 或 快捷查询>"; diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index f9bc3a51..56ef5f5f 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -127,6 +127,15 @@ public PendingMessage rsMessage(Context ctx, Response response) { ); } + public PendingMessage tbMessage(Context ctx, Response response) { + return PendingMessage.ofMarkdownRaw( + at(ctx) + "今日BP查询完成\n" + + "> 玩家: " + cmd("/u " + response.getUserId(), response.getUserId()) + "\n" + + "> 数量: " + response.getScoreIds().size(), + buttons().boButtons(response.getUserId()) + ); + } + public PendingMessage beatmapMessage(Context ctx, Response response) { return PendingMessage.ofMarkdownRaw( at(ctx) + "谱面查询完成\n" + @@ -576,6 +585,7 @@ public static String helpContent(Context ctx) { > /rp - 获取最近通过的一个成绩 > /bo [个数] [玩家ID] - 获取一个或多个最佳成绩 > /rp [个数] [玩家ID] - 获取最近通过一个或多个成绩 + > /tb [玩家ID] - 获取今日新增的BP > /s <成绩ID或快捷查询> - 获取指定成绩 > /m <谱面ID或快捷查询> - 获取谱面 > /ms <谱面集ID或快捷查询> - 获取谱面集 diff --git a/src/main/java/xyz/zcraft/seira/command/route/Router.java b/src/main/java/xyz/zcraft/seira/command/route/Router.java index bb59e1cc..9d9b1aae 100644 --- a/src/main/java/xyz/zcraft/seira/command/route/Router.java +++ b/src/main/java/xyz/zcraft/seira/command/route/Router.java @@ -214,6 +214,7 @@ private static CommandRegistry createCommandRegistry( .register(socialCommands::handleMp, "mp") .register(ctx -> scoreCommands.handleRs(ctx, true), "rs") .register(ctx -> scoreCommands.handleRs(ctx, false), "rp") + .register(scoreCommands::handleTb, "tb") .register(beatmapCommands::handleM, "m") .register(beatmapCommands::handleAp, "ap") .register(beatmapCommands::handleBpv, "bpv") From 77a4f98380533f4924cde2181ac2f6b0325d6ba3 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 19 Aug 2026 19:05:07 +0800 Subject: [PATCH 13/22] =?UTF-8?q?=E6=B7=BB=E5=8A=A0/mpwatch=E7=9A=84stable?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MultiplayerRoomWatchCommandHandler.java | 81 ++++++++++++++----- .../seira/command/reply/ReplyFactory.java | 2 +- .../seira/watch/MultiplayerRoomVersion.java | 29 +++++++ .../seira/watch/MultiplayerRoomWatchApi.java | 4 +- .../watch/MultiplayerRoomWatchService.java | 40 +++++---- .../watch/OstellaMultiplayerRoomWatchApi.java | 17 +++- .../xyz/zcraft/seira/watch/RoomWatchView.java | 2 +- 7 files changed, 131 insertions(+), 44 deletions(-) create mode 100644 src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomVersion.java diff --git a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java index 4ec45fc4..dbd91edc 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java @@ -10,6 +10,7 @@ import xyz.zcraft.seira.command.ResolutionException; import xyz.zcraft.seira.command.TaskCoordinator; import xyz.zcraft.seira.watch.MultiplayerRoomWatchService; +import xyz.zcraft.seira.watch.MultiplayerRoomVersion; import xyz.zcraft.seira.watch.RoomWatchView; import java.util.Locale; @@ -19,10 +20,14 @@ public final class MultiplayerRoomWatchCommandHandler { private static final String USAGE = - "用法:/mpwatch start <房间ID/房间链接>;/mpwatch stop;/mpwatch status"; - private static final Pattern ROOM_URL = Pattern.compile( + "用法:/mpwatch [start] <房间ID> [stable|lazer];" + + "/mpwatch [start] <房间链接>;/mpwatch stop;/mpwatch status"; + private static final Pattern LAZER_ROOM_URL = Pattern.compile( "(?i)^https?://(?:www\\.)?osu\\.ppy\\.sh/multiplayer/rooms/(\\d+)(?:[/?#].*)?$" ); + private static final Pattern STABLE_ROOM_URL = Pattern.compile( + "(?i)^https?://(?:www\\.)?osu\\.ppy\\.sh/(?:community/matches|mp)/(\\d+)(?:[/?#].*)?$" + ); private final TaskCoordinator taskCoordinator; private final MultiplayerRoomWatchService watchService; @@ -42,35 +47,41 @@ public void handleMpWatch(Context ctx) { } if (ctx.argumentCount() == 0) { - handleStart(ctx, true); + handleStart(ctx, 0); return; } switch (ctx.argument(0).toLowerCase(Locale.ROOT)) { - case "start" -> handleStart(ctx, false); + case "start" -> handleStart(ctx, 1); case "stop" -> handleStop(ctx); case "status", "list" -> handleStatus(ctx); - default -> usage(ctx); + default -> handleStart(ctx, 0); } } - private void handleStart(Context ctx, boolean current) { - Long roomId; + private void handleStart(Context ctx, int argumentOffset) { + int startArgumentCount = ctx.argumentCount() - argumentOffset; + if (startArgumentCount < 0 || startArgumentCount > 2) { + usage(ctx); + return; + } - if (!current || ctx.argumentCount() != 2) { - roomId = parseRoomId(ctx.argument(1)); - } else { + RoomTarget target; + if (startArgumentCount == 0) { final OsuToken osuToken = UserDataStore.findOsuToken(ctx.senderUserId()); if (osuToken == null) { ctx.sendReply("由于未绑定账户,无法获取当前房间,请手动提供ID~"); return; } final Response multiplayerRoom = APIHelper.getMultiplayerRoom(osuToken.accessToken()); - roomId = multiplayerRoom.getContent().getId(); + target = new RoomTarget(multiplayerRoom.getContent().getId(), MultiplayerRoomVersion.LAZER); + } else { + String version = startArgumentCount == 2 ? ctx.argument(argumentOffset + 1) : null; + target = parseRoomTarget(ctx.argument(argumentOffset), version); } - if (roomId == null) { - ctx.sendReply(PendingMessage.ofString("房间 ID 或链接格式不正确。\n" + USAGE)); + if (target == null) { + ctx.sendReply(PendingMessage.ofString("房间 ID、链接或版本格式不正确。\n" + USAGE)); return; } @@ -82,9 +93,9 @@ private void handleStart(Context ctx, boolean current) { return; } try { - RoomWatchView view = watchService.watch(ctx.groupId(), roomId); + RoomWatchView view = watchService.watch(ctx.groupId(), target.version(), target.roomId()); ctx.sendReply(PendingMessage.ofString( - "已开始监视多人房间“" + view.roomName() + "” (#" + view.roomId() + ")。" + "已开始监视" + formatRoom(view) + "。" + "之后完成的每张图都会自动推送结果。" )); } catch (IllegalArgumentException | IllegalStateException e) { @@ -102,7 +113,7 @@ private void handleStop(Context ctx) { ctx.sendReply(PendingMessage.ofString( stopped == null ? "当前群聊没有多人房间监视。" - : "已停止监视多人房间“" + stopped.roomName() + "” (#" + stopped.roomId() + ")。" + : "已停止监视" + formatRoom(stopped) + "。" )); } @@ -115,31 +126,57 @@ private void handleStatus(Context ctx) { ctx.sendReply(PendingMessage.ofString( view == null ? "当前群聊没有多人房间监视。" - : "当前正在监视多人房间“" + view.roomName() + "” (#" + view.roomId() + ")。" + : "当前正在监视" + formatRoom(view) + "。" )); } - static Long parseRoomId(String value) { + static RoomTarget parseRoomTarget(String value, String explicitVersion) { if (value == null || value.isBlank()) { return null; } String normalized = value.trim(); String numeric = normalized; - Matcher matcher = ROOM_URL.matcher(normalized); - if (matcher.matches()) { - numeric = matcher.group(1); + MultiplayerRoomVersion inferredVersion = null; + Matcher lazerMatcher = LAZER_ROOM_URL.matcher(normalized); + Matcher stableMatcher = STABLE_ROOM_URL.matcher(normalized); + if (lazerMatcher.matches()) { + numeric = lazerMatcher.group(1); + inferredVersion = MultiplayerRoomVersion.LAZER; + } else if (stableMatcher.matches()) { + numeric = stableMatcher.group(1); + inferredVersion = MultiplayerRoomVersion.STABLE; } else if (!normalized.matches("\\d+")) { return null; } + + MultiplayerRoomVersion requestedVersion = explicitVersion == null + ? null + : MultiplayerRoomVersion.parse(explicitVersion); + if (explicitVersion != null && requestedVersion == null) { + return null; + } + if (inferredVersion != null && requestedVersion != null && inferredVersion != requestedVersion) { + return null; + } + MultiplayerRoomVersion version = inferredVersion != null + ? inferredVersion + : requestedVersion == null ? MultiplayerRoomVersion.LAZER : requestedVersion; try { long roomId = Long.parseLong(numeric); - return roomId > 0 ? roomId : null; + return roomId > 0 ? new RoomTarget(roomId, version) : null; } catch (NumberFormatException ignored) { return null; } } + private static String formatRoom(RoomWatchView view) { + return view.version().value() + " 多人房间“" + view.roomName() + "” (#" + view.roomId() + ")"; + } + private static void usage(Context ctx) { ctx.sendReply(PendingMessage.ofString(USAGE)); } + + record RoomTarget(long roomId, MultiplayerRoomVersion version) { + } } diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 56ef5f5f..8f00b57b 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -594,7 +594,7 @@ public static String helpContent(Context ctx) { > /lb <谱面ID> [玩家ID列表] - 获取指定谱面排行榜 > /watch add <玩家ID/用户名/@用户> [分钟] - 监视群友的新成绩 > /wx start <谱面ID列表> - 监视指定玩家在指定谱面的成绩 - > /mpwatch start <房间ID/链接> - 监视多人房间的逐图结果 + > /mpwatch [start] <房间ID> [stable|lazer] - 监视多人房间的逐图结果(链接可自动识别版本) > /f - 获取好友列表 详细指令列表请在 [这里](https://docs.seira.top/overview/commands.html) 查看 diff --git a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomVersion.java b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomVersion.java new file mode 100644 index 00000000..0bc8cc42 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomVersion.java @@ -0,0 +1,29 @@ +package xyz.zcraft.seira.watch; + +import java.util.Locale; + +public enum MultiplayerRoomVersion { + LAZER("lazer"), + STABLE("stable"); + + private final String value; + + MultiplayerRoomVersion(String value) { + this.value = value; + } + + public String value() { + return value; + } + + public static MultiplayerRoomVersion parse(String value) { + if (value == null) { + return null; + } + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "lazer" -> LAZER; + case "stable" -> STABLE; + default -> null; + }; + } +} diff --git a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchApi.java b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchApi.java index 028b379c..c724e1b2 100644 --- a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchApi.java +++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchApi.java @@ -1,7 +1,7 @@ package xyz.zcraft.seira.watch; public interface MultiplayerRoomWatchApi { - RoomWatchSnapshot getSnapshot(long roomId); + RoomWatchSnapshot getSnapshot(MultiplayerRoomVersion version, long roomId); - byte[] renderResult(long roomId, long playlistItemId); + byte[] renderResult(MultiplayerRoomVersion version, long roomId, long playlistItemId); } diff --git a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java index 85593596..fb8b3a57 100644 --- a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java +++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java @@ -60,19 +60,20 @@ public void start() { ); } - public RoomWatchView watch(String groupId, long roomId) { + public RoomWatchView watch(String groupId, MultiplayerRoomVersion version, long roomId) { Objects.requireNonNull(groupId); + Objects.requireNonNull(version); if (roomId <= 0) { throw new IllegalArgumentException("房间 ID 必须为正整数。"); } - RoomWatchSnapshot snapshot = api.getSnapshot(roomId); + RoomWatchSnapshot snapshot = api.getSnapshot(version, roomId); if (!snapshot.active()) { throw new IllegalStateException("该多人房间已经结束,无法开始监视。"); } Set baseline = new LinkedHashSet<>(); snapshot.completedPlays().forEach(play -> baseline.add(play.playlistItemId())); - WatchEntry entry = new WatchEntry(snapshot.roomId(), snapshot.roomName(), baseline); + WatchEntry entry = new WatchEntry(version, snapshot.roomId(), snapshot.roomName(), baseline); synchronized (lock) { watchesByGroup.put(groupId, entry); } @@ -94,8 +95,8 @@ public RoomWatchView get(String groupId) { } public void pollNow() { - Map> watchesByRoom = snapshotByRoom(); - for (Map.Entry> room : watchesByRoom.entrySet()) { + Map> watchesByRoom = snapshotByRoom(); + for (Map.Entry> room : watchesByRoom.entrySet()) { try { processRoom(room.getKey(), room.getValue()); } catch (RuntimeException e) { @@ -104,8 +105,8 @@ public void pollNow() { } } - private void processRoom(long roomId, List watches) { - RoomWatchSnapshot snapshot = api.getSnapshot(roomId); + private void processRoom(RoomKey room, List watches) { + RoomWatchSnapshot snapshot = api.getSnapshot(room.version(), room.roomId()); List completed = snapshot.completedPlays().stream() .sorted(Comparator .comparing(CompletedRoomPlay::playedAt, Comparator.nullsLast(String::compareTo)) @@ -122,7 +123,8 @@ private void processRoom(long roomId, List watches) { if (notifier.sendRoomEnded(watch.groupId(), snapshot)) { removeIfCurrent(watch); } else { - LOG.warn("Failed to send room-ended notice for room {} to group {}", roomId, watch.groupId()); + LOG.warn("Failed to send {} room-ended notice for room {} to group {}", + room.version().value(), room.roomId(), watch.groupId()); } } } @@ -140,7 +142,7 @@ private boolean sendPendingResults( try { byte[] image = rendered.computeIfAbsent( play.playlistItemId(), - itemId -> api.renderResult(watch.entry().roomId, itemId) + itemId -> api.renderResult(watch.entry().version, watch.entry().roomId, itemId) ); if (!notifier.sendResult(watch.groupId(), image)) { LOG.warn( @@ -161,11 +163,11 @@ private boolean sendPendingResults( return true; } - private Map> snapshotByRoom() { + private Map> snapshotByRoom() { synchronized (lock) { - Map> result = new LinkedHashMap<>(); + Map> result = new LinkedHashMap<>(); watchesByGroup.forEach((groupId, entry) -> result - .computeIfAbsent(entry.roomId, ignored -> new ArrayList<>()) + .computeIfAbsent(new RoomKey(entry.version, entry.roomId), ignored -> new ArrayList<>()) .add(new WatchRef(groupId, entry))); return result; } @@ -216,7 +218,7 @@ private static Duration requirePositive(Duration duration) { } private static RoomWatchView view(WatchEntry entry) { - return new RoomWatchView(entry.roomId, entry.roomName); + return new RoomWatchView(entry.version, entry.roomId, entry.roomName); } @Override @@ -227,11 +229,18 @@ public void close() { } private static final class WatchEntry { + private final MultiplayerRoomVersion version; private final long roomId; private final String roomName; private final Set sentPlaylistItemIds; - private WatchEntry(long roomId, String roomName, Set sentPlaylistItemIds) { + private WatchEntry( + MultiplayerRoomVersion version, + long roomId, + String roomName, + Set sentPlaylistItemIds + ) { + this.version = version; this.roomId = roomId; this.roomName = roomName; this.sentPlaylistItemIds = new LinkedHashSet<>(sentPlaylistItemIds); @@ -240,4 +249,7 @@ private WatchEntry(long roomId, String roomName, Set sentPlaylistItemIds) private record WatchRef(String groupId, WatchEntry entry) { } + + private record RoomKey(MultiplayerRoomVersion version, long roomId) { + } } diff --git a/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java b/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java index 107270e5..43133f68 100644 --- a/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java +++ b/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java @@ -28,9 +28,10 @@ public OstellaMultiplayerRoomWatchApi(String endpoint) { } @Override - public RoomWatchSnapshot getSnapshot(long roomId) { + public RoomWatchSnapshot getSnapshot(MultiplayerRoomVersion version, long roomId) { HttpResponse response = get( - "/multiplayer/rooms/" + requirePositive(roomId, "roomId") + "/watch", + "/multiplayer/rooms/" + requirePositive(roomId, "roomId") + "/watch?version=" + + requireVersion(version).value(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8) ); if (response.statusCode() == 404) { @@ -47,10 +48,11 @@ public RoomWatchSnapshot getSnapshot(long roomId) { } @Override - public byte[] renderResult(long roomId, long playlistItemId) { + public byte[] renderResult(MultiplayerRoomVersion version, long roomId, long playlistItemId) { HttpResponse response = get( "/multiplayer/rooms/" + requirePositive(roomId, "roomId") - + "/playlist/" + requirePositive(playlistItemId, "playlistItemId") + "/result", + + "/playlist/" + requirePositive(playlistItemId, "playlistItemId") + "/result?version=" + + requireVersion(version).value(), HttpResponse.BodyHandlers.ofByteArray() ); ensureSuccessfulStatus(response.statusCode(), response.body(), "生成多人房间结果图片"); @@ -110,4 +112,11 @@ private static long requirePositive(long value, String name) { } return value; } + + private static MultiplayerRoomVersion requireVersion(MultiplayerRoomVersion version) { + if (version == null) { + throw new IllegalArgumentException("version is required"); + } + return version; + } } diff --git a/src/main/java/xyz/zcraft/seira/watch/RoomWatchView.java b/src/main/java/xyz/zcraft/seira/watch/RoomWatchView.java index d6aacc32..bf906517 100644 --- a/src/main/java/xyz/zcraft/seira/watch/RoomWatchView.java +++ b/src/main/java/xyz/zcraft/seira/watch/RoomWatchView.java @@ -1,4 +1,4 @@ package xyz.zcraft.seira.watch; -public record RoomWatchView(long roomId, String roomName) { +public record RoomWatchView(MultiplayerRoomVersion version, long roomId, String roomName) { } From 4b1ee9e39c44cdb5d8be4a2d8ad136587c0d9bb9 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 19 Aug 2026 22:45:11 +0800 Subject: [PATCH 14/22] =?UTF-8?q?=E6=B7=BB=E5=8A=A0/bma,=20=E6=89=A9?= =?UTF-8?q?=E5=B1=95/tb=E7=94=A8=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- panel.json | 8 +--- .../java/xyz/zcraft/seira/api/APIHelper.java | 17 +++++++- .../handler/BeatmapCommandHandler.java | 43 +++++++++++++++++++ .../command/handler/ScoreCommandHandler.java | 30 ++++++++++--- .../seira/command/reply/CommandUsage.java | 3 +- .../seira/command/reply/ReplyFactory.java | 6 ++- .../zcraft/seira/command/route/Router.java | 1 + 8 files changed, 94 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ac9d88aa..8cc801f0 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 | `/bo` | `/bo [n] [uid/username/@user] [filters...]` | 获取最多200条最好成绩后过滤;省略n时返回第一个匹配成绩详情 | | `/rs` | `/rs [n] [uid/username/@user] [filters...]` | 获取最多200条最近成绩后过滤;省略n时返回第一个匹配成绩详情 | | `/rp` | `/rp [n] [uid/username/@user] [filters...]` | 获取最多200条最近通过成绩后过滤;省略n时返回第一个匹配成绩详情 | -| `/tb` | `/tb [uid/username/@user]` | 获取最近200条通过成绩与全部200条BP的交集,并保留全BP排名编号 | +| `/tb` | `/tb [#days] [uid/username/@user]` | 获取近N天达成的BP(默认1天),并保留完整BP排名编号 | | `/m` | `/m [Mod]` | 获取指定谱面信息 | | `/ap` | `/ap ` | 获取指定谱面音频预览 | | `/bgp` | `/bgp ` | 获取指定谱面背景预览 | diff --git a/panel.json b/panel.json index 8e4e12fa..ea07e8e7 100644 --- a/panel.json +++ b/panel.json @@ -1,15 +1,11 @@ { + "#note": "items should be no bigger than 20 elements", "items": [ { "type": "command", "name": "/rp", "desc": "查询最近通过成绩" }, - { - "type": "command", - "name": "/rs", - "desc": "查询通过成绩" - }, { "type": "command", "name": "/bind", @@ -38,7 +34,7 @@ { "type": "command", "name": "/tb", - "desc": "查询今日新增BP" + "desc": "查询近期达成BP" }, { "type": "command", diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index 6bf2a676..8b5d1b3a 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -112,10 +112,14 @@ public static Response getBoNResponse(int n, UserRef userRef, List< } public static Response getTodayBestResponse(UserRef userRef) { + return getTodayBestResponse(userRef, 1); + } + + public static Response getTodayBestResponse(UserRef userRef, int days) { long uid = resolveUid(userRef); return getBase64BytesResponse( - "/users/" + uid + "/scores/today-best", - "获取今日BP失败", + "/users/" + uid + "/scores/today-best?days=" + days, + "获取近期BP失败", null ); } @@ -228,6 +232,15 @@ public static Response getBeatmapResponse(ShortcutTarget target, St return getBase64BytesResponse("/beatmaps/" + beatmapId + (mod != null ? "?mod=" + mod : ""), "获取谱面失败", null); } + public static Response getBeatmapAnalysisResponse(ShortcutTarget target, String mod, String auth) { + final long beatmapId = lookupBeatmap(target, auth); + String query = "/beatmaps/" + beatmapId + "/analysis"; + if (mod != null && !mod.isBlank()) { + query += "?mod=" + URLEncoder.encode(mod, StandardCharsets.UTF_8); + } + return getBase64BytesResponse(query, "获取谱面分析失败", null); + } + public static Response getBeatmapsetBgResponse(ShortcutTarget target, String auth) { final long beatmapsetId = lookupBeatmapset(target, auth); return getBase64BytesResponse("/beatmapsets/" + beatmapsetId + "/background", "获取谱面集失败", null); diff --git a/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java index 5e4895d4..24cf7b32 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java @@ -88,6 +88,49 @@ public void handleM(Context ctx) { } } + public void handleBma(Context ctx) { + if (ctx.args().length >= 1) { + TargetResolution targetResolution = resolver.resolveTargetWithOptionalMention( + ctx.args(), ctx.senderUserId()); + ShortcutTarget target = targetResolution.target(); + if (target.isError()) { + ctx.sendReply(PendingMessage.ofString(target.errorMessage())); + return; + } + + lastTarget.put(ctx.senderUserId(), target); + if (ctx.args().length > targetResolution.consumedArgs() + 1) { + ctx.sendReply(PendingMessage.ofString(CommandUsage.BMA)); + return; + } + + String mod = ctx.args().length == targetResolution.consumedArgs() + 1 + ? ctx.args()[targetResolution.consumedArgs()] + : null; + taskCoordinator.runImageRequest( + ctx, + "Beatmap Analysis", + () -> APIHelper.getBeatmapAnalysisResponse( + target, mod, accessTokenProvider.apply(ctx.senderUserId())), + replyFactory::beatmapMessage + ); + return; + } + + ShortcutTarget target = lastTarget.get(ctx.senderUserId()); + if (target == null) { + ctx.sendReply(PendingMessage.ofString(CommandUsage.BMA)); + return; + } + taskCoordinator.runImageRequest( + ctx, + "Beatmap Analysis", + () -> APIHelper.getBeatmapAnalysisResponse( + target, null, accessTokenProvider.apply(ctx.senderUserId())), + replyFactory::beatmapMessage + ); + } + public void handleAp(Context ctx) { ShortcutTarget target; diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java index 9ba75f33..70084fa5 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java @@ -97,14 +97,15 @@ public void handleRs(Context ctx, boolean includeFail) { } public void handleTb(Context ctx) { - if (ctx.args().length > 1) { + TbArguments request = parseTbArguments(ctx.args()); + if (request == null) { ctx.sendReply(PendingMessage.ofString(CommandUsage.TB)); return; } UserRef userRef; - if (ctx.args().length == 1) { - UserRefResolution resolution = resolver.resolveUserRefArgument(ctx.args()[0]); + if (request.target() != null) { + UserRefResolution resolution = resolver.resolveUserRefArgument(request.target()); if (resolution.errorMessage() != null) { ctx.sendReply(PendingMessage.ofString(resolution.errorMessage())); return; @@ -126,12 +127,31 @@ public void handleTb(Context ctx) { UserRef target = userRef; taskCoordinator.runImageRequest( ctx, - "Today's Best Scores", - () -> APIHelper.getTodayBestResponse(target), + "Recent Best Scores", + () -> APIHelper.getTodayBestResponse(target, request.days()), replyFactory::tbMessage ); } + static TbArguments parseTbArguments(String[] args) { + int days = 1; + int targetIndex = 0; + if (args.length > 0 && args[0].startsWith("#")) { + try { + days = Integer.parseInt(args[0].substring(1)); + } catch (NumberFormatException e) { + return null; + } + if (days <= 0) return null; + targetIndex = 1; + } + if (args.length - targetIndex > 1) return null; + return new TbArguments(days, args.length > targetIndex ? args[targetIndex] : null); + } + + record TbArguments(int days, String target) { + } + private void handleFilteredSingleScore(Context ctx, String macroType) { ScoreFilterArguments.ParseResult filters = ScoreFilterArguments.parse(ctx.args(), 0); if (filters.isError()) { diff --git a/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java b/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java index 8d989e33..a80feb60 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java @@ -5,9 +5,10 @@ public final class CommandUsage { public static final String NO_BIND = "你还没有绑定玩家ID,请先使用 /bind 绑定"; public static final String REBIND = "由于发生了一个技术问题,使用此功能需要重新绑定。请使用 `/unbind` 解除绑定,再使用 `/bind` 重新绑定~"; public static final String RS = "用法:/rs <个数> [玩家ID/用户名/@用户] [过滤条件 ...]"; - public static final String TB = "用法:/tb [玩家ID/用户名/@用户]"; + public static final String TB = "用法:/tb [#天数] [玩家ID/用户名/@用户];天数必须为正整数"; public static final String SCORE_FILTERS = "过滤示例:acc>=98 combo>=1000 mod~HD mod!~DT time<2:00 pp>=300;多个条件同时生效。"; public static final String M = "用法:/m <谱面ID 或 快捷查询> [Mod]"; + public static final String BMA = "用法:/bma <谱面ID 或 快捷查询> [Mod]"; public static final String AP = "用法:/ap <谱面ID 或 快捷查询>"; public static final String BPV = "用法:/bpv <谱面ID 或 快捷查询> [Mod]"; public static final String BGP = "用法:/bgp <谱面ID 或 快捷查询>"; diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 8f00b57b..13e5918f 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -585,9 +585,10 @@ public static String helpContent(Context ctx) { > /rp - 获取最近通过的一个成绩 > /bo [个数] [玩家ID] - 获取一个或多个最佳成绩 > /rp [个数] [玩家ID] - 获取最近通过一个或多个成绩 - > /tb [玩家ID] - 获取今日新增的BP + > /tb [#天数] [玩家ID] - 获取近N天达成的BP > /s <成绩ID或快捷查询> - 获取指定成绩 > /m <谱面ID或快捷查询> - 获取谱面 + > /bma <谱面ID或快捷查询> [Mod] - 分析谱面PP构成和类型 > /ms <谱面集ID或快捷查询> - 获取谱面集 > /r [成绩ID或快捷查询] [[mm:ss]-[mm:ss]] - 生成成绩高光视频或指定片段 > /rg - 在群聊中进行猜 Rank 游戏 @@ -792,6 +793,9 @@ List> beatmapButtons(String beatmapId) { Button.row( Button.command(3, "预览音频", "/ap m" + beatmapId), Button.command(4, "查询自己的分数", "/s m" + beatmapId) + ), + Button.row( + Button.command(5, "分析谱面", "/bma " + beatmapId) ) ); } diff --git a/src/main/java/xyz/zcraft/seira/command/route/Router.java b/src/main/java/xyz/zcraft/seira/command/route/Router.java index 9d9b1aae..866b2663 100644 --- a/src/main/java/xyz/zcraft/seira/command/route/Router.java +++ b/src/main/java/xyz/zcraft/seira/command/route/Router.java @@ -216,6 +216,7 @@ private static CommandRegistry createCommandRegistry( .register(ctx -> scoreCommands.handleRs(ctx, false), "rp") .register(scoreCommands::handleTb, "tb") .register(beatmapCommands::handleM, "m") + .register(beatmapCommands::handleBma, "bma") .register(beatmapCommands::handleAp, "ap") .register(beatmapCommands::handleBpv, "bpv") .register(beatmapCommands::handleBgp, "bgp") From 9851c877d1c5f1e23cb11013ede55e7122faa84e Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 20 Aug 2026 00:20:48 +0800 Subject: [PATCH 15/22] =?UTF-8?q?=E6=9B=B4=E6=96=B0/u?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xyz/zcraft/seira/api/APIHelper.java | 9 +++++ .../handler/GeneralCommandHandler.java | 33 ++++++++++++------- .../seira/command/reply/ReplyFactory.java | 23 ++++++++++++- .../zcraft/seira/command/route/Router.java | 2 +- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index 8b5d1b3a..672c82d3 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -111,6 +111,15 @@ public static Response getBoNResponse(int n, UserRef userRef, List< ); } + public static Response getUserInfoResponse(UserRef userRef) { + long uid = resolveUid(userRef); + return getBase64BytesResponse( + "/users/" + uid, + "获取玩家资料失败", + null + ); + } + public static Response getTodayBestResponse(UserRef userRef) { return getTodayBestResponse(userRef, 1); } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/GeneralCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/GeneralCommandHandler.java index 0989c980..7437d359 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/GeneralCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/GeneralCommandHandler.java @@ -5,6 +5,8 @@ import xyz.zcraft.seira.bot.MessageSender; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.*; +import xyz.zcraft.seira.command.parse.Resolver; +import xyz.zcraft.seira.command.parse.UserRefResolution; import xyz.zcraft.seira.command.reply.ReplyFactory; import xyz.zcraft.seira.data.UploadedImage; import xyz.zcraft.seira.services.DailyLuck; @@ -15,24 +17,21 @@ public final class GeneralCommandHandler { private final MessageSender messageSender; private final TaskCoordinator taskCoordinator; private final ReplyFactory replyFactory; - private final ScoreCommandHandler scoreCommands; + private final Resolver resolver; private final Predicate adminAuthorizer; - private final Runnable commandMetric; public GeneralCommandHandler( MessageSender messageSender, TaskCoordinator taskCoordinator, ReplyFactory replyFactory, - ScoreCommandHandler scoreCommands, - Predicate adminAuthorizer, - Runnable commandMetric + Resolver resolver, + Predicate adminAuthorizer ) { this.messageSender = messageSender; this.taskCoordinator = taskCoordinator; this.replyFactory = replyFactory; - this.scoreCommands = scoreCommands; + this.resolver = resolver; this.adminAuthorizer = adminAuthorizer; - this.commandMetric = commandMetric; } public void handleU(Context context) { @@ -41,12 +40,22 @@ public void handleU(Context context) { return; } - String target = context.argument(0); - Context bestScoreContext = context.asCommand("bo", new String[]{"8", target}, "8 " + target); + UserRefResolution target = resolver.resolveUserRefArgument(context.argument(0)); + if (target.errorMessage() != null) { + context.sendReply(PendingMessage.ofString(target.errorMessage())); + return; + } + if (target.userRef() == null) { + context.sendReply(PendingMessage.ofString("用法:/u <玩家ID/用户名/@用户>")); + return; + } - // /u historically routed through /bo and therefore counted both commands. - commandMetric.run(); - scoreCommands.handleBo(bestScoreContext); + taskCoordinator.runImageRequest( + context, + "User Info", + () -> APIHelper.getUserInfoResponse(target.userRef()), + replyFactory::userInfoMessage + ); } public void handleLuck(Context context) { diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 13e5918f..75fdbdf1 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -127,6 +127,14 @@ public PendingMessage rsMessage(Context ctx, Response response) { ); } + public PendingMessage userInfoMessage(Context ctx, Response response) { + return PendingMessage.ofMarkdownRaw( + at(ctx) + "玩家资料查询完成\n" + + "> 玩家: " + cmd("/u " + response.getUserId(), response.getUserId()), + buttons().userInfoButtons(response.getUserId()) + ); + } + public PendingMessage tbMessage(Context ctx, Response response) { return PendingMessage.ofMarkdownRaw( at(ctx) + "今日BP查询完成\n" + @@ -715,6 +723,18 @@ List> boButtons(String userId) { ); } + List> userInfoButtons(String userId) { + return Button.keyboard( + Button.row( + Button.command(1, "查询最好成绩", "/bo 5 " + userId), + Button.command(2, "查询最近成绩", "/rs 5 " + userId) + ), + Button.row( + Button.openUrl(3, "在游戏中查看", directUrl + "/u/" + userId) + ) + ); + } + List> rsButtons() { return Button.keyboard(Button.row( Button.command(1, "查询最好成绩", "/s bo1"), @@ -795,7 +815,8 @@ List> beatmapButtons(String beatmapId) { Button.command(4, "查询自己的分数", "/s m" + beatmapId) ), Button.row( - Button.command(5, "分析谱面", "/bma " + beatmapId) + Button.command(5, "预览谱面", "/bpv " + beatmapId), + Button.command(6, "分析谱面", "/bma " + beatmapId) ) ); } diff --git a/src/main/java/xyz/zcraft/seira/command/route/Router.java b/src/main/java/xyz/zcraft/seira/command/route/Router.java index 866b2663..cb141dd4 100644 --- a/src/main/java/xyz/zcraft/seira/command/route/Router.java +++ b/src/main/java/xyz/zcraft/seira/command/route/Router.java @@ -84,7 +84,7 @@ public Router( this::getAccessTokenFor ); GeneralCommandHandler generalCommands = new GeneralCommandHandler( - messageSender, taskCoordinator, replyFactory, scoreCommands, admins::isAdmin, commandMetric + messageSender, taskCoordinator, replyFactory, resolver, admins::isAdmin ); WatchCommandHandler watchCommands = new WatchCommandHandler(resolver, taskCoordinator, watchService, admins::isAdmin); SpecificScoreWatchCommandHandler specificScoreWatchCommands = From 4c90b0eed92748c2b149854d26cae981eba714c4 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 20 Aug 2026 02:05:05 +0800 Subject: [PATCH 16/22] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/xyz/zcraft/seira/bot/QQBot.java | 18 +++++- .../RealtimeServiceInterruptionNotifier.java | 62 +++++++++++++++++++ .../seira/rankguess/RankGuessGameService.java | 4 ++ .../watch/MultiplayerRoomWatchService.java | 6 ++ .../zcraft/seira/watch/ScoreWatchService.java | 8 +++ 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java diff --git a/src/main/java/xyz/zcraft/seira/bot/QQBot.java b/src/main/java/xyz/zcraft/seira/bot/QQBot.java index 7a3298f1..348e256f 100644 --- a/src/main/java/xyz/zcraft/seira/bot/QQBot.java +++ b/src/main/java/xyz/zcraft/seira/bot/QQBot.java @@ -54,6 +54,8 @@ public class QQBot implements AutoCloseable, ConsoleRuntimeControl { private final MessageSender sender; private final ScoreWatchService watchService; private final MultiplayerRoomWatchService multiplayerRoomWatchService; + private final RankGuessGameService rankGuessGameService; + private final RealtimeServiceInterruptionNotifier interruptionNotifier; private final DiscordBridgeService discordBridgeService; private final AppConfig startupConfig; private final Router router; @@ -84,6 +86,7 @@ public QQBot( this.cos = new CosService(config.cos()); this.sender = new MessageSender(tokenManager, cos); + this.interruptionNotifier = new RealtimeServiceInterruptionNotifier(sender); LOG.info("Initializing Discord bridge service"); this.discordBridgeService = new DiscordBridgeService(config.discord(), config.bridge(), sender); @@ -105,7 +108,7 @@ public QQBot( ); LOG.info("Initializing rank guess service"); - RankGuessGameService rankGuessGameService = new RankGuessGameService(); + this.rankGuessGameService = new RankGuessGameService(); this.attachmentHandler = new AttachmentHandler(executors.attachmentDownloads()); this.router = new Router( sender, @@ -295,6 +298,19 @@ public CacheControlResult controlCache(String operation, String type, long id) { @Override public void requestStop() { + RealtimeServiceInterruptionNotifier.NotificationResult result = interruptionNotifier.notifyGroups( + watchService.activeTransientGroupIds(), + rankGuessGameService.activeGroupIds(), + multiplayerRoomWatchService.activeGroupIds() + ); + if (result.failedGroups() == 0) { + LOG.info("Sent restart interruption notices to {} affected groups", result.sentGroups()); + } else { + LOG.warn( + "Sent restart interruption notices to {}/{} affected groups", + result.sentGroups(), result.targetGroups() + ); + } stop(); } diff --git a/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java b/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java new file mode 100644 index 00000000..50a53400 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java @@ -0,0 +1,62 @@ +package xyz.zcraft.seira.bot; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class RealtimeServiceInterruptionNotifier { + private static final String SCORE_WATCH = "成绩监视"; + private static final String RANK_GUESS = "猜 Rank"; + private static final String MULTIPLAYER_WATCH = "MP 监视"; + + private final MessageSender sender; + + public RealtimeServiceInterruptionNotifier(MessageSender sender) { + this.sender = Objects.requireNonNull(sender); + } + + public NotificationResult notifyGroups( + Set scoreWatchGroups, + Set rankGuessGroups, + Set multiplayerWatchGroups + ) { + Map> servicesByGroup = new LinkedHashMap<>(); + addService(servicesByGroup, scoreWatchGroups, SCORE_WATCH); + addService(servicesByGroup, rankGuessGroups, RANK_GUESS); + addService(servicesByGroup, multiplayerWatchGroups, MULTIPLAYER_WATCH); + + int sent = 0; + for (Map.Entry> entry : servicesByGroup.entrySet()) { + if (sender.sendGroupText(entry.getKey(), message(entry.getValue()))) { + sent++; + } + } + return new NotificationResult(servicesByGroup.size(), sent); + } + + private static void addService( + Map> servicesByGroup, + Set groupIds, + String service + ) { + Objects.requireNonNull(groupIds); + groupIds.stream().sorted().forEach(groupId -> servicesByGroup + .computeIfAbsent(groupId, ignored -> new LinkedHashSet<>()) + .add(service)); + } + + private static String message(Set services) { + return "服务器即将重启,本群正在运行的实时服务(" + + String.join("、", new ArrayList<>(services)) + + ")将会中断。服务器恢复后,请重新启动相关服务。"; + } + + public record NotificationResult(int targetGroups, int sentGroups) { + public int failedGroups() { + return targetGroups - sentGroups; + } + } +} diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index d8d53474..93396a98 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -62,6 +62,10 @@ public synchronized void cancel(Reservation reservation) { } } + public synchronized Set activeGroupIds() { + return Set.copyOf(games.keySet()); + } + public synchronized GuessResponse guess(String groupId, String senderUserId, long guess) { if (guess <= 0) { throw new IllegalArgumentException("Rank must be positive"); diff --git a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java index fb8b3a57..928d9d8e 100644 --- a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java +++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java @@ -94,6 +94,12 @@ public RoomWatchView get(String groupId) { } } + public Set activeGroupIds() { + synchronized (lock) { + return Set.copyOf(watchesByGroup.keySet()); + } + } + public void pollNow() { Map> watchesByRoom = snapshotByRoom(); for (Map.Entry> room : watchesByRoom.entrySet()) { diff --git a/src/main/java/xyz/zcraft/seira/watch/ScoreWatchService.java b/src/main/java/xyz/zcraft/seira/watch/ScoreWatchService.java index fd93d844..ae6ffaa2 100644 --- a/src/main/java/xyz/zcraft/seira/watch/ScoreWatchService.java +++ b/src/main/java/xyz/zcraft/seira/watch/ScoreWatchService.java @@ -176,6 +176,14 @@ public Map> listAll() { } } + /** Returns groups whose temporary score watches will be lost when this process stops. */ + public Set activeTransientGroupIds() { + synchronized (lock) { + removeExpiredLocked(clock.instant()); + return Set.copyOf(watchesByGroup.keySet()); + } + } + public SpecificScoreWatchState startSpecific( String groupId, Set userIds, From 501ba6709b71423a60db27f579d571de93af25f2 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 20 Aug 2026 10:49:07 +0800 Subject: [PATCH 17/22] =?UTF-8?q?=E6=B7=BB=E5=8A=A0/u=E6=97=A0=E5=8F=82?= =?UTF-8?q?=E7=94=A8=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../handler/GeneralCommandHandler.java | 29 +++++++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 8cc801f0..790ce5dc 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 | `/s` | `/s ` | 获取指定的在线或本地成绩图 | | `/sa` | `/sa ` | 获取指定成绩分析图 | | `/ma` | `/ma [id/locId/rsN/boN] [n/#n]` | 获取指定或最近目标成绩的Miss分析;省略目标时用`#n`指定Miss | -| `/u` | `/u ` | 获取指定用户信息 | +| `/u` | `/u [uid/username/@user]` | 获取指定用户信息 | | `/r` | `/r [id/locId/rsN/boN] [[mm:ss]-[mm:ss]]` | 生成并发送指定或最近目标的回放视频。省略范围时自动识别高光,使用`-`渲染整个回放 | | `/rg` | `/rg ` | 在群聊中开始、参与或结束猜 Rank 游戏 | | `/rsc` | `/rsc [id/locId/rsN/boN] [+,...]` | 生成并发送指定或最近目标的成绩同屏回放视频;追加用户和范围顺序不限 | diff --git a/src/main/java/xyz/zcraft/seira/command/handler/GeneralCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/GeneralCommandHandler.java index 7437d359..59cd63e3 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/GeneralCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/GeneralCommandHandler.java @@ -4,11 +4,13 @@ import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.bot.MessageSender; import xyz.zcraft.seira.bot.data.PendingMessage; -import xyz.zcraft.seira.command.*; +import xyz.zcraft.seira.command.Context; +import xyz.zcraft.seira.command.TaskCoordinator; import xyz.zcraft.seira.command.parse.Resolver; import xyz.zcraft.seira.command.parse.UserRefResolution; import xyz.zcraft.seira.command.reply.ReplyFactory; import xyz.zcraft.seira.data.UploadedImage; +import xyz.zcraft.seira.data.UserRef; import xyz.zcraft.seira.services.DailyLuck; import java.util.function.Predicate; @@ -35,25 +37,28 @@ public GeneralCommandHandler( } public void handleU(Context context) { - if (context.argumentCount() != 1) { - context.sendReply(PendingMessage.ofString("用法:/u <玩家ID/用户名/@用户>")); - return; + UserRef userRef; + if (context.argumentCount() == 0) { + Long boundUid = resolver.resolveBoundUid(context.senderUserId()); + userRef = boundUid == null ? null : new UserRef.ByUid(boundUid); + } else { + UserRefResolution target = resolver.resolveUserRefArgument(context.argument(0)); + if (target.errorMessage() != null) { + context.sendReply(PendingMessage.ofString(target.errorMessage())); + return; + } + userRef = target.userRef(); } - UserRefResolution target = resolver.resolveUserRefArgument(context.argument(0)); - if (target.errorMessage() != null) { - context.sendReply(PendingMessage.ofString(target.errorMessage())); - return; - } - if (target.userRef() == null) { - context.sendReply(PendingMessage.ofString("用法:/u <玩家ID/用户名/@用户>")); + if (userRef == null) { + context.sendReply(PendingMessage.ofString("用法:/u [玩家ID/用户名/@用户]")); return; } taskCoordinator.runImageRequest( context, "User Info", - () -> APIHelper.getUserInfoResponse(target.userRef()), + () -> APIHelper.getUserInfoResponse(userRef), replyFactory::userInfoMessage ); } From c7ca8b5063cee2846a151d5355b18812f23d4f42 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 20 Aug 2026 17:50:36 +0800 Subject: [PATCH 18/22] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E7=9A=84=E8=BF=87=E6=BB=A4=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seira/command/parse/CommandParser.java | 33 ++++++++- .../command/parse/ScoreFilterArguments.java | 71 ++++++++++++++----- .../seira/command/reply/CommandUsage.java | 2 +- 3 files changed, 88 insertions(+), 18 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/parse/CommandParser.java b/src/main/java/xyz/zcraft/seira/command/parse/CommandParser.java index bdc82718..c29bd778 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/CommandParser.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/CommandParser.java @@ -2,7 +2,9 @@ import xyz.zcraft.seira.command.Context; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.function.UnaryOperator; @@ -36,7 +38,7 @@ public ParseResult parse(String rawContent, String senderUserId, String groupId, return ParseResult.emptyCommand(); } - String[] parts = body.split("\\s+"); + String[] parts = splitArguments(body); String command = parts[0].toLowerCase(Locale.ROOT); String query = body.substring(parts[0].length()).trim(); String[] args = Arrays.copyOfRange(parts, 1, parts.length); @@ -44,6 +46,35 @@ public ParseResult parse(String rawContent, String senderUserId, String groupId, return ParseResult.parsed(new Context(senderUserId, groupId, messageId, command, args, query)); } + /** Splits arguments on unquoted whitespace and removes double-quote delimiters. */ + private static String[] splitArguments(String body) { + List parts = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean quoted = false; + + for (int i = 0; i < body.length(); i++) { + char character = body.charAt(i); + if (character == '\\' && i + 1 < body.length() && body.charAt(i + 1) == '"') { + current.append('"'); + i++; + } else if (character == '"') { + quoted = !quoted; + } else if (Character.isWhitespace(character) && !quoted) { + if (!current.isEmpty()) { + parts.add(current.toString()); + current.setLength(0); + } + } else { + current.append(character); + } + } + + if (!current.isEmpty()) { + parts.add(current.toString()); + } + return parts.toArray(String[]::new); + } + public record ParseResult(Status status, Context context) { public enum Status { IGNORED, diff --git a/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java b/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java index ed7385c0..56869cd3 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java @@ -10,9 +10,12 @@ /** Parses score-list filters and converts aliases to oStella's compact filter syntax. */ public final class ScoreFilterArguments { private static final Pattern FILTER_PATTERN = Pattern.compile( - "(?i)^(acc(?:uracy)?|combo|pp|time|length|len|star|stars|sr|bpm|miss|misses|score|mod|mods|rank)" - + "(大于等于|小于等于|不包含|不等于|包含|等于|大于|小于|>=|<=|!=|!~|>|<|=|~)(.+)$" + "(?i)^(acc(?:uracy)?|combo|pp|time|length|len|star|stars|sr|bpm|miss|misses|score|mod|mods|rank" + + "|title|artist|mapper|genre|language|video|storyboard|fullcombo)" + + "(>=|<=|!=|!~|>|<|=|~)(.+)$" ); + private static final Pattern MISS_SHORTHAND_PATTERN = Pattern.compile("(?i)^(!?)(\\d+)miss(?:es)?$"); + private static final Pattern NEGATED_RANK_SHORTHAND_PATTERN = Pattern.compile("(?i)^!(XH|X|SH|S|A|B|C|D|F)$"); private static final Pattern DURATION_PATTERN = Pattern.compile("(?i)^(?:(\\d+)m)?(?:(\\d+(?:\\.\\d+)?)s)?$"); private static final Set RANKS = Set.of("XH", "X", "SH", "S", "A", "B", "C", "D", "F"); @@ -21,7 +24,9 @@ private ScoreFilterArguments() { public static boolean looksLikeFilter(String value) { if (value == null) return false; - return value.matches(".*(?:>=|<=|!=|!~|>|<|=|~|大于等于|小于等于|不包含|不等于|包含|等于|大于|小于).*"); + String trimmed = value.trim(); + return !expandShorthand(trimmed).equals(trimmed) + || trimmed.matches(".*(?:>=|<=|!=|!~|>|<|=|~).*"); } public static ParseResult parse(String[] args, int startIndex) { @@ -41,13 +46,14 @@ public static ParseResult parse(String[] args, int startIndex) { } private static String parseOne(String token) { - Matcher matcher = FILTER_PATTERN.matcher(token.trim()); + String expanded = expandShorthand(token.trim()); + Matcher matcher = FILTER_PATTERN.matcher(expanded); if (!matcher.matches()) { throw new IllegalArgumentException("无法识别 `" + token + "`"); } String field = normalizeField(matcher.group(1)); - String operator = normalizeOperator(matcher.group(2)); + String operator = matcher.group(2); String value = matcher.group(3).trim(); if (field.equals("mod")) { @@ -63,6 +69,21 @@ private static String parseOne(String token) { if (!RANKS.contains(value)) { throw new IllegalArgumentException("rank 必须是 XH/X/SH/S/A/B/C/D/F"); } + } else if (Set.of("title", "artist", "mapper", "genre", "language").contains(field)) { + if (!Set.of("~", "!~", "=", "!=").contains(operator)) { + throw new IllegalArgumentException(field + " 仅支持 ~、!~、=、!="); + } + if (value.isBlank()) { + throw new IllegalArgumentException(field + " 不能为空"); + } + } else if (Set.of("video", "storyboard", "fullcombo").contains(field)) { + if (!Set.of("=", "!=").contains(operator)) { + throw new IllegalArgumentException(field + " 仅支持 =、!="); + } + value = value.toLowerCase(Locale.ROOT); + if (!Set.of("true", "false").contains(value)) { + throw new IllegalArgumentException(field + " 必须是 true 或 false"); + } } else { if (Set.of("~", "!~").contains(operator)) { throw new IllegalArgumentException(field + " 不支持包含运算符"); @@ -91,22 +112,40 @@ private static String normalizeField(String value) { case "score" -> "score"; case "mod", "mods" -> "mod"; case "rank" -> "rank"; + case "title" -> "title"; + case "artist" -> "artist"; + case "mapper" -> "mapper"; + case "genre" -> "genre"; + case "language" -> "language"; + case "video" -> "video"; + case "storyboard" -> "storyboard"; + case "fullcombo" -> "fullcombo"; default -> throw new IllegalArgumentException("未知字段 " + value); }; } - private static String normalizeOperator(String value) { - return switch (value) { - case "大于等于" -> ">="; - case "小于等于" -> "<="; - case "不包含" -> "!~"; - case "不等于" -> "!="; - case "包含" -> "~"; - case "等于" -> "="; - case "大于" -> ">"; - case "小于" -> "<"; - default -> value; + private static String expandShorthand(String value) { + Matcher missMatcher = MISS_SHORTHAND_PATTERN.matcher(value); + if (missMatcher.matches()) { + return "miss" + (missMatcher.group(1).isEmpty() ? "=" : "!=") + missMatcher.group(2); + } + + String booleanFilter = switch (value.toLowerCase(Locale.ROOT)) { + case "video" -> "video=true"; + case "!video" -> "video=false"; + case "sb", "storyboard" -> "storyboard=true"; + case "!sb", "!storyboard" -> "storyboard=false"; + case "fc", "fullcombo" -> "fullcombo=true"; + case "!fc", "!fullcombo" -> "fullcombo=false"; + default -> null; }; + if (booleanFilter != null) return booleanFilter; + + Matcher rankMatcher = NEGATED_RANK_SHORTHAND_PATTERN.matcher(value); + if (rankMatcher.matches()) { + return "rank!=" + rankMatcher.group(1).toUpperCase(Locale.ROOT); + } + return value; } private static void validateMods(String value) { diff --git a/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java b/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java index a80feb60..e400db19 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java @@ -6,7 +6,7 @@ public final class CommandUsage { public static final String REBIND = "由于发生了一个技术问题,使用此功能需要重新绑定。请使用 `/unbind` 解除绑定,再使用 `/bind` 重新绑定~"; public static final String RS = "用法:/rs <个数> [玩家ID/用户名/@用户] [过滤条件 ...]"; public static final String TB = "用法:/tb [#天数] [玩家ID/用户名/@用户];天数必须为正整数"; - public static final String SCORE_FILTERS = "过滤示例:acc>=98 combo>=1000 mod~HD mod!~DT time<2:00 pp>=300;多个条件同时生效。"; + public static final String SCORE_FILTERS = "过滤示例:acc>=98 title~\"Song Name\" 1miss video !sb fc !S;含空格的值请使用双引号,多个条件同时生效。"; public static final String M = "用法:/m <谱面ID 或 快捷查询> [Mod]"; public static final String BMA = "用法:/bma <谱面ID 或 快捷查询> [Mod]"; public static final String AP = "用法:/ap <谱面ID 或 快捷查询>"; From 3098dab980f10c7d2e327306bba1a48d5e0b9fdc Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 20 Aug 2026 21:25:29 +0800 Subject: [PATCH 19/22] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=95=E6=9D=A1?= =?UTF-8?q?=E6=88=90=E7=BB=A9=E7=9B=B8=E5=85=B3=E6=8C=87=E4=BB=A4=E8=AF=AD?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/handler/ScoreCommandHandler.java | 82 +++++++++++-------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java index 70084fa5..74997757 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java @@ -2,14 +2,12 @@ import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.bot.data.PendingMessage; -import xyz.zcraft.seira.command.*; -import xyz.zcraft.seira.command.parse.Resolver; -import xyz.zcraft.seira.command.parse.ScoreFilterArguments; +import xyz.zcraft.seira.command.Context; +import xyz.zcraft.seira.command.TargetHistory; +import xyz.zcraft.seira.command.TaskCoordinator; +import xyz.zcraft.seira.command.parse.*; import xyz.zcraft.seira.command.reply.CommandUsage; import xyz.zcraft.seira.command.reply.ReplyFactory; -import xyz.zcraft.seira.command.parse.ShortcutTarget; -import xyz.zcraft.seira.command.parse.TargetResolution; -import xyz.zcraft.seira.command.parse.UserRefResolution; import xyz.zcraft.seira.data.UserRef; public final class ScoreCommandHandler { @@ -32,6 +30,22 @@ public ScoreCommandHandler( this.replyFactory = replyFactory; } + static TbArguments parseTbArguments(String[] args) { + int days = 1; + int targetIndex = 0; + if (args.length > 0 && args[0].startsWith("#")) { + try { + days = Integer.parseInt(args[0].substring(1)); + } catch (NumberFormatException e) { + return null; + } + if (days <= 0) return null; + targetIndex = 1; + } + if (args.length - targetIndex > 1) return null; + return new TbArguments(days, args.length > targetIndex ? args[targetIndex] : null); + } + public void handleBo(Context ctx) { if (ctx.args().length == 0) { ShortcutTarget target = resolver.parseTarget("bo1", ctx.senderUserId()); @@ -80,7 +94,13 @@ public void handleRs(Context ctx, boolean includeFail) { ); return; } - if (ScoreFilterArguments.looksLikeFilter(ctx.args()[0])) { + + if (resolver.looksLikeMention(ctx.args()[0])) { + if (ctx.args().length > 1 && ScoreFilterArguments.looksLikeFilter(ctx.args()[1])) { + handleFilteredSingleScore(ctx, ctx.command()); + return; + } + } else if (ScoreFilterArguments.looksLikeFilter(ctx.args()[0])) { handleFilteredSingleScore(ctx, ctx.command()); return; } @@ -133,39 +153,34 @@ public void handleTb(Context ctx) { ); } - static TbArguments parseTbArguments(String[] args) { - int days = 1; - int targetIndex = 0; - if (args.length > 0 && args[0].startsWith("#")) { - try { - days = Integer.parseInt(args[0].substring(1)); - } catch (NumberFormatException e) { - return null; + private void handleFilteredSingleScore(Context ctx, String macroType) { + UserRef targetUser = null; + + if (resolver.looksLikeMention(ctx.args()[0])) { + final UserRefResolution userRefResolution = resolver.resolveUserRefArgument(ctx.args()[0]); + if (userRefResolution.errorMessage() != null) { + ctx.sendReply(PendingMessage.ofString(userRefResolution.errorMessage())); + return; } - if (days <= 0) return null; - targetIndex = 1; + targetUser = userRefResolution.userRef(); } - if (args.length - targetIndex > 1) return null; - return new TbArguments(days, args.length > targetIndex ? args[targetIndex] : null); - } - - record TbArguments(int days, String target) { - } - private void handleFilteredSingleScore(Context ctx, String macroType) { ScoreFilterArguments.ParseResult filters = ScoreFilterArguments.parse(ctx.args(), 0); if (filters.isError()) { ctx.sendReply(PendingMessage.ofString(filters.errorMessage() + "\n" + CommandUsage.SCORE_FILTERS)); return; } - Long uid = resolver.resolveBoundUid(ctx.senderUserId()); - if (uid == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); - return; + if (targetUser == null) { + Long uid = resolver.resolveBoundUid(ctx.senderUserId()); + if (uid == null) { + ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); + return; + } + targetUser = new UserRef.ByUid(uid); } - ShortcutTarget target = new ShortcutTarget(null, new UserRef.ByUid(uid), macroType, 1L, null); + ShortcutTarget target = new ShortcutTarget(null, targetUser, macroType, 1L, null); taskCoordinator.runImageRequest( ctx, "Score", @@ -213,9 +228,6 @@ private ScoreListRequest parseScoreListRequest(Context ctx, String usage) { return new ScoreListRequest(count, userRef, filters.filters()); } - private record ScoreListRequest(int count, UserRef userRef, java.util.List filters) { - } - public void handleS(Context ctx) { ShortcutTarget target; if (ctx.args().length == 0) { @@ -346,4 +358,10 @@ private Integer parseMissIndex(String arg, boolean requirePrefix) { return resolver.parsePositiveInt(value); } + record TbArguments(int days, String target) { + } + + private record ScoreListRequest(int count, UserRef userRef, java.util.List filters) { + } + } From 650e5b1887161ab5fa8e564b5b44c990f276db24 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 20 Aug 2026 21:48:17 +0800 Subject: [PATCH 20/22] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=95=E6=9D=A1?= =?UTF-8?q?=E6=88=90=E7=BB=A9=E7=9B=B8=E5=85=B3=E6=8C=87=E4=BB=A4=E8=AF=AD?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seira/command/handler/ScoreCommandHandler.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java index 74997757..78dfaeb2 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java @@ -62,7 +62,13 @@ public void handleBo(Context ctx) { ); return; } - if (ScoreFilterArguments.looksLikeFilter(ctx.args()[0])) { + + if (resolver.looksLikeMention(ctx.args()[0])) { + if (ctx.args().length == 1 || (ctx.args().length > 1 && ScoreFilterArguments.looksLikeFilter(ctx.args()[1]))) { + handleFilteredSingleScore(ctx, "bo"); + return; + } + } else if (ScoreFilterArguments.looksLikeFilter(ctx.args()[0])) { handleFilteredSingleScore(ctx, "bo"); return; } @@ -96,7 +102,7 @@ public void handleRs(Context ctx, boolean includeFail) { } if (resolver.looksLikeMention(ctx.args()[0])) { - if (ctx.args().length > 1 && ScoreFilterArguments.looksLikeFilter(ctx.args()[1])) { + if (ctx.args().length == 1 || (ctx.args().length > 1 && ScoreFilterArguments.looksLikeFilter(ctx.args()[1]))) { handleFilteredSingleScore(ctx, ctx.command()); return; } From 4dd14a2ed9cb5495b5b31c1cbbf5d1a21537bcd1 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 20 Aug 2026 21:50:35 +0800 Subject: [PATCH 21/22] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=95=E6=9D=A1?= =?UTF-8?q?=E6=88=90=E7=BB=A9=E7=9B=B8=E5=85=B3=E6=8C=87=E4=BB=A4=E8=AF=AD?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xyz/zcraft/seira/command/handler/ScoreCommandHandler.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java index 78dfaeb2..c59a8634 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java @@ -161,6 +161,7 @@ public void handleTb(Context ctx) { private void handleFilteredSingleScore(Context ctx, String macroType) { UserRef targetUser = null; + int startIndex = 0; if (resolver.looksLikeMention(ctx.args()[0])) { final UserRefResolution userRefResolution = resolver.resolveUserRefArgument(ctx.args()[0]); @@ -169,9 +170,10 @@ private void handleFilteredSingleScore(Context ctx, String macroType) { return; } targetUser = userRefResolution.userRef(); + startIndex = 1; } - ScoreFilterArguments.ParseResult filters = ScoreFilterArguments.parse(ctx.args(), 0); + ScoreFilterArguments.ParseResult filters = ScoreFilterArguments.parse(ctx.args(), startIndex); if (filters.isError()) { ctx.sendReply(PendingMessage.ofString(filters.errorMessage() + "\n" + CommandUsage.SCORE_FILTERS)); return; From e361de0f1d4c95f0f706ad512be5269edf501ac4 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 21 Aug 2026 00:28:10 +0800 Subject: [PATCH 22/22] =?UTF-8?q?=E4=BD=BF/mpwatch=E6=8C=89=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=85=B3=E8=81=94=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../MultiplayerRoomWatchCommandHandler.java | 27 ++++-- .../seira/command/reply/ReplyFactory.java | 2 +- .../watch/MultiplayerRoomWatchService.java | 96 +++++++++++++++---- 4 files changed, 101 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 790ce5dc..5b0ae5d9 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 | `/luck` | `/luck` | 今日人品 | | `/mp` | `/mp` | 多人房间列表 | | `/watch` | `/watch add/del/list [目标]` | 添加/删除/列出监视任务 | -| `/mpwatch` | `/watch start/stop/status [目标]` | 添加/删除/列出多人房间监视 | +| `/mpwatch` | `/mpwatch start/stop/status [目标]` | 按群成员添加、停止或查看多人房间监视;`stop all` 可停止本群全部监视 | | `/wx` | `/wx start <谱面ID列表>` / `/wx stop` | 监视指定玩家在指定谱面取得的成绩,重启后自动恢复 | | `/dcs` | `/dcs start .` / `/dcs stop` | 开启或解除当前 QQ 群与 Discord 频道的双向消息同步 | | `/stat` | `/stat` | 服务状态和统计信息文本 | diff --git a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java index dbd91edc..f5c5333c 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java @@ -21,7 +21,7 @@ public final class MultiplayerRoomWatchCommandHandler { private static final String USAGE = "用法:/mpwatch [start] <房间ID> [stable|lazer];" - + "/mpwatch [start] <房间链接>;/mpwatch stop;/mpwatch status"; + + "/mpwatch [start] <房间链接>;/mpwatch stop [all];/mpwatch status"; private static final Pattern LAZER_ROOM_URL = Pattern.compile( "(?i)^https?://(?:www\\.)?osu\\.ppy\\.sh/multiplayer/rooms/(\\d+)(?:[/?#].*)?$" ); @@ -93,7 +93,9 @@ private void handleStart(Context ctx, int argumentOffset) { return; } try { - RoomWatchView view = watchService.watch(ctx.groupId(), target.version(), target.roomId()); + RoomWatchView view = watchService.watch( + ctx.groupId(), ctx.senderUserId(), target.version(), target.roomId() + ); ctx.sendReply(PendingMessage.ofString( "已开始监视" + formatRoom(view) + "。" + "之后完成的每张图都会自动推送结果。" @@ -105,15 +107,24 @@ private void handleStart(Context ctx, int argumentOffset) { } private void handleStop(Context ctx) { + if (ctx.argumentCount() == 2 && "all".equalsIgnoreCase(ctx.argument(1))) { + int stoppedCount = watchService.stopAll(ctx.groupId()).size(); + ctx.sendReply(PendingMessage.ofString( + stoppedCount == 0 + ? "当前群聊没有多人房间监视。" + : "已停止当前群聊的全部 " + stoppedCount + " 个多人房间监视。" + )); + return; + } if (ctx.argumentCount() != 1) { usage(ctx); return; } - RoomWatchView stopped = watchService.stop(ctx.groupId()); + RoomWatchView stopped = watchService.stop(ctx.groupId(), ctx.senderUserId()); ctx.sendReply(PendingMessage.ofString( stopped == null - ? "当前群聊没有多人房间监视。" - : "已停止监视" + formatRoom(stopped) + "。" + ? "你当前没有在本群启动多人房间监视。" + : "已停止你启动的监视:" + formatRoom(stopped) + "。" )); } @@ -122,11 +133,11 @@ private void handleStatus(Context ctx) { usage(ctx); return; } - RoomWatchView view = watchService.get(ctx.groupId()); + RoomWatchView view = watchService.get(ctx.groupId(), ctx.senderUserId()); ctx.sendReply(PendingMessage.ofString( view == null - ? "当前群聊没有多人房间监视。" - : "当前正在监视" + formatRoom(view) + "。" + ? "你当前没有在本群启动多人房间监视。" + : "你当前正在监视" + formatRoom(view) + "。" )); } diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 75fdbdf1..ec20a8b2 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -603,7 +603,7 @@ public static String helpContent(Context ctx) { > /lb <谱面ID> [玩家ID列表] - 获取指定谱面排行榜 > /watch add <玩家ID/用户名/@用户> [分钟] - 监视群友的新成绩 > /wx start <谱面ID列表> - 监视指定玩家在指定谱面的成绩 - > /mpwatch [start] <房间ID> [stable|lazer] - 监视多人房间的逐图结果(链接可自动识别版本) + > /mpwatch [start] <房间ID> [stable|lazer] - 监视多人房间的逐图结果(链接可自动识别版本,stop all 停止本群全部监视) > /f - 获取好友列表 详细指令列表请在 [这里](https://docs.seira.top/overview/commands.html) 查看 diff --git a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java index 928d9d8e..eecad975 100644 --- a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java +++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java @@ -22,7 +22,7 @@ public final class MultiplayerRoomWatchService implements AutoCloseable { private static final Logger LOG = LogManager.getLogger(MultiplayerRoomWatchService.class); private final Object lock = new Object(); - private final Map watchesByGroup = new LinkedHashMap<>(); + private final Map> watchesByGroup = new LinkedHashMap<>(); private final MultiplayerRoomWatchApi api; private final MultiplayerRoomNotifier notifier; private final Duration pollInterval; @@ -60,12 +60,22 @@ public void start() { ); } - public RoomWatchView watch(String groupId, MultiplayerRoomVersion version, long roomId) { - Objects.requireNonNull(groupId); + public RoomWatchView watch( + String groupId, + String userId, + MultiplayerRoomVersion version, + long roomId + ) { + requireIdentifier(groupId, "groupId"); + requireIdentifier(userId, "userId"); Objects.requireNonNull(version); if (roomId <= 0) { throw new IllegalArgumentException("房间 ID 必须为正整数。"); } + RoomKey room = new RoomKey(version, roomId); + synchronized (lock) { + ensureRoomAvailable(groupId, userId, room); + } RoomWatchSnapshot snapshot = api.getSnapshot(version, roomId); if (!snapshot.active()) { throw new IllegalStateException("该多人房间已经结束,无法开始监视。"); @@ -75,21 +85,46 @@ public RoomWatchView watch(String groupId, MultiplayerRoomVersion version, long snapshot.completedPlays().forEach(play -> baseline.add(play.playlistItemId())); WatchEntry entry = new WatchEntry(version, snapshot.roomId(), snapshot.roomName(), baseline); synchronized (lock) { - watchesByGroup.put(groupId, entry); + ensureRoomAvailable(groupId, userId, room); + watchesByGroup.computeIfAbsent(groupId, ignored -> new LinkedHashMap<>()) + .put(userId, entry); } return view(entry); } - public RoomWatchView stop(String groupId) { + public RoomWatchView stop(String groupId, String userId) { + requireIdentifier(groupId, "groupId"); + requireIdentifier(userId, "userId"); synchronized (lock) { - WatchEntry removed = watchesByGroup.remove(groupId); + Map groupWatches = watchesByGroup.get(groupId); + if (groupWatches == null) { + return null; + } + WatchEntry removed = groupWatches.remove(userId); + if (groupWatches.isEmpty()) { + watchesByGroup.remove(groupId); + } return removed == null ? null : view(removed); } } - public RoomWatchView get(String groupId) { + public List stopAll(String groupId) { + requireIdentifier(groupId, "groupId"); synchronized (lock) { - WatchEntry entry = watchesByGroup.get(groupId); + Map removed = watchesByGroup.remove(groupId); + if (removed == null) { + return List.of(); + } + return removed.values().stream().map(MultiplayerRoomWatchService::view).toList(); + } + } + + public RoomWatchView get(String groupId, String userId) { + requireIdentifier(groupId, "groupId"); + requireIdentifier(userId, "userId"); + synchronized (lock) { + Map groupWatches = watchesByGroup.get(groupId); + WatchEntry entry = groupWatches == null ? null : groupWatches.get(userId); return entry == null ? null : view(entry); } } @@ -172,29 +207,29 @@ private boolean sendPendingResults( private Map> snapshotByRoom() { synchronized (lock) { Map> result = new LinkedHashMap<>(); - watchesByGroup.forEach((groupId, entry) -> result + watchesByGroup.forEach((groupId, groupWatches) -> groupWatches.forEach((userId, entry) -> result .computeIfAbsent(new RoomKey(entry.version, entry.roomId), ignored -> new ArrayList<>()) - .add(new WatchRef(groupId, entry))); + .add(new WatchRef(groupId, userId, entry)))); return result; } } private boolean isCurrent(WatchRef watch) { synchronized (lock) { - return watchesByGroup.get(watch.groupId()) == watch.entry(); + return currentEntry(watch) == watch.entry(); } } private boolean wasSent(WatchRef watch, long playlistItemId) { synchronized (lock) { - return watchesByGroup.get(watch.groupId()) != watch.entry() + return currentEntry(watch) != watch.entry() || watch.entry().sentPlaylistItemIds.contains(playlistItemId); } } private void markSent(WatchRef watch, long playlistItemId) { synchronized (lock) { - if (watchesByGroup.get(watch.groupId()) == watch.entry()) { + if (currentEntry(watch) == watch.entry()) { watch.entry().sentPlaylistItemIds.add(playlistItemId); } } @@ -202,12 +237,35 @@ private void markSent(WatchRef watch, long playlistItemId) { private void removeIfCurrent(WatchRef watch) { synchronized (lock) { - if (watchesByGroup.get(watch.groupId()) == watch.entry()) { - watchesByGroup.remove(watch.groupId()); + Map groupWatches = watchesByGroup.get(watch.groupId()); + if (groupWatches != null && groupWatches.get(watch.userId()) == watch.entry()) { + groupWatches.remove(watch.userId()); + if (groupWatches.isEmpty()) { + watchesByGroup.remove(watch.groupId()); + } } } } + private WatchEntry currentEntry(WatchRef watch) { + Map groupWatches = watchesByGroup.get(watch.groupId()); + return groupWatches == null ? null : groupWatches.get(watch.userId()); + } + + private void ensureRoomAvailable(String groupId, String userId, RoomKey room) { + Map groupWatches = watchesByGroup.get(groupId); + if (groupWatches == null) { + return; + } + boolean watchedByAnotherUser = groupWatches.entrySet().stream() + .anyMatch(watch -> !watch.getKey().equals(userId) + && watch.getValue().version == room.version() + && watch.getValue().roomId == room.roomId()); + if (watchedByAnotherUser) { + throw new IllegalStateException("该多人房间已由本群其他用户监视,不能重复监视。"); + } + } + private void pollSafely() { try { pollNow(); @@ -223,6 +281,12 @@ private static Duration requirePositive(Duration duration) { return duration; } + private static void requireIdentifier(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + } + private static RoomWatchView view(WatchEntry entry) { return new RoomWatchView(entry.version, entry.roomId, entry.roomName); } @@ -253,7 +317,7 @@ private WatchEntry( } } - private record WatchRef(String groupId, WatchEntry entry) { + private record WatchRef(String groupId, String userId, WatchEntry entry) { } private record RoomKey(MultiplayerRoomVersion version, long roomId) {