@zhin.js/adapter-qq 5.0.3 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +36 -4
  3. package/adapters/qq.ts +27 -8
  4. package/commands/endpoint/add/[name:string=].ts +3 -0
  5. package/commands/endpoint/cancel.ts +15 -0
  6. package/commands/endpoint/list.ts +3 -0
  7. package/commands/endpoint/remove/[name:string].ts +3 -0
  8. package/lib/endpoint.d.ts +7 -1
  9. package/lib/endpoint.js +49 -4
  10. package/lib/index.d.ts +1 -0
  11. package/lib/index.js +1 -0
  12. package/lib/outbound.d.ts +17 -0
  13. package/lib/outbound.js +241 -0
  14. package/lib/protocol.js +15 -2
  15. package/lib/qq-bind-api.d.ts +36 -0
  16. package/lib/qq-bind-api.js +89 -0
  17. package/lib/qq-bind-flow.d.ts +19 -0
  18. package/lib/qq-bind-flow.js +85 -0
  19. package/lib/qq-bind-persist.d.ts +12 -0
  20. package/lib/qq-bind-persist.js +22 -0
  21. package/lib/qq-endpoint-commands.d.ts +38 -0
  22. package/lib/qq-endpoint-commands.js +122 -0
  23. package/lib/qq-endpoint-config.d.ts +17 -0
  24. package/lib/qq-endpoint-config.js +22 -0
  25. package/lib/qq-runtime-state.d.ts +17 -0
  26. package/lib/qq-runtime-state.js +13 -0
  27. package/lib/webhook.js +4 -11
  28. package/lib/ws.d.ts +25 -4
  29. package/lib/ws.js +18 -1
  30. package/package.json +19 -13
  31. package/plugin.ts +4 -1
  32. package/schema.json +47 -13
  33. package/src/endpoint.ts +61 -5
  34. package/src/index.ts +7 -0
  35. package/src/outbound.ts +262 -0
  36. package/src/protocol.ts +16 -2
  37. package/src/qq-bind-api.ts +145 -0
  38. package/src/qq-bind-flow.ts +127 -0
  39. package/src/qq-bind-persist.ts +44 -0
  40. package/src/qq-endpoint-commands.ts +160 -0
  41. package/src/qq-endpoint-config.ts +44 -0
  42. package/src/qq-runtime-state.ts +36 -0
  43. package/src/webhook.ts +9 -15
  44. package/src/ws.ts +37 -10
package/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # @zhin.js/adapter-qq
2
2
 
