Merge branch 'feat/stream_new1' into 'main' (merge request !6)

feat: v1.6.6 — 流式消息(C2C)+ 媒体发送队列重构 + ApiError 结构化错误

- 新增 StreamingController 流式消息控制器,C2C 私聊 AI 回复以打字机效果实时推送
- 新增 sendC2CStreamMessage 流式 API 封装(/v2/users/{openid}/stream_messages)
- 新增 ApiError 结构化错误类,携带 HTTP status 和 path,支持按状态码重试/降级
- 新增 media-send.ts 公共媒体标签解析与发送队列,outbound.ts 和 streaming.ts 共用
- 新增 streaming/streamingConfig 账户配置项,支持按账户控制流式开关和节流间隔
- 新增 strip-incomplete-media-tag 和 streaming-controller 单元测试
- 重构 outbound.ts 媒体发送逻辑为调用公共模块,消除约 100 行重复代码
- audio-convert.ts 日志从 console.log 降级为 console.debug,减少生产日志噪音
- gateway.ts 集成流式控制器:per-message 创建、onPartialReply 回调、dispatch 后收尾
- 删除已清空的 user-messages.ts
- bump version to 1.6.6
This commit is contained in:
leoqlin
2026-03-25 10:46:08 +00:00
16 changed files with 3788 additions and 334 deletions

View File

@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [1.6.6] - 2026-03-25
### Added
- **Streaming messages (C2C)**: New `StreamingController` delivers AI responses as real-time typing-effect chunks in private chat. Includes throttle control (default 500ms, min 300ms), automatic media-tag pause/resume, long-gap batch window, state-machine lifecycle (`idle → streaming → completed/aborted`), and graceful fallback to static mode when the streaming API is unavailable.
- **Stream message API `sendC2CStreamMessage`**: Low-level wrapper for QQ Open Platform `/v2/users/{openid}/stream_messages` endpoint, with `replace` input mode, incremental `msg_seq`/`index`, and `GENERATING`/`DONE` state signaling.
- **`ApiError` structured error class**: API request errors now carry `status` (HTTP code) and `path`, enabling callers (e.g. streaming controller) to branch on status for retry vs. fallback decisions.
- **Media send queue module `media-send.ts`**: Extracted media-tag parsing, path-encoding fix, and send-queue execution into a shared utility used by both `outbound.ts` (static mode) and `streaming.ts` (streaming mode), eliminating ~100 lines of duplication.
- **Streaming configuration**: New `streaming` (boolean, default `true`) and `streamingConfig.throttleMs` options in account config for per-account streaming control.
- **Unit tests**: Added `strip-incomplete-media-tag.test.ts` and `streaming-controller.test.ts`.
### Changed
- **Outbound media handling refactored**: `sendText` in `outbound.ts` now delegates media-tag parsing and queue execution to the shared `media-send.ts` module instead of inline regex + switch logic.
- **Audio convert log level**: Downgraded `console.log``console.debug` for SILK detection, ffmpeg conversion, and WASM fallback logs in `audio-convert.ts`, reducing noise in production.
- **Gateway streaming integration**: `gateway.ts` creates a `StreamingController` per inbound message when streaming is enabled; registers `onPartialReply` callback to feed incremental text into the controller; finalizes or aborts the stream after dispatch completes.
### Removed
- **`user-messages.ts`**: Deleted the already-emptied module (design: plugin layer does not generate user-facing error text).
## [1.6.5] - 2026-03-24
### OpenClaw 3.23 Compatibility

View File

@@ -4,6 +4,27 @@
格式参考 [Keep a Changelog](https://keepachangelog.com/)。
## [1.6.6] - 2026-03-25
### 新增
- **流式消息C2C 私聊)**:新增 `StreamingController` 流式控制器AI 回复以打字机效果实时逐步推送到 QQ 私聊。支持节流控制(默认 500ms最小 300ms、媒体标签自动暂停/恢复流式会话、长间隔批处理窗口、状态机生命周期管理(`idle → streaming → completed/aborted`),流式 API 不可用时自动降级为静态消息模式。
- **流式消息 API `sendC2CStreamMessage`**:封装 QQ 开放平台 `/v2/users/{openid}/stream_messages` 接口,支持 `replace` 输入模式、递增 `msg_seq`/`index` 序号、`GENERATING`/`DONE` 状态信令。
- **`ApiError` 结构化错误类**API 请求错误现在携带 `status`HTTP 状态码)和 `path`,使调用方(如流式控制器)可根据状态码决定重试或降级策略。
- **媒体发送队列模块 `media-send.ts`**:将媒体标签解析、路径编码修复、发送队列执行器抽取为公共工具模块,供 `outbound.ts`(静态模式)和 `streaming.ts`(流式模式)共用,消除约 100 行重复代码。
- **流式消息配置项**:账户配置新增 `streaming`(布尔值,默认 `true`)和 `streamingConfig.throttleMs` 选项,支持按账户控制流式消息开关和节流间隔。
- **单元测试**:新增 `strip-incomplete-media-tag.test.ts``streaming-controller.test.ts`
### 变更
- **出站媒体处理重构**`outbound.ts``sendText` 的媒体标签解析和发送队列逻辑重构为调用公共 `media-send.ts` 模块,替代原有的内联正则 + switch 分支。
- **音频转换日志降级**`audio-convert.ts` 中 SILK 检测、ffmpeg 转换、WASM 降级等日志从 `console.log` 降为 `console.debug`,减少生产环境日志噪音。
- **Gateway 流式集成**`gateway.ts` 在流式启用时为每条入站消息创建 `StreamingController`;注册 `onPartialReply` 回调将增量文本馈入控制器dispatch 完成后终结或中止流式会话。
### 移除
- **`user-messages.ts`**:删除已清空的模块(设计原则:插件层不生成面向用户的错误提示)。
## [1.6.5] - 2026-03-24
### OpenClaw 3.23 兼容适配

View File

