mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat: normalize internal system user ID in notification dispatch
- Add SYSTEM_INTERNAL_USER_ID constant and helpers to app.utils.identity - Ensure internal user ID is normalized to None before dispatching notifications, preventing misrouting to external channels - Refactor MessageChain to use normalization for all message dispatch methods - Add tests for internal user ID normalization and notification dispatch behavior
This commit is contained in:
@@ -34,6 +34,7 @@ from app.log import logger
|
|||||||
from app.schemas import Notification, NotificationType
|
from app.schemas import Notification, NotificationType
|
||||||
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
|
from app.schemas.message import ChannelCapabilityManager, ChannelCapability
|
||||||
from app.schemas.types import MessageChannel
|
from app.schemas.types import MessageChannel
|
||||||
|
from app.utils.identity import SYSTEM_INTERNAL_USER_ID
|
||||||
|
|
||||||
|
|
||||||
class AgentChain(ChainBase):
|
class AgentChain(ChainBase):
|
||||||
@@ -543,16 +544,12 @@ class MoviePilotAgent:
|
|||||||
"""
|
"""
|
||||||
通过原渠道发送消息给用户
|
通过原渠道发送消息给用户
|
||||||
"""
|
"""
|
||||||
user_id = self.user_id
|
|
||||||
if self.user_id == "system":
|
|
||||||
user_id = None
|
|
||||||
|
|
||||||
await AgentChain().async_post_message(
|
await AgentChain().async_post_message(
|
||||||
Notification(
|
Notification(
|
||||||
channel=self.channel,
|
channel=self.channel,
|
||||||
source=self.source,
|
source=self.source,
|
||||||
mtype=NotificationType.Agent,
|
mtype=NotificationType.Agent,
|
||||||
userid=user_id,
|
userid=self.user_id,
|
||||||
username=self.username,
|
username=self.username,
|
||||||
title=title,
|
title=title,
|
||||||
text=message,
|
text=message,
|
||||||
@@ -853,7 +850,7 @@ class AgentManager:
|
|||||||
try:
|
try:
|
||||||
# 每次使用唯一的 session_id,避免共享上下文
|
# 每次使用唯一的 session_id,避免共享上下文
|
||||||
session_id = f"__agent_heartbeat_{uuid.uuid4().hex[:12]}__"
|
session_id = f"__agent_heartbeat_{uuid.uuid4().hex[:12]}__"
|
||||||
user_id = "system"
|
user_id = SYSTEM_INTERNAL_USER_ID
|
||||||
|
|
||||||
logger.info("智能体心跳唤醒:开始检查待处理任务...")
|
logger.info("智能体心跳唤醒:开始检查待处理任务...")
|
||||||
|
|
||||||
@@ -948,7 +945,7 @@ class AgentManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
session_id = f"__agent_retry_transfer_batch_{uuid.uuid4().hex[:8]}__"
|
session_id = f"__agent_retry_transfer_batch_{uuid.uuid4().hex[:8]}__"
|
||||||
user_id = "system"
|
user_id = SYSTEM_INTERNAL_USER_ID
|
||||||
|
|
||||||
ids_str = ", ".join(str(i) for i in history_ids)
|
ids_str = ", ".join(str(i) for i in history_ids)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1107,7 +1104,7 @@ class AgentManager:
|
|||||||
手动触发单条历史记录的 AI 整理。
|
手动触发单条历史记录的 AI 整理。
|
||||||
"""
|
"""
|
||||||
session_id = f"__agent_manual_redo_{history_id}_{uuid.uuid4().hex[:8]}__"
|
session_id = f"__agent_manual_redo_{history_id}_{uuid.uuid4().hex[:8]}__"
|
||||||
user_id = "system"
|
user_id = SYSTEM_INTERNAL_USER_ID
|
||||||
agent = MoviePilotAgent(
|
agent = MoviePilotAgent(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
+44
-17
@@ -38,6 +38,7 @@ from app.schemas import (
|
|||||||
TransferDirectoryConf,
|
TransferDirectoryConf,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
)
|
)
|
||||||
|
from app.utils.identity import normalize_internal_user_id
|
||||||
from app.schemas.category import CategoryConfig
|
from app.schemas.category import CategoryConfig
|
||||||
from app.schemas.types import (
|
from app.schemas.types import (
|
||||||
TorrentStatus,
|
TorrentStatus,
|
||||||
@@ -119,6 +120,21 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
"""
|
"""
|
||||||
self.filecache.delete(filename)
|
self.filecache.delete(filename)
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
async def async_remove_cache(self, filename: str) -> None:
|
async def async_remove_cache(self, filename: str) -> None:
|
||||||
"""
|
"""
|
||||||
异步删除缓存,同时删除Redis和本地缓存
|
异步删除缓存,同时删除Redis和本地缓存
|
||||||
@@ -1119,10 +1135,13 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
# 保存消息
|
# 保存消息
|
||||||
self.messagehelper.put(message, role="user", title=message.title)
|
self.messagehelper.put(message, role="user", title=message.title)
|
||||||
self.messageoper.add(**message.model_dump())
|
self.messageoper.add(**message.model_dump())
|
||||||
|
dispatch_message = self._normalize_notification_for_dispatch(message)
|
||||||
# 发送消息按设置隔离
|
# 发送消息按设置隔离
|
||||||
if not message.userid and message.mtype:
|
if not dispatch_message.userid and dispatch_message.mtype:
|
||||||
# 消息隔离设置
|
# 消息隔离设置
|
||||||
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
|
notify_action = ServiceConfigHelper.get_notification_switch(
|
||||||
|
dispatch_message.mtype
|
||||||
|
)
|
||||||
if notify_action:
|
if notify_action:
|
||||||
# 'admin' 'user,admin' 'user' 'all'
|
# 'admin' 'user,admin' 'user' 'all'
|
||||||
actions = notify_action.split(",")
|
actions = notify_action.split(",")
|
||||||
@@ -1131,7 +1150,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
send_orignal = False
|
send_orignal = False
|
||||||
useroper = UserOper()
|
useroper = UserOper()
|
||||||
for action in actions:
|
for action in actions:
|
||||||
send_message = copy.deepcopy(message)
|
send_message = copy.deepcopy(dispatch_message)
|
||||||
if action == "admin" and not admin_sended:
|
if action == "admin" and not admin_sended:
|
||||||
# 仅发送管理员
|
# 仅发送管理员
|
||||||
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
||||||
@@ -1186,13 +1205,13 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
# 发送消息事件
|
# 发送消息事件
|
||||||
self.eventmanager.send_event(
|
self.eventmanager.send_event(
|
||||||
etype=EventType.NoticeMessage,
|
etype=EventType.NoticeMessage,
|
||||||
data={**message.model_dump(), "type": message.mtype},
|
data={**dispatch_message.model_dump(), "type": dispatch_message.mtype},
|
||||||
)
|
)
|
||||||
# 按原消息发送
|
# 按原消息发送
|
||||||
self.messagequeue.send_message(
|
self.messagequeue.send_message(
|
||||||
"post_message",
|
"post_message",
|
||||||
message=message,
|
message=dispatch_message,
|
||||||
immediately=True if message.userid else False,
|
immediately=True if dispatch_message.userid else False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1233,10 +1252,13 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
# 保存消息
|
# 保存消息
|
||||||
self.messagehelper.put(message, role="user", title=message.title)
|
self.messagehelper.put(message, role="user", title=message.title)
|
||||||
await self.messageoper.async_add(**message.model_dump())
|
await self.messageoper.async_add(**message.model_dump())
|
||||||
|
dispatch_message = self._normalize_notification_for_dispatch(message)
|
||||||
# 发送消息按设置隔离
|
# 发送消息按设置隔离
|
||||||
if not message.userid and message.mtype:
|
if not dispatch_message.userid and dispatch_message.mtype:
|
||||||
# 消息隔离设置
|
# 消息隔离设置
|
||||||
notify_action = ServiceConfigHelper.get_notification_switch(message.mtype)
|
notify_action = ServiceConfigHelper.get_notification_switch(
|
||||||
|
dispatch_message.mtype
|
||||||
|
)
|
||||||
if notify_action:
|
if notify_action:
|
||||||
# 'admin' 'user,admin' 'user' 'all'
|
# 'admin' 'user,admin' 'user' 'all'
|
||||||
actions = notify_action.split(",")
|
actions = notify_action.split(",")
|
||||||
@@ -1245,7 +1267,7 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
send_orignal = False
|
send_orignal = False
|
||||||
useroper = UserOper()
|
useroper = UserOper()
|
||||||
for action in actions:
|
for action in actions:
|
||||||
send_message = copy.deepcopy(message)
|
send_message = copy.deepcopy(dispatch_message)
|
||||||
if action == "admin" and not admin_sended:
|
if action == "admin" and not admin_sended:
|
||||||
# 仅发送管理员
|
# 仅发送管理员
|
||||||
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
||||||
@@ -1300,13 +1322,13 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
# 发送消息事件
|
# 发送消息事件
|
||||||
await self.eventmanager.async_send_event(
|
await self.eventmanager.async_send_event(
|
||||||
etype=EventType.NoticeMessage,
|
etype=EventType.NoticeMessage,
|
||||||
data={**message.model_dump(), "type": message.mtype},
|
data={**dispatch_message.model_dump(), "type": dispatch_message.mtype},
|
||||||
)
|
)
|
||||||
# 按原消息发送
|
# 按原消息发送
|
||||||
await self.messagequeue.async_send_message(
|
await self.messagequeue.async_send_message(
|
||||||
"post_message",
|
"post_message",
|
||||||
message=message,
|
message=dispatch_message,
|
||||||
immediately=True if message.userid else False,
|
immediately=True if dispatch_message.userid else False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1324,11 +1346,12 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
message, role="user", note=note_list, title=message.title
|
message, role="user", note=note_list, title=message.title
|
||||||
)
|
)
|
||||||
self.messageoper.add(**message.model_dump(), note=note_list)
|
self.messageoper.add(**message.model_dump(), note=note_list)
|
||||||
|
dispatch_message = self._normalize_notification_for_dispatch(message)
|
||||||
return self.messagequeue.send_message(
|
return self.messagequeue.send_message(
|
||||||
"post_medias_message",
|
"post_medias_message",
|
||||||
message=message,
|
message=dispatch_message,
|
||||||
medias=medias,
|
medias=medias,
|
||||||
immediately=True if message.userid else False,
|
immediately=True if dispatch_message.userid else False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def post_torrents_message(
|
def post_torrents_message(
|
||||||
@@ -1345,11 +1368,12 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
message, role="user", note=note_list, title=message.title
|
message, role="user", note=note_list, title=message.title
|
||||||
)
|
)
|
||||||
self.messageoper.add(**message.model_dump(), note=note_list)
|
self.messageoper.add(**message.model_dump(), note=note_list)
|
||||||
|
dispatch_message = self._normalize_notification_for_dispatch(message)
|
||||||
return self.messagequeue.send_message(
|
return self.messagequeue.send_message(
|
||||||
"post_torrents_message",
|
"post_torrents_message",
|
||||||
message=message,
|
message=dispatch_message,
|
||||||
torrents=torrents,
|
torrents=torrents,
|
||||||
immediately=True if message.userid else False,
|
immediately=True if dispatch_message.userid else False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete_message(
|
def delete_message(
|
||||||
@@ -1411,7 +1435,10 @@ class ChainBase(metaclass=ABCMeta):
|
|||||||
:param message: 消息体
|
:param message: 消息体
|
||||||
:return: 消息响应(包含message_id, chat_id等)
|
:return: 消息响应(包含message_id, chat_id等)
|
||||||
"""
|
"""
|
||||||
return self.run_module("send_direct_message", message=message)
|
return self.run_module(
|
||||||
|
"send_direct_message",
|
||||||
|
message=self._normalize_notification_for_dispatch(message),
|
||||||
|
)
|
||||||
|
|
||||||
def metadata_img(
|
def metadata_img(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
# 后台任务会话使用的内部占位用户ID。
|
||||||
|
# 它只用于在 agent/memory/session 侧标识“系统触发的任务”,
|
||||||
|
# 不能直接作为真实消息接收人下发到 Telegram/企业微信 等通知渠道。
|
||||||
|
SYSTEM_INTERNAL_USER_ID = "system"
|
||||||
|
|
||||||
|
|
||||||
|
def is_internal_user_id(userid: Optional[Union[str, int]]) -> bool:
|
||||||
|
"""
|
||||||
|
判断是否为系统内部占位用户ID。
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
isinstance(userid, str)
|
||||||
|
and userid.strip().lower() == SYSTEM_INTERNAL_USER_ID
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_internal_user_id(
|
||||||
|
userid: Optional[Union[str, int]]
|
||||||
|
) -> Optional[Union[str, int]]:
|
||||||
|
"""
|
||||||
|
将系统内部占位用户ID归一化为 None,避免被通知渠道误认为真实接收人。
|
||||||
|
"""
|
||||||
|
if is_internal_user_id(userid):
|
||||||
|
return None
|
||||||
|
return userid
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from types import ModuleType
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.modules.setdefault("qbittorrentapi", ModuleType("qbittorrentapi"))
|
||||||
|
setattr(sys.modules["qbittorrentapi"], "TorrentFilesList", list)
|
||||||
|
sys.modules.setdefault("transmission_rpc", ModuleType("transmission_rpc"))
|
||||||
|
setattr(sys.modules["transmission_rpc"], "File", object)
|
||||||
|
sys.modules.setdefault("psutil", ModuleType("psutil"))
|
||||||
|
|
||||||
|
from app.chain.message import MessageChain
|
||||||
|
from app.schemas import Notification
|
||||||
|
from app.utils.identity import (
|
||||||
|
SYSTEM_INTERNAL_USER_ID,
|
||||||
|
is_internal_user_id,
|
||||||
|
normalize_internal_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemNotificationDispatch(unittest.TestCase):
|
||||||
|
def test_internal_userid_identity_helpers(self):
|
||||||
|
self.assertTrue(is_internal_user_id(SYSTEM_INTERNAL_USER_ID))
|
||||||
|
self.assertTrue(is_internal_user_id(" System "))
|
||||||
|
self.assertIsNone(normalize_internal_user_id(SYSTEM_INTERNAL_USER_ID))
|
||||||
|
self.assertEqual(normalize_internal_user_id("10001"), "10001")
|
||||||
|
|
||||||
|
def test_post_message_normalizes_internal_userid_before_queueing(self):
|
||||||
|
chain = MessageChain()
|
||||||
|
message = Notification(
|
||||||
|
userid=SYSTEM_INTERNAL_USER_ID,
|
||||||
|
username="admin",
|
||||||
|
title="后台报告",
|
||||||
|
text="任务完成",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.chain.MessageTemplateHelper.render", return_value=message), patch.object(
|
||||||
|
chain.messagehelper, "put"
|
||||||
|
), patch.object(chain.messageoper, "add"), patch.object(
|
||||||
|
chain.eventmanager, "send_event"
|
||||||
|
) as send_event, patch.object(
|
||||||
|
chain.messagequeue, "send_message"
|
||||||
|
) as send_message:
|
||||||
|
chain.post_message(message)
|
||||||
|
|
||||||
|
event_payload = send_event.call_args.kwargs["data"]
|
||||||
|
queued_message = send_message.call_args.kwargs["message"]
|
||||||
|
|
||||||
|
self.assertIsNone(event_payload["userid"])
|
||||||
|
self.assertIsNone(queued_message.userid)
|
||||||
|
self.assertFalse(send_message.call_args.kwargs["immediately"])
|
||||||
|
|
||||||
|
def test_send_direct_message_normalizes_internal_userid(self):
|
||||||
|
chain = MessageChain()
|
||||||
|
message = Notification(
|
||||||
|
userid=SYSTEM_INTERNAL_USER_ID,
|
||||||
|
username="admin",
|
||||||
|
title="后台报告",
|
||||||
|
text="任务完成",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(chain, "run_module") as run_module:
|
||||||
|
chain.send_direct_message(message)
|
||||||
|
|
||||||
|
sent_message = run_module.call_args.kwargs["message"]
|
||||||
|
self.assertIsNone(sent_message.userid)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user