Compare commits

..

5 Commits
v2.11.0 ... v2

11 changed files with 2757 additions and 2 deletions

View File

@@ -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)

View File

@@ -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",),

View File

@@ -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"])

View 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)

View File

@@ -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

View File

@@ -31,7 +31,7 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]):
@staticmethod
def get_name() -> str:
return "微信"
return "企业微信"
@staticmethod
def get_type() -> ModuleType:

View 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 不支持原生菜单命令,跳过命令注册")

File diff suppressed because it is too large Load Diff

View File

@@ -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={

View File

@@ -305,6 +305,7 @@ class MessageChannel(Enum):
消息渠道
"""
Wechat = "微信"
WechatClawBot = "微信ClawBot"
Telegram = "Telegram"
Slack = "Slack"
Discord = "Discord"

View 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()