diff --git a/README.md b/README.md index ffcbbd5a..5b0ae5d9 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)。 @@ -93,16 +73,17 @@ mvn -U clean compile exec:java | `/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...]` | 获取最多200条最好成绩后过滤;省略n时返回第一个匹配成绩详情 | +| `/rs` | `/rs [n] [uid/username/@user] [filters...]` | 获取最多200条最近成绩后过滤;省略n时返回第一个匹配成绩详情 | +| `/rp` | `/rp [n] [uid/username/@user] [filters...]` | 获取最多200条最近通过成绩后过滤;省略n时返回第一个匹配成绩详情 | +| `/tb` | `/tb [#days] [uid/username/@user]` | 获取近N天达成的BP(默认1天),并保留完整BP排名编号 | | `/m` | `/m [Mod]` | 获取指定谱面信息 | | `/ap` | `/ap ` | 获取指定谱面音频预览 | | `/bgp` | `/bgp ` | 获取指定谱面背景预览 | | `/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] [+,...]` | 生成并发送指定或最近目标的成绩同屏回放视频;追加用户和范围顺序不限 | @@ -115,6 +96,7 @@ mvn -U clean compile exec:java | `/luck` | `/luck` | 今日人品 | | `/mp` | `/mp` | 多人房间列表 | | `/watch` | `/watch add/del/list [目标]` | 添加/删除/列出监视任务 | +| `/mpwatch` | `/mpwatch start/stop/status [目标]` | 按群成员添加、停止或查看多人房间监视;`stop all` 可停止本群全部监视 | | `/wx` | `/wx start <谱面ID列表>` / `/wx stop` | 监视指定玩家在指定谱面取得的成绩,重启后自动恢复 | | `/dcs` | `/dcs start .` / `/dcs stop` | 开启或解除当前 QQ 群与 Discord 频道的双向消息同步 | | `/stat` | `/stat` | 服务状态和统计信息文本 | @@ -169,3 +151,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 +``` diff --git a/panel.json b/panel.json index 918d09e8..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", @@ -35,6 +31,11 @@ "name": "/bp", "desc": "查询最好成绩" }, + { + "type": "command", + "name": "/tb", + "desc": "查询近期达成BP" + }, { "type": "command", "name": "/s", @@ -97,4 +98,4 @@ } ], "remark": "v1.10.0" -} \ No newline at end of file +} 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 diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index 3476e9cc..672c82d3 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -99,8 +99,38 @@ 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 getUserInfoResponse(UserRef userRef) { + long uid = resolveUid(userRef); + return getBase64BytesResponse( + "/users/" + uid, + "获取玩家资料失败", + null + ); + } + + 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?days=" + days, + "获取近期BP失败", + null + ); } public static Response getGroupLeaderboardResponse(ShortcutTarget target, List uids, String auth) { @@ -187,8 +217,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) { @@ -196,6 +241,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); @@ -333,7 +387,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); } @@ -510,6 +568,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; @@ -539,13 +616,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, @@ -597,6 +670,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(); @@ -604,7 +681,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)) @@ -678,8 +755,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) { @@ -697,8 +777,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); @@ -1030,7 +1115,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/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/bot/QQBot.java b/src/main/java/xyz/zcraft/seira/bot/QQBot.java index 87a8aaa5..348e256f 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,9 @@ public class QQBot implements AutoCloseable, ConsoleRuntimeControl { @Getter 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; @@ -80,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); @@ -93,8 +100,15 @@ 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.rankGuessGameService = new RankGuessGameService(); this.attachmentHandler = new AttachmentHandler(executors.attachmentDownloads()); this.router = new Router( sender, @@ -102,6 +116,7 @@ public QQBot( admins, bindingService, watchService, + multiplayerRoomWatchService, discordBridgeService, rankGuessGameService, executors.commandTasks(), @@ -119,6 +134,7 @@ public void start() { runnerThread = Thread.currentThread(); tokenManager.start(); watchService.start(); + multiplayerRoomWatchService.start(); discordBridgeService.start(); LOG.info("Starting bot connection loop..."); @@ -214,6 +230,7 @@ public void stop() { thread.interrupt(); } watchService.close(); + multiplayerRoomWatchService.close(); discordBridgeService.close(); tokenManager.close(); } @@ -281,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/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/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/handler/BeatmapCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java index 32b67245..24cf7b32 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; } @@ -84,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; @@ -112,6 +159,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/handler/GeneralCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/GeneralCommandHandler.java index 0989c980..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,9 +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; @@ -15,38 +19,48 @@ 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) { - 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(); } - String target = context.argument(0); - Context bestScoreContext = context.asCommand("bo", new String[]{"8", target}, "8 " + target); + if (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(userRef), + replyFactory::userInfoMessage + ); } public void handleLuck(Context context) { 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..f5c5333c --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java @@ -0,0 +1,193 @@ +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; +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; +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> [stable|lazer];" + + "/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+)(?:[/?#].*)?$" + ); + 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; + + 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) { + handleStart(ctx, 0); + return; + } + + switch (ctx.argument(0).toLowerCase(Locale.ROOT)) { + case "start" -> handleStart(ctx, 1); + case "stop" -> handleStop(ctx); + case "status", "list" -> handleStatus(ctx); + default -> handleStart(ctx, 0); + } + } + + private void handleStart(Context ctx, int argumentOffset) { + int startArgumentCount = ctx.argumentCount() - argumentOffset; + if (startArgumentCount < 0 || startArgumentCount > 2) { + usage(ctx); + return; + } + + 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()); + 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 (target == 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(), ctx.senderUserId(), target.version(), target.roomId() + ); + ctx.sendReply(PendingMessage.ofString( + "已开始监视" + formatRoom(view) + "。" + + "之后完成的每张图都会自动推送结果。" + )); + } catch (IllegalArgumentException | IllegalStateException e) { + throw new ResolutionException(e.getMessage()); + } + }); + } + + 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(), ctx.senderUserId()); + ctx.sendReply(PendingMessage.ofString( + stopped == null + ? "你当前没有在本群启动多人房间监视。" + : "已停止你启动的监视:" + formatRoom(stopped) + "。" + )); + } + + private void handleStatus(Context ctx) { + if (ctx.argumentCount() != 1) { + usage(ctx); + return; + } + RoomWatchView view = watchService.get(ctx.groupId(), ctx.senderUserId()); + ctx.sendReply(PendingMessage.ofString( + view == null + ? "你当前没有在本群启动多人房间监视。" + : "你当前正在监视" + formatRoom(view) + "。" + )); + } + + static RoomTarget parseRoomTarget(String value, String explicitVersion) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim(); + String numeric = normalized; + 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 ? 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/handler/ScoreCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java index 0afe401b..c59a8634 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java @@ -2,16 +2,17 @@ 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.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 { + private static final int MAX_SCORE_LIST_COUNT = 200; + private final Resolver resolver; private final TargetHistory targetHistory; private final TaskCoordinator taskCoordinator; @@ -29,49 +30,63 @@ public ScoreCommandHandler( this.replyFactory = replyFactory; } - 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; + 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 (userRefResolution.userRef() == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.BO)); + 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()); + if (target.isError()) { + ctx.sendReply(PendingMessage.ofString(target.errorMessage())); return; } taskCoordinator.runImageRequest( ctx, - "Best Scores", - () -> APIHelper.getBoNResponse(n, userRefResolution.userRef()), - replyFactory::boMessage + "Score", + () -> APIHelper.getScoreResponse(target), + replyFactory::scoreMessage ); - } 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; + } + + 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; + } - taskCoordinator.runImageRequest( - ctx, - "Best Scores", - () -> APIHelper.getBoNResponse(n, new UserRef.ByUid(uid)), - replyFactory::boMessage - ); - } else if (ctx.args().length == 0) { - ShortcutTarget target = resolver.parseTarget("bo1", ctx.senderUserId()); + ScoreListRequest request = parseScoreListRequest(ctx, CommandUsage.BO); + if (request == null) return; + + taskCoordinator.runImageRequest( + ctx, + "Best Scores", + () -> APIHelper.getBoNResponse(request.count(), request.userRef(), request.filters()), + replyFactory::boMessage + ); + } + + 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())); return; @@ -83,70 +98,142 @@ 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)); + if (resolver.looksLikeMention(ctx.args()[0])) { + if (ctx.args().length == 1 || (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; + } - UserRefResolution userRefResolution = resolver.resolveUserRefArgument(ctx.args()[1]); - if (userRefResolution.errorMessage() != null) { - ctx.sendReply(PendingMessage.ofString(userRefResolution.errorMessage())); - return; - } + ScoreListRequest request = parseScoreListRequest(ctx, CommandUsage.RS); + if (request == null) return; - if (userRefResolution.userRef() == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.RS)); + taskCoordinator.runImageRequest( + ctx, + "Recent Score", + () -> APIHelper.getRecentResponse(request.count(), request.userRef(), includeFail, request.filters()), + replyFactory::rsMessage + ); + } + + public void handleTb(Context ctx) { + TbArguments request = parseTbArguments(ctx.args()); + if (request == null) { + ctx.sendReply(PendingMessage.ofString(CommandUsage.TB)); + return; + } + + UserRef userRef; + if (request.target() != null) { + UserRefResolution resolution = resolver.resolveUserRefArgument(request.target()); + if (resolution.errorMessage() != null) { + ctx.sendReply(PendingMessage.ofString(resolution.errorMessage())); 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)); + 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); + } - taskCoordinator.runImageRequest( - ctx, - "Recent Score", - () -> APIHelper.getRecentResponse(n, new UserRef.ByUid(uid), includeFail), - replyFactory::rsMessage - ); - } else if (ctx.args().length == 0) { - ShortcutTarget target = resolver.parseTarget(ctx.command() + "1", ctx.senderUserId()); - if (target.isError()) { - ctx.sendReply(PendingMessage.ofString(target.errorMessage())); + UserRef target = userRef; + taskCoordinator.runImageRequest( + ctx, + "Recent Best Scores", + () -> APIHelper.getTodayBestResponse(target, request.days()), + replyFactory::tbMessage + ); + } + + 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]); + if (userRefResolution.errorMessage() != null) { + ctx.sendReply(PendingMessage.ofString(userRefResolution.errorMessage())); return; } + targetUser = userRefResolution.userRef(); + startIndex = 1; + } - taskCoordinator.runImageRequest( - ctx, - "Score", - () -> APIHelper.getScoreResponse(target), - replyFactory::scoreMessage - ); + ScoreFilterArguments.ParseResult filters = ScoreFilterArguments.parse(ctx.args(), startIndex); + if (filters.isError()) { + ctx.sendReply(PendingMessage.ofString(filters.errorMessage() + "\n" + CommandUsage.SCORE_FILTERS)); + 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, targetUser, 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]); + if (count == null || count > MAX_SCORE_LIST_COUNT) { + ctx.sendReply(PendingMessage.ofString(usage + "\n数量必须在 1 到 " + MAX_SCORE_LIST_COUNT + " 之间。")); + return null; + } + + int nextArg = 1; + UserRef userRef; + if (nextArg < args.length && resolver.looksLikeMention(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()); } public void handleS(Context ctx) { @@ -279,4 +366,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) { + } + } 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/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) { 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..56869cd3 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java @@ -0,0 +1,198 @@ +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" + + "|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"); + + private ScoreFilterArguments() { + } + + public static boolean looksLikeFilter(String value) { + if (value == null) return false; + String trimmed = value.trim(); + return !expandShorthand(trimmed).equals(trimmed) + || trimmed.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) { + 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 = 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("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 + " 不支持包含运算符"); + } + 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"; + 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 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) { + 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 19c24b00..e400db19 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java @@ -1,12 +1,16 @@ 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 TB = "用法:/tb [#天数] [玩家ID/用户名/@用户];天数必须为正整数"; + 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 或 快捷查询>"; + 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..ec20a8b2 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,23 @@ 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" + + "> 玩家: " + 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" + @@ -324,6 +341,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())); @@ -387,6 +417,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(); } @@ -560,14 +593,17 @@ public static String helpContent(Context ctx) { > /rp - 获取最近通过的一个成绩 > /bo [个数] [玩家ID] - 获取一个或多个最佳成绩 > /rp [个数] [玩家ID] - 获取最近通过一个或多个成绩 + > /tb [#天数] [玩家ID] - 获取近N天达成的BP > /s <成绩ID或快捷查询> - 获取指定成绩 > /m <谱面ID或快捷查询> - 获取谱面 + > /bma <谱面ID或快捷查询> [Mod] - 分析谱面PP构成和类型 > /ms <谱面集ID或快捷查询> - 获取谱面集 > /r [成绩ID或快捷查询] [[mm:ss]-[mm:ss]] - 生成成绩高光视频或指定片段 > /rg - 在群聊中进行猜 Rank 游戏 > /lb <谱面ID> [玩家ID列表] - 获取指定谱面排行榜 > /watch add <玩家ID/用户名/@用户> [分钟] - 监视群友的新成绩 > /wx start <谱面ID列表> - 监视指定玩家在指定谱面的成绩 + > /mpwatch [start] <房间ID> [stable|lazer] - 监视多人房间的逐图结果(链接可自动识别版本,stop all 停止本群全部监视) > /f - 获取好友列表 详细指令列表请在 [这里](https://docs.seira.top/overview/commands.html) 查看 @@ -687,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"), @@ -765,6 +813,10 @@ List> beatmapButtons(String beatmapId) { Button.row( Button.command(3, "预览音频", "/ap m" + beatmapId), Button.command(4, "查询自己的分数", "/s m" + beatmapId) + ), + Button.row( + 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 235339f9..cb141dd4 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, @@ -59,6 +61,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 +69,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,16 +79,18 @@ public Router( targetHistory, taskCoordinator, replyFactory, - new VideoRenderRecord(), + videoRenderRecord, replayResults, 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 = new SpecificScoreWatchCommandHandler(taskCoordinator, watchService); + MultiplayerRoomWatchCommandHandler multiplayerRoomWatchCommands = + new MultiplayerRoomWatchCommandHandler(taskCoordinator, multiplayerRoomWatchService); DcsCommandHandler dcsCommands = new DcsCommandHandler(discordBridgeService); RankGuessCommandHandler rankGuessCommands = new RankGuessCommandHandler( taskCoordinator, replyFactory, rankGuessGameService, admins::isAdmin @@ -101,6 +106,7 @@ public Router( generalCommands, watchCommands, specificScoreWatchCommands, + multiplayerRoomWatchCommands, dcsCommands, rankGuessCommands ); @@ -195,6 +201,7 @@ private static CommandRegistry createCommandRegistry( GeneralCommandHandler generalCommands, WatchCommandHandler watchCommands, SpecificScoreWatchCommandHandler specificScoreWatchCommands, + MultiplayerRoomWatchCommandHandler multiplayerRoomWatchCommands, DcsCommandHandler dcsCommands, RankGuessCommandHandler rankGuessCommands ) { @@ -207,8 +214,11 @@ 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::handleBma, "bma") .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") @@ -231,6 +241,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/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/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/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 new file mode 100644 index 00000000..c724e1b2 --- /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(MultiplayerRoomVersion version, long roomId); + + 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 new file mode 100644 index 00000000..eecad975 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java @@ -0,0 +1,325 @@ +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, + 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("该多人房间已经结束,无法开始监视。"); + } + + Set baseline = new LinkedHashSet<>(); + snapshot.completedPlays().forEach(play -> baseline.add(play.playlistItemId())); + WatchEntry entry = new WatchEntry(version, snapshot.roomId(), snapshot.roomName(), baseline); + synchronized (lock) { + ensureRoomAvailable(groupId, userId, room); + watchesByGroup.computeIfAbsent(groupId, ignored -> new LinkedHashMap<>()) + .put(userId, entry); + } + return view(entry); + } + + public RoomWatchView stop(String groupId, String userId) { + requireIdentifier(groupId, "groupId"); + requireIdentifier(userId, "userId"); + synchronized (lock) { + 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 List stopAll(String groupId) { + requireIdentifier(groupId, "groupId"); + synchronized (lock) { + 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); + } + } + + public Set activeGroupIds() { + synchronized (lock) { + return Set.copyOf(watchesByGroup.keySet()); + } + } + + 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(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)) + .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 {}", + room.version().value(), room.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().version, 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, groupWatches) -> groupWatches.forEach((userId, entry) -> result + .computeIfAbsent(new RoomKey(entry.version, entry.roomId), ignored -> new ArrayList<>()) + .add(new WatchRef(groupId, userId, entry)))); + return result; + } + } + + private boolean isCurrent(WatchRef watch) { + synchronized (lock) { + return currentEntry(watch) == watch.entry(); + } + } + + private boolean wasSent(WatchRef watch, long playlistItemId) { + synchronized (lock) { + return currentEntry(watch) != watch.entry() + || watch.entry().sentPlaylistItemIds.contains(playlistItemId); + } + } + + private void markSent(WatchRef watch, long playlistItemId) { + synchronized (lock) { + if (currentEntry(watch) == watch.entry()) { + watch.entry().sentPlaylistItemIds.add(playlistItemId); + } + } + } + + private void removeIfCurrent(WatchRef watch) { + synchronized (lock) { + 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(); + } 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 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); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + scheduler.shutdownNow(); + } + } + + private static final class WatchEntry { + private final MultiplayerRoomVersion version; + private final long roomId; + private final String roomName; + private final 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); + } + } + + private record WatchRef(String groupId, String userId, 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 new file mode 100644 index 00000000..43133f68 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java @@ -0,0 +1,122 @@ +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(MultiplayerRoomVersion version, long roomId) { + HttpResponse response = get( + "/multiplayer/rooms/" + requirePositive(roomId, "roomId") + "/watch?version=" + + requireVersion(version).value(), + 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(MultiplayerRoomVersion version, long roomId, long playlistItemId) { + HttpResponse response = get( + "/multiplayer/rooms/" + requirePositive(roomId, "roomId") + + "/playlist/" + requirePositive(playlistItemId, "playlistItemId") + "/result?version=" + + requireVersion(version).value(), + 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; + } + + 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/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..bf906517 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/watch/RoomWatchView.java @@ -0,0 +1,4 @@ +package xyz.zcraft.seira.watch; + +public record RoomWatchView(MultiplayerRoomVersion version, long roomId, String roomName) { +} 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, 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: