@zhin.js/adapter-qq 1.1.10 → 1.1.12

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,56 @@
1
1
  # @zhin.js/adapter-qq
2
2
 
3
+ ## 1.1.12
4
+
5
+ ### Patch Changes
6
+
7
+ - e40899f: Adapters now project group owners and admins onto the canonical `sender.roles` names `owner` / `admin` (Telegram creator/administrator, KOOK guild owner and admin-permission roles, Lark chat owner/managers, QQ official guild owner/admin identities). KOOK numeric role ids moved from `roles` to `permissions` (`role:<id>`). `@zhin.js/command` exports `canManageConversation` ("private chat or owner/admin/master"), now shared by the Bilibili and earthquake subscription commands; Roulette lets owners and admins toggle the mute penalty and cancel a game.
8
+ - 5fc9e20: 修复 QQ 官方机器人分页「菜单」只显示标题和翻页按钮、指令列表丢失的问题:一条 QQ 消息只能携带一个 markdown,按钮消息或含内联 markdown 的消息中,文本与内联 markdown 现按顺序合并为同一 markdown 段(模板 markdown 与纯文本消息不变)。图片与 markdown/按钮同时出现时(如菜单渲染成图片),公网 URL 图片按 `![图片 #宽px #高px](url)` 嵌入 markdown(尺寸读取图片文件头获得);base64/本地图片(QQ 上传接口只返回 file_info,拿不到 URL)或尺寸未知时,降级为先发独立图片消息、再发 markdown 与按钮,避免 msg_type 冲突导致图片丢失。新增导出 `formatOutboundMessages`、`collectOutboundImageSizes`、`probeImageSize`、`imageSizeFromBytes`。
9
+ - Updated dependencies [1a11207]
10
+ - Updated dependencies [9943df6]
11
+ - Updated dependencies [5be09ae]
12
+ - Updated dependencies [a3ab153]
13
+ - Updated dependencies [e8f7309]
14
+ - Updated dependencies [5afc18f]
15
+ - Updated dependencies [d3563d9]
16
+ - Updated dependencies [c618eb7]
17
+ - Updated dependencies [fbfefb7]
18
+ - @zhin.js/tool@1.1.8
19
+ - @zhin.js/core@1.1.47
20
+ - @zhin.js/adapter@1.1.21
21
+ - @zhin.js/feature-kit@1.1.7
22
+ - @zhin.js/im-contract@1.1.5
23
+ - @zhin.js/agent-feature@1.1.8
24
+ - @zhin.js/skill@1.1.8
25
+ - @zhin.js/host-http@1.1.8
26
+
27
+ ## 1.1.11
28
+
29
+ ### Patch Changes
30
+
31
+ - 9816c18: 将应用插件装配改为 configVersion: 2:主配置 plugins 映射实例名到包,disable_plugins 控制启停,业务配置使用顶层同名块。package.json 继续保留 features、entry、engine 等包契约,并支持静态 displayName/icon。
32
+
33
+ 这是配置格式的破坏性更新:旧配置不能继续运行,请先停止 Bot,运行 `zhin migrate instances` 预览,再使用 `--apply` 转换;提供备份与中断继续/回滚,无法无损转换的嵌套、optional 和别名碰撞需人工迁移。Runtime、Console、CLI 与创建向导统一使用新声明。
34
+
35
+ 保留框架顶层 `menuKeyword` / `menuStyle`,通过当前 generation 的 Root 配置视图供 Command Feature 消费;严格校验和配置修改不会丢失菜单设置,子实例配置保持隔离。
36
+
37
+ - Updated dependencies [c407bd2]
38
+ - Updated dependencies [064b06d]
39
+ - Updated dependencies [2b4d6dc]
40
+ - Updated dependencies [a50fc8a]
41
+ - Updated dependencies [b58548a]
42
+ - Updated dependencies [e914a93]
43
+ - Updated dependencies [e914a93]
44
+ - Updated dependencies [064b06d]
45
+ - @zhin.js/core@1.1.46
46
+ - @zhin.js/feature-kit@1.1.6
47
+ - @zhin.js/host-http@1.1.7
48
+ - @zhin.js/permission@1.1.7
49
+ - @zhin.js/adapter@1.1.20
50
+ - @zhin.js/agent-feature@1.1.7
51
+ - @zhin.js/skill@1.1.7
52
+ - @zhin.js/tool@1.1.7
53
+
3
54
  ## 1.1.10
