fix(wecombot): handle native select card callbacks

This commit is contained in:
fdc310
2026-10-29 04:23:54 +08:00
parent 506fb3505b
commit 2721a213e8
4 changed files with 304 additions and 18 deletions
@@ -0,0 +1,44 @@
# Interaction Callback Lifecycle: Follow-up Design
## Current Issue
The DingTalk A2UI adapter stores `outTrackId` callback context only in process
memory. The callback is ignored after an adapter restart even though the
interaction request and hashed callback token are persisted. The in-memory
context uses a fixed 30-minute expiry, while a Host interaction may remain
valid for up to 24 hours. Expired contexts are pruned only when another card is
sent, and there is no capacity limit.
The legacy DingTalk Dify form path has separate in-memory `card_state` and
requires its own migration assessment.
## Intended Contract
1. `InteractionStore` remains authoritative for callback token, request,
delivery target, status, and expiry. An in-memory cache miss must never
invalidate a pending card.
2. Parse the callback token and `outTrackId` from DingTalk's card callback.
Resolve the stored request and delivery result, then validate the bot,
conversation, actor, and card identity before consuming the submission.
Reconstruct field types and option values from the stored request.
3. If profiling justifies a cache, bound it globally and per bot, cache only
small derived metadata, and use a short TTL with LRU eviction. Starting
limits to evaluate: 4,096 entries per process, 512 per bot, and five
minutes. Cache eviction affects performance only.
4. Waiting cards are database records, not queued in-memory tasks. Bound
concurrent callback execution separately. Persist callback admission and
the resume intent atomically before acknowledging delivery; a durable
worker queue with leases can then resume after process restart. A bounded
in-memory queue alone cannot close the crash gap after submission commit.
5. Track pending-card count, callback backlog, processing latency, cache
occupancy, and expired/rejected callbacks. Apply workspace-level admission
limits and database retention separately from the cache bounds.
## Verification Needed
- A pending card still submits after adapter restart and after cache eviction.
- A card remains usable until its actual persisted expiry, including when
other cards are sent.
- Replayed callbacks resume the processor once; crashes between admission and
execution recover the queued work once.
- Forged card, conversation, actor, or option identifiers are rejected.
@@ -19,6 +19,16 @@ The adapter is registered as `wecombot-omni`.
This is separate from regular WeCom internal applications (`wecom-omni`). WeComBot supports WebSocket long connection mode, which does not require a webhook URL. Webhook mode remains available when `enable-webhook=true`.
## AI Interactions
In WebSocket mode, `interaction.request` renders actions as a
`button_interaction` template card. A single `select` field renders as a native
`multiple_interaction` dropdown with a Submit button (up to 10 options). The
callback's `selected_items.selected_item[].option_ids.option_id` carries the
selected option ID, as documented in the WeCom AI Bot event protocol. The Host
resolves the original option value from its persisted interaction request.
Webhook mode does not offer this proactive card API.
## Configuration
| Field | Required | Default | Description |
@@ -2,10 +2,12 @@
from __future__ import annotations
import json
import time
import typing
import uuid
from langbot.libs.wecom_ai_bot_api.api import extract_template_card_event_payload
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot_plugin.api.entities.builtin.platform import events as platform_events
@@ -31,6 +33,7 @@ def build_interaction_card(request: dict[str, typing.Any], callback_token: str)
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
buttons: list[dict[str, typing.Any]] = []
select_field: dict[str, typing.Any] | None = None
if actions and not fields:
buttons = [
{
@@ -46,18 +49,43 @@ def build_interaction_card(request: dict[str, typing.Any], callback_token: str)
if field.get('type') != 'select':
return None
options = field.get('options') if isinstance(field.get('options'), list) else []
buttons = [
{
'text': str(option.get('label') or option.get('value') or index + 1),
'style': 0,
'key': f'lbi:{callback_token}:f:0:{index}',
}
for index, option in enumerate(options[:6])
if isinstance(option, dict)
]
if not buttons:
if len(options) > 10:
raise ValueError('WeCom select cards support at most 10 options')
if options and all(isinstance(option, dict) for option in options):
select_field = field
if not buttons and select_field is None:
return None
description = str(request.get('description') or '').strip()
if select_field is not None:
options = select_field['options']
selected_id = 'opt_1'
for index, option in enumerate(options):
if option.get('value') == select_field.get('default'):
selected_id = f'opt_{index + 1}'
break
return {
'msgtype': 'template_card',
'template_card': {
'card_type': 'multiple_interaction',
'main_title': {'title': str(request.get('title') or ''), 'desc': description},
'select_list': [
{
'question_key': 'f0',
'title': str(select_field.get('label') or ''),
'selected_id': selected_id,
'option_list': [
{
'id': f'opt_{index + 1}',
'text': str(option.get('label') or option.get('value') or '')[:10],
}
for index, option in enumerate(options)
],
}
],
'submit_button': {'text': 'Submit', 'key': f'lbi:{callback_token}:s'},
'task_id': f'lbi-{uuid.uuid4().hex}',
},
}
if len(fields) == 1 and isinstance(fields[0], dict):
label = str(fields[0].get('label') or '').strip()
description = '\n\n'.join(part for part in (description, label) if part)
@@ -95,18 +123,14 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -
result = await adapter.send_message(target_type, target_id, adapter._plain_message(fallback))
return {'ok': True, 'message_id': result.message_id, 'rich': False}
raw = await adapter.bot.send_template_card(target_id, card)
if not isinstance(raw, dict) or raw.get('errcode', 0) != 0:
detail = raw.get('errmsg', raw.get('errcode')) if isinstance(raw, dict) else 'no acknowledgement'
raise RuntimeError(f'WeCom template card delivery failed: {detail}')
return {'ok': True, 'message_id': None, 'rich': True, 'raw': raw}
def _template_card_event(event: WecomBotEvent) -> dict[str, typing.Any]:
wrapper = event.get('event') or {}
if not isinstance(wrapper, dict):
return {}
for key in ('template_card_event', 'templateCardEvent', 'TemplateCardEvent'):
value = wrapper.get(key)
if isinstance(value, dict):
return value
return wrapper
return extract_template_card_event_payload(event)
def _callback_key(payload: dict[str, typing.Any]) -> str:
@@ -125,6 +149,8 @@ def _callback_key(payload: dict[str, typing.Any]) -> str:
def parse_callback_key(value: str) -> dict[str, typing.Any]:
parts = value.split(':')
if len(parts) == 3 and parts[0] == 'lbi' and parts[1] and parts[2] == 's':
return {'callback_token': parts[1], 'select_submit': True}
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
if (
@@ -143,6 +169,57 @@ def parse_callback_key(value: str) -> dict[str, typing.Any]:
raise ValueError('invalid WeCom interaction callback key')
def _selected_option_ref(payload: dict[str, typing.Any]) -> int:
selected_id: typing.Any = None
selected_items = payload.get('selected_items', payload.get('SelectedItems'))
if isinstance(selected_items, dict):
items = selected_items.get('selected_item', selected_items.get('SelectedItem'))
if isinstance(items, list):
for item in items:
if not isinstance(item, dict) or item.get('question_key', item.get('QuestionKey')) != 'f0':
continue
option_ids = item.get('option_ids', item.get('OptionIds'))
if isinstance(option_ids, dict):
option_ids = option_ids.get('option_id', option_ids.get('OptionId'))
if isinstance(option_ids, list) and len(option_ids) == 1:
selected_id = option_ids[0]
break
if selected_id is None and selected_items is None:
response: typing.Any = next(
(payload[key] for key in ('ResponseData', 'response_data', 'responseData') if key in payload), None
)
if isinstance(response, str):
try:
response = json.loads(response)
except json.JSONDecodeError as exc:
raise ValueError('invalid WeCom select response data') from exc
if isinstance(response, dict):
selections = next(
(response[key] for key in ('select_list', 'SelectList', 'selectList') if key in response), None
)
if isinstance(selections, list):
for item in selections:
if not isinstance(item, dict) or item.get('question_key', item.get('QuestionKey')) != 'f0':
continue
selected_id = next(
(
item[key]
for key in ('option_id', 'OptionId', 'optionId', 'selected_id', 'SelectedId', 'selectedId')
if item.get(key)
),
None,
)
break
else:
selected_id = response.get('f0')
if not isinstance(selected_id, str) or not selected_id.startswith('opt_') or not selected_id[4:].isdigit():
raise ValueError('WeCom select response has an invalid option')
option_number = int(selected_id[4:])
if option_number < 1:
raise ValueError('WeCom select response has an invalid option')
return option_number - 1
def interaction_event_from_native(
event: WecomBotEvent,
) -> platform_events.PlatformSpecificEvent | None:
@@ -150,6 +227,9 @@ def interaction_event_from_native(
if not callback_key.startswith('lbi:'):
return None
parsed = parse_callback_key(callback_key)
if parsed.pop('select_submit', False):
parsed['field_ref'] = 0
parsed['option_ref'] = _selected_option_ref(_template_card_event(event))
target_type = 'group' if event.type == 'group' or event.chatid else 'person'
target_id = str(event.chatid or event.userid or '')
return platform_events.PlatformSpecificEvent(
@@ -7,6 +7,7 @@ import pytest
import yaml
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot.pkg.agent.runner.interaction_manager import InteractionManager
from langbot.pkg.platform.adapters.wecombot.adapter import WecomBotAdapter
from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter
@@ -164,6 +165,157 @@ def test_wecombot_interaction_card_uses_host_callback_token_and_index():
assert button['key'] == 'lbi:callback-token:a:0'
@pytest.mark.asyncio
async def test_wecombot_select_card_submits_native_dropdown_value():
adapter = make_adapter()
request = {
'interaction_id': 'form-1',
'title': 'Choose priority',
'description': 'Select one option.',
'fields': [
{
'id': 'priority',
'label': 'Priority',
'type': 'select',
'default': 'normal',
'options': [
{'value': 'normal', 'label': 'Normal'},
{'value': 'urgent', 'label': 'Urgent'},
],
}
],
'fallback_text': 'Choose priority.',
}
result = await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'callback-token',
'reply_target': {'target_type': 'group', 'target_id': 'group-1'},
'request': request,
},
)
assert result['rich'] is True
card = adapter.bot.send_template_card.await_args.args[1]['template_card']
assert card['card_type'] == 'multiple_interaction'
assert card['select_list'] == [
{
'question_key': 'f0',
'title': 'Priority',
'selected_id': 'opt_1',
'option_list': [
{'id': 'opt_1', 'text': 'Normal'},
{'id': 'opt_2', 'text': 'Urgent'},
],
}
]
assert card['submit_button'] == {'text': 'Submit', 'key': 'lbi:callback-token:s'}
native = wecombot_event(type='group', msgtype='event')
native['event'] = {
'eventtype': 'template_card_event',
'template_card_event': {
'card_type': 'multiple_interaction',
'event_key': card['submit_button']['key'],
'selected_items': {
'selected_item': [
{'question_key': 'f0', 'option_ids': {'option_id': ['opt_2']}},
]
},
},
}
event = interaction_event_from_native(native)
assert event.data['callback_token'] == 'callback-token'
resolved = InteractionManager._resolve_submission_refs(
{'interaction_id': request['interaction_id'], 'request': request}, event.data
)
assert resolved['values'] == {'priority': 'urgent'}
listener = AsyncMock()
adapter.register_listener(platform_events.PlatformSpecificEvent, listener)
await adapter._handle_interaction_event(native)
listener.assert_awaited_once()
assert listener.await_args.args[0].data['option_ref'] == 1
def test_wecombot_select_rejects_more_than_ten_options():
with pytest.raises(ValueError, match='at most 10 options'):
build_interaction_card(
{
'title': 'Choose priority',
'fields': [
{
'id': 'priority',
'type': 'select',
'options': [{'value': f'option-{index}'} for index in range(11)],
}
],
},
'callback-token',
)
@pytest.mark.parametrize(
'selected_items',
[
{'selected_item': []},
{'selected_item': [{'question_key': 'f0', 'option_ids': {'option_id': []}}]},
{'selected_item': [{'question_key': 'f0', 'option_ids': {'option_id': ['opt_0']}}]},
],
)
def test_wecombot_select_rejects_invalid_native_selection(selected_items):
native = wecombot_event(type='single', msgtype='event')
native['event'] = {
'eventtype': 'template_card_event',
'template_card_event': {
'event_key': 'lbi:callback-token:s',
'selected_items': selected_items,
},
}
with pytest.raises(ValueError, match='invalid option'):
interaction_event_from_native(native)
def test_wecombot_select_accepts_legacy_response_data():
native = wecombot_event(type='single', msgtype='event')
native['event'] = {
'eventtype': 'template_card_event',
'template_card_event': {
'EventKey': 'lbi:callback-token:s',
'ResponseData': '{"select_list":[{"question_key":"f0","option_id":"opt_2"}]}',
},
}
assert interaction_event_from_native(native).data['option_ref'] == 1
@pytest.mark.asyncio
async def test_wecombot_interaction_delivery_rejects_failed_ack():
adapter = make_adapter()
adapter.bot.send_template_card.return_value = {'errcode': 40001, 'errmsg': 'invalid card'}
with pytest.raises(RuntimeError, match='invalid card'):
await adapter.call_platform_api(
'interaction.request',
{
'callback_token': 'callback-token',
'reply_target': {'target_type': 'person', 'target_id': 'user-1'},
'request': {
'interaction_id': 'form-1',
'title': 'Choose priority',
'fields': [
{
'id': 'priority',
'label': 'Priority',
'type': 'select',
'options': [{'value': 'normal', 'label': 'Normal'}],
}
],
'fallback_text': 'Choose priority.',
},
},
)
@pytest.mark.asyncio
async def test_wecombot_interaction_delivery_and_callback_event():
adapter = make_adapter()