@zhin.js/adapter-icqq 3.0.4 → 3.0.5
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/CHANGELOG.md +17 -0
- package/README.md +1 -1
- package/dist/index.js +1 -1
- package/lib/bot.d.ts +56 -12
- package/lib/bot.d.ts.map +1 -1
- package/lib/bot.js +416 -136
- package/lib/bot.js.map +1 -1
- package/lib/cq-message.d.ts +10 -0
- package/lib/cq-message.d.ts.map +1 -0
- package/lib/cq-message.js +119 -0
- package/lib/cq-message.js.map +1 -0
- package/lib/forward-msg.d.ts +27 -0
- package/lib/forward-msg.d.ts.map +1 -0
- package/lib/forward-msg.js +387 -0
- package/lib/forward-msg.js.map +1 -0
- package/lib/get-msg.d.ts +3 -0
- package/lib/get-msg.d.ts.map +1 -0
- package/lib/get-msg.js +46 -0
- package/lib/get-msg.js.map +1 -0
- package/lib/icqq-inbound.d.ts +114 -0
- package/lib/icqq-inbound.d.ts.map +1 -0
- package/lib/icqq-inbound.js +495 -0
- package/lib/icqq-inbound.js.map +1 -0
- package/lib/icqq-side-events.d.ts +34 -0
- package/lib/icqq-side-events.d.ts.map +1 -0
- package/lib/icqq-side-events.js +194 -0
- package/lib/icqq-side-events.js.map +1 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/ipc-client.d.ts +7 -2
- package/lib/ipc-client.d.ts.map +1 -1
- package/lib/ipc-client.js +74 -16
- package/lib/ipc-client.js.map +1 -1
- package/lib/protocol.d.ts +3 -10
- package/lib/protocol.d.ts.map +1 -1
- package/lib/protocol.js +2 -0
- package/lib/protocol.js.map +1 -1
- package/lib/types.d.ts +44 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/typing-indicator-example.d.ts +108 -0
- package/lib/typing-indicator-example.d.ts.map +1 -0
- package/lib/typing-indicator-example.js +220 -0
- package/lib/typing-indicator-example.js.map +1 -0
- package/lib/typing-indicator.d.ts +89 -0
- package/lib/typing-indicator.d.ts.map +1 -0
- package/lib/typing-indicator.js +237 -0
- package/lib/typing-indicator.js.map +1 -0
- package/package.json +14 -10
- package/src/bot.ts +524 -149
- package/src/cq-message.ts +120 -0
- package/src/forward-msg.ts +433 -0
- package/src/get-msg.ts +56 -0
- package/src/icqq-inbound.ts +616 -0
- package/src/icqq-side-events.ts +228 -0
- package/src/index.ts +8 -0
- package/src/ipc-client.ts +76 -16
- package/src/protocol.ts +4 -10
- package/src/types.ts +45 -0
- package/src/typing-indicator-example.ts +269 -0
- package/src/typing-indicator.ts +334 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* icqq 守护进程 IPC 入站事件归一化(post_type / message_type / message 段)
|
|
3
|
+
*/
|
|
4
|
+
import { Message, type MessageSegment, type QuotedMessagePayload } from "zhin.js";
|
|
5
|
+
import { parseCqMessage } from "./cq-message.js";
|
|
6
|
+
import { extractForwardResidFromJsonElement } from "./forward-msg.js";
|
|
7
|
+
|
|
8
|
+
/** 守护进程推送的通用事件壳 */
|
|
9
|
+
export interface IcqqIpcEventBase {
|
|
10
|
+
post_type?: string;
|
|
11
|
+
self_id?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** post_type=message 时的消息体(字段随 icqqjs/cli 演进,未知字段保留) */
|
|
15
|
+
export interface IcqqIpcMessageEvent extends IcqqIpcEventBase {
|
|
16
|
+
post_type: "message";
|
|
17
|
+
message_id?: string;
|
|
18
|
+
msg_id?: string;
|
|
19
|
+
user_id: number;
|
|
20
|
+
user_uid?: string;
|
|
21
|
+
time: number;
|
|
22
|
+
seq?: number;
|
|
23
|
+
message_type: string;
|
|
24
|
+
sub_type?: string;
|
|
25
|
+
raw_message?: string;
|
|
26
|
+
message?: IcqqMessageElement[];
|
|
27
|
+
sender?: {
|
|
28
|
+
user_id?: number;
|
|
29
|
+
user_uid?: string;
|
|
30
|
+
nickname?: string;
|
|
31
|
+
card?: string;
|
|
32
|
+
};
|
|
33
|
+
from_id?: number;
|
|
34
|
+
from_uid?: string;
|
|
35
|
+
group_id?: number;
|
|
36
|
+
to_id?: number;
|
|
37
|
+
nickname?: string;
|
|
38
|
+
/** 旧版 IPC 仅有 type 无 message_type */
|
|
39
|
+
type?: "group" | "private";
|
|
40
|
+
/** oicq Message#source:被引用回复的源消息 */
|
|
41
|
+
source?: IcqqMessageSource;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** oicq 引用源消息(与 MessageEvent.source 对齐) */
|
|
45
|
+
export interface IcqqMessageSource {
|
|
46
|
+
message_id?: string;
|
|
47
|
+
msg_id?: string;
|
|
48
|
+
seq?: number;
|
|
49
|
+
rand?: number;
|
|
50
|
+
time?: number;
|
|
51
|
+
user_id?: number;
|
|
52
|
+
group_id?: number;
|
|
53
|
+
raw_message?: string;
|
|
54
|
+
/** 元素数组,或 NT 推送的纯文本摘要 */
|
|
55
|
+
message?: IcqqMessageElement[] | string;
|
|
56
|
+
sender?: {
|
|
57
|
+
user_id?: number;
|
|
58
|
+
nickname?: string;
|
|
59
|
+
card?: string;
|
|
60
|
+
};
|
|
61
|
+
[key: string]: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type IcqqMessageElement = {
|
|
65
|
+
type: string;
|
|
66
|
+
text?: string;
|
|
67
|
+
qq?: string | number;
|
|
68
|
+
user_id?: string | number;
|
|
69
|
+
id?: string | number;
|
|
70
|
+
url?: string;
|
|
71
|
+
file?: string;
|
|
72
|
+
[key: string]: unknown;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** 归一化后供适配器使用的入站消息 */
|
|
76
|
+
export interface NormalizedIcqqInbound {
|
|
77
|
+
messageId: string;
|
|
78
|
+
idSource: "message_id" | "msg_id" | "seq" | "synthetic";
|
|
79
|
+
channelType: "group" | "private";
|
|
80
|
+
channelId: string;
|
|
81
|
+
userId: string;
|
|
82
|
+
nickname: string;
|
|
83
|
+
content: MessageSegment[];
|
|
84
|
+
rawMessage: string;
|
|
85
|
+
timestampMs: number;
|
|
86
|
+
raw: IcqqIpcMessageEvent;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const DEDUPE_TTL_MS = 120_000;
|
|
90
|
+
|
|
91
|
+
const MESSAGE_POST_TYPES = new Set(["message"]);
|
|
92
|
+
|
|
93
|
+
function isMessagePostType(postType: unknown): boolean {
|
|
94
|
+
if (typeof postType !== "string" || postType === "") return false;
|
|
95
|
+
if (MESSAGE_POST_TYPES.has(postType)) return true;
|
|
96
|
+
// 如 message.private / message.group
|
|
97
|
+
return postType.startsWith("message.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 从 IPC 事件壳中取出 OneBot/icqq 载荷(兼容 data 嵌套或字段在根级) */
|
|
101
|
+
export function unwrapIcqqIpcEventPayload(event: {
|
|
102
|
+
data?: unknown;
|
|
103
|
+
[key: string]: unknown;
|
|
104
|
+
}): unknown {
|
|
105
|
+
const nested = event.data;
|
|
106
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
|
107
|
+
const d = nested as Record<string, unknown>;
|
|
108
|
+
if (isRecognizedIcqqIpcPayload(d)) {
|
|
109
|
+
return nested;
|
|
110
|
+
}
|
|
111
|
+
for (const key of ["data", "detail", "payload", "event"]) {
|
|
112
|
+
const inner = d[key];
|
|
113
|
+
if (inner && typeof inner === "object" && !Array.isArray(inner)) {
|
|
114
|
+
return inner;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { id: _id, event: _ev, data: _data, ok: _ok, ...rest } = event;
|
|
120
|
+
if (isRecognizedIcqqIpcPayload(rest)) {
|
|
121
|
+
return rest;
|
|
122
|
+
}
|
|
123
|
+
return nested;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isRecognizedIcqqIpcPayload(d: Record<string, unknown>): boolean {
|
|
127
|
+
const pt = d.post_type;
|
|
128
|
+
if (typeof pt === "string") {
|
|
129
|
+
if (
|
|
130
|
+
isMessagePostType(pt) ||
|
|
131
|
+
pt === "notice" ||
|
|
132
|
+
pt === "request" ||
|
|
133
|
+
pt === "meta_event" ||
|
|
134
|
+
pt.startsWith("notice.") ||
|
|
135
|
+
pt.startsWith("request.") ||
|
|
136
|
+
pt.startsWith("system.")
|
|
137
|
+
) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return (
|
|
142
|
+
d.message_type != null ||
|
|
143
|
+
d.raw_message != null ||
|
|
144
|
+
d.message != null ||
|
|
145
|
+
d.type === "group" ||
|
|
146
|
+
d.type === "private" ||
|
|
147
|
+
d.notice_type != null ||
|
|
148
|
+
d.request_type != null ||
|
|
149
|
+
d.meta_event_type != null
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function resolveIcqqInboundUserId(
|
|
154
|
+
data: Record<string, unknown>,
|
|
155
|
+
): number | undefined {
|
|
156
|
+
const sender = data.sender;
|
|
157
|
+
if (sender && typeof sender === "object" && !Array.isArray(sender)) {
|
|
158
|
+
const suid = (sender as { user_id?: unknown }).user_id;
|
|
159
|
+
if (suid != null) return Number(suid);
|
|
160
|
+
}
|
|
161
|
+
if (data.user_id != null) return Number(data.user_id);
|
|
162
|
+
if (data.from_id != null) return Number(data.from_id);
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function isIcqqMessagePostType(
|
|
167
|
+
data: unknown,
|
|
168
|
+
): data is IcqqIpcMessageEvent {
|
|
169
|
+
if (!data || typeof data !== "object") return false;
|
|
170
|
+
const d = data as Record<string, unknown>;
|
|
171
|
+
if (isMessagePostType(d.post_type)) return true;
|
|
172
|
+
// 其它 post_type(notice/request 等)明确排除
|
|
173
|
+
if (d.post_type != null && d.post_type !== "") return false;
|
|
174
|
+
const hasBody =
|
|
175
|
+
typeof d.raw_message === "string" ||
|
|
176
|
+
Array.isArray(d.message) ||
|
|
177
|
+
typeof d.message_type === "string" ||
|
|
178
|
+
d.type === "group" ||
|
|
179
|
+
d.type === "private";
|
|
180
|
+
return hasBody && resolveIcqqInboundUserId(d) != null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function shouldSkipSelfInboundMessage(
|
|
184
|
+
data: IcqqIpcMessageEvent,
|
|
185
|
+
): boolean {
|
|
186
|
+
if (data.self_id == null || data.user_id == null) return false;
|
|
187
|
+
return Number(data.self_id) === Number(data.user_id);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function resolveChannelFromIcqqMessage(
|
|
191
|
+
data: IcqqIpcMessageEvent,
|
|
192
|
+
): { channelType: "group" | "private"; channelId: string } {
|
|
193
|
+
const messageType =
|
|
194
|
+
data.message_type ??
|
|
195
|
+
(data.type === "group" || data.type === "private" ? data.type : undefined);
|
|
196
|
+
|
|
197
|
+
if (messageType === "group" || data.group_id != null) {
|
|
198
|
+
return {
|
|
199
|
+
channelType: "group",
|
|
200
|
+
channelId: String(data.group_id ?? data.from_id ?? data.user_id),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
channelType: "private",
|
|
205
|
+
channelId: String(data.from_id ?? data.user_id),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function resolveIcqqInboundMessageId(
|
|
210
|
+
data: IcqqIpcMessageEvent,
|
|
211
|
+
channelId: string,
|
|
212
|
+
): { id: string; source: "message_id" | "msg_id" | "seq" | "synthetic" } {
|
|
213
|
+
if (data.message_id != null && String(data.message_id) !== "") {
|
|
214
|
+
return { id: String(data.message_id), source: "message_id" };
|
|
215
|
+
}
|
|
216
|
+
if (data.msg_id != null && String(data.msg_id) !== "") {
|
|
217
|
+
return { id: String(data.msg_id), source: "msg_id" };
|
|
218
|
+
}
|
|
219
|
+
if (data.seq != null && String(data.seq) !== "") {
|
|
220
|
+
return { id: String(data.seq), source: "seq" };
|
|
221
|
+
}
|
|
222
|
+
const uid =
|
|
223
|
+
resolveIcqqInboundUserId(data as unknown as Record<string, unknown>) ?? 0;
|
|
224
|
+
return {
|
|
225
|
+
id: `${data.time}_${uid}_${channelId}`,
|
|
226
|
+
source: "synthetic",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function icqqElementsToSegments(
|
|
231
|
+
elements: IcqqMessageElement[] | undefined,
|
|
232
|
+
): MessageSegment[] | null {
|
|
233
|
+
if (!elements?.length) return null;
|
|
234
|
+
const out: MessageSegment[] = [];
|
|
235
|
+
|
|
236
|
+
for (const el of elements) {
|
|
237
|
+
if (!el || typeof el !== "object") continue;
|
|
238
|
+
const type = el.type;
|
|
239
|
+
switch (type) {
|
|
240
|
+
case "text":
|
|
241
|
+
if (el.text != null && el.text !== "") {
|
|
242
|
+
out.push({ type: "text", data: { text: String(el.text) } });
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
245
|
+
case "at":
|
|
246
|
+
out.push({
|
|
247
|
+
type: "at",
|
|
248
|
+
data: { qq: String(el.qq ?? el.user_id ?? "") },
|
|
249
|
+
});
|
|
250
|
+
break;
|
|
251
|
+
case "face":
|
|
252
|
+
out.push({ type: "face", data: { id: Number(el.id) } });
|
|
253
|
+
break;
|
|
254
|
+
case "image":
|
|
255
|
+
out.push({
|
|
256
|
+
type: "image",
|
|
257
|
+
data: {
|
|
258
|
+
url: String(el.url ?? el.file ?? ""),
|
|
259
|
+
file: String(el.file ?? el.url ?? ""),
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
break;
|
|
263
|
+
case "forward": {
|
|
264
|
+
const forwardResid = String(
|
|
265
|
+
el.id ?? el.resid ?? el.res_id ?? el.file ?? "",
|
|
266
|
+
).trim();
|
|
267
|
+
if (forwardResid) {
|
|
268
|
+
out.push({
|
|
269
|
+
type: "forward",
|
|
270
|
+
data: { id: forwardResid, resid: forwardResid },
|
|
271
|
+
});
|
|
272
|
+
} else {
|
|
273
|
+
out.push({ type: "text", data: { text: "[聊天记录]" } });
|
|
274
|
+
}
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "light_app":
|
|
278
|
+
case "xml": {
|
|
279
|
+
const forwardResid = extractForwardResidFromJsonElement(el);
|
|
280
|
+
if (forwardResid) {
|
|
281
|
+
out.push({
|
|
282
|
+
type: "forward",
|
|
283
|
+
data: { id: forwardResid, resid: forwardResid },
|
|
284
|
+
});
|
|
285
|
+
} else if (el.text != null && el.text !== "") {
|
|
286
|
+
out.push({ type: "text", data: { text: String(el.text) } });
|
|
287
|
+
} else {
|
|
288
|
+
out.push({ type: "text", data: { text: "[聊天记录]" } });
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
case "reply": {
|
|
293
|
+
const replyId = String(el.id ?? el.message_id ?? "");
|
|
294
|
+
if (replyId) {
|
|
295
|
+
out.push({
|
|
296
|
+
type: "reply",
|
|
297
|
+
data: { id: replyId, message_id: replyId },
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
case "json": {
|
|
303
|
+
const forwardResid = extractForwardResidFromJsonElement(el);
|
|
304
|
+
if (forwardResid) {
|
|
305
|
+
out.push({
|
|
306
|
+
type: "forward",
|
|
307
|
+
data: { id: forwardResid, resid: forwardResid },
|
|
308
|
+
});
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
const replyFromJson = resolveReplyIdFromJsonElement(el);
|
|
312
|
+
if (replyFromJson) {
|
|
313
|
+
out.push({
|
|
314
|
+
type: "reply",
|
|
315
|
+
data: { id: replyFromJson, message_id: replyFromJson },
|
|
316
|
+
});
|
|
317
|
+
} else if (el.text != null && el.text !== "") {
|
|
318
|
+
out.push({ type: "text", data: { text: String(el.text) } });
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
case "record":
|
|
323
|
+
case "audio":
|
|
324
|
+
out.push({
|
|
325
|
+
type: "record",
|
|
326
|
+
data: { file: String(el.file ?? el.url ?? "") },
|
|
327
|
+
});
|
|
328
|
+
break;
|
|
329
|
+
case "video":
|
|
330
|
+
out.push({
|
|
331
|
+
type: "video",
|
|
332
|
+
data: { file: String(el.file ?? el.url ?? "") },
|
|
333
|
+
});
|
|
334
|
+
break;
|
|
335
|
+
default:
|
|
336
|
+
if (el.text != null && el.text !== "") {
|
|
337
|
+
out.push({ type: "text", data: { text: String(el.text) } });
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return out.length ? out : null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function resolveReplyIdFromJsonElement(el: IcqqMessageElement): string | undefined {
|
|
346
|
+
let payload: unknown = el.data ?? el;
|
|
347
|
+
if (typeof payload === "string") {
|
|
348
|
+
try {
|
|
349
|
+
payload = JSON.parse(payload) as unknown;
|
|
350
|
+
} catch {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (!payload || typeof payload !== "object") return undefined;
|
|
355
|
+
const record = payload as Record<string, unknown>;
|
|
356
|
+
const refer =
|
|
357
|
+
record.msgRefer ??
|
|
358
|
+
record.reply ??
|
|
359
|
+
record.resid ??
|
|
360
|
+
record.message_id ??
|
|
361
|
+
record.id;
|
|
362
|
+
if (refer && typeof refer === "object") {
|
|
363
|
+
const r = refer as Record<string, unknown>;
|
|
364
|
+
const id = r.msgId ?? r.message_id ?? r.id ?? r.msg_id;
|
|
365
|
+
if (id != null && String(id)) return String(id);
|
|
366
|
+
}
|
|
367
|
+
if (typeof refer === "string" && refer) return refer;
|
|
368
|
+
if (typeof refer === "number") return String(refer);
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const MAX_SOURCE_WALK_DEPTH = 10;
|
|
373
|
+
|
|
374
|
+
function icqqSourceMessageBody(s: IcqqMessageSource): unknown {
|
|
375
|
+
return (s as Record<string, unknown>).message;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function icqqSourceHasBody(s: IcqqMessageSource): boolean {
|
|
379
|
+
const body = icqqSourceMessageBody(s);
|
|
380
|
+
if (Array.isArray(body) && body.length > 0) return true;
|
|
381
|
+
if (typeof body === "string" && body.trim().length > 0) return true;
|
|
382
|
+
return typeof s.raw_message === "string" && s.raw_message.length > 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function looksLikeIcqqMessageSource(value: unknown): value is IcqqMessageSource {
|
|
386
|
+
if (!value || typeof value !== "object") return false;
|
|
387
|
+
const r = value as Record<string, unknown>;
|
|
388
|
+
// 整条入站事件也有 message_id,不能与 oicq Message#source 混淆
|
|
389
|
+
if (r.post_type != null || r.message_type != null) return false;
|
|
390
|
+
const s = value as IcqqMessageSource;
|
|
391
|
+
if (!icqqSourceHasBody(s)) return false;
|
|
392
|
+
return !!resolveQuoteIdFromIcqqSource(value);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** IPC 载荷可能把 source 嵌在 data / event 等字段,深度查找 */
|
|
396
|
+
export function findIcqqNestedMessageSource(
|
|
397
|
+
root: unknown,
|
|
398
|
+
maxDepth = MAX_SOURCE_WALK_DEPTH,
|
|
399
|
+
): IcqqMessageSource | undefined {
|
|
400
|
+
const seen = new Set<unknown>();
|
|
401
|
+
function walk(node: unknown, depth: number): IcqqMessageSource | undefined {
|
|
402
|
+
if (depth > maxDepth || node == null) return undefined;
|
|
403
|
+
if (typeof node !== "object") return undefined;
|
|
404
|
+
if (seen.has(node)) return undefined;
|
|
405
|
+
seen.add(node);
|
|
406
|
+
|
|
407
|
+
if (looksLikeIcqqMessageSource(node)) {
|
|
408
|
+
return node as IcqqMessageSource;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const record = node as Record<string, unknown>;
|
|
412
|
+
if (record.source && looksLikeIcqqMessageSource(record.source)) {
|
|
413
|
+
return record.source as IcqqMessageSource;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const value of Object.values(record)) {
|
|
417
|
+
if (value && typeof value === "object") {
|
|
418
|
+
const hit = walk(value, depth + 1);
|
|
419
|
+
if (hit) return hit;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
return walk(root, 0);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** 从 oicq MessageEvent.source 解析被引用消息的 message_id */
|
|
428
|
+
export function resolveQuoteIdFromIcqqSource(
|
|
429
|
+
source: unknown,
|
|
430
|
+
): string | undefined {
|
|
431
|
+
if (!source || typeof source !== "object") return undefined;
|
|
432
|
+
const s = source as IcqqMessageSource;
|
|
433
|
+
if (s.message_id != null && String(s.message_id).trim()) {
|
|
434
|
+
return String(s.message_id).trim();
|
|
435
|
+
}
|
|
436
|
+
if (s.msg_id != null && String(s.msg_id).trim()) {
|
|
437
|
+
return String(s.msg_id).trim();
|
|
438
|
+
}
|
|
439
|
+
// NT 引用源常仅有 seq/rand,无 message_id
|
|
440
|
+
if (s.seq != null && String(s.seq) !== "") {
|
|
441
|
+
const seq = String(s.seq);
|
|
442
|
+
if (s.rand != null && String(s.rand) !== "") {
|
|
443
|
+
return `${seq}:${s.rand}`;
|
|
444
|
+
}
|
|
445
|
+
return seq;
|
|
446
|
+
}
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function icqqSourceToContentSegments(
|
|
451
|
+
s: IcqqMessageSource,
|
|
452
|
+
): MessageSegment[] {
|
|
453
|
+
const body = icqqSourceMessageBody(s);
|
|
454
|
+
if (Array.isArray(body) && body.length) {
|
|
455
|
+
return icqqElementsToSegments(body as IcqqMessageElement[]) ?? [];
|
|
456
|
+
}
|
|
457
|
+
if (typeof body === "string" && body.trim()) {
|
|
458
|
+
return [{ type: "text", data: { text: body.trim() } }];
|
|
459
|
+
}
|
|
460
|
+
if (typeof s.raw_message === "string" && s.raw_message) {
|
|
461
|
+
return parseCqMessage(s.raw_message);
|
|
462
|
+
}
|
|
463
|
+
return [];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** 将 source 转为 QuotedMessagePayload(供 $getMsg 缓存,避免重复拉取) */
|
|
467
|
+
export function quotedPayloadFromIcqqSource(
|
|
468
|
+
source: unknown,
|
|
469
|
+
): QuotedMessagePayload | null {
|
|
470
|
+
if (!source || typeof source !== "object") return null;
|
|
471
|
+
const s = source as IcqqMessageSource;
|
|
472
|
+
const body = icqqSourceMessageBody(s);
|
|
473
|
+
const content = icqqSourceToContentSegments(s);
|
|
474
|
+
const messageId = resolveQuoteIdFromIcqqSource(source);
|
|
475
|
+
if (!messageId && !content.length) return null;
|
|
476
|
+
const senderRaw = s.sender;
|
|
477
|
+
const senderFromUserId =
|
|
478
|
+
s.user_id != null
|
|
479
|
+
? { id: String(s.user_id), name: "" }
|
|
480
|
+
: undefined;
|
|
481
|
+
return {
|
|
482
|
+
messageId: messageId ?? `seq:${s.seq ?? 0}:${s.rand ?? 0}`,
|
|
483
|
+
sender: senderRaw
|
|
484
|
+
? {
|
|
485
|
+
id: String(senderRaw.user_id ?? ""),
|
|
486
|
+
name: String(senderRaw.nickname ?? senderRaw.card ?? ""),
|
|
487
|
+
}
|
|
488
|
+
: senderFromUserId,
|
|
489
|
+
content,
|
|
490
|
+
raw:
|
|
491
|
+
typeof s.raw_message === "string"
|
|
492
|
+
? s.raw_message
|
|
493
|
+
: typeof body === "string"
|
|
494
|
+
? body
|
|
495
|
+
: undefined,
|
|
496
|
+
time: typeof s.time === "number" ? s.time : undefined,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** 从 IPC 事件顶层字段解析引用 message_id(与 message 段互补) */
|
|
501
|
+
export function resolveIcqqQuoteIdFromEvent(
|
|
502
|
+
data: IcqqIpcMessageEvent,
|
|
503
|
+
): string | undefined {
|
|
504
|
+
const fromSource =
|
|
505
|
+
resolveQuoteIdFromIcqqSource(data.source) ??
|
|
506
|
+
resolveQuoteIdFromIcqqSource(findIcqqNestedMessageSource(data));
|
|
507
|
+
if (fromSource) return fromSource;
|
|
508
|
+
|
|
509
|
+
const ext = data as IcqqIpcMessageEvent & Record<string, unknown>;
|
|
510
|
+
const candidates = [
|
|
511
|
+
ext.reply,
|
|
512
|
+
ext.quoted_message_id,
|
|
513
|
+
ext.quote_message_id,
|
|
514
|
+
ext.source_message_id,
|
|
515
|
+
];
|
|
516
|
+
for (const c of candidates) {
|
|
517
|
+
if (typeof c === "string" && c.trim()) return c.trim();
|
|
518
|
+
if (typeof c === "number") return String(c);
|
|
519
|
+
if (c && typeof c === "object") {
|
|
520
|
+
const r = c as Record<string, unknown>;
|
|
521
|
+
const id = r.id ?? r.message_id ?? r.msg_id ?? r.msgId;
|
|
522
|
+
if (id != null && String(id)) return String(id);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function mergeReplyFromRawMessage(
|
|
529
|
+
content: MessageSegment[],
|
|
530
|
+
rawMessage: string,
|
|
531
|
+
): MessageSegment[] {
|
|
532
|
+
if (!rawMessage || Message.quoteIdFromContent(content)) return content;
|
|
533
|
+
const fromRaw = parseCqMessage(rawMessage);
|
|
534
|
+
const quoteFromRaw = Message.quoteIdFromContent(fromRaw);
|
|
535
|
+
if (!quoteFromRaw) return content;
|
|
536
|
+
return [
|
|
537
|
+
{ type: "reply", data: { id: quoteFromRaw, message_id: quoteFromRaw } },
|
|
538
|
+
...content.filter((s) => s.type !== "reply"),
|
|
539
|
+
];
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function mergeReplyFromSource(
|
|
543
|
+
content: MessageSegment[],
|
|
544
|
+
data: IcqqIpcMessageEvent,
|
|
545
|
+
): MessageSegment[] {
|
|
546
|
+
if (Message.quoteIdFromContent(content)) return content;
|
|
547
|
+
const quoteId =
|
|
548
|
+
resolveQuoteIdFromIcqqSource(data.source) ??
|
|
549
|
+
resolveQuoteIdFromIcqqSource(findIcqqNestedMessageSource(data));
|
|
550
|
+
if (!quoteId) return content;
|
|
551
|
+
return [
|
|
552
|
+
{ type: "reply", data: { id: quoteId, message_id: quoteId } },
|
|
553
|
+
...content.filter((s) => s.type !== "reply"),
|
|
554
|
+
];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function resolveInboundContent(
|
|
558
|
+
data: IcqqIpcMessageEvent,
|
|
559
|
+
): MessageSegment[] {
|
|
560
|
+
const fromElements = icqqElementsToSegments(data.message);
|
|
561
|
+
const raw = data.raw_message ?? "";
|
|
562
|
+
let content: MessageSegment[];
|
|
563
|
+
if (fromElements?.length) {
|
|
564
|
+
content = mergeReplyFromRawMessage(fromElements, raw);
|
|
565
|
+
} else if (raw) {
|
|
566
|
+
content = parseCqMessage(raw);
|
|
567
|
+
} else {
|
|
568
|
+
content = [{ type: "text", data: { text: "" } }];
|
|
569
|
+
}
|
|
570
|
+
return mergeReplyFromSource(content, data);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function normalizeIcqqInboundMessage(
|
|
574
|
+
data: IcqqIpcMessageEvent,
|
|
575
|
+
): NormalizedIcqqInbound | null {
|
|
576
|
+
if (!isIcqqMessagePostType(data)) return null;
|
|
577
|
+
const ext = data as IcqqIpcMessageEvent & Record<string, unknown>;
|
|
578
|
+
const userIdNum = resolveIcqqInboundUserId(ext);
|
|
579
|
+
if (userIdNum == null) return null;
|
|
580
|
+
const { channelType, channelId } = resolveChannelFromIcqqMessage(data);
|
|
581
|
+
const resolved = resolveIcqqInboundMessageId(data, channelId);
|
|
582
|
+
const nickname =
|
|
583
|
+
data.sender?.nickname ?? data.nickname ?? String(userIdNum);
|
|
584
|
+
const rawMessage = data.raw_message ?? "";
|
|
585
|
+
return {
|
|
586
|
+
messageId: resolved.id,
|
|
587
|
+
idSource: resolved.source,
|
|
588
|
+
channelType,
|
|
589
|
+
channelId,
|
|
590
|
+
userId: String(userIdNum),
|
|
591
|
+
nickname,
|
|
592
|
+
content: resolveInboundContent(data),
|
|
593
|
+
rawMessage,
|
|
594
|
+
timestampMs: data.time * 1000,
|
|
595
|
+
raw: data,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** 按 message_id 去重(多路 subscribe 会收到同一消息多次) */
|
|
600
|
+
export class InboundMessageDeduper {
|
|
601
|
+
private readonly seen = new Map<string, number>();
|
|
602
|
+
|
|
603
|
+
shouldProcess(messageId: string): boolean {
|
|
604
|
+
const now = Date.now();
|
|
605
|
+
for (const [id, t] of this.seen) {
|
|
606
|
+
if (now - t > DEDUPE_TTL_MS) this.seen.delete(id);
|
|
607
|
+
}
|
|
608
|
+
if (this.seen.has(messageId)) return false;
|
|
609
|
+
this.seen.set(messageId, now);
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
clear(): void {
|
|
614
|
+
this.seen.clear();
|
|
615
|
+
}
|
|
616
|
+
}
|