@zhin.js/adapter-icqq 1.1.16 → 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 (75) hide show
  1. package/CHANGELOG.md +72 -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/agents/icqq/members.js +16 -0
  6. package/agents/icqq/members.ts +17 -0
  7. package/agents/icqq/skills/icqq-group-admin/tools/essence/index.js +1 -1
  8. package/agents/icqq/skills/icqq-group-admin/tools/essence/index.ts +1 -1
  9. package/agents/icqq/skills/icqq-group-admin/tools/group_invite/index.js +2 -2
  10. package/agents/icqq/skills/icqq-group-admin/tools/group_invite/index.ts +2 -2
  11. package/agents/icqq/skills/icqq-group-admin/tools/group_leave/index.js +1 -0
  12. package/agents/icqq/skills/icqq-group-admin/tools/group_leave/index.ts +1 -0
  13. package/agents/icqq/skills/icqq-group-admin/tools/recall_member_message/index.js +2 -1
  14. package/agents/icqq/skills/icqq-group-admin/tools/recall_member_message/index.ts +2 -1
  15. package/agents/icqq/skills/icqq-group-admin/tools/set_anonymous/index.js +2 -2
  16. package/agents/icqq/skills/icqq-group-admin/tools/set_anonymous/index.ts +2 -2
  17. package/agents/icqq/skills/icqq-group-admin/tools/set_mute/index.js +4 -4
  18. package/agents/icqq/skills/icqq-group-admin/tools/set_mute/index.ts +4 -4
  19. package/agents/icqq/skills/icqq-group-admin/tools/set_title/index.js +4 -4
  20. package/agents/icqq/skills/icqq-group-admin/tools/set_title/index.ts +4 -4
  21. package/agents/icqq/skills/icqq-group-directory/tools/group_member_info/index.js +16 -6
  22. package/agents/icqq/skills/icqq-group-directory/tools/group_member_info/index.ts +16 -5
  23. package/agents/icqq/skills/icqq-messages/tools/forward_messages/index.js +1 -1
  24. package/agents/icqq/skills/icqq-messages/tools/forward_messages/index.ts +1 -1
  25. package/agents/icqq/skills/icqq-messages/tools/quote_reply/index.js +1 -1
  26. package/agents/icqq/skills/icqq-messages/tools/quote_reply/index.ts +1 -1
  27. package/agents/icqq/skills/icqq-messages/tools/recall_own_message/index.js +1 -1
  28. package/agents/icqq/skills/icqq-messages/tools/recall_own_message/index.ts +1 -1
  29. package/agents/icqq/system.md +3 -3
  30. package/commands/icqq/login/code/index.js +28 -0
  31. package/commands/icqq/login/code/index.ts +30 -0
  32. package/commands/icqq/login/confirm/index.js +20 -0
  33. package/commands/icqq/login/confirm/index.ts +21 -0
  34. package/commands/icqq/login/definition.js +65 -0
  35. package/commands/icqq/login/definition.ts +72 -0
  36. package/commands/icqq/login/qr/index.js +53 -0
  37. package/commands/icqq/login/qr/index.ts +55 -0
  38. package/commands/icqq/login/retry/index.js +21 -0
  39. package/commands/icqq/login/retry/index.ts +23 -0
  40. package/commands/icqq/login/status/index.js +31 -0
  41. package/commands/icqq/login/status/index.ts +34 -0
  42. package/lib/console-spec.d.ts +17 -0
  43. package/lib/console-spec.js +11 -0
  44. package/lib/console.d.ts +8 -0
  45. package/lib/console.js +16 -0
  46. package/lib/endpoint.d.ts +9 -2
  47. package/lib/endpoint.js +200 -45
  48. package/lib/group-moderation.js +7 -2
  49. package/lib/group-scene.d.ts +11 -0
  50. package/lib/group-scene.js +71 -0
  51. package/lib/icqq-inbound.d.ts +2 -0
  52. package/lib/icqq-inbound.js +3 -0
  53. package/lib/icqq-inbox.d.ts +5 -0
  54. package/lib/icqq-inbox.js +14 -0
  55. package/lib/icqq-login-control.d.ts +18 -0
  56. package/lib/icqq-login-control.js +8 -0
  57. package/lib/index.d.ts +1 -1
  58. package/lib/index.js +1 -1
  59. package/lib/protocol.d.ts +12 -0
  60. package/lib/protocol.js +28 -11
  61. package/package.json +17 -9
  62. package/pages/index/index.js +15 -0
  63. package/pages/index/index.ts +16 -0
  64. package/plugin.js +6 -1
  65. package/schema.json +174 -0
  66. package/src/console-spec.ts +17 -0
  67. package/src/console.ts +21 -0
  68. package/src/endpoint.ts +199 -49
  69. package/src/group-moderation.ts +7 -2
  70. package/src/group-scene.ts +76 -0
  71. package/src/icqq-inbound.ts +5 -0
  72. package/src/icqq-inbox.ts +15 -0
  73. package/src/icqq-login-control.ts +30 -0
  74. package/src/index.ts +2 -0
  75. package/src/protocol.ts +38 -10
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 {
@@ -39,6 +41,7 @@ import {
39
41
  type IcqqMessageEvent,
40
42
  } from './icqq-inbound.js';
41
43
  import {
44
+ icqqNoticeDedupeKey,
42
45
  isIcqqNoticePayload,
43
46
  isIcqqRequestPayload,
44
47
  } from './icqq-inbox.js';
@@ -52,7 +55,7 @@ import {
52
55
  } from './outbound-media.js';
53
56
  import {
54
57
  formatInboundContent,
55
- formatOutboundMessages,
58
+ formatOutboundMessageParts,
56
59
  normalizeIcqqImageMedia,
57
60
  icqqInboundConversation,
58
61
  icqqOutboundTarget,
@@ -62,12 +65,15 @@ import {
62
65
  import type { SystemMessage as IcqqSystemMessage } from './types.js';
63
66
  import { ManagedIcqqClient, type IcqqBackgroundOperation } from './managed-client.js';
64
67
  import { inspectGroupMember, moderateGroupMember } from './group-moderation.js';
68
+ import { inspectGroupMemberProfile, inspectGroupScene } from './group-scene.js';
65
69
 
66
70
  export interface IcqqEndpointOptions {
67
71
  readonly id: CapabilityId;
68
72
  readonly config: ResolvedIcqqConfig;
69
73
  readonly loginAssist: LoginAssist;
70
74
  readonly locale?: import('zhin.js/i18n').Locale;
75
+ /** Out-of-band outage notifier; absent in tests/hosts without outbound. */
76
+ readonly notifier?: AdapterOutageNotifier;
71
77
  }
72
78
 
73
79
  const BOUND_EVENTS = [
@@ -112,9 +118,17 @@ export class IcqqEndpoint extends Endpoint<Client> {
112
118
  readonly #inboundOwner = new AsyncLocalStorage<symbol>();
113
119
  readonly #contentResolver: IcqqContentResolver;
114
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;
115
127
 
116
128
  readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
117
129
  inspectGroupMember: (group, user, signal) => inspectGroupMember(this.client, group, user, signal),
130
+ inspectGroupScene: (group, signal) => inspectGroupScene(this.client, group, signal),
131
+ inspectGroupMemberProfile: (group, user, signal) => inspectGroupMemberProfile(this.client, group, user, signal),
118
132
  moderateGroupMember: (group, user, action, signal) => moderateGroupMember(
119
133
  this.client, group, user, action, signal, () => this.#open && !this.#retiring && !this.#stopped,
120
134
  ),
@@ -206,6 +220,19 @@ export class IcqqEndpoint extends Endpoint<Client> {
206
220
  this.#logger = getAdapterLogger('icqq', options.config.id);
207
221
  this.#options = options;
208
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
+ });
209
236
  }
210
237
 
211
238
  #handleBackgroundFailure(operation: IcqqBackgroundOperation, error: unknown): void {
@@ -224,6 +251,141 @@ export class IcqqEndpoint extends Endpoint<Client> {
224
251
  this.client.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
225
252
  }
226
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
+
227
389
  #ignoreReactionAck(action: 'add' | 'remove', task: Promise<unknown> | unknown): void {
228
390
  void Promise.resolve(task).catch((error) => {
229
391
  this.#logger.debug(formatCompact({
@@ -270,6 +432,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
270
432
  `connected (direct) | friends: ${this.client.fl.size} | groups: ${this.client.gl.size}`,
271
433
  );
272
434
  } catch (error) {
435
+ this.#notifyOffline('start_failed', false);
273
436
  await this.stop();
274
437
  this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
275
438
  throw error;
@@ -304,6 +467,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
304
467
 
305
468
  async stop(): Promise<void> {
306
469
  this.#stopped = true;
470
+ this.#clearReloginTimer();
307
471
  this.#assertExternalLifecycle('stop');
308
472
  this.#armRetirementFence();
309
473
  await Promise.allSettled([...this.#inflightInbound]);
@@ -354,7 +518,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
354
518
  });
355
519
  }
356
520
 
357
- async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
521
+ async send({ conversation, payload }: EndpointSendRequest): Promise<EndpointSendResult> {
358
522
  if (!this.#started) throw new EndpointDeliveryError('endpoint_disconnected', 'icqq endpoint 未连接', 'not_sent');
359
523
  const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
360
524
  const prepared = isIcqqSegmentPayload(payload)
@@ -362,10 +526,10 @@ export class IcqqEndpoint extends Endpoint<Client> {
362
526
  : { content: payload, dispose() {} };
363
527
  try {
364
528
  // Standalone elements (share / file / …) are split into ordered messages.
365
- const messages = formatOutboundMessages(prepared.content);
529
+ const messages = formatOutboundMessageParts(prepared.content);
366
530
  const parsed = icqqOutboundTarget(conversation);
367
- let messageId: string | undefined;
368
- for (const [index, message] of messages.entries()) {
531
+ const parts: EndpointSendPart[] = [];
532
+ for (const [index, { message, sourceIndices }] of messages.entries()) {
369
533
  const preview = formatIcqqSendablePreview(message);
370
534
  let result: { message_id?: unknown };
371
535
  try {
@@ -379,48 +543,30 @@ export class IcqqEndpoint extends Endpoint<Client> {
379
543
  preview: truncatePreview(preview, 120),
380
544
  }));
381
545
  if (index === 0) throw error;
382
- // Earlier parts are already delivered: a retry would duplicate them.
383
- throw new EndpointDeliveryError('partial_delivery', `ICQQ delivered ${index} of ${messages.length} message parts`, 'unknown', { cause: error });
546
+ // Earlier sends completed; retrying can duplicate accepted messages, even without ids.
547
+ throw new EndpointDeliveryError('partial_delivery', `ICQQ completed ${index} of ${messages.length} message sends`, 'unknown', { cause: error, parts });
384
548
  }
385
549
  const rawId = result?.message_id;
386
550
  const id = (typeof rawId === 'string' || (typeof rawId === 'number' && Number.isFinite(rawId))) && String(rawId).trim() !== ''
387
551
  ? String(rawId) : undefined;
388
- messageId ??= id;
552
+ parts.push({ ...(id ? { messageId: id } : {}), ...(sourceIndices ? { sourceIndices } : {}) });
389
553
  this.#logger.info(
390
554
  `send ${conversation.kind}:${conversation.id} | id: ${id ?? '-'} | ${truncatePreview(preview, 80)}`,
391
555
  );
392
556
  }
393
- // Native share / file APIs return no id; the reply is confirmed by any part that has one.
394
- if (!messageId) {
395
- 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 });
396
560
  }
397
- return messageId;
561
+ return { parts };
398
562
  } finally {
399
- prepared.dispose();
400
- }
401
- }
402
-
403
- /**
404
- * ICQQ's share API returns no message id; a resolved call only means the server accepted it.
405
- * The card is the bot's own newest card message in the conversation history, so read the id
406
- * from there (once more after a short wait, history can lag). Undefined keeps the delivery
407
- * unconfirmed rather than inventing an id.
408
- */
409
- async #findOwnCard(
410
- history: () => Promise<readonly { message_id?: unknown; time?: number; message?: readonly { type?: string }[]; sender?: { user_id?: number }; user_id?: number }[]>,
411
- sentAt: number,
412
- ): Promise<string | undefined> {
413
- const self = Number(this.client.uin);
414
- for (const wait of [0, 500]) {
415
- if (wait) await new Promise(resolve => setTimeout(resolve, wait));
416
- // A missing or throwing history API must not turn an accepted share into a send failure.
417
- const messages = await Promise.resolve().then(history).catch(() => []);
418
- const card = [...messages].reverse().find(item => Number(item.sender?.user_id ?? item.user_id) === self
419
- && (item.time ?? 0) >= sentAt - 2
420
- && (item.message ?? []).some(element => element.type === 'json' || element.type === 'xml' || element.type === 'share'));
421
- 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
+ }
422
569
  }
423
- return undefined;
424
570
  }
