From fea7c579c50cc948bdcf35788e9c23013b7e2e91 Mon Sep 17 00:00:00 2001 From: wanger <310779211wym@gmail.com> Date: Sat, 25 Jul 2026 14:04:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81tg=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E7=9A=84=E9=83=A8=E5=88=86=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_main_agent.py | 26 ++-- astrbot/core/message/components.py | 2 + .../platform/sources/telegram/tg_adapter.py | 9 ++ astrbot/core/utils/string_utils.py | 8 ++ tests/fixtures/helpers.py | 28 ++++ tests/test_telegram_adapter.py | 119 +++++++++++++++++ tests/unit/test_astr_main_agent.py | 124 +++++++++++++++++- tests/unit/test_string_utils.py | 21 +++ 8 files changed, 322 insertions(+), 15 deletions(-) create mode 100644 tests/unit/test_string_utils.py diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 9312b8c3af..7742e56935 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -115,7 +115,10 @@ extract_quoted_message_images, extract_quoted_message_text, ) -from astrbot.core.utils.string_utils import normalize_and_dedupe_strings +from astrbot.core.utils.string_utils import ( + normalize_and_dedupe_strings, + normalize_optional_text, +) from astrbot.core.workspace import ( normalize_umo_for_workspace, resolve_workspace_root_for_umo, @@ -850,15 +853,20 @@ async def _process_quote_message( content_parts = [] sender_info = f"({quote.sender_nickname}): " if quote.sender_nickname else "" - message_str = ( - await extract_quoted_message_text( - event, - quote, - settings=quoted_message_settings, + excerpt = normalize_optional_text(quote.selected_excerpt) + if excerpt is not None: + # 发送者手动圈出了原消息的一段,只把这段交给模型 + message_str = f"{excerpt}" + else: + message_str = ( + await extract_quoted_message_text( + event, + quote, + settings=quoted_message_settings, + ) + or quote.message_str + or "[Empty Text]" ) - or quote.message_str - or "[Empty Text]" - ) content_parts.append(f"{sender_info}{message_str}") image_seg = None diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index f0d0a8cbdc..af43fb1d99 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -593,6 +593,8 @@ class Reply(BaseMessageComponent): """被引用的消息发送时间""" message_str: str | None = "" """被引用的消息解析后的纯文本消息字符串""" + selected_excerpt: str | None = None + """发送者在客户端手动选中的原消息片段, 如 `完整消息中的<部分消息>`""" text: str | None = "" """deprecated""" diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index 8e6722ccff..215deed95b 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -33,6 +33,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.media_utils import MediaResolver +from astrbot.core.utils.string_utils import normalize_optional_text from .tg_event import TelegramPlatformEvent @@ -506,6 +507,13 @@ def _apply_caption() -> None: reply_abm = await self.convert_message(reply_update, context, False) if reply_abm: + ## 引用的部分消息。is_manual 为假时是 TG 服务端自动截的开头预览,不是用户的关注点 + tg_quote = update.message.quote + selected_excerpt = ( + normalize_optional_text(tg_quote.text) + if tg_quote is not None and tg_quote.is_manual is True + else None + ) message.message.append( Comp.Reply( id=reply_abm.message_id, @@ -516,6 +524,7 @@ def _apply_caption() -> None: message_str=reply_abm.message_str, text=reply_abm.message_str, qq=reply_abm.sender.user_id, + selected_excerpt=selected_excerpt, ), ) diff --git a/astrbot/core/utils/string_utils.py b/astrbot/core/utils/string_utils.py index 8c2aacb4d1..fdfb8806f8 100644 --- a/astrbot/core/utils/string_utils.py +++ b/astrbot/core/utils/string_utils.py @@ -4,6 +4,14 @@ from typing import Any +def normalize_optional_text(value: str | None) -> str | None: + """去除首尾空白后返回文本;None、空串、纯空白串一律归一为 None。""" + if value is None: + return None + stripped = value.strip() + return None if len(stripped) == 0 else stripped + + def normalize_and_dedupe_strings(items: Iterable[Any] | None) -> list[str]: if items is None: return [] diff --git a/tests/fixtures/helpers.py b/tests/fixtures/helpers.py index f290caff52..f01751374e 100644 --- a/tests/fixtures/helpers.py +++ b/tests/fixtures/helpers.py @@ -100,6 +100,7 @@ def create_mock_update( voice: MagicMock | None = None, sticker: MagicMock | None = None, reply_to_message: MagicMock | None = None, + quote: MagicMock | None = None, caption: str | None = None, entities: list | None = None, caption_entities: list | None = None, @@ -122,6 +123,7 @@ def create_mock_update( voice: 语音对象 sticker: 贴纸对象 reply_to_message: 回复的消息 + quote: 部分引用片段(Bot API 7.0 TextQuote),见 create_mock_text_quote caption: 说明文字 entities: 实体列表 caption_entities: 说明实体列表 @@ -158,6 +160,9 @@ def create_mock_update( message.voice = voice message.sticker = sticker message.reply_to_message = reply_to_message + # 显式置空:MagicMock 会为未赋值的属性自动生成 truthy 子 mock, + # 会让适配器误以为每条消息都带部分引用。 + message.quote = quote message.caption = caption message.entities = entities message.caption_entities = caption_entities @@ -168,6 +173,29 @@ def create_mock_update( return update +def create_mock_text_quote( + text: str, + is_manual: bool | None = True, + position: int = 0, +) -> MagicMock: + """创建模拟的 Telegram TextQuote 对象。 + + Args: + text: 被引用的片段文本 + is_manual: 是否由发送者手动选中。False / None 表示 Telegram 服务端自动生成的引用 + position: 片段在原消息中的起始偏移 + + Returns: + MagicMock: 模拟的 TextQuote 对象 + """ + quote = MagicMock() + quote.text = text + quote.is_manual = is_manual + quote.position = position + quote.entities = None + return quote + + def create_mock_file(file_path: str = "https://api.telegram.org/file/test.jpg"): """创建模拟的 Telegram File 对象。 diff --git a/tests/test_telegram_adapter.py b/tests/test_telegram_adapter.py index 948b84f5ba..7ca7d44e34 100644 --- a/tests/test_telegram_adapter.py +++ b/tests/test_telegram_adapter.py @@ -10,6 +10,7 @@ from tests.fixtures.helpers import ( NoopAwaitable, create_mock_file, + create_mock_text_quote, create_mock_update, make_platform_config, ) @@ -82,6 +83,124 @@ def _build_context() -> MagicMock: return context +_QUOTED_SOURCE_TEXT = "第一段讲 public-api 仓库。第二段讲别的东西。" + + +def _build_quoted_source_message() -> MagicMock: + """构造一条被回复的原消息,供 reply_to_message 使用。""" + return create_mock_update( + message_text=_QUOTED_SOURCE_TEXT, + message_id=42, + user_id=555, + username="alice", + ).message + + +def _find_reply_component( + components: list[Comp.BaseMessageComponent], +) -> Comp.Reply | None: + for component in components: + if isinstance(component, Comp.Reply): + return component + return None + + +@pytest.mark.asyncio +async def test_telegram_manual_quote_populates_reply_selected_excerpt(): + TelegramPlatformAdapter = _load_telegram_adapter() + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + update = create_mock_update( + message_text="这个仓库是干嘛的?", + reply_to_message=_build_quoted_source_message(), + quote=create_mock_text_quote("第一段讲 public-api 仓库。"), + ) + + result = await adapter.convert_message(update, _build_context()) + + assert result is not None + reply = _find_reply_component(result.message) + assert reply is not None + assert reply.selected_excerpt == "第一段讲 public-api 仓库。" + # 原消息全文必须保留,引用消息里的附件靠 chain 才能进入 LLM 请求 + assert reply.message_str == _QUOTED_SOURCE_TEXT + assert reply.chain is not None + assert any( + isinstance(component, Comp.Plain) and component.text == _QUOTED_SOURCE_TEXT + for component in reply.chain + ) + + +@pytest.mark.parametrize("is_manual", [False, None]) +@pytest.mark.asyncio +async def test_telegram_auto_quote_is_ignored(is_manual: bool | None): + """只有 is_manual 明确为 True 才算手动选中;服务端自动截取的预览要忽略。""" + TelegramPlatformAdapter = _load_telegram_adapter() + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + update = create_mock_update( + message_text="这个仓库是干嘛的?", + reply_to_message=_build_quoted_source_message(), + quote=create_mock_text_quote("第一段讲 public-api", is_manual=is_manual), + ) + + result = await adapter.convert_message(update, _build_context()) + + assert result is not None + reply = _find_reply_component(result.message) + assert reply is not None + assert reply.selected_excerpt is None + + +@pytest.mark.asyncio +async def test_telegram_blank_quote_text_is_ignored(): + TelegramPlatformAdapter = _load_telegram_adapter() + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + update = create_mock_update( + message_text="这个仓库是干嘛的?", + reply_to_message=_build_quoted_source_message(), + quote=create_mock_text_quote(" \n "), + ) + + result = await adapter.convert_message(update, _build_context()) + + assert result is not None + reply = _find_reply_component(result.message) + assert reply is not None + assert reply.selected_excerpt is None + + +@pytest.mark.asyncio +async def test_telegram_reply_without_quote_leaves_excerpt_unset(): + TelegramPlatformAdapter = _load_telegram_adapter() + adapter = TelegramPlatformAdapter( + make_platform_config("telegram"), + {}, + asyncio.Queue(), + ) + update = create_mock_update( + message_text="这个仓库是干嘛的?", + reply_to_message=_build_quoted_source_message(), + ) + + result = await adapter.convert_message(update, _build_context()) + + assert result is not None + reply = _find_reply_component(result.message) + assert reply is not None + assert reply.selected_excerpt is None + + @pytest.mark.asyncio async def test_telegram_document_caption_populates_message_text_and_plain(): TelegramPlatformAdapter = _load_telegram_adapter() diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 254d1ea7ef..370c2a3b74 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -801,9 +801,7 @@ async def test_inline_genui_prompt_is_added_with_custom_persona( mock_context.persona_manager.resolve_selected_persona = AsyncMock( return_value=("conv-persona", persona, None, False) ) - mock_event.get_extra.side_effect = ( - lambda key: key == "enable_inline_genui" - ) + mock_event.get_extra.side_effect = lambda key: key == "enable_inline_genui" req = ProviderRequest() req.conversation = MagicMock(persona_id="conv-persona") @@ -818,9 +816,7 @@ async def test_inline_genui_prompt_does_not_require_conversation( ): """Test inline GenUI instructions are added before conversation setup.""" module = ama - mock_event.get_extra.side_effect = ( - lambda key: key == "enable_inline_genui" - ) + mock_event.get_extra.side_effect = lambda key: key == "enable_inline_genui" req = ProviderRequest() await module._ensure_persona_and_skills(req, {}, mock_context, mock_event) @@ -1287,6 +1283,122 @@ async def test_decorate_llm_request_no_conversation(self, mock_event, mock_conte assert req.prompt == "Hello" +class TestProcessQuoteMessage: + """Tests for _process_quote_message function.""" + + FULL_TEXT = "第一段讲 public-api 仓库。第二段讲别的东西。" + + def _build_reply(self, selected_excerpt: str | None = None) -> Reply: + return Reply( + id="42", + chain=[Plain(text=self.FULL_TEXT)], + sender_id="555", + sender_nickname="alice", + message_str=self.FULL_TEXT, + selected_excerpt=selected_excerpt, + ) + + @pytest.mark.asyncio + async def test_quote_without_selection_uses_full_message(self, mock_event): + module = ama + mock_event.message_obj.message = [self._build_reply()] + req = ProviderRequest(prompt="这个仓库是干嘛的?") + + await module._process_quote_message(mock_event, req, "", MagicMock()) + + assert len(req.extra_user_content_parts) == 1 + assert ( + req.extra_user_content_parts[0].text + == f"\n(alice): {self.FULL_TEXT}\n" + ) + + @pytest.mark.asyncio + async def test_manual_quote_uses_selected_fragment_only(self, mock_event): + module = ama + selected = "第一段讲 public-api 仓库。" + mock_event.message_obj.message = [self._build_reply(selected_excerpt=selected)] + req = ProviderRequest(prompt="这个仓库是干嘛的?") + + await module._process_quote_message(mock_event, req, "", MagicMock()) + + quoted_part = req.extra_user_content_parts[0].text + assert quoted_part == ( + "\n" + f"(alice): {selected}\n" + "" + ) + assert "第二段讲别的东西" not in quoted_part + + @pytest.mark.asyncio + async def test_blank_excerpt_falls_back_to_full_message(self, mock_event): + """纯空白的片段等同于没有片段,不能产出空的 标签。""" + module = ama + mock_event.message_obj.message = [self._build_reply(selected_excerpt=" \n ")] + req = ProviderRequest(prompt="这个仓库是干嘛的?") + + await module._process_quote_message(mock_event, req, "", MagicMock()) + + assert ( + req.extra_user_content_parts[0].text + == f"\n(alice): {self.FULL_TEXT}\n" + ) + + @pytest.mark.asyncio + async def test_manual_quote_without_sender_nickname(self, mock_event): + module = ama + reply = self._build_reply(selected_excerpt="第一段讲 public-api 仓库。") + reply.sender_nickname = "" + mock_event.message_obj.message = [reply] + req = ProviderRequest(prompt="这个仓库是干嘛的?") + + await module._process_quote_message(mock_event, req, "", MagicMock()) + + assert req.extra_user_content_parts[0].text == ( + "\n" + "第一段讲 public-api 仓库。\n" + "" + ) + + @pytest.mark.asyncio + async def test_manual_quote_still_captions_quoted_image(self, mock_event): + """片段引用不应影响引用消息里图片的 caption 流程。""" + module = ama + reply = self._build_reply(selected_excerpt="第一段讲 public-api 仓库。") + reply.chain = [Plain(text=self.FULL_TEXT), Image(file="pic.jpg")] + mock_event.message_obj.message = [reply] + req = ProviderRequest(prompt="这个仓库是干嘛的?") + + provider = MagicMock(spec=Provider) + provider.text_chat = AsyncMock( + return_value=MagicMock(completion_text="a screenshot") + ) + plugin_context = MagicMock() + plugin_context.get_provider_by_id.return_value = provider + + with ( + patch.object( + Image, + "convert_to_file_path", + AsyncMock(return_value="/tmp/pic.jpg"), + ), + patch.object( + module, + "_compress_image_for_provider", + AsyncMock(return_value="/tmp/pic.jpg"), + ), + ): + await module._process_quote_message( + mock_event, req, "cap-provider", plugin_context + ) + + quoted_part = req.extra_user_content_parts[0].text + assert ( + "(alice): 第一段讲 public-api 仓库。" + in quoted_part + ) + assert "[Image Caption in quoted message]: a screenshot" in quoted_part + + class TestPluginToolFix: """Tests for _plugin_tool_fix function.""" diff --git a/tests/unit/test_string_utils.py b/tests/unit/test_string_utils.py new file mode 100644 index 0000000000..4db48b3be1 --- /dev/null +++ b/tests/unit/test_string_utils.py @@ -0,0 +1,21 @@ +"""Tests for astrbot.core.utils.string_utils.""" + +import pytest + +from astrbot.core.utils.string_utils import normalize_optional_text + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + ("", None), + (" ", None), + ("\n\t ", None), + ("hello", "hello"), + (" hello ", "hello"), + (" 多行\n文本 ", "多行\n文本"), + ], +) +def test_normalize_optional_text(value: str | None, expected: str | None): + assert normalize_optional_text(value) == expected