diff --git a/README.md b/README.md
index 5b0ae5d9..cde85722 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,11 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会
+每局结算后自动保存战绩。在群内使用 `/rg stats` 查询自己的本群战绩,使用 `/rg stats all` 查询自己的全部群聊战绩。
+统计包括参与数、获胜数、胜率、前 20% 次数和达成率、平均分、最高分、总得分及平均名次。
+每人每局只统计最终猜测,获胜按最终排行榜第一名计算;前 20% 名额向上取整,仅统计至少 5 人参与的对局,达成率以这些对局为分母。
+战绩与用户绑定共用配置中的 SQLite 数据库,分数采用最终结算得分;历史未记录的对局不会自动补入。
+
### 查询群友的排行榜!
@@ -65,43 +70,43 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会
> 所有命令都以 `/` 开头。指令详情请见[文档](https://docs.seira.top/overview/commands.html)。
-| 命令 | 用法 | 结果 |
-|-----------------|----------------------------------------------------|---------------------------------------------------------------------------------|
-| `/bind` | `/bind` | 开始osu账号绑定流程 |
-| `/unbind` | `/unbind` | 解除当前用户的osu账号绑定 |
-| `/clearhistory` | `/clearhistory` | 清除当前用户在群聊中的记录 |
-| `/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时返回第一个匹配成绩详情 |
-| `/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 [uid/username/@user]` | 获取指定用户信息 |
-| `/r` | `/r [id/locId/rsN/boN] [[mm:ss]-[mm:ss]]` | 生成并发送指定或最近目标的回放视频。省略范围时自动识别高光,使用`-`渲染整个回放 |
-| `/rg` | `/rg ` | 在群聊中开始、参与或结束猜 Rank 游戏 |
-| `/rsc` | `/rsc [id/locId/rsN/boN] [+,...]` | 生成并发送指定或最近目标的成绩同屏回放视频;追加用户和范围顺序不限 |
-| `/rstat` | `/rstat [id]` | 获取视频生成进度 |
-| `/ms` | `/ms ` | 获取指定谱面集信息 |
-| `/dl` | `/dl ` | 获取指定谱面集的镜像下载链接 |
-| `/sms` | `/sms ` | 搜索谱面集 |
-| `/lb` | `/lb [id] [,...]` | 列出指定谱面排行或表现分排行 |
-| `/daily` | `/daily` | 每日挑战信息 |
-| `/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` | 服务状态和统计信息文本 |
-| `/inspect` | `/inspect` | 获取当前上下文信息 |
-| `/help` | `/help` | 显示帮助信息 |
+| 命令 | 用法 | 结果 |
+|-----------------|---------------------------------------------------------|---------------------------------------------------------------------------------|
+| `/bind` | `/bind` | 开始osu账号绑定流程 |
+| `/unbind` | `/unbind` | 解除当前用户的osu账号绑定 |
+| `/clearhistory` | `/clearhistory` | 清除当前用户在群聊中的记录 |
+| `/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时返回第一个匹配成绩详情 |
+| `/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 [uid/username/@user]` | 获取指定用户信息 |
+| `/r` | `/r [id/locId/rsN/boN] [[mm:ss]-[mm:ss]]` | 生成并发送指定或最近目标的回放视频。省略范围时自动识别高光,使用`-`渲染整个回放 |
+| `/rg` | `/rg ` | 猜 Rank 游戏及个人战绩查询 |
+| `/rsc` | `/rsc [id/locId/rsN/boN] [+,...]` | 生成并发送指定或最近目标的成绩同屏回放视频;追加用户和范围顺序不限 |
+| `/rstat` | `/rstat [id]` | 获取视频生成进度 |
+| `/ms` | `/ms ` | 获取指定谱面集信息 |
+| `/dl` | `/dl ` | 获取指定谱面集的镜像下载链接 |
+| `/sms` | `/sms ` | 搜索谱面集 |
+| `/lb` | `/lb [id] [,...]` | 列出指定谱面排行或表现分排行 |
+| `/daily` | `/daily` | 每日挑战信息 |
+| `/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` | 服务状态和统计信息文本 |
+| `/inspect` | `/inspect` | 获取当前上下文信息 |
+| `/help` | `/help` | 显示帮助信息 |
部分指令会先回复“请求已加入队列,预计等待时间 X 秒”,待异步请求完成后再额外发送结果消息。
diff --git a/pom.xml b/pom.xml
index 93b65a1c..5d0e389f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
com.github.BotSeira
SeiraCore
- 1.11.0
+ 1.11.6
25
diff --git a/src/main/java/xyz/zcraft/seira/Seira.java b/src/main/java/xyz/zcraft/seira/Seira.java
index 654c7493..aab9e820 100644
--- a/src/main/java/xyz/zcraft/seira/Seira.java
+++ b/src/main/java/xyz/zcraft/seira/Seira.java
@@ -6,7 +6,6 @@
import org.apache.logging.log4j.core.config.Configurator;
import xyz.zcraft.seira.config.AppConfig;
import xyz.zcraft.seira.config.ConfigLoader;
-import xyz.zcraft.seira.runtime.SeiraApplication;
import java.io.IOException;
diff --git a/src/main/java/xyz/zcraft/seira/runtime/SeiraApplication.java b/src/main/java/xyz/zcraft/seira/SeiraApplication.java
similarity index 88%
rename from src/main/java/xyz/zcraft/seira/runtime/SeiraApplication.java
rename to src/main/java/xyz/zcraft/seira/SeiraApplication.java
index 4cd98d76..20f8b800 100644
--- a/src/main/java/xyz/zcraft/seira/runtime/SeiraApplication.java
+++ b/src/main/java/xyz/zcraft/seira/SeiraApplication.java
@@ -1,25 +1,28 @@
-package xyz.zcraft.seira.runtime;
+package xyz.zcraft.seira;
+import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
-import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.core.config.Configurator;
-import xyz.zcraft.seira.binding.BindingService;
-import xyz.zcraft.seira.binding.UserDataStore;
import xyz.zcraft.seira.bot.QQBot;
+import xyz.zcraft.seira.config.AppConfig;
+import xyz.zcraft.seira.config.RuntimeConfig;
import xyz.zcraft.seira.console.ConsoleCommandProcessor;
import xyz.zcraft.seira.console.JLineConsole;
import xyz.zcraft.seira.console.UserDataConsoleAccess;
-import xyz.zcraft.seira.config.AppConfig;
-import xyz.zcraft.seira.config.RuntimeConfig;
-import xyz.zcraft.seira.security.AdminRegistry;
+import xyz.zcraft.seira.db.SqliteDatabase;
+import xyz.zcraft.seira.services.BindingService;
import xyz.zcraft.seira.services.BotStat;
import xyz.zcraft.seira.services.DailyLuck;
+import xyz.zcraft.seira.util.AdminRegistry;
+import xyz.zcraft.seira.util.ApplicationExecutors;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
-/** Composition root and single owner of all long-lived application resources. */
+/**
+ * Composition root and single owner of all long-lived application resources.
+ */
public final class SeiraApplication implements AutoCloseable {
private static final Logger LOG = LogManager.getLogger(SeiraApplication.class);
@@ -31,7 +34,7 @@ public final class SeiraApplication implements AutoCloseable {
public SeiraApplication(AppConfig config) {
Objects.requireNonNull(config, "config");
- UserDataStore.init(config.seira().sqlitePath());
+ SqliteDatabase.init(config.seira().sqlitePath());
RuntimeConfig createdRuntimeConfig = new RuntimeConfig(config);
AdminRegistry createdAdmins = new AdminRegistry(config.seira().adminIds());
createdRuntimeConfig.addReloadListener(result -> {
@@ -92,5 +95,6 @@ public void close() {
bot.close();
DailyLuck.saveToFile();
BotStat.shutdown();
+ LOG.info("Shutdown complete");
}
}
diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java
index 672c82d3..42b5cd03 100644
--- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java
+++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java
@@ -9,9 +9,9 @@
import xyz.zcraft.osu.model.*;
import xyz.zcraft.seira.Seira;
import xyz.zcraft.seira.api.data.*;
+import xyz.zcraft.seira.bot.data.FileInfo;
import xyz.zcraft.seira.command.ResolutionException;
import xyz.zcraft.seira.command.parse.ShortcutTarget;
-import xyz.zcraft.seira.bot.data.FileInfo;
import xyz.zcraft.seira.data.UserRef;
import xyz.zcraft.seira.util.TimeDurationParser;
@@ -138,7 +138,7 @@ public static Response getGroupLeaderboardResponse(ShortcutTarget t
return getBase64BytesResponse(
"/beatmaps/" + beatmapId + "/leaderboards",
"获取群排行失败",
- GSON.toJsonTree(Map.of("uids", uids)).toString()
+ GSON.toJsonTree(Map.of("targets", uids)).toString()
);
}
@@ -146,7 +146,7 @@ public static Response getLeaderboardResponse(List uids) {
return getBase64BytesResponse(
"/users/leaderboards",
"获取排行失败",
- GSON.toJsonTree(Map.of("uids", uids)).toString()
+ GSON.toJsonTree(Map.of("targets", uids)).toString()
);
}
@@ -505,8 +505,8 @@ public static ReplayTaskInfo createReplayRenderTask(ShortcutTarget target, TimeD
}
public static ReplayTaskInfo createReplayRenderTask(ShortcutTarget target,
- TimeDurationParser.TimeRange timeRange,
- QqUploadRequest qqUpload) {
+ TimeDurationParser.TimeRange timeRange,
+ QqUploadRequest qqUpload) {
return createReplayTask(target, timeRange, qqUpload);
}
@@ -564,12 +564,83 @@ public static RandomScore getRandomScore() {
}
}
+ public static String getRandomScoreWeight(Long userId, JsonObject weights) {
+ try {
+ JsonObject body = new JsonObject();
+
+ body.add("weight_factor", weights);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(ENDPOINT + "/scores/random/users/" + userId + "/weights"))
+ .POST(HttpRequest.BodyPublishers.ofString(body.toString()))
+ .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 payload.getData().getAsString();
+ } catch (IOException e) {
+ throw requestFailure(e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("成绩权重请求被中断", e);
+ }
+ }
+
+ public static RandomScore getRandomScoreFromUsers(List uids, JsonObject weights) {
+ try {
+ JsonObject body = new JsonObject();
+ final JsonArray uidsArray = new JsonArray();
+ for (Long uid : uids) {
+ uidsArray.add(uid);
+ }
+
+ body.add("uids", uidsArray);
+ body.add("weight_factor", weights);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(ENDPOINT + "/scores/random/users"))
+ .POST(HttpRequest.BodyPublishers.ofString(body.toString()))
+ .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, "获取随机成绩失败");
+ JsonObject data = requireDataObject(payload, "随机成绩响应缺少data");
+ if (!data.has("user") || !data.get("user").isJsonObject()
+ || !data.has("score") || !data.get("score").isJsonObject()) {
+ throw new RuntimeException("随机成绩响应缺少用户或成绩数据");
+ }
+
+ return new RandomScore(
+ GSON.fromJson(data.getAsJsonObject("user"), UserExtended.class),
+ GSON.fromJson(data.getAsJsonObject("score"), Score.class),
+ data.get("best_index").getAsInt(),
+ data.get("diff").getAsString()
+ );
+ } catch (IOException e) {
+ throw requestFailure(e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("随机成绩请求被中断", e);
+ }
+ }
+
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,
- QqUploadRequest qqUpload) {
+ QqUploadRequest qqUpload) {
long beatmapId = lookupBeatmap(target, auth);
JsonObject body = new JsonObject();
if (mods != null && !mods.isBlank()) {
@@ -587,22 +658,22 @@ public static ReplayTaskInfo createBeatmapPreviewTask(ShortcutTarget target, Str
return getReplayTaskInfo(request);
}
- public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget target, String[] ids, String auth,
- QqUploadRequest qqUpload) {
- ids = ids == null ? new String[0] : ids;
+ public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget beatmapTarget, String[] scoreTargets, String auth,
+ QqUploadRequest qqUpload) {
+ scoreTargets = scoreTargets == null ? new String[0] : scoreTargets;
- if (target.isLocalScore()) {
- ids = Stream.concat(Stream.of("s" + target.localScoreId()), Arrays.stream(ids))
+ if (beatmapTarget.isLocalScore()) {
+ scoreTargets = Stream.concat(Stream.of("s" + beatmapTarget.localScoreId()), Arrays.stream(scoreTargets))
.distinct()
.toArray(String[]::new);
}
- if (ids.length == 0) {
+ if (scoreTargets.length == 0) {
throw new RuntimeException("同屏回放需要至少一个ID。");
}
- final long beatmapId = lookupBeatmap(target, auth);
+ final long beatmapId = lookupBeatmap(beatmapTarget, auth);
- JsonObject body = GSON.toJsonTree(Map.of("ids", ids)).getAsJsonObject();
+ JsonObject body = GSON.toJsonTree(Map.of("ids", scoreTargets)).getAsJsonObject();
if (qqUpload != null) {
body.add("qqUpload", GSON.toJsonTree(qqUpload));
}
@@ -622,8 +693,8 @@ public static ReplayRenderResult waitReplayVideo(String taskId) {
}
private static ReplayTaskInfo createReplayTask(ShortcutTarget target,
- TimeDurationParser.TimeRange timeRange,
- QqUploadRequest qqUpload) {
+ TimeDurationParser.TimeRange timeRange,
+ QqUploadRequest qqUpload) {
String scoreId = lookupScoreId(target);
if (timeRange == null) {
@@ -1003,6 +1074,33 @@ public static long resolveUid(UserRef userRef) {
throw new ResolutionException("无法识别指定的玩家");
}
+ public static long getUserRank(UserRef userRef) {
+ long uid = resolveUid(userRef);
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(ENDPOINT + "/users/" + uid + "/rank"))
+ .header("Content-Type", "application/json")
+ .GET()
+ .build();
+
+ final HttpResponse send = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (send.statusCode() != 200) {
+ throw parseHttpError(send.body(), send.statusCode(), "获取玩家Rank失败");
+ }
+
+ final RawResponse response = GSON.fromJson(send.body(), RawResponse.class);
+ ensureApiSuccess(response, "获取玩家Rank失败");
+ final JsonObject data = requireDataObject(response, "获取玩家Rank响应缺少用户数据");
+ return data.get("global_rank").getAsLong();
+ } catch (IOException e) {
+ throw requestFailure(e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("获取玩家Rank请求被中断", e);
+ }
+ }
+
public static Response lookupUser(String username) {
try {
HttpRequest request = HttpRequest.newBuilder()
diff --git a/src/main/java/xyz/zcraft/seira/api/ApiRequestException.java b/src/main/java/xyz/zcraft/seira/api/ApiRequestException.java
index 08143fdf..b7e044fe 100644
--- a/src/main/java/xyz/zcraft/seira/api/ApiRequestException.java
+++ b/src/main/java/xyz/zcraft/seira/api/ApiRequestException.java
@@ -43,8 +43,11 @@ public static String getDefaultMessage(Integer code) {
case BEATMAP_PARSE_FAILED -> "谱面数据解析失败,请稍后重试喵";
case SCORE_PARSE_FAILED -> "成绩数据解析失败,请稍后重试喵";
case REPLAY_PARSE_FAILED -> "回放数据解析失败,请稍后重试喵";
- case RENDER_QUEUE_FULL -> "回放渲染队列已满,请稍后再试喵";
case REPLAY_UPLOAD_FAILED -> "回放上传失败,请稍后再试喵";
+
+ case RENDER_QUEUE_FULL -> "回放渲染队列已满,请稍后再试喵";
+ case RENDERER_UNAVAILABLE -> "回放渲染服务暂不可用,请稍后再试喵";
+ case PERFORMANCE_PLUS_UNAVAILABLE -> "Performance+服务暂不可用,请稍后再试喵";
};
}
diff --git a/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java b/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java
index 38f99d01..21a4b495 100644
--- a/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java
+++ b/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java
@@ -7,8 +7,19 @@ public ReplayRenderException(String status, String error) {
static String formatMessage(String status, String error) {
if (error != null && !error.isBlank()) {
- return "回放渲染失败:" + error.trim();
+ String possibleReason = tryParseError(error);
+ return "回放渲染失败" + (possibleReason != null ? ",这可能是由于" + possibleReason : "") + "。日志输出:\n```\n" + error.trim() + "\n```\n";
}
return "回放渲染失败,状态:" + (status == null || status.isBlank() ? "failed" : status);
}
+
+ static String tryParseError(String error) {
+ if (error.contains("Beatmap not found, closing...")) {
+ return "镜像站谱面版本不正确";
+ } else if (error.contains("Cannot run program \"xvfb-run\": Failed to exec spawn helper")) {
+ return "服务器正在进行更新";
+ } else {
+ return null;
+ }
+ }
}
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 ebe91de9..15a1b9fb 100644
--- a/src/main/java/xyz/zcraft/seira/api/data/ErrorCode.java
+++ b/src/main/java/xyz/zcraft/seira/api/data/ErrorCode.java
@@ -30,7 +30,9 @@ public enum ErrorCode {
REPLAY_PARSE_FAILED(4004),
REPLAY_UPLOAD_FAILED(4005),
- RENDER_QUEUE_FULL(5001);
+ RENDER_QUEUE_FULL(5001),
+ RENDERER_UNAVAILABLE(5002),
+ PERFORMANCE_PLUS_UNAVAILABLE(5003);
private final int code;
diff --git a/src/main/java/xyz/zcraft/seira/bot/MessageSender.java b/src/main/java/xyz/zcraft/seira/bot/MessageSender.java
index 533cbdb5..11f491a3 100644
--- a/src/main/java/xyz/zcraft/seira/bot/MessageSender.java
+++ b/src/main/java/xyz/zcraft/seira/bot/MessageSender.java
@@ -3,10 +3,11 @@
import com.google.gson.Gson;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import xyz.zcraft.seira.api.data.QqUploadRequest;
import xyz.zcraft.seira.bot.data.FileInfo;
import xyz.zcraft.seira.bot.data.Message;
import xyz.zcraft.seira.bot.data.PendingMessage;
-import xyz.zcraft.seira.api.data.QqUploadRequest;
+import xyz.zcraft.seira.bot.data.SentMessage;
import xyz.zcraft.seira.data.UploadedImage;
import xyz.zcraft.seira.services.CosService;
import xyz.zcraft.seira.util.TokenManager;
@@ -25,23 +26,21 @@ protected MessageSender(TokenManager tokenManager, CosService cos) {
this.cos = cos;
}
- public boolean sendPrivateMessage(String userId, Message message) {
+ public SentMessage sendPrivateMessage(String userId, Message message) {
try {
- QQApi.sendPrivateMessage(tokenManager.getToken(), userId, message);
- return true;
+ return QQApi.sendPrivateMessage(tokenManager.getToken(), userId, message);
} catch (RuntimeException e) {
LOG.error("Failed to send message to private {}", userId, e);
- return false;
+ return null;
}
}
- public boolean sendGroupMessage(String groupId, Message message) {
+ public SentMessage sendGroupMessage(String groupId, Message message) {
try {
- QQApi.sendGroupMessage(tokenManager.getToken(), groupId, message);
- return true;
+ return QQApi.sendGroupMessage(tokenManager.getToken(), groupId, message);
} catch (RuntimeException e) {
LOG.error("Failed to send message to group {}", groupId, e);
- return false;
+ return null;
}
}
@@ -133,21 +132,21 @@ public FileInfo uploadGroupMediaBase64(String groupId, int fileType, String base
}
}
- public boolean sendPrivateText(String userId, String content) {
+ public SentMessage sendPrivateText(String userId, String content) {
Message message = new Message();
message.setMsgType(PendingMessage.MSG_TYPE_TEXT);
message.setContent(content);
return sendPrivateMessage(userId, message);
}
- public boolean sendGroupText(String groupId, String content) {
+ public SentMessage sendGroupText(String groupId, String content) {
Message message = new Message();
message.setMsgType(PendingMessage.MSG_TYPE_TEXT);
message.setContent(content);
return sendGroupMessage(groupId, message);
}
- public boolean sendGroupMarkdown(String groupId, String content) {
+ public SentMessage sendGroupMarkdown(String groupId, String content) {
Message message = new Message();
message.setMsgType(PendingMessage.MSG_TYPE_MARKDOWN);
message.setMarkdown(new Gson().toJsonTree(Map.of("content", content)).getAsJsonObject());
diff --git a/src/main/java/xyz/zcraft/seira/bot/QQApi.java b/src/main/java/xyz/zcraft/seira/bot/QQApi.java
index 18b3e0a1..9dde38f6 100644
--- a/src/main/java/xyz/zcraft/seira/bot/QQApi.java
+++ b/src/main/java/xyz/zcraft/seira/bot/QQApi.java
@@ -34,7 +34,7 @@ public class QQApi {
private static final long MAX_MEDIA_SIZE = 200L * 1024 * 1024;
private static final Gson gson = new Gson();
private static final Logger LOG = LogManager.getLogger(QQApi.class);
-
+ private static final Gson GSON = new Gson();
public static String getWSSEndpoint(AccessToken accessToken) {
try {
@@ -73,7 +73,7 @@ public static AccessToken getAccessToken(String appId, String appSecret) throws
);
}
- public static void sendPrivateMessage(AccessToken accessToken, String openId, Message message) {
+ public static SentMessage sendPrivateMessage(AccessToken accessToken, String openId, Message message) {
try {
final var request = newRequestBuilder(accessToken)
.uri(URI.create(ENDPOINT + "/v2/users/" + openId + "/messages"))
@@ -84,12 +84,14 @@ public static void sendPrivateMessage(AccessToken accessToken, String openId, Me
if (send.statusCode() != 200) {
throw new RuntimeException("Failed to send private message to " + openId + " " + send.body());
}
+
+ return GSON.fromJson(send.body(), SentMessage.class);
} catch (IOException | InterruptedException e) {
throw requestFailure(e);
}
}
- public static void sendGroupMessage(AccessToken accessToken, String groupId, Message message) {
+ public static SentMessage sendGroupMessage(AccessToken accessToken, String groupId, Message message) {
try {
final var request = newRequestBuilder(accessToken)
.uri(URI.create(ENDPOINT + "/v2/groups/" + groupId + "/messages"))
@@ -100,6 +102,8 @@ public static void sendGroupMessage(AccessToken accessToken, String groupId, Mes
if (send.statusCode() != 200) {
throw new RuntimeException("Failed to send group message to " + groupId + " " + send.body());
}
+
+ return GSON.fromJson(send.body(), SentMessage.class);
} catch (IOException | InterruptedException e) {
throw requestFailure(e);
}
diff --git a/src/main/java/xyz/zcraft/seira/bot/QQBot.java b/src/main/java/xyz/zcraft/seira/bot/QQBot.java
index 348e256f..7db86c07 100644
--- a/src/main/java/xyz/zcraft/seira/bot/QQBot.java
+++ b/src/main/java/xyz/zcraft/seira/bot/QQBot.java
@@ -8,30 +8,22 @@
import xyz.zcraft.seira.bot.data.PanelItem;
import xyz.zcraft.seira.bot.data.PanelRecord;
import xyz.zcraft.seira.bot.data.QQUser;
-import xyz.zcraft.seira.binding.BindingService;
import xyz.zcraft.seira.command.AttachmentHandler;
import xyz.zcraft.seira.command.route.Router;
+import xyz.zcraft.seira.config.AppConfig;
+import xyz.zcraft.seira.config.RuntimeConfig;
import xyz.zcraft.seira.console.ConsoleCommandProcessor;
import xyz.zcraft.seira.console.ConsoleRuntimeControl;
import xyz.zcraft.seira.console.OstellaCacheControlClient;
-import xyz.zcraft.seira.config.AppConfig;
-import xyz.zcraft.seira.config.RuntimeConfig;
import xyz.zcraft.seira.discord.DiscordBridgeService;
import xyz.zcraft.seira.rankguess.RankGuessGameService;
+import xyz.zcraft.seira.services.BindingService;
import xyz.zcraft.seira.services.BotStat;
import xyz.zcraft.seira.services.CosService;
-import xyz.zcraft.seira.runtime.ApplicationExecutors;
-import xyz.zcraft.seira.security.AdminRegistry;
+import xyz.zcraft.seira.util.AdminRegistry;
+import xyz.zcraft.seira.util.ApplicationExecutors;
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;
-import xyz.zcraft.seira.watch.SqliteSpecificScoreWatchStore;
-import xyz.zcraft.seira.watch.WatchView;
+import xyz.zcraft.seira.watch.*;
import java.net.URI;
import java.nio.file.Files;
@@ -216,6 +208,7 @@ public void close() {
return;
}
stop();
+ rankGuessGameService.saveWeights();
cos.close();
}
@@ -298,6 +291,8 @@ public CacheControlResult controlCache(String operation, String type, long id) {
@Override
public void requestStop() {
+ rankGuessGameService.stopAll();
+
RealtimeServiceInterruptionNotifier.NotificationResult result = interruptionNotifier.notifyGroups(
watchService.activeTransientGroupIds(),
rankGuessGameService.activeGroupIds(),
diff --git a/src/main/java/xyz/zcraft/seira/bot/QqFaceNames.java b/src/main/java/xyz/zcraft/seira/bot/QqFaceNames.java
index fa46e28b..2d662d5d 100644
--- a/src/main/java/xyz/zcraft/seira/bot/QqFaceNames.java
+++ b/src/main/java/xyz/zcraft/seira/bot/QqFaceNames.java
@@ -4,7 +4,9 @@
import static java.util.Map.entry;
-/** Human-readable names for QQ's documented built-in face IDs. */
+/**
+ * Human-readable names for QQ's documented built-in face IDs.
+ */
public final class QqFaceNames {
private static final Map NAMES = Map.ofEntries(
entry("0", "惊讶"), entry("1", "撇嘴"), entry("2", "色"), entry("3", "发呆"),
@@ -78,7 +80,8 @@ public final class QqFaceNames {
entry("346", "你真棒棒"), entry("347", "大展宏兔"), entry("348", "福萝卜")
);
- private QqFaceNames() {}
+ private QqFaceNames() {
+ }
public static String describe(String id) {
if (id == null || id.isBlank()) return "[表情]";
diff --git a/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java b/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java
index 50a53400..0a75db7b 100644
--- a/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java
+++ b/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java
@@ -1,11 +1,6 @@
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;
+import java.util.*;
public final class RealtimeServiceInterruptionNotifier {
private static final String SCORE_WATCH = "成绩监视";
@@ -18,6 +13,23 @@ public RealtimeServiceInterruptionNotifier(MessageSender sender) {
this.sender = Objects.requireNonNull(sender);
}
+ 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 NotificationResult notifyGroups(
Set scoreWatchGroups,
Set rankGuessGroups,
@@ -30,30 +42,13 @@ public NotificationResult notifyGroups(
int sent = 0;
for (Map.Entry> entry : servicesByGroup.entrySet()) {
- if (sender.sendGroupText(entry.getKey(), message(entry.getValue()))) {
+ if (sender.sendGroupText(entry.getKey(), message(entry.getValue())) != null) {
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/bot/WSClient.java b/src/main/java/xyz/zcraft/seira/bot/WSClient.java
index 645f3aab..54a4db30 100644
--- a/src/main/java/xyz/zcraft/seira/bot/WSClient.java
+++ b/src/main/java/xyz/zcraft/seira/bot/WSClient.java
@@ -64,6 +64,16 @@ public WSClient(
LOG.info("QQ Gateway WebSocket Client created");
}
+ private static String firstText(JsonObject object, String... names) {
+ for (String name : names) {
+ if (object.has(name) && !object.get(name).isJsonNull()) {
+ String value = object.get(name).getAsString();
+ if (!value.isBlank()) return value;
+ }
+ }
+ return "QQ用户";
+ }
+
@Override
public void onOpen(ServerHandshake handshake) {
LOG.info("Gateway connected");
@@ -229,16 +239,6 @@ private String stripSelfMention(String content) {
: normalized;
}
- private static String firstText(JsonObject object, String... names) {
- for (String name : names) {
- if (object.has(name) && !object.get(name).isJsonNull()) {
- String value = object.get(name).getAsString();
- if (!value.isBlank()) return value;
- }
- }
- return "QQ用户";
- }
-
private void sendIdentify() {
JsonObject data = new JsonObject();
data.addProperty("token", "QQBot " + tokenSupplier.get().token());
diff --git a/src/main/java/xyz/zcraft/seira/bot/data/Message.java b/src/main/java/xyz/zcraft/seira/bot/data/Message.java
index 06146246..53c2c0dd 100644
--- a/src/main/java/xyz/zcraft/seira/bot/data/Message.java
+++ b/src/main/java/xyz/zcraft/seira/bot/data/Message.java
@@ -36,4 +36,7 @@ public class Message {
@SerializedName("is_wakeup")
private Boolean isWakeup;
+
+ @SerializedName("message_reference")
+ private MessageReference messageReference;
}
diff --git a/src/main/java/xyz/zcraft/seira/bot/data/MessageReference.java b/src/main/java/xyz/zcraft/seira/bot/data/MessageReference.java
new file mode 100644
index 00000000..d08a74ec
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/bot/data/MessageReference.java
@@ -0,0 +1,23 @@
+package xyz.zcraft.seira.bot.data;
+
+import com.google.gson.annotations.SerializedName;
+
+import java.util.Optional;
+
+public record MessageReference(
+ @SerializedName("message_id") String messageId
+) {
+ public static MessageReference of(SentMessage message) {
+ return Optional.ofNullable(message)
+ .map(SentMessage::extInfo)
+ .map(SentMessage.MessageExtInfo::refIdx)
+ .map(MessageReference::new)
+ .orElse(null);
+ }
+
+ public static MessageReference of(String messageId) {
+ return Optional.ofNullable(messageId)
+ .map(MessageReference::new)
+ .orElse(null);
+ }
+}
diff --git a/src/main/java/xyz/zcraft/seira/bot/data/PendingMessage.java b/src/main/java/xyz/zcraft/seira/bot/data/PendingMessage.java
index 6f6f7ffb..d9da7f41 100644
--- a/src/main/java/xyz/zcraft/seira/bot/data/PendingMessage.java
+++ b/src/main/java/xyz/zcraft/seira/bot/data/PendingMessage.java
@@ -21,6 +21,7 @@ public class PendingMessage {
private FileInfo uploadedMedia = null;
private int fileType = -1;
private boolean upload = true;
+ private MessageReference messageReference = null;
public static PendingMessage ofString(String content) {
final PendingMessage message = new PendingMessage();
@@ -89,4 +90,9 @@ public PendingMessage doUpload(boolean upload) {
this.upload = upload;
return this;
}
+
+ public PendingMessage ref(MessageReference reference) {
+ this.messageReference = reference;
+ return this;
+ }
}
diff --git a/src/main/java/xyz/zcraft/seira/bot/data/SentMessage.java b/src/main/java/xyz/zcraft/seira/bot/data/SentMessage.java
new file mode 100644
index 00000000..c196e9ea
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/bot/data/SentMessage.java
@@ -0,0 +1,14 @@
+package xyz.zcraft.seira.bot.data;
+
+import com.google.gson.annotations.SerializedName;
+
+public record SentMessage(
+ String id,
+ String timestamp,
+ @SerializedName("ext_info") MessageExtInfo extInfo
+) {
+ public record MessageExtInfo(
+ @SerializedName("ref_idx") String refIdx
+ ) {
+ }
+}
diff --git a/src/main/java/xyz/zcraft/seira/command/AttachmentHandler.java b/src/main/java/xyz/zcraft/seira/command/AttachmentHandler.java
index 3c14234d..932a6014 100644
--- a/src/main/java/xyz/zcraft/seira/command/AttachmentHandler.java
+++ b/src/main/java/xyz/zcraft/seira/command/AttachmentHandler.java
@@ -7,6 +7,7 @@
import xyz.zcraft.seira.bot.data.Attachment;
import xyz.zcraft.seira.bot.data.PendingMessage;
import xyz.zcraft.seira.command.reply.ReplyFactory;
+
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
diff --git a/src/main/java/xyz/zcraft/seira/command/CommandRegistry.java b/src/main/java/xyz/zcraft/seira/command/CommandRegistry.java
index a13f9259..7029c832 100644
--- a/src/main/java/xyz/zcraft/seira/command/CommandRegistry.java
+++ b/src/main/java/xyz/zcraft/seira/command/CommandRegistry.java
@@ -1,10 +1,6 @@
package xyz.zcraft.seira.command;
-import java.util.LinkedHashMap;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
+import java.util.*;
public final class CommandRegistry {
private final Map handlers;
@@ -17,6 +13,10 @@ public static Builder builder() {
return new Builder();
}
+ private static String normalize(String command) {
+ return Objects.requireNonNull(command, "command").trim().toLowerCase(Locale.ROOT);
+ }
+
public void dispatch(Context context, CommandHandler fallback) {
CommandHandler handler = handlers.get(normalize(context.command()));
(handler == null ? fallback : handler).handle(context);
@@ -55,8 +55,4 @@ public CommandRegistry build() {
return new CommandRegistry(handlers);
}
}
-
- private static String normalize(String command) {
- return Objects.requireNonNull(command, "command").trim().toLowerCase(Locale.ROOT);
- }
}
diff --git a/src/main/java/xyz/zcraft/seira/command/CommandReplyChannel.java b/src/main/java/xyz/zcraft/seira/command/CommandReplyChannel.java
index 8a8e3fa5..5b9228fd 100644
--- a/src/main/java/xyz/zcraft/seira/command/CommandReplyChannel.java
+++ b/src/main/java/xyz/zcraft/seira/command/CommandReplyChannel.java
@@ -1,6 +1,7 @@
package xyz.zcraft.seira.command;
import xyz.zcraft.seira.bot.data.PendingMessage;
+import xyz.zcraft.seira.data.SendResult;
/**
* The outbound side of one command invocation.
@@ -10,9 +11,9 @@
* not consume the passive reply sequence.
*/
public interface CommandReplyChannel {
- boolean sendReply(PendingMessage message);
+ SendResult sendReply(PendingMessage message);
- boolean sendProactive(PendingMessage message);
+ SendResult sendProactive(PendingMessage message);
- boolean sendQueueNotice(PendingMessage message);
+ SendResult sendQueueNotice(PendingMessage message);
}
diff --git a/src/main/java/xyz/zcraft/seira/command/Context.java b/src/main/java/xyz/zcraft/seira/command/Context.java
index 6306d29d..ccc13d70 100644
--- a/src/main/java/xyz/zcraft/seira/command/Context.java
+++ b/src/main/java/xyz/zcraft/seira/command/Context.java
@@ -1,6 +1,7 @@
package xyz.zcraft.seira.command;
import xyz.zcraft.seira.bot.data.PendingMessage;
+import xyz.zcraft.seira.data.SendResult;
import java.util.Objects;
@@ -11,6 +12,7 @@ public record Context(
String command,
String[] args,
String query,
+ String rawContent,
CommandReplyChannel replies) {
public Context(
String senderUserId,
@@ -18,9 +20,10 @@ public Context(
String messageId,
String command,
String[] args,
+ String rawContent,
String query
) {
- this(senderUserId, groupId, messageId, command, args, query, null);
+ this(senderUserId, groupId, messageId, command, args, query, rawContent, null);
}
public Context {
@@ -48,32 +51,36 @@ public boolean inGroup() {
public Context withReplies(CommandReplyChannel replyChannel) {
return new Context(
- senderUserId, groupId, messageId, command, args, query,
+ senderUserId, groupId, messageId, command, args, query, rawContent,
Objects.requireNonNull(replyChannel, "replyChannel")
);
}
public Context asCommand(String nextCommand, String[] nextArgs, String nextQuery) {
return new Context(
- senderUserId, groupId, messageId, nextCommand, nextArgs, nextQuery, replies
+ senderUserId, groupId, messageId, nextCommand, nextArgs, nextQuery, rawContent, replies
);
}
- /** Sends a passive reply associated with the message that invoked this command. */
- public boolean sendReply(PendingMessage message) {
+ /**
+ * Sends a passive reply associated with the message that invoked this command.
+ */
+ public SendResult sendReply(PendingMessage message) {
return requireReplies().sendReply(Objects.requireNonNull(message, "message"));
}
- public boolean sendReply(String message) {
+ public SendResult 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) {
+ /**
+ * Sends an active message to the same user or group, without an inbound message reference.
+ */
+ public SendResult sendMessage(PendingMessage message) {
return requireReplies().sendProactive(Objects.requireNonNull(message, "message"));
}
- public boolean sendQueueNotice(PendingMessage message) {
+ public SendResult sendQueueNotice(PendingMessage message) {
return requireReplies().sendQueueNotice(Objects.requireNonNull(message, "message"));
}
diff --git a/src/main/java/xyz/zcraft/seira/command/ReplayResultStore.java b/src/main/java/xyz/zcraft/seira/command/ReplayResultStore.java
index dcac0e85..de5e26c3 100644
--- a/src/main/java/xyz/zcraft/seira/command/ReplayResultStore.java
+++ b/src/main/java/xyz/zcraft/seira/command/ReplayResultStore.java
@@ -9,6 +9,13 @@
public final class ReplayResultStore {
private final ConcurrentMap results = new ConcurrentHashMap<>();
+ private static String requireTaskId(String taskId) {
+ if (taskId == null || taskId.isBlank()) {
+ throw new IllegalArgumentException("Task ID must not be blank");
+ }
+ return taskId;
+ }
+
void put(String taskId, APIHelper.ReplayRenderResult result) {
results.put(requireTaskId(taskId), Objects.requireNonNull(result));
}
@@ -22,11 +29,4 @@ public void remove(String taskId) {
results.remove(taskId);
}
}
-
- private static String requireTaskId(String taskId) {
- if (taskId == null || taskId.isBlank()) {
- throw new IllegalArgumentException("Task ID must not be blank");
- }
- return taskId;
- }
}
diff --git a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java
index 9244ffd2..988d6f7f 100644
--- a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java
+++ b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java
@@ -10,10 +10,8 @@
import xyz.zcraft.seira.api.data.QqUploadRequest;
import xyz.zcraft.seira.api.data.Response;
import xyz.zcraft.seira.bot.MessageSender;
-import xyz.zcraft.seira.bot.data.FileInfo;
-import xyz.zcraft.seira.bot.data.MDMessage;
-import xyz.zcraft.seira.bot.data.Message;
-import xyz.zcraft.seira.bot.data.PendingMessage;
+import xyz.zcraft.seira.bot.data.*;
+import xyz.zcraft.seira.data.SendResult;
import xyz.zcraft.seira.data.UploadedImage;
import xyz.zcraft.seira.discord.DiscordBridgeService;
import xyz.zcraft.seira.services.ApiRequestStats;
@@ -46,6 +44,30 @@ public TaskCoordinator(
this.discordBridgeService = java.util.Objects.requireNonNull(discordBridgeService);
}
+ static String resolveErrorMessage(Exception exception) {
+ Throwable cursor = exception;
+ while (cursor != null) {
+ switch (cursor) {
+ case ApiRequestException e -> {
+ return ApiRequestException.getDefaultMessage(e.getErrorCode());
+ }
+ case ClosedChannelException _ -> {
+ return "oStella API 无法连接,请稍后再试。";
+ }
+ case ResolutionException e -> {
+ return e.getMessage();
+ }
+ case ReplayRenderException e -> {
+ return e.getMessage();
+ }
+ default -> {
+ }
+ }
+ cursor = cursor.getCause();
+ }
+ return "请求处理失败,请稍后再试。";
+ }
+
public CommandReplyChannel openReplyChannel(
String targetId,
String messageId,
@@ -73,7 +95,7 @@ public boolean runApiRequest(Context ctx, String requestType, Runnable action) {
action.run();
return true;
} catch (Exception e) {
- ctx.sendReply(PendingMessage.ofString(resolveErrorMessage(e)));
+ ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + resolveErrorMessage(e)));
String message = e.getMessage();
if (e instanceof ApiRequestException apiException) {
message += " - " + apiException.getDefaultMessage();
@@ -97,10 +119,8 @@ public APIHelper.ReplayRenderResult waitForReplay(APIHelper.ReplayTaskInfo taskI
}
APIHelper.ReplayRenderResult result = APIHelper.waitReplayVideo(taskInfo.taskId());
- if (result != null) {
- replayResults.put(taskInfo.taskId(), result);
- BotStat.incrementReplays();
- }
+ replayResults.put(taskInfo.taskId(), result);
+ BotStat.incrementReplays();
return result;
}
@@ -146,13 +166,15 @@ public void runReplayRequest(
return;
}
- if (ctx.sendReply(replayVideoMessage(result))) {
+ if (ctx.sendReply(replayVideoMessage(result)).success()) {
removeReplayResult(taskInfo.taskId());
}
});
}
- /** Waits for an image renderer and returns a sendable message without sending it. */
+ /**
+ * Waits for an image renderer and returns a sendable message without sending it.
+ */
private PendingMessage waitForImage(
Context ctx,
Supplier> creator,
@@ -182,7 +204,7 @@ private PendingMessage combineImageAndCompletion(UploadedImage image, PendingMes
);
}
- public boolean sendOutboundMessage(String targetId, String messageId, boolean groupMessage, PendingMessage pendingMsg, AtomicInteger messageSeqCounter) {
+ public SendResult sendOutboundMessage(String targetId, String messageId, boolean groupMessage, PendingMessage pendingMsg, AtomicInteger messageSeqCounter) {
Message message = new Message();
message.setMsgType(pendingMsg.getMsgType());
message.setMsgId(messageId);
@@ -190,6 +212,10 @@ public boolean sendOutboundMessage(String targetId, String messageId, boolean gr
message.setMsgSeq(messageSeqCounter.getAndIncrement());
}
+ if (pendingMsg.getMessageReference() != null) {
+ message.setMessageReference(pendingMsg.getMessageReference());
+ }
+
if (pendingMsg instanceof MDMessage md) {
message.setMsgType(PendingMessage.MSG_TYPE_MARKDOWN);
message.setMarkdown(new Gson().toJsonTree(Map.of("content", md.getMarkdown())).getAsJsonObject());
@@ -234,21 +260,21 @@ public boolean sendOutboundMessage(String targetId, String messageId, boolean gr
}
}
- boolean sendResult;
+ SentMessage sentMessage;
if (groupMessage) {
- sendResult = messageSender.sendGroupMessage(targetId, message);
+ sentMessage = messageSender.sendGroupMessage(targetId, message);
} else {
- sendResult = messageSender.sendPrivateMessage(targetId, message);
+ sentMessage = messageSender.sendPrivateMessage(targetId, message);
}
- if (sendResult && groupMessage) {
+ if (sentMessage != null && groupMessage) {
PendingMessage portableResult = uploadResult
? pendingMsg
: PendingMessage.ofString("媒体文件上传失败");
discordBridgeService.acceptQqCommandReply(targetId, portableResult);
}
- return uploadResult && sendResult;
+ return new SendResult(uploadResult && sentMessage != null, sentMessage);
}
private final class OutboundReplyChannel implements CommandReplyChannel {
@@ -271,46 +297,22 @@ private OutboundReplyChannel(
}
@Override
- public synchronized boolean sendReply(PendingMessage message) {
+ public synchronized SendResult sendReply(PendingMessage message) {
return sendOutboundMessage(targetId, messageId, groupMessage, message, passiveSequence);
}
@Override
- public synchronized boolean sendProactive(PendingMessage message) {
+ public synchronized SendResult sendProactive(PendingMessage message) {
return sendOutboundMessage(targetId, null, groupMessage, message, null);
}
@Override
- public synchronized boolean sendQueueNotice(PendingMessage message) {
+ public synchronized SendResult sendQueueNotice(PendingMessage message) {
if (groupMessage && !queueMessageInGroup) {
- return true;
+ return new SendResult(true, null);
}
return sendReply(message);
}
}
-
- static String resolveErrorMessage(Exception exception) {
- Throwable cursor = exception;
- while (cursor != null) {
- switch (cursor) {
- case ApiRequestException e -> {
- return ApiRequestException.getDefaultMessage(e.getErrorCode());
- }
- case ClosedChannelException _ -> {
- return "oStella API 无法连接,请稍后再试。";
- }
- case ResolutionException e -> {
- return e.getMessage();
- }
- case ReplayRenderException e -> {
- return e.getMessage();
- }
- default -> {
- }
- }
- cursor = cursor.getCause();
- }
- return "请求处理失败,请稍后再试。";
- }
}
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 24cf7b32..b5671813 100644
--- a/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java
+++ b/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java
@@ -6,12 +6,14 @@
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.Context;
+import xyz.zcraft.seira.command.TargetHistory;
+import xyz.zcraft.seira.command.TaskCoordinator;
import xyz.zcraft.seira.command.parse.Resolver;
-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.reply.CommandUsage;
+import xyz.zcraft.seira.command.reply.ReplyFactory;
import java.util.List;
import java.util.function.Function;
@@ -302,9 +304,9 @@ public void handleSms(Context ctx) {
return;
}
taskCoordinator.runApiRequest(ctx, "Search Beatmapset", () -> {
- Response> searchResponse = APIHelper.searchBeatmapSetResponse(searchQuery);
- ctx.sendReply(replyFactory.searchMessage(ctx, searchResponse, searchQuery));
- });
+ 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 9a81bcf5..26fe2a67 100644
--- a/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java
+++ b/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java
@@ -1,11 +1,11 @@
package xyz.zcraft.seira.command.handler;
-import xyz.zcraft.seira.binding.BindingService;
-import xyz.zcraft.seira.binding.UserDataStore;
import xyz.zcraft.seira.bot.data.PendingMessage;
import xyz.zcraft.seira.command.Context;
import xyz.zcraft.seira.command.reply.ReplyFactory;
import xyz.zcraft.seira.config.AppConfig;
+import xyz.zcraft.seira.db.UserDataStore;
+import xyz.zcraft.seira.services.BindingService;
public final class BindingCommandHandler {
private final AppConfig config;
@@ -30,7 +30,7 @@ public void handleBind(Context ctx) {
}
if (ctx.args().length != 0) {
- ctx.sendReply(PendingMessage.ofString("用法:/bind"));
+ ctx.sendReply(PendingMessage.ofString("用法(无需ID):/bind"));
return;
}
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 a5b2a698..63c83970 100644
--- a/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java
+++ b/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java
@@ -17,6 +17,10 @@ public DcsCommandHandler(DiscordBridgeService bridgeService) {
this.bridgeService = Objects.requireNonNull(bridgeService);
}
+ private static void usage(Context ctx) {
+ ctx.sendReply(PendingMessage.ofString(USAGE));
+ }
+
public void handleDcs(Context ctx) {
if (!ctx.inGroup()) {
ctx.sendReply(PendingMessage.ofString("/dcs 仅支持在群聊中使用。"));
@@ -44,7 +48,7 @@ private void handleStart(Context ctx) {
return;
}
- final boolean b = ctx.sendMessage(PendingMessage.ofString("正在尝试开启 Discord 消息同步,请稍候..."));
+ final boolean b = ctx.sendMessage(PendingMessage.ofString("正在尝试开启 Discord 消息同步,请稍候...")).success();
if (!b) {
ctx.sendReply(PendingMessage.ofMarkdownRaw("由于缺少主动消息权限,无法添加消息同步!权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)~"));
return;
@@ -70,8 +74,4 @@ private void handleStop(Context ctx) {
removed ? "Discord 消息同步已解除。" : "当前群聊尚未开启 Discord 消息同步。"
));
}
-
- private static void usage(Context ctx) {
- ctx.sendReply(PendingMessage.ofString(USAGE));
- }
}
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 f5c5333c..1da47a28 100644
--- a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java
+++ b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java
@@ -4,13 +4,13 @@
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.db.UserDataStore;
import xyz.zcraft.seira.watch.MultiplayerRoomVersion;
+import xyz.zcraft.seira.watch.MultiplayerRoomWatchService;
import xyz.zcraft.seira.watch.RoomWatchView;
import java.util.Locale;
@@ -40,6 +40,53 @@ public MultiplayerRoomWatchCommandHandler(
this.watchService = Objects.requireNonNull(watchService);
}
+ 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));
+ }
+
public void handleMpWatch(Context ctx) {
if (!ctx.inGroup()) {
ctx.sendReply(PendingMessage.ofString("/mpwatch 仅支持群聊使用。"));
@@ -86,7 +133,7 @@ private void handleStart(Context ctx, int argumentOffset) {
}
taskCoordinator.runApiRequest(ctx, "Start Multiplayer Room Watch", () -> {
- if (!ctx.sendMessage(PendingMessage.ofString("正在尝试启动多人房间监视……"))) {
+ if (!ctx.sendMessage(PendingMessage.ofString("正在尝试启动多人房间监视……")).success()) {
ctx.sendReply(PendingMessage.ofString(
"由于缺少主动消息权限,无法启动监视!权限配置请见:https://docs.seira.top/overview/use.html#extra-permission"
));
@@ -96,8 +143,8 @@ private void handleStart(Context ctx, int argumentOffset) {
RoomWatchView view = watchService.watch(
ctx.groupId(), ctx.senderUserId(), target.version(), target.roomId()
);
- ctx.sendReply(PendingMessage.ofString(
- "已开始监视" + formatRoom(view) + "。"
+ ctx.sendReply(PendingMessage.ofMarkdownRaw(
+ "已开始监视 `" + formatRoom(view) + "` 。"
+ "之后完成的每张图都会自动推送结果。"
));
} catch (IllegalArgumentException | IllegalStateException e) {
@@ -141,53 +188,6 @@ private void handleStatus(Context ctx) {
));
}
- 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/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java
index cabd1310..5b855093 100644
--- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java
+++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java
@@ -1,14 +1,24 @@
package xyz.zcraft.seira.command.handler;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
import xyz.zcraft.seira.api.APIHelper;
+import xyz.zcraft.seira.api.data.RandomScore;
+import xyz.zcraft.seira.bot.data.MessageReference;
import xyz.zcraft.seira.bot.data.PendingMessage;
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.rankguess.RankGuessGame;
-import xyz.zcraft.seira.rankguess.RankGuessGameService;
-
-import java.util.Locale;
+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.data.*;
+
+import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
import java.util.regex.Matcher;
@@ -17,27 +27,31 @@
import static xyz.zcraft.seira.command.reply.ReplyFactory.at;
public final class RankGuessCommandHandler {
- private static final String USAGE = "用法:/rg start | /rg #Rank | /rg end";
- private static final Pattern RANK_PATTERN = Pattern.compile("^#(\\d+)[wk]?$");
+ 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]?$");
private final TaskCoordinator taskCoordinator;
private final ReplyFactory replyFactory;
private final RankGuessGameService games;
+ private final Resolver resolver;
private final Predicate adminAuthorizer;
public RankGuessCommandHandler(
TaskCoordinator taskCoordinator,
ReplyFactory replyFactory,
RankGuessGameService games,
+ Resolver resolver,
Predicate adminAuthorizer
) {
this.taskCoordinator = taskCoordinator;
this.replyFactory = replyFactory;
this.games = games;
+ this.resolver = resolver;
this.adminAuthorizer = adminAuthorizer;
}
private static Long parseRank(String argument) {
- final Matcher matcher = RANK_PATTERN.matcher(argument);
+ final Matcher matcher = RANK_PATTERN.matcher(argument.replace(",",""));
if (!matcher.matches()) {
return null;
}
@@ -50,8 +64,9 @@ private static Long parseRank(String argument) {
} else if (argument.endsWith("k")) {
multiplier = 1000;
}
- return base * multiplier;
- } catch (NumberFormatException _) {
+
+ return Math.multiplyExact(base, multiplier);
+ } catch (NumberFormatException | ArithmeticException _) {
return null;
}
}
@@ -61,31 +76,268 @@ public void handleRankGuess(Context ctx) {
ctx.sendReply(PendingMessage.ofString("/rg 仅支持群聊使用。"));
return;
}
- if (ctx.argumentCount() != 1) {
- ctx.sendReply(PendingMessage.ofString(USAGE));
+
+ if (ctx.argumentCount() == 0) {
+ currentStatus(ctx);
return;
}
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));
+ }
+ return;
+ }
if ("start".equalsIgnoreCase(argument)) {
- start(ctx);
+ 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));
+ return;
+ }
+
+ start(ctx, true);
return;
}
if ("end".equalsIgnoreCase(argument)) {
- end(ctx, true);
+ if (ctx.argumentCount() != 1) {
+ ctx.sendReply(PendingMessage.ofString(USAGE));
+ return;
+ }
+ end(ctx, false);
return;
}
- Long rank = parseRank(argument);
- if (rank == null) {
- ctx.sendReply(PendingMessage.ofString(USAGE));
+ if ("wish".equalsIgnoreCase(argument)) {
+ if (ctx.argumentCount() != 1) {
+ ctx.sendReply(PendingMessage.ofString(USAGE));
+ return;
+ }
+ wish(ctx);
return;
}
+
+ if ("weight".equalsIgnoreCase(argument)) {
+ if (ctx.argumentCount() != 1) {
+ ctx.sendReply(PendingMessage.ofString(USAGE));
+ return;
+ }
+ weight(ctx);
+ return;
+ }
+
+ Long rank;
+
+ if (resolver.looksLikeMention(argument)) {
+ final UserRefResolution userRefResolution = resolver.resolveUserRefArgument(argument);
+
+ if (userRefResolution.errorMessage() != null) {
+ ctx.sendReply(userRefResolution.errorMessage());
+ }
+
+ final UserRef userRef = userRefResolution.userRef();
+
+ rank = APIHelper.getUserRank(userRef);
+ } else {
+ rank = parseRank(argument);
+ if (rank == null) {
+ ctx.sendReply(PendingMessage.ofString(USAGE));
+ return;
+ }
+ }
+
guess(ctx, rank);
}
- private void start(Context ctx) {
- RankGuessGameService.Reservation reservation = games.reserve(ctx.groupId(), ctx.senderUserId());
+ 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 开始游戏喵~"));
+ return;
+ }
+
+ final MessageReference videoMessageRef = games.getVideoMessageRef(ctx.groupId());
+
+ if (status == RankGuessGameService.GameStatus.STARTING || videoMessageRef == null) {
+ ctx.sendReply(PendingMessage.ofString("游戏即将开始,稍等片刻喵~"));
+ return;
+ }
+
+ String reply = "本群猜 Rank 正火热进行中🔥🔥🔥" +
+ "\n目前已经有 " + games.getParticipantCount(ctx.groupId()) + " 个参与者~";
+
+ ctx.sendReply(PendingMessage.ofString(reply).ref(games.getVideoMessageRef(ctx.groupId())));
+ }
+
+ private void weight(Context ctx) {
+ final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId());
+ if (boundUid == null) {
+ ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于未绑定,无法查看权重喵~"));
+ return;
+ }
+ 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 randomScoreWeight = APIHelper.getRandomScoreWeight(boundUid, games.generateWeights(ctx.groupId()));
+
+ reply.append("你的成绩在本群权重:\n>").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());
+ try {
+ RankGuessRecordStore.Statistics.Personal statistics = RankGuessRecordStore.getPersonalStatistics(
+ ctx.senderUserId(),
+ allGroups ? null : ctx.groupId(),
+ null
+ );
+
+ RankGuessRecordStore.Statistics.Personal recentStatistics = RankGuessRecordStore.getRecentPersonalStatistics(
+ ctx.senderUserId(),
+ allGroups ? null : ctx.groupId(),
+ null,
+ Rank.RECENT_GAME_LIMIT
+ );
+
+ 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());
+
+ ctx.sendReply(replyFactory.rankGuessStatisticsMessage(
+ ctx, statistics, recentStatistics, allGroups, rank, pickedTimes, groupGameCount
+ ));
+ } catch (RuntimeException e) {
+ LOG.error("Failed to query rank guess statistics", e);
+ ctx.sendReply(PendingMessage.ofString("战绩查询失败,请稍后重试喵。"));
+ }
+ }
+
+ 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();
+
+ StringBuilder reply = new StringBuilder();
+
+ 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");
+ }
+ }
+ }
+
+ ctx.sendReply(PendingMessage.ofMarkdownRaw(reply.toString().trim()));
+ } catch (RuntimeException e) {
+ LOG.error("Failed to query rank guess statistics", e);
+ ctx.sendReply(PendingMessage.ofString("战绩查询失败,请稍后重试喵。"));
+ }
+ }
+
+ private void wish(Context ctx) {
+ final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId());
+
+ if (boundUid == null) {
+ ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于未绑定,无法进行许愿喵~"));
+ return;
+ }
+
+ final RankGuessGameService.WishResult wish = games.wish(ctx.groupId(), boundUid);
+
+ ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + switch (wish) {
+ case SUCCESS -> "小星听到你的愿望啦!";
+ case ALREADY_WISHED -> "已经许过愿了喵~";
+ case RECENTLY_PICKED -> "最近已经被抽到过了喵~";
+ case null -> "发生了一些不好的事情...";
+ }));
+ }
+
+ 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 正在进行。"));
return;
@@ -97,18 +349,38 @@ private void start(Context ctx) {
"Rank Guess Render",
() -> {
final PendingMessage message = PendingMessage.ofMarkdownRaw(at(ctx) + "正在选定随机成绩...");
- final boolean activeMessageEnabled = ctx.sendMessage(message);
+ final boolean activeMessageEnabled = ctx.sendMessage(message).success();
if (!activeMessageEnabled) {
ctx.sendReply(message);
}
- var randomScore = APIHelper.getRandomScore();
- RankGuessGameService.Round round = RankGuessGameService.Round.from(randomScore);
+ RandomScore randomScore;
- String content = at(ctx) + "随机用户与成绩已选定,正在渲染回放片段...";
+ 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);
+
+ String content = at(ctx);
+
+ if (fromGroup) {
+ content += "随机群友及其成绩已选定";
+ } else {
+ content += "随机用户与成绩已选定";
+ }
+
+ content += ",正在渲染回放片段...";
if (!activeMessageEnabled) {
- content += "\n\n> 提示: 由于缺少主动消息权限,阶段提示已禁用。权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)。";
+ content += "\n\n> 提示: 由于缺少主动消息权限,阶段提示与自动结束已禁用。稍后需要使用 `/rg end` 手动结束。权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)。";
}
ctx.sendReply(PendingMessage.ofMarkdownRaw(content));
@@ -117,13 +389,20 @@ private void start(Context ctx) {
round.scoreId(), taskCoordinator.createVideoUploadRequest(ctx)
);
- var replay = taskCoordinator.waitForReplay(renderTask);
+ APIHelper.ReplayRenderResult replay = null;
+ try {
+ replay = taskCoordinator.waitForReplay(renderTask);
+ } catch (Exception e) {
+ LOG.error("Failed to render replay for rank guess", e);
+ }
+
if (replay == null) {
ctx.sendReply(PendingMessage.ofMarkdownRaw("由于回放渲染失败,本轮游戏已取消~"));
return;
}
- boolean videoSent = ctx.sendReply(taskCoordinator.replayVideoMessage(replay));
+ final SendResult sendResult = ctx.sendReply(taskCoordinator.replayVideoMessage(replay));
+ boolean videoSent = sendResult.success();
if (!videoSent) {
taskCoordinator.removeReplayResult(renderTask.taskId());
ctx.sendReply(PendingMessage.ofMarkdownRaw("由于回放发送失败,本轮游戏已取消~"));
@@ -131,7 +410,7 @@ private void start(Context ctx) {
}
taskCoordinator.removeReplayResult(renderTask.taskId());
- var game = games.activate(reservation, round);
+ var game = games.activate(reservation, round, MessageReference.of(sendResult.sentMessage()));
if (game == null) {
ctx.sendReply(PendingMessage.ofMarkdownRaw("无法开始游戏,请稍后再试喵"));
@@ -142,7 +421,11 @@ private void start(Context ctx) {
StringBuilder result = new StringBuilder("回放渲染完成,游戏已开始!请在群内发送 `/rg #Rank` 猜测排名~");
- var hints = RankGuessGameService.prepareHints(round.getNormalHints(), 4);
+ if (fromGroup) {
+ result.append("\n").append("__Tip: 这是一位群友的成绩喵~__").append("\n");
+ }
+
+ var hints = HintUtil.prepareHints(round.getNormalHints(), 4);
if (!activeMessageEnabled) {
result.append("\n").append("> 提示: ");
@@ -152,7 +435,7 @@ private void start(Context ctx) {
} else {
result.append("\n").append("> 第一个提示将在 1 分钟后揭晓~");
}
- boolean startMessageSent = ctx.sendReply(PendingMessage.ofMarkdownRaw(result.toString().trim()));
+ boolean startMessageSent = ctx.sendReply(PendingMessage.ofMarkdownRaw(result.toString().trim())).success();
if (!activeMessageEnabled) {
if (startMessageSent) {
@@ -163,7 +446,9 @@ private void start(Context ctx) {
boolean firstHint = true;
- while (!hints.isEmpty()) {
+ StringBuilder hintString = new StringBuilder();
+
+ while (!hints.isEmpty() && !game.isEnded()) {
try {
//noinspection BusyWait
Thread.sleep((firstHint ? 60 : 30) * 1000);
@@ -180,15 +465,17 @@ private void start(Context ctx) {
final var hint = hints.removeFirst();
- String hintContent = "> 提示: " + hint.content();
+ hintString.insert(0, "- " + hint.content() + "\n");
+
+ String hintContent = "__猜Rank提示:__\n" + hintString;
if (!hints.isEmpty()) {
hintContent += "\n" + "> 下一个提示将在 30 秒后揭晓~";
} else {
- hintContent += "\n" + "> 所有提示已经揭晓!游戏将在 1 分钟后自动结束~";
+ hintContent += "\n" + "> 所有提示已经揭晓啦!游戏将在 1 分钟后自动结束~";
}
- if (ctx.sendMessage(PendingMessage.ofMarkdownRaw(hintContent))) {
+ if (ctx.sendMessage(PendingMessage.ofMarkdownRaw(hintContent)).success()) {
game.revealHint(hint);
}
}
@@ -199,7 +486,9 @@ private void start(Context ctx) {
Thread.currentThread().interrupt();
}
- end(ctx, false);
+ if (!game.isEnded()) {
+ end(ctx, true);
+ }
}
);
if (!activated.get()) {
@@ -208,40 +497,57 @@ private void start(Context ctx) {
}
private void guess(Context ctx, long rank) {
- RankGuessGameService.GuessResponse response = games.guess(ctx.groupId(), ctx.senderUserId(), rank);
- final RankGuessGameService.GuessResult result = response.guessResult();
+ 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 UPDATED, RECORDED -> PendingMessage.ofMarkdownRaw(
at(ctx)
+ "已" + (result.status() == RankGuessGameService.GuessStatus.UPDATED ? "更新" : "记录") + "你的猜测:"
+ "`#" + String.format(Locale.US, "%,d", rank) + "`"
- + " " + result.multiplierString()
+ + " " + result.multiplierString() + "\n"
+ + "目前已经有 `" + result.guessCount() + "` 个猜测~"
);
};
ctx.sendReply(message);
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 fromCommand) {
- RankGuessGameService.EndResult result = games.end(
- ctx.groupId(), ctx.senderUserId(), adminAuthorizer.test(ctx.senderUserId()), !fromCommand
- );
+ private void end(Context ctx, boolean force) {
+ EndResult result;
+ try {
+ 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 重试喵。");
+ 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(
"开始猜测后的3分钟内,仅发起者和机器人管理员可以结束游戏喵"
);
- case FINISHED -> replyFactory.rankGuessResultMessage(result.round());
+ case FINISHED -> replyFactory.rankGuessResultMessage(ctx, result.round(), result.recorded());
};
- if (fromCommand) {
- ctx.sendReply(message);
- } else {
+ if (!ctx.sendReply(message).success()) {
ctx.sendMessage(message);
}
}
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 537a924d..b3357de2 100644
--- a/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java
+++ b/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java
@@ -3,13 +3,16 @@
import xyz.zcraft.seira.api.APIHelper;
import xyz.zcraft.seira.api.data.VideoRenderRecord;
import xyz.zcraft.seira.bot.data.PendingMessage;
-import xyz.zcraft.seira.command.*;
+import xyz.zcraft.seira.command.Context;
+import xyz.zcraft.seira.command.ReplayResultStore;
+import xyz.zcraft.seira.command.TargetHistory;
+import xyz.zcraft.seira.command.TaskCoordinator;
import xyz.zcraft.seira.command.parse.Resolver;
-import xyz.zcraft.seira.command.reply.CommandUsage;
-import xyz.zcraft.seira.command.reply.ReplyFactory;
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.util.TimeDurationParser;
import java.util.function.Function;
@@ -92,7 +95,7 @@ public void handleRsc(Context ctx) {
TargetResolution targetResolution = targetHistory.resolveOptionalTarget(
ctx,
resolver,
- arg -> arg.startsWith("+") || TimeDurationParser.isTimeRange(arg)
+ arg -> arg.startsWith("+") || arg.startsWith("=")
);
ShortcutTarget target = targetResolution.target();
if (target == null) {
@@ -106,13 +109,11 @@ public void handleRsc(Context ctx) {
String extraUidArg = null;
- for (int i = targetResolution.consumedArgs(); i < ctx.args().length; i++) {
+ int i = targetResolution.consumedArgs();
+
+ if (i < ctx.args().length) {
if (ctx.args()[i].startsWith("+") || ctx.args()[i].startsWith("=")) {
- if (extraUidArg != null) {
- ctx.sendReply(PendingMessage.ofString(CommandUsage.RSC));
- return;
- }
- extraUidArg = ctx.args()[i];
+ extraUidArg = ctx.query().substring(Math.max(ctx.query().indexOf("+"), ctx.query().indexOf("=")));
} else {
ctx.sendReply(PendingMessage.ofString(CommandUsage.RSC));
return;
@@ -127,7 +128,7 @@ public void handleRsc(Context ctx) {
return;
}
- String[] uidArray = rscTarget.uids();
+ String[] targetsArray = rscTarget.targets();
targetHistory.rememberExplicitTarget(ctx, targetResolution);
@@ -136,7 +137,7 @@ public void handleRsc(Context ctx) {
"Showcase Render",
qqUpload -> {
var task = APIHelper.createReplayShowcaseTask(
- target, uidArray, accessTokenProvider.apply(ctx.senderUserId()), qqUpload);
+ target, targetsArray, accessTokenProvider.apply(ctx.senderUserId()), qqUpload);
videoRenderRecord.updateRenderTask(ctx.senderUserId(), task.taskId());
return task;
},
@@ -166,7 +167,7 @@ public void handleRstat(Context ctx) {
PendingMessage video = replayResult.qqFile() != null
? PendingMessage.ofUploadedVideo(replayResult.qqFile(), replayResult.videoUrl())
: PendingMessage.ofVideoUrl(replayResult.videoUrl());
- if (ctx.sendReply(video)) {
+ if (ctx.sendReply(video).success()) {
replayResults.remove(jobId);
}
return;
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 c59a8634..0c69c177 100644
--- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java
+++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java
@@ -163,7 +163,7 @@ private void handleFilteredSingleScore(Context ctx, String macroType) {
UserRef targetUser = null;
int startIndex = 0;
- if (resolver.looksLikeMention(ctx.args()[0])) {
+ 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()));
@@ -207,7 +207,7 @@ private ScoreListRequest parseScoreListRequest(Context ctx, String usage) {
int nextArg = 1;
UserRef userRef;
- if (nextArg < args.length && resolver.looksLikeMention(args[nextArg])) {
+ 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()));
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 a062fbaf..85b99dca 100644
--- a/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java
+++ b/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java
@@ -6,17 +6,21 @@
import xyz.zcraft.seira.api.data.FriendEntry;
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.*;
+import xyz.zcraft.seira.command.Context;
+import xyz.zcraft.seira.command.TaskCoordinator;
import xyz.zcraft.seira.command.parse.Resolver;
-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.reply.CommandUsage;
+import xyz.zcraft.seira.command.reply.ReplyFactory;
+import xyz.zcraft.seira.db.UserDataStore;
import xyz.zcraft.seira.util.OsuAuthHelper;
-import java.util.*;
+import java.util.Comparator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
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 4721ac0b..9b281b87 100644
--- a/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java
+++ b/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java
@@ -23,6 +23,25 @@ public SpecificScoreWatchCommandHandler(TaskCoordinator taskCoordinator, ScoreWa
this.watchService = Objects.requireNonNull(watchService);
}
+ static Set parseIds(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ LinkedHashSet ids = new LinkedHashSet<>();
+ for (String part : value.split("[,,]", -1)) {
+ try {
+ long id = Long.parseLong(part.trim());
+ if (id <= 0) {
+ return null;
+ }
+ ids.add(id);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+ return ids.isEmpty() ? null : Set.copyOf(ids);
+ }
+
public void handleWx(Context ctx) {
if (!ctx.inGroup()) {
ctx.sendReply(PendingMessage.ofString("/wx 仅支持群聊使用。"));
@@ -54,7 +73,7 @@ private void handleStart(Context ctx) {
}
taskCoordinator.runApiRequest(ctx, "Start Specific Score Watch", () -> {
- if (!ctx.sendMessage(PendingMessage.ofString("正在尝试启动指定谱面成绩监视……"))) {
+ if (!ctx.sendMessage(PendingMessage.ofString("正在尝试启动指定谱面成绩监视……")).success()) {
ctx.sendReply(PendingMessage.ofString(
"由于缺少主动消息权限,无法启动监视!权限配置请见:https://docs.seira.top/overview/use.html#extra-permission"
));
@@ -82,23 +101,4 @@ private void handleStop(Context ctx) {
private void usage(Context ctx) {
ctx.sendReply(PendingMessage.ofString(USAGE));
}
-
- static Set parseIds(String value) {
- if (value == null || value.isBlank()) {
- return null;
- }
- LinkedHashSet ids = new LinkedHashSet<>();
- for (String part : value.split("[,,]", -1)) {
- try {
- long id = Long.parseLong(part.trim());
- if (id <= 0) {
- return null;
- }
- ids.add(id);
- } catch (NumberFormatException e) {
- return null;
- }
- }
- return ids.isEmpty() ? null : Set.copyOf(ids);
- }
}
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 32631686..cbbf2a24 100644
--- a/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java
+++ b/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java
@@ -2,12 +2,12 @@
import xyz.zcraft.osu.model.User;
import xyz.zcraft.seira.api.APIHelper;
-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.command.parse.Resolver;
+import xyz.zcraft.seira.db.UserDataStore;
import xyz.zcraft.seira.watch.ScoreWatchService;
import xyz.zcraft.seira.watch.WatchTarget;
import xyz.zcraft.seira.watch.WatchView;
@@ -40,18 +40,52 @@ public WatchCommandHandler(Resolver resolver, TaskCoordinator taskCoordinator, S
this.targetResolver = this::resolveTarget;
}
- WatchCommandHandler(
- Resolver resolver,
- TaskCoordinator taskCoordinator,
- ScoreWatchService watchService,
- Predicate adminAuthorizer,
- BiFunction targetResolver
- ) {
- this.resolver = Objects.requireNonNull(resolver);
- this.taskCoordinator = Objects.requireNonNull(taskCoordinator);
- this.watchService = watchService;
- this.adminAuthorizer = Objects.requireNonNull(adminAuthorizer);
- this.targetResolver = Objects.requireNonNull(targetResolver);
+ private static User findUserById(long userId) {
+ return APIHelper.getUsers(List.of(userId)).stream()
+ .filter(user -> user.getId() == userId)
+ .findFirst()
+ .orElseThrow(() -> new ResolutionException("未找到指定的玩家。"));
+ }
+
+ private static PendingMessage removedMessage(WatchView removed) {
+ return PendingMessage.ofMarkdownRaw(
+ removed == null
+ ? "当前群聊中没有该用户的监视任务。"
+ : displayTarget(removed.target()) + " 的监视已移除。"
+ );
+ }
+
+ private static Integer parseDurationMinutes(String value) {
+ if (value == null || !value.matches("\\d+")) {
+ return null;
+ }
+ try {
+ int minutes = Integer.parseInt(value);
+ return minutes >= 1 && minutes <= MAX_DURATION_MINUTES ? minutes : null;
+ } catch (NumberFormatException ignored) {
+ return null;
+ }
+ }
+
+ private static String displayTarget(WatchTarget target) {
+ return target.username() + "()";
+ }
+
+ private static String formatRemaining(Duration duration) {
+ long totalSeconds = Math.max(1, (duration.toMillis() + 999) / 1000);
+ long minutes = totalSeconds / 60;
+ long seconds = totalSeconds % 60;
+ if (minutes == 0) {
+ return seconds + "秒";
+ }
+ if (seconds == 0) {
+ return minutes + "分钟";
+ }
+ return minutes + "分" + seconds + "秒";
+ }
+
+ private static void usage(Context ctx) {
+ ctx.sendReply(PendingMessage.ofString(USAGE));
}
public void handleWatch(Context ctx) {
@@ -104,9 +138,9 @@ private void handleAdd(Context ctx) {
WatchTarget target = targetResolver.apply(ctx.groupId(), targetArgument);
final boolean b = ctx.sendMessage(PendingMessage.ofMarkdownRaw(
at(ctx) + "正在尝试添加监视..."
- ));
+ )).success();
if (!b) {
- ctx.sendReply(PendingMessage.ofString("由于缺少主动消息权限,无法添加监视!权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)~"));
+ ctx.sendReply(PendingMessage.ofMarkdownRaw("由于缺少主动消息权限,无法添加监视!权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)~"));
return;
}
@@ -196,52 +230,4 @@ private WatchTarget resolveTarget(String groupId, String argument) {
UserDataStore.storeUserInfo(user.getId(), user.getUsername());
return new WatchTarget(user.getId(), user.getUsername(), qqOpenId);
}
-
- private static User findUserById(long userId) {
- return APIHelper.getUsers(List.of(userId)).stream()
- .filter(user -> user.getId() == userId)
- .findFirst()
- .orElseThrow(() -> new ResolutionException("未找到指定的玩家。"));
- }
-
- private static PendingMessage removedMessage(WatchView removed) {
- return PendingMessage.ofMarkdownRaw(
- removed == null
- ? "当前群聊中没有该用户的监视任务。"
- : displayTarget(removed.target()) + " 的监视已移除。"
- );
- }
-
- private static Integer parseDurationMinutes(String value) {
- if (value == null || !value.matches("\\d+")) {
- return null;
- }
- try {
- int minutes = Integer.parseInt(value);
- return minutes >= 1 && minutes <= MAX_DURATION_MINUTES ? minutes : null;
- } catch (NumberFormatException ignored) {
- return null;
- }
- }
-
- private static String displayTarget(WatchTarget target) {
- return target.username() + "()";
- }
-
- private static String formatRemaining(Duration duration) {
- long totalSeconds = Math.max(1, (duration.toMillis() + 999) / 1000);
- long minutes = totalSeconds / 60;
- long seconds = totalSeconds % 60;
- if (minutes == 0) {
- return seconds + "秒";
- }
- if (seconds == 0) {
- return minutes + "分钟";
- }
- return minutes + "分" + seconds + "秒";
- }
-
- private static void usage(Context ctx) {
- ctx.sendReply(PendingMessage.ofString(USAGE));
- }
}
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 c29bd778..9c341d1d 100644
--- a/src/main/java/xyz/zcraft/seira/command/parse/CommandParser.java
+++ b/src/main/java/xyz/zcraft/seira/command/parse/CommandParser.java
@@ -2,51 +2,21 @@
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.*;
import java.util.function.UnaryOperator;
public final class CommandParser {
private static final String PREFIX = "/";
- private final UnaryOperator preProcessor;
+ private final UnaryOperator sanitizer;
- public CommandParser(UnaryOperator preProcessor) {
- this.preProcessor = Objects.requireNonNull(preProcessor);
+ public CommandParser(UnaryOperator sanitizer) {
+ this.sanitizer = Objects.requireNonNull(sanitizer);
}
- public ParseResult parse(String rawContent, String senderUserId, String groupId, String messageId) {
- if (rawContent == null) {
- return ParseResult.ignored();
- }
-
- String normalized = rawContent.trim();
- if (!normalized.startsWith(PREFIX)) {
- return ParseResult.ignored();
- }
-
- String body = normalized.substring(PREFIX.length()).trim();
- if (body.isEmpty()) {
- return ParseResult.emptyCommand();
- }
-
- body = Objects.requireNonNull(preProcessor.apply(body), "Command pre-processor returned null").trim();
- if (body.isEmpty()) {
- return ParseResult.emptyCommand();
- }
-
- 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);
-
- return ParseResult.parsed(new Context(senderUserId, groupId, messageId, command, args, query));
- }
-
- /** Splits arguments on unquoted whitespace and removes double-quote delimiters. */
+ /**
+ * Splits arguments on unquoted whitespace and removes double-quote delimiters.
+ */
private static String[] splitArguments(String body) {
List parts = new ArrayList<>();
StringBuilder current = new StringBuilder();
@@ -75,13 +45,35 @@ private static String[] splitArguments(String body) {
return parts.toArray(String[]::new);
}
- public record ParseResult(Status status, Context context) {
- public enum Status {
- IGNORED,
- EMPTY_COMMAND,
- PARSED
+ public ParseResult parse(String rawContent, String senderUserId, String groupId, String messageId) {
+ if (rawContent == null) {
+ return ParseResult.ignored();
+ }
+
+ String normalized = rawContent.trim();
+ if (!normalized.startsWith(PREFIX)) {
+ return ParseResult.ignored();
}
+ String body = normalized.substring(PREFIX.length()).trim();
+ if (body.isEmpty()) {
+ return ParseResult.emptyCommand();
+ }
+
+ body = Objects.requireNonNull(sanitizer.apply(body), "Command pre-processor returned null").trim();
+ if (body.isEmpty()) {
+ return ParseResult.emptyCommand();
+ }
+
+ 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);
+
+ return ParseResult.parsed(new Context(senderUserId, groupId, messageId, command, args, rawContent, query));
+ }
+
+ public record ParseResult(Status status, Context context) {
public ParseResult {
if ((status == Status.PARSED) == (context == null)) {
throw new IllegalArgumentException("Only parsed results may contain a context");
@@ -99,5 +91,11 @@ static ParseResult emptyCommand() {
static ParseResult parsed(Context context) {
return new ParseResult(Status.PARSED, Objects.requireNonNull(context));
}
+
+ public enum Status {
+ IGNORED,
+ EMPTY_COMMAND,
+ PARSED
+ }
}
}
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 cc90f4f7..9f98ab36 100644
--- a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java
+++ b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java
@@ -1,8 +1,8 @@
package xyz.zcraft.seira.command.parse;
import xyz.zcraft.seira.api.data.SearchQuery;
-import xyz.zcraft.seira.binding.UserDataStore;
import xyz.zcraft.seira.data.UserRef;
+import xyz.zcraft.seira.db.UserDataStore;
import java.util.*;
import java.util.regex.MatchResult;
@@ -12,12 +12,15 @@
public final class Resolver {
private static final ArrayList USER_MACRO_TYPES = new ArrayList<>(List.of("rs", "bo", "rp"));
- public String preProcess(String rawContent) {
+ public String sanitize(String rawContent) {
Matcher matcher = Patterns.USER_MACRO_PATTERN.matcher(rawContent);
if (matcher.matches()) {
- return "s " + rawContent;
+ rawContent = "s " + rawContent;
}
+ // Add surrounding space to <@>
+ rawContent = Patterns.QQ_INLINE_AT_PATTERN.matcher(rawContent).replaceAll(r -> " " + r.group() + " ");
+
return rawContent;
}
@@ -83,17 +86,25 @@ public UserRefResolution resolveUserRefArgument(String arg) {
if (mentionedUserId != null) {
Long boundUid = resolveBoundUid(mentionedUserId);
if (boundUid == null) {
- return new UserRefResolution(null, "被@的用户还没有绑定玩家ID,请先让对方使用 /bind <玩家ID>");
+ return new UserRefResolution(null, "被@的用户还没有绑定玩家ID,请先让对方使用 /bind");
}
return new UserRefResolution(new UserRef.ByUid(boundUid), null);
}
- if (looksLikeMention(arg)) {
- return new UserRefResolution(null, "@用户格式无效,请使用 @用户 后再输入指令。示例:/bo 5 @123456");
- }
+// if (looksLikeMention(arg)) {
+// return new UserRefResolution(null, "@用户格式无效,请使用 @用户 后再输入指令。示例:/bo 5 @123456");
+// }
String username = arg == null ? "" : arg.trim();
- return new UserRefResolution(username.isEmpty() ? null : new UserRef.ByUsername(username), null);
+ if (username.startsWith("@")) {
+ username = username.substring(1);
+ }
+
+ if (username.isEmpty()) {
+ return new UserRefResolution(null, null);
+ }
+
+ return new UserRefResolution(new UserRef.ByUsername(username), null);
}
public RscTarget resolveRscTarget(String groupId, String extraUidArg) {
@@ -102,7 +113,7 @@ public RscTarget resolveRscTarget(String groupId, String extraUidArg) {
if (extraUidArg == null || extraUidArg.trim().startsWith("+")) {
List groupBoundUids = UserDataStore.findBoundUidsByGroup(groupId);
if (groupBoundUids.isEmpty()) {
- return new RscTarget(null, "本群还没有已绑定的玩家,请先使用 /bind <玩家ID>");
+ return new RscTarget(null, "本群还没有已绑定的玩家,请先使用 /bind");
}
groupBoundUids.stream().map(String::valueOf).forEach(merged::add);
}
@@ -117,10 +128,21 @@ public RscTarget resolveRscTarget(String groupId, String extraUidArg) {
String[] extraTokens = body.split(",");
for (String token : extraTokens) {
- if (!Patterns.RSC_TARGET_PATTERN.matcher(token.trim()).matches()) {
+ if (Patterns.RSC_TARGET_PATTERN.matcher(token.trim()).matches()) {
+ merged.add(token);
+ } else if (looksLikeMention(token)) {
+ final UserRefResolution userRefResolution = resolveUserRefArgument(token);
+ if (userRefResolution.errorMessage() != null) {
+ return new RscTarget(null, "解析 " + token + " 时出错:" + userRefResolution.errorMessage());
+ }
+ if (userRefResolution.userRef() instanceof UserRef.ByUid ref) {
+ merged.add("u" + ref.getUid());
+ } else if (userRefResolution.userRef() instanceof UserRef.ByUsername ref) {
+ merged.add("@" + ref.getUsername());
+ }
+ } else {
return new RscTarget(null, "追加ID列表包含非法值。");
}
- merged.add(token.trim());
}
return new RscTarget(merged.toArray(String[]::new), null);
@@ -173,8 +195,8 @@ private ShortcutTarget parseTarget(String arg, UserRef userRef, boolean mentione
index = 1L;
}
- if (index < 1 || index > 100) {
- return new ShortcutTarget(null, null, null, null, "快捷指令索引无效,请输入 1-100 之间的数字。例如: rs5");
+ if (index < 1 || index > 200) {
+ return new ShortcutTarget(null, null, null, null, "快捷指令索引无效,请输入 1-200 之间的数字。例如: rs5");
}
if (userRef == null) {
@@ -268,12 +290,17 @@ public Long parsePositiveLong(String value) {
}
}
+ public boolean looksLikeUid(String arg) {
+ return Pattern.compile("^\\d+$").matcher(arg).matches();
+ }
+
private static final class Patterns {
private static final Pattern USER_MACRO_PATTERN = Pattern.compile("(?i)^(rs|bo|rp|bp)(\\d+)?$");
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*$");
private static final Pattern QQ_AT_PATTERN = Pattern.compile("^<@([A-Z|0-9]{32})>$");
+ private static final Pattern QQ_INLINE_AT_PATTERN = Pattern.compile("(<@[A-Z|0-9]{32}>)");
private static final Pattern PLAIN_AT_PATTERN = Pattern.compile("^@(\\d+)$");
private static final Pattern SEARCH_PATTERN = Pattern.compile("^(?:#(\\d+) )?(.+)$");
private static final Pattern RSC_TARGET_PATTERN = Pattern.compile("^[us]?\\d+$");
diff --git a/src/main/java/xyz/zcraft/seira/command/parse/RscTarget.java b/src/main/java/xyz/zcraft/seira/command/parse/RscTarget.java
index aaf01516..9193b455 100644
--- a/src/main/java/xyz/zcraft/seira/command/parse/RscTarget.java
+++ b/src/main/java/xyz/zcraft/seira/command/parse/RscTarget.java
@@ -1,4 +1,4 @@
package xyz.zcraft.seira.command.parse;
-public record RscTarget(String[] uids, String errorMessage) {
+public record RscTarget(String[] targets, String errorMessage) {
}
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 56869cd3..253575b3 100644
--- a/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java
+++ b/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java
@@ -7,10 +7,12 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-/** Parses score-list filters and converts aliases to oStella's compact filter syntax. */
+/**
+ * Parses score-list filters and converts aliases to oStella's compact filter syntax.
+ */
public final class ScoreFilterArguments {
private static final Pattern FILTER_PATTERN = Pattern.compile(
- "(?i)^(acc(?:uracy)?|combo|pp|time|length|len|star|stars|sr|bpm|miss|misses|score|mod|mods|rank"
+ "(?i)^(acc(?:uracy)?|combo|pp|time|length|len|star|stars|sr|bpm|miss|misses|score|mod|mods|rank|replay"
+ "|title|artist|mapper|genre|language|video|storyboard|fullcombo)"
+ "(>=|<=|!=|!~|>|<|=|~)(.+)$"
);
@@ -76,7 +78,7 @@ private static String parseOne(String token) {
if (value.isBlank()) {
throw new IllegalArgumentException(field + " 不能为空");
}
- } else if (Set.of("video", "storyboard", "fullcombo").contains(field)) {
+ } else if (Set.of("video", "storyboard", "fullcombo", "replay").contains(field)) {
if (!Set.of("=", "!=").contains(operator)) {
throw new IllegalArgumentException(field + " 仅支持 =、!=");
}
@@ -120,6 +122,7 @@ private static String normalizeField(String value) {
case "video" -> "video";
case "storyboard" -> "storyboard";
case "fullcombo" -> "fullcombo";
+ case "replay" -> "replay";
default -> throw new IllegalArgumentException("未知字段 " + value);
};
}
@@ -136,6 +139,8 @@ private static String expandShorthand(String value) {
case "sb", "storyboard" -> "storyboard=true";
case "!sb", "!storyboard" -> "storyboard=false";
case "fc", "fullcombo" -> "fullcombo=true";
+ case "replay" -> "replay=true";
+ case "!replay" -> "replay=false";
case "!fc", "!fullcombo" -> "fullcombo=false";
default -> null;
};
diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java
index ec20a8b2..cfc925de 100644
--- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java
+++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java
@@ -7,17 +7,19 @@
import xyz.zcraft.osu.model.*;
import xyz.zcraft.seira.api.APIHelper;
import xyz.zcraft.seira.api.data.*;
-import xyz.zcraft.seira.binding.BindingService;
-import xyz.zcraft.seira.binding.UserDataStore;
import xyz.zcraft.seira.bot.data.Button;
import xyz.zcraft.seira.bot.data.PendingMessage;
import xyz.zcraft.seira.command.Context;
import xyz.zcraft.seira.config.AppConfig;
import xyz.zcraft.seira.config.BindingConfig;
import xyz.zcraft.seira.data.UploadedImage;
-import xyz.zcraft.seira.rankguess.FinishedRound;
-import xyz.zcraft.seira.rankguess.RankGuessGameService;
-import xyz.zcraft.seira.rankguess.Standing;
+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.services.BindingService;
import xyz.zcraft.seira.services.BotStat;
import xyz.zcraft.seira.services.DailyLuck;
import xyz.zcraft.seira.util.VersionInfo;
@@ -64,7 +66,7 @@ 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("/m " + info.beatmapId(), String.valueOf(info.beatmapId())) + "\n" +
"> 用户: " + cmd("/u " + info.userId(), info.username()) + "\n").trim(),
null
);
@@ -74,15 +76,26 @@ private Buttons buttons() {
return new Buttons(configSupplier.get().seira().directUrl());
}
- public PendingMessage rankGuessResultMessage(FinishedRound result) {
- RankGuessGameService.Round round = result.round();
+ public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result, boolean recorded) {
+ Round round = result.round();
String rank = String.format(Locale.US, "%,d", round.actualRank());
String pp = round.pp() == null
? "未知"
: "%.1f".formatted(round.pp()) + "pp";
- StringBuilder content = new StringBuilder()
- .append("本轮猜测结束~\n")
+ String userAt = UserDataStore.findGroupOpenIdByUid(ctx.groupId(), round.userId())
+ .map(e -> "(" + at(e) + ")")
+ .orElse("");
+
+ StringBuilder content = new StringBuilder("本轮猜测结束");
+
+ if (recorded) {
+ content.append(",战绩已记录~\n");
+ } else {
+ 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("\n猜测排行榜:\n");
@@ -110,9 +123,51 @@ public PendingMessage rankGuessResultMessage(FinishedRound result) {
return PendingMessage.ofMarkdownRaw(content.toString().trim());
}
+ public PendingMessage rankGuessStatisticsMessage(
+ Context ctx, RankGuessRecordStore.Statistics.Personal statistics,
+ RankGuessRecordStore.Statistics.Personal recentStatistics,
+ boolean allGroups, Rank rank,
+ Long pickedTimes, Long groupGameCount
+ ) {
+ String scope = allGroups ? "全部群聊" : "本群";
+ if (statistics.participation() == 0) {
+ return PendingMessage.ofMarkdownRaw(at(ctx) + "你在" + scope + "还没有已结算的猜 Rank 战绩喵~");
+ }
+
+ String rankText = "?".equals(rank.rank()) ? "" : "根据你最近 %d 场的表现,可以给到一个 `%s` 喵!\n"
+ .formatted(Rank.RECENT_GAME_LIMIT, rank.rank());
+ String groupCountText = "";
+ if (!allGroups && pickedTimes != null && groupGameCount != null) {
+ groupCountText = "> 被猜次数:`%d`,占本群:`%.3f%%`\n".formatted(pickedTimes, (double) pickedTimes / groupGameCount * 100);
+ }
+ return PendingMessage.ofMarkdownRaw(at(ctx) + String.format(Locale.ROOT, """
+ 你的猜 Rank 战绩(%s,括号为近 %d 场)
+ > 总参与数:`%d`,Rating:`%.2f`
+ > 获胜数:`%d`(`%d`)
+ > 胜率:`%.2f%%`(`%.2f%%`)
+ > 前20%%:`%d`(`%d`)
+ > 前20%%达成率:`%.2f%%`(`%.2f%%`)
+ > 平均分:`%.2f`(`%.2f`)
+ > 最高分:`%.2f`(`%.2f`)
+ > 平均名次:`%.2f`(`%.2f`)
+ > 总得分:`%.2f`
+ %s%s
+ """,
+ scope, Rank.RECENT_GAME_LIMIT,
+ statistics.participation(), rank.rating(),
+ statistics.wins(), recentStatistics.wins(),
+ statistics.winRate() * 100, recentStatistics.winRate() * 100,
+ statistics.topTwentyCount(), recentStatistics.topTwentyCount(),
+ statistics.topTwentyRate() * 100, recentStatistics.topTwentyRate() * 100,
+ statistics.averageScore(), recentStatistics.averageScore(),
+ statistics.highestScore(), recentStatistics.highestScore(),
+ statistics.averagePlacement(), recentStatistics.averagePlacement(),
+ statistics.totalScore(), groupCountText, rankText).strip());
+ }
+
public PendingMessage boMessage(Context ctx, Response> response) {
return PendingMessage.ofMarkdownRaw(
- at(ctx) + "B" + response.getScoreIds().size() + "查询完成\n" +
+ at(ctx) + "查询完成,共" + response.getScoreIds().size() + "个成绩\n" +
"> 玩家: " + cmd("/u " + response.getUserId(), response.getUserId()),
buttons().boButtons(response.getUserId())
);
@@ -490,28 +545,40 @@ public static String friendContent(Context ctx,
sb.append("\n");
+ boolean collapsed = false;
+
sb.append("> 好友←→ (").append(mutual.size()).append(")\n>");
- for (User p : mutual) {
- sb.append(getFriendItem(p)).append(" ");
- }
+ collapsed |= appendFriends(ctx, mutual, sb);
sb.append("\n> 仅关注→ (").append(onlyFollowed.size()).append(")\n>");
- for (User p : onlyFollowed) {
- sb.append(getFriendItem(p)).append(" ");
- }
+ collapsed |= appendFriends(ctx, onlyFollowed, sb);
sb.append("\n> 仅粉丝← (");
sb.append(onlyFollower.size()).append(" 已知");
if (all) sb.append(" 共 ").append(self.getFollowerCount() - allMutualCount);
sb.append(")\n>");
+ collapsed |= appendFriends(ctx, onlyFollower, sb);
- for (User p : onlyFollower) {
- sb.append(getFriendItem(p)).append(" ");
+ if (ctx.inGroup() && collapsed) {
+ sb.append("\n部分结果已折叠,如需查看完整结果请在私聊中使用指令~");
}
return sb.toString().trim();
}
+ private static boolean appendFriends(Context ctx, List onlyFollowed, StringBuilder sb) {
+ int count = 0;
+ for (User p : onlyFollowed) {
+ if (count >= 20 && ctx.inGroup()) {
+ sb.append("\n...剩余").append(onlyFollowed.size() - count).append("个");
+ return true;
+ }
+ sb.append(getFriendItem(p)).append(" ");
+ count++;
+ }
+ return false;
+ }
+
private static String getFriendItem(User u) {
return cmd("/u " + u.getId(), "[" + (u.isOnline() ? "▶" : "") + u.getUsername() + "]");
}
@@ -599,7 +666,7 @@ public static String helpContent(Context ctx) {
> /bma <谱面ID或快捷查询> [Mod] - 分析谱面PP构成和类型
> /ms <谱面集ID或快捷查询> - 获取谱面集
> /r [成绩ID或快捷查询] [[mm:ss]-[mm:ss]] - 生成成绩高光视频或指定片段
- > /rg - 在群聊中进行猜 Rank 游戏
+ > /rg - 猜 Rank 游戏与个人战绩
> /lb <谱面ID> [玩家ID列表] - 获取指定谱面排行榜
> /watch add <玩家ID/用户名/@用户> [分钟] - 监视群友的新成绩
> /wx start <谱面ID列表> - 监视指定玩家在指定谱面的成绩
diff --git a/src/main/java/xyz/zcraft/seira/command/route/DebugRoutes.java b/src/main/java/xyz/zcraft/seira/command/route/DebugRoutes.java
index b520a59a..b2cfb9e5 100644
--- a/src/main/java/xyz/zcraft/seira/command/route/DebugRoutes.java
+++ b/src/main/java/xyz/zcraft/seira/command/route/DebugRoutes.java
@@ -8,15 +8,15 @@
import xyz.zcraft.seira.api.data.FriendEntry;
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.MessageSender;
import xyz.zcraft.seira.bot.data.FileInfo;
import xyz.zcraft.seira.bot.data.PendingMessage;
-import xyz.zcraft.seira.bot.MessageSender;
-import xyz.zcraft.seira.command.Context;
import xyz.zcraft.seira.command.CommandHandler;
+import xyz.zcraft.seira.command.Context;
import xyz.zcraft.seira.command.TaskCoordinator;
import xyz.zcraft.seira.command.reply.ReplyFactory;
import xyz.zcraft.seira.config.AppConfig;
+import xyz.zcraft.seira.db.UserDataStore;
import xyz.zcraft.seira.util.OsuAuthHelper;
import java.io.ByteArrayOutputStream;
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 cb141dd4..b9df6464 100644
--- a/src/main/java/xyz/zcraft/seira/command/route/Router.java
+++ b/src/main/java/xyz/zcraft/seira/command/route/Router.java
@@ -5,8 +5,6 @@
import org.apache.logging.log4j.Logger;
import xyz.zcraft.seira.api.data.OsuToken;
import xyz.zcraft.seira.api.data.VideoRenderRecord;
-import xyz.zcraft.seira.binding.UserDataStore;
-import xyz.zcraft.seira.binding.BindingService;
import xyz.zcraft.seira.bot.MessageSender;
import xyz.zcraft.seira.bot.data.PendingMessage;
import xyz.zcraft.seira.command.*;
@@ -15,12 +13,14 @@
import xyz.zcraft.seira.command.parse.Resolver;
import xyz.zcraft.seira.command.reply.ReplyFactory;
import xyz.zcraft.seira.config.AppConfig;
+import xyz.zcraft.seira.db.UserDataStore;
import xyz.zcraft.seira.discord.DiscordBridgeService;
import xyz.zcraft.seira.rankguess.RankGuessGameService;
-import xyz.zcraft.seira.security.AdminRegistry;
+import xyz.zcraft.seira.services.BindingService;
+import xyz.zcraft.seira.util.AdminRegistry;
import xyz.zcraft.seira.util.OsuAuthHelper;
-import xyz.zcraft.seira.watch.ScoreWatchService;
import xyz.zcraft.seira.watch.MultiplayerRoomWatchService;
+import xyz.zcraft.seira.watch.ScoreWatchService;
import java.util.Optional;
import java.util.Set;
@@ -28,9 +28,12 @@
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
+import static xyz.zcraft.seira.command.reply.ReplyFactory.at;
+
public class Router {
private static final Logger LOG = LogManager.getLogger(Router.class);
-
+ @Getter
+ private static volatile Context lastContext = null;
private final Supplier configSupplier;
private final TaskCoordinator taskCoordinator;
private final OsuAuthHelper authHelper;
@@ -93,10 +96,10 @@ public Router(
new MultiplayerRoomWatchCommandHandler(taskCoordinator, multiplayerRoomWatchService);
DcsCommandHandler dcsCommands = new DcsCommandHandler(discordBridgeService);
RankGuessCommandHandler rankGuessCommands = new RankGuessCommandHandler(
- taskCoordinator, replyFactory, rankGuessGameService, admins::isAdmin
+ taskCoordinator, replyFactory, rankGuessGameService, resolver, admins::isAdmin
);
this.unknownCommand = generalCommands::handleUnknown;
- this.commandParser = new CommandParser(resolver::preProcess);
+ this.commandParser = new CommandParser(resolver::sanitize);
this.commandRegistry = createCommandRegistry(
bindingCommands,
scoreCommands,
@@ -121,6 +124,61 @@ public Router(
);
}
+ private static CommandRegistry createCommandRegistry(
+ BindingCommandHandler bindingCommands,
+ ScoreCommandHandler scoreCommands,
+ BeatmapCommandHandler beatmapCommands,
+ SocialCommandHandler socialCommands,
+ ReplayCommandHandler replayCommands,
+ GeneralCommandHandler generalCommands,
+ WatchCommandHandler watchCommands,
+ SpecificScoreWatchCommandHandler specificScoreWatchCommands,
+ MultiplayerRoomWatchCommandHandler multiplayerRoomWatchCommands,
+ DcsCommandHandler dcsCommands,
+ RankGuessCommandHandler rankGuessCommands
+ ) {
+ return CommandRegistry.builder()
+ .register(bindingCommands::handleBind, "bind")
+ .register(bindingCommands::handleUnbind, "unbind")
+ .register(bindingCommands::handleClearHistory, "clearhistory")
+ .register(scoreCommands::handleBo, "bp", "bo")
+ .register(beatmapCommands::handleDaily, "daily")
+ .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")
+ .register(socialCommands::handleFclear, "fclear")
+ .register(beatmapCommands::handleDl, "dl")
+ .register(scoreCommands::handleS, "s")
+ .register(scoreCommands::handleSa, "sa")
+ .register(scoreCommands::handleMa, "ma")
+ .register(replayCommands::handleR, "r")
+ .register(replayCommands::handleRsc, "rsc")
+ .register(beatmapCommands::handleMs, "ms")
+ .register(beatmapCommands::handleSms, "sms")
+ .register(socialCommands::handleLb, "lb")
+ .register(generalCommands::handleStat, "stat")
+ .register(generalCommands::handleU, "u")
+ .register(generalCommands::handleLuck, "luck")
+ .register(replayCommands::handleRstat, "rstat")
+ .register(generalCommands::handleInspect, "inspect")
+ .register(generalCommands::handleHelp, "help")
+ .register(generalCommands::handleFaq, "faq")
+ .register(watchCommands::handleWatch, "watch")
+ .register(specificScoreWatchCommands::handleWx, "wx")
+ .register(multiplayerRoomWatchCommands::handleMpWatch, "mpwatch", "mpw")
+ .register(dcsCommands::handleDcs, "dcs")
+ .register(rankGuessCommands::handleRankGuess, "rg")
+ .build();
+ }
+
public void onPrivateMessageReceived(String userId, String messageId, String rawContent) {
handleMessageReceived(userId, null, userId, messageId, rawContent, false);
}
@@ -130,7 +188,6 @@ public void onGroupMessageReceived(String groupId, String senderUserId, String m
}
private void handleMessageReceived(String targetId, String groupId, String userId, String messageId, String rawContent, boolean groupMessage) {
- LOG.info("Received {} message : {}", groupMessage ? "group" : "private", rawContent);
AtomicInteger messageSeqCounter = new AtomicInteger(1);
try {
final boolean group = groupMessage && groupId != null && !groupId.isBlank();
@@ -167,9 +224,10 @@ private void handleMessageReceived(String targetId, String groupId, String userI
Context context = parseResult.context().withReplies(replies);
commandExecutor.execute(() -> {
try {
+ LOG.info("Routing {} message : {}", groupMessage ? "group" : "private", context.rawContent());
dispatch(context);
} catch (Exception e) {
- context.sendReply(PendingMessage.ofString("处理指令时发生错误,请稍后再试。"));
+ context.sendReply(PendingMessage.ofMarkdownRaw(at(context) + "处理指令时发生错误,请稍后再试。"));
LOG.error("Failed to process inbound message {}", messageId, e);
}
});
@@ -179,9 +237,6 @@ private void handleMessageReceived(String targetId, String groupId, String userI
}
}
- @Getter
- private static volatile Context lastContext = null;
-
private void dispatch(Context ctx) {
lastContext = ctx;
commandMetric.run();
@@ -192,61 +247,6 @@ private void dispatch(Context ctx) {
commandRegistry.dispatch(ctx, unknownCommand);
}
- private static CommandRegistry createCommandRegistry(
- BindingCommandHandler bindingCommands,
- ScoreCommandHandler scoreCommands,
- BeatmapCommandHandler beatmapCommands,
- SocialCommandHandler socialCommands,
- ReplayCommandHandler replayCommands,
- GeneralCommandHandler generalCommands,
- WatchCommandHandler watchCommands,
- SpecificScoreWatchCommandHandler specificScoreWatchCommands,
- MultiplayerRoomWatchCommandHandler multiplayerRoomWatchCommands,
- DcsCommandHandler dcsCommands,
- RankGuessCommandHandler rankGuessCommands
- ) {
- return CommandRegistry.builder()
- .register(bindingCommands::handleBind, "bind")
- .register(bindingCommands::handleUnbind, "unbind")
- .register(bindingCommands::handleClearHistory, "clearhistory")
- .register(scoreCommands::handleBo, "bp", "bo")
- .register(beatmapCommands::handleDaily, "daily")
- .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")
- .register(socialCommands::handleFclear, "fclear")
- .register(beatmapCommands::handleDl, "dl")
- .register(scoreCommands::handleS, "s")
- .register(scoreCommands::handleSa, "sa")
- .register(scoreCommands::handleMa, "ma")
- .register(replayCommands::handleR, "r")
- .register(replayCommands::handleRsc, "rsc")
- .register(beatmapCommands::handleMs, "ms")
- .register(beatmapCommands::handleSms, "sms")
- .register(socialCommands::handleLb, "lb")
- .register(generalCommands::handleStat, "stat")
- .register(generalCommands::handleU, "u")
- .register(generalCommands::handleLuck, "luck")
- .register(replayCommands::handleRstat, "rstat")
- .register(generalCommands::handleInspect, "inspect")
- .register(generalCommands::handleHelp, "help")
- .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();
- }
-
@SuppressWarnings("unused")
public Set registeredCommands() {
return commandRegistry.registeredCommands();
diff --git a/src/main/java/xyz/zcraft/seira/config/ConfigValidator.java b/src/main/java/xyz/zcraft/seira/config/ConfigValidator.java
index 6bf09d8c..05defeba 100644
--- a/src/main/java/xyz/zcraft/seira/config/ConfigValidator.java
+++ b/src/main/java/xyz/zcraft/seira/config/ConfigValidator.java
@@ -4,7 +4,9 @@
import java.util.ArrayList;
import java.util.List;
-/** Validates configuration once at the application boundary. */
+/**
+ * Validates configuration once at the application boundary.
+ */
public final class ConfigValidator {
private ConfigValidator() {
}
diff --git a/src/main/java/xyz/zcraft/seira/config/RuntimeConfig.java b/src/main/java/xyz/zcraft/seira/config/RuntimeConfig.java
index 7e6edbc7..3252f236 100644
--- a/src/main/java/xyz/zcraft/seira/config/RuntimeConfig.java
+++ b/src/main/java/xyz/zcraft/seira/config/RuntimeConfig.java
@@ -35,43 +35,6 @@ public RuntimeConfig(AppConfig initial) {
this.loader = Objects.requireNonNull(loader, "loader");
}
- public AppConfig current() {
- return current.get();
- }
-
- public List pendingRestart() {
- return pendingRestart;
- }
-
- public void addReloadListener(Consumer listener) {
- listeners.add(Objects.requireNonNull(listener));
- }
-
- /** Reads and validates the source configuration without applying it. */
- public AppConfig validateSource() {
- return loader.get();
- }
-
- public synchronized ReloadResult reload() {
- AppConfig previous = current.get();
- AppConfig loaded = loader.get();
- List applied = appliedChanges(previous, loaded);
- List restartRequired = restartRequiredChanges(previous, loaded);
- AppConfig effective = mergeReloadable(previous, loaded);
-
- current.set(effective);
- pendingRestart = List.copyOf(restartRequired);
- ReloadResult result = new ReloadResult(effective, applied, pendingRestart);
- for (Consumer listener : listeners) {
- try {
- listener.accept(result);
- } catch (RuntimeException e) {
- LOG.error("Runtime configuration listener failed", e);
- }
- }
- return result;
- }
-
private static AppConfig mergeReloadable(AppConfig previous, AppConfig loaded) {
SeiraConfig oldSeira = previous.seira();
SeiraConfig newSeira = loaded.seira();
@@ -128,6 +91,45 @@ private static void addIfChanged(List changed, String name, Object befor
}
}
+ public AppConfig current() {
+ return current.get();
+ }
+
+ public List pendingRestart() {
+ return pendingRestart;
+ }
+
+ public void addReloadListener(Consumer listener) {
+ listeners.add(Objects.requireNonNull(listener));
+ }
+
+ /**
+ * Reads and validates the source configuration without applying it.
+ */
+ public AppConfig validateSource() {
+ return loader.get();
+ }
+
+ public synchronized ReloadResult reload() {
+ AppConfig previous = current.get();
+ AppConfig loaded = loader.get();
+ List applied = appliedChanges(previous, loaded);
+ List restartRequired = restartRequiredChanges(previous, loaded);
+ AppConfig effective = mergeReloadable(previous, loaded);
+
+ current.set(effective);
+ pendingRestart = List.copyOf(restartRequired);
+ ReloadResult result = new ReloadResult(effective, applied, pendingRestart);
+ for (Consumer listener : listeners) {
+ try {
+ listener.accept(result);
+ } catch (RuntimeException e) {
+ LOG.error("Runtime configuration listener failed", e);
+ }
+ }
+ return result;
+ }
+
public record ReloadResult(
AppConfig effectiveConfig,
List applied,
diff --git a/src/main/java/xyz/zcraft/seira/console/ConsoleCommandProcessor.java b/src/main/java/xyz/zcraft/seira/console/ConsoleCommandProcessor.java
index b67feedb..f7e3ced9 100644
--- a/src/main/java/xyz/zcraft/seira/console/ConsoleCommandProcessor.java
+++ b/src/main/java/xyz/zcraft/seira/console/ConsoleCommandProcessor.java
@@ -4,24 +4,20 @@
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.Configurator;
-import xyz.zcraft.seira.binding.UserDataStore;
import xyz.zcraft.seira.bot.MessageSender;
import xyz.zcraft.seira.command.Context;
import xyz.zcraft.seira.command.route.Router;
import xyz.zcraft.seira.config.AppConfig;
import xyz.zcraft.seira.config.RuntimeConfig;
-import xyz.zcraft.seira.security.AdminRegistry;
+import xyz.zcraft.seira.db.SqliteDatabase;
import xyz.zcraft.seira.services.BotStat;
+import xyz.zcraft.seira.util.AdminRegistry;
import xyz.zcraft.seira.watch.WatchView;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Properties;
+import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
@@ -67,6 +63,152 @@ public ConsoleCommandProcessor(
this.runtimeControl = Objects.requireNonNull(runtimeControl);
}
+ static List rootCommands() {
+ return ROOT_COMMANDS;
+ }
+
+ static List subcommands(String command) {
+ return SUBCOMMANDS.getOrDefault(command.toLowerCase(Locale.ROOT), List.of());
+ }
+
+ private static String formatCacheControl(ConsoleRuntimeControl.CacheControlResult result) {
+ StringBuilder output = new StringBuilder(result.operation().toLowerCase(Locale.ROOT))
+ .append(' ').append(result.type().toLowerCase(Locale.ROOT)).append(' ').append(result.id());
+ for (ConsoleRuntimeControl.CacheNodeResult node : result.nodes()) {
+ output.append("\n ").append(node.node()).append(": ").append(node.status());
+ if (node.path() != null) output.append(" | path=").append(node.path());
+ if (node.sizeBytes() != null) output.append(" | size=").append(formatBytes(node.sizeBytes()));
+ if (node.modifiedAt() != null) output.append(" | modified=").append(node.modifiedAt());
+ if (node.message() != null) output.append(" | ").append(node.message());
+ }
+ return output.toString();
+ }
+
+ private static ConsoleResult exact(
+ ConsoleInputParser.ParsedInput input,
+ int size,
+ java.util.function.Supplier action,
+ String usage
+ ) {
+ return input.size() == size ? action.get() : ConsoleResult.failure(usage);
+ }
+
+ private static String formatWatches(String groupId, List watches) {
+ if (watches.isEmpty()) {
+ return "Group " + groupId + ": no active watches.";
+ }
+ StringBuilder output = new StringBuilder("Group " + groupId + " (" + watches.size() + "):");
+ watches.forEach(watch -> output.append("\n ")
+ .append(watch.target().username())
+ .append(" | UID ").append(watch.target().userId())
+ .append(" | QQ ").append(watch.target().qqOpenId())
+ .append(" | remaining ").append(formatDuration(watch.remaining())));
+ return output.toString();
+ }
+
+ private static String formatQueryResult(SqliteDatabase.QueryResult result) {
+ StringBuilder output = new StringBuilder();
+ output.append(result.columns().stream().map(ConsoleCommandProcessor::cell).reduce(
+ (left, right) -> left + " | " + right
+ ).orElse("(no columns)"));
+ for (List row : result.rows()) {
+ output.append('\n').append(row.stream().map(ConsoleCommandProcessor::cell).reduce(
+ (left, right) -> left + " | " + right
+ ).orElse(""));
+ }
+ output.append("\nReturned ").append(result.rows().size()).append(" row(s)");
+ if (result.truncated()) {
+ output.append(" (result truncated)");
+ }
+ return output.toString();
+ }
+
+ private static String cell(String value) {
+ if (value == null) {
+ return "NULL";
+ }
+ String normalized = value.replace('\n', ' ').replace('\r', ' ').replace('\t', ' ');
+ return normalized.length() <= MAX_CELL_LENGTH
+ ? normalized
+ : normalized.substring(0, MAX_CELL_LENGTH - 1) + "...";
+ }
+
+ private static long positiveLong(String value, String name) {
+ try {
+ long parsed = Long.parseLong(value);
+ if (parsed > 0) {
+ return parsed;
+ }
+ } catch (NumberFormatException ignored) {
+ }
+ throw new IllegalArgumentException(name + " must be a positive integer.");
+ }
+
+ private static String formatDuration(Duration duration) {
+ if (duration == null || duration.isNegative()) {
+ return "unknown";
+ }
+ long seconds = duration.toSeconds();
+ long days = seconds / 86_400;
+ long hours = seconds % 86_400 / 3_600;
+ long minutes = seconds % 3_600 / 60;
+ long remainingSeconds = seconds % 60;
+ if (days > 0) {
+ return "%dd %02dh %02dm".formatted(days, hours, minutes);
+ }
+ if (hours > 0) {
+ return "%dh %02dm %02ds".formatted(hours, minutes, remainingSeconds);
+ }
+ if (minutes > 0) {
+ return "%dm %02ds".formatted(minutes, remainingSeconds);
+ }
+ return remainingSeconds + "s";
+ }
+
+ private static String formatBytes(long bytes) {
+ if (bytes < 1024) {
+ return bytes + " B";
+ }
+ double value = bytes;
+ String[] units = {"KiB", "MiB", "GiB", "TiB"};
+ int unit = -1;
+ do {
+ value /= 1024.0;
+ unit++;
+ } while (value >= 1024 && unit < units.length - 1);
+ return String.format(Locale.ROOT, "%.1f %s", value, units[unit]);
+ }
+
+ private static String blankAs(String value, String fallback) {
+ return value == null || value.isBlank() ? fallback : value;
+ }
+
+ private static String version() {
+ Package currentPackage = ConsoleCommandProcessor.class.getPackage();
+ String implementation = currentPackage == null ? null : currentPackage.getImplementationVersion();
+ if (implementation != null && !implementation.isBlank()) {
+ return implementation;
+ }
+ Properties properties = new Properties();
+ try (InputStream input = ConsoleCommandProcessor.class.getResourceAsStream("/version.properties")) {
+ if (input != null) {
+ properties.load(input);
+ return properties.getProperty("version", "development");
+ }
+ } catch (IOException ignored) {
+ }
+ return "development";
+ }
+
+ private static String rootMessage(Throwable error) {
+ Throwable cursor = error;
+ while (cursor.getCause() != null) {
+ cursor = cursor.getCause();
+ }
+ String message = cursor.getMessage();
+ return message == null || message.isBlank() ? cursor.getClass().getSimpleName() : message;
+ }
+
public ConsoleResult execute(String line) {
try {
ConsoleInputParser.ParsedInput input = ConsoleInputParser.parse(line);
@@ -101,14 +243,6 @@ public ConsoleResult execute(String line) {
}
}
- static List rootCommands() {
- return ROOT_COMMANDS;
- }
-
- static List subcommands(String command) {
- return SUBCOMMANDS.getOrDefault(command.toLowerCase(Locale.ROOT), List.of());
- }
-
private ConsoleResult help(ConsoleInputParser.ParsedInput input) {
if (input.size() > 2) {
return ConsoleResult.failure("Usage: help [command]");
@@ -134,7 +268,7 @@ private ConsoleResult help(ConsoleInputParser.ParsedInput input) {
log Inspect or change the runtime log level
inspect Show the last dispatched message context
stop confirm Gracefully stop SeiraCore
-
+
Aliases: ? (help), stat (status), shutdown/exit/quit (stop)
Use 'help ' for examples and safety notes.
""".stripTrailing());
@@ -194,7 +328,8 @@ private ConsoleResult help(ConsoleInputParser.ParsedInput input) {
panel edit
Manage command panels to be displayed on QQ platform.""";
case "inspect" -> "inspect\nShows IDs and parsed command data from the last dispatched QQ message.";
- case "stop", "shutdown", "exit", "quit" -> "stop confirm\nGracefully stops the gateway and all application services.";
+ case "stop", "shutdown", "exit", "quit" ->
+ "stop confirm\nGracefully stops the gateway and all application services.";
default -> null;
};
return detail == null
@@ -366,11 +501,16 @@ private ConsoleResult panel(ConsoleInputParser.ParsedInput input) {
return ConsoleResult.failure("Usage: panel [args]");
}
return switch (input.value(1).toLowerCase(Locale.ROOT)) {
- case "list" -> input.size() == 3 ? runtimeControl.listPanels(input.value(2)) : ConsoleResult.failure("Usage: panel list ");
- case "delete" -> input.size() == 3 ? runtimeControl.deletePanel(input.value(2)) : ConsoleResult.failure("Usage: panel delete ");
- case "get" -> input.size() == 3 ? runtimeControl.getPanel(input.value(2)) : ConsoleResult.failure("Usage: panel get ");
- case "create" -> input.size() == 4 ? runtimeControl.createPanel(input.value(2), input.value(3)) : ConsoleResult.failure("Usage: panel create ");
- case "edit" -> input.size() == 4 ? runtimeControl.editPanel(input.value(2), input.value(3)) : ConsoleResult.failure("Usage: panel edit ");
+ case "list" ->
+ input.size() == 3 ? runtimeControl.listPanels(input.value(2)) : ConsoleResult.failure("Usage: panel list ");
+ case "delete" ->
+ input.size() == 3 ? runtimeControl.deletePanel(input.value(2)) : ConsoleResult.failure("Usage: panel delete ");
+ case "get" ->
+ input.size() == 3 ? runtimeControl.getPanel(input.value(2)) : ConsoleResult.failure("Usage: panel get ");
+ case "create" ->
+ input.size() == 4 ? runtimeControl.createPanel(input.value(2), input.value(3)) : ConsoleResult.failure("Usage: panel create ");
+ case "edit" ->
+ input.size() == 4 ? runtimeControl.editPanel(input.value(2), input.value(3)) : ConsoleResult.failure("Usage: panel edit ");
default -> ConsoleResult.failure("Usage: panel [args]");
};
}
@@ -469,8 +609,8 @@ private ConsoleResult send(ConsoleInputParser.ParsedInput input) {
}
boolean sent = switch (targetType) {
- case "group" -> messenger.sendGroupText(targetId, content);
- case "private" -> messenger.sendPrivateText(targetId, content);
+ case "group" -> messenger.sendGroupText(targetId, content) != null;
+ case "private" -> messenger.sendPrivateText(targetId, content) != null;
default -> throw new IllegalArgumentException("Message type must be 'group' or 'private'.");
};
return sent
@@ -484,7 +624,8 @@ private ConsoleResult watch(ConsoleInputParser.ParsedInput input) {
}
return switch (input.value(1).toLowerCase(Locale.ROOT)) {
case "status" -> input.size() == 2 ? watchStatus() : ConsoleResult.failure("Usage: watch status");
- case "list" -> input.size() <= 3 ? listWatches(input) : ConsoleResult.failure("Usage: watch list [group-id]");
+ case "list" ->
+ input.size() <= 3 ? listWatches(input) : ConsoleResult.failure("Usage: watch list [group-id]");
case "poll" -> input.size() == 2 ? pollWatches() : ConsoleResult.failure("Usage: watch poll");
case "remove" -> removeWatch(input);
case "clear" -> clearWatches(input);
@@ -583,19 +724,6 @@ private ConsoleResult cache(ConsoleInputParser.ParsedInput input) {
return ConsoleResult.success(formatCacheControl(runtimeControl.controlCache(operation, type, id)));
}
- private static String formatCacheControl(ConsoleRuntimeControl.CacheControlResult result) {
- StringBuilder output = new StringBuilder(result.operation().toLowerCase(Locale.ROOT))
- .append(' ').append(result.type().toLowerCase(Locale.ROOT)).append(' ').append(result.id());
- for (ConsoleRuntimeControl.CacheNodeResult node : result.nodes()) {
- output.append("\n ").append(node.node()).append(": ").append(node.status());
- if (node.path() != null) output.append(" | path=").append(node.path());
- if (node.sizeBytes() != null) output.append(" | size=").append(formatBytes(node.sizeBytes()));
- if (node.modifiedAt() != null) output.append(" | modified=").append(node.modifiedAt());
- if (node.message() != null) output.append(" | ").append(node.message());
- }
- return output.toString();
- }
-
private ConsoleResult log(ConsoleInputParser.ParsedInput input) {
if (input.size() == 2 && "show".equalsIgnoreCase(input.value(1))) {
return ConsoleResult.success("Root log level: " + LogManager.getRootLogger().getLevel());
@@ -649,131 +777,6 @@ private ConsoleResult stop(ConsoleInputParser.ParsedInput input) {
return ConsoleResult.success("Graceful shutdown requested.");
}
- private static ConsoleResult exact(
- ConsoleInputParser.ParsedInput input,
- int size,
- java.util.function.Supplier action,
- String usage
- ) {
- return input.size() == size ? action.get() : ConsoleResult.failure(usage);
- }
-
- private static String formatWatches(String groupId, List watches) {
- if (watches.isEmpty()) {
- return "Group " + groupId + ": no active watches.";
- }
- StringBuilder output = new StringBuilder("Group " + groupId + " (" + watches.size() + "):");
- watches.forEach(watch -> output.append("\n ")
- .append(watch.target().username())
- .append(" | UID ").append(watch.target().userId())
- .append(" | QQ ").append(watch.target().qqOpenId())
- .append(" | remaining ").append(formatDuration(watch.remaining())));
- return output.toString();
- }
-
- private static String formatQueryResult(UserDataStore.QueryResult result) {
- StringBuilder output = new StringBuilder();
- output.append(result.columns().stream().map(ConsoleCommandProcessor::cell).reduce(
- (left, right) -> left + " | " + right
- ).orElse("(no columns)"));
- for (List row : result.rows()) {
- output.append('\n').append(row.stream().map(ConsoleCommandProcessor::cell).reduce(
- (left, right) -> left + " | " + right
- ).orElse(""));
- }
- output.append("\nReturned ").append(result.rows().size()).append(" row(s)");
- if (result.truncated()) {
- output.append(" (result truncated)");
- }
- return output.toString();
- }
-
- private static String cell(String value) {
- if (value == null) {
- return "NULL";
- }
- String normalized = value.replace('\n', ' ').replace('\r', ' ').replace('\t', ' ');
- return normalized.length() <= MAX_CELL_LENGTH
- ? normalized
- : normalized.substring(0, MAX_CELL_LENGTH - 1) + "...";
- }
-
- private static long positiveLong(String value, String name) {
- try {
- long parsed = Long.parseLong(value);
- if (parsed > 0) {
- return parsed;
- }
- } catch (NumberFormatException ignored) {
- }
- throw new IllegalArgumentException(name + " must be a positive integer.");
- }
-
- private static String formatDuration(Duration duration) {
- if (duration == null || duration.isNegative()) {
- return "unknown";
- }
- long seconds = duration.toSeconds();
- long days = seconds / 86_400;
- long hours = seconds % 86_400 / 3_600;
- long minutes = seconds % 3_600 / 60;
- long remainingSeconds = seconds % 60;
- if (days > 0) {
- return "%dd %02dh %02dm".formatted(days, hours, minutes);
- }
- if (hours > 0) {
- return "%dh %02dm %02ds".formatted(hours, minutes, remainingSeconds);
- }
- if (minutes > 0) {
- return "%dm %02ds".formatted(minutes, remainingSeconds);
- }
- return remainingSeconds + "s";
- }
-
- private static String formatBytes(long bytes) {
- if (bytes < 1024) {
- return bytes + " B";
- }
- double value = bytes;
- String[] units = {"KiB", "MiB", "GiB", "TiB"};
- int unit = -1;
- do {
- value /= 1024.0;
- unit++;
- } while (value >= 1024 && unit < units.length - 1);
- return String.format(Locale.ROOT, "%.1f %s", value, units[unit]);
- }
-
- private static String blankAs(String value, String fallback) {
- return value == null || value.isBlank() ? fallback : value;
- }
-
- private static String version() {
- Package currentPackage = ConsoleCommandProcessor.class.getPackage();
- String implementation = currentPackage == null ? null : currentPackage.getImplementationVersion();
- if (implementation != null && !implementation.isBlank()) {
- return implementation;
- }
- Properties properties = new Properties();
- try (InputStream input = ConsoleCommandProcessor.class.getResourceAsStream("/version.properties")) {
- if (input != null) {
- properties.load(input);
- return properties.getProperty("version", "development");
- }
- } catch (IOException ignored) {
- }
- return "development";
- }
-
- private static String rootMessage(Throwable error) {
- Throwable cursor = error;
- while (cursor.getCause() != null) {
- cursor = cursor.getCause();
- }
- String message = cursor.getMessage();
- return message == null || message.isBlank() ? cursor.getClass().getSimpleName() : message;
- }
-
public record ConsoleResult(boolean success, String message) {
public static ConsoleResult success(String message) {
return new ConsoleResult(true, message);
diff --git a/src/main/java/xyz/zcraft/seira/console/ConsoleDataAccess.java b/src/main/java/xyz/zcraft/seira/console/ConsoleDataAccess.java
index 80b7fc2c..f015049a 100644
--- a/src/main/java/xyz/zcraft/seira/console/ConsoleDataAccess.java
+++ b/src/main/java/xyz/zcraft/seira/console/ConsoleDataAccess.java
@@ -1,9 +1,9 @@
package xyz.zcraft.seira.console;
-import xyz.zcraft.seira.binding.UserDataStore;
+import xyz.zcraft.seira.db.SqliteDatabase;
public interface ConsoleDataAccess {
- UserDataStore.QueryResult query(String sql, int maxRows);
+ SqliteDatabase.QueryResult query(String sql, int maxRows);
int boundUsers();
diff --git a/src/main/java/xyz/zcraft/seira/console/JLineConsole.java b/src/main/java/xyz/zcraft/seira/console/JLineConsole.java
index 4cdcad12..eb877b0d 100644
--- a/src/main/java/xyz/zcraft/seira/console/JLineConsole.java
+++ b/src/main/java/xyz/zcraft/seira/console/JLineConsole.java
@@ -2,13 +2,7 @@
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
-import org.jline.reader.Candidate;
-import org.jline.reader.Completer;
-import org.jline.reader.EndOfFileException;
-import org.jline.reader.LineReader;
-import org.jline.reader.LineReaderBuilder;
-import org.jline.reader.ParsedLine;
-import org.jline.reader.UserInterruptException;
+import org.jline.reader.*;
import org.jline.reader.impl.DefaultParser;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
@@ -21,7 +15,9 @@
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
-/** Interactive local administration console backed by JLine. */
+/**
+ * Interactive local administration console backed by JLine.
+ */
public final class JLineConsole implements AutoCloseable {
private static final Logger LOG = LogManager.getLogger(JLineConsole.class);
private static final String PROMPT = "seira> ";
diff --git a/src/main/java/xyz/zcraft/seira/console/JLineLogBridge.java b/src/main/java/xyz/zcraft/seira/console/JLineLogBridge.java
index 8709c79a..63479575 100644
--- a/src/main/java/xyz/zcraft/seira/console/JLineLogBridge.java
+++ b/src/main/java/xyz/zcraft/seira/console/JLineLogBridge.java
@@ -17,7 +17,9 @@
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
-/** Routes terminal log events through JLine so the active prompt can be redrawn safely. */
+/**
+ * Routes terminal log events through JLine so the active prompt can be redrawn safely.
+ */
final class JLineLogBridge implements AutoCloseable {
private static final String CONSOLE_APPENDER_NAME = "CONSOLE";
private static final String JLINE_APPENDER_NAME = "JLINE_CONSOLE";
diff --git a/src/main/java/xyz/zcraft/seira/console/UserDataConsoleAccess.java b/src/main/java/xyz/zcraft/seira/console/UserDataConsoleAccess.java
index 032346e4..1a712fa2 100644
--- a/src/main/java/xyz/zcraft/seira/console/UserDataConsoleAccess.java
+++ b/src/main/java/xyz/zcraft/seira/console/UserDataConsoleAccess.java
@@ -1,11 +1,12 @@
package xyz.zcraft.seira.console;
-import xyz.zcraft.seira.binding.UserDataStore;
+import xyz.zcraft.seira.db.SqliteDatabase;
+import xyz.zcraft.seira.db.UserDataStore;
public final class UserDataConsoleAccess implements ConsoleDataAccess {
@Override
- public UserDataStore.QueryResult query(String sql, int maxRows) {
- return UserDataStore.queryReadOnly(sql, maxRows);
+ public SqliteDatabase.QueryResult query(String sql, int maxRows) {
+ return SqliteDatabase.queryReadOnly(sql, maxRows);
}
@Override
diff --git a/src/main/java/xyz/zcraft/seira/data/SendResult.java b/src/main/java/xyz/zcraft/seira/data/SendResult.java
new file mode 100644
index 00000000..678f7805
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/data/SendResult.java
@@ -0,0 +1,9 @@
+package xyz.zcraft.seira.data;
+
+import xyz.zcraft.seira.bot.data.SentMessage;
+
+public record SendResult(
+ boolean success,
+ SentMessage sentMessage
+) {
+}
diff --git a/src/main/java/xyz/zcraft/seira/data/UserRef.java b/src/main/java/xyz/zcraft/seira/data/UserRef.java
index b8c4409f..87dc0cc1 100644
--- a/src/main/java/xyz/zcraft/seira/data/UserRef.java
+++ b/src/main/java/xyz/zcraft/seira/data/UserRef.java
@@ -3,7 +3,8 @@
import lombok.Getter;
public class UserRef {
- private UserRef() {}
+ private UserRef() {
+ }
public static class ByUid extends UserRef {
@Getter
diff --git a/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java b/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java
new file mode 100644
index 00000000..287c33e3
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java
@@ -0,0 +1,306 @@
+package xyz.zcraft.seira.db;
+
+import xyz.zcraft.seira.rankguess.data.FinishedRound;
+import xyz.zcraft.seira.rankguess.data.Standing;
+
+import java.sql.*;
+import java.util.Objects;
+
+import static xyz.zcraft.seira.rankguess.RankGuessGameService.MIN_GAMES_TO_RANK;
+
+public class RankGuessRecordStore {
+ public static final int TOP_TWENTY_MIN_PARTICIPANTS = 1;
+
+ /**
+ * Returns false when this round has already been saved. All rows are committed together.
+ */
+ public static boolean save(FinishedRound finished) {
+ validate(finished);
+ String gameSql = """
+ INSERT INTO rank_guess_games (
+ round_id, group_id, source_mode, target_user_id, target_score_id, actual_rank,
+ started_at, ended_at, participant_count, scoring_version
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(round_id) DO NOTHING
+ """;
+ String resultSql = """
+ INSERT INTO rank_guess_results (
+ round_id, user_id, guessed_rank, placement, raw_score, multiplier, final_score
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
+ """;
+ try (Connection connection = SqliteDatabase.getConnection()) {
+ connection.setAutoCommit(false);
+ try {
+ String roundId = finished.id().toString();
+ try (PreparedStatement statement = connection.prepareStatement(gameSql)) {
+ statement.setString(1, roundId);
+ statement.setString(2, finished.groupId());
+ statement.setString(3, finished.fromGroup() ? "group" : "random");
+ statement.setLong(4, finished.round().userId());
+ statement.setLong(5, finished.round().scoreId());
+ statement.setLong(6, finished.round().actualRank());
+ statement.setLong(7, finished.startedAt().toEpochMilli());
+ statement.setLong(8, finished.endedAt().toEpochMilli());
+ statement.setInt(9, finished.standings().size());
+ statement.setInt(10, finished.scoringVersion());
+ if (statement.executeUpdate() == 0) {
+ connection.rollback();
+ return false;
+ }
+ }
+ try (PreparedStatement statement = connection.prepareStatement(resultSql)) {
+ for (int i = 0; i < finished.standings().size(); i++) {
+ Standing result = finished.standings().get(i);
+ statement.setString(1, roundId);
+ statement.setString(2, result.senderUserId());
+ statement.setLong(3, result.guess());
+ statement.setInt(4, i + 1);
+ statement.setDouble(5, result.pointsRaw());
+ statement.setDouble(6, result.multiplier());
+ statement.setDouble(7, result.points());
+ statement.addBatch();
+ }
+ statement.executeBatch();
+ }
+ connection.commit();
+ return true;
+ } catch (SQLException | RuntimeException e) {
+ try {
+ connection.rollback();
+ } catch (SQLException rollbackFailure) {
+ e.addSuppressed(rollbackFailure);
+ }
+ throw e;
+ }
+ } catch (SQLException e) {
+ throw new RecordSaveException("Failed to save rank guess round " + finished.id(), e);
+ }
+ }
+
+ public static long getPickedTimes(Long osuUid, String groupId) {
+ String sql = """
+ SELECT COUNT(*)
+ FROM rank_guess_games g
+ WHERE g.target_user_id = ?
+ """;
+ if (groupId != null) sql += " AND g.group_id = ?";
+ try (Connection connection = SqliteDatabase.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setLong(1, osuUid);
+ if (groupId != null) statement.setString(2, groupId);
+ try (ResultSet result = statement.executeQuery()) {
+ result.next();
+ return result.getLong(1);
+ }
+ } catch (SQLException e) {
+ throw new IllegalStateException("Failed to query picked times", e);
+ }
+ }
+
+ public static long getGroupGameCount(String groupId, Integer scoringVersion) {
+ requireText(groupId, "groupId");
+ if (scoringVersion != null && scoringVersion < 1) {
+ throw new IllegalArgumentException("scoringVersion must be positive");
+ }
+
+ String sql = """
+ SELECT COUNT(*)
+ FROM rank_guess_games g
+ WHERE g.group_id = ?
+ """;
+
+ if (scoringVersion != null) sql += " AND g.scoring_version = ?";
+ try (Connection connection = SqliteDatabase.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, groupId);
+ if (scoringVersion != null) statement.setInt(2, scoringVersion);
+
+ try (ResultSet result = statement.executeQuery()) {
+ result.next();
+ return result.getLong(1);
+ }
+ } catch (SQLException e) {
+ throw new IllegalStateException("Failed to query rank guess statistics", e);
+ }
+ }
+
+ public static boolean canBeRanked(String userId, String groupId) {
+ String sql = """
+ SELECT COUNT(*) AS participation
+ FROM rank_guess_results r
+ JOIN rank_guess_games g ON g.round_id = r.round_id
+ WHERE r.user_id = ?
+ """;
+ if (groupId != null) sql += " AND g.group_id = ?";
+ try (Connection connection = SqliteDatabase.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, userId);
+ if (groupId != null) statement.setString(2, groupId);
+ try (ResultSet result = statement.executeQuery()) {
+ result.next();
+ return result.getLong("participation") >= MIN_GAMES_TO_RANK;
+ }
+ } catch (SQLException e) {
+ throw new IllegalStateException("Failed to query rank guess statistics", e);
+ }
+ }
+
+ public static Statistics.Personal getPersonalStatistics(String userId, String groupId, Integer scoringVersion) {
+ return getPersonalStatistics(userId, groupId, scoringVersion, null);
+ }
+
+ public static Statistics.Personal getRecentPersonalStatistics(
+ String userId, String groupId, Integer scoringVersion, int gameLimit
+ ) {
+ if (gameLimit < 1) {
+ throw new IllegalArgumentException("gameLimit must be positive");
+ }
+ return getPersonalStatistics(userId, groupId, scoringVersion, gameLimit);
+ }
+
+ private static Statistics.Personal getPersonalStatistics(
+ String userId, String groupId, Integer scoringVersion, Integer gameLimit
+ ) {
+ requireText(userId, "userId");
+ if (groupId != null) requireText(groupId, "groupId");
+ if (scoringVersion != null && scoringVersion < 1) {
+ throw new IllegalArgumentException("scoringVersion must be positive");
+ }
+ String sql = """
+ SELECT COUNT(*) AS participation,
+ COALESCE(SUM(CASE WHEN recent.placement = 1 THEN 1 ELSE 0 END), 0) AS wins,
+ COALESCE(SUM(CASE WHEN recent.participant_count >= ?
+ AND recent.placement <= (recent.participant_count + 4) / 5 THEN 1 ELSE 0 END), 0) AS top_twenty,
+ COALESCE(SUM(CASE WHEN recent.participant_count >= ? THEN 1 ELSE 0 END), 0) AS top_twenty_eligible,
+ COALESCE(SUM(recent.final_score), 0) AS total_score,
+ COALESCE(AVG(recent.final_score), 0) AS average_score,
+ COALESCE(MAX(recent.final_score), 0) AS highest_score,
+ COALESCE(AVG(recent.placement), 0) AS average_placement
+ FROM (
+ SELECT r.placement, r.final_score, g.participant_count
+ FROM rank_guess_results r
+ JOIN rank_guess_games g ON g.round_id = r.round_id
+ WHERE r.user_id = ?
+ """;
+ if (groupId != null) sql += " AND g.group_id = ?";
+ if (scoringVersion != null) sql += " AND g.scoring_version = ?";
+ if (gameLimit != null) sql += " ORDER BY g.ended_at DESC, g.round_id DESC LIMIT ?";
+ sql += ") recent";
+ try (Connection connection = SqliteDatabase.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setInt(1, TOP_TWENTY_MIN_PARTICIPANTS);
+ statement.setInt(2, TOP_TWENTY_MIN_PARTICIPANTS);
+ statement.setString(3, userId);
+ int index = 4;
+ if (groupId != null) statement.setString(index++, groupId);
+ if (scoringVersion != null) statement.setInt(index++, scoringVersion);
+ if (gameLimit != null) statement.setInt(index, gameLimit);
+ try (ResultSet result = statement.executeQuery()) {
+ result.next();
+ return new Statistics.Personal(
+ result.getLong("participation"), result.getLong("wins"),
+ result.getLong("top_twenty"), result.getLong("top_twenty_eligible"),
+ result.getDouble("total_score"), result.getDouble("average_score"),
+ result.getDouble("highest_score"), result.getDouble("average_placement")
+ );
+ }
+ } catch (SQLException e) {
+ throw new IllegalStateException("Failed to query rank guess statistics", e);
+ }
+ }
+
+ private static void validate(FinishedRound finished) {
+ Objects.requireNonNull(finished, "finished");
+ Objects.requireNonNull(finished.id(), "id");
+ Objects.requireNonNull(finished.round(), "round");
+ Objects.requireNonNull(finished.startedAt(), "startedAt");
+ Objects.requireNonNull(finished.endedAt(), "endedAt");
+ requireText(finished.groupId(), "groupId");
+ if (finished.endedAt().isBefore(finished.startedAt()) || finished.scoringVersion() < 1) {
+ throw new IllegalArgumentException("Invalid round timestamps or scoring version");
+ }
+ for (Standing result : finished.standings()) {
+ requireText(result.senderUserId(), "userId");
+ if (result.guess() <= 0 || !Double.isFinite(result.pointsRaw()) || result.pointsRaw() < 0
+ || !Double.isFinite(result.multiplier()) || result.multiplier() < 0
+ || !Double.isFinite(result.points()) || result.points() < 0) {
+ throw new IllegalArgumentException("Invalid rank guess result");
+ }
+ }
+ }
+
+ private static void requireText(String value, String name) {
+ if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " must not be blank");
+ }
+
+ public static void createTablesIfNeeded(Connection connection) throws SQLException {
+ String rgGameSql = """
+ CREATE TABLE IF NOT EXISTS rank_guess_games (
+ round_id TEXT PRIMARY KEY,
+ group_id TEXT NOT NULL,
+ source_mode TEXT NOT NULL
+ CHECK (source_mode IN ('random', 'group')),
+
+ target_user_id INTEGER NOT NULL,
+ target_score_id INTEGER NOT NULL,
+ actual_rank INTEGER NOT NULL CHECK (actual_rank > 0),
+
+ started_at INTEGER NOT NULL,
+ ended_at INTEGER NOT NULL,
+ participant_count INTEGER NOT NULL CHECK (participant_count >= 0),
+ scoring_version INTEGER NOT NULL DEFAULT 1
+ );
+ """;
+ String rgResultSql = """
+ CREATE TABLE IF NOT EXISTS rank_guess_results (
+ round_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+
+ guessed_rank INTEGER NOT NULL CHECK (guessed_rank > 0),
+ placement INTEGER NOT NULL CHECK (placement > 0),
+ raw_score REAL NOT NULL,
+ multiplier REAL NOT NULL,
+ final_score REAL NOT NULL,
+
+ PRIMARY KEY (round_id, user_id),
+ FOREIGN KEY (round_id) REFERENCES rank_guess_games(round_id)
+ );
+ """;
+ String rgResultIndex = """
+ CREATE INDEX IF NOT EXISTS idx_rg_results_user
+ ON rank_guess_results(user_id, round_id);
+ """;
+ String rgGameIndex = """
+ CREATE INDEX IF NOT EXISTS idx_rg_games_group_time
+ ON rank_guess_games(group_id, ended_at);
+ """;
+ try (Statement statement = connection.createStatement()) {
+ statement.execute(rgGameSql);
+ statement.execute(rgResultSql);
+ statement.execute(rgResultIndex);
+ statement.execute(rgGameIndex);
+ }
+ }
+
+ public static class Statistics {
+ public record Personal(
+ long participation, long wins, long topTwentyCount, long topTwentyEligibleParticipation,
+ double totalScore, double averageScore, double highestScore, double averagePlacement
+ ) {
+ public double winRate() {
+ return participation == 0 ? 0 : wins / (double) participation;
+ }
+
+ public double topTwentyRate() {
+ return topTwentyEligibleParticipation == 0 ? 0
+ : topTwentyCount / (double) topTwentyEligibleParticipation;
+ }
+ }
+ }
+
+ public static final class RecordSaveException extends RuntimeException {
+ public RecordSaveException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+}
diff --git a/src/main/java/xyz/zcraft/seira/db/SqliteDatabase.java b/src/main/java/xyz/zcraft/seira/db/SqliteDatabase.java
new file mode 100644
index 00000000..74da1b15
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/db/SqliteDatabase.java
@@ -0,0 +1,145 @@
+package xyz.zcraft.seira.db;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.*;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+public class SqliteDatabase {
+ private static final Logger LOG = LogManager.getLogger(SqliteDatabase.class);
+ private static final Object INIT_LOCK = new Object();
+
+ private static volatile String jdbcUrl;
+ private static volatile Path initializedDbPath;
+
+ public static void init(String sqlitePath) {
+ synchronized (INIT_LOCK) {
+ if (jdbcUrl != null && initializedDbPath != null && Files.exists(initializedDbPath)) {
+ return;
+ }
+ try {
+ Path dbPath = Path.of(sqlitePath).toAbsolutePath().normalize();
+ Path parent = dbPath.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
+ String createdUrl = "jdbc:sqlite:" + dbPath;
+ try (Connection connection = openConnection(createdUrl)) {
+ connection.setAutoCommit(false);
+ UserDataStore.createTablesIfNeeded(connection);
+ RankGuessRecordStore.createTablesIfNeeded(connection);
+ connection.commit();
+ }
+ initializedDbPath = dbPath;
+ jdbcUrl = createdUrl;
+
+ LOG.info("SQLite database initialized at {}", dbPath);
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to initialize sqlite store: " + sqlitePath, e);
+ }
+ }
+ }
+
+ public static Connection getConnection() throws SQLException {
+ ensureInitialized();
+ return openConnection(jdbcUrl);
+ }
+
+ private static Connection openConnection(String url) throws SQLException {
+ Connection connection = DriverManager.getConnection(url);
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("PRAGMA foreign_keys = ON");
+ statement.execute("PRAGMA busy_timeout = 5000");
+ } catch (SQLException e) {
+ connection.close();
+ throw e;
+ }
+ return connection;
+ }
+
+ public static void ensureInitialized() {
+ if (jdbcUrl == null) {
+ throw new IllegalStateException("SQLite database is not initialized");
+ }
+ }
+
+ public static QueryResult queryReadOnly(String sql, int maxRows) {
+ SqliteDatabase.ensureInitialized();
+ if (sql == null || sql.isBlank()) {
+ throw new IllegalArgumentException("SQL query must not be blank");
+ }
+ if (maxRows < 1 || maxRows > 200) {
+ throw new IllegalArgumentException("maxRows must be between 1 and 200");
+ }
+
+ String statementSql = sql.strip();
+ if (statementSql.endsWith(";")) {
+ statementSql = statementSql.substring(0, statementSql.length() - 1).stripTrailing();
+ }
+ if (statementSql.contains(";")) {
+ throw new IllegalArgumentException("Multiple SQL statements are not allowed");
+ }
+ String normalized = statementSql.toLowerCase(Locale.ROOT);
+ String keyword = normalized.split("\\s+", 2)[0];
+ if (!List.of("select", "with", "pragma", "explain").contains(keyword)) {
+ throw new IllegalArgumentException("Only SELECT, WITH, PRAGMA and EXPLAIN queries are allowed");
+ }
+ if ("pragma".equals(keyword) && !isAllowedReadOnlyPragma(normalized)) {
+ throw new IllegalArgumentException("This PRAGMA is not allowed in the read-only console");
+ }
+
+ try (Connection connection = SqliteDatabase.getConnection();
+ Statement queryOnly = connection.createStatement();
+ Statement statement = connection.createStatement()) {
+ queryOnly.execute("PRAGMA query_only = ON");
+ statement.setMaxRows(maxRows + 1);
+ statement.setQueryTimeout(10);
+ // SQL is intentionally accepted from the trusted local console. The
+ // SQLite connection is query-only and the result size is bounded.
+ //noinspection SqlSourceToSinkFlow
+ try (ResultSet resultSet = statement.executeQuery(statementSql)) {
+ ResultSetMetaData metadata = resultSet.getMetaData();
+ int columnCount = metadata.getColumnCount();
+ List columns = new ArrayList<>(columnCount);
+ for (int index = 1; index <= columnCount; index++) {
+ columns.add(metadata.getColumnLabel(index));
+ }
+
+ List> rows = new ArrayList<>();
+ boolean truncated = false;
+ while (resultSet.next()) {
+ if (rows.size() == maxRows) {
+ truncated = true;
+ break;
+ }
+ List row = new ArrayList<>(columnCount);
+ for (int index = 1; index <= columnCount; index++) {
+ row.add(resultSet.getString(index));
+ }
+ rows.add(List.copyOf(row));
+ }
+ return new QueryResult(List.copyOf(columns), List.copyOf(rows), truncated);
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to execute read-only SQL query", e);
+ }
+ }
+
+ private static boolean isAllowedReadOnlyPragma(String normalizedSql) {
+ String pragma = normalizedSql.substring("pragma".length()).stripLeading();
+ int separator = pragma.indexOf('(');
+ String name = (separator >= 0 ? pragma.substring(0, separator) : pragma).strip();
+ return List.of(
+ "table_info", "table_xinfo", "table_list", "index_list", "index_info",
+ "index_xinfo", "foreign_key_list", "database_list", "compile_options"
+ ).contains(name);
+ }
+
+ public record QueryResult(List columns, List> rows, boolean truncated) {
+ }
+}
diff --git a/src/main/java/xyz/zcraft/seira/binding/UserDataStore.java b/src/main/java/xyz/zcraft/seira/db/UserDataStore.java
similarity index 79%
rename from src/main/java/xyz/zcraft/seira/binding/UserDataStore.java
rename to src/main/java/xyz/zcraft/seira/db/UserDataStore.java
index f3a878cb..e589b994 100644
--- a/src/main/java/xyz/zcraft/seira/binding/UserDataStore.java
+++ b/src/main/java/xyz/zcraft/seira/db/UserDataStore.java
@@ -1,4 +1,4 @@
-package xyz.zcraft.seira.binding;
+package xyz.zcraft.seira.db;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -7,49 +7,14 @@
import xyz.zcraft.seira.util.OsuAuthHelper;
import xyz.zcraft.seira.watch.SpecificScoreWatchState;
-import java.nio.file.Files;
-import java.nio.file.Path;
import java.sql.*;
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Locale;
-import java.util.Set;
+import java.util.*;
public final class UserDataStore {
private static final Logger LOG = LogManager.getLogger(UserDataStore.class);
- private static final Object INIT_LOCK = new Object();
-
- private static volatile String jdbcUrl;
- private static volatile Path initializedDbPath;
-
- public static void init(String sqlitePath) {
- synchronized (INIT_LOCK) {
- if (jdbcUrl != null && initializedDbPath != null && Files.exists(initializedDbPath)) {
- return;
- }
- try {
- Path dbPath = Path.of(sqlitePath).toAbsolutePath().normalize();
- Path parent = dbPath.getParent();
- if (parent != null) {
- Files.createDirectories(parent);
- }
- jdbcUrl = "jdbc:sqlite:" + dbPath;
- initializedDbPath = dbPath;
- createTablesIfNeeded();
- LOG.info("SQLite binding store initialized at {}", dbPath);
- } catch (Exception e) {
- throw new IllegalStateException("Failed to initialize sqlite store: " + sqlitePath, e);
- }
- }
- }
public static void bind(String openId, long osuUid) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
long now = System.currentTimeMillis();
String sql = """
INSERT INTO user_bindings(open_id, osu_uid, created_at, updated_at)
@@ -58,7 +23,7 @@ ON CONFLICT(open_id) DO UPDATE SET
osu_uid = excluded.osu_uid,
updated_at = excluded.updated_at
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, openId);
statement.setLong(2, osuUid);
@@ -71,7 +36,7 @@ ON CONFLICT(open_id) DO UPDATE SET
}
public static void storeToken(String openId, OsuToken token) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = """
INSERT INTO token_store(open_id, access_token, refresh_token, expires_in, refreshed_at)
VALUES(?, ?, ?, ?, ?)
@@ -81,7 +46,7 @@ ON CONFLICT(open_id) DO UPDATE SET
expires_in = excluded.expires_in,
refreshed_at = excluded.refreshed_at
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, openId);
statement.setString(2, token.accessToken());
@@ -95,12 +60,12 @@ ON CONFLICT(open_id) DO UPDATE SET
}
public static void removeToken(String openId) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = """
DELETE FROM token_store
WHERE open_id = ?
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, openId);
statement.executeUpdate();
@@ -110,14 +75,14 @@ public static void removeToken(String openId) {
}
public static void storeUserInfo(long osuId, String username) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = """
INSERT INTO user_info(uid, username)
VALUES(?, ?)
ON CONFLICT(uid) DO UPDATE SET
username = excluded.username
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, osuId);
statement.setString(2, username);
@@ -128,12 +93,12 @@ ON CONFLICT(uid) DO UPDATE SET
}
public static Optional findUsername(long osuId) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = """
SELECT username FROM user_info
WHERE uid = ?
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, osuId);
final ResultSet resultSet = statement.executeQuery();
@@ -147,9 +112,9 @@ public static Optional findUsername(long osuId) {
}
public static Long findBoundUid(String openId) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = "SELECT osu_uid FROM user_bindings WHERE open_id = ?";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, openId);
try (ResultSet resultSet = statement.executeQuery()) {
@@ -164,9 +129,9 @@ public static Long findBoundUid(String openId) {
}
public static OsuToken findOsuToken(String openId) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = "SELECT access_token, refresh_token, expires_in, refreshed_at FROM token_store WHERE open_id = ?";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, openId);
try (ResultSet resultSet = statement.executeQuery()) {
@@ -186,10 +151,10 @@ public static OsuToken findOsuToken(String openId) {
}
public static List getAllOsuTokens() {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
List tokens = new LinkedList<>();
String sql = "SELECT open_id, access_token, refresh_token, expires_in, refreshed_at FROM token_store";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
Statement statement = connection.createStatement();
final ResultSet rs = statement.executeQuery(sql)) {
while (rs.next()) {
@@ -212,10 +177,10 @@ public static List getAllOsuTokens() {
}
public static List findFollower(long uid) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
List followers = new ArrayList<>();
String sql = "SELECT self FROM user_follows WHERE followed = ?";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, uid);
try (ResultSet resultSet = statement.executeQuery()) {
@@ -231,13 +196,13 @@ public static List findFollower(long uid) {
}
public static void storeFollowed(long selfId, long followed) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = """
INSERT INTO user_follows(self, followed)
VALUES(?, ?)
ON CONFLICT(self, followed) DO NOTHING
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
+ try (Connection connection = SqliteDatabase.getConnection()) {
PreparedStatement statement = connection.prepareStatement(sql);
statement.setLong(1, selfId);
statement.setLong(2, followed);
@@ -248,13 +213,13 @@ ON CONFLICT(self, followed) DO NOTHING
}
public static void removeFollowed(long selfId, long followed) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = """
DELETE FROM user_follows
WHERE self = ?
AND followed = ?;
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
+ try (Connection connection = SqliteDatabase.getConnection()) {
PreparedStatement statement = connection.prepareStatement(sql);
statement.setLong(1, selfId);
statement.setLong(2, followed);
@@ -265,12 +230,12 @@ public static void removeFollowed(long selfId, long followed) {
}
public static int clearFollowed(long selfId) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = """
DELETE FROM user_follows
WHERE self = ?;
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
+ try (Connection connection = SqliteDatabase.getConnection()) {
PreparedStatement statement = connection.prepareStatement(sql);
statement.setLong(1, selfId);
return statement.executeUpdate();
@@ -281,13 +246,13 @@ public static int clearFollowed(long selfId) {
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public static boolean haveFollowed(long selfId, long followed) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = """
SELECT * FROM user_follows
WHERE self = ?
AND followed = ?;
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
+ try (Connection connection = SqliteDatabase.getConnection()) {
PreparedStatement statement = connection.prepareStatement(sql);
statement.setLong(1, selfId);
statement.setLong(2, followed);
@@ -301,10 +266,10 @@ public static boolean haveFollowed(long selfId, long followed) {
}
public static boolean unbind(String openId) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String bindingSql = "DELETE FROM user_bindings WHERE open_id = ?";
String tokenSql = "DELETE FROM token_store WHERE open_id = ?";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement bindingStatement = connection.prepareStatement(bindingSql);
PreparedStatement tokenStatement = connection.prepareStatement(tokenSql)) {
connection.setAutoCommit(false);
@@ -332,7 +297,7 @@ public static boolean unbind(String openId) {
}
public static void upsertGroupMember(String groupId, String openId) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
long now = System.currentTimeMillis();
String sql = """
INSERT INTO group_members(group_id, open_id, updated_at)
@@ -340,7 +305,7 @@ INSERT INTO group_members(group_id, open_id, updated_at)
ON CONFLICT(group_id, open_id) DO UPDATE SET
updated_at = excluded.updated_at
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, groupId);
statement.setString(2, openId);
@@ -352,9 +317,9 @@ ON CONFLICT(group_id, open_id) DO UPDATE SET
}
public static int clearGroupMember(String openId) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = "DELETE FROM group_members WHERE open_id = ?";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, openId);
return statement.executeUpdate();
@@ -363,8 +328,30 @@ public static int clearGroupMember(String openId) {
}
}
+ public static List findAllGroupMembers(String groupId) {
+ SqliteDatabase.ensureInitialized();
+ String sql = """
+ SELECT open_id
+ FROM group_members
+ WHERE group_id = ?;
+ """;
+ List groupMembers = new LinkedList<>();
+ try (Connection connection = SqliteDatabase.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, groupId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ while (resultSet.next()) {
+ groupMembers.add(resultSet.getString("open_id"));
+ }
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to query group members", e);
+ }
+ return groupMembers;
+ }
+
public static List findBoundUidsByGroup(String groupId) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = """
SELECT DISTINCT ub.osu_uid
FROM group_members gm
@@ -374,7 +361,7 @@ public static List findBoundUidsByGroup(String groupId) {
ORDER BY ub.osu_uid
""";
List uids = new ArrayList<>();
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, groupId);
try (ResultSet resultSet = statement.executeQuery()) {
@@ -389,9 +376,9 @@ public static List findBoundUidsByGroup(String groupId) {
}
public static boolean isGroupMember(String groupId, String openId) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = "SELECT 1 FROM group_members WHERE group_id = ? AND open_id = ?";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, groupId);
statement.setString(2, openId);
@@ -404,7 +391,7 @@ public static boolean isGroupMember(String groupId, String openId) {
}
public static Optional findGroupOpenIdByUid(String groupId, long osuUid) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = """
SELECT gm.open_id
FROM group_members gm
@@ -415,7 +402,7 @@ public static Optional findGroupOpenIdByUid(String groupId, long osuUid)
ORDER BY gm.updated_at DESC
LIMIT 1
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, groupId);
statement.setLong(2, osuUid);
@@ -430,7 +417,7 @@ public static Optional findGroupOpenIdByUid(String groupId, long osuUid)
}
public static void upsertDiscordBridge(DiscordBridgeMapping mapping) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = """
INSERT INTO discord_bridges(group_id, guild_id, channel_id, updated_at)
VALUES(?, ?, ?, ?)
@@ -439,7 +426,7 @@ ON CONFLICT(group_id) DO UPDATE SET
channel_id = excluded.channel_id,
updated_at = excluded.updated_at
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, mapping.groupId());
statement.setString(2, mapping.guildId());
@@ -452,9 +439,9 @@ ON CONFLICT(group_id) DO UPDATE SET
}
public static boolean removeDiscordBridge(String groupId) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = "DELETE FROM discord_bridges WHERE group_id = ?";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, groupId);
return statement.executeUpdate() > 0;
@@ -464,10 +451,10 @@ public static boolean removeDiscordBridge(String groupId) {
}
public static List findAllDiscordBridges() {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = "SELECT group_id, guild_id, channel_id FROM discord_bridges ORDER BY group_id";
List mappings = new ArrayList<>();
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
@@ -484,7 +471,7 @@ public static List findAllDiscordBridges() {
}
public static void saveSpecificScoreWatch(SpecificScoreWatchState state) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String upsertWatch = """
INSERT INTO specific_score_watches(group_id, updated_at)
VALUES(?, ?)
@@ -500,7 +487,7 @@ INSERT INTO specific_score_watch_users(group_id, user_id, last_score_id)
INSERT INTO specific_score_watch_beatmaps(group_id, beatmap_id)
VALUES(?, ?)
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
+ try (Connection connection = SqliteDatabase.getConnection()) {
connection.setAutoCommit(false);
try (PreparedStatement watchStatement = connection.prepareStatement(upsertWatch);
PreparedStatement deleteUsersStatement = connection.prepareStatement(deleteUsers);
@@ -546,8 +533,8 @@ INSERT INTO specific_score_watch_beatmaps(group_id, beatmap_id)
}
public static boolean removeSpecificScoreWatch(String groupId) {
- ensureInitialized();
- try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
+ SqliteDatabase.ensureInitialized();
+ try (Connection connection = SqliteDatabase.getConnection()) {
connection.setAutoCommit(false);
try (PreparedStatement users = connection.prepareStatement(
"DELETE FROM specific_score_watch_users WHERE group_id = ?");
@@ -573,13 +560,13 @@ public static boolean removeSpecificScoreWatch(String groupId) {
}
public static void updateSpecificScoreWatchCursor(String groupId, long userId, long scoreId) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = """
UPDATE specific_score_watch_users
SET last_score_id = ?
WHERE group_id = ? AND user_id = ?
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, scoreId);
statement.setString(2, groupId);
@@ -593,7 +580,7 @@ public static void updateSpecificScoreWatchCursor(String groupId, long userId, l
}
public static List findAllSpecificScoreWatches() {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
Map> usersByGroup = new LinkedHashMap<>();
Map> beatmapsByGroup = new LinkedHashMap<>();
Map> cursorsByGroup = new LinkedHashMap<>();
@@ -608,7 +595,7 @@ public static List findAllSpecificScoreWatches() {
FROM specific_score_watch_beatmaps
ORDER BY group_id, beatmap_id
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl)) {
+ try (Connection connection = SqliteDatabase.getConnection()) {
try (Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(groupsSql)) {
while (resultSet.next()) {
@@ -658,10 +645,10 @@ public static List findAllSpecificScoreWatches() {
}
public static int countBoundUser() {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = "SELECT COUNT(*) AS count FROM user_bindings";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
Statement statement = connection.createStatement()) {
try (ResultSet resultSet = statement.executeQuery(sql)) {
if (resultSet.next()) {
@@ -676,10 +663,10 @@ public static int countBoundUser() {
}
public static int countGroups() {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
String sql = "SELECT COUNT(DISTINCT group_id) AS count FROM group_members";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
Statement statement = connection.createStatement()) {
try (ResultSet resultSet = statement.executeQuery(sql)) {
if (resultSet.next()) {
@@ -694,9 +681,9 @@ public static int countGroups() {
}
public static String executeQueryOrEdit(String sql) {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
- try (Connection c = DriverManager.getConnection(jdbcUrl);
+ try (Connection c = SqliteDatabase.getConnection();
Statement stmt = c.createStatement()) {
// This method should be invoked only in debug context.
@@ -735,80 +722,8 @@ public static String executeQueryOrEdit(String sql) {
}
}
- public static QueryResult queryReadOnly(String sql, int maxRows) {
- ensureInitialized();
- if (sql == null || sql.isBlank()) {
- throw new IllegalArgumentException("SQL query must not be blank");
- }
- if (maxRows < 1 || maxRows > 200) {
- throw new IllegalArgumentException("maxRows must be between 1 and 200");
- }
-
- String statementSql = sql.strip();
- if (statementSql.endsWith(";")) {
- statementSql = statementSql.substring(0, statementSql.length() - 1).stripTrailing();
- }
- if (statementSql.contains(";")) {
- throw new IllegalArgumentException("Multiple SQL statements are not allowed");
- }
- String normalized = statementSql.toLowerCase(Locale.ROOT);
- String keyword = normalized.split("\\s+", 2)[0];
- if (!List.of("select", "with", "pragma", "explain").contains(keyword)) {
- throw new IllegalArgumentException("Only SELECT, WITH, PRAGMA and EXPLAIN queries are allowed");
- }
- if ("pragma".equals(keyword) && !isAllowedReadOnlyPragma(normalized)) {
- throw new IllegalArgumentException("This PRAGMA is not allowed in the read-only console");
- }
-
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
- Statement queryOnly = connection.createStatement();
- Statement statement = connection.createStatement()) {
- queryOnly.execute("PRAGMA query_only = ON");
- statement.setMaxRows(maxRows + 1);
- statement.setQueryTimeout(10);
- // SQL is intentionally accepted from the trusted local console. The
- // SQLite connection is query-only and the result size is bounded.
- //noinspection SqlSourceToSinkFlow
- try (ResultSet resultSet = statement.executeQuery(statementSql)) {
- ResultSetMetaData metadata = resultSet.getMetaData();
- int columnCount = metadata.getColumnCount();
- List columns = new ArrayList<>(columnCount);
- for (int index = 1; index <= columnCount; index++) {
- columns.add(metadata.getColumnLabel(index));
- }
-
- List> rows = new ArrayList<>();
- boolean truncated = false;
- while (resultSet.next()) {
- if (rows.size() == maxRows) {
- truncated = true;
- break;
- }
- List row = new ArrayList<>(columnCount);
- for (int index = 1; index <= columnCount; index++) {
- row.add(resultSet.getString(index));
- }
- rows.add(List.copyOf(row));
- }
- return new QueryResult(List.copyOf(columns), List.copyOf(rows), truncated);
- }
- } catch (SQLException e) {
- throw new RuntimeException("Failed to execute read-only SQL query", e);
- }
- }
-
- private static boolean isAllowedReadOnlyPragma(String normalizedSql) {
- String pragma = normalizedSql.substring("pragma".length()).stripLeading();
- int separator = pragma.indexOf('(');
- String name = (separator >= 0 ? pragma.substring(0, separator) : pragma).strip();
- return List.of(
- "table_info", "table_xinfo", "table_list", "index_list", "index_info",
- "index_xinfo", "foreign_key_list", "database_list", "compile_options"
- ).contains(name);
- }
-
public static List findAllUsers() {
- ensureInitialized();
+ SqliteDatabase.ensureInitialized();
List result = new LinkedList<>();
@@ -820,7 +735,7 @@ public static List findAllUsers() {
};
for (String sql : queries) {
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
+ try (Connection connection = SqliteDatabase.getConnection();
Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery(sql)) {
while (rs.next()) {
@@ -835,7 +750,7 @@ public static List findAllUsers() {
return result.stream().distinct().toList();
}
- private static void createTablesIfNeeded() throws SQLException {
+ public static void createTablesIfNeeded(Connection connection) throws SQLException {
String bindingSql = """
CREATE TABLE IF NOT EXISTS user_bindings (
open_id TEXT NOT NULL,
@@ -917,8 +832,7 @@ CREATE TABLE IF NOT EXISTS specific_score_watch_beatmaps (
PRIMARY KEY(group_id, beatmap_id)
)
""";
- try (Connection connection = DriverManager.getConnection(jdbcUrl);
- Statement statement = connection.createStatement()) {
+ try (Statement statement = connection.createStatement()) {
statement.execute(bindingSql);
statement.execute(groupMemberSql);
statement.execute(tokenStoreSql);
@@ -932,14 +846,5 @@ PRIMARY KEY(group_id, beatmap_id)
statement.execute(specificScoreWatchBeatmapSql);
}
}
-
- private static void ensureInitialized() {
- if (jdbcUrl == null) {
- throw new IllegalStateException("UserBindingStore is not initialized");
- }
- }
-
- public record QueryResult(List columns, List> rows, boolean truncated) {
- }
}
diff --git a/src/main/java/xyz/zcraft/seira/discord/DiscordBridgeService.java b/src/main/java/xyz/zcraft/seira/discord/DiscordBridgeService.java
index 8ca57626..1860d260 100644
--- a/src/main/java/xyz/zcraft/seira/discord/DiscordBridgeService.java
+++ b/src/main/java/xyz/zcraft/seira/discord/DiscordBridgeService.java
@@ -13,28 +13,16 @@
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
-import xyz.zcraft.seira.binding.UserDataStore;
+import org.jetbrains.annotations.NotNull;
import xyz.zcraft.seira.bot.MessageSender;
-import xyz.zcraft.seira.bot.data.Attachment;
-import xyz.zcraft.seira.bot.data.FileInfo;
-import xyz.zcraft.seira.bot.data.MDMessage;
-import xyz.zcraft.seira.bot.data.Message;
-import xyz.zcraft.seira.bot.data.PendingMessage;
+import xyz.zcraft.seira.bot.data.*;
import xyz.zcraft.seira.config.BridgeConfig;
import xyz.zcraft.seira.config.DiscordConfig;
import xyz.zcraft.seira.config.DiscordProxyConfig;
+import xyz.zcraft.seira.db.UserDataStore;
import java.net.URI;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Base64;
-import java.util.HashSet;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Queue;
-import java.util.Set;
+import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
@@ -71,6 +59,187 @@ public DiscordBridgeService(DiscordConfig discordConfig, BridgeConfig bridgeConf
UserDataStore.findAllDiscordBridges().forEach(mapping -> mappings.put(mapping.groupId(), mapping));
}
+ private static List collectDiscordAttachments(MessageReceivedEvent event) {
+ List attachments = new ArrayList<>();
+ Set seen = new HashSet<>();
+ for (String url : BridgeFormatter.findImageUrls(event.getMessage().getContentDisplay())) {
+ if (seen.add(url)) {
+ attachments.add(new BridgeAttachment(
+ filenameFromUrl(url), List.of(url), url, isGifUrl(url)
+ ));
+ }
+ }
+ event.getMessage().getAttachments().forEach(item -> {
+ if (seen.add(item.getUrl())) {
+ attachments.add(new BridgeAttachment(
+ item.getFileName(), distinct(item.getUrl(), item.getProxyUrl()), null,
+ "image/gif".equalsIgnoreCase(item.getContentType())
+ ));
+ }
+ });
+ for (MessageEmbed embed : event.getMessage().getEmbeds()) {
+ if (embed.getType() == EmbedType.GIFV && embed.getVideoInfo() != null) {
+ List candidates = gifCandidates(embed);
+ String key = candidates.isEmpty() ? null : candidates.getFirst();
+ if (key != null && seen.add(key)) {
+ attachments.add(new BridgeAttachment("discord-gif.gif", candidates, embed.getUrl(), true));
+ }
+ continue;
+ }
+ MessageEmbed.ImageInfo image = embed.getImage();
+ if (image != null && seen.add(image.getUrl())) {
+ attachments.add(new BridgeAttachment(
+ "embed-image", distinct(image.getUrl(), image.getProxyUrl()), embed.getUrl(), false
+ ));
+ }
+ }
+ event.getMessage().getStickers().forEach(sticker -> {
+ if (seen.add(sticker.getIconUrl())) {
+ attachments.add(new BridgeAttachment(
+ sticker.getName(), List.of(sticker.getIconUrl()), null,
+ sticker.getIconUrl().toLowerCase(Locale.ROOT).contains(".gif")
+ ));
+ }
+ });
+ event.getMessage().getMentions().getCustomEmojis().forEach(emoji -> {
+ if (seen.add(emoji.getImageUrl())) {
+ attachments.add(new BridgeAttachment(
+ "emoji-" + emoji.getName(), List.of(emoji.getImageUrl()), null, emoji.isAnimated()
+ ));
+ }
+ });
+ return List.copyOf(attachments);
+ }
+
+ private static String filenameFromUrl(String value) {
+ try {
+ String path = URI.create(value).getPath();
+ if (path != null && path.lastIndexOf('/') < path.length() - 1) {
+ return path.substring(path.lastIndexOf('/') + 1);
+ }
+ } catch (IllegalArgumentException ignored) {
+ // The downloader will report malformed URLs if one reaches it.
+ }
+ return "discord-image";
+ }
+
+ private static boolean isGifUrl(String value) {
+ try {
+ String path = URI.create(value).getPath();
+ return path != null && path.toLowerCase(Locale.ROOT).endsWith(".gif");
+ } catch (IllegalArgumentException ignored) {
+ return false;
+ }
+ }
+
+ static List gifCandidates(MessageEmbed embed) {
+ LinkedHashSet candidates = new LinkedHashSet<>();
+ MessageEmbed.VideoInfo video = embed.getVideoInfo();
+ if (video != null) {
+ addDerivedGif(candidates, video.getUrl());
+ addDerivedGif(candidates, video.getProxyUrl());
+ add(candidates, video.getUrl());
+ add(candidates, video.getProxyUrl());
+ }
+ MessageEmbed.ImageInfo image = embed.getImage();
+ if (image != null) {
+ add(candidates, image.getUrl());
+ add(candidates, image.getProxyUrl());
+ }
+ return List.copyOf(candidates);
+ }
+
+ static String deriveTenorGifUrl(String mediaUrl) {
+ if (mediaUrl == null || mediaUrl.isBlank()) return null;
+ try {
+ URI uri = URI.create(mediaUrl);
+ String host = uri.getHost();
+ String path = uri.getRawPath();
+ if (host == null || path == null || !host.toLowerCase(Locale.ROOT)
+ .matches("media[0-9]*\\.tenor\\.(com|co)")) {
+ return replaceVideoExtension(mediaUrl);
+ }
+ String[] segments = path.split("/");
+ for (int index = 0; index < segments.length; index++) {
+ if (segments[index].endsWith("AAAPo")) {
+ segments[index] = segments[index].substring(0, segments[index].length() - 5) + "AAAAC";
+ }
+ }
+ String rebuilt = String.join("/", segments);
+ int dot = rebuilt.lastIndexOf('.');
+ if (dot > rebuilt.lastIndexOf('/')) rebuilt = rebuilt.substring(0, dot) + ".gif";
+ String query = uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery();
+ return uri.getScheme() + "://" + uri.getRawAuthority() + rebuilt + query;
+ } catch (IllegalArgumentException ignored) {
+ return null;
+ }
+ }
+
+ private static String replaceVideoExtension(String value) {
+ int queryIndex = value.indexOf('?');
+ String path = queryIndex < 0 ? value : value.substring(0, queryIndex);
+ String query = queryIndex < 0 ? "" : value.substring(queryIndex);
+ String lower = path.toLowerCase(Locale.ROOT);
+ if (!lower.endsWith(".mp4") && !lower.endsWith(".webm")) return null;
+ return path.substring(0, path.lastIndexOf('.')) + ".gif" + query;
+ }
+
+ private static void addDerivedGif(Set target, String value) {
+ add(target, deriveTenorGifUrl(value));
+ }
+
+ private static void add(Set target, String value) {
+ if (value != null && !value.isBlank()) target.add(value);
+ }
+
+ private static List distinct(String... values) {
+ LinkedHashSet result = new LinkedHashSet<>();
+ for (String value : values) add(result, value);
+ return List.copyOf(result);
+ }
+
+ private static int qqFileType(String contentType) {
+ if (contentType == null) return PendingMessage.FILE_TYPE_FILE;
+ if (contentType.startsWith("image/")) return PendingMessage.FILE_TYPE_IMAGE;
+ if (contentType.startsWith("video/")) return PendingMessage.FILE_TYPE_VIDEO;
+ if (contentType.startsWith("audio/")) return PendingMessage.FILE_TYPE_VOICE;
+ return PendingMessage.FILE_TYPE_FILE;
+ }
+
+ private static String commandMediaContentType(int fileType, byte[] data) {
+ return MediaFormat.detectContentType(data).orElse(switch (fileType) {
+ case PendingMessage.FILE_TYPE_IMAGE -> "image/png";
+ case PendingMessage.FILE_TYPE_VIDEO -> "video/mp4";
+ case PendingMessage.FILE_TYPE_VOICE -> "audio/ogg";
+ default -> "application/octet-stream";
+ });
+ }
+
+ private static void replaceBody(StringBuilder target, String value) {
+ target.setLength(0);
+ target.append(value);
+ }
+
+ private static boolean isDcsCommand(String text) {
+ if (text == null) return false;
+ String normalized = text.stripLeading().toLowerCase(Locale.ROOT);
+ return normalized.equals("/dcs") || normalized.startsWith("/dcs ");
+ }
+
+ private static String displayFilename(String value) {
+ return value == null || value.isBlank() ? "media" : value;
+ }
+
+ private static String firstUrl(List values) {
+ return values == null || values.isEmpty() ? "" : values.getFirst();
+ }
+
+ private static void appendLine(StringBuilder target, String value) {
+ if (value == null || value.isBlank()) return;
+ if (!target.isEmpty() && target.charAt(target.length() - 1) != '\n') target.append('\n');
+ target.append(value);
+ }
+
public void start() {
if (closed.get()) throw new IllegalStateException("Discord bridge has already been closed");
if (!started.compareAndSet(false, true)) return;
@@ -131,7 +300,9 @@ public void acceptQqMessage(QqIncomingMessage message) {
.execute(() -> relayQqToDiscord(mapping, message));
}
- /** Relays the portable part of a QQ command result to the mapped Discord channel. */
+ /**
+ * Relays the portable part of a QQ command result to the mapped Discord channel.
+ */
public void acceptQqCommandReply(String groupId, PendingMessage message) {
DiscordBridgeMapping mapping = mappings.get(groupId);
if (mapping == null || message == null) return;
@@ -291,7 +462,7 @@ private void sendPreparedQqMedia(String groupId, PreparedQqMedia item) {
Message outbound = new Message();
outbound.setMsgType(PendingMessage.MSG_TYPE_MEDIA);
outbound.setMedia(item.uploaded());
- if (!qqSender.sendGroupMessage(groupId, outbound)) {
+ if (qqSender.sendGroupMessage(groupId, outbound) == null) {
qqSender.sendGroupText(groupId, "[媒体发送到 QQ 失败: " + item.media().filename() + "]");
}
}
@@ -365,219 +536,6 @@ public void close() {
workers.shutdownNow();
}
- private final class DiscordListener extends ListenerAdapter {
- @Override
- public void onReady(ReadyEvent event) {
- connectionError = null;
- LOG.info("Discord bridge connected as {}", event.getJDA().getSelfUser().getName());
- }
-
- @Override
- public void onMessageReceived(MessageReceivedEvent event) {
- if (!event.isFromGuild() || event.getAuthor().isBot() || event.isWebhookMessage()) return;
- String guildId = event.getGuild().getId();
- String channelId = event.getChannel().getId();
- List groups = groupsFor(guildId, channelId);
- if (groups.isEmpty()) return;
-
- String authorName = event.getMember() == null
- ? event.getAuthor().getEffectiveName()
- : event.getMember().getEffectiveName();
- DiscordIncomingMessage incoming = new DiscordIncomingMessage(
- guildId,
- channelId,
- event.getAuthor().getId(),
- authorName,
- event.getMessage().getContentDisplay(),
- collectDiscordAttachments(event)
- );
- for (String groupId : groups) {
- queue("discord:" + groupId).execute(() -> relayDiscordToQq(groupId, incoming));
- }
- }
- }
-
- private static List collectDiscordAttachments(MessageReceivedEvent event) {
- List attachments = new ArrayList<>();
- Set seen = new HashSet<>();
- for (String url : BridgeFormatter.findImageUrls(event.getMessage().getContentDisplay())) {
- if (seen.add(url)) {
- attachments.add(new BridgeAttachment(
- filenameFromUrl(url), List.of(url), url, isGifUrl(url)
- ));
- }
- }
- event.getMessage().getAttachments().forEach(item -> {
- if (seen.add(item.getUrl())) {
- attachments.add(new BridgeAttachment(
- item.getFileName(), distinct(item.getUrl(), item.getProxyUrl()), null,
- "image/gif".equalsIgnoreCase(item.getContentType())
- ));
- }
- });
- for (MessageEmbed embed : event.getMessage().getEmbeds()) {
- if (embed.getType() == EmbedType.GIFV && embed.getVideoInfo() != null) {
- List candidates = gifCandidates(embed);
- String key = candidates.isEmpty() ? null : candidates.getFirst();
- if (key != null && seen.add(key)) {
- attachments.add(new BridgeAttachment("discord-gif.gif", candidates, embed.getUrl(), true));
- }
- continue;
- }
- MessageEmbed.ImageInfo image = embed.getImage();
- if (image != null && seen.add(image.getUrl())) {
- attachments.add(new BridgeAttachment(
- "embed-image", distinct(image.getUrl(), image.getProxyUrl()), embed.getUrl(), false
- ));
- }
- }
- event.getMessage().getStickers().forEach(sticker -> {
- if (seen.add(sticker.getIconUrl())) {
- attachments.add(new BridgeAttachment(
- sticker.getName(), List.of(sticker.getIconUrl()), null,
- sticker.getIconUrl().toLowerCase(Locale.ROOT).contains(".gif")
- ));
- }
- });
- event.getMessage().getMentions().getCustomEmojis().forEach(emoji -> {
- if (seen.add(emoji.getImageUrl())) {
- attachments.add(new BridgeAttachment(
- "emoji-" + emoji.getName(), List.of(emoji.getImageUrl()), null, emoji.isAnimated()
- ));
- }
- });
- return List.copyOf(attachments);
- }
-
- private static String filenameFromUrl(String value) {
- try {
- String path = URI.create(value).getPath();
- if (path != null && path.lastIndexOf('/') < path.length() - 1) {
- return path.substring(path.lastIndexOf('/') + 1);
- }
- } catch (IllegalArgumentException ignored) {
- // The downloader will report malformed URLs if one reaches it.
- }
- return "discord-image";
- }
-
- private static boolean isGifUrl(String value) {
- try {
- String path = URI.create(value).getPath();
- return path != null && path.toLowerCase(Locale.ROOT).endsWith(".gif");
- } catch (IllegalArgumentException ignored) {
- return false;
- }
- }
-
- static List gifCandidates(MessageEmbed embed) {
- LinkedHashSet candidates = new LinkedHashSet<>();
- MessageEmbed.VideoInfo video = embed.getVideoInfo();
- if (video != null) {
- addDerivedGif(candidates, video.getUrl());
- addDerivedGif(candidates, video.getProxyUrl());
- add(candidates, video.getUrl());
- add(candidates, video.getProxyUrl());
- }
- MessageEmbed.ImageInfo image = embed.getImage();
- if (image != null) {
- add(candidates, image.getUrl());
- add(candidates, image.getProxyUrl());
- }
- return List.copyOf(candidates);
- }
-
- static String deriveTenorGifUrl(String mediaUrl) {
- if (mediaUrl == null || mediaUrl.isBlank()) return null;
- try {
- URI uri = URI.create(mediaUrl);
- String host = uri.getHost();
- String path = uri.getRawPath();
- if (host == null || path == null || !host.toLowerCase(Locale.ROOT)
- .matches("media[0-9]*\\.tenor\\.(com|co)")) {
- return replaceVideoExtension(mediaUrl);
- }
- String[] segments = path.split("/");
- for (int index = 0; index < segments.length; index++) {
- if (segments[index].endsWith("AAAPo")) {
- segments[index] = segments[index].substring(0, segments[index].length() - 5) + "AAAAC";
- }
- }
- String rebuilt = String.join("/", segments);
- int dot = rebuilt.lastIndexOf('.');
- if (dot > rebuilt.lastIndexOf('/')) rebuilt = rebuilt.substring(0, dot) + ".gif";
- String query = uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery();
- return uri.getScheme() + "://" + uri.getRawAuthority() + rebuilt + query;
- } catch (IllegalArgumentException ignored) {
- return null;
- }
- }
-
- private static String replaceVideoExtension(String value) {
- int queryIndex = value.indexOf('?');
- String path = queryIndex < 0 ? value : value.substring(0, queryIndex);
- String query = queryIndex < 0 ? "" : value.substring(queryIndex);
- String lower = path.toLowerCase(Locale.ROOT);
- if (!lower.endsWith(".mp4") && !lower.endsWith(".webm")) return null;
- return path.substring(0, path.lastIndexOf('.')) + ".gif" + query;
- }
-
- private static void addDerivedGif(Set target, String value) {
- add(target, deriveTenorGifUrl(value));
- }
-
- private static void add(Set target, String value) {
- if (value != null && !value.isBlank()) target.add(value);
- }
-
- private static List distinct(String... values) {
- LinkedHashSet result = new LinkedHashSet<>();
- for (String value : values) add(result, value);
- return List.copyOf(result);
- }
-
- private static int qqFileType(String contentType) {
- if (contentType == null) return PendingMessage.FILE_TYPE_FILE;
- if (contentType.startsWith("image/")) return PendingMessage.FILE_TYPE_IMAGE;
- if (contentType.startsWith("video/")) return PendingMessage.FILE_TYPE_VIDEO;
- if (contentType.startsWith("audio/")) return PendingMessage.FILE_TYPE_VOICE;
- return PendingMessage.FILE_TYPE_FILE;
- }
-
- private static String commandMediaContentType(int fileType, byte[] data) {
- return MediaFormat.detectContentType(data).orElse(switch (fileType) {
- case PendingMessage.FILE_TYPE_IMAGE -> "image/png";
- case PendingMessage.FILE_TYPE_VIDEO -> "video/mp4";
- case PendingMessage.FILE_TYPE_VOICE -> "audio/ogg";
- default -> "application/octet-stream";
- });
- }
-
- private static void replaceBody(StringBuilder target, String value) {
- target.setLength(0);
- target.append(value);
- }
-
- private static boolean isDcsCommand(String text) {
- if (text == null) return false;
- String normalized = text.stripLeading().toLowerCase(Locale.ROOT);
- return normalized.equals("/dcs") || normalized.startsWith("/dcs ");
- }
-
- private static String displayFilename(String value) {
- return value == null || value.isBlank() ? "media" : value;
- }
-
- private static String firstUrl(List values) {
- return values == null || values.isEmpty() ? "" : values.getFirst();
- }
-
- private static void appendLine(StringBuilder target, String value) {
- if (value == null || value.isBlank()) return;
- if (!target.isEmpty() && target.charAt(target.length() - 1) != '\n') target.append('\n');
- target.append(value);
- }
-
public record BindResult(boolean success, String message, String guildName, String channelName) {
static BindResult success(String guildName, String channelName) {
return new BindResult(true, "", guildName, channelName);
@@ -601,7 +559,7 @@ private SerialExecutor(Executor executor) {
}
@Override
- public synchronized void execute(Runnable command) {
+ public synchronized void execute(@NotNull Runnable command) {
tasks.offer(() -> {
try {
command.run();
@@ -616,4 +574,36 @@ private synchronized void scheduleNext() {
if ((active = tasks.poll()) != null) executor.execute(active);
}
}
+
+ private final class DiscordListener extends ListenerAdapter {
+ @Override
+ public void onReady(ReadyEvent event) {
+ connectionError = null;
+ LOG.info("Discord bridge connected as {}", event.getJDA().getSelfUser().getName());
+ }
+
+ @Override
+ public void onMessageReceived(MessageReceivedEvent event) {
+ if (!event.isFromGuild() || event.getAuthor().isBot() || event.isWebhookMessage()) return;
+ String guildId = event.getGuild().getId();
+ String channelId = event.getChannel().getId();
+ List groups = groupsFor(guildId, channelId);
+ if (groups.isEmpty()) return;
+
+ String authorName = event.getMember() == null
+ ? event.getAuthor().getEffectiveName()
+ : event.getMember().getEffectiveName();
+ DiscordIncomingMessage incoming = new DiscordIncomingMessage(
+ guildId,
+ channelId,
+ event.getAuthor().getId(),
+ authorName,
+ event.getMessage().getContentDisplay(),
+ collectDiscordAttachments(event)
+ );
+ for (String groupId : groups) {
+ queue("discord:" + groupId).execute(() -> relayDiscordToQq(groupId, incoming));
+ }
+ }
+ }
}
diff --git a/src/main/java/xyz/zcraft/seira/discord/MediaDownloader.java b/src/main/java/xyz/zcraft/seira/discord/MediaDownloader.java
index ca3da244..a9f5b58a 100644
--- a/src/main/java/xyz/zcraft/seira/discord/MediaDownloader.java
+++ b/src/main/java/xyz/zcraft/seira/discord/MediaDownloader.java
@@ -5,11 +5,7 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
-import java.net.Authenticator;
-import java.net.InetSocketAddress;
-import java.net.PasswordAuthentication;
-import java.net.ProxySelector;
-import java.net.URI;
+import java.net.*;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@@ -38,6 +34,20 @@ protected PasswordAuthentication getPasswordAuthentication() {
this.client = builder.build();
}
+ private static String safeFilename(String suggested, URI uri, String contentType) {
+ String filename = suggested;
+ if (filename == null || filename.isBlank() || filename.startsWith("http://") || filename.startsWith("https://")) {
+ String path = uri.getPath();
+ filename = path == null ? "" : path.substring(path.lastIndexOf('/') + 1);
+ }
+ filename = filename.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_");
+ if (filename.isBlank() || filename.length() > 150) {
+ filename = "media" + MediaFormat.extensionFor(contentType);
+ }
+ if (!filename.contains(".")) filename += MediaFormat.extensionFor(contentType);
+ return filename;
+ }
+
DownloadedMedia download(String url, String suggestedFilename) throws IOException, InterruptedException {
URI uri;
try {
@@ -84,18 +94,4 @@ DownloadedMedia download(String url, String suggestedFilename) throws IOExceptio
String filename = safeFilename(suggestedFilename, uri, contentType);
return new DownloadedMedia(data, MediaFormat.normalizeFilename(filename, contentType), contentType, url);
}
-
- private static String safeFilename(String suggested, URI uri, String contentType) {
- String filename = suggested;
- if (filename == null || filename.isBlank() || filename.startsWith("http://") || filename.startsWith("https://")) {
- String path = uri.getPath();
- filename = path == null ? "" : path.substring(path.lastIndexOf('/') + 1);
- }
- filename = filename == null ? "" : filename.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_");
- if (filename.isBlank() || filename.length() > 150) {
- filename = "media" + MediaFormat.extensionFor(contentType);
- }
- if (!filename.contains(".")) filename += MediaFormat.extensionFor(contentType);
- return filename;
- }
}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/FinishedRound.java b/src/main/java/xyz/zcraft/seira/rankguess/FinishedRound.java
deleted file mode 100644
index 19c3d969..00000000
--- a/src/main/java/xyz/zcraft/seira/rankguess/FinishedRound.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package xyz.zcraft.seira.rankguess;
-
-import java.util.List;
-
-public record FinishedRound(RankGuessGameService.Round round, List standings) {
-}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/Guess.java b/src/main/java/xyz/zcraft/seira/rankguess/Guess.java
deleted file mode 100644
index 8522a9e5..00000000
--- a/src/main/java/xyz/zcraft/seira/rankguess/Guess.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package xyz.zcraft.seira.rankguess;
-
-import java.util.List;
-
-public record Guess(long rank, long sequence, List multipliers) {
-}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/HintUtil.java b/src/main/java/xyz/zcraft/seira/rankguess/HintUtil.java
new file mode 100644
index 00000000..e71a1048
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/rankguess/HintUtil.java
@@ -0,0 +1,125 @@
+package xyz.zcraft.seira.rankguess;
+
+import java.util.*;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.random.RandomGenerator;
+
+public class HintUtil {
+ // TODO This is so messed up. Need to rewrite in the future.
+ public static LinkedList prepareHints(List source, int maxCount) {
+ var random = ThreadLocalRandom.current();
+
+ List remaining = new ArrayList<>(source);
+ limitCategory(remaining, RankGuessGame.Hint.HintCategory.ACTIVITY, 2, random);
+
+ LinkedList result = new LinkedList<>();
+ while (!remaining.isEmpty() && result.size() < maxCount) {
+ List candidates = candidatesForNextHint(remaining, result);
+ RankGuessGame.Hint selected = selectWeightedByStrength(
+ candidates,
+ result.size(),
+ Math.min(remaining.size() + result.size(), maxCount),
+ random
+ );
+ result.add(selected);
+ remaining.remove(selected);
+ }
+ return result;
+ }
+
+ private static void limitCategory(
+ List hints,
+ RankGuessGame.Hint.HintCategory category,
+ int maximum,
+ RandomGenerator random
+ ) {
+ List categoryHints = hints.stream()
+ .filter(hint -> hint.category() == category)
+ .toList();
+ if (categoryHints.size() <= maximum) {
+ return;
+ }
+
+ List shuffled = new ArrayList<>(categoryHints);
+ Collections.shuffle(shuffled, new Random(random.nextLong()));
+ Set retained = new HashSet<>(shuffled.subList(0, maximum));
+ hints.removeIf(hint -> hint.category() == category && !retained.contains(hint));
+ }
+
+ private static List candidatesForNextHint(
+ List remaining,
+ List selected
+ ) {
+ List candidates = remaining;
+ if (selected.isEmpty()) {
+ List nonRevealing = remaining.stream()
+ .filter(hint -> hint.strength() != RankGuessGame.Hint.HintStrength.VERY_STRONG)
+ .filter(hint -> hint.strength() != RankGuessGame.Hint.HintStrength.REVEALING)
+ .toList();
+ if (!nonRevealing.isEmpty()) {
+ candidates = nonRevealing;
+ }
+ }
+
+ if (!selected.isEmpty()) {
+ RankGuessGame.Hint.HintCategory lastCategory = selected.getLast().category();
+ List differentCategory = candidates.stream()
+ .filter(hint -> hint.category() != lastCategory)
+ .toList();
+ if (!differentCategory.isEmpty()) {
+ candidates = differentCategory;
+ }
+ }
+ return candidates;
+ }
+
+ private static RankGuessGame.Hint selectWeightedByStrength(
+ List candidates,
+ int position,
+ int total,
+ RandomGenerator random
+ ) {
+ double progress = total <= 1 ? 1.0 : position / (double) (total - 1);
+ EnumMap weights = strengthWeights(progress);
+
+ EnumMap> byStrength =
+ new EnumMap<>(RankGuessGame.Hint.HintStrength.class);
+ for (RankGuessGame.Hint hint : candidates) {
+ byStrength.computeIfAbsent(hint.strength(), ignored -> new ArrayList<>()).add(hint);
+ }
+
+ double totalWeight = byStrength.keySet().stream().mapToDouble(weights::get).sum();
+ if (totalWeight <= 0) {
+ return candidates.get(random.nextInt(candidates.size()));
+ }
+
+ double roll = random.nextDouble(totalWeight);
+ for (Map.Entry> entry : byStrength.entrySet()) {
+ roll -= weights.get(entry.getKey());
+ if (roll < 0) {
+ List hints = entry.getValue();
+ return hints.get(random.nextInt(hints.size()));
+ }
+ }
+ return candidates.getLast();
+ }
+
+ private static EnumMap strengthWeights(double progress) {
+ double[] first = {50, 35, 15, 0, 0};
+ double[] middle = {10, 30, 45, 15, 0};
+ double[] late = {0, 10, 40, 50, 0};
+
+ double phase = progress <= 0.5 ? progress * 2 : (progress - 0.5) * 2;
+
+ double[] from = progress <= 0.5 ? first : middle;
+ double[] to = progress <= 0.5 ? middle : late;
+
+ EnumMap weights =
+ new EnumMap<>(RankGuessGame.Hint.HintStrength.class);
+ RankGuessGame.Hint.HintStrength[] strengths = RankGuessGame.Hint.HintStrength.values();
+ for (int i = 0; i < strengths.length; i++) {
+ weights.put(strengths[i], from[i] + (to[i] - from[i]) * phase);
+ }
+ return weights;
+ }
+}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java
index a46521c5..073fc52a 100644
--- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java
+++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java
@@ -1,6 +1,10 @@
package xyz.zcraft.seira.rankguess;
import lombok.Getter;
+import xyz.zcraft.seira.bot.data.MessageReference;
+import xyz.zcraft.seira.rankguess.data.Guess;
+import xyz.zcraft.seira.rankguess.data.Round;
+import xyz.zcraft.seira.rankguess.data.ScoreMultiplier;
import java.time.Instant;
import java.util.*;
@@ -8,20 +12,26 @@
@Getter
public final class RankGuessGame {
+ public static final int COPY_PUNISHMENT_THRESHOLD = 8;
public final UUID token;
public final String starterUserId;
+ public final boolean fromGroup;
public final Map guesses = new LinkedHashMap<>();
public final AtomicInteger guessCount = new AtomicInteger(0);
private final List revealedHints = new ArrayList<>();
- public RankGuessGameService.Round round;
+ public boolean copyPunishmentReduced = false;
+ public Round round;
public Instant guessingStartedAt;
public long nextSequence;
@Getter
- private boolean ended = false;
+ public MessageReference videoRef = null;
+ @Getter
+ private volatile boolean ended = false;
- RankGuessGame(UUID token, String starterUserId) {
+ RankGuessGame(UUID token, String starterUserId, boolean fromGroup) {
this.token = token;
this.starterUserId = starterUserId;
+ this.fromGroup = fromGroup;
}
public void markEnded() {
@@ -43,24 +53,24 @@ public synchronized List getRevealedHints() {
return List.copyOf(revealedHints);
}
- public double getMultiplierDelta(RankGuessGameService.ScoreMultiplier multiplier) {
- if (multiplier instanceof RankGuessGameService.ScoreMultiplier.FirstGuessMultiplier) {
+ public double getMultiplierDelta(ScoreMultiplier multiplier) {
+ if (multiplier instanceof ScoreMultiplier.FirstGuessMultiplier) {
return 0.05;
- } else if (multiplier instanceof RankGuessGameService.ScoreMultiplier.OrderMultiplier orderMultiplier) {
+ } else if (multiplier instanceof ScoreMultiplier.OrderMultiplier orderMultiplier) {
return Math.max(-0.10, 0.00 - (orderMultiplier.getOrder() - 2) * 0.01);
- } else if (multiplier instanceof RankGuessGameService.ScoreMultiplier.CopyPunishmentMultiplier) {
- if (guessCount.get() >= 10) {
+ } else if (multiplier instanceof ScoreMultiplier.CopyPunishmentMultiplier) {
+ if (guesses.size() >= COPY_PUNISHMENT_THRESHOLD) {
return -0.025;
} else {
return -0.05;
}
- } else if (multiplier instanceof RankGuessGameService.ScoreMultiplier.HintMultiplier hintMultiplier) {
+ } else if (multiplier instanceof ScoreMultiplier.HintMultiplier hintMultiplier) {
return -hintMultiplier.getHint().strength().penalty();
}
return 0;
}
- public String getMultipliersString(List multipliers) {
+ public String getMultipliersString(List multipliers) {
if (multipliers == null || multipliers.isEmpty()) {
return "倍率: `x1.00`\n";
}
@@ -75,13 +85,13 @@ public String getMultipliersString(List mu
.append(String.format(Locale.US, "%.2f", 1 + sum))
.append("`\n");
- for (RankGuessGameService.ScoreMultiplier multiplier : multipliers) {
+ for (ScoreMultiplier multiplier : multipliers) {
builder.append("> ")
.append(multiplier.getReason())
.append(": ")
.append(String.format(
Locale.US,
- "%+.0f%%",
+ "%+.2f%%",
this.getMultiplierDelta(multiplier) * 100
))
.append("\n");
@@ -90,6 +100,13 @@ public String getMultipliersString(List mu
return builder.toString();
}
+ public double getNextMaxPoints() {
+ final double hintsMultiplier = -revealedHints.stream().mapToDouble(h -> h.strength().penalty()).sum();
+ final double orderMultiplier = Math.max(-0.10, 0.00 - (guessCount.get() - 2) * 0.01);
+
+ return 1000 * (1 + hintsMultiplier + orderMultiplier);
+ }
+
public record Hint(String content, String name, HintCategory category, HintStrength strength) {
public enum HintCategory {
RANK,
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java
index 93396a98..c4de7c94 100644
--- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java
+++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java
@@ -1,46 +1,100 @@
package xyz.zcraft.seira.rankguess;
-import lombok.Getter;
+import com.google.gson.JsonObject;
import org.jetbrains.annotations.NotNull;
-import xyz.zcraft.osu.model.Score;
-import xyz.zcraft.osu.model.UserExtended;
-import xyz.zcraft.seira.api.data.RandomScore;
+import xyz.zcraft.seira.bot.data.MessageReference;
+import xyz.zcraft.seira.db.RankGuessRecordStore;
+import xyz.zcraft.seira.rankguess.data.*;
import java.time.Clock;
import java.time.Duration;
import java.util.*;
-import java.util.concurrent.ThreadLocalRandom;
-import java.util.random.RandomGenerator;
+import java.util.function.Consumer;
+
+import static xyz.zcraft.seira.rankguess.RankGuessGame.COPY_PUNISHMENT_THRESHOLD;
public final class RankGuessGameService {
+ public static final int SCORING_VERSION = 1;
+ public static final int MIN_GAMES_TO_RANK = 10;
private static final Duration END_PROTECTION_DURATION = Duration.ofMinutes(3);
-
+ private static final int MIN_PARTICIPANT_TO_RECORD = 3;
private final Map games = new HashMap<>();
+ private final RankGuessWeights weights;
private final Clock clock;
+ private final Consumer recordWriter;
public RankGuessGameService() {
this(Clock.systemUTC());
}
RankGuessGameService(Clock clock) {
+ this(clock, new RankGuessWeights());
+ }
+
+ RankGuessGameService(Clock clock, RankGuessWeights weights) {
+ this(clock, weights, RankGuessRecordStore::save);
+ }
+
+ RankGuessGameService(Clock clock, RankGuessWeights weights, Consumer recordWriter) {
this.clock = clock;
+ this.weights = weights;
+ this.recordWriter = Objects.requireNonNull(recordWriter);
}
static double logarithmicError(long guess, long actualRank) {
return Math.abs(Math.log10(guess) - Math.log10(actualRank));
}
- public synchronized Reservation reserve(String groupId, String starterUserId) {
+ @NotNull
+ public static String getGlobalRankRange(long l) {
+ String range;
+
+ if (l <= 10_000) {
+ range = "#1 - #10k";
+ } else if (l <= 50_000) {
+ range = "#10k - #50k";
+ } else if (l <= 200_000) {
+ range = "#50k - #200k";
+ } else if (l <= 500_000) {
+ range = "#200k - #500k";
+ } else {
+ range = ">#500k";
+ }
+
+ return range;
+ }
+
+ public void saveWeights() {
+ weights.saveToFile();
+ }
+
+ public JsonObject generateWeights(String groupId) {
+ return weights.generateWeights(groupId);
+ }
+
+ public String getRevealedHintsString(String groupId) {
+ final RankGuessGame rankGuessGame = games.get(groupId);
+ if (rankGuessGame == null) {
+ return null;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (RankGuessGame.Hint revealedHint : rankGuessGame.getRevealedHints()) {
+ sb.append(revealedHint.content()).append("\n");
+ }
+ return sb.toString().trim();
+ }
+
+ public synchronized Reservation reserve(String groupId, String starterUserId, boolean fromGroup) {
if (games.containsKey(groupId)) {
return null;
}
Reservation reservation = new Reservation(groupId, UUID.randomUUID());
- games.put(groupId, new RankGuessGame(reservation.token(), starterUserId));
+ games.put(groupId, new RankGuessGame(reservation.token(), starterUserId, fromGroup));
return reservation;
}
- public synchronized RankGuessGame activate(Reservation reservation, Round round) {
+ public synchronized RankGuessGame activate(Reservation reservation, Round round, MessageReference videoRef) {
if (round == null) {
throw new IllegalArgumentException("Round must not be null");
}
@@ -50,6 +104,7 @@ public synchronized RankGuessGame activate(Reservation reservation, Round round)
}
game.round = round;
+ game.videoRef = videoRef;
game.guessingStartedAt = clock.instant();
return game;
@@ -79,6 +134,16 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon
return GuessResponse.ofStatus(GuessStatus.STARTING);
}
+ final Guess previousGuess = game.guesses.get(senderUserId);
+
+ if (previousGuess != null) {
+ final long l = (System.currentTimeMillis() - previousGuess.timestamp()) / 1000;
+
+ if (l < 20) {
+ return GuessResponse.ofStatus(GuessStatus.TOO_SOON);
+ }
+ }
+
final int guessNumber = game.guessCount.incrementAndGet();
LinkedList multipliers = new LinkedList<>();
@@ -108,171 +173,52 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon
if (closestGuess != -1) {
double absoluteDifference = Math.abs(closestGuess - guess);
- double relativeDifference = absoluteDifference
- / (double) Math.max(closestGuess, guess);
+ double relativeDifference = absoluteDifference / (double) Math.max(closestGuess, guess);
boolean copied =
(absoluteDifference <= 100 && relativeDifference <= 0.01)
- || Math.abs(
- Math.log10(closestGuess)
- - Math.log10(guess)
- ) < 0.005;
-
- final int i = game.guessCount.get();
-
- if (i >= 10) {
- message = "提示:由于本次游戏参与人数较多,所有猜测的抄袭惩罚已降至 `-2.5%` ~";
- }
+ || Math.abs(Math.log10(closestGuess) - Math.log10(guess)) < 0.005;
if (copied) {
multipliers.add(new ScoreMultiplier.CopyPunishmentMultiplier());
}
}
- Guess previousGuess = game.guesses.put(senderUserId, new Guess(guess, game.nextSequence++, multipliers));
+ game.guesses.put(senderUserId, Guess.of(guess, game.nextSequence++, multipliers));
+
+ final int i = game.guesses.size();
+
+ if (i >= COPY_PUNISHMENT_THRESHOLD && !game.copyPunishmentReduced) {
+ game.copyPunishmentReduced = true;
+ message = "提示:由于本次游戏参与人数较多,所有猜测的抄袭惩罚已降至 `-2.5%` ~";
+ }
final GuessResult guessResult = new GuessResult(
previousGuess == null ? GuessStatus.RECORDED : GuessStatus.UPDATED,
guess,
multipliers,
- game.getMultipliersString(multipliers)
+ game.getMultipliersString(multipliers),
+ game.guesses.size()
);
- return GuessResponse.of(guessResult, message);
- }
-
- public static LinkedList prepareHints(List source, int maxCount) {
- var random = ThreadLocalRandom.current();
-
- List remaining = new ArrayList<>(source);
- limitCategory(remaining, RankGuessGame.Hint.HintCategory.ACTIVITY, 2, random);
-
- LinkedList result = new LinkedList<>();
- while (!remaining.isEmpty() && result.size() < maxCount) {
- List candidates = candidatesForNextHint(remaining, result);
- RankGuessGame.Hint selected = selectWeightedByStrength(
- candidates,
- result.size(),
- remaining.size() + result.size(),
- random
- );
- result.add(selected);
- remaining.remove(selected);
- }
- return result;
- }
-
- private static void limitCategory(
- List hints,
- RankGuessGame.Hint.HintCategory category,
- int maximum,
- RandomGenerator random
- ) {
- List categoryHints = hints.stream()
- .filter(hint -> hint.category() == category)
- .toList();
- if (categoryHints.size() <= maximum) {
- return;
- }
-
- List shuffled = new ArrayList<>(categoryHints);
- Collections.shuffle(shuffled, new Random(random.nextLong()));
- Set retained = new HashSet<>(shuffled.subList(0, maximum));
- hints.removeIf(hint -> hint.category() == category && !retained.contains(hint));
- }
-
- private static List candidatesForNextHint(
- List remaining,
- List selected
- ) {
- List candidates = remaining;
- if (selected.isEmpty()) {
- List nonRevealing = remaining.stream()
- .filter(hint -> hint.strength() != RankGuessGame.Hint.HintStrength.VERY_STRONG)
- .filter(hint -> hint.strength() != RankGuessGame.Hint.HintStrength.REVEALING)
- .toList();
- if (!nonRevealing.isEmpty()) {
- candidates = nonRevealing;
- }
- }
-
- if (!selected.isEmpty()) {
- RankGuessGame.Hint.HintCategory lastCategory = selected.getLast().category();
- List differentCategory = candidates.stream()
- .filter(hint -> hint.category() != lastCategory)
- .toList();
- if (!differentCategory.isEmpty()) {
- candidates = differentCategory;
- }
- }
- return candidates;
- }
-
- private static RankGuessGame.Hint selectWeightedByStrength(
- List candidates,
- int position,
- int total,
- RandomGenerator random
- ) {
- double progress = total <= 1 ? 1.0 : position / (double) (total - 1);
- EnumMap weights = strengthWeights(progress);
-
- EnumMap> byStrength =
- new EnumMap<>(RankGuessGame.Hint.HintStrength.class);
- for (RankGuessGame.Hint hint : candidates) {
- byStrength.computeIfAbsent(hint.strength(), ignored -> new ArrayList<>()).add(hint);
- }
-
- double totalWeight = byStrength.keySet().stream().mapToDouble(weights::get).sum();
- if (totalWeight <= 0) {
- return candidates.get(random.nextInt(candidates.size()));
- }
-
- double roll = random.nextDouble(totalWeight);
- for (Map.Entry> entry : byStrength.entrySet()) {
- roll -= weights.get(entry.getKey());
- if (roll < 0) {
- List hints = entry.getValue();
- return hints.get(random.nextInt(hints.size()));
- }
- }
- return candidates.getLast();
- }
-
- private static EnumMap strengthWeights(double progress) {
- double[] first = {50, 35, 15, 0, 0};
- double[] middle = {25, 35, 30, 10, 0};
- double[] late = {10, 20, 35, 35, 0};
- double phase = progress <= 0.5 ? progress * 2 : (progress - 0.5) * 2;
- double[] from = progress <= 0.5 ? first : middle;
- double[] to = progress <= 0.5 ? middle : late;
-
- EnumMap weights =
- new EnumMap<>(RankGuessGame.Hint.HintStrength.class);
- RankGuessGame.Hint.HintStrength[] strengths = RankGuessGame.Hint.HintStrength.values();
- for (int i = 0; i < strengths.length; i++) {
- weights.put(strengths[i], from[i] + (to[i] - from[i]) * phase);
- }
- return weights;
+ return GuessResponse.of(game, guessResult, message);
}
public synchronized EndResult end(String groupId, String senderUserId, boolean admin, boolean force) {
RankGuessGame game = games.get(groupId);
if (game == null) {
- return new EndResult(EndStatus.NO_GAME, null);
+ return new EndResult(EndResult.EndStatus.NO_GAME, null, false);
}
if (game.round == null) {
- return new EndResult(EndStatus.STARTING, null);
+ return new EndResult(EndResult.EndStatus.STARTING, null, false);
}
if (!force
&& clock.instant().isBefore(game.guessingStartedAt.plus(END_PROTECTION_DURATION))
&& !Objects.equals(game.starterUserId, senderUserId)
&& !admin) {
- return new EndResult(EndStatus.FORBIDDEN, null);
+ return new EndResult(EndResult.EndStatus.FORBIDDEN, null, false);
}
- games.remove(groupId);
- game.markEnded();
List standings = new ArrayList<>(game.guesses.size());
for (Map.Entry entry : game.guesses.entrySet()) {
Guess guess = entry.getValue();
@@ -301,264 +247,89 @@ public synchronized EndResult end(String groupId, String senderUserId, boolean a
.thenComparingDouble(Standing::error)
.thenComparingLong(Standing::sequence));
- return new EndResult(
- EndStatus.FINISHED,
- new FinishedRound(game.round, List.copyOf(standings))
+ FinishedRound finished = new FinishedRound(
+ game.token, groupId, game.fromGroup, game.guessingStartedAt, clock.instant(),
+ SCORING_VERSION, game.round, standings
);
- }
- public enum GuessStatus {
- NO_GAME,
- STARTING,
- RECORDED,
- UPDATED
- }
+ final boolean shouldRecord = game.guesses.size() >= MIN_PARTICIPANT_TO_RECORD;
- public enum EndStatus {
- NO_GAME,
- STARTING,
- FORBIDDEN,
- FINISHED
+ if (shouldRecord) {
+ recordWriter.accept(finished);
+ }
+
+ games.remove(groupId);
+ game.markEnded();
+ weights.recordRound(groupId, game.round.userId(), game.round.scoreId());
+
+ return new EndResult(
+ EndResult.EndStatus.FINISHED,
+ finished,
+ shouldRecord
+ );
}
- public record Reservation(String groupId, UUID token) {
+ public WishResult wish(String groupId, Long boundUid) {
+ return weights.tryWish(groupId, boundUid);
}
- public abstract static class ScoreMultiplier {
- @Getter
- private final String reason;
+ public void stopAll() {
+ Map.copyOf(games).forEach((s, g) -> {
+ if (g.isEnded()) return;
- protected ScoreMultiplier(String reason) {
- this.reason = reason;
- }
+ end(s, null, true, true);
+ });
+ }
- static class FirstGuessMultiplier extends ScoreMultiplier {
- FirstGuessMultiplier() {
- super("首猜加成");
- }
+ public GameStatus getStatus(String s) {
+ final RankGuessGame rankGuessGame = games.get(s);
+ if (rankGuessGame == null) {
+ return GameStatus.NO_GAME;
}
-
- static class OrderMultiplier extends ScoreMultiplier {
- @Getter
- private final int order;
-
- OrderMultiplier(int order) {
- this.order = order;
- super("第" + order + "猜");
- }
+ if (rankGuessGame.getRound() == null) {
+ return GameStatus.STARTING;
}
+ return GameStatus.RUNNING;
+ }
- static class CopyPunishmentMultiplier extends ScoreMultiplier {
- CopyPunishmentMultiplier() {
- super("抄袭惩罚");
- }
+ public MessageReference getVideoMessageRef(String s) {
+ final RankGuessGame rankGuessGame = games.get(s);
+ if (rankGuessGame == null || rankGuessGame.getRound() == null || rankGuessGame.getVideoRef() == null) {
+ return null;
}
- static class HintMultiplier extends ScoreMultiplier {
- @Getter
- private final RankGuessGame.Hint hint;
-
- HintMultiplier(RankGuessGame.Hint hint) {
- super("提示 " + hint.name() + " 已揭晓");
- this.hint = Objects.requireNonNull(hint, "hint");
- }
- }
+ return rankGuessGame.getVideoRef();
}
- public record GuessResponse(GuessResult guessResult, String message) {
- public static GuessResponse ofStatus(GuessStatus status) {
- return new GuessResponse(new GuessResult(status, 0, null, null), null);
- }
-
- public static GuessResponse of(GuessResult guessResult, String message) {
- return new GuessResponse(guessResult, message);
+ public int getParticipantCount(String groupId) {
+ final RankGuessGame rankGuessGame = games.get(groupId);
+ if (rankGuessGame == null) {
+ return 0;
}
+ return rankGuessGame.guesses.size();
}
- public record GuessResult(GuessStatus status, long rank, List multipliers, String multiplierString) {
-
+ public RankGuessWeights.Probability getProbabilityFor(String groupId, Long boundUid) {
+ return weights.getProbability(groupId, boundUid);
}
- public record EndResult(EndStatus status, FinishedRound round) {
+ public enum GameStatus {
+ NO_GAME,
+ STARTING,
+ RUNNING
}
- public record Round(long userId, long scoreId, int bestIndex, long actualRank, Double pp, RandomScore randomScore) {
- public Round {
- if (userId <= 0 || scoreId <= 0 || actualRank <= 0) {
- throw new IllegalArgumentException("Rank Guess 数据必须包含有效的用户、成绩和排名");
- }
- }
-
- public static Round from(RandomScore randomScore) {
- if (randomScore == null || randomScore.user() == null || randomScore.score() == null) {
- throw new IllegalArgumentException("随机成绩响应缺少用户或成绩数据");
- }
- if (randomScore.user().getStatistics() == null
- || randomScore.user().getStatistics().getGlobalRank() == null
- || randomScore.user().getStatistics().getGlobalRank() <= 0) {
- throw new IllegalArgumentException("随机用户缺少有效的全球排名");
- }
- if (randomScore.score().getId() == null || randomScore.score().getId() <= 0) {
- throw new IllegalArgumentException("随机成绩缺少有效的成绩ID");
- }
- if (randomScore.user().getId() <= 0) {
- throw new IllegalArgumentException("随机用户缺少有效的用户ID");
- }
-
- return new Round(
- randomScore.user().getId(),
- randomScore.score().getId(),
- randomScore.bestIndex(),
- randomScore.user().getStatistics().getGlobalRank(),
- randomScore.score().getPp(),
- randomScore
- );
- }
-
- public LinkedList getNormalHints() {
- LinkedList hints = new LinkedList<>();
-
- final UserExtended user = this.randomScore.user();
- final Score score = this.randomScore.score();
-
- // ===== Rank =====
-
- String range = getGlobalRankRange(
- user.getStatistics().getGlobalRank()
- );
-
- hints.add(new RankGuessGame.Hint(
- "本玩家的排名范围为 `" + range + "`",
- "排名范围",
- RankGuessGame.Hint.HintCategory.RANK,
- RankGuessGame.Hint.HintStrength.VERY_STRONG
- ));
-
- // ===== Target score =====
-
- hints.add(new RankGuessGame.Hint(
- "这是此玩家的 `BP" + this.bestIndex + "`",
- "BP位置",
- RankGuessGame.Hint.HintCategory.TARGET_SCORE,
- RankGuessGame.Hint.HintStrength.MEDIUM
- ));
-
- final long perfect = score.getStatistics().getOrDefault("perfect", 0L);
- final long ok = score.getStatistics().getOrDefault("ok", 0L);
- final long meh = score.getStatistics().getOrDefault("meh", 0L);
- final long miss = score.getStatistics().getOrDefault("miss", 0L);
-
- hints.add(new RankGuessGame.Hint(
- "本成绩的结果为: `300: %d / 100: %d / 50: %d / Miss: %d (%.2f%%)`"
- .formatted(
- perfect,
- ok,
- meh,
- miss,
- score.getAccuracy() * 100
- ),
- "成绩结果",
- RankGuessGame.Hint.HintCategory.TARGET_SCORE,
- RankGuessGame.Hint.HintStrength.WEAK
- ));
-
- hints.add(new RankGuessGame.Hint(
- "本成绩的最大连击为 `%d`".formatted(score.getMaxCombo()),
- "成绩连击",
- RankGuessGame.Hint.HintCategory.TARGET_SCORE,
- RankGuessGame.Hint.HintStrength.WEAK
- ));
-
-// if (score.getEndedAt() != null) {
-// hints.add(new RankGuessGame.Hint(
-// "本成绩完成于 `%s`".formatted(score.getEndedAt()),
-// "成绩时间",
-// RankGuessGame.Hint.HintCategory.TARGET_SCORE,
-// RankGuessGame.Hint.HintStrength.WEAK
-// ));
-// }
-
- // ===== Difficulty =====
-
- hints.add(new RankGuessGame.Hint(
- "本谱面的难度为: `%s`".formatted(this.randomScore.beatmapDiff()),
- "谱面难度",
- RankGuessGame.Hint.HintCategory.DIFFICULTY,
- RankGuessGame.Hint.HintStrength.STRONG
- ));
-
- // ===== Account activity =====
-
- if (user.getStatistics() != null) {
- var stats = user.getStatistics();
-
- hints.add(new RankGuessGame.Hint(
- "本玩家的总游玩次数为 `%d`".formatted(stats.getPlayCount()),
- "游玩次数",
- RankGuessGame.Hint.HintCategory.ACTIVITY,
- RankGuessGame.Hint.HintStrength.WEAK
- ));
-
- hints.add(new RankGuessGame.Hint(
- "本玩家的总游玩时间约为 `%.0f 小时`".formatted(stats.getPlayTime() / 3600.0),
- "游玩时间",
- RankGuessGame.Hint.HintCategory.ACTIVITY,
- RankGuessGame.Hint.HintStrength.WEAK
- ));
-
-// hints.add(new RankGuessGame.Hint(
-// "本玩家的总命中数约为 `%,d`".formatted(stats.getTotalHits()),
-// "总命中数",
-// RankGuessGame.Hint.HintCategory.ACTIVITY,
-// RankGuessGame.Hint.HintStrength.WEAK
-// ));
-
-// hints.add(new RankGuessGame.Hint(
-// "本玩家的历史最大连击为 `%d`".formatted(stats.getMaximumCombo()),
-// "最大连击",
-// RankGuessGame.Hint.HintCategory.ACTIVITY,
-// RankGuessGame.Hint.HintStrength.WEAK
-// ));
-
- hints.add(new RankGuessGame.Hint(
- "本玩家当前等级约为 `%.1f`".formatted(stats.getLevel().getCurrent() + stats.getLevel().getProgress() / 100.0),
- "玩家等级",
- RankGuessGame.Hint.HintCategory.ACTIVITY,
- RankGuessGame.Hint.HintStrength.WEAK
- ));
- }
-
- // ===== Account metadata =====
-
- if (user.getJoinDate() != null) {
- hints.add(new RankGuessGame.Hint(
- "本玩家于 `%s` 注册 osu!".formatted(user.getJoinDate()),
- "注册时间",
- RankGuessGame.Hint.HintCategory.ACTIVITY,
- RankGuessGame.Hint.HintStrength.WEAK
- ));
- }
-
- return hints;
- }
+ public enum GuessStatus {
+ NO_GAME,
+ STARTING,
+ RECORDED,
+ TOO_SOON,
+ UPDATED
}
- @NotNull
- public static String getGlobalRankRange(long l) {
- String range;
-
- if (l <= 10_000) {
- range = "#1 - #10k";
- } else if (l <= 50_000) {
- range = "#10k - #50k";
- } else if (l <= 200_000) {
- range = "#50k - #200k";
- } else if (l <= 500_000) {
- range = "#200k - #500k";
- } else {
- range = ">#500k";
- }
-
- return range;
+ public enum WishResult {
+ SUCCESS,
+ ALREADY_WISHED,
+ RECENTLY_PICKED
}
}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java
new file mode 100644
index 00000000..baa1738c
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java
@@ -0,0 +1,290 @@
+package xyz.zcraft.seira.rankguess;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import xyz.zcraft.seira.db.UserDataStore;
+
+import java.io.IOException;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class RankGuessWeights {
+ private static final Gson GSON = new Gson();
+ private static final Logger LOG = LogManager.getLogger(RankGuessWeights.class);
+ private static final Path WEIGHTS_FILE = Path.of("data", "rank-guess-weights.json");
+
+ private static final int RECENT_USER_LIMIT = 8;
+ private static final double WISH_WEIGHT = 2.5;
+ private static final double RECENT_USER_WEIGHT = 0.25;
+ private static final double SCORE_REPEAT_FACTOR = 0.25;
+
+ private final Map groups = new ConcurrentHashMap<>();
+ private final Path store;
+ private final Object persistenceLock = new Object();
+
+ public RankGuessWeights() {
+ this(WEIGHTS_FILE);
+ }
+
+ RankGuessWeights(Path store) {
+ this.store = Objects.requireNonNull(store, "store").toAbsolutePath();
+ loadFromFile();
+ }
+
+ private static void recordUser(GroupState state, long userId) {
+ state.userRecords.add(userId);
+ if (state.userRecords.size() > RECENT_USER_LIMIT) {
+ state.userRecords.removeFirst();
+ }
+ state.userWishes.remove(userId);
+ }
+
+ private void loadFromFile() {
+ if (!Files.exists(store)) {
+ return;
+ }
+ try {
+ JsonObject savedGroups = JsonParser.parseString(Files.readString(store))
+ .getAsJsonObject().getAsJsonObject("groups");
+ Map restored = new HashMap<>();
+ for (var entry : Objects.requireNonNull(savedGroups, "Missing groups").entrySet()) {
+ if (entry.getKey().isBlank()) {
+ continue;
+ }
+ GroupSnapshot snapshot = Objects.requireNonNull(
+ GSON.fromJson(entry.getValue(), GroupSnapshot.class), "Missing group state");
+ GroupState state = new GroupState();
+ if (snapshot.scoreRecords() != null) {
+ snapshot.scoreRecords().forEach((scoreId, count) -> {
+ if (scoreId != null && scoreId > 0 && count != null && count > 0) {
+ state.scoreRecords.put(scoreId, count);
+ }
+ });
+ }
+ if (snapshot.userRecords() != null) {
+ for (Long userId : snapshot.userRecords()) {
+ if (userId != null && userId > 0) {
+ state.userRecords.add(userId);
+ if (state.userRecords.size() > RECENT_USER_LIMIT) {
+ state.userRecords.removeFirst();
+ }
+ }
+ }
+ }
+ if (snapshot.userWishes() != null) {
+ for (Long userId : snapshot.userWishes()) {
+ if (userId != null && userId > 0 && !state.userRecords.contains(userId)) {
+ state.userWishes.add(userId);
+ }
+ }
+ }
+ restored.put(entry.getKey(), state);
+ }
+ groups.putAll(restored);
+ } catch (IOException | RuntimeException e) {
+ LOG.warn("Failed to load rank guess weights from {}, starting with empty history", store, e);
+ }
+ }
+
+ public void saveToFile() {
+ // Take the snapshot after acquiring the file lock so an older save cannot overwrite a newer one.
+ synchronized (persistenceLock) {
+ Map snapshot = new TreeMap<>();
+ groups.forEach((groupId, state) -> {
+ synchronized (state) {
+ snapshot.put(groupId, new GroupSnapshot(
+ new TreeMap<>(state.scoreRecords), List.copyOf(state.userRecords),
+ new TreeSet<>(state.userWishes)));
+ }
+ });
+ JsonObject data = new JsonObject();
+ data.add("groups", GSON.toJsonTree(snapshot));
+ Path temporary = null;
+ try {
+ Files.createDirectories(store.getParent());
+ temporary = Files.createTempFile(store.getParent(), "rank-guess-weights-", ".tmp");
+ Files.writeString(temporary, data.toString());
+ try {
+ Files.move(temporary, store, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ } catch (AtomicMoveNotSupportedException e) {
+ Files.move(temporary, store, StandardCopyOption.REPLACE_EXISTING);
+ }
+ } catch (IOException e) {
+ LOG.error("Failed to save rank guess weights to {}", store, e);
+ } finally {
+ if (temporary != null) {
+ try {
+ Files.deleteIfExists(temporary);
+ } catch (IOException e) {
+ LOG.warn("Failed to remove temporary rank guess weights file {}", temporary, e);
+ }
+ }
+ }
+ }
+ }
+
+ private GroupState getGroup(String groupId) {
+ return groups.computeIfAbsent(groupId, _ -> new GroupState());
+ }
+
+ public void recordScore(String groupId, long scoreId) {
+ final GroupState state = getGroup(groupId);
+
+ synchronized (state) {
+ state.scoreRecords.merge(scoreId, 1, Integer::sum);
+ }
+ saveToFile();
+ }
+
+ public void recordUser(String groupId, long userId) {
+ final GroupState state = getGroup(groupId);
+
+ synchronized (state) {
+ recordUser(state, userId);
+ }
+ saveToFile();
+ }
+
+ public void recordRound(String groupId, long userId, long scoreId) {
+ final GroupState state = getGroup(groupId);
+ synchronized (state) {
+ state.scoreRecords.merge(scoreId, 1, Integer::sum);
+ recordUser(state, userId);
+ }
+ saveToFile();
+ }
+
+ public boolean recentPicked(String groupId, long userId) {
+ final GroupState state = getGroup(groupId);
+
+ synchronized (state) {
+ return state.userRecords.contains(userId);
+ }
+ }
+
+ public RankGuessGameService.WishResult tryWish(String groupId, long userId) {
+ final GroupState state = getGroup(groupId);
+
+ synchronized (state) {
+ if (state.userWishes.contains(userId)) {
+ return RankGuessGameService.WishResult.ALREADY_WISHED;
+ }
+
+ if (state.userRecords.contains(userId)) {
+ return RankGuessGameService.WishResult.RECENTLY_PICKED;
+ }
+
+ state.userWishes.add(userId);
+ }
+ saveToFile();
+ return RankGuessGameService.WishResult.SUCCESS;
+ }
+
+ public JsonObject generateWeights(String groupId) {
+ final GroupState state = getGroup(groupId);
+
+ final Map users = new HashMap<>();
+ final Map scores = new HashMap<>();
+
+ synchronized (state) {
+ for (Long wishedId : state.userWishes) {
+ users.put(wishedId, WISH_WEIGHT);
+ }
+
+ for (Long pickedId : state.userRecords) {
+ users.put(pickedId, RECENT_USER_WEIGHT);
+ }
+
+ state.scoreRecords.forEach((scoreId, count) ->
+ scores.put(
+ scoreId,
+ Math.pow(SCORE_REPEAT_FACTOR, count)
+ )
+ );
+ }
+
+ final JsonObject weights = new JsonObject();
+
+ weights.add("users", GSON.toJsonTree(users));
+ weights.add("scores", GSON.toJsonTree(scores));
+
+ return weights;
+ }
+
+ public List getGroupWishes(String groupId) {
+ final GroupState state = getGroup(groupId);
+
+ synchronized (state) {
+ return List.copyOf(state.userWishes);
+ }
+ }
+
+ public List getGroupUserRecords(String groupId) {
+ final GroupState state = getGroup(groupId);
+
+ synchronized (state) {
+ return List.copyOf(state.userRecords);
+ }
+ }
+
+ public Map getGroupScoreRecords(String groupId) {
+ final GroupState state = getGroup(groupId);
+
+ synchronized (state) {
+ return Map.copyOf(state.scoreRecords);
+ }
+ }
+
+ public record Probability(
+ double weight,
+ double chance
+ ){}
+
+ public Probability getProbability(String groupId, long boundUid) {
+ final GroupState state = getGroup(groupId);
+
+ final Map users = new HashMap<>();
+
+ double weight;
+
+ synchronized (state) {
+ for (Long wishedId : state.userWishes) {
+ users.put(wishedId, WISH_WEIGHT);
+ }
+
+ for (Long pickedId : state.userRecords) {
+ users.put(pickedId, RECENT_USER_WEIGHT);
+ }
+ }
+
+ weight = users.getOrDefault(boundUid, 1.0);
+
+ double accumulation = 0;
+
+ for (Long l : UserDataStore.findBoundUidsByGroup(groupId)) {
+ accumulation += users.getOrDefault(l, 1.0);
+ }
+
+ if (accumulation == 0) throw new IllegalStateException("Error calculating probability for " + groupId + " " + boundUid);
+
+ double chance = weight / accumulation;
+
+ return new Probability(weight, chance);
+ }
+
+ private static class GroupState {
+ private final Map scoreRecords = new HashMap<>();
+ private final LinkedList userRecords = new LinkedList<>();
+ private final Set userWishes = new HashSet<>();
+ }
+
+ private record GroupSnapshot(Map scoreRecords, List userRecords, Set userWishes) {
+ }
+}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/EndResult.java b/src/main/java/xyz/zcraft/seira/rankguess/data/EndResult.java
new file mode 100644
index 00000000..a1f5cc03
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/rankguess/data/EndResult.java
@@ -0,0 +1,10 @@
+package xyz.zcraft.seira.rankguess.data;
+
+public record EndResult(EndStatus status, FinishedRound round, boolean recorded) {
+ public enum EndStatus {
+ NO_GAME,
+ STARTING,
+ FORBIDDEN,
+ FINISHED
+ }
+}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/FinishedRound.java b/src/main/java/xyz/zcraft/seira/rankguess/data/FinishedRound.java
new file mode 100644
index 00000000..71bb6fb3
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/rankguess/data/FinishedRound.java
@@ -0,0 +1,14 @@
+package xyz.zcraft.seira.rankguess.data;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+public record FinishedRound(
+ UUID id, String groupId, boolean fromGroup, Instant startedAt, Instant endedAt,
+ int scoringVersion, Round round, List standings
+) {
+ public FinishedRound {
+ standings = List.copyOf(standings);
+ }
+}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/Guess.java b/src/main/java/xyz/zcraft/seira/rankguess/data/Guess.java
new file mode 100644
index 00000000..918936a6
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/rankguess/data/Guess.java
@@ -0,0 +1,9 @@
+package xyz.zcraft.seira.rankguess.data;
+
+import java.util.List;
+
+public record Guess(long rank, long sequence, List multipliers, long timestamp) {
+ public static Guess of(long rank, long sequence, List multipliers) {
+ return new Guess(rank, sequence, multipliers, System.currentTimeMillis());
+ }
+}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/GuessResponse.java b/src/main/java/xyz/zcraft/seira/rankguess/data/GuessResponse.java
new file mode 100644
index 00000000..2ae8fb7d
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/rankguess/data/GuessResponse.java
@@ -0,0 +1,14 @@
+package xyz.zcraft.seira.rankguess.data;
+
+import xyz.zcraft.seira.rankguess.RankGuessGame;
+import xyz.zcraft.seira.rankguess.RankGuessGameService;
+
+public record GuessResponse(RankGuessGame game, GuessResult guessResult, String message) {
+ public static GuessResponse ofStatus(RankGuessGameService.GuessStatus status) {
+ return new GuessResponse(null, new GuessResult(status, 0, null, null, 0), null);
+ }
+
+ public static GuessResponse of(RankGuessGame game, GuessResult guessResult, String message) {
+ return new GuessResponse(game, guessResult, message);
+ }
+}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/GuessResult.java b/src/main/java/xyz/zcraft/seira/rankguess/data/GuessResult.java
new file mode 100644
index 00000000..18ba8207
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/rankguess/data/GuessResult.java
@@ -0,0 +1,11 @@
+package xyz.zcraft.seira.rankguess.data;
+
+import xyz.zcraft.seira.rankguess.RankGuessGameService;
+
+import java.util.List;
+
+public record GuessResult(RankGuessGameService.GuessStatus status, long rank, List multipliers,
+ String multiplierString,
+ int guessCount) {
+
+}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/Rank.java b/src/main/java/xyz/zcraft/seira/rankguess/data/Rank.java
new file mode 100644
index 00000000..72f83b4a
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/rankguess/data/Rank.java
@@ -0,0 +1,64 @@
+package xyz.zcraft.seira.rankguess.data;
+
+import xyz.zcraft.seira.db.RankGuessRecordStore;
+
+public record Rank(double rating, double ratingRaw, String rank) {
+ public static final int RECENT_GAME_LIMIT = 10;
+
+ public static Rank from(RankGuessRecordStore.Statistics.Personal recent,
+ RankGuessRecordStore.Statistics.Personal all) {
+ final double pendingRatingRaw = getRatingRaw(recent, all);
+ final double pendingRating = standardRating(pendingRatingRaw);
+ final String pendingRank = getRankText(all.participation(), pendingRatingRaw);
+
+ return new Rank(pendingRating, pendingRatingRaw, pendingRank);
+ }
+
+ private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal recent,
+ RankGuessRecordStore.Statistics.Personal all) {
+ double averageScoreRate = Math.clamp(
+ recent.averageScore() / 900.0,
+ 0.0, 1.0
+ );
+
+ double rawRating = averageScoreRate * 0.55
+ + recent.winRate() * 0.10
+ + recent.topTwentyRate() * 0.35;
+
+ rawRating *= 1.025;
+
+ double confidence = 1.0 - Math.exp(-all.participation() / 10.0);
+
+ return 0.50 * (1.0 - confidence) + rawRating * confidence;
+ }
+
+ private static double standardRating(double ratingRaw) {
+ double z = 2 * ratingRaw - 1;
+ double p = 1.1;
+
+ return 1 + Math.copySign(Math.pow(Math.abs(z), p), z);
+ }
+
+ private static String getRankText(long totalParticipation, double rawRating) {
+ if (totalParticipation < 5) {
+ return "?";
+ }
+
+ String rank;
+
+ if (rawRating >= 1.00) rank = "SS";
+ else if (rawRating >= 0.82) rank = "S";
+ else if (rawRating >= 0.68) rank = "A";
+ else if (rawRating >= 0.57) rank = "B";
+ else if (rawRating >= 0.42) rank = "C";
+ else rank = "D";
+
+ if (totalParticipation < 10) {
+ rank += "?";
+ } else if (rawRating >= 1.00) {
+ rank += "(强强!?!)";
+ }
+
+ return rank;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/Reservation.java b/src/main/java/xyz/zcraft/seira/rankguess/data/Reservation.java
new file mode 100644
index 00000000..92fbaa94
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/rankguess/data/Reservation.java
@@ -0,0 +1,6 @@
+package xyz.zcraft.seira.rankguess.data;
+
+import java.util.UUID;
+
+public record Reservation(String groupId, UUID token) {
+}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/Round.java b/src/main/java/xyz/zcraft/seira/rankguess/data/Round.java
new file mode 100644
index 00000000..1c07972b
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/rankguess/data/Round.java
@@ -0,0 +1,170 @@
+package xyz.zcraft.seira.rankguess.data;
+
+import xyz.zcraft.osu.model.Score;
+import xyz.zcraft.osu.model.UserExtended;
+import xyz.zcraft.seira.api.data.RandomScore;
+import xyz.zcraft.seira.rankguess.RankGuessGame;
+import xyz.zcraft.seira.rankguess.RankGuessGameService;
+
+import java.util.LinkedList;
+
+public record Round(long userId, long scoreId, int bestIndex, long actualRank, Double pp, RandomScore randomScore) {
+ public Round {
+ if (userId <= 0 || scoreId <= 0 || actualRank <= 0) {
+ throw new IllegalArgumentException("Rank Guess 数据必须包含有效的用户、成绩和排名");
+ }
+ }
+
+ public static Round from(RandomScore randomScore) {
+ if (randomScore == null || randomScore.user() == null || randomScore.score() == null) {
+ throw new IllegalArgumentException("随机成绩响应缺少用户或成绩数据");
+ }
+ if (randomScore.user().getStatistics() == null
+ || randomScore.user().getStatistics().getGlobalRank() == null
+ || randomScore.user().getStatistics().getGlobalRank() <= 0) {
+ throw new IllegalArgumentException("随机用户缺少有效的全球排名");
+ }
+ if (randomScore.score().getId() == null || randomScore.score().getId() <= 0) {
+ throw new IllegalArgumentException("随机成绩缺少有效的成绩ID");
+ }
+ if (randomScore.user().getId() <= 0) {
+ throw new IllegalArgumentException("随机用户缺少有效的用户ID");
+ }
+
+ return new Round(
+ randomScore.user().getId(),
+ randomScore.score().getId(),
+ randomScore.bestIndex(),
+ randomScore.user().getStatistics().getGlobalRank(),
+ randomScore.score().getPp(),
+ randomScore
+ );
+ }
+
+ public LinkedList getNormalHints() {
+ LinkedList hints = new LinkedList<>();
+
+ final UserExtended user = this.randomScore.user();
+ final Score score = this.randomScore.score();
+
+ // ===== Rank =====
+
+ String range = RankGuessGameService.getGlobalRankRange(
+ user.getStatistics().getGlobalRank()
+ );
+
+ hints.add(new RankGuessGame.Hint(
+ "本玩家的排名范围为 `" + range + "`",
+ "排名范围",
+ RankGuessGame.Hint.HintCategory.RANK,
+ RankGuessGame.Hint.HintStrength.VERY_STRONG
+ ));
+
+ // ===== Target score =====
+
+ hints.add(new RankGuessGame.Hint(
+ "这是此玩家的 `BP" + this.bestIndex + "`",
+ "BP位置",
+ RankGuessGame.Hint.HintCategory.TARGET_SCORE,
+ RankGuessGame.Hint.HintStrength.MEDIUM
+ ));
+
+ final long perfect = score.getStatistics().getOrDefault("great", 0L);
+ final long ok = score.getStatistics().getOrDefault("ok", 0L);
+ final long meh = score.getStatistics().getOrDefault("meh", 0L);
+ final long miss = score.getStatistics().getOrDefault("miss", 0L);
+
+ hints.add(new RankGuessGame.Hint(
+ "本成绩的结果为: `300: %d / 100: %d / 50: %d / Miss: %d (%.2f%%)`"
+ .formatted(
+ perfect,
+ ok,
+ meh,
+ miss,
+ score.getAccuracy() * 100
+ ),
+ "成绩结果",
+ RankGuessGame.Hint.HintCategory.TARGET_SCORE,
+ RankGuessGame.Hint.HintStrength.WEAK
+ ));
+
+ hints.add(new RankGuessGame.Hint(
+ "本成绩的最大连击为 `%d`".formatted(score.getMaxCombo()),
+ "成绩连击",
+ RankGuessGame.Hint.HintCategory.TARGET_SCORE,
+ RankGuessGame.Hint.HintStrength.WEAK
+ ));
+
+// if (score.getEndedAt() != null) {
+// hints.add(new RankGuessGame.Hint(
+// "本成绩完成于 `%s`".formatted(score.getEndedAt()),
+// "成绩时间",
+// RankGuessGame.Hint.HintCategory.TARGET_SCORE,
+// RankGuessGame.Hint.HintStrength.WEAK
+// ));
+// }
+
+ // ===== Difficulty =====
+
+ hints.add(new RankGuessGame.Hint(
+ "本谱面的难度为: `%s`".formatted(this.randomScore.beatmapDiff()),
+ "谱面难度",
+ RankGuessGame.Hint.HintCategory.DIFFICULTY,
+ RankGuessGame.Hint.HintStrength.STRONG
+ ));
+
+ // ===== Account activity =====
+
+ if (user.getStatistics() != null) {
+ var stats = user.getStatistics();
+
+ hints.add(new RankGuessGame.Hint(
+ "本玩家的总游玩次数为 `%d`".formatted(stats.getPlayCount()),
+ "游玩次数",
+ RankGuessGame.Hint.HintCategory.ACTIVITY,
+ RankGuessGame.Hint.HintStrength.WEAK
+ ));
+
+ hints.add(new RankGuessGame.Hint(
+ "本玩家的总游玩时间约为 `%.0f 小时`".formatted(stats.getPlayTime() / 3600.0),
+ "游玩时间",
+ RankGuessGame.Hint.HintCategory.ACTIVITY,
+ RankGuessGame.Hint.HintStrength.WEAK
+ ));
+
+// hints.add(new RankGuessGame.Hint(
+// "本玩家的总命中数约为 `%,d`".formatted(stats.getTotalHits()),
+// "总命中数",
+// RankGuessGame.Hint.HintCategory.ACTIVITY,
+// RankGuessGame.Hint.HintStrength.WEAK
+// ));
+
+// hints.add(new RankGuessGame.Hint(
+// "本玩家的历史最大连击为 `%d`".formatted(stats.getMaximumCombo()),
+// "最大连击",
+// RankGuessGame.Hint.HintCategory.ACTIVITY,
+// RankGuessGame.Hint.HintStrength.WEAK
+// ));
+
+ hints.add(new RankGuessGame.Hint(
+ "本玩家当前等级约为 `%.1f`".formatted(stats.getLevel().getCurrent() + stats.getLevel().getProgress() / 100.0),
+ "玩家等级",
+ RankGuessGame.Hint.HintCategory.ACTIVITY,
+ RankGuessGame.Hint.HintStrength.WEAK
+ ));
+ }
+
+ // ===== Account metadata =====
+
+ if (user.getJoinDate() != null) {
+ hints.add(new RankGuessGame.Hint(
+ "本玩家于 `%s` 注册 osu!".formatted(user.getJoinDate()),
+ "注册时间",
+ RankGuessGame.Hint.HintCategory.ACTIVITY,
+ RankGuessGame.Hint.HintStrength.WEAK
+ ));
+ }
+
+ return hints;
+ }
+}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/ScoreMultiplier.java b/src/main/java/xyz/zcraft/seira/rankguess/data/ScoreMultiplier.java
new file mode 100644
index 00000000..7e6fe3f5
--- /dev/null
+++ b/src/main/java/xyz/zcraft/seira/rankguess/data/ScoreMultiplier.java
@@ -0,0 +1,47 @@
+package xyz.zcraft.seira.rankguess.data;
+
+import lombok.Getter;
+import xyz.zcraft.seira.rankguess.RankGuessGame;
+
+import java.util.Objects;
+
+public abstract class ScoreMultiplier {
+ @Getter
+ private final String reason;
+
+ protected ScoreMultiplier(String reason) {
+ this.reason = reason;
+ }
+
+ public static class FirstGuessMultiplier extends ScoreMultiplier {
+ public FirstGuessMultiplier() {
+ super("首猜加成");
+ }
+ }
+
+ public static class OrderMultiplier extends ScoreMultiplier {
+ @Getter
+ private final int order;
+
+ public OrderMultiplier(int order) {
+ this.order = order;
+ super("第" + order + "猜");
+ }
+ }
+
+ public static class CopyPunishmentMultiplier extends ScoreMultiplier {
+ public CopyPunishmentMultiplier() {
+ super("抄袭惩罚");
+ }
+ }
+
+ public static class HintMultiplier extends ScoreMultiplier {
+ @Getter
+ private final RankGuessGame.Hint hint;
+
+ public HintMultiplier(RankGuessGame.Hint hint) {
+ super("提示 " + hint.name() + " 已揭晓");
+ this.hint = Objects.requireNonNull(hint, "hint");
+ }
+ }
+}
diff --git a/src/main/java/xyz/zcraft/seira/rankguess/Standing.java b/src/main/java/xyz/zcraft/seira/rankguess/data/Standing.java
similarity index 84%
rename from src/main/java/xyz/zcraft/seira/rankguess/Standing.java
rename to src/main/java/xyz/zcraft/seira/rankguess/data/Standing.java
index 80d66136..ce49254a 100644
--- a/src/main/java/xyz/zcraft/seira/rankguess/Standing.java
+++ b/src/main/java/xyz/zcraft/seira/rankguess/data/Standing.java
@@ -1,4 +1,4 @@
-package xyz.zcraft.seira.rankguess;
+package xyz.zcraft.seira.rankguess.data;
public record Standing(
String senderUserId,
diff --git a/src/main/java/xyz/zcraft/seira/binding/BindingService.java b/src/main/java/xyz/zcraft/seira/services/BindingService.java
similarity index 94%
rename from src/main/java/xyz/zcraft/seira/binding/BindingService.java
rename to src/main/java/xyz/zcraft/seira/services/BindingService.java
index 901c84c6..ea2fdb38 100644
--- a/src/main/java/xyz/zcraft/seira/binding/BindingService.java
+++ b/src/main/java/xyz/zcraft/seira/services/BindingService.java
@@ -1,4 +1,4 @@
-package xyz.zcraft.seira.binding;
+package xyz.zcraft.seira.services;
import io.javalin.Javalin;
import io.javalin.http.Context;
@@ -13,16 +13,13 @@
import java.time.Duration;
import java.util.Objects;
import java.util.UUID;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.Executor;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiConsumer;
-/** Owns the OAuth callback server and its pending binding requests. */
+/**
+ * Owns the OAuth callback server and its pending binding requests.
+ */
public final class BindingService implements AutoCloseable {
private static final Logger LOG = LogManager.getLogger(BindingService.class);
private static final Duration TASK_TTL = Duration.ofMinutes(20);
@@ -165,7 +162,11 @@ public synchronized void close() {
Javalin currentServer = server;
server = null;
if (currentServer != null) {
- currentServer.stop();
+ try {
+ currentServer.stop();
+ } catch (Exception e) {
+ LOG.warn("Error stopping server", e);
+ }
}
bindingTasks.clear();
}
diff --git a/src/main/java/xyz/zcraft/seira/services/BotStat.java b/src/main/java/xyz/zcraft/seira/services/BotStat.java
index 5ac485e7..94cbd882 100644
--- a/src/main/java/xyz/zcraft/seira/services/BotStat.java
+++ b/src/main/java/xyz/zcraft/seira/services/BotStat.java
@@ -12,8 +12,8 @@
import java.util.LinkedList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
public class BotStat {
private static final Path STAT_FILE = Path.of("data", "bot-stat.json");
diff --git a/src/main/java/xyz/zcraft/seira/services/CosService.java b/src/main/java/xyz/zcraft/seira/services/CosService.java
index 93a6f7ab..56b8cea3 100644
--- a/src/main/java/xyz/zcraft/seira/services/CosService.java
+++ b/src/main/java/xyz/zcraft/seira/services/CosService.java
@@ -291,6 +291,11 @@ private String trimTrailingSlash(String value) {
return result;
}
+ @Override
+ public void close() {
+ client.shutdown();
+ }
+
private record ImageDimensions(int width, int height) {
}
@@ -329,10 +334,5 @@ private boolean isExpired(long now) {
}
}
- @Override
- public void close() {
- client.shutdown();
- }
-
}
diff --git a/src/main/java/xyz/zcraft/seira/security/AdminRegistry.java b/src/main/java/xyz/zcraft/seira/util/AdminRegistry.java
similarity index 97%
rename from src/main/java/xyz/zcraft/seira/security/AdminRegistry.java
rename to src/main/java/xyz/zcraft/seira/util/AdminRegistry.java
index 32031700..52d95e0d 100644
--- a/src/main/java/xyz/zcraft/seira/security/AdminRegistry.java
+++ b/src/main/java/xyz/zcraft/seira/util/AdminRegistry.java
@@ -1,4 +1,4 @@
-package xyz.zcraft.seira.security;
+package xyz.zcraft.seira.util;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -14,7 +14,9 @@
import java.util.Set;
import java.util.concurrent.locks.ReentrantReadWriteLock;
-/** Combines administrators declared in config with administrators added from the console. */
+/**
+ * Combines administrators declared in config with administrators added from the console.
+ */
public final class AdminRegistry {
public static final Path DEFAULT_STORE = Path.of("data", "console-admins.txt");
@@ -22,8 +24,8 @@ public final class AdminRegistry {
private final Path store;
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
- private Set configured = Set.of();
private final Set persisted = new LinkedHashSet<>();
+ private Set configured = Set.of();
public AdminRegistry(Collection configured) {
this(configured, DEFAULT_STORE);
@@ -35,6 +37,18 @@ public AdminRegistry(Collection configured, Path store) {
loadPersisted();
}
+ private static String validate(String openId) {
+ if (openId == null || openId.isBlank()) {
+ throw new IllegalArgumentException("Administrator ID must not be blank");
+ }
+ String normalized = openId.trim();
+ if (normalized.length() > 256 || normalized.chars().anyMatch(Character::isWhitespace)
+ || normalized.chars().anyMatch(Character::isISOControl)) {
+ throw new IllegalArgumentException("Administrator ID contains invalid characters");
+ }
+ return normalized;
+ }
+
public boolean isAdmin(String openId) {
if (openId == null || openId.isBlank()) {
return false;
@@ -158,18 +172,6 @@ private void savePersisted() {
}
}
- private static String validate(String openId) {
- if (openId == null || openId.isBlank()) {
- throw new IllegalArgumentException("Administrator ID must not be blank");
- }
- String normalized = openId.trim();
- if (normalized.length() > 256 || normalized.chars().anyMatch(Character::isWhitespace)
- || normalized.chars().anyMatch(Character::isISOControl)) {
- throw new IllegalArgumentException("Administrator ID contains invalid characters");
- }
- return normalized;
- }
-
public enum AddResult {
ADDED,
ALREADY_PRESENT
diff --git a/src/main/java/xyz/zcraft/seira/runtime/ApplicationExecutors.java b/src/main/java/xyz/zcraft/seira/util/ApplicationExecutors.java
similarity index 98%
rename from src/main/java/xyz/zcraft/seira/runtime/ApplicationExecutors.java
rename to src/main/java/xyz/zcraft/seira/util/ApplicationExecutors.java
index ddc67977..926ac1e1 100644
--- a/src/main/java/xyz/zcraft/seira/runtime/ApplicationExecutors.java
+++ b/src/main/java/xyz/zcraft/seira/util/ApplicationExecutors.java
@@ -1,4 +1,4 @@
-package xyz.zcraft.seira.runtime;
+package xyz.zcraft.seira.util;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -32,6 +32,22 @@ public final class ApplicationExecutors implements AutoCloseable {
daemonThreadFactory("seira-attachment-")
);
+ private static void awaitTermination(ExecutorService executor) {
+ try {
+ if (!executor.awaitTermination(SHUTDOWN_TIMEOUT.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)) {
+ executor.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ executor.shutdownNow();
+ Thread.currentThread().interrupt();
+ LOG.warn("Interrupted while shutting down application executor");
+ }
+ }
+
+ private static ThreadFactory daemonThreadFactory(String prefix) {
+ return Thread.ofPlatform().daemon().name(prefix, 0).factory();
+ }
+
public ExecutorService gatewayEvents() {
return gatewayEvents;
}
@@ -51,20 +67,4 @@ public void close() {
awaitTermination(executor);
}
}
-
- private static void awaitTermination(ExecutorService executor) {
- try {
- if (!executor.awaitTermination(SHUTDOWN_TIMEOUT.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)) {
- executor.shutdownNow();
- }
- } catch (InterruptedException e) {
- executor.shutdownNow();
- Thread.currentThread().interrupt();
- LOG.warn("Interrupted while shutting down application executor");
- }
- }
-
- private static ThreadFactory daemonThreadFactory(String prefix) {
- return Thread.ofPlatform().daemon().name(prefix, 0).factory();
- }
}
diff --git a/src/main/java/xyz/zcraft/seira/util/OsuAuthHelper.java b/src/main/java/xyz/zcraft/seira/util/OsuAuthHelper.java
index c96dec7e..50a16558 100644
--- a/src/main/java/xyz/zcraft/seira/util/OsuAuthHelper.java
+++ b/src/main/java/xyz/zcraft/seira/util/OsuAuthHelper.java
@@ -4,8 +4,8 @@
import org.apache.logging.log4j.Logger;
import xyz.zcraft.seira.api.OsuAuthApi;
import xyz.zcraft.seira.api.data.OsuToken;
-import xyz.zcraft.seira.binding.UserDataStore;
import xyz.zcraft.seira.config.BindingConfig;
+import xyz.zcraft.seira.db.UserDataStore;
public class OsuAuthHelper {
private static final Logger LOG = LogManager.getLogger();
diff --git a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomVersion.java b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomVersion.java
index 0bc8cc42..0ab2ac8f 100644
--- a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomVersion.java
+++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomVersion.java
@@ -12,10 +12,6 @@ public enum MultiplayerRoomVersion {
this.value = value;
}
- public String value() {
- return value;
- }
-
public static MultiplayerRoomVersion parse(String value) {
if (value == null) {
return null;
@@ -26,4 +22,8 @@ public static MultiplayerRoomVersion parse(String value) {
default -> null;
};
}
+
+ public String value() {
+ return value;
+ }
}
diff --git a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java
index eecad975..27c2c466 100644
--- a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java
+++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java
@@ -4,15 +4,7 @@
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.*;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@@ -45,6 +37,23 @@ public MultiplayerRoomWatchService(
});
}
+ 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);
+ }
+
public void start() {
if (closed.get()) {
throw new IllegalStateException("Multiplayer room watch service has been closed");
@@ -274,23 +283,6 @@ private void pollSafely() {
}
}
- 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)) {
@@ -298,12 +290,8 @@ public void close() {
}
}
- private static final class WatchEntry {
- private final MultiplayerRoomVersion version;
- private final long roomId;
- private final String roomName;
- private final Set sentPlaylistItemIds;
-
+ private record WatchEntry(MultiplayerRoomVersion version, long roomId, String roomName,
+ Set sentPlaylistItemIds) {
private WatchEntry(
MultiplayerRoomVersion version,
long roomId,
diff --git a/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java b/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java
index 43133f68..57c69ac3 100644
--- a/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java
+++ b/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java
@@ -27,6 +27,47 @@ public OstellaMultiplayerRoomWatchApi(String endpoint) {
this.gson = gson;
}
+ 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;
+ }
+
@Override
public RoomWatchSnapshot getSnapshot(MultiplayerRoomVersion version, long roomId) {
HttpResponse response = get(
@@ -78,45 +119,4 @@ private HttpResponse get(String path, HttpResponse.BodyHandler handler
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/OstellaWatchApi.java b/src/main/java/xyz/zcraft/seira/watch/OstellaWatchApi.java
index 42dfd78e..e06a8f4d 100644
--- a/src/main/java/xyz/zcraft/seira/watch/OstellaWatchApi.java
+++ b/src/main/java/xyz/zcraft/seira/watch/OstellaWatchApi.java
@@ -36,6 +36,28 @@ public OstellaWatchApi(String endpoint) {
this.gson = gson;
}
+ private static void ensureSuccessfulResponse(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);
+ }
+ }
+
+ 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);
+ }
+
@Override
public Map> getRecentScores(Collection userIds, int limit) {
JsonObject body = new JsonObject();
@@ -104,26 +126,4 @@ private HttpResponse send(String path, String body, HttpResponse.BodyHand
throw new RuntimeException("无法连接 oStella", e);
}
}
-
- private static void ensureSuccessfulResponse(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);
- }
- }
-
- 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);
- }
}
diff --git a/src/main/java/xyz/zcraft/seira/watch/QqMultiplayerRoomNotifier.java b/src/main/java/xyz/zcraft/seira/watch/QqMultiplayerRoomNotifier.java
index 255af82e..bd2f6639 100644
--- a/src/main/java/xyz/zcraft/seira/watch/QqMultiplayerRoomNotifier.java
+++ b/src/main/java/xyz/zcraft/seira/watch/QqMultiplayerRoomNotifier.java
@@ -29,7 +29,7 @@ public boolean sendResult(String groupId, byte[] imageBytes) {
Message message = new Message();
message.setMsgType(PendingMessage.MSG_TYPE_MEDIA);
message.setMedia(media);
- return messageSender.sendGroupMessage(groupId, message);
+ return messageSender.sendGroupMessage(groupId, message) != null;
}
@Override
@@ -37,6 +37,6 @@ public boolean sendRoomEnded(String groupId, RoomWatchSnapshot snapshot) {
return messageSender.sendGroupText(
groupId,
"多人房间“" + snapshot.roomName() + "” (#" + snapshot.roomId() + ") 已结束,监视已自动停止。"
- );
+ ) != null;
}
}
diff --git a/src/main/java/xyz/zcraft/seira/watch/ScoreWatchService.java b/src/main/java/xyz/zcraft/seira/watch/ScoreWatchService.java
index ae6ffaa2..d0631a6d 100644
--- a/src/main/java/xyz/zcraft/seira/watch/ScoreWatchService.java
+++ b/src/main/java/xyz/zcraft/seira/watch/ScoreWatchService.java
@@ -6,15 +6,7 @@
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
-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.*;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
@@ -61,6 +53,47 @@ public ScoreWatchService(
});
}
+ private static List scoresAfter(List scores, Long lastScoreId) {
+ if (scores.isEmpty()) {
+ return List.of();
+ }
+ if (lastScoreId == null) {
+ return List.copyOf(scores);
+ }
+ for (int index = 0; index < scores.size(); index++) {
+ if (scores.get(index).scoreId() == lastScoreId) {
+ return List.copyOf(scores.subList(0, index));
+ }
+ }
+ return List.copyOf(scores);
+ }
+
+ private static WatchView view(WatchEntry entry, Instant now) {
+ Duration remaining = Duration.between(now, entry.expiresAt);
+ return new WatchView(entry.target, remaining.isNegative() ? Duration.ZERO : remaining);
+ }
+
+ private static Duration requirePositive(Duration duration, String name) {
+ if (duration == null || duration.isZero() || duration.isNegative()) {
+ throw new IllegalArgumentException(name + " must be positive");
+ }
+ return duration;
+ }
+
+ private static Set requirePositiveIds(Set ids, String name) {
+ if (ids == null || ids.isEmpty()) {
+ throw new IllegalArgumentException(name + " must not be empty");
+ }
+ LinkedHashSet result = new LinkedHashSet<>();
+ for (Long id : ids) {
+ if (id == null || id <= 0) {
+ throw new IllegalArgumentException(name + " must contain only positive IDs");
+ }
+ result.add(id);
+ }
+ return Set.copyOf(result);
+ }
+
public void start() {
if (closed.get()) {
throw new IllegalStateException("Score watch service has been closed");
@@ -176,7 +209,9 @@ public Map> listAll() {
}
}
- /** Returns groups whose temporary score watches will be lost when this process stops. */
+ /**
+ * Returns groups whose temporary score watches will be lost when this process stops.
+ */
public Set activeTransientGroupIds() {
synchronized (lock) {
removeExpiredLocked(clock.instant());
@@ -396,47 +431,6 @@ private void removeExpiredLocked(Instant now) {
emptyGroups.forEach(watchesByGroup::remove);
}
- private static List scoresAfter(List scores, Long lastScoreId) {
- if (scores.isEmpty()) {
- return List.of();
- }
- if (lastScoreId == null) {
- return List.copyOf(scores);
- }
- for (int index = 0; index < scores.size(); index++) {
- if (scores.get(index).scoreId() == lastScoreId) {
- return List.copyOf(scores.subList(0, index));
- }
- }
- return List.copyOf(scores);
- }
-
- private static WatchView view(WatchEntry entry, Instant now) {
- Duration remaining = Duration.between(now, entry.expiresAt);
- return new WatchView(entry.target, remaining.isNegative() ? Duration.ZERO : remaining);
- }
-
- private static Duration requirePositive(Duration duration, String name) {
- if (duration == null || duration.isZero() || duration.isNegative()) {
- throw new IllegalArgumentException(name + " must be positive");
- }
- return duration;
- }
-
- private static Set requirePositiveIds(Set ids, String name) {
- if (ids == null || ids.isEmpty()) {
- throw new IllegalArgumentException(name + " must not be empty");
- }
- LinkedHashSet result = new LinkedHashSet<>();
- for (Long id : ids) {
- if (id == null || id <= 0) {
- throw new IllegalArgumentException(name + " must contain only positive IDs");
- }
- result.add(id);
- }
- return Set.copyOf(result);
- }
-
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
@@ -459,11 +453,7 @@ private WatchEntry(WatchTarget target, Instant expiresAt, Long lastScoreId) {
private record WatchRef(String groupId, WatchEntry entry) {
}
- private static final class SpecificWatchEntry {
- private final Set userIds;
- private final Set beatmapIds;
- private final Map lastScoreIds;
-
+ private record SpecificWatchEntry(Set userIds, Set beatmapIds, Map lastScoreIds) {
private SpecificWatchEntry(Set