diff --git a/src/langbot/libs/dingtalk_api/a2ui.py b/src/langbot/libs/dingtalk_api/a2ui.py index d352d85bf..d6f5739c8 100644 --- a/src/langbot/libs/dingtalk_api/a2ui.py +++ b/src/langbot/libs/dingtalk_api/a2ui.py @@ -9,12 +9,40 @@ import typing A2UI_CATALOG_ID = 'https://dingtalk.com/card/a2ui/catalogs/public/catalog.json' A2UI_MESSAGE_VERSION = 'v1.0' A2UI_PROTOCOL_VERSION = '1.0' +LANGBOT_LOGO_URL = 'https://langbot.app/logo-blue.png' +LANGBOT_CARD_URL = 'https://langbot.app?utm_source=dingtalk&utm_medium=bot_card&utm_campaign=langbot' def binding(path: str) -> dict[str, str]: return {'path': path} +def brand_components() -> list[dict[str, typing.Any]]: + return [ + { + 'id': 'langbot-brand', + 'component': 'Row', + 'children': ['langbot-brand-icon', 'langbot-brand-link'], + 'align': 'center', + 'gap': 4, + }, + { + 'id': 'langbot-brand-icon', + 'component': 'Image', + 'url': LANGBOT_LOGO_URL, + 'variant': 'icon', + 'fit': 'contain', + 'previewEnabled': False, + }, + { + 'id': 'langbot-brand-link', + 'component': 'Link', + 'text': 'LangBot', + 'action': {'functionCall': {'call': 'openUrl', 'args': {'url': LANGBOT_CARD_URL}}}, + }, + ] + + def serialize_messages(messages: typing.Iterable[dict | str]) -> list[str]: serialized = [ message if isinstance(message, str) else json.dumps(message, ensure_ascii=False, separators=(',', ':')) @@ -95,7 +123,7 @@ def text_card_components() -> list[dict[str, typing.Any]]: { 'id': 'root', 'component': 'Column', - 'children': ['content'], + 'children': ['content', 'langbot-brand'], 'gap': 8, }, { @@ -103,6 +131,7 @@ def text_card_components() -> list[dict[str, typing.Any]]: 'component': 'Markdown', 'content': binding('/content'), }, + *brand_components(), ] diff --git a/src/langbot/pkg/platform/adapters/dingtalk/interaction.py b/src/langbot/pkg/platform/adapters/dingtalk/interaction.py index 3f9e71765..a36e89375 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/interaction.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/interaction.py @@ -7,7 +7,7 @@ import time import typing import uuid -from langbot.libs.dingtalk_api.a2ui import binding, create_surface_messages, update_surface_messages +from langbot.libs.dingtalk_api.a2ui import brand_components, binding, create_surface_messages, update_surface_messages from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent from langbot_plugin.api.entities.builtin.platform import events as platform_events @@ -150,6 +150,8 @@ def _build_interaction_messages( ) children.append(button_id) + children.append('langbot-brand') + components.extend(brand_components()) components.insert(0, {'id': 'root', 'component': 'Column', 'children': children, 'gap': 12}) return {'form': form}, components, field_types diff --git a/src/langbot/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py index e70ef5b8f..e1ca4f7f8 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.py +++ b/src/langbot/pkg/platform/sources/dingtalk.py @@ -1,4 +1,5 @@ from langbot.libs.dingtalk_api.a2ui import ( + brand_components, binding, create_surface_messages, create_text_card_messages, @@ -519,6 +520,8 @@ def _dingtalk_a2ui_form( ] ) children.append(button_id) + children.append('langbot-brand') + components.extend(brand_components()) return data, components diff --git a/tests/unit_tests/platform/test_dingtalk_adapter.py b/tests/unit_tests/platform/test_dingtalk_adapter.py index af7de7364..a5fb28382 100644 --- a/tests/unit_tests/platform/test_dingtalk_adapter.py +++ b/tests/unit_tests/platform/test_dingtalk_adapter.py @@ -53,6 +53,8 @@ def test_dingtalk_dify_input_card_uses_a2ui_binding_and_required_check(): assert field['value'] == {'path': '/form/value'} assert button['action']['event']['context']['input'] == {'path': '/form/value'} assert button['checks'][0]['condition']['call'] == 'required' + assert components[0]['children'][-1] == 'langbot-brand' + assert any(component['id'] == 'langbot-brand-link' for component in components) def test_dingtalk_extract_select_from_builtin_result_dict(): diff --git a/tests/unit_tests/platform/test_dingtalk_api.py b/tests/unit_tests/platform/test_dingtalk_api.py index 8d467bc59..f7a5b7824 100644 --- a/tests/unit_tests/platform/test_dingtalk_api.py +++ b/tests/unit_tests/platform/test_dingtalk_api.py @@ -31,6 +31,21 @@ def _response(result=None): return type('Response', (), {'status_code': 200, 'json': lambda self: payload})() +def test_text_card_keeps_brand_when_content_updates(): + components = create_text_card_messages('surface-id', 'hello')[2]['updateComponents']['components'] + by_id = {component['id']: component for component in components} + + assert by_id['root']['children'] == ['content', 'langbot-brand'] + assert by_id['langbot-brand']['children'] == ['langbot-brand-icon', 'langbot-brand-link'] + assert by_id['langbot-brand-icon']['url'] == 'https://langbot.app/logo-blue.png' + assert by_id['langbot-brand-icon']['previewEnabled'] is False + assert by_id['langbot-brand-link']['action']['functionCall'] == { + 'call': 'openUrl', + 'args': {'url': 'https://langbot.app?utm_source=dingtalk&utm_medium=bot_card&utm_campaign=langbot'}, + } + assert update_text_card_messages('surface-id', 'done')[0]['updateDataModel']['path'] == '/content' + + @pytest.mark.asyncio async def test_create_ai_card_uses_group_robot_delivery_model(): post = AsyncMock( diff --git a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py index 2b1bfd08b..280a4f48e 100644 --- a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py +++ b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py @@ -342,6 +342,8 @@ async def test_dingtalk_interaction_delivery_and_callback_event(): components = kwargs['a2ui_messages'][2]['updateComponents']['components'] button = next(component for component in components if component['component'] == 'Button') assert button['action']['event']['context']['actionId'] == 'lbi:callback-token:a:0' + assert components[0]['children'][-1] == 'langbot-brand' + assert any(component['id'] == 'langbot-brand-link' for component in components) native = dingtalk_card_callback_event( content={'actionId': 'lbi:callback-token:a:0'},