@zhin.js/adapter-icqq 1.1.17 → 1.1.18

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 (39) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +44 -3
  3. package/adapters/icqq/index.js +10 -2
  4. package/adapters/icqq/index.ts +10 -2
  5. package/commands/icqq/login/code/index.js +28 -0
  6. package/commands/icqq/login/code/index.ts +30 -0
  7. package/commands/icqq/login/confirm/index.js +20 -0
  8. package/commands/icqq/login/confirm/index.ts +21 -0
  9. package/commands/icqq/login/definition.js +65 -0
  10. package/commands/icqq/login/definition.ts +72 -0
  11. package/commands/icqq/login/qr/index.js +53 -0
  12. package/commands/icqq/login/qr/index.ts +55 -0
  13. package/commands/icqq/login/retry/index.js +21 -0
  14. package/commands/icqq/login/retry/index.ts +23 -0
  15. package/commands/icqq/login/status/index.js +31 -0
  16. package/commands/icqq/login/status/index.ts +34 -0
  17. package/lib/console-spec.d.ts +17 -0
  18. package/lib/console-spec.js +11 -0
  19. package/lib/console.d.ts +8 -0
  20. package/lib/console.js +16 -0
  21. package/lib/endpoint.d.ts +9 -2
  22. package/lib/endpoint.js +194 -37
  23. package/lib/icqq-login-control.d.ts +18 -0
  24. package/lib/icqq-login-control.js +8 -0
  25. package/lib/index.d.ts +1 -1
  26. package/lib/index.js +1 -1
  27. package/lib/protocol.d.ts +10 -0
  28. package/lib/protocol.js +24 -9
  29. package/package.json +17 -9
  30. package/pages/index/index.js +15 -0
  31. package/pages/index/index.ts +16 -0
  32. package/plugin.js +6 -1
  33. package/schema.json +174 -0
  34. package/src/console-spec.ts +17 -0
  35. package/src/console.ts +21 -0
  36. package/src/endpoint.ts +193 -42
  37. package/src/icqq-login-control.ts +30 -0
  38. package/src/index.ts +2 -0
  39. package/src/protocol.ts +32 -8
@@ -0,0 +1,15 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { definePage } from '@zhin.js/console-contract';
3
+ import { createAdapterConsolePage } from '@zhin.js/client';
4
+ export const meta = definePage({
5
+ title: 'ICQQ',
6
+ titleI18n: { en: 'ICQQ' },
7
+ icon: 'MessageCircle',
8
+ order: 64,
9
+ requiredPermissions: ['icqq:read'],
10
+ });
11
+ /**
12
+ * Convention page: `pages/index/index.ts` → `/icqq`(插件路径)。
13
+ * 连接状态、连接测试、endpoint 增删;登录待办来自 LoginAssist(ICQQ 等)。
14
+ */
15
+ export default createAdapterConsolePage('icqq');
@@ -0,0 +1,16 @@
1
+ import { definePage } from '@zhin.js/console-contract';
2
+ import { createAdapterConsolePage } from '@zhin.js/client';
3
+
4
+ export const meta = definePage({
5
+ title: 'ICQQ',
6
+ titleI18n: { en: 'ICQQ' },
7
+ icon: 'MessageCircle',
8
+ order: 64,
9
+ requiredPermissions: ['icqq:read'],
10
+ });
11
+
12
+ /**
13
+ * Convention page: `pages/index/index.ts` → `/icqq`(插件路径)。
14
+ * 连接状态、连接测试、endpoint 增删;登录待办来自 LoginAssist(ICQQ 等)。
15
+ */
16
+ export default createAdapterConsolePage('icqq');
package/plugin.js CHANGED
@@ -1,8 +1,10 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
2
  import { createEndpointRuntimeState } from 'zhin.js/adapter';
3
- import { definePlugin } from 'zhin.js';
3
+ import { definePlugin, pluginConsoleRpcToken } from 'zhin.js';
4
4
  import { permissionHostToken, createSceneRolePlatformChecker } from '@zhin.js/permission';
5
5
  import { icqqRuntimeStateToken } from "./lib/icqq-runtime-state.js";
