@zhin.js/adapter-qq 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +40 -1
  3. package/dist/index.js +2 -2
  4. package/lib/adapter.d.ts +10 -3
  5. package/lib/adapter.d.ts.map +1 -1
  6. package/lib/adapter.js +12 -4
  7. package/lib/adapter.js.map +1 -1
  8. package/lib/endpoint.d.ts +5 -0
  9. package/lib/endpoint.d.ts.map +1 -1
  10. package/lib/endpoint.js +79 -2
  11. package/lib/endpoint.js.map +1 -1
  12. package/lib/group-at-normalize.d.ts.map +1 -1
  13. package/lib/group-at-normalize.js +2 -5
  14. package/lib/group-at-normalize.js.map +1 -1
  15. package/lib/inbound-action.d.ts +15 -0
  16. package/lib/inbound-action.d.ts.map +1 -0
  17. package/lib/inbound-action.js +57 -0
  18. package/lib/inbound-action.js.map +1 -0
  19. package/lib/index.js +3 -3
  20. package/lib/mention-enrich.d.ts +12 -0
  21. package/lib/mention-enrich.d.ts.map +1 -0
  22. package/lib/mention-enrich.js +52 -0
  23. package/lib/mention-enrich.js.map +1 -0
  24. package/lib/outbound-keyboard.d.ts +4 -0
  25. package/lib/outbound-keyboard.d.ts.map +1 -0
  26. package/lib/outbound-keyboard.js +96 -0
  27. package/lib/outbound-keyboard.js.map +1 -0
  28. package/lib/outbound-markdown.d.ts +16 -4
  29. package/lib/outbound-markdown.d.ts.map +1 -1
  30. package/lib/outbound-markdown.js +116 -17
  31. package/lib/outbound-markdown.js.map +1 -1
  32. package/lib/outbound-media.d.ts +2 -0
  33. package/lib/outbound-media.d.ts.map +1 -1
  34. package/lib/outbound-media.js +2 -1
  35. package/lib/outbound-media.js.map +1 -1
  36. package/lib/outbound-mixed-media.d.ts +21 -0
  37. package/lib/outbound-mixed-media.d.ts.map +1 -0
  38. package/lib/outbound-mixed-media.js +78 -0
  39. package/lib/outbound-mixed-media.js.map +1 -0
  40. package/lib/platform-permit.js +2 -2
  41. package/lib/qq-bind-api.d.ts +10 -0
  42. package/lib/qq-bind-api.d.ts.map +1 -1
  43. package/lib/qq-bind-api.js +9 -5
  44. package/lib/qq-bind-api.js.map +1 -1
  45. package/lib/qq-bind-flow.d.ts +1 -0
  46. package/lib/qq-bind-flow.d.ts.map +1 -1
  47. package/lib/qq-bind-flow.js +11 -2
  48. package/lib/qq-bind-flow.js.map +1 -1
  49. package/lib/qq-bind-persist.d.ts +11 -0
  50. package/lib/qq-bind-persist.d.ts.map +1 -0
  51. package/lib/qq-bind-persist.js +52 -0
  52. package/lib/qq-bind-persist.js.map +1 -0
  53. package/lib/qq-endpoint-manager.d.ts.map +1 -1
  54. package/lib/qq-endpoint-manager.js +43 -8
  55. package/lib/qq-endpoint-manager.js.map +1 -1
  56. package/lib/segment-mapper.d.ts +2 -0
  57. package/lib/segment-mapper.d.ts.map +1 -0
  58. package/lib/segment-mapper.js +2 -0
  59. package/lib/segment-mapper.js.map +1 -0
  60. package/lib/types.d.ts +7 -0
  61. package/lib/types.d.ts.map +1 -1
  62. package/package.json +16 -16
  63. package/skills/qq/PERMITS.md +1 -1
  64. package/src/adapter.ts +12 -4
  65. package/src/endpoint.ts +119 -6
  66. package/src/group-at-normalize.ts +2 -4
  67. package/src/inbound-action.ts +87 -0
  68. package/src/index.ts +4 -4
  69. package/src/mention-enrich.ts +71 -0
  70. package/src/outbound-keyboard.ts +118 -0
  71. package/src/outbound-markdown.ts +127 -17
  72. package/src/outbound-media.ts +2 -1
  73. package/src/outbound-mixed-media.ts +101 -0
  74. package/src/platform-permit.ts +2 -2
  75. package/src/qq-bind-api.ts +24 -5
  76. package/src/qq-bind-flow.ts +13 -3
  77. package/src/qq-bind-persist.ts +71 -0
  78. package/src/qq-endpoint-manager.ts +62 -9
  79. package/src/segment-mapper.ts +1 -0
  80. package/src/types.ts +7 -0
