Files
hermes-agent/plugins/platforms/wecom/adapter.py
teknium1 6062ad5aee fix(platforms): QR fallback tip names Hermes' own uv when it is not on PATH
The installer drops uv in $HERMES_HOME/bin without exporting it, so the
bare `uv pip install --python ...` tip failed with `uv: command not found`
for installer-only users. The four copies of the tip (QQ Bot, Feishu, WeCom,
managed Telegram bot) now render through one helper, managed_uv.pip_install_hint,
which names the managed binary when present and falls back to `uv` otherwise.
2026-09-15 18:55:47 -07:00

897 lines
49 KiB
Python

"""WeCom (Enterprise WeChat) AI Bot adapter over the ``openws`` WebSocket gateway.
Streaming lives in ``streaming.py``, media in ``media.py``, per-chat send queue in ``send_queue.py``.
Config (``platforms.wecom.extra``): ``bot_id``/``secret`` (or WECOM_BOT_ID / WECOM_SECRET), ``websocket_url``,
``dm_policy``/``group_policy`` (open|allowlist|disabled|pairing), ``allow_from``, ``group_allow_from``,
``groups: {<group_id>: {allow_from: [...]}}``."""
from __future__ import annotations
import asyncio
import json
import logging
import re
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
try:
import aiohttp
except ImportError:
aiohttp = None # type: ignore[assignment]
try:
import httpx
except ImportError:
httpx = None # type: ignore[assignment]
AIOHTTP_AVAILABLE = aiohttp is not None
HTTPX_AVAILABLE = httpx is not None
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator, bounded_put
from gateway.platforms.access_policy_mixin import OwnAccessPolicyMixin
from gateway.platforms.base import gateway_trust_env, BasePlatformAdapter, SendResult
from gateway.platforms.event import MessageEvent, MessageType
from utils import env_float
from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error
from plugins.platforms.wecom.send_queue import ChatSendQueueMixin
from plugins.platforms.wecom.media import WeComMediaMixin, APP_CMD_SEND
from plugins.platforms.wecom.streaming import (
WeComStreamMixin, ReplyQueue, StreamTurn, APP_CMD_RESPONSE,
STREAM_NOT_SUBSCRIBED_ERRCODE, MAX_STREAM_CONTENT_LENGTH,
STREAM_SAFE_DURATION_SECONDS, STREAM_KEEPALIVE_INTERVAL_SECONDS, STREAM_KEEPALIVE_ENABLED_DEFAULT,
)
logger = logging.getLogger(__name__)
DEFAULT_WS_URL = "wss://openws.work.weixin.qq.com"
APP_CMD_SUBSCRIBE = "aibot_subscribe"
APP_CMD_CALLBACK = "aibot_msg_callback"
APP_CMD_LEGACY_CALLBACK = "aibot_callback"
APP_CMD_EVENT_CALLBACK = "aibot_event_callback"
APP_CMD_PING = "ping"
CALLBACK_COMMANDS = {APP_CMD_CALLBACK, APP_CMD_LEGACY_CALLBACK}
NON_RESPONSE_COMMANDS = CALLBACK_COMMANDS | {APP_CMD_EVENT_CALLBACK}
MAX_MESSAGE_LENGTH = 4000
CONNECT_TIMEOUT_SECONDS = 20.0
REQUEST_TIMEOUT_SECONDS = 15.0
HEARTBEAT_INTERVAL_SECONDS = 30.0
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
DEDUP_MAX_SIZE = 1000
def check_wecom_requirements() -> bool:
return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE
def _coerce_list(value: Any) -> List[str]:
"""Coerce config values (None | "a, b" | iterable | scalar) into a trimmed, non-empty string list."""
if isinstance(value, str):
value = value.split(",")
elif not isinstance(value, (list, tuple, set)):
value = [] if value is None else [value]
return [item for item in (str(item).strip() for item in value) if item]
def _normalize_entry(raw: str) -> str:
"""Normalize allowlist entries such as ``wecom:user:foo``."""
value = re.sub(r"^wecom:", "", str(raw).strip(), flags=re.IGNORECASE)
return re.sub(r"^(user|group):", "", value, flags=re.IGNORECASE).strip()
def _entry_matches(entries: List[str], target: str) -> bool:
"""Case-insensitive allowlist match with ``*`` support."""
normalized_target = str(target).strip().lower()
return any(_normalize_entry(e).lower() in ("*", normalized_target) for e in entries)
def _dict_or_empty(container: Dict[str, Any], key: str) -> Dict[str, Any]:
return container.get(key) if isinstance(container.get(key), dict) else {}
def _content_of(container: Dict[str, Any], key: str) -> str:
return str(_dict_or_empty(container, key).get("content") or "").strip()
class WeComAdapter(WeComStreamMixin, WeComMediaMixin, ChatSendQueueMixin, OwnAccessPolicyMixin, BasePlatformAdapter):
"""WeCom AI Bot adapter backed by a persistent WebSocket connection."""
ALLOW_ALL_ENV_PREFIX = "WECOM"
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
SUPPORTS_MESSAGE_EDITING = False
SUPPORTS_NATIVE_STREAMING = True # msgtype "stream" via aibot_respond_msg, not edit-based
MAX_STREAM_CONTENT_LENGTH = MAX_STREAM_CONTENT_LENGTH
_SPLIT_THRESHOLD = 3900 # chunks near the 4000-char client split are almost certainly continued
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.WECOM)
extra = config.extra or {}
def _extra_float(key: str, default: float) -> float:
try:
return float(extra.get(key, default))
except (TypeError, ValueError):
return default
def _setting(*keys: str, env: str = "", default: str = "") -> str:
return str(next((extra[k] for k in keys if extra.get(k)), None) or (_get_scoped_secret(env, default) if env else "")).strip()
self._bot_id = _setting("bot_id", env="WECOM_BOT_ID")
self._secret = _setting("secret", env="WECOM_SECRET")
self._ws_url = _setting("websocket_url", "websocketUrl", env="WECOM_WEBSOCKET_URL", default=DEFAULT_WS_URL) or DEFAULT_WS_URL
self._dm_policy = _setting("dm_policy", env="WECOM_DM_POLICY", default="pairing").lower()
# WECOM_ALLOWED_USERS fallback: env-only allowlist setups otherwise drop every DM at intake.
self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom") or _get_scoped_secret("WECOM_ALLOWED_USERS", ""))
self._group_policy = _setting("group_policy", env="WECOM_GROUP_POLICY", default="pairing").lower()
self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom"))
self._groups = extra.get("groups") if isinstance(extra.get("groups"), dict) else {}
self._session = self._ws = self._http_client = self._listen_task = self._heartbeat_task = None
self._pending_responses: Dict[str, asyncio.Future] = {}
self._reply_queues: Dict[str, ReplyQueue] = {}
self._dedup, self._reply_req_ids = MessageDeduplicator(max_size=DEDUP_MAX_SIZE), {}
# Text batching (clients split long messages ~4000 chars); attachment-only frames are held
# for the merge window so the trailing text callback joins the same event (official: 800ms).
self._text_batch_delay_seconds = env_float("HERMES_WECOM_TEXT_BATCH_DELAY_SECONDS", 0.6)
self._text_batch_split_delay_seconds = env_float("HERMES_WECOM_TEXT_BATCH_SPLIT_DELAY_SECONDS", 2.0)
self._attachment_text_merge_delay_seconds = _extra_float("attachment_text_merge_delay_seconds", 0.8)
# Stream keep-alive config (see streaming.py STREAM_* constants).
self._stream_safe_duration_seconds = _extra_float("stream_safe_duration_seconds", STREAM_SAFE_DURATION_SECONDS)
self._stream_keepalive_enabled = bool(extra.get("stream_keepalive_enabled", STREAM_KEEPALIVE_ENABLED_DEFAULT))
self._stream_keepalive_interval_seconds = _extra_float("stream_keepalive_interval_seconds", STREAM_KEEPALIVE_INTERVAL_SECONDS)
self._device_id = uuid.uuid4().hex
self._last_chat_req_ids: Dict[str, str] = {}
# Turns keyed f"{chat_id}:{req_id|turn_id}"; expired chats clear on the next inbound req_id.
self._stream_turns: Dict[str, StreamTurn] = {}
self._stream_expired_chats, self._group_chat_ids = set(), set() # groups can't receive proactive APP_CMD_SEND
# Per-chat FIFO send queues (normal + control lanes) + token buckets — see send_queue.py.
self._chat_queues, self._chat_workers, self._control_queues, self._control_workers, self._chat_token_usage = {}, {}, {}, {}, {}
def _startup_failure(self, code: str, message: str, log_msg: str, *args: Any) -> bool:
self._set_fatal_error(code, message, retryable=True)
logger.warning(log_msg, self.name, message, *args)
return False
async def connect(self, *, is_reconnect: bool = False) -> bool:
for available, dep in ((AIOHTTP_AVAILABLE, "aiohttp"), (HTTPX_AVAILABLE, "httpx")):
if not available:
return self._startup_failure("wecom_missing_dependency", f"WeCom startup failed: {dep} not installed", "[%s] %s. Run: pip install %s", dep)
if not self._bot_id or not self._secret:
return self._startup_failure("wecom_missing_credentials", "WeCom startup failed: WECOM_BOT_ID and WECOM_SECRET are required", "[%s] %s")
try:
# Tighter keepalive so idle CLOSE_WAIT drains promptly.
# See #18451.
from gateway.platforms._http_client_limits import platform_httpx_limits
from gateway.platforms.base import _ssrf_redirect_guard
from tools.url_safety import create_ssrf_safe_async_client
self._http_client = create_ssrf_safe_async_client(timeout=30.0, follow_redirects=True, event_hooks={"response": [_ssrf_redirect_guard]}, limits=platform_httpx_limits())
await self._open_connection()
self._mark_connected()
self._listen_task, self._heartbeat_task = asyncio.create_task(self._listen_loop()), asyncio.create_task(self._heartbeat_loop())
logger.info("[%s] Connected to %s", self.name, self._ws_url)
self._wire_plugin_handlers(None) # ctx.register_platform_handler hooks
return True
except Exception as exc:
self._set_fatal_error("wecom_connect_error", f"WeCom startup failed: {exc}", retryable=True)
logger.error("[%s] Failed to connect: %s", self.name, exc, exc_info=True)
await self._teardown()
return False
async def disconnect(self) -> None:
self._running = False
self._mark_disconnected()
for task in list(self._chat_workers.values()) + list(self._control_workers.values()):
task.cancel()
for registry in (self._chat_workers, self._control_workers, self._chat_queues, self._control_queues):
registry.clear()
for attr in ("_listen_task", "_heartbeat_task"):
task = getattr(self, attr)
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
setattr(self, attr, None)
self._fail_all(RuntimeError("WeCom adapter disconnected"))
await self._teardown()
self._dedup.clear()
logger.info("[%s] Disconnected", self.name)
def _fail_all(self, exc: Exception) -> None:
self._fail_pending_responses(exc)
self._fail_reply_queues(exc)
async def _cleanup_ws(self) -> None:
"""Close the live websocket, then its session, if any."""
for attr in ("_ws", "_session"):
live = getattr(self, attr)
if live and not live.closed:
await live.close()
setattr(self, attr, None)
async def _teardown(self) -> None:
"""_cleanup_ws, then close the httpx client."""
await self._cleanup_ws()
if self._http_client:
await self._http_client.aclose()
self._http_client = None
async def _open_connection(self) -> None:
await self._cleanup_ws()
# certifi's CA bundle so aiohttp trusts the same roots as urllib/requests (macOS stale OpenSSL path).
import ssl as _ssl
try:
import certifi
cafile = certifi.where()
except ImportError:
cafile = None
_ssl_ctx = _ssl.create_default_context(cafile=cafile)
self._session = aiohttp.ClientSession(trust_env=gateway_trust_env(), connector=aiohttp.TCPConnector(ssl=_ssl_ctx))
self._ws = await self._session.ws_connect(self._ws_url, heartbeat=HEARTBEAT_INTERVAL_SECONDS * 2, timeout=CONNECT_TIMEOUT_SECONDS)
req_id = self._new_req_id("subscribe")
await self._send_json({"cmd": APP_CMD_SUBSCRIBE, "headers": {"req_id": req_id}, "body": {"bot_id": self._bot_id, "secret": self._secret, "device_id": self._device_id}})
auth_payload = await self._wait_for_handshake(req_id)
errcode = auth_payload.get("errcode", 0)
if errcode not in {0, None}:
raise RuntimeError(f"{auth_payload.get('errmsg', 'authentication failed')} (errcode={errcode})")
async def _wait_for_handshake(self, req_id: str) -> Dict[str, Any]:
if not self._ws:
raise RuntimeError("WebSocket not initialized")
loop = asyncio.get_running_loop()
deadline = loop.time() + CONNECT_TIMEOUT_SECONDS
while (remaining := deadline - loop.time()) > 0:
msg = await asyncio.wait_for(self._ws.receive(), timeout=remaining)
if msg.type == aiohttp.WSMsgType.TEXT:
payload = self._parse_json(msg.data)
if not payload or payload.get("cmd") == APP_CMD_PING:
continue
if self._payload_req_id(payload) == req_id:
return payload
logger.debug("[%s] Ignoring pre-auth payload: %s", self.name, payload.get("cmd"))
elif msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR}:
raise RuntimeError("WeCom websocket closed during authentication")
raise TimeoutError("Timed out waiting for WeCom subscribe acknowledgement")
async def _listen_loop(self) -> None:
backoff_idx = 0
while self._running:
try:
await self._read_events()
backoff_idx = 0
except asyncio.CancelledError:
return
except Exception as exc:
if not self._running:
return
logger.warning("[%s] WebSocket error: %s", self.name, exc)
self._fail_all(RuntimeError("WeCom connection interrupted"))
await asyncio.sleep(RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)])
backoff_idx += 1
try:
await self._open_connection()
backoff_idx = 0
self._mark_connected()
logger.info("[%s] Reconnected", self.name)
except Exception as reconnect_exc:
logger.warning("[%s] Reconnect failed: %s", self.name, reconnect_exc)
async def _read_events(self) -> None:
if not self._ws:
raise RuntimeError("WebSocket not connected")
while self._running and self._ws and not self._ws.closed:
msg = await self._ws.receive()
if msg.type in (aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY):
await self._handle_frame(msg.data, msg.type == aiohttp.WSMsgType.BINARY)
elif msg.type in {aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSING}:
raise RuntimeError("WeCom websocket closed")
else:
logger.info("[%s] Inbound frame ignored: WSMsgType=%s", self.name, msg.type)
async def _handle_frame(self, data: Any, is_binary: bool) -> None:
"""Parse one TEXT/BINARY frame and dispatch it; every drop is logged at INFO."""
data_len = len(data) if isinstance(data, (str, bytes, bytearray)) else -1
if is_binary: # WeCom should send TEXT; log a preview so an unhandled transport isn't silently dropped
decoded = data.decode("utf-8", errors="replace") if isinstance(data, (bytes, bytearray)) else "<undecodable>"
logger.info("[%s] Inbound BINARY frame received (len=%d) head=%r — attempting JSON parse", self.name, data_len, decoded[:200])
payload = self._parse_json(data)
if payload:
await self._dispatch_payload(payload)
elif is_binary:
logger.info("[%s] BINARY frame not parseable as JSON — dropped", self.name)
else: # _parse_json logged the detail; make the DROP itself visible at INFO
logger.info("[%s] Inbound TEXT frame dropped (unparseable/non-dict) len=%d", self.name, data_len)
async def _heartbeat_loop(self) -> None:
try:
while self._running:
await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
try:
if self._ws and not self._ws.closed:
await self._send_json({"cmd": APP_CMD_PING, "headers": {"req_id": self._new_req_id("ping")}, "body": {}})
except Exception as exc:
logger.debug("[%s] Heartbeat send failed: %s", self.name, exc)
except asyncio.CancelledError:
pass
async def _dispatch_payload(self, payload: Dict[str, Any]) -> None:
req_id = self._payload_req_id(payload)
cmd = str(payload.get("cmd") or "")
body_dict = payload.get("body") if isinstance(payload.get("body"), dict) else None
if self._reply_queues and cmd != APP_CMD_PING:
logger.debug("[%s] _dispatch_payload[ALL]: req_id=%s cmd=%r active_queues=%s", self.name, req_id or "(none)", cmd or "(empty)", list(self._reply_queues.keys()))
if req_id and self._reply_queues.get(req_id):
logger.debug(
"[%s] _dispatch_payload: req_id=%s cmd=%r has_pending_ack=%s errcode=%s in_NON_RESPONSE=%s payload_keys=%s", self.name, req_id, cmd,
self._reply_queues[req_id].pending_ack is not None, body_dict.get("errcode", "N/A") if body_dict is not None else "N/A", cmd in NON_RESPONSE_COMMANDS, list(payload.keys()),
)
# Reply-queue acks (inbound req_id, no/other cmd) MUST win over _pending_responses.
if req_id and cmd not in NON_RESPONSE_COMMANDS:
if self._resolve_reply_ack(req_id, payload):
return
if req_id in self._pending_responses:
future = self._pending_responses[req_id]
if future and not future.done():
future.set_result(payload)
return
if cmd in CALLBACK_COMMANDS:
await self._on_message(payload)
elif cmd == APP_CMD_EVENT_CALLBACK:
# Kicked by server (another connection exists): suppress reconnect like the official SDK.
if str((payload.get("body") or {}).get("event_type") or "") == "disconnected_event":
logger.warning("[%s] Kicked by server (another WS connection established). Suppressing reconnect to avoid mutual kicking. Check for duplicate gateway instances.", self.name)
self._running = False
elif cmd != APP_CMD_PING:
logger.info("[%s] Unrouted websocket payload dropped: cmd=%r req_id=%s body_keys=%s", self.name, cmd or "(empty)", req_id or "(none)", list(body_dict.keys()) if body_dict is not None else None)
def _fail_pending_responses(self, exc: Exception) -> None:
for req_id, future in list(self._pending_responses.items()):
if not future.done():
future.set_exception(exc)
self._pending_responses.pop(req_id, None)
def _require_ws(self) -> None:
if not self._ws or self._ws.closed:
raise RuntimeError("WeCom websocket is not connected")
async def _send_json(self, payload: Dict[str, Any]) -> None:
self._require_ws()
await self._ws.send_json(payload)
async def _request(self, cmd: str, req_id: str, body: Dict[str, Any], timeout: float) -> Dict[str, Any]:
future = self._pending_responses[req_id] = asyncio.get_running_loop().create_future()
try:
await self._send_json({"cmd": cmd, "headers": {"req_id": req_id}, "body": body})
return await asyncio.wait_for(future, timeout=timeout)
finally:
self._pending_responses.pop(req_id, None)
async def _send_request(self, cmd: str, body: Dict[str, Any], timeout: float = REQUEST_TIMEOUT_SECONDS) -> Dict[str, Any]:
self._require_ws()
return await self._request(cmd, self._new_req_id(cmd), body, timeout)
async def _send_reply_request(self, reply_req_id: str, body: Dict[str, Any], cmd: str = APP_CMD_RESPONSE, timeout: float = REQUEST_TIMEOUT_SECONDS) -> Dict[str, Any]:
"""Send a reply frame correlated to an inbound callback req_id."""
self._require_ws()
return await self._request(cmd, self._require_reply_req_id(reply_req_id), body, timeout)
@staticmethod
def _require_reply_req_id(reply_req_id: str) -> str:
normalized = str(reply_req_id or "").strip()
if not normalized:
raise ValueError("reply_req_id is required")
return normalized
@staticmethod
def _new_req_id(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex}"
@staticmethod
def _payload_req_id(payload: Dict[str, Any]) -> str:
headers = payload.get("headers")
return str(headers.get("req_id") or "") if isinstance(headers, dict) else ""
@staticmethod
def _parse_json(raw: Any) -> Optional[Dict[str, Any]]:
raw_len = len(raw) if isinstance(raw, (str, bytes)) else -1
try:
payload = json.loads(raw)
except json.JSONDecodeError:
# WeCom sometimes sends raw control chars inside JSON strings; strict=False accepts them.
try:
text = raw if isinstance(raw, str) else raw.decode("utf-8", errors="replace")
payload = json.JSONDecoder(strict=False).decode(text)
logger.info("WeCom payload required strict=False fallback (len=%d)", raw_len)
except Exception as exc2:
tail = raw[-100:] if isinstance(raw, (str, bytes)) and len(raw) > 100 else raw
logger.warning("Failed to parse WeCom payload (strict=False also failed): error=%s len=%d tail=%r", exc2, raw_len, tail)
return None
except Exception as exc:
logger.warning("Failed to parse WeCom payload: error=%s len=%d", exc, raw_len)
return None
return payload if isinstance(payload, dict) else None
async def _on_message(self, payload: Dict[str, Any]) -> None:
body = payload.get("body")
if not isinstance(body, dict):
return
req_id = self._payload_req_id(payload)
msg_id = str(body.get("msgid") or req_id or uuid.uuid4().hex)
sender = _dict_or_empty(body, "from")
sender_id = str(sender.get("userid") or "").strip()
if self._dedup.is_duplicate(msg_id):
# INFO: a msgid redelivered after a processing exception is dropped for the TTL.
logger.info("[%s] Duplicate message %s ignored (dedup drop) req_id=%s sender=%r chattype=%r", self.name, msg_id, req_id, sender.get("userid") if sender else None, body.get("chattype"))
return
if req_id:
bounded_put(self._reply_req_ids, msg_id, req_id, DEDUP_MAX_SIZE)
chat_id = str(body.get("chatid") or sender_id).strip()
logger.info("[%s] Inbound callback: chattype=%r chatid=%r sender=%r msgtype=%r has_chatid=%s", self.name, body.get("chattype"), body.get("chatid"), sender_id, body.get("msgtype"), bool(body.get("chatid")))
if not chat_id:
logger.info("[%s] Missing chat id, skipping message; body_keys=%s", self.name, list(body.keys()))
return
is_group = str(body.get("chattype") or "").lower() == "group"
if not self._admit_inbound(is_group, chat_id, sender_id):
return
# Post-policy: cache req_id so sends can fall back to passive reply (required in groups).
self._remember_chat_req_id(chat_id, req_id)
text, reply_text = self._extract_text(body)
if is_group and text:
text = re.sub(r"^@\S+\s*", "", text).strip() # "@Bot /approve" -> "/approve"
media_urls, media_types = await self._extract_media(body)
message_type = self._derive_message_type(body, text, media_types)
has_reply_context = bool(reply_text and (text or media_urls))
if reply_text and not has_reply_context: # quote-only message: the quote becomes the text
text = reply_text
if not text and not media_urls:
logger.info("[%s] Empty WeCom message skipped: is_group=%s chat=%s msgtype=%r", self.name, is_group, chat_id, body.get("msgtype"))
return
source = self.build_source(chat_id=chat_id, chat_type="group" if is_group else "dm", user_id=sender_id or None, user_name=sender_id or None,
message_id=msg_id)
event = MessageEvent(
text=text, message_type=message_type, source=source, raw_message=payload, message_id=msg_id, media_urls=media_urls, media_types=media_types,
reply_to_message_id=f"quote:{msg_id}" if has_reply_context else None, reply_to_text=reply_text if has_reply_context else None, timestamp=datetime.now(tz=timezone.utc),
)
# Only plain text is batched, EXCEPT attachment-only messages, which are held so the
# trailing text callback merges instead of "interrupting" a run the attachment spawned.
has_pending_batch = self._text_batch_key(event) in self._pending_text_batches
is_attachment_only = bool(media_urls) and not (text or "").strip()
if (message_type == MessageType.TEXT and (self._text_batch_delay_seconds > 0 or has_pending_batch)) or (is_attachment_only and self._attachment_text_merge_delay_seconds > 0):
self._enqueue_text_event(event)
else:
await self.handle_message(event)
def _admit_inbound(self, is_group: bool, chat_id: str, sender_id: str) -> bool:
"""Apply group_policy / dm_policy at intake; logs and returns False when dropped."""
if not is_group:
allowed = self._is_dm_intake_allowed(sender_id)
if not allowed:
logger.info("[%s] DM sender %s blocked by policy", self.name, sender_id)
return allowed
self._group_chat_ids.add(chat_id)
allowed = self._is_group_allowed(chat_id, sender_id)
if not allowed:
logger.info(
"[%s] Group message DROPPED by policy: chat=%s sender=%s group_policy=%r (set group_policy to 'open' or add to group_allow_from to receive)",
self.name, chat_id, sender_id, self._group_policy,
)
return allowed
def _enqueue_text_event(self, event: MessageEvent) -> None:
"""Buffer + reset the flush timer; real text joining a buffered attachment promotes it to TEXT and inherits the quote context."""
existing = self._pending_text_batches.get(self._text_batch_key(event))
super()._enqueue_text_event(event) # merge text/media + restart the flush timer
if existing is not None and event.text and event.text.strip():
existing.message_type = MessageType.TEXT
if event.reply_to_text and not existing.reply_to_text:
existing.reply_to_text = event.reply_to_text
existing.reply_to_message_id = event.reply_to_message_id
def _text_batch_delay_for(self, pending: Optional[MessageEvent]) -> float:
if pending is not None and pending.media_urls and not (pending.text or "").strip():
return self._attachment_text_merge_delay_seconds # attachment-only: wait for the text frame
return super()._text_batch_delay_for(pending)
@staticmethod
def _extract_text(body: Dict[str, Any]) -> Tuple[str, Optional[str]]:
msgtype = str(body.get("msgtype") or "").lower()
if msgtype == "mixed":
items = _dict_or_empty(body, "mixed").get("msg_item")
text_parts = [_content_of(item, "text") for item in (items if isinstance(items, list) else []) if isinstance(item, dict) and str(item.get("msgtype") or "").lower() == "text"]
else: # voice transcript / appmsg attachment title (filename) follow the text; empties drop below
text_parts = [
_content_of(body, "text"), _content_of(body, "voice") if msgtype == "voice" else "",
str(_dict_or_empty(body, "appmsg").get("title") or "").strip() if msgtype == "appmsg" else "",
]
quote = _dict_or_empty(body, "quote")
quote_type = str(quote.get("msgtype") or "").lower()
reply_text = _content_of(quote, quote_type) or None if quote_type in ("text", "voice") else None
return "\n".join(part for part in text_parts if part).strip(), reply_text
@staticmethod
def _derive_message_type(body: Dict[str, Any], text: str, media_types: List[str]) -> MessageType:
if any(mtype.startswith(("application/", "text/")) for mtype in media_types):
return MessageType.DOCUMENT
if any(mtype.startswith("image/") for mtype in media_types):
return MessageType.TEXT if text else MessageType.PHOTO
if str(body.get("msgtype") or "").lower() == "voice":
return MessageType.VOICE
return MessageType.TEXT
def _entry_matches(self, entries: List[str], target: str) -> bool:
return _entry_matches(entries, target)
def _is_group_allowed(self, chat_id: str, sender_id: str) -> bool:
"""Per-group ``groups.<id>.allow_from`` restricts senders on top of the chat-level policy."""
if not super()._is_group_allowed(chat_id):
return False
group_cfg = self._resolve_group_cfg(chat_id)
sender_allow = _coerce_list(group_cfg.get("allow_from") or group_cfg.get("allowFrom"))
return _entry_matches(sender_allow, sender_id) if sender_allow else True
def _resolve_group_cfg(self, chat_id: str) -> Dict[str, Any]:
"""Exact key, then case-insensitive key, then ``"*"``; only dict values count."""
if not isinstance(self._groups, dict):
return {}
lowered = chat_id.lower()
candidates = (self._groups.get(chat_id), next((v for k, v in self._groups.items() if isinstance(k, str) and k.lower() == lowered and isinstance(v, dict)), None), self._groups.get("*"))
return next((c for c in candidates if isinstance(c, dict)), {})
def _remember_chat_req_id(self, chat_id: str, req_id: str) -> None:
"""Cache the chat's latest inbound req_id; a fresh one also resurrects its stream channel."""
chat_id, req_id = str(chat_id or "").strip(), str(req_id or "").strip()
if chat_id and req_id:
bounded_put(self._last_chat_req_ids, chat_id, req_id, DEDUP_MAX_SIZE)
self._stream_expired_chats.discard(chat_id)
def _reply_req_id_for_message(self, reply_to: Optional[str]) -> Optional[str]:
normalized = str(reply_to or "").strip()
return None if not normalized or normalized.startswith("quote:") else self._reply_req_ids.get(normalized)
def _cached_reply_req_id(self, chat_id: str, reply_to: Optional[str]) -> Optional[str]:
"""Explicit reply_to mapping, else the chat's last inbound req_id."""
return self._reply_req_id_for_message(reply_to) or self._last_chat_req_ids.get(chat_id)
async def _force_reconnect_on_stale_subscription(self, errcode: int) -> None:
"""On 846609 (subscription lost) drop req_ids bound to the dead session. Do NOT close the
WS: a second connection gets kicked and invalidates the first (infinite kick loop)."""
if errcode != STREAM_NOT_SUBSCRIBED_ERRCODE:
return
logger.warning("[%s] Got errcode %d (subscription lost) — clearing stale state", self.name, errcode)
self._last_chat_req_ids.clear()
self._reply_req_ids.clear()
@staticmethod
def _response_error(response: Dict[str, Any]) -> Optional[str]:
errcode = response.get("errcode", 0)
return None if errcode in {0, None} else f"WeCom errcode {errcode}: {response.get('errmsg') or 'unknown error'}"
@classmethod
def _raise_for_wecom_error(cls, response: Dict[str, Any], operation: str) -> None:
error = cls._response_error(response)
if error:
raise RuntimeError(f"{operation} failed: {error}")
def _markdown_body(self, content: str) -> Dict[str, Any]:
return {"msgtype": "markdown", "markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]}}
async def _send_reply_markdown(self, reply_req_id: str, content: str) -> Dict[str, Any]:
response = await self._send_reply_request(reply_req_id, self._markdown_body(content))
self._raise_for_wecom_error(response, "send reply markdown")
return response
async def _send_proactive_markdown(self, chat_id: str, content: str) -> Dict[str, Any]:
return await self._send_request(APP_CMD_SEND, {"chatid": chat_id, **self._markdown_body(content)})
async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
"""Send standalone markdown (never touches active streams); serialized per chat for the 30 msgs/min
limit (846607). ``metadata["is_approval_prompt"]`` uses the control lane."""
if not chat_id:
return SendResult(success=False, error="chat_id is required")
metadata = metadata or {} # pops mutate the caller's dict on purpose (consumed flags)
is_control = metadata.pop("is_approval_prompt", False)
# Approval *confirmations* must not consume the req_id the stream consumer still needs.
force_proactive = bool(metadata.pop("force_proactive_send", False))
return await self._enqueue_chat_send(chat_id, lambda: self._send_inner(chat_id, content, reply_to, force_proactive=force_proactive), is_control=is_control)
async def _send_inner(self, chat_id: str, content: str, reply_to: Optional[str] = None, *, force_proactive: bool = False) -> SendResult:
"""Send under the per-chat queue; force_proactive skips passive reply except in groups."""
try:
reply_req_id = None if force_proactive and chat_id not in self._group_chat_ids else self._cached_reply_req_id(chat_id, reply_to)
if reply_req_id:
try:
response = await self._send_reply_markdown(reply_req_id, content)
except (asyncio.TimeoutError, RuntimeError) as passive_err:
# req_id may be stale after a reconnect — proactive send needs none.
logger.warning("[%s] Passive reply failed (%s), falling back to proactive send", self.name, passive_err)
response = await self._send_proactive_markdown(chat_id, content)
elif chat_id in self._group_chat_ids:
logger.warning("[%s] No cached req_id for group chat %s — cannot send (groups require passive reply via req_id)", self.name, chat_id)
return SendResult(success=False, error="No req_id available for group chat (passive reply required)")
else:
response = await self._send_proactive_markdown(chat_id, content)
except asyncio.TimeoutError:
return SendResult(success=False, error="Timeout sending message to WeCom")
except Exception as exc:
logger.error("[%s] Send failed: %s", self.name, exc)
return self._send_failure(str(exc), str(STREAM_NOT_SUBSCRIBED_ERRCODE) in str(exc))
if error := self._response_error(response):
return self._send_failure(error, response.get("errcode", 0) == STREAM_NOT_SUBSCRIBED_ERRCODE)
return SendResult(success=True, message_id=self._payload_req_id(response) or uuid.uuid4().hex[:12], raw_response=response)
def _send_failure(self, error: str, subscription_lost: bool) -> SendResult:
"""Failed SendResult; on 846609 schedule the stale-req_id purge so later sends recover."""
if subscription_lost:
asyncio.ensure_future(self._force_reconnect_on_stale_subscription(STREAM_NOT_SUBSCRIBED_ERRCODE))
return SendResult(success=False, error=error)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": chat_id, "type": "group" if chat_id and chat_id.lower().startswith("group") else "dm"}
_QR_GENERATE_URL = "https://work.weixin.qq.com/ai/qc/generate"
_QR_QUERY_URL = "https://work.weixin.qq.com/ai/qc/query_result"
_QR_CODE_PAGE = "https://work.weixin.qq.com/ai/qc/gen?source=hermes&scode="
_QR_POLL_INTERVAL, _QR_POLL_TIMEOUT = 3, 300 # seconds (poll every 3s, give up after 5 minutes)
def qr_scan_for_bot_info(*, timeout_seconds: int = _QR_POLL_TIMEOUT) -> Optional[Dict[str, str]]:
"""Fetch a WeCom QR code, render it, poll until scanned or timeout; ``{"bot_id", "secret"}`` or None.
The ``ai/qc/*`` endpoints back the admin console, not the public API, and may change."""
import urllib.request
import urllib.parse
def _get_json(url: str, timeout: int) -> Dict[str, Any]:
req = urllib.request.Request(url, headers={"User-Agent": "HermesAgent/1.0"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def _fail(log_msg: str, detail: Any, shown: Any) -> None:
logger.error(log_msg, detail)
print(f" failed: {shown}")
print(" Connecting to WeCom...", end="", flush=True)
try:
raw = _get_json(f"{_QR_GENERATE_URL}?source=hermes", 15)
except Exception as exc:
return _fail("WeCom QR: failed to fetch QR code: %s", exc, exc)
scode, auth_url = (str((raw.get("data") or {}).get(k) or "").strip() for k in ("scode", "auth_url"))
if not scode or not auth_url:
return _fail("WeCom QR: unexpected response format: %s", raw, "unexpected response format")
print(" done.\n")
page_url = f"{_QR_CODE_PAGE}{urllib.parse.quote(scode)}"
try:
import qrcode as _qrcode
qr = _qrcode.QRCode()
qr.add_data(auth_url)
qr.make(fit=True)
qr.print_ascii(invert=True)
print(f"\n Scan the QR code above, or open this URL directly:\n {page_url}")
except Exception:
print(f" Open this URL in WeCom on your phone:\n\n {page_url}\n")
from hermes_cli.managed_uv import pip_install_hint
print(f" Tip: {pip_install_hint('qrcode')} to display a scannable QR code here next time")
print("\n Fetching configuration results...", end="", flush=True)
deadline = time.monotonic() + timeout_seconds
query_url = f"{_QR_QUERY_URL}?scode={urllib.parse.quote(scode)}"
while time.monotonic() < deadline:
try:
result = _get_json(query_url, 10)
print(".", end="", flush=True) # progress dot on every poll
except Exception as exc:
logger.debug("WeCom QR poll error: %s", exc)
result = {}
result_data = result.get("data") or {}
if str(result_data.get("status") or "").lower() != "success":
time.sleep(_QR_POLL_INTERVAL)
continue
bot_info = result_data.get("bot_info") or {}
bot_id, secret = str(bot_info.get("botid") or bot_info.get("bot_id") or "").strip(), str(bot_info.get("secret") or "").strip()
if bot_id and secret:
print()
return {"bot_id": bot_id, "secret": secret}
logger.warning("WeCom QR: scan reported success but bot_info missing or incomplete: %s", result_data)
print("\n QR scan reported success but no bot credentials were returned.\n This usually means the bot was not actually created on the WeCom side.\n Falling back to manual credential entry.")
return None
print(f"\n QR scan timed out ({timeout_seconds // 60} minutes). Please try again.")
return None
async def _send_via(adapter, chat_id, message, *, live: bool):
try:
result = await adapter.send(chat_id, message)
except Exception as e:
return send_error(f"WeCom live adapter send failed: {e}" if live else f"WeCom send failed: {e}")
if result.success:
return {"success": True, "platform": "wecom", "chat_id": chat_id, "message_id": result.message_id}
return send_error(f"WeCom send failed: {result.error}")
async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False):
"""Reuse the live gateway adapter in-process, else connect ephemerally (WeCom allows ONE
WebSocket per bot — a second connection kicks the first). The live adapter is the ACTIVE
PROFILE's (``_live_adapter``): a bare ``runner.adapters`` hit is the default profile's bot under
multiplex, so a secondary profile's send would leave with the wrong identity."""
try:
from tools.send_message_senders import _live_adapter
_, adapter = _live_adapter(Platform.WECOM)
except Exception:
adapter = None
if adapter is not None:
return await _send_via(adapter, chat_id, message, live=True)
if not check_wecom_requirements():
return send_error("WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET.")
try:
adapter = WeComAdapter(pconfig)
if not await adapter.connect():
return send_error(f"WeCom: failed to connect - {getattr(adapter, 'fatal_error_message', None) or 'unknown error'}")
try:
return await _send_via(adapter, chat_id, message, live=False)
finally:
await adapter.disconnect()
except Exception as e:
return send_error(f"WeCom send failed: {e}")
_MANUAL_SETUP_STEPS = (
"1. Go to WeCom Application → Workspace → Smart Robot -> Create smart robots",
"2. Select API Mode",
"3. Copy the Bot ID and Secret from the bot's credentials info",
"4. The bot connects via WebSocket — no public endpoint needed",
)
# (menu label, env saves, (print level, message)...) per unauthorized-user choice; index 3 = skip
_ACCESS_CHOICES = (
("Enable open access (anyone can message the bot)", (("WECOM_DM_POLICY", "open"), ("GATEWAY_ALLOW_ALL_USERS", "true")),
(("warning", "Open access enabled — anyone can use your bot!"),)),
("Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')", (("WECOM_DM_POLICY", "pairing"),),
(("success", "DM pairing mode — users will receive a code to request access."), ("info", "Approve with: hermes pairing approve <platform> <code>"))),
("Disable direct messages", (("WECOM_DM_POLICY", "disabled"),), (("warning", "Direct messages disabled."),)),
("Skip for now (bot will deny all users until configured)", (), (("info", "Skipped — configure later with 'hermes gateway setup'"),)),
)
def interactive_setup() -> None:
from hermes_cli.config import remove_env_value, save_env_value
from hermes_cli.setup import prompt_choice
from hermes_cli.cli_output import prompt, print_header, print_info, print_success, print_warning
from hermes_cli.setup_platforms import declines_reconfigure
print_header("WeCom (Enterprise WeChat)")
if declines_reconfigure("WeCom", "Reconfigure WeCom?", "WECOM_BOT_ID"):
return
method_idx = prompt_choice("How would you like to set up WeCom?", ["Scan QR code to obtain Bot ID and Secret automatically (recommended)", "Enter existing Bot ID and Secret manually"], 0)
bot_id = secret = None
if method_idx == 0:
try:
credentials = qr_scan_for_bot_info() or {}
except KeyboardInterrupt:
print_warning("WeCom setup cancelled.")
return
except Exception as exc:
print_warning(f"QR scan failed: {exc}")
credentials = {}
if credentials:
bot_id, secret = credentials.get("bot_id", ""), credentials.get("secret", "")
print_success("✔ QR scan successful! Bot ID and Secret obtained.")
if not bot_id or not secret:
print_info("QR scan did not complete. Continuing with manual input.")
bot_id = secret = None
if not bot_id or not secret:
for line in _MANUAL_SETUP_STEPS:
print_info(line)
creds = []
for label, password in (("Bot ID", False), ("Secret", True)):
creds.append(prompt(label, password=password))
if not creds[-1]:
print_warning(f"Skipped — WeCom won't work without a {label}.")
return
bot_id, secret = creds
save_env_value("WECOM_BOT_ID", bot_id)
save_env_value("WECOM_SECRET", secret)
print_info("The gateway DENIES all users by default for security.")
print_info("Enter user IDs to create an allowlist, or leave empty.")
allowed = prompt("Allowed user IDs (comma-separated, or empty)", password=False)
if allowed:
save_env_value("WECOM_ALLOWED_USERS", allowed.replace(" ", ""))
print_success("Saved — only these users can interact with the bot.")
else:
access_idx = prompt_choice("How should unauthorized users be handled?", [label for label, _, _ in _ACCESS_CHOICES], 1)
_, saves, messages = _ACCESS_CHOICES[access_idx if access_idx in (0, 1, 2) else 3]
for key, value in saves:
save_env_value(key, value)
for level, message in messages:
{"warning": print_warning, "success": print_success, "info": print_info}[level](message)
if home := prompt("Home chat ID (optional, for cron/notifications)", password=False).strip():
save_env_value("WECOM_HOME_CHANNEL", home)
print_success(f"Home channel set to {home}")
elif remove_env_value("WECOM_HOME_CHANNEL"):
print_info("Home channel cleared.")
print_success("💬 WeCom configured!")
def _is_connected(config) -> bool:
return bool((getattr(config, "extra", {}) or {}).get("bot_id"))
def _callback_is_connected(config) -> bool:
"""Callback mode: corp_id or a multi-app `apps` block."""
extra = getattr(config, "extra", {}) or {}
return bool(extra.get("corp_id") or extra.get("apps"))
def _build_callback_adapter(config):
from plugins.platforms.wecom.callback_adapter import WecomCallbackAdapter
return WecomCallbackAdapter(config)
def register(ctx) -> None:
common = dict(install_hint="Run `hermes setup` to install WeCom support.", emoji="💼", allow_update_command=True)
ctx.register_platform(
name="wecom", label="WeCom (Enterprise WeChat)", adapter_factory=WeComAdapter, check_fn=check_wecom_requirements,
is_connected=_is_connected, validate_config=_is_connected, required_env=["WECOM_BOT_ID", "WECOM_SECRET"],
setup_fn=interactive_setup, allowed_users_env="WECOM_ALLOWED_USERS", allow_all_env="WECOM_ALLOW_ALL_USERS",
cron_deliver_env_var="WECOM_HOME_CHANNEL", standalone_sender_fn=_standalone_send, max_message_length=4000, **common,
)
from plugins.platforms.wecom.callback_adapter import check_wecom_callback_requirements, ensure_wecom_callback_requirements
ctx.register_platform(
name="wecom_callback", label="WeCom Callback (self-built apps)", adapter_factory=_build_callback_adapter,
check_fn=check_wecom_callback_requirements, ensure_deps_fn=ensure_wecom_callback_requirements,
is_connected=_callback_is_connected, validate_config=_callback_is_connected,
required_env=["WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"],
allowed_users_env="WECOM_CALLBACK_ALLOWED_USERS", allow_all_env="WECOM_CALLBACK_ALLOW_ALL_USERS", **common,
)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from pathlib import Path # noqa: F401,E402
import base64 # noqa: F401,E402
from dataclasses import dataclass # noqa: F401,E402
from collections import deque # noqa: F401,E402
import hashlib # noqa: F401,E402
import mimetypes # noqa: F401,E402
import os # noqa: F401,E402
from urllib.parse import unquote # noqa: F401,E402
from urllib.parse import urlparse # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'ABSOLUTE_MAX_BYTES': ('plugins.platforms.wecom.media', 'ABSOLUTE_MAX_BYTES'),
'APP_CMD_UPLOAD_MEDIA_CHUNK': ('plugins.platforms.wecom.media', 'APP_CMD_UPLOAD_MEDIA_CHUNK'),
'APP_CMD_UPLOAD_MEDIA_FINISH': ('plugins.platforms.wecom.media', 'APP_CMD_UPLOAD_MEDIA_FINISH'),
'APP_CMD_UPLOAD_MEDIA_INIT': ('plugins.platforms.wecom.media', 'APP_CMD_UPLOAD_MEDIA_INIT'),
'FILE_MAX_BYTES': ('plugins.platforms.wecom.media', 'FILE_MAX_BYTES'),
'IMAGE_MAX_BYTES': ('plugins.platforms.wecom.media', 'IMAGE_MAX_BYTES'),
'MAX_INTERMEDIATE_FRAMES': ('plugins.platforms.wecom.streaming', 'MAX_INTERMEDIATE_FRAMES'),
'MAX_UPLOAD_CHUNKS': ('plugins.platforms.wecom.media', 'MAX_UPLOAD_CHUNKS'),
'ReplyFrame': ('plugins.platforms.wecom.streaming', 'ReplyFrame'),
'STREAM_EXPIRED_ERRCODE': ('plugins.platforms.wecom.streaming', 'STREAM_EXPIRED_ERRCODE'),
'STREAM_REQUEST_EXPIRED_ERRCODE': ('plugins.platforms.wecom.streaming', 'STREAM_REQUEST_EXPIRED_ERRCODE'),
'STREAM_VERSION_CONFLICT_ERRCODE': ('plugins.platforms.wecom.streaming', 'STREAM_VERSION_CONFLICT_ERRCODE'),
'UPLOAD_CHUNK_SIZE': ('plugins.platforms.wecom.media', 'UPLOAD_CHUNK_SIZE'),
'VIDEO_MAX_BYTES': ('plugins.platforms.wecom.media', 'VIDEO_MAX_BYTES'),
'VOICE_MAX_BYTES': ('plugins.platforms.wecom.media', 'VOICE_MAX_BYTES'),
'VOICE_SUPPORTED_MIMES': ('plugins.platforms.wecom.media', 'VOICE_SUPPORTED_MIMES'),
'WeComStreamExpiredError': ('plugins.platforms.wecom.streaming', 'WeComStreamExpiredError'),
'cache_document_from_bytes_async': ('gateway.platforms.base', 'cache_document_from_bytes_async'),
'cache_image_from_bytes_async': ('gateway.platforms.base', 'cache_image_from_bytes_async'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----