Files
MoviePilot/app/modules/wechatclawbot/__init__.py

415 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.notification 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) -> bool:
"""停止全部微信 ClawBot 实例,并返回资源是否全部收敛。"""
return self._stop_service_instances()
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,
)