From e62a972e0b43677c804728e48f0932cc76d52c39 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sun, 10 May 2026 01:17:59 +0800 Subject: [PATCH 1/4] fix(core): pass images through active replies --- astrbot/builtin_stars/astrbot/main.py | 8 ++++ tests/unit/test_builtin_astrbot_main.py | 53 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/unit/test_builtin_astrbot_main.py diff --git a/astrbot/builtin_stars/astrbot/main.py b/astrbot/builtin_stars/astrbot/main.py index 828e49552f..3db85a9852 100644 --- a/astrbot/builtin_stars/astrbot/main.py +++ b/astrbot/builtin_stars/astrbot/main.py @@ -177,6 +177,13 @@ async def on_message(self, event: AstrMessageEvent): ) prompt = event.message_str + image_urls = [] + for comp in event.message_obj.message: + if isinstance(comp, Image): + try: + image_urls.append(await comp.convert_to_file_path()) + except BaseException as e: + logger.error(f"主动回复处理图片失败: {e}") if not conv: logger.error("未找到对话,无法主动回复") @@ -185,6 +192,7 @@ async def on_message(self, event: AstrMessageEvent): yield event.request_llm( prompt=prompt, session_id=event.session_id, + image_urls=image_urls, conversation=conv, ) except BaseException as e: diff --git a/tests/unit/test_builtin_astrbot_main.py b/tests/unit/test_builtin_astrbot_main.py new file mode 100644 index 0000000000..0ddd594a98 --- /dev/null +++ b/tests/unit/test_builtin_astrbot_main.py @@ -0,0 +1,53 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from astrbot.api.message_components import Image +from astrbot.builtin_stars.astrbot.main import Main + + +@pytest.mark.asyncio +async def test_active_reply_passes_image_urls_to_llm(monkeypatch): + main = Main.__new__(Main) + main.ltm = MagicMock() + main.ltm.need_active_reply = AsyncMock(return_value=True) + + context = MagicMock() + context.get_config.return_value = { + "provider_ltm_settings": { + "active_reply": {"enable": True}, + "group_icl_enable": False, + }, + } + context.get_using_provider.return_value = MagicMock() + context.conversation_manager.get_curr_conversation_id = AsyncMock( + return_value="conv-id", + ) + conversation = MagicMock() + context.conversation_manager.get_conversation = AsyncMock( + return_value=conversation, + ) + main.context = context + + async def convert_to_file_path(self): + return "/tmp/picture.png" + + monkeypatch.setattr(Image, "convert_to_file_path", convert_to_file_path) + image = Image(file="file:///tmp/picture.png") + event = MagicMock() + event.message_obj.message = [image] + event.message_str = "" + event.session_id = "session-id" + event.unified_msg_origin = "platform:group:123" + request = object() + event.request_llm.return_value = request + + results = [item async for item in main.on_message(event)] + + assert results == [request] + event.request_llm.assert_called_once_with( + prompt="", + session_id="session-id", + image_urls=["/tmp/picture.png"], + conversation=conversation, + ) From cee6f0e977bbdaaf6c90b4580698822b8fe07ab8 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sun, 10 May 2026 13:02:16 +0800 Subject: [PATCH 2/4] fix: harden active reply image collection --- astrbot/builtin_stars/astrbot/main.py | 17 ++++++-- tests/unit/test_builtin_astrbot_main.py | 58 ++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/astrbot/builtin_stars/astrbot/main.py b/astrbot/builtin_stars/astrbot/main.py index 3db85a9852..c1500a5d1f 100644 --- a/astrbot/builtin_stars/astrbot/main.py +++ b/astrbot/builtin_stars/astrbot/main.py @@ -1,5 +1,6 @@ import copy import traceback +from collections.abc import Iterable from sys import maxsize import astrbot.api.message_components as Comp @@ -19,6 +20,13 @@ from .long_term_memory import LongTermMemory +def _iter_message_components(event: AstrMessageEvent): + messages = getattr(getattr(event, "message_obj", None), "message", None) + if not isinstance(messages, Iterable) or isinstance(messages, (str, bytes)): + return () + return tuple(messages) + + class Main(star.Star): def __init__(self, context: star.Context) -> None: self.context = context @@ -134,8 +142,9 @@ def ltm_enabled(self, event: AstrMessageEvent): @filter.platform_adapter_type(filter.PlatformAdapterType.ALL) async def on_message(self, event: AstrMessageEvent): """群聊记忆增强""" + message_components = _iter_message_components(event) has_image_or_plain = False - for comp in event.message_obj.message: + for comp in message_components: if isinstance(comp, Plain) or isinstance(comp, Image): has_image_or_plain = True break @@ -178,12 +187,12 @@ async def on_message(self, event: AstrMessageEvent): prompt = event.message_str image_urls = [] - for comp in event.message_obj.message: + for comp in message_components: if isinstance(comp, Image): try: image_urls.append(await comp.convert_to_file_path()) - except BaseException as e: - logger.error(f"主动回复处理图片失败: {e}") + except Exception: + logger.exception("主动回复处理图片失败") if not conv: logger.error("未找到对话,无法主动回复") diff --git a/tests/unit/test_builtin_astrbot_main.py b/tests/unit/test_builtin_astrbot_main.py index 0ddd594a98..747e6bd6ea 100644 --- a/tests/unit/test_builtin_astrbot_main.py +++ b/tests/unit/test_builtin_astrbot_main.py @@ -6,8 +6,7 @@ from astrbot.builtin_stars.astrbot.main import Main -@pytest.mark.asyncio -async def test_active_reply_passes_image_urls_to_llm(monkeypatch): +def _active_reply_main(): main = Main.__new__(Main) main.ltm = MagicMock() main.ltm.need_active_reply = AsyncMock(return_value=True) @@ -28,6 +27,12 @@ async def test_active_reply_passes_image_urls_to_llm(monkeypatch): return_value=conversation, ) main.context = context + return main, conversation + + +@pytest.mark.asyncio +async def test_active_reply_passes_image_urls_to_llm(monkeypatch): + main, conversation = _active_reply_main() async def convert_to_file_path(self): return "/tmp/picture.png" @@ -51,3 +56,52 @@ async def convert_to_file_path(self): image_urls=["/tmp/picture.png"], conversation=conversation, ) + + +@pytest.mark.asyncio +async def test_active_reply_skips_failed_image_conversion(monkeypatch): + main, conversation = _active_reply_main() + + async def convert_to_file_path(self): + raise RuntimeError("broken image") + + mock_logger = MagicMock() + monkeypatch.setattr( + "astrbot.builtin_stars.astrbot.main.logger", + mock_logger, + ) + monkeypatch.setattr(Image, "convert_to_file_path", convert_to_file_path) + image = Image(file="file:///tmp/picture.png") + event = MagicMock() + event.message_obj.message = [image] + event.message_str = "" + event.session_id = "session-id" + event.unified_msg_origin = "platform:group:123" + request = object() + event.request_llm.return_value = request + + results = [item async for item in main.on_message(event)] + + assert results == [request] + mock_logger.exception.assert_called_once_with( + "主动回复处理图片失败", + ) + event.request_llm.assert_called_once_with( + prompt="", + session_id="session-id", + image_urls=[], + conversation=conversation, + ) + + +@pytest.mark.asyncio +async def test_active_reply_ignores_non_iterable_messages(): + main, _ = _active_reply_main() + event = MagicMock() + event.message_obj.message = None + event.unified_msg_origin = "platform:group:123" + + results = [item async for item in main.on_message(event)] + + assert results == [] + event.request_llm.assert_not_called() From 6de8118c56bc86f16d0dff3683e6d993869c266f Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sun, 10 May 2026 13:07:10 +0800 Subject: [PATCH 3/4] test: avoid logger coupling in active reply test --- tests/unit/test_builtin_astrbot_main.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/unit/test_builtin_astrbot_main.py b/tests/unit/test_builtin_astrbot_main.py index 747e6bd6ea..1517cc9ade 100644 --- a/tests/unit/test_builtin_astrbot_main.py +++ b/tests/unit/test_builtin_astrbot_main.py @@ -65,11 +65,6 @@ async def test_active_reply_skips_failed_image_conversion(monkeypatch): async def convert_to_file_path(self): raise RuntimeError("broken image") - mock_logger = MagicMock() - monkeypatch.setattr( - "astrbot.builtin_stars.astrbot.main.logger", - mock_logger, - ) monkeypatch.setattr(Image, "convert_to_file_path", convert_to_file_path) image = Image(file="file:///tmp/picture.png") event = MagicMock() @@ -83,9 +78,6 @@ async def convert_to_file_path(self): results = [item async for item in main.on_message(event)] assert results == [request] - mock_logger.exception.assert_called_once_with( - "主动回复处理图片失败", - ) event.request_llm.assert_called_once_with( prompt="", session_id="session-id", From a0d8b51191f4db4c92ebb0317217296369ff5fb7 Mon Sep 17 00:00:00 2001 From: Weilong Liao <37870767+Soulter@users.noreply.github.com> Date: Sat, 16 May 2026 01:33:31 +0800 Subject: [PATCH 4/4] Delete tests/unit/test_builtin_astrbot_main.py --- tests/unit/test_builtin_astrbot_main.py | 99 ------------------------- 1 file changed, 99 deletions(-) delete mode 100644 tests/unit/test_builtin_astrbot_main.py diff --git a/tests/unit/test_builtin_astrbot_main.py b/tests/unit/test_builtin_astrbot_main.py deleted file mode 100644 index 1517cc9ade..0000000000 --- a/tests/unit/test_builtin_astrbot_main.py +++ /dev/null @@ -1,99 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from astrbot.api.message_components import Image -from astrbot.builtin_stars.astrbot.main import Main - - -def _active_reply_main(): - main = Main.__new__(Main) - main.ltm = MagicMock() - main.ltm.need_active_reply = AsyncMock(return_value=True) - - context = MagicMock() - context.get_config.return_value = { - "provider_ltm_settings": { - "active_reply": {"enable": True}, - "group_icl_enable": False, - }, - } - context.get_using_provider.return_value = MagicMock() - context.conversation_manager.get_curr_conversation_id = AsyncMock( - return_value="conv-id", - ) - conversation = MagicMock() - context.conversation_manager.get_conversation = AsyncMock( - return_value=conversation, - ) - main.context = context - return main, conversation - - -@pytest.mark.asyncio -async def test_active_reply_passes_image_urls_to_llm(monkeypatch): - main, conversation = _active_reply_main() - - async def convert_to_file_path(self): - return "/tmp/picture.png" - - monkeypatch.setattr(Image, "convert_to_file_path", convert_to_file_path) - image = Image(file="file:///tmp/picture.png") - event = MagicMock() - event.message_obj.message = [image] - event.message_str = "" - event.session_id = "session-id" - event.unified_msg_origin = "platform:group:123" - request = object() - event.request_llm.return_value = request - - results = [item async for item in main.on_message(event)] - - assert results == [request] - event.request_llm.assert_called_once_with( - prompt="", - session_id="session-id", - image_urls=["/tmp/picture.png"], - conversation=conversation, - ) - - -@pytest.mark.asyncio -async def test_active_reply_skips_failed_image_conversion(monkeypatch): - main, conversation = _active_reply_main() - - async def convert_to_file_path(self): - raise RuntimeError("broken image") - - monkeypatch.setattr(Image, "convert_to_file_path", convert_to_file_path) - image = Image(file="file:///tmp/picture.png") - event = MagicMock() - event.message_obj.message = [image] - event.message_str = "" - event.session_id = "session-id" - event.unified_msg_origin = "platform:group:123" - request = object() - event.request_llm.return_value = request - - results = [item async for item in main.on_message(event)] - - assert results == [request] - event.request_llm.assert_called_once_with( - prompt="", - session_id="session-id", - image_urls=[], - conversation=conversation, - ) - - -@pytest.mark.asyncio -async def test_active_reply_ignores_non_iterable_messages(): - main, _ = _active_reply_main() - event = MagicMock() - event.message_obj.message = None - event.unified_msg_origin = "platform:group:123" - - results = [item async for item in main.on_message(event)] - - assert results == [] - event.request_llm.assert_not_called()