@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/src/endpoint.ts CHANGED
@@ -11,16 +11,29 @@ import {
11
11
  import { inspect } from 'node:util';
12
12
  import { AsyncLocalStorage } from 'node:async_hooks';
13
13
  import {
14
- Endpoint,
14
+ createEndpointSpecification,
15
+ endpointSpec,
16
+ type Endpoint,
17
+ type EndpointSpecification,
15
18
  type EndpointControl,
16
19
  type EndpointManagement,
17
20
  type EndpointPendingRequest,
18
21
  type EndpointSendRequest,
19
22
  type EndpointTransportState,
20
23
  type AdapterOutageNotifier,
24
+ createEndpointOperation,
25
+ type EndpointManagementRequest,
26
+ type EndpointGroupRequest,
27
+ type EndpointApproveRequest,
28
+ type EndpointRejectRequest,
29
+ type EndpointMemberRequest,
30
+ type EndpointMuteRequest,
31
+ type EndpointAdminRequest,
32
+ type EndpointUserRequest,
33
+ type EndpointGroupNoticeRequest,
21
34
  } from 'zhin.js/adapter';
22
35
  import type { EndpointContentPort } from '@zhin.js/adapter';
23
- import { receiveOneBotLikeSideEvent, buildSystem, toCanonicalSegments, type LoginAssist, type PendingLoginTask } from '@zhin.js/core';
36
+ import { receiveOneBotLikeSideEvent, buildSystem, toCanonicalSegments, type LoginAssist, type LoginAssistEndpointIdentity, type PendingLoginTask } from '@zhin.js/core';
24
37
  import { EndpointDeliveryError, type EndpointSendPart, type EndpointSendResult, type MessageRef } from '@zhin.js/im-contract';
25
38
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
26
39
  import type { CapabilityId } from 'zhin.js';
@@ -70,6 +83,8 @@ import { inspectGroupMemberProfile, inspectGroupScene } from './group-scene.js';
70
83
  export interface IcqqEndpointOptions {
71
84
  readonly id: CapabilityId;
72
85
  readonly config: ResolvedIcqqConfig;
86
+ readonly signal?: AbortSignal;
87
+ readonly endpointIdentity?: LoginAssistEndpointIdentity;
73
88
  readonly loginAssist: LoginAssist;
74
89
  readonly locale?: import('zhin.js/i18n').Locale;
75
90
  /** Out-of-band outage notifier; absent in tests/hosts without outbound. */
@@ -95,8 +110,8 @@ const BOUND_EVENTS = [
95
110
  'system.login.auth',
96
111
  ] as const;
97
112
 
98
- export class IcqqEndpoint extends Endpoint<Client> {
99
- readonly client: ManagedIcqqClient;
113
+ export class IcqqEndpoint extends ManagedIcqqClient implements Endpoint {
114
+ readonly [endpointSpec]: EndpointSpecification;
100
115
  readonly #logger: ReturnType<typeof getAdapterLogger>;
101
116
  readonly #options: IcqqEndpointOptions;
102
117
  readonly endpointName: string;
@@ -117,7 +132,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
117
132
  readonly #inflightInbound = new Set<Promise<void>>();
118
133
  readonly #inboundOwner = new AsyncLocalStorage<symbol>();
119
134
  readonly #contentResolver: IcqqContentResolver;
120
- readonly content: EndpointContentPort;
135
+ readonly #content: EndpointContentPort;
121
136
  readonly loginControl: IcqqLoginControl;
122
137
  #reloginTimer?: ReturnType<typeof setTimeout>;
123
138
  #reloginAttempts = 0;
@@ -125,67 +140,71 @@ export class IcqqEndpoint extends Endpoint<Client> {
125
140
  #offlineNoticeAt = 0;
126
141
  #challengeNoticeAt = 0;
127
142
 
128
- readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
129
- inspectGroupMember: (group, user, signal) => inspectGroupMember(this.client, group, user, signal),
130
- inspectGroupScene: (group, signal) => inspectGroupScene(this.client, group, signal),
131
- inspectGroupMemberProfile: (group, user, signal) => inspectGroupMemberProfile(this.client, group, user, signal),
132
- moderateGroupMember: (group, user, action, signal) => moderateGroupMember(
133
- this.client, group, user, action, signal, () => this.#open && !this.#retiring && !this.#stopped,
134
- ),
135
- listFriends: async () =>
136
- Array.from(this.client.fl.values()).map((f) => ({
143
+ readonly #management: EndpointManagement = Object.freeze<EndpointManagement>({
144
+ inspectGroupMember: request => inspectGroupMember(this, request),
145
+ inspectGroupScene: request => inspectGroupScene(this, request),
146
+ inspectGroupMemberProfile: request => inspectGroupMemberProfile(this, request),
147
+ moderateGroupMember: request => moderateGroupMember({ client: this, isActive: () => this.#open && !this.#retiring && !this.#stopped }, request),
148
+ listFriends: async (request: EndpointManagementRequest = {}) =>
149
+ createEndpointOperation(request).run(() => Array.from(this.fl.values()).map((f) => ({
137
150
  user_id: f.user_id,
138
151
  nickname: f.nickname,
139
152
  remark: f.remark ?? '',
140
- })),
141
- listGroups: async () =>
142
- Array.from(this.client.gl.values()).map((g) => ({
153
+ }))),
154
+ listGroups: async (request: EndpointManagementRequest = {}) =>
155
+ createEndpointOperation(request).run(() => Array.from(this.gl.values()).map((g) => ({
143
156
  group_id: g.group_id,
144
157
  name: g.group_name,
145
- })),
146
- listChannels: async () => {
147
- await this.#guildCatalog.syncAll(this.client);
148
- return this.#guildCatalog.getGuildChannelList();
158
+ }))),
159
+ listChannels: async (request: EndpointManagementRequest = {}) => {
160
+ const operation = createEndpointOperation(request);
161
+ await operation.run(() => this.#guildCatalog.syncAll(this));
162
+ return operation.run(() => this.#guildCatalog.getGuildChannelList());
149
163
  },
150
- listGroupMembers: async (groupId) =>
151
- Array.from((await this.client.getGroupMemberList(Number(groupId))).values()),
152
- listRequests: () => this.#listPendingRequests(),
153
- approveRequest: (requestId, remark) => this.#approveRequest(requestId, remark),
154
- rejectRequest: (requestId, reason) => this.#rejectRequest(requestId, reason),
155
- kickGroupMember: async (groupId, userId) => {
156
- await this.client.setGroupKick(Number(groupId), Number(userId));
164
+ listGroupMembers: async ({ groupId, signal }: EndpointGroupRequest) =>
165
+ Array.from((await createEndpointOperation({ signal }).run(() => this.getGroupMemberList(Number(groupId)))).values()),
166
+ listRequests: (request: EndpointManagementRequest = {}) => createEndpointOperation(request).run(() => this.#listPendingRequests()),
167
+ approveRequest: request => this.#approveRequest(request),
168
+ rejectRequest: request => this.#rejectRequest(request),
169
+ kickGroupMember: async ({ groupId, userId, signal }: EndpointMemberRequest) => {
170
+ const operation = createEndpointOperation({ signal });
171
+ await operation.run(() => this.setGroupKick(Number(groupId), Number(userId)));
157
172
  },
158
- muteGroupMember: async (groupId, userId, duration) => {
159
- await this.client.setGroupBan(Number(groupId), Number(userId), duration);
173
+ muteGroupMember: async ({ groupId, userId, durationSeconds: duration, signal }: EndpointMuteRequest) => {
174
+ const operation = createEndpointOperation({ signal });
175
+ await operation.run(() => this.setGroupBan(Number(groupId), Number(userId), duration));
160
176
  },
161
- setGroupAdmin: async (groupId, userId, enabled) => {
162
- await this.client.setGroupAdmin(Number(groupId), Number(userId), enabled);
177
+ setGroupAdmin: async ({ groupId, userId, enabled, signal }: EndpointAdminRequest) => {
178
+ const operation = createEndpointOperation({ signal });
179
+ await operation.run(() => this.setGroupAdmin(Number(groupId), Number(userId), enabled));
163
180
  },
164
- deleteFriend: async (userId) => {
165
- await this.client.deleteFriend(Number(userId));
181
+ deleteFriend: async ({ userId, signal }: EndpointUserRequest) => {
182
+ const operation = createEndpointOperation({ signal });
183
+ await operation.run(() => this.deleteFriend(Number(userId)));
166
184
  },
167
- sendGroupNotice: async (groupId, content) => {
168
- if (await this.client.sendGroupNotice(Number(groupId), content) === false) {
185
+ sendGroupNotice: async ({ groupId, content, signal }: EndpointGroupNoticeRequest) => {
186
+ const operation = createEndpointOperation({ signal });
187
+ if (await operation.run(() => this.sendGroupNotice(Number(groupId), content)) === false) {
169
188
  throw new Error('QQ did not confirm the group notice');
170
189
  }
171
190
  },
172
191
  });
173
192
 
174
- readonly control: EndpointControl = Object.freeze({
175
- recall: async (message: MessageRef) => {
193
+ readonly #control: EndpointControl = Object.freeze<EndpointControl>({
194
+ recall: async ({ message, signal }) => {
176
195
  if (!message.id || message.id.startsWith('outbound:')) return;
177
- if (!await this.client.deleteMsg(message.id)) throw new Error('QQ did not confirm message recall');
196
+ if (!await createEndpointOperation({ signal }).run(() => this.deleteMsg(message.id))) throw new Error('QQ did not confirm message recall');
178
197
  },
179
- addReaction: async (message: MessageRef, emoji: string) => {
198
+ addReaction: async ({ message, emoji, signal }) => {
180
199
  const target = resolveIcqqGroupReactionTarget(message);
181
200
  if (!target) return null;
182
201
  try {
183
202
  // Do not await protocol ACK — packet timeout must not stall the AI reply.
184
- this.#ignoreReactionAck(
185
- 'add',
186
- this.client.pickGroup(target.groupId).setReaction(target.seq, emoji),
187
- );
203
+ await createEndpointOperation({ signal }).run(() => {
204
+ this.#ignoreReactionAck('add', this.pickGroup(target.groupId).setReaction(target.seq, emoji));
205
+ });
188
206
  } catch (error) {
207
+ if (error instanceof EndpointDeliveryError && error.code === 'endpoint_inactive') throw error;
189
208
  this.#logger.debug(formatCompact({
190
209
  op: 'reaction_add_failed',
191
210
  error: error instanceof Error ? error.message : String(error),
@@ -193,15 +212,15 @@ export class IcqqEndpoint extends Endpoint<Client> {
193
212
  }
194
213
  return emoji;
195
214
  },
196
- removeReaction: async (message: MessageRef, reactionId: string) => {
215
+ removeReaction: async ({ message, reactionId, signal }) => {
197
216
  const target = resolveIcqqGroupReactionTarget(message);
198
217
  if (!target) return;
199
218
  try {
200
- this.#ignoreReactionAck(
201
- 'remove',
202
- this.client.pickGroup(target.groupId).delReaction(target.seq, reactionId),
203
- );
219
+ await createEndpointOperation({ signal }).run(() => {
220
+ this.#ignoreReactionAck('remove', this.pickGroup(target.groupId).delReaction(target.seq, reactionId));
221
+ });
204
222
  } catch (error) {
223
+ if (error instanceof EndpointDeliveryError && error.code === 'endpoint_inactive') throw error;
205
224
  this.#logger.debug(formatCompact({
206
225
  op: 'reaction_remove_failed',
207
226
  error: error instanceof Error ? error.message : String(error),
@@ -211,12 +230,20 @@ export class IcqqEndpoint extends Endpoint<Client> {
211
230
  });
212
231
 
213
232
  constructor(options: IcqqEndpointOptions) {
214
- super();
215
- const nativeConfig = resolveNativeClientConfig(options.config);
216
- this.client = new ManagedIcqqClient(Number(options.config.id), nativeConfig,
233
+ super(Number(options.config.id), resolveNativeClientConfig(options.config),
217
234
  (operation, error) => this.#handleBackgroundFailure(operation, error));
218
- this.#contentResolver = new IcqqContentResolver(this.client);
219
- this.content = this.#contentResolver.port;
235
+ const name = () => this.endpointName;
236
+ const state = () => this.#transportState;
237
+ const content = () => this.#content;
238
+ this[endpointSpec] = createEndpointSpecification(this, {
239
+ start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(), stop: () => this.#stopFramework(),
240
+ send: request => this.#sendFramework(request), get name() { return name(); },
241
+ get transportState() { return state(); },
242
+ management: this.#management, control: this.#control,
243
+ get content() { return content(); },
244
+ });
245
+ this.#contentResolver = new IcqqContentResolver(this);
246
+ this.#content = this.#contentResolver.port;
220
247
  this.#logger = getAdapterLogger('icqq', options.config.id);
221
248
  this.#options = options;
222
249
  this.endpointName = options.config.id;
@@ -224,14 +251,14 @@ export class IcqqEndpoint extends Endpoint<Client> {
224
251
  endpointId: this.endpointName,
225
252
  state: () => ({
226
253
  endpointId: this.endpointName,
227
- transportState: this.transportState,
228
- online: this.client.isOnline(),
254
+ transportState: this.#transportState,
255
+ online: this.isOnline(),
229
256
  pending: this.#pendingLoginTasks(),
230
257
  }),
231
258
  pending: () => this.#pendingLoginTasks(),
232
- relogin: () => this.relogin(),
233
- submit: (taskId: string, value: string | Record<string, unknown>) => this.#options.loginAssist.submit(taskId, value),
234
- cancel: (taskId: string, reason?: string) => this.#options.loginAssist.cancel(taskId, reason),
259
+ relogin: async () => { this.#options.signal?.throwIfAborted(); await this.relogin(); },
260
+ submit: (taskId: string, value: string | Record<string, unknown>) => !this.#options.signal?.aborted && this.#options.loginAssist.submit(taskId, value, this.#options.endpointIdentity),
261
+ cancel: (taskId: string, reason?: string) => !this.#options.signal?.aborted && this.#options.loginAssist.cancel(taskId, reason, this.#options.endpointIdentity),
235
262
  });
236
263
  }
237
264
 
@@ -244,11 +271,11 @@ export class IcqqEndpoint extends Endpoint<Client> {
244
271
  : typeof error === 'string' ? error : 'Unknown ICQQ SDK background failure',
245
272
  ...(error && typeof error === 'object' && 'code' in error && typeof error.code === 'number' ? { code: error.code } : {}),
246
273
  }));
247
- if (operation === 'sso-heartbeat' && !this.client.isOnline()) return;
274
+ if (operation === 'sso-heartbeat' && !this.isOnline()) return;
248
275
  if (operation === 'login' && !this.#everOnline) return;
249
276
  // Delegate to the SDK's existing transport termination, offline event and
250
277
  // reconn_interval policy. No account-level logout or second retry timer.
251
- this.client.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
278
+ this.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
252
279
  }
253
280
 
254
281
  /** Force a login attempt (manual recovery command). */
@@ -266,7 +293,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
266
293
  this.#reloginInFlight = task;
267
294
  void task.catch(() => undefined).finally(() => {
268
295
  if (this.#reloginInFlight === task) this.#reloginInFlight = undefined;
269
- if (this.#started && !this.#stopped && !this.#retiring && !this.client.isOnline()) {
296
+ if (this.#started && !this.#stopped && !this.#retiring && !this.isOnline()) {
270
297
  this.#scheduleRelogin('relogin_failed');
271
298
  }
272
299
  });
@@ -275,10 +302,10 @@ export class IcqqEndpoint extends Endpoint<Client> {
275
302
 
276
303
  async #performRelogin(): Promise<void> {
277
304
  this.#clearReloginTimer();
278
- this.client.cancelReconnect();
305
+ this.cancelReconnect();
279
306
  this.#logger.info(formatCompact({ op: 'icqq_relogin', endpoint: this.endpointName }));
280
307
  try {
281
- await this.client.login(this.#options.config.password);
308
+ await this.login(this.#options.config.password);
282
309
  } catch (error) {
283
310
  this.#logger.warn(formatCompact({
284
311
  op: 'icqq_relogin_failed',
@@ -318,8 +345,14 @@ export class IcqqEndpoint extends Endpoint<Client> {
318
345
  }
319
346
 
320
347
  #pendingLoginTasks(): readonly PendingLoginTask[] {
348
+ if (this.#options.signal?.aborted) return [];
321
349
  return this.#options.loginAssist.listPending()
322
- .filter((task) => task.adapter === 'icqq' && task.endpointKey === this.endpointName);
350
+ .filter((task) => task.adapter === 'icqq' && (this.#options.endpointIdentity
351
+ ? task.endpointIdentity?.canonicalId === this.#options.endpointIdentity.canonicalId
352
+ && task.endpointIdentity.instanceId === this.#options.endpointIdentity.instanceId
353
+ && task.endpointIdentity.epoch === this.#options.endpointIdentity.epoch
354
+ && task.endpointIdentity.incarnationId === this.#options.endpointIdentity.incarnationId
355
+ : task.endpointKey === this.endpointName));
323
356
  }
324
357
 
325
358
  #notifyOffline(reason: string, willRetry: boolean): void {
@@ -395,15 +428,15 @@ export class IcqqEndpoint extends Endpoint<Client> {
395
428
  });
396
429
  }
397
430
 
398
- get transportState(): EndpointTransportState {
431
+ get #transportState(): EndpointTransportState {
399
432
  if (this.#stopped || this.#retiring) return 'stopped';
400
433
  if (!this.#started) return 'idle';
401
- if (this.client.isOnline()) return 'open';
434
+ if (this.isOnline()) return 'open';
402
435
  if (!this.#everOnline) return 'connecting';
403
436
  return this.#options.config.autoReconnect ? 'reconnecting' : 'closed';
404
437
  }
405
438
 
406
- async start(signal: AbortSignal): Promise<void> {
439
+ async #startFramework(signal: AbortSignal): Promise<void> {
407
440
  if (this.#started) return;
408
441
  signal.throwIfAborted();
409
442
  this.#stopped = false;
@@ -412,7 +445,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
412
445
  this.#retirementEpoch += 1;
413
446
  this.#retirementArmed = false;
414
447
  this.#retiring = false;
415
- this.client.off('system.online');
448
+ this.off('system.online');
416
449
  this.#started = true;
417
450
  let resolveOnline!: () => void;
418
451
  const onlineReady = new Promise<void>((resolve) => {
@@ -423,17 +456,17 @@ export class IcqqEndpoint extends Endpoint<Client> {
423
456
  signal.addEventListener('abort', onAbort, { once: true });
424
457
  try {
425
458
  this.#bindClientEvents();
426
- await raceAbort(Promise.resolve(this.client.login(this.#options.config.password)), signal);
459
+ await raceAbort(Promise.resolve(this.login(this.#options.config.password)), signal);
427
460
  await onlineReady;
428
461
  signal.throwIfAborted();
429
462
  await this.#pullPendingSystemMessages(signal);
430
463
  signal.throwIfAborted();
431
464
  this.#logger.info(
432
- `connected (direct) | friends: ${this.client.fl.size} | groups: ${this.client.gl.size}`,
465
+ `connected (direct) | friends: ${this.fl.size} | groups: ${this.gl.size}`,
433
466
  );
434
467
  } catch (error) {
435
468
  this.#notifyOffline('start_failed', false);
436
- await this.stop();
469
+ await this.#stopFramework();
437
470
  this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
438
471
  throw error;
439
472
  } finally {
@@ -442,11 +475,11 @@ export class IcqqEndpoint extends Endpoint<Client> {
442
475
  }
443
476
  }
444
477
 
445
- open(): void {
478
+ #openFramework(): void {
446
479
  if (this.#retirementArmed) {
447
480
  this.#retirementEpoch += 1;
448
481
  this.#retirementArmed = false;
449
- this.client.off('system.online');
482
+ this.off('system.online');
450
483
  this.#bindSystemOnlineEvent();
451
484
  }
452
485
  this.#retiring = false;
@@ -459,16 +492,16 @@ export class IcqqEndpoint extends Endpoint<Client> {
459
492
  }
460
493
  }
461
494
 
462
- async close(): Promise<void> {
495
+ async #closeFramework(): Promise<void> {
463
496
  this.#assertExternalLifecycle('close');
464
497
  this.#armRetirementFence();
465
498
  await Promise.allSettled([...this.#inflightInbound]);
466
499
  }
467
500
 
468
- async stop(): Promise<void> {
501
+ async #stopFramework(): Promise<void> {
502
+ this.#assertExternalLifecycle('stop');
469
503
  this.#stopped = true;
470
504
  this.#clearReloginTimer();
471
- this.#assertExternalLifecycle('stop');
472
505
  this.#armRetirementFence();
473
506
  await Promise.allSettled([...this.#inflightInbound]);
474
507
  this.#resolveStartOnline?.();
@@ -479,7 +512,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
479
512
  for (const event of BOUND_EVENTS) {
480
513
  // The retirement guard owns this matcher until a late login handshake
481
514
  // has finished. start()/open() replace it with the normal listener.
482
- if (event !== 'system.online') this.client.off(event);
515
+ if (event !== 'system.online') this.off(event);
483
516
  }
484
517
  // `Client.logout()` sends an account-level unregister packet before it
485
518
  // closes this transport. During config HMR the replacement endpoint for
@@ -487,7 +520,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
487
520
  // also closes the replacement session. Endpoint ownership is transport
488
521
  // scoped: retire only this Client's TCP connection.
489
522
  try {
490
- this.client.terminate();
523
+ this.terminate();
491
524
  } catch { /* ignore */ }
492
525
  this.#inboundDeduper.clear();
493
526
  this.#sideEventDeduper.clear();
@@ -504,26 +537,37 @@ export class IcqqEndpoint extends Endpoint<Client> {
504
537
  const retirementEpoch = ++this.#retirementEpoch;
505
538
  // ICQQ owns reconnect through this per-Client timer. Clear it before any
506
539
  // asynchronous drain so a queued callback cannot enter while close waits.
507
- this.client.cancelReconnect();
540
+ this.cancelReconnect();
508
541
  // A login callback may already be inside the SDK handshake, which has no
509
542
  // abort API. Replace the normal online listener with an epoch-owned guard
510
543
  // before draining inbound work. Transport termination is account-local;
511
544
  // logout would also unregister an HMR replacement using the same UIN.
512
- this.client.off('system.online');
513
- this.client.on('system.online', () => {
545
+ this.off('system.online');
546
+ this.on('system.online', () => {
514
547
  if (this.#retirementEpoch !== retirementEpoch || !this.#retiring) return;
515
548
  try {
516
- this.client.terminate();
549
+ this.terminate();
517
550
  } catch { /* already closed */ }
518
551
  });
519
552
  }
520
553
 
521
- async send({ conversation, payload }: EndpointSendRequest): Promise<EndpointSendResult> {
554
+ async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<EndpointSendResult> {
522
555
  if (!this.#started) throw new EndpointDeliveryError('endpoint_disconnected', 'icqq endpoint 未连接', 'not_sent');
556
+ const operation = createEndpointOperation({ signal });
523
557
  const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
524
- const prepared = isIcqqSegmentPayload(payload)
558
+ const prepared = await operation.prepare(() => isIcqqSegmentPayload(payload)
525
559
  ? prepareIcqqOutboundMedia(normalizeIcqqImageMedia(payload), mediaMode)
526
- : { content: payload, dispose() {} };
560
+ : { content: payload, dispose() {} });
561
+ // Cancellation ends our wait, not the SDK's use of temporary media. The actual native
562
+ // promises own that resource until they settle, including a late rejection after cancellation.
563
+ const pendingSends = new Set<Promise<{ message_id?: unknown }>>();
564
+ const sendNative = (target: ReturnType<typeof icqqOutboundTarget>, message: Sendable) => {
565
+ const task = this.#sendNative(target, message);
566
+ pendingSends.add(task);
567
+ const settled = () => { pendingSends.delete(task); };
568
+ void task.then(settled, settled);
569
+ return task;
570
+ };
527
571
  try {
528
572
  // Standalone elements (share / file / …) are split into ordered messages.
529
573
  const messages = formatOutboundMessageParts(prepared.content);
@@ -533,7 +577,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
533
577
  const preview = formatIcqqSendablePreview(message);
534
578
  let result: { message_id?: unknown };
535
579
  try {
536
- result = await this.#sendNative(parsed, message);
580
+ result = await operation.run(() => sendNative(parsed, message));
537
581
  } catch (error) {
538
582
  this.#logger.warn(formatCompact({
539
583
  op: 'icqq_send_failed',
@@ -560,12 +604,14 @@ export class IcqqEndpoint extends Endpoint<Client> {
560
604
  }
561
605
  return { parts };
562
606
  } finally {
563
- try {
564
- prepared.dispose();
565
- } catch (error) {
566
- // Temporary-file cleanup cannot change an already observed platform send outcome.
567
- this.#logger.warn(formatCompact({ op: 'icqq_media_cleanup_failed', error: describeUnknownError(error) }));
568
- }
607
+ const disposeMedia = () => {
608
+ try { prepared.dispose(); } catch (error) {
609
+ // Temporary-file cleanup cannot change an observed send result.
610
+ this.#logger.warn(formatCompact({ op: 'icqq_media_cleanup_failed', error: describeUnknownError(error) }));
611
+ }
612
+ };
613
+ if (pendingSends.size) void Promise.allSettled([...pendingSends]).then(disposeMedia);
614
+ else disposeMedia();
569
615
  }
570
616
  }
571
617
 
@@ -576,20 +622,20 @@ export class IcqqEndpoint extends Endpoint<Client> {
576
622
  if (isIcqqShareElement(message)) {
577
623
  switch (target.kind) {
578
624
  case 'private': {
579
- const friend = this.client.pickFriend(target.userId);
625
+ const friend = this.pickFriend(target.userId);
580
626
  await friend.share(message);
581
627
  return {};
582
628
  }
583
629
  case 'group': {
584
- const group = this.client.pickGroup(target.groupId);
630
+ const group = this.pickGroup(target.groupId);
585
631
  await group.share(message);
586
632
  return {};
587
633
  }
588
634
  case 'temp':
589
- await this.client.pickMember(target.groupId, target.userId).share(message);
635
+ await this.pickMember(target.groupId, target.userId).share(message);
590
636
  return {};
591
637
  case 'channel': {
592
- const channel = this.client.pickGuild(target.guildId).channels.get(target.channelId);
638
+ const channel = this.pickGuild(target.guildId).channels.get(target.channelId);
593
639
  if (!channel) {
594
640
  throw new TypeError(`ICQQ guild channel is unavailable: ${target.guildId}/${target.channelId}`);
595
641
  }
@@ -601,11 +647,11 @@ export class IcqqEndpoint extends Endpoint<Client> {
601
647
  if (isIcqqFileElement(message)) {
602
648
  switch (target.kind) {
603
649
  case 'private': {
604
- await this.client.pickFriend(target.userId).sendFile(message.file, message.name);
650
+ await this.pickFriend(target.userId).sendFile(message.file, message.name);
605
651
  return {};
606
652
  }
607
653
  case 'group': {
608
- await this.client.pickGroup(target.groupId).sendFile(message.file, '/', message.name);
654
+ await this.pickGroup(target.groupId).sendFile(message.file, '/', message.name);
609
655
  return {};
610
656
  }
611
657
  case 'temp':
@@ -615,13 +661,13 @@ export class IcqqEndpoint extends Endpoint<Client> {
615
661
  }
616
662
  switch (target.kind) {
617
663
  case 'private':
618
- return this.client.sendPrivateMsg(target.userId, message);
664
+ return this.sendPrivateMsg(target.userId, message);
619
665
  case 'group':
620
- return this.client.sendGroupMsg(target.groupId, message);
666
+ return this.sendGroupMsg(target.groupId, message);
621
667
  case 'temp':
622
- return this.client.sendTempMsg(target.groupId, target.userId, message);
668
+ return this.sendTempMsg(target.groupId, target.userId, message);
623
669
  case 'channel':
624
- return this.client.sendGuildMsg(target.guildId, target.channelId, message) as unknown as { message_id?: unknown };
670
+ return this.sendGuildMsg(target.guildId, target.channelId, message) as unknown as { message_id?: unknown };
625
671
  }
626
672
  }
627
673
 
@@ -636,7 +682,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
636
682
  this.#inflightInbound.add(settlement);
637
683
  let operation: Promise<void>;
638
684
  try {
639
- operation = this.#inboundOwner.run(owner, run);
685
+ operation = this.#inboundOwner.run(owner, () => this[endpointSpec].events.run(run));
640
686
  } catch (error) {
641
687
  this.#inflightInbound.delete(settlement);
642
688
  settle();
@@ -690,7 +736,9 @@ export class IcqqEndpoint extends Endpoint<Client> {
690
736
  + (mentioned ? ' (mentioned)' : '')
691
737
  + ` | ${truncatePreview(msg.content, 80)}`,
692
738
  );
693
- await this.emit('message.receive', {
739
+ await this[endpointSpec].events.emit('message.receive', {
740
+ event: msg.event,
741
+ platformSentAt: Number.isFinite(msg.platformSentAt) ? msg.platformSentAt : undefined,
694
742
  conversation,
695
743
  message: { conversation, id: msg.id },
696
744
  content: msg.content,
@@ -717,77 +765,70 @@ export class IcqqEndpoint extends Endpoint<Client> {
717
765
  #bindClientEvents(): void {
718
766
  this.#bindNativeEvents();
719
767
  const safe = (label: string, fn: () => void): void => {
720
- try {
721
- fn();
722
- } catch (error) {
723
- this.#logger.warn(formatCompact({
724
- op: 'icqq_event_handler',
725
- endpoint: this.endpointName,
726
- event: label,
727
- error: error instanceof Error ? error.message : String(error),
728
- }));
729
- }
768
+ void this[endpointSpec].events.run(fn).catch(() => {
769
+ this.#logger.debug(formatCompact({ op: 'icqq_event_not_admitted', endpoint: this.endpointName, event: label }));
770
+ });
730
771
  };
731
- this.client.on('message.private', (event) => {
772
+ this.on('message.private', (event) => {
732
773
  this.#startMessageEvent('message.private', event);
733
774
  });
734
- this.client.on('message.group', (event) => {
775
+ this.on('message.group', (event) => {
735
776
  this.#startMessageEvent('message.group', event);
736
777
  });
737
- this.client.on('message.guild', (event) => {
738
- void this.#handleGuildEvent(event).catch((error) => {
778
+ this.on('message.guild', (event) => {
779
+ void this.#trackInbound(() => this.#handleGuildEvent(event)).catch((error) => {
739
780
  this.#logInboundHandlerFailure('message.guild', error);
740
781
  });
741
782
  });
742
- this.client.on('request.friend', (event) => safe('request.friend', () => {
783
+ this.on('request.friend', (event) => safe('request.friend', () => {
743
784
  this.#onRequestEvent(serializeIcqqEvent(event));
744
785
  }));
745
- this.client.on('request.group', (event) => safe('request.group', () => {
786
+ this.on('request.group', (event) => safe('request.group', () => {
746
787
  this.#onRequestEvent(serializeIcqqEvent(event));
747
788
  }));
748
- this.client.on('notice.friend', (event) => safe('notice.friend', () => {
789
+ this.on('notice.friend', (event) => safe('notice.friend', () => {
749
790
  this.#onNoticeEvent(serializeIcqqEvent(event));
750
791
  }));
751
- this.client.on('notice.group', (event) => safe('notice.group', () => {
792
+ this.on('notice.group', (event) => safe('notice.group', () => {
752
793
  this.#onNoticeEvent(serializeIcqqEvent(event));
753
794
  }));
754
795
  this.#bindSystemOnlineEvent();
755
- this.client.on('system.offline', (event) => {
796
+ this.on('system.offline', (event) => {
756
797
  this.#logSystemEvent('offline', event);
757
798
  this.#notifyOffline('offline', this.#options.config.autoReconnect);
758
799
  });
759
- this.client.on('system.offline.network', (event) => {
800
+ this.on('system.offline.network', (event) => {
760
801
  this.#logSystemEvent('offline.network', event);
761
802
  this.#notifyOffline('network', this.#options.config.autoReconnect);
762
803
  });
763
- this.client.on('system.offline.kickoff', (event) => {
804
+ this.on('system.offline.kickoff', (event) => {
764
805
  this.#logSystemEvent('offline.kickoff', event);
765
806
  this.#notifyOffline('kickoff', this.#options.config.autoReconnect);
766
807
  this.#scheduleRelogin('kickoff');
767
808
  });
768
- this.client.on('system.login.qrcode', (event) => {
809
+ this.on('system.login.qrcode', (event) => {
769
810
  this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
770
811
  this.#handleLoginChallenge('qrcode', event);
771
812
  });
772
- this.client.on('system.login.device', (event) => {
813
+ this.on('system.login.device', (event) => {
773
814
  this.#handleLoginChallenge('device', event);
774
815
  });
775
- this.client.on('system.login.slider', (event) => {
816
+ this.on('system.login.slider', (event) => {
776
817
  this.#handleLoginChallenge('slider', event);
777
818
  });
778
- this.client.on('system.login.error', (event) => {
819
+ this.on('system.login.error', (event) => {
779
820
  this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
780
821
  this.#logSystemEvent('login.error', event);
781
822
  this.#notifyOffline('login_error', this.#options.config.autoReconnect);
782
823
  this.#scheduleRelogin('login_error');
783
824
  });
784
- this.client.on('system.login.auth', (event) => {
825
+ this.on('system.login.auth', (event) => {
785
826
  this.#handleLoginChallenge('auth', event);
786
827
  });
787
828
  }
788
829
 
789
830
  #bindSystemOnlineEvent(): void {
790
- this.client.on('system.online', (event) => {
831
+ this.on('system.online', (event) => {
791
832
  if (this.#retiring) return;
792
833
  this.#everOnline = true;
793
834
  this.#clearReloginTimer();
@@ -798,7 +839,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
798
839
  this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
799
840
  try {
800
841
  // transport 重连不清除(账号不变);stop/retire 由基类撤销登记。上报失败不影响上线流程。
801
- this.reportSelfUserId(resolveIcqqSelfUserId(this.client));
842
+ this[endpointSpec].reportSelfUserId(resolveIcqqSelfUserId(this));
802
843
  } catch (error) {
803
844
  this.#logger.warn(formatCompact({
804
845
  op: 'icqq_report_self_user_id_failed',
@@ -822,7 +863,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
822
863
  #bindNativeEvents(): void {
823
864
  this.#disposeNativeEvents?.();
824
865
  let matchedName: string | undefined;
825
- const registration = this.client.on(
866
+ const registration = this.on(
826
867
  (eventName: unknown) => {
827
868
  matchedName = typeof eventName === 'string' && !eventName.startsWith('internal.')
828
869
  ? eventName
@@ -838,7 +879,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
838
879
  }
839
880
 
840
881
  #emitNativeEvent(name: string, event: unknown): void {
841
- void this.emitPlatform(name, event).catch((error) => {
882
+ void this[endpointSpec].events.platform(name, event).catch((error) => {
842
883
  this.#logger.warn(formatCompact({
843
884
  op: 'icqq_platform_event_failed',
844
885
  endpoint: this.endpointName,
@@ -852,6 +893,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
852
893
  step: 'qrcode' | 'slider' | 'device' | 'auth',
853
894
  event: unknown,
854
895
  ): void {
896
+ if (this.#retiring || this.#stopped || this.#options.signal?.aborted) return;
855
897
  const payload = serializeIcqqEvent(event) ?? (event && typeof event === 'object'
856
898
  ? event as Record<string, unknown>
857
899
  : null);
@@ -861,10 +903,11 @@ export class IcqqEndpoint extends Endpoint<Client> {
861
903
  void runIcqqLoginAssistStep({
862
904
  assist: this.#options.loginAssist,
863
905
  locale: this.#options.locale,
864
- client: this.client,
906
+ client: this,
865
907
  adapter: 'icqq',
866
908
  endpointKey: this.endpointName,
867
909
  owner: this.#loginAssistOwner,
910
+ signal: this.#options.signal, endpointIdentity: this.#options.endpointIdentity,
868
911
  logger: this.#logger,
869
912
  step,
870
913
  event: payload ?? event,
@@ -885,14 +928,14 @@ export class IcqqEndpoint extends Endpoint<Client> {
885
928
  const postType = String(payload.post_type ?? '');
886
929
  const isRequest = postType === 'request' || postType.startsWith('request.');
887
930
  const flag = payload.flag != null ? String(payload.flag) : '';
888
- void receiveOneBotLikeSideEvent((name, payload) => this.emit(name, payload), {
931
+ void receiveOneBotLikeSideEvent((name, payload) => this[endpointSpec].events.emit(name, payload), {
889
932
  adapter: 'icqq',
890
933
  endpointKey: this.endpointName,
891
934
  platform: 'icqq',
892
935
  raw: payload,
893
936
  ...(isRequest ? {
894
- approve: (id, remark) => this.#approveRequest(flag || id, remark),
895
- reject: (id, reason) => this.#rejectRequest(flag || id, reason),
937
+ approve: (id, remark) => this.#approveRequest({ requestId: flag || id, remark }),
938
+ reject: (id, reason) => this.#rejectRequest({ requestId: flag || id, reason }),
896
939
  } : {}),
897
940
  }).catch((err) => {
898
941
  this.#logger.warn(formatCompact({
@@ -905,7 +948,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
905
948
  }
906
949
 
907
950
  #dispatchIcqqSystemSideEvent(type: string, payload: Record<string, unknown> | null): void {
908
- void this.emit('system.receive', buildSystem(payload ?? {}, {
951
+ void this[endpointSpec].events.emit('system.receive', buildSystem(payload ?? {}, {
909
952
  id: `system:${this.endpointName}:${type}:${Date.now()}`,
910
953
  clientAdapter: 'icqq',
911
954
  endpointId: this.endpointName,
@@ -998,6 +1041,8 @@ export class IcqqEndpoint extends Endpoint<Client> {
998
1041
  }));
999
1042
  }
1000
1043
  await this.#deliverInbound({
1044
+ event,
1045
+ platformSentAt: normalized.timestampMs,
1001
1046
  id: normalized.messageId,
1002
1047
  conversation,
1003
1048
  content: formatInboundContent(normalized.rawMessage),
@@ -1029,13 +1074,13 @@ export class IcqqEndpoint extends Endpoint<Client> {
1029
1074
  if (!Number.isFinite(userId) || userId <= 0) return null;
1030
1075
  if (data.message_type === 'group' || data.group_id != null) {
1031
1076
  const groupId = Number(data.group_id);
1032
- return Number.isFinite(groupId) && groupId > 0 ? this.client.pickGroup(groupId) : null;
1077
+ return Number.isFinite(groupId) && groupId > 0 ? this.pickGroup(groupId) : null;
1033
1078
  }
1034
1079
  const sourceGroupId = Number(data.sender?.group_id);
1035
1080
  if (Number.isFinite(sourceGroupId) && sourceGroupId > 0) {
1036
- return this.client.pickMember(sourceGroupId, userId);
1081
+ return this.pickMember(sourceGroupId, userId);
1037
1082
  }
1038
- return this.client.pickFriend(userId);
1083
+ return this.pickFriend(userId);
1039
1084
  }
1040
1085
 
1041
1086
  #logInboundHandlerFailure(event: string, error: unknown): void {
@@ -1072,6 +1117,9 @@ export class IcqqEndpoint extends Endpoint<Client> {
1072
1117
  return;
1073
1118
  }
1074
1119
  await this.admit({
1120
+ event,
1121
+ platformSentAt: typeof normalized.raw.time === 'number' && Number.isFinite(normalized.raw.time)
1122
+ ? normalized.timestampMs : undefined,
1075
1123
  id: normalized.messageId,
1076
1124
  conversation: icqqInboundConversation(String(this.#options.id), {
1077
1125
  channelType: 'channel',
@@ -1103,7 +1151,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
1103
1151
  }
1104
1152
 
1105
1153
  async #listPendingRequests(): Promise<readonly EndpointPendingRequest[]> {
1106
- const messages = await this.client.getSystemMsg();
1154
+ const messages = await this.getSystemMsg();
1107
1155
  const rows: EndpointPendingRequest[] = [];
1108
1156
  for (const raw of messages) {
1109
1157
  const msg = raw as unknown as IcqqSystemMessage & { request_type?: string };
@@ -1131,7 +1179,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
1131
1179
 
1132
1180
  async #pullPendingSystemMessages(signal: AbortSignal): Promise<void> {
1133
1181
  try {
1134
- const messages = await raceAbort(Promise.resolve(this.client.getSystemMsg()), signal);
1182
+ const messages = await raceAbort(Promise.resolve(this.getSystemMsg()), signal);
1135
1183
  for (const raw of messages) {
1136
1184
  signal.throwIfAborted();
1137
1185
  const msg = raw as unknown as IcqqSystemMessage & { request_type?: string };
@@ -1159,8 +1207,9 @@ export class IcqqEndpoint extends Endpoint<Client> {
1159
1207
  }
1160
1208
  }
1161
1209
 
1162
- async #approveRequest(id: string, remark?: string): Promise<void> {
1163
- const messages = await this.client.getSystemMsg();
1210
+ async #approveRequest({ requestId: id, remark, signal }: EndpointApproveRequest): Promise<void> {
1211
+ const operation = createEndpointOperation({ signal });
1212
+ const messages = await operation.run(() => this.getSystemMsg());
1164
1213
  const target = messages.find((m) => {
1165
1214
  const msg = m as unknown as IcqqSystemMessage;
1166
1215
  return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
@@ -1168,15 +1217,17 @@ export class IcqqEndpoint extends Endpoint<Client> {
1168
1217
  if (!target?.flag) {
1169
1218
  throw new Error(`icqq 未找到待处理请求: ${id}`);
1170
1219
  }
1220
+ const flag = target.flag;
1171
1221
  if ((target as { request_type?: string }).request_type === 'friend') {
1172
- await this.client.setFriendAddRequest(target.flag, true, remark);
1222
+ await operation.run(() => this.setFriendAddRequest(flag, true, remark));
1173
1223
  } else {
1174
- await this.client.setGroupAddRequest(target.flag, true);
1224
+ await operation.run(() => this.setGroupAddRequest(flag, true));
1175
1225
  }
1176
1226
  }
1177
1227
 
1178
- async #rejectRequest(id: string, reason?: string): Promise<void> {
1179
- const messages = await this.client.getSystemMsg();
1228
+ async #rejectRequest({ requestId: id, reason, signal }: EndpointRejectRequest): Promise<void> {
1229
+ const operation = createEndpointOperation({ signal });
1230
+ const messages = await operation.run(() => this.getSystemMsg());
1180
1231
  const target = messages.find((m) => {
1181
1232
  const msg = m as unknown as IcqqSystemMessage;
1182
1233
  return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
@@ -1184,10 +1235,11 @@ export class IcqqEndpoint extends Endpoint<Client> {
1184
1235
  if (!target?.flag) {
1185
1236
  throw new Error(`icqq 未找到待处理请求: ${id}`);
1186
1237
  }
1238
+ const flag = target.flag;
1187
1239
  if ((target as { request_type?: string }).request_type === 'friend') {
1188
- await this.client.setFriendAddRequest(target.flag, false);
1240
+ await operation.run(() => this.setFriendAddRequest(flag, false));
1189
1241
  } else {
1190
- await this.client.setGroupAddRequest(target.flag, false, reason);
1242
+ await operation.run(() => this.setGroupAddRequest(flag, false, reason));
1191
1243
  }
1192
1244
  }
1193
1245
  }