@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.
- package/CHANGELOG.md +67 -0
- package/PERMITS.md +1 -0
- package/README.md +1 -1
- package/adapters/icqq/index.js +96 -25
- package/adapters/icqq/index.ts +97 -27
- package/agents/icqq/messages.js +20 -0
- package/agents/icqq/messages.ts +20 -0
- package/agents/icqq/skills/icqq-group-admin/SKILL.md +5 -0
- package/agents/icqq/skills/icqq-group-admin/tools/announce/index.js +2 -0
- package/agents/icqq/skills/icqq-group-admin/tools/announce/index.ts +2 -0
- package/agents/icqq/skills/icqq-group-admin/tools/essence/index.js +2 -0
- package/agents/icqq/skills/icqq-group-admin/tools/essence/index.ts +2 -0
- package/agents/icqq/skills/icqq-group-admin/tools/group_kick/index.js +2 -0
- package/agents/icqq/skills/icqq-group-admin/tools/group_kick/index.ts +2 -0
- package/agents/icqq/skills/icqq-group-admin/tools/recall_member_message/index.js +2 -0
- package/agents/icqq/skills/icqq-group-admin/tools/recall_member_message/index.ts +2 -0
- package/agents/icqq/skills/icqq-group-admin/tools/set_group_admin/index.js +43 -0
- package/agents/icqq/skills/icqq-group-admin/tools/set_group_admin/index.ts +36 -0
- package/agents/icqq/skills/icqq-group-admin/tools/set_mute/index.js +2 -0
- package/agents/icqq/skills/icqq-group-admin/tools/set_mute/index.ts +2 -0
- package/agents/icqq/skills/icqq-group-admin/tools/set_title/index.js +2 -0
- package/agents/icqq/skills/icqq-group-admin/tools/set_title/index.ts +2 -0
- package/lib/endpoint.js +4 -1
- package/lib/group-actions.d.ts +10 -0
- package/lib/group-actions.js +94 -0
- package/lib/icqq-inbound.d.ts +2 -6
- package/lib/icqq-inbound.js +5 -30
- package/lib/outbound-media.js +10 -4
- package/lib/sensitive-commands.d.ts +8 -0
- package/lib/sensitive-commands.js +16 -0
- package/package.json +24 -18
- package/plugin.js +8 -1
- package/src/endpoint.ts +4 -1
- package/src/group-actions.ts +105 -0
- package/src/icqq-inbound.ts +13 -32
- package/src/outbound-media.ts +9 -3
- 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
|
|
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
|
+
}
|
package/lib/icqq-inbound.d.ts
CHANGED
|
@@ -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
|
|
167
|
-
private readonly seen;
|
|
168
|
-
shouldProcess(messageId: string): boolean;
|
|
169
|
-
clear(): void;
|
|
170
|
-
}
|
|
171
|
-
export {};
|
|
167
|
+
export { InboundMessageDeduper };
|
package/lib/icqq-inbound.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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 };
|
package/lib/outbound-media.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
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": "
|
|
37
|
-
"@zhin.js/
|
|
38
|
-
"@zhin.js/
|
|
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.
|
|
41
|
-
"@zhin.js/feature-kit": "1.1.
|
|
42
|
-
"@zhin.js/im-contract": "1.1.
|
|
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.
|
|
45
|
-
"@zhin.js/permission": "1.1.
|
|
46
|
-
"@zhin.js/skill": "1.1.
|
|
47
|
-
"@zhin.js/tool": "1.1.
|
|
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.
|
|
53
|
-
"@zhin.js/
|
|
54
|
-
"@zhin.js/
|
|
55
|
-
"@zhin.js/
|
|
56
|
-
"zhin.js": "^1.1.
|
|
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/
|
|
82
|
-
"zhin.js": "1.1.
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/icqq-inbound.ts
CHANGED
|
@@ -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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
|
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 };
|