package/src/endpoint.ts CHANGED
@@ -14,7 +14,8 @@ import {
14
14
  Message,
15
15
  SendOptions,
16
16
  SendContent,
17
- segment,} from 'zhin.js';
17
+ segment,
18
+ } from 'zhin.js';
18
19
  import type { MessageElement } from "zhin.js";
19
20
  import { ReceiverMode, type QQEndpointConfig, type ApplicationPlatform } from "./types.js";
20
21
  import type { QQAdapter } from "./adapter.js";
@@ -22,9 +23,19 @@ import { normalizeQqInboundWsPayload, type QqWsPacket } from "./inbound-normaliz
22
23
  import { normalizeGroupAtPrefix } from "./group-at-normalize.js";
23
24
  import { SDK_VERSION, SDK_VERSION_HEADER } from "./sdk-version.js";
24
25
  import { applyCustomAuthEndpoints } from "./gateway-config.js";
25
- import { normalizeOutboundMarkdown } from "./outbound-markdown.js";
26
+ import { normalizeOutboundMarkdown, asOutboundSegments, splitReplyPrefix } from "./outbound-markdown.js";
26
27
  import { normalizeOutboundMedia } from "./outbound-media.js";
28
+ import { fromCanonicalSegments, toCanonicalSegments } from "./segment-mapper.js";
29
+ import { enrichCanonicalMentionNames } from "./mention-enrich.js";
30
+ import {
31
+ buildMixedMediaMessagePayload,
32
+ buildQqImageUploadPayload,
33
+ firstImageSegment,
34
+ shouldSendTextImageMixedMedia,
35
+ } from "./outbound-mixed-media.js";
27
36
  import { normalizeQqGuildSenderForPermit } from "./platform-permit.js";
37
+ import { expandKeyboardSegmentsForQq } from "./outbound-keyboard.js";
38
+ import { formatQqActionMessage, type QqActionNoticeEvent } from "./inbound-action.js";
28
39
 
29
40
  /** 从 qq-official-bot SendResult / 审核回包中解析出站消息 ID */
30
41
  export function resolveOutboundMessageId(result: unknown): string {
@@ -171,6 +182,34 @@ export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform =
171
182
  }
172
183
  }
173
184
 
