@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,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICQQ Console 页面元数据(浏览器与服务端共享;不要在这里 import 服务端代码)。
|
|
3
|
+
*/
|
|
4
|
+
import type { AdapterConsoleField } from 'zhin.js/adapter';
|
|
5
|
+
export declare const icqqConsoleSpec: {
|
|
6
|
+
readonly adapterKey: "icqq";
|
|
7
|
+
readonly adapterDisplayName: "ICQQ";
|
|
8
|
+
readonly fields: readonly AdapterConsoleField[];
|
|
9
|
+
readonly docsUrl: "https://zhin.js.org/adapters/icqq";
|
|
10
|
+
readonly readPermission: "icqq:read";
|
|
11
|
+
readonly writePermission: "icqq:write";
|
|
12
|
+
readonly idPattern: RegExp;
|
|
13
|
+
readonly idHint: "ICQQ endpoint id 是 QQ 号(纯数字)";
|
|
14
|
+
readonly idHintI18n: {
|
|
15
|
+
readonly en: "ICQQ endpoint ID is the QQ number (digits only)";
|
|
16
|
+
};
|
|
17
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const icqqConsoleSpec = {
|
|
2
|
+
adapterKey: 'icqq',
|
|
3
|
+
adapterDisplayName: 'ICQQ',
|
|
4
|
+
fields: [],
|
|
5
|
+
docsUrl: 'https://zhin.js.org/adapters/icqq',
|
|
6
|
+
readPermission: 'icqq:read',
|
|
7
|
+
writePermission: 'icqq:write',
|
|
8
|
+
idPattern: /^\d+$/,
|
|
9
|
+
idHint: 'ICQQ endpoint id 是 QQ 号(纯数字)',
|
|
10
|
+
idHintI18n: { en: 'ICQQ endpoint ID is the QQ number (digits only)' },
|
|
11
|
+
};
|
package/lib/console.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICQQ Console RPC:运行状态 + endpoint 增删(原 `icqq endpoint` 聊天命令)。
|
|
3
|
+
*
|
|
4
|
+
* 页面绑定 `pages/index`,读取 / 写入分别要求 `<adapter>:read` / `<adapter>:write`。
|
|
5
|
+
*/
|
|
6
|
+
import { type AdapterConsoleResources } from 'zhin.js/adapter';
|
|
7
|
+
import type { PluginConsoleRpc } from 'zhin.js';
|
|
8
|
+
export declare function createIcqqConsoleRpc(resources: AdapterConsoleResources, getConfig: () => unknown): PluginConsoleRpc;
|
package/lib/console.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICQQ Console RPC:运行状态 + endpoint 增删(原 `icqq endpoint` 聊天命令)。
|
|
3
|
+
*
|
|
4
|
+
* 页面绑定 `pages/index`,读取 / 写入分别要求 `<adapter>:read` / `<adapter>:write`。
|
|
5
|
+
*/
|
|
6
|
+
import { createAdapterConsoleRpc } from 'zhin.js/adapter';
|
|
7
|
+
import { icqqRuntimeStateToken } from './icqq-runtime-state.js';
|
|
8
|
+
import { icqqConsoleSpec } from './console-spec.js';
|
|
9
|
+
export function createIcqqConsoleRpc(resources, getConfig) {
|
|
10
|
+
const state = resources.use(icqqRuntimeStateToken);
|
|
11
|
+
return createAdapterConsoleRpc({
|
|
12
|
+
...icqqConsoleSpec,
|
|
13
|
+
running: () => state.endpoints.values(),
|
|
14
|
+
getConfig,
|
|
15
|
+
}, resources);
|
|
16
|
+
}
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Client } from '@icqqjs/icqq';
|
|
2
|
-
import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest, type EndpointTransportState } from 'zhin.js/adapter';
|
|
2
|
+
import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest, type EndpointTransportState, type AdapterOutageNotifier } from 'zhin.js/adapter';
|
|
3
3
|
import type { EndpointContentPort } from '@zhin.js/adapter';
|
|
4
4
|
import { type LoginAssist } from '@zhin.js/core';
|
|
5
|
+
import { type EndpointSendResult } from '@zhin.js/im-contract';
|
|
5
6
|
import type { CapabilityId } from 'zhin.js';
|
|
7
|
+
import type { IcqqLoginControl } from './icqq-login-control.js';
|
|
6
8
|
import { type IcqqInboundMessage, type ResolvedIcqqConfig } from './protocol.js';
|
|
7
9
|
import { ManagedIcqqClient } from './managed-client.js';
|
|
8
10
|
export interface IcqqEndpointOptions {
|
|
@@ -10,20 +12,25 @@ export interface IcqqEndpointOptions {
|
|
|
10
12
|
readonly config: ResolvedIcqqConfig;
|
|
11
13
|
readonly loginAssist: LoginAssist;
|
|
12
14
|
readonly locale?: import('zhin.js/i18n').Locale;
|
|
15
|
+
/** Out-of-band outage notifier; absent in tests/hosts without outbound. */
|
|
16
|
+
readonly notifier?: AdapterOutageNotifier;
|
|
13
17
|
}
|
|
14
18
|
export declare class IcqqEndpoint extends Endpoint<Client> {
|
|
15
19
|
#private;
|
|
16
20
|
readonly client: ManagedIcqqClient;
|
|
17
21
|
readonly endpointName: string;
|
|
18
22
|
readonly content: EndpointContentPort;
|
|
23
|
+
readonly loginControl: IcqqLoginControl;
|
|
19
24
|
readonly management: EndpointManagement;
|
|
20
25
|
readonly control: EndpointControl;
|
|
21
26
|
constructor(options: IcqqEndpointOptions);
|
|
27
|
+
/** Force a login attempt (manual recovery command). */
|
|
28
|
+
relogin(): Promise<void>;
|
|
22
29
|
get transportState(): EndpointTransportState;
|
|
23
30
|
start(signal: AbortSignal): Promise<void>;
|
|
24
31
|
open(): void;
|
|
25
32
|
close(): Promise<void>;
|
|
26
33
|
stop(): Promise<void>;
|
|
27
|
-
send({ conversation, payload }: EndpointSendRequest): Promise<
|
|
34
|
+
send({ conversation, payload }: EndpointSendRequest): Promise<EndpointSendResult>;
|
|
28
35
|
admit(msg: IcqqInboundMessage): Promise<void>;
|
|
29
36
|
}
|
package/lib/endpoint.js
CHANGED
|
@@ -11,7 +11,7 @@ import { InboundMessageDeduper, findIcqqNestedMessageSource, isIcqqBotMentioned,
|
|
|
11
11
|
import { icqqNoticeDedupeKey, isIcqqNoticePayload, isIcqqRequestPayload, } from './icqq-inbox.js';
|
|
12
12
|
import { IcqqGuildCatalog, normalizeIcqqGuildInboundMessage, } from './icqq-guild.js';
|
|
13
13
|
import { prepareIcqqOutboundMedia, resolveIcqqOutboundMediaMode, } from './outbound-media.js';
|
|
14
|
-
import { formatInboundContent,
|
|
14
|
+
import { formatInboundContent, formatOutboundMessageParts, normalizeIcqqImageMedia, icqqInboundConversation, icqqOutboundTarget, } from './protocol.js';
|
|
15
15
|
import { ManagedIcqqClient } from './managed-client.js';
|
|
16
16
|
import { inspectGroupMember, moderateGroupMember } from './group-moderation.js';
|
|
17
17
|
import { inspectGroupMemberProfile, inspectGroupScene } from './group-scene.js';
|
|
@@ -56,6 +56,12 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
56
56
|
#inboundOwner = new AsyncLocalStorage();
|
|
57
57
|
#contentResolver;
|
|
58
58
|
content;
|
|
59
|
+
loginControl;
|
|
60
|
+
#reloginTimer;
|
|
61
|
+
#reloginAttempts = 0;
|
|
62
|
+
#reloginInFlight;
|
|
63
|
+
#offlineNoticeAt = 0;
|
|
64
|
+
#challengeNoticeAt = 0;
|
|
59
65
|
management = Object.freeze({
|
|
60
66
|
inspectGroupMember: (group, user, signal) => inspectGroupMember(this.client, group, user, signal),
|
|
61
67
|
inspectGroupScene: (group, signal) => inspectGroupScene(this.client, group, signal),
|
|
@@ -143,6 +149,19 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
143
149
|
this.#logger = getAdapterLogger('icqq', options.config.id);
|
|
144
150
|
this.#options = options;
|
|
145
151
|
this.endpointName = options.config.id;
|
|
152
|
+
this.loginControl = Object.freeze({
|
|
153
|
+
endpointId: this.endpointName,
|
|
154
|
+
state: () => ({
|
|
155
|
+
endpointId: this.endpointName,
|
|
156
|
+
transportState: this.transportState,
|
|
157
|
+
online: this.client.isOnline(),
|
|
158
|
+
pending: this.#pendingLoginTasks(),
|
|
159
|
+
}),
|
|
160
|
+
pending: () => this.#pendingLoginTasks(),
|
|
161
|
+
relogin: () => this.relogin(),
|
|
162
|
+
submit: (taskId, value) => this.#options.loginAssist.submit(taskId, value),
|
|
163
|
+
cancel: (taskId, reason) => this.#options.loginAssist.cancel(taskId, reason),
|
|
164
|
+
});
|
|
146
165
|
}
|
|
147
166
|
#handleBackgroundFailure(operation, error) {
|
|
148
167
|
if (!this.#started || this.#stopped || this.#retiring)
|
|
@@ -162,6 +181,148 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
162
181
|
// reconn_interval policy. No account-level logout or second retry timer.
|
|
163
182
|
this.client.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
|
|
164
183
|
}
|
|
184
|
+
/** Force a login attempt (manual recovery command). */
|
|
185
|
+
relogin() {
|
|
186
|
+
return this.#startRelogin(true);
|
|
187
|
+
}
|
|
188
|
+
#startRelogin(resetAttempts) {
|
|
189
|
+
if (this.#stopped || this.#retiring) {
|
|
190
|
+
return Promise.reject(new Error('ICQQ endpoint 已停止'));
|
|
191
|
+
}
|
|
192
|
+
if (this.#reloginInFlight)
|
|
193
|
+
return this.#reloginInFlight;
|
|
194
|
+
if (resetAttempts)
|
|
195
|
+
this.#reloginAttempts = 0;
|
|
196
|
+
const task = this.#performRelogin();
|
|
197
|
+
this.#reloginInFlight = task;
|
|
198
|
+
void task.catch(() => undefined).finally(() => {
|
|
199
|
+
if (this.#reloginInFlight === task)
|
|
200
|
+
this.#reloginInFlight = undefined;
|
|
201
|
+
if (this.#started && !this.#stopped && !this.#retiring && !this.client.isOnline()) {
|
|
202
|
+
this.#scheduleRelogin('relogin_failed');
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
return task;
|
|
206
|
+
}
|
|
207
|
+
async #performRelogin() {
|
|
208
|
+
this.#clearReloginTimer();
|
|
209
|
+
this.client.cancelReconnect();
|
|
210
|
+
this.#logger.info(formatCompact({ op: 'icqq_relogin', endpoint: this.endpointName }));
|
|
211
|
+
try {
|
|
212
|
+
await this.client.login(this.#options.config.password);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
this.#logger.warn(formatCompact({
|
|
216
|
+
op: 'icqq_relogin_failed',
|
|
217
|
+
endpoint: this.endpointName,
|
|
218
|
+
error: error instanceof Error ? error.message : String(error),
|
|
219
|
+
}));
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
#scheduleRelogin(reason) {
|
|
224
|
+
if (!this.#options.config.autoReconnect || this.#stopped || this.#retiring)
|
|
225
|
+
return;
|
|
226
|
+
if (this.#reloginTimer || this.#reloginInFlight)
|
|
227
|
+
return;
|
|
228
|
+
if (this.#reloginAttempts >= 8)
|
|
229
|
+
return;
|
|
230
|
+
const attempt = this.#reloginAttempts++;
|
|
231
|
+
const delayMs = Math.min(300_000, 5_000 * (2 ** Math.min(attempt, 5)));
|
|
232
|
+
this.#logger.info(formatCompact({
|
|
233
|
+
op: 'icqq_relogin_scheduled',
|
|
234
|
+
endpoint: this.endpointName,
|
|
235
|
+
reason,
|
|
236
|
+
attempt: attempt + 1,
|
|
237
|
+
delay_ms: delayMs,
|
|
238
|
+
}));
|
|
239
|
+
this.#reloginTimer = setTimeout(() => {
|
|
240
|
+
this.#reloginTimer = undefined;
|
|
241
|
+
void this.#startRelogin(false).catch(() => {
|
|
242
|
+
// #performRelogin already scheduled the next attempt.
|
|
243
|
+
});
|
|
244
|
+
}, delayMs);
|
|
245
|
+
this.#reloginTimer.unref?.();
|
|
246
|
+
}
|
|
247
|
+
#clearReloginTimer() {
|
|
248
|
+
if (!this.#reloginTimer)
|
|
249
|
+
return;
|
|
250
|
+
clearTimeout(this.#reloginTimer);
|
|
251
|
+
this.#reloginTimer = undefined;
|
|
252
|
+
}
|
|
253
|
+
#pendingLoginTasks() {
|
|
254
|
+
return this.#options.loginAssist.listPending()
|
|
255
|
+
.filter((task) => task.adapter === 'icqq' && task.endpointKey === this.endpointName);
|
|
256
|
+
}
|
|
257
|
+
#notifyOffline(reason, willRetry) {
|
|
258
|
+
const notifier = this.#options.notifier;
|
|
259
|
+
if (!notifier?.configured)
|
|
260
|
+
return;
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
if (now - this.#offlineNoticeAt < 60_000)
|
|
263
|
+
return;
|
|
264
|
+
this.#offlineNoticeAt = now;
|
|
265
|
+
const recovery = willRetry
|
|
266
|
+
? '已开始自动重新登录。'
|
|
267
|
+
: '请手动重新登录。';
|
|
268
|
+
const qrCommand = `icqq login qr ${this.endpointName}`;
|
|
269
|
+
const confirmCommand = `icqq login confirm ${this.endpointName}`;
|
|
270
|
+
const codeCommand = `icqq login code ${this.endpointName} <内容>`;
|
|
271
|
+
const text = `[Zhin] ICQQ ${this.endpointName} 掉线(${reason})。${recovery}\n`
|
|
272
|
+
+ `恢复:在 QQ 回复 \`${qrCommand}\` 获取二维码,扫码后回复 \`${confirmCommand}\`;`
|
|
273
|
+
+ `滑块/短信验证请回复 \`${codeCommand}\`。也可以打开 Console 的 ICQQ 页面完成登录。`;
|
|
274
|
+
void notifier.send(text).then((sent) => {
|
|
275
|
+
if (sent)
|
|
276
|
+
return;
|
|
277
|
+
this.#logger.warn(formatCompact({
|
|
278
|
+
op: 'icqq_notify_failed',
|
|
279
|
+
endpoint: this.endpointName,
|
|
280
|
+
reason,
|
|
281
|
+
}));
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
#notifyChallenge(step) {
|
|
285
|
+
const notifier = this.#options.notifier;
|
|
286
|
+
if (!notifier?.configured)
|
|
287
|
+
return;
|
|
288
|
+
const now = Date.now();
|
|
289
|
+
if (now - this.#challengeNoticeAt < 30_000)
|
|
290
|
+
return;
|
|
291
|
+
this.#challengeNoticeAt = now;
|
|
292
|
+
const label = step === 'qrcode' ? '二维码验证'
|
|
293
|
+
: step === 'slider' ? '滑块验证'
|
|
294
|
+
: step === 'device' ? '设备确认'
|
|
295
|
+
: '登录确认';
|
|
296
|
+
const qrCommand = `icqq login qr ${this.endpointName}`;
|
|
297
|
+
const confirmCommand = `icqq login confirm ${this.endpointName}`;
|
|
298
|
+
const codeCommand = `icqq login code ${this.endpointName} <内容>`;
|
|
299
|
+
const text = `[Zhin] ICQQ ${this.endpointName} 需要${label}。\n`
|
|
300
|
+
+ (step === 'qrcode' || step === 'auth'
|
|
301
|
+
? `在 QQ 回复 \`${qrCommand}\` 获取二维码,完成后回复 \`${confirmCommand}\`。`
|
|
302
|
+
: `在 QQ 回复 \`icqq login status ${this.endpointName}\` 查看验证链接,再用 \`${codeCommand}\` 提交验证码;也可以打开 Console 的 ICQQ 页面。`);
|
|
303
|
+
void notifier.send(text).then((sent) => {
|
|
304
|
+
if (sent)
|
|
305
|
+
return;
|
|
306
|
+
this.#logger.warn(formatCompact({
|
|
307
|
+
op: 'icqq_notify_failed',
|
|
308
|
+
endpoint: this.endpointName,
|
|
309
|
+
reason: `login_${step}`,
|
|
310
|
+
}));
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
#notifyRecovered() {
|
|
314
|
+
const notifier = this.#options.notifier;
|
|
315
|
+
const hadOfflineNotice = this.#offlineNoticeAt > 0;
|
|
316
|
+
this.#offlineNoticeAt = 0;
|
|
317
|
+
this.#challengeNoticeAt = 0;
|
|
318
|
+
if (!notifier?.configured || !hadOfflineNotice)
|
|
319
|
+
return;
|
|
320
|
+
void notifier.send(`[Zhin] ICQQ ${this.endpointName} 已重新上线。`).then((sent) => {
|
|
321
|
+
if (sent)
|
|
322
|
+
return;
|
|
323
|
+
this.#logger.warn(formatCompact({ op: 'icqq_notify_failed', endpoint: this.endpointName, reason: 'recovered' }));
|
|
324
|
+
});
|
|
325
|
+
}
|
|
165
326
|
#ignoreReactionAck(action, task) {
|
|
166
327
|
void Promise.resolve(task).catch((error) => {
|
|
167
328
|
this.#logger.debug(formatCompact({
|
|
@@ -210,6 +371,7 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
210
371
|
this.#logger.info(`connected (direct) | friends: ${this.client.fl.size} | groups: ${this.client.gl.size}`);
|
|
211
372
|
}
|
|
212
373
|
catch (error) {
|
|
374
|
+
this.#notifyOffline('start_failed', false);
|
|
213
375
|
await this.stop();
|
|
214
376
|
this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
215
377
|
throw error;
|
|
@@ -243,6 +405,7 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
243
405
|
}
|
|
244
406
|
async stop() {
|
|
245
407
|
this.#stopped = true;
|
|
408
|
+
this.#clearReloginTimer();
|
|
246
409
|
this.#assertExternalLifecycle('stop');
|
|
247
410
|
this.#armRetirementFence();
|
|
248
411
|
await Promise.allSettled([...this.#inflightInbound]);
|
|
@@ -305,10 +468,10 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
305
468
|
: { content: payload, dispose() { } };
|
|
306
469
|
try {
|
|
307
470
|
// Standalone elements (share / file / …) are split into ordered messages.
|
|
308
|
-
const messages =
|
|
471
|
+
const messages = formatOutboundMessageParts(prepared.content);
|
|
309
472
|
const parsed = icqqOutboundTarget(conversation);
|
|
310
|
-
|
|
311
|
-
for (const [index, message] of messages.entries()) {
|
|
473
|
+
const parts = [];
|
|
474
|
+
for (const [index, { message, sourceIndices }] of messages.entries()) {
|
|
312
475
|
const preview = formatIcqqSendablePreview(message);
|
|
313
476
|
let result;
|
|
314
477
|
try {
|
|
@@ -324,60 +487,43 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
324
487
|
}));
|
|
325
488
|
if (index === 0)
|
|
326
489
|
throw error;
|
|
327
|
-
// Earlier
|
|
328
|
-
throw new EndpointDeliveryError('partial_delivery', `ICQQ
|
|
490
|
+
// Earlier sends completed; retrying can duplicate accepted messages, even without ids.
|
|
491
|
+
throw new EndpointDeliveryError('partial_delivery', `ICQQ completed ${index} of ${messages.length} message sends`, 'unknown', { cause: error, parts });
|
|
329
492
|
}
|
|
330
493
|
const rawId = result?.message_id;
|
|
331
494
|
const id = (typeof rawId === 'string' || (typeof rawId === 'number' && Number.isFinite(rawId))) && String(rawId).trim() !== ''
|
|
332
495
|
? String(rawId) : undefined;
|
|
333
|
-
messageId
|
|
496
|
+
parts.push({ ...(id ? { messageId: id } : {}), ...(sourceIndices ? { sourceIndices } : {}) });
|
|
334
497
|
this.#logger.info(`send ${conversation.kind}:${conversation.id} | id: ${id ?? '-'} | ${truncatePreview(preview, 80)}`);
|
|
335
498
|
}
|
|
336
|
-
//
|
|
337
|
-
if (!messageId) {
|
|
338
|
-
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: ICQQ returned no message_id', 'unknown');
|
|
499
|
+
// A resolved share/file call has no message identity; retain that unknown slot in the batch.
|
|
500
|
+
if (!parts.some(part => part.messageId)) {
|
|
501
|
+
throw new EndpointDeliveryError('delivery_unconfirmed', 'Message delivery is unconfirmed: ICQQ returned no message_id', 'unknown', { parts });
|
|
339
502
|
}
|
|
340
|
-
return
|
|
503
|
+
return { parts };
|
|
341
504
|
}
|
|
342
505
|
finally {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
* unconfirmed rather than inventing an id.
|
|
351
|
-
*/
|
|
352
|
-
async #findOwnCard(history, sentAt) {
|
|
353
|
-
const self = Number(this.client.uin);
|
|
354
|
-
for (const wait of [0, 500]) {
|
|
355
|
-
if (wait)
|
|
356
|
-
await new Promise(resolve => setTimeout(resolve, wait));
|
|
357
|
-
// A missing or throwing history API must not turn an accepted share into a send failure.
|
|
358
|
-
const messages = await Promise.resolve().then(history).catch(() => []);
|
|
359
|
-
const card = [...messages].reverse().find(item => Number(item.sender?.user_id ?? item.user_id) === self
|
|
360
|
-
&& (item.time ?? 0) >= sentAt - 2
|
|
361
|
-
&& (item.message ?? []).some(element => element.type === 'json' || element.type === 'xml' || element.type === 'share'));
|
|
362
|
-
if (card && card.message_id != null && String(card.message_id).trim())
|
|
363
|
-
return String(card.message_id);
|
|
506
|
+
try {
|
|
507
|
+
prepared.dispose();
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
// Temporary-file cleanup cannot change an already observed platform send outcome.
|
|
511
|
+
this.#logger.warn(formatCompact({ op: 'icqq_media_cleanup_failed', error: describeUnknownError(error) }));
|
|
512
|
+
}
|
|
364
513
|
}
|
|
365
|
-
return undefined;
|
|
366
514
|
}
|
|
367
515
|
async #sendNative(target, message) {
|
|
368
516
|
if (isIcqqShareElement(message)) {
|
|
369
517
|
switch (target.kind) {
|
|
370
518
|
case 'private': {
|
|
371
|
-
const sentAt = Math.floor(Date.now() / 1000);
|
|
372
519
|
const friend = this.client.pickFriend(target.userId);
|
|
373
520
|
await friend.share(message);
|
|
374
|
-
return {
|
|
521
|
+
return {};
|
|
375
522
|
}
|
|
376
523
|
case 'group': {
|
|
377
|
-
const sentAt = Math.floor(Date.now() / 1000);
|
|
378
524
|
const group = this.client.pickGroup(target.groupId);
|
|
379
525
|
await group.share(message);
|
|
380
|
-
return {
|
|
526
|
+
return {};
|
|
381
527
|
}
|
|
382
528
|
case 'temp':
|
|
383
529
|
await this.client.pickMember(target.groupId, target.userId).share(message);
|
|
@@ -541,12 +687,16 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
541
687
|
this.#bindSystemOnlineEvent();
|
|
542
688
|
this.client.on('system.offline', (event) => {
|
|
543
689
|
this.#logSystemEvent('offline', event);
|
|
690
|
+
this.#notifyOffline('offline', this.#options.config.autoReconnect);
|
|
544
691
|
});
|
|
545
692
|
this.client.on('system.offline.network', (event) => {
|
|
546
693
|
this.#logSystemEvent('offline.network', event);
|
|
694
|
+
this.#notifyOffline('network', this.#options.config.autoReconnect);
|
|
547
695
|
});
|
|
548
696
|
this.client.on('system.offline.kickoff', (event) => {
|
|
549
697
|
this.#logSystemEvent('offline.kickoff', event);
|
|
698
|
+
this.#notifyOffline('kickoff', this.#options.config.autoReconnect);
|
|
699
|
+
this.#scheduleRelogin('kickoff');
|
|
550
700
|
});
|
|
551
701
|
this.client.on('system.login.qrcode', (event) => {
|
|
552
702
|
this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
|
|
@@ -561,6 +711,8 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
561
711
|
this.client.on('system.login.error', (event) => {
|
|
562
712
|
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
|
|
563
713
|
this.#logSystemEvent('login.error', event);
|
|
714
|
+
this.#notifyOffline('login_error', this.#options.config.autoReconnect);
|
|
715
|
+
this.#scheduleRelogin('login_error');
|
|
564
716
|
});
|
|
565
717
|
this.client.on('system.login.auth', (event) => {
|
|
566
718
|
this.#handleLoginChallenge('auth', event);
|
|
@@ -571,6 +723,9 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
571
723
|
if (this.#retiring)
|
|
572
724
|
return;
|
|
573
725
|
this.#everOnline = true;
|
|
726
|
+
this.#clearReloginTimer();
|
|
727
|
+
this.#reloginAttempts = 0;
|
|
728
|
+
this.#notifyRecovered();
|
|
574
729
|
try {
|
|
575
730
|
this.#resolveStartOnline?.();
|
|
576
731
|
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
|
|
@@ -628,6 +783,8 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
628
783
|
? event
|
|
629
784
|
: null);
|
|
630
785
|
this.#logSystemEvent(`login.${step}`, event);
|
|
786
|
+
this.#clearReloginTimer();
|
|
787
|
+
this.#notifyChallenge(step);
|
|
631
788
|
void runIcqqLoginAssistStep({
|
|
632
789
|
assist: this.#options.loginAssist,
|
|
633
790
|
locale: this.#options.locale,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PendingLoginTask } from '@zhin.js/core';
|
|
2
|
+
export interface IcqqLoginControlState {
|
|
3
|
+
readonly endpointId: string;
|
|
4
|
+
readonly transportState: string;
|
|
5
|
+
readonly online: boolean;
|
|
6
|
+
readonly pending: readonly PendingLoginTask[];
|
|
7
|
+
}
|
|
8
|
+
export interface IcqqLoginControl {
|
|
9
|
+
readonly endpointId: string;
|
|
10
|
+
state(): IcqqLoginControlState;
|
|
11
|
+
pending(): readonly PendingLoginTask[];
|
|
12
|
+
/** Force a login attempt; rejects when the endpoint is stopped or the SDK login fails. */
|
|
13
|
+
relogin(): Promise<void>;
|
|
14
|
+
submit(taskId: string, value: string | Record<string, unknown>): boolean;
|
|
15
|
+
cancel(taskId: string, reason?: string): boolean;
|
|
16
|
+
}
|
|
17
|
+
/** `endpoint id (QQ uin)` → control for the current generation. */
|
|
18
|
+
export declare const icqqLoginControlToken: import("zhin.js").Token<Map<string, IcqqLoginControl>>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICQQ login control surface exposed to the Plugin Runtime, Console and the
|
|
3
|
+
* recovery command. One control per endpoint instance (UIN), provided by the
|
|
4
|
+
* ICQQ plugin setup and filled by `adapters/icqq/create()`.
|
|
5
|
+
*/
|
|
6
|
+
import { createToken } from 'zhin.js';
|
|
7
|
+
/** `endpoint id (QQ uin)` → control for the current generation. */
|
|
8
|
+
export const icqqLoginControlToken = createToken('zhin.icqq.login-control', 'ICQQ login control surface for recovery commands');
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Actions, formatInboundContent, formatOutboundBody, formatOutboundMessages, icqqInboundConversation, icqqOutboundTarget, type ActionResult, type IcqqEndpointConfig, type IcqqInboundMessage, type IcqqWireSegment, type ParsedIcqqSendTarget, type ResolvedIcqqConfig, } from './protocol.js';
|
|
1
|
+
export { Actions, formatInboundContent, formatOutboundBody, formatOutboundMessages, formatOutboundMessageParts, icqqInboundConversation, icqqOutboundTarget, type ActionResult, type IcqqEndpointConfig, type IcqqInboundMessage, type IcqqWireSegment, type IcqqOutboundMessagePart, type ParsedIcqqSendTarget, type ResolvedIcqqConfig, } from './protocol.js';
|
|
2
2
|
export { IcqqEndpoint, type IcqqEndpointOptions, } from './endpoint.js';
|
|
3
3
|
export * from './types.js';
|
|
4
4
|
export { icqqClient, type IcqqClient, type IcqqClientEventMap, } from './client.js';
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Actions, formatInboundContent, formatOutboundBody, formatOutboundMessages, icqqInboundConversation, icqqOutboundTarget, } from './protocol.js';
|
|
1
|
+
export { Actions, formatInboundContent, formatOutboundBody, formatOutboundMessages, formatOutboundMessageParts, icqqInboundConversation, icqqOutboundTarget, } from './protocol.js';
|
|
2
2
|
export { IcqqEndpoint, } from './endpoint.js';
|
|
3
3
|
export * from './types.js';
|
|
4
4
|
export { icqqClient, } from './client.js';
|
package/lib/protocol.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Sendable } from '@icqqjs/icqq';
|
|
2
2
|
import { type ConversationRef } from '@zhin.js/im-contract';
|
|
3
|
+
import { type AdapterNotifyTarget, type ResolvedAdapterNotifyTarget } from 'zhin.js/adapter';
|
|
3
4
|
import type { MessageSegment, SendContent } from "zhin.js";
|
|
4
5
|
export type ActionResult = {
|
|
5
6
|
id: string;
|
|
@@ -177,6 +178,8 @@ export interface IcqqEndpointConfig {
|
|
|
177
178
|
readonly autoServer?: boolean;
|
|
178
179
|
readonly qqnt?: boolean;
|
|
179
180
|
readonly ntLogin?: boolean;
|
|
181
|
+
/** Optional out-of-band outage notification target (usually a QQ adapter instance). */
|
|
182
|
+
readonly notify?: AdapterNotifyTarget;
|
|
180
183
|
}
|
|
181
184
|
export interface ResolvedIcqqConfig {
|
|
182
185
|
readonly context: 'icqq';
|
|
@@ -194,6 +197,7 @@ export interface ResolvedIcqqConfig {
|
|
|
194
197
|
readonly autoServer?: boolean;
|
|
195
198
|
readonly qqnt?: boolean;
|
|
196
199
|
readonly ntLogin?: boolean;
|
|
200
|
+
readonly notify?: ResolvedAdapterNotifyTarget;
|
|
197
201
|
}
|
|
198
202
|
export interface IcqqWireSegment {
|
|
199
203
|
readonly type: string;
|
|
@@ -269,4 +273,10 @@ export declare function formatOutboundBody(payload: unknown): Sendable;
|
|
|
269
273
|
* quote or whitespace are dropped: a standalone element cannot carry the quote.
|
|
270
274
|
*/
|
|
271
275
|
export declare function formatOutboundMessages(payload: unknown): Sendable[];
|
|
276
|
+
/** Native bubble and the exact source segment positions which produced it. */
|
|
277
|
+
export interface IcqqOutboundMessagePart {
|
|
278
|
+
readonly message: Sendable;
|
|
279
|
+
readonly sourceIndices?: readonly number[];
|
|
280
|
+
}
|
|
281
|
+
export declare function formatOutboundMessageParts(payload: unknown): IcqqOutboundMessagePart[];
|
|
272
282
|
export declare function formatInboundContent(rawMessage: string): string;
|
package/lib/protocol.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isMediaRef } from '@zhin.js/im-contract';
|
|
2
|
+
import { resolveAdapterNotifyTarget } from 'zhin.js/adapter';
|
|
2
3
|
import { resolveIcqqMediaFile } from "./cq-message.js";
|
|
3
4
|
export { resolveIcqqInboundMessageId } from "./icqq-inbound.js";
|
|
4
5
|
/**
|
|
@@ -190,6 +191,7 @@ export function resolveIcqqConfig(config) {
|
|
|
190
191
|
const autoServer = config.autoServer ?? true;
|
|
191
192
|
const qqnt = config.qqnt;
|
|
192
193
|
const ntLogin = config.ntLogin;
|
|
194
|
+
const notify = resolveAdapterNotifyTarget(config.notify);
|
|
193
195
|
return {
|
|
194
196
|
context: 'icqq',
|
|
195
197
|
id,
|
|
@@ -206,6 +208,7 @@ export function resolveIcqqConfig(config) {
|
|
|
206
208
|
...(autoServer != null ? { autoServer } : {}),
|
|
207
209
|
...(qqnt != null ? { qqnt } : {}),
|
|
208
210
|
...(ntLogin != null ? { ntLogin } : {}),
|
|
211
|
+
...(notify ? { notify } : {}),
|
|
209
212
|
};
|
|
210
213
|
}
|
|
211
214
|
/**
|
|
@@ -326,22 +329,34 @@ export function formatOutboundBody(payload) {
|
|
|
326
329
|
* quote or whitespace are dropped: a standalone element cannot carry the quote.
|
|
327
330
|
*/
|
|
328
331
|
export function formatOutboundMessages(payload) {
|
|
332
|
+
return formatOutboundMessageParts(payload).map(part => part.message);
|
|
333
|
+
}
|
|
334
|
+
export function formatOutboundMessageParts(payload) {
|
|
329
335
|
if (!Array.isArray(payload)) {
|
|
330
|
-
|
|
336
|
+
if (isWireSegment(payload))
|
|
337
|
+
return formatOutboundMessageParts([payload]);
|
|
338
|
+
const message = formatOutboundBody(payload);
|
|
339
|
+
return [{ message, ...(typeof payload === 'string' && message !== payload ? {} : { sourceIndices: [0] }) }];
|
|
331
340
|
}
|
|
332
341
|
const groups = [[]];
|
|
333
|
-
for (const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
342
|
+
for (const [sourceIndex, source] of payload.entries()) {
|
|
343
|
+
for (const element of formatOutboundElements([source])) {
|
|
344
|
+
const item = { element, sourceIndex };
|
|
345
|
+
if (isStandaloneElement(element))
|
|
346
|
+
groups.push([item], []);
|
|
347
|
+
else
|
|
348
|
+
groups.at(-1).push(item);
|
|
349
|
+
}
|
|
338
350
|
}
|
|
339
|
-
const messages = groups.filter(group => group.some(element => typeof element === 'string'
|
|
351
|
+
const messages = groups.filter(group => group.some(({ element }) => typeof element === 'string'
|
|
340
352
|
? element.trim() !== ''
|
|
341
353
|
: element.type !== 'reply' && element.type !== 'quote'));
|
|
342
354
|
if (messages.length === 0)
|
|
343
|
-
return ['\u200b'];
|
|
344
|
-
return messages.map(group =>
|
|
355
|
+
return [{ message: '\u200b', sourceIndices: [] }];
|
|
356
|
+
return messages.map(group => ({
|
|
357
|
+
message: group.length === 1 ? group[0].element : group.map(item => item.element),
|
|
358
|
+
sourceIndices: [...new Set(group.map(item => item.sourceIndex))],
|
|
359
|
+
}));
|
|
345
360
|
}
|
|
346
361
|
function isStandaloneElement(element) {
|
|
347
362
|
return typeof element !== 'string' && ICQQ_STANDALONE_ELEMENTS.has(element.type);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-icqq",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.18",
|
|
4
4
|
"description": "Zhin.js ICQQ adapter — direct connection to @icqqjs/icqq",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -33,24 +33,27 @@
|
|
|
33
33
|
"directory": "plugins/adapters/icqq"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@zhin.js/adapter": "1.1.
|
|
36
|
+
"@zhin.js/adapter": "1.1.25",
|
|
37
37
|
"@zhin.js/agent-feature": "1.1.10",
|
|
38
|
-
"@zhin.js/
|
|
38
|
+
"@zhin.js/client": "1.1.21",
|
|
39
|
+
"@zhin.js/console-contract": "1.1.3",
|
|
40
|
+
"@zhin.js/core": "1.1.53",
|
|
39
41
|
"@zhin.js/feature-kit": "1.1.9",
|
|
40
|
-
"@zhin.js/im-contract": "1.1.
|
|
42
|
+
"@zhin.js/im-contract": "1.1.9",
|
|
41
43
|
"@zhin.js/logger": "1.1.2",
|
|
44
|
+
"@zhin.js/page": "1.1.10",
|
|
42
45
|
"@zhin.js/permission": "1.1.9",
|
|
43
46
|
"@zhin.js/skill": "1.1.10",
|
|
44
|
-
"@zhin.js/tool": "1.1.
|
|
47
|
+
"@zhin.js/tool": "1.1.12"
|
|
45
48
|
},
|
|
46
49
|
"peerDependencies": {
|
|
47
50
|
"@icqqjs/icqq": "^1.12.2",
|
|
48
51
|
"zod": "^4.0.0",
|
|
49
|
-
"@zhin.js/adapter": "^1.1.
|
|
52
|
+
"@zhin.js/adapter": "^1.1.25",
|
|
50
53
|
"@zhin.js/command": "^1.1.11",
|
|
51
|
-
"@zhin.js/core": "^1.1.
|
|
54
|
+
"@zhin.js/core": "^1.1.53",
|
|
52
55
|
"@zhin.js/prompt-section": "^1.1.9",
|
|
53
|
-
"zhin.js": "^1.1.
|
|
56
|
+
"zhin.js": "^1.1.18"
|
|
54
57
|
},
|
|
55
58
|
"peerDependenciesMeta": {
|
|
56
59
|
"@icqqjs/icqq": {
|
|
@@ -76,11 +79,12 @@
|
|
|
76
79
|
"vitest": "^4.1.10",
|
|
77
80
|
"zod": "^4.4.3",
|
|
78
81
|
"@zhin.js/prompt-section": "1.1.9",
|
|
79
|
-
"zhin.js": "1.1.
|
|
82
|
+
"zhin.js": "1.1.18"
|
|
80
83
|
},
|
|
81
84
|
"files": [
|
|
82
85
|
"adapters",
|
|
83
86
|
"commands",
|
|
87
|
+
"pages",
|
|
84
88
|
"plugin.js",
|
|
85
89
|
"schema.json",
|
|
86
90
|
"src",
|
|
@@ -124,6 +128,10 @@
|
|
|
124
128
|
{
|
|
125
129
|
"package": "@zhin.js/agent-feature",
|
|
126
130
|
"api": "^1.0.0"
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"package": "@zhin.js/page",
|
|
134
|
+
"api": "^1.0.0"
|
|
127
135
|
}
|
|
128
136
|
],
|
|
129
137
|
"plugins": []
|