import json from typing import Any, Dict, List, Optional, Tuple, Union from app.runtime.cache import TTLCache from app.domain.context import Context, MediaInfo from app.application.messaging.agent import ( matches_channel_admin, register_channel_admin_resolver, resolve_config_principal_ids, ) from app.runtime.log import logger from app.modules._base import _MessageChannelModuleBase from app.modules.wechatclawbot.wechatclawbot import WechatClawBot from app.schemas.message import IncomingMessage from app.schemas.message import Message from app.schemas.types import NotificationChannel, ModuleType, NotificationAction register_channel_admin_resolver( NotificationChannel.WechatClawBot, lambda config: resolve_config_principal_ids( config, "WECHATCLAWBOT_ADMINS", "WECHATCLAWBOT_DEFAULT_TARGET" ), ) class WechatClawBotModule(_MessageChannelModuleBase[WechatClawBot]): def __init__(self): """初始化模块级去重缓存,拦截 iLink 偶发的重复回放消息。""" super().__init__() # iLink 偶发会重复回放同一条 update,这里按 message_id 做渠道内幂等保护。 self._recent_message_ids = TTLCache( region="wechatclawbot_message_dedup", maxsize=8192, ttl=7 * 24 * 60 * 60, ) def init_module(self) -> None: """初始化模块。""" super().init_service( service_name=WechatClawBot.__name__.lower(), service_type=WechatClawBot ) self._channel = NotificationChannel.WechatClawBot @staticmethod def get_name() -> str: """获取模块名称。""" return "微信 ClawBot" @staticmethod def get_type() -> ModuleType: """获取模块类型。""" return ModuleType.Notification @staticmethod def get_subtype() -> NotificationChannel: """获取模块子类型。""" return NotificationChannel.WechatClawBot @staticmethod def get_priority() -> int: """获取模块优先级。""" return 2 def _commands_enabled(self, config: Optional[dict]) -> bool: """ 微信爪爪机器人客户端未提供命令注册/删除 API,跳过命令注册, 避免基类默认钩子调用不存在的 client.register_commands。 """ return False def stop(self) -> None: """停止模块""" for client in self.get_instances().values(): try: client.stop() except Exception as err: logger.error(f"停止微信 ClawBot 模块实例失败:{err}") def _test_connection(self, client) -> Tuple[bool, str]: """微信 ClawBot 的连接探测返回 (状态, 信息)。""" return client.test_connection() def init_setting(self) -> Tuple[str, Union[str, bool]]: """初始化模块设置。""" pass def channel_manage( self, channel: NotificationChannel, action: NotificationAction, **params: Any, ) -> Optional[Dict[str, Any]]: """通知渠道通用管理入口,按渠道名路由,仅处理本渠道。 动作语义与表单参数全部由模块自行解释:优先使用已保存配置实例, 无匹配配置时可基于表单参数构造临时实例(未保存配置的扫码预览)。 统一返回 {"success": bool, "message": ..., "data": ...} 结构。 """ # 路由标识归一化:兼容枚举名、枚举值与原始枚举对象 if isinstance(channel, str) and channel not in (self.get_subtype().name, self.get_subtype().value): return None if not isinstance(channel, str) and channel != self.get_subtype(): return None try: action = NotificationAction(action) except ValueError: return {"success": False, "message": f"不支持的渠道管理动作:{action}"} if action == NotificationAction.MIGRATE_CACHE: success, message = WechatClawBot.migrate_cached_state( old_name=params.get("old_name"), new_name=params.get("new_name"), cleanup_old=bool(params.get("cleanup_old")), overwrite=bool(params.get("overwrite")), ) return {"success": success, "message": message} client, errmsg = self._resolve_client(params) if not client: return {"success": False, "message": errmsg} if action == NotificationAction.STATUS: data = client.get_status( refresh_remote=bool(params.get("refresh_remote", True)), auto_generate_qrcode=bool(params.get("auto_generate_qrcode", True)), ) return {"success": bool(data.get("success")), "message": data.get("message"), "data": data} if action == NotificationAction.REFRESH_QRCODE: data = client.refresh_qrcode() return {"success": bool(data.get("success")), "message": data.get("message"), "data": data} if action == NotificationAction.LOGOUT: data = client.logout() return {"success": bool(data.get("success")), "message": data.get("message"), "data": data} if action == NotificationAction.TEST_CONNECTION: state, message = client.test_connection() return {"success": state, "message": message} return {"success": False, "message": f"不支持的渠道管理动作:{action.value}"} def _resolve_client(self, params: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]: """解析微信 ClawBot 客户端实例,返回 (客户端, 错误信息)。 优先使用已加载的配置实例,均无配置时退回到基于表单参数的临时客户端, 用于未保存配置的扫码状态预览。 """ source_name = str(params.get("source") or "").strip() or None fallback_name = str(params.get("fallback_source") or "").strip() or None candidate_names = [] for candidate in (fallback_name, source_name): if candidate and candidate not in candidate_names: candidate_names.append(candidate) if candidate_names: for candidate in candidate_names: config = self.get_config(candidate) if not config: continue client = self.get_instance(config.name) if client: return client, None else: client = self.get_instance() if client: return client, None temp_client = self._build_temp_client(params) if temp_client: return temp_client, None if source_name: return None, f"未找到名为 {source_name} 的微信 ClawBot 通知配置" return None, "微信 ClawBot 通知未启用或配置尚未保存,请先保存并启用当前渠道" def _build_temp_client(self, params: Dict[str, Any]) -> Optional[Any]: """基于表单参数创建临时客户端,用于未保存配置时的扫码状态预览。""" source_name = str(params.get("source") or params.get("fallback_source") or "").strip() if not source_name: return None return WechatClawBot( name=source_name, WECHATCLAWBOT_BASE_URL=params.get("WECHATCLAWBOT_BASE_URL"), WECHATCLAWBOT_DEFAULT_TARGET=params.get("WECHATCLAWBOT_DEFAULT_TARGET"), WECHATCLAWBOT_ADMINS=params.get("WECHATCLAWBOT_ADMINS"), WECHATCLAWBOT_POLL_TIMEOUT=params.get("WECHATCLAWBOT_POLL_TIMEOUT"), auto_start_polling=False, ) @staticmethod def _load_json(body: Any) -> Optional[dict]: """将内容解析为 JSON 字典。""" if isinstance(body, dict): payload = body elif isinstance(body, bytes): payload = json.loads(body.decode("utf-8", errors="replace")) else: payload = json.loads(body) while isinstance(payload, str): payload = json.loads(payload) return payload if isinstance(payload, dict) else None @staticmethod def _normalize_audio_refs(audio_refs: Any) -> Optional[List[str]]: """标准化音频引用列表。""" if not audio_refs: return None if not isinstance(audio_refs, list): audio_refs = [audio_refs] normalized = [str(item).strip() for item in audio_refs if str(item).strip()] return normalized or None @staticmethod def _normalize_files(files: Any) -> Optional[List[IncomingMessage.MessageAttachment]]: """标准化文件附件列表。""" if not files: return None if not isinstance(files, list): files = [files] normalized = [] for item in files: if not isinstance(item, dict): continue ref = item.get("ref") or item.get("url") or item.get("file_url") if not ref: continue size = item.get("size") try: size = int(size) if size is not None else None except (TypeError, ValueError): size = None normalized.append( IncomingMessage.MessageAttachment( ref=ref, name=item.get("name") or item.get("filename"), mime_type=item.get("mime_type") or item.get("content_type"), size=size, ) ) return normalized or None def _is_duplicate_message( self, source: str, message_id: Optional[Union[str, int]] ) -> bool: """按渠道名和消息ID判断是否重复,避免重复回放再次进入业务链路。""" if message_id in (None, ""): return False cache_key = f"{source}:{message_id}" if self._recent_message_ids.exists(cache_key): return True self._recent_message_ids.set(cache_key, True) return False def message_parser( self, source: str, body: Any, form: Any, args: Any ) -> Optional[IncomingMessage]: """解析微信 ClawBot 转发到消息入口的 JSON 报文。""" client_config = self.get_config(source) if not client_config: return None try: message = self._load_json(body) except Exception as err: logger.debug(f"解析微信 ClawBot 消息失败:{err}") return None if not message: return None channel_name = (message.get("__channel__") or "").strip().lower() if channel_name and channel_name != "wechatclawbot": return None user_id = str(message.get("userid") or "").strip() if not user_id: return None message_id = message.get("message_id") text = str(message.get("text") or "").strip() username = str(message.get("username") or user_id).strip() or user_id images = IncomingMessage.MessageImage.normalize_list(message.get("images")) audio_refs = self._normalize_audio_refs(message.get("audio_refs")) files = self._normalize_files(message.get("files")) if not text and not images and not audio_refs and not files: return None if self._is_duplicate_message(client_config.name, message_id): logger.info( "忽略重复的微信 ClawBot 消息:source=%s, userid=%s, message_id=%s", client_config.name, user_id, message_id, ) return None admins = [ admin.strip() for admin in str(client_config.config.get("WECHATCLAWBOT_ADMINS") or "").split(",") if admin.strip() ] callback_data = text[9:].strip() if text.startswith("CALLBACK:") else "" is_admin_command = text.startswith("/") or callback_data.startswith("/") is_channel_admin = matches_channel_admin( NotificationChannel.WechatClawBot, client_config.config, user_id, ) if is_admin_command and admins and not is_channel_admin: client = self.get_instance(client_config.name) if client: client.send_msg(title="只有管理员才有权限执行此命令", userid=user_id) return None logger.info( f"收到来自 {client_config.name} 的微信 ClawBot 消息:" f"userid={user_id}, message_id={message_id}, text={text}, " f"images={len(images) if images else 0}, " f"audios={len(audio_refs) if audio_refs else 0}, files={len(files) if files else 0}" ) return IncomingMessage( channel=NotificationChannel.WechatClawBot, source=client_config.name, userid=user_id, username=username, is_channel_admin=is_channel_admin, text=text, message_id=message_id, chat_id=str(message.get("chat_id") or "") or None, images=images, audio_refs=audio_refs, files=files, ) def post_message(self, message: Message, **kwargs) -> None: """发送消息。""" for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue targets = message.targets userid = message.userid if not userid and targets is not None: userid = targets.get("wechatclawbot_userid") if not userid: logger.warning("用户没有指定 微信 ClawBot 用户ID,消息无法发送") return client: WechatClawBot = self.get_instance(conf.name) if not client: continue if message.file_path: client.send_file( file_path=message.file_path, file_name=message.file_name, title=message.title, text=message.text, userid=userid, ) elif message.voice_path: client.send_file( file_path=message.voice_path, title=message.voice_caption or message.title, text=message.text, userid=userid, ) else: client.send_msg( title=message.title or "", text=message.text, image=message.image, userid=userid, link=message.link, ) def download_wechat_image_to_data_url( self, image_ref: str, source: str ) -> Optional[str]: """下载微信 ClawBot 图片并转换为 data URL。""" if not image_ref or not image_ref.startswith("wxclaw://image/"): return None client_config = self.get_config(source) if not client_config: return None client = self.get_instance(client_config.name) if not client: return None return client.download_image_to_data_url(image_ref) def download_wechat_media_bytes( self, media_ref: str, source: str ) -> Optional[bytes]: """下载微信 ClawBot 语音或文件附件。""" if not media_ref or not media_ref.startswith(("wxclaw://file/", "wxclaw://voice/")): return None client_config = self.get_config(source) if not client_config: return None client = self.get_instance(client_config.name) if not client: return None return client.download_media_bytes(media_ref) def post_medias_message(self, message: Message, medias: List[MediaInfo]) -> None: """发送媒体选择列表。""" for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: WechatClawBot = self.get_instance(conf.name) if client: client.send_medias_msg(medias=medias, userid=message.userid) def post_torrents_message(self, message: Message, torrents: List[Context]) -> None: """发送种子选择列表。""" for conf in self.get_configs().values(): if not self.check_message(message, conf.name): continue client: WechatClawBot = self.get_instance(conf.name) if client: client.send_torrents_msg( torrents=torrents, userid=message.userid, title=message.title, link=message.link, )