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