4
55
 
5
56
  ### Patch Changes
package/README.md CHANGED
@@ -24,7 +24,7 @@ pnpm add @zhin.js/adapter-qq
24
24
  - `@zhin.js/adapter` — 约定式 `adapters/qq/index.ts`(`defineAdapter`)
25
25
  - `@zhin.js/core` — `Endpoint.emit(...)` 入站、`outboundMessageToken` 出站
26
26
  - `zhin.js` — `plugin.ts`(`definePlugin`)
27
- - 配置经插件 `schema.json` 落到 `plugins.<instanceKey>`
27
+ - 配置经插件 `schema.json` 落到顶层 `<instanceKey>`
28
28
  - **无需** `@zhin.js/host-http` / `@zhin.js/host-router`(WebSocket 路径)
29
29
 
30
30
  入站:`gateway.receive({ conversation, message, content, sender, metadata })`(ConversationRef 结构化寻址)
@@ -43,15 +43,17 @@ pnpm add @zhin.js/adapter-qq
43
43
  ## 最小配置
44
44
 
45
45
  ```yaml
46
+ configVersion: 2
46
47
  # zhin.config.yml(Plugin Runtime)
47
48
  plugins:
48
- qq:
49
- # mode: websocket # 默认
50
- endpoints:
51
- - id: my-qq-bot
52
- appid: ${QQ_APPID}
53
- secret: ${QQ_SECRET}
54
- # botKind / intents 可由向导写入;`qq endpoint add` 扫码后会追问公域/私域再写入
49
+ qq: "@zhin.js/adapter-qq"
50
+ qq:
51
+ # mode: websocket # 默认
52
+ endpoints:
53
+ - id: my-qq-bot
54
+ appid: ${QQ_APPID}
55
+ secret: ${QQ_SECRET}
56
+ # botKind / intents 可由向导写入;`qq endpoint add` 扫码后会追问公域/私域再写入
55
57
  ```
56
58
 
57
59
  ### botKind 与 intents
@@ -70,35 +72,37 @@ WebSocket Identify 的 `intents` **必须与开放平台已开通的权限一致
70
72
  多账号:一个插件实例挂多个 endpoint(`endpoints` 数组逐项覆盖顶层字段,`id` 必填):
71
73
 
72
74
  ```yaml
75
+ configVersion: 2
73
76
  plugins:
74
- qq:
75
- mode: websocket
76
- endpoints:
77
- - id: main-bot
78
- appid: ${QQ_APPID}
79
- secret: ${QQ_SECRET}
80
- botKind: public
81
- intents:
82
- - GROUP_AND_C2C_EVENT
83
- - GROUP_MEMBER
84
- - GUILDS
85
- - GUILD_MEMBERS
86
- - DIRECT_MESSAGE
87
- - PUBLIC_GUILD_MESSAGES
88
- - id: private-bot
89
- appid: ${QQ_APPID_2}
90
- secret: ${QQ_SECRET_2}
91
- botKind: private
92
- intents:
93
- - GROUP_AND_C2C_EVENT
94
- - GROUP_MEMBER
95
- - GUILDS
96
- - GUILD_MEMBERS
97
- - DIRECT_MESSAGE
98
- - GUILD_MESSAGES
77
+ qq: "@zhin.js/adapter-qq"
78
+ qq:
79
+ mode: websocket
80
+ endpoints:
81
+ - id: main-bot
82
+ appid: ${QQ_APPID}
83
+ secret: ${QQ_SECRET}
84
+ botKind: public
85
+ intents:
86
+ - GROUP_AND_C2C_EVENT
87
+ - GROUP_MEMBER
88
+ - GUILDS
89
+ - GUILD_MEMBERS
90
+ - DIRECT_MESSAGE
91
+ - PUBLIC_GUILD_MESSAGES
92
+ - id: private-bot
93
+ appid: ${QQ_APPID_2}
94
+ secret: ${QQ_SECRET_2}
95
+ botKind: private
96
+ intents:
97
+ - GROUP_AND_C2C_EVENT
98
+ - GROUP_MEMBER
99
+ - GUILDS
100
+ - GUILD_MEMBERS
101
+ - DIRECT_MESSAGE
102
+ - GUILD_MESSAGES
99
103
  ```
