@zhin.js/adapter-qq 6.0.0 → 6.0.2

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,59 @@
1
1
  # @zhin.js/adapter-qq
2
2
 
3
+ ## 6.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d5cd4aa: Publish Plugin Runtime entry points and convention modules as JavaScript so
8
+ installed npm plugins load on Node without TypeScript stripping. Workspace
9
+ development continues to prefer TypeScript sources for local HMR.
10
+
11
+ Remove the unconsumed legacy game hub APIs from game-kit; game navigation and
12
+ records now use ordinary convention commands owned by the game hub plugin.
13
+
14
+ - Updated dependencies [d5cd4aa]
15
+ - @zhin.js/command@1.0.4
16
+ - zhin.js@5.0.2
17
+ - @zhin.js/adapter@1.1.2
18
+ - @zhin.js/core@1.4.2
19
+ - @zhin.js/agent@1.0.7
20
+
21
+ ## 6.0.1
22
+
23
+ ### Patch Changes
24
+
25
+ - 5691aba: 第二轮全量审计修复批(8 面 ~60 bug):
26
+
27
+ - **安全**:email 附件路径穿越修复(basename + downloadPath 约束);lark/telegram/satori webhook 鉴权(缺密钥告警、timingSafeEqual、±5min 时效窗、chat_type 修正);onebot wss/webhook 缺 token 告警;qq webhook 改原始字节验签;renderJsx/JSX 转义注入修复;console runtime token 401 死循环。
28
+ - **P0 功能**:sandbox 多 endpoint 解析 + WS 路径隔离;short-url expand(undici opaqueredirect)改 follow;AI 压缩摘要失败不再静默丢历史(熔断恢复生效);console-ui 实时推送事件名归一化 + IndexedDB schema 对齐;process-monitor 热重载不再误判崩溃。
29
+ - **生命周期**:email IMAP 断线重连 + 在飞锁;onebot11/12 start 失败清理;line replyToken TTL + push 兜底;wechat-mp token 过期重试 + MsgId 去重;weixin-ilink buf 推进/防抖写盘/媒体 TTL/QR abort;satori PONG 看门狗;退避自毁修复。
30
+ - **游戏**:text-adventure 终局 restart 复活 + requires 服务端校验;tic-tac-toe PvP 占用/restart/队列清理/TTL;idiom-chain/word-riddle 闲聊不扣失误;别名中间件不劫持普通聊天。
31
+ - **共享库**:schema falsy 默认值/date/tuple/union 修复;database parseCondition Date/未知操作符、sqlite TEXT 往返、query 分派、belongsToMany 方言、migration dry-run;schedule DST 回拨死循环、重复 id 去重、flush 串行化;game-kit fallback 编号/onboarding 提示/尾缀边界/活引用拷贝。
32
+ - **渲染语音**:fetch 全部超时 + 渲染并发闸;sanitizeHtml form 保文本;STT 扩展名映射 + 删临时文件;TTS 未知 provider 报错;emojiCache LRU 负缓存/fontCache style/clearFonts 恢复;register 错误分类收窄。
33
+
34
+ - 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)。
35
+ - 3e925d0: QQ 出站富媒体移植(自 legacy):`image` 段支持 http(s) URL 直发与 base64/本地路径经 SDK `/files` 上传;`markdown` 段(原文与 custom_template_id+params 模板);`keyboard` 段(按钮矩阵 callback/command,自动合并为 markdown+keyboard 载荷);`at`/`reply` 段透传。文本行为不变,不可映射段降级文本并 warn。
36
+ - Updated dependencies [cdf64e7]
37
+ - Updated dependencies [2d0a159]
38
+ - Updated dependencies [5691aba]
39
+ - Updated dependencies [078e3f7]
40
+ - Updated dependencies [50497a5]
41
+ - Updated dependencies [9c997b2]
42
+ - Updated dependencies [09d4f25]
43
+ - Updated dependencies [43485a9]
44
+ - Updated dependencies [f0ec5ab]
45
+ - Updated dependencies [3e925d0]
46
+ - Updated dependencies [fa66c4c]
47
+ - Updated dependencies [fa66c4c]
48
+ - Updated dependencies [6cb6152]
49
+ - @zhin.js/command@1.0.3
50
+ - @zhin.js/agent@1.0.6
51
+ - @zhin.js/plugin-runtime@1.1.1
52
+ - @zhin.js/host-http@1.0.3
53
+ - zhin.js@5.0.1
54
+ - @zhin.js/adapter@1.1.1
55
+ - @zhin.js/core@1.4.1
56
+
3
57
  ## 6.0.0
