koishi-plugin-dew-bot 1.0.262 → 1.0.263

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/lib/index.js CHANGED
@@ -67,7 +67,7 @@ var serviceProto;
67
67
  var init_serviceProto = __esm({
68
68
  "src/shared/bot/serviceProto.ts"() {
69
69
  serviceProto = {
70
- "version": 38,
70
+ "version": 39,
71
71
  "services": [
72
72
  {
73
73
  "id": 0,
@@ -295,6 +295,18 @@ var init_serviceProto = __esm({
295
295
  "target": "messageFace/TitleCardData"
296
296
  },
297
297
  "optional": true
298
+ },
299
+ {
300
+ "id": 17,
301
+ "name": "buttons",
302
+ "type": {
303
+ "type": "Array",
304
+ "elementType": {
305
+ "type": "Reference",
306
+ "target": "messageFace/MessageButtonRow"
307
+ }
308
+ },
309
+ "optional": true
298
310
  }
299
311
  ]
300
312
  },
@@ -1899,6 +1911,65 @@ var init_serviceProto = __esm({
1899
1911
  }
1900
1912
  ]
1901
1913
  },
1914
+ "messageFace/MessageButtonRow": {
1915
+ "type": "Interface",
1916
+ "properties": [
1917
+ {
1918
+ "id": 0,
1919
+ "name": "buttons",
1920
+ "type": {
1921
+ "type": "Array",
1922
+ "elementType": {
1923
+ "type": "Reference",
1924
+ "target": "messageFace/MessageButton"
1925
+ }
1926
+ }
1927
+ }
1928
+ ]
1929
+ },
1930
+ "messageFace/MessageButton": {
1931
+ "type": "Interface",
1932
+ "properties": [
1933
+ {
1934
+ "id": 0,
1935
+ "name": "label",
1936
+ "type": {
1937
+ "type": "String"
1938
+ }
1939
+ },
1940
+ {
1941
+ "id": 1,
1942
+ "name": "command",
1943
+ "type": {
1944
+ "type": "String"
1945
+ }
1946
+ },
1947
+ {
1948
+ "id": 2,
1949
+ "name": "style",
1950
+ "type": {
1951
+ "type": "Union",
1952
+ "members": [
1953
+ {
1954
+ "id": 0,
1955
+ "type": {
1956
+ "type": "Literal",
1957
+ "literal": "primary"
1958
+ }
1959
+ },
1960
+ {
1961
+ "id": 1,
1962
+ "type": {
1963
+ "type": "Literal",
1964
+ "literal": "secondary"
1965
+ }
1966
+ }
1967
+ ]
1968
+ },
1969
+ "optional": true
1970
+ }
1971
+ ]
1972
+ },
1902
1973
  "PtlMessage/ReqMessage": {
1903
1974
  "type": "Interface",
1904
1975
  "extends": [
@@ -3151,6 +3222,18 @@ var import_node_crypto = require("node:crypto");
3151
3222
  // src/plugin/cardLayout.ts
3152
3223
  async function layoutCard(document2 = globalThis.document) {
3153
3224
  await document2.fonts.ready;
3225
+ for (const name3 of document2.querySelectorAll("#character-name,#companion-name")) {
3226
+ const style = document2.defaultView.getComputedStyle(name3);
3227
+ const maximum = parseFloat(style.fontSize);
3228
+ const spacing = parseFloat(style.letterSpacing) || 0;
3229
+ name3.style.whiteSpace = "nowrap";
3230
+ for (let size = maximum; size >= 18; size--) {
3231
+ name3.style.fontSize = size + "px";
3232
+ name3.style.letterSpacing = spacing * size / maximum + "px";
3233
+ if (name3.scrollWidth <= name3.clientWidth) break;
3234
+ }
3235
+ if (name3.scrollWidth > name3.clientWidth) name3.style.whiteSpace = "normal";
3236
+ }
3154
3237
  const titleBanner = document2.querySelector(".title-banner-artwork");
3155
3238
  if (titleBanner) {
3156
3239
  try {
@@ -3944,6 +4027,143 @@ var handel_base = class {
3944
4027
  // src/plugin/serverHandel/text.ts
3945
4028
  init_sessions();
3946
4029
 
4030
+ // src/shared/bot/inlineCommands.ts
4031
+ function inlineCommandSpans(content) {
4032
+ const spans = [];
4033
+ let depth = 0, start = 0, labelStart = 0;
4034
+ for (let index = 0; index < content.length; index++) {
4035
+ if (content[index] === "[") {
4036
+ if (depth === 0) {
4037
+ start = index > 0 && content[index - 1] === "\\" ? index - 1 : index;
4038
+ labelStart = index + 1;
4039
+ }
4040
+ depth++;
4041
+ } else if (content[index] === "]" && depth > 0 && --depth === 0) {
4042
+ const label = content.slice(labelStart, index).replace(/\\$/, "");
4043
+ if (!label.trim() || /[\[\]\r\n]/.test(label) || /^\d+$/.test(label.trim())) continue;
4044
+ const alias = /^\(command:([^()[\]\r\n]+)\)/.exec(content.slice(index + 1));
4045
+ const command = alias ? alias[1].trim() : label.split(/[++]/, 1)[0].trim().replace(/^🔄\s*/u, "");
4046
+ if (!command) continue;
4047
+ const end = index + 1 + (alias?.[0].length ?? 0);
4048
+ spans.push({ start, end, label, command, ...alias ? { aliased: true } : {} });
4049
+ if (alias) index = end - 1;
4050
+ }
4051
+ }
4052
+ return spans;
4053
+ }
4054
+ __name(inlineCommandSpans, "inlineCommandSpans");
4055
+ function renderPlainInlineCommandAliases(content) {
4056
+ let result = "", offset = 0;
4057
+ for (const span of inlineCommandSpans(content)) {
4058
+ if (!span.aliased) continue;
4059
+ result += content.slice(offset, span.start) + (span.label.trim() === span.command ? `[${span.command}]` : `${span.label}[${span.command}]`);
4060
+ offset = span.end;
4061
+ }
4062
+ return result + content.slice(offset);
4063
+ }
4064
+ __name(renderPlainInlineCommandAliases, "renderPlainInlineCommandAliases");
4065
+
4066
+ // src/shared/bot/analysisMsg.ts
4067
+ function messageButtonFallbackLines(data) {
4068
+ return [...new Set((data.buttons ?? []).flatMap((row) => row.buttons.map((button) => "[" + button.command + "]" + button.label)))];
4069
+ }
4070
+ __name(messageButtonFallbackLines, "messageButtonFallbackLines");
4071
+ function matchesButtonFallback(content, fallback) {
4072
+ const bracket = fallback.indexOf("]");
4073
+ const prefix = fallback.slice(0, bracket + 1);
4074
+ const line = content.trim();
4075
+ return line.startsWith(prefix) && line.slice(prefix.length).trim() === fallback.slice(prefix.length).trim();
4076
+ }
4077
+ __name(matchesButtonFallback, "matchesButtonFallback");
4078
+ function appendMessageButtonFallbacks(content, data) {
4079
+ const lines = content.replace(/\r\n?/g, "\n").split("\n");
4080
+ const missing = messageButtonFallbackLines(data).filter((fallback) => !lines.some((line) => matchesButtonFallback(line, fallback)));
4081
+ if (!missing.length) return content;
4082
+ return content + (content && !content.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n";
4083
+ }
4084
+ __name(appendMessageButtonFallbacks, "appendMessageButtonFallbacks");
4085
+ function withoutMessageButtonFallbacks(data) {
4086
+ const fallbacks = messageButtonFallbackLines(data);
4087
+ if (!fallbacks.length) return data;
4088
+ return { ...data, line: data.line.map((item) => {
4089
+ if (item.type !== 1 /* line */ || item.codeBlock || item.reward || item.mailPart !== void 0) return item;
4090
+ const lines = item.content.replace(/\r\n?/g, "\n").split("\n");
4091
+ const kept = lines.filter((line) => !fallbacks.some((fallback) => matchesButtonFallback(line, fallback)));
4092
+ return kept.length === lines.length ? item : { ...item, content: kept.join("\n") };
4093
+ }) };
4094
+ }
4095
+ __name(withoutMessageButtonFallbacks, "withoutMessageButtonFallbacks");
4096
+ var AnalysisMsg = class {
4097
+ static {
4098
+ __name(this, "AnalysisMsg");
4099
+ }
4100
+ parse(data) {
4101
+ let msg = "";
4102
+ let haveTitle = false;
4103
+ const commands = new Map(data.pageNav?.entries?.map((entry) => [entry.lineIndex, entry.viewCommand]) ?? []);
4104
+ const labeledEntries = /* @__PURE__ */ new Map();
4105
+ for (const entry of data.pageNav?.entries ?? []) {
4106
+ if (entry.label === void 0) continue;
4107
+ const row = labeledEntries.get(entry.lineIndex) ?? [];
4108
+ row.push(entry);
4109
+ labeledEntries.set(entry.lineIndex, row);
4110
+ }
4111
+ const withCommandHint = /* @__PURE__ */ __name((content, command) => {
4112
+ const hint = command && !content.includes(`[${command}]`) && content.trim() !== command ? ` [${command}]` : "";
4113
+ return content + hint;
4114
+ }, "withCommandHint");
4115
+ for (const [lineIndex, item] of data.line.entries()) {
4116
+ switch (item.type) {
4117
+ case 0 /* title */:
4118
+ haveTitle = true;
4119
+ msg += `┄━═${item.content}═━┄
4120
+ `;
4121
+ break;
4122
+ case 1 /* line */:
4123
+ const labeledRow = labeledEntries.get(lineIndex);
4124
+ const content = labeledRow ? labeledRow.map((entry) => entry.label + (entry.description ? " · " + entry.description : "") + ` [${entry.viewCommand}]`).join("") : withCommandHint(item.content, commands.get(lineIndex));
4125
+ const plainContent = item.codeBlock || item.mailPart === "body" || labeledRow ? content : renderPlainInlineCommandAliases(content);
4126
+ msg += `${plainContent}
4127
+ `;
4128
+ break;
4129
+ case 2 /* error */:
4130
+ msg += `❌出错了!错误原因:${item.content},请反馈至联系开发者
4131
+ `;
4132
+ break;
4133
+ default:
4134
+ break;
4135
+ }
4136
+ }
4137
+ msg = appendMessageButtonFallbacks(msg, data);
4138
+ if (haveTitle) {
4139
+ msg += "┄━════════━┄";
4140
+ }
4141
+ return msg;
4142
+ }
4143
+ };
4144
+
4145
+ // src/plugin/serverHandel/qqMessageButtons.ts
4146
+ function renderQQKeyboard(data) {
4147
+ const rows = data.buttons;
4148
+ if (!rows?.length) return void 0;
4149
+ if (rows.length > 5 || rows.some((row) => !row.buttons.length || row.buttons.length > 5)) {
4150
+ throw new Error("QQ 按钮最多 5 行,每行必须有 1–5 个按钮");
4151
+ }
4152
+ return { content: { rows: rows.map((row, rowIndex) => ({
4153
+ buttons: row.buttons.map((button, buttonIndex) => {
4154
+ if (!button.label.trim() || !button.command.trim() || /[\r\n\0]/.test(button.label + button.command) || button.command.length > 4e3 || button.style !== void 0 && button.style !== "primary" && button.style !== "secondary") {
4155
+ throw new Error("QQ 按钮文案和指令必须是非空单行文本,样式必须为 primary 或 secondary");
4156
+ }
4157
+ return {
4158
+ id: "action-" + rowIndex + "-" + buttonIndex,
4159
+ render_data: { label: button.label, visited_label: button.label, style: button.style === "primary" ? 1 : 0 },
4160
+ action: { type: 2, permission: { type: 2 }, data: button.command, enter: true, reply: false, unsupport_tips: "请发送:" + button.command }
4161
+ };
4162
+ })
4163
+ })) } };
4164
+ }
4165
+ __name(renderQQKeyboard, "renderQQKeyboard");
4166
+
3947
4167
  // src/plugin/serverHandel/qqMarkdownHelpers.ts
3948
4168
  function escapeMarkdown(value) {
3949
4169
  return value.replace(/[\\`*_{}\[\]()#+\-.!|>~<&]/g, "\\$&");
@@ -4066,6 +4286,7 @@ function renderQQMailMarkdown(data) {
4066
4286
  }
4067
4287
  __name(renderQQMailMarkdown, "renderQQMailMarkdown");
4068
4288
  function renderQQRewardMarkdown(data, showGreeting) {
4289
+ data = withoutMessageButtonFallbacks(data);
4069
4290
  if (data.mail) return appendMissingBattleLogLinks(renderQQMailMarkdown(data), data);
4070
4291
  const rewards = data.line.filter((item) => item.type === 1 /* line */ && item.reward).map((item) => renderRewardLine(item.content)).filter(Boolean);
4071
4292
  if (rewards.length && !data.mail) return appendMissingBattleLogLinks("🎁获得奖励\n\n" + rewards.join(" \n"), data);
@@ -4095,42 +4316,6 @@ function renderQQRewardMarkdown(data, showGreeting) {
4095
4316
  }
4096
4317
  __name(renderQQRewardMarkdown, "renderQQRewardMarkdown");
4097
4318
 
4098
- // src/shared/bot/inlineCommands.ts
4099
- function inlineCommandSpans(content) {
4100
- const spans = [];
4101
- let depth = 0, start = 0, labelStart = 0;
4102
- for (let index = 0; index < content.length; index++) {
4103
- if (content[index] === "[") {
4104
- if (depth === 0) {
4105
- start = index > 0 && content[index - 1] === "\\" ? index - 1 : index;
4106
- labelStart = index + 1;
4107
- }
4108
- depth++;
4109
- } else if (content[index] === "]" && depth > 0 && --depth === 0) {
4110
- const label = content.slice(labelStart, index).replace(/\\$/, "");
4111
- if (!label.trim() || /[\[\]\r\n]/.test(label) || /^\d+$/.test(label.trim())) continue;
4112
- const alias = /^\(command:([^()[\]\r\n]+)\)/.exec(content.slice(index + 1));
4113
- const command = alias ? alias[1].trim() : label.split(/[++]/, 1)[0].trim().replace(/^🔄\s*/u, "");
4114
- if (!command) continue;
4115
- const end = index + 1 + (alias?.[0].length ?? 0);
4116
- spans.push({ start, end, label, command, ...alias ? { aliased: true } : {} });
4117
- if (alias) index = end - 1;
4118
- }
4119
- }
4120
- return spans;
4121
- }
4122
- __name(inlineCommandSpans, "inlineCommandSpans");
4123
- function renderPlainInlineCommandAliases(content) {
4124
- let result = "", offset = 0;
4125
- for (const span of inlineCommandSpans(content)) {
4126
- if (!span.aliased) continue;
4127
- result += content.slice(offset, span.start) + (span.label.trim() === span.command ? `[${span.command}]` : `${span.label}[${span.command}]`);
4128
- offset = span.end;
4129
- }
4130
- return result + content.slice(offset);
4131
- }
4132
- __name(renderPlainInlineCommandAliases, "renderPlainInlineCommandAliases");
4133
-
4134
4319
  // src/plugin/serverHandel/qqPageMessage.ts
4135
4320
  function compactListLabel(value) {
4136
4321
  const label = compactDisplayText(value);
@@ -4291,6 +4476,7 @@ function renderQQInventoryMarkdown(data, showGreeting) {
4291
4476
  }
4292
4477
  __name(renderQQInventoryMarkdown, "renderQQInventoryMarkdown");
4293
4478
  function renderQQPageMarkdown(data, showGreeting) {
4479
+ data = withoutMessageButtonFallbacks(data);
4294
4480
  const hasEntryLabels = data.pageNav?.entries?.some((entry) => entry.label !== void 0) ?? false;
4295
4481
  if (data.task && !hasEntryLabels) return renderQQTaskMarkdown(data);
4296
4482
  if (!hasEntryLabels && data.pageNav && data.line.some((item) => item.type === 0 /* title */ && /^(?:💼)?我的背包$/u.test(compactDisplayText(item.content))))
@@ -4416,7 +4602,7 @@ function canSendQQPage(session, data) {
4416
4602
  return !!internal?.sendMessage && !!internal.sendPrivateMessage;
4417
4603
  }
4418
4604
  __name(canSendQQPage, "canSendQQPage");
4419
- async function sendQQMarkdown(session, markdown) {
4605
+ async function sendQQMarkdown(session, markdown, keyboard) {
4420
4606
  const internal = session.bot.internal;
4421
4607
  const request = {
4422
4608
  // With msg_type=2, QQ requires the ordinary content field to be empty.
@@ -4424,6 +4610,7 @@ async function sendQQMarkdown(session, markdown) {
4424
4610
  msg_type: 2,
4425
4611
  markdown: { content: markdown }
4426
4612
  };
4613
+ if (keyboard) request.keyboard = keyboard;
4427
4614
  const age = Date.now() - session.timestamp;
4428
4615
  if (session.messageId && Number.isFinite(age) && age >= 0 && age < 5 * 60 * 1e3 - 2e3) {
4429
4616
  const sequenced = session;
@@ -4437,57 +4624,9 @@ async function sendQQMarkdown(session, markdown) {
4437
4624
  }
4438
4625
  __name(sendQQMarkdown, "sendQQMarkdown");
4439
4626
 
4440
- // src/shared/bot/analysisMsg.ts
4441
- var AnalysisMsg = class {
4442
- static {
4443
- __name(this, "AnalysisMsg");
4444
- }
4445
- parse(data) {
4446
- let msg = "";
4447
- let haveTitle = false;
4448
- const commands = new Map(data.pageNav?.entries?.map((entry) => [entry.lineIndex, entry.viewCommand]) ?? []);
4449
- const labeledEntries = /* @__PURE__ */ new Map();
4450
- for (const entry of data.pageNav?.entries ?? []) {
4451
- if (entry.label === void 0) continue;
4452
- const row = labeledEntries.get(entry.lineIndex) ?? [];
4453
- row.push(entry);
4454
- labeledEntries.set(entry.lineIndex, row);
4455
- }
4456
- const withCommandHint = /* @__PURE__ */ __name((content, command) => {
4457
- const hint = command && !content.includes(`[${command}]`) && content.trim() !== command ? ` [${command}]` : "";
4458
- return content + hint;
4459
- }, "withCommandHint");
4460
- for (const [lineIndex, item] of data.line.entries()) {
4461
- switch (item.type) {
4462
- case 0 /* title */:
4463
- haveTitle = true;
4464
- msg += `┄━═${item.content}═━┄
4465
- `;
4466
- break;
4467
- case 1 /* line */:
4468
- const labeledRow = labeledEntries.get(lineIndex);
4469
- const content = labeledRow ? labeledRow.map((entry) => entry.label + (entry.description ? " · " + entry.description : "") + ` [${entry.viewCommand}]`).join("") : withCommandHint(item.content, commands.get(lineIndex));
4470
- const plainContent = item.codeBlock || item.mailPart === "body" || labeledRow ? content : renderPlainInlineCommandAliases(content);
4471
- msg += `${plainContent}
4472
- `;
4473
- break;
4474
- case 2 /* error */:
4475
- msg += `❌出错了!错误原因:${item.content},请反馈至联系开发者
4476
- `;
4477
- break;
4478
- default:
4479
- break;
4480
- }
4481
- }
4482
- if (haveTitle) {
4483
- msg += "┄━════════━┄";
4484
- }
4485
- return msg;
4486
- }
4487
- };
4488
-
4489
4627
  // src/plugin/serverHandel/qqCommandMessage.ts
4490
4628
  function renderQQCommandMarkdown(data, showGreeting) {
4629
+ data = withoutMessageButtonFallbacks(data);
4491
4630
  if (data.notification) {
4492
4631
  const onlyLine = data.line.length === 1 ? data.line[0] : void 0;
4493
4632
  if (onlyLine?.type === 1 /* line */ && /^🔔系统提示:[^\r\n]+$/.test(onlyLine.content)) {
@@ -4551,6 +4690,7 @@ __name(renderQQCommandMarkdown, "renderQQCommandMarkdown");
4551
4690
 
4552
4691
  // src/plugin/serverHandel/qqTransactionMessage.ts
4553
4692
  function renderQQTransactionMarkdown(data, showGreeting) {
4693
+ data = withoutMessageButtonFallbacks(data);
4554
4694
  const rewardMessage = isRewardMessage(data);
4555
4695
  const shortageMessage = data.transaction === true && data.line.some((item) => item.type === 0 /* title */ && item.content === "❌材料不足");
4556
4696
  const sections = [];
@@ -4644,6 +4784,7 @@ function fencedStory(content) {
4644
4784
  }
4645
4785
  __name(fencedStory, "fencedStory");
4646
4786
  function renderQQStoryMarkdown(data, showGreeting) {
4787
+ data = withoutMessageButtonFallbacks(data);
4647
4788
  if (data.reward === true && data.line.some((item) => item.type === 1 /* line */ && item.reward === true && item.content.trim())) {
4648
4789
  const stories = data.line.filter((item) => item.type === 1 /* line */ && item.codeBlock === true).map((item) => fencedStory(item.content));
4649
4790
  return [...stories, renderQQRewardMarkdown(data, false)].join("\n\n");
@@ -4702,15 +4843,29 @@ __name(renderQQStoryMarkdown, "renderQQStoryMarkdown");
4702
4843
  function renderQQMessagePreview(data, showGreeting = false) {
4703
4844
  const plain = new AnalysisMsg().parse(data);
4704
4845
  const text = data.notification ? plain.trimEnd() : plain;
4705
- if (data.notification) return { kind: "command", markdown: renderQQCommandMarkdown(data, false), text };
4706
- if (hasStoryCodeBlock(data)) return { kind: "story", markdown: renderQQStoryMarkdown(data, showGreeting), text };
4707
- if (data.commandHelp) return { kind: "command", markdown: renderQQCommandMarkdown(data, showGreeting), text };
4708
- if (data.transaction) return { kind: "transaction", markdown: renderQQTransactionMarkdown(data, showGreeting), text };
4709
- if (data.pageNav) return { kind: "page", markdown: renderQQPageMarkdown(data, showGreeting), text };
4710
- if (isRewardMessage(data)) return { kind: "reward", markdown: renderQQRewardMarkdown(data, showGreeting), text };
4846
+ let keyboard;
4847
+ try {
4848
+ keyboard = renderQQKeyboard(data);
4849
+ } catch {
4850
+ return { kind: "text", text };
4851
+ }
4852
+ data = withoutMessageButtonFallbacks(data);
4853
+ const native = /* @__PURE__ */ __name((kind, markdown) => ({
4854
+ kind,
4855
+ markdown,
4856
+ text,
4857
+ ...keyboard ? { keyboard } : {}
4858
+ }), "native");
4859
+ if (data.notification) return native("command", renderQQCommandMarkdown(data, false));
4860
+ if (hasStoryCodeBlock(data)) return native("story", renderQQStoryMarkdown(data, showGreeting));
4861
+ if (data.commandHelp) return native("command", renderQQCommandMarkdown(data, showGreeting));
4862
+ if (data.transaction) return native("transaction", renderQQTransactionMarkdown(data, showGreeting));
4863
+ if (data.pageNav) return native("page", renderQQPageMarkdown(data, showGreeting));
4864
+ if (isRewardMessage(data)) return native("reward", renderQQRewardMarkdown(data, showGreeting));
4711
4865
  if (battleLogMessageLinks(data).length || data.line.some((item) => (item.type === 1 /* line */ || item.type === 2 /* error */) && hasInlineCommands(item.content))) {
4712
- return { kind: "command", markdown: renderQQCommandMarkdown(data, showGreeting), text };
4866
+ return native("command", renderQQCommandMarkdown(data, showGreeting));
4713
4867
  }
4868
+ if (keyboard) return native("command", renderQQCommandMarkdown(data, showGreeting));
4714
4869
  return { kind: "text", text };
4715
4870
  }
4716
4871
  __name(renderQQMessagePreview, "renderQQMessagePreview");
@@ -4727,7 +4882,7 @@ var handelr_text = class extends handel_base {
4727
4882
  if (preview.markdown !== void 0 && canSendQQPage(session, data)) {
4728
4883
  const sendStartedAt = this.timing ? performance.now() : 0;
4729
4884
  try {
4730
- await sendQQMarkdown(session, preview.markdown);
4885
+ await sendQQMarkdown(session, preview.markdown, preview.keyboard);
4731
4886
  if (this.timing) {
4732
4887
  this.timing.delivery = "qq-markdown";
4733
4888
  this.timing.sendMs = performance.now() - sendStartedAt;
@@ -4772,6 +4927,23 @@ function titleCardFollowup(data) {
4772
4927
  }
4773
4928
  __name(titleCardFollowup, "titleCardFollowup");
4774
4929
 
4930
+ // src/plugin/serverHandel/imageMessageFollowup.ts
4931
+ function renderQQImageFollowup(data) {
4932
+ const title = titleCardFollowup(data);
4933
+ const actionLines = messageButtonFallbackLines(data);
4934
+ if (!title && !actionLines.length) return void 0;
4935
+ const text = appendMessageButtonFallbacks(title?.text ?? "", data).trimEnd();
4936
+ let keyboard;
4937
+ try {
4938
+ keyboard = renderQQKeyboard(data);
4939
+ } catch {
4940
+ return { text };
4941
+ }
4942
+ const markdown = titleCardFollowup(withoutMessageButtonFallbacks(data))?.markdown ?? " ";
4943
+ return { text, markdown, ...keyboard ? { keyboard } : {} };
4944
+ }
4945
+ __name(renderQQImageFollowup, "renderQQImageFollowup");
4946
+
4775
4947
  // src/plugin/serverHandel/image.ts
4776
4948
  var ImageHandler = class extends handel_base {
4777
4949
  static {
@@ -4802,16 +4974,16 @@ var ImageHandler = class extends handel_base {
4802
4974
  }
4803
4975
  const sendStartedAt = this.timing ? performance.now() : 0;
4804
4976
  await this.send(data, content, "image");
4805
- const followup = titleCardFollowup(data);
4977
+ const followup = renderQQImageFollowup(data);
4806
4978
  if (!followup) return;
4807
4979
  const session = sessions_default.get(sessionKey(data.botId, data.userId, data.channelId));
4808
4980
  let sentMarkdown = false;
4809
- if (canSendQQPage(session, data)) {
4981
+ if (followup.markdown !== void 0 && canSendQQPage(session, data)) {
4810
4982
  try {
4811
- await sendQQMarkdown(session, followup.markdown);
4983
+ await sendQQMarkdown(session, followup.markdown, followup.keyboard);
4812
4984
  sentMarkdown = true;
4813
4985
  } catch (error) {
4814
- this.ctx.logger.warn("称号操作 Markdown 发送失败,改用普通文字", error instanceof Error ? error.message : error);
4986
+ this.ctx.logger.warn("图片操作 Markdown 发送失败,改用普通文字", error instanceof Error ? error.message : error);
4815
4987
  }
4816
4988
  }
4817
4989
  if (!sentMarkdown) await this.send({ ...data, notification: true }, followup.text);
@@ -0,0 +1,9 @@
1
+ import type { IMessage } from '../../shared/face';
2
+ import { type QQKeyboard } from './qqMessageButtons';
3
+ export interface QQImageFollowup {
4
+ text: string;
5
+ markdown?: string;
6
+ keyboard?: QQKeyboard;
7
+ }
8
+ /** Images are sent first; actions stay outside the image and retain a plain fallback. */
9
+ export declare function renderQQImageFollowup(data: IMessage): QQImageFollowup | undefined;
@@ -0,0 +1,27 @@
1
+ import type { IMessage } from '../../shared/face';
2
+ export interface QQKeyboard {
3
+ content: {
4
+ rows: Array<{
5
+ buttons: Array<{
6
+ id: string;
7
+ render_data: {
8
+ label: string;
9
+ visited_label: string;
10
+ style: 0 | 1;
11
+ };
12
+ action: {
13
+ type: 2;
14
+ permission: {
15
+ type: 2;
16
+ };
17
+ data: string;
18
+ enter: true;
19
+ reply: false;
20
+ unsupport_tips: string;
21
+ };
22
+ }>;
23
+ }>;
24
+ };
25
+ }
26
+ /** Explicit actions use QQ's native keyboard; ordinary command links stay inline. */
27
+ export declare function renderQQKeyboard(data: Pick<IMessage, 'buttons'>): QQKeyboard | undefined;
@@ -1,9 +1,12 @@
1
1
  import { type IMessage } from '../../shared/face';
2
+ import { type QQKeyboard } from './qqMessageButtons';
2
3
  export type QQMessageKind = 'text' | 'story' | 'command' | 'transaction' | 'page' | 'reward';
3
4
  export interface QQMessagePreview {
4
5
  kind: QQMessageKind;
5
6
  /** Exact native QQ Markdown payload; absent when the bot sends plain text. */
6
7
  markdown?: string;
8
+ /** Native buttons accompany the same Markdown in bot delivery and editor preview. */
9
+ keyboard?: QQKeyboard;
7
10
  /** Plain fallback before the transport adds its QQ group greeting. */
8
11
  text: string;
9
12
  }
@@ -1,6 +1,7 @@
1
1
  import type { Session } from 'koishi';
2
2
  import { type IMessage, type PageNavData } from '../../shared/face';
3
3
  import type { MsgMessage } from '../../shared/bot/MsgMessage';
4
+ import { type QQKeyboard } from './qqMessageButtons';
4
5
  export { escapeMarkdown, renderQQTitle } from './qqMarkdownHelpers';
5
6
  export declare function inlineCommandUrl(command: string): string;
6
7
  export declare function hasInlineCommands(content: string): boolean;
@@ -13,4 +14,4 @@ export declare function canSendQQPage(session: Session | undefined, data: MsgMes
13
14
  /** Send a native QQ Markdown page with inline command links. */
14
15
  export declare function sendQQPage(session: Session, data: MsgMessage): Promise<void>;
15
16
  /** Reuse the adapter's native reply API for structured Markdown messages. */
16
- export declare function sendQQMarkdown(session: Session, markdown: string): Promise<void>;
17
+ export declare function sendQQMarkdown(session: Session, markdown: string, keyboard?: QQKeyboard): Promise<void>;
@@ -1,4 +1,9 @@
1
1
  import { IMessage } from '../face';
2
+ /** Platform-neutral button hints also work when Markdown or keyboards are unavailable. */
3
+ export declare function messageButtonFallbackLines(data: Pick<IMessage, 'buttons'>): string[];
4
+ export declare function appendMessageButtonFallbacks(content: string, data: Pick<IMessage, 'buttons'>): string;
5
+ /** Remove only complete action hints; keep source line indexes used by page metadata. */
6
+ export declare function withoutMessageButtonFallbacks<T extends IMessage>(data: T): T;
2
7
  /** 把服务器下发的结构化行拼成纯文本;第一阶段只处理 title / line / error **/
3
8
  export declare class AnalysisMsg {
4
9
  parse(data: IMessage): string;
@@ -267,6 +267,17 @@ export interface PageNavData {
267
267
  command: string;
268
268
  }>;
269
269
  }
270
+ /** 平台无关的消息按钮;点击后发送普通指令,业务仍负责权限与状态校验。 */
271
+ export interface MessageButton {
272
+ label: string;
273
+ command: string;
274
+ /** 主操作蓝色线框,次操作灰色线框;缺省为次操作。 */
275
+ style?: 'primary' | 'secondary';
276
+ }
277
+ /** 同一行的按钮;QQ 原生键盘最多五行,每行最多五个。 */
278
+ export interface MessageButtonRow {
279
+ buttons: MessageButton[];
280
+ }
270
281
  /** 下发给客户端的消息主体 **/
271
282
  export interface IMessage {
272
283
  handleType: MessageHandleType;
@@ -293,4 +304,6 @@ export interface IMessage {
293
304
  line: IMessageItem[];
294
305
  companionCard?: CompanionCardData;
295
306
  titleCard?: TitleCardData;
307
+ /** 通用指令按钮;不支持原生按钮的平台保留文字指令。 */
308
+ buttons?: MessageButtonRow[];
296
309
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-dew-bot",
3
3
  "description": "基于《无限恐怖》小说改编的 RPG 机器人。踏入主神空间,与伙伴迎接轮回试炼,书写属于你的无限冒险。",
4
- "version": "1.0.262",
4
+ "version": "1.0.263",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
@@ -2,6 +2,20 @@
2
2
  export async function layoutCard(document: Document = globalThis.document) {
3
3
 
4
4
  await document.fonts.ready
5
+ // Measure names in one line before the flow-layout cards return below.
6
+ for (const name of document.querySelectorAll<HTMLElement>('#character-name,#companion-name')) {
7
+ const style = document.defaultView!.getComputedStyle(name)
8
+ const maximum = parseFloat(style.fontSize)
9
+ const spacing = parseFloat(style.letterSpacing) || 0
10
+ name.style.whiteSpace = 'nowrap'
11
+ for (let size = maximum; size >= 18; size--) {
12
+ name.style.fontSize = size + 'px'
13
+ name.style.letterSpacing = spacing * size / maximum + 'px'
14
+ if (name.scrollWidth <= name.clientWidth) break
15
+ }
16
+ // Preserve the complete name when even the readable minimum cannot fit.
17
+ if (name.scrollWidth > name.clientWidth) name.style.whiteSpace = 'normal'
18
+ }
5
19
  const titleBanner = document.querySelector<HTMLImageElement>('.title-banner-artwork')
6
20
  if (titleBanner) {
7
21
  try { await titleBanner.decode() } catch {
@@ -5,7 +5,7 @@ import handel_base from './handel_base'
5
5
  import TextHandler from './text'
6
6
  import sessions, { sessionKey } from '../sessions'
7
7
  import { canSendQQPage, sendQQMarkdown } from './qqPageMessage'
8
- import { titleCardFollowup } from './titleCardMessage'
8
+ import { renderQQImageFollowup } from './imageMessageFollowup'
9
9
  export default class ImageHandler extends handel_base {
10
10
  async start(data: MsgMessage) {
11
11
  let content = ''
@@ -32,16 +32,16 @@ export default class ImageHandler extends handel_base {
32
32
  }
33
33
  const sendStartedAt = this.timing ? performance.now() : 0
34
34
  await this.send(data, content, 'image')
35
- const followup = titleCardFollowup(data)
35
+ const followup = renderQQImageFollowup(data)
36
36
  if (!followup) return
37
37
  const session = sessions.get(sessionKey(data.botId, data.userId, data.channelId))
38
38
  let sentMarkdown = false
39
- if (canSendQQPage(session, data)) {
39
+ if (followup.markdown !== undefined && canSendQQPage(session, data)) {
40
40
  try {
41
- await sendQQMarkdown(session, followup.markdown)
41
+ await sendQQMarkdown(session, followup.markdown, followup.keyboard)
42
42
  sentMarkdown = true
43
43
  } catch (error) {
44
- this.ctx.logger.warn('称号操作 Markdown 发送失败,改用普通文字', error instanceof Error ? error.message : error)
44
+ this.ctx.logger.warn('图片操作 Markdown 发送失败,改用普通文字', error instanceof Error ? error.message : error)
45
45
  }
46
46
  }
47
47
  if (!sentMarkdown) await this.send({ ...data, notification: true }, followup.text)
@@ -0,0 +1,23 @@
1
+ import type { IMessage } from '../../shared/face'
2
+ import { appendMessageButtonFallbacks, messageButtonFallbackLines, withoutMessageButtonFallbacks } from '../../shared/bot/analysisMsg'
3
+ import { titleCardFollowup } from './titleCardMessage'
4
+ import { renderQQKeyboard, type QQKeyboard } from './qqMessageButtons'
5
+
6
+ export interface QQImageFollowup {
7
+ text: string
8
+ markdown?: string
9
+ keyboard?: QQKeyboard
10
+ }
11
+
12
+ /** Images are sent first; actions stay outside the image and retain a plain fallback. */
13
+ export function renderQQImageFollowup(data: IMessage): QQImageFollowup | undefined {
14
+ const title = titleCardFollowup(data)
15
+ const actionLines = messageButtonFallbackLines(data)
16
+ if (!title && !actionLines.length) return undefined
17
+ const text = appendMessageButtonFallbacks(title?.text ?? '', data).trimEnd()
18
+ let keyboard: QQKeyboard | undefined
19
+ try { keyboard = renderQQKeyboard(data) }
20
+ catch { return { text } }
21
+ const markdown = titleCardFollowup(withoutMessageButtonFallbacks(data))?.markdown ?? ' '
22
+ return { text, markdown, ...(keyboard ? { keyboard } : {}) }
23
+ }
@@ -1,3 +1,5 @@
1
+ import { withoutMessageButtonFallbacks } from '../../shared/bot/analysisMsg'
2
+ import { renderQQKeyboard } from './qqMessageButtons'
1
3
  import type { Session } from 'koishi'
2
4
  import { MessageType, type IMessage } from '../../shared/face'
3
5
  import type { MsgMessage } from '../../shared/bot/MsgMessage'
@@ -6,6 +8,7 @@ import { isRewardMessage, renderRewardLine, renderRewardTitle } from './qqReward
6
8
 
7
9
  /** Render command help and ordinary prose with clickable bracketed commands. */
8
10
  export function renderQQCommandMarkdown(data: IMessage, showGreeting: boolean): string {
11
+ data = withoutMessageButtonFallbacks(data)
9
12
  if (data.notification) {
10
13
  const onlyLine = data.line.length === 1 ? data.line[0] : undefined
11
14
  if (onlyLine?.type === MessageType.line && /^🔔系统提示:[^\r\n]+$/.test(onlyLine.content)) {
@@ -72,5 +75,5 @@ export function renderQQCommandMarkdown(data: IMessage, showGreeting: boolean):
72
75
  }
73
76
 
74
77
  export async function sendQQCommand(session: Session, data: MsgMessage): Promise<void> {
75
- await sendQQMarkdown(session, renderQQCommandMarkdown(data, session.event?.channel?.type === 0))
78
+ await sendQQMarkdown(session, renderQQCommandMarkdown(data, session.event?.channel?.type === 0), renderQQKeyboard(data))
76
79
  }
@@ -0,0 +1,38 @@
1
+ import type { IMessage } from '../../shared/face'
2
+
3
+ export interface QQKeyboard {
4
+ content: { rows: Array<{ buttons: Array<{
5
+ id: string
6
+ render_data: { label: string; visited_label: string; style: 0 | 1 }
7
+ action: {
8
+ type: 2
9
+ permission: { type: 2 }
10
+ data: string
11
+ enter: true
12
+ reply: false
13
+ unsupport_tips: string
14
+ }
15
+ }> }> }
16
+ }
17
+
18
+ /** Explicit actions use QQ's native keyboard; ordinary command links stay inline. */
19
+ export function renderQQKeyboard(data: Pick<IMessage, 'buttons'>): QQKeyboard | undefined {
20
+ const rows = data.buttons
21
+ if (!rows?.length) return undefined
22
+ if (rows.length > 5 || rows.some(row => !row.buttons.length || row.buttons.length > 5)) {
23
+ throw new Error('QQ 按钮最多 5 行,每行必须有 1–5 个按钮')
24
+ }
25
+ return { content: { rows: rows.map((row, rowIndex) => ({
26
+ buttons: row.buttons.map((button, buttonIndex) => {
27
+ if (!button.label.trim() || !button.command.trim() || /[\r\n\0]/.test(button.label + button.command)
28
+ || button.command.length > 4000 || (button.style !== undefined && button.style !== 'primary' && button.style !== 'secondary')) {
29
+ throw new Error('QQ 按钮文案和指令必须是非空单行文本,样式必须为 primary 或 secondary')
30
+ }
31
+ return {
32
+ id: 'action-' + rowIndex + '-' + buttonIndex,
33
+ render_data: { label: button.label, visited_label: button.label, style: button.style === 'primary' ? 1 : 0 },
34
+ action: { type: 2, permission: { type: 2 }, data: button.command, enter: true, reply: false, unsupport_tips: '请发送:' + button.command },
35
+ }
36
+ }),
37
+ })) } }
38
+ }
@@ -1,5 +1,6 @@
1
1
  import { type IMessage, MessageType } from '../../shared/face'
2
- import { AnalysisMsg } from '../../shared/bot/analysisMsg'
2
+ import { AnalysisMsg, withoutMessageButtonFallbacks } from '../../shared/bot/analysisMsg'
3
+ import { renderQQKeyboard, type QQKeyboard } from './qqMessageButtons'
3
4
  import { hasInlineCommands, renderQQPageMarkdown } from './qqPageMessage'
4
5
  import { renderQQCommandMarkdown } from './qqCommandMessage'
5
6
  import { renderQQTransactionMarkdown } from './qqTransactionMessage'
@@ -13,6 +14,8 @@ export interface QQMessagePreview {
13
14
  kind: QQMessageKind
14
15
  /** Exact native QQ Markdown payload; absent when the bot sends plain text. */
15
16
  markdown?: string
17
+ /** Native buttons accompany the same Markdown in bot delivery and editor preview. */
18
+ keyboard?: QQKeyboard
16
19
  /** Plain fallback before the transport adds its QQ group greeting. */
17
20
  text: string
18
21
  }
@@ -21,14 +24,22 @@ export interface QQMessagePreview {
21
24
  export function renderQQMessagePreview(data: IMessage, showGreeting = false): QQMessagePreview {
22
25
  const plain = new AnalysisMsg().parse(data)
23
26
  const text = data.notification ? plain.trimEnd() : plain
24
- if (data.notification) return { kind: 'command', markdown: renderQQCommandMarkdown(data, false), text }
25
- if (hasStoryCodeBlock(data)) return { kind: 'story', markdown: renderQQStoryMarkdown(data, showGreeting), text }
26
- if (data.commandHelp) return { kind: 'command', markdown: renderQQCommandMarkdown(data, showGreeting), text }
27
- if (data.transaction) return { kind: 'transaction', markdown: renderQQTransactionMarkdown(data, showGreeting), text }
28
- if (data.pageNav) return { kind: 'page', markdown: renderQQPageMarkdown(data, showGreeting), text }
29
- if (isRewardMessage(data)) return { kind: 'reward', markdown: renderQQRewardMarkdown(data, showGreeting), text }
27
+ let keyboard: QQKeyboard | undefined
28
+ try { keyboard = renderQQKeyboard(data) }
29
+ catch { return { kind: 'text', text } }
30
+ data = withoutMessageButtonFallbacks(data)
31
+ const native = (kind: QQMessageKind, markdown: string): QQMessagePreview => ({
32
+ kind, markdown, text, ...(keyboard ? { keyboard } : {}),
33
+ })
34
+ if (data.notification) return native('command', renderQQCommandMarkdown(data, false))
35
+ if (hasStoryCodeBlock(data)) return native('story', renderQQStoryMarkdown(data, showGreeting))
36
+ if (data.commandHelp) return native('command', renderQQCommandMarkdown(data, showGreeting))
37
+ if (data.transaction) return native('transaction', renderQQTransactionMarkdown(data, showGreeting))
38
+ if (data.pageNav) return native('page', renderQQPageMarkdown(data, showGreeting))
39
+ if (isRewardMessage(data)) return native('reward', renderQQRewardMarkdown(data, showGreeting))
30
40
  if (battleLogMessageLinks(data).length || data.line.some(item => (item.type === MessageType.line || item.type === MessageType.error) && hasInlineCommands(item.content))) {
31
- return { kind: 'command', markdown: renderQQCommandMarkdown(data, showGreeting), text }
41
+ return native('command', renderQQCommandMarkdown(data, showGreeting))
32
42
  }
43
+ if (keyboard) return native('command', renderQQCommandMarkdown(data, showGreeting))
33
44
  return { kind: 'text', text }
34
45
  }
@@ -1,6 +1,8 @@
1
1
  import type { Session } from 'koishi'
2
2
  import { MessageType, type IMessage, type PageNavData } from '../../shared/face'
3
3
  import type { MsgMessage } from '../../shared/bot/MsgMessage'
4
+ import { withoutMessageButtonFallbacks } from '../../shared/bot/analysisMsg'
5
+ import { renderQQKeyboard, type QQKeyboard } from './qqMessageButtons'
4
6
  import { compactDisplayText, escapeMarkdown, renderQQTitle } from './qqMarkdownHelpers'
5
7
  import { isRewardMessage, renderRewardLine, renderRewardTitle } from './qqRewardMessage'
6
8
  import { artworkIdentity } from '../skillArtwork'
@@ -13,6 +15,7 @@ type QQRequest = {
13
15
  content: string
14
16
  msg_type: 2
15
17
  markdown: { content: string }
18
+ keyboard?: QQKeyboard
16
19
  msg_id?: string
17
20
  msg_seq?: number
18
21
  }
@@ -216,6 +219,7 @@ function renderQQInventoryMarkdown(data: IMessage, showGreeting: boolean): strin
216
219
  }
217
220
 
218
221
  export function renderQQPageMarkdown(data: IMessage, showGreeting: boolean): string {
222
+ data = withoutMessageButtonFallbacks(data)
219
223
  const hasEntryLabels = data.pageNav?.entries?.some(entry => entry.label !== undefined) ?? false
220
224
  if (data.task && !hasEntryLabels) return renderQQTaskMarkdown(data)
221
225
  if (!hasEntryLabels && data.pageNav && data.line.some(item => item.type === MessageType.title
@@ -358,11 +362,11 @@ export function canSendQQPage(session: Session | undefined, data: MsgMessage): s
358
362
  /** Send a native QQ Markdown page with inline command links. */
359
363
  export async function sendQQPage(session: Session, data: MsgMessage): Promise<void> {
360
364
  const markdown = renderQQPageMarkdown(data, session.event?.channel?.type === 0)
361
- await sendQQMarkdown(session, markdown)
365
+ await sendQQMarkdown(session, markdown, renderQQKeyboard(data))
362
366
  }
363
367
 
364
368
  /** Reuse the adapter's native reply API for structured Markdown messages. */
365
- export async function sendQQMarkdown(session: Session, markdown: string): Promise<void> {
369
+ export async function sendQQMarkdown(session: Session, markdown: string, keyboard?: QQKeyboard): Promise<void> {
366
370
  const internal = (session.bot as typeof session.bot & { internal: QQInternal }).internal
367
371
  const request: QQRequest = {
368
372
  // With msg_type=2, QQ requires the ordinary content field to be empty.
@@ -370,6 +374,7 @@ export async function sendQQMarkdown(session: Session, markdown: string): Promis
370
374
  msg_type: 2,
371
375
  markdown: { content: markdown },
372
376
  }
377
+ if (keyboard) request.keyboard = keyboard
373
378
  // Match the QQ adapter's passive-reply sequence.
374
379
  const age = Date.now() - session.timestamp
375
380
  if (session.messageId && Number.isFinite(age) && age >= 0 && age < 5 * 60 * 1000 - 2000) {
@@ -1,3 +1,4 @@
1
+ import { withoutMessageButtonFallbacks } from '../../shared/bot/analysisMsg'
1
2
  import { MessageType, type IMessage } from '../../shared/face'
2
3
  import { compactDisplayText, escapeMarkdown } from './qqMarkdownHelpers'
3
4
  import { appendMissingBattleLogLinks, renderBattleLogLinkText } from './qqBattleLogLinks'
@@ -68,6 +69,7 @@ function renderQQMailMarkdown(data: IMessage): string {
68
69
 
69
70
  /** Reward cards stay compact; mail keeps its content before the attachment quote. */
70
71
  export function renderQQRewardMarkdown(data: IMessage, showGreeting: boolean): string {
72
+ data = withoutMessageButtonFallbacks(data)
71
73
  if (data.mail) return appendMissingBattleLogLinks(renderQQMailMarkdown(data), data)
72
74
  const rewards = data.line.filter(item => item.type === MessageType.line && item.reward)
73
75
  .map(item => renderRewardLine(item.content)).filter(Boolean)
@@ -1,3 +1,5 @@
1
+ import { withoutMessageButtonFallbacks } from '../../shared/bot/analysisMsg'
2
+ import { renderQQKeyboard } from './qqMessageButtons'
1
3
  import type { Session } from 'koishi'
2
4
  import { MessageType, type IMessage } from '../../shared/face'
3
5
  import type { MsgMessage } from '../../shared/bot/MsgMessage'
@@ -20,6 +22,7 @@ function fencedStory(content: string): string {
20
22
 
21
23
  /** Marked stories keep their complete text; settled rewards use the shared reward card. */
22
24
  export function renderQQStoryMarkdown(data: IMessage, showGreeting: boolean): string {
25
+ data = withoutMessageButtonFallbacks(data)
23
26
  if (data.reward === true && data.line.some(item => item.type === MessageType.line && item.reward === true && item.content.trim())) {
24
27
  const stories = data.line.filter(item => item.type === MessageType.line && item.codeBlock === true)
25
28
  .map(item => fencedStory(item.content))
@@ -75,5 +78,5 @@ export function renderQQStoryMarkdown(data: IMessage, showGreeting: boolean): st
75
78
  }
76
79
 
77
80
  export async function sendQQStory(session: Session, data: MsgMessage): Promise<void> {
78
- await sendQQMarkdown(session, renderQQStoryMarkdown(data, session.event?.channel?.type === 0))
81
+ await sendQQMarkdown(session, renderQQStoryMarkdown(data, session.event?.channel?.type === 0), renderQQKeyboard(data))
79
82
  }
@@ -1,3 +1,5 @@
1
+ import { withoutMessageButtonFallbacks } from '../../shared/bot/analysisMsg'
2
+ import { renderQQKeyboard } from './qqMessageButtons'
1
3
  import type { Session } from 'koishi'
2
4
  import { MessageType, type IMessage } from '../../shared/face'
3
5
  import type { MsgMessage } from '../../shared/bot/MsgMessage'
@@ -6,6 +8,7 @@ import { isRewardMessage, renderRewardLine, renderRewardTitle } from './qqReward
6
8
 
7
9
  /** Render only explicitly marked transactions; line content never enables this channel. */
8
10
  export function renderQQTransactionMarkdown(data: IMessage, showGreeting: boolean): string {
11
+ data = withoutMessageButtonFallbacks(data)
9
12
  const rewardMessage = isRewardMessage(data)
10
13
  const shortageMessage = data.transaction === true && data.line.some(item =>
11
14
  item.type === MessageType.title && item.content === '❌材料不足')
@@ -90,5 +93,5 @@ export function renderQQTransactionMarkdown(data: IMessage, showGreeting: boolea
90
93
  }
91
94
 
92
95
  export async function sendQQTransaction(session: Session, data: MsgMessage): Promise<void> {
93
- await sendQQMarkdown(session, renderQQTransactionMarkdown(data, session.event?.channel?.type === 0))
96
+ await sendQQMarkdown(session, renderQQTransactionMarkdown(data, session.event?.channel?.type === 0), renderQQKeyboard(data))
94
97
  }
@@ -12,7 +12,7 @@ export default class handelr_text extends handel_base {
12
12
  if (preview.markdown !== undefined && canSendQQPage(session, data)) {
13
13
  const sendStartedAt = this.timing ? performance.now() : 0
14
14
  try {
15
- await sendQQMarkdown(session, preview.markdown)
15
+ await sendQQMarkdown(session, preview.markdown, preview.keyboard)
16
16
  if (this.timing) {
17
17
  this.timing.delivery = 'qq-markdown'
18
18
  this.timing.sendMs = performance.now() - sendStartedAt
@@ -1,6 +1,39 @@
1
1
  import { IMessage, MessageType, type PageNavData } from '../face'
2
2
  import { renderPlainInlineCommandAliases } from './inlineCommands'
3
3
 
4
+ /** Platform-neutral button hints also work when Markdown or keyboards are unavailable. */
5
+ export function messageButtonFallbackLines(data: Pick<IMessage, 'buttons'>): string[] {
6
+ return [...new Set((data.buttons ?? []).flatMap(row => row.buttons.map(button =>
7
+ '[' + button.command + ']' + button.label)))]
8
+ }
9
+
10
+ function matchesButtonFallback(content: string, fallback: string): boolean {
11
+ const bracket = fallback.indexOf(']')
12
+ const prefix = fallback.slice(0, bracket + 1)
13
+ const line = content.trim()
14
+ return line.startsWith(prefix) && line.slice(prefix.length).trim() === fallback.slice(prefix.length).trim()
15
+ }
16
+
17
+ export function appendMessageButtonFallbacks(content: string, data: Pick<IMessage, 'buttons'>): string {
18
+ const lines = content.replace(/\r\n?/g, '\n').split('\n')
19
+ const missing = messageButtonFallbackLines(data).filter(fallback =>
20
+ !lines.some(line => matchesButtonFallback(line, fallback)))
21
+ if (!missing.length) return content
22
+ return content + (content && !content.endsWith('\n') ? '\n' : '') + missing.join('\n') + '\n'
23
+ }
24
+
25
+ /** Remove only complete action hints; keep source line indexes used by page metadata. */
26
+ export function withoutMessageButtonFallbacks<T extends IMessage>(data: T): T {
27
+ const fallbacks = messageButtonFallbackLines(data)
28
+ if (!fallbacks.length) return data
29
+ return { ...data, line: data.line.map(item => {
30
+ if (item.type !== MessageType.line || item.codeBlock || item.reward || item.mailPart !== undefined) return item
31
+ const lines = item.content.replace(/\r\n?/g, '\n').split('\n')
32
+ const kept = lines.filter(line => !fallbacks.some(fallback => matchesButtonFallback(line, fallback)))
33
+ return kept.length === lines.length ? item : { ...item, content: kept.join('\n') }
34
+ }) }
35
+ }
36
+
4
37
  /** 把服务器下发的结构化行拼成纯文本;第一阶段只处理 title / line / error **/
5
38
  export class AnalysisMsg {
6
39
  parse(data: IMessage) {
@@ -43,6 +76,7 @@ export class AnalysisMsg {
43
76
  break
44
77
  }
45
78
  }
79
+ msg = appendMessageButtonFallbacks(msg, data)
46
80
  if (haveTitle) {
47
81
  msg += '┄━════════━┄'
48
82
  }
@@ -25,7 +25,7 @@ export interface ServiceType {
25
25
  }
26
26
 
27
27
  export const serviceProto: ServiceProto<ServiceType> = {
28
- "version": 38,
28
+ "version": 39,
29
29
  "services": [
30
30
  {
31
31
  "id": 0,
@@ -253,6 +253,18 @@ export const serviceProto: ServiceProto<ServiceType> = {
253
253
  "target": "messageFace/TitleCardData"
254
254
  },
255
255
  "optional": true
256
+ },
257
+ {
258
+ "id": 17,
259
+ "name": "buttons",
260
+ "type": {
261
+ "type": "Array",
262
+ "elementType": {
263
+ "type": "Reference",
264
+ "target": "messageFace/MessageButtonRow"
265
+ }
266
+ },
267
+ "optional": true
256
268
  }
257
269
  ]
258
270
  },
@@ -1857,6 +1869,65 @@ export const serviceProto: ServiceProto<ServiceType> = {
1857
1869
  }
1858
1870
  ]
1859
1871
  },
1872
+ "messageFace/MessageButtonRow": {
1873
+ "type": "Interface",
1874
+ "properties": [
1875
+ {
1876
+ "id": 0,
1877
+ "name": "buttons",
1878
+ "type": {
1879
+ "type": "Array",
1880
+ "elementType": {
1881
+ "type": "Reference",
1882
+ "target": "messageFace/MessageButton"
1883
+ }
1884
+ }
1885
+ }
1886
+ ]
1887
+ },
1888
+ "messageFace/MessageButton": {
1889
+ "type": "Interface",
1890
+ "properties": [
1891
+ {
1892
+ "id": 0,
1893
+ "name": "label",
1894
+ "type": {
1895
+ "type": "String"
1896
+ }
1897
+ },
1898
+ {
1899
+ "id": 1,
1900
+ "name": "command",
1901
+ "type": {
1902
+ "type": "String"
1903
+ }
1904
+ },
1905
+ {
1906
+ "id": 2,
1907
+ "name": "style",
1908
+ "type": {
1909
+ "type": "Union",
1910
+ "members": [
1911
+ {
1912
+ "id": 0,
1913
+ "type": {
1914
+ "type": "Literal",
1915
+ "literal": "primary"
1916
+ }
1917
+ },
1918
+ {
1919
+ "id": 1,
1920
+ "type": {
1921
+ "type": "Literal",
1922
+ "literal": "secondary"
1923
+ }
1924
+ }
1925
+ ]
1926
+ },
1927
+ "optional": true
1928
+ }
1929
+ ]
1930
+ },
1860
1931
  "PtlMessage/ReqMessage": {
1861
1932
  "type": "Interface",
1862
1933
  "extends": [
@@ -198,6 +198,19 @@ export interface PageNavData {
198
198
  filters?: Array<{ name: string; emoji?: string; command: string }>;
199
199
  }
200
200
 
201
+ /** 平台无关的消息按钮;点击后发送普通指令,业务仍负责权限与状态校验。 */
202
+ export interface MessageButton {
203
+ label: string;
204
+ command: string;
205
+ /** 主操作蓝色线框,次操作灰色线框;缺省为次操作。 */
206
+ style?: 'primary' | 'secondary';
207
+ }
208
+
209
+ /** 同一行的按钮;QQ 原生键盘最多五行,每行最多五个。 */
210
+ export interface MessageButtonRow {
211
+ buttons: MessageButton[];
212
+ }
213
+
201
214
  /** 下发给客户端的消息主体 **/
202
215
  export interface IMessage {
203
216
  handleType: MessageHandleType;
@@ -224,4 +237,6 @@ export interface IMessage {
224
237
  line: IMessageItem[];
225
238
  companionCard?: CompanionCardData;
226
239
  titleCard?: TitleCardData;
240
+ /** 通用指令按钮;不支持原生按钮的平台保留文字指令。 */
241
+ buttons?: MessageButtonRow[];
227
242
  }