mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-01 05:15:22 +08:00
fix(dingtalk): show submitted HITL choices in cards
This commit is contained in:
@@ -19,6 +19,7 @@ from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEvent
|
||||
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
|
||||
from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.dingtalk.interaction import (
|
||||
acknowledge_interaction,
|
||||
interaction_delivery_capabilities,
|
||||
interaction_event_from_native,
|
||||
send_interaction,
|
||||
@@ -165,6 +166,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
]
|
||||
if hasattr(self.bot, 'create_and_deliver_ai_card'):
|
||||
apis.append('interaction.request')
|
||||
if hasattr(self.bot, 'update_ai_card'):
|
||||
apis.append('interaction.acknowledge')
|
||||
return apis
|
||||
|
||||
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
|
||||
@@ -288,6 +291,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
if action == 'interaction.request' and action in self.get_supported_apis():
|
||||
return await send_interaction(self, params)
|
||||
if action == 'interaction.acknowledge' and action in self.get_supported_apis():
|
||||
return await acknowledge_interaction(self, params)
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
|
||||
@@ -25,10 +25,87 @@ def _json_pointer_segment(value: str) -> str:
|
||||
return value.replace('~', '~0').replace('/', '~1')
|
||||
|
||||
|
||||
def _stored_submitted_values(value: typing.Any) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
'label': str(item['label'])[:200],
|
||||
'value': str(item['value'])[:2000],
|
||||
**({'description': str(item['description'])[:4000]} if item.get('description') is not None else {}),
|
||||
}
|
||||
for item in value[:50]
|
||||
if isinstance(item, dict) and item.get('label') is not None and item.get('value') is not None
|
||||
]
|
||||
|
||||
|
||||
def _submission_display_values(
|
||||
request: dict[str, typing.Any], submission: dict[str, typing.Any]
|
||||
) -> list[dict[str, str]]:
|
||||
values = submission.get('values') if isinstance(submission.get('values'), dict) else {}
|
||||
displayed: list[dict[str, str]] = []
|
||||
for field in request.get('fields') or []:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
field_id = str(field.get('id') or '')
|
||||
if not field_id or field_id not in values:
|
||||
continue
|
||||
value = values[field_id]
|
||||
if field.get('type') == 'select':
|
||||
value = next(
|
||||
(
|
||||
option.get('label') or option.get('value')
|
||||
for option in field.get('options') or []
|
||||
if isinstance(option, dict) and option.get('value') == value
|
||||
),
|
||||
value,
|
||||
)
|
||||
rendered = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value)
|
||||
display_value = {'label': str(field.get('label') or field_id), 'value': rendered[:2000]}
|
||||
description = str(request.get('description') or '').strip()
|
||||
if description:
|
||||
display_value['description'] = description[:4000]
|
||||
displayed.append(display_value)
|
||||
|
||||
action_id = submission.get('action_id')
|
||||
if action_id:
|
||||
action_label = next(
|
||||
(
|
||||
str(action.get('label') or action_id)
|
||||
for action in request.get('actions') or []
|
||||
if isinstance(action, dict) and str(action.get('id') or '') == str(action_id)
|
||||
),
|
||||
str(action_id),
|
||||
)
|
||||
action_value = {'label': '', 'value': action_label}
|
||||
description = str(request.get('description') or '').strip()
|
||||
if description and not displayed:
|
||||
action_value['description'] = description[:4000]
|
||||
displayed.append(action_value)
|
||||
return displayed
|
||||
|
||||
|
||||
def _submitted_components(values: list[dict[str, str]]) -> tuple[list[dict[str, typing.Any]], list[str]]:
|
||||
components = []
|
||||
children = []
|
||||
for index, item in enumerate(values):
|
||||
description = item.get('description')
|
||||
if description:
|
||||
description_id = f'submitted-description-{index}'
|
||||
components.append({'id': description_id, 'component': 'Text', 'text': description})
|
||||
children.append(description_id)
|
||||
component_id = f'submitted-{index}'
|
||||
label = f'{item["label"]}: ' if item['label'] else ''
|
||||
components.append({'id': component_id, 'component': 'Text', 'text': f'\u2705 {label}{item["value"]}'})
|
||||
children.append(component_id)
|
||||
return components, children
|
||||
|
||||
|
||||
def _build_interaction_messages(
|
||||
surface_id: str,
|
||||
request: dict[str, typing.Any],
|
||||
callback_token: str,
|
||||
submitted_values: list[dict[str, str]] | None = None,
|
||||
) -> tuple[dict[str, typing.Any], list[dict[str, typing.Any]], dict[str, str]]:
|
||||
raw_fields = request.get('fields') if isinstance(request.get('fields'), list) else []
|
||||
raw_actions = request.get('actions') if isinstance(request.get('actions'), list) else []
|
||||
@@ -46,6 +123,9 @@ def _build_interaction_messages(
|
||||
if title:
|
||||
components.append({'id': 'title', 'component': 'Text', 'text': title})
|
||||
children.append('title')
|
||||
history_components, history_children = _submitted_components(submitted_values or [])
|
||||
components.extend(history_components)
|
||||
children.extend(history_children)
|
||||
if description:
|
||||
components.append({'id': 'description', 'component': 'Markdown', 'content': description})
|
||||
children.append('description')
|
||||
@@ -188,6 +268,9 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -
|
||||
if target_type not in {'group', 'person'} or not target_id:
|
||||
raise ValueError('interaction.request has an invalid DingTalk target')
|
||||
update_target = params.get('update_target')
|
||||
submitted_values = _stored_submitted_values(
|
||||
update_target.get('submitted_values') if isinstance(update_target, dict) else None
|
||||
)
|
||||
if isinstance(update_target, dict):
|
||||
if update_target.get('rich') is not True or not update_target.get('message_id'):
|
||||
raise ValueError('DingTalk interaction update target is not an A2UI card')
|
||||
@@ -195,7 +278,7 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -
|
||||
else:
|
||||
out_track_id = uuid.uuid4().hex
|
||||
surface_id = f'langbot-interaction-{out_track_id}'
|
||||
data, components, field_types = _build_interaction_messages(surface_id, request, callback_token)
|
||||
data, components, field_types = _build_interaction_messages(surface_id, request, callback_token, submitted_values)
|
||||
if isinstance(update_target, dict):
|
||||
await adapter.bot.update_ai_card(
|
||||
out_track_id=out_track_id,
|
||||
@@ -222,12 +305,47 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -
|
||||
'target_id': target_id,
|
||||
'expires_at': time.monotonic() + 30 * 60,
|
||||
}
|
||||
return {
|
||||
result = {
|
||||
'ok': True,
|
||||
'message_id': out_track_id,
|
||||
'rich': True,
|
||||
'updated': isinstance(update_target, dict),
|
||||
}
|
||||
if submitted_values:
|
||||
result['submitted_values'] = submitted_values
|
||||
return result
|
||||
|
||||
|
||||
async def acknowledge_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
request = params.get('request')
|
||||
submission = params.get('submission')
|
||||
target = params.get('update_target')
|
||||
if not all(isinstance(value, dict) for value in (request, submission, target)):
|
||||
raise ValueError('interaction.acknowledge requires request, submission, and update_target')
|
||||
if target.get('rich') is not True or not target.get('message_id'):
|
||||
raise ValueError('DingTalk interaction acknowledgement requires an A2UI card')
|
||||
|
||||
out_track_id = str(target['message_id'])
|
||||
submitted_values = [
|
||||
*_stored_submitted_values(target.get('submitted_values')),
|
||||
*_submission_display_values(request, submission),
|
||||
][-50:]
|
||||
components, children = _submitted_components(submitted_values)
|
||||
title = str(request.get('title') or '').strip()
|
||||
if title:
|
||||
components.insert(0, {'id': 'title', 'component': 'Text', 'text': title})
|
||||
children.insert(0, 'title')
|
||||
children.append('langbot-brand')
|
||||
components.extend(brand_components())
|
||||
components.insert(0, {'id': 'root', 'component': 'Column', 'children': children, 'gap': 12})
|
||||
await adapter.bot.update_ai_card(
|
||||
out_track_id=out_track_id,
|
||||
a2ui_messages=update_surface_messages(
|
||||
f'langbot-interaction-{out_track_id}', data={'form': {}}, components=components
|
||||
),
|
||||
flow_status='CONFIRMED',
|
||||
)
|
||||
return {**target, 'updated': True, 'submitted_values': submitted_values}
|
||||
|
||||
|
||||
def parse_callback_data(value: str) -> dict[str, typing.Any]:
|
||||
|
||||
@@ -86,6 +86,7 @@ spec:
|
||||
- get_file_url
|
||||
- call_platform_api
|
||||
- interaction.request
|
||||
- interaction.acknowledge
|
||||
|
||||
platform_specific_apis:
|
||||
- action: check_access_token
|
||||
|
||||
@@ -632,6 +632,112 @@ async def test_dingtalk_interaction_update_reuses_existing_a2ui_card():
|
||||
assert adapter.interaction_callback_contexts['existing-track-id']['callback_token'] == 'new-token'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_acknowledgement_shows_text_selection_and_clicked_button():
|
||||
adapter = make_adapter()
|
||||
assert 'interaction.acknowledge' in adapter.get_supported_apis()
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.acknowledge',
|
||||
{
|
||||
'request': {
|
||||
'title': 'Review',
|
||||
'fields': [
|
||||
{'id': 'reason', 'label': 'Reason', 'type': 'text'},
|
||||
{
|
||||
'id': 'priority',
|
||||
'label': 'Priority',
|
||||
'type': 'select',
|
||||
'options': [{'value': 'urgent', 'label': 'Urgent'}],
|
||||
},
|
||||
],
|
||||
'actions': [{'id': 'approve', 'label': 'Approve'}],
|
||||
},
|
||||
'submission': {'values': {'reason': 'Looks good', 'priority': 'urgent'}, 'action_id': 'approve'},
|
||||
'update_target': {'message_id': 'card-1', 'rich': True},
|
||||
},
|
||||
)
|
||||
|
||||
kwargs = adapter.bot.update_ai_card.await_args.kwargs
|
||||
assert kwargs['out_track_id'] == 'card-1'
|
||||
assert kwargs['flow_status'] == 'CONFIRMED'
|
||||
components = kwargs['a2ui_messages'][1]['updateComponents']['components']
|
||||
assert [
|
||||
component['text']
|
||||
for component in components
|
||||
if component['component'] == 'Text' and component['id'].startswith('submitted-')
|
||||
] == [
|
||||
'\u2705 Reason: Looks good',
|
||||
'\u2705 Priority: Urgent',
|
||||
'\u2705 Approve',
|
||||
]
|
||||
assert not any(component['component'] in {'Button', 'TextField', 'ChoicePicker'} for component in components)
|
||||
assert result['submitted_values'] == [
|
||||
{'label': 'Reason', 'value': 'Looks good'},
|
||||
{'label': 'Priority', 'value': 'Urgent'},
|
||||
{'label': '', 'value': 'Approve'},
|
||||
]
|
||||
|
||||
next_result = await adapter.call_platform_api(
|
||||
'interaction.request',
|
||||
{
|
||||
'callback_token': 'next-token',
|
||||
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
|
||||
'update_target': result,
|
||||
'request': {'title': 'Next step', 'actions': [{'id': 'finish', 'label': 'Finish'}]},
|
||||
},
|
||||
)
|
||||
components = adapter.bot.update_ai_card.await_args.kwargs['a2ui_messages'][1]['updateComponents']['components']
|
||||
assert next_result['submitted_values'] == result['submitted_values']
|
||||
assert components[0]['children'][:4] == ['title', 'submitted-0', 'submitted-1', 'submitted-2']
|
||||
assert any(component['component'] == 'Button' for component in components)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_field_submit_shows_entered_value_without_submit_action():
|
||||
adapter = make_adapter()
|
||||
await adapter.call_platform_api(
|
||||
'interaction.acknowledge',
|
||||
{
|
||||
'request': {
|
||||
'description': 'Please enter your question',
|
||||
'fields': [{'id': 'question', 'label': 'Question', 'type': 'textarea'}],
|
||||
},
|
||||
'submission': {'values': {'question': 'What happened?'}},
|
||||
'update_target': {'message_id': 'card-2', 'rich': True},
|
||||
},
|
||||
)
|
||||
components = adapter.bot.update_ai_card.await_args.kwargs['a2ui_messages'][1]['updateComponents']['components']
|
||||
assert [component['text'] for component in components if component['id'].startswith('submitted-')] == [
|
||||
'Please enter your question',
|
||||
'\u2705 Question: What happened?',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dingtalk_button_submit_preserves_its_prompt():
|
||||
adapter = make_adapter()
|
||||
result = await adapter.call_platform_api(
|
||||
'interaction.acknowledge',
|
||||
{
|
||||
'request': {
|
||||
'title': 'Human input',
|
||||
'description': 'Choose how to proceed',
|
||||
'actions': [{'id': 'continue', 'label': 'Continue'}],
|
||||
},
|
||||
'submission': {'action_id': 'continue', 'values': {}},
|
||||
'update_target': {'message_id': 'card-3', 'rich': True},
|
||||
},
|
||||
)
|
||||
components = adapter.bot.update_ai_card.await_args.kwargs['a2ui_messages'][1]['updateComponents']['components']
|
||||
assert [component['text'] for component in components if component['id'].startswith('submitted-')] == [
|
||||
'Choose how to proceed',
|
||||
'\u2705 Continue',
|
||||
]
|
||||
assert result['submitted_values'] == [
|
||||
{'label': '', 'value': 'Continue', 'description': 'Choose how to proceed'},
|
||||
]
|
||||
|
||||
|
||||
def test_dingtalk_stale_callback_does_not_consume_replacement_context():
|
||||
contexts = {
|
||||
'card-1': {
|
||||
|
||||
Reference in New Issue
Block a user