@zhin.js/adapter-icqq 1.1.18 → 1.1.20

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +12 -8
  3. package/adapters/icqq/index.js +4 -28
  4. package/adapters/icqq/index.ts +3 -34
  5. package/commands/icqq/endpoint/definition.js +3 -25
  6. package/commands/icqq/endpoint/definition.ts +3 -24
  7. package/commands/icqq/login/code/index.js +3 -3
  8. package/commands/icqq/login/code/index.ts +3 -3
  9. package/commands/icqq/login/confirm/index.js +3 -3
  10. package/commands/icqq/login/confirm/index.ts +3 -3
  11. package/commands/icqq/login/definition.js +2 -2
  12. package/commands/icqq/login/definition.ts +2 -2
  13. package/commands/icqq/login/qr/index.js +3 -3
  14. package/commands/icqq/login/qr/index.ts +3 -3
  15. package/commands/icqq/login/retry/index.js +3 -3
  16. package/commands/icqq/login/retry/index.ts +3 -3
  17. package/commands/icqq/login/status/index.js +3 -3
  18. package/commands/icqq/login/status/index.ts +3 -3
  19. package/lib/adapter.d.ts +13 -0
  20. package/lib/adapter.js +27 -0
  21. package/lib/client.d.ts +6 -9
  22. package/lib/client.js +1 -3
  23. package/lib/console-spec.js +11 -1
  24. package/lib/console.d.ts +1 -7
  25. package/lib/console.js +2 -5
  26. package/lib/endpoint.d.ts +6 -16
  27. package/lib/endpoint.js +195 -143
  28. package/lib/group-moderation.d.ts +6 -3
  29. package/lib/group-moderation.js +9 -8
  30. package/lib/group-scene.d.ts +3 -3
  31. package/lib/group-scene.js +8 -7
  32. package/lib/icqq-login-assist.d.ts +3 -1
  33. package/lib/icqq-login-assist.js +3 -1
  34. package/lib/icqq-login-control.d.ts +8 -2
  35. package/lib/icqq-login-control.js +22 -5
  36. package/lib/index.d.ts +2 -1
  37. package/lib/index.js +1 -1
  38. package/lib/protocol.d.ts +3 -0
  39. package/package.json +18 -18
  40. package/plugin.js +1 -8
  41. package/src/adapter.ts +29 -0
  42. package/src/client.ts +6 -11
  43. package/src/console-spec.ts +9 -0
  44. package/src/console.ts +2 -6
  45. package/src/endpoint.ts +205 -153
  46. package/src/group-moderation.ts +10 -10
  47. package/src/group-scene.ts +8 -8
  48. package/src/icqq-login-assist.ts +6 -2
  49. package/src/icqq-login-control.ts +20 -8
  50. package/src/index.ts +2 -1
  51. package/src/protocol.ts +3 -0
  52. package/lib/icqq-runtime-state.d.ts +0 -1
  53. package/lib/icqq-runtime-state.js +0 -6
  54. package/src/icqq-runtime-state.ts +0 -7
package/lib/endpoint.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { parseGroupMessageId, } from '@icqqjs/icqq';
2
2
  import { inspect } from 'node:util';
3
3
  import { AsyncLocalStorage } from 'node:async_hooks';
4
- import { Endpoint, } from 'zhin.js/adapter';
4
+ import { createEndpointSpecification, endpointSpec, createEndpointOperation, } from 'zhin.js/adapter';
5
5
  import { receiveOneBotLikeSideEvent, buildSystem, toCanonicalSegments } from '@zhin.js/core';
6
6
  import { EndpointDeliveryError } from '@zhin.js/im-contract';
7
7
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
@@ -33,8 +33,8 @@ const BOUND_EVENTS = [
33
33
  'system.login.error',
34
34
  'system.login.auth',
35
35
  ];