425
571
 
426
572
  async #sendNative(
@@ -430,16 +576,14 @@ export class IcqqEndpoint extends Endpoint<Client> {
430
576
  if (isIcqqShareElement(message)) {
431
577
  switch (target.kind) {
432
578
  case 'private': {
433
- const sentAt = Math.floor(Date.now() / 1000);
434
579
  const friend = this.client.pickFriend(target.userId);
435
580
  await friend.share(message);
436
- return { message_id: await this.#findOwnCard(() => friend.getChatHistory(), sentAt) };
581
+ return {};
437
582
  }
438
583
  case 'group': {
439
- const sentAt = Math.floor(Date.now() / 1000);
440
584
  const group = this.client.pickGroup(target.groupId);
441
585
  await group.share(message);
442
- return { message_id: await this.#findOwnCard(() => group.getChatHistory(), sentAt) };
586
+ return {};
443
587
  }
444
588
  case 'temp':
445
589
  await this.client.pickMember(target.groupId, target.userId).share(message);
@@ -610,12 +754,16 @@ export class IcqqEndpoint extends Endpoint<Client> {
610
754
  this.#bindSystemOnlineEvent();
611
755
  this.client.on('system.offline', (event) => {
612
756
  this.#logSystemEvent('offline', event);
757
+ this.#notifyOffline('offline', this.#options.config.autoReconnect);
613
758
  });
614
759
  this.client.on('system.offline.network', (event) => {
615
760
  this.#logSystemEvent('offline.network', event);
761
+ this.#notifyOffline('network', this.#options.config.autoReconnect);
616
762
  });
617
763
  this.client.on('system.offline.kickoff', (event) => {
618
764
  this.#logSystemEvent('offline.kickoff', event);
765
+ this.#notifyOffline('kickoff', this.#options.config.autoReconnect);
766
+ this.#scheduleRelogin('kickoff');
619
767
  });
620
768
  this.client.on('system.login.qrcode', (event) => {
621
769
  this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
@@ -630,6 +778,8 @@ export class IcqqEndpoint extends Endpoint<Client> {
630
778
  this.client.on('system.login.error', (event) => {
631
779
  this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
632
780
  this.#logSystemEvent('login.error', event);
781
+ this.#notifyOffline('login_error', this.#options.config.autoReconnect);
782
+ this.#scheduleRelogin('login_error');
633
783
  });
634
784
  this.client.on('system.login.auth', (event) => {
635
785
  this.#handleLoginChallenge('auth', event);
@@ -640,6 +790,9 @@ export class IcqqEndpoint extends Endpoint<Client> {
640
790
  this.client.on('system.online', (event) => {
641
791
  if (this.#retiring) return;
642
792
  this.#everOnline = true;
793
+ this.#clearReloginTimer();
794
+ this.#reloginAttempts = 0;
795
+ this.#notifyRecovered();
643
796
  try {
644
797
  this.#resolveStartOnline?.();
645
798
  this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
@@ -703,6 +856,8 @@ export class IcqqEndpoint extends Endpoint<Client> {
703
856
  ? event as Record<string, unknown>
704
857
  : null);
705
858
  this.#logSystemEvent(`login.${step}`, event);
859
+ this.#clearReloginTimer();
860
+ this.#notifyChallenge(step);
706
861
  void runIcqqLoginAssistStep({
707
862
  assist: this.#options.loginAssist,
708
863
  locale: this.#options.locale,
@@ -823,6 +978,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
823
978
  channelType: normalized.channelType,
824
979
  channelId: normalized.channelId,
825
980
  channelParentGroupId: normalized.channelParentGroupId,
981
+ name: normalized.groupName,
826
982
  });
827
983
  const quoteId = resolveIcqqQuoteIdFromEvent(data);
828
984
  const quoted = messageLookupFromIcqqSource(findIcqqNestedMessageSource(data));
@@ -942,13 +1098,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
942
1098
 
943
1099
  #onNoticeEvent(payload: Record<string, unknown> | null): void {
944
1100
  if (!payload || !isIcqqNoticePayload(payload)) return;
945
- const key = [
946
- payload.time,
947
- payload.operator_id ?? payload.user_id,
948
- payload.notice_type ?? payload.type,
949
- payload.sub_type,
950
- ].join('_');
951
- if (!this.#sideEventDeduper.shouldProcess(`notice:${key}`)) return;
1101
+ if (!this.#sideEventDeduper.shouldProcess(icqqNoticeDedupeKey(payload))) return;
952
1102
  this.#dispatchIcqqSideEvent(payload);
953
1103
  }
954
1104
 
@@ -8,17 +8,22 @@ export async function inspectGroupMember(
8
8
  signal.throwIfAborted();
9
9
  const group = numericId(groupId);
10
10
  const user = numericId(userId);
11
+ const selfLookup = client.getGroupMemberInfo(group, client.uin, true);
11
12
  const [self, member] = await Promise.all([
12
- client.getGroupMemberInfo(group, client.uin, true),
13
- client.getGroupMemberInfo(group, user, true),
13
+ selfLookup,
14
+ user === client.uin ? selfLookup : client.getGroupMemberInfo(group, user, true),
14
15
  ]);
15
16
  signal.throwIfAborted();
16
17
  const joinedAt = member.join_time * 1000;
18
+ const platformRole = member.user_id === user && member.group_id === group
19
+ && (member.role === 'owner' || member.role === 'admin' || member.role === 'member')
20
+ ? member.role : undefined;
17
21
  return {
18
22
  canModerate: client.isOnline() && user !== client.uin
19
23
  && self.user_id === client.uin && self.group_id === group
20
24
  && member.user_id === user && member.group_id === group
21
25
  && (self.role === 'owner' || self.role === 'admin') && member.role === 'member',
26
+ ...(platformRole ? { platformRole } : {}),
22
27
  ...(Number.isSafeInteger(joinedAt) && joinedAt > 0 && joinedAt <= Date.now() ? { joinedAt } : {}),
23
28
  };
24
29
  }
@@ -0,0 +1,76 @@
1
+ import type { Client } from '@icqqjs/icqq';
2
+ import type { GroupMemberProfile, GroupSceneFacts } from 'zhin.js/adapter';
3
+
4
+ /** Listing a very large roster only to find admins is not worth the packet; adminIds stay unknown. */
5
+ export const ADMIN_SCAN_MAX_MEMBERS = 1000;
6
+ const MAX_ADMIN_IDS = 20;
7
+
8
+ function positiveId(value: unknown): string | undefined {
9
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? String(value) : undefined;
10
+ }
11
+
12
+ function numericId(value: string): number {
13
+ const id = Number(value);
14
+ if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(id)) throw new Error('Invalid QQ identifier');
15
+ return id;
16
+ }
17
+
18
+ /**
19
+ * ADR 0041 scene facts: one uncached group-info read, plus (small groups only) one roster read for
20
+ * admins. A roster failure leaves adminIds unknown instead of failing the other facts.
21
+ */
22
+ export async function inspectGroupScene(client: Client, groupId: string, signal: AbortSignal): Promise<GroupSceneFacts> {
23
+ signal.throwIfAborted();
24
+ const group = numericId(groupId);
25
+ const info = await client.getGroupInfo(group, true);
26
+ signal.throwIfAborted();
27
+ if (info.group_id !== group) return {};
28
+ const memberCount = Number.isSafeInteger(info.member_count) && info.member_count > 0 ? info.member_count : undefined;
29
+ const ownerId = positiveId(info.owner_id);
30
+ let adminIds: string[] | undefined;
31
+ if (memberCount === undefined || memberCount <= ADMIN_SCAN_MAX_MEMBERS) {
32
+ try {
33
+ const roster = await client.getGroupMemberList(group);
34
+ signal.throwIfAborted();
35
+ adminIds = [...roster.values()]
36
+ .filter(member => member.role === 'admin' && member.group_id === group)
37
+ .map(member => positiveId(member.user_id))
38
+ .filter((id): id is string => id !== undefined)
39
+ .slice(0, MAX_ADMIN_IDS);
40
+ } catch {
41
+ signal.throwIfAborted();
42
+ adminIds = undefined;
43
+ }
44
+ }
45
+ const name = typeof info.group_name === 'string' && info.group_name.trim() ? info.group_name.trim() : undefined;
46
+ const nowSeconds = Date.now() / 1000;
47
+ return {
48
+ ...(name ? { name } : {}),
49
+ ...(ownerId ? { ownerId } : {}),
50
+ ...(adminIds ? { adminIds } : {}),
51
+ ...(memberCount !== undefined ? { memberCount } : {}),
52
+ ...(typeof info.shutup_time_whole === 'number' ? { muteAll: info.shutup_time_whole > nowSeconds } : {}),
53
+ };
54
+ }
55
+
56
+ /** ADR 0041 member facts from one uncached lookup (same rationale as the agent `freshMember` helper). */
57
+ export async function inspectGroupMemberProfile(
58
+ client: Client, groupId: string, userId: string, signal: AbortSignal,
59
+ ): Promise<GroupMemberProfile> {
60
+ signal.throwIfAborted();
61
+ const group = numericId(groupId);
62
+ const user = numericId(userId);
63
+ const member = await client.getGroupMemberInfo(group, user, true);
64
+ signal.throwIfAborted();
65
+ if (member.user_id !== user || member.group_id !== group) return {};
66
+ const card = typeof member.card === 'string' && member.card.trim() ? member.card.trim() : undefined;
67
+ const title = typeof member.title === 'string' && member.title.trim() ? member.title.trim() : undefined;
68
+ const shutupSeconds = member.shutup_time;
69
+ return {
70
+ ...(member.role === 'owner' || member.role === 'admin' || member.role === 'member' ? { platformRole: member.role } : {}),
71
+ ...(card ? { card } : {}),
72
+ ...(title ? { title } : {}),
73
+ ...(typeof shutupSeconds === 'number' && Number.isFinite(shutupSeconds)
74
+ ? { mutedUntil: shutupSeconds * 1000 > Date.now() ? shutupSeconds * 1000 : 0 } : {}),
75
+ };
76
+ }
@@ -129,6 +129,8 @@ export interface NormalizedIcqqInbound {
129
129
  channelId: string;
130
130
  /** 群临时会话:来源群号 */
131
131
  channelParentGroupId?: string;
132
+ /** 群聊时来自 group_name */
133
+ groupName?: string;
132
134
  userId: string;
133
135
  nickname: string;
134
136
  /** 群聊时来自 sender.role(私聊无) */
@@ -847,6 +849,9 @@ export function normalizeIcqqInboundMessage(
847
849
  channelType,
848
850
  channelId,
849
851
  channelParentGroupId,
852
+ ...(channelType === "group" && typeof ext.group_name === "string" && ext.group_name.trim()
853
+ ? { groupName: ext.group_name.trim() }
854
+ : {}),
850
855
  userId: String(userIdNum),
851
856
  nickname,
852
857
  senderRole,
package/src/icqq-inbox.ts CHANGED
@@ -20,6 +20,21 @@ export function isIcqqNoticePayload(payload: unknown): boolean {
20
20
  && (postType === 'notice' || postType.startsWith('notice.'));
21
21
  }
22
22
 
23
+ /**
24
+ * notice 去重键。成员与操作人分开记:同一管理员先后拉两个人进群时,`operator_id ?? user_id`
25
+ * 会撞键(icqq 群通知又常不带 time),后一次入群被当重复吞掉。
26
+ */
27
+ export function icqqNoticeDedupeKey(payload: Record<string, unknown>): string {
28
+ return `notice:${[
29
+ payload.time,
30
+ payload.group_id,
31
+ payload.user_id,
32
+ payload.operator_id,
33
+ payload.notice_type ?? payload.type,
34
+ payload.sub_type,
35
+ ].join('_')}`;
36
+ }
37
+
23
38
  /**
24
39
  * request 事件载荷 → unified_inbox_request 行。
25
40
  * platform_request_id 取 flag(审批回执句柄),缺省回退 seq;两者皆无则无法审批,丢弃。
@@ -0,0 +1,30 @@
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
+ import type { PendingLoginTask } from '@zhin.js/core';
8
+
9
+ export interface IcqqLoginControlState {
10
+ readonly endpointId: string;
11
+ readonly transportState: string;
12
+ readonly online: boolean;
13
+ readonly pending: readonly PendingLoginTask[];
14
+ }
15
+
16
+ export interface IcqqLoginControl {
17
+ readonly endpointId: string;
18
+ state(): IcqqLoginControlState;
19
+ pending(): readonly PendingLoginTask[];
20
+ /** Force a login attempt; rejects when the endpoint is stopped or the SDK login fails. */
21
+ relogin(): Promise<void>;
22
+ submit(taskId: string, value: string | Record<string, unknown>): boolean;
23
+ cancel(taskId: string, reason?: string): boolean;
24
+ }
25
+
26
+ /** `endpoint id (QQ uin)` → control for the current generation. */
27
+ export const icqqLoginControlToken = createToken<Map<string, IcqqLoginControl>>(
28
+ 'zhin.icqq.login-control',
29
+ 'ICQQ login control surface for recovery commands',
30
+ );
package/src/index.ts CHANGED
@@ -3,12 +3,14 @@ export {
3
3
  formatInboundContent,
4
4
  formatOutboundBody,
5
5
  formatOutboundMessages,
6
+ formatOutboundMessageParts,
6
7
  icqqInboundConversation,
7
8
  icqqOutboundTarget,
8
9
  type ActionResult,
9
10
  type IcqqEndpointConfig,
10
11
  type IcqqInboundMessage,
11
12
  type IcqqWireSegment,
13
+ type IcqqOutboundMessagePart,
12
14
  type ParsedIcqqSendTarget,
13
15
  type ResolvedIcqqConfig,
14
16
  } from './protocol.js';