__init__.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. """plugin-tips-cn — 把 Hermes 发出来的英文系统提示,就地换成随机的日常中文说法。
  2. 三层网(缺一层就会漏):
  3. 1. ``transform_tool_result`` —— 工具结果里的英文(超时、权限、路径、接口报错…)
  4. 2. ``transform_llm_output`` —— 助手最终回复里的同类英文
  5. 3. **适配器出口包装** —— 网关自己发到聊天的通知(忙时回执、进度心跳、自主进化复盘、
  6. 后台任务收工、cron 失败…)。**这些不走任何插件钩子**(源码里直接
  7. ``adapter.send(...)``),所以只能把适配器的文本发送方法包起来。这是本插件
  8. 最关键的一层,少了它,聊天里看到的还是英文。
  9. 词库在 ``phrases.json``,每类可放任意多条中文候选,命中随机挑一条;改完不用重启
  10. (按 mtime 热加载)。
  11. 开关(环境变量,新旧名字都认):
  12. PLUGIN_TIPS_CN_DISABLE=1 / CN_VOICE_DISABLE=1 整个插件静默(默认开)
  13. PLUGIN_TIPS_CN_LOG=1 / CN_VOICE_LOG=1 每次替换打日志
  14. PLUGIN_TIPS_CN_NO_SEND=1 只关「适配器出口」那一层
  15. """
  16. from __future__ import annotations
  17. import inspect
  18. import json
  19. import logging
  20. import os
  21. import random
  22. import re
  23. from pathlib import Path
  24. from typing import Any, Dict, List, Optional, Tuple
  25. logger = logging.getLogger(__name__)
  26. _HERE = Path(__file__).resolve().parent
  27. _PHRASES = _HERE / "phrases.json"
  28. # 太大的文本(多 MB 的工具输出)跳过:收益低、还拖慢循环。
  29. _MAX_LEN = 200_000
  30. _TRUTHY = {"1", "true", "yes", "on"}
  31. # 适配器上「发文本」的方法名。send/edit_message 覆盖绝大多数;
  32. # send_stream_frame 是原生流式(企微走这条);send_exec_approval/send_clarify/
  33. # send_slash_confirm 是审批与提问;send_image 等带 caption;emit_warning 是诊断推送。
  34. _TEXT_METHODS = (
  35. "send", "edit_message", "send_draft", "send_stream_frame", "send_private_notice",
  36. "send_slash_confirm", "send_clarify", "send_exec_approval", "retire_clarify_card",
  37. "send_image", "send_image_file", "send_animation", "send_voice", "send_video",
  38. "send_document", "send_multiple_images", "emit_warning", "emit_media_warning",
  39. "send_final_ledgered", "_send_final_text", "_send_plain_fallback", "_send_with_retry",
  40. )
  41. # 参数名命中这些就替换(一个方法可能有多个,比如 send_slash_confirm 的 title+message)
  42. _TEXT_PARAMS = (
  43. "content", "text", "message", "caption", "title", "notice", "question",
  44. "text_content", "prompt",
  45. )
  46. # 这些方法「返回值」才是要发出去的文本(文本在函数内部拼,包参数没用)
  47. _FORMAT_METHODS = ("_format_exec_approval", "_format_clarify_text")
  48. def _flag(*names: str) -> bool:
  49. for name in names:
  50. if os.environ.get(name, "").strip().lower() in _TRUTHY:
  51. return True
  52. return False
  53. def _disabled() -> bool:
  54. return _flag("PLUGIN_TIPS_CN_DISABLE", "CN_VOICE_DISABLE")
  55. # ────────────────────────── 词库 ──────────────────────────
  56. def _load_categories() -> List[Dict[str, Any]]:
  57. """读词库并预编译正则;坏规则只跳过自己,不拖垮插件。"""
  58. try:
  59. with open(_PHRASES, encoding="utf-8") as fh:
  60. data = json.load(fh)
  61. except Exception as exc: # pragma: no cover - 配置问题
  62. logger.warning("plugin-tips-cn: 词库读取失败 %s: %s", _PHRASES, exc)
  63. return []
  64. cats: List[Dict[str, Any]] = []
  65. for raw in data.get("categories", []) or []:
  66. pats = []
  67. for pat in raw.get("patterns", []) or []:
  68. try:
  69. pats.append(re.compile(pat, re.IGNORECASE))
  70. except re.error as exc:
  71. logger.warning("plugin-tips-cn: 跳过非法正则 %r (%s): %s", pat, raw.get("id"), exc)
  72. variants = [v for v in (raw.get("variants") or []) if isinstance(v, str) and v.strip()]
  73. if pats and variants:
  74. cats.append({"id": raw.get("id", "?"), "pats": pats, "variants": variants})
  75. return cats
  76. _CATEGORIES: List[Dict[str, Any]] = _load_categories()
  77. _CATS_MTIME: float = 0.0
  78. try:
  79. _CATS_MTIME = _PHRASES.stat().st_mtime
  80. except OSError:
  81. pass
  82. def _categories() -> List[Dict[str, Any]]:
  83. """词库改了就热加载(一次 stat,开销可忽略),省得每改一句话都重启网关。"""
  84. global _CATEGORIES, _CATS_MTIME
  85. try:
  86. mtime = _PHRASES.stat().st_mtime
  87. except OSError:
  88. return _CATEGORIES
  89. if mtime != _CATS_MTIME:
  90. _CATEGORIES = _load_categories()
  91. _CATS_MTIME = mtime
  92. logger.info("plugin-tips-cn: 词库已热加载,共 %d 类", len(_CATEGORIES))
  93. return _CATEGORIES
  94. def localize(text: str) -> Tuple[str, List[str]]:
  95. """返回 (替换后的文本, 命中的类别 id 列表)。没命中就原样返回。"""
  96. if not isinstance(text, str) or not text or _disabled():
  97. return text, []
  98. if len(text) > _MAX_LEN:
  99. return text, []
  100. hits: List[str] = []
  101. out = text
  102. for cat in _categories():
  103. variants = cat["variants"]
  104. def _replace(match: "re.Match[str]", _variants: List[str] = variants, _cid: str = cat["id"]) -> str:
  105. phrase = random.choice(_variants)
  106. groups = {k: v for k, v in (match.groupdict() or {}).items() if v is not None}
  107. if groups:
  108. try:
  109. phrase = phrase.format(**groups)
  110. except (KeyError, IndexError, ValueError):
  111. pass
  112. hits.append(_cid)
  113. return phrase
  114. for pat in cat["pats"]:
  115. out = pat.sub(_replace, out)
  116. if hits and _flag("PLUGIN_TIPS_CN_LOG", "CN_VOICE_LOG"):
  117. logger.info("plugin-tips-cn: 命中 %s", ", ".join(sorted(set(hits))))
  118. return out, hits
  119. def _loc(value: Any) -> Any:
  120. """只处理字符串;命中才换。"""
  121. if isinstance(value, str):
  122. new, hits = localize(value)
  123. return new if hits else value
  124. return value
  125. # ─────────────────── 第三层:适配器出口包装 ───────────────────
  126. def _wrap_method(cls: type, name: str) -> int:
  127. """包住 cls 自己的 name 方法,把它的**所有**文本参数过一遍词库。幂等。"""
  128. fn = cls.__dict__.get(name)
  129. if fn is None or getattr(fn, "_tips_cn_wrapped", False):
  130. return 0
  131. if not inspect.iscoroutinefunction(fn):
  132. return 0
  133. try:
  134. params = list(inspect.signature(fn).parameters)[1:] # 去掉 self
  135. except (TypeError, ValueError):
  136. return 0
  137. # 一个方法可能不止一个文本参数(send_slash_confirm 的 title + message)
  138. names = {i: p for i, p in enumerate(params) if p in _TEXT_PARAMS}
  139. if not names:
  140. return 0
  141. async def wrapper(self, *args, **kwargs):
  142. try:
  143. a = list(args)
  144. for i, pname in names.items():
  145. if len(a) > i:
  146. a[i] = _loc(a[i])
  147. elif pname in kwargs:
  148. kwargs[pname] = _loc(kwargs[pname])
  149. args = tuple(a)
  150. except Exception:
  151. pass # 替换失败绝不拦发送
  152. return await fn(self, *args, **kwargs)
  153. wrapper._tips_cn_wrapped = True
  154. try:
  155. wrapper.__name__ = getattr(fn, "__name__", name)
  156. wrapper.__doc__ = getattr(fn, "__doc__", None)
  157. except Exception:
  158. pass
  159. setattr(cls, name, wrapper)
  160. return 1
  161. def _wrap_formatter(cls: type, name: str) -> int:
  162. """包住「返回值就是文本」的方法:审批提示这类文案是在函数内部拼的,包参数没用。"""
  163. fn = cls.__dict__.get(name)
  164. if fn is None or getattr(fn, "_tips_cn_wrapped", False):
  165. return 0
  166. if inspect.iscoroutinefunction(fn):
  167. return 0
  168. def wrapper(*args, **kwargs):
  169. out = fn(*args, **kwargs)
  170. try:
  171. return _loc(out)
  172. except Exception:
  173. return out
  174. wrapper._tips_cn_wrapped = True
  175. try:
  176. wrapper.__name__ = getattr(fn, "__name__", name)
  177. wrapper.__doc__ = getattr(fn, "__doc__", None)
  178. except Exception:
  179. pass
  180. setattr(cls, name, wrapper)
  181. return 1
  182. def patch_adapters() -> int:
  183. """把出口焊死:所有平台适配器(含子类)的文本发送方法都过一遍词库。幂等,可反复调。"""
  184. if _disabled() or _flag("PLUGIN_TIPS_CN_NO_SEND"):
  185. return 0
  186. try:
  187. from gateway.platforms.base import BasePlatformAdapter
  188. except Exception as exc:
  189. logger.debug("plugin-tips-cn: 拿不到 BasePlatformAdapter(%s)", exc)
  190. return 0
  191. total = 0
  192. seen, stack = set(), [BasePlatformAdapter]
  193. while stack:
  194. cls = stack.pop()
  195. if cls in seen:
  196. continue
  197. seen.add(cls)
  198. for name in _TEXT_METHODS:
  199. try:
  200. total += _wrap_method(cls, name)
  201. except Exception as exc:
  202. logger.debug("plugin-tips-cn: 包 %s.%s 失败:%s", cls.__name__, name, exc)
  203. for name in _FORMAT_METHODS:
  204. try:
  205. total += _wrap_formatter(cls, name)
  206. except Exception as exc:
  207. logger.debug("plugin-tips-cn: 包 %s.%s 失败:%s", cls.__name__, name, exc)
  208. try:
  209. stack.extend(cls.__subclasses__())
  210. except Exception:
  211. pass
  212. if total:
  213. logger.info("plugin-tips-cn: 已包住 %d 个适配器出站方法(覆盖 %d 个类)", total, len(seen))
  214. return total
  215. # ────────────────────────── 钩子 ──────────────────────────
  216. def _on_transform_tool_result(tool_name: str = "", result: Any = None, **_: Any) -> Optional[str]:
  217. if not isinstance(result, str):
  218. return None
  219. new, hits = localize(result)
  220. return new if hits else None
  221. def _on_transform_llm_output(response_text: Any = None, **_: Any) -> Optional[str]:
  222. if not isinstance(response_text, str):
  223. return None
  224. new, hits = localize(response_text)
  225. return new if hits else None
  226. def _on_pre_gateway_dispatch(**_: Any):
  227. """每条入站消息都顺手补一次适配器包装(适配器可能是插件加载之后才导入的)。"""
  228. try:
  229. patch_adapters()
  230. except Exception:
  231. pass
  232. return None
  233. def register(ctx) -> None:
  234. ctx.register_hook("transform_tool_result", _on_transform_tool_result)
  235. ctx.register_hook("transform_llm_output", _on_transform_llm_output)
  236. ctx.register_hook("pre_gateway_dispatch", _on_pre_gateway_dispatch)
  237. try:
  238. patch_adapters()
  239. except Exception as exc:
  240. logger.warning("plugin-tips-cn: 适配器出口包装失败:%s", exc)