@zhin.js/adapter-icqq 1.1.21 → 1.1.23

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 (37) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/PERMITS.md +1 -0
  3. package/README.md +1 -1
  4. package/adapters/icqq/index.js +96 -25
  5. package/adapters/icqq/index.ts +97 -27
  6. package/agents/icqq/messages.js +20 -0
  7. package/agents/icqq/messages.ts +20 -0
  8. package/agents/icqq/skills/icqq-group-admin/SKILL.md +5 -0
  9. package/agents/icqq/skills/icqq-group-admin/tools/announce/index.js +2 -0
  10. package/agents/icqq/skills/icqq-group-admin/tools/announce/index.ts +2 -0
  11. package/agents/icqq/skills/icqq-group-admin/tools/essence/index.js +2 -0
  12. package/agents/icqq/skills/icqq-group-admin/tools/essence/index.ts +2 -0
  13. package/agents/icqq/skills/icqq-group-admin/tools/group_kick/index.js +2 -0
  14. package/agents/icqq/skills/icqq-group-admin/tools/group_kick/index.ts +2 -0
  15. package/agents/icqq/skills/icqq-group-admin/tools/recall_member_message/index.js +2 -0
  16. package/agents/icqq/skills/icqq-group-admin/tools/recall_member_message/index.ts +2 -0
  17. package/agents/icqq/skills/icqq-group-admin/tools/set_group_admin/index.js +43 -0
  18. package/agents/icqq/skills/icqq-group-admin/tools/set_group_admin/index.ts +36 -0
  19. package/agents/icqq/skills/icqq-group-admin/tools/set_mute/index.js +2 -0
  20. package/agents/icqq/skills/icqq-group-admin/tools/set_mute/index.ts +2 -0
  21. package/agents/icqq/skills/icqq-group-admin/tools/set_title/index.js +2 -0
  22. package/agents/icqq/skills/icqq-group-admin/tools/set_title/index.ts +2 -0
  23. package/lib/endpoint.js +4 -1
  24. package/lib/group-actions.d.ts +10 -0
  25. package/lib/group-actions.js +94 -0
  26. package/lib/icqq-inbound.d.ts +2 -6
  27. package/lib/icqq-inbound.js +5 -30
  28. package/lib/outbound-media.js +10 -4
  29. package/lib/sensitive-commands.d.ts +8 -0
  30. package/lib/sensitive-commands.js +16 -0
  31. package/package.json +24 -18
  32. package/plugin.js +8 -1
  33. package/src/endpoint.ts +4 -1
  34. package/src/group-actions.ts +105 -0
  35. package/src/icqq-inbound.ts +13 -32
  36. package/src/outbound-media.ts +9 -3
  37. package/src/sensitive-commands.ts +19 -0
@@ -11,6 +11,8 @@ export default defineAgentTool({
11
11
  duration: z.number().describe('禁言时间(秒),0=解除禁言').default(0),
12
12
  }),
13
13
  adapter: 'icqq',
14
+ // ADR 0044: offered only while the Bot can actually do this here (e.g. it is a group admin).
15
+ requiresCapabilities: ['actions.mute'],
14
16
  scopes: ['group'],
15
17
  requiresApproval: 'always',
16
18
  async execute({ group_id, user_id, duration }, context) {
@@ -10,6 +10,8 @@ export default defineAgentTool<{ group_id: number; user_id: number; duration?: n
10
10
  duration: z.number().describe('禁言时间(秒),0=解除禁言').default(0),
11
11
  }),
12
12
  adapter: 'icqq',
13
+ // ADR 0044: offered only while the Bot can actually do this here (e.g. it is a group admin).
14
+ requiresCapabilities: ['actions.mute'],
13
15
  scopes:['group'],
14
16
  requiresApproval: 'always',
