@zhin.js/adapter-icqq 8.0.12 → 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.
package/src/endpoint.ts CHANGED
@@ -2,35 +2,46 @@ import {
2
2
  Client,
3
3
  parseGroupMessageId,
4
4
  type Config as NativeIcqqClientConfig,
5
+ type FileElem,
6
+ type PttElem,
5
7
  type Sendable,
8
+ type VideoElem,
6
9
  } from '@icqqjs/icqq';
7
10
  import { inspect } from 'node:util';
8
11
  import type {
9
12
  EndpointControl,
10
13
  EndpointInstance,
11
14
  EndpointManagement,
15
+ EndpointPendingRequest,
12
16
  EndpointSendRequest,
13
17
  } from 'zhin.js/adapter';
14
- import type { MessageGateway } from '@zhin.js/core/runtime';
15
- import type { MessageRef } from '@zhin.js/im-contract';
18
+ import type { EndpointContentPort, EndpointContentResolveContext } from '@zhin.js/adapter';
19
+ import type { MessageGateway, SideEventGateway } from '@zhin.js/core/runtime';
20
+ import { receiveOneBotLikeSideEvent, SystemEvent, toCanonicalSegments, type LoginAssist } from '@zhin.js/core';
21
+ import type {
22
+ ConversationMessage,
23
+ ConversationReference,
24
+ ForwardEntry,
25
+ MessageRef,
26
+ Segment,
27
+ } from '@zhin.js/im-contract';
16
28
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
17
29
  import type { CapabilityId } from 'zhin.js';
18
30
  import { registerIcqqAgentEndpoint } from './icqq-agent-deps.js';
31
+ import { runIcqqLoginAssistStep } from './icqq-login-assist.js';
19
32
  import {
20
33
  InboundMessageDeduper,
21
34
  findIcqqNestedMessageSource,
22
35
  isIcqqBotMentioned,
23
36
  isIcqqMessagePostType,
24
37
  normalizeIcqqInboundMessage,
25
- quotedPayloadFromIcqqSource,
38
+ messageLookupFromIcqqSource,
26
39
  resolveIcqqQuoteIdFromEvent,
40
+ resolveIcqqInboundMedia,
27
41
  shouldSkipSelfInboundMessage,
28
42
  type IcqqMessageEvent,
29
43
  } from './icqq-inbound.js';
30
44
  import {
31
- buildIcqqInboxNoticeRow,
32
- buildIcqqInboxRequestRow,
33
- buildIcqqSystemRequestRow,
34
45
  isIcqqNoticePayload,
35
46
  isIcqqRequestPayload,
36
47
  } from './icqq-inbox.js';
@@ -39,7 +50,7 @@ import {
39
50
  normalizeIcqqGuildInboundMessage,
40
51
  } from './icqq-guild.js';
41
52
  import {
42
- materializeOutboundBase64,
53
+ prepareIcqqOutboundMedia,
43
54
  resolveIcqqOutboundMediaMode,
44
55
  } from './outbound-media.js';
