koishi-plugin-minecraft-adapter 1.0.9 → 1.0.11

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,7 +5,7 @@ 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
  /**
@@ -415,7 +415,8 @@ class MinecraftAdapter extends koishi_1.Adapter {
415
415
  return states[state] || `UNKNOWN(${state})`;
416
416
  }
417
417
  async connectRcon(bot) {
418
- const config = bot.config;
418
+ // 优先使用 rconConfigs(经 migrateConfig 扁平化后的可靠配置),回退到 bot.config
419
+ const config = this.rconConfigs.get(bot.selfId) ?? bot.config;
419
420
  const selfId = bot.selfId;
420
421
  // rconHost 未配置时,从 WebSocket URL 中提取主机地址作为回退
421
422
  let rconHost = config.rconHost;
@@ -439,7 +440,8 @@ class MinecraftAdapter extends koishi_1.Adapter {
439
440
  });
440
441
  }
441
442
  try {
442
- const rcon = await this.createRconWithTimeout(rconHost, rconPort, config.rconPassword || '', rconTimeout);
443
+ const rconPassword = String(config.rconPassword ?? '');
444
+ const rcon = await this.createRconWithTimeout(rconHost, rconPort, rconPassword, rconTimeout);
443
445
  this.rconConnections.set(selfId, rcon);
444
446
  bot.rcon = rcon;
445
447
  this.rconReconnectAttempts.set(selfId, 0);
@@ -463,11 +465,12 @@ class MinecraftAdapter extends koishi_1.Adapter {
463
465
  }
464
466
  }
465
467
  createRconWithTimeout(host, port, password, timeout) {
468
+ const debug = this.debug;
466
469
  return new Promise((resolve, reject) => {
467
470
  const timer = setTimeout(() => {
468
471
  reject(new Error(`RCON TCP connection timeout after ${timeout}ms to ${host}:${port}`));
469
472
  }, timeout);
470
- rcon_client_1.Rcon.connect({ host, port, password, timeout }).then((rcon) => { clearTimeout(timer); resolve(rcon); }, (err) => { clearTimeout(timer); reject(err); });
473
+ rcon_1.Rcon.connect({ host, port, password, timeout, debug }).then((rcon) => { clearTimeout(timer); resolve(rcon); }, (err) => { clearTimeout(timer); reject(err); });
471
474
  });
472
475
  }
473
476
  scheduleRconReconnect(bot) {
@@ -1225,7 +1228,7 @@ MinecraftBot.MessageEncoder = MinecraftMessageEncoder;
1225
1228
  const serverSchema = koishi_1.Schema.object({
1226
1229
  selfId: koishi_1.Schema.string().description('机器人 ID(唯一标识)').required(),
1227
1230
  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(),
1231
+ url: koishi_1.Schema.string().description('WebSocket 地址(如 ws://127.0.0.1:8080)'),
1229
1232
  accessToken: koishi_1.Schema.string().description('访问令牌(需与鹊桥 config.yml 中的 access_token 一致)'),
1230
1233
  extraHeaders: koishi_1.Schema.dict(koishi_1.Schema.string()).description('额外请求头'),
1231
1234
  enableRcon: koishi_1.Schema.boolean().description('启用 RCON 远程命令执行').default(false),
@@ -1235,6 +1238,10 @@ const serverSchema = koishi_1.Schema.object({
1235
1238
  rconTimeout: koishi_1.Schema.number().description('RCON 超时时间(ms)').default(5000),
1236
1239
  enableChatImage: koishi_1.Schema.boolean().description('启用 ChatImage CICode 图片发送(需客户端安装 ChatImage Mod)').default(false),
1237
1240
  chatImageDefaultName: koishi_1.Schema.string().description('图片在聊天栏中的默认显示名称').default('图片'),
1241
+ // 接受嵌套配置格式(README 文档中推荐的格式),由 flattenServerConfig 扁平化
1242
+ websocket: koishi_1.Schema.any().hidden(),
1243
+ rcon: koishi_1.Schema.any().hidden(),
1244
+ chatImage: koishi_1.Schema.any().hidden(),
1238
1245
  });
1239
1246
  (function (MinecraftAdapter) {
1240
1247
  MinecraftAdapter.Config = koishi_1.Schema.object({
@@ -0,0 +1,492 @@
1
+ import { Adapter, Bot, Context, Logger, Schema, Session } from 'koishi'
2
+ import { Rcon } from 'rcon-client'
3
+ import WebSocket from 'ws'
4
+
5
+ const logger = new Logger('minecraft')
6
+
7
+ export interface MinecraftBotConfig {
8
+ selfId: string
9
+ serverName?: string
10
+ rcon?: {
11
+ host: string
12
+ port: number
13
+ password: string
14
+ timeout?: number
15
+ }
16
+ websocket?: {
17
+ url: string
18
+ accessToken?: string
19
+ extraHeaders?: Record<string, string>
20
+ }
21
+ }
22
+
23
+ export class MinecraftBot<C extends Context = Context> extends Bot<C, MinecraftBotConfig> {
24
+ public rcon?: Rcon
25
+ public ws?: WebSocket
26
+
27
+ constructor(ctx: C, config: MinecraftBotConfig) {
28
+ super(ctx, config, 'minecraft')
29
+ this.selfId = config.selfId
30
+ }
31
+
32
+ async sendMessage(channelId: string, content: string) {
33
+ if (channelId.startsWith('mc:')) {
34
+ const player = channelId.slice(3)
35
+ return await this.sendPrivateMessage(player, content)
36
+ } else {
37
+ if (this.adapter instanceof MinecraftAdapter) {
38
+ await this.adapter.broadcast(content)
39
+ return []
40
+ }
41
+ return []
42
+ }
43
+ }
44
+
45
+ async sendPrivateMessage(userId: string, content: string): Promise<string[]> {
46
+ if (this.adapter instanceof MinecraftAdapter) {
47
+ await this.adapter.sendPrivateMessage(userId, content)
48
+ return []
49
+ }
50
+ return []
51
+ }
52
+
53
+ async executeCommand(command: string): Promise<string> {
54
+ if (!this.rcon) throw new Error('RCON not connected')
55
+ return await this.rcon.send(command)
56
+ }
57
+ }
58
+
59
+ export interface MinecraftAdapterConfig {
60
+ bots: MinecraftBotConfig[]
61
+ debug?: boolean
62
+ reconnectInterval?: number
63
+ maxReconnectAttempts?: number
64
+ }
65
+
66
+ export class MinecraftAdapter<C extends Context = Context> extends Adapter<C, MinecraftBot<C>> {
67
+ private rconConnections = new Map<string, Rcon>()
68
+ private wsConnections = new Map<string, WebSocket>()
69
+ private reconnectAttempts = new Map<string, number>()
70
+ private debug: boolean
71
+ private reconnectInterval: number
72
+ private maxReconnectAttempts: number
73
+
74
+ constructor(ctx: C, config: MinecraftAdapterConfig) {
75
+ super(ctx)
76
+ this.debug = config.debug ?? false
77
+ this.reconnectInterval = config.reconnectInterval ?? 5000
78
+ this.maxReconnectAttempts = config.maxReconnectAttempts ?? 10
79
+
80
+ if (this.debug) {
81
+ logger.info(`[DEBUG] MinecraftAdapter initialized with config:`, {
82
+ debug: this.debug,
83
+ reconnectInterval: this.reconnectInterval,
84
+ maxReconnectAttempts: this.maxReconnectAttempts,
85
+ botCount: config.bots.length
86
+ })
87
+ }
88
+
89
+ // 为每个配置创建机器人
90
+ ctx.on('ready', async () => {
91
+ if (this.debug) {
92
+ logger.info(`[DEBUG] Koishi ready event triggered, initializing ${config.bots.length} bots`)
93
+ }
94
+
95
+ for (const botConfig of config.bots) {
96
+ if (this.debug) {
97
+ logger.info(`[DEBUG] Initializing bot ${botConfig.selfId}`)
98
+ }
99
+
100
+ const bot = new MinecraftBot(ctx, botConfig)
101
+ bot.adapter = this
102
+ this.bots.push(bot)
103
+
104
+ // 初始化 RCON 连接
105
+ if (botConfig.rcon) {
106
+ try {
107
+ if (this.debug) {
108
+ logger.info(`[DEBUG] Connecting RCON for bot ${botConfig.selfId} to ${botConfig.rcon.host}:${botConfig.rcon.port}`)
109
+ }
110
+
111
+ const rcon = await Rcon.connect({
112
+ host: botConfig.rcon.host,
113
+ port: botConfig.rcon.port,
114
+ password: botConfig.rcon.password,
115
+ timeout: botConfig.rcon.timeout || 5000,
116
+ })
117
+ this.rconConnections.set(botConfig.selfId, rcon)
118
+ bot.rcon = rcon
119
+ logger.info(`RCON connected for bot ${botConfig.selfId}`)
120
+ } catch (error) {
121
+ logger.warn(`Failed to connect RCON for bot ${botConfig.selfId}:`, error)
122
+ if (this.debug) {
123
+ logger.info(`[DEBUG] RCON connection error details:`, error.message, error.stack)
124
+ }
125
+ }
126
+ } else {
127
+ if (this.debug) {
128
+ logger.info(`[DEBUG] No RCON config for bot ${botConfig.selfId}`)
129
+ }
130
+ }
131
+
132
+ // 初始化 WebSocket 连接
133
+ if (botConfig.websocket) {
134
+ if (this.debug) {
135
+ logger.info(`[DEBUG] Initializing WebSocket for bot ${botConfig.selfId}`)
136
+ }
137
+ await this.connectWebSocket(bot, botConfig.websocket)
138
+ } else {
139
+ if (this.debug) {
140
+ logger.info(`[DEBUG] No WebSocket config for bot ${botConfig.selfId}`)
141
+ }
142
+ }
143
+ }
144
+ })
145
+ }
146
+
147
+ private getWebSocketCloseCode(code: number): string {
148
+ const codes: Record<number, string> = {
149
+ 1000: 'Normal Closure',
150
+ 1001: 'Going Away',
151
+ 1002: 'Protocol Error',
152
+ 1003: 'Unsupported Data',
153
+ 1004: 'Reserved',
154
+ 1005: 'No Status Received',
155
+ 1006: 'Abnormal Closure',
156
+ 1007: 'Invalid Frame Payload Data',
157
+ 1008: 'Policy Violation',
158
+ 1009: 'Message Too Big',
159
+ 1010: 'Missing Extension',
160
+ 1011: 'Internal Error',
161
+ 1012: 'Service Restart',
162
+ 1013: 'Try Again Later',
163
+ 1014: 'Bad Gateway',
164
+ 1015: 'TLS Handshake'
165
+ }
166
+ return codes[code] || `Unknown Code ${code}`
167
+ }
168
+
169
+ private getWebSocketStateString(state: number): string {
170
+ const states = {
171
+ 0: 'CONNECTING',
172
+ 1: 'OPEN',
173
+ 2: 'CLOSING',
174
+ 3: 'CLOSED'
175
+ }
176
+ return states[state as keyof typeof states] || `UNKNOWN(${state})`
177
+ }
178
+ const headers: Record<string, string> = {
179
+ 'x-self-name': bot.config.serverName || bot.selfId,
180
+ ...(wsConfig.extraHeaders || {}),
181
+ }
182
+ if (wsConfig.accessToken) {
183
+ headers['Authorization'] = `Bearer ${wsConfig.accessToken}`
184
+ }
185
+
186
+ if (this.debug) {
187
+ logger.info(`[DEBUG] Connecting to WebSocket: ${wsConfig.url}`)
188
+ logger.info(`[DEBUG] Headers:`, headers)
189
+ }
190
+
191
+ const ws = new WebSocket(wsConfig.url, { headers })
192
+ this.wsConnections.set(bot.selfId, ws)
193
+ bot.ws = ws
194
+
195
+ // 添加连接超时处理
196
+ const connectionTimeout = setTimeout(() => {
197
+ if (ws.readyState === WebSocket.CONNECTING) {
198
+ if (this.debug) {
199
+ logger.info(`[DEBUG] WebSocket connection timeout for bot ${bot.selfId}`)
200
+ }
201
+ ws.close()
202
+ }
203
+ }, 10000) // 10秒超时
204
+
205
+ ws.on('open', () => {
206
+ logger.info(`WebSocket connected for bot ${bot.selfId}`)
207
+ if (this.debug) {
208
+ logger.info(`[DEBUG] WebSocket opened successfully for bot ${bot.selfId}`)
209
+ }
210
+ // 重置重连尝试次数
211
+ this.reconnectAttempts.set(bot.selfId, 0)
212
+ bot.online()
213
+ clearTimeout(connectionTimeout)
214
+ })
215
+
216
+ ws.on('message', (data: WebSocket.RawData) => {
217
+ try {
218
+ const text = data.toString('utf8')
219
+ if (this.debug) {
220
+ logger.info(`[DEBUG] Received WebSocket message for bot ${bot.selfId}:`, text)
221
+ logger.info(`[DEBUG] Message length: ${text.length} characters`)
222
+ }
223
+
224
+ const obj = JSON.parse(text)
225
+ const type = obj.type || obj.event || 'unknown'
226
+ const payload = obj.data ?? obj
227
+
228
+ if (this.debug) {
229
+ logger.info(`[DEBUG] Parsed message type: ${type}, payload:`, payload)
230
+ }
231
+
232
+ const session = this.createSession(bot, type, payload)
233
+ if (session) {
234
+ if (this.debug) {
235
+ logger.info(`[DEBUG] Created session:`, session)
236
+ logger.info(`[DEBUG] Dispatching session to bot ${bot.selfId}`)
237
+ }
238
+ bot.dispatch(session)
239
+ if (this.debug) {
240
+ logger.info(`[DEBUG] Session dispatched successfully`)
241
+ }
242
+ } else {
243
+ if (this.debug) {
244
+ logger.info(`[DEBUG] No session created for message type: ${type}`)
245
+ }
246
+ }
247
+ } catch (error) {
248
+ logger.warn('Failed to process WebSocket message:', error)
249
+ if (this.debug) {
250
+ logger.info(`[DEBUG] Raw message data:`, data.toString('utf8'))
251
+ logger.info(`[DEBUG] Parse error:`, error.message, error.stack)
252
+ }
253
+ }
254
+ })
255
+
256
+ ws.on('close', (code, reason) => {
257
+ logger.warn(`WebSocket disconnected for bot ${bot.selfId} (code: ${code}, reason: ${reason.toString()})`)
258
+ if (this.debug) {
259
+ logger.info(`[DEBUG] Close code details:`, this.getWebSocketCloseCode(code))
260
+ logger.info(`[DEBUG] Close reason:`, reason.toString())
261
+ }
262
+ bot.offline()
263
+
264
+ const attempts = this.reconnectAttempts.get(bot.selfId) || 0
265
+ if (attempts < this.maxReconnectAttempts) {
266
+ this.reconnectAttempts.set(bot.selfId, attempts + 1)
267
+ const delay = this.reconnectInterval * Math.pow(2, attempts) // 指数退避
268
+ if (this.debug) {
269
+ logger.info(`[DEBUG] Attempting to reconnect WebSocket for bot ${bot.selfId} in ${delay}ms (attempt ${attempts + 1}/${this.maxReconnectAttempts})`)
270
+ }
271
+ setTimeout(() => {
272
+ if (!this.wsConnections.has(bot.selfId)) {
273
+ this.connectWebSocket(bot, wsConfig)
274
+ }
275
+ }, delay)
276
+ } else {
277
+ logger.error(`Max reconnect attempts reached for bot ${bot.selfId}`)
278
+ }
279
+ })
280
+
281
+ ws.on('error', (error) => {
282
+ logger.warn(`WebSocket error for bot ${bot.selfId}:`, error)
283
+ if (this.debug) {
284
+ logger.info(`[DEBUG] WebSocket error details:`, error.message, error.stack)
285
+ }
286
+ })
287
+
288
+ ws.on('ping', () => {
289
+ if (this.debug) {
290
+ logger.info(`[DEBUG] Received ping from server for bot ${bot.selfId}`)
291
+ }
292
+ })
293
+
294
+ ws.on('pong', () => {
295
+ if (this.debug) {
296
+ logger.info(`[DEBUG] Received pong from server for bot ${bot.selfId}`)
297
+ }
298
+ })
299
+ }
300
+
301
+ private createSession(bot: MinecraftBot<C>, type: string, payload: any): Session | undefined {
302
+ if (this.debug) {
303
+ logger.info(`[DEBUG] Creating session for event type: ${type}, payload:`, payload)
304
+ }
305
+
306
+ switch (type) {
307
+ case 'chat':
308
+ case 'player_chat':
309
+ return {
310
+ type: 'message',
311
+ subtype: 'private',
312
+ platform: 'minecraft',
313
+ selfId: bot.selfId,
314
+ userId: payload.player || payload.name || 'unknown',
315
+ channelId: `mc:${payload.player || payload.name || 'unknown'}`,
316
+ guildId: 'minecraft',
317
+ content: payload.message || payload.text || '',
318
+ timestamp: Date.now(),
319
+ author: {
320
+ userId: payload.player || payload.name || 'unknown',
321
+ username: payload.player || payload.name || 'unknown',
322
+ },
323
+ } as Session
324
+
325
+ case 'join':
326
+ case 'player_join':
327
+ return {
328
+ type: 'guild-member-added',
329
+ platform: 'minecraft',
330
+ selfId: bot.selfId,
331
+ userId: payload.player || payload.name || 'unknown',
332
+ guildId: 'minecraft',
333
+ timestamp: Date.now(),
334
+ } as Session
335
+
336
+ case 'leave':
337
+ case 'quit':
338
+ case 'player_quit':
339
+ return {
340
+ type: 'guild-member-removed',
341
+ platform: 'minecraft',
342
+ selfId: bot.selfId,
343
+ userId: payload.player || payload.name || 'unknown',
344
+ guildId: 'minecraft',
345
+ timestamp: Date.now(),
346
+ } as Session
347
+
348
+ case 'death':
349
+ case 'player_death':
350
+ return {
351
+ type: 'message',
352
+ subtype: 'private',
353
+ platform: 'minecraft',
354
+ selfId: bot.selfId,
355
+ userId: payload.player || payload.name || 'unknown',
356
+ channelId: 'minecraft',
357
+ guildId: 'minecraft',
358
+ content: `💀 ${payload.player || payload.name || 'unknown'} ${payload.deathMessage || 'died'}`,
359
+ timestamp: Date.now(),
360
+ author: {
361
+ userId: payload.player || payload.name || 'unknown',
362
+ username: payload.player || payload.name || 'unknown',
363
+ },
364
+ } as Session
365
+
366
+ case 'advancement':
367
+ case 'achievement':
368
+ return {
369
+ type: 'message',
370
+ subtype: 'private',
371
+ platform: 'minecraft',
372
+ selfId: bot.selfId,
373
+ userId: payload.player || payload.name || 'unknown',
374
+ channelId: 'minecraft',
375
+ guildId: 'minecraft',
376
+ content: `🏆 ${payload.player || payload.name || 'unknown'} achieved: ${payload.advancement || payload.achievement}`,
377
+ timestamp: Date.now(),
378
+ author: {
379
+ userId: payload.player || payload.name || 'unknown',
380
+ username: payload.player || payload.name || 'unknown',
381
+ },
382
+ } as Session
383
+
384
+ default:
385
+ // 自定义事件可以通过其他方式处理
386
+ if (this.debug) {
387
+ logger.info(`[DEBUG] Unhandled event type: ${type}, payload:`, payload)
388
+ }
389
+ logger.debug(`Unhandled event type: ${type}`, payload)
390
+ return undefined
391
+ }
392
+ }
393
+
394
+ async sendPrivateMessage(player: string, message: string): Promise<void> {
395
+ // 优先使用 WebSocket 发送
396
+ for (const [botId, ws] of this.wsConnections) {
397
+ if (ws.readyState === WebSocket.OPEN) {
398
+ const payload = {
399
+ api: 'tell',
400
+ data: { player, message }
401
+ }
402
+ ws.send(JSON.stringify(payload))
403
+ return
404
+ }
405
+ }
406
+
407
+ // 回退到 RCON
408
+ for (const [botId, rcon] of this.rconConnections) {
409
+ try {
410
+ const json = JSON.stringify([{ text: message }])
411
+ await rcon.send(`tellraw ${player} ${json}`)
412
+ return
413
+ } catch (error) {
414
+ logger.warn(`Failed to send message via RCON for bot ${botId}:`, error)
415
+ }
416
+ }
417
+
418
+ throw new Error('No available connection to send message')
419
+ }
420
+
421
+ async broadcast(message: string): Promise<void> {
422
+ // 优先使用 WebSocket 发送
423
+ for (const [botId, ws] of this.wsConnections) {
424
+ if (ws.readyState === WebSocket.OPEN) {
425
+ const payload = {
426
+ api: 'broadcast',
427
+ data: { message }
428
+ }
429
+ ws.send(JSON.stringify(payload))
430
+ return
431
+ }
432
+ }
433
+
434
+ // 回退到 RCON
435
+ for (const [botId, rcon] of this.rconConnections) {
436
+ try {
437
+ await rcon.send(`say ${message}`)
438
+ return
439
+ } catch (error) {
440
+ logger.warn(`Failed to broadcast via RCON for bot ${botId}:`, error)
441
+ }
442
+ }
443
+
444
+ throw new Error('No available connection to broadcast message')
445
+ }
446
+
447
+ async stop() {
448
+ // 关闭所有连接
449
+ for (const [botId, ws] of this.wsConnections) {
450
+ try {
451
+ ws.close()
452
+ } catch (error) {
453
+ logger.warn(`Failed to close WebSocket for bot ${botId}:`, error)
454
+ }
455
+ }
456
+ this.wsConnections.clear()
457
+
458
+ for (const [botId, rcon] of this.rconConnections) {
459
+ try {
460
+ rcon.end()
461
+ } catch (error) {
462
+ logger.warn(`Failed to close RCON for bot ${botId}:`, error)
463
+ }
464
+ }
465
+ this.rconConnections.clear()
466
+ }
467
+ }
468
+
469
+ export namespace MinecraftAdapter {
470
+ export const Config: Schema<MinecraftAdapterConfig> = Schema.object({
471
+ debug: Schema.boolean().description('启用调试模式,输出详细日志').default(false),
472
+ reconnectInterval: Schema.number().description('重连间隔时间(ms)').default(5000),
473
+ maxReconnectAttempts: Schema.number().description('最大重连尝试次数').default(10),
474
+ bots: Schema.array(Schema.object({
475
+ selfId: Schema.string().description('机器人 ID').required(),
476
+ serverName: Schema.string().description('服务器名称'),
477
+ rcon: Schema.object({
478
+ host: Schema.string().description('RCON 主机地址').default('127.0.0.1'),
479
+ port: Schema.number().description('RCON 端口').default(25575),
480
+ password: Schema.string().description('RCON 密码').required(),
481
+ timeout: Schema.number().description('RCON 超时时间(ms)').default(5000),
482
+ }).description('RCON 配置'),
483
+ websocket: Schema.object({
484
+ url: Schema.string().description('WebSocket 地址').required(),
485
+ accessToken: Schema.string().description('访问令牌'),
486
+ extraHeaders: Schema.dict(String).description('额外请求头'),
487
+ }).description('WebSocket 配置'),
488
+ })).description('机器人配置列表').default([]),
489
+ })
490
+ }
491
+
492
+ export default MinecraftAdapter
package/lib/rcon.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ export interface RconOptions {
2
+ host: string;
3
+ port?: number;
4
+ password: string;
5
+ timeout?: number;
6
+ debug?: boolean;
7
+ }
8
+ /**
9
+ * 轻量级 Minecraft RCON 客户端
10
+ * 替换已停止维护的 rcon-client 库,修复认证兼容性问题
11
+ *
12
+ * 主要改进:
13
+ * - 正确处理 Minecraft 服务端的认证响应(兼容 Vanilla/Paper/Spigot/Fabric)
14
+ * - 稳健的 TCP 分包/合包缓冲处理
15
+ * - 独立的 TCP 连接超时和认证超时
16
+ * - 密码类型安全(防止 YAML 解析为非字符串)
17
+ */
18
+ export declare class Rcon {
19
+ private socket;
20
+ private emitter;
21
+ private buf;
22
+ private nextId;
23
+ private authenticated;
24
+ private authCb;
25
+ private pending;
26
+ private host;
27
+ private port;
28
+ private password;
29
+ private timeout;
30
+ private debug;
31
+ constructor(options: RconOptions);
32
+ static connect(options: RconOptions): Promise<Rcon>;
33
+ on(event: string, listener: (...args: any[]) => void): void;
34
+ once(event: string, listener: (...args: any[]) => void): void;
35
+ off(event: string, listener: (...args: any[]) => void): void;
36
+ connect(): Promise<void>;
37
+ send(command: string): Promise<string>;
38
+ end(): void;
39
+ /**
40
+ * 编码 RCON 数据包
41
+ * 格式: [4:size][4:id][4:type][body][0x00][0x00]
42
+ */
43
+ private encode;
44
+ /**
45
+ * 从缓冲区中提取并处理完整的 RCON 数据包
46
+ * 正确处理 TCP 分包和合包
47
+ */
48
+ private drain;
49
+ /**
50
+ * 处理接收到的 RCON 数据包
51
+ *
52
+ * 认证阶段:
53
+ * Minecraft 服务端对认证请求的响应因实现而异:
54
+ * - Vanilla: 仅发送 Auth_Response (type=2, id=请求id 或 -1)
55
+ * - 部分实现: 先发送空 Response_Value (type=0) 再发送 Auth_Response
56
+ *
57
+ * 处理策略:
58
+ * 1. 收到 type=2(Auth_Response)→ 立即判断认证结果
59
+ * 2. 收到 id=-1 → 认证失败(无论 type)
60
+ * 3. 其他包 → 忽略(可能是前导空响应)
61
+ */
62
+ private onPacket;
63
+ }
package/lib/rcon.js ADDED
@@ -0,0 +1,218 @@
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
+ debug;
31
+ constructor(options) {
32
+ this.host = options.host;
33
+ this.port = options.port ?? 25575;
34
+ // 关键修复:强制转为字符串,防止 YAML 将纯数字/布尔密码解析为非 string 类型
35
+ this.password = String(options.password ?? '');
36
+ this.timeout = options.timeout ?? 5000;
37
+ this.debug = options.debug ?? false;
38
+ }
39
+ static async connect(options) {
40
+ const rcon = new Rcon(options);
41
+ await rcon.connect();
42
+ return rcon;
43
+ }
44
+ on(event, listener) {
45
+ this.emitter.on(event, listener);
46
+ }
47
+ once(event, listener) {
48
+ this.emitter.once(event, listener);
49
+ }
50
+ off(event, listener) {
51
+ this.emitter.removeListener(event, listener);
52
+ }
53
+ async connect() {
54
+ if (this.socket)
55
+ throw new Error('Already connected');
56
+ // ── Phase 1: TCP 连接 ──
57
+ const socket = (0, net_1.createConnection)({ host: this.host, port: this.port });
58
+ this.socket = socket;
59
+ await new Promise((resolve, reject) => {
60
+ const timer = setTimeout(() => {
61
+ socket.destroy();
62
+ this.socket = null;
63
+ reject(new Error(`RCON: TCP connection to ${this.host}:${this.port} timed out (${this.timeout}ms)`));
64
+ }, this.timeout);
65
+ const onError = (err) => {
66
+ clearTimeout(timer);
67
+ this.socket = null;
68
+ reject(err);
69
+ };
70
+ socket.once('error', onError);
71
+ socket.once('connect', () => {
72
+ clearTimeout(timer);
73
+ socket.removeListener('error', onError);
74
+ resolve();
75
+ });
76
+ });
77
+ socket.setNoDelay(true);
78
+ socket.on('data', (chunk) => {
79
+ this.buf = Buffer.concat([this.buf, chunk]);
80
+ this.drain();
81
+ });
82
+ socket.on('error', (err) => this.emitter.emit('error', err));
83
+ socket.on('close', () => {
84
+ this.authenticated = false;
85
+ this.socket = null;
86
+ this.buf = Buffer.alloc(0);
87
+ for (const [, p] of this.pending) {
88
+ clearTimeout(p.timer);
89
+ p.reject(new Error('Connection closed'));
90
+ }
91
+ this.pending.clear();
92
+ if (this.authCb) {
93
+ clearTimeout(this.authCb.timer);
94
+ this.authCb.reject(new Error('Connection closed during authentication'));
95
+ this.authCb = null;
96
+ }
97
+ this.emitter.emit('end');
98
+ });
99
+ // ── Phase 2: RCON 认证 ──
100
+ const authId = this.nextId++;
101
+ await new Promise((resolve, reject) => {
102
+ const timer = setTimeout(() => {
103
+ this.authCb = null;
104
+ socket.destroy();
105
+ this.socket = null;
106
+ reject(new Error(`RCON: Authentication timed out (${this.timeout}ms)`));
107
+ }, this.timeout);
108
+ this.authCb = { id: authId, resolve, reject, timer };
109
+ // Auth packet: type = 3 (SERVERDATA_AUTH)
110
+ const authPkt = this.encode(authId, 3, this.password);
111
+ socket.write(authPkt);
112
+ });
113
+ }
114
+ async send(command) {
115
+ if (!this.authenticated || !this.socket) {
116
+ throw new Error('RCON not connected');
117
+ }
118
+ const id = this.nextId++;
119
+ return new Promise((resolve, reject) => {
120
+ const timer = setTimeout(() => {
121
+ this.pending.delete(id);
122
+ reject(new Error(`RCON command timed out: ${command}`));
123
+ }, this.timeout);
124
+ this.pending.set(id, { resolve, reject, timer });
125
+ // Command packet: type = 2 (SERVERDATA_EXECCOMMAND)
126
+ this.socket.write(this.encode(id, 2, command));
127
+ });
128
+ }
129
+ end() {
130
+ this.socket?.destroy();
131
+ }
132
+ // ── RCON 协议编解码 ──
133
+ /**
134
+ * 编码 RCON 数据包
135
+ * 格式: [4:size][4:id][4:type][body][0x00][0x00]
136
+ */
137
+ encode(id, type, body) {
138
+ const payload = Buffer.from(body, 'utf-8');
139
+ const size = 4 + 4 + payload.length + 2; // id + type + body + 2 null terminators
140
+ const pkt = Buffer.alloc(4 + size); // 4 for size field
141
+ pkt.writeInt32LE(size, 0);
142
+ pkt.writeInt32LE(id, 4);
143
+ pkt.writeInt32LE(type, 8);
144
+ payload.copy(pkt, 12);
145
+ // 末尾 2 字节已由 Buffer.alloc 置 0x00(null terminators)
146
+ return pkt;
147
+ }
148
+ /**
149
+ * 从缓冲区中提取并处理完整的 RCON 数据包
150
+ * 正确处理 TCP 分包和合包
151
+ */
152
+ drain() {
153
+ while (this.buf.length >= 4) {
154
+ const size = this.buf.readInt32LE(0);
155
+ // 安全校验:size 最小为 10(4 id + 4 type + 2 null),最大为 1MB
156
+ if (size < 10 || size > 0x100000) {
157
+ this.buf = Buffer.alloc(0);
158
+ return;
159
+ }
160
+ const total = 4 + size;
161
+ if (this.buf.length < total)
162
+ return; // 等待更多数据
163
+ const raw = this.buf.subarray(0, total);
164
+ this.buf = this.buf.subarray(total);
165
+ const id = raw.readInt32LE(4);
166
+ const type = raw.readInt32LE(8);
167
+ // body 位于 header(12字节) 之后、末尾 2 字节 null 之前
168
+ const bodyEnd = Math.max(12, total - 2);
169
+ const body = raw.subarray(12, bodyEnd).toString('utf-8').replace(/\0+$/, '');
170
+ this.onPacket(id, type, body);
171
+ }
172
+ }
173
+ /**
174
+ * 处理接收到的 RCON 数据包
175
+ *
176
+ * 认证阶段:
177
+ * Minecraft 服务端对认证请求的响应因实现而异:
178
+ * - Vanilla: 仅发送 Auth_Response (type=2, id=请求id 或 -1)
179
+ * - 部分实现: 先发送空 Response_Value (type=0) 再发送 Auth_Response
180
+ *
181
+ * 处理策略:
182
+ * 1. 收到 type=2(Auth_Response)→ 立即判断认证结果
183
+ * 2. 收到 id=-1 → 认证失败(无论 type)
184
+ * 3. 其他包 → 忽略(可能是前导空响应)
185
+ */
186
+ onPacket(id, type, body) {
187
+ if (!this.authenticated && this.authCb) {
188
+ // Auth_Response (type=2) 或 id=-1 → 最终认证结果
189
+ if (type === 2 || id === -1) {
190
+ const cb = this.authCb;
191
+ this.authCb = null;
192
+ clearTimeout(cb.timer);
193
+ if (id === -1) {
194
+ cb.reject(new Error('RCON authentication failed: incorrect password'));
195
+ }
196
+ else if (id === cb.id) {
197
+ this.authenticated = true;
198
+ this.emitter.emit('authenticated');
199
+ cb.resolve();
200
+ }
201
+ else {
202
+ cb.reject(new Error(`RCON authentication failed: unexpected response id ${id}`));
203
+ }
204
+ return;
205
+ }
206
+ // type != 2 且 id != -1 → 前导空响应包,忽略
207
+ return;
208
+ }
209
+ // ── 命令响应 ──
210
+ const cb = this.pending.get(id);
211
+ if (cb) {
212
+ this.pending.delete(id);
213
+ clearTimeout(cb.timer);
214
+ cb.resolve(body);
215
+ }
216
+ }
217
+ }
218
+ 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.9",
4
+ "version": "1.0.11",
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": {