6
+ import { icqqLoginControlToken } from "./lib/icqq-login-control.js";
7
+ import { createIcqqConsoleRpc } from "./lib/console.js";
6
8
  export default definePlugin({
7
9
  name: 'icqq',
8
10
  metadata: {
@@ -11,6 +13,9 @@ export default definePlugin({
11
13
  setup(context) {
12
14
  // 运行中 endpoint 注册表(icqq endpoint list 的"运行中"数据源)
13
15
  context.resources.provide(icqqRuntimeStateToken, createEndpointRuntimeState());
16
+ // 掉线恢复命令用:当前 generation 每个 UIN 的登录控制面
17
+ context.resources.provide(icqqLoginControlToken, new Map());
18
+ context.resources.provide(pluginConsoleRpcToken, createIcqqConsoleRpc(context.resources, () => context.config.get()));
14
19
  const disposePlatform = context.resources.has(permissionHostToken)
15
20
  ? context.resources.use(permissionHostToken).registerPlatform('icqq', createSceneRolePlatformChecker())
16
21
  : undefined;
package/schema.json CHANGED
@@ -346,6 +346,93 @@
346
346
  "description": "Command prefix for this endpoint."
347
347
  }
348
348
  }
349
+ },
350
+ "notify": {
351
+ "type": "object",
352
+ "additionalProperties": false,
353
+ "description": "掉线/登录失败时通过另一个适配器实例给 master 发通知(adapter/endpoint/kind/id 指向实际会话)",
354
+ "x-i18n": {
355
+ "en": {
356
+ "description": "Send outage/login notices to the master through another adapter instance (adapter/endpoint/kind/id identify the actual conversation)."
357
+ }
358
+ },
359
+ "properties": {
360
+ "adapter": {
361
+ "type": "string",
362
+ "description": "通知使用的适配器类型,如 qq / icqq",
363
+ "x-i18n": {
364
+ "en": {
365
+ "description": "Adapter type used for the notice, such as qq or icqq."
366
+ }
367
+ }
368
+ },
369
+ "endpoint": {
370
+ "type": "string",
371
+ "description": "该适配器的 endpoint id",
372
+ "x-i18n": {
373
+ "en": {
374
+ "description": "Endpoint id of that adapter."
375
+ }
376
+ }
377
+ },
378
+ "kind": {
379
+ "type": "string",
380
+ "enum": [
381
+ "private",
382
+ "group",
383
+ "channel"
384
+ ],
385
+ "default": "private",
386
+ "description": "会话类型,默认 private",
387
+ "x-i18n": {
388
+ "en": {
389
+ "description": "Conversation kind; defaults to private."
390
+ }
391
+ }
392
+ },
393
+ "id": {
394
+ "type": [
395
+ "string",
396
+ "number"
397
+ ],
398
+ "description": "master 在该平台上的会话 id(QQ 官方为 openid,ICQQ 为 QQ 号)",
399
+ "x-i18n": {
400
+ "en": {
401
+ "description": "Master conversation id on that platform (openid for QQ official, QQ number for ICQQ)."
402
+ }
403
+ }
404
+ },
405
+ "parent": {
406
+ "type": "object",
407
+ "additionalProperties": false,
408
+ "properties": {
409
+ "kind": {
410
+ "type": "string",
411
+ "enum": [
412
+ "private",
413
+ "group",
414
+ "channel"
415
+ ]
416
+ },
417
+ "id": {
418
+ "type": [
419
+ "string",
420
+ "number"
421
+ ]
422
+ }
423
+ },
424
+ "required": [
425
+ "kind",
426
+ "id"
427
+ ],
428
+ "description": "可选的父频道/群容器"
429
+ }
430
+ },
431
+ "required": [
432
+ "adapter",
433
+ "endpoint",
434
+ "id"
435
+ ]
349
436
  }
350
437
  },
351
438
  "required": [
@@ -367,6 +454,93 @@
367
454
  "description": "Command prefix, empty by default. Each endpoints[i] can override it."
368
455
  }
369
456
  }
457
+ },
458
+ "notify": {
459
+ "type": "object",
460
+ "additionalProperties": false,
461
+ "description": "掉线/登录失败时通过另一个适配器实例给 master 发通知(adapter/endpoint/kind/id 指向实际会话)",
462
+ "x-i18n": {
463
+ "en": {
464
+ "description": "Send outage/login notices to the master through another adapter instance (adapter/endpoint/kind/id identify the actual conversation)."
465
+ }
466
+ },
467
+ "properties": {
468
+ "adapter": {
469
+ "type": "string",
470
+ "description": "通知使用的适配器类型,如 qq / icqq",
471
+ "x-i18n": {
472
+ "en": {
473
+ "description": "Adapter type used for the notice, such as qq or icqq."
474
+ }
475
+ }
476
+ },
477
+ "endpoint": {
478
+ "type": "string",
479
+ "description": "该适配器的 endpoint id",
480
+ "x-i18n": {
481
+ "en": {
482
+ "description": "Endpoint id of that adapter."
483
+ }
484
+ }
485
+ },
486
+ "kind": {
487
+ "type": "string",
488
+ "enum": [
489
+ "private",
490
+ "group",
491
+ "channel"
492
+ ],
493
+ "default": "private",
494
+ "description": "会话类型,默认 private",
495
+ "x-i18n": {
496
+ "en": {
497
+ "description": "Conversation kind; defaults to private."
498
+ }
499
+ }
500
+ },
501
+ "id": {
502
+ "type": [
503
+ "string",
504
+ "number"
505
+ ],
506
+ "description": "master 在该平台上的会话 id(QQ 官方为 openid,ICQQ 为 QQ 号)",
507
+ "x-i18n": {
508
+ "en": {
509
+ "description": "Master conversation id on that platform (openid for QQ official, QQ number for ICQQ)."
510
+ }
511
+ }
512
+ },
513
+ "parent": {
514
+ "type": "object",
515
+ "additionalProperties": false,
516
+ "properties": {
517
+ "kind": {
518
+ "type": "string",
519
+ "enum": [
520
+ "private",
521
+ "group",
522
+ "channel"
523
+ ]
524
+ },
525
+ "id": {
526
+ "type": [
527
+ "string",
528
+ "number"
529
+ ]
530
+ }
531
+ },
532
+ "required": [
533
+ "kind",
534
+ "id"
535
+ ],
536
+ "description": "可选的父频道/群容器"
537
+ }
538
+ },
539
+ "required": [
540
+ "adapter",
541
+ "endpoint",
542
+ "id"
543
+ ]
370
544
  }