3
+ ## 6.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
8
+
9
+ - **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
10
+ - **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
11
+ - **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
12
+ - **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
13
+ - **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
14
+ - **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
15
+
16
+ - 9c997b2: 通用 endpoint 管理命令套件:`@zhin.js/adapter` 新增 `createEndpointCommands(spec, defineCommand)`——`<adapter> endpoint list / add <name> key=value... / remove <name>` 三件套,含 kv 解析、`.env` 凭据派生(`<ADAPTER>_<NAME>_<FIELD>`)、yaml 写回保留注释、master 权限门禁(通用 `isEndpointOperator`)、自定义 bindFlow 钩子。QQ 迁移至套件(行为与扫码绑定流程不变);napcat / onebot11 / onebot12 / milky / slack / telegram 接入(字段对齐各自 schema,features 补 @zhin.js/command)。
17
+ - 3e925d0: QQ 出站富媒体移植(自 legacy):`image` 段支持 http(s) URL 直发与 base64/本地路径经 SDK `/files` 上传;`markdown` 段(原文与 custom_template_id+params 模板);`keyboard` 段(按钮矩阵 callback/command,自动合并为 markdown+keyboard 载荷);`at`/`reply` 段透传。文本行为不变,不可映射段降级文本并 warn。
18
+ - Updated dependencies [cdf64e7]
19
+ - Updated dependencies [2d0a159]
20
+ - Updated dependencies [5691aba]
21
+ - Updated dependencies [078e3f7]
22
+ - Updated dependencies [50497a5]
23
+ - Updated dependencies [9c997b2]
24
+ - Updated dependencies [09d4f25]
25
+ - Updated dependencies [43485a9]
26
+ - Updated dependencies [f0ec5ab]
27
+ - Updated dependencies [3e925d0]
28
+ - Updated dependencies [fa66c4c]
29
+ - Updated dependencies [fa66c4c]
30
+ - Updated dependencies [6cb6152]
31
+ - @zhin.js/command@1.0.3
32
+ - @zhin.js/agent@1.0.6
33
+ - @zhin.js/plugin-runtime@1.1.1
34
+ - @zhin.js/host-http@1.0.3
35
+ - zhin.js@5.0.1
36
+ - @zhin.js/adapter@1.1.1
37
+ - @zhin.js/core@1.4.1
38
+
39
+ ## 6.0.0
40
+
41
+ ### Minor Changes
42
+
43
+ - 3ea88d8: 恢复 legacy 的聊天命令添加 endpoint 能力(随适配器发布):`qq endpoint add [name]`(手机 QQ 扫码绑定 → 凭据写 `.env` 的 `QQ_<NAME>_APPID/SECRET` → 追加 `plugins.qq.endpoints` 配置,重启生效)、`qq endpoint cancel`(中止绑定)、`qq endpoint list`(运行中 + 配置内 endpoints)、`qq endpoint remove <name>`(移除配置项)。add/cancel/remove 受 `master` 限制(顶层或 `endpoints[i]` 声明时仅 master 可执行,未配置则放行);schema 新增顶层共享字段 `master`。绑定协议(create_bind_task / poll_bind_result / AES-256-GCM 解密)自 legacy 移植,二维码当前以链接文本下发(QQ 出站富媒体待迁移)。
44
+
45
+ ### Patch Changes
46
+
47
+ - 7db69c1: 命令前缀改为适配器配置项:`MessageDispatcher` 不再硬编码 `/`,默认按消息所属适配器实例 config 的 `commandPrefix` 解析(默认 `''` 无前缀,任意文本按命令匹配),`endpoints[i].commandPrefix` 逐项覆盖;`ImRuntime({ commandPrefix })` 仍可设全局静态前缀。全部 20 个平台适配器 schema 新增 `commandPrefix` 属性。
48
+
49
+ BREAKING(行为变化):未配置时命令不再需要 `/` 前缀——原 `/zt` 写法不再命中,直接发 `zt` 即可;需要斜杠风格的适配器请在配置里显式设 `commandPrefix: '/'`。
50
+
51
+ - e5c84ed: Adapter 多账号:插件实例 config 支持 `endpoints: [{name, ...覆盖}]` 数组,`expandEndpointConfigs` 将一个实例展开为多个 endpoint record(id 为 `<slotId>~<name>`,顶层字段共享、逐项覆盖),替代多 `instanceKey` 方案;Console `/api/plugins` 收敛为一个插件卡片 + 多 endpoint。icqq / qq schema 与 README 补 `endpoints` 配置。
52
+
53
+ Plugin Runtime Console Host:补 `/esm/*` React/router ESM 代理路由(legacy `consoleApiRouter` 对齐),TypeScriptClientBuilder 裸导入改写为 `/esm/<enc>.mjs`。
54
+
55
+ - 713445c: 适配器配置格式定稿(不兼容旧格式):`plugins.<adapter>` 顶层仅共享字段 + `commandPrefix`,`endpoints[i]` 携带 endpoint 级字段(`name` + 凭据,各 schema 已类型化),`endpoints` 为必填(icqq 另需顶层 `master`);icqq 新增 `trusted` 列表(顶层/逐项均可)。scaffold-wizard 全部字段式与自定义 configure() 产出改为新格式,examples(full-bot / qq-games-bot)与 20 个适配器 README 同步迁移。
56
+ - f32c424: 适配器配置统一为 endpoints 数组格式:`plugins.<adapter>` 为该 adapter 所有 endpoint 的通用配置,`plugins.<adapter>.endpoints[i]` 为单个 endpoint 的特殊配置(逐项覆盖,`name` 必填)。slack / github schema 新增 `endpoints` 属性;icqq / qq / slack 顶层必填改为 `anyOf`(单 endpoint 字段或 `endpoints` 数组二选一,兼容 Ajv strictRequired)。
57
+ - Updated dependencies [7db69c1]
58
+ - Updated dependencies [e5c84ed]
59
+ - Updated dependencies [3ea84a0]
60
+ - Updated dependencies [1ddcd70]
61
+ - Updated dependencies [ac9da66]
62
+ - @zhin.js/core@1.4.0
63
+ - @zhin.js/adapter@1.1.0
64
+ - @zhin.js/plugin-runtime@1.1.0
65
+ - @zhin.js/agent@1.0.5
66
+ - @zhin.js/host-http@1.0.2
67
+ - zhin.js@5.0.0
68
+ - @zhin.js/command@1.0.2
69
+
3
70
  ## 5.0.3
