@zhin.js/adapter-icqq 1.1.17 → 1.1.19

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 (59) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +55 -10
  3. package/adapters/icqq/index.js +3 -19
  4. package/adapters/icqq/index.ts +2 -25
  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 +28 -0
  8. package/commands/icqq/login/code/index.ts +30 -0
  9. package/commands/icqq/login/confirm/index.js +20 -0
  10. package/commands/icqq/login/confirm/index.ts +21 -0
  11. package/commands/icqq/login/definition.js +65 -0
  12. package/commands/icqq/login/definition.ts +72 -0
  13. package/commands/icqq/login/qr/index.js +53 -0
  14. package/commands/icqq/login/qr/index.ts +55 -0
  15. package/commands/icqq/login/retry/index.js +21 -0
  16. package/commands/icqq/login/retry/index.ts +23 -0
  17. package/commands/icqq/login/status/index.js +31 -0
  18. package/commands/icqq/login/status/index.ts +34 -0
  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.d.ts +17 -0
  24. package/lib/console-spec.js +21 -0
  25. package/lib/console.d.ts +2 -0
  26. package/lib/console.js +13 -0
  27. package/lib/endpoint.d.ts +12 -15
  28. package/lib/endpoint.js +373 -164
  29. package/lib/group-moderation.d.ts +6 -3
  30. package/lib/group-moderation.js +9 -8
  31. package/lib/group-scene.d.ts +3 -3
  32. package/lib/group-scene.js +8 -7
  33. package/lib/icqq-login-assist.d.ts +3 -1
  34. package/lib/icqq-login-assist.js +3 -1
  35. package/lib/icqq-login-control.d.ts +24 -0
  36. package/lib/icqq-login-control.js +25 -0
  37. package/lib/index.d.ts +3 -2
  38. package/lib/index.js +2 -2
  39. package/lib/protocol.d.ts +13 -0
  40. package/lib/protocol.js +24 -9
  41. package/package.json +25 -17
  42. package/pages/index/index.js +15 -0
  43. package/pages/index/index.ts +16 -0
  44. package/plugin.js +3 -5
  45. package/schema.json +174 -0
  46. package/src/adapter.ts +29 -0
  47. package/src/client.ts +6 -11
  48. package/src/console-spec.ts +26 -0
  49. package/src/console.ts +17 -0
  50. package/src/endpoint.ts +381 -178
  51. package/src/group-moderation.ts +10 -10
  52. package/src/group-scene.ts +8 -8
  53. package/src/icqq-login-assist.ts +6 -2
  54. package/src/icqq-login-control.ts +42 -0
  55. package/src/index.ts +4 -1
  56. package/src/protocol.ts +35 -8
  57. package/lib/icqq-runtime-state.d.ts +0 -1
  58. package/lib/icqq-runtime-state.js +0 -6
  59. package/src/icqq-runtime-state.ts +0 -7
package/src/endpoint.ts CHANGED
@@ -11,18 +11,33 @@ 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,
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,
20
34
  } from 'zhin.js/adapter';
21
35
  import type { EndpointContentPort } from '@zhin.js/adapter';
22
- import { receiveOneBotLikeSideEvent, buildSystem, toCanonicalSegments, type LoginAssist } from '@zhin.js/core';
23
- import { EndpointDeliveryError, type MessageRef } from '@zhin.js/im-contract';
36
+ import { receiveOneBotLikeSideEvent, buildSystem, toCanonicalSegments, type LoginAssist, type LoginAssistEndpointIdentity, type PendingLoginTask } from '@zhin.js/core';
37
+ import { EndpointDeliveryError, type EndpointSendPart, type EndpointSendResult, type MessageRef } from '@zhin.js/im-contract';
24
38
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
25
39
  import type { CapabilityId } from 'zhin.js';
40
+ import type { IcqqLoginControl } from './icqq-login-control.js';
26
41
  import { runIcqqLoginAssistStep } from './icqq-login-assist.js';
27
42
  import { IcqqContentResolver } from './content-resolver.js';
28
43
  import {
@@ -53,7 +68,7 @@ import {
53
68
  } from './outbound-media.js';
54
69
  import {
55
70
  formatInboundContent,
56
- formatOutboundMessages,
71
+ formatOutboundMessageParts,
57
72
  normalizeIcqqImageMedia,
58
73
  icqqInboundConversation,
59
74
  icqqOutboundTarget,
@@ -68,8 +83,12 @@ import { inspectGroupMemberProfile, inspectGroupScene } from './group-scene.js';
68
83
  export interface IcqqEndpointOptions {
69
84
  readonly id: CapabilityId;
70
85
  readonly config: ResolvedIcqqConfig;
86
+ readonly signal?: AbortSignal;
87
+ readonly endpointIdentity?: LoginAssistEndpointIdentity;
71
88
  readonly loginAssist: LoginAssist;
72
89
  readonly locale?: import('zhin.js/i18n').Locale;
90
+ /** Out-of-band outage notifier; absent in tests/hosts without outbound. */
91
+ readonly notifier?: AdapterOutageNotifier;
73
92
  }
74
93
 
75
94
  const BOUND_EVENTS = [
@@ -91,8 +110,8 @@ const BOUND_EVENTS = [
91
110
  'system.login.auth',
92
111
  ] as const;
93
112
 
94
- export class IcqqEndpoint extends Endpoint<Client> {
95
- readonly client: ManagedIcqqClient;
113
+ export class IcqqEndpoint extends ManagedIcqqClient implements Endpoint {
114
+ readonly [endpointSpec]: EndpointSpecification;
96
115
  readonly #logger: ReturnType<typeof getAdapterLogger>;
97
116
  readonly #options: IcqqEndpointOptions;
98
117
  readonly endpointName: string;
@@ -113,69 +132,79 @@ export class IcqqEndpoint extends Endpoint<Client> {
113
132
  readonly #inflightInbound = new Set<Promise<void>>();
114
133
  readonly #inboundOwner = new AsyncLocalStorage<symbol>();
115
134
  readonly #contentResolver: IcqqContentResolver;
116
- readonly content: EndpointContentPort;
135
+ readonly #content: EndpointContentPort;
136
+ readonly loginControl: IcqqLoginControl;
137
+ #reloginTimer?: ReturnType<typeof setTimeout>;
138
+ #reloginAttempts = 0;
139
+ #reloginInFlight?: Promise<void>;
140
+ #offlineNoticeAt = 0;
141
+ #challengeNoticeAt = 0;
117
142
 
118
- readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
119
- inspectGroupMember: (group, user, signal) => inspectGroupMember(this.client, group, user, signal),
120
- inspectGroupScene: (group, signal) => inspectGroupScene(this.client, group, signal),
121
- inspectGroupMemberProfile: (group, user, signal) => inspectGroupMemberProfile(this.client, group, user, signal),
122
- moderateGroupMember: (group, user, action, signal) => moderateGroupMember(
123
- this.client, group, user, action, signal, () => this.#open && !this.#retiring && !this.#stopped,
124
- ),
125
- listFriends: async () =>
126
- 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) => ({
127
150
  user_id: f.user_id,
128
151
  nickname: f.nickname,
129
152
  remark: f.remark ?? '',
130
- })),
131
- listGroups: async () =>
132
- 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) => ({
133
156
  group_id: g.group_id,
134
157
  name: g.group_name,
135
- })),
136
- listChannels: async () => {
137
- await this.#guildCatalog.syncAll(this.client);
138
- 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());
139
163
  },
140
- listGroupMembers: async (groupId) =>
141
- Array.from((await this.client.getGroupMemberList(Number(groupId))).values()),
142
- listRequests: () => this.#listPendingRequests(),
143
- approveRequest: (requestId, remark) => this.#approveRequest(requestId, remark),
144
- rejectRequest: (requestId, reason) => this.#rejectRequest(requestId, reason),
145
- kickGroupMember: async (groupId, userId) => {
146
- 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)));
147
172
  },
