Files
LangBot/tests/unit_tests/platform/test_line_eba_adapter.py
T
d8271d9198 feat(line): Native Event-Based Agent (EBA) LINE Platform Adapter (#2736)
* feat(line): EBA LINE adapter skeleton (WO-LINE-01-SKELETON-1018)

- pkg/platform/adapters/line/ 9 files (LineAdapter hybrid inheritance +
  Capability + event namespace + config env load, zero hardcoded secrets)
- LINE SDK calls all stub (NotImplementedError) pending WO-LINE-00/02/03
- Verified: compileall 7/7 + ruff 0 + import chain green against
  v4.11.0-hotfixed SDK (container-extracted, read-only)
- Source: handoff box WO-LINE-01-SKELETON-1018 (sha256 9/9 matched)

Co-authored-by: multica-agent <[email protected]>

* feat(line): implement WO-LINE-02 message.received loop and converter

Co-authored-by: multica-agent <[email protected]>

* feat(line): implement WO-LINE-03 streaming and send API

Co-authored-by: multica-agent <[email protected]>

* feat(line): implement WO-LINE-04 group and user event extensions

Co-authored-by: multica-agent <[email protected]>

* feat(line): implement WO-LINE-05 group and user event extensions

Co-authored-by: multica-agent <[email protected]>

* docs(line): add EBA LINE adapter architecture guide and documentation

Co-authored-by: multica-agent <[email protected]>

* feat(line): resolve Ruff warnings and manifest spec update

Co-authored-by: multica-agent <[email protected]>

* feat(line): add webhook_url config and help_links to PR_DESCRIPTION.md

Co-authored-by: multica-agent <[email protected]>

* feat(line): add webhook_url config and help_links for PR #2736

Co-authored-by: multica-agent <[email protected]>

* fix(line): ruff format, update help_links, remove nested duplicate dir

Co-authored-by: multica-agent <[email protected]>

* fix(line): resolve 12 test failures, align types, and move architecture docs to dev-docs

Co-authored-by: multica-agent <[email protected]>

* fix(line): resolve ruff lint and formatting issues for CI

Co-authored-by: multica-agent <[email protected]>

* docs(line): sanitize internal orchestration notes and standardize documentation

Co-authored-by: multica-agent <[email protected]>

* clean(line): remove internal tracking ticket references from source code

Co-authored-by: multica-agent <[email protected]>

* fix(line): resolve MessageReceivedEvent and Friend entity mapping for 100% test pass

Co-authored-by: multica-agent <[email protected]>

* fix(line): resolve E402 import order in event_converter.py

Co-authored-by: multica-agent <[email protected]>

* feat(line): implement the LINE Omni adapter on the legacy messaging flow

Replace the placeholder skeleton with a working unified-webhook adapter:

- verify X-Line-Signature with WebhookParser and acknowledge before dispatch
- build real LINE messaging clients from channel_access_token and drop
  the adapter's own messages using the account id from get_bot_info
- map message/image/voice events to message.received, unsend to
  message.deleted, membership changes to group/bot events, and the
  remaining LINE event types to platform.specific with chat identity
- send and reply through MessagingApi with LINE's five-message budget,
  a reply-token to push fallback, and honest NotSupportedError paths
- expose get_user_info, get_group_info, leave_group and the get_bot_info,
  get_profile, get_group_summary pass-through actions
- keep the legacy FriendMessage/GroupMessage listeners working
- move tests to tests/unit_tests/platform, add the adapter doc, and
  register line in OMNI_ADAPTER_NAMES

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: RockChinQ <[email protected]>
2026-10-30 12:58:04 +08:00

465 lines
16 KiB
Python

"""Unit coverage for the LINE Omni adapter (unified webhook mode)."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import pathlib
import pytest
import yaml
from linebot.v3.messaging import PushMessageRequest, ReplyMessageRequest
from linebot.v3.webhook import WebhookParser
from langbot.pkg.platform.adapters.line.adapter import LineAdapter
from langbot.pkg.platform.adapters.line.errors import NotSupportedError
from langbot.pkg.platform.adapters.line.event_converter import LineEventConverter
from langbot.pkg.platform.adapters.line.message_converter import LineMessageConverter
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
SECRET = 'test-channel-secret'
TOKEN = 'test-channel-access-token'
PLATFORM_ROOT = pathlib.Path(__file__).parents[3] / 'src/langbot/pkg/platform'
class DummyLogger(AbstractEventLogger):
def __init__(self):
self.lines: list[tuple[str, str]] = []
async def info(self, text, images=None, message_session_id=None, no_throw=True):
self.lines.append(('info', str(text)))
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
self.lines.append(('warning', str(text)))
async def error(self, text, images=None, message_session_id=None, no_throw=True):
self.lines.append(('error', str(text)))
class DummyMessagingApi:
def __init__(self, fail_reply: bool = False):
self.push_requests: list[PushMessageRequest] = []
self.reply_requests: list[ReplyMessageRequest] = []
self.fail_reply = fail_reply
self.leaved_groups: list[str] = []
def push_message(self, request):
self.push_requests.append(request)
return {'sent': len(request.messages)}
def reply_message(self, request):
if self.fail_reply:
raise RuntimeError('reply token expired')
self.reply_requests.append(request)
return {'sent': len(request.messages)}
def get_bot_info(self):
return type(
'BotInfo', (), {'user_id': 'Ubot', 'basic_id': '@bot', 'display_name': 'Bot', 'chat_mode': 'chat'}
)()
def get_profile(self, user_id):
return type(
'Profile',
(),
{'user_id': user_id, 'display_name': 'Alice', 'picture_url': 'https://img/1.png', 'status_message': None},
)()
def get_group_summary(self, group_id):
return type('Summary', (), {'group_id': group_id, 'group_name': 'Team', 'picture_url': None})()
def leave_group(self, group_id):
self.leaved_groups.append(group_id)
class DummyBlobApi:
def __init__(self, content: bytes = b'image-bytes'):
self.content = content
self.requested: list[str] = []
def get_message_content(self, message_id):
self.requested.append(message_id)
return self.content
class DummyApiClient:
def __init__(self):
self.closed = False
def close(self):
self.closed = True
class DummyHeaders(dict):
def get(self, key, default=None):
return dict.get(self, key, default)
class DummyRequest:
def __init__(self, body: str, signature: str | None):
self.headers = DummyHeaders({} if signature is None else {'X-Line-Signature': signature})
self._body = body
async def get_data(self, as_text: bool = False):
return self._body if as_text else self._body.encode()
def sign(body: str, secret: str = SECRET) -> str:
return base64.b64encode(hmac.new(secret.encode(), body.encode(), hashlib.sha256).digest()).decode()
def message_event(
*,
text: str = 'hello',
source: dict | None = None,
reply_token: str = 'reply-token-1',
message: dict | None = None,
) -> dict:
return {
'type': 'message',
'mode': 'active',
'timestamp': 1730000000000,
'webhookEventId': '01HWEBHOOKID',
'deliveryContext': {'isRedelivery': False},
'source': source or {'type': 'user', 'userId': 'U-user-1'},
'replyToken': reply_token,
'message': message or {'id': 'msg-1', 'type': 'text', 'text': text, 'quoteToken': 'quote-1'},
}
def webhook_body(*events: dict) -> str:
return json.dumps({'destination': 'Ubot', 'events': list(events)})
def build_adapter(logger: DummyLogger | None = None) -> LineAdapter:
return LineAdapter(
config={'channel_access_token': TOKEN, 'channel_secret': SECRET},
logger=logger or DummyLogger(),
)
class EventSink:
def __init__(self):
self.events: list[platform_events.Event] = []
async def __call__(self, event, adapter):
self.events.append(event)
async def dispatch(adapter: LineAdapter, body: str, signature: str | None = None):
response = await adapter.handle_unified_webhook(
bot_uuid='bot-uuid', path='', request=DummyRequest(body, sign(body) if signature is None else signature)
)
pending = list(adapter.background_tasks)
if pending:
await asyncio_gather(pending)
return response
async def asyncio_gather(tasks):
import asyncio
await asyncio.gather(*tasks, return_exceptions=True)
def parse_events(body: str) -> list:
return WebhookParser(SECRET).parse(body, sign(body))
def test_manifest_is_discoverable_and_keeps_legacy_config():
manifest = yaml.safe_load((PLATFORM_ROOT / 'adapters/line/manifest.yaml').read_text(encoding='utf-8'))
legacy = yaml.safe_load((PLATFORM_ROOT / 'sources/line.yaml').read_text(encoding='utf-8'))
assert manifest['metadata']['name'] == 'line-omni'
assert manifest['execution']['python'] == {'path': './adapter.py', 'attr': 'LineAdapter'}
assert {field['name'] for field in legacy['spec']['config']} <= {
field['name'] for field in manifest['spec']['config']
}
assert manifest['spec']['help_links'] == legacy['spec']['help_links']
assert 'message.received' in manifest['spec']['supported_events']
assert (PLATFORM_ROOT / 'adapters/line' / manifest['metadata']['icon']).is_file()
def test_adapter_requires_credentials():
with pytest.raises(ValueError, match='channel_access_token'):
LineAdapter(config={'channel_secret': SECRET}, logger=DummyLogger())
@pytest.mark.asyncio
async def test_webhook_rejects_missing_and_invalid_signature():
adapter = build_adapter()
body = webhook_body(message_event())
assert (await adapter.handle_unified_webhook('bot', '', DummyRequest(body, None))).status_code == 400
assert (await adapter.handle_unified_webhook('bot', '', DummyRequest(body, 'bogus'))).status_code == 400
@pytest.mark.asyncio
async def test_webhook_dispatches_message_received_event():
adapter = build_adapter()
sink = EventSink()
adapter.register_listener(platform_events.MessageReceivedEvent, sink)
response = await dispatch(adapter, webhook_body(message_event(text='hi there')))
assert response.status_code == 200
assert len(sink.events) == 1
event = sink.events[0]
assert isinstance(event, platform_events.MessageReceivedEvent)
assert event.adapter_name == 'line-omni'
assert event.chat_type == platform_entities.ChatType.PRIVATE
assert event.chat_id == 'U-user-1'
assert event.message_id == 'msg-1'
assert [type(component) for component in event.message_chain] == [platform_message.Source, platform_message.Plain]
assert event.message_chain[1].text == 'hi there'
@pytest.mark.asyncio
async def test_webhook_serves_legacy_listeners_and_ignores_self_messages():
adapter = build_adapter()
adapter.bot_account_id = 'Ubot'
legacy_sink = EventSink()
adapter.register_listener(platform_events.FriendMessage, legacy_sink)
await dispatch(adapter, webhook_body(message_event(source={'type': 'user', 'userId': 'Ubot'})))
assert legacy_sink.events == []
await dispatch(adapter, webhook_body(message_event(source={'type': 'user', 'userId': 'U-user-9'})))
assert len(legacy_sink.events) == 1
assert isinstance(legacy_sink.events[0], platform_events.FriendMessage)
assert legacy_sink.events[0].sender.id == 'U-user-9'
@pytest.mark.asyncio
async def test_group_message_uses_group_chat_identity():
events = parse_events(
webhook_body(message_event(source={'type': 'group', 'groupId': 'C-group', 'userId': 'U-user-2'}))
)
event = await LineEventConverter().target2yiri(events[0])
assert event.chat_type == platform_entities.ChatType.GROUP
assert event.chat_id == 'C-group'
assert event.group.id == 'C-group'
assert event.sender.id == 'U-user-2'
@pytest.mark.asyncio
async def test_unsend_becomes_message_deleted():
body = webhook_body(
{
'type': 'unsend',
'mode': 'active',
'timestamp': 1730000000000,
'webhookEventId': '01HUNSEND',
'deliveryContext': {'isRedelivery': False},
'source': {'type': 'user', 'userId': 'U-user-1'},
'unsend': {'messageId': 'msg-9'},
}
)
event = await LineEventConverter().target2yiri(parse_events(body)[0])
assert isinstance(event, platform_events.MessageDeletedEvent)
assert event.message_id == 'msg-9'
assert event.chat_id == 'U-user-1'
@pytest.mark.asyncio
async def test_follow_and_postback_stay_platform_specific():
follow = webhook_body(
{
'type': 'follow',
'mode': 'active',
'timestamp': 1730000000000,
'webhookEventId': '01HFOLLOW',
'deliveryContext': {'isRedelivery': False},
'source': {'type': 'user', 'userId': 'U-user-3'},
'replyToken': 'token',
'follow': {'isUnblocked': True},
}
)
follow_event = await LineEventConverter().target2yiri(parse_events(follow)[0])
assert follow_event.type == 'platform.specific'
assert follow_event.action == 'line.follow'
postback = webhook_body(
{
'type': 'postback',
'mode': 'active',
'timestamp': 1730000000000,
'webhookEventId': '01HPOSTBACK',
'deliveryContext': {'isRedelivery': False},
'source': {'type': 'group', 'groupId': 'C-group', 'userId': 'U-user-4'},
'replyToken': 'token',
'postback': {'data': 'action=buy'},
}
)
postback_event = await LineEventConverter().target2yiri(parse_events(postback)[0])
assert postback_event.action == 'line.postback'
assert postback_event.data['data'] == 'action=buy'
assert postback_event.data['chat_id'] == 'C-group'
@pytest.mark.asyncio
async def test_member_joined_maps_group_and_member():
body = webhook_body(
{
'type': 'memberJoined',
'mode': 'active',
'timestamp': 1730000000000,
'webhookEventId': '01HJOINED',
'deliveryContext': {'isRedelivery': False},
'source': {'type': 'group', 'groupId': 'C-group'},
'replyToken': 'token',
'joined': {'members': [{'type': 'user', 'userId': 'U-user-5'}]},
}
)
event = await LineEventConverter().target2yiri(parse_events(body)[0])
assert isinstance(event, platform_events.MemberJoinedEvent)
assert event.group.id == 'C-group'
assert event.member.id == 'U-user-5'
@pytest.mark.asyncio
async def test_text_mention_becomes_at_component_for_the_bot():
body = webhook_body(
message_event(
text='@bot hello',
message={
'id': 'msg-2',
'type': 'text',
'quoteToken': 'quote-2',
'text': '@bot hello',
'mention': {
'mentionees': [
{'index': 0, 'length': 4, 'userId': 'Ubot', 'isSelf': True, 'type': 'user'},
]
},
},
)
)
event = await LineEventConverter(bot_account_id='Ubot').target2yiri(parse_events(body)[0])
at_components = [component for component in event.message_chain if isinstance(component, platform_message.At)]
assert len(at_components) == 1
assert at_components[0].target == 'Ubot'
assert at_components[0].display == 'bot'
@pytest.mark.asyncio
async def test_image_message_is_downloaded_through_the_blob_api():
blob = DummyBlobApi(b'jpeg-bytes')
body = webhook_body(
message_event(
message={
'id': 'msg-3',
'type': 'image',
'quoteToken': 'quote-3',
'contentProvider': {'type': 'line'},
}
)
)
event = await LineEventConverter(blob_api=blob).target2yiri(parse_events(body)[0])
image = event.message_chain[1]
assert isinstance(image, platform_message.Image)
assert image.base64 == f'data:image/jpeg;base64,{base64.b64encode(b"jpeg-bytes").decode()}'
assert blob.requested == ['msg-3']
@pytest.mark.asyncio
async def test_outgoing_chain_builds_line_messages():
chain = platform_message.MessageChain(
[
platform_message.Source(id='msg-1', time=__import__('datetime').datetime.now()),
platform_message.Plain(text='hello'),
platform_message.At(target='U-user-1', display='Alice'),
]
)
messages = await LineMessageConverter.yiri2target(chain)
assert [message.type for message in messages] == ['text', 'text']
assert messages[0].text == 'hello'
assert messages[1].text == '@Alice'
@pytest.mark.asyncio
async def test_outgoing_image_requires_a_public_url():
chain = platform_message.MessageChain([platform_message.Image(base64='ZGly')])
with pytest.raises(NotSupportedError):
await LineMessageConverter.yiri2target(chain)
@pytest.mark.asyncio
async def test_send_message_batches_five_messages_per_request():
adapter = build_adapter()
adapter.bot = DummyMessagingApi()
chain = platform_message.MessageChain([platform_message.Plain(text='x'), platform_message.Plain(text='y')] * 3)
result = await adapter.send_message('person', 'U-user-1', chain)
assert [len(request.messages) for request in adapter.bot.push_requests] == [5, 1]
assert all(request.to == 'U-user-1' for request in adapter.bot.push_requests)
assert result.raw == {'sent': 6, 'target_id': 'U-user-1'}
@pytest.mark.asyncio
async def test_reply_uses_reply_token_then_falls_back_to_push():
adapter = build_adapter()
event = await LineEventConverter().target2yiri(parse_events(webhook_body(message_event()))[0])
chain = platform_message.MessageChain([platform_message.Plain(text='pong')])
adapter.bot = DummyMessagingApi()
await adapter.reply_message(event, chain)
assert adapter.bot.reply_requests[0].reply_token == 'reply-token-1'
assert adapter.bot.push_requests == []
adapter.bot = DummyMessagingApi(fail_reply=True)
await adapter.reply_message(event, chain)
assert adapter.bot.push_requests[0].to == 'U-user-1'
@pytest.mark.asyncio
async def test_profile_and_group_apis_use_the_messaging_api():
adapter = build_adapter()
adapter.bot = DummyMessagingApi()
user = await adapter.get_user_info('U-user-1')
group = await adapter.get_group_info('C-group')
await adapter.leave_group('C-group')
assert user.id == 'U-user-1'
assert user.nickname == 'Alice'
assert group.name == 'Team'
assert adapter.bot.leaved_groups == ['C-group']
with pytest.raises(NotSupportedError):
await adapter.get_group_member_list('C-group')
@pytest.mark.asyncio
async def test_call_platform_api_dispatches_and_rejects_unknown_actions():
adapter = build_adapter()
adapter.bot = DummyMessagingApi()
info = await adapter.call_platform_api('get_bot_info', {})
assert info['user_id'] == 'Ubot'
with pytest.raises(NotSupportedError):
await adapter.call_platform_api('not_a_line_action', {})
@pytest.mark.asyncio
async def test_kill_closes_the_api_client():
adapter = build_adapter()
api_client = DummyApiClient()
adapter.api_client = api_client
assert await adapter.kill() is True
assert api_client.closed is True
assert adapter.background_tasks == set()