45
56
  import {
@@ -51,18 +62,14 @@ import {
51
62
  type ResolvedIcqqConfig,
52
63
  } from './protocol.js';
53
64
  import type { SystemMessage as IcqqSystemMessage } from './types.js';
54
-
55
- export interface IcqqInboxHooks {
56
- recordRequest(row: Record<string, unknown>): void | Promise<void>;
57
- recordNotice(row: Record<string, unknown>): void | Promise<void>;
58
- publish?(type: string, data: unknown): void;
59
- }
65
+ import { normalizeForwardMsgResponse } from './forward-msg.js';
60
66
 
61
67
  export interface IcqqEndpointOptions {
62
68
  readonly id: CapabilityId;
63
69
  readonly gateway: MessageGateway;
64
70
  readonly config: ResolvedIcqqConfig;
65
- readonly inbox?: IcqqInboxHooks;
71
+ readonly sideEvents: SideEventGateway;
72
+ readonly loginAssist: LoginAssist;
66
73
  }
67
74
 
68
75
  const BOUND_EVENTS = [
@@ -90,11 +97,96 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
90
97
  readonly endpointName: string;
91
98
  readonly #guildCatalog = new IcqqGuildCatalog();
92
99
  readonly #inboundDeduper = new InboundMessageDeduper();
93
- readonly #inboxDeduper = new InboundMessageDeduper();
100
+ readonly #sideEventDeduper = new InboundMessageDeduper();
101
+ readonly #loginAssistOwner = Object.freeze({});
94
102
  #open = false;
103
+ #retiring = false;
95
104
  #started = false;
105
+ #resolveStartOnline?: () => void;
96
106
  #heldInbound: IcqqInboundMessage[] = [];
97
107
  #unregisterAgent?: () => void;
108
+ readonly #inflightInbound = new Set<Promise<void>>();
109
+ readonly #messageContent = new Map<string, ConversationMessage>();
110
+
111
+ readonly content: EndpointContentPort = Object.freeze({
112
+ resolve: async (reference: ConversationReference, context: EndpointContentResolveContext) => {
113
+ context.signal.throwIfAborted();
114
+ if (reference.kind === 'message') {
115
+ const cached = this.#messageContent.get(reference.message.id);
116
+ return cached
117
+ ? Object.freeze({ status: 'resolved' as const, reference, value: cached })
118
+ : Object.freeze({ status: 'not_found' as const, code: 'icqq_message_not_observed' });
119
+ }
120
+ if (reference.kind === 'forward') {
121
+ try {
122
+ const entries = await this.#resolveForwardEntries(
123
+ reference.forwardId,
124
+ context,
125
+ { remainingEntries: context.maxEntries, path: new Set<string>() },
126
+ 0,
127
+ );
128
+ if (entries.length === 0) return Object.freeze({ status: 'not_found' as const, code: 'icqq_forward_not_found' });
129
+ return Object.freeze({ status: 'resolved' as const, reference, value: Object.freeze(entries) });
130
+ } catch (error) {
131
+ if (context.signal.aborted) throw error;
132
+ return Object.freeze({
133
+ status: 'failed' as const,
134
+ code: 'icqq_forward_fetch_failed',
135
+ message: error instanceof Error ? error.message : String(error),
136
+ });
137
+ }
138
+ }
139
+ if (reference.media.kind === 'url' || reference.media.kind === 'base64' || reference.media.kind === 'path') {
140
+ return Object.freeze({ status: 'resolved' as const, reference, value: reference.media });
141
+ }
142
+ return Object.freeze({ status: 'unsupported' as const, code: 'icqq_media_reference_unsupported' });
143
+ },
144
+ });
145
+
146
+ async #resolveForwardEntries(
147
+ forwardId: string,
148
+ context: EndpointContentResolveContext,
149
+ state: { remainingEntries: number; readonly path: Set<string> },
150
+ depth: number,
151
+ ): Promise<readonly ForwardEntry[]> {
152
+ context.signal.throwIfAborted();
153
+ if (state.remainingEntries <= 0 || state.path.has(forwardId)) return Object.freeze([]);
154
+ state.path.add(forwardId);
155
+ try {
156
+ // ICQQ does not expose a cancellable getForwardMsg API. Keep the caller's
157
+ // generation lease until the native operation actually settles, then honor
158
+ // cancellation before publishing any resolved content.
159
+ const raw = await this.getForwardMsg(forwardId);
160
+ context.signal.throwIfAborted();
161
+ const entries = normalizeForwardMsgResponse(raw).slice(0, state.remainingEntries);
162
+ state.remainingEntries -= entries.length;
163
+ const expanded: ForwardEntry[] = [];
164
+ for (const entry of entries) {
165
+ context.signal.throwIfAborted();
166
+ const segments: Segment[] = [];
167
+ for (const segment of entry.segments) {
168
+ if (segment.type !== 'forward' || depth >= context.maxDepth) {
169
+ segments.push(segment);
170
+ continue;
171
+ }
172
+ const nestedId = String((segment.data as { forward_id?: unknown }).forward_id ?? '').trim();
173
+ if (!nestedId || state.path.has(nestedId) || state.remainingEntries <= 0) {
174
+ segments.push(segment);
175
+ continue;
176
+ }
177
+ const nested = await this.#resolveForwardEntries(nestedId, context, state, depth + 1);
178
+ segments.push(Object.freeze({
179
+ ...segment,
180
+ data: Object.freeze({ ...segment.data, ...(nested.length > 0 ? { entries: nested } : {}) }),
181
+ }));
182
+ }
183
+ expanded.push(Object.freeze({ ...entry, segments: Object.freeze(segments) }));
184
+ }
185
+ return Object.freeze(expanded);
186
+ } finally {
187
+ state.path.delete(forwardId);
188
+ }
189
+ }
98
190
 
99
191
  readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
100
192
  listFriends: async () =>
@@ -114,6 +206,7 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
114
206
  },
115
207
  listGroupMembers: async (groupId) =>
116
208
  Array.from((await this.getGroupMemberList(Number(groupId))).values()),
209
+ listRequests: () => this.#listPendingRequests(),
117
210
  approveRequest: (requestId, remark) => this.#approveRequest(requestId, remark),
118
211
  rejectRequest: (requestId, reason) => this.#rejectRequest(requestId, reason),
119
212
  kickGroupMember: async (groupId, userId) => {
@@ -186,17 +279,24 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
186
279
  });
187
280
  }
188
281
 