185
+ private async handleQQAction(event: QqActionNoticeEvent): Promise<void> {
186
+ try {
187
+ await event.reply(0);
188
+ const channel = event.notice_type === 'group'
189
+ ? { id: String(event.group_id), type: 'group' as const }
190
+ : event.notice_type === 'guild'
191
+ ? { id: String(event.channel_id), type: 'channel' as const }
192
+ : { id: String(event.operator_id), type: 'private' as const };
193
+ const message = formatQqActionMessage(event, {
194
+ endpointName: this.$config.name,
195
+ send: async (content) => this.adapter.sendMessage({
196
+ ...channel,
197
+ context: 'qq',
198
+ endpoint: this.$config.name,
199
+ content,
200
+ }),
201
+ });
202
+ await this.adapter.receiveMessageAwait(message);
203
+ this.pluginLogger.debug(
204
+ `qq action ${message.$channel.type}(${message.$channel.id}): ${message.$raw}`,
205
+ );
206
+ } catch (err) {
207
+ this.pluginLogger.warn(
208
+ `qq format action failed: ${err instanceof Error ? err.message : String(err)}`,
209
+ );
210
+ }
211
+ }
212
+
174
213
  private handleGroupNotice(event: string, payload: { group_id?: string; operator_id?: string }): void {
175
214
  this.pluginLogger.info(
176
215
  `qq notice ${event}: group=${payload.group_id ?? '?'} operator=${payload.operator_id ?? '?'}`,
@@ -181,6 +220,9 @@ export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform =
181
220
  this.on("message.group", this.handleQQMessage.bind(this));
182
221
  this.on("message.guild", this.handleQQMessage.bind(this));
183
222
  this.on("message.private", this.handleQQMessage.bind(this));
223
+ this.on("notice.friend.action", this.handleQQAction.bind(this));
224
+ this.on("notice.group.action", this.handleQQAction.bind(this));
225
+ this.on("notice.guild.action", this.handleQQAction.bind(this));
184
226
  this.on("notice.group.increase", (e) => this.handleGroupNotice('group.add_robot', e));
185
227
  this.on("notice.group.decrease", (e) => this.handleGroupNotice('group.del_robot', e));
186
228
  this.on("notice.group.receive_open", (e) => this.handleGroupNotice('group.msg_receive_open', e));
@@ -252,10 +294,18 @@ export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform =
252
294
  }
253
295
  }
254
296
 
297
+ private toWireSendContent(content: SendContent): MessageElement[] {
298
+ const items = (Array.isArray(content) ? content : [content]).map((item) =>
299
+ typeof item === "string" ? { type: "text", data: { text: item } } : item as MessageElement,
300
+ );
301
+ return fromCanonicalSegments(toCanonicalSegments(items as never));
302
+ }
303
+
255
304
  $formatMessage(msg: PrivateMessageEvent | GroupMessageEvent) {
256
305
  const raw = msg as PrivateMessageEvent & GroupMessageEvent & {
257
306
  group_openid?: string;
258
307
  author?: { member_openid?: string; user_openid?: string; id?: string; username?: string };
308
+ mentions?: Array<{ id?: string; member_openid?: string; username?: string; nickname?: string }>;
259
309
  __zhin_group_at?: boolean;
260
310
  };
261
311
 
@@ -282,6 +332,14 @@ export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform =
282
332
  content = normalizeGroupAtPrefix(content, endpointAtIds, raw.__zhin_group_at === true);
283
333
  }
284
334
 
