mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-15 08:56:07 +00:00
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:
@@ -23,6 +23,26 @@ _stdout_logger = logging.getLogger('langbot.dingtalk_api')
|
|||||||
DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com'
|
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:
|
class DingTalkClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -608,7 +628,7 @@ class DingTalkClient:
|
|||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
await self.get_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:
|
if card_data_config is not None:
|
||||||
cardData['config'] = json.dumps(card_data_config)
|
cardData['config'] = json.dumps(card_data_config)
|
||||||
|
|
||||||
@@ -732,7 +752,7 @@ class DingTalkClient:
|
|||||||
|
|
||||||
body: dict = {
|
body: dict = {
|
||||||
'outTrackId': out_track_id,
|
'outTrackId': out_track_id,
|
||||||
'cardData': {'cardParamMap': card_param_map or {}},
|
'cardData': {'cardParamMap': _stringify_card_param_map(card_param_map)},
|
||||||
}
|
}
|
||||||
if private_data:
|
if private_data:
|
||||||
body['privateData'] = private_data
|
body['privateData'] = private_data
|
||||||
@@ -746,7 +766,7 @@ class DingTalkClient:
|
|||||||
_stdout_logger.info(
|
_stdout_logger.info(
|
||||||
'DingTalk update_card_data request: out_track_id=%s body=%s',
|
'DingTalk update_card_data request: out_track_id=%s body=%s',
|
||||||
out_track_id,
|
out_track_id,
|
||||||
json.dumps(body, ensure_ascii=False)[:500],
|
json.dumps(body, ensure_ascii=False)[:1500],
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.put(url, headers=headers, json=body, timeout=30.0)
|
response = await client.put(url, headers=headers, json=body, timeout=30.0)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ _PARAM_PATHS = (
|
|||||||
('params',),
|
('params',),
|
||||||
('cardPrivateData', 'params'),
|
('cardPrivateData', 'params'),
|
||||||
('userPrivateData', 'params'),
|
('userPrivateData', 'params'),
|
||||||
|
('actionData', 'cardPrivateData', 'params'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +49,14 @@ def _extract_params(content: dict) -> dict:
|
|||||||
return {}
|
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):
|
class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -61,12 +70,13 @@ class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler):
|
|||||||
async def process(self, callback: dingtalk_stream.CallbackMessage):
|
async def process(self, callback: dingtalk_stream.CallbackMessage):
|
||||||
try:
|
try:
|
||||||
message = CardCallbackMessage.from_dict(callback.data)
|
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
|
# `CardCallbackMessage.from_dict` does not surface `actionId` (the
|
||||||
# top-level field that ButtonGroup's sendCardRequest event puts
|
# top-level field that ButtonGroup's sendCardRequest event puts
|
||||||
# there). Pull it from the raw callback.data instead.
|
# there). Pull it from the raw callback.data instead.
|
||||||
raw = callback.data if isinstance(callback.data, dict) else {}
|
raw = callback.data if isinstance(callback.data, dict) else {}
|
||||||
|
params = _merge_params(_extract_params(content), _extract_params(raw))
|
||||||
action_id = raw.get('actionId') or ''
|
action_id = raw.get('actionId') or ''
|
||||||
if not action_id:
|
if not action_id:
|
||||||
# Some templates nest it under actionData / cardPrivateData.
|
# Some templates nest it under actionData / cardPrivateData.
|
||||||
|
|||||||
@@ -779,6 +779,308 @@ def _wecom_button_style(action: dict, *, selected: bool = False) -> int:
|
|||||||
return 1
|
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(
|
def build_button_interaction_payload(
|
||||||
form_data: dict,
|
form_data: dict,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
@@ -815,14 +1117,20 @@ def build_button_interaction_payload(
|
|||||||
"""
|
"""
|
||||||
actions = list(form_data.get('actions') or [])
|
actions = list(form_data.get('actions') or [])
|
||||||
node_title = (form_data.get('node_title') or '').strip() 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]
|
visible_actions = actions[:6] if should_show_actions else []
|
||||||
overflow = actions[6:]
|
overflow = actions[6:] if should_show_actions else []
|
||||||
|
|
||||||
sub_title_parts: list[str] = []
|
sub_title_parts: list[str] = []
|
||||||
if form_content:
|
if form_content:
|
||||||
sub_title_parts.append(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:
|
if overflow:
|
||||||
extra_lines = [f' - {a.get("title") or a.get("id") or ""} (回复 id: {a.get("id") or ""})' for a in 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))
|
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',
|
'card_type': 'button_interaction',
|
||||||
'main_title': {
|
'main_title': {
|
||||||
'title': node_title,
|
'title': node_title,
|
||||||
|
'desc': _wecom_form_desc(form_data),
|
||||||
},
|
},
|
||||||
'sub_title_text': sub_title_text,
|
'sub_title_text': sub_title_text,
|
||||||
'button_list': button_list,
|
'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 '')
|
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:
|
def resolve_form_action_title(form_data: dict, action_id: str) -> str:
|
||||||
"""Resolve a Dify form action title from its id."""
|
"""Resolve a Dify form action title from its id."""
|
||||||
|
|
||||||
@@ -954,6 +1481,73 @@ def build_button_interaction_update_card(
|
|||||||
return 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:
|
class WecomBotClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -1000,6 +1594,7 @@ class WecomBotClient:
|
|||||||
|
|
||||||
self._feedback_callback: Optional[Callable] = None
|
self._feedback_callback: Optional[Callable] = None
|
||||||
self._card_action_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
|
# Optional `source` block injected into every interactive template_card
|
||||||
# the client builds. Set via `set_card_source` from the adapter after
|
# the client builds. Set via `set_card_source` from the adapter after
|
||||||
# reading config. Format: {icon_url, desc, desc_color}.
|
# reading config. Format: {icon_url, desc, desc_color}.
|
||||||
@@ -1065,7 +1660,7 @@ class WecomBotClient:
|
|||||||
"""Class-level shim — delegates to module-level builder and auto-
|
"""Class-level shim — delegates to module-level builder and auto-
|
||||||
injects the client's configured `source` block so every card emitted
|
injects the client's configured `source` block so every card emitted
|
||||||
through this client carries the LangBot header."""
|
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]:
|
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)
|
msg_json = json.loads(decrypted_xml)
|
||||||
|
|
||||||
event = msg_json.get('event', {})
|
event_type = extract_wecom_event_type(msg_json)
|
||||||
event_type = event.get('eventtype', '')
|
|
||||||
|
|
||||||
if event_type == 'feedback_event':
|
if event_type == 'feedback_event':
|
||||||
return await self._handle_feedback_event(msg_json, nonce)
|
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``).
|
the button's ``key`` (which we set to the Dify ``action_id``).
|
||||||
"""
|
"""
|
||||||
try:
|
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)
|
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}')
|
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:
|
if not stream_id:
|
||||||
return False
|
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)
|
await self.stream_sessions.publish(stream_id, chunk)
|
||||||
|
self._stream_last_content[msg_id] = next_content
|
||||||
if is_final:
|
if is_final:
|
||||||
|
self._stream_last_content.pop(msg_id, None)
|
||||||
self.stream_sessions.mark_finished(stream_id)
|
self.stream_sessions.mark_finished(stream_id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,15 @@ from langbot.libs.wecom_ai_bot_api import wecombotevent
|
|||||||
from langbot.libs.wecom_ai_bot_api.api import (
|
from langbot.libs.wecom_ai_bot_api.api import (
|
||||||
parse_wecom_bot_message,
|
parse_wecom_bot_message,
|
||||||
StreamSession,
|
StreamSession,
|
||||||
build_button_interaction_payload,
|
build_human_input_template_card_payload,
|
||||||
|
build_human_input_text_prompt,
|
||||||
build_button_interaction_update_card,
|
build_button_interaction_update_card,
|
||||||
|
build_multiple_interaction_update_card,
|
||||||
extract_template_card_action,
|
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
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
@@ -49,6 +55,10 @@ def _generate_req_id(prefix: str) -> str:
|
|||||||
return f'{prefix}_{ts}_{rand}'
|
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:
|
class WecomBotWsClient:
|
||||||
"""WeChat Work AI Bot WebSocket long connection client.
|
"""WeChat Work AI Bot WebSocket long connection client.
|
||||||
|
|
||||||
@@ -334,6 +344,21 @@ class WecomBotWsClient:
|
|||||||
task_id = f'dify-{secrets.token_hex(12)}'
|
task_id = f'dify-{secrets.token_hex(12)}'
|
||||||
|
|
||||||
session_info = self._stream_sessions.get(msg_id) or {}
|
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] = {
|
self._pending_forms_by_task[task_id] = {
|
||||||
'form_data': form_data,
|
'form_data': form_data,
|
||||||
'msg_id': msg_id,
|
'msg_id': msg_id,
|
||||||
@@ -344,7 +369,12 @@ class WecomBotWsClient:
|
|||||||
}
|
}
|
||||||
self._task_id_by_msg[msg_id] = task_id
|
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:
|
try:
|
||||||
await self.reply_template_card(req_id, card_payload)
|
await self.reply_template_card(req_id, card_payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -421,8 +451,19 @@ class WecomBotWsClient:
|
|||||||
return False
|
return False
|
||||||
req_id, stream_id = key.split('|', 1)
|
req_id, stream_id = key.split('|', 1)
|
||||||
try:
|
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)
|
# 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
|
return True
|
||||||
|
|
||||||
# Skip empty/whitespace-only chunks — the runner injects a
|
# Skip empty/whitespace-only chunks — the runner injects a
|
||||||
@@ -435,7 +476,7 @@ class WecomBotWsClient:
|
|||||||
if not is_final:
|
if not is_final:
|
||||||
import re as _re
|
import re as _re
|
||||||
|
|
||||||
if not _re.sub(r'[\s]', '', content):
|
if not _re.sub(r'[\s]', '', delta_content):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Generate feedback_id for final chunk
|
# Generate feedback_id for final chunk
|
||||||
@@ -448,8 +489,8 @@ class WecomBotWsClient:
|
|||||||
if session_info:
|
if session_info:
|
||||||
self._feedback_sessions[feedback_id] = 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)
|
await self.reply_stream(req_id, stream_id, delta_content, finish=is_final, feedback_id=feedback_id)
|
||||||
self._stream_last_content[msg_id] = content
|
self._stream_last_content[msg_id] = next_content
|
||||||
if is_final:
|
if is_final:
|
||||||
self._stream_ids.pop(msg_id, None)
|
self._stream_ids.pop(msg_id, None)
|
||||||
self._stream_last_content.pop(msg_id, None)
|
self._stream_last_content.pop(msg_id, None)
|
||||||
@@ -623,7 +664,7 @@ class WecomBotWsClient:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Unknown frame
|
# 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):
|
async def _handle_message_callback(self, frame: dict):
|
||||||
"""Handle an incoming message callback frame."""
|
"""Handle an incoming message callback frame."""
|
||||||
@@ -631,6 +672,13 @@ class WecomBotWsClient:
|
|||||||
body = frame.get('body', {})
|
body = frame.get('body', {})
|
||||||
req_id = frame.get('headers', {}).get('req_id', '')
|
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
|
# Parse message using shared logic
|
||||||
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
|
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
|
||||||
if not message_data:
|
if not message_data:
|
||||||
@@ -664,8 +712,12 @@ class WecomBotWsClient:
|
|||||||
body = frame.get('body', {})
|
body = frame.get('body', {})
|
||||||
req_id = frame.get('headers', {}).get('req_id', '')
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
event_info = body.get('event', {})
|
event_info = body.get('event', {}) if isinstance(body.get('event'), dict) else body
|
||||||
event_type = event_info.get('eventtype', '')
|
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 = {
|
message_data = {
|
||||||
'msgtype': 'event',
|
'msgtype': 'event',
|
||||||
@@ -727,64 +779,7 @@ class WecomBotWsClient:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if event_type == 'template_card_event':
|
if event_type == 'template_card_event':
|
||||||
tce = event_info.get('template_card_event', {})
|
await self._handle_template_card_event_frame(frame, body)
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
@@ -800,6 +795,72 @@ class WecomBotWsClient:
|
|||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Error in event callback: {traceback.format_exc()}')
|
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):
|
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):
|
||||||
"""Dispatch a message event to registered handlers with deduplication."""
|
"""Dispatch a message event to registered handlers with deduplication."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import re
|
||||||
import traceback
|
import traceback
|
||||||
import typing
|
import typing
|
||||||
import uuid
|
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):
|
class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
bot: DingTalkClient
|
bot: DingTalkClient
|
||||||
bot_account_id: str
|
bot_account_id: str
|
||||||
@@ -329,8 +585,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
content=content,
|
content=content,
|
||||||
btns='[]',
|
btns='[]',
|
||||||
flowStatus='3' if is_final else '1',
|
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:
|
except Exception:
|
||||||
if self.ap is not None:
|
if self.ap is not None:
|
||||||
self.ap.logger.exception('DingTalk: update card content failed')
|
self.ap.logger.exception('DingTalk: update card content failed')
|
||||||
@@ -343,7 +603,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
try:
|
try:
|
||||||
await self.bot.update_card_data(
|
await self.bot.update_card_data(
|
||||||
out_track_id=card_instance_id,
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -396,7 +659,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
out_track_id=out_track_id,
|
out_track_id=out_track_id,
|
||||||
open_space_id=open_space_id,
|
open_space_id=open_space_id,
|
||||||
is_group=is_group,
|
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',
|
callback_type='STREAM',
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -606,7 +874,15 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
"""Update an existing card's content + buttons for human-input."""
|
"""Update an existing card's content + buttons for human-input."""
|
||||||
actions = list(form_data.get('actions') or [])
|
actions = list(form_data.get('actions') or [])
|
||||||
node_title = form_data.get('node_title', '') or 'Human Input Required'
|
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.
|
# Record form state for the click-handler.
|
||||||
launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source)
|
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,
|
'sender_user_id': sender_user_id,
|
||||||
'form_token': form_data.get('form_token', ''),
|
'form_token': form_data.get('form_token', ''),
|
||||||
'workflow_run_id': form_data.get('workflow_run_id', ''),
|
'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,
|
'node_title': node_title,
|
||||||
'form_content': form_content,
|
'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] = []
|
parts: list[str] = []
|
||||||
prior = self.active_turn_text.get(session_key, '') if session_key else ''
|
prior = self.active_turn_text.get(session_key, '') if session_key else ''
|
||||||
if prior.strip():
|
if prior.strip():
|
||||||
@@ -636,6 +916,14 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
parts.append(f'**{node_title}**')
|
parts.append(f'**{node_title}**')
|
||||||
if form_content:
|
if form_content:
|
||||||
parts.append(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 '请选择一个操作以继续。'
|
display_content = '<br><br>'.join(parts) or '请选择一个操作以继续。'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -645,6 +933,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
content=display_content,
|
content=display_content,
|
||||||
btns=json.dumps(btns, ensure_ascii=False),
|
btns=json.dumps(btns, ensure_ascii=False),
|
||||||
flowStatus='3',
|
flowStatus='3',
|
||||||
|
**component_params,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -653,9 +942,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
await self.send_message_text_form(message_source, form_data)
|
await self.send_message_text_form(message_source, form_data)
|
||||||
return
|
return
|
||||||
|
|
||||||
if session_key:
|
|
||||||
self.active_turn_text[session_key] = display_content
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_btns(actions: list, out_track_id: str) -> list:
|
def _build_btns(actions: list, out_track_id: str) -> list:
|
||||||
btns = []
|
btns = []
|
||||||
@@ -699,7 +985,15 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
actions = list(form_data.get('actions') or [])
|
actions = list(form_data.get('actions') or [])
|
||||||
node_title = form_data.get('node_title', '') or 'Human Input Required'
|
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] = {
|
self.card_state[out_track_id] = {
|
||||||
'session_key': session_key,
|
'session_key': session_key,
|
||||||
@@ -708,9 +1002,13 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'sender_user_id': sender_user_id,
|
'sender_user_id': sender_user_id,
|
||||||
'form_token': form_data.get('form_token', ''),
|
'form_token': form_data.get('form_token', ''),
|
||||||
'workflow_run_id': form_data.get('workflow_run_id', ''),
|
'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,
|
'node_title': node_title,
|
||||||
'form_content': form_content,
|
'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,
|
'open_space_id': open_space_id,
|
||||||
'is_group': is_group,
|
'is_group': is_group,
|
||||||
}
|
}
|
||||||
@@ -720,33 +1018,17 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
parts.append(f'**{node_title}**')
|
parts.append(f'**{node_title}**')
|
||||||
if form_content:
|
if form_content:
|
||||||
parts.append(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 '请选择一个操作以继续。'
|
display_content = '<br><br>'.join(parts) or '请选择一个操作以继续。'
|
||||||
|
|
||||||
btns = []
|
btns = self._build_btns(actions if should_show_actions else [], out_track_id)
|
||||||
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},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.ap is not None:
|
if self.ap is not None:
|
||||||
@@ -763,6 +1045,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
content=display_content,
|
content=display_content,
|
||||||
btns=json.dumps(btns, ensure_ascii=False),
|
btns=json.dumps(btns, ensure_ascii=False),
|
||||||
flowStatus='3',
|
flowStatus='3',
|
||||||
|
**component_params,
|
||||||
),
|
),
|
||||||
callback_type='STREAM',
|
callback_type='STREAM',
|
||||||
)
|
)
|
||||||
@@ -790,7 +1073,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
out_track_id = uuid.uuid4().hex
|
out_track_id = uuid.uuid4().hex
|
||||||
open_space_id, is_group = self._derive_open_space(message_source)
|
open_space_id, is_group = self._derive_open_space(message_source)
|
||||||
if form_template_id:
|
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
|
card_data_config = None
|
||||||
else:
|
else:
|
||||||
# Legacy chat-card template doesn't carry a `bot_avatar`
|
# Legacy chat-card template doesn't carry a `bot_avatar`
|
||||||
@@ -829,11 +1117,21 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
form_data: dict,
|
form_data: dict,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fallback: send the human-input prompt as plain text."""
|
"""Fallback: send the human-input prompt as plain text."""
|
||||||
display_text = _format_human_input_text(
|
if form_data.get('_current_input_field') and not form_data.get('_action_select_only'):
|
||||||
form_data.get('node_title', ''),
|
parts = []
|
||||||
form_data.get('form_content', ''),
|
node_title = form_data.get('node_title', '')
|
||||||
form_data.get('actions', []) or [],
|
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)
|
await self._send_proactive_to_event(message_source, display_text)
|
||||||
|
|
||||||
async def _send_proactive_to_event(
|
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('actionId') or '').strip()
|
||||||
or (params.get('id') 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)
|
state = self.card_state.get(out_track_id)
|
||||||
if state is None:
|
if state is None:
|
||||||
await self.logger.warning(f'DingTalk: card action received for unknown out_track_id={out_track_id}')
|
await self.logger.warning(f'DingTalk: card action received for unknown out_track_id={out_track_id}')
|
||||||
return
|
return
|
||||||
|
|
||||||
actions = state.get('actions', []) or []
|
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_id = raw_action_id
|
||||||
action_title = raw_action_id
|
action_title = raw_action_id
|
||||||
for action in actions:
|
for action in actions:
|
||||||
@@ -1002,6 +1305,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
action_title,
|
action_title,
|
||||||
node_title=state.get('node_title', ''),
|
node_title=state.get('node_title', ''),
|
||||||
form_content=state.get('form_content', ''),
|
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.
|
# Once consumed, drop the state — the runner clears _PENDING_FORMS too.
|
||||||
self.card_state.pop(out_track_id, None)
|
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(
|
async def _mark_card_resolved(
|
||||||
self,
|
self,
|
||||||
out_track_id: str,
|
out_track_id: str,
|
||||||
@@ -1025,6 +1418,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
*,
|
*,
|
||||||
node_title: str = '',
|
node_title: str = '',
|
||||||
form_content: str = '',
|
form_content: str = '',
|
||||||
|
input_defs: list | None = None,
|
||||||
|
inputs: dict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update the form card to acknowledge the user's selection.
|
"""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}**')
|
parts.append(f'**{node_title}**')
|
||||||
if form_content:
|
if form_content:
|
||||||
parts.append(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}**')
|
parts.append(f'<hr>✅ 已选择:**{action_title}**')
|
||||||
content = '<br><br>'.join(parts)
|
content = '<br><br>'.join(parts)
|
||||||
if self.ap is not None:
|
if self.ap is not None:
|
||||||
@@ -1048,6 +1451,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
content=content,
|
content=content,
|
||||||
btns='[]',
|
btns='[]',
|
||||||
flowStatus='3',
|
flowStatus='3',
|
||||||
|
**_dingtalk_empty_form_component_params(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -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
|
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):
|
class AESCipher(object):
|
||||||
def __init__(self, key):
|
def __init__(self, key):
|
||||||
self.bs = AES.block_size
|
self.bs = AES.block_size
|
||||||
@@ -804,6 +961,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
card_pre_pause_text: dict[str, str]
|
card_pre_pause_text: dict[str, str]
|
||||||
# card_id → form_content captured when the form is first shown (for resume notice)
|
# card_id → form_content captured when the form is first shown (for resume notice)
|
||||||
card_form_content: dict[str, str]
|
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"
|
# set of card_ids that have already transitioned from "buttons visible" to "resume layout"
|
||||||
card_resume_transitioned: set[str]
|
card_resume_transitioned: set[str]
|
||||||
_MONITORING_MAPPING_TTL = 600 # 10 minutes
|
_MONITORING_MAPPING_TTL = 600 # 10 minutes
|
||||||
@@ -834,13 +994,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
try:
|
try:
|
||||||
action_value_raw = getattr(getattr(event.event, 'action', None), 'value', {})
|
action_value_raw = getattr(getattr(event.event, 'action', None), 'value', {})
|
||||||
# Parse JSON string values (from form action buttons)
|
# Parse JSON string values (from form action buttons)
|
||||||
if isinstance(action_value_raw, str):
|
action_value_obj = _lark_mapping_from_value(action_value_raw)
|
||||||
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 = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
||||||
|
|
||||||
# Handle Dify form action button clicks
|
# 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', '')
|
workflow_run_id = action_value_obj.get('workflow_run_id', '')
|
||||||
action_id = action_value_obj.get('action_id', '')
|
action_id = action_value_obj.get('action_id', '')
|
||||||
session_key = action_value_obj.get('session_key', '')
|
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:'):
|
if session_key.startswith('group_') or session_key.startswith('g:'):
|
||||||
launcher_type = provider_session.LauncherTypes.GROUP
|
launcher_type = provider_session.LauncherTypes.GROUP
|
||||||
@@ -879,11 +1035,26 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'workflow_run_id': workflow_run_id,
|
'workflow_run_id': workflow_run_id,
|
||||||
'action_id': action_id,
|
'action_id': action_id,
|
||||||
'user': f'{launcher_type.value}_{launcher_id}',
|
'user': f'{launcher_type.value}_{launcher_id}',
|
||||||
'inputs': {},
|
'inputs': form_inputs,
|
||||||
}
|
}
|
||||||
|
|
||||||
context = getattr(event.event, 'context', None)
|
context = getattr(event.event, 'context', None)
|
||||||
open_message_id = getattr(context, 'open_message_id', 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()
|
source_time = datetime.datetime.now()
|
||||||
event_time = source_time.timestamp()
|
event_time = source_time.timestamp()
|
||||||
action_text = action_value_obj.get('action_id', 'confirm')
|
action_text = action_value_obj.get('action_id', 'confirm')
|
||||||
@@ -1033,6 +1204,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
card_streaming_text={},
|
card_streaming_text={},
|
||||||
card_pre_pause_text={},
|
card_pre_pause_text={},
|
||||||
card_form_content={},
|
card_form_content={},
|
||||||
|
card_form_input_defs={},
|
||||||
|
card_form_inputs={},
|
||||||
card_resume_transitioned=set(),
|
card_resume_transitioned=set(),
|
||||||
seq=1,
|
seq=1,
|
||||||
listeners={},
|
listeners={},
|
||||||
@@ -1299,6 +1472,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.card_streaming_text.pop(card_id, None)
|
self.card_streaming_text.pop(card_id, None)
|
||||||
self.card_pre_pause_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_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)
|
self.card_resume_transitioned.discard(card_id)
|
||||||
|
|
||||||
async def create_card_id(self, message_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, '')
|
stored_form_content = self.card_form_content.get(card_id, '')
|
||||||
if stored_form_content:
|
if stored_form_content:
|
||||||
notice_parts.append(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}**')
|
notice_parts.append(f'---\n✅ 已选择:**{action_title}**')
|
||||||
selected_notice = '\n\n'.join(notice_parts)
|
selected_notice = '\n\n'.join(notice_parts)
|
||||||
else:
|
else:
|
||||||
@@ -1937,6 +2120,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.card_streaming_text.pop(card_id, None)
|
self.card_streaming_text.pop(card_id, None)
|
||||||
self.card_pre_pause_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_content.pop(card_id, None)
|
||||||
|
self.card_form_input_defs.pop(card_id, None)
|
||||||
|
self.card_form_inputs.pop(card_id, None)
|
||||||
else:
|
else:
|
||||||
# The old card is now a frozen snapshot; let go of its
|
# The old card is now a frozen snapshot; let go of its
|
||||||
# streaming-side state but keep its source registrations
|
# 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_streaming_text.pop(card_id, None)
|
||||||
self.card_pre_pause_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_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)
|
self.card_resume_transitioned.discard(card_id)
|
||||||
else:
|
else:
|
||||||
# Initial pause path: render prompt + buttons in place on
|
# Initial pause path: render prompt + buttons in place on
|
||||||
@@ -1962,8 +2149,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
# transitions to resume mode.
|
# transitions to resume mode.
|
||||||
self.card_pre_pause_text[card_id] = self.card_streaming_text.get(card_id, '')
|
self.card_pre_pause_text[card_id] = self.card_streaming_text.get(card_id, '')
|
||||||
self.card_streaming_text[card_id] = ''
|
self.card_streaming_text[card_id] = ''
|
||||||
# Store form_content for the resume notice
|
# Store cleaned form state for the resume notice.
|
||||||
self.card_form_content[card_id] = form_data.get('form_content', '') if form_data else ''
|
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:
|
else:
|
||||||
# Normal finish: keep pre-pause + resume content visible,
|
# Normal finish: keep pre-pause + resume content visible,
|
||||||
# remove buttons/notice, drop the resume placeholder.
|
# remove buttons/notice, drop the resume placeholder.
|
||||||
@@ -2040,6 +2233,78 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
form_data=None,
|
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(
|
async def _update_card_layout(
|
||||||
self,
|
self,
|
||||||
card_id: str,
|
card_id: str,
|
||||||
@@ -2063,6 +2328,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
workflow_run_id = form_data.get('workflow_run_id', '')
|
workflow_run_id = form_data.get('workflow_run_id', '')
|
||||||
node_title = form_data.get('node_title', '') or 'Human Input Required'
|
node_title = form_data.get('node_title', '') or 'Human Input Required'
|
||||||
form_content = form_data.get('form_content', '')
|
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
|
# When form_data is set, the visible content is rendered inside the
|
||||||
# interactive container, so the top streaming text should stay empty
|
# 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
|
# Build button elements matching the existing card template's thumbsup/down format
|
||||||
action_buttons = []
|
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:
|
for action in actions:
|
||||||
action_id = action.get('id', '')
|
action_id = action.get('id', '')
|
||||||
action_title = action.get('title', action_id)
|
action_title = action.get('title', action_id)
|
||||||
@@ -2095,29 +2369,33 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
else:
|
else:
|
||||||
lark_button_type = 'default'
|
lark_button_type = 'default'
|
||||||
|
|
||||||
action_buttons.append(
|
button = {
|
||||||
{
|
'tag': 'button',
|
||||||
'tag': 'button',
|
'text': {'tag': 'plain_text', 'content': action_title},
|
||||||
'text': {'tag': 'plain_text', 'content': action_title},
|
'type': lark_button_type,
|
||||||
'type': lark_button_type,
|
'width': 'fill',
|
||||||
'width': 'fill',
|
'size': 'medium',
|
||||||
'size': 'medium',
|
'hover_tips': {'tag': 'plain_text', 'content': action_title},
|
||||||
'hover_tips': {'tag': 'plain_text', 'content': action_title},
|
'behaviors': [
|
||||||
'behaviors': [
|
{
|
||||||
{
|
'type': 'callback',
|
||||||
'type': 'callback',
|
'value': {
|
||||||
'value': {
|
'form_action': True,
|
||||||
'form_action': True,
|
'form_token': form_token,
|
||||||
'form_token': form_token,
|
'workflow_run_id': workflow_run_id,
|
||||||
'workflow_run_id': workflow_run_id,
|
'action_id': action_id,
|
||||||
'action_id': action_id,
|
'session_key': session_key,
|
||||||
'session_key': session_key,
|
'card_id': card_id,
|
||||||
},
|
'input_name_map': input_name_map,
|
||||||
}
|
},
|
||||||
],
|
}
|
||||||
'margin': '0px 0px 0px 0px',
|
],
|
||||||
}
|
'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 = []
|
interactive_elements = []
|
||||||
if form_data:
|
if form_data:
|
||||||
@@ -2141,38 +2419,84 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'margin': '0px 0px 8px 0px',
|
'margin': '0px 0px 8px 0px',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
interactive_elements.append(
|
completed_lines = _lark_completed_input_lines(
|
||||||
{
|
{
|
||||||
'tag': 'column_set',
|
'input_defs': input_defs,
|
||||||
'horizontal_spacing': '8px',
|
'inputs': form_data.get('inputs') or {},
|
||||||
'horizontal_align': 'left',
|
}
|
||||||
'margin': '0px 0px 0px 0px',
|
)
|
||||||
'columns': [
|
if completed_lines:
|
||||||
|
interactive_elements.append(
|
||||||
{
|
{
|
||||||
'tag': 'column',
|
'tag': 'markdown',
|
||||||
'width': 'weighted',
|
'content': '---\n' + '\n'.join(completed_lines),
|
||||||
'elements': [btn],
|
'text_align': 'left',
|
||||||
'padding': '0px 0px 0px 0px',
|
'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
|
# Build the full card JSON with buttons, same structure as create_card_id
|
||||||
# ── mid_section: either form buttons, resume notice, or empty ──
|
# ── mid_section: either form buttons, resume notice, or empty ──
|
||||||
mid_section_elements = []
|
mid_section_elements = []
|
||||||
if form_data:
|
if form_data:
|
||||||
mid_section_elements = [
|
if uses_form_container:
|
||||||
{
|
form_elements = interactive_elements[:-1] if action_buttons else interactive_elements[:]
|
||||||
'tag': 'interactive_container',
|
form_elements.extend(form_field_elements)
|
||||||
'margin': '12px 0px 8px 0px',
|
if action_buttons:
|
||||||
'padding': '12px 12px 12px 12px',
|
form_elements.append(interactive_elements[-1])
|
||||||
'has_border': True,
|
mid_section_elements = [
|
||||||
'elements': interactive_elements,
|
{
|
||||||
},
|
'tag': 'form',
|
||||||
{'tag': 'hr', 'margin': '0px 0px 0px 0px'},
|
'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:
|
elif notice_text:
|
||||||
mid_section_elements = [
|
mid_section_elements = [
|
||||||
{
|
{
|
||||||
@@ -2442,9 +2766,121 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
action = event_data.get('action', {})
|
action = event_data.get('action', {})
|
||||||
context_data = event_data.get('context', {})
|
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 ''
|
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 == '有帮助':
|
if action_value == '有帮助':
|
||||||
feedback_type = 1
|
feedback_type = 1
|
||||||
elif action_value == '无帮助':
|
elif action_value == '无帮助':
|
||||||
|
|||||||
@@ -833,12 +833,47 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
|
|
||||||
node_title = form_data.get('node_title') or 'Confirmation needed'
|
node_title = form_data.get('node_title') or 'Confirmation needed'
|
||||||
form_content = form_data.get('form_content') or ''
|
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}']
|
parts = [f'### {node_title}']
|
||||||
if form_content.strip():
|
if form_content.strip():
|
||||||
parts.append(form_content.strip())
|
parts.append(form_content.strip())
|
||||||
parts.append('请点击下方按钮选择:')
|
parts.append('请点击下方按钮选择:')
|
||||||
markdown_content = '\n\n'.join(parts)
|
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)
|
keyboard = build_keyboard_from_form(form_data, buttons_per_row=2)
|
||||||
if not keyboard.get('content', {}).get('rows'):
|
if not keyboard.get('content', {}).get('rows'):
|
||||||
# No actions to render — fall back to plain text.
|
# No actions to render — fall back to plain text.
|
||||||
|
|||||||
@@ -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
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
from ..logger import EventLogger
|
from ..logger import EventLogger
|
||||||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
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
|
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', {})
|
object.__setattr__(self, '_synthetic_buffers', {})
|
||||||
buffers: dict[str, str] = self._synthetic_buffers
|
buffers: dict[str, str] = self._synthetic_buffers
|
||||||
if content and not form_data:
|
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:
|
if not is_final:
|
||||||
return {'stream': True, 'synthetic': True, 'buffered': True}
|
return {'stream': True, 'synthetic': True, 'buffered': True}
|
||||||
|
|
||||||
final_content = buffers.pop(buf_key, '')
|
final_content = buffers.pop(buf_key, '')
|
||||||
if content and final_content.startswith(content):
|
if content:
|
||||||
# is_final chunk re-emitted the full accumulated text — keep
|
if final_content and content.startswith(final_content):
|
||||||
# whichever is longer.
|
final_content = content
|
||||||
final_content = final_content if len(final_content) >= len(content) else content
|
elif final_content and final_content.endswith(content):
|
||||||
elif content and not final_content:
|
pass
|
||||||
final_content = content
|
else:
|
||||||
|
final_content = final_content + content
|
||||||
|
|
||||||
if not ws_mode:
|
if not ws_mode:
|
||||||
await self.logger.warning(
|
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.
|
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.
|
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
|
import secrets as _secrets
|
||||||
|
|
||||||
task_id = f'dify-{_secrets.token_hex(12)}'
|
task_id = f'dify-{_secrets.token_hex(12)}'
|
||||||
source = getattr(self.bot, 'card_source', None)
|
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.
|
# 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
|
# 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 []
|
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
|
action_title = clean_action_id
|
||||||
for a in actions:
|
for a in actions:
|
||||||
if str(a.get('id', '')) == clean_action_id:
|
if str(a.get('id', '')) == clean_action_id:
|
||||||
action_title = a.get('title') or clean_action_id
|
action_title = a.get('title') or clean_action_id
|
||||||
break
|
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 ''
|
launcher_id = session.user_id or session.chat_id or ''
|
||||||
sender_user_id = session.user_id or launcher_id
|
sender_user_id = session.user_id or launcher_id
|
||||||
# WeCom AI bot has both single-chat and group-chat; chat_id present
|
# 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,
|
'action_title': action_title,
|
||||||
'node_title': form.get('node_title', ''),
|
'node_title': form.get('node_title', ''),
|
||||||
'user': f'{launcher_type.value}_{launcher_id}',
|
'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}]')])
|
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 sys
|
||||||
import types
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
logger_module = types.ModuleType('langbot.pkg.platform.logger')
|
logger_module = types.ModuleType('langbot.pkg.platform.logger')
|
||||||
logger_module.EventLogger = object
|
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
|
from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402
|
||||||
build_button_interaction_payload,
|
build_button_interaction_payload,
|
||||||
|
build_human_input_template_card_payload,
|
||||||
build_button_interaction_update_card,
|
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,
|
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():
|
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'
|
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():
|
def test_build_button_interaction_update_card_marks_clicked_button():
|
||||||
card = build_button_interaction_update_card(
|
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': 'Approve', 'style': 2, 'key': 'approve'},
|
||||||
{'text': 'Reject', 'style': 2, 'key': 'reject'},
|
{'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
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
|
||||||
|
|
||||||
class TestDifyExtractTextOutput:
|
class TestDifyExtractTextOutput:
|
||||||
@@ -26,7 +29,7 @@ class TestDifyExtractTextOutput:
|
|||||||
'base-url': 'https://api.dify.ai',
|
'base-url': 'https://api.dify.ai',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'output': {'misc': {}}
|
'output': {'misc': {}},
|
||||||
}
|
}
|
||||||
|
|
||||||
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
|
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
|
||||||
@@ -111,7 +114,7 @@ class TestDifyRunnerConfigValidation:
|
|||||||
'base-url': 'https://api.dify.ai',
|
'base-url': 'https://api.dify.ai',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'output': {'misc': {}}
|
'output': {'misc': {}},
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(DifyAPIError, match='不支持'):
|
with pytest.raises(DifyAPIError, match='不支持'):
|
||||||
@@ -134,7 +137,7 @@ class TestDifyRunnerConfigValidation:
|
|||||||
'base-url': 'https://api.dify.ai',
|
'base-url': 'https://api.dify.ai',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'output': {'misc': {}}
|
'output': {'misc': {}},
|
||||||
}
|
}
|
||||||
|
|
||||||
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
|
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
|
||||||
@@ -160,10 +163,509 @@ class TestDifyRunnerInit:
|
|||||||
'base-url': 'https://api.dify.ai',
|
'base-url': 'https://api.dify.ai',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'output': {'misc': {}}
|
'output': {'misc': {}},
|
||||||
}
|
}
|
||||||
|
|
||||||
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
|
runner = DifyServiceAPIRunner(mock_app, pipeline_config)
|
||||||
|
|
||||||
assert runner.pipeline_config == 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
|
||||||
|
|||||||
Reference in New Issue
Block a user