diff --git a/CHANGELOG.md b/CHANGELOG.md index cef75c0..b7cbaf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - **Provider over-return 数量保护**:修复部分上游 API 在请求 `num=1` 时返回多个图片 URL,导致插件连续发送 2 张图片的问题。下载层现在会按本轮缺口裁剪候选 URL,只下载并交付用户请求数量的图片;正常补齐、下载重试、缓存落盘和 sender 发送策略不变。新增回归测试覆盖「请求 1 张但 provider 返回 2 个 URL」场景。 +- **NapCat 发送确认超时去重**:修复 OneBot/NapCat `send_group_msg` 返回 retcode `1200` 且 wording 为 NTQQ `sendMsg` 超时时被误判为可重试失败的问题。此类结果现在仅在 OneBot-like 平台且匹配已知 NTQQ `sendMsg` 超时标记时视为 pending delivery,不再继续触发普通发送、stream 或 HTML fallback,避免平台实际已送达但确认丢失时把同一张图片重复发送;非 OneBot 平台和不相关的 retcode `1200` 超时仍按普通失败处理。 ## [2.1.1] - 2026-06-15 diff --git a/src/infrastructure/sending/send_strategies.py b/src/infrastructure/sending/send_strategies.py index 2152c33..0868a4b 100644 --- a/src/infrastructure/sending/send_strategies.py +++ b/src/infrastructure/sending/send_strategies.py @@ -24,6 +24,14 @@ logger = get_logger() +_UNKNOWN_PLATFORM_NAME = "unknown" +_ONEBOT_UNCERTAIN_DELIVERY_RETCODE = "1200" +_ONEBOT_SENDMSG_TIMEOUT_MARKERS = ( + "Timeout", + "NodeIKernelMsgService/sendMsg", + "NodeIKernelMsgListener/onMsgInfoListUpdate", +) + def extract_message_ids(response: Any) -> tuple[str, ...]: """Extract OneBot message ids from common adapter response shapes.""" @@ -65,6 +73,14 @@ def _get_bot_client(event: AstrMessageEvent) -> Any | None: return getattr(event, "bot", None) or getattr(event, "_bot", None) +def _platform_name(event: AstrMessageEvent) -> str: + """统一提取平台名,避免不同发送策略对缺省值处理不一致。""" + return ( + str(getattr(getattr(event, "platform", None), "name", "") or "") + or _UNKNOWN_PLATFORM_NAME + ) + + def _onebot_target(event: AstrMessageEvent) -> tuple[str, int] | None: group_id = event.get_group_id() if group_id: @@ -139,6 +155,23 @@ async def _component_to_onebot_message(comp: Any) -> dict[str, Any]: return comp.toDict() +def _is_onebot_uncertain_delivery_error(exc: Exception) -> bool: + """识别 OneBot/NapCat 已提交但确认超时的发送错误。""" + retcode = getattr(exc, "retcode", None) + text = " ".join( + str(value) + for value in ( + getattr(exc, "message", ""), + getattr(exc, "wording", ""), + str(exc), + ) + if value + ) + return str(retcode) == _ONEBOT_UNCERTAIN_DELIVERY_RETCODE and all( + marker in text for marker in _ONEBOT_SENDMSG_TIMEOUT_MARKERS + ) + + class SendStrategy(ABC): """Abstract base class for send strategies.""" @@ -217,7 +250,7 @@ async def send_with_status( try: send_result = await self._send_message(event, chain, auto_revoke) - platform_name = getattr(event.platform, "name", "unknown") + platform_name = _platform_name(event) # AstrBot/OneBot 适配器偶尔会在平台侧已接收消息时返回 None。 # 这里不把不确定返回当作失败,避免图片仍在延迟送达时误触发回退策略。 @@ -246,18 +279,34 @@ async def send_with_status( ) return SendAttemptResult.success(message_ids) except TimeoutError as exc: + platform_name = _platform_name(event) # 发送接口超时后无法判断平台侧是否已经接收消息,重发 fallback 可能造成重复图片。 logger.warning( "[send] direct send confirmation timed out, treating as pending delivery: platform=%s, chain=%d, error=%s", - getattr(event.platform, "name", "unknown"), + platform_name, len(chain), exc, ) return SendAttemptResult.pending_delivery("send confirmation timed out") except Exception as exc: + platform_name = _platform_name(event) + if is_onebot_like_platform( + platform_name + ) and _is_onebot_uncertain_delivery_error(exc): + # NapCat/NTQQ 可能已经发送成功但没有等到本地确认; + # 此时进入 stream/HTML fallback 会把同一张图再发一遍。 + logger.warning( + "[send] direct send returned uncertain OneBot timeout, treating as pending delivery: platform=%s, chain=%d, error=%s", + platform_name, + len(chain), + exc, + ) + return SendAttemptResult.pending_delivery( + "onebot send confirmation timed out" + ) logger.exception( "[send] direct send failed: platform=%s, chain=%d, error=%s", - getattr(event.platform, "name", "unknown"), + platform_name, len(chain), exc, ) @@ -279,7 +328,7 @@ async def _send_message( def _requires_onebot_passthrough( self, event: AstrMessageEvent, chain: list[Any], auto_revoke: bool ) -> bool: - platform_name = getattr(getattr(event, "platform", None), "name", "") or "" + platform_name = _platform_name(event) if not is_onebot_like_platform(platform_name): return False if auto_revoke and any( @@ -420,7 +469,7 @@ async def _send_nodes_direct_with_status( ) -> SendAttemptResult: """Send forward nodes and expose whether fallback is safe.""" try: - platform_name = getattr(getattr(event, "platform", None), "name", "") + platform_name = _platform_name(event) if auto_revoke and is_onebot_like_platform(platform_name): attempted, raw_result = await self._send_nodes_raw(event, nodes) if attempted: @@ -455,14 +504,28 @@ async def _send_nodes_direct_with_status( logger.info("[forward] send completed: nodes=%d", len(nodes)) return SendAttemptResult.success(extract_message_ids(send_result)) except TimeoutError as exc: + platform_name = _platform_name(event) # 合并转发也可能在平台侧已接收后只丢失本地确认,立刻 fallback 会造成重复消息。 logger.warning( - "[forward] send confirmation timed out, treating as pending delivery: nodes=%d, error=%s", + "[forward] send confirmation timed out, treating as pending delivery: platform=%s, nodes=%d, error=%s", + platform_name, len(nodes), exc, ) return SendAttemptResult.pending_delivery("forward confirmation timed out") except Exception as exc: + platform_name = _platform_name(event) + if is_onebot_like_platform( + platform_name + ) and _is_onebot_uncertain_delivery_error(exc): + logger.warning( + "[forward] send returned uncertain OneBot timeout, treating as pending delivery: nodes=%d, error=%s", + len(nodes), + exc, + ) + return SendAttemptResult.pending_delivery( + "forward onebot confirmation timed out" + ) logger.exception( "[forward] send failed: nodes=%d, error=%s", len(nodes), diff --git a/tests/infrastructure/test_image_sender.py b/tests/infrastructure/test_image_sender.py index cbeb849..208e978 100644 --- a/tests/infrastructure/test_image_sender.py +++ b/tests/infrastructure/test_image_sender.py @@ -53,6 +53,21 @@ def without_delivery_notices(config_dict: dict[str, Any]) -> dict[str, Any]: return updated +class OneBotActionTimeout(Exception): + retcode = 1200 + message = ( + "Timeout: NTEvent serviceAndMethod:NodeIKernelMsgService/sendMsg " + "ListenerName:NodeIKernelMsgListener/onMsgInfoListUpdate EventRet:\n{}\n" + ) + wording = message + + +class GenericRetcodeTimeout(Exception): + retcode = 1200 + message = "Timeout while waiting for an unrelated operation" + wording = message + + @pytest.fixture(autouse=True) def reset_singletons() -> None: """Keep config/context singletons isolated.""" @@ -190,6 +205,134 @@ async def test_send_images_treats_napcat_none_ack_as_pending( mock_event.bot.call_action.assert_not_called() +@pytest.mark.asyncio +async def test_send_images_treats_onebot_action_timeout_as_pending( + tmp_path: Path, mock_event, sample_config_dict +) -> None: + """NapCat raw action 超时确认不触发 stream fallback,避免同图重复发送。""" + image_path = tmp_path / "shared" / "image.jpg" + image_path.parent.mkdir() + image_path.write_bytes(b"image-data") + + context = MagicMock() + context.send_message = AsyncMock() + set_plugin_context(context) + + mock_event.platform.name = "aiocqhttp" + mock_event.get_group_id.return_value = "123456" + mock_event.get_sender_id.return_value = "654321" + mock_event.bot = MagicMock() + mock_event.bot.send_group_msg = AsyncMock(side_effect=OneBotActionTimeout()) + mock_event.bot.call_action = AsyncMock() + + config_dict = with_napcat_transport( + without_delivery_notices(sample_config_dict), + local_file_mode="always", + local_file_allowed_roots=[str(tmp_path / "shared")], + stream_mode="fallback", + ) + config = SetuPluginConfig(**config_dict) + payload = ImagePayload( + urls=("https://example.com/image.jpg",), + raw_bytes=(), + file_paths=(image_path,), + items=(image_path,), + r18=False, + tags=(), + ) + + results = [ + item async for item in ImageSender(config).send_images(payload, mock_event) + ] + + assert results == [{"send_success": True, "image_count": 1, "send_pending": True}] + mock_event.bot.send_group_msg.assert_awaited_once() + mock_event.bot.call_action.assert_not_called() + context.send_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_direct_send_strategy_keeps_action_timeout_failure_on_non_onebot( + mock_event, +) -> None: + """非 OneBot 平台不把 NapCat/NTQQ timeout 归类为待确认投递。""" + context = MagicMock() + context.send_message = AsyncMock(side_effect=OneBotActionTimeout()) + strategy = DirectSendStrategy(context) + + mock_event.platform.name = "telegram" + + result = await strategy.send_with_status( + mock_event, + [Comp.Image.fromBytes(b"image-data")], + ) + + assert result.accepted is False + assert result.pending is False + + +@pytest.mark.asyncio +async def test_forward_send_strategy_keeps_action_timeout_failure_on_non_onebot( + mock_event, +) -> None: + """合并转发同样只在 OneBot-like 平台识别 NapCat/NTQQ 待确认投递。""" + context = MagicMock() + context.send_message = AsyncMock(side_effect=OneBotActionTimeout()) + strategy = ForwardSendStrategy(context) + + mock_event.platform.name = "telegram" + mock_event.get_self_id.return_value = "10000" + + result = await strategy.send_with_status( + mock_event, + [Comp.Image.fromBytes(b"image-data")], + ) + + assert result.accepted is False + assert result.pending is False + + +@pytest.mark.asyncio +async def test_forward_send_strategy_treats_onebot_action_timeout_as_pending( + mock_event, +) -> None: + """OneBot-like 合并转发遇到 NapCat/NTQQ 确认超时时不触发后续 fallback。""" + context = MagicMock() + context.send_message = AsyncMock(side_effect=OneBotActionTimeout()) + strategy = ForwardSendStrategy(context) + + mock_event.platform.name = "aiocqhttp" + mock_event.get_self_id.return_value = "10000" + + result = await strategy.send_with_status( + mock_event, + [Comp.Image.fromBytes(b"image-data")], + ) + + assert result.accepted is True + assert result.pending is True + + +@pytest.mark.asyncio +async def test_direct_send_strategy_rejects_unrelated_onebot_retcode_timeout( + mock_event, +) -> None: + """OneBot retcode 1200 也必须匹配已知 sendMsg 超时模式才视为待投递。""" + context = MagicMock() + context.send_message = AsyncMock(side_effect=GenericRetcodeTimeout()) + strategy = DirectSendStrategy(context) + + mock_event.platform.name = "aiocqhttp" + + result = await strategy.send_with_status( + mock_event, + [Comp.Image.fromBytes(b"image-data")], + ) + + assert result.accepted is False + assert result.pending is False + + @pytest.mark.asyncio async def test_send_images_reports_partial_batch_failure( tmp_path: Path, mock_event, sample_config_dict