Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions astrbot/core/astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"<selected_excerpt>{excerpt}</selected_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
Expand Down
2 changes: 2 additions & 0 deletions astrbot/core/message/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,8 @@ class Reply(BaseMessageComponent):
"""被引用的消息发送时间"""
message_str: str | None = ""
"""被引用的消息解析后的纯文本消息字符串"""
selected_excerpt: str | None = None
"""发送者在客户端手动选中的原消息片段, 如 `完整消息中的<部分消息>`"""

text: str | None = ""
"""deprecated"""
Expand Down
9 changes: 9 additions & 0 deletions astrbot/core/platform/sources/telegram/tg_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
),
)

Expand Down
8 changes: 8 additions & 0 deletions astrbot/core/utils/string_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
Expand Down
28 changes: 28 additions & 0 deletions tests/fixtures/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: 说明实体列表
Expand Down Expand Up @@ -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
Expand All @@ -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 对象。

Expand Down
119 changes: 119 additions & 0 deletions tests/test_telegram_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from tests.fixtures.helpers import (
NoopAwaitable,
create_mock_file,
create_mock_text_quote,
create_mock_update,
make_platform_config,
)
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading