@napuketto/adapter 0.2.1 → 0.3.1
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/dist/gray-tip-8zPh4d9W.d.mts +1 -0
- package/dist/{raw-message-Bx18pIQS.mjs → gray-tip-B80z9KR4.mjs} +2 -12
- package/dist/gray-tip-B80z9KR4.mjs.map +1 -0
- package/dist/kurobot/index.d.mts +840 -0
- package/dist/kurobot/index.d.mts.map +1 -0
- package/dist/kurobot/index.mjs +998 -0
- package/dist/kurobot/index.mjs.map +1 -0
- package/dist/onebot11/index.d.mts +2 -2
- package/dist/onebot11/index.mjs +2 -1
- package/dist/onebot11/index.mjs.map +1 -1
- package/dist/raw-message-6KdlG8kR.mjs +13 -0
- package/dist/raw-message-6KdlG8kR.mjs.map +1 -0
- package/dist/satori/index.d.mts +2 -2
- package/dist/satori/index.mjs +2 -1
- package/dist/satori/index.mjs.map +1 -1
- package/package.json +9 -2
- package/dist/gray-tip-CcpfI1Og.d.mts +0 -1
- package/dist/raw-message-Bx18pIQS.mjs.map +0 -1
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
import { r as BaseProtocolAdapter } from "../core-of7sLiQX.mjs";
|
|
2
|
+
import { t as forEachRawMessage } from "../raw-message-6KdlG8kR.mjs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { ChatType, toCanonicalElements } from "@napuketto/kernel";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { WebSocket } from "ws";
|
|
8
|
+
//#region src/kurobot/schema.ts
|
|
9
|
+
/**
|
|
10
|
+
* kurobridge-ws 协议 schema(镜像实现,任务书 KUROBOT-PROMPT §1.2 决策一)
|
|
11
|
+
*
|
|
12
|
+
* **SSOT: KuroBridge bridge/protocol**(`C:\Dev\MC-Ecosystem\KuroAdapter\bridge\protocol\src\`),
|
|
13
|
+
* 镜像基线 **0.4.x**(KuroAdapter ADR-030 品牌改名:kurobot-ws.v1 → kurobridge-ws.v1,
|
|
14
|
+
* PROTOCOL_VERSION 0.4.0;帧形状与 0.3.1 逐字段一致,仅品牌字符串与版本号变更)。
|
|
15
|
+
* `@kuro-bridge/protocol` 已发 npm,本仓暂维持镜像(golden 帧对表测试锁漂移:
|
|
16
|
+
* connection.test.ts / schema.test.ts),将来可切换为真依赖。
|
|
17
|
+
*
|
|
18
|
+
* 只镜像 WS 对端可见的消息集(meta / frame / messages/ws);IPC 侧消息
|
|
19
|
+
* (ready/broadcast/execute_command 等,Java↔Node 内部通道)不镜像。
|
|
20
|
+
* 帧格式:`{header: {type, id?}, body}`(单行 JSON 文本帧);事件帧无 id,
|
|
21
|
+
* 请求/响应帧 id 必填(UUID 关联);解析后 transform 为扁平消息 `{type, id?, body}`。
|
|
22
|
+
*/
|
|
23
|
+
const PROTOCOL_NAME = "kurobridge-ws";
|
|
24
|
+
/** 语义化版本(主版本相同即兼容,ADR-026:0.2.x/0.3.0 服务端均接受 0.3.1 hello)。 */
|
|
25
|
+
const PROTOCOL_VERSION = "0.4.0";
|
|
26
|
+
/** WS 子协议(握手期校验,服务端 handleProtocols 不带即拒连)。 */
|
|
27
|
+
const WS_SUBPROTOCOL = "kurobridge-ws.v1";
|
|
28
|
+
const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
29
|
+
function parseMajor(version) {
|
|
30
|
+
const match = VERSION_PATTERN.exec(version);
|
|
31
|
+
if (match === null) return null;
|
|
32
|
+
const major = Number.parseInt(match[1], 10);
|
|
33
|
+
return Number.isNaN(major) ? null : major;
|
|
34
|
+
}
|
|
35
|
+
/** 协议版本兼容判定(镜像 meta.ts 同名函数):主版本号相同即兼容。 */
|
|
36
|
+
function isProtocolVersionCompatible(peerVersion, serverVersion) {
|
|
37
|
+
const peer = parseMajor(peerVersion);
|
|
38
|
+
const server = parseMajor(serverVersion);
|
|
39
|
+
return peer !== null && server !== null && peer === server;
|
|
40
|
+
}
|
|
41
|
+
const frameHeaderSchema = z.object({
|
|
42
|
+
type: z.string().regex(/^[a-z][a-z0-9_]*$/),
|
|
43
|
+
id: z.uuid().optional()
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* 通用线格式帧(两段式解析的第一段):只约束帧骨架(type 受 snake_case、
|
|
47
|
+
* id 存在时须 UUID、body 任意),供收帧侧「先取 type、再分发到具体 schema」。
|
|
48
|
+
*/
|
|
49
|
+
const wireFrameSchema = z.object({
|
|
50
|
+
header: frameHeaderSchema,
|
|
51
|
+
body: z.unknown()
|
|
52
|
+
});
|
|
53
|
+
/** 事件帧(单向通知,携带 id 即校验失败) */
|
|
54
|
+
function eventFrameSchema(type, body) {
|
|
55
|
+
return z.object({
|
|
56
|
+
header: z.strictObject({ type: z.literal(type) }),
|
|
57
|
+
body
|
|
58
|
+
}).transform((frame) => {
|
|
59
|
+
const wire = frame;
|
|
60
|
+
return {
|
|
61
|
+
type: wire.header.type,
|
|
62
|
+
body: wire.body
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** 请求/响应帧(id 必填) */
|
|
67
|
+
function requestFrameSchema(type, body) {
|
|
68
|
+
return z.object({
|
|
69
|
+
header: frameHeaderSchema.extend({
|
|
70
|
+
type: z.literal(type),
|
|
71
|
+
id: z.uuid()
|
|
72
|
+
}),
|
|
73
|
+
body
|
|
74
|
+
}).transform((frame) => {
|
|
75
|
+
const wire = frame;
|
|
76
|
+
return {
|
|
77
|
+
type: wire.header.type,
|
|
78
|
+
id: wire.header.id,
|
|
79
|
+
body: wire.body
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** 出帧:扁平消息 → 线格式 JSON 文本(单行,可直接走 WS 文本帧) */
|
|
84
|
+
function encodeFrame(message) {
|
|
85
|
+
const header = { type: message.type };
|
|
86
|
+
if (message.id !== void 0) header.id = message.id;
|
|
87
|
+
return JSON.stringify({
|
|
88
|
+
header,
|
|
89
|
+
body: message.body
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
z.union([z.object({ ok: z.literal(true) }), z.object({
|
|
93
|
+
ok: z.literal(false),
|
|
94
|
+
error: z.string().min(1)
|
|
95
|
+
})]);
|
|
96
|
+
/** 请求-响应的命令结果体(ok 分支可选 `output`:命令输出行;与 query/命令链共用)。 */
|
|
97
|
+
const commandResultBodySchema = z.union([z.object({
|
|
98
|
+
ok: z.literal(true),
|
|
99
|
+
output: z.array(z.string()).optional()
|
|
100
|
+
}), z.object({
|
|
101
|
+
ok: z.literal(false),
|
|
102
|
+
error: z.string().min(1)
|
|
103
|
+
})]);
|
|
104
|
+
/** 对端注册(请求,服务端必须回同 id 的 hello_ack)。 */
|
|
105
|
+
const helloFrame = requestFrameSchema("hello", z.object({
|
|
106
|
+
peerId: z.string().min(1),
|
|
107
|
+
platform: z.string().min(1),
|
|
108
|
+
version: z.string().min(1),
|
|
109
|
+
protocolVersion: z.string().regex(/^\d+\.\d+\.\d+$/),
|
|
110
|
+
/** 鉴权 token(服务端配置非空 token 时未带/带错 → hello_ack ok:false + close 1008)。 */
|
|
111
|
+
token: z.string().optional(),
|
|
112
|
+
/** 对端自报身份(0.3.1,MVP-3:建议 `名称/版本`,服务端仅日志辨识)。 */
|
|
113
|
+
client: z.string().optional()
|
|
114
|
+
}));
|
|
115
|
+
/** 心跳请求(应用层帧——服务端空闲检测只认应用层入帧,WS 层 ping 无效)。 */
|
|
116
|
+
const pingFrame = requestFrameSchema("ping", z.object({ timestamp: z.number().int().nonnegative() }));
|
|
117
|
+
/** 平台 → 游戏聊天(事件;服务端按绑定表过滤未绑定频道)。 */
|
|
118
|
+
const platformChatFrame = eventFrameSchema("chat", z.object({
|
|
119
|
+
/** 消息来源频道(QQ 群号字符串) */
|
|
120
|
+
channel: z.string().min(1),
|
|
121
|
+
sender: z.string().min(1),
|
|
122
|
+
content: z.string().min(1)
|
|
123
|
+
}));
|
|
124
|
+
const commandSourceSchema = z.object({
|
|
125
|
+
/** 消息来源频道(QQ 群号字符串) */
|
|
126
|
+
channel: z.string().min(1),
|
|
127
|
+
/** 发送者 QQ 号(协议端负责从群消息提取;管理员判定在服务端) */
|
|
128
|
+
userId: z.string().min(1)
|
|
129
|
+
});
|
|
130
|
+
/** 群指令 → 执行游戏命令(请求;响应为同 id command_result)。 */
|
|
131
|
+
const commandFrame = requestFrameSchema("command", z.object({
|
|
132
|
+
/** 待执行的命令行(不含前导斜杠,如 "whitelist list") */
|
|
133
|
+
command: z.string().min(1),
|
|
134
|
+
source: commandSourceSchema
|
|
135
|
+
}));
|
|
136
|
+
/** 状态/绑定查询(请求;响应为同 id query_result)。 */
|
|
137
|
+
const queryFrame = requestFrameSchema("query", z.object({
|
|
138
|
+
/** 查询类别:status = 最近一帧服务器状态快照;bindings = 当前绑定频道列表 */
|
|
139
|
+
kind: z.union([z.literal("status"), z.literal("bindings")]) }));
|
|
140
|
+
const helloAckOkBodySchema = z.object({
|
|
141
|
+
ok: z.literal(true),
|
|
142
|
+
serverId: z.string().min(1),
|
|
143
|
+
version: z.string().min(1),
|
|
144
|
+
protocolVersion: z.string().regex(/^\d+\.\d+\.\d+$/),
|
|
145
|
+
/** 服务端绑定表快照(空数组合法 = 全部不发)。 */
|
|
146
|
+
channelBindings: z.array(z.string().min(1))
|
|
147
|
+
});
|
|
148
|
+
const helloAckErrorBodySchema = z.object({
|
|
149
|
+
ok: z.literal(false),
|
|
150
|
+
reason: z.string().min(1)
|
|
151
|
+
});
|
|
152
|
+
/** 握手结果(响应,同 id)。 */
|
|
153
|
+
const helloAckFrame = z.union([requestFrameSchema("hello_ack", helloAckOkBodySchema), requestFrameSchema("hello_ack", helloAckErrorBodySchema)]);
|
|
154
|
+
/** 心跳响应(不需要处理:收不到就等服务端 1001 关闭走重连)。 */
|
|
155
|
+
const pongFrame = requestFrameSchema("pong", z.object({ timestamp: z.number().int().nonnegative() }));
|
|
156
|
+
/** 游戏 → 平台聊天(事件)。 */
|
|
157
|
+
const gameChatFrame = eventFrameSchema("chat", z.object({
|
|
158
|
+
/** 目标频道(服务端按绑定表逐频道 fan-out,每频道一帧) */
|
|
159
|
+
channel: z.string().min(1),
|
|
160
|
+
playerName: z.string().min(1),
|
|
161
|
+
content: z.string().min(1)
|
|
162
|
+
}));
|
|
163
|
+
/** 玩家进服(事件,按绑定频道 fan-out)。 */
|
|
164
|
+
const joinFrame = eventFrameSchema("join", z.object({
|
|
165
|
+
channel: z.string().min(1),
|
|
166
|
+
playerName: z.string().min(1)
|
|
167
|
+
}));
|
|
168
|
+
/** 玩家退服(事件,按绑定频道 fan-out)。 */
|
|
169
|
+
const leaveFrame = eventFrameSchema("leave", z.object({
|
|
170
|
+
channel: z.string().min(1),
|
|
171
|
+
playerName: z.string().min(1)
|
|
172
|
+
}));
|
|
173
|
+
/** 服务器状态(事件;无 channel——全服状态而非频道消息)。 */
|
|
174
|
+
const statusFrame = eventFrameSchema("status", z.object({
|
|
175
|
+
/** 1 分钟 TPS 均值(Paper getTPS()[0]) */
|
|
176
|
+
tps: z.number().nonnegative(),
|
|
177
|
+
onlinePlayers: z.number().int().nonnegative(),
|
|
178
|
+
uptimeSeconds: z.number().int().nonnegative()
|
|
179
|
+
}));
|
|
180
|
+
/** 绑定表变更推送(事件)。 */
|
|
181
|
+
const bindingsUpdatedFrame = eventFrameSchema("bindings_updated", z.object({
|
|
182
|
+
/** 变更后的完整绑定列表(非增量;空数组合法 = 全部不发)。 */
|
|
183
|
+
channelBindings: z.array(z.string().min(1)) }));
|
|
184
|
+
/** 群指令的执行结果(响应,同 id)。 */
|
|
185
|
+
const commandResultFrame = requestFrameSchema("command_result", commandResultBodySchema);
|
|
186
|
+
/** 查询结果(响应,同 id)。 */
|
|
187
|
+
const queryResultFrame = requestFrameSchema("query_result", z.union([z.object({
|
|
188
|
+
ok: z.literal(true),
|
|
189
|
+
/** data 形状由请求 kind 决定:status → StatusBody 同构;bindings → string[]。 */
|
|
190
|
+
data: z.unknown()
|
|
191
|
+
}), z.object({
|
|
192
|
+
ok: z.literal(false),
|
|
193
|
+
error: z.string().min(1)
|
|
194
|
+
})]));
|
|
195
|
+
/** 玩家死亡(事件,按绑定频道 fan-out 对齐 join/leave)。 */
|
|
196
|
+
const deathFrame = eventFrameSchema("death", z.object({
|
|
197
|
+
/** 目标频道(服务端按绑定表逐频道 fan-out,每频道一帧) */
|
|
198
|
+
channel: z.string().min(1),
|
|
199
|
+
/** 死亡玩家名(SSOT 原文命名 `player`——与 join/leave 的 playerName 不一致,照抄不「修正」) */
|
|
200
|
+
player: z.string().min(1),
|
|
201
|
+
/** 死亡消息文本(Bukkit deathMessage 可为 null → 服务端以空串兜底,故允许空串) */
|
|
202
|
+
message: z.string()
|
|
203
|
+
}));
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/kurobot/connection.ts
|
|
206
|
+
/**
|
|
207
|
+
* KurobotConnection:kurobridge-ws 客户端连接层(任务书 KUROBOT-PROMPT §1.2 决策二)
|
|
208
|
+
*
|
|
209
|
+
* 自建而不复用 network.WsClient 的四个协议语义理由(均已核实 WsClient 现状):
|
|
210
|
+
* ① 服务端 handleProtocols 校验子协议 `kurobridge-ws.v1`,不带直接拒连——WsClient
|
|
211
|
+
* 不支持 Sec-WebSocket-Protocol;
|
|
212
|
+
* ② 服务端空闲检测(30s)只认**应用层入帧**,WS 层 ws.ping() 不重置计时——必须发
|
|
213
|
+
* 应用层 ping 帧;
|
|
214
|
+
* ③ 每次连接(含重连)成功后必须立即重发 hello——WsClient 无 onOpen 回调;
|
|
215
|
+
* ④ 重连需感知 close code(1002/1008 停止重连)——WsClient 是固定延迟重连。
|
|
216
|
+
* 这四项都是 kurobridge-ws 协议语义,塞进协议无关的 network 违反其定位(红线:不改 network)。
|
|
217
|
+
*
|
|
218
|
+
* 状态机:connecting → establishing(hello 已发,等 ack)→ established(应用层 ping)
|
|
219
|
+
* ↔ backoff(指数退避 1s/2s/…/60s 封顶);hello_ack ok:false / close 1002 / 1008 →
|
|
220
|
+
* 永久停止(等版本升级或配置修正,error 日志)。未 established 时发送直接丢弃(不积压)。
|
|
221
|
+
*/
|
|
222
|
+
/**
|
|
223
|
+
* 已知 type → 具体 schema 校验(返回扁平消息;body 非法返回 null 由调用方统一 warn)。
|
|
224
|
+
* 与 schema.ts 的 Server→Peer 消息集一一对应;hello_ack 在连接层单独处理,不在此表。
|
|
225
|
+
*/
|
|
226
|
+
const SERVER_FRAME_PARSERS = {
|
|
227
|
+
pong: (json) => {
|
|
228
|
+
const ok = pongFrame.safeParse(json);
|
|
229
|
+
return ok.success ? {
|
|
230
|
+
type: "pong",
|
|
231
|
+
id: ok.data.id,
|
|
232
|
+
body: ok.data.body
|
|
233
|
+
} : null;
|
|
234
|
+
},
|
|
235
|
+
chat: (json) => {
|
|
236
|
+
const ok = gameChatFrame.safeParse(json);
|
|
237
|
+
return ok.success ? {
|
|
238
|
+
type: "chat",
|
|
239
|
+
body: ok.data.body
|
|
240
|
+
} : null;
|
|
241
|
+
},
|
|
242
|
+
join: (json) => {
|
|
243
|
+
const ok = joinFrame.safeParse(json);
|
|
244
|
+
return ok.success ? {
|
|
245
|
+
type: "join",
|
|
246
|
+
body: ok.data.body
|
|
247
|
+
} : null;
|
|
248
|
+
},
|
|
249
|
+
leave: (json) => {
|
|
250
|
+
const ok = leaveFrame.safeParse(json);
|
|
251
|
+
return ok.success ? {
|
|
252
|
+
type: "leave",
|
|
253
|
+
body: ok.data.body
|
|
254
|
+
} : null;
|
|
255
|
+
},
|
|
256
|
+
death: (json) => {
|
|
257
|
+
const ok = deathFrame.safeParse(json);
|
|
258
|
+
return ok.success ? {
|
|
259
|
+
type: "death",
|
|
260
|
+
body: ok.data.body
|
|
261
|
+
} : null;
|
|
262
|
+
},
|
|
263
|
+
status: (json) => {
|
|
264
|
+
const ok = statusFrame.safeParse(json);
|
|
265
|
+
return ok.success ? {
|
|
266
|
+
type: "status",
|
|
267
|
+
body: ok.data.body
|
|
268
|
+
} : null;
|
|
269
|
+
},
|
|
270
|
+
bindings_updated: (json) => {
|
|
271
|
+
const ok = bindingsUpdatedFrame.safeParse(json);
|
|
272
|
+
return ok.success ? {
|
|
273
|
+
type: "bindings_updated",
|
|
274
|
+
body: ok.data.body
|
|
275
|
+
} : null;
|
|
276
|
+
},
|
|
277
|
+
command_result: (json) => {
|
|
278
|
+
const ok = commandResultFrame.safeParse(json);
|
|
279
|
+
return ok.success ? {
|
|
280
|
+
type: "command_result",
|
|
281
|
+
id: ok.data.id,
|
|
282
|
+
body: ok.data.body
|
|
283
|
+
} : null;
|
|
284
|
+
},
|
|
285
|
+
query_result: (json) => {
|
|
286
|
+
const ok = queryResultFrame.safeParse(json);
|
|
287
|
+
return ok.success ? {
|
|
288
|
+
type: "query_result",
|
|
289
|
+
id: ok.data.id,
|
|
290
|
+
body: ok.data.body
|
|
291
|
+
} : null;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
/** kurobridge-ws 客户端连接(子协议 + hello + 应用层 ping + 感知 close code 的指数退避)。 */
|
|
295
|
+
var KurobotConnection = class {
|
|
296
|
+
opts;
|
|
297
|
+
log;
|
|
298
|
+
state = "idle";
|
|
299
|
+
ws = null;
|
|
300
|
+
/** 用户主动 stop(其后一切 close 都不再重连、不上报 onEnded)。 */
|
|
301
|
+
userStopped = false;
|
|
302
|
+
/** 已判定永久拒绝(版本/token),不再重连。 */
|
|
303
|
+
rejected = false;
|
|
304
|
+
pendingHelloId = null;
|
|
305
|
+
helloTimer = null;
|
|
306
|
+
pingTimer = null;
|
|
307
|
+
retryTimer = null;
|
|
308
|
+
/** 连续失败计数(established 成功后清零)。 */
|
|
309
|
+
attempt = 0;
|
|
310
|
+
constructor(opts) {
|
|
311
|
+
this.opts = opts;
|
|
312
|
+
this.log = opts.logger ?? SILENT_LOGGER;
|
|
313
|
+
}
|
|
314
|
+
/** 当前是否已建立(ack ok)。 */
|
|
315
|
+
isEstablished() {
|
|
316
|
+
return this.state === "established";
|
|
317
|
+
}
|
|
318
|
+
/** 启动:发起首次连接(异步,结果经回调上报;重复调用幂等)。 */
|
|
319
|
+
start() {
|
|
320
|
+
if (this.state !== "idle") return;
|
|
321
|
+
this.connect();
|
|
322
|
+
}
|
|
323
|
+
/** 停止:关闭连接、清定时器、不再重连(幂等;不触发 onEnded)。 */
|
|
324
|
+
stop() {
|
|
325
|
+
if (this.state === "stopped") return;
|
|
326
|
+
this.userStopped = true;
|
|
327
|
+
this.clearTimers();
|
|
328
|
+
this.state = "stopped";
|
|
329
|
+
const ws = this.ws;
|
|
330
|
+
this.ws = null;
|
|
331
|
+
ws?.close(1e3, "client stop");
|
|
332
|
+
}
|
|
333
|
+
/** 发送出站帧(chat/command/query 等扁平消息);未 established 丢弃 + debug。 */
|
|
334
|
+
send(message) {
|
|
335
|
+
const ws = this.ws;
|
|
336
|
+
if (this.state !== "established" || ws === null || ws.readyState !== WebSocket.OPEN) {
|
|
337
|
+
this.log.debug({ type: message.type }, "kurobot: 未建立连接,丢弃出站帧");
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
ws.send(encodeFrame(message));
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
/** 发起一次连接。 */
|
|
344
|
+
connect() {
|
|
345
|
+
if (this.userStopped || this.rejected) return;
|
|
346
|
+
this.state = "connecting";
|
|
347
|
+
const ws = new WebSocket(this.opts.url, WS_SUBPROTOCOL);
|
|
348
|
+
this.ws = ws;
|
|
349
|
+
ws.on("open", () => {
|
|
350
|
+
this.onOpen();
|
|
351
|
+
});
|
|
352
|
+
ws.on("message", (data) => {
|
|
353
|
+
this.onText(String(data));
|
|
354
|
+
});
|
|
355
|
+
ws.on("close", (code, reason) => {
|
|
356
|
+
this.onClose(code, reason.toString());
|
|
357
|
+
});
|
|
358
|
+
ws.on("error", (err) => {
|
|
359
|
+
this.log.warn({ err: err.message }, "kurobot: 连接错误");
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/** 连接建立:立即发 hello(每次连接都重发——服务端按连接会话握手)。 */
|
|
363
|
+
onOpen() {
|
|
364
|
+
if (this.ws === null || this.userStopped || this.rejected) return;
|
|
365
|
+
this.state = "establishing";
|
|
366
|
+
this.pendingHelloId = randomUUID();
|
|
367
|
+
this.ws.send(encodeFrame({
|
|
368
|
+
type: "hello",
|
|
369
|
+
id: this.pendingHelloId,
|
|
370
|
+
body: this.opts.hello
|
|
371
|
+
}));
|
|
372
|
+
this.helloTimer = setTimeout(() => {
|
|
373
|
+
this.helloTimer = null;
|
|
374
|
+
if (this.state === "establishing") {
|
|
375
|
+
this.log.warn({}, "kurobot: 等待 hello_ack 超时,关闭重试");
|
|
376
|
+
this.ws?.close();
|
|
377
|
+
}
|
|
378
|
+
}, this.opts.helloTimeoutMs);
|
|
379
|
+
}
|
|
380
|
+
/** 收到文本帧:两段式解析(wire 取 type → 分发具体 schema)+ 未知帧容忍。 */
|
|
381
|
+
onText(text) {
|
|
382
|
+
let json;
|
|
383
|
+
try {
|
|
384
|
+
json = JSON.parse(text);
|
|
385
|
+
} catch {
|
|
386
|
+
this.log.warn({ text: text.slice(0, 200) }, "kurobot: 非法 JSON 帧,忽略");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const wire = wireFrameSchema.safeParse(json);
|
|
390
|
+
if (!wire.success) {
|
|
391
|
+
this.log.warn({ text: text.slice(0, 200) }, "kurobot: 帧骨架非法,忽略");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const { type } = wire.data.header;
|
|
395
|
+
if (type === "hello") {
|
|
396
|
+
this.log.warn({}, "kurobot: 收到非预期的 hello 帧,忽略");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (type === "hello_ack") {
|
|
400
|
+
this.handleHelloAck(json);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const msg = this.parseServerFrame(type, json);
|
|
404
|
+
if (msg !== null) this.opts.onFrame(msg);
|
|
405
|
+
}
|
|
406
|
+
/** 已知 type 的具体 schema 校验(未知 type 走容忍;body 非法 warn 丢弃)。 */
|
|
407
|
+
parseServerFrame(type, json) {
|
|
408
|
+
const parser = SERVER_FRAME_PARSERS[type];
|
|
409
|
+
if (parser === void 0) {
|
|
410
|
+
this.handleUnknownFrame(type, headerId(json));
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const msg = parser(json);
|
|
414
|
+
if (msg === null) this.log.warn({ type }, "kurobot: 已知帧 body 校验失败,丢弃");
|
|
415
|
+
return msg;
|
|
416
|
+
}
|
|
417
|
+
/** 未知帧容忍(对齐 ADR-026 服务端策略):事件帧忽略;请求帧回 `<type>_result` 失败回执;`_result` 结尾不回执(防乒乓)。 */
|
|
418
|
+
handleUnknownFrame(type, id) {
|
|
419
|
+
if (id === void 0) {
|
|
420
|
+
this.log.debug({ type }, "kurobot: 未知事件帧,忽略");
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (type.endsWith("_result")) {
|
|
424
|
+
this.log.debug({ type }, "kurobot: 未知响应帧,忽略(不回执防乒乓)");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
this.log.debug({ type }, "kurobot: 未知请求帧,回执 unknown frame type");
|
|
428
|
+
this.send({
|
|
429
|
+
type: `${type}_result`,
|
|
430
|
+
id,
|
|
431
|
+
body: {
|
|
432
|
+
ok: false,
|
|
433
|
+
error: "unknown frame type"
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
/** hello_ack 处理:ok → 建立;ok:false → 永久停止(版本/鉴权问题不可自愈)。 */
|
|
438
|
+
handleHelloAck(json) {
|
|
439
|
+
const parsed = helloAckFrame.safeParse(json);
|
|
440
|
+
if (!parsed.success) {
|
|
441
|
+
this.log.warn({ type: "hello_ack" }, "kurobot: 已知帧 body 校验失败,丢弃");
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (parsed.data.id !== this.pendingHelloId) {
|
|
445
|
+
this.log.warn({}, "kurobot: hello_ack id 与请求不匹配,忽略");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (!parsed.data.body.ok) {
|
|
449
|
+
this.log.error({ reason: parsed.data.body.reason }, "kurobot: 握手被拒,停止重连");
|
|
450
|
+
this.reject(`hello_ack: ${parsed.data.body.reason}`);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const ack = parsed.data.body;
|
|
454
|
+
if (!isProtocolVersionCompatible(this.opts.hello.protocolVersion, ack.protocolVersion)) this.log.warn({
|
|
455
|
+
peer: ack.protocolVersion,
|
|
456
|
+
self: this.opts.hello.protocolVersion
|
|
457
|
+
}, "kurobot: hello_ack 版本与本地声明主版本不一致");
|
|
458
|
+
if (this.helloTimer !== null) {
|
|
459
|
+
clearTimeout(this.helloTimer);
|
|
460
|
+
this.helloTimer = null;
|
|
461
|
+
}
|
|
462
|
+
this.attempt = 0;
|
|
463
|
+
this.state = "established";
|
|
464
|
+
this.startPing();
|
|
465
|
+
this.log.info({
|
|
466
|
+
serverId: ack.serverId,
|
|
467
|
+
version: ack.version,
|
|
468
|
+
protocolVersion: ack.protocolVersion
|
|
469
|
+
}, "kurobot: 握手成功");
|
|
470
|
+
this.opts.onEstablished(ack);
|
|
471
|
+
}
|
|
472
|
+
/** 应用层心跳:每 pingIntervalMs 发 ping 帧(服务端任何入帧重置 30s 空闲计时)。 */
|
|
473
|
+
startPing() {
|
|
474
|
+
this.stopPing();
|
|
475
|
+
if (this.opts.pingIntervalMs <= 0) return;
|
|
476
|
+
this.pingTimer = setInterval(() => {
|
|
477
|
+
if (this.state === "established") this.send({
|
|
478
|
+
type: "ping",
|
|
479
|
+
id: randomUUID(),
|
|
480
|
+
body: { timestamp: Date.now() }
|
|
481
|
+
});
|
|
482
|
+
}, this.opts.pingIntervalMs);
|
|
483
|
+
}
|
|
484
|
+
stopPing() {
|
|
485
|
+
if (this.pingTimer !== null) {
|
|
486
|
+
clearInterval(this.pingTimer);
|
|
487
|
+
this.pingTimer = null;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
clearTimers() {
|
|
491
|
+
if (this.helloTimer !== null) {
|
|
492
|
+
clearTimeout(this.helloTimer);
|
|
493
|
+
this.helloTimer = null;
|
|
494
|
+
}
|
|
495
|
+
this.stopPing();
|
|
496
|
+
if (this.retryTimer !== null) {
|
|
497
|
+
clearTimeout(this.retryTimer);
|
|
498
|
+
this.retryTimer = null;
|
|
499
|
+
}
|
|
500
|
+
this.pendingHelloId = null;
|
|
501
|
+
}
|
|
502
|
+
/** 永久拒绝:关闭连接,不再重连,上报 onEnded。 */
|
|
503
|
+
reject(detail, code) {
|
|
504
|
+
this.rejected = true;
|
|
505
|
+
this.clearTimers();
|
|
506
|
+
this.state = "stopped";
|
|
507
|
+
const ws = this.ws;
|
|
508
|
+
this.ws = null;
|
|
509
|
+
ws?.close();
|
|
510
|
+
const reason = {
|
|
511
|
+
permanent: true,
|
|
512
|
+
detail
|
|
513
|
+
};
|
|
514
|
+
if (code !== void 0) reason.code = code;
|
|
515
|
+
this.opts.onEnded(reason);
|
|
516
|
+
}
|
|
517
|
+
/** close:区分用户停止 / 永久拒绝(1002/1008)/ 可重连断开。 */
|
|
518
|
+
onClose(code, reason) {
|
|
519
|
+
if (this.helloTimer !== null) {
|
|
520
|
+
clearTimeout(this.helloTimer);
|
|
521
|
+
this.helloTimer = null;
|
|
522
|
+
}
|
|
523
|
+
this.stopPing();
|
|
524
|
+
const wasEstablished = this.state === "established";
|
|
525
|
+
this.ws = null;
|
|
526
|
+
if (this.userStopped || this.rejected) {
|
|
527
|
+
this.state = "stopped";
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
this.state = "idle";
|
|
531
|
+
if (code === 1002 || code === 1008) {
|
|
532
|
+
this.log.error({
|
|
533
|
+
code,
|
|
534
|
+
reason
|
|
535
|
+
}, "kurobot: 连接被拒(不可重试错误),停止重连");
|
|
536
|
+
this.rejected = true;
|
|
537
|
+
this.state = "stopped";
|
|
538
|
+
this.opts.onEnded({
|
|
539
|
+
permanent: true,
|
|
540
|
+
code,
|
|
541
|
+
detail: reason || `closed ${code}`
|
|
542
|
+
});
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (wasEstablished) this.log.warn({
|
|
546
|
+
code,
|
|
547
|
+
reason
|
|
548
|
+
}, "kurobot: 连接断开,准备重连");
|
|
549
|
+
this.scheduleRetry();
|
|
550
|
+
}
|
|
551
|
+
/** 指数退避重连:base * 2^attempt 封顶 retryMaxDelayMs;established 成功清零。 */
|
|
552
|
+
scheduleRetry() {
|
|
553
|
+
if (this.userStopped || this.rejected || this.retryTimer !== null) return;
|
|
554
|
+
this.state = "backoff";
|
|
555
|
+
const delay = Math.min(this.opts.retryBaseDelayMs * 2 ** this.attempt, this.opts.retryMaxDelayMs);
|
|
556
|
+
this.attempt += 1;
|
|
557
|
+
this.log.debug({
|
|
558
|
+
delay,
|
|
559
|
+
attempt: this.attempt
|
|
560
|
+
}, "kurobot: 退避后重连");
|
|
561
|
+
this.retryTimer = setTimeout(() => {
|
|
562
|
+
this.retryTimer = null;
|
|
563
|
+
this.connect();
|
|
564
|
+
}, delay);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
/** 从已解析的原始帧 JSON 取 header.id(容忍缺失)。 */
|
|
568
|
+
function headerId(json) {
|
|
569
|
+
if (typeof json !== "object" || json === null) return;
|
|
570
|
+
const header = json.header;
|
|
571
|
+
if (typeof header !== "object" || header === null) return;
|
|
572
|
+
const id = header.id;
|
|
573
|
+
return typeof id === "string" ? id : void 0;
|
|
574
|
+
}
|
|
575
|
+
/** 静默日志(缺省)。 */
|
|
576
|
+
const SILENT_LOGGER = {
|
|
577
|
+
debug: () => void 0,
|
|
578
|
+
info: () => void 0,
|
|
579
|
+
warn: () => void 0,
|
|
580
|
+
error: () => void 0
|
|
581
|
+
};
|
|
582
|
+
//#endregion
|
|
583
|
+
//#region src/kurobot/translate.ts
|
|
584
|
+
/**
|
|
585
|
+
* 消息映射纯函数(任务书 KUROBOT-PROMPT §1.2 QQ↔MC 映射;翻译层无副作用,ADR-008)
|
|
586
|
+
*
|
|
587
|
+
* QQ → MC:canonical elements 富文本降级为单行文本(无法表达的类型静默跳过,
|
|
588
|
+
* 对齐 OB11 收向「无法表达静默跳过」惯例)。
|
|
589
|
+
* MC → QQ:渲染模板占位符替换({player}/{content}/{message},MVP 纯文本)+
|
|
590
|
+
* 1500 字符截断。
|
|
591
|
+
*/
|
|
592
|
+
/** 发往 QQ 文本的总长上限(超出截断追加 …(截断);任务书对 command_result 规定,统一套用)。 */
|
|
593
|
+
const MAX_GROUP_TEXT_LENGTH = 1500;
|
|
594
|
+
/** 截断追加的省略标记。 */
|
|
595
|
+
const TRUNCATE_SUFFIX = "…(截断)";
|
|
596
|
+
/** 超长截断(按 UTF-16 码元计,超限部分丢弃)。 */
|
|
597
|
+
function truncateForGroup(text) {
|
|
598
|
+
if (text.length <= 1500) return text;
|
|
599
|
+
return `${text.slice(0, MAX_GROUP_TEXT_LENGTH)}${TRUNCATE_SUFFIX}`;
|
|
600
|
+
}
|
|
601
|
+
/** QQ 群消息发送者显示名:sendMemberName ?? sendNickName(双空兜底 senderUin,schema min(1))。 */
|
|
602
|
+
function resolveSenderName(msg) {
|
|
603
|
+
const member = msg.sendMemberName;
|
|
604
|
+
if (typeof member === "string" && member !== "") return member;
|
|
605
|
+
const nick = msg.sendNickName;
|
|
606
|
+
if (typeof nick === "string" && nick !== "") return nick;
|
|
607
|
+
return String(msg.senderUin);
|
|
608
|
+
}
|
|
609
|
+
/** at 元素 → 文本占位(@全体成员 / @{display ?? uid})。 */
|
|
610
|
+
function atToText(el) {
|
|
611
|
+
if (el.target === "all") return "@全体成员";
|
|
612
|
+
return `@${el.display ?? el.target}`;
|
|
613
|
+
}
|
|
614
|
+
/** canonical 元素序列 → 降级文本(text 原文、@、图片/表情/回复占位;空白返回 null = 不发)。 */
|
|
615
|
+
function degradeElements(elements) {
|
|
616
|
+
const parts = [];
|
|
617
|
+
for (const el of elements) switch (el.type) {
|
|
618
|
+
case "text":
|
|
619
|
+
parts.push(el.text);
|
|
620
|
+
break;
|
|
621
|
+
case "at":
|
|
622
|
+
parts.push(atToText(el));
|
|
623
|
+
break;
|
|
624
|
+
case "image":
|
|
625
|
+
parts.push("[图片]");
|
|
626
|
+
break;
|
|
627
|
+
case "face":
|
|
628
|
+
parts.push("[表情]");
|
|
629
|
+
break;
|
|
630
|
+
case "reply": parts.push("[回复]");
|
|
631
|
+
}
|
|
632
|
+
const text = parts.join("").trim();
|
|
633
|
+
return text === "" ? null : text;
|
|
634
|
+
}
|
|
635
|
+
/** RawMessage → 降级文本(便捷封装;null = 空消息不发)。 */
|
|
636
|
+
function degradeMessage(msg) {
|
|
637
|
+
return degradeElements(toCanonicalElements(msg));
|
|
638
|
+
}
|
|
639
|
+
/** 渲染模板:替换 {player}/{content}/{message} 占位符(未知占位符原样保留)。 */
|
|
640
|
+
function renderTemplate(template, vars) {
|
|
641
|
+
return template.replaceAll("{player}", vars.player).replaceAll("{content}", vars.content ?? "").replaceAll("{message}", vars.message ?? "");
|
|
642
|
+
}
|
|
643
|
+
/** death 事件 → 群文本(message 空串时以「死亡了」代入:Bukkit deathMessage null 兜底)。 */
|
|
644
|
+
function renderDeathText(template, player, message) {
|
|
645
|
+
return renderTemplate(template, {
|
|
646
|
+
player,
|
|
647
|
+
message: message === "" ? "死亡了" : message
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
/** command_result body → 群文本(ok 合并 output;error → 命令失败:{error})。 */
|
|
651
|
+
function renderCommandResult(body) {
|
|
652
|
+
if (!body.ok) return `命令失败:${body.error}`;
|
|
653
|
+
const output = body.output ?? [];
|
|
654
|
+
if (output.length === 0) return "(命令执行成功,无输出)";
|
|
655
|
+
return output.join("\n");
|
|
656
|
+
}
|
|
657
|
+
//#endregion
|
|
658
|
+
//#region src/kurobot/adapter.ts
|
|
659
|
+
/**
|
|
660
|
+
* NapukettoKurobotAdapter:kurobridge-ws 协议适配器(KuroBot MVP-3,2026-09-13)
|
|
661
|
+
*
|
|
662
|
+
* - 出链路:订阅 kernel 消息事件通道 → 仅群聊 → 富文本降级 → 出站绑定过滤 →
|
|
663
|
+
* kurobridge-ws chat 帧;命令前缀消息 → command 帧(source 由本侧提取)。
|
|
664
|
+
* - 入链路:KurobotConnection 收服务端帧 → chat/join/leave/death 渲染模板 →
|
|
665
|
+
* kernel 群消息 API 纯文本发送;status 仅缓存;command_result 经 pending 表
|
|
666
|
+
* 关联回发群(帧无 channel 字段,靠请求 UUID 关联)。
|
|
667
|
+
*
|
|
668
|
+
* 生命周期走 BaseProtocolAdapter 骨架(start 校验配置 → onStart 建连接 + 订阅 →
|
|
669
|
+
* onStop 清理);连接层状态机见 connection.ts。不发协议事件给 network 广播器
|
|
670
|
+
* (kurobot 是纯客户端出向协议,无第三方接入门面)。
|
|
671
|
+
*/
|
|
672
|
+
/** kurobot 协议适配器。 */
|
|
673
|
+
var NapukettoKurobotAdapter = class extends BaseProtocolAdapter {
|
|
674
|
+
protocol = "kurobot";
|
|
675
|
+
msgChannel;
|
|
676
|
+
msgApi;
|
|
677
|
+
selfUin;
|
|
678
|
+
log;
|
|
679
|
+
connection = null;
|
|
680
|
+
unsubscribe = null;
|
|
681
|
+
/** 当前生效配置(收链路取 commandPrefix 等;onStart/onReload 刷新)。 */
|
|
682
|
+
currentConfig = null;
|
|
683
|
+
/** 出站绑定集(hello_ack 快照 + bindings_updated 全量替换;空集 = 全部不发)。 */
|
|
684
|
+
bindings = /* @__PURE__ */ new Set();
|
|
685
|
+
/** command 请求 UUID → 回发群号(command_result 帧无 channel,靠 id 关联)。 */
|
|
686
|
+
pendingCommands = /* @__PURE__ */ new Map();
|
|
687
|
+
/** query 请求 UUID → 查询类别。 */
|
|
688
|
+
pendingQueries = /* @__PURE__ */ new Map();
|
|
689
|
+
/** 最近一帧 status 快照(仅缓存不发群,M-04 决策同构)。 */
|
|
690
|
+
lastStatus = null;
|
|
691
|
+
constructor(opts) {
|
|
692
|
+
super({
|
|
693
|
+
config: opts.config,
|
|
694
|
+
hooks: {
|
|
695
|
+
onStart: (config) => this.startKurobot(config),
|
|
696
|
+
onStop: () => this.stopAll(),
|
|
697
|
+
onReload: (config) => this.reloadKurobot(config)
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
this.msgChannel = opts.msgChannel;
|
|
701
|
+
this.msgApi = opts.msgApi;
|
|
702
|
+
this.selfUin = String(opts.self.uin);
|
|
703
|
+
this.log = opts.logger;
|
|
704
|
+
}
|
|
705
|
+
/** 最近一帧 status 快照(仅缓存;无帧时 null)。 */
|
|
706
|
+
getLastStatus() {
|
|
707
|
+
return this.lastStatus;
|
|
708
|
+
}
|
|
709
|
+
/** 连接是否已建立(hello_ack ok 后为 true)。 */
|
|
710
|
+
isConnected() {
|
|
711
|
+
return this.connection?.isEstablished() ?? false;
|
|
712
|
+
}
|
|
713
|
+
/** 发起 query 请求(status/bindings)。MVP 未接 QQ 触发面——预留能力(见 design.md §8)。 */
|
|
714
|
+
query(kind) {
|
|
715
|
+
const conn = this.connection;
|
|
716
|
+
if (conn === null || !conn.isEstablished()) return false;
|
|
717
|
+
const id = randomUUID();
|
|
718
|
+
this.pendingQueries.set(id, kind);
|
|
719
|
+
return conn.send({
|
|
720
|
+
type: "query",
|
|
721
|
+
id,
|
|
722
|
+
body: { kind }
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
/** 启动:建连接(异步握手,日志可见)+ 订阅 kernel 消息。 */
|
|
726
|
+
async startKurobot(config) {
|
|
727
|
+
this.currentConfig = config;
|
|
728
|
+
this.bindings = /* @__PURE__ */ new Set();
|
|
729
|
+
this.pendingCommands.clear();
|
|
730
|
+
this.pendingQueries.clear();
|
|
731
|
+
this.lastStatus = null;
|
|
732
|
+
const hello = {
|
|
733
|
+
peerId: this.selfUin,
|
|
734
|
+
platform: "qq",
|
|
735
|
+
version: resolveAdapterVersion(),
|
|
736
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
737
|
+
client: config.client ?? resolveDefaultClient()
|
|
738
|
+
};
|
|
739
|
+
if (config.token !== "") hello.token = config.token;
|
|
740
|
+
const connOptions = {
|
|
741
|
+
url: config.url,
|
|
742
|
+
hello,
|
|
743
|
+
pingIntervalMs: config.pingIntervalMs,
|
|
744
|
+
helloTimeoutMs: 1e4,
|
|
745
|
+
retryBaseDelayMs: 1e3,
|
|
746
|
+
retryMaxDelayMs: 6e4,
|
|
747
|
+
onEstablished: (ack) => this.onEstablished(ack),
|
|
748
|
+
onFrame: (msg) => this.onServerFrame(msg),
|
|
749
|
+
onEnded: (reason) => this.onEnded(reason)
|
|
750
|
+
};
|
|
751
|
+
if (this.log !== void 0) connOptions.logger = this.log;
|
|
752
|
+
this.connection = new KurobotConnection(connOptions);
|
|
753
|
+
this.connection.start();
|
|
754
|
+
this.subscribe();
|
|
755
|
+
}
|
|
756
|
+
/** 配置热更新:停旧连接/退订 → 按新配置重建(对齐 satori reloadTransports)。 */
|
|
757
|
+
async reloadKurobot(config) {
|
|
758
|
+
await this.stopAll();
|
|
759
|
+
await this.startKurobot(config);
|
|
760
|
+
}
|
|
761
|
+
/** 停止:退订 + 关连接 + 清 pending 表。 */
|
|
762
|
+
async stopAll() {
|
|
763
|
+
this.unsubscribeAll();
|
|
764
|
+
this.connection?.stop();
|
|
765
|
+
this.connection = null;
|
|
766
|
+
this.pendingCommands.clear();
|
|
767
|
+
this.pendingQueries.clear();
|
|
768
|
+
}
|
|
769
|
+
/** 握手成功:绑定集整体替换为服务端快照 + 清上一连接的 pending 表。 */
|
|
770
|
+
onEstablished(ack) {
|
|
771
|
+
this.pendingCommands.clear();
|
|
772
|
+
this.pendingQueries.clear();
|
|
773
|
+
this.bindings = new Set(ack.channelBindings);
|
|
774
|
+
}
|
|
775
|
+
/** 连接永久停止(1002/1008/hello_ack 拒绝):清 pending(绑定集留给下次握手覆盖)。 */
|
|
776
|
+
onEnded(reason) {
|
|
777
|
+
this.pendingCommands.clear();
|
|
778
|
+
this.pendingQueries.clear();
|
|
779
|
+
this.log?.error({ reason }, "kurobot: 连接终止");
|
|
780
|
+
}
|
|
781
|
+
/** 服务端帧分发(渲染回群 / 缓存 / 绑定集维护 / 命令结果关联回发)。 */
|
|
782
|
+
onServerFrame(msg) {
|
|
783
|
+
switch (msg.type) {
|
|
784
|
+
case "pong": return;
|
|
785
|
+
case "chat":
|
|
786
|
+
this.sendToGroup(msg.body.channel, renderTemplate(this.requireConfig().chatTemplate, {
|
|
787
|
+
player: msg.body.playerName,
|
|
788
|
+
content: msg.body.content
|
|
789
|
+
}));
|
|
790
|
+
return;
|
|
791
|
+
case "join":
|
|
792
|
+
this.sendToGroup(msg.body.channel, renderTemplate(this.requireConfig().joinTemplate, { player: msg.body.playerName }));
|
|
793
|
+
return;
|
|
794
|
+
case "leave":
|
|
795
|
+
this.sendToGroup(msg.body.channel, renderTemplate(this.requireConfig().leaveTemplate, { player: msg.body.playerName }));
|
|
796
|
+
return;
|
|
797
|
+
case "death":
|
|
798
|
+
this.sendToGroup(msg.body.channel, truncateForGroup(renderDeathText(this.requireConfig().deathTemplate, msg.body.player, msg.body.message)));
|
|
799
|
+
return;
|
|
800
|
+
case "status":
|
|
801
|
+
this.lastStatus = msg.body;
|
|
802
|
+
this.log?.debug({ status: msg.body }, "kurobot: status 已缓存");
|
|
803
|
+
return;
|
|
804
|
+
case "bindings_updated":
|
|
805
|
+
this.bindings = new Set(msg.body.channelBindings);
|
|
806
|
+
this.log?.info({ bindings: msg.body.channelBindings }, "kurobot: 绑定表已更新");
|
|
807
|
+
return;
|
|
808
|
+
case "command_result": {
|
|
809
|
+
const channel = this.pendingCommands.get(msg.id);
|
|
810
|
+
if (channel === void 0) {
|
|
811
|
+
this.log?.warn({ id: msg.id }, "kurobot: command_result 无对应请求,丢弃");
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
this.pendingCommands.delete(msg.id);
|
|
815
|
+
this.sendToGroup(channel, truncateForGroup(renderCommandResult(msg.body)));
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
case "query_result":
|
|
819
|
+
this.onQueryResult(msg.id, msg.body);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/** query_result 处理:bindings 刷新绑定集(防御性收窄);status 缓存。 */
|
|
824
|
+
onQueryResult(id, body) {
|
|
825
|
+
const kind = this.pendingQueries.get(id);
|
|
826
|
+
if (kind === void 0) {
|
|
827
|
+
this.log?.warn({ id }, "kurobot: query_result 无对应请求,丢弃");
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
this.pendingQueries.delete(id);
|
|
831
|
+
if (!body.ok) {
|
|
832
|
+
this.log?.warn({
|
|
833
|
+
kind,
|
|
834
|
+
error: body.error
|
|
835
|
+
}, "kurobot: 查询失败");
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (kind === "bindings" && isStringArray(body.data)) {
|
|
839
|
+
this.bindings = new Set(body.data);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
this.log?.debug({
|
|
843
|
+
kind,
|
|
844
|
+
data: body.data
|
|
845
|
+
}, "kurobot: 查询结果已记录");
|
|
846
|
+
}
|
|
847
|
+
/** kernel 群消息 → kurobot 出站(仅群聊 + 自消息过滤 + 命令分流 + 绑定过滤)。 */
|
|
848
|
+
handleIncomingMessage(msg) {
|
|
849
|
+
const config = this.currentConfig;
|
|
850
|
+
if (config === null) return;
|
|
851
|
+
if (msg.chatType !== ChatType.GROUP) return;
|
|
852
|
+
if (String(msg.senderUin) === this.selfUin) return;
|
|
853
|
+
const channel = msg.peerUid;
|
|
854
|
+
if (channel === "") return;
|
|
855
|
+
const text = degradeMessage(msg);
|
|
856
|
+
if (text === null) return;
|
|
857
|
+
if (text.startsWith(config.commandPrefix)) {
|
|
858
|
+
const command = text.slice(config.commandPrefix.length).trim();
|
|
859
|
+
if (command === "") return;
|
|
860
|
+
this.sendCommand(channel, command, String(msg.senderUin));
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
if (this.bindings.size === 0 || !this.bindings.has(channel)) {
|
|
864
|
+
this.log?.debug({ channel }, "kurobot: 非绑定群,跳过 chat 帧");
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
this.sendChat(channel, resolveSenderName(msg), text);
|
|
868
|
+
}
|
|
869
|
+
/** 发 chat 事件帧(无 id)。 */
|
|
870
|
+
sendChat(channel, sender, content) {
|
|
871
|
+
this.connection?.send({
|
|
872
|
+
type: "chat",
|
|
873
|
+
body: {
|
|
874
|
+
channel,
|
|
875
|
+
sender,
|
|
876
|
+
content
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
/** 发 command 请求帧(登记 pending 表供 command_result 关联)。 */
|
|
881
|
+
sendCommand(channel, command, userId) {
|
|
882
|
+
const id = randomUUID();
|
|
883
|
+
this.pendingCommands.set(id, channel);
|
|
884
|
+
if (this.connection?.send({
|
|
885
|
+
type: "command",
|
|
886
|
+
id,
|
|
887
|
+
body: {
|
|
888
|
+
command,
|
|
889
|
+
source: {
|
|
890
|
+
channel,
|
|
891
|
+
userId
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}) !== true) this.pendingCommands.delete(id);
|
|
895
|
+
}
|
|
896
|
+
/** 群文本发送(失败 error 日志不抛出——不影响连接与收链路)。 */
|
|
897
|
+
sendToGroup(channel, text) {
|
|
898
|
+
this.msgApi.sendMessage({
|
|
899
|
+
chatType: ChatType.GROUP,
|
|
900
|
+
peerUid: channel
|
|
901
|
+
}, [{
|
|
902
|
+
type: "text",
|
|
903
|
+
text
|
|
904
|
+
}]).then(() => {
|
|
905
|
+
this.log?.debug({ channel }, "kurobot: 群消息已发送");
|
|
906
|
+
}).catch((err) => {
|
|
907
|
+
this.log?.error({
|
|
908
|
+
channel,
|
|
909
|
+
err: err instanceof Error ? err.message : String(err)
|
|
910
|
+
}, "kurobot: 群消息发送失败");
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
/** 当前生效配置(收链路保证已 start)。 */
|
|
914
|
+
requireConfig() {
|
|
915
|
+
const config = this.currentConfig;
|
|
916
|
+
if (config === null) throw new Error("kurobot: 配置未初始化(适配器未启动)");
|
|
917
|
+
return config;
|
|
918
|
+
}
|
|
919
|
+
/** 订阅 kernel 消息事件(幂等)。 */
|
|
920
|
+
subscribe() {
|
|
921
|
+
if (this.unsubscribe !== null) return;
|
|
922
|
+
this.unsubscribe = this.msgChannel.on("Msg/onRecvMsg", (msgs) => {
|
|
923
|
+
forEachRawMessage(msgs, (msg) => {
|
|
924
|
+
this.handleIncomingMessage(msg);
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
/** 退订(幂等)。 */
|
|
929
|
+
unsubscribeAll() {
|
|
930
|
+
this.unsubscribe?.();
|
|
931
|
+
this.unsubscribe = null;
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
/** string[] 防御性收窄(query_result bindings data)。 */
|
|
935
|
+
function isStringArray(value) {
|
|
936
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
937
|
+
}
|
|
938
|
+
/** best-effort 读取 adapter 包版本(dist 与 src 双布局相对包根一致)。 */
|
|
939
|
+
function resolvePackageVersion() {
|
|
940
|
+
try {
|
|
941
|
+
const pkg = createRequire(import.meta.url)("../../package.json");
|
|
942
|
+
if (typeof pkg.version === "string" && pkg.version !== "") return pkg.version;
|
|
943
|
+
return null;
|
|
944
|
+
} catch {
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/** hello.version:adapter 包版本(取不到用 unknown)。 */
|
|
949
|
+
function resolveAdapterVersion() {
|
|
950
|
+
return resolvePackageVersion() ?? "unknown";
|
|
951
|
+
}
|
|
952
|
+
/** hello.client 缺省身份:napukettoqq/<版本>(取不到版本则 napukettoqq)。 */
|
|
953
|
+
function resolveDefaultClient() {
|
|
954
|
+
const version = resolvePackageVersion();
|
|
955
|
+
return version === null ? "napukettoqq" : `napukettoqq/${version}`;
|
|
956
|
+
}
|
|
957
|
+
//#endregion
|
|
958
|
+
//#region src/kurobot/config.ts
|
|
959
|
+
/**
|
|
960
|
+
* kurobot 协议配置(zod schema,归属本包,ADR-012;任务书 KUROBOT-PROMPT §1.2)
|
|
961
|
+
*
|
|
962
|
+
* 单一 TOML 的 `[accounts.kurobot]` 账号段(段存在即启用、不写不启用),
|
|
963
|
+
* seed 注入对齐 onebot11/satori 的 ConfigBase 模式。
|
|
964
|
+
*/
|
|
965
|
+
/** kurobot 配置 schema。 */
|
|
966
|
+
const kurobotConfigSchema = z.object({
|
|
967
|
+
/** KuroBot 服务端完整地址(必填,ws:// 或 wss://)。 */
|
|
968
|
+
url: z.url().refine((u) => u.startsWith("ws://") || u.startsWith("wss://"), { message: "url 必须是 ws:// 或 wss:// 地址" }),
|
|
969
|
+
/** 鉴权 token(缺省空 = 不带;服务端配置了非空 token 则必须一致,否则 close 1008)。 */
|
|
970
|
+
token: z.string().default(""),
|
|
971
|
+
/** 自报身份(缺省 napukettoqq,能取到 adapter 包版本则 napukettoqq/<版本>)。 */
|
|
972
|
+
client: z.string().min(1).optional(),
|
|
973
|
+
/** 群命令前缀(前缀开头 → command 帧,不再作为 chat 发送)。 */
|
|
974
|
+
commandPrefix: z.string().min(1).default("/"),
|
|
975
|
+
/** 应用层心跳间隔毫秒(服务端空闲阈值 30s,任何入帧重置;须显著小于 30000)。 */
|
|
976
|
+
pingIntervalMs: z.number().int().positive().default(15e3),
|
|
977
|
+
/** MC 聊天渲染模板。 */
|
|
978
|
+
chatTemplate: z.string().min(1).default("[{player}] {content}"),
|
|
979
|
+
/** 玩家进服渲染模板。 */
|
|
980
|
+
joinTemplate: z.string().min(1).default("{player} 加入了服务器"),
|
|
981
|
+
/** 玩家退服渲染模板。 */
|
|
982
|
+
leaveTemplate: z.string().min(1).default("{player} 离开了服务器"),
|
|
983
|
+
/** 玩家死亡渲染模板(message 为空串时以「死亡了」代入)。 */
|
|
984
|
+
deathTemplate: z.string().min(1).default("{player} {message}")
|
|
985
|
+
});
|
|
986
|
+
/**
|
|
987
|
+
* ConfigBase 必填的 defaults 形参兜底值(url 必填 → 无法 `parse({})`)。
|
|
988
|
+
* 装配走 seed 模式(段存在才装配,seed 已校验),defaults 不参与生效值;
|
|
989
|
+
* 仅在「无 seed 且配置文件缺失」的直连场景被落盘——端口 0 地址连接会失败退避,
|
|
990
|
+
* 不会静默假在线。
|
|
991
|
+
*/
|
|
992
|
+
function kurobotConfigDefaults() {
|
|
993
|
+
return kurobotConfigSchema.parse({ url: "ws://127.0.0.1:0" });
|
|
994
|
+
}
|
|
995
|
+
//#endregion
|
|
996
|
+
export { KurobotConnection, MAX_GROUP_TEXT_LENGTH, NapukettoKurobotAdapter, PROTOCOL_NAME, PROTOCOL_VERSION, WS_SUBPROTOCOL, bindingsUpdatedFrame, commandFrame, commandResultFrame, deathFrame, degradeElements, degradeMessage, encodeFrame, gameChatFrame, helloAckFrame, helloFrame, isProtocolVersionCompatible, joinFrame, kurobotConfigDefaults, kurobotConfigSchema, leaveFrame, pingFrame, platformChatFrame, pongFrame, queryFrame, queryResultFrame, renderCommandResult, renderDeathText, renderTemplate, resolveSenderName, statusFrame, truncateForGroup, wireFrameSchema };
|
|
997
|
+
|
|
998
|
+
//# sourceMappingURL=index.mjs.map
|