@zhin.js/adapter-qq 6.0.7 → 7.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,34 @@
1
1
  # @zhin.js/adapter-qq
2
2
 
3
+ ## 7.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
+
3
32
  ## 6.0.7
4
33
 
5
34
  ### Patch Changes
package/README.md CHANGED
@@ -71,14 +71,14 @@ plugins:
71
71
 
72
72
  ## Endpoint 管理命令
73
73
 
74
- 适配器自带 `qq endpoint` 命令组(聊天内直接使用,默认无前缀;受 `commandPrefix` 影响):
74
+ 适配器自带 `qq.endpoint` 命令组(聊天内直接使用,默认无前缀;受 `commandPrefix` 影响):
75
75
 
76
76
  | 命令 | 说明 |
77
77
  |------|------|
78
- | `qq endpoint add [name]` | 手机 QQ 扫码绑定:下发二维码链接 → 确认后凭据写入 `.env`(`QQ_<NAME>_APPID/SECRET`),并追加 `plugins.qq.endpoints`(重启生效) |
79
- | `qq endpoint cancel` | 取消进行中的扫码绑定(同时只允许一个流程) |
80
- | `qq endpoint list` | 列出运行中与配置中的 endpoints |
81
- | `qq endpoint remove <name>` | 从配置移除 endpoint(`.env` 键保留,可手动清理) |
78
+ | `qq.endpoint add [name]` | 手机 QQ 扫码绑定:下发二维码链接 → 确认后凭据写入 `.env`(`QQ_<NAME>_APPID/SECRET`),并追加 `plugins.qq.endpoints`(重启生效) |
79
+ | `qq.endpoint cancel` | 取消进行中的扫码绑定(同时只允许一个流程) |
80
+ | `qq.endpoint list` | 列出运行中与配置中的 endpoints |
81
+ | `qq.endpoint remove <name>` | 从配置移除 endpoint(`.env` 键保留,可手动清理) |
82
82
 
83
83
  add/cancel/remove 受 `master` 限制:实例配置声明了 `master`(顶层或 `endpoints[i]`)时仅
84
84
  master 可执行;未配置则放行(首个扫码绑定者即 owner)。二维码当前以链接文本下发
