identity_bridge.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. """工具桥 v3:把「当前这次工具调用」绑定到可信身份(隔离副本,未上机)
  2. v3 修订(对应 2026-11-02 二轮复核 1/2/4/5):
  3. 1. **不再回落到 os.environ**:`get_session_env()` 在 ContextVar 为 `_UNSET` 时会去读环境变量
  4. (源码注释:「never set here」→ falls back to os.environ),这正是「合成 env 就能骗过」的口子。
  5. v3 直接读 `gateway.session_context._VAR_MAP` 里的 ContextVar:`_UNSET` → 空串(本回合未绑定),
  6. **绝不查环境变量**;`_VAR_MAP` 不存在 → `SESSION_READER_UNAVAILABLE`(明确失败)。
  7. 2. **有效会话键**:单聊原始帧无 chatid 时落盘 `chatId=null`,而 `SessionSource.chat_id=sender_id`,
  8. 故核对用 `aibot_identity.effective_chat_id()`(= chatId or senderId),不再拿原始 chatId 硬比。
  9. 3. **botId 唯一来源 = 受控实例登记**(适配器自己写 `instance.json`),删掉顶层优先/extra 优先/正则兜底那套猜测。
  10. 4. **只服务 aibot**:`wecom_callback` 明确不支持(回调模式的来源/字段是另一套,不混接)。
  11. """
  12. from __future__ import annotations
  13. import json
  14. import logging
  15. import os
  16. import pathlib
  17. from typing import Any, Callable, Dict, Optional
  18. try: # 运行时(平台插件包内)
  19. from plugins.platforms.wecom.aibot_identity import ( # type: ignore
  20. DEFAULT_TTL_SECONDS, IdentityRejected, effective_chat_id, identity_summary,
  21. normalize_session_chat_type, read_context, read_instance_registry,
  22. )
  23. except ImportError: # 离线夹具/独立加载
  24. import sys
  25. sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
  26. from aibot_identity import ( # type: ignore
  27. DEFAULT_TTL_SECONDS, IdentityRejected, effective_chat_id, identity_summary,
  28. normalize_session_chat_type, read_context, read_instance_registry,
  29. )
  30. logger = logging.getLogger(__name__)
  31. TOOL_NAME = "wecom_identity_current"
  32. TOOLSET = "wecom"
  33. SUPPORTED_PLATFORMS = ("wecom",) # 只 aibot;wecom_callback 不接
  34. _UNSET_SENTINEL = object()
  35. class SessionReaderUnavailable(Exception):
  36. """生产会话读取器不可用(版本不符/缺变量)—— 不许退回环境变量猜。"""
  37. def __init__(self, code: str):
  38. super().__init__(code)
  39. self.code = code
  40. def strict_session_reader() -> Callable[[str], str]:
  41. """**无环境变量回落**的当前回合读取器:只认 ContextVar。
  42. 生产 reader 缺失(`_VAR_MAP` 不在)或某个变量缺失 → 抛 SessionReaderUnavailable。
  43. `_UNSET`(本回合未绑定)→ 返回 ""(空),由上层判失败关闭;**不查 os.environ**。
  44. """
  45. try:
  46. import gateway.session_context as session_context # type: ignore
  47. except Exception as exc: # noqa: BLE001
  48. raise SessionReaderUnavailable("SESSION_READER_UNAVAILABLE") from exc
  49. var_map = getattr(session_context, "_VAR_MAP", None)
  50. unset = getattr(session_context, "_UNSET", None)
  51. if not isinstance(var_map, dict) or "HERMES_SESSION_MESSAGE_ID" not in var_map:
  52. raise SessionReaderUnavailable("SESSION_READER_UNAVAILABLE")
  53. if unset is None:
  54. # 没有 `_UNSET` 哨兵就无法区分「本回合未绑定」与「真值」——
  55. # 若退回自造哨兵,未绑定的 ContextVar 会把内部对象 repr 当身份值输出。
  56. # 一律固定失败码(不输出原始异常)。
  57. raise SessionReaderUnavailable("SESSION_READER_UNAVAILABLE")
  58. def _read(name: str) -> str:
  59. var = var_map.get(name)
  60. if var is None:
  61. raise SessionReaderUnavailable(f"SESSION_VAR_MISSING:{name}")
  62. try:
  63. value = var.get()
  64. except Exception: # noqa: BLE001 — 固定码,不泄原始异常
  65. raise SessionReaderUnavailable("SESSION_VAR_UNAVAILABLE") from None
  66. if value is unset: # 本回合未绑定 —— 明确为空,**不回落 env**
  67. return ""
  68. if isinstance(value, (str, bytes)):
  69. return value.decode("utf-8", "replace") if isinstance(value, bytes) else value
  70. # 非字符串值不该出现在会话变量里:视为不可信,固定失败码
  71. raise SessionReaderUnavailable("SESSION_VAR_UNAVAILABLE")
  72. return _read
  73. def current_identity(args: Any = None, *, reader: Optional[Callable[[str], str]] = None,
  74. directory: Optional[str] = None,
  75. ttl_seconds: int = DEFAULT_TTL_SECONDS,
  76. now: Optional[float] = None,
  77. instance_dir: Optional[str] = None) -> Dict[str, Any]:
  78. """返回**已核验**的本次调用身份;任何不确定一律 ok=False(失败关闭)。"""
  79. try:
  80. read = reader or strict_session_reader()
  81. except SessionReaderUnavailable as exc:
  82. return {"ok": False, "error": exc.code}
  83. try:
  84. platform = str(read("HERMES_SESSION_PLATFORM") or "").strip().lower()
  85. message_id = str(read("HERMES_SESSION_MESSAGE_ID") or "").strip()
  86. sender_id = str(read("HERMES_SESSION_USER_ID") or "").strip()
  87. raw_type = str(read("HERMES_SESSION_CHAT_TYPE") or "").strip()
  88. session_chat_id = str(read("HERMES_SESSION_CHAT_ID") or "").strip() or None
  89. except SessionReaderUnavailable as exc:
  90. return {"ok": False, "error": exc.code}
  91. if not platform:
  92. # 本回合没有绑定平台 = 没有会话上下文(不是「别的平台」)
  93. return {"ok": False, "error": "SESSION_IDENTITY_INCOMPLETE",
  94. "have": {"platform": False}}
  95. if platform not in SUPPORTED_PLATFORMS:
  96. # 含 wecom_callback —— 明确不接,不猜
  97. return {"ok": False, "error": "UNSUPPORTED_PLATFORM", "platform": platform}
  98. registry = read_instance_registry(instance_dir or directory)
  99. if registry is None:
  100. return {"ok": False, "error": "INSTANCE_REGISTRY_MISSING"} # 不猜配置、不读环境变量
  101. trusted_bot = str(registry["botId"]).strip()
  102. if not (message_id and sender_id):
  103. return {"ok": False, "error": "SESSION_IDENTITY_INCOMPLETE",
  104. "have": {"message_id": bool(message_id), "sender_id": bool(sender_id)}}
  105. if isinstance(args, dict):
  106. for key, trusted in (("message_id", message_id), ("sender_id", sender_id), ("bot_id", trusted_bot)):
  107. supplied = str(args.get(key) or "").strip()
  108. if supplied and supplied != trusted:
  109. return {"ok": False, "error": "CALLER_VALUE_CONFLICT", "field": key}
  110. chat_type = normalize_session_chat_type(raw_type)
  111. if chat_type is None:
  112. return {"ok": False, "error": "UNKNOWN_CHAT_TYPE", "chat_type": raw_type}
  113. if chat_type == "group" and not session_chat_id:
  114. return {"ok": False, "error": "MISSING_GROUP_CHAT_ID"}
  115. # 有效会话键:单聊 = session chat_id or sender_id(与 `chat_id = chatid or sender_id` 同一条规则);
  116. # 群聊必须显式有 chat_id。这样「单聊帧无 chatid」不会因为落盘 chatId=null 而被误判。
  117. expected_chat = session_chat_id if chat_type == "group" else (session_chat_id or sender_id)
  118. try:
  119. ctx = read_context(trusted_bot, message_id,
  120. expect={"senderId": sender_id, "chatType": chat_type,
  121. "chatId": expected_chat}, # 有效会话键
  122. directory=directory, ttl_seconds=ttl_seconds, now=now)
  123. except IdentityRejected as exc:
  124. return {"ok": False, "error": exc.code}
  125. if ctx is None:
  126. return {"ok": False, "error": "IDENTITY_CONTEXT_UNAVAILABLE",
  127. "detail": "无本消息的可信上下文(未落盘/已过期/字段与当前调用不一致)"}
  128. return {"ok": True,
  129. "trust": "gateway_contextvar(no-env-fallback)+adapter_instance_registry",
  130. "identity": identity_summary(ctx),
  131. "effectiveChatId": effective_chat_id(ctx["chatType"], ctx["chatId"], ctx["senderId"])}
  132. TOOL_SCHEMA = {
  133. "name": TOOL_NAME,
  134. "description": ("返回**本次消息**已核验的企微 aibot 发送者身份(botId/senderId/chatType/chatId/messageId)。"
  135. "身份取自网关会话上下文与本实例受控登记,模型无法指定,也不需要任何参数。"
  136. "ok=false 时必须停止身份登记,不得改用正文、session 键或旧文件。"),
  137. "parameters": {"type": "object", "properties": {}, "additionalProperties": False},
  138. }
  139. def _handler(args: Optional[Dict[str, Any]] = None, **_kw: Any) -> str:
  140. # Only the registered tool returns text. Registration calls current_identity()
  141. # directly and still requires its structured, verified identity result.
  142. if args is not None and (not isinstance(args, dict) or args):
  143. return json.dumps({"ok": False, "error": "NO_ARGUMENTS_ALLOWED"})
  144. return json.dumps(current_identity(), ensure_ascii=False)
  145. def register(ctx) -> None:
  146. """插件入口:`ctx.register_tool(name, toolset, schema, handler, …)`。"""
  147. ctx.register_tool(TOOL_NAME, TOOLSET, TOOL_SCHEMA, _handler,
  148. description=TOOL_SCHEMA["description"], emoji="🪪")
  149. logger.info("wecom-identity bridge loaded: tool=%s version=v3", TOOL_NAME)