@zhin.js/adapter-icqq 7.0.4 → 8.0.0
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 +37 -0
- package/adapters/icqq.js +5 -4
- package/adapters/icqq.ts +5 -4
- package/lib/cq-message.d.ts +13 -0
- package/lib/cq-message.js +49 -29
- package/lib/endpoint.d.ts +2 -1
- package/lib/endpoint.js +5 -0
- package/lib/icqq-endpoint-commands.js +2 -2
- package/lib/icqq-inbound.js +13 -10
- package/lib/icqq-runtime-state.js +1 -1
- package/lib/outbound-media.d.ts +5 -4
- package/lib/outbound-media.js +24 -27
- package/lib/protocol.js +13 -28
- package/package.json +11 -11
- package/plugin.js +1 -1
- package/src/cq-message.ts +55 -27
- package/src/endpoint.ts +7 -1
- package/src/icqq-endpoint-commands.ts +2 -2
- package/src/icqq-inbound.ts +13 -10
- package/src/icqq-runtime-state.ts +1 -1
- package/src/outbound-media.ts +27 -37
- package/src/protocol.ts +13 -28
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# @zhin.js/adapter-icqq
|
|
2
2
|
|
|
3
|
+
## 8.0.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 7c1e63a: feat(command)!: 命令名前缀分隔符由空格改为点号(BREAKING)
|
|
8
|
+
|
|
9
|
+
非 root 插件命令的自动前缀从空格改为点号:`qq endpoint list` → `qq.endpoint list`,多级挂载 `b a foo` → `b.a.foo`;root 插件命令不变。点号前缀同时消除了旧规则下 root 目录段与子插件 owner 段的命名冲突(`child status` vs `child.status` 现在可共存)。配套的 endpoint 管理命令套件(`createEndpointCommands`)及 QQ/ICQQ 适配器的用户可见用法文案同步更新为新风格。迁移:用户与文档中所有 `<adapter> <command>` 形式的指令改为 `<adapter>.<command>`。
|
|
10
|
+
|
|
11
|
+
- 4fbff5d: feat!: 多模态双向 Segment 一贯制(BREAKING,无兼容层)
|
|
12
|
+
|
|
13
|
+
全框架唯一媒体表达统一为 canonical `Segment` + `MediaRef{kind: url|path|base64|file, value, mime_type?, file_name?, size?}`,新增 audio/video/file 段类型;所有第二形状(legacy `data.url/file/base64` 字段、`mediaRefFromLegacyData`/`mediaRefToLegacyFields` 桥、双写)全部删除。
|
|
14
|
+
|
|
15
|
+
- **core**:`SendContent` 一等支持 `Segment[]`;endpoint 出站载荷只含 canonical 段;`resolveOutboundMediaPolicy` 改为纯声明驱动(adapter definition `segments.outboundMedia`),内置策略表删除,未声明回退 `url-or-text`;`ImageContent` 旧桥删除。
|
|
16
|
+
- **ai**:新增 `MediaContentBlock`/`MediaBlockRef`(Segment 同构)与 `UserMessage.media`(当前 turn 媒体,**不持久化**——存储层自动剥离);`createUserMessage(text, media?)` 签名变更(`ImageContent` 删除);provider 边界序列化器 `filterMediaBlocksForProvider` + 能力表(缺省 image-only,不支持类型降级占位文本);ai-sdk 桥媒体块 → SDK image/file parts。
|
|
17
|
+
- **agent**:入站 turn 注入(`turn/inbound-media.ts`)——commMessage 媒体段 → 当前 turn `UserMessage.media`;图片 path 物化、音频默认 STT(`@zhin.js/speech` 可选,失败降级占位)、视频/文件占位;`publishOutboundElements` 产出 canonical Segment;`transcribeAudioPayload` 导出。
|
|
18
|
+
- **cli**:`bridgeRuntimeMessage` 回复链路媒体段透传,不再压平为文本(`$reply` 直达 normalize → adapter)。
|
|
19
|
+
- **全部 20 个平台适配器**:出站媒体只消费 `data.media`(url 直发 / base64 直发 / 平台上传 / 读盘),入站媒体产出 canonical `data.media`;`segments.outboundMedia` 声明与实际消费逐一核对修正;QQ 入站新增 canonical segments(image/audio/video/file/mention/face/reply),图片/语音/视频不再丢失。
|
|
20
|
+
|
|
21
|
+
迁移:适配器/插件产媒体一律用 `{ type, data: { media: MediaRef } }`;发送 legacy `data.url/file/base64` 形状的段会被 warn 丢弃。
|
|
22
|
+
|
|
23
|
+
- Updated dependencies [7c1e63a]
|
|
24
|
+
- Updated dependencies [4fbff5d]
|
|
25
|
+
- Updated dependencies [5b94d9c]
|
|
26
|
+
- @zhin.js/command@1.0.5
|
|
27
|
+
- @zhin.js/adapter@1.1.3
|
|
28
|
+
- @zhin.js/core@1.5.0
|
|
29
|
+
- @zhin.js/agent@1.1.0
|
|
30
|
+
- zhin.js@6.0.0
|
|
31
|
+
|
|
32
|
+
## 7.0.5
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- @zhin.js/agent@1.0.10
|
|
37
|
+
- @zhin.js/core@1.4.3
|
|
38
|
+
- zhin.js@5.0.3
|
|
39
|
+
|
|
3
40
|
## 7.0.4
|
|
4
41
|
|
|
5
42
|
### Patch Changes
|
package/adapters/icqq.js
CHANGED
|
@@ -13,18 +13,19 @@ export { IcqqIpcEndpoint } from "../lib/endpoint.js";
|
|
|
13
13
|
const logger = getLogger('icqq');
|
|
14
14
|
export default defineAdapter({
|
|
15
15
|
capabilities: ['inbound', 'outbound'],
|
|
16
|
-
|
|
17
|
-
// (
|
|
16
|
+
operations: ['recall', 'reaction'],
|
|
17
|
+
// 统一消息元素通道(UNI-Channel):端点可消费 base64(CQ base64:// 守护进程解码)
|
|
18
|
+
// / url(CQ 直发)/ path(同机守护进程读盘;file 模式出站物化产物)媒体;
|
|
18
19
|
// 卡片/按钮等富交互段无原生承载,降级纯文本。
|
|
19
20
|
segments: {
|
|
20
|
-
outboundMedia: ['base64', 'url', '
|
|
21
|
+
outboundMedia: ['base64', 'url', 'path'],
|
|
21
22
|
interactive: 'text',
|
|
22
23
|
},
|
|
23
24
|
create(context) {
|
|
24
25
|
// Client-library / IPC daemon path — no httpHostToken.
|
|
25
26
|
// Console loginAssist + host-router routes deferred.
|
|
26
27
|
const config = resolveIcqqConfig(context.config);
|
|
27
|
-
// 注册到插件运行时状态(icqq
|
|
28
|
+
// 注册到插件运行时状态(icqq.endpoint list 的"运行中"数据源)
|
|
28
29
|
context.use(icqqRuntimeStateToken).endpoints.set(config.name, {
|
|
29
30
|
name: config.name,
|
|
30
31
|
mode: config.rpc ? 'rpc' : 'ipc',
|
package/adapters/icqq.ts
CHANGED
|
@@ -29,18 +29,19 @@ const logger = getLogger('icqq');
|
|
|
29
29
|
|
|
30
30
|
export default defineAdapter<IcqqAdapterConfig>({
|
|
31
31
|
capabilities: ['inbound', 'outbound'],
|
|
32
|
-
|
|
33
|
-
// (
|
|
32
|
+
operations: ['recall', 'reaction'],
|
|
33
|
+
// 统一消息元素通道(UNI-Channel):端点可消费 base64(CQ base64:// 守护进程解码)
|
|
34
|
+
// / url(CQ 直发)/ path(同机守护进程读盘;file 模式出站物化产物)媒体;
|
|
34
35
|
// 卡片/按钮等富交互段无原生承载,降级纯文本。
|
|
35
36
|
segments: {
|
|
36
|
-
outboundMedia: ['base64', 'url', '
|
|
37
|
+
outboundMedia: ['base64', 'url', 'path'],
|
|
37
38
|
interactive: 'text',
|
|
38
39
|
},
|
|
39
40
|
create(context) {
|
|
40
41
|
// Client-library / IPC daemon path — no httpHostToken.
|
|
41
42
|
// Console loginAssist + host-router routes deferred.
|
|
42
43
|
const config = resolveIcqqConfig(context.config);
|
|
43
|
-
// 注册到插件运行时状态(icqq
|
|
44
|
+
// 注册到插件运行时状态(icqq.endpoint list 的"运行中"数据源)
|
|
44
45
|
context.use(icqqRuntimeStateToken).endpoints.set(config.name, {
|
|
45
46
|
name: config.name,
|
|
46
47
|
mode: config.rpc ? 'rpc' : 'ipc',
|
package/lib/cq-message.d.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CQ 码与 MessageSegment 互转。
|
|
3
3
|
* 出站:@icqqjs/cli 要求 message 为非空字符串;引用段编码为 [reply:id](需 cli parse-message 支持 reply,见 scripts/patch-icqq-cli-reply.mjs)。
|
|
4
|
+
* 媒体段双向均为 canonical MediaRef(`data.media`),无 legacy url/file/base64 字段。
|
|
4
5
|
*/
|
|
6
|
+
import { type MediaRef } from "@zhin.js/core";
|
|
5
7
|
import { MessageSegment, SendContent } from "zhin.js";
|
|
8
|
+
/**
|
|
9
|
+
* CQ 媒体参数 / 入站元素媒体值 → canonical MediaRef:
|
|
10
|
+
* `base64://` → base64;http(s) → url;本机路径 → path;其余视为平台不透明引用(file)。
|
|
11
|
+
*/
|
|
12
|
+
export declare function icqqMediaRefFromString(value: string): MediaRef;
|
|
13
|
+
/**
|
|
14
|
+
* canonical MediaRef(`data.media`,唯一媒体来源)→ CQ 媒体参数:
|
|
15
|
+
* base64 → `base64://` 前缀(守护进程解码);url/path/file 原样直出。
|
|
16
|
+
* 无 canonical 媒体引用时 warn 并返回 undefined(调用方丢弃该段)。
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveCqMediaArg(type: string, data: Record<string, unknown> | undefined): string | undefined;
|
|
6
19
|
export declare function parseCqMessage(raw: string): MessageSegment[];
|
|
7
20
|
/** 构建 icqq IPC send_*_msg 的 message 字符串(非空) */
|
|
8
21
|
export declare function buildIcqqIpcMessage(content: SendContent): string;
|
package/lib/cq-message.js
CHANGED
|
@@ -1,16 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CQ 码与 MessageSegment 互转。
|
|
3
3
|
* 出站:@icqqjs/cli 要求 message 为非空字符串;引用段编码为 [reply:id](需 cli parse-message 支持 reply,见 scripts/patch-icqq-cli-reply.mjs)。
|
|
4
|
+
* 媒体段双向均为 canonical MediaRef(`data.media`),无 legacy url/file/base64 字段。
|
|
4
5
|
*/
|
|
6
|
+
import { isMediaRef } from "@zhin.js/core";
|
|
7
|
+
import { formatCompact, getLogger } from "@zhin.js/logger";
|
|
5
8
|
import { segment } from "zhin.js";
|
|
9
|
+
const logger = getLogger("icqq");
|
|
6
10
|
const MAX_CQ_PARSE_LEN = 256_000;
|
|
11
|
+
/**
|
|
12
|
+
* CQ 媒体参数 / 入站元素媒体值 → canonical MediaRef:
|
|
13
|
+
* `base64://` → base64;http(s) → url;本机路径 → path;其余视为平台不透明引用(file)。
|
|
14
|
+
*/
|
|
15
|
+
export function icqqMediaRefFromString(value) {
|
|
16
|
+
if (value.startsWith("base64://")) {
|
|
17
|
+
return { kind: "base64", value: value.slice("base64://".length) };
|
|
18
|
+
}
|
|
19
|
+
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
20
|
+
return { kind: "url", value };
|
|
21
|
+
}
|
|
22
|
+
if (value.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(value)) {
|
|
23
|
+
return { kind: "path", value };
|
|
24
|
+
}
|
|
25
|
+
return { kind: "file", value };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* canonical MediaRef(`data.media`,唯一媒体来源)→ CQ 媒体参数:
|
|
29
|
+
* base64 → `base64://` 前缀(守护进程解码);url/path/file 原样直出。
|
|
30
|
+
* 无 canonical 媒体引用时 warn 并返回 undefined(调用方丢弃该段)。
|
|
31
|
+
*/
|
|
32
|
+
export function resolveCqMediaArg(type, data) {
|
|
33
|
+
const media = data?.media;
|
|
34
|
+
if (!isMediaRef(media)) {
|
|
35
|
+
logger.warn(formatCompact({
|
|
36
|
+
op: "icqq_outbound_media_dropped",
|
|
37
|
+
type,
|
|
38
|
+
reason: "missing_media_ref",
|
|
39
|
+
}));
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
if (media.kind === "base64") {
|
|
43
|
+
return media.value.startsWith("base64://") ? media.value : `base64://${media.value}`;
|
|
44
|
+
}
|
|
45
|
+
return media.value;
|
|
46
|
+
}
|
|
7
47
|
function pushCqSegment(segments, type, arg) {
|
|
8
48
|
switch (type) {
|
|
9
49
|
case "face":
|
|
10
50
|
segments.push({ type: "face", data: { id: Number(arg) } });
|
|
11
51
|
break;
|
|
12
52
|
case "image":
|
|
13
|
-
segments.push({ type: "image", data: {
|
|
53
|
+
segments.push({ type: "image", data: { media: icqqMediaRefFromString(arg) } });
|
|
14
54
|
break;
|
|
15
55
|
case "at":
|
|
16
56
|
if (arg === "all") {
|
|
@@ -28,10 +68,10 @@ function pushCqSegment(segments, type, arg) {
|
|
|
28
68
|
break;
|
|
29
69
|
case "record":
|
|
30
70
|
case "audio":
|
|
31
|
-
segments.push({ type: "record", data: {
|
|
71
|
+
segments.push({ type: "record", data: { media: icqqMediaRefFromString(arg) } });
|
|
32
72
|
break;
|
|
33
73
|
case "video":
|
|
34
|
-
segments.push({ type: "video", data: {
|
|
74
|
+
segments.push({ type: "video", data: { media: icqqMediaRefFromString(arg) } });
|
|
35
75
|
break;
|
|
36
76
|
case "reply":
|
|
37
77
|
segments.push({ type: "reply", data: { message_id: arg } });
|
|
@@ -96,20 +136,8 @@ export function toCqString(content) {
|
|
|
96
136
|
case "face":
|
|
97
137
|
return `[face:${data.id}]`;
|
|
98
138
|
case "image": {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
if (media.kind === "base64")
|
|
102
|
-
return `[image:base64://${media.value}]`;
|
|
103
|
-
return `[image:${media.value}]`;
|
|
104
|
-
}
|
|
105
|
-
const b64 = typeof data.base64 === "string"
|
|
106
|
-
? data.base64
|
|
107
|
-
: typeof data.data === "string"
|
|
108
|
-
? data.data
|
|
109
|
-
: undefined;
|
|
110
|
-
if (b64)
|
|
111
|
-
return `[image:base64://${b64}]`;
|
|
112
|
-
return `[image:${data.file || data.url || data.src}]`;
|
|
139
|
+
const arg = resolveCqMediaArg("image", data);
|
|
140
|
+
return arg ? `[image:${arg}]` : "";
|
|
113
141
|
}
|
|
114
142
|
case "at":
|
|
115
143
|
case "mention": {
|
|
@@ -122,20 +150,12 @@ export function toCqString(content) {
|
|
|
122
150
|
return "[rps]";
|
|
123
151
|
case "record":
|
|
124
152
|
case "audio": {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
: typeof data.data === "string"
|
|
128
|
-
? data.data
|
|
129
|
-
: undefined;
|
|
130
|
-
if (b64)
|
|
131
|
-
return `[record:base64://${b64}]`;
|
|
132
|
-
return `[record:${data.file || data.url}]`;
|
|
153
|
+
const arg = resolveCqMediaArg("record", data);
|
|
154
|
+
return arg ? `[record:${arg}]` : "";
|
|
133
155
|
}
|
|
134
156
|
case "video": {
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
return `[video:base64://${b64}]`;
|
|
138
|
-
return `[video:${data.file || data.url}]`;
|
|
157
|
+
const arg = resolveCqMediaArg("video", data);
|
|
158
|
+
return arg ? `[video:${arg}]` : "";
|
|
139
159
|
}
|
|
140
160
|
case "reply":
|
|
141
161
|
return `[reply:${data.message_id ?? data.id}]`;
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* IcqqIpcEndpoint — lifecycle, outbound, IPC subscribe/admit for ICQQ daemon.
|
|
3
3
|
*/
|
|
4
|
-
import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
|
|
4
|
+
import type { EndpointControl, EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
|
|
5
5
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
6
6
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
7
7
|
import { type IcqqInboundMessage, type IpcEvent, type IpcResponse, type ResolvedIcqqConfig } from './protocol.js';
|
|
@@ -41,6 +41,7 @@ export declare class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
41
41
|
readonly friends: Map<number, IpcFriendInfo>;
|
|
42
42
|
readonly groups: Map<number, IpcGroupInfo>;
|
|
43
43
|
readonly management: EndpointManagement;
|
|
44
|
+
readonly control: EndpointControl;
|
|
44
45
|
constructor(options: IcqqEndpointOptions);
|
|
45
46
|
request(action: string, params?: Record<string, unknown>): Promise<IpcResponse>;
|
|
46
47
|
start(): Promise<void>;
|
package/lib/endpoint.js
CHANGED
|
@@ -31,6 +31,11 @@ export class IcqqIpcEndpoint {
|
|
|
31
31
|
setGroupAdmin: (groupId, userId, enabled) => this.setModerator(groupId, userId, enabled),
|
|
32
32
|
deleteFriend: (userId) => this.deleteFriend(userId),
|
|
33
33
|
});
|
|
34
|
+
control = Object.freeze({
|
|
35
|
+
recall: (messageId) => this.recallMessage(messageId),
|
|
36
|
+
addReaction: (messageId, emoji) => this.addReaction(messageId, emoji),
|
|
37
|
+
removeReaction: (messageId, reactionId) => this.removeReaction(messageId, reactionId),
|
|
38
|
+
});
|
|
34
39
|
#open = false;
|
|
35
40
|
#started = false;
|
|
36
41
|
#subscriptions = [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `icqq
|
|
2
|
+
* `icqq.endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
3
|
*
|
|
4
4
|
* ICQQ 的凭据由外部守护进程(@icqqjs/cli)按账号持有(~/.icqq/<uin>/),
|
|
5
5
|
* 其 IPC/RPC 协议是「按 uin 连接 + auth + 账号级 action」,没有 add-account /
|
|
@@ -18,7 +18,7 @@ export const icqqEndpointCommands = createEndpointCommands({
|
|
|
18
18
|
addDescription: '登记 ICQQ endpoint(uin 需先经 icqq login 完成扫码登录;重启 zhin 生效)',
|
|
19
19
|
bindFlow: ({ name }) => {
|
|
20
20
|
if (!name) {
|
|
21
|
-
return '用法:icqq
|
|
21
|
+
return '用法:icqq.endpoint add <uin>(uin 为纯数字 QQ 号,且需先执行 icqq login <uin> 完成扫码登录)';
|
|
22
22
|
}
|
|
23
23
|
if (!/^\d+$/.test(name)) {
|
|
24
24
|
return 'icqq endpoint 名必须是纯数字 QQ 号(uin)';
|
package/lib/icqq-inbound.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* icqq 守护进程 IPC 入站事件归一化(post_type / message_type / message 段)
|
|
3
3
|
*/
|
|
4
4
|
import { Message, toCanonicalSegments } from "zhin.js";
|
|
5
|
-
import { parseCqMessage } from "./cq-message.js";
|
|
5
|
+
import { parseCqMessage, icqqMediaRefFromString } from "./cq-message.js";
|
|
6
6
|
import { extractForwardResidFromJsonElement } from "./forward-msg.js";
|
|
7
7
|
const DEDUPE_TTL_MS = 120_000;
|
|
8
8
|
const ICQQ_GROUP_ROLES = new Set(["owner", "admin", "member"]);
|
|
@@ -210,15 +210,14 @@ export function icqqElementsToSegments(elements) {
|
|
|
210
210
|
case "face":
|
|
211
211
|
out.push({ type: "face", data: { id: Number(el.id) } });
|
|
212
212
|
break;
|
|
213
|
-
case "image":
|
|
213
|
+
case "image": {
|
|
214
|
+
const value = String(el.url ?? el.file ?? "");
|
|
214
215
|
out.push({
|
|
215
216
|
type: "image",
|
|
216
|
-
data: {
|
|
217
|
-
url: String(el.url ?? el.file ?? ""),
|
|
218
|
-
file: String(el.file ?? el.url ?? ""),
|
|
219
|
-
},
|
|
217
|
+
data: { media: icqqMediaRefFromString(value) },
|
|
220
218
|
});
|
|
221
219
|
break;
|
|
220
|
+
}
|
|
222
221
|
case "forward": {
|
|
223
222
|
const forwardResid = String(el.id ?? el.resid ?? el.res_id ?? el.file ?? "").trim();
|
|
224
223
|
if (forwardResid) {
|
|
@@ -281,18 +280,22 @@ export function icqqElementsToSegments(elements) {
|
|
|
281
280
|
break;
|
|
282
281
|
}
|
|
283
282
|
case "record":
|
|
284
|
-
case "audio":
|
|
283
|
+
case "audio": {
|
|
284
|
+
const value = String(el.file ?? el.url ?? "");
|
|
285
285
|
out.push({
|
|
286
286
|
type: "record",
|
|
287
|
-
data: {
|
|
287
|
+
data: { media: icqqMediaRefFromString(value) },
|
|
288
288
|
});
|
|
289
289
|
break;
|
|
290
|
-
|
|
290
|
+
}
|
|
291
|
+
case "video": {
|
|
292
|
+
const value = String(el.file ?? el.url ?? "");
|
|
291
293
|
out.push({
|
|
292
294
|
type: "video",
|
|
293
|
-
data: {
|
|
295
|
+
data: { media: icqqMediaRefFromString(value) },
|
|
294
296
|
});
|
|
295
297
|
break;
|
|
298
|
+
}
|
|
296
299
|
default:
|
|
297
300
|
if (el.text != null && el.text !== "") {
|
|
298
301
|
out.push({ type: "text", data: { text: String(el.text) } });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ICQQ 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
-
* 由 plugin.ts setup() provide,adapter create 与 `icqq
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `icqq.endpoint` 命令共享(同一 owner generation)。
|
|
4
4
|
*/
|
|
5
5
|
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
6
|
export const icqqRuntimeStateToken = defineEndpointRuntimeStateToken('icqq');
|
package/lib/outbound-media.d.ts
CHANGED
|
@@ -3,12 +3,13 @@ import type { IcqqEndpointConfig } from "./types.js";
|
|
|
3
3
|
export type IcqqOutboundMediaMode = "file" | "base64";
|
|
4
4
|
/**
|
|
5
5
|
* 出站媒体模式:file=本机落盘后 CQ [image:path](与 icqq 同机时可用);
|
|
6
|
-
* base64=保留
|
|
6
|
+
* base64=保留 MediaRef base64,CQ 编码为 [image:base64://...] 经 IPC 交给守护进程解码(异机 RPC 默认)。
|
|
7
7
|
*/
|
|
8
8
|
export declare function resolveIcqqOutboundMediaMode(config: Pick<IcqqEndpointConfig, "rpc" | "outboundMedia">): IcqqOutboundMediaMode;
|
|
9
9
|
/**
|
|
10
|
-
* file 模式:将
|
|
11
|
-
*
|
|
12
|
-
* base64 模式:不改动(由 toCqString/formatOutboundBody 生成 base64
|
|
10
|
+
* file 模式:将 canonical MediaRef(`data.media`,kind=base64)物化为本机路径,
|
|
11
|
+
* 并把 media 改写为 path 引用(与 icqq 同机时守护进程直接读盘)。
|
|
12
|
+
* base64 模式:不改动(由 toCqString/formatOutboundBody 生成 base64://,供异机守护进程解码)。
|
|
13
|
+
* 无 canonical MediaRef 的段原样保留(不可投递,由下游 CQ 序列化 warn + 丢弃)。
|
|
13
14
|
*/
|
|
14
15
|
export declare function materializeOutboundBase64(content: SendContent, mode?: IcqqOutboundMediaMode): SendContent;
|
package/lib/outbound-media.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as crypto from "node:crypto";
|
|
5
|
+
import { isMediaRef } from "@zhin.js/core";
|
|
5
6
|
function extForMime(mime, fallback) {
|
|
6
7
|
const map = {
|
|
7
8
|
"image/jpeg": ".jpg",
|
|
@@ -24,7 +25,7 @@ function spoolBase64ToFile(base64, mime, kind) {
|
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
27
|
* 出站媒体模式:file=本机落盘后 CQ [image:path](与 icqq 同机时可用);
|
|
27
|
-
* base64=保留
|
|
28
|
+
* base64=保留 MediaRef base64,CQ 编码为 [image:base64://...] 经 IPC 交给守护进程解码(异机 RPC 默认)。
|
|
28
29
|
*/
|
|
29
30
|
export function resolveIcqqOutboundMediaMode(config) {
|
|
30
31
|
if (config.outboundMedia === "base64" || config.outboundMedia === "file") {
|
|
@@ -33,9 +34,10 @@ export function resolveIcqqOutboundMediaMode(config) {
|
|
|
33
34
|
return config.rpc ? "base64" : "file";
|
|
34
35
|
}
|
|
35
36
|
/**
|
|
36
|
-
* file 模式:将
|
|
37
|
-
*
|
|
38
|
-
* base64 模式:不改动(由 toCqString/formatOutboundBody 生成 base64
|
|
37
|
+
* file 模式:将 canonical MediaRef(`data.media`,kind=base64)物化为本机路径,
|
|
38
|
+
* 并把 media 改写为 path 引用(与 icqq 同机时守护进程直接读盘)。
|
|
39
|
+
* base64 模式:不改动(由 toCqString/formatOutboundBody 生成 base64://,供异机守护进程解码)。
|
|
40
|
+
* 无 canonical MediaRef 的段原样保留(不可投递,由下游 CQ 序列化 warn + 丢弃)。
|
|
39
41
|
*/
|
|
40
42
|
export function materializeOutboundBase64(content, mode = "file") {
|
|
41
43
|
if (mode === "base64")
|
|
@@ -47,31 +49,26 @@ export function materializeOutboundBase64(content, mode = "file") {
|
|
|
47
49
|
const { type, data } = seg;
|
|
48
50
|
const d = data;
|
|
49
51
|
const media = d.media;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const legacyBase64 = d.base64 ?? d.data;
|
|
54
|
-
const b64 = mediaBase64 ??
|
|
55
|
-
(typeof legacyBase64 === "string" && legacyBase64 ? legacyBase64 : undefined);
|
|
52
|
+
if (!isMediaRef(media) || media.kind !== "base64")
|
|
53
|
+
return seg;
|
|
54
|
+
const b64 = media.value.replace(/^base64:\/\//, "");
|
|
56
55
|
if (!b64)
|
|
57
56
|
return seg;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const filePath = spoolBase64ToFile(b64, mime || (type === "image" ? "image/jpeg" : type === "video" ? "video/mp4" : "audio/mpeg"), type === "record" || type === "audio" ? "audio" : type);
|
|
61
|
-
return {
|
|
62
|
-
type,
|
|
63
|
-
data: {
|
|
64
|
-
...d,
|
|
65
|
-
media: {
|
|
66
|
-
kind: "path",
|
|
67
|
-
value: filePath,
|
|
68
|
-
...(mime ? { mime_type: mime } : {}),
|
|
69
|
-
},
|
|
70
|
-
file: filePath,
|
|
71
|
-
url: filePath,
|
|
72
|
-
},
|
|
73
|
-
};
|
|
57
|
+
if (type !== "image" && type !== "record" && type !== "audio" && type !== "video") {
|
|
58
|
+
return seg;
|
|
74
59
|
}
|
|
75
|
-
|
|
60
|
+
const mime = media.mime_type ?? "";
|
|
61
|
+
const filePath = spoolBase64ToFile(b64, mime || (type === "image" ? "image/jpeg" : type === "video" ? "video/mp4" : "audio/mpeg"), type === "record" || type === "audio" ? "audio" : type);
|
|
62
|
+
return {
|
|
63
|
+
type,
|
|
64
|
+
data: {
|
|
65
|
+
...d,
|
|
66
|
+
media: {
|
|
67
|
+
kind: "path",
|
|
68
|
+
value: filePath,
|
|
69
|
+
...(mime ? { mime_type: mime } : {}),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
76
73
|
});
|
|
77
74
|
}
|
package/lib/protocol.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* 登录相关:**推送**走 IpcEvent(`system.login.*`)。
|
|
13
13
|
*/
|
|
14
14
|
import { pickCredential } from '@zhin.js/adapter';
|
|
15
|
+
import { resolveCqMediaArg } from "./cq-message.js";
|
|
15
16
|
/** 从 IPC 入站 payload 解析可用于引用/撤回的消息 ID */
|
|
16
17
|
export { resolveIcqqInboundMessageId } from "./icqq-inbound.js";
|
|
17
18
|
/**
|
|
@@ -276,17 +277,23 @@ export function formatOutboundBody(payload) {
|
|
|
276
277
|
case 'at':
|
|
277
278
|
case 'mention':
|
|
278
279
|
return `[at:${item.data?.qq ?? item.data?.id ?? item.data?.target ?? ''}]`;
|
|
279
|
-
case 'image':
|
|
280
|
-
|
|
280
|
+
case 'image': {
|
|
281
|
+
const arg = resolveCqMediaArg('image', item.data);
|
|
282
|
+
return arg ? `[image:${arg}]` : '';
|
|
283
|
+
}
|
|
281
284
|
case 'face':
|
|
282
285
|
return `[face:${item.data?.id ?? ''}]`;
|
|
283
286
|
case 'reply':
|
|
284
287
|
return `[reply:${item.data?.message_id ?? item.data?.id ?? ''}]`;
|
|
285
288
|
case 'record':
|
|
286
|
-
case 'audio':
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
289
|
+
case 'audio': {
|
|
290
|
+
const arg = resolveCqMediaArg('record', item.data);
|
|
291
|
+
return arg ? `[record:${arg}]` : '';
|
|
292
|
+
}
|
|
293
|
+
case 'video': {
|
|
294
|
+
const arg = resolveCqMediaArg('video', item.data);
|
|
295
|
+
return arg ? `[video:${arg}]` : '';
|
|
296
|
+
}
|
|
290
297
|
default:
|
|
291
298
|
return String(item.data?.text ?? '');
|
|
292
299
|
}
|
|
@@ -296,28 +303,6 @@ export function formatOutboundBody(payload) {
|
|
|
296
303
|
export function formatInboundContent(rawMessage) {
|
|
297
304
|
return rawMessage;
|
|
298
305
|
}
|
|
299
|
-
/** base64 媒体回退:CQ `base64://` 由守护进程解码(异机 RPC 场景)。 */
|
|
300
|
-
function base64Media(data) {
|
|
301
|
-
const b64 = data?.base64;
|
|
302
|
-
return typeof b64 === 'string' && b64 ? `base64://${b64}` : undefined;
|
|
303
|
-
}
|
|
304
|
-
/**
|
|
305
|
-
* canonical MediaRef(`data.media`,见 built/segment-contract)→ CQ 媒体值:
|
|
306
|
-
* base64 → `base64://` 前缀(守护进程解码);url/path 直出。
|
|
307
|
-
* file 模式下 materializeOutboundBase64 已把 base64 落盘并把 media 改写为 path 引用。
|
|
308
|
-
*/
|
|
309
|
-
function mediaRefValue(data) {
|
|
310
|
-
const media = data?.media;
|
|
311
|
-
if (!media || typeof media !== 'object' || Array.isArray(media))
|
|
312
|
-
return undefined;
|
|
313
|
-
const { kind, value } = media;
|
|
314
|
-
if (typeof value !== 'string' || !value)
|
|
315
|
-
return undefined;
|
|
316
|
-
if (kind === 'base64') {
|
|
317
|
-
return value.startsWith('base64://') ? value : `base64://${value}`;
|
|
318
|
-
}
|
|
319
|
-
return value;
|
|
320
|
-
}
|
|
321
306
|
/** 段对象判定:`{ type: string, data? }`(非数组 payload 的防御入口)。 */
|
|
322
307
|
function isWireSegment(value) {
|
|
323
308
|
return typeof value === 'object'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-icqq",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.0",
|
|
4
4
|
"description": "Zhin.js ICQQ adapter for Plugin Runtime (IPC daemon client)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -34,19 +34,19 @@
|
|
|
34
34
|
"directory": "plugins/adapters/icqq"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@zhin.js/adapter": "1.1.
|
|
38
|
-
"@zhin.js/command": "1.0.
|
|
39
|
-
"@zhin.js/core": "1.
|
|
37
|
+
"@zhin.js/adapter": "1.1.3",
|
|
38
|
+
"@zhin.js/command": "1.0.5",
|
|
39
|
+
"@zhin.js/core": "1.5.0",
|
|
40
40
|
"@zhin.js/logger": "1.0.75",
|
|
41
41
|
"@zhin.js/plugin-runtime": "1.1.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"zod": "^4.0.0",
|
|
45
|
-
"@zhin.js/adapter": "1.1.
|
|
46
|
-
"@zhin.js/agent": "1.0
|
|
47
|
-
"@zhin.js/core": "1.
|
|
45
|
+
"@zhin.js/adapter": "1.1.3",
|
|
46
|
+
"@zhin.js/agent": "1.1.0",
|
|
47
|
+
"@zhin.js/core": "1.5.0",
|
|
48
48
|
"@zhin.js/plugin-runtime": "1.1.1",
|
|
49
|
-
"zhin.js": "
|
|
49
|
+
"zhin.js": "6.0.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependenciesMeta": {
|
|
52
52
|
"zhin.js": {
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@types/node": "^26.1.
|
|
63
|
+
"@types/node": "^26.1.2",
|
|
64
64
|
"typescript": "^6.0.3",
|
|
65
65
|
"vitest": "^4.1.10",
|
|
66
66
|
"zod": "^4.4.3",
|
|
67
|
-
"
|
|
68
|
-
"zhin.js": "
|
|
67
|
+
"zhin.js": "6.0.0",
|
|
68
|
+
"@zhin.js/agent": "1.1.0"
|
|
69
69
|
},
|
|
70
70
|
"files": [
|
|
71
71
|
"adapters",
|
package/plugin.js
CHANGED
|
@@ -11,7 +11,7 @@ export default definePlugin({
|
|
|
11
11
|
displayName: 'ICQQ Adapter',
|
|
12
12
|
},
|
|
13
13
|
setup(context) {
|
|
14
|
-
// 运行中 endpoint 注册表(icqq
|
|
14
|
+
// 运行中 endpoint 注册表(icqq.endpoint list 的"运行中"数据源)
|
|
15
15
|
context.resources.provide(icqqRuntimeStateToken, createEndpointRuntimeState());
|
|
16
16
|
// Agent prompt contributor (orchestrator/deferred-worker ICQQ guidance).
|
|
17
17
|
// `zhin.js/agent` is an optional peer — skip silently on IM-only installs.
|
package/src/cq-message.ts
CHANGED
|
@@ -1,18 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CQ 码与 MessageSegment 互转。
|
|
3
3
|
* 出站:@icqqjs/cli 要求 message 为非空字符串;引用段编码为 [reply:id](需 cli parse-message 支持 reply,见 scripts/patch-icqq-cli-reply.mjs)。
|
|
4
|
+
* 媒体段双向均为 canonical MediaRef(`data.media`),无 legacy url/file/base64 字段。
|
|
4
5
|
*/
|
|
6
|
+
import { isMediaRef, type MediaRef } from "@zhin.js/core";
|
|
7
|
+
import { formatCompact, getLogger } from "@zhin.js/logger";
|
|
5
8
|
import { MessageSegment, segment, SendContent } from "zhin.js";
|
|
6
9
|
|
|
10
|
+
const logger = getLogger("icqq");
|
|
11
|
+
|
|
7
12
|
const MAX_CQ_PARSE_LEN = 256_000;
|
|
8
13
|
|
|
14
|
+
/**
|
|
15
|
+
* CQ 媒体参数 / 入站元素媒体值 → canonical MediaRef:
|
|
16
|
+
* `base64://` → base64;http(s) → url;本机路径 → path;其余视为平台不透明引用(file)。
|
|
17
|
+
*/
|
|
18
|
+
export function icqqMediaRefFromString(value: string): MediaRef {
|
|
19
|
+
if (value.startsWith("base64://")) {
|
|
20
|
+
return { kind: "base64", value: value.slice("base64://".length) };
|
|
21
|
+
}
|
|
22
|
+
if (value.startsWith("http://") || value.startsWith("https://")) {
|
|
23
|
+
return { kind: "url", value };
|
|
24
|
+
}
|
|
25
|
+
if (value.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(value)) {
|
|
26
|
+
return { kind: "path", value };
|
|
27
|
+
}
|
|
28
|
+
return { kind: "file", value };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* canonical MediaRef(`data.media`,唯一媒体来源)→ CQ 媒体参数:
|
|
33
|
+
* base64 → `base64://` 前缀(守护进程解码);url/path/file 原样直出。
|
|
34
|
+
* 无 canonical 媒体引用时 warn 并返回 undefined(调用方丢弃该段)。
|
|
35
|
+
*/
|
|
36
|
+
export function resolveCqMediaArg(
|
|
37
|
+
type: string,
|
|
38
|
+
data: Record<string, unknown> | undefined,
|
|
39
|
+
): string | undefined {
|
|
40
|
+
const media = data?.media;
|
|
41
|
+
if (!isMediaRef(media)) {
|
|
42
|
+
logger.warn(formatCompact({
|
|
43
|
+
op: "icqq_outbound_media_dropped",
|
|
44
|
+
type,
|
|
45
|
+
reason: "missing_media_ref",
|
|
46
|
+
}));
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (media.kind === "base64") {
|
|
50
|
+
return media.value.startsWith("base64://") ? media.value : `base64://${media.value}`;
|
|
51
|
+
}
|
|
52
|
+
return media.value;
|
|
53
|
+
}
|
|
54
|
+
|
|
9
55
|
function pushCqSegment(segments: MessageSegment[], type: string, arg: string): void {
|
|
10
56
|
switch (type) {
|
|
11
57
|
case "face":
|
|
12
58
|
segments.push({ type: "face", data: { id: Number(arg) } });
|
|
13
59
|
break;
|
|
14
60
|
case "image":
|
|
15
|
-
segments.push({ type: "image", data: {
|
|
61
|
+
segments.push({ type: "image", data: { media: icqqMediaRefFromString(arg) } });
|
|
16
62
|
break;
|
|
17
63
|
case "at":
|
|
18
64
|
if (arg === "all") {
|
|
@@ -29,10 +75,10 @@ function pushCqSegment(segments: MessageSegment[], type: string, arg: string): v
|
|
|
29
75
|
break;
|
|
30
76
|
case "record":
|
|
31
77
|
case "audio":
|
|
32
|
-
segments.push({ type: "record", data: {
|
|
78
|
+
segments.push({ type: "record", data: { media: icqqMediaRefFromString(arg) } });
|
|
33
79
|
break;
|
|
34
80
|
case "video":
|
|
35
|
-
segments.push({ type: "video", data: {
|
|
81
|
+
segments.push({ type: "video", data: { media: icqqMediaRefFromString(arg) } });
|
|
36
82
|
break;
|
|
37
83
|
case "reply":
|
|
38
84
|
segments.push({ type: "reply", data: { message_id: arg } });
|
|
@@ -98,19 +144,8 @@ export function toCqString(content: SendContent): string {
|
|
|
98
144
|
case "face":
|
|
99
145
|
return `[face:${data.id}]`;
|
|
100
146
|
case "image": {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
if (media.kind === "base64") return `[image:base64://${media.value}]`;
|
|
104
|
-
return `[image:${media.value}]`;
|
|
105
|
-
}
|
|
106
|
-
const b64 =
|
|
107
|
-
typeof data.base64 === "string"
|
|
108
|
-
? data.base64
|
|
109
|
-
: typeof data.data === "string"
|
|
110
|
-
? data.data
|
|
111
|
-
: undefined;
|
|
112
|
-
if (b64) return `[image:base64://${b64}]`;
|
|
113
|
-
return `[image:${data.file || data.url || data.src}]`;
|
|
147
|
+
const arg = resolveCqMediaArg("image", data);
|
|
148
|
+
return arg ? `[image:${arg}]` : "";
|
|
114
149
|
}
|
|
115
150
|
case "at":
|
|
116
151
|
case "mention": {
|
|
@@ -123,19 +158,12 @@ export function toCqString(content: SendContent): string {
|
|
|
123
158
|
return "[rps]";
|
|
124
159
|
case "record":
|
|
125
160
|
case "audio": {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
? data.base64
|
|
129
|
-
: typeof data.data === "string"
|
|
130
|
-
? data.data
|
|
131
|
-
: undefined;
|
|
132
|
-
if (b64) return `[record:base64://${b64}]`;
|
|
133
|
-
return `[record:${data.file || data.url}]`;
|
|
161
|
+
const arg = resolveCqMediaArg("record", data);
|
|
162
|
+
return arg ? `[record:${arg}]` : "";
|
|
134
163
|
}
|
|
135
164
|
case "video": {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
return `[video:${data.file || data.url}]`;
|
|
165
|
+
const arg = resolveCqMediaArg("video", data);
|
|
166
|
+
return arg ? `[video:${arg}]` : "";
|
|
139
167
|
}
|
|
140
168
|
case "reply":
|
|
141
169
|
return `[reply:${data.message_id ?? data.id}]`;
|
package/src/endpoint.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* IcqqIpcEndpoint — lifecycle, outbound, IPC subscribe/admit for ICQQ daemon.
|
|
3
3
|
*/
|
|
4
|
-
import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
|
|
4
|
+
import type { EndpointControl, EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
|
|
5
5
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
6
6
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
7
7
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
@@ -107,6 +107,12 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
107
107
|
setGroupAdmin: (groupId, userId, enabled) => this.setModerator(groupId, userId, enabled),
|
|
108
108
|
deleteFriend: (userId) => this.deleteFriend(userId),
|
|
109
109
|
});
|
|
110
|
+
readonly control: EndpointControl = Object.freeze({
|
|
111
|
+
recall: (messageId: string) => this.recallMessage(messageId),
|
|
112
|
+
addReaction: (messageId: string, emoji: string) => this.addReaction(messageId, emoji),
|
|
113
|
+
removeReaction: (messageId: string, reactionId: string) =>
|
|
114
|
+
this.removeReaction(messageId, reactionId),
|
|
115
|
+
});
|
|
110
116
|
#open = false;
|
|
111
117
|
#started = false;
|
|
112
118
|
#subscriptions: Array<{ unsubscribe: () => Promise<void> }> = [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `icqq
|
|
2
|
+
* `icqq.endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
3
|
*
|
|
4
4
|
* ICQQ 的凭据由外部守护进程(@icqqjs/cli)按账号持有(~/.icqq/<uin>/),
|
|
5
5
|
* 其 IPC/RPC 协议是「按 uin 连接 + auth + 账号级 action」,没有 add-account /
|
|
@@ -19,7 +19,7 @@ export const icqqEndpointCommands = createEndpointCommands({
|
|
|
19
19
|
addDescription: '登记 ICQQ endpoint(uin 需先经 icqq login 完成扫码登录;重启 zhin 生效)',
|
|
20
20
|
bindFlow: ({ name }) => {
|
|
21
21
|
if (!name) {
|
|
22
|
-
return '用法:icqq
|
|
22
|
+
return '用法:icqq.endpoint add <uin>(uin 为纯数字 QQ 号,且需先执行 icqq login <uin> 完成扫码登录)';
|
|
23
23
|
}
|
|
24
24
|
if (!/^\d+$/.test(name)) {
|
|
25
25
|
return 'icqq endpoint 名必须是纯数字 QQ 号(uin)';
|
package/src/icqq-inbound.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* icqq 守护进程 IPC 入站事件归一化(post_type / message_type / message 段)
|
|
3
3
|
*/
|
|
4
4
|
import { Message, toCanonicalSegments, type MessageSegment, type QuotedMessagePayload } from "zhin.js";
|
|
5
|
-
import { parseCqMessage } from "./cq-message.js";
|
|
5
|
+
import { parseCqMessage, icqqMediaRefFromString } from "./cq-message.js";
|
|
6
6
|
import { extractForwardResidFromJsonElement } from "./forward-msg.js";
|
|
7
7
|
import type { GroupRole } from "./types.js";
|
|
8
8
|
|
|
@@ -341,15 +341,14 @@ export function icqqElementsToSegments(
|
|
|
341
341
|
case "face":
|
|
342
342
|
out.push({ type: "face", data: { id: Number(el.id) } });
|
|
343
343
|
break;
|
|
344
|
-
case "image":
|
|
344
|
+
case "image": {
|
|
345
|
+
const value = String(el.url ?? el.file ?? "");
|
|
345
346
|
out.push({
|
|
346
347
|
type: "image",
|
|
347
|
-
data: {
|
|
348
|
-
url: String(el.url ?? el.file ?? ""),
|
|
349
|
-
file: String(el.file ?? el.url ?? ""),
|
|
350
|
-
},
|
|
348
|
+
data: { media: icqqMediaRefFromString(value) },
|
|
351
349
|
});
|
|
352
350
|
break;
|
|
351
|
+
}
|
|
353
352
|
case "forward": {
|
|
354
353
|
const forwardResid = String(
|
|
355
354
|
el.id ?? el.resid ?? el.res_id ?? el.file ?? "",
|
|
@@ -410,18 +409,22 @@ export function icqqElementsToSegments(
|
|
|
410
409
|
break;
|
|
411
410
|
}
|
|
412
411
|
case "record":
|
|
413
|
-
case "audio":
|
|
412
|
+
case "audio": {
|
|
413
|
+
const value = String(el.file ?? el.url ?? "");
|
|
414
414
|
out.push({
|
|
415
415
|
type: "record",
|
|
416
|
-
data: {
|
|
416
|
+
data: { media: icqqMediaRefFromString(value) },
|
|
417
417
|
});
|
|
418
418
|
break;
|
|
419
|
-
|
|
419
|
+
}
|
|
420
|
+
case "video": {
|
|
421
|
+
const value = String(el.file ?? el.url ?? "");
|
|
420
422
|
out.push({
|
|
421
423
|
type: "video",
|
|
422
|
-
data: {
|
|
424
|
+
data: { media: icqqMediaRefFromString(value) },
|
|
423
425
|
});
|
|
424
426
|
break;
|
|
427
|
+
}
|
|
425
428
|
default:
|
|
426
429
|
if (el.text != null && el.text !== "") {
|
|
427
430
|
out.push({ type: "text", data: { text: String(el.text) } });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ICQQ 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
-
* 由 plugin.ts setup() provide,adapter create 与 `icqq
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `icqq.endpoint` 命令共享(同一 owner generation)。
|
|
4
4
|
*/
|
|
5
5
|
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
6
|
|
package/src/outbound-media.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as crypto from "node:crypto";
|
|
5
|
+
import { isMediaRef } from "@zhin.js/core";
|
|
5
6
|
import type { MessageSegment, SendContent } from "zhin.js";
|
|
6
7
|
import type { IcqqEndpointConfig } from "./types.js";
|
|
7
8
|
|
|
@@ -35,7 +36,7 @@ function spoolBase64ToFile(
|
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* 出站媒体模式:file=本机落盘后 CQ [image:path](与 icqq 同机时可用);
|
|
38
|
-
* base64=保留
|
|
39
|
+
* base64=保留 MediaRef base64,CQ 编码为 [image:base64://...] 经 IPC 交给守护进程解码(异机 RPC 默认)。
|
|
39
40
|
*/
|
|
40
41
|
export function resolveIcqqOutboundMediaMode(
|
|
41
42
|
config: Pick<IcqqEndpointConfig, "rpc" | "outboundMedia">,
|
|
@@ -47,9 +48,10 @@ export function resolveIcqqOutboundMediaMode(
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
|
-
* file 模式:将
|
|
51
|
-
*
|
|
52
|
-
* base64 模式:不改动(由 toCqString/formatOutboundBody 生成 base64
|
|
51
|
+
* file 模式:将 canonical MediaRef(`data.media`,kind=base64)物化为本机路径,
|
|
52
|
+
* 并把 media 改写为 path 引用(与 icqq 同机时守护进程直接读盘)。
|
|
53
|
+
* base64 模式:不改动(由 toCqString/formatOutboundBody 生成 base64://,供异机守护进程解码)。
|
|
54
|
+
* 无 canonical MediaRef 的段原样保留(不可投递,由下游 CQ 序列化 warn + 丢弃)。
|
|
53
55
|
*/
|
|
54
56
|
export function materializeOutboundBase64(
|
|
55
57
|
content: SendContent,
|
|
@@ -62,42 +64,30 @@ export function materializeOutboundBase64(
|
|
|
62
64
|
if (typeof seg === "string") return seg;
|
|
63
65
|
const { type, data } = seg as MessageSegment;
|
|
64
66
|
const d = data as Record<string, unknown>;
|
|
65
|
-
const media = d.media
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const mediaBase64 =
|
|
69
|
-
media && media.kind === "base64" && typeof media.value === "string" && media.value
|
|
70
|
-
? media.value.replace(/^base64:\/\//, "")
|
|
71
|
-
: undefined;
|
|
72
|
-
const legacyBase64 = d.base64 ?? d.data;
|
|
73
|
-
const b64 =
|
|
74
|
-
mediaBase64 ??
|
|
75
|
-
(typeof legacyBase64 === "string" && legacyBase64 ? legacyBase64 : undefined);
|
|
67
|
+
const media = d.media;
|
|
68
|
+
if (!isMediaRef(media) || media.kind !== "base64") return seg;
|
|
69
|
+
const b64 = media.value.replace(/^base64:\/\//, "");
|
|
76
70
|
if (!b64) return seg;
|
|
71
|
+
if (type !== "image" && type !== "record" && type !== "audio" && type !== "video") {
|
|
72
|
+
return seg;
|
|
73
|
+
}
|
|
77
74
|
|
|
78
|
-
const mime =
|
|
79
|
-
|
|
75
|
+
const mime = media.mime_type ?? "";
|
|
76
|
+
const filePath = spoolBase64ToFile(
|
|
77
|
+
b64,
|
|
78
|
+
mime || (type === "image" ? "image/jpeg" : type === "video" ? "video/mp4" : "audio/mpeg"),
|
|
79
|
+
type === "record" || type === "audio" ? "audio" : type,
|
|
80
80
|
);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
data: {
|
|
90
|
-
...d,
|
|
91
|
-
media: {
|
|
92
|
-
kind: "path",
|
|
93
|
-
value: filePath,
|
|
94
|
-
...(mime ? { mime_type: mime } : {}),
|
|
95
|
-
},
|
|
96
|
-
file: filePath,
|
|
97
|
-
url: filePath,
|
|
81
|
+
return {
|
|
82
|
+
type,
|
|
83
|
+
data: {
|
|
84
|
+
...d,
|
|
85
|
+
media: {
|
|
86
|
+
kind: "path",
|
|
87
|
+
value: filePath,
|
|
88
|
+
...(mime ? { mime_type: mime } : {}),
|
|
98
89
|
},
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return seg;
|
|
90
|
+
},
|
|
91
|
+
} as MessageSegment;
|
|
102
92
|
});
|
|
103
93
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { pickCredential } from '@zhin.js/adapter';
|
|
16
16
|
import type { MessageSegment } from "zhin.js";
|
|
17
|
+
import { resolveCqMediaArg } from "./cq-message.js";
|
|
17
18
|
|
|
18
19
|
/** CLI → Daemon 请求 */
|
|
19
20
|
export type IpcRequest = {
|
|
@@ -427,17 +428,23 @@ export function formatOutboundBody(payload: unknown): string {
|
|
|
427
428
|
case 'at':
|
|
428
429
|
case 'mention':
|
|
429
430
|
return `[at:${item.data?.qq ?? item.data?.id ?? item.data?.target ?? ''}]`;
|
|
430
|
-
case 'image':
|
|
431
|
-
|
|
431
|
+
case 'image': {
|
|
432
|
+
const arg = resolveCqMediaArg('image', item.data);
|
|
433
|
+
return arg ? `[image:${arg}]` : '';
|
|
434
|
+
}
|
|
432
435
|
case 'face':
|
|
433
436
|
return `[face:${item.data?.id ?? ''}]`;
|
|
434
437
|
case 'reply':
|
|
435
438
|
return `[reply:${item.data?.message_id ?? item.data?.id ?? ''}]`;
|
|
436
439
|
case 'record':
|
|
437
|
-
case 'audio':
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
440
|
+
case 'audio': {
|
|
441
|
+
const arg = resolveCqMediaArg('record', item.data);
|
|
442
|
+
return arg ? `[record:${arg}]` : '';
|
|
443
|
+
}
|
|
444
|
+
case 'video': {
|
|
445
|
+
const arg = resolveCqMediaArg('video', item.data);
|
|
446
|
+
return arg ? `[video:${arg}]` : '';
|
|
447
|
+
}
|
|
441
448
|
default:
|
|
442
449
|
return String(item.data?.text ?? '');
|
|
443
450
|
}
|
|
@@ -449,28 +456,6 @@ export function formatInboundContent(rawMessage: string): string {
|
|
|
449
456
|
return rawMessage;
|
|
450
457
|
}
|
|
451
458
|
|
|
452
|
-
/** base64 媒体回退:CQ `base64://` 由守护进程解码(异机 RPC 场景)。 */
|
|
453
|
-
function base64Media(data: Record<string, unknown> | undefined): string | undefined {
|
|
454
|
-
const b64 = data?.base64;
|
|
455
|
-
return typeof b64 === 'string' && b64 ? `base64://${b64}` : undefined;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* canonical MediaRef(`data.media`,见 built/segment-contract)→ CQ 媒体值:
|
|
460
|
-
* base64 → `base64://` 前缀(守护进程解码);url/path 直出。
|
|
461
|
-
* file 模式下 materializeOutboundBase64 已把 base64 落盘并把 media 改写为 path 引用。
|
|
462
|
-
*/
|
|
463
|
-
function mediaRefValue(data: Record<string, unknown> | undefined): string | undefined {
|
|
464
|
-
const media = data?.media;
|
|
465
|
-
if (!media || typeof media !== 'object' || Array.isArray(media)) return undefined;
|
|
466
|
-
const { kind, value } = media as { kind?: unknown; value?: unknown };
|
|
467
|
-
if (typeof value !== 'string' || !value) return undefined;
|
|
468
|
-
if (kind === 'base64') {
|
|
469
|
-
return value.startsWith('base64://') ? value : `base64://${value}`;
|
|
470
|
-
}
|
|
471
|
-
return value;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
459
|
/** 段对象判定:`{ type: string, data? }`(非数组 payload 的防御入口)。 */
|
|
475
460
|
function isWireSegment(value: unknown): value is IcqqWireSegment {
|
|
476
461
|
return typeof value === 'object'
|