335
+ if (Array.isArray(content)) {
336
+ content = enrichCanonicalMentionNames(
337
+ toCanonicalSegments(content),
338
+ this.adapter,
339
+ raw.mentions,
340
+ );
341
+ }
342
+
285
343
  const result = Message.from(msg, {
286
344
  $id: msg.message_id?.toString(),
287
345
  $adapter: "qq" as const,
@@ -315,10 +373,20 @@ export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform =
315
373
  }
316
374
 
317
375
  async $sendMessage(options: SendOptions): Promise<string> {
318
- const content = normalizeOutboundMarkdown(
319
- normalizeOutboundMedia(options.content),
320
- this.$config.outboundMarkdown,
321
- );
376
+ const expanded = expandKeyboardSegmentsForQq(options.content);
377
+ const canonicalized = this.toWireSendContent(expanded);
378
+ const mediaNorm = normalizeOutboundMedia(canonicalized);
379
+ const markdownMode = this.$config.outboundMarkdown;
380
+
381
+ if (
382
+ (options.type === "group" || options.type === "private")
383
+ && !options.id.startsWith("direct:")
384
+ && shouldSendTextImageMixedMedia(mediaNorm, markdownMode)
385
+ ) {
386
+ return await this.$sendTextImageMixedMedia(options, mediaNorm);
387
+ }
388
+
389
+ const content = normalizeOutboundMarkdown(mediaNorm, markdownMode);
322
390
  switch (options.type) {
323
391
  case "private": {
324
392
  if (options.id.startsWith("direct:")) {
@@ -347,6 +415,51 @@ export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform =
347
415
  }
348
416
  }
349
417
 
418
+ /** 内联图 + 文本:上传 file_info 后以 msg_type=7 图文混排发送(二维码等场景) */
419
+ private async $sendTextImageMixedMedia(
420
+ options: SendOptions,
421
+ content: SendContent,
422
+ ): Promise<string> {
423
+ const endpointPath = this.resolveV2EndpointPath(options);
424
+ const { body } = splitReplyPrefix(asOutboundSegments(content));
425
+ const image = firstImageSegment(body);
426
+ if (!image) {
427
+ throw new Error("QQ 图文混排:未找到图片段");
428
+ }
429
+
430
+ const uploadPayload = buildQqImageUploadPayload(image);
431
+ const { data: uploadResult } = await this.request.post(
432
+ `${endpointPath}/files`,
433
+ { ...uploadPayload, srv_send_msg: false },
434
+ ) as { data: { file_info?: string } };
435
+
436
+ if (!uploadResult?.file_info) {
437
+ throw new Error("QQ 图文混排:图片上传未返回 file_info");
438
+ }
439
+
440
+ const messagePayload = buildMixedMediaMessagePayload(content, uploadResult.file_info);
441
+ const { data: result } = await this.request.post(
442
+ `${endpointPath}/messages`,
443
+ messagePayload,
444
+ );
445
+ this.pluginLogger.debug(
446
+ `${this.$config.name} send mixed-media ${options.type}(${options.id}):${segment.raw(content)}`,
447
+ );
448
+ const idPrefix = options.type === "group" ? "group" : "private";
449
+ return `${idPrefix}-${options.id}:${resolveOutboundMessageId(result)}`;
450
+ }
451
+
452
+ private resolveV2EndpointPath(options: SendOptions): string {
453
+ switch (options.type) {
454
+ case "group":
455
+ return `/v2/groups/${options.id}`;
456
+ case "private":
457
+ return `/v2/users/${options.id}`;
458
+ default:
459
+ throw new Error(`QQ 图文混排不支持 ${options.type}`);
460
+ }
461
+ }
462
+
350
463
  async $recallMessage(id: string): Promise<void> {
351
464
  if (!/^(private|group|channel|direct)-([^\:]+):(.+)$/.test(id)) throw new Error(`invalid message id ${id}`);
352
465
  const match = id.match(/^(private|group|channel|direct)-([^\:]+):(.+)$/);
@@ -1,10 +1,8 @@
1
1
  import type { MessageElement } from "zhin.js";
2
+ import { readMentionSegmentTarget } from "zhin.js";
2
3
 
3
4
  function segmentAtId(seg: MessageElement): string {
4
- if (seg.type !== "at" && seg.type !== "mention") return "";
5
- const data = seg.data as Record<string, unknown> | undefined;
6
- const raw = data?.user_id ?? data?.qq ?? data?.id;
7
- return raw == null ? "" : String(raw);
5
+ return readMentionSegmentTarget(seg);
8
6
  }
9
7
 
10
8
  function textMentionsEndpoint(text: string, endpointIds: string[]): boolean {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * QQ 官方互动事件 → 标准 action 段
3
+ * @see https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/trans/msg-btn.html
4
+ */
5
+ import type {
6
+ FriendActionNoticeEvent,
7
+ GroupActionNoticeEvent,
8
+ GuildActionNoticeEvent,
9
+ } from 'qq-official-bot';
10
+ import { Message, type MessageSegment, type SendContent } from 'zhin.js';
11
+
12
+ export type QqActionNoticeEvent =
13
+ | FriendActionNoticeEvent
14
+ | GroupActionNoticeEvent
15
+ | GuildActionNoticeEvent;
16
+
17
+ export interface FormatQqActionMessageOptions {
18
+ endpointName: string;
19
+ send: (content: SendContent) => Promise<string>;
20
+ }
21
+
22
+ /** `group-{openid}:{msgId}` → 平台 msg_id */
23
+ export function stripQqOutboundMessageId(storedId: string): string {
24
+ const match = /^(?:private|group|channel|direct)-[^:]+:(.+)$/.exec(storedId);
25
+ return match ? match[1]! : storedId;
26
+ }
27
+
28
+ function prependReply(content: SendContent, msgId: string): MessageSegment[] {
29
+ const items = Array.isArray(content) ? [...content] : [content];
30
+ items.unshift({ type: 'reply', data: { id: msgId } });
31
+ return items as MessageSegment[];
32
+ }
33
+
34
+ function createQqActionReplyHandler(
35
+ send: (content: SendContent) => Promise<string>,
36
+ ) {
37
+ return async (content: SendContent, quote: boolean | string = false): Promise<string> => {
38
+ const outbound = typeof quote === 'string'
39
+ ? prependReply(content, stripQqOutboundMessageId(quote))
40
+ : content;
41
+ return await send(outbound);
42
+ };
43
+ }
44
+
45
+ export function formatQqActionMessage(
46
+ event: QqActionNoticeEvent,
47
+ options: FormatQqActionMessageOptions,
48
+ ): ReturnType<typeof Message.from<QqActionNoticeEvent>> {
49
+ const resolved = event.data?.resolved;
50
+ const buttonId = String(resolved?.button_id ?? '');
51
+ const buttonData = resolved?.button_data != null ? String(resolved.button_data) : '';
52
+ const payload = buttonData || buttonId;
53
+ const sourceMessageId = resolved?.message_id != null ? String(resolved.message_id) : undefined;
54
+
55
+ let channelType: 'private' | 'group' | 'channel' = 'private';
56
+ let channelId = String(event.operator_id ?? '');
57
+
58
+ if (event.notice_type === 'group') {
59
+ channelType = 'group';
60
+ channelId = String(event.group_id);
61
+ } else if (event.notice_type === 'guild') {
62
+ channelType = 'channel';
63
+ channelId = String(event.channel_id);
64
+ }
65
+
66
+ const senderId = String(event.operator_id ?? '');
67
+
68
+ return Message.from(event, {
69
+ $id: event.notice_id ?? event.event_id ?? `action:${Date.now()}`,
70
+ $adapter: 'qq',
71
+ $endpoint: options.endpointName,
72
+ $sender: { id: senderId, name: senderId },
73
+ $channel: { id: channelId, type: channelType },
74
+ $content: [{
75
+ type: 'action',
76
+ data: {
77
+ id: buttonId,
78
+ payload,
79
+ sourceMessageId,
80
+ },
81
+ }],
82
+ $raw: payload,
83
+ $timestamp: Date.now(),
84
+ $recall: async () => {},
85
+ $reply: createQqActionReplyHandler(options.send),
86
+ });
87
+ }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * QQ 官方适配器入口:类型扩展、导出、注册
3
3
  */
4
4
  import path from "node:path";
5
- import { usePlugin, type Plugin, type IGroupManagement, createGroupManagementTools, type ToolFeature } from "zhin.js";
5
+ import { usePlugin, type Plugin, type ISceneManagement, createSceneManagementTools, type ToolFeature } from "zhin.js";
6
6
  import type { Router } from "@zhin.js/host-router";
7
7
  import { QQAdapter } from "./adapter.js";
8
8
  import { PageManager } from "@zhin.js/host-api";
@@ -56,12 +56,12 @@ useContext("router", (router: Router) => {
56
56
  useContext('tool', 'qq', (toolService: ToolFeature, qq: QQAdapter) => {
57
57
  const disposers: (() => void)[] = [];
58
58
  disposers.push(registerQqPlatformPermitChecker());
59
- const groupTools = createGroupManagementTools(
60
- qq as unknown as IGroupManagement,
59
+ const sceneTools = createSceneManagementTools(
60
+ qq as unknown as ISceneManagement,
61
61
  'qq',
62
62
  { permitResolver: qqGuildPermitResolver, registerChecker: false },
63
63
  );
64
- disposers.push(...groupTools.map(t => toolService.addTool(t, plugin.name)));
64
+ disposers.push(...sceneTools.map(t => toolService.addTool(t, plugin.name)));
65
65
 
66
66
  // 获取频道列表
67
67
  disposers.push(toolService.addTool({
@@ -0,0 +1,71 @@
1
+ import type { Segment } from 'zhin.js';
2
+ import type { QQAdapter } from './adapter.js';
3
+
4
+ type MentionWire = {
5
+ id?: string;
6
+ member_openid?: string;
7
+ username?: string;
8
+ nickname?: string;
9
+ };
10
+
11
+ type QQEndpointLike = {
12
+ $id: string;
13
+ $platformUserId?: string;
14
+ $config: { name?: string; appid?: string; nickname?: string };
15
+ };
16
+
17
+ function mentionWireId(row: MentionWire): string {
18
+ return String(row.id ?? row.member_openid ?? '').trim();
19
+ }
20
+
21
+ function mentionWireName(row: MentionWire): string | undefined {
22
+ const name = row.username ?? row.nickname;
23
+ return typeof name === 'string' && name.trim() ? name.trim() : undefined;
24
+ }
25
+
26
+ function endpointLookupIds(endpoint: QQEndpointLike): string[] {
27
+ const cfg = endpoint.$config;
28
+ return [
29
+ endpoint.$platformUserId,
30
+ cfg.appid,
31
+ cfg.name,
32
+ endpoint.$id,
33
+ ].filter((id): id is string => Boolean(id)).map(String);
34
+ }
35
+
36
+ function resolveEndpointDisplayName(endpoint: QQEndpointLike): string | undefined {
37
+ const cfg = endpoint.$config;
38
+ return cfg.nickname?.trim() || cfg.name?.trim() || endpoint.$id;
39
+ }
40
+
41
+ /** 为 canonical mention 段补齐展示名(平台 payload / endpoint 配置)。 */
42
+ export function enrichCanonicalMentionNames(
43
+ segments: Segment[],
44
+ adapter: QQAdapter,
45
+ wireMentions?: MentionWire[],
46
+ ): Segment[] {
47
+ const nameById = new Map<string, string>();
48
+ for (const row of wireMentions ?? []) {
49
+ const id = mentionWireId(row);
50
+ const name = mentionWireName(row);
51
+ if (id && name) nameById.set(id, name);
52
+ }
53
+
54
+ for (const endpoint of adapter.endpoints.values()) {
55
+ const display = resolveEndpointDisplayName(endpoint);
56
+ if (!display) continue;
57
+ for (const id of endpointLookupIds(endpoint)) {
58
+ if (!nameById.has(id)) nameById.set(id, display);
59
+ }
60
+ }
61
+
62
+ return segments.map((seg) => {
63
+ if (seg.type !== 'mention') return seg;
64
+ const data = seg.data as { target?: string; name?: string };
65
+ const target = String(data.target ?? '').trim();
66
+ if (!target || data.name) return seg;
67
+ const name = nameById.get(target);
68
+ if (!name) return seg;
69
+ return { ...seg, data: { target, name } };
70
+ });
71
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 将 core keyboard 段转为 qq-official-bot 可识别的 button / markdown 段。
3
+ * @see https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/trans/msg-btn.html
4
+ */
5
+ import type { ButtonData, KeyboardSegmentData } from "zhin.js";
6
+ import type { MessageSegment, SendContent } from "zhin.js";
7
+ import { isKeyboardSegment, KeyboardSegment } from "zhin.js";
8
+
9
+ function asKeyboardData(item: unknown): KeyboardSegmentData | null {
10
+ if (item instanceof KeyboardSegment) return item.data;
11
+ if (item != null && typeof item === "object" && isKeyboardSegment(item as MessageSegment)) {
12
+ return (item as MessageSegment).data as KeyboardSegmentData;
13
+ }
14
+ return null;
15
+ }
16
+
17
+ type QqButton = {
18
+ id: string;
19
+ render_data: { label: string; visited_label: string; style: number };
20
+ action: {
21
+ type: number;
22
+ permission: { type: number };
23
+ data: string;
24
+ click_limit: number;
25
+ unsupport_tips: string;
26
+ enter?: boolean;
27
+ reply?: boolean;
28
+ };
29
+ };
30
+
31
+ function coreButtonToQq(btn: ButtonData): QqButton {
32
+ const isCommand = btn.mode === 'command';
33
+ const action: QqButton['action'] = {
34
+ type: isCommand ? 2 : 1,
35
+ permission: { type: 2 },
36
+ data: btn.payload,
37
+ click_limit: btn.disabled ? 0 : 10,
38
+ unsupport_tips: btn.disabled ? "该按钮不可用" : "",
39
+ };
40
+ if (isCommand) {
41
+ if (btn.command?.enter != null) action.enter = btn.command.enter;
42
+ if (btn.command?.reply != null) action.reply = btn.command.reply;
43
+ }
44
+ return {
45
+ id: btn.id,
46
+ render_data: {
47
+ label: btn.label,
48
+ visited_label: btn.label,
49
+ style: 0,
50
+ },
51
+ action,
52
+ };
53
+ }
54
+
55
+ function keyboardDataToQqSegments(data: KeyboardSegmentData): MessageSegment[] {
56
+ const out: MessageSegment[] = [];
57
+ for (const row of data.rows) {
58
+ out.push({
59
+ type: "button",
60
+ data: {
61
+ buttons: row.map(coreButtonToQq),
62
+ },
63
+ } as MessageSegment);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** QQ 官方出站:keyboard → button 行;前置 text 合并为 markdown(按钮消息需带 markdown) */
69
+ export function expandKeyboardSegmentsForQq(content: SendContent): SendContent {
70
+ const items = Array.isArray(content) ? content : [content];
71
+ const out: (string | MessageSegment)[] = [];
72
+ const textParts: string[] = [];
73
+
74
+ for (const item of items) {
75
+ if (typeof item === "string") {
76
+ textParts.push(item);
77
+ continue;
78
+ }
79
+ const keyboardData = asKeyboardData(item);
80
+ if (keyboardData) {
81
+ if (textParts.length > 0) {
82
+ out.push({
83
+ type: "markdown",
84
+ data: { content: textParts.join("\n") },
85
+ } as MessageSegment);
86
+ textParts.length = 0;
87
+ }
88
+ out.push(...keyboardDataToQqSegments(keyboardData));
89
+ continue;
90
+ }
91
+
92
+ if (item.type === "text") {
93
+ const t = item.data?.text ?? item.data?.content ?? "";
94
+ if (t) textParts.push(String(t));
95
+ continue;
96
+ }
97
+
98
+ if (textParts.length > 0) {
99
+ out.push({
100
+ type: "markdown",
101
+ data: { content: textParts.join("\n") },
102
+ } as MessageSegment);
103
+ textParts.length = 0;
104
+ }
105
+ out.push(item as MessageSegment);
106
+ }
107
+
108
+ if (textParts.length > 0) {
109
+ out.push({
110
+ type: "markdown",
111
+ data: { content: textParts.join("\n") },
112
+ } as MessageSegment);
113
+ }
114
+
115
+ if (out.length === 0) return { type: "text", data: { text: "" } };
116
+ if (out.length === 1) return out[0]!;
117
+ return out;
118
+ }