diff --git a/README.md b/README.md index cde85722..aa9d3200 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Seira 是一个提供 osu! 成绩查询的 QQ 机器人。 支持生成最好成绩图、最近成绩图、排行榜等,持续更新中... -Seira 依赖 [oStella](https://github.com/BotSeira/oStella) 作为上游数据服务。 +Seira 依赖 [oStella](https://github.com/bptSeira/oStella) 作为上游数据服务。 详细使用文档在[这里](https://docs.seira.top/)~ @@ -47,11 +47,6 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 image -每局结算后自动保存战绩。在群内使用 `/rg stats` 查询自己的本群战绩,使用 `/rg stats all` 查询自己的全部群聊战绩。 -统计包括参与数、获胜数、胜率、前 20% 次数和达成率、平均分、最高分、总得分及平均名次。 -每人每局只统计最终猜测,获胜按最终排行榜第一名计算;前 20% 名额向上取整,仅统计至少 5 人参与的对局,达成率以这些对局为分母。 -战绩与用户绑定共用配置中的 SQLite 数据库,分数采用最终结算得分;历史未记录的对局不会自动补入。 - ### 查询群友的排行榜! image @@ -78,23 +73,24 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 | `/f` | `/f` | 获取好友列表 | | `/fall` | `/fall` | 获取全部好友列表 | | `/fclear` | `/fclear` | 清除好友记录 | -| `/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时返回第一个匹配成绩详情 | +| `/bp` | `/bp [n/a-b] [uid/username/@user] [filters...]` | 获取最多200条最好成绩或指定范围后过滤;省略数量时返回第一个匹配成绩详情 | +| `/rs` | `/rs [n/a-b] [uid/username/@user] [filters...]` | 获取最多200条最近成绩或指定范围后过滤;省略数量时返回第一个匹配成绩详情 | +| `/rp` | `/rp [n/a-b] [uid/username/@user] [filters...]` | 获取最多200条最近通过成绩或指定范围后过滤;省略数量时返回第一个匹配成绩详情 | | `/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 | +| `/m` | `/m [Mod]` | 获取指定谱面信息 | +| `/ap` | `/ap ` | 获取指定谱面音频预览 | +| `/bgp` | `/bgp ` | 获取指定谱面背景预览 | +| `/s` | `/s ` | 获取指定的在线或本地成绩图 | +| `/sa` | `/sa ` | 获取指定成绩分析图 | +| `/ma` | `/ma [id/locId/rsN/bpN] [n/#n]` | 获取指定或最近目标成绩的Miss分析;省略目标时用`#n`指定Miss | | `/u` | `/u [uid/username/@user]` | 获取指定用户信息 | -| `/r` | `/r [id/locId/rsN/boN] [[mm:ss]-[mm:ss]]` | 生成并发送指定或最近目标的回放视频。省略范围时自动识别高光,使用`-`渲染整个回放 | +| `/r` | `/r [id/locId/rsN/bpN] [[mm:ss]-[mm:ss]]` | 生成并发送指定或最近目标的回放视频。省略范围时自动识别高光,使用`-`渲染整个回放 | | `/rg` | `/rg ` | 猜 Rank 游戏及个人战绩查询 | -| `/rsc` | `/rsc [id/locId/rsN/boN] [+,...]` | 生成并发送指定或最近目标的成绩同屏回放视频;追加用户和范围顺序不限 | +| `/rsc` | `/rsc [id/locId/rsN/bpN] [+,...]` | 生成并发送指定或最近目标的成绩同屏回放视频;追加用户和范围顺序不限 | | `/rstat` | `/rstat [id]` | 获取视频生成进度 | -| `/ms` | `/ms ` | 获取指定谱面集信息 | -| `/dl` | `/dl ` | 获取指定谱面集的镜像下载链接 | +| `/rcancel` | `/rcancel ` | 取消排队中、渲染中或上传中的回放任务 | +| `/ms` | `/ms ` | 获取指定谱面集信息 | +| `/dl` | `/dl ` | 获取指定谱面集的镜像下载链接 | | `/sms` | `/sms ` | 搜索谱面集 | | `/lb` | `/lb [id] [,...]` | 列出指定谱面排行或表现分排行 | | `/daily` | `/daily` | 每日挑战信息 | @@ -116,15 +112,17 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 ### 快捷查询 -对于一些需要指定谱面ID或成绩ID的指令(如 `/m`、`/s`、`/ms` 等),支持快捷查询写法,格式为 `rs5`、`bo3`、`rp2`。 +对于一些需要指定谱面ID或成绩ID的指令(如 `/m`、`/s`、`/ms` 等),支持快捷查询写法,格式为 `rs5`、`bp3`、`rp2`。 + +快捷查询也可以直接作为指令使用,并在后面指定玩家,例如 `/bp5 @用户`;紧凑写法同样支持列表范围,例如 `/bp21-30`、`/rp6-10 @用户`。 这些指令会共享最近一次显式指定的查询目标。已有最近目标时,`/r`、`/rsc`、`/ma` 可以省略目标,例如 `/r 01:00-01:30`、`/rsc +12345,67890 -`、`/ma #2`。 -也可以在前面写上玩家ID、用户名或@用户,例如 `123456 rs5`、`peppy bo3`、`@ABC bo3`,表示查询指定玩家的最近成绩第 5 条或最好成绩第 3 条。 +也可以在前面写上玩家ID、用户名或@用户,例如 `123456 rs5`、`peppy bp3`、`@ABC bp3`,表示查询指定玩家的最近成绩第 5 条或最好成绩第 3 条。 - `rs5`:使用你已绑定的玩家ID,查询“最近成绩第 5 条” - `rp1`:使用你已绑定的玩家ID,查询“最近通过成绩第 1 条” -- `bo3`:使用你已绑定的玩家ID,查询“最好成绩第 3 条” +- `bp3`:使用你已绑定的玩家ID,查询“最好成绩第 3 条” - `12345 rs1`:使用12345作为玩家ID,查询“最近成绩第 1 条” - `peppy rs1`:先按用户名查找玩家ID,再查询“最近成绩第 1 条” - `@ABC rs1`:使用ABC绑定的用户的ID作为玩家ID,查询“最近成绩第 1 条” @@ -144,7 +142,7 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 > > **警告:危险区域!** > -> 以下命令仅用于调试,使用不当可能会造成数据丢失、账号封禁等后果,且仅在调试模式启用且发送者为Bot管理员的情况下可用。 +> 以下命令仅用于调试,使用不当可能会造成数据丢失、账号封禁等后果,且仅在调试模式启用且发送者为bpt管理员的情况下可用。 > > 这些指令可能会随时进行添加、修改或删除,且不保证向后兼容。 diff --git a/pom.xml b/pom.xml index 5d0e389f..c003a53f 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira SeiraCore - 1.11.6 + 1.12.0 25 diff --git a/src/main/java/xyz/zcraft/seira/SeiraApplication.java b/src/main/java/xyz/zcraft/seira/SeiraApplication.java index 20f8b800..dbf49881 100644 --- a/src/main/java/xyz/zcraft/seira/SeiraApplication.java +++ b/src/main/java/xyz/zcraft/seira/SeiraApplication.java @@ -14,6 +14,7 @@ import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.services.BotStat; import xyz.zcraft.seira.services.DailyLuck; +import xyz.zcraft.seira.services.NoticeStore; import xyz.zcraft.seira.util.AdminRegistry; import xyz.zcraft.seira.util.ApplicationExecutors; @@ -45,6 +46,7 @@ public SeiraApplication(AppConfig config) { LOG.info("Initializing application services"); DailyLuck.initialize(config.qq().appId()); BotStat.initialize(); + NoticeStore.initialize(); ApplicationExecutors createdExecutors = new ApplicationExecutors(); BindingService createdBindingService = new BindingService( diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index 42b5cd03..828b2f6f 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -23,11 +23,9 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.Arrays; import java.util.LinkedList; import java.util.List; import java.util.Map; -import java.util.stream.Stream; public class APIHelper { private static final String ENDPOINT; @@ -98,14 +96,19 @@ public static Response getSelf(String accessToken) { } } + @SuppressWarnings("unused") public static Response getBoNResponse(int n, UserRef userRef) { return getBoNResponse(n, userRef, List.of()); } public static Response getBoNResponse(int n, UserRef userRef, List filters) { + return getBoNResponse(n, 1, userRef, filters); + } + + public static Response getBoNResponse(int n, int start, UserRef userRef, List filters) { long uid = resolveUid(userRef); return getBase64BytesResponse( - "/users/" + uid + "/scores/bestof?n=" + n + encodeScoreFilters(filters), + "/users/" + uid + "/scores/bestof?n=" + n + encodeScoreRangeStart(start) + encodeScoreFilters(filters), "获取最好成绩失败", null ); @@ -120,6 +123,32 @@ public static Response getUserInfoResponse(UserRef userRef) { ); } + public static UserExtended getUserRaw(UserRef userRef) { + long uid = resolveUid(userRef); + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(ENDPOINT + "/users/" + uid)) + .header("Accept", "application/json") + .GET() + .build(); + + final HttpResponse send = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + + if (send.statusCode() != 200) { + throw parseHttpError(send.body(), send.statusCode(), "获取用户信息失败"); + } + + final RawResponse r = GSON.fromJson(send.body(), RawResponse.class); + ensureApiSuccess(r, "获取用户信息失败"); + final var data = r.getData().getAsJsonObject(); + + return GSON.fromJson(data, UserExtended.class); + } catch (IOException | InterruptedException e) { + throw requestFailure(e); + } + } + + @SuppressWarnings("unused") public static Response getTodayBestResponse(UserRef userRef) { return getTodayBestResponse(userRef, 1); } @@ -133,8 +162,7 @@ public static Response getTodayBestResponse(UserRef userRef, int da ); } - public static Response getGroupLeaderboardResponse(ShortcutTarget target, List uids, String auth) { - final long beatmapId = lookupBeatmap(target, auth); + public static Response getGroupLeaderboardResponse(long beatmapId, List uids) { return getBase64BytesResponse( "/beatmaps/" + beatmapId + "/leaderboards", "获取群排行失败", @@ -216,19 +244,35 @@ public static Response getMultiplayerRoom(String accessToken) { } } + @SuppressWarnings("unused") 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) { + return getRecentResponse(n, 1, userRef, includeFail, filters); + } + + public static Response getRecentResponse( + int n, + int start, + UserRef userRef, + boolean includeFail, + List filters + ) { long uid = resolveUid(userRef); return getBase64BytesResponse( - "/users/" + uid + "/scores/recent?n=" + n + "&fail=" + includeFail + encodeScoreFilters(filters), + "/users/" + uid + "/scores/recent?n=" + n + "&fail=" + includeFail + + encodeScoreRangeStart(start) + encodeScoreFilters(filters), "获取最近成绩失败", null ); } + private static String encodeScoreRangeStart(int start) { + return start > 1 ? "&start=" + start : ""; + } + private static String encodeScoreFilters(List filters) { if (filters == null || filters.isEmpty()) { return ""; @@ -236,13 +280,11 @@ private static String encodeScoreFilters(List filters) { return "&filters=" + URLEncoder.encode(String.join(",", filters), StandardCharsets.UTF_8); } - public static Response getBeatmapResponse(ShortcutTarget target, String mod, String auth) { - final long beatmapId = lookupBeatmap(target, auth); + public static Response getBeatmapResponse(long beatmapId, String mod) { 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); + public static Response getBeatmapAnalysisResponse(long beatmapId, String mod) { String query = "/beatmaps/" + beatmapId + "/analysis"; if (mod != null && !mod.isBlank()) { query += "?mod=" + URLEncoder.encode(mod, StandardCharsets.UTF_8); @@ -250,20 +292,21 @@ public static Response getBeatmapAnalysisResponse(ShortcutTarget ta return getBase64BytesResponse(query, "获取谱面分析失败", null); } - public static Response getBeatmapsetBgResponse(ShortcutTarget target, String auth) { - final long beatmapsetId = lookupBeatmapset(target, auth); + @SuppressWarnings("unused") + public static Response getBeatmapsetBgResponse(long beatmapsetId) { return getBase64BytesResponse("/beatmapsets/" + beatmapsetId + "/background", "获取谱面集失败", null); } - public static Response getBeatmapBgResponse(ShortcutTarget target, String auth) { - final long beatmapId = lookupBeatmap(target, auth); + public static Response getBeatmapBgResponse(long beatmapId) { return getBase64BytesResponse("/beatmaps/" + beatmapId + "/background", "获取谱面失败", null); } public static long lookupBeatmap(ShortcutTarget target, String auth) { long beatmapId; - if (target.isLocalScore()) { - beatmapId = lookupScoreData(target.localScoreId()).get("beatmap_id").getAsLong(); + if (target.isLocalScore() || "s".equals(target.macroType())) { + beatmapId = lookupScoreData(lookupScoreId(target, List.of(), null)).get("beatmap_id").getAsLong(); + } else if ("m".equals(target.macroType())) { + beatmapId = target.explicitId(); } else if (!target.isMacro()) { beatmapId = target.explicitId(); } else { @@ -298,7 +341,7 @@ private static String getBeatmapQuery(ShortcutTarget target) { String query = "/beatmaps/lookup?"; if (target.isMacro()) { switch (target.macroType().toLowerCase()) { - case "rs", "bo", "rp" -> { + case "rs", "bp", "rp" -> { query += "&of=" + target.macroType() + "&u=" + resolveUid(target.userRef()); query += "&i=" + target.macroIndex(); } @@ -315,8 +358,7 @@ private static String getBeatmapQuery(ShortcutTarget target) { return query; } - public static Response getBeatmapsetResponse(ShortcutTarget target, String auth) { - final long beatmapsetId = lookupBeatmapset(target, auth); + public static Response getBeatmapsetResponse(long beatmapsetId) { return getBase64BytesResponse("/beatmapsets/" + beatmapsetId, "获取谱面集失败", null); } @@ -344,7 +386,9 @@ public static Beatmapset getBeatmapsetRaw(long id) { public static long lookupBeatmapset(ShortcutTarget target, String auth) { long beatmapsetId; - if (!target.isMacro()) { + if (target.isLocalScore() || "s".equals(target.macroType())) { + return lookupBeatmapset(new ShortcutTarget(lookupBeatmap(target, auth), null, "m", null, null), auth); + } else if (!target.isMacro() || "ms".equals(target.macroType())) { beatmapsetId = target.explicitId(); } else { try { @@ -379,29 +423,23 @@ private static String getBeatmapsetQuery(ShortcutTarget target) { return switch (target.macroType().toLowerCase()) { case "m" -> query + "?m=" + target.explicitId(); - case "rs", "bo", "rp" -> + case "ms" -> query + "?ms=" + target.explicitId(); + case "rs", "bp", "rp" -> query + "?of=" + target.macroType() + "&i=" + target.macroIndex() + "&u=" + resolveUid(target.userRef()); case "mp" -> query + "?of=mp"; case null, default -> throw new ResolutionException("快捷查询格式错误。"); }; } - public static Response getScoreResponse(ShortcutTarget target) { - return getScoreResponse(target, List.of()); - } - - public static Response getScoreResponse(ShortcutTarget target, List filters) { - String scoreId = lookupScoreId(target, filters); + public static Response getScoreResponse(String scoreId) { return getBase64BytesResponse("/scores/" + scoreId, "获取成绩失败", null); } - public static Response getScoreAnalyzeResponse(ShortcutTarget target) { - String scoreId = lookupScoreId(target); + public static Response getScoreAnalyzeResponse(String scoreId) { return getBase64BytesResponse("/scores/" + scoreId + "/analysis", "获取成绩分析失败", null); } - public static Response getMissVisualizeResponse(ShortcutTarget target, int index) { - String scoreId = lookupScoreId(target); + public static Response getMissVisualizeResponse(String scoreId, int index) { return getBase64BytesResponse("/scores/" + scoreId + "/misses/" + index + "/visualize", "获取Miss可视化失败", null); } @@ -434,7 +472,7 @@ private static Response getBase64BytesResponse(String query, String private static String getScoreQuery(ShortcutTarget target) { return switch (target.macroType().toLowerCase()) { - case "rs", "bo", "rp" -> + case "rs", "bp", "rp" -> "/scores/lookup?of=" + target.macroType() + "&i=" + target.macroIndex() + "&u=" + resolveUid(target.userRef()); case "m" -> "/scores/lookup?m=" + target.explicitId() + "&u=" + resolveUid(target.userRef()); case "ms" -> @@ -443,13 +481,13 @@ private static String getScoreQuery(ShortcutTarget target) { }; } - public static Response getLookupBeatmapsetResponse(@NotNull ShortcutTarget target, String s) { + public static Response getLookupBeatmapsetResponse(long beatmapsetId, String auth) { try { - final String query = target.isMacro() ? getBeatmapsetQuery(target) : "/beatmapsets/lookup?ms=" + target.explicitId(); + final String query = "/beatmapsets/lookup?ms=" + beatmapsetId; HttpRequest localRequest = HttpRequest.newBuilder() .uri(URI.create(ENDPOINT + query)) - .header("Authorization", "Bearer " + s) + .header("Authorization", "Bearer " + auth) .GET() .build(); @@ -500,16 +538,7 @@ public static Response> searchBeatmapSetResponse(SearchQu } } - public static ReplayTaskInfo createReplayRenderTask(ShortcutTarget target, TimeDurationParser.TimeRange timeRange) { - return createReplayTask(target, timeRange, null); - } - - public static ReplayTaskInfo createReplayRenderTask(ShortcutTarget target, - TimeDurationParser.TimeRange timeRange, - QqUploadRequest qqUpload) { - return createReplayTask(target, timeRange, qqUpload); - } - + @SuppressWarnings("unused") public static ReplayTaskInfo createObscuredReplayRenderTask(long scoreId) { return createObscuredReplayRenderTask(scoreId, null); } @@ -564,14 +593,14 @@ public static RandomScore getRandomScore() { } } - public static String getRandomScoreWeight(Long userId, JsonObject weights) { + public static String getRandomScoreWeight(Long userId, JsonObject weights, boolean all) { try { JsonObject body = new JsonObject(); body.add("weight_factor", weights); HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(ENDPOINT + "/scores/random/users/" + userId + "/weights")) + .uri(URI.create(ENDPOINT + "/scores/random/users/" + userId + "/weights?all=" + all)) .POST(HttpRequest.BodyPublishers.ofString(body.toString())) .build(); @@ -635,13 +664,9 @@ public static RandomScore getRandomScoreFromUsers(List uids, JsonObject we } } - public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget target, String[] ids, String auth) { - return createReplayShowcaseTask(target, ids, auth, null); - } - public static ReplayTaskInfo createBeatmapPreviewTask(ShortcutTarget target, String mods, String auth, + public static ReplayTaskInfo createBeatmapPreviewTask(long beatmapId, String mods, QqUploadRequest qqUpload) { - long beatmapId = lookupBeatmap(target, auth); JsonObject body = new JsonObject(); if (mods != null && !mods.isBlank()) { body.addProperty("mods", mods); @@ -658,21 +683,14 @@ public static ReplayTaskInfo createBeatmapPreviewTask(ShortcutTarget target, Str return getReplayTaskInfo(request); } - public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget beatmapTarget, String[] scoreTargets, String auth, + public static ReplayTaskInfo createReplayShowcaseTask(long beatmapId, String[] scoreTargets, QqUploadRequest qqUpload) { scoreTargets = scoreTargets == null ? new String[0] : scoreTargets; - if (beatmapTarget.isLocalScore()) { - scoreTargets = Stream.concat(Stream.of("s" + beatmapTarget.localScoreId()), Arrays.stream(scoreTargets)) - .distinct() - .toArray(String[]::new); - } if (scoreTargets.length == 0) { throw new RuntimeException("同屏回放需要至少一个ID。"); } - final long beatmapId = lookupBeatmap(beatmapTarget, auth); - JsonObject body = GSON.toJsonTree(Map.of("ids", scoreTargets)).getAsJsonObject(); if (qqUpload != null) { body.add("qqUpload", GSON.toJsonTree(qqUpload)); @@ -686,17 +704,15 @@ public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget beatmapTarg return getReplayTaskInfo(request); } - public static ReplayRenderResult waitReplayVideo(String taskId) { - FileInfo qqFile = waitReplayDone(taskId); + public static ReplayRenderResult waitReplayVideo(String taskId, long timeout) { + FileInfo qqFile = waitReplayDone(taskId, timeout); return new ReplayRenderResult( ENDPOINT + "/replays/" + taskId + "/video/replay.mp4", taskId, qqFile); } - private static ReplayTaskInfo createReplayTask(ShortcutTarget target, - TimeDurationParser.TimeRange timeRange, - QqUploadRequest qqUpload) { - String scoreId = lookupScoreId(target); - + public static ReplayTaskInfo createReplayRenderTask(String scoreId, + TimeDurationParser.TimeRange timeRange, + QqUploadRequest qqUpload) { if (timeRange == null) { timeRange = getScoreHighlight(scoreId, 5); } @@ -710,6 +726,7 @@ private static ReplayTaskInfo createReplayTask(ShortcutTarget target, return getReplayTaskInfo(request); } + @SuppressWarnings("SameParameterValue") private static TimeDurationParser.TimeRange getScoreHighlight(long scoreId, int extend) { return getScoreHighlight(String.valueOf(scoreId), extend); } @@ -740,19 +757,16 @@ 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) { + public static String lookupScoreId(ShortcutTarget target, List filters, String mod) { String scoreId; if (target.isLocalScore()) { scoreId = target.localScoreId(); - } else if (!target.isMacro()) { + } else if (!target.isMacro() || "s".equals(target.macroType())) { scoreId = String.valueOf(target.explicitId()); } else { try { - final String query = getScoreQuery(target) + encodeScoreFilters(filters); + final String query = getScoreQuery(target) + encodeScoreFilters(filters) + + (mod == null ? "" : "&mod=" + URLEncoder.encode(mod, StandardCharsets.UTF_8)); HttpRequest localRequest = HttpRequest.newBuilder() .uri(URI.create(ENDPOINT + query)) @@ -777,6 +791,10 @@ private static String lookupScoreId(ShortcutTarget target, List filters) return scoreId; } + public static long getScoreBeatmapId(String scoreId) { + return lookupScoreData(scoreId).get("beatmap_id").getAsLong(); + } + private static JsonObject lookupScoreData(String scoreId) { try { HttpRequest request = HttpRequest.newBuilder() @@ -839,7 +857,8 @@ private static ReplayTaskInfo getReplayTaskInfo(HttpRequest request) { } } - private static FileInfo waitReplayDone(String taskId) { + private static FileInfo waitReplayDone(String taskId, long timeout) { + final long start = System.currentTimeMillis(); for (int attempt = 1; attempt <= REPLAY_MAX_POLL_ATTEMPTS; attempt++) { JsonObject statusData = getReplayStatus(taskId); String status = statusData.get("status").getAsString(); @@ -856,6 +875,9 @@ private static FileInfo waitReplayDone(String taskId) { : null; throw new ReplayRenderException(status, error); } + if (timeout > 0 && System.currentTimeMillis() - start > timeout) { + throw new RuntimeException("回放渲染超时,请稍后重试。"); + } try { Thread.sleep(REPLAY_POLL_INTERVAL_MS); } catch (InterruptedException e) { @@ -994,6 +1016,28 @@ public static RenderStat getRenderStat(String jobId) { } } + public static RenderStat cancelReplayRender(String jobId) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(ENDPOINT + "/replays/" + jobId + "/cancel")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (codeNotOk(response.statusCode())) { + throw parseHttpError(response.body(), response.statusCode(), "取消回放渲染失败"); + } + RawResponse payload = GSON.fromJson(response.body(), RawResponse.class); + ensureApiSuccess(payload, "取消回放渲染失败"); + return GSON.fromJson(requireDataObject(payload, "取消回放渲染响应缺少data"), RenderStat.class); + } catch (IOException e) { + throw requestFailure(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Replay cancellation request interrupted", e); + } + } + public static ServerStatus getServerStatus() { boolean oStella = false; boolean osu = false; @@ -1028,8 +1072,7 @@ public static ServerStatus getServerStatus() { return new ServerStatus(true, oStella, oStellaVersion, osu); } - public static Response> getScoreMissesResponse(ShortcutTarget target) { - final String scoreId = lookupScoreId(target); + public static Response> getScoreMissesResponse(String scoreId) { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(ENDPOINT + "/scores/" + scoreId + "/misses")) @@ -1201,6 +1244,7 @@ public record ServerStatus(boolean gateway, boolean oStella, String oStellaVersi } public record ReplayRenderResult(String videoUrl, String taskId, FileInfo qqFile) { + @SuppressWarnings("unused") public ReplayRenderResult(String videoUrl, String taskId) { this(videoUrl, taskId, null); } diff --git a/src/main/java/xyz/zcraft/seira/api/ApiRequestException.java b/src/main/java/xyz/zcraft/seira/api/ApiRequestException.java index b7e044fe..2baa0192 100644 --- a/src/main/java/xyz/zcraft/seira/api/ApiRequestException.java +++ b/src/main/java/xyz/zcraft/seira/api/ApiRequestException.java @@ -48,6 +48,7 @@ public static String getDefaultMessage(Integer code) { case RENDER_QUEUE_FULL -> "回放渲染队列已满,请稍后再试喵"; case RENDERER_UNAVAILABLE -> "回放渲染服务暂不可用,请稍后再试喵"; case PERFORMANCE_PLUS_UNAVAILABLE -> "Performance+服务暂不可用,请稍后再试喵"; + case IMAGE_RENDER_TIMEOUT -> "超时了喵。"; }; } diff --git a/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java b/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java index 21a4b495..0d1c2577 100644 --- a/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java +++ b/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java @@ -6,6 +6,9 @@ public ReplayRenderException(String status, String error) { } static String formatMessage(String status, String error) { + if ("canceled".equalsIgnoreCase(status)) { + return "回放渲染已取消。"; + } if (error != null && !error.isBlank()) { String possibleReason = tryParseError(error); return "回放渲染失败" + (possibleReason != null ? ",这可能是由于" + possibleReason : "") + "。日志输出:\n```\n" + error.trim() + "\n```\n"; diff --git a/src/main/java/xyz/zcraft/seira/api/data/ErrorCode.java b/src/main/java/xyz/zcraft/seira/api/data/ErrorCode.java index 15a1b9fb..970eca98 100644 --- a/src/main/java/xyz/zcraft/seira/api/data/ErrorCode.java +++ b/src/main/java/xyz/zcraft/seira/api/data/ErrorCode.java @@ -32,7 +32,8 @@ public enum ErrorCode { RENDER_QUEUE_FULL(5001), RENDERER_UNAVAILABLE(5002), - PERFORMANCE_PLUS_UNAVAILABLE(5003); + PERFORMANCE_PLUS_UNAVAILABLE(5003), + IMAGE_RENDER_TIMEOUT(5004); private final int code; diff --git a/src/main/java/xyz/zcraft/seira/api/data/VideoRenderRecord.java b/src/main/java/xyz/zcraft/seira/api/data/VideoRenderRecord.java index 538be1c0..59c51ad4 100644 --- a/src/main/java/xyz/zcraft/seira/api/data/VideoRenderRecord.java +++ b/src/main/java/xyz/zcraft/seira/api/data/VideoRenderRecord.java @@ -17,7 +17,12 @@ public String getRenderTask(String uid) { return renderRecord.get(uid); } + @SuppressWarnings("unused") public void removeRenderTask(String uid) { renderRecord.remove(uid); } + + public void removeRenderTask(String uid, String jobId) { + renderRecord.remove(uid, jobId); + } } diff --git a/src/main/java/xyz/zcraft/seira/command/TargetHistory.java b/src/main/java/xyz/zcraft/seira/command/TargetHistory.java index 564dff75..eff75bab 100644 --- a/src/main/java/xyz/zcraft/seira/command/TargetHistory.java +++ b/src/main/java/xyz/zcraft/seira/command/TargetHistory.java @@ -1,38 +1,285 @@ package xyz.zcraft.seira.command; +import xyz.zcraft.seira.api.APIHelper; +import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.parse.Resolver; 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; +import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.function.Function; import java.util.function.Predicate; +import static xyz.zcraft.seira.command.reply.ReplyFactory.at; + public final class TargetHistory { - private final ConcurrentMap targets = new ConcurrentHashMap<>(); + private final ConcurrentMap users = new ConcurrentHashMap<>(); + private final Resolver resolver; + private final Function accessToken; + public TargetHistory(Resolver resolver, Function accessToken) { + this.resolver = resolver; + this.accessToken = accessToken; + } + + private static boolean isLocalId(String id) { + return !id.chars().allMatch(Character::isDigit); + } + + /** + * 显式记忆一个新目标,清除旧目标的关联 ID。 + */ + public void remember(Context ctx, long id, Type type) { + remember(ctx, Long.toString(id), type); + } + + public void remember(Context ctx, String id, Type type) { + Ids ids = new Ids(); + switch (type) { + case BEATMAPSET -> ids.beatmapsetId = Long.parseLong(id); + case BEATMAP -> ids.beatmapId = Long.parseLong(id); + case SCORE -> ids.scoreId = id; + } + users.put(ctx.senderUserId(), ids); + } + + /** + * 只取指定类型已经记住的 ID,不进行查找。 + */ + public ShortcutTarget get(Context ctx, Type type) { + Ids ids = users.get(ctx.senderUserId()); + if (ids == null) return null; + String id = switch (type) { + case BEATMAPSET -> ids.beatmapsetId == null ? null : ids.beatmapsetId.toString(); + case BEATMAP -> ids.beatmapId == null ? null : ids.beatmapId.toString(); + case SCORE -> ids.scoreId; + }; + if (id == null) return null; + if (type == Type.SCORE && isLocalId(id)) return ShortcutTarget.localScore(id); + return new ShortcutTarget(Long.parseLong(id), null, null, null, null); + } - public ShortcutTarget get(String userId) { - return targets.get(userId); + public void remember(Context ctx, Ids ids) { + users.put(ctx.senderUserId(), new Ids(ids)); } - public void put(String userId, ShortcutTarget target) { - targets.put(userId, target); + public Ids resolve(Context ctx, Type type, TargetResolution args) { + return resolve(ctx, type, args, List.of(), null); } - public TargetResolution resolveOptionalTarget( - Context context, - Resolver resolver, - Predicate isOptionalArgument - ) { - if (context.argumentCount() == 0 || isOptionalArgument.test(context.argument(0))) { - return new TargetResolution(get(context.senderUserId()), 0); + /** + * 查找只修改本次结果;调用者显式 remember 后才更新历史。 + */ + public Ids resolve(Context ctx, Type type, TargetResolution args, List filters, String mod) { + ShortcutTarget target = args.target(); + if (target != null && target.isError()) throw new ResolutionException(target.errorMessage()); + + Ids previous = users.get(ctx.senderUserId()); + // 省略目标时沿用三个 ID;显式输入目标时从空记忆开始。 + Ids ids = target == null ? new Ids(previous) : new Ids(); + UserRef player = args.userOverride() != null ? args.userOverride() + : target == null ? null : target.userRef(); + boolean selectedPlayerScore = false; + + if (target != null) { + if (target.isLocalScore()) { + ids.scoreId = target.localScoreId(); + } else if (!target.isMacro()) { + switch (type) { + case BEATMAPSET -> ids.beatmapsetId = target.explicitId(); + case BEATMAP -> ids.beatmapId = target.explicitId(); + case SCORE -> ids.scoreId = target.explicitId().toString(); + } + } else { + switch (target.macroType()) { + case "m" -> ids.beatmapId = target.explicitId(); + case "s" -> ids.scoreId = target.explicitId().toString(); + case "ms" -> { + ids.beatmapsetId = target.explicitId(); + if (type != Type.BEATMAPSET) { + if (target.macroIndex() == null) throw new ResolutionException("请指定指令目标谱面喵"); + if (type == Type.BEATMAP) { + ids.beatmapId = APIHelper.lookupBeatmap(target, accessToken.apply(ctx.senderUserId())); + } else { + player = requirePlayer(ctx, player); + ids.scoreId = APIHelper.lookupScoreId(new ShortcutTarget( + target.explicitId(), player, "ms", target.macroIndex(), null), filters, mod); + selectedPlayerScore = true; + } + } + } + case "rs", "rp", "bp" -> { + // 先把列表位置固定为实际成绩 ID,后续指令不再重新查询列表。 + player = requirePlayer(ctx, player); + ids.scoreId = APIHelper.lookupScoreId(new ShortcutTarget( + null, player, target.macroType(), target.macroIndex(), null), filters, mod); + selectedPlayerScore = true; + } + case "mp" -> { + if (type == Type.BEATMAPSET) { + ids.beatmapsetId = APIHelper.lookupBeatmapset(target, accessToken.apply(ctx.senderUserId())); + } else { + ids.beatmapId = APIHelper.lookupBeatmap(target, accessToken.apply(ctx.senderUserId())); + } + } + default -> throw new ResolutionException("未知的快捷查询"); + } + } + } + + // 指定用户或 Mods 时,按同一谱面重新查成绩,不能直接沿用旧成绩 ID。 + // /s rs2 @用户 已经选好了该用户的 rs2,不再改查谱面最佳成绩。 + if (type == Type.SCORE && (args.userOverride() != null || mod != null) + && ids.scoreId != null && !selectedPlayerScore) { + if (ids.beatmapId == null) { + ids.beatmapId = APIHelper.getScoreBeatmapId(ids.scoreId); + } + ids.scoreId = null; + } + + switch (type) { + case BEATMAP -> { + if (ids.beatmapId == null) { + if (ids.scoreId == null) throw new ResolutionException("请指定指令目标谱面喵"); + ids.beatmapId = APIHelper.getScoreBeatmapId(ids.scoreId); + } + } + case BEATMAPSET -> { + if (ids.beatmapsetId == null) { + if (ids.beatmapId == null && ids.scoreId != null) { + ids.beatmapId = APIHelper.getScoreBeatmapId(ids.scoreId); + } + if (ids.beatmapId == null) throw new ResolutionException("请指定指令目标喵"); + ids.beatmapsetId = APIHelper.lookupBeatmapset(new ShortcutTarget( + ids.beatmapId, null, "m", null, null), accessToken.apply(ctx.senderUserId())); + } + } + case SCORE -> { + if (ids.scoreId == null) { + if (ids.beatmapId == null) throw new ResolutionException("请指定指令目标谱面喵"); + player = requirePlayer(ctx, player); + ids.scoreId = APIHelper.lookupScoreId(new ShortcutTarget( + ids.beatmapId, player, "m", null, null), filters, mod); + } + } } - return resolver.resolveTargetWithOptionalMention(context.args(), context.senderUserId()); + return ids; } - public void rememberExplicitTarget(Context context, TargetResolution resolution) { - if (resolution.consumedArgs() > 0) { - put(context.senderUserId(), resolution.target()); + private UserRef requirePlayer(Context ctx, UserRef player) { + if (player != null) return player; + Long uid = resolver.resolveBoundUid(ctx.senderUserId()); + if (uid == null) throw new ResolutionException("请先绑定 osu! 账号,再查找记忆谱面上的成绩喵"); + return new UserRef.ByUid(uid); + } + + /** + * 同屏回放需要保留本地成绩本身,以便把它加入回放列表。 + */ + public boolean isLocalScore(Context ctx, TargetResolution args) { + if (args.target() != null) return args.target().isLocalScore(); + Ids ids = users.get(ctx.senderUserId()); + return ids != null && ids.scoreId != null && isLocalId(ids.scoreId); + } + + public TargetResolution parseArguments(Context ctx, String usage, int maxOptions) { + return parseArguments(ctx, usage, maxOptions, _ -> false); + } + + /** + * optional 判断首个参数是否是省略目标后的选项;返回的 consumedArgs 标记选项起点。 + */ + public TargetResolution parseArguments(Context ctx, String usage, int maxOptions, Predicate optional) { + try { + TargetResolution args; + if (ctx.argumentCount() == 0 || optional.test(ctx.argument(0))) { + if (!users.containsKey(ctx.senderUserId())) throw new ResolutionException(usage); + args = new TargetResolution(null, 0); + } else { + args = resolver.resolveTargetWithOptionalMention(ctx.args(), ctx.senderUserId()); + if (args.target().isError()) throw new ResolutionException(args.target().errorMessage()); + } + if (ctx.argumentCount() - args.consumedArgs() > maxOptions) throw new ResolutionException(usage); + return args; + } catch (ResolutionException e) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + e.getMessage())); + return null; + } + } + + public TargetResolution parseScoreArguments(Context ctx, String usage) { + return parseScoreArguments(ctx, usage, 0, _ -> false); + } + + /** + * 目标和可选用户在前,其余参数交给指令;这里不认识 +mod 等具体选项。 + */ + public TargetResolution parseScoreArguments(Context ctx, String usage, int maxOptions, Predicate optional) { + try { + TargetResolution args; + if (ctx.argumentCount() > 0 && resolver.looksLikeMention(ctx.argument(0)) + && (ctx.argumentCount() == 1 || optional.test(ctx.argument(1)))) { + UserRef player = parsePlayer(ctx.argument(0), usage); + if (!users.containsKey(ctx.senderUserId())) throw new ResolutionException(usage); + args = new TargetResolution(null, 1, player); + } else { + args = parseArguments(ctx, usage, Integer.MAX_VALUE, optional); + if (args == null) return null; + String next = args.nextArgument(ctx); + if (next != null && !optional.test(next)) { + args = new TargetResolution(args.target(), args.consumedArgs() + 1, parsePlayer(next, usage)); + } + } + if (ctx.argumentCount() - args.consumedArgs() > maxOptions) throw new ResolutionException(usage); + for (int i = args.consumedArgs(); i < ctx.argumentCount(); i++) { + if (!optional.test(ctx.argument(i))) throw new ResolutionException(usage); + } + return args; + } catch (ResolutionException e) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + e.getMessage())); + return null; + } + } + + private UserRef parsePlayer(String argument, String usage) { + UserRefResolution result = resolver.resolveUserRefArgument(argument); + if (result.errorMessage() != null) throw new ResolutionException(result.errorMessage()); + if (result.userRef() == null) throw new ResolutionException(usage); + return result.userRef(); + } + + public enum Type {BEATMAPSET, BEATMAP, SCORE} + + // 每个调用者只保存三个 ID。成绩 ID 使用字符串以兼容 loc... 本地成绩。 + public static final class Ids { + private Long beatmapsetId; + private Long beatmapId; + private String scoreId; + + Ids() { + } + + Ids(Ids previous) { + if (previous != null) { + beatmapsetId = previous.beatmapsetId; + beatmapId = previous.beatmapId; + scoreId = previous.scoreId; + } + } + + public Long beatmapsetId() { + return beatmapsetId; + } + + public Long beatmapId() { + return beatmapId; + } + + public String scoreId() { + return scoreId; } } } diff --git a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java index 988d6f7f..fe6fb070 100644 --- a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java +++ b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java @@ -3,6 +3,7 @@ import com.google.gson.Gson; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.ApiRequestException; import xyz.zcraft.seira.api.ReplayRenderException; @@ -20,9 +21,6 @@ import java.nio.channels.ClosedChannelException; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.BiFunction; -import java.util.function.Function; -import java.util.function.Supplier; import static xyz.zcraft.seira.command.reply.ReplyFactory.at; @@ -44,7 +42,7 @@ public TaskCoordinator( this.discordBridgeService = java.util.Objects.requireNonNull(discordBridgeService); } - static String resolveErrorMessage(Exception exception) { + public static String resolveErrorMessage(Exception exception) { Throwable cursor = exception; while (cursor != null) { switch (cursor) { @@ -77,48 +75,43 @@ public CommandReplyChannel openReplyChannel( return new OutboundReplyChannel(targetId, messageId, groupMessage, queueMessageInGroup); } + /** - * Runs a reusable queued API flow while leaving the number, type and timing - * of its replies entirely under the command handler's control. - * - * @return whether the action completed without throwing + * Tracks queue estimates and elapsed time; the caller executes the request directly. */ - @SuppressWarnings("UnusedReturnValue") - public boolean runApiRequest(Context ctx, String requestType, Runnable action) { + public RequestTiming beginRequest(Context ctx, String requestType) { long estimatedSeconds = apiRequestStats.estimateAndEnqueue(requestType); - ctx.sendQueueNotice(PendingMessage.ofMarkdownRaw( - at(ctx) + "请求已加入队列,预计等待时间" + estimatedSeconds + "秒。" - )); - - long startedAt = System.nanoTime(); + RequestTiming timing = new RequestTiming(requestType); try { - action.run(); - return true; - } catch (Exception e) { - ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + resolveErrorMessage(e))); - String message = e.getMessage(); - if (e instanceof ApiRequestException apiException) { - message += " - " + apiException.getDefaultMessage(); - } - LOG.error("Failed to execute command flow {}: {}", requestType, message, e); - return false; - } finally { - long elapsedMillis = Math.max(1L, (System.nanoTime() - startedAt) / 1_000_000L); - apiRequestStats.complete(requestType, elapsedMillis); + ctx.sendQueueNotice(PendingMessage.ofMarkdownRaw( + at(ctx) + "请求已加入队列,预计等待时间" + estimatedSeconds + "秒。")); + return timing; + } catch (RuntimeException e) { + timing.close(); + throw e; } } + public PendingMessage imageMessage(Response response, PendingMessage completion) { + UploadedImage image = messageSender.uploadImageToCos(response.getContent().bytes()); + return combineImageAndCompletion(image, completion); + } + public QqUploadRequest createVideoUploadRequest(Context ctx) { String targetId = ctx.inGroup() ? ctx.groupId() : ctx.senderUserId(); return messageSender.createVideoUploadRequest(targetId, ctx.inGroup()); } public APIHelper.ReplayRenderResult waitForReplay(APIHelper.ReplayTaskInfo taskInfo) { + return waitForReplay(taskInfo, -1); + } + + public APIHelper.ReplayRenderResult waitForReplay(APIHelper.ReplayTaskInfo taskInfo, long timeout) { if (taskInfo == null || taskInfo.taskId() == null || taskInfo.taskId().isBlank()) { throw new IllegalArgumentException("回放任务未返回有效请求ID,无法获取视频结果。"); } - APIHelper.ReplayRenderResult result = APIHelper.waitReplayVideo(taskInfo.taskId()); + APIHelper.ReplayRenderResult result = APIHelper.waitReplayVideo(taskInfo.taskId(), timeout); replayResults.put(taskInfo.taskId(), result); BotStat.incrementReplays(); return result; @@ -139,54 +132,6 @@ public void removeReplayResult(String taskId) { } } - public void runImageRequest( - Context ctx, - String requestType, - Supplier> creator, - BiFunction, PendingMessage> postProcessor - ) { - runApiRequest(ctx, requestType, () -> - ctx.sendReply(waitForImage(ctx, creator, postProcessor)) - ); - } - - public void runReplayRequest( - Context ctx, - String requestType, - Function creator, - BiFunction taskMessageCreator - ) { - runApiRequest(ctx, requestType, () -> { - APIHelper.ReplayTaskInfo taskInfo = creator.apply(createVideoUploadRequest(ctx)); - ctx.sendReply(taskMessageCreator.apply(ctx, taskInfo)); - - APIHelper.ReplayRenderResult result = waitForReplay(taskInfo); - if (result == null) { - ctx.sendReply(PendingMessage.ofString("回放视频生成失败,请稍后重试。")); - return; - } - - if (ctx.sendReply(replayVideoMessage(result)).success()) { - removeReplayResult(taskInfo.taskId()); - } - }); - } - - /** - * Waits for an image renderer and returns a sendable message without sending it. - */ - private PendingMessage waitForImage( - Context ctx, - Supplier> creator, - BiFunction, PendingMessage> postProcessor - ) { - Response response = creator.get(); - byte[] imageBytes = response.getContent().bytes(); - UploadedImage uploadedImage = messageSender.uploadImageToCos(imageBytes); - PendingMessage completionMessage = postProcessor.apply(ctx, response); - return combineImageAndCompletion(uploadedImage, completionMessage); - } - private PendingMessage combineImageAndCompletion(UploadedImage image, PendingMessage completionMessage) { String imageMarkdown = image.toMarkdown(); if (completionMessage instanceof MDMessage md) { @@ -204,6 +149,7 @@ private PendingMessage combineImageAndCompletion(UploadedImage image, PendingMes ); } + @NotNull public SendResult sendOutboundMessage(String targetId, String messageId, boolean groupMessage, PendingMessage pendingMsg, AtomicInteger messageSeqCounter) { Message message = new Message(); message.setMsgType(pendingMsg.getMsgType()); @@ -277,6 +223,24 @@ public SendResult sendOutboundMessage(String targetId, String messageId, boolean return new SendResult(uploadResult && sentMessage != null, sentMessage); } + public final class RequestTiming implements AutoCloseable { + private final String requestType; + private final long startedAt = System.nanoTime(); + private boolean closed; + + private RequestTiming(String requestType) { + this.requestType = requestType; + } + + @Override + public void close() { + if (closed) return; + closed = true; + apiRequestStats.complete(requestType, + Math.max(1L, (System.nanoTime() - startedAt) / 1_000_000L)); + } + } + private final class OutboundReplyChannel implements CommandReplyChannel { private final String targetId; private final String messageId; 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 b5671813..25251e58 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java @@ -1,5 +1,6 @@ package xyz.zcraft.seira.command.handler; +import org.jline.utils.Log; import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.data.Response; import xyz.zcraft.seira.api.data.SearchQuery; @@ -10,17 +11,20 @@ import xyz.zcraft.seira.command.TargetHistory; import xyz.zcraft.seira.command.TaskCoordinator; import xyz.zcraft.seira.command.parse.Resolver; -import xyz.zcraft.seira.command.parse.ShortcutTarget; -import xyz.zcraft.seira.command.parse.TargetResolution; import xyz.zcraft.seira.command.reply.CommandUsage; import xyz.zcraft.seira.command.reply.ReplyFactory; +import xyz.zcraft.seira.data.SendResult; import java.util.List; import java.util.function.Function; +import static xyz.zcraft.seira.command.TargetHistory.Type.BEATMAP; +import static xyz.zcraft.seira.command.TargetHistory.Type.BEATMAPSET; +import static xyz.zcraft.seira.command.reply.ReplyFactory.at; + public final class BeatmapCommandHandler { private final Resolver resolver; - private final TargetHistory lastTarget; + private final TargetHistory history; private final TaskCoordinator taskCoordinator; private final ReplyFactory replyFactory; private final VideoRenderRecord videoRenderRecord; @@ -28,14 +32,14 @@ public final class BeatmapCommandHandler { public BeatmapCommandHandler( Resolver resolver, - TargetHistory targetHistory, + TargetHistory history, TaskCoordinator taskCoordinator, ReplyFactory replyFactory, VideoRenderRecord videoRenderRecord, Function accessTokenProvider ) { this.resolver = resolver; - this.lastTarget = targetHistory; + this.history = history; this.taskCoordinator = taskCoordinator; this.replyFactory = replyFactory; this.videoRenderRecord = videoRenderRecord; @@ -43,270 +47,122 @@ public BeatmapCommandHandler( } public void handleDaily(Context ctx) { - taskCoordinator.runApiRequest(ctx, "Daily Challenge", () -> - ctx.sendReply(PendingMessage.ofMarkdownRaw(APIHelper.getDaily())) - ); + try (var timing = taskCoordinator.beginRequest(ctx, "Daily Challenge")) { + var daily = APIHelper.getDaily(); + ctx.sendReply(PendingMessage.ofMarkdownRaw(daily)); + } } public void handleM(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.M)); - return; - } - - String mod = ctx.args().length == targetResolution.consumedArgs() + 1 - ? ctx.args()[targetResolution.consumedArgs()] - : null; - - taskCoordinator.runImageRequest( - ctx, - "Beatmap", - () -> APIHelper.getBeatmapResponse(target, mod, accessTokenProvider.apply(ctx.senderUserId())), - replyFactory::beatmapMessage - ); - } else { - if (lastTarget.get(ctx.senderUserId()) != null) { - ShortcutTarget target = lastTarget.get(ctx.senderUserId()); - taskCoordinator.runImageRequest( - ctx, - "Beatmap", - () -> APIHelper.getBeatmapResponse(target, null, accessTokenProvider.apply(ctx.senderUserId())), - replyFactory::beatmapMessage - ); - return; - } - - ctx.sendReply(PendingMessage.ofString(CommandUsage.M)); + var target = history.parseArguments(ctx, CommandUsage.M, 1); + if (target == null) return; + try (var timing = taskCoordinator.beginRequest(ctx, "Beatmap")) { + var ids = history.resolve(ctx, BEATMAP, target); + history.remember(ctx, ids); + var response = APIHelper.getBeatmapResponse(ids.beatmapId(), target.nextArgument(ctx)); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.beatmapMessage(ctx, response))); } } 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; + var target = history.parseArguments(ctx, CommandUsage.BMA, 1); + if (target == null) return; + try (var timing = taskCoordinator.beginRequest(ctx, "Beatmap Analysis")) { + var ids = history.resolve(ctx, BEATMAP, target); + history.remember(ctx, ids); + var response = APIHelper.getBeatmapAnalysisResponse(ids.beatmapId(), target.nextArgument(ctx)); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.beatmapMessage(ctx, response))); } - taskCoordinator.runImageRequest( - ctx, - "Beatmap Analysis", - () -> APIHelper.getBeatmapAnalysisResponse( - target, null, accessTokenProvider.apply(ctx.senderUserId())), - replyFactory::beatmapMessage - ); } public void handleAp(Context ctx) { - ShortcutTarget target; - - if (ctx.args().length >= 1) { - TargetResolution targetResolution = resolver.resolveTargetWithOptionalMention(ctx.args(), ctx.senderUserId()); - target = targetResolution.target(); - if (target.isError()) { - ctx.sendReply(PendingMessage.ofString(target.errorMessage())); - return; - } - - lastTarget.put(ctx.senderUserId(), target); - } else { - if (lastTarget.get(ctx.senderUserId()) != null) { - target = lastTarget.get(ctx.senderUserId()); - } else { - ctx.sendReply(PendingMessage.ofString(CommandUsage.AP)); - return; - } + var target = history.parseArguments(ctx, CommandUsage.AP, Integer.MAX_VALUE); + if (target == null) return; + try (var timing = taskCoordinator.beginRequest(ctx, "Audio Preview")) { + var ids = history.resolve(ctx, BEATMAPSET, target); + history.remember(ctx, ids); + long id = ids.beatmapsetId(); + ctx.sendReply(PendingMessage.ofVoiceUrl("https://b.ppy.sh/preview/" + id + ".mp3").doUpload(false)); } - - taskCoordinator.runApiRequest(ctx, "Audio Preview", () -> { - final long id = APIHelper.lookupBeatmapset(target, accessTokenProvider.apply(ctx.senderUserId())); - ctx.sendReply(PendingMessage.ofVoiceUrl("https://b.ppy.sh/preview/" + id + ".mp3").doUpload(false)); - } - ); } 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; - - if (ctx.args().length >= 1) { - TargetResolution targetResolution = resolver.resolveTargetWithOptionalMention(ctx.args(), ctx.senderUserId()); - target = targetResolution.target(); - if (target.isError()) { - ctx.sendReply(PendingMessage.ofString(target.errorMessage())); + var target = history.parseArguments(ctx, CommandUsage.BPV, 1, arg -> arg.startsWith("+")); + if (target == null) return; + try (var timing = taskCoordinator.beginRequest(ctx, "Beatmap Preview Render")) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "正在获取谱面以及回放文件,请稍作等待喵...")); + var qqUpload = taskCoordinator.createVideoUploadRequest(ctx); + var ids = history.resolve(ctx, BEATMAP, target); + history.remember(ctx, ids); + var task = APIHelper.createBeatmapPreviewTask(ids.beatmapId(), target.nextArgument(ctx), qqUpload); + videoRenderRecord.updateRenderTask(ctx.senderUserId(), task.taskId()); + ctx.sendReply(replyFactory.replayMessage(ctx, task)); + + APIHelper.ReplayRenderResult result; + + try { + result = taskCoordinator.waitForReplay(task); + } catch (Exception e) { + Log.error("Error while waiting for replay", e); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + e.getMessage())); return; } - lastTarget.put(ctx.senderUserId(), target); - } else { - if (lastTarget.get(ctx.senderUserId()) != null) { - target = lastTarget.get(ctx.senderUserId()); - } else { - ctx.sendReply(PendingMessage.ofString(CommandUsage.BGP)); - return; - } - } - - taskCoordinator.runImageRequest( - ctx, - "Background Preview", - () -> APIHelper.getBeatmapBgResponse(target, accessTokenProvider.apply(ctx.senderUserId())), - replyFactory::bgpMessage - ); - } + SendResult sendResult = ctx.sendReply(taskCoordinator.replayVideoMessage(result)); - public void handleDl(Context ctx) { - ShortcutTarget target; - - if (ctx.args().length == 0) { - target = lastTarget.get(ctx.senderUserId()); - } else if (ctx.args().length <= 2) { - TargetResolution targetResolution = resolver.resolveTargetWithOptionalMention(ctx.args(), ctx.senderUserId()); - if (ctx.args().length != targetResolution.consumedArgs()) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.DL)); - return; + if (!sendResult.success()) { + sendResult = ctx.sendMessage(taskCoordinator.replayVideoMessage(result)); } - target = targetResolution.target(); - if (target.isError()) { - ctx.sendReply(PendingMessage.ofString(target.errorMessage())); - return; + if (sendResult.success()) { + taskCoordinator.removeReplayResult(task.taskId()); } - - lastTarget.put(ctx.senderUserId(), target); - } else { - target = null; } + } - if (target == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.DL)); - return; + public void handleBgp(Context ctx) { + var target = history.parseArguments(ctx, CommandUsage.BGP, Integer.MAX_VALUE); + if (target == null) return; + try (var timing = taskCoordinator.beginRequest(ctx, "Background Preview")) { + var ids = history.resolve(ctx, BEATMAP, target); + history.remember(ctx, ids); + var response = APIHelper.getBeatmapBgResponse(ids.beatmapId()); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.bgpMessage(ctx, response))); } - - taskCoordinator.runApiRequest(ctx, "Download Beatmap", () -> - ctx.sendReply(replyFactory.dlMessage( - ctx, - APIHelper.getLookupBeatmapsetResponse(target, accessTokenProvider.apply(ctx.senderUserId())) - )) - ); } - public void handleMs(Context ctx) { - ShortcutTarget target; - if (ctx.args().length == 0) { - target = lastTarget.get(ctx.senderUserId()); - } else if (ctx.args().length <= 2) { - TargetResolution targetResolution = resolver.resolveTargetWithOptionalMention(ctx.args(), ctx.senderUserId()); - if (ctx.args().length != targetResolution.consumedArgs()) { - ctx.sendReply(PendingMessage.ofString("用法:/ms <谱面集ID 或 快捷查询>")); - return; - } - target = targetResolution.target(); - if (target.isError()) { - ctx.sendReply(PendingMessage.ofString(target.errorMessage())); - return; - } - - lastTarget.put(ctx.senderUserId(), target); - } else { - target = null; + public void handleDl(Context ctx) { + var target = history.parseArguments(ctx, CommandUsage.DL, 0); + if (target == null) return; + try (var timing = taskCoordinator.beginRequest(ctx, "Download Beatmap")) { + var ids = history.resolve(ctx, BEATMAPSET, target); + history.remember(ctx, ids); + var response = APIHelper.getLookupBeatmapsetResponse(ids.beatmapsetId(), accessTokenProvider.apply(ctx.senderUserId())); + ctx.sendReply(replyFactory.dlMessage(ctx, response)); } + } - if (target == null) { - ctx.sendReply(PendingMessage.ofString("用法:/ms <谱面集ID 或 快捷查询>")); - return; + public void handleMs(Context ctx) { + var target = history.parseArguments(ctx, "用法:/ms <谱面集ID 或 快捷查询>", 0); + if (target == null) return; + try (var timing = taskCoordinator.beginRequest(ctx, "Beatmapset")) { + var ids = history.resolve(ctx, BEATMAPSET, target); + history.remember(ctx, ids); + var response = APIHelper.getBeatmapsetResponse(ids.beatmapsetId()); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.beatmapsetMessage(ctx, response))); } - - taskCoordinator.runImageRequest( - ctx, - "Beatmapset", - () -> APIHelper.getBeatmapsetResponse(target, accessTokenProvider.apply(ctx.senderUserId())), - replyFactory::beatmapsetMessage - ); } public void handleSms(Context ctx) { final SearchQuery searchQuery = resolver.resolveSearchQuery(ctx.query()); if (searchQuery == null) { - ctx.sendReply(PendingMessage.ofString("用法:/sms [#页数] <搜索关键字>")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "用法:/sms [#页数] <搜索关键字>")); return; } - taskCoordinator.runApiRequest(ctx, "Search Beatmapset", () -> { + try (var timing = taskCoordinator.beginRequest(ctx, "Search Beatmapset")) { Response> searchResponse = APIHelper.searchBeatmapSetResponse(searchQuery); ctx.sendReply(replyFactory.searchMessage(ctx, searchResponse, searchQuery)); - }); + } } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java index 26fe2a67..f87cb096 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java @@ -7,6 +7,8 @@ import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.services.BindingService; +import static xyz.zcraft.seira.command.reply.ReplyFactory.at; + public final class BindingCommandHandler { private final AppConfig config; private final ReplyFactory replyFactory; @@ -20,17 +22,17 @@ public BindingCommandHandler(AppConfig config, ReplyFactory replyFactory, Bindin public void handleBind(Context ctx) { if (ctx.senderUserId() == null || ctx.senderUserId().isBlank()) { - ctx.sendReply(PendingMessage.ofString("无法识别你的用户ID,暂时无法绑定。请稍后重试。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "无法识别你的用户ID,暂时无法绑定。请稍后重试。")); return; } if (UserDataStore.findBoundUid(ctx.senderUserId()) != null) { - ctx.sendReply(PendingMessage.ofString("你已经绑定了玩家ID,如果要更换绑定请先使用 /unbind 解绑当前玩家ID。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "你已经绑定了玩家ID,如果要更换绑定请先使用 /unbind 解绑当前玩家ID。")); return; } if (ctx.args().length != 0) { - ctx.sendReply(PendingMessage.ofString("用法(无需ID):/bind")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "用法(无需ID):/bind")); return; } @@ -46,17 +48,17 @@ public void handleBind(Context ctx) { public void handleUnbind(Context ctx) { if (ctx.senderUserId() == null || ctx.senderUserId().isBlank()) { - ctx.sendReply(PendingMessage.ofString("无法识别你的用户ID,暂时无法解绑。请稍后重试。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "无法识别你的用户ID,暂时无法解绑。请稍后重试。")); return; } if (ctx.args().length != 0) { - ctx.sendReply(PendingMessage.ofString("用法:/unbind")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "用法:/unbind")); return; } boolean removed = UserDataStore.unbind(ctx.senderUserId()); - ctx.sendReply(PendingMessage.ofString(removed + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + (removed ? "解绑成功。" - : "你当前还没有绑定玩家ID,无需解绑。")); + : "你当前还没有绑定玩家ID,无需解绑。"))); } public void handleClearHistory(Context ctx) { @@ -65,11 +67,11 @@ public void handleClearHistory(Context ctx) { return; } if (ctx.args().length != 0) { - ctx.sendReply(PendingMessage.ofString("用法:/clearhistory")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "用法:/clearhistory")); return; } int removed = UserDataStore.clearGroupMember(ctx.senderUserId()); - ctx.sendReply(PendingMessage.ofString("清除了 " + removed + " 条群聊记录。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "清除了 " + removed + " 条群聊记录。")); } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java index 63c83970..e598e5e7 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java @@ -8,6 +8,8 @@ import java.util.Locale; import java.util.Objects; +import static xyz.zcraft.seira.command.reply.ReplyFactory.at; + public final class DcsCommandHandler { private static final String USAGE = "用法:/dcs start .,或 /dcs stop"; @@ -18,12 +20,12 @@ public DcsCommandHandler(DiscordBridgeService bridgeService) { } private static void usage(Context ctx) { - ctx.sendReply(PendingMessage.ofString(USAGE)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + USAGE)); } public void handleDcs(Context ctx) { if (!ctx.inGroup()) { - ctx.sendReply(PendingMessage.ofString("/dcs 仅支持在群聊中使用。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "/dcs 仅支持在群聊中使用。")); return; } if (ctx.argumentCount() == 0) { @@ -44,22 +46,22 @@ private void handleStart(Context ctx) { } DcsTarget target = DcsTarget.parse(ctx.argument(1)); if (target == null) { - ctx.sendReply(PendingMessage.ofString("Discord 目标格式无效,应为 .。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "Discord 目标格式无效,应为 .。")); return; } final boolean b = ctx.sendMessage(PendingMessage.ofString("正在尝试开启 Discord 消息同步,请稍候...")).success(); if (!b) { - ctx.sendReply(PendingMessage.ofMarkdownRaw("由于缺少主动消息权限,无法添加消息同步!权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)~")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于缺少主动消息权限,无法添加消息同步!权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)~")); return; } DiscordBridgeService.BindResult result = bridgeService.bind(ctx.groupId(), target); if (!result.success()) { - ctx.sendReply(PendingMessage.ofString("开启 Discord 同步失败:" + result.message())); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "开启 Discord 同步失败:" + result.message())); return; } - ctx.sendReply(PendingMessage.ofString( + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "Discord 消息同步已开启:" + result.guildName() + " / #" + result.channelName() )); } @@ -70,8 +72,6 @@ private void handleStop(Context ctx) { return; } boolean removed = bridgeService.unbind(ctx.groupId()); - ctx.sendReply(PendingMessage.ofString( - removed ? "Discord 消息同步已解除。" : "当前群聊尚未开启 Discord 消息同步。" - )); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + (removed ? "Discord 消息同步已解除。" : "当前群聊尚未开启 Discord 消息同步。"))); } } 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 59cd63e3..e20a8b10 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/GeneralCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/GeneralCommandHandler.java @@ -9,12 +9,16 @@ 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.Notice; import xyz.zcraft.seira.data.UploadedImage; import xyz.zcraft.seira.data.UserRef; import xyz.zcraft.seira.services.DailyLuck; +import xyz.zcraft.seira.services.NoticeStore; import java.util.function.Predicate; +import static xyz.zcraft.seira.command.reply.ReplyFactory.at; + public final class GeneralCommandHandler { private final MessageSender messageSender; private final TaskCoordinator taskCoordinator; @@ -44,37 +48,36 @@ public void handleU(Context context) { } else { UserRefResolution target = resolver.resolveUserRefArgument(context.argument(0)); if (target.errorMessage() != null) { - context.sendReply(PendingMessage.ofString(target.errorMessage())); + context.sendReply(PendingMessage.ofMarkdownRaw(at(context) + target.errorMessage())); return; } userRef = target.userRef(); } if (userRef == null) { - context.sendReply(PendingMessage.ofString("用法:/u [玩家ID/用户名/@用户]")); + context.sendReply(PendingMessage.ofMarkdownRaw(at(context) + "用法:/u [玩家ID/用户名/@用户]")); return; } - taskCoordinator.runImageRequest( - context, - "User Info", - () -> APIHelper.getUserInfoResponse(userRef), - replyFactory::userInfoMessage - ); + try (var _ = taskCoordinator.beginRequest(context, "User Info")) { + var response = APIHelper.getUserInfoResponse(userRef); + var completion = replyFactory.userInfoMessage(context, response); + context.sendReply(taskCoordinator.imageMessage(response, completion)); + } } public void handleLuck(Context context) { if (context.argumentCount() != 0) { - context.sendReply(PendingMessage.ofString("用法:/luck")); + context.sendReply(PendingMessage.ofMarkdownRaw(at(context) + "用法:/luck")); return; } - taskCoordinator.runApiRequest(context, "Luck", () -> { + try (var _ = taskCoordinator.beginRequest(context, "Luck")) { DailyLuck.Luck luck = DailyLuck.getLuck(context.senderUserId()); Beatmapset mapset = APIHelper.getBeatmapsetRaw(luck.dailyMapset()); UploadedImage cover = messageSender.uploadImageToCos(mapset.getCovers().getCover()); context.sendReply(replyFactory.luckMessage(context, luck, mapset, cover)); - }); + } } public void handleInspect(Context context) { @@ -97,6 +100,35 @@ public void handleStat(Context context) { } public void handleUnknown(Context context) { - context.sendReply(PendingMessage.ofString("未知指令。使用/help获取帮助。")); + if (!context.inGroup()) { + context.sendReply(PendingMessage.ofMarkdownRaw(at(context) + "未知指令。使用/help获取帮助。")); + } + } + + public void handleNotice(Context context) { + if (context.argumentCount() != 0 && context.argumentCount() != 1) { + context.sendReply(PendingMessage.ofMarkdownRaw(at(context) + "用法:/notice [公告ID]")); + return; + } + + long noticeId; + + if (context.argumentCount() == 1) { + noticeId = Long.parseLong(context.argument(0)); + } else { + noticeId = NoticeStore.getNewestId(); + } + + StringBuilder sb = new StringBuilder(); + NoticeStore.getNotices().stream() + .filter(notice -> notice.id() == noticeId) + .filter(Notice::isActive) + .findFirst() + .ifPresentOrElse(notice -> { + sb.append(at(context)).append("公告#").append(notice.id()).append(" ").append(notice.title()).append("\n"); + sb.append(NoticeStore.getContentFor(notice)); + }, () -> sb.append(at(context)).append("未找到公告#").append(noticeId)); + + context.sendReply(PendingMessage.ofMarkdownRaw(sb.toString())); } } 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 1da47a28..cf161988 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java @@ -18,6 +18,8 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static xyz.zcraft.seira.command.reply.ReplyFactory.at; + public final class MultiplayerRoomWatchCommandHandler { private static final String USAGE = "用法:/mpwatch [start] <房间ID> [stable|lazer];" @@ -84,12 +86,12 @@ private static String formatRoom(RoomWatchView view) { } private static void usage(Context ctx) { - ctx.sendReply(PendingMessage.ofString(USAGE)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + USAGE)); } public void handleMpWatch(Context ctx) { if (!ctx.inGroup()) { - ctx.sendReply(PendingMessage.ofString("/mpwatch 仅支持群聊使用。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "/mpwatch 仅支持群聊使用。")); return; } @@ -117,7 +119,7 @@ private void handleStart(Context ctx, int argumentOffset) { if (startArgumentCount == 0) { final OsuToken osuToken = UserDataStore.findOsuToken(ctx.senderUserId()); if (osuToken == null) { - ctx.sendReply("由于未绑定账户,无法获取当前房间,请手动提供ID~"); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于未绑定账户,无法获取当前房间,请手动提供ID~")); return; } final Response multiplayerRoom = APIHelper.getMultiplayerRoom(osuToken.accessToken()); @@ -128,13 +130,13 @@ private void handleStart(Context ctx, int argumentOffset) { } if (target == null) { - ctx.sendReply(PendingMessage.ofString("房间 ID、链接或版本格式不正确。\n" + USAGE)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "房间 ID、链接或版本格式不正确。\n" + USAGE)); return; } - taskCoordinator.runApiRequest(ctx, "Start Multiplayer Room Watch", () -> { + try (var timing = taskCoordinator.beginRequest(ctx, "Start Multiplayer Room Watch")) { if (!ctx.sendMessage(PendingMessage.ofString("正在尝试启动多人房间监视……")).success()) { - ctx.sendReply(PendingMessage.ofString( + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于缺少主动消息权限,无法启动监视!权限配置请见:https://docs.seira.top/overview/use.html#extra-permission" )); return; @@ -144,23 +146,21 @@ private void handleStart(Context ctx, int argumentOffset) { ctx.groupId(), ctx.senderUserId(), target.version(), target.roomId() ); ctx.sendReply(PendingMessage.ofMarkdownRaw( - "已开始监视 `" + formatRoom(view) + "` 。" + at(ctx) + "已开始监视 `" + 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 + " 个多人房间监视。" - )); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + (stoppedCount == 0 + ? "当前群聊没有多人房间监视。" + : "已停止当前群聊的全部 " + stoppedCount + " 个多人房间监视。"))); return; } if (ctx.argumentCount() != 1) { @@ -168,11 +168,9 @@ private void handleStop(Context ctx) { return; } RoomWatchView stopped = watchService.stop(ctx.groupId(), ctx.senderUserId()); - ctx.sendReply(PendingMessage.ofString( - stopped == null - ? "你当前没有在本群启动多人房间监视。" - : "已停止你启动的监视:" + formatRoom(stopped) + "。" - )); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + (stopped == null + ? "你当前没有在本群启动多人房间监视。" + : "已停止你启动的监视:" + formatRoom(stopped) + "。"))); } private void handleStatus(Context ctx) { @@ -181,11 +179,9 @@ private void handleStatus(Context ctx) { return; } RoomWatchView view = watchService.get(ctx.groupId(), ctx.senderUserId()); - ctx.sendReply(PendingMessage.ofString( - view == null - ? "你当前没有在本群启动多人房间监视。" - : "你当前正在监视" + formatRoom(view) + "。" - )); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + (view == null + ? "你当前没有在本群启动多人房间监视。" + : "你当前正在监视" + formatRoom(view) + "。"))); } record RoomTarget(long roomId, MultiplayerRoomVersion version) { diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 5b855093..2b6cc407 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -9,32 +9,42 @@ 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.ShortcutTarget; import xyz.zcraft.seira.command.parse.UserRefResolution; import xyz.zcraft.seira.command.reply.ReplyFactory; import xyz.zcraft.seira.data.SendResult; import xyz.zcraft.seira.data.UserRef; import xyz.zcraft.seira.db.RankGuessRecordStore; import xyz.zcraft.seira.db.UserDataStore; -import xyz.zcraft.seira.rankguess.*; +import xyz.zcraft.seira.rankguess.HintUtil; +import xyz.zcraft.seira.rankguess.RankGuessGame; +import xyz.zcraft.seira.rankguess.RankGuessGameService; import xyz.zcraft.seira.rankguess.data.*; +import xyz.zcraft.seira.util.RandomReply; import java.util.*; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; import static xyz.zcraft.seira.command.reply.ReplyFactory.at; +import static xyz.zcraft.seira.command.reply.ReplyFactory.cmd; public final class RankGuessCommandHandler { - private static final Logger LOG = LogManager.getLogger(RankGuessCommandHandler.class); - private static final String USAGE = "用法:/rg start|group|#Rank|end|wish|stats|lb"; - private static final Pattern RANK_PATTERN = Pattern.compile("^#?(\\d+)[wk]?$"); + static final Logger LOG = LogManager.getLogger(RankGuessCommandHandler.class); + static final Pattern RANGE_PATTERN = Pattern.compile("^(\\d+)-(\\d+)$"); + static final int MAX_LEADERBOARD_RANGE = 50; + private static final Pattern RANK_PATTERN = Pattern.compile("^#?(\\d+(?:\\.\\d+)?)[wk]?$"); + private static final ScheduledExecutorService SCHEDULER = Executors.newSingleThreadScheduledExecutor(); private final TaskCoordinator taskCoordinator; private final ReplyFactory replyFactory; private final RankGuessGameService games; private final Resolver resolver; private final Predicate adminAuthorizer; + private final Pattern BP_PATTERN = Pattern.compile("^bp(\\d+)$"); public RankGuessCommandHandler( TaskCoordinator taskCoordinator, @@ -51,12 +61,12 @@ public RankGuessCommandHandler( } private static Long parseRank(String argument) { - final Matcher matcher = RANK_PATTERN.matcher(argument.replace(",","")); + final Matcher matcher = RANK_PATTERN.matcher(argument.replace(",", "")); if (!matcher.matches()) { return null; } try { - long base = Long.parseLong(matcher.group(1)); + double base = Double.parseDouble(matcher.group(1)); long multiplier = 1; if (argument.endsWith("w")) { @@ -65,15 +75,46 @@ private static Long parseRank(String argument) { multiplier = 1000; } - return Math.multiplyExact(base, multiplier); + double result = multiplier * base; + + if (!Double.isFinite(result)) { + throw new ArithmeticException("double overflow"); + } + + return (long) result; } catch (NumberFormatException | ArithmeticException _) { return null; } } + private LeaderboardRange parseLeaderboardRange(String raw) { + final Matcher matcher = RANGE_PATTERN.matcher(raw); + + if (!matcher.matches()) { + return null; + } + + try { + final int start = Integer.parseInt(matcher.group(1)); + final int end = Integer.parseInt(matcher.group(2)); + + if (start < 1 || end < start) { + return null; + } + + if (end - start + 1 > MAX_LEADERBOARD_RANGE) { + return null; + } + + return new LeaderboardRange(start, end); + } catch (NumberFormatException _) { + return null; + } + } + public void handleRankGuess(Context ctx) { if (!ctx.inGroup()) { - ctx.sendReply(PendingMessage.ofString("/rg 仅支持群聊使用。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "/rg 仅支持群聊使用。")); return; } @@ -83,75 +124,138 @@ public void handleRankGuess(Context ctx) { } String argument = ctx.argument(0); - if ("stats".equalsIgnoreCase(argument)) { - if (ctx.argumentCount() == 1) { - statistics(ctx, false); - } else if (ctx.argumentCount() == 2 && "all".equalsIgnoreCase(ctx.argument(1))) { - statistics(ctx, true); - } else { - ctx.sendReply(PendingMessage.ofString(USAGE)); - } - return; - } - if ("lb".equalsIgnoreCase(argument)) { - if (ctx.argumentCount() == 1) { - leaderboard(ctx, false); - } else if (ctx.argumentCount() == 2 && "all".equalsIgnoreCase(ctx.argument(1))) { - leaderboard(ctx, true); - } else { - ctx.sendReply(PendingMessage.ofString(USAGE)); + + switch (argument.toLowerCase()) { + case "stats" -> { + if (ctx.argumentCount() == 1) { + statistics(ctx, false, null); + } else if (ctx.argumentCount() == 2 && resolver.looksLikeMention(ctx.argument(1))) { + final String s = resolver.extractMentionedUserId(ctx.argument(1)); + statistics(ctx, false, s); + } else if (ctx.argumentCount() == 2 && "all".equalsIgnoreCase(ctx.argument(1))) { + statistics(ctx, true, null); + } else { + usage(ctx); + } + return; } - return; - } - if ("start".equalsIgnoreCase(argument)) { - if (ctx.argumentCount() == 2) { - final String arg = ctx.argument(1); - if ("group".equalsIgnoreCase(arg) || "g".equalsIgnoreCase(arg)) { - start(ctx, true); + case "lb" -> { + if (ctx.argumentCount() == 1) { + LeaderboardHandler.leaderboard(ctx, LeaderboardType.GROUP_SELF); + return; + } + + if (ctx.argumentCount() == 2) { + final String arg = ctx.argument(1); + + if ("all".equalsIgnoreCase(arg)) { + LeaderboardHandler.leaderboard(ctx, LeaderboardType.GROUP_FULL); + return; + } + + if ("global".equalsIgnoreCase(arg)) { + LeaderboardHandler.leaderboard(ctx, LeaderboardType.GLOBAL_SELF); + return; + } + + final LeaderboardRange range = parseLeaderboardRange(arg); + + if (range != null) { + LeaderboardHandler.leaderboard(ctx, LeaderboardType.GROUP_RANGE, range.start(), range.end()); + return; + } + + usage(ctx); return; } - } else if (ctx.argumentCount() == 1) { - start(ctx, false); + + if (ctx.argumentCount() == 3 && "global".equalsIgnoreCase(ctx.argument(1))) { + + final LeaderboardRange range = parseLeaderboardRange(ctx.argument(2)); + + if (range != null) { + LeaderboardHandler.leaderboard(ctx, LeaderboardType.GLOBAL_RANGE, range.start(), range.end()); + return; + } + } + + usage(ctx); return; } + case "start" -> { + if (ctx.argumentCount() == 2) { + final String arg = ctx.argument(1); + if ("group".equalsIgnoreCase(arg) || "g".equalsIgnoreCase(arg)) { + start(ctx, true); + return; + } + } else if (ctx.argumentCount() == 1) { + start(ctx, false); + return; + } - ctx.sendReply(PendingMessage.ofString(USAGE)); - return; - } - if ("group".equalsIgnoreCase(argument)) { - if (ctx.argumentCount() != 1) { - ctx.sendReply(PendingMessage.ofString(USAGE)); + usage(ctx); return; } + case "group" -> { + if (ctx.argumentCount() != 1) { + usage(ctx); + return; + } - start(ctx, true); - return; - } - if ("end".equalsIgnoreCase(argument)) { - if (ctx.argumentCount() != 1) { - ctx.sendReply(PendingMessage.ofString(USAGE)); + start(ctx, true); return; } - end(ctx, false); - return; - } - - if ("wish".equalsIgnoreCase(argument)) { - if (ctx.argumentCount() != 1) { - ctx.sendReply(PendingMessage.ofString(USAGE)); + case "end" -> { + if (ctx.argumentCount() != 1) { + usage(ctx); + return; + } + end(ctx, false); return; } - wish(ctx); - return; - } + case "wish" -> { + if (ctx.argumentCount() == 1) { + wish(ctx); + } else if (ctx.argumentCount() == 2) { + final Matcher matcher = BP_PATTERN.matcher(ctx.argument(1).toLowerCase()); + if (!matcher.matches()) { + usage(ctx); + return; + } + + final int i; + try { + i = Integer.parseInt(matcher.group(1)); + } catch (NumberFormatException _) { + usage(ctx); + return; + } - if ("weight".equalsIgnoreCase(argument)) { - if (ctx.argumentCount() != 1) { - ctx.sendReply(PendingMessage.ofString(USAGE)); + if (i <= 0 || i > 200) { + usage(ctx); + return; + } + + wishScore(ctx, i); + } else { + usage(ctx); + } + return; + } + case "weight" -> { + if (ctx.argumentCount() == 1) { + weight(ctx, false, null); + } else if (ctx.argumentCount() == 2 && resolver.looksLikeMention(ctx.argument(1))) { + final String s = resolver.extractMentionedUserId(ctx.argument(1)); + weight(ctx, false, s); + } else if (ctx.argumentCount() == 2 && "all".equalsIgnoreCase(ctx.argument(1))) { + weight(ctx, true, null); + } else { + usage(ctx); + } return; } - weight(ctx); - return; } Long rank; @@ -160,7 +264,8 @@ public void handleRankGuess(Context ctx) { final UserRefResolution userRefResolution = resolver.resolveUserRefArgument(argument); if (userRefResolution.errorMessage() != null) { - ctx.sendReply(userRefResolution.errorMessage()); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + userRefResolution.errorMessage())); + return; } final UserRef userRef = userRefResolution.userRef(); @@ -169,7 +274,7 @@ public void handleRankGuess(Context ctx) { } else { rank = parseRank(argument); if (rank == null) { - ctx.sendReply(PendingMessage.ofString(USAGE)); + usage(ctx); return; } } @@ -177,17 +282,23 @@ public void handleRankGuess(Context ctx) { guess(ctx, rank); } + private void usage(Context ctx) { + final String USAGE = "用法:/rg start|group|#Rank|end|wish|stats|lb|weight"; + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + USAGE)); + } + private void currentStatus(Context ctx) { final RankGuessGameService.GameStatus status = games.getStatus(ctx.groupId()); if (status == RankGuessGameService.GameStatus.NO_GAME) { - ctx.sendReply(PendingMessage.ofString("目前本群没有进行中的猜 Rank 游戏喵!可以使用 /rg group 或 /rg start 开始游戏喵~")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "目前本群没有进行中的猜 Rank 游戏喵!可以使用 " + + cmd("/rg group") + " 或 " + cmd("/rg start") + " 开始游戏喵~")); return; } final MessageReference videoMessageRef = games.getVideoMessageRef(ctx.groupId()); if (status == RankGuessGameService.GameStatus.STARTING || videoMessageRef == null) { - ctx.sendReply(PendingMessage.ofString("游戏即将开始,稍等片刻喵~")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "游戏即将开始,稍等片刻喵~")); return; } @@ -197,128 +308,95 @@ private void currentStatus(Context ctx) { ctx.sendReply(PendingMessage.ofString(reply).ref(games.getVideoMessageRef(ctx.groupId()))); } - private void weight(Context ctx) { - final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId()); + private void weight(Context ctx, boolean all, String target) { + final String effectiveTarget = target == null ? ctx.senderUserId() : target; + final Long boundUid = UserDataStore.findBoundUid(effectiveTarget); + + final String ref = target == null ? "你" : "对方"; + if (boundUid == null) { - ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于未绑定,无法查看权重喵~")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于" + ref + "未绑定,无法查看权重喵~")); return; } + + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "正在计算" + ref + "的权重喵~\n> Tip: 第一次计算可能耗时较长")); + StringBuilder reply = new StringBuilder(); final var probability = games.getProbabilityFor(ctx.groupId(), boundUid); final int totalPlayer = UserDataStore.findBoundUidsByGroup(ctx.groupId()).size(); - reply.append(at(ctx)).append("目前你在本群权重为 `%.2f`\n".formatted(probability.weight())); - reply.append("在本群 `%d` 名玩家中,你被选中的概率为 `%.3f%%`\n".formatted(totalPlayer, probability.chance() * 100)); + final String factors = String.join(",", probability.factors()); + + reply.append(at(ctx)).append("目前%s在本群权重为 `%.2f` (%s)\n".formatted(ref, probability.weight(), factors.isBlank() ? "基础权重" : factors)); + reply.append("在本群 `%d` 名玩家中,%s被选中的概率为 `%.3f%%`\n".formatted(totalPlayer, ref, probability.chance() * 100)); - final String randomScoreWeight = APIHelper.getRandomScoreWeight(boundUid, games.generateWeights(ctx.groupId())); + final String randomScoreWeight = APIHelper.getRandomScoreWeight(boundUid, games.generateWeights(ctx.groupId()), all); - reply.append("你的成绩在本群权重:\n>").append(randomScoreWeight).append("\n"); + reply.append("%s的成绩当前抽选概率:\n>".formatted(ref)).append(randomScoreWeight).append("\n"); ctx.sendReply(PendingMessage.ofMarkdownRaw(reply.toString().trim())); } - private void statistics(Context ctx, boolean allGroups) { - final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId()); + private void statistics(Context ctx, boolean allGroups, String target) { + final String effectiveTarget = target == null ? ctx.senderUserId() : target; + final Long boundUid = UserDataStore.findBoundUid(effectiveTarget); + final String ref = target == null ? "你" : "对方"; + if (boundUid == null) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于" + ref + "未绑定,无法查看战绩喵~")); + return; + } try { RankGuessRecordStore.Statistics.Personal statistics = RankGuessRecordStore.getPersonalStatistics( - ctx.senderUserId(), + effectiveTarget, allGroups ? null : ctx.groupId(), - null + null, + Rank.STATS_MIN_PARTICIPANTS ); RankGuessRecordStore.Statistics.Personal recentStatistics = RankGuessRecordStore.getRecentPersonalStatistics( - ctx.senderUserId(), + effectiveTarget, allGroups ? null : ctx.groupId(), null, - Rank.RECENT_GAME_LIMIT + Rank.RECENT_GAME_LIMIT, + Rank.STATS_MIN_PARTICIPANTS ); final Rank rank = Rank.from(recentStatistics, statistics); - Long groupGameCount = boundUid == null ? null : RankGuessRecordStore.getGroupGameCount(ctx.groupId(), null); - Long pickedTimes = boundUid == null ? null : RankGuessRecordStore.getPickedTimes(boundUid, ctx.groupId()); + Long groupGameCount = RankGuessRecordStore.getGroupGameCount(ctx.groupId(), null); + Long pickedTimes = RankGuessRecordStore.getPickedTimes(boundUid, ctx.groupId()); + Long gameStarted = RankGuessRecordStore.getGamesStarted(boundUid, ctx.groupId()); + RankGuessRecordStore.RankGuessed rankGuessed = RankGuessRecordStore.getAverageRankGuessed(boundUid, ctx.groupId()); ctx.sendReply(replyFactory.rankGuessStatisticsMessage( - ctx, statistics, recentStatistics, allGroups, rank, pickedTimes, groupGameCount + ctx, ref, statistics, recentStatistics, allGroups, rank, pickedTimes, groupGameCount, rankGuessed, gameStarted )); } catch (RuntimeException e) { LOG.error("Failed to query rank guess statistics", e); - ctx.sendReply(PendingMessage.ofString("战绩查询失败,请稍后重试喵。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "战绩查询失败,请稍后重试喵。")); } } - private void leaderboard(Context ctx, boolean all) { - try { - final List allGroupMembers = UserDataStore.findAllGroupMembers(ctx.groupId()); - final Map ranks = new HashMap<>(); - - for (String openId : allGroupMembers) { - if (!RankGuessRecordStore.canBeRanked(openId, ctx.groupId())) { - continue; - } - - RankGuessRecordStore.Statistics.Personal statistics = RankGuessRecordStore.getPersonalStatistics( - openId, - ctx.groupId(), - null - ); - - RankGuessRecordStore.Statistics.Personal recentStatistics = RankGuessRecordStore.getRecentPersonalStatistics( - openId, - ctx.groupId(), - null, - Rank.RECENT_GAME_LIMIT - ); - - ranks.put(openId, Rank.from(recentStatistics, statistics)); - } - - final List> groupRanks = ranks.entrySet().stream() - .sorted(Comparator.comparingDouble(entry -> entry.getValue().rating())) - .toList() - .reversed(); + private void wish(Context ctx) { + final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId()); - StringBuilder reply = new StringBuilder(); + if (boundUid == null) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于未绑定,无法进行许愿喵~")); + return; + } - if (all) { - reply.append(at(ctx)).append("本群猜 Rank 战绩排行:\n"); - for (int i = 0; i < groupRanks.size(); i++) { - final Map.Entry aRank = groupRanks.get(i); - reply.append("> __\\#").append(i + 1).append("__ ").append(at(aRank.getKey())).append(" (%.2f)".formatted(aRank.getValue().rating())).append("\n"); - } - } else { - if (!RankGuessRecordStore.canBeRanked(ctx.senderUserId(), ctx.groupId()) || !ranks.containsKey(ctx.senderUserId())) { - reply.append(at(ctx)).append("你还未在本群参加过猜 Rank,或参与次数不足喵~"); - } else { - int placement = 0; - for (int i = 0; i < groupRanks.size(); i++) { - if (groupRanks.get(i).getKey().equals(ctx.senderUserId())) { - placement = i + 1; - break; - } - } - reply.append(at(ctx)).append("你在本群猜 Rank 战绩排行第 __").append(placement).append("__ 名!\n"); - reply.append("以下是你附近的玩家:\n"); - for (int i = Math.max(0, placement - 1 - 2); i < groupRanks.size() && i < placement - 1 + 3; i++) { - final Map.Entry aRank = groupRanks.get(i); - reply.append("> __\\#").append(i + 1).append("__ ").append(at(aRank.getKey())) - .append(" (%.2f) (%+.3f)".formatted( - aRank.getValue().rating(), - groupRanks.get(placement - 1).getValue().rating() - aRank.getValue().rating()) - ).append("\n"); - } - } - } + final RankGuessGameService.WishResult wish = games.wish(ctx.groupId(), boundUid); - ctx.sendReply(PendingMessage.ofMarkdownRaw(reply.toString().trim())); - } catch (RuntimeException e) { - LOG.error("Failed to query rank guess statistics", e); - ctx.sendReply(PendingMessage.ofString("战绩查询失败,请稍后重试喵。")); - } + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + switch (wish) { + case SUCCESS -> "小星听到你的愿望啦!"; + case ALREADY_WISHED -> "已经许过愿了喵~"; + case RECENTLY_PICKED -> "最近已经被抽到过了喵~"; + case null -> "发生了一些不好的事情..."; + })); } - private void wish(Context ctx) { + private void wishScore(Context ctx, int index) { final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId()); if (boundUid == null) { @@ -326,7 +404,24 @@ private void wish(Context ctx) { return; } - final RankGuessGameService.WishResult wish = games.wish(ctx.groupId(), boundUid); + Long scoreId = null; + + try { + scoreId = Long.parseLong( + APIHelper.lookupScoreId(new ShortcutTarget( + null, new UserRef.ByUid(boundUid), "bp", (long) index, null) + , List.of(), null) + ); + } catch (Exception e) { + LOG.error("Failed to lookup score id", e); + } + + if (scoreId == null) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "获取成绩失败,请稍后再试喵~")); + return; + } + + final RankGuessGameService.WishResult wish = games.wishScore(ctx.groupId(), scoreId); ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + switch (wish) { case SUCCESS -> "小星听到你的愿望啦!"; @@ -339,160 +434,211 @@ private void wish(Context ctx) { private void start(Context ctx, boolean fromGroup) { Reservation reservation = games.reserve(ctx.groupId(), ctx.senderUserId(), fromGroup); if (reservation == null) { - ctx.sendReply(PendingMessage.ofString("本群已有一轮 Rank Guess 正在进行。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "本群已有一轮 Rank Guess 正在进行。")); return; } - AtomicBoolean activated = new AtomicBoolean(); - taskCoordinator.runApiRequest( - ctx, - "Rank Guess Render", - () -> { - final PendingMessage message = PendingMessage.ofMarkdownRaw(at(ctx) + "正在选定随机成绩..."); - final boolean activeMessageEnabled = ctx.sendMessage(message).success(); - if (!activeMessageEnabled) { - ctx.sendReply(message); - } + boolean activated = false; + try (var _ = taskCoordinator.beginRequest(ctx, "Rank Guess Render")) { + final PendingMessage message = PendingMessage.ofMarkdownRaw(at(ctx) + RandomReply.loading()); + final boolean activeMessageEnabled = ctx.sendMessage(message).success(); + if (!activeMessageEnabled) { + ctx.sendReply(message); + } - RandomScore randomScore; - - if (fromGroup) { - final List uids = UserDataStore.findBoundUidsByGroup(ctx.groupId()); - if (uids.isEmpty()) { - ctx.sendReply(PendingMessage.ofMarkdownRaw("本群没有绑定的用户,无法开始游戏喵")); - return; - } - randomScore = APIHelper.getRandomScoreFromUsers(uids, games.generateWeights(ctx.groupId())); - } else { - randomScore = APIHelper.getRandomScore(); - } + RandomScore randomScore; + + if (fromGroup) { + final List uids = UserDataStore.findBoundUidsByGroup(ctx.groupId()); + if (uids.isEmpty()) { + ctx.sendReply(PendingMessage.ofMarkdownRaw("本群没有绑定的用户,无法开始游戏喵")); + return; + } + randomScore = APIHelper.getRandomScoreFromUsers(uids, games.generateWeights(ctx.groupId())); + } else { + randomScore = APIHelper.getRandomScore(); + } - Round round = Round.from(randomScore); + Round round = Round.from(randomScore, activeMessageEnabled); - String content = at(ctx); + String content = at(ctx); - if (fromGroup) { - content += "随机群友及其成绩已选定"; - } else { - content += "随机用户与成绩已选定"; - } + if (fromGroup) { + content += "随机群友及其成绩已选定"; + } else { + content += "随机用户与成绩已选定"; + } - content += ",正在渲染回放片段..."; + content += ",正在渲染回放片段..."; - if (!activeMessageEnabled) { - content += "\n\n> 提示: 由于缺少主动消息权限,阶段提示与自动结束已禁用。稍后需要使用 `/rg end` 手动结束。权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)。"; - } + if (!activeMessageEnabled) { + content += "\n\n> 提示: 由于缺少主动消息权限,阶段提示与自动结束已禁用。稍后需要使用 `/rg end` 手动结束。权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)。"; + } - ctx.sendReply(PendingMessage.ofMarkdownRaw(content)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(content)); - var renderTask = APIHelper.createObscuredReplayRenderTask( - round.scoreId(), taskCoordinator.createVideoUploadRequest(ctx) - ); + var renderTask = APIHelper.createObscuredReplayRenderTask( + round.scoreId(), taskCoordinator.createVideoUploadRequest(ctx) + ); - APIHelper.ReplayRenderResult replay = null; - try { - replay = taskCoordinator.waitForReplay(renderTask); - } catch (Exception e) { - LOG.error("Failed to render replay for rank guess", e); - } + var warning = SCHEDULER.schedule( + () -> { + ctx.sendReply(PendingMessage.ofMarkdownRaw( + "回放渲染时间超过预期,可" + cmd("/rstat " + renderTask.taskId(), "点击查看渲染进度") + "喵~") + ); + }, + 60, + TimeUnit.SECONDS + ); - if (replay == null) { - ctx.sendReply(PendingMessage.ofMarkdownRaw("由于回放渲染失败,本轮游戏已取消~")); - return; - } + APIHelper.ReplayRenderResult replay = null; + try { + replay = taskCoordinator.waitForReplay(renderTask); + } catch (Exception e) { + LOG.error("Failed to render replay for rank guess", e); + } finally { + warning.cancel(false); + } - final SendResult sendResult = ctx.sendReply(taskCoordinator.replayVideoMessage(replay)); - boolean videoSent = sendResult.success(); - if (!videoSent) { - taskCoordinator.removeReplayResult(renderTask.taskId()); - ctx.sendReply(PendingMessage.ofMarkdownRaw("由于回放发送失败,本轮游戏已取消~")); - return; - } + if (replay == null) { + ctx.sendReply(PendingMessage.ofMarkdownRaw("由于回放渲染失败,本轮游戏已取消~")); + return; + } - taskCoordinator.removeReplayResult(renderTask.taskId()); - var game = games.activate(reservation, round, MessageReference.of(sendResult.sentMessage())); + final PendingMessage videoMessage = taskCoordinator.replayVideoMessage(replay); + SendResult sendResult = ctx.sendReply(videoMessage); - if (game == null) { - ctx.sendReply(PendingMessage.ofMarkdownRaw("无法开始游戏,请稍后再试喵")); - return; - } + if (!sendResult.success()) { + sendResult = ctx.sendMessage(videoMessage); + } - activated.set(true); + if (!sendResult.success()) { + taskCoordinator.removeReplayResult(renderTask.taskId()); + final PendingMessage cancelMessage = PendingMessage.ofMarkdownRaw("由于回放发送失败,本轮游戏已取消~"); + if (!ctx.sendReply(cancelMessage).success()) { + ctx.sendMessage(cancelMessage); + } + return; + } - StringBuilder result = new StringBuilder("回放渲染完成,游戏已开始!请在群内发送 `/rg #Rank` 猜测排名~"); + taskCoordinator.removeReplayResult(renderTask.taskId()); + var game = games.activate(reservation, round, MessageReference.of(sendResult.sentMessage())); - if (fromGroup) { - result.append("\n").append("__Tip: 这是一位群友的成绩喵~__").append("\n"); - } + if (game == null) { + ctx.sendReply(PendingMessage.ofMarkdownRaw("无法开始游戏,请稍后再试喵")); + return; + } - var hints = HintUtil.prepareHints(round.getNormalHints(), 4); + activated = true; - if (!activeMessageEnabled) { - result.append("\n").append("> 提示: "); - for (RankGuessGame.Hint s : hints) { - result.append("\n").append("> ").append(s.content()); - } - } else { - result.append("\n").append("> 第一个提示将在 1 分钟后揭晓~"); - } - boolean startMessageSent = ctx.sendReply(PendingMessage.ofMarkdownRaw(result.toString().trim())).success(); + StringBuilder result = new StringBuilder("回放渲染完成,游戏已开始!请在群内发送 `/rg #Rank` 猜测排名~"); - if (!activeMessageEnabled) { - if (startMessageSent) { - game.revealHints(hints); - } - return; - } + if (fromGroup) { + result.append("\n").append("__Tip: 这是一位群友的成绩喵~__").append("\n"); + } + + final List hintSource = new ArrayList<>(20); + int maxHintCount = 4; + + hintSource.addAll(round.getNormalHints()); + + if (fromGroup) { + hintSource.addAll(round.getGroupHints()); + } + + var hints = HintUtil.prepareHints(hintSource, maxHintCount); - boolean firstHint = true; + if (!activeMessageEnabled) { + result.append("\n").append("> 提示: "); + for (RankGuessGame.Hint s : hints) { + result.append("\n").append("> ").append(s.content()); + } + } else { + result.append("\n").append("> 第一个提示将在 1 分钟后揭晓~"); + } - StringBuilder hintString = new StringBuilder(); + boolean startMessageSent = ctx.sendReply( + PendingMessage.ofMarkdownRaw(result.toString().trim()) + ).success(); + + if (!activeMessageEnabled) { + if (startMessageSent) { + game.revealHints(hints); + } + return; + } - while (!hints.isEmpty() && !game.isEnded()) { - try { - //noinspection BusyWait - Thread.sleep((firstHint ? 60 : 30) * 1000); - } catch (InterruptedException _) { - Thread.currentThread().interrupt(); - break; - } + boolean firstHint = true; - firstHint = false; + StringBuilder hintString = new StringBuilder(); - if (game.isEnded()) { - return; - } + while (!hints.isEmpty() && !game.isEnded()) { + try { + //noinspection BusyWait + Thread.sleep((firstHint ? 60 : 30) * 1000); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + break; + } - final var hint = hints.removeFirst(); + if (game.isEnded()) { + return; + } - hintString.insert(0, "- " + hint.content() + "\n"); + boolean hasOutstandingGuess = game.getGuesses() + .values() + .stream() + .anyMatch(guess -> + RankGuessGameService.isOutstandingGuess( + guess.rank(), + game.getRound().actualRank(), + game.getGuesses().size(), + game.getRevealedHints().size(), + hints.size() + ) + ); + + if (hasOutstandingGuess && !firstHint) { + String hintContent = "__猜Rank提示:__\n" + + "- 有人已经做出了非常精准的猜测!游戏将在 30 秒后结束喵~\n" + + hintString; + + ctx.sendMessage(PendingMessage.ofMarkdownRaw(hintContent.trim())); + break; + } - String hintContent = "__猜Rank提示:__\n" + hintString; + firstHint = false; - if (!hints.isEmpty()) { - hintContent += "\n" + "> 下一个提示将在 30 秒后揭晓~"; - } else { - hintContent += "\n" + "> 所有提示已经揭晓啦!游戏将在 1 分钟后自动结束~"; - } + final RankGuessGame.Hint hint = hints.removeFirst(); - if (ctx.sendMessage(PendingMessage.ofMarkdownRaw(hintContent)).success()) { - game.revealHint(hint); - } - } + hintString.insert(0, "- " + hint.content() + "\n"); - try { - Thread.sleep(60 * 1000); - } catch (InterruptedException _) { - Thread.currentThread().interrupt(); - } + String hintContent = "__猜Rank提示:__\n" + hintString; - if (!game.isEnded()) { - end(ctx, true); - } + if (!hints.isEmpty()) { + hintContent += "\n> 下一个提示将在 30 秒后揭晓~"; + } else { + hintContent += "\n> 所有提示已经揭晓啦!游戏将在 30 秒后自动结束~"; } - ); - if (!activated.get()) { - games.cancel(reservation); + + if (ctx.sendMessage(PendingMessage.ofMarkdownRaw(hintContent)).success()) { + game.revealHint(hint); + } + } + + try { + Thread.sleep(30 * 1000); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + } + + if (!game.isEnded()) { + end(ctx, true); + } + } finally { + if (!activated) { + games.cancel(reservation); + } } } @@ -500,9 +646,9 @@ private void guess(Context ctx, long rank) { GuessResponse response = games.guess(ctx.groupId(), ctx.senderUserId(), rank); final GuessResult result = response.guessResult(); PendingMessage message = switch (result.status()) { - case NO_GAME -> PendingMessage.ofString("本群当前没有进行中的 Rank Guess 喵"); - case STARTING -> PendingMessage.ofString("回放仍在渲染,请等待视频发送后再猜测喵"); - case TOO_SOON -> PendingMessage.ofString("距离上次猜测不足20秒,无法修改猜测喵"); + case NO_GAME -> PendingMessage.ofMarkdownRaw(at(ctx) + "本群当前没有进行中的 Rank Guess 喵"); + case STARTING -> PendingMessage.ofMarkdownRaw(at(ctx) + "回放仍在渲染,请等待视频发送后再猜测喵"); + case TOO_SOON -> PendingMessage.ofMarkdownRaw(at(ctx) + "距离上次猜测不足20秒,无法修改猜测喵"); case UPDATED, RECORDED -> PendingMessage.ofMarkdownRaw( at(ctx) + "已" + (result.status() == RankGuessGameService.GuessStatus.UPDATED ? "更新" : "记录") + "你的猜测:" @@ -515,16 +661,6 @@ private void guess(Context ctx, long rank) { if (response.message() != null && !response.message().isBlank()) { ctx.sendReply(PendingMessage.ofMarkdownRaw(response.message())); } - - if (result.status() != RankGuessGameService.GuessStatus.UPDATED - && result.status() != RankGuessGameService.GuessStatus.RECORDED) { - return; - } - - if (rank == response.game().getRound().actualRank()) { - ctx.sendReply(PendingMessage.ofMarkdownRaw("看来已经有人知晓了答案喵!游戏将会自动结束~")); - end(ctx, true); - } } private void end(Context ctx, boolean force) { @@ -533,22 +669,264 @@ private void end(Context ctx, boolean force) { result = games.end(ctx.groupId(), ctx.senderUserId(), adminAuthorizer.test(ctx.senderUserId()), force); } catch (RankGuessRecordStore.RecordSaveException e) { LOG.error("Failed to record rank guess round in group {}", ctx.groupId(), e); - PendingMessage failure = PendingMessage.ofString("战绩保存失败,本轮尚未结算,请稍后使用 /rg end 重试喵。"); + PendingMessage failure = PendingMessage.ofMarkdownRaw(at(ctx) + "战绩保存失败,本轮尚未结算,请稍后使用 /rg end 重试喵。"); if (!ctx.sendReply(failure).success()) ctx.sendMessage(failure); return; } PendingMessage message = switch (result.status()) { - case NO_GAME -> PendingMessage.ofString("本群当前没有进行中的 Rank Guess 喵"); - case STARTING -> PendingMessage.ofString("高光仍在渲染,请等待视频发送后再结束游戏喵"); - case FORBIDDEN -> PendingMessage.ofString( + case NO_GAME -> PendingMessage.ofMarkdownRaw(at(ctx) + "本群当前没有进行中的 Rank Guess 喵"); + case STARTING -> PendingMessage.ofMarkdownRaw(at(ctx) + "高光仍在渲染,请等待视频发送后再结束游戏喵"); + case FORBIDDEN -> PendingMessage.ofMarkdownRaw(at(ctx) + "开始猜测后的3分钟内,仅发起者和机器人管理员可以结束游戏喵" ); - case FINISHED -> replyFactory.rankGuessResultMessage(ctx, result.round(), result.recorded()); + case FINISHED -> replyFactory.rankGuessResultMessage(ctx, result.round(), result.rankType()); }; if (!ctx.sendReply(message).success()) { ctx.sendMessage(message); } } + + enum LeaderboardType { + GROUP_SELF, GROUP_FULL, GROUP_RANGE, GLOBAL_SELF, GLOBAL_RANGE + } + + record LeaderboardRange(int start, int end) { + } +} + +final class LeaderboardHandler { + static void leaderboard(Context ctx, RankGuessCommandHandler.LeaderboardType type) { + leaderboard(ctx, type, null, null); + } + + static void leaderboard( + Context ctx, RankGuessCommandHandler.LeaderboardType type, Integer start, Integer end + ) { + try { + final StringBuilder reply = new StringBuilder(); + + final String effectiveGroupId = + (type == RankGuessCommandHandler.LeaderboardType.GLOBAL_SELF || type == RankGuessCommandHandler.LeaderboardType.GLOBAL_RANGE) + ? null + : ctx.groupId(); + + final Map rankData = + RankGuessRecordStore.getGroupRankData( + effectiveGroupId, + null, + Rank.RECENT_GAME_LIMIT, + Rank.STATS_MIN_PARTICIPANTS, + RankGuessGameService.MIN_GAMES_TO_RANK + ); + + final List> ranks = + rankData.entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), Rank.from(entry.getValue()))) + .filter(entry -> UserDataStore.findBoundUid(entry.getKey()) != null) + .sorted(Comparator.>comparingDouble( + entry -> entry.getValue().rating() + ) + .reversed() + .thenComparing(Map.Entry::getKey) + ) + .toList(); + + if (type == RankGuessCommandHandler.LeaderboardType.GROUP_RANGE || type == RankGuessCommandHandler.LeaderboardType.GLOBAL_RANGE) { + + if (start == null || end == null + || start < 1 || end < start || end - start + 1 > RankGuessCommandHandler.MAX_LEADERBOARD_RANGE) { + throw new IllegalArgumentException( + "Invalid leaderboard range: " + start + "-" + end + ); + } + } + + switch (type) { + case GROUP_FULL -> groupLeaderboard(ctx, reply, ranks, 0, ranks.size()); + case GROUP_RANGE -> groupLeaderboard(ctx, reply, ranks, start - 1, Math.min(end, ranks.size())); + case GROUP_SELF -> selfLeaderboard(ctx, reply, effectiveGroupId, ranks); + case GLOBAL_SELF -> globalLeaderboard(ctx, reply, ranks); + case GLOBAL_RANGE -> globalRangeLeaderboard(ctx, reply, ranks, start, end); + } + + ctx.sendReply(PendingMessage.ofMarkdownRaw(reply.toString().trim())); + } catch (RuntimeException e) { + RankGuessCommandHandler.LOG.error("Failed to query rank guess statistics", e); + + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "战绩查询失败,请稍后重试喵。")); + } + } + + private static void globalRangeLeaderboard( + Context ctx, StringBuilder reply, List> ranks, int start, int end + ) { + reply.append(at(ctx)) + .append("全局猜 Rank 战绩排行:\n"); + + final int from = start - 1; + final int to = Math.min(end, ranks.size()); + + if (from >= to) { + reply.append("> (该范围暂无玩家)"); + return; + } + + for (int i = from; i < to; i++) { + final Map.Entry rank = ranks.get(i); + + reply.append("> __\\#") + .append(i + 1) + .append("__: ") + .append(getLeaderboardName(rank.getKey())) + .append(" (%.2f)".formatted(rank.getValue().rating())) + .append("\n"); + } + } + + private static String getLeaderboardName(String userId) { + return Optional.ofNullable(userId) + .map(UserDataStore::findBoundUid) + .flatMap(UserDataStore::findUsername) + .orElse("未知"); + } + + private static void globalLeaderboard( + Context ctx, StringBuilder reply, List> ranks + ) { + final String userId = ctx.senderUserId(); + + if (!RankGuessRecordStore.canBeRanked(userId, null)) { + reply.append(at(ctx)) + .append("你还未在全局参加过猜 Rank,或参与次数不足喵~"); + return; + } + + int placement = -1; + + for (int i = 0; i < ranks.size(); i++) { + if (ranks.get(i).getKey().equals(userId)) { + placement = i; + break; + } + } + + if (placement < 0) { + reply.append(at(ctx)) + .append("你还未在全局参加过猜 Rank,或参与次数不足喵~"); + return; + } + + final double selfRating = + ranks.get(placement).getValue().rating(); + + reply.append(at(ctx)) + .append("你在全局猜 Rank 战绩排行第 __") + .append(placement + 1) + .append("__ 名!\n"); + + reply.append("以下是你附近的玩家:\n"); + + final int window = 5; + final int from = Math.max(0, placement - window); + final int to = Math.min(ranks.size(), placement + window + 1); + + for (int i = from; i < to; i++) { + final Map.Entry rank = ranks.get(i); + final double otherRating = rank.getValue().rating(); + + reply.append("> __\\#") + .append(i + 1) + .append("__: ") + .append(getLeaderboardName(rank.getKey())) + .append(" (%.2f) (%+.3f)".formatted( + otherRating, + otherRating - selfRating + )) + .append("\n"); + } + } + + private static void selfLeaderboard( + Context ctx, StringBuilder reply, String groupId, List> ranks + ) { + final String userId = ctx.senderUserId(); + + if (!RankGuessRecordStore.canBeRanked(userId, groupId)) { + reply.append(at(ctx)) + .append("你还未在本群参加过猜 Rank,或参与次数不足喵~"); + return; + } + + int placement = -1; + + for (int i = 0; i < ranks.size(); i++) { + if (ranks.get(i).getKey().equals(userId)) { + placement = i; + break; + } + } + + if (placement < 0) { + reply.append(at(ctx)).append("你还未在本群参加过猜 Rank,或参与次数不足喵~"); + return; + } + + final double selfRating = + ranks.get(placement).getValue().rating(); + + reply.append(at(ctx)) + .append("你在本群猜 Rank 战绩排行第 __") + .append(placement + 1) + .append("__ 名!\n"); + + reply.append("以下是你附近的玩家:\n"); + + final int window = 3; + final int from = Math.max(0, placement - window); + final int to = Math.min(ranks.size(), placement + window + 1); + + for (int i = from; i < to; i++) { + final Map.Entry rank = ranks.get(i); + final double otherRating = rank.getValue().rating(); + + reply.append("> __\\#") + .append(i + 1) + .append("__: ") + .append(getLeaderboardName(rank.getKey())) + .append(" (%.2f) (%+.3f)".formatted( + otherRating, + otherRating - selfRating + )) + .append("\n"); + } + } + + private static void groupLeaderboard( + Context ctx, StringBuilder reply, List> ranks, int from, int to + ) { + reply.append(at(ctx)).append("本群猜 Rank 战绩排行:\n"); + + if (ranks.isEmpty()) { + reply.append("> (暂无玩家)"); + return; + } + + if (from >= to) { + reply.append("> (该范围暂无玩家)"); + return; + } + + for (int i = from; i < to; i++) { + final Map.Entry rank = ranks.get(i); + + reply.append("> __\\#") + .append(i + 1) + .append("__: ") + .append(getLeaderboardName(rank.getKey())) + .append(" (%.2f)".formatted(rank.getValue().rating())) + .append("\n"); + } + } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java index b3357de2..ef781a40 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java @@ -1,5 +1,6 @@ package xyz.zcraft.seira.command.handler; +import org.jline.utils.Log; import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.data.VideoRenderRecord; import xyz.zcraft.seira.bot.data.PendingMessage; @@ -9,144 +10,165 @@ import xyz.zcraft.seira.command.TaskCoordinator; import xyz.zcraft.seira.command.parse.Resolver; import xyz.zcraft.seira.command.parse.RscTarget; -import xyz.zcraft.seira.command.parse.ShortcutTarget; -import xyz.zcraft.seira.command.parse.TargetResolution; import xyz.zcraft.seira.command.reply.CommandUsage; import xyz.zcraft.seira.command.reply.ReplyFactory; +import xyz.zcraft.seira.data.SendResult; import xyz.zcraft.seira.util.TimeDurationParser; -import java.util.function.Function; +import java.util.Objects; +import java.util.UUID; + +import static xyz.zcraft.seira.command.TargetHistory.Type.BEATMAP; +import static xyz.zcraft.seira.command.TargetHistory.Type.SCORE; +import static xyz.zcraft.seira.command.reply.ReplyFactory.at; public final class ReplayCommandHandler { private final Resolver resolver; - private final TargetHistory targetHistory; + private final TargetHistory history; private final TaskCoordinator taskCoordinator; private final ReplyFactory replyFactory; private final VideoRenderRecord videoRenderRecord; private final ReplayResultStore replayResults; - private final Function accessTokenProvider; public ReplayCommandHandler( Resolver resolver, - TargetHistory targetHistory, + TargetHistory history, TaskCoordinator taskCoordinator, ReplyFactory replyFactory, VideoRenderRecord videoRenderRecord, - ReplayResultStore replayResults, - Function accessTokenProvider + ReplayResultStore replayResults ) { this.resolver = resolver; - this.targetHistory = targetHistory; + this.history = history; this.taskCoordinator = taskCoordinator; this.replyFactory = replyFactory; this.videoRenderRecord = videoRenderRecord; this.replayResults = replayResults; - this.accessTokenProvider = accessTokenProvider; } public void handleR(Context ctx) { - TargetResolution targetResolution = targetHistory.resolveOptionalTarget(ctx, resolver, TimeDurationParser::isTimeRange); - if (ctx.args().length - targetResolution.consumedArgs() > 1) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.R)); - return; - } - - ShortcutTarget target = targetResolution.target(); - if (target == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.R)); - return; - } - if (target.isError()) { - ctx.sendReply(PendingMessage.ofString(target.errorMessage())); - return; - } + var target = history.parseArguments(ctx, CommandUsage.R, 1, TimeDurationParser::isTimeRange); + if (target == null) return; TimeDurationParser.TimeRange range = null; - if (ctx.args().length > targetResolution.consumedArgs()) { + if (ctx.args().length > target.consumedArgs()) { try { - range = TimeDurationParser.parseRange(ctx.args()[targetResolution.consumedArgs()]); + range = TimeDurationParser.parseRange(ctx.args()[target.consumedArgs()]); } catch (IllegalArgumentException e) { - ctx.sendReply(PendingMessage.ofString("无法解析时间范围")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "无法解析时间范围")); return; } } - targetHistory.rememberExplicitTarget(ctx, targetResolution); - - TimeDurationParser.TimeRange finalRange = range; - taskCoordinator.runReplayRequest( - ctx, - "Score Render", - qqUpload -> { - APIHelper.ReplayTaskInfo task = APIHelper.createReplayRenderTask(target, finalRange, qqUpload); - videoRenderRecord.updateRenderTask(ctx.senderUserId(), task.taskId()); - return task; - }, - replyFactory::replayMessage); + try (var _ = taskCoordinator.beginRequest(ctx, "Score Render")) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "正在获取谱面以及回放文件,请稍作等待喵...")); + var ids = history.resolve(ctx, SCORE, target); + history.remember(ctx, ids); + var upload = taskCoordinator.createVideoUploadRequest(ctx); + var task = APIHelper.createReplayRenderTask(ids.scoreId(), range, upload); + videoRenderRecord.updateRenderTask(ctx.senderUserId(), task.taskId()); + ctx.sendReply(replyFactory.replayMessage(ctx, task)); + + APIHelper.ReplayRenderResult result; + + try { + result = taskCoordinator.waitForReplay(task); + } catch (Exception e) { + Log.error("Error while waiting for replay", e); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + e.getMessage())); + return; + } + + SendResult sendResult = ctx.sendReply(taskCoordinator.replayVideoMessage(result)); + + if (!sendResult.success()) { + sendResult = ctx.sendMessage(taskCoordinator.replayVideoMessage(result)); + } + + if (sendResult.success()) { + replayResults.remove(task.taskId()); + } + } } public void handleRsc(Context ctx) { if (ctx.groupId() == null || ctx.groupId().isBlank()) { - ctx.sendReply(PendingMessage.ofString("/rsc 仅支持群聊使用。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "/rsc 仅支持群聊使用。")); return; } - TargetResolution targetResolution = targetHistory.resolveOptionalTarget( - ctx, - resolver, - arg -> arg.startsWith("+") || arg.startsWith("=") - ); - ShortcutTarget target = targetResolution.target(); - if (target == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.RSC)); - return; - } - if (target.isError()) { - ctx.sendReply(PendingMessage.ofString(target.errorMessage())); - return; - } + var target = history.parseArguments(ctx, CommandUsage.RSC, Integer.MAX_VALUE, + arg -> arg.startsWith("+") || arg.startsWith("=")); + if (target == null) return; String extraUidArg = null; - int i = targetResolution.consumedArgs(); + int i = target.consumedArgs(); if (i < ctx.args().length) { if (ctx.args()[i].startsWith("+") || ctx.args()[i].startsWith("=")) { extraUidArg = ctx.query().substring(Math.max(ctx.query().indexOf("+"), ctx.query().indexOf("="))); } else { - ctx.sendReply(PendingMessage.ofString(CommandUsage.RSC)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.RSC)); return; } } - RscTarget rscTarget = target.isLocalScore() && extraUidArg == null + RscTarget rscTarget = history.isLocalScore(ctx, target) && extraUidArg == null ? new RscTarget(new String[0], null) : resolver.resolveRscTarget(ctx.groupId(), extraUidArg); if (rscTarget.errorMessage() != null) { - ctx.sendReply(PendingMessage.ofString(rscTarget.errorMessage())); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + rscTarget.errorMessage())); return; } - String[] targetsArray = rscTarget.targets(); + try (var _ = taskCoordinator.beginRequest(ctx, "Showcase Render")) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "正在获取谱面以及回放文件,请稍作等待喵...")); + var targetType = history.isLocalScore(ctx, target) ? SCORE : BEATMAP; + var resolved = history.resolve(ctx, targetType, target); + history.remember(ctx, resolved); + var upload = taskCoordinator.createVideoUploadRequest(ctx); + String[] scoreTargets = rscTarget.targets(); + long beatmapId; + if (targetType == SCORE) { + var ids = new java.util.LinkedHashSet(); + ids.add("s" + resolved.scoreId()); + java.util.Collections.addAll(ids, scoreTargets); + scoreTargets = ids.toArray(String[]::new); + beatmapId = APIHelper.getScoreBeatmapId(resolved.scoreId()); + } else { + beatmapId = resolved.beatmapId(); + } + var task = APIHelper.createReplayShowcaseTask(beatmapId, scoreTargets, upload); + videoRenderRecord.updateRenderTask(ctx.senderUserId(), task.taskId()); + ctx.sendReply(replyFactory.replayMessage(ctx, task)); - targetHistory.rememberExplicitTarget(ctx, targetResolution); + APIHelper.ReplayRenderResult result; - taskCoordinator.runReplayRequest( - ctx, - "Showcase Render", - qqUpload -> { - var task = APIHelper.createReplayShowcaseTask( - target, targetsArray, accessTokenProvider.apply(ctx.senderUserId()), qqUpload); - videoRenderRecord.updateRenderTask(ctx.senderUserId(), task.taskId()); - return task; - }, - replyFactory::replayMessage); + try { + result = taskCoordinator.waitForReplay(task); + } catch (Exception e) { + Log.error("Error while waiting for replay", e); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + e.getMessage())); + return; + } + + SendResult sendResult = ctx.sendReply(taskCoordinator.replayVideoMessage(result)); + + if (!sendResult.success()) { + sendResult = ctx.sendMessage(taskCoordinator.replayVideoMessage(result)); + } + + if (sendResult.success()) { + replayResults.remove(task.taskId()); + } + } } public void handleRstat(Context ctx) { if (ctx.args().length != 1 && ctx.args().length != 0) { - ctx.sendReply(PendingMessage.ofString("用法:/rstat [任务ID]")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "用法:/rstat [任务ID]")); return; } @@ -155,7 +177,7 @@ public void handleRstat(Context ctx) { if (videoRenderRecord.hasRenderTask(ctx.senderUserId())) { jobId = videoRenderRecord.getRenderTask(ctx.senderUserId()); } else { - ctx.sendReply(PendingMessage.ofString("未找到渲染请求")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "未找到渲染请求")); return; } } else { @@ -176,4 +198,36 @@ public void handleRstat(Context ctx) { ctx.sendReply(replyFactory.replayStatMessage(ctx, jobId, APIHelper.getRenderStat(jobId))); } + public void handleRcancel(Context ctx) { + if (ctx.args().length != 1) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.RCANCEL)); + return; + } + + String jobId = ctx.args()[0]; + try { + if (!UUID.fromString(jobId).toString().equalsIgnoreCase(jobId)) { + throw new IllegalArgumentException("Non-canonical UUID"); + } + } catch (IllegalArgumentException e) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "渲染任务 ID 格式无效。\n" + CommandUsage.RCANCEL)); + return; + } + + var result = APIHelper.cancelReplayRender(jobId); + String status = Objects.toString(result.getStatus(), "unknown").toLowerCase(); + String message = switch (status) { + case "canceled" -> "回放渲染已取消。"; + case "done" -> "该回放已经渲染完成,无法取消。"; + case "failed" -> "该回放渲染已经失败,无需取消。"; + case "timeout" -> "该回放渲染已经超时,无需取消。"; + default -> "该回放当前状态为 `" + status + "`,无法取消。"; + }; + if ("canceled".equals(status)) { + replayResults.remove(jobId); + videoRenderRecord.removeRenderTask(ctx.senderUserId(), jobId); + } + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + message)); + } + } 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 0c69c177..866b9255 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java @@ -10,22 +10,30 @@ import xyz.zcraft.seira.command.reply.ReplyFactory; import xyz.zcraft.seira.data.UserRef; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static xyz.zcraft.seira.command.TargetHistory.Type.SCORE; +import static xyz.zcraft.seira.command.reply.ReplyFactory.at; + public final class ScoreCommandHandler { private static final int MAX_SCORE_LIST_COUNT = 200; + private static final Pattern SCORE_LIST_RANGE_PATTERN = Pattern.compile("^(\\d+)(?:-(\\d+))?$"); private final Resolver resolver; - private final TargetHistory targetHistory; + private final TargetHistory history; private final TaskCoordinator taskCoordinator; private final ReplyFactory replyFactory; public ScoreCommandHandler( Resolver resolver, - TargetHistory targetHistory, + TargetHistory history, TaskCoordinator taskCoordinator, ReplyFactory replyFactory ) { this.resolver = resolver; - this.targetHistory = targetHistory; + this.history = history; this.taskCoordinator = taskCoordinator; this.replyFactory = replyFactory; } @@ -46,58 +54,60 @@ static TbArguments parseTbArguments(String[] args) { return new TbArguments(days, args.length > targetIndex ? args[targetIndex] : null); } - public void handleBo(Context ctx) { + public void handleBp(Context ctx) { if (ctx.args().length == 0) { - ShortcutTarget target = resolver.parseTarget("bo1", ctx.senderUserId()); + ShortcutTarget target = resolver.parseTarget("bp1", ctx.senderUserId()); if (target.isError()) { - ctx.sendReply(PendingMessage.ofString(target.errorMessage())); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + target.errorMessage())); return; } - - taskCoordinator.runImageRequest( - ctx, - "Score", - () -> APIHelper.getScoreResponse(target), - replyFactory::scoreMessage - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Score")) { + var ids = history.resolve(ctx, SCORE, new TargetResolution(target, 0)); + history.remember(ctx, ids); + String scoreId = ids.scoreId(); + var response = APIHelper.getScoreResponse(scoreId); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.scoreMessage(ctx, response))); + } return; } if (resolver.looksLikeMention(ctx.args()[0])) { if (ctx.args().length == 1 || (ctx.args().length > 1 && ScoreFilterArguments.looksLikeFilter(ctx.args()[1]))) { - handleFilteredSingleScore(ctx, "bo"); + handleFilteredSingleScore(ctx, "bp"); return; } } else if (ScoreFilterArguments.looksLikeFilter(ctx.args()[0])) { - handleFilteredSingleScore(ctx, "bo"); + handleFilteredSingleScore(ctx, "bp"); return; } - ScoreListRequest request = parseScoreListRequest(ctx, CommandUsage.BO); + ScoreListRequest request = parseScoreListRequest(ctx, CommandUsage.BP); if (request == null) return; - - taskCoordinator.runImageRequest( - ctx, - "Best Scores", - () -> APIHelper.getBoNResponse(request.count(), request.userRef(), request.filters()), - replyFactory::boMessage - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Best Scores")) { + var response = APIHelper.getBoNResponse( + request.range().end(), + request.range().start(), + request.userRef(), + request.filters() + ); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.bpMessage(ctx, response))); + } } 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())); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + target.errorMessage())); return; } - - taskCoordinator.runImageRequest( - ctx, - "Score", - () -> APIHelper.getScoreResponse(target), - replyFactory::scoreMessage - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Score")) { + var ids = history.resolve(ctx, SCORE, new TargetResolution(target, 0)); + history.remember(ctx, ids); + String scoreId = ids.scoreId(); + var response = APIHelper.getScoreResponse(scoreId); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.scoreMessage(ctx, response))); + } return; } @@ -113,19 +123,22 @@ public void handleRs(Context ctx, boolean includeFail) { 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 - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Recent Score")) { + var response = APIHelper.getRecentResponse( + request.range().end(), + request.range().start(), + request.userRef(), + includeFail, + request.filters() + ); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.rsMessage(ctx, response))); + } } public void handleTb(Context ctx) { TbArguments request = parseTbArguments(ctx.args()); if (request == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.TB)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.TB)); return; } @@ -133,30 +146,28 @@ public void handleTb(Context ctx) { if (request.target() != null) { UserRefResolution resolution = resolver.resolveUserRefArgument(request.target()); if (resolution.errorMessage() != null) { - ctx.sendReply(PendingMessage.ofString(resolution.errorMessage())); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + resolution.errorMessage())); return; } if (resolution.userRef() == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.TB)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.TB)); return; } userRef = resolution.userRef(); } else { Long uid = resolver.resolveBoundUid(ctx.senderUserId()); if (uid == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.NO_BIND)); return; } userRef = new UserRef.ByUid(uid); } UserRef target = userRef; - taskCoordinator.runImageRequest( - ctx, - "Recent Best Scores", - () -> APIHelper.getTodayBestResponse(target, request.days()), - replyFactory::tbMessage - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Recent Best Scores")) { + var response = APIHelper.getTodayBestResponse(target, request.days()); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.tbMessage(ctx, response))); + } } private void handleFilteredSingleScore(Context ctx, String macroType) { @@ -166,7 +177,7 @@ private void handleFilteredSingleScore(Context ctx, String macroType) { if (resolver.looksLikeMention(ctx.args()[0]) || resolver.looksLikeUid(ctx.args()[0])) { final UserRefResolution userRefResolution = resolver.resolveUserRefArgument(ctx.args()[0]); if (userRefResolution.errorMessage() != null) { - ctx.sendReply(PendingMessage.ofString(userRefResolution.errorMessage())); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + userRefResolution.errorMessage())); return; } targetUser = userRefResolution.userRef(); @@ -175,33 +186,35 @@ private void handleFilteredSingleScore(Context ctx, String macroType) { ScoreFilterArguments.ParseResult filters = ScoreFilterArguments.parse(ctx.args(), startIndex); if (filters.isError()) { - ctx.sendReply(PendingMessage.ofString(filters.errorMessage() + "\n" + CommandUsage.SCORE_FILTERS)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + 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)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + 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 - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Score")) { + var ids = history.resolve(ctx, SCORE, new TargetResolution(target, 0), filters.filters(), null); + history.remember(ctx, ids); + String scoreId = ids.scoreId(); + var response = APIHelper.getScoreResponse(scoreId); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.scoreMessage(ctx, response))); + } } 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 + " 之间。")); + ScoreListRange range = parseScoreListRange(args[0]); + if (range == null) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + usage + + "\n数量或范围必须在 1 到 " + MAX_SCORE_LIST_COUNT + " 之间,且范围起点不能大于终点。")); return null; } @@ -210,11 +223,11 @@ private ScoreListRequest parseScoreListRequest(Context ctx, String usage) { if (nextArg < args.length && (resolver.looksLikeMention(args[nextArg]) || resolver.looksLikeUid(args[nextArg]))) { UserRefResolution resolution = resolver.resolveUserRefArgument(args[nextArg]); if (resolution.errorMessage() != null) { - ctx.sendReply(PendingMessage.ofString(resolution.errorMessage())); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + resolution.errorMessage())); return null; } if (resolution.userRef() == null) { - ctx.sendReply(PendingMessage.ofString(usage)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + usage)); return null; } userRef = resolution.userRef(); @@ -222,7 +235,7 @@ private ScoreListRequest parseScoreListRequest(Context ctx, String usage) { } else { Long uid = resolver.resolveBoundUid(ctx.senderUserId()); if (uid == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.NO_BIND)); return null; } userRef = new UserRef.ByUid(uid); @@ -230,130 +243,102 @@ private ScoreListRequest parseScoreListRequest(Context ctx, String usage) { ScoreFilterArguments.ParseResult filters = ScoreFilterArguments.parse(args, nextArg); if (filters.isError()) { - ctx.sendReply(PendingMessage.ofString(filters.errorMessage() + "\n" + CommandUsage.SCORE_FILTERS)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + filters.errorMessage() + "\n" + CommandUsage.SCORE_FILTERS)); return null; } - return new ScoreListRequest(count, userRef, filters.filters()); + return new ScoreListRequest(range, userRef, filters.filters()); } - public void handleS(Context ctx) { - ShortcutTarget target; - if (ctx.args().length == 0) { - target = targetHistory.get(ctx.senderUserId()); - } else if (ctx.args().length <= 2) { - TargetResolution targetResolution = resolver.resolveTargetWithOptionalMention(ctx.args(), ctx.senderUserId()); - - if (ctx.args().length != targetResolution.consumedArgs()) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.S)); - return; - } - - target = targetResolution.target(); + static ScoreListRange parseScoreListRange(String value) { + Matcher matcher = SCORE_LIST_RANGE_PATTERN.matcher(value); + if (!matcher.matches()) return null; + try { + int first = Integer.parseInt(matcher.group(1)); + String endGroup = matcher.group(2); + int start = endGroup == null ? 1 : first; + int end = endGroup == null ? first : Integer.parseInt(endGroup); + if (start <= 0 || start > end || end > MAX_SCORE_LIST_COUNT) return null; + return new ScoreListRange(start, end); + } catch (NumberFormatException ignored) { + return null; + } + } - if (target.isError()) { - ctx.sendReply(PendingMessage.ofString(target.errorMessage())); + public void handleS(Context ctx) { + var target = history.parseScoreArguments(ctx, CommandUsage.S, 1, arg -> arg.startsWith("+")); + if (target == null) return; + String option = target.nextArgument(ctx); + String mod = option == null ? null : option.substring(1).toUpperCase(java.util.Locale.ROOT); + List filters = List.of(); + if (mod != null) { + var parsed = ScoreFilterArguments.parse(new String[]{"mod=" + mod}, 0); + if (parsed.isError()) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + parsed.errorMessage())); return; } - - targetHistory.put(ctx.senderUserId(), target); - } else { - target = null; + filters = parsed.filters(); } - - if (target == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.S)); - return; + try (var _ = taskCoordinator.beginRequest(ctx, "Score")) { + var ids = history.resolve(ctx, SCORE, target, filters, mod); + history.remember(ctx, ids); + var response = APIHelper.getScoreResponse(ids.scoreId()); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.scoreMessage(ctx, response))); + } catch (Exception e) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + TaskCoordinator.resolveErrorMessage(e))); +// ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + TaskCoordinator.resolveErrorMessage(e) +// + "\n> Tips: 若要查找指定谱面上的成绩,请使用 /s __m__`bid`")); + org.apache.logging.log4j.LogManager.getLogger(ScoreCommandHandler.class) + .error("Failed to execute /s", e); } - - taskCoordinator.runImageRequest( - ctx, - "Score", - () -> APIHelper.getScoreResponse(target), - replyFactory::scoreMessage - ); } public void handleSa(Context ctx) { - ShortcutTarget target; - if (ctx.args().length == 0) { - target = targetHistory.get(ctx.senderUserId()); - } else if (ctx.args().length <= 2) { - TargetResolution targetResolution = resolver.resolveTargetWithOptionalMention(ctx.args(), ctx.senderUserId()); - - if (ctx.args().length != targetResolution.consumedArgs()) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.SA)); - return; - } - - target = targetResolution.target(); - - if (target.isError()) { - ctx.sendReply(PendingMessage.ofString(target.errorMessage())); - return; - } - - targetHistory.put(ctx.senderUserId(), target); - } else { - target = null; - } - - if (target == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.SA)); - return; + var target = history.parseArguments(ctx, CommandUsage.SA, 0); + if (target == null) return; + try (var _ = taskCoordinator.beginRequest(ctx, "Score Analysis")) { + var ids = history.resolve(ctx, SCORE, target); + history.remember(ctx, ids); + String scoreId = ids.scoreId(); + var response = APIHelper.getScoreAnalyzeResponse(scoreId); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.scoreAnalyzeMessage(ctx, response))); } - - taskCoordinator.runImageRequest( - ctx, - "Score Analysis", - () -> APIHelper.getScoreAnalyzeResponse(target), - replyFactory::scoreAnalyzeMessage - ); } public void handleMa(Context ctx) { - TargetResolution targetResolution = targetHistory.resolveOptionalTarget(ctx, resolver, arg -> arg.startsWith("#")); - ShortcutTarget target = targetResolution.target(); - if (target == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.MA)); - return; - } - if (target.isError()) { - ctx.sendReply(PendingMessage.ofString(target.errorMessage())); - return; - } - - int remainingArgs = ctx.args().length - targetResolution.consumedArgs(); - if (remainingArgs > 1) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.MA)); - return; - } - - if (remainingArgs == 1) { - Integer index = parseMissIndex( - ctx.args()[targetResolution.consumedArgs()], - targetResolution.consumedArgs() == 0 - ); + var target = history.parseArguments(ctx, CommandUsage.MA, 1, arg -> arg.startsWith("#")); + if (target == null) return; + String indexArgument = target.nextArgument(ctx); + if (indexArgument != null) { + Integer index = parseMissIndex(indexArgument, target.consumedArgs() == 0); if (index == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.MA)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.MA)); return; } - - targetHistory.rememberExplicitTarget(ctx, targetResolution); - - taskCoordinator.runImageRequest( - ctx, - "Miss Visualize", - () -> APIHelper.getMissVisualizeResponse(target, index), - (_, _) -> null - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Miss Visualize")) { + var ids = history.resolve(ctx, SCORE, target); + history.remember(ctx, ids); + String scoreId = ids.scoreId(); + var misses = APIHelper.getScoreMissesResponse(scoreId).getContent(); + if (misses.isEmpty()) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "本成绩没有Miss喵~")); + return; + } + if (index <= 0 || index > misses.size()) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "Miss序号不在范围内喵(1~" + misses.size() + ")")); + return; + } + var response = APIHelper.getMissVisualizeResponse(scoreId, index); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.missImageMessage(ctx, scoreId, index, misses.size()))); + } return; } - - targetHistory.rememberExplicitTarget(ctx, targetResolution); - - taskCoordinator.runApiRequest(ctx, "Get Score Misses", () -> - ctx.sendReply(replyFactory.scoreMissesMessage(ctx, APIHelper.getScoreMissesResponse(target))) - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Get Score Misses")) { + var ids = history.resolve(ctx, SCORE, target); + history.remember(ctx, ids); + String scoreId = ids.scoreId(); + var response = APIHelper.getScoreMissesResponse(scoreId); + ctx.sendReply(replyFactory.scoreMissesMessage(ctx, response)); + } } private Integer parseMissIndex(String arg, boolean requirePrefix) { @@ -369,7 +354,10 @@ private Integer parseMissIndex(String arg, boolean requirePrefix) { record TbArguments(int days, String target) { } - private record ScoreListRequest(int count, UserRef userRef, java.util.List filters) { + record ScoreListRange(int start, int end) { + } + + private record ScoreListRequest(ScoreListRange range, UserRef userRef, java.util.List filters) { } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java index 85b99dca..1288a63b 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java @@ -12,18 +12,20 @@ import xyz.zcraft.seira.command.parse.Resolver; 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.command.reply.CommandUsage; import xyz.zcraft.seira.command.reply.ReplyFactory; +import xyz.zcraft.seira.data.UserRef; import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.util.OsuAuthHelper; -import java.util.Comparator; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.function.Predicate; +import static xyz.zcraft.seira.command.reply.ReplyFactory.at; + public final class SocialCommandHandler { private final Resolver resolver; private final OsuAuthHelper authHelper; @@ -47,7 +49,7 @@ public SocialCommandHandler( public void handleMp(Context ctx) { if (resolver.resolveBoundUid(ctx.senderUserId()) == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.NO_BIND)); return; } @@ -58,17 +60,83 @@ public void handleMp(Context ctx) { return; } - taskCoordinator.runApiRequest(ctx, "Multiplayer Room", () -> - ctx.sendReply(replyFactory.mpMessage(ctx, APIHelper.getMultiplayerRoom(token.accessToken())))); + try (var _ = taskCoordinator.beginRequest(ctx, "Multiplayer Room")) { + var response = APIHelper.getMultiplayerRoom(token.accessToken()); + ctx.sendReply(replyFactory.mpMessage(ctx, response)); + } } public void handleF(Context ctx, boolean all) { final Long uid = resolver.resolveBoundUid(ctx.senderUserId()); if (uid == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.NO_BIND)); return; } + if (ctx.argumentCount() == 0) { + handleFriendList(ctx, all); + } else if (ctx.command().equals("f") + && ctx.inGroup() + && ctx.argumentCount() == 1 + && resolver.looksLikeMention(ctx.argument(0))) { + handleFriendStatus(ctx); + } else { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.F)); + } + } + + public void handleFriendStatus(Context ctx) { + final Long selfId = resolver.resolveBoundUid(ctx.senderUserId()); + + if (ctx.argumentCount() == 0) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "用法:/mu @someone\n> 注: 读取@需要开启权限。")); + } + + final String s = resolver.extractMentionedUserId(ctx.argument(0)); + final Long targetId = resolver.resolveBoundUid(s); + + if (targetId == null) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "对方还未绑定喵")); + return; + } + + boolean selfFollowed; + final AtomicReference targetFollowed = new AtomicReference<>(); + + final OsuToken self = authHelper.updateTokenAndGet(ctx.senderUserId()); + final List selfFollowedList = APIHelper.getFollowed(self.accessToken()).getContent(); + updateFriends(selfId, selfFollowedList); + final Set users = new HashSet<>(selfFollowedList.stream().map(FriendEntry::user).toList()); + + selfFollowed = selfFollowedList.stream().anyMatch(e -> e.user().getId() == targetId); + + selfFollowedList.stream().filter(e -> e.user().getId() == targetId).findFirst().ifPresentOrElse( + e -> targetFollowed.set(e.mutual()), () -> { + } + ); + + if (targetFollowed.get() == null) { + final List targetFollowedList; + final OsuToken target = authHelper.updateTokenAndGet(s); + if (target != null) { + targetFollowedList = APIHelper.getFollowed(target.accessToken()).getContent(); + targetFollowed.set(targetFollowedList.stream().anyMatch(e -> e.user().getId() == selfId)); + users.addAll(targetFollowedList.stream().map(FriendEntry::user).toList()); + } + } + + UserDataStore.storeUserInfo(users); + + ctx.sendReply(replyFactory.friendStatusMessage( + ctx.senderUserId(), selfId, UserDataStore.findUsername(selfId).orElse("未知"), + s, targetId, UserDataStore.findUsername(targetId).orElse("未知"), + selfFollowed, targetFollowed.get() + ) + ); + } + + public void handleFriendList(Context ctx, boolean all) { + final Long uid = resolver.resolveBoundUid(ctx.senderUserId()); OsuToken token = authHelper.updateTokenAndGet(ctx.senderUserId()); if (token == null) { @@ -76,11 +144,10 @@ public void handleF(Context ctx, boolean all) { return; } - taskCoordinator.runApiRequest(ctx, "Friend List", () -> { + try (var _ = taskCoordinator.beginRequest(ctx, "Friend List")) { final Response self = APIHelper.getSelf(token.accessToken()); final Response> response = APIHelper.getFollowed(token.accessToken()); - final List content = response.getContent(); - final List ids = content.stream().map(e -> e.user().getId()).toList(); + final List friendEntries = response.getContent(); final Predicate filter; if (ctx.inGroup() && !all) { @@ -91,27 +158,11 @@ public void handleF(Context ctx, boolean all) { } UserDataStore.storeUserInfo(self.getContent().getId(), self.getContent().getUsername()); - response.getContent().stream() + UserDataStore.storeUserInfo(response.getContent().stream() .map(FriendEntry::user) - .forEach(u -> UserDataStore.storeUserInfo(u.getId(), u.getUsername())); + .toList()); - final List origFollower = UserDataStore.findFollower(uid); - - origFollower.stream() - .filter(i -> !ids.contains(i)) - .forEach(i -> UserDataStore.removeFollowed(uid, i)); - - for (FriendEntry friendEntry : content) { - if (!UserDataStore.haveFollowed(uid, friendEntry.user().getId())) { - UserDataStore.storeFollowed(uid, friendEntry.user().getId()); - } - - if (friendEntry.mutual()) { - if (!UserDataStore.haveFollowed(friendEntry.user().getId(), uid)) { - UserDataStore.storeFollowed(friendEntry.user().getId(), uid); - } - } - } + updateFriends(uid, friendEntries); final List follower = UserDataStore.findFollower(uid); @@ -119,7 +170,7 @@ public void handleF(Context ctx, boolean all) { final List onlyFollowed = new LinkedList<>(); final List onlyFollower = new LinkedList<>(); - for (FriendEntry e : content) { + for (FriendEntry e : friendEntries) { if (!filter.test(e.user().getId())) continue; if (follower.contains(e.user().getId())) { mutual.add(e.user()); @@ -130,7 +181,7 @@ public void handleF(Context ctx, boolean all) { for (Long i : follower) { if (!filter.test(i)) continue; - if (content.stream().noneMatch(entry -> Objects.equals(entry.user().getId(), i))) { + if (friendEntries.stream().noneMatch(entry -> Objects.equals(entry.user().getId(), i))) { User u = new User(); u.setId(i); u.setUsername(UserDataStore.findUsername(i).orElse("未知-" + i)); @@ -138,7 +189,7 @@ public void handleF(Context ctx, boolean all) { } } - long allMutualCount = content.stream().filter(FriendEntry::mutual).count(); + long allMutualCount = friendEntries.stream().filter(FriendEntry::mutual).count(); final Comparator userComparator = Comparator.comparing(User::isOnline, Comparator.reverseOrder()).thenComparing(User::getUsername); mutual.sort(userComparator); @@ -146,20 +197,41 @@ public void handleF(Context ctx, boolean all) { onlyFollowed.sort(userComparator); ctx.sendReply(replyFactory.friendMessage( - ctx, all, self.getContent(), content.size(), allMutualCount, + ctx, all, self.getContent(), friendEntries.size(), allMutualCount, mutual, onlyFollowed, onlyFollower )); - }); + } + } + + private void updateFriends(Long uid, List newFriends) { + final List ids = newFriends.stream().map(e -> e.user().getId()).toList(); + final List origFollower = UserDataStore.findFollower(uid); + + origFollower.stream() + .filter(i -> !ids.contains(i)) + .forEach(i -> UserDataStore.removeFollowed(uid, i)); + + for (FriendEntry friendEntry : newFriends) { + if (!UserDataStore.haveFollowed(uid, friendEntry.user().getId())) { + UserDataStore.storeFollowed(uid, friendEntry.user().getId()); + } + + if (friendEntry.mutual()) { + if (!UserDataStore.haveFollowed(friendEntry.user().getId(), uid)) { + UserDataStore.storeFollowed(friendEntry.user().getId(), uid); + } + } + } } public void handleFclear(Context ctx) { Long uid = resolver.resolveBoundUid(ctx.senderUserId()); if (uid == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.NO_BIND)); return; } - ctx.sendReply(PendingMessage.ofString( + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "已清除 " + UserDataStore.clearFollowed(uid) + " 条好友记录。" )); } @@ -169,35 +241,31 @@ public void handleLb(Context ctx) { if (ctx.groupId() != null && !ctx.groupId().isBlank()) { List groupBoundUids = UserDataStore.findBoundUidsByGroup(ctx.groupId()); if (groupBoundUids.isEmpty()) { - ctx.sendReply(PendingMessage.ofString("本群还没有已绑定的玩家,请先使用 /bind")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "本群还没有已绑定的玩家,请先使用 /bind")); return; } - taskCoordinator.runImageRequest( - ctx, - "Leaderboard", - () -> APIHelper.getLeaderboardResponse(groupBoundUids), - replyFactory::lbMessage - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Leaderboard")) { + var response = APIHelper.getLeaderboardResponse(groupBoundUids); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.lbMessage(ctx, response))); + } return; } Long uid = resolver.resolveBoundUid(ctx.senderUserId()); if (uid == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.NO_BIND)); return; } - taskCoordinator.runImageRequest( - ctx, - "Leaderboard", - () -> APIHelper.getLeaderboardResponse(List.of(uid)), - replyFactory::lbMessage - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Leaderboard")) { + var response = APIHelper.getLeaderboardResponse(List.of(uid)); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.lbMessage(ctx, response))); + } } else if (ctx.args().length == 1 || ctx.args().length == 2) { TargetResolution targetResolution = resolver.resolveTargetWithOptionalMention(ctx.args(), ctx.senderUserId()); ShortcutTarget target = targetResolution.target(); if (target.isError()) { - ctx.sendReply(PendingMessage.ofString(target.errorMessage())); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + target.errorMessage())); return; } @@ -206,40 +274,38 @@ public void handleLb(Context ctx) { if (ctx.groupId() != null && !ctx.groupId().isBlank()) { List groupBoundUids = UserDataStore.findBoundUidsByGroup(ctx.groupId()); if (groupBoundUids.isEmpty()) { - ctx.sendReply(PendingMessage.ofString("本群还没有已绑定的玩家,请先使用 /bind")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "本群还没有已绑定的玩家,请先使用 /bind")); return; } - taskCoordinator.runImageRequest( - ctx, - "Map Leaderboard", - () -> APIHelper.getGroupLeaderboardResponse(target, groupBoundUids, accessTokenProvider.apply(ctx.senderUserId())), - replyFactory::lbMessage - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Map Leaderboard")) { + long beatmapId = APIHelper.lookupBeatmap(target, accessTokenProvider.apply(ctx.senderUserId())); + var response = APIHelper.getGroupLeaderboardResponse(beatmapId, groupBoundUids); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.lbMessage(ctx, response))); + } return; } Long uid = resolver.resolveBoundUid(ctx.senderUserId()); if (uid == null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.NO_BIND)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.NO_BIND)); return; } - taskCoordinator.runImageRequest( - ctx, - "Map Leaderboard", - () -> APIHelper.getGroupLeaderboardResponse(target, List.of(uid), accessTokenProvider.apply(ctx.senderUserId())), - replyFactory::lbMessage - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Map Leaderboard")) { + long beatmapId = APIHelper.lookupBeatmap(target, accessTokenProvider.apply(ctx.senderUserId())); + var response = APIHelper.getGroupLeaderboardResponse(beatmapId, List.of(uid)); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.lbMessage(ctx, response))); + } return; } if (remainingArgs != 1) { - ctx.sendReply(PendingMessage.ofString("用法:/lb <谱面ID或快捷查询> [玩家ID列表(逗号分隔)]")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "用法:/lb <谱面ID或快捷查询> [玩家ID列表(逗号分隔)]")); return; } String[] uidTokens = ctx.args()[targetResolution.consumedArgs()].split(","); if (uidTokens.length == 0) { - ctx.sendReply(PendingMessage.ofString("玩家ID列表不能为空。用法:/lb <谱面ID或快捷查询> [玩家ID列表(逗号分隔)]")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "玩家ID列表不能为空。用法:/lb <谱面ID或快捷查询> [玩家ID列表(逗号分隔)]")); return; } @@ -247,21 +313,47 @@ public void handleLb(Context ctx) { for (String uidToken : uidTokens) { Long uid = resolver.parsePositiveLong(uidToken.trim()); if (uid == null) { - ctx.sendReply(PendingMessage.ofString("玩家ID列表包含非法值。用法:/lb <谱面ID或快捷查询> [玩家ID列表(逗号分隔)]")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "玩家ID列表包含非法值。用法:/lb <谱面ID或快捷查询> [玩家ID列表(逗号分隔)]")); return; } uids.add(uid); } - taskCoordinator.runImageRequest( - ctx, - "Map Leaderboard", - () -> APIHelper.getGroupLeaderboardResponse(target, uids, accessTokenProvider.apply(ctx.senderUserId())), - replyFactory::lbMessage - ); + try (var _ = taskCoordinator.beginRequest(ctx, "Map Leaderboard")) { + long beatmapId = APIHelper.lookupBeatmap(target, accessTokenProvider.apply(ctx.senderUserId())); + var response = APIHelper.getGroupLeaderboardResponse(beatmapId, uids); + ctx.sendReply(taskCoordinator.imageMessage(response, replyFactory.lbMessage(ctx, response))); + } } else { - ctx.sendReply(PendingMessage.ofString("用法:/lb <谱面ID或快捷查询> [玩家ID列表(逗号分隔)]")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "用法:/lb <谱面ID或快捷查询> [玩家ID列表(逗号分隔)]")); } } + public void handleSup(Context ctx) { + final UserRef target; + + if (ctx.argumentCount() == 0) { + Long targetId = resolver.resolveBoundUid(ctx.senderUserId()); + if (targetId == null) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.NO_BIND)); + return; + } + target = new UserRef.ByUid(targetId); + } else if (ctx.argumentCount() == 1) { + final UserRefResolution res = resolver.resolveUserRefArgument(ctx.argument(0)); + if (res.errorMessage() != null) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + res.errorMessage())); + return; + } + target = res.userRef(); + } else { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + CommandUsage.SUP)); + return; + } + + final UserExtended user = APIHelper.getUserRaw(target); + final String openId = UserDataStore.findGroupOpenIdByUid(ctx.groupId(), user.getId()).orElse(null); + + ctx.sendReply(replyFactory.supMessage(ctx, user.getUsername(), openId, user.isSupporter(), user.getHasSupported(), user.getSupportLevel())); + } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java index 9b281b87..cce656b4 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java @@ -11,6 +11,8 @@ import java.util.Objects; import java.util.Set; +import static xyz.zcraft.seira.command.reply.ReplyFactory.at; + public final class SpecificScoreWatchCommandHandler { private static final String USAGE = "用法:/wx start <谱面ID列表,逗号分隔>;/wx stop"; @@ -44,7 +46,7 @@ static Set parseIds(String value) { public void handleWx(Context ctx) { if (!ctx.inGroup()) { - ctx.sendReply(PendingMessage.ofString("/wx 仅支持群聊使用。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "/wx 仅支持群聊使用。")); return; } if (ctx.argumentCount() == 0) { @@ -68,23 +70,23 @@ private void handleStart(Context ctx) { Set userIds = parseIds(ctx.argument(1)); Set beatmapIds = parseIds(ctx.argument(2)); if (userIds == null || beatmapIds == null) { - ctx.sendReply(PendingMessage.ofString("UID 与谱面 ID 必须是以逗号分隔的正整数。\n" + USAGE)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "UID 与谱面 ID 必须是以逗号分隔的正整数。\n" + USAGE)); return; } - taskCoordinator.runApiRequest(ctx, "Start Specific Score Watch", () -> { + try (var timing = taskCoordinator.beginRequest(ctx, "Start Specific Score Watch")) { if (!ctx.sendMessage(PendingMessage.ofString("正在尝试启动指定谱面成绩监视……")).success()) { - ctx.sendReply(PendingMessage.ofString( + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于缺少主动消息权限,无法启动监视!权限配置请见:https://docs.seira.top/overview/use.html#extra-permission" )); return; } SpecificScoreWatchState state = watchService.startSpecific(ctx.groupId(), userIds, beatmapIds); - ctx.sendReply(PendingMessage.ofString( + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "指定谱面成绩监视已启动,目标为" + state.userIds().size() + " 名玩家," - + state.beatmapIds().size() + " 张谱面。" + + state.beatmapIds().size() + " 张谱面。" )); - }); + } } private void handleStop(Context ctx) { @@ -93,12 +95,10 @@ private void handleStop(Context ctx) { return; } boolean stopped = watchService.stopSpecific(ctx.groupId()); - ctx.sendReply(PendingMessage.ofString( - stopped ? "已停止当前群聊的指定谱面成绩监视。" : "当前群聊没有指定谱面成绩监视。" - )); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + (stopped ? "已停止当前群聊的指定谱面成绩监视。" : "当前群聊没有指定谱面成绩监视。"))); } private void usage(Context ctx) { - ctx.sendReply(PendingMessage.ofString(USAGE)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + USAGE)); } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java index cbbf2a24..4360163a 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java @@ -85,16 +85,16 @@ private static String formatRemaining(Duration duration) { } private static void usage(Context ctx) { - ctx.sendReply(PendingMessage.ofString(USAGE)); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + USAGE)); } public void handleWatch(Context ctx) { if (!ctx.inGroup()) { - ctx.sendReply(PendingMessage.ofString("/watch 仅支持群聊使用。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "/watch 仅支持群聊使用。")); return; } if (watchService == null) { - ctx.sendReply(PendingMessage.ofString("成绩监视服务暂不可用。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "成绩监视服务暂不可用。")); return; } if (ctx.argumentCount() == 0) { @@ -114,9 +114,9 @@ public void handleWatch(Context ctx) { private void handleNow(Context ctx) { if (adminAuthorizer.test(ctx.senderUserId())) { watchService.pollNow(); - ctx.sendReply(PendingMessage.ofString("已触发立即轮询。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "已触发立即轮询。")); } else { - ctx.sendReply(PendingMessage.ofString("你没有权限使用此指令。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "你没有权限使用此指令。")); } } @@ -134,21 +134,21 @@ private void handleAdd(Context ctx) { } String targetArgument = ctx.argument(1); - taskCoordinator.runApiRequest(ctx, "Add Score Watch", () -> { + try (var timing = taskCoordinator.beginRequest(ctx, "Add Score Watch")) { WatchTarget target = targetResolver.apply(ctx.groupId(), targetArgument); final boolean b = ctx.sendMessage(PendingMessage.ofMarkdownRaw( at(ctx) + "正在尝试添加监视..." )).success(); if (!b) { - ctx.sendReply(PendingMessage.ofMarkdownRaw("由于缺少主动消息权限,无法添加监视!权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)~")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于缺少主动消息权限,无法添加监视!权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)~")); return; } watchService.add(ctx.groupId(), target, Duration.ofMinutes(minutes)); ctx.sendReply(PendingMessage.ofMarkdownRaw( - displayTarget(target) + " 添加监视成功!有效期:" + minutes + "分钟" + at(ctx) + ": " + displayTarget(target) + " 添加监视成功!有效期:" + minutes + "分钟" )); - }); + } } private void handleDelete(Context ctx) { @@ -158,10 +158,10 @@ private void handleDelete(Context ctx) { } if (ctx.argumentCount() == 1) { int removed = watchService.removeAll(ctx.groupId()); - ctx.sendReply(PendingMessage.ofString( - removed == 0 + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + + (removed == 0 ? "当前群聊没有监视任务。" - : "已移除当前群聊中的全部监视任务,共 " + removed + " 个。" + : "已移除当前群聊中的全部监视任务,共 " + removed + " 个。") )); return; } @@ -170,7 +170,7 @@ private void handleDelete(Context ctx) { String mentionedOpenId = resolver.extractMentionedUserId(targetArgument); if (mentionedOpenId != null) { if (!UserDataStore.isGroupMember(ctx.groupId(), mentionedOpenId)) { - ctx.sendReply(PendingMessage.ofString("指定的用户不在当前群聊中。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "指定的用户不在当前群聊中。")); return; } WatchView removed = watchService.removeByQqOpenId(ctx.groupId(), mentionedOpenId); @@ -178,10 +178,10 @@ private void handleDelete(Context ctx) { return; } - taskCoordinator.runApiRequest(ctx, "Delete Score Watch", () -> { + try (var timing = taskCoordinator.beginRequest(ctx, "Delete Score Watch")) { WatchTarget target = resolveTarget(ctx.groupId(), targetArgument); ctx.sendReply(removedMessage(watchService.remove(ctx.groupId(), target.userId()))); - }); + } } private void handleList(Context ctx) { @@ -191,11 +191,11 @@ private void handleList(Context ctx) { } List watches = watchService.list(ctx.groupId()); if (watches.isEmpty()) { - ctx.sendReply(PendingMessage.ofString("当前群聊没有监视任务。")); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "当前群聊没有监视任务。")); return; } - StringBuilder content = new StringBuilder("当前群聊的监视任务:\n"); + StringBuilder content = new StringBuilder(at(ctx) + "当前群聊的监视任务:\n"); for (WatchView watch : watches) { content.append("> ") .append(displayTarget(watch.target())) 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 9f98ab36..65ce5b6b 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java @@ -10,17 +10,37 @@ import java.util.regex.Pattern; public final class Resolver { - private static final ArrayList USER_MACRO_TYPES = new ArrayList<>(List.of("rs", "bo", "rp")); + private static final ArrayList USER_MACRO_TYPES = new ArrayList<>(List.of("rs", "bp", "rp")); + private final java.util.function.Function boundUid; - public String sanitize(String rawContent) { - Matcher matcher = Patterns.USER_MACRO_PATTERN.matcher(rawContent); - if (matcher.matches()) { - rawContent = "s " + rawContent; - } + public Resolver() { + this(UserDataStore::findBoundUid); + } - // Add surrounding space to <@> + public Resolver(java.util.function.Function boundUid) { + this.boundUid = Objects.requireNonNull(boundUid); + } + + public String sanitize(String rawContent) { + // Add surrounding space to <@> before expanding compact commands so /bp5<@...> is recognized. rawContent = Patterns.QQ_INLINE_AT_PATTERN.matcher(rawContent).replaceAll(r -> " " + r.group() + " "); + Matcher matcher = Patterns.COMPACT_SCORE_COMMAND_PATTERN.matcher(rawContent); + if (matcher.find()) { + String type = matcher.group(1).toLowerCase(Locale.ROOT); + String start = matcher.group(2); + String end = matcher.group(3); + String remaining = rawContent.substring(matcher.end()); + if (end == null) { + String player = remaining.trim(); + rawContent = player.isEmpty() + ? "s " + type + start + : "s " + player + " " + type + start; + } else { + rawContent = type + " " + start + "-" + end + remaining; + } + } + return rawContent; } @@ -152,7 +172,7 @@ public Long resolveBoundUid(String senderUserId) { if (senderUserId == null || senderUserId.isBlank()) { return null; } - return UserDataStore.findBoundUid(senderUserId); + return boundUid.apply(senderUserId); } public Integer parsePositiveInt(String value) { @@ -181,10 +201,6 @@ private ShortcutTarget parseTarget(String arg, UserRef userRef, boolean mentione if (userMatcher.matches()) { String type = userMatcher.group(1).toLowerCase(); - if (Objects.equals("bp", type)) { - type = "bo"; - } - if (!USER_MACRO_TYPES.contains(type)) { return new ShortcutTarget(null, null, null, null, "未知的快捷查询"); } @@ -296,6 +312,9 @@ public boolean looksLikeUid(String arg) { private static final class Patterns { private static final Pattern USER_MACRO_PATTERN = Pattern.compile("(?i)^(rs|bo|rp|bp)(\\d+)?$"); + private static final Pattern COMPACT_SCORE_COMMAND_PATTERN = Pattern.compile( + "(?i)^(rs|rp|bp)(\\d+)(?:-(\\d+))?(?=\\s|$)" + ); private static final Pattern SET_MACRO_PATTERN = Pattern.compile("^(\\d+)#(\\d+)$"); private static final Pattern BEATMAP_MACRO_PATTERN = Pattern.compile("^m(\\d+)$"); private static final Pattern LOCAL_SCORE_PATTERN = Pattern.compile("(?i)^loc[1-9]\\d*$"); 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 253575b3..cce66a5a 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java @@ -13,13 +13,13 @@ 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|replay" - + "|title|artist|mapper|genre|language|video|storyboard|fullcombo)" + + "|any|title|artist|mapper|genre|language|video|storyboard|fullcombo|ar|od|cs|hp)" + "(>=|<=|!=|!~|>|<|=|~)(.+)$" ); 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 NEGATED_RANK_SHORTHAND_PATTERN = Pattern.compile("(?i)^!(SSH|SS|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 static final Set RANKS = Set.of("SSH", "SS", "XH", "X", "SH", "S", "A", "B", "C", "D", "F"); private ScoreFilterArguments() { } @@ -64,20 +64,37 @@ private static String parseOne(String token) { } validateMods(value); } else if (field.equals("rank")) { - if (!Set.of("=", "!=").contains(operator)) { - throw new IllegalArgumentException("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"); + throw new IllegalArgumentException("rank 必须是 SSH/SS/XH/X/SH/S/A/B/C/D/F"); } - } else if (Set.of("title", "artist", "mapper", "genre", "language").contains(field)) { + } else if (Set.of("any", "title", "artist", "mapper", "genre", "language").contains(field)) { if (!Set.of("~", "!~", "=", "!=").contains(operator)) { throw new IllegalArgumentException(field + " 仅支持 ~、!~、=、!="); } + if (field.equals("any") && !Set.of("=", "!=").contains(operator)) { + throw new IllegalArgumentException("any 仅支持 =、!="); + } if (value.isBlank()) { throw new IllegalArgumentException(field + " 不能为空"); } + if (isRegexValue(value)) { + if (!Set.of("=", "!=").contains(operator)) { + throw new IllegalArgumentException("正则表达式仅支持 =、!="); + } + String expression = value.substring(1, value.length() - 1); + if (expression.isEmpty()) { + throw new IllegalArgumentException("正则表达式不能为空"); + } + try { + Pattern.compile(expression, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); + } catch (java.util.regex.PatternSyntaxException e) { + throw new IllegalArgumentException("正则表达式无效:" + e.getDescription()); + } + } } else if (Set.of("video", "storyboard", "fullcombo", "replay").contains(field)) { if (!Set.of("=", "!=").contains(operator)) { throw new IllegalArgumentException(field + " 仅支持 =、!="); @@ -112,8 +129,13 @@ private static String normalizeField(String value) { case "bpm" -> "bpm"; case "miss", "misses" -> "miss"; case "score" -> "score"; + case "ar" -> "ar"; + case "od" -> "od"; + case "cs" -> "cs"; + case "hp" -> "hp"; case "mod", "mods" -> "mod"; case "rank" -> "rank"; + case "any" -> "any"; case "title" -> "title"; case "artist" -> "artist"; case "mapper" -> "mapper"; @@ -153,6 +175,10 @@ private static String expandShorthand(String value) { return value; } + private static boolean isRegexValue(String value) { + return value.length() >= 2 && value.startsWith("[") && value.endsWith("]"); + } + private static void validateMods(String value) { String normalized = value.toUpperCase(Locale.ROOT).replace("+", ""); if (normalized.equals("NM")) return; diff --git a/src/main/java/xyz/zcraft/seira/command/parse/ShortcutTarget.java b/src/main/java/xyz/zcraft/seira/command/parse/ShortcutTarget.java index 796ab4ce..217e00d1 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/ShortcutTarget.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/ShortcutTarget.java @@ -2,6 +2,10 @@ import xyz.zcraft.seira.data.UserRef; +/** + * 用户输入或 API 目标。解析后的普通目标只包含 explicitId; + * 本地成绩使用 localScoreId。跨类型查找时 m/ms/s 标明原始 ID 的类型。 + */ public record ShortcutTarget( Long explicitId, String localScoreId, diff --git a/src/main/java/xyz/zcraft/seira/command/parse/TargetResolution.java b/src/main/java/xyz/zcraft/seira/command/parse/TargetResolution.java index 78fd0ae1..39c58d5d 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/TargetResolution.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/TargetResolution.java @@ -1,4 +1,17 @@ package xyz.zcraft.seira.command.parse; -public record TargetResolution(ShortcutTarget target, int consumedArgs) { +import xyz.zcraft.seira.command.Context; +import xyz.zcraft.seira.data.UserRef; + +/** + * target 为 null 表示省略目标;consumedArgs 之后是指令自己的可选参数。 + */ +public record TargetResolution(ShortcutTarget target, int consumedArgs, UserRef userOverride) { + public TargetResolution(ShortcutTarget target, int consumedArgs) { + this(target, consumedArgs, null); + } + + public String nextArgument(Context ctx) { + return ctx.argumentCount() > consumedArgs ? ctx.argument(consumedArgs) : 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 e400db19..a69468d6 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/CommandUsage.java @@ -1,23 +1,26 @@ package xyz.zcraft.seira.command.reply; public final class CommandUsage { - public static final String BO = "用法:/bo <个数> [玩家ID/用户名/@用户] [过滤条件 ...]"; - public static final String NO_BIND = "你还没有绑定玩家ID,请先使用 /bind 绑定"; + public static final String BP = "用法:/bp <个数或范围> [玩家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 SCORE_FILTERS = "过滤示例:acc>=98 any=stream title=[Song.*] 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 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 或 快捷查询>"; + public static final String S = "用法:/s [成绩ID 或 快捷查询] [用户] [+Mod](省略目标时使用记忆)"; public static final String SA = "用法:/sa <成绩ID 或 快捷查询>"; public static final String MA = "用法:/ma [成绩ID 或 快捷查询] [序号];省略目标并指定序号时请使用 #序号"; public static final String R = "用法:/r [成绩ID 或 快捷查询] [[mm:ss]-[mm:ss]]"; public static final String RSC = "用法:/rsc [谱面ID或快捷查询] [+/=用户ID列表,逗号分隔]"; + public static final String F = "用法:/f [@用户]"; + public static final String RCANCEL = "用法:/rcancel <渲染任务ID>"; + public static final String SUP = "用法:/sup [@用户]"; private CommandUsage() { } 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 cfc925de..92061d15 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -15,10 +15,7 @@ import xyz.zcraft.seira.data.UploadedImage; import xyz.zcraft.seira.db.RankGuessRecordStore; import xyz.zcraft.seira.db.UserDataStore; -import xyz.zcraft.seira.rankguess.data.FinishedRound; -import xyz.zcraft.seira.rankguess.data.Rank; -import xyz.zcraft.seira.rankguess.data.Round; -import xyz.zcraft.seira.rankguess.data.Standing; +import xyz.zcraft.seira.rankguess.data.*; import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.services.BotStat; import xyz.zcraft.seira.services.DailyLuck; @@ -45,6 +42,51 @@ public static String cmd(String command, String text) { return "".formatted(command, text); } + public static String cmd(String command) { + return cmd(command, command); + } + + public static String m(String id) { + return cmd("/m " + id, id); + } + + public static String m(long id) { + return cmd("/m " + id, String.valueOf(id)); + } + + public static String s(String id) { + return cmd("/s " + id, id); + } + + public static String s(long id) { + return cmd("/s " + id, String.valueOf(id)); + } + + public static String ms(String id) { + return cmd("/ms " + id, id); + } + + public static String ms(long id) { + return cmd("/ms " + id, String.valueOf(id)); + } + + @SuppressWarnings("unused") + public static String u(String id, String name) { + return cmd("/u " + id, name); + } + + public static String u(long id, String name) { + return cmd("/u " + id, name); + } + + public static String u(long id) { + return cmd("/u " + id, String.valueOf(id)); + } + + public static String u(String id) { + return cmd("/u " + id, id); + } + public static String at(Context ctx) { if (ctx.inGroup()) { return at(ctx.senderUserId()); @@ -65,18 +107,39 @@ static String url(String text, String url) { public static PendingMessage replayUploadMessage(ReplayUploadInfo info) { return PendingMessage.ofMarkdownRaw( ("\n" + "## Replay上传成功~" + "\n" + - "> 成绩: " + cmd("/s " + info.scoreId(), String.valueOf(info.scoreId())) + "\n" + - "> 谱面: " + cmd("/m " + info.beatmapId(), String.valueOf(info.beatmapId())) + "\n" + - "> 用户: " + cmd("/u " + info.userId(), info.username()) + "\n").trim(), + "> 成绩: " + s(info.scoreId()) + "\n" + + "> 谱面: " + m(info.beatmapId()) + "\n" + + "> 用户: " + u(info.userId(), info.username()) + "\n").trim(), null ); } + private static boolean isCancelableReplayStatus(String status) { + return "queued".equals(status) || "rendering".equals(status) + || "upload_queued".equals(status) || "uploading".equals(status); + } + + public PendingMessage friendStatusMessage(String selfOpenId, Long selfUid, String selfUsername, + String targetOpenId, Long targetUid, String targetUsername, + boolean selfFollowed, Boolean targetFollowed) { + return PendingMessage.ofMarkdownRaw( + Contents.friendStatusContent( + selfOpenId, selfUid, selfUsername, + targetOpenId, targetUid, targetUsername, + selfFollowed, targetFollowed + ) + ); + } + private Buttons buttons() { - return new Buttons(configSupplier.get().seira().directUrl()); + return new Buttons(getDirectUrl()); + } + + private String getDirectUrl() { + return configSupplier.get().seira().directUrl(); } - public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result, boolean recorded) { + public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result, EndResult.RankType rankType) { Round round = result.round(); String rank = String.format(Locale.US, "%,d", round.actualRank()); String pp = round.pp() == null @@ -89,15 +152,24 @@ public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result, StringBuilder content = new StringBuilder("本轮猜测结束"); - if (recorded) { - content.append(",战绩已记录~\n"); - } else { - content.append(",由于参与人数过少,战绩不会记录~\n"); + if (rankType == EndResult.RankType.RANKED) { + content.append(",战绩已记录"); + } else if (rankType == EndResult.RankType.NOT_ENOUGH_PARTICIPANT) { + content.append(",由于参与人数过少,战绩不会记录"); + } else if (rankType == EndResult.RankType.NOT_A_STANDARD_GAME) { + content.append(",由于无主动消息权限,非完整游戏,战绩不会记录"); } + content.append("~\n"); + content.append("> 玩家:`%s` %s\n".formatted(round.randomScore().user().getUsername(), userAt)) - .append("> 实际Rank:`#%s` (%s)\n".formatted(rank, cmd("/u " + round.userId(), String.valueOf(round.userId())))) - .append("> 成绩PP:`%s` (%s)\n".formatted(pp, cmd("/s " + round.scoreId(), String.valueOf(round.scoreId())))) + .append("> 实际Rank:`#%s` (%s)\n".formatted(rank, u(round.userId()))) + .append("> 成绩:`%s` (%s|%s)\n" + .formatted( + pp, + "BP" + round.randomScore().bestIndex(), + s(round.scoreId()) + )) .append("\n猜测排行榜:\n"); if (result.standings().isEmpty()) { @@ -124,24 +196,34 @@ public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result, } public PendingMessage rankGuessStatisticsMessage( - Context ctx, RankGuessRecordStore.Statistics.Personal statistics, + Context ctx, String ref, RankGuessRecordStore.Statistics.Personal statistics, RankGuessRecordStore.Statistics.Personal recentStatistics, boolean allGroups, Rank rank, - Long pickedTimes, Long groupGameCount + Long pickedTimes, Long groupGameCount, + RankGuessRecordStore.RankGuessed rankGuessed, + Long gameStarted ) { String scope = allGroups ? "全部群聊" : "本群"; if (statistics.participation() == 0) { - return PendingMessage.ofMarkdownRaw(at(ctx) + "你在" + scope + "还没有已结算的猜 Rank 战绩喵~"); + return PendingMessage.ofMarkdownRaw(at(ctx) + ref + "在" + scope + "还没有已结算的猜 Rank 战绩喵~"); } - String rankText = "?".equals(rank.rank()) ? "" : "根据你最近 %d 场的表现,可以给到一个 `%s` 喵!\n" + String rankText = "?".equals(rank.rank()) ? "" : "根据" + ref + "最近 %d 场的表现,可以给到一个 `%s` 喵!\n" .formatted(Rank.RECENT_GAME_LIMIT, rank.rank()); String groupCountText = ""; + String averageGuessedText = ""; + String gameStartedText = ""; if (!allGroups && pickedTimes != null && groupGameCount != null) { groupCountText = "> 被猜次数:`%d`,占本群:`%.3f%%`\n".formatted(pickedTimes, (double) pickedTimes / groupGameCount * 100); } + if (!allGroups && rankGuessed != null) { + averageGuessedText = "> 平均被猜为:`#%,d` / `#%,d`\n".formatted((long) rankGuessed.average(), (long) rankGuessed.logAverage()); + } + if (!allGroups && gameStarted != null) { + gameStartedText = "> 在本群发起了 `%d` 场游戏\n".formatted(gameStarted); + } return PendingMessage.ofMarkdownRaw(at(ctx) + String.format(Locale.ROOT, """ - 你的猜 Rank 战绩(%s,括号为近 %d 场) + %s的猜 Rank 战绩(%s,括号为近 %d 场) > 总参与数:`%d`,Rating:`%.2f` > 获胜数:`%d`(`%d`) > 胜率:`%.2f%%`(`%.2f%%`) @@ -151,9 +233,9 @@ public PendingMessage rankGuessStatisticsMessage( > 最高分:`%.2f`(`%.2f`) > 平均名次:`%.2f`(`%.2f`) > 总得分:`%.2f` - %s%s + %s%s%s%s """, - scope, Rank.RECENT_GAME_LIMIT, + ref, scope, Rank.RECENT_GAME_LIMIT, statistics.participation(), rank.rating(), statistics.wins(), recentStatistics.wins(), statistics.winRate() * 100, recentStatistics.winRate() * 100, @@ -162,21 +244,21 @@ public PendingMessage rankGuessStatisticsMessage( statistics.averageScore(), recentStatistics.averageScore(), statistics.highestScore(), recentStatistics.highestScore(), statistics.averagePlacement(), recentStatistics.averagePlacement(), - statistics.totalScore(), groupCountText, rankText).strip()); + statistics.totalScore(), gameStartedText, averageGuessedText, groupCountText, rankText).strip()); } - public PendingMessage boMessage(Context ctx, Response response) { + public PendingMessage bpMessage(Context ctx, Response response) { return PendingMessage.ofMarkdownRaw( at(ctx) + "查询完成,共" + response.getScoreIds().size() + "个成绩\n" + - "> 玩家: " + cmd("/u " + response.getUserId(), response.getUserId()), - buttons().boButtons(response.getUserId()) + "> 玩家: " + u(response.getUserId()), + buttons().bpButtons(response.getUserId()) ); } public PendingMessage rsMessage(Context ctx, Response response) { return PendingMessage.ofMarkdownRaw( at(ctx) + "最近成绩查询完成\n" + - "> 玩家: " + cmd("/u " + response.getUserId(), response.getUserId()) + "\n" + + "> 玩家: " + u(response.getUserId()) + "\n" + "> 数量: " + response.getScoreIds().size(), buttons().rsButtons() ); @@ -185,25 +267,25 @@ 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()), + "> 玩家: " + u(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" + + at(ctx) + "近日BP查询完成\n" + + "> 玩家: " + u(response.getUserId()) + "\n" + "> 数量: " + response.getScoreIds().size(), - buttons().boButtons(response.getUserId()) + buttons().bpButtons(response.getUserId()) ); } public PendingMessage beatmapMessage(Context ctx, Response response) { return PendingMessage.ofMarkdownRaw( at(ctx) + "谱面查询完成\n" + - "> 谱面: " + cmd("/m " + response.getBeatmapId(), response.getBeatmapId()) + "\n" + - "> 谱面集: " + cmd("/ms " + response.getBeatmapsetId(), response.getBeatmapsetId()), + "> 谱面: " + m(response.getBeatmapId()) + "\n" + + "> 谱面集: " + ms(response.getBeatmapsetId()), buttons().beatmapButtons(response.getBeatmapId()) ); @@ -212,8 +294,8 @@ public PendingMessage beatmapMessage(Context ctx, Response response) { public PendingMessage scoreMessage(Context ctx, Response response) { return PendingMessage.ofMarkdownRaw( at(ctx) + "成绩查询完成\n" + - "> 谱面: " + cmd("/m " + response.getBeatmapId(), response.getBeatmapId()) + "\n" + - "> 成绩: " + cmd("/s " + response.getScoreId(), response.getScoreId()), + "> 谱面: " + m(response.getBeatmapId()) + "\n" + + "> 成绩: " + s(response.getScoreId()), buttons().sButtons(response.getBeatmapId(), response.getScoreId()) ); } @@ -221,8 +303,8 @@ public PendingMessage scoreMessage(Context ctx, Response response) { public PendingMessage scoreAnalyzeMessage(Context ctx, Response response) { return PendingMessage.ofMarkdownRaw( at(ctx) + "成绩分析完成\n" + - "> 谱面: " + cmd("/m " + response.getBeatmapId(), response.getBeatmapId()) + "\n" + - "> 成绩: " + cmd("/s " + response.getScoreId(), response.getScoreId()), + "> 谱面: " + m(response.getBeatmapId()) + "\n" + + "> 成绩: " + s(response.getScoreId()), buttons().saButtons(response.getBeatmapId(), response.getScoreId()) ); } @@ -230,7 +312,7 @@ public PendingMessage scoreAnalyzeMessage(Context ctx, Response response) { public PendingMessage lbMessage(Context ctx, Response response) { return PendingMessage.ofMarkdownRaw( at(ctx) + "排行榜查询完成" + - (response.getBeatmapId() == null ? "" : "\n> 谱面: " + cmd("/m " + response.getBeatmapId(), response.getBeatmapId())), + (response.getBeatmapId() == null ? "" : "\n> 谱面: " + m(response.getBeatmapId())), buttons().lbButtons(response.getBeatmapId()) ); } @@ -238,14 +320,14 @@ public PendingMessage lbMessage(Context ctx, Response response) { public PendingMessage replayMessage(Context ctx, APIHelper.ReplayTaskInfo taskInfo) { return PendingMessage.ofMarkdownRaw( Contents.replayTaskContent(ctx, taskInfo), - buttons().replayProgressButtons(taskInfo.taskId()) + buttons().replayProgressButtons(taskInfo.taskId(), ctx.senderUserId()) ); } public PendingMessage replayStatMessage(Context ctx, String jobId, RenderStat renderStat) { return PendingMessage.ofMarkdownRaw( Contents.replayStatContent(ctx, renderStat, jobId), - buttons().replayProgressButtons(jobId) + buttons().replayProgressButtons(jobId, isCancelableReplayStatus(renderStat.getStatus()), ctx.senderUserId()) ); } @@ -370,6 +452,17 @@ public PendingMessage luckMessage(Context ctx, DailyLuck.Luck luck, Beatmapset m ); } + public PendingMessage missImageMessage(Context ctx, String scoreId, Integer index, int size) { + return PendingMessage.ofMarkdownRaw( + Contents.missImageContent(ctx, scoreId, index, size), + Buttons.missImageButton(ctx, scoreId, index, size) + ); + } + + public PendingMessage supMessage(Context ctx, String username, String openId, Boolean isSupporter, Boolean hasSupported, Integer supportLevel) { + return PendingMessage.ofMarkdownRaw(Contents.supContent(ctx, username, openId, isSupporter, hasSupported, supportLevel)); + } + private static final class Contents { static String replayTaskContent(Context ctx, APIHelper.ReplayTaskInfo taskInfo) { StringBuilder sb = new StringBuilder(); @@ -378,7 +471,7 @@ static String replayTaskContent(Context ctx, APIHelper.ReplayTaskInfo taskInfo) if (taskInfo.beatmap() != null) { BeatmapExtended beatmap = taskInfo.beatmap(); - sb.append("> 谱面: ").append(cmd("/m " + beatmap.getId(), String.valueOf(beatmap.getId()))).append("\n"); + sb.append("> 谱面: ").append(m(beatmap.getId())).append("\n"); sb.append("> ").append(beatmap.getBeatmapset().getArtist()).append(" - ").append(beatmap.getBeatmapset().getTitle()).append("\n"); sb.append("> ").append(String.format("%.2f★", beatmap.getDifficultyRating())).append(" ").append(beatmap.getVersion()).append("\n"); } @@ -439,7 +532,7 @@ private static String buildScoreLine(JsonObject score) { return null; } - return "> - %s - %s \n (%s %s %s)".formatted(cmd("/s " + id, id), username, rank, accuracy, pp); + return "> - %s - %s \n (%s %s %s)".formatted(s(id), username, rank, accuracy, pp); } private static String getScoreField(JsonObject score, String field) { @@ -464,6 +557,9 @@ static String replayStatContent(Context ctx, RenderStat renderStat, String jobId case "timeout" -> "超时"; case "queued" -> "排队中"; case "rendering" -> "渲染中"; + case "upload_queued" -> "等待上传"; + case "uploading" -> "上传中"; + case "canceled" -> "已取消"; default -> "未知"; }).append("\n"); @@ -505,7 +601,7 @@ static String searchContent(Context ctx, Response> respon static String beatmapsetContent(Context ctx, Response response) { StringBuilder sb = new StringBuilder(); sb.append(at(ctx)).append("谱面集查询完成").append("\n"); - sb.append("> 谱面集: ").append(cmd("/ms " + response.getBeatmapsetId(), response.getBeatmapsetId())).append("\n"); + sb.append("> 谱面集: ").append(ms(response.getBeatmapsetId())).append("\n"); sb.append("> "); for (int i = 0; i < response.getBeatmapStars().size(); i++) { sb.append(cmd("/m " + response.getBeatmapIds().get(i), response.getBeatmapStars().get(i) + "★")).append(" "); @@ -591,7 +687,7 @@ public static String mpContent(Context ctx, MultiplayerRoom content) { final MultiplayerRoom.CurrentPlaylistItem cur = content.getCurrentPlaylistItem(); if (cur != null) { sb += "> 当前: " + "%s - %s - %s [%.2f★ %s]".formatted( - cmd("/m " + cur.getBeatmapId(), String.valueOf(cur.getBeatmapId())), + m(cur.getBeatmapId()), cur.getBeatmap().getBeatmapset().getArtist(), cur.getBeatmap().getBeatmapset().getTitle(), cur.getBeatmap().getDifficultyRating(), @@ -648,6 +744,7 @@ public static String statContent(Context ctx, APIHelper.ServerStatus status) { "> - 连续运行了 `" + BotStat.getCurrentUptime() / 1000 / 60 + "` 分钟" + "\n" + "> - 总共处理了 `" + BotStat.getTotalCommands() + "` 条指令" + "(近30分钟 `" + BotStat.getCommandCountFor(30) + "` )\n" + "> - 总共渲染了 `" + BotStat.getTotalReplays() + "` 条回放" + "\n" + + "> - 总共进行了 `" + RankGuessRecordStore.getTotalGamesCount(null) + "` 次猜 Rank" + "\n" + "> - 并正在为 `" + UserDataStore.countGroups() + "` 个群聊和 `" + UserDataStore.countBoundUser() + "` 位用户提供服务~" + "\n"; return (stat + version + res).trim(); } @@ -666,6 +763,7 @@ public static String helpContent(Context ctx) { > /bma <谱面ID或快捷查询> [Mod] - 分析谱面PP构成和类型 > /ms <谱面集ID或快捷查询> - 获取谱面集 > /r [成绩ID或快捷查询] [[mm:ss]-[mm:ss]] - 生成成绩高光视频或指定片段 + > /rcancel <任务ID> - 取消回放渲染任务 > /rg - 猜 Rank 游戏与个人战绩 > /lb <谱面ID> [玩家ID列表] - 获取指定谱面排行榜 > /watch add <玩家ID/用户名/@用户> [分钟] - 监视群友的新成绩 @@ -674,6 +772,7 @@ public static String helpContent(Context ctx) { > /f - 获取好友列表 详细指令列表请在 [这里](https://docs.seira.top/overview/commands.html) 查看 + 配置额外权限请在 [这里](https://docs.seira.top/overview/use.html#extra-permission) 查看 """ + "\n" + "当前版本: " + VersionInfo.getVersion() + " [更新日志](https://docs.seira.top/overview/changelog.html)" + "\n" + "[常见问题](https://docs.seira.top/overview/faq.html)" + " " + cmd("/stat", "状态信息").trim(); @@ -686,11 +785,7 @@ public static String faqContent(Context ctx) { } public static String bgpContent(Context context, Response response) { - return at(context) + "\n> 背景预览(" - + cmd("/ms " + response.getBeatmapsetId(), response.getBeatmapsetId()) - + " - " - + cmd("/m " + response.getBeatmapId(), response.getBeatmapId()) - + ")"; + return at(context) + "\n> 背景预览(" + ms(response.getBeatmapsetId()) + " - " + m(response.getBeatmapId()) + ")"; } public static String luckContent(Context ctx, DailyLuck.Luck luck, Beatmapset mapset, UploadedImage cover) { @@ -700,14 +795,97 @@ public static String luckContent(Context ctx, DailyLuck.Luck luck, Beatmapset ma "> 人品值: **" + luck.luck() + "**/100\n" + "> 宜: " + luck.ups() + "\n" + "> 忌: " + luck.downs() + "\n\n" + - "今日推荐图: " + cmd("/ms " + mapset.getId(), mapset.getId().toString()) + "\n" + + "今日推荐图: " + ms(mapset.getId()) + "\n" + "> %s - %s [★%.2f-★%.2f]".formatted(mapset.getArtist(), mapset.getTitle(), list.getFirst(), list.getLast()) + "\n" + ">" + cover.toMarkdown(); return sb.trim(); } + + public static String friendStatusContent(String selfOpenId, Long selfUid, String selfUsername, + String targetOpenId, Long targetUid, String targetUsername, + boolean selfFollowed, Boolean targetFollowed) { + final String status; + if (targetFollowed == null) { + if (selfFollowed) { + status = "? 未知 ↓"; + } else { + status = "? 未知 ✕"; + } + } else { + if (selfFollowed && targetFollowed) { + status = "↑ 好友 ↓"; + } else if (selfFollowed) { + status = "✕ 单向 ↓"; + } else if (targetFollowed) { + status = "↑ 单向 ✕"; + } else { + status = "✕ 路人 ✕"; + } + } + return at(selfOpenId) + "你们的好友状态(点击打开个人主页):" + "\n" + + url(selfUsername, "https://osu.ppy.sh/users/" + selfUid) + " (" + at(selfOpenId) + ")\n" + + " " + status + "\n" + + url(targetUsername, "https://osu.ppy.sh/users/" + targetUid) + " (" + at(targetOpenId) + ")"; + } + + public static String missImageContent(Context ctx, String scoreId, Integer index, int size) { + return at(ctx) + s(scoreId) + " - " + "Miss#" + index + "/" + size; + } + + public static String supContent( + Context ctx, String username, String openId, Boolean isSupporter, Boolean hasSupported, Integer supportLevel + ) { + final StringBuilder sb = new StringBuilder(); + sb.append(at(ctx)).append("当前 ").append("`%s`".formatted(username)); + if (openId != null) { + sb.append("(%s)".formatted(at(openId))); + } + sb.append(" 的支持者状态:\n"); + if (isSupporter != null) { + if (isSupporter) { + sb.append("> - √ 是撒泼特"); + } else { + sb.append("> - × 不是撒泼特"); + } + sb.append("\n"); + } + if (hasSupported != null) { + if (hasSupported) { + sb.append("> - √ 有支持历史"); + } else { + sb.append("> - × 无支持历史"); + } + sb.append("\n"); + } + if (supportLevel != null) { + sb.append("> - 支持者等级: `%d`".formatted(supportLevel)); + sb.append("\n"); + } + + return sb.toString().trim(); + } } private record Buttons(String directUrl) { + public static List> missImageButton(Context ctx, String scoreId, Integer index, int size) { + List