mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 12:06:51 +08:00
415 lines
16 KiB
Python
415 lines
16 KiB
Python
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) -> 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,
|
|
)
|