mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-21 04:31:59 +08:00
feat(message-processing-status): unified processing status indicator for Telegram, Slack, Discord, Feishu
- Add ChannelCapability.PROCESSING_STATUS and capability detection for supported channels - Implement mark_message_processing_started/finished in Telegram, Slack, Discord, Feishu modules - Telegram: manage typing lifecycle with max duration and explicit stop - Slack: add/remove reaction as processing indicator - Discord: start/stop typing indicator with async task management - Feishu: add/remove reaction for processing status - Refactor message chain to invoke processing status hooks for supported channels - Ensure processing status is properly finished on sync and async message handling paths - Add tests for processing status lifecycle and capability detection across channels
This commit is contained in:
@@ -965,6 +965,50 @@ class TestFeishu(unittest.TestCase):
|
||||
self.assertEqual(reaction_id, "reaction_2")
|
||||
self.assertTrue(deleted)
|
||||
|
||||
def test_module_processing_status_uses_reaction_helpers(self):
|
||||
module = FeishuModule()
|
||||
module._channel = MessageChannel.Feishu
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
module,
|
||||
"add_feishu_message_reaction",
|
||||
return_value="reaction_processing",
|
||||
) as add_reaction,
|
||||
patch.object(
|
||||
module,
|
||||
"delete_feishu_message_reaction",
|
||||
return_value=True,
|
||||
) as delete_reaction,
|
||||
):
|
||||
status = module.mark_message_processing_started(
|
||||
channel=MessageChannel.Feishu,
|
||||
source="feishu-main",
|
||||
userid="ou_x",
|
||||
message_id="om_x",
|
||||
chat_id="oc_x",
|
||||
text="hello",
|
||||
)
|
||||
deleted = module.mark_message_processing_finished(
|
||||
channel=MessageChannel.Feishu,
|
||||
source="feishu-main",
|
||||
userid="ou_x",
|
||||
status=status,
|
||||
)
|
||||
|
||||
add_reaction.assert_called_once_with(
|
||||
message_id="om_x",
|
||||
emoji_type="GLANCE",
|
||||
source="feishu-main",
|
||||
)
|
||||
delete_reaction.assert_called_once_with(
|
||||
message_id="om_x",
|
||||
reaction_id="reaction_processing",
|
||||
source="feishu-main",
|
||||
)
|
||||
self.assertEqual(status["metadata"]["reaction_id"], "reaction_processing")
|
||||
self.assertTrue(deleted)
|
||||
|
||||
def test_module_finalize_message_closes_streaming_card(self):
|
||||
module = FeishuModule()
|
||||
module._channel = MessageChannel.Feishu
|
||||
|
||||
153
tests/test_message_processing_status.py
Normal file
153
tests/test_message_processing_status.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import json
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.agent import _finish_processing_status
|
||||
from app.modules.discord import DiscordModule
|
||||
from app.modules.slack import SlackModule
|
||||
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
class TestMessageProcessingStatus(unittest.TestCase):
|
||||
def test_processing_status_capability_only_enabled_for_supported_channels(self):
|
||||
supported = {
|
||||
MessageChannel.Telegram,
|
||||
MessageChannel.Feishu,
|
||||
MessageChannel.Slack,
|
||||
MessageChannel.Discord,
|
||||
}
|
||||
|
||||
for channel in MessageChannel:
|
||||
self.assertEqual(
|
||||
ChannelCapabilityManager.supports_capability(
|
||||
channel, ChannelCapability.PROCESSING_STATUS
|
||||
),
|
||||
channel in supported,
|
||||
)
|
||||
|
||||
def test_slack_processing_status_uses_reaction(self):
|
||||
module = SlackModule()
|
||||
module._channel = MessageChannel.Slack
|
||||
client = MagicMock()
|
||||
client.add_reaction.return_value = True
|
||||
client.remove_reaction.return_value = True
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
module, "get_config", return_value=SimpleNamespace(name="slack-main")
|
||||
),
|
||||
patch.object(module, "get_instance", return_value=client),
|
||||
):
|
||||
status = module.mark_message_processing_started(
|
||||
channel=MessageChannel.Slack,
|
||||
source="slack-main",
|
||||
userid="U01",
|
||||
message_id="1710000000.000100",
|
||||
chat_id="C01",
|
||||
text="hello",
|
||||
)
|
||||
removed = module.mark_message_processing_finished(
|
||||
channel=MessageChannel.Slack,
|
||||
source="slack-main",
|
||||
userid="U01",
|
||||
status=status,
|
||||
)
|
||||
|
||||
client.add_reaction.assert_called_once_with(
|
||||
channel="C01",
|
||||
timestamp="1710000000.000100",
|
||||
emoji="eyes",
|
||||
)
|
||||
client.remove_reaction.assert_called_once_with(
|
||||
channel="C01",
|
||||
timestamp="1710000000.000100",
|
||||
emoji="eyes",
|
||||
)
|
||||
self.assertEqual(status["metadata"]["kind"], "reaction")
|
||||
self.assertTrue(removed)
|
||||
|
||||
def test_slack_parser_exposes_message_location_for_reaction_status(self):
|
||||
module = SlackModule()
|
||||
|
||||
with patch.object(
|
||||
module, "get_config", return_value=SimpleNamespace(name="slack-main")
|
||||
):
|
||||
message = module.message_parser(
|
||||
source="slack-main",
|
||||
body=json.dumps(
|
||||
{
|
||||
"type": "message",
|
||||
"user": "U01",
|
||||
"text": "hello",
|
||||
"ts": "1710000000.000100",
|
||||
"channel": "C01",
|
||||
}
|
||||
),
|
||||
form=None,
|
||||
args=None,
|
||||
)
|
||||
|
||||
self.assertEqual(message.message_id, "1710000000.000100")
|
||||
self.assertEqual(message.chat_id, "C01")
|
||||
|
||||
def test_discord_processing_status_starts_and_stops_typing(self):
|
||||
module = DiscordModule()
|
||||
module._channel = MessageChannel.Discord
|
||||
client = MagicMock()
|
||||
client.start_typing.return_value = True
|
||||
client.stop_typing.return_value = True
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
module, "get_config", return_value=SimpleNamespace(name="discord-main")
|
||||
),
|
||||
patch.object(module, "get_instance", return_value=client),
|
||||
):
|
||||
status = module.mark_message_processing_started(
|
||||
channel=MessageChannel.Discord,
|
||||
source="discord-main",
|
||||
userid="10001",
|
||||
message_id="20002",
|
||||
chat_id="30003",
|
||||
text="hello",
|
||||
)
|
||||
finished = module.mark_message_processing_finished(
|
||||
channel=MessageChannel.Discord,
|
||||
source="discord-main",
|
||||
userid="10001",
|
||||
status=status,
|
||||
)
|
||||
|
||||
client.start_typing.assert_called_once_with(userid="10001", chat_id="30003")
|
||||
client.stop_typing.assert_called_once_with(userid="10001", chat_id="30003")
|
||||
self.assertEqual(status["metadata"]["kind"], "typing")
|
||||
self.assertTrue(finished)
|
||||
|
||||
def test_agent_finish_processing_status_uses_module_interface(self):
|
||||
status = {
|
||||
"channel": MessageChannel.Telegram.value,
|
||||
"source": "telegram-main",
|
||||
"userid": "10001",
|
||||
"message_id": None,
|
||||
"chat_id": "-100",
|
||||
"metadata": {"kind": "typing"},
|
||||
}
|
||||
|
||||
with patch("app.agent.AgentChain") as chain_cls:
|
||||
_finish_processing_status(status, user_id="fallback")
|
||||
|
||||
chain_cls.return_value.run_module.assert_called_once_with(
|
||||
"mark_message_processing_finished",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-main",
|
||||
userid="10001",
|
||||
message_id=None,
|
||||
chat_id="-100",
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
189
tests/test_telegram_typing_lifecycle.py
Normal file
189
tests/test_telegram_typing_lifecycle.py
Normal file
@@ -0,0 +1,189 @@
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app.chain.message import MessageChain
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
time.sleep(0.08)
|
||||
|
||||
self.assertNotIn("chat-3", Telegram._typing_tasks)
|
||||
|
||||
def test_slash_command_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="/sites",
|
||||
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_async_agent_keeps_processing_status_for_worker(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", return_value=True), 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",
|
||||
)
|
||||
|
||||
finish_status.assert_not_called()
|
||||
|
||||
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, "run_module") as run_module:
|
||||
chain._mark_message_processing_finished(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
status=status,
|
||||
original_chat_id="-100",
|
||||
)
|
||||
|
||||
run_module.assert_called_once_with(
|
||||
"mark_message_processing_finished",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
message_id=None,
|
||||
chat_id="-100",
|
||||
status=status.to_dict(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user