koishi-plugin-minecraft-adapter 1.0.8 → 1.0.10

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/lib/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Adapter, Bot, Context, Schema } from 'koishi';
2
- import { Rcon } from 'rcon-client';
2
+ import { Rcon } from './rcon';
3
3
  import WebSocket from 'ws';
4
4
  /**
5
5
  * Minecraft 文本组件格式
package/lib/index.js CHANGED
@@ -5,33 +5,56 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.MinecraftAdapter = exports.MinecraftBot = void 0;
7
7
  const koishi_1 = require("koishi");
8
- const rcon_client_1 = require("rcon-client");
8
+ const rcon_1 = require("./rcon");
9
9
  const ws_1 = __importDefault(require("ws"));
10
10
  const logger = new koishi_1.Logger('minecraft');
11
11
  /**
12
- * 将嵌套格式的服务器配置扁平化
12
+ * 将嵌套格式的服务器配置扁平化。
13
+ * 每种嵌套对象(websocket / rcon / chatImage)独立处理,
14
+ * 确保任何混合格式(如扁平 url + 嵌套 rcon)都能正确转换。
13
15
  */
14
16
  function flattenServerConfig(server) {
15
- if (server.websocket && typeof server.websocket === 'object') {
16
- // 嵌套格式的 rcon 对象:只要存在且有 host/port/password 任一字段,即视为启用
17
- const hasRcon = server.rcon && typeof server.rcon === 'object'
18
- && (server.rcon.host || server.rcon.port || server.rcon.password);
19
- return {
20
- selfId: server.selfId,
21
- serverName: server.serverName,
22
- url: server.websocket.url,
23
- accessToken: server.websocket.accessToken,
24
- extraHeaders: server.websocket.extraHeaders,
25
- enableRcon: hasRcon ? true : (server.rcon?.enabled ?? false),
26
- rconHost: server.rcon?.host,
27
- rconPort: server.rcon?.port,
28
- rconPassword: server.rcon?.password,
29
- rconTimeout: server.rcon?.timeout,
30
- enableChatImage: server.chatImage?.enabled ?? !!(server.chatImage && (server.chatImage.defaultImageName)),
31
- chatImageDefaultName: server.chatImage?.defaultImageName,
32
- };
17
+ const result = { ...server };
18
+ // ── websocket 嵌套 扁平 ──
19
+ if (result.websocket && typeof result.websocket === 'object') {
20
+ const ws = result.websocket;
21
+ if (ws.url !== undefined)
22
+ result.url = ws.url;
23
+ if (ws.accessToken !== undefined)
24
+ result.accessToken = ws.accessToken;
25
+ if (ws.extraHeaders !== undefined)
26
+ result.extraHeaders = ws.extraHeaders;
27
+ delete result.websocket;
28
+ }
29
+ // ── rcon 嵌套 → 扁平 ──
30
+ if (result.rcon && typeof result.rcon === 'object') {
31
+ const rcon = result.rcon;
32
+ const hasRconConfig = rcon.host || rcon.port || rcon.password;
33
+ if (hasRconConfig || rcon.enabled) {
34
+ result.enableRcon = true;
35
+ }
36
+ // 仅在嵌套值实际存在时覆盖,避免用 undefined 覆盖已有值或阻止 Schema 默认值
37
+ if (rcon.host !== undefined)
38
+ result.rconHost = rcon.host;
39
+ if (rcon.port !== undefined)
40
+ result.rconPort = rcon.port;
41
+ if (rcon.password !== undefined)
42
+ result.rconPassword = rcon.password;
43
+ if (rcon.timeout !== undefined)
44
+ result.rconTimeout = rcon.timeout;
45
+ delete result.rcon;
46
+ }
47
+ // ── chatImage 嵌套 → 扁平 ──
48
+ if (result.chatImage && typeof result.chatImage === 'object') {
49
+ const ci = result.chatImage;
50
+ if (ci.enabled !== undefined || ci.defaultImageName) {
51
+ result.enableChatImage = ci.enabled ?? !!ci.defaultImageName;
52
+ }
53
+ if (ci.defaultImageName !== undefined)
54
+ result.chatImageDefaultName = ci.defaultImageName;
55
+ delete result.chatImage;
33
56
  }
34
- return server;
57
+ return result;
35
58
  }
36
59
  /**
37
60
  * 向后兼容:
@@ -120,8 +143,7 @@ class MinecraftAdapter extends koishi_1.Adapter {
120
143
  constructor(ctx, rawConfig) {
121
144
  super(ctx);
122
145
  try {
123
- const resolvedConfig = MinecraftAdapter.Config(rawConfig);
124
- const config = migrateConfig(resolvedConfig);
146
+ const config = migrateConfig(rawConfig);
125
147
  this.debug = config.debug ?? false;
126
148
  this.detailedLogging = config.detailedLogging ?? false;
127
149
  this.tokenizeMode = config.tokenizeMode ?? 'split';
@@ -395,9 +417,17 @@ class MinecraftAdapter extends koishi_1.Adapter {
395
417
  async connectRcon(bot) {
396
418
  const config = bot.config;
397
419
  const selfId = bot.selfId;
398
- const rconHost = config.rconHost || '127.0.0.1';
399
- const rconPort = config.rconPort || 25575;
400
- const rconTimeout = config.rconTimeout || 5000;
420
+ // rconHost 未配置时,从 WebSocket URL 中提取主机地址作为回退
421
+ let rconHost = config.rconHost;
422
+ if (!rconHost && config.url) {
423
+ try {
424
+ rconHost = new URL(config.url).hostname;
425
+ }
426
+ catch { }
427
+ }
428
+ rconHost = rconHost || '127.0.0.1';
429
+ const rconPort = config.rconPort ?? 25575;
430
+ const rconTimeout = config.rconTimeout ?? 5000;
401
431
  if (this.debug) {
402
432
  logger.info(`[DEBUG] RCON config for server ${selfId}:`, {
403
433
  rconHost: config.rconHost,
@@ -405,12 +435,11 @@ class MinecraftAdapter extends koishi_1.Adapter {
405
435
  rconPassword: config.rconPassword ? '***' : undefined,
406
436
  rconTimeout: config.rconTimeout,
407
437
  enableRcon: config.enableRcon,
408
- configKeys: Object.keys(config),
438
+ resolved: `${rconHost}:${rconPort}`,
409
439
  });
410
- logger.info(`[DEBUG] Connecting RCON for server ${selfId} to ${rconHost}:${rconPort}`);
411
440
  }
412
441
  try {
413
- const rcon = await this.createRconWithTimeout(rconHost, rconPort, config.rconPassword || '', rconTimeout);
442
+ const rcon = await this.createRconWithTimeout(rconHost, rconPort, String(config.rconPassword ?? ''), rconTimeout);
414
443
  this.rconConnections.set(selfId, rcon);
415
444
  bot.rcon = rcon;
416
445
  this.rconReconnectAttempts.set(selfId, 0);
@@ -438,7 +467,7 @@ class MinecraftAdapter extends koishi_1.Adapter {
438
467
  const timer = setTimeout(() => {
439
468
  reject(new Error(`RCON TCP connection timeout after ${timeout}ms to ${host}:${port}`));
440
469
  }, timeout);
441
- rcon_client_1.Rcon.connect({ host, port, password, timeout }).then((rcon) => { clearTimeout(timer); resolve(rcon); }, (err) => { clearTimeout(timer); reject(err); });
470
+ rcon_1.Rcon.connect({ host, port, password, timeout }).then((rcon) => { clearTimeout(timer); resolve(rcon); }, (err) => { clearTimeout(timer); reject(err); });
442
471
  });
443
472
  }
444
473
  scheduleRconReconnect(bot) {
@@ -1193,34 +1222,20 @@ MinecraftBot.MessageEncoder = MinecraftMessageEncoder;
1193
1222
  // ============================================================================
1194
1223
  // Koishi Schema 配置
1195
1224
  // ============================================================================
1196
- const serverSchema = koishi_1.Schema.intersect([
1197
- koishi_1.Schema.object({
1198
- selfId: koishi_1.Schema.string().description('机器人 ID(唯一标识)').required(),
1199
- serverName: koishi_1.Schema.string().description('服务器名称(需与鹊桥 config.yml 中的 server_name 一致)'),
1200
- url: koishi_1.Schema.string().description('WebSocket 地址(如 ws://127.0.0.1:8080)').required(),
1201
- accessToken: koishi_1.Schema.string().description('访问令牌(需与鹊桥 config.yml 中的 access_token 一致)'),
1202
- extraHeaders: koishi_1.Schema.dict(koishi_1.Schema.string()).description('额外请求头'),
1203
- enableRcon: koishi_1.Schema.boolean().description('启用 RCON 远程命令执行').default(false),
1204
- enableChatImage: koishi_1.Schema.boolean().description('启用 ChatImage CICode 图片发送(需客户端安装 ChatImage Mod)').default(false),
1205
- }),
1206
- koishi_1.Schema.union([
1207
- koishi_1.Schema.object({
1208
- enableRcon: koishi_1.Schema.const(true).required(),
1209
- rconHost: koishi_1.Schema.string().description('RCON 主机地址').default('127.0.0.1'),
1210
- rconPort: koishi_1.Schema.number().description('RCON 端口').default(25575),
1211
- rconPassword: koishi_1.Schema.string().description('RCON 密码(留空表示无密码)'),
1212
- rconTimeout: koishi_1.Schema.number().description('RCON 超时时间(ms)').default(5000),
1213
- }),
1214
- koishi_1.Schema.object({}),
1215
- ]),
1216
- koishi_1.Schema.union([
1217
- koishi_1.Schema.object({
1218
- enableChatImage: koishi_1.Schema.const(true).required(),
1219
- chatImageDefaultName: koishi_1.Schema.string().description('图片在聊天栏中的默认显示名称').default('图片'),
1220
- }),
1221
- koishi_1.Schema.object({}),
1222
- ]),
1223
- ]);
1225
+ const serverSchema = koishi_1.Schema.object({
1226
+ selfId: koishi_1.Schema.string().description('机器人 ID(唯一标识)').required(),
1227
+ serverName: koishi_1.Schema.string().description('服务器名称(需与鹊桥 config.yml 中的 server_name 一致)'),
1228
+ url: koishi_1.Schema.string().description('WebSocket 地址(如 ws://127.0.0.1:8080)').required(),
1229
+ accessToken: koishi_1.Schema.string().description('访问令牌(需与鹊桥 config.yml 中的 access_token 一致)'),
1230
+ extraHeaders: koishi_1.Schema.dict(koishi_1.Schema.string()).description('额外请求头'),
1231
+ enableRcon: koishi_1.Schema.boolean().description('启用 RCON 远程命令执行').default(false),
1232
+ rconHost: koishi_1.Schema.string().description('RCON 主机地址(留空则自动取 WebSocket 地址中的主机)'),
1233
+ rconPort: koishi_1.Schema.number().description('RCON 端口').default(25575),
1234
+ rconPassword: koishi_1.Schema.string().description('RCON 密码(留空表示无密码)'),
1235
+ rconTimeout: koishi_1.Schema.number().description('RCON 超时时间(ms)').default(5000),
1236
+ enableChatImage: koishi_1.Schema.boolean().description('启用 ChatImage CICode 图片发送(需客户端安装 ChatImage Mod)').default(false),
1237
+ chatImageDefaultName: koishi_1.Schema.string().description('图片在聊天栏中的默认显示名称').default('图片'),
1238
+ });
1224
1239
  (function (MinecraftAdapter) {
1225
1240
  MinecraftAdapter.Config = koishi_1.Schema.object({
1226
1241
  debug: koishi_1.Schema.boolean().description('启用调试模式,输出详细日志').default(false),
package/lib/rcon.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ export interface RconOptions {
2
+ host: string;
3
+ port?: number;
4
+ password: string;
5
+ timeout?: number;
6
+ }
7
+ /**
8
+ * 轻量级 Minecraft RCON 客户端
9
+ * 替换已停止维护的 rcon-client 库,修复认证兼容性问题
10
+ *
11
+ * 主要改进:
12
+ * - 正确处理 Minecraft 服务端的认证响应(兼容 Vanilla/Paper/Spigot/Fabric)
13
+ * - 稳健的 TCP 分包/合包缓冲处理
14
+ * - 独立的 TCP 连接超时和认证超时
15
+ * - 密码类型安全(防止 YAML 解析为非字符串)
16
+ */
17
+ export declare class Rcon {
18
+ private socket;
19
+ private emitter;
20
+ private buf;
21
+ private nextId;
22
+ private authenticated;
23
+ private authCb;
24
+ private pending;
25
+ private host;
26
+ private port;
27
+ private password;
28
+ private timeout;
29
+ constructor(options: RconOptions);
30
+ static connect(options: RconOptions): Promise<Rcon>;
31
+ on(event: string, listener: (...args: any[]) => void): void;
32
+ once(event: string, listener: (...args: any[]) => void): void;
33
+ off(event: string, listener: (...args: any[]) => void): void;
34
+ connect(): Promise<void>;
35
+ send(command: string): Promise<string>;
36
+ end(): void;
37
+ /**
38
+ * 编码 RCON 数据包
39
+ * 格式: [4:size][4:id][4:type][body][0x00][0x00]
40
+ */
41
+ private encode;
42
+ /**
43
+ * 从缓冲区中提取并处理完整的 RCON 数据包
44
+ * 正确处理 TCP 分包和合包
45
+ */
46
+ private drain;
47
+ /**
48
+ * 处理接收到的 RCON 数据包
49
+ *
50
+ * 认证阶段:
51
+ * Minecraft 服务端对认证请求的响应因实现而异:
52
+ * - Vanilla: 仅发送 Auth_Response (type=2, id=请求id 或 -1)
53
+ * - 部分实现: 先发送空 Response_Value (type=0) 再发送 Auth_Response
54
+ *
55
+ * 处理策略:
56
+ * 1. 收到 type=2(Auth_Response)→ 立即判断认证结果
57
+ * 2. 收到 id=-1 → 认证失败(无论 type)
58
+ * 3. 其他包 → 忽略(可能是前导空响应)
59
+ */
60
+ private onPacket;
61
+ }
package/lib/rcon.js ADDED
@@ -0,0 +1,215 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Rcon = void 0;
4
+ const net_1 = require("net");
5
+ const events_1 = require("events");
6
+ /**
7
+ * 轻量级 Minecraft RCON 客户端
8
+ * 替换已停止维护的 rcon-client 库,修复认证兼容性问题
9
+ *
10
+ * 主要改进:
11
+ * - 正确处理 Minecraft 服务端的认证响应(兼容 Vanilla/Paper/Spigot/Fabric)
12
+ * - 稳健的 TCP 分包/合包缓冲处理
13
+ * - 独立的 TCP 连接超时和认证超时
14
+ * - 密码类型安全(防止 YAML 解析为非字符串)
15
+ */
16
+ class Rcon {
17
+ socket = null;
18
+ emitter = new events_1.EventEmitter();
19
+ buf = Buffer.alloc(0);
20
+ nextId = 0;
21
+ authenticated = false;
22
+ // 认证阶段回调(独立于命令回调,避免 id 冲突)
23
+ authCb = null;
24
+ // 命令响应回调
25
+ pending = new Map();
26
+ host;
27
+ port;
28
+ password;
29
+ timeout;
30
+ constructor(options) {
31
+ this.host = options.host;
32
+ this.port = options.port ?? 25575;
33
+ // 关键修复:强制转为字符串,防止 YAML 将纯数字/布尔密码解析为非 string 类型
34
+ this.password = String(options.password ?? '');
35
+ this.timeout = options.timeout ?? 5000;
36
+ }
37
+ static async connect(options) {
38
+ const rcon = new Rcon(options);
39
+ await rcon.connect();
40
+ return rcon;
41
+ }
42
+ on(event, listener) {
43
+ this.emitter.on(event, listener);
44
+ }
45
+ once(event, listener) {
46
+ this.emitter.once(event, listener);
47
+ }
48
+ off(event, listener) {
49
+ this.emitter.removeListener(event, listener);
50
+ }
51
+ async connect() {
52
+ if (this.socket)
53
+ throw new Error('Already connected');
54
+ // ── Phase 1: TCP 连接 ──
55
+ const socket = (0, net_1.createConnection)({ host: this.host, port: this.port });
56
+ this.socket = socket;
57
+ await new Promise((resolve, reject) => {
58
+ const timer = setTimeout(() => {
59
+ socket.destroy();
60
+ this.socket = null;
61
+ reject(new Error(`RCON: TCP connection to ${this.host}:${this.port} timed out (${this.timeout}ms)`));
62
+ }, this.timeout);
63
+ const onError = (err) => {
64
+ clearTimeout(timer);
65
+ this.socket = null;
66
+ reject(err);
67
+ };
68
+ socket.once('error', onError);
69
+ socket.once('connect', () => {
70
+ clearTimeout(timer);
71
+ socket.removeListener('error', onError);
72
+ resolve();
73
+ });
74
+ });
75
+ socket.setNoDelay(true);
76
+ socket.on('data', (chunk) => {
77
+ this.buf = Buffer.concat([this.buf, chunk]);
78
+ this.drain();
79
+ });
80
+ socket.on('error', (err) => this.emitter.emit('error', err));
81
+ socket.on('close', () => {
82
+ this.authenticated = false;
83
+ this.socket = null;
84
+ this.buf = Buffer.alloc(0);
85
+ for (const [, p] of this.pending) {
86
+ clearTimeout(p.timer);
87
+ p.reject(new Error('Connection closed'));
88
+ }
89
+ this.pending.clear();
90
+ if (this.authCb) {
91
+ clearTimeout(this.authCb.timer);
92
+ this.authCb.reject(new Error('Connection closed during authentication'));
93
+ this.authCb = null;
94
+ }
95
+ this.emitter.emit('end');
96
+ });
97
+ // ── Phase 2: RCON 认证 ──
98
+ const authId = this.nextId++;
99
+ await new Promise((resolve, reject) => {
100
+ const timer = setTimeout(() => {
101
+ this.authCb = null;
102
+ socket.destroy();
103
+ this.socket = null;
104
+ reject(new Error(`RCON: Authentication timed out (${this.timeout}ms)`));
105
+ }, this.timeout);
106
+ this.authCb = { id: authId, resolve, reject, timer };
107
+ // Auth packet: type = 3 (SERVERDATA_AUTH)
108
+ socket.write(this.encode(authId, 3, this.password));
109
+ });
110
+ }
111
+ async send(command) {
112
+ if (!this.authenticated || !this.socket) {
113
+ throw new Error('RCON not connected');
114
+ }
115
+ const id = this.nextId++;
116
+ return new Promise((resolve, reject) => {
117
+ const timer = setTimeout(() => {
118
+ this.pending.delete(id);
119
+ reject(new Error(`RCON command timed out: ${command}`));
120
+ }, this.timeout);
121
+ this.pending.set(id, { resolve, reject, timer });
122
+ // Command packet: type = 2 (SERVERDATA_EXECCOMMAND)
123
+ this.socket.write(this.encode(id, 2, command));
124
+ });
125
+ }
126
+ end() {
127
+ this.socket?.destroy();
128
+ }
129
+ // ── RCON 协议编解码 ──
130
+ /**
131
+ * 编码 RCON 数据包
132
+ * 格式: [4:size][4:id][4:type][body][0x00][0x00]
133
+ */
134
+ encode(id, type, body) {
135
+ const payload = Buffer.from(body, 'utf-8');
136
+ const size = 4 + 4 + payload.length + 2; // id + type + body + 2 null terminators
137
+ const pkt = Buffer.alloc(4 + size); // 4 for size field
138
+ pkt.writeInt32LE(size, 0);
139
+ pkt.writeInt32LE(id, 4);
140
+ pkt.writeInt32LE(type, 8);
141
+ payload.copy(pkt, 12);
142
+ // 末尾 2 字节已由 Buffer.alloc 置 0x00(null terminators)
143
+ return pkt;
144
+ }
145
+ /**
146
+ * 从缓冲区中提取并处理完整的 RCON 数据包
147
+ * 正确处理 TCP 分包和合包
148
+ */
149
+ drain() {
150
+ while (this.buf.length >= 4) {
151
+ const size = this.buf.readInt32LE(0);
152
+ // 安全校验:size 最小为 10(4 id + 4 type + 2 null),最大为 1MB
153
+ if (size < 10 || size > 0x100000) {
154
+ this.buf = Buffer.alloc(0);
155
+ return;
156
+ }
157
+ const total = 4 + size;
158
+ if (this.buf.length < total)
159
+ return; // 等待更多数据
160
+ const raw = this.buf.subarray(0, total);
161
+ this.buf = this.buf.subarray(total);
162
+ const id = raw.readInt32LE(4);
163
+ const type = raw.readInt32LE(8);
164
+ // body 位于 header(12字节) 之后、末尾 2 字节 null 之前
165
+ const bodyEnd = Math.max(12, total - 2);
166
+ const body = raw.subarray(12, bodyEnd).toString('utf-8').replace(/\0+$/, '');
167
+ this.onPacket(id, type, body);
168
+ }
169
+ }
170
+ /**
171
+ * 处理接收到的 RCON 数据包
172
+ *
173
+ * 认证阶段:
174
+ * Minecraft 服务端对认证请求的响应因实现而异:
175
+ * - Vanilla: 仅发送 Auth_Response (type=2, id=请求id 或 -1)
176
+ * - 部分实现: 先发送空 Response_Value (type=0) 再发送 Auth_Response
177
+ *
178
+ * 处理策略:
179
+ * 1. 收到 type=2(Auth_Response)→ 立即判断认证结果
180
+ * 2. 收到 id=-1 → 认证失败(无论 type)
181
+ * 3. 其他包 → 忽略(可能是前导空响应)
182
+ */
183
+ onPacket(id, type, body) {
184
+ if (!this.authenticated && this.authCb) {
185
+ // Auth_Response (type=2) 或 id=-1 → 最终认证结果
186
+ if (type === 2 || id === -1) {
187
+ const cb = this.authCb;
188
+ this.authCb = null;
189
+ clearTimeout(cb.timer);
190
+ if (id === -1) {
191
+ cb.reject(new Error('RCON authentication failed: incorrect password'));
192
+ }
193
+ else if (id === cb.id) {
194
+ this.authenticated = true;
195
+ this.emitter.emit('authenticated');
196
+ cb.resolve();
197
+ }
198
+ else {
199
+ cb.reject(new Error(`RCON authentication failed: unexpected response id ${id}`));
200
+ }
201
+ return;
202
+ }
203
+ // type != 2 且 id != -1 → 前导空响应包,忽略
204
+ return;
205
+ }
206
+ // ── 命令响应 ──
207
+ const cb = this.pending.get(id);
208
+ if (cb) {
209
+ this.pending.delete(id);
210
+ clearTimeout(cb.timer);
211
+ cb.resolve(body);
212
+ }
213
+ }
214
+ }
215
+ exports.Rcon = Rcon;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-minecraft-adapter",
3
3
  "description": "Minecraft adapter for Koishi based on QueQiao V2 protocol",
4
- "version": "1.0.8",
4
+ "version": "1.0.10",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
@@ -22,7 +22,6 @@
22
22
  "koishi": "^4.17.9"
23
23
  },
24
24
  "dependencies": {
25
- "rcon-client": "^4.2.5",
26
25
  "ws": "^8.18.0"
27
26
  },
28
27
  "devDependencies": {