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