100
104
 
101
- 根插件 `zhin.plugins`(或项目图)需引用 `@zhin.js/adapter-qq`(`instanceKey: qq`)。
105
+ 主配置 `plugins` 映射需引用 `@zhin.js/adapter-qq`(`instanceKey: qq`)。
102
106
 
103
107
  ## Endpoint 管理命令
104
108
 
@@ -106,7 +110,7 @@ plugins:
106
110
 
107
111
  | 命令 | 说明 |
108
112
  |------|------|
109
- | `qq endpoint add [name]` | 手机 QQ 扫码绑定 → **追问公域/私域** → 一次性写入 `.env` + `plugins.qq.endpoints`(重启生效) |
113
+ | `qq endpoint add [name]` | 手机 QQ 扫码绑定 → **追问公域/私域** → 一次性写入 `.env` + `qq.endpoints`(重启生效) |
110
114
  | `qq endpoint cancel` | 取消进行中的扫码绑定或待确认的公域/私域选择 |
111
115
  | `qq endpoint list` | 列出运行中与配置中的 endpoints |
112
116
  | `qq endpoint remove <name>` | 从配置移除 endpoint(`.env` 键保留,可手动清理) |
package/lib/endpoint.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { installQqUploadDiagnostics } from './upload-diagnostics.js';
2
+ import { projectQqGuildSenderRoles } from './platform-permit.js';
2
3
  import { qqDeliveryFailure } from './delivery-error.js';
3
4
  import { normalizeQqInteraction } from './interaction.js';
4
5
  import { awaitQqStartup } from './start-deadline.js';
@@ -8,7 +9,7 @@ import { EndpointDeliveryError } from '@zhin.js/im-contract';
8
9
  */
9
10
  import { Endpoint, createEndpointLifecycle, createRecallEndpointControl, } from 'zhin.js/adapter';
10
11
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
11
- import { formatOutbound } from './outbound.js';
12
+ import { collectOutboundImageSizes, formatOutboundMessages } from './outbound.js';
12
13
  import { formatInboundContent, parseCompoundMessageId, qqInboundConversation, qqOutboundKind, resolveOutboundMessageId, senderDisplayName, } from './protocol.js';
13
14
  import { defaultCreateHttpBot, registerQqWebhookRoutes, } from './webhook.js';
14
15
  import { bindQqBotInboundEvents, defaultCreateBot, normalizeQqMessage, } from './ws.js';
@@ -115,26 +116,31 @@ export class QqWebsocketEndpoint extends Endpoint {
115
116
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
116
117
  }
