mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-11 01:49:49 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5021b2c86f | ||
|
|
412e10972f | ||
|
|
d0b1b3d7f0 | ||
|
|
f5fea25b41 | ||
|
|
68706d3d5b |
@@ -303,6 +303,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
MessageChannel.Telegram: "telegram",
|
||||
MessageChannel.Discord: "discord",
|
||||
MessageChannel.Wechat: "wechat",
|
||||
MessageChannel.WechatClawBot: "wechatclawbot",
|
||||
MessageChannel.Slack: "slack",
|
||||
MessageChannel.VoceChat: "vocechat",
|
||||
MessageChannel.SynologyChat: "synologychat",
|
||||
@@ -322,6 +323,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"telegram": "TELEGRAM_ADMINS",
|
||||
"discord": "DISCORD_ADMINS",
|
||||
"wechat": "WECHAT_ADMINS",
|
||||
"wechatclawbot": "WECHATCLAWBOT_ADMINS",
|
||||
"slack": "SLACK_ADMINS",
|
||||
"vocechat": "VOCECHAT_ADMINS",
|
||||
"synologychat": "SYNOLOGYCHAT_ADMINS",
|
||||
@@ -332,6 +334,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
"telegram": "TELEGRAM_CHAT_ID",
|
||||
"vocechat": "VOCECHAT_CHANNEL_ID",
|
||||
"wechat": "WECHAT_BOT_CHAT_ID",
|
||||
"wechatclawbot": "WECHATCLAWBOT_DEFAULT_TARGET",
|
||||
}
|
||||
|
||||
admin_key = admin_key_map.get(channel_type)
|
||||
|
||||
@@ -117,6 +117,7 @@ class AddSubscribeTool(MoviePilotTool):
|
||||
MessageChannel.Telegram: ("telegram_userid",),
|
||||
MessageChannel.Discord: ("discord_userid",),
|
||||
MessageChannel.Wechat: ("wechat_userid",),
|
||||
MessageChannel.WechatClawBot: ("wechatclawbot_userid",),
|
||||
MessageChannel.Slack: ("slack_userid",),
|
||||
MessageChannel.VoceChat: ("vocechat_userid",),
|
||||
MessageChannel.SynologyChat: ("synologychat_userid",),
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api.endpoints import login, user, webhook, message, site, subscribe, \
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
|
||||
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa, openai, anthropic, llm
|
||||
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa, openai, anthropic, llm, notification
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
||||
@@ -18,6 +18,7 @@ api_router.include_router(douban.router, prefix="/douban", tags=["douban"])
|
||||
api_router.include_router(tmdb.router, prefix="/tmdb", tags=["tmdb"])
|
||||
api_router.include_router(history.router, prefix="/history", tags=["history"])
|
||||
api_router.include_router(system.router, prefix="/system", tags=["system"])
|
||||
api_router.include_router(notification.router, prefix="/notification", tags=["notification"])
|
||||
api_router.include_router(llm.router, prefix="/llm", tags=["llm"])
|
||||
api_router.include_router(plugin.router, prefix="/plugin", tags=["plugin"])
|
||||
api_router.include_router(download.router, prefix="/download", tags=["download"])
|
||||
|
||||
238
app/api/endpoints/notification.py
Normal file
238
app/api/endpoints/notification.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.core.module import ModuleManager
|
||||
from app.db.models import User
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.modules.wechatclawbot.wechatclawbot import WechatClawBot
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _build_wechatclawbot_temp_client(
|
||||
source: Optional[str] = None,
|
||||
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
|
||||
WECHATCLAWBOT_ADMINS: Optional[str] = None,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
|
||||
):
|
||||
"""基于当前表单配置创建一个临时客户端,用于未保存时的扫码状态预览。"""
|
||||
source_name = str(source or "").strip()
|
||||
if not source_name:
|
||||
return None
|
||||
return WechatClawBot(
|
||||
name=source_name,
|
||||
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
|
||||
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
|
||||
auto_start_polling=False,
|
||||
)
|
||||
|
||||
|
||||
def _get_wechatclawbot_client(
|
||||
source: Optional[str] = None,
|
||||
fallback_source: Optional[str] = None,
|
||||
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
|
||||
WECHATCLAWBOT_ADMINS: Optional[str] = None,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
|
||||
allow_temporary: bool = False,
|
||||
):
|
||||
"""获取已加载的微信 ClawBot 客户端,必要时退回到临时客户端。"""
|
||||
module = ModuleManager().get_running_module("WechatClawBotModule")
|
||||
source_name = str(source or "").strip() or None
|
||||
fallback_name = str(fallback_source or "").strip() or None
|
||||
|
||||
if module:
|
||||
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 = module.get_config(candidate)
|
||||
if not config:
|
||||
continue
|
||||
client = module.get_instance(config.name)
|
||||
if client:
|
||||
return client, None
|
||||
else:
|
||||
client = module.get_instance()
|
||||
if client:
|
||||
return client, None
|
||||
|
||||
if allow_temporary:
|
||||
temp_client = _build_wechatclawbot_temp_client(
|
||||
source=source_name or fallback_name,
|
||||
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
|
||||
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
|
||||
)
|
||||
if temp_client:
|
||||
return temp_client, None
|
||||
|
||||
if source_name:
|
||||
return None, f"未找到名为 {source_name} 的微信 ClawBot 通知配置"
|
||||
return None, "微信 ClawBot 通知未启用或配置尚未保存,请先保存并启用当前渠道"
|
||||
|
||||
|
||||
@router.get(
|
||||
"/wechatclawbot/status",
|
||||
summary="查询微信 ClawBot 登录状态",
|
||||
response_model=schemas.Response,
|
||||
)
|
||||
def wechatclawbot_status(
|
||||
source: Optional[str] = None,
|
||||
fallback_source: Optional[str] = None,
|
||||
refresh_remote: bool = True,
|
||||
auto_generate_qrcode: bool = True,
|
||||
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
|
||||
WECHATCLAWBOT_ADMINS: Optional[str] = None,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""查询微信 ClawBot 登录状态和二维码。"""
|
||||
client, errmsg = _get_wechatclawbot_client(
|
||||
source=source,
|
||||
fallback_source=fallback_source,
|
||||
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
|
||||
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
|
||||
allow_temporary=True,
|
||||
)
|
||||
if not client:
|
||||
return schemas.Response(success=False, message=errmsg)
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
data=client.get_status(
|
||||
refresh_remote=refresh_remote,
|
||||
auto_generate_qrcode=auto_generate_qrcode,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/wechatclawbot/refresh",
|
||||
summary="刷新微信 ClawBot 二维码",
|
||||
response_model=schemas.Response,
|
||||
)
|
||||
def refresh_wechatclawbot_qrcode(
|
||||
source: Optional[str] = None,
|
||||
fallback_source: Optional[str] = None,
|
||||
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
|
||||
WECHATCLAWBOT_ADMINS: Optional[str] = None,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""刷新微信 ClawBot 二维码。"""
|
||||
client, errmsg = _get_wechatclawbot_client(
|
||||
source=source,
|
||||
fallback_source=fallback_source,
|
||||
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
|
||||
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
|
||||
allow_temporary=True,
|
||||
)
|
||||
if not client:
|
||||
return schemas.Response(success=False, message=errmsg)
|
||||
result = client.refresh_qrcode()
|
||||
return schemas.Response(
|
||||
success=bool(result.get("success")),
|
||||
message=result.get("message"),
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/wechatclawbot/logout",
|
||||
summary="退出微信 ClawBot 登录",
|
||||
response_model=schemas.Response,
|
||||
)
|
||||
def logout_wechatclawbot(
|
||||
source: Optional[str] = None,
|
||||
fallback_source: Optional[str] = None,
|
||||
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
|
||||
WECHATCLAWBOT_ADMINS: Optional[str] = None,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""退出微信 ClawBot 登录。"""
|
||||
client, errmsg = _get_wechatclawbot_client(
|
||||
source=source,
|
||||
fallback_source=fallback_source,
|
||||
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
|
||||
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
|
||||
allow_temporary=True,
|
||||
)
|
||||
if not client:
|
||||
return schemas.Response(success=False, message=errmsg)
|
||||
result = client.logout()
|
||||
return schemas.Response(
|
||||
success=bool(result.get("success")),
|
||||
message=result.get("message"),
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/wechatclawbot/test",
|
||||
summary="测试微信 ClawBot 连通性",
|
||||
response_model=schemas.Response,
|
||||
)
|
||||
def test_wechatclawbot(
|
||||
source: Optional[str] = None,
|
||||
fallback_source: Optional[str] = None,
|
||||
WECHATCLAWBOT_BASE_URL: Optional[str] = None,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET: Optional[str] = None,
|
||||
WECHATCLAWBOT_ADMINS: Optional[str] = None,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT: Optional[int] = None,
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""测试微信 ClawBot 当前登录态是否可用。"""
|
||||
client, errmsg = _get_wechatclawbot_client(
|
||||
source=source,
|
||||
fallback_source=fallback_source,
|
||||
WECHATCLAWBOT_BASE_URL=WECHATCLAWBOT_BASE_URL,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET=WECHATCLAWBOT_DEFAULT_TARGET,
|
||||
WECHATCLAWBOT_ADMINS=WECHATCLAWBOT_ADMINS,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT=WECHATCLAWBOT_POLL_TIMEOUT,
|
||||
allow_temporary=True,
|
||||
)
|
||||
if not client:
|
||||
return schemas.Response(success=False, message=errmsg)
|
||||
state, message = client.test_connection()
|
||||
return schemas.Response(success=state, message=message)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/wechatclawbot/migrate",
|
||||
summary="迁移微信 ClawBot 登录缓存",
|
||||
response_model=schemas.Response,
|
||||
)
|
||||
def migrate_wechatclawbot_cache(
|
||||
old_source: str,
|
||||
new_source: str,
|
||||
cleanup_old: bool = False,
|
||||
overwrite: bool = False,
|
||||
_: User = Depends(get_current_active_superuser),
|
||||
):
|
||||
"""在通知名称变更时迁移对应的微信 ClawBot 登录缓存。"""
|
||||
success, message = WechatClawBot.migrate_cached_state(
|
||||
old_name=old_source,
|
||||
new_name=new_source,
|
||||
cleanup_old=cleanup_old,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
return schemas.Response(success=success, message=message)
|
||||
@@ -1139,6 +1139,15 @@ class MessageChain(ChainBase):
|
||||
source=source,
|
||||
)
|
||||
filename = "input.amr"
|
||||
elif audio_ref.startswith("wxclaw://voice/"):
|
||||
content = self.run_module(
|
||||
"download_wechat_media_bytes",
|
||||
media_ref=audio_ref,
|
||||
source=source,
|
||||
)
|
||||
filename = self._guess_audio_filename(
|
||||
audio_ref, default="input.amr"
|
||||
)
|
||||
elif audio_ref.startswith("slack://file/"):
|
||||
content = self.run_module(
|
||||
"download_slack_file_bytes", file_ref=audio_ref, source=source
|
||||
@@ -1270,6 +1279,8 @@ class MessageChain(ChainBase):
|
||||
"wxwork://media_id/"
|
||||
) or attachment_ref.startswith(
|
||||
"wxbot://image/"
|
||||
) or attachment_ref.startswith(
|
||||
"wxclaw://image/"
|
||||
):
|
||||
data_url = self.run_module(
|
||||
"download_wechat_image_to_data_url",
|
||||
@@ -1438,10 +1449,19 @@ class MessageChain(ChainBase):
|
||||
"download_wechat_image_to_data_url", image_ref=file_ref, source=source
|
||||
)
|
||||
return self._decode_data_url_bytes(data_url) if data_url else None
|
||||
if file_ref.startswith("wxclaw://image/"):
|
||||
data_url = self.run_module(
|
||||
"download_wechat_image_to_data_url", image_ref=file_ref, source=source
|
||||
)
|
||||
return self._decode_data_url_bytes(data_url) if data_url else None
|
||||
if file_ref.startswith("wxbot://file/"):
|
||||
file_url = unquote(file_ref.replace("wxbot://file/", "", 1))
|
||||
resp = RequestUtils(timeout=30).get_res(file_url)
|
||||
return resp.content if resp and resp.content else None
|
||||
if file_ref.startswith("wxclaw://file/") or file_ref.startswith("wxclaw://voice/"):
|
||||
return self.run_module(
|
||||
"download_wechat_media_bytes", media_ref=file_ref, source=source
|
||||
)
|
||||
if file_ref.startswith("slack://file/"):
|
||||
return self.run_module(
|
||||
"download_slack_file_bytes", file_ref=file_ref, source=source
|
||||
|
||||
@@ -31,7 +31,7 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "微信"
|
||||
return "企业微信"
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
|
||||
296
app/modules/wechatclawbot/__init__.py
Normal file
296
app/modules/wechatclawbot/__init__.py
Normal file
@@ -0,0 +1,296 @@
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from app.core.cache import TTLCache
|
||||
from app.core.context import Context, MediaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _MessageBase, _ModuleBase
|
||||
from app.modules.wechatclawbot.wechatclawbot import WechatClawBot
|
||||
from app.schemas import CommingMessage, Notification
|
||||
from app.schemas.types import MessageChannel, ModuleType
|
||||
|
||||
|
||||
class WechatClawBotModule(_ModuleBase, _MessageBase[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:
|
||||
"""初始化模块。"""
|
||||
self.stop()
|
||||
super().init_service(
|
||||
service_name=WechatClawBot.__name__.lower(), service_type=WechatClawBot
|
||||
)
|
||||
self._channel = MessageChannel.WechatClawBot
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "微信 ClawBot"
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""获取模块类型。"""
|
||||
return ModuleType.Notification
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MessageChannel:
|
||||
"""获取模块子类型。"""
|
||||
return MessageChannel.WechatClawBot
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""获取模块优先级。"""
|
||||
return 2
|
||||
|
||||
def stop(self):
|
||||
"""停止模块。"""
|
||||
for client in self.get_instances().values():
|
||||
if hasattr(client, "stop"):
|
||||
try:
|
||||
client.stop()
|
||||
except Exception as err:
|
||||
logger.error(f"停止微信 ClawBot 模块实例失败:{err}")
|
||||
|
||||
def test(self) -> Optional[Tuple[bool, str]]:
|
||||
"""测试模块连接性。"""
|
||||
if not self.get_instances():
|
||||
return None
|
||||
for name, client in self.get_instances().items():
|
||||
state, message = client.test_connection()
|
||||
if not state:
|
||||
return False, f"微信 ClawBot {name} 未就绪:{message}"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _load_json(body: Any) -> Optional[dict]:
|
||||
if isinstance(body, dict):
|
||||
payload = body
|
||||
elif isinstance(body, bytes):
|
||||
payload = json.loads(body.decode("utf-8", errors="ignore"))
|
||||
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[CommingMessage.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(
|
||||
CommingMessage.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[CommingMessage]:
|
||||
"""解析微信 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 = CommingMessage.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()
|
||||
]
|
||||
if text.startswith("/") and admins and user_id not in admins:
|
||||
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 CommingMessage(
|
||||
channel=MessageChannel.WechatClawBot,
|
||||
source=client_config.name,
|
||||
userid=user_id,
|
||||
username=username,
|
||||
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: Notification, **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: Notification, 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: Notification, 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,
|
||||
)
|
||||
|
||||
def register_commands(self, commands: Dict[str, dict]):
|
||||
"""微信 ClawBot 不支持原生菜单命令,统一走文本交互。"""
|
||||
logger.debug("微信 ClawBot 不支持原生菜单命令,跳过命令注册")
|
||||
2122
app/modules/wechatclawbot/wechatclawbot.py
Normal file
2122
app/modules/wechatclawbot/wechatclawbot.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -324,6 +324,17 @@ class ChannelCapabilityManager:
|
||||
},
|
||||
fallback_enabled=True,
|
||||
),
|
||||
MessageChannel.WechatClawBot: ChannelCapabilities(
|
||||
channel=MessageChannel.WechatClawBot,
|
||||
capabilities={
|
||||
ChannelCapability.MARKDOWN,
|
||||
ChannelCapability.IMAGES,
|
||||
ChannelCapability.LINKS,
|
||||
ChannelCapability.FILE_SENDING,
|
||||
},
|
||||
max_message_length=2800,
|
||||
fallback_enabled=True,
|
||||
),
|
||||
MessageChannel.Slack: ChannelCapabilities(
|
||||
channel=MessageChannel.Slack,
|
||||
capabilities={
|
||||
|
||||
@@ -305,6 +305,7 @@ class MessageChannel(Enum):
|
||||
消息渠道
|
||||
"""
|
||||
Wechat = "微信"
|
||||
WechatClawBot = "微信ClawBot"
|
||||
Telegram = "Telegram"
|
||||
Slack = "Slack"
|
||||
Discord = "Discord"
|
||||
|
||||
62
tests/test_wechatclawbot.py
Normal file
62
tests/test_wechatclawbot.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import json
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.modules.wechatclawbot import WechatClawBotModule
|
||||
from app.modules.wechatclawbot.wechatclawbot import ILinkClient
|
||||
|
||||
|
||||
class WechatClawBotTest(unittest.TestCase):
|
||||
def test_ilink_parse_incoming_uses_seq_as_message_id_fallback(self):
|
||||
client = ILinkClient(base_url="https://ilinkai.weixin.qq.com")
|
||||
|
||||
message = client._parse_incoming(
|
||||
{
|
||||
"seq": 123456,
|
||||
"from_user_id": "wxid_user_1",
|
||||
"item_list": [{"type": 1, "text_item": {"text": "你好"}}],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertIsNotNone(message)
|
||||
self.assertEqual(message.message_id, "123456")
|
||||
self.assertEqual(message.text, "你好")
|
||||
|
||||
def test_wechatclawbot_message_parser_deduplicates_message_id(self):
|
||||
module = WechatClawBotModule()
|
||||
body = json.dumps(
|
||||
{
|
||||
"__channel__": "wechatclawbot",
|
||||
"userid": "wxid_user_1",
|
||||
"username": "tester",
|
||||
"message_id": "msg-1001",
|
||||
"text": "刷新订阅",
|
||||
}
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
module,
|
||||
"get_config",
|
||||
return_value=SimpleNamespace(name="wechatclawbot-test", config={}),
|
||||
):
|
||||
first = module.message_parser(
|
||||
source="wechatclawbot-test",
|
||||
body=body,
|
||||
form={},
|
||||
args={},
|
||||
)
|
||||
second = module.message_parser(
|
||||
source="wechatclawbot-test",
|
||||
body=body,
|
||||
form={},
|
||||
args={},
|
||||
)
|
||||
|
||||
self.assertIsNotNone(first)
|
||||
self.assertEqual(first.message_id, "msg-1001")
|
||||
self.assertIsNone(second)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user