@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.
package/lib/endpoint.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { Client, parseGroupMessageId, } from '@icqqjs/icqq';
2
2
  import { inspect } from 'node:util';
3
+ import { receiveOneBotLikeSideEvent, SystemEvent, toCanonicalSegments } from '@zhin.js/core';
3
4
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
4
5
  import { registerIcqqAgentEndpoint } from './icqq-agent-deps.js';
5
- import { InboundMessageDeduper, findIcqqNestedMessageSource, isIcqqBotMentioned, isIcqqMessagePostType, normalizeIcqqInboundMessage, quotedPayloadFromIcqqSource, resolveIcqqQuoteIdFromEvent, shouldSkipSelfInboundMessage, } from './icqq-inbound.js';
6
- import { buildIcqqInboxNoticeRow, buildIcqqInboxRequestRow, buildIcqqSystemRequestRow, isIcqqNoticePayload, isIcqqRequestPayload, } from './icqq-inbox.js';
6
+ import { runIcqqLoginAssistStep } from './icqq-login-assist.js';
7
+ import { InboundMessageDeduper, findIcqqNestedMessageSource, isIcqqBotMentioned, isIcqqMessagePostType, normalizeIcqqInboundMessage, messageLookupFromIcqqSource, resolveIcqqQuoteIdFromEvent, resolveIcqqInboundMedia, shouldSkipSelfInboundMessage, } from './icqq-inbound.js';
8
+ import { isIcqqNoticePayload, isIcqqRequestPayload, } from './icqq-inbox.js';
7
9
  import { IcqqGuildCatalog, normalizeIcqqGuildInboundMessage, } from './icqq-guild.js';
8
- import { materializeOutboundBase64, resolveIcqqOutboundMediaMode, } from './outbound-media.js';
10
+ import { prepareIcqqOutboundMedia, resolveIcqqOutboundMediaMode, } from './outbound-media.js';
9
11
  import { formatInboundContent, formatOutboundBody, icqqInboundConversation, icqqOutboundTarget, } from './protocol.js';
