koishi-plugin-minecraft-adapter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # koishi-plugin-minecraft-adapter
2
+
3
+ Koishi 的 Minecraft 适配器:支持 RCON 与鹊桥(Queqiao) Webhook。
4
+
5
+ - RCON:连接并发送命令/广播
6
+ - Webhook:接收聊天、加入、离开等事件并在 Koishi 中触发
7
+
8
+ 参考实现:
9
+ - https://github.com/17TheWord/nonebot-adapter-minecraft
10
+ - https://github.com/KroMiose/nekro-agent/tree/main/nekro_agent/adapters/minecraft
11
+
12
+ ## 安装
13
+
14
+ ```bash
15
+ npm i koishi-plugin-minecraft-adapter
16
+ ```
17
+
18
+ ## 配置
19
+
20
+ - RCON:host/port/password/timeout/reconnectInterval/reconnectStrategy/maxReconnectInterval/broadcastMode
21
+ - Webhook:enabled/path/secret/verifyMode/signatureHeader/secretHeader
22
+
23
+ ## 用法
24
+
25
+ ```ts
26
+ await ctx.minecraft.execute('list')
27
+ await ctx.minecraft.broadcast('服务器即将重启')
28
+ await ctx.minecraft.sendTo('Steve', '你好')
29
+
30
+ ctx.on('minecraft/chat', (p) => {
31
+ // p: { player, message, raw }
32
+ })
33
+ ```
34
+
35
+ MIT
package/lib/index.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { Context, Schema, Service } from 'koishi';
2
+ export interface MinecraftServiceAPI {
3
+ /** 发送任意 RCON 指令,返回原始响应字符串 */
4
+ execute(command: string): Promise<string>;
5
+ /** 广播一条消息(基于配置选择 say 或 tellraw) */
6
+ broadcast(message: string): Promise<string>;
7
+ /** 向指定玩家发送消息(tellraw) */
8
+ sendTo(player: string, message: string): Promise<string>;
9
+ }
10
+ declare class MinecraftService extends Service implements MinecraftServiceAPI {
11
+ ctx: Context;
12
+ config: MinecraftService.Config;
13
+ static inject: {
14
+ required: any[];
15
+ optional: string[];
16
+ };
17
+ private rcon?;
18
+ private isConnecting;
19
+ private reconnectTimer?;
20
+ private currentReconnectInterval?;
21
+ private commandQueue;
22
+ private isProcessingQueue;
23
+ constructor(ctx: Context, config: MinecraftService.Config);
24
+ private registerWebhook;
25
+ start(): void;
26
+ stop(): void;
27
+ private connectRcon;
28
+ private scheduleReconnect;
29
+ private ensureConnected;
30
+ private disconnect;
31
+ execute(command: string): Promise<string>;
32
+ broadcast(message: string): Promise<string>;
33
+ sendTo(player: string, message: string): Promise<string>;
34
+ private enqueue;
35
+ private processQueue;
36
+ }
37
+ declare namespace MinecraftService {
38
+ interface RconConfig {
39
+ enabled: boolean;
40
+ host: string;
41
+ port: number;
42
+ password: string;
43
+ timeout: number;
44
+ reconnectInterval: number;
45
+ reconnectStrategy?: 'fixed' | 'exponential';
46
+ maxReconnectInterval?: number;
47
+ broadcastMode: 'say' | 'tellraw';
48
+ }
49
+ interface WebhookConfig {
50
+ enabled: boolean;
51
+ path: string;
52
+ secret?: string;
53
+ verifyMode?: 'none' | 'header-secret' | 'hmac-sha256';
54
+ signatureHeader?: string;
55
+ secretHeader?: string;
56
+ }
57
+ interface Config {
58
+ rcon?: RconConfig;
59
+ webhook?: WebhookConfig;
60
+ }
61
+ const Config: Schema<Config>;
62
+ }
63
+ export default MinecraftService;
package/lib/index.js ADDED
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const koishi_1 = require("koishi");
4
+ const rcon_client_1 = require("rcon-client");
5
+ const logger = new koishi_1.Logger('minecraft');
6
+ class MinecraftService extends koishi_1.Service {
7
+ ctx;
8
+ config;
9
+ static inject = {
10
+ required: [],
11
+ optional: ['server'],
12
+ };
13
+ rcon;
14
+ isConnecting = false;
15
+ reconnectTimer;
16
+ currentReconnectInterval;
17
+ commandQueue = [];
18
+ isProcessingQueue = false;
19
+ constructor(ctx, config) {
20
+ super(ctx, 'minecraft', true);
21
+ this.ctx = ctx;
22
+ this.config = config;
23
+ if (config.webhook?.enabled) {
24
+ this.registerWebhook();
25
+ }
26
+ }
27
+ registerWebhook() {
28
+ const { path = '/minecraft/webhook', secret, verifyMode = 'header-secret', signatureHeader = 'x-queqiao-signature', secretHeader = 'x-queqiao-secret' } = this.config.webhook;
29
+ const router = this.ctx.router;
30
+ if (!router) {
31
+ logger.warn('未检测到 Koishi 服务器(router),Webhook 将不会启用。请安装并启用 @koishijs/plugin-server。');
32
+ return;
33
+ }
34
+ router.post(path, async (koaCtx) => {
35
+ try {
36
+ const req = koaCtx.request;
37
+ const body = req.body || {};
38
+ // 验证
39
+ if (verifyMode === 'header-secret' && secret) {
40
+ const provided = koaCtx.headers[secretHeader] || koaCtx.query['secret'] || '';
41
+ if (provided !== secret) {
42
+ koaCtx.status = 401;
43
+ koaCtx.body = 'invalid secret';
44
+ return;
45
+ }
46
+ }
47
+ else if (verifyMode === 'hmac-sha256' && secret) {
48
+ const sig = koaCtx.headers[signatureHeader] || '';
49
+ const raw = req.rawBody ?? JSON.stringify(body);
50
+ if (!verifyHmacSha256(raw, secret, sig)) {
51
+ koaCtx.status = 401;
52
+ koaCtx.body = 'invalid signature';
53
+ return;
54
+ }
55
+ }
56
+ // 兼容常见事件格式:{ type, data }
57
+ const type = body.type || body.event || 'unknown';
58
+ const data = body.data ?? body;
59
+ const mapped = mapQueqiaoEvent(type, data);
60
+ // 派发 Koishi 事件,名称按 "minecraft/<type>"
61
+ this.ctx.emit(`minecraft/${mapped.type}`, mapped.payload);
62
+ koaCtx.body = { ok: true };
63
+ }
64
+ catch (e) {
65
+ logger.warn(e);
66
+ koaCtx.status = 500;
67
+ koaCtx.body = 'internal error';
68
+ }
69
+ });
70
+ logger.info(`已注册鹊桥 Webhook 路由: ${path}`);
71
+ }
72
+ start() {
73
+ if (this.config.rcon?.enabled) {
74
+ this.ensureConnected();
75
+ }
76
+ }
77
+ stop() {
78
+ if (this.reconnectTimer) {
79
+ clearTimeout(this.reconnectTimer);
80
+ this.reconnectTimer = undefined;
81
+ }
82
+ this.disconnect();
83
+ }
84
+ async connectRcon() {
85
+ if (this.isConnecting || this.rcon)
86
+ return;
87
+ this.isConnecting = true;
88
+ const { host, port, password, timeout } = this.config.rcon;
89
+ try {
90
+ logger.info(`RCON 连接中: ${host}:${port}`);
91
+ const conn = await rcon_client_1.Rcon.connect({ host, port, password, timeout });
92
+ this.rcon = conn;
93
+ this.isConnecting = false;
94
+ logger.info('RCON 连接成功');
95
+ conn.on('end', () => {
96
+ logger.warn('RCON 连接断开');
97
+ this.rcon = undefined;
98
+ this.scheduleReconnect();
99
+ });
100
+ conn.on('error', (err) => {
101
+ logger.warn('RCON 错误: ' + (err?.message || err));
102
+ });
103
+ }
104
+ catch (err) {
105
+ this.isConnecting = false;
106
+ logger.warn('RCON 连接失败: ' + (err?.message || err));
107
+ this.scheduleReconnect();
108
+ }
109
+ }
110
+ scheduleReconnect() {
111
+ const base = this.config.rcon?.reconnectInterval ?? 5000;
112
+ const strategy = this.config.rcon?.reconnectStrategy ?? 'fixed';
113
+ const maxInterval = this.config.rcon?.maxReconnectInterval ?? 60000;
114
+ if (this.currentReconnectInterval == null)
115
+ this.currentReconnectInterval = base;
116
+ const interval = strategy === 'exponential' ? Math.min(this.currentReconnectInterval * 2, maxInterval) : base;
117
+ if (!this.config.rcon?.enabled)
118
+ return;
119
+ if (this.reconnectTimer)
120
+ return;
121
+ this.reconnectTimer = setTimeout(() => {
122
+ this.reconnectTimer = undefined;
123
+ this.currentReconnectInterval = interval;
124
+ this.ensureConnected();
125
+ }, interval);
126
+ }
127
+ ensureConnected() {
128
+ if (this.rcon)
129
+ return;
130
+ void this.connectRcon();
131
+ }
132
+ disconnect() {
133
+ if (this.rcon) {
134
+ try {
135
+ this.rcon.end();
136
+ }
137
+ catch { }
138
+ this.rcon = undefined;
139
+ }
140
+ }
141
+ async execute(command) {
142
+ if (!this.config.rcon?.enabled)
143
+ throw new Error('RCON 未启用');
144
+ this.ensureConnected();
145
+ if (!this.rcon)
146
+ throw new Error('RCON 未连接');
147
+ return await this.enqueue(command);
148
+ }
149
+ async broadcast(message) {
150
+ if (!this.config.rcon?.enabled)
151
+ throw new Error('RCON 未启用');
152
+ const mode = this.config.rcon.broadcastMode || 'say';
153
+ if (mode === 'say') {
154
+ return await this.execute(`say ${escapeForMc(message)}`);
155
+ }
156
+ // tellraw 使用简单 JSON 文本
157
+ const json = JSON.stringify([{ text: message }]);
158
+ return await this.execute(`tellraw @a ${json}`);
159
+ }
160
+ async sendTo(player, message) {
161
+ if (!this.config.rcon?.enabled)
162
+ throw new Error('RCON 未启用');
163
+ const json = JSON.stringify([{ text: message }]);
164
+ return await this.execute(`tellraw ${player} ${json}`);
165
+ }
166
+ async enqueue(command) {
167
+ return await new Promise((resolve, reject) => {
168
+ this.commandQueue.push({ command, resolve, reject });
169
+ this.processQueue();
170
+ });
171
+ }
172
+ async processQueue() {
173
+ if (this.isProcessingQueue)
174
+ return;
175
+ if (!this.rcon)
176
+ return;
177
+ this.isProcessingQueue = true;
178
+ try {
179
+ while (this.commandQueue.length && this.rcon) {
180
+ const task = this.commandQueue.shift();
181
+ try {
182
+ const result = await this.rcon.send(task.command);
183
+ task.resolve(result);
184
+ }
185
+ catch (err) {
186
+ task.reject(err);
187
+ }
188
+ }
189
+ }
190
+ finally {
191
+ this.isProcessingQueue = false;
192
+ }
193
+ }
194
+ }
195
+ function escapeForMc(text) {
196
+ return text.replace(/[\n\r]/g, ' ').replace(/[§]/g, '');
197
+ }
198
+ (function (MinecraftService) {
199
+ MinecraftService.Config = koishi_1.Schema.object({
200
+ rcon: koishi_1.Schema.object({
201
+ enabled: koishi_1.Schema.boolean().description('启用 RCON').default(true),
202
+ host: koishi_1.Schema.string().description('RCON 主机地址').default('127.0.0.1'),
203
+ port: koishi_1.Schema.number().description('RCON 端口').default(25575),
204
+ password: koishi_1.Schema.string().description('RCON 密码').default(''),
205
+ timeout: koishi_1.Schema.number().description('RCON 超时(ms)').default(5000),
206
+ reconnectInterval: koishi_1.Schema.number().description('断线重连基础间隔(ms)').default(5000),
207
+ reconnectStrategy: koishi_1.Schema.union(['fixed', 'exponential']).description('重连策略').default('fixed'),
208
+ maxReconnectInterval: koishi_1.Schema.number().description('最大重连间隔(ms),用于指数退避').default(60000),
209
+ broadcastMode: koishi_1.Schema.union(['say', 'tellraw']).description('广播模式').default('say'),
210
+ }).description('RCON 设置').default({
211
+ enabled: true,
212
+ host: '127.0.0.1',
213
+ port: 25575,
214
+ password: '',
215
+ timeout: 5000,
216
+ reconnectInterval: 5000,
217
+ reconnectStrategy: 'fixed',
218
+ maxReconnectInterval: 60000,
219
+ broadcastMode: 'say',
220
+ }),
221
+ webhook: koishi_1.Schema.object({
222
+ enabled: koishi_1.Schema.boolean().description('启用鹊桥 Webhook').default(false),
223
+ path: koishi_1.Schema.string().description('Webhook 路径').default('/minecraft/webhook'),
224
+ secret: koishi_1.Schema.string().role('secret').description('共享密钥(可选)'),
225
+ verifyMode: koishi_1.Schema.union(['none', 'header-secret', 'hmac-sha256']).description('校验方式').default('header-secret'),
226
+ signatureHeader: koishi_1.Schema.string().description('签名头(用于 HMAC)').default('x-queqiao-signature'),
227
+ secretHeader: koishi_1.Schema.string().description('密钥头(用于 header-secret)').default('x-queqiao-secret'),
228
+ }).description('Webhook 设置').default({
229
+ enabled: false,
230
+ path: '/minecraft/webhook',
231
+ secret: '',
232
+ verifyMode: 'header-secret',
233
+ signatureHeader: 'x-queqiao-signature',
234
+ secretHeader: 'x-queqiao-secret',
235
+ }),
236
+ });
237
+ })(MinecraftService || (MinecraftService = {}));
238
+ exports.default = MinecraftService;
239
+ function verifyHmacSha256(rawBody, secret, signatureHeader) {
240
+ try {
241
+ const sig = signatureHeader.replace(/^sha256=/i, '');
242
+ // 延迟引入 crypto,避免浏览器侧打包
243
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
244
+ const crypto = require('crypto');
245
+ const h = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
246
+ return timingSafeEqual(h, sig);
247
+ }
248
+ catch {
249
+ return false;
250
+ }
251
+ }
252
+ function timingSafeEqual(a, b) {
253
+ if (a.length !== b.length)
254
+ return false;
255
+ let r = 0;
256
+ for (let i = 0; i < a.length; i++)
257
+ r |= a.charCodeAt(i) ^ b.charCodeAt(i);
258
+ return r === 0;
259
+ }
260
+ function mapQueqiaoEvent(type, data) {
261
+ // 兼容常见事件名,向 Koishi 事件命名空间收敛
262
+ switch (type) {
263
+ case 'chat':
264
+ case 'player_chat':
265
+ return { type: 'chat', payload: { player: data.player || data.name, message: data.message || data.text, raw: data } };
266
+ case 'join':
267
+ case 'player_join':
268
+ return { type: 'join', payload: { player: data.player || data.name, raw: data } };
269
+ case 'leave':
270
+ case 'quit':
271
+ case 'player_quit':
272
+ return { type: 'leave', payload: { player: data.player || data.name, reason: data.reason, raw: data } };
273
+ case 'death':
274
+ return { type: 'death', payload: { player: data.player || data.name, message: data.message || data.reason, raw: data } };
275
+ case 'advancement':
276
+ case 'advancement_earned':
277
+ return { type: 'advancement', payload: { player: data.player || data.name, advancement: data.advancement || data.key, raw: data } };
278
+ default:
279
+ return { type, payload: data };
280
+ }
281
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "koishi-plugin-minecraft-adapter",
3
+ "description": "Minecraft RCON & webhook integration for Koishi",
4
+ "version": "0.1.0",
5
+ "main": "lib/index.js",
6
+ "typings": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "dist"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": ""
16
+ },
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "peerDependencies": {
22
+ "koishi": "^4.17.9"
23
+ },
24
+ "dependencies": {
25
+ "rcon-client": "^4.2.5"
26
+ },
27
+ "devDependencies": {
28
+ "typescript": "^5.0.0"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "keywords": [
34
+ "koishi",
35
+ "plugin",
36
+ "minecraft",
37
+ "rcon",
38
+ "webhook"
39
+ ],
40
+ "koishi": {
41
+ "service": {
42
+ "implements": [
43
+ "minecraft"
44
+ ]
45
+ },
46
+ "description": {
47
+ "en": "Minecraft RCON & webhook integration for Koishi",
48
+ "zh": "Minecraft RCON 与 Webhook 集成,提供 minecraft 服务"
49
+ }
50
+ }
51
+ }
52
+
53
+