@zhin.js/adapter-icqq 8.0.13 → 8.0.14

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.
@@ -1,17 +1,13 @@
1
- import { segment, type MessageSegment, type QuotedMessagePayload } from 'zhin.js';
2
-
3
1
  import {
4
2
  icqqElementsToSegments,
5
3
  type IcqqMessageElement,
6
4
  } from "./icqq-inbound.js";
7
- import { parseCqMessage } from "./cq-message.js";
5
+ import { toCanonicalSegments } from '@zhin.js/core';
6
+ import type { ForwardEntry, Segment } from '@zhin.js/im-contract';
8
7
 
9
8
  const RESID_IN_XML_RE =
10
9
  /(?:m_resid|resid|fileid|res_id)=["']?([A-Za-z0-9+/=_.-]{8,})/i;
11
10
 
12
- const FORWARD_PLACEHOLDER_RE =
13
- /\[聊天记录\]|合并转发|转发消息|聊天记录/i;
14
-
15
11
  function parseJsonPayload(raw: unknown): Record<string, unknown> | null {
16
12
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
17
13
  return raw as Record<string, unknown>;
@@ -76,344 +72,41 @@ export function extractForwardResidFromJsonElement(
76
72
  return undefined;
77
73
  }
78
74
 
79
- /** 从 get_msg 的 raw_message 解析 multimsg resid(XML / JSON / CQ:json) */
80
- export function extractForwardResidFromRawMessage(
81
- raw: string,
82
- ): string | undefined {
83
- const text = raw.trim();
84
- if (!text) return undefined;
85
-
86
- const xml = text.match(RESID_IN_XML_RE);
87
- if (xml?.[1]) return xml[1].trim();
88
-
89
- const asJson = parseJsonPayload(text);
90
- if (asJson) {
91
- const fromJson = extractForwardResidFromJsonElement({
92
- type: "json",
93
- data: asJson,
94
- });
95
- if (fromJson) return fromJson;
96
- }
97
-
98
- for (const match of text.matchAll(/\[CQ:json(?:,([^\]]*))?\]/gi)) {
99
- const arg = match[1] ?? "";
100
- const dataMatch = arg.match(/(?:^|,)data=(.+)$/s);
101
- if (!dataMatch?.[1]) continue;
102
- const record = parseJsonPayload(dataMatch[1].trim());
103
- if (!record) continue;
104
- const fromCq = extractForwardResidFromJsonElement({
105
- type: "json",
106
- data: record,
107
- });
108
- if (fromCq) return fromCq;
109
- }
110
-
111
- const segs = parseCqMessage(text);
112
- return extractForwardResidFromSegments(segs);
113
- }
114
-
115
- /** 从 get_msg 的 message 元素数组解析 multimsg resid */
116
- export function extractForwardResidFromGetMsgElements(
117
- elements: IcqqMessageElement[] | undefined,
118
- ): string | undefined {
119
- if (!elements?.length) return undefined;
120
- for (const el of elements) {
121
- if (!el || typeof el !== "object") continue;
122
- if (el.type === "forward") {
123
- const id = String(el.id ?? el.resid ?? el.res_id ?? el.file ?? "").trim();
124
- if (id) return id;
125
- }
126
- const fromJson = extractForwardResidFromJsonElement(el);
127
- if (fromJson) return fromJson;
128
- }
129
- const segs = icqqElementsToSegments(elements);
130
- if (segs?.length) {
131
- return extractForwardResidFromSegments(segs);
132
- }
133
- return extractForwardResidDeep(elements);
134
- }
135
-
136
- /**
137
- * 从 get_msg 响应解析合并转发 resid(优先 message 数组,其次 raw_message)。
138
- */
139
- export function extractForwardResidFromGetMsg(
140
- data: unknown,
141
- ): string | undefined {
142
- if (!data || typeof data !== "object") return undefined;
143
- const record = data as Record<string, unknown>;
144
-
145
- if (Array.isArray(record.message)) {
146
- const fromElements = extractForwardResidFromGetMsgElements(
147
- record.message as IcqqMessageElement[],
148
- );
149
- if (fromElements) return fromElements;
150
- }
151
-
152
- if (typeof record.raw_message === "string" && record.raw_message.trim()) {
153
- const fromRaw = extractForwardResidFromRawMessage(record.raw_message);
154
- if (fromRaw) return fromRaw;
155
- }
156
-
157
- if (typeof record.message === "string" && record.message.trim()) {
158
- return extractForwardResidFromRawMessage(record.message);
159
- }
160
-
161
- return undefined;
162
- }
163
-
164
- export function extractForwardResidFromSegments(
165
- content: MessageSegment[],
166
- ): string | undefined {
167
- for (const seg of content) {
168
- if (seg.type === "forward") {
169
- const id = seg.data?.id ?? seg.data?.resid;
170
- if (id != null && String(id).trim()) return String(id).trim();
171
- }
172
- if (seg.type === "json" && seg.data) {
173
- const resid = extractForwardResidFromJsonElement(
174
- seg.data as IcqqMessageElement,
175
- );
176
- if (resid) return resid;
177
- const raw = seg.data.data ?? seg.data.text;
178
- const record = parseJsonPayload(raw);
179
- if (record) {
180
- const fromEl = extractForwardResidFromJsonElement({
181
- type: "json",
182
- data: record,
183
- });
184
- if (fromEl) return fromEl;
185
- }
186
- }
187
- }
188
- const raw = segment.raw(content);
189
- const m = raw.match(RESID_IN_XML_RE);
190
- return m?.[1]?.trim();
191
- }
192
-
193
- export function extractForwardResidFromPayload(
194
- payload: QuotedMessagePayload,
195
- ): string | undefined {
196
- if (Array.isArray(payload.content)) {
197
- const fromSeg = extractForwardResidFromSegments(
198
- payload.content as MessageSegment[],
199
- );
200
- if (fromSeg) return fromSeg;
201
- }
202
- if (typeof payload.raw === "string") {
203
- const m = payload.raw.match(RESID_IN_XML_RE);
204
- if (m?.[1]) return m[1].trim();
205
- const record = parseJsonPayload(payload.raw);
206
- if (record) {
207
- return extractForwardResidFromJsonElement({ type: "json", data: record });
208
- }
209
- }
210
- return undefined;
211
- }
212
-
213
- /** 在 get_msg 原始对象里深度搜索 resid */
214
- export function extractForwardResidDeep(
215
- root: unknown,
216
- maxDepth = 14,
217
- ): string | undefined {
218
- const seen = new Set<unknown>();
219
- function walk(node: unknown, depth: number): string | undefined {
220
- if (depth > maxDepth || node == null) return undefined;
221
- if (typeof node === "string") {
222
- const m = node.match(RESID_IN_XML_RE);
223
- if (m?.[1]) return m[1].trim();
224
- const record = parseJsonPayload(node);
225
- if (record) {
226
- const fromJson = extractForwardResidFromJsonElement({
227
- type: "json",
228
- data: record,
229
- });
230
- if (fromJson) return fromJson;
231
- }
232
- return undefined;
233
- }
234
- if (typeof node !== "object") return undefined;
235
- if (seen.has(node)) return undefined;
236
- seen.add(node);
237
-
238
- if (Array.isArray(node)) {
239
- for (const item of node) {
240
- const hit = walk(item, depth + 1);
241
- if (hit) return hit;
242
- }
243
- return undefined;
244
- }
245
-
246
- const fromEl = extractForwardResidFromJsonElement({
247
- type: "json",
248
- data: node as IcqqMessageElement,
249
- });
250
- if (fromEl) return fromEl;
251
-
252
- for (const value of Object.values(node as Record<string, unknown>)) {
253
- const hit = walk(value, depth + 1);
254
- if (hit) return hit;
255
- }
256
- return undefined;
257
- }
258
- return walk(root, 0);
259
- }
260
-
261
- export function isForwardPlaceholderPayload(
262
- payload: QuotedMessagePayload,
263
- ): boolean {
264
- if (
265
- Array.isArray(payload.content) &&
266
- payload.content.some((s) => s.type === "forward")
267
- ) {
268
- return true;
269
- }
270
- const raw = Array.isArray(payload.content)
271
- ? segment.raw(payload.content as MessageSegment[])
272
- : String(payload.content ?? payload.raw ?? "");
273
- return FORWARD_PLACEHOLDER_RE.test(raw);
274
- }
275
-
276
- function hasMergedForwardBlock(payload: QuotedMessagePayload): boolean {
277
- const raw = Array.isArray(payload.content)
278
- ? segment.raw(payload.content as MessageSegment[])
279
- : String(payload.content ?? "");
280
- return raw.includes("[Merged chat history");
281
- }
282
-
283
- function senderLabel(sender: unknown): string {
284
- if (!sender || typeof sender !== "object") return "unknown";
285
- const s = sender as Record<string, unknown>;
286
- const name = s.nickname ?? s.name ?? s.card;
287
- const id = s.user_id ?? s.uin ?? s.uid ?? s.id;
288
- if (name && id) return `${name} (${id})`;
289
- if (name) return String(name);
290
- if (id != null) return String(id);
291
- return "unknown";
292
- }
293
-
294
- function messageBodyToText(body: unknown): string {
295
- if (body == null) return "";
296
- if (typeof body === "string") return body.trim();
297
- if (Array.isArray(body)) {
298
- const segs = icqqElementsToSegments(body as IcqqMessageElement[]);
299
- return segs?.length ? segment.raw(segs).trim() : segment.raw(body).trim();
300
- }
301
- if (typeof body === "object") {
302
- const r = body as Record<string, unknown>;
303
- if (typeof r.raw_message === "string" && r.raw_message.trim()) {
304
- return r.raw_message.trim();
305
- }
306
- if (Array.isArray(r.message)) {
307
- return messageBodyToText(r.message);
308
- }
309
- if (Array.isArray(r.elements)) {
310
- return messageBodyToText(r.elements);
311
- }
312
- if (typeof r.content === "string") return r.content.trim();
313
- }
314
- return "";
315
- }
316
-
317
- /** 将 get_forward_msg 响应格式化为可读聊天记录 */
318
- export function formatForwardMsgResponse(data: unknown): string {
319
- if (!data) return "";
320
-
321
- const root =
322
- data && typeof data === "object" && !Array.isArray(data)
323
- ? (data as Record<string, unknown>)
324
- : null;
325
-
75
+ /** Canonical merged-forward entries. Speakers are data actors, never LLM roles. */
76
+ export function normalizeForwardMsgResponse(data: unknown): readonly ForwardEntry[] {
77
+ const root = data && typeof data === 'object' && !Array.isArray(data)
78
+ ? data as Record<string, unknown>
79
+ : undefined;
326
80
  const list: unknown[] = Array.isArray(data)
327
81
  ? data
328
- : Array.isArray(root?.messages)
329
- ? (root!.messages as unknown[])
330
- : Array.isArray(root?.msgList)
331
- ? (root!.msgList as unknown[])
332
- : Array.isArray(root?.msg_list)
333
- ? (root!.msg_list as unknown[])
334
- : Array.isArray(root?.message)
335
- ? (root!.message as unknown[])
82
+ : Array.isArray(root?.messages) ? root.messages as unknown[]
83
+ : Array.isArray(root?.msgList) ? root.msgList as unknown[]
84
+ : Array.isArray(root?.msg_list) ? root.msg_list as unknown[]
85
+ : Array.isArray(root?.message) ? root.message as unknown[]
336
86
  : [];
337
-
338
- if (!list.length) return "";
339
-
340
- const lines: string[] = [];
341
- let index = 0;
342
- for (const item of list) {
343
- if (!item || typeof item !== "object") continue;
87
+ return Object.freeze(list.flatMap((item): ForwardEntry[] => {
88
+ if (!item || typeof item !== 'object') return [];
344
89
  const row = item as Record<string, unknown>;
345
- index += 1;
346
- const who = senderLabel(row.sender ?? row.user);
347
- const time =
348
- typeof row.time === "number"
349
- ? new Date(row.time * (row.time > 1e12 ? 1 : 1000)).toISOString()
350
- : "";
351
- const text = messageBodyToText(
352
- row.message ??
353
- row.content ??
354
- row.raw_message ??
355
- row.elements ??
356
- row,
357
- );
358
- const head = time ? `${index}. ${who} @ ${time}` : `${index}. ${who}`;
359
- lines.push(text ? `${head}\n${text}` : `${head}\n(非文本或无法解析的内容)`);
360
- }
361
- return lines.join("\n\n");
362
- }
363
-
364
- export async function fetchForwardMsgText(
365
- client: { getForwardMsg(resId: string, fileName?: string): Promise<unknown[]> },
366
- id: string,
367
- ): Promise<string> {
368
- try {
369
- const messages = await client.getForwardMsg(id);
370
- const text = formatForwardMsgResponse(messages);
371
- if (text.trim()) return text.trim();
372
- } catch { /* ignore */ }
373
- return "";
374
- }
375
-
376
- function appendForwardBlock(
377
- payload: QuotedMessagePayload,
378
- block: string,
379
- ): QuotedMessagePayload {
380
- if (Array.isArray(payload.content)) {
381
- return {
382
- ...payload,
383
- content: [...payload.content, { type: "text", data: { text: block } }],
384
- };
385
- }
386
- const prev =
387
- typeof payload.content === "string" ? payload.content.trim() : "";
388
- return {
389
- ...payload,
390
- content: prev ? `${prev}\n\n${block}` : block,
391
- };
392
- }
393
-
394
- export async function enrichQuotedPayloadWithForward(
395
- client: { getForwardMsg(resId: string, fileName?: string): Promise<unknown[]> } | null | undefined,
396
- payload: QuotedMessagePayload,
397
- rawData?: unknown,
398
- ): Promise<QuotedMessagePayload> {
399
- if (!client || hasMergedForwardBlock(payload)) return payload;
400
-
401
- const resid =
402
- extractForwardResidFromPayload(payload) ??
403
- (rawData ? extractForwardResidFromGetMsg(rawData) : undefined) ??
404
- (rawData ? extractForwardResidDeep(rawData) : undefined);
405
-
406
- const fetchIds = [
407
- resid,
408
- isForwardPlaceholderPayload(payload) ? payload.messageId : undefined,
409
- ].filter((v, i, a): v is string => !!v && a.indexOf(v) === i);
410
-
411
- for (const fetchId of fetchIds) {
412
- const forwardText = await fetchForwardMsgText(client, fetchId);
413
- if (!forwardText.trim()) continue;
414
- const block = `[Merged chat history — id ${fetchId}]\n${forwardText.trim()}`;
415
- return appendForwardBlock(payload, block);
416
- }
417
-
418
- return payload;
90
+ const sender = row.sender && typeof row.sender === 'object'
91
+ ? row.sender as Record<string, unknown>
92
+ : row.user && typeof row.user === 'object'
93
+ ? row.user as Record<string, unknown>
94
+ : undefined;
95
+ const id = sender?.user_id ?? sender?.uin ?? sender?.id;
96
+ const displayName = sender?.nickname ?? sender?.card ?? sender?.name;
97
+ const body = row.message ?? row.content ?? row.elements ?? row.raw_message;
98
+ let segments: Segment[];
99
+ if (Array.isArray(body)) segments = toCanonicalSegments(icqqElementsToSegments(body as IcqqMessageElement[]) ?? body);
100
+ else if (typeof body === 'string' && body.trim()) segments = [{ type: 'text', data: { text: body.trim() } }];
101
+ else segments = [];
102
+ if (segments.length === 0) return [];
103
+ return [Object.freeze({
104
+ ...(id != null ? { actor: Object.freeze({
105
+ id: String(id),
106
+ ...(displayName != null && String(displayName).trim() ? { displayName: String(displayName) } : {}),
107
+ }) } : {}),
108
+ ...(typeof row.time === 'number' ? { timestamp: row.time < 1e12 ? row.time * 1000 : row.time } : {}),
109
+ segments: Object.freeze(segments),
110
+ })];
111
+ }));
419
112
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * icqq 入站事件归一化(post_type / message_type / message 段)
3
3
  */
4
- import { Message, toCanonicalSegments, type MessageSegment, type QuotedMessagePayload } from "zhin.js";
4
+ import { toCanonicalSegments, type MessageSegment } from "zhin.js";
5
5
  import { parseCqMessage, icqqMediaRefFromString } from "./cq-message.js";
6
6
  import { extractForwardResidFromJsonElement } from "./forward-msg.js";
7
7
  import type { GroupRole } from "./types.js";
@@ -66,6 +66,14 @@ export interface IcqqMessageSource {
66
66
  [key: string]: unknown;
67
67
  }
68
68
 
69
+ interface IcqqMessageLookup {
70
+ readonly messageId: string;
71
+ readonly sender?: Readonly<{ id?: string; name?: string }>;
72
+ readonly content: readonly MessageSegment[] | string;
73
+ readonly raw?: string;
74
+ readonly time?: number;
75
+ }
76
+
69
77
  export type IcqqMessageElement = {
70
78
  type: string;
71
79
  text?: string;
@@ -74,9 +82,45 @@ export type IcqqMessageElement = {
74
82
  id?: string | number;
75
83
  url?: string;
76
84
  file?: string;
85
+ fid?: string;
86
+ name?: string;
87
+ size?: number;
88
+ seconds?: number;
77
89
  [key: string]: unknown;
78
90
  };
79
91
 
92
+ export interface IcqqInboundMediaResolver {
93
+ getPttUrl(element: IcqqMessageElement): Promise<string | null | undefined>;
94
+ getVideoUrl(element: IcqqMessageElement): Promise<string | null | undefined>;
95
+ }
96
+
97
+ /**
98
+ * Resolve ICQQ's opaque protobuf media references while the endpoint-owned
99
+ * native client is still available. Resolution failure preserves the opaque
100
+ * reference, so canonical ingress stays truthful and can report unsupported
101
+ * media instead of inventing a URL.
102
+ */
103
+ export async function resolveIcqqInboundMedia(
104
+ elements: IcqqMessageElement[] | undefined,
105
+ resolver: IcqqInboundMediaResolver,
106
+ ): Promise<void> {
107
+ if (!elements?.length) return;
108
+ await Promise.all(elements.map(async (element) => {
109
+ if (!element || typeof element !== "object") return;
110
+ if (typeof element.url === "string" && element.url) return;
111
+ try {
112
+ const url = element.type === "record" || element.type === "audio"
113
+ ? await resolver.getPttUrl(element)
114
+ : element.type === "video"
115
+ ? await resolver.getVideoUrl(element)
116
+ : undefined;
117
+ if (typeof url === "string" && url) element.url = url;
118
+ } catch {
119
+ // The original platform reference remains the canonical fallback.
120
+ }
121
+ }));
122
+ }
123
+
80
124
  /** 归一化后供适配器使用的入站消息 */
81
125
  export interface NormalizedIcqqInbound {
82
126
  messageId: string;
@@ -343,9 +387,19 @@ export function icqqElementsToSegments(
343
387
  break;
344
388
  case "image": {
345
389
  const value = String(el.url ?? el.file ?? "");
390
+ if (!value) break;
391
+ out.push({
392
+ type: "image",
393
+ data: { media: icqqMediaRefFromElement(value, el) },
394
+ });
395
+ break;
396
+ }
397
+ case "flash": {
398
+ const value = String(el.url ?? el.file ?? "");
399
+ if (!value) break;
346
400
  out.push({
347
401
  type: "image",
348
- data: { media: icqqMediaRefFromString(value) },
402
+ data: { media: icqqMediaRefFromElement(value, el), flash: true },
349
403
  });
350
404
  break;
351
405
  }
@@ -410,18 +464,39 @@ export function icqqElementsToSegments(
410
464
  }
411
465
  case "record":
412
466
  case "audio": {
413
- const value = String(el.file ?? el.url ?? "");
467
+ const value = String(el.url ?? el.file ?? "");
468
+ if (!value) break;
414
469
  out.push({
415
- type: "record",
416
- data: { media: icqqMediaRefFromString(value) },
470
+ type: "audio",
471
+ data: {
472
+ media: icqqMediaRefFromElement(value, el),
473
+ ...(typeof el.seconds === "number" ? { duration: el.seconds } : {}),
474
+ },
417
475
  });
418
476
  break;
419
477
  }
420
478
  case "video": {
421
- const value = String(el.file ?? el.url ?? "");
479
+ const value = String(el.url ?? el.file ?? "");
480
+ if (!value) break;
422
481
  out.push({
423
482
  type: "video",
424
- data: { media: icqqMediaRefFromString(value) },
483
+ data: {
484
+ media: icqqMediaRefFromElement(value, el),
485
+ ...(typeof el.seconds === "number" ? { duration: el.seconds } : {}),
486
+ },
487
+ });
488
+ break;
489
+ }
490
+ case "file": {
491
+ const value = String(el.url ?? el.file ?? el.fid ?? "");
492
+ if (!value) break;
493
+ const name = typeof el.name === "string" && el.name ? el.name : undefined;
494
+ out.push({
495
+ type: "file",
496
+ data: {
497
+ media: icqqMediaRefFromElement(value, el),
498
+ ...(name ? { name } : {}),
499
+ },
425
500
  });
426
501
  break;
427
502
  }
@@ -435,6 +510,24 @@ export function icqqElementsToSegments(
435
510
  return out.length ? out : null;
436
511
  }
437
512
 
513
+ function icqqMediaRefFromElement(
514
+ value: string,
515
+ element: IcqqMessageElement,
516
+ ): ReturnType<typeof icqqMediaRefFromString> {
517
+ const media = icqqMediaRefFromString(value);
518
+ const fileName = typeof element.name === "string" && element.name
519
+ ? element.name
520
+ : undefined;
521
+ const size = typeof element.size === "number" && Number.isFinite(element.size)
522
+ ? element.size
523
+ : undefined;
524
+ return {
525
+ ...media,
526
+ ...(fileName ? { file_name: fileName } : {}),
527
+ ...(size != null ? { size } : {}),
528
+ };
529
+ }
530
+
438
531
  function resolveReplyIdFromJsonElement(el: IcqqMessageElement): string | undefined {
439
532
  let payload: unknown = el.data ?? el;
440
533
  if (typeof payload === "string") {
@@ -556,10 +649,10 @@ function icqqSourceToContentSegments(
556
649
  return [];
557
650
  }
558
651
 
559
- /** 将 source 转为 QuotedMessagePayload(供 $getMsg 缓存,避免重复拉取) */
560
- export function quotedPayloadFromIcqqSource(
652
+ /** Convert an ICQQ source into the adapter-local lookup payload. */
653
+ export function messageLookupFromIcqqSource(
561
654
  source: unknown,
562
- ): QuotedMessagePayload | null {
655
+ ): IcqqMessageLookup | null {
563
656
  if (!source || typeof source !== "object") return null;
564
657
  const s = source as IcqqMessageSource;
565
658
  const body = icqqSourceMessageBody(s);
@@ -622,9 +715,9 @@ function mergeReplyFromRawMessage(
622
715
  content: MessageSegment[],
623
716
  rawMessage: string,
624
717
  ): MessageSegment[] {
625
- if (!rawMessage || Message.quoteIdFromContent(content)) return content;
718
+ if (!rawMessage || quoteIdFromSegments(content)) return content;
626
719
  const fromRaw = parseCqMessage(rawMessage);
627
- const quoteFromRaw = Message.quoteIdFromContent(fromRaw);
720
+ const quoteFromRaw = quoteIdFromSegments(fromRaw);
628
721
  if (!quoteFromRaw) return content;
629
722
  return [
630
723
  { type: "reply", data: { message_id: quoteFromRaw } },
@@ -636,7 +729,7 @@ function mergeReplyFromSource(
636
729
  content: MessageSegment[],
637
730
  data: IcqqMessageEvent,
638
731
  ): MessageSegment[] {
639
- if (Message.quoteIdFromContent(content)) return content;
732
+ if (quoteIdFromSegments(content)) return content;
640
733
  const quoteId =
641
734
  resolveQuoteIdFromIcqqSource(data.source) ??
642
735
  resolveQuoteIdFromIcqqSource(findIcqqNestedMessageSource(data));
@@ -647,6 +740,12 @@ function mergeReplyFromSource(
647
740
  ];
648
741
  }
649
742
 
743
+ function quoteIdFromSegments(content: readonly MessageSegment[]): string | undefined {
744
+ const reply = content.find((item) => item.type === 'reply');
745
+ const value = reply?.data?.message_id ?? reply?.data?.id;
746
+ return value == null || !String(value).trim() ? undefined : String(value).trim();
747
+ }
748
+
650
749
  export function resolveInboundContent(
651
750
  data: IcqqMessageEvent,
652
751
  ): MessageSegment[] {