@zhin.js/adapter-icqq 1.1.16 → 1.1.17

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 (43) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/agents/icqq/members.js +16 -0
  3. package/agents/icqq/members.ts +17 -0
  4. package/agents/icqq/skills/icqq-group-admin/tools/essence/index.js +1 -1
  5. package/agents/icqq/skills/icqq-group-admin/tools/essence/index.ts +1 -1
  6. package/agents/icqq/skills/icqq-group-admin/tools/group_invite/index.js +2 -2
  7. package/agents/icqq/skills/icqq-group-admin/tools/group_invite/index.ts +2 -2
  8. package/agents/icqq/skills/icqq-group-admin/tools/group_leave/index.js +1 -0
  9. package/agents/icqq/skills/icqq-group-admin/tools/group_leave/index.ts +1 -0
  10. package/agents/icqq/skills/icqq-group-admin/tools/recall_member_message/index.js +2 -1
  11. package/agents/icqq/skills/icqq-group-admin/tools/recall_member_message/index.ts +2 -1
  12. package/agents/icqq/skills/icqq-group-admin/tools/set_anonymous/index.js +2 -2
  13. package/agents/icqq/skills/icqq-group-admin/tools/set_anonymous/index.ts +2 -2
  14. package/agents/icqq/skills/icqq-group-admin/tools/set_mute/index.js +4 -4
  15. package/agents/icqq/skills/icqq-group-admin/tools/set_mute/index.ts +4 -4
  16. package/agents/icqq/skills/icqq-group-admin/tools/set_title/index.js +4 -4
  17. package/agents/icqq/skills/icqq-group-admin/tools/set_title/index.ts +4 -4
  18. package/agents/icqq/skills/icqq-group-directory/tools/group_member_info/index.js +16 -6
  19. package/agents/icqq/skills/icqq-group-directory/tools/group_member_info/index.ts +16 -5
  20. package/agents/icqq/skills/icqq-messages/tools/forward_messages/index.js +1 -1
  21. package/agents/icqq/skills/icqq-messages/tools/forward_messages/index.ts +1 -1
  22. package/agents/icqq/skills/icqq-messages/tools/quote_reply/index.js +1 -1
  23. package/agents/icqq/skills/icqq-messages/tools/quote_reply/index.ts +1 -1
  24. package/agents/icqq/skills/icqq-messages/tools/recall_own_message/index.js +1 -1
  25. package/agents/icqq/skills/icqq-messages/tools/recall_own_message/index.ts +1 -1
  26. package/agents/icqq/system.md +3 -3
  27. package/lib/endpoint.js +6 -8
  28. package/lib/group-moderation.js +7 -2
  29. package/lib/group-scene.d.ts +11 -0
  30. package/lib/group-scene.js +71 -0
  31. package/lib/icqq-inbound.d.ts +2 -0
  32. package/lib/icqq-inbound.js +3 -0
  33. package/lib/icqq-inbox.d.ts +5 -0
  34. package/lib/icqq-inbox.js +14 -0
  35. package/lib/protocol.d.ts +2 -0
  36. package/lib/protocol.js +4 -2
  37. package/package.json +9 -9
  38. package/src/endpoint.ts +6 -7
  39. package/src/group-moderation.ts +7 -2
  40. package/src/group-scene.ts +76 -0
  41. package/src/icqq-inbound.ts +5 -0
  42. package/src/icqq-inbox.ts +15 -0
  43. package/src/protocol.ts +6 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @zhin.js/adapter-icqq
2
2
 
