@napuketto/adapter 0.4.2 → 0.6.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.
- package/dist/buddy-req-Q7Q4bVuz.mjs +43 -0
- package/dist/buddy-req-Q7Q4bVuz.mjs.map +1 -0
- package/dist/gray-tip-LwBee5mo.mjs +33 -0
- package/dist/gray-tip-LwBee5mo.mjs.map +1 -0
- package/dist/kurobot/index.mjs +1 -1
- package/dist/milky/index.d.mts +236 -0
- package/dist/milky/index.d.mts.map +1 -0
- package/dist/milky/index.mjs +2513 -0
- package/dist/milky/index.mjs.map +1 -0
- package/dist/onebot11/index.d.mts +2 -0
- package/dist/onebot11/index.d.mts.map +1 -1
- package/dist/onebot11/index.mjs +79 -93
- package/dist/onebot11/index.mjs.map +1 -1
- package/dist/raw-message-D_ecJy4C.mjs +19 -0
- package/dist/raw-message-D_ecJy4C.mjs.map +1 -0
- package/dist/satori/index.mjs +3 -2
- package/dist/satori/index.mjs.map +1 -1
- package/dist/{gray-tip-B80z9KR4.mjs → transport-schema-CpPxyRK0.mjs} +2 -21
- package/dist/transport-schema-CpPxyRK0.mjs.map +1 -0
- package/package.json +9 -4
- package/dist/gray-tip-B80z9KR4.mjs.map +0 -1
- package/dist/raw-message-6KdlG8kR.mjs +0 -13
- package/dist/raw-message-6KdlG8kR.mjs.map +0 -1
|
@@ -0,0 +1,2513 @@
|
|
|
1
|
+
import { o as ActionRegistry, r as BaseProtocolAdapter } from "../core-DyXyDUcU.mjs";
|
|
2
|
+
import { n as firstUntranslatedGrayTip, t as collectGrayTipUids } from "../gray-tip-LwBee5mo.mjs";
|
|
3
|
+
import { n as MS_TO_SEC$2, r as resolveUin, t as narrowBuddyReqs } from "../buddy-req-Q7Q4bVuz.mjs";
|
|
4
|
+
import { n as subscribeRawMessages } from "../raw-message-D_ecJy4C.mjs";
|
|
5
|
+
import { ChatType, GrayTipSubType, GroupApi, GroupNotifyMsgStatus, GroupNotifyMsgType, NTGroupRequestOperateTypes, TicketApi, TipGroupElementType, isKernelError, kernelError, toCanonicalElements } from "@napuketto/kernel";
|
|
6
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
7
|
+
import { detectFileType, downloadUrl, inferExtension } from "@napuketto/media";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { HttpClient, HttpServer, createSseResponse } from "@napuketto/network";
|
|
12
|
+
import { WebSocket } from "ws";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
//#region src/milky/api.ts
|
|
15
|
+
var MilkyApi = class {
|
|
16
|
+
msgApi;
|
|
17
|
+
groupApi;
|
|
18
|
+
friendApi;
|
|
19
|
+
profileApi;
|
|
20
|
+
profileLikeApi;
|
|
21
|
+
ticketApi;
|
|
22
|
+
webApi;
|
|
23
|
+
groupNotifyApi;
|
|
24
|
+
richMediaApi;
|
|
25
|
+
self;
|
|
26
|
+
groupCache;
|
|
27
|
+
qqVersion;
|
|
28
|
+
implVersion;
|
|
29
|
+
mediaDir;
|
|
30
|
+
/** message_seq ↔ msgId 双向映射(接收链路 record,动作侧反查)。 */
|
|
31
|
+
seqIndex;
|
|
32
|
+
constructor(opts, seqIndex) {
|
|
33
|
+
this.msgApi = opts.msgApi;
|
|
34
|
+
this.groupApi = opts.groupApi;
|
|
35
|
+
this.friendApi = opts.friendApi;
|
|
36
|
+
this.profileApi = opts.profileApi;
|
|
37
|
+
this.profileLikeApi = opts.profileLikeApi;
|
|
38
|
+
this.ticketApi = opts.ticketApi;
|
|
39
|
+
this.webApi = opts.webApi;
|
|
40
|
+
this.groupNotifyApi = opts.groupNotifyApi;
|
|
41
|
+
this.richMediaApi = opts.richMediaApi;
|
|
42
|
+
this.self = opts.self;
|
|
43
|
+
this.groupCache = opts.groupCache;
|
|
44
|
+
this.qqVersion = opts.qqVersion;
|
|
45
|
+
this.implVersion = opts.implVersion;
|
|
46
|
+
this.mediaDir = opts.mediaDir;
|
|
47
|
+
this.seqIndex = seqIndex;
|
|
48
|
+
}
|
|
49
|
+
/** uin → uid(私聊目标 / at 段发送转换 / 群管动作的 uid 入参)。 */
|
|
50
|
+
uinToUid(uins) {
|
|
51
|
+
return this.groupApi.uinToUid(uins);
|
|
52
|
+
}
|
|
53
|
+
/** uid → uin(事件路径 mention 转换)。 */
|
|
54
|
+
uidToUin(uids) {
|
|
55
|
+
return this.groupApi.uidToUin(uids);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* uin(数字)→ uid(群管动作入参转换);转换失败抛 INVALID_PARAM
|
|
59
|
+
* (Milky 入参 user_id 都是 uin,kernel 群管 API 要 uid)。
|
|
60
|
+
*/
|
|
61
|
+
async resolveUidFromUin(uin) {
|
|
62
|
+
const uinStr = String(uin);
|
|
63
|
+
try {
|
|
64
|
+
const uid = (await this.uinToUid([uinStr])).get(uinStr);
|
|
65
|
+
if (uid !== void 0 && uid !== "") return uid;
|
|
66
|
+
} catch {}
|
|
67
|
+
throw kernelError(`用户 ${uinStr} 不存在或 uin→uid 转换失败`, "INVALID_PARAM");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* message_scene + 数字 ID → NT Peer。
|
|
71
|
+
* group = 群号直作 peerUid;friend/temp = uin → uid(失败退 uin 原样,交 QQ 端判断)。
|
|
72
|
+
*/
|
|
73
|
+
async resolvePeer(scene, id) {
|
|
74
|
+
if (scene === "group") return {
|
|
75
|
+
chatType: ChatType.GROUP,
|
|
76
|
+
peerUid: String(id)
|
|
77
|
+
};
|
|
78
|
+
const uin = String(id);
|
|
79
|
+
let uid;
|
|
80
|
+
try {
|
|
81
|
+
uid = (await this.uinToUid([uin])).get(uin);
|
|
82
|
+
} catch {}
|
|
83
|
+
return {
|
|
84
|
+
chatType: scene === "friend" ? ChatType.C2C : ChatType.C2C_TEMP,
|
|
85
|
+
peerUid: uid ?? uin
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* seq → msgId(索引优先,未登记扫描近段历史兜底)。
|
|
90
|
+
* 两层都未命中返回 undefined(调用方抛 NOT_FOUND)。
|
|
91
|
+
*/
|
|
92
|
+
async resolveMsgIdBySeq(peer, seq, chatType) {
|
|
93
|
+
const indexed = this.seqIndex.resolveMsgId(chatType, peer.peerUid, seq);
|
|
94
|
+
if (indexed !== void 0) return indexed;
|
|
95
|
+
const hit = (await this.msgApi.fetchMessages(peer, { count: 60 }).catch(() => [])).find((m) => Number(m.msgSeq) === seq);
|
|
96
|
+
if (hit === void 0) return;
|
|
97
|
+
this.seqIndex.record(hit);
|
|
98
|
+
return hit.msgId;
|
|
99
|
+
}
|
|
100
|
+
/** 发送结果回查:msgId → {message_seq, time}(sendMessage 只回 msgId)。 */
|
|
101
|
+
async resolveSentOutput(peer, msgId) {
|
|
102
|
+
const [sent] = await this.msgApi.fetchMsgsByMsgId(peer, [msgId]);
|
|
103
|
+
if (sent === void 0) throw kernelError("发送结果回查失败(msgId 未命中)", "UNKNOWN");
|
|
104
|
+
this.seqIndex.record(sent);
|
|
105
|
+
return {
|
|
106
|
+
message_seq: Number(sent.msgSeq),
|
|
107
|
+
time: Math.floor(Number(sent.msgTime) / 1e3)
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
/** 通知查找默认搜索数量(单屏全量,OB11 DEFAULT_COUNT 同值)。 */
|
|
112
|
+
const NOTIFY_SEARCH_COUNT$1 = 100;
|
|
113
|
+
/**
|
|
114
|
+
* 按通知 seq 查找群通知(先非可疑后可疑,OB11 set_group_add_request 同路径)。
|
|
115
|
+
* 未找到抛 NOT_FOUND(应答动作的 seq 无效或已过期)。
|
|
116
|
+
* 模块级函数(非类成员):应答动作的公共入口,OB11 同路径。
|
|
117
|
+
*/
|
|
118
|
+
async function findGroupNotify(api, seq) {
|
|
119
|
+
const hit = (await api.groupNotifyApi.getSingleScreenNotifies(false, NOTIFY_SEARCH_COUNT$1)).find((item) => item.seq === seq);
|
|
120
|
+
if (hit !== void 0) return hit;
|
|
121
|
+
const doubtHit = (await api.groupNotifyApi.getSingleScreenNotifies(true, NOTIFY_SEARCH_COUNT$1)).find((item) => item.seq === seq);
|
|
122
|
+
if (doubtHit !== void 0) return doubtHit;
|
|
123
|
+
throw kernelError(`未找到通知 ${seq}(无效或已过期)`, "NOT_FOUND");
|
|
124
|
+
}
|
|
125
|
+
/** 按发起者 uid 查找好友申请(getBuddyReqList 匹配 friendUid);未找到抛 NOT_FOUND。 */
|
|
126
|
+
async function findBuddyReq(api, initiatorUid) {
|
|
127
|
+
const hit = (await api.friendApi.getBuddyReqList()).find((req) => req.friendUid === initiatorUid);
|
|
128
|
+
if (hit === void 0) throw kernelError(`未找到来自 ${initiatorUid} 的好友申请`, "NOT_FOUND");
|
|
129
|
+
return hit;
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/milky/helper/entities.ts
|
|
133
|
+
/** GroupDetailInfo / Group → GroupEntity(detail 缺失时全兜底)。 */
|
|
134
|
+
function toGroupEntity(detail, groupCode) {
|
|
135
|
+
return {
|
|
136
|
+
group_id: Number(groupCode),
|
|
137
|
+
group_name: detail?.groupName ?? "",
|
|
138
|
+
member_count: detail?.memberNum ?? 0,
|
|
139
|
+
max_member_count: detail?.maxMemberNum ?? 0,
|
|
140
|
+
remark: detail?.remarkName ?? "",
|
|
141
|
+
created_time: detail?.groupCreateTime ?? 0,
|
|
142
|
+
description: "",
|
|
143
|
+
question: "",
|
|
144
|
+
announcement: ""
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** 群列表项(GroupCache Group)→ GroupEntity。 */
|
|
148
|
+
function groupListToEntity(group) {
|
|
149
|
+
return {
|
|
150
|
+
group_id: Number(group.groupCode),
|
|
151
|
+
group_name: group.groupName,
|
|
152
|
+
member_count: group.memberCount,
|
|
153
|
+
max_member_count: group.maxMember,
|
|
154
|
+
remark: "",
|
|
155
|
+
created_time: 0,
|
|
156
|
+
description: "",
|
|
157
|
+
question: "",
|
|
158
|
+
announcement: ""
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** GroupMember → GroupMemberEntity(member 缺失时按 uin 兜底最小实体)。 */
|
|
162
|
+
function toGroupMemberEntity(member, groupCode, uin) {
|
|
163
|
+
const shutUpSeconds = member?.shutUpTime ?? 0;
|
|
164
|
+
return {
|
|
165
|
+
user_id: Number(member?.uin !== void 0 && member.uin !== "" ? member.uin : uin),
|
|
166
|
+
nickname: member?.nick ?? "",
|
|
167
|
+
sex: "unknown",
|
|
168
|
+
group_id: Number(groupCode),
|
|
169
|
+
card: member?.cardName ?? "",
|
|
170
|
+
title: member?.memberSpecialTitle ?? "",
|
|
171
|
+
level: 0,
|
|
172
|
+
role: GroupApi.roleToString(member?.role ?? 2),
|
|
173
|
+
join_time: Number(member?.joinTime) || 0,
|
|
174
|
+
last_sent_time: Number(member?.lastSpeakTime) || 0,
|
|
175
|
+
shut_up_end_time: shutUpSeconds > 0 ? Math.floor(Date.now() / 1e3) + shutUpSeconds : null
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** Friend → FriendEntity(kernel 无 qid/分类挂载,按最小实体兜底)。 */
|
|
179
|
+
function toFriendEntity(friend, uin, nick) {
|
|
180
|
+
return {
|
|
181
|
+
user_id: Number(friend?.uin !== void 0 && friend.uin !== "" ? friend.uin : uin),
|
|
182
|
+
nickname: friend?.nickname ?? nick,
|
|
183
|
+
sex: "unknown",
|
|
184
|
+
qid: "",
|
|
185
|
+
remark: friend?.remark ?? "",
|
|
186
|
+
category: {
|
|
187
|
+
category_id: 0,
|
|
188
|
+
category_name: ""
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/milky/helper/request.ts
|
|
194
|
+
/** uid → uin(core 共享解析;ctx 携带映射)。 */
|
|
195
|
+
function toUin$1(uid, ctx) {
|
|
196
|
+
return resolveUin(uid, ctx.uidToUin);
|
|
197
|
+
}
|
|
198
|
+
/** BuddyReq → friend_request 事件(comment 取 words 字段,形状待校准,缺省空串)。 */
|
|
199
|
+
function toFriendRequestEvent(req, ctx) {
|
|
200
|
+
const words = req["words"];
|
|
201
|
+
return {
|
|
202
|
+
time: Math.floor(Date.now() / MS_TO_SEC$2),
|
|
203
|
+
self_id: Number(ctx.selfUin),
|
|
204
|
+
event_type: "friend_request",
|
|
205
|
+
data: {
|
|
206
|
+
initiator_id: toUin$1(req.friendUid, ctx),
|
|
207
|
+
initiator_uid: req.friendUid,
|
|
208
|
+
comment: typeof words === "string" ? words : "",
|
|
209
|
+
via: req.sourceName ?? ""
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** GroupNotify → Milky 群请求事件(非未处理状态 / 非请求类返回 null)。 */
|
|
214
|
+
function toGroupRequestEvent(notify, doubt, ctx) {
|
|
215
|
+
if (notify.status !== GroupNotifyMsgStatus.KUNHANDLE) return null;
|
|
216
|
+
const groupId = Number(notify.group?.groupCode ?? 0);
|
|
217
|
+
const seq = Number(notify.seq);
|
|
218
|
+
const time = Math.floor(Date.now() / MS_TO_SEC$2);
|
|
219
|
+
const selfId = Number(ctx.selfUin);
|
|
220
|
+
switch (notify.type) {
|
|
221
|
+
case GroupNotifyMsgType.REQUEST_JOIN_NEED_ADMINI_STRATOR_PASS: return {
|
|
222
|
+
time,
|
|
223
|
+
self_id: selfId,
|
|
224
|
+
event_type: "group_join_request",
|
|
225
|
+
data: {
|
|
226
|
+
group_id: groupId,
|
|
227
|
+
notification_seq: seq,
|
|
228
|
+
is_filtered: doubt,
|
|
229
|
+
initiator_id: toUin$1(notify.user1?.uid, ctx),
|
|
230
|
+
comment: notify.postscript ?? ""
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
case GroupNotifyMsgType.INVITED_NEED_ADMINI_STRATOR_PASS: return {
|
|
234
|
+
time,
|
|
235
|
+
self_id: selfId,
|
|
236
|
+
event_type: "group_invited_join_request",
|
|
237
|
+
data: {
|
|
238
|
+
group_id: groupId,
|
|
239
|
+
notification_seq: seq,
|
|
240
|
+
initiator_id: toUin$1(notify.user1?.uid, ctx),
|
|
241
|
+
target_user_id: toUin$1(notify.user2?.uid, ctx)
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
case GroupNotifyMsgType.INVITED_BY_MEMBER: return {
|
|
245
|
+
time,
|
|
246
|
+
self_id: selfId,
|
|
247
|
+
event_type: "group_invitation",
|
|
248
|
+
data: {
|
|
249
|
+
group_id: groupId,
|
|
250
|
+
invitation_seq: seq,
|
|
251
|
+
initiator_id: toUin$1(notify.user1?.uid, ctx),
|
|
252
|
+
source_group_id: null
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
default: return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* 群通知涉及的 uid 集合(去重;get_group_notifications 与事件广播共用)。
|
|
260
|
+
* kernel GroupEventChannel 的 onGroupNotifiesUpdated 回调参数已类型化
|
|
261
|
+
* (GroupNotify[]),无需防御收窄。
|
|
262
|
+
*/
|
|
263
|
+
function collectNotifyUids(notifies) {
|
|
264
|
+
const uids = /* @__PURE__ */ new Set();
|
|
265
|
+
for (const n of notifies) for (const uid of [n.user1?.uid, n.user2?.uid]) if (uid !== void 0 && uid !== "") uids.add(uid);
|
|
266
|
+
return [...uids];
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region src/milky/action/error-map.ts
|
|
270
|
+
const milkyErrorCodeMap = {
|
|
271
|
+
SEND_FAILED: -500,
|
|
272
|
+
PERMISSION_DENIED: -405,
|
|
273
|
+
NOT_FOUND: -404,
|
|
274
|
+
TIMEOUT: -504,
|
|
275
|
+
NOT_LOGIN: -403,
|
|
276
|
+
INVALID_PARAM: -400,
|
|
277
|
+
INVALID_STATE: -409,
|
|
278
|
+
UNKNOWN: -500
|
|
279
|
+
};
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/milky/action/base-action.ts
|
|
282
|
+
/**
|
|
283
|
+
* MilkyAction:Milky 动作基类(zod 校验 + KernelError → 负数 retcode)
|
|
284
|
+
*
|
|
285
|
+
* 与 OB11 BaseAction 分离的原因:Milky 响应信封无 echo、参数解析失败恒
|
|
286
|
+
* retcode=-400(规范),不复用 OB11 的 400/1400 语义。
|
|
287
|
+
* 响应信封由 transport 组装(status/retcode/data/message)。
|
|
288
|
+
*/
|
|
289
|
+
/** zod 错误摘要(单行,issues 取前 3 条)。 */
|
|
290
|
+
function zodSummary(error) {
|
|
291
|
+
const parts = [];
|
|
292
|
+
for (const issue of error.issues.slice(0, 3)) parts.push(`${issue.path.map(String).join(".")}: ${issue.message}`);
|
|
293
|
+
return parts.join("; ");
|
|
294
|
+
}
|
|
295
|
+
var MilkyAction = class {
|
|
296
|
+
/** 校验并执行(HTTP /api 入口;WS /event 不承载动作)。 */
|
|
297
|
+
async handle(payload) {
|
|
298
|
+
const parsed = this.schema.safeParse(payload);
|
|
299
|
+
if (!parsed.success) return {
|
|
300
|
+
status: "failed",
|
|
301
|
+
retcode: -400,
|
|
302
|
+
data: null,
|
|
303
|
+
message: `参数解析失败: ${zodSummary(parsed.error)}`
|
|
304
|
+
};
|
|
305
|
+
try {
|
|
306
|
+
return {
|
|
307
|
+
status: "ok",
|
|
308
|
+
retcode: 0,
|
|
309
|
+
data: await this._handle(parsed.data),
|
|
310
|
+
message: ""
|
|
311
|
+
};
|
|
312
|
+
} catch (err) {
|
|
313
|
+
if (isKernelError(err)) return {
|
|
314
|
+
status: "failed",
|
|
315
|
+
retcode: this.errorCodeMap[err.code] ?? this.errorCodeMap.UNKNOWN,
|
|
316
|
+
data: null,
|
|
317
|
+
message: err.message
|
|
318
|
+
};
|
|
319
|
+
return {
|
|
320
|
+
status: "failed",
|
|
321
|
+
retcode: this.errorCodeMap.UNKNOWN,
|
|
322
|
+
data: null,
|
|
323
|
+
message: err instanceof Error ? err.message : "未知错误"
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
/**
|
|
329
|
+
* 持 MilkyApi 的动作基类:api 注入 + 统一错误映射表(milkyErrorCodeMap)收拢,
|
|
330
|
+
* 动作子类只写 name / schema / _handle。
|
|
331
|
+
*/
|
|
332
|
+
var MilkyApiAction = class extends MilkyAction {
|
|
333
|
+
errorCodeMap = milkyErrorCodeMap;
|
|
334
|
+
api;
|
|
335
|
+
constructor(api) {
|
|
336
|
+
super();
|
|
337
|
+
this.api = api;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
//#endregion
|
|
341
|
+
//#region src/milky/action/group.ts
|
|
342
|
+
const groupIdField$1 = { group_id: z.number().int().min(10001) };
|
|
343
|
+
const userIdField = { user_id: z.number().int().min(10001) };
|
|
344
|
+
const optBool = z.boolean().nullable().optional();
|
|
345
|
+
const optStr$1 = z.string().nullable().optional();
|
|
346
|
+
/** get_group_info:获取群信息(GroupCache 详情缓存优先)。 */
|
|
347
|
+
var GetGroupInfoAction = class extends MilkyApiAction {
|
|
348
|
+
name = "get_group_info";
|
|
349
|
+
schema = z.object({
|
|
350
|
+
...groupIdField$1,
|
|
351
|
+
no_cache: optBool
|
|
352
|
+
});
|
|
353
|
+
async _handle(payload) {
|
|
354
|
+
const groupCode = String(payload.group_id);
|
|
355
|
+
return { group: toGroupEntity(this.api.groupCache !== void 0 ? await this.api.groupCache.getGroupDetail(groupCode) : await this.api.groupApi.getGroupInfo(groupCode), groupCode) };
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
/** get_group_member_list:获取群成员列表(GroupCache 惰性回填)。 */
|
|
359
|
+
var GetGroupMemberListAction = class extends MilkyApiAction {
|
|
360
|
+
name = "get_group_member_list";
|
|
361
|
+
schema = z.object({
|
|
362
|
+
...groupIdField$1,
|
|
363
|
+
no_cache: optBool
|
|
364
|
+
});
|
|
365
|
+
async _handle(payload) {
|
|
366
|
+
return { members: (await membersOf(this.api, String(payload.group_id))).map((m) => toGroupMemberEntity(m, String(payload.group_id), m.uin)) };
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
/** get_group_member_info:获取群成员信息(user_id = uin → 成员单查)。 */
|
|
370
|
+
var GetGroupMemberInfoAction = class extends MilkyApiAction {
|
|
371
|
+
name = "get_group_member_info";
|
|
372
|
+
schema = z.object({
|
|
373
|
+
...groupIdField$1,
|
|
374
|
+
...userIdField,
|
|
375
|
+
no_cache: optBool
|
|
376
|
+
});
|
|
377
|
+
async _handle(payload) {
|
|
378
|
+
return { member: toGroupMemberEntity(await memberOf(this.api, payload.group_id, payload.user_id), String(payload.group_id), String(payload.user_id)) };
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
/** set_group_name:设置群名。 */
|
|
382
|
+
var SetGroupNameAction = class extends MilkyApiAction {
|
|
383
|
+
name = "set_group_name";
|
|
384
|
+
schema = z.object({
|
|
385
|
+
...groupIdField$1,
|
|
386
|
+
new_group_name: z.string()
|
|
387
|
+
});
|
|
388
|
+
async _handle(payload) {
|
|
389
|
+
await this.api.groupApi.modifyGroupName(String(payload.group_id), payload.new_group_name);
|
|
390
|
+
return {};
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
/** set_group_member_card:设置群名片。 */
|
|
394
|
+
var SetGroupMemberCardAction = class extends MilkyApiAction {
|
|
395
|
+
name = "set_group_member_card";
|
|
396
|
+
schema = z.object({
|
|
397
|
+
...groupIdField$1,
|
|
398
|
+
...userIdField,
|
|
399
|
+
card: z.string()
|
|
400
|
+
});
|
|
401
|
+
async _handle(payload) {
|
|
402
|
+
const uid = await this.api.resolveUidFromUin(payload.user_id);
|
|
403
|
+
this.api.groupApi.setMemberCardName(String(payload.group_id), uid, payload.card);
|
|
404
|
+
return {};
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
/** set_group_member_admin:设置/取消管理员(is_set 缺省 true)。 */
|
|
408
|
+
var SetGroupMemberAdminAction = class extends MilkyApiAction {
|
|
409
|
+
name = "set_group_member_admin";
|
|
410
|
+
schema = z.object({
|
|
411
|
+
...groupIdField$1,
|
|
412
|
+
...userIdField,
|
|
413
|
+
is_set: optBool
|
|
414
|
+
});
|
|
415
|
+
async _handle(payload) {
|
|
416
|
+
const uid = await this.api.resolveUidFromUin(payload.user_id);
|
|
417
|
+
this.api.groupApi.setMemberRole(String(payload.group_id), uid, payload.is_set === false ? NTGroupMemberRoleMember : NTGroupMemberRoleAdmin);
|
|
418
|
+
return {};
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
/** set_group_member_mute:禁言成员(duration 秒,0 解除)。 */
|
|
422
|
+
var SetGroupMemberMuteAction = class extends MilkyApiAction {
|
|
423
|
+
name = "set_group_member_mute";
|
|
424
|
+
schema = z.object({
|
|
425
|
+
...groupIdField$1,
|
|
426
|
+
...userIdField,
|
|
427
|
+
duration: z.number().int().min(0).nullable().optional()
|
|
428
|
+
});
|
|
429
|
+
async _handle(payload) {
|
|
430
|
+
const uid = await this.api.resolveUidFromUin(payload.user_id);
|
|
431
|
+
await this.api.groupApi.setMemberShutUp(String(payload.group_id), [{
|
|
432
|
+
uid,
|
|
433
|
+
duration: payload.duration ?? 0
|
|
434
|
+
}]);
|
|
435
|
+
return {};
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
/** set_group_whole_mute:全员禁言开关(is_mute 缺省 true)。 */
|
|
439
|
+
var SetGroupWholeMuteAction = class extends MilkyApiAction {
|
|
440
|
+
name = "set_group_whole_mute";
|
|
441
|
+
schema = z.object({
|
|
442
|
+
...groupIdField$1,
|
|
443
|
+
is_mute: optBool
|
|
444
|
+
});
|
|
445
|
+
async _handle(payload) {
|
|
446
|
+
await this.api.groupApi.setGroupShutUp(String(payload.group_id), payload.is_mute !== false);
|
|
447
|
+
return {};
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
/** kick_group_member:移除成员(reject_add_request 默认 false)。 */
|
|
451
|
+
var KickGroupMemberAction = class extends MilkyApiAction {
|
|
452
|
+
name = "kick_group_member";
|
|
453
|
+
schema = z.object({
|
|
454
|
+
...groupIdField$1,
|
|
455
|
+
...userIdField,
|
|
456
|
+
reject_add_request: optBool
|
|
457
|
+
});
|
|
458
|
+
async _handle(payload) {
|
|
459
|
+
const uid = await this.api.resolveUidFromUin(payload.user_id);
|
|
460
|
+
await this.api.groupApi.kickMember(String(payload.group_id), [uid], payload.reject_add_request === true);
|
|
461
|
+
return {};
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
/** quit_group:退出群聊。 */
|
|
465
|
+
var QuitGroupAction = class extends MilkyApiAction {
|
|
466
|
+
name = "quit_group";
|
|
467
|
+
schema = z.object({ ...groupIdField$1 });
|
|
468
|
+
async _handle(payload) {
|
|
469
|
+
await this.api.groupApi.quitGroup(String(payload.group_id));
|
|
470
|
+
return {};
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* get_group_essence_messages:获取精华消息列表。
|
|
475
|
+
* WebApi.getEssenceMsgList 全量拉取后按 page_index/page_size 客户端分页
|
|
476
|
+
* (kernel 无分页参数);message_time 无源取 operation_time 兜底。
|
|
477
|
+
*/
|
|
478
|
+
var GetGroupEssenceMessagesAction = class extends MilkyApiAction {
|
|
479
|
+
name = "get_group_essence_messages";
|
|
480
|
+
schema = z.object({
|
|
481
|
+
...groupIdField$1,
|
|
482
|
+
page_index: z.number().int().min(0),
|
|
483
|
+
page_size: z.number().int().min(0)
|
|
484
|
+
});
|
|
485
|
+
async _handle(payload) {
|
|
486
|
+
const items = await this.api.webApi.getEssenceMsgList(String(payload.group_id));
|
|
487
|
+
const start = payload.page_index * payload.page_size;
|
|
488
|
+
const page = items.slice(start, start + payload.page_size);
|
|
489
|
+
return {
|
|
490
|
+
messages: page.map((item) => essenceToEntity(item, payload.group_id)),
|
|
491
|
+
is_end: start + page.length >= items.length
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
/** set_group_essence_message:设置/取消精华(seq → msgId 反查)。 */
|
|
496
|
+
var SetGroupEssenceMessageAction = class extends MilkyApiAction {
|
|
497
|
+
name = "set_group_essence_message";
|
|
498
|
+
schema = z.object({
|
|
499
|
+
...groupIdField$1,
|
|
500
|
+
message_seq: z.number().int().min(0),
|
|
501
|
+
is_set: optBool
|
|
502
|
+
});
|
|
503
|
+
async _handle(payload) {
|
|
504
|
+
const groupCode = String(payload.group_id);
|
|
505
|
+
const peer = {
|
|
506
|
+
chatType: ChatType.GROUP,
|
|
507
|
+
peerUid: groupCode
|
|
508
|
+
};
|
|
509
|
+
const msgId = await this.api.resolveMsgIdBySeq(peer, payload.message_seq, ChatType.GROUP);
|
|
510
|
+
if (msgId === void 0) throw kernelError(`消息 ${payload.message_seq} 不存在`, "NOT_FOUND");
|
|
511
|
+
if (payload.is_set === false) await this.api.groupApi.removeGroupEssence(groupCode, msgId);
|
|
512
|
+
else await this.api.groupApi.addGroupEssence(groupCode, msgId);
|
|
513
|
+
return {};
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
/** send_group_message_reaction:消息表情表态(reaction_type face→"1" / emoji→"2")。 */
|
|
517
|
+
var SendGroupMessageReactionAction = class extends MilkyApiAction {
|
|
518
|
+
name = "send_group_message_reaction";
|
|
519
|
+
schema = z.object({
|
|
520
|
+
...groupIdField$1,
|
|
521
|
+
message_seq: z.number().int().min(0),
|
|
522
|
+
reaction: z.string().min(1),
|
|
523
|
+
reaction_type: z.enum(["face", "emoji"]).nullable().optional(),
|
|
524
|
+
is_add: z.boolean().nullable().optional()
|
|
525
|
+
});
|
|
526
|
+
async _handle(payload) {
|
|
527
|
+
const peer = {
|
|
528
|
+
chatType: ChatType.GROUP,
|
|
529
|
+
peerUid: String(payload.group_id)
|
|
530
|
+
};
|
|
531
|
+
await this.api.msgApi.setMsgEmojiLike(peer, {
|
|
532
|
+
msgSeq: String(payload.message_seq),
|
|
533
|
+
emojiId: payload.reaction,
|
|
534
|
+
emojiType: payload.reaction_type === "emoji" ? "2" : "1",
|
|
535
|
+
like: payload.is_add !== false
|
|
536
|
+
});
|
|
537
|
+
return {};
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
/**
|
|
541
|
+
* get_group_notifications:获取群通知列表。
|
|
542
|
+
* kernel getSingleScreenNotifies 为单屏快照(无翻页)——start_notification_seq
|
|
543
|
+
* 作客户端过滤(取 seq 更小的记录);next = 本页最小 seq(仍有余量时)。
|
|
544
|
+
*/
|
|
545
|
+
var GetGroupNotificationsAction = class extends MilkyApiAction {
|
|
546
|
+
name = "get_group_notifications";
|
|
547
|
+
schema = z.object({
|
|
548
|
+
start_notification_seq: z.number().int().min(0).nullable().optional(),
|
|
549
|
+
is_filtered: optBool,
|
|
550
|
+
limit: z.number().int().min(0).nullable().optional()
|
|
551
|
+
});
|
|
552
|
+
async _handle(payload) {
|
|
553
|
+
const notifies = await this.api.groupNotifyApi.getSingleScreenNotifies(payload.is_filtered === true, NOTIFY_SEARCH_COUNT);
|
|
554
|
+
const start = payload.start_notification_seq;
|
|
555
|
+
const filtered = notifies.filter((n) => start === void 0 || start === null || Number(n.seq) < start);
|
|
556
|
+
const uidToUin = await this.collectUidToUin(filtered);
|
|
557
|
+
const entities = filtered.map((n) => notifyToEntity(n, payload.is_filtered === true, uidToUin)).filter((e) => e !== null);
|
|
558
|
+
const limit = payload.limit ?? NOTIFICATIONS_DEFAULT_LIMIT;
|
|
559
|
+
const page = entities.slice(0, limit);
|
|
560
|
+
const minSeq = page.reduce((acc, e) => acc === null ? e.notification_seq : Math.min(acc, e.notification_seq), null);
|
|
561
|
+
return {
|
|
562
|
+
notifications: page,
|
|
563
|
+
next_notification_seq: entities.length > page.length && minSeq !== null ? minSeq : null
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/** 通知 user1/user2 uid → uin 批量转换(失败退空 Map,实体 id 兜底 0)。 */
|
|
567
|
+
async collectUidToUin(notifies) {
|
|
568
|
+
const uids = collectNotifyUids(notifies);
|
|
569
|
+
if (uids.length === 0) return /* @__PURE__ */ new Map();
|
|
570
|
+
return await this.api.uidToUin(uids).catch(() => /* @__PURE__ */ new Map());
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
/** accept/reject 群请求 / 群邀请公共基类:seq → 通知匹配 → handleGroupRequest。 */
|
|
574
|
+
var HandleGroupNotifyBase = class extends MilkyApiAction {
|
|
575
|
+
async respond(seq, doubt) {
|
|
576
|
+
const notify = await findGroupNotify(this.api, String(seq));
|
|
577
|
+
await this.api.groupNotifyApi.handleGroupRequest(doubt, notify, this.approve ? NTGroupRequestOperateTypes.KAGREE : NTGroupRequestOperateTypes.KREFUSE, " ");
|
|
578
|
+
return {};
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
/** accept_group_request:同意入群请求(notification_type/group_id 仅 schema 校验)。 */
|
|
582
|
+
var AcceptGroupRequestAction = class extends HandleGroupNotifyBase {
|
|
583
|
+
name = "accept_group_request";
|
|
584
|
+
schema = z.object({
|
|
585
|
+
notification_seq: z.number().int().min(0),
|
|
586
|
+
notification_type: z.enum(["join_request", "invited_join_request"]),
|
|
587
|
+
...groupIdField$1,
|
|
588
|
+
is_filtered: optBool
|
|
589
|
+
});
|
|
590
|
+
approve = true;
|
|
591
|
+
async _handle(payload) {
|
|
592
|
+
return await this.respond(payload.notification_seq, payload.is_filtered === true);
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
/** reject_group_request:拒绝入群请求(reason 无源,kernel postscript 占位空格)。 */
|
|
596
|
+
var RejectGroupRequestAction = class extends HandleGroupNotifyBase {
|
|
597
|
+
name = "reject_group_request";
|
|
598
|
+
schema = z.object({
|
|
599
|
+
notification_seq: z.number().int().min(0),
|
|
600
|
+
notification_type: z.enum(["join_request", "invited_join_request"]),
|
|
601
|
+
...groupIdField$1,
|
|
602
|
+
is_filtered: optBool,
|
|
603
|
+
reason: optStr$1
|
|
604
|
+
});
|
|
605
|
+
approve = false;
|
|
606
|
+
async _handle(payload) {
|
|
607
|
+
return await this.respond(payload.notification_seq, payload.is_filtered === true);
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
/** accept_group_invitation:接受入群邀请(按 invitation_seq 匹配)。 */
|
|
611
|
+
var AcceptGroupInvitationAction = class extends HandleGroupNotifyBase {
|
|
612
|
+
name = "accept_group_invitation";
|
|
613
|
+
schema = z.object({
|
|
614
|
+
...groupIdField$1,
|
|
615
|
+
invitation_seq: z.number().int().min(0)
|
|
616
|
+
});
|
|
617
|
+
approve = true;
|
|
618
|
+
async _handle(payload) {
|
|
619
|
+
return await this.respond(payload.invitation_seq, false);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
/** reject_group_invitation:拒绝入群邀请。 */
|
|
623
|
+
var RejectGroupInvitationAction = class extends HandleGroupNotifyBase {
|
|
624
|
+
name = "reject_group_invitation";
|
|
625
|
+
schema = z.object({
|
|
626
|
+
...groupIdField$1,
|
|
627
|
+
invitation_seq: z.number().int().min(0)
|
|
628
|
+
});
|
|
629
|
+
approve = false;
|
|
630
|
+
async _handle(payload) {
|
|
631
|
+
return await this.respond(payload.invitation_seq, false);
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
/** ---------- 内部辅助 ---------- */
|
|
635
|
+
/** 通知查找数量(单屏全量,OB11 DEFAULT_COUNT 同值)。 */
|
|
636
|
+
const NOTIFY_SEARCH_COUNT = 100;
|
|
637
|
+
/** get_group_notifications 默认 limit(规范 default 20)。 */
|
|
638
|
+
const NOTIFICATIONS_DEFAULT_LIMIT = 20;
|
|
639
|
+
/** NTGroupMemberRole 数值(ADMIN=3 / MEMBER=2,setMemberRole 入参)。 */
|
|
640
|
+
const NTGroupMemberRoleAdmin = 3;
|
|
641
|
+
const NTGroupMemberRoleMember = 2;
|
|
642
|
+
/** 成员列表(GroupCache 优先,未装配直查)。 */
|
|
643
|
+
async function membersOf(api, groupCode) {
|
|
644
|
+
if (api.groupCache !== void 0) return await api.groupCache.getMembers(groupCode);
|
|
645
|
+
return await api.groupApi.getGroupMemberList(groupCode, true);
|
|
646
|
+
}
|
|
647
|
+
/** 成员单查(user_id = uin;缓存路径经 uid,直查路径按 uin 匹配)。 */
|
|
648
|
+
async function memberOf(api, groupId, userId) {
|
|
649
|
+
const groupCode = String(groupId);
|
|
650
|
+
const uin = String(userId);
|
|
651
|
+
if (api.groupCache !== void 0) {
|
|
652
|
+
const uid = await api.resolveUidFromUin(userId).catch(() => uin);
|
|
653
|
+
const member = await api.groupCache.getMember(groupCode, uid);
|
|
654
|
+
if (member === void 0) throw kernelError(`成员 ${uin} 不在群 ${groupCode}`, "NOT_FOUND");
|
|
655
|
+
return member;
|
|
656
|
+
}
|
|
657
|
+
const hit = (await api.groupApi.getGroupMemberList(groupCode, true)).find((m) => m.uin === uin);
|
|
658
|
+
if (hit === void 0) throw kernelError(`成员 ${uin} 不在群 ${groupCode}`, "NOT_FOUND");
|
|
659
|
+
return hit;
|
|
660
|
+
}
|
|
661
|
+
/** 精华项 → GroupEssenceMessage(msg_content 近似翻译:text / image)。 */
|
|
662
|
+
function essenceToEntity(item, groupId) {
|
|
663
|
+
const segments = [];
|
|
664
|
+
for (const c of item.msg_content) if (c.text !== void 0 && c.text !== "") segments.push({
|
|
665
|
+
type: "text",
|
|
666
|
+
data: { text: c.text }
|
|
667
|
+
});
|
|
668
|
+
else if (c.image_url !== void 0 && c.image_url !== "") segments.push({
|
|
669
|
+
type: "image",
|
|
670
|
+
data: {
|
|
671
|
+
resource_id: c.image_url,
|
|
672
|
+
temp_url: c.image_url,
|
|
673
|
+
width: 0,
|
|
674
|
+
height: 0,
|
|
675
|
+
summary: "",
|
|
676
|
+
sub_type: "normal"
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
return {
|
|
680
|
+
group_id: groupId,
|
|
681
|
+
message_seq: item.msg_seq,
|
|
682
|
+
message_time: item.add_digest_time,
|
|
683
|
+
sender_id: Number(item.sender_uin) || 0,
|
|
684
|
+
sender_name: item.sender_nick,
|
|
685
|
+
operator_id: Number(item.add_digest_uin) || 0,
|
|
686
|
+
operator_name: item.add_digest_nick,
|
|
687
|
+
operation_time: item.add_digest_time,
|
|
688
|
+
segments
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* GroupNotify → GroupNotificationEntity(uidToUin 已由调用方批量转换)。
|
|
693
|
+
* 类型→构造器表:7=入群申请 / 5=成员邀请他人 / 8=设管理 / 12,13=取消管理 /
|
|
694
|
+
* 9,10=踢出 / 11=退群;1=邀请自身无实体变体(事件层表达),其余 null。
|
|
695
|
+
*/
|
|
696
|
+
const NOTIFY_ENTITY_BUILDERS = {
|
|
697
|
+
7: (n, uinOf) => ({
|
|
698
|
+
type: "join_request",
|
|
699
|
+
group_id: Number(n.group?.groupCode ?? 0),
|
|
700
|
+
notification_seq: Number(n.seq),
|
|
701
|
+
is_filtered: false,
|
|
702
|
+
initiator_id: uinOf(n.user1?.uid),
|
|
703
|
+
state: "pending",
|
|
704
|
+
operator_id: null,
|
|
705
|
+
comment: n.postscript ?? ""
|
|
706
|
+
}),
|
|
707
|
+
5: (n, uinOf) => ({
|
|
708
|
+
type: "invited_join_request",
|
|
709
|
+
group_id: Number(n.group?.groupCode ?? 0),
|
|
710
|
+
notification_seq: Number(n.seq),
|
|
711
|
+
initiator_id: uinOf(n.user1?.uid),
|
|
712
|
+
target_user_id: uinOf(n.user2?.uid),
|
|
713
|
+
state: "pending",
|
|
714
|
+
operator_id: null
|
|
715
|
+
}),
|
|
716
|
+
8: (n, uinOf) => adminChangeEntity(n, uinOf, true),
|
|
717
|
+
12: (n, uinOf) => adminChangeEntity(n, uinOf, false),
|
|
718
|
+
13: (n, uinOf) => adminChangeEntity(n, uinOf, false),
|
|
719
|
+
9: (n, uinOf) => kickEntity(n, uinOf),
|
|
720
|
+
10: (n, uinOf) => kickEntity(n, uinOf),
|
|
721
|
+
11: (n, uinOf) => ({
|
|
722
|
+
type: "quit",
|
|
723
|
+
group_id: Number(n.group?.groupCode ?? 0),
|
|
724
|
+
notification_seq: Number(n.seq),
|
|
725
|
+
target_user_id: uinOf(n.user1?.uid)
|
|
726
|
+
})
|
|
727
|
+
};
|
|
728
|
+
/** admin_change 实体(8/12/13 共用)。 */
|
|
729
|
+
function adminChangeEntity(n, uinOf, isSet) {
|
|
730
|
+
return {
|
|
731
|
+
type: "admin_change",
|
|
732
|
+
group_id: Number(n.group?.groupCode ?? 0),
|
|
733
|
+
notification_seq: Number(n.seq),
|
|
734
|
+
target_user_id: uinOf(n.user1?.uid),
|
|
735
|
+
is_set: isSet,
|
|
736
|
+
operator_id: uinOf(n.user2?.uid)
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
/** kick 实体(9/10 共用)。 */
|
|
740
|
+
function kickEntity(n, uinOf) {
|
|
741
|
+
return {
|
|
742
|
+
type: "kick",
|
|
743
|
+
group_id: Number(n.group?.groupCode ?? 0),
|
|
744
|
+
notification_seq: Number(n.seq),
|
|
745
|
+
target_user_id: uinOf(n.user1?.uid),
|
|
746
|
+
operator_id: uinOf(n.user2?.uid)
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
/** GroupNotify → 实体(isFiltered 透传给 join_request;未识别类型 null)。 */
|
|
750
|
+
function notifyToEntity(notify, isFiltered, uidToUin) {
|
|
751
|
+
const uinOf = (uid) => uid === void 0 || uid === "" ? 0 : Number(uidToUin.get(uid) ?? 0) || 0;
|
|
752
|
+
const builder = NOTIFY_ENTITY_BUILDERS[notify.type];
|
|
753
|
+
if (builder === void 0) return null;
|
|
754
|
+
const entity = builder(notify, uinOf);
|
|
755
|
+
if (entity.type === "join_request") entity.is_filtered = isFiltered;
|
|
756
|
+
return entity;
|
|
757
|
+
}
|
|
758
|
+
//#endregion
|
|
759
|
+
//#region src/milky/action/group-file.ts
|
|
760
|
+
const groupIdField = { group_id: z.number().int().min(10001) };
|
|
761
|
+
const fileIdField = { file_id: z.string().min(1) };
|
|
762
|
+
const optStr = z.string().nullable().optional();
|
|
763
|
+
/** get_group_files:获取群文件列表(根目录或指定文件夹;过滤 parent 匹配项)。 */
|
|
764
|
+
var GetGroupFilesAction = class extends MilkyApiAction {
|
|
765
|
+
name = "get_group_files";
|
|
766
|
+
schema = z.object({
|
|
767
|
+
...groupIdField,
|
|
768
|
+
parent_folder_id: optStr
|
|
769
|
+
});
|
|
770
|
+
async _handle(payload) {
|
|
771
|
+
const groupCode = String(payload.group_id);
|
|
772
|
+
const parent = payload.parent_folder_id ?? "";
|
|
773
|
+
const items = await this.api.richMediaApi.getGroupFileList(groupCode, parent);
|
|
774
|
+
const files = [];
|
|
775
|
+
const folders = [];
|
|
776
|
+
for (const item of items) if (item.fileInfo !== void 0) files.push(fileToEntity(item.fileInfo, payload.group_id));
|
|
777
|
+
else if (item.folderInfo !== void 0) folders.push(folderToEntity(item.folderInfo, payload.group_id));
|
|
778
|
+
return {
|
|
779
|
+
files,
|
|
780
|
+
folders
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
/** move_group_file:移动文件(target_folder_id 空 = 移到根目录;parent 入参仅 schema 兼容)。 */
|
|
785
|
+
var MoveGroupFileAction = class extends MilkyApiAction {
|
|
786
|
+
name = "move_group_file";
|
|
787
|
+
schema = z.object({
|
|
788
|
+
...groupIdField,
|
|
789
|
+
...fileIdField,
|
|
790
|
+
parent_folder_id: optStr,
|
|
791
|
+
target_folder_id: optStr
|
|
792
|
+
});
|
|
793
|
+
async _handle(payload) {
|
|
794
|
+
await this.api.richMediaApi.moveGroupFile(String(payload.group_id), [payload.file_id], payload.target_folder_id ?? "");
|
|
795
|
+
return {};
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
/** rename_group_file:重命名文件。 */
|
|
799
|
+
var RenameGroupFileAction = class extends MilkyApiAction {
|
|
800
|
+
name = "rename_group_file";
|
|
801
|
+
schema = z.object({
|
|
802
|
+
...groupIdField,
|
|
803
|
+
...fileIdField,
|
|
804
|
+
parent_folder_id: optStr,
|
|
805
|
+
new_file_name: z.string().min(1)
|
|
806
|
+
});
|
|
807
|
+
async _handle(payload) {
|
|
808
|
+
await this.api.richMediaApi.renameGroupFile(String(payload.group_id), payload.file_id, payload.new_file_name);
|
|
809
|
+
return {};
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
/** delete_group_file:删除文件。 */
|
|
813
|
+
var DeleteGroupFileAction = class extends MilkyApiAction {
|
|
814
|
+
name = "delete_group_file";
|
|
815
|
+
schema = z.object({
|
|
816
|
+
...groupIdField,
|
|
817
|
+
...fileIdField
|
|
818
|
+
});
|
|
819
|
+
async _handle(payload) {
|
|
820
|
+
await this.api.richMediaApi.deleteGroupFile(String(payload.group_id), [payload.file_id]);
|
|
821
|
+
return {};
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
/**
|
|
825
|
+
* persist_group_file:转存文件(RichMediaApi.transGroupFile,转存语义待对齐)。
|
|
826
|
+
*/
|
|
827
|
+
var PersistGroupFileAction = class extends MilkyApiAction {
|
|
828
|
+
name = "persist_group_file";
|
|
829
|
+
schema = z.object({
|
|
830
|
+
...groupIdField,
|
|
831
|
+
...fileIdField
|
|
832
|
+
});
|
|
833
|
+
async _handle(payload) {
|
|
834
|
+
await this.api.richMediaApi.transGroupFile(String(payload.group_id), payload.file_id);
|
|
835
|
+
return {};
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
/** create_group_folder:创建文件夹(返回 folder_id)。 */
|
|
839
|
+
var CreateGroupFolderAction = class extends MilkyApiAction {
|
|
840
|
+
name = "create_group_folder";
|
|
841
|
+
schema = z.object({
|
|
842
|
+
...groupIdField,
|
|
843
|
+
folder_name: z.string().min(1)
|
|
844
|
+
});
|
|
845
|
+
async _handle(payload) {
|
|
846
|
+
return { folder_id: await this.api.richMediaApi.createGroupFolder(String(payload.group_id), payload.folder_name) };
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
/** delete_group_folder:删除文件夹。 */
|
|
850
|
+
var DeleteGroupFolderAction = class extends MilkyApiAction {
|
|
851
|
+
name = "delete_group_folder";
|
|
852
|
+
schema = z.object({
|
|
853
|
+
...groupIdField,
|
|
854
|
+
folder_id: z.string().min(1)
|
|
855
|
+
});
|
|
856
|
+
async _handle(payload) {
|
|
857
|
+
await this.api.richMediaApi.deleteGroupFolder(String(payload.group_id), payload.folder_id);
|
|
858
|
+
return {};
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
/** GroupFileItemInfo → GroupFileEntity(fileSize 原生为字符串;无源字段兜底)。 */
|
|
862
|
+
function fileToEntity(info, groupId) {
|
|
863
|
+
return {
|
|
864
|
+
group_id: groupId,
|
|
865
|
+
file_id: info.fileId,
|
|
866
|
+
file_name: info.fileName,
|
|
867
|
+
parent_folder_id: info.parent ?? "",
|
|
868
|
+
file_size: Number(info.fileSize) || 0,
|
|
869
|
+
uploaded_time: Number(info.uploadTime) || 0,
|
|
870
|
+
expire_time: null,
|
|
871
|
+
uploader_id: Number(info.uploaderUin) || 0,
|
|
872
|
+
downloaded_times: 0
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
/** GroupFolderInfo → GroupFolderEntity(元数据无类型字段防御读取,缺省 0)。 */
|
|
876
|
+
function folderToEntity(info, groupId) {
|
|
877
|
+
const num = (key) => {
|
|
878
|
+
const v = info[key];
|
|
879
|
+
return typeof v === "number" ? v : Number(v) || 0;
|
|
880
|
+
};
|
|
881
|
+
return {
|
|
882
|
+
group_id: groupId,
|
|
883
|
+
folder_id: info.folderId,
|
|
884
|
+
parent_folder_id: info.folderUid ?? "",
|
|
885
|
+
folder_name: info.folderName,
|
|
886
|
+
created_time: num("createTime"),
|
|
887
|
+
last_modified_time: num("modifyTime"),
|
|
888
|
+
creator_id: 0,
|
|
889
|
+
file_count: num("totalFileCount")
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
//#endregion
|
|
893
|
+
//#region src/milky/helper/media-uri.ts
|
|
894
|
+
/**
|
|
895
|
+
* 发送媒体 URI 解析(Milky OutgoingSegment uri → 本地路径)
|
|
896
|
+
*
|
|
897
|
+
* 规范支持 file:// / http(s):// / base64:// 三种(image/record/video 共用):
|
|
898
|
+
* - file:// → 平台路径(segments.ts 的同步面已处理)
|
|
899
|
+
* - base64:// → 解码落盘 cacheDir/milky/,file-type 探测扩展名后改名
|
|
900
|
+
* - http(s):// → @napuketto/media downloadUrl 落盘 cacheDir/milky/
|
|
901
|
+
* 未配置 mediaDir 时远程 URI 报错(不发静默空消息)。
|
|
902
|
+
*/
|
|
903
|
+
/** 解析远程媒体 URI 为本地绝对路径(base64:// / http(s)://;file:// 走同步面)。 */
|
|
904
|
+
async function resolveMediaUri(uri, mediaDir) {
|
|
905
|
+
if (mediaDir === void 0 || mediaDir === "") throw kernelError("远程媒体发送需要配置 [accounts.milky] cacheDir(下载缓存目录)", "INVALID_PARAM");
|
|
906
|
+
if (uri.startsWith("base64://")) return await writeBase64(uri.slice(9), mediaDir);
|
|
907
|
+
const dest = join(mediaDir, `${randomUUID()}${inferExtension(null, uri)}`);
|
|
908
|
+
await downloadUrl(uri, dest);
|
|
909
|
+
return dest;
|
|
910
|
+
}
|
|
911
|
+
/** base64 载荷 → 落盘(先写 .bin,探测到扩展名再改名)。 */
|
|
912
|
+
async function writeBase64(payload, mediaDir) {
|
|
913
|
+
let bytes;
|
|
914
|
+
try {
|
|
915
|
+
bytes = Buffer.from(payload, "base64");
|
|
916
|
+
} catch {
|
|
917
|
+
throw kernelError("base64:// 载荷解码失败", "INVALID_PARAM");
|
|
918
|
+
}
|
|
919
|
+
if (bytes.length === 0) throw kernelError("base64:// 载荷为空", "INVALID_PARAM");
|
|
920
|
+
await mkdir(mediaDir, { recursive: true });
|
|
921
|
+
const raw = join(mediaDir, `${randomUUID()}.bin`);
|
|
922
|
+
await writeFile(raw, bytes);
|
|
923
|
+
try {
|
|
924
|
+
const { ext } = await detectFileType(raw);
|
|
925
|
+
const final = join(mediaDir, `${randomUUID()}${ext}`);
|
|
926
|
+
await rename(raw, final);
|
|
927
|
+
return final;
|
|
928
|
+
} catch {
|
|
929
|
+
return raw;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
//#endregion
|
|
933
|
+
//#region src/milky/helper/segments.ts
|
|
934
|
+
/**
|
|
935
|
+
* Milky 消息段 ↔ canonical 元素转换(纯函数,ADR-008)
|
|
936
|
+
*
|
|
937
|
+
* 接收方向:canonical → IncomingSegment(规范 14 种中映射 12 种;market_face /
|
|
938
|
+
* markdown 无 canonical 对应(MFACE/MARKDOWN 原始元素形状未探测),unknown 一律
|
|
939
|
+
* 跳过——规范兼容性要求「数组中未知项跳过」)。forward 接收段不经 canonical:
|
|
940
|
+
* kernel 无 MULTI_FORWARD 分支,adapter 从 RawMessage 提取 resId 后经
|
|
941
|
+
* incomingForwardSegment + ctx 注入。
|
|
942
|
+
* 发送方向:OutgoingSegment → canonical(10 种中映射 9 种;forward 发送需要
|
|
943
|
+
* kernel 组装任意内容的新转发(未探测),显式报错不静默跳过)。
|
|
944
|
+
*/
|
|
945
|
+
/** uin 字符串 → Milky user_id(非有限正数 = 转换失败)。 */
|
|
946
|
+
function toUserId(uin) {
|
|
947
|
+
const n = Number(uin);
|
|
948
|
+
return Number.isFinite(n) && n >= 10001 ? n : void 0;
|
|
949
|
+
}
|
|
950
|
+
/** canonical 元素 → IncomingSegment 列表(unknown / 转换失败段跳过)。 */
|
|
951
|
+
function canonicalToIncomingSegments(elements, ctx = {}) {
|
|
952
|
+
const out = [];
|
|
953
|
+
for (const el of elements) {
|
|
954
|
+
const seg = canonicalToIncomingSegment(el, ctx);
|
|
955
|
+
if (seg !== null) out.push(seg);
|
|
956
|
+
}
|
|
957
|
+
return out;
|
|
958
|
+
}
|
|
959
|
+
/** canonical image → image 段(resource_id = 本地路径优先,url 兜底)。 */
|
|
960
|
+
function imageToIncoming(el) {
|
|
961
|
+
return {
|
|
962
|
+
type: "image",
|
|
963
|
+
data: {
|
|
964
|
+
resource_id: el.path !== "" ? el.path : el.url ?? "",
|
|
965
|
+
temp_url: el.url ?? "",
|
|
966
|
+
width: el.size?.width ?? 0,
|
|
967
|
+
height: el.size?.height ?? 0,
|
|
968
|
+
summary: "",
|
|
969
|
+
sub_type: "normal"
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
/** canonical voice → record 段。 */
|
|
974
|
+
function voiceToIncoming(el) {
|
|
975
|
+
return {
|
|
976
|
+
type: "record",
|
|
977
|
+
data: {
|
|
978
|
+
resource_id: el.path !== "" ? el.path : el.url ?? "",
|
|
979
|
+
temp_url: el.url ?? "",
|
|
980
|
+
duration: el.durationMs !== void 0 ? Math.round(el.durationMs / 1e3) : 0
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
/** canonical video → video 段。 */
|
|
985
|
+
function videoToIncoming(el) {
|
|
986
|
+
return {
|
|
987
|
+
type: "video",
|
|
988
|
+
data: {
|
|
989
|
+
resource_id: el.path !== "" ? el.path : el.url ?? "",
|
|
990
|
+
temp_url: el.url ?? "",
|
|
991
|
+
width: el.width ?? 0,
|
|
992
|
+
height: el.height ?? 0,
|
|
993
|
+
duration: el.durationSec ?? 0
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
/** 按类型分发的段映射器表(键 = canonical type;缺项 = 跳过)。 */
|
|
998
|
+
const INCOMING_MAPPERS = {
|
|
999
|
+
text: (el) => ({
|
|
1000
|
+
type: "text",
|
|
1001
|
+
data: { text: el.text }
|
|
1002
|
+
}),
|
|
1003
|
+
at: atToIncoming,
|
|
1004
|
+
face: (el) => ({
|
|
1005
|
+
type: "face",
|
|
1006
|
+
data: {
|
|
1007
|
+
face_id: el.id,
|
|
1008
|
+
is_large: false
|
|
1009
|
+
}
|
|
1010
|
+
}),
|
|
1011
|
+
reply: replyToIncoming,
|
|
1012
|
+
image: imageToIncoming,
|
|
1013
|
+
voice: voiceToIncoming,
|
|
1014
|
+
video: videoToIncoming,
|
|
1015
|
+
file: fileToIncoming,
|
|
1016
|
+
json: (el) => ({
|
|
1017
|
+
type: "light_app",
|
|
1018
|
+
data: {
|
|
1019
|
+
app_name: parseAppName(el.raw),
|
|
1020
|
+
json_payload: rawToPayload(el.raw)
|
|
1021
|
+
}
|
|
1022
|
+
}),
|
|
1023
|
+
xml: (el) => ({
|
|
1024
|
+
type: "xml",
|
|
1025
|
+
data: {
|
|
1026
|
+
service_id: 0,
|
|
1027
|
+
xml_payload: rawToPayload(el.raw)
|
|
1028
|
+
}
|
|
1029
|
+
})
|
|
1030
|
+
};
|
|
1031
|
+
function canonicalToIncomingSegment(el, ctx) {
|
|
1032
|
+
const mapper = INCOMING_MAPPERS[el.type];
|
|
1033
|
+
return mapper === void 0 ? null : mapper(el, ctx);
|
|
1034
|
+
}
|
|
1035
|
+
/** canonical at → mention / mention_all(uid→uin 失败跳过该段)。 */
|
|
1036
|
+
function atToIncoming(el, ctx) {
|
|
1037
|
+
if (el.target === "all") return {
|
|
1038
|
+
type: "mention_all",
|
|
1039
|
+
data: {}
|
|
1040
|
+
};
|
|
1041
|
+
const userId = toUserId(ctx.uidToUin?.get(el.target) ?? el.target);
|
|
1042
|
+
if (userId === void 0) return null;
|
|
1043
|
+
return {
|
|
1044
|
+
type: "mention",
|
|
1045
|
+
data: {
|
|
1046
|
+
user_id: userId,
|
|
1047
|
+
name: el.display ?? ""
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
/** canonical reply → reply 段(引用元信息经 ctx 回填,未命中全 0 兜底)。 */
|
|
1052
|
+
function replyToIncoming(el, ctx) {
|
|
1053
|
+
const meta = ctx.replyMeta?.get(el.messageId);
|
|
1054
|
+
return {
|
|
1055
|
+
type: "reply",
|
|
1056
|
+
data: {
|
|
1057
|
+
message_seq: meta?.messageSeq ?? 0,
|
|
1058
|
+
sender_id: meta?.senderId ?? 0,
|
|
1059
|
+
sender_name: null,
|
|
1060
|
+
time: meta?.time ?? 0,
|
|
1061
|
+
segments: []
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
/** canonical file → file 段。 */
|
|
1066
|
+
function fileToIncoming(el) {
|
|
1067
|
+
return {
|
|
1068
|
+
type: "file",
|
|
1069
|
+
data: {
|
|
1070
|
+
file_id: el.path,
|
|
1071
|
+
file_name: el.name ?? "",
|
|
1072
|
+
file_size: el.size ?? 0
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* 合并转发 → forward 段(P1)。
|
|
1078
|
+
* 接收方向 canonical 不承载 MULTI_FORWARD(kernel toCanonicalElement 无此分支),
|
|
1079
|
+
* 由 adapter 从 RawMessage 提取 resId 后调用本函数经 ctx 注入(翻译保持纯函数)。
|
|
1080
|
+
* title 取 NT fileName(转发卡片标题语义)。
|
|
1081
|
+
*/
|
|
1082
|
+
function incomingForwardSegment(resId, title) {
|
|
1083
|
+
return {
|
|
1084
|
+
type: "forward",
|
|
1085
|
+
data: {
|
|
1086
|
+
forward_id: resId,
|
|
1087
|
+
title,
|
|
1088
|
+
preview: [],
|
|
1089
|
+
summary: ""
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
/** json 载荷 → 小程序名(spec app_name:JSON 顶层 app 字段,取不到空串)。 */
|
|
1094
|
+
function parseAppName(raw) {
|
|
1095
|
+
let obj = raw;
|
|
1096
|
+
if (typeof raw === "string") try {
|
|
1097
|
+
obj = JSON.parse(raw);
|
|
1098
|
+
} catch {
|
|
1099
|
+
return "";
|
|
1100
|
+
}
|
|
1101
|
+
if (typeof obj !== "object" || obj === null || !("app" in obj)) return "";
|
|
1102
|
+
const app = obj["app"];
|
|
1103
|
+
return typeof app === "string" ? app : "";
|
|
1104
|
+
}
|
|
1105
|
+
/** canonical json/xml raw → 字符串载荷(字符串原样,其余 JSON 序列化)。 */
|
|
1106
|
+
function rawToPayload(raw) {
|
|
1107
|
+
if (typeof raw === "string") return raw;
|
|
1108
|
+
try {
|
|
1109
|
+
return JSON.stringify(raw) ?? "";
|
|
1110
|
+
} catch {
|
|
1111
|
+
return "";
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* OutgoingSegment 数组 → canonical 元素(同步映射;媒体 URI 解析与 at uin→uid
|
|
1116
|
+
* 由调用方在返回值上继续处理)。
|
|
1117
|
+
* forward 显式报错(发送走 kernel 专用链路,P1 接)。
|
|
1118
|
+
*/
|
|
1119
|
+
function outgoingSegmentsToCanonical(segments) {
|
|
1120
|
+
const elements = [];
|
|
1121
|
+
const atUins = [];
|
|
1122
|
+
const replySeqs = [];
|
|
1123
|
+
for (const seg of segments) switch (seg.type) {
|
|
1124
|
+
case "text":
|
|
1125
|
+
elements.push({
|
|
1126
|
+
type: "text",
|
|
1127
|
+
text: seg.data.text
|
|
1128
|
+
});
|
|
1129
|
+
break;
|
|
1130
|
+
case "mention": {
|
|
1131
|
+
const uin = String(seg.data.user_id);
|
|
1132
|
+
atUins.push(uin);
|
|
1133
|
+
elements.push({
|
|
1134
|
+
type: "at",
|
|
1135
|
+
target: uin
|
|
1136
|
+
});
|
|
1137
|
+
break;
|
|
1138
|
+
}
|
|
1139
|
+
case "mention_all":
|
|
1140
|
+
elements.push({
|
|
1141
|
+
type: "at",
|
|
1142
|
+
target: "all"
|
|
1143
|
+
});
|
|
1144
|
+
break;
|
|
1145
|
+
case "face":
|
|
1146
|
+
elements.push({
|
|
1147
|
+
type: "face",
|
|
1148
|
+
id: seg.data.face_id
|
|
1149
|
+
});
|
|
1150
|
+
break;
|
|
1151
|
+
case "reply":
|
|
1152
|
+
replySeqs.push(seg.data.message_seq);
|
|
1153
|
+
elements.push({
|
|
1154
|
+
type: "reply",
|
|
1155
|
+
messageId: String(seg.data.message_seq)
|
|
1156
|
+
});
|
|
1157
|
+
break;
|
|
1158
|
+
case "image":
|
|
1159
|
+
elements.push({
|
|
1160
|
+
type: "image",
|
|
1161
|
+
path: resolveUriToPathPlaceholder(seg.data.uri)
|
|
1162
|
+
});
|
|
1163
|
+
break;
|
|
1164
|
+
case "record":
|
|
1165
|
+
elements.push({
|
|
1166
|
+
type: "voice",
|
|
1167
|
+
path: resolveUriToPathPlaceholder(seg.data.uri)
|
|
1168
|
+
});
|
|
1169
|
+
break;
|
|
1170
|
+
case "video":
|
|
1171
|
+
elements.push({
|
|
1172
|
+
type: "video",
|
|
1173
|
+
path: resolveUriToPathPlaceholder(seg.data.uri)
|
|
1174
|
+
});
|
|
1175
|
+
break;
|
|
1176
|
+
case "light_app":
|
|
1177
|
+
elements.push({
|
|
1178
|
+
type: "json",
|
|
1179
|
+
raw: seg.data.json_payload
|
|
1180
|
+
});
|
|
1181
|
+
break;
|
|
1182
|
+
case "forward": throw kernelError("forward 段发送暂未支持(需 kernel 组装任意内容的新转发,P2 探测)", "INVALID_PARAM");
|
|
1183
|
+
}
|
|
1184
|
+
return {
|
|
1185
|
+
elements,
|
|
1186
|
+
atUins,
|
|
1187
|
+
replySeqs
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* 媒体 URI → 本地路径(同步面:file:// / 裸路径直接给 canonical;base64:// 与
|
|
1192
|
+
* http(s):// 需要异步落盘,由 send 核心在 canonical 上二次解析——此处先放原始
|
|
1193
|
+
* URI 作占位,send 核心按元素类型调用 resolveMediaUri 替换)。
|
|
1194
|
+
*/
|
|
1195
|
+
function resolveUriToPathPlaceholder(uri) {
|
|
1196
|
+
if (uri.startsWith("file://")) return fileUriToPath(uri);
|
|
1197
|
+
if (uri.startsWith("http://") || uri.startsWith("https://") || uri.startsWith("base64://")) return uri;
|
|
1198
|
+
return uri;
|
|
1199
|
+
}
|
|
1200
|
+
/** file:// 前缀(fileURLToPath 失败时的裸剥离兜底)。 */
|
|
1201
|
+
const FILE_URI_PREFIX_RE = /^file:\/\//;
|
|
1202
|
+
/** file:// URI → 平台路径(Windows 盘符兼容)。 */
|
|
1203
|
+
function fileUriToPath(uri) {
|
|
1204
|
+
try {
|
|
1205
|
+
return fileURLToPath(uri);
|
|
1206
|
+
} catch {
|
|
1207
|
+
return uri.replace(FILE_URI_PREFIX_RE, "");
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
//#endregion
|
|
1211
|
+
//#region src/milky/helper/translate.ts
|
|
1212
|
+
/**
|
|
1213
|
+
* Milky 事件翻译(RawMessage → Milky 事件,纯函数,ADR-008)
|
|
1214
|
+
*
|
|
1215
|
+
* P0 事件两种:message_receive(onRecvMsg)/ message_recall(grayTip REVOKE)。
|
|
1216
|
+
* 实体与 reply 回填在广播路径异步取得后经 ctx 注入(翻译保持纯函数);
|
|
1217
|
+
* 撤回事件的 sender_id 取 RawMessage.senderUin(grayTip 系统消息的 sender
|
|
1218
|
+
* 即被撤回原消息的发送者,口径待真实事件校准)。
|
|
1219
|
+
*/
|
|
1220
|
+
/** 毫秒 → 秒(Unix 时间戳)。 */
|
|
1221
|
+
const MS_TO_SEC$1 = 1e3;
|
|
1222
|
+
/** chatType → message_scene。 */
|
|
1223
|
+
function chatTypeToScene(chatType) {
|
|
1224
|
+
if (chatType === ChatType.GROUP) return "group";
|
|
1225
|
+
if (chatType === ChatType.C2C_TEMP) return "temp";
|
|
1226
|
+
return "friend";
|
|
1227
|
+
}
|
|
1228
|
+
/** peer_id:群聊 = 群号(peerUid);好友/临时 = 对端 uin(peerUin,空退 peerUid)。 */
|
|
1229
|
+
function peerIdOf(msg, scene) {
|
|
1230
|
+
const raw = scene === "group" ? msg.peerUid : msg.peerUin !== "" ? msg.peerUin : msg.peerUid;
|
|
1231
|
+
return Number(raw);
|
|
1232
|
+
}
|
|
1233
|
+
/** RawMessage → message_receive 事件(实体缺省最小合法值,schema 形状完整)。 */
|
|
1234
|
+
function toMessageReceiveEvent(msg, ctx) {
|
|
1235
|
+
const segments = canonicalToIncomingSegments(toCanonicalElements(msg), {
|
|
1236
|
+
...ctx.uidToUin !== void 0 ? { uidToUin: ctx.uidToUin } : {},
|
|
1237
|
+
...ctx.replyMeta !== void 0 ? { replyMeta: ctx.replyMeta } : {}
|
|
1238
|
+
});
|
|
1239
|
+
if (ctx.forwardSegments !== void 0) segments.push(...ctx.forwardSegments);
|
|
1240
|
+
const scene = chatTypeToScene(msg.chatType);
|
|
1241
|
+
const time = Math.floor(Number(msg.msgTime) / MS_TO_SEC$1);
|
|
1242
|
+
let data;
|
|
1243
|
+
if (scene === "group") data = {
|
|
1244
|
+
message_scene: "group",
|
|
1245
|
+
peer_id: peerIdOf(msg, scene),
|
|
1246
|
+
message_seq: Number(msg.msgSeq),
|
|
1247
|
+
sender_id: Number(msg.senderUin),
|
|
1248
|
+
time,
|
|
1249
|
+
segments,
|
|
1250
|
+
group: ctx.groupEntity ?? toGroupEntity(void 0, msg.peerUid),
|
|
1251
|
+
group_member: ctx.groupMemberEntity ?? toGroupMemberEntity(void 0, msg.peerUid, msg.senderUin)
|
|
1252
|
+
};
|
|
1253
|
+
else if (scene === "friend") data = {
|
|
1254
|
+
message_scene: "friend",
|
|
1255
|
+
peer_id: peerIdOf(msg, scene),
|
|
1256
|
+
message_seq: Number(msg.msgSeq),
|
|
1257
|
+
sender_id: Number(msg.senderUin),
|
|
1258
|
+
time,
|
|
1259
|
+
segments,
|
|
1260
|
+
friend: ctx.friendEntity ?? toFriendEntity(void 0, msg.peerUin !== "" ? msg.peerUin : msg.senderUin, msg.peerName)
|
|
1261
|
+
};
|
|
1262
|
+
else data = {
|
|
1263
|
+
message_scene: "temp",
|
|
1264
|
+
peer_id: peerIdOf(msg, scene),
|
|
1265
|
+
message_seq: Number(msg.msgSeq),
|
|
1266
|
+
sender_id: Number(msg.senderUin),
|
|
1267
|
+
time,
|
|
1268
|
+
segments,
|
|
1269
|
+
group: ctx.groupEntity ?? null
|
|
1270
|
+
};
|
|
1271
|
+
return {
|
|
1272
|
+
time,
|
|
1273
|
+
self_id: Number(ctx.selfUin),
|
|
1274
|
+
event_type: "message_receive",
|
|
1275
|
+
data
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
/** 撤回 grayTip → message_recall 事件(非撤回 grayTip 返回 null)。 */
|
|
1279
|
+
function toMessageRecallEvent(msg, ctx) {
|
|
1280
|
+
const revoke = (msg.elements.find((el) => el.grayTipElement !== void 0)?.grayTipElement)?.revokeElement;
|
|
1281
|
+
if (revoke === void 0) return null;
|
|
1282
|
+
const scene = chatTypeToScene(msg.chatType);
|
|
1283
|
+
const operatorUin = ctx.uidToUin?.get(revoke.operatorUid) ?? revoke.operatorUid;
|
|
1284
|
+
return {
|
|
1285
|
+
time: Math.floor(Number(msg.msgTime) / MS_TO_SEC$1),
|
|
1286
|
+
self_id: Number(ctx.selfUin),
|
|
1287
|
+
event_type: "message_recall",
|
|
1288
|
+
data: {
|
|
1289
|
+
message_scene: scene,
|
|
1290
|
+
peer_id: peerIdOf(msg, scene),
|
|
1291
|
+
message_seq: Number(msg.msgSeq),
|
|
1292
|
+
sender_id: Number(msg.senderUin),
|
|
1293
|
+
operator_id: Number(operatorUin) || 0,
|
|
1294
|
+
display_suffix: revoke.wording ?? ""
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
/** 群文件上传事件(群场景文件消息 → group_file_upload,替代 message_receive)。 */
|
|
1299
|
+
function toGroupFileUploadEvent(msg, selfUin) {
|
|
1300
|
+
if (msg.chatType !== ChatType.GROUP) return null;
|
|
1301
|
+
const file = msg.elements.find((el) => el.fileElement !== void 0)?.fileElement;
|
|
1302
|
+
if (file === void 0) return null;
|
|
1303
|
+
return {
|
|
1304
|
+
time: Math.floor(Number(msg.msgTime) / MS_TO_SEC$1),
|
|
1305
|
+
self_id: Number(selfUin),
|
|
1306
|
+
event_type: "group_file_upload",
|
|
1307
|
+
data: {
|
|
1308
|
+
group_id: Number(msg.peerUid),
|
|
1309
|
+
user_id: Number(msg.senderUin) || 0,
|
|
1310
|
+
file_id: file.fileUuid ?? file.fileName ?? "",
|
|
1311
|
+
file_name: file.fileName ?? "",
|
|
1312
|
+
file_size: Number(file.fileSize ?? 0)
|
|
1313
|
+
}
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
//#endregion
|
|
1317
|
+
//#region src/milky/action/message.ts
|
|
1318
|
+
/**
|
|
1319
|
+
* OutgoingSegment 宽松校验:入口只保证「{type, data}」形状,字段级转换在
|
|
1320
|
+
* outgoingSegmentsToCanonical 内做(未知段跳过 = 规范兼容性要求)。
|
|
1321
|
+
*/
|
|
1322
|
+
const outgoingSegmentSchema = z.object({
|
|
1323
|
+
type: z.string(),
|
|
1324
|
+
data: z.record(z.string(), z.unknown())
|
|
1325
|
+
}).loose();
|
|
1326
|
+
const sendMessageFields = { message: z.array(outgoingSegmentSchema).min(1) };
|
|
1327
|
+
const recallFields = { message_seq: z.number().int().min(0) };
|
|
1328
|
+
/** send_private_message:发送私聊消息。 */
|
|
1329
|
+
var SendPrivateMessageAction = class extends MilkyApiAction {
|
|
1330
|
+
name = "send_private_message";
|
|
1331
|
+
schema = z.object({
|
|
1332
|
+
user_id: z.number().int().min(10001),
|
|
1333
|
+
...sendMessageFields
|
|
1334
|
+
});
|
|
1335
|
+
async _handle(payload) {
|
|
1336
|
+
return await sendMilkyMessage(await this.api.resolvePeer("friend", payload.user_id), payload.message, this.api);
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
/** send_group_message:发送群聊消息。 */
|
|
1340
|
+
var SendGroupMessageAction = class extends MilkyApiAction {
|
|
1341
|
+
name = "send_group_message";
|
|
1342
|
+
schema = z.object({
|
|
1343
|
+
group_id: z.number().int().min(10001),
|
|
1344
|
+
...sendMessageFields
|
|
1345
|
+
});
|
|
1346
|
+
async _handle(payload) {
|
|
1347
|
+
return await sendMilkyMessage(await this.api.resolvePeer("group", payload.group_id), payload.message, this.api);
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
/** get_message:获取单条消息(seq → msgId 反查 + 实体回填)。 */
|
|
1351
|
+
var GetMessageAction = class extends MilkyApiAction {
|
|
1352
|
+
name = "get_message";
|
|
1353
|
+
schema = z.object({ ...peerMessageFields });
|
|
1354
|
+
async _handle(payload) {
|
|
1355
|
+
const peer = await this.api.resolvePeer(payload.message_scene, payload.peer_id);
|
|
1356
|
+
const msgId = await this.api.resolveMsgIdBySeq(peer, payload.message_seq, peer.chatType);
|
|
1357
|
+
if (msgId === void 0) throw kernelError(`消息 ${payload.message_seq} 不存在`, "NOT_FOUND");
|
|
1358
|
+
const [msg] = await this.api.msgApi.fetchMsgsByMsgId(peer, [msgId]);
|
|
1359
|
+
if (msg === void 0) throw kernelError(`消息 ${payload.message_seq} 不存在`, "NOT_FOUND");
|
|
1360
|
+
return { message: await this.translateWithEntities(payload.message_scene, peer, msg) };
|
|
1361
|
+
}
|
|
1362
|
+
/** 实体回填(group 场景 schema 要求完整实体)→ IncomingMessage。 */
|
|
1363
|
+
async translateWithEntities(scene, peer, msg) {
|
|
1364
|
+
const ctx = { selfUin: this.api.self.uin };
|
|
1365
|
+
if (scene === "group") {
|
|
1366
|
+
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)]);
|
|
1367
|
+
ctx.groupEntity = toGroupEntity(detail, peer.peerUid);
|
|
1368
|
+
ctx.groupMemberEntity = toGroupMemberEntity(member, peer.peerUid, msg.senderUin);
|
|
1369
|
+
}
|
|
1370
|
+
return toMessageReceiveEvent(msg, ctx).data;
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
/** recall_private_message:撤回私聊消息。 */ var RecallPrivateMessageAction = class extends MilkyApiAction {
|
|
1374
|
+
name = "recall_private_message";
|
|
1375
|
+
schema = z.object({
|
|
1376
|
+
user_id: z.number().int().min(10001),
|
|
1377
|
+
...recallFields
|
|
1378
|
+
});
|
|
1379
|
+
async _handle(payload) {
|
|
1380
|
+
await recallBySeq(await this.api.resolvePeer("friend", payload.user_id), payload.message_seq, this.api);
|
|
1381
|
+
return {};
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
/** recall_group_message:撤回群消息。 */
|
|
1385
|
+
var RecallGroupMessageAction = class extends MilkyApiAction {
|
|
1386
|
+
name = "recall_group_message";
|
|
1387
|
+
schema = z.object({
|
|
1388
|
+
group_id: z.number().int().min(10001),
|
|
1389
|
+
...recallFields
|
|
1390
|
+
});
|
|
1391
|
+
async _handle(payload) {
|
|
1392
|
+
await recallBySeq(await this.api.resolvePeer("group", payload.group_id), payload.message_seq, this.api);
|
|
1393
|
+
return {};
|
|
1394
|
+
}
|
|
1395
|
+
};
|
|
1396
|
+
/** seq → msgId(反查)→ recallMessage。 */
|
|
1397
|
+
async function recallBySeq(peer, seq, api) {
|
|
1398
|
+
const msgId = await api.resolveMsgIdBySeq(peer, seq, peer.chatType);
|
|
1399
|
+
if (msgId === void 0) throw kernelError(`消息 ${seq} 不存在`, "NOT_FOUND");
|
|
1400
|
+
await api.msgApi.recallMessage(peer, [msgId]);
|
|
1401
|
+
}
|
|
1402
|
+
/** 发送核心:segments → canonical(URI/ID 转换)→ sendMessage → 回查输出。 */
|
|
1403
|
+
async function sendMilkyMessage(peer, segments, api) {
|
|
1404
|
+
const { elements, atUins } = outgoingSegmentsToCanonical(segments);
|
|
1405
|
+
const resolved = await resolveElements(elements, atUins, api);
|
|
1406
|
+
if (resolved.length === 0) throw kernelError("消息内容为空(全部消息段不可用)", "INVALID_PARAM");
|
|
1407
|
+
const { msgId } = await api.msgApi.sendMessage(peer, resolved);
|
|
1408
|
+
return await api.resolveSentOutput(peer, msgId);
|
|
1409
|
+
}
|
|
1410
|
+
/** 元素二次解析:at uin→uid + 远程媒体 URI 落盘(reply seq 已在映射层兜底原样)。 */
|
|
1411
|
+
async function resolveElements(elements, atUins, api) {
|
|
1412
|
+
const uinToUid = await resolveAtUins(atUins, api);
|
|
1413
|
+
const out = [];
|
|
1414
|
+
for (const el of elements) out.push(await resolveElement(el, uinToUid, api));
|
|
1415
|
+
return out;
|
|
1416
|
+
}
|
|
1417
|
+
/** at uin→uid 批量转换(失败返回 undefined:at 原样 uin,交 QQ 端判断)。 */
|
|
1418
|
+
async function resolveAtUins(atUins, api) {
|
|
1419
|
+
if (atUins.length === 0) return;
|
|
1420
|
+
try {
|
|
1421
|
+
return await api.uinToUid(atUins);
|
|
1422
|
+
} catch {
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
/** 单元素解析:at 目标替换 / 远程媒体 URI 落盘 / 其余原样。 */
|
|
1427
|
+
async function resolveElement(el, uinToUid, api) {
|
|
1428
|
+
if (el.type === "at" && el.target !== "all") return {
|
|
1429
|
+
...el,
|
|
1430
|
+
target: uinToUid?.get(el.target) ?? el.target
|
|
1431
|
+
};
|
|
1432
|
+
if (isRemoteMediaUri(el)) {
|
|
1433
|
+
const media = el;
|
|
1434
|
+
return {
|
|
1435
|
+
...media,
|
|
1436
|
+
path: await resolveMediaUri(media.path, api.mediaDir)
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
return el;
|
|
1440
|
+
}
|
|
1441
|
+
/** 远程媒体元素(image/voice/video 且 path 为需落盘的 URI)。 */
|
|
1442
|
+
function isRemoteMediaUri(el) {
|
|
1443
|
+
return (el.type === "image" || el.type === "voice" || el.type === "video") && (el.path.startsWith("http://") || el.path.startsWith("https://") || el.path.startsWith("base64://"));
|
|
1444
|
+
}
|
|
1445
|
+
/** get_history_messages:获取历史消息(start seq 锚点 → 原生翻页 → 升序输出)。 */
|
|
1446
|
+
var GetHistoryMessagesAction = class extends MilkyApiAction {
|
|
1447
|
+
name = "get_history_messages";
|
|
1448
|
+
schema = z.object({
|
|
1449
|
+
message_scene: z.enum([
|
|
1450
|
+
"friend",
|
|
1451
|
+
"group",
|
|
1452
|
+
"temp"
|
|
1453
|
+
]),
|
|
1454
|
+
peer_id: z.number().int().min(10001),
|
|
1455
|
+
start_message_seq: z.number().int().min(0).nullable().optional(),
|
|
1456
|
+
limit: z.number().int().min(0).nullable().optional()
|
|
1457
|
+
});
|
|
1458
|
+
async _handle(payload) {
|
|
1459
|
+
const limit = payload.limit ?? HISTORY_DEFAULT_LIMIT;
|
|
1460
|
+
const peer = await this.api.resolvePeer(payload.message_scene, payload.peer_id);
|
|
1461
|
+
const anchor = await this.resolveAnchor(peer, payload.start_message_seq);
|
|
1462
|
+
const msgs = await this.api.msgApi.fetchMessages(peer, {
|
|
1463
|
+
count: limit,
|
|
1464
|
+
...anchor !== void 0 ? { msgId: anchor } : {}
|
|
1465
|
+
});
|
|
1466
|
+
for (const m of msgs) this.api.seqIndex.record(m);
|
|
1467
|
+
const groupData = payload.message_scene === "group" ? await this.loadGroupData(peer.peerUid) : null;
|
|
1468
|
+
const ordered = [...msgs].reverse();
|
|
1469
|
+
const messages = ordered.map((m) => this.translateHistoryMessage(m, peer.peerUid, groupData));
|
|
1470
|
+
const hasMore = msgs.length >= limit;
|
|
1471
|
+
const oldest = ordered[0];
|
|
1472
|
+
return {
|
|
1473
|
+
messages,
|
|
1474
|
+
next_message_seq: hasMore && oldest !== void 0 ? Number(oldest.msgSeq) : null
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
/** start_message_seq → 锚点 msgId(给了 seq 却反查不到 = NOT_FOUND)。 */
|
|
1478
|
+
async resolveAnchor(peer, startSeq) {
|
|
1479
|
+
if (startSeq === void 0 || startSeq === null) return;
|
|
1480
|
+
const msgId = await this.api.resolveMsgIdBySeq(peer, startSeq, peer.chatType);
|
|
1481
|
+
if (msgId === void 0) throw kernelError(`起始消息 ${startSeq} 不存在`, "NOT_FOUND");
|
|
1482
|
+
return msgId;
|
|
1483
|
+
}
|
|
1484
|
+
/** 群场景回填数据(详情 + 成员表;缓存未装配返回空数据)。 */
|
|
1485
|
+
async loadGroupData(groupCode) {
|
|
1486
|
+
if (this.api.groupCache === void 0) return {
|
|
1487
|
+
detail: void 0,
|
|
1488
|
+
members: /* @__PURE__ */ new Map()
|
|
1489
|
+
};
|
|
1490
|
+
const [detail, members] = await Promise.all([this.api.groupCache.getGroupDetail(groupCode).catch(() => void 0), this.api.groupCache.getMembers(groupCode).catch(() => [])]);
|
|
1491
|
+
const memberByUid = /* @__PURE__ */ new Map();
|
|
1492
|
+
for (const m of members) memberByUid.set(m.uid, m);
|
|
1493
|
+
return {
|
|
1494
|
+
detail,
|
|
1495
|
+
members: memberByUid
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
/** 单条历史消息 → IncomingMessage(群场景注入实体,fail-soft)。 */
|
|
1499
|
+
translateHistoryMessage(msg, peerUid, groupData) {
|
|
1500
|
+
const ctx = { selfUin: this.api.self.uin };
|
|
1501
|
+
if (groupData !== null) {
|
|
1502
|
+
if (groupData.detail !== void 0) ctx.groupEntity = toGroupEntity(groupData.detail, peerUid);
|
|
1503
|
+
const member = groupData.members.get(msg.senderUid);
|
|
1504
|
+
if (member !== void 0) ctx.groupMemberEntity = toGroupMemberEntity(member, peerUid, msg.senderUin);
|
|
1505
|
+
}
|
|
1506
|
+
return toMessageReceiveEvent(msg, ctx).data;
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
/** get_forwarded_messages:获取合并转发内容(resId → 转发索引 → fetchForwardMessage)。 */
|
|
1510
|
+
var GetForwardedMessagesAction = class extends MilkyApiAction {
|
|
1511
|
+
name = "get_forwarded_messages";
|
|
1512
|
+
schema = z.object({ forward_id: z.string().min(1) });
|
|
1513
|
+
async _handle(payload) {
|
|
1514
|
+
const entry = this.api.seqIndex.resolveForward(payload.forward_id);
|
|
1515
|
+
if (entry === void 0) throw kernelError(`转发消息 ${payload.forward_id} 不存在(未登记)`, "NOT_FOUND");
|
|
1516
|
+
const peer = {
|
|
1517
|
+
chatType: entry.chatType,
|
|
1518
|
+
peerUid: entry.peerUid
|
|
1519
|
+
};
|
|
1520
|
+
const msgs = await this.api.msgApi.fetchForwardMessage(peer, entry.msgId);
|
|
1521
|
+
for (const m of msgs) this.api.seqIndex.record(m);
|
|
1522
|
+
const uidToUin = await this.collectUidToUin(msgs);
|
|
1523
|
+
const messages = [];
|
|
1524
|
+
for (const m of msgs) messages.push({
|
|
1525
|
+
message_seq: Number(m.msgSeq),
|
|
1526
|
+
sender_name: await this.senderNameOf(m),
|
|
1527
|
+
avatar_url: avatarUrlOf(m.senderUin),
|
|
1528
|
+
time: Math.floor(Number(m.msgTime) / 1e3),
|
|
1529
|
+
segments: receiveSegmentsOf(m, uidToUin)
|
|
1530
|
+
});
|
|
1531
|
+
return { messages };
|
|
1532
|
+
}
|
|
1533
|
+
/** at 段 uid → uin 批量转换(mention 翻译;失败退空 Map)。 */
|
|
1534
|
+
async collectUidToUin(msgs) {
|
|
1535
|
+
const uids = /* @__PURE__ */ new Set();
|
|
1536
|
+
for (const m of msgs) for (const el of toCanonicalElements(m)) if (el.type === "at" && el.target !== "all") uids.add(el.target);
|
|
1537
|
+
if (uids.size === 0) return /* @__PURE__ */ new Map();
|
|
1538
|
+
return await this.api.uidToUin([...uids]).catch(() => /* @__PURE__ */ new Map());
|
|
1539
|
+
}
|
|
1540
|
+
/** 发送者名(群场景成员缓存昵称/名片,失败退 uin 字符串)。 */
|
|
1541
|
+
async senderNameOf(msg) {
|
|
1542
|
+
if (this.api.groupCache !== void 0 && msg.chatType === ChatType.GROUP) {
|
|
1543
|
+
const member = await this.api.groupCache.getMember(msg.peerUid, msg.senderUid).catch(() => void 0);
|
|
1544
|
+
const name = member?.cardName !== void 0 && member.cardName !== "" ? member.cardName : member?.nick;
|
|
1545
|
+
if (name !== void 0 && name !== "") return name;
|
|
1546
|
+
}
|
|
1547
|
+
return msg.senderUin;
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
/** mark_message_as_read:标记会话已读(NT setMsgRead 以会话为粒度,seq 入参仅兼容 schema)。 */
|
|
1551
|
+
var MarkMessageAsReadAction = class extends MilkyApiAction {
|
|
1552
|
+
name = "mark_message_as_read";
|
|
1553
|
+
schema = z.object({ ...peerMessageFields });
|
|
1554
|
+
async _handle(payload) {
|
|
1555
|
+
const peer = await this.api.resolvePeer(payload.message_scene, payload.peer_id);
|
|
1556
|
+
await this.api.msgApi.markRead(peer);
|
|
1557
|
+
return {};
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
/** 历史消息默认条数(规范 default 20)。 */
|
|
1561
|
+
const HISTORY_DEFAULT_LIMIT = 20;
|
|
1562
|
+
/** get_message / mark_message_as_read 共入参(场景 + 对端 + seq)。 */
|
|
1563
|
+
const peerMessageFields = {
|
|
1564
|
+
message_scene: z.enum([
|
|
1565
|
+
"friend",
|
|
1566
|
+
"group",
|
|
1567
|
+
"temp"
|
|
1568
|
+
]),
|
|
1569
|
+
peer_id: z.number().int().min(10001),
|
|
1570
|
+
message_seq: z.number().int().min(0)
|
|
1571
|
+
};
|
|
1572
|
+
/** 单条消息 → 接收段(canonical 映射 + forward 段注入,forward 不经 canonical)。 */
|
|
1573
|
+
function receiveSegmentsOf(msg, uidToUin) {
|
|
1574
|
+
const segments = canonicalToIncomingSegments(toCanonicalElements(msg), { ...uidToUin.size > 0 ? { uidToUin } : {} });
|
|
1575
|
+
const fwd = msg.elements.find((el) => el.multiForwardMsgElement !== void 0)?.multiForwardMsgElement;
|
|
1576
|
+
if (fwd !== void 0) segments.push(incomingForwardSegment(fwd.resId, fwd.fileName));
|
|
1577
|
+
return segments;
|
|
1578
|
+
}
|
|
1579
|
+
/** QQ 头像 URL(公开头像服务,640px)。 */
|
|
1580
|
+
function avatarUrlOf(uin) {
|
|
1581
|
+
return uin === "" ? "" : `https://q1.qlogo.cn/g?b=qq&nk=${uin}&s=640`;
|
|
1582
|
+
}
|
|
1583
|
+
//#endregion
|
|
1584
|
+
//#region src/milky/action/profile.ts
|
|
1585
|
+
/**
|
|
1586
|
+
* Milky 资料类动作(P1):get_user_profile / get_friend_info / set_avatar /
|
|
1587
|
+
* set_nickname / set_bio / get_cookies / get_csrf_token
|
|
1588
|
+
*
|
|
1589
|
+
* set_avatar 的 uri 经 media-uri 解析(本地路径 / file:// 直用,http(s)/base64
|
|
1590
|
+
* 落盘 mediaDir);get_cookies 输出规范为单字符串(k=v 分号拼接);
|
|
1591
|
+
* get_csrf_token = qun.qq.com cookies 的 skey → bkn(OB11 get_csrf_token 同口径)。
|
|
1592
|
+
*/
|
|
1593
|
+
/** CSRF 计算域名(bkn 口径,OB11 get_csrf_token 同源)。 */
|
|
1594
|
+
const CSRF_DOMAIN = "qun.qq.com";
|
|
1595
|
+
/** get_user_profile:获取用户个人信息(陌生人信息 API;country/city/school 无源空兜底)。 */
|
|
1596
|
+
var GetUserProfileAction = class extends MilkyApiAction {
|
|
1597
|
+
name = "get_user_profile";
|
|
1598
|
+
schema = z.object({ user_id: z.number().int().min(10001) });
|
|
1599
|
+
async _handle(payload) {
|
|
1600
|
+
const info = await this.api.profileApi.getStrangerInfo(String(payload.user_id));
|
|
1601
|
+
return {
|
|
1602
|
+
nickname: info.nickname,
|
|
1603
|
+
qid: info.qid,
|
|
1604
|
+
age: info.age,
|
|
1605
|
+
sex: info.sex,
|
|
1606
|
+
remark: info.remark,
|
|
1607
|
+
bio: info.long_nick,
|
|
1608
|
+
level: info.qq_level,
|
|
1609
|
+
country: "",
|
|
1610
|
+
city: "",
|
|
1611
|
+
school: ""
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
};
|
|
1615
|
+
/** get_friend_info:获取好友信息(好友列表单查)。 */
|
|
1616
|
+
var GetFriendInfoAction = class extends MilkyApiAction {
|
|
1617
|
+
name = "get_friend_info";
|
|
1618
|
+
schema = z.object({
|
|
1619
|
+
user_id: z.number().int().min(10001),
|
|
1620
|
+
no_cache: z.boolean().nullable().optional()
|
|
1621
|
+
});
|
|
1622
|
+
async _handle(payload) {
|
|
1623
|
+
const uin = String(payload.user_id);
|
|
1624
|
+
const friend = (await this.api.friendApi.getFriendList()).find((f) => f.uin === uin);
|
|
1625
|
+
if (friend === void 0) throw kernelError(`好友 ${uin} 不存在`, "NOT_FOUND");
|
|
1626
|
+
return { friend: toFriendEntity(friend, friend.uin, friend.nickname) };
|
|
1627
|
+
}
|
|
1628
|
+
};
|
|
1629
|
+
/** set_avatar:设置头像(uri → 本地路径)。 */
|
|
1630
|
+
var SetAvatarAction = class extends MilkyApiAction {
|
|
1631
|
+
name = "set_avatar";
|
|
1632
|
+
schema = z.object({ uri: z.string().min(1) });
|
|
1633
|
+
async _handle(payload) {
|
|
1634
|
+
const path = await resolveMediaUri(payload.uri, this.api.mediaDir);
|
|
1635
|
+
await this.api.profileApi.setHeader(path);
|
|
1636
|
+
return {};
|
|
1637
|
+
}
|
|
1638
|
+
};
|
|
1639
|
+
/** set_nickname:设置昵称。 */
|
|
1640
|
+
var SetNicknameAction = class extends MilkyApiAction {
|
|
1641
|
+
name = "set_nickname";
|
|
1642
|
+
schema = z.object({ new_nickname: z.string() });
|
|
1643
|
+
async _handle(payload) {
|
|
1644
|
+
await this.api.profileApi.setNickName(payload.new_nickname);
|
|
1645
|
+
return {};
|
|
1646
|
+
}
|
|
1647
|
+
};
|
|
1648
|
+
/** set_bio:设置个性签名。 */
|
|
1649
|
+
var SetBioAction = class extends MilkyApiAction {
|
|
1650
|
+
name = "set_bio";
|
|
1651
|
+
schema = z.object({ new_bio: z.string() });
|
|
1652
|
+
async _handle(payload) {
|
|
1653
|
+
await this.api.profileApi.setLongNick(payload.new_bio);
|
|
1654
|
+
return {};
|
|
1655
|
+
}
|
|
1656
|
+
};
|
|
1657
|
+
/** get_cookies:获取 cookies(规范输出为 k=v 分号拼接字符串)。 */
|
|
1658
|
+
var GetCookiesAction = class extends MilkyApiAction {
|
|
1659
|
+
name = "get_cookies";
|
|
1660
|
+
schema = z.object({ domain: z.string().min(1) });
|
|
1661
|
+
async _handle(payload) {
|
|
1662
|
+
const cookies = await this.api.ticketApi.getCookies(payload.domain, this.api.self.uin);
|
|
1663
|
+
return { cookies: Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join("; ") };
|
|
1664
|
+
}
|
|
1665
|
+
};
|
|
1666
|
+
/** get_csrf_token:bkn(qun.qq.com skey 派生,OB11 get_csrf_token 同口径)。 */
|
|
1667
|
+
var GetCSRFTokenAction = class extends MilkyApiAction {
|
|
1668
|
+
name = "get_csrf_token";
|
|
1669
|
+
schema = z.object({}).strict();
|
|
1670
|
+
async _handle() {
|
|
1671
|
+
const cookies = await this.api.ticketApi.getCookies(CSRF_DOMAIN, this.api.self.uin);
|
|
1672
|
+
return { csrf_token: TicketApi.getBkn(cookies["skey"] ?? "") };
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
//#endregion
|
|
1676
|
+
//#region src/milky/action/social.ts
|
|
1677
|
+
/**
|
|
1678
|
+
* Milky 社交类动作(P0:get_friend_list / get_group_list;
|
|
1679
|
+
* P1 +5:send_profile_like / delete_friend / get_friend_requests /
|
|
1680
|
+
* accept_friend_request / reject_friend_request)
|
|
1681
|
+
*/
|
|
1682
|
+
const emptySchema$1 = z.object({}).strict();
|
|
1683
|
+
/** get_friend_list:获取好友列表。 */
|
|
1684
|
+
var GetFriendListAction = class extends MilkyApiAction {
|
|
1685
|
+
name = "get_friend_list";
|
|
1686
|
+
schema = emptySchema$1;
|
|
1687
|
+
async _handle() {
|
|
1688
|
+
return { friends: (await this.api.friendApi.getFriendList()).map((f) => toFriendEntity(f, f.uin, f.nickname)) };
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
/** get_group_list:获取群列表。
|
|
1692
|
+
* 实测校准(2026-08-08,OB11 同款):原生 getGroupList 返回值无数据,列表经
|
|
1693
|
+
* onGroupListUpdate 事件进 GroupCache——缓存优先,为空触发刷新回填。
|
|
1694
|
+
*/
|
|
1695
|
+
var GetGroupListAction = class extends MilkyApiAction {
|
|
1696
|
+
name = "get_group_list";
|
|
1697
|
+
schema = emptySchema$1;
|
|
1698
|
+
async _handle() {
|
|
1699
|
+
return { groups: (this.api.groupCache !== void 0 ? await this.api.groupCache.listGroupsRefreshed() : await this.api.groupApi.getGroupList(true)).map(groupListToEntity) };
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
/** send_profile_like:发送好友赞(count 缺省 1,规范 default 同值)。 */
|
|
1703
|
+
var SendProfileLikeAction = class extends MilkyApiAction {
|
|
1704
|
+
name = "send_profile_like";
|
|
1705
|
+
schema = z.object({
|
|
1706
|
+
user_id: z.number().int().min(10001),
|
|
1707
|
+
count: z.number().int().min(0).nullable().optional()
|
|
1708
|
+
});
|
|
1709
|
+
async _handle(payload) {
|
|
1710
|
+
const uid = await this.api.resolveUidFromUin(payload.user_id);
|
|
1711
|
+
await this.api.profileLikeApi.sendLike(uid, payload.count ?? 1);
|
|
1712
|
+
return {};
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
/** delete_friend:删除好友。 */
|
|
1716
|
+
var DeleteFriendAction = class extends MilkyApiAction {
|
|
1717
|
+
name = "delete_friend";
|
|
1718
|
+
schema = z.object({ user_id: z.number().int().min(10001) });
|
|
1719
|
+
async _handle(payload) {
|
|
1720
|
+
const uid = await this.api.resolveUidFromUin(payload.user_id);
|
|
1721
|
+
await this.api.friendApi.deleteFriend(uid);
|
|
1722
|
+
return {};
|
|
1723
|
+
}
|
|
1724
|
+
};
|
|
1725
|
+
/**
|
|
1726
|
+
* get_friend_requests:获取好友请求列表。
|
|
1727
|
+
* kernel getBuddyReqList 无过滤/数量参数——is_filtered 入参仅作 schema 兼容
|
|
1728
|
+
* (全量返回);state 无源字段恒 pending(真实状态待探测校准)。
|
|
1729
|
+
*/
|
|
1730
|
+
var GetFriendRequestsAction = class extends MilkyApiAction {
|
|
1731
|
+
name = "get_friend_requests";
|
|
1732
|
+
schema = z.object({
|
|
1733
|
+
limit: z.number().int().min(0).nullable().optional(),
|
|
1734
|
+
is_filtered: z.boolean().nullable().optional()
|
|
1735
|
+
});
|
|
1736
|
+
async _handle(payload) {
|
|
1737
|
+
const reqs = await this.api.friendApi.getBuddyReqList();
|
|
1738
|
+
let uidToUin = /* @__PURE__ */ new Map();
|
|
1739
|
+
if (reqs.length > 0) uidToUin = await this.api.uidToUin(reqs.map((req) => req.friendUid)).catch(() => /* @__PURE__ */ new Map());
|
|
1740
|
+
const requests = reqs.map((req) => ({
|
|
1741
|
+
time: Number(req.reqTime) || 0,
|
|
1742
|
+
initiator_id: Number(uidToUin.get(req.friendUid)) || 0,
|
|
1743
|
+
initiator_uid: req.friendUid,
|
|
1744
|
+
target_user_id: Number(this.api.self.uin),
|
|
1745
|
+
target_user_uid: this.api.self.uid ?? "",
|
|
1746
|
+
state: "pending",
|
|
1747
|
+
comment: typeof req["words"] === "string" ? req["words"] : "",
|
|
1748
|
+
via: req.sourceName ?? "",
|
|
1749
|
+
is_filtered: false
|
|
1750
|
+
}));
|
|
1751
|
+
const limit = payload.limit ?? requests.length;
|
|
1752
|
+
return { requests: requests.slice(0, limit) };
|
|
1753
|
+
}
|
|
1754
|
+
};
|
|
1755
|
+
/**
|
|
1756
|
+
* accept/reject_friend_request 公共基类:initiator_uid → 查申请 → 应答。
|
|
1757
|
+
* kernel handleFriendRequest 无 reason 参数(拒绝理由无源,入参仅 schema 兼容)。
|
|
1758
|
+
*/
|
|
1759
|
+
var HandleFriendRequestBase = class extends MilkyApiAction {
|
|
1760
|
+
schema = z.object({
|
|
1761
|
+
initiator_uid: z.string().min(1),
|
|
1762
|
+
is_filtered: z.boolean().nullable().optional(),
|
|
1763
|
+
reason: z.string().nullable().optional()
|
|
1764
|
+
});
|
|
1765
|
+
async _handle(payload) {
|
|
1766
|
+
const notify = await findBuddyReq(this.api, payload.initiator_uid);
|
|
1767
|
+
await this.api.friendApi.handleFriendRequest(notify, this.accept);
|
|
1768
|
+
return {};
|
|
1769
|
+
}
|
|
1770
|
+
};
|
|
1771
|
+
/** accept_friend_request:同意好友申请。 */
|
|
1772
|
+
var AcceptFriendRequestAction = class extends HandleFriendRequestBase {
|
|
1773
|
+
name = "accept_friend_request";
|
|
1774
|
+
accept = true;
|
|
1775
|
+
};
|
|
1776
|
+
/** reject_friend_request:拒绝好友申请。 */
|
|
1777
|
+
var RejectFriendRequestAction = class extends HandleFriendRequestBase {
|
|
1778
|
+
name = "reject_friend_request";
|
|
1779
|
+
accept = false;
|
|
1780
|
+
};
|
|
1781
|
+
//#endregion
|
|
1782
|
+
//#region src/milky/action/system.ts
|
|
1783
|
+
/**
|
|
1784
|
+
* Milky 系统类动作(P0):get_login_info / get_impl_info
|
|
1785
|
+
*
|
|
1786
|
+
* get_impl_info 是 Milky 的版本协商面(规范 §compatibility):milky_version
|
|
1787
|
+
* 固定 "1.3"(目标协议版本 1.3.0),qq_protocol_type 固定 windows(自建宿主)。
|
|
1788
|
+
*/
|
|
1789
|
+
/** Milky 协议版本(docs/protocols/milky.md:目标 1.3.0)。 */
|
|
1790
|
+
const MILKY_VERSION = "1.3";
|
|
1791
|
+
const emptySchema = z.object({}).strict();
|
|
1792
|
+
/** get_login_info:获取登录信息。 */
|
|
1793
|
+
var GetLoginInfoAction = class extends MilkyApiAction {
|
|
1794
|
+
name = "get_login_info";
|
|
1795
|
+
schema = emptySchema;
|
|
1796
|
+
async _handle() {
|
|
1797
|
+
return {
|
|
1798
|
+
uin: Number(this.api.self.uin),
|
|
1799
|
+
nickname: this.api.self.nickname
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
};
|
|
1803
|
+
/** get_impl_info:获取协议端信息(版本协商)。 */
|
|
1804
|
+
var GetImplInfoAction = class extends MilkyApiAction {
|
|
1805
|
+
name = "get_impl_info";
|
|
1806
|
+
schema = emptySchema;
|
|
1807
|
+
async _handle() {
|
|
1808
|
+
return {
|
|
1809
|
+
impl_name: "napukettoqq",
|
|
1810
|
+
impl_version: this.api.implVersion,
|
|
1811
|
+
qq_protocol_version: this.api.qqVersion,
|
|
1812
|
+
qq_protocol_type: "windows",
|
|
1813
|
+
milky_version: MILKY_VERSION
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
};
|
|
1817
|
+
//#endregion
|
|
1818
|
+
//#region src/milky/action/index.ts
|
|
1819
|
+
/**
|
|
1820
|
+
* Milky 动作注册表(P0 9 个 + P1 40 个 = 49 个)
|
|
1821
|
+
*
|
|
1822
|
+
* P0 面 = docs/protocols/milky.md §9 P0 清单;P1 面 = §9 P1 清单(群管/精华/
|
|
1823
|
+
* 群文件/好友操作/票据/历史与转发)。延后项(nudge 发送 / rename_folder /
|
|
1824
|
+
* download_url / resource 反查 / 置顶 / 公告 / 上传)见 milky.md §6。
|
|
1825
|
+
*/
|
|
1826
|
+
/** Milky 动作注册表(P0+P1 49 动作)。 */
|
|
1827
|
+
function createMilkyActionRegistry(opts) {
|
|
1828
|
+
const registry = new ActionRegistry();
|
|
1829
|
+
const { api } = opts;
|
|
1830
|
+
registry.register(new GetLoginInfoAction(api));
|
|
1831
|
+
registry.register(new GetImplInfoAction(api));
|
|
1832
|
+
registry.register(new SendPrivateMessageAction(api));
|
|
1833
|
+
registry.register(new SendGroupMessageAction(api));
|
|
1834
|
+
registry.register(new GetMessageAction(api));
|
|
1835
|
+
registry.register(new RecallPrivateMessageAction(api));
|
|
1836
|
+
registry.register(new RecallGroupMessageAction(api));
|
|
1837
|
+
registry.register(new GetFriendListAction(api));
|
|
1838
|
+
registry.register(new GetGroupListAction(api));
|
|
1839
|
+
registry.register(new GetUserProfileAction(api));
|
|
1840
|
+
registry.register(new GetFriendInfoAction(api));
|
|
1841
|
+
registry.register(new SetAvatarAction(api));
|
|
1842
|
+
registry.register(new SetNicknameAction(api));
|
|
1843
|
+
registry.register(new SetBioAction(api));
|
|
1844
|
+
registry.register(new GetCookiesAction(api));
|
|
1845
|
+
registry.register(new GetCSRFTokenAction(api));
|
|
1846
|
+
registry.register(new SendProfileLikeAction(api));
|
|
1847
|
+
registry.register(new DeleteFriendAction(api));
|
|
1848
|
+
registry.register(new GetFriendRequestsAction(api));
|
|
1849
|
+
registry.register(new AcceptFriendRequestAction(api));
|
|
1850
|
+
registry.register(new RejectFriendRequestAction(api));
|
|
1851
|
+
registry.register(new GetHistoryMessagesAction(api));
|
|
1852
|
+
registry.register(new GetForwardedMessagesAction(api));
|
|
1853
|
+
registry.register(new MarkMessageAsReadAction(api));
|
|
1854
|
+
registry.register(new GetGroupInfoAction(api));
|
|
1855
|
+
registry.register(new GetGroupMemberListAction(api));
|
|
1856
|
+
registry.register(new GetGroupMemberInfoAction(api));
|
|
1857
|
+
registry.register(new SetGroupNameAction(api));
|
|
1858
|
+
registry.register(new SetGroupMemberCardAction(api));
|
|
1859
|
+
registry.register(new SetGroupMemberAdminAction(api));
|
|
1860
|
+
registry.register(new SetGroupMemberMuteAction(api));
|
|
1861
|
+
registry.register(new SetGroupWholeMuteAction(api));
|
|
1862
|
+
registry.register(new KickGroupMemberAction(api));
|
|
1863
|
+
registry.register(new QuitGroupAction(api));
|
|
1864
|
+
registry.register(new GetGroupEssenceMessagesAction(api));
|
|
1865
|
+
registry.register(new SetGroupEssenceMessageAction(api));
|
|
1866
|
+
registry.register(new SendGroupMessageReactionAction(api));
|
|
1867
|
+
registry.register(new GetGroupNotificationsAction(api));
|
|
1868
|
+
registry.register(new AcceptGroupRequestAction(api));
|
|
1869
|
+
registry.register(new RejectGroupRequestAction(api));
|
|
1870
|
+
registry.register(new AcceptGroupInvitationAction(api));
|
|
1871
|
+
registry.register(new RejectGroupInvitationAction(api));
|
|
1872
|
+
registry.register(new GetGroupFilesAction(api));
|
|
1873
|
+
registry.register(new MoveGroupFileAction(api));
|
|
1874
|
+
registry.register(new RenameGroupFileAction(api));
|
|
1875
|
+
registry.register(new DeleteGroupFileAction(api));
|
|
1876
|
+
registry.register(new PersistGroupFileAction(api));
|
|
1877
|
+
registry.register(new CreateGroupFolderAction(api));
|
|
1878
|
+
registry.register(new DeleteGroupFolderAction(api));
|
|
1879
|
+
return registry;
|
|
1880
|
+
}
|
|
1881
|
+
//#endregion
|
|
1882
|
+
//#region src/milky/helper/notice.ts
|
|
1883
|
+
/** 毫秒 → 秒(Unix 时间戳)。 */
|
|
1884
|
+
const MS_TO_SEC = 1e3;
|
|
1885
|
+
/** uid → uin(core 共享解析;ctx 携带映射)。 */
|
|
1886
|
+
function toUin(uid, ctx) {
|
|
1887
|
+
return resolveUin(uid, ctx.uidToUin);
|
|
1888
|
+
}
|
|
1889
|
+
/** 事件信封公共字段。 */
|
|
1890
|
+
function envelope(msg, ctx, eventType, data) {
|
|
1891
|
+
return {
|
|
1892
|
+
time: Math.floor(Number(msg.msgTime) / MS_TO_SEC),
|
|
1893
|
+
self_id: Number(ctx.selfUin),
|
|
1894
|
+
event_type: eventType,
|
|
1895
|
+
data
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
/** 单个 grayTip → Milky notice 事件(null = 不可翻译)。 */
|
|
1899
|
+
function translateGrayTip(msg, g, ctx) {
|
|
1900
|
+
if (g.aioOpGrayTipElement !== void 0) return aioOpToEvent(msg, g, ctx);
|
|
1901
|
+
if (g.subElementType === GrayTipSubType.GROUP) return groupElementToEvent(msg, g.groupElement, ctx);
|
|
1902
|
+
return null;
|
|
1903
|
+
}
|
|
1904
|
+
/** 群成员变动类映射器表(键 = TipGroupElementType;缺项 = 不可翻译返回 null)。 */
|
|
1905
|
+
const GROUP_CHANGE_MAPPERS = {
|
|
1906
|
+
[TipGroupElementType.MEMBER_ADD]: toMemberIncrease,
|
|
1907
|
+
[TipGroupElementType.QUIT]: toMemberDecrease,
|
|
1908
|
+
[TipGroupElementType.SHUT_UP]: toShutUpEvent,
|
|
1909
|
+
[TipGroupElementType.GROUP_NAME_MODIFIED]: toNameChange,
|
|
1910
|
+
[TipGroupElementType.DISBANDED]: toDisband
|
|
1911
|
+
};
|
|
1912
|
+
function groupElementToEvent(msg, grp, ctx) {
|
|
1913
|
+
if (grp === void 0 || grp.type === void 0) return null;
|
|
1914
|
+
const mapper = GROUP_CHANGE_MAPPERS[grp.type];
|
|
1915
|
+
if (mapper === void 0) return null;
|
|
1916
|
+
return mapper(msg, groupChangeCommon(grp, Number(msg.peerUid)), grp, ctx);
|
|
1917
|
+
}
|
|
1918
|
+
/** 禁言载荷优先(shutUp.member/admin 覆盖顶层 memberUid/adminUid)。 */
|
|
1919
|
+
function groupChangeCommon(grp, groupId) {
|
|
1920
|
+
return {
|
|
1921
|
+
groupId,
|
|
1922
|
+
memberUid: grp.shutUp?.member?.uid ?? grp.memberUid ?? "",
|
|
1923
|
+
adminUid: grp.shutUp?.admin?.uid ?? grp.adminUid ?? ""
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
/** 群成员增加(管理员同意 → operator_id;邀请入群 → invitor_id)。 */
|
|
1927
|
+
function toMemberIncrease(msg, common, _grp, ctx) {
|
|
1928
|
+
const { groupId, memberUid, adminUid } = common;
|
|
1929
|
+
return envelope(msg, ctx, "group_member_increase", {
|
|
1930
|
+
group_id: groupId,
|
|
1931
|
+
user_id: toUin(memberUid, ctx),
|
|
1932
|
+
operator_id: adminUid !== "" && adminUid === memberUid ? toUin(adminUid, ctx) : null,
|
|
1933
|
+
invitor_id: adminUid !== "" && adminUid !== memberUid ? toUin(adminUid, ctx) : null
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
/** 群成员减少(操作者与本人不同 = 被踢;主动退群无操作者)。 */
|
|
1937
|
+
function toMemberDecrease(msg, common, _grp, ctx) {
|
|
1938
|
+
const { groupId, memberUid, adminUid } = common;
|
|
1939
|
+
return envelope(msg, ctx, "group_member_decrease", {
|
|
1940
|
+
group_id: groupId,
|
|
1941
|
+
user_id: toUin(memberUid, ctx),
|
|
1942
|
+
operator_id: adminUid !== "" && adminUid !== memberUid ? toUin(adminUid, ctx) : null
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
/** 禁言(member 空 = 全员禁言口径;is_mute = 是否处于禁言态)。 */
|
|
1946
|
+
function toShutUpEvent(msg, common, grp, ctx) {
|
|
1947
|
+
const { groupId, memberUid, adminUid } = common;
|
|
1948
|
+
const duration = Number(grp.shutUp?.duration ?? 0);
|
|
1949
|
+
if (memberUid === "") return envelope(msg, ctx, "group_whole_mute", {
|
|
1950
|
+
group_id: groupId,
|
|
1951
|
+
operator_id: toUin(adminUid, ctx),
|
|
1952
|
+
is_mute: duration > 0
|
|
1953
|
+
});
|
|
1954
|
+
return envelope(msg, ctx, "group_mute", {
|
|
1955
|
+
group_id: groupId,
|
|
1956
|
+
user_id: toUin(memberUid, ctx),
|
|
1957
|
+
operator_id: toUin(adminUid, ctx),
|
|
1958
|
+
duration
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
/** 改名 / 解散公共守卫:操作者 uid 不可得时不产出(schema operator_id 下限)。 */
|
|
1962
|
+
function operatorOf(common) {
|
|
1963
|
+
return common.adminUid !== "" ? common.adminUid : common.memberUid;
|
|
1964
|
+
}
|
|
1965
|
+
function toNameChange(msg, common, grp, ctx) {
|
|
1966
|
+
const operatorUid = operatorOf(common);
|
|
1967
|
+
if (operatorUid === "") return null;
|
|
1968
|
+
return envelope(msg, ctx, "group_name_change", {
|
|
1969
|
+
group_id: common.groupId,
|
|
1970
|
+
new_group_name: grp.groupName ?? "",
|
|
1971
|
+
operator_id: toUin(operatorUid, ctx)
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
function toDisband(msg, common, _grp, ctx) {
|
|
1975
|
+
const operatorUid = operatorOf(common);
|
|
1976
|
+
if (operatorUid === "") return null;
|
|
1977
|
+
return envelope(msg, ctx, "group_disband", {
|
|
1978
|
+
group_id: common.groupId,
|
|
1979
|
+
operator_id: toUin(operatorUid, ctx)
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
/**
|
|
1983
|
+
* aioOp → 戳一戳事件(friend_nudge / group_nudge)。
|
|
1984
|
+
* aioOp 载荷完整字段形状未经实测(已证字段仅 operateType/peerUid):
|
|
1985
|
+
* sender 取消息发送者、receiver 取 aioOp.peerUid(口径与 OB11 poke 一致,待校准);
|
|
1986
|
+
* display_* 字段无来源,恒空串;始终打 raw 日志积累校准数据。
|
|
1987
|
+
*/
|
|
1988
|
+
function aioOpToEvent(msg, g, ctx) {
|
|
1989
|
+
const aioOp = g.aioOpGrayTipElement;
|
|
1990
|
+
ctx.logger?.warn({ raw: JSON.stringify(g)?.slice(0, 2e3) }, "milky: aioOp grayTip raw(戳一戳待真实事件校准)");
|
|
1991
|
+
if (msg.chatType === ChatType.GROUP) return envelope(msg, ctx, "group_nudge", {
|
|
1992
|
+
group_id: Number(msg.peerUid),
|
|
1993
|
+
sender_id: toUin(msg.senderUid, ctx),
|
|
1994
|
+
receiver_id: toUin(aioOp?.peerUid ?? "", ctx),
|
|
1995
|
+
display_action: "",
|
|
1996
|
+
display_suffix: "",
|
|
1997
|
+
display_action_img_url: ""
|
|
1998
|
+
});
|
|
1999
|
+
const senderUin = toUin(msg.senderUid, ctx);
|
|
2000
|
+
return envelope(msg, ctx, "friend_nudge", {
|
|
2001
|
+
user_id: senderUin,
|
|
2002
|
+
is_self_send: senderUin === Number(ctx.selfUin),
|
|
2003
|
+
is_self_receive: senderUin !== Number(ctx.selfUin),
|
|
2004
|
+
display_action: "",
|
|
2005
|
+
display_suffix: "",
|
|
2006
|
+
display_action_img_url: ""
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* RawMessage → Milky notice 事件(无 grayTip / 不可翻译返回 null)。
|
|
2011
|
+
* 未知子类型打 raw JSON 日志(JSON/BUDDY/ESSENCE/FILE 等——校准数据积累入口)。
|
|
2012
|
+
*/
|
|
2013
|
+
function toMilkyNoticeEvent(msg, ctx) {
|
|
2014
|
+
for (const el of msg.elements) {
|
|
2015
|
+
const g = el.grayTipElement;
|
|
2016
|
+
if (g === void 0) continue;
|
|
2017
|
+
const event = translateGrayTip(msg, g, ctx);
|
|
2018
|
+
if (event !== null) return event;
|
|
2019
|
+
}
|
|
2020
|
+
const untranslated = firstUntranslatedGrayTip(msg);
|
|
2021
|
+
if (untranslated !== void 0) ctx.logger?.warn({
|
|
2022
|
+
subType: untranslated.subElementType,
|
|
2023
|
+
raw: JSON.stringify(untranslated)?.slice(0, 2e3)
|
|
2024
|
+
}, "milky: 未翻译的 grayTip 子类型(raw 校准数据)");
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
//#endregion
|
|
2028
|
+
//#region src/milky/helper/seq-index.ts
|
|
2029
|
+
/** 单方向最大条目数(测试用例引用容量语义)。 */
|
|
2030
|
+
const MAX_ENTRIES = 4096;
|
|
2031
|
+
var MessageSeqIndex = class {
|
|
2032
|
+
/** `${chatType}:${peerUid}:${seq}` → msgId。 */
|
|
2033
|
+
seqToMsg = /* @__PURE__ */ new Map();
|
|
2034
|
+
/** msgId → seq 元信息。 */
|
|
2035
|
+
msgToMeta = /* @__PURE__ */ new Map();
|
|
2036
|
+
/** 合并转发 resId → 定位信息(P1:get_forwarded_messages)。 */
|
|
2037
|
+
forwards = /* @__PURE__ */ new Map();
|
|
2038
|
+
/** 接收链路登记(一条消息双向写入;重复 msgId 覆盖)。 */
|
|
2039
|
+
record(msg) {
|
|
2040
|
+
const seq = Number(msg.msgSeq);
|
|
2041
|
+
if (!Number.isFinite(seq) || seq < 0) return;
|
|
2042
|
+
this.seqToMsg.set(this.key(msg.chatType, msg.peerUid, seq), msg.msgId);
|
|
2043
|
+
this.msgToMeta.set(msg.msgId, {
|
|
2044
|
+
seq,
|
|
2045
|
+
senderUin: msg.senderUin,
|
|
2046
|
+
timeSec: Math.floor(Number(msg.msgTime) / 1e3)
|
|
2047
|
+
});
|
|
2048
|
+
this.evict();
|
|
2049
|
+
}
|
|
2050
|
+
/** seq → msgId(反查;未登记返回 undefined)。 */
|
|
2051
|
+
resolveMsgId(chatType, peerUid, seq) {
|
|
2052
|
+
return this.seqToMsg.get(this.key(chatType, peerUid, seq));
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* 批量回填 reply 引用元信息(msgId → Milky reply 段 data 所需三元组)。
|
|
2056
|
+
* 未命中的 id 不进表(翻译层按 0 兜底)。
|
|
2057
|
+
*/
|
|
2058
|
+
collectReplyMeta(msgIds) {
|
|
2059
|
+
const out = /* @__PURE__ */ new Map();
|
|
2060
|
+
for (const id of msgIds) {
|
|
2061
|
+
const m = this.msgToMeta.get(id);
|
|
2062
|
+
if (m !== void 0) out.set(id, {
|
|
2063
|
+
messageSeq: m.seq,
|
|
2064
|
+
senderId: Number(m.senderUin) || 0,
|
|
2065
|
+
time: m.timeSec
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
return out;
|
|
2069
|
+
}
|
|
2070
|
+
/** key 规则(chatType 数值 + peerUid + seq;peerUid 群聊=群号,C2C=uid)。 */
|
|
2071
|
+
key(chatType, peerUid, seq) {
|
|
2072
|
+
return `${chatType}:${peerUid}:${seq}`;
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* 登记合并转发(接收链路:消息含 multiForwardMsgElement 时调用)。
|
|
2076
|
+
* resId 是 Milky forward_id 的来源;同一 resId 重复覆盖。
|
|
2077
|
+
*/
|
|
2078
|
+
recordForward(resId, entry) {
|
|
2079
|
+
if (resId === "") return;
|
|
2080
|
+
this.forwards.set(resId, entry);
|
|
2081
|
+
while (this.forwards.size > MAX_ENTRIES) {
|
|
2082
|
+
const oldest = this.forwards.keys().next().value;
|
|
2083
|
+
if (oldest === void 0) break;
|
|
2084
|
+
this.forwards.delete(oldest);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
/** resId → 转发定位(get_forwarded_messages;未登记返回 undefined)。 */
|
|
2088
|
+
resolveForward(resId) {
|
|
2089
|
+
return this.forwards.get(resId);
|
|
2090
|
+
}
|
|
2091
|
+
/** FIFO 淘汰到容量上限内。 */
|
|
2092
|
+
evict() {
|
|
2093
|
+
while (this.seqToMsg.size > MAX_ENTRIES) {
|
|
2094
|
+
const oldest = this.seqToMsg.keys().next().value;
|
|
2095
|
+
if (oldest === void 0) break;
|
|
2096
|
+
this.seqToMsg.delete(oldest);
|
|
2097
|
+
}
|
|
2098
|
+
while (this.msgToMeta.size > MAX_ENTRIES) {
|
|
2099
|
+
const oldest = this.msgToMeta.keys().next().value;
|
|
2100
|
+
if (oldest === void 0) break;
|
|
2101
|
+
this.msgToMeta.delete(oldest);
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
};
|
|
2105
|
+
//#endregion
|
|
2106
|
+
//#region src/milky/transport.ts
|
|
2107
|
+
/** /event 路径(规范固定)。 */
|
|
2108
|
+
const EVENT_PATH = "/event";
|
|
2109
|
+
/** /api/ 前缀 + 动作名(小写字母开头,小写/数字/下划线)。 */
|
|
2110
|
+
const API_PATH_RE = /^\/api\/([a-z][a-z0-9_]*)$/;
|
|
2111
|
+
/** Bearer 前缀。 */
|
|
2112
|
+
const BEARER_PREFIX = "Bearer ";
|
|
2113
|
+
/**
|
|
2114
|
+
* Milky 事件枢纽:SSE 客户端 + WS 客户端集合,send 即广播。
|
|
2115
|
+
* 传输适配器面(open/close/send,EventBroadcaster 多态派发)——生命周期随
|
|
2116
|
+
* HttpServer。连接注册经 attachTo 挂到 HttpServer 注入点(本类内部闭环);
|
|
2117
|
+
* WS 入帧忽略(规范:/event 为纯事件推送通道)。
|
|
2118
|
+
*/
|
|
2119
|
+
var MilkyEventHub = class {
|
|
2120
|
+
sseClients = /* @__PURE__ */ new Set();
|
|
2121
|
+
sockets = /* @__PURE__ */ new Set();
|
|
2122
|
+
/** 把 /event 的 SSE(onRawRequest)与 WS 升级(onUpgrade)挂到 HTTP server。 */
|
|
2123
|
+
attachTo(server, config) {
|
|
2124
|
+
server.onRawRequest = (ctx) => {
|
|
2125
|
+
if (ctx.path !== EVENT_PATH || ctx.method !== "GET") return;
|
|
2126
|
+
if (!config.eventModes.includes("sse")) return new Response("event mode sse disabled", { status: 404 });
|
|
2127
|
+
return createSseResponse((client) => {
|
|
2128
|
+
this.sseClients.add(client);
|
|
2129
|
+
return () => {
|
|
2130
|
+
this.sseClients.delete(client);
|
|
2131
|
+
};
|
|
2132
|
+
});
|
|
2133
|
+
};
|
|
2134
|
+
server.onUpgrade = (req, accept, reject) => {
|
|
2135
|
+
if (pathnameOf(req.url) !== EVENT_PATH) return;
|
|
2136
|
+
if (!config.eventModes.includes("ws")) {
|
|
2137
|
+
reject(404, "event mode ws disabled");
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
if (config.token !== "" && !tokenMatches(req, config.token)) {
|
|
2141
|
+
reject(401, "unauthorized");
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
accept((socket) => {
|
|
2145
|
+
this.sockets.add(socket);
|
|
2146
|
+
const remove = () => {
|
|
2147
|
+
this.sockets.delete(socket);
|
|
2148
|
+
};
|
|
2149
|
+
socket.on("close", remove);
|
|
2150
|
+
socket.on("error", remove);
|
|
2151
|
+
});
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
/** 广播事件(EventBroadcaster emit 入口)。 */
|
|
2155
|
+
send(event) {
|
|
2156
|
+
const json = JSON.stringify(event);
|
|
2157
|
+
for (const client of this.sseClients) client.send(json, "milky_event");
|
|
2158
|
+
for (const socket of this.sockets) if (socket.readyState === WebSocket.OPEN) socket.send(json);
|
|
2159
|
+
}
|
|
2160
|
+
open() {}
|
|
2161
|
+
async close() {
|
|
2162
|
+
for (const client of this.sseClients) client.close();
|
|
2163
|
+
this.sseClients.clear();
|
|
2164
|
+
for (const socket of this.sockets) socket.close(1001, "server shutdown");
|
|
2165
|
+
this.sockets.clear();
|
|
2166
|
+
}
|
|
2167
|
+
onRequest;
|
|
2168
|
+
};
|
|
2169
|
+
/** 按配置装配 Milky 传输。 */
|
|
2170
|
+
function assembleMilkyTransports(opts) {
|
|
2171
|
+
const { config, broadcaster, registry, logger } = opts;
|
|
2172
|
+
const hub = new MilkyEventHub();
|
|
2173
|
+
const token = config.token;
|
|
2174
|
+
const authorize = token !== "" ? makeMilkyAuthorize(token) : void 0;
|
|
2175
|
+
const server = new HttpServer({
|
|
2176
|
+
host: config.host,
|
|
2177
|
+
port: config.port,
|
|
2178
|
+
...authorize !== void 0 ? { authorize } : {}
|
|
2179
|
+
});
|
|
2180
|
+
hub.attachTo(server, config);
|
|
2181
|
+
server.onPathRequest = (ctx, req, respond) => {
|
|
2182
|
+
handleMilkyHttpRequest(ctx, req, respond, registry);
|
|
2183
|
+
};
|
|
2184
|
+
const adapters = [server, hub];
|
|
2185
|
+
for (const url of config.webhooks) {
|
|
2186
|
+
const client = new HttpClient({
|
|
2187
|
+
url,
|
|
2188
|
+
...token !== "" ? { headers: { authorization: `${BEARER_PREFIX}${token}` } } : {},
|
|
2189
|
+
timeoutMs: 5e3,
|
|
2190
|
+
onError: (err) => {
|
|
2191
|
+
logger?.warn({
|
|
2192
|
+
url,
|
|
2193
|
+
err: String(err)
|
|
2194
|
+
}, "milky: webhook 推送失败");
|
|
2195
|
+
}
|
|
2196
|
+
});
|
|
2197
|
+
adapters.push(client);
|
|
2198
|
+
}
|
|
2199
|
+
for (const adapter of adapters) broadcaster.register(adapter);
|
|
2200
|
+
return {
|
|
2201
|
+
adapters,
|
|
2202
|
+
close: async () => {
|
|
2203
|
+
await server.close();
|
|
2204
|
+
await hub.close();
|
|
2205
|
+
for (const adapter of adapters) broadcaster.unregister(adapter);
|
|
2206
|
+
}
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
/** HTTP /api 分发(规范信封 + 404/415 语义)。 */
|
|
2210
|
+
function handleMilkyHttpRequest(ctx, req, respond, registry) {
|
|
2211
|
+
if (ctx.method !== "POST") {
|
|
2212
|
+
respond(void 0, 404);
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
const match = API_PATH_RE.exec(ctx.path);
|
|
2216
|
+
if (match === null) {
|
|
2217
|
+
respond(void 0, 404);
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
const contentType = ctx.headers?.["content-type"];
|
|
2221
|
+
if (contentType !== void 0 && !contentType.toLowerCase().includes("application/json")) {
|
|
2222
|
+
respond(void 0, 415);
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
const action = registry.get(match[1] ?? "");
|
|
2226
|
+
if (action === void 0) {
|
|
2227
|
+
respond(void 0, 404);
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
action.handle(req).then((result) => {
|
|
2231
|
+
if (result.status === "ok") respond({
|
|
2232
|
+
status: "ok",
|
|
2233
|
+
retcode: 0,
|
|
2234
|
+
data: result.data ?? {}
|
|
2235
|
+
});
|
|
2236
|
+
else respond({
|
|
2237
|
+
status: "failed",
|
|
2238
|
+
retcode: result.retcode,
|
|
2239
|
+
data: null,
|
|
2240
|
+
message: result.message
|
|
2241
|
+
});
|
|
2242
|
+
}).catch(() => {
|
|
2243
|
+
respond({
|
|
2244
|
+
status: "failed",
|
|
2245
|
+
retcode: -500,
|
|
2246
|
+
data: null,
|
|
2247
|
+
message: "internal error"
|
|
2248
|
+
});
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
/** 鉴权钩子:Bearer 头或 access_token 查询参数(规范允许 SSE/WS 查询参数兜底)。 */
|
|
2252
|
+
function makeMilkyAuthorize(token) {
|
|
2253
|
+
return (ctx) => tokenMatches(ctx, token);
|
|
2254
|
+
}
|
|
2255
|
+
/** 请求凭据匹配(头优先,查询参数兜底)。 */
|
|
2256
|
+
function tokenMatches(ctx, token) {
|
|
2257
|
+
const auth = ctx.headers["authorization"] ?? ctx.headers["Authorization"];
|
|
2258
|
+
if (auth?.startsWith(BEARER_PREFIX)) return auth.slice(7) === token;
|
|
2259
|
+
try {
|
|
2260
|
+
return new URL(ctx.url, "http://localhost").searchParams.get("access_token") === token;
|
|
2261
|
+
} catch {
|
|
2262
|
+
return false;
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
/** URL 路径部分(无 scheme 相对 url 容错)。 */
|
|
2266
|
+
function pathnameOf(url) {
|
|
2267
|
+
try {
|
|
2268
|
+
return new URL(url, "http://localhost").pathname;
|
|
2269
|
+
} catch {
|
|
2270
|
+
return url.split("?")[0] ?? "";
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
//#endregion
|
|
2274
|
+
//#region src/milky/adapter.ts
|
|
2275
|
+
var NapukettoMilkyAdapter = class extends BaseProtocolAdapter {
|
|
2276
|
+
protocol = "milky";
|
|
2277
|
+
msgChannel;
|
|
2278
|
+
groupChannel;
|
|
2279
|
+
buddyChannel;
|
|
2280
|
+
calibLogger;
|
|
2281
|
+
api;
|
|
2282
|
+
seqIndex = new MessageSeqIndex();
|
|
2283
|
+
/** 动作注册表(公共只读:装配方/测试枚举)。 */
|
|
2284
|
+
registry;
|
|
2285
|
+
unsubscribes = [];
|
|
2286
|
+
stopTransports = null;
|
|
2287
|
+
constructor(opts) {
|
|
2288
|
+
super({
|
|
2289
|
+
config: opts.config,
|
|
2290
|
+
broadcaster: opts.broadcaster,
|
|
2291
|
+
hooks: {
|
|
2292
|
+
onStart: (config) => this.startTransports(config),
|
|
2293
|
+
onStop: () => this.stopAll(),
|
|
2294
|
+
onReload: (config) => this.reloadTransports(config)
|
|
2295
|
+
}
|
|
2296
|
+
});
|
|
2297
|
+
this.msgChannel = opts.msgChannel;
|
|
2298
|
+
this.groupChannel = opts.groupChannel;
|
|
2299
|
+
this.buddyChannel = opts.buddyChannel;
|
|
2300
|
+
this.calibLogger = opts.logger;
|
|
2301
|
+
this.api = new MilkyApi(opts, this.seqIndex);
|
|
2302
|
+
this.registry = createMilkyActionRegistry({ api: this.api });
|
|
2303
|
+
}
|
|
2304
|
+
/** 启动传输 + 订阅(enabled=false 空转:不订阅不起传输)。 */
|
|
2305
|
+
async startTransports(config) {
|
|
2306
|
+
if (!config.enabled) {
|
|
2307
|
+
this.calibLogger?.info({}, "milky: 未启用(enabled=false),跳过装配");
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
const broadcaster = this.getBroadcaster();
|
|
2311
|
+
if (broadcaster !== void 0) {
|
|
2312
|
+
const set = assembleMilkyTransports({
|
|
2313
|
+
config,
|
|
2314
|
+
broadcaster,
|
|
2315
|
+
registry: this.registry,
|
|
2316
|
+
...this.calibLogger !== void 0 ? { logger: this.calibLogger } : {}
|
|
2317
|
+
});
|
|
2318
|
+
this.stopTransports = set.close;
|
|
2319
|
+
}
|
|
2320
|
+
this.subscribe();
|
|
2321
|
+
this.calibLogger?.info({
|
|
2322
|
+
host: config.host,
|
|
2323
|
+
port: config.port,
|
|
2324
|
+
modes: config.eventModes
|
|
2325
|
+
}, "milky: adapter started");
|
|
2326
|
+
}
|
|
2327
|
+
/** 热更新:停止重建(enabled 切换生效)。 */
|
|
2328
|
+
async reloadTransports(config) {
|
|
2329
|
+
await this.stopAll();
|
|
2330
|
+
await this.startTransports(config);
|
|
2331
|
+
}
|
|
2332
|
+
/** 停止:退订 + 关传输。 */
|
|
2333
|
+
async stopAll() {
|
|
2334
|
+
this.unsubscribeAll();
|
|
2335
|
+
if (this.stopTransports !== null) {
|
|
2336
|
+
await this.stopTransports();
|
|
2337
|
+
this.stopTransports = null;
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
/** 订阅 kernel 事件(消息 + 群通知 + 好友请求,幂等)。 */
|
|
2341
|
+
subscribe() {
|
|
2342
|
+
if (this.unsubscribes.length > 0) return;
|
|
2343
|
+
this.unsubscribes.push(subscribeRawMessages(this.msgChannel, (msg) => {
|
|
2344
|
+
this.handleIncoming(msg);
|
|
2345
|
+
}));
|
|
2346
|
+
if (this.groupChannel !== void 0) {
|
|
2347
|
+
const groupChannel = this.groupChannel;
|
|
2348
|
+
this.unsubscribes.push(groupChannel.on("Group/onGroupNotifiesUpdated", (doubt, notifies) => {
|
|
2349
|
+
this.handleGroupNotifies(doubt, notifies);
|
|
2350
|
+
}));
|
|
2351
|
+
}
|
|
2352
|
+
if (this.buddyChannel !== void 0) {
|
|
2353
|
+
const buddyChannel = this.buddyChannel;
|
|
2354
|
+
this.unsubscribes.push(buddyChannel.on("Buddy/onBuddyReqChange", (arg) => {
|
|
2355
|
+
this.handleBuddyReqs(arg);
|
|
2356
|
+
}));
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
/** 退订(幂等)。 */
|
|
2360
|
+
unsubscribeAll() {
|
|
2361
|
+
for (const off of this.unsubscribes) off();
|
|
2362
|
+
this.unsubscribes = [];
|
|
2363
|
+
}
|
|
2364
|
+
/** 单条消息:seq 登记 → grayTip→notice / 群文件上传 / message_receive(自消息不上报)。 */
|
|
2365
|
+
async handleIncoming(msg) {
|
|
2366
|
+
this.seqIndex.record(msg);
|
|
2367
|
+
this.recordForward(msg);
|
|
2368
|
+
if (hasGrayTip(msg)) {
|
|
2369
|
+
await this.broadcastNotice(msg);
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
const upload = toGroupFileUploadEvent(msg, this.api.self.uin);
|
|
2373
|
+
if (upload !== null) {
|
|
2374
|
+
this.broadcastEvent(upload);
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
if (String(msg.senderUin) === this.api.self.uin) return;
|
|
2378
|
+
this.broadcastEvent(await this.buildMessageReceiveEvent(msg));
|
|
2379
|
+
}
|
|
2380
|
+
/** grayTip → notice 事件广播(REVOKE 走 message_recall,其余走 notice 翻译)。 */
|
|
2381
|
+
async broadcastNotice(msg) {
|
|
2382
|
+
const uidToUin = await this.resolveUidToUin(collectGrayTipUids(msg));
|
|
2383
|
+
const event = findRevokeElement(msg) !== void 0 ? toMessageRecallEvent(msg, {
|
|
2384
|
+
selfUin: this.api.self.uin,
|
|
2385
|
+
...uidToUin !== void 0 ? { uidToUin } : {}
|
|
2386
|
+
}) : toMilkyNoticeEvent(msg, this.noticeCtx(uidToUin));
|
|
2387
|
+
if (event !== null) this.broadcastEvent(event);
|
|
2388
|
+
}
|
|
2389
|
+
/** notice 翻译上下文(uidToUin 缺省空 Map;logger 透传校准日志)。 */
|
|
2390
|
+
noticeCtx(uidToUin) {
|
|
2391
|
+
return {
|
|
2392
|
+
selfUin: this.api.self.uin,
|
|
2393
|
+
...uidToUin !== void 0 ? { uidToUin } : { uidToUin: /* @__PURE__ */ new Map() },
|
|
2394
|
+
...this.calibLogger !== void 0 ? { logger: this.calibLogger } : {}
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
/** 群通知通道:入群请求 / 成员邀请 / 邀请自身(uid→uin 批量后翻译)。 */
|
|
2398
|
+
async handleGroupNotifies(doubt, notifies) {
|
|
2399
|
+
const ctx = await this.requestCtx(collectNotifyUids(notifies));
|
|
2400
|
+
for (const notify of notifies) {
|
|
2401
|
+
const event = toGroupRequestEvent(notify, doubt, ctx);
|
|
2402
|
+
if (event !== null) this.broadcastEvent(event);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
/** 好友请求通道:Buddy/onBuddyReqChange(防御收窄,未知形状 raw 日志)。 */
|
|
2406
|
+
handleBuddyReqs(arg) {
|
|
2407
|
+
const reqs = narrowBuddyReqs(arg);
|
|
2408
|
+
if (reqs === null) {
|
|
2409
|
+
this.calibLogger?.warn({ raw: truncate(JSON.stringify(arg)) }, "milky: onBuddyReqChange 未知参数形状");
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
(async () => {
|
|
2413
|
+
const ctx = await this.requestCtx(reqs.map((req) => req.friendUid));
|
|
2414
|
+
for (const req of reqs) this.broadcastEvent(toFriendRequestEvent(req, ctx));
|
|
2415
|
+
})();
|
|
2416
|
+
}
|
|
2417
|
+
/** request 翻译上下文(uid→uin 批量转换)。 */
|
|
2418
|
+
async requestCtx(uids) {
|
|
2419
|
+
const uidToUin = await this.resolveUidToUin(uids);
|
|
2420
|
+
return {
|
|
2421
|
+
selfUin: this.api.self.uin,
|
|
2422
|
+
...uidToUin !== void 0 ? { uidToUin } : { uidToUin: /* @__PURE__ */ new Map() }
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
/** message_receive 组装:at uid→uin + reply 回填 + 群实体回填 + 转发段(全部 fail-soft)。 */
|
|
2426
|
+
async buildMessageReceiveEvent(msg) {
|
|
2427
|
+
const elements = toCanonicalElements(msg);
|
|
2428
|
+
const ctx = { selfUin: this.api.self.uin };
|
|
2429
|
+
await this.fillMentionUids(elements, ctx);
|
|
2430
|
+
this.fillReplyMeta(elements, ctx);
|
|
2431
|
+
await this.fillGroupEntities(msg, ctx);
|
|
2432
|
+
const forward = msg.elements.find((el) => el.multiForwardMsgElement !== void 0)?.multiForwardMsgElement;
|
|
2433
|
+
if (forward !== void 0) ctx.forwardSegments = [incomingForwardSegment(forward.resId, forward.fileName)];
|
|
2434
|
+
return toMessageReceiveEvent(msg, ctx);
|
|
2435
|
+
}
|
|
2436
|
+
/** at uid → uin 批量转换(mention.user_id 用;失败 mention 段跳过)。 */
|
|
2437
|
+
async fillMentionUids(elements, ctx) {
|
|
2438
|
+
const atUids = elements.filter((el) => el.type === "at" && el.target !== "all").map((el) => el.target);
|
|
2439
|
+
if (atUids.length === 0) return;
|
|
2440
|
+
try {
|
|
2441
|
+
ctx.uidToUin = await this.api.uidToUin(atUids);
|
|
2442
|
+
} catch {}
|
|
2443
|
+
}
|
|
2444
|
+
/** reply 引用元信息回填(seqIndex 命中即用;未命中不强查)。 */
|
|
2445
|
+
fillReplyMeta(elements, ctx) {
|
|
2446
|
+
const replyIds = elements.filter((el) => el.type === "reply").map((el) => el.messageId);
|
|
2447
|
+
const meta = this.seqIndex.collectReplyMeta(replyIds);
|
|
2448
|
+
if (meta.size > 0) ctx.replyMeta = meta;
|
|
2449
|
+
}
|
|
2450
|
+
/** 群场景实体回填(GroupCache 缓存优先,失败最小实体兜底)。 */
|
|
2451
|
+
async fillGroupEntities(msg, ctx) {
|
|
2452
|
+
if (msg.chatType !== ChatType.GROUP || this.api.groupCache === void 0) return;
|
|
2453
|
+
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)]);
|
|
2454
|
+
ctx.groupEntity = toGroupEntity(detail, msg.peerUid);
|
|
2455
|
+
ctx.groupMemberEntity = toGroupMemberEntity(member, msg.peerUid, msg.senderUin);
|
|
2456
|
+
}
|
|
2457
|
+
/** 转发 resId → 定位登记(get_forwarded_messages 反查用)。 */
|
|
2458
|
+
recordForward(msg) {
|
|
2459
|
+
const forward = msg.elements.find((el) => el.multiForwardMsgElement !== void 0)?.multiForwardMsgElement;
|
|
2460
|
+
if (forward !== void 0 && forward.resId !== "") this.seqIndex.recordForward(forward.resId, {
|
|
2461
|
+
chatType: msg.chatType,
|
|
2462
|
+
peerUid: msg.peerUid,
|
|
2463
|
+
msgId: msg.msgId
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
/** uid 批量 → uin(空集/失败返回 undefined 语义:调用方用空 Map)。 */
|
|
2467
|
+
resolveUidToUin(uids) {
|
|
2468
|
+
const unique = [...new Set(uids.filter((uid) => uid !== ""))];
|
|
2469
|
+
if (unique.length === 0) return Promise.resolve(void 0);
|
|
2470
|
+
return this.api.uidToUin(unique).catch(() => void 0);
|
|
2471
|
+
}
|
|
2472
|
+
};
|
|
2473
|
+
/** 是否含 grayTip 元素(与 OB11 收链路同判据)。 */
|
|
2474
|
+
function hasGrayTip(msg) {
|
|
2475
|
+
return msg.elements.some((el) => el.grayTipElement !== void 0);
|
|
2476
|
+
}
|
|
2477
|
+
/** 撤回 grayTip 元素(非撤回消息返回 undefined)。 */
|
|
2478
|
+
function findRevokeElement(msg) {
|
|
2479
|
+
return msg.elements.find((el) => el.grayTipElement?.revokeElement !== void 0)?.grayTipElement?.revokeElement;
|
|
2480
|
+
}
|
|
2481
|
+
/** 字符串截断(raw 日志防刷屏)。 */
|
|
2482
|
+
function truncate(raw) {
|
|
2483
|
+
return raw === void 0 ? "" : raw.slice(0, 2e3);
|
|
2484
|
+
}
|
|
2485
|
+
//#endregion
|
|
2486
|
+
//#region src/milky/config.ts
|
|
2487
|
+
/**
|
|
2488
|
+
* Milky 协议配置(zod schema,归属本包,ADR-012)
|
|
2489
|
+
*
|
|
2490
|
+
* 单一 TOML 的 `[accounts.milky]` 账号段(docs/protocols/milky.md §4):
|
|
2491
|
+
* 单端口 HTTP 模型(/api + /event 同端口),`enabled` 缺省 false = 不启用。
|
|
2492
|
+
*/
|
|
2493
|
+
/** Milky 配置 schema。 */
|
|
2494
|
+
const milkyConfigSchema = z.object({
|
|
2495
|
+
/** 启用开关(缺省 false:账号段存在但 enabled 未开 = 装配为空转)。 */
|
|
2496
|
+
enabled: z.boolean().default(false),
|
|
2497
|
+
/** HTTP 监听地址。 */
|
|
2498
|
+
host: z.string().min(1).default("127.0.0.1"),
|
|
2499
|
+
/** HTTP 监听端口(/api 与 /event 同端口)。 */
|
|
2500
|
+
port: z.number().int().min(0).max(65535).default(3001),
|
|
2501
|
+
/** access_token(空 = 不校验,仅限内网)。 */
|
|
2502
|
+
token: z.string().default(""),
|
|
2503
|
+
/** /event 允许的连接形态(缺省两者都开)。 */
|
|
2504
|
+
eventModes: z.array(z.enum(["sse", "ws"])).default(["sse", "ws"]),
|
|
2505
|
+
/** 额外事件推送地址(WebHook,可多个;协议端在请求头发 Bearer token)。 */
|
|
2506
|
+
webhooks: z.array(z.string().min(1)).default([]),
|
|
2507
|
+
/** 发送媒体 http(s) URI 下载缓存目录(缺省不下载 = http(s) 发送媒体不可用)。 */
|
|
2508
|
+
cacheDir: z.string().optional()
|
|
2509
|
+
});
|
|
2510
|
+
//#endregion
|
|
2511
|
+
export { NapukettoMilkyAdapter, milkyConfigSchema };
|
|
2512
|
+
|
|
2513
|
+
//# sourceMappingURL=index.mjs.map
|