@zhin.js/adapter-qq 1.1.11 → 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 +24 -0
- package/lib/endpoint.js +54 -37
- package/lib/image-size.d.ts +20 -0
- package/lib/image-size.js +99 -0
- package/lib/index.d.ts +2 -1
- package/lib/index.js +2 -1
- package/lib/outbound.d.ts +23 -0
- package/lib/outbound.js +142 -11
- package/lib/platform-permit.d.ts +5 -0
- package/lib/platform-permit.js +17 -0
- package/package.json +17 -17
- package/src/endpoint.ts +52 -35
- package/src/image-size.ts +110 -0
- package/src/index.ts +5 -0
- package/src/outbound.ts +143 -10
- package/src/platform-permit.ts +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
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 图片按 `` 嵌入 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
|
+
|
|
3
27
|
## 1.1.11
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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 图片语法 `` 需要图片尺寸:对公网 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 语法 `` 嵌入同一
|
|
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>>;
|
package/lib/outbound.js
CHANGED
|
@@ -8,13 +8,16 @@
|
|
|
8
8
|
* 归一到 data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
|
|
9
9
|
* - markdown:data.content 原文,或 custom_template_id + params 模板。
|
|
10
10
|
* - keyboard:data.rows 按钮矩阵展开为 button 段(SDK processButtons 组装
|
|
11
|
-
* keyboard 载荷);按钮消息必须带 markdown
|
|
11
|
+
* keyboard 载荷);按钮消息必须带 markdown,且一条消息只承载一个 markdown、
|
|
12
|
+
* msg_type=2 时 content 不展示,故含按钮或内联 markdown 时文本与内联 markdown
|
|
13
|
+
* 段按顺序合并为同一 markdown 段(模板 markdown 原样透传)。
|
|
12
14
|
* data.id 模板键盘原样透传。
|
|
13
15
|
* canonical share 当前明确 not_sent/unsupported,避免静默丢标题/描述/URL。
|
|
14
16
|
* 其他未识别段保持既有 data.text fallback;正常发送另受声明的 supported 白名单约束。
|
|
15
17
|
*/
|
|
16
18
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
17
19
|
import { EndpointDeliveryError, isMediaRef } from '@zhin.js/im-contract';
|
|
20
|
+
import { probeImageSize } from './image-size.js';
|
|
18
21
|
const logger = getLogger('qq');
|
|
19
22
|
/**
|
|
20
23
|
* 解析 image/audio/video/file 段的投递源(canonical MediaRef 唯一来源):
|
|
@@ -57,13 +60,16 @@ function normalizeMediaSegment(seg) {
|
|
|
57
60
|
}
|
|
58
61
|
return { type: seg.type, data: { file } };
|
|
59
62
|
}
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
const templateId = typeof data.custom_template_id === 'string' && data.custom_template_id
|
|
63
|
+
function markdownTemplateId(data) {
|
|
64
|
+
return typeof data.custom_template_id === 'string' && data.custom_template_id
|
|
63
65
|
? data.custom_template_id
|
|
64
66
|
: typeof data.template_id === 'string' && data.template_id
|
|
65
67
|
? data.template_id
|
|
66
68
|
: undefined;
|
|
69
|
+
}
|
|
70
|
+
function normalizeMarkdownSegment(seg) {
|
|
71
|
+
const data = seg.data ?? {};
|
|
72
|
+
const templateId = markdownTemplateId(data);
|
|
67
73
|
if (templateId) {
|
|
68
74
|
return {
|
|
69
75
|
type: 'markdown',
|
|
@@ -155,16 +161,38 @@ export function formatOutbound(payload) {
|
|
|
155
161
|
? JSON.stringify(payload)
|
|
156
162
|
: String(payload);
|
|
157
163
|
}
|
|
158
|
-
// QQ
|
|
164
|
+
// 一条 QQ 消息只有一个 markdown 字段(SDK handleMarkdown 后者覆盖前者),且 msg_type=2
|
|
165
|
+
// 时 content 文本不展示。按钮消息必须携带 markdown;含内联 markdown 的消息同理:
|
|
166
|
+
// 文本与内联 markdown 按顺序合并为同一 markdown 段(落在首次出现的位置),避免正文被丢弃。
|
|
167
|
+
// 模板 markdown(custom_template_id)无法拼接,保持原样。
|
|
159
168
|
const hasKeyboard = segments.some((item) => typeof item !== 'string' && item.type === 'keyboard');
|
|
169
|
+
const hasInlineMarkdown = segments.some((item) => typeof item !== 'string' && item.type === 'markdown' && !markdownTemplateId(item.data ?? {}));
|
|
170
|
+
const mergeIntoMarkdown = hasKeyboard || hasInlineMarkdown;
|
|
160
171
|
const parts = [];
|
|
172
|
+
let markdownSlot;
|
|
173
|
+
let lastPieceWasMarkdown = false;
|
|
174
|
+
const appendMarkdown = (piece, isMarkdown) => {
|
|
175
|
+
if (!piece)
|
|
176
|
+
return;
|
|
177
|
+
if (!markdownSlot) {
|
|
178
|
+
markdownSlot = { type: 'markdown', data: { content: '' } };
|
|
179
|
+
parts.push(markdownSlot);
|
|
180
|
+
}
|
|
181
|
+
const current = markdownSlot.data.content;
|
|
182
|
+
const needsBreak = current.trim() !== '' && (isMarkdown || lastPieceWasMarkdown);
|
|
183
|
+
markdownSlot.data.content = needsBreak
|
|
184
|
+
? `${current.replace(/\s+$/, '')}\n\n${piece.replace(/^\s+/, '')}`
|
|
185
|
+
: current + piece;
|
|
186
|
+
lastPieceWasMarkdown = isMarkdown;
|
|
187
|
+
};
|
|
161
188
|
let textBuf = '';
|
|
162
189
|
const flushText = () => {
|
|
163
190
|
if (!textBuf)
|
|
164
191
|
return;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
192
|
+
if (mergeIntoMarkdown)
|
|
193
|
+
appendMarkdown(textBuf, false);
|
|
194
|
+
else
|
|
195
|
+
parts.push(textBuf);
|
|
168
196
|
textBuf = '';
|
|
169
197
|
};
|
|
170
198
|
for (const item of segments) {
|
|
@@ -198,10 +226,15 @@ export function formatOutbound(payload) {
|
|
|
198
226
|
break;
|
|
199
227
|
}
|
|
200
228
|
case 'markdown': {
|
|
201
|
-
flushText();
|
|
202
229
|
const markdown = normalizeMarkdownSegment(item);
|
|
203
|
-
if (markdown)
|
|
204
|
-
|
|
230
|
+
if (!markdown)
|
|
231
|
+
break;
|
|
232
|
+
flushText();
|
|
233
|
+
if (mergeIntoMarkdown && typeof markdown.data.content === 'string') {
|
|
234
|
+
appendMarkdown(markdown.data.content, true);
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
parts.push(markdown);
|
|
205
238
|
break;
|
|
206
239
|
}
|
|
207
240
|
case 'keyboard': {
|
|
@@ -229,9 +262,107 @@ export function formatOutbound(payload) {
|
|
|
229
262
|
}
|
|
230
263
|
}
|
|
231
264
|
flushText();
|
|
265
|
+
if (markdownSlot && !markdownSlot.data.content.trim())
|
|
266
|
+
parts.splice(parts.indexOf(markdownSlot), 1);
|
|
232
267
|
if (parts.length === 0)
|
|
233
268
|
return '';
|
|
234
269
|
if (parts.length === 1)
|
|
235
270
|
return parts[0];
|
|
236
271
|
return parts;
|
|
237
272
|
}
|
|
273
|
+
/** SDK 能作为 msg_type=7 富媒体发送的段 */
|
|
274
|
+
const SPLIT_MEDIA_TYPES = new Set(['image', 'audio', 'video']);
|
|
275
|
+
/** 走 msg_type=2 的 markdown/按钮段 */
|
|
276
|
+
const MARKDOWN_MESSAGE_TYPES = new Set(['markdown', 'keyboard', 'button']);
|
|
277
|
+
function isElemOf(types) {
|
|
278
|
+
return (part) => typeof part !== 'string' && types.has(part.type);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* 渲染后 payload → 按序发送的 QQ Sendable 列表。
|
|
282
|
+
*
|
|
283
|
+
* QQ 单条消息的 msg_type 只能是一种:富媒体(7,media.file_info)与 markdown/按钮(2)
|
|
284
|
+
* 互斥,SDK 后处理的段会覆盖 msg_type,导致图片被静默丢弃。处理顺序:
|
|
285
|
+
* 1. 公网 URL 图片且已知尺寸:按 QQ markdown 语法 `` 嵌入同一
|
|
286
|
+
* markdown 段(开放平台会下载转存),仍为一条消息;
|
|
287
|
+
* 2. 其余媒体(base64 / 本地渲染图:v2 /files 上传只返回 file_info,拿不到 URL;或尺寸
|
|
288
|
+
* 未知、模板 markdown 无法拼接)降级为独立富媒体消息先发,markdown + 按钮作为最后一条
|
|
289
|
+
* (返回其消息 ID)。reply 段随每条消息携带,保持被动回复。
|
|
290
|
+
* 不含媒体与 markdown/按钮冲突时与 formatOutbound 一致(单条)。
|
|
291
|
+
*/
|
|
292
|
+
export function formatOutboundMessages(payload, options = {}) {
|
|
293
|
+
const body = formatOutbound(payload);
|
|
294
|
+
if (!Array.isArray(body))
|
|
295
|
+
return [body];
|
|
296
|
+
const elems = body;
|
|
297
|
+
const isMedia = isElemOf(SPLIT_MEDIA_TYPES);
|
|
298
|
+
if (!elems.some(isMedia) || !elems.some(isElemOf(MARKDOWN_MESSAGE_TYPES)))
|
|
299
|
+
return [body];
|
|
300
|
+
const markdownIndex = elems.findIndex(isElemOf(new Set(['markdown'])));
|
|
301
|
+
const markdownElem = markdownIndex >= 0 ? elems[markdownIndex] : undefined;
|
|
302
|
+
const markdownContent = markdownElem ? markdownElem.data.content : '';
|
|
303
|
+
const embedded = new Set();
|
|
304
|
+
let merged;
|
|
305
|
+
if (typeof markdownContent === 'string') {
|
|
306
|
+
const before = [];
|
|
307
|
+
const after = [];
|
|
308
|
+
elems.forEach((part, index) => {
|
|
309
|
+
if (!isMedia(part) || part.type !== 'image' || typeof part.data.url !== 'string')
|
|
310
|
+
return;
|
|
311
|
+
const size = options.imageSizes?.get(part.data.url);
|
|
312
|
+
if (!size)
|
|
313
|
+
return;
|
|
314
|
+
(markdownIndex < 0 || index < markdownIndex ? before : after)
|
|
315
|
+
.push(``);
|
|
316
|
+
embedded.add(part);
|
|
317
|
+
});
|
|
318
|
+
if (embedded.size > 0) {
|
|
319
|
+
merged = { type: 'markdown', data: { content: [...before, markdownContent, ...after].filter((piece) => piece.trim()).join('\n\n') } };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const replies = elems.filter(isElemOf(new Set(['reply'])));
|
|
323
|
+
const separate = [];
|
|
324
|
+
const rest = [];
|
|
325
|
+
let mergedPlaced = false;
|
|
326
|
+
for (const part of elems) {
|
|
327
|
+
if (merged && (part === markdownElem || (!markdownElem && embedded.has(part)))) {
|
|
328
|
+
if (!mergedPlaced)
|
|
329
|
+
rest.push(merged);
|
|
330
|
+
mergedPlaced = true;
|
|
331
|
+
}
|
|
332
|
+
else if (embedded.has(part)) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
else if (isMedia(part)) {
|
|
336
|
+
separate.push(part);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
rest.push(part);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return [
|
|
343
|
+
...separate.map((item) => (replies.length ? [...replies, item] : item)),
|
|
344
|
+
rest.length === 1 ? rest[0] : rest,
|
|
345
|
+
];
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* 预先探测需要嵌入 markdown 的公网图片尺寸:仅当 payload 同时含图片与 markdown/按钮时,
|
|
349
|
+
* 对 http(s) URL 图片并发读取文件头;失败的图片不入表,formatOutboundMessages 会降级。
|
|
350
|
+
*/
|
|
351
|
+
export async function collectOutboundImageSizes(payload, probe = (url) => probeImageSize(url)) {
|
|
352
|
+
const sizes = new Map();
|
|
353
|
+
const segments = toSegmentList(payload).filter((item) => typeof item !== 'string');
|
|
354
|
+
if (!segments.some((item) => MARKDOWN_MESSAGE_TYPES.has(item.type)))
|
|
355
|
+
return sizes;
|
|
356
|
+
const urls = new Set();
|
|
357
|
+
for (const item of segments) {
|
|
358
|
+
const media = item.type === 'image' ? (item.data ?? {}).media : undefined;
|
|
359
|
+
if (isMediaRef(media) && media.kind === 'url' && /^https?:\/\//i.test(media.value))
|
|
360
|
+
urls.add(media.value);
|
|
361
|
+
}
|
|
362
|
+
await Promise.all([...urls].map(async (url) => {
|
|
363
|
+
const size = await probe(url);
|
|
364
|
+
if (size)
|
|
365
|
+
sizes.set(url, size);
|
|
366
|
+
}));
|
|
367
|
+
return sizes;
|
|
368
|
+
}
|
package/lib/platform-permit.d.ts
CHANGED
|
@@ -12,4 +12,9 @@ export declare function normalizeQqGuildSenderForPermit(input: {
|
|
|
12
12
|
role?: string;
|
|
13
13
|
permissions?: string[];
|
|
14
14
|
};
|
|
15
|
+
/** 入站角色投影:规范角色 owner / admin 进 roles,原始角色 id 进 permissions(`role:<id>`)。 */
|
|
16
|
+
export declare function projectQqGuildSenderRoles(authorRoles: readonly string[] | undefined): {
|
|
17
|
+
role?: 'owner' | 'admin';
|
|
18
|
+
permissions: string[];
|
|
19
|
+
};
|
|
15
20
|
export declare function checkQqPlatformPermit(perm: string, subject: PermissionSubject): boolean;
|
package/lib/platform-permit.js
CHANGED
|
@@ -22,6 +22,23 @@ export function normalizeQqGuildSenderForPermit(input) {
|
|
|
22
22
|
}
|
|
23
23
|
return { role: 'member', permissions };
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* QQ 官方频道事件 `member.roles` 中的内置身份 id:4 = 频道主,2 = 超级管理员。
|
|
27
|
+
* 事件已自带,无需额外 API;其余 id(含 5 子频道管理员)不视为 admin。
|
|
28
|
+
*/
|
|
29
|
+
const QQ_GUILD_OWNER_ROLE_ID = '4';
|
|
30
|
+
const QQ_GUILD_ADMIN_ROLE_ID = '2';
|
|
31
|
+
/** 入站角色投影:规范角色 owner / admin 进 roles,原始角色 id 进 permissions(`role:<id>`)。 */
|
|
32
|
+
export function projectQqGuildSenderRoles(authorRoles) {
|
|
33
|
+
const ids = authorRoles ?? [];
|
|
34
|
+
const projected = normalizeQqGuildSenderForPermit({
|
|
35
|
+
roles: ids.map((id) => `role:${id}`),
|
|
36
|
+
isOwner: ids.includes(QQ_GUILD_OWNER_ROLE_ID),
|
|
37
|
+
isAdmin: ids.includes(QQ_GUILD_ADMIN_ROLE_ID),
|
|
38
|
+
});
|
|
39
|
+
const role = projected.role === 'owner' || projected.role === 'admin' ? projected.role : undefined;
|
|
40
|
+
return { ...(role ? { role } : {}), permissions: projected.permissions ?? [] };
|
|
41
|
+
}
|
|
25
42
|
export function checkQqPlatformPermit(perm, subject) {
|
|
26
43
|
const role = subject.sender?.role?.[0];
|
|
27
44
|
const permissions = subject.sender?.permissions ?? [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-qq",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.12",
|
|
4
4
|
"description": "Zhin.js QQ Official adapter for Plugin Runtime (WebSocket gateway)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -34,24 +34,24 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"qq-official-bot": "^1.3.1",
|
|
37
|
-
"@zhin.js/adapter": "1.1.
|
|
38
|
-
"@zhin.js/agent-feature": "1.1.
|
|
39
|
-
"@zhin.js/core": "1.1.
|
|
40
|
-
"@zhin.js/feature-kit": "1.1.
|
|
41
|
-
"@zhin.js/host-http": "1.1.
|
|
42
|
-
"@zhin.js/im-contract": "1.1.
|
|
43
|
-
"@zhin.js/logger": "1.1.2",
|
|
37
|
+
"@zhin.js/adapter": "1.1.21",
|
|
38
|
+
"@zhin.js/agent-feature": "1.1.8",
|
|
39
|
+
"@zhin.js/core": "1.1.47",
|
|
40
|
+
"@zhin.js/feature-kit": "1.1.7",
|
|
41
|
+
"@zhin.js/host-http": "1.1.8",
|
|
42
|
+
"@zhin.js/im-contract": "1.1.5",
|
|
44
43
|
"@zhin.js/permission": "1.1.7",
|
|
45
|
-
"@zhin.js/skill": "1.1.
|
|
46
|
-
"@zhin.js/
|
|
44
|
+
"@zhin.js/skill": "1.1.8",
|
|
45
|
+
"@zhin.js/logger": "1.1.2",
|
|
46
|
+
"@zhin.js/tool": "1.1.8"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"zod": "^4.0.0",
|
|
50
|
-
"@zhin.js/adapter": "^1.1.
|
|
51
|
-
"@zhin.js/command": "^1.1.
|
|
52
|
-
"@zhin.js/core": "^1.1.
|
|
53
|
-
"@zhin.js/middleware": "^1.1.
|
|
54
|
-
"zhin.js": "^1.1.
|
|
50
|
+
"@zhin.js/adapter": "^1.1.21",
|
|
51
|
+
"@zhin.js/command": "^1.1.9",
|
|
52
|
+
"@zhin.js/core": "^1.1.47",
|
|
53
|
+
"@zhin.js/middleware": "^1.1.8",
|
|
54
|
+
"zhin.js": "^1.1.12"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@zhin.js/command": {
|
|
@@ -72,8 +72,8 @@
|
|
|
72
72
|
"typescript": "^6.0.3",
|
|
73
73
|
"vitest": "^4.1.10",
|
|
74
74
|
"zod": "^4.4.3",
|
|
75
|
-
"@zhin.js/host-http": "1.1.
|
|
76
|
-
"zhin.js": "1.1.
|
|
75
|
+
"@zhin.js/host-http": "1.1.8",
|
|
76
|
+
"zhin.js": "1.1.12"
|
|
77
77
|
},
|
|
78
78
|
"files": [
|
|
79
79
|
"adapters",
|
package/src/endpoint.ts
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';
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
import type { HttpHost } from '@zhin.js/host-http';
|
|
19
20
|
import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
|
|
20
21
|
import type { CapabilityId } from 'zhin.js';
|
|
21
|
-
import {
|
|
22
|
+
import { collectOutboundImageSizes, formatOutboundMessages } from './outbound.js';
|
|
22
23
|
import {
|
|
23
24
|
formatInboundContent,
|
|
24
25
|
parseCompoundMessageId,
|
|
@@ -160,25 +161,30 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
163
|
-
|
|
164
|
+
// 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
|
|
165
|
+
// 拿不到 URL/尺寸的媒体降级为独立消息先发
|
|
166
|
+
const messages = formatOutboundMessages(payload, { imageSizes: await collectOutboundImageSizes(payload) });
|
|
167
|
+
const body = messages[messages.length - 1]!;
|
|
164
168
|
const kind = qqOutboundKind(conversation);
|
|
165
169
|
const bot = this.#requireBot();
|
|
166
170
|
let result: unknown;
|
|
167
171
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
172
|
+
for (const message of messages) {
|
|
173
|
+
switch (kind) {
|
|
174
|
+
case 'private':
|
|
175
|
+
result = await bot.sendPrivateMessage(conversation.id, message);
|
|
176
|
+
break;
|
|
177
|
+
case 'group':
|
|
178
|
+
result = await bot.sendGroupMessage(conversation.id, message);
|
|
179
|
+
break;
|
|
180
|
+
case 'channel':
|
|
181
|
+
result = await bot.sendGuildMessage(conversation.id, message);
|
|
182
|
+
break;
|
|
183
|
+
case 'direct':
|
|
184
|
+
if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
|
|
185
|
+
result = await bot.sendDirectMessage(conversation.id, message);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
182
188
|
}
|
|
183
189
|
} catch (error) {
|
|
184
190
|
const { failure, diagnostic } = qqDeliveryFailure(error);
|
|
@@ -217,6 +223,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
217
223
|
+ (msg.mentioned ? ' (mentioned)' : '')
|
|
218
224
|
+ ` | ${truncatePreview(content, 80)}`,
|
|
219
225
|
);
|
|
226
|
+
const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
|
|
220
227
|
void this.emit('message.receive', {
|
|
221
228
|
conversation,
|
|
222
229
|
message: { conversation, id: msg.id },
|
|
@@ -225,7 +232,8 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
225
232
|
sender: {
|
|
226
233
|
id: msg.authorId,
|
|
227
234
|
name: senderDisplayName(msg) || undefined,
|
|
228
|
-
...(
|
|
235
|
+
...(roleProjection.role ? { roles: [roleProjection.role] } : {}),
|
|
236
|
+
...(roleProjection.permissions.length ? { permissions: roleProjection.permissions } : {}),
|
|
229
237
|
},
|
|
230
238
|
endpointId: this.#options.config.id,
|
|
231
239
|
...(msg.mentioned ? { mentioned: true } : {}),
|
|
@@ -233,7 +241,8 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
233
241
|
channelKind: msg.channelKind,
|
|
234
242
|
userId: msg.authorId,
|
|
235
243
|
guildId: msg.guildId,
|
|
236
|
-
|
|
244
|
+
authorRoleIds: msg.authorRoles,
|
|
245
|
+
...(roleProjection.role ? { senderRole: roleProjection.role } : {}),
|
|
237
246
|
}),
|
|
238
247
|
}).catch((err) => {
|
|
239
248
|
this.#logger.warn(formatCompact({
|
|
@@ -415,25 +424,30 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
415
424
|
}
|
|
416
425
|
|
|
417
426
|
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
418
|
-
|
|
427
|
+
// 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
|
|
428
|
+
// 拿不到 URL/尺寸的媒体降级为独立消息先发
|
|
429
|
+
const messages = formatOutboundMessages(payload, { imageSizes: await collectOutboundImageSizes(payload) });
|
|
430
|
+
const body = messages[messages.length - 1]!;
|
|
419
431
|
const kind = qqOutboundKind(conversation);
|
|
420
432
|
const bot = this.#requireBot();
|
|
421
433
|
let result: unknown;
|
|
422
434
|
try {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
435
|
+
for (const message of messages) {
|
|
436
|
+
switch (kind) {
|
|
437
|
+
case 'private':
|
|
438
|
+
result = await bot.sendPrivateMessage(conversation.id, message);
|
|
439
|
+
break;
|
|
440
|
+
case 'group':
|
|
441
|
+
result = await bot.sendGroupMessage(conversation.id, message);
|
|
442
|
+
break;
|
|
443
|
+
case 'channel':
|
|
444
|
+
result = await bot.sendGuildMessage(conversation.id, message);
|
|
445
|
+
break;
|
|
446
|
+
case 'direct':
|
|
447
|
+
if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
|
|
448
|
+
result = await bot.sendDirectMessage(conversation.id, message);
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
437
451
|
}
|
|
438
452
|
} catch (error) {
|
|
439
453
|
const { failure, diagnostic } = qqDeliveryFailure(error);
|
|
@@ -471,6 +485,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
471
485
|
+ (msg.mentioned ? ' (mentioned)' : '')
|
|
472
486
|
+ ` | ${truncatePreview(content, 80)}`,
|
|
473
487
|
);
|
|
488
|
+
const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
|
|
474
489
|
void this.emit('message.receive', {
|
|
475
490
|
conversation,
|
|
476
491
|
message: { conversation, id: msg.id },
|
|
@@ -479,7 +494,8 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
479
494
|
sender: {
|
|
480
495
|
id: msg.authorId,
|
|
481
496
|
name: senderDisplayName(msg) || undefined,
|
|
482
|
-
...(
|
|
497
|
+
...(roleProjection.role ? { roles: [roleProjection.role] } : {}),
|
|
498
|
+
...(roleProjection.permissions.length ? { permissions: roleProjection.permissions } : {}),
|
|
483
499
|
},
|
|
484
500
|
endpointId: this.#options.config.id,
|
|
485
501
|
...(msg.mentioned ? { mentioned: true } : {}),
|
|
@@ -487,7 +503,8 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
487
503
|
channelKind: msg.channelKind,
|
|
488
504
|
userId: msg.authorId,
|
|
489
505
|
guildId: msg.guildId,
|
|
490
|
-
|
|
506
|
+
authorRoleIds: msg.authorRoles,
|
|
507
|
+
...(roleProjection.role ? { senderRole: roleProjection.role } : {}),
|
|
491
508
|
}),
|
|
492
509
|
}).catch((err) => {
|
|
493
510
|
this.#logger.warn(formatCompact({
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ markdown 图片语法 `` 需要图片尺寸:对公网 URL 图片只读取文件头
|
|
3
|
+
* (≤64 KiB)解析 PNG / JPEG / GIF / WebP 尺寸。探测失败返回 undefined,由调用方降级。
|
|
4
|
+
*/
|
|
5
|
+
export interface QqImageSize {
|
|
6
|
+
readonly width: number;
|
|
7
|
+
readonly height: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PROBE_BYTES = 64 * 1024;
|
|
11
|
+
const PROBE_TIMEOUT_MS = 3000;
|
|
12
|
+
|
|
13
|
+
export type ImageProbeFetch = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<{
|
|
14
|
+
readonly ok: boolean;
|
|
15
|
+
readonly body?: ReadableStream<Uint8Array> | null;
|
|
16
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
17
|
+
}>;
|
|
18
|
+
|
|
19
|
+
/** 从图片文件头解析尺寸(PNG / GIF / JPEG / WebP)。 */
|
|
20
|
+
export function imageSizeFromBytes(bytes: Uint8Array): QqImageSize | undefined {
|
|
21
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
22
|
+
const size = (width: number, height: number): QqImageSize | undefined =>
|
|
23
|
+
width > 0 && height > 0 ? { width, height } : undefined;
|
|
24
|
+
const ascii = (offset: number, length: number): string =>
|
|
25
|
+
offset + length <= bytes.length ? String.fromCharCode(...bytes.subarray(offset, offset + length)) : '';
|
|
26
|
+
|
|
27
|
+
// PNG: 8 字节签名 + IHDR
|
|
28
|
+
if (bytes.length >= 24 && bytes[0] === 0x89 && ascii(1, 3) === 'PNG' && ascii(12, 4) === 'IHDR') {
|
|
29
|
+
return size(view.getUint32(16), view.getUint32(20));
|
|
30
|
+
}
|
|
31
|
+
// GIF87a / GIF89a
|
|
32
|
+
if (bytes.length >= 10 && (ascii(0, 6) === 'GIF87a' || ascii(0, 6) === 'GIF89a')) {
|
|
33
|
+
return size(view.getUint16(6, true), view.getUint16(8, true));
|
|
34
|
+
}
|
|
35
|
+
// WebP: RIFF....WEBP + VP8 / VP8L / VP8X
|
|
36
|
+
if (bytes.length >= 30 && ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
|
|
37
|
+
const chunk = ascii(12, 4);
|
|
38
|
+
if (chunk === 'VP8X') {
|
|
39
|
+
const w = 1 + (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16));
|
|
40
|
+
const h = 1 + (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16));
|
|
41
|
+
return size(w, h);
|
|
42
|
+
}
|
|
43
|
+
if (chunk === 'VP8L' && bytes[20] === 0x2f) {
|
|
44
|
+
const b = view.getUint32(21, true);
|
|
45
|
+
return size((b & 0x3fff) + 1, ((b >>> 14) & 0x3fff) + 1);
|
|
46
|
+
}
|
|
47
|
+
if (chunk === 'VP8 ') return size(view.getUint16(26, true) & 0x3fff, view.getUint16(28, true) & 0x3fff);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
// JPEG: 扫描 SOFn 段
|
|
51
|
+
if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
52
|
+
let offset = 2;
|
|
53
|
+
while (offset + 9 <= bytes.length) {
|
|
54
|
+
if (bytes[offset] !== 0xff) return undefined;
|
|
55
|
+
const marker = bytes[offset + 1]!;
|
|
56
|
+
if (marker === 0xff) { offset += 1; continue; }
|
|
57
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { offset += 2; continue; }
|
|
58
|
+
const length = view.getUint16(offset + 2);
|
|
59
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
60
|
+
return size(view.getUint16(offset + 7), view.getUint16(offset + 5));
|
|
61
|
+
}
|
|
62
|
+
offset += 2 + length;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readHead(response: Awaited<ReturnType<ImageProbeFetch>>): Promise<Uint8Array> {
|
|
69
|
+
if (!response.body) return new Uint8Array(await response.arrayBuffer()).subarray(0, PROBE_BYTES);
|
|
70
|
+
const reader = response.body.getReader();
|
|
71
|
+
const chunks: Uint8Array[] = [];
|
|
72
|
+
let total = 0;
|
|
73
|
+
try {
|
|
74
|
+
while (total < PROBE_BYTES) {
|
|
75
|
+
const { done, value } = await reader.read();
|
|
76
|
+
if (done || !value) break;
|
|
77
|
+
chunks.push(value);
|
|
78
|
+
total += value.length;
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
await reader.cancel().catch(() => undefined);
|
|
82
|
+
}
|
|
83
|
+
const out = new Uint8Array(Math.min(total, PROBE_BYTES));
|
|
84
|
+
let offset = 0;
|
|
85
|
+
for (const chunk of chunks) {
|
|
86
|
+
const take = Math.min(chunk.length, out.length - offset);
|
|
87
|
+
out.set(chunk.subarray(0, take), offset);
|
|
88
|
+
offset += take;
|
|
89
|
+
if (offset >= out.length) break;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 读取公网图片文件头得到尺寸;网络错误、超时或格式不识别时返回 undefined。 */
|
|
95
|
+
export async function probeImageSize(
|
|
96
|
+
url: string,
|
|
97
|
+
fetchImpl: ImageProbeFetch = fetch as unknown as ImageProbeFetch,
|
|
98
|
+
timeoutMs = PROBE_TIMEOUT_MS,
|
|
99
|
+
): Promise<QqImageSize | undefined> {
|
|
100
|
+
try {
|
|
101
|
+
const response = await fetchImpl(url, {
|
|
102
|
+
headers: { Range: `bytes=0-${PROBE_BYTES - 1}` },
|
|
103
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
104
|
+
});
|
|
105
|
+
if (!response.ok) return undefined;
|
|
106
|
+
return imageSizeFromBytes(await readHead(response));
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -25,11 +25,16 @@ export {
|
|
|
25
25
|
|
|
26
26
|
export {
|
|
27
27
|
formatOutbound,
|
|
28
|
+
formatOutboundMessages,
|
|
29
|
+
collectOutboundImageSizes,
|
|
30
|
+
type FormatOutboundMessagesOptions,
|
|
28
31
|
resolveMediaFile,
|
|
29
32
|
type QqOutboundElem,
|
|
30
33
|
type QqOutboundMessage,
|
|
31
34
|
} from './outbound.js';
|
|
32
35
|
|
|
36
|
+
export { imageSizeFromBytes, probeImageSize, type QqImageSize } from './image-size.js';
|
|
37
|
+
|
|
33
38
|
export {
|
|
34
39
|
QqHttpEndpoint,
|
|
35
40
|
QqWebsocketEndpoint,
|
package/src/outbound.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* 归一到 data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
|
|
9
9
|
* - markdown:data.content 原文,或 custom_template_id + params 模板。
|
|
10
10
|
* - keyboard:data.rows 按钮矩阵展开为 button 段(SDK processButtons 组装
|
|
11
|
-
* keyboard 载荷);按钮消息必须带 markdown
|
|
11
|
+
* keyboard 载荷);按钮消息必须带 markdown,且一条消息只承载一个 markdown、
|
|
12
|
+
* msg_type=2 时 content 不展示,故含按钮或内联 markdown 时文本与内联 markdown
|
|
13
|
+
* 段按顺序合并为同一 markdown 段(模板 markdown 原样透传)。
|
|
12
14
|
* data.id 模板键盘原样透传。
|
|
13
15
|
* canonical share 当前明确 not_sent/unsupported,避免静默丢标题/描述/URL。
|
|
14
16
|
* 其他未识别段保持既有 data.text fallback;正常发送另受声明的 supported 白名单约束。
|
|
@@ -16,6 +18,7 @@
|
|
|
16
18
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
17
19
|
import { EndpointDeliveryError, isMediaRef } from '@zhin.js/im-contract';
|
|
18
20
|
import type { QqWireSegment } from './protocol.js';
|
|
21
|
+
import { probeImageSize, type QqImageSize } from './image-size.js';
|
|
19
22
|
|
|
20
23
|
const logger = getLogger('qq');
|
|
21
24
|
|
|
@@ -79,13 +82,17 @@ function normalizeMediaSegment(seg: QqWireSegment): QqOutboundElem | null {
|
|
|
79
82
|
return { type: seg.type, data: { file } };
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
function
|
|
83
|
-
|
|
84
|
-
const templateId = typeof data.custom_template_id === 'string' && data.custom_template_id
|
|
85
|
+
function markdownTemplateId(data: Record<string, unknown>): string | undefined {
|
|
86
|
+
return typeof data.custom_template_id === 'string' && data.custom_template_id
|
|
85
87
|
? data.custom_template_id
|
|
86
88
|
: typeof data.template_id === 'string' && data.template_id
|
|
87
89
|
? data.template_id
|
|
88
90
|
: undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeMarkdownSegment(seg: QqWireSegment): QqOutboundElem | null {
|
|
94
|
+
const data = seg.data ?? {};
|
|
95
|
+
const templateId = markdownTemplateId(data);
|
|
89
96
|
if (templateId) {
|
|
90
97
|
return {
|
|
91
98
|
type: 'markdown',
|
|
@@ -179,18 +186,39 @@ export function formatOutbound(payload: unknown): QqOutboundMessage {
|
|
|
179
186
|
: String(payload);
|
|
180
187
|
}
|
|
181
188
|
|
|
182
|
-
// QQ
|
|
189
|
+
// 一条 QQ 消息只有一个 markdown 字段(SDK handleMarkdown 后者覆盖前者),且 msg_type=2
|
|
190
|
+
// 时 content 文本不展示。按钮消息必须携带 markdown;含内联 markdown 的消息同理:
|
|
191
|
+
// 文本与内联 markdown 按顺序合并为同一 markdown 段(落在首次出现的位置),避免正文被丢弃。
|
|
192
|
+
// 模板 markdown(custom_template_id)无法拼接,保持原样。
|
|
183
193
|
const hasKeyboard = segments.some(
|
|
184
194
|
(item) => typeof item !== 'string' && item.type === 'keyboard',
|
|
185
195
|
);
|
|
196
|
+
const hasInlineMarkdown = segments.some(
|
|
197
|
+
(item) => typeof item !== 'string' && item.type === 'markdown' && !markdownTemplateId(item.data ?? {}),
|
|
198
|
+
);
|
|
199
|
+
const mergeIntoMarkdown = hasKeyboard || hasInlineMarkdown;
|
|
186
200
|
|
|
187
201
|
const parts: Array<string | QqOutboundElem> = [];
|
|
202
|
+
let markdownSlot: { type: 'markdown'; data: { content: string } } | undefined;
|
|
203
|
+
let lastPieceWasMarkdown = false;
|
|
204
|
+
const appendMarkdown = (piece: string, isMarkdown: boolean): void => {
|
|
205
|
+
if (!piece) return;
|
|
206
|
+
if (!markdownSlot) {
|
|
207
|
+
markdownSlot = { type: 'markdown', data: { content: '' } };
|
|
208
|
+
parts.push(markdownSlot);
|
|
209
|
+
}
|
|
210
|
+
const current = markdownSlot.data.content;
|
|
211
|
+
const needsBreak = current.trim() !== '' && (isMarkdown || lastPieceWasMarkdown);
|
|
212
|
+
markdownSlot.data.content = needsBreak
|
|
213
|
+
? `${current.replace(/\s+$/, '')}\n\n${piece.replace(/^\s+/, '')}`
|
|
214
|
+
: current + piece;
|
|
215
|
+
lastPieceWasMarkdown = isMarkdown;
|
|
216
|
+
};
|
|
188
217
|
let textBuf = '';
|
|
189
218
|
const flushText = (): void => {
|
|
190
219
|
if (!textBuf) return;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
: textBuf);
|
|
220
|
+
if (mergeIntoMarkdown) appendMarkdown(textBuf, false);
|
|
221
|
+
else parts.push(textBuf);
|
|
194
222
|
textBuf = '';
|
|
195
223
|
};
|
|
196
224
|
|
|
@@ -223,9 +251,14 @@ export function formatOutbound(payload: unknown): QqOutboundMessage {
|
|
|
223
251
|
break;
|
|
224
252
|
}
|
|
225
253
|
case 'markdown': {
|
|
226
|
-
flushText();
|
|
227
254
|
const markdown = normalizeMarkdownSegment(item);
|
|
228
|
-
if (markdown)
|
|
255
|
+
if (!markdown) break;
|
|
256
|
+
flushText();
|
|
257
|
+
if (mergeIntoMarkdown && typeof markdown.data.content === 'string') {
|
|
258
|
+
appendMarkdown(markdown.data.content, true);
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
parts.push(markdown);
|
|
229
262
|
break;
|
|
230
263
|
}
|
|
231
264
|
case 'keyboard': {
|
|
@@ -252,8 +285,108 @@ export function formatOutbound(payload: unknown): QqOutboundMessage {
|
|
|
252
285
|
}
|
|
253
286
|
}
|
|
254
287
|
flushText();
|
|
288
|
+
if (markdownSlot && !markdownSlot.data.content.trim()) parts.splice(parts.indexOf(markdownSlot), 1);
|
|
255
289
|
|
|
256
290
|
if (parts.length === 0) return '';
|
|
257
291
|
if (parts.length === 1) return parts[0]!;
|
|
258
292
|
return parts;
|
|
259
293
|
}
|
|
294
|
+
|
|
295
|
+
/** SDK 能作为 msg_type=7 富媒体发送的段 */
|
|
296
|
+
const SPLIT_MEDIA_TYPES = new Set(['image', 'audio', 'video']);
|
|
297
|
+
/** 走 msg_type=2 的 markdown/按钮段 */
|
|
298
|
+
const MARKDOWN_MESSAGE_TYPES = new Set(['markdown', 'keyboard', 'button']);
|
|
299
|
+
|
|
300
|
+
export interface FormatOutboundMessagesOptions {
|
|
301
|
+
/** 公网图片 URL → 尺寸;有尺寸的图片才能嵌入 markdown(见 collectOutboundImageSizes)。 */
|
|
302
|
+
readonly imageSizes?: ReadonlyMap<string, QqImageSize>;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isElemOf(types: Set<string>) {
|
|
306
|
+
return (part: string | QqOutboundElem): part is QqOutboundElem => typeof part !== 'string' && types.has(part.type);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 渲染后 payload → 按序发送的 QQ Sendable 列表。
|
|
311
|
+
*
|
|
312
|
+
* QQ 单条消息的 msg_type 只能是一种:富媒体(7,media.file_info)与 markdown/按钮(2)
|
|
313
|
+
* 互斥,SDK 后处理的段会覆盖 msg_type,导致图片被静默丢弃。处理顺序:
|
|
314
|
+
* 1. 公网 URL 图片且已知尺寸:按 QQ markdown 语法 `` 嵌入同一
|
|
315
|
+
* markdown 段(开放平台会下载转存),仍为一条消息;
|
|
316
|
+
* 2. 其余媒体(base64 / 本地渲染图:v2 /files 上传只返回 file_info,拿不到 URL;或尺寸
|
|
317
|
+
* 未知、模板 markdown 无法拼接)降级为独立富媒体消息先发,markdown + 按钮作为最后一条
|
|
318
|
+
* (返回其消息 ID)。reply 段随每条消息携带,保持被动回复。
|
|
319
|
+
* 不含媒体与 markdown/按钮冲突时与 formatOutbound 一致(单条)。
|
|
320
|
+
*/
|
|
321
|
+
export function formatOutboundMessages(payload: unknown, options: FormatOutboundMessagesOptions = {}): QqOutboundMessage[] {
|
|
322
|
+
const body = formatOutbound(payload);
|
|
323
|
+
if (!Array.isArray(body)) return [body as QqOutboundMessage];
|
|
324
|
+
const elems = body as ReadonlyArray<string | QqOutboundElem>;
|
|
325
|
+
const isMedia = isElemOf(SPLIT_MEDIA_TYPES);
|
|
326
|
+
if (!elems.some(isMedia) || !elems.some(isElemOf(MARKDOWN_MESSAGE_TYPES))) return [body];
|
|
327
|
+
|
|
328
|
+
const markdownIndex = elems.findIndex(isElemOf(new Set(['markdown'])));
|
|
329
|
+
const markdownElem = markdownIndex >= 0 ? elems[markdownIndex] as QqOutboundElem : undefined;
|
|
330
|
+
const markdownContent = markdownElem ? markdownElem.data.content : '';
|
|
331
|
+
const embedded = new Set<QqOutboundElem>();
|
|
332
|
+
let merged: QqOutboundElem | undefined;
|
|
333
|
+
if (typeof markdownContent === 'string') {
|
|
334
|
+
const before: string[] = [];
|
|
335
|
+
const after: string[] = [];
|
|
336
|
+
elems.forEach((part, index) => {
|
|
337
|
+
if (!isMedia(part) || part.type !== 'image' || typeof part.data.url !== 'string') return;
|
|
338
|
+
const size = options.imageSizes?.get(part.data.url);
|
|
339
|
+
if (!size) return;
|
|
340
|
+
(markdownIndex < 0 || index < markdownIndex ? before : after)
|
|
341
|
+
.push(``);
|
|
342
|
+
embedded.add(part);
|
|
343
|
+
});
|
|
344
|
+
if (embedded.size > 0) {
|
|
345
|
+
merged = { type: 'markdown', data: { content: [...before, markdownContent, ...after].filter((piece) => piece.trim()).join('\n\n') } };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const replies = elems.filter(isElemOf(new Set(['reply'])));
|
|
350
|
+
const separate: QqOutboundElem[] = [];
|
|
351
|
+
const rest: Array<string | QqOutboundElem> = [];
|
|
352
|
+
let mergedPlaced = false;
|
|
353
|
+
for (const part of elems) {
|
|
354
|
+
if (merged && (part === markdownElem || (!markdownElem && embedded.has(part as QqOutboundElem)))) {
|
|
355
|
+
if (!mergedPlaced) rest.push(merged);
|
|
356
|
+
mergedPlaced = true;
|
|
357
|
+
} else if (embedded.has(part as QqOutboundElem)) {
|
|
358
|
+
continue;
|
|
359
|
+
} else if (isMedia(part)) {
|
|
360
|
+
separate.push(part);
|
|
361
|
+
} else {
|
|
362
|
+
rest.push(part);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return [
|
|
366
|
+
...separate.map((item): QqOutboundMessage => (replies.length ? [...replies, item] : item)),
|
|
367
|
+
rest.length === 1 ? rest[0]! : rest,
|
|
368
|
+
];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 预先探测需要嵌入 markdown 的公网图片尺寸:仅当 payload 同时含图片与 markdown/按钮时,
|
|
373
|
+
* 对 http(s) URL 图片并发读取文件头;失败的图片不入表,formatOutboundMessages 会降级。
|
|
374
|
+
*/
|
|
375
|
+
export async function collectOutboundImageSizes(
|
|
376
|
+
payload: unknown,
|
|
377
|
+
probe: (url: string) => Promise<QqImageSize | undefined> = (url) => probeImageSize(url),
|
|
378
|
+
): Promise<Map<string, QqImageSize>> {
|
|
379
|
+
const sizes = new Map<string, QqImageSize>();
|
|
380
|
+
const segments = toSegmentList(payload).filter((item): item is QqWireSegment => typeof item !== 'string');
|
|
381
|
+
if (!segments.some((item) => MARKDOWN_MESSAGE_TYPES.has(item.type))) return sizes;
|
|
382
|
+
const urls = new Set<string>();
|
|
383
|
+
for (const item of segments) {
|
|
384
|
+
const media = item.type === 'image' ? (item.data ?? {}).media : undefined;
|
|
385
|
+
if (isMediaRef(media) && media.kind === 'url' && /^https?:\/\//i.test(media.value)) urls.add(media.value);
|
|
386
|
+
}
|
|
387
|
+
await Promise.all([...urls].map(async (url) => {
|
|
388
|
+
const size = await probe(url);
|
|
389
|
+
if (size) sizes.set(url, size);
|
|
390
|
+
}));
|
|
391
|
+
return sizes;
|
|
392
|
+
}
|
package/src/platform-permit.ts
CHANGED
|
@@ -36,6 +36,28 @@ export function normalizeQqGuildSenderForPermit(input: {
|
|
|
36
36
|
return { role: 'member', permissions };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* QQ 官方频道事件 `member.roles` 中的内置身份 id:4 = 频道主,2 = 超级管理员。
|
|
41
|
+
* 事件已自带,无需额外 API;其余 id(含 5 子频道管理员)不视为 admin。
|
|
42
|
+
*/
|
|
43
|
+
const QQ_GUILD_OWNER_ROLE_ID = '4';
|
|
44
|
+
const QQ_GUILD_ADMIN_ROLE_ID = '2';
|
|
45
|
+
|
|
46
|
+
/** 入站角色投影:规范角色 owner / admin 进 roles,原始角色 id 进 permissions(`role:<id>`)。 */
|
|
47
|
+
export function projectQqGuildSenderRoles(authorRoles: readonly string[] | undefined): {
|
|
48
|
+
role?: 'owner' | 'admin';
|
|
49
|
+
permissions: string[];
|
|
50
|
+
} {
|
|
51
|
+
const ids = authorRoles ?? [];
|
|
52
|
+
const projected = normalizeQqGuildSenderForPermit({
|
|
53
|
+
roles: ids.map((id) => `role:${id}`),
|
|
54
|
+
isOwner: ids.includes(QQ_GUILD_OWNER_ROLE_ID),
|
|
55
|
+
isAdmin: ids.includes(QQ_GUILD_ADMIN_ROLE_ID),
|
|
56
|
+
});
|
|
57
|
+
const role = projected.role === 'owner' || projected.role === 'admin' ? projected.role : undefined;
|
|
58
|
+
return { ...(role ? { role } : {}), permissions: projected.permissions ?? [] };
|
|
59
|
+
}
|
|
60
|
+
|
|
39
61
|
export function checkQqPlatformPermit(perm: string, subject: PermissionSubject): boolean {
|
|
40
62
|
const role = subject.sender?.role?.[0];
|
|
41
63
|
const permissions = subject.sender?.permissions ?? [];
|