| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131 |
- /**
- * 从消息正文里提取云盘链接
- *
- * ## 为什么需要
- *
- * 两类文件来源不同:
- * - **用户上传**:发的是 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
- * ```
- *
- * 🔴 原本期望的 `<!--fmode:files [...]-->` 结构化声明段**容器从未实现**
- * (容器原话:输出侧不写任何结构化注释),那条解析分支已删除。
- * 这里改为认容器**真的会写**的格式。
- */
- 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();
- }
|