Files
archived-MoviePilot/tests/test_telegram_typing_lifecycle.py
2026-05-23 00:41:12 +08:00

500 lines
18 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.
import asyncio
import sys
import time
import unittest
from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
sys.modules.setdefault("app.helper.sites", ModuleType("app.helper.sites"))
setattr(sys.modules["app.helper.sites"], "SitesHelper", object)
from app.agent import AgentManager, _MessageTask, _async_start_processing_status
from app.chain.message import MessageChain
from app.command import Command, _finish_command_processing_status
from app.modules.telegram import TelegramModule
from app.modules.telegram.telegram import Telegram
from app.schemas.types import MessageChannel
class TestTelegramTypingLifecycle(unittest.TestCase):
def setUp(self):
self._cleanup_typing_tasks()
def tearDown(self):
self._cleanup_typing_tasks()
@staticmethod
def _cleanup_typing_tasks():
helper = Telegram.__new__(Telegram)
for chat_id in list(Telegram._typing_tasks.keys()):
helper._stop_typing_task(chat_id)
Telegram._typing_tasks.clear()
Telegram._typing_stop_flags.clear()
Telegram._user_chat_mapping.clear()
@staticmethod
def _telegram_client() -> Telegram:
telegram = Telegram.__new__(Telegram)
telegram._bot = Mock()
telegram._telegram_token = "token"
telegram._telegram_chat_id = "default-chat"
# 缩短测试中的等待时间,不改变生产默认续发间隔。
telegram._typing_interval_seconds = 0.01
telegram._typing_max_duration_seconds = 1
return telegram
def test_start_typing_can_stop_by_chat_id(self):
telegram = self._telegram_client()
telegram._start_typing_task(
"chat-1",
max_duration_seconds=1,
initial_delay_seconds=0,
)
time.sleep(0.03)
self.assertIn("chat-1", Telegram._typing_tasks)
self.assertTrue(telegram._bot.send_chat_action.called)
self.assertTrue(telegram.stop_typing(chat_id="chat-1"))
self.assertNotIn("chat-1", Telegram._typing_tasks)
def test_start_typing_can_stop_by_user_mapping(self):
telegram = self._telegram_client()
Telegram._user_chat_mapping["10001"] = "chat-2"
telegram._start_typing_task(
"chat-2",
max_duration_seconds=1,
initial_delay_seconds=0,
)
time.sleep(0.03)
self.assertTrue(telegram.stop_typing(userid="10001"))
self.assertNotIn("chat-2", Telegram._typing_tasks)
def test_typing_task_has_max_duration_guard(self):
telegram = self._telegram_client()
telegram._start_typing_task(
"chat-3",
max_duration_seconds=0.02,
initial_delay_seconds=0,
)
time.sleep(0.08)
self.assertNotIn("chat-3", Telegram._typing_tasks)
def test_short_typing_task_can_stop_before_first_chat_action(self):
"""
短响应在首次 typing 发出前结束时,不应留下客户端自然过期的残留状态。
"""
telegram = self._telegram_client()
telegram._start_typing_task(
"chat-4",
max_duration_seconds=1,
initial_delay_seconds=0.05,
)
telegram.stop_typing(chat_id="chat-4")
time.sleep(0.08)
telegram._bot.send_chat_action.assert_not_called()
self.assertNotIn("chat-4", Telegram._typing_tasks)
def test_agent_managed_send_msg_keeps_typing_for_worker_cleanup(self):
telegram = self._telegram_client()
sent = SimpleNamespace(message_id=1, chat=SimpleNamespace(id="chat-1"))
with patch.object(
telegram, "_Telegram__send_request", return_value=sent
), patch.object(telegram, "_stop_typing_task") as stop_typing:
result = telegram.send_msg(
title="处理中",
userid="10001",
stop_typing=False,
)
self.assertTrue(result["success"])
stop_typing.assert_not_called()
def test_send_msg_does_not_stop_typing_by_default(self):
"""
响应发送不再默认结束 typing由处理状态统一收口。
"""
telegram = self._telegram_client()
sent = SimpleNamespace(message_id=1, chat=SimpleNamespace(id="chat-1"))
with patch.object(
telegram, "_Telegram__send_request", return_value=sent
), patch.object(telegram, "_stop_typing_task") as stop_typing:
result = telegram.send_msg(title="处理中", userid="10001")
self.assertTrue(result["success"])
stop_typing.assert_not_called()
def test_telegram_module_processing_status_starts_typing(self):
"""
Telegram 通过模块处理状态接口启动 typing 保活。
"""
module = TelegramModule()
module._channel = MessageChannel.Telegram
client = Mock()
client.start_typing.return_value = True
with patch.object(
module, "get_config", return_value=SimpleNamespace(name="telegram-test")
), patch.object(module, "get_instance", return_value=client):
status = module.mark_message_processing_started(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
chat_id="-100",
text="hello",
)
client.start_typing.assert_called_once_with(chat_id="-100", userid="10001")
self.assertEqual(status["metadata"]["kind"], "typing")
def test_slash_command_defers_processing_status_to_command_handler(self):
chain = MessageChain.__new__(MessageChain)
chain.eventmanager = Mock()
status = MessageChain._ProcessingStatus(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
chat_id="-100",
metadata={"kind": "typing"},
)
with patch.object(chain, "_record_user_message"), patch.object(
chain, "_mark_message_processing_started", return_value=status
), patch.object(
chain, "_mark_message_processing_finished"
) as finish_status:
chain.handle_message(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
text="/sites",
original_chat_id="-100",
)
finish_status.assert_not_called()
chain.eventmanager.send_event.assert_called_once()
self.assertEqual(
chain.eventmanager.send_event.call_args.args[1]["processing_status"],
status.to_dict(),
)
def test_command_handler_finishes_processing_status_after_execute(self):
"""
传统命令响应完成后由命令处理器统一结束 processing status。
"""
command = Command.__new__(Command)
command.get = Mock(return_value={"func": Mock()})
command.execute = Mock()
event = SimpleNamespace(
event_data={
"cmd": "/sites",
"user": "10001",
"channel": MessageChannel.Telegram,
"source": "telegram-test",
"processing_status": {
"channel": MessageChannel.Telegram.value,
"source": "telegram-test",
"userid": "10001",
"chat_id": "-100",
"metadata": {"kind": "typing"},
},
}
)
with patch("app.command._finish_command_processing_status") as finish_status:
command.command_event(event)
command.execute.assert_called_once()
finish_status.assert_called_once_with(
event.event_data["processing_status"],
user_id="10001",
)
def test_finish_command_processing_status_uses_module_interface(self):
status = {
"channel": MessageChannel.Telegram.value,
"source": "telegram-test",
"userid": "10001",
"chat_id": "-100",
"metadata": {"kind": "typing"},
}
with patch("app.command.CommandChain") as chain_cls:
_finish_command_processing_status(status, user_id="fallback")
chain_cls.return_value.finish_message_processing_status.assert_called_once_with(
status=status,
userid="fallback",
)
def test_async_agent_leaves_processing_status_to_worker(self):
chain = MessageChain.__new__(MessageChain)
with patch.object(chain, "_record_user_message"), patch.object(
chain, "_mark_message_processing_started"
) as start_status, patch(
"app.chain.message.settings.AI_AGENT_ENABLE", True
), patch(
"app.chain.message.agent_manager.process_message",
new_callable=AsyncMock,
) as process_message, patch(
"app.chain.message.asyncio.run_coroutine_threadsafe",
side_effect=lambda coro, _loop: (coro.close(), Mock())[1],
), patch.object(
chain, "_mark_message_processing_finished"
) as finish_status:
chain.handle_message(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
text="/ai 搜索电影",
original_chat_id="-100",
)
start_status.assert_not_called()
finish_status.assert_not_called()
process_message.assert_called_once()
self.assertNotIn("processing_status", process_message.call_args.kwargs)
self.assertEqual(
process_message.call_args.kwargs["channel"],
MessageChannel.Telegram.value,
)
self.assertEqual(process_message.call_args.kwargs["source"], "telegram-test")
self.assertEqual(process_message.call_args.kwargs["original_chat_id"], "-100")
def test_agent_manager_starts_processing_status_when_task_runs(self):
async def _run():
manager = AgentManager()
task = _MessageTask(
session_id="session-1",
user_id="10001",
message="第一条",
channel=MessageChannel.Telegram.value,
source="telegram-test",
original_chat_id="-100",
)
status = {
"channel": MessageChannel.Telegram.value,
"source": "telegram-test",
"userid": "10001",
"chat_id": "-100",
"metadata": {"kind": "typing"},
}
with patch(
"app.agent._async_start_processing_status",
new_callable=AsyncMock,
return_value=status,
) as start_status:
await manager._start_task_processing_status(task)
start_status.assert_awaited_once_with(task)
self.assertEqual(task.processing_status, status)
asyncio.run(_run())
def test_agent_start_processing_status_uses_chain_interface(self):
async def _run():
task = _MessageTask(
session_id="session-1",
user_id="10001",
message="第一条",
channel=MessageChannel.Telegram.value,
source="telegram-test",
original_message_id="10",
original_chat_id="-100",
)
status = {
"channel": MessageChannel.Telegram.value,
"source": "telegram-test",
"userid": "10001",
"message_id": "10",
"chat_id": "-100",
"metadata": {"kind": "typing"},
}
with patch("app.agent.AgentChain") as chain_cls:
chain_cls.return_value.start_message_processing_status.return_value = status
result = await _async_start_processing_status(task)
chain_cls.return_value.start_message_processing_status.assert_called_once_with(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
message_id="10",
chat_id="-100",
text="第一条",
)
self.assertEqual(result, status)
asyncio.run(_run())
def test_callback_stops_typing_when_message_handler_returns(self):
chain = MessageChain.__new__(MessageChain)
status = MessageChain._ProcessingStatus(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
chat_id="-100",
metadata={"kind": "typing"},
)
with patch.object(chain, "_record_user_message"), patch.object(
chain, "_mark_message_processing_started", return_value=status
), patch.object(chain, "_handle_message_core"), patch.object(
chain, "_mark_message_processing_finished"
) as finish_status:
chain.handle_message(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
username="tester",
text="CALLBACK:sites:req-1:refresh",
original_chat_id="-100",
)
finish_status.assert_called_once_with(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
status=status,
original_message_id=None,
original_chat_id="-100",
)
def test_chain_finishes_processing_through_module_interface(self):
chain = MessageChain.__new__(MessageChain)
status = MessageChain._ProcessingStatus(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
chat_id="-100",
metadata={"kind": "typing"},
)
with patch.object(chain, "finish_message_processing_status") as finish_status:
chain._mark_message_processing_finished(
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
status=status,
original_chat_id="-100",
)
finish_status.assert_called_once_with(
status=status.to_dict(),
channel=MessageChannel.Telegram,
source="telegram-test",
userid="10001",
message_id=None,
chat_id="-100",
)
def test_agent_manager_finishes_processing_status_after_each_task(self):
async def _run():
manager = AgentManager()
status = {
"channel": MessageChannel.Telegram.value,
"source": "telegram-test",
"userid": "10001",
"chat_id": "-100",
"metadata": {"kind": "typing"},
}
task = _MessageTask(
session_id="session-1",
user_id="10001",
message="第一条",
processing_status=status,
)
with patch(
"app.agent._async_finish_processing_status",
new_callable=AsyncMock,
) as finish_status:
await manager._finish_task_processing_status(task)
finish_status.assert_awaited_once_with(status, "10001")
self.assertIsNone(task.processing_status)
asyncio.run(_run())
def test_agent_worker_starts_and_finishes_each_queued_task(self):
async def _run():
manager = AgentManager()
manager._session_queues["session-1"] = asyncio.Queue()
first_status = {
"channel": MessageChannel.Telegram.value,
"source": "telegram-test",
"userid": "10001",
"chat_id": "-100",
"metadata": {"kind": "typing", "seq": 1},
}
second_status = {
"channel": MessageChannel.Telegram.value,
"source": "telegram-test",
"userid": "10001",
"chat_id": "-100",
"metadata": {"kind": "typing", "seq": 2},
}
await manager._session_queues["session-1"].put(_MessageTask(
session_id="session-1",
user_id="10001",
message="第一条",
channel=MessageChannel.Telegram.value,
source="telegram-test",
original_chat_id="-100",
))
await manager._session_queues["session-1"].put(_MessageTask(
session_id="session-1",
user_id="10001",
message="第二条",
channel=MessageChannel.Telegram.value,
source="telegram-test",
original_chat_id="-100",
))
with patch(
"app.agent._async_start_processing_status",
new_callable=AsyncMock,
side_effect=[first_status, second_status],
) as start_status, patch.object(
manager,
"_process_message_internal",
new_callable=AsyncMock,
), patch(
"app.agent._async_finish_processing_status",
new_callable=AsyncMock,
) as finish_status:
manager._session_workers["session-1"] = asyncio.create_task(
manager._session_worker("session-1")
)
await manager._session_queues["session-1"].join()
manager._session_workers["session-1"].cancel()
await manager._session_workers["session-1"]
self.assertEqual(start_status.await_count, 2)
self.assertEqual(
finish_status.await_args_list[0].args,
(first_status, "10001"),
)
self.assertEqual(
finish_status.await_args_list[1].args,
(second_status, "10001"),
)
asyncio.run(_run())
if __name__ == "__main__":
unittest.main()