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