Prechádzať zdrojové kódy

plugin-wecom-fix v1.0.0: 企微官方插件通用补丁包(F1入站512MB/F2长文件名截断/F3视频三路径/F4 chatrecord+重试限流)——幂等patch.py+批量install+README; 本机skip等价验证+tuye/xinting全applied

liuyuyang 1 týždeň pred
commit
f437a65a62

+ 60 - 0
plugin-wecom-fix/README.md

@@ -0,0 +1,60 @@
+# plugin-wecom-fix · 企微官方插件通用补丁包
+
+> 修复 Hermes 官方 wecom 插件的四项接收缺陷(2026-09-09 淬炼自真实翻车:55 分钟视频收不到/合并转发被拒/批量图片丢失/长文件名 Errno 36)
+> 适用:所有装有官方 wecom 插件的 agent-node 容器(全集群数字生命)
+
+## 修复清单
+
+| # | 缺陷 | 症状 | 修复 |
+|---|---|---|---|
+| F1 | 入站下载上限错配 | 用户发大视频(>20MB)收不到 | 新增 `INBOUND_MAX_BYTES=512MB`,出站 20MB 上限不再误伤入站 |
+| F2 | 长文件名写盘失败 | 长中文标题文件转发丢消息(Errno 36) | 缓存文件名 200 字节 UTF-8 安全截断 |
+| F3 | 视频消息无提取分支 | 单发/组合/引用视频全部静默丢弃 | 三路径补 video 提取 |
+| F4 | 合并转发不识别+批量图片丢失 | 转发合并消息收到空消息(模型道歉"不支持此类消息");10+ 张图片只到 2-3 张 | chatrecord 文本提取(标题+逐条子消息);下载重试 3 次+CDN 限流退避+每 3 张 0.3s 间隔 |
+
+## 安装(存量数字生命)
+
+```bash
+# 1. 拉仓库
+git clone https://git.fmode.cn/fmode/plugin-wecom-fix.git /tmp/plugin-wecom-fix
+# 2. 预检
+python3 /tmp/plugin-wecom-fix/patch.py --check
+# 3. 应用(自动备份 .fmode-fix-bak)
+python3 /tmp/plugin-wecom-fix/patch.py --apply
+# 4. 重启 gateway 加载
+docker exec <容器> bash -c "pkill -f gateway.run"   # supervisor 自动拉起
+# 5. 验证
+python3 /tmp/plugin-wecom-fix/patch.py --check   # 全部 skip=已生效
+```
+
+## 批量安装(全集群)
+
+```bash
+bash install-all.sh   # 遍历 R730 全部 agent-node 容器执行 1-5 步(见脚本)
+```
+
+## 新数字生命初始化自动打补丁
+
+初始化 SOP(life-init-standard)在**步骤"插件装配"后**追加:
+
+```bash
+git clone https://git.fmode.cn/fmode/plugin-wecom-fix.git /tmp/plugin-wecom-fix && \
+python3 /tmp/plugin-wecom-fix/patch.py --apply && \
+rm -rf /tmp/plugin-wecom-fix
+```
+
+- `patch.py` 幂等(已应用自动 skip),初始化时重复执行无副作用
+- 官方插件升级后补丁如失效:`--check` 会重新报 applied,重跑 `--apply` 即可
+
+## 回滚
+
+```bash
+python3 patch.py --rollback   # 用 .fmode-fix-bak 还原两个原文件
+```
+
+## 验证清单(打完补丁必测)
+
+1. 发一个 >20MB 视频 → 能收到并缓存
+2. 转发一条合并消息 → 能复述内容(不是"不支持此类消息")
+3. 连续转发 10+ 张图片 → 全数到达
+4. 转发长中文标题文件(60+ 字)→ 正常落盘

+ 27 - 0
plugin-wecom-fix/install-all.sh