371
545
  },
372
546
  "required": [
@@ -0,0 +1,17 @@
1
+ /**
2
+ * ICQQ Console 页面元数据(浏览器与服务端共享;不要在这里 import 服务端代码)。
3
+ */
4
+ import type { AdapterConsoleField } from 'zhin.js/adapter';
5
+
6
+ export const icqqConsoleSpec = {
7
+ adapterKey: 'icqq',
8
+ adapterDisplayName: 'ICQQ',
9
+ fields: [
10
+ ] as readonly AdapterConsoleField[],
11
+ docsUrl: 'https://zhin.js.org/adapters/icqq',
12
+ readPermission: 'icqq:read',
13
+ writePermission: 'icqq:write',
14
+ idPattern: /^\d+$/,
15
+ idHint: 'ICQQ endpoint id 是 QQ 号(纯数字)',
16
+ idHintI18n: { en: 'ICQQ endpoint ID is the QQ number (digits only)' },
17
+ } as const;
package/src/console.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * ICQQ Console RPC:运行状态 + endpoint 增删(原 `icqq endpoint` 聊天命令)。
3
+ *
4
+ * 页面绑定 `pages/index`,读取 / 写入分别要求 `<adapter>:read` / `<adapter>:write`。
5
+ */
6
+ import { createAdapterConsoleRpc, type AdapterConsoleResources } from 'zhin.js/adapter';
7
+ import type { PluginConsoleRpc } from 'zhin.js';
8
+ import { icqqRuntimeStateToken } from './icqq-runtime-state.js';
9
+ import { icqqConsoleSpec } from './console-spec.js';
10
+
11
+ export function createIcqqConsoleRpc(
12
+ resources: AdapterConsoleResources,
13
+ getConfig: () => unknown,
14
+ ): PluginConsoleRpc {
15
+ const state = resources.use(icqqRuntimeStateToken);
16
+ return createAdapterConsoleRpc({
17
+ ...icqqConsoleSpec,
18
+ running: () => state.endpoints.values(),
19
+ getConfig,
20
+ }, resources);
21
+ }
package/src/endpoint.ts CHANGED
@@ -17,12 +17,14 @@ import {
17
17
  type EndpointPendingRequest,
18
18
  type EndpointSendRequest,
19
19
  type EndpointTransportState,
20
+ type AdapterOutageNotifier,
20
21
  } from 'zhin.js/adapter';
21
22
  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';
23
+ import { receiveOneBotLikeSideEvent, buildSystem, toCanonicalSegments, type LoginAssist, type PendingLoginTask } from '@zhin.js/core';
24
+ import { EndpointDeliveryError, type EndpointSendPart, type EndpointSendResult, type MessageRef } from '@zhin.js/im-contract';
24
25
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
25
26
  import type { CapabilityId } from 'zhin.js';
27
+ import type { IcqqLoginControl } from './icqq-login-control.js';
26
28
  import { runIcqqLoginAssistStep } from './icqq-login-assist.js';
27
29
  import { IcqqContentResolver } from './content-resolver.js';
28
30
  import {
@@ -53,7 +55,7 @@ import {
53
55
  } from './outbound-media.js';
54
56
  import {
55
57
  formatInboundContent,
56
- formatOutboundMessages,
58
+ formatOutboundMessageParts,
57
59
  normalizeIcqqImageMedia,
58
60
  icqqInboundConversation,
59
61
  icqqOutboundTarget,
@@ -70,6 +72,8 @@ export interface IcqqEndpointOptions {
70
72
  readonly config: ResolvedIcqqConfig;
71
73
  readonly loginAssist: LoginAssist;
72
74
  readonly locale?: import('zhin.js/i18n').Locale;
75
+ /** Out-of-band outage notifier; absent in tests/hosts without outbound. */
76
+ readonly notifier?: AdapterOutageNotifier;
73
77
  }
74
78
 
75
79
  const BOUND_EVENTS = [
@@ -114,6 +118,12 @@ export class IcqqEndpoint extends Endpoint<Client> {
114
118
  readonly #inboundOwner = new AsyncLocalStorage<symbol>();
115
119
  readonly #contentResolver: IcqqContentResolver;
116
120
  readonly content: EndpointContentPort;
121
+ readonly loginControl: IcqqLoginControl;
122
+ #reloginTimer?: ReturnType<typeof setTimeout>;
123
+ #reloginAttempts = 0;
124
+ #reloginInFlight?: Promise<void>;
125
+ #offlineNoticeAt = 0;
126
+ #challengeNoticeAt = 0;
117
127
 
118
128
  readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
119
129
  inspectGroupMember: (group, user, signal) => inspectGroupMember(this.client, group, user, signal),
@@ -210,6 +220,19 @@ export class IcqqEndpoint extends Endpoint<Client> {
210
220
  this.#logger = getAdapterLogger('icqq', options.config.id);
211
221
  this.#options = options;
212
222
  this.endpointName = options.config.id;
223
+ this.loginControl = Object.freeze({
224
+ endpointId: this.endpointName,
225
+ state: () => ({
226
+ endpointId: this.endpointName,
227
+ transportState: this.transportState,
228
+ online: this.client.isOnline(),
229
+ pending: this.#pendingLoginTasks(),
230
+ }),
231
+ 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),
235
+ });
213
236
  }
214
237
 
215
238
  #handleBackgroundFailure(operation: IcqqBackgroundOperation, error: unknown): void {
@@ -228,6 +251,141 @@ export class IcqqEndpoint extends Endpoint<Client> {
228
251
  this.client.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
229
252
  }
230
253
 
254
+ /** Force a login attempt (manual recovery command). */
255
+ relogin(): Promise<void> {
256
+ return this.#startRelogin(true);
257
+ }
258
+
259
+ #startRelogin(resetAttempts: boolean): Promise<void> {
260
+ if (this.#stopped || this.#retiring) {
261
+ return Promise.reject(new Error('ICQQ endpoint 已停止'));
262
+ }
263
+ if (this.#reloginInFlight) return this.#reloginInFlight;
264
+ if (resetAttempts) this.#reloginAttempts = 0;
265
+ const task = this.#performRelogin();
266
+ this.#reloginInFlight = task;
267
+ void task.catch(() => undefined).finally(() => {
268
+ if (this.#reloginInFlight === task) this.#reloginInFlight = undefined;
269
+ if (this.#started && !this.#stopped && !this.#retiring && !this.client.isOnline()) {
270
+ this.#scheduleRelogin('relogin_failed');
271
+ }
272
+ });
273
+ return task;
274
+ }
275
+
276
+ async #performRelogin(): Promise<void> {
277
+ this.#clearReloginTimer();
278
+ this.client.cancelReconnect();
279
+ this.#logger.info(formatCompact({ op: 'icqq_relogin', endpoint: this.endpointName }));
280
+ try {
281
+ await this.client.login(this.#options.config.password);
282
+ } catch (error) {
283
+ this.#logger.warn(formatCompact({
284
+ op: 'icqq_relogin_failed',
285
+ endpoint: this.endpointName,
286
+ error: error instanceof Error ? error.message : String(error),
287
+ }));
288
+ throw error;
289
+ }
290
+ }
291
+
292
+ #scheduleRelogin(reason: string): void {
293
+ if (!this.#options.config.autoReconnect || this.#stopped || this.#retiring) return;
294
+ if (this.#reloginTimer || this.#reloginInFlight) return;
295
+ if (this.#reloginAttempts >= 8) return;
296
+ const attempt = this.#reloginAttempts++;
297
+ const delayMs = Math.min(300_000, 5_000 * (2 ** Math.min(attempt, 5)));
298
+ this.#logger.info(formatCompact({
299
+ op: 'icqq_relogin_scheduled',
300
+ endpoint: this.endpointName,
301
+ reason,
302
+ attempt: attempt + 1,
303
+ delay_ms: delayMs,
304
+ }));
305
+ this.#reloginTimer = setTimeout(() => {
306
+ this.#reloginTimer = undefined;
307
+ void this.#startRelogin(false).catch(() => {
308
+ // #performRelogin already scheduled the next attempt.
309
+ });
310
+ }, delayMs);
311
+ this.#reloginTimer.unref?.();
312
+ }
313
+
314
+ #clearReloginTimer(): void {
315
+ if (!this.#reloginTimer) return;
316
+ clearTimeout(this.#reloginTimer);
317
+ this.#reloginTimer = undefined;
318
+ }
319
+
320
+ #pendingLoginTasks(): readonly PendingLoginTask[] {
321
+ return this.#options.loginAssist.listPending()
322
+ .filter((task) => task.adapter === 'icqq' && task.endpointKey === this.endpointName);
323
+ }
324
+
325
+ #notifyOffline(reason: string, willRetry: boolean): void {
326
+ const notifier = this.#options.notifier;
327
+ if (!notifier?.configured) return;
328
+ const now = Date.now();
329
+ if (now - this.#offlineNoticeAt < 60_000) return;
330
+ this.#offlineNoticeAt = now;
331
+ const recovery = willRetry
332
+ ? '已开始自动重新登录。'
333
+ : '请手动重新登录。';
334
+ const qrCommand = `icqq login qr ${this.endpointName}`;
335
+ const confirmCommand = `icqq login confirm ${this.endpointName}`;
336
+ const codeCommand = `icqq login code ${this.endpointName} <内容>`;
337
+ const text = `[Zhin] ICQQ ${this.endpointName} 掉线(${reason})。${recovery}\n`
338
+ + `恢复:在 QQ 回复 \`${qrCommand}\` 获取二维码,扫码后回复 \`${confirmCommand}\`;`
339
+ + `滑块/短信验证请回复 \`${codeCommand}\`。也可以打开 Console 的 ICQQ 页面完成登录。`;
340
+ void notifier.send(text).then((sent) => {
341
+ if (sent) return;
342
+ this.#logger.warn(formatCompact({
343
+ op: 'icqq_notify_failed',
344
+ endpoint: this.endpointName,
345
+ reason,
346
+ }));
347
+ });
348
+ }
349
+
350
+ #notifyChallenge(step: 'qrcode' | 'slider' | 'device' | 'auth'): void {
351
+ const notifier = this.#options.notifier;
352
+ if (!notifier?.configured) return;
353
+ const now = Date.now();
354
+ if (now - this.#challengeNoticeAt < 30_000) return;
355
+ this.#challengeNoticeAt = now;
356
+ const label = step === 'qrcode' ? '二维码验证'
357
+ : step === 'slider' ? '滑块验证'
358
+ : step === 'device' ? '设备确认'
359
+ : '登录确认';
360
+ const qrCommand = `icqq login qr ${this.endpointName}`;
361
+ const confirmCommand = `icqq login confirm ${this.endpointName}`;
362
+ const codeCommand = `icqq login code ${this.endpointName} <内容>`;
363
+ const text = `[Zhin] ICQQ ${this.endpointName} 需要${label}。\n`
364
+ + (step === 'qrcode' || step === 'auth'
365
+ ? `在 QQ 回复 \`${qrCommand}\` 获取二维码,完成后回复 \`${confirmCommand}\`。`
366
+ : `在 QQ 回复 \`icqq login status ${this.endpointName}\` 查看验证链接,再用 \`${codeCommand}\` 提交验证码;也可以打开 Console 的 ICQQ 页面。`);
367
+ void notifier.send(text).then((sent) => {
368
+ if (sent) return;
369
+ this.#logger.warn(formatCompact({
370
+ op: 'icqq_notify_failed',
371
+ endpoint: this.endpointName,
372
+ reason: `login_${step}`,
373
+ }));
374
+ });
375
+ }
376
+
377
+ #notifyRecovered(): void {
378
+ const notifier = this.#options.notifier;
379
+ const hadOfflineNotice = this.#offlineNoticeAt > 0;
380
+ this.#offlineNoticeAt = 0;
381
+ this.#challengeNoticeAt = 0;
382
+ if (!notifier?.configured || !hadOfflineNotice) return;
383
+ void notifier.send(`[Zhin] ICQQ ${this.endpointName} 已重新上线。`).then((sent) => {
384
+ if (sent) return;
385
+ this.#logger.warn(formatCompact({ op: 'icqq_notify_failed', endpoint: this.endpointName, reason: 'recovered' }));
386
+ });
387
+ }
388
+
231
389
  #ignoreReactionAck(action: 'add' | 'remove', task: Promise<unknown> | unknown): void {
