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,也会
-每局结算后自动保存战绩。在群内使用 `/rg stats` 查询自己的本群战绩,使用 `/rg stats all` 查询自己的全部群聊战绩。
-统计包括参与数、获胜数、胜率、前 20% 次数和达成率、平均分、最高分、总得分及平均名次。
-每人每局只统计最终猜测,获胜按最终排行榜第一名计算;前 20% 名额向上取整,仅统计至少 5 人参与的对局,达成率以这些对局为分母。
-战绩与用户绑定共用配置中的 SQLite 数据库,分数采用最终结算得分;历史未记录的对局不会自动补入。
-
### 查询群友的排行榜!
@@ -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