@@ -0,0 +1,27 @@
+#!/bin/bash
+# install-all.sh — 全集群 agent-node 容器批量安装 wecom-fix 补丁
+# 用法: bash install-all.sh [容器名...]
+# 缺省遍历 R730 上全部 fmode agent-node* 容器
+set -u
+CONTAINERS="${*:-agent-node agent-node-tuye agent-node-xinting}"
+REPO_URL="https://git.fmode.cn/fmode/plugin-wecom-fix.git"
+FIX_DIR="/tmp/plugin-wecom-fix"
+REPO_LOCAL="$(cd "$(dirname "$0")" && pwd)"
+
+# 打包本地补丁目录(免 git 依赖: 直接 tar 投递)
+TARBALL="/tmp/wecom-fix.tar.gz"
+tar -czf "$TARBALL" -C "$REPO_LOCAL" patch.py README.md 2>/dev/null || { echo "打包失败"; exit 1; }
+B64=$(base64 -w0 "$TARBALL")
+
+for C in $CONTAINERS; do
+  echo "=== $C ==="
+  # 投递补丁
+  docker exec "$C" bash -c "echo $B64 | base64 -d > /tmp/wecom-fix.tar.gz && mkdir -p $FIX_DIR && tar -xzf /tmp/wecom-fix.tar.gz -C $FIX_DIR" || { echo "  投递失败"; continue; }
+  # 预检+应用
+  docker exec "$C" python3 "$FIX_DIR/patch.py" --check 2>&1 | tail -2
+  docker exec "$C" python3 "$FIX_DIR/patch.py" --apply 2>&1 | tail -2
+  # 重启 gateway(独立 shell, supervisor 自动拉起)
+  docker exec "$C" bash -c "pkill -f gateway.run; echo restarted" 2>&1 | tail -1
+  echo ""
+done
+echo "全部完成。逐容器验证: python3 $FIX_DIR/patch.py --check"

+ 193 - 0
plugin-wecom-fix/patch.py

