@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 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
- // 统一消息元素通道(UNI-Channel):端点可消费 base64 / url / upload 媒体
17
- // (path 由出站物化按 file/base64 模式转换,非声明消费形态);
16
+ operations: ['recall', 'reaction'],
17
+ // 统一消息元素通道(UNI-Channel):端点可消费 base64(CQ base64:// 守护进程解码)
18
+ // / url(CQ 直发)/ path(同机守护进程读盘;file 模式出站物化产物)媒体;
18
19
  // 卡片/按钮等富交互段无原生承载,降级纯文本。
19
20
  segments: {
20
- outboundMedia: ['base64', 'url', 'upload'],
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 endpoint list 的"运行中"数据源)
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
- // 统一消息元素通道(UNI-Channel):端点可消费 base64 / url / upload 媒体
33
- // (path 由出站物化按 file/base64 模式转换,非声明消费形态);
32
+ operations: ['recall', 'reaction'],
33
+ // 统一消息元素通道(UNI-Channel):端点可消费 base64(CQ base64:// 守护进程解码)
34
+ // / url(CQ 直发)/ path(同机守护进程读盘;file 模式出站物化产物)媒体;
34
35
  // 卡片/按钮等富交互段无原生承载,降级纯文本。
35
36
  segments: {
36
- outboundMedia: ['base64', 'url', 'upload'],
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 endpoint list 的"运行中"数据源)
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',
@@ -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: { url: arg, file: arg } });
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: { file: arg } });
71
+ segments.push({ type: "record", data: { media: icqqMediaRefFromString(arg) } });
32
72
  break;
33
73
  case "video":
34
- segments.push({ type: "video", data: { file: arg } });
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 media = data.media;
100
- if (media?.value) {
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 b64 = typeof data.base64 === "string"
126
- ? data.base64
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 b64 = typeof data.base64 === "string" ? data.base64 : undefined;
136
- if (b64)
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 endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
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 endpoint add <uin>(uin 为纯数字 QQ 号,且需先执行 icqq login <uin> 完成扫码登录)';
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)';
@@ -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: { file: String(el.file ?? el.url ?? "") },
287
+ data: { media: icqqMediaRefFromString(value) },
288
288
  });
289
289
  break;
290
- case "video":
290
+ }
291
+ case "video": {
292
+ const value = String(el.file ?? el.url ?? "");
291
293
  out.push({
292
294
  type: "video",
293
- data: { file: String(el.file ?? el.url ?? "") },
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 endpoint` 命令共享(同一 owner generation)。
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');
@@ -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=保留 segment.base64,CQ 编码为 [image:base64://...] 经 IPC 交给守护进程解码(异机 RPC 默认)。
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 模式:将 segment.data.base64(legacy)或 canonical MediaRef
11
- * (`data.media`,kind=base64)物化为本机路径,并把 media 改写为 path 引用。
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;
@@ -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=保留 segment.base64,CQ 编码为 [image:base64://...] 经 IPC 交给守护进程解码(异机 RPC 默认)。
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 模式:将 segment.data.base64(legacy)或 canonical MediaRef
37
- * (`data.media`,kind=base64)物化为本机路径,并把 media 改写为 path 引用。
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
- const mediaBase64 = media && media.kind === "base64" && typeof media.value === "string" && media.value
51
- ? media.value.replace(/^base64:\/\//, "")
52
- : undefined;
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
- const mime = String(d.mime ?? d.mimeType ?? (typeof media?.mime_type === "string" ? media.mime_type : "") ?? "");
59
- if (type === "image" || type === "record" || type === "audio" || type === "video") {
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
- return seg;
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
- return `[image:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
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
- return `[record:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
288
- case 'video':
289
- return `[video:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
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": "7.0.4",
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.2",
38
- "@zhin.js/command": "1.0.4",
39
- "@zhin.js/core": "1.4.3",
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.2",
46
- "@zhin.js/agent": "1.0.9",
47
- "@zhin.js/core": "1.4.3",
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": "5.0.3"
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.0",
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
- "@zhin.js/agent": "1.0.9",
68
- "zhin.js": "5.0.3"
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 endpoint list 的"运行中"数据源)
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: { url: arg, file: arg } });
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: { file: arg } });
78
+ segments.push({ type: "record", data: { media: icqqMediaRefFromString(arg) } });
33
79
  break;
34
80
  case "video":
35
- segments.push({ type: "video", data: { file: arg } });
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 media = data.media as { kind?: string; value?: string } | undefined;
102
- if (media?.value) {
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 b64 =
127
- typeof data.base64 === "string"
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 b64 = typeof data.base64 === "string" ? data.base64 : undefined;
137
- if (b64) return `[video:base64://${b64}]`;
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 endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
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 endpoint add <uin>(uin 为纯数字 QQ 号,且需先执行 icqq login <uin> 完成扫码登录)';
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)';
@@ -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: { file: String(el.file ?? el.url ?? "") },
416
+ data: { media: icqqMediaRefFromString(value) },
417
417
  });
418
418
  break;
419
- case "video":
419
+ }
420
+ case "video": {
421
+ const value = String(el.file ?? el.url ?? "");
420
422
  out.push({
421
423
  type: "video",
422
- data: { file: String(el.file ?? el.url ?? "") },
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 endpoint` 命令共享(同一 owner generation)。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `icqq.endpoint` 命令共享(同一 owner generation)。
4
4
  */
5
5
  import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
6
 
@@ -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=保留 segment.base64,CQ 编码为 [image:base64://...] 经 IPC 交给守护进程解码(异机 RPC 默认)。
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 模式:将 segment.data.base64(legacy)或 canonical MediaRef
51
- * (`data.media`,kind=base64)物化为本机路径,并把 media 改写为 path 引用。
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 as
66
- | { kind?: unknown; value?: unknown; mime_type?: unknown }
67
- | undefined;
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 = String(
79
- d.mime ?? d.mimeType ?? (typeof media?.mime_type === "string" ? media.mime_type : "") ?? "",
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
- if (type === "image" || type === "record" || type === "audio" || type === "video") {
82
- const filePath = spoolBase64ToFile(
83
- b64,
84
- mime || (type === "image" ? "image/jpeg" : type === "video" ? "video/mp4" : "audio/mpeg"),
85
- type === "record" || type === "audio" ? "audio" : type,
86
- );
87
- return {
88
- type,
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
- } as MessageSegment;
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
- return `[image:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
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
- return `[record:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
439
- case 'video':
440
- return `[video:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
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'