36
- export class IcqqEndpoint extends Endpoint {
37
- client;
36
+ export class IcqqEndpoint extends ManagedIcqqClient {
37
+ [endpointSpec];
38
38
  #logger;
39
39
  #options;
40
40
  endpointName;
@@ -55,69 +55,79 @@ export class IcqqEndpoint extends Endpoint {
55
55
  #inflightInbound = new Set();
56
56
  #inboundOwner = new AsyncLocalStorage();
57
57
  #contentResolver;
58
- content;
58
+ #content;
59
59
  loginControl;
60
60
  #reloginTimer;
61
61
  #reloginAttempts = 0;
62
62
  #reloginInFlight;
63
63
  #offlineNoticeAt = 0;
64
64
  #challengeNoticeAt = 0;
65
- management = Object.freeze({
66
- inspectGroupMember: (group, user, signal) => inspectGroupMember(this.client, group, user, signal),
67
- inspectGroupScene: (group, signal) => inspectGroupScene(this.client, group, signal),
68
- inspectGroupMemberProfile: (group, user, signal) => inspectGroupMemberProfile(this.client, group, user, signal),
69
- moderateGroupMember: (group, user, action, signal) => moderateGroupMember(this.client, group, user, action, signal, () => this.#open && !this.#retiring && !this.#stopped),
70
- listFriends: async () => Array.from(this.client.fl.values()).map((f) => ({
65
+ #management = Object.freeze({
66
+ inspectGroupMember: request => inspectGroupMember(this, request),
67
+ inspectGroupScene: request => inspectGroupScene(this, request),
68
+ inspectGroupMemberProfile: request => inspectGroupMemberProfile(this, request),
69
+ moderateGroupMember: request => moderateGroupMember({ client: this, isActive: () => this.#open && !this.#retiring && !this.#stopped }, request),
70
+ listFriends: async (request = {}) => createEndpointOperation(request).run(() => Array.from(this.fl.values()).map((f) => ({
71
71
  user_id: f.user_id,
72
72
  nickname: f.nickname,
73
73
  remark: f.remark ?? '',
74
- })),
75
- listGroups: async () => Array.from(this.client.gl.values()).map((g) => ({
74
+ }))),
75
+ listGroups: async (request = {}) => createEndpointOperation(request).run(() => Array.from(this.gl.values()).map((g) => ({
76
76
  group_id: g.group_id,
77
77
  name: g.group_name,
78
- })),
79
- listChannels: async () => {
80
- await this.#guildCatalog.syncAll(this.client);
81
- return this.#guildCatalog.getGuildChannelList();
78
+ }))),
79
+ listChannels: async (request = {}) => {
80
+ const operation = createEndpointOperation(request);
81
+ await operation.run(() => this.#guildCatalog.syncAll(this));
82
+ return operation.run(() => this.#guildCatalog.getGuildChannelList());
82
83
  },
83
- listGroupMembers: async (groupId) => Array.from((await this.client.getGroupMemberList(Number(groupId))).values()),
84
- listRequests: () => this.#listPendingRequests(),
85
- approveRequest: (requestId, remark) => this.#approveRequest(requestId, remark),
86
- rejectRequest: (requestId, reason) => this.#rejectRequest(requestId, reason),
87
- kickGroupMember: async (groupId, userId) => {
88
- await this.client.setGroupKick(Number(groupId), Number(userId));
84
+ listGroupMembers: async ({ groupId, signal }) => Array.from((await createEndpointOperation({ signal }).run(() => this.getGroupMemberList(Number(groupId)))).values()),
85
+ listRequests: (request = {}) => createEndpointOperation(request).run(() => this.#listPendingRequests()),
86
+ approveRequest: request => this.#approveRequest(request),
87
+ rejectRequest: request => this.#rejectRequest(request),
88
+ kickGroupMember: async ({ groupId, userId, signal }) => {
89
+ const operation = createEndpointOperation({ signal });
90
+ await operation.run(() => this.setGroupKick(Number(groupId), Number(userId)));
89
91
  },
90
- muteGroupMember: async (groupId, userId, duration) => {
91
- await this.client.setGroupBan(Number(groupId), Number(userId), duration);
92
+ muteGroupMember: async ({ groupId, userId, durationSeconds: duration, signal }) => {
93
+ const operation = createEndpointOperation({ signal });
94
+ await operation.run(() => this.setGroupBan(Number(groupId), Number(userId), duration));
92
95
  },
93
- setGroupAdmin: async (groupId, userId, enabled) => {
94
- await this.client.setGroupAdmin(Number(groupId), Number(userId), enabled);
96
+ setGroupAdmin: async ({ groupId, userId, enabled, signal }) => {
97
+ const operation = createEndpointOperation({ signal });
98
+ await operation.run(() => this.setGroupAdmin(Number(groupId), Number(userId), enabled));
95
99
  },
96
- deleteFriend: async (userId) => {
97
- await this.client.deleteFriend(Number(userId));
100
+ deleteFriend: async ({ userId, signal }) => {
101
+ const operation = createEndpointOperation({ signal });
102
+ await operation.run(() => this.deleteFriend(Number(userId)));
98
103
  },
99
- sendGroupNotice: async (groupId, content) => {
100
- if (await this.client.sendGroupNotice(Number(groupId), content) === false) {
104
+ sendGroupNotice: async ({ groupId, content, signal }) => {
105
+ const operation = createEndpointOperation({ signal });
106
+ if (await operation.run(() => this.sendGroupNotice(Number(groupId), content)) === false) {
101
107
  throw new Error('QQ did not confirm the group notice');
102
108
  }
103
109
  },
104
110
  });
105
- control = Object.freeze({
106
- recall: async (message) => {
111
+ #control = Object.freeze({
112
+ recall: async ({ message, signal }) => {
107
113
  if (!message.id || message.id.startsWith('outbound:'))
108
114
  return;
109
- if (!await this.client.deleteMsg(message.id))
115
+ if (!await createEndpointOperation({ signal }).run(() => this.deleteMsg(message.id)))
110
116
  throw new Error('QQ did not confirm message recall');
111
117
  },
112
- addReaction: async (message, emoji) => {
118
+ addReaction: async ({ message, emoji, signal }) => {
113
119
  const target = resolveIcqqGroupReactionTarget(message);
114
120
  if (!target)
115
121
  return null;
116
122
  try {
117
123
  // Do not await protocol ACK — packet timeout must not stall the AI reply.
118
- this.#ignoreReactionAck('add', this.client.pickGroup(target.groupId).setReaction(target.seq, emoji));
124
+ await createEndpointOperation({ signal }).run(() => {
125
+ this.#ignoreReactionAck('add', this.pickGroup(target.groupId).setReaction(target.seq, emoji));
126
+ });
119
127
  }
120
128
  catch (error) {
129
+ if (error instanceof EndpointDeliveryError && error.code === 'endpoint_inactive')
130
+ throw error;
121
131
  this.#logger.debug(formatCompact({
122
132
  op: 'reaction_add_failed',
123
133
  error: error instanceof Error ? error.message : String(error),
@@ -125,14 +135,18 @@ export class IcqqEndpoint extends Endpoint {
125
135
  }
126
136
  return emoji;
127
137
  },
128
- removeReaction: async (message, reactionId) => {
138
+ removeReaction: async ({ message, reactionId, signal }) => {
129
139
  const target = resolveIcqqGroupReactionTarget(message);
130
140
  if (!target)
131
141
  return;
132
142
  try {
133
- this.#ignoreReactionAck('remove', this.client.pickGroup(target.groupId).delReaction(target.seq, reactionId));
143
+ await createEndpointOperation({ signal }).run(() => {
144
+ this.#ignoreReactionAck('remove', this.pickGroup(target.groupId).delReaction(target.seq, reactionId));
145
+ });
134
146
  }
135
147
  catch (error) {
148
+ if (error instanceof EndpointDeliveryError && error.code === 'endpoint_inactive')
149
+ throw error;
136
150
  this.#logger.debug(formatCompact({
137
151
  op: 'reaction_remove_failed',
138
152
  error: error instanceof Error ? error.message : String(error),
@@ -141,11 +155,19 @@ export class IcqqEndpoint extends Endpoint {
141
155
  },
142
156
  });
143
157
  constructor(options) {
144
- super();
145
- const nativeConfig = resolveNativeClientConfig(options.config);
146
- this.client = new ManagedIcqqClient(Number(options.config.id), nativeConfig, (operation, error) => this.#handleBackgroundFailure(operation, error));
147
- this.#contentResolver = new IcqqContentResolver(this.client);
148
- this.content = this.#contentResolver.port;
158
+ super(Number(options.config.id), resolveNativeClientConfig(options.config), (operation, error) => this.#handleBackgroundFailure(operation, error));
159
+ const name = () => this.endpointName;
160
+ const state = () => this.#transportState;
161
+ const content = () => this.#content;
162
+ this[endpointSpec] = createEndpointSpecification(this, {
163
+ start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(), stop: () => this.#stopFramework(),
164
+ send: request => this.#sendFramework(request), get name() { return name(); },
165
+ get transportState() { return state(); },
166
+ management: this.#management, control: this.#control,
167
+ get content() { return content(); },
168
+ });
169
+ this.#contentResolver = new IcqqContentResolver(this);
170
+ this.#content = this.#contentResolver.port;
149
171
  this.#logger = getAdapterLogger('icqq', options.config.id);
150
172
  this.#options = options;
151
173
  this.endpointName = options.config.id;
@@ -153,14 +175,14 @@ export class IcqqEndpoint extends Endpoint {
153
175
  endpointId: this.endpointName,
154
176
  state: () => ({
155
177
  endpointId: this.endpointName,
156
- transportState: this.transportState,
157
- online: this.client.isOnline(),
178
+ transportState: this.#transportState,
179
+ online: this.isOnline(),
158
180
  pending: this.#pendingLoginTasks(),
159
181
  }),
160
182
  pending: () => this.#pendingLoginTasks(),
161
- relogin: () => this.relogin(),
162
- submit: (taskId, value) => this.#options.loginAssist.submit(taskId, value),
163
- cancel: (taskId, reason) => this.#options.loginAssist.cancel(taskId, reason),
183
+ relogin: async () => { this.#options.signal?.throwIfAborted(); await this.relogin(); },
184
+ submit: (taskId, value) => !this.#options.signal?.aborted && this.#options.loginAssist.submit(taskId, value, this.#options.endpointIdentity),
185
+ cancel: (taskId, reason) => !this.#options.signal?.aborted && this.#options.loginAssist.cancel(taskId, reason, this.#options.endpointIdentity),
164
186
  });
165
187
  }
166
188
  #handleBackgroundFailure(operation, error) {
@@ -173,13 +195,13 @@ export class IcqqEndpoint extends Endpoint {
173
195
  : typeof error === 'string' ? error : 'Unknown ICQQ SDK background failure',
174
196
  ...(error && typeof error === 'object' && 'code' in error && typeof error.code === 'number' ? { code: error.code } : {}),
175
197
  }));
176
- if (operation === 'sso-heartbeat' && !this.client.isOnline())
198
+ if (operation === 'sso-heartbeat' && !this.isOnline())
177
199
  return;
178
200
  if (operation === 'login' && !this.#everOnline)
179
201
  return;
180
202
  // Delegate to the SDK's existing transport termination, offline event and
181
203
  // reconn_interval policy. No account-level logout or second retry timer.
182
- this.client.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
204
+ this.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
183
205
  }
184
206
  /** Force a login attempt (manual recovery command). */
185
207
  relogin() {
@@ -198,7 +220,7 @@ export class IcqqEndpoint extends Endpoint {
198
220
  void task.catch(() => undefined).finally(() => {
199
221
  if (this.#reloginInFlight === task)
200
222
  this.#reloginInFlight = undefined;
201
- if (this.#started && !this.#stopped && !this.#retiring && !this.client.isOnline()) {
223
+ if (this.#started && !this.#stopped && !this.#retiring && !this.isOnline()) {
202
224
  this.#scheduleRelogin('relogin_failed');
203
225
  }
204
226
  });
@@ -206,10 +228,10 @@ export class IcqqEndpoint extends Endpoint {
206
228
  }
207
229
  async #performRelogin() {
208
230
  this.#clearReloginTimer();
209
- this.client.cancelReconnect();
231
+ this.cancelReconnect();
210
232
  this.#logger.info(formatCompact({ op: 'icqq_relogin', endpoint: this.endpointName }));
211
233
  try {
212
- await this.client.login(this.#options.config.password);
234
+ await this.login(this.#options.config.password);
213
235
  }
214
236
  catch (error) {
215
237
  this.#logger.warn(formatCompact({
@@ -251,8 +273,15 @@ export class IcqqEndpoint extends Endpoint {
251
273
  this.#reloginTimer = undefined;
252
274
  }
253
275
  #pendingLoginTasks() {
276
+ if (this.#options.signal?.aborted)
277
+ return [];
254
278
  return this.#options.loginAssist.listPending()
255
- .filter((task) => task.adapter === 'icqq' && task.endpointKey === this.endpointName);
279
+ .filter((task) => task.adapter === 'icqq' && (this.#options.endpointIdentity
280
+ ? task.endpointIdentity?.canonicalId === this.#options.endpointIdentity.canonicalId
281
+ && task.endpointIdentity.instanceId === this.#options.endpointIdentity.instanceId
282
+ && task.endpointIdentity.epoch === this.#options.endpointIdentity.epoch
283
+ && task.endpointIdentity.incarnationId === this.#options.endpointIdentity.incarnationId
284
+ : task.endpointKey === this.endpointName));
256
285
  }
257
286
  #notifyOffline(reason, willRetry) {
258
287
  const notifier = this.#options.notifier;
@@ -331,18 +360,18 @@ export class IcqqEndpoint extends Endpoint {
331
360
  }));
332
361
  });
333
362
  }
334
- get transportState() {
363
+ get #transportState() {
335
364
  if (this.#stopped || this.#retiring)
336
365
  return 'stopped';
337
366
  if (!this.#started)
338
367
  return 'idle';
339
- if (this.client.isOnline())
368
+ if (this.isOnline())
340
369
  return 'open';
341
370
  if (!this.#everOnline)
342
371
  return 'connecting';
343
372
  return this.#options.config.autoReconnect ? 'reconnecting' : 'closed';
344
373
  }
345
- async start(signal) {
374
+ async #startFramework(signal) {
346
375
  if (this.#started)
347
376
  return;
348
377
  signal.throwIfAborted();
@@ -352,7 +381,7 @@ export class IcqqEndpoint extends Endpoint {
352
381
  this.#retirementEpoch += 1;
353
382
  this.#retirementArmed = false;
354
383
  this.#retiring = false;
355
- this.client.off('system.online');
384
+ this.off('system.online');
356
385
  this.#started = true;
357
386
  let resolveOnline;
358
387
  const onlineReady = new Promise((resolve) => {
@@ -363,16 +392,16 @@ export class IcqqEndpoint extends Endpoint {
363
392
  signal.addEventListener('abort', onAbort, { once: true });
364
393
  try {
365
394
  this.#bindClientEvents();
366
- await raceAbort(Promise.resolve(this.client.login(this.#options.config.password)), signal);
395
+ await raceAbort(Promise.resolve(this.login(this.#options.config.password)), signal);
367
396
  await onlineReady;
368
397
  signal.throwIfAborted();
369
398
  await this.#pullPendingSystemMessages(signal);
370
399
  signal.throwIfAborted();
371
- this.#logger.info(`connected (direct) | friends: ${this.client.fl.size} | groups: ${this.client.gl.size}`);
400
+ this.#logger.info(`connected (direct) | friends: ${this.fl.size} | groups: ${this.gl.size}`);
372
401
  }
373
402
  catch (error) {
374
403
  this.#notifyOffline('start_failed', false);
375
- await this.stop();
404
+ await this.#stopFramework();
376
405
  this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
377
406
  throw error;
378
407
  }
@@ -382,11 +411,11 @@ export class IcqqEndpoint extends Endpoint {
382
411
  this.#resolveStartOnline = undefined;
383
412
  }
384
413
  }
385
- open() {
414
+ #openFramework() {
386
415
  if (this.#retirementArmed) {
387
416
  this.#retirementEpoch += 1;
388
417
  this.#retirementArmed = false;
389
- this.client.off('system.online');
418
+ this.off('system.online');
390
419
  this.#bindSystemOnlineEvent();
391
420
  }
392
421
  this.#retiring = false;
@@ -398,15 +427,15 @@ export class IcqqEndpoint extends Endpoint {
398
427
  });
399
428
  }
400
429
  }
401
- async close() {
430
+ async #closeFramework() {
402
431
  this.#assertExternalLifecycle('close');
403
432
  this.#armRetirementFence();
404
433
  await Promise.allSettled([...this.#inflightInbound]);
405
434
  }
406
- async stop() {
435
+ async #stopFramework() {
436
+ this.#assertExternalLifecycle('stop');
407
437
  this.#stopped = true;
408
438
  this.#clearReloginTimer();
409
- this.#assertExternalLifecycle('stop');
410
439
  this.#armRetirementFence();
411
440
  await Promise.allSettled([...this.#inflightInbound]);
412
441
  this.#resolveStartOnline?.();
@@ -418,7 +447,7 @@ export class IcqqEndpoint extends Endpoint {
418
447
  // The retirement guard owns this matcher until a late login handshake
419
448
  // has finished. start()/open() replace it with the normal listener.
420
449
  if (event !== 'system.online')
421
- this.client.off(event);
450
+ this.off(event);
422
451
  }
423
452
  // `Client.logout()` sends an account-level unregister packet before it
424
453
  // closes this transport. During config HMR the replacement endpoint for
@@ -426,7 +455,7 @@ export class IcqqEndpoint extends Endpoint {
426
455
  // also closes the replacement session. Endpoint ownership is transport
427
456
  // scoped: retire only this Client's TCP connection.
428
457
  try {
429
- this.client.terminate();
458
+ this.terminate();
430
459
  }
431
460
  catch { /* ignore */ }
432
461
  this.#inboundDeduper.clear();
@@ -444,28 +473,39 @@ export class IcqqEndpoint extends Endpoint {
444
473
  const retirementEpoch = ++this.#retirementEpoch;
445
474
  // ICQQ owns reconnect through this per-Client timer. Clear it before any
446
475
  // asynchronous drain so a queued callback cannot enter while close waits.
447
- this.client.cancelReconnect();
476
+ this.cancelReconnect();
448
477
  // A login callback may already be inside the SDK handshake, which has no
449
478
  // abort API. Replace the normal online listener with an epoch-owned guard
450
479
  // before draining inbound work. Transport termination is account-local;
451
480
  // logout would also unregister an HMR replacement using the same UIN.
452
- this.client.off('system.online');
453
- this.client.on('system.online', () => {
481
+ this.off('system.online');
482
+ this.on('system.online', () => {
454
483
  if (this.#retirementEpoch !== retirementEpoch || !this.#retiring)
455
484
  return;
456
485
  try {
457
- this.client.terminate();
486
+ this.terminate();
458
487
  }
459
488
  catch { /* already closed */ }
460
489
  });
461
490
  }
462
- async send({ conversation, payload }) {
491
+ async #sendFramework({ conversation, payload, signal }) {
463
492
  if (!this.#started)
464
493
  throw new EndpointDeliveryError('endpoint_disconnected', 'icqq endpoint 未连接', 'not_sent');
494
+ const operation = createEndpointOperation({ signal });
465
495
  const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
466
- const prepared = isIcqqSegmentPayload(payload)
496
+ const prepared = await operation.prepare(() => isIcqqSegmentPayload(payload)
467
497
  ? prepareIcqqOutboundMedia(normalizeIcqqImageMedia(payload), mediaMode)
468
- : { content: payload, dispose() { } };
498
+ : { content: payload, dispose() { } });
499
+ // Cancellation ends our wait, not the SDK's use of temporary media. The actual native
500
+ // promises own that resource until they settle, including a late rejection after cancellation.
501
+ const pendingSends = new Set();
502
+ const sendNative = (target, message) => {
503
+ const task = this.#sendNative(target, message);
504
+ pendingSends.add(task);
505
+ const settled = () => { pendingSends.delete(task); };
506
+ void task.then(settled, settled);
507
+ return task;
508
+ };
469
509
  try {
470
510
  // Standalone elements (share / file / …) are split into ordered messages.
471
511
  const messages = formatOutboundMessageParts(prepared.content);
@@ -475,7 +515,7 @@ export class IcqqEndpoint extends Endpoint {
475
515
  const preview = formatIcqqSendablePreview(message);
476
516
  let result;
477
517
  try {
478
- result = await this.#sendNative(parsed, message);
518
+ result = await operation.run(() => sendNative(parsed, message));
479
519
  }
480
520
  catch (error) {
481
521
  this.#logger.warn(formatCompact({
@@ -503,33 +543,39 @@ export class IcqqEndpoint extends Endpoint {
503
543
  return { parts };
504
544
  }
505
545
  finally {
506
- try {
507
- prepared.dispose();
508
- }
509
- catch (error) {
510
- // Temporary-file cleanup cannot change an already observed platform send outcome.
511
- this.#logger.warn(formatCompact({ op: 'icqq_media_cleanup_failed', error: describeUnknownError(error) }));
512
- }
546
+ const disposeMedia = () => {
547
+ try {
548
+ prepared.dispose();
549
+ }
550
+ catch (error) {
551
+ // Temporary-file cleanup cannot change an observed send result.
552
+ this.#logger.warn(formatCompact({ op: 'icqq_media_cleanup_failed', error: describeUnknownError(error) }));
553
+ }
554
+ };
555
+ if (pendingSends.size)
556
+ void Promise.allSettled([...pendingSends]).then(disposeMedia);
557
+ else
558
+ disposeMedia();
513
559
  }
514
560
  }
515
561
  async #sendNative(target, message) {
516
562
  if (isIcqqShareElement(message)) {
517
563
  switch (target.kind) {
518
564
  case 'private': {
519
- const friend = this.client.pickFriend(target.userId);
565
+ const friend = this.pickFriend(target.userId);
520
566
  await friend.share(message);
521
567
  return {};
522
568
  }
523
569
  case 'group': {
524
- const group = this.client.pickGroup(target.groupId);
570
+ const group = this.pickGroup(target.groupId);
525
571
  await group.share(message);
526
572
  return {};
527
573
  }
528
574
  case 'temp':
529
- await this.client.pickMember(target.groupId, target.userId).share(message);
575
+ await this.pickMember(target.groupId, target.userId).share(message);
530
576
  return {};
531
577
  case 'channel': {
532
- const channel = this.client.pickGuild(target.guildId).channels.get(target.channelId);
578
+ const channel = this.pickGuild(target.guildId).channels.get(target.channelId);
533
579
  if (!channel) {
534
580
  throw new TypeError(`ICQQ guild channel is unavailable: ${target.guildId}/${target.channelId}`);
535
581
  }
@@ -541,11 +587,11 @@ export class IcqqEndpoint extends Endpoint {
541
587
  if (isIcqqFileElement(message)) {
542
588
  switch (target.kind) {
543
589
  case 'private': {
544
- await this.client.pickFriend(target.userId).sendFile(message.file, message.name);
590
+ await this.pickFriend(target.userId).sendFile(message.file, message.name);
545
591
  return {};
546
592
  }
547
593
  case 'group': {
548
- await this.client.pickGroup(target.groupId).sendFile(message.file, '/', message.name);
594
+ await this.pickGroup(target.groupId).sendFile(message.file, '/', message.name);
549
595
  return {};
550
596
  }
551
597
  case 'temp':
@@ -555,13 +601,13 @@ export class IcqqEndpoint extends Endpoint {
555
601
  }
556
602
  switch (target.kind) {
557
603
  case 'private':
558
- return this.client.sendPrivateMsg(target.userId, message);
604
+ return this.sendPrivateMsg(target.userId, message);
559
605
  case 'group':
560
- return this.client.sendGroupMsg(target.groupId, message);
606
+ return this.sendGroupMsg(target.groupId, message);
561
607
  case 'temp':
562
- return this.client.sendTempMsg(target.groupId, target.userId, message);
608
+ return this.sendTempMsg(target.groupId, target.userId, message);
563
609
  case 'channel':
564
- return this.client.sendGuildMsg(target.guildId, target.channelId, message);
610
+ return this.sendGuildMsg(target.guildId, target.channelId, message);
565
611
  }
566
612
  }
567
613
  admit(msg) {
@@ -574,7 +620,7 @@ export class IcqqEndpoint extends Endpoint {
574
620
  this.#inflightInbound.add(settlement);
575
621
  let operation;
576
622
  try {
577
- operation = this.#inboundOwner.run(owner, run);
623
+ operation = this.#inboundOwner.run(owner, () => this[endpointSpec].events.run(run));
578
624
  }
579
625
  catch (error) {
580
626
  this.#inflightInbound.delete(settlement);
@@ -623,7 +669,9 @@ export class IcqqEndpoint extends Endpoint {
623
669
  + (msg.sender ? ` from ${msg.sender.name ?? msg.sender.id}` : '')
624
670
  + (mentioned ? ' (mentioned)' : '')
625
671
  + ` | ${truncatePreview(msg.content, 80)}`);
626
- await this.emit('message.receive', {
672
+ await this[endpointSpec].events.emit('message.receive', {
673
+ event: msg.event,
674
+ platformSentAt: Number.isFinite(msg.platformSentAt) ? msg.platformSentAt : undefined,
627
675
  conversation,
628
676
  message: { conversation, id: msg.id },
629
677
  content: msg.content,
@@ -649,77 +697,69 @@ export class IcqqEndpoint extends Endpoint {
649
697
  #bindClientEvents() {
650
698
  this.#bindNativeEvents();
651
699
  const safe = (label, fn) => {
652
- try {
653
- fn();
654
- }
655
- catch (error) {
656
- this.#logger.warn(formatCompact({
657
- op: 'icqq_event_handler',
658
- endpoint: this.endpointName,
659
- event: label,
660
- error: error instanceof Error ? error.message : String(error),
661
- }));
662
- }
700
+ void this[endpointSpec].events.run(fn).catch(() => {
701
+ this.#logger.debug(formatCompact({ op: 'icqq_event_not_admitted', endpoint: this.endpointName, event: label }));
702
+ });
663
703
  };
664
- this.client.on('message.private', (event) => {
704
+ this.on('message.private', (event) => {
665
705
  this.#startMessageEvent('message.private', event);
666
706
  });
667
- this.client.on('message.group', (event) => {
707
+ this.on('message.group', (event) => {
668
708
  this.#startMessageEvent('message.group', event);
669
709
  });
670
- this.client.on('message.guild', (event) => {
671
- void this.#handleGuildEvent(event).catch((error) => {
710
+ this.on('message.guild', (event) => {
711
+ void this.#trackInbound(() => this.#handleGuildEvent(event)).catch((error) => {
672
712
  this.#logInboundHandlerFailure('message.guild', error);
673
713
  });
674
714
  });
675
- this.client.on('request.friend', (event) => safe('request.friend', () => {
715
+ this.on('request.friend', (event) => safe('request.friend', () => {
676
716
  this.#onRequestEvent(serializeIcqqEvent(event));
677
717
  }));
678
- this.client.on('request.group', (event) => safe('request.group', () => {
718
+ this.on('request.group', (event) => safe('request.group', () => {
679
719
  this.#onRequestEvent(serializeIcqqEvent(event));
680
720
  }));
681
- this.client.on('notice.friend', (event) => safe('notice.friend', () => {
721
+ this.on('notice.friend', (event) => safe('notice.friend', () => {
682
722
  this.#onNoticeEvent(serializeIcqqEvent(event));
683
723
  }));
684
- this.client.on('notice.group', (event) => safe('notice.group', () => {
724
+ this.on('notice.group', (event) => safe('notice.group', () => {
685
725
  this.#onNoticeEvent(serializeIcqqEvent(event));
686
726
  }));
687
727
  this.#bindSystemOnlineEvent();
688
- this.client.on('system.offline', (event) => {
728
+ this.on('system.offline', (event) => {
689
729
  this.#logSystemEvent('offline', event);
690
730
  this.#notifyOffline('offline', this.#options.config.autoReconnect);
691
731
  });
692
- this.client.on('system.offline.network', (event) => {
732
+ this.on('system.offline.network', (event) => {
693
733
  this.#logSystemEvent('offline.network', event);
694
734
  this.#notifyOffline('network', this.#options.config.autoReconnect);
695
735
  });
696
- this.client.on('system.offline.kickoff', (event) => {
736
+ this.on('system.offline.kickoff', (event) => {
697
737
  this.#logSystemEvent('offline.kickoff', event);
698
738
  this.#notifyOffline('kickoff', this.#options.config.autoReconnect);
699
739
  this.#scheduleRelogin('kickoff');
700
740
  });
701
- this.client.on('system.login.qrcode', (event) => {
741
+ this.on('system.login.qrcode', (event) => {
702
742
  this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
703
743
  this.#handleLoginChallenge('qrcode', event);
704
744
  });
705
- this.client.on('system.login.device', (event) => {
745
+ this.on('system.login.device', (event) => {
706
746
  this.#handleLoginChallenge('device', event);
707
747
  });
708
- this.client.on('system.login.slider', (event) => {
748
+ this.on('system.login.slider', (event) => {
709
749
  this.#handleLoginChallenge('slider', event);
710
750
  });
711
- this.client.on('system.login.error', (event) => {
751
+ this.on('system.login.error', (event) => {
712
752
  this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
713
753
  this.#logSystemEvent('login.error', event);
714
754
  this.#notifyOffline('login_error', this.#options.config.autoReconnect);
715
755
  this.#scheduleRelogin('login_error');
716
756
  });
717
- this.client.on('system.login.auth', (event) => {
757
+ this.on('system.login.auth', (event) => {
718
758
  this.#handleLoginChallenge('auth', event);
719
759
  });
720
760
  }
721
761
  #bindSystemOnlineEvent() {
722
- this.client.on('system.online', (event) => {
762
+ this.on('system.online', (event) => {
723
763
  if (this.#retiring)
724
764
  return;
725
765
  this.#everOnline = true;
@@ -731,7 +771,7 @@ export class IcqqEndpoint extends Endpoint {
731
771
  this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
732
772
  try {
733
773
  // transport 重连不清除(账号不变);stop/retire 由基类撤销登记。上报失败不影响上线流程。
734
- this.reportSelfUserId(resolveIcqqSelfUserId(this.client));
774
+ this[endpointSpec].reportSelfUserId(resolveIcqqSelfUserId(this));
735
775
  }
736
776
  catch (error) {
737
777
  this.#logger.warn(formatCompact({
@@ -756,7 +796,7 @@ export class IcqqEndpoint extends Endpoint {
756
796
  #bindNativeEvents() {
757
797
  this.#disposeNativeEvents?.();
758
798
  let matchedName;
759
- const registration = this.client.on((eventName) => {
799
+ const registration = this.on((eventName) => {
760
800
  matchedName = typeof eventName === 'string' && !eventName.startsWith('internal.')
761
801
  ? eventName
762
802
  : undefined;
@@ -769,7 +809,7 @@ export class IcqqEndpoint extends Endpoint {
769
809
  this.#disposeNativeEvents = () => registration();
770
810
  }
771
811
  #emitNativeEvent(name, event) {
772
- void this.emitPlatform(name, event).catch((error) => {
812
+ void this[endpointSpec].events.platform(name, event).catch((error) => {
773
813
  this.#logger.warn(formatCompact({
774
814
  op: 'icqq_platform_event_failed',
775
815
  endpoint: this.endpointName,
@@ -779,6 +819,8 @@ export class IcqqEndpoint extends Endpoint {
779
819
  });
780
820
  }
781
821
  #handleLoginChallenge(step, event) {
822
+ if (this.#retiring || this.#stopped || this.#options.signal?.aborted)
823
+ return;
782
824
  const payload = serializeIcqqEvent(event) ?? (event && typeof event === 'object'
783
825
  ? event
784
826
  : null);
@@ -788,10 +830,11 @@ export class IcqqEndpoint extends Endpoint {
788
830
  void runIcqqLoginAssistStep({
789
831
  assist: this.#options.loginAssist,
790
832
  locale: this.#options.locale,
791
- client: this.client,
833
+ client: this,
792
834
  adapter: 'icqq',
793
835
  endpointKey: this.endpointName,
794
836
  owner: this.#loginAssistOwner,
837
+ signal: this.#options.signal, endpointIdentity: this.#options.endpointIdentity,
795
838
  logger: this.#logger,
796
839
  step,
797
840
  event: payload ?? event,
@@ -810,14 +853,14 @@ export class IcqqEndpoint extends Endpoint {
810
853
  const postType = String(payload.post_type ?? '');
811
854
  const isRequest = postType === 'request' || postType.startsWith('request.');
812
855
  const flag = payload.flag != null ? String(payload.flag) : '';
813
- void receiveOneBotLikeSideEvent((name, payload) => this.emit(name, payload), {
856
+ void receiveOneBotLikeSideEvent((name, payload) => this[endpointSpec].events.emit(name, payload), {
814
857
  adapter: 'icqq',
815
858
  endpointKey: this.endpointName,
816
859
  platform: 'icqq',
817
860
  raw: payload,
818
861
  ...(isRequest ? {
819
- approve: (id, remark) => this.#approveRequest(flag || id, remark),
820
- reject: (id, reason) => this.#rejectRequest(flag || id, reason),
862
+ approve: (id, remark) => this.#approveRequest({ requestId: flag || id, remark }),
863
+ reject: (id, reason) => this.#rejectRequest({ requestId: flag || id, reason }),
821
864
  } : {}),
822
865
  }).catch((err) => {
823
866
  this.#logger.warn(formatCompact({
@@ -829,7 +872,7 @@ export class IcqqEndpoint extends Endpoint {
829
872
  });
830
873
  }
831
874
  #dispatchIcqqSystemSideEvent(type, payload) {
832
- void this.emit('system.receive', buildSystem(payload ?? {}, {
875
+ void this[endpointSpec].events.emit('system.receive', buildSystem(payload ?? {}, {
833
876
  id: `system:${this.endpointName}:${type}:${Date.now()}`,
834
877
  clientAdapter: 'icqq',
835
878
  endpointId: this.endpointName,
@@ -921,6 +964,8 @@ export class IcqqEndpoint extends Endpoint {
921
964
  }));
922
965
  }
923
966
  await this.#deliverInbound({
967
+ event,
968
+ platformSentAt: normalized.timestampMs,
924
969
  id: normalized.messageId,
925
970
  conversation,
926
971
  content: formatInboundContent(normalized.rawMessage),
@@ -951,13 +996,13 @@ export class IcqqEndpoint extends Endpoint {
951
996
  return null;
952
997
  if (data.message_type === 'group' || data.group_id != null) {
953
998
  const groupId = Number(data.group_id);
954
- return Number.isFinite(groupId) && groupId > 0 ? this.client.pickGroup(groupId) : null;
999
+ return Number.isFinite(groupId) && groupId > 0 ? this.pickGroup(groupId) : null;
955
1000
  }
956
1001
  const sourceGroupId = Number(data.sender?.group_id);
957
1002
  if (Number.isFinite(sourceGroupId) && sourceGroupId > 0) {
958
- return this.client.pickMember(sourceGroupId, userId);
1003
+ return this.pickMember(sourceGroupId, userId);
959
1004
  }
960
- return this.client.pickFriend(userId);
1005
+ return this.pickFriend(userId);
961
1006
  }
962
1007
  #logInboundHandlerFailure(event, error) {
963
1008
  this.#logger.warn(formatCompact({
@@ -991,6 +1036,9 @@ export class IcqqEndpoint extends Endpoint {
991
1036
  return;
992
1037
  }
993
1038
  await this.admit({
1039
+ event,
1040
+ platformSentAt: typeof normalized.raw.time === 'number' && Number.isFinite(normalized.raw.time)
1041
+ ? normalized.timestampMs : undefined,
994
1042
  id: normalized.messageId,
995
1043
  conversation: icqqInboundConversation(String(this.#options.id), {
996
1044
  channelType: 'channel',
@@ -1023,7 +1071,7 @@ export class IcqqEndpoint extends Endpoint {
1023
1071
  this.#dispatchIcqqSideEvent(payload);
1024
1072
  }
1025
1073
  async #listPendingRequests() {
1026
- const messages = await this.client.getSystemMsg();
1074
+ const messages = await this.getSystemMsg();
1027
1075
  const rows = [];
1028
1076
  for (const raw of messages) {
1029
1077
  const msg = raw;
@@ -1051,7 +1099,7 @@ export class IcqqEndpoint extends Endpoint {
1051
1099
  }
1052
1100
  async #pullPendingSystemMessages(signal) {
1053
1101
  try {
1054
- const messages = await raceAbort(Promise.resolve(this.client.getSystemMsg()), signal);
1102
+ const messages = await raceAbort(Promise.resolve(this.getSystemMsg()), signal);
1055
1103
  for (const raw of messages) {
1056
1104
  signal.throwIfAborted();
1057
1105
  const msg = raw;
@@ -1081,8 +1129,9 @@ export class IcqqEndpoint extends Endpoint {
1081
1129
  }));
1082
1130
  }
1083
1131
  }
1084
- async #approveRequest(id, remark) {
1085
- const messages = await this.client.getSystemMsg();
1132
+ async #approveRequest({ requestId: id, remark, signal }) {
1133
+ const operation = createEndpointOperation({ signal });
1134
+ const messages = await operation.run(() => this.getSystemMsg());
1086
1135
  const target = messages.find((m) => {
1087
1136
  const msg = m;
1088
1137
  return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
@@ -1090,15 +1139,17 @@ export class IcqqEndpoint extends Endpoint {
1090
1139
  if (!target?.flag) {
1091
1140
  throw new Error(`icqq 未找到待处理请求: ${id}`);
1092
1141
  }
1142
+ const flag = target.flag;
1093
1143
  if (target.request_type === 'friend') {
1094
- await this.client.setFriendAddRequest(target.flag, true, remark);
1144
+ await operation.run(() => this.setFriendAddRequest(flag, true, remark));
1095
1145
  }
1096
1146
  else {
1097
- await this.client.setGroupAddRequest(target.flag, true);
1147
+ await operation.run(() => this.setGroupAddRequest(flag, true));
1098
1148
  }
1099
1149
  }
1100
- async #rejectRequest(id, reason) {
1101
- const messages = await this.client.getSystemMsg();
1150
+ async #rejectRequest({ requestId: id, reason, signal }) {
1151
+ const operation = createEndpointOperation({ signal });
1152
+ const messages = await operation.run(() => this.getSystemMsg());
1102
1153
  const target = messages.find((m) => {
1103
1154
  const msg = m;
1104
1155
  return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
@@ -1106,11 +1157,12 @@ export class IcqqEndpoint extends Endpoint {
1106
1157
  if (!target?.flag) {
1107
1158
  throw new Error(`icqq 未找到待处理请求: ${id}`);
1108
1159
  }
1160
+ const flag = target.flag;
1109
1161
  if (target.request_type === 'friend') {
1110
- await this.client.setFriendAddRequest(target.flag, false);
1162
+ await operation.run(() => this.setFriendAddRequest(flag, false));
1111
1163
  }
1112
1164
  else {
1113
- await this.client.setGroupAddRequest(target.flag, false, reason);
1165
+ await operation.run(() => this.setGroupAddRequest(flag, false, reason));
1114
1166
  }
1115
1167
  }
1116
1168
  }