4
58
 
5
59
  ### Minor Changes
package/adapters/qq.js ADDED
@@ -0,0 +1,49 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ /**
3
+ * Convention entry: discover `adapters/qq.ts` → defineAdapter.
4
+ */
5
+ import { defineAdapter } from '@zhin.js/adapter';
6
+ import { messageGatewayToken } from '@zhin.js/core/runtime';
7
+ import { httpHostToken } from '@zhin.js/host-http';
8
+ import { QqHttpEndpoint, QqWebsocketEndpoint } from "../lib/endpoint.js";
9
+ import { resolveQqConfig, } from "../lib/protocol.js";
10
+ import { qqRuntimeStateToken } from "../lib/qq-runtime-state.js";
11
+ export { QqHttpEndpoint, QqWebsocketEndpoint, } from "../lib/endpoint.js";
12
+ export default defineAdapter({
13
+ capabilities: ['inbound', 'outbound'],
14
+ // 媒体 url 直发,base64/path 由 SDK formatMediaData 物化走 /files 上传;
15
+ // markdown/keyboard 原生按钮承载交互段。
16
+ segments: {
17
+ outboundMedia: ['url', 'upload'],
18
+ interactive: 'native',
19
+ },
20
+ create(context) {
21
+ const config = resolveQqConfig(context.config);
22
+ const gateway = context.use(messageGatewayToken);
23
+ // 注册到插件运行时状态(qq endpoint list 的"运行中"数据源)
24
+ const state = context.use(qqRuntimeStateToken);
25
+ state.endpoints.set(config.name, {
26
+ name: config.name,
27
+ mode: config.mode,
28
+ });
29
+ const endpoint = (config.mode === 'webhook' || config.mode === 'middleware')
30
+ ? new QqHttpEndpoint({
31
+ id: context.id,
32
+ gateway,
33
+ http: context.use(httpHostToken),
34
+ config,
35
+ })
36
+ : new QqWebsocketEndpoint({
37
+ id: context.id,
38
+ gateway,
39
+ config,
40
+ });
41
+ // 运行状态表只增不减:stop 时同步摘除,避免 stop 后 endpoint list 仍显示运行中。
42
+ const stopEndpoint = endpoint.stop.bind(endpoint);
43
+ endpoint.stop = async () => {
44
+ state.endpoints.delete(config.name);
45
+ await stopEndpoint();
46
+ };
47
+ return endpoint;
48
+ },
49
+ });
package/adapters/qq.ts CHANGED
@@ -26,26 +26,39 @@ export type {
26
26
 
27
27
  export default defineAdapter<QqAdapterConfig>({
28
28
  capabilities: ['inbound', 'outbound'],
29
+ // 媒体 url 直发,base64/path 由 SDK formatMediaData 物化走 /files 上传;
30
+ // markdown/keyboard 原生按钮承载交互段。
31
+ segments: {
32
+ outboundMedia: ['url', 'upload'],
33
+ interactive: 'native',
34
+ },
29
35
  create(context) {
30
36
  const config = resolveQqConfig(context.config);
31
37
  const gateway = context.use(messageGatewayToken);
32
38
  // 注册到插件运行时状态(qq endpoint list 的"运行中"数据源)
33
- context.use(qqRuntimeStateToken).endpoints.set(config.name, {
39
+ const state = context.use(qqRuntimeStateToken);
40
+ state.endpoints.set(config.name, {
34
41
  name: config.name,
35
42
  mode: config.mode,
36
43
  });
37
- if (config.mode === 'webhook' || config.mode === 'middleware') {
38
- return new QqHttpEndpoint({
44
+ const endpoint = (config.mode === 'webhook' || config.mode === 'middleware')
45
+ ? new QqHttpEndpoint({
39
46
  id: context.id,
40
47
  gateway,
41
48
  http: context.use(httpHostToken),
42
49
  config,
50
+ })
51
+ : new QqWebsocketEndpoint({
52
+ id: context.id,
53
+ gateway,
54
+ config,
43
55
  });
44
- }
45
- return new QqWebsocketEndpoint({
46
- id: context.id,
47
- gateway,
48
- config,
49
- });
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;
50
63
  },
51
64
  });
@@ -0,0 +1,3 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { qqEndpointCommands } from "../../../lib/qq-endpoint-commands.js";
3
+ export default qqEndpointCommands.add;
@@ -1,21 +1,3 @@
1
- import { defineCommand } from '@zhin.js/command';
2
- import {
3
- extractQqCommandReply,
4
- isQqEndpointOperator,
5
- QQ_ENDPOINT_FORBIDDEN,
6
- runQqEndpointAdd,
7
- } from '../../../src/qq-endpoint-commands.js';
8
- import { qqRuntimeStateToken } from '../../../src/qq-runtime-state.js';
1
+ import { qqEndpointCommands } from '../../../src/qq-endpoint-commands.js';
9
2
 
10
- export default defineCommand({
11
- description: '手机 QQ 扫码绑定机器人,凭据写入 .env 并追加到 zhin.config.yml(重启生效)',
12
- execute({ config, input, params, use }) {
13
- if (!isQqEndpointOperator(config, input)) return QQ_ENDPOINT_FORBIDDEN;
14
- const name = typeof params.name === 'string' && params.name.trim() ? params.name.trim() : undefined;
15
- return runQqEndpointAdd(
16
- use(qqRuntimeStateToken),
17
- name,
18
- extractQqCommandReply(input),
19
- );
20
- },
21
- });
3
+ export default qqEndpointCommands.add;
@@ -0,0 +1,12 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineCommand } from '@zhin.js/command';
3
+ import { isQqEndpointOperator, QQ_ENDPOINT_FORBIDDEN, runQqEndpointCancel, } from "../../lib/qq-endpoint-commands.js";
4
+ import { qqRuntimeStateToken } from "../../lib/qq-runtime-state.js";
5
+ export default defineCommand({
6
+ description: '取消进行中的 QQ 扫码绑定流程',
7
+ execute({ config, input, use }) {
8
+ if (!isQqEndpointOperator(config, input))
9
+ return QQ_ENDPOINT_FORBIDDEN;
10
+ return runQqEndpointCancel(use(qqRuntimeStateToken));
11
+ },
12
+ });
@@ -0,0 +1,3 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { qqEndpointCommands } from "../../lib/qq-endpoint-commands.js";
3
+ export default qqEndpointCommands.list;
@@ -1,10 +1,3 @@
1
- import { defineCommand } from '@zhin.js/command';
2
- import { runQqEndpointList } from '../../src/qq-endpoint-commands.js';
3
- import { qqRuntimeStateToken } from '../../src/qq-runtime-state.js';
1
+ import { qqEndpointCommands } from '../../src/qq-endpoint-commands.js';
4
2
 
5
- export default defineCommand({
6
- description: '列出 QQ endpoints(运行中 + zhin.config.yml 配置)',
7
- execute({ use }) {
8
- return runQqEndpointList(use(qqRuntimeStateToken));
9
- },
10
- });
3
+ export default qqEndpointCommands.list;
@@ -0,0 +1,3 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { qqEndpointCommands } from "../../../lib/qq-endpoint-commands.js";
3
+ export default qqEndpointCommands.remove;
@@ -1,15 +1,3 @@
1
- import { defineCommand } from '@zhin.js/command';
2
- import {
3
- isQqEndpointOperator,
4
- QQ_ENDPOINT_FORBIDDEN,
5
- runQqEndpointRemove,
6
- } from '../../../src/qq-endpoint-commands.js';
7
- import { qqRuntimeStateToken } from '../../../src/qq-runtime-state.js';
1
+ import { qqEndpointCommands } from '../../../src/qq-endpoint-commands.js';
8
2
 
9
- export default defineCommand({
10
- description: '从 zhin.config.yml 的 plugins.qq.endpoints 移除指定 endpoint(重启生效)',
11
- execute({ config, input, params, use }) {
12
- if (!isQqEndpointOperator(config, input)) return QQ_ENDPOINT_FORBIDDEN;
13
- return runQqEndpointRemove(use(qqRuntimeStateToken), String(params.name ?? ''));
14
- },
15
- });
3
+ export default qqEndpointCommands.remove;
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');
@@ -39,7 +40,8 @@ export class QqWebsocketEndpoint {
39
40
  catch (error) {
40
41
  await this.stop();
41
42
  logger.error('Failed to connect QQ websocket:', error);
42
- 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 });
43
45
  }
44
46
  }
45
47
  open() {
@@ -66,7 +68,7 @@ export class QqWebsocketEndpoint {
66
68
  logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
67
69
  }
68
70
  async send({ target, payload }) {
69
- const body = formatOutboundText(payload);
71
+ const body = formatOutbound(payload);
70
72
  const parsed = parseSendTarget(target);
71
73
  const bot = this.#requireBot();
72
74
  let result;
@@ -229,7 +231,7 @@ export class QqHttpEndpoint {
229
231
  logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
230
232
  }
231
233
  async send({ target, payload }) {
232
- const body = formatOutboundText(payload);
234
+ const body = formatOutbound(payload);
233
235
  const parsed = parseSendTarget(target);
234
236
  const bot = this.#requireBot();
235
237
  let result;
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:
@@ -1,3 +1,5 @@
1
+ import { resolveProjectRoot } from '@zhin.js/adapter';
2
+ export { resolveProjectRoot };
1
3
  export interface QqCredentialEnvKeys {
2
4
  appidKey: string;
3
5
  secretKey: string;
@@ -6,7 +8,5 @@ export interface QqCredentialEnvKeys {
6
8
  }
7
9
  /** 按 endpoint 名称生成唯一 env 键(如 `QQ_MY_BOT_APPID`) */
8
10
  export declare function buildQqCredentialEnvKeys(endpointName: string): QqCredentialEnvKeys;
9
- /** 项目根:ZHIN_PROJECT_ROOT 优先,缺省 process.cwd()(替代 legacy runtimeCwd) */
10
- export declare function resolveProjectRoot(): string;
11
11
  /** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
12
12
  export declare function persistQqCredentialsToEnv(endpointName: string, appId: string, appSecret: string, projectRoot?: string): QqCredentialEnvKeys;
@@ -1,10 +1,9 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
1
+ import { buildEndpointEnvKey, persistEndpointEnvValues, resolveProjectRoot, } from '@zhin.js/adapter';
2
+ export { resolveProjectRoot };
3
3
  /** 按 endpoint 名称生成唯一 env 键(如 `QQ_MY_BOT_APPID`) */
4
4
  export function buildQqCredentialEnvKeys(endpointName) {
5
- const slug = endpointName.replace(/[^a-zA-Z0-9_]/g, '_').toUpperCase();
6
- const appidKey = `QQ_${slug}_APPID`;
7
- const secretKey = `QQ_${slug}_SECRET`;
5
+ const appidKey = buildEndpointEnvKey('qq', endpointName, 'appid');
6
+ const secretKey = buildEndpointEnvKey('qq', endpointName, 'secret');
8
7
  return {
9
8
  appidKey,
10
9
  secretKey,
@@ -12,40 +11,12 @@ export function buildQqCredentialEnvKeys(endpointName) {
12
11
  secretRef: `\${${secretKey}}`,
13
12
  };
14
13
  }
15
- /** 项目根:ZHIN_PROJECT_ROOT 优先,缺省 process.cwd()(替代 legacy runtimeCwd) */
16
- export function resolveProjectRoot() {
17
- const envRoot = process.env.ZHIN_PROJECT_ROOT?.trim();
18
- return path.resolve(envRoot || process.cwd());
19
- }
20
- function escapeRegExp(text) {
21
- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
- }
23
- function upsertEnvLine(content, key, value) {
24
- const lineRe = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, 'm');
25
- const newLine = `${key}=${value}`;
26
- if (lineRe.test(content)) {
27
- return content.replace(lineRe, newLine);
28
- }
29
- const trimmed = content.replace(/\s*$/, '');
30
- if (trimmed.length === 0) {
31
- return `${newLine}\n`;
32
- }
33
- return `${trimmed}\n${newLine}\n`;
34
- }
35
14
  /** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
36
15
  export function persistQqCredentialsToEnv(endpointName, appId, appSecret, projectRoot) {
37
16
  const keys = buildQqCredentialEnvKeys(endpointName);
38
- const root = projectRoot ?? resolveProjectRoot();
39
- const envPath = path.join(root, '.env');
40
- let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : '';
41
- const entries = {
17
+ persistEndpointEnvValues({
42
18
  [keys.appidKey]: appId,
43
19
  [keys.secretKey]: appSecret,
44
- };
45
- for (const [key, value] of Object.entries(entries)) {
46
- content = upsertEnvLine(content, key, value);
47
- process.env[key] = value;
48
- }
49
- fs.writeFileSync(envPath, content);
20
+ }, projectRoot);
50
21
  return keys;
51
22
  }