Files
MoviePilot/app/chain/_messaging.py
jxxghp 7e851dbfa7 refactor(chain): 处理链功能域 mixin 化,清理未使用导入并根治兼容层循环导入
- ChainBase 拆分为 RecognitionMixin/MessageProcessingMixin/NotificationMixin
- TransferChain 拆分为 7 个功能 mixin(_mixins.py),SubscribeChain 音乐订阅域拆出 _music.py
- 斜杠命令交互四件套收敛为 InteractionChainMixin 委托,会话管理器移至 application 层,chain 层不再 re-export
- 模块基础类收敛到 app/modules/_base(notification/mediaserver 语义重命名)
- 清理 app/chain/__init__.py 24 个未使用导入,修正 49 处测试 patch 目标到实际命名空间
- 兼容层 legacy 符号不再并入 __all__,根治 schemas 初始化反向拉起 application.transfer 的循环导入
- 修复 bangumi 集数为字符串时 set_bangumi_info 抛 TypeError
- 新增重复代码等架构门禁测试;capability 清单校验排除下划线内部目录
2026-08-16 16:30:16 +08:00

487 lines
20 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.
"""消息处理与通知发送 mixin。
从 ChainBase 拆出的消息域:渠道输入状态机、通知派发规范化、消息渲染、
隔离路由与队列发送。方法经 MRO 解析,依赖 ChainBase 实例的 run_module、
eventmanager、messageoper、messagequeue 等协作对象。
"""
import copy
from datetime import datetime
from typing import Any, Dict, List, Optional, Union
from app.db.oper.user import UserOper
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
from app.domain.meta.metabase import MetaBase
from app.foundation.identity import normalize_internal_user_id
from app.application.messaging.message import MessageTemplateHelper
from app.runtime.config import settings
from app.runtime.extensions.service_registry import ServiceConfigHelper
from app.runtime.log import logger
from app.schemas import MessageResponse, Notification, TransferInfo
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import EventType, MessageChannel
class MessageProcessingMixin:
"""消息输入/处理状态机与通知派发规范化。"""
def start_message_processing_status(
self,
channel: MessageChannel,
source: Optional[str],
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
text: Optional[str] = None,
) -> Optional[dict]:
"""
启动渠道侧消息输入/处理状态。
具体表现由消息模块实现,例如 typing 保活或消息 reaction。
"""
if not channel or not ChannelCapabilityManager.supports_capability(
channel, ChannelCapability.PROCESSING_STATUS
):
return None
try:
status = self.run_module(
"mark_message_processing_started",
channel=channel,
source=source,
userid=userid,
message_id=message_id,
chat_id=chat_id,
text=text,
)
except Exception as err:
logger.debug(f"启动消息处理状态失败: {err}")
return None
return status if isinstance(status, dict) else None
def finish_message_processing_status(
self,
status: Optional[dict] = None,
channel: Optional[MessageChannel] = None,
source: Optional[str] = None,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
) -> None:
"""
结束渠道侧消息输入/处理状态。
优先使用 start 返回的 status缺失时使用显式渠道和消息定位参数。
"""
target_channel = channel
if status:
try:
target_channel = MessageChannel(status.get("channel"))
except Exception:
target_channel = channel
if not target_channel or not ChannelCapabilityManager.supports_capability(
target_channel, ChannelCapability.PROCESSING_STATUS
):
return
try:
self.run_module(
"mark_message_processing_finished",
channel=target_channel,
source=(status or {}).get("source") or source,
userid=(status or {}).get("userid") or userid,
message_id=(status or {}).get("message_id") or message_id,
chat_id=(status or {}).get("chat_id") or chat_id,
status=status,
)
except Exception as err:
logger.debug(f"结束消息处理状态失败: {err}")
@staticmethod
def _normalize_notification_for_dispatch(
message: Notification
) -> Notification:
"""
规范化待发送的通知消息。
后台任务会复用内部占位用户ID作为会话身份这里在真正发送前清空
让消息重新走默认通知路由或基于 targets 的目标解析。
"""
dispatch_message = copy.deepcopy(message)
dispatch_message.userid = normalize_internal_user_id(
dispatch_message.userid
)
return dispatch_message
@staticmethod
def _build_notice_message_data(message: Notification) -> dict:
"""
构造消息通知事件数据。
"""
return {**message.model_dump(exclude={"save_history"}), "type": message.mtype}
class NotificationMixin:
"""通知消息发送域:渲染、隔离路由、队列发送与消息编辑。"""
def post_message(
self,
message: Optional[Notification] = None,
meta: Optional[MetaBase] = None,
mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None,
torrentinfo: Optional[TorrentInfo] = None,
transferinfo: Optional[TransferInfo] = None,
**kwargs,
) -> None:
"""
发送消息
:param message: Notification实例
:param meta: 元数据
:param mediainfo: 媒体信息
:param torrentinfo: 种子信息
:param transferinfo: 文件整理信息
:param kwargs: 其他参数(覆盖业务对象属性值)
:return: 成功或失败
"""
# 添加格式化的时间参数
kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
# 渲染消息
message = MessageTemplateHelper.render(
message=message,
meta=meta,
mediainfo=mediainfo,
torrentinfo=torrentinfo,
transferinfo=transferinfo,
**kwargs,
)
# 检查消息是否有效
if not message:
logger.warning("消息为空,跳过发送")
return
if message.save_history:
self.messageoper.add(**message.model_dump())
dispatch_message = self._normalize_notification_for_dispatch(message)
# 发送消息按设置隔离
if not dispatch_message.userid and dispatch_message.mtype:
# 消息隔离设置
notify_action = ServiceConfigHelper.get_notification_switch(
dispatch_message.mtype
)
if notify_action:
# 'admin' 'user,admin' 'user' 'all'
actions = notify_action.split(",")
# 是否已发送管理员标志
admin_sended = False
send_orignal = False
useroper = UserOper()
for action in actions:
send_message = copy.deepcopy(dispatch_message)
if action == "admin" and not admin_sended:
# 仅发送管理员
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
# 读取管理员消息IDS
send_message.targets = useroper.get_settings(settings.SUPERUSER)
admin_sended = True
elif action == "user" and send_message.username:
# 发送对应用户
logger.info(
f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}"
)
# 读取用户消息IDS
send_message.targets = useroper.get_settings(
send_message.username
)
if send_message.targets is None:
# 没有找到用户
if not admin_sended:
# 回滚发送管理员
logger.info(
f"用户 {send_message.username} 不存在,消息将发送给管理员"
)
# 读取管理员消息IDS
send_message.targets = useroper.get_settings(
settings.SUPERUSER
)
admin_sended = True
else:
# 管理员发过了,此消息不发了
logger.info(
f"用户 {send_message.username} 不存在,消息无法发送到对应用户"
)
continue
elif send_message.username == settings.SUPERUSER:
# 管理员同名已发送
admin_sended = True
else:
# 按原消息发送全体
if not admin_sended:
send_orignal = True
break
# 按设定发送
self.eventmanager.send_event(
etype=EventType.NoticeMessage,
data=self._build_notice_message_data(send_message),
)
self.messagequeue.send_message(
"post_message", message=send_message, **kwargs
)
if not send_orignal:
return
# 发送消息事件
self.eventmanager.send_event(
etype=EventType.NoticeMessage,
data=self._build_notice_message_data(dispatch_message),
)
# 按原消息发送
self.messagequeue.send_message(
"post_message",
message=dispatch_message,
immediately=True if dispatch_message.userid else False,
**kwargs,
)
async def async_post_message(
self,
message: Optional[Notification] = None,
meta: Optional[MetaBase] = None,
mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None,
torrentinfo: Optional[TorrentInfo] = None,
transferinfo: Optional[TransferInfo] = None,
**kwargs,
) -> None:
"""
异步发送消息
:param message: Notification实例
:param meta: 元数据
:param mediainfo: 媒体信息
:param torrentinfo: 种子信息
:param transferinfo: 文件整理信息
:param kwargs: 其他参数(覆盖业务对象属性值)
:return: 成功或失败
"""
# 添加格式化的时间参数
kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
# 渲染消息
message = MessageTemplateHelper.render(
message=message,
meta=meta,
mediainfo=mediainfo,
torrentinfo=torrentinfo,
transferinfo=transferinfo,
**kwargs,
)
# 检查消息是否有效
if not message:
logger.warning("消息为空,跳过发送")
return
if message.save_history:
await self.messageoper.async_add(**message.model_dump())
dispatch_message = self._normalize_notification_for_dispatch(message)
# 发送消息按设置隔离
if not dispatch_message.userid and dispatch_message.mtype:
# 消息隔离设置
notify_action = ServiceConfigHelper.get_notification_switch(
dispatch_message.mtype
)
if notify_action:
# 'admin' 'user,admin' 'user' 'all'
actions = notify_action.split(",")
# 是否已发送管理员标志
admin_sended = False
send_orignal = False
useroper = UserOper()
for action in actions:
send_message = copy.deepcopy(dispatch_message)
if action == "admin" and not admin_sended:
# 仅发送管理员
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
# 读取管理员消息IDS
send_message.targets = useroper.get_settings(settings.SUPERUSER)
admin_sended = True
elif action == "user" and send_message.username:
# 发送对应用户
logger.info(
f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}"
)
# 读取用户消息IDS
send_message.targets = useroper.get_settings(
send_message.username
)
if send_message.targets is None:
# 没有找到用户
if not admin_sended:
# 回滚发送管理员
logger.info(
f"用户 {send_message.username} 不存在,消息将发送给管理员"
)
# 读取管理员消息IDS
send_message.targets = useroper.get_settings(
settings.SUPERUSER
)
admin_sended = True
else:
# 管理员发过了,此消息不发了
logger.info(
f"用户 {send_message.username} 不存在,消息无法发送到对应用户"
)
continue
elif send_message.username == settings.SUPERUSER:
# 管理员同名已发送
admin_sended = True
else:
# 按原消息发送全体
if not admin_sended:
send_orignal = True
break
# 按设定发送
await self.eventmanager.async_send_event(
etype=EventType.NoticeMessage,
data=self._build_notice_message_data(send_message),
)
await self.messagequeue.async_send_message(
"post_message", message=send_message, **kwargs
)
if not send_orignal:
return
# 发送消息事件
await self.eventmanager.async_send_event(
etype=EventType.NoticeMessage,
data=self._build_notice_message_data(dispatch_message),
)
# 按原消息发送
await self.messagequeue.async_send_message(
"post_message",
message=dispatch_message,
immediately=True if dispatch_message.userid else False,
**kwargs,
)
def post_medias_message(
self, message: Notification, medias: List[MediaInfo]
) -> None:
"""
发送媒体信息选择列表
:param message: 消息体
:param medias: 媒体列表
:return: 成功或失败
"""
note_list = [media.to_dict() for media in medias]
if message.save_history:
self.messageoper.add(**message.model_dump(), note=note_list)
dispatch_message = self._normalize_notification_for_dispatch(message)
return self.messagequeue.send_message(
"post_medias_message",
message=dispatch_message,
medias=medias,
immediately=True if dispatch_message.userid else False,
)
def post_torrents_message(
self, message: Notification, torrents: List[Context]
) -> None:
"""
发送种子信息选择列表
:param message: 消息体
:param torrents: 种子列表
:return: 成功或失败
"""
note_list = [torrent.torrent_info.to_dict() for torrent in torrents]
if message.save_history:
self.messageoper.add(**message.model_dump(), note=note_list)
dispatch_message = self._normalize_notification_for_dispatch(message)
return self.messagequeue.send_message(
"post_torrents_message",
message=dispatch_message,
torrents=torrents,
immediately=True if dispatch_message.userid else False,
)
def delete_message(
self,
channel: MessageChannel,
source: str,
message_id: Union[str, int],
chat_id: Optional[Union[str, int]] = None,
) -> bool:
"""
删除消息
:param channel: 消息渠道
:param source: 消息源(指定特定的消息模块)
:param message_id: 消息ID
:param chat_id: 聊天ID如群组ID
:return: 删除是否成功
"""
return self.run_module(
"delete_message",
channel=channel,
source=source,
message_id=message_id,
chat_id=chat_id,
)
def edit_message(
self,
channel: MessageChannel,
source: str,
message_id: Union[str, int],
chat_id: Union[str, int],
text: str,
title: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
"""
编辑已发送的消息
:param channel: 消息渠道
:param source: 消息源(指定特定的消息模块)
:param message_id: 消息ID
:param chat_id: 聊天ID
:param text: 新的消息内容
:param title: 消息标题
:param buttons: 更新后的按钮列表
:param metadata: 其他消息元数据
:return: 编辑是否成功
"""
if channel == MessageChannel.WebAgent:
try:
from app.application.messaging.agent import edit_web_agent_message
return edit_web_agent_message(
user_id=str((metadata or {}).get("userid") or ""),
message_id=message_id,
title=title,
text=text,
buttons=buttons,
)
except Exception as err:
logger.debug(f"编辑 WebAgent 消息失败: {err}")
return False
return self.run_module(
"edit_message",
channel=channel,
source=source,
message_id=message_id,
chat_id=chat_id,
text=text,
title=title,
buttons=buttons,
metadata=metadata,
)
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
"""
直接发送消息并返回消息ID等信息用于后续编辑消息的场景
不经过消息队列、不保存消息历史
:param message: 消息体
:return: 消息响应包含message_id, chat_id等
"""
return self.run_module(
"send_direct_message",
message=self._normalize_notification_for_dispatch(message),
)
def finalize_message(
self,
response: MessageResponse,
) -> bool:
"""
对已发送消息执行渠道收尾动作。
例如关闭流式卡片状态;无特殊收尾的渠道直接返回 False。
"""
return self.run_module("finalize_message", response=response)