12
+ import { normalizeForwardMsgResponse } from './forward-msg.js';
10
13
  const BOUND_EVENTS = [
11
14
  'message.private',
12
15
  'message.group',
@@ -31,11 +34,89 @@ export class IcqqEndpoint extends Client {
31
34
  endpointName;
32
35
  #guildCatalog = new IcqqGuildCatalog();
33
36
  #inboundDeduper = new InboundMessageDeduper();
34
- #inboxDeduper = new InboundMessageDeduper();
37
+ #sideEventDeduper = new InboundMessageDeduper();
38
+ #loginAssistOwner = Object.freeze({});
35
39
  #open = false;
40
+ #retiring = false;
36
41
  #started = false;
42
+ #resolveStartOnline;
37
43
  #heldInbound = [];
38
44
  #unregisterAgent;
45
+ #inflightInbound = new Set();
46
+ #messageContent = new Map();
47
+ content = Object.freeze({
48
+ resolve: async (reference, context) => {
49
+ context.signal.throwIfAborted();
50
+ if (reference.kind === 'message') {
51
+ const cached = this.#messageContent.get(reference.message.id);
52
+ return cached
53
+ ? Object.freeze({ status: 'resolved', reference, value: cached })
54
+ : Object.freeze({ status: 'not_found', code: 'icqq_message_not_observed' });
55
+ }
56
+ if (reference.kind === 'forward') {
57
+ try {
58
+ const entries = await this.#resolveForwardEntries(reference.forwardId, context, { remainingEntries: context.maxEntries, path: new Set() }, 0);
59
+ if (entries.length === 0)
60
+ return Object.freeze({ status: 'not_found', code: 'icqq_forward_not_found' });
61
+ return Object.freeze({ status: 'resolved', reference, value: Object.freeze(entries) });
62
+ }
63
+ catch (error) {
64
+ if (context.signal.aborted)
65
+ throw error;
66
+ return Object.freeze({
67
+ status: 'failed',
68
+ code: 'icqq_forward_fetch_failed',
69
+ message: error instanceof Error ? error.message : String(error),
70
+ });
71
+ }
72
+ }
73
+ if (reference.media.kind === 'url' || reference.media.kind === 'base64' || reference.media.kind === 'path') {
74
+ return Object.freeze({ status: 'resolved', reference, value: reference.media });
75
+ }
76
+ return Object.freeze({ status: 'unsupported', code: 'icqq_media_reference_unsupported' });
77
+ },
78
+ });
79
+ async #resolveForwardEntries(forwardId, context, state, depth) {
80
+ context.signal.throwIfAborted();
81
+ if (state.remainingEntries <= 0 || state.path.has(forwardId))
82
+ return Object.freeze([]);
83
+ state.path.add(forwardId);
84
+ try {
85
+ // ICQQ does not expose a cancellable getForwardMsg API. Keep the caller's
86
+ // generation lease until the native operation actually settles, then honor
87
+ // cancellation before publishing any resolved content.
88
+ const raw = await this.getForwardMsg(forwardId);
89
+ context.signal.throwIfAborted();
90
+ const entries = normalizeForwardMsgResponse(raw).slice(0, state.remainingEntries);
91
+ state.remainingEntries -= entries.length;
92
+ const expanded = [];
93
+ for (const entry of entries) {
94
+ context.signal.throwIfAborted();
95
+ const segments = [];
96
+ for (const segment of entry.segments) {
97
+ if (segment.type !== 'forward' || depth >= context.maxDepth) {
98
+ segments.push(segment);
99
+ continue;
100
+ }
101
+ const nestedId = String(segment.data.forward_id ?? '').trim();
102
+ if (!nestedId || state.path.has(nestedId) || state.remainingEntries <= 0) {
103
+ segments.push(segment);
104
+ continue;
105
+ }
106
+ const nested = await this.#resolveForwardEntries(nestedId, context, state, depth + 1);
107
+ segments.push(Object.freeze({
108
+ ...segment,
109
+ data: Object.freeze({ ...segment.data, ...(nested.length > 0 ? { entries: nested } : {}) }),
110
+ }));
111
+ }
112
+ expanded.push(Object.freeze({ ...entry, segments: Object.freeze(segments) }));
113
+ }
114
+ return Object.freeze(expanded);
115
+ }
116
+ finally {
117
+ state.path.delete(forwardId);
118
+ }
119
+ }
39
120
  management = Object.freeze({
40
121
  listFriends: async () => Array.from(this.fl.values()).map((f) => ({
41
122
  user_id: f.user_id,
@@ -51,6 +132,7 @@ export class IcqqEndpoint extends Client {
51
132
  return this.#guildCatalog.getGuildChannelList();
52
133
  },
53
134
  listGroupMembers: async (groupId) => Array.from((await this.getGroupMemberList(Number(groupId))).values()),
135
+ listRequests: () => this.#listPendingRequests(),
54
136
  approveRequest: (requestId, remark) => this.#approveRequest(requestId, remark),
55
137
  rejectRequest: (requestId, reason) => this.#rejectRequest(requestId, reason),
56
138
  kickGroupMember: async (groupId, userId) => {
@@ -118,18 +200,25 @@ export class IcqqEndpoint extends Client {
118
200
  }));
119
201
  });
120
202
  }
121
- async start() {
203
+ async start(signal) {
122
204
  if (this.#started)
123
205
  return;
206
+ signal.throwIfAborted();
124
207
  this.#started = true;
208
+ let resolveOnline;
209
+ const onlineReady = new Promise((resolve) => {
210
+ resolveOnline = resolve;
211
+ });
212
+ const onAbort = () => resolveOnline();
213
+ this.#resolveStartOnline = resolveOnline;
214
+ signal.addEventListener('abort', onAbort, { once: true });
125
215
  try {
126
- const onlineReady = new Promise((resolve) => {
127
- this.on('system.online', () => resolve());
128
- });
129
216
  this.#bindClientEvents();
130
- await this.login(this.#options.config.password);
217
+ await raceAbort(Promise.resolve(this.login(this.#options.config.password)), signal);
131
218
  await onlineReady;
132
- await this.#pullPendingSystemMessages();
219
+ signal.throwIfAborted();
220
+ await this.#pullPendingSystemMessages(signal);
221
+ signal.throwIfAborted();
133
222
  this.#unregisterAgent = registerIcqqAgentEndpoint(this.endpointName, this);
134
223
  this.#logger.info(`connected (direct) | friends: ${this.fl.size} | groups: ${this.gl.size}`);
135
224
  }
@@ -138,18 +227,30 @@ export class IcqqEndpoint extends Client {
138
227
  this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
139
228
  throw error;
140
229
  }
230
+ finally {
231
+ signal.removeEventListener('abort', onAbort);
232
+ if (this.#resolveStartOnline === resolveOnline)
233
+ this.#resolveStartOnline = undefined;
234
+ }
141
235
  }
142
236
  open() {
237
+ this.#retiring = false;
143
238
  this.#open = true;
144
239
  const held = this.#heldInbound.splice(0);
145
240
  for (const msg of held)
146
241
  this.admit(msg);
147
242
  }
148
- close() {
243
+ async close() {
244
+ this.#retiring = true;
149
245
  this.#open = false;
246
+ await Promise.allSettled([...this.#inflightInbound]);
150
247
  }
151
248
  async stop() {
249
+ this.#retiring = true;
152
250
  this.#open = false;
251
+ await Promise.allSettled([...this.#inflightInbound]);
252
+ this.#resolveStartOnline?.();
253
+ this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'endpoint_stopped');
153
254
  this.#heldInbound.length = 0;
154
255
  this.#unregisterAgent?.();
155
256
  this.#unregisterAgent = undefined;
@@ -164,7 +265,7 @@ export class IcqqEndpoint extends Client {
164
265
  }
165
266
  catch { /* ignore */ }
166
267
  this.#inboundDeduper.clear();
167
- this.#inboxDeduper.clear();
268
+ this.#sideEventDeduper.clear();
168
269
  this.#guildCatalog.clear();
169
270
  this.#started = false;
170
271
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
@@ -173,44 +274,66 @@ export class IcqqEndpoint extends Client {
173
274
  if (!this.#started)
174
275
  throw new Error('icqq endpoint 未连接');
175
276
  const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
176
- const content = Array.isArray(payload)
177
- ? materializeOutboundBase64(payload, mediaMode)
178
- : payload;
179
- const message = formatOutboundBody(content);
180
- const parsed = icqqOutboundTarget(conversation);
181
- let result;
277
+ const prepared = isIcqqSegmentPayload(payload)
278
+ ? prepareIcqqOutboundMedia(payload, mediaMode)
279
+ : { content: payload, dispose() { } };
182
280
  try {
183
- switch (parsed.kind) {
184
- case 'private':
185
- result = await this.sendPrivateMsg(parsed.userId, message);
186
- break;
187
- case 'group':
188
- result = await this.sendGroupMsg(parsed.groupId, message);
189
- break;
281
+ const message = formatOutboundBody(prepared.content);
282
+ const preview = formatIcqqSendablePreview(message);
283
+ const parsed = icqqOutboundTarget(conversation);
284
+ let result;
285
+ try {
286
+ result = await this.#sendNative(parsed, message);
287
+ }
288
+ catch (error) {
289
+ this.#logger.warn(formatCompact({
290
+ op: 'icqq_send_failed',
291
+ target: `${conversation.kind}:${conversation.id}`,
292
+ error: describeUnknownError(error),
293
+ preview: truncatePreview(preview, 120),
294
+ }));
295
+ throw error;
296
+ }
297
+ const messageId = String(result?.message_id ?? `sent_${Date.now()}`);
298
+ this.#logger.info(`send ${conversation.kind}:${conversation.id} | id: ${messageId} | ${truncatePreview(preview, 80)}`);
299
+ return messageId;
300
+ }
301
+ finally {
302
+ prepared.dispose();
303
+ }
304
+ }
305
+ async #sendNative(target, message) {
306
+ if (isIcqqFileElement(message)) {
307
+ switch (target.kind) {
308
+ case 'private': {
309
+ const id = await this.pickFriend(target.userId).sendFile(message.file, message.name);
310
+ return { message_id: id };
311
+ }
312
+ case 'group': {
313
+ const stat = await this.pickGroup(target.groupId).sendFile(message.file, '/', message.name);
314
+ return { message_id: stat.fid };
315
+ }
190
316
  case 'temp':
191
- result = await this.sendTempMsg(parsed.groupId, parsed.userId, message);
192
- break;
193
317
  case 'channel':
194
- result = await this.sendGuildMsg(parsed.guildId, parsed.channelId, message);
195
- break;
318
+ throw new TypeError(`ICQQ ${target.kind} conversation does not support file delivery`);
196
319
  }
197
320
  }
198
- catch (error) {
199
- this.#logger.warn(formatCompact({
200
- op: 'icqq_send_failed',
201
- target: `${conversation.kind}:${conversation.id}`,
202
- error: describeUnknownError(error),
203
- preview: truncatePreview(typeof message === 'string' ? message : String(message), 120),
204
- }));
205
- throw error;
321
+ switch (target.kind) {
322
+ case 'private':
323
+ return this.sendPrivateMsg(target.userId, message);
324
+ case 'group':
325
+ return this.sendGroupMsg(target.groupId, message);
326
+ case 'temp':
327
+ return this.sendTempMsg(target.groupId, target.userId, message);
328
+ case 'channel':
329
+ return this.sendGuildMsg(target.guildId, target.channelId, message);
206
330
  }
207
- const messageId = String(result?.message_id ?? `sent_${Date.now()}`);
208
- this.#logger.info(`send ${conversation.kind}:${conversation.id} | id: ${messageId} | ${truncatePreview(typeof message === 'string' ? message : String(message), 80)}`);
209
- return messageId;
210
331
  }
211
- admit(msg) {
332
+ admit(msg, admittedBeforeRetire = false) {
212
333
  const conversation = msg.conversation;
213
- if (!this.#open) {
334
+ if (!this.#open && !admittedBeforeRetire) {
335
+ if (this.#retiring)
336
+ return;
214
337
  if (this.#heldInbound.length >= INBOUND_HOLD_LIMIT) {
215
338
  const dropped = this.#heldInbound.shift();
216
339
  this.#logger.warn(formatCompact({
@@ -238,8 +361,6 @@ export class IcqqEndpoint extends Client {
238
361
  + (msg.sender ? ` from ${msg.sender.name ?? msg.sender.id}` : '')
239
362
  + (mentioned ? ' (mentioned)' : '')
240
363
  + ` | ${truncatePreview(msg.content, 80)}`);
241
- const quoteId = msg.metadata?.quote_id;
242
- const { quote_id: _dropQuoteId, ...restMetadata } = msg.metadata ?? {};
243
364
  void this.#options.gateway.receive({
244
365
  conversation,
245
366
  message: { conversation, id: msg.id },
@@ -248,10 +369,10 @@ export class IcqqEndpoint extends Client {
248
369
  sender: msg.sender,
249
370
  endpointId: this.endpointName,
250
371
  ...(mentioned ? { mentioned: true } : {}),
251
- ...(quoteId ? { replyTo: { id: String(quoteId) } } : {}),
372
+ ...(msg.replyTo ? { replyTo: msg.replyTo } : {}),
252
373
  metadata: Object.freeze({
253
374
  channelType: msg.channelType,
254
- ...restMetadata,
375
+ ...(msg.metadata ?? {}),
255
376
  }),
256
377
  }).catch((err) => {
257
378
  this.#logger.warn(formatCompact({
@@ -277,23 +398,48 @@ export class IcqqEndpoint extends Client {
277
398
  }));
278
399
  }
279
400
  };
280
- this.on('message.private', (event) => safe('message.private', () => this.#handleMessageEvent('message.private', event)));
281
- this.on('message.group', (event) => safe('message.group', () => this.#handleMessageEvent('message.group', event)));
401
+ this.on('message.private', (event) => this.#startMessageEvent('message.private', event));
402
+ this.on('message.group', (event) => this.#startMessageEvent('message.group', event));
282
403
  this.on('message.guild', (event) => safe('message.guild', () => this.#handleGuildEvent(event)));
283
- this.on('request.friend', (event) => safe('request.friend', () => this.#recordInboxRequest(serializeIcqqEvent(event))));
284
- this.on('request.group', (event) => safe('request.group', () => this.#recordInboxRequest(serializeIcqqEvent(event))));
285
- this.on('notice.friend', (event) => safe('notice.friend', () => this.#recordInboxNotice(serializeIcqqEvent(event))));
286
- this.on('notice.group', (event) => safe('notice.group', () => this.#recordInboxNotice(serializeIcqqEvent(event))));
404
+ this.on('request.friend', (event) => safe('request.friend', () => this.#onRequestEvent(serializeIcqqEvent(event))));
405
+ this.on('request.group', (event) => safe('request.group', () => this.#onRequestEvent(serializeIcqqEvent(event))));
406
+ this.on('notice.friend', (event) => safe('notice.friend', () => this.#onNoticeEvent(serializeIcqqEvent(event))));
407
+ this.on('notice.group', (event) => safe('notice.group', () => this.#onNoticeEvent(serializeIcqqEvent(event))));
408
+ this.on('system.online', (event) => safe('system.online', () => {
409
+ this.#resolveStartOnline?.();
410
+ this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
411
+ this.#logSystemEvent('online', event);
412
+ }));
287
413
  this.on('system.offline', (event) => this.#logSystemEvent('offline', event));
288
414
  this.on('system.offline.network', (event) => this.#logSystemEvent('offline.network', event));
289
415
  this.on('system.offline.kickoff', (event) => this.#logSystemEvent('offline.kickoff', event));
290
- this.on('system.login.qrcode', () => {
291
- this.#logger.info('icqq 收到二维码登录事件,请按 icqq 默认流程扫码后继续登录');
416
+ this.on('system.login.qrcode', (event) => {
417
+ this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
418
+ this.#handleLoginChallenge('qrcode', event);
419
+ });
420
+ this.on('system.login.device', (event) => this.#handleLoginChallenge('device', event));
421
+ this.on('system.login.slider', (event) => this.#handleLoginChallenge('slider', event));
422
+ this.on('system.login.error', (event) => {
423
+ this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
424
+ this.#logSystemEvent('login.error', event);
425
+ });
426
+ this.on('system.login.auth', (event) => this.#handleLoginChallenge('auth', event));
427
+ }
428
+ #handleLoginChallenge(step, event) {
429
+ const payload = serializeIcqqEvent(event) ?? (event && typeof event === 'object'
430
+ ? event
431
+ : null);
432
+ this.#logSystemEvent(`login.${step}`, event);
433
+ void runIcqqLoginAssistStep({
434
+ assist: this.#options.loginAssist,
435
+ client: this,
436
+ adapter: 'icqq',
437
+ endpointKey: this.endpointName,
438
+ owner: this.#loginAssistOwner,
439
+ logger: this.#logger,
440
+ step,
441
+ event: payload ?? event,
292
442
  });
293
- this.on('system.login.device', (event) => this.#logSystemEvent('login.device', event));
294
- this.on('system.login.slider', (event) => this.#logSystemEvent('login.slider', event));
295
- this.on('system.login.error', (event) => this.#logSystemEvent('login.error', event));
296
- this.on('system.login.auth', (event) => this.#logSystemEvent('login.auth', event));
297
443
  }
298
444
  #logSystemEvent(type, event) {
299
445
  const payload = serializeIcqqEvent(event);
@@ -302,8 +448,56 @@ export class IcqqEndpoint extends Client {
302
448
  endpoint: this.endpointName,
303
449
  ...(payload ? { event: JSON.stringify(payload) } : {}),
304
450
  }));
451
+ this.#dispatchIcqqSystemSideEvent(type, payload);
452
+ }
453
+ #dispatchIcqqSideEvent(payload) {
454
+ const sideEvents = this.#options.sideEvents;
455
+ const postType = String(payload.post_type ?? '');
456
+ const isRequest = postType === 'request' || postType.startsWith('request.');
457
+ const flag = payload.flag != null ? String(payload.flag) : '';
458
+ void receiveOneBotLikeSideEvent(sideEvents, {
459
+ adapter: 'icqq',
460
+ endpointKey: this.endpointName,
461
+ platform: 'icqq',
462
+ raw: payload,
463
+ ...(isRequest ? {
464
+ approve: (id, remark) => this.#approveRequest(flag || id, remark),
465
+ reject: (id, reason) => this.#rejectRequest(flag || id, reason),
466
+ } : {}),
467
+ }).catch((err) => {
468
+ this.#logger.warn(formatCompact({
469
+ op: 'icqq_side_event_failed',
470
+ endpoint: this.endpointName,
471
+ post_type: postType || undefined,
472
+ error: err instanceof Error ? err.message : String(err),
473
+ }));
474
+ });
305
475
  }
306
- #handleMessageEvent(eventName, event) {
476
+ #dispatchIcqqSystemSideEvent(type, payload) {
477
+ const sideEvents = this.#options.sideEvents;
478
+ const { scene_type, sub_type } = parseIcqqSystemScene(type);
479
+ void sideEvents.receiveSystem(SystemEvent.from(payload ?? {}, {
480
+ $id: `system:${this.endpointName}:${type}:${Date.now()}`,
481
+ $adapter: 'icqq',
482
+ $endpoint: this.endpointName,
483
+ $type: 'system',
484
+ $scene_id: this.endpointName,
485
+ $scene_type: scene_type,
486
+ $sub_type: sub_type,
487
+ $timestamp: Date.now(),
488
+ })).catch((err) => {
489
+ this.#logger.warn(formatCompact({
490
+ op: 'icqq_system_side_event_failed',
491
+ endpoint: this.endpointName,
492
+ type,
493
+ error: err instanceof Error ? err.message : String(err),
494
+ }));
495
+ });
496
+ }
497
+ async #handleMessageEvent(eventName, event) {
498
+ if (this.#retiring)
499
+ return;
500
+ const admittedBeforeRetire = this.#open;
307
501
  const payload = serializeIcqqEvent(event);
308
502
  if (!payload || !isIcqqMessagePostType(payload)) {
309
503
  this.#logger.debug(formatCompact({
@@ -325,6 +519,15 @@ export class IcqqEndpoint extends Client {
325
519
  }));
326
520
  return;
327
521
  }
522
+ const contact = this.#resolveInboundContact(data);
523
+ if (contact) {
524
+ await resolveIcqqInboundMedia(data.message, {
525
+ getPttUrl: (element) => contact.getPttUrl(element),
526
+ getVideoUrl: (element) => contact.getVideoUrl(element),
527
+ });
528
+ if (!this.#started)
529
+ return;
530
+ }
328
531
  const normalized = normalizeIcqqInboundMessage(data);
329
532
  if (!normalized) {
330
533
  this.#logger.debug(formatCompact({
@@ -345,13 +548,29 @@ export class IcqqEndpoint extends Client {
345
548
  }));
346
549
  return;
347
550
  }
551
+ const conversation = icqqInboundConversation(String(this.#options.id), {
552
+ channelType: normalized.channelType,
553
+ channelId: normalized.channelId,
554
+ channelParentGroupId: normalized.channelParentGroupId,
555
+ });
556
+ const quoteId = resolveIcqqQuoteIdFromEvent(data);
557
+ const quoted = messageLookupFromIcqqSource(findIcqqNestedMessageSource(data));
558
+ if (quoteId && quoted) {
559
+ this.#messageContent.set(quoteId, Object.freeze({
560
+ ref: Object.freeze({ conversation, id: quoteId }),
561
+ ...(quoted.sender?.id ? { actor: Object.freeze({
562
+ id: quoted.sender.id,
563
+ ...(quoted.sender.name ? { displayName: quoted.sender.name } : {}),
564
+ }) } : {}),
565
+ segments: Object.freeze(toCanonicalSegments(Array.isArray(quoted.content)
566
+ ? quoted.content
567
+ : [{ type: 'text', data: { text: quoted.content } }])),
568
+ timestamp: quoted.time ? quoted.time * (quoted.time < 1e12 ? 1000 : 1) : Date.now(),
569
+ }));
570
+ }
348
571
  this.admit({
349
572
  id: normalized.messageId,
350
- conversation: icqqInboundConversation(String(this.#options.id), {
351
- channelType: normalized.channelType,
352
- channelId: normalized.channelId,
353
- channelParentGroupId: normalized.channelParentGroupId,
354
- }),
573
+ conversation,
355
574
  content: formatInboundContent(normalized.rawMessage),
356
575
  segments: normalized.content,
357
576
  sender: {
@@ -360,12 +579,44 @@ export class IcqqEndpoint extends Client {
360
579
  ...(normalized.senderRole ? { roles: [normalized.senderRole] } : {}),
361
580
  },
362
581
  channelType: normalized.channelType,
363
- metadata: buildIcqqQuoteMetadata(data, {
582
+ ...(quoteId ? { replyTo: { id: quoteId } } : {}),
583
+ metadata: {
364
584
  nickname: normalized.nickname,
365
585
  senderRole: normalized.senderRole,
366
- }),
586
+ },
587
+ }, admittedBeforeRetire);
588
+ }
589
+ #startMessageEvent(eventName, event) {
590
+ const operation = this.#handleMessageEvent(eventName, event);
591
+ this.#inflightInbound.add(operation);
592
+ void operation.catch((error) => {
593
+ this.#logInboundHandlerFailure(eventName, error);
594
+ }).finally(() => {
595
+ this.#inflightInbound.delete(operation);
367
596
  });
368
597
  }
598
+ #resolveInboundContact(data) {
599
+ const userId = Number(data.user_id ?? data.from_id);
600
+ if (!Number.isFinite(userId) || userId <= 0)
601
+ return null;
602
+ if (data.message_type === 'group' || data.group_id != null) {
603
+ const groupId = Number(data.group_id);
604
+ return Number.isFinite(groupId) && groupId > 0 ? this.pickGroup(groupId) : null;
605
+ }
606
+ const sourceGroupId = Number(data.sender?.group_id);
607
+ if (Number.isFinite(sourceGroupId) && sourceGroupId > 0) {
608
+ return this.pickMember(sourceGroupId, userId);
609
+ }
610
+ return this.pickFriend(userId);
611
+ }
612
+ #logInboundHandlerFailure(event, error) {
613
+ this.#logger.warn(formatCompact({
614
+ op: 'icqq_inbound_handler_failed',
615
+ endpoint: this.endpointName,
616
+ event,
617
+ error: describeUnknownError(error),
618
+ }));
619
+ }
369
620
  #handleGuildEvent(event) {
370
621
  const payload = serializeIcqqEvent(event);
371
622
  if (!payload || typeof payload !== 'object')
@@ -406,71 +657,81 @@ export class IcqqEndpoint extends Client {
406
657
  },
407
658
  });
408
659
  }
409
- #recordInboxRequest(payload) {
660
+ #onRequestEvent(payload) {
410
661
  if (!payload || !isIcqqRequestPayload(payload))
411
662
  return;
412
- const hooks = this.#options.inbox;
413
- if (!hooks)
414
- return;
415
- const row = buildIcqqInboxRequestRow(payload, this.#inboxBase());
416
- if (!row)
663
+ const flag = payload.flag != null ? String(payload.flag) : (payload.seq != null ? String(payload.seq) : '');
664
+ if (flag && !this.#sideEventDeduper.shouldProcess(`request:${flag}`))
417
665
  return;
418
- if (!this.#inboxDeduper.shouldProcess(`request:${String(row.platform_request_id)}`))
419
- return;
420
- void hooks.recordRequest(row);
421
- hooks.publish?.('endpoint:request', row);
666
+ this.#dispatchIcqqSideEvent(payload);
422
667
  }
423
- #recordInboxNotice(payload) {
668
+ #onNoticeEvent(payload) {
424
669
  if (!payload || !isIcqqNoticePayload(payload))
425
670
  return;
426
- const hooks = this.#options.inbox;
427
- if (!hooks)
428
- return;
429
- const row = buildIcqqInboxNoticeRow(payload, this.#inboxBase());
430
- if (!row)
431
- return;
432
- if (!this.#inboxDeduper.shouldProcess(`notice:${String(row.platform_notice_id)}`))
671
+ const key = [
672
+ payload.time,
673
+ payload.operator_id ?? payload.user_id,
674
+ payload.notice_type ?? payload.type,
675
+ payload.sub_type,
676
+ ].join('_');
677
+ if (!this.#sideEventDeduper.shouldProcess(`notice:${key}`))
433
678
  return;
434
- void hooks.recordNotice(row);
435
- hooks.publish?.('endpoint:notice', row);
679
+ this.#dispatchIcqqSideEvent(payload);
436
680
  }
437
- #inboxBase() {
438
- const id = String(this.#options.id);
439
- return { adapter: id.split('\0').pop() ?? id, endpointKey: this.endpointName };
681
+ async #listPendingRequests() {
682
+ const messages = await this.getSystemMsg();
683
+ const rows = [];
684
+ for (const raw of messages) {
685
+ const msg = raw;
686
+ const kind = msg.request_type === 'friend' ? 'friend' : 'group';
687
+ const platformRequestId = (msg.flag && String(msg.flag).trim())
688
+ || (msg.seq != null ? String(msg.seq) : '');
689
+ if (!platformRequestId || msg.user_id == null)
690
+ continue;
691
+ const createdAt = Number(msg.time);
692
+ rows.push(Object.freeze({
693
+ platform_request_id: platformRequestId,
694
+ type: kind,
695
+ scene_type: kind === 'group' ? 'group' : null,
696
+ scene_id: String(kind === 'group' ? (msg.group_id ?? msg.user_id) : msg.user_id),
697
+ sub_type: kind === 'group' ? (msg.type != null ? String(msg.type) : null) : null,
698
+ actor_id: String(msg.user_id),
699
+ actor_name: msg.nickname != null ? String(msg.nickname) : null,
700
+ comment: msg.comment != null ? String(msg.comment) : null,
701
+ created_at: Number.isFinite(createdAt) && createdAt > 0
702
+ ? (createdAt < 1e12 ? createdAt * 1000 : createdAt)
703
+ : Date.now(),
704
+ }));
705
+ }
706
+ return Object.freeze(rows);
440
707
  }
441
- async #pullPendingSystemMessages() {
442
- const hooks = this.#options.inbox;
443
- if (!hooks)
444
- return;
708
+ async #pullPendingSystemMessages(signal) {
445
709
  try {
446
- const messages = await this.getSystemMsg();
447
- const friendRequests = [];
448
- const groupRequests = [];
449
- for (const msg of messages) {
450
- if (msg.request_type === 'friend') {
451
- friendRequests.push(msg);
452
- }
453
- else {
454
- groupRequests.push(msg);
455
- }
456
- }
457
- const base = this.#inboxBase();
458
- const rows = [
459
- ...friendRequests.map((m) => buildIcqqSystemRequestRow(m, 'friend', base)),
460
- ...groupRequests.map((m) => buildIcqqSystemRequestRow(m, 'group', base)),
461
- ];
462
- for (const row of rows) {
463
- if (!row)
710
+ const messages = await raceAbort(Promise.resolve(this.getSystemMsg()), signal);
711
+ for (const raw of messages) {
712
+ signal.throwIfAborted();
713
+ const msg = raw;
714
+ const kind = msg.request_type === 'friend' ? 'friend' : 'group';
715
+ const flag = msg.flag != null ? String(msg.flag) : (msg.seq != null ? String(msg.seq) : '');
716
+ if (!flag || !this.#sideEventDeduper.shouldProcess(`request:${flag}`))
464
717
  continue;
465
- if (!this.#inboxDeduper.shouldProcess(`request:${String(row.platform_request_id)}`))
466
- continue;
467
- void hooks.recordRequest(row);
468
- hooks.publish?.('endpoint:request', row);
718
+ this.#dispatchIcqqSideEvent({
719
+ post_type: 'request',
720
+ request_type: kind,
721
+ flag: msg.flag,
722
+ user_id: msg.user_id,
723
+ group_id: msg.group_id,
724
+ comment: msg.comment,
725
+ time: msg.time,
726
+ sub_type: msg.type,
727
+ });
469
728
  }
470
729
  }
471
730
  catch (error) {
731
+ if (signal.aborted)
732
+ throw signal.reason ?? error;
472
733
  this.#logger.debug(formatCompact({
473
- op: 'inbox_pull_system_msg',
734
+ op: 'side_event_pull_system_msg',
474
735
  endpoint: this.endpointName,
475
736
  error: error instanceof Error ? error.message : String(error),
476
737
  }));
@@ -509,6 +770,41 @@ export class IcqqEndpoint extends Client {
509
770
  }
510
771
  }
511
772
  }
773
+ function formatIcqqSendablePreview(message) {
774
+ if (typeof message === 'string')
775
+ return message;
776
+ try {
777
+ return JSON.stringify(message, (_key, value) => Buffer.isBuffer(value) ? '<buffer>' : value);
778
+ }
779
+ catch {
780
+ return '[ICQQ Sendable]';
781
+ }
782
+ }
783
+ function isIcqqSegmentPayload(payload) {
784
+ if (Array.isArray(payload))
785
+ return true;
786
+ return typeof payload === 'object'
787
+ && payload !== null
788
+ && typeof payload.type === 'string';
789
+ }
790
+ function isIcqqFileElement(message) {
791
+ return !Array.isArray(message)
792
+ && typeof message !== 'string'
793
+ && message.type === 'file';
794
+ }
795
+ async function raceAbort(operation, signal) {
796
+ signal.throwIfAborted();
797
+ let rejectAbort;
798
+ const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; });
799
+ const onAbort = () => rejectAbort(signal.reason ?? new Error('ICQQ operation aborted'));
800
+ signal.addEventListener('abort', onAbort, { once: true });
801
+ try {
802
+ return await Promise.race([operation, aborted]);
803
+ }
804
+ finally {
805
+ signal.removeEventListener('abort', onAbort);
806
+ }
807
+ }
512
808
  function describeUnknownError(error) {
513
809
  if (error instanceof Error)
514
810
  return error.stack ?? error.message;
@@ -559,31 +855,6 @@ function resolveNativeClientConfig(config) {
559
855
  reconn_interval: config.autoReconnect ? 5 : 0,
560
856
  };
561
857
  }
562
- function buildIcqqQuoteMetadata(data, base) {
563
- const metadata = { ...base };
564
- const quoteId = resolveIcqqQuoteIdFromEvent(data);
565
- if (quoteId)
566
- metadata.quote_id = quoteId;
567
- const quoted = quotedPayloadFromIcqqSource(findIcqqNestedMessageSource(data));
568
- if (quoted?.sender?.id)
569
- metadata.quote_sender_id = quoted.sender.id;
570
- if (quoted?.sender?.name)
571
- metadata.quote_sender_name = quoted.sender.name;
572
- const quoteText = quoted
573
- ? (typeof quoted.content === 'string'
574
- ? quoted.content
575
- : quoted.content
576
- .map((seg) => seg && typeof seg === 'object' && seg.type === 'text'
577
- ? String(seg.data?.text ?? '')
578
- : '')
579
- .join('')).trim() || quoted.raw || ''
580
- : '';
581
- if (quoteText)
582
- metadata.quote_content = quoteText;
583
- return metadata;
584
- }
585
- const INBOUND_HOLD_LIMIT = 32;
586
- const ICQQ_SERIALIZE_SKIP = ['group', 'member', 'friend', 'discuss', 'client'];
587
858
  function serializeIcqqEvent(event) {
588
859
  if (!event || typeof event !== 'object')
589
860
  return null;
@@ -607,3 +878,20 @@ function serializeIcqqEvent(event) {
607
878
  }
608
879
  return Object.keys(out).length > 0 ? out : null;
609
880
  }
881
+ function parseIcqqSystemScene(type) {
882
+ if (type.startsWith('login.')) {
883
+ return { scene_type: 'login', sub_type: type.slice('login.'.length) || 'unknown' };
884
+ }
885
+ if (type.startsWith('offline.')) {
886
+ return { scene_type: 'offline', sub_type: type.slice('offline.'.length) || 'unknown' };
887
+ }
888
+ if (type === 'online') {
889
+ return { scene_type: 'online', sub_type: 'online' };
890
+ }
891
+ if (type === 'offline') {
892
+ return { scene_type: 'offline', sub_type: 'offline' };
893
+ }
894
+ return { scene_type: 'system', sub_type: type };
895
+ }
896
+ const INBOUND_HOLD_LIMIT = 32;
897
+ const ICQQ_SERIALIZE_SKIP = ['group', 'member', 'friend', 'discuss', 'client'];