mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-17 22:00:47 +08:00
feat(qq): render answers as markdown
Deliver final Harness answers to QQ as msg_type=2 markdown so bold, headings, code blocks, lists, and tables render natively in QQ. - add markdown-reply.mjs: chunk long answers at code-fence/table boundaries (4500-char limit) and send each chunk with a unique msg_seq to avoid passive-reply dedup (40054005) - fall back to plain text per chunk when the platform rejects markdown, so bots without markdown capability keep working - route the non-stream final answer path in qq-bridge through the markdown sender; streaming replies already use markdown content type, and command/notice texts stay plain
This commit is contained in:
parent
29bcb67a64
commit
81884173df
5 changed files with 493 additions and 124 deletions
247
lib/index.js
247
lib/index.js
File diff suppressed because one or more lines are too long
135
src/channels/qq/markdown-reply.mjs
Normal file
135
src/channels/qq/markdown-reply.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
// QQ markdown 回复投递:长文按代码块/表格边界切分,以 msg_type=2 发送,
|
||||||
|
// 平台拒绝 markdown 时逐条回退纯文本。
|
||||||
|
|
||||||
|
const DEFAULT_CHUNK_LIMIT = 4_500;
|
||||||
|
const CODE_FENCE_OPEN = /^```/;
|
||||||
|
const GFM_TABLE_LINE = /^\|.+\|$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按换行边界切分 Markdown 文本:
|
||||||
|
* - 不在代码块中间断开;
|
||||||
|
* - 不在 GFM 表格中间断开;
|
||||||
|
* - 超长行在 limit 处硬切,避免单行超限无法投递。
|
||||||
|
*/
|
||||||
|
export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
|
||||||
|
const value = typeof text === 'string' ? text : '';
|
||||||
|
const bound = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CHUNK_LIMIT;
|
||||||
|
if (value.length <= bound) return value ? [value] : [];
|
||||||
|
|
||||||
|
const lines = value.split('\n');
|
||||||
|
const chunks = [];
|
||||||
|
let current = '';
|
||||||
|
let inCodeBlock = false;
|
||||||
|
let tableBuffer = [];
|
||||||
|
|
||||||
|
const appendBlock = (block) => {
|
||||||
|
if (block.length <= bound) {
|
||||||
|
if (!current) {
|
||||||
|
current = block;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const candidate = `${current}\n${block}`;
|
||||||
|
if (candidate.length > bound) {
|
||||||
|
chunks.push(current);
|
||||||
|
current = block;
|
||||||
|
} else {
|
||||||
|
current = candidate;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 超大块:收束当前块后按 bound 硬切,保证每块可投递。
|
||||||
|
if (current) {
|
||||||
|
chunks.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
let remaining = block;
|
||||||
|
while (remaining.length > bound) {
|
||||||
|
chunks.push(remaining.slice(0, bound));
|
||||||
|
remaining = remaining.slice(bound);
|
||||||
|
}
|
||||||
|
current = remaining;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushTable = () => {
|
||||||
|
if (tableBuffer.length === 0) return;
|
||||||
|
const block = tableBuffer.join('\n');
|
||||||
|
tableBuffer = [];
|
||||||
|
appendBlock(block);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendLine = (line) => {
|
||||||
|
let remaining = line;
|
||||||
|
// 超长行先硬切,保证每块不超过 bound。
|
||||||
|
while (remaining.length > bound) {
|
||||||
|
if (current) {
|
||||||
|
chunks.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
chunks.push(remaining.slice(0, bound));
|
||||||
|
remaining = remaining.slice(bound);
|
||||||
|
}
|
||||||
|
appendBlock(remaining);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (CODE_FENCE_OPEN.test(line)) {
|
||||||
|
flushTable();
|
||||||
|
if (!inCodeBlock && current) {
|
||||||
|
// 代码块开启:先收束当前块,让整个代码块从新块开始。
|
||||||
|
chunks.push(current);
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
inCodeBlock = !inCodeBlock;
|
||||||
|
appendLine(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCodeBlock) {
|
||||||
|
appendLine(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (GFM_TABLE_LINE.test(line)) {
|
||||||
|
tableBuffer.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flushTable();
|
||||||
|
appendLine(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
flushTable();
|
||||||
|
if (current) chunks.push(current);
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMsgSeq() {
|
||||||
|
// 与 SDK getNextMsgSeq 相同的随机策略:被动回复同 msg_id 的多条消息
|
||||||
|
// 各自带不同 msg_seq,避免平台去重(错误码 40054005)。
|
||||||
|
const timePart = Date.now() % 100_000_000;
|
||||||
|
const random = Math.floor(Math.random() * 65_536);
|
||||||
|
return (timePart ^ random) % 65_536;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以 markdown(msg_type=2)发送回复;单条被平台拒绝时回退纯文本(msg_type=0)。
|
||||||
|
* 返回每条消息的平台响应,供调用方提取 provider message ids。
|
||||||
|
*/
|
||||||
|
export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
||||||
|
const chunks = chunkMarkdownText(text);
|
||||||
|
const results = [];
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
if (typeof bot?.send === 'function') {
|
||||||
|
try {
|
||||||
|
results.push(await bot.send({
|
||||||
|
target,
|
||||||
|
msgType: 2,
|
||||||
|
markdown: { content: chunk },
|
||||||
|
extra: { msg_seq: nextMsgSeq() },
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
} catch (error) {
|
||||||
|
logger?.warn?.('[dsh-im:qq] markdown delivery failed; retrying as plain text:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.push(await bot.sendText(target, chunk));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,7 @@ import {
|
||||||
mergeDeliveryReceipts,
|
mergeDeliveryReceipts,
|
||||||
providerMessageIdsFor,
|
providerMessageIdsFor,
|
||||||
} from '../shared/semantic/delivery.mjs';
|
} from '../shared/semantic/delivery.mjs';
|
||||||
|
import { sendMarkdownReply } from './markdown-reply.mjs';
|
||||||
|
|
||||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||||
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
||||||
|
|
@ -661,11 +662,13 @@ export class QqHarnessBridge {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!streamFinished) {
|
if (!streamFinished) {
|
||||||
const sent = await this.#bot.sendText(target, displayAnswer);
|
const deliveries = await sendMarkdownReply(this.#bot, target, displayAnswer, {
|
||||||
|
logger: this.#logger,
|
||||||
|
});
|
||||||
textReceipt = createDeliveryReceipt({
|
textReceipt = createDeliveryReceipt({
|
||||||
deliveryId: messageId,
|
deliveryId: messageId,
|
||||||
presentation: 'qq-text',
|
presentation: 'qq-text',
|
||||||
providerMessageIds: providerMessageIdsFor(sent),
|
providerMessageIds: deliveries.flatMap((delivery) => providerMessageIdsFor(delivery)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,78 @@ test('QQ private messages stream Harness snapshots and finalize once', async ()
|
||||||
assert.equal(bridge.status.messagesReplied, 1);
|
assert.equal(bridge.status.messagesReplied, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('QQ delivers final group answers as markdown messages', async () => {
|
||||||
|
const sentText = [];
|
||||||
|
const markdownCalls = [];
|
||||||
|
const bridge = new QqHarnessBridge({
|
||||||
|
bot: {
|
||||||
|
sendText: async (_target, text) => sentText.push(text),
|
||||||
|
send: async (options) => {
|
||||||
|
markdownCalls.push(options);
|
||||||
|
return { id: `md-${markdownCalls.length}` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ownerUserOpenid: 'owner-openid',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async () => '## 标题\n\n**加粗**与 `代码`',
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
hasSeen: () => false,
|
||||||
|
markSeen: async () => {},
|
||||||
|
sessionFor: () => 'session-group',
|
||||||
|
setSession: async () => {},
|
||||||
|
clearSession: async () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message({
|
||||||
|
kind: 'group',
|
||||||
|
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
|
||||||
|
groupOpenid: 'group-md',
|
||||||
|
messageId: 'msg-group-md',
|
||||||
|
content: '请回答',
|
||||||
|
replyTarget: { scope: 'group', targetId: 'group-md', msgId: 'msg-group-md' },
|
||||||
|
}));
|
||||||
|
assert.equal(markdownCalls.length, 1);
|
||||||
|
assert.equal(markdownCalls[0].msgType, 2);
|
||||||
|
assert.equal(markdownCalls[0].markdown.content, '## 标题\n\n**加粗**与 `代码`');
|
||||||
|
assert.equal(Number.isInteger(markdownCalls[0].extra.msg_seq), true);
|
||||||
|
assert.deepEqual(sentText, []);
|
||||||
|
assert.equal(bridge.status.messagesReplied, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QQ falls back to plain text when the platform rejects markdown', async () => {
|
||||||
|
const sentText = [];
|
||||||
|
const bridge = new QqHarnessBridge({
|
||||||
|
bot: {
|
||||||
|
sendText: async (_target, text) => sentText.push(text),
|
||||||
|
send: async () => { throw new Error('markdown rejected'); },
|
||||||
|
},
|
||||||
|
ownerUserOpenid: 'owner-openid',
|
||||||
|
harness: {
|
||||||
|
sessionExists: async () => true,
|
||||||
|
ask: async () => '**回答**内容',
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
hasSeen: () => false,
|
||||||
|
markSeen: async () => {},
|
||||||
|
sessionFor: () => 'session-fallback',
|
||||||
|
setSession: async () => {},
|
||||||
|
clearSession: async () => {},
|
||||||
|
},
|
||||||
|
logger: { warn() {}, error() {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(message({
|
||||||
|
messageId: 'msg-md-fallback',
|
||||||
|
replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'msg-md-fallback' },
|
||||||
|
}));
|
||||||
|
assert.deepEqual(sentText, ['**回答**内容']);
|
||||||
|
assert.equal(bridge.status.messagesReplied, 1);
|
||||||
|
assert.equal(bridge.status.lastError, null);
|
||||||
|
});
|
||||||
|
|
||||||
test('QQ closes an opened progress stream and announces when the Harness turn is stopped', async () => {
|
test('QQ closes an opened progress stream and announces when the Harness turn is stopped', async () => {
|
||||||
const fixture = stateFixture([['c2c:owner-openid', 'session-stopped']]);
|
const fixture = stateFixture([['c2c:owner-openid', 'session-stopped']]);
|
||||||
const frames = [];
|
const frames = [];
|
||||||
|
|
|
||||||
156
test/channels/qq/markdown-reply.test.mjs
Normal file
156
test/channels/qq/markdown-reply.test.mjs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
chunkMarkdownText,
|
||||||
|
sendMarkdownReply,
|
||||||
|
} from '../../../src/channels/qq/markdown-reply.mjs';
|
||||||
|
|
||||||
|
const target = { scope: 'c2c', targetId: 'user-openid', msgId: 'msg-1' };
|
||||||
|
|
||||||
|
test('chunkMarkdownText keeps short text as a single chunk', () => {
|
||||||
|
assert.deepEqual(chunkMarkdownText('**你好**,世界'), ['**你好**,世界']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chunkMarkdownText returns no chunks for empty text', () => {
|
||||||
|
assert.deepEqual(chunkMarkdownText(''), []);
|
||||||
|
assert.deepEqual(chunkMarkdownText(null), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chunkMarkdownText splits long text within the limit', () => {
|
||||||
|
const text = Array.from({ length: 200 }, (_, index) => `第${index}行内容`).join('\n');
|
||||||
|
const chunks = chunkMarkdownText(text, 100);
|
||||||
|
assert.ok(chunks.length > 1);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
assert.ok(chunk.length <= 100, `chunk exceeds the limit: ${chunk.length}`);
|
||||||
|
}
|
||||||
|
assert.equal(chunks.join('\n'), text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chunkMarkdownText does not break inside a code block that fits the limit', () => {
|
||||||
|
const code = '```js\nconsole.log(1);\n```';
|
||||||
|
const text = `${'A'.repeat(80)}\n\n${code}\n\n${'B'.repeat(80)}`;
|
||||||
|
const chunks = chunkMarkdownText(text, 100);
|
||||||
|
const codeChunk = chunks.find((chunk) => chunk.includes('```js'));
|
||||||
|
assert.ok(codeChunk);
|
||||||
|
assert.ok(codeChunk.startsWith(code));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chunkMarkdownText still chunks an oversized code block within the limit', () => {
|
||||||
|
const code = ['```js', ...Array.from({ length: 30 }, (_, i) => `console.log(${i});`), '```']
|
||||||
|
.join('\n');
|
||||||
|
const chunks = chunkMarkdownText(code, 100);
|
||||||
|
assert.ok(chunks.length > 1);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
assert.ok(chunk.length <= 100, `chunk exceeds the limit: ${chunk.length}`);
|
||||||
|
}
|
||||||
|
assert.equal(chunks.join('\n'), code);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chunkMarkdownText keeps a GFM table together', () => {
|
||||||
|
const table = [
|
||||||
|
'| 列一 | 列二 |',
|
||||||
|
'| --- | --- |',
|
||||||
|
'| a | b |',
|
||||||
|
'| c | d |',
|
||||||
|
].join('\n');
|
||||||
|
const chunks = chunkMarkdownText(table, 200);
|
||||||
|
assert.equal(chunks.length, 1);
|
||||||
|
assert.equal(chunks[0], table);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chunkMarkdownText hard-splits an oversized single line', () => {
|
||||||
|
const line = 'x'.repeat(250);
|
||||||
|
const chunks = chunkMarkdownText(line, 100);
|
||||||
|
assert.deepEqual(chunks, ['x'.repeat(100), 'x'.repeat(100), 'x'.repeat(50)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendMarkdownReply sends markdown with unique msg_seq per chunk', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const results = await sendMarkdownReply({
|
||||||
|
send: async (options) => {
|
||||||
|
calls.push(options);
|
||||||
|
return { id: `id-${calls.length}` };
|
||||||
|
},
|
||||||
|
sendText: async () => {
|
||||||
|
throw new Error('sendText must not be called when markdown succeeds');
|
||||||
|
},
|
||||||
|
}, target, '# 标题\n\n**加粗**内容');
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].target, target);
|
||||||
|
assert.equal(calls[0].msgType, 2);
|
||||||
|
assert.equal(calls[0].markdown.content, '# 标题\n\n**加粗**内容');
|
||||||
|
assert.equal(Number.isInteger(calls[0].extra.msg_seq), true);
|
||||||
|
assert.deepEqual(results, [{ id: 'id-1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendMarkdownReply assigns distinct msg_seq values across chunks', async () => {
|
||||||
|
const seqs = [];
|
||||||
|
const bot = {
|
||||||
|
send: async ({ extra }) => {
|
||||||
|
seqs.push(extra.msg_seq);
|
||||||
|
return { id: 'x' };
|
||||||
|
},
|
||||||
|
sendText: async () => { throw new Error('unexpected'); },
|
||||||
|
};
|
||||||
|
const text = Array.from({ length: 1_500 }, (_, index) => `第${index}行`).join('\n');
|
||||||
|
await sendMarkdownReply(bot, target, text);
|
||||||
|
assert.ok(seqs.length > 1);
|
||||||
|
assert.equal(new Set(seqs).size, seqs.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendMarkdownReply falls back to plain text per chunk on markdown rejection', async () => {
|
||||||
|
const sentText = [];
|
||||||
|
const warnings = [];
|
||||||
|
const results = await sendMarkdownReply({
|
||||||
|
send: async () => {
|
||||||
|
throw new Error('markdown rejected: no permission');
|
||||||
|
},
|
||||||
|
sendText: async (_target, text) => {
|
||||||
|
sentText.push(text);
|
||||||
|
return { id: `text-${sentText.length}` };
|
||||||
|
},
|
||||||
|
}, target, '回答内容', { logger: { warn: (...args) => warnings.push(args) } });
|
||||||
|
assert.deepEqual(sentText, ['回答内容']);
|
||||||
|
assert.deepEqual(results, [{ id: 'text-1' }]);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendMarkdownReply uses sendText directly when the bot lacks send()', async () => {
|
||||||
|
const sentText = [];
|
||||||
|
const results = await sendMarkdownReply({
|
||||||
|
sendText: async (_target, text) => {
|
||||||
|
sentText.push(text);
|
||||||
|
return { id: 'plain-1' };
|
||||||
|
},
|
||||||
|
}, target, '没有 send 方法的机器人');
|
||||||
|
assert.deepEqual(sentText, ['没有 send 方法的机器人']);
|
||||||
|
assert.deepEqual(results, [{ id: 'plain-1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendMarkdownReply delivers long answers as multiple markdown chunks', async () => {
|
||||||
|
const markdownChunks = [];
|
||||||
|
const bot = {
|
||||||
|
send: async ({ markdown }) => {
|
||||||
|
markdownChunks.push(markdown.content);
|
||||||
|
return { id: `id-${markdownChunks.length}` };
|
||||||
|
},
|
||||||
|
sendText: async () => { throw new Error('unexpected'); },
|
||||||
|
};
|
||||||
|
const text = Array.from({ length: 600 }, (_, index) => `- 列表项 ${index}`).join('\n');
|
||||||
|
const results = await sendMarkdownReply(bot, target, text);
|
||||||
|
assert.ok(markdownChunks.length > 1);
|
||||||
|
for (const chunk of markdownChunks) {
|
||||||
|
assert.ok(chunk.length <= 4_500);
|
||||||
|
}
|
||||||
|
assert.equal(results.length, markdownChunks.length);
|
||||||
|
assert.equal(markdownChunks.join('\n'), text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendMarkdownReply returns no deliveries for empty text', async () => {
|
||||||
|
const results = await sendMarkdownReply({
|
||||||
|
send: async () => { throw new Error('unexpected'); },
|
||||||
|
sendText: async () => { throw new Error('unexpected'); },
|
||||||
|
}, target, '');
|
||||||
|
assert.deepEqual(results, []);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue