Skip to content

Commit 4ff07e3

Browse files
authored
fix: 完善转发引用解析与图片回退并支持配置化控制 (#5054)
* feat: support fallback image parsing for quoted messages * fix: fallback parse quoted images when reply chain has placeholders * style: format network utils with ruff * test: expand quoted parser coverage and improve fallback diagnostics * fix: fallback to text-only retry when image requests fail * fix: tighten image fallback and resolve nested quoted forwards * refactor: simplify quoted message extraction and dedupe images * fix: harden quoted parsing and openai error candidates * fix: harden quoted image ref normalization * refactor: organize quoted parser settings and logging * fix: cap quoted fallback images and avoid retry loops * refactor: split quoted message parser into focused modules * refactor: share onebot segment parsing logic * refactor: unify quoted message parsing flow * feat: move quoted parser tuning to provider settings * fix: add missing i18n metadata for quoted parser settings * chore: refine forwarded message setting labels
1 parent 473e01a commit 4ff07e3

17 files changed

Lines changed: 2422 additions & 28 deletions

File tree

astrbot/core/astr_main_agent.py

Lines changed: 110 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@
5252
)
5353
from astrbot.core.utils.file_extract import extract_file_moonshotai
5454
from astrbot.core.utils.llm_metadata import LLM_METADATAS
55+
from astrbot.core.utils.quoted_message.settings import (
56+
SETTINGS as DEFAULT_QUOTED_MESSAGE_SETTINGS,
57+
)
58+
from astrbot.core.utils.quoted_message.settings import (
59+
QuotedMessageParserSettings,
60+
)
61+
from astrbot.core.utils.quoted_message_parser import (
62+
extract_quoted_message_images,
63+
extract_quoted_message_text,
64+
)
65+
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
5566

5667

5768
@dataclass(slots=True)
@@ -108,6 +119,8 @@ class MainAgentBuildConfig:
108119
provider_settings: dict = field(default_factory=dict)
109120
subagent_orchestrator: dict = field(default_factory=dict)
110121
timezone: str | None = None
122+
max_quoted_fallback_images: int = 20
123+
"""Maximum number of images injected from quoted-message fallback extraction."""
111124

112125

113126
@dataclass(slots=True)
@@ -470,11 +483,29 @@ async def _ensure_img_caption(
470483
logger.error("处理图片描述失败: %s", exc)
471484

472485

486+
def _append_quoted_image_attachment(req: ProviderRequest, image_path: str) -> None:
487+
req.extra_user_content_parts.append(
488+
TextPart(text=f"[Image Attachment in quoted message: path {image_path}]")
489+
)
490+
491+
492+
def _get_quoted_message_parser_settings(
493+
provider_settings: dict[str, object] | None,
494+
) -> QuotedMessageParserSettings:
495+
if not isinstance(provider_settings, dict):
496+
return DEFAULT_QUOTED_MESSAGE_SETTINGS
497+
overrides = provider_settings.get("quoted_message_parser")
498+
if not isinstance(overrides, dict):
499+
return DEFAULT_QUOTED_MESSAGE_SETTINGS
500+
return DEFAULT_QUOTED_MESSAGE_SETTINGS.with_overrides(overrides)
501+
502+
473503
async def _process_quote_message(
474504
event: AstrMessageEvent,
475505
req: ProviderRequest,
476506
img_cap_prov_id: str,
477507
plugin_context: Context,
508+
quoted_message_settings: QuotedMessageParserSettings = DEFAULT_QUOTED_MESSAGE_SETTINGS,
478509
) -> None:
479510
quote = None
480511
for comp in event.message_obj.message:
@@ -486,7 +517,15 @@ async def _process_quote_message(
486517

487518
content_parts = []
488519
sender_info = f"({quote.sender_nickname}): " if quote.sender_nickname else ""
489-
message_str = quote.message_str or "[Empty Text]"
520+
message_str = (
521+
await extract_quoted_message_text(
522+
event,
523+
quote,
524+
settings=quoted_message_settings,
525+
)
526+
or quote.message_str
527+
or "[Empty Text]"
528+
)
490529
content_parts.append(f"{sender_info}{message_str}")
491530

492531
image_seg = None
@@ -592,11 +631,13 @@ async def _decorate_llm_request(
592631
)
593632

594633
img_cap_prov_id = cfg.get("default_image_caption_provider_id") or ""
634+
quoted_message_settings = _get_quoted_message_parser_settings(cfg)
595635
await _process_quote_message(
596636
event,
597637
req,
598638
img_cap_prov_id,
599639
plugin_context,
640+
quoted_message_settings,
600641
)
601642

602643
tz = config.timezone
@@ -886,32 +927,78 @@ async def build_main_agent(
886927
)
887928
# quoted message attachments
888929
reply_comps = [
889-
comp
890-
for comp in event.message_obj.message
891-
if isinstance(comp, Reply) and comp.chain
930+
comp for comp in event.message_obj.message if isinstance(comp, Reply)
892931
]
932+
quoted_message_settings = _get_quoted_message_parser_settings(
933+
config.provider_settings
934+
)
935+
fallback_quoted_image_count = 0
893936
for comp in reply_comps:
894-
if not comp.chain:
895-
continue
896-
for reply_comp in comp.chain:
897-
if isinstance(reply_comp, Image):
898-
image_path = await reply_comp.convert_to_file_path()
899-
req.image_urls.append(image_path)
900-
req.extra_user_content_parts.append(
901-
TextPart(
902-
text=f"[Image Attachment in quoted message: path {image_path}]"
937+
has_embedded_image = False
938+
if comp.chain:
939+
for reply_comp in comp.chain:
940+
if isinstance(reply_comp, Image):
941+
has_embedded_image = True
942+
image_path = await reply_comp.convert_to_file_path()
943+
req.image_urls.append(image_path)
944+
_append_quoted_image_attachment(req, image_path)
945+
elif isinstance(reply_comp, File):
946+
file_path = await reply_comp.get_file()
947+
file_name = reply_comp.name or os.path.basename(file_path)
948+
req.extra_user_content_parts.append(
949+
TextPart(
950+
text=(
951+
f"[File Attachment in quoted message: "
952+
f"name {file_name}, path {file_path}]"
953+
)
954+
)
955+
)
956+
957+
# Fallback quoted image extraction for reply-id-only payloads, or when
958+
# embedded reply chain only contains placeholders (e.g. [Forward Message], [Image]).
959+
if not has_embedded_image:
960+
try:
961+
fallback_images = normalize_and_dedupe_strings(
962+
await extract_quoted_message_images(
963+
event,
964+
comp,
965+
settings=quoted_message_settings,
903966
)
904967
)
905-
elif isinstance(reply_comp, File):
906-
file_path = await reply_comp.get_file()
907-
file_name = reply_comp.name or os.path.basename(file_path)
908-
req.extra_user_content_parts.append(
909-
TextPart(
910-
text=(
911-
f"[File Attachment in quoted message: "
912-
f"name {file_name}, path {file_path}]"
913-
)
968+
remaining_limit = max(
969+
config.max_quoted_fallback_images
970+
- fallback_quoted_image_count,
971+
0,
972+
)
973+
if remaining_limit <= 0 and fallback_images:
974+
logger.warning(
975+
"Skip quoted fallback images due to limit=%d for umo=%s",
976+
config.max_quoted_fallback_images,
977+
event.unified_msg_origin,
978+
)
979+
continue
980+
if len(fallback_images) > remaining_limit:
981+
logger.warning(
982+
"Truncate quoted fallback images for umo=%s, reply_id=%s from %d to %d",
983+
event.unified_msg_origin,
984+
getattr(comp, "id", None),
985+
len(fallback_images),
986+
remaining_limit,
914987
)
988+
fallback_images = fallback_images[:remaining_limit]
989+
for image_ref in fallback_images:
990+
if image_ref in req.image_urls:
991+
continue
992+
req.image_urls.append(image_ref)
993+
fallback_quoted_image_count += 1
994+
_append_quoted_image_attachment(req, image_ref)
995+
except Exception as exc: # noqa: BLE001
996+
logger.warning(
997+
"Failed to resolve fallback quoted images for umo=%s, reply_id=%s: %s",
998+
event.unified_msg_origin,
999+
getattr(comp, "id", None),
1000+
exc,
1001+
exc_info=True,
9151002
)
9161003

9171004
conversation = await _get_session_conv(event, plugin_context)
@@ -921,6 +1008,7 @@ async def build_main_agent(
9211008

9221009
if isinstance(req.contexts, str):
9231010
req.contexts = json.loads(req.contexts)
1011+
req.image_urls = normalize_and_dedupe_strings(req.image_urls)
9241012

9251013
if config.file_extract_enabled:
9261014
try:

astrbot/core/config/default.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@
9999
"streaming_response": False,
100100
"show_tool_use_status": False,
101101
"sanitize_context_by_modalities": False,
102+
"max_quoted_fallback_images": 20,
103+
"quoted_message_parser": {
104+
"max_component_chain_depth": 4,
105+
"max_forward_node_depth": 6,
106+
"max_forward_fetch": 32,
107+
"warn_on_action_failure": False,
108+
},
102109
"agent_runner_type": "local",
103110
"dify_agent_runner_provider_id": "",
104111
"coze_agent_runner_provider_id": "",
@@ -2908,6 +2915,46 @@ class ChatProviderTemplate(TypedDict):
29082915
"provider_settings.agent_runner_type": "local",
29092916
},
29102917
},
2918+
"provider_settings.max_quoted_fallback_images": {
2919+
"description": "引用图片回退解析上限",
2920+
"type": "int",
2921+
"hint": "引用/转发消息回退解析图片时的最大注入数量,超出会截断。",
2922+
"condition": {
2923+
"provider_settings.agent_runner_type": "local",
2924+
},
2925+
},
2926+
"provider_settings.quoted_message_parser.max_component_chain_depth": {
2927+
"description": "引用解析组件链深度",
2928+
"type": "int",
2929+
"hint": "解析 Reply 组件链时允许的最大递归深度。",
2930+
"condition": {
2931+
"provider_settings.agent_runner_type": "local",
2932+
},
2933+
},
2934+
"provider_settings.quoted_message_parser.max_forward_node_depth": {
2935+
"description": "引用解析转发节点深度",
2936+
"type": "int",
2937+
"hint": "解析合并转发节点时允许的最大递归深度。",
2938+
"condition": {
2939+
"provider_settings.agent_runner_type": "local",
2940+
},
2941+
},
2942+
"provider_settings.quoted_message_parser.max_forward_fetch": {
2943+
"description": "引用解析转发拉取上限",
2944+
"type": "int",
2945+
"hint": "递归拉取 get_forward_msg 的最大次数。",
2946+
"condition": {
2947+
"provider_settings.agent_runner_type": "local",
2948+
},
2949+
},
2950+
"provider_settings.quoted_message_parser.warn_on_action_failure": {
2951+
"description": "引用解析 action 失败告警",
2952+
"type": "bool",
2953+
"hint": "开启后,get_msg/get_forward_msg 全部尝试失败时输出 warning 日志。",
2954+
"condition": {
2955+
"provider_settings.agent_runner_type": "local",
2956+
},
2957+
},
29112958
"provider_settings.max_agent_step": {
29122959
"description": "工具调用轮数上限",
29132960
"type": "int",

astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ async def initialize(self, ctx: PipelineContext) -> None:
123123
provider_settings=settings,
124124
subagent_orchestrator=conf.get("subagent_orchestrator", {}),
125125
timezone=self.ctx.plugin_manager.context.get_config().get("timezone"),
126+
max_quoted_fallback_images=settings.get("max_quoted_fallback_images", 20),
126127
)
127128

128129
async def process(

0 commit comments

Comments
 (0)