4
71
 
5
72
  ### Patch Changes
package/README.md CHANGED
@@ -36,7 +36,7 @@ pnpm add @zhin.js/adapter-qq
36
36
  | **WebSocket(默认)** | `qq-official-bot` 正向连接;无需公网回调 |
37
37
  | **host-http** | WebSocket **不需要**;Webhook / middleware 模式需要(经 `httpHostToken`) |
38
38
 
39
- 必填字段:`appid`、`secret`。
39
+ 必填字段(`endpoints[i]`):`name`、`appid`、`secret`。
40
40
 
41
41
  ## 最小配置
42
42
 
@@ -44,14 +44,46 @@ pnpm add @zhin.js/adapter-qq
44
44
  # zhin.config.yml(Plugin Runtime)
45
45
  plugins:
46
46
  qq:
47
- name: my-qq-bot
48
- appid: ${QQ_APPID}
49
- secret: ${QQ_SECRET}
50
47
  # mode: websocket # 默认
48
+ endpoints:
49
+ - name: my-qq-bot
50
+ appid: ${QQ_APPID}
51
+ secret: ${QQ_SECRET}
52
+ ```
53
+
54
+ 多账号:一个插件实例挂多个 endpoint(`endpoints` 数组逐项覆盖顶层字段,`name` 必填):
55
+
56
+ ```yaml
57
+ plugins:
58
+ qq:
59
+ mode: websocket
60
+ intents: [GUILDS, GROUP_AND_C2C_EVENT]
61
+ endpoints:
62
+ - name: main-bot
63
+ appid: ${QQ_APPID}
64
+ secret: ${QQ_SECRET}
65
+ - name: second-bot
66
+ appid: ${QQ_APPID_2}
67
+ secret: ${QQ_SECRET_2}
51
68
  ```
52
69
 
53
70
  根插件 `zhin.plugins`(或项目图)需引用 `@zhin.js/adapter-qq`(`instanceKey: qq`)。
54
71
 
72
+ ## Endpoint 管理命令
73
+
74
+ 适配器自带 `qq endpoint` 命令组(聊天内直接使用,默认无前缀;受 `commandPrefix` 影响):
75
+
76
+ | 命令 | 说明 |
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` 键保留,可手动清理) |
82
+
83
+ add/cancel/remove 受 `master` 限制:实例配置声明了 `master`(顶层或 `endpoints[i]`)时仅
84
+ master 可执行;未配置则放行(首个扫码绑定者即 owner)。二维码当前以链接文本下发
85
+ (出站富媒体待迁移),用手机 QQ 打开链接即可扫码。
86
+
55
87
  ## 环境变量
56
88
 
57
89
  | 变量 | 说明 |
package/adapters/qq.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  resolveQqConfig,
10
10
  type QqAdapterConfig,
11
11
  } from '../src/protocol.js';
12
+ import { qqRuntimeStateToken } from '../src/qq-runtime-state.js';
12
13
 
