diff --git a/astrbot/builtin_stars/astrbot/group_chat_context.py b/astrbot/builtin_stars/astrbot/group_chat_context.py index ebbaf20364..6e832d4cb0 100644 --- a/astrbot/builtin_stars/astrbot/group_chat_context.py +++ b/astrbot/builtin_stars/astrbot/group_chat_context.py @@ -1,5 +1,6 @@ import asyncio import datetime +import json import random import uuid from collections import defaultdict, deque @@ -14,6 +15,7 @@ File, Forward, Image, + Json, Plain, Record, Reply, @@ -217,6 +219,37 @@ async def _format_message(self, event: AstrMessageEvent, cfg: dict) -> str: logger.error(f"获取图片描述失败: {e}") else: parts.append(" [Image]") + elif isinstance(comp, Json): + card_data = comp.data + if isinstance(card_data, dict) and isinstance( + card_data.get("data"), str + ): + try: + nested_data = json.loads(card_data["data"]) + if isinstance(nested_data, dict): + card_data = nested_data + except json.JSONDecodeError: + pass + + detail = {} + if isinstance(card_data, dict): + meta = card_data.get("meta") + if isinstance(meta, dict): + candidate = meta.get("detail_1") or meta.get("news") + if isinstance(candidate, dict): + detail = candidate + + fields = [] + for label, value in ( + ("Title", detail.get("title")), + ("Description", detail.get("desc")), + ("URL", detail.get("qqdocurl") or detail.get("jumpUrl")), + ): + if isinstance(value, str) and value.strip(): + normalized = " ".join(value.split()) + fields.append(f"{label}: {_truncate_reply_text(normalized)}") + suffix = f": {'; '.join(fields)}" if fields else "" + parts.append(f" [Shared Card{suffix}]") elif isinstance(comp, At): is_at_self = str(comp.qq) in ( event.get_self_id(), diff --git a/astrbot/builtin_stars/astrbot/main.py b/astrbot/builtin_stars/astrbot/main.py index 2b1729e6e7..cc2c368a23 100644 --- a/astrbot/builtin_stars/astrbot/main.py +++ b/astrbot/builtin_stars/astrbot/main.py @@ -6,7 +6,7 @@ import astrbot.api.message_components as Comp from astrbot.api import star from astrbot.api.event import AstrMessageEvent, filter -from astrbot.api.message_components import Image, Plain +from astrbot.api.message_components import Image, Json, Plain from astrbot.api.provider import LLMResponse, ProviderRequest from astrbot.core import logger from astrbot.core.message.message_event_result import MessageChain @@ -197,10 +197,10 @@ async def persist_group_message(self, event: AstrMessageEvent) -> None: async def on_message(self, event: AstrMessageEvent): """群聊上下文感知""" message_components = _iter_message_components(event) - has_image_or_plain = False + has_context_content = False for comp in message_components: - if isinstance(comp, Plain) or isinstance(comp, Image): - has_image_or_plain = True + if isinstance(comp, Plain | Image | Json): + has_context_content = True break group_context_enabled = False @@ -210,7 +210,7 @@ async def on_message(self, event: AstrMessageEvent): except BaseException as e: logger.error(f"group chat context: {e}") - if group_context_enabled and self.group_chat_context and has_image_or_plain: + if group_context_enabled and self.group_chat_context and has_context_content: need_active = await self.group_chat_context.need_active_reply(event) group_icl_enable = self.context.get_config(umo=event.unified_msg_origin)[ diff --git a/tests/unit/test_group_chat_context_wiring.py b/tests/unit/test_group_chat_context_wiring.py index 534e1fccbc..a103d4f913 100644 --- a/tests/unit/test_group_chat_context_wiring.py +++ b/tests/unit/test_group_chat_context_wiring.py @@ -1,10 +1,12 @@ +import json from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest -from astrbot.api.message_components import Plain +from astrbot.api.message_components import Json, Plain from astrbot.api.provider import LLMResponse +from astrbot.builtin_stars.astrbot.group_chat_context import GroupChatContext from astrbot.builtin_stars.astrbot.main import Main from astrbot.core.message.message_event_result import MessageChain from astrbot.core.platform.message_type import MessageType @@ -149,6 +151,30 @@ async def test_on_message_does_not_clear_group_context_on_first_enabled_message( main.group_chat_context.remove_session.assert_not_called() +@pytest.mark.asyncio +async def test_on_message_records_json_card_and_checks_active_reply(): + main = Main.__new__(Main) + main.context = MagicMock() + main.context.get_config.return_value = { + "provider_ltm_settings": { + "group_icl_enable": True, + "active_reply": {"enable": False}, + }, + } + main.group_chat_context = SimpleNamespace( + need_active_reply=AsyncMock(return_value=False), + handle_message=AsyncMock(), + ) + event = make_event() + event.message_obj.message = [Json(data={"meta": {"news": {"title": "News"}}})] + + async for _ in main.on_message(event): + pass + + main.group_chat_context.need_active_reply.assert_awaited_once_with(event) + main.group_chat_context.handle_message.assert_awaited_once_with(event) + + @pytest.mark.asyncio async def test_on_message_skips_recording_when_command_handler_matched(): """A slash-command message (handlers_parsed_params non-empty) must not be @@ -210,3 +236,67 @@ async def test_llm_response_persists_final_complete_chain(): sender_name="bot", max_messages=700, ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("card_data", "expected"), + [ + ( + { + "meta": { + "detail_1": { + "title": "WeChat AI models", + "desc": "AI learning\nwith examples", + "qqdocurl": "https://example.com/detail", + } + } + }, + " [Shared Card: Title: WeChat AI models; Description: AI learning " + "with examples; URL: https://example.com/detail]", + ), + ( + { + "data": json.dumps( + { + "meta": { + "news": { + "title": "Wrapped card", + "jumpUrl": "https://example.com/news", + } + } + } + ) + }, + " [Shared Card: Title: Wrapped card; URL: https://example.com/news]", + ), + ({"app": "com.example.unknown"}, " [Shared Card]"), + ], +) +async def test_format_message_summarizes_json_card(card_data, expected): + context = GroupChatContext(MagicMock(), MagicMock()) + event = MagicMock() + event.message_obj = SimpleNamespace(sender=SimpleNamespace(nickname="Alice")) + event.get_messages.return_value = [Json(data=card_data)] + + formatted = await context._format_message(event, {}) + + assert formatted.endswith(expected) + + +@pytest.mark.asyncio +async def test_format_message_truncates_long_json_card_fields(): + context = GroupChatContext(MagicMock(), MagicMock()) + event = MagicMock() + event.message_obj = SimpleNamespace(sender=SimpleNamespace(nickname="Alice")) + event.get_messages.return_value = [ + Json( + data={ + "meta": {"news": {"desc": "a" * 201}}, + } + ) + ] + + formatted = await context._format_message(event, {}) + + assert f"Description: {'a' * 200}...]" in formatted