| 1 | """WeCom (Enterprise WeChat) channel implementation using wecom_aibot_sdk.""" |
| 2 | |
| 3 | import asyncio |
| 4 | import base64 |
| 5 | import hashlib |
| 6 | import importlib.util |
| 7 | import os |
| 8 | import re |
| 9 | from collections import OrderedDict |
| 10 | from pathlib import Path |
| 11 | from typing import Any |
| 12 | |
| 13 | from loguru import logger |
| 14 | |
| 15 | from nanobot.bus.events import OutboundMessage |
| 16 | from nanobot.bus.queue import MessageBus |
| 17 | from nanobot.channels.base import BaseChannel |
| 18 | from nanobot.config.paths import get_media_dir |
| 19 | from nanobot.config.schema import Base |
| 20 | from pydantic import Field |
| 21 | |
| 22 | WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None |
| 23 | |
| 24 | # Upload safety limits (matching QQ channel defaults) |
| 25 | WECOM_UPLOAD_MAX_BYTES = 1024 * 1024 * 200 # 200MB |
| 26 | |
| 27 | # Replace unsafe characters with "_", keep Chinese and common safe punctuation. |
| 28 | _SAFE_NAME_RE = re.compile(r"[^\w.\-()\[\]()【】\u4e00-\u9fff]+", re.UNICODE) |
| 29 | |
| 30 | |
| 31 | def _sanitize_filename(name: str) -> str: |
| 32 | """Sanitize filename to avoid traversal and problematic chars.""" |
| 33 | name = (name or "").strip() |
| 34 | name = Path(name).name |
| 35 | name = _SAFE_NAME_RE.sub("_", name).strip("._ ") |
| 36 | return name |
| 37 | |
| 38 | |
| 39 | _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} |
| 40 | _VIDEO_EXTS = {".mp4", ".avi", ".mov"} |
| 41 | _AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg"} |
| 42 | |
| 43 | |
| 44 | def _guess_wecom_media_type(filename: str) -> str: |
| 45 | """Classify file extension as WeCom media_type string.""" |
| 46 | ext = Path(filename).suffix.lower() |
| 47 | if ext in _IMAGE_EXTS: |
| 48 | return "image" |
| 49 | if ext in _VIDEO_EXTS: |
| 50 | return "video" |
| 51 | if ext in _AUDIO_EXTS: |
| 52 | return "voice" |
| 53 | return "file" |
| 54 | |
| 55 | class WecomConfig(Base): |
| 56 | """WeCom (Enterprise WeChat) AI Bot channel configuration.""" |
| 57 | |
| 58 | enabled: bool = False |
| 59 | bot_id: str = "" |
| 60 | secret: str = "" |
| 61 | allow_from: list[str] = Field(default_factory=list) |
| 62 | welcome_message: str = "" |
| 63 | |
| 64 | |
| 65 | # Message type display mapping |
| 66 | MSG_TYPE_MAP = { |
| 67 | "image": "[image]", |
| 68 | "voice": "[voice]", |
| 69 | "file": "[file]", |
| 70 | "mixed": "[mixed content]", |
| 71 | } |
| 72 | |
| 73 | |
| 74 | class WecomChannel(BaseChannel): |
| 75 | """ |
| 76 | WeCom (Enterprise WeChat) channel using WebSocket long connection. |
| 77 | |
| 78 | Uses WebSocket to receive events - no public IP or webhook required. |
| 79 | |
| 80 | Requires: |
| 81 | - Bot ID and Secret from WeCom AI Bot platform |
| 82 | """ |
| 83 | |
| 84 | name = "wecom" |
| 85 | display_name = "WeCom" |
| 86 | |
| 87 | @classmethod |
| 88 | def default_config(cls) -> dict[str, Any]: |
| 89 | return WecomConfig().model_dump(by_alias=True) |
| 90 | |
| 91 | def __init__(self, config: Any, bus: MessageBus): |
| 92 | if isinstance(config, dict): |
| 93 | config = WecomConfig.model_validate(config) |
| 94 | super().__init__(config, bus) |
| 95 | self.config: WecomConfig = config |
| 96 | self._client: Any = None |
| 97 | self._processed_message_ids: OrderedDict[str, None] = OrderedDict() |
| 98 | self._loop: asyncio.AbstractEventLoop | None = None |
| 99 | self._generate_req_id = None |
| 100 | # Store frame headers for each chat to enable replies |
| 101 | self._chat_frames: dict[str, Any] = {} |
| 102 | |
| 103 | async def start(self) -> None: |
| 104 | """Start the WeCom bot with WebSocket long connection.""" |
| 105 | if not WECOM_AVAILABLE: |
| 106 | logger.error("WeCom SDK not installed. Run: pip install echo-director-agent[wecom]") |
| 107 | return |
| 108 | |
| 109 | if not self.config.bot_id or not self.config.secret: |
| 110 | logger.error("WeCom bot_id and secret not configured") |
| 111 | return |
| 112 | |
| 113 | from wecom_aibot_sdk import WSClient, generate_req_id |
| 114 | |
| 115 | self._running = True |
| 116 | self._loop = asyncio.get_running_loop() |
| 117 | self._generate_req_id = generate_req_id |
| 118 | |
| 119 | # Create WebSocket client |
| 120 | self._client = WSClient({ |
| 121 | "bot_id": self.config.bot_id, |
| 122 | "secret": self.config.secret, |
| 123 | "reconnect_interval": 1000, |
| 124 | "max_reconnect_attempts": -1, # Infinite reconnect |
| 125 | "heartbeat_interval": 30000, |
| 126 | }) |
| 127 | |
| 128 | # Register event handlers |
| 129 | self._client.on("connected", self._on_connected) |
| 130 | self._client.on("authenticated", self._on_authenticated) |
| 131 | self._client.on("disconnected", self._on_disconnected) |
| 132 | self._client.on("error", self._on_error) |
| 133 | self._client.on("message.text", self._on_text_message) |
| 134 | self._client.on("message.image", self._on_image_message) |
| 135 | self._client.on("message.voice", self._on_voice_message) |
| 136 | self._client.on("message.file", self._on_file_message) |
| 137 | self._client.on("message.mixed", self._on_mixed_message) |
| 138 | self._client.on("event.enter_chat", self._on_enter_chat) |
| 139 | |
| 140 | logger.info("WeCom bot starting with WebSocket long connection") |
| 141 | logger.info("No public IP required - using WebSocket to receive events") |
| 142 | |
| 143 | # Connect |
| 144 | await self._client.connect_async() |
| 145 | |
| 146 | # Keep running until stopped |
| 147 | while self._running: |
| 148 | await asyncio.sleep(1) |
| 149 | |
| 150 | async def stop(self) -> None: |
| 151 | """Stop the WeCom bot.""" |
| 152 | self._running = False |
| 153 | if self._client: |
| 154 | await self._client.disconnect() |
| 155 | logger.info("WeCom bot stopped") |
| 156 | |
| 157 | async def _on_connected(self, frame: Any) -> None: |
| 158 | """Handle WebSocket connected event.""" |
| 159 | logger.info("WeCom WebSocket connected") |
| 160 | |
| 161 | async def _on_authenticated(self, frame: Any) -> None: |
| 162 | """Handle authentication success event.""" |
| 163 | logger.info("WeCom authenticated successfully") |
| 164 | |
| 165 | async def _on_disconnected(self, frame: Any) -> None: |
| 166 | """Handle WebSocket disconnected event.""" |
| 167 | reason = frame.body if hasattr(frame, 'body') else str(frame) |
| 168 | logger.warning("WeCom WebSocket disconnected: {}", reason) |
| 169 | |
| 170 | async def _on_error(self, frame: Any) -> None: |
| 171 | """Handle error event.""" |
| 172 | logger.error("WeCom error: {}", frame) |
| 173 | |
| 174 | async def _on_text_message(self, frame: Any) -> None: |
| 175 | """Handle text message.""" |
| 176 | await self._process_message(frame, "text") |
| 177 | |
| 178 | async def _on_image_message(self, frame: Any) -> None: |
| 179 | """Handle image message.""" |
| 180 | await self._process_message(frame, "image") |
| 181 | |
| 182 | async def _on_voice_message(self, frame: Any) -> None: |
| 183 | """Handle voice message.""" |
| 184 | await self._process_message(frame, "voice") |
| 185 | |
| 186 | async def _on_file_message(self, frame: Any) -> None: |
| 187 | """Handle file message.""" |
| 188 | await self._process_message(frame, "file") |
| 189 | |
| 190 | async def _on_mixed_message(self, frame: Any) -> None: |
| 191 | """Handle mixed content message.""" |
| 192 | await self._process_message(frame, "mixed") |
| 193 | |
| 194 | async def _on_enter_chat(self, frame: Any) -> None: |
| 195 | """Handle enter_chat event (user opens chat with bot).""" |
| 196 | try: |
| 197 | # Extract body from WsFrame dataclass or dict |
| 198 | if hasattr(frame, 'body'): |
| 199 | body = frame.body or {} |
| 200 | elif isinstance(frame, dict): |
| 201 | body = frame.get("body", frame) |
| 202 | else: |
| 203 | body = {} |
| 204 | |
| 205 | chat_id = body.get("chatid", "") if isinstance(body, dict) else "" |
| 206 | |
| 207 | if chat_id and self.config.welcome_message: |
| 208 | await self._client.reply_welcome(frame, { |
| 209 | "msgtype": "text", |
| 210 | "text": {"content": self.config.welcome_message}, |
| 211 | }) |
| 212 | except Exception as e: |
| 213 | logger.error("Error handling enter_chat: {}", e) |
| 214 | |
| 215 | async def _process_message(self, frame: Any, msg_type: str) -> None: |
| 216 | """Process incoming message and forward to bus.""" |
| 217 | try: |
| 218 | # Extract body from WsFrame dataclass or dict |
| 219 | if hasattr(frame, 'body'): |
| 220 | body = frame.body or {} |
| 221 | elif isinstance(frame, dict): |
| 222 | body = frame.get("body", frame) |
| 223 | else: |
| 224 | body = {} |
| 225 | |
| 226 | # Ensure body is a dict |
| 227 | if not isinstance(body, dict): |
| 228 | logger.warning("Invalid body type: {}", type(body)) |
| 229 | return |
| 230 | |
| 231 | # Extract message info |
| 232 | msg_id = body.get("msgid", "") |
| 233 | if not msg_id: |
| 234 | msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}" |
| 235 | |
| 236 | # Deduplication check |
| 237 | if msg_id in self._processed_message_ids: |
| 238 | return |
| 239 | self._processed_message_ids[msg_id] = None |
| 240 | |
| 241 | # Trim cache |
| 242 | while len(self._processed_message_ids) > 1000: |
| 243 | self._processed_message_ids.popitem(last=False) |
| 244 | |
| 245 | # Extract sender info from "from" field (SDK format) |
| 246 | from_info = body.get("from", {}) |
| 247 | sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown" |
| 248 | |
| 249 | # For single chat, chatid is the sender's userid |
| 250 | # For group chat, chatid is provided in body |
| 251 | chat_type = body.get("chattype", "single") |
| 252 | chat_id = body.get("chatid", sender_id) |
| 253 | |
| 254 | content_parts = [] |
| 255 | media_paths: list[str] = [] |
| 256 | |
| 257 | if msg_type == "text": |
| 258 | text = body.get("text", {}).get("content", "") |
| 259 | if text: |
| 260 | content_parts.append(text) |
| 261 | |
| 262 | elif msg_type == "image": |
| 263 | image_info = body.get("image", {}) |
| 264 | file_url = image_info.get("url", "") |
| 265 | aes_key = image_info.get("aeskey", "") |
| 266 | |
| 267 | if file_url and aes_key: |
| 268 | file_path = await self._download_and_save_media(file_url, aes_key, "image") |
| 269 | if file_path: |
| 270 | filename = os.path.basename(file_path) |
| 271 | content_parts.append(f"[image: {filename}]") |
| 272 | media_paths.append(file_path) |
| 273 | else: |
| 274 | content_parts.append("[image: download failed]") |
| 275 | else: |
| 276 | content_parts.append("[image: download failed]") |
| 277 | |
| 278 | elif msg_type == "voice": |
| 279 | voice_info = body.get("voice", {}) |
| 280 | # Voice message already contains transcribed content from WeCom |
| 281 | voice_content = voice_info.get("content", "") |
| 282 | if voice_content: |
| 283 | content_parts.append(f"[voice] {voice_content}") |
| 284 | else: |
| 285 | content_parts.append("[voice]") |
| 286 | |
| 287 | elif msg_type == "file": |
| 288 | file_info = body.get("file", {}) |
| 289 | file_url = file_info.get("url", "") |
| 290 | aes_key = file_info.get("aeskey", "") |
| 291 | file_name = file_info.get("name", "unknown") |
| 292 | |
| 293 | if file_url and aes_key: |
| 294 | file_path = await self._download_and_save_media(file_url, aes_key, "file", file_name) |
| 295 | if file_path: |
| 296 | content_parts.append(f"[file: {file_name}]") |
| 297 | media_paths.append(file_path) |
| 298 | else: |
| 299 | content_parts.append(f"[file: {file_name}: download failed]") |
| 300 | else: |
| 301 | content_parts.append(f"[file: {file_name}: download failed]") |
| 302 | |
| 303 | elif msg_type == "mixed": |
| 304 | # Mixed content contains multiple message items |
| 305 | msg_items = body.get("mixed", {}).get("msg_item", []) |
| 306 | for item in msg_items: |
| 307 | item_type = item.get("msgtype", "") |
| 308 | if item_type == "text": |
| 309 | text = item.get("text", {}).get("content", "") |
| 310 | if text: |
| 311 | content_parts.append(text) |
| 312 | elif item_type == "image": |
| 313 | file_url = item.get("image", {}).get("url", "") |
| 314 | aes_key = item.get("image", {}).get("aeskey", "") |
| 315 | if file_url and aes_key: |
| 316 | file_path = await self._download_and_save_media(file_url, aes_key, "image") |
| 317 | if file_path: |
| 318 | filename = os.path.basename(file_path) |
| 319 | content_parts.append(f"[image: {filename}]") |
| 320 | media_paths.append(file_path) |
| 321 | else: |
| 322 | content_parts.append(MSG_TYPE_MAP.get(item_type, f"[{item_type}]")) |
| 323 | |
| 324 | else: |
| 325 | content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) |
| 326 | |
| 327 | content = "\n".join(content_parts) if content_parts else "" |
| 328 | |
| 329 | if not content: |
| 330 | return |
| 331 | |
| 332 | # Store frame for this chat to enable replies |
| 333 | self._chat_frames[chat_id] = frame |
| 334 | |
| 335 | # Forward to message bus |
| 336 | await self._handle_message( |
| 337 | sender_id=sender_id, |
| 338 | chat_id=chat_id, |
| 339 | content=content, |
| 340 | media=media_paths or None, |
| 341 | metadata={ |
| 342 | "message_id": msg_id, |
| 343 | "msg_type": msg_type, |
| 344 | "chat_type": chat_type, |
| 345 | } |
| 346 | ) |
| 347 | |
| 348 | except Exception as e: |
| 349 | logger.error("Error processing WeCom message: {}", e) |
| 350 | |
| 351 | async def _download_and_save_media( |
| 352 | self, |
| 353 | file_url: str, |
| 354 | aes_key: str, |
| 355 | media_type: str, |
| 356 | filename: str | None = None, |
| 357 | ) -> str | None: |
| 358 | """ |
| 359 | Download and decrypt media from WeCom. |
| 360 | |
| 361 | Returns: |
| 362 | file_path or None if download failed |
| 363 | """ |
| 364 | try: |
| 365 | data, fname = await self._client.download_file(file_url, aes_key) |
| 366 | |
| 367 | if not data: |
| 368 | logger.warning("Failed to download media from WeCom") |
| 369 | return None |
| 370 | |
| 371 | if len(data) > WECOM_UPLOAD_MAX_BYTES: |
| 372 | logger.warning( |
| 373 | "WeCom inbound media too large: {} bytes (max {})", |
| 374 | len(data), |
| 375 | WECOM_UPLOAD_MAX_BYTES, |
| 376 | ) |
| 377 | return None |
| 378 | |
| 379 | media_dir = get_media_dir("wecom") |
| 380 | if not filename: |
| 381 | filename = fname or f"{media_type}_{hash(file_url) % 100000}" |
| 382 | filename = _sanitize_filename(filename) |
| 383 | |
| 384 | file_path = media_dir / filename |
| 385 | await asyncio.to_thread(file_path.write_bytes, data) |
| 386 | logger.debug("Downloaded {} to {}", media_type, file_path) |
| 387 | return str(file_path) |
| 388 | |
| 389 | except Exception as e: |
| 390 | logger.error("Error downloading media: {}", e) |
| 391 | return None |
| 392 | |
| 393 | async def _upload_media_ws( |
| 394 | self, client: Any, file_path: str, |
| 395 | ) -> "tuple[str, str] | tuple[None, None]": |
| 396 | """Upload a local file to WeCom via WebSocket 3-step protocol (base64). |
| 397 | |
| 398 | Uses the WeCom WebSocket upload commands directly via |
| 399 | ``client._ws_manager.send_reply()``: |
| 400 | |
| 401 | ``aibot_upload_media_init`` → upload_id |
| 402 | ``aibot_upload_media_chunk`` × N (≤512 KB raw per chunk, base64) |
| 403 | ``aibot_upload_media_finish`` → media_id |
| 404 | |
| 405 | Returns (media_id, media_type) on success, (None, None) on failure. |
| 406 | """ |
| 407 | from wecom_aibot_sdk.utils import generate_req_id as _gen_req_id |
| 408 | |
| 409 | try: |
| 410 | fname = os.path.basename(file_path) |
| 411 | media_type = _guess_wecom_media_type(fname) |
| 412 | |
| 413 | # Read file size and data in a thread to avoid blocking the event loop |
| 414 | def _read_file(): |
| 415 | file_size = os.path.getsize(file_path) |
| 416 | if file_size > WECOM_UPLOAD_MAX_BYTES: |
| 417 | raise ValueError( |
| 418 | f"File too large: {file_size} bytes (max {WECOM_UPLOAD_MAX_BYTES})" |
| 419 | ) |
| 420 | with open(file_path, "rb") as f: |
| 421 | return file_size, f.read() |
| 422 | |
| 423 | file_size, data = await asyncio.to_thread(_read_file) |
| 424 | # MD5 is used for file integrity only, not cryptographic security |
| 425 | md5_hash = hashlib.md5(data).hexdigest() |
| 426 | |
| 427 | CHUNK_SIZE = 512 * 1024 # 512 KB raw (before base64) |
| 428 | mv = memoryview(data) |
| 429 | chunk_list = [bytes(mv[i : i + CHUNK_SIZE]) for i in range(0, file_size, CHUNK_SIZE)] |
| 430 | n_chunks = len(chunk_list) |
| 431 | del mv, data |
| 432 | |
| 433 | # Step 1: init |
| 434 | req_id = _gen_req_id("upload_init") |
| 435 | resp = await client._ws_manager.send_reply(req_id, { |
| 436 | "type": media_type, |
| 437 | "filename": fname, |
| 438 | "total_size": file_size, |
| 439 | "total_chunks": n_chunks, |
| 440 | "md5": md5_hash, |
| 441 | }, "aibot_upload_media_init") |
| 442 | if resp.errcode != 0: |
| 443 | logger.warning("WeCom upload init failed ({}): {}", resp.errcode, resp.errmsg) |
| 444 | return None, None |
| 445 | upload_id = resp.body.get("upload_id") if resp.body else None |
| 446 | if not upload_id: |
| 447 | logger.warning("WeCom upload init: no upload_id in response") |
| 448 | return None, None |
| 449 | |
| 450 | # Step 2: send chunks |
| 451 | for i, chunk in enumerate(chunk_list): |
| 452 | req_id = _gen_req_id("upload_chunk") |
| 453 | resp = await client._ws_manager.send_reply(req_id, { |
| 454 | "upload_id": upload_id, |
| 455 | "chunk_index": i, |
| 456 | "base64_data": base64.b64encode(chunk).decode(), |
| 457 | }, "aibot_upload_media_chunk") |
| 458 | if resp.errcode != 0: |
| 459 | logger.warning("WeCom upload chunk {} failed ({}): {}", i, resp.errcode, resp.errmsg) |
| 460 | return None, None |
| 461 | |
| 462 | # Step 3: finish |
| 463 | req_id = _gen_req_id("upload_finish") |
| 464 | resp = await client._ws_manager.send_reply(req_id, { |
| 465 | "upload_id": upload_id, |
| 466 | }, "aibot_upload_media_finish") |
| 467 | if resp.errcode != 0: |
| 468 | logger.warning("WeCom upload finish failed ({}): {}", resp.errcode, resp.errmsg) |
| 469 | return None, None |
| 470 | |
| 471 | media_id = resp.body.get("media_id") if resp.body else None |
| 472 | if not media_id: |
| 473 | logger.warning("WeCom upload finish: no media_id in response body={}", resp.body) |
| 474 | return None, None |
| 475 | |
| 476 | suffix = "..." if len(media_id) > 16 else "" |
| 477 | logger.debug("WeCom uploaded {} ({}) → media_id={}", fname, media_type, media_id[:16] + suffix) |
| 478 | return media_id, media_type |
| 479 | |
| 480 | except ValueError as e: |
| 481 | logger.warning("WeCom upload skipped for {}: {}", file_path, e) |
| 482 | return None, None |
| 483 | except Exception as e: |
| 484 | logger.error("WeCom _upload_media_ws error for {}: {}", file_path, e) |
| 485 | return None, None |
| 486 | |
| 487 | async def send(self, msg: OutboundMessage) -> None: |
| 488 | """Send a message through WeCom.""" |
| 489 | if not self._client: |
| 490 | logger.warning("WeCom client not initialized") |
| 491 | return |
| 492 | |
| 493 | try: |
| 494 | content = (msg.content or "").strip() |
| 495 | is_progress = bool(msg.metadata.get("_progress")) |
| 496 | |
| 497 | # Get the stored frame for this chat |
| 498 | frame = self._chat_frames.get(msg.chat_id) |
| 499 | |
| 500 | # Send media files via WebSocket upload |
| 501 | for file_path in msg.media or []: |
| 502 | if not os.path.isfile(file_path): |
| 503 | logger.warning("WeCom media file not found: {}", file_path) |
| 504 | continue |
| 505 | media_id, media_type = await self._upload_media_ws(self._client, file_path) |
| 506 | if media_id: |
| 507 | if frame: |
| 508 | await self._client.reply(frame, { |
| 509 | "msgtype": media_type, |
| 510 | media_type: {"media_id": media_id}, |
| 511 | }) |
| 512 | else: |
| 513 | await self._client.send_message(msg.chat_id, { |
| 514 | "msgtype": media_type, |
| 515 | media_type: {"media_id": media_id}, |
| 516 | }) |
| 517 | logger.debug("WeCom sent {} → {}", media_type, msg.chat_id) |
| 518 | else: |
| 519 | content += f"\n[file upload failed: {os.path.basename(file_path)}]" |
| 520 | |
| 521 | if not content: |
| 522 | return |
| 523 | |
| 524 | if frame: |
| 525 | # Both progress and final messages must use reply_stream (cmd="aibot_respond_msg"). |
| 526 | # The plain reply() uses cmd="reply" which does not support "text" msgtype |
| 527 | # and causes errcode=40008 from WeCom API. |
| 528 | stream_id = self._generate_req_id("stream") |
| 529 | await self._client.reply_stream( |
| 530 | frame, |
| 531 | stream_id, |
| 532 | content, |
| 533 | finish=not is_progress, |
| 534 | ) |
| 535 | logger.debug( |
| 536 | "WeCom {} sent to {}", |
| 537 | "progress" if is_progress else "message", |
| 538 | msg.chat_id, |
| 539 | ) |
| 540 | else: |
| 541 | # No frame (e.g. cron push): proactive send only supports markdown |
| 542 | await self._client.send_message(msg.chat_id, { |
| 543 | "msgtype": "markdown", |
| 544 | "markdown": {"content": content}, |
| 545 | }) |
| 546 | logger.info("WeCom proactive send to {}", msg.chat_id) |
| 547 | |
| 548 | except Exception: |
| 549 | logger.exception("Error sending WeCom message to chat_id={}", msg.chat_id) |
| 550 |