232
390
  void Promise.resolve(task).catch((error) => {
233
391
  this.#logger.debug(formatCompact({
@@ -274,6 +432,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
274
432
  `connected (direct) | friends: ${this.client.fl.size} | groups: ${this.client.gl.size}`,
275
433
  );
276
434
  } catch (error) {
435
+ this.#notifyOffline('start_failed', false);
277
436
  await this.stop();
278
437
  this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
279
438
  throw error;
@@ -308,6 +467,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
308
467
 
309
468
  async stop(): Promise<void> {
310
469
  this.#stopped = true;
470
+ this.#clearReloginTimer();
311
471
  this.#assertExternalLifecycle('stop');
312
472
  this.#armRetirementFence();
313
473
  await Promise.allSettled([...this.#inflightInbound]);
@@ -358,7 +518,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
358
518
  });
359
519
  }
360
520
 
361
- async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
521
+ async send({ conversation, payload }: EndpointSendRequest): Promise<EndpointSendResult> {
362
522
  if (!this.#started) throw new EndpointDeliveryError('endpoint_disconnected', 'icqq endpoint 未连接', 'not_sent');
363
523
  const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
364
524
  const prepared = isIcqqSegmentPayload(payload)
@@ -366,10 +526,10 @@ export class IcqqEndpoint extends Endpoint<Client> {
366
526
  : { content: payload, dispose() {} };
367
527
  try {
368
528
  // Standalone elements (share / file / …) are split into ordered messages.
369
- const messages = formatOutboundMessages(prepared.content);
529
+ const messages = formatOutboundMessageParts(prepared.content);
370
530
  const parsed = icqqOutboundTarget(conversation);
371
- let messageId: string | undefined;
372
- for (const [index, message] of messages.entries()) {
531
+ const parts: EndpointSendPart[] = [];
532
+ for (const [index, { message, sourceIndices }] of messages.entries()) {
373
533
  const preview = formatIcqqSendablePreview(message);
374
534
  let result: { message_id?: unknown };
375
535
  try {
@@ -383,48 +543,30 @@ export class IcqqEndpoint extends Endpoint<Client> {
383
543
  preview: truncatePreview(preview, 120),
384
544
  }));
385
545
  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 });
546
+ // Earlier sends completed; retrying can duplicate accepted messages, even without ids.
547
+ throw new EndpointDeliveryError('partial_delivery', `ICQQ completed ${index} of ${messages.length} message sends`, 'unknown', { cause: error, parts });
388
548
  }
389
549
  const rawId = result?.message_id;
390
550
  const id = (typeof rawId === 'string' || (typeof rawId === 'number' && Number.isFinite(rawId))) && String(rawId).trim() !== ''
391
551
  ? String(rawId) : undefined;
392
- messageId ??= id;
552
+ parts.push({ ...(id ? { messageId: id } : {}), ...(sourceIndices ? { sourceIndices } : {}) });
393
553
  this.#logger.info(
394
554
  `send ${conversation.kind}:${conversation.id} | id: ${id ?? '-'} | ${truncatePreview(preview, 80)}`,
395
555
  );
396
556
  }
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');
557
+ // A resolved share/file call has no message identity; retain that unknown slot in the batch.
558
+ if (!parts.some(part => part.messageId)) {
559
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: ICQQ returned no message_id', 'unknown', { parts });
400
560
  }
401
- return messageId;
561
+ return { parts };
402
562
  } 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);
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
+ }
426
569
  }
