|
|
@@ -0,0 +1,878 @@
|
|
|
+# 新容器一键复刻 lj-test:文件收发完整配置手册
|
|
|
+
|
|
|
+> 版本:v1.0 · 2026-11-13
|
|
|
+> 参考实现:`agent-node-lj-test`(`@zgdcmgnqcd:fmode.cn`,**端到端验证通过**)
|
|
|
+> 适用:任意新建的 Hermes 容器
|
|
|
+> 目标:照本文档逐节执行,即可获得与 lj-test **完全一致**的文件收发行为
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 〇、先建立整体认知
|
|
|
+
|
|
|
+### 0.1 我们要达成什么
|
|
|
+
|
|
|
+| 方向 | 行为 |
|
|
|
+|---|---|
|
|
|
+| **入站**(用户 → 容器) | App 只发一条 `m.file` 事件,`content.url` = `https://s3.fmode.cn/...`。**文件本体不进 Matrix**。容器收到后用**自己的网络**去 S3 下载,落本地缓存给 Agent 读 |
|
|
|
+| **出站**(容器 → 用户) | 容器产出文件 → 上传 S3 → 在正文写 `**已上云(可直接下载)**` + https 链接。**禁止**用 Matrix 回发文件本体(`MEDIA:` 标记会被剥离) |
|
|
|
+
|
|
|
+### 0.2 三个硬约束(违反任意一条都会失效)
|
|
|
+
|
|
|
+1. **`/opt/hermes` 是 root 所有、在 overlay 根分区** → 改它需要 root,且**容器重建会丢失**
|
|
|
+2. **`/opt/data` 是唯一持久卷** → 所有能跨重建保留的东西必须放这里
|
|
|
+3. **`transform_llm_output` 钩子是「first non-None wins」** → 两个插件无法串联,**必须合并进同一个回调**
|
|
|
+
|
|
|
+### 0.3 一张图看清改动落点
|
|
|
+
|
|
|
+```
|
|
|
+┌─────────────────────────── 容器 ───────────────────────────┐
|
|
|
+│ │
|
|
|
+│ /opt/hermes/...(overlay,重建即丢) ← 需要 root 改 │
|
|
|
+│ └─ adapter.py │
|
|
|
+│ ├─ _is_allowed_media_url() [新增] │
|
|
|
+│ ├─ _fetch_http_media() [新增] │
|
|
|
+│ ├─ _handle_media_message [改 2 处判断] │
|
|
|
+│ └─ _download_and_cache_media [改 1 处分流] │
|
|
|
+│ │
|
|
|
+│ /opt/data/...(持久卷,长期有效) ← hermes 即可改 │
|
|
|
+│ ├─ plugins/plugin-deliverable-inject/ │
|
|
|
+│ │ ├─ __init__.py [改:步骤 0 调 media_strip] │
|
|
|
+│ │ └─ media_strip.py [新增] │
|
|
|
+│ └─ .fmode-media-patch/ [新增:持久化兜底] │
|
|
|
+│ ├─ entry.sh │
|
|
|
+│ ├─ patch_spec.py │
|
|
|
+│ └── README.md │
|
|
|
+└────────────────────────────────────────────────────────────┘
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 一、准备阶段(10 分钟,全部只读)
|
|
|
+
|
|
|
+### 1.1 记录容器指纹
|
|
|
+
|
|
|
+```bash
|
|
|
+cat > /tmp/fingerprint.txt <<'EOF'
|
|
|
+AGENT_ID = $(grep -E '^AGENT_ID=' /opt/data/.env | cut -d= -f2-)
|
|
|
+MATRIX_USER = $(grep -E '^MATRIX_USER_ID=' /opt/data/.env | cut -d= -f2-)
|
|
|
+hostname = $(hostname)
|
|
|
+uid = $(id -u)($(id -un))
|
|
|
+EOF
|
|
|
+sh /tmp/fingerprint.txt
|
|
|
+```
|
|
|
+
|
|
|
+把输出记下来。**后续所有宿主机操作都用 `AGENT_ID` 定位,不要用 docker 容器名。**
|
|
|
+
|
|
|
+> 🔴 真实教训:曾出现「docker 名为 `agent-node-lj-test` 的容器 `AGENT_ID` 为空,真正的目标容器 docker 名是 `agent-node-u19170119836`」,按名字找连改错两轮。
|
|
|
+
|
|
|
+### 1.2 探明运行环境
|
|
|
+
|
|
|
+```bash
|
|
|
+echo "=== 解释器 ==="
|
|
|
+ls -l /opt/hermes/.venv/bin/python*
|
|
|
+/opt/hermes/.venv/bin/python -c "import aiohttp; print('aiohttp', aiohttp.__version__)" 2>&1
|
|
|
+/opt/hermes/.venv/bin/python -c "import httpx; print('httpx', httpx.__version__)" 2>&1
|
|
|
+
|
|
|
+echo "=== s6 服务名(决定重启命令) ==="
|
|
|
+pgrep -af s6-supervise
|
|
|
+
|
|
|
+echo "=== 分区类型 ==="
|
|
|
+findmnt -T / | head -2
|
|
|
+findmnt -T /opt/data | head -2
|
|
|
+
|
|
|
+echo "=== adapter.py 现状 ==="
|
|
|
+stat -c '%s %y %U:%G %a' /opt/hermes/plugins/platforms/matrix/adapter.py
|
|
|
+md5sum /opt/hermes/plugins/platforms/matrix/adapter.py
|
|
|
+
|
|
|
+echo "=== 现有插件 ==="
|
|
|
+ls /opt/data/plugins/
|
|
|
+grep -n "plugins:" -A20 /opt/data/config.yaml | head -25
|
|
|
+```
|
|
|
+
|
|
|
+**必须记录的四项**:
|
|
|
+
|
|
|
+| 项 | 记录 | 为什么 |
|
|
|
+|---|---|---|
|
|
|
+| venv python 路径 | | 所有 Python 校验命令都用它,**不要用裸 `python3`** |
|
|
|
+| s6 服务名 | | `main-hermes` / `gateway-default` 各容器不同,用错**不报错但不生效** |
|
|
|
+| adapter.py 原始 md5 | | 事后判断"有没有被改过"的唯一依据 |
|
|
|
+| 现有插件清单 | | 避免重复安装、避免覆盖别人的插件 |
|
|
|
+
|
|
|
+### 1.3 确认上游关键函数的位置
|
|
|
+
|
|
|
+```bash
|
|
|
+F=/opt/hermes/plugins/platforms/matrix/adapter.py
|
|
|
+grep -n "async def _handle_media_message\|async def _download_and_cache_media\|startswith(\"mxc://\")" $F
|
|
|
+grep -n "from urllib.parse import" $F | head -3
|
|
|
+```
|
|
|
+
|
|
|
+> 🔴 **如果 `from urllib.parse import` 里没有 `urlparse`,新增函数必须在函数内部 import**——
|
|
|
+> 否则 `NameError` 被 `except` 吞掉,表现为「所有附件都被拒,但日志看不出来」。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 二、入站改造:让容器能收 https 附件
|
|
|
+
|
|
|
+### 2.1 完整代码
|
|
|
+
|
|
|
+以下四段是 lj-test 上**端到端验证通过**的最终代码(`adapter.py` 第 251-269、2152-2179、2217-2261、2263-2300 行)。
|
|
|
+
|
|
|
+#### ① 模块级白名单函数(放在 `class MatrixAdapter` **之前**,模块层级)
|
|
|
+
|
|
|
+```python
|
|
|
+# Fmode: 允许容器直接拉取的附件域名。防 SSRF:只放行这一个。
|
|
|
+# 注意:file-cloud.fmode.cn 证书已过期(2026-11-13 实测),不要加回来。
|
|
|
+# 🔴 urlparse 必须在函数内 import —— 模块顶部只导入了 urljoin/urlsplit/urlunsplit,
|
|
|
+# 裸用 urlparse 会 NameError,被 except 吞掉后静默返回 False,附件照旧被拒。
|
|
|
+_FMODE_MEDIA_HOSTS = {"s3.fmode.cn"}
|
|
|
+
|
|
|
+
|
|
|
+def _is_allowed_media_url(u: str) -> bool:
|
|
|
+ """mxc:// 一律放行;https:// 只放行个人存储白名单域名。"""
|
|
|
+ if u.startswith("mxc://"):
|
|
|
+ return True
|
|
|
+ if not u.startswith("https://"):
|
|
|
+ return False
|
|
|
+ try:
|
|
|
+ from urllib.parse import urlparse
|
|
|
+
|
|
|
+ return (urlparse(u).hostname or "").lower() in _FMODE_MEDIA_HOSTS
|
|
|
+ except Exception:
|
|
|
+ return False
|
|
|
+```
|
|
|
+
|
|
|
+> 🔴 **插入位置必须是模块级**(`class MatrixAdapter(...):` 之前)。
|
|
|
+> 曾实测:插到类方法前 → 函数落进类体 → `async def` 缩进异常 → `IndentationError`。
|
|
|
+
|
|
|
+#### ② `_handle_media_message` 两处判断(原文件约 2136 / 2155 行)
|
|
|
+
|
|
|
+```python
|
|
|
+ # 【原】if url and not str(url).startswith("mxc://"):
|
|
|
+ # 【改】
|
|
|
+ if url and not _is_allowed_media_url(str(url)):
|
|
|
+ logger.warning("[Matrix] Rejecting inbound media %s with non-allowed URL: %s", event_id, url)
|
|
|
+ return
|
|
|
+```
|
|
|
+
|
|
|
+```python
|
|
|
+ # 【原】if url and not str(url).startswith("mxc://"):
|
|
|
+ # 【改】
|
|
|
+ if url and not _is_allowed_media_url(str(url)):
|
|
|
+ logger.warning("[Matrix] Rejecting inbound encrypted media %s with non-allowed URL: %s", event_id, url)
|
|
|
+ return
|
|
|
+```
|
|
|
+
|
|
|
+#### ③ `_fetch_http_media` 新增方法(放在 `_download_and_cache_media` 之前,**类内**)
|
|
|
+
|
|
|
+```python
|
|
|
+ async def _fetch_http_media(self, url: str, event_id: str):
|
|
|
+ """Fmode: 从个人存储(https)流式拉取附件。超限/失败返回 None。"""
|
|
|
+ if not _is_allowed_media_url(url):
|
|
|
+ logger.warning("[Matrix] Refusing non-allowed media URL %s: %s", event_id, url)
|
|
|
+ return None
|
|
|
+ limit = self._max_media_bytes or 0
|
|
|
+
|
|
|
+ async def _read_aiohttp():
|
|
|
+ import aiohttp
|
|
|
+ timeout = aiohttp.ClientTimeout(total=600)
|
|
|
+ async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
|
+ async with session.get(url, allow_redirects=False) as resp:
|
|
|
+ if resp.status != 200:
|
|
|
+ logger.warning("[Matrix] HTTP media fetch failed %s: %s", event_id, resp.status)
|
|
|
+ return None
|
|
|
+ buf = bytearray()
|
|
|
+ async for chunk in resp.content.iter_chunked(64 * 1024):
|
|
|
+ buf.extend(chunk)
|
|
|
+ if limit and len(buf) > limit:
|
|
|
+ logger.warning("[Matrix] Rejecting oversized HTTP media %s", event_id)
|
|
|
+ return None
|
|
|
+ return bytes(buf)
|
|
|
+
|
|
|
+ async def _read_httpx():
|
|
|
+ import httpx
|
|
|
+ async with httpx.AsyncClient(timeout=600, follow_redirects=False) as hc:
|
|
|
+ async with hc.stream("GET", url) as resp:
|
|
|
+ if resp.status_code != 200:
|
|
|
+ logger.warning("[Matrix] HTTP media fetch failed %s: %s", event_id, resp.status_code)
|
|
|
+ return None
|
|
|
+ buf = bytearray()
|
|
|
+ async for chunk in resp.aiter_bytes(64 * 1024):
|
|
|
+ buf.extend(chunk)
|
|
|
+ if limit and len(buf) > limit:
|
|
|
+ logger.warning("[Matrix] Rejecting oversized HTTP media %s", event_id)
|
|
|
+ return None
|
|
|
+ return bytes(buf)
|
|
|
+
|
|
|
+ try:
|
|
|
+ return await _read_aiohttp()
|
|
|
+ except ImportError:
|
|
|
+ return await _read_httpx()
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning("[Matrix] HTTP media download error %s: %s", event_id, e)
|
|
|
+ return None
|
|
|
+```
|
|
|
+
|
|
|
+**三个必须保留的安全细节**:
|
|
|
+
|
|
|
+| 细节 | 原因 |
|
|
|
+|---|---|
|
|
|
+| `allow_redirects=False` | 白名单域名 302 到内网 → SSRF 绕过 |
|
|
|
+| **流式累加计数** | `Content-Length` 在 chunked 时为 `None`,用它判上限等于没判 |
|
|
|
+| `ImportError` 才回落 httpx | 各容器 venv 依赖不同,有的只有 aiohttp,有的两者都有 |
|
|
|
+
|
|
|
+#### ④ `_download_and_cache_media` 分流(**该函数第一行**)
|
|
|
+
|
|
|
+```python
|
|
|
+ # 【原】file_bytes = await self._client.download_media(ContentURI(url))
|
|
|
+ # 【改】
|
|
|
+ if str(url).startswith("https://"):
|
|
|
+ # Fmode: 附件直读个人存储(s3.fmode.cn),不经 Matrix media
|
|
|
+ file_bytes = await self._fetch_http_media(url, event_id)
|
|
|
+ if file_bytes is None:
|
|
|
+ return None
|
|
|
+ else:
|
|
|
+ file_bytes = await self._client.download_media(ContentURI(url))
|
|
|
+```
|
|
|
+
|
|
|
+**其余一律不动**:解密逻辑、`cache_image/audio/document_from_bytes_async` 分类,都是上游原样。
|
|
|
+
|
|
|
+> 分流用的是 `startswith("https://")` 而不是 `not startswith("mxc://")` —— 语义更明确。
|
|
|
+> 视频会被归到 document 类(上游行为,保持不变)。
|
|
|
+
|
|
|
+### 2.2 打补丁脚本(幂等,带 `--check`)
|
|
|
+
|
|
|
+保存为 `/opt/data/patches/matrix-inbound/patch_inbound.py`。
|
|
|
+
|
|
|
+```python
|
|
|
+#!/usr/bin/env python3
|
|
|
+"""Fmode 入站附件补丁:放行 https 白名单附件。支持 --check / --apply / --revert。
|
|
|
+
|
|
|
+设计要点:
|
|
|
+ · 幂等:已打过就跳过(靠 MARK 标记)
|
|
|
+ · 安全:任何一处匹配次数 ≠ 1 就整体中止,不写文件
|
|
|
+ · 可验证:--check 在内存里编译 + 抽函数 exec + 跑白名单用例
|
|
|
+"""
|
|
|
+import argparse
|
|
|
+import hashlib
|
|
|
+import shutil
|
|
|
+import sys
|
|
|
+import time
|
|
|
+from pathlib import Path
|
|
|
+
|
|
|
+TARGET = Path(sys.argv[0]).parent / "adapter.py" # 部署时改成绝对路径
|
|
|
+MARK = "# --- FMODE INBOUND HTTPS PATCH ---"
|
|
|
+
|
|
|
+HELPER = '''# --- FMODE INBOUND HTTPS PATCH ---
|
|
|
+# Fmode: 允许容器直接拉取的附件域名。防 SSRF:只放行这一个。
|
|
|
+# 注意:file-cloud.fmode.cn 证书已过期(2026-11-13 实测),不要加回来。
|
|
|
+_FMODE_MEDIA_HOSTS = {"s3.fmode.cn"}
|
|
|
+
|
|
|
+
|
|
|
+def _is_allowed_media_url(u: str) -> bool:
|
|
|
+ """mxc:// 一律放行;https:// 只放行个人存储白名单域名。"""
|
|
|
+ if u.startswith("mxc://"):
|
|
|
+ return True
|
|
|
+ if not u.startswith("https://"):
|
|
|
+ return False
|
|
|
+ try:
|
|
|
+ from urllib.parse import urlparse
|
|
|
+
|
|
|
+ return (urlparse(u).hostname or "").lower() in _FMODE_MEDIA_HOSTS
|
|
|
+ except Exception:
|
|
|
+ return False
|
|
|
+# --- END FMODE INBOUND HTTPS PATCH ---
|
|
|
+
|
|
|
+
|
|
|
+'''
|
|
|
+
|
|
|
+FETCH = ''' async def _fetch_http_media(self, url: str, event_id: str):
|
|
|
+ """Fmode: 从个人存储(https)流式拉取附件。超限/失败返回 None。"""
|
|
|
+ if not _is_allowed_media_url(url):
|
|
|
+ logger.warning("[Matrix] Refusing non-allowed media URL %s: %s", event_id, url)
|
|
|
+ return None
|
|
|
+ limit = self._max_media_bytes or 0
|
|
|
+
|
|
|
+ async def _read_aiohttp():
|
|
|
+ import aiohttp
|
|
|
+ timeout = aiohttp.ClientTimeout(total=600)
|
|
|
+ async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
|
+ async with session.get(url, allow_redirects=False) as resp:
|
|
|
+ if resp.status != 200:
|
|
|
+ logger.warning("[Matrix] HTTP media fetch failed %s: %s", event_id, resp.status)
|
|
|
+ return None
|
|
|
+ buf = bytearray()
|
|
|
+ async for chunk in resp.content.iter_chunked(64 * 1024):
|
|
|
+ buf.extend(chunk)
|
|
|
+ if limit and len(buf) > limit:
|
|
|
+ logger.warning("[Matrix] Rejecting oversized HTTP media %s", event_id)
|
|
|
+ return None
|
|
|
+ return bytes(buf)
|
|
|
+
|
|
|
+ async def _read_httpx():
|
|
|
+ import httpx
|
|
|
+ async with httpx.AsyncClient(timeout=600, follow_redirects=False) as hc:
|
|
|
+ async with hc.stream("GET", url) as resp:
|
|
|
+ if resp.status_code != 200:
|
|
|
+ logger.warning("[Matrix] HTTP media fetch failed %s: %s", event_id, resp.status_code)
|
|
|
+ return None
|
|
|
+ buf = bytearray()
|
|
|
+ async for chunk in resp.aiter_bytes(64 * 1024):
|
|
|
+ buf.extend(chunk)
|
|
|
+ if limit and len(buf) > limit:
|
|
|
+ logger.warning("[Matrix] Rejecting oversized HTTP media %s", event_id)
|
|
|
+ return None
|
|
|
+ return bytes(buf)
|
|
|
+
|
|
|
+ try:
|
|
|
+ return await _read_aiohttp()
|
|
|
+ except ImportError:
|
|
|
+ return await _read_httpx()
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning("[Matrix] HTTP media download error %s: %s", event_id, e)
|
|
|
+ return None
|
|
|
+
|
|
|
+'''
|
|
|
+
|
|
|
+REPS = [
|
|
|
+ (
|
|
|
+ ' if url and not str(url).startswith("mxc://"):\n'
|
|
|
+ ' logger.warning("[Matrix] Rejecting inbound media %s with non-MXC URL", event_id)\n',
|
|
|
+ ' if url and not _is_allowed_media_url(str(url)):\n'
|
|
|
+ ' logger.warning("[Matrix] Rejecting inbound media %s with non-allowed URL: %s", event_id, url)\n',
|
|
|
+ ),
|
|
|
+ (
|
|
|
+ ' if url and not str(url).startswith("mxc://"):\n'
|
|
|
+ ' logger.warning("[Matrix] Rejecting inbound encrypted media %s with non-MXC URL", event_id)\n',
|
|
|
+ ' if url and not _is_allowed_media_url(str(url)):\n'
|
|
|
+ ' logger.warning("[Matrix] Rejecting inbound encrypted media %s with non-allowed URL: %s", event_id, url)\n',
|
|
|
+ ),
|
|
|
+ (
|
|
|
+ ' file_bytes = await self._client.download_media(ContentURI(url))',
|
|
|
+ ' if str(url).startswith("https://"):\n'
|
|
|
+ ' # Fmode: 附件直读个人存储(s3.fmode.cn),不经 Matrix media\n'
|
|
|
+ ' file_bytes = await self._fetch_http_media(url, event_id)\n'
|
|
|
+ ' if file_bytes is None:\n'
|
|
|
+ ' return None\n'
|
|
|
+ ' else:\n'
|
|
|
+ ' file_bytes = await self._client.download_media(ContentURI(url))',
|
|
|
+ ),
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+def build(src: str) -> str:
|
|
|
+ for a, _ in REPS:
|
|
|
+ n = src.count(a)
|
|
|
+ if n != 1:
|
|
|
+ raise SystemExit(f"匹配 {n} 次(期望 1),中止:{a[:60]!r}")
|
|
|
+ for a, b in REPS:
|
|
|
+ src = src.replace(a, b, 1)
|
|
|
+ # 模块级插入:必须在 class 定义之前
|
|
|
+ anchor = "\nclass "
|
|
|
+ i = src.index(anchor)
|
|
|
+ src = src[:i] + "\n" + HELPER.rstrip("\n") + "\n" + src[i:]
|
|
|
+ # 方法插入:在 _download_and_cache_media 之前
|
|
|
+ m = " async def _download_and_cache_media("
|
|
|
+ if src.count(m) != 1:
|
|
|
+ raise SystemExit("找不到 _download_and_cache_media,中止")
|
|
|
+ src = src.replace(m, FETCH + m, 1)
|
|
|
+ return src
|
|
|
+
|
|
|
+
|
|
|
+def check(src: str) -> int:
|
|
|
+ patched = build(src)
|
|
|
+ compile(patched, str(TARGET), "exec")
|
|
|
+ print("compile OK")
|
|
|
+ # 抽函数真实执行
|
|
|
+ ns = {}
|
|
|
+ start = patched.index("_FMODE_MEDIA_HOSTS")
|
|
|
+ end = patched.index("# --- END FMODE INBOUND HTTPS PATCH ---")
|
|
|
+ exec(patched[start:end], ns)
|
|
|
+ f = ns["_is_allowed_media_url"]
|
|
|
+ print("白名单集合:", ns["_FMODE_MEDIA_HOSTS"])
|
|
|
+ cases = {
|
|
|
+ "https://s3.fmode.cn/a.txt": True,
|
|
|
+ "mxc://fmode.cn/abc": True,
|
|
|
+ "https://s3.fmode.cn.evil.com/a": False,
|
|
|
+ "https://evil.com/s3.fmode.cn": False,
|
|
|
+ "https://s3.fmode.cn@evil.com/a": False,
|
|
|
+ "http://s3.fmode.cn/a": False,
|
|
|
+ "https://sub.s3.fmode.cn/a": False,
|
|
|
+ "https://file-cloud.fmode.cn/a": False,
|
|
|
+ }
|
|
|
+ bad = 0
|
|
|
+ for u, want in cases.items():
|
|
|
+ got = f(u)
|
|
|
+ ok = got is want
|
|
|
+ bad += 0 if ok else 1
|
|
|
+ print(("PASS" if ok else "FAIL"), u, "->", got, "(期望", want, ")")
|
|
|
+ print("ALL_PASS" if bad == 0 else f"HAS_FAIL {bad}")
|
|
|
+ return 0 if bad == 0 else 1
|
|
|
+
|
|
|
+
|
|
|
+def main() -> int:
|
|
|
+ ap = argparse.ArgumentParser()
|
|
|
+ ap.add_argument("--check", action="store_true")
|
|
|
+ ap.add_argument("--apply", action="store_true")
|
|
|
+ ap.add_argument("--revert", action="store_true")
|
|
|
+ a = ap.parse_args()
|
|
|
+
|
|
|
+ src = TARGET.read_text(encoding="utf-8")
|
|
|
+ if a.revert:
|
|
|
+ # 🔴 不能取「最近的 .bak」——最近的那份可能是别的操作留下的、甚至已含补丁。
|
|
|
+ # 只认「内容里不含 MARK」的备份,且取其中最新的那份。
|
|
|
+ baks = sorted(TARGET.parent.glob(TARGET.name + ".bak*"), reverse=True)
|
|
|
+ good = []
|
|
|
+ for b in baks:
|
|
|
+ try:
|
|
|
+ if MARK not in b.read_text(encoding="utf-8"):
|
|
|
+ good.append(b)
|
|
|
+ except Exception:
|
|
|
+ continue
|
|
|
+ if not good:
|
|
|
+ raise SystemExit("没有可用的原版备份(全部含补丁或不可读)")
|
|
|
+ shutil.copy2(good[0], TARGET)
|
|
|
+ print("已回滚自", good[0])
|
|
|
+ print("md5:", hashlib.md5(TARGET.read_bytes()).hexdigest())
|
|
|
+ print("⚠️ 需要重启 gateway 才会生效:s6-svc -r /run/service/gateway-default")
|
|
|
+ return 0
|
|
|
+ if MARK in src:
|
|
|
+ print("已打过补丁,跳过")
|
|
|
+ return 0
|
|
|
+ if a.check:
|
|
|
+ return check(src)
|
|
|
+ if a.apply:
|
|
|
+ if check(src) != 0:
|
|
|
+ raise SystemExit("校验未通过,拒绝写盘")
|
|
|
+ bak = TARGET.with_name(TARGET.name + ".bak." + time.strftime("%Y%m%d%H%M%S"))
|
|
|
+ shutil.copy2(TARGET, bak)
|
|
|
+ TARGET.write_text(build(src), encoding="utf-8")
|
|
|
+ print("已写入;备份:", bak)
|
|
|
+ print("md5:", hashlib.md5(TARGET.read_bytes()).hexdigest())
|
|
|
+ return 0
|
|
|
+ ap.print_help()
|
|
|
+ return 2
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ sys.exit(main())
|
|
|
+```
|
|
|
+
|
|
|
+**用法**:
|
|
|
+
|
|
|
+```bash
|
|
|
+PY=/opt/hermes/.venv/bin/python
|
|
|
+# 1) 先检查(不写盘)
|
|
|
+sudo -u root $PY /opt/data/patches/matrix-inbound/patch_inbound.py --check
|
|
|
+# 2) 通过后应用
|
|
|
+sudo -u root $PY /opt/data/patches/matrix-inbound/patch_inbound.py --apply
|
|
|
+# 3) 出问题回滚
|
|
|
+sudo -u root $PY /opt/data/patches/matrix-inbound/patch_inbound.py --revert
|
|
|
+```
|
|
|
+
|
|
|
+> 🔴 **`--revert` 必须真的存在且真的还原。** 实测踩过:某容器的补丁脚本只判断了
|
|
|
+> `--apply`,其它参数一律走 `--check` —— 跑 `--revert` 时**静默什么都没做**,却打印了看似正常的信息,
|
|
|
+> 让人误以为「已回滚」。判断参数时**必须显式拒绝未知参数**:
|
|
|
+> ```python
|
|
|
+> if not (a.check or a.apply or a.revert):
|
|
|
+> raise SystemExit(f"未知参数:{sys.argv[1:]}")
|
|
|
+> ```
|
|
|
+>
|
|
|
+> **最可靠的回滚**永远是:用宿主机侧备份覆盖 + 重启 gateway(见 §5.4)。
|
|
|
+
|
|
|
+### 2.3 权限问题(新容器必遇)
|
|
|
+
|
|
|
+`adapter.py` 属 `root:root 644`,容器内 `hermes` 用户**无写权限也无 sudo**。两个选择:
|
|
|
+
|
|
|
+**方案 A(推荐)**:宿主机执行一次 chown
|
|
|
+
|
|
|
+```bash
|
|
|
+docker exec -u root <容器名> chown hermes:hermes /opt/hermes/plugins/platforms/matrix/adapter.py
|
|
|
+```
|
|
|
+
|
|
|
+**方案 B**:宿主机直接执行 apply
|
|
|
+
|
|
|
+```bash
|
|
|
+docker cp /opt/data/patches/matrix-inbound/patch_inbound.py <容器名>:/tmp/
|
|
|
+docker exec -u root <容器名> /opt/hermes/.venv/bin/python /tmp/patch_inbound.py --apply
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 三、出站改造:切断 Matrix 回发文件的老路
|
|
|
+
|
|
|
+### 3.1 原理
|
|
|
+
|
|
|
+Agent 在回复里写 `MEDIA:<本地路径>` 时,gateway 会解析出 `media_files`,然后:
|
|
|
+- `send_video()` / `send_image_batch()` / `send_document()` → **把文件本体上传进 Matrix**
|
|
|
+- 路径不存在 → 发 `⚠️ Couldn't deliver the xxx attachment.`
|
|
|
+
|
|
|
+**必须切断**。但有一个硬约束:
|
|
|
+
|
|
|
+> 🔴 `transform_llm_output` 是 **first non-None wins + break**
|
|
|
+> (`/opt/hermes/agent/turn_finalizer.py:427-429`):
|
|
|
+> 同一 hook 的多个插件拿到**同一份** `response_text`、互不可见,
|
|
|
+> **只有第一个返回非 None 的生效**并 break。
|
|
|
+>
|
|
|
+> **⇒ 剥离逻辑必须合并进 `plugin-deliverable-inject` 的同一个回调,且顺序是:**
|
|
|
+> ```
|
|
|
+> ① 先剥离 MEDIA 标记 → ② 再注入 **已上云(可直接下载)** + https
|
|
|
+> ```
|
|
|
+
|
|
|
+### 3.2 新增 `media_strip.py`
|
|
|
+
|
|
|
+路径:`/opt/data/plugins/plugin-deliverable-inject/media_strip.py`
|
|
|
+
|
|
|
+```python
|
|
|
+"""MEDIA 标记全量剥离。
|
|
|
+
|
|
|
+剥离语义(四条,缺一不可):
|
|
|
+ 1) 行首 MEDIA:<任意路径> → 整行删除(不限前缀:/opt/hermes、/srv、Windows 盘符都覆盖)
|
|
|
+ 2) 行内 MEDIA:<path> → 只删标记+路径 token,保留同行其余文字
|
|
|
+ 3) 绝不触碰 **已上云(可直接下载)** 段与其中的 https
|
|
|
+ 4) 幂等:无改动返回 None
|
|
|
+"""
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import logging
|
|
|
+import os
|
|
|
+import re
|
|
|
+from typing import Optional
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+# 🔴 注意:源码里 MEDIA 是字面量,此处为文档排版用「MEDIA:」示意
|
|
|
+_LINE_MEDIA = re.compile(r"(?m)^[ \t]*(?:>[ \t]*)*(?:[-*+][ \t]+)?MEDIA:[ \t]*[^\n]*(?:\n|$)")
|
|
|
+_INLINE_MEDIA = re.compile(
|
|
|
+ r"[ \t]*MEDIA:[ \t]*"
|
|
|
+ r"(?:`(?P<bt>/[^`\n]*|[A-Za-z]:[^`\n]*)`"
|
|
|
+ r"|<(?P<ang>/[^>\n]*)>"
|
|
|
+ r"|(?P<bare>/[^\s,。;:!?、))\]】\"'`]+)"
|
|
|
+ r"|(?P<win>[A-Za-z]:[\\/][^\s,。;:!?、))\]】\"'`]+)"
|
|
|
+ r"|(?P<url>[a-z]+://[^\s,。;:!?、))\]】\"'`]+)"
|
|
|
+ r")?"
|
|
|
+)
|
|
|
+_UPLOAD_HEADER = "**已上云(可直接下载)**"
|
|
|
+_LOCAL_HINTS = ("/opt/data/", "/tmp/", "/home/", "file://")
|
|
|
+
|
|
|
+
|
|
|
+def _disabled() -> bool:
|
|
|
+ return str(os.environ.get("PLUGIN_FMODE_STRIP_MEDIA_DISABLE", "")).strip().lower() in {"1", "true", "yes", "on"}
|
|
|
+
|
|
|
+
|
|
|
+def strip_media(text: str) -> Optional[str]:
|
|
|
+ if _disabled() or not isinstance(text, str) or "MEDIA:" not in text:
|
|
|
+ return None
|
|
|
+ original = text
|
|
|
+ out = _LINE_MEDIA.sub("", text)
|
|
|
+ if "MEDIA:" in out:
|
|
|
+ out = _INLINE_MEDIA.sub("", out)
|
|
|
+ out = re.sub(r"[ \t]+(?=\n)", "", out)
|
|
|
+ out = re.sub(r"(?m)^[ \t]*[,。;:、,;:][ \t]*$", "", out)
|
|
|
+ out = re.sub(r"\n{3,}", "\n\n", out).strip()
|
|
|
+ if out == original:
|
|
|
+ return None
|
|
|
+ # 自检:注入段标题必须原样存活,否则放弃本次剥离
|
|
|
+ if _UPLOAD_HEADER in original and _UPLOAD_HEADER not in out:
|
|
|
+ logger.error("strip_media: 误删上云段标题,放弃剥离")
|
|
|
+ return None
|
|
|
+ logger.info("strip_media: 已剥离 MEDIA 标记(%d -> %d 字符)", len(original), len(out))
|
|
|
+ return out
|
|
|
+```
|
|
|
+
|
|
|
+### 3.3 修改 `plugin-deliverable-inject/__init__.py`
|
|
|
+
|
|
|
+在 `_on_transform_llm_output` 的**最开头**(注入之前)插入:
|
|
|
+
|
|
|
+```python
|
|
|
+ # ── 步骤 0:全量剥离 MEDIA 标记(必须在注入之前)──
|
|
|
+ # 为什么在这里而不是独立插件:invoke_hook 对同一 hook 的多个回调传同一份 kwargs、
|
|
|
+ # 只 collect 返回值(互不可见),且 turn_finalizer 只取第一个 non-None str。
|
|
|
+ # 两插件无法串联,只能同回调内按序执行。
|
|
|
+ original_input = response_text
|
|
|
+ try:
|
|
|
+ from . import media_strip # type: ignore
|
|
|
+ stripped = media_strip.strip_media(response_text)
|
|
|
+ if stripped is not None:
|
|
|
+ response_text = stripped
|
|
|
+ except Exception as exc:
|
|
|
+ logger.warning("deliverable-inject: MEDIA 剥离失败,继续注入(%s)", exc)
|
|
|
+```
|
|
|
+
|
|
|
+并**删除**原来那段只认行首、前缀仅 `/opt/data|/tmp|/home` 的旧正则(避免两套语义漂移)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 四、插件与环境变量配置
|
|
|
+
|
|
|
+### 4.1 插件清单(lj-test 实际状态)
|
|
|
+
|
|
|
+| 插件 | 作用 | 必须启用 |
|
|
|
+|---|---|---|
|
|
|
+| `plugin-deliverable-inject` | 剥离 MEDIA + 注入「已上云」链接 | ✅ **核心** |
|
|
|
+| `plugin-active-reporter` | 状态上报(在线/心跳) | ✅ 推荐 |
|
|
|
+| `plugin-media-text-merge` | 图文两轮合并成一轮 | 推荐 |
|
|
|
+| `plugin-turn-end` | 已弃用(typing 方案作废) | ❌ |
|
|
|
+| `plugin-tips-cn` / `plugin-topup-guard` | 业务相关 | 按需 |
|
|
|
+
|
|
|
+**登记方式**(插件不登记 = 没装,**且不会有任何报错**):
|
|
|
+
|
|
|
+```bash
|
|
|
+hermes plugins list # 看当前
|
|
|
+hermes plugins enable <插件名> # 启用
|
|
|
+# 或直接编辑 /opt/data/config.yaml 的 plugins.enabled 列表
|
|
|
+grep -n "plugins:" -A20 /opt/data/config.yaml # 确认已登记
|
|
|
+```
|
|
|
+
|
|
|
+### 4.2 关键环境变量(`/opt/data/.env`)
|
|
|
+
|
|
|
+```ini
|
|
|
+# ---- Matrix 接入 ----
|
|
|
+MATRIX_HOMESERVER=https://matrix.fmode.cn
|
|
|
+MATRIX_USER_ID=@<你的agentId>:fmode.cn
|
|
|
+AGENT_ID=<你的 agentId>
|
|
|
+
|
|
|
+# ---- 对话行为 ----
|
|
|
+MATRIX_REQUIRE_MENTION=false # DM 全放开;群聊按需改 true
|
|
|
+MATRIX_AUTO_THREAD=true # 无 thread 的消息自动成为 thread 根
|
|
|
+
|
|
|
+# ---- 媒体 ----
|
|
|
+MATRIX_MAX_MEDIA_BYTES=104857600 # 100MB(默认值,可调大)
|
|
|
+
|
|
|
+# ---- 🔴 鉴权白名单(新容器最容易漏)----
|
|
|
+# 不配这一项,App 发的消息会被静默丢弃,表现为「只打 ✅ 不回正文」
|
|
|
+MATRIX_ALLOWED_USERS=@<App用户MXID>:fmode.cn,@<其他用户>:fmode.cn
|
|
|
+```
|
|
|
+
|
|
|
+> 🔴 **鉴权白名单是最隐蔽的坑**。
|
|
|
+> 真实案例:某容器改造后「只打 ✅ 不回复」,排查半天,最终发现是
|
|
|
+> `Unauthorized user (ignored): Dropped a message from unrecognized matrix user`,
|
|
|
+> 19 条消息全被丢弃。**与代码改造无关,只是没加白名单。**
|
|
|
+
|
|
|
+**怎么拿到 App 用户的 MXID**:让 App 侧执行 `provision({agentId})`,返回的 `userMxId` 就是。
|
|
|
+
|
|
|
+### 4.3 重启 gateway
|
|
|
+
|
|
|
+```bash
|
|
|
+# 🔴 先用 §1.2 查到的实际服务名,不要照抄
|
|
|
+s6-svc -r /run/service/gateway-default
|
|
|
+# 或
|
|
|
+s6-svc -r /run/s6/services/main-hermes
|
|
|
+
|
|
|
+sleep 5
|
|
|
+ps -o lstart= -p $(pgrep -f "hermes gateway run" | head -1) # 确认启动时间是刚才
|
|
|
+```
|
|
|
+
|
|
|
+> ⚠️ **服务名写错不会报错,但补丁不生效** —— 这是「改了没反应」的常见原因。
|
|
|
+> ⚠️ `s6-svc -r` 会**打断当前这个 agent 回合**,所以重启命令建议放在回合最后一步。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 五、持久化兜底(跨容器重建保留)
|
|
|
+
|
|
|
+### 5.1 为什么需要
|
|
|
+
|
|
|
+实测:
|
|
|
+
|
|
|
+| 路径 | 类型 | `docker restart` | `docker rm` + 重建 | 镜像升级 |
|
|
|
+|---|---|---|---|---|
|
|
|
+| `/opt/hermes/...` | **overlay 可写层** | ✅ 保留 | ❌ **丢失** | ❌ **丢失** |
|
|
|
+| `/opt/data/...` | ext4 持久卷 | ✅ 保留 | ✅ 保留 | ✅ 保留 |
|
|
|
+
|
|
|
+**结论:网关重启 / 容器重启不影响;容器重建会丢入站补丁。**
|
|
|
+
|
|
|
+丢了之后的症状:用户发附件收不到,日志只有一行 `Rejecting inbound media ... non-MXC URL`,
|
|
|
+**极难联想到是补丁没了**。
|
|
|
+
|
|
|
+### 5.2 `entry.sh` 载体方案(lj-test 已验证)
|
|
|
+
|
|
|
+在持久卷建目录:
|
|
|
+
|
|
|
+```bash
|
|
|
+mkdir -p /opt/data/.fmode-media-patch
|
|
|
+cp /opt/data/patches/matrix-inbound/patch_inbound.py /opt/data/.fmode-media-patch/
|
|
|
+```
|
|
|
+
|
|
|
+`/opt/data/.fmode-media-patch/entry.sh`:
|
|
|
+
|
|
|
+```bash
|
|
|
+#!/bin/bash
|
|
|
+# Fmode: 容器启动时幂等重打入站附件补丁,然后执行原入口。
|
|
|
+# 幂等:已打过就直接跳过(patch_inbound.py 内部有 MARK 判断)。
|
|
|
+set -u
|
|
|
+
|
|
|
+PATCH=/opt/data/.fmode-media-patch/patch_inbound.py
|
|
|
+TARGET=/opt/hermes/plugins/platforms/matrix/adapter.py
|
|
|
+PY=/opt/hermes/.venv/bin/python
|
|
|
+
|
|
|
+echo "[fmode-patch] 检查入站附件补丁…"
|
|
|
+if [ -w "$TARGET" ]; then
|
|
|
+ "$PY" "$PATCH" --apply || echo "[fmode-patch] 打补丁失败,继续启动(附件功能可能不可用)"
|
|
|
+else
|
|
|
+ echo "[fmode-patch] adapter.py 不可写(需 root),跳过 —— 如附件失效请重新授权"
|
|
|
+fi
|
|
|
+
|
|
|
+# 执行原入口(保持容器原本的启动命令)
|
|
|
+exec /opt/hermes/docker/main-wrapper.sh "$@"
|
|
|
+```
|
|
|
+
|
|
|
+```bash
|
|
|
+chmod +x /opt/data/.fmode-media-patch/entry.sh
|
|
|
+```
|
|
|
+
|
|
|
+### 5.3 重建容器时挂上
|
|
|
+
|
|
|
+```bash
|
|
|
+docker run -d --name <容器名> \
|
|
|
+ --restart unless-stopped \
|
|
|
+ --entrypoint /opt/data/.fmode-media-patch/entry.sh \
|
|
|
+ -e TZ=Asia/Shanghai \
|
|
|
+ <其余原有 env> \
|
|
|
+ -v /opt/fmode-containers/<容器名>/data:/opt/data \
|
|
|
+ fmode/agent-node:<版本> gateway run
|
|
|
+```
|
|
|
+
|
|
|
+> ⚠️ 重建前必须先取到原容器的完整参数:
|
|
|
+> `docker inspect <容器名> > /tmp/orig.json`,照着恢复。
|
|
|
+> **数据卷不动就不会丢数据。**
|
|
|
+
|
|
|
+### 5.4 简化方案:不改 entrypoint,手动重打
|
|
|
+
|
|
|
+如果不方便改启动命令,至少保留:
|
|
|
+
|
|
|
+- `/opt/data/.fmode-media-patch/patch_inbound.py`(持久卷里)
|
|
|
+- `/opt/data/backups/adapter.py.bak-host-<ts>`(宿主机侧备份)
|
|
|
+
|
|
|
+重建后手动执行一次 `--apply` 即可。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 六、验收(四道门槛,缺一不可)
|
|
|
+
|
|
|
+### 门槛 1:文件层面
|
|
|
+
|
|
|
+```bash
|
|
|
+F=/opt/hermes/plugins/platforms/matrix/adapter.py
|
|
|
+stat -c '%s %y %U:%G' $F # 大小应变大、时间是刚才
|
|
|
+grep -c "_is_allowed_media_url" $F # 期望 4
|
|
|
+md5sum $F # 与改造前不同
|
|
|
+```
|
|
|
+
|
|
|
+### 门槛 2:逻辑层面(**决定性**)
|
|
|
+
|
|
|
+```bash
|
|
|
+/opt/hermes/.venv/bin/python - <<'PY'
|
|
|
+import importlib.util, sys
|
|
|
+sys.path.insert(0, '/opt/hermes')
|
|
|
+spec = importlib.util.spec_from_file_location('probe', '/opt/hermes/plugins/platforms/matrix/adapter.py')
|
|
|
+m = importlib.util.module_from_spec(spec)
|
|
|
+sys.modules[spec.name] = m # 🔴 必须注册,否则 @dataclass 解析失败
|
|
|
+spec.loader.exec_module(m)
|
|
|
+f = m._is_allowed_media_url
|
|
|
+for u, want in [
|
|
|
+ ('https://s3.fmode.cn/a.txt', True),
|
|
|
+ ('mxc://fmode.cn/abc', True),
|
|
|
+ ('https://s3.fmode.cn.evil.com/a', False),
|
|
|
+ ('https://evil.com/s3.fmode.cn', False),
|
|
|
+ ('https://s3.fmode.cn@evil.com/a', False),
|
|
|
+ ('http://s3.fmode.cn/a', False),
|
|
|
+ ('https://sub.s3.fmode.cn/a', False),
|
|
|
+]:
|
|
|
+ got = f(u)
|
|
|
+ print('PASS' if got is want else 'FAIL', u, '->', got)
|
|
|
+PY
|
|
|
+```
|
|
|
+
|
|
|
+**必须 `https://s3.fmode.cn/a.txt -> True`。**
|
|
|
+⚠️ 只"拒绝得都对"不够——某次补丁全返回 `False`,"拒绝得很对",但合法链接也被拒了。
|
|
|
+
|
|
|
+### 门槛 3:容器能正常对话(**之前必须先过这道**)
|
|
|
+
|
|
|
+App 侧发「1+1 等于几」,能收到正文「2」。
|
|
|
+❌ 只打 ✅ 不回正文 → 查 §4.2 的 `MATRIX_ALLOWED_USERS`。
|
|
|
+
|
|
|
+### 门槛 4:端到端(App 侧执行)
|
|
|
+
|
|
|
+App 发一个文件(`m.file`,`url=https://s3.fmode.cn/...`,内容里埋随机指纹码),检查:
|
|
|
+
|
|
|
+```bash
|
|
|
+# 容器内:确认下载了
|
|
|
+grep -E "Rejecting inbound media|non-allowed" /opt/data/logs/gateway.log | tail -5
|
|
|
+ls -lat /opt/data/cache/documents/ | head -3
|
|
|
+
|
|
|
+# 权威判据:查消息库
|
|
|
+/opt/hermes/.venv/bin/python -c "
|
|
|
+import sqlite3
|
|
|
+n=sqlite3.connect('/opt/data/state.db').execute(
|
|
|
+ \"select count(*) from messages where content like '%<指纹码>%'\").fetchone()[0]
|
|
|
+print('DBHIT' if n else 'DBMISS', n)"
|
|
|
+```
|
|
|
+
|
|
|
+必须 `DBHIT`。
|
|
|
+
|
|
|
+> 🔴 **不要用「容器在某房间回话」判断**——容器可能把回复发到别的 session,会误判。
|
|
|
+
|
|
|
+### 出站验收
|
|
|
+
|
|
|
+让 Agent 产出一个文件,检查回复正文:
|
|
|
+
|
|
|
+| 检查 | 期望 |
|
|
|
+|---|---|
|
|
|
+| 有 `MEDIA:` | ❌ 不该有 |
|
|
|
+| 有 `/opt/data/...` 本地路径 | ❌ 不该有 |
|
|
|
+| 有 `**已上云(可直接下载)**` + https | ✅ 应该有 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 七、踩坑速查表(全部实测过)
|
|
|
+
|
|
|
+| # | 坑 | 症状 | 解法 |
|
|
|
+|---|---|---|---|
|
|
|
+| 1 | `urlparse` 未导入 | 所有附件被拒,**日志无异常** | 函数内 `from urllib.parse import urlparse` |
|
|
|
+| 2 | 用裸 `python3` | 报"依赖缺失" | 一律 `/opt/hermes/.venv/bin/python` |
|
|
|
+| 3 | `file-cloud.fmode.cn` | 证书已过期 | 白名单只放 `s3.fmode.cn` |
|
|
|
+| 4 | 用 `Content-Length` 判大小 | chunked 时为 `None`,上限失效 | 流式累加计数 |
|
|
|
+| 5 | 跟随重定向 | SSRF 绕过 | `allow_redirects=False` |
|
|
|
+| 6 | 白名单函数插进类体 | `IndentationError` | 插到 `class` 之前(模块级) |
|
|
|
+| 7 | s6 服务名写错 | 补丁不生效,**无报错** | `pgrep -af s6-supervise` 确认 |
|
|
|
+| 8 | 插件没登记 | 完全不生效,**无报错** | `grep 插件名 config.yaml` |
|
|
|
+| 9 | **鉴权白名单没加** | **只打 ✅ 不回正文** | `MATRIX_ALLOWED_USERS` 加 App 用户 |
|
|
|
+| 10 | 两个插件各注册 `transform_llm_output` | 只有一个生效 | 合并进同一回调 |
|
|
|
+| 11 | 把模块级函数插在类方法前 | `IndentationError` | 插入锚点用 `\nclass ` |
|
|
|
+| 12 | 按 docker 容器名找容器 | 改错容器 | 只认 `AGENT_ID` + `MATRIX_USER_ID` |
|
|
|
+| 13 | 只做容器内自测 | 自测 PASS、App 侧不通 | **必须 App 侧端到端验证** |
|
|
|
+| 14 | Monkey Patch 整份替换上游方法 | 上游升级即静默失效 | 只改入口判断,不复制方法体 |
|
|
|
+| 15 | `s6-svc -r` 打断自己回合 | 重启命令后的输出丢失 | 重启命令放回合最后 |
|
|
|
+| 16 | **`--revert` 实际不存在** | 跑 `--revert` 静默走了 `--check`,什么都没还原,却像"已回滚" | 参数判断里**显式拒绝未知参数**;最可靠的回滚 = 宿主机备份覆盖 |
|
|
|
+| 17 | **`py_compile` 因 `__pycache__` 不可写而失败** | 报错但源码没问题,容易误判 | 用 `compile(src, path, "exec")` 做内存编译,或 `python -B` |
|
|
|
+| 18 | **`s6-svc` 不在 PATH** | `command not found`,以为服务不存在 | 用绝对路径 `/command/s6-svc`;先 `ls /command/` 确认 |
|
|
|
+| 19 | **entry.sh 两套补丁脚本打架** | 容器自带 patch 与手册脚本实现不同(frozenset+env 扩展 vs 固定白名单) | entry.sh **优先用容器自带的那份**,它不在时才回退;不要两套并存 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 八、改造前后对照
|
|
|
+
|
|
|
+| 环节 | 改造前 | 改造后 |
|
|
|
+|---|---|---|
|
|
|
+| 用户发文件 | App 双写:S3 + Matrix 媒体库 | 只写 S3,Matrix 只传链接 |
|
|
|
+| 容器收文件 | 只认 `mxc://`,https 静默丢弃 | 认 https 白名单域名,自己下载 |
|
|
|
+| 容器发文件 | 可能用 `MEDIA:` 回发本体 | 只能上云 + 正文给链接 |
|
|
|
+| 带宽 | 文件走两遍 | 走一遍 |
|
|
|
+| 换设备 | Matrix 清理媒体后失效 | S3 链接长期有效 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 附:给新容器的执行清单
|
|
|
+
|
|
|
+```markdown
|
|
|
+□ 1. 跑 §1.1 记录指纹,上报 AGENT_ID / MATRIX_USER_ID
|
|
|
+□ 2. 跑 §1.2 探明:venv python 路径 / s6 服务名 / adapter md5 / 插件清单
|
|
|
+□ 3. 跑 §1.3 确认 _handle_media_message 与 _download_and_cache_media 的行号
|
|
|
+□ 4. 检查 .env 的 MATRIX_ALLOWED_USERS 是否包含 App 用户 MXID(§4.2)
|
|
|
+□ 5. 安装 §2.2 的 patch_inbound.py 到 /opt/data/patches/matrix-inbound/
|
|
|
+□ 6. --check 通过后 --apply(需要 root,见 §2.3)
|
|
|
+□ 7. 安装 §3.2 的 media_strip.py,改 §3.3 的 plugin-deliverable-inject
|
|
|
+□ 8. 确认插件已登记(§4.1),重启 gateway(§4.3)
|
|
|
+□ 9. 过 §6 门槛 1、2
|
|
|
+□ 10. 通知 App 侧验证门槛 3、4
|
|
|
+□ 11. 建立 §5.2 的 entry.sh 持久化兜底
|
|
|
+```
|