@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""
+fmode/plugin-wecom-fix/patch.py — WeCom 官方插件通用补丁 (v1, 2026-09-09)
+修复官方 hermes wecom 插件四项接收缺陷:
+  F1 入站媒体下载上限错配(出站20MB被用于入站, 大视频收不到) → INBOUND_MAX_BYTES=512MB
+  F2 长文件名 [Errno 36] 写盘失败丢消息(CJK 标题 260+ 字符) → cache 截断 200 字节
+  F3 视频消息无提取分支(单发/mixed/quote 三路径静默丢弃) → 三处补 video
+  F4 chatrecord 合并转发不识别(空消息→模型道歉) + 批量图片CDN限流丢失(无重试无间隔)
+     → chatrecord 文本提取 + 下载重试3次退避 + 每3张媒体0.3s间隔
+用法: python3 patch.py [--check|--apply|--rollback]
+幂等: 已应用的补丁自动跳过; --check 只报告; --rollback 用 .bak 还原
+"""
+import sys, shutil, py_compile
+from pathlib import Path
+
+MARK = "# FMODE-WECOM-FIX"
+ADAPTER = "/opt/hermes/plugins/platforms/wecom/adapter.py"
+BASE = "/opt/hermes/gateway/platforms/base.py"
+VERSION = "1.0.0"
+
+def _read(p): return Path(p).read_text(encoding="utf-8")
+
+def _write(p, t):
+    Path(p).write_text(t, encoding="utf-8")
+    py_compile.compile(p, doraise=True)
+
+def _bak(p):
+    bak = p + ".fmode-fix-bak"
+    if not Path(bak).exists():
+        shutil.copy2(p, bak)
+
+# ---------------- F1: INBOUND_MAX_BYTES ----------------
+def f1(t):
+    if "INBOUND_MAX_BYTES" in t: return t, "skip"
+    old = "ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES"
+    new = (old + "\n# FMODE-WECOM-FIX F1: inbound media cap (outbound 20MB cap must not gate inbound)\nINBOUND_MAX_BYTES = 512 * 1024 * 1024")
+    t = t.replace(old, new, 1)
+    # 两处入站下载改用新上限
+    t = t.replace("self._download_remote_bytes(url, max_bytes=ABSOLUTE_MAX_BYTES)",
+                  "self._download_remote_bytes(url, max_bytes=INBOUND_MAX_BYTES)")
+    return t, "applied"
+
+# ---------------- F2: 文件名截断 ----------------
+def f2(t):
+    if "max_name_bytes" in t: return t, "skip"
+    old = '''    safe_name = safe_name.replace("\\x00", "").strip()
+    if not safe_name or safe_name in {".", ".."}:
+        safe_name = "document"
+    cached_name = f"doc_{uuid.uuid4().hex[:12]}_{safe_name}"'''
+    new = '''    safe_name = safe_name.replace("\\x00", "").strip()
+    if not safe_name or safe_name in {".", ".."}:
+        safe_name = "document"
+    # FMODE-WECOM-FIX F2: filename component <=255 BYTES; truncate at UTF-8 boundary
+    max_name_bytes = 200
+    raw_bytes = safe_name.encode("utf-8")
+    if len(raw_bytes) > max_name_bytes:
+        truncated = raw_bytes[:max_name_bytes]
+        while truncated:
+            try:
+                safe_name = truncated.decode("utf-8") + "…"
+                break
+            except UnicodeDecodeError:
+                truncated = truncated[:-1]
+        else:
+            safe_name = "document"
+    cached_name = f"doc_{uuid.uuid4().hex[:12]}_{safe_name}"'''
+    if old not in t: return t, "MISS"
+    return t.replace(old, new, 1), "applied"
+
+# ---------------- F3: video 三路径 ----------------
+def f3(t):
+    if 'msgtype == "video"' in t: return t, "skip"
+    c = 0
+    o1 = '''                if item_type == "image" and isinstance(item.get("image"), dict):
+                    refs.append(("image", item["image"]))'''
+    n1 = o1 + '''
+                if item_type == "video" and isinstance(item.get("video"), dict):
+                    refs.append(("video", item["video"]))  # FMODE-WECOM-FIX F3'''
+    if o1 in t: t = t.replace(o1, n1, 1); c += 1
+    o2 = '''            if isinstance(body.get("image"), dict):
+                refs.append(("image", body["image"]))'''
+    n2 = o2 + '''
+            if msgtype == "video" and isinstance(body.get("video"), dict):
+                refs.append(("video", body["video"]))  # FMODE-WECOM-FIX F3'''
+    if o2 in t: t = t.replace(o2, n2, 1); c += 1
+    o3 = '''        elif quote_type == "file" and isinstance(quote.get("file"), dict):
+            refs.append(("file", quote["file"]))'''
+    n3 = '''        elif quote_type == "video" and isinstance(quote.get("video"), dict):
+            refs.append(("video", quote["video"]))  # FMODE-WECOM-FIX F3
+''' + o3
+    if o3 in t: t = t.replace(o3, n3, 1); c += 1
+    return t, f"applied({c}/3)" if c == 3 else f"PARTIAL({c}/3)"
+
+# ---------------- F4: chatrecord + 重试 + 限流间隔 ----------------
+def f4(t):
+    out = []
+    if 'elif msgtype == "chatrecord":' not in t:
+        anchor = '''                    if content:
+                        text_parts.append(content)
+        else:'''
+        rep = '''                    if content:
+                        text_parts.append(content)
+        elif msgtype == "chatrecord":
+            # FMODE-WECOM-FIX F4: merged-forward record — extract title + sub-items
+            cr = body.get("chatrecord") if isinstance(body.get("chatrecord"), dict) else {}
+            _ti = str(cr.get("title") or "").strip()
+            if _ti: text_parts.append(f"[合并转发] {_ti}")
+            _its = cr.get("record_items") or cr.get("item") or cr.get("messages") or []
+            if isinstance(_its, list):
+                for _i, _s in enumerate(_its[:50], 1):
+                    if not isinstance(_s, dict): continue
+                    _st = str(_s.get("msgtype") or "").lower()
+                    _nk = str(_s.get("nickname") or "").strip()
+                    _px = f"{_i}. {_nk}:" if _nk else f"{_i}."
+                    if _st == "text":
+                        _b = _s.get("text") if isinstance(_s.get("text"), dict) else {}
+                        _c = str(_b.get("content") or "").strip()
+                        if _c: text_parts.append(f"{_px} {_c[:500]}")
+                    elif _st in ("image", "video", "file", "voice"):
+                        _lb = {"image": "[图片]", "video": "[视频]", "file": "[文件]", "voice": "[语音]"}[_st]
+                        _b = _s.get(_st) if isinstance(_s.get(_st), dict) else {}
+                        _fn = str(_b.get("filename") or _b.get("name") or "").strip()
+                        text_parts.append(f"{_px} {_lb}{(' ' + _fn) if _fn else ''}")
+        else:'''
+        if anchor in t:
+            t = t.replace(anchor, rep, 1); out.append("chatrecord")
+    if "for attempt in range(3)" not in t:
+        oldB = '''        try:
+            raw, headers = await self._download_remote_bytes(url, max_bytes=INBOUND_MAX_BYTES)
+        except Exception as exc:
+            logger.debug("[%s] Failed to download %s from %s: %s", self.name, kind, url, exc)
+            return None'''
+        newB = '''        raw = headers = None
+        last_exc = None
+        for attempt in range(3):  # FMODE-WECOM-FIX F4: retry w/ CDN throttle backoff
+            try:
+                if attempt:
+                    import asyncio as _aio
+                    await _aio.sleep(0.5 * attempt * 1.5)
+                raw, headers = await self._download_remote_bytes(url, max_bytes=INBOUND_MAX_BYTES)
+                break
+            except Exception as exc:
+                last_exc = exc
+                logger.debug("[%s] Download attempt %d failed for %s: %s", self.name, attempt + 1, kind, exc)
+        if raw is None:
+            logger.warning("[%s] Failed to download %s after retries from %s: %s", self.name, kind, url, last_exc)
+            return None'''
+        if oldB in t:
+            t = t.replace(oldB, newB, 1); out.append("retry")
+    if "_ref_idx" not in t:
+        oldC = '''        for kind, ref in refs:
+            cached = await self._cache_media(kind, ref)'''
+        newC = '''        for _ref_idx, (kind, ref) in enumerate(refs):  # FMODE-WECOM-FIX F4
+            if _ref_idx and _ref_idx % 3 == 0:
+                import asyncio as _aio
+                await _aio.sleep(0.3)  # CDN throttle guard for batch media
+            cached = await self._cache_media(kind, ref)'''
+        if oldC in t:
+            t = t.replace(oldC, newC, 1); out.append("throttle")
+    return t, "+".join(out) if out else "skip"
+
+FIXES = {"F1": f1, "F2": f2, "F3": f3, "F4": f4}
+
+def main():
+    mode = sys.argv[1] if len(sys.argv) > 1 else "--apply"
+    adapter = _read(ADAPTER)
+    base = _read(BASE)
+    report = {}
+    if mode in ("--apply", "--check"):
+        work = {"adapter": adapter, "base": base}
+        for fid, fn in FIXES.items():
+            for fname in ("adapter", "base"):
+                if (fid in ("F1", "F3", "F4") and fname == "adapter") or (fid == "F2" and fname == "base"):
+                    new, status = fn(work[fname])
+                    if mode == "--apply" and status not in ("skip",):
+                        if status != "MISS":
+                            _bak(ADAPTER if fname == "adapter" else BASE)
+                        work[fname] = new
+                    report[f"{fname}:{fid}"] = status
+        if mode == "--apply":
+            _write(ADAPTER, work["adapter"]); _write(BASE, work["base"])
+            print(f"[fmode-wecom-fix v{VERSION}] applied: {report}")
+        else:
+            print(f"[fmode-wecom-fix v{VERSION}] check: {report}")
+    elif mode == "--rollback":
+        for p in (ADAPTER, BASE):
+            bak = p + ".fmode-fix-bak"
+            if Path(bak).exists():
+                shutil.copy2(bak, p); print(f"rolled back: {p}")
+    print(f"done mode={mode}")
+
+if __name__ == "__main__":
+    main()