3
+ ## 1.1.17
4
+
5
+ ### Patch Changes
6
+
7
+ - e23cabc: ADR 0041 slice B: the turn context now carries real scene facts. `EndpointManagement` gains `inspectGroupScene` (name, owner, admins, member count, global mute) and `inspectGroupMemberProfile` (role, card, title, mute expiry; one lookup), implemented by the ICQQ adapter from uncached group/member reads. The CLI resolves them per turn (bot role always fresh; group and sender facts reuse a 60 s, 256-entry in-memory cache scoped to the generation; failures omit fields) and the inbound actor now carries `groupNickname` / `groupTitle` / `mutedUntil` plus the platform role. The Session / Bot state blocks are replaced by one projected scene line `[scene_id:…;scene_type:…;scene_name:…;owner_id:…;admins:…;member_count:…;global_mute:…;assistant_role:…;]` (values escaped and clipped to 64 chars). No new on-disk state.
8
+ - 4d728c0: ADR 0043 §4 + §5 (H4) and the ADR 0041 actor-only amendment. Participant headers are no longer stored: user messages keep only `actor`, assistant messages keep `assistantActor` (plus the delivered `messageId`), and the header is rendered at the LLM boundary; legacy rows that still carry `name` are read as before. Headers now use session message aliases `id:m<n>` (an ordinal over the stored active path, so already-sent prefixes never change), `time:HH:mm` shown only when a minute has passed since the last shown time (the first message and the first message of a day also carry `date`), and `reply_to:m<n>` when the message replies to one inside the visible window. A reply to a message outside the window gets one `[quote id:…;user_id:…;time:…;] <first 120 chars>` line (media as placeholders, a forward expanded one level) so the model no longer needs `inspect_conversation_reference` for ordinary quotes; the quote is resolved by the ingress through the reference port (3 s timeout) and stored as structured `UserMessage.quote`. Tool arguments named `message_id` / `message_ids` / `*_message_id` / `reference` and `reply` segments accept the alias as well as the platform id; `inspect_conversation_reference` also accepts the quoted message id.
9
+ - 51f1328: 修复 icqq 群成员相关 Agent 工具:`group_member_info` 返回空对象(模型据此误判成员不在群里)、`group_invite` 永远判定"已在群里"、`set_mute` / `set_title` / `set_anonymous` 读旧缓存误判 Bot 不是管理员。统一改为实时查询群成员。
10
+ - d0e1fac: Keep stable prompt instructions ahead of turn-specific capabilities, project unchanged context without placeholder text, and retain scoped state snapshots with readable updates and compaction recovery. Preserve native conversation/tool chronology and separate participant identity from runtime guidance.
11
+
12
+ Resolve the ICQQ bot account and current-group role before capability discovery, and support explicitly declared current-conversation bot-role requirements without changing existing requester permissions or approvals. Keep role lookup failures unknown and preserve execution-time platform checks.
13
+
14
+ - 3771f7a: 修复同一群先后两人入群时后一次入群被当重复事件丢掉:side event 去重键(core 与 icqq)加入成员与操作人。
15
+ - 4f96ad5: Fix member-join welcome display: icqq now sends the mention's display name as the `at` element text, the joining member's nickname from the notice is threaded into `target.name`, and icqq group messages populate `conversation.name` from `group_name` so `{group}` renders the group name instead of the group number.
16
+ - Updated dependencies [f67883e]
17
+ - Updated dependencies [e23cabc]
18
+ - Updated dependencies [4d728c0]
19
+ - Updated dependencies [eb8feeb]
20
+ - Updated dependencies [24a2b71]
21
+ - Updated dependencies [d0e1fac]
22
+ - Updated dependencies [3771f7a]
23
+ - Updated dependencies [4f96ad5]
24
+ - @zhin.js/core@1.1.52
25
+ - @zhin.js/adapter@1.1.24
26
+ - @zhin.js/tool@1.1.11
27
+ - @zhin.js/im-contract@1.1.8
28
+
3
29
  ## 1.1.16
4
30
 
5
31
  ### Patch Changes
