koishi-plugin-minecraft-adapter 1.0.10 → 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.js +11 -4
- package/lib/index.ts.backup +492 -0
- package/lib/rcon.d.ts +2 -0
- package/lib/rcon.js +4 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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_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)')
|
|
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
CHANGED
|
@@ -3,6 +3,7 @@ export interface RconOptions {
|
|
|
3
3
|
port?: number;
|
|
4
4
|
password: string;
|
|
5
5
|
timeout?: number;
|
|
6
|
+
debug?: boolean;
|
|
6
7
|
}
|
|
7
8
|
/**
|
|
8
9
|
* 轻量级 Minecraft RCON 客户端
|
|
@@ -26,6 +27,7 @@ export declare class Rcon {
|
|
|
26
27
|
private port;
|
|
27
28
|
private password;
|
|
28
29
|
private timeout;
|
|
30
|
+
private debug;
|
|
29
31
|
constructor(options: RconOptions);
|
|
30
32
|
static connect(options: RconOptions): Promise<Rcon>;
|
|
31
33
|
on(event: string, listener: (...args: any[]) => void): void;
|
package/lib/rcon.js
CHANGED
|
@@ -27,12 +27,14 @@ class Rcon {
|
|
|
27
27
|
port;
|
|
28
28
|
password;
|
|
29
29
|
timeout;
|
|
30
|
+
debug;
|
|
30
31
|
constructor(options) {
|
|
31
32
|
this.host = options.host;
|
|
32
33
|
this.port = options.port ?? 25575;
|
|
33
34
|
// 关键修复:强制转为字符串,防止 YAML 将纯数字/布尔密码解析为非 string 类型
|
|
34
35
|
this.password = String(options.password ?? '');
|
|
35
36
|
this.timeout = options.timeout ?? 5000;
|
|
37
|
+
this.debug = options.debug ?? false;
|
|
36
38
|
}
|
|
37
39
|
static async connect(options) {
|
|
38
40
|
const rcon = new Rcon(options);
|
|
@@ -105,7 +107,8 @@ class Rcon {
|
|
|
105
107
|
}, this.timeout);
|
|
106
108
|
this.authCb = { id: authId, resolve, reject, timer };
|
|
107
109
|
// Auth packet: type = 3 (SERVERDATA_AUTH)
|
|
108
|
-
|
|
110
|
+
const authPkt = this.encode(authId, 3, this.password);
|
|
111
|
+
socket.write(authPkt);
|
|
109
112
|
});
|
|
110
113
|
}
|
|
111
114
|
async send(command) {
|
package/package.json
CHANGED