修复: 飞书交互批: 推送汇总卡/伪受理/回查/watcher弃守/status/md净化

- 34 push_to_chat 滚动汇总卡: 同 chat 在途事务卡原地追加, ✅/❌ 终态另起新卡, 更新失败回退重绑; L3 过程不再逐条新开卡片刷屏
- 20 伪受理断层: 发票前过 can_accept_request 守卫, 不可启动回实况话术; inflight 移交 watcher 持有, 运行期计数不随 handler 返回归零
- 22 兜底回查限定受理时间戳边界(纯函数+单测), 缺时间戳视为边界外, 禁投旧回答冒充
- 23 watcher 消费本会话 AiError 视同终局投失败卡, 不再空挂 70min
- 26 话术改实况: 未受理(暂不入队)可稍后重发, 删假排队承诺
- 27 快任务兜底改受理卡发出后 8s 宽限窗判定, 替换入场恒假死分支
- 21 /status 命令桌面入口: ai_chat_send 拦截, 零 LLM 零 spawn 返回进度摘要
- 38 出站 md 净化: 标题转粗体/表格转列表/围栏保留, feishu_channel 与 df-im-gateway 同规则互引
- 受理卡符号对齐规范(任务开始用🚀, ✅仅完成)
- 验收: cargo check 零 error + feishu 13/df-im-gateway 19/chat 17 passed + vue-tsc 0
This commit is contained in:
lxy
2026-10-06 23:48:29 +08:00
parent 9520c198a2
commit 1ce1360a0b
4 changed files with 666 additions and 77 deletions
+70 -19
View File
@@ -260,7 +260,7 @@ async fn handle_card_action(events: Sender<InboundEvent>, action: larksuite_oapi
/// 构造审批卡片(Card JSON 1.0):标题 + 详情 + 同意/拒绝按钮
fn build_approval_card(approval_id: &str, title: &str, detail: &str) -> Card {
let detail_md = format!("{}\n\n**审批 ID**: `{approval_id}`", escape_md(detail));
let detail_md = format!("{}\n\n**审批 ID**: `{approval_id}`", md_to_lark_friendly(detail));
let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue {
serde_json::from_value(serde_json::to_value(v).expect("serde_json::Value 可逆"))
.expect("按钮 value 结构兼容")
@@ -285,19 +285,54 @@ fn build_approval_card(approval_id: &str, title: &str, detail: &str) -> Card {
.element(Element::Action(actions))
}
/// Markdown 转义(飞书卡片 lark_md 语法)
fn escape_md(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('*', "\\*")
.replace('_', "\\_")
.replace('`', "\\`")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('<', "\\<")
.replace('>', "\\>")
.replace('(', "\\(")
.replace(')', "\\)")
.replace('#', "\\#")
/// 出站 Markdown 净化(飞书卡片 lark_md 语法适配)。
///
/// AI 产出常带标准 Markdown 结构,lark_md 不支持标题与表格语法,原样注入按字面量渲染。
/// 规则(与 src-tauri feishu_channel.rs 侧 md_to_lark_friendly 同规则互引,双端出站口径一致):
/// 标题行(行首一个以上 #)转粗体行;表格行(| 开头)转列表行,表头分隔行整行跳过;
/// 代码块围栏内外均保留原样(转义或改写破坏代码展示);其余行原样透传(lark_md 原生
/// 支持粗体/链接/行内代码,不额外转义)。
fn md_to_lark_friendly(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_code = false;
for line in s.lines() {
if line.trim_start().starts_with("```") {
in_code = !in_code;
out.push_str(line);
out.push('\n');
continue;
}
if in_code {
out.push_str(line);
out.push('\n');
continue;
}
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
let title = trimmed.trim_start_matches('#').trim();
if !title.is_empty() {
out.push_str(&format!("**{title}**\n"));
}
continue;
}
if trimmed.starts_with('|') {
let cells: Vec<&str> = trimmed.trim_matches('|').split('|').map(str::trim).collect();
// 表头分隔行(|---|:---|)无内容语义,整行跳过
if cells.iter().all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':')) {
continue;
}
let shown: Vec<&str> = cells.into_iter().filter(|c| !c.is_empty()).collect();
out.push_str(&format!("- {}\n", shown.join(" · ")));
continue;
}
out.push_str(line);
out.push('\n');
}
// lines() 逐行补换行,源文本不以换行结尾时尾部多出一个,与源对齐
if !s.ends_with('\n') {
out.pop();
}
out
}
#[cfg(test)]
@@ -349,11 +384,27 @@ mod tests {
assert!(approved_flags.contains(&true) && approved_flags.contains(&false));
}
/// Markdown 转义:卡片详情中的 markdown 语法字符被转义
/// 出站净化:标题转粗体行,表格行转列表行,表头分隔行跳过
#[test]
fn escape_md_works() {
assert_eq!(escape_md("a*b_c`d\\e"), "a\\*b\\_c\\`d\\\\e");
// 走查 #7:链接/标题/引用语法字符也须转义,防用户内容注入卡片结构
assert_eq!(escape_md("[x](http://e)#h <b>(y)"), "\\[x\\]\\(http://e\\)\\#h \\<b\\>\\(y\\)");
fn lark_friendly_converts_headings_and_tables() {
let md = "# 标题\n| a | b |\n|---|---|\n| 1 | 2 |";
assert_eq!(md_to_lark_friendly(md), "**标题**\n- a · b\n- 1 · 2");
}
/// 出站净化:代码围栏内外均保留原样,围栏外的标题照常改写
#[test]
fn lark_friendly_preserves_code_fence() {
let md = "前文\n```\n# 不是标题\n| 不 | 是表格 |\n```\n## 后文";
assert_eq!(
md_to_lark_friendly(md),
"前文\n```\n# 不是标题\n| 不 | 是表格 |\n```\n**后文**"
);
}
/// 出站净化:普通行原样透传(lark_md 原生支持粗体/链接,不额外转义)
#[test]
fn lark_friendly_passes_plain_lines() {
let md = "**粗** 与 [链接](http://e)\n普通行";
assert_eq!(md_to_lark_friendly(md), md);
}
}