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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
75 changes: 69 additions & 6 deletions src/infrastructure/sending/send_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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。
# 这里不把不确定返回当作失败,避免图片仍在延迟送达时误触发回退策略。
Expand Down Expand Up @@ -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,
)
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand Down
143 changes: 143 additions & 0 deletions tests/infrastructure/test_image_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -190,6 +205,134 @@ async def test_send_images_treats_napcat_none_ack_as_pending(
mock_event.bot.call_action.assert_not_called()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): 考虑增加一个“负向测试”,验证非 OneBot 类的平台不会把相同的异常视为待投递

当前测试已经通过 mock_event.platform.name = "aiocqhttp" 覆盖了 OneBot 类平台的路径,请再补充一个使用非 OneBot 类平台名(例如你现有适配器栈里的另一个平台)的测试,其中同样的 ActionTimeout 会导致正常失败,而不是被标记为 pending_delivery。这样可以验证基于平台的分类逻辑,并防止未来的回归不小心把 OneBot/NapCat 的行为扩展到更广的平台范围。

建议的实现方式:

    context = MagicMock()
    context.send_message = AsyncMock()
    set_plugin_context(context)

    # OneBot-like platform: aiocqhttp should treat ActionTimeout as pending_delivery
    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=ActionTimeout())
    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")],
    )



async def test_image_sender_action_timeout_on_non_onebot_platform_is_failure(
    tmp_path: Path,
    sample_config_dict: dict[str, Any],
    mock_event: MagicMock,
) -> None:
    """
    Non-OneBot-like platforms (e.g. telegram) should not classify ActionTimeout
    as pending delivery; instead the send should fail normally.
    """

    # Arrange
    message = (
        "Timeout: NTEvent serviceAndMethod:NodeIKernelMsgService/sendMsg "
        "ListenerName:NodeIKernelMsgListener/onMsgInfoListUpdate EventRet:\n{}\n"
    )
    wording = message

    context = MagicMock()
    context.send_message = AsyncMock()
    set_plugin_context(context)

    # Use a non-OneBot-like platform name from the existing adapter stack
    mock_event.platform.name = "telegram"
    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=ActionTimeout())
    mock_event.bot.call_action = AsyncMock()

    config_dict = without_delivery_notices(
        sample_config_dict,
    )

    # Act
    image_sender = ImageSender(config_dict)
    with pytest.raises(ActionTimeout):
        await image_sender.send_image(mock_event, wording)

    # Assert
    # The failure should be a normal timeout; nothing should be marked as pending delivery
    assert not getattr(mock_event, "pending_delivery", False)
  1. 确认 ImageSendersend_image 以及 pending_delivery 属性(或同等标记)的名字与该测试模块内实际使用的保持一致;如果你的代码使用了不同的发送类、方法或标记字段来表示“待投递”,请相应调整。
  2. 如果现有的 OneBot/NapCat 测试目前对“待投递”有特定的结构断言(例如:返回结果对象中带 pending_delivery 字段,或使用专门的状态枚举),请在这里镜像同样的结构,并断言平台 "telegram" 会产生正常失败,而不是该待投递状态。
  3. "telegram" 替换为你当前适配器栈中已经存在的其他非 OneBot 适配器名称(例如 "kook""discord" 等),以保持测试与实际支持的平台一致。
  4. 如果非 OneBot 平台的“正常失败路径”是以不同方式暴露的(比如返回结果对象,而不是直接抛出 ActionTimeout),那就需要调整 with pytest.raises(ActionTimeout) 这段代码,使之匹配你的图片发送器实际暴露失败的方式,并断言它不会被标记为待投递。
Original comment in English

suggestion (testing): Consider a negative test showing non-OneBot-like platforms do not treat similar exceptions as pending delivery

Since this test covers the OneBot-like path via mock_event.platform.name = "aiocqhttp", please also add a complementary test using a non-OneBot-like platform name (e.g., another adapter in your stack) where the same ActionTimeout results in a normal failure rather than pending_delivery. This will verify the platform-based classification and help prevent regressions that accidentally extend the OneBot/NapCat behavior more broadly.

Suggested implementation:

    context = MagicMock()
    context.send_message = AsyncMock()
    set_plugin_context(context)

    # OneBot-like platform: aiocqhttp should treat ActionTimeout as pending_delivery
    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=ActionTimeout())
    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")],
    )



async def test_image_sender_action_timeout_on_non_onebot_platform_is_failure(
    tmp_path: Path,
    sample_config_dict: dict[str, Any],
    mock_event: MagicMock,
) -> None:
    """
    Non-OneBot-like platforms (e.g. telegram) should not classify ActionTimeout
    as pending delivery; instead the send should fail normally.
    """

    # Arrange
    message = (
        "Timeout: NTEvent serviceAndMethod:NodeIKernelMsgService/sendMsg "
        "ListenerName:NodeIKernelMsgListener/onMsgInfoListUpdate EventRet:\n{}\n"
    )
    wording = message

    context = MagicMock()
    context.send_message = AsyncMock()
    set_plugin_context(context)

    # Use a non-OneBot-like platform name from the existing adapter stack
    mock_event.platform.name = "telegram"
    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=ActionTimeout())
    mock_event.bot.call_action = AsyncMock()

    config_dict = without_delivery_notices(
        sample_config_dict,
    )

    # Act
    image_sender = ImageSender(config_dict)
    with pytest.raises(ActionTimeout):
        await image_sender.send_image(mock_event, wording)

    # Assert
    # The failure should be a normal timeout; nothing should be marked as pending delivery
    assert not getattr(mock_event, "pending_delivery", False)
  1. Ensure the names ImageSender, send_image, and the pending_delivery attribute (or equivalent) match what is actually used in this test module; adjust them if your code uses a different sender class, method, or flag to indicate pending delivery.
  2. If the existing OneBot/NapCat test currently asserts a specific structure for the "pending delivery" classification (e.g. a result object with a pending_delivery field or a dedicated status enum), mirror that structure here and assert that the platform "telegram" produces a normal failure instead of that pending-delivery classification.
  3. Replace "telegram" with another non-OneBot adapter name already present in your stack (e.g. "kook", "discord", etc.) to keep the test aligned with the actual platforms supported by your infrastructure.
  4. If the normal failure path for non-OneBot platforms is surfaced differently (for example, returning a result object instead of raising ActionTimeout), adapt the with pytest.raises(ActionTimeout) block to match the way your image sender exposes failures and then assert that it is not marked as pending delivery.



@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
Expand Down