189
- async start(): Promise<void> {
282
+ async start(signal: AbortSignal): Promise<void> {
190
283
  if (this.#started) return;
284
+ signal.throwIfAborted();
191
285
  this.#started = true;
286
+ let resolveOnline!: () => void;
287
+ const onlineReady = new Promise<void>((resolve) => {
288
+ resolveOnline = resolve;
289
+ });
290
+ const onAbort = () => resolveOnline();
291
+ this.#resolveStartOnline = resolveOnline;
292
+ signal.addEventListener('abort', onAbort, { once: true });
192
293
  try {
193
- const onlineReady = new Promise<void>((resolve) => {
194
- this.on('system.online', () => resolve());
195
- });
196
294
  this.#bindClientEvents();
197
- await this.login(this.#options.config.password);
295
+ await raceAbort(Promise.resolve(this.login(this.#options.config.password)), signal);
198
296
  await onlineReady;
199
- await this.#pullPendingSystemMessages();
297
+ signal.throwIfAborted();
298
+ await this.#pullPendingSystemMessages(signal);
299
+ signal.throwIfAborted();
200
300
  this.#unregisterAgent = registerIcqqAgentEndpoint(this.endpointName, this);
201
301
  this.#logger.info(
202
302
  `connected (direct) | friends: ${this.fl.size} | groups: ${this.gl.size}`,
@@ -205,21 +305,31 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
205
305
  await this.stop();
206
306
  this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
207
307
  throw error;
308
+ } finally {
309
+ signal.removeEventListener('abort', onAbort);
310
+ if (this.#resolveStartOnline === resolveOnline) this.#resolveStartOnline = undefined;
208
311
  }
209
312
  }
210
313
 
211
314
  open(): void {
315
+ this.#retiring = false;
212
316
  this.#open = true;
213
317
  const held = this.#heldInbound.splice(0);
214
318
  for (const msg of held) this.admit(msg);
215
319
  }
216
320
 
217
- close(): void {
321
+ async close(): Promise<void> {
322
+ this.#retiring = true;
218
323
  this.#open = false;
324
+ await Promise.allSettled([...this.#inflightInbound]);
219
325
  }
220
326
 
221
327
  async stop(): Promise<void> {
328
+ this.#retiring = true;
222
329
  this.#open = false;
330
+ await Promise.allSettled([...this.#inflightInbound]);
331
+ this.#resolveStartOnline?.();
332
+ this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'endpoint_stopped');
223
333
  this.#heldInbound.length = 0;
224
334
  this.#unregisterAgent?.();
225
335
  this.#unregisterAgent = undefined;
@@ -230,8 +340,8 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
230
340
  try {
231
341
  this.terminate();
232
342
  } catch { /* ignore */ }
233
- this.#inboundDeduper.clear();
234
- this.#inboxDeduper.clear();
343
+ this.#inboundDeduper.clear();
344
+ this.#sideEventDeduper.clear();
235
345
  this.#guildCatalog.clear();
236
346
  this.#started = false;
237
347
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
@@ -240,46 +350,70 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
240
350
  async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
241
351
  if (!this.#started) throw new Error('icqq endpoint 未连接');
242
352
  const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
243
- const content = Array.isArray(payload)
244
- ? materializeOutboundBase64(payload, mediaMode)
245
- : payload;
246
- const message = formatOutboundBody(content);
247
- const parsed = icqqOutboundTarget(conversation);
248
- let result: { message_id?: unknown };
353
+ const prepared = isIcqqSegmentPayload(payload)
354
+ ? prepareIcqqOutboundMedia(payload, mediaMode)
355
+ : { content: payload, dispose() {} };
249
356
  try {
250
- switch (parsed.kind) {
251
- case 'private':
252
- result = await this.sendPrivateMsg(parsed.userId, message as Sendable);
253
- break;
254
- case 'group':
255
- result = await this.sendGroupMsg(parsed.groupId, message as Sendable);
256
- break;
357
+ const message = formatOutboundBody(prepared.content);
358
+ const preview = formatIcqqSendablePreview(message);
359
+ const parsed = icqqOutboundTarget(conversation);
360
+ let result: { message_id?: unknown };
361
+ try {
362
+ result = await this.#sendNative(parsed, message);
363
+ } catch (error) {
364
+ this.#logger.warn(formatCompact({
365
+ op: 'icqq_send_failed',
366
+ target: `${conversation.kind}:${conversation.id}`,
367
+ error: describeUnknownError(error),
368
+ preview: truncatePreview(preview, 120),
369
+ }));
370
+ throw error;
371
+ }
372
+ const messageId = String(result?.message_id ?? `sent_${Date.now()}`);
373
+ this.#logger.info(
374
+ `send ${conversation.kind}:${conversation.id} | id: ${messageId} | ${truncatePreview(preview, 80)}`,
375
+ );
376
+ return messageId;
377
+ } finally {
378
+ prepared.dispose();
379
+ }
380
+ }
381
+
382
+ async #sendNative(
383
+ target: ReturnType<typeof icqqOutboundTarget>,
384
+ message: Sendable,
385
+ ): Promise<{ message_id?: unknown }> {
386
+ if (isIcqqFileElement(message)) {
387
+ switch (target.kind) {
388
+ case 'private': {
389
+ const id = await this.pickFriend(target.userId).sendFile(message.file, message.name);
390
+ return { message_id: id };
391
+ }
392
+ case 'group': {
393
+ const stat = await this.pickGroup(target.groupId).sendFile(message.file, '/', message.name);
394
+ return { message_id: stat.fid };
395
+ }
257
396
  case 'temp':
258
- result = await this.sendTempMsg(parsed.groupId, parsed.userId, message as Sendable);
259
- break;
260
397
  case 'channel':
261
- result = await this.sendGuildMsg(parsed.guildId, parsed.channelId, message as Sendable) as unknown as { message_id?: unknown };
262
- break;
398
+ throw new TypeError(`ICQQ ${target.kind} conversation does not support file delivery`);
263
399
  }
264
- } catch (error) {
265
- this.#logger.warn(formatCompact({
266
- op: 'icqq_send_failed',
267
- target: `${conversation.kind}:${conversation.id}`,
268
- error: describeUnknownError(error),
269
- preview: truncatePreview(typeof message === 'string' ? message : String(message), 120),
270
- }));
271
- throw error;
272
400
  }
273
- const messageId = String(result?.message_id ?? `sent_${Date.now()}`);
274
- this.#logger.info(
275
- `send ${conversation.kind}:${conversation.id} | id: ${messageId} | ${truncatePreview(typeof message === 'string' ? message : String(message), 80)}`,
276
- );
277
- return messageId;
401
+ switch (target.kind) {
402
+ case 'private':
403
+ return this.sendPrivateMsg(target.userId, message);
404
+ case 'group':
405
+ return this.sendGroupMsg(target.groupId, message);
406
+ case 'temp':
407
+ return this.sendTempMsg(target.groupId, target.userId, message);
408
+ case 'channel':
409
+ return this.sendGuildMsg(target.guildId, target.channelId, message) as unknown as { message_id?: unknown };
410
+ }
278
411
  }
279
412
 
280
- admit(msg: IcqqInboundMessage): void {
413
+ admit(msg: IcqqInboundMessage, admittedBeforeRetire = false): void {
281
414
  const conversation = msg.conversation;
282
- if (!this.#open) {
415
+ if (!this.#open && !admittedBeforeRetire) {
416
+ if (this.#retiring) return;
283
417
  if (this.#heldInbound.length >= INBOUND_HOLD_LIMIT) {
284
418
  const dropped = this.#heldInbound.shift();
285
419
  this.#logger.warn(formatCompact({
@@ -311,8 +445,6 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
311
445
  + (mentioned ? ' (mentioned)' : '')
312
446
  + ` | ${truncatePreview(msg.content, 80)}`,
313
447
  );
314
- const quoteId = msg.metadata?.quote_id;
315
- const { quote_id: _dropQuoteId, ...restMetadata } = msg.metadata ?? {};
316
448
  void this.#options.gateway.receive({
317
449
  conversation,
318
450
  message: { conversation, id: msg.id },
@@ -321,10 +453,10 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
321
453
  sender: msg.sender,
322
454
  endpointId: this.endpointName,
323
455
  ...(mentioned ? { mentioned: true } : {}),
324
- ...(quoteId ? { replyTo: { id: String(quoteId) } } : {}),
456
+ ...(msg.replyTo ? { replyTo: msg.replyTo } : {}),
325
457
  metadata: Object.freeze({
326
458
  channelType: msg.channelType,
327
- ...restMetadata,
459
+ ...(msg.metadata ?? {}),
328
460
  }),
329
461
  }).catch((err) => {
330
462
  this.#logger.warn(formatCompact({
@@ -350,23 +482,52 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
350
482
  }));
351
483
  }
352
484
  };
353
- this.on('message.private', (event) => safe('message.private', () => this.#handleMessageEvent('message.private', event)));
354
- this.on('message.group', (event) => safe('message.group', () => this.#handleMessageEvent('message.group', event)));
485
+ this.on('message.private', (event) => this.#startMessageEvent('message.private', event));
486
+ this.on('message.group', (event) => this.#startMessageEvent('message.group', event));
355
487
  this.on('message.guild', (event) => safe('message.guild', () => this.#handleGuildEvent(event)));
356
- this.on('request.friend', (event) => safe('request.friend', () => this.#recordInboxRequest(serializeIcqqEvent(event))));
357
- this.on('request.group', (event) => safe('request.group', () => this.#recordInboxRequest(serializeIcqqEvent(event))));
358
- this.on('notice.friend', (event) => safe('notice.friend', () => this.#recordInboxNotice(serializeIcqqEvent(event))));
359
- this.on('notice.group', (event) => safe('notice.group', () => this.#recordInboxNotice(serializeIcqqEvent(event))));
488
+ this.on('request.friend', (event) => safe('request.friend', () => this.#onRequestEvent(serializeIcqqEvent(event))));
489
+ this.on('request.group', (event) => safe('request.group', () => this.#onRequestEvent(serializeIcqqEvent(event))));
490
+ this.on('notice.friend', (event) => safe('notice.friend', () => this.#onNoticeEvent(serializeIcqqEvent(event))));
491
+ this.on('notice.group', (event) => safe('notice.group', () => this.#onNoticeEvent(serializeIcqqEvent(event))));
492
+ this.on('system.online', (event) => safe('system.online', () => {
493
+ this.#resolveStartOnline?.();
494
+ this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
495
+ this.#logSystemEvent('online', event);
496
+ }));
360
497
  this.on('system.offline', (event) => this.#logSystemEvent('offline', event));
361
498
  this.on('system.offline.network', (event) => this.#logSystemEvent('offline.network', event));
362
499
  this.on('system.offline.kickoff', (event) => this.#logSystemEvent('offline.kickoff', event));
363
- this.on('system.login.qrcode', () => {
364
- this.#logger.info('icqq 收到二维码登录事件,请按 icqq 默认流程扫码后继续登录');
500
+ this.on('system.login.qrcode', (event) => {
501
+ this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
502
+ this.#handleLoginChallenge('qrcode', event);
503
+ });
504
+ this.on('system.login.device', (event) => this.#handleLoginChallenge('device', event));
505
+ this.on('system.login.slider', (event) => this.#handleLoginChallenge('slider', event));
506
+ this.on('system.login.error', (event) => {
507
+ this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
508
+ this.#logSystemEvent('login.error', event);
509
+ });
510
+ this.on('system.login.auth', (event) => this.#handleLoginChallenge('auth', event));
511
+ }
512
+
513
+ #handleLoginChallenge(
514
+ step: 'qrcode' | 'slider' | 'device' | 'auth',
515
+ event: unknown,
516
+ ): void {
517
+ const payload = serializeIcqqEvent(event) ?? (event && typeof event === 'object'
518
+ ? event as Record<string, unknown>
519
+ : null);
520
+ this.#logSystemEvent(`login.${step}`, event);
521
+ void runIcqqLoginAssistStep({
522
+ assist: this.#options.loginAssist,
523
+ client: this,
524
+ adapter: 'icqq',
525
+ endpointKey: this.endpointName,
526
+ owner: this.#loginAssistOwner,
527
+ logger: this.#logger,
528
+ step,
529
+ event: payload ?? event,
365
530
  });
366
- this.on('system.login.device', (event) => this.#logSystemEvent('login.device', event));
367
- this.on('system.login.slider', (event) => this.#logSystemEvent('login.slider', event));
368
- this.on('system.login.error', (event) => this.#logSystemEvent('login.error', event));
369
- this.on('system.login.auth', (event) => this.#logSystemEvent('login.auth', event));
370
531
  }
371
532
 
372
533
  #logSystemEvent(type: string, event: unknown): void {
@@ -376,9 +537,58 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
376
537
  endpoint: this.endpointName,
377
538
  ...(payload ? { event: JSON.stringify(payload) } : {}),
378
539
  }));
540
+ this.#dispatchIcqqSystemSideEvent(type, payload);
541
+ }
542
+
543
+ #dispatchIcqqSideEvent(payload: Record<string, unknown>): void {
544
+ const sideEvents = this.#options.sideEvents;
545
+ const postType = String(payload.post_type ?? '');
546
+ const isRequest = postType === 'request' || postType.startsWith('request.');
547
+ const flag = payload.flag != null ? String(payload.flag) : '';
548
+ void receiveOneBotLikeSideEvent(sideEvents, {
549
+ adapter: 'icqq',
550
+ endpointKey: this.endpointName,
551
+ platform: 'icqq',
552
+ raw: payload,
553
+ ...(isRequest ? {
554
+ approve: (id, remark) => this.#approveRequest(flag || id, remark),
555
+ reject: (id, reason) => this.#rejectRequest(flag || id, reason),
556
+ } : {}),
557
+ }).catch((err) => {
558
+ this.#logger.warn(formatCompact({
559
+ op: 'icqq_side_event_failed',
560
+ endpoint: this.endpointName,
561
+ post_type: postType || undefined,
562
+ error: err instanceof Error ? err.message : String(err),
563
+ }));
564
+ });
565
+ }
566
+
567
+ #dispatchIcqqSystemSideEvent(type: string, payload: Record<string, unknown> | null): void {
568
+ const sideEvents = this.#options.sideEvents;
569
+ const { scene_type, sub_type } = parseIcqqSystemScene(type);
570
+ void sideEvents.receiveSystem(SystemEvent.from(payload ?? {}, {
571
+ $id: `system:${this.endpointName}:${type}:${Date.now()}`,
572
+ $adapter: 'icqq' as never,
573
+ $endpoint: this.endpointName,
574
+ $type: 'system',
575
+ $scene_id: this.endpointName,
576
+ $scene_type: scene_type,
577
+ $sub_type: sub_type,
578
+ $timestamp: Date.now(),
579
+ })).catch((err) => {
580
+ this.#logger.warn(formatCompact({
581
+ op: 'icqq_system_side_event_failed',
582
+ endpoint: this.endpointName,
583
+ type,
584
+ error: err instanceof Error ? err.message : String(err),
585
+ }));
586
+ });
379
587
  }
380
588
 
381
- #handleMessageEvent(eventName: 'message.private' | 'message.group', event: unknown): void {
589
+ async #handleMessageEvent(eventName: 'message.private' | 'message.group', event: unknown): Promise<void> {
590
+ if (this.#retiring) return;
591
+ const admittedBeforeRetire = this.#open;
382
592
  const payload = serializeIcqqEvent(event);
383
593
  if (!payload || !isIcqqMessagePostType(payload)) {
384
594
  this.#logger.debug(formatCompact({
@@ -400,6 +610,14 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
400
610
  }));
401
611
  return;
402
612
  }
613
+ const contact = this.#resolveInboundContact(data);
614
+ if (contact) {
615
+ await resolveIcqqInboundMedia(data.message, {
616
+ getPttUrl: (element) => contact.getPttUrl(element as PttElem),
617
+ getVideoUrl: (element) => contact.getVideoUrl(element as VideoElem),
618
+ });
619
+ if (!this.#started) return;
620
+ }
403
621
  const normalized = normalizeIcqqInboundMessage(data);
404
622
  if (!normalized) {
405
623
  this.#logger.debug(formatCompact({
@@ -420,13 +638,31 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
420
638
  }));
421
639
  return;
422
640
  }
641
+ const conversation = icqqInboundConversation(String(this.#options.id), {
642
+ channelType: normalized.channelType,
643
+ channelId: normalized.channelId,
644
+ channelParentGroupId: normalized.channelParentGroupId,
645
+ });
646
+ const quoteId = resolveIcqqQuoteIdFromEvent(data);
647
+ const quoted = messageLookupFromIcqqSource(findIcqqNestedMessageSource(data));
648
+ if (quoteId && quoted) {
649
+ this.#messageContent.set(quoteId, Object.freeze({
650
+ ref: Object.freeze({ conversation, id: quoteId }),
651
+ ...(quoted.sender?.id ? { actor: Object.freeze({
652
+ id: quoted.sender.id,
653
+ ...(quoted.sender.name ? { displayName: quoted.sender.name } : {}),
654
+ }) } : {}),
655
+ segments: Object.freeze(toCanonicalSegments(
656
+ Array.isArray(quoted.content)
657
+ ? quoted.content
658
+ : [{ type: 'text', data: { text: quoted.content } }],
659
+ )),
660
+ timestamp: quoted.time ? quoted.time * (quoted.time < 1e12 ? 1000 : 1) : Date.now(),
661
+ }));
662
+ }
423
663
  this.admit({
424
664
  id: normalized.messageId,
425
- conversation: icqqInboundConversation(String(this.#options.id), {
426
- channelType: normalized.channelType,
427
- channelId: normalized.channelId,
428
- channelParentGroupId: normalized.channelParentGroupId,
429
- }),
665
+ conversation,
430
666
  content: formatInboundContent(normalized.rawMessage),
431
667
  segments: normalized.content,
432
668
  sender: {
@@ -435,13 +671,47 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
435
671
  ...(normalized.senderRole ? { roles: [normalized.senderRole] } : {}),
436
672
  },
437
673
  channelType: normalized.channelType,
438
- metadata: buildIcqqQuoteMetadata(data, {
674
+ ...(quoteId ? { replyTo: { id: quoteId } } : {}),
675
+ metadata: {
439
676
  nickname: normalized.nickname,
440
677
  senderRole: normalized.senderRole,
441
- }),
678
+ },
679
+ }, admittedBeforeRetire);
680
+ }
681
+
682
+ #startMessageEvent(eventName: 'message.private' | 'message.group', event: unknown): void {
683
+ const operation = this.#handleMessageEvent(eventName, event);
684
+ this.#inflightInbound.add(operation);
685
+ void operation.catch((error) => {
686
+ this.#logInboundHandlerFailure(eventName, error);
687
+ }).finally(() => {
688
+ this.#inflightInbound.delete(operation);
442
689
  });
443
690
  }
444
691
 
692
+ #resolveInboundContact(data: IcqqMessageEvent) {
693
+ const userId = Number(data.user_id ?? data.from_id);
694
+ if (!Number.isFinite(userId) || userId <= 0) return null;
695
+ if (data.message_type === 'group' || data.group_id != null) {
696
+ const groupId = Number(data.group_id);
697
+ return Number.isFinite(groupId) && groupId > 0 ? this.pickGroup(groupId) : null;
698
+ }
699
+ const sourceGroupId = Number(data.sender?.group_id);
700
+ if (Number.isFinite(sourceGroupId) && sourceGroupId > 0) {
701
+ return this.pickMember(sourceGroupId, userId);
702
+ }
703
+ return this.pickFriend(userId);
704
+ }
705
+
706
+ #logInboundHandlerFailure(event: string, error: unknown): void {
707
+ this.#logger.warn(formatCompact({
708
+ op: 'icqq_inbound_handler_failed',
709
+ endpoint: this.endpointName,
710
+ event,
711
+ error: describeUnknownError(error),
712
+ }));
713
+ }
714
+
445
715
  #handleGuildEvent(event: unknown): void {
446
716
  const payload = serializeIcqqEvent(event);
447
717
  if (!payload || typeof payload !== 'object') return;
@@ -484,61 +754,76 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
484
754
  });
485
755
  }
486
756
 
487
- #recordInboxRequest(payload: Record<string, unknown> | null): void {
757
+ #onRequestEvent(payload: Record<string, unknown> | null): void {
488
758
  if (!payload || !isIcqqRequestPayload(payload)) return;
489
- const hooks = this.#options.inbox;
490
- if (!hooks) return;
491
- const row = buildIcqqInboxRequestRow(payload, this.#inboxBase());
492
- if (!row) return;
493
- if (!this.#inboxDeduper.shouldProcess(`request:${String(row.platform_request_id)}`)) return;
494
- void hooks.recordRequest(row);
495
- hooks.publish?.('endpoint:request', row);
759
+ const flag = payload.flag != null ? String(payload.flag) : (payload.seq != null ? String(payload.seq) : '');
760
+ if (flag && !this.#sideEventDeduper.shouldProcess(`request:${flag}`)) return;
761
+ this.#dispatchIcqqSideEvent(payload);
496
762
  }
497
763
 
498
- #recordInboxNotice(payload: Record<string, unknown> | null): void {
764
+ #onNoticeEvent(payload: Record<string, unknown> | null): void {
499
765
  if (!payload || !isIcqqNoticePayload(payload)) return;
500
- const hooks = this.#options.inbox;
501
- if (!hooks) return;
502
- const row = buildIcqqInboxNoticeRow(payload, this.#inboxBase());
503
- if (!row) return;
504
- if (!this.#inboxDeduper.shouldProcess(`notice:${String(row.platform_notice_id)}`)) return;
505
- void hooks.recordNotice(row);
506
- hooks.publish?.('endpoint:notice', row);
766
+ const key = [
767
+ payload.time,
768
+ payload.operator_id ?? payload.user_id,
769
+ payload.notice_type ?? payload.type,
770
+ payload.sub_type,
771
+ ].join('_');
772
+ if (!this.#sideEventDeduper.shouldProcess(`notice:${key}`)) return;
773
+ this.#dispatchIcqqSideEvent(payload);
507
774
  }
508
775
 
509
- #inboxBase(): { adapter: string; endpointKey: string } {
510
- const id = String(this.#options.id);
511
- return { adapter: id.split('\0').pop() ?? id, endpointKey: this.endpointName };
776
+ async #listPendingRequests(): Promise<readonly EndpointPendingRequest[]> {
777
+ const messages = await this.getSystemMsg();
778
+ const rows: EndpointPendingRequest[] = [];
779
+ for (const raw of messages) {
780
+ const msg = raw as unknown as IcqqSystemMessage & { request_type?: string };
781
+ const kind = msg.request_type === 'friend' ? 'friend' : 'group';
782
+ const platformRequestId = (msg.flag && String(msg.flag).trim())
783
+ || (msg.seq != null ? String(msg.seq) : '');
784
+ if (!platformRequestId || msg.user_id == null) continue;
785
+ const createdAt = Number(msg.time);
786
+ rows.push(Object.freeze({
787
+ platform_request_id: platformRequestId,
788
+ type: kind,
789
+ scene_type: kind === 'group' ? 'group' : null,
790
+ scene_id: String(kind === 'group' ? (msg.group_id ?? msg.user_id) : msg.user_id),
791
+ sub_type: kind === 'group' ? (msg.type != null ? String(msg.type) : null) : null,
792
+ actor_id: String(msg.user_id),
793
+ actor_name: msg.nickname != null ? String(msg.nickname) : null,
794
+ comment: msg.comment != null ? String(msg.comment) : null,
795
+ created_at: Number.isFinite(createdAt) && createdAt > 0
796
+ ? (createdAt < 1e12 ? createdAt * 1000 : createdAt)
797
+ : Date.now(),
798
+ }));
799
+ }
800
+ return Object.freeze(rows);
512
801
  }
513
802
 
514
- async #pullPendingSystemMessages(): Promise<void> {
515
- const hooks = this.#options.inbox;
516
- if (!hooks) return;
803
+ async #pullPendingSystemMessages(signal: AbortSignal): Promise<void> {
517
804
  try {
518
- const messages = await this.getSystemMsg();
519
- const friendRequests: IcqqSystemMessage[] = [];
520
- const groupRequests: IcqqSystemMessage[] = [];
521
- for (const msg of messages) {
522
- if ((msg as { request_type?: string }).request_type === 'friend') {
523
- friendRequests.push(msg as unknown as IcqqSystemMessage);
524
- } else {
525
- groupRequests.push(msg as unknown as IcqqSystemMessage);
526
- }
527
- }
528
- const base = this.#inboxBase();
529
- const rows = [
530
- ...friendRequests.map((m) => buildIcqqSystemRequestRow(m, 'friend', base)),
531
- ...groupRequests.map((m) => buildIcqqSystemRequestRow(m, 'group', base)),
532
- ];
533
- for (const row of rows) {
534
- if (!row) continue;
535
- if (!this.#inboxDeduper.shouldProcess(`request:${String(row.platform_request_id)}`)) continue;
536
- void hooks.recordRequest(row);
537
- hooks.publish?.('endpoint:request', row);
805
+ const messages = await raceAbort(Promise.resolve(this.getSystemMsg()), signal);
806
+ for (const raw of messages) {
807
+ signal.throwIfAborted();
808
+ const msg = raw as unknown as IcqqSystemMessage & { request_type?: string };
809
+ const kind = msg.request_type === 'friend' ? 'friend' : 'group';
810
+ const flag = msg.flag != null ? String(msg.flag) : (msg.seq != null ? String(msg.seq) : '');
811
+ if (!flag || !this.#sideEventDeduper.shouldProcess(`request:${flag}`)) continue;
812
+ this.#dispatchIcqqSideEvent({
813
+ post_type: 'request',
814
+ request_type: kind,
815
+ flag: msg.flag,
816
+ user_id: msg.user_id,
817
+ group_id: msg.group_id,
818
+ comment: msg.comment,
819
+ time: msg.time,
820
+ sub_type: msg.type,
821
+ });
538
822
  }
539
823
  } catch (error) {
824
+ if (signal.aborted) throw signal.reason ?? error;
540
825
  this.#logger.debug(formatCompact({
541
- op: 'inbox_pull_system_msg',
826
+ op: 'side_event_pull_system_msg',
542
827
  endpoint: this.endpointName,
543
828
  error: error instanceof Error ? error.message : String(error),
544
829
  }));
@@ -578,6 +863,41 @@ export class IcqqEndpoint extends Client implements EndpointInstance {
578
863
  }
579
864
  }
580
865
 
866
+ function formatIcqqSendablePreview(message: Sendable): string {
867
+ if (typeof message === 'string') return message;
868
+ try {
869
+ return JSON.stringify(message, (_key, value) => Buffer.isBuffer(value) ? '<buffer>' : value);
870
+ } catch {
871
+ return '[ICQQ Sendable]';
872
+ }
873
+ }
874
+
875
+ function isIcqqSegmentPayload(payload: unknown): payload is import('zhin.js').SendContent {
876
+ if (Array.isArray(payload)) return true;
877
+ return typeof payload === 'object'
878
+ && payload !== null
879
+ && typeof (payload as { type?: unknown }).type === 'string';
880
+ }
881
+
882
+ function isIcqqFileElement(message: Sendable): message is FileElem {
883
+ return !Array.isArray(message)
884
+ && typeof message !== 'string'
885
+ && message.type === 'file';
886
+ }
887
+
888
+ async function raceAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
889
+ signal.throwIfAborted();
890
+ let rejectAbort!: (reason: unknown) => void;
891
+ const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject; });
892
+ const onAbort = () => rejectAbort(signal.reason ?? new Error('ICQQ operation aborted'));
893
+ signal.addEventListener('abort', onAbort, { once: true });
894
+ try {
895
+ return await Promise.race([operation, aborted]);
896
+ } finally {
897
+ signal.removeEventListener('abort', onAbort);
898
+ }
899
+ }
900
+
581
901
  function describeUnknownError(error: unknown): string {
582
902
  if (error instanceof Error) return error.stack ?? error.message;
583
903
  if (error && typeof error === 'object') {
@@ -625,36 +945,6 @@ function resolveNativeClientConfig(config: ResolvedIcqqConfig): NativeIcqqClient
625
945
  };
626
946
  }
627
947
 
628
- function buildIcqqQuoteMetadata(
629
- data: IcqqMessageEvent,
630
- base: Record<string, unknown>,
631
- ): Record<string, unknown> {
632
- const metadata: Record<string, unknown> = { ...base };
633
- const quoteId = resolveIcqqQuoteIdFromEvent(data);
634
- if (quoteId) metadata.quote_id = quoteId;
635
- const quoted = quotedPayloadFromIcqqSource(findIcqqNestedMessageSource(data));
636
- if (quoted?.sender?.id) metadata.quote_sender_id = quoted.sender.id;
637
- if (quoted?.sender?.name) metadata.quote_sender_name = quoted.sender.name;
638
- const quoteText = quoted
639
- ? (typeof quoted.content === 'string'
640
- ? quoted.content
641
- : quoted.content
642
- .map((seg) =>
643
- seg && typeof seg === 'object' && seg.type === 'text'
644
- ? String((seg.data as { text?: unknown } | undefined)?.text ?? '')
645
- : '',
646
- )
647
- .join('')
648
- ).trim() || quoted.raw || ''
649
- : '';
650
- if (quoteText) metadata.quote_content = quoteText;
651
- return metadata;
652
- }
653
-
654
- const INBOUND_HOLD_LIMIT = 32;
655
-
656
- const ICQQ_SERIALIZE_SKIP = ['group', 'member', 'friend', 'discuss', 'client'] as const;
657
-
658
948
  function serializeIcqqEvent(event: unknown): Record<string, unknown> | null {
659
949
  if (!event || typeof event !== 'object') return null;
660
950
  const nativeToJSON = (event as { toJSON?: (keys: string[]) => unknown }).toJSON;
@@ -675,3 +965,23 @@ function serializeIcqqEvent(event: unknown): Record<string, unknown> | null {
675
965
  }
676
966
  return Object.keys(out).length > 0 ? out : null;
677
967
  }
968
+
969
+ function parseIcqqSystemScene(type: string): { scene_type: string; sub_type: string } {
970
+ if (type.startsWith('login.')) {
971
+ return { scene_type: 'login', sub_type: type.slice('login.'.length) || 'unknown' };
972
+ }
973
+ if (type.startsWith('offline.')) {
974
+ return { scene_type: 'offline', sub_type: type.slice('offline.'.length) || 'unknown' };
975
+ }
976
+ if (type === 'online') {
977
+ return { scene_type: 'online', sub_type: 'online' };
978
+ }
979
+ if (type === 'offline') {
980
+ return { scene_type: 'offline', sub_type: 'offline' };
981
+ }
982
+ return { scene_type: 'system', sub_type: type };
983
+ }
984
+
985
+ const INBOUND_HOLD_LIMIT = 32;
986
+
987
+ const ICQQ_SERIALIZE_SKIP = ['group', 'member', 'friend', 'discuss', 'client'] as const;