@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/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,故同行文本合并为 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 normalizeMarkdownSegment(seg: QqWireSegment): QqOutboundElem | null {
83
- const data = seg.data ?? {};
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 按钮消息必须携带 markdown(msg_type=2),keyboard 存在时文本合并为 markdown 段
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
- parts.push(hasKeyboard
192
- ? { type: 'markdown', data: { content: textBuf } }
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) parts.push(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 语法 `![图片 #宽px #高px](url)` 嵌入同一
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(`![图片 #${size.width}px #${size.height}px](${part.data.url})`);
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
+ }
@@ -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 ?? [];