15
17
  async execute({ group_id, user_id, duration }, context) {
@@ -12,6 +12,8 @@ export default defineAgentTool({
12
12
  duration: z.number().optional().describe('持续时间(秒),-1永久').default(-1),
13
13
  }),
14
14
  adapter: 'icqq',
15
+ // ADR 0044: offered only while the Bot can actually do this here (e.g. it is a group admin).
16
+ requiresCapabilities: ['actions.setTitle'],
15
17
  scopes: ['group'],
16
18
  requiresApproval: 'always',
17
19
  async execute({ group_id, user_id, title, duration }, context) {
@@ -11,6 +11,8 @@ export default defineAgentTool<{ group_id: number; user_id: number; title: strin
11
11
  duration: z.number().optional().describe('持续时间(秒),-1永久').default(-1),
12
12
  }),
13
13
  adapter: 'icqq',
14
+ // ADR 0044: offered only while the Bot can actually do this here (e.g. it is a group admin).
15
+ requiresCapabilities: ['actions.setTitle'],
14
16
  scopes:['group'],
15
17
  requiresApproval: 'always',
16
18
  async execute({ group_id, user_id, title, duration }, context) {
package/lib/endpoint.js CHANGED
@@ -2,7 +2,8 @@ import { parseGroupMessageId, } from '@icqqjs/icqq';
2
2
  import { inspect } from 'node:util';
3
3
  import { AsyncLocalStorage } from 'node:async_hooks';
4
4
  import { createEndpointSpecification, endpointSpec, createEndpointOperation, } from 'zhin.js/adapter';
5
- import { receiveOneBotLikeSideEvent, buildSystem, toCanonicalSegments } from '@zhin.js/core';
5
+ import { receiveOneBotLikeSideEvent } from '@zhin.js/adapter-onebot-events';
6
+ import { buildSystem, toCanonicalSegments } from '@zhin.js/core';
6
7
  import { EndpointDeliveryError } from '@zhin.js/im-contract';
7
8
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
8
9
  import { runIcqqLoginAssistStep } from './icqq-login-assist.js';
@@ -13,6 +14,7 @@ import { IcqqGuildCatalog, normalizeIcqqGuildInboundMessage, } from './icqq-guil
13
14
  import { prepareIcqqOutboundMedia, resolveIcqqOutboundMediaMode, } from './outbound-media.js';
14
15
  import { formatInboundContent, formatOutboundMessageParts, normalizeIcqqImageMedia, icqqInboundConversation, icqqOutboundTarget, } from './protocol.js';
15
16
  import { ManagedIcqqClient } from './managed-client.js';
17
+ import { createIcqqActionPorts } from './group-actions.js';
16
18
  import { inspectGroupMember, moderateGroupMember } from './group-moderation.js';
17
19
  import { inspectGroupMemberProfile, inspectGroupScene } from './group-scene.js';
18
20
  import { createIcqqSelfProfile } from './self-profile.js';
@@ -108,6 +110,7 @@ export class IcqqEndpoint extends ManagedIcqqClient {
108
110
  throw new Error('QQ did not confirm the group notice');
109
111
  }
110
112
  },
113
+ ...createIcqqActionPorts(() => this),
111
114
  });
112
115
  #control = Object.freeze({