117
118
  async send({ conversation, payload }) {
118
- const body = formatOutbound(payload);
119
+ // 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
120
+ // 拿不到 URL/尺寸的媒体降级为独立消息先发
121
+ const messages = formatOutboundMessages(payload, { imageSizes: await collectOutboundImageSizes(payload) });
122
+ const body = messages[messages.length - 1];
119
123
  const kind = qqOutboundKind(conversation);
120
124
  const bot = this.#requireBot();
121
125
  let result;
122
126
  try {
123
- switch (kind) {
124
- case 'private':
125
- result = await bot.sendPrivateMessage(conversation.id, body);
126
- break;
127
- case 'group':
128
- result = await bot.sendGroupMessage(conversation.id, body);
129
- break;
130
- case 'channel':
131
- result = await bot.sendGuildMessage(conversation.id, body);
132
- break;
133
- case 'direct':
134
- if (!bot.sendDirectMessage)
135
- throw new Error('QQ direct message not supported by transport');
136
- result = await bot.sendDirectMessage(conversation.id, body);
137
- break;
127
+ for (const message of messages) {
128
+ switch (kind) {
129
+ case 'private':
130
+ result = await bot.sendPrivateMessage(conversation.id, message);
131
+ break;
132
+ case 'group':
133
+ result = await bot.sendGroupMessage(conversation.id, message);
134
+ break;
135
+ case 'channel':
136
+ result = await bot.sendGuildMessage(conversation.id, message);
137
+ break;
138
+ case 'direct':
139
+ if (!bot.sendDirectMessage)
140
+ throw new Error('QQ direct message not supported by transport');
141
+ result = await bot.sendDirectMessage(conversation.id, message);
142
+ break;
143
+ }
138
144
  }
139
145
  }
140
146
  catch (error) {
@@ -169,6 +175,7 @@ export class QqWebsocketEndpoint extends Endpoint {
169
175
  + (msg.authorId ? ` from ${msg.authorId}` : '')
170
176
  + (msg.mentioned ? ' (mentioned)' : '')
171
177
  + ` | ${truncatePreview(content, 80)}`);
178
+ const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
172
179
  void this.emit('message.receive', {
173
180
  conversation,
174
181
  message: { conversation, id: msg.id },
@@ -177,7 +184,8 @@ export class QqWebsocketEndpoint extends Endpoint {
177
184
  sender: {
178
185
  id: msg.authorId,
179
186
  name: senderDisplayName(msg) || undefined,
180
- ...(msg.authorRoles?.length ? { roles: msg.authorRoles } : {}),
187
+ ...(roleProjection.role ? { roles: [roleProjection.role] } : {}),
188
+ ...(roleProjection.permissions.length ? { permissions: roleProjection.permissions } : {}),
181
189
  },
182
190
  endpointId: this.#options.config.id,
183
191
  ...(msg.mentioned ? { mentioned: true } : {}),
@@ -185,7 +193,8 @@ export class QqWebsocketEndpoint extends Endpoint {
185
193
  channelKind: msg.channelKind,
186
194
  userId: msg.authorId,
187
195
  guildId: msg.guildId,
188
- roles: msg.authorRoles,
196
+ authorRoleIds: msg.authorRoles,
197
+ ...(roleProjection.role ? { senderRole: roleProjection.role } : {}),
189
198
  }),
190
199
  }).catch((err) => {
191
200
  this.#logger.warn(formatCompact({
@@ -362,26 +371,31 @@ export class QqHttpEndpoint extends Endpoint {
362
371
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
363
372
  }
364
373
  async send({ conversation, payload }) {
365
- const body = formatOutbound(payload);
374
+ // 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
375
+ // 拿不到 URL/尺寸的媒体降级为独立消息先发
376
+ const messages = formatOutboundMessages(payload, { imageSizes: await collectOutboundImageSizes(payload) });
377
+ const body = messages[messages.length - 1];
366
378
  const kind = qqOutboundKind(conversation);
367
379
  const bot = this.#requireBot();
368
380
  let result;
369
381
  try {
370
- switch (kind) {
371
- case 'private':
372
- result = await bot.sendPrivateMessage(conversation.id, body);
373
- break;
374
- case 'group':
375
- result = await bot.sendGroupMessage(conversation.id, body);
376
- break;
377
- case 'channel':
378
- result = await bot.sendGuildMessage(conversation.id, body);
379
- break;
380
- case 'direct':
381
- if (!bot.sendDirectMessage)
382
- throw new Error('QQ direct message not supported by transport');
383
- result = await bot.sendDirectMessage(conversation.id, body);
384
- break;
382
+ for (const message of messages) {
383
+ switch (kind) {
384
+ case 'private':
385
+ result = await bot.sendPrivateMessage(conversation.id, message);
386
+ break;
387
+ case 'group':
388
+ result = await bot.sendGroupMessage(conversation.id, message);
389
+ break;
390
+ case 'channel':
391
+ result = await bot.sendGuildMessage(conversation.id, message);
392
+ break;
393
+ case 'direct':
394
+ if (!bot.sendDirectMessage)
395
+ throw new Error('QQ direct message not supported by transport');
396
+ result = await bot.sendDirectMessage(conversation.id, message);
397
+ break;
398
+ }
385
399
  }
386
400
  }
387
401
  catch (error) {
@@ -415,6 +429,7 @@ export class QqHttpEndpoint extends Endpoint {
415
429
  + (msg.authorId ? ` from ${msg.authorId}` : '')
416
430
  + (msg.mentioned ? ' (mentioned)' : '')
417
431
  + ` | ${truncatePreview(content, 80)}`);
432
+ const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
418
433
  void this.emit('message.receive', {
419
434
  conversation,
420
435
  message: { conversation, id: msg.id },
@@ -423,7 +438,8 @@ export class QqHttpEndpoint extends Endpoint {
423
438
  sender: {
424
439
  id: msg.authorId,
425
440
  name: senderDisplayName(msg) || undefined,
426
- ...(msg.authorRoles?.length ? { roles: msg.authorRoles } : {}),
441
+ ...(roleProjection.role ? { roles: [roleProjection.role] } : {}),
442
+ ...(roleProjection.permissions.length ? { permissions: roleProjection.permissions } : {}),
427
443
  },
428
444
  endpointId: this.#options.config.id,
429
445
  ...(msg.mentioned ? { mentioned: true } : {}),
@@ -431,7 +447,8 @@ export class QqHttpEndpoint extends Endpoint {
431
447
  channelKind: msg.channelKind,
432
448
  userId: msg.authorId,
433
449
  guildId: msg.guildId,
434
- roles: msg.authorRoles,
450
+ authorRoleIds: msg.authorRoles,
451
+ ...(roleProjection.role ? { senderRole: roleProjection.role } : {}),
435
452
  }),
436
453
  }).catch((err) => {
437
454
  this.#logger.warn(formatCompact({
@@ -0,0 +1,20 @@
1
+ /**
2
+ * QQ markdown 图片语法 `![alt #宽px #高px](url)` 需要图片尺寸:对公网 URL 图片只读取文件头
3
+ * (≤64 KiB)解析 PNG / JPEG / GIF / WebP 尺寸。探测失败返回 undefined,由调用方降级。
4
+ */
5
+ export interface QqImageSize {
6
+ readonly width: number;
7
+ readonly height: number;
8
+ }
9
+ export type ImageProbeFetch = (url: string, init: {
10
+ headers: Record<string, string>;
11
+ signal: AbortSignal;
12
+ }) => Promise<{
13
+ readonly ok: boolean;
14
+ readonly body?: ReadableStream<Uint8Array> | null;
15
+ arrayBuffer(): Promise<ArrayBuffer>;
16
+ }>;
17
+ /** 从图片文件头解析尺寸(PNG / GIF / JPEG / WebP)。 */
18
+ export declare function imageSizeFromBytes(bytes: Uint8Array): QqImageSize | undefined;
19
+ /** 读取公网图片文件头得到尺寸;网络错误、超时或格式不识别时返回 undefined。 */
20
+ export declare function probeImageSize(url: string, fetchImpl?: ImageProbeFetch, timeoutMs?: number): Promise<QqImageSize | undefined>;
@@ -0,0 +1,99 @@
1
+ const PROBE_BYTES = 64 * 1024;
2
+ const PROBE_TIMEOUT_MS = 3000;
3
+ /** 从图片文件头解析尺寸(PNG / GIF / JPEG / WebP)。 */
4
+ export function imageSizeFromBytes(bytes) {
5
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
6
+ const size = (width, height) => width > 0 && height > 0 ? { width, height } : undefined;
7
+ const ascii = (offset, length) => offset + length <= bytes.length ? String.fromCharCode(...bytes.subarray(offset, offset + length)) : '';
8
+ // PNG: 8 字节签名 + IHDR
9
+ if (bytes.length >= 24 && bytes[0] === 0x89 && ascii(1, 3) === 'PNG' && ascii(12, 4) === 'IHDR') {
10
+ return size(view.getUint32(16), view.getUint32(20));
11
+ }
12
+ // GIF87a / GIF89a
13
+ if (bytes.length >= 10 && (ascii(0, 6) === 'GIF87a' || ascii(0, 6) === 'GIF89a')) {
14
+ return size(view.getUint16(6, true), view.getUint16(8, true));
15
+ }
16
+ // WebP: RIFF....WEBP + VP8 / VP8L / VP8X
17
+ if (bytes.length >= 30 && ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
18
+ const chunk = ascii(12, 4);
19
+ if (chunk === 'VP8X') {
20
+ const w = 1 + (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16));
21
+ const h = 1 + (bytes[27] | (bytes[28] << 8) | (bytes[29] << 16));
22
+ return size(w, h);
23
+ }
24
+ if (chunk === 'VP8L' && bytes[20] === 0x2f) {
25
+ const b = view.getUint32(21, true);
26
+ return size((b & 0x3fff) + 1, ((b >>> 14) & 0x3fff) + 1);
27
+ }
28
+ if (chunk === 'VP8 ')
29
+ return size(view.getUint16(26, true) & 0x3fff, view.getUint16(28, true) & 0x3fff);
30
+ return undefined;
31
+ }
32
+ // JPEG: 扫描 SOFn 段
33
+ if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
34
+ let offset = 2;
35
+ while (offset + 9 <= bytes.length) {
36
+ if (bytes[offset] !== 0xff)
37
+ return undefined;
38
+ const marker = bytes[offset + 1];
39
+ if (marker === 0xff) {
40
+ offset += 1;
41
+ continue;
42
+ }
43
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
44
+ offset += 2;
45
+ continue;
46
+ }
47
+ const length = view.getUint16(offset + 2);
48
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
49
+ return size(view.getUint16(offset + 7), view.getUint16(offset + 5));
50
+ }
51
+ offset += 2 + length;
52
+ }
53
+ }
54
+ return undefined;
55
+ }
56
+ async function readHead(response) {
57
+ if (!response.body)
58
+ return new Uint8Array(await response.arrayBuffer()).subarray(0, PROBE_BYTES);
59
+ const reader = response.body.getReader();
60
+ const chunks = [];
61
+ let total = 0;
62
+ try {
63
+ while (total < PROBE_BYTES) {
64
+ const { done, value } = await reader.read();
65
+ if (done || !value)
66
+ break;
67
+ chunks.push(value);
68
+ total += value.length;
69
+ }
70
+ }
71
+ finally {
72
+ await reader.cancel().catch(() => undefined);
73
+ }
74
+ const out = new Uint8Array(Math.min(total, PROBE_BYTES));
75
+ let offset = 0;
76
+ for (const chunk of chunks) {
77
+ const take = Math.min(chunk.length, out.length - offset);
78
+ out.set(chunk.subarray(0, take), offset);
79
+ offset += take;
80
+ if (offset >= out.length)
81
+ break;
82
+ }
83
+ return out;
84
+ }
85
+ /** 读取公网图片文件头得到尺寸;网络错误、超时或格式不识别时返回 undefined。 */
86
+ export async function probeImageSize(url, fetchImpl = fetch, timeoutMs = PROBE_TIMEOUT_MS) {
87
+ try {
88
+ const response = await fetchImpl(url, {
89
+ headers: { Range: `bytes=0-${PROBE_BYTES - 1}` },
90
+ signal: AbortSignal.timeout(timeoutMs),
91
+ });
92
+ if (!response.ok)
93
+ return undefined;
94
+ return imageSizeFromBytes(await readHead(response));
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ }
package/lib/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { formatInboundContent, formatOutboundText, resolveOutboundMessageId, resolveQqConfig, senderDisplayName, type QqEndpointConfig, type QqChannelKind, type QqInboundMessage, type QqWireSegment, type ResolvedQqConfig, type ResolvedQqHttpConfig, type ResolvedQqWebsocketConfig, } from './protocol.js';
2
2
  export { DEFAULT_QQ_BOT_KIND, QQ_INTENTS_BY_KIND, defaultQqEndpointIntentFields, parseQqBotKind, resolveQqIntents, type QqBotKind, type QqIntent, } from './qq-intents.js';
3
- export { formatOutbound, resolveMediaFile, type QqOutboundElem, type QqOutboundMessage, } from './outbound.js';
3
+ export { formatOutbound, formatOutboundMessages, collectOutboundImageSizes, type FormatOutboundMessagesOptions, resolveMediaFile, type QqOutboundElem, type QqOutboundMessage, } from './outbound.js';
4
+ export { imageSizeFromBytes, probeImageSize, type QqImageSize } from './image-size.js';
4
5
  export { QqHttpEndpoint, QqWebsocketEndpoint, type CreateQqBot, type CreateQqHttpBot, type QqBotTransport, type QqOfficialApi, type QqEndpointOptions, type QqHttpBotTransport, type QqHttpEndpointOptions, } from './endpoint.js';
5
6
  export { registerQqWebhookRoutes, handleQqWebhookRequest, readRequestBodyText, defaultCreateHttpBot, type QqWebhookHandler, } from './webhook.js';
6
7
  export { bindQqBotInboundEvents, defaultCreateBot, normalizeQqMessage, } from './ws.js';
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { formatInboundContent, formatOutboundText, resolveOutboundMessageId, resolveQqConfig, senderDisplayName, } from './protocol.js';
2
2
  export { DEFAULT_QQ_BOT_KIND, QQ_INTENTS_BY_KIND, defaultQqEndpointIntentFields, parseQqBotKind, resolveQqIntents, } from './qq-intents.js';
3
- export { formatOutbound, resolveMediaFile, } from './outbound.js';
3
+ export { formatOutbound, formatOutboundMessages, collectOutboundImageSizes, resolveMediaFile, } from './outbound.js';
4
+ export { imageSizeFromBytes, probeImageSize } from './image-size.js';
4
5
  export { QqHttpEndpoint, QqWebsocketEndpoint, } from './endpoint.js';
5
6
  export { registerQqWebhookRoutes, handleQqWebhookRequest, readRequestBodyText, defaultCreateHttpBot, } from './webhook.js';
6
7
  export { bindQqBotInboundEvents, defaultCreateBot, normalizeQqMessage, } from './ws.js';
package/lib/outbound.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type QqImageSize } from './image-size.js';
1
2
  /** qq-official-bot Sendable 的最小结构子集(字符串模板或消息段)。 */
2
3
  export interface QqOutboundElem {
3
4
  readonly type: string;
@@ -15,3 +16,25 @@ export declare function resolveMediaFile(data: Record<string, unknown>): string
15
16
  * 纯文本输入原样返回(与既有行为一致);含富媒体段时返回段数组。
16
17
  */
17
18
  export declare function formatOutbound(payload: unknown): QqOutboundMessage;
19
+ export interface FormatOutboundMessagesOptions {
20
+ /** 公网图片 URL → 尺寸;有尺寸的图片才能嵌入 markdown(见 collectOutboundImageSizes)。 */
21
+ readonly imageSizes?: ReadonlyMap<string, QqImageSize>;
22
+ }
23
+ /**
24
+ * 渲染后 payload → 按序发送的 QQ Sendable 列表。
25
+ *
26
+ * QQ 单条消息的 msg_type 只能是一种:富媒体(7,media.file_info)与 markdown/按钮(2)
27
+ * 互斥,SDK 后处理的段会覆盖 msg_type,导致图片被静默丢弃。处理顺序:
28
+ * 1. 公网 URL 图片且已知尺寸:按 QQ markdown 语法 `![图片 #宽px #高px](url)` 嵌入同一
29
+ * markdown 段(开放平台会下载转存),仍为一条消息;
30
+ * 2. 其余媒体(base64 / 本地渲染图:v2 /files 上传只返回 file_info,拿不到 URL;或尺寸
31
+ * 未知、模板 markdown 无法拼接)降级为独立富媒体消息先发,markdown + 按钮作为最后一条
32
+ * (返回其消息 ID)。reply 段随每条消息携带,保持被动回复。
33
+ * 不含媒体与 markdown/按钮冲突时与 formatOutbound 一致(单条)。
34
+ */
35
+ export declare function formatOutboundMessages(payload: unknown, options?: FormatOutboundMessagesOptions): QqOutboundMessage[];
36
+ /**
37
+ * 预先探测需要嵌入 markdown 的公网图片尺寸:仅当 payload 同时含图片与 markdown/按钮时,
38
+ * 对 http(s) URL 图片并发读取文件头;失败的图片不入表,formatOutboundMessages 会降级。
39
+ */
40
+ export declare function collectOutboundImageSizes(payload: unknown, probe?: (url: string) => Promise<QqImageSize | undefined>): Promise<Map<string, QqImageSize>>;