Skip to content
Merged
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
33 changes: 33 additions & 0 deletions astrbot/builtin_stars/astrbot/group_chat_context.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
import datetime
import json
import random
import uuid
from collections import defaultdict, deque
Expand All @@ -14,6 +15,7 @@
File,
Forward,
Image,
Json,
Plain,
Record,
Reply,
Expand Down Expand Up @@ -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(),
Expand Down
10 changes: 5 additions & 5 deletions astrbot/builtin_stars/astrbot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)[
Expand Down
92 changes: 91 additions & 1 deletion tests/unit/test_group_chat_context_wiring.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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