113
116
  recall: async ({ message, signal }) => {
@@ -0,0 +1,10 @@
1
+ import type { Client } from '@icqqjs/icqq';
2
+ import { type EndpointManagement } from 'zhin.js/adapter';
3
+ type ActionPorts = Pick<EndpointManagement, 'pokeUser' | 'unmuteGroupMember' | 'muteAllGroupMembers' | 'setGroupCard' | 'setGroupTitle' | 'setGroupEssence' | 'inviteGroupMember'>;
4
+ /**
5
+ * ADR 0044 action ports of the icqq endpoint. Where the bot's role decides the outcome the role is
6
+ * read fresh from the platform first, so a stale cache can neither allow nor refuse the action.
7
+ * Each request carries its own lifetime: reads are `prepare`, the native effect is `run`.
8
+ */
9
+ export declare function createIcqqActionPorts(getClient: () => Client): ActionPorts;
10
+ export {};
@@ -0,0 +1,94 @@
1
+ import { createEndpointOperation } from 'zhin.js/adapter';
2
+ function numericId(value) {
3
+ const id = Number(value);
4
+ if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(id))
5
+ throw new Error('Invalid QQ identifier');
6
+ return id;
7
+ }
8
+ /** Uncached member lookup; `pickMember` and the cached roster would report stale roles. */
9
+ async function freshMember(operation, client, group, user) {
10
+ try {
11
+ const member = await operation.prepare(() => client.getGroupMemberInfo(group, user, true));
12
+ return member.user_id === user && member.group_id === group ? member : undefined;
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ async function requireBotRole(operation, client, group, roles, message) {
19
+ const self = await freshMember(operation, client, group, client.uin);
20
+ if (!self || !roles.includes(self.role))
21
+ throw new Error(message);
22
+ }
23
+ function confirmed(ok, what) {
24
+ if (ok === false)
25
+ throw new Error(`QQ did not confirm ${what}`);
26
+ }
27
+ /**
28
+ * ADR 0044 action ports of the icqq endpoint. Where the bot's role decides the outcome the role is
29
+ * read fresh from the platform first, so a stale cache can neither allow nor refuse the action.
30
+ * Each request carries its own lifetime: reads are `prepare`, the native effect is `run`.
31
+ */
32
+ export function createIcqqActionPorts(getClient) {
33
+ return {
34
+ async pokeUser({ userId, groupId, signal }) {
35
+ const client = getClient();
36
+ const operation = createEndpointOperation({ signal });
37
+ const user = numericId(userId);
38
+ confirmed(groupId === undefined
39
+ ? await operation.run(() => client.pickFriend(user).poke(false))
40
+ : await operation.run(() => client.sendGroupPoke(numericId(groupId), user)), 'poke');
41
+ },
42
+ async unmuteGroupMember({ groupId, userId, signal }) {
43
+ const client = getClient();
44
+ const operation = createEndpointOperation({ signal });
45
+ const group = numericId(groupId);
46
+ await requireBotRole(operation, client, group, ['owner', 'admin'], 'Bot is not a group admin');
47
+ confirmed(await operation.run(() => client.setGroupBan(group, numericId(userId), 0)), 'unmute');
48
+ },
49
+ async muteAllGroupMembers({ groupId, enabled, signal }) {
50
+ const client = getClient();
51
+ const operation = createEndpointOperation({ signal });
52
+ const group = numericId(groupId);
53
+ await requireBotRole(operation, client, group, ['owner', 'admin'], 'Bot is not a group admin');
54
+ confirmed(await operation.run(() => client.setGroupWholeBan(group, enabled)), 'whole-group mute');
55
+ },
56
+ async setGroupCard({ groupId, userId, card, signal }) {
57
+ const client = getClient();
58
+ const operation = createEndpointOperation({ signal });
59
+ const group = numericId(groupId);
60
+ const user = numericId(userId);
61
+ if (user !== client.uin)
62
+ await requireBotRole(operation, client, group, ['owner', 'admin'], 'Bot is not a group admin');
63
+ confirmed(await operation.run(() => client.setGroupCard(group, user, card)), 'card change');
64
+ },
65
+ async setGroupTitle({ groupId, userId, title, durationSeconds, signal }) {
66
+ const client = getClient();
67
+ const operation = createEndpointOperation({ signal });
68
+ const group = numericId(groupId);
69
+ const user = numericId(userId);
70
+ await requireBotRole(operation, client, group, ['owner'], 'Only the group owner can set special titles');
71
+ if (!await freshMember(operation, client, group, user))
72
+ throw new Error('Target is not a group member');
73
+ confirmed(await operation.run(() => client.setGroupSpecialTitle(group, user, title, durationSeconds ?? -1)), 'title change');
74
+ },
75
+ async setGroupEssence({ groupId, messageId, enabled, signal }) {
76
+ const client = getClient();
77
+ const operation = createEndpointOperation({ signal });
78
+ await requireBotRole(operation, client, numericId(groupId), ['owner', 'admin'], 'Bot is not a group admin');
79
+ if (enabled)
80
+ await operation.run(() => client.setEssenceMessage(messageId));
81
+ else
82
+ await operation.run(() => client.removeEssenceMessage(messageId));
83
+ },
84
+ async inviteGroupMember({ groupId, userId, signal }) {
85
+ const client = getClient();
86
+ const operation = createEndpointOperation({ signal });
87
+ const group = numericId(groupId);
88
+ const user = numericId(userId);
89
+ if (await freshMember(operation, client, group, user))
90
+ throw new Error('Target is already a group member');
91
+ confirmed(await operation.run(() => client.inviteFriend(group, user)), 'invitation');
92
+ },
93
+ };
94
+ }
@@ -2,6 +2,7 @@
2
2
  * icqq 入站事件归一化(post_type / message_type / message 段)
3
3
  */
4
4
  import { type MessageSegment } from "zhin.js";
5
+ import { InboundMessageDeduper } from "zhin.js/adapter";
5
6
  import type { GroupRole } from "./types.js";
6
7
  /** 守护进程推送的通用事件壳 */
7
8
  export interface IcqqEventBase {
@@ -163,9 +164,4 @@ export declare function resolveIcqqQuoteIdFromEvent(data: IcqqMessageEvent): str
163
164
  export declare function resolveInboundContent(data: IcqqMessageEvent): MessageSegment[];
164
165
  export declare function normalizeIcqqInboundMessage(data: IcqqMessageEvent): NormalizedIcqqInbound | null;
165
166
  /** 按 message_id 去重(多路 subscribe 会收到同一消息多次) */
166
- export declare class InboundMessageDeduper {
167
- private readonly seen;
168
- shouldProcess(messageId: string): boolean;
169
- clear(): void;
170
- }
171
- export {};
167
+ export { InboundMessageDeduper };
@@ -2,6 +2,7 @@
2
2
  * icqq 入站事件归一化(post_type / message_type / message 段)
3
3
  */
4
4
  import { toCanonicalSegments } from "zhin.js";
5
+ import { InboundMessageDeduper, promoteInboundMessagePayload } from "zhin.js/adapter";
5
6
  import { parseCqMessage, icqqMediaRefFromString } from "./cq-message.js";
6
7
  import { extractForwardResidFromJsonElement } from "./forward-msg.js";
7
8
  /**
@@ -32,7 +33,6 @@ export async function resolveIcqqInboundMedia(elements, resolver) {
32
33
  }
33
34
  }));
34
35
  }
35
- const DEDUPE_TTL_MS = 120_000;
36
36
  const ICQQ_GROUP_ROLES = new Set(["owner", "admin", "member"]);
37
37
  /** 从 sender.role 解析群成员角色(供 Message.$sender → resolveSenderRoles) */
38
38
  export function parseIcqqSenderGroupRole(sender) {
@@ -51,14 +51,8 @@ export function isIcqqBotMentioned(input) {
51
51
  const uin = String(input.uin ?? "").trim();
52
52
  if (!uin)
53
53
  return false;
54
- for (const seg of input.content ?? []) {
55
- if (!seg || (seg.type !== "at" && seg.type !== "mention"))
56
- continue;
57
- const data = (seg.data ?? {});
58
- const target = String(data.qq ?? data.user_id ?? data.id ?? data.target ?? "").trim();
59
- if (target && target !== "all" && Number(target) === Number(uin)) {
60
- return true;
61
- }
54
+ if (promoteInboundMessagePayload({ segments: input.content ?? [] }, uin).mentioned === true) {
55
+ return true;
62
56
  }
63
57
  const raw = input.rawMessage ?? "";
64
58
  if (!raw)
@@ -648,10 +642,7 @@ function mergeReplyFromSource(content, data) {
648
642
  ];
649
643
  }
650
644
  function quoteIdFromSegments(content) {
651
- const reply = content.find((item) => item.type === 'reply');
652
- const data = reply?.data;
653
- const value = data?.message_id ?? data?.id;
654
- return value == null || !String(value).trim() ? undefined : String(value).trim();
645
+ return promoteInboundMessagePayload({ segments: content }, undefined).replyTo?.id;
655
646
  }
656
647
  export function resolveInboundContent(data) {
657
648
  const fromElements = icqqElementsToSegments(data.message);
@@ -701,20 +692,4 @@ export function normalizeIcqqInboundMessage(data) {
701
692
  };
702
693
  }
703
694
  /** 按 message_id 去重(多路 subscribe 会收到同一消息多次) */
704
- export class InboundMessageDeduper {
705
- seen = new Map();
706
- shouldProcess(messageId) {
707
- const now = Date.now();
708
- for (const [id, t] of this.seen) {
709
- if (now - t > DEDUPE_TTL_MS)
710
- this.seen.delete(id);
711
- }
712
- if (this.seen.has(messageId))
713
- return false;
714
- this.seen.set(messageId, now);
715
- return true;
716
- }
717
- clear() {
718
- this.seen.clear();
719
- }
720
- }
695
+ export { InboundMessageDeduper };
@@ -43,21 +43,27 @@ export function prepareIcqqOutboundMedia(content, mode = "file") {
43
43
  return seg;
44
44
  const d = data;
45
45
  const media = d.media;
46
- if (!isMediaRef(media) || media.kind !== "base64")
47
- return seg;
48
- const b64 = media.value.replace(/^base64:\/\//, "");
46
+ let b64 = "";
47
+ let mime = "";
48
+ if (isMediaRef(media) && media.kind === "base64") {
49
+ b64 = media.value.replace(/^base64:\/\//, "");
50
+ mime = media.mime_type ?? "";
51
+ }
52
+ else if (typeof d.file === "string" && d.file.startsWith("base64://")) {
53
+ b64 = d.file.slice("base64://".length);
54
+ }
49
55
  if (!b64)
50
56
  return seg;
51
57
  if (type !== "image" && type !== "record" && type !== "audio" && type !== "video") {
52
58
  return seg;
53
59
  }
54
- const mime = media.mime_type ?? "";
55
60
  const filePath = spoolBase64ToFile(b64, mime || (type === "image" ? "image/jpeg" : type === "video" ? "video/mp4" : "audio/mpeg"), type === "record" || type === "audio" ? "audio" : type);
56
61
  createdFiles.push(filePath);
57
62
  return {
58
63
  type,
59
64
  data: {
60
65
  ...d,
66
+ ...(typeof d.file === "string" && d.file.startsWith("base64://") ? { file: filePath } : {}),
61
67
  media: {
62
68
  kind: "path",
63
69
  value: filePath,
@@ -0,0 +1,8 @@
1
+ import type { SensitiveExecCommandRule } from '@zhin.js/agent';
2
+ /**
3
+ * The `icqq` command-line client can kick members, move money and delete files. Under an `allowlist`
4
+ * exec policy its non-sensitive subcommands run freely, while these need owner approval (ADR 0044:
5
+ * this knowledge lives with the adapter, the exec policy only reads the registered rule).
6
+ */
7
+ export declare const ICQQ_SENSITIVE_SUBCOMMAND_REGEXES: readonly RegExp[];
8
+ export declare const icqqSensitiveExecRule: SensitiveExecCommandRule;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The `icqq` command-line client can kick members, move money and delete files. Under an `allowlist`
3
+ * exec policy its non-sensitive subcommands run freely, while these need owner approval (ADR 0044:
4
+ * this knowledge lives with the adapter, the exec policy only reads the registered rule).
5
+ */
6
+ export const ICQQ_SENSITIVE_SUBCOMMAND_REGEXES = Object.freeze([
7
+ /\bicqq\s+friend\s+(?:delete|block|move)\b/i,
8
+ /\bicqq\s+group\s+(?:kick|mute|set|admin|announce|transfer|quit|dissolve|invite)\b/i,
9
+ /\bicqq\s+(?:request|wallet|pay|recall)\b/i,
10
+ /\bicqq\s+file\s+(?:upload|delete)\b/i,
11
+ /\bicqq\s+gfs\b.*\b(?:rm|delete|del)\b/i,
12
+ ]);
13
+ export const icqqSensitiveExecRule = Object.freeze({
14
+ command: 'icqq',
15
+ sensitive: ICQQ_SENSITIVE_SUBCOMMAND_REGEXES,
16
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-icqq",
3
- "version": "1.1.21",
3
+ "version": "1.1.23",
4
4
  "description": "Zhin.js ICQQ adapter — direct connection to @icqqjs/icqq",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -33,32 +33,37 @@
33
33
  "directory": "plugins/adapters/icqq"
34
34
  },
35
35
  "dependencies": {
36
- "@zhin.js/adapter": "1.1.27",
37
- "@zhin.js/agent-feature": "1.1.12",
38
- "@zhin.js/client": "1.1.23",
36
+ "@zhin.js/adapter-onebot-events": "0.0.2",
37
+ "@zhin.js/adapter": "1.1.29",
38
+ "@zhin.js/agent-feature": "1.1.14",
39
+ "@zhin.js/client": "1.1.25",
39
40
  "@zhin.js/console-contract": "1.1.3",
40
- "@zhin.js/core": "1.1.55",
41
- "@zhin.js/feature-kit": "1.1.11",
42
- "@zhin.js/im-contract": "1.1.10",
41
+ "@zhin.js/core": "1.1.57",
42
+ "@zhin.js/feature-kit": "1.1.13",
43
+ "@zhin.js/im-contract": "1.1.12",
43
44
  "@zhin.js/logger": "1.1.2",
44
- "@zhin.js/page": "1.1.13",
45
- "@zhin.js/permission": "1.1.11",
46
- "@zhin.js/skill": "1.1.12",
47
- "@zhin.js/tool": "1.1.14"
45
+ "@zhin.js/page": "1.1.15",
46
+ "@zhin.js/permission": "1.1.13",
47
+ "@zhin.js/skill": "1.1.14",
48
+ "@zhin.js/tool": "1.1.16"
48
49
  },
49
50
  "peerDependencies": {
50
51
  "@icqqjs/icqq": "^1.12.2",
51
52
  "zod": "^4.0.0",
52
- "@zhin.js/adapter": "^1.1.27",
53
- "@zhin.js/command": "^1.1.13",
54
- "@zhin.js/core": "^1.1.55",
55
- "@zhin.js/prompt-section": "^1.1.11",
56
- "zhin.js": "^1.1.20"
53
+ "@zhin.js/adapter": "^1.1.29",
54
+ "@zhin.js/agent": "^1.1.48",
55
+ "@zhin.js/command": "^1.1.15",
56
+ "@zhin.js/core": "^1.1.57",
57
+ "@zhin.js/prompt-section": "^1.1.13",
58
+ "zhin.js": "^1.1.22"
57
59
  },
58
60
  "peerDependenciesMeta": {
59
61
  "@icqqjs/icqq": {
60
62
  "optional": true
61
63
  },
64
+ "@zhin.js/agent": {
65
+ "optional": true
66
+ },
62
67
  "@zhin.js/command": {
63
68
  "optional": true
64
69
  },
@@ -78,8 +83,9 @@
78
83
  "typescript": "^6.0.3",
79
84
  "vitest": "^4.1.10",
80
85
  "zod": "^4.4.3",
81
- "@zhin.js/prompt-section": "1.1.11",
82
- "zhin.js": "1.1.20"
86
+ "@zhin.js/agent": "1.1.48",
87
+ "@zhin.js/prompt-section": "1.1.13",
88
+ "zhin.js": "1.1.22"
83
89
  },
84
90
  "files": [
85
91
  "adapters",
package/plugin.js CHANGED
@@ -1,9 +1,12 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
2
  import { definePlugin, pluginConsoleRpcToken } from 'zhin.js';
3
3
  import { permissionHostToken, createSceneRolePlatformChecker } from '@zhin.js/permission';
4
+ import { registerSensitiveExecCommand } from '@zhin.js/agent';
4
5
  import { createIcqqConsoleRpc } from "./lib/console.js";
6
+ import { icqqSensitiveExecRule } from "./lib/sensitive-commands.js";
5
7
  export default definePlugin({
6
8
  name: 'icqq',
9
+ configUpdate: { featurePaths: [['nickname'], ['profile'], ['endpoints']] },
7
10
  metadata: {
8
11
  displayName: 'ICQQ Adapter',
9
12
  },
@@ -12,6 +15,10 @@ export default definePlugin({
12
15
  const disposePlatform = context.resources.has(permissionHostToken)
13
16
  ? context.resources.use(permissionHostToken).registerPlatform('icqq', createSceneRolePlatformChecker())
14
17
  : undefined;
15
- return disposePlatform;
18
+ const disposeExecRule = registerSensitiveExecCommand(icqqSensitiveExecRule);
19
+ return () => {
20
+ disposeExecRule();
21
+ disposePlatform?.();
22
+ };
16
23
  },
17
24
  });
package/src/endpoint.ts CHANGED
@@ -33,7 +33,8 @@ import {
33
33
  type EndpointGroupNoticeRequest,
34
34
  } from 'zhin.js/adapter';
35
35
  import type { EndpointContentPort } from '@zhin.js/adapter';
36
- import { receiveOneBotLikeSideEvent, buildSystem, toCanonicalSegments, type LoginAssist, type LoginAssistEndpointIdentity, type PendingLoginTask } from '@zhin.js/core';
36
+ import { receiveOneBotLikeSideEvent } from '@zhin.js/adapter-onebot-events';
37
+ import { buildSystem, toCanonicalSegments, type LoginAssist, type LoginAssistEndpointIdentity, type PendingLoginTask } from '@zhin.js/core';
37
38
  import { EndpointDeliveryError, type EndpointSendPart, type EndpointSendResult, type MessageRef } from '@zhin.js/im-contract';
38
39
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
39
40
  import type { CapabilityId } from 'zhin.js';
@@ -77,6 +78,7 @@ import {
77
78
  } from './protocol.js';
78
79
  import type { SystemMessage as IcqqSystemMessage } from './types.js';
79
80
  import { ManagedIcqqClient, type IcqqBackgroundOperation } from './managed-client.js';
81
+ import { createIcqqActionPorts } from './group-actions.js';
80
82
  import { inspectGroupMember, moderateGroupMember } from './group-moderation.js';
81
83
  import { inspectGroupMemberProfile, inspectGroupScene } from './group-scene.js';
82
84
  import { createIcqqSelfProfile } from './self-profile.js';
@@ -189,6 +191,7 @@ export class IcqqEndpoint extends ManagedIcqqClient implements Endpoint {
189
191
  throw new Error('QQ did not confirm the group notice');
190
192
  }
191
193
  },
194
+ ...createIcqqActionPorts(() => this),
192
195
  });
193
196
 
194
197
  readonly #control: EndpointControl = Object.freeze<EndpointControl>({
@@ -0,0 +1,105 @@
1
+ import type { Client, MemberInfo } from '@icqqjs/icqq';
2
+ import { createEndpointOperation, type EndpointManagement, type EndpointOperation } from 'zhin.js/adapter';
3
+
4
+ type ActionPorts = Pick<
5
+ EndpointManagement,
6
+ | 'pokeUser'
7
+ | 'unmuteGroupMember'
8
+ | 'muteAllGroupMembers'
9
+ | 'setGroupCard'
10
+ | 'setGroupTitle'
11
+ | 'setGroupEssence'
12
+ | 'inviteGroupMember'
13
+ >;
14
+
15
+ function numericId(value: string): number {
16
+ const id = Number(value);
17
+ if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(id)) throw new Error('Invalid QQ identifier');
18
+ return id;
19
+ }
20
+
21
+ /** Uncached member lookup; `pickMember` and the cached roster would report stale roles. */
22
+ async function freshMember(operation: EndpointOperation, client: Client, group: number, user: number): Promise<MemberInfo | undefined> {
23
+ try {
24
+ const member = await operation.prepare(() => client.getGroupMemberInfo(group, user, true));
25
+ return member.user_id === user && member.group_id === group ? member : undefined;
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ async function requireBotRole(operation: EndpointOperation, client: Client, group: number, roles: readonly string[], message: string): Promise<void> {
32
+ const self = await freshMember(operation, client, group, client.uin);
33
+ if (!self || !roles.includes(self.role)) throw new Error(message);
34
+ }
35
+
36
+ function confirmed(ok: unknown, what: string): void {
37
+ if (ok === false) throw new Error(`QQ did not confirm ${what}`);
38
+ }
39
+
40
+ /**
41
+ * ADR 0044 action ports of the icqq endpoint. Where the bot's role decides the outcome the role is
42
+ * read fresh from the platform first, so a stale cache can neither allow nor refuse the action.
43
+ * Each request carries its own lifetime: reads are `prepare`, the native effect is `run`.
44
+ */
45
+ export function createIcqqActionPorts(getClient: () => Client): ActionPorts {
46
+ return {
47
+ async pokeUser({ userId, groupId, signal }) {
48
+ const client = getClient();
49
+ const operation = createEndpointOperation({ signal });
50
+ const user = numericId(userId);
51
+ confirmed(
52
+ groupId === undefined
53
+ ? await operation.run(() => client.pickFriend(user).poke(false))
54
+ : await operation.run(() => client.sendGroupPoke(numericId(groupId), user)),
55
+ 'poke',
56
+ );
57
+ },
58
+ async unmuteGroupMember({ groupId, userId, signal }) {
59
+ const client = getClient();
60
+ const operation = createEndpointOperation({ signal });
61
+ const group = numericId(groupId);
62
+ await requireBotRole(operation, client, group, ['owner', 'admin'], 'Bot is not a group admin');
63
+ confirmed(await operation.run(() => client.setGroupBan(group, numericId(userId), 0)), 'unmute');
64
+ },
65
+ async muteAllGroupMembers({ groupId, enabled, signal }) {
66
+ const client = getClient();
67
+ const operation = createEndpointOperation({ signal });
68
+ const group = numericId(groupId);
69
+ await requireBotRole(operation, client, group, ['owner', 'admin'], 'Bot is not a group admin');
70
+ confirmed(await operation.run(() => client.setGroupWholeBan(group, enabled)), 'whole-group mute');
71
+ },
72
+ async setGroupCard({ groupId, userId, card, signal }) {
73
+ const client = getClient();
74
+ const operation = createEndpointOperation({ signal });
75
+ const group = numericId(groupId);
76
+ const user = numericId(userId);
77
+ if (user !== client.uin) await requireBotRole(operation, client, group, ['owner', 'admin'], 'Bot is not a group admin');
78
+ confirmed(await operation.run(() => client.setGroupCard(group, user, card)), 'card change');
79
+ },
80
+ async setGroupTitle({ groupId, userId, title, durationSeconds, signal }) {
81
+ const client = getClient();
82
+ const operation = createEndpointOperation({ signal });
83
+ const group = numericId(groupId);
84
+ const user = numericId(userId);
85
+ await requireBotRole(operation, client, group, ['owner'], 'Only the group owner can set special titles');
86
+ if (!await freshMember(operation, client, group, user)) throw new Error('Target is not a group member');
87
+ confirmed(await operation.run(() => client.setGroupSpecialTitle(group, user, title, durationSeconds ?? -1)), 'title change');
88
+ },
89
+ async setGroupEssence({ groupId, messageId, enabled, signal }) {
90
+ const client = getClient();
91
+ const operation = createEndpointOperation({ signal });
92
+ await requireBotRole(operation, client, numericId(groupId), ['owner', 'admin'], 'Bot is not a group admin');
93
+ if (enabled) await operation.run(() => client.setEssenceMessage(messageId));
94
+ else await operation.run(() => client.removeEssenceMessage(messageId));
95
+ },
96
+ async inviteGroupMember({ groupId, userId, signal }) {
97
+ const client = getClient();
98
+ const operation = createEndpointOperation({ signal });
99
+ const group = numericId(groupId);
100
+ const user = numericId(userId);
101
+ if (await freshMember(operation, client, group, user)) throw new Error('Target is already a group member');
102
+ confirmed(await operation.run(() => client.inviteFriend(group, user)), 'invitation');
103
+ },
104
+ };
105
+ }
@@ -2,6 +2,7 @@
2
2
  * icqq 入站事件归一化(post_type / message_type / message 段)
3
3
  */
4
4
  import { toCanonicalSegments, type MessageSegment } from "zhin.js";
5
+ import { InboundMessageDeduper, promoteInboundMessagePayload } from "zhin.js/adapter";
5
6
  import { parseCqMessage, icqqMediaRefFromString } from "./cq-message.js";
6
7
  import { extractForwardResidFromJsonElement } from "./forward-msg.js";
7
8
  import type { GroupRole } from "./types.js";
@@ -141,8 +142,6 @@ export interface NormalizedIcqqInbound {
141
142
  raw: IcqqMessageEvent;
142
143
  }
143
144
 
144
- const DEDUPE_TTL_MS = 120_000;
145
-
146
145
  const ICQQ_GROUP_ROLES = new Set<GroupRole>(["owner", "admin", "member"]);
147
146
 
148
147
  /** 从 sender.role 解析群成员角色(供 Message.$sender → resolveSenderRoles) */
@@ -167,15 +166,13 @@ export function isIcqqBotMentioned(input: {
167
166
  }): boolean {
168
167
  const uin = String(input.uin ?? "").trim();
169
168
  if (!uin) return false;
170
- for (const seg of input.content ?? []) {
171
- if (!seg || (seg.type !== "at" && seg.type !== "mention")) continue;
172
- const data = (seg.data ?? {}) as Record<string, unknown>;
173
- const target = String(
174
- data.qq ?? data.user_id ?? data.id ?? data.target ?? "",
175
- ).trim();
176
- if (target && target !== "all" && Number(target) === Number(uin)) {
177
- return true;
178
- }
169
+ if (
170
+ promoteInboundMessagePayload<{ segments: readonly MessageSegment[]; mentioned?: boolean }>(
171
+ { segments: input.content ?? [] },
172
+ uin,
173
+ ).mentioned === true
174
+ ) {
175
+ return true;
179
176
  }
180
177
  const raw = input.rawMessage ?? "";
181
178
  if (!raw) return false;
@@ -805,10 +802,10 @@ function mergeReplyFromSource(
805
802
  }
806
803
 
807
804
  function quoteIdFromSegments(content: readonly MessageSegment[]): string | undefined {
808
- const reply = content.find((item) => item.type === 'reply');
809
- const data = reply?.data as Readonly<Record<string, unknown>> | undefined;
810
- const value = data?.message_id ?? data?.id;
811
- return value == null || !String(value).trim() ? undefined : String(value).trim();
805
+ return promoteInboundMessagePayload<{ segments: readonly MessageSegment[]; replyTo?: { readonly id: string } }>(
806
+ { segments: content },
807
+ undefined,
808
+ ).replyTo?.id;
812
809
  }
813
810
 
814
811
  export function resolveInboundContent(
@@ -863,20 +860,4 @@ export function normalizeIcqqInboundMessage(
863
860
  }
864
861
 
865
862
  /** 按 message_id 去重(多路 subscribe 会收到同一消息多次) */
866
- export class InboundMessageDeduper {
867
- private readonly seen = new Map<string, number>();
868
-
869
- shouldProcess(messageId: string): boolean {
870
- const now = Date.now();
871
- for (const [id, t] of this.seen) {
872
- if (now - t > DEDUPE_TTL_MS) this.seen.delete(id);
873
- }
874
- if (this.seen.has(messageId)) return false;
875
- this.seen.set(messageId, now);
876
- return true;
877
- }
878
-
879
- clear(): void {
880
- this.seen.clear();
881
- }
882
- }
863
+ export { InboundMessageDeduper };