13
14
  export {
14
15
  QqHttpEndpoint,
@@ -25,21 +26,39 @@ export type {
25
26
 
26
27
  export default defineAdapter<QqAdapterConfig>({
27
28
  capabilities: ['inbound', 'outbound'],
29
+ // 媒体 url 直发,base64/path 由 SDK formatMediaData 物化走 /files 上传;
30
+ // markdown/keyboard 原生按钮承载交互段。
31
+ segments: {
32
+ outboundMedia: ['url', 'upload'],
33
+ interactive: 'native',
34
+ },
28
35
  create(context) {
29
36
  const config = resolveQqConfig(context.config);
30
37
  const gateway = context.use(messageGatewayToken);
31
- if (config.mode === 'webhook' || config.mode === 'middleware') {
32
- return new QqHttpEndpoint({
38
+ // 注册到插件运行时状态(qq endpoint list 的"运行中"数据源)
39
+ const state = context.use(qqRuntimeStateToken);
40
+ state.endpoints.set(config.name, {
41
+ name: config.name,
42
+ mode: config.mode,
43
+ });
44
+ const endpoint = (config.mode === 'webhook' || config.mode === 'middleware')
45
+ ? new QqHttpEndpoint({
33
46
  id: context.id,
34
47
  gateway,
35
48
  http: context.use(httpHostToken),
36
49
  config,
50
+ })
51
+ : new QqWebsocketEndpoint({
52
+ id: context.id,
53
+ gateway,
54
+ config,
37
55
  });
38
- }
39
- return new QqWebsocketEndpoint({
40
- id: context.id,
41
- gateway,
42
- config,
43
- });
56
+ // 运行状态表只增不减:stop 时同步摘除,避免 stop 后 endpoint list 仍显示运行中。
57
+ const stopEndpoint = endpoint.stop.bind(endpoint);
58
+ endpoint.stop = async () => {
59
+ state.endpoints.delete(config.name);
60
+ await stopEndpoint();
61
+ };
62
+ return endpoint;
44
63
  },
45
64
  });
@@ -0,0 +1,3 @@
1
+ import { qqEndpointCommands } from '../../../src/qq-endpoint-commands.js';
2
+
3
+ export default qqEndpointCommands.add;
@@ -0,0 +1,15 @@
1
+ import { defineCommand } from '@zhin.js/command';
2
+ import {
3
+ isQqEndpointOperator,
4
+ QQ_ENDPOINT_FORBIDDEN,
5
+ runQqEndpointCancel,
6
+ } from '../../src/qq-endpoint-commands.js';
7
+ import { qqRuntimeStateToken } from '../../src/qq-runtime-state.js';
8
+
9
+ export default defineCommand({
10
+ description: '取消进行中的 QQ 扫码绑定流程',
11
+ execute({ config, input, use }) {
12
+ if (!isQqEndpointOperator(config, input)) return QQ_ENDPOINT_FORBIDDEN;
13
+ return runQqEndpointCancel(use(qqRuntimeStateToken));
14
+ },
15
+ });
@@ -0,0 +1,3 @@
1
+ import { qqEndpointCommands } from '../../src/qq-endpoint-commands.js';
2
+
3
+ export default qqEndpointCommands.list;
@@ -0,0 +1,3 @@
1
+ import { qqEndpointCommands } from '../../../src/qq-endpoint-commands.js';
2
+
3
+ export default qqEndpointCommands.remove;
package/lib/endpoint.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * QQ endpoints — lifecycle, outbound, admit, agent tool surface.
3
3
  */
4
- import type { EndpointInstance } from '@zhin.js/adapter';
4
+ import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
5
5
  import type { MessageGateway } from '@zhin.js/core/runtime';
6
6
  import type { HttpHost } from '@zhin.js/host-http';
7
7
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
@@ -18,7 +18,10 @@ export interface QqEndpointOptions {
18
18
  }
19
19
  export declare class QqWebsocketEndpoint implements EndpointInstance {
20
20
  #private;
21
+ readonly management: EndpointManagement;
21
22
  constructor(options: QqEndpointOptions);
23
+ /** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
24
+ get name(): string;
22
25
  start(): Promise<void>;
23
26
  open(): void;
24
27
  close(): void;
@@ -48,7 +51,10 @@ export interface QqHttpEndpointOptions {
48
51
  /** Webhook / middleware inbound via httpHostToken POST (qq-official-bot Middleware receiver). */
49
52
  export declare class QqHttpEndpoint implements EndpointInstance {
50
53
  #private;
54
+ readonly management: EndpointManagement;
51
55
  constructor(options: QqHttpEndpointOptions);
56
+ /** Live endpoint 名(Console/AdapterIndex 展示用)。 */
57
+ get name(): string;
52
58
  start(): Promise<void>;
53
59
  open(): void;
54
60
  close(): void;
package/lib/endpoint.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { formatCompact, getLogger } from '@zhin.js/logger';
2
+ import { formatOutbound } from './outbound.js';
2
3
  import { registerQqAgentEndpoint } from './qq-agent-deps.js';
3
- import { formatInboundContent, formatInboundTarget, formatOutboundText, parseSendTarget, resolveOutboundMessageId, senderDisplayName, } from './protocol.js';
4
+ import { formatInboundContent, formatInboundTarget, parseSendTarget, resolveOutboundMessageId, senderDisplayName, } from './protocol.js';
4
5
  import { defaultCreateHttpBot, registerQqWebhookRoutes, } from './webhook.js';
5
6
  import { bindQqBotInboundEvents, defaultCreateBot, normalizeQqMessage, } from './ws.js';
6
7
  const logger = getLogger('qq');
@@ -11,10 +12,15 @@ export class QqWebsocketEndpoint {
11
12
  #open = false;
12
13
  #started = false;
13
14
  #unregisterAgent;
15
+ management = createQqEndpointManagement(this);
14
16
  constructor(options) {
15
17
  this.#options = options;
16
18
  this.#createBot = options.createBot ?? defaultCreateBot;
17
19
  }
20
+ /** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
21
+ get name() {
22
+ return this.#options.config.name;
23
+ }
18
24
  async start() {
19
25
  if (this.#started)
20
26
  return;
@@ -34,7 +40,8 @@ export class QqWebsocketEndpoint {
34
40
  catch (error) {
35
41
  await this.stop();
36
42
  logger.error('Failed to connect QQ websocket:', error);
37
- throw error;
43
+ const raw = error instanceof Error ? error.message : String(error);
44
+ throw new Error(`QQ WebSocket 连接失败:请检查 appid/secret 是否配对、网关地址(gatewayUrl/accessTokenUrl)是否可达(原始错误:${raw})`, { cause: error });
38
45
  }
39
46
  }
40
47
  open() {
@@ -61,7 +68,7 @@ export class QqWebsocketEndpoint {
61
68
  logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
62
69
  }
63
70
  async send({ target, payload }) {
64
- const body = formatOutboundText(payload);
71
+ const body = formatOutbound(payload);
65
72
  const parsed = parseSendTarget(target);
66
73
  const bot = this.#requireBot();
67
74
  let result;
@@ -166,10 +173,15 @@ export class QqHttpEndpoint {
166
173
  #open = false;
167
174
  #started = false;
168
175
  #unregisterAgent;
176
+ management = createQqEndpointManagement(this);
169
177
  constructor(options) {
170
178
  this.#options = options;
171
179
  this.#createBot = options.createBot ?? defaultCreateHttpBot;
172
180
  }
181
+ /** Live endpoint 名(Console/AdapterIndex 展示用)。 */
182
+ get name() {
183
+ return this.#options.config.name;
184
+ }
173
185
  async start() {
174
186
  if (this.#started)
175
187
  return;
@@ -219,7 +231,7 @@ export class QqHttpEndpoint {
219
231
  logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
220
232
  }
221
233
  async send({ target, payload }) {
222
- const body = formatOutboundText(payload);
234
+ const body = formatOutbound(payload);
223
235
  const parsed = parseSendTarget(target);
224
236
  const bot = this.#requireBot();
225
237
  let result;
@@ -313,3 +325,36 @@ export class QqHttpEndpoint {
313
325
  return this.#bot;
314
326
  }
315
327
  }
328
+ function createQqEndpointManagement(endpoint) {
329
+ return Object.freeze({
330
+ async listChannels() {
331
+ const channels = [];
332
+ const guilds = await endpoint.getGuilds();
333
+ for (const guildValue of Array.isArray(guilds) ? guilds : []) {
334
+ const guild = asRecord(guildValue);
335
+ const guildId = String(guild.id ?? guild.guild_id ?? guildValue ?? '');
336
+ if (!guildId)
337
+ continue;
338
+ const guildName = String(guild.name ?? guild.guild_name ?? guildId);
339
+ const rows = await endpoint.getChannels(guildId);
340
+ for (const channelValue of Array.isArray(rows) ? rows : []) {
341
+ const channel = asRecord(channelValue);
342
+ const id = String(channel.id ?? channel.channel_id ?? channelValue ?? '');
343
+ if (!id)
344
+ continue;
345
+ channels.push({
346
+ id,
347
+ name: String(channel.name ?? channel.channel_name ?? id),
348
+ parent: { type: 'guild', id: guildId, name: guildName },
349
+ });
350
+ }
351
+ }
352
+ return channels;
353
+ },
354
+ });
355
+ }
356
+ function asRecord(value) {
357
+ return value !== null && typeof value === 'object'
358
+ ? value
359
+ : {};
360
+ }
package/lib/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { formatInboundContent, formatInboundTarget, formatOutboundText, parseSendTarget, resolveOutboundMessageId, resolveQqConfig, senderDisplayName, type ParsedSendTarget, type QqAdapterConfig, type QqChannelKind, type QqInboundMessage, type QqWireSegment, type ResolvedQqConfig, type ResolvedQqHttpConfig, type ResolvedQqWebsocketConfig, } from './protocol.js';
2
+ export { formatOutbound, resolveMediaFile, type QqOutboundElem, type QqOutboundMessage, } from './outbound.js';
2
3
  export { QqHttpEndpoint, QqWebsocketEndpoint, type CreateQqBot, type CreateQqHttpBot, type QqBotTransport, type QqEndpointOptions, type QqHttpBotTransport, type QqHttpEndpointOptions, } from './endpoint.js';
3
4
  export { registerQqWebhookRoutes, handleQqWebhookRequest, readRequestBodyText, defaultCreateHttpBot, type QqWebhookHandler, } from './webhook.js';
4
5
  export { bindQqBotInboundEvents, defaultCreateBot, normalizeQqMessage, } from './ws.js';
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { formatInboundContent, formatInboundTarget, formatOutboundText, parseSendTarget, resolveOutboundMessageId, resolveQqConfig, senderDisplayName, } from './protocol.js';
2
+ export { formatOutbound, resolveMediaFile, } from './outbound.js';
2
3
  export { QqHttpEndpoint, QqWebsocketEndpoint, } from './endpoint.js';
3
4
  export { registerQqWebhookRoutes, handleQqWebhookRequest, readRequestBodyText, defaultCreateHttpBot, } from './webhook.js';
4
5
  export { bindQqBotInboundEvents, defaultCreateBot, normalizeQqMessage, } from './ws.js';
@@ -0,0 +1,17 @@
1
+ /** qq-official-bot Sendable 的最小结构子集(字符串模板或消息段)。 */
2
+ export interface QqOutboundElem {
3
+ readonly type: string;
4
+ readonly data: Record<string, unknown>;
5
+ }
6
+ export type QqOutboundMessage = string | QqOutboundElem | readonly (string | QqOutboundElem)[];
7
+ /**
8
+ * 解析 image/audio/video/file 段的本地或内联 payload。
9
+ * qq-official-bot 的 formatMediaData 对 data.url 直接当远程 URL;
10
+ * base64:// / data:...;base64 / 本地路径须走 data.file 才会解析为 file_data。
11
+ */
12
+ export declare function resolveMediaFile(data: Record<string, unknown>): string | undefined;
13
+ /**
14
+ * 渲染后 payload → QQ Sendable。
15
+ * 纯文本输入原样返回(与既有行为一致);含富媒体段时返回段数组。
16
+ */
17
+ export declare function formatOutbound(payload: unknown): QqOutboundMessage;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * QQ 官方出站富媒体归一化:把渲染后的 payload 转为 qq-official-bot 可识别的
3
+ * Sendable 结构(文本串 + 消息段)。移植自 legacy 版 outbound-media /
4
+ * outbound-markdown / outbound-keyboard。
5
+ *
6
+ * 移植面:
7
+ * - image:http(s) URL 直发(data.url);base64://、data:...;base64、本地路径
8
+ * 归一到 data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
9
+ * - markdown:data.content 原文,或 custom_template_id + params 模板。
10
+ * - keyboard:data.rows 按钮矩阵展开为 button 段(SDK processButtons 组装
11
+ * keyboard 载荷);按钮消息必须带 markdown,故同行文本合并为 markdown 段。
12
+ * data.id 模板键盘原样透传。
13
+ * 不可映射的段一律降级为文本并 warn(保持既有 fallback 行为)。
14
+ */
15
+ import { formatCompact, getLogger } from '@zhin.js/logger';
16
+ 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
+ /**
24
+ * 解析 image/audio/video/file 段的本地或内联 payload。
25
+ * qq-official-bot 的 formatMediaData 对 data.url 直接当远程 URL;
26
+ * base64:// / data:...;base64 / 本地路径须走 data.file 才会解析为 file_data。
27
+ */
28
+ 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;
41
+ }
42
+ 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 };
50
+ }
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 } };
57
+ }
58
+ // 既无远程 URL 也无本地上传源:无法投递,降级留痕
59
+ logger.warn(formatCompact({
60
+ op: 'qq_outbound_media_dropped',
61
+ type: seg.type,
62
+ reason: 'missing_source',
63
+ }));
64
+ return null;
65
+ }
66
+ function normalizeMarkdownSegment(seg) {
67
+ const data = seg.data ?? {};
68
+ const templateId = typeof data.custom_template_id === 'string' && data.custom_template_id
69
+ ? data.custom_template_id
70
+ : typeof data.template_id === 'string' && data.template_id
71
+ ? data.template_id
72
+ : undefined;
73
+ if (templateId) {
74
+ return {
75
+ type: 'markdown',
76
+ data: {
77
+ custom_template_id: templateId,
78
+ params: Array.isArray(data.params) ? data.params : [],
79
+ },
80
+ };
81
+ }
82
+ const content = String(data.content ?? data.text ?? '');
83
+ if (!content.trim()) {
84
+ logger.warn(formatCompact({ op: 'qq_outbound_markdown_dropped', reason: 'empty_content' }));
85
+ return null;
86
+ }
87
+ return { type: 'markdown', data: { content } };
88
+ }
89
+ function coreButtonToQq(btn) {
90
+ const isCommand = btn.mode === 'command';
91
+ const action = {
92
+ type: isCommand ? 2 : 1,
93
+ permission: { type: 2 },
94
+ data: btn.payload ?? '',
95
+ click_limit: btn.disabled ? 0 : 10,
96
+ unsupport_tips: btn.disabled ? '该按钮不可用' : '',
97
+ };
98
+ if (isCommand) {
99
+ if (btn.command?.enter != null)
100
+ action.enter = btn.command.enter;
101
+ if (btn.command?.reply != null)
102
+ action.reply = btn.command.reply;
103
+ }
104
+ return {
105
+ id: btn.id ?? '',
106
+ render_data: {
107
+ label: btn.label ?? '',
108
+ visited_label: btn.label ?? '',
109
+ style: 0,
110
+ },
111
+ action,
112
+ };
113
+ }
114
+ /** keyboard 段 → 模板键盘(data.id)或 button 行段(data.rows) */
115
+ function expandKeyboardSegment(seg) {
116
+ const data = seg.data ?? {};
117
+ if (typeof data.id === 'string' && data.id) {
118
+ return [{ type: 'keyboard', data: { id: data.id } }];
119
+ }
120
+ if (!Array.isArray(data.rows)) {
121
+ logger.warn(formatCompact({ op: 'qq_outbound_keyboard_dropped', reason: 'missing_rows' }));
122
+ return [];
123
+ }
124
+ const out = [];
125
+ for (const row of data.rows) {
126
+ if (!Array.isArray(row))
127
+ continue;
128
+ out.push({
129
+ type: 'button',
130
+ data: {
131
+ buttons: row.map((btn) => coreButtonToQq((btn ?? {}))),
132
+ },
133
+ });
134
+ }
135
+ return out;
136
+ }
137
+ function toSegmentList(payload) {
138
+ if (Array.isArray(payload))
139
+ return payload;
140
+ if (payload && typeof payload === 'object' && 'type' in payload) {
141
+ return [payload];
142
+ }
143
+ return [];
144
+ }
145
+ function textFromUnknownSegment(seg) {
146
+ const data = seg.data ?? {};
147
+ return data.text != null ? String(data.text) : '';
148
+ }
149
+ /**
150
+ * 渲染后 payload → QQ Sendable。
151
+ * 纯文本输入原样返回(与既有行为一致);含富媒体段时返回段数组。
152
+ */
153
+ export function formatOutbound(payload) {
154
+ if (typeof payload === 'string')
155
+ return payload;
156
+ const segments = toSegmentList(payload);
157
+ if (segments.length === 0) {
158
+ return payload == null
159
+ ? ''
160
+ : typeof payload === 'object'
161
+ ? JSON.stringify(payload)
162
+ : String(payload);
163
+ }
164
+ // QQ 按钮消息必须携带 markdown(msg_type=2),keyboard 存在时文本合并为 markdown 段
165
+ const hasKeyboard = segments.some((item) => typeof item !== 'string' && item.type === 'keyboard');
166
+ const parts = [];
167
+ let textBuf = '';
168
+ const flushText = () => {
169
+ if (!textBuf)
170
+ return;
171
+ parts.push(hasKeyboard
172
+ ? { type: 'markdown', data: { content: textBuf } }
173
+ : textBuf);
174
+ textBuf = '';
175
+ };
176
+ for (const item of segments) {
177
+ if (typeof item === 'string') {
178
+ textBuf += item;
179
+ continue;
180
+ }
181
+ const data = item.data ?? {};
182
+ switch (item.type) {
183
+ case 'text':
184
+ textBuf += String(data.text ?? data.content ?? '');
185
+ break;
186
+ case 'at': {
187
+ const id = String(data.user_id ?? data.qq ?? data.id ?? '');
188
+ if (!id)
189
+ break;
190
+ flushText();
191
+ parts.push({ type: 'at', data: { user_id: id } });
192
+ break;
193
+ }
194
+ case 'image':
195
+ case 'audio':
196
+ case 'video':
197
+ case 'file': {
198
+ flushText();
199
+ const media = normalizeMediaSegment(item);
200
+ if (media)
201
+ parts.push(media);
202
+ break;
203
+ }
204
+ case 'markdown': {
205
+ flushText();
206
+ const markdown = normalizeMarkdownSegment(item);
207
+ if (markdown)
208
+ parts.push(markdown);
209
+ break;
210
+ }
211
+ case 'keyboard': {
212
+ flushText();
213
+ parts.push(...expandKeyboardSegment(item));
214
+ break;
215
+ }
216
+ case 'reply': {
217
+ const id = data.id ?? data.event_id;
218
+ if (id == null || id === '')
219
+ break;
220
+ flushText();
221
+ parts.push({
222
+ type: 'reply',
223
+ data: data.event_id != null
224
+ ? { event_id: String(data.event_id) }
225
+ : { id: String(id) },
226
+ });
227
+ break;
228
+ }
229
+ default:
230
+ // 未识别段:保持既有文本降级
231
+ textBuf += textFromUnknownSegment(item);
232
+ break;
233
+ }
234
+ }
235
+ flushText();
236
+ if (parts.length === 0)
237
+ return '';
238
+ if (parts.length === 1)
239
+ return parts[0];
240
+ return parts;
241
+ }
package/lib/protocol.js CHANGED
@@ -3,6 +3,8 @@
3
3
  * Canonicalization is owned by gateway/core before endpoint.send.
4
4
  */
5
5
  import { pickCredential } from '@zhin.js/adapter';
6
+ import { formatCompact, getLogger } from '@zhin.js/logger';
7
+ const logger = getLogger('qq');
6
8
  export function resolveQqConfig(config = {}) {
7
9
  const entry = config.endpoints?.find((item) => item.context === 'qq' || !item.context);
8
10
  const appid = pickCredential(config.appid, entry?.appid, process.env.QQ_APPID, process.env.QQ_BOT_APPID);
@@ -96,8 +98,19 @@ export function formatOutboundText(payload) {
96
98
  const id = String(data.user_id ?? data.qq ?? data.id ?? '');
97
99
  return id ? `<@!${id}>` : '';
98
100
  }
99
- case 'image':
100
- return String(data.url || data.file || '');
101
+ case 'image': {
102
+ const src = String(data.url || data.file || '');
103
+ // base64/本地路径由 formatOutbound 走媒体上传,文本视图静默省略;
104
+ // 仅当彻底缺少来源时留痕,避免出站静默降级排障无头绪。
105
+ if (!src) {
106
+ logger.warn(formatCompact({
107
+ op: 'qq_outbound_image_dropped',
108
+ reason: 'missing_source',
109
+ }));
110
+ return '';
111
+ }
112
+ return /^https?:\/\//.test(src) ? src : '';
113
+ }
101
114
  case 'reply':
102
115
  return '';
103
116
  default: