1258 lines
58 KiB
Python
1258 lines
58 KiB
Python
"""
|
|
WeCom (Enterprise WeChat) platform adapter.
|
|
|
|
Uses the WeCom AI Bot WebSocket gateway: authenticate via ``aibot_subscribe``,
|
|
receive ``aibot_msg_callback`` events, send markdown via ``aibot_send_msg`` /
|
|
``aibot_respond_msg``, upload media via ``aibot_upload_media_*``. Native
|
|
streaming lives in ``streaming.py``, media in ``media.py``, the per-chat
|
|
send queue in ``send_queue.py``.
|
|
|
|
Configuration in config.yaml:
|
|
platforms:
|
|
wecom:
|
|
enabled: true
|
|
extra:
|
|
bot_id: "your-bot-id" # or WECOM_BOT_ID env var
|
|
secret: "your-secret" # or WECOM_SECRET env var
|
|
websocket_url: "wss://openws.work.weixin.qq.com"
|
|
dm_policy: "pairing" # open | allowlist | disabled | pairing
|
|
allow_from: ["user_id_1"]
|
|
group_policy: "pairing" # open | allowlist | disabled | pairing
|
|
group_allow_from: ["group_id_1"]
|
|
groups:
|
|
group_id_1:
|
|
allow_from: ["user_id_1"]
|
|
"""
|
|
|
|
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
|
|
AIOHTTP_AVAILABLE = True
|
|
except ImportError:
|
|
AIOHTTP_AVAILABLE = False
|
|
aiohttp = None # type: ignore[assignment]
|
|
|
|
try:
|
|
import httpx
|
|
HTTPX_AVAILABLE = True
|
|
except ImportError:
|
|
HTTPX_AVAILABLE = False
|
|
httpx = None # type: ignore[assignment]
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.helpers import MessageDeduplicator
|
|
from gateway.platforms.base import gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, SendResult
|
|
from utils import env_float
|
|
|
|
from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret
|
|
from plugins.platforms.wecom.send_queue import ChatSendQueueMixin
|
|
from plugins.platforms.wecom.media import WeComMediaMixin, APP_CMD_SEND
|
|
from plugins.platforms.wecom.streaming import ( # noqa: F401 — re-exported for tests/stream_consumer
|
|
WeComStreamMixin, WeComStreamExpiredError, ReplyQueue, StreamTurn, APP_CMD_RESPONSE,
|
|
STREAM_EXPIRED_ERRCODE, STREAM_NOT_SUBSCRIBED_ERRCODE, MAX_STREAM_CONTENT_LENGTH, MAX_INTERMEDIATE_FRAMES,
|
|
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:
|
|
"""Check if WeCom runtime dependencies are available."""
|
|
return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE
|
|
|
|
|
|
def _coerce_list(value: Any) -> List[str]:
|
|
"""Coerce config values into a trimmed string list."""
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, str):
|
|
return [item.strip() for item in value.split(",") if item.strip()]
|
|
if isinstance(value, (list, tuple, set)):
|
|
return [str(item).strip() for item in value if str(item).strip()]
|
|
return [str(value).strip()] if str(value).strip() else []
|
|
|
|
|
|
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]:
|
|
value = container.get(key)
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _content_of(container: Dict[str, Any], key: str) -> str:
|
|
return str(_dict_or_empty(container, key).get("content") or "").strip()
|
|
|
|
|
|
def _bounded_put(store: Dict[str, str], key: str, value: str) -> bool:
|
|
"""Insert into an insertion-ordered dict bounded at DEDUP_MAX_SIZE; False if key/value empty."""
|
|
key = str(key or "").strip()
|
|
value = str(value or "").strip()
|
|
if not key or not value:
|
|
return False
|
|
store[key] = value
|
|
while len(store) > DEDUP_MAX_SIZE:
|
|
store.pop(next(iter(store)))
|
|
return True
|
|
|
|
|
|
class WeComAdapter(WeComStreamMixin, WeComMediaMixin, ChatSendQueueMixin, BasePlatformAdapter):
|
|
"""WeCom AI Bot adapter backed by a persistent WebSocket connection."""
|
|
|
|
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
|
|
SUPPORTS_MESSAGE_EDITING = False
|
|
# msgtype "stream" via aibot_respond_msg bypasses the edit-based streaming path.
|
|
SUPPORTS_NATIVE_STREAMING = True
|
|
MAX_STREAM_CONTENT_LENGTH = MAX_STREAM_CONTENT_LENGTH
|
|
# Chunks near the 4000-char WeCom client split are almost certainly continued.
|
|
_SPLIT_THRESHOLD = 3900
|
|
|
|
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
|
|
|
|
self._bot_id = str(extra.get("bot_id") or _get_scoped_secret("WECOM_BOT_ID", "")).strip()
|
|
self._secret = str(extra.get("secret") or _get_scoped_secret("WECOM_SECRET", "")).strip()
|
|
self._ws_url = str(
|
|
extra.get("websocket_url") or extra.get("websocketUrl") or _get_scoped_secret("WECOM_WEBSOCKET_URL", DEFAULT_WS_URL)
|
|
).strip() or DEFAULT_WS_URL
|
|
|
|
self._dm_policy = str(extra.get("dm_policy") or _get_scoped_secret("WECOM_DM_POLICY", "pairing")).strip().lower()
|
|
# Env-only setups (dm_policy=allowlist via env) need the WECOM_ALLOWED_USERS
|
|
# fallback or every authorized DM is dropped 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 = str(extra.get("group_policy") or _get_scoped_secret("WECOM_GROUP_POLICY", "pairing")).strip().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: Optional["aiohttp.ClientSession"] = None
|
|
self._ws: Optional["aiohttp.ClientWebSocketResponse"] = None
|
|
self._http_client: Optional["httpx.AsyncClient"] = None
|
|
self._listen_task: Optional[asyncio.Task] = None
|
|
self._heartbeat_task: Optional[asyncio.Task] = None
|
|
self._pending_responses: Dict[str, asyncio.Future] = {}
|
|
self._reply_queues: Dict[str, ReplyQueue] = {}
|
|
self._dedup = MessageDeduplicator(max_size=DEDUP_MAX_SIZE)
|
|
self._reply_req_ids: Dict[str, str] = {}
|
|
|
|
# Text batching: WeCom clients split long messages around 4000 chars.
|
|
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)
|
|
# WeCom sends "image + text" as two callbacks a few hundred ms apart; hold an
|
|
# attachment-only message this long so the trailing text merges into ONE
|
|
# event (official plugin: ATTACHMENT_TEXT_MERGE_WINDOW_MS = 800).
|
|
self._attachment_text_merge_delay_seconds = _extra_float("attachment_text_merge_delay_seconds", 0.8)
|
|
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
|
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
|
|
|
# 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] = {}
|
|
# Per-turn stream state keyed f"{chat_id}:{req_id|turn_id}" so concurrent
|
|
# messages (e.g. approval during streaming) never share a stream.
|
|
self._stream_turns: Dict[str, StreamTurn] = {}
|
|
# Chats whose stream session was retired (846608 / 846609 / no req_id);
|
|
# cleared when a fresh inbound callback gives the chat a new req_id.
|
|
self._stream_expired_chats: set[str] = set()
|
|
# Group chats can't receive proactive APP_CMD_SEND (populated in _on_message).
|
|
self._group_chat_ids: set[str] = set()
|
|
|
|
# Per-chat FIFO send queues (normal + control lanes) with token-bucket
|
|
# rate limiting — see send_queue.py.
|
|
self._chat_queues: Dict[str, asyncio.Queue] = {}
|
|
self._chat_workers: Dict[str, asyncio.Task] = {}
|
|
self._control_queues: Dict[str, asyncio.Queue] = {}
|
|
self._control_workers: Dict[str, asyncio.Task] = {}
|
|
self._chat_token_usage: Dict[str, Dict[str, float]] = {}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Connection lifecycle
|
|
# ------------------------------------------------------------------
|
|
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
"""Connect to the WeCom AI Bot gateway."""
|
|
for available, dep in ((AIOHTTP_AVAILABLE, "aiohttp"), (HTTPX_AVAILABLE, "httpx")):
|
|
if not available:
|
|
message = f"WeCom startup failed: {dep} not installed"
|
|
self._set_fatal_error("wecom_missing_dependency", message, retryable=True)
|
|
logger.warning("[%s] %s. Run: pip install %s", self.name, message, dep)
|
|
return False
|
|
if not self._bot_id or not self._secret:
|
|
message = "WeCom startup failed: WECOM_BOT_ID and WECOM_SECRET are required"
|
|
self._set_fatal_error("wecom_missing_credentials", message, retryable=True)
|
|
logger.warning("[%s] %s", self.name, message)
|
|
return False
|
|
|
|
try:
|
|
# Tighter keepalive so idle CLOSE_WAIT drains promptly.
|
|
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 = asyncio.create_task(self._listen_loop())
|
|
self._heartbeat_task = 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:
|
|
message = f"WeCom startup failed: {exc}"
|
|
self._set_fatal_error("wecom_connect_error", message, retryable=True)
|
|
logger.error("[%s] Failed to connect: %s", self.name, exc, exc_info=True)
|
|
await self._cleanup_ws()
|
|
await self._close_http_client()
|
|
return False
|
|
|
|
async def _close_http_client(self) -> None:
|
|
if self._http_client:
|
|
await self._http_client.aclose()
|
|
self._http_client = None
|
|
|
|
async def disconnect(self) -> None:
|
|
"""Disconnect from WeCom."""
|
|
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_pending_responses(RuntimeError("WeCom adapter disconnected"))
|
|
self._fail_reply_queues(RuntimeError("WeCom adapter disconnected"))
|
|
await self._cleanup_ws()
|
|
await self._close_http_client()
|
|
self._dedup.clear()
|
|
logger.info("[%s] Disconnected", self.name)
|
|
|
|
async def _cleanup_ws(self) -> None:
|
|
"""Close the live websocket/session, if any."""
|
|
if self._ws and not self._ws.closed:
|
|
await self._ws.close()
|
|
self._ws = None
|
|
if self._session and not self._session.closed:
|
|
await self._session.close()
|
|
self._session = None
|
|
|
|
async def _open_connection(self) -> None:
|
|
"""Open and authenticate a websocket connection."""
|
|
await self._cleanup_ws()
|
|
# certifi's CA bundle so aiohttp trusts the same roots as urllib/requests
|
|
# (avoids SSL_CERTIFICATE_VERIFY_FAILED on macOS with a stale OpenSSL path).
|
|
import ssl as _ssl
|
|
try:
|
|
import certifi
|
|
_ssl_ctx = _ssl.create_default_context(cafile=certifi.where())
|
|
except ImportError:
|
|
_ssl_ctx = _ssl.create_default_context()
|
|
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}:
|
|
errmsg = auth_payload.get("errmsg", "authentication failed")
|
|
raise RuntimeError(f"{errmsg} (errcode={errcode})")
|
|
|
|
async def _wait_for_handshake(self, req_id: str) -> Dict[str, Any]:
|
|
"""Wait for the subscribe acknowledgement."""
|
|
if not self._ws:
|
|
raise RuntimeError("WebSocket not initialized")
|
|
deadline = asyncio.get_running_loop().time() + CONNECT_TIMEOUT_SECONDS
|
|
while True:
|
|
remaining = deadline - asyncio.get_running_loop().time()
|
|
if remaining <= 0:
|
|
raise TimeoutError("Timed out waiting for WeCom subscribe acknowledgement")
|
|
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")
|
|
|
|
async def _listen_loop(self) -> None:
|
|
"""Read websocket events forever, reconnecting on errors."""
|
|
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_pending_responses(RuntimeError("WeCom connection interrupted"))
|
|
self._fail_reply_queues(RuntimeError("WeCom connection interrupted"))
|
|
delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)]
|
|
backoff_idx += 1
|
|
await asyncio.sleep(delay)
|
|
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:
|
|
"""Read websocket frames until the connection closes."""
|
|
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):
|
|
is_binary = msg.type == aiohttp.WSMsgType.BINARY
|
|
data_len = len(msg.data) if isinstance(msg.data, (str, bytes, bytearray)) else -1
|
|
if is_binary:
|
|
# WeCom is expected to send TEXT; log a decoded preview so an
|
|
# unhandled transport for group messages isn't silently discarded.
|
|
try:
|
|
decoded = msg.data.decode("utf-8", errors="replace")
|
|
except Exception:
|
|
decoded = "<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(msg.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 so a missing inbound message can be correlated to a bad frame.
|
|
logger.info("[%s] Inbound TEXT frame dropped (unparseable/non-dict) len=%d", self.name, data_len)
|
|
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 _heartbeat_loop(self) -> None:
|
|
"""Send lightweight application-level pings."""
|
|
try:
|
|
while self._running:
|
|
await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
|
|
if not self._ws or self._ws.closed:
|
|
continue
|
|
try:
|
|
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:
|
|
"""Route inbound websocket payloads."""
|
|
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
|
|
|
|
# Diagnostics for ack-timeout analysis: do WeCom acks arrive at all, and
|
|
# under which cmd?
|
|
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()),
|
|
)
|
|
|
|
# aibot_respond_msg acks arrive with the inbound req_id and no/other cmd.
|
|
# Reply-queue acks MUST be checked before _pending_responses so the
|
|
# _send_reply_request path can't steal them.
|
|
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.get(req_id)
|
|
if future and not future.done():
|
|
future.set_result(payload)
|
|
return
|
|
|
|
if cmd in CALLBACK_COMMANDS:
|
|
await self._on_message(payload)
|
|
return
|
|
if cmd == APP_CMD_PING:
|
|
return
|
|
if cmd == APP_CMD_EVENT_CALLBACK:
|
|
# "Kicked by server": another connection was established elsewhere.
|
|
# Mirror the official SDK — suppress reconnect to avoid mutual kicking.
|
|
body = payload.get("body") or {}
|
|
if str(body.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
|
|
return
|
|
|
|
# Unrouted: if WeCom delivered group messages under an unknown cmd they
|
|
# would land here, so log cmd + body keys at INFO.
|
|
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:
|
|
"""Fail all outstanding request futures."""
|
|
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)
|
|
|
|
async def _send_json(self, payload: Dict[str, Any]) -> None:
|
|
"""Send a raw JSON frame over the active websocket."""
|
|
if not self._ws or self._ws.closed:
|
|
raise RuntimeError("WeCom websocket is not connected")
|
|
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 = asyncio.get_running_loop().create_future()
|
|
self._pending_responses[req_id] = 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]:
|
|
"""Send a JSON request and await the correlated response."""
|
|
if not self._ws or self._ws.closed:
|
|
raise RuntimeError("WeCom websocket is not connected")
|
|
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."""
|
|
if not self._ws or self._ws.closed:
|
|
raise RuntimeError("WeCom websocket is not connected")
|
|
normalized_req_id = str(reply_req_id or "").strip()
|
|
if not normalized_req_id:
|
|
raise ValueError("reply_req_id is required")
|
|
return await self._request(cmd, normalized_req_id, body, timeout)
|
|
|
|
@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 unescaped control chars (raw newlines) 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:
|
|
logger.warning(
|
|
"Failed to parse WeCom payload (strict=False also failed): "
|
|
"error=%s len=%d tail=%r",
|
|
exc2, raw_len,
|
|
raw[-100:] if isinstance(raw, (str, bytes)) and len(raw) > 100 else raw,
|
|
)
|
|
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
|
|
|
|
# ------------------------------------------------------------------
|
|
# Inbound message parsing
|
|
# ------------------------------------------------------------------
|
|
|
|
async def _on_message(self, payload: Dict[str, Any]) -> None:
|
|
"""Process an inbound WeCom message callback event."""
|
|
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 = body.get("from") if isinstance(body.get("from"), dict) else {}
|
|
sender_id = str(sender.get("userid") or "").strip()
|
|
if self._dedup.is_duplicate(msg_id):
|
|
# INFO (not debug): is_duplicate marks at check time, so a msgid
|
|
# redelivered after a processing exception is dropped for the TTL —
|
|
# a top suspect for intermittent group non-replies.
|
|
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
|
|
_bounded_put(self._reply_req_ids, msg_id, req_id)
|
|
|
|
chat_id = str(body.get("chatid") or sender_id).strip()
|
|
# Shape of every inbound callback at INFO: group frames may arrive with
|
|
# a chattype other than the literal "group".
|
|
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 is_group:
|
|
self._group_chat_ids.add(chat_id)
|
|
if not self._is_group_allowed(chat_id, sender_id):
|
|
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
|
|
elif not self._is_dm_intake_allowed(sender_id):
|
|
logger.info("[%s] DM sender %s blocked by policy", self.name, sender_id)
|
|
return
|
|
|
|
# After policy checks: cache the req_id so proactive sends can fall back to
|
|
# APP_CMD_RESPONSE (required for groups, where APP_CMD_SEND is blocked).
|
|
self._remember_chat_req_id(chat_id, req_id)
|
|
|
|
text, reply_text = self._extract_text(body)
|
|
if is_group and text:
|
|
# Strip leading @mention so "@Bot /approve" is recognized as "/approve".
|
|
text = re.sub(r"^@\S+\s*", "", text).strip()
|
|
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 not text and reply_text and not media_urls:
|
|
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,
|
|
)
|
|
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 (commands/media aren't split by the client),
|
|
# EXCEPT an attachment-only message, which is held for the merge window so
|
|
# the trailing text callback merges into the same event instead of
|
|
# "interrupting" a run the attachment already 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):
|
|
self._enqueue_text_event(event)
|
|
elif is_attachment_only and self._attachment_text_merge_delay_seconds > 0:
|
|
self._enqueue_text_event(event)
|
|
else:
|
|
await self.handle_message(event)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Text message aggregation (handles WeCom client-side splits)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
|
"""Buffer an event and reset the flush timer.
|
|
|
|
Merges both 4000-char client splits and the "attachment-only frame, then
|
|
text frame" pair: once real text joins a buffered attachment the type is
|
|
promoted to TEXT (and it inherits the text frame's quote context).
|
|
"""
|
|
key = self._text_batch_key(event)
|
|
existing = self._pending_text_batches.get(key)
|
|
chunk_len = len(event.text or "")
|
|
if existing is None:
|
|
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
|
self._pending_text_batches[key] = event
|
|
else:
|
|
if event.text:
|
|
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
|
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
|
if event.media_urls:
|
|
existing.media_urls.extend(event.media_urls)
|
|
existing.media_types.extend(event.media_types)
|
|
if event.text and (event.text or "").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
|
|
|
|
prior_task = self._pending_text_batch_tasks.get(key)
|
|
if prior_task and not prior_task.done():
|
|
prior_task.cancel()
|
|
self._pending_text_batch_tasks[key] = asyncio.create_task(self._flush_text_batch(key))
|
|
|
|
async def _flush_text_batch(self, key: str) -> None:
|
|
"""Wait for the quiet period then dispatch the aggregated text."""
|
|
current_task = asyncio.current_task()
|
|
try:
|
|
pending = self._pending_text_batches.get(key)
|
|
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
|
if pending and pending.media_urls and not (pending.text or "").strip():
|
|
delay = self._attachment_text_merge_delay_seconds # attachment-only: wait for text
|
|
elif last_len >= self._SPLIT_THRESHOLD:
|
|
delay = self._text_batch_split_delay_seconds # continuation almost certain
|
|
else:
|
|
delay = self._text_batch_delay_seconds
|
|
await asyncio.sleep(delay)
|
|
# Cancel-delivery race: if the sleep timer fired just before cancel(),
|
|
# CancelledError is delivered at the NEXT await, after we'd have popped
|
|
# the merged event — so the superseding task would find nothing.
|
|
# This check is synchronous (no await between it and the pop).
|
|
if self._pending_text_batch_tasks.get(key) is not current_task:
|
|
return
|
|
event = self._pending_text_batches.pop(key, None)
|
|
if not event:
|
|
return
|
|
logger.info(
|
|
"[WeCom] Flushing batch %s (%d chars, %d media)",
|
|
key, len(event.text or ""), len(event.media_urls or []),
|
|
)
|
|
await self.handle_message(event)
|
|
finally:
|
|
if self._pending_text_batch_tasks.get(key) is current_task:
|
|
self._pending_text_batch_tasks.pop(key, None)
|
|
|
|
@staticmethod
|
|
def _extract_text(body: Dict[str, Any]) -> Tuple[str, Optional[str]]:
|
|
"""Extract plain text and quoted text from a callback payload."""
|
|
text_parts: List[str] = []
|
|
msgtype = str(body.get("msgtype") or "").lower()
|
|
if msgtype == "mixed":
|
|
items = _dict_or_empty(body, "mixed").get("msg_item")
|
|
for item in items if isinstance(items, list) else []:
|
|
if isinstance(item, dict) and str(item.get("msgtype") or "").lower() == "text":
|
|
content = _content_of(item, "text")
|
|
if content:
|
|
text_parts.append(content)
|
|
else:
|
|
text_parts.append(_content_of(body, "text"))
|
|
if msgtype == "voice":
|
|
text_parts.append(_content_of(body, "voice"))
|
|
if msgtype == "appmsg": # attachment title (filename)
|
|
text_parts.append(str(_dict_or_empty(body, "appmsg").get("title") or "").strip())
|
|
|
|
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:
|
|
"""Choose the normalized inbound message type."""
|
|
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
|
|
|
|
# ------------------------------------------------------------------
|
|
# Policy helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def enforces_own_access_policy(self) -> bool:
|
|
"""WeCom gates DM/group access at intake via dm_policy/group_policy."""
|
|
return True
|
|
|
|
def _open_dm_opted_in(self) -> bool:
|
|
# Scoped reads: the default profile's allow-all flag must not leak into a
|
|
# multiplexed secondary profile's admission gate.
|
|
return any(
|
|
(_get_scoped_secret(var, "") or "").lower() in {"true", "1", "yes"}
|
|
for var in ("GATEWAY_ALLOW_ALL_USERS", "WECOM_ALLOW_ALL_USERS")
|
|
)
|
|
|
|
def _is_dm_allowed(self, sender_id: str) -> bool:
|
|
if self._dm_policy == "allowlist":
|
|
return _entry_matches(self._allow_from, sender_id)
|
|
if self._dm_policy == "open":
|
|
return self._open_dm_opted_in()
|
|
return False
|
|
|
|
def _is_dm_intake_allowed(self, sender_id: str) -> bool:
|
|
principal = str(sender_id or "").strip()
|
|
if not principal:
|
|
return False
|
|
if self._dm_policy == "pairing":
|
|
return True
|
|
return self._is_dm_allowed(principal)
|
|
|
|
def _is_group_allowed(self, chat_id: str, sender_id: str) -> bool:
|
|
if self._group_policy in ("disabled", "pairing"):
|
|
return False
|
|
if self._group_policy == "allowlist" and not _entry_matches(self._group_allow_from, 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]:
|
|
if not isinstance(self._groups, dict):
|
|
return {}
|
|
if isinstance(self._groups.get(chat_id), dict):
|
|
return self._groups[chat_id]
|
|
lowered = chat_id.lower()
|
|
for key, value in self._groups.items():
|
|
if isinstance(key, str) and key.lower() == lowered and isinstance(value, dict):
|
|
return value
|
|
wildcard = self._groups.get("*")
|
|
return wildcard if isinstance(wildcard, dict) else {}
|
|
|
|
def _remember_chat_req_id(self, chat_id: str, req_id: str) -> None:
|
|
"""Cache the most recent inbound req_id per chat (bounded like _reply_req_ids).
|
|
|
|
Fallback reply target for group sends (APP_CMD_SEND is blocked in groups).
|
|
A fresh req_id also resurrects the chat's stream channel.
|
|
"""
|
|
if _bounded_put(self._last_chat_req_ids, chat_id, req_id):
|
|
self._stream_expired_chats.discard(str(chat_id).strip())
|
|
|
|
def _reply_req_id_for_message(self, reply_to: Optional[str]) -> Optional[str]:
|
|
normalized = str(reply_to or "").strip()
|
|
if not normalized or normalized.startswith("quote:"):
|
|
return None
|
|
return self._reply_req_ids.get(normalized)
|
|
|
|
async def _force_reconnect_on_stale_subscription(self, errcode: int) -> None:
|
|
"""On 846609 (subscription lost) invalidate cached req_ids bound to the dead session.
|
|
|
|
Do NOT close the WS: that makes _listen_loop open a second connection,
|
|
WeCom kicks it and invalidates the first — an infinite kick-reconnect
|
|
loop. The server closes the WS itself and _listen_loop reconnects then.
|
|
"""
|
|
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()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Outbound messaging
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _response_error(response: Dict[str, Any]) -> Optional[str]:
|
|
errcode = response.get("errcode", 0)
|
|
if errcode in {0, None}:
|
|
return None
|
|
return 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}")
|
|
|
|
async def _send_reply_markdown(self, reply_req_id: str, content: str) -> Dict[str, Any]:
|
|
response = await self._send_reply_request(
|
|
reply_req_id, {"msgtype": "markdown", "markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]}},
|
|
)
|
|
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, "msgtype": "markdown", "markdown": {"content": content[:self.MAX_MESSAGE_LENGTH]}},
|
|
)
|
|
|
|
async def send(
|
|
self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None,
|
|
) -> SendResult:
|
|
"""Send markdown to a WeCom chat as a standalone message (never touches active streams).
|
|
|
|
Serialized per chat to stay under 30 msgs/min/chat (errcode 846607).
|
|
``metadata["is_approval_prompt"]`` routes through the control lane.
|
|
"""
|
|
if not chat_id:
|
|
return SendResult(success=False, error="chat_id is required")
|
|
is_control = False
|
|
force_proactive = False
|
|
if metadata:
|
|
is_control = metadata.pop("is_approval_prompt", False)
|
|
# Approval *confirmations* must not consume the req_id the stream
|
|
# consumer needs for resumed output. The initial approval *prompt*
|
|
# still uses passive reply (required in groups).
|
|
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:
|
|
"""Actual send logic, run under the per-chat queue.
|
|
|
|
force_proactive: always use APP_CMD_SEND instead of passive reply
|
|
(except in groups, where APP_CMD_SEND is blocked).
|
|
"""
|
|
try:
|
|
reply_req_id = self._reply_req_id_for_message(reply_to)
|
|
if not reply_req_id and chat_id in self._last_chat_req_ids:
|
|
reply_req_id = self._last_chat_req_ids[chat_id]
|
|
if force_proactive and chat_id not in self._group_chat_ids:
|
|
reply_req_id = None
|
|
|
|
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 WS reconnect — proactive send
|
|
# doesn't depend on any prior req_id.
|
|
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)
|
|
else:
|
|
if 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)")
|
|
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)
|
|
# 846609 (subscription lost): clear stale req_ids so later sends don't
|
|
# fail for minutes while the dead WS lingers.
|
|
if str(STREAM_NOT_SUBSCRIBED_ERRCODE) in str(exc):
|
|
asyncio.ensure_future(self._force_reconnect_on_stale_subscription(STREAM_NOT_SUBSCRIBED_ERRCODE))
|
|
return SendResult(success=False, error=str(exc))
|
|
|
|
error = self._response_error(response)
|
|
if error:
|
|
errcode = response.get("errcode", 0)
|
|
if errcode == STREAM_NOT_SUBSCRIBED_ERRCODE:
|
|
asyncio.ensure_future(self._force_reconnect_on_stale_subscription(errcode))
|
|
return SendResult(success=False, error=error)
|
|
return SendResult(
|
|
success=True,
|
|
message_id=self._payload_req_id(response) or uuid.uuid4().hex[:12],
|
|
raw_response=response,
|
|
)
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
"""Return minimal chat info."""
|
|
return {"name": chat_id, "type": "group" if chat_id and chat_id.lower().startswith("group") else "dm"}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# QR code scan flow for obtaining bot credentials
|
|
# ------------------------------------------------------------------
|
|
|
|
_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 = 3 # seconds
|
|
_QR_POLL_TIMEOUT = 300 # 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 in the terminal, poll until scanned or timeout.
|
|
|
|
Returns ``{"bot_id", "secret"}`` or None. The ``ai/qc/{generate,query_result}``
|
|
endpoints back the admin-console bot-creation UI, not the public API, and
|
|
may change without notice.
|
|
"""
|
|
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"))
|
|
|
|
print(" Connecting to WeCom...", end="", flush=True)
|
|
try:
|
|
raw = _get_json(f"{_QR_GENERATE_URL}?source=hermes", 15)
|
|
except Exception as exc:
|
|
logger.error("WeCom QR: failed to fetch QR code: %s", exc)
|
|
print(f" failed: {exc}")
|
|
return None
|
|
data = raw.get("data") or {}
|
|
scode = str(data.get("scode") or "").strip()
|
|
auth_url = str(data.get("auth_url") or "").strip()
|
|
if not scode or not auth_url:
|
|
logger.error("WeCom QR: unexpected response format: %s", raw)
|
|
print(" failed: unexpected response format")
|
|
return None
|
|
print(" done.")
|
|
|
|
print()
|
|
qr_rendered = False
|
|
try:
|
|
import qrcode as _qrcode
|
|
qr = _qrcode.QRCode()
|
|
qr.add_data(auth_url)
|
|
qr.make(fit=True)
|
|
qr.print_ascii(invert=True)
|
|
qr_rendered = True
|
|
except Exception:
|
|
pass
|
|
page_url = f"{_QR_CODE_PAGE}{urllib.parse.quote(scode)}"
|
|
if qr_rendered:
|
|
print(f"\n Scan the QR code above, or open this URL directly:\n {page_url}")
|
|
else:
|
|
print(f" Open this URL in WeCom on your phone:\n\n {page_url}\n")
|
|
print(" Tip: pip install qrcode to display a scannable QR code here next time")
|
|
print()
|
|
print(" 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)
|
|
except Exception as exc:
|
|
logger.debug("WeCom QR poll error: %s", exc)
|
|
time.sleep(_QR_POLL_INTERVAL)
|
|
continue
|
|
print(".", end="", flush=True) # progress dot on every poll
|
|
result_data = result.get("data") or {}
|
|
if str(result_data.get("status") or "").lower() == "success":
|
|
print()
|
|
bot_info = result_data.get("bot_info") or {}
|
|
bot_id = str(bot_info.get("botid") or bot_info.get("bot_id") or "").strip()
|
|
secret = str(bot_info.get("secret") or "").strip()
|
|
if bot_id and secret:
|
|
return {"bot_id": bot_id, "secret": secret}
|
|
logger.warning("WeCom QR: scan reported success but bot_info missing or incomplete: %s", result_data)
|
|
print(
|
|
" 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
|
|
time.sleep(_QR_POLL_INTERVAL)
|
|
|
|
print()
|
|
print(f" QR scan timed out ({timeout_seconds // 60} minutes). Please try again.")
|
|
return None
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
# Plugin glue: register() exposes both WeCom platforms (wecom + wecom_callback)
|
|
# via the registry; env→PlatformConfig seeding stays in core.
|
|
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def _send_via(adapter, chat_id, message, *, live: bool):
|
|
try:
|
|
result = await adapter.send(chat_id, message)
|
|
except Exception as e:
|
|
return {"error": f"WeCom live adapter send failed: {e}" if live else f"WeCom send failed: {e}"}
|
|
if not result.success:
|
|
return {"error": f"WeCom send failed: {result.error}"}
|
|
return {"success": True, "platform": "wecom", "chat_id": chat_id, "message_id": result.message_id}
|
|
|
|
|
|
async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False):
|
|
"""standalone_sender_fn: reuse the live gateway adapter when in-process, else
|
|
open an ephemeral connection. WeCom allows ONE WebSocket per bot — a second
|
|
connection kicks the first."""
|
|
try:
|
|
from gateway.run import _gateway_runner_ref
|
|
runner = _gateway_runner_ref()
|
|
except Exception:
|
|
runner = None
|
|
if runner is not None:
|
|
from gateway.platforms.base import Platform
|
|
adapter = None
|
|
try:
|
|
adapter = runner.adapters.get(Platform.WECOM)
|
|
except Exception:
|
|
pass
|
|
if adapter is not None:
|
|
return await _send_via(adapter, chat_id, message, live=True)
|
|
|
|
if not check_wecom_requirements():
|
|
return {"error": "WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET."}
|
|
try:
|
|
adapter = WeComAdapter(pconfig)
|
|
if not await adapter.connect():
|
|
return {"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 {"error": f"WeCom send failed: {e}"}
|
|
|
|
|
|
def interactive_setup() -> None:
|
|
"""Interactive setup for WeCom — QR scan or manual credential input."""
|
|
from hermes_cli.config import get_env_value, remove_env_value, save_env_value
|
|
from hermes_cli.setup import prompt_choice
|
|
from hermes_cli.cli_output import (
|
|
prompt, prompt_yes_no, print_header, print_info, print_success, print_warning,
|
|
)
|
|
|
|
print_header("WeCom (Enterprise WeChat)")
|
|
existing_bot_id = get_env_value("WECOM_BOT_ID")
|
|
existing_secret = get_env_value("WECOM_SECRET")
|
|
if existing_bot_id and existing_secret:
|
|
print_success("WeCom is already configured.")
|
|
if not prompt_yes_no("Reconfigure WeCom?", False):
|
|
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 = None
|
|
secret = None
|
|
if method_idx == 0:
|
|
try:
|
|
credentials = qr_scan_for_bot_info()
|
|
except KeyboardInterrupt:
|
|
print_warning("WeCom setup cancelled.")
|
|
return
|
|
except Exception as exc:
|
|
print_warning(f"QR scan failed: {exc}")
|
|
credentials = None
|
|
if credentials:
|
|
bot_id = credentials.get("bot_id", "")
|
|
secret = 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 = None
|
|
secret = None
|
|
|
|
if not bot_id or not secret:
|
|
print_info("1. Go to WeCom Application → Workspace → Smart Robot -> Create smart robots")
|
|
print_info("2. Select API Mode")
|
|
print_info("3. Copy the Bot ID and Secret from the bot's credentials info")
|
|
print_info("4. The bot connects via WebSocket — no public endpoint needed")
|
|
bot_id = prompt("Bot ID", password=False)
|
|
if not bot_id:
|
|
print_warning("Skipped — WeCom won't work without a Bot ID.")
|
|
return
|
|
secret = prompt("Secret", password=True)
|
|
if not secret:
|
|
print_warning("Skipped — WeCom won't work without a Secret.")
|
|
return
|
|
|
|
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?",
|
|
[
|
|
"Enable open access (anyone can message the bot)",
|
|
"Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')",
|
|
"Disable direct messages",
|
|
"Skip for now (bot will deny all users until configured)",
|
|
],
|
|
1,
|
|
)
|
|
if access_idx == 0:
|
|
save_env_value("WECOM_DM_POLICY", "open")
|
|
save_env_value("GATEWAY_ALLOW_ALL_USERS", "true")
|
|
print_warning("Open access enabled — anyone can use your bot!")
|
|
elif access_idx == 1:
|
|
save_env_value("WECOM_DM_POLICY", "pairing")
|
|
print_success("DM pairing mode — users will receive a code to request access.")
|
|
print_info("Approve with: hermes pairing approve <platform> <code>")
|
|
elif access_idx == 2:
|
|
save_env_value("WECOM_DM_POLICY", "disabled")
|
|
print_warning("Direct messages disabled.")
|
|
else:
|
|
print_info("Skipped — configure later with 'hermes gateway setup'")
|
|
|
|
home = prompt("Home chat ID (optional, for cron/notifications)", password=False).strip()
|
|
if home:
|
|
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:
|
|
"""Connected when a bot_id is configured."""
|
|
extra = getattr(config, "extra", {}) or {}
|
|
return bool(extra.get("bot_id"))
|
|
|
|
|
|
def _callback_is_connected(config) -> bool:
|
|
"""Callback mode is connected when corp_id (or a multi-app `apps` block) is configured."""
|
|
extra = getattr(config, "extra", {}) or {}
|
|
return bool(extra.get("corp_id") or extra.get("apps"))
|
|
|
|
|
|
def _build_adapter(config):
|
|
return WeComAdapter(config)
|
|
|
|
|
|
def _build_callback_adapter(config):
|
|
from plugins.platforms.wecom.callback_adapter import WecomCallbackAdapter
|
|
return WecomCallbackAdapter(config)
|
|
|
|
|
|
def register(ctx) -> None:
|
|
"""Plugin entry point — registers both WeCom platforms."""
|
|
ctx.register_platform(
|
|
name="wecom",
|
|
label="WeCom (Enterprise WeChat)",
|
|
adapter_factory=_build_adapter,
|
|
check_fn=check_wecom_requirements,
|
|
is_connected=_is_connected,
|
|
validate_config=_is_connected,
|
|
required_env=["WECOM_BOT_ID", "WECOM_SECRET"],
|
|
install_hint="Run `hermes setup` to install WeCom support.",
|
|
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,
|
|
emoji="💼",
|
|
allow_update_command=True,
|
|
)
|
|
|
|
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"],
|
|
install_hint="Run `hermes setup` to install WeCom support.",
|
|
allowed_users_env="WECOM_CALLBACK_ALLOWED_USERS",
|
|
allow_all_env="WECOM_CALLBACK_ALLOW_ALL_USERS",
|
|
emoji="💼",
|
|
allow_update_command=True,
|
|
)
|