mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 09:05:46 +08:00
fix(qq): harden markdown reply delivery
This commit is contained in:
parent
f4deb4ac20
commit
ea5176be93
6 changed files with 907 additions and 253 deletions
355
lib/index.js
355
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -2,10 +2,11 @@
|
|||
// 平台拒绝 markdown 时逐条回退纯文本。
|
||||
|
||||
import { t } from '../shared/i18n.mjs';
|
||||
import { ApiError } from '@tencent-connect/qqbot-nodejs';
|
||||
|
||||
const DEFAULT_CHUNK_LIMIT = 4_500;
|
||||
const CODE_FENCE_OPEN = /^```/;
|
||||
const GFM_TABLE_LINE = /^\|.+\|$/;
|
||||
const CODE_FENCE_OPEN = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
||||
const MARKDOWN_REJECTION_CODES = new Set([40_034_090]);
|
||||
const PASSIVE_REPLY_LIMIT = Object.freeze({ c2c: 4, group: 5 });
|
||||
const PARTIAL_REPLY_NOTICE = () => t('回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。');
|
||||
|
||||
|
|
@ -19,32 +20,253 @@ function safeSliceIndex(value, limit) {
|
|||
return Math.max(1, index);
|
||||
}
|
||||
|
||||
function openingFence(line) {
|
||||
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
||||
const match = CODE_FENCE_OPEN.exec(normalized);
|
||||
if (!match) return null;
|
||||
// CommonMark forbids backticks in the info string of a backtick fence.
|
||||
if (match[2][0] === '`' && match[3].includes('`')) return null;
|
||||
return { delimiter: match[2], info: match[3], indent: match[1].length };
|
||||
}
|
||||
|
||||
function closesFence(line, opening) {
|
||||
const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
||||
return Boolean(match
|
||||
&& match[1][0] === opening.delimiter[0]
|
||||
&& match[1].length >= opening.delimiter.length);
|
||||
}
|
||||
|
||||
function longestLeadingRun(text, character) {
|
||||
let longest = 0;
|
||||
for (const line of text.split('\n')) {
|
||||
let offset = 0;
|
||||
while (offset < 3 && line[offset] === ' ') offset += 1;
|
||||
let length = 0;
|
||||
while (line[offset + length] === character) length += 1;
|
||||
longest = Math.max(longest, length);
|
||||
}
|
||||
return longest;
|
||||
}
|
||||
|
||||
function safeCodeFence(text, info) {
|
||||
const backticks = '`'.repeat(Math.max(3, longestLeadingRun(text, '`') + 1));
|
||||
const tildes = '~'.repeat(Math.max(3, longestLeadingRun(text, '~') + 1));
|
||||
if (info.includes('`')) return tildes;
|
||||
return backticks.length <= tildes.length ? backticks : tildes;
|
||||
}
|
||||
|
||||
function removeFenceIndent(line, indent) {
|
||||
let index = 0;
|
||||
let column = 0;
|
||||
let remaining = indent;
|
||||
while (remaining > 0 && index < line.length) {
|
||||
if (line[index] === ' ') {
|
||||
index += 1;
|
||||
column += 1;
|
||||
remaining -= 1;
|
||||
continue;
|
||||
}
|
||||
if (line[index] === '\t') {
|
||||
const width = 4 - (column % 4);
|
||||
if (width <= remaining) {
|
||||
index += 1;
|
||||
column += width;
|
||||
remaining -= width;
|
||||
continue;
|
||||
}
|
||||
return `${' '.repeat(width - remaining)}${line.slice(index + 1)}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return line.slice(index);
|
||||
}
|
||||
|
||||
function normalizeFencedContent(lines, indent) {
|
||||
return indent === 0 ? lines : lines.map((line) => removeFenceIndent(line, indent));
|
||||
}
|
||||
|
||||
function splitRawText(text, limit) {
|
||||
if (text.length <= limit) return [text];
|
||||
const parts = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > limit) {
|
||||
let index = safeSliceIndex(remaining, limit);
|
||||
const newline = remaining.lastIndexOf('\n', index - 1);
|
||||
if (newline >= 0) index = newline + 1;
|
||||
parts.push(remaining.slice(0, index));
|
||||
remaining = remaining.slice(index);
|
||||
}
|
||||
if (remaining || parts.length === 0) parts.push(remaining);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function splitAsFencedCode(text, info, limit) {
|
||||
if (limit < 8) return splitRawText(text, limit).map((part) => ({
|
||||
markdown: part,
|
||||
plain: part,
|
||||
}));
|
||||
const delimiter = safeCodeFence(text, info);
|
||||
let opening = `${delimiter}${info}`;
|
||||
if (opening.length + delimiter.length + 2 >= limit) opening = delimiter;
|
||||
const payloadLimit = limit - opening.length - delimiter.length - 2;
|
||||
if (payloadLimit < 1) return splitRawText(text, limit).map((part) => ({
|
||||
markdown: part,
|
||||
plain: part,
|
||||
}));
|
||||
return splitRawText(text, payloadLimit).map((part) => (
|
||||
{
|
||||
markdown: `${opening}\n${part}${part.endsWith('\n') ? '' : '\n'}${delimiter}`,
|
||||
plain: part,
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
function hasClosingBacktickRun(text, start, length) {
|
||||
for (let index = start; index < text.length;) {
|
||||
if (text[index] !== '`') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let end = index + 1;
|
||||
while (text[end] === '`') end += 1;
|
||||
if (end - index === length) return true;
|
||||
index = end;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function gfmTableCells(line) {
|
||||
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
||||
if (normalized.startsWith(' ') || normalized.startsWith('\t')) return null;
|
||||
const text = normalized.trim();
|
||||
if (!text) return null;
|
||||
|
||||
const cells = [];
|
||||
let cell = '';
|
||||
let separators = 0;
|
||||
let codeRun = 0;
|
||||
for (let index = 0; index < text.length;) {
|
||||
const character = text[index];
|
||||
if (character === '\\' && index + 1 < text.length) {
|
||||
cell += text.slice(index, index + 2);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (character === '`') {
|
||||
let end = index + 1;
|
||||
while (text[end] === '`') end += 1;
|
||||
const length = end - index;
|
||||
if (codeRun === length) {
|
||||
codeRun = 0;
|
||||
} else if (codeRun === 0 && hasClosingBacktickRun(text, end, length)) {
|
||||
codeRun = length;
|
||||
}
|
||||
cell += text.slice(index, end);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (character === '|' && codeRun === 0) {
|
||||
cells.push(cell);
|
||||
cell = '';
|
||||
separators += 1;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
cell += character;
|
||||
index += 1;
|
||||
}
|
||||
if (separators === 0) return null;
|
||||
cells.push(cell);
|
||||
if (cells[0].trim() === '') cells.shift();
|
||||
if (cells.at(-1)?.trim() === '') cells.pop();
|
||||
return cells.length > 0 ? cells : null;
|
||||
}
|
||||
|
||||
function gfmTableStart(lines, index) {
|
||||
const headerCells = gfmTableCells(lines[index]);
|
||||
const separatorCells = gfmTableCells(lines[index + 1] ?? '');
|
||||
if (!headerCells || !separatorCells || headerCells.length !== separatorCells.length) return null;
|
||||
if (!separatorCells.every((cell) => /^\s*:?-+:?\s*$/.test(cell))) return null;
|
||||
return { headerCells };
|
||||
}
|
||||
|
||||
function supportedTableBodyCells(line) {
|
||||
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
||||
const content = normalized.replace(/^ {0,3}/, '');
|
||||
if (openingFence(normalized)
|
||||
|| /^(?:#{1,6}(?:[ \t]+|$)|>|(?:[-+*]|\d{1,9}[.)])[ \t]+)/.test(content)) {
|
||||
return null;
|
||||
}
|
||||
return gfmTableCells(normalized);
|
||||
}
|
||||
|
||||
function splitOversizedTable(lines, limit) {
|
||||
const source = lines.join('\n');
|
||||
if (lines.length < 2 || !gfmTableStart(lines, 0)) {
|
||||
return splitAsFencedCode(source, 'text', limit);
|
||||
}
|
||||
const prefix = `${lines[0]}\n${lines[1]}`;
|
||||
const rows = lines.slice(2);
|
||||
if (prefix.length > limit
|
||||
|| rows.some((row) => `${prefix}\n${row}`.length > limit)) {
|
||||
return splitAsFencedCode(source, 'text', limit);
|
||||
}
|
||||
const chunks = [];
|
||||
let current = prefix;
|
||||
let currentRows = [];
|
||||
for (const row of rows) {
|
||||
const candidate = `${current}\n${row}`;
|
||||
if (candidate.length > limit) {
|
||||
chunks.push({
|
||||
markdown: current,
|
||||
plain: chunks.length === 0 ? current : currentRows.join('\n'),
|
||||
});
|
||||
current = `${prefix}\n${row}`;
|
||||
currentRows = [row];
|
||||
} else {
|
||||
current = candidate;
|
||||
currentRows.push(row);
|
||||
}
|
||||
}
|
||||
chunks.push({
|
||||
markdown: current,
|
||||
plain: chunks.length === 0 ? current : currentRows.join('\n'),
|
||||
});
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按换行边界切分 Markdown 文本:
|
||||
* - 不在代码块中间断开;
|
||||
* - 不在 GFM 表格中间断开;
|
||||
* - 可容纳的代码块和可识别的 GFM pipe table 保持完整;
|
||||
* - 超长代码块会补齐围栏,超长表格会为每段重复表头;
|
||||
* - 超长行在 limit 处硬切,避免单行超限无法投递。
|
||||
*/
|
||||
export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
|
||||
function chunkMarkdownParts(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] : [];
|
||||
if (value.length <= bound) return value ? [{ markdown: value, plain: value }] : [];
|
||||
|
||||
const lines = value.split('\n');
|
||||
// Once splitting is required, normalize line endings so a chunk never ends
|
||||
// with a dangling CR whose paired LF moved to the next QQ message.
|
||||
const lines = value.replace(/\r\n?/g, '\n').split('\n');
|
||||
const chunks = [];
|
||||
let current = '';
|
||||
let inCodeBlock = false;
|
||||
let tableBuffer = [];
|
||||
let current = null;
|
||||
|
||||
const flushCurrent = () => {
|
||||
if (current === null) return;
|
||||
if (current.length > 0) chunks.push({ markdown: current, plain: current });
|
||||
current = null;
|
||||
};
|
||||
|
||||
const appendBlock = (block) => {
|
||||
if (block.length <= bound) {
|
||||
if (!current) {
|
||||
if (current === null) {
|
||||
current = block;
|
||||
return;
|
||||
}
|
||||
const candidate = `${current}\n${block}`;
|
||||
if (candidate.length > bound) {
|
||||
chunks.push(current);
|
||||
if (current.length > 0) chunks.push({ markdown: current, plain: current });
|
||||
current = block;
|
||||
} else {
|
||||
current = candidate;
|
||||
|
|
@ -52,85 +274,112 @@ export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
|
|||
return;
|
||||
}
|
||||
// 超大块:收束当前块后按 bound 硬切,保证每块可投递。
|
||||
if (current) {
|
||||
chunks.push(current);
|
||||
current = '';
|
||||
}
|
||||
flushCurrent();
|
||||
let remaining = block;
|
||||
while (remaining.length > bound) {
|
||||
const index = safeSliceIndex(remaining, bound);
|
||||
chunks.push(remaining.slice(0, index));
|
||||
const part = remaining.slice(0, index);
|
||||
chunks.push({ markdown: part, plain: part });
|
||||
remaining = remaining.slice(index);
|
||||
}
|
||||
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 = '';
|
||||
}
|
||||
flushCurrent();
|
||||
const index = safeSliceIndex(remaining, bound);
|
||||
chunks.push(remaining.slice(0, index));
|
||||
const part = remaining.slice(0, index);
|
||||
chunks.push({ markdown: part, plain: part });
|
||||
remaining = remaining.slice(index);
|
||||
}
|
||||
appendBlock(remaining);
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (CODE_FENCE_OPEN.test(line)) {
|
||||
flushTable();
|
||||
if (!inCodeBlock && current) {
|
||||
// 代码块开启:先收束当前块,让整个代码块从新块开始。
|
||||
chunks.push(current);
|
||||
current = '';
|
||||
for (let index = 0; index < lines.length;) {
|
||||
const fence = openingFence(lines[index]);
|
||||
if (fence) {
|
||||
flushCurrent();
|
||||
let end = index + 1;
|
||||
while (end < lines.length && !closesFence(lines[end], fence)) end += 1;
|
||||
const hasClosingFence = end < lines.length;
|
||||
const blockLines = lines.slice(index, hasClosingFence ? end + 1 : lines.length);
|
||||
const block = blockLines.join('\n');
|
||||
if (block.length <= bound) {
|
||||
appendBlock(block);
|
||||
} else {
|
||||
const contentLines = blockLines.slice(1, hasClosingFence ? -1 : undefined);
|
||||
const content = normalizeFencedContent(contentLines, fence.indent).join('\n');
|
||||
chunks.push(...splitAsFencedCode(content, fence.info, bound));
|
||||
}
|
||||
inCodeBlock = !inCodeBlock;
|
||||
appendLine(line);
|
||||
index = hasClosingFence ? end + 1 : lines.length;
|
||||
continue;
|
||||
}
|
||||
if (inCodeBlock) {
|
||||
appendLine(line);
|
||||
|
||||
if (gfmTableStart(lines, index)) {
|
||||
let end = index + 2;
|
||||
while (end < lines.length && supportedTableBodyCells(lines[end])) end += 1;
|
||||
const tableLines = lines.slice(index, end);
|
||||
const table = tableLines.join('\n');
|
||||
if (table.length <= bound) {
|
||||
appendBlock(table);
|
||||
} else {
|
||||
flushCurrent();
|
||||
chunks.push(...splitOversizedTable(tableLines, bound));
|
||||
}
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (GFM_TABLE_LINE.test(line)) {
|
||||
tableBuffer.push(line);
|
||||
continue;
|
||||
}
|
||||
flushTable();
|
||||
appendLine(line);
|
||||
|
||||
appendLine(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
flushTable();
|
||||
if (current) chunks.push(current);
|
||||
return chunks;
|
||||
flushCurrent();
|
||||
return chunks.filter(({ markdown, plain }) => markdown.length > 0 || plain.length > 0);
|
||||
}
|
||||
|
||||
export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
|
||||
return chunkMarkdownParts(text, limit).map(({ markdown }) => markdown);
|
||||
}
|
||||
|
||||
function nextMsgSeq() {
|
||||
// 与 SDK getNextMsgSeq 相同的随机策略:被动回复同 msg_id 的多条消息
|
||||
// 各自带不同 msg_seq,避免平台去重(错误码 40054005)。
|
||||
// 与 SDK getNextMsgSeq 相同的 16-bit 随机 seed;同批分片在 seed 上递增,
|
||||
// 保证同一个被动回复 msg_id 内不发生随机碰撞。
|
||||
const timePart = Date.now() % 100_000_000;
|
||||
const random = Math.floor(Math.random() * 65_536);
|
||||
return (timePart ^ random) % 65_536;
|
||||
}
|
||||
|
||||
function isMarkdownRejection(error) {
|
||||
return error instanceof ApiError
|
||||
&& error.httpStatus >= 400
|
||||
&& error.httpStatus < 500
|
||||
&& MARKDOWN_REJECTION_CODES.has(Number(error.bizCode));
|
||||
}
|
||||
|
||||
function sendPlainText(bot, target, content, msgSeq) {
|
||||
if (typeof bot?.send === 'function') {
|
||||
return bot.send({
|
||||
target,
|
||||
msgType: 0,
|
||||
content,
|
||||
extra: { msg_seq: msgSeq },
|
||||
});
|
||||
}
|
||||
return bot.sendText(target, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 markdown(msg_type=2)发送回复;单条被平台拒绝时回退纯文本(msg_type=0)。
|
||||
* 返回每条消息的平台响应,供调用方提取 provider message ids。
|
||||
*/
|
||||
export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
||||
const chunks = chunkMarkdownText(text);
|
||||
const chunks = chunkMarkdownParts(text);
|
||||
const results = [];
|
||||
const firstMsgSeq = nextMsgSeq();
|
||||
const passiveLimit = target?.msgId ? PASSIVE_REPLY_LIMIT[target.scope] : null;
|
||||
const overflow = passiveLimit !== null && chunks.length > passiveLimit;
|
||||
const passiveContentCount = overflow ? passiveLimit - 1 : chunks.length;
|
||||
|
|
@ -143,13 +392,19 @@ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
|||
if (partialNoticeSent || !target?.msgId) return;
|
||||
partialNoticeSent = true;
|
||||
try {
|
||||
results.push(await bot.sendText(target, PARTIAL_REPLY_NOTICE()));
|
||||
results.push(await sendPlainText(
|
||||
bot,
|
||||
target,
|
||||
PARTIAL_REPLY_NOTICE(),
|
||||
(firstMsgSeq + chunks.length) & 0xFFFF,
|
||||
));
|
||||
} catch (error) {
|
||||
logger?.warn?.('[dsh-im:qq] unable to send partial reply notice:', error);
|
||||
}
|
||||
};
|
||||
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
const msgSeq = (firstMsgSeq + index) & 0xFFFF;
|
||||
const deliveryTarget = overflow && index >= passiveContentCount
|
||||
? proactiveTarget
|
||||
: target;
|
||||
|
|
@ -158,16 +413,29 @@ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
|||
results.push(await bot.send({
|
||||
target: deliveryTarget,
|
||||
msgType: 2,
|
||||
markdown: { content: chunk },
|
||||
extra: { msg_seq: nextMsgSeq() },
|
||||
markdown: { content: chunk.markdown },
|
||||
extra: { msg_seq: msgSeq },
|
||||
}));
|
||||
continue;
|
||||
} catch (error) {
|
||||
if (!isMarkdownRejection(error)) {
|
||||
logger?.warn?.(
|
||||
'[dsh-im:qq] markdown delivery outcome is uncertain; refusing a duplicate-prone retry:',
|
||||
error,
|
||||
);
|
||||
if (results.length === 0) throw error;
|
||||
await sendPartialNotice();
|
||||
break;
|
||||
}
|
||||
logger?.warn?.('[dsh-im:qq] markdown delivery failed; retrying as plain text:', error);
|
||||
}
|
||||
}
|
||||
// Synthetic Markdown can represent an empty fenced-code body even though
|
||||
// its plain equivalent is empty. Never turn that into an invalid QQ text
|
||||
// message after a definite Markdown rejection (or on legacy text clients).
|
||||
if (chunk.plain.length === 0) continue;
|
||||
try {
|
||||
results.push(await bot.sendText(deliveryTarget, chunk));
|
||||
results.push(await sendPlainText(bot, deliveryTarget, chunk.plain, msgSeq));
|
||||
} catch (error) {
|
||||
if (results.length === 0) throw error;
|
||||
await sendPartialNotice();
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ export class QqRuntime {
|
|||
logger: sdkLogger,
|
||||
transport: 'websocket',
|
||||
tokenPrefetch: 'sync',
|
||||
// sendText is reserved for literal notices/connection tests. Markdown
|
||||
// replies use the explicit msg_type=2 path in sendMarkdownReply().
|
||||
markdownSupport: false,
|
||||
});
|
||||
if (!bot || typeof bot.start !== 'function' || typeof bot.stop !== 'function') {
|
||||
throw new TypeError('QQ bot factory returned an invalid client');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { ApiError } from '@tencent-connect/qqbot-nodejs';
|
||||
|
||||
import {
|
||||
createQqBridgeStatus,
|
||||
|
|
@ -809,12 +810,17 @@ test('QQ remembers any authorized private inbound as a connection-test target',
|
|||
});
|
||||
|
||||
test('QQ private messages deliver the final answer as Markdown without opening a stream', async () => {
|
||||
const sent = [];
|
||||
const markdown = [];
|
||||
const sentText = [];
|
||||
let streamCalls = 0;
|
||||
const seen = new Set();
|
||||
const bridge = new QqHarnessBridge({
|
||||
bot: {
|
||||
sendText: async (_target, text) => sent.push(text),
|
||||
send: async (options) => {
|
||||
markdown.push(options);
|
||||
return { message: { id: 'qq-private-markdown' } };
|
||||
},
|
||||
sendText: async (_target, text) => sentText.push(text),
|
||||
openStream: () => { streamCalls += 1; },
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
|
|
@ -838,11 +844,19 @@ test('QQ private messages deliver the final answer as Markdown without opening a
|
|||
},
|
||||
});
|
||||
|
||||
await bridge.accept(message());
|
||||
assert.deepEqual(sent, ['最终回答']);
|
||||
const receipt = await bridge.accept(message());
|
||||
assert.equal(markdown.length, 1);
|
||||
assert.equal(markdown[0].msgType, 2);
|
||||
assert.equal(markdown[0].markdown.content, '最终回答');
|
||||
assert.deepEqual(markdown[0].target, {
|
||||
scope: 'c2c', targetId: 'owner-openid', msgId: 'msg-1',
|
||||
});
|
||||
assert.equal(Number.isInteger(markdown[0].extra.msg_seq), true);
|
||||
assert.deepEqual(sentText, []);
|
||||
assert.equal(streamCalls, 0);
|
||||
assert.equal(seen.has('msg-1'), true);
|
||||
assert.equal(bridge.status.messagesReplied, 1);
|
||||
assert.deepEqual(receipt.providerMessageIds, ['qq-private-markdown']);
|
||||
});
|
||||
|
||||
test('QQ group messages suppress every successful progress update', async () => {
|
||||
|
|
@ -976,8 +990,20 @@ 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'); },
|
||||
sendText: async () => { throw new Error('fallback must explicitly use msg_type=0'); },
|
||||
send: async (options) => {
|
||||
if (options.msgType === 2) {
|
||||
throw new ApiError(
|
||||
'markdown rejected',
|
||||
400,
|
||||
'/v2/users/test/messages',
|
||||
40_034_090,
|
||||
'markdown rejected',
|
||||
);
|
||||
}
|
||||
sentText.push(options.content);
|
||||
return { id: 'plain-fallback' };
|
||||
},
|
||||
},
|
||||
ownerUserOpenid: 'owner-openid',
|
||||
harness: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { ApiError } from '@tencent-connect/qqbot-nodejs';
|
||||
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
|
|
@ -8,6 +9,14 @@ import {
|
|||
|
||||
const target = { scope: 'c2c', targetId: 'user-openid', msgId: 'msg-1' };
|
||||
|
||||
function apiRejection(
|
||||
message = 'markdown rejected',
|
||||
httpStatus = 400,
|
||||
bizCode = 40_034_090,
|
||||
) {
|
||||
return new ApiError(message, httpStatus, '/v2/users/test/messages', bizCode, message);
|
||||
}
|
||||
|
||||
test('chunkMarkdownText keeps short text as a single chunk', () => {
|
||||
assert.deepEqual(chunkMarkdownText('**你好**,世界'), ['**你好**,世界']);
|
||||
});
|
||||
|
|
@ -27,6 +36,12 @@ test('chunkMarkdownText splits long text within the limit', () => {
|
|||
assert.equal(chunks.join('\n'), text);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText never emits an empty edge chunk at an exact limit', () => {
|
||||
const exact = 'x'.repeat(100);
|
||||
assert.deepEqual(chunkMarkdownText(`${exact}\n`, 100), [exact]);
|
||||
assert.deepEqual(chunkMarkdownText(`\n${exact}`, 100), [exact]);
|
||||
});
|
||||
|
||||
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)}`;
|
||||
|
|
@ -36,15 +51,103 @@ test('chunkMarkdownText does not break inside a code block that fits the limit',
|
|||
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);
|
||||
test('chunkMarkdownText makes every oversized code-block chunk independently renderable', () => {
|
||||
const payload = 'x'.repeat(250);
|
||||
const chunks = chunkMarkdownText(`\`\`\`js\n${payload}\n\`\`\``, 100);
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.length <= 100, `chunk exceeds the limit: ${chunk.length}`);
|
||||
const lines = chunk.split('\n');
|
||||
const opening = /^(`{3,}|~{3,})js$/.exec(lines[0]);
|
||||
assert.ok(opening, `missing opening fence: ${lines[0]}`);
|
||||
assert.match(lines.at(-1), new RegExp(`^\\${opening[1][0]}{${opening[1].length},}$`));
|
||||
}
|
||||
assert.equal(chunks.map((chunk) => chunk.split('\n').slice(1, -1).join('\n')).join(''), payload);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText uses a safe synthetic fence around indented nested fences', () => {
|
||||
const cases = [
|
||||
{ sourceFence: '````', nested: ' ```', expectedFence: '~' },
|
||||
{ sourceFence: '~~~~', nested: ' ~~~', expectedFence: '`' },
|
||||
];
|
||||
for (const { sourceFence, nested, expectedFence } of cases) {
|
||||
const source = ` ${sourceFence}js\n${nested}\n${'x'.repeat(250)}\n ${sourceFence}`;
|
||||
const chunks = chunkMarkdownText(source, 100);
|
||||
assert.ok(chunks.length > 1);
|
||||
// A two-space opener removes up to two leading spaces from each code line.
|
||||
assert.equal(chunks.some((chunk) => chunk.includes(nested.slice(2))), true);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.length <= 100, `chunk exceeds the limit: ${chunk.length}`);
|
||||
const lines = chunk.split('\n');
|
||||
const opening = /^(`{3,}|~{3,})js$/.exec(lines[0]);
|
||||
assert.ok(opening, `missing opening fence: ${lines[0]}`);
|
||||
assert.equal(opening[1][0], expectedFence);
|
||||
assert.equal(lines.at(-1), opening[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('chunkMarkdownText preserves the CommonMark literal of oversized indented fences', async () => {
|
||||
const literal = `${' console.log(1);\n'.repeat(400)} x`;
|
||||
const expectedLiteral = `${'console.log(1);\n'.repeat(400)}x`;
|
||||
const plain = [];
|
||||
await sendMarkdownReply({
|
||||
send: async (options) => {
|
||||
if (options.msgType === 2) throw apiRejection();
|
||||
plain.push(options.content);
|
||||
return { id: `plain-${plain.length}` };
|
||||
},
|
||||
sendText: async () => { throw new Error('explicit msg_type=0 must be used'); },
|
||||
}, target, ` \`\`\`js\n${literal}\n \`\`\``, { logger: { warn() {} } });
|
||||
|
||||
assert.equal(plain.join(''), expectedLiteral);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText applies CommonMark tab stops to oversized indented fences', async () => {
|
||||
const patterns = [
|
||||
{ source: '\talpha', literal: ' alpha' },
|
||||
{ source: ' \tbeta', literal: ' beta' },
|
||||
{ source: ' gamma', literal: 'gamma' },
|
||||
{ source: 'delta', literal: 'delta' },
|
||||
];
|
||||
const sourceLines = Array.from({ length: 800 }, (_, index) => patterns[index % patterns.length].source);
|
||||
const literalLines = Array.from({ length: 800 }, (_, index) => patterns[index % patterns.length].literal);
|
||||
const plain = [];
|
||||
await sendMarkdownReply({
|
||||
send: async (options) => {
|
||||
if (options.msgType === 2) throw apiRejection();
|
||||
plain.push(options.content);
|
||||
return { id: `plain-${plain.length}` };
|
||||
},
|
||||
sendText: async () => { throw new Error('explicit msg_type=0 must be used'); },
|
||||
}, target, ` \`\`\`text\n${sourceLines.join('\n')}\n \`\`\``, { logger: { warn() {} } });
|
||||
|
||||
assert.equal(plain.join(''), literalLines.join('\n'));
|
||||
});
|
||||
|
||||
test('chunkMarkdownText does not reinterpret an invalid backtick info string as fenced code', () => {
|
||||
const source = `\`\`\` a\`b\n${'not-code '.repeat(40)}\n\`\`\``;
|
||||
const chunks = chunkMarkdownText(source, 80);
|
||||
assert.ok(chunks.length > 1);
|
||||
assert.equal(chunks.some((chunk) => /^~~~ a`b/m.test(chunk)), false);
|
||||
assert.equal(chunks.includes('``` a`b'), true);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText accepts a backtick in a tilde-fence info string', () => {
|
||||
const chunks = chunkMarkdownText(`~~~ lang\`x\n${'payload '.repeat(100)}\n~~~`, 100);
|
||||
assert.ok(chunks.length > 1);
|
||||
assert.equal(chunks.every((chunk) => /^~{3,} lang`x\n/.test(chunk)), true);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText closes every chunk of an unfinished oversized code block', () => {
|
||||
const chunks = chunkMarkdownText(`\`\`\`js\n${'x'.repeat(250)}`, 100);
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split('\n');
|
||||
const opening = /^(`{3,}|~{3,})js$/.exec(lines[0]);
|
||||
assert.ok(opening);
|
||||
assert.equal(lines.at(-1), opening[1]);
|
||||
}
|
||||
assert.equal(chunks.join('\n'), code);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText keeps a GFM table together', () => {
|
||||
|
|
@ -59,6 +162,105 @@ test('chunkMarkdownText keeps a GFM table together', () => {
|
|||
assert.equal(chunks[0], table);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText repeats the header for every oversized GFM table chunk', () => {
|
||||
const header = '| 列一 | 列二 |';
|
||||
const separator = '| --- | --- |';
|
||||
const rows = Array.from({ length: 30 }, (_, index) => `| row-${index} | value-${index} |`);
|
||||
const chunks = chunkMarkdownText([header, separator, ...rows].join('\n'), 100);
|
||||
assert.ok(chunks.length > 1);
|
||||
const deliveredRows = [];
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.length <= 100, `chunk exceeds the limit: ${chunk.length}`);
|
||||
const lines = chunk.split('\n');
|
||||
assert.deepEqual(lines.slice(0, 2), [header, separator]);
|
||||
assert.ok(lines.slice(2).every((line) => /^\|.+\|$/.test(line)));
|
||||
deliveredRows.push(...lines.slice(2));
|
||||
}
|
||||
assert.deepEqual(deliveredRows, rows);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText chunks GFM tables without outer pipes and ignores escaped or code-span pipes', () => {
|
||||
const header = 'name | value';
|
||||
const separator = ':--- | ---:';
|
||||
const rows = Array.from(
|
||||
{ length: 30 },
|
||||
(_, index) => `row-${index} | \`a|b\` and x\\|y-${index}`,
|
||||
);
|
||||
const chunks = chunkMarkdownText([header, separator, ...rows].join('\n'), 110);
|
||||
assert.ok(chunks.length > 1);
|
||||
const deliveredRows = [];
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.length <= 110, `chunk exceeds the limit: ${chunk.length}`);
|
||||
const lines = chunk.split('\n');
|
||||
assert.deepEqual(lines.slice(0, 2), [header, separator]);
|
||||
deliveredRows.push(...lines.slice(2));
|
||||
}
|
||||
assert.deepEqual(deliveredRows, rows);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText accepts one-hyphen GFM delimiter cells', () => {
|
||||
const header = 'left | right';
|
||||
const separator = '- | :-:';
|
||||
const rows = Array.from({ length: 30 }, (_, index) => `row-${index} | value-${index}`);
|
||||
const chunks = chunkMarkdownText([header, separator, ...rows].join('\n'), 100);
|
||||
assert.ok(chunks.length > 1);
|
||||
assert.equal(chunks.every((chunk) => chunk.startsWith(`${header}\n${separator}\n`)), true);
|
||||
assert.deepEqual(chunks.flatMap((chunk) => chunk.split('\n').slice(2)), rows);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText normalizes CRLF without leaking carriage returns across table chunks', () => {
|
||||
const header = '| key | value |';
|
||||
const separator = '| --- | --- |';
|
||||
const rows = Array.from({ length: 20 }, (_, index) => `| row-${index} | value-${index} |`);
|
||||
const chunks = chunkMarkdownText([header, separator, ...rows].join('\r\n'), 100);
|
||||
assert.ok(chunks.length > 1);
|
||||
const deliveredRows = [];
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.length <= 100, `chunk exceeds the limit: ${chunk.length}`);
|
||||
assert.equal(chunk.includes('\r'), false);
|
||||
const lines = chunk.split('\n');
|
||||
assert.deepEqual(lines.slice(0, 2), [header, separator]);
|
||||
deliveredRows.push(...lines.slice(2));
|
||||
}
|
||||
assert.deepEqual(deliveredRows, rows);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText does not promote a cell-count mismatch to a GFM table', () => {
|
||||
const header = 'a | b';
|
||||
const invalidSeparator = '--- | --- | ---';
|
||||
const rows = Array.from({ length: 20 }, (_, index) => `row-${index} | value-${index}`);
|
||||
const source = [header, invalidSeparator, ...rows].join('\n');
|
||||
const chunks = chunkMarkdownText(source, 100);
|
||||
assert.ok(chunks.length > 1);
|
||||
assert.equal(chunks.join('\n'), source);
|
||||
assert.equal(chunks.filter((chunk) => chunk.startsWith(`${header}\n${invalidSeparator}`)).length, 1);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText ends a GFM table before a competing block marker', () => {
|
||||
const header = 'a | b';
|
||||
const separator = '--- | ---';
|
||||
const rows = Array.from({ length: 30 }, (_, index) => `row-${index} | value-${index}`);
|
||||
const heading = '# next section | not a table row';
|
||||
const chunks = chunkMarkdownText([header, separator, ...rows, heading].join('\n'), 100);
|
||||
const tableChunks = chunks.filter((chunk) => chunk.startsWith(`${header}\n${separator}\n`));
|
||||
assert.ok(tableChunks.length > 1);
|
||||
assert.deepEqual(tableChunks.flatMap((chunk) => chunk.split('\n').slice(2)), rows);
|
||||
assert.equal(tableChunks.some((chunk) => chunk.includes(heading)), false);
|
||||
assert.equal(chunks.some((chunk) => chunk.includes(heading)), true);
|
||||
});
|
||||
|
||||
test('chunkMarkdownText safely code-fences a table with an individually oversized row', () => {
|
||||
const table = ['| key | value |', '| --- | --- |', `| huge | ${'x'.repeat(250)} |`].join('\n');
|
||||
const chunks = chunkMarkdownText(table, 100);
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.length <= 100, `chunk exceeds the limit: ${chunk.length}`);
|
||||
const lines = chunk.split('\n');
|
||||
assert.match(lines[0], /^(`{3,}|~{3,})text$/);
|
||||
assert.match(lines.at(-1), /^(`{3,}|~{3,})$/);
|
||||
}
|
||||
});
|
||||
|
||||
test('chunkMarkdownText hard-splits an oversized single line', () => {
|
||||
const line = 'x'.repeat(250);
|
||||
const chunks = chunkMarkdownText(line, 100);
|
||||
|
|
@ -91,7 +293,24 @@ test('sendMarkdownReply sends markdown with unique msg_seq per chunk', async ()
|
|||
assert.deepEqual(results, [{ id: 'id-1' }]);
|
||||
});
|
||||
|
||||
test('sendMarkdownReply assigns distinct msg_seq values across chunks', async () => {
|
||||
test('sendMarkdownReply does not send an empty message after an exact-limit trailing newline', async () => {
|
||||
const calls = [];
|
||||
const exact = 'x'.repeat(4_500);
|
||||
await sendMarkdownReply({
|
||||
send: async (options) => {
|
||||
calls.push(options);
|
||||
return { id: `id-${calls.length}` };
|
||||
},
|
||||
sendText: async () => { throw new Error('unexpected plain-text send'); },
|
||||
}, target, `${exact}\n`);
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].markdown.content, exact);
|
||||
});
|
||||
|
||||
test('sendMarkdownReply assigns deterministic distinct 16-bit msg_seq values across chunks', async (t) => {
|
||||
t.mock.method(Date, 'now', () => 0);
|
||||
t.mock.method(Math, 'random', () => 1 - Number.EPSILON);
|
||||
const seqs = [];
|
||||
const bot = {
|
||||
send: async ({ extra }) => {
|
||||
|
|
@ -104,25 +323,128 @@ test('sendMarkdownReply assigns distinct msg_seq values across chunks', async ()
|
|||
await sendMarkdownReply(bot, target, text);
|
||||
assert.ok(seqs.length > 1);
|
||||
assert.equal(new Set(seqs).size, seqs.length);
|
||||
assert.deepEqual(seqs.slice(0, 3), [65_535, 0, 1]);
|
||||
assert.ok(seqs.every((seq) => Number.isInteger(seq) && seq >= 0 && seq <= 65_535));
|
||||
});
|
||||
|
||||
test('sendMarkdownReply falls back to plain text per chunk on markdown rejection', async () => {
|
||||
const sentText = [];
|
||||
const calls = [];
|
||||
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}` };
|
||||
send: async (options) => {
|
||||
calls.push(options);
|
||||
if (options.msgType === 2) throw apiRejection('markdown rejected: no permission');
|
||||
return { id: 'text-1' };
|
||||
},
|
||||
sendText: async () => { throw new Error('fallback must explicitly use msg_type=0'); },
|
||||
}, target, '回答内容', { logger: { warn: (...args) => warnings.push(args) } });
|
||||
assert.deepEqual(sentText, ['回答内容']);
|
||||
assert.deepEqual(calls.map(({ msgType }) => msgType), [2, 0]);
|
||||
assert.equal(calls[1].content, '回答内容');
|
||||
assert.equal(calls[1].extra.msg_seq, calls[0].extra.msg_seq);
|
||||
assert.deepEqual(results, [{ id: 'text-1' }]);
|
||||
assert.equal(warnings.length, 1);
|
||||
});
|
||||
|
||||
test('sendMarkdownReply falls back only for the known definite Markdown rejection', async () => {
|
||||
const errors = [
|
||||
apiRejection('network outcome unknown', 0),
|
||||
apiRejection('server outcome unknown', 500),
|
||||
apiRejection('success response unreadable', 200),
|
||||
apiRejection('duplicate msg_seq', 400, 40_054_005),
|
||||
new Error('unstructured failure'),
|
||||
];
|
||||
for (const deliveryError of errors) {
|
||||
const calls = [];
|
||||
await assert.rejects(
|
||||
sendMarkdownReply({
|
||||
send: async (options) => {
|
||||
calls.push(options);
|
||||
throw deliveryError;
|
||||
},
|
||||
sendText: async () => { throw new Error('non-Markdown errors must not be retried'); },
|
||||
}, target, 'possibly delivered', { logger: { warn() {} } }),
|
||||
(error) => error === deliveryError,
|
||||
);
|
||||
assert.deepEqual(calls.map(({ msgType }) => msgType), [2]);
|
||||
}
|
||||
});
|
||||
|
||||
test('sendMarkdownReply removes synthetic code fences from oversized-code plain fallback', async () => {
|
||||
const payload = 'x'.repeat(9_000);
|
||||
const markdown = [];
|
||||
const plain = [];
|
||||
await sendMarkdownReply({
|
||||
send: async (options) => {
|
||||
if (options.msgType === 2) {
|
||||
markdown.push(options.markdown.content);
|
||||
throw apiRejection();
|
||||
}
|
||||
plain.push(options.content);
|
||||
return { id: `plain-${plain.length}` };
|
||||
},
|
||||
sendText: async () => { throw new Error('explicit msg_type=0 must be used'); },
|
||||
}, target, `\`\`\`js\n${payload}\n\`\`\``, { logger: { warn() {} } });
|
||||
|
||||
assert.ok(markdown.length > 1);
|
||||
assert.ok(markdown.every((chunk) => /^(`{3,}|~{3,})js\n/.test(chunk)));
|
||||
assert.equal(plain.join(''), payload);
|
||||
assert.equal(plain.some((chunk) => /^(`{3,}|~{3,})/.test(chunk)), false);
|
||||
});
|
||||
|
||||
test('sendMarkdownReply never sends an empty fallback for an empty oversized fenced body', async () => {
|
||||
const source = `\`\`\`${'a'.repeat(4_495)}\n\`\`\``;
|
||||
const calls = [];
|
||||
const results = await sendMarkdownReply({
|
||||
send: async (options) => {
|
||||
calls.push(options);
|
||||
if (options.msgType === 2) throw apiRejection();
|
||||
throw new Error('empty plain fallback must not be sent');
|
||||
},
|
||||
sendText: async () => { throw new Error('empty plain fallback must not be sent'); },
|
||||
}, target, source, { logger: { warn() {} } });
|
||||
|
||||
assert.deepEqual(calls.map(({ msgType }) => msgType), [2]);
|
||||
assert.deepEqual(results, []);
|
||||
|
||||
let legacyTextCalls = 0;
|
||||
const legacyResults = await sendMarkdownReply({
|
||||
sendText: async () => {
|
||||
legacyTextCalls += 1;
|
||||
throw new Error('empty legacy text must not be sent');
|
||||
},
|
||||
}, target, source);
|
||||
assert.equal(legacyTextCalls, 0);
|
||||
assert.deepEqual(legacyResults, []);
|
||||
});
|
||||
|
||||
test('sendMarkdownReply does not repeat synthetic table headers in plain fallback', async () => {
|
||||
const header = '| key | value |';
|
||||
const separator = '| --- | --- |';
|
||||
const rows = Array.from(
|
||||
{ length: 500 },
|
||||
(_, index) => `| row-${String(index).padStart(3, '0')} | ${'x'.repeat(20)} |`,
|
||||
);
|
||||
const source = [header, separator, ...rows].join('\n');
|
||||
const markdown = [];
|
||||
const plain = [];
|
||||
await sendMarkdownReply({
|
||||
send: async (options) => {
|
||||
if (options.msgType === 2) {
|
||||
markdown.push(options.markdown.content);
|
||||
throw apiRejection();
|
||||
}
|
||||
plain.push(options.content);
|
||||
return { id: `plain-${plain.length}` };
|
||||
},
|
||||
sendText: async () => { throw new Error('explicit msg_type=0 must be used'); },
|
||||
}, target, source, { logger: { warn() {} } });
|
||||
|
||||
assert.ok(markdown.length > 1);
|
||||
assert.ok(markdown.every((chunk) => chunk.startsWith(`${header}\n${separator}\n`)));
|
||||
assert.equal(plain.join('\n'), source);
|
||||
assert.equal((plain.join('\n').match(/\| key \| value \|/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test('sendMarkdownReply uses sendText directly when the bot lacks send()', async () => {
|
||||
const sentText = [];
|
||||
const results = await sendMarkdownReply({
|
||||
|
|
@ -173,23 +495,42 @@ test('sendMarkdownReply moves overflow chunks off the passive group reply target
|
|||
]);
|
||||
});
|
||||
|
||||
test('sendMarkdownReply reserves the fourth passive C2C reply and moves overflow proactive', async () => {
|
||||
const targets = [];
|
||||
await sendMarkdownReply({
|
||||
send: async ({ target: sentTarget }) => {
|
||||
targets.push(sentTarget);
|
||||
return { id: `id-${targets.length}` };
|
||||
},
|
||||
sendText: async () => { throw new Error('unexpected'); },
|
||||
}, target, 'x'.repeat(4_500 * 5));
|
||||
|
||||
assert.equal(targets.length, 5);
|
||||
assert.deepEqual(targets.map((sentTarget) => Boolean(sentTarget.msgId)), [
|
||||
true, true, true, false, false,
|
||||
]);
|
||||
});
|
||||
|
||||
test('sendMarkdownReply uses the reserved passive reply for a visible partial notice', async () => {
|
||||
const groupTarget = { scope: 'group', targetId: 'group-1', msgId: 'group-msg' };
|
||||
const notices = [];
|
||||
const seqs = [];
|
||||
const results = await sendMarkdownReply({
|
||||
send: async ({ target: sentTarget }) => {
|
||||
if (!sentTarget.msgId) throw new Error('proactive disabled');
|
||||
send: async ({ target: sentTarget, msgType, content, extra }) => {
|
||||
seqs.push(extra.msg_seq);
|
||||
if (msgType === 0) {
|
||||
notices.push(content);
|
||||
return { id: 'partial-notice' };
|
||||
}
|
||||
if (!sentTarget.msgId) throw new Error('proactive delivery outcome unknown');
|
||||
return { id: 'passive' };
|
||||
},
|
||||
sendText: async (sentTarget, text) => {
|
||||
if (!sentTarget.msgId) throw new Error('proactive disabled');
|
||||
notices.push(text);
|
||||
return { id: 'partial-notice' };
|
||||
},
|
||||
sendText: async () => { throw new Error('partial notice must explicitly use msg_type=0'); },
|
||||
}, groupTarget, 'x'.repeat(4_500 * 6), { logger: { warn() {} } });
|
||||
|
||||
assert.equal(results.length, 5);
|
||||
assert.deepEqual(notices, ['回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。']);
|
||||
assert.equal(new Set(seqs).size, seqs.length);
|
||||
});
|
||||
|
||||
test('sendMarkdownReply returns no deliveries for empty text', async () => {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ test('QQ runtime waits for gateway ready, installs typing, and stops its client'
|
|||
assert.equal(bot.middlewares[0].options.keepAlive, true);
|
||||
assert.equal(bot.middlewares[0].options.predicate({ message: { senderId: 'owner' } }), true);
|
||||
assert.equal(bot.middlewares[0].options.predicate({ message: { senderId: 'other' } }), false);
|
||||
assert.equal(botOptions.markdownSupport, false);
|
||||
botOptions.logger.debug('raw gateway payload');
|
||||
botOptions.logger.info('gateway ready');
|
||||
assert.deepEqual(sdkLogs, [['info', 'gateway ready']]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue