@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/lib/endpoint.js CHANGED
@@ -8,12 +8,13 @@ import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logge
8
8
  import { runIcqqLoginAssistStep } from './icqq-login-assist.js';
9
9
  import { IcqqContentResolver } from './content-resolver.js';
10
10
  import { InboundMessageDeduper, findIcqqNestedMessageSource, isIcqqBotMentioned, isIcqqMessagePostType, normalizeIcqqInboundMessage, messageLookupFromIcqqSource, resolveIcqqQuoteIdFromEvent, resolveIcqqInboundMedia, resolveIcqqSelfUserId, shouldSkipSelfInboundMessage, } from './icqq-inbound.js';
11
- import { isIcqqNoticePayload, isIcqqRequestPayload, } from './icqq-inbox.js';
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
+ import { inspectGroupMemberProfile, inspectGroupScene } from './group-scene.js';
17
18
  const BOUND_EVENTS = [
18
19
  'message.private',
19
20
  'message.group',
@@ -55,8 +56,16 @@ export class IcqqEndpoint extends Endpoint {
55
56
  #inboundOwner = new AsyncLocalStorage();
56
57
  #contentResolver;
57
58
  content;
59
+ loginControl;
60
+ #reloginTimer;
61
+ #reloginAttempts = 0;
62
+ #reloginInFlight;
63
+ #offlineNoticeAt = 0;
64
+ #challengeNoticeAt = 0;
58
65
  management = Object.freeze({
59
66
  inspectGroupMember: (group, user, signal) => inspectGroupMember(this.client, group, user, signal),
67
+ inspectGroupScene: (group, signal) => inspectGroupScene(this.client, group, signal),
68
+ inspectGroupMemberProfile: (group, user, signal) => inspectGroupMemberProfile(this.client, group, user, signal),
60
69
  moderateGroupMember: (group, user, action, signal) => moderateGroupMember(this.client, group, user, action, signal, () => this.#open && !this.#retiring && !this.#stopped),
61
70
  listFriends: async () => Array.from(this.client.fl.values()).map((f) => ({
62
71
  user_id: f.user_id,
@@ -140,6 +149,19 @@ export class IcqqEndpoint extends Endpoint {
140
149
  this.#logger = getAdapterLogger('icqq', options.config.id);
141
150
  this.#options = options;
142
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
+ });
143
165
  }
144
166
  #handleBackgroundFailure(operation, error) {
145
167
  if (!this.#started || this.#stopped || this.#retiring)
@@ -159,6 +181,148 @@ export class IcqqEndpoint extends Endpoint {
159
181
  // reconn_interval policy. No account-level logout or second retry timer.
160
182
  this.client.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
161
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
+ }
162
326
  #ignoreReactionAck(action, task) {
163
327
  void Promise.resolve(task).catch((error) => {
164
328
  this.#logger.debug(formatCompact({
@@ -207,6 +371,7 @@ export class IcqqEndpoint extends Endpoint {
207
371
  this.#logger.info(`connected (direct) | friends: ${this.client.fl.size} | groups: ${this.client.gl.size}`);
208
372
  }
209
373
  catch (error) {
374
+ this.#notifyOffline('start_failed', false);
210
375
  await this.stop();
211
376
  this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
212
377
  throw error;
@@ -240,6 +405,7 @@ export class IcqqEndpoint extends Endpoint {
240
405
  }
241
406
  async stop() {
242
407
  this.#stopped = true;
408
+ this.#clearReloginTimer();
243
409
  this.#assertExternalLifecycle('stop');
244
410
  this.#armRetirementFence();
245
411
  await Promise.allSettled([...this.#inflightInbound]);
@@ -302,10 +468,10 @@ export class IcqqEndpoint extends Endpoint {
302
468
  : { content: payload, dispose() { } };
303
469
  try {
304
470
  // Standalone elements (share / file / …) are split into ordered messages.
305
- const messages = formatOutboundMessages(prepared.content);
471
+ const messages = formatOutboundMessageParts(prepared.content);
306
472
  const parsed = icqqOutboundTarget(conversation);
307
- let messageId;
308
- for (const [index, message] of messages.entries()) {
473
+ const parts = [];
474
+ for (const [index, { message, sourceIndices }] of messages.entries()) {
309
475
  const preview = formatIcqqSendablePreview(message);
310
476
  let result;
311
477
  try {
@@ -321,60 +487,43 @@ export class IcqqEndpoint extends Endpoint {
321
487
  }));
322
488
  if (index === 0)
323
489
  throw error;
324
- // Earlier parts are already delivered: a retry would duplicate them.
325
- 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 });
326
492
  }
327
493
  const rawId = result?.message_id;
328
494
  const id = (typeof rawId === 'string' || (typeof rawId === 'number' && Number.isFinite(rawId))) && String(rawId).trim() !== ''
329
495
  ? String(rawId) : undefined;
330
- messageId ??= id;
496
+ parts.push({ ...(id ? { messageId: id } : {}), ...(sourceIndices ? { sourceIndices } : {}) });
331
497
  this.#logger.info(`send ${conversation.kind}:${conversation.id} | id: ${id ?? '-'} | ${truncatePreview(preview, 80)}`);
332
498
  }
333
- // Native share / file APIs return no id; the reply is confirmed by any part that has one.
334
- if (!messageId) {
335
- 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 });
336
502
  }
337
- return messageId;
503
+ return { parts };
338
504
  }
339
505
  finally {
340
- prepared.dispose();
341
- }
342
- }
343
- /**
344
- * ICQQ's share API returns no message id; a resolved call only means the server accepted it.
345
- * The card is the bot's own newest card message in the conversation history, so read the id
346
- * from there (once more after a short wait, history can lag). Undefined keeps the delivery
347
- * unconfirmed rather than inventing an id.
348
- */
349
- async #findOwnCard(history, sentAt) {
350
- const self = Number(this.client.uin);
351
- for (const wait of [0, 500]) {
352
- if (wait)
353
- await new Promise(resolve => setTimeout(resolve, wait));
354
- // A missing or throwing history API must not turn an accepted share into a send failure.
355
- const messages = await Promise.resolve().then(history).catch(() => []);
356
- const card = [...messages].reverse().find(item => Number(item.sender?.user_id ?? item.user_id) === self
357
- && (item.time ?? 0) >= sentAt - 2
358
- && (item.message ?? []).some(element => element.type === 'json' || element.type === 'xml' || element.type === 'share'));
359
- if (card && card.message_id != null && String(card.message_id).trim())
360
- 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
+ }
361
513
  }
362
- return undefined;
363
514
  }
364
515
  async #sendNative(target, message) {
365
516
  if (isIcqqShareElement(message)) {
366
517
  switch (target.kind) {
367
518
  case 'private': {
368
- const sentAt = Math.floor(Date.now() / 1000);
369
519
  const friend = this.client.pickFriend(target.userId);
370
520
  await friend.share(message);
371
- return { message_id: await this.#findOwnCard(() => friend.getChatHistory(), sentAt) };
521
+ return {};
372
522
  }
373
523
  case 'group': {
374
- const sentAt = Math.floor(Date.now() / 1000);
375
524
  const group = this.client.pickGroup(target.groupId);
376
525
  await group.share(message);
377
- return { message_id: await this.#findOwnCard(() => group.getChatHistory(), sentAt) };
526
+ return {};
378
527
  }
379
528
  case 'temp':
380
529
  await this.client.pickMember(target.groupId, target.userId).share(message);
@@ -538,12 +687,16 @@ export class IcqqEndpoint extends Endpoint {
538
687
  this.#bindSystemOnlineEvent();
539
688
  this.client.on('system.offline', (event) => {
540
689
  this.#logSystemEvent('offline', event);
690
+ this.#notifyOffline('offline', this.#options.config.autoReconnect);
541
691
  });
542
692
  this.client.on('system.offline.network', (event) => {
543
693
  this.#logSystemEvent('offline.network', event);
694
+ this.#notifyOffline('network', this.#options.config.autoReconnect);
544
695
  });
545
696
  this.client.on('system.offline.kickoff', (event) => {
546
697
  this.#logSystemEvent('offline.kickoff', event);
698
+ this.#notifyOffline('kickoff', this.#options.config.autoReconnect);
699
+ this.#scheduleRelogin('kickoff');
547
700
  });
548
701
  this.client.on('system.login.qrcode', (event) => {
549
702
  this.#logger.info('icqq 收到二维码登录事件,等待 LoginAssist / 终端确认后继续');
@@ -558,6 +711,8 @@ export class IcqqEndpoint extends Endpoint {
558
711
  this.client.on('system.login.error', (event) => {
559
712
  this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'login_error');
560
713
  this.#logSystemEvent('login.error', event);
714
+ this.#notifyOffline('login_error', this.#options.config.autoReconnect);
715
+ this.#scheduleRelogin('login_error');
561
716
  });
562
717
  this.client.on('system.login.auth', (event) => {
563
718
  this.#handleLoginChallenge('auth', event);
@@ -568,6 +723,9 @@ export class IcqqEndpoint extends Endpoint {
568
723
  if (this.#retiring)
569
724
  return;
570
725
  this.#everOnline = true;
726
+ this.#clearReloginTimer();
727
+ this.#reloginAttempts = 0;
728
+ this.#notifyRecovered();
571
729
  try {
572
730
  this.#resolveStartOnline?.();
573
731
  this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
@@ -625,6 +783,8 @@ export class IcqqEndpoint extends Endpoint {
625
783
  ? event
626
784
  : null);
627
785
  this.#logSystemEvent(`login.${step}`, event);
786
+ this.#clearReloginTimer();
787
+ this.#notifyChallenge(step);
628
788
  void runIcqqLoginAssistStep({
629
789
  assist: this.#options.loginAssist,
630
790
  locale: this.#options.locale,
@@ -743,6 +903,7 @@ export class IcqqEndpoint extends Endpoint {
743
903
  channelType: normalized.channelType,
744
904
  channelId: normalized.channelId,
745
905
  channelParentGroupId: normalized.channelParentGroupId,
906
+ name: normalized.groupName,
746
907
  });
747
908
  const quoteId = resolveIcqqQuoteIdFromEvent(data);
748
909
  const quoted = messageLookupFromIcqqSource(findIcqqNestedMessageSource(data));
@@ -857,13 +1018,7 @@ export class IcqqEndpoint extends Endpoint {
857
1018
  #onNoticeEvent(payload) {
858
1019
  if (!payload || !isIcqqNoticePayload(payload))
859
1020
  return;
860
- const key = [
861
- payload.time,
862
- payload.operator_id ?? payload.user_id,
863
- payload.notice_type ?? payload.type,
864
- payload.sub_type,
865
- ].join('_');
866
- if (!this.#sideEventDeduper.shouldProcess(`notice:${key}`))
1021
+ if (!this.#sideEventDeduper.shouldProcess(icqqNoticeDedupeKey(payload)))
867
1022
  return;
868
1023
  this.#dispatchIcqqSideEvent(payload);
869
1024
  }
@@ -3,17 +3,22 @@ export async function inspectGroupMember(client, groupId, userId, signal) {
3
3
  signal.throwIfAborted();
4
4
  const group = numericId(groupId);
5
5
  const user = numericId(userId);
6
+ const selfLookup = client.getGroupMemberInfo(group, client.uin, true);
6
7
  const [self, member] = await Promise.all([
7
- client.getGroupMemberInfo(group, client.uin, true),
8
- client.getGroupMemberInfo(group, user, true),
8
+ selfLookup,
9
+ user === client.uin ? selfLookup : client.getGroupMemberInfo(group, user, true),
9
10
  ]);
10
11
  signal.throwIfAborted();
11
12
  const joinedAt = member.join_time * 1000;
13
+ const platformRole = member.user_id === user && member.group_id === group
14
+ && (member.role === 'owner' || member.role === 'admin' || member.role === 'member')
15
+ ? member.role : undefined;
12
16
  return {
13
17
  canModerate: client.isOnline() && user !== client.uin
14
18
  && self.user_id === client.uin && self.group_id === group
15
19
  && member.user_id === user && member.group_id === group
16
20
  && (self.role === 'owner' || self.role === 'admin') && member.role === 'member',
21
+ ...(platformRole ? { platformRole } : {}),
17
22
  ...(Number.isSafeInteger(joinedAt) && joinedAt > 0 && joinedAt <= Date.now() ? { joinedAt } : {}),
18
23
  };
19
24
  }
@@ -0,0 +1,11 @@
1
+ import type { Client } from '@icqqjs/icqq';
2
+ import type { GroupMemberProfile, GroupSceneFacts } from 'zhin.js/adapter';
3
+ /** Listing a very large roster only to find admins is not worth the packet; adminIds stay unknown. */
4
+ export declare const ADMIN_SCAN_MAX_MEMBERS = 1000;
5
+ /**
6
+ * ADR 0041 scene facts: one uncached group-info read, plus (small groups only) one roster read for
7
+ * admins. A roster failure leaves adminIds unknown instead of failing the other facts.
8
+ */
9
+ export declare function inspectGroupScene(client: Client, groupId: string, signal: AbortSignal): Promise<GroupSceneFacts>;
10
+ /** ADR 0041 member facts from one uncached lookup (same rationale as the agent `freshMember` helper). */
11
+ export declare function inspectGroupMemberProfile(client: Client, groupId: string, userId: string, signal: AbortSignal): Promise<GroupMemberProfile>;
@@ -0,0 +1,71 @@
1
+ /** Listing a very large roster only to find admins is not worth the packet; adminIds stay unknown. */
2
+ export const ADMIN_SCAN_MAX_MEMBERS = 1000;
3
+ const MAX_ADMIN_IDS = 20;
4
+ function positiveId(value) {
5
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? String(value) : undefined;
6
+ }
7
+ function numericId(value) {
8
+ const id = Number(value);
9
+ if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(id))
10
+ throw new Error('Invalid QQ identifier');
11
+ return id;
12
+ }
13
+ /**
14
+ * ADR 0041 scene facts: one uncached group-info read, plus (small groups only) one roster read for
15
+ * admins. A roster failure leaves adminIds unknown instead of failing the other facts.
16
+ */
17
+ export async function inspectGroupScene(client, groupId, signal) {
18
+ signal.throwIfAborted();
19
+ const group = numericId(groupId);
20
+ const info = await client.getGroupInfo(group, true);
21
+ signal.throwIfAborted();
22
+ if (info.group_id !== group)
23
+ return {};
24
+ const memberCount = Number.isSafeInteger(info.member_count) && info.member_count > 0 ? info.member_count : undefined;
25
+ const ownerId = positiveId(info.owner_id);
26
+ let adminIds;
27
+ if (memberCount === undefined || memberCount <= ADMIN_SCAN_MAX_MEMBERS) {
28
+ try {
29
+ const roster = await client.getGroupMemberList(group);
30
+ signal.throwIfAborted();
31
+ adminIds = [...roster.values()]
32
+ .filter(member => member.role === 'admin' && member.group_id === group)
33
+ .map(member => positiveId(member.user_id))
34
+ .filter((id) => id !== undefined)
35
+ .slice(0, MAX_ADMIN_IDS);
36
+ }
37
+ catch {
38
+ signal.throwIfAborted();
39
+ adminIds = undefined;
40
+ }
41
+ }
42
+ const name = typeof info.group_name === 'string' && info.group_name.trim() ? info.group_name.trim() : undefined;
43
+ const nowSeconds = Date.now() / 1000;
44
+ return {
45
+ ...(name ? { name } : {}),
46
+ ...(ownerId ? { ownerId } : {}),
47
+ ...(adminIds ? { adminIds } : {}),
48
+ ...(memberCount !== undefined ? { memberCount } : {}),
49
+ ...(typeof info.shutup_time_whole === 'number' ? { muteAll: info.shutup_time_whole > nowSeconds } : {}),
50
+ };
51
+ }
52
+ /** ADR 0041 member facts from one uncached lookup (same rationale as the agent `freshMember` helper). */
53
+ export async function inspectGroupMemberProfile(client, groupId, userId, signal) {
54
+ signal.throwIfAborted();
55
+ const group = numericId(groupId);
56
+ const user = numericId(userId);
57
+ const member = await client.getGroupMemberInfo(group, user, true);
58
+ signal.throwIfAborted();
59
+ if (member.user_id !== user || member.group_id !== group)
60
+ return {};
61
+ const card = typeof member.card === 'string' && member.card.trim() ? member.card.trim() : undefined;
62
+ const title = typeof member.title === 'string' && member.title.trim() ? member.title.trim() : undefined;
63
+ const shutupSeconds = member.shutup_time;
64
+ return {
65
+ ...(member.role === 'owner' || member.role === 'admin' || member.role === 'member' ? { platformRole: member.role } : {}),
66
+ ...(card ? { card } : {}),
67
+ ...(title ? { title } : {}),
68
+ ...(typeof shutupSeconds === 'number' && Number.isFinite(shutupSeconds)
69
+ ? { mutedUntil: shutupSeconds * 1000 > Date.now() ? shutupSeconds * 1000 : 0 } : {}),
70
+ };
71
+ }
@@ -103,6 +103,8 @@ export interface NormalizedIcqqInbound {
103
103
  channelId: string;
104
104
  /** 群临时会话:来源群号 */
105
105
  channelParentGroupId?: string;
106
+ /** 群聊时来自 group_name */
107
+ groupName?: string;
106
108
  userId: string;
107
109
  nickname: string;
108
110
  /** 群聊时来自 sender.role(私聊无) */
@@ -688,6 +688,9 @@ export function normalizeIcqqInboundMessage(data) {
688
688
  channelType,
689
689
  channelId,
690
690
  channelParentGroupId,
691
+ ...(channelType === "group" && typeof ext.group_name === "string" && ext.group_name.trim()
692
+ ? { groupName: ext.group_name.trim() }
693
+ : {}),
691
694
  userId: String(userIdNum),
692
695
  nickname,
693
696
  senderRole,
@@ -8,6 +8,11 @@ export interface IcqqInboxBase {
8
8
  export declare function isIcqqRequestPayload(payload: unknown): boolean;
9
9
  /** post_type=notice / notice.* 判定。 */
10
10
  export declare function isIcqqNoticePayload(payload: unknown): boolean;
11
+ /**
12
+ * notice 去重键。成员与操作人分开记:同一管理员先后拉两个人进群时,`operator_id ?? user_id`
13
+ * 会撞键(icqq 群通知又常不带 time),后一次入群被当重复吞掉。
14
+ */
15
+ export declare function icqqNoticeDedupeKey(payload: Record<string, unknown>): string;
11
16
  /**
12
17
  * request 事件载荷 → unified_inbox_request 行。
13
18
  * platform_request_id 取 flag(审批回执句柄),缺省回退 seq;两者皆无则无法审批,丢弃。
package/lib/icqq-inbox.js CHANGED
@@ -10,6 +10,20 @@ export function isIcqqNoticePayload(payload) {
10
10
  return typeof postType === 'string'
11
11
  && (postType === 'notice' || postType.startsWith('notice.'));
12
12
  }
13
+ /**
14
+ * notice 去重键。成员与操作人分开记:同一管理员先后拉两个人进群时,`operator_id ?? user_id`
15
+ * 会撞键(icqq 群通知又常不带 time),后一次入群被当重复吞掉。
16
+ */
17
+ export function icqqNoticeDedupeKey(payload) {
18
+ return `notice:${[
19
+ payload.time,
20
+ payload.group_id,
21
+ payload.user_id,
22
+ payload.operator_id,
23
+ payload.notice_type ?? payload.type,
24
+ payload.sub_type,
25
+ ].join('_')}`;
26
+ }
13
27
  /**
14
28
  * request 事件载荷 → unified_inbox_request 行。
15
29
  * platform_request_id 取 flag(审批回执句柄),缺省回退 seq;两者皆无则无法审批,丢弃。
@@ -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;
@@ -246,6 +250,8 @@ export declare function icqqInboundConversation(endpointKey: string, input: {
246
250
  readonly channelId: string;
247
251
  readonly channelParentGroupId?: string;
248
252
  readonly guildId?: string;
253
+ /** 群名(仅 group 会话使用) */
254
+ readonly name?: string;
249
255
  }): ConversationRef;
250
256
  /**
251
257
  * 出站:从 ConversationRef 派生 action 路由。
@@ -267,4 +273,10 @@ export declare function formatOutboundBody(payload: unknown): Sendable;
267
273
  * quote or whitespace are dropped: a standalone element cannot carry the quote.
268
274
  */
269
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[];
270
282
  export declare function formatInboundContent(rawMessage: string): string;