s3-link.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. /**
  2. * 从消息正文里提取云盘链接
  3. *
  4. * ## 为什么需要
  5. *
  6. * 两类文件来源不同:
  7. * - **用户上传**:发的是 Matrix 文件附件(`m.file`),有 `file` 字段
  8. * - **容器成果**:容器把文件传到云盘后,**把 URL 写在回复正文里**,不发附件
  9. *
  10. * 实测(2026-11-09):只认附件字段的话,容器产出的成果在右栏「成果」里
  11. * 完全看不到 —— 用户会以为文件丢了。
  12. *
  13. * ## 安全
  14. *
  15. * 只提取**本平台云盘域名**下的链接,不把任意 URL 都当成成果。
  16. */
  17. /** 云盘域名白名单(与 API 契约里的 OBS 公网前缀一致) */
  18. const CLOUD_HOSTS = ['s3.fmode.cn', 'file-cloud.fmode.cn'];
  19. /** URL 里常见的中英文边界符,遇到即截断 */
  20. const TRAILING_JUNK = /[)\]>,,。;;、"'<>)】」》]+$/;
  21. /**
  22. * 容器交付段落的小标题(2026-11-10 取证确认)。
  23. *
  24. * 容器侧插件 `plugin-deliverable-inject` 的实际输出格式是:
  25. *
  26. * ```
  27. * **已上云(可直接下载)**
  28. * - https://s3.fmode.cn/xxx.md
  29. * - https://s3.fmode.cn/yyy.pdf
  30. * ```
  31. *
  32. * 🔴 原本期望的 `<!--fmode:files [...]-->` 结构化声明段**容器从未实现**
  33. * (容器原话:输出侧不写任何结构化注释),那条解析分支已删除。
  34. * 这里改为认容器**真的会写**的格式。
  35. */
  36. const DELIVERY_HEADER = /\*\*已上云[^*]*\*\*/;
  37. /** 容器交付段落的起始标记 */
  38. export function hasDeliveryBlock(text: string | undefined | null): boolean {
  39. return !!text && DELIVERY_HEADER.test(text);
  40. }
  41. /**
  42. * 从容器交付段落里取链接(`- url` 列表形式)。
  43. *
  44. * 与 `extractS3Links` 的区别:这里**只认交付段落内的链接**,
  45. * 不把正文里举例、引用、用户提问里出现的链接当成成果。
  46. *
  47. * @returns 交付段落内的云盘链接(保持顺序、去重);无交付段落返回空数组
  48. */
  49. export function extractDeliveredLinks(text: string | undefined | null): string[] {
  50. if (!text) return [];
  51. const m = DELIVERY_HEADER.exec(text);
  52. if (!m) return [];
  53. // 交付段落到文末为止(容器把它放在回复末尾)
  54. const tail = text.slice(m.index + m[0].length);
  55. return extractS3Links(tail);
  56. }
  57. /**
  58. * 从一段文本里提取云盘链接。
  59. *
  60. * @param text 消息正文
  61. * @returns 去重后的链接数组(保持出现顺序);无匹配返回空数组
  62. */
  63. export function extractS3Links(text: string | undefined | null): string[] {
  64. if (!text) return [];
  65. const out: string[] = [];
  66. /* 只匹配 https,且必须落在白名单域名下 */
  67. const re = /https:\/\/([a-z0-9.-]+)\/[^\s一-龥]*/gi;
  68. let m: RegExpExecArray | null;
  69. while ((m = re.exec(text)) !== null) {
  70. const host = m[1].toLowerCase();
  71. if (!CLOUD_HOSTS.includes(host)) continue;
  72. // 去掉句末标点、右括号等尾巴
  73. const url = m[0].replace(TRAILING_JUNK, '');
  74. if (url && !out.includes(url)) out.push(url);
  75. }
  76. return out;
  77. }
  78. /**
  79. * 把正文里的指定链接**摘掉**,返回清理后的正文。
  80. *
  81. * ## 用途
  82. *
  83. * 用户裁决:文件卡片统一放在底部,正文里的 URL 隐去。
  84. *
  85. * ## 🔴 必须处理的坑
  86. *
  87. * 只把链接删掉是不够的,会留下空行、孤立的「下载链接:」前缀:
  88. *
  89. * ```
  90. * 本周工作计划已生成。 ← 保留
  91. *
  92. * 下载链接: ← 删掉(只剩前缀,没有链接)
  93. * https://s3.fmode.cn/xxx.md ← 删掉
  94. * ```
  95. *
  96. * @param text 原文
  97. * @param links 要摘掉的链接(通常来自 `extractS3Links`)
  98. * @returns 清理后的正文;**可能为空字符串** —— 调用方必须判断空再决定是否渲染
  99. */
  100. export function stripLinks(text: string | undefined | null, links: readonly string[]): string {
  101. if (!text) return '';
  102. if (links.length === 0) return text.trim();
  103. let out = text;
  104. for (const l of links) out = out.split(l).join('');
  105. const lines = out.split('\n');
  106. const kept: string[] = [];
  107. for (const raw of lines) {
  108. const line = raw.trim();
  109. if (!line) continue;
  110. /*
  111. * 整行只剩「短标签 + 冒号 + 修饰符」的,一并删掉 ——
  112. * 那是给链接配的前缀,链接没了它就是一句废话。
  113. * 覆盖:`下载链接:`、`**下载链接:**`、`文件:`、`## 下载地址:` 等。
  114. */
  115. if (/^[*_#\s>·-]*[^。!?]{1,14}[::][*_\s]*$/.test(line)) continue;
  116. kept.push(line);
  117. }
  118. // 每行之间用单空行分隔:保留段落结构,不留下大片空白
  119. return kept.join('\n\n').trim();
  120. }