Add unit tests for DingTalk, Lark, WeComBot, and Dify service API runners

- Implement tests for DingTalk adapter helper functions including form content cleaning, input extraction, and completed input lines.
- Create unit tests for Lark adapter helper functions focusing on input extraction and completed input lines.
- Add tests for WeComBot template card functionalities, including event extraction and payload building for human input.
- Enhance Dify service API runner tests to cover human input forms, including input collection, action handling, and form snapshot extraction.
This commit is contained in:
fdc310
2026-07-10 18:25:24 +08:00
parent 776d48380e
commit 98a633c712
15 changed files with 3741 additions and 352 deletions
+23 -3
View File
@@ -23,6 +23,26 @@ _stdout_logger = logging.getLogger('langbot.dingtalk_api')
DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com'
def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict:
"""DingTalk cardParamMap only accepts string values.
Keep callers free to pass structured values for template variables such
as button groups or select options, then encode them once at the API
boundary.
"""
if not card_param_map:
return {}
result = {}
for key, value in card_param_map.items():
if value is None:
result[key] = ''
elif isinstance(value, str):
result[key] = value
else:
result[key] = json.dumps(value, ensure_ascii=False)
return result
class DingTalkClient:
def __init__(
self,
@@ -608,7 +628,7 @@ class DingTalkClient:
if not await self.check_access_token():
await self.get_access_token()
cardData: dict = {'cardParamMap': card_param_map or {}}
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
if card_data_config is not None:
cardData['config'] = json.dumps(card_data_config)
@@ -732,7 +752,7 @@ class DingTalkClient:
body: dict = {
'outTrackId': out_track_id,
'cardData': {'cardParamMap': card_param_map or {}},
'cardData': {'cardParamMap': _stringify_card_param_map(card_param_map)},
}
if private_data:
body['privateData'] = private_data
@@ -746,7 +766,7 @@ class DingTalkClient:
_stdout_logger.info(
'DingTalk update_card_data request: out_track_id=%s body=%s',
out_track_id,
json.dumps(body, ensure_ascii=False)[:500],
json.dumps(body, ensure_ascii=False)[:1500],
)
async with httpx.AsyncClient() as client:
response = await client.put(url, headers=headers, json=body, timeout=30.0)
+11 -1
View File
@@ -29,6 +29,7 @@ _PARAM_PATHS = (
('params',),
('cardPrivateData', 'params'),
('userPrivateData', 'params'),
('actionData', 'cardPrivateData', 'params'),
)
@@ -48,6 +49,14 @@ def _extract_params(content: dict) -> dict:
return {}
def _merge_params(*sources: dict) -> dict:
merged = {}
for source in sources:
if isinstance(source, dict):
merged.update(source)
return merged
class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler):
def __init__(
self,
@@ -61,12 +70,13 @@ class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler):
async def process(self, callback: dingtalk_stream.CallbackMessage):
try:
message = CardCallbackMessage.from_dict(callback.data)
params = _extract_params(message.content if isinstance(message.content, dict) else {})
content = message.content if isinstance(message.content, dict) else {}
# `CardCallbackMessage.from_dict` does not surface `actionId` (the
# top-level field that ButtonGroup's sendCardRequest event puts
# there). Pull it from the raw callback.data instead.
raw = callback.data if isinstance(callback.data, dict) else {}
params = _merge_params(_extract_params(content), _extract_params(raw))
action_id = raw.get('actionId') or ''
if not action_id:
# Some templates nest it under actionData / cardPrivateData.
+618 -8
View File
@@ -779,6 +779,308 @@ def _wecom_button_style(action: dict, *, selected: bool = False) -> int:
return 1
def _wecom_field_display_name(field: dict, fallback: str = '') -> str:
label = (
field.get('label') or field.get('title') or field.get('name') or field.get('output_variable_name') or fallback
)
return str(label or fallback).strip()
def _wecom_input_hint_lines(form_data: dict) -> list[str]:
lines: list[str] = []
current_field = str(form_data.get('_current_input_field') or '').strip()
for field in form_data.get('input_defs') or []:
field_name = str(field.get('output_variable_name') or '').strip()
field_type = str(field.get('type') or 'text').strip().lower()
field_label = _wecom_field_display_name(field, field_name)
if current_field and field_name != current_field:
continue
if not field_name:
continue
if field_type == 'select':
source = field.get('option_source') or {}
options = source.get('value') if isinstance(source, dict) else []
if isinstance(options, list) and options:
option_text = ', '.join(f'{idx}. {option}' for idx, option in enumerate(options, start=1))
lines.append(f'- {field_label}: {option_text}')
else:
lines.append(f'- {field_label}: choose one option')
elif field_type in {'file', 'file-list'}:
limit = field.get('number_limits') if field_type == 'file-list' else 1
allowed_types = ', '.join(field.get('allowed_file_types') or [])
suffix = f', up to {limit}' if field_type == 'file-list' and limit else ''
allowed = f' ({allowed_types})' if allowed_types else ''
lines.append(f'- {field_label}: upload file(s){allowed}{suffix} or reply `{field_name}: <url>`')
else:
lines.append(f'请直接回复:{field_label}')
return lines
def _wecom_pending_input_defs(form_data: dict) -> list[dict]:
if form_data.get('_action_select_only'):
return []
inputs = form_data.get('inputs') or {}
current_field = str(form_data.get('_current_input_field') or '').strip()
pending = []
for field in form_data.get('input_defs') or []:
field_name = str(field.get('output_variable_name') or '').strip()
if not field_name:
continue
if current_field and field_name != current_field:
continue
if str(field.get('type') or '').strip().lower() in {'file', 'file-list'}:
continue
if inputs.get(field_name) in (None, '', []):
pending.append(field)
return pending
def _wecom_select_options(field: dict) -> list[str]:
source = field.get('option_source') or {}
options = source.get('value') if isinstance(source, dict) else []
if not isinstance(options, list):
return []
return [str(option) for option in options]
def _wecom_select_option_id(index: int) -> str:
return f'opt_{index + 1}'
def _wecom_pending_select_defs(form_data: dict) -> list[dict]:
return [
field
for field in _wecom_pending_input_defs(form_data)
if str(field.get('type') or '').strip().lower() == 'select' and _wecom_select_options(field)
]
def _wecom_field_title(field: dict, fallback: str) -> str:
title = _wecom_field_display_name(field, fallback)
return str(title or fallback).strip()[:13] or fallback
def _wecom_form_desc(form_data: dict) -> str:
current_field = str(form_data.get('_current_input_field') or '').strip()
if current_field:
for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []:
if str(field.get('output_variable_name') or '').strip() != current_field:
continue
field_type = str(field.get('type') or 'text').strip().lower()
if field_type != 'select':
return f'请直接回复:{_wecom_field_display_name(field, current_field)}'
form_content = _wecom_clean_form_content(form_data)
if form_content:
return form_content[:512]
return 'Please choose an option to continue.'
def build_human_input_text_prompt(form_data: dict) -> Optional[str]:
"""Build a plain-text prompt for a current non-select input field."""
current_field = str(form_data.get('_current_input_field') or '').strip()
if not current_field:
return None
for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []:
if str(field.get('output_variable_name') or '').strip() != current_field:
continue
field_type = str(field.get('type') or 'text').strip().lower()
if field_type == 'select':
return None
label = _wecom_field_display_name(field, current_field)
return f'{str(form_data.get("node_title") or "人工介入").strip()}\n\n请直接回复:{label}'
return None
def build_multiple_interaction_payload(
form_data: dict,
task_id: str,
*,
source: Optional[dict] = None,
) -> dict[str, Any]:
"""Build a WeCom multiple_interaction card for pending select fields."""
select_fields = _wecom_pending_select_defs(form_data)
node_title = (form_data.get('node_title') or '').strip() or 'Human Input'
inputs = form_data.get('inputs') or {}
select_list = []
for field_index, field in enumerate(select_fields[:10]):
field_name = str(field.get('output_variable_name') or '').strip()
if not field_name:
continue
options = _wecom_select_options(field)[:10]
option_list = [
{
'id': _wecom_select_option_id(idx),
'text': option_text[:10] or _wecom_select_option_id(idx),
}
for idx, option_text in enumerate(options)
]
selected_id = _wecom_select_option_id(0)
current_value = inputs.get(field_name)
if current_value not in (None, '', []):
for idx, option_text in enumerate(options):
if str(current_value) == option_text:
selected_id = _wecom_select_option_id(idx)
break
select_list.append(
{
'question_key': field_name,
'title': _wecom_field_title(field, f'Select {field_index + 1}'),
'selected_id': selected_id,
'option_list': option_list,
}
)
card: dict[str, Any] = {
'card_type': 'multiple_interaction',
'main_title': {
'title': node_title,
'desc': _wecom_form_desc(form_data),
},
'select_list': select_list,
'submit_button': {
'text': 'Submit',
'key': 'submit_human_input',
},
'task_id': task_id,
}
if source:
card['source'] = source
return {
'msgtype': 'template_card',
'template_card': card,
}
_SELECT_BUTTON_KEY_PREFIX = '__dify_select__'
def _encode_select_button_key(field_name: str, option_index: int) -> str:
data = json.dumps({'f': field_name, 'i': option_index}, ensure_ascii=False, separators=(',', ':'))
encoded = base64.urlsafe_b64encode(data.encode('utf-8')).decode('ascii').rstrip('=')
return f'{_SELECT_BUTTON_KEY_PREFIX}:{encoded}'
def parse_select_button_action(action_id: str, form_data: dict) -> dict[str, str]:
"""Decode a select option represented as a button_interaction click."""
action_id = str(action_id or '').strip()
prefix = f'{_SELECT_BUTTON_KEY_PREFIX}:'
if not action_id.startswith(prefix):
return {}
encoded = action_id[len(prefix) :]
try:
padded = encoded + '=' * (-len(encoded) % 4)
data = json.loads(base64.urlsafe_b64decode(padded.encode('ascii')).decode('utf-8'))
except Exception:
return {}
field_name = str(data.get('f') or '').strip()
option_index = data.get('i')
if not field_name or not isinstance(option_index, int):
return {}
for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []:
if str(field.get('output_variable_name') or '').strip() != field_name:
continue
options = _wecom_select_options(field)
if 0 <= option_index < len(options):
return {field_name: options[option_index]}
return {}
def build_select_button_interaction_payload(
form_data: dict,
task_id: str,
*,
source: Optional[dict] = None,
) -> dict[str, Any]:
"""Build a button_interaction card that emulates a select field.
WeCom AI Bot long-connection callbacks are reliable for button clicks, so
this is used as a fallback when multiple_interaction submit callbacks are
not delivered by the platform.
"""
select_fields = _wecom_pending_select_defs(form_data)
field = select_fields[0] if select_fields else {}
field_name = str(field.get('output_variable_name') or '').strip()
options = _wecom_select_options(field)[:10] if field else []
visible_options = options[:6]
overflow_options = options[6:]
node_title = (form_data.get('node_title') or '').strip() or 'Human Input'
field_title = _wecom_field_title(field, field_name or 'Select') if field else 'Select'
form_content = _wecom_clean_form_content(form_data)
sub_title_parts: list[str] = []
if form_content:
sub_title_parts.append(form_content)
sub_title_parts.append(f'Choose {field_title}.')
if overflow_options:
extra_lines = [f' - {idx + 7}. {option}' for idx, option in enumerate(overflow_options)]
sub_title_parts.append(
'More options can be entered by replying with the option text:\n' + '\n'.join(extra_lines)
)
button_list = [
{
'text': option_text[:10] or f'Option {idx + 1}',
'style': 2 if idx == 0 else 0,
'key': _encode_select_button_key(field_name, idx),
}
for idx, option_text in enumerate(visible_options)
]
card: dict[str, Any] = {
'card_type': 'button_interaction',
'main_title': {
'title': node_title,
},
'sub_title_text': '\n\n'.join(sub_title_parts),
'button_list': button_list,
'task_id': task_id,
}
if source:
card['source'] = source
return {
'msgtype': 'template_card',
'template_card': card,
}
def build_human_input_template_card_payload(
form_data: dict,
task_id: str,
*,
source: Optional[dict] = None,
select_as_buttons: bool = False,
) -> dict[str, Any]:
"""Build the best WeCom template card for a Dify human-input form."""
if _wecom_pending_select_defs(form_data):
if select_as_buttons:
return build_select_button_interaction_payload(form_data, task_id, source=source)
return build_multiple_interaction_payload(form_data, task_id, source=source)
return build_button_interaction_payload(form_data, task_id, source=source)
def _wecom_clean_form_content(form_data: dict) -> str:
content = form_data.get('raw_form_content') or form_data.get('form_content') or ''
field_names = {
str(field.get('output_variable_name') or '').strip()
for field in form_data.get('input_defs') or []
if str(field.get('output_variable_name') or '').strip()
}
kept_lines: list[str] = []
for line in str(content).splitlines():
placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line)
if placeholder and placeholder.group(1) in field_names:
continue
kept_lines.append(line)
return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip())
def build_button_interaction_payload(
form_data: dict,
task_id: str,
@@ -815,14 +1117,20 @@ def build_button_interaction_payload(
"""
actions = list(form_data.get('actions') or [])
node_title = (form_data.get('node_title') or '').strip() or '人工介入'
form_content = (form_data.get('form_content') or '').strip()
form_content = _wecom_clean_form_content(form_data)
should_show_actions = not _wecom_pending_input_defs(form_data)
visible_actions = actions[:6]
overflow = actions[6:]
visible_actions = actions[:6] if should_show_actions else []
overflow = actions[6:] if should_show_actions else []
sub_title_parts: list[str] = []
if form_content:
sub_title_parts.append(form_content)
input_hint_lines = _wecom_input_hint_lines(form_data)
if input_hint_lines:
sub_title_parts.append('Fill these fields in chat before choosing an action:\n' + '\n'.join(input_hint_lines))
elif should_show_actions and actions:
sub_title_parts.append('Choose an action to continue.')
if overflow:
extra_lines = [f' - {a.get("title") or a.get("id") or ""} (回复 id: {a.get("id") or ""})' for a in overflow]
sub_title_parts.append(f'另有 {len(overflow)} 个选项不在按钮列表中,可直接回复 id:\n' + '\n'.join(extra_lines))
@@ -844,6 +1152,7 @@ def build_button_interaction_payload(
'card_type': 'button_interaction',
'main_title': {
'title': node_title,
'desc': _wecom_form_desc(form_data),
},
'sub_title_text': sub_title_text,
'button_list': button_list,
@@ -890,6 +1199,224 @@ def extract_template_card_action(tce: dict[str, Any]) -> tuple[str, str, str]:
return str(task_id or ''), str(event_key or ''), str(card_type or '')
def extract_wecom_event_type(payload: dict[str, Any]) -> str:
"""Extract eventtype from common WeCom callback wrapper shapes."""
event = payload.get('event') if isinstance(payload, dict) else {}
if not isinstance(event, dict):
event = {}
event_type = (
event.get('eventtype')
or event.get('event_type')
or event.get('eventType')
or event.get('EventType')
or payload.get('eventtype')
or payload.get('event_type')
or payload.get('eventType')
or payload.get('EventType')
or ''
)
if event_type:
return str(event_type)
tce = extract_template_card_event_payload(payload)
task_id, event_key, card_type = extract_template_card_action(tce)
if task_id or event_key or card_type or extract_template_card_selections(tce):
return 'template_card_event'
return ''
def extract_template_card_event_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Extract template_card_event from common WeCom callback wrapper shapes."""
if not isinstance(payload, dict):
return {}
event = payload.get('event') if isinstance(payload.get('event'), dict) else {}
candidates = (
event.get('template_card_event'),
event.get('templateCardEvent'),
event.get('TemplateCardEvent'),
event.get('template_card'),
payload.get('template_card_event'),
payload.get('templateCardEvent'),
payload.get('TemplateCardEvent'),
payload.get('template_card'),
)
for candidate in candidates:
if isinstance(candidate, dict):
return candidate
if any(
key in payload
for key in (
'TaskId',
'task_id',
'taskId',
'EventKey',
'event_key',
'eventKey',
'CardType',
'card_type',
'cardType',
'ResponseData',
'response_data',
'select_list',
'SelectList',
)
):
return payload
if any(
key in event
for key in (
'TaskId',
'task_id',
'taskId',
'EventKey',
'event_key',
'eventKey',
'CardType',
'card_type',
'cardType',
'ResponseData',
'response_data',
'select_list',
'SelectList',
)
):
return event
return {}
def extract_template_card_selections(tce: dict[str, Any], form_data: Optional[dict] = None) -> dict[str, str]:
"""Extract multiple_interaction select values from a WeCom callback.
WeCom callback examples differ between webhook and websocket docs, so this
parser accepts common snake_case/camelCase/PascalCase variants and maps the
selected option id back to the Dify select option text when form_data is
available.
"""
fields_by_name: dict[str, dict] = {}
if form_data:
for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []:
field_name = str(field.get('output_variable_name') or '').strip()
if field_name:
fields_by_name[field_name] = field
def _maybe_decode_json(value: Any) -> Any:
if not isinstance(value, str):
return value
text = value.strip()
if not text or text[0] not in '[{':
return value
try:
return json.loads(text)
except json.JSONDecodeError:
return value
def _walk(value: Any) -> list[dict]:
value = _maybe_decode_json(value)
found: list[dict] = []
if isinstance(value, dict):
found.append(value)
for child in value.values():
found.extend(_walk(child))
elif isinstance(value, list):
for child in value:
found.extend(_walk(child))
return found
def _lookup(item: dict, *keys: str) -> Any:
for key in keys:
if key in item:
return item.get(key)
lower_map = {str(key).lower(): value for key, value in item.items()}
for key in keys:
lowered = key.lower()
if lowered in lower_map:
return lower_map[lowered]
return ''
def _normalise_selected_value(question_key: str, selected: Any) -> str:
selected = _maybe_decode_json(selected)
if isinstance(selected, dict):
selected = _lookup(
selected,
'selected_id',
'SelectedId',
'selected_option_id',
'SelectedOptionId',
'option_id',
'OptionId',
'id',
'Id',
'value',
'Value',
)
selected_id = str(selected or '').strip()
if not selected_id:
return ''
selected_value = selected_id
field = fields_by_name.get(question_key)
if field:
options = _wecom_select_options(field)
for idx, option_text in enumerate(options):
if selected_id in {_wecom_select_option_id(idx), option_text}:
selected_value = option_text
break
return selected_value
selections: dict[str, str] = {}
for item in _walk(tce):
question_key = _lookup(
item,
'question_key',
'questionKey',
'QuestionKey',
'question',
'Question',
'key',
'Key',
)
selected_id = _lookup(
item,
'selected_id',
'selectedId',
'SelectedId',
'selected_option_id',
'selectedOptionId',
'SelectedOptionId',
'option_id',
'optionId',
'OptionId',
'value',
'Value',
)
question_key = str(question_key or '').strip()
selected_id = str(selected_id or '').strip()
if question_key not in fields_by_name or not selected_id:
continue
selected_value = _normalise_selected_value(question_key, selected_id)
if not selected_value:
continue
selections[question_key] = selected_value
# Some WeCom callbacks encode ResponseData as a direct mapping:
# {"xiala": "id_two"} rather than a select_list item array.
for item in _walk(tce):
if not isinstance(item, dict):
continue
for question_key, selected in item.items():
question_key = str(question_key or '').strip()
if question_key not in fields_by_name or question_key in selections:
continue
selected_value = _normalise_selected_value(question_key, selected)
if selected_value:
selections[question_key] = selected_value
return selections
def resolve_form_action_title(form_data: dict, action_id: str) -> str:
"""Resolve a Dify form action title from its id."""
@@ -954,6 +1481,73 @@ def build_button_interaction_update_card(
return card
def build_multiple_interaction_update_card(
form_data: dict,
task_id: str,
selections: Optional[dict[str, str]] = None,
source: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""Build an update card that freezes submitted select values."""
node_title = str(form_data.get('node_title') or '').strip() or 'Human Input'
selected_values = dict(form_data.get('inputs') or {})
selected_values.update(selections or {})
select_list = []
fields = _wecom_pending_select_defs(form_data)
if not fields:
fields = [
field
for field in (form_data.get('input_defs') or form_data.get('all_input_defs') or [])
if str(field.get('type') or '').strip().lower() == 'select'
]
for field_index, field in enumerate(fields[:10]):
field_name = str(field.get('output_variable_name') or '').strip()
if not field_name:
continue
options = _wecom_select_options(field)[:10]
option_list = [
{
'id': _wecom_select_option_id(idx),
'text': option_text[:10] or _wecom_select_option_id(idx),
}
for idx, option_text in enumerate(options)
]
selected_id = _wecom_select_option_id(0)
current_value = selected_values.get(field_name)
if current_value not in (None, '', []):
for idx, option_text in enumerate(options):
if str(current_value) == option_text or str(current_value) == _wecom_select_option_id(idx):
selected_id = _wecom_select_option_id(idx)
break
select_list.append(
{
'question_key': field_name,
'title': _wecom_field_title(field, f'Select {field_index + 1}'),
'disable': True,
'selected_id': selected_id,
'option_list': option_list,
}
)
card: dict[str, Any] = {
'card_type': 'multiple_interaction',
'main_title': {
'title': node_title,
'desc': 'Submitted',
},
'select_list': select_list,
'submit_button': {
'text': 'Submitted',
'key': 'submit_human_input',
},
'task_id': task_id,
}
if source:
card['source'] = source
return card
class WecomBotClient:
def __init__(
self,
@@ -1000,6 +1594,7 @@ class WecomBotClient:
self._feedback_callback: Optional[Callable] = None
self._card_action_callback: Optional[Callable] = None
self._stream_last_content: dict[str, str] = {}
# Optional `source` block injected into every interactive template_card
# the client builds. Set via `set_card_source` from the adapter after
# reading config. Format: {icon_url, desc, desc_color}.
@@ -1065,7 +1660,7 @@ class WecomBotClient:
"""Class-level shim — delegates to module-level builder and auto-
injects the client's configured `source` block so every card emitted
through this client carries the LangBot header."""
return build_button_interaction_payload(form_data, task_id, source=self.card_source)
return build_human_input_template_card_payload(form_data, task_id, source=self.card_source)
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
"""对响应进行加密封装并返回给企业微信。
@@ -1277,8 +1872,7 @@ class WecomBotClient:
msg_json = json.loads(decrypted_xml)
event = msg_json.get('event', {})
event_type = event.get('eventtype', '')
event_type = extract_wecom_event_type(msg_json)
if event_type == 'feedback_event':
return await self._handle_feedback_event(msg_json, nonce)
@@ -1302,7 +1896,7 @@ class WecomBotClient:
the button's ``key`` (which we set to the Dify ``action_id``).
"""
try:
tce = msg_json.get('event', {}).get('template_card_event', {})
tce = extract_template_card_event_payload(msg_json)
task_id, event_key, card_type = extract_template_card_action(tce)
await self.logger.info(f'收到按钮点击: task_id={task_id} event_key={event_key!r} card_type={card_type}')
@@ -1428,9 +2022,25 @@ class WecomBotClient:
if not stream_id:
return False
chunk = StreamChunk(content=content, is_final=is_final)
previous_content = self._stream_last_content.get(msg_id, '')
if previous_content and content.startswith(previous_content):
delta_content = content[len(previous_content) :]
next_content = content
elif previous_content and not content:
delta_content = ''
next_content = previous_content
else:
delta_content = content
next_content = previous_content + content if previous_content else content
if not is_final and not delta_content:
return True
chunk = StreamChunk(content=delta_content, is_final=is_final)
await self.stream_sessions.publish(stream_id, chunk)
self._stream_last_content[msg_id] = next_content
if is_final:
self._stream_last_content.pop(msg_id, None)
self.stream_sessions.mark_finished(stream_id)
return True
+128 -67
View File
@@ -23,9 +23,15 @@ from langbot.libs.wecom_ai_bot_api import wecombotevent
from langbot.libs.wecom_ai_bot_api.api import (
parse_wecom_bot_message,
StreamSession,
build_button_interaction_payload,
build_human_input_template_card_payload,
build_human_input_text_prompt,
build_button_interaction_update_card,
build_multiple_interaction_update_card,
extract_template_card_action,
extract_template_card_event_payload,
extract_template_card_selections,
extract_wecom_event_type,
parse_select_button_action,
)
from langbot.pkg.platform.logger import EventLogger
@@ -49,6 +55,10 @@ def _generate_req_id(prefix: str) -> str:
return f'{prefix}_{ts}_{rand}'
def _frame_snippet(frame: dict, limit: int = 1000) -> str:
return json.dumps(frame, ensure_ascii=False, default=str)[:limit]
class WecomBotWsClient:
"""WeChat Work AI Bot WebSocket long connection client.
@@ -334,6 +344,21 @@ class WecomBotWsClient:
task_id = f'dify-{secrets.token_hex(12)}'
session_info = self._stream_sessions.get(msg_id) or {}
text_prompt = build_human_input_text_prompt(form_data)
if text_prompt:
try:
ack = await self.reply_text(req_id, text_prompt)
if ack is None:
return False, stream_id, None
except Exception:
await self.logger.error(f'Failed to send human-input text prompt: {traceback.format_exc()}')
return False, stream_id, None
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
return True, stream_id, None
self._pending_forms_by_task[task_id] = {
'form_data': form_data,
'msg_id': msg_id,
@@ -344,7 +369,12 @@ class WecomBotWsClient:
}
self._task_id_by_msg[msg_id] = task_id
card_payload = build_button_interaction_payload(form_data, task_id, source=self.card_source)
card_payload = build_human_input_template_card_payload(
form_data,
task_id,
source=self.card_source,
select_as_buttons=True,
)
try:
await self.reply_template_card(req_id, card_payload)
except Exception:
@@ -421,8 +451,19 @@ class WecomBotWsClient:
return False
req_id, stream_id = key.split('|', 1)
try:
previous_content = self._stream_last_content.get(msg_id, '')
if previous_content and content.startswith(previous_content):
delta_content = content[len(previous_content) :]
next_content = content
elif previous_content and not content:
delta_content = ''
next_content = previous_content
else:
delta_content = content
next_content = previous_content + content if previous_content else content
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
if not is_final and content == self._stream_last_content.get(msg_id):
if not is_final and not delta_content:
return True
# Skip empty/whitespace-only chunks — the runner injects a
@@ -435,7 +476,7 @@ class WecomBotWsClient:
if not is_final:
import re as _re
if not _re.sub(r'[\s]', '', content):
if not _re.sub(r'[\s]', '', delta_content):
return True
# Generate feedback_id for final chunk
@@ -448,8 +489,8 @@ class WecomBotWsClient:
if session_info:
self._feedback_sessions[feedback_id] = session_info
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
self._stream_last_content[msg_id] = content
await self.reply_stream(req_id, stream_id, delta_content, finish=is_final, feedback_id=feedback_id)
self._stream_last_content[msg_id] = next_content
if is_final:
self._stream_ids.pop(msg_id, None)
self._stream_last_content.pop(msg_id, None)
@@ -623,7 +664,7 @@ class WecomBotWsClient:
return
# Unknown frame
await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}')
await self.logger.warning(f'Unknown frame: {_frame_snippet(frame)}')
async def _handle_message_callback(self, frame: dict):
"""Handle an incoming message callback frame."""
@@ -631,6 +672,13 @@ class WecomBotWsClient:
body = frame.get('body', {})
req_id = frame.get('headers', {}).get('req_id', '')
event_type = extract_wecom_event_type(body)
if event_type == 'template_card_event':
await self._handle_template_card_event_frame(frame, body)
return
if event_type:
await self.logger.debug(f'Received msg_callback event_type={event_type}: {_frame_snippet(frame)}')
# Parse message using shared logic
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
if not message_data:
@@ -664,8 +712,12 @@ class WecomBotWsClient:
body = frame.get('body', {})
req_id = frame.get('headers', {}).get('req_id', '')
event_info = body.get('event', {})
event_type = event_info.get('eventtype', '')
event_info = body.get('event', {}) if isinstance(body.get('event'), dict) else body
event_type = extract_wecom_event_type(body)
if not event_type:
await self.logger.warning(f'Received event_callback without event_type: {_frame_snippet(frame)}')
else:
await self.logger.debug(f'Received event_callback event_type={event_type}')
message_data = {
'msgtype': 'event',
@@ -727,64 +779,7 @@ class WecomBotWsClient:
return
if event_type == 'template_card_event':
tce = event_info.get('template_card_event', {})
task_id, event_key, card_type = extract_template_card_action(tce)
await self.logger.info(
f'收到按钮点击 (ws): task_id={task_id} event_key={event_key!r} card_type={card_type}'
)
pending = self._pending_forms_by_task.get(task_id)
if pending is None:
await self.logger.warning(f'未找到 task_id={task_id} 对应的 pending_form (ws),按钮点击被丢弃')
else:
# Update the card in-place to show which button was clicked.
# Must happen within 5s of the event, using the same req_id.
req_id_for_update = frame.get('headers', {}).get('req_id', '')
form_data = pending.get('form_data', {}) or {}
action_title = event_key
node_title = form_data.get('node_title', '') or ''
main_title_text = f'{node_title} - 已选择' if node_title else '已选择'
main_title_desc = f'{action_title}'
update_card = {
'card_type': 'button_interaction',
'main_title': {
'title': main_title_text,
'desc': main_title_desc,
},
'button_list': [],
'task_id': task_id,
}
if self.card_source:
update_card['source'] = self.card_source
update_card = build_button_interaction_update_card(
form_data,
task_id,
event_key,
source=self.card_source,
)
try:
await self.update_template_card(req_id_for_update, update_card)
except Exception:
await self.logger.warning(f'更新卡片失败 (ws): {traceback.format_exc()}')
if self._card_action_callback is not None:
try:
session = StreamSession(
stream_id=pending.get('stream_id', ''),
msg_id=pending.get('msg_id', ''),
chat_id=pending.get('chat_id') or None,
user_id=pending.get('user_id') or None,
)
session.pending_form = pending.get('form_data')
session.pending_form_task_id = task_id
await self._card_action_callback(session, event_key, task_id, body)
except Exception:
await self.logger.error(f'card action callback raised (ws): {traceback.format_exc()}')
# Consume — drop bookkeeping so a stale click can't re-fire.
self._pending_forms_by_task.pop(task_id, None)
msg_id = pending.get('msg_id', '')
if msg_id:
self._task_id_by_msg.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
await self._handle_template_card_event_frame(frame, body)
return
event = wecombotevent.WecomBotEvent(message_data)
@@ -800,6 +795,72 @@ class WecomBotWsClient:
except Exception:
await self.logger.error(f'Error in event callback: {traceback.format_exc()}')
async def _handle_template_card_event_frame(self, frame: dict, body: dict):
"""Handle template_card_event frames from event_callback or msg_callback."""
tce = extract_template_card_event_payload(body)
task_id, event_key, card_type = extract_template_card_action(tce)
await self.logger.info(
f'Received template_card_event (ws): task_id={task_id} event_key={event_key!r} card_type={card_type}'
)
pending = self._pending_forms_by_task.get(task_id)
if pending is None:
await self.logger.warning(f'No pending_form found for task_id={task_id} (ws); card event ignored')
return
req_id_for_update = frame.get('headers', {}).get('req_id', '')
form_data = pending.get('form_data', {}) or {}
selections = extract_template_card_selections(tce, form_data)
if not selections:
selections = parse_select_button_action(event_key, form_data)
if card_type == 'multiple_interaction' and not selections:
await self.logger.warning(
f'multiple_interaction callback has no parseable selections (ws): raw={str(tce)[:1000]}'
)
self._drop_pending_form_task(task_id, pending)
return
update_card = build_button_interaction_update_card(
form_data,
task_id,
event_key,
source=self.card_source,
)
if card_type == 'multiple_interaction' or selections:
update_card = build_multiple_interaction_update_card(
form_data,
task_id,
selections,
source=self.card_source,
)
try:
await self.update_template_card(req_id_for_update, update_card)
except Exception:
await self.logger.warning(f'Failed to update template card (ws): {traceback.format_exc()}')
if self._card_action_callback is not None:
try:
session = StreamSession(
stream_id=pending.get('stream_id', ''),
msg_id=pending.get('msg_id', ''),
chat_id=pending.get('chat_id') or None,
user_id=pending.get('user_id') or None,
)
session.pending_form = pending.get('form_data')
session.pending_form_task_id = task_id
await self._card_action_callback(session, event_key, task_id, body)
except Exception:
await self.logger.error(f'card action callback raised (ws): {traceback.format_exc()}')
self._drop_pending_form_task(task_id, pending)
def _drop_pending_form_task(self, task_id: str, pending: dict) -> None:
self._pending_forms_by_task.pop(task_id, None)
msg_id = pending.get('msg_id', '')
if msg_id:
self._task_id_by_msg.pop(msg_id, None)
self._stream_sessions.pop(msg_id, None)
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):
"""Dispatch a message event to registered handlers with deduplication."""
try:
+449 -45
View File
@@ -1,6 +1,7 @@
import asyncio
import json
import pathlib
import re
import traceback
import typing
import uuid
@@ -168,6 +169,261 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
)
def _dingtalk_input_hint_lines(form_data: dict) -> list[str]:
lines: list[str] = []
current_field = str(form_data.get('_current_input_field') or '').strip()
for field in form_data.get('input_defs') or []:
field_name = str(field.get('output_variable_name') or '').strip()
field_type = str(field.get('type') or 'text').strip().lower()
if current_field and field_name != current_field:
continue
if not field_name:
continue
if field_type == 'select':
source = field.get('option_source') or {}
options = source.get('value') if isinstance(source, dict) else []
if isinstance(options, list) and options:
option_text = ', '.join(f'{idx}. {option}' for idx, option in enumerate(options, start=1))
lines.append(f'- {field_name}: {option_text}')
else:
lines.append(f'- {field_name}: choose one option')
elif field_type in {'file', 'file-list'}:
limit = field.get('number_limits') if field_type == 'file-list' else 1
allowed_types = ', '.join(field.get('allowed_file_types') or [])
suffix = f', up to {limit}' if field_type == 'file-list' and limit else ''
allowed = f' ({allowed_types})' if allowed_types else ''
lines.append(f'- {field_name}: upload file(s){allowed}{suffix} or reply `{field_name}: <url>`')
else:
lines.append(f'- {field_name}: reply `{field_name}: <value>`')
return lines
def _dingtalk_pending_input_defs(form_data: dict) -> list[dict]:
if form_data.get('_action_select_only'):
return []
inputs = form_data.get('inputs') or {}
pending = []
for field in form_data.get('input_defs') or []:
field_name = str(field.get('output_variable_name') or '').strip()
if not field_name:
continue
if inputs.get(field_name) in (None, '', []):
pending.append(field)
return pending
def _dingtalk_clean_form_content(form_data: dict) -> str:
content = form_data.get('raw_form_content') or form_data.get('form_content') or ''
field_names = {
str(field.get('output_variable_name') or '').strip()
for field in _dingtalk_form_input_defs(form_data)
if str(field.get('output_variable_name') or '').strip()
}
kept_lines: list[str] = []
for line in str(content).splitlines():
placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line)
if placeholder and placeholder.group(1) in field_names:
continue
kept_lines.append(line)
return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip())
def _dingtalk_form_input_defs(form_data: dict) -> list[dict]:
return list(form_data.get('all_input_defs') or form_data.get('input_defs') or [])
def _dingtalk_display_input_value(field: dict, value: typing.Any) -> str:
field_type = _dingtalk_field_type(field)
if field_type == 'file':
if isinstance(value, dict):
return value.get('url') or value.get('upload_file_id') or '1 file'
return str(value)
if field_type == 'file-list':
if isinstance(value, list):
return f'{len(value)} file(s)'
return str(value)
return str(value)
def _dingtalk_completed_input_lines(form_data: dict) -> list[str]:
inputs = form_data.get('inputs') or {}
if not isinstance(inputs, dict):
return []
lines: list[str] = []
for field in _dingtalk_form_input_defs(form_data):
field_name = _dingtalk_field_name(field)
if not field_name:
continue
value = inputs.get(field_name)
if value in (None, '', []):
continue
field_type = _dingtalk_field_type(field)
display_value = _dingtalk_display_input_value(field, value)
if field_type == 'select':
lines.append(f'✅ 已选择 {field_name}**{display_value}**')
elif field_type in {'file', 'file-list'}:
lines.append(f'✅ 已上传 {field_name}**{display_value}**')
else:
lines.append(f'✅ 已填写 {field_name}**{display_value}**')
return lines
def _dingtalk_supports_native_field(form_data: dict) -> bool:
current_name = str(form_data.get('_current_input_field') or '').strip()
if not current_name or form_data.get('_action_select_only'):
return False
for field in _dingtalk_form_input_defs(form_data):
if str(field.get('output_variable_name') or '').strip() != current_name:
continue
return str(field.get('type') or 'text').strip().lower() not in {'file', 'file-list'}
return False
def _dingtalk_field_name(field: dict) -> str:
return str(field.get('output_variable_name') or field.get('name') or field.get('id') or '').strip()
def _dingtalk_field_type(field: dict) -> str:
return str(field.get('type') or 'text').strip().lower()
def _dingtalk_select_options(field: dict) -> list[str]:
source = field.get('option_source') or {}
value = source.get('value') if isinstance(source, dict) else None
if isinstance(value, list):
return [str(item) for item in value]
if isinstance(value, str):
return [part.strip() for part in value.splitlines() if part.strip()]
options = field.get('options')
if isinstance(options, list):
result = []
for item in options:
if isinstance(item, dict):
result.append(str(item.get('label') or item.get('value') or ''))
else:
result.append(str(item))
return [item for item in result if item]
return []
def _dingtalk_select_block_options(options: list[str]) -> list[dict]:
"""Build the option shape consumed by DingTalk SelectBlock templates."""
locales = (
'zh_CN',
'zh_TW',
'en_US',
'ja_JP',
'vi_VN',
'th_TH',
'id_ID',
'ne_NP',
'ms_MY',
'ko_KR',
'ru_RU',
'es_EA',
'tr_TR',
'fr_FR',
'pt_BR',
)
return [{'value': option, 'text': {locale: option for locale in locales}} for option in options]
def _dingtalk_current_input_field(form_data: dict) -> dict | None:
current_name = str(form_data.get('_current_input_field') or '').strip()
if not current_name or form_data.get('_action_select_only'):
return None
for field in _dingtalk_form_input_defs(form_data):
if _dingtalk_field_name(field) == current_name:
return field
return None
def _dingtalk_form_component_params(form_data: dict) -> dict:
field = _dingtalk_current_input_field(form_data)
params = {
'input_visible': '',
'input_title': '',
'input_placeholder': '',
'input_value': '',
'select_visible': '',
'select_placeholder': '',
'select_options': [],
'index_o': [],
'test_index': [],
'select_index': -1,
}
if not field:
return params
field_name = _dingtalk_field_name(field)
field_type = _dingtalk_field_type(field)
value = form_data.get('inputs', {}).get(field_name, '')
if field_type == 'select':
options = _dingtalk_select_options(field)
selected_index = -1
if value not in (None, ''):
try:
selected_index = options.index(str(value))
except ValueError:
selected_index = -1
params.update(
{
'select_visible': 'true',
'select_placeholder': field_name or 'Select',
'select_options': options,
'index_o': _dingtalk_select_block_options(options),
'test_index': _dingtalk_select_block_options(options),
'select_index': selected_index,
}
)
elif field_type not in {'file', 'file-list'}:
params.update(
{
'input_visible': 'true',
'input_title': field_name or 'Input',
'input_placeholder': field_name or 'Input',
'input_value': '' if value is None else str(value),
}
)
return params
def _dingtalk_empty_form_component_params() -> dict:
return _dingtalk_form_component_params({})
def _dingtalk_extract_component_inputs(params: dict) -> dict:
"""Normalize DingTalk native Input/SelectBlock callback payloads."""
if not isinstance(params, dict):
return {}
result = {}
input_value = params.get('input')
if input_value in (None, ''):
input_result = params.get('inputResult')
if isinstance(input_result, dict):
input_value = input_result.get('value') or input_result.get('input')
elif input_result not in (None, ''):
input_value = input_result
if input_value not in (None, ''):
result['input'] = input_value
select_value = params.get('select')
if select_value in (None, ''):
for key in ('selectResult', 'select_result', '__built_in_selectResult__'):
candidate = params.get(key)
if candidate not in (None, ''):
select_value = candidate
break
if isinstance(select_value, dict):
select_value = select_value.get('value') or select_value.get('label') or select_value.get('index')
if select_value not in (None, ''):
result['select'] = select_value
return result
class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot: DingTalkClient
bot_account_id: str
@@ -329,8 +585,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content=content,
btns='[]',
flowStatus='3' if is_final else '1',
**_dingtalk_empty_form_component_params(),
),
)
session_key = self._session_key_from_event(message_source)
if session_key:
self.active_turn_text[session_key] = content
except Exception:
if self.ap is not None:
self.ap.logger.exception('DingTalk: update card content failed')
@@ -343,7 +603,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
try:
await self.bot.update_card_data(
out_track_id=card_instance_id,
card_param_map=self._card_params(flowStatus='3'),
card_param_map=self._card_params(
flowStatus='3',
**_dingtalk_empty_form_component_params(),
),
)
except Exception:
pass
@@ -396,7 +659,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
out_track_id=out_track_id,
open_space_id=open_space_id,
is_group=is_group,
card_param_map=self._card_params(content='', btns='[]', flowStatus='1'),
card_param_map=self._card_params(
content='',
btns='[]',
flowStatus='1',
**_dingtalk_empty_form_component_params(),
),
callback_type='STREAM',
)
except Exception:
@@ -606,7 +874,15 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Update an existing card's content + buttons for human-input."""
actions = list(form_data.get('actions') or [])
node_title = form_data.get('node_title', '') or 'Human Input Required'
form_content = form_data.get('form_content', '') or ''
form_content = _dingtalk_clean_form_content(form_data)
should_show_actions = not _dingtalk_pending_input_defs(form_data)
component_params = _dingtalk_form_component_params(form_data)
native_field = _dingtalk_supports_native_field(form_data)
if self.ap is not None and component_params.get('select_visible'):
self.ap.logger.info(
f'DingTalk form select params: field={form_data.get("_current_input_field", "")!r} '
f'options={len(component_params.get("select_options") or [])}'
)
# Record form state for the click-handler.
launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source)
@@ -617,12 +893,16 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'sender_user_id': sender_user_id,
'form_token': form_data.get('form_token', ''),
'workflow_run_id': form_data.get('workflow_run_id', ''),
'actions': actions,
'actions': actions if should_show_actions else [],
'all_actions': actions,
'node_title': node_title,
'form_content': form_content,
'current_input_field': str(form_data.get('_current_input_field') or ''),
'input_defs': _dingtalk_form_input_defs(form_data),
'inputs': form_data.get('inputs') or {},
}
btns = self._build_btns(actions, out_track_id)
btns = self._build_btns(actions if should_show_actions else [], out_track_id)
parts: list[str] = []
prior = self.active_turn_text.get(session_key, '') if session_key else ''
if prior.strip():
@@ -636,6 +916,14 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
parts.append(f'**{node_title}**')
if form_content:
parts.append(form_content)
completed_lines = _dingtalk_completed_input_lines(form_data)
if completed_lines:
parts.append('<hr>' + '<br>'.join(completed_lines))
input_hint_lines = [] if native_field else _dingtalk_input_hint_lines(form_data)
if input_hint_lines:
parts.append('Fill these fields in chat before choosing an action:<br>' + '<br>'.join(input_hint_lines))
elif should_show_actions and actions:
parts.append('Choose an action to continue.')
display_content = '<br><br>'.join(parts) or '请选择一个操作以继续。'
try:
@@ -645,6 +933,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content=display_content,
btns=json.dumps(btns, ensure_ascii=False),
flowStatus='3',
**component_params,
),
)
except Exception:
@@ -653,9 +942,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.send_message_text_form(message_source, form_data)
return
if session_key:
self.active_turn_text[session_key] = display_content
@staticmethod
def _build_btns(actions: list, out_track_id: str) -> list:
btns = []
@@ -699,7 +985,15 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
actions = list(form_data.get('actions') or [])
node_title = form_data.get('node_title', '') or 'Human Input Required'
form_content = form_data.get('form_content', '') or ''
form_content = _dingtalk_clean_form_content(form_data)
should_show_actions = not _dingtalk_pending_input_defs(form_data)
component_params = _dingtalk_form_component_params(form_data)
native_field = _dingtalk_supports_native_field(form_data)
if self.ap is not None and component_params.get('select_visible'):
self.ap.logger.info(
f'DingTalk form select params: field={form_data.get("_current_input_field", "")!r} '
f'options={len(component_params.get("select_options") or [])}'
)
self.card_state[out_track_id] = {
'session_key': session_key,
@@ -708,9 +1002,13 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'sender_user_id': sender_user_id,
'form_token': form_data.get('form_token', ''),
'workflow_run_id': form_data.get('workflow_run_id', ''),
'actions': actions,
'actions': actions if should_show_actions else [],
'all_actions': actions,
'node_title': node_title,
'form_content': form_content,
'current_input_field': str(form_data.get('_current_input_field') or ''),
'input_defs': _dingtalk_form_input_defs(form_data),
'inputs': form_data.get('inputs') or {},
'open_space_id': open_space_id,
'is_group': is_group,
}
@@ -720,33 +1018,17 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
parts.append(f'**{node_title}**')
if form_content:
parts.append(form_content)
completed_lines = _dingtalk_completed_input_lines(form_data)
if completed_lines:
parts.append('<hr>' + '<br>'.join(completed_lines))
input_hint_lines = [] if native_field else _dingtalk_input_hint_lines(form_data)
if input_hint_lines:
parts.append('Fill these fields in chat before choosing an action:<br>' + '<br>'.join(input_hint_lines))
elif should_show_actions and actions:
parts.append('Choose an action to continue.')
display_content = '<br><br>'.join(parts) or '请选择一个操作以继续。'
btns = []
for idx, action in enumerate(actions):
action_id = str(action.get('id') or '')
title = str(action.get('title') or action_id or f'选项 {idx + 1}')
style = (action.get('button_style') or '').lower()
if style == 'primary' or (style == '' and idx == 0):
color = 'blue'
elif style == 'danger':
color = 'red'
else:
color = 'gray'
btns.append(
{
'text': title,
'color': color,
'status': 'normal',
'event': {
'type': 'sendCardRequest',
'params': {
'actionId': action_id,
'params': {'action_id': action_id, 'out_track_id': out_track_id},
},
},
}
)
btns = self._build_btns(actions if should_show_actions else [], out_track_id)
try:
if self.ap is not None:
@@ -763,6 +1045,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content=display_content,
btns=json.dumps(btns, ensure_ascii=False),
flowStatus='3',
**component_params,
),
callback_type='STREAM',
)
@@ -790,7 +1073,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
out_track_id = uuid.uuid4().hex
open_space_id, is_group = self._derive_open_space(message_source)
if form_template_id:
card_param_map = self._card_params(content='', btns='[]', flowStatus='1')
card_param_map = self._card_params(
content='',
btns='[]',
flowStatus='1',
**_dingtalk_empty_form_component_params(),
)
card_data_config = None
else:
# Legacy chat-card template doesn't carry a `bot_avatar`
@@ -829,11 +1117,21 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
form_data: dict,
) -> None:
"""Fallback: send the human-input prompt as plain text."""
display_text = _format_human_input_text(
form_data.get('node_title', ''),
form_data.get('form_content', ''),
form_data.get('actions', []) or [],
)
if form_data.get('_current_input_field') and not form_data.get('_action_select_only'):
parts = []
node_title = form_data.get('node_title', '')
if node_title:
parts.append(f'[Human Input Required] {node_title}')
form_content = form_data.get('form_content') or ''
if form_content:
parts.append(form_content)
display_text = '\n\n'.join(parts)
else:
display_text = _format_human_input_text(
form_data.get('node_title', ''),
form_data.get('form_content', ''),
form_data.get('actions', []) or [],
)
await self._send_proactive_to_event(message_source, display_text)
async def _send_proactive_to_event(
@@ -891,16 +1189,21 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
or (params.get('actionId') or '').strip()
or (params.get('id') or '').strip()
)
if not raw_action_id:
await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}')
return
state = self.card_state.get(out_track_id)
if state is None:
await self.logger.warning(f'DingTalk: card action received for unknown out_track_id={out_track_id}')
return
actions = state.get('actions', []) or []
known_action_ids = {str(action.get('id', '')) for action in actions}
component_inputs = _dingtalk_extract_component_inputs(params)
if component_inputs and (not raw_action_id or raw_action_id not in known_action_ids):
await self._enqueue_card_form_progress(payload, state, component_inputs)
return
if not raw_action_id:
await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}')
return
action_id = raw_action_id
action_title = raw_action_id
for action in actions:
@@ -1002,6 +1305,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
action_title,
node_title=state.get('node_title', ''),
form_content=state.get('form_content', ''),
input_defs=state.get('input_defs') or [],
inputs=state.get('inputs') or {},
)
)
@@ -1018,6 +1323,94 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Once consumed, drop the state — the runner clears _PENDING_FORMS too.
self.card_state.pop(out_track_id, None)
async def _enqueue_card_form_progress(
self,
payload: dict,
state: dict,
component_inputs: dict,
) -> None:
out_track_id = payload.get('out_track_id') or ''
launcher_type = (
provider_session.LauncherTypes.GROUP
if state.get('launcher_type') == provider_session.LauncherTypes.GROUP.value
else provider_session.LauncherTypes.PERSON
)
launcher_id = state.get('launcher_id', '')
sender_user_id = state.get('sender_user_id') or payload.get('user_id') or launcher_id
form_action_data = {
'form_token': state.get('form_token', ''),
'workflow_run_id': state.get('workflow_run_id', ''),
'action_id': '',
'action_title': '',
'node_title': state.get('node_title', ''),
'user': f'{launcher_type.value}_{launcher_id}',
'inputs': component_inputs,
'_current_input_field': state.get('current_input_field', ''),
'_input_progress': True,
}
message_chain = platform_message.MessageChain([platform_message.Plain(text='[Form Input]')])
if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=sender_user_id,
member_name='',
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=launcher_id,
name='',
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=int(datetime.datetime.now().timestamp()),
source_platform_object=None,
)
else:
synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend(
id=sender_user_id,
nickname='',
remark='',
),
message_chain=message_chain,
time=int(datetime.datetime.now().timestamp()),
source_platform_object=None,
)
if self.ap is None:
return
bot_uuid = ''
pipeline_uuid = None
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
break
try:
await self.ap.query_pool.add_query(
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=sender_user_id,
message_event=synthetic_event,
message_chain=message_chain,
adapter=self,
pipeline_uuid=pipeline_uuid,
variables={
'_dify_form_action': form_action_data,
'_routed_by_rule': True,
},
)
self.ap.logger.info(
f'DingTalk card form input enqueued: out_track_id={out_track_id} '
f'field={state.get("current_input_field", "")!r}'
)
except Exception:
self.ap.logger.exception('DingTalk: enqueue form input query failed')
async def _mark_card_resolved(
self,
out_track_id: str,
@@ -1025,6 +1418,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
*,
node_title: str = '',
form_content: str = '',
input_defs: list | None = None,
inputs: dict | None = None,
) -> None:
"""Update the form card to acknowledge the user's selection.
@@ -1037,6 +1432,14 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
parts.append(f'**{node_title}**')
if form_content:
parts.append(form_content)
completed_lines = _dingtalk_completed_input_lines(
{
'input_defs': input_defs or [],
'inputs': inputs or {},
}
)
if completed_lines:
parts.append('<hr>' + '<br>'.join(completed_lines))
parts.append(f'<hr>✅ 已选择:**{action_title}**')
content = '<br><br>'.join(parts)
if self.ap is not None:
@@ -1048,6 +1451,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content=content,
btns='[]',
flowStatus='3',
**_dingtalk_empty_form_component_params(),
),
)
except Exception:
+495 -59
View File
@@ -34,6 +34,163 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_
import langbot_plugin.api.entities.builtin.provider.session as provider_session
def _lark_form_component_name(prefix: str, field_name: str, index: int) -> str:
safe_name = re.sub(r'[^A-Za-z0-9_]', '_', field_name)[:8] or 'field'
digest = hashlib.sha1(field_name.encode('utf-8')).hexdigest()[:6]
return f'{prefix}_{index}_{safe_name}_{digest}'[:32]
def _dify_field_name(field: dict) -> str:
return str(field.get('output_variable_name') or field.get('name') or field.get('id') or '').strip()
def _dify_field_type(field: dict) -> str:
return str(field.get('type') or 'text').strip().lower()
def _dify_select_options(field: dict) -> list[str]:
source = field.get('option_source') or {}
value = source.get('value') if isinstance(source, dict) else None
if isinstance(value, list):
return [str(item) for item in value]
if isinstance(value, str):
return [part.strip() for part in value.splitlines() if part.strip()]
options = field.get('options')
if isinstance(options, list):
result: list[str] = []
for item in options:
if isinstance(item, dict):
result.append(str(item.get('label') or item.get('value') or ''))
else:
result.append(str(item))
return [item for item in result if item]
return []
def _dify_default_value(field: dict) -> str:
default = field.get('default')
if isinstance(default, dict):
value = default.get('value') if default.get('type') == 'constant' or 'value' in default else ''
else:
value = default
return '' if value is None else str(value)
def _lark_clean_form_content(form_content: str, input_defs: list[dict]) -> str:
field_names = {_dify_field_name(field) for field in input_defs if _dify_field_name(field)}
kept_lines: list[str] = []
for line in (form_content or '').splitlines():
placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line)
if placeholder and placeholder.group(1) in field_names:
continue
kept_lines.append(line)
return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip())
def _lark_form_input_defs(form_data: dict) -> list[dict]:
return list(form_data.get('all_input_defs') or form_data.get('input_defs') or [])
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
field_type = _dify_field_type(field)
if field_type == 'file':
if isinstance(value, dict):
return value.get('url') or value.get('upload_file_id') or '1 file'
return str(value)
if field_type == 'file-list':
if isinstance(value, list):
return f'{len(value)} file(s)'
return str(value)
if isinstance(value, dict):
if 'value' in value and value.get('value') not in (None, ''):
return str(value.get('value'))
text = value.get('text')
if isinstance(text, dict):
content = text.get('content')
if content not in (None, ''):
return str(content)
if text not in (None, ''):
return str(text)
if isinstance(value, list):
return ', '.join(_lark_display_input_value(field, item) for item in value if item not in (None, ''))
return str(value)
def _lark_completed_input_lines(form_data: dict) -> list[str]:
inputs = form_data.get('inputs') or {}
if not isinstance(inputs, dict):
return []
lines: list[str] = []
for field in _lark_form_input_defs(form_data):
field_name = _dify_field_name(field)
if not field_name:
continue
value = inputs.get(field_name)
if value in (None, '', []):
continue
display_value = _lark_display_input_value(field, value)
field_type = _dify_field_type(field)
if field_type == 'select':
lines.append(f'✅ 已选择 {field_name}**{display_value}**')
elif field_type in {'file', 'file-list'}:
lines.append(f'✅ 已上传 {field_name}**{display_value}**')
else:
lines.append(f'✅ 已填写 {field_name}**{display_value}**')
return lines
def _lark_mapping_from_value(value: typing.Any) -> dict:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except (json.JSONDecodeError, TypeError):
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _lark_action_attr(action: typing.Any, name: str) -> typing.Any:
if isinstance(action, dict):
return action.get(name)
return getattr(action, name, None)
def _lark_extract_action_form_inputs(action: typing.Any, action_value_obj: dict) -> dict:
input_name_map = action_value_obj.get('input_name_map', {})
if not isinstance(input_name_map, dict):
input_name_map = {}
form_value = _lark_mapping_from_value(_lark_action_attr(action, 'form_value'))
if not form_value:
for key in ('form_value', 'formValue', 'form_values', 'formValues'):
form_value = _lark_mapping_from_value(action_value_obj.get(key))
if form_value:
break
if not form_value:
action_name = _lark_action_attr(action, 'name')
input_value = _lark_action_attr(action, 'input_value')
option_value = _lark_action_attr(action, 'option')
if action_name and input_value not in (None, ''):
form_value = {action_name: input_value}
elif action_name and option_value not in (None, ''):
form_value = {action_name: option_value}
form_inputs = {}
for component_name, value in form_value.items():
field_name = input_name_map.get(component_name)
if not field_name and isinstance(component_name, str) and '.' in component_name:
field_name = input_name_map.get(component_name.rsplit('.', 1)[-1], component_name)
if not field_name:
field_name = component_name
if field_name and value not in (None, '', []):
form_inputs[str(field_name)] = value
return form_inputs
class AESCipher(object):
def __init__(self, key):
self.bs = AES.block_size
@@ -804,6 +961,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
card_pre_pause_text: dict[str, str]
# card_id → form_content captured when the form is first shown (for resume notice)
card_form_content: dict[str, str]
# card_id → input_defs / inputs captured for the selected-action notice
card_form_input_defs: dict[str, list[dict]]
card_form_inputs: dict[str, dict]
# set of card_ids that have already transitioned from "buttons visible" to "resume layout"
card_resume_transitioned: set[str]
_MONITORING_MAPPING_TTL = 600 # 10 minutes
@@ -834,13 +994,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
try:
action_value_raw = getattr(getattr(event.event, 'action', None), 'value', {})
# Parse JSON string values (from form action buttons)
if isinstance(action_value_raw, str):
try:
action_value_obj = json.loads(action_value_raw)
except (json.JSONDecodeError, TypeError):
action_value_obj = {}
else:
action_value_obj = action_value_raw if isinstance(action_value_raw, dict) else {}
action_value_obj = _lark_mapping_from_value(action_value_raw)
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
# Handle Dify form action button clicks
@@ -849,6 +1003,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
workflow_run_id = action_value_obj.get('workflow_run_id', '')
action_id = action_value_obj.get('action_id', '')
session_key = action_value_obj.get('session_key', '')
action = getattr(event.event, 'action', None)
form_inputs = _lark_extract_action_form_inputs(action, action_value_obj)
if session_key.startswith('group_') or session_key.startswith('g:'):
launcher_type = provider_session.LauncherTypes.GROUP
@@ -879,11 +1035,26 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'workflow_run_id': workflow_run_id,
'action_id': action_id,
'user': f'{launcher_type.value}_{launcher_id}',
'inputs': {},
'inputs': form_inputs,
}
context = getattr(event.event, 'context', None)
open_message_id = getattr(context, 'open_message_id', None)
if open_message_id and form_inputs:
card_id = self.reply_message_card_ids.get(str(open_message_id))
else:
card_id = None
if not card_id:
card_id = str(action_value_obj.get('card_id') or '')
if card_id and form_inputs:
cached_inputs = dict(self.card_form_inputs.get(card_id) or {})
cached_inputs.update(form_inputs)
self.card_form_inputs[card_id] = cached_inputs
if self.ap is not None:
self.ap.logger.info(
f'Lark form action inputs cached: card_id={card_id} '
f'open_message_id={open_message_id} keys={list(form_inputs.keys())}'
)
source_time = datetime.datetime.now()
event_time = source_time.timestamp()
action_text = action_value_obj.get('action_id', 'confirm')
@@ -1033,6 +1204,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
card_streaming_text={},
card_pre_pause_text={},
card_form_content={},
card_form_input_defs={},
card_form_inputs={},
card_resume_transitioned=set(),
seq=1,
listeners={},
@@ -1299,6 +1472,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.card_streaming_text.pop(card_id, None)
self.card_pre_pause_text.pop(card_id, None)
self.card_form_content.pop(card_id, None)
self.card_form_input_defs.pop(card_id, None)
self.card_form_inputs.pop(card_id, None)
self.card_resume_transitioned.discard(card_id)
async def create_card_id(self, message_id):
@@ -1809,6 +1984,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
stored_form_content = self.card_form_content.get(card_id, '')
if stored_form_content:
notice_parts.append(stored_form_content)
completed_lines = _lark_completed_input_lines(
{
'input_defs': self.card_form_input_defs.get(card_id, []),
'inputs': self.card_form_inputs.get(card_id, {}),
}
)
if completed_lines:
notice_parts.append('---\n' + '\n'.join(completed_lines))
notice_parts.append(f'---\n✅ 已选择:**{action_title}**')
selected_notice = '\n\n'.join(notice_parts)
else:
@@ -1937,6 +2120,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.card_streaming_text.pop(card_id, None)
self.card_pre_pause_text.pop(card_id, None)
self.card_form_content.pop(card_id, None)
self.card_form_input_defs.pop(card_id, None)
self.card_form_inputs.pop(card_id, None)
else:
# The old card is now a frozen snapshot; let go of its
# streaming-side state but keep its source registrations
@@ -1945,6 +2130,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.card_streaming_text.pop(card_id, None)
self.card_pre_pause_text.pop(card_id, None)
self.card_form_content.pop(card_id, None)
self.card_form_input_defs.pop(card_id, None)
self.card_form_inputs.pop(card_id, None)
self.card_resume_transitioned.discard(card_id)
else:
# Initial pause path: render prompt + buttons in place on
@@ -1962,8 +2149,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# transitions to resume mode.
self.card_pre_pause_text[card_id] = self.card_streaming_text.get(card_id, '')
self.card_streaming_text[card_id] = ''
# Store form_content for the resume notice
self.card_form_content[card_id] = form_data.get('form_content', '') if form_data else ''
# Store cleaned form state for the resume notice.
input_defs = _lark_form_input_defs(form_data)
self.card_form_content[card_id] = _lark_clean_form_content(
form_data.get('raw_form_content') or form_data.get('form_content', ''),
input_defs,
)
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
else:
# Normal finish: keep pre-pause + resume content visible,
# remove buttons/notice, drop the resume placeholder.
@@ -2040,6 +2233,78 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
form_data=None,
)
def _build_lark_form_field_elements(self, form_data: dict) -> tuple[list[dict], dict[str, str], list[str]]:
elements: list[dict] = []
input_name_map: dict[str, str] = {}
file_help_lines: list[str] = []
for idx, field in enumerate(form_data.get('input_defs') or [], start=1):
field_name = _dify_field_name(field)
if not field_name:
continue
field_type = _dify_field_type(field)
if field_type == 'select':
options = _dify_select_options(field)
component_name = _lark_form_component_name('Select', field_name, idx)
input_name_map[component_name] = field_name
elements.append(
{
'tag': 'select_static',
'name': component_name,
'label': {'tag': 'plain_text', 'content': field_name},
'placeholder': {'tag': 'plain_text', 'content': '请选择'},
'options': [
{
'text': {'tag': 'plain_text', 'content': option},
'value': option,
}
for option in options
],
'type': 'default',
'width': 'fill',
'required': False,
}
)
elif field_type in {'file', 'file-list'}:
allowed_types = ', '.join(field.get('allowed_file_types') or [])
allowed = f' ({allowed_types})' if allowed_types else ''
if field_type == 'file-list':
limit = field.get('number_limits')
suffix = f', up to {limit}' if limit else ''
file_help_lines.append(
f'- {field_name}: upload file(s){allowed}{suffix} in chat or reply `{field_name}: <url>`'
)
else:
file_help_lines.append(
f'- {field_name}: upload a file{allowed} in chat or reply `{field_name}: <url>`'
)
else:
component_name = _lark_form_component_name('Input', field_name, idx)
input_name_map[component_name] = field_name
is_multiline = field_type in {'paragraph', 'long_text', 'multiline_text', 'textarea'}
input_element = {
'tag': 'input',
'name': component_name,
'label': {'tag': 'plain_text', 'content': field_name},
'placeholder': {'tag': 'plain_text', 'content': '请输入'},
'default_value': _dify_default_value(field),
'width': 'fill',
'required': False,
}
if is_multiline:
input_element.update(
{
'input_type': 'multiline_text',
'rows': 3,
'auto_resize': True,
'max_rows': 6,
}
)
elements.append(input_element)
return elements, input_name_map, file_help_lines
async def _update_card_layout(
self,
card_id: str,
@@ -2063,6 +2328,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
workflow_run_id = form_data.get('workflow_run_id', '')
node_title = form_data.get('node_title', '') or 'Human Input Required'
form_content = form_data.get('form_content', '')
raw_form_content = form_data.get('raw_form_content') or form_content
input_defs = _lark_form_input_defs(form_data)
# When form_data is set, the visible content is rendered inside the
# interactive container, so the top streaming text should stay empty
@@ -2083,6 +2350,13 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Build button elements matching the existing card template's thumbsup/down format
action_buttons = []
form_field_elements, input_name_map, file_help_lines = self._build_lark_form_field_elements(form_data)
uses_form_container = bool(form_field_elements or input_name_map)
if form_data:
form_content = _lark_clean_form_content(raw_form_content, input_defs)
self.card_form_content[card_id] = form_content
self.card_form_input_defs[card_id] = input_defs
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
for action in actions:
action_id = action.get('id', '')
action_title = action.get('title', action_id)
@@ -2095,29 +2369,33 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else:
lark_button_type = 'default'
action_buttons.append(
{
'tag': 'button',
'text': {'tag': 'plain_text', 'content': action_title},
'type': lark_button_type,
'width': 'fill',
'size': 'medium',
'hover_tips': {'tag': 'plain_text', 'content': action_title},
'behaviors': [
{
'type': 'callback',
'value': {
'form_action': True,
'form_token': form_token,
'workflow_run_id': workflow_run_id,
'action_id': action_id,
'session_key': session_key,
},
}
],
'margin': '0px 0px 0px 0px',
}
)
button = {
'tag': 'button',
'text': {'tag': 'plain_text', 'content': action_title},
'type': lark_button_type,
'width': 'fill',
'size': 'medium',
'hover_tips': {'tag': 'plain_text', 'content': action_title},
'behaviors': [
{
'type': 'callback',
'value': {
'form_action': True,
'form_token': form_token,
'workflow_run_id': workflow_run_id,
'action_id': action_id,
'session_key': session_key,
'card_id': card_id,
'input_name_map': input_name_map,
},
}
],
'margin': '0px 0px 0px 0px',
}
if uses_form_container:
button['name'] = _lark_form_component_name('Button', action_id or action_title, len(action_buttons) + 1)
button['form_action_type'] = 'submit'
action_buttons.append(button)
interactive_elements = []
if form_data:
@@ -2141,38 +2419,84 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'margin': '0px 0px 8px 0px',
}
)
interactive_elements.append(
{
'tag': 'column_set',
'horizontal_spacing': '8px',
'horizontal_align': 'left',
'margin': '0px 0px 0px 0px',
'columns': [
completed_lines = _lark_completed_input_lines(
{
'input_defs': input_defs,
'inputs': form_data.get('inputs') or {},
}
)
if completed_lines:
interactive_elements.append(
{
'tag': 'column',
'width': 'weighted',
'elements': [btn],
'padding': '0px 0px 0px 0px',
'tag': 'markdown',
'content': '---\n' + '\n'.join(completed_lines),
'text_align': 'left',
'text_size': 'normal',
'margin': '0px 0px 8px 0px',
}
for btn in action_buttons
],
}
)
)
if file_help_lines and uses_form_container:
interactive_elements.append(
{
'tag': 'markdown',
'content': '\n'.join(file_help_lines),
'text_align': 'left',
'text_size': 'normal',
'text_color': 'grey',
'margin': '0px 0px 8px 0px',
}
)
if action_buttons:
interactive_elements.append(
{
'tag': 'column_set',
'horizontal_spacing': '8px',
'horizontal_align': 'left',
'margin': '0px 0px 0px 0px',
'columns': [
{
'tag': 'column',
'width': 'weighted',
'elements': [btn],
'padding': '0px 0px 0px 0px',
}
for btn in action_buttons
],
}
)
# Build the full card JSON with buttons, same structure as create_card_id
# ── mid_section: either form buttons, resume notice, or empty ──
mid_section_elements = []
if form_data:
mid_section_elements = [
{
'tag': 'interactive_container',
'margin': '12px 0px 8px 0px',
'padding': '12px 12px 12px 12px',
'has_border': True,
'elements': interactive_elements,
},
{'tag': 'hr', 'margin': '0px 0px 0px 0px'},
]
if uses_form_container:
form_elements = interactive_elements[:-1] if action_buttons else interactive_elements[:]
form_elements.extend(form_field_elements)
if action_buttons:
form_elements.append(interactive_elements[-1])
mid_section_elements = [
{
'tag': 'form',
'name': _lark_form_component_name('Form', form_token or workflow_run_id or card_id, 1),
'direction': 'vertical',
'vertical_spacing': '12px',
'margin': '12px 0px 8px 0px',
'padding': '12px 12px 12px 12px',
'elements': form_elements,
},
{'tag': 'hr', 'margin': '0px 0px 0px 0px'},
]
else:
mid_section_elements = [
{
'tag': 'interactive_container',
'margin': '12px 0px 8px 0px',
'padding': '12px 12px 12px 12px',
'has_border': True,
'elements': interactive_elements,
},
{'tag': 'hr', 'margin': '0px 0px 0px 0px'},
]
elif notice_text:
mid_section_elements = [
{
@@ -2442,9 +2766,121 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
action = event_data.get('action', {})
context_data = event_data.get('context', {})
action_value_obj = action.get('value', {})
action_value_obj = _lark_mapping_from_value(action.get('value', {}))
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
if isinstance(action_value_obj, dict) and action_value_obj.get('form_action'):
form_token = action_value_obj.get('form_token', '')
workflow_run_id = action_value_obj.get('workflow_run_id', '')
action_id = action_value_obj.get('action_id', '')
session_key = action_value_obj.get('session_key', '')
form_inputs = _lark_extract_action_form_inputs(action, action_value_obj)
if session_key.startswith('group_') or session_key.startswith('g:'):
launcher_type = provider_session.LauncherTypes.GROUP
launcher_id = (
session_key.split(':', 1)[1]
if session_key.startswith('g:')
else session_key[len('group_') :]
)
else:
launcher_type = provider_session.LauncherTypes.PERSON
launcher_id = (
session_key.split(':', 1)[1]
if session_key.startswith('p:')
else session_key[len('person_') :]
)
form_action_data = {
'form_token': form_token,
'workflow_run_id': workflow_run_id,
'action_id': action_id,
'user': f'{launcher_type.value}_{launcher_id}',
'inputs': form_inputs,
}
open_message_id = context_data.get('open_message_id')
card_id = self.reply_message_card_ids.get(str(open_message_id)) if open_message_id else None
if not card_id:
card_id = str(action_value_obj.get('card_id') or '')
if card_id and form_inputs:
cached_inputs = dict(self.card_form_inputs.get(card_id) or {})
cached_inputs.update(form_inputs)
self.card_form_inputs[card_id] = cached_inputs
if self.ap is not None:
self.ap.logger.info(
f'Lark form action inputs cached: card_id={card_id} '
f'open_message_id={open_message_id} keys={list(form_inputs.keys())}'
)
source_time = datetime.datetime.now()
message_chain = platform_message.MessageChain(
[platform_message.Plain(text=f'[Form Action: {action_id or "confirm"}]')]
)
if open_message_id:
message_chain.insert(
0,
platform_message.Source(
id=open_message_id,
time=source_time,
),
)
user_id = operator.get('open_id') or operator.get('user_id') or str(launcher_id)
event_time = source_time.timestamp()
if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=user_id,
member_name='',
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=launcher_id,
name='',
permission=platform_entities.Permission.Member,
),
),
message_chain=message_chain,
time=event_time,
source_platform_object=data,
)
else:
synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend(
id=user_id,
nickname='',
remark='',
),
message_chain=message_chain,
time=event_time,
source_platform_object=data,
)
bot_uuid = ''
pipeline_uuid = None
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
break
await self.ap.query_pool.add_query(
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=user_id,
message_event=synthetic_event,
message_chain=message_chain,
adapter=self,
pipeline_uuid=pipeline_uuid,
variables={
'_dify_form_action': form_action_data,
'_routed_by_rule': True,
},
)
return {'toast': {'type': 'success', 'content': '操作成功'}}
if action_value == '有帮助':
feedback_type = 1
elif action_value == '无帮助':
@@ -833,12 +833,47 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
node_title = form_data.get('node_title') or 'Confirmation needed'
form_content = form_data.get('form_content') or ''
is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only')
parts = [f'### {node_title}']
if form_content.strip():
parts.append(form_content.strip())
parts.append('请点击下方按钮选择:')
markdown_content = '\n\n'.join(parts)
if is_field_step:
field_parts = parts[:-1] if len(parts) > 1 else parts
text_msg = platform_message.MessageChain([platform_message.Plain(text='\n\n'.join(field_parts))])
try:
await self.reply_message(message_source, text_msg)
except Exception:
await self.logger.error(f'QQ Official: field-step text send failed: {traceback.format_exc()}')
return
sender_id = ''
if source is not None:
sender_id = (
getattr(source, 'user_openid', None)
or getattr(source, 'member_openid', None)
or getattr(source, 'd_author_id', None)
or ''
)
if not sender_id and message_source.sender is not None:
sender_id = str(getattr(message_source.sender, 'id', '') or '')
self._pending_forms[session_key] = {
'form_data': form_data,
'msg_id': msg_id,
'sender_id': sender_id,
'target_type': target_type,
'target_id': target_id,
'source_event_t': source.t if source is not None else None,
'posted_at': time.time(),
}
await self.logger.info(
f'QQ Official: form field step posted session={session_key} '
f'field={form_data.get("_current_input_field")!r}'
)
return
keyboard = build_keyboard_from_form(form_data, buttons_per_row=2)
if not keyboard.get('content', {}).get('rows'):
# No actions to render — fall back to plain text.
+106 -12
View File
@@ -11,7 +11,13 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
from ..logger import EventLogger
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
from langbot.libs.wecom_ai_bot_api.api import (
WecomBotClient,
extract_template_card_action,
extract_template_card_event_payload,
extract_template_card_selections,
parse_select_button_action,
)
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
@@ -511,18 +517,25 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
object.__setattr__(self, '_synthetic_buffers', {})
buffers: dict[str, str] = self._synthetic_buffers
if content and not form_data:
buffers[buf_key] = buffers.get(buf_key, '') + content
previous = buffers.get(buf_key, '')
if previous and content.startswith(previous):
buffers[buf_key] = content
elif previous and previous.endswith(content):
buffers[buf_key] = previous
else:
buffers[buf_key] = previous + content
if not is_final:
return {'stream': True, 'synthetic': True, 'buffered': True}
final_content = buffers.pop(buf_key, '')
if content and final_content.startswith(content):
# is_final chunk re-emitted the full accumulated text — keep
# whichever is longer.
final_content = final_content if len(final_content) >= len(content) else content
elif content and not final_content:
final_content = content
if content:
if final_content and content.startswith(final_content):
final_content = content
elif final_content and final_content.endswith(content):
pass
else:
final_content = final_content + content
if not ws_mode:
await self.logger.warning(
@@ -575,12 +588,17 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
need the task_id registered so button clicks find pending_form.
For ws mode we stash it directly on the ws_client's pending dict.
"""
from langbot.libs.wecom_ai_bot_api.api import build_button_interaction_payload
from langbot.libs.wecom_ai_bot_api.api import build_human_input_template_card_payload
import secrets as _secrets
task_id = f'dify-{_secrets.token_hex(12)}'
source = getattr(self.bot, 'card_source', None)
payload = build_button_interaction_payload(form_data, task_id, source=source)
payload = build_human_input_template_card_payload(
form_data,
task_id,
source=source,
select_as_buttons=not self.config.get('enable-webhook', False),
)
# Register task_id → form_data so the click callback can find it.
# user_id / chat_id are required so _on_card_action can route the
@@ -766,13 +784,87 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
)
actions = form.get('actions') or []
clean_action_id = (action_id or '').strip()
tce = extract_template_card_event_payload(raw_event) if isinstance(raw_event, dict) else {}
_, _, card_type = extract_template_card_action(tce)
selections = extract_template_card_selections(tce, form)
if not selections:
selections = parse_select_button_action(action_id, form)
await self.logger.info(
f'WeComBot template_card selections: task_id={task_id} card_type={card_type} selections={selections}'
)
if card_type == 'multiple_interaction' and not selections:
await self.logger.warning(
f'WeComBot: multiple_interaction callback has no parseable selections; raw={str(tce)[:1000]}'
)
return
is_select_submit = card_type == 'multiple_interaction' or bool(selections)
clean_action_id = '' if is_select_submit else (action_id or '').strip()
action_title = clean_action_id
for a in actions:
if str(a.get('id', '')) == clean_action_id:
action_title = a.get('title') or clean_action_id
break
inputs = dict(form.get('inputs') or {})
inputs.update(selections)
def _missing_fields_after_select() -> list[str]:
missing: list[str] = []
for field in form.get('input_defs') or form.get('all_input_defs') or []:
field_name = str(field.get('output_variable_name') or '').strip()
if not field_name:
continue
if inputs.get(field_name) in (None, '', []):
missing.append(field_name)
return missing
input_progress = False
if is_select_submit:
missing_fields = _missing_fields_after_select()
if not missing_fields and len(actions) == 1:
action = actions[0]
clean_action_id = str(action.get('id') or '').strip()
action_title = action.get('title') or clean_action_id
elif not missing_fields and len(actions) > 1:
if not self.config.get('enable-webhook', False):
action_form_data = {
'form_content': form.get('raw_form_content') or form.get('form_content') or '',
'raw_form_content': form.get('raw_form_content') or form.get('form_content') or '',
'input_defs': [],
'all_input_defs': form.get('all_input_defs') or form.get('input_defs') or [],
'inputs': inputs,
'actions': actions,
'node_title': form.get('node_title', ''),
'workflow_run_id': form.get('workflow_run_id', ''),
'form_token': form.get('form_token', ''),
'_action_select_only': True,
}
target_chat_id = session.chat_id or session.user_id or ''
try:
payload = self._build_button_interaction_payload_from_form(
action_form_data,
user_id=session.user_id or '',
chat_id=session.chat_id or '',
)
await self.bot.send_template_card(target_chat_id, payload)
await self.logger.info(
f'WeComBot: sent action-select button card after select submit '
f'task_id={task_id} action_count={len(actions)}'
)
except Exception:
await self.logger.error(
f'WeComBot: failed to send action-select button card: {traceback.format_exc()}'
)
return
await self.logger.warning(
'WeComBot webhook mode cannot proactively send action-select button card after select submit'
)
return
else:
input_progress = True
action_title = 'Submit'
launcher_id = session.user_id or session.chat_id or ''
sender_user_id = session.user_id or launcher_id
# WeCom AI bot has both single-chat and group-chat; chat_id present
@@ -791,8 +883,10 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'action_title': action_title,
'node_title': form.get('node_title', ''),
'user': f'{launcher_type.value}_{launcher_id}',
'inputs': {},
'inputs': inputs,
}
if input_progress:
form_action_data['_input_progress'] = True
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')])
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,104 @@
"""Tests for DingTalk adapter helper behavior."""
from langbot.pkg.platform.sources.dingtalk import (
_dingtalk_clean_form_content,
_dingtalk_completed_input_lines,
_dingtalk_extract_component_inputs,
_dingtalk_form_component_params,
_dingtalk_pending_input_defs,
)
def test_dingtalk_select_component_params_expose_options():
params = _dingtalk_form_component_params(
{
'_current_input_field': 'choice',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
'inputs': {},
}
)
assert params['select_visible'] == 'true'
assert params['select_placeholder'] == 'choice'
assert params['select_options'] == ['A', 'B']
assert [option['value'] for option in params['index_o']] == ['A', 'B']
assert [option['value'] for option in params['test_index']] == ['A', 'B']
assert params['index_o'][0]['text']['zh_CN'] == 'A'
assert params['index_o'][0]['text']['en_US'] == 'A'
assert params['select_index'] == -1
def test_dingtalk_extract_select_from_builtin_result_dict():
inputs = _dingtalk_extract_component_inputs({'selectResult': {'index': 1, 'value': 'B'}})
assert inputs == {'select': 'B'}
def test_dingtalk_extract_select_from_template_param_string():
inputs = _dingtalk_extract_component_inputs({'select': '{"index": 1, "value": "B"}'})
assert inputs == {'select': '{"index": 1, "value": "B"}'}
def test_dingtalk_extract_input_and_select_together():
inputs = _dingtalk_extract_component_inputs(
{
'inputResult': {'value': 'looks good'},
'__built_in_selectResult__': {'index': 0, 'value': 'A'},
}
)
assert inputs == {'input': 'looks good', 'select': 'A'}
def test_dingtalk_pending_input_defs_includes_file_fields():
pending = _dingtalk_pending_input_defs(
{
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'files', 'type': 'file-list'},
],
'inputs': {'comment': 'ready'},
}
)
assert [field['output_variable_name'] for field in pending] == ['files']
def test_dingtalk_completed_input_lines_include_text_and_select_values():
lines = _dingtalk_completed_input_lines(
{
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {'comment': 'looks good', 'choice': 'B'},
}
)
assert lines == ['✅ 已填写 comment**looks good**', '✅ 已选择 choice**B**']
def test_dingtalk_clean_form_content_uses_all_input_defs():
content = _dingtalk_clean_form_content(
{
'raw_form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n',
'input_defs': [],
'all_input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'choice', 'type': 'select'},
],
}
)
assert content == 'Hello'
@@ -0,0 +1,42 @@
"""Tests for DingTalk API payload helpers."""
import json
from langbot.libs.dingtalk_api.api import _stringify_card_param_map
def test_dingtalk_card_param_map_stringifies_select_component_arrays():
params = _stringify_card_param_map(
{
'content': 'Pick one',
'btns': json.dumps([{'text': 'OK'}], ensure_ascii=False),
'select_options': ['A', 'B'],
'index_o': [
{
'value': 'A',
'text': {'zh_CN': 'A', 'en_US': 'A'},
}
],
'test_index': [
{
'value': 'A',
'text': {'zh_CN': 'A', 'en_US': 'A'},
}
],
'select_index': -1,
}
)
assert params['content'] == 'Pick one'
assert params['btns'] == '[{"text": "OK"}]'
assert params['select_options'] == '["A", "B"]'
assert json.loads(params['index_o'])[0]['value'] == 'A'
assert json.loads(params['test_index'])[0]['value'] == 'A'
assert params['select_index'] == '-1'
def test_dingtalk_card_param_map_stringifies_unregistered_structures():
params = _stringify_card_param_map({'other': ['A'], 'empty': None})
assert params['other'] == '["A"]'
assert params['empty'] == ''
@@ -0,0 +1,111 @@
"""Tests for Lark adapter helper behavior."""
from langbot.pkg.platform.sources.lark import (
_lark_clean_form_content,
_lark_completed_input_lines,
_lark_extract_action_form_inputs,
)
def test_lark_completed_input_lines_include_text_select_and_files():
lines = _lark_completed_input_lines(
{
'all_input_defs': [
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{'output_variable_name': 'xiala', 'type': 'select'},
{'output_variable_name': 'files', 'type': 'file-list'},
],
'inputs': {
'us_input': '你好',
'xiala': 'or',
'files': [{'upload_file_id': 'file-1'}, {'upload_file_id': 'file-2'}],
},
}
)
assert lines == [
'✅ 已填写 us_input**你好**',
'✅ 已选择 xiala**or**',
'✅ 已上传 files**2 file(s)**',
]
def test_lark_clean_form_content_removes_all_input_placeholders():
content = _lark_clean_form_content(
'人工介入\n\n{{#$output.us_input#}}\n\n{{#$output.xiala#}}\n',
[
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{'output_variable_name': 'xiala', 'type': 'select'},
],
)
assert content == '人工介入'
def test_lark_extract_action_form_inputs_from_json_form_value():
class Action:
form_value = '{"Input_1_us_input_abcd12": "hello", "Select_2_xiala_abcd12": "B"}'
input_value = None
option = None
name = None
inputs = _lark_extract_action_form_inputs(
Action(),
{
'input_name_map': {
'Input_1_us_input_abcd12': 'us_input',
'Select_2_xiala_abcd12': 'xiala',
}
},
)
assert inputs == {'us_input': 'hello', 'xiala': 'B'}
def test_lark_extract_action_form_inputs_from_webhook_dict_action():
inputs = _lark_extract_action_form_inputs(
{
'form_value': {
'Input_1_us_input_abcd12': 'hello',
'Select_2_xiala_abcd12': {'value': 'B', 'text': {'content': 'Option B'}},
}
},
{
'input_name_map': {
'Input_1_us_input_abcd12': 'us_input',
'Select_2_xiala_abcd12': 'xiala',
}
},
)
assert inputs == {'us_input': 'hello', 'xiala': {'value': 'B', 'text': {'content': 'Option B'}}}
def test_lark_extract_action_form_inputs_maps_dotted_component_names():
inputs = _lark_extract_action_form_inputs(
{
'form_value': {
'Form_1_token_abcd12.Input_1_us_input_abcd12': 'hello',
}
},
{
'input_name_map': {
'Input_1_us_input_abcd12': 'us_input',
}
},
)
assert inputs == {'us_input': 'hello'}
def test_lark_completed_input_lines_display_select_value_from_object():
lines = _lark_completed_input_lines(
{
'all_input_defs': [
{'output_variable_name': 'xiala', 'type': 'select'},
],
'inputs': {'xiala': {'value': 'B', 'text': {'content': 'Option B'}}},
}
)
assert lines == ['✅ 已选择 xiala**B**']
@@ -1,6 +1,8 @@
import sys
import types
import pytest
logger_module = types.ModuleType('langbot.pkg.platform.logger')
logger_module.EventLogger = object
@@ -8,9 +10,17 @@ sys.modules.setdefault('langbot.pkg.platform.logger', logger_module)
from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402
build_button_interaction_payload,
build_human_input_template_card_payload,
build_button_interaction_update_card,
build_multiple_interaction_update_card,
extract_template_card_event_payload,
extract_template_card_selections,
extract_wecom_event_type,
extract_template_card_action,
build_human_input_text_prompt,
parse_select_button_action,
)
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient # noqa: E402
def test_extract_template_card_action_supports_nested_button_key():
@@ -27,6 +37,31 @@ def test_extract_template_card_action_supports_nested_button_key():
assert card_type == 'button_interaction'
def test_extract_wecom_event_type_supports_top_level_template_card_event():
payload = {
'eventtype': 'template_card_event',
'template_card_event': {
'TaskId': 'task-1',
'CardType': 'multiple_interaction',
'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}',
},
}
assert extract_wecom_event_type(payload) == 'template_card_event'
assert extract_template_card_event_payload(payload)['TaskId'] == 'task-1'
def test_extract_wecom_event_type_infers_template_card_event_from_top_level_card_fields():
payload = {
'TaskId': 'task-1',
'CardType': 'button_interaction',
'EventKey': 'approve',
}
assert extract_wecom_event_type(payload) == 'template_card_event'
assert extract_template_card_event_payload(payload)['EventKey'] == 'approve'
def test_build_button_interaction_update_card_marks_clicked_button():
card = build_button_interaction_update_card(
{
@@ -70,3 +105,281 @@ def test_build_button_interaction_payload_uses_preselected_button_styles_before_
{'text': 'Approve', 'style': 2, 'key': 'approve'},
{'text': 'Reject', 'style': 2, 'key': 'reject'},
]
def test_build_payload_uses_multiple_interaction_for_pending_select_field():
payload = build_human_input_template_card_payload(
{
'node_title': 'Manual Review',
'form_content': 'Choose a label\n\n{{#$output.choice#}}',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
'inputs': {},
'actions': [{'id': 'yes', 'title': 'Yes'}],
},
task_id='task-1',
source={'desc': 'LangBot'},
)
card = payload['template_card']
assert card['card_type'] == 'multiple_interaction'
assert card['source'] == {'desc': 'LangBot'}
assert card['select_list'] == [
{
'question_key': 'choice',
'title': 'choice',
'selected_id': 'opt_1',
'option_list': [
{'id': 'opt_1', 'text': 'A'},
{'id': 'opt_2', 'text': 'B'},
],
}
]
assert card['submit_button'] == {'text': 'Submit', 'key': 'submit_human_input'}
def test_build_payload_can_emulate_select_as_buttons_for_wecombot_ws():
form_data = {
'node_title': 'Manual Review',
'form_content': 'Choose a label\n\n{{#$output.choice#}}',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
'inputs': {},
'actions': [{'id': 'yes', 'title': 'Yes'}],
}
payload = build_human_input_template_card_payload(
form_data,
task_id='task-1',
select_as_buttons=True,
)
card = payload['template_card']
assert card['card_type'] == 'button_interaction'
assert card['button_list'][0]['text'] == 'A'
assert parse_select_button_action(card['button_list'][1]['key'], form_data) == {'choice': 'B'}
def test_text_input_card_shows_direct_reply_prompt():
payload = build_human_input_template_card_payload(
{
'node_title': '人工介入',
'form_content': '请输入你的问题\n\n{{#$output.us_input#}}',
'input_defs': [
{
'output_variable_name': 'us_input',
'type': 'paragraph',
'label': '请输入你的问题',
}
],
'inputs': {},
'actions': [{'id': 'yes', 'title': 'yes'}],
'_current_input_field': 'us_input',
},
task_id='task-1',
)
card = payload['template_card']
assert card['main_title']['desc'] == '请直接回复:请输入你的问题'
assert '请直接回复:请输入你的问题' in card['sub_title_text']
assert card['button_list'] == []
def test_build_human_input_text_prompt_for_current_text_field():
prompt = build_human_input_text_prompt(
{
'node_title': '人工介入',
'input_defs': [
{
'output_variable_name': 'us_input',
'type': 'paragraph',
'label': '请输入你的问题',
}
],
'_current_input_field': 'us_input',
}
)
assert prompt == '人工介入\n\n请直接回复:请输入你的问题'
@pytest.mark.asyncio
async def test_ws_push_form_pause_sends_text_prompt_without_empty_card():
client = WecomBotWsClient('bot-id', 'secret', object())
client._stream_ids['msg-1'] = 'req-1|stream-1'
client._stream_sessions['msg-1'] = {'user_id': 'user-1'}
sent = []
async def fake_reply_text(req_id, content):
sent.append((req_id, content))
return {}
client.reply_text = fake_reply_text
ok, stream_id, task_id = await client.push_form_pause(
'msg-1',
{
'node_title': '人工介入',
'input_defs': [
{
'output_variable_name': 'us_input',
'type': 'paragraph',
'label': '请输入你的问题',
}
],
'_current_input_field': 'us_input',
},
)
assert ok is True
assert stream_id == 'stream-1'
assert task_id is None
assert sent == [('req-1', '人工介入\n\n请直接回复:请输入你的问题')]
assert client._pending_forms_by_task == {}
assert 'msg-1' not in client._stream_ids
@pytest.mark.asyncio
async def test_ws_stream_sends_delta_chunks_to_wecom():
client = WecomBotWsClient('bot-id', 'secret', object())
client._stream_ids['msg-1'] = 'req-1|stream-1'
client._stream_sessions['msg-1'] = {}
sent = []
async def fake_reply_stream(req_id, stream_id, content, finish=False, feedback_id=''):
sent.append((req_id, stream_id, content, finish))
return {}
client.reply_stream = fake_reply_stream
assert await client.push_stream_chunk('msg-1', '', is_final=False)
assert await client.push_stream_chunk('msg-1', '你好', is_final=False)
assert await client.push_stream_chunk('msg-1', '你好', is_final=True)
assert sent == [
('req-1', 'stream-1', '', False),
('req-1', 'stream-1', '', False),
('req-1', 'stream-1', '', True),
]
def test_human_input_payload_keeps_action_select_stage_as_buttons():
payload = build_human_input_template_card_payload(
{
'node_title': 'Manual Review',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
'inputs': {'choice': 'B'},
'actions': [
{'id': 'approve', 'title': 'Approve'},
{'id': 'reject', 'title': 'Reject'},
],
'_action_select_only': True,
},
task_id='task-1',
)
card = payload['template_card']
assert card['card_type'] == 'button_interaction'
assert card['button_list'] == [
{'text': 'Approve', 'style': 2, 'key': 'approve'},
{'text': 'Reject', 'style': 2, 'key': 'reject'},
]
def test_extract_template_card_selections_maps_selected_id_to_option_text():
selections = extract_template_card_selections(
{
'SelectedItems': [
{'QuestionKey': 'choice', 'SelectedId': 'opt_2'},
],
},
{
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
},
)
assert selections == {'choice': 'B'}
def test_extract_template_card_selections_reads_nested_response_data_json():
selections = extract_template_card_selections(
{
'CardType': 'multiple_interaction',
'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}',
},
{
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
},
)
assert selections == {'choice': 'B'}
def test_extract_template_card_selections_reads_response_data_direct_mapping():
selections = extract_template_card_selections(
{
'CardType': 'multiple_interaction',
'EventKey': 'submit_human_input',
'ResponseData': '{"choice":"opt_2"}',
},
{
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
},
)
assert selections == {'choice': 'B'}
def test_build_multiple_interaction_update_card_disables_submitted_select():
card = build_multiple_interaction_update_card(
{
'node_title': 'Manual Review',
'input_defs': [
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
}
],
},
task_id='task-1',
selections={'choice': 'B'},
)
assert card['card_type'] == 'multiple_interaction'
assert card['main_title']['desc'] == 'Submitted'
assert card['select_list'][0]['disable'] is True
assert card['select_list'][0]['selected_id'] == 'opt_2'
@@ -6,6 +6,9 @@ Tests the helper methods that don't require real Dify API calls.
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock
import langbot_plugin.api.entities.builtin.platform.message as platform_message
class TestDifyExtractTextOutput:
@@ -26,7 +29,7 @@ class TestDifyExtractTextOutput:
'base-url': 'https://api.dify.ai',
}
},
'output': {'misc': {}}
'output': {'misc': {}},
}
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
@@ -111,7 +114,7 @@ class TestDifyRunnerConfigValidation:
'base-url': 'https://api.dify.ai',
}
},
'output': {'misc': {}}
'output': {'misc': {}},
}
with pytest.raises(DifyAPIError, match='不支持'):
@@ -134,7 +137,7 @@ class TestDifyRunnerConfigValidation:
'base-url': 'https://api.dify.ai',
}
},
'output': {'misc': {}}
'output': {'misc': {}},
}
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
@@ -160,10 +163,509 @@ class TestDifyRunnerInit:
'base-url': 'https://api.dify.ai',
}
},
'output': {'misc': {}}
'output': {'misc': {}},
}
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
assert runner.pipeline_config == pipeline_config
assert runner.ap == mock_app
assert runner.ap == mock_app
class TestDifyHumanInputForms:
"""Tests for Dify human-input form helpers."""
def _create_runner(self):
from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner
mock_app = MagicMock()
mock_app.logger = MagicMock()
pipeline_config = {
'ai': {
'dify-service-api': {
'app-type': 'workflow',
'api-key': 'test-key',
'base-url': 'https://api.dify.ai',
'base-prompt': '',
}
},
'output': {'misc': {}},
}
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
runner.dify_client = MagicMock()
runner.dify_client.upload_file = AsyncMock(return_value={'id': 'upload-1'})
return runner
def test_format_human_input_text_includes_field_help(self):
from langbot.pkg.provider.runners.difysvapi import _format_human_input_text
text = _format_human_input_text(
'Manual Review',
'Please fill fields.',
[{'id': 'yes', 'title': 'Yes'}],
[
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
{'output_variable_name': 'attachment', 'type': 'file-list', 'number_limits': 2},
],
)
assert 'comment: <value>' in text
assert 'choice (select): 1. A, 2. B' in text
assert 'attachment (file-list' in text
assert 'action: <number or title>' in text
def test_form_snapshot_for_platform_omits_action_text_and_placeholders(self):
from langbot.pkg.provider.runners.difysvapi import _extract_form_snapshot
snapshot, _, _, display_form_content = _extract_form_snapshot(
'run-1',
{
'form_token': 'token-1',
'node_title': 'Manual Review',
'form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n',
'inputs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'actions': [{'id': 'yes', 'title': 'Yes'}],
},
'person_user-1',
)
assert '{{#$output.comment#}}' not in display_form_content
assert 'Actions:' not in display_form_content
assert 'comment (paragraph)' in display_form_content
assert snapshot['form_content'] == display_form_content
@pytest.mark.asyncio
async def test_match_pending_form_collects_select_and_text_inputs(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'node_title': 'Manual Review',
'actions': [{'id': 'yes', 'title': 'Yes'}],
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {},
'user': session_key,
},
)
query = MagicMock()
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
action = await runner._match_pending_form_action(
query,
session_key,
'action: yes\ncomment: looks good\nchoice: 2',
)
assert action['action_id'] == 'yes'
assert action['inputs'] == {'comment': 'looks good', 'choice': 'B'}
@pytest.mark.asyncio
async def test_collect_form_inputs_uploads_files(self):
runner = self._create_runner()
image = platform_message.Image(base64='data:image/png;base64,aGVsbG8=')
query = MagicMock()
query.message_chain = platform_message.MessageChain([image])
inputs = await runner._collect_form_inputs_from_query(
query,
{
'input_defs': [{'output_variable_name': 'photo', 'type': 'file'}],
'inputs': {},
'user': 'person_user-1',
},
'',
)
assert inputs['photo'] == {
'type': 'image',
'transfer_method': 'local_file',
'upload_file_id': 'upload-1',
}
@pytest.mark.asyncio
async def test_partial_input_with_multiple_actions_waits_for_missing_fields(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'node_title': 'Manual Review',
'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}],
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {},
'user': session_key,
},
)
query = MagicMock()
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
action = await runner._match_pending_form_action(query, session_key, 'comment: ready')
assert action['_partial'] is True
assert action['inputs'] == {'comment': 'ready'}
assert action['_form_data']['_current_input_field'] == 'choice'
assert 'choice (select)' in action['notice']
assert difysvapi._get_latest_pending_form(session_key)['inputs'] == {'comment': 'ready'}
@pytest.mark.asyncio
async def test_complete_partial_input_with_multiple_actions_renders_action_form(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'node_title': 'Manual Review',
'raw_form_content': 'Please review\n\n{{#$output.comment#}}\n',
'form_content': 'Please review\n\nFields:\n - comment (paragraph): reply "comment: <value>"',
'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}],
'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
'inputs': {},
'user': session_key,
},
)
query = MagicMock()
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
action = await runner._match_pending_form_action(query, session_key, 'comment: ready')
assert action['_partial'] is True
assert action['inputs'] == {'comment': 'ready'}
assert 'action: <number or title>' in action['notice']
assert action['_form_data']['_action_select_only'] is True
assert action['_form_data']['input_defs'] == []
assert action['_form_data']['actions'] == [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}]
assert '{{#$output.comment#}}' not in action['_form_data']['form_content']
assert difysvapi._get_latest_pending_form(session_key)['inputs'] == {'comment': 'ready'}
@pytest.mark.asyncio
async def test_sequential_field_collection_advances_one_field_at_a_time(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'node_title': 'Manual Review',
'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}],
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {},
'current_input_field': 'comment',
'user': session_key,
},
)
query = MagicMock()
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
first = await runner._match_pending_form_action(query, session_key, 'looks good')
assert first['_partial'] is True
assert first['inputs'] == {'comment': 'looks good'}
assert first['_form_data']['_current_input_field'] == 'choice'
assert first['_form_data']['input_defs'][0]['output_variable_name'] == 'comment'
assert 'choice (select)' in first['_form_data']['form_content']
second = await runner._match_pending_form_action(query, session_key, '2')
assert second['_partial'] is True
assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'}
assert second['_form_data']['_action_select_only'] is True
@pytest.mark.asyncio
async def test_digit_reply_fills_missing_select_before_matching_action_number(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'node_title': 'Manual Review',
'actions': [
{'id': 'yes', 'title': 'yes'},
{'id': 'no', 'title': 'no'},
{'id': 'or', 'title': 'or'},
{'id': 'but', 'title': 'but'},
],
'input_defs': [
{'output_variable_name': 'us_input', 'type': 'paragraph'},
{
'output_variable_name': 'xiala',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['1', '2']},
},
],
'inputs': {'us_input': 'hello'},
'user': session_key,
},
)
query = MagicMock()
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
action = await runner._match_pending_form_action(query, session_key, '2')
assert action['_partial'] is True
assert action['inputs'] == {'us_input': 'hello', 'xiala': '2'}
assert action['_form_data']['_action_select_only'] is True
@pytest.mark.asyncio
async def test_workflow_pause_without_text_yields_form_chunk(self):
from langbot.pkg.provider.runners import difysvapi
async def workflow_run(**kwargs):
del kwargs
yield {
'event': 'workflow_started',
'data': {'workflow_run_id': 'run-1'},
}
yield {
'event': 'workflow_paused',
'data': {
'workflow_run_id': 'run-1',
'reasons': [
{
'TYPE': 'human_input_required',
'form_token': 'token-1',
'node_title': 'Manual Review',
'form_content': 'Please review\n\n{{#$output.comment#}}\n',
'inputs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
'actions': [{'id': 'yes', 'title': 'Yes'}],
}
],
},
}
runner = self._create_runner()
runner.dify_client.workflow_run = workflow_run
query = MagicMock()
query.session.launcher_type.value = 'person'
query.session.launcher_id = 'user-1'
query.session.using_conversation.uuid = 'conversation-1'
query.variables = {
'session_id': 'session-1',
'conversation_id': 'conversation-1',
'msg_create_time': '0',
}
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='hello')])
difysvapi._PENDING_FORMS.clear()
chunks = [chunk async for chunk in runner._workflow_messages_chunk(query)]
assert chunks[-1].is_final is True
assert chunks[-1].content == difysvapi._STREAM_FORM_PLACEHOLDER
assert chunks[-1]._form_data['form_token'] == 'token-1'
assert chunks[-1]._form_data['_current_input_field'] == 'comment'
@pytest.mark.asyncio
async def test_action_after_partial_input_reuses_saved_inputs(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'node_title': 'Manual Review',
'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}],
'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
'inputs': {},
'user': session_key,
},
)
query = MagicMock()
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
await runner._match_pending_form_action(query, session_key, 'comment: ready')
action = await runner._match_pending_form_action(query, session_key, 'action: yes')
assert action.get('_partial') is not True
assert action['action_id'] == 'yes'
assert action['inputs'] == {'comment': 'ready'}
def test_form_action_merges_card_inputs_with_saved_inputs(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'actions': [{'id': 'yes', 'title': 'Yes'}],
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'photo', 'type': 'file'},
],
'inputs': {'photo': {'type': 'image', 'transfer_method': 'local_file', 'upload_file_id': 'upload-1'}},
'user': session_key,
},
)
action = runner._merge_pending_form_action(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'action_id': 'yes',
'inputs': {'comment': 'ready'},
},
)
assert action['inputs'] == {
'comment': 'ready',
'photo': {'type': 'image', 'transfer_method': 'local_file', 'upload_file_id': 'upload-1'},
}
def test_form_action_with_missing_required_file_fields_stays_partial(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'actions': [{'id': 'yes', 'title': 'Yes'}],
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{'output_variable_name': 'file', 'type': 'file'},
{'output_variable_name': 'files', 'type': 'file-list', 'number_limits': 5},
],
'inputs': {},
'user': session_key,
},
)
action = runner._merge_pending_form_action(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'action_id': 'yes',
'inputs': {'comment': 'ready'},
},
)
assert action['_partial'] is True
assert action['inputs'] == {'comment': 'ready'}
assert 'file, files' in action['notice']
assert action['_form_data']['_current_input_field'] == 'file'
assert action['_form_data']['input_defs'][1]['output_variable_name'] == 'file'
def test_card_component_input_progress_maps_to_current_field(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}],
'input_defs': [
{'output_variable_name': 'comment', 'type': 'paragraph'},
{
'output_variable_name': 'choice',
'type': 'select',
'option_source': {'type': 'constant', 'value': ['A', 'B']},
},
],
'inputs': {},
'current_input_field': 'comment',
'user': session_key,
},
)
first = runner._merge_pending_form_action(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'inputs': {'input': 'looks good'},
'_current_input_field': 'comment',
'_input_progress': True,
},
)
assert first['_partial'] is True
assert first['inputs'] == {'comment': 'looks good'}
assert first['_form_data']['_current_input_field'] == 'choice'
second = runner._merge_pending_form_action(
session_key,
{
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'inputs': {'select': '{"index": 1, "value": "B"}'},
'_current_input_field': 'choice',
'_input_progress': True,
},
)
assert second['_partial'] is True
assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'}
assert second['_form_data']['_action_select_only'] is True