Files
AstrBot/tests/test_lark_sender_name.py
NayukiChiba 32a751394c fix(lark): Ensure Lark private messages are delivered reliably by falling back from rejected open IDs to known chat IDs without duplicating successful sends. (#10018)
* fix(lark): fall back to private chat ID when open ID send fails

Private file sends can fail with error 230101 when addressed by open ID,
even though the same file can be delivered to the private chat ID.

Persist private open ID to chat ID mappings from incoming messages,
scoped by platform and app ID, while preserving existing session IDs.
Keep open ID as the primary recipient and retry each rejected message
once with its known private chat ID, reusing the content and UUID.
Do not resend successful components or retry transport exceptions.

Propagate proactive send and attachment upload failures to callers so
failed deliveries are not reported as successful. Preserve the existing
boolean failure handling for replies.

Refs: #9989

* test(lark): cover private chat fallback and delivery failures

Reproduce rejected open ID file sends and verify that the same uploaded
file is retried once with the private chat ID and original UUID.

Cover successful primary sends, mixed message chains, failed deliveries,
upload failures, group routing, transport timeouts, and reply behavior.
Verify stored routes survive adapter recreation, remain scoped to the
bot, and cannot be overwritten by group messages.

Isolate sender-name tests from persistent route storage and supply the
platform and app identity required by route persistence.

Validation: 26 related tests passed; ruff format and ruff check passed.
Live Feishu API validation remains pending application credentials.

Refs: #9989
2026-09-10 22:53:17 +08:00

139 lines
4.3 KiB
Python

from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from astrbot.core.platform.sources.lark.lark_adapter import LarkPlatformAdapter
@pytest.fixture(autouse=True)
def mock_private_chat_storage(monkeypatch):
"""Keep sender-name tests independent of persistent routing storage.
Args:
monkeypatch: Fixture used to replace the route storage writer.
"""
monkeypatch.setattr(
"astrbot.core.platform.sources.lark.lark_adapter.sp.put_async", AsyncMock()
)
def _private_message_event(message_id: str = "message-1") -> SimpleNamespace:
"""Builds a Lark private-message event.
Args:
message_id: Unique message identifier.
Returns:
Lark-compatible event object.
"""
return SimpleNamespace(
event=SimpleNamespace(
sender=SimpleNamespace(
sender_id=SimpleNamespace(open_id="ou_sender"),
sender_type="user",
),
message=SimpleNamespace(
create_time="1700000000000",
chat_type="p2p",
chat_id="oc_private",
parent_id=None,
mentions=None,
content='{"text":"hello"}',
message_id=message_id,
message_type="text",
),
),
)
def _adapter(user_response: SimpleNamespace) -> LarkPlatformAdapter:
"""Builds an adapter with a mocked Contact API.
Args:
user_response: Response returned by the Contact API.
Returns:
Lark adapter test double.
"""
adapter = LarkPlatformAdapter.__new__(LarkPlatformAdapter)
adapter.config = {"id": "lark-test"}
adapter.appid = "cli_test"
adapter.bot_open_id = "ou_bot"
adapter.bot_name = "AstrBot"
adapter._user_name_cache = {}
adapter.handle_msg = AsyncMock()
adapter.lark_api = SimpleNamespace(
contact=SimpleNamespace(
v3=SimpleNamespace(
user=SimpleNamespace(aget=AsyncMock(return_value=user_response)),
),
),
)
return adapter
@pytest.mark.asyncio
async def test_lark_private_sender_uses_contact_display_name_and_cache():
adapter = _adapter(
SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(user=SimpleNamespace(name="Alice Zhang")),
),
)
await adapter.convert_msg(_private_message_event("message-1"))
await adapter.convert_msg(_private_message_event("message-2"))
first_message = adapter.handle_msg.await_args_list[0].args[0]
second_message = adapter.handle_msg.await_args_list[1].args[0]
assert first_message.sender.user_id == "ou_sender"
assert first_message.sender.nickname == "Alice Zhang"
assert second_message.sender.nickname == "Alice Zhang"
adapter.lark_api.contact.v3.user.aget.assert_awaited_once()
request = adapter.lark_api.contact.v3.user.aget.await_args.args[0]
assert request.user_id == "ou_sender"
assert request.user_id_type == "open_id"
@pytest.mark.asyncio
async def test_lark_private_sender_falls_back_when_contact_lookup_fails():
adapter = _adapter(
SimpleNamespace(
success=lambda: False,
data=None,
code=999,
msg="permission denied",
),
)
await adapter.convert_msg(_private_message_event())
message = adapter.handle_msg.await_args.args[0]
assert message.sender.user_id == "ou_sender"
assert message.sender.nickname == "ou_sende"
@pytest.mark.asyncio
async def test_lark_private_sender_retries_after_failure_cache_expires():
adapter = _adapter(
SimpleNamespace(
success=lambda: False,
data=None,
code=999,
msg="permission denied",
),
)
await adapter.convert_msg(_private_message_event("message-1"))
adapter._user_name_cache["ou_sender"] = ("ou_sende", 0)
adapter.lark_api.contact.v3.user.aget.return_value = SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(user=SimpleNamespace(name="Alice Zhang")),
)
await adapter.convert_msg(_private_message_event("message-2"))
message = adapter.handle_msg.await_args_list[1].args[0]
assert message.sender.nickname == "Alice Zhang"
assert adapter.lark_api.contact.v3.user.aget.await_count == 2