diff --git a/bot-images/dashboard.py b/bot-images/dashboard.py index 7e40593..522ff52 100644 --- a/bot-images/dashboard.py +++ b/bot-images/dashboard.py @@ -114,8 +114,9 @@ class DashboardChannel(BaseChannel): """处理来自面板的指令入站""" try: data = await request.json() - user_message = data.get("message", "").strip() - media = [str(v).strip().replace("\\", "/") for v in (data.get("media") or []) if str(v).strip()] + user_message = str(data.get("message") or "").strip() + raw_media = data.get("media") or [] + media = [str(v).strip().replace("\\", "/") for v in raw_media if str(v).strip()] if isinstance(raw_media, list) else [] if not user_message and not media: return web.json_response({"status": "error", "reason": "empty message and media"}, status=400) diff --git a/bot-images/wecom.py b/bot-images/wecom.py new file mode 100644 index 0000000..3544577 --- /dev/null +++ b/bot-images/wecom.py @@ -0,0 +1,552 @@ +"""WeCom (Enterprise WeChat) channel implementation using wecom_aibot_sdk.""" + +import asyncio +import base64 +import hashlib +import importlib.util +import os +import re +from collections import OrderedDict +from pathlib import Path +from typing import Any + +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.paths import get_media_dir, get_workspace_path +from nanobot.config.schema import Base +from pydantic import Field + +WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None + +# Upload safety limits (matching QQ channel defaults) +WECOM_UPLOAD_MAX_BYTES = 1024 * 1024 * 200 # 200MB + +# Replace unsafe characters with "_", keep Chinese and common safe punctuation. +_SAFE_NAME_RE = re.compile(r"[^\w.\-()\[\]()【】\u4e00-\u9fff]+", re.UNICODE) + + +def _sanitize_filename(name: str) -> str: + """Sanitize filename to avoid traversal and problematic chars.""" + name = (name or "").strip() + name = Path(name).name + name = _SAFE_NAME_RE.sub("_", name).strip("._ ") + return name + + +_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} +_VIDEO_EXTS = {".mp4", ".avi", ".mov"} +_AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg"} + + +def _guess_wecom_media_type(filename: str) -> str: + """Classify file extension as WeCom media_type string.""" + ext = Path(filename).suffix.lower() + if ext in _IMAGE_EXTS: + return "image" + if ext in _VIDEO_EXTS: + return "video" + if ext in _AUDIO_EXTS: + return "voice" + return "file" + +class WecomConfig(Base): + """WeCom (Enterprise WeChat) AI Bot channel configuration.""" + + enabled: bool = False + bot_id: str = "" + secret: str = "" + allow_from: list[str] = Field(default_factory=list) + welcome_message: str = "" + + +# Message type display mapping +MSG_TYPE_MAP = { + "image": "[image]", + "voice": "[voice]", + "file": "[file]", + "mixed": "[mixed content]", +} + + +class WecomChannel(BaseChannel): + """ + WeCom (Enterprise WeChat) channel using WebSocket long connection. + + Uses WebSocket to receive events - no public IP or webhook required. + + Requires: + - Bot ID and Secret from WeCom AI Bot platform + """ + + name = "wecom" + display_name = "WeCom" + + @classmethod + def default_config(cls) -> dict[str, Any]: + return WecomConfig().model_dump(by_alias=True) + + def __init__(self, config: Any, bus: MessageBus): + if isinstance(config, dict): + config = WecomConfig.model_validate(config) + super().__init__(config, bus) + self.config: WecomConfig = config + self._client: Any = None + self._processed_message_ids: OrderedDict[str, None] = OrderedDict() + self._loop: asyncio.AbstractEventLoop | None = None + self._generate_req_id = None + # Store frame headers for each chat to enable replies + self._chat_frames: dict[str, Any] = {} + + async def start(self) -> None: + """Start the WeCom bot with WebSocket long connection.""" + if not WECOM_AVAILABLE: + logger.error("WeCom SDK not installed. Run: pip install nanobot-ai[wecom]") + return + + if not self.config.bot_id or not self.config.secret: + logger.error("WeCom bot_id and secret not configured") + return + + from wecom_aibot_sdk import WSClient, generate_req_id + + self._running = True + self._loop = asyncio.get_running_loop() + self._generate_req_id = generate_req_id + + # Create WebSocket client + self._client = WSClient({ + "bot_id": self.config.bot_id, + "secret": self.config.secret, + "reconnect_interval": 1000, + "max_reconnect_attempts": -1, # Infinite reconnect + "heartbeat_interval": 30000, + }) + + # Register event handlers + self._client.on("connected", self._on_connected) + self._client.on("authenticated", self._on_authenticated) + self._client.on("disconnected", self._on_disconnected) + self._client.on("error", self._on_error) + self._client.on("message.text", self._on_text_message) + self._client.on("message.image", self._on_image_message) + self._client.on("message.voice", self._on_voice_message) + self._client.on("message.file", self._on_file_message) + self._client.on("message.mixed", self._on_mixed_message) + self._client.on("event.enter_chat", self._on_enter_chat) + + logger.info("WeCom bot starting with WebSocket long connection") + logger.info("No public IP required - using WebSocket to receive events") + + # Connect + await self._client.connect_async() + + # Keep running until stopped + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + """Stop the WeCom bot.""" + self._running = False + if self._client: + await self._client.disconnect() + logger.info("WeCom bot stopped") + + async def _on_connected(self, frame: Any) -> None: + """Handle WebSocket connected event.""" + logger.info("WeCom WebSocket connected") + + async def _on_authenticated(self, frame: Any) -> None: + """Handle authentication success event.""" + logger.info("WeCom authenticated successfully") + + async def _on_disconnected(self, frame: Any) -> None: + """Handle WebSocket disconnected event.""" + reason = frame.body if hasattr(frame, 'body') else str(frame) + logger.warning("WeCom WebSocket disconnected: {}", reason) + + async def _on_error(self, frame: Any) -> None: + """Handle error event.""" + logger.error("WeCom error: {}", frame) + + async def _on_text_message(self, frame: Any) -> None: + """Handle text message.""" + await self._process_message(frame, "text") + + async def _on_image_message(self, frame: Any) -> None: + """Handle image message.""" + await self._process_message(frame, "image") + + async def _on_voice_message(self, frame: Any) -> None: + """Handle voice message.""" + await self._process_message(frame, "voice") + + async def _on_file_message(self, frame: Any) -> None: + """Handle file message.""" + await self._process_message(frame, "file") + + async def _on_mixed_message(self, frame: Any) -> None: + """Handle mixed content message.""" + await self._process_message(frame, "mixed") + + async def _on_enter_chat(self, frame: Any) -> None: + """Handle enter_chat event (user opens chat with bot).""" + try: + # Extract body from WsFrame dataclass or dict + if hasattr(frame, 'body'): + body = frame.body or {} + elif isinstance(frame, dict): + body = frame.get("body", frame) + else: + body = {} + + chat_id = body.get("chatid", "") if isinstance(body, dict) else "" + + if chat_id and self.config.welcome_message: + await self._client.reply_welcome(frame, { + "msgtype": "text", + "text": {"content": self.config.welcome_message}, + }) + except Exception as e: + logger.error("Error handling enter_chat: {}", e) + + async def _process_message(self, frame: Any, msg_type: str) -> None: + """Process incoming message and forward to bus.""" + try: + # Extract body from WsFrame dataclass or dict + if hasattr(frame, 'body'): + body = frame.body or {} + elif isinstance(frame, dict): + body = frame.get("body", frame) + else: + body = {} + + # Ensure body is a dict + if not isinstance(body, dict): + logger.warning("Invalid body type: {}", type(body)) + return + + # Extract message info + msg_id = body.get("msgid", "") + if not msg_id: + msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}" + + # Deduplication check + if msg_id in self._processed_message_ids: + return + self._processed_message_ids[msg_id] = None + + # Trim cache + while len(self._processed_message_ids) > 1000: + self._processed_message_ids.popitem(last=False) + + # Extract sender info from "from" field (SDK format) + from_info = body.get("from", {}) + sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown" + + # For single chat, chatid is the sender's userid + # For group chat, chatid is provided in body + chat_type = body.get("chattype", "single") + chat_id = body.get("chatid", sender_id) + + content_parts = [] + media_paths: list[str] = [] + + if msg_type == "text": + text = body.get("text", {}).get("content", "") + if text: + content_parts.append(text) + + elif msg_type == "image": + image_info = body.get("image", {}) + file_url = image_info.get("url", "") + aes_key = image_info.get("aeskey", "") + + if file_url and aes_key: + file_path = await self._download_and_save_media(file_url, aes_key, "image") + if file_path: + filename = os.path.basename(file_path) + content_parts.append(f"[image: {filename}]") + media_paths.append(file_path) + else: + content_parts.append("[image: download failed]") + else: + content_parts.append("[image: download failed]") + + elif msg_type == "voice": + voice_info = body.get("voice", {}) + # Voice message already contains transcribed content from WeCom + voice_content = voice_info.get("content", "") + if voice_content: + content_parts.append(f"[voice] {voice_content}") + else: + content_parts.append("[voice]") + + elif msg_type == "file": + file_info = body.get("file", {}) + file_url = file_info.get("url", "") + aes_key = file_info.get("aeskey", "") + file_name = file_info.get("name", "unknown") + + if file_url and aes_key: + file_path = await self._download_and_save_media(file_url, aes_key, "file", file_name) + if file_path: + content_parts.append(f"[file: {file_name}]") + media_paths.append(file_path) + else: + content_parts.append(f"[file: {file_name}: download failed]") + else: + content_parts.append(f"[file: {file_name}: download failed]") + + elif msg_type == "mixed": + # Mixed content contains multiple message items + msg_items = body.get("mixed", {}).get("msg_item", []) + for item in msg_items: + item_type = item.get("msgtype", "") + if item_type == "text": + text = item.get("text", {}).get("content", "") + if text: + content_parts.append(text) + elif item_type == "image": + file_url = item.get("image", {}).get("url", "") + aes_key = item.get("image", {}).get("aeskey", "") + if file_url and aes_key: + file_path = await self._download_and_save_media(file_url, aes_key, "image") + if file_path: + filename = os.path.basename(file_path) + content_parts.append(f"[image: {filename}]") + media_paths.append(file_path) + else: + content_parts.append(MSG_TYPE_MAP.get(item_type, f"[{item_type}]")) + + else: + content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) + + content = "\n".join(content_parts) if content_parts else "" + + if not content: + return + + # Store frame for this chat to enable replies + self._chat_frames[chat_id] = frame + + # Forward to message bus + await self._handle_message( + sender_id=sender_id, + chat_id=chat_id, + content=content, + media=media_paths or None, + metadata={ + "message_id": msg_id, + "msg_type": msg_type, + "chat_type": chat_type, + } + ) + + except Exception as e: + logger.error("Error processing WeCom message: {}", e) + + async def _download_and_save_media( + self, + file_url: str, + aes_key: str, + media_type: str, + filename: str | None = None, + ) -> str | None: + """ + Download and decrypt media from WeCom. + + Returns: + file_path or None if download failed + """ + try: + data, fname = await self._client.download_file(file_url, aes_key) + + if not data: + logger.warning("Failed to download media from WeCom") + return None + + if len(data) > WECOM_UPLOAD_MAX_BYTES: + logger.warning( + "WeCom inbound media too large: {} bytes (max {})", + len(data), + WECOM_UPLOAD_MAX_BYTES, + ) + return None + + media_dir = get_media_dir("wecom") + if not filename: + filename = fname or f"{media_type}_{hash(file_url) % 100000}" + filename = _sanitize_filename(filename) + + file_path = media_dir / filename + await asyncio.to_thread(file_path.write_bytes, data) + logger.debug("Downloaded {} to {}", media_type, file_path) + return str(file_path) + + except Exception as e: + logger.error("Error downloading media: {}", e) + return None + + async def _upload_media_ws( + self, client: Any, file_path: str, + ) -> "tuple[str, str] | tuple[None, None]": + """Upload a local file to WeCom via WebSocket 3-step protocol (base64). + + Uses the WeCom WebSocket upload commands directly via + ``client._ws_manager.send_reply()``: + + ``aibot_upload_media_init`` → upload_id + ``aibot_upload_media_chunk`` × N (≤512 KB raw per chunk, base64) + ``aibot_upload_media_finish`` → media_id + + Returns (media_id, media_type) on success, (None, None) on failure. + """ + from wecom_aibot_sdk.utils import generate_req_id as _gen_req_id + + try: + fname = os.path.basename(file_path) + media_type = _guess_wecom_media_type(fname) + + # Read file size and data in a thread to avoid blocking the event loop + def _read_file(): + file_size = os.path.getsize(file_path) + if file_size > WECOM_UPLOAD_MAX_BYTES: + raise ValueError( + f"File too large: {file_size} bytes (max {WECOM_UPLOAD_MAX_BYTES})" + ) + with open(file_path, "rb") as f: + return file_size, f.read() + + file_size, data = await asyncio.to_thread(_read_file) + # MD5 is used for file integrity only, not cryptographic security + md5_hash = hashlib.md5(data).hexdigest() + + CHUNK_SIZE = 512 * 1024 # 512 KB raw (before base64) + mv = memoryview(data) + chunk_list = [bytes(mv[i : i + CHUNK_SIZE]) for i in range(0, file_size, CHUNK_SIZE)] + n_chunks = len(chunk_list) + del mv, data + + # Step 1: init + req_id = _gen_req_id("upload_init") + resp = await client._ws_manager.send_reply(req_id, { + "type": media_type, + "filename": fname, + "total_size": file_size, + "total_chunks": n_chunks, + "md5": md5_hash, + }, "aibot_upload_media_init") + if resp.errcode != 0: + logger.warning("WeCom upload init failed ({}): {}", resp.errcode, resp.errmsg) + return None, None + upload_id = resp.body.get("upload_id") if resp.body else None + if not upload_id: + logger.warning("WeCom upload init: no upload_id in response") + return None, None + + # Step 2: send chunks + for i, chunk in enumerate(chunk_list): + req_id = _gen_req_id("upload_chunk") + resp = await client._ws_manager.send_reply(req_id, { + "upload_id": upload_id, + "chunk_index": i, + "base64_data": base64.b64encode(chunk).decode(), + }, "aibot_upload_media_chunk") + if resp.errcode != 0: + logger.warning("WeCom upload chunk {} failed ({}): {}", i, resp.errcode, resp.errmsg) + return None, None + + # Step 3: finish + req_id = _gen_req_id("upload_finish") + resp = await client._ws_manager.send_reply(req_id, { + "upload_id": upload_id, + }, "aibot_upload_media_finish") + if resp.errcode != 0: + logger.warning("WeCom upload finish failed ({}): {}", resp.errcode, resp.errmsg) + return None, None + + media_id = resp.body.get("media_id") if resp.body else None + if not media_id: + logger.warning("WeCom upload finish: no media_id in response body={}", resp.body) + return None, None + + suffix = "..." if len(media_id) > 16 else "" + logger.debug("WeCom uploaded {} ({}) → media_id={}", fname, media_type, media_id[:16] + suffix) + return media_id, media_type + + except ValueError as e: + logger.warning("WeCom upload skipped for {}: {}", file_path, e) + return None, None + except Exception as e: + logger.error("WeCom _upload_media_ws error for {}: {}", file_path, e) + return None, None + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through WeCom.""" + if not self._client: + logger.warning("WeCom client not initialized") + return + + try: + content = (msg.content or "").strip() + is_progress = bool(msg.metadata.get("_progress")) + + # Get the stored frame for this chat + frame = self._chat_frames.get(msg.chat_id) + + # Send media files via WebSocket upload + for file_path in msg.media or []: + upload_path = file_path + if not os.path.isfile(upload_path) and not os.path.isabs(file_path): + upload_path = str(get_workspace_path() / file_path) + if not os.path.isfile(upload_path): + logger.warning("WeCom media file not found: {}", file_path) + continue + media_id, media_type = await self._upload_media_ws(self._client, upload_path) + if media_id: + if frame: + await self._client.reply(frame, { + "msgtype": media_type, + media_type: {"media_id": media_id}, + }) + else: + await self._client.send_message(msg.chat_id, { + "msgtype": media_type, + media_type: {"media_id": media_id}, + }) + logger.debug("WeCom sent {} → {}", media_type, msg.chat_id) + else: + content += f"\n[file upload failed: {os.path.basename(file_path)}]" + + if not content: + return + + if frame: + # Both progress and final messages must use reply_stream (cmd="aibot_respond_msg"). + # The plain reply() uses cmd="reply" which does not support "text" msgtype + # and causes errcode=40008 from WeCom API. + stream_id = self._generate_req_id("stream") + await self._client.reply_stream( + frame, + stream_id, + content, + finish=not is_progress, + ) + logger.debug( + "WeCom {} sent to {}", + "progress" if is_progress else "message", + msg.chat_id, + ) + else: + # No frame (e.g. cron push): proactive send only supports markdown + await self._client.send_message(msg.chat_id, { + "msgtype": "markdown", + "markdown": {"content": content}, + }) + logger.info("WeCom proactive send to {}", msg.chat_id) + + except Exception: + logger.exception("Error sending WeCom message to chat_id={}", msg.chat_id) diff --git a/frontend/src/i18n/channels.en.ts b/frontend/src/i18n/channels.en.ts index f062bc2..cb6cfb6 100644 --- a/frontend/src/i18n/channels.en.ts +++ b/frontend/src/i18n/channels.en.ts @@ -2,7 +2,6 @@ export const channelsEn = { wizardSectionTitle: 'Channel Configuration', wizardSectionDesc: 'Manage custom channels.', globalDeliveryTitle: 'Global Delivery', - globalDeliveryDesc: '`sendProgress` / `sendToolHints` are global switches and apply to all channels.', openManager: 'Manage Channels', defaultChannel: 'Default Channel', customChannel: 'Custom Channel', diff --git a/frontend/src/i18n/channels.zh-cn.ts b/frontend/src/i18n/channels.zh-cn.ts index a8a5d34..3bc1cda 100644 --- a/frontend/src/i18n/channels.zh-cn.ts +++ b/frontend/src/i18n/channels.zh-cn.ts @@ -2,7 +2,6 @@ export const channelsZhCn = { wizardSectionTitle: '渠道配置', wizardSectionDesc: '管理自定义渠道。', globalDeliveryTitle: '全局消息投递', - globalDeliveryDesc: 'sendProgress / sendToolHints 为全局开关,对所有渠道统一生效。', openManager: '管理渠道', defaultChannel: '默认渠道', customChannel: '自定义渠道', diff --git a/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx b/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx index 704467b..6cf5bb1 100644 --- a/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx +++ b/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx @@ -444,7 +444,6 @@ export function ChannelConfigModal({
{labels.globalDeliveryTitle}
-
{labels.globalDeliveryDesc}