package/adapters/qq.js CHANGED
@@ -20,7 +20,7 @@ export default defineAdapter({
20
20
  create(context) {
21
21
  const config = resolveQqConfig(context.config);
22
22
  const gateway = context.use(messageGatewayToken);
23
- // 注册到插件运行时状态(qq endpoint list 的"运行中"数据源)
23
+ // 注册到插件运行时状态(qq.endpoint list 的"运行中"数据源)
24
24
  const state = context.use(qqRuntimeStateToken);
25
25
  state.endpoints.set(config.name, {
26
26
  name: config.name,
@@ -38,7 +38,7 @@ export default defineAdapter({
38
38
  gateway,
39
39
  config,
40
40
  });
41
- // 运行状态表只增不减:stop 时同步摘除,避免 stop 后 endpoint list 仍显示运行中。
41
+ // 运行状态表只增不减:stop 时同步摘除,避免 stop 后 qq.endpoint list 仍显示运行中。
42
42
  const stopEndpoint = endpoint.stop.bind(endpoint);
43
43
  endpoint.stop = async () => {
44
44
  state.endpoints.delete(config.name);
package/adapters/qq.ts CHANGED
@@ -35,7 +35,7 @@ export default defineAdapter<QqAdapterConfig>({
35
35
  create(context) {
36
36
  const config = resolveQqConfig(context.config);
37
37
  const gateway = context.use(messageGatewayToken);
38
- // 注册到插件运行时状态(qq endpoint list 的"运行中"数据源)
38
+ // 注册到插件运行时状态(qq.endpoint list 的"运行中"数据源)
39
39
  const state = context.use(qqRuntimeStateToken);
40
40
  state.endpoints.set(config.name, {
41
41
  name: config.name,
@@ -53,7 +53,7 @@ export default defineAdapter<QqAdapterConfig>({
53
53
  gateway,
54
54
  config,
55
55
  });
56
- // 运行状态表只增不减:stop 时同步摘除,避免 stop 后 endpoint list 仍显示运行中。
56
+ // 运行状态表只增不减:stop 时同步摘除,避免 stop 后 qq.endpoint list 仍显示运行中。
57
57
  const stopEndpoint = endpoint.stop.bind(endpoint);
58
58
  endpoint.stop = async () => {
59
59
  state.endpoints.delete(config.name);
package/lib/endpoint.js CHANGED
@@ -132,6 +132,7 @@ export class QqWebsocketEndpoint {
132
132
  adapter: this.#options.id,
133
133
  target,
134
134
  content: formatInboundContent(msg),
135
+ ...(msg.segments?.length ? { segments: msg.segments } : {}),
135
136
  sender: senderDisplayName(msg),
136
137
  id: msg.id,
137
138
  metadata: Object.freeze({
@@ -311,6 +312,7 @@ export class QqHttpEndpoint {
311
312
  adapter: this.#options.id,
312
313
  target,
313
314
  content: formatInboundContent(msg),
315
+ ...(msg.segments?.length ? { segments: msg.segments } : {}),
314
316
  sender: senderDisplayName(msg),
315
317
  id: msg.id,
316
318
  metadata: Object.freeze({
package/lib/outbound.d.ts CHANGED
@@ -5,9 +5,9 @@ export interface QqOutboundElem {
5
5
  }
6
6
  export type QqOutboundMessage = string | QqOutboundElem | readonly (string | QqOutboundElem)[];
7
7
  /**
8
- * 解析 image/audio/video/file 段的本地或内联 payload。
9
- * qq-official-bot 的 formatMediaData 对 data.url 直接当远程 URL;
10
- * base64:// / data:...;base64 / 本地路径须走 data.file 才会解析为 file_data。
8
+ * 解析 image/audio/video/file 段的投递源(canonical MediaRef 唯一来源):
9
+ * - kind=url → data.url 直发;
10
+ * - kind=base64 / path / file → data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
11
11
  */
12
12
  export declare function resolveMediaFile(data: Record<string, unknown>): string | undefined;
13
13
  /**
package/lib/outbound.js CHANGED
@@ -13,55 +13,48 @@
13
13
  * 不可映射的段一律降级为文本并 warn(保持既有 fallback 行为)。
14
14
  */
15
15
  import { formatCompact, getLogger } from '@zhin.js/logger';
16
+ import { isMediaRef } from '@zhin.js/core';
16
17
  const logger = getLogger('qq');
17
- function isRemoteUrl(value) {
18
- return /^https?:\/\//i.test(value);
19
- }
20
- function isInlineBase64(value) {
21
- return value.startsWith('base64://') || /^data:[^/]+\/[^;]+;base64,/i.test(value);
22
- }
23
18
  /**
24
- * 解析 image/audio/video/file 段的本地或内联 payload。
25
- * qq-official-bot 的 formatMediaData 对 data.url 直接当远程 URL;
26
- * base64:// / data:...;base64 / 本地路径须走 data.file 才会解析为 file_data。
19
+ * 解析 image/audio/video/file 段的投递源(canonical MediaRef 唯一来源):
20
+ * - kind=url → data.url 直发;
21
+ * - kind=base64 / path / file → data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
27
22
  */
28
23
  export function resolveMediaFile(data) {
29
- const url = typeof data.url === 'string' ? data.url : undefined;
30
- const file = typeof data.file === 'string' ? data.file : undefined;
31
- const base64 = typeof data.base64 === 'string' ? data.base64 : undefined;
32
- if (file && !isRemoteUrl(file))
33
- return file;
34
- if (url && isInlineBase64(url))
35
- return url;
36
- if (url && !isRemoteUrl(url) && (url.startsWith('file://') || url.startsWith('/')))
37
- return url;
38
- if (base64)
39
- return base64.startsWith('base64://') ? base64 : `base64://${base64}`;
40
- return undefined;
24
+ const media = data.media;
25
+ if (!isMediaRef(media))
26
+ return undefined;
27
+ if (media.kind === 'url')
28
+ return undefined;
29
+ if (media.kind === 'base64') {
30
+ return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
31
+ }
32
+ return media.value;
41
33
  }
42
34
  function normalizeMediaSegment(seg) {
43
- const data = { ...(seg.data ?? {}) };
44
- const file = resolveMediaFile(data);
45
- if (file) {
46
- delete data.url;
47
- delete data.base64;
48
- data.file = file;
49
- return { type: seg.type, data };
35
+ const media = (seg.data ?? {}).media;
36
+ if (!isMediaRef(media)) {
37
+ // 无 canonical 媒体引用:无法投递,降级留痕
38
+ logger.warn(formatCompact({
39
+ op: 'qq_outbound_media_dropped',
40
+ type: seg.type,
41
+ reason: 'missing_media_ref',
42
+ }));
43
+ return null;
50
44
  }
51
- const url = typeof data.url === 'string' ? data.url
52
- : typeof data.file === 'string' ? data.file
53
- : undefined;
54
- if (url && isRemoteUrl(url)) {
55
- delete data.base64;
56
- return { type: seg.type, data: { ...data, url } };
45
+ if (media.kind === 'url') {
46
+ return { type: seg.type, data: { url: media.value } };
47
+ }
48
+ const file = resolveMediaFile(seg.data ?? {});
49
+ if (!file) {
50
+ logger.warn(formatCompact({
51
+ op: 'qq_outbound_media_dropped',
52
+ type: seg.type,
53
+ reason: 'missing_source',
54
+ }));
55
+ return null;
57
56
  }
58
- // 既无远程 URL 也无本地上传源:无法投递,降级留痕
59
- logger.warn(formatCompact({
60
- op: 'qq_outbound_media_dropped',
61
- type: seg.type,
62
- reason: 'missing_source',
63
- }));
64
- return null;
57
+ return { type: seg.type, data: { file } };
65
58
  }
66
59
  function normalizeMarkdownSegment(seg) {
67
60
  const data = seg.data ?? {};
package/lib/protocol.d.ts CHANGED
@@ -65,6 +65,8 @@ export interface QqInboundMessage {
65
65
  readonly timestamp: number;
66
66
  readonly guildId?: string;
67
67
  readonly rawMessage?: string;
68
+ /** canonical 入站段(text/mention/face/image/audio/video/file/reply),与 content 同源 */
69
+ readonly segments?: readonly import('@zhin.js/core').Segment[];
68
70
  /**
69
71
  * 入站是否 @ 当前机器人:统一按 mentions 判定。群场景只认 is_you(@ 另一个
70
72
  * 机器人时 bot:true 但无 is_you,不误判);bot 回退仅限频道 AT 事件。
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `qq endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
2
+ * `qq.endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
3
3
  * 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
4
4
  * @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
5
5
  * 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
@@ -19,17 +19,17 @@ export declare const QQ_ENDPOINT_FORBIDDEN: string;
19
19
  * 从命令 input(Runtime Message)提取 $reply;非消息来源(如 Host API 调用)降级为 no-op。
20
20
  */
21
21
  export declare function extractQqCommandReply(input: unknown): QqCommandReply;
22
- /** `qq endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
22
+ /** `qq.endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
23
23
  export declare function runQqEndpointList(state: QqRuntimeState, projectRoot?: string): string;
24
24
  /**
25
- * `qq endpoint add [name]`:启动扫码绑定流程。
25
+ * `qq.endpoint add [name]`:启动扫码绑定流程。
26
26
  * 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
27
27
  * 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
28
28
  */
29
29
  export declare function runQqEndpointAdd(state: QqRuntimeState, name: string | undefined, reply: QqCommandReply, projectRoot?: string): Promise<string>;
30
- /** `qq endpoint cancel`:中止进行中的绑定流程 */
30
+ /** `qq.endpoint cancel`:中止进行中的绑定流程 */
31
31
  export declare function runQqEndpointCancel(state: QqRuntimeState): string;
32
- /** `qq endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
32
+ /** `qq.endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
33
33
  export declare function runQqEndpointRemove(_state: QqRuntimeState, name: string, projectRoot?: string): string;
34
34
  /**
35
35
  * 通用套件生成的 QQ endpoint 命令(add 经 bindFlow 钩子走扫码绑定)。
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `qq endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
2
+ * `qq.endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
3
3
  * 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
4
4
  * @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
5
5
  * 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
@@ -26,12 +26,12 @@ export const QQ_ENDPOINT_FORBIDDEN = endpointCommandForbidden('QQ');
26
26
  export function extractQqCommandReply(input) {
27
27
  return extractEndpointCommandReply(input);
28
28
  }
29
- /** `qq endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
29
+ /** `qq.endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
30
30
  export function runQqEndpointList(state, projectRoot) {
31
31
  return formatEndpointList(qqEndpointListSpec, {
32
32
  running: state.endpoints.values(),
33
33
  configured: listQqEndpointEntries(projectRoot),
34
- footer: state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq endpoint cancel 取消' : undefined,
34
+ footer: state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq.endpoint cancel 取消' : undefined,
35
35
  });
36
36
  }
37
37
  const qqEndpointListSpec = {
@@ -40,13 +40,13 @@ const qqEndpointListSpec = {
40
40
  describeEntry: (entry) => `appid: ${String(entry.appid)}`,
41
41
  };
42
42
  /**
43
- * `qq endpoint add [name]`:启动扫码绑定流程。
43
+ * `qq.endpoint add [name]`:启动扫码绑定流程。
44
44
  * 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
45
45
  * 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
46
46
  */
47
47
  export function runQqEndpointAdd(state, name, reply, projectRoot) {
48
48
  if (state.bindFlow) {
49
- return Promise.resolve('已有进行中的 QQ 机器人绑定,请先发送 qq endpoint cancel 取消后再试');
49
+ return Promise.resolve('已有进行中的 QQ 机器人绑定,请先发送 qq.endpoint cancel 取消后再试');
50
50
  }
51
51
  const endpointName = name?.trim() || undefined;
52
52
  return new Promise((resolve) => {
@@ -89,7 +89,7 @@ export function runQqEndpointAdd(state, name, reply, projectRoot) {
89
89
  state.bindFlow = { name: endpointName, stop };
90
90
  });
91
91
  }
92
- /** `qq endpoint cancel`:中止进行中的绑定流程 */
92
+ /** `qq.endpoint cancel`:中止进行中的绑定流程 */
93
93
  export function runQqEndpointCancel(state) {
94
94
  if (!state.bindFlow) {
95
95
  return '当前没有进行中的 QQ 绑定流程';
@@ -98,7 +98,7 @@ export function runQqEndpointCancel(state) {
98
98
  state.bindFlow = null;
99
99
  return '已取消进行中的 QQ 绑定流程';
100
100
  }
101
- /** `qq endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
101
+ /** `qq.endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
102
102
  export function runQqEndpointRemove(_state, name, projectRoot) {
103
103
  return removeEndpointByName(qqEndpointListSpec, name, projectRoot);
104
104
  }
@@ -115,7 +115,7 @@ export const qqEndpointCommands = createEndpointCommands({
115
115
  running: (use) => use(qqRuntimeStateToken).endpoints.values(),
116
116
  listFooter: (use) => {
117
117
  const state = use(qqRuntimeStateToken);
118
- return state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq endpoint cancel 取消' : undefined;
118
+ return state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq.endpoint cancel 取消' : undefined;
119
119
  },
120
120
  addDescription: '手机 QQ 扫码绑定机器人,凭据写入 .env 并追加到 zhin.config.yml(重启生效)',
121
121
  bindFlow: ({ name, reply, use }) => runQqEndpointAdd(use(qqRuntimeStateToken), name, reply),
package/lib/ws.js CHANGED
@@ -61,6 +61,7 @@ export function normalizeQqMessage(raw) {
61
61
  ?? msg.author?.id
62
62
  ?? '');
63
63
  const content = extractTextContent(msg.message) || msg.raw_message || '';
64
+ const segments = extractCanonicalSegments(msg.message);
64
65
  const rawRoles = msg.member?.roles ?? msg.sender?.roles;
65
66
  const authorRoles = Array.isArray(rawRoles) ? rawRoles.map(String) : undefined;
66
67
  // 统一按 mentions 判定 @:is_you 精确标识当前机器人(群载荷实测字段)。
@@ -80,9 +81,92 @@ export function normalizeQqMessage(raw) {
80
81
  timestamp: Date.now(),
81
82
  guildId: msg.guild_id != null ? String(msg.guild_id) : undefined,
82
83
  rawMessage: msg.raw_message,
84
+ ...(segments.length > 0 ? { segments } : {}),
83
85
  ...(mentioned ? { mentioned: true } : {}),
84
86
  };
85
87
  }
88
+ /**
89
+ * SDK 解析后的消息段(text/at/face/image/video/audio/file/reply)→ canonical Segment[]。
90
+ * 附件段的 url 已由 SDK 归一(补 https:// 前缀);mime 无法从载荷得知时按类型给默认。
91
+ */
92
+ function extractCanonicalSegments(message) {
93
+ if (!Array.isArray(message))
94
+ return [];
95
+ const out = [];
96
+ for (const seg of message) {
97
+ if (!seg || typeof seg !== 'object')
98
+ continue;
99
+ const item = seg;
100
+ const type = item.type ?? '';
101
+ const data = item.data ?? {};
102
+ switch (type) {
103
+ case 'text': {
104
+ const text = String(data.text ?? '');
105
+ if (text)
106
+ out.push({ type: 'text', data: { text } });
107
+ break;
108
+ }
109
+ case 'at': {
110
+ const target = String(data.user_id ?? data.id ?? '');
111
+ if (!target)
112
+ break;
113
+ const name = typeof data.username === 'string' && data.username
114
+ ? data.username
115
+ : undefined;
116
+ out.push({ type: 'mention', data: name ? { target, name } : { target } });
117
+ break;
118
+ }
119
+ case 'face': {
120
+ const id = data.id;
121
+ if (id == null)
122
+ break;
123
+ out.push({ type: 'face', data: { id: id } });
124
+ break;
125
+ }
126
+ case 'image':
127
+ case 'video':
128
+ case 'audio':
129
+ case 'file': {
130
+ const url = typeof data.url === 'string' ? data.url : '';
131
+ if (!url)
132
+ break;
133
+ const media = {
134
+ kind: 'url',
135
+ value: url,
136
+ mime_type: defaultMimeForSegment(type),
137
+ ...(typeof data.name === 'string' && data.name ? { file_name: data.name } : {}),
138
+ ...(typeof data.size === 'number' ? { size: data.size } : {}),
139
+ };
140
+ if (type === 'image')
141
+ out.push({ type: 'image', data: { media } });
142
+ else if (type === 'video')
143
+ out.push({ type: 'video', data: { media } });
144
+ else if (type === 'audio')
145
+ out.push({ type: 'audio', data: { media } });
146
+ else
147
+ out.push({ type: 'file', data: { media, ...(media.file_name ? { name: media.file_name } : {}) } });
148
+ break;
149
+ }
150
+ case 'reply': {
151
+ const messageId = String(data.id ?? data.message_id ?? '');
152
+ if (messageId)
153
+ out.push({ type: 'reply', data: { message_id: messageId } });
154
+ break;
155
+ }
156
+ default:
157
+ break;
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+ function defaultMimeForSegment(type) {
163
+ switch (type) {
164
+ case 'image': return 'image/jpeg';
165
+ case 'video': return 'video/mp4';
166
+ case 'audio': return 'audio/mpeg';
167
+ default: return undefined;
168
+ }
169
+ }
86
170
  function extractTextContent(message) {
87
171
  if (typeof message === 'string')
88
172
  return message;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-qq",
3
- "version": "6.0.7",
3
+ "version": "7.0.0",
4
4
  "description": "Zhin.js QQ Official adapter for Plugin Runtime (WebSocket gateway)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -35,20 +35,20 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "qq-official-bot": "^1.2.3",
38
- "@zhin.js/adapter": "1.1.2",
39
- "@zhin.js/command": "1.0.4",
40
- "@zhin.js/logger": "1.0.75",
41
- "@zhin.js/core": "1.4.3",
38
+ "@zhin.js/adapter": "1.1.3",
39
+ "@zhin.js/command": "1.0.5",
40
+ "@zhin.js/core": "1.5.0",
42
41
  "@zhin.js/host-http": "1.0.4",
42
+ "@zhin.js/logger": "1.0.75",
43
43
  "@zhin.js/plugin-runtime": "1.1.1"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "zod": "^4.0.0",
47
- "@zhin.js/adapter": "1.1.2",
48
- "@zhin.js/agent": "1.0.10",
49
- "@zhin.js/core": "1.4.3",
50
- "zhin.js": "5.0.3",
51
- "@zhin.js/plugin-runtime": "1.1.1"
47
+ "@zhin.js/adapter": "1.1.3",
48
+ "@zhin.js/agent": "1.1.0",
49
+ "@zhin.js/core": "1.5.0",
50
+ "@zhin.js/plugin-runtime": "1.1.1",
51
+ "zhin.js": "6.0.0"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "zhin.js": {
@@ -66,9 +66,9 @@
66
66
  "typescript": "^6.0.3",
67
67
  "vitest": "^4.1.10",
68
68
  "zod": "^4.4.3",
69
- "@zhin.js/agent": "1.0.10",
69
+ "@zhin.js/agent": "1.1.0",
70
70
  "@zhin.js/host-http": "1.0.4",
71
- "zhin.js": "5.0.3"
71
+ "zhin.js": "6.0.0"
72
72
  },
73
73
  "files": [
74
74
  "adapters",
package/plugin.js CHANGED
@@ -8,7 +8,7 @@ export default definePlugin({
8
8
  displayName: 'QQ Official WebSocket Adapter',
9
9
  },
10
10
  setup(context) {
11
- // 运行中 endpoint 注册表 + 扫码绑定单例,供 adapter create 与 `qq endpoint` 命令共享
11
+ // 运行中 endpoint 注册表 + 扫码绑定单例,供 adapter create 与 `qq.endpoint` 命令共享
12
12
  context.resources.provide(qqRuntimeStateToken, createQqRuntimeState());
13
13
  // 平台权限门禁:guild_owner / guild_admin / manage_roles / manage_channels(agent 工具 platformPermit)
14
14
  return registerQqPlatformPermitChecker();
package/src/endpoint.ts CHANGED
@@ -183,6 +183,7 @@ export class QqWebsocketEndpoint implements EndpointInstance {
183
183
  adapter: this.#options.id,
184
184
  target,
185
185
  content: formatInboundContent(msg),
186
+ ...(msg.segments?.length ? { segments: msg.segments } : {}),
186
187
  sender: senderDisplayName(msg),
187
188
  id: msg.id,
188
189
  metadata: Object.freeze({
@@ -387,6 +388,7 @@ export class QqHttpEndpoint implements EndpointInstance {
387
388
  adapter: this.#options.id,
388
389
  target,
389
390
  content: formatInboundContent(msg),
391
+ ...(msg.segments?.length ? { segments: msg.segments } : {}),
390
392
  sender: senderDisplayName(msg),
391
393
  id: msg.id,
392
394
  metadata: Object.freeze({
package/src/outbound.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * 不可映射的段一律降级为文本并 warn(保持既有 fallback 行为)。
14
14
  */
15
15
  import { formatCompact, getLogger } from '@zhin.js/logger';
16
+ import { isMediaRef } from '@zhin.js/core';
16
17
  import type { QqWireSegment } from './protocol.js';
17
18
 
18
19
  const logger = getLogger('qq');
@@ -34,54 +35,45 @@ interface QqOutboundButtonSpec {
34
35
  readonly command?: { readonly enter?: boolean; readonly reply?: boolean };
35
36
  }
36
37
 
37
- function isRemoteUrl(value: string): boolean {
38
- return /^https?:\/\//i.test(value);
39
- }
40
-
41
- function isInlineBase64(value: string): boolean {
42
- return value.startsWith('base64://') || /^data:[^/]+\/[^;]+;base64,/i.test(value);
43
- }
44
-
45
38
  /**
46
- * 解析 image/audio/video/file 段的本地或内联 payload。
47
- * qq-official-bot 的 formatMediaData 对 data.url 直接当远程 URL;
48
- * base64:// / data:...;base64 / 本地路径须走 data.file 才会解析为 file_data。
39
+ * 解析 image/audio/video/file 段的投递源(canonical MediaRef 唯一来源):
40
+ * - kind=url → data.url 直发;
41
+ * - kind=base64 / path / file → data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
49
42
  */
50
43
  export function resolveMediaFile(data: Record<string, unknown>): string | undefined {
51
- const url = typeof data.url === 'string' ? data.url : undefined;
52
- const file = typeof data.file === 'string' ? data.file : undefined;
53
- const base64 = typeof data.base64 === 'string' ? data.base64 : undefined;
54
-
55
- if (file && !isRemoteUrl(file)) return file;
56
- if (url && isInlineBase64(url)) return url;
57
- if (url && !isRemoteUrl(url) && (url.startsWith('file://') || url.startsWith('/'))) return url;
58
- if (base64) return base64.startsWith('base64://') ? base64 : `base64://${base64}`;
59
- return undefined;
44
+ const media = data.media;
45
+ if (!isMediaRef(media)) return undefined;
46
+ if (media.kind === 'url') return undefined;
47
+ if (media.kind === 'base64') {
48
+ return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
49
+ }
50
+ return media.value;
60
51
  }
61
52
 
62
53
  function normalizeMediaSegment(seg: QqWireSegment): QqOutboundElem | null {
63
- const data = { ...(seg.data ?? {}) };
64
- const file = resolveMediaFile(data);
65
- if (file) {
66
- delete data.url;
67
- delete data.base64;
68
- data.file = file;
69
- return { type: seg.type, data };
54
+ const media = (seg.data ?? {}).media;
55
+ if (!isMediaRef(media)) {
56
+ // 无 canonical 媒体引用:无法投递,降级留痕
57
+ logger.warn(formatCompact({
58
+ op: 'qq_outbound_media_dropped',
59
+ type: seg.type,
60
+ reason: 'missing_media_ref',
61
+ }));
62
+ return null;
70
63
  }
71
- const url = typeof data.url === 'string' ? data.url
72
- : typeof data.file === 'string' ? data.file
73
- : undefined;
74
- if (url && isRemoteUrl(url)) {
75
- delete data.base64;
76
- return { type: seg.type, data: { ...data, url } };
64
+ if (media.kind === 'url') {
65
+ return { type: seg.type, data: { url: media.value } };
66
+ }
67
+ const file = resolveMediaFile(seg.data ?? {});
68
+ if (!file) {
69
+ logger.warn(formatCompact({
70
+ op: 'qq_outbound_media_dropped',
71
+ type: seg.type,
72
+ reason: 'missing_source',
73
+ }));
74
+ return null;
77
75
  }
78
- // 既无远程 URL 也无本地上传源:无法投递,降级留痕
79
- logger.warn(formatCompact({
80
- op: 'qq_outbound_media_dropped',
81
- type: seg.type,
82
- reason: 'missing_source',
83
- }));
84
- return null;
76
+ return { type: seg.type, data: { file } };
85
77
  }
86
78
 
87
79
  function normalizeMarkdownSegment(seg: QqWireSegment): QqOutboundElem | null {
package/src/protocol.ts CHANGED
@@ -76,6 +76,8 @@ export interface QqInboundMessage {
76
76
  readonly timestamp: number;
77
77
  readonly guildId?: string;
78
78
  readonly rawMessage?: string;
79
+ /** canonical 入站段(text/mention/face/image/audio/video/file/reply),与 content 同源 */
80
+ readonly segments?: readonly import('@zhin.js/core').Segment[];
79
81
  /**
80
82
  * 入站是否 @ 当前机器人:统一按 mentions 判定。群场景只认 is_you(@ 另一个
81
83
  * 机器人时 bot:true 但无 is_you,不误判);bot 回退仅限频道 AT 事件。
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `qq endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
2
+ * `qq.endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
3
3
  * 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
4
4
  * @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
5
5
  * 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
@@ -41,12 +41,12 @@ export function extractQqCommandReply(input: unknown): QqCommandReply {
41
41
  return extractEndpointCommandReply(input);
42
42
  }
43
43
 
44
- /** `qq endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
44
+ /** `qq.endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
45
45
  export function runQqEndpointList(state: QqRuntimeState, projectRoot?: string): string {
46
46
  return formatEndpointList(qqEndpointListSpec, {
47
47
  running: state.endpoints.values(),
48
48
  configured: listQqEndpointEntries(projectRoot),
49
- footer: state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq endpoint cancel 取消' : undefined,
49
+ footer: state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq.endpoint cancel 取消' : undefined,
50
50
  });
51
51
  }
52
52
 
@@ -57,7 +57,7 @@ const qqEndpointListSpec = {
57
57
  } as const;
58
58
 
59
59
  /**
60
- * `qq endpoint add [name]`:启动扫码绑定流程。
60
+ * `qq.endpoint add [name]`:启动扫码绑定流程。
61
61
  * 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
62
62
  * 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
63
63
  */
@@ -68,7 +68,7 @@ export function runQqEndpointAdd(
68
68
  projectRoot?: string,
69
69
  ): Promise<string> {
70
70
  if (state.bindFlow) {
71
- return Promise.resolve('已有进行中的 QQ 机器人绑定,请先发送 qq endpoint cancel 取消后再试');
71
+ return Promise.resolve('已有进行中的 QQ 机器人绑定,请先发送 qq.endpoint cancel 取消后再试');
72
72
  }
73
73
  const endpointName = name?.trim() || undefined;
74
74
  return new Promise<string>((resolve) => {
@@ -121,7 +121,7 @@ export function runQqEndpointAdd(
121
121
  });
122
122
  }
123
123
 
124
- /** `qq endpoint cancel`:中止进行中的绑定流程 */
124
+ /** `qq.endpoint cancel`:中止进行中的绑定流程 */
125
125
  export function runQqEndpointCancel(state: QqRuntimeState): string {
126
126
  if (!state.bindFlow) {
127
127
  return '当前没有进行中的 QQ 绑定流程';
@@ -131,7 +131,7 @@ export function runQqEndpointCancel(state: QqRuntimeState): string {
131
131
  return '已取消进行中的 QQ 绑定流程';
132
132
  }
133
133
 
134
- /** `qq endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
134
+ /** `qq.endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
135
135
  export function runQqEndpointRemove(
136
136
  _state: QqRuntimeState,
137
137
  name: string,
@@ -153,7 +153,7 @@ export const qqEndpointCommands = createEndpointCommands({
153
153
  running: (use) => use(qqRuntimeStateToken).endpoints.values(),
154
154
  listFooter: (use) => {
155
155
  const state = use(qqRuntimeStateToken);
156
- return state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq endpoint cancel 取消' : undefined;
156
+ return state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq.endpoint cancel 取消' : undefined;
157
157
  },
158
158
  addDescription: '手机 QQ 扫码绑定机器人,凭据写入 .env 并追加到 zhin.config.yml(重启生效)',
159
159
  bindFlow: ({ name, reply, use }) => runQqEndpointAdd(use(qqRuntimeStateToken), name, reply),
package/src/ws.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import path from 'node:path';
5
5
  import { Bot, ReceiverMode, type Sendable } from 'qq-official-bot';
6
6
  import { getLevel, toLog4jsLevel } from '@zhin.js/logger';
7
+ import type { MediaRef, Segment } from '@zhin.js/core';
7
8
  import type { QqOutboundMessage } from './outbound.js';
8
9
  import type { QqChannelKind, QqInboundMessage, ResolvedQqWebsocketConfig } from './protocol.js';
9
10
 
@@ -130,6 +131,7 @@ export function normalizeQqMessage(raw: unknown): QqInboundMessage | null {
130
131
  ?? '',
131
132
  );
132
133
  const content = extractTextContent(msg.message) || msg.raw_message || '';
134
+ const segments = extractCanonicalSegments(msg.message);
133
135
  const rawRoles = msg.member?.roles ?? msg.sender?.roles;
134
136
  const authorRoles = Array.isArray(rawRoles) ? rawRoles.map(String) : undefined;
135
137
  // 统一按 mentions 判定 @:is_you 精确标识当前机器人(群载荷实测字段)。
@@ -150,10 +152,84 @@ export function normalizeQqMessage(raw: unknown): QqInboundMessage | null {
150
152
  timestamp: Date.now(),
151
153
  guildId: msg.guild_id != null ? String(msg.guild_id) : undefined,
152
154
  rawMessage: msg.raw_message,
155
+ ...(segments.length > 0 ? { segments } : {}),
153
156
  ...(mentioned ? { mentioned: true } : {}),
154
157
  };
155
158
  }
156
159
 
160
+ /**
161
+ * SDK 解析后的消息段(text/at/face/image/video/audio/file/reply)→ canonical Segment[]。
162
+ * 附件段的 url 已由 SDK 归一(补 https:// 前缀);mime 无法从载荷得知时按类型给默认。
163
+ */
164
+ function extractCanonicalSegments(message: unknown): Segment[] {
165
+ if (!Array.isArray(message)) return [];
166
+ const out: Segment[] = [];
167
+ for (const seg of message) {
168
+ if (!seg || typeof seg !== 'object') continue;
169
+ const item = seg as { type?: string; data?: Record<string, unknown> };
170
+ const type = item.type ?? '';
171
+ const data = item.data ?? {};
172
+ switch (type) {
173
+ case 'text': {
174
+ const text = String(data.text ?? '');
175
+ if (text) out.push({ type: 'text', data: { text } });
176
+ break;
177
+ }
178
+ case 'at': {
179
+ const target = String(data.user_id ?? data.id ?? '');
180
+ if (!target) break;
181
+ const name = typeof data.username === 'string' && data.username
182
+ ? data.username
183
+ : undefined;
184
+ out.push({ type: 'mention', data: name ? { target, name } : { target } });
185
+ break;
186
+ }
187
+ case 'face': {
188
+ const id = data.id;
189
+ if (id == null) break;
190
+ out.push({ type: 'face', data: { id: id as string | number } });
191
+ break;
192
+ }
193
+ case 'image':
194
+ case 'video':
195
+ case 'audio':
196
+ case 'file': {
197
+ const url = typeof data.url === 'string' ? data.url : '';
198
+ if (!url) break;
199
+ const media: MediaRef = {
200
+ kind: 'url',
201
+ value: url,
202
+ mime_type: defaultMimeForSegment(type),
203
+ ...(typeof data.name === 'string' && data.name ? { file_name: data.name } : {}),
204
+ ...(typeof data.size === 'number' ? { size: data.size } : {}),
205
+ };
206
+ if (type === 'image') out.push({ type: 'image', data: { media } });
207
+ else if (type === 'video') out.push({ type: 'video', data: { media } });
208
+ else if (type === 'audio') out.push({ type: 'audio', data: { media } });
209
+ else out.push({ type: 'file', data: { media, ...(media.file_name ? { name: media.file_name } : {}) } });
210
+ break;
211
+ }
212
+ case 'reply': {
213
+ const messageId = String(data.id ?? data.message_id ?? '');
214
+ if (messageId) out.push({ type: 'reply', data: { message_id: messageId } });
215
+ break;
216
+ }
217
+ default:
218
+ break;
219
+ }
220
+ }
221
+ return out;
222
+ }
223
+
224
+ function defaultMimeForSegment(type: string): string | undefined {
225
+ switch (type) {
226
+ case 'image': return 'image/jpeg';
227
+ case 'video': return 'video/mp4';
228
+ case 'audio': return 'audio/mpeg';
229
+ default: return undefined;
230
+ }
231
+ }
232
+
157
233
  function extractTextContent(message: unknown): string {
158
234
  if (typeof message === 'string') return message;
159
235
  if (!Array.isArray(message)) return '';