@napuketto/adapter 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1240 @@
1
+ import { o as ActionRegistry, r as BaseProtocolAdapter } from "../core-DyXyDUcU.mjs";
2
+ import { n as subscribeRawMessages } from "../raw-message-D_ecJy4C.mjs";
3
+ import { ChatType, GroupApi, isKernelError, kernelError, toCanonicalElements } from "@napuketto/kernel";
4
+ import { mkdir, rename, writeFile } from "node:fs/promises";
5
+ import { detectFileType, downloadUrl, inferExtension } from "@napuketto/media";
6
+ import { z } from "zod";
7
+ import { randomUUID } from "node:crypto";
8
+ import { join } from "node:path";
9
+ import { HttpClient, HttpServer, createSseResponse } from "@napuketto/network";
10
+ import { WebSocket } from "ws";
11
+ import { fileURLToPath } from "node:url";
12
+ //#region src/milky/helper/entities.ts
13
+ /** GroupDetailInfo / Group → GroupEntity(detail 缺失时全兜底)。 */
14
+ function toGroupEntity(detail, groupCode) {
15
+ return {
16
+ group_id: Number(groupCode),
17
+ group_name: detail?.groupName ?? "",
18
+ member_count: detail?.memberNum ?? 0,
19
+ max_member_count: detail?.maxMemberNum ?? 0,
20
+ remark: detail?.remarkName ?? "",
21
+ created_time: detail?.groupCreateTime ?? 0,
22
+ description: "",
23
+ question: "",
24
+ announcement: ""
25
+ };
26
+ }
27
+ /** 群列表项(GroupCache Group)→ GroupEntity。 */
28
+ function groupListToEntity(group) {
29
+ return {
30
+ group_id: Number(group.groupCode),
31
+ group_name: group.groupName,
32
+ member_count: group.memberCount,
33
+ max_member_count: group.maxMember,
34
+ remark: "",
35
+ created_time: 0,
36
+ description: "",
37
+ question: "",
38
+ announcement: ""
39
+ };
40
+ }
41
+ /** GroupMember → GroupMemberEntity(member 缺失时按 uin 兜底最小实体)。 */
42
+ function toGroupMemberEntity(member, groupCode, uin) {
43
+ const shutUpSeconds = member?.shutUpTime ?? 0;
44
+ return {
45
+ user_id: Number(member?.uin !== void 0 && member.uin !== "" ? member.uin : uin),
46
+ nickname: member?.nick ?? "",
47
+ sex: "unknown",
48
+ group_id: Number(groupCode),
49
+ card: member?.cardName ?? "",
50
+ title: member?.memberSpecialTitle ?? "",
51
+ level: 0,
52
+ role: GroupApi.roleToString(member?.role ?? 2),
53
+ join_time: Number(member?.joinTime) || 0,
54
+ last_sent_time: Number(member?.lastSpeakTime) || 0,
55
+ shut_up_end_time: shutUpSeconds > 0 ? Math.floor(Date.now() / 1e3) + shutUpSeconds : null
56
+ };
57
+ }
58
+ /** Friend → FriendEntity(kernel 无 qid/分类挂载,按最小实体兜底)。 */
59
+ function toFriendEntity(friend, uin, nick) {
60
+ return {
61
+ user_id: Number(friend?.uin !== void 0 && friend.uin !== "" ? friend.uin : uin),
62
+ nickname: friend?.nickname ?? nick,
63
+ sex: "unknown",
64
+ qid: "",
65
+ remark: friend?.remark ?? "",
66
+ category: {
67
+ category_id: 0,
68
+ category_name: ""
69
+ }
70
+ };
71
+ }
72
+ //#endregion
73
+ //#region src/milky/helper/media-uri.ts
74
+ /**
75
+ * 发送媒体 URI 解析(Milky OutgoingSegment uri → 本地路径)
76
+ *
77
+ * 规范支持 file:// / http(s):// / base64:// 三种(image/record/video 共用):
78
+ * - file:// → 平台路径(segments.ts 的同步面已处理)
79
+ * - base64:// → 解码落盘 cacheDir/milky/,file-type 探测扩展名后改名
80
+ * - http(s):// → @napuketto/media downloadUrl 落盘 cacheDir/milky/
81
+ * 未配置 mediaDir 时远程 URI 报错(不发静默空消息)。
82
+ */
83
+ /** 解析远程媒体 URI 为本地绝对路径(base64:// / http(s)://;file:// 走同步面)。 */
84
+ async function resolveMediaUri(uri, mediaDir) {
85
+ if (mediaDir === void 0 || mediaDir === "") throw kernelError("远程媒体发送需要配置 [accounts.milky] cacheDir(下载缓存目录)", "INVALID_PARAM");
86
+ if (uri.startsWith("base64://")) return await writeBase64(uri.slice(9), mediaDir);
87
+ const dest = join(mediaDir, `${randomUUID()}${inferExtension(null, uri)}`);
88
+ await downloadUrl(uri, dest);
89
+ return dest;
90
+ }
91
+ /** base64 载荷 → 落盘(先写 .bin,探测到扩展名再改名)。 */
92
+ async function writeBase64(payload, mediaDir) {
93
+ let bytes;
94
+ try {
95
+ bytes = Buffer.from(payload, "base64");
96
+ } catch {
97
+ throw kernelError("base64:// 载荷解码失败", "INVALID_PARAM");
98
+ }
99
+ if (bytes.length === 0) throw kernelError("base64:// 载荷为空", "INVALID_PARAM");
100
+ await mkdir(mediaDir, { recursive: true });
101
+ const raw = join(mediaDir, `${randomUUID()}.bin`);
102
+ await writeFile(raw, bytes);
103
+ try {
104
+ const { ext } = await detectFileType(raw);
105
+ const final = join(mediaDir, `${randomUUID()}${ext}`);
106
+ await rename(raw, final);
107
+ return final;
108
+ } catch {
109
+ return raw;
110
+ }
111
+ }
112
+ //#endregion
113
+ //#region src/milky/helper/segments.ts
114
+ /**
115
+ * Milky 消息段 ↔ canonical 元素转换(纯函数,ADR-008)
116
+ *
117
+ * 接收方向:canonical → IncomingSegment(规范 14 种中 P0 映射 12 种;
118
+ * market_face / markdown 无 canonical 对应,unknown 一律跳过——规范兼容性
119
+ * 要求「数组中未知项跳过」)。
120
+ * 发送方向:OutgoingSegment → canonical(10 种中 P0 映射 9 种;forward 发送
121
+ * 走专用内核链路,P0 显式报错不静默跳过)。
122
+ */
123
+ /** uin 字符串 → Milky user_id(非有限正数 = 转换失败)。 */
124
+ function toUserId(uin) {
125
+ const n = Number(uin);
126
+ return Number.isFinite(n) && n >= 10001 ? n : void 0;
127
+ }
128
+ /** canonical 元素 → IncomingSegment 列表(unknown / 转换失败段跳过)。 */
129
+ function canonicalToIncomingSegments(elements, ctx = {}) {
130
+ const out = [];
131
+ for (const el of elements) {
132
+ const seg = canonicalToIncomingSegment(el, ctx);
133
+ if (seg !== null) out.push(seg);
134
+ }
135
+ return out;
136
+ }
137
+ /** canonical image → image 段(resource_id = 本地路径优先,url 兜底)。 */
138
+ function imageToIncoming(el) {
139
+ return {
140
+ type: "image",
141
+ data: {
142
+ resource_id: el.path !== "" ? el.path : el.url ?? "",
143
+ temp_url: el.url ?? "",
144
+ width: el.size?.width ?? 0,
145
+ height: el.size?.height ?? 0,
146
+ summary: "",
147
+ sub_type: "normal"
148
+ }
149
+ };
150
+ }
151
+ /** canonical voice → record 段。 */
152
+ function voiceToIncoming(el) {
153
+ return {
154
+ type: "record",
155
+ data: {
156
+ resource_id: el.path !== "" ? el.path : el.url ?? "",
157
+ temp_url: el.url ?? "",
158
+ duration: el.durationMs !== void 0 ? Math.round(el.durationMs / 1e3) : 0
159
+ }
160
+ };
161
+ }
162
+ /** canonical video → video 段。 */
163
+ function videoToIncoming(el) {
164
+ return {
165
+ type: "video",
166
+ data: {
167
+ resource_id: el.path !== "" ? el.path : el.url ?? "",
168
+ temp_url: el.url ?? "",
169
+ width: el.width ?? 0,
170
+ height: el.height ?? 0,
171
+ duration: el.durationSec ?? 0
172
+ }
173
+ };
174
+ }
175
+ /** 按类型分发的段映射器表(键 = canonical type;缺项 = 跳过)。 */
176
+ const INCOMING_MAPPERS = {
177
+ text: (el) => ({
178
+ type: "text",
179
+ data: { text: el.text }
180
+ }),
181
+ at: atToIncoming,
182
+ face: (el) => ({
183
+ type: "face",
184
+ data: {
185
+ face_id: el.id,
186
+ is_large: false
187
+ }
188
+ }),
189
+ reply: replyToIncoming,
190
+ image: imageToIncoming,
191
+ voice: voiceToIncoming,
192
+ video: videoToIncoming,
193
+ file: fileToIncoming,
194
+ forward: forwardToIncoming,
195
+ json: (el) => ({
196
+ type: "light_app",
197
+ data: {
198
+ app_name: parseAppName(el.raw),
199
+ json_payload: rawToPayload(el.raw)
200
+ }
201
+ }),
202
+ xml: (el) => ({
203
+ type: "xml",
204
+ data: {
205
+ service_id: 0,
206
+ xml_payload: rawToPayload(el.raw)
207
+ }
208
+ })
209
+ };
210
+ function canonicalToIncomingSegment(el, ctx) {
211
+ const mapper = INCOMING_MAPPERS[el.type];
212
+ return mapper === void 0 ? null : mapper(el, ctx);
213
+ }
214
+ /** canonical at → mention / mention_all(uid→uin 失败跳过该段)。 */
215
+ function atToIncoming(el, ctx) {
216
+ if (el.target === "all") return {
217
+ type: "mention_all",
218
+ data: {}
219
+ };
220
+ const userId = toUserId(ctx.uidToUin?.get(el.target) ?? el.target);
221
+ if (userId === void 0) return null;
222
+ return {
223
+ type: "mention",
224
+ data: {
225
+ user_id: userId,
226
+ name: el.display ?? ""
227
+ }
228
+ };
229
+ }
230
+ /** canonical reply → reply 段(引用元信息经 ctx 回填,未命中全 0 兜底)。 */
231
+ function replyToIncoming(el, ctx) {
232
+ const meta = ctx.replyMeta?.get(el.messageId);
233
+ return {
234
+ type: "reply",
235
+ data: {
236
+ message_seq: meta?.messageSeq ?? 0,
237
+ sender_id: meta?.senderId ?? 0,
238
+ sender_name: null,
239
+ time: meta?.time ?? 0,
240
+ segments: []
241
+ }
242
+ };
243
+ }
244
+ /** canonical file → file 段。 */
245
+ function fileToIncoming(el) {
246
+ return {
247
+ type: "file",
248
+ data: {
249
+ file_id: el.path,
250
+ file_name: el.name ?? "",
251
+ file_size: el.size ?? 0
252
+ }
253
+ };
254
+ }
255
+ /** canonical forward → forward 段(title/preview/summary 无 canonical 来源,空兜底)。 */
256
+ function forwardToIncoming(el) {
257
+ return {
258
+ type: "forward",
259
+ data: {
260
+ forward_id: el.messageIds[0] ?? "",
261
+ title: "",
262
+ preview: [],
263
+ summary: ""
264
+ }
265
+ };
266
+ }
267
+ /** json 载荷 → 小程序名(spec app_name:JSON 顶层 app 字段,取不到空串)。 */
268
+ function parseAppName(raw) {
269
+ let obj = raw;
270
+ if (typeof raw === "string") try {
271
+ obj = JSON.parse(raw);
272
+ } catch {
273
+ return "";
274
+ }
275
+ if (typeof obj !== "object" || obj === null || !("app" in obj)) return "";
276
+ const app = obj["app"];
277
+ return typeof app === "string" ? app : "";
278
+ }
279
+ /** canonical json/xml raw → 字符串载荷(字符串原样,其余 JSON 序列化)。 */
280
+ function rawToPayload(raw) {
281
+ if (typeof raw === "string") return raw;
282
+ try {
283
+ return JSON.stringify(raw) ?? "";
284
+ } catch {
285
+ return "";
286
+ }
287
+ }
288
+ /**
289
+ * OutgoingSegment 数组 → canonical 元素(同步映射;媒体 URI 解析与 at uin→uid
290
+ * 由调用方在返回值上继续处理)。
291
+ * forward 显式报错(发送走 kernel 专用链路,P1 接)。
292
+ */
293
+ function outgoingSegmentsToCanonical(segments) {
294
+ const elements = [];
295
+ const atUins = [];
296
+ const replySeqs = [];
297
+ for (const seg of segments) switch (seg.type) {
298
+ case "text":
299
+ elements.push({
300
+ type: "text",
301
+ text: seg.data.text
302
+ });
303
+ break;
304
+ case "mention": {
305
+ const uin = String(seg.data.user_id);
306
+ atUins.push(uin);
307
+ elements.push({
308
+ type: "at",
309
+ target: uin
310
+ });
311
+ break;
312
+ }
313
+ case "mention_all":
314
+ elements.push({
315
+ type: "at",
316
+ target: "all"
317
+ });
318
+ break;
319
+ case "face":
320
+ elements.push({
321
+ type: "face",
322
+ id: seg.data.face_id
323
+ });
324
+ break;
325
+ case "reply":
326
+ replySeqs.push(seg.data.message_seq);
327
+ elements.push({
328
+ type: "reply",
329
+ messageId: String(seg.data.message_seq)
330
+ });
331
+ break;
332
+ case "image":
333
+ elements.push({
334
+ type: "image",
335
+ path: resolveUriToPathPlaceholder(seg.data.uri)
336
+ });
337
+ break;
338
+ case "record":
339
+ elements.push({
340
+ type: "voice",
341
+ path: resolveUriToPathPlaceholder(seg.data.uri)
342
+ });
343
+ break;
344
+ case "video":
345
+ elements.push({
346
+ type: "video",
347
+ path: resolveUriToPathPlaceholder(seg.data.uri)
348
+ });
349
+ break;
350
+ case "light_app":
351
+ elements.push({
352
+ type: "json",
353
+ raw: seg.data.json_payload
354
+ });
355
+ break;
356
+ case "forward": throw kernelError("forward 段发送暂未支持(P1 接内核转发链路)", "INVALID_PARAM");
357
+ }
358
+ return {
359
+ elements,
360
+ atUins,
361
+ replySeqs
362
+ };
363
+ }
364
+ /**
365
+ * 媒体 URI → 本地路径(同步面:file:// / 裸路径直接给 canonical;base64:// 与
366
+ * http(s):// 需要异步落盘,由 send 核心在 canonical 上二次解析——此处先放原始
367
+ * URI 作占位,send 核心按元素类型调用 resolveMediaUri 替换)。
368
+ */
369
+ function resolveUriToPathPlaceholder(uri) {
370
+ if (uri.startsWith("file://")) return fileUriToPath(uri);
371
+ if (uri.startsWith("http://") || uri.startsWith("https://") || uri.startsWith("base64://")) return uri;
372
+ return uri;
373
+ }
374
+ /** file:// 前缀(fileURLToPath 失败时的裸剥离兜底)。 */
375
+ const FILE_URI_PREFIX_RE = /^file:\/\//;
376
+ /** file:// URI → 平台路径(Windows 盘符兼容)。 */
377
+ function fileUriToPath(uri) {
378
+ try {
379
+ return fileURLToPath(uri);
380
+ } catch {
381
+ return uri.replace(FILE_URI_PREFIX_RE, "");
382
+ }
383
+ }
384
+ //#endregion
385
+ //#region src/milky/helper/translate.ts
386
+ /**
387
+ * Milky 事件翻译(RawMessage → Milky 事件,纯函数,ADR-008)
388
+ *
389
+ * P0 事件两种:message_receive(onRecvMsg)/ message_recall(grayTip REVOKE)。
390
+ * 实体与 reply 回填在广播路径异步取得后经 ctx 注入(翻译保持纯函数);
391
+ * 撤回事件的 sender_id 取 RawMessage.senderUin(grayTip 系统消息的 sender
392
+ * 即被撤回原消息的发送者,口径待真实事件校准)。
393
+ */
394
+ /** 毫秒 → 秒(Unix 时间戳)。 */
395
+ const MS_TO_SEC = 1e3;
396
+ /** chatType → message_scene。 */
397
+ function chatTypeToScene(chatType) {
398
+ if (chatType === ChatType.GROUP) return "group";
399
+ if (chatType === ChatType.C2C_TEMP) return "temp";
400
+ return "friend";
401
+ }
402
+ /** peer_id:群聊 = 群号(peerUid);好友/临时 = 对端 uin(peerUin,空退 peerUid)。 */
403
+ function peerIdOf(msg, scene) {
404
+ const raw = scene === "group" ? msg.peerUid : msg.peerUin !== "" ? msg.peerUin : msg.peerUid;
405
+ return Number(raw);
406
+ }
407
+ /** RawMessage → message_receive 事件(实体缺省最小合法值,schema 形状完整)。 */
408
+ function toMessageReceiveEvent(msg, ctx) {
409
+ const segments = canonicalToIncomingSegments(toCanonicalElements(msg), {
410
+ ...ctx.uidToUin !== void 0 ? { uidToUin: ctx.uidToUin } : {},
411
+ ...ctx.replyMeta !== void 0 ? { replyMeta: ctx.replyMeta } : {}
412
+ });
413
+ const scene = chatTypeToScene(msg.chatType);
414
+ const time = Math.floor(Number(msg.msgTime) / MS_TO_SEC);
415
+ let data;
416
+ if (scene === "group") data = {
417
+ message_scene: "group",
418
+ peer_id: peerIdOf(msg, scene),
419
+ message_seq: Number(msg.msgSeq),
420
+ sender_id: Number(msg.senderUin),
421
+ time,
422
+ segments,
423
+ group: ctx.groupEntity ?? toGroupEntity(void 0, msg.peerUid),
424
+ group_member: ctx.groupMemberEntity ?? toGroupMemberEntity(void 0, msg.peerUid, msg.senderUin)
425
+ };
426
+ else if (scene === "friend") data = {
427
+ message_scene: "friend",
428
+ peer_id: peerIdOf(msg, scene),
429
+ message_seq: Number(msg.msgSeq),
430
+ sender_id: Number(msg.senderUin),
431
+ time,
432
+ segments,
433
+ friend: ctx.friendEntity ?? toFriendEntity(void 0, msg.peerUin !== "" ? msg.peerUin : msg.senderUin, msg.peerName)
434
+ };
435
+ else data = {
436
+ message_scene: "temp",
437
+ peer_id: peerIdOf(msg, scene),
438
+ message_seq: Number(msg.msgSeq),
439
+ sender_id: Number(msg.senderUin),
440
+ time,
441
+ segments,
442
+ group: ctx.groupEntity ?? null
443
+ };
444
+ return {
445
+ time,
446
+ self_id: Number(ctx.selfUin),
447
+ event_type: "message_receive",
448
+ data
449
+ };
450
+ }
451
+ /** 撤回 grayTip → message_recall 事件(非撤回 grayTip 返回 null)。 */
452
+ function toMessageRecallEvent(msg, ctx) {
453
+ const revoke = (msg.elements.find((el) => el.grayTipElement !== void 0)?.grayTipElement)?.revokeElement;
454
+ if (revoke === void 0) return null;
455
+ const scene = chatTypeToScene(msg.chatType);
456
+ const operatorUin = ctx.uidToUin?.get(revoke.operatorUid) ?? revoke.operatorUid;
457
+ return {
458
+ time: Math.floor(Number(msg.msgTime) / MS_TO_SEC),
459
+ self_id: Number(ctx.selfUin),
460
+ event_type: "message_recall",
461
+ data: {
462
+ message_scene: scene,
463
+ peer_id: peerIdOf(msg, scene),
464
+ message_seq: Number(msg.msgSeq),
465
+ sender_id: Number(msg.senderUin),
466
+ operator_id: Number(operatorUin) || 0,
467
+ display_suffix: revoke.wording ?? ""
468
+ }
469
+ };
470
+ }
471
+ //#endregion
472
+ //#region src/milky/action/error-map.ts
473
+ const milkyErrorCodeMap = {
474
+ SEND_FAILED: -500,
475
+ PERMISSION_DENIED: -405,
476
+ NOT_FOUND: -404,
477
+ TIMEOUT: -504,
478
+ NOT_LOGIN: -403,
479
+ INVALID_PARAM: -400,
480
+ INVALID_STATE: -409,
481
+ UNKNOWN: -500
482
+ };
483
+ //#endregion
484
+ //#region src/milky/action/base-action.ts
485
+ /**
486
+ * MilkyAction:Milky 动作基类(zod 校验 + KernelError → 负数 retcode)
487
+ *
488
+ * 与 OB11 BaseAction 分离的原因:Milky 响应信封无 echo、参数解析失败恒
489
+ * retcode=-400(规范),不复用 OB11 的 400/1400 语义。
490
+ * 响应信封由 transport 组装(status/retcode/data/message)。
491
+ */
492
+ /** zod 错误摘要(单行,issues 取前 3 条)。 */
493
+ function zodSummary(error) {
494
+ const parts = [];
495
+ for (const issue of error.issues.slice(0, 3)) parts.push(`${issue.path.map(String).join(".")}: ${issue.message}`);
496
+ return parts.join("; ");
497
+ }
498
+ var MilkyAction = class {
499
+ /** 校验并执行(HTTP /api 入口;WS /event 不承载动作)。 */
500
+ async handle(payload) {
501
+ const parsed = this.schema.safeParse(payload);
502
+ if (!parsed.success) return {
503
+ status: "failed",
504
+ retcode: -400,
505
+ data: null,
506
+ message: `参数解析失败: ${zodSummary(parsed.error)}`
507
+ };
508
+ try {
509
+ return {
510
+ status: "ok",
511
+ retcode: 0,
512
+ data: await this._handle(parsed.data),
513
+ message: ""
514
+ };
515
+ } catch (err) {
516
+ if (isKernelError(err)) return {
517
+ status: "failed",
518
+ retcode: this.errorCodeMap[err.code] ?? this.errorCodeMap.UNKNOWN,
519
+ data: null,
520
+ message: err.message
521
+ };
522
+ return {
523
+ status: "failed",
524
+ retcode: this.errorCodeMap.UNKNOWN,
525
+ data: null,
526
+ message: err instanceof Error ? err.message : "未知错误"
527
+ };
528
+ }
529
+ }
530
+ };
531
+ /**
532
+ * 持 MilkyApi 的动作基类:api 注入 + 统一错误映射表(milkyErrorCodeMap)收拢,
533
+ * 动作子类只写 name / schema / _handle。
534
+ */
535
+ var MilkyApiAction = class extends MilkyAction {
536
+ errorCodeMap = milkyErrorCodeMap;
537
+ api;
538
+ constructor(api) {
539
+ super();
540
+ this.api = api;
541
+ }
542
+ };
543
+ //#endregion
544
+ //#region src/milky/action/message.ts
545
+ /**
546
+ * OutgoingSegment 宽松校验:入口只保证「{type, data}」形状,字段级转换在
547
+ * outgoingSegmentsToCanonical 内做(未知段跳过 = 规范兼容性要求)。
548
+ */
549
+ const outgoingSegmentSchema = z.object({
550
+ type: z.string(),
551
+ data: z.record(z.string(), z.unknown())
552
+ }).loose();
553
+ const sendMessageFields = { message: z.array(outgoingSegmentSchema).min(1) };
554
+ const recallFields = { message_seq: z.number().int().min(0) };
555
+ /** send_private_message:发送私聊消息。 */
556
+ var SendPrivateMessageAction = class extends MilkyApiAction {
557
+ name = "send_private_message";
558
+ schema = z.object({
559
+ user_id: z.number().int().min(10001),
560
+ ...sendMessageFields
561
+ });
562
+ async _handle(payload) {
563
+ return await sendMilkyMessage(await this.api.resolvePeer("friend", payload.user_id), payload.message, this.api);
564
+ }
565
+ };
566
+ /** send_group_message:发送群聊消息。 */
567
+ var SendGroupMessageAction = class extends MilkyApiAction {
568
+ name = "send_group_message";
569
+ schema = z.object({
570
+ group_id: z.number().int().min(10001),
571
+ ...sendMessageFields
572
+ });
573
+ async _handle(payload) {
574
+ return await sendMilkyMessage(await this.api.resolvePeer("group", payload.group_id), payload.message, this.api);
575
+ }
576
+ };
577
+ /** get_message:获取单条消息(seq → msgId 反查 + 实体回填)。 */
578
+ var GetMessageAction = class extends MilkyApiAction {
579
+ name = "get_message";
580
+ schema = z.object({
581
+ message_scene: z.enum([
582
+ "friend",
583
+ "group",
584
+ "temp"
585
+ ]),
586
+ peer_id: z.number().int().min(10001),
587
+ message_seq: z.number().int().min(0)
588
+ });
589
+ async _handle(payload) {
590
+ const peer = await this.api.resolvePeer(payload.message_scene, payload.peer_id);
591
+ const msgId = await this.api.resolveMsgIdBySeq(peer, payload.message_seq, peer.chatType);
592
+ if (msgId === void 0) throw kernelError(`消息 ${payload.message_seq} 不存在`, "NOT_FOUND");
593
+ const [msg] = await this.api.msgApi.fetchMsgsByMsgId(peer, [msgId]);
594
+ if (msg === void 0) throw kernelError(`消息 ${payload.message_seq} 不存在`, "NOT_FOUND");
595
+ return { message: await this.translateWithEntities(payload.message_scene, peer, msg) };
596
+ }
597
+ /** 实体回填(group 场景 schema 要求完整实体)→ IncomingMessage。 */
598
+ async translateWithEntities(scene, peer, msg) {
599
+ const ctx = { selfUin: this.api.self.uin };
600
+ if (scene === "group") {
601
+ const [detail, member] = await Promise.all([this.api.groupCache?.getGroupDetail(peer.peerUid).catch(() => void 0), this.api.groupCache?.getMember(peer.peerUid, msg.senderUid).catch(() => void 0)]);
602
+ ctx.groupEntity = toGroupEntity(detail, peer.peerUid);
603
+ ctx.groupMemberEntity = toGroupMemberEntity(member, peer.peerUid, msg.senderUin);
604
+ }
605
+ return toMessageReceiveEvent(msg, ctx).data;
606
+ }
607
+ };
608
+ /** recall_private_message:撤回私聊消息。 */
609
+ var RecallPrivateMessageAction = class extends MilkyApiAction {
610
+ name = "recall_private_message";
611
+ schema = z.object({
612
+ user_id: z.number().int().min(10001),
613
+ ...recallFields
614
+ });
615
+ async _handle(payload) {
616
+ await recallBySeq(await this.api.resolvePeer("friend", payload.user_id), payload.message_seq, this.api);
617
+ return {};
618
+ }
619
+ };
620
+ /** recall_group_message:撤回群消息。 */
621
+ var RecallGroupMessageAction = class extends MilkyApiAction {
622
+ name = "recall_group_message";
623
+ schema = z.object({
624
+ group_id: z.number().int().min(10001),
625
+ ...recallFields
626
+ });
627
+ async _handle(payload) {
628
+ await recallBySeq(await this.api.resolvePeer("group", payload.group_id), payload.message_seq, this.api);
629
+ return {};
630
+ }
631
+ };
632
+ /** seq → msgId(反查)→ recallMessage。 */
633
+ async function recallBySeq(peer, seq, api) {
634
+ const msgId = await api.resolveMsgIdBySeq(peer, seq, peer.chatType);
635
+ if (msgId === void 0) throw kernelError(`消息 ${seq} 不存在`, "NOT_FOUND");
636
+ await api.msgApi.recallMessage(peer, [msgId]);
637
+ }
638
+ /** 发送核心:segments → canonical(URI/ID 转换)→ sendMessage → 回查输出。 */
639
+ async function sendMilkyMessage(peer, segments, api) {
640
+ const { elements, atUins } = outgoingSegmentsToCanonical(segments);
641
+ const resolved = await resolveElements(elements, atUins, api);
642
+ if (resolved.length === 0) throw kernelError("消息内容为空(全部消息段不可用)", "INVALID_PARAM");
643
+ const { msgId } = await api.msgApi.sendMessage(peer, resolved);
644
+ return await api.resolveSentOutput(peer, msgId);
645
+ }
646
+ /** 元素二次解析:at uin→uid + 远程媒体 URI 落盘(reply seq 已在映射层兜底原样)。 */
647
+ async function resolveElements(elements, atUins, api) {
648
+ const uinToUid = await resolveAtUins(atUins, api);
649
+ const out = [];
650
+ for (const el of elements) out.push(await resolveElement(el, uinToUid, api));
651
+ return out;
652
+ }
653
+ /** at uin→uid 批量转换(失败返回 undefined:at 原样 uin,交 QQ 端判断)。 */
654
+ async function resolveAtUins(atUins, api) {
655
+ if (atUins.length === 0) return;
656
+ try {
657
+ return await api.uinToUid(atUins);
658
+ } catch {
659
+ return;
660
+ }
661
+ }
662
+ /** 单元素解析:at 目标替换 / 远程媒体 URI 落盘 / 其余原样。 */
663
+ async function resolveElement(el, uinToUid, api) {
664
+ if (el.type === "at" && el.target !== "all") return {
665
+ ...el,
666
+ target: uinToUid?.get(el.target) ?? el.target
667
+ };
668
+ if (isRemoteMediaUri(el)) {
669
+ const media = el;
670
+ return {
671
+ ...media,
672
+ path: await resolveMediaUri(media.path, api.mediaDir)
673
+ };
674
+ }
675
+ return el;
676
+ }
677
+ /** 远程媒体元素(image/voice/video 且 path 为需落盘的 URI)。 */
678
+ function isRemoteMediaUri(el) {
679
+ return (el.type === "image" || el.type === "voice" || el.type === "video") && (el.path.startsWith("http://") || el.path.startsWith("https://") || el.path.startsWith("base64://"));
680
+ }
681
+ //#endregion
682
+ //#region src/milky/action/social.ts
683
+ /**
684
+ * Milky 社交类动作(P0):get_friend_list / get_group_list
685
+ */
686
+ const emptySchema$1 = z.object({}).strict();
687
+ /** get_friend_list:获取好友列表。 */
688
+ var GetFriendListAction = class extends MilkyApiAction {
689
+ name = "get_friend_list";
690
+ schema = emptySchema$1;
691
+ async _handle() {
692
+ return { friends: (await this.api.friendApi.getFriendList()).map((f) => toFriendEntity(f, f.uin, f.nickname)) };
693
+ }
694
+ };
695
+ /**
696
+ * get_group_list:获取群列表。
697
+ * 实测校准(2026-08-08,OB11 同款):原生 getGroupList 返回值无数据,列表经
698
+ * onGroupListUpdate 事件进 GroupCache——缓存优先,为空触发刷新回填。
699
+ */
700
+ var GetGroupListAction = class extends MilkyApiAction {
701
+ name = "get_group_list";
702
+ schema = emptySchema$1;
703
+ async _handle() {
704
+ return { groups: (this.api.groupCache !== void 0 ? await this.api.groupCache.listGroupsRefreshed() : await this.api.groupApi.getGroupList(true)).map(groupListToEntity) };
705
+ }
706
+ };
707
+ //#endregion
708
+ //#region src/milky/action/system.ts
709
+ /**
710
+ * Milky 系统类动作(P0):get_login_info / get_impl_info
711
+ *
712
+ * get_impl_info 是 Milky 的版本协商面(规范 §compatibility):milky_version
713
+ * 固定 "1.3"(目标协议版本 1.3.0),qq_protocol_type 固定 windows(自建宿主)。
714
+ */
715
+ /** Milky 协议版本(docs/protocols/milky.md:目标 1.3.0)。 */
716
+ const MILKY_VERSION = "1.3";
717
+ const emptySchema = z.object({}).strict();
718
+ /** get_login_info:获取登录信息。 */
719
+ var GetLoginInfoAction = class extends MilkyApiAction {
720
+ name = "get_login_info";
721
+ schema = emptySchema;
722
+ async _handle() {
723
+ return {
724
+ uin: Number(this.api.self.uin),
725
+ nickname: this.api.self.nickname
726
+ };
727
+ }
728
+ };
729
+ /** get_impl_info:获取协议端信息(版本协商)。 */
730
+ var GetImplInfoAction = class extends MilkyApiAction {
731
+ name = "get_impl_info";
732
+ schema = emptySchema;
733
+ async _handle() {
734
+ return {
735
+ impl_name: "napukettoqq",
736
+ impl_version: this.api.implVersion,
737
+ qq_protocol_version: this.api.qqVersion,
738
+ qq_protocol_type: "windows",
739
+ milky_version: MILKY_VERSION
740
+ };
741
+ }
742
+ };
743
+ //#endregion
744
+ //#region src/milky/action/index.ts
745
+ /**
746
+ * Milky 动作注册表(P0:9 个动作)
747
+ *
748
+ * P0 面 = docs/protocols/milky.md §9 P0 清单:get_login_info / get_impl_info /
749
+ * send_private_message / send_group_message / get_message / recall_*_message /
750
+ * get_friend_list / get_group_list。P1 扩面(群管/精华/群文件/好友请求/票据)
751
+ * 往本注册表追加。
752
+ */
753
+ /** Milky 动作注册表(P0 9 动作)。 */
754
+ function createMilkyActionRegistry(opts) {
755
+ const registry = new ActionRegistry();
756
+ const { api } = opts;
757
+ registry.register(new GetLoginInfoAction(api));
758
+ registry.register(new GetImplInfoAction(api));
759
+ registry.register(new SendPrivateMessageAction(api));
760
+ registry.register(new SendGroupMessageAction(api));
761
+ registry.register(new GetMessageAction(api));
762
+ registry.register(new RecallPrivateMessageAction(api));
763
+ registry.register(new RecallGroupMessageAction(api));
764
+ registry.register(new GetFriendListAction(api));
765
+ registry.register(new GetGroupListAction(api));
766
+ return registry;
767
+ }
768
+ //#endregion
769
+ //#region src/milky/api.ts
770
+ var MilkyApi = class {
771
+ msgApi;
772
+ groupApi;
773
+ friendApi;
774
+ profileApi;
775
+ self;
776
+ groupCache;
777
+ qqVersion;
778
+ implVersion;
779
+ mediaDir;
780
+ /** message_seq ↔ msgId 双向映射(接收链路 record,动作侧反查)。 */
781
+ seqIndex;
782
+ constructor(opts, seqIndex) {
783
+ this.msgApi = opts.msgApi;
784
+ this.groupApi = opts.groupApi;
785
+ this.friendApi = opts.friendApi;
786
+ this.profileApi = opts.profileApi;
787
+ this.self = opts.self;
788
+ this.groupCache = opts.groupCache;
789
+ this.qqVersion = opts.qqVersion;
790
+ this.implVersion = opts.implVersion;
791
+ this.mediaDir = opts.mediaDir;
792
+ this.seqIndex = seqIndex;
793
+ }
794
+ /** uin → uid(私聊目标 / at 段发送转换)。 */
795
+ uinToUid(uins) {
796
+ return this.groupApi.uinToUid(uins);
797
+ }
798
+ /** uid → uin(事件路径 mention 转换)。 */
799
+ uidToUin(uids) {
800
+ return this.groupApi.uidToUin(uids);
801
+ }
802
+ /**
803
+ * message_scene + 数字 ID → NT Peer。
804
+ * group = 群号直作 peerUid;friend/temp = uin → uid(失败退 uin 原样,交 QQ 端判断)。
805
+ */
806
+ async resolvePeer(scene, id) {
807
+ if (scene === "group") return {
808
+ chatType: ChatType.GROUP,
809
+ peerUid: String(id)
810
+ };
811
+ const uin = String(id);
812
+ let uid;
813
+ try {
814
+ uid = (await this.uinToUid([uin])).get(uin);
815
+ } catch {}
816
+ return {
817
+ chatType: scene === "friend" ? ChatType.C2C : ChatType.C2C_TEMP,
818
+ peerUid: uid ?? uin
819
+ };
820
+ }
821
+ /**
822
+ * seq → msgId(索引优先,未登记扫描近段历史兜底)。
823
+ * 两层都未命中返回 undefined(调用方抛 NOT_FOUND)。
824
+ */
825
+ async resolveMsgIdBySeq(peer, seq, chatType) {
826
+ const indexed = this.seqIndex.resolveMsgId(chatType, peer.peerUid, seq);
827
+ if (indexed !== void 0) return indexed;
828
+ const hit = (await this.msgApi.fetchMessages(peer, { count: 60 }).catch(() => [])).find((m) => Number(m.msgSeq) === seq);
829
+ if (hit === void 0) return;
830
+ this.seqIndex.record(hit);
831
+ return hit.msgId;
832
+ }
833
+ /** 发送结果回查:msgId → {message_seq, time}(sendMessage 只回 msgId)。 */
834
+ async resolveSentOutput(peer, msgId) {
835
+ const [sent] = await this.msgApi.fetchMsgsByMsgId(peer, [msgId]);
836
+ if (sent === void 0) throw kernelError("发送结果回查失败(msgId 未命中)", "UNKNOWN");
837
+ this.seqIndex.record(sent);
838
+ return {
839
+ message_seq: Number(sent.msgSeq),
840
+ time: Math.floor(Number(sent.msgTime) / 1e3)
841
+ };
842
+ }
843
+ };
844
+ //#endregion
845
+ //#region src/milky/helper/seq-index.ts
846
+ /** 单方向最大条目数(测试用例引用容量语义)。 */
847
+ const MAX_ENTRIES = 4096;
848
+ var MessageSeqIndex = class {
849
+ /** `${chatType}:${peerUid}:${seq}` → msgId。 */
850
+ seqToMsg = /* @__PURE__ */ new Map();
851
+ /** msgId → seq 元信息。 */
852
+ msgToMeta = /* @__PURE__ */ new Map();
853
+ /** 接收链路登记(一条消息双向写入;重复 msgId 覆盖)。 */
854
+ record(msg) {
855
+ const seq = Number(msg.msgSeq);
856
+ if (!Number.isFinite(seq) || seq < 0) return;
857
+ this.seqToMsg.set(this.key(msg.chatType, msg.peerUid, seq), msg.msgId);
858
+ this.msgToMeta.set(msg.msgId, {
859
+ seq,
860
+ senderUin: msg.senderUin,
861
+ timeSec: Math.floor(Number(msg.msgTime) / 1e3)
862
+ });
863
+ this.evict();
864
+ }
865
+ /** seq → msgId(反查;未登记返回 undefined)。 */
866
+ resolveMsgId(chatType, peerUid, seq) {
867
+ return this.seqToMsg.get(this.key(chatType, peerUid, seq));
868
+ }
869
+ /**
870
+ * 批量回填 reply 引用元信息(msgId → Milky reply 段 data 所需三元组)。
871
+ * 未命中的 id 不进表(翻译层按 0 兜底)。
872
+ */
873
+ collectReplyMeta(msgIds) {
874
+ const out = /* @__PURE__ */ new Map();
875
+ for (const id of msgIds) {
876
+ const m = this.msgToMeta.get(id);
877
+ if (m !== void 0) out.set(id, {
878
+ messageSeq: m.seq,
879
+ senderId: Number(m.senderUin) || 0,
880
+ time: m.timeSec
881
+ });
882
+ }
883
+ return out;
884
+ }
885
+ /** key 规则(chatType 数值 + peerUid + seq;peerUid 群聊=群号,C2C=uid)。 */
886
+ key(chatType, peerUid, seq) {
887
+ return `${chatType}:${peerUid}:${seq}`;
888
+ }
889
+ /** FIFO 淘汰到容量上限内。 */
890
+ evict() {
891
+ while (this.seqToMsg.size > MAX_ENTRIES) {
892
+ const oldest = this.seqToMsg.keys().next().value;
893
+ if (oldest === void 0) break;
894
+ this.seqToMsg.delete(oldest);
895
+ }
896
+ while (this.msgToMeta.size > MAX_ENTRIES) {
897
+ const oldest = this.msgToMeta.keys().next().value;
898
+ if (oldest === void 0) break;
899
+ this.msgToMeta.delete(oldest);
900
+ }
901
+ }
902
+ };
903
+ //#endregion
904
+ //#region src/milky/transport.ts
905
+ /** /event 路径(规范固定)。 */
906
+ const EVENT_PATH = "/event";
907
+ /** /api/ 前缀 + 动作名(小写字母开头,小写/数字/下划线)。 */
908
+ const API_PATH_RE = /^\/api\/([a-z][a-z0-9_]*)$/;
909
+ /** Bearer 前缀。 */
910
+ const BEARER_PREFIX = "Bearer ";
911
+ /**
912
+ * Milky 事件枢纽:SSE 客户端 + WS 客户端集合,send 即广播。
913
+ * 传输适配器面(open/close/send,EventBroadcaster 多态派发)——生命周期随
914
+ * HttpServer。连接注册经 attachTo 挂到 HttpServer 注入点(本类内部闭环);
915
+ * WS 入帧忽略(规范:/event 为纯事件推送通道)。
916
+ */
917
+ var MilkyEventHub = class {
918
+ sseClients = /* @__PURE__ */ new Set();
919
+ sockets = /* @__PURE__ */ new Set();
920
+ /** 把 /event 的 SSE(onRawRequest)与 WS 升级(onUpgrade)挂到 HTTP server。 */
921
+ attachTo(server, config) {
922
+ server.onRawRequest = (ctx) => {
923
+ if (ctx.path !== EVENT_PATH || ctx.method !== "GET") return;
924
+ if (!config.eventModes.includes("sse")) return new Response("event mode sse disabled", { status: 404 });
925
+ return createSseResponse((client) => {
926
+ this.sseClients.add(client);
927
+ return () => {
928
+ this.sseClients.delete(client);
929
+ };
930
+ });
931
+ };
932
+ server.onUpgrade = (req, accept, reject) => {
933
+ if (pathnameOf(req.url) !== EVENT_PATH) return;
934
+ if (!config.eventModes.includes("ws")) {
935
+ reject(404, "event mode ws disabled");
936
+ return;
937
+ }
938
+ if (config.token !== "" && !tokenMatches(req, config.token)) {
939
+ reject(401, "unauthorized");
940
+ return;
941
+ }
942
+ accept((socket) => {
943
+ this.sockets.add(socket);
944
+ const remove = () => {
945
+ this.sockets.delete(socket);
946
+ };
947
+ socket.on("close", remove);
948
+ socket.on("error", remove);
949
+ });
950
+ };
951
+ }
952
+ /** 广播事件(EventBroadcaster emit 入口)。 */
953
+ send(event) {
954
+ const json = JSON.stringify(event);
955
+ for (const client of this.sseClients) client.send(json, "milky_event");
956
+ for (const socket of this.sockets) if (socket.readyState === WebSocket.OPEN) socket.send(json);
957
+ }
958
+ open() {}
959
+ async close() {
960
+ for (const client of this.sseClients) client.close();
961
+ this.sseClients.clear();
962
+ for (const socket of this.sockets) socket.close(1001, "server shutdown");
963
+ this.sockets.clear();
964
+ }
965
+ onRequest;
966
+ };
967
+ /** 按配置装配 Milky 传输。 */
968
+ function assembleMilkyTransports(opts) {
969
+ const { config, broadcaster, registry, logger } = opts;
970
+ const hub = new MilkyEventHub();
971
+ const token = config.token;
972
+ const authorize = token !== "" ? makeMilkyAuthorize(token) : void 0;
973
+ const server = new HttpServer({
974
+ host: config.host,
975
+ port: config.port,
976
+ ...authorize !== void 0 ? { authorize } : {}
977
+ });
978
+ hub.attachTo(server, config);
979
+ server.onPathRequest = (ctx, req, respond) => {
980
+ handleMilkyHttpRequest(ctx, req, respond, registry);
981
+ };
982
+ const adapters = [server, hub];
983
+ for (const url of config.webhooks) {
984
+ const client = new HttpClient({
985
+ url,
986
+ ...token !== "" ? { headers: { authorization: `${BEARER_PREFIX}${token}` } } : {},
987
+ timeoutMs: 5e3,
988
+ onError: (err) => {
989
+ logger?.warn({
990
+ url,
991
+ err: String(err)
992
+ }, "milky: webhook 推送失败");
993
+ }
994
+ });
995
+ adapters.push(client);
996
+ }
997
+ for (const adapter of adapters) broadcaster.register(adapter);
998
+ return {
999
+ adapters,
1000
+ close: async () => {
1001
+ await server.close();
1002
+ await hub.close();
1003
+ for (const adapter of adapters) broadcaster.unregister(adapter);
1004
+ }
1005
+ };
1006
+ }
1007
+ /** HTTP /api 分发(规范信封 + 404/415 语义)。 */
1008
+ function handleMilkyHttpRequest(ctx, req, respond, registry) {
1009
+ if (ctx.method !== "POST") {
1010
+ respond(void 0, 404);
1011
+ return;
1012
+ }
1013
+ const match = API_PATH_RE.exec(ctx.path);
1014
+ if (match === null) {
1015
+ respond(void 0, 404);
1016
+ return;
1017
+ }
1018
+ const contentType = ctx.headers?.["content-type"];
1019
+ if (contentType !== void 0 && !contentType.toLowerCase().includes("application/json")) {
1020
+ respond(void 0, 415);
1021
+ return;
1022
+ }
1023
+ const action = registry.get(match[1] ?? "");
1024
+ if (action === void 0) {
1025
+ respond(void 0, 404);
1026
+ return;
1027
+ }
1028
+ action.handle(req).then((result) => {
1029
+ if (result.status === "ok") respond({
1030
+ status: "ok",
1031
+ retcode: 0,
1032
+ data: result.data ?? {}
1033
+ });
1034
+ else respond({
1035
+ status: "failed",
1036
+ retcode: result.retcode,
1037
+ data: null,
1038
+ message: result.message
1039
+ });
1040
+ }).catch(() => {
1041
+ respond({
1042
+ status: "failed",
1043
+ retcode: -500,
1044
+ data: null,
1045
+ message: "internal error"
1046
+ });
1047
+ });
1048
+ }
1049
+ /** 鉴权钩子:Bearer 头或 access_token 查询参数(规范允许 SSE/WS 查询参数兜底)。 */
1050
+ function makeMilkyAuthorize(token) {
1051
+ return (ctx) => tokenMatches(ctx, token);
1052
+ }
1053
+ /** 请求凭据匹配(头优先,查询参数兜底)。 */
1054
+ function tokenMatches(ctx, token) {
1055
+ const auth = ctx.headers["authorization"] ?? ctx.headers["Authorization"];
1056
+ if (auth?.startsWith(BEARER_PREFIX)) return auth.slice(7) === token;
1057
+ try {
1058
+ return new URL(ctx.url, "http://localhost").searchParams.get("access_token") === token;
1059
+ } catch {
1060
+ return false;
1061
+ }
1062
+ }
1063
+ /** URL 路径部分(无 scheme 相对 url 容错)。 */
1064
+ function pathnameOf(url) {
1065
+ try {
1066
+ return new URL(url, "http://localhost").pathname;
1067
+ } catch {
1068
+ return url.split("?")[0] ?? "";
1069
+ }
1070
+ }
1071
+ //#endregion
1072
+ //#region src/milky/adapter.ts
1073
+ var NapukettoMilkyAdapter = class extends BaseProtocolAdapter {
1074
+ protocol = "milky";
1075
+ msgChannel;
1076
+ calibLogger;
1077
+ api;
1078
+ seqIndex = new MessageSeqIndex();
1079
+ /** 动作注册表(公共只读:装配方/测试枚举)。 */
1080
+ registry;
1081
+ unsubscribes = [];
1082
+ stopTransports = null;
1083
+ constructor(opts) {
1084
+ super({
1085
+ config: opts.config,
1086
+ broadcaster: opts.broadcaster,
1087
+ hooks: {
1088
+ onStart: (config) => this.startTransports(config),
1089
+ onStop: () => this.stopAll(),
1090
+ onReload: (config) => this.reloadTransports(config)
1091
+ }
1092
+ });
1093
+ this.msgChannel = opts.msgChannel;
1094
+ this.calibLogger = opts.logger;
1095
+ this.api = new MilkyApi(opts, this.seqIndex);
1096
+ this.registry = createMilkyActionRegistry({ api: this.api });
1097
+ }
1098
+ /** 启动传输 + 订阅(enabled=false 空转:不订阅不起传输)。 */
1099
+ async startTransports(config) {
1100
+ if (!config.enabled) {
1101
+ this.calibLogger?.info({}, "milky: 未启用(enabled=false),跳过装配");
1102
+ return;
1103
+ }
1104
+ const broadcaster = this.getBroadcaster();
1105
+ if (broadcaster !== void 0) {
1106
+ const set = assembleMilkyTransports({
1107
+ config,
1108
+ broadcaster,
1109
+ registry: this.registry,
1110
+ ...this.calibLogger !== void 0 ? { logger: this.calibLogger } : {}
1111
+ });
1112
+ this.stopTransports = set.close;
1113
+ }
1114
+ this.subscribe();
1115
+ this.calibLogger?.info({
1116
+ host: config.host,
1117
+ port: config.port,
1118
+ modes: config.eventModes
1119
+ }, "milky: adapter started");
1120
+ }
1121
+ /** 热更新:停止重建(enabled 切换生效)。 */
1122
+ async reloadTransports(config) {
1123
+ await this.stopAll();
1124
+ await this.startTransports(config);
1125
+ }
1126
+ /** 停止:退订 + 关传输。 */
1127
+ async stopAll() {
1128
+ this.unsubscribeAll();
1129
+ if (this.stopTransports !== null) {
1130
+ await this.stopTransports();
1131
+ this.stopTransports = null;
1132
+ }
1133
+ }
1134
+ /** 订阅 kernel 消息通道(幂等)。 */
1135
+ subscribe() {
1136
+ if (this.unsubscribes.length > 0) return;
1137
+ this.unsubscribes.push(subscribeRawMessages(this.msgChannel, (msg) => {
1138
+ this.handleIncoming(msg);
1139
+ }));
1140
+ }
1141
+ /** 退订(幂等)。 */
1142
+ unsubscribeAll() {
1143
+ for (const off of this.unsubscribes) off();
1144
+ this.unsubscribes = [];
1145
+ }
1146
+ /** 单条消息:seq 登记 → grayTip→recall / 普通→message_receive(自消息不上报)。 */
1147
+ async handleIncoming(msg) {
1148
+ this.seqIndex.record(msg);
1149
+ if (hasGrayTip(msg)) {
1150
+ const event = await this.buildRecallEvent(msg);
1151
+ if (event !== null) this.broadcastEvent(event);
1152
+ return;
1153
+ }
1154
+ if (String(msg.senderUin) === this.api.self.uin) return;
1155
+ const event = await this.buildMessageReceiveEvent(msg);
1156
+ this.broadcastEvent(event);
1157
+ }
1158
+ /** message_receive 组装:at uid→uin + reply 回填 + 群实体回填(全部 fail-soft)。 */
1159
+ async buildMessageReceiveEvent(msg) {
1160
+ const elements = toCanonicalElements(msg);
1161
+ const ctx = { selfUin: this.api.self.uin };
1162
+ await this.fillMentionUids(elements, ctx);
1163
+ this.fillReplyMeta(elements, ctx);
1164
+ await this.fillGroupEntities(msg, ctx);
1165
+ return toMessageReceiveEvent(msg, ctx);
1166
+ }
1167
+ /** at uid → uin 批量转换(mention.user_id 用;失败 mention 段跳过)。 */
1168
+ async fillMentionUids(elements, ctx) {
1169
+ const atUids = elements.filter((el) => el.type === "at" && el.target !== "all").map((el) => el.target);
1170
+ if (atUids.length === 0) return;
1171
+ try {
1172
+ ctx.uidToUin = await this.api.uidToUin(atUids);
1173
+ } catch {}
1174
+ }
1175
+ /** reply 引用元信息回填(seqIndex 命中即用;未命中不强查)。 */
1176
+ fillReplyMeta(elements, ctx) {
1177
+ const replyIds = elements.filter((el) => el.type === "reply").map((el) => el.messageId);
1178
+ const meta = this.seqIndex.collectReplyMeta(replyIds);
1179
+ if (meta.size > 0) ctx.replyMeta = meta;
1180
+ }
1181
+ /** 群场景实体回填(GroupCache 缓存优先,失败最小实体兜底)。 */
1182
+ async fillGroupEntities(msg, ctx) {
1183
+ if (msg.chatType !== ChatType.GROUP || this.api.groupCache === void 0) return;
1184
+ const [detail, member] = await Promise.all([this.api.groupCache.getGroupDetail(msg.peerUid).catch(() => void 0), this.api.groupCache.getMember(msg.peerUid, msg.senderUid).catch(() => void 0)]);
1185
+ ctx.groupEntity = toGroupEntity(detail, msg.peerUid);
1186
+ ctx.groupMemberEntity = toGroupMemberEntity(member, msg.peerUid, msg.senderUin);
1187
+ }
1188
+ /** message_recall 组装(非撤回 grayTip 返回 null 静默)。 */
1189
+ async buildRecallEvent(msg) {
1190
+ const uidToUin = await this.resolveOperatorUin(msg);
1191
+ return toMessageRecallEvent(msg, {
1192
+ selfUin: this.api.self.uin,
1193
+ ...uidToUin !== void 0 ? { uidToUin } : {}
1194
+ });
1195
+ }
1196
+ /** 撤回操作者 uid → uin(未撤回/转换失败返回 undefined,operator_id 兜底 0)。 */
1197
+ resolveOperatorUin(msg) {
1198
+ const operatorUid = findRevokeOperatorUid(msg);
1199
+ if (operatorUid === void 0) return Promise.resolve(void 0);
1200
+ return this.api.uidToUin([operatorUid]).catch(() => void 0);
1201
+ }
1202
+ };
1203
+ /** 撤回 grayTip 的操作者 uid(非撤回消息返回 undefined)。 */
1204
+ function findRevokeOperatorUid(msg) {
1205
+ const uid = msg.elements.find((el) => el.grayTipElement?.revokeElement !== void 0)?.grayTipElement?.revokeElement?.operatorUid;
1206
+ return uid === void 0 || uid === "" ? void 0 : uid;
1207
+ }
1208
+ /** 是否含 grayTip 元素(与 OB11 收链路同判据)。 */
1209
+ function hasGrayTip(msg) {
1210
+ return msg.elements.some((el) => el.grayTipElement !== void 0);
1211
+ }
1212
+ //#endregion
1213
+ //#region src/milky/config.ts
1214
+ /**
1215
+ * Milky 协议配置(zod schema,归属本包,ADR-012)
1216
+ *
1217
+ * 单一 TOML 的 `[accounts.milky]` 账号段(docs/protocols/milky.md §4):
1218
+ * 单端口 HTTP 模型(/api + /event 同端口),`enabled` 缺省 false = 不启用。
1219
+ */
1220
+ /** Milky 配置 schema。 */
1221
+ const milkyConfigSchema = z.object({
1222
+ /** 启用开关(缺省 false:账号段存在但 enabled 未开 = 装配为空转)。 */
1223
+ enabled: z.boolean().default(false),
1224
+ /** HTTP 监听地址。 */
1225
+ host: z.string().min(1).default("127.0.0.1"),
1226
+ /** HTTP 监听端口(/api 与 /event 同端口)。 */
1227
+ port: z.number().int().min(0).max(65535).default(3001),
1228
+ /** access_token(空 = 不校验,仅限内网)。 */
1229
+ token: z.string().default(""),
1230
+ /** /event 允许的连接形态(缺省两者都开)。 */
1231
+ eventModes: z.array(z.enum(["sse", "ws"])).default(["sse", "ws"]),
1232
+ /** 额外事件推送地址(WebHook,可多个;协议端在请求头发 Bearer token)。 */
1233
+ webhooks: z.array(z.string().min(1)).default([]),
1234
+ /** 发送媒体 http(s) URI 下载缓存目录(缺省不下载 = http(s) 发送媒体不可用)。 */
1235
+ cacheDir: z.string().optional()
1236
+ });
1237
+ //#endregion
1238
+ export { NapukettoMilkyAdapter, milkyConfigSchema };
1239
+
1240
+ //# sourceMappingURL=index.mjs.map