@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,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
+ };
@@ -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<string>;
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, formatOutboundMessages, normalizeIcqqImageMedia, icqqInboundConversation, icqqOutboundTarget, } from './protocol.js';
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 = formatOutboundMessages(prepared.content);
471
+ const messages = formatOutboundMessageParts(prepared.content);
309
472
  const parsed = icqqOutboundTarget(conversation);
310
- let messageId;
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 parts are already delivered: a retry would duplicate them.
328
- throw new EndpointDeliveryError('partial_delivery', `ICQQ delivered ${index} of ${messages.length} message parts`, 'unknown', { cause: error });
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 ??= id;
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
- // Native share / file APIs return no id; the reply is confirmed by any part that has one.
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 messageId;
503
+ return { parts };
341
504
  }
342
505
  finally {
343
- prepared.dispose();
344
- }
345
- }
346
- /**
347
- * ICQQ's share API returns no message id; a resolved call only means the server accepted it.
348
- * The card is the bot's own newest card message in the conversation history, so read the id
349
- * from there (once more after a short wait, history can lag). Undefined keeps the delivery
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 { message_id: await this.#findOwnCard(() => friend.getChatHistory(), sentAt) };
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 { message_id: await this.#findOwnCard(() => group.getChatHistory(), sentAt) };
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
- return isWireSegment(payload) ? formatOutboundMessages([payload]) : [formatOutboundBody(payload)];
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 element of formatOutboundElements(payload)) {
334
- if (isStandaloneElement(element))
335
- groups.push([element], []);
336
- else
337
- groups.at(-1).push(element);
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 => group.length === 1 ? group[0] : 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.17",
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.24",
36
+ "@zhin.js/adapter": "1.1.25",
37
37
  "@zhin.js/agent-feature": "1.1.10",
38
- "@zhin.js/core": "1.1.52",
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.8",
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.11"
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.24",
52
+ "@zhin.js/adapter": "^1.1.25",
50
53
  "@zhin.js/command": "^1.1.11",
51
- "@zhin.js/core": "^1.1.52",
54
+ "@zhin.js/core": "^1.1.53",
52
55
  "@zhin.js/prompt-section": "^1.1.9",
53
- "zhin.js": "^1.1.17"
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.17"
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": []