diff --git a/README.md b/README.md index 5b0ae5d9..cde85722 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 image +每局结算后自动保存战绩。在群内使用 `/rg stats` 查询自己的本群战绩,使用 `/rg stats all` 查询自己的全部群聊战绩。 +统计包括参与数、获胜数、胜率、前 20% 次数和达成率、平均分、最高分、总得分及平均名次。 +每人每局只统计最终猜测,获胜按最终排行榜第一名计算;前 20% 名额向上取整,仅统计至少 5 人参与的对局,达成率以这些对局为分母。 +战绩与用户绑定共用配置中的 SQLite 数据库,分数采用最终结算得分;历史未记录的对局不会自动补入。 + ### 查询群友的排行榜! image @@ -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 userIds, Set beatmapIds, Map lastScoreIds) { this.userIds = userIds; this.beatmapIds = beatmapIds; diff --git a/src/main/java/xyz/zcraft/seira/watch/SpecificScoreNotifier.java b/src/main/java/xyz/zcraft/seira/watch/SpecificScoreNotifier.java index 40097be9..1f6404cd 100644 --- a/src/main/java/xyz/zcraft/seira/watch/SpecificScoreNotifier.java +++ b/src/main/java/xyz/zcraft/seira/watch/SpecificScoreNotifier.java @@ -14,6 +14,6 @@ public SpecificScoreNotifier(MessageSender messageSender) { } public boolean sendScoreId(String groupId, long scoreId) { - return messageSender.sendGroupMarkdown(groupId, "捕获到成绩 ID:" + cmd("/s " + scoreId, String.valueOf(scoreId))); + return messageSender.sendGroupMarkdown(groupId, "捕获到成绩 ID:" + cmd("/s " + scoreId, String.valueOf(scoreId))) != null; } } diff --git a/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchState.java b/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchState.java index ab42a855..543ea080 100644 --- a/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchState.java +++ b/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchState.java @@ -1,12 +1,10 @@ package xyz.zcraft.seira.watch; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Objects; -import java.util.Set; +import java.util.*; -/** Persisted state for one group's /wx watch. */ +/** + * Persisted state for one group's /wx watch. + */ public record SpecificScoreWatchState( String groupId, Set userIds, diff --git a/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchStore.java b/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchStore.java index 17d2c0df..99de98f2 100644 --- a/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchStore.java +++ b/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchStore.java @@ -10,26 +10,4 @@ public interface SpecificScoreWatchStore { boolean delete(String groupId); void updateLastScoreId(String groupId, long userId, long scoreId); - - static SpecificScoreWatchStore none() { - return new SpecificScoreWatchStore() { - @Override - public List loadAll() { - return List.of(); - } - - @Override - public void save(SpecificScoreWatchState state) { - } - - @Override - public boolean delete(String groupId) { - return false; - } - - @Override - public void updateLastScoreId(String groupId, long userId, long scoreId) { - } - }; - } } diff --git a/src/main/java/xyz/zcraft/seira/watch/SqliteSpecificScoreWatchStore.java b/src/main/java/xyz/zcraft/seira/watch/SqliteSpecificScoreWatchStore.java index ddc58ed5..9537db7e 100644 --- a/src/main/java/xyz/zcraft/seira/watch/SqliteSpecificScoreWatchStore.java +++ b/src/main/java/xyz/zcraft/seira/watch/SqliteSpecificScoreWatchStore.java @@ -1,6 +1,6 @@ package xyz.zcraft.seira.watch; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.UserDataStore; import java.util.List; diff --git a/src/main/java/xyz/zcraft/seira/watch/WatchApi.java b/src/main/java/xyz/zcraft/seira/watch/WatchApi.java index bd97bbec..30bc9d11 100644 --- a/src/main/java/xyz/zcraft/seira/watch/WatchApi.java +++ b/src/main/java/xyz/zcraft/seira/watch/WatchApi.java @@ -4,7 +4,9 @@ import java.util.List; import java.util.Map; -/** Backend boundary used by the score watch domain service. */ +/** + * Backend boundary used by the score watch domain service. + */ public interface WatchApi { Map> getRecentScores(Collection userIds, int limit); diff --git a/src/main/java/xyz/zcraft/seira/watch/WatchScoreNotifier.java b/src/main/java/xyz/zcraft/seira/watch/WatchScoreNotifier.java index 888c66b8..9003f73c 100644 --- a/src/main/java/xyz/zcraft/seira/watch/WatchScoreNotifier.java +++ b/src/main/java/xyz/zcraft/seira/watch/WatchScoreNotifier.java @@ -14,7 +14,7 @@ public final class WatchScoreNotifier { public WatchScoreNotifier(MessageSender messageSender) { this.messageSender = Objects.requireNonNull(messageSender); } - + public boolean sendScore(String groupId, byte[] imageBytes) { String base64 = Base64.getEncoder().encodeToString(imageBytes); FileInfo media = messageSender.uploadGroupMediaBase64( @@ -29,6 +29,6 @@ public boolean sendScore(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; } }