/** * 从消息正文里提取云盘链接 * * ## 为什么需要 * * 两类文件来源不同: * - **用户上传**:发的是 Matrix 文件附件(`m.file`),有 `file` 字段 * - **容器成果**:容器把文件传到云盘后,**把 URL 写在回复正文里**,不发附件 * * 实测(2026-11-09):只认附件字段的话,容器产出的成果在右栏「成果」里 * 完全看不到 —— 用户会以为文件丢了。 * * ## 安全 * * 只提取**本平台云盘域名**下的链接,不把任意 URL 都当成成果。 */ /** 云盘域名白名单(与 API 契约里的 OBS 公网前缀一致) */ const CLOUD_HOSTS = ['s3.fmode.cn', 'file-cloud.fmode.cn']; /** URL 里常见的中英文边界符,遇到即截断 */ const TRAILING_JUNK = /[)\]>,,。;;、"'<>)】」》]+$/; /** * 容器交付段落的小标题(2026-11-10 取证确认)。 * * 容器侧插件 `plugin-deliverable-inject` 的实际输出格式是: * * ``` * **已上云(可直接下载)** * - https://s3.fmode.cn/xxx.md * - https://s3.fmode.cn/yyy.pdf * ``` * * 🔴 原本期望的 `` 结构化声明段**容器从未实现** * (容器原话:输出侧不写任何结构化注释),那条解析分支已删除。 * 这里改为认容器**真的会写**的格式。 */ const DELIVERY_HEADER = /\*\*已上云[^*]*\*\*/; /** 容器交付段落的起始标记 */ export function hasDeliveryBlock(text: string | undefined | null): boolean { return !!text && DELIVERY_HEADER.test(text); } /** * 从容器交付段落里取链接(`- url` 列表形式)。 * * 与 `extractS3Links` 的区别:这里**只认交付段落内的链接**, * 不把正文里举例、引用、用户提问里出现的链接当成成果。 * * @returns 交付段落内的云盘链接(保持顺序、去重);无交付段落返回空数组 */ export function extractDeliveredLinks(text: string | undefined | null): string[] { if (!text) return []; const m = DELIVERY_HEADER.exec(text); if (!m) return []; // 交付段落到文末为止(容器把它放在回复末尾) const tail = text.slice(m.index + m[0].length); return extractS3Links(tail); } /** * 从一段文本里提取云盘链接。 * * @param text 消息正文 * @returns 去重后的链接数组(保持出现顺序);无匹配返回空数组 */ export function extractS3Links(text: string | undefined | null): string[] { if (!text) return []; const out: string[] = []; /* 只匹配 https,且必须落在白名单域名下 */ const re = /https:\/\/([a-z0-9.-]+)\/[^\s一-龥]*/gi; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { const host = m[1].toLowerCase(); if (!CLOUD_HOSTS.includes(host)) continue; // 去掉句末标点、右括号等尾巴 const url = m[0].replace(TRAILING_JUNK, ''); if (url && !out.includes(url)) out.push(url); } return out; } /** * 把正文里的指定链接**摘掉**,返回清理后的正文。 * * ## 用途 * * 用户裁决:文件卡片统一放在底部,正文里的 URL 隐去。 * * ## 🔴 必须处理的坑 * * 只把链接删掉是不够的,会留下空行、孤立的「下载链接:」前缀: * * ``` * 本周工作计划已生成。 ← 保留 * * 下载链接: ← 删掉(只剩前缀,没有链接) * https://s3.fmode.cn/xxx.md ← 删掉 * ``` * * @param text 原文 * @param links 要摘掉的链接(通常来自 `extractS3Links`) * @returns 清理后的正文;**可能为空字符串** —— 调用方必须判断空再决定是否渲染 */ export function stripLinks(text: string | undefined | null, links: readonly string[]): string { if (!text) return ''; if (links.length === 0) return text.trim(); let out = text; for (const l of links) out = out.split(l).join(''); const lines = out.split('\n'); const kept: string[] = []; for (const raw of lines) { const line = raw.trim(); if (!line) continue; /* * 整行只剩「短标签 + 冒号 + 修饰符」的,一并删掉 —— * 那是给链接配的前缀,链接没了它就是一句废话。 * 覆盖:`下载链接:`、`**下载链接:**`、`文件:`、`## 下载地址:` 等。 */ if (/^[*_#\s>·-]*[^。!?]{1,14}[::][*_\s]*$/.test(line)) continue; kept.push(line); } // 每行之间用单空行分隔:保留段落结构,不留下大片空白 return kept.join('\n\n').trim(); }