@zhin.js/adapter-icqq 6.0.3 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/endpoint.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { formatCompact, getLogger } from '@zhin.js/logger';
2
2
  import { registerIcqqAgentEndpoint } from './icqq-agent-deps.js';
3
3
  import { InboundMessageDeduper, findIcqqNestedMessageSource, isIcqqBotMentioned, isIcqqMessagePostType, normalizeIcqqInboundMessage, quotedPayloadFromIcqqSource, resolveIcqqQuoteIdFromEvent, shouldSkipSelfInboundMessage, unwrapIcqqIpcEventPayload, } from './icqq-inbound.js';
4
- import { isIcqqGuildIpcEvent, normalizeIcqqGuildInboundMessage, } from './icqq-guild.js';
4
+ import { buildIcqqInboxNoticeRow, buildIcqqInboxRequestRow, buildIcqqSystemRequestRow, isIcqqNoticePayload, isIcqqRequestPayload, } from './icqq-inbox.js';
5
+ import { IcqqGuildCatalog, isIcqqGuildIpcEvent, normalizeIcqqGuildInboundMessage, } from './icqq-guild.js';
5
6
  import { IpcClient } from './ipc-client.js';
6
7
  import { materializeOutboundBase64, resolveIcqqOutboundMediaMode, } from './outbound-media.js';
7
8
  import { Actions, formatInboundContent, formatInboundTarget, formatOutboundBody, parseSendTarget, } from './protocol.js';
@@ -14,10 +15,28 @@ export class IcqqIpcEndpoint {
14
15
  ipc;
15
16
  friends = new Map();
16
17
  groups = new Map();
18
+ #guildCatalog = new IcqqGuildCatalog();
19
+ management = Object.freeze({
20
+ listFriends: () => this.getFriendList(),
21
+ listGroups: () => this.getGroupList(),
22
+ listChannels: async () => {
23
+ await this.#guildCatalog.syncAll(this.ipc);
24
+ return this.#guildCatalog.getGuildChannelList();
25
+ },
26
+ listGroupMembers: (groupId) => this.getGroupMemberList(groupId),
27
+ approveRequest: (requestId, remark) => this.approveRequest(requestId, remark),
28
+ rejectRequest: (requestId, reason) => this.rejectRequest(requestId, reason),
29
+ kickGroupMember: (groupId, userId) => this.removeMember(groupId, userId),
30
+ muteGroupMember: (groupId, userId, duration) => this.muteMember(groupId, userId, duration),
31
+ setGroupAdmin: (groupId, userId, enabled) => this.setModerator(groupId, userId, enabled),
32
+ deleteFriend: (userId) => this.deleteFriend(userId),
33
+ });
17
34
  #open = false;
18
35
  #started = false;
19
36
  #subscriptions = [];
20
37
  #inboundDeduper = new InboundMessageDeduper();
38
+ /** request/notice 去重(推送事件与 GET_SYSTEM_MSG 首拉可能重复同一 flag)。 */
39
+ #inboxDeduper = new InboundMessageDeduper();
21
40
  #unregisterAgent;
22
41
  /** 用户主动 stop 时为 true,阻止自动重连 */
23
42
  #intentionalDisconnect = false;
@@ -71,6 +90,9 @@ export class IcqqIpcEndpoint {
71
90
  this.#handleEvent(event);
72
91
  });
73
92
  this.#subscriptions.push(sub);
93
+ // daemon 推送 client.em 全部事件,request/notice 实时到达;
94
+ // 启动时补一次 GET_SYSTEM_MSG,捞离线期间积存的待处理请求。
95
+ void this.#pullPendingSystemMessages();
74
96
  }
75
97
  /** IPC/RPC 意外断开时调度重连(指数退避) */
76
98
  #scheduleReconnect() {
@@ -145,8 +167,10 @@ export class IcqqIpcEndpoint {
145
167
  this.#unregisterAgent?.();
146
168
  this.#unregisterAgent = undefined;
147
169
  this.#inboundDeduper.clear();
170
+ this.#inboxDeduper.clear();
148
171
  this.friends.clear();
149
172
  this.groups.clear();
173
+ this.#guildCatalog.clear();
150
174
  try {
151
175
  this.ipc?.close();
152
176
  }
@@ -342,16 +366,166 @@ export class IcqqIpcEndpoint {
342
366
  }));
343
367
  }
344
368
  }
