Files
MoviePilot/tests/test_telegram_typing_lifecycle.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

709 lines
22 KiB
Python
Raw Permalink 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.
"""
验证Telegram 输入状态的启动、续期和结束清理。
"""
import asyncio
import threading
import time
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import pytest
from app.agent.manager import AgentManager
from app.agent.session import _async_start_processing_status, _MessageTask
from app.chain.message import MessageChain
from app.command import Command, _finish_command_processing_status
# 此用例覆盖运行时惰性导出,静态检查无法解析该公开入口。
from app.modules.telegram import TelegramModule # pylint: disable=no-name-in-module
from app.modules.telegram.telegram import Telegram
from app.runtime.loop import main_loop_registry
from app.schemas.types import NotificationChannel
def _wait_until(predicate, timeout: float = 1.0) -> bool:
"""
等待后台线程完成目标状态,避免用例依赖固定 sleep 时长。
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.01)
return predicate()
class _FakeTelegramBot:
"""
记录 typing 调用的轻量 bot,避免后台线程与 Mock 内部锁交互。
"""
def __init__(self):
"""
初始化调用记录和首个动作事件。
"""
self.chat_actions = []
self.action_event = threading.Event()
def send_chat_action(self, chat_id, action):
"""
记录一次 typing 动作并唤醒等待者。
"""
self.chat_actions.append((chat_id, action))
self.action_event.set()
class _BlockingTelegramBot:
"""
模拟阻塞中的 Telegram SDK 请求。
"""
def __init__(self) -> None:
"""
初始化进入和释放请求的事件屏障。
"""
self.entered = threading.Event()
self.release = threading.Event()
def send_chat_action(self, _chat_id, _action) -> None:
"""
阻塞请求,直到测试显式释放。
"""
self.entered.set()
self.release.wait(timeout=1)
def _telegram_client(bot=None) -> Telegram:
"""
构造不连接外部服务且持有独立运行状态的 Telegram client。
"""
telegram = Telegram.__new__(Telegram)
telegram._bot = bot or _FakeTelegramBot()
telegram._polling_thread = None
telegram._telegram_token = "token"
telegram._telegram_chat_id = "default-chat"
telegram._user_chat_mapping = {}
telegram._typing_tasks = {}
telegram._typing_stop_flags = {}
telegram._typing_lock = threading.RLock()
telegram._typing_lifecycle_lock = threading.RLock()
telegram._typing_accepting = True
telegram._typing_join_timeout_seconds = 0.01
# 缩短测试中的等待时间,不改变生产默认续发间隔。
telegram._typing_interval_seconds = 0.01
telegram._typing_max_duration_seconds = 1
return telegram
def test_start_typing_can_stop_by_chat_id():
"""
输入状态任务可以通过会话标识停止,并从任务表移除。
"""
telegram = _telegram_client()
telegram._start_typing_task(
"chat-1",
max_duration_seconds=1,
initial_delay_seconds=0,
)
assert "chat-1" in telegram._typing_tasks
assert telegram._bot.action_event.wait(1.0)
assert telegram.stop_typing(chat_id="chat-1")
assert "chat-1" not in telegram._typing_tasks
def test_start_typing_can_stop_by_user_mapping():
"""
仅提供用户标识时,通过用户与会话映射定位并停止输入状态任务。
"""
telegram = _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)
assert telegram.stop_typing(userid="10001")
assert "chat-2" not in telegram._typing_tasks
def test_typing_task_has_max_duration_guard():
"""
输入状态达到最长持续时间后自行结束,避免反馈任务永久存活。
"""
telegram = _telegram_client()
telegram._start_typing_task(
"chat-3",
max_duration_seconds=0.02,
initial_delay_seconds=0,
)
assert _wait_until(lambda: "chat-3" not in telegram._typing_tasks)
assert "chat-3" not in telegram._typing_tasks
def test_short_typing_task_can_stop_before_first_chat_action():
"""
短响应在首次 typing 发出前结束时,不应留下客户端自然过期的残留状态。
"""
telegram = _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)
assert telegram._bot.chat_actions == []
assert "chat-4" not in telegram._typing_tasks
def test_typing_owner_is_isolated_between_config_instances():
"""
不同 Telegram 配置即使 chat_id 相同也不得互相停止 typing。
"""
first = _telegram_client()
second = _telegram_client()
try:
assert first._start_typing_task("shared-chat", initial_delay_seconds=0)
assert second._start_typing_task("shared-chat", initial_delay_seconds=0)
assert first._bot.action_event.wait(timeout=1)
assert second._bot.action_event.wait(timeout=1)
assert first.stop_typing(chat_id="shared-chat")
assert "shared-chat" not in first._typing_tasks
assert "shared-chat" in second._typing_tasks
finally:
first.stop_typing(chat_id="shared-chat")
second.stop_typing(chat_id="shared-chat")
def test_typing_stop_keeps_blocked_thread_owner_until_terminal():
"""
SDK 请求阻塞超过等待预算时,不得提前删除线程 owner。
"""
bot = _BlockingTelegramBot()
telegram = _telegram_client(bot)
try:
assert telegram._start_typing_task("blocked-chat", initial_delay_seconds=0)
assert bot.entered.wait(timeout=1)
assert telegram._stop_typing_task("blocked-chat") is False
owner = telegram._typing_tasks["blocked-chat"]
assert owner.is_alive()
assert telegram._start_typing_task(
"blocked-chat", initial_delay_seconds=0
) is False
bot.release.set()
owner.join(timeout=1)
assert not owner.is_alive()
assert "blocked-chat" not in telegram._typing_tasks
finally:
bot.release.set()
telegram.stop_typing(chat_id="blocked-chat")
def test_typing_start_failure_releases_registered_owner(monkeypatch):
"""
线程启动失败时应清理已登记的 owner 和停止信号。
"""
telegram = _telegram_client()
class FailingThread:
"""
模拟登记成功后无法启动的线程对象。
"""
def __init__(self, **_kwargs) -> None:
"""
接收真实 Thread 构造参数。
"""
def start(self) -> None:
"""
模拟系统拒绝创建新线程。
"""
raise RuntimeError("thread start failed")
monkeypatch.setattr("app.modules.telegram.telegram.threading.Thread", FailingThread)
with pytest.raises(RuntimeError, match="thread start failed"):
telegram._start_typing_task("failed-chat", initial_delay_seconds=0)
assert telegram._typing_tasks == {}
assert telegram._typing_stop_flags == {}
def test_typing_start_is_rejected_after_client_stop():
"""
client 停止封口后不得再接受新的 typing 线程。
"""
telegram = _telegram_client()
telegram._bot = None
telegram.stop()
assert telegram._start_typing_task("closed-chat", initial_delay_seconds=0) is False
assert telegram._typing_tasks == {}
def test_agent_managed_send_msg_keeps_typing_for_worker_cleanup():
"""
Agent 托管的消息发送保留输入状态,由工作任务统一收尾。
"""
telegram = _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,
)
assert result["success"]
stop_typing.assert_not_called()
def test_send_msg_does_not_stop_typing_by_default():
"""
响应发送不再默认结束 typing,由处理状态统一收口。
"""
telegram = _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")
assert result["success"]
stop_typing.assert_not_called()
def test_telegram_module_processing_status_starts_typing():
"""
Telegram 通过模块处理状态接口启动 typing 保活。
"""
module = TelegramModule()
module._channel = NotificationChannel.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=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
chat_id="-100",
text="hello",
)
client.start_typing.assert_called_once_with(chat_id="-100", userid="10001")
assert status["metadata"]["kind"] == "typing"
def test_slash_command_defers_processing_status_to_command_handler():
"""
斜杠命令的处理状态交由命令处理器结束,消息入口不提前清理。
"""
chain = MessageChain.__new__(MessageChain)
chain.eventmanager = Mock()
status = MessageChain._ProcessingStatus(
channel=NotificationChannel.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=NotificationChannel.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()
assert (
chain.eventmanager.send_event.call_args.args[1]["processing_status"]
== status.to_dict()
)
def test_command_handler_finishes_processing_status_after_execute():
"""
传统命令响应完成后由命令处理器统一结束 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": NotificationChannel.Telegram,
"source": "telegram-test",
"processing_status": {
"channel": NotificationChannel.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():
"""
命令结束时通过统一模块接口清理状态,并传递备用用户标识。
"""
status = {
"channel": NotificationChannel.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():
"""
异步 Agent 消息把处理状态生命周期交给工作任务管理。
"""
chain = MessageChain.__new__(MessageChain)
chain.eventmanager = Mock()
chain.runtime_config = replace(
chain.runtime_config,
ai_agent_enable=True,
)
loop = Mock(**{"is_running.return_value": True, "is_closed.return_value": False})
with patch.object(main_loop_registry, "require", return_value=loop), patch.object(
chain, "_record_user_message"
), patch.object(
chain, "_mark_message_processing_started"
) as start_status, patch(
"app.chain.message.get_running_agent_manager",
) as get_running_manager, 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:
process_message = AsyncMock()
get_running_manager.return_value.process_message = process_message
chain.handle_message(
channel=NotificationChannel.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()
assert "processing_status" not in process_message.call_args.kwargs
assert (
process_message.call_args.kwargs["channel"]
== NotificationChannel.Telegram.value
)
assert process_message.call_args.kwargs["source"] == "telegram-test"
assert process_message.call_args.kwargs["original_chat_id"] == "-100"
def test_agent_manager_starts_processing_status_when_task_runs():
"""
Agent 管理器在任务实际执行时启动处理状态,而非排队时启动。
"""
async def _run():
manager = AgentManager()
task = _MessageTask(
session_id="session-1",
user_id="10001",
message="第一条",
channel=NotificationChannel.Telegram.value,
source="telegram-test",
original_chat_id="-100",
)
status = {
"channel": NotificationChannel.Telegram.value,
"source": "telegram-test",
"userid": "10001",
"chat_id": "-100",
"metadata": {"kind": "typing"},
}
with patch(
"app.agent.session._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)
assert task.processing_status == status
asyncio.run(_run())
def test_agent_start_processing_status_uses_chain_interface():
"""
Agent 启动处理状态时通过链层接口传递原始消息与会话位置。
"""
async def _run():
task = _MessageTask(
session_id="session-1",
user_id="10001",
message="第一条",
channel=NotificationChannel.Telegram.value,
source="telegram-test",
original_message_id="10",
original_chat_id="-100",
)
status = {
"channel": NotificationChannel.Telegram.value,
"source": "telegram-test",
"userid": "10001",
"message_id": "10",
"chat_id": "-100",
"metadata": {"kind": "typing"},
}
calls = []
class FakeAgentChain:
"""
记录 processing status 请求的 Agent Chain 替身。
"""
def start_message_processing_status(self, **kwargs):
"""
记录请求并返回预设状态。
"""
calls.append(kwargs)
return status
with patch("app.agent.session.AgentChain", FakeAgentChain):
result = await _async_start_processing_status(task)
assert calls == [{
"channel": NotificationChannel.Telegram,
"source": "telegram-test",
"userid": "10001",
"message_id": "10",
"chat_id": "-100",
"text": "第一条",
}]
assert result == status
asyncio.run(_run())
def test_callback_stops_typing_when_message_handler_returns():
"""
消息回调处理结束后停止输入反馈,避免保留已完成任务的状态。
"""
chain = MessageChain.__new__(MessageChain)
chain.eventmanager = Mock()
status = MessageChain._ProcessingStatus(
channel=NotificationChannel.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=NotificationChannel.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=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
status=status,
original_message_id=None,
original_chat_id="-100",
)
def test_chain_finishes_processing_through_module_interface():
"""
消息链结束处理时调用统一模块接口,不直接操作具体渠道客户端。
"""
chain = MessageChain.__new__(MessageChain)
status = MessageChain._ProcessingStatus(
channel=NotificationChannel.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=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
status=status,
original_chat_id="-100",
)
finish_status.assert_called_once_with(
status=status.to_dict(),
channel=NotificationChannel.Telegram,
source="telegram-test",
userid="10001",
message_id=None,
chat_id="-100",
)
def test_agent_manager_finishes_processing_status_after_each_task():
"""
Agent 管理器在每个任务结束后清理该任务的处理状态。
"""
async def _run():
manager = AgentManager()
status = {
"channel": NotificationChannel.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.session._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")
assert task.processing_status is None
asyncio.run(_run())
def test_agent_worker_starts_and_finishes_each_queued_task():
"""
同一会话队列中的每个任务分别启动和结束处理状态。
"""
async def _run():
manager = AgentManager()
manager._session_queues["session-1"] = asyncio.Queue()
first_status = {
"channel": NotificationChannel.Telegram.value,
"source": "telegram-test",
"userid": "10001",
"chat_id": "-100",
"metadata": {"kind": "typing", "seq": 1},
}
second_status = {
"channel": NotificationChannel.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=NotificationChannel.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=NotificationChannel.Telegram.value,
source="telegram-test",
original_chat_id="-100",
))
with patch(
"app.agent.session._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.session._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"]
assert start_status.await_count == 2
assert finish_status.await_args_list[0].args == (first_status, "10001")
assert finish_status.await_args_list[1].args == (second_status, "10001")
asyncio.run(_run())