@@ -0,0 +1,16 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ /**
3
+ * 实时查询群成员(跳过缓存);不在群里或查询失败返回 undefined。
4
+ * 不要用 `pickMember`:它总会返回实体对象(判空永远为真),`is_admin` 等也读旧缓存——Bot 刚被设为管理员时会误判。
5
+ */
6
+ export async function freshMember(client, groupId, userId) {
7
+ try {
8
+ return await client.getGroupMemberInfo(groupId, userId, true);
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ export function isGroupManager(info) {
15
+ return info?.role === 'owner' || info?.role === 'admin';
16
+ }
@@ -0,0 +1,17 @@
1
+ import type { Client, MemberInfo } from '@icqqjs/icqq';
2
+
3
+ /**
4
+ * 实时查询群成员(跳过缓存);不在群里或查询失败返回 undefined。
5
+ * 不要用 `pickMember`:它总会返回实体对象(判空永远为真),`is_admin` 等也读旧缓存——Bot 刚被设为管理员时会误判。
6
+ */
7
+ export async function freshMember(client: Client, groupId: number, userId: number): Promise<MemberInfo | undefined> {
8
+ try {
9
+ return await client.getGroupMemberInfo(groupId, userId, true);
10
+ } catch {
11
+ return undefined;
12
+ }
13
+ }
14
+
15
+ export function isGroupManager(info: MemberInfo | undefined): boolean {
16
+ return info?.role === 'owner' || info?.role === 'admin';
17
+ }
@@ -5,7 +5,7 @@ import { z } from 'zod';
5
5
  export default defineAgentTool({
6
6
  description: '设置或移除 QQ 群精华消息',
7
7
  inputSchema: z.object({
8
- message_id: z.string().describe('消息 ID'),
8
+ message_id: z.string().describe('消息 ID,或消息头里的 m<n> 别名'),
9
9
  action: z.enum(['add', 'remove']).describe('add=设为精华, remove=移除精华'),
10
10
  }),
11
11
  adapter: 'icqq',
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  export default defineAgentTool<{ message_id: string; action: 'add' | 'remove' }>({
5
5
  description: '设置或移除 QQ 群精华消息',
6
6
  inputSchema: z.object({
7
- message_id: z.string().describe('消息 ID'),
7
+ message_id: z.string().describe('消息 ID,或消息头里的 m<n> 别名'),
8
8
  action: z.enum(['add', 'remove']).describe('add=设为精华, remove=移除精华'),
9
9
  }),
10
10
  adapter: 'icqq',
@@ -1,5 +1,6 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
2
  import { messages as t } from "../../../../messages.js";
3
+ import { freshMember } from "../../../../members.js";
3
4
  import { defineAgentTool } from '@zhin.js/tool';
4
5
  import { z } from 'zod';
5
6
  export default defineAgentTool({
@@ -16,8 +17,7 @@ export default defineAgentTool({
16
17
  const groupInfo = client.pickGroup(group_id);
17
18
  if (!groupInfo)
18
19
  return { success: false, message: t(context.locale, 'groupMissing') };
19
- const memberInfo = client.pickMember(group_id, user_id);
20
- if (memberInfo)
20
+ if (await freshMember(client, group_id, user_id))
21
21
  return { success: false, message: t(context.locale, 'memberExists') };
22
22
  await client.inviteFriend(group_id, user_id);
23
23
  return { success: true, message: t(context.locale, 'inviteAttempted', { p0: user_id, p1: group_id }) };
@@ -1,4 +1,5 @@
1
1
  import { messages as t } from "../../../../messages.js";
2
+ import { freshMember } from "../../../../members.js";
2
3
  import { defineAgentTool } from '@zhin.js/tool';
3
4
  import { z } from 'zod';
4
5
  export default defineAgentTool<{ group_id: number; user_id: number }>({
@@ -14,8 +15,7 @@ export default defineAgentTool<{ group_id: number; user_id: number }>({
14
15
  const client = context.$client;
15
16
  const groupInfo= client.pickGroup(group_id);
16
17
  if(!groupInfo) return { success: false, message: t(context.locale, 'groupMissing') };
17
- const memberInfo= client.pickMember(group_id,user_id);
18
- if(memberInfo) return { success: false, message: t(context.locale, 'memberExists') };
18
+ if (await freshMember(client, group_id, user_id)) return { success: false, message: t(context.locale, 'memberExists') };
19
19
  await client.inviteFriend(group_id, user_id);
20
20
  return { success: true, message: t(context.locale, 'inviteAttempted', { p0: user_id, p1: group_id }) };
21
21
  },
@@ -7,6 +7,7 @@ export default defineAgentTool({
7
7
  description: '让机器人退出当前 QQ 群;仅主人可发起且每次人工审批,机器人是群主时拒绝执行以免解散群',
8
8
  adapter: 'icqq',
9
9
  scopes: ['group'],
10
+ currentConversationBotRoles: ['admin', 'member'],
10
11
  permissions: ['role(master)'],
11
12
  requiresApproval: 'human',
12
13
  risk: 'dangerous',
@@ -7,6 +7,7 @@ export default defineAgentTool({
7
7
  description: '让机器人退出当前 QQ 群;仅主人可发起且每次人工审批,机器人是群主时拒绝执行以免解散群',
8
8
  adapter: 'icqq',
9
9
  scopes: ['group'],
10
+ currentConversationBotRoles: ['admin', 'member'],
10
11
  permissions: ['role(master)'],
11
12
  requiresApproval: 'human',
12
13
  risk: 'dangerous',
@@ -7,10 +7,11 @@ export default defineAgentTool({
7
7
  description: '撤回当前 QQ 群内他人的真实消息;需调用方具备群管发起权限及机器人具备实时平台权限,无需逐次审批',
8
8
  adapter: 'icqq',
9
9
  scopes: ['group'],
10
+ currentConversationBotRoles: ['owner', 'admin'],
10
11
  permissions: ['role(master,admin,trusted,owner)'],
11
12
  requiresApproval: 'never',
12
13
  inputSchema: z.object({
13
- message_id: z.string().min(1).describe('当前群中要撤回的真实消息 ID'),
14
+ message_id: z.string().min(1).describe('当前群中要撤回的真实消息 ID,或消息头里的 m<n> 别名'),
14
15
  reason: z.string().min(1).describe('撤回的具体原因,自动处理必须依据已确认的群规'),
15
16
  }),
16
17
  async execute({ message_id }, context) {
@@ -7,10 +7,11 @@ export default defineAgentTool({
7
7
  description: '撤回当前 QQ 群内他人的真实消息;需调用方具备群管发起权限及机器人具备实时平台权限,无需逐次审批',
8
8
  adapter: 'icqq',
9
9
  scopes: ['group'],
10
+ currentConversationBotRoles: ['owner', 'admin'],
10
11
  permissions: ['role(master,admin,trusted,owner)'],
11
12
  requiresApproval: 'never',
12
13
  inputSchema: z.object({
13
- message_id: z.string().min(1).describe('当前群中要撤回的真实消息 ID'),
14
+ message_id: z.string().min(1).describe('当前群中要撤回的真实消息 ID,或消息头里的 m<n> 别名'),
14
15
  reason: z.string().min(1).describe('撤回的具体原因,自动处理必须依据已确认的群规'),
15
16
  }),
16
17
  async execute({ message_id }, context) {
@@ -1,5 +1,6 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
2
  import { messages as t } from "../../../../messages.js";
3
+ import { freshMember, isGroupManager } from "../../../../members.js";
3
4
  import { defineAgentTool } from '@zhin.js/tool';
4
5
  import { z } from 'zod';
5
6
  export default defineAgentTool({
@@ -15,8 +16,7 @@ export default defineAgentTool({
15
16
  async execute({ group_id, enable }, context) {
16
17
  const client = context.$client;
17
18
  const on = enable ?? true;
18
- const selfInfo = client.pickMember(group_id, client.uin);
19
- if (!selfInfo.is_admin && !selfInfo.is_owner)
19
+ if (!isGroupManager(await freshMember(client, group_id, client.uin)))
20
20
  return { success: false, message: t(context.locale, 'anonymousForbidden') };
21
21
  await client.setGroupAnonymous(group_id, on);
22
22
  return { success: true, message: on ? t(context.locale, 'anonymousEnabled') : t(context.locale, 'anonymousDisabled') };
@@ -1,4 +1,5 @@
1
1
  import { messages as t } from "../../../../messages.js";
2
+ import { freshMember, isGroupManager } from "../../../../members.js";
2
3
  import { defineAgentTool } from '@zhin.js/tool';
3
4
  import { z } from 'zod';
4
5
  export default defineAgentTool<{ group_id: number; enable?: boolean }>({
@@ -14,8 +15,7 @@ export default defineAgentTool<{ group_id: number; enable?: boolean }>({
14
15
  async execute({ group_id, enable }, context) {
15
16
  const client = context.$client;
16
17
  const on = enable ?? true;
17
- const selfInfo= client.pickMember(group_id,client.uin);
18
- if(!selfInfo.is_admin && !selfInfo.is_owner) return { success: false, message: t(context.locale, 'anonymousForbidden') };
18
+ if (!isGroupManager(await freshMember(client, group_id, client.uin))) return { success: false, message: t(context.locale, 'anonymousForbidden') };
19
19
  await client.setGroupAnonymous(group_id, on);
20
20
  return { success: true, message: on ? t(context.locale, 'anonymousEnabled') : t(context.locale, 'anonymousDisabled') };
21
21
  },
@@ -1,5 +1,6 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
2
  import { messages as t } from "../../../../messages.js";
3
+ import { freshMember, isGroupManager } from "../../../../members.js";
3
4
  import { defineAgentTool } from '@zhin.js/tool';
4
5
  import { z } from 'zod';
5
6
  export default defineAgentTool({
@@ -14,11 +15,10 @@ export default defineAgentTool({
14
15
  requiresApproval: 'always',
15
16
  async execute({ group_id, user_id, duration }, context) {
16
17
  const client = context.$client;
17
- const selfInfo = client.pickMember(group_id, client.uin);
18
- if (!selfInfo.is_admin && !selfInfo.is_owner)
18
+ const [self, member] = await Promise.all([freshMember(client, group_id, client.uin), freshMember(client, group_id, user_id)]);
19
+ if (!isGroupManager(self))
19
20
  return { success: false, message: t(context.locale, 'muteForbidden') };
20
- const memberInfo = client.pickMember(group_id, user_id);
21
- if (!memberInfo)
21
+ if (!member)
22
22
  return { success: false, message: t(context.locale, 'memberMissing') };
23
23
  await client.setGroupBan(group_id, user_id, duration === 0 ? 0 : duration);
24
24
  return { success: true, message: duration === 0 ? t(context.locale, 'unmuted', { user: user_id }) : t(context.locale, 'muted', { user: user_id, seconds: duration ?? 0 }) };
@@ -1,4 +1,5 @@
1
1
  import { messages as t } from "../../../../messages.js";
2
+ import { freshMember, isGroupManager } from "../../../../members.js";
2
3
  import { defineAgentTool } from '@zhin.js/tool';
3
4
  import { z } from 'zod';
4
5
  export default defineAgentTool<{ group_id: number; user_id: number; duration?: number }>({
@@ -13,10 +14,9 @@ export default defineAgentTool<{ group_id: number; user_id: number; duration?: n
13
14
  requiresApproval: 'always',
14
15
  async execute({ group_id, user_id, duration }, context) {
15
16
  const client = context.$client;
16
- const selfInfo= client.pickMember(group_id,client.uin);
17
- if(!selfInfo.is_admin && !selfInfo.is_owner) return { success: false, message: t(context.locale, 'muteForbidden') };
18
- const memberInfo= client.pickMember(group_id,user_id);
19
- if(!memberInfo) return { success: false, message: t(context.locale, 'memberMissing') };
17
+ const [self, member] = await Promise.all([freshMember(client, group_id, client.uin), freshMember(client, group_id, user_id)]);
18
+ if (!isGroupManager(self)) return { success: false, message: t(context.locale, 'muteForbidden') };
19
+ if (!member) return { success: false, message: t(context.locale, 'memberMissing') };
20
20
  await client.setGroupBan(group_id, user_id, duration === 0 ? 0 : duration);
21
21
  return { success: true, message: duration === 0 ? t(context.locale, 'unmuted', { user: user_id }) : t(context.locale, 'muted', { user: user_id, seconds: duration ?? 0 }) };
22
22
  },
@@ -1,5 +1,6 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
2
  import { messages as t } from "../../../../messages.js";
3
+ import { freshMember, isGroupManager } from "../../../../members.js";
3
4
  import { defineAgentTool } from '@zhin.js/tool';
4
5
  import { z } from 'zod';
5
6
  export default defineAgentTool({
@@ -15,11 +16,10 @@ export default defineAgentTool({
15
16
  requiresApproval: 'always',
16
17
  async execute({ group_id, user_id, title, duration }, context) {
17
18
  const client = context.$client;
18
- const selfInfo = client.pickMember(group_id, client.uin);
19
- if (!selfInfo.is_admin && !selfInfo.is_owner)
19
+ const [self, member] = await Promise.all([freshMember(client, group_id, client.uin), freshMember(client, group_id, user_id)]);
20
+ if (!isGroupManager(self))
20
21
  return { success: false, message: t(context.locale, 'titleForbidden') };
21
- const memberInfo = client.pickMember(group_id, user_id);
22
- if (!memberInfo)
22
+ if (!member)
23
23
  return { success: false, message: t(context.locale, 'memberMissing') };
24
24
  await client.setGroupSpecialTitle(group_id, user_id, title, duration ?? -1);
25
25
  return { success: true, message: t(context.locale, 'titleChanged', { p0: user_id, p1: title }) };
@@ -1,4 +1,5 @@
1
1
  import { messages as t } from "../../../../messages.js";
2
+ import { freshMember, isGroupManager } from "../../../../members.js";
2
3
  import { defineAgentTool } from '@zhin.js/tool';
3
4
  import { z } from 'zod';
4
5
  export default defineAgentTool<{ group_id: number; user_id: number; title: string; duration?: number }>({
@@ -14,10 +15,9 @@ export default defineAgentTool<{ group_id: number; user_id: number; title: strin
14
15
  requiresApproval: 'always',
15
16
  async execute({ group_id, user_id, title, duration }, context) {
16
17
  const client = context.$client;
17
- const selfInfo= client.pickMember(group_id,client.uin);
18
- if(!selfInfo.is_admin && !selfInfo.is_owner) return { success: false, message: t(context.locale, 'titleForbidden') };
19
- const memberInfo= client.pickMember(group_id,user_id);
20
- if(!memberInfo) return { success: false, message: t(context.locale, 'memberMissing') };
18
+ const [self, member] = await Promise.all([freshMember(client, group_id, client.uin), freshMember(client, group_id, user_id)]);
19
+ if (!isGroupManager(self)) return { success: false, message: t(context.locale, 'titleForbidden') };
20
+ if (!member) return { success: false, message: t(context.locale, 'memberMissing') };
21
21
  await client.setGroupSpecialTitle(group_id, user_id, title, duration ?? -1);
22
22
  return { success: true, message: t(context.locale, 'titleChanged', { p0: user_id, p1: title }) };
23
23
  },
@@ -1,5 +1,6 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
2
  import { messages as t } from "../../../../messages.js";
3
+ import { freshMember } from "../../../../members.js";
3
4
  import { defineAgentTool } from '@zhin.js/tool';
4
5
  import { z } from 'zod';
5
6
  export default defineAgentTool({
@@ -13,15 +14,24 @@ export default defineAgentTool({
13
14
  permissions: ['role(master,admin,trusted,owner)'],
14
15
  requiresApproval: 'never',
15
16
  async execute({ group_id, user_id }, context) {
16
- const group = context.$client.pickGroup(group_id);
17
- if (!group)
18
- return { success: false, message: t(context.locale, 'groupMissing') };
19
- const memberInfo = group.pickMember(user_id);
20
- if (!memberInfo)
17
+ // pickMember 返回的是实体对象,序列化后只剩 `{}`:模型会据此以为成员不在群里。按 group_kick 的口径实时查询。
18
+ const info = await freshMember(context.$client, group_id, user_id);
19
+ if (!info)
21
20
  return { success: false, message: t(context.locale, 'memberMissing') };
22
21
  return {
23
22
  success: true,
24
- member: memberInfo,
23
+ member: {
24
+ group_id: info.group_id,
25
+ user_id: info.user_id,
26
+ nickname: info.nickname,
27
+ card: info.card,
28
+ role: info.role,
29
+ title: info.title,
30
+ level: info.level,
31
+ join_time: info.join_time,
32
+ last_sent_time: info.last_sent_time,
33
+ shutup_time: info.shutup_time,
34
+ },
25
35
  };
26
36
  },
27
37
  });
@@ -1,4 +1,5 @@
1
1
  import { messages as t } from "../../../../messages.js";
2
+ import { freshMember } from "../../../../members.js";
2
3
  import { defineAgentTool } from '@zhin.js/tool';
3
4
  import { z } from 'zod';
4
5
  interface GroupMemberInfoInput {
@@ -17,13 +18,23 @@ export default defineAgentTool<GroupMemberInfoInput>({
17
18
  permissions: ['role(master,admin,trusted,owner)'],
18
19
  requiresApproval: 'never',
19
20
  async execute({ group_id, user_id }, context) {
20
- const group = context.$client.pickGroup(group_id);
21
- if (!group) return { success: false, message: t(context.locale, 'groupMissing') };
22
- const memberInfo = group.pickMember(user_id);
23
- if (!memberInfo) return { success: false, message: t(context.locale, 'memberMissing') };
21
+ // pickMember 返回的是实体对象,序列化后只剩 `{}`:模型会据此以为成员不在群里。按 group_kick 的口径实时查询。
22
+ const info = await freshMember(context.$client, group_id, user_id);
23
+ if (!info) return { success: false, message: t(context.locale, 'memberMissing') };
24
24
  return {
25
25
  success: true,
26
- member: memberInfo,
26
+ member: {
27
+ group_id: info.group_id,
28
+ user_id: info.user_id,
29
+ nickname: info.nickname,
30
+ card: info.card,
31
+ role: info.role,
32
+ title: info.title,
33
+ level: info.level,
34
+ join_time: info.join_time,
35
+ last_sent_time: info.last_sent_time,
36
+ shutup_time: info.shutup_time,
37
+ },
27
38
  };
28
39
  },
29
40
  });
@@ -8,7 +8,7 @@ export default defineAgentTool({
8
8
  adapter: 'icqq',
9
9
  scopes: ['private', 'group'],
10
10
  requiresApproval: 'never',
11
- inputSchema: z.object({ message_ids: z.array(z.string().min(1)).min(1).describe('当前会话的真实消息 ID,按转发顺序排列') }),
11
+ inputSchema: z.object({ message_ids: z.array(z.string().min(1)).min(1).describe('当前会话的真实消息 ID(或消息头里的 m<n> 别名),按转发顺序排列') }),
12
12
  async execute({ message_ids }, context) {
13
13
  const conversation = currentConversation(context);
14
14
  if (conversation.kind === 'channel')
@@ -8,7 +8,7 @@ export default defineAgentTool({
8
8
  adapter: 'icqq',
9
9
  scopes: ['private', 'group'],
10
10
  requiresApproval: 'never',
11
- inputSchema: z.object({ message_ids: z.array(z.string().min(1)).min(1).describe('当前会话的真实消息 ID,按转发顺序排列') }),
11
+ inputSchema: z.object({ message_ids: z.array(z.string().min(1)).min(1).describe('当前会话的真实消息 ID(或消息头里的 m<n> 别名),按转发顺序排列') }),
12
12
  async execute({ message_ids }, context) {
13
13
  const conversation = currentConversation(context);
14
14
  if (conversation.kind === 'channel') throw new Error(t(context.locale, 'channelOperationUnsupported'));
@@ -8,7 +8,7 @@ export default defineAgentTool({
8
8
  scopes: ['private', 'group'],
9
9
  requiresApproval: 'never',
10
10
  inputSchema: z.object({
11
- message_id: z.string().min(1).describe('当前会话中真实的原消息 ID'),
11
+ message_id: z.string().min(1).describe('当前会话中真实的原消息 ID,或消息头里的 m<n> 别名'),
12
12
  text: z.string().min(1).describe('本次回复的文字'),
13
13
  }),
14
14
  async execute({ message_id, text }, context) {
@@ -8,7 +8,7 @@ export default defineAgentTool({
8
8
  scopes: ['private', 'group'],
9
9
  requiresApproval: 'never',
10
10
  inputSchema: z.object({
11
- message_id: z.string().min(1).describe('当前会话中真实的原消息 ID'),
11
+ message_id: z.string().min(1).describe('当前会话中真实的原消息 ID,或消息头里的 m<n> 别名'),
12
12
  text: z.string().min(1).describe('本次回复的文字'),
13
13
  }),
14
14
  async execute({ message_id, text }, context) {
@@ -8,7 +8,7 @@ export default defineAgentTool({
8
8
  adapter: 'icqq',
9
9
  scopes: ['private', 'group'],
10
10
  requiresApproval: 'never',
11
- inputSchema: z.object({ message_id: z.string().min(1).describe('机器人在当前会话中发送的真实消息 ID') }),
11
+ inputSchema: z.object({ message_id: z.string().min(1).describe('机器人在当前会话中发送的真实消息 ID,或消息头里的 m<n> 别名') }),
12
12
  async execute({ message_id }, context) {
13
13
  const message = await resolveMessage(context, message_id);
14
14
  if (message.sender.user_id !== context.$client.uin)
@@ -8,7 +8,7 @@ export default defineAgentTool({
8
8
  adapter: 'icqq',
9
9
  scopes: ['private', 'group'],
10
10
  requiresApproval: 'never',
11
- inputSchema: z.object({ message_id: z.string().min(1).describe('机器人在当前会话中发送的真实消息 ID') }),
11
+ inputSchema: z.object({ message_id: z.string().min(1).describe('机器人在当前会话中发送的真实消息 ID,或消息头里的 m<n> 别名') }),
12
12
  async execute({ message_id }, context) {
13
13
  const message = await resolveMessage(context, message_id);
14
14
  if (message.sender.user_id !== context.$client.uin) return { success: false, message: t(context.locale, 'ownMessageRequired') };
@@ -1,7 +1,7 @@
1
1
  # ICQQ Platform Agent
2
2
 
3
- You are the icqq platform specialist. Interpret requests in the current icqq conversation and load only the narrowest private Skill needed for the task.
3
+ You are the icqq platform specialist. Interpret requests in the current icqq conversation and load only the narrowest available Skill needed for the task.
4
4
 
5
- For your own group/guild role, current scene information or platform permissions, load `icqq-conversation` and query `conversation_info`. Sender roles describe the requester, not you. Do not infer your role from old session summaries or another endpoint account.
5
+ Use the current Bot snapshot for your platform role. Load `icqq-conversation` and query `conversation_info` when the role is unknown or you need more scene or permission details. Sender roles describe the requester, not you. Do not infer your role from old session summaries or another endpoint account.
6
6
 
7
- For quoting, forwarding or recalling your own speech, load `icqq-messages`. Your own-message recall needs no group management role or per-call approval. For recalling another member's message or leaving a group, load `icqq-group-admin` and follow the tool-specific rules; only leaving requires fresh approval. All these message tools are restricted to the current conversation. Guild-channel limitations are explicit in `conversation_info`.
7
+ Discover message and management operations from the current scene's Skill catalog; follow each tool's target, platform and approval rules. Recalling your own messages needs no group management role or per-call approval. Leaving a group requires fresh approval and is forbidden when you own the group. Do not load a Skill that is absent from the current catalog.
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
14
  import { formatInboundContent, formatOutboundMessages, 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',
@@ -57,6 +58,8 @@ export class IcqqEndpoint extends Endpoint {
57
58
  content;
58
59
  management = Object.freeze({
59
60
  inspectGroupMember: (group, user, signal) => inspectGroupMember(this.client, group, user, signal),
61
+ inspectGroupScene: (group, signal) => inspectGroupScene(this.client, group, signal),
62
+ inspectGroupMemberProfile: (group, user, signal) => inspectGroupMemberProfile(this.client, group, user, signal),
60
63
  moderateGroupMember: (group, user, action, signal) => moderateGroupMember(this.client, group, user, action, signal, () => this.#open && !this.#retiring && !this.#stopped),
61
64
  listFriends: async () => Array.from(this.client.fl.values()).map((f) => ({
62
65
  user_id: f.user_id,
@@ -743,6 +746,7 @@ export class IcqqEndpoint extends Endpoint {
743
746
  channelType: normalized.channelType,
744
747
  channelId: normalized.channelId,
745
748
  channelParentGroupId: normalized.channelParentGroupId,
749
+ name: normalized.groupName,
746
750
  });
747
751
  const quoteId = resolveIcqqQuoteIdFromEvent(data);
748
752
  const quoted = messageLookupFromIcqqSource(findIcqqNestedMessageSource(data));
@@ -857,13 +861,7 @@ export class IcqqEndpoint extends Endpoint {
857
861
  #onNoticeEvent(payload) {
858
862
  if (!payload || !isIcqqNoticePayload(payload))
859
863
  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}`))
864
+ if (!this.#sideEventDeduper.shouldProcess(icqqNoticeDedupeKey(payload)))
867
865
  return;
868
866
  this.#dispatchIcqqSideEvent(payload);
869
867
  }
@@ -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;两者皆无则无法审批,丢弃。
package/lib/protocol.d.ts CHANGED
@@ -246,6 +246,8 @@ export declare function icqqInboundConversation(endpointKey: string, input: {
246
246
  readonly channelId: string;
247
247
  readonly channelParentGroupId?: string;
248
248
  readonly guildId?: string;
249
+ /** 群名(仅 group 会话使用) */
250
+ readonly name?: string;
249
251
  }): ConversationRef;
250
252
  /**
251
253
  * 出站:从 ConversationRef 派生 action 路由。
package/lib/protocol.js CHANGED
@@ -232,7 +232,8 @@ export function icqqInboundConversation(endpointKey, input) {
232
232
  parent: { kind: 'channel', id: input.guildId },
233
233
  };
234
234
  }
235
- return { endpoint, kind: input.channelType, id: input.channelId };
235
+ const name = input.channelType === 'group' ? input.name?.trim() : undefined;
236
+ return { endpoint, kind: input.channelType, id: input.channelId, ...(name ? { name } : {}) };
236
237
  }
237
238
  /**
238
239
  * 出站:从 ConversationRef 派生 action 路由。
@@ -362,7 +363,8 @@ function formatOutboundElements(payload) {
362
363
  if (!Number.isSafeInteger(qq) || qq <= 0) {
363
364
  throw new TypeError(`Invalid ICQQ mention target: ${String(target ?? '')}`);
364
365
  }
365
- return { type: 'at', qq };
366
+ const name = typeof item.data?.name === 'string' ? item.data.name.trim() : '';
367
+ return name ? { type: 'at', qq, text: name.startsWith('@') ? name : `@${name}` } : { type: 'at', qq };
366
368
  }
367
369
  case 'image': {
368
370
  const arg = resolveIcqqMediaFile(normalizeIcqqImageData(item.data));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-icqq",
3
- "version": "1.1.16",
3
+ "version": "1.1.17",
4
4
  "description": "Zhin.js ICQQ adapter — direct connection to @icqqjs/icqq",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -33,24 +33,24 @@
33
33
  "directory": "plugins/adapters/icqq"
34
34
  },
35
35
  "dependencies": {
36
- "@zhin.js/adapter": "1.1.23",
36
+ "@zhin.js/adapter": "1.1.24",
37
37
  "@zhin.js/agent-feature": "1.1.10",
38
- "@zhin.js/core": "1.1.51",
38
+ "@zhin.js/core": "1.1.52",
39
39
  "@zhin.js/feature-kit": "1.1.9",
40
- "@zhin.js/im-contract": "1.1.7",
40
+ "@zhin.js/im-contract": "1.1.8",
41
41
  "@zhin.js/logger": "1.1.2",
42
42
  "@zhin.js/permission": "1.1.9",
43
43
  "@zhin.js/skill": "1.1.10",
44
- "@zhin.js/tool": "1.1.10"
44
+ "@zhin.js/tool": "1.1.11"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@icqqjs/icqq": "^1.12.2",
48
48
  "zod": "^4.0.0",
49
- "@zhin.js/adapter": "^1.1.23",
49
+ "@zhin.js/adapter": "^1.1.24",
50
50
  "@zhin.js/command": "^1.1.11",
51
- "@zhin.js/core": "^1.1.51",
51
+ "@zhin.js/core": "^1.1.52",
52
52
  "@zhin.js/prompt-section": "^1.1.9",
53
- "zhin.js": "^1.1.16"
53
+ "zhin.js": "^1.1.17"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
56
  "@icqqjs/icqq": {
@@ -76,7 +76,7 @@
76
76
  "vitest": "^4.1.10",
77
77
  "zod": "^4.4.3",
78
78
  "@zhin.js/prompt-section": "1.1.9",
79
- "zhin.js": "1.1.16"
79
+ "zhin.js": "1.1.17"
80
80
  },
81
81
  "files": [
82
82
  "adapters",
package/src/endpoint.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  type IcqqMessageEvent,
40
40
  } from './icqq-inbound.js';
41
41
  import {
42
+ icqqNoticeDedupeKey,
42
43
  isIcqqNoticePayload,
43
44
  isIcqqRequestPayload,
44
45
  } from './icqq-inbox.js';
@@ -62,6 +63,7 @@ import {
62
63
  import type { SystemMessage as IcqqSystemMessage } from './types.js';
63
64
  import { ManagedIcqqClient, type IcqqBackgroundOperation } from './managed-client.js';
64
65
  import { inspectGroupMember, moderateGroupMember } from './group-moderation.js';
66
+ import { inspectGroupMemberProfile, inspectGroupScene } from './group-scene.js';
65
67
 
66
68
  export interface IcqqEndpointOptions {
67
69
  readonly id: CapabilityId;
@@ -115,6 +117,8 @@ export class IcqqEndpoint extends Endpoint<Client> {
115
117
 
116
118
  readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
117
119
  inspectGroupMember: (group, user, signal) => inspectGroupMember(this.client, group, user, signal),
120
+ inspectGroupScene: (group, signal) => inspectGroupScene(this.client, group, signal),
121
+ inspectGroupMemberProfile: (group, user, signal) => inspectGroupMemberProfile(this.client, group, user, signal),
118
122
  moderateGroupMember: (group, user, action, signal) => moderateGroupMember(
119
123
  this.client, group, user, action, signal, () => this.#open && !this.#retiring && !this.#stopped,
120
124
  ),
@@ -823,6 +827,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
823
827
  channelType: normalized.channelType,
824
828
  channelId: normalized.channelId,
825
829
  channelParentGroupId: normalized.channelParentGroupId,
830
+ name: normalized.groupName,
826
831
  });
827
832
  const quoteId = resolveIcqqQuoteIdFromEvent(data);
828
833
  const quoted = messageLookupFromIcqqSource(findIcqqNestedMessageSource(data));
@@ -942,13 +947,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
942
947
 
943
948
  #onNoticeEvent(payload: Record<string, unknown> | null): void {
944
949
  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;
950
+ if (!this.#sideEventDeduper.shouldProcess(icqqNoticeDedupeKey(payload))) return;
952
951
  this.#dispatchIcqqSideEvent(payload);
953
952
  }
954
953
 
@@ -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;两者皆无则无法审批,丢弃。
package/src/protocol.ts CHANGED
@@ -342,6 +342,8 @@ export function icqqInboundConversation(endpointKey: string, input: {
342
342
  readonly channelId: string;
343
343
  readonly channelParentGroupId?: string;
344
344
  readonly guildId?: string;
345
+ /** 群名(仅 group 会话使用) */
346
+ readonly name?: string;
345
347
  }): ConversationRef {
346
348
  const endpoint = { id: endpointKey, adapter: ICQQ_ADAPTER_NAME };
347
349
  if (input.channelType === 'private' && input.channelParentGroupId) {
@@ -360,7 +362,8 @@ export function icqqInboundConversation(endpointKey: string, input: {
360
362
  parent: { kind: 'channel', id: input.guildId },
361
363
  };
362
364
  }
363
- return { endpoint, kind: input.channelType, id: input.channelId };
365
+ const name = input.channelType === 'group' ? input.name?.trim() : undefined;
366
+ return { endpoint, kind: input.channelType, id: input.channelId, ...(name ? { name } : {}) };
364
367
  }
365
368
 
366
369
  /**
@@ -487,7 +490,8 @@ function formatOutboundElements(payload: readonly unknown[]): (string | MessageE
487
490
  if (!Number.isSafeInteger(qq) || qq <= 0) {
488
491
  throw new TypeError(`Invalid ICQQ mention target: ${String(target ?? '')}`);
489
492
  }
490
- return { type: 'at', qq };
493
+ const name = typeof item.data?.name === 'string' ? item.data.name.trim() : '';
494
+ return name ? { type: 'at', qq, text: name.startsWith('@') ? name : `@${name}` } : { type: 'at', qq };
491
495
  }
492
496
  case 'image': {
493
497
  const arg = resolveIcqqMediaFile(normalizeIcqqImageData(item.data));