mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-01 13:25:18 +08:00
fix(wecombot): handle native select card callbacks
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user