369
+ // ── 社交/群管(Console endpoint RPC 探测面)────────────────────────
370
+ // console-rpc-extended.ts 按方法名探测这些接口;全部薄封装走 IPC daemon。
371
+ /** EndpointManagement.listFriends —— 好友列表(LIST_FRIENDS,归一为 {user_id, nickname, remark})。 */
372
+ async getFriendList() {
373
+ const resp = await this.#mustRequest(Actions.LIST_FRIENDS, undefined, '获取好友列表');
374
+ const list = Array.isArray(resp.data) ? resp.data : [];
375
+ return list.map((f) => ({
376
+ user_id: f.user_id,
377
+ nickname: f.nickname,
378
+ remark: f.remark ?? '',
379
+ }));
380
+ }
381
+ /** EndpointManagement.listGroups —— 群列表(LIST_GROUPS,归一为 {group_id, name})。 */
382
+ async getGroupList() {
383
+ const resp = await this.#mustRequest(Actions.LIST_GROUPS, undefined, '获取群列表');
384
+ const list = Array.isArray(resp.data) ? resp.data : [];
385
+ return list.map((g) => ({
386
+ group_id: g.group_id,
387
+ name: g.group_name,
388
+ }));
389
+ }
390
+ /** EndpointManagement.listGroupMembers —— 群成员列表(字段对齐 daemon 返回)。 */
391
+ async getGroupMemberList(groupId) {
392
+ const resp = await this.#mustRequest(Actions.LIST_GROUP_MEMBERS, { group_id: toNumericId(groupId, 'group_id') }, '获取群成员列表');
393
+ return Array.isArray(resp.data) ? resp.data : [];
394
+ }
395
+ /** getGroupMemberList 别名(console 探测 listMembers / getMemberList)。 */
396
+ listMembers(groupId) {
397
+ return this.getGroupMemberList(groupId);
398
+ }
399
+ getMemberList(groupId) {
400
+ return this.getGroupMemberList(groupId);
401
+ }
402
+ /**
403
+ * EndpointManagement.resolveRequest —— id 为 inbox 行的 platform_request_id。
404
+ * 先 GET_SYSTEM_MSG 按 flag(回退 seq)定位请求,再按类型路由 handle_friend/group_request。
405
+ */
406
+ async approveRequest(id, remark) {
407
+ await this.#handleSystemRequest(id, true, { remark });
408
+ }
409
+ async rejectRequest(id, reason) {
410
+ await this.#handleSystemRequest(id, false, { reason });
411
+ }
412
+ /** EndpointManagement.deleteFriend —— FRIEND_DELETE。 */
413
+ async deleteFriend(userId) {
414
+ await this.#mustRequest(Actions.FRIEND_DELETE, { user_id: toNumericId(userId, 'user_id') }, '删除好友');
415
+ }
416
+ /** deleteFriend 别名(console 探测 delete_friend)。 */
417
+ delete_friend(userId) {
418
+ return this.deleteFriend(userId);
419
+ }
420
+ /** EndpointManagement.kickGroupMember —— GROUP_KICK。 */
421
+ async removeMember(groupId, userId) {
422
+ await this.#mustRequest(Actions.GROUP_KICK, { group_id: toNumericId(groupId, 'group_id'), user_id: toNumericId(userId, 'user_id') }, '踢出群成员');
423
+ }
424
+ kickMember(groupId, userId) {
425
+ return this.removeMember(groupId, userId);
426
+ }
427
+ setGroupKick(groupId, userId) {
428
+ return this.removeMember(groupId, userId);
429
+ }
430
+ /** EndpointManagement.muteGroupMember —— GROUP_MUTE(duration 秒,默认 600)。 */
431
+ async muteMember(groupId, userId, duration = 600) {
432
+ await this.#mustRequest(Actions.GROUP_MUTE, {
433
+ group_id: toNumericId(groupId, 'group_id'),
434
+ user_id: toNumericId(userId, 'user_id'),
435
+ duration,
436
+ }, '禁言群成员');
437
+ }
438
+ banMember(groupId, userId, duration = 600) {
439
+ return this.muteMember(groupId, userId, duration);
440
+ }
441
+ setGroupMute(groupId, userId, duration = 600) {
442
+ return this.muteMember(groupId, userId, duration);
443
+ }
444
+ /** EndpointManagement.setGroupAdmin —— SET_GROUP_ADMIN(enable 默认 true)。 */
445
+ async setModerator(groupId, userId, enable = true) {
446
+ await this.#mustRequest(Actions.SET_GROUP_ADMIN, {
447
+ group_id: toNumericId(groupId, 'group_id'),
448
+ user_id: toNumericId(userId, 'user_id'),
449
+ enable,
450
+ }, '设置群管理员');
451
+ }
452
+ setAdmin(groupId, userId, enable = true) {
453
+ return this.setModerator(groupId, userId, enable);
454
+ }
455
+ setGroupAdmin(groupId, userId, enable = true) {
456
+ return this.setModerator(groupId, userId, enable);
457
+ }
458
+ /** IPC 请求并统一错误上下文:daemon 返回 ok=false 时抛出带操作名的错误。 */
459
+ async #mustRequest(action, params, label) {
460
+ const resp = await this.request(action, params);
461
+ if (!resp.ok) {
462
+ throw new Error(`icqq ${label}失败: ${resp.error ?? 'daemon 未返回错误详情'}`);
463
+ }
464
+ return resp;
465
+ }
466
+ /** 按 flag/seq 在 GET_SYSTEM_MSG 中定位请求,路由到好友/群请求处理 action。 */
467
+ async #handleSystemRequest(id, approve, extra) {
468
+ const resp = await this.#mustRequest(Actions.GET_SYSTEM_MSG, undefined, '获取待处理请求');
469
+ const data = resp.data;
470
+ const friendRequests = Array.isArray(data?.friendRequests)
471
+ ? data.friendRequests
472
+ : [];
473
+ const groupRequests = Array.isArray(data?.groupRequests)
474
+ ? data.groupRequests
475
+ : [];
476
+ const matches = (m) => m.flag === id || (m.seq != null && String(m.seq) === id);
477
+ const friend = friendRequests.find(matches);
478
+ const group = friend ? undefined : groupRequests.find(matches);
479
+ const target = friend ?? group;
480
+ if (!target?.flag) {
481
+ throw new Error(`icqq 未找到待处理请求: ${id}(GET_SYSTEM_MSG 中无匹配 flag/seq)`);
482
+ }
483
+ const params = { flag: target.flag, approve };
484
+ if (friend) {
485
+ if (extra.remark)
486
+ params.remark = extra.remark;
487
+ await this.#mustRequest(Actions.HANDLE_FRIEND_REQUEST, params, '处理好友请求');
488
+ }
489
+ else {
490
+ if (extra.reason)
491
+ params.reason = extra.reason;
492
+ await this.#mustRequest(Actions.HANDLE_GROUP_REQUEST, params, '处理群请求');
493
+ }
494
+ }
345
495
  /** Test / internal: admit when open. */
