| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- """plugin-tips-cn — 把 Hermes 发出来的英文系统提示,就地换成随机的日常中文说法。
- 三层网(缺一层就会漏):
- 1. ``transform_tool_result`` —— 工具结果里的英文(超时、权限、路径、接口报错…)
- 2. ``transform_llm_output`` —— 助手最终回复里的同类英文
- 3. **适配器出口包装** —— 网关自己发到聊天的通知(忙时回执、进度心跳、自主进化复盘、
- 后台任务收工、cron 失败…)。**这些不走任何插件钩子**(源码里直接
- ``adapter.send(...)``),所以只能把适配器的文本发送方法包起来。这是本插件
- 最关键的一层,少了它,聊天里看到的还是英文。
- 词库在 ``phrases.json``,每类可放任意多条中文候选,命中随机挑一条;改完不用重启
- (按 mtime 热加载)。
- 开关(环境变量,新旧名字都认):
- PLUGIN_TIPS_CN_DISABLE=1 / CN_VOICE_DISABLE=1 整个插件静默(默认开)
- PLUGIN_TIPS_CN_LOG=1 / CN_VOICE_LOG=1 每次替换打日志
- PLUGIN_TIPS_CN_NO_SEND=1 只关「适配器出口」那一层
- """
- from __future__ import annotations
- import inspect
- import json
- import logging
- import os
- import random
- import re
- from pathlib import Path
- from typing import Any, Dict, List, Optional, Tuple
- logger = logging.getLogger(__name__)
- _HERE = Path(__file__).resolve().parent
- _PHRASES = _HERE / "phrases.json"
- # 太大的文本(多 MB 的工具输出)跳过:收益低、还拖慢循环。
- _MAX_LEN = 200_000
- _TRUTHY = {"1", "true", "yes", "on"}
- # 适配器上「发文本」的方法名。send/edit_message 覆盖绝大多数;
- # send_stream_frame 是原生流式(企微走这条);send_exec_approval/send_clarify/
- # send_slash_confirm 是审批与提问;send_image 等带 caption;emit_warning 是诊断推送。
- _TEXT_METHODS = (
- "send", "edit_message", "send_draft", "send_stream_frame", "send_private_notice",
- "send_slash_confirm", "send_clarify", "send_exec_approval", "retire_clarify_card",
- "send_image", "send_image_file", "send_animation", "send_voice", "send_video",
- "send_document", "send_multiple_images", "emit_warning", "emit_media_warning",
- "send_final_ledgered", "_send_final_text", "_send_plain_fallback", "_send_with_retry",
- )
- # 参数名命中这些就替换(一个方法可能有多个,比如 send_slash_confirm 的 title+message)
- _TEXT_PARAMS = (
- "content", "text", "message", "caption", "title", "notice", "question",
- "text_content", "prompt",
- )
- # 这些方法「返回值」才是要发出去的文本(文本在函数内部拼,包参数没用)
- _FORMAT_METHODS = ("_format_exec_approval", "_format_clarify_text")
- def _flag(*names: str) -> bool:
- for name in names:
- if os.environ.get(name, "").strip().lower() in _TRUTHY:
- return True
- return False
- def _disabled() -> bool:
- return _flag("PLUGIN_TIPS_CN_DISABLE", "CN_VOICE_DISABLE")
- # ────────────────────────── 词库 ──────────────────────────
- def _load_categories() -> List[Dict[str, Any]]:
- """读词库并预编译正则;坏规则只跳过自己,不拖垮插件。"""
- try:
- with open(_PHRASES, encoding="utf-8") as fh:
- data = json.load(fh)
- except Exception as exc: # pragma: no cover - 配置问题
- logger.warning("plugin-tips-cn: 词库读取失败 %s: %s", _PHRASES, exc)
- return []
- cats: List[Dict[str, Any]] = []
- for raw in data.get("categories", []) or []:
- pats = []
- for pat in raw.get("patterns", []) or []:
- try:
- pats.append(re.compile(pat, re.IGNORECASE))
- except re.error as exc:
- logger.warning("plugin-tips-cn: 跳过非法正则 %r (%s): %s", pat, raw.get("id"), exc)
- variants = [v for v in (raw.get("variants") or []) if isinstance(v, str) and v.strip()]
- if pats and variants:
- cats.append({"id": raw.get("id", "?"), "pats": pats, "variants": variants})
- return cats
- _CATEGORIES: List[Dict[str, Any]] = _load_categories()
- _CATS_MTIME: float = 0.0
- try:
- _CATS_MTIME = _PHRASES.stat().st_mtime
- except OSError:
- pass
- def _categories() -> List[Dict[str, Any]]:
- """词库改了就热加载(一次 stat,开销可忽略),省得每改一句话都重启网关。"""
- global _CATEGORIES, _CATS_MTIME
- try:
- mtime = _PHRASES.stat().st_mtime
- except OSError:
- return _CATEGORIES
- if mtime != _CATS_MTIME:
- _CATEGORIES = _load_categories()
- _CATS_MTIME = mtime
- logger.info("plugin-tips-cn: 词库已热加载,共 %d 类", len(_CATEGORIES))
- return _CATEGORIES
- def localize(text: str) -> Tuple[str, List[str]]:
- """返回 (替换后的文本, 命中的类别 id 列表)。没命中就原样返回。"""
- if not isinstance(text, str) or not text or _disabled():
- return text, []
- if len(text) > _MAX_LEN:
- return text, []
- hits: List[str] = []
- out = text
- for cat in _categories():
- variants = cat["variants"]
- def _replace(match: "re.Match[str]", _variants: List[str] = variants, _cid: str = cat["id"]) -> str:
- phrase = random.choice(_variants)
- groups = {k: v for k, v in (match.groupdict() or {}).items() if v is not None}
- if groups:
- try:
- phrase = phrase.format(**groups)
- except (KeyError, IndexError, ValueError):
- pass
- hits.append(_cid)
- return phrase
- for pat in cat["pats"]:
- out = pat.sub(_replace, out)
- if hits and _flag("PLUGIN_TIPS_CN_LOG", "CN_VOICE_LOG"):
- logger.info("plugin-tips-cn: 命中 %s", ", ".join(sorted(set(hits))))
- return out, hits
- def _loc(value: Any) -> Any:
- """只处理字符串;命中才换。"""
- if isinstance(value, str):
- new, hits = localize(value)
- return new if hits else value
- return value
- # ─────────────────── 第三层:适配器出口包装 ───────────────────
- def _wrap_method(cls: type, name: str) -> int:
- """包住 cls 自己的 name 方法,把它的**所有**文本参数过一遍词库。幂等。"""
- fn = cls.__dict__.get(name)
- if fn is None or getattr(fn, "_tips_cn_wrapped", False):
- return 0
- if not inspect.iscoroutinefunction(fn):
- return 0
- try:
- params = list(inspect.signature(fn).parameters)[1:] # 去掉 self
- except (TypeError, ValueError):
- return 0
- # 一个方法可能不止一个文本参数(send_slash_confirm 的 title + message)
- names = {i: p for i, p in enumerate(params) if p in _TEXT_PARAMS}
- if not names:
- return 0
- async def wrapper(self, *args, **kwargs):
- try:
- a = list(args)
- for i, pname in names.items():
- if len(a) > i:
- a[i] = _loc(a[i])
- elif pname in kwargs:
- kwargs[pname] = _loc(kwargs[pname])
- args = tuple(a)
- except Exception:
- pass # 替换失败绝不拦发送
- return await fn(self, *args, **kwargs)
- wrapper._tips_cn_wrapped = True
- try:
- wrapper.__name__ = getattr(fn, "__name__", name)
- wrapper.__doc__ = getattr(fn, "__doc__", None)
- except Exception:
- pass
- setattr(cls, name, wrapper)
- return 1
- def _wrap_formatter(cls: type, name: str) -> int:
- """包住「返回值就是文本」的方法:审批提示这类文案是在函数内部拼的,包参数没用。"""
- fn = cls.__dict__.get(name)
- if fn is None or getattr(fn, "_tips_cn_wrapped", False):
- return 0
- if inspect.iscoroutinefunction(fn):
- return 0
- def wrapper(*args, **kwargs):
- out = fn(*args, **kwargs)
- try:
- return _loc(out)
- except Exception:
- return out
- wrapper._tips_cn_wrapped = True
- try:
- wrapper.__name__ = getattr(fn, "__name__", name)
- wrapper.__doc__ = getattr(fn, "__doc__", None)
- except Exception:
- pass
- setattr(cls, name, wrapper)
- return 1
- def patch_adapters() -> int:
- """把出口焊死:所有平台适配器(含子类)的文本发送方法都过一遍词库。幂等,可反复调。"""
- if _disabled() or _flag("PLUGIN_TIPS_CN_NO_SEND"):
- return 0
- try:
- from gateway.platforms.base import BasePlatformAdapter
- except Exception as exc:
- logger.debug("plugin-tips-cn: 拿不到 BasePlatformAdapter(%s)", exc)
- return 0
- total = 0
- seen, stack = set(), [BasePlatformAdapter]
- while stack:
- cls = stack.pop()
- if cls in seen:
- continue
- seen.add(cls)
- for name in _TEXT_METHODS:
- try:
- total += _wrap_method(cls, name)
- except Exception as exc:
- logger.debug("plugin-tips-cn: 包 %s.%s 失败:%s", cls.__name__, name, exc)
- for name in _FORMAT_METHODS:
- try:
- total += _wrap_formatter(cls, name)
- except Exception as exc:
- logger.debug("plugin-tips-cn: 包 %s.%s 失败:%s", cls.__name__, name, exc)
- try:
- stack.extend(cls.__subclasses__())
- except Exception:
- pass
- if total:
- logger.info("plugin-tips-cn: 已包住 %d 个适配器出站方法(覆盖 %d 个类)", total, len(seen))
- return total
- # ────────────────────────── 钩子 ──────────────────────────
- def _on_transform_tool_result(tool_name: str = "", result: Any = None, **_: Any) -> Optional[str]:
- if not isinstance(result, str):
- return None
- new, hits = localize(result)
- return new if hits else None
- def _on_transform_llm_output(response_text: Any = None, **_: Any) -> Optional[str]:
- if not isinstance(response_text, str):
- return None
- new, hits = localize(response_text)
- return new if hits else None
- def _on_pre_gateway_dispatch(**_: Any):
- """每条入站消息都顺手补一次适配器包装(适配器可能是插件加载之后才导入的)。"""
- try:
- patch_adapters()
- except Exception:
- pass
- return None
- def register(ctx) -> None:
- ctx.register_hook("transform_tool_result", _on_transform_tool_result)
- ctx.register_hook("transform_llm_output", _on_transform_llm_output)
- ctx.register_hook("pre_gateway_dispatch", _on_pre_gateway_dispatch)
- try:
- patch_adapters()
- except Exception as exc:
- logger.warning("plugin-tips-cn: 适配器出口包装失败:%s", exc)
|