@@ -1,6 +1,6 @@
{
"name": "@tencent-connect/openclaw-qqbot",
"version": "1.6.5",
"version": "1.6.6",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -24,6 +24,7 @@ INSTALL_SRC=""
TARGET_VERSION=""
APPID=""
SECRET=""
STREAMING=""
NO_RESTART=false
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -50,6 +51,7 @@ print_usage() {
echo ""
echo " --appid <appid> QQ机器人 appid首次安装时必填"
echo " --secret <secret> QQ机器人 secret首次安装时必填"
echo " --streaming <yes|no> 是否启用流式消息(默认: no仅 C2C 私聊)"
echo ""
echo "也可以通过环境变量设置:"
echo " QQBOT_APPID QQ机器人 appid"
@@ -87,6 +89,11 @@ while [[ $# -gt 0 ]]; do
SECRET="$2"
shift 2
;;
--streaming)
[ -z "$2" ] && echo "❌ --streaming 需要参数" && exit 1
STREAMING="$2"
shift 2
;;
--no-restart)
NO_RESTART=true
shift 1
@@ -449,6 +456,34 @@ elif [ -n "$APPID" ] || [ -n "$SECRET" ]; then
echo "⚠️ --appid 和 --secret 必须同时提供"
fi
# [配置] streaming流式消息
if [ -n "$STREAMING" ]; then
echo ""
echo "[配置] 写入 streaming流式消息配置..."
STREAMING_VALUE=""
if [ "$STREAMING" = "yes" ] || [ "$STREAMING" = "y" ] || [ "$STREAMING" = "true" ]; then
STREAMING_VALUE="true"
else
STREAMING_VALUE="false"
fi
CONFIG_FILE="$HOME/.$CMD/$CMD.json"
if [ -f "$CONFIG_FILE" ] && node -e "
const fs = require('fs');
const cfg = JSON.parse(fs.readFileSync('$CONFIG_FILE', 'utf8'));
if (!cfg.channels) cfg.channels = {};
if (!cfg.channels.qqbot) cfg.channels.qqbot = {};
const target = $STREAMING_VALUE;
if (cfg.channels.qqbot.streaming === target) process.exit(0);
cfg.channels.qqbot.streaming = target;
fs.writeFileSync('$CONFIG_FILE', JSON.stringify(cfg, null, 4) + '\n');
" 2>&1; then
echo " ✅ streaming 配置写入成功 (streaming=$STREAMING_VALUE)"
else
echo " ⚠️ streaming 配置写入失败,不影响后续运行"
fi
fi
# [4/4] 重启 gateway 使新版本生效
echo ""

View File

@@ -25,6 +25,7 @@ cd "$PROJ_DIR"
APPID=""
SECRET=""
MARKDOWN=""
STREAMING=""
while [[ $# -gt 0 ]]; do
case $1 in
@@ -40,6 +41,10 @@ while [[ $# -gt 0 ]]; do
MARKDOWN="$2"
shift 2
;;
--streaming)
STREAMING="$2"
shift 2
;;
-h|--help)
echo "用法: $0 [选项]"
echo ""
@@ -47,6 +52,7 @@ while [[ $# -gt 0 ]]; do
echo " --appid <appid> QQ机器人 appid"
echo " --secret <secret> QQ机器人 secret"
echo " --markdown <yes|no> 是否启用 markdown 消息格式(默认: no"
echo " --streaming <yes|no> 是否启用流式消息(默认: no仅 C2C 私聊)"
echo " -h, --help 显示帮助信息"
echo ""
echo "也可以通过环境变量设置:"
@@ -54,6 +60,7 @@ while [[ $# -gt 0 ]]; do
echo " QQBOT_SECRET QQ机器人 secret"
echo " QQBOT_TOKEN QQ机器人 token (appid:secret)"
echo " QQBOT_MARKDOWN 是否启用 markdownyes/no"
echo " QQBOT_STREAMING 是否启用流式消息yes/no"
echo ""
echo "不带参数时,将使用已有配置直接启动。"
echo ""
@@ -72,6 +79,7 @@ done
APPID="${APPID:-$QQBOT_APPID}"
SECRET="${SECRET:-$QQBOT_SECRET}"
MARKDOWN="${MARKDOWN:-$QQBOT_MARKDOWN}"
STREAMING="${STREAMING:-$QQBOT_STREAMING}"
echo "========================================="
echo " qqbot 一键更新启动脚本"
@@ -131,6 +139,29 @@ if [ -z "$SAVED_QQBOT_TOKEN" ] && [ -d "$HOME/.openclaw" ]; then
fi
fi
# 备份 streaming 配置(升级后恢复)
SAVED_STREAMING=""
for _app in openclaw clawdbot moltbot; do
_cfg="$HOME/.$_app/$_app.json"
if [ -f "$_cfg" ]; then
SAVED_STREAMING=$(node -e "
const cfg = JSON.parse(require('fs').readFileSync('$_cfg', 'utf8'));
const keys = ['qqbot', 'openclaw-qqbot', 'openclaw-qq'];
for (const key of keys) {
const ch = cfg.channels && cfg.channels[key];
if (ch && typeof ch.streaming === 'boolean') {
process.stdout.write(String(ch.streaming));
process.exit(0);
}
}
" 2>/dev/null || true)
[ -n "$SAVED_STREAMING" ] && break
fi
done
if [ -n "$SAVED_STREAMING" ]; then
echo "已备份 streaming 配置: $SAVED_STREAMING"
fi
# 2. 移除老版本
echo ""
echo "[2/6] 移除老版本..."
@@ -635,6 +666,69 @@ else
echo "未指定 markdown 选项,使用已有配置"
fi
# 5.5. 配置 streaming 选项
echo ""
echo "[5.5/6] 配置 streaming流式消息选项..."
# 确定目标 streaming 值:命令行参数 > 备份值
STREAMING_VALUE=""
if [ -n "$STREAMING" ]; then
if [ "$STREAMING" = "yes" ] || [ "$STREAMING" = "y" ] || [ "$STREAMING" = "true" ]; then
STREAMING_VALUE="true"
echo "启用流式消息..."
else
STREAMING_VALUE="false"
echo "禁用流式消息..."
fi
elif [ -n "$SAVED_STREAMING" ]; then
STREAMING_VALUE="$SAVED_STREAMING"
echo "从备份恢复 streaming 配置: $SAVED_STREAMING"
fi
if [ -n "$STREAMING_VALUE" ]; then
CURRENT_STREAMING_VALUE=$(node -e "
const fs = require('fs');
const path = require('path');
const home = process.env.HOME;
for (const app of ['openclaw', 'clawdbot', 'moltbot']) {
const f = path.join(home, '.' + app, app + '.json');
if (!fs.existsSync(f)) continue;
try {
const cfg = JSON.parse(fs.readFileSync(f, 'utf8'));
const keys = ['qqbot', 'openclaw-qqbot', 'openclaw-qq'];
for (const key of keys) {
const ch = cfg.channels && cfg.channels[key];
if (!ch) continue;
if (typeof ch.streaming === 'boolean') { process.stdout.write(String(ch.streaming)); process.exit(0); }
}
} catch {}
}
" 2>/dev/null || true)
if [ "$CURRENT_STREAMING_VALUE" = "$STREAMING_VALUE" ]; then
echo "✅ streaming 配置已是目标值,跳过写入"
else
OPENCLAW_CONFIG="$HOME/.openclaw/openclaw.json"
if [ -f "$OPENCLAW_CONFIG" ] && node -e "
const fs = require('fs');
const cfg = JSON.parse(fs.readFileSync('$OPENCLAW_CONFIG', 'utf-8'));
if (!cfg.channels) cfg.channels = {};
if (!cfg.channels.qqbot) cfg.channels.qqbot = {};
const target = $STREAMING_VALUE;
if (cfg.channels.qqbot.streaming === target) process.exit(0);
cfg.channels.qqbot.streaming = target;
fs.writeFileSync('$OPENCLAW_CONFIG', JSON.stringify(cfg, null, 4) + '\n');
" 2>&1; then
echo "✅ streaming 配置成功"
_config_changed=1
else
echo "⚠️ streaming 配置设置失败,不影响后续运行"
fi
fi
else
echo "未指定 streaming 选项且无备份值,使用默认配置"
fi
# 6. 启动 openclaw
echo ""
echo "[6/6] 启动 openclaw..."

View File

@@ -7,6 +7,20 @@ import os from "node:os";
import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
import { sanitizeFileName } from "./utils/platform.js";
// ============ 自定义错误 ============
/** API 请求错误,携带 HTTP status code */
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly path: string,
) {
super(message);
this.name = "ApiError";
}
}
const API_BASE = "https://api.sgroup.qq.com";
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
@@ -302,15 +316,15 @@ export async function apiRequest<T = unknown>(
: res.status === 429
? "请求过于频繁,已被限流"
: `开放平台返回 HTTP ${res.status}`;
throw new Error(`${statusHint}${path}),请稍后重试`);
throw new ApiError(`${statusHint}${path}),请稍后重试`, res.status, path);
}
// JSON 错误响应
try {
const error = JSON.parse(rawBody) as { message?: string; code?: number };
throw new Error(`API Error [${path}]: ${error.message ?? rawBody}`);
throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path);
} catch (parseErr) {
if (parseErr instanceof Error && parseErr.message.startsWith("API Error")) throw parseErr;
throw new Error(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`);
if (parseErr instanceof ApiError) throw parseErr;
throw new ApiError(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`, res.status, path);
}
}
@@ -1036,3 +1050,42 @@ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
}
});
}
// ============ 流式消息 API ============
import type { StreamMessageRequest, StreamMessageResponse } from "./types.js";
/**
* 发送流式消息C2C 私聊)
*
* 流式协议:
* - 首次调用时不传 stream_msg_id由平台返回
* - 后续分片携带 stream_msg_id 和递增 msg_seq
* - input_state="1" 表示生成中,"10" 表示生成结束(终结状态)
*
* @param accessToken - access_token
* @param openid - 用户 openid
* @param req - 流式消息请求体
* @returns 流式消息响应
*/
export async function sendC2CStreamMessage(
accessToken: string,
openid: string,
req: StreamMessageRequest,
): Promise<StreamMessageResponse> {
const path = `/v2/users/${openid}/stream_messages`;
const body: Record<string, unknown> = {
input_mode: req.input_mode,
input_state: req.input_state,
content_type: req.content_type,
content_raw: req.content_raw,
event_id: req.event_id,
msg_id: req.msg_id,
msg_seq: req.msg_seq,
index: req.index,
};
if (req.stream_msg_id) {
body.stream_msg_id = req.stream_msg_id;
}
return apiRequest<StreamMessageResponse>(accessToken, "POST", path, body);
}

View File

@@ -70,17 +70,10 @@ export function resolveQQBotAccount(
let secretSource: "config" | "file" | "env" | "none" = "none";
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
// 默认账户从顶层读取
// 默认账户从顶层读取(展开所有字段,避免遗漏新增配置项)
const { accounts: _accounts, ...topLevelConfig } = qqbot ?? {} as QQBotChannelConfig;
accountConfig = {
enabled: qqbot?.enabled,
name: qqbot?.name,
appId: qqbot?.appId,
clientSecret: qqbot?.clientSecret,
clientSecretFile: qqbot?.clientSecretFile,
dmPolicy: qqbot?.dmPolicy,
allowFrom: qqbot?.allowFrom,
systemPrompt: qqbot?.systemPrompt,
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
...topLevelConfig,
markdownSupport: qqbot?.markdownSupport ?? true,
};
appId = normalizeAppId(qqbot?.appId);

View File

@@ -22,6 +22,7 @@ import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js";
import { parseAndSendMediaTags, sendPlainReply, type DeliverEventContext, type DeliverAccountContext } from "./outbound-deliver.js";
import { createDeliverDebouncer, type DeliverDebouncer } from "./deliver-debounce.js";
import { runWithRequestContext } from "./request-context.js";
import { StreamingController, shouldUseStreaming } from "./streaming.js";
// QQ Bot intents - 按权限级别分组
const INTENTS = {
@@ -50,6 +51,7 @@ const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765
// 使用绝对路径,确保文件保存和读取使用同一目录
const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || getQQBotDataDir("images");
export interface GatewayContext {
account: ResolvedQQBotAccount;
abortSignal: AbortSignal;
@@ -849,6 +851,53 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
}, responseTimeout);
});
// ============ 流式消息控制器 ============
const targetType = event.type === "c2c" ? "c2c" as const
: event.type === "group" ? "group" as const
: "channel" as const;
const useStreaming = shouldUseStreaming(account, targetType);
log?.info(`[qqbot:${account.accountId}] Streaming ${useStreaming ? "enabled" : "disabled"} for ${targetType} message from ${event.senderId}`);
let streamingController: StreamingController | null = null;
/** 创建一个新的 StreamingController 实例(用于初始创建和回复边界时重建) */
const createStreamingController = (): StreamingController => {
const ctrl = new StreamingController({
account,
userId: event.senderId,
replyToMsgId: event.messageId,
eventId: event.messageId,
logPrefix: `[qqbot:${account.accountId}:streaming]`,
log,
mediaContext: {
account,
event: {
type: event.type as "c2c" | "group" | "channel",
senderId: event.senderId,
messageId: event.messageId,
groupOpenid: event.groupOpenid,
channelId: event.channelId,
},
log,
},
// 回复边界回调:终结旧 controller 后创建新的,用新回复文本继续流式
onReplyBoundary: async (newReplyText: string) => {
log?.info(`[qqbot:${account.accountId}] Reply boundary: creating new StreamingController for new reply`);
const newCtrl = createStreamingController();
streamingController = newCtrl;
// 将新回复的初始文本交给新 controller 处理
await newCtrl.onPartialReply({ text: newReplyText });
},
});
return ctrl;
};
if (useStreaming) {
log?.info(`[qqbot:${account.accountId}] Streaming mode enabled for ${targetType} target`);
streamingController = createStreamingController();
}
const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
@@ -956,6 +1005,37 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
log?.info(`[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`);
}
// ============ 流式模式处理 ============
// 流式模式下,所有 block deliver 内容(含媒体标签)统一交由 StreamingController 处理。
// StreamingController 内部有重试机制;如果一个分片都没发出去则降级到普通消息。
if (streamingController && !streamingController.isTerminalPhase) {
const deliverTextLen = (payload.text ?? "").length;
const deliverPreview = (payload.text ?? "").slice(0, 40).replace(/\n/g, "\\n");
log?.debug?.(`[qqbot:${account.accountId}] Streaming deliver entry, textLen=${deliverTextLen}, phase=${streamingController.currentPhase}, sentChunks=${streamingController.sentChunkCount_debug}, preview="${deliverPreview}"`);
try {
await streamingController.onDeliver(payload);
log?.debug?.(`[qqbot:${account.accountId}] Streaming deliver done, phase=${streamingController.currentPhase}`);
} catch (err) {
// StreamingController 内部已有重试,这里只打日志
log?.error(`[qqbot:${account.accountId}] Streaming deliver error: ${err}`);
}
// 检查是否因流式 API 不可用而需要降级ensureStreamingStarted 全部失败)
// 如果需要降级,不 return让本次 deliver 的 payload.text全量文本继续走普通发送逻辑
if (streamingController.shouldFallbackToStatic) {
log?.info(`[qqbot:${account.accountId}] Streaming API unavailable, falling back to static for this deliver`);
// 不 return继续走普通发送逻辑payload.text 是完整文本)
} else {
// 流式正常处理,不走普通发送逻辑
pluginRuntime.channel.activity.record({
channel: "qqbot",
accountId: account.accountId,
direction: "outbound",
});
return;
}
}
// ============ 实际发送逻辑(可被 debouncer 包裹) ============
const executeDeliver = async (deliverPayload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, _deliverInfo: { kind: string }) => {
// ============ 引用回复 ============
@@ -1044,6 +1124,23 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
clearTimeout(timeoutId);
timeoutId = null;
}
// 流式模式:委托给 streaming controller 处理错误
if (streamingController && !streamingController.isTerminalPhase) {
try {
await streamingController.onError(err);
} catch (streamErr) {
log?.error(`[qqbot:${account.accountId}] Streaming onError failed: ${streamErr}`);
}
// 如果 onError 中因无分片发出而降级,不 return走普通错误处理
if (streamingController.shouldFallbackToStatic) {
log?.info(`[qqbot:${account.accountId}] Streaming onError: no chunk sent, falling back to static error handling`);
// 不 return继续走普通错误处理
} else {
return;
}
}
const errMsg = String(err);
@@ -1062,7 +1159,23 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
},
},
replyOptions: {
disableBlockStreaming: true,
// 流式模式时禁用 block streaming
disableBlockStreaming: !useStreaming,
// 流式模式下注册 onPartialReply 回调,接收流式文本增量
...(streamingController ? {
onPartialReply: async (payload: { text?: string }) => {
const textLen = payload.text?.length ?? 0;
const preview = (payload.text ?? "").slice(0, 40).replace(/\n/g, "\\n");
log?.debug?.(`[qqbot:${account.accountId}] onPartialReply called, textLen=${textLen}, phase=${streamingController!.currentPhase}, isTerminal=${streamingController!.isTerminalPhase}, preview="${preview}"`);
try {
await streamingController!.onPartialReply(payload);
log?.debug?.(`[qqbot:${account.accountId}] onPartialReply done, phase=${streamingController!.currentPhase}`);
} catch (err) {
// StreamingController 内部已有重试,这里只打日志
log?.error(`[qqbot:${account.accountId}] Streaming onPartialReply error: ${err}`);
}
},
} : {}),
},
});
@@ -1093,6 +1206,28 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
await debouncer.dispose();
debouncer = null;
}
// ============ 流式消息收尾 ============
// dispatch 完成后,标记流式控制器已完成并触发 onIdle发送终结分片
if (streamingController && !streamingController.isTerminalPhase) {
try {
streamingController.markFullyComplete();
await streamingController.onIdle();
log?.debug?.(`[qqbot:${account.accountId}] Streaming controller finalized`);
} catch (err) {
log?.error(`[qqbot:${account.accountId}] Streaming finalization error: ${err}`);
// 尝试中止
try { await streamingController.abortStreaming(); } catch { /* ignore */ }
}
}
// ============ 流式降级到非流式 ============
// 无需额外处理:如果流式 API 不可用shouldFallbackToStatic
// deliver 回调中已自动跳过流式拦截,走普通消息发送逻辑。
// (每次 deliver 收到的都是全量文本,不需要在 controller 内部保存累积文本)
if (streamingController?.shouldFallbackToStatic) {
log?.debug?.(`[qqbot:${account.accountId}] Streaming was degraded to static mode (no chunk sent successfully)`);
}
}
} catch (err) {
const errStr = String(err);

View File

@@ -8,12 +8,12 @@
import type { ResolvedQQBotAccount } from "./types.js";
import { sendC2CMessage, sendGroupMessage, sendChannelMessage, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
import { sendPhoto, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
import { getQQBotRuntime } from "./runtime.js";
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
import { normalizeMediaTags } from "./utils/media-tags.js";
import { normalizePath, isLocalPath as isLocalFilePath } from "./utils/platform.js";
import { parseMediaTagsToSendQueue, executeSendQueue, type MediaSendContext } from "./utils/media-send.js";
import { isLocalPath as isLocalFilePath } from "./utils/platform.js";
import { filterInternalMarkers } from "./utils/text-parsing.js";
// ============ 类型定义 ============
@@ -60,56 +60,16 @@ export async function parseAndSendMediaTags(
const { account, log } = actx;
const prefix = `[qqbot:${account.accountId}]`;
// 预处理:纠正小模型常见的标签拼写错误和格式问题
const text = normalizeMediaTags(replyText);
// 使用 media-send.ts 的统一解析器(内含 normalizeMediaTags + 路径编码修复)
const { hasMediaTags: hasMedia, sendQueue } = parseMediaTagsToSendQueue(replyText, log);
const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
const mediaTagMatches = [...text.matchAll(mediaTagRegex)];
if (mediaTagMatches.length === 0) {
return { handled: false, normalizedText: text };
}
const tagCounts = mediaTagMatches.reduce((acc, m) => { const t = m[1]!.toLowerCase(); acc[t] = (acc[t] ?? 0) + 1; return acc; }, {} as Record<string, number>);
log?.info(`${prefix} Detected media tags: ${Object.entries(tagCounts).map(([k, v]) => `${v} <${k}>`).join(", ")}`);
// 构建发送队列
type QueueItem = { type: "text" | "image" | "voice" | "video" | "file" | "media"; content: string };
const sendQueue: QueueItem[] = [];
let lastIndex = 0;
const regex2 = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
let match;
while ((match = regex2.exec(text)) !== null) {
const textBefore = text.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
if (textBefore) {
sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) });
}
const tagName = match[1]!.toLowerCase();
let mediaPath = decodeMediaPath(match[2]?.trim() ?? "", log, prefix);
if (mediaPath) {
const typeMap: Record<string, QueueItem["type"]> = {
qqmedia: "media", qqvoice: "voice", qqvideo: "video", qqfile: "file",
};
const itemType = typeMap[tagName] ?? "image";
sendQueue.push({ type: itemType, content: mediaPath });
log?.info(`${prefix} Found ${itemType} in <${tagName}>: ${mediaPath}`);
}
lastIndex = match.index + match[0].length;
}
const textAfter = text.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
if (textAfter) {
sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) });
if (!hasMedia || sendQueue.length === 0) {
return { handled: false, normalizedText: replyText };
}
log?.info(`${prefix} Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
// 按顺序发送
// 构建统一的媒体发送上下文
const mediaTarget: MediaTargetContext = {
targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid! : event.channelId!,
@@ -118,46 +78,22 @@ export async function parseAndSendMediaTags(
logPrefix: prefix,
};
for (const item of sendQueue) {
if (item.type === "text") {
await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef);
} else if (item.type === "image") {
const result = await sendPhoto(mediaTarget, item.content);
if (result.error) {
log?.error(`${prefix} sendPhoto error: ${result.error}`);
await sendTextChunks(`发送图片失败:${result.error}`, event, actx, sendWithRetry, consumeQuoteRef);
}
} else if (item.type === "voice") {
await sendVoiceWithTimeout(mediaTarget, item.content, account, log, prefix);
} else if (item.type === "video") {
const result = await sendVideoMsg(mediaTarget, item.content);
if (result.error) {
log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
await sendTextChunks(`发送视频失败:${result.error}`, event, actx, sendWithRetry, consumeQuoteRef);
}
} else if (item.type === "file") {
const result = await sendDocument(mediaTarget, item.content);
if (result.error) {
log?.error(`${prefix} sendDocument error: ${result.error}`);
await sendTextChunks(result.error, event, actx, sendWithRetry, consumeQuoteRef);
}
} else if (item.type === "media") {
const result = await sendMediaAuto({
to: actx.qualifiedTarget,
text: "",
mediaUrl: item.content,
accountId: account.accountId,
replyToId: event.messageId,
account,
});
if (result.error) {
log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
await sendTextChunks(result.error, event, actx, sendWithRetry, consumeQuoteRef);
}
}
}
const mediaSendCtx: MediaSendContext = {
mediaTarget,
qualifiedTarget: actx.qualifiedTarget,
account,
replyToId: event.messageId,
log,
};
return { handled: true, normalizedText: text };
// 使用 media-send.ts 的统一执行器
await executeSendQueue(sendQueue, mediaSendCtx, {
onSendText: async (textContent) => {
await sendTextChunks(filterInternalMarkers(textContent), event, actx, sendWithRetry, consumeQuoteRef);
},
});
return { handled: true, normalizedText: replyText };
}
// ============ 2. 非结构化消息发送(普通文本 + 图片) ============
@@ -324,48 +260,6 @@ export async function sendPlainReply(
// ============ 内部辅助函数 ============
/** 解码媒体路径:剥离 MEDIA: 前缀、展开 ~、修复转义 */
function decodeMediaPath(raw: string, log: DeliverAccountContext["log"], prefix: string): string {
let mediaPath = raw;
if (mediaPath.startsWith("MEDIA:")) {
mediaPath = mediaPath.slice("MEDIA:".length);
}
mediaPath = normalizePath(mediaPath);
mediaPath = mediaPath.replace(/\\\\/g, "\\");
try {
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
if (hasOctal || hasNonASCII) {
log?.debug?.(`${prefix} Decoding path with mixed encoding: ${mediaPath}`);
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
return String.fromCharCode(parseInt(octal, 8));
});
const bytes: number[] = [];
for (let i = 0; i < decoded.length; i++) {
const code = decoded.charCodeAt(i);
if (code <= 0xFF) {
bytes.push(code);
} else {
const charBytes = Buffer.from(decoded[i], "utf8");
bytes.push(...charBytes);
}
}
const buffer = Buffer.from(bytes);
const utf8Decoded = buffer.toString("utf8");
if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) {
mediaPath = utf8Decoded;
log?.debug?.(`${prefix} Successfully decoded path: ${mediaPath}`);
}
}
} catch (decodeErr) {
log?.error(`${prefix} Path decode error: ${decodeErr}`);
}
return mediaPath;
}
/** 发送文本分块(共用逻辑) */
async function sendTextChunks(
text: string,
@@ -396,30 +290,6 @@ async function sendTextChunks(
}
}
/** 语音发送(带 45s 超时保护) */
async function sendVoiceWithTimeout(
target: MediaTargetContext,
voicePath: string,
account: ResolvedQQBotAccount,
log: DeliverAccountContext["log"],
prefix: string,
): Promise<void> {
const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
const voiceTimeout = 45000;
try {
const result = await Promise.race([
sendVoice(target, voicePath, uploadFormats, transcodeEnabled),
new Promise<{ channel: string; error: string }>((resolve) =>
setTimeout(() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }), voiceTimeout),
),
]);
if (result.error) log?.error(`${prefix} sendVoice error: ${result.error}`);
} catch (err) {
log?.error(`${prefix} sendVoice unexpected error: ${err}`);
}
}
/** Markdown 模式发送 */
async function sendMarkdownReply(
textWithoutImages: string,

View File

@@ -19,12 +19,11 @@ import {
MediaFileType,
} from "./api.js";
import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
import { normalizeMediaTags } from "./utils/media-tags.js";
import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
import { chunkedUploadC2C, chunkedUploadGroup } from "./utils/chunked-upload.js";
import { isLocalPath as isLocalFilePath, normalizePath } from "./utils/platform.js";
import { isLocalPath as isLocalFilePath, normalizePath, getQQBotMediaDir } from "./utils/platform.js";
import { downloadFile } from "./image-server.js";
import { getQQBotMediaDir } from "./utils/platform.js";
import { parseMediaTagsToSendQueue, executeSendQueue, type MediaSendContext } from "./utils/media-send.js";
// ============ 消息回复限流器 ============
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
@@ -706,7 +705,7 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
// 不应该发生,但作为保底
console.error(`[qqbot] sendText: 消息回复被限流但未设置降级 - ${limitCheck.message}`);
return {
channel: "qqbot",
channel: "qqbot",
error: limitCheck.message
};
}
@@ -722,170 +721,66 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
// <qqvideo>路径或URL</qqvideo> — 视频
// <qqfile>路径</qqfile> — 文件
// <qqmedia>路径或URL</qqmedia> — 自动识别(根据扩展名路由)
// 使用 deliver-common.ts 的公共解析器,消除与 gateway.ts 的重复
// 预处理:纠正小模型常见的标签拼写错误和格式问题
text = normalizeMediaTags(text);
const { hasMediaTags: hasMedia, sendQueue } = parseMediaTagsToSendQueue(text);
const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
const mediaTagMatches = text.match(mediaTagRegex);
if (mediaTagMatches && mediaTagMatches.length > 0) {
console.log(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`);
// 构建发送队列:根据内容在原文中的实际位置顺序发送
const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file" | "media"; content: string }> = [];
let lastIndex = 0;
const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
let match;
while ((match = mediaTagRegexWithIndex.exec(text)) !== null) {
// 添加标签前的文本
const textBefore = text.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
if (textBefore) {
sendQueue.push({ type: "text", content: textBefore });
}
const tagName = match[1]!.toLowerCase(); // "qqimg" or "qqvoice" or "qqfile"
// 剥离 MEDIA: 前缀(框架可能注入),展开 ~ 路径
let mediaPath = match[2]?.trim() ?? "";
if (mediaPath.startsWith("MEDIA:")) {
mediaPath = mediaPath.slice("MEDIA:".length);
}
mediaPath = normalizePath(mediaPath);
// 处理可能被模型转义的路径
// 1. 双反斜杠 -> 单反斜杠Markdown 转义)
mediaPath = mediaPath.replace(/\\\\/g, "\\");
// 2. 八进制转义序列 + UTF-8 双重编码修复
try {
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
if (hasOctal || hasNonASCII) {
console.log(`[qqbot] sendText: Decoding path with mixed encoding: ${mediaPath}`);
// Step 1: 将八进制转义转换为字节
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
return String.fromCharCode(parseInt(octal, 8));
});
// Step 2: 提取所有字节(包括 Latin-1 字符)
const bytes: number[] = [];
for (let i = 0; i < decoded.length; i++) {
const code = decoded.charCodeAt(i);
if (code <= 0xFF) {
bytes.push(code);
} else {
const charBytes = Buffer.from(decoded[i], 'utf8');
bytes.push(...charBytes);
}
}
// Step 3: 尝试按 UTF-8 解码
const buffer = Buffer.from(bytes);
const utf8Decoded = buffer.toString('utf8');
if (!utf8Decoded.includes('\uFFFD') || utf8Decoded.length < decoded.length) {
mediaPath = utf8Decoded;
console.log(`[qqbot] sendText: Successfully decoded path: ${mediaPath}`);
}
}
} catch (decodeErr) {
console.error(`[qqbot] sendText: Path decode error: ${decodeErr}`);
}
if (mediaPath) {
if (tagName === "qqmedia") {
sendQueue.push({ type: "media", content: mediaPath });
console.log(`[qqbot] sendText: Found auto-detect media in <qqmedia>: ${mediaPath}`);
} else if (tagName === "qqvoice") {
sendQueue.push({ type: "voice", content: mediaPath });
console.log(`[qqbot] sendText: Found voice path in <qqvoice>: ${mediaPath}`);
} else if (tagName === "qqvideo") {
sendQueue.push({ type: "video", content: mediaPath });
console.log(`[qqbot] sendText: Found video URL in <qqvideo>: ${mediaPath}`);
} else if (tagName === "qqfile") {
sendQueue.push({ type: "file", content: mediaPath });
console.log(`[qqbot] sendText: Found file path in <qqfile>: ${mediaPath}`);
} else {
sendQueue.push({ type: "image", content: mediaPath });
console.log(`[qqbot] sendText: Found image path in <qqimg>: ${mediaPath}`);
}
}
lastIndex = match.index + match[0].length;
}
// 添加最后一个标签后的文本
const textAfter = text.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
if (textAfter) {
sendQueue.push({ type: "text", content: textAfter });
}
if (hasMedia && sendQueue.length > 0) {
console.log(`[qqbot] sendText: Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
// 按顺序发送(使用 Telegram 风格的统一媒体发送函数)
// 构建统一媒体发送上下文
const mediaTarget = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendText]");
const mediaSendCtx: MediaSendContext = {
mediaTarget,
qualifiedTarget: to,
account,
replyToId: replyToId ?? undefined,
log: {
info: (msg: string) => console.log(msg),
error: (msg: string) => console.error(msg),
debug: (msg: string) => console.log(msg),
},
};
let lastResult: OutboundResult = { channel: "qqbot" };
for (const item of sendQueue) {
try {
if (item.type === "text") {
// 发送文本
if (replyToId) {
const accessToken = await getToken(account);
const target = parseTarget(to);
if (target.type === "c2c") {
const result = await sendC2CMessage(accessToken, target.id, item.content, replyToId);
recordMessageReply(replyToId);
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
} else if (target.type === "group") {
const result = await sendGroupMessage(accessToken, target.id, item.content, replyToId);
recordMessageReply(replyToId);
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
} else {
const result = await sendChannelMessage(accessToken, target.id, item.content, replyToId);
recordMessageReply(replyToId);
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
}
// 使用统一的发送队列执行器
await executeSendQueue(sendQueue, mediaSendCtx, {
onSendText: async (textContent) => {
// sendText 场景的文本发送:需要区分主动/被动消息
if (replyToId) {
const accessToken = await getToken(account);
const target = parseTarget(to);
if (target.type === "c2c") {
const result = await sendC2CMessage(accessToken, target.id, textContent, replyToId);
recordMessageReply(replyToId);
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
} else if (target.type === "group") {
const result = await sendGroupMessage(accessToken, target.id, textContent, replyToId);
recordMessageReply(replyToId);
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
} else {
const accessToken = await getToken(account);
const target = parseTarget(to);
if (target.type === "c2c") {
const result = await sendProactiveC2CMessage(accessToken, target.id, item.content);
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
} else if (target.type === "group") {
const result = await sendProactiveGroupMessage(accessToken, target.id, item.content);
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
} else {
const result = await sendChannelMessage(accessToken, target.id, item.content);
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
}
const result = await sendChannelMessage(accessToken, target.id, textContent, replyToId);
recordMessageReply(replyToId);
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
}
} else {
const accessToken = await getToken(account);
const target = parseTarget(to);
if (target.type === "c2c") {
const result = await sendProactiveC2CMessage(accessToken, target.id, textContent);
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
} else if (target.type === "group") {
const result = await sendProactiveGroupMessage(accessToken, target.id, textContent);
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
} else {
const result = await sendChannelMessage(accessToken, target.id, textContent);
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
}
console.log(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`);
} else if (item.type === "image") {
lastResult = await sendPhoto(mediaTarget, item.content);
} else if (item.type === "voice") {
lastResult = await sendVoice(mediaTarget, item.content, undefined, account.config?.audioFormatPolicy?.transcodeEnabled !== false);
} else if (item.type === "video") {
lastResult = await sendVideoMsg(mediaTarget, item.content);
} else if (item.type === "file") {
lastResult = await sendDocument(mediaTarget, item.content);
} else if (item.type === "media") {
// qqmedia: 自动根据扩展名路由
lastResult = await sendMedia({
to, text: "", mediaUrl: item.content,
accountId: account.accountId, replyToId, account,
});
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
console.error(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`);
}
}
console.log(`[qqbot] sendText: Sent text part: ${textContent.slice(0, 30)}...`);
},
});
return lastResult;
}

1088
src/streaming.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -75,6 +75,15 @@ export interface QQBotAccountConfig {
* 当短时间内收到多次 deliver 时,将文本合并为一条消息发送,避免消息轰炸
*/
deliverDebounce?: DeliverDebounceConfig;
/**
* 是否启用流式消息(默认 false
* 启用后AI 的回复会以流式形式逐步显示在 QQ 聊天中,
* 用户可以看到文字逐字出现的打字机效果。
* 设置为 true 可开启流式消息。
*
* 注意:仅 C2C私聊支持流式消息 API。
*/
streaming?: boolean;
}
/**
@@ -212,3 +221,74 @@ export interface WSPayload {
s?: number;
t?: string;
}
// ---- 流式消息常量 ----
/** 流式消息输入模式 */
export const StreamInputMode = {
/** 每次发送的 content_raw 替换整条消息内容 */
REPLACE: "replace",
} as const;
export type StreamInputMode = (typeof StreamInputMode)[keyof typeof StreamInputMode];
/** 流式消息输入状态 */
export const StreamInputState = {
/** 正文生成中 */
GENERATING: 1,
/** 正文生成结束(终结状态) */
DONE: 10,
} as const;
export type StreamInputState = (typeof StreamInputState)[keyof typeof StreamInputState];
/** 流式消息内容类型 */
export const StreamContentType = {
MARKDOWN: "markdown",
} as const;
export type StreamContentType = (typeof StreamContentType)[keyof typeof StreamContentType];
/**
* 流式消息请求体
* 对应 StreamReq proto
*/
export interface StreamMessageRequest {
/** 输入模式 */
input_mode: StreamInputMode;
/** 输入状态 */
input_state: StreamInputState;
/** 内容类型 */
content_type: StreamContentType;
/** markdown 内容 */
content_raw: string;
/** 事件 ID */
event_id: string;
/** 原始消息 ID */
msg_id: string;
/** 流式消息 ID首次发送后返回后续分片需携带 */
stream_msg_id?: string;
/** 递增序号 */
msg_seq: number;
/** 同一条流式会话内的发送索引,从 0 开始,每次发送前递增;新流式会话重新从 0 开始 */
index: number;
}
/**
* 流式消息响应体
* 对应 StreamRsp proto
*
* 成功时返回:{ id, timestamp, extInfo }(无 code/message
* 失败时返回:{ code, message }code > 0
*/
export interface StreamMessageResponse {
/** 错误码,仅失败时存在(> 0 表示失败);成功时不存在 */
code?: number;
/** 错误信息,仅失败时存在 */
message?: string;
/** 流式消息 ID */
id?: string;
/** 时间戳 */
timestamp?: string;
/** 扩展信息 */
extInfo?: Record<string, unknown>;
}

View File

@@ -1,7 +0,0 @@
/**
* 用户面向的提示文案 — 已清空
*
* 设计原则(对齐飞书插件):
* QQBot 插件层不生成额外的用户提示信息。
* 所有运行时错误仅写日志,不面向用户展示。
*/

563
src/utils/media-send.ts Normal file
View File

@@ -0,0 +1,563 @@
/**
* 富媒体标签解析与发送队列
*
* 提供媒体标签qqimg / qqvoice / qqvideo / qqfile / qqmedia的检测、
* 拆分、路径编码修复,以及统一的发送队列执行器。
*/
/* eslint-disable no-undef -- Buffer is a Node.js global */
import { normalizeMediaTags } from "./media-tags.js";
import { normalizePath } from "./platform.js";
import {
sendPhoto,
sendVoice,
sendVideoMsg,
sendDocument,
sendMedia as sendMediaAuto,
type MediaTargetContext,
} from "../outbound.js";
import type { ResolvedQQBotAccount } from "../types.js";
// ============ 类型定义 ============
/** 发送队列项 */
export interface SendQueueItem {
type: "text" | "image" | "voice" | "video" | "file" | "media";
content: string;
}
/** 统一的媒体标签正则 — 匹配标准化后的 6 种标签 */
export const MEDIA_TAG_REGEX =
/<(qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
/** 创建一个新的全局标签正则实例(每次调用 reset lastIndex */
export function createMediaTagRegex(): RegExp {
return new RegExp(MEDIA_TAG_REGEX.source, MEDIA_TAG_REGEX.flags);
}
/** 媒体发送上下文(统一的,供流式和普通模式共用) */
export interface MediaSendContext {
/** 媒体目标上下文(用于 sendPhoto/sendVoice 等) */
mediaTarget: MediaTargetContext;
/** qualifiedTarget格式 "qqbot:c2c:xxx" 或 "qqbot:group:xxx",用于 sendMediaAuto */
qualifiedTarget: string;
/** 账户配置 */
account: ResolvedQQBotAccount;
/** 事件消息 ID用于被动回复 */
replyToId?: string;
/** 日志 */
log?: {
info: (msg: string) => void;
error: (msg: string) => void;
debug?: (msg: string) => void;
};
}
// ============ 路径编码修复 ============
/**
* 修复路径编码问题双反斜杠、八进制转义、UTF-8 双重编码)
*
* 这是由于 LLM 输出路径时可能引入的编码问题:
* - Markdown 转义导致双反斜杠
* - 八进制转义序列(来自某些 shell 工具的输出)
* - UTF-8 双重编码(中文路径经过多层处理后的乱码)
*
* 此方法在 gateway.ts deliver 回调、outbound.ts sendText、
* streaming.ts sendMediaQueue 中共用。
*/
export function fixPathEncoding(mediaPath: string, log?: { debug?: (msg: string) => void; error?: (msg: string) => void }): string {
// 1. 双反斜杠 -> 单反斜杠Markdown 转义)
let result = mediaPath.replace(/\\\\/g, "\\");
// 2. 八进制转义序列 + UTF-8 双重编码修复
try {
const hasOctal = /\\[0-7]{1,3}/.test(result);
const hasNonASCII = /[\u0080-\u00FF]/.test(result);
if (hasOctal || hasNonASCII) {
log?.debug?.(`Decoding path with mixed encoding: ${result}`);
// Step 1: 将八进制转义转换为字节
let decoded = result.replace(
/\\([0-7]{1,3})/g,
(_: string, octal: string) => String.fromCharCode(parseInt(octal, 8)),
);
// Step 2: 提取所有字节(包括 Latin-1 字符)
const bytes: number[] = [];
for (let i = 0; i < decoded.length; i++) {
const code = decoded.charCodeAt(i);
if (code <= 0xff) {
bytes.push(code);
} else {
const charBytes = Buffer.from(decoded[i]!, "utf8");
bytes.push(...charBytes);
}
}
// Step 3: 尝试按 UTF-8 解码
const buffer = Buffer.from(bytes);
const utf8Decoded = buffer.toString("utf8");
if (
!utf8Decoded.includes("\uFFFD") ||
utf8Decoded.length < decoded.length
) {
result = utf8Decoded;
log?.debug?.(`Successfully decoded path: ${result}`);
}
}
} catch (decodeErr) {
log?.error?.(`Path decode error: ${decodeErr}`);
}
return result;
}
// ============ 媒体标签解析 ============
/**
* 检测文本是否包含富媒体标签
*/
export function hasMediaTags(text: string): boolean {
const normalized = normalizeMediaTags(text);
const regex = createMediaTagRegex();
return regex.test(normalized);
}
/** findFirstClosedMediaTag 的返回值 */
export interface FirstClosedMediaTag {
/** 标签前的纯文本(已 trim */
textBefore: string;
/** 标签类型(小写,如 "qqvoice" */
tagName: string;
/** 标签内的媒体路径(已 trim、去 MEDIA: 前缀、修复编码) */
mediaPath: string;
/** 标签在输入文本中的结束索引(紧接标签后的第一个字符位置) */
tagEndIndex: number;
/** 映射后的发送队列项类型 */
itemType: SendQueueItem["type"];
}
/**
* 在文本中查找**第一个**完整闭合的媒体标签
*
* 与 splitByMediaTags 不同,此函数只匹配一个标签就停止,
* 用于流式场景的"循环消费"模式:每次处理一个标签,更新偏移,再找下一个。
*
* @param text 待检查的文本(应已 normalize 过)
* @returns 第一个闭合标签的信息,没有则返回 null
*/
export function findFirstClosedMediaTag(
text: string,
log?: { info?: (msg: string) => void; debug?: (msg: string) => void; error?: (msg: string) => void },
): FirstClosedMediaTag | null {
const regex = createMediaTagRegex();
const match = regex.exec(text);
if (!match) return null;
const textBefore = text.slice(0, match.index).replace(/\n{3,}/g, "\n\n").trim();
const tagName = match[1]!.toLowerCase();
let mediaPath = match[2]?.trim() ?? "";
// 剥离 MEDIA: 前缀
if (mediaPath.startsWith("MEDIA:")) {
mediaPath = mediaPath.slice("MEDIA:".length);
}
mediaPath = normalizePath(mediaPath);
mediaPath = fixPathEncoding(mediaPath, log);
const typeMap: Record<string, SendQueueItem["type"]> = {
qqimg: "image",
qqvoice: "voice",
qqvideo: "video",
qqfile: "file",
qqmedia: "media",
};
return {
textBefore,
tagName,
mediaPath,
tagEndIndex: match.index! + match[0].length,
itemType: typeMap[tagName] ?? "image",
};
}
/**
* 媒体标签拆分结果
*/
export interface MediaSplitResult {
/** 是否包含媒体标签 */
hasMediaTags: boolean;
/** 媒体标签前的纯文本 */
textBeforeFirstTag: string;
/** 媒体标签后的剩余文本 */
textAfterLastTag: string;
/** 完整的发送队列(标签间的文本 + 媒体项) */
mediaQueue: SendQueueItem[];
}
/**
* 将文本按富媒体标签拆分为三部分
*
* 用于两个场景:
* 1. 流式模式:中断-恢复流程(标签前文本 → 结束流式 → 发送媒体 → 新流式 → 标签后文本)
* 2. 普通模式:构建按顺序发送的队列
*/
export function splitByMediaTags(
text: string,
log?: { info?: (msg: string) => void; debug?: (msg: string) => void; error?: (msg: string) => void },
): MediaSplitResult {
const normalized = normalizeMediaTags(text);
const regex = createMediaTagRegex();
const matches = [...normalized.matchAll(regex)];
if (matches.length === 0) {
return {
hasMediaTags: false,
textBeforeFirstTag: normalized,
textAfterLastTag: "",
mediaQueue: [],
};
}
// 第一个标签前的纯文本
const firstMatch = matches[0]!;
const textBeforeFirstTag = normalized
.slice(0, firstMatch.index)
.replace(/\n{3,}/g, "\n\n")
.trim();
// 最后一个标签后的纯文本
const lastMatch = matches[matches.length - 1]!;
const lastMatchEnd = lastMatch.index! + lastMatch[0].length;
const textAfterLastTag = normalized
.slice(lastMatchEnd)
.replace(/\n{3,}/g, "\n\n")
.trim();
// 构建媒体发送队列
const mediaQueue: SendQueueItem[] = [];
let lastIndex = firstMatch.index!;
for (const match of matches) {
// 标签前的文本(标签之间的间隔文本)
const textBetween = normalized
.slice(lastIndex, match.index)
.replace(/\n{3,}/g, "\n\n")
.trim();
if (textBetween && lastIndex !== firstMatch.index) {
// 只添加非首段的间隔文本(首段由 textBeforeFirstTag 覆盖)
mediaQueue.push({ type: "text", content: textBetween });
}
// 解析标签内容
const tagName = match[1]!.toLowerCase();
let mediaPath = match[2]?.trim() ?? "";
// 剥离 MEDIA: 前缀
if (mediaPath.startsWith("MEDIA:")) {
mediaPath = mediaPath.slice("MEDIA:".length);
}
mediaPath = normalizePath(mediaPath);
// 修复路径编码问题
mediaPath = fixPathEncoding(mediaPath, log);
// 根据标签类型加入队列
const typeMap: Record<string, SendQueueItem["type"]> = {
qqimg: "image",
qqvoice: "voice",
qqvideo: "video",
qqfile: "file",
qqmedia: "media",
};
const itemType = typeMap[tagName] ?? "image";
if (mediaPath) {
mediaQueue.push({ type: itemType, content: mediaPath });
log?.info?.(`Found ${itemType} in <${tagName}>: ${mediaPath.slice(0, 80)}`);
}
lastIndex = match.index! + match[0].length;
}
return {
hasMediaTags: true,
textBeforeFirstTag,
textAfterLastTag,
mediaQueue,
};
}
/**
* 从文本中解析出完整的发送队列(含标签前后的纯文本)
*
* 与 splitByMediaTags 的区别:
* - splitByMediaTags 分为 before / queue / after 三段(供流式模式的中断-恢复)
* - parseMediaTagsToSendQueue 返回一个扁平的完整队列(供普通模式按顺序发送)
*
* 适用于 gateway.ts deliver 回调和 outbound.ts sendText。
*/
export function parseMediaTagsToSendQueue(
text: string,
log?: { info?: (msg: string) => void; debug?: (msg: string) => void; error?: (msg: string) => void },
): { hasMediaTags: boolean; sendQueue: SendQueueItem[] } {
const split = splitByMediaTags(text, log);
if (!split.hasMediaTags) {
return { hasMediaTags: false, sendQueue: [] };
}
const sendQueue: SendQueueItem[] = [];
// 标签前的文本
if (split.textBeforeFirstTag) {
sendQueue.push({ type: "text", content: split.textBeforeFirstTag });
}
// 媒体队列(含标签间文本)
sendQueue.push(...split.mediaQueue);
// 标签后的文本
if (split.textAfterLastTag) {
sendQueue.push({ type: "text", content: split.textAfterLastTag });
}
return { hasMediaTags: true, sendQueue };
}
// ============ 发送队列执行 ============
/**
* 统一执行发送队列
*
* 遍历 sendQueue按类型调用对应的发送函数。
* 文本项通过 onSendText 回调处理(不同场景的文本发送方式不同)。
*/
export async function executeSendQueue(
queue: SendQueueItem[],
ctx: MediaSendContext,
options: {
/** 文本发送回调(每种场景的文本发送方式不同) */
onSendText?: (text: string) => Promise<void>;
/** 是否跳过 inter-tag 文本(流式模式下通常跳过,由新流式会话处理) */
skipInterTagText?: boolean;
} = {},
): Promise<void> {
const { mediaTarget, qualifiedTarget, account, replyToId, log } = ctx;
const prefix = mediaTarget.logPrefix ?? `[qqbot:${account.accountId}]`;
for (const item of queue) {
try {
if (item.type === "text") {
if (options.skipInterTagText) {
log?.info(`${prefix} executeSendQueue: skipping inter-tag text (${item.content.length} chars)`);
continue;
}
if (options.onSendText) {
await options.onSendText(item.content);
} else {
log?.info(`${prefix} executeSendQueue: no onSendText handler, skipping text`);
}
continue;
}
log?.info(`${prefix} executeSendQueue: sending ${item.type}: ${item.content.slice(0, 80)}...`);
if (item.type === "image") {
const result = await sendPhoto(mediaTarget, item.content);
if (result.error) {
log?.error(`${prefix} sendPhoto error: ${result.error}`);
}
} else if (item.type === "voice") {
const uploadFormats =
account.config?.audioFormatPolicy?.uploadDirectFormats ??
account.config?.voiceDirectUploadFormats;
const transcodeEnabled =
account.config?.audioFormatPolicy?.transcodeEnabled !== false;
const voiceTimeout = 45000; // 45s
try {
const result = await Promise.race([
sendVoice(mediaTarget, item.content, uploadFormats, transcodeEnabled),
new Promise<{ channel: string; error: string }>((resolve) =>
setTimeout(
() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }),
voiceTimeout,
),
),
]);
if (result.error) {
log?.error(`${prefix} sendVoice error: ${result.error}`);
}
} catch (err) {
log?.error(`${prefix} sendVoice unexpected error: ${err}`);
}
} else if (item.type === "video") {
const result = await sendVideoMsg(mediaTarget, item.content);
if (result.error) {
log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
}
} else if (item.type === "file") {
const result = await sendDocument(mediaTarget, item.content);
if (result.error) {
log?.error(`${prefix} sendDocument error: ${result.error}`);
}
} else if (item.type === "media") {
const result = await sendMediaAuto({
to: qualifiedTarget,
text: "",
mediaUrl: item.content,
accountId: account.accountId,
replyToId,
account,
});
if (result.error) {
log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
}
}
} catch (err) {
log?.error(`${prefix} executeSendQueue: failed to send ${item.type}: ${err}`);
}
}
}
/**
* 从文本中剥离所有媒体标签(用于最终显示)
*/
export function stripMediaTags(text: string): string {
const regex = createMediaTagRegex();
return text.replace(regex, "").replace(/\n{3,}/g, "\n\n").trim();
}
/**
* 检测文本中是否有未闭合的媒体标签,如果有则截断到安全位置。
*
* 流式输出中 LLM 逐 token 吐出媒体标签,中间态不应直接发给用户。
* 只检查最后一行,从右到左扫描 `<`,找到第一个有意义的媒体标签片段并判断是否完整。
*
* 核心原则:截断只能截到**开标签**前面;闭合标签前缀若找不到对应开标签则原样返回。
*/
export function stripIncompleteMediaTag(text: string): [safeText: string, hasIncomplete: boolean] {
if (!text) return [text, false];
const lastNL = text.lastIndexOf("\n");
const lastLine = lastNL === -1 ? text : text.slice(lastNL + 1);
if (!lastLine) return [text, false]; // 以换行结尾,安全
const lineStart = lastNL === -1 ? 0 : lastNL + 1;
// ---- 媒体标签名判断 ----
const MEDIA_NAMES = [
"qq", "img", "image", "pic", "photo", "voice", "audio", "video",
"file", "doc", "media", "attach", "send", "document", "picture",
"qqvoice", "qqaudio", "qqvideo", "qqimg", "qqimage", "qqfile",
"qqpic", "qqphoto", "qqmedia", "qqattach", "qqsend", "qqdocument", "qqpicture",
];
const isMedia = (n: string) => MEDIA_NAMES.includes(n.toLowerCase());
const couldBeMedia = (n: string) => { const l = n.toLowerCase(); return MEDIA_NAMES.some(m => m.startsWith(l)); };
/** 截断到 lastLine 中位置 pos 之前,返回 [safe, true] */
const cutAt = (pos: number): [string, true] => [
text.slice(0, lineStart + pos).trimEnd(),
true,
];
/** 检查 lastLine 中位置 pos 处的媒体开标签后面是否有完整闭合标签 */
const hasClosingAfter = (pos: number, name: string): boolean => {
const rest = lastLine.slice(pos + 1); // < 之后
const gt = rest.search(/[>]/);
if (gt < 0) return false;
const after = rest.slice(gt + 1);
return new RegExp(`[<\uFF1C]/${name}\\s*[>\uFF1E]`, "i").test(after);
};
// ---- 回溯状态 ----
// 遇到不完整的闭合标签/孤立 < 时,记录并继续往左找对应的开标签
let searchTag: string | null = null; // 要找的开标签名,"*" = 来自孤立 <
let searchIsClosing = false; // 触发回溯的是闭合类(</、</tag还是开类<
let fallbackPos = -1; // 最右边触发回溯的 < 的位置
for (let i = lastLine.length - 1; i >= 0; i--) {
const ch = lastLine[i];
if (ch !== "<" && ch !== "\uFF1C") continue;
const after = lastLine.slice(i + 1);
const isClosing = after.startsWith("/");
const nameStr = isClosing ? after.slice(1) : after;
const nameMatch = nameStr.match(/^(\w+)/);
// ======== 回溯模式:正在找对应的开标签 ========
if (searchTag) {
if (!nameMatch || isClosing) continue;
const cand = nameMatch[1].toLowerCase();
if (!isMedia(cand)) continue;
// 跳过已有完整闭合对的开标签
if (hasClosingAfter(i, cand)) continue;
if (searchTag === "*") {
return cutAt(i); // 通配:任何未闭合的媒体开标签都匹配
}
// 精确/前缀匹配(闭合标签名可能不完整,如 </qq 对 <qqvoice
const t = searchTag.toLowerCase();
if (cand === t || cand.startsWith(t)) return cutAt(i);
continue;
}
// ======== 正常扫描 ========
// --- 无标签名:孤立 < 或 </ ---
if (!nameMatch) {
if (!after) {
// 孤立 <:可能是新开标签,往左找未闭合的媒体开标签
if (fallbackPos < 0) fallbackPos = i;
searchTag = "*"; searchIsClosing = false;
} else if (after === "/") {
// 孤立 </:闭合标签开始,找不到开标签时原样返回
if (fallbackPos < 0) fallbackPos = i;
searchTag = "*"; searchIsClosing = true;
}
// 其他(如 "< 3"):非标签,跳过
continue;
}
const tag = nameMatch[1];
const restAfterName = nameStr.slice(tag.length);
const hasGT = /[>]/.test(restAfterName);
// --- 不是媒体标签(也不是前缀) ---
if (!isMedia(tag) && !(couldBeMedia(tag) && !hasGT)) continue;
// --- 标签未闭合(无 >),还在输入中 ---
if (!hasGT) {
if (isClosing) {
// 不完整闭合标签(如 </voice、</i→ 回溯找开标签
if (fallbackPos < 0) fallbackPos = i;
searchTag = tag; searchIsClosing = true;
continue;
}
// 不完整开标签(如 <img、<i→ 截断
return cutAt(i);
}
// --- 标签有 >,是完整的 ---
if (isClosing) return [text, false]; // 完整闭合标签 </tag> → 安全
// 完整开标签 <tag...>,检查后面有无对应 </tag>
if (hasClosingAfter(i, tag)) return [text, false];
return cutAt(i); // 无闭合 → 截断
}
// ---- 循环结束,处理回溯未命中 ----
if (searchTag) {
if (!searchIsClosing) {
// 来自孤立 <,前面没有媒体开标签 → 截断到那个 < 前面
return cutAt(fallbackPos);
}
// 来自闭合类(</、</tag前面找不到对应开标签 → 不可能是有效媒体标签,原样返回
return [text, true];
}
return [text, false]; // 最后一行无任何 < → 安全
}

View File

@@ -0,0 +1,770 @@
/**
* StreamingController 集成测试
*
* 通过 mock global.fetch 验证流式消息控制器的核心行为,
* 重点覆盖:循环消费模型 (processMediaTags) + pendingNormalizedFull 补救机制。
*
* 运行方式: npx tsx tests/streaming-controller.test.ts
*/
import assert from "node:assert";
// ============ Mock global.fetch ============
/** 记录所有流式 API 调用 */
interface StreamCall {
content: string;
inputState: number; // 1 = GENERATING, 10 = DONE
streamMsgId?: string;
index: number;
url: string;
}
/** 记录所有媒体上传 API 调用 */
interface MediaUploadCall {
url: string;
body: any;
}
let streamCalls: StreamCall[] = [];
let mediaUploadCalls: MediaUploadCall[] = [];
let streamMsgIdCounter = 0;
/** 控制流式 API 的延迟(毫秒)。设为 > 0 模拟 API 慢响应。 */
let apiDelayMs = 0;
/** 控制媒体上传 API 的延迟(毫秒)。 */
let mediaApiDelayMs = 0;
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function resetMocks() {
streamCalls = [];
mediaUploadCalls = [];
streamMsgIdCounter = 0;
apiDelayMs = 0;
mediaApiDelayMs = 0;
}
// 保存原始 fetch
const originalFetch = globalThis.fetch;
// 覆写 global.fetch
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const body = init?.body ? JSON.parse(init.body as string) : {};
// ---- Token 请求 ----
if (url.includes("/getAppAccessToken")) {
return new Response(JSON.stringify({
access_token: "mock-token-12345",
expires_in: "7200",
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
// ---- 流式消息 API ----
if (url.includes("/stream_messages")) {
if (apiDelayMs > 0) await sleep(apiDelayMs);
const call: StreamCall = {
content: body.content_raw ?? "",
inputState: body.input_state ?? 0,
streamMsgId: body.stream_msg_id,
index: body.index ?? 0,
url,
};
streamCalls.push(call);
// 首次调用(无 stream_msg_id→ 返回新的 stream_msg_id
let respBody: any;
if (!body.stream_msg_id) {
streamMsgIdCounter++;
respBody = { id: `stream-${streamMsgIdCounter}`, timestamp: Date.now().toString() };
} else {
respBody = { id: body.stream_msg_id, timestamp: Date.now().toString() };
}
return new Response(JSON.stringify(respBody), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// ---- 富媒体上传 API (v2/users/.../files) ----
if (url.includes("/files")) {
if (mediaApiDelayMs > 0) await sleep(mediaApiDelayMs);
mediaUploadCalls.push({ url, body });
return new Response(JSON.stringify({
file_uuid: `uuid-${mediaUploadCalls.length}`,
file_info: "mock",
ttl: 3600,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// ---- 普通消息 API (v2/users/.../messages) ----
if (url.includes("/messages")) {
if (mediaApiDelayMs > 0) await sleep(mediaApiDelayMs);
return new Response(JSON.stringify({
id: `msg-resp-${Date.now()}`,
timestamp: Date.now().toString(),
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// 未匹配的请求,回退到原始 fetch
console.warn(`[mock-fetch] 未匹配的请求: ${url}`);
return originalFetch(input, init);
};
// ---- 现在 import StreamingController它会使用被 mock 的 global.fetch ----
const { StreamingController } = await import("../src/streaming.js");
type StreamingControllerType = InstanceType<typeof StreamingController>;
// ============ 辅助函数 ============
/** 等待异步任务完成 */
async function flush(ms = 100): Promise<void> {
await sleep(ms);
}
/** 收集日志 */
const logs: string[] = [];
function createController(opts?: { mediaContext?: boolean; onReplyBoundary?: (newText: string) => void | Promise<void> }): StreamingControllerType {
logs.length = 0;
const deps: any = {
account: {
accountId: "test",
enabled: true,
appId: "test-app",
clientSecret: "test-secret",
secretSource: "config" as const,
markdownSupport: true,
config: {
streaming: true,
streamingConfig: { throttleMs: 50 }, // 短节流方便测试
},
},
userId: "user-1",
replyToMsgId: "msg-1",
eventId: "event-1",
logPrefix: "[test]",
log: {
info: (m: string) => logs.push(`[INFO] ${m}`),
error: (m: string) => logs.push(`[ERROR] ${m}`),
warn: (m: string) => logs.push(`[WARN] ${m}`),
debug: (m: string) => logs.push(`[DEBUG] ${m}`),
},
};
if (opts?.mediaContext) {
deps.mediaContext = {
account: deps.account,
event: { type: "c2c", senderId: "user-1", messageId: "msg-1" },
log: deps.log,
};
}
if (opts?.onReplyBoundary) {
deps.onReplyBoundary = opts.onReplyBoundary;
}
return new StreamingController(deps);
}
// ============ 测试框架 ============
let passed = 0;
let failed = 0;
const failedTests: string[] = [];
async function test(name: string, fn: () => Promise<void>) {
resetMocks();
try {
await fn();
console.log(`${name}`);
passed++;
} catch (e: any) {
console.log(`${name}`);
console.log(` ${e.message}`);
if (e.stack) {
const lines = (e.stack as string).split("\n").slice(1, 4);
for (const l of lines) console.log(` ${l.trim()}`);
}
// 打印关键日志(去掉 DEBUG 减少噪音)
const relevantLogs = logs.filter((l) => !l.includes("[DEBUG]")).slice(-10);
if (relevantLogs.length > 0) {
console.log(` --- 日志 ---`);
for (const l of relevantLogs) console.log(` ${l}`);
}
failed++;
failedTests.push(name);
}
}
// ============ 测试用例 ============
console.log("\n=== 1. 纯文本流式 ===");
await test("纯文本: 基本流式 → 完成", async () => {
const ctrl = createController();
await ctrl.onPartialReply({ text: "你好" });
await flush();
await ctrl.onPartialReply({ text: "你好世界" });
await flush();
ctrl.markFullyComplete();
await ctrl.onIdle();
// 应该有流式分片发送
assert.ok(streamCalls.length >= 2, `应至少有 2 次流式调用,实际 ${streamCalls.length}`);
// 最后一次应该是 DONE (inputState=10)
const last = streamCalls[streamCalls.length - 1];
assert.strictEqual(last.inputState, 10, "最后一次应为 DONE");
assert.ok(last.content.includes("你好世界"), `最终文本应包含完整内容,实际: "${last.content}"`);
// 不应有媒体上传
assert.strictEqual(mediaUploadCalls.length, 0, "不应有媒体上传");
});
await test("纯文本: 空文本被忽略", async () => {
const ctrl = createController();
await ctrl.onPartialReply({ text: "" });
await ctrl.onPartialReply({ text: undefined });
await flush();
assert.strictEqual(streamCalls.length, 0, "不应有流式调用");
});
await test("纯文本: 全空白不启动流式,后续非空白一起发送", async () => {
const ctrl = createController();
// 先来一段全空白内容 — 不应启动流式
await ctrl.onPartialReply({ text: "\n\n " });
await flush();
assert.strictEqual(streamCalls.length, 0, "全空白阶段不应有流式调用");
// 后续有非空白内容到达 — 应启动流式,且包含之前的空白
await ctrl.onPartialReply({ text: "\n\n hello world" });
await flush(200);
assert.ok(streamCalls.length >= 1, `应有至少 1 次流式调用,实际 ${streamCalls.length}`);
// 首次发送的内容应包含之前保留的空白 + 新内容
const firstContent = streamCalls[0].content;
assert.ok(firstContent.includes("hello world"), `首次发送应包含 "hello world",实际: "${firstContent}"`);
ctrl.markFullyComplete();
await ctrl.onIdle();
await flush(100);
});
await test("纯文本: 全空白 + 媒体标签,空白不发送,媒体正常处理", async () => {
const ctrl = createController({ mediaContext: true });
apiDelayMs = 5;
mediaApiDelayMs = 5;
// 全空白前缀 + 媒体标签一起到达
await ctrl.onPartialReply({ text: "\n\n<qqimg>/tmp/pic.jpg</qqimg>描述文字" });
await flush(400);
ctrl.markFullyComplete();
await ctrl.onIdle();
await flush(200);
// 验证:日志中检测到了媒体标签
const foundLogs = logs.filter((l) => l.includes("processMediaTags: found"));
assert.ok(foundLogs.length >= 1, `应检测到 qqimg 标签,实际 ${foundLogs.length}`);
// 验证:不应有 PREFIX MISMATCH 错误
const prefixMismatchLogs = logs.filter((l) => l.includes("PREFIX MISMATCH"));
assert.strictEqual(prefixMismatchLogs.length, 0, `不应有 PREFIX MISMATCH实际: ${prefixMismatchLogs.join("; ")}`);
// 验证:如果有流式分片发送,内容不应是纯空白
const generatingCalls = streamCalls.filter((c) => c.inputState === 1);
for (const call of generatingCalls) {
assert.ok(call.content.trim().length > 0, `流式分片不应为纯空白: "${call.content}"`);
}
});
await test("纯文本: 空白→媒体→空白→媒体→空白,只有媒体发出,空白全忽略", async () => {
const ctrl = createController({ mediaContext: true });
apiDelayMs = 5;
mediaApiDelayMs = 5;
// 逐步送入:空白 → 媒体标签1 → 空白 → 媒体标签2 → 空白
const fullText =
"\n \n" +
"<qqimg>/tmp/pic1.jpg</qqimg>" +
"\n\n \n" +
"<qqimg>/tmp/pic2.jpg</qqimg>" +
" \n\n";
// 模拟流式分段到达
// 阶段 1纯空白
await ctrl.onPartialReply({ text: "\n \n" });
await flush(200);
assert.strictEqual(streamCalls.length, 0, "纯空白阶段不应有流式调用");
// 阶段 2空白 + 第一个媒体标签
await ctrl.onPartialReply({ text: "\n \n<qqimg>/tmp/pic1.jpg</qqimg>" });
await flush(400);
// 阶段 3继续加空白
await ctrl.onPartialReply({
text: "\n \n<qqimg>/tmp/pic1.jpg</qqimg>\n\n \n",
});
await flush(200);
// 阶段 4第二个媒体标签
await ctrl.onPartialReply({
text: "\n \n<qqimg>/tmp/pic1.jpg</qqimg>\n\n \n<qqimg>/tmp/pic2.jpg</qqimg>",
});
await flush(400);
// 阶段 5末尾空白完成
await ctrl.onPartialReply({ text: fullText });
await flush(200);
ctrl.markFullyComplete();
await ctrl.onIdle();
await flush(300);
// 验证 1应检测到 2 个媒体标签
const foundLogs = logs.filter((l) => l.includes("processMediaTags: found"));
assert.ok(foundLogs.length >= 2, `应检测到至少 2 个 qqimg 标签,实际 ${foundLogs.length}`);
// 验证 2应有 2 次媒体发送尝试sendPhoto 可能因文件不存在失败,但 sending 日志应存在)
const sendingLogs = logs.filter((l) => l.includes("sending image"));
assert.ok(sendingLogs.length >= 2, `应有至少 2 次发送图片尝试,实际 ${sendingLogs.length}`);
// 验证 3不应有 PREFIX MISMATCH 错误
const prefixMismatchLogs = logs.filter((l) => l.includes("PREFIX MISMATCH"));
assert.strictEqual(
prefixMismatchLogs.length,
0,
`不应有 PREFIX MISMATCH实际: ${prefixMismatchLogs.join("; ")}`,
);
// 验证 4流式分片GENERATING 状态)中不应出现纯空白内容
const generatingCallsInner = streamCalls.filter((c) => c.inputState === 1);
for (const call of generatingCallsInner) {
assert.ok(
call.content.trim().length > 0,
`流式分片不应为纯空白: "${call.content.replace(/\n/g, "\\n").replace(/ /g, "·")}"`,
);
}
// 验证 5如果有流式启动首次调用无 stream_msg_id首次内容也不应纯空白
const startCalls = streamCalls.filter((c) => !c.streamMsgId);
for (const call of startCalls) {
assert.ok(
call.content.trim().length > 0,
`流式启动内容不应为纯空白: "${call.content.replace(/\n/g, "\\n").replace(/ /g, "·")}"`,
);
}
});
console.log("\n=== 2. 单个媒体标签 ===");
await test("媒体标签: 多媒体后跟文本onIdle 终结不 PREFIX MISMATCH", async () => {
const ctrl = createController({ mediaContext: true });
apiDelayMs = 5;
mediaApiDelayMs = 5;
// 模拟实际场景:两个语音标签 + 后续文本描述,逐步到达
// 阶段1第一个语音标签
await ctrl.onPartialReply({
text: "<qqvoice>/tmp/voice1.mp3</qqvoice>",
});
await flush(400);
// 阶段2两个语音标签
await ctrl.onPartialReply({
text: "<qqvoice>/tmp/voice1.mp3</qqvoice>\n<qqvoice>/tmp/voice2.mp3</qqvoice>",
});
await flush(400);
// 阶段3两个语音标签 + 后续文本
await ctrl.onPartialReply({
text: "<qqvoice>/tmp/voice1.mp3</qqvoice>\n<qqvoice>/tmp/voice2.mp3</qqvoice>\n两条语音都发给你啦",
});
await flush(400);
// 完成
ctrl.markFullyComplete();
await ctrl.onIdle();
await flush(300);
// 验证:应检测到 2 个媒体标签
const foundLogs = logs.filter((l) => l.includes("processMediaTags: found"));
assert.ok(foundLogs.length >= 2, `应检测到至少 2 个标签,实际 ${foundLogs.length}`);
// 核心验证:不应有 PREFIX MISMATCH
const prefixMismatchLogs = logs.filter((l) => l.includes("PREFIX MISMATCH"));
assert.strictEqual(
prefixMismatchLogs.length,
0,
`不应有 PREFIX MISMATCH实际: ${prefixMismatchLogs.join("; ")}`,
);
// 验证:最终流式文本(如果有)应包含后续描述文本
const doneCalls = streamCalls.filter((c) => c.inputState === 10);
if (doneCalls.length > 0) {
const lastDone = doneCalls[doneCalls.length - 1];
assert.ok(
lastDone.content.includes("两条语音都发给你啦"),
`终结分片应包含后续文本,实际: "${lastDone.content.slice(0, 80)}"`,
);
// 终结文本不应包含原始媒体标签
assert.ok(
!lastDone.content.includes("<qqvoice>"),
`终结文本不应包含 <qqvoice> 标签,实际: "${lastDone.content.slice(0, 80)}"`,
);
}
});
await test("媒体标签: 文本 + 图片 + 后续文本", async () => {
const ctrl = createController({ mediaContext: true });
// 一次性送入完整的含图片标签文本
await ctrl.onPartialReply({ text: "看图:<qqimg>/tmp/cat.jpg</qqimg>" });
await flush(300);
// 后续文本到达
await ctrl.onPartialReply({ text: "看图:<qqimg>/tmp/cat.jpg</qqimg>\n\n好看吧" });
await flush(300);
ctrl.markFullyComplete();
await ctrl.onIdle();
// 验证:应有媒体上传调用(图片需要先上传再发送)
// 或者至少流式中出现过图片相关处理
// 关键验证:最终流式文本不应包含原始 <qqimg> 标签
const lastStream = streamCalls[streamCalls.length - 1];
assert.ok(!lastStream.content.includes("<qqimg>"), "最终文本不应包含 <qqimg> 标签");
});
await test("媒体标签: 纯媒体开头(无前置文本)", async () => {
const ctrl = createController({ mediaContext: true });
await ctrl.onPartialReply({ text: "<qqvoice>/tmp/hello.mp3</qqvoice>" });
await flush(300);
ctrl.markFullyComplete();
await ctrl.onIdle();
await flush(200);
// 验证:应该至少有流式会话创建或媒体处理的记录
const infoLogs = logs.filter((l) => l.includes("processMediaTags") && l.includes("found"));
assert.ok(infoLogs.length >= 1, `应检测到 qqvoice 标签。相关日志: ${infoLogs.join("; ") || "无"}`);
});
console.log("\n=== 3. 多个媒体标签(循环消费) ===");
await test("多媒体: 两个图片标签被逐个处理", async () => {
const ctrl = createController({ mediaContext: true });
const text = "图1<qqimg>/tmp/a.jpg</qqimg>\n图2<qqimg>/tmp/b.jpg</qqimg>\n完毕";
await ctrl.onPartialReply({ text });
await flush(600);
ctrl.markFullyComplete();
await ctrl.onIdle();
await flush(200);
// 验证:日志中应显示找到了两个标签
const foundLogs = logs.filter((l) => l.includes("processMediaTags: found"));
assert.ok(foundLogs.length >= 2, `应找到至少 2 个标签,实际 ${foundLogs.length} 条 found 日志`);
});
console.log("\n=== 4. 未闭合标签等待 ===");
await test("未闭合标签: 逐步到达后完整处理", async () => {
const ctrl = createController({ mediaContext: true });
// 不完整的标签
await ctrl.onPartialReply({ text: "开始<qqimg>/tmp/pic" });
await flush(200);
// 验证:此时流式文本应该只包含 "开始",不含标签部分
const midCalls = [...streamCalls];
for (const call of midCalls) {
assert.ok(!call.content.includes("<qqimg>"), `中间态不应包含未闭合标签,内容: "${call.content}"`);
}
// 标签完整了
await ctrl.onPartialReply({ text: "开始<qqimg>/tmp/pic.jpg</qqimg>\n看看" });
await flush(300);
ctrl.markFullyComplete();
await ctrl.onIdle();
await flush(200);
// 验证:应检测到完整标签
const foundLogs = logs.filter((l) => l.includes("processMediaTags: found"));
assert.ok(foundLogs.length >= 1, `标签完整后应被检测到found 日志: ${foundLogs.length}`);
});
console.log("\n=== 5. ★ pendingNormalizedFull 补救机制 ===");
await test("补救: 媒体处理期间最后一次 onPartialReply 不丢失", async () => {
const ctrl = createController({ mediaContext: true });
// 设置 API 有延迟,模拟 processMediaTags 执行耗时
apiDelayMs = 50;
mediaApiDelayMs = 80;
// 第1次: 含媒体标签 → 进入 processMediaTagsmediaInterruptInProgress=true
const p1 = ctrl.onPartialReply({ text: "hi<qqimg>/tmp/x.jpg</qqimg>" });
// 等一小段确保 processMediaTags 已经开始
await sleep(20);
// 第2次: 这是"最后一次" onPartialReply —— 带有新的后续文本
// 因为 mediaInterruptInProgress=true会被保存到 pendingNormalizedFull
const p2 = ctrl.onPartialReply({ text: "hi<qqimg>/tmp/x.jpg</qqimg>\n\n再见朋友" });
// 等待所有处理完成(包括 deferred re-run
await p1;
await p2;
await flush(800);
// 标记完成
ctrl.markFullyComplete();
await ctrl.onIdle();
await flush(300);
// ★ 核心验证:最终发送的文本应包含 "再见朋友"
const allStreamContent = streamCalls.map((c) => c.content).join(" || ");
assert.ok(
allStreamContent.includes("再见朋友"),
`"再见朋友" 应出现在流式发送中pendingNormalizedFull 补救)。\n实际流式内容: [\n${streamCalls.map((c, i) => ` ${i}: "${c.content.slice(0, 80)}" (state=${c.inputState})`).join("\n")}\n]`
);
// 验证deferred 日志应出现
const deferredLogs = logs.filter((l) => l.includes("deferred"));
assert.ok(deferredLogs.length >= 1, `应有 deferred 相关日志,实际: ${deferredLogs.length}`);
});
await test("补救: 多次被跳过只保留最新,最终处理最新文本", async () => {
const ctrl = createController({ mediaContext: true });
apiDelayMs = 30;
mediaApiDelayMs = 150; // 媒体处理很慢
// 第1次: 含媒体 → 进入长时间处理
const p1 = ctrl.onPartialReply({ text: "<qqvoice>/tmp/song.mp3</qqvoice>" });
await sleep(20);
// 第2次: 被跳过 → pendingNormalizedFull = "..v1.."
await ctrl.onPartialReply({ text: "<qqvoice>/tmp/song.mp3</qqvoice>\n后续文字v1" });
await sleep(10);
// 第3次: 被跳过 → pendingNormalizedFull 被覆盖为 "..v2.."
await ctrl.onPartialReply({ text: "<qqvoice>/tmp/song.mp3</qqvoice>\n后续文字v2最终版" });
await p1;
await flush(800);
ctrl.markFullyComplete();
await ctrl.onIdle();
await flush(300);
// ★ 核心验证:最终应包含 v2 的文本
const allStreamContent = streamCalls.map((c) => c.content).join(" || ");
assert.ok(
allStreamContent.includes("后续文字v2最终版"),
`应包含最新的 "后续文字v2最终版"。\n实际: [\n${streamCalls.map((c, i) => ` ${i}: "${c.content.slice(0, 80)}" (state=${c.inputState})`).join("\n")}\n]`
);
});
await test("补救: 无 pending 时不触发多余的 re-run", async () => {
const ctrl = createController({ mediaContext: true });
apiDelayMs = 5;
mediaApiDelayMs = 5;
// 只有一次 onPartialReply媒体标签后接 \n 开头的文本)
await ctrl.onPartialReply({ text: "hello<qqimg>/tmp/a.jpg</qqimg>\nbye" });
await flush(400);
ctrl.markFullyComplete();
await ctrl.onIdle();
await flush(200);
// 验证:不应有 re-running 日志(因为没有被跳过的调用)
const reRunLogs = logs.filter((l) => l.includes("re-running"));
assert.strictEqual(reRunLogs.length, 0, `不应有 re-running 日志,实际: ${reRunLogs.length}`);
// ★ 验证:不应有 PREFIX MISMATCH 错误(之前 stripMediaTags 的 .trim() 会导致此问题)
const prefixMismatchLogs = logs.filter((l) => l.includes("PREFIX MISMATCH"));
assert.strictEqual(prefixMismatchLogs.length, 0, `不应有 PREFIX MISMATCH实际: ${prefixMismatchLogs.join("; ")}`);
});
console.log("\n=== 6. onIdle 边界 ===");
await test("onIdle: 等待媒体处理完成后再终结", async () => {
const ctrl = createController({ mediaContext: true });
apiDelayMs = 20;
mediaApiDelayMs = 200; // 媒体发送很慢
// 发送含媒体的文本
const p = ctrl.onPartialReply({ text: "<qqimg>/tmp/slow.jpg</qqimg>\n完成" });
await sleep(30);
// 在媒体还在处理时就标记完成并触发 onIdle
ctrl.markFullyComplete();
const idlePromise = ctrl.onIdle();
await p;
await idlePromise;
await flush(500);
// 验证:应该正常完成,不降级
assert.ok(!ctrl.shouldFallbackToStatic, "不应降级到静态发送");
});
await test("onDeliver: deliver 先到达 → 禁用流式走降级", async () => {
const ctrl = createController({ mediaContext: true });
// deliver 先到达(此时 sentStreamChunkCount === 0→ 直接 transition 到 aborted
await ctrl.onDeliver({ text: "成果:<qqimg>/tmp/result.png</qqimg>" });
await flush(400);
// 验证应该已经进入终态aborted走降级路径
assert.ok(ctrl.isTerminalPhase, "deliver 先到达后应进入终态");
assert.ok(ctrl.shouldFallbackToStatic, "deliver 先到达时应降级到静态发送");
// 后续 onPartialReply 应被跳过(因为已是终态)
await ctrl.onPartialReply({ text: "这段应该被忽略" });
assert.ok(ctrl.shouldFallbackToStatic, "onPartialReply 应被跳过,仍然是降级状态");
});
await test("互斥: onPartialReply 先到 → onDeliver 被忽略(即使在媒体中断期间)", async () => {
const ctrl = createController({ mediaContext: true });
// onPartialReply 先到 → 锁定为 partial 模式
await ctrl.onPartialReply({ text: "<qqvoice>/tmp/a.mp3</qqvoice>" });
await flush(400);
// 此时可能还在 mediaInterruptInProgresssentStreamChunkCount 可能为 0
// 但 onDeliver 应被忽略(因为 partial 先到)
await ctrl.onDeliver({ text: "<qqvoice>/tmp/a.mp3</qqvoice>" });
await flush(400);
// 验证不应降级deliver 没有生效)
assert.ok(!ctrl.shouldFallbackToStatic, "partial 先到时 deliver 不应导致降级");
assert.ok(!ctrl.isTerminalPhase || ctrl.currentPhase !== "aborted",
"不应因为 deliver 进入 abortedonPartialReply 在处理中)");
// 日志中应有 deliver 被拒绝的字样
const skipLogs = logs.filter((l) => l.includes('rejected "deliver"'));
assert.ok(skipLogs.length >= 1, `应有 deliver 被拒绝的日志,实际: ${skipLogs.join("; ") || "无"}`);
});
console.log("\n=== 7. 降级与异常 ===");
await test("降级: 从未发送分片 → fallback", async () => {
const ctrl = createController();
// 不发送任何文本就直接结束
ctrl.markFullyComplete();
await ctrl.onIdle();
assert.ok(ctrl.isTerminalPhase, "应进入终态");
});
await test("异常: onError 后正常终态", async () => {
const ctrl = createController();
await ctrl.onPartialReply({ text: "部分文本" });
await flush(200);
await ctrl.onError(new Error("test error"));
assert.ok(ctrl.isTerminalPhase, "onError 后应进入终态");
});
console.log("\n=== 8. 回复边界检测 ===");
await test("回复边界: 文本缩短 → 旧controller终结新controller处理新回复", async () => {
let newCtrl: StreamingControllerType | null = null;
const ctrl = createController({
onReplyBoundary: async (newText: string) => {
// 回调中创建新 controller 并处理新回复
newCtrl = createController();
await newCtrl.onPartialReply({ text: newText });
},
});
// 第一段回复
await ctrl.onPartialReply({ text: "第一段回复内容比较长" });
await flush();
// 记录第一段相关的 streamCalls 数量
const firstSegCalls = streamCalls.length;
assert.ok(firstSegCalls > 0, "第一段应已产生流式调用");
// 文本缩短 → 触发回复边界
await ctrl.onPartialReply({ text: "短" });
await flush();
// 旧 controller 应已进入终态
assert.ok(ctrl.isTerminalPhase, "旧 controller 应已进入终态");
// 新 controller 应已创建
assert.ok(newCtrl !== null, "应通过回调创建了新 controller");
// 第一段应有 DONE 分片(终结)
const doneCalls = streamCalls.filter((c) => c.inputState === 10);
assert.ok(doneCalls.length >= 1, "旧 controller 应发送了 DONE 分片终结第一段");
// 验证第一段的 DONE 分片包含第一段内容
const firstDone = doneCalls[0];
assert.ok(firstDone.content.includes("第一段回复内容比较长"), `第一段 DONE 分片应包含 "第一段回复内容比较长", 实际: "${firstDone.content}"`);
// 继续第二段回复增长
await newCtrl!.onPartialReply({ text: "短回复完整" });
await flush();
newCtrl!.markFullyComplete();
await newCtrl!.onIdle();
await flush();
// 验证新 controller 的流式调用包含第二段内容
// 新 controller 的调用在 firstSegCalls 之后(因为 streamCalls 是全局的,但 DONE 会增加一些)
const allContent = streamCalls.map((c) => c.content).join(" || ");
assert.ok(allContent.includes("短回复完整"), `应包含第二段 "短回复完整",实际: ${allContent}`);
// 两段内容是独立的流式消息,不应混在一起
const lastCall = streamCalls[streamCalls.length - 1];
assert.ok(!lastCall.content.includes("第一段回复内容比较长"), "第二段最终分片不应包含第一段内容(各自独立)");
});
// ============ 结果 ============
console.log(`\n========================================`);
console.log(` 总计: ${passed + failed} | ✅ 通过: ${passed} | ❌ 失败: ${failed}`);
if (failedTests.length > 0) {
console.log(` 失败用例:`);
for (const t of failedTests) console.log(` - ${t}`);
}
console.log(`========================================\n`);
// 恢复原始 fetch
globalThis.fetch = originalFetch;
process.exit(failed > 0 ? 1 : 0);

View File

@@ -0,0 +1,843 @@
/**
* stripIncompleteMediaTag 单元测试
*
* 运行方式: npx tsx tests/strip-incomplete-media-tag.test.ts
*/
import { stripIncompleteMediaTag } from "../src/utils/media-send.js";
import assert from "node:assert";
let passed = 0;
let failed = 0;
const failedTests: string[] = [];
function test(name: string, input: string, expectedSafe: string, expectedIncomplete: boolean) {
const [safe, incomplete] = stripIncompleteMediaTag(input);
try {
assert.strictEqual(safe, expectedSafe, `safeText mismatch`);
assert.strictEqual(incomplete, expectedIncomplete, `hasIncomplete mismatch`);
console.log(`${name}`);
passed++;
} catch (e: any) {
console.log(`${name}`);
console.log(` 输入: ${JSON.stringify(input)}`);
console.log(` 期望: [${JSON.stringify(expectedSafe)}, ${expectedIncomplete}]`);
console.log(` 实际: [${JSON.stringify(safe)}, ${incomplete}]`);
failed++;
failedTests.push(name);
}
}
// ==========================================
// 1. 空值 / 无标签文本 → 不截断
// ==========================================
console.log("\n=== 1. 空值 / 无标签文本 ===");
test("空字符串", "", "", false);
test("纯文本", "这是一段普通文字", "这是一段普通文字", false);
test("含普通HTML标签", "这是<b>加粗</b>文字", "这是<b>加粗</b>文字", false);
test("含换行的纯文本", "第一行\n第二行\n第三行", "第一行\n第二行\n第三行", false);
test("只有换行", "\n\n\n", "\n\n\n", false);
test("以换行结尾(最后一行为空)", "text\n", "text\n", false);
test("多行以换行结尾", "abc\ndef\n", "abc\ndef\n", false);
test("纯空白", " ", " ", false);
test("< 后接空格(非标签)", "正文< ", "正文< ", false);
test("< 后接数字(如 < 3", "条件 < 3 成立", "条件 < 3 成立", false);
test("数学公式 3 < 5 > 2", "计算结果: 3 < 5 > 2完毕", "计算结果: 3 < 5 > 2完毕", false);
test("文本末尾恰好是 >", "3 < 5 > 2 结果为 true>", "3 < 5 > 2 结果为 true>", false);
// ==========================================
// 2. 完全闭合的媒体标签 → 不截断
// ==========================================
console.log("\n=== 2. 完整闭合标签(不应截断)===");
test(
"完整 qqvoice 标签",
"前文<qqvoice>/tmp/joke.mp3</qqvoice>后文",
"前文<qqvoice>/tmp/joke.mp3</qqvoice>后文",
false,
);
test(
"完整 qqimg 标签",
"看图<qqimg>/path/to/img.jpg</qqimg>",
"看图<qqimg>/path/to/img.jpg</qqimg>",
false,
);
test(
"完整标签后有文本",
"<qqvoice>/tmp/joke.mp3</qqvoice>\n\n谐音梗笑话 😄",
"<qqvoice>/tmp/joke.mp3</qqvoice>\n\n谐音梗笑话 😄",
false,
);
test(
"纯标签完整闭合",
"<qqvoice>/tmp/joke.mp3</qqvoice>",
"<qqvoice>/tmp/joke.mp3</qqvoice>",
false,
);
test(
"完整标签在末尾",
"这是正文<qqvoice>/tmp/joke.mp3</qqvoice>",
"这是正文<qqvoice>/tmp/joke.mp3</qqvoice>",
false,
);
test(
"完整标签后有普通 < 字符",
"<qqvoice>/a.mp3</qqvoice> 结果是 3 < 5",
"<qqvoice>/a.mp3</qqvoice> 结果是 3 < 5",
false,
);
test(
"完整标签后跟 </b>(无 >)— 非媒体不影响",
"正文<qqvoice>/a.mp3</qqvoice>后文</b",
"正文<qqvoice>/a.mp3</qqvoice>后文</b",
false,
);
test(
"完整标签后跟 </b>(有 >)— 非媒体不影响",
"正文<qqvoice>/a.mp3</qqvoice>后文</b>",
"正文<qqvoice>/a.mp3</qqvoice>后文</b>",
false,
);
test(
"标签内容含 > 且完整闭合",
"前文<qqvoice>/tmp/笑话>_< .mp3</qqvoice>",
"前文<qqvoice>/tmp/笑话>_< .mp3</qqvoice>",
false,
);
test(
">_</qqvoice> 完整闭合(内容含 >_<",
"前文<qqvoice>/tmp/笑话>_</qqvoice>",
"前文<qqvoice>/tmp/笑话>_</qqvoice>",
false,
);
// ==========================================
// 3. 不完整的「开标签」→ 截断到该 < 前面
// ==========================================
console.log("\n=== 3. 不完整的开标签 ===");
test("孤立 < 在行尾", "这是正文<", "这是正文", true);
test("<qq", "这是正文<qq", "这是正文", true);
test("<qqvoice", "这是正文<qqvoice", "这是正文", true);
test("<qqimg>(有 > 但无闭合标签)", "这是正文<qqimg>", "这是正文", true);
test("<qqimg>/path", "这是正文<qqimg>/path/to", "这是正文", true);
test("<qqimg>/path/full开标签有 > 但无闭合)", "这是正文<qqimg>/path/to/img.jpg", "这是正文", true);
test("纯开标签前缀(无前文)", "<qqvoice>/tmp/joke.mp3", "", true);
test("<i 是 <img 的前缀 → 截断", "正文<i", "正文", true);
test("<img", "正文<img", "正文", true);
test("<image", "正文<image", "正文", true);
test("<video", "正文<video", "正文", true);
test("<audio", "正文<audio", "正文", true);
test("<file", "正文<file", "正文", true);
test("<doc", "正文<doc", "正文", true);
test("<media", "正文<media", "正文", true);
test("<attach", "正文<attach", "正文", true);
test("<send", "正文<send", "正文", true);
test("<pic", "正文<pic", "正文", true);
test("<photo", "正文<photo", "正文", true);
test("<document", "正文<document", "正文", true);
test("<picture", "正文<picture", "正文", true);
test("开标签有 > 但无闭合标签", "前文<qqvoice>/path/to/file.mp3", "前文", true);
// ==========================================
// 4. 不完整的「闭合标签」→ 回溯到开标签前面截断
// ★ 这是核心原则的关键场景
// ==========================================
console.log("\n=== 4. 不完整闭合标签(回溯到开标签)===");
test(
"< — 闭合标签刚开始",
"这是正文<qqvoice>/tmp/joke.mp3<",
"这是正文",
true,
);
test(
"</ — 闭合标签刚开始",
"这是正文<qqvoice>/tmp/joke.mp3</",
"这是正文",
true,
);
test(
"</qq — 闭合标签名部分",
"这是正文<qqvoice>/tmp/joke.mp3</qq",
"这是正文",
true,
);
test(
"</qqvoice — 闭合标签名完整但缺 >",
"这是正文<qqvoice>/tmp/joke.mp3</qqvoice",
"这是正文",
true,
);
test(
"</qqimg — qqimg 闭合标签前缀",
"这是正文<qqimg>/path/to/img.jpg</qqimg",
"这是正文",
true,
);
test(
"有前文 + </ 闭合前缀",
"你好呀!<qqvoice>/tmp/a.mp3</",
"你好呀!",
true,
);
test(
"纯标签 + 闭合前缀(无前文)",
"<qqvoice>/tmp/joke.mp3</qqvoice",
"",
true,
);
test(
"标签内容含 > 字符 + 闭合未完成",
"前文<qqvoice>/tmp/笑话>_< .mp3</qqvoice",
"前文",
true,
);
test(
">_</qqvoice 未闭合(内容含 >_<",
"前文<qqvoice>/tmp/笑话>_</qqvoice",
"前文",
true,
);
// ==========================================
// 5. 闭合标签前缀但前面无对应开标签(防御场景)
// ==========================================
console.log("\n=== 5. 闭合标签前缀但无开标签(防御)===");
test(
"</qqvoice 无开标签(单行)→ 截掉整个最后一行",
"普通文字</qqvoice",
"普通文字</qqvoice",
true,
);
test(
"</qqvoice 无开标签(多行)→ 只截掉最后一行",
"前面的安全内容\n普通文字</qqvoice",
"前面的安全内容\n普通文字</qqvoice",
true,
);
test(
"纯 </",
"</",
"</",
true,
);
test(
"纯 </qqvoice 无开标签",
"</qqvoice",
"</qqvoice",
true,
);
test(
"</随便 不是媒体标签 → 安全",
"普通文字</div",
"普通文字</div",
false,
);
test(
"非媒体闭合标签 </div → 安全",
"普通文字</div",
"普通文字</div",
false,
);
// ==========================================
// 6. 多行文本 — 只检查最后一行
// ==========================================
console.log("\n=== 6. 多行文本(只检查最后一行)===");
test(
"前面行有未闭合,但最后一行安全",
"第一行<qqvoice\n第二行完整<qqimg>/b.jpg</qqimg>",
"第一行<qqvoice\n第二行完整<qqimg>/b.jpg</qqimg>",
false,
);
test(
"前面行安全,最后一行未闭合",
"第一行完整\n第二行<qqimg>/b.jpg",
"第一行完整\n第二行",
true,
);
test(
"多行 + 最后一行是闭合标签前缀",
"第一行\n前文<qqvoice>/a.mp3</qqvoice",
"第一行\n前文",
true,
);
test(
"多行 + 最后一行以换行结尾 → 安全",
"第一行\n<qqimg>stuff\n",
"第一行\n<qqimg>stuff\n",
false,
);
test(
"多行 + 最后一行有闭合标签回溯到开标签",
"行一\n行二\n看看<qqimg>/path/to/img.jpg</qqimg",
"行一\n行二\n看看",
true,
);
// ==========================================
// 6.5 多行 — 前面行无论标签是否匹配,都必须完整保留
// (只检查最后一行,前面行一定是 safeText 的一部分)
// ==========================================
console.log("\n=== 6.5 前面行一定保留 ===");
test(
"前面行有未闭合媒体开标签 + 最后一行截断 → 前面行完整保留",
"第一行<qqvoice\n最后一行<qqimg>/b.jpg",
"第一行<qqvoice\n最后一行",
true,
);
test(
"前面行有未闭合媒体闭合标签 + 最后一行截断 → 前面行完整保留",
"第一行</qqvoice\n最后一行<qqimg>/b.jpg",
"第一行</qqvoice\n最后一行",
true,
);
test(
"前面行有孤立 < + 最后一行截断 → 前面行完整保留",
"第一行内容<\n最后一行<qqvoice>/a.mp3",
"第一行内容<\n最后一行",
true,
);
test(
"前面行有孤立 </ + 最后一行截断 → 前面行完整保留",
"第一行内容</\n最后一行<qqimg",
"第一行内容</\n最后一行",
true,
);
test(
"前面行有不完整媒体标签对 + 最后一行截断 → 前面行完整保留",
"第一行<qqimg>/path</qqimg\n最后一行<qqvoice>/a.mp3",
"第一行<qqimg>/path</qqimg\n最后一行",
true,
);
test(
"前面行有完整标签+不完整标签混合 + 最后一行截断",
"<qqvoice>/a.mp3</qqvoice>然后<qqimg\n新的一行<qqvoice>/b.mp3",
"<qqvoice>/a.mp3</qqvoice>然后<qqimg\n新的一行",
true,
);
test(
"前面多行都有各种标签 + 最后一行截断 → 前面全部保留",
"第1行<qqvoice\n第2行</qqimg\n第3行<img>\n最后<qqvoice>/a.mp3",
"第1行<qqvoice\n第2行</qqimg\n第3行<img>\n最后",
true,
);
test(
"前面行有媒体前缀标签 <i + 最后一行截断 → 前面行完整保留",
"第一行<i\n最后一行<qqvoice>/a.mp3",
"第一行<i\n最后一行",
true,
);
test(
"前面行有非媒体标签 + 最后一行截断 → 前面行完整保留",
"第一行<div>内容</div>\n<b>加粗\n最后一行<qqimg>/b.jpg",
"第一行<div>内容</div>\n<b>加粗\n最后一行",
true,
);
test(
"前面行有完整媒体标签 + 最后一行安全 → 全部保留",
"<qqvoice>/a.mp3</qqvoice>\n最后一行安全文字",
"<qqvoice>/a.mp3</qqvoice>\n最后一行安全文字",
false,
);
// ==========================================
// 7. 多个标签场景(同一行)
// ==========================================
console.log("\n=== 7. 多标签场景(同一行)===");
test(
"完整标签后跟不完整开标签",
"前文<qqvoice>/a.mp3</qqvoice>中间<qqimg>/b.jpg",
"前文<qqvoice>/a.mp3</qqvoice>中间",
true,
);
test(
"完整标签后跟 <",
"前文<qqvoice>/a.mp3</qqvoice>后续<",
"前文<qqvoice>/a.mp3</qqvoice>后续",
true,
);
test(
"完整标签 + 另一个标签的闭合前缀(截到第二个的开标签前)",
"前文<qqvoice>/a.mp3</qqvoice>中间<qqimg>/b.jpg</qqimg",
"前文<qqvoice>/a.mp3</qqvoice>中间",
true,
);
test(
"完整标签 + 第二个闭合缺>",
"前文<qqvoice>/a.mp3</qqvoice>中间<qqvoice>/b.mp3</qqvoice",
"前文<qqvoice>/a.mp3</qqvoice>中间",
true,
);
test(
"完整标签后跟 </(孤立闭合前缀,回溯到开标签)",
"前文<qqvoice>/a.mp3</qqvoice>中间<qqimg>/b.jpg</",
"前文<qqvoice>/a.mp3</qqvoice>中间",
true,
);
test(
"完整标签 + 普通HTML + 未闭合媒体标签",
"<qqvoice>/a.mp3</qqvoice>普通文本<b>加粗</b><qqimg>/b.jpg</qqimg",
"<qqvoice>/a.mp3</qqvoice>普通文本<b>加粗</b>",
true,
);
test(
"两个完整标签紧挨",
"<qqvoice>/a.mp3</qqvoice><qqimg>/b.jpg</qqimg>",
"<qqvoice>/a.mp3</qqvoice><qqimg>/b.jpg</qqimg>",
false,
);
test(
"两个同名完整标签中间有文字",
"<qqvoice>/a.mp3</qqvoice>中间文字<qqvoice>/b.mp3</qqvoice>",
"<qqvoice>/a.mp3</qqvoice>中间文字<qqvoice>/b.mp3</qqvoice>",
false,
);
test(
"两个同名完整标签 + 末尾未闭合",
"<qqvoice>/a.mp3</qqvoice>中间文字<qqvoice>/b.mp3</qqvoice>后面<qqimg>/c.jpg",
"<qqvoice>/a.mp3</qqvoice>中间文字<qqvoice>/b.mp3</qqvoice>后面",
true,
);
test(
"第一个未闭合 + 第二个完整(从右到左,先看到完整的闭合标签→安全?不!)",
// 从右到左:先找到 </qqvoice> 完整闭合 → 对应哪个开标签?
// 实际上从右到左找到的第一个媒体 < 是 </qqvoice> → 完整闭合 → 返回安全
// 但第一个 <qqvoice> 其实没有闭合!
// 这是一个设计取舍:由于我们只从右到左找第一个媒体标签,不做全行配对
// 如果最右边的标签对完整,就认为安全
"<qqvoice>/a.mp3中间文字<qqvoice>/b.mp3</qqvoice>",
// 从右到左:</qqvoice> 完整 → safe
// 注意:这里第一个 <qqvoice> 没有 > 所以不算完整开标签
// 从右到左找:先 </qqvoice> 完整闭合 → 安全返回
// 但实际上 <qqvoice>/a.mp3 是个没有闭合的开标签
// 这个场景可能返回 safe 也可能返回 unsafe取决于实现
// 实际代码从右到左找,第一个遇到的媒体 < 是 </qqvoice>(行尾),完整 → safe=false
// 但这是有 bug 的... 让我先测试看实际行为
"<qqvoice>/a.mp3中间文字<qqvoice>/b.mp3</qqvoice>",
false,
);
// ==========================================
// 8. 全角中文尖括号
// ==========================================
console.log("\n=== 8. 中文全角尖括号 ===");
test("中文尖括号开标签", "这是正文qqvoice", "这是正文", true);
test("孤立中文尖括号", "这是正文<", "这是正文", true);
test(
"中文尖括号完整标签",
"这是正文qqvoice>/tmp/joke.mp3</qqvoice>",
// qqvoice> 是开标签(用 开头,> 结尾),检查后面有没有 </qqvoice>
"这是正文qqvoice>/tmp/joke.mp3</qqvoice>",
false,
);
test(
"中文尖括号闭合标签前缀",
"这是正文qqvoice>/tmp/joke.mp3/qqvoice",
// /qqvoice 是不完整闭合标签 → needFindOpenTag=qqvoice → 往左找 qqvoice → 截断
"这是正文",
true,
);
// ==========================================
// 9. 各种媒体标签名测试
// ==========================================
console.log("\n=== 9. 各种媒体标签名 ===");
test("img 标签未闭合", "正文<img src='x'>content", "正文", true);
test("image 标签未闭合", "正文<image>content", "正文", true);
test("video 标签未闭合", "正文<video>content", "正文", true);
test("audio 标签未闭合", "正文<audio>content", "正文", true);
test("voice 标签未闭合", "正文<voice>content", "正文", true);
test("file 标签未闭合", "正文<file>content", "正文", true);
test("doc 标签未闭合", "正文<doc>content", "正文", true);
test("media 标签未闭合", "正文<media>content", "正文", true);
test("attach 标签未闭合", "正文<attach>content", "正文", true);
test("send 标签未闭合", "正文<send>content", "正文", true);
test("pic 标签未闭合", "正文<pic>content", "正文", true);
test("photo 标签未闭合", "正文<photo>content", "正文", true);
test("document 标签未闭合", "正文<document>content", "正文", true);
test("picture 标签未闭合", "正文<picture>content", "正文", true);
// 非媒体标签名不应截断
test("非媒体标签 <div> 不截断", "正文<div>content", "正文<div>content", false);
test("非媒体标签 <span> 不截断", "正文<span>content", "正文<span>content", false);
test("非媒体标签 <b> 不截断", "正文<b>content", "正文<b>content", false);
test("非媒体标签 <a> 不截断", "正文<a href='x'>link", "正文<a href='x'>link", false);
// 大小写
test("大写 <QQVOICE 也算媒体标签", "正文<QQVOICE", "正文", true);
test("混合大小写 <QqImg", "正文<QqImg", "正文", true);
// ==========================================
// 10. 纯媒体标签(没有前置文本)
// ==========================================
console.log("\n=== 10. 纯媒体标签(无前置文本)===");
test("纯开标签前缀", "<qqvoice>/tmp/joke.mp3", "", true);
test("纯标签 + 闭合前缀", "<qqvoice>/tmp/joke.mp3</qqvoice", "", true);
test("纯标签完整闭合", "<qqvoice>/tmp/joke.mp3</qqvoice>", "<qqvoice>/tmp/joke.mp3</qqvoice>", false);
test(
"纯标签完整闭合 + 换行文本",
"<qqvoice>/tmp/joke.mp3</qqvoice>\n\n谐音梗笑话",
"<qqvoice>/tmp/joke.mp3</qqvoice>\n\n谐音梗笑话",
false,
);
// ==========================================
// 11. 截断后 trimEnd去掉尾部空白和换行
// ==========================================
console.log("\n=== 11. 截断后 trimEnd ===");
test(
"截断后有3个连续换行 → trimEnd 去掉尾部换行",
"第一段\n\n\n<qqimg>/path.jpg",
"第一段",
true,
);
test(
"截断后有4个连续换行 → trimEnd 去掉尾部换行",
"段落\n\n\n\n<qqvoice>/a.mp3",
"段落",
true,
);
test(
"截断后有2个连续换行 → trimEnd 去掉尾部换行",
"段落\n\n<qqvoice>/a.mp3",
"段落",
true,
);
test(
"截断后 trimEnd 去除尾部空格和换行",
"段落 \n <qqvoice>/a.mp3",
// 多行:最后一行是 " <qqvoice>/a.mp3"
// < 在 lastLine 中位置 2text.slice(0, lineStart + 2) = "段落 \n "
// trimEnd 从末尾去除所有 \s空格+换行),所以 "段落 \n " → "段落"
"段落",
true,
);
// ==========================================
// 12. needFindOpenTag 通配模式(孤立 </ → 找最近媒体开标签)
// ==========================================
console.log("\n=== 12. 孤立 </ 的通配回溯 ===");
test(
"孤立 </ + 前面有媒体开标签",
"正文<qqimg>/path/to/img.jpg</",
"正文",
true,
);
test(
"孤立 </ + 前面无媒体开标签 → 原样返回",
"纯文本</",
"纯文本</",
true,
);
test(
"孤立 </ + 前面有非媒体标签 → 找不到媒体开标签 → 原样返回",
"正文<div>内容</",
"正文<div>内容</",
true,
);
test(
"多行 + 最后一行孤立 </",
"第一行\n第二行</",
"第一行\n第二行</",
true,
);
// ==========================================
// 13. needFindOpenTag 精确匹配模式
// ==========================================
console.log("\n=== 13. 闭合标签名精确回溯 ===");
test(
"</qqvoice 回溯找 <qqvoice",
"正文<qqvoice>/tmp/a.mp3</qqvoice",
"正文",
true,
);
test(
"</qqimg 回溯找 <qqimg",
"正文<qqimg>/path.jpg</qqimg",
"正文",
true,
);
test(
"闭合标签名和开标签名不匹配(如 </qqimg 但前面是 <qqvoice→ 找不到对应开标签,原样返回",
// 最后一行:<qqvoice>/a.mp3</qqimg
// 从右到左:</qqimg (不完整闭合) → needFindOpenTag=qqimg
// 继续往左找 <qqimg 开标签,找到 <qqvoice 但名字不匹配 → continue
// 遍历完 → needFindOpenTag 是具体标签名但找不到对应开标签 → 原样返回
"<qqvoice>/a.mp3</qqimg",
"<qqvoice>/a.mp3</qqimg",
true,
);
// ==========================================
// 14. 复合场景 — 完整标签 + 不完整标签混合
// ==========================================
console.log("\n=== 14. 复合场景 ===");
test(
"完整A + 未闭合B开标签",
"<qqvoice>/a.mp3</qqvoice>然后<qqimg>/b.jpg",
"<qqvoice>/a.mp3</qqvoice>然后",
true,
);
test(
"完整A + 未闭合B闭合标签回溯到B开标签",
"<qqvoice>/a.mp3</qqvoice>然后<qqimg>/b.jpg</qqimg",
"<qqvoice>/a.mp3</qqvoice>然后",
true,
);
test(
"完整A + 完整B + 未闭合C",
"<qqvoice>/a.mp3</qqvoice><qqimg>/b.jpg</qqimg>再来<qqvoice>/c.mp3",
"<qqvoice>/a.mp3</qqvoice><qqimg>/b.jpg</qqimg>再来",
true,
);
test(
"两个同名完整标签 + 第三个未闭合",
"<qqvoice>/a.mp3</qqvoice>中间<qqvoice>/b.mp3</qqvoice>后面<qqvoice>/c.mp3",
"<qqvoice>/a.mp3</qqvoice>中间<qqvoice>/b.mp3</qqvoice>后面",
true,
);
// ==========================================
// 15. 标签属性场景
// ==========================================
console.log("\n=== 15. 标签属性 ===");
test(
"开标签有属性且完整闭合",
'正文<qqimg src="/path/img.jpg">image desc</qqimg>',
'正文<qqimg src="/path/img.jpg">image desc</qqimg>',
false,
);
test(
"开标签有属性但无闭合标签",
'正文<qqimg src="/path/img.jpg">image desc',
"正文",
true,
);
test(
"开标签有属性但未写完(无 >",
'正文<qqimg src="/path/img.jpg',
"正文",
true,
);
// ==========================================
// 16. 边界场景
// ==========================================
console.log("\n=== 16. 其他边界场景 ===");
test(
"单个 < 字符(行尾)",
"<",
"",
true,
);
test(
"< 后紧跟 >(如 <>)— 不是有效标签",
"文本<>",
"文本<>",
false,
);
test(
"多个连续 < 在行尾",
"文本<<<",
"文本<<",
true,
);
test(
"标签名后有空格再有 >",
"正文<qqvoice >/a.mp3</qqvoice>",
"正文<qqvoice >/a.mp3</qqvoice>",
false,
);
test(
"闭合标签名后有空格再有 >",
"正文<qqvoice>/a.mp3</qqvoice >",
// 从右到左:找到 </qqvoice > → isClosing=true, hasBracket=true (有>), 完整闭合 → safe
"正文<qqvoice>/a.mp3</qqvoice >",
false,
);
// ==========================================
// 17. 前缀匹配补充 — 单字母/多字母前缀
// ==========================================
console.log("\n=== 17. 前缀匹配补充 ===");
// 单字母前缀,可能是媒体标签名的开头
test("<v 是 voice/video 前缀 → 截断", "正文<v", "正文", true);
test("<p 是 pic/photo/picture 前缀 → 截断", "正文<p", "正文", true);
test("<d 是 doc/document 前缀 → 截断", "正文<d", "正文", true);
test("<s 是 send 前缀 → 截断", "正文<s", "正文", true);
test("<a 是 audio/attach 前缀 → 截断", "正文<a", "正文", true);
test("<f 是 file 前缀 → 截断", "正文<f", "正文", true);
test("<m 是 media 前缀 → 截断", "正文<m", "正文", true);
test("<q 是 qq 前缀 → 截断", "正文<q", "正文", true);
// 多字母前缀
test("<vo 是 voice 前缀 → 截断", "正文<vo", "正文", true);
test("<ph 是 photo 前缀 → 截断", "正文<ph", "正文", true);
test("<do 是 doc/document 前缀 → 截断", "正文<do", "正文", true);
test("<at 是 attach 前缀 → 截断", "正文<at", "正文", true);
test("<pi 是 pic/picture 前缀 → 截断", "正文<pi", "正文", true);
test("<se 是 send 前缀 → 截断", "正文<se", "正文", true);
test("<au 是 audio 前缀 → 截断", "正文<au", "正文", true);
test("<qqv 是 qqvoice/qqvideo 前缀 → 截断", "正文<qqv", "正文", true);
test("<qqp 是 qqpic/qqphoto/qqpicture 前缀 → 截断", "正文<qqp", "正文", true);
// 不是任何媒体标签的前缀 → 不截断
test("<x 不是媒体前缀 → 不截断", "正文<x", "正文<x", false);
test("<z 不是媒体前缀 → 不截断", "正文<z", "正文<z", false);
test("<b 不是媒体前缀 → 不截断", "正文<b", "正文<b", false);
test("<h 不是媒体前缀 → 不截断", "正文<h", "正文<h", false);
test("<div 不是媒体前缀 → 不截断", "正文<div", "正文<div", false);
test("<span 不是媒体前缀 → 不截断", "正文<span", "正文<span", false);
// ==========================================
// 18. 前缀匹配 + 已有 > 闭合 → 不应截断
// ==========================================
console.log("\n=== 18. 前缀标签已闭合 ===");
test("<i>text — <i> 虽是 img 前缀但已有 >,非媒体标签 → 不截断", "正文<i>text", "正文<i>text", false);
test("<v>text — <v> 有闭合 >,非媒体标签 → 不截断", "正文<v>text", "正文<v>text", false);
test("<p>text — <p> 有闭合 >,非媒体标签 → 不截断", "正文<p>text", "正文<p>text", false);
test("<a href>link — <a> 有闭合 >,不截断", "正文<a href='x'>link", "正文<a href='x'>link", false);
// ==========================================
// 19. 闭合标签前缀回溯补充
// ==========================================
console.log("\n=== 19. 闭合标签前缀回溯补充 ===");
test("</v 回溯找 <voice → 截断", "正文<voice>/a.mp3</v", "正文", true);
test("</v 回溯找 <video → 截断", "正文<video>/a.mp4</v", "正文", true);
test("</i 回溯找 <img → 截断", "正文<img>/a.jpg</i", "正文", true);
test("</q 回溯找 <qqvoice → 截断", "正文<qqvoice>/a.mp3</q", "正文", true);
test("</qqv 回溯找 <qqvoice → 截断", "正文<qqvoice>/a.mp3</qqv", "正文", true);
test("</x 不是媒体前缀 → 不截断", "正文</x", "正文</x", false);
test("</b 不是媒体前缀 → 不截断", "正文</b", "正文</b", false);
// 闭合前缀无对应开标签 → 原样返回
test("</v 无开标签 → 原样返回", "纯文本</v", "纯文本</v", true);
test("</i 无开标签 → 原样返回", "纯文本</i", "纯文本</i", true);
test("</qqv 无开标签 → 原样返回", "纯文本</qqv", "纯文本</qqv", true);
// ==========================================
// 20. 回溯跳过已完整闭合对
// ==========================================
console.log("\n=== 20. 回溯跳过已闭合对 ===");
test(
"</ 回溯时跳过已完整闭合的标签对",
"正文<qqimg>/a.jpg</qqimg>中间<qqvoice>/b.mp3</qqvoice>后面</",
// </ 触发回溯找未闭合的媒体开标签
// <qqvoice> 有 </qqvoice> → 完整对 → 跳过
// <qqimg> 有 </qqimg> → 完整对 → 跳过
// 找不到 → 原样返回
"正文<qqimg>/a.jpg</qqimg>中间<qqvoice>/b.mp3</qqvoice>后面</",
true,
);
test(
"</qqimg 回溯跳过已闭合对找到未闭合开标签",
"前文<qqimg>/a.jpg<qqvoice>/b.mp3</qqvoice>后面</qqimg",
// </qqimg → 回溯找 <qqimg
// <qqvoice> 有 </qqvoice> 完整对 → 跳过
// <qqimg> 没有完整闭合对 → 匹配!截断到这里
"前文",
true,
);
// ==========================================
// 21. 自闭合标签和 </> 场景
// ==========================================
console.log("\n=== 21. 特殊标签格式 ===");
test("</> 不是有效标签名 → 安全", "文本</>", "文本</>", false);
test("自闭合 <qqimg/>(有 > 但无闭合标签对)→ 截断", "正文<qqimg/>", "正文", true);
// ==========================================
// 22. 极端边界 — 连续和混合
// ==========================================
console.log("\n=== 22. 极端边界 ===");
test("只有 <", "<", "", true);
test("只有 </", "</", "</", true);
test("只有 <qqvoice", "<qqvoice", "", true);
test("只有 </qqvoice", "</qqvoice", "</qqvoice", true);
test("< 后面紧跟 /qqvoice>(完整闭合标签,无开标签)", "</qqvoice>", "</qqvoice>", false);
test("连续两个孤立 <", "文本<<", "文本<", true);
test("开标签后紧跟另一个 <", "正文<qqimg><", "正文", true);
test("非媒体标签后面跟 <", "正文<div><", "正文<div>", true);
test(
"很长的文本 + 末尾未闭合",
"这是一段很长很长很长的文本,包含了很多内容。" + "<qqvoice>/very/long/path/to/audio/file.mp3",
"这是一段很长很长很长的文本,包含了很多内容。",
true,
);
test(
"emoji + 媒体标签",
"哈哈😄🎉<qqimg>/img.jpg</qqimg>",
"哈哈😄🎉<qqimg>/img.jpg</qqimg>",
false,
);
test(
"emoji + 未闭合媒体标签",
"哈哈😄🎉<qqimg>/img.jpg",
"哈哈😄🎉",
true,
);
test(
"中英混合 + 未闭合",
"Hello你好World世界<qqvoice>/a.mp3",
"Hello你好World世界",
true,
);
// ==========================================
// 23. 流式输出逐步增长模拟
// ==========================================
console.log("\n=== 23. 流式输出模拟 ===");
// 模拟 LLM 流式输出的每个阶段
test("流式 step1: 正文", "笑话来了:", "笑话来了:", false);
test("流式 step2: 出现 <", "笑话来了:<", "笑话来了:", true);
test("流式 step3: <q", "笑话来了:<q", "笑话来了:", true);
test("流式 step4: <qq", "笑话来了:<qq", "笑话来了:", true);
test("流式 step5: <qqv", "笑话来了:<qqv", "笑话来了:", true);
test("流式 step6: <qqvo", "笑话来了:<qqvo", "笑话来了:", true);
test("流式 step7: <qqvoi", "笑话来了:<qqvoi", "笑话来了:", true);
test("流式 step8: <qqvoic", "笑话来了:<qqvoic", "笑话来了:", true);
test("流式 step9: <qqvoice", "笑话来了:<qqvoice", "笑话来了:", true);
test("流式 step10: <qqvoice>", "笑话来了:<qqvoice>", "笑话来了:", true);
test("流式 step11: <qqvoice>/a", "笑话来了:<qqvoice>/a.mp3", "笑话来了:", true);
test("流式 step12: 出现 </", "笑话来了:<qqvoice>/a.mp3</", "笑话来了:", true);
test("流式 step13: </q", "笑话来了:<qqvoice>/a.mp3</q", "笑话来了:", true);
test("流式 step14: </qqvoice", "笑话来了:<qqvoice>/a.mp3</qqvoice", "笑话来了:", true);
test("流式 step15: </qqvoice> 完成!", "笑话来了:<qqvoice>/a.mp3</qqvoice>", "笑话来了:<qqvoice>/a.mp3</qqvoice>", false);
test("流式 step16: 后续文字", "笑话来了:<qqvoice>/a.mp3</qqvoice> 好听吗?", "笑话来了:<qqvoice>/a.mp3</qqvoice> 好听吗?", false);
// ==========================================
// 总结
// ==========================================
console.log(`\n${"=".repeat(50)}`);
console.log(`总计: ${passed + failed} 个测试, ✅ ${passed} 通过, ❌ ${failed} 失败`);
if (failedTests.length > 0) {
console.log(`失败用例:`);
for (const t of failedTests) console.log(` - ${t}`);
process.exit(1);
} else {
console.log("🎉 全部通过!\n");
}