427
- return undefined;
428
570
  }
429
571
 
430
572
  async #sendNative(
@@ -434,16 +576,14 @@ export class IcqqEndpoint extends Endpoint<Client> {
434
576
  if (isIcqqShareElement(message)) {
435
577
  switch (target.kind) {
436
578
  case 'private': {
437
- const sentAt = Math.floor(Date.now() / 1000);
438
579
  const friend = this.client.pickFriend(target.userId);
439
580
  await friend.share(message);
440
- return { message_id: await this.#findOwnCard(() => friend.getChatHistory(), sentAt) };
581
+ return {};
441
582
  }
442
583
  case 'group': {
443
- const sentAt = Math.floor(Date.now() / 1000);
444
584
  const group = this.client.pickGroup(target.groupId);
445
585
  await group.share(message);
446
- return { message_id: await this.#findOwnCard(() => group.getChatHistory(), sentAt) };
586
+ return {};
447
587
  }
448
588
  case 'temp':
449
589
  await this.client.pickMember(target.groupId, target.userId).share(message);
@@ -614,12 +754,16 @@ export class IcqqEndpoint extends Endpoint<Client> {
614
754
  this.#bindSystemOnlineEvent();
615
755
  this.client.on('system.offline', (event) => {
616
756
  this.#logSystemEvent('offline', event);
757
+ this.#notifyOffline('offline', this.#options.config.autoReconnect);
617
758
  });
618
759
  this.client.on('system.offline.network', (event) => {
619
760
  this.#logSystemEvent('offline.network', event);
761
+ this.#notifyOffline('network', this.#options.config.autoReconnect);
620
762
  });
621
763
  this.client.on('system.offline.kickoff', (event) => {
622
764
  this.#logSystemEvent('offline.kickoff', event);
765
+ this.#notifyOffline('kickoff', this.#options.config.autoReconnect);
766
+ this.#scheduleRelogin('kickoff');
623
767
  });
624
768
  this.client.on('system.login.qrcode', (event) => {
625
769
  this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
@@ -634,6 +778,8 @@ export class IcqqEndpoint extends Endpoint<Client> {
634
778
  this.client.on('system.login.error', (event) => {
635
779
  this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
636
780
  this.#logSystemEvent('login.error', event);
781
+ this.#notifyOffline('login_error', this.#options.config.autoReconnect);
782
+ this.#scheduleRelogin('login_error');
637
783
  });
638
784
  this.client.on('system.login.auth', (event) => {
639
785
  this.#handleLoginChallenge('auth', event);
@@ -644,6 +790,9 @@ export class IcqqEndpoint extends Endpoint<Client> {
644
790
  this.client.on('system.online', (event) => {
645
791
  if (this.#retiring) return;
646
792
  this.#everOnline = true;
793
+ this.#clearReloginTimer();
794
+ this.#reloginAttempts = 0;
795
+ this.#notifyRecovered();
647
796
  try {
648
797
  this.#resolveStartOnline?.();
649
798
  this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
@@ -707,6 +856,8 @@ export class IcqqEndpoint extends Endpoint<Client> {
707
856
  ? event as Record<string, unknown>
708
857
  : null);
709
858
  this.#logSystemEvent(`login.${step}`, event);
859
+ this.#clearReloginTimer();
860
+ this.#notifyChallenge(step);
710
861
  void runIcqqLoginAssistStep({
711
862
  assist: this.#options.loginAssist,
712
863
  locale: this.#options.locale,