346
496
  admit(msg) {
347
- if (!this.#open)
497
+ if (!this.#open) {
498
+ logger.debug(formatCompact({
499
+ op: 'icqq_inbound_skip',
500
+ reason: 'endpoint_closed',
501
+ endpoint: this.name,
502
+ id: msg.id,
503
+ target: msg.target,
504
+ }));
348
505
  return;
349
- // 新 Runtime Message.content 为纯文本:@ 本机(uin = name)只能经 metadata 传递
350
- const mentioned = isIcqqBotMentioned({ uin: this.name, rawMessage: msg.content });
506
+ }
507
+ // 新 Runtime Message.content 为纯文本:@ 本机(uin = name)经结构化段 / metadata 传递
508
+ const mentioned = isIcqqBotMentioned({
509
+ uin: this.name,
510
+ content: msg.segments,
511
+ rawMessage: msg.content,
512
+ });
513
+ logger.debug(formatCompact({
514
+ op: 'icqq_inbound_admit',
515
+ endpoint: this.name,
516
+ id: msg.id,
517
+ target: msg.target,
518
+ sender: msg.sender,
519
+ channelType: msg.channelType,
520
+ mentioned: mentioned || undefined,
521
+ preview: msg.content.slice(0, 80),
522
+ segments: summarizeSegmentTypes(msg.segments),
523
+ }));
351
524
  void this.#options.gateway.receive({
352
525
  adapter: this.#options.id,
353
526
  target: msg.target,
354
527
  content: msg.content,
528
+ ...(msg.segments ? { segments: msg.segments } : {}),
355
529
  sender: msg.sender,
356
530
  id: msg.id,
357
531
  metadata: Object.freeze({
@@ -394,16 +568,47 @@ export class IcqqIpcEndpoint {
394
568
  const payload = unwrapIcqqIpcEventPayload(event);
395
569
  if (!payload || typeof payload !== 'object')
396
570
  return;
571
+ if (isIcqqRequestPayload(payload)) {
572
+ this.#recordInboxRequest(payload);
573
+ return;
574
+ }
575
+ if (isIcqqNoticePayload(payload)) {
576
+ this.#recordInboxNotice(payload);
577
+ return;
578
+ }
397
579
  if (!isIcqqMessagePostType(payload))
398
580
  return;
399
581
  const data = payload;
400
- if (shouldSkipSelfInboundMessage(data))
582
+ if (shouldSkipSelfInboundMessage(data)) {
583
+ logger.debug(formatCompact({
584
+ op: 'icqq_inbound_skip',
585
+ reason: 'self',
586
+ endpoint: this.name,
587
+ user_id: data.user_id,
588
+ self_id: data.self_id,
589
+ }));
401
590
  return;
591
+ }
402
592
  const normalized = normalizeIcqqInboundMessage(data);
403
- if (!normalized)
593
+ if (!normalized) {
594
+ logger.debug(formatCompact({
595
+ op: 'icqq_inbound_skip',
596
+ reason: 'normalize_failed',
597
+ endpoint: this.name,
598
+ message_id: data.message_id,
599
+ message_type: data.message_type ?? data.type,
600
+ }));
404
601
  return;
405
- if (!this.#inboundDeduper.shouldProcess(normalized.messageId))
602
+ }
603
+ if (!this.#inboundDeduper.shouldProcess(normalized.messageId)) {
604
+ logger.debug(formatCompact({
605
+ op: 'icqq_inbound_skip',
606
+ reason: 'dedupe',
607
+ endpoint: this.name,
608
+ id: normalized.messageId,
609
+ }));
406
610
  return;
611
+ }
407
612
  this.admit({
408
613
  id: normalized.messageId,
409
614
  target: formatInboundTarget({
@@ -412,6 +617,7 @@ export class IcqqIpcEndpoint {
412
617
  channelParentGroupId: normalized.channelParentGroupId,
413
618
  }),
414
619
  content: formatInboundContent(normalized.rawMessage),
620
+ segments: normalized.content,
415
621
  sender: normalized.userId,
416
622
  channelType: normalized.channelType,
417
623
  metadata: buildIcqqQuoteMetadata(data, {
@@ -425,10 +631,24 @@ export class IcqqIpcEndpoint {
425
631
  if (!payload || typeof payload !== 'object')
426
632
  return;
427
633
  const normalized = normalizeIcqqGuildInboundMessage(payload);
428
- if (!normalized)
634
+ if (!normalized) {
635
+ logger.debug(formatCompact({
636
+ op: 'icqq_inbound_skip',
637
+ reason: 'guild_normalize_failed',
638
+ endpoint: this.name,
639
+ }));
429
640
  return;
430
- if (!this.#inboundDeduper.shouldProcess(`guild:${normalized.messageId}`))
641
+ }
642
+ this.#guildCatalog.upsertFromInbound(normalized.raw);
643
+ if (!this.#inboundDeduper.shouldProcess(`guild:${normalized.messageId}`)) {
644
+ logger.debug(formatCompact({
645
+ op: 'icqq_inbound_skip',
646
+ reason: 'dedupe',
647
+ endpoint: this.name,
648
+ id: `guild:${normalized.messageId}`,
649
+ }));
431
650
  return;
651
+ }
432
652
  this.admit({
433
653
  id: normalized.messageId,
434
654
  target: formatInboundTarget({
@@ -437,6 +657,7 @@ export class IcqqIpcEndpoint {
437
657
  guildId: normalized.guildId,
438
658
  }),
439
659
  content: formatInboundContent(normalized.rawMessage),
660
+ segments: normalized.content,
440
661
  sender: normalized.userId,
441
662
  channelType: 'channel',
442
663
  metadata: {
@@ -445,6 +666,71 @@ export class IcqqIpcEndpoint {
445
666
  },
446
667
  });
447
668
  }
669
+ /** 收件箱行公共前缀:adapter 槽 localName + endpoint live 名(uin)。 */
670
+ #inboxBase() {
671
+ const id = String(this.#options.id);
672
+ return { adapter: id.split('\0').pop() ?? id, endpointId: this.name };
673
+ }
674
+ #recordInboxRequest(payload) {
675
+ const hooks = this.#options.inbox;
676
+ if (!hooks)
677
+ return;
678
+ const row = buildIcqqInboxRequestRow(payload, this.#inboxBase());
679
+ if (!row)
680
+ return;
681
+ if (!this.#inboxDeduper.shouldProcess(`request:${String(row.platform_request_id)}`))
682
+ return;
683
+ void hooks.recordRequest(row);
684
+ hooks.publish?.('endpoint:request', row);
685
+ }
686
+ #recordInboxNotice(payload) {
687
+ const hooks = this.#options.inbox;
688
+ if (!hooks)
689
+ return;
690
+ const row = buildIcqqInboxNoticeRow(payload, this.#inboxBase());
691
+ if (!row)
692
+ return;
693
+ if (!this.#inboxDeduper.shouldProcess(`notice:${String(row.platform_notice_id)}`))
694
+ return;
695
+ void hooks.recordNotice(row);
696
+ hooks.publish?.('endpoint:notice', row);
697
+ }
698
+ /** 启动/重连后首拉 GET_SYSTEM_MSG:补录离线期间的好友/群待处理请求。 */
699
+ async #pullPendingSystemMessages() {
700
+ const hooks = this.#options.inbox;
701
+ if (!hooks || !this.ipc)
702
+ return;
703
+ try {
704
+ const resp = await this.ipc.request(Actions.GET_SYSTEM_MSG);
705
+ if (!resp.ok)
706
+ return;
707
+ const data = resp.data;
708
+ const base = this.#inboxBase();
709
+ const rows = [
710
+ ...(Array.isArray(data?.friendRequests)
711
+ ? data.friendRequests.map((m) => buildIcqqSystemRequestRow(m, 'friend', base))
712
+ : []),
713
+ ...(Array.isArray(data?.groupRequests)
714
+ ? data.groupRequests.map((m) => buildIcqqSystemRequestRow(m, 'group', base))
715
+ : []),
716
+ ];
717
+ for (const row of rows) {
718
+ if (!row)
719
+ continue;
720
+ if (!this.#inboxDeduper.shouldProcess(`request:${String(row.platform_request_id)}`))
721
+ continue;
722
+ void hooks.recordRequest(row);
723
+ hooks.publish?.('endpoint:request', row);
724
+ }
725
+ }
726
+ catch (error) {
727
+ logger.debug(formatCompact({
728
+ op: 'inbox_pull_system_msg',
729
+ endpoint: this.name,
730
+ error: error instanceof Error ? error.message : String(error),
731
+ }));
732
+ }
733
+ }
448
734
  }
449
735
  /**
450
736
  * 组装入站 metadata:在基础字段上补充 quote 链路信息。
@@ -474,6 +760,28 @@ function buildIcqqQuoteMetadata(data, base) {
474
760
  metadata.quote_content = quoteText;
475
761
  return metadata;
476
762
  }
763
+ /** console RPC 传入的 gid/uid 可能是字符串,统一收敛为数字。 */
764
+ function toNumericId(value, label) {
765
+ const n = Number(value);
766
+ if (!Number.isFinite(n) || String(value).trim() === '') {
767
+ throw new TypeError(`icqq ${label} 必须是数字: ${String(value)}`);
768
+ }
769
+ return n;
770
+ }
771
+ function summarizeSegmentTypes(segments) {
772
+ if (!segments?.length)
773
+ return undefined;
774
+ return segments
775
+ .map((segment) => {
776
+ if (typeof segment.type === 'string')
777
+ return segment.type;
778
+ if (segment.type && typeof segment.type === 'object' && 'name' in segment.type) {
779
+ return String(segment.type.name);
780
+ }
781
+ return '?';
782
+ })
783
+ .join(',');
784
+ }
477
785
  async function defaultCreateIpc(config) {
478
786
  const uin = Number(config.name);
479
787
  if (config.rpc) {
@@ -0,0 +1 @@
1
+ export declare const icqqEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `icqq endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
3
+ *
4
+ * ICQQ 的凭据由外部守护进程(@icqqjs/cli)按账号持有(~/.icqq/<uin>/),
5
+ * 其 IPC/RPC 协议是「按 uin 连接 + auth + 账号级 action」,没有 add-account /
6
+ * 触发 login 的 action——扫码登录只能经 `icqq login <uin>` CLI 完成。
7
+ * 因此 add 不走 kv 凭据录入,而是 bindFlow:校验 uin、把 { name: '<uin>' }
8
+ * 追加到 plugins.icqq.endpoints,并引导用户先完成守护进程登录。
9
+ */
10
+ import { addEndpointToConfig, createEndpointCommands } from '@zhin.js/adapter';
11
+ import { defineCommand } from '@zhin.js/command';
12
+ import { icqqRuntimeStateToken } from './icqq-runtime-state.js';
13
+ export const icqqEndpointCommands = createEndpointCommands({
14
+ adapterKey: 'icqq',
15
+ adapterDisplayName: 'ICQQ',
16
+ running: (use) => use(icqqRuntimeStateToken).endpoints.values(),
17
+ describeEntry: (entry) => (entry.rpc ? 'rpc(远程守护进程)' : 'ipc(本地守护进程)'),
18
+ addDescription: '登记 ICQQ endpoint(uin 需先经 icqq login 完成扫码登录;重启 zhin 生效)',
19
+ bindFlow: ({ name }) => {
20
+ if (!name) {
21
+ return '用法:icqq endpoint add <uin>(uin 为纯数字 QQ 号,且需先执行 icqq login <uin> 完成扫码登录)';
22
+ }
23
+ if (!/^\d+$/.test(name)) {
24
+ return 'icqq endpoint 名必须是纯数字 QQ 号(uin)';
25
+ }
26
+ try {
27
+ const filePath = addEndpointToConfig('icqq', { name });
28
+ return (`✅ endpoint「${name}」已追加到 ${filePath} 的 plugins.icqq.endpoints。\n` +
29
+ `⚠️ 请确保已执行 icqq login ${name} 启动守护进程并完成扫码登录;重启 zhin 后生效。`);
30
+ }
31
+ catch (error) {
32
+ return `添加失败:${error instanceof Error ? error.message : String(error)}`;
33
+ }
34
+ },
35
+ }, defineCommand);
@@ -3,7 +3,13 @@
3
3
  */
4
4
  import { type MessageSegment } from "zhin.js";
5
5
  import { type IpcGuildMessageEventData } from "./protocol.js";
6
- import type { IpcClient } from "./ipc-client.js";
6
+ /** Narrow request port shared by the real IPC client and endpoint test transports. */
7
+ export interface IcqqGuildRpc {
8
+ request(action: string, params?: Record<string, unknown>): Promise<{
9
+ ok: boolean;
10
+ data?: unknown;
11
+ }>;
12
+ }
7
13
  export interface IcqqGuildChannelEntry {
8
14
  channel_id: string;
9
15
  channel_name: string;
@@ -27,7 +33,7 @@ export declare class IcqqGuildCatalog {
27
33
  private readonly channels;
28
34
  private readonly guilds;
29
35
  clear(): void;
30
- syncAll(ipc: IpcClient): Promise<void>;
36
+ syncAll(ipc: IcqqGuildRpc): Promise<void>;
31
37
  upsertFromInbound(data: IpcGuildMessageEventData): void;
32
38
  getGuildChannelList(): Array<{
33
39
  id: string;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * icqq IPC request/notice 事件 → 统一收件箱行(unified_inbox_request / unified_inbox_notice)。
3
+ *
4
+ * daemon 经 IpcEvent 推送 icqq client.em 分发的全部事件(见 protocol.ts),
5
+ * 好友/群请求(request.friend.add、request.group.add、request.group.invite)
6
+ * 与通知(notice.friend.*、notice.group.*)随事件流实时到达,无需轮询;
7
+ * endpoint 启动时补一次 GET_SYSTEM_MSG 拉取离线期间积存的待处理请求。
8
+ */
9
+ import type { IpcSystemMessage } from './types.js';
10
+ /** 行级公共前缀:adapter 槽 localName + endpoint live 名(uin)。 */
11
+ export interface IcqqInboxBase {
12
+ readonly adapter: string;
13
+ readonly endpointId: string;
14
+ }
15
+ /** post_type=request / request.* 判定(OneBot 事件壳)。 */
16
+ export declare function isIcqqRequestPayload(payload: unknown): boolean;
17
+ /** post_type=notice / notice.* 判定。 */
18
+ export declare function isIcqqNoticePayload(payload: unknown): boolean;
19
+ /**
20
+ * request 事件载荷 → unified_inbox_request 行。
21
+ * platform_request_id 取 flag(审批回执句柄),缺省回退 seq;两者皆无则无法审批,丢弃。
22
+ */
23
+ export declare function buildIcqqInboxRequestRow(payload: Record<string, unknown>, base: IcqqInboxBase): Record<string, unknown> | null;
24
+ /**
25
+ * notice 事件载荷 → unified_inbox_notice 行。
26
+ * 事件本身无稳定 id,platform_notice_id 由 time + actor + type 合成(配合去重)。
27
+ */
28
+ export declare function buildIcqqInboxNoticeRow(payload: Record<string, unknown>, base: IcqqInboxBase): Record<string, unknown> | null;
29
+ /**
30
+ * GET_SYSTEM_MSG 返回项 → unified_inbox_request 行(启动时首次拉取)。
31
+ * friend 列表项 type 语义即好友请求;group 列表项 type 为 add/invite(记 sub_type)。
32
+ */
33
+ export declare function buildIcqqSystemRequestRow(message: IpcSystemMessage, kind: 'friend' | 'group', base: IcqqInboxBase): Record<string, unknown> | null;
@@ -0,0 +1,129 @@
1
+ /** post_type=request / request.* 判定(OneBot 事件壳)。 */
2
+ export function isIcqqRequestPayload(payload) {
3
+ const postType = payload?.post_type;
4
+ return typeof postType === 'string'
5
+ && (postType === 'request' || postType.startsWith('request.'));
6
+ }
7
+ /** post_type=notice / notice.* 判定。 */
8
+ export function isIcqqNoticePayload(payload) {
9
+ const postType = payload?.post_type;
10
+ return typeof postType === 'string'
11
+ && (postType === 'notice' || postType.startsWith('notice.'));
12
+ }
13
+ /**
14
+ * request 事件载荷 → unified_inbox_request 行。
15
+ * platform_request_id 取 flag(审批回执句柄),缺省回退 seq;两者皆无则无法审批,丢弃。
16
+ */
17
+ export function buildIcqqInboxRequestRow(payload, base) {
18
+ const userId = payload.user_id ?? payload.actor_id;
19
+ if (userId == null)
20
+ return null;
21
+ const flag = asNonEmptyString(payload.flag);
22
+ const platformRequestId = flag ?? (payload.seq != null ? String(payload.seq) : '');
23
+ if (!platformRequestId)
24
+ return null;
25
+ const requestType = asNonEmptyString(payload.request_type) ?? asNonEmptyString(payload.type);
26
+ const groupId = payload.group_id;
27
+ const isGroup = requestType === 'group' || groupId != null;
28
+ return {
29
+ adapter: base.adapter,
30
+ endpoint_id: base.endpointId,
31
+ platform_request_id: platformRequestId,
32
+ type: isGroup ? 'group' : (requestType ?? 'friend'),
33
+ scene_type: isGroup ? 'group' : null,
34
+ scene_id: String(groupId ?? userId),
35
+ sub_type: asNonEmptyString(payload.sub_type),
36
+ actor_id: String(userId),
37
+ actor_name: asNonEmptyString(payload.nickname),
38
+ comment: asNonEmptyString(payload.comment),
39
+ created_at: toMillis(payload.time),
40
+ resolved: 0,
41
+ resolved_at: null,
42
+ consumed: 0,
43
+ consumed_at: null,
44
+ };
45
+ }
46
+ /**
47
+ * notice 事件载荷 → unified_inbox_notice 行。
48
+ * 事件本身无稳定 id,platform_notice_id 由 time + actor + type 合成(配合去重)。
49
+ */
50
+ export function buildIcqqInboxNoticeRow(payload, base) {
51
+ const noticeType = asNonEmptyString(payload.notice_type) ?? asNonEmptyString(payload.type);
52
+ if (!noticeType)
53
+ return null;
54
+ const subType = asNonEmptyString(payload.sub_type);
55
+ const userId = payload.user_id;
56
+ const operatorId = payload.operator_id;
57
+ const groupId = payload.group_id;
58
+ const createdAt = toMillis(payload.time);
59
+ const actorId = operatorId ?? userId;
60
+ const platformNoticeId = asNonEmptyString(payload.notice_id)
61
+ ?? (payload.seq != null ? String(payload.seq) : undefined)
62
+ ?? `${createdAt}_${String(actorId ?? '')}_${noticeType}${subType ? `.${subType}` : ''}`;
63
+ const targetId = userId != null && String(userId) !== String(actorId ?? '') ? userId : undefined;
64
+ return {
65
+ adapter: base.adapter,
66
+ endpoint_id: base.endpointId,
67
+ platform_notice_id: platformNoticeId,
68
+ type: noticeType,
69
+ scene_type: groupId != null ? 'group' : null,
70
+ scene_id: String(groupId ?? userId ?? operatorId ?? ''),
71
+ sub_type: subType,
72
+ actor_id: actorId != null ? String(actorId) : null,
73
+ actor_name: asNonEmptyString(payload.nickname),
74
+ target_id: targetId != null ? String(targetId) : null,
75
+ target_name: null,
76
+ payload: safeJson(payload),
77
+ created_at: createdAt,
78
+ consumed: 0,
79
+ consumed_at: null,
80
+ };
81
+ }
82
+ /**
83
+ * GET_SYSTEM_MSG 返回项 → unified_inbox_request 行(启动时首次拉取)。
84
+ * friend 列表项 type 语义即好友请求;group 列表项 type 为 add/invite(记 sub_type)。
85
+ */
86
+ export function buildIcqqSystemRequestRow(message, kind, base) {
87
+ if (message.user_id == null)
88
+ return null;
89
+ const platformRequestId = asNonEmptyString(message.flag)
90
+ ?? (message.seq != null ? String(message.seq) : '');
91
+ if (!platformRequestId)
92
+ return null;
93
+ return {
94
+ adapter: base.adapter,
95
+ endpoint_id: base.endpointId,
96
+ platform_request_id: platformRequestId,
97
+ type: kind,
98
+ scene_type: kind === 'group' ? 'group' : null,
99
+ scene_id: String(kind === 'group' ? (message.group_id ?? message.user_id) : message.user_id),
100
+ sub_type: kind === 'group' ? (asNonEmptyString(message.type) ?? null) : null,
101
+ actor_id: String(message.user_id),
102
+ actor_name: asNonEmptyString(message.nickname),
103
+ comment: asNonEmptyString(message.comment),
104
+ created_at: toMillis(message.time),
105
+ resolved: 0,
106
+ resolved_at: null,
107
+ consumed: 0,
108
+ consumed_at: null,
109
+ };
110
+ }
111
+ function asNonEmptyString(value) {
112
+ if (typeof value !== 'string')
113
+ return null;
114
+ const trimmed = value.trim();
115
+ return trimmed ? trimmed : null;
116
+ }
117
+ /** oicq 事件 time 为秒;缺失时回退当前时间。 */
118
+ function toMillis(time) {
119
+ const seconds = Number(time);
120
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : Date.now();
121
+ }
122
+ function safeJson(value) {
123
+ try {
124
+ return JSON.stringify(value ?? {});
125
+ }
126
+ catch {
127
+ return '{}';
128
+ }
129
+ }
@@ -0,0 +1 @@
1
+ export declare const icqqRuntimeStateToken: import("zhin.js").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * ICQQ 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `icqq endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+ export const icqqRuntimeStateToken = defineEndpointRuntimeStateToken('icqq');
package/lib/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Actions, formatInboundContent, formatInboundTarget, formatOutboundBody, parseSendTarget, resolveIcqqConfig, type IcqqAdapterConfig, type IcqqInboundMessage, type IcqqWireSegment, type IpcEvent, type IpcMessage, type IpcRequest, type IpcResponse, type ParsedIcqqSendTarget, type ResolvedIcqqConfig, } from './protocol.js';
2
- export { IcqqIpcEndpoint, type CreateIcqqIpc, type IcqqEndpointOptions, type IcqqIpcTransport, } from './endpoint.js';
2
+ export { IcqqIpcEndpoint, type CreateIcqqIpc, type IcqqEndpointOptions, type IcqqInboxHooks, type IcqqIpcTransport, } from './endpoint.js';
3
3
  export * from './types.js';
4
4
  export { getIcqqAgentDeps, registerIcqqAgentEndpoint, setIcqqAgentDeps, type IcqqAgentDeps, type IcqqAgentEndpoint, } from './icqq-agent-deps.js';
5
5
  export { IpcClient } from './ipc-client.js';