From 2721a213e8b2e0dba0dbbee7a4d357c83707251a Mon Sep 17 00:00:00 2001 From: fdc310 <2213070223@qq.com> Date: Thu, 29 Oct 2026 04:23:54 +0800 Subject: [PATCH] fix(wecombot): handle native select card callbacks --- .../INTERACTION_CALLBACK_LIFECYCLE.md | 44 +++++ docs/event-based-agents/adapters/wecombot.md | 10 ++ .../platform/adapters/wecombot/interaction.py | 116 ++++++++++--- .../platform/test_wecombot_eba_adapter.py | 152 ++++++++++++++++++ 4 files changed, 304 insertions(+), 18 deletions(-) create mode 100644 docs/agent-runner-pluginization/INTERACTION_CALLBACK_LIFECYCLE.md diff --git a/docs/agent-runner-pluginization/INTERACTION_CALLBACK_LIFECYCLE.md b/docs/agent-runner-pluginization/INTERACTION_CALLBACK_LIFECYCLE.md new file mode 100644 index 000000000..72089e64c --- /dev/null +++ b/docs/agent-runner-pluginization/INTERACTION_CALLBACK_LIFECYCLE.md @@ -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. diff --git a/docs/event-based-agents/adapters/wecombot.md b/docs/event-based-agents/adapters/wecombot.md index 23751aec7..5525853d2 100644 --- a/docs/event-based-agents/adapters/wecombot.md +++ b/docs/event-based-agents/adapters/wecombot.md @@ -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 | diff --git a/src/langbot/pkg/platform/adapters/wecombot/interaction.py b/src/langbot/pkg/platform/adapters/wecombot/interaction.py index 46e46dd00..4e5cf7663 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/interaction.py +++ b/src/langbot/pkg/platform/adapters/wecombot/interaction.py @@ -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( diff --git a/tests/unit_tests/platform/test_wecombot_eba_adapter.py b/tests/unit_tests/platform/test_wecombot_eba_adapter.py index 4e8fccc9b..2cfec1729 100644 --- a/tests/unit_tests/platform/test_wecombot_eba_adapter.py +++ b/tests/unit_tests/platform/test_wecombot_eba_adapter.py @@ -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()