diff --git a/.idea/misc.xml b/.idea/misc.xml index 87a20fc..fe0b0da 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,9 @@ + + + diff --git a/README.md b/README.md index e5f3f8c..3ebec67 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,10 @@ - [x] 玩家间聊天 - [x] 聊天管理命令 - [x] 发言频率限制 +- [x] 聊天api (OneBot) +- [x] 玩家上下线提醒 +- [ ] 指定管理账号在群内执行控制台命令 - [ ] 聊天内容审查 -- [ ] 聊天api _(~~OneBot api~~)_ -- [ ] ... ## 安装 @@ -39,37 +40,125 @@ - `/serverchat unban @uid` 解除指定玩家禁言 - `/serverchat limit <次每分钟>` 设置发消息频率限制 - `/serverchat reload` 重载配置文件 +- `/serverchat group ` 设置互联群号 -`/serverchat` 可用别名 `/sc` +`/serverchat` 可用别名 `/sc`,例如 `/sc ban @xxx` +## 群服互联 -## 配置 +推荐使用 [go-cqhttp](https://github.com/Mrs4s/go-cqhttp) ,[快速开始](https://docs.go-cqhttp.org/guide/quick_start.htm) + +![群服互联聊天示例](/doc/Chat-OneBot.png) + +除了登录设置外,需[配置](https://docs.go-cqhttp.org/guide/config.html) `config.yml` 中以下内容 +- `access-token: ''` 为插件配置中的token +- `ws-reverse:` + - `universal: ws://your_websocket_universal.server` 为OpenChat地址,例如 `ws://127.0.0.1:443/openchat` + +建议使用 `Android Watch` 协议登录(在 `device.json` 中 `"protocol": 5` 修改为 `"protocol": 2` ) + +--- + +## 插件配置 ```json5 { // 服务器聊天开关 - serverChatEnabled: true, + "serverChatEnabled": true, // 服务器聊天消息格式 // {nickName} 为玩家昵称 // {uid} 为玩家UID // {message} 为消息内容 - serverChatFormat: "{nickName}({uid}): {message}", + "serverChatFormat": "{nickName}({uid}): {message}", // 每分钟发言消息数限制 - messageFreLimitPerMinute: 20, + "messageFreLimitPerMinute": 20, // 是否在玩家加入时发送消息 - sendJoinMessage: true, + "sendJoinMessage": true, // 玩家加入时发送消息 - joinMessage: "本服已启用聊天,/chat on 开启(默认),/chat off 屏蔽", + "joinMessage": "本服已启用聊天,/chat on 开启(默认),/chat off 屏蔽", // 被禁言反馈消息 - bannedFeedback: "你已经被禁言!", + "bannedFeedback": "你已经被禁言!", // 消息太频繁反馈消息 // {limit} 服务器设置的限制次数 - msgTooFrequentFeedback: "服务器设置每分钟仅允许发言{limit}次" + "msgTooFrequentFeedback": "服务器设置每分钟仅允许发言{limit}次", + + // 是否将聊天log + "logChat": true, + + // WebSocket Access Token + // 安全令牌,仅允许授权的连接 + // 如果为空将会在启动时自动生成一个32位随机令牌并显示在控制台 + "wsToken": "", + + // WebSocket Path + // 反向WS的路径,即机器人连接到本插件开放的WS接口路径 + // 若不想开放WS,则留空,默认为 /openchat + // OneBot设置示例:ws://127.0.0.1:443/openchat + "wsPath": "/openchat", + + // // WebSocket Address + // // 正向WS的地址,即本插件主动连接机器人开放的WS接口地址 + // // 示例:ws://127.0.0.1:8080 + // // 若不需要,则留空 + // // TODO:由于需要引入外部依赖,正向WS方式暂不实现 + // public String wsAddress: "", + + // 群ID + // 可以使用指令 `/sc group ` 设定 + "groupId": 0, + + // 群消息格式化 + // {id} 为QQ号 + // {name} 为群名片,如果为空则显示昵称 + // {message} 为消息 + "groupToGameFormat": "[QQ]{name}: {message}", + + // 服务器聊天消息格式 + // {nickName} 为玩家昵称 + // {uid} 为玩家UID + // {message} 为消息内容 + "gameToGroupFormat": "[GC]{nickName}({uid}): {message}", + + // /** + // * 频道ID + // */ + // public String guildId: "", + // /** + // * 子频道ID集 + // */ + // public List channelIds: new ArrayList<>(), + + // 是否将游戏里的聊天转发到群聊 + "isSendToBot": true, + + // 是否接收群消息并发送到游戏里 + "isSendToGame": true, + + // // 管理员账号 + // "adminId": 0, + + // 是否启用登录消息 + // 当玩家登录服务器时,发送消息通知到群里 + "sendLoginMessageToBot": true, + + // 玩家登录服务器消息格式 + // {nickName} 为玩家昵称 + // {uid} 为玩家UID + "loginMessageFormat": "{nickName}({uid}) 加入了服务器", + + // 是否启用登出消息 + // 当玩家离开服务器时,发送消息通知到群里 + "sendLogoutMessageToBot": true, + + // 玩家登出服务器消息格式 + // {nickName} 为玩家昵称 + // {uid} 为玩家UID + "logoutMessageFormat": "{nickName}({uid}) 离开了服务器", } ``` diff --git a/build.gradle b/build.gradle index 644710c..346d70b 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { } group 'com.github.jie65535.openchat' -version 'dev-0.1.0' +version 'dev-0.2.0' repositories { mavenCentral() diff --git a/doc/Chat-OneBot.png b/doc/Chat-OneBot.png new file mode 100644 index 0000000..bd03d9f Binary files /dev/null and b/doc/Chat-OneBot.png differ diff --git a/src/main/java/com/github/jie65535/minionebot/MiniOneBot.java b/src/main/java/com/github/jie65535/minionebot/MiniOneBot.java new file mode 100644 index 0000000..dfaa836 --- /dev/null +++ b/src/main/java/com/github/jie65535/minionebot/MiniOneBot.java @@ -0,0 +1,286 @@ +/* + * MiniOneBot + * Copyright (C) 2023 jie65535 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.github.jie65535.minionebot; + +import com.google.gson.JsonObject; +import emu.grasscutter.utils.JsonUtils; +import io.javalin.Javalin; +import org.slf4j.Logger; +import com.github.jie65535.minionebot.events.GroupMessage; +import com.github.jie65535.minionebot.events.GroupMessageHandler; +import com.github.jie65535.minionebot.events.GuildChannelMessage; +import com.github.jie65535.minionebot.events.GuildChannelMessageHandler; + +import java.io.IOException; +import java.util.Objects; +import java.util.regex.Pattern; + +public class MiniOneBot implements WsStream.WsMessageHandler { + private final Logger logger; + private final Javalin javalin; + private final String token; + private MiniOneBotWsServer server; +// private MiniOneBotWsClient client; + + public MiniOneBot(Javalin javalin, String token, Logger logger) { + this.javalin = javalin; + this.token = token; + this.logger = logger; + } + + // region WebSocket + + public void startWsServer(String path) { + if (server == null) { + logger.debug("Start MiniOneBot WebSocket Server"); + server = new MiniOneBotWsServer(javalin, path, token, logger); + server.subscribe(this); + } + } + +// public void startWsClient(URI serverUri) { +// if (client == null) { +// logger.info("Start MiniOneBot WebSocket Client"); +// client = MiniOneBotWsClient.create(serverUri, token, logger); +// client.subscribe(this); +// } +// } + + public void stop() { +// if (client != null) { +// client.close(); +// } + if (server != null) { + try { + server.close(); + } catch (IOException e) { + logger.error("Stop MiniOneBot WebSocket Server Failed!", e); + } + } + } + + private void sendMessageToAll(String message) { + logger.debug("Sending... message=\"{}\"", message); + server.send(message); +// client.send(message); + } + + // endregion + + // region OneBot message + + @Override + public void onMessage(String message) { + var map = JsonUtils.decode(message, JsonObject.class); + if (!map.has("post_type")) return; + var postType = map.get("post_type").getAsString(); + // 消息事件上报 + if (Objects.equals(postType, "message")) { + var messageType = map.get("message_type").getAsString(); + var subType = map.get("sub_type").getAsString(); + + // 发送者信息 https://docs.go-cqhttp.org/reference/data_struct.html#post-message-messagesender + var sender = map.get("sender").getAsJsonObject(); + var senderId = sender.get("user_id").getAsLong(); + var senderNickname = sender.get("nickname").getAsString(); + + // 群消息上报 https://docs.go-cqhttp.org/event/#%E7%BE%A4%E6%B6%88%E6%81%AF + if (Objects.equals(messageType, "group") + && Objects.equals(subType, "normal")) { + var groupId = map.get("group_id").getAsLong(); +// var message = (List>)map.get("message"); + var rawMessage = map.get("raw_message").getAsString(); + + var senderCard = sender.get("card").getAsString(); + var senderLevel = sender.get("level").getAsString(); + var senderRole = sender.get("role").getAsString(); + var senderCardOrNickname = senderCard == null || senderCard.isEmpty() ? senderNickname : senderCard; + var senderTitle = sender.get("title").getAsString(); + onGroupMessage(groupId, handleRawMessage(rawMessage), senderId, senderCardOrNickname, senderLevel, senderRole, senderTitle); + } + // 频道消息上报 https://docs.go-cqhttp.org/event/guild.html#%E6%94%B6%E5%88%B0%E9%A2%91%E9%81%93%E6%B6%88%E6%81%AF + else if (Objects.equals(messageType, "guild") + && Objects.equals(subType, "channel")) { + var guildId = map.get("guild_id").getAsString(); + var channelId = map.get("channel_id").getAsString(); + var rawMessage = map.get("message").getAsString(); // 需要 Message 消息链类型处理 + var tinyId = map.get("user_id").getAsString(); + onGuildMessage(guildId, channelId, handleRawMessage(rawMessage), tinyId, senderNickname); + } + } + } + + /** + * 当收到群消息时触发 + * @param groupId 群号 + * @param message 消息 + * @param senderId 发送者ID + * @param senderCardOrNickname 发送者群名片或昵称,名片为空时是昵称 + * @param senderLevel 发送者群等级 + * @param senderRole 发送者群身份 + * @param senderTitle 发送者群专属头衔 + */ + private void onGroupMessage(long groupId, + String message, + long senderId, + String senderCardOrNickname, + String senderLevel, + String senderRole, + String senderTitle) { + logger.debug("groupId={}, message={}, senderId={}, senderCardOrNickname={}, senderLevel={}, senderRole={}, senderTitle={}", + groupId, message, senderId, senderCardOrNickname, senderLevel, senderRole, senderTitle); + groupMessageHandler.handleGroupMessage(new GroupMessage(groupId, message, senderId, senderCardOrNickname, senderLevel, senderRole, senderTitle)); + } + + /** + * 当收到频道消息时触发 + * @param guildId 频道Id + * @param channelId 子频道Id + * @param message 消息 + * @param senderId 发送者Id + * @param senderName 发送者昵称 + */ + private void onGuildMessage(String guildId, String channelId, String message, String senderId, String senderName) { + logger.debug("guildId={}, channelId={}, message={}, senderId={}, senderNickname={}", + guildId, channelId, message, senderId, senderName); + guildChannelMessageHandler.handleGuildChannelMessage(new GuildChannelMessage(guildId, channelId, message, senderId, senderName)); + } + + // endregion + + // region Message API + + // region Models + private static class Action { + public String action; + public Object params; + public Action(String action, Object params) { + this.action = action; + this.params = params; + } + } + + private static class SendGroupMsgArgs { + public long group_id; + public String message; + public boolean auto_escape = true; + public SendGroupMsgArgs(long groupId, String message) { + this.group_id = groupId; + this.message = message; + } + } + + private static class SendGuildChannelMsgArgs { + public String guild_id; + public String channel_id; + public String message; + public boolean auto_escape = true; + public SendGuildChannelMsgArgs(String guildId, String channelId, String message) { + this.guild_id = guildId; + this.channel_id = channelId; + this.message = message; + } + } + // endregion + + /** + * 发送消息到群 + * @param groupId 群号 + * @param message 消息 + */ + public void sendGroupMessage(long groupId, String message) { + sendMessageToAll(JsonUtils.encode(new Action("send_group_msg", new SendGroupMsgArgs(groupId, message)))); + } + + /** + * 发送消息到子频道 + * @param guildId 频道ID + * @param channelId 子频道ID + * @param message 消息 + */ + public void sendGuildChannelMessage(String guildId, String channelId, String message) { + sendMessageToAll(JsonUtils.encode(new Action("send_guild_channel_msg", new SendGuildChannelMsgArgs(guildId, channelId, message)))); + } + + GroupMessageHandler groupMessageHandler; + + GuildChannelMessageHandler guildChannelMessageHandler; + + /** + * 订阅群消息事件 + * @param handler 群消息处理器 + */ + public void subscribeGroupMessageEvent(GroupMessageHandler handler) { + groupMessageHandler = handler; + } + + /** + * 订阅频道消息事件 + * @param handler 频道消息处理器 + */ + public void subscribeGuildChannelMessageEvent(GuildChannelMessageHandler handler) { + guildChannelMessageHandler = handler; + } + + // endregion + + // region Utils + + private static final Pattern cqCodePattern = Pattern.compile("\\[CQ:(\\w+).*?]"); + + private static String handleRawMessage(String rawMessage) { + if (rawMessage.indexOf('[') == -1) + return unescape(rawMessage); + var message = new StringBuilder(); + var matcher = cqCodePattern.matcher(rawMessage); + while (matcher.find()) { + var type = matcher.group(1); + var replacement = switch (type) { + case "image" -> "[图片]"; + case "reply" -> "[回复]"; + case "at" -> "[@]"; + case "record" -> "[语音]"; + case "forward" -> "[合并转发]"; + case "video" -> "[视频]"; + case "music" -> "[音乐]"; + case "redbag" -> "[红包]"; + case "poke" -> "[戳一戳]"; + default -> ""; + }; + matcher.appendReplacement(message, replacement); + } + matcher.appendTail(message); + return unescape(message.toString()); + } + +// private static String escape(String msg) { +// return msg.replace("&", "&") +// .replace("[", "[") +// .replace("]", "]") +// .replace(",", ","); +// } + + private static String unescape(String msg) { + return msg.replace("&", "&") + .replace("[", "[") + .replace("]", "]") + .replace(",", ","); + } + + // endregion +} diff --git a/src/main/java/com/github/jie65535/minionebot/MiniOneBotWsClient.java b/src/main/java/com/github/jie65535/minionebot/MiniOneBotWsClient.java new file mode 100644 index 0000000..1012632 --- /dev/null +++ b/src/main/java/com/github/jie65535/minionebot/MiniOneBotWsClient.java @@ -0,0 +1,90 @@ +/* + * MiniOneBot + * Copyright (C) 2023 jie65535 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +//package com.github.jie65535.minionebot; +// +//import org.java_websocket.client.WebSocketClient; +//import org.java_websocket.handshake.ServerHandshake; +//import org.slf4j.Logger; +// +//import java.net.URI; +//import java.util.HashMap; +//import java.util.Map; +//import java.util.Timer; +//import java.util.TimerTask; +// +//public class MiniOneBotWsClient extends WebSocketClient implements WsStream { +// private final Logger logger; +// +// private MiniOneBotWsClient(URI serverUri, Map headers, Logger logger) { +// super(serverUri, headers); +// +// this.logger = logger; +// } +// +// public static MiniOneBotWsClient create(URI serverUri, String token, Logger logger) { +// var headers = new HashMap(); +// headers.put("Authorization", "Bearer " + token); +// var client = new MiniOneBotWsClient(serverUri, headers, logger); +// var wsClientDaemon = new Timer("WsClientDaemon", true); +// wsClientDaemon.schedule(new TimerTask() { +// @Override +// public void run() { +// if (!client.isOpen()) { +// logger.debug("Try connect..."); +// client.connect(); +// } +// } +// }, 5_000); +// return client; +// } +// +// private WsMessageHandler callback; +// +// @Override +// public void subscribe(WsMessageHandler callback) { +// this.callback = callback; +// } +// +// @Override +// public void onOpen(ServerHandshake handshakedata) { +// logger.info("onOpen: statusMessage={}", handshakedata.getHttpStatusMessage()); +// } +// +// @Override +// public void onMessage(String message) { +// logger.info("onMessage: {}", message); +// callback.onMessage(message); +// } +// +// @Override +// public void onClose(int code, String reason, boolean remote) { +// logger.info("onClose: code={} reason={} isRemote={}", code, reason, remote); +// } +// +// @Override +// public void onError(Exception ex) { +// logger.error("onError:", ex); +// } +// +// @Override +// public void send(String message) { +// if (isOpen()) { +// super.send(message); +// } +// } +//} diff --git a/src/main/java/com/github/jie65535/minionebot/MiniOneBotWsServer.java b/src/main/java/com/github/jie65535/minionebot/MiniOneBotWsServer.java new file mode 100644 index 0000000..d6b46dd --- /dev/null +++ b/src/main/java/com/github/jie65535/minionebot/MiniOneBotWsServer.java @@ -0,0 +1,123 @@ +/* + * MiniOneBot + * Copyright (C) 2023 jie65535 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.github.jie65535.minionebot; + +import io.javalin.Javalin; +import io.javalin.websocket.*; +import org.eclipse.jetty.websocket.api.CloseStatus; +import org.slf4j.Logger; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class MiniOneBotWsServer implements WsStream, Closeable { + + private final String token; + private final Logger logger; + private final Map connections = new ConcurrentHashMap<>(); + + public MiniOneBotWsServer(Javalin javalin, String path, String token, Logger logger) { + this.token = token; + this.logger = logger; + javalin.ws(path, ws -> { + ws.onConnect(this::onConnect); + ws.onClose(this::onClose); + ws.onError(this::onError); + ws.onMessage(this::onMessage); + }); + + logger.info("WebSocket server started at {}", path); + } + + public void onConnect(WsConnectContext ctx) { + logger.info("onConnect: address={} headers={}", ctx.session.getRemoteAddress(), ctx.headerMap()); + var author = ctx.header("Authorization"); + // Check access token. + if (author == null) { + logger.warn("The connection was closed because the request did not contain an authorization token"); + ctx.session.close(new CloseStatus(401, "Unauthorized")); + } else if (!author.equals("Bearer " + token) && !author.equals("Token " + token)) { + logger.warn("Connection closed due to incorrect authorization token in the request"); + ctx.session.close(new CloseStatus(403, "Unauthorized")); + } else { + var selfId = ctx.header("X-Self-ID"); + if (selfId != null && !selfId.isEmpty()) { + logger.info("Bot [{}] WebSocket connected", selfId); + } else { + logger.info("[{}] WebSocket connected", ctx.session.getRemoteAddress()); + } + connections.put(ctx, selfId); + } + } + + public void onClose(WsCloseContext ctx) { + logger.debug("onClose: address={} status={} reason={}", ctx.session.getRemoteAddress(), ctx.status(), ctx.reason()); + var selfId = connections.remove(ctx); + if (selfId != null && !selfId.isEmpty()) { + logger.warn("Bot [{}] WebSocket disconnected, status={} reason={}", selfId, ctx.status(), ctx.reason()); + } else { + logger.warn("[{}] WebSocket disconnected, status={} reason={}", ctx.session.getRemoteAddress(), ctx.status(), ctx.reason()); + } + } + + public void onError(WsErrorContext ctx) { + logger.debug("onError: address={}", ctx.session.getRemoteAddress(), ctx.error()); + var selfId = connections.remove(ctx); + if (selfId != null && !selfId.isEmpty()) { + logger.warn("Bot [{}] WebSocket disconnected", selfId, ctx.error()); + } else { + logger.warn("[{}] WebSocket disconnected", ctx.session.getRemoteAddress(), ctx.error()); + } + } + + public void onMessage(WsMessageContext ctx) { + logger.debug("onMessage: {}", ctx.message()); + + callback.onMessage(ctx.message()); + } + + private WsMessageHandler callback; + + @Override + public void subscribe(WsMessageHandler callback) { + this.callback = callback; + } + + @Override + public void send(String message) { + if (connections.isEmpty()) return; + for (var ctx : connections.keySet()) { + if (ctx.session.isOpen()) { + ctx.send(message); + } + } + } + + @Override + public void close() throws IOException { + if (connections.isEmpty()) return; + for (var ctx : connections.keySet()) { + if (ctx.session.isOpen()) { + ctx.session.close(1001, "Service stopped"); + } + } + connections.clear(); + } +} diff --git a/src/main/java/com/github/jie65535/minionebot/WsStream.java b/src/main/java/com/github/jie65535/minionebot/WsStream.java new file mode 100644 index 0000000..4e8cfd1 --- /dev/null +++ b/src/main/java/com/github/jie65535/minionebot/WsStream.java @@ -0,0 +1,28 @@ +/* + * MiniOneBot + * Copyright (C) 2023 jie65535 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.github.jie65535.minionebot; + +public interface WsStream { + void subscribe(WsMessageHandler callback); + + void send(String message); + + interface WsMessageHandler { + void onMessage(String message); + } +} diff --git a/src/main/java/com/github/jie65535/minionebot/events/GroupMessage.java b/src/main/java/com/github/jie65535/minionebot/events/GroupMessage.java new file mode 100644 index 0000000..192284f --- /dev/null +++ b/src/main/java/com/github/jie65535/minionebot/events/GroupMessage.java @@ -0,0 +1,29 @@ +/* + * MiniOneBot + * Copyright (C) 2023 jie65535 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.github.jie65535.minionebot.events; + +public record GroupMessage( + long groupId, + String message, + long senderId, + String senderCardOrNickname, + String senderLevel, + String senderRole, + String senderTitle +) { +} diff --git a/src/main/java/com/github/jie65535/minionebot/events/GroupMessageHandler.java b/src/main/java/com/github/jie65535/minionebot/events/GroupMessageHandler.java new file mode 100644 index 0000000..79bc29f --- /dev/null +++ b/src/main/java/com/github/jie65535/minionebot/events/GroupMessageHandler.java @@ -0,0 +1,22 @@ +/* + * MiniOneBot + * Copyright (C) 2023 jie65535 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.github.jie65535.minionebot.events; + +public interface GroupMessageHandler { + void handleGroupMessage(GroupMessage event); +} diff --git a/src/main/java/com/github/jie65535/minionebot/events/GuildChannelMessage.java b/src/main/java/com/github/jie65535/minionebot/events/GuildChannelMessage.java new file mode 100644 index 0000000..e15e110 --- /dev/null +++ b/src/main/java/com/github/jie65535/minionebot/events/GuildChannelMessage.java @@ -0,0 +1,27 @@ +/* + * MiniOneBot + * Copyright (C) 2023 jie65535 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.github.jie65535.minionebot.events; + +public record GuildChannelMessage( + String guildId, + String channelId, + String message, + String senderId, + String senderName +) { +} diff --git a/src/main/java/com/github/jie65535/minionebot/events/GuildChannelMessageHandler.java b/src/main/java/com/github/jie65535/minionebot/events/GuildChannelMessageHandler.java new file mode 100644 index 0000000..dad308b --- /dev/null +++ b/src/main/java/com/github/jie65535/minionebot/events/GuildChannelMessageHandler.java @@ -0,0 +1,22 @@ +/* + * MiniOneBot + * Copyright (C) 2023 jie65535 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.github.jie65535.minionebot.events; + +public interface GuildChannelMessageHandler { + void handleGuildChannelMessage(GuildChannelMessage event); +} diff --git a/src/main/java/com/github/jie65535/openchat/EventListeners.java b/src/main/java/com/github/jie65535/openchat/EventListeners.java index 06a3da6..1aa5bd7 100644 --- a/src/main/java/com/github/jie65535/openchat/EventListeners.java +++ b/src/main/java/com/github/jie65535/openchat/EventListeners.java @@ -17,28 +17,13 @@ */ package com.github.jie65535.openchat; -//import emu.grasscutter.server.event.player.PlayerJoinEvent; -//import emu.grasscutter.server.scheduler.ServerTaskScheduler; +import emu.grasscutter.server.event.player.PlayerJoinEvent; -//public final class EventListeners { -// private static final OpenChatPlugin plugin = OpenChatPlugin.getInstance(); -// private static final OpenChatConfig config = OpenChatPlugin.getInstance().getConfig(); - -// public static void onJoin(PlayerJoinEvent event) { -// // 检查聊天系统是否被其它插件替换 -// // 不再检查 -//// if (!(plugin.getServer().getChatSystem() instanceof OpenChatSystem)) { -//// plugin.getLogger().warn("聊天系统已被其它插件更改,现已重置为 OpenChat !"); -//// plugin.getServer().setChatSystem(new OpenChatSystem(plugin)); -//// } -// -// if (!config.sendJoinMessage || config.joinMessage.isEmpty()) -// return; -// var player = event.getPlayer(); -// plugin.getLogger().debug(String.format("Player %s(%d) joined the game, send join message.", -// player.getNickname(), player.getUid())); -// plugin.getServer().getScheduler().scheduleDelayedTask(() -> { -// if (player.isOnline()) player.dropMessage(config.joinMessage); -// }, 60); -// } -//} +public final class EventListeners { + public static void onJoin(PlayerJoinEvent event) { + var cs = OpenChatPlugin.getInstance().getServer().getChatSystem(); + if (cs instanceof OpenChatSystem) { + ((OpenChatSystem) cs).onPlayerJoin(event); + } + } +} diff --git a/src/main/java/com/github/jie65535/openchat/OpenChatConfig.java b/src/main/java/com/github/jie65535/openchat/OpenChatConfig.java index e94f549..9c70084 100644 --- a/src/main/java/com/github/jie65535/openchat/OpenChatConfig.java +++ b/src/main/java/com/github/jie65535/openchat/OpenChatConfig.java @@ -57,4 +57,104 @@ public class OpenChatConfig { * {limit} 服务器设置的限制次数 */ public String msgTooFrequentFeedback = "服务器设置每分钟仅允许发言{limit}次"; + + /** + * 将聊天log + */ + public boolean logChat = true; + + /** + * WebSocket Access Token + * 安全令牌,仅允许授权的连接 + * 如果为空将会在启动时自动生成一个32位随机令牌并显示在控制台 + */ + public String wsToken = ""; + + /** + * WebSocket Path + * 反向WS的路径,即机器人连接到本插件开放的WS接口路径 + * 若不想开放WS,则留空,默认为 /openchat + * OneBot设置示例:ws://127.0.0.1:443/openchat + */ + public String wsPath = "/openchat"; + +// /** +// * WebSocket Address +// * 正向WS的地址,即本插件主动连接机器人开放的WS接口地址 +// * 示例:ws://127.0.0.1:8080 +// * 若不需要,则留空 +// * TODO:由于需要引入外部依赖,正向WS方式暂不实现 +// */ +// public String wsAddress = ""; + + /** + * 群ID + */ + public Long groupId = 0L; + + /** + * 群消息格式化 + * {id} 为QQ号 + * {name} 为群名片,如果为空则显示昵称 + * {message} 为消息 + */ + public String groupToGameFormat = "[QQ]{name}: {message}"; + + /** + * 服务器聊天消息格式 + * {nickName} 为玩家昵称 + * {uid} 为玩家UID + * {message} 为消息内容 + */ + public String gameToGroupFormat = "[GC]{nickName}({uid}): {message}"; + +// /** +// * 频道ID +// */ +// public String guildId = ""; +// /** +// * 子频道ID集 +// */ +// public List channelIds = new ArrayList<>(); + + /** + * 是否将游戏里的聊天转发到群聊 + */ + public boolean isSendToBot = true; + + /** + * 是否接收群消息并发送到游戏里 + */ + public boolean isSendToGame = true; + +// /** +// * 管理员账号 +// */ +// public Long adminId = 0L; + + /** + * 是否启用登录消息 + * 当玩家登录服务器时,发送消息通知到群里 + */ + public boolean sendLoginMessageToBot = true; + + /** + * 玩家登录服务器消息格式 + * {nickName} 为玩家昵称 + * {uid} 为玩家UID + */ + public String loginMessageFormat = "{nickName}({uid}) 加入了服务器"; + + /** + * 是否启用登出消息 + * 当玩家离开服务器时,发送消息通知到群里 + */ + public boolean sendLogoutMessageToBot = true; + + /** + * 玩家登出服务器消息格式 + * {nickName} 为玩家昵称 + * {uid} 为玩家UID + */ + public String logoutMessageFormat = "{nickName}({uid}) 离开了服务器"; } diff --git a/src/main/java/com/github/jie65535/openchat/OpenChatPlugin.java b/src/main/java/com/github/jie65535/openchat/OpenChatPlugin.java index d5ffd67..1b4012a 100644 --- a/src/main/java/com/github/jie65535/openchat/OpenChatPlugin.java +++ b/src/main/java/com/github/jie65535/openchat/OpenChatPlugin.java @@ -20,6 +20,9 @@ package com.github.jie65535.openchat; import com.github.jie65535.openchat.commands.ChatPlayerCommands; import com.github.jie65535.openchat.commands.ChatServerCommands; import emu.grasscutter.plugin.Plugin; +import emu.grasscutter.server.event.EventHandler; +import emu.grasscutter.server.event.HandlerPriority; +import emu.grasscutter.server.event.player.PlayerJoinEvent; import emu.grasscutter.utils.JsonUtils; import java.io.File; @@ -101,10 +104,10 @@ public final class OpenChatPlugin extends Plugin { @Override public void onEnable() { // Register event listeners. -// new EventHandler<>(PlayerJoinEvent.class) -// .priority(HandlerPriority.NORMAL) -// .listener(EventListeners::onJoin) -// .register(this); + new EventHandler<>(PlayerJoinEvent.class) + .priority(HandlerPriority.NORMAL) + .listener(EventListeners::onJoin) + .register(this); // Register commands. getHandle().registerCommand(new ChatServerCommands()); diff --git a/src/main/java/com/github/jie65535/openchat/OpenChatSystem.java b/src/main/java/com/github/jie65535/openchat/OpenChatSystem.java index c19a77c..a4ca5ef 100644 --- a/src/main/java/com/github/jie65535/openchat/OpenChatSystem.java +++ b/src/main/java/com/github/jie65535/openchat/OpenChatSystem.java @@ -17,64 +17,134 @@ */ package com.github.jie65535.openchat; +import com.github.jie65535.minionebot.MiniOneBot; +import com.github.jie65535.minionebot.events.GroupMessage; import emu.grasscutter.GameConstants; import emu.grasscutter.game.chat.ChatSystem; import emu.grasscutter.game.player.Player; +import emu.grasscutter.server.event.player.PlayerJoinEvent; +import emu.grasscutter.utils.Crypto; +import emu.grasscutter.utils.Utils; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import it.unimi.dsi.fastutil.ints.IntOpenHashSet; import it.unimi.dsi.fastutil.ints.IntSet; import it.unimi.dsi.fastutil.longs.LongArrayFIFOQueue; +import org.slf4j.Logger; public class OpenChatSystem extends ChatSystem { private final OpenChatPlugin plugin; + private final Logger logger; + private final MiniOneBot miniOneBot; + public OpenChatSystem(OpenChatPlugin plugin) { super(plugin.getServer()); this.plugin = plugin; - plugin.getLogger().debug("OpenChatSystem created."); + this.logger = plugin.getLogger(); + + // 获取HttpServer框架 + var javalin = plugin.getHandle().getHttpServer().getHandle(); + var token = plugin.getConfig().wsToken; + if (token == null || token.isEmpty()) { + token = Utils.base64Encode(Crypto.createSessionKey(24)); + plugin.getConfig().wsToken = token; + plugin.saveConfig(); + logger.warn("Detected that wsToken is empty, automatically generated Token for you as follows: {}", token); + } + // 构造MiniOneBot + miniOneBot = new MiniOneBot(javalin, token, logger); + // 启动WebSocket服务 + miniOneBot.startWsServer(plugin.getConfig().wsPath); + // 订阅群消息事件 + miniOneBot.subscribeGroupMessageEvent(this::onGroupMessage); } + /** + * 玩家进入服务器时触发 + * + * @param event 事件 + */ + public void onPlayerJoin(PlayerJoinEvent event) { + if (!plugin.getConfig().sendLoginMessageToBot || plugin.getConfig().groupId < 1) return; + var player = event.getPlayer(); + miniOneBot.sendGroupMessage(plugin.getConfig().groupId, plugin.getConfig().loginMessageFormat + .replace("{nickName}", player.getNickname()) + .replace("{uid}", String.valueOf(player.getUid()))); + } + + /** + * 用于标识玩家是否首次获取聊天记录 + */ IntSet hasHistory = new IntOpenHashSet(); + /** + * 在登出时清理聊天记录 + * + * @param player 登出玩家 + */ @Override public void clearHistoryOnLogout(Player player) { super.clearHistoryOnLogout(player); hasHistory.remove(player.getUid()); + + // 发送离线消息 + if (!plugin.getConfig().sendLogoutMessageToBot || plugin.getConfig().groupId < 1) return; + miniOneBot.sendGroupMessage(plugin.getConfig().groupId, plugin.getConfig().logoutMessageFormat + .replace("{nickName}", player.getNickname()) + .replace("{uid}", String.valueOf(player.getUid()))); } + /** + * 处理拉取聊天记录请求 + * + * @param player 拉取聊天记录的玩家 + */ @Override public void handlePullRecentChatReq(Player player) { super.handlePullRecentChatReq(player); if (!hasHistory.contains(player.getUid())) { + // 如果是首次拉取,则向玩家发送欢迎消息 hasHistory.add(player.getUid()); if (plugin.getConfig().sendJoinMessage && !plugin.getConfig().joinMessage.isEmpty()) { - plugin.getLogger().debug(String.format("send join message to %s(%d)", - player.getNickname(), player.getUid())); + logger.debug("send join message to {}({})", + player.getNickname(), player.getUid()); player.dropMessage(plugin.getConfig().joinMessage); } } } + /** + * 重载父类发送私聊消息方法 + * 将普通聊天消息接出由本系统处理 + * + * @param player 发言玩家 + * @param targetUid 目标玩家Uid + * @param message 消息内容 + */ @Override public void sendPrivateMessage(Player player, int targetUid, String message) { - plugin.getLogger().debug(String.format("onSendPrivateMessage: player=%s(%d) targetUid=%d message=%s", - player.getNickname(), player.getUid(), targetUid, message)); + logger.debug("onSendPrivateMessage: player={}({}) targetUid={} message={}", + player.getNickname(), player.getUid(), targetUid, message); // Sanity checks. if (message == null || message.length() == 0) { return; } + // 调用父类发送消息方法 super.sendPrivateMessage(player, targetUid, message); + // 如果目标不是服务器,或者消息是命令,则忽略 if (targetUid != GameConstants.SERVER_CONSOLE_UID || message.charAt(0) == '/' || message.charAt(0) == '!') { return; } + // 否则执行玩家任意消息方法 handlePlayerMessage(player, message); } /** * 处理玩家消息 - * @param player 玩家对象 + * + * @param player 玩家对象 * @param message 消息内容 */ private void handlePlayerMessage(Player player, String message) { @@ -85,8 +155,8 @@ public class OpenChatSystem extends ChatSystem { // 检测是否正在禁言中 if (checkIsBanning(player)) { - plugin.getLogger().warn(String.format("Message blocked (banning): player=%s(%d): \"%s\"", - player.getNickname(), player.getUid(), message)); + logger.warn("Message blocked (banning): player={}({}): \"{}\"", + player.getNickname(), player.getUid(), message); if (!plugin.getConfig().bannedFeedback.isEmpty()) { player.dropMessage(plugin.getConfig().bannedFeedback); } @@ -96,8 +166,8 @@ public class OpenChatSystem extends ChatSystem { // 处理发言频率限制 if (!checkMessageFre(player)) { // 可提示也可忽略,忽略可让玩家以为自己发送成功,其实别人看不到 - plugin.getLogger().warn(String.format("Message blocked (too often): player=%s(%d): \"%s\"", - player.getNickname(), player.getUid(), message)); + logger.warn("Message blocked (too often): player={}({}): \"{}\"", + player.getNickname(), player.getUid(), message); if (!plugin.getConfig().msgTooFrequentFeedback.isEmpty()) { player.dropMessage( plugin.getConfig().msgTooFrequentFeedback @@ -108,25 +178,80 @@ public class OpenChatSystem extends ChatSystem { // 处理发言内容审查 if (!checkMessageModeration(message)) { - plugin.getLogger().warn(String.format("Message blocked (moderation): player=%s(%d): \"%s\"", - player.getNickname(), player.getUid(), message)); + logger.warn("Message blocked (moderation): player={}({}): \"{}\"", + player.getNickname(), player.getUid(), message); return; } + // log messages + if (plugin.getConfig().logChat) { + logger.info("{}({}): \"{}\"", + player.getNickname(), player.getUid(), message); + } + // 格式化消息 - message = OpenChatPlugin.getInstance().getConfig().serverChatFormat + var formattedMessage = OpenChatPlugin.getInstance().getConfig().serverChatFormat .replace("{nickName}", player.getNickname()) .replace("{uid}", String.valueOf(player.getUid())) .replace("{message}", message); + // 转发给其它玩家 for (Player p : getServer().getPlayers().values()) { // 将消息发送给除了自己以外所有未关闭聊天的玩家 if (p != player && !plugin.getData().offChatPlayers.contains(p.getUid())) { + p.dropMessage(formattedMessage); + } + } + + // 转发到机器人 + if (!plugin.getConfig().isSendToBot || plugin.getConfig().groupId < 1) return; + miniOneBot.sendGroupMessage(plugin.getConfig().groupId, plugin.getConfig().gameToGroupFormat + .replace("{nickName}", player.getNickname()) + .replace("{uid}", String.valueOf(player.getUid())) + .replace("{message}", message)); + } + + /** + * 收到群消息时触发 + * + * @param event 群消息事件 + */ + private void onGroupMessage(GroupMessage event) { + if (!plugin.getConfig().isSendToGame + || plugin.getConfig().groupId < 1 + || event.groupId() != plugin.getConfig().groupId + ) return; + + // log messages + if (plugin.getConfig().logChat) { + logger.info("[MiniOneBot] {}: \"{}\"", + event.senderCardOrNickname(), event.message()); + } + broadcastChatMessage(plugin.getConfig().groupToGameFormat + .replace("{id}", String.valueOf(event.senderId())) + .replace("{name}", event.senderCardOrNickname()) + .replace("{message}", event.message())); + } + + /** + * 广播聊天消息给所有玩家(未开启聊天玩家除外) + * + * @param message 纯文本消息 + */ + public void broadcastChatMessage(String message) { + for (Player p : getServer().getPlayers().values()) { + if (!plugin.getData().offChatPlayers.contains(p.getUid())) { p.dropMessage(message); } } } + /** + * 检查玩家是否正在禁言中 + * + * @param player 玩家对象 + * @return 是否禁言中 + */ private boolean checkIsBanning(Player player) { var banList = plugin.getData().banList; // 检测是否正在禁言中 @@ -142,6 +267,7 @@ public class OpenChatSystem extends ChatSystem { /** * 消息内容审查 + * * @param message 消息 * @return 是否合法合规 */ @@ -150,10 +276,16 @@ public class OpenChatSystem extends ChatSystem { return !message.isEmpty(); } + // region 发言频率限制 + + /** + * 发言频率计时器 + */ Int2ObjectMap speakingTimes = new Int2ObjectOpenHashMap<>(); /** * 消息频率检查 + * * @param player 玩家对象 * @return 是否在约定阈值内 */ @@ -166,4 +298,6 @@ public class OpenChatSystem extends ChatSystem { list.dequeueLong(); return list.size() <= plugin.getConfig().messageFreLimitPerMinute; } + + // endregion } diff --git a/src/main/java/com/github/jie65535/openchat/commands/ChatServerCommands.java b/src/main/java/com/github/jie65535/openchat/commands/ChatServerCommands.java index bf20ae4..4873454 100644 --- a/src/main/java/com/github/jie65535/openchat/commands/ChatServerCommands.java +++ b/src/main/java/com/github/jie65535/openchat/commands/ChatServerCommands.java @@ -26,7 +26,14 @@ import java.util.List; @Command(label = "serverchat", aliases = { "sc" }, - usage = { "on/off", "unban|unmute @", "ban|mute @ [time(Minutes)]", "limit ", "reload" }, + usage = { + "on/off", + "unban|unmute @", + "ban|mute @ [time(Minutes)]", + "limit ", + "reload", + "group ", + }, permission = "server.chat", permissionTargeted = "server.chat.others", targetRequirement = Command.TargetRequirement.NONE) @@ -98,6 +105,18 @@ public class ChatServerCommands implements CommandHandler { plugin.loadConfig(); CommandHandler.sendMessage(sender, "OK"); } + case "group" -> { + var groupId = 0L; + try { + groupId = Long.parseLong(args.get(1)); + } catch (NumberFormatException ignored) { + sendUsageMessage(sender); + return; + } + plugin.getConfig().groupId = groupId; + plugin.saveConfig(); + CommandHandler.sendMessage(sender, "OK"); + } default -> sendUsageMessage(sender); } }