148
- muteGroupMember: async (groupId, userId, duration) => {
149
- 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));
150
176
  },
151
- setGroupAdmin: async (groupId, userId, enabled) => {
152
- 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));
153
180
  },
154
- deleteFriend: async (userId) => {
155
- 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)));
156
184
  },
157
- sendGroupNotice: async (groupId, content) => {
158
- 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) {
159
188
  throw new Error('QQ did not confirm the group notice');
160
189
  }
161
190
  },
162
191
  });
163
192
 
164
- readonly control: EndpointControl = Object.freeze({
165
- recall: async (message: MessageRef) => {
193
+ readonly #control: EndpointControl = Object.freeze<EndpointControl>({
194
+ recall: async ({ message, signal }) => {
166
195
  if (!message.id || message.id.startsWith('outbound:')) return;
167
- 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');
168
197
  },
169
- addReaction: async (message: MessageRef, emoji: string) => {
198
+ addReaction: async ({ message, emoji, signal }) => {
170
199
  const target = resolveIcqqGroupReactionTarget(message);
171
200
  if (!target) return null;
172
201
  try {
173
202
  // Do not await protocol ACK — packet timeout must not stall the AI reply.
174
- this.#ignoreReactionAck(
175
- 'add',
176
- this.client.pickGroup(target.groupId).setReaction(target.seq, emoji),
177
- );
203
+ await createEndpointOperation({ signal }).run(() => {
204
+ this.#ignoreReactionAck('add', this.pickGroup(target.groupId).setReaction(target.seq, emoji));
205
+ });
178
206
  } catch (error) {
207
+ if (error instanceof EndpointDeliveryError && error.code === 'endpoint_inactive') throw error;
179
208
  this.#logger.debug(formatCompact({
180
209
  op: 'reaction_add_failed',
181
210
  error: error instanceof Error ? error.message : String(error),
@@ -183,15 +212,15 @@ export class IcqqEndpoint extends Endpoint<Client> {
183
212
  }
184
213
  return emoji;
185
214
  },
186
- removeReaction: async (message: MessageRef, reactionId: string) => {
215
+ removeReaction: async ({ message, reactionId, signal }) => {
187
216
  const target = resolveIcqqGroupReactionTarget(message);
188
217
  if (!target) return;
189
218
  try {
190
- this.#ignoreReactionAck(
191
- 'remove',
192
- this.client.pickGroup(target.groupId).delReaction(target.seq, reactionId),
193
- );
219
+ await createEndpointOperation({ signal }).run(() => {
220
+ this.#ignoreReactionAck('remove', this.pickGroup(target.groupId).delReaction(target.seq, reactionId));
221
+ });
194
222
  } catch (error) {
223
+ if (error instanceof EndpointDeliveryError && error.code === 'endpoint_inactive') throw error;
195
224
  this.#logger.debug(formatCompact({
196
225
  op: 'reaction_remove_failed',
197
226
  error: error instanceof Error ? error.message : String(error),
@@ -201,15 +230,36 @@ export class IcqqEndpoint extends Endpoint<Client> {
201
230
  });
202
231
 
203
232
  constructor(options: IcqqEndpointOptions) {
204
- super();
205
- const nativeConfig = resolveNativeClientConfig(options.config);
206
- this.client = new ManagedIcqqClient(Number(options.config.id), nativeConfig,
233
+ super(Number(options.config.id), resolveNativeClientConfig(options.config),
207
234
  (operation, error) => this.#handleBackgroundFailure(operation, error));
208
- this.#contentResolver = new IcqqContentResolver(this.client);
209
- 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;
210
247
  this.#logger = getAdapterLogger('icqq', options.config.id);
211
248
  this.#options = options;
212
249
  this.endpointName = options.config.id;
250
+ this.loginControl = Object.freeze({
251
+ endpointId: this.endpointName,
252
+ state: () => ({
253
+ endpointId: this.endpointName,
254
+ transportState: this.#transportState,
255
+ online: this.isOnline(),
256
+ pending: this.#pendingLoginTasks(),
257
+ }),
258
+ pending: () => this.#pendingLoginTasks(),
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),
262
+ });
213
263
  }
214
264
 
215
265
  #handleBackgroundFailure(operation: IcqqBackgroundOperation, error: unknown): void {
@@ -221,11 +271,152 @@ export class IcqqEndpoint extends Endpoint<Client> {
221
271
  : typeof error === 'string' ? error : 'Unknown ICQQ SDK background failure',
222
272
  ...(error && typeof error === 'object' && 'code' in error && typeof error.code === 'number' ? { code: error.code } : {}),
223
273
  }));
224
- if (operation === 'sso-heartbeat' && !this.client.isOnline()) return;
274
+ if (operation === 'sso-heartbeat' && !this.isOnline()) return;
225
275
  if (operation === 'login' && !this.#everOnline) return;
226
276
  // Delegate to the SDK's existing transport termination, offline event and
227
277
  // reconn_interval policy. No account-level logout or second retry timer.
228
- this.client.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
278
+ this.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
279
+ }
280
+
281
+ /** Force a login attempt (manual recovery command). */
282
+ relogin(): Promise<void> {
283
+ return this.#startRelogin(true);
284
+ }
285
+
286
+ #startRelogin(resetAttempts: boolean): Promise<void> {
287
+ if (this.#stopped || this.#retiring) {
288
+ return Promise.reject(new Error('ICQQ endpoint 已停止'));
289
+ }
290
+ if (this.#reloginInFlight) return this.#reloginInFlight;
291
+ if (resetAttempts) this.#reloginAttempts = 0;
292
+ const task = this.#performRelogin();
293
+ this.#reloginInFlight = task;
294
+ void task.catch(() => undefined).finally(() => {
295
+ if (this.#reloginInFlight === task) this.#reloginInFlight = undefined;
296
+ if (this.#started && !this.#stopped && !this.#retiring && !this.isOnline()) {
297
+ this.#scheduleRelogin('relogin_failed');
298
+ }
299
+ });
300
+ return task;
301
+ }
302
+
303
+ async #performRelogin(): Promise<void> {
304
+ this.#clearReloginTimer();
305
+ this.cancelReconnect();
306
+ this.#logger.info(formatCompact({ op: 'icqq_relogin', endpoint: this.endpointName }));
307
+ try {
308
+ await this.login(this.#options.config.password);
309
+ } catch (error) {
310
+ this.#logger.warn(formatCompact({
311
+ op: 'icqq_relogin_failed',
312
+ endpoint: this.endpointName,
313
+ error: error instanceof Error ? error.message : String(error),
314
+ }));
315
+ throw error;
316
+ }
317
+ }
318
+
319
+ #scheduleRelogin(reason: string): void {
320
+ if (!this.#options.config.autoReconnect || this.#stopped || this.#retiring) return;
321
+ if (this.#reloginTimer || this.#reloginInFlight) return;
322
+ if (this.#reloginAttempts >= 8) return;
323
+ const attempt = this.#reloginAttempts++;
324
+ const delayMs = Math.min(300_000, 5_000 * (2 ** Math.min(attempt, 5)));
325
+ this.#logger.info(formatCompact({
326
+ op: 'icqq_relogin_scheduled',
327
+ endpoint: this.endpointName,
328
+ reason,
329
+ attempt: attempt + 1,
330
+ delay_ms: delayMs,
331
+ }));
332
+ this.#reloginTimer = setTimeout(() => {
333
+ this.#reloginTimer = undefined;
334
+ void this.#startRelogin(false).catch(() => {
335
+ // #performRelogin already scheduled the next attempt.
336
+ });
337
+ }, delayMs);
338
+ this.#reloginTimer.unref?.();
339
+ }
340
+
341
+ #clearReloginTimer(): void {
342
+ if (!this.#reloginTimer) return;
343
+ clearTimeout(this.#reloginTimer);
344
+ this.#reloginTimer = undefined;
345
+ }
346
+
347
+ #pendingLoginTasks(): readonly PendingLoginTask[] {
348
+ if (this.#options.signal?.aborted) return [];
349
+ return this.#options.loginAssist.listPending()
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));
356
+ }
357
+
358
+ #notifyOffline(reason: string, willRetry: boolean): void {
359
+ const notifier = this.#options.notifier;
360
+ if (!notifier?.configured) return;
361
+ const now = Date.now();
362
+ if (now - this.#offlineNoticeAt < 60_000) return;
363
+ this.#offlineNoticeAt = now;
364
+ const recovery = willRetry
365
+ ? '已开始自动重新登录。'
366
+ : '请手动重新登录。';
367
+ const qrCommand = `icqq login qr ${this.endpointName}`;
368
+ const confirmCommand = `icqq login confirm ${this.endpointName}`;
369
+ const codeCommand = `icqq login code ${this.endpointName} <内容>`;
370
+ const text = `[Zhin] ICQQ ${this.endpointName} 掉线(${reason})。${recovery}\n`
371
+ + `恢复:在 QQ 回复 \`${qrCommand}\` 获取二维码,扫码后回复 \`${confirmCommand}\`;`
372
+ + `滑块/短信验证请回复 \`${codeCommand}\`。也可以打开 Console 的 ICQQ 页面完成登录。`;
373
+ void notifier.send(text).then((sent) => {
374
+ if (sent) return;
375
+ this.#logger.warn(formatCompact({
376
+ op: 'icqq_notify_failed',
377
+ endpoint: this.endpointName,
378
+ reason,
379
+ }));
380
+ });
381
+ }
382
+
383
+ #notifyChallenge(step: 'qrcode' | 'slider' | 'device' | 'auth'): void {
384
+ const notifier = this.#options.notifier;
385
+ if (!notifier?.configured) return;
386
+ const now = Date.now();
387
+ if (now - this.#challengeNoticeAt < 30_000) return;
388
+ this.#challengeNoticeAt = now;
389
+ const label = step === 'qrcode' ? '二维码验证'
390
+ : step === 'slider' ? '滑块验证'
391
+ : step === 'device' ? '设备确认'
392
+ : '登录确认';
393
+ const qrCommand = `icqq login qr ${this.endpointName}`;
394
+ const confirmCommand = `icqq login confirm ${this.endpointName}`;
395
+ const codeCommand = `icqq login code ${this.endpointName} <内容>`;
396
+ const text = `[Zhin] ICQQ ${this.endpointName} 需要${label}。\n`
397
+ + (step === 'qrcode' || step === 'auth'
398
+ ? `在 QQ 回复 \`${qrCommand}\` 获取二维码,完成后回复 \`${confirmCommand}\`。`
399
+ : `在 QQ 回复 \`icqq login status ${this.endpointName}\` 查看验证链接,再用 \`${codeCommand}\` 提交验证码;也可以打开 Console 的 ICQQ 页面。`);
400
+ void notifier.send(text).then((sent) => {
401
+ if (sent) return;
402
+ this.#logger.warn(formatCompact({
403
+ op: 'icqq_notify_failed',
404
+ endpoint: this.endpointName,
405
+ reason: `login_${step}`,
406
+ }));
407
+ });
408
+ }
409
+
410
+ #notifyRecovered(): void {
411
+ const notifier = this.#options.notifier;
412
+ const hadOfflineNotice = this.#offlineNoticeAt > 0;
413
+ this.#offlineNoticeAt = 0;
414
+ this.#challengeNoticeAt = 0;
415
+ if (!notifier?.configured || !hadOfflineNotice) return;
416
+ void notifier.send(`[Zhin] ICQQ ${this.endpointName} 已重新上线。`).then((sent) => {
417
+ if (sent) return;
418
+ this.#logger.warn(formatCompact({ op: 'icqq_notify_failed', endpoint: this.endpointName, reason: 'recovered' }));
419
+ });
229
420
  }
