@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.
- package/CHANGELOG.md +46 -0
- package/README.md +44 -3
- package/adapters/icqq/index.js +10 -2
- package/adapters/icqq/index.ts +10 -2
- package/commands/icqq/login/code/index.js +28 -0
- package/commands/icqq/login/code/index.ts +30 -0
- package/commands/icqq/login/confirm/index.js +20 -0
- package/commands/icqq/login/confirm/index.ts +21 -0
- package/commands/icqq/login/definition.js +65 -0
- package/commands/icqq/login/definition.ts +72 -0
- package/commands/icqq/login/qr/index.js +53 -0
- package/commands/icqq/login/qr/index.ts +55 -0
- package/commands/icqq/login/retry/index.js +21 -0
- package/commands/icqq/login/retry/index.ts +23 -0
- package/commands/icqq/login/status/index.js +31 -0
- package/commands/icqq/login/status/index.ts +34 -0
- package/lib/console-spec.d.ts +17 -0
- package/lib/console-spec.js +11 -0
- package/lib/console.d.ts +8 -0
- package/lib/console.js +16 -0
- package/lib/endpoint.d.ts +9 -2
- package/lib/endpoint.js +194 -37
- package/lib/icqq-login-control.d.ts +18 -0
- package/lib/icqq-login-control.js +8 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/protocol.d.ts +10 -0
- package/lib/protocol.js +24 -9
- package/package.json +17 -9
- package/pages/index/index.js +15 -0
- package/pages/index/index.ts +16 -0
- package/plugin.js +6 -1
- package/schema.json +174 -0
- package/src/console-spec.ts +17 -0
- package/src/console.ts +21 -0
- package/src/endpoint.ts +193 -42
- package/src/icqq-login-control.ts +30 -0
- package/src/index.ts +2 -0
- 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
|
-
|
|
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<
|
|
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 =
|
|
529
|
+
const messages = formatOutboundMessageParts(prepared.content);
|
|
370
530
|
const parsed = icqqOutboundTarget(conversation);
|
|
371
|
-
|
|
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
|
|
387
|
-
throw new EndpointDeliveryError('partial_delivery', `ICQQ
|
|
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
|
|
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
|
-
//
|
|
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
|
|
561
|
+
return { parts };
|
|
402
562
|
} finally {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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 {
|
|
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 {
|
|
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,
|