Implement OneBot protocol

Add subcommand `group`
Update version to v0.2.0
Update plugin config
This commit is contained in:
2023-03-05 10:27:02 +08:00
parent d060b1804f
commit d8caa42899
17 changed files with 1014 additions and 54 deletions

View File

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>

111
README.md
View File

@ -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 <groupId>` 设置互联群号
`/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: "<color=#99CC99>{nickName}({uid})</color>: {message}",
"serverChatFormat": "<color=#99CC99>{nickName}({uid})</color>: {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>` 设定
"groupId": 0,
// 群消息格式化
// {id} 为QQ号
// {name} 为群名片,如果为空则显示昵称
// {message} 为消息
"groupToGameFormat": "<color=#6699CC>[QQ]</color><color=#99CC99>{name}</color>: {message}",
// 服务器聊天消息格式
// {nickName} 为玩家昵称
// {uid} 为玩家UID
// {message} 为消息内容
"gameToGroupFormat": "[GC]{nickName}({uid}): {message}",
// /**
// * 频道ID
// */
// public String guildId: "",
// /**
// * 子频道ID集
// */
// public List<String> 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}) 离开了服务器",
}
```

View File

@ -3,7 +3,7 @@ plugins {
}
group 'com.github.jie65535.openchat'
version 'dev-0.1.0'
version 'dev-0.2.0'
repositories {
mavenCentral()

BIN
doc/Chat-OneBot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 KiB

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<?, ?>>)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("&", "&amp;")
// .replace("[", "&#91;")
// .replace("]", "&#93;")
// .replace(",", "&#44;");
// }
private static String unescape(String msg) {
return msg.replace("&amp;", "&")
.replace("&#91;", "[")
.replace("&#93;", "]")
.replace("&#44;", ",");
}
// endregion
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
//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<String, String> headers, Logger logger) {
// super(serverUri, headers);
//
// this.logger = logger;
// }
//
// public static MiniOneBotWsClient create(URI serverUri, String token, Logger logger) {
// var headers = new HashMap<String, String>();
// 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);
// }
// }
//}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<WsContext, String> 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();
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
package com.github.jie65535.minionebot;
public interface WsStream {
void subscribe(WsMessageHandler callback);
void send(String message);
interface WsMessageHandler {
void onMessage(String message);
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
package com.github.jie65535.minionebot.events;
public record GroupMessage(
long groupId,
String message,
long senderId,
String senderCardOrNickname,
String senderLevel,
String senderRole,
String senderTitle
) {
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
package com.github.jie65535.minionebot.events;
public interface GroupMessageHandler {
void handleGroupMessage(GroupMessage event);
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
package com.github.jie65535.minionebot.events;
public record GuildChannelMessage(
String guildId,
String channelId,
String message,
String senderId,
String senderName
) {
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
package com.github.jie65535.minionebot.events;
public interface GuildChannelMessageHandler {
void handleGuildChannelMessage(GuildChannelMessage event);
}

View File

@ -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);
}
}
}

View File

@ -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 = "<color=#6699CC>[QQ]</color><color=#99CC99>{name}</color>: {message}";
/**
* 服务器聊天消息格式
* {nickName} 为玩家昵称
* {uid} 为玩家UID
* {message} 为消息内容
*/
public String gameToGroupFormat = "[GC]{nickName}({uid}): {message}";
// /**
// * 频道ID
// */
// public String guildId = "";
// /**
// * 子频道ID集
// */
// public List<String> 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}) 离开了服务器";
}

View File

@ -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());

View File

@ -17,63 +17,133 @@
*/
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 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<LongArrayFIFOQueue> speakingTimes = new Int2ObjectOpenHashMap<>();
/**
* 消息频率检查
*
* @param player 玩家对象
* @return 是否在约定阈值内
*/
@ -166,4 +298,6 @@ public class OpenChatSystem extends ChatSystem {
list.dequeueLong();
return list.size() <= plugin.getConfig().messageFreLimitPerMinute;
}
// endregion
}

View File

@ -26,7 +26,14 @@ import java.util.List;
@Command(label = "serverchat",
aliases = { "sc" },
usage = { "on/off", "unban|unmute @<UID>", "ban|mute @<UID> [time(Minutes)]", "limit <timesPerMinute>", "reload" },
usage = {
"on/off",
"unban|unmute @<UID>",
"ban|mute @<UID> [time(Minutes)]",
"limit <timesPerMinute>",
"reload",
"group <groupId>",
},
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);
}
}