230
421
 
231
422
  #ignoreReactionAck(action: 'add' | 'remove', task: Promise<unknown> | unknown): void {
@@ -237,15 +428,15 @@ export class IcqqEndpoint extends Endpoint<Client> {
237
428
  });
238
429
  }
239
430
 
240
- get transportState(): EndpointTransportState {
431
+ get #transportState(): EndpointTransportState {
241
432
  if (this.#stopped || this.#retiring) return 'stopped';
242
433
  if (!this.#started) return 'idle';
243
- if (this.client.isOnline()) return 'open';
434
+ if (this.isOnline()) return 'open';
244
435
  if (!this.#everOnline) return 'connecting';
245
436
  return this.#options.config.autoReconnect ? 'reconnecting' : 'closed';
246
437
  }
247
438
 
248
- async start(signal: AbortSignal): Promise<void> {
439
+ async #startFramework(signal: AbortSignal): Promise<void> {
249
440
  if (this.#started) return;
250
441
  signal.throwIfAborted();
251
442
  this.#stopped = false;
@@ -254,7 +445,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
254
445
  this.#retirementEpoch += 1;
255
446
  this.#retirementArmed = false;
256
447
  this.#retiring = false;
257
- this.client.off('system.online');
448
+ this.off('system.online');
258
449
  this.#started = true;
259
450
  let resolveOnline!: () => void;
260
451
  const onlineReady = new Promise<void>((resolve) => {
@@ -265,16 +456,17 @@ export class IcqqEndpoint extends Endpoint<Client> {
265
456
  signal.addEventListener('abort', onAbort, { once: true });
266
457
  try {
267
458
  this.#bindClientEvents();
268
- await raceAbort(Promise.resolve(this.client.login(this.#options.config.password)), signal);
459
+ await raceAbort(Promise.resolve(this.login(this.#options.config.password)), signal);
269
460
  await onlineReady;
270
461
  signal.throwIfAborted();
271
462
  await this.#pullPendingSystemMessages(signal);
272
463
  signal.throwIfAborted();
273
464
  this.#logger.info(
274
- `connected (direct) | friends: ${this.client.fl.size} | groups: ${this.client.gl.size}`,
465
+ `connected (direct) | friends: ${this.fl.size} | groups: ${this.gl.size}`,
275
466
  );
276
467
  } catch (error) {
277
- await this.stop();
468
+ this.#notifyOffline('start_failed', false);
469
+ await this.#stopFramework();
278
470
  this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
279
471
  throw error;
280
472
  } finally {
@@ -283,11 +475,11 @@ export class IcqqEndpoint extends Endpoint<Client> {
283
475
  }
284
476
  }
285
477
 
286
- open(): void {
478
+ #openFramework(): void {
287
479
  if (this.#retirementArmed) {
288
480
  this.#retirementEpoch += 1;
289
481
  this.#retirementArmed = false;
290
- this.client.off('system.online');
482
+ this.off('system.online');
291
483
  this.#bindSystemOnlineEvent();
292
484
  }
293
485
  this.#retiring = false;
@@ -300,15 +492,16 @@ export class IcqqEndpoint extends Endpoint<Client> {
300
492
  }
301
493
  }
302
494
 
303
- async close(): Promise<void> {
495
+ async #closeFramework(): Promise<void> {
304
496
  this.#assertExternalLifecycle('close');
305
497
  this.#armRetirementFence();
306
498
  await Promise.allSettled([...this.#inflightInbound]);
307
499
  }
308
500
 
309
- async stop(): Promise<void> {
310
- this.#stopped = true;
501
+ async #stopFramework(): Promise<void> {
311
502
  this.#assertExternalLifecycle('stop');
503
+ this.#stopped = true;
504
+ this.#clearReloginTimer();
312
505
  this.#armRetirementFence();
313
506
  await Promise.allSettled([...this.#inflightInbound]);
314
507
  this.#resolveStartOnline?.();
@@ -319,7 +512,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
319
512
  for (const event of BOUND_EVENTS) {
320
513
  // The retirement guard owns this matcher until a late login handshake
321
514
  // has finished. start()/open() replace it with the normal listener.
322
- if (event !== 'system.online') this.client.off(event);
515
+ if (event !== 'system.online') this.off(event);
323
516
  }
324
517
  // `Client.logout()` sends an account-level unregister packet before it
325
518
  // closes this transport. During config HMR the replacement endpoint for
@@ -327,7 +520,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
327
520
  // also closes the replacement session. Endpoint ownership is transport
328
521
  // scoped: retire only this Client's TCP connection.
329
522
  try {
330
- this.client.terminate();
523
+ this.terminate();
331
524
  } catch { /* ignore */ }
332
525
  this.#inboundDeduper.clear();
333
526
  this.#sideEventDeduper.clear();
@@ -344,36 +537,47 @@ export class IcqqEndpoint extends Endpoint<Client> {
344
537
  const retirementEpoch = ++this.#retirementEpoch;
345
538
  // ICQQ owns reconnect through this per-Client timer. Clear it before any
346
539
  // asynchronous drain so a queued callback cannot enter while close waits.
347
- this.client.cancelReconnect();
540
+ this.cancelReconnect();
348
541
  // A login callback may already be inside the SDK handshake, which has no
349
542
  // abort API. Replace the normal online listener with an epoch-owned guard
350
543
  // before draining inbound work. Transport termination is account-local;
351
544
  // logout would also unregister an HMR replacement using the same UIN.
352
- this.client.off('system.online');
353
- this.client.on('system.online', () => {
545
+ this.off('system.online');
546
+ this.on('system.online', () => {
354
547
  if (this.#retirementEpoch !== retirementEpoch || !this.#retiring) return;
355
548
  try {
356
- this.client.terminate();
549
+ this.terminate();
357
550
  } catch { /* already closed */ }
358
551
  });
359
552
  }
360
553
 
361
- async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
554
+ async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<EndpointSendResult> {
362
555
  if (!this.#started) throw new EndpointDeliveryError('endpoint_disconnected', 'icqq endpoint 未连接', 'not_sent');
556
+ const operation = createEndpointOperation({ signal });
363
557
  const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
364
- const prepared = isIcqqSegmentPayload(payload)
558
+ const prepared = await operation.prepare(() => isIcqqSegmentPayload(payload)
365
559
  ? prepareIcqqOutboundMedia(normalizeIcqqImageMedia(payload), mediaMode)
366
- : { 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
+ };
367
571
  try {
368
572
  // Standalone elements (share / file / …) are split into ordered messages.
369
- const messages = formatOutboundMessages(prepared.content);
573
+ const messages = formatOutboundMessageParts(prepared.content);
370
574
  const parsed = icqqOutboundTarget(conversation);
371
- let messageId: string | undefined;
372
- for (const [index, message] of messages.entries()) {
575
+ const parts: EndpointSendPart[] = [];
576
+ for (const [index, { message, sourceIndices }] of messages.entries()) {
373
577
  const preview = formatIcqqSendablePreview(message);
374
578
  let result: { message_id?: unknown };
375
579
  try {
376
- result = await this.#sendNative(parsed, message);
580
+ result = await operation.run(() => sendNative(parsed, message));
377
581
  } catch (error) {
378
582
  this.#logger.warn(formatCompact({
379
583
  op: 'icqq_send_failed',
@@ -383,48 +587,32 @@ export class IcqqEndpoint extends Endpoint<Client> {
383
587
  preview: truncatePreview(preview, 120),
384
588
  }));
385
589
  if (index === 0) throw error;
386
- // Earlier parts are already delivered: a retry would duplicate them.
387
- throw new EndpointDeliveryError('partial_delivery', `ICQQ delivered ${index} of ${messages.length} message parts`, 'unknown', { cause: error });
590
+ // Earlier sends completed; retrying can duplicate accepted messages, even without ids.
591
+ throw new EndpointDeliveryError('partial_delivery', `ICQQ completed ${index} of ${messages.length} message sends`, 'unknown', { cause: error, parts });
388
592
  }
389
593
  const rawId = result?.message_id;
390
594
  const id = (typeof rawId === 'string' || (typeof rawId === 'number' && Number.isFinite(rawId))) && String(rawId).trim() !== ''
391
595
  ? String(rawId) : undefined;
392
- messageId ??= id;
596
+ parts.push({ ...(id ? { messageId: id } : {}), ...(sourceIndices ? { sourceIndices } : {}) });
393
597
  this.#logger.info(
394
598
  `send ${conversation.kind}:${conversation.id} | id: ${id ?? '-'} | ${truncatePreview(preview, 80)}`,
395
599
  );
396
600
  }
397
- // Native share / file APIs return no id; the reply is confirmed by any part that has one.
398
- if (!messageId) {
399
- throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: ICQQ returned no message_id', 'unknown');
601
+ // A resolved share/file call has no message identity; retain that unknown slot in the batch.
602
+ if (!parts.some(part => part.messageId)) {
603
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: ICQQ returned no message_id', 'unknown', { parts });
400
604
  }
401
- return messageId;
605
+ return { parts };
402
606
  } finally {
403
- prepared.dispose();
404
- }
405
- }
406
-
407
- /**
408
- * ICQQ's share API returns no message id; a resolved call only means the server accepted it.
409
- * The card is the bot's own newest card message in the conversation history, so read the id
410
- * from there (once more after a short wait, history can lag). Undefined keeps the delivery
411
- * unconfirmed rather than inventing an id.
412
- */
413
- async #findOwnCard(
414
- history: () => Promise<readonly { message_id?: unknown; time?: number; message?: readonly { type?: string }[]; sender?: { user_id?: number }; user_id?: number }[]>,
415
- sentAt: number,
416
- ): Promise<string | undefined> {
417
- const self = Number(this.client.uin);
418
- for (const wait of [0, 500]) {
419
- if (wait) await new Promise(resolve => setTimeout(resolve, wait));
420
- // A missing or throwing history API must not turn an accepted share into a send failure.
421
- const messages = await Promise.resolve().then(history).catch(() => []);
422
- const card = [...messages].reverse().find(item => Number(item.sender?.user_id ?? item.user_id) === self
423
- && (item.time ?? 0) >= sentAt - 2
424
- && (item.message ?? []).some(element => element.type === 'json' || element.type === 'xml' || element.type === 'share'));
425
- if (card && card.message_id != null && String(card.message_id).trim()) return String(card.message_id);
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();
426
615
  }
427
- return undefined;
428
616
  }
429
617
 
430
618
  async #sendNative(
@@ -434,22 +622,20 @@ export class IcqqEndpoint extends Endpoint<Client> {
434
622
  if (isIcqqShareElement(message)) {
435
623
  switch (target.kind) {
436
624
  case 'private': {
437
- const sentAt = Math.floor(Date.now() / 1000);
438
- const friend = this.client.pickFriend(target.userId);
625
+ const friend = this.pickFriend(target.userId);
439
626
  await friend.share(message);
440
- return { message_id: await this.#findOwnCard(() => friend.getChatHistory(), sentAt) };
627
+ return {};
441
628
  }
442
629
  case 'group': {
443
- const sentAt = Math.floor(Date.now() / 1000);
444
- const group = this.client.pickGroup(target.groupId);
630
+ const group = this.pickGroup(target.groupId);
445
631
  await group.share(message);
446
- return { message_id: await this.#findOwnCard(() => group.getChatHistory(), sentAt) };
632
+ return {};
447
633
  }
448
634
  case 'temp':
449
- await this.client.pickMember(target.groupId, target.userId).share(message);
635
+ await this.pickMember(target.groupId, target.userId).share(message);
450
636
  return {};
451
637
  case 'channel': {
452
- const channel = this.client.pickGuild(target.guildId).channels.get(target.channelId);
638
+ const channel = this.pickGuild(target.guildId).channels.get(target.channelId);
453
639
  if (!channel) {
454
640
  throw new TypeError(`ICQQ guild channel is unavailable: ${target.guildId}/${target.channelId}`);
455
641
  }
@@ -461,11 +647,11 @@ export class IcqqEndpoint extends Endpoint<Client> {
461
647
  if (isIcqqFileElement(message)) {
462
648
  switch (target.kind) {
463
649
  case 'private': {
464
- await this.client.pickFriend(target.userId).sendFile(message.file, message.name);
650
+ await this.pickFriend(target.userId).sendFile(message.file, message.name);
465
651
  return {};
466
652
  }
467
653
  case 'group': {
468
- await this.client.pickGroup(target.groupId).sendFile(message.file, '/', message.name);
654
+ await this.pickGroup(target.groupId).sendFile(message.file, '/', message.name);
469
655
  return {};
470
656
  }
471
657
  case 'temp':
@@ -475,13 +661,13 @@ export class IcqqEndpoint extends Endpoint<Client> {
475
661
  }
476
662
  switch (target.kind) {
477
663
  case 'private':
478
- return this.client.sendPrivateMsg(target.userId, message);
664
+ return this.sendPrivateMsg(target.userId, message);
479
665
  case 'group':
480
- return this.client.sendGroupMsg(target.groupId, message);
666
+ return this.sendGroupMsg(target.groupId, message);
481
667
  case 'temp':
482
- return this.client.sendTempMsg(target.groupId, target.userId, message);
668
+ return this.sendTempMsg(target.groupId, target.userId, message);
483
669
  case 'channel':
484
- 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 };
485
671
  }
486
672
  }
487
673
 
@@ -496,7 +682,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
496
682
  this.#inflightInbound.add(settlement);
497
683
  let operation: Promise<void>;
498
684
  try {
499
- operation = this.#inboundOwner.run(owner, run);
685
+ operation = this.#inboundOwner.run(owner, () => this[endpointSpec].events.run(run));
500
686
  } catch (error) {
501
687
  this.#inflightInbound.delete(settlement);
502
688
  settle();
@@ -550,7 +736,9 @@ export class IcqqEndpoint extends Endpoint<Client> {
550
736
  + (mentioned ? ' (mentioned)' : '')
551
737
  + ` | ${truncatePreview(msg.content, 80)}`,
552
738
  );
553
- 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,
554
742
  conversation,
555
743
  message: { conversation, id: msg.id },
556
744
  content: msg.content,
@@ -577,79 +765,81 @@ export class IcqqEndpoint extends Endpoint<Client> {
577
765
  #bindClientEvents(): void {
578
766
  this.#bindNativeEvents();
579
767
  const safe = (label: string, fn: () => void): void => {
580
- try {
581
- fn();
582
- } catch (error) {
583
- this.#logger.warn(formatCompact({
584
- op: 'icqq_event_handler',
585
- endpoint: this.endpointName,
586
- event: label,
587
- error: error instanceof Error ? error.message : String(error),
588
- }));
589
- }
768
+ void this[endpointSpec].events.run(fn).catch(() => {
769
+ this.#logger.debug(formatCompact({ op: 'icqq_event_not_admitted', endpoint: this.endpointName, event: label }));
770
+ });
590
771
  };
591
- this.client.on('message.private', (event) => {
772
+ this.on('message.private', (event) => {
592
773
  this.#startMessageEvent('message.private', event);
593
774
  });
594
- this.client.on('message.group', (event) => {
775
+ this.on('message.group', (event) => {
595
776
  this.#startMessageEvent('message.group', event);
596
777
  });
597
- this.client.on('message.guild', (event) => {
598
- void this.#handleGuildEvent(event).catch((error) => {
778
+ this.on('message.guild', (event) => {
779
+ void this.#trackInbound(() => this.#handleGuildEvent(event)).catch((error) => {
599
780
  this.#logInboundHandlerFailure('message.guild', error);
600
781
  });
601
782
  });
602
- this.client.on('request.friend', (event) => safe('request.friend', () => {
783
+ this.on('request.friend', (event) => safe('request.friend', () => {
603
784
  this.#onRequestEvent(serializeIcqqEvent(event));
604
785
  }));
605
- this.client.on('request.group', (event) => safe('request.group', () => {
786
+ this.on('request.group', (event) => safe('request.group', () => {
606
787
  this.#onRequestEvent(serializeIcqqEvent(event));
607
788
  }));
608
- this.client.on('notice.friend', (event) => safe('notice.friend', () => {
789
+ this.on('notice.friend', (event) => safe('notice.friend', () => {
609
790
  this.#onNoticeEvent(serializeIcqqEvent(event));
610
791
  }));
611
- this.client.on('notice.group', (event) => safe('notice.group', () => {
792
+ this.on('notice.group', (event) => safe('notice.group', () => {
612
793
  this.#onNoticeEvent(serializeIcqqEvent(event));
613
794
  }));
614
795
  this.#bindSystemOnlineEvent();
615
- this.client.on('system.offline', (event) => {
796
+ this.on('system.offline', (event) => {
616
797
  this.#logSystemEvent('offline', event);
798
+ this.#notifyOffline('offline', this.#options.config.autoReconnect);
617
799
  });
618
- this.client.on('system.offline.network', (event) => {
800
+ this.on('system.offline.network', (event) => {
619
801
  this.#logSystemEvent('offline.network', event);
802
+ this.#notifyOffline('network', this.#options.config.autoReconnect);
620
803
  });
621
- this.client.on('system.offline.kickoff', (event) => {
804
+ this.on('system.offline.kickoff', (event) => {
622
805
  this.#logSystemEvent('offline.kickoff', event);
806
+ this.#notifyOffline('kickoff', this.#options.config.autoReconnect);
807
+ this.#scheduleRelogin('kickoff');
623
808
  });
624
- this.client.on('system.login.qrcode', (event) => {
809
+ this.on('system.login.qrcode', (event) => {
625
810
  this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
626
811
  this.#handleLoginChallenge('qrcode', event);
627
812
  });
628
- this.client.on('system.login.device', (event) => {
813
+ this.on('system.login.device', (event) => {
629
814
  this.#handleLoginChallenge('device', event);
630
815
  });
631
- this.client.on('system.login.slider', (event) => {
816
+ this.on('system.login.slider', (event) => {
632
817
  this.#handleLoginChallenge('slider', event);
633
818
  });
634
- this.client.on('system.login.error', (event) => {
819
+ this.on('system.login.error', (event) => {
635
820
  this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
636
821
  this.#logSystemEvent('login.error', event);
822
+ this.#notifyOffline('login_error', this.#options.config.autoReconnect);
823
+ this.#scheduleRelogin('login_error');
637
824
  });
638
- this.client.on('system.login.auth', (event) => {
825
+ this.on('system.login.auth', (event) => {
639
826
  this.#handleLoginChallenge('auth', event);
640
827
  });
641
828
  }
642
829
 
643
830
  #bindSystemOnlineEvent(): void {
644
- this.client.on('system.online', (event) => {
831
+ this.on('system.online', (event) => {
645
832
  if (this.#retiring) return;
646
833
  this.#everOnline = true;
834
+ this.#clearReloginTimer();
835
+ this.#reloginAttempts = 0;
836
+ this.#notifyRecovered();
647
837
  try {
648
838
  this.#resolveStartOnline?.();
649
839
  this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
650
840
  try {
651
841
  // transport 重连不清除(账号不变);stop/retire 由基类撤销登记。上报失败不影响上线流程。
652
- this.reportSelfUserId(resolveIcqqSelfUserId(this.client));
842
+ this[endpointSpec].reportSelfUserId(resolveIcqqSelfUserId(this));
653
843
  } catch (error) {
654
844
  this.#logger.warn(formatCompact({
655
845
  op: 'icqq_report_self_user_id_failed',
@@ -673,7 +863,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
673
863
  #bindNativeEvents(): void {
674
864
  this.#disposeNativeEvents?.();
675
865
  let matchedName: string | undefined;
676
- const registration = this.client.on(
866
+ const registration = this.on(
677
867
  (eventName: unknown) => {
678
868
  matchedName = typeof eventName === 'string' && !eventName.startsWith('internal.')
679
869
  ? eventName
@@ -689,7 +879,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
689
879
  }
690
880
 
691
881
  #emitNativeEvent(name: string, event: unknown): void {
692
- void this.emitPlatform(name, event).catch((error) => {
882
+ void this[endpointSpec].events.platform(name, event).catch((error) => {
693
883
  this.#logger.warn(formatCompact({
694
884
  op: 'icqq_platform_event_failed',
695
885
  endpoint: this.endpointName,
@@ -703,17 +893,21 @@ export class IcqqEndpoint extends Endpoint<Client> {
703
893
  step: 'qrcode' | 'slider' | 'device' | 'auth',
704
894
  event: unknown,
705
895
  ): void {
896
+ if (this.#retiring || this.#stopped || this.#options.signal?.aborted) return;
706
897
  const payload = serializeIcqqEvent(event) ?? (event && typeof event === 'object'
707
898
  ? event as Record<string, unknown>
708
899
  : null);
709
900
  this.#logSystemEvent(`login.${step}`, event);
901
+ this.#clearReloginTimer();
902
+ this.#notifyChallenge(step);
710
903
  void runIcqqLoginAssistStep({
711
904
  assist: this.#options.loginAssist,
712
905
  locale: this.#options.locale,
713
- client: this.client,
906
+ client: this,
714
907
  adapter: 'icqq',
715
908
  endpointKey: this.endpointName,
716
909
  owner: this.#loginAssistOwner,
910
+ signal: this.#options.signal, endpointIdentity: this.#options.endpointIdentity,
717
911
  logger: this.#logger,
718
912
  step,
719
913
  event: payload ?? event,
@@ -734,14 +928,14 @@ export class IcqqEndpoint extends Endpoint<Client> {
734
928
  const postType = String(payload.post_type ?? '');
735
929
  const isRequest = postType === 'request' || postType.startsWith('request.');
736
930
  const flag = payload.flag != null ? String(payload.flag) : '';
737
- void receiveOneBotLikeSideEvent((name, payload) => this.emit(name, payload), {
931
+ void receiveOneBotLikeSideEvent((name, payload) => this[endpointSpec].events.emit(name, payload), {
738
932
  adapter: 'icqq',
739
933
  endpointKey: this.endpointName,
740
934
  platform: 'icqq',
741
935
  raw: payload,
742
936
  ...(isRequest ? {
743
- approve: (id, remark) => this.#approveRequest(flag || id, remark),
744
- 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 }),
745
939
  } : {}),
746
940
  }).catch((err) => {
747
941
  this.#logger.warn(formatCompact({
@@ -754,7 +948,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
754
948
  }
755
949
 
756
950
  #dispatchIcqqSystemSideEvent(type: string, payload: Record<string, unknown> | null): void {
757
- void this.emit('system.receive', buildSystem(payload ?? {}, {
951
+ void this[endpointSpec].events.emit('system.receive', buildSystem(payload ?? {}, {
758
952
  id: `system:${this.endpointName}:${type}:${Date.now()}`,
759
953
  clientAdapter: 'icqq',
760
954
  endpointId: this.endpointName,
@@ -847,6 +1041,8 @@ export class IcqqEndpoint extends Endpoint<Client> {
847
1041
  }));
848
1042
  }
849
1043
  await this.#deliverInbound({
1044
+ event,
1045
+ platformSentAt: normalized.timestampMs,
850
1046
  id: normalized.messageId,
851
1047
  conversation,
852
1048
  content: formatInboundContent(normalized.rawMessage),
@@ -878,13 +1074,13 @@ export class IcqqEndpoint extends Endpoint<Client> {
878
1074
  if (!Number.isFinite(userId) || userId <= 0) return null;
879
1075
  if (data.message_type === 'group' || data.group_id != null) {
880
1076
  const groupId = Number(data.group_id);
881
- return Number.isFinite(groupId) && groupId > 0 ? this.client.pickGroup(groupId) : null;
1077
+ return Number.isFinite(groupId) && groupId > 0 ? this.pickGroup(groupId) : null;
882
1078
  }
883
1079
  const sourceGroupId = Number(data.sender?.group_id);
884
1080
  if (Number.isFinite(sourceGroupId) && sourceGroupId > 0) {
885
- return this.client.pickMember(sourceGroupId, userId);
1081
+ return this.pickMember(sourceGroupId, userId);
886
1082
  }
887
- return this.client.pickFriend(userId);
1083
+ return this.pickFriend(userId);
888
1084
  }
889
1085
 
890
1086
  #logInboundHandlerFailure(event: string, error: unknown): void {
@@ -921,6 +1117,9 @@ export class IcqqEndpoint extends Endpoint<Client> {
921
1117
  return;
922
1118
  }
923
1119
  await this.admit({
1120
+ event,
1121
+ platformSentAt: typeof normalized.raw.time === 'number' && Number.isFinite(normalized.raw.time)
1122
+ ? normalized.timestampMs : undefined,
924
1123
  id: normalized.messageId,
925
1124
  conversation: icqqInboundConversation(String(this.#options.id), {
926
1125
  channelType: 'channel',
@@ -952,7 +1151,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
952
1151
  }
953
1152
 
954
1153
  async #listPendingRequests(): Promise<readonly EndpointPendingRequest[]> {
955
- const messages = await this.client.getSystemMsg();
1154
+ const messages = await this.getSystemMsg();
956
1155
  const rows: EndpointPendingRequest[] = [];
957
1156
  for (const raw of messages) {
958
1157
  const msg = raw as unknown as IcqqSystemMessage & { request_type?: string };
@@ -980,7 +1179,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
980
1179
 
981
1180
  async #pullPendingSystemMessages(signal: AbortSignal): Promise<void> {
982
1181
  try {
983
- const messages = await raceAbort(Promise.resolve(this.client.getSystemMsg()), signal);
1182
+ const messages = await raceAbort(Promise.resolve(this.getSystemMsg()), signal);
984
1183
  for (const raw of messages) {
985
1184
  signal.throwIfAborted();
986
1185
  const msg = raw as unknown as IcqqSystemMessage & { request_type?: string };
@@ -1008,8 +1207,9 @@ export class IcqqEndpoint extends Endpoint<Client> {
1008
1207
  }
1009
1208
  }
1010
1209
 
1011
- async #approveRequest(id: string, remark?: string): Promise<void> {
1012
- 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());
1013
1213
  const target = messages.find((m) => {
1014
1214
  const msg = m as unknown as IcqqSystemMessage;
1015
1215
  return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
@@ -1017,15 +1217,17 @@ export class IcqqEndpoint extends Endpoint<Client> {
1017
1217
  if (!target?.flag) {
1018
1218
  throw new Error(`icqq 未找到待处理请求: ${id}`);
1019
1219
  }
1220
+ const flag = target.flag;
1020
1221
  if ((target as { request_type?: string }).request_type === 'friend') {
1021
- await this.client.setFriendAddRequest(target.flag, true, remark);
1222
+ await operation.run(() => this.setFriendAddRequest(flag, true, remark));
1022
1223
  } else {
1023
- await this.client.setGroupAddRequest(target.flag, true);
1224
+ await operation.run(() => this.setGroupAddRequest(flag, true));
1024
1225
  }
1025
1226
  }
1026
1227
 
1027
- async #rejectRequest(id: string, reason?: string): Promise<void> {
1028
- 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());
1029
1231
  const target = messages.find((m) => {
1030
1232
  const msg = m as unknown as IcqqSystemMessage;
1031
1233
  return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
@@ -1033,10 +1235,11 @@ export class IcqqEndpoint extends Endpoint<Client> {
1033
1235
  if (!target?.flag) {
1034
1236
  throw new Error(`icqq 未找到待处理请求: ${id}`);
1035
1237
  }
1238
+ const flag = target.flag;
1036
1239
  if ((target as { request_type?: string }).request_type === 'friend') {
1037
- await this.client.setFriendAddRequest(target.flag, false);
1240
+ await operation.run(() => this.setFriendAddRequest(flag, false));
1038
1241
  } else {
1039
- await this.client.setGroupAddRequest(target.flag, false, reason);
1242
+ await operation.run(() => this.setGroupAddRequest(flag, false, reason));
1040
1243
  }
1041
1244
  }
1042
1245
  }