mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 12:25:29 +08:00
1426 lines
61 KiB
Python
1426 lines
61 KiB
Python
from langbot.libs.dingtalk_api.a2ui import (
|
||
brand_components,
|
||
binding,
|
||
create_surface_messages,
|
||
create_text_card_messages,
|
||
update_surface_messages,
|
||
)
|
||
import asyncio
|
||
import re
|
||
import traceback
|
||
import typing
|
||
import uuid
|
||
import time
|
||
|
||
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||
from langbot.libs.dingtalk_api.api import DingTalkClient, is_stream_card_configured
|
||
import datetime
|
||
from langbot.pkg.platform.logger import EventLogger
|
||
from langbot.pkg.platform.human_input import format_human_input_text
|
||
|
||
|
||
class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||
@staticmethod
|
||
def _format_image_as_markdown(msg: platform_message.Image) -> str:
|
||
"""Convert an Image message to Markdown format for DingTalk."""
|
||
if msg.url:
|
||
return f'\n\n'
|
||
elif msg.base64:
|
||
# For base64 images, try to include them as data URIs
|
||
# DingTalk may have limited support for base64 in markdown
|
||
if msg.base64.startswith('data:'):
|
||
return f'\n\n'
|
||
else:
|
||
return f'\n\n'
|
||
return ''
|
||
|
||
@staticmethod
|
||
async def yiri2target(message_chain: platform_message.MessageChain, markdown_enabled: bool = True):
|
||
content = ''
|
||
at = False
|
||
for msg in message_chain:
|
||
if type(msg) is platform_message.At:
|
||
at = True
|
||
elif type(msg) is platform_message.Plain:
|
||
content += msg.text
|
||
elif type(msg) is platform_message.Image:
|
||
# DingTalk supports markdown images when markdown_card is enabled
|
||
# When markdown is disabled, images cannot be rendered in plain text mode
|
||
if markdown_enabled:
|
||
content += DingTalkMessageConverter._format_image_as_markdown(msg)
|
||
# Note: When markdown_enabled is False, images are not included
|
||
# as DingTalk plain text messages don't support image embedding
|
||
elif type(msg) is platform_message.Forward:
|
||
for node in msg.node_list:
|
||
forwarded_content, _ = await DingTalkMessageConverter.yiri2target(
|
||
node.message_chain, markdown_enabled
|
||
)
|
||
content += forwarded_content
|
||
return content, at
|
||
|
||
@staticmethod
|
||
async def target2yiri(event: DingTalkEvent, bot_name: str):
|
||
yiri_msg_list = []
|
||
yiri_msg_list.append(
|
||
platform_message.Source(id=event.incoming_message.message_id, time=datetime.datetime.now())
|
||
)
|
||
|
||
chatbot_id = getattr(event.incoming_message, 'chatbot_user_id', None)
|
||
mentioned = getattr(event.incoming_message, 'is_in_at_list', False) or any(
|
||
chatbot_id and getattr(at_user, 'dingtalk_id', None) == chatbot_id
|
||
for at_user in getattr(event.incoming_message, 'at_users', []) or []
|
||
)
|
||
if mentioned:
|
||
yiri_msg_list.append(platform_message.At(target=bot_name))
|
||
|
||
if event.rich_content:
|
||
elements = event.rich_content.get('Elements')
|
||
for element in elements:
|
||
if element.get('Type') == 'text':
|
||
text = element.get('Content', '')
|
||
if text.strip():
|
||
yiri_msg_list.append(platform_message.Plain(text=text))
|
||
elif element.get('Type') == 'image' and element.get('Picture'):
|
||
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
||
else:
|
||
# 回退到原有简单逻辑
|
||
# 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理
|
||
if event.content and event.type != 'audio':
|
||
text_content = event.content
|
||
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
||
if event.picture:
|
||
yiri_msg_list.append(platform_message.Image(base64=event.picture))
|
||
|
||
# 处理其他类型消息(文件、音频等)
|
||
if event.file:
|
||
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
||
if event.audio:
|
||
# 优先使用钉钉自带的语音转写文字(recognition字段)
|
||
if event.content and event.type == 'audio':
|
||
yiri_msg_list.append(platform_message.Plain(text=event.content))
|
||
else:
|
||
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
||
|
||
# Handle quoted/replied message - extract content as top-level components
|
||
# so that plugins like FileReader can process them the same way as direct messages
|
||
if event.quoted_message:
|
||
quote_info = event.quoted_message
|
||
msg_type = quote_info.get('msg_type', '')
|
||
|
||
# Process quoted file - add as top-level File component (same as private chat)
|
||
if msg_type == 'file' and quote_info.get('file_url'):
|
||
file_name = quote_info.get('file_name', 'file')
|
||
yiri_msg_list.append(platform_message.File(url=quote_info['file_url'], name=file_name))
|
||
|
||
# Process quoted image - add as top-level Image component
|
||
elif msg_type == 'picture' and quote_info.get('picture'):
|
||
yiri_msg_list.append(platform_message.Image(base64=quote_info['picture']))
|
||
|
||
# Process quoted audio - add as top-level Voice component
|
||
elif msg_type == 'audio' and quote_info.get('audio'):
|
||
yiri_msg_list.append(platform_message.Voice(base64=quote_info['audio']))
|
||
|
||
# Process quoted text - add as Plain text with context prefix
|
||
elif msg_type == 'text' and quote_info.get('content'):
|
||
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
|
||
|
||
# Process quoted rich text - add as Plain text with context prefix
|
||
elif msg_type == 'richText' and quote_info.get('content'):
|
||
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
|
||
|
||
chain = platform_message.MessageChain(yiri_msg_list)
|
||
|
||
return chain
|
||
|
||
|
||
class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||
@staticmethod
|
||
async def yiri2target(event: platform_events.MessageEvent):
|
||
return event.source_platform_object
|
||
|
||
@staticmethod
|
||
async def target2yiri(event: DingTalkEvent, bot_name: str):
|
||
message_chain = await DingTalkMessageConverter.target2yiri(event, bot_name)
|
||
|
||
if event.conversation == 'FriendMessage':
|
||
return platform_events.FriendMessage(
|
||
sender=platform_entities.Friend(
|
||
id=event.incoming_message.sender_staff_id,
|
||
nickname=event.incoming_message.sender_nick,
|
||
remark='',
|
||
),
|
||
message_chain=message_chain,
|
||
time=event.incoming_message.create_at,
|
||
source_platform_object=event,
|
||
)
|
||
elif event.conversation == 'GroupMessage':
|
||
sender = platform_entities.GroupMember(
|
||
id=event.incoming_message.sender_staff_id,
|
||
member_name=event.incoming_message.sender_nick,
|
||
permission='MEMBER',
|
||
group=platform_entities.Group(
|
||
id=event.incoming_message.conversation_id,
|
||
name=event.incoming_message.conversation_title,
|
||
permission=platform_entities.Permission.Member,
|
||
),
|
||
special_title='',
|
||
)
|
||
time = event.incoming_message.create_at
|
||
return platform_events.GroupMessage(
|
||
sender=sender,
|
||
message_chain=message_chain,
|
||
time=time,
|
||
source_platform_object=event,
|
||
)
|
||
|
||
|
||
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:
|
||
is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only')
|
||
raw_content = str(form_data.get('raw_form_content') or '')
|
||
content = raw_content or form_data.get('form_content') or ''
|
||
input_defs = _dingtalk_form_input_defs(form_data)
|
||
field_names = {_dingtalk_field_name(field) for field in input_defs if _dingtalk_field_name(field)}
|
||
|
||
if is_field_step and raw_content:
|
||
current_field = str(form_data.get('_current_input_field') or '').strip()
|
||
current_placeholder = next(
|
||
(
|
||
match
|
||
for match in re.finditer(r'\{\{#\$output\.([^#{}]+)#\}\}', raw_content)
|
||
if match.group(1).strip() == current_field
|
||
),
|
||
None,
|
||
)
|
||
content = (
|
||
raw_content[: current_placeholder.end()] if current_placeholder else form_data.get('form_content') or ''
|
||
)
|
||
|
||
if form_data.get('_action_select_only') or is_field_step:
|
||
fields = {_dingtalk_field_name(field): field for field in input_defs if _dingtalk_field_name(field)}
|
||
inputs = form_data.get('inputs') or {}
|
||
|
||
def replace_placeholder(match: re.Match[str]) -> str:
|
||
field_name = match.group(1).strip()
|
||
field = fields.get(field_name)
|
||
if not field or inputs.get(field_name) in (None, '', []):
|
||
return ''
|
||
lines = _dingtalk_completed_input_lines(
|
||
{
|
||
'input_defs': [field],
|
||
'inputs': {field_name: inputs[field_name]},
|
||
}
|
||
)
|
||
return lines[0] if lines else ''
|
||
|
||
content = re.sub(r'\{\{#\$output\.([^#{}]+)#\}\}', replace_placeholder, str(content))
|
||
|
||
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_card_markdown(content: str) -> str:
|
||
"""Preserve line breaks inside DingTalk card-template markdown slots."""
|
||
return '<br>'.join(str(content or '').splitlines())
|
||
|
||
|
||
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
|
||
display_value = _dingtalk_display_input_value(field, value)
|
||
lines.append(f'✅ {field_name}:{display_value}')
|
||
return lines
|
||
|
||
|
||
def _dingtalk_missing_completed_input_lines(form_data: dict, form_content: str) -> list[str]:
|
||
"""Return completed values that are not already rendered in the form body."""
|
||
rendered_lines = {
|
||
line.strip()
|
||
for line in re.split(r'<br\s*/?>|\r?\n', str(form_content or ''), flags=re.IGNORECASE)
|
||
if line.strip()
|
||
}
|
||
return [line for line in _dingtalk_completed_input_lines(form_data) if line.strip() not in rendered_lines]
|
||
|
||
|
||
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_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_extract_component_inputs(params: dict) -> dict:
|
||
"""Normalize legacy template and A2UI input 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, ''):
|
||
if isinstance(input_value, str):
|
||
input_value = input_value.strip()
|
||
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 isinstance(select_value, list):
|
||
select_value = select_value[0] if select_value else ''
|
||
if select_value not in (None, ''):
|
||
if isinstance(select_value, str):
|
||
select_value = select_value.strip()
|
||
result['select'] = select_value
|
||
|
||
return result
|
||
|
||
|
||
def _dingtalk_a2ui_form(
|
||
form_data: dict,
|
||
out_track_id: str,
|
||
*,
|
||
prior_content: str = '',
|
||
) -> tuple[dict, list[dict]]:
|
||
node_title = str(form_data.get('node_title') or 'Human Input Required')
|
||
form_content = _dingtalk_clean_form_content(form_data)
|
||
content_parts = [part for part in (prior_content.strip(), f'## {node_title}', form_content) if part]
|
||
data: dict[str, typing.Any] = {'content': '\n\n'.join(content_parts), 'form': {}}
|
||
components: list[dict] = [
|
||
{'id': 'root', 'component': 'Column', 'children': ['content'], 'gap': 12},
|
||
{'id': 'content', 'component': 'Markdown', 'content': binding('/content')},
|
||
]
|
||
children = components[0]['children']
|
||
|
||
field = _dingtalk_current_input_field(form_data)
|
||
if field is not None:
|
||
field_name = _dingtalk_field_name(field) or 'value'
|
||
field_type = _dingtalk_field_type(field)
|
||
default = (form_data.get('inputs') or {}).get(field_name, field.get('default'))
|
||
label = str(field.get('label') or field_name)
|
||
placeholder = str(field.get('placeholder') or '')
|
||
if field_type == 'select':
|
||
options = [{'label': option, 'value': option} for option in _dingtalk_select_options(field)]
|
||
data['form']['value'] = [] if default in (None, '') else [str(default)]
|
||
components.append(
|
||
{
|
||
'id': 'field',
|
||
'component': 'ChoicePicker',
|
||
'label': placeholder or label,
|
||
'variant': 'mutuallyExclusive',
|
||
'displayStyle': 'dropdown',
|
||
'options': options,
|
||
'value': binding('/form/value'),
|
||
}
|
||
)
|
||
value_key = 'select'
|
||
elif field_type == 'number':
|
||
data['form']['value'] = default if isinstance(default, (int, float)) else 0
|
||
components.append(
|
||
{
|
||
'id': 'field',
|
||
'component': 'NumberInput',
|
||
'label': label,
|
||
'placeholder': placeholder,
|
||
'value': binding('/form/value'),
|
||
}
|
||
)
|
||
value_key = 'input'
|
||
else:
|
||
data['form']['value'] = '' if default is None else str(default)
|
||
components.append(
|
||
{
|
||
'id': 'field',
|
||
'component': 'TextField',
|
||
'label': label,
|
||
'placeholder': placeholder,
|
||
'variant': 'longText' if field_type == 'textarea' else 'shortText',
|
||
'value': binding('/form/value'),
|
||
}
|
||
)
|
||
value_key = 'input'
|
||
components.extend(
|
||
[
|
||
{
|
||
'id': 'submit-input',
|
||
'component': 'Button',
|
||
'child': 'submit-input-label',
|
||
'variant': 'primary',
|
||
'action': {
|
||
'event': {
|
||
'name': 'langbot_form_input',
|
||
'context': {
|
||
'actionId': '__langbot_input__',
|
||
'out_track_id': out_track_id,
|
||
value_key: binding('/form/value'),
|
||
},
|
||
}
|
||
},
|
||
'checks': [
|
||
{
|
||
'condition': {
|
||
'call': 'required',
|
||
'args': {'value': binding('/form/value')},
|
||
},
|
||
'message': f'{label} is required',
|
||
}
|
||
],
|
||
},
|
||
{'id': 'submit-input-label', 'component': 'Text', 'text': 'Submit'},
|
||
]
|
||
)
|
||
children.extend(['field', 'submit-input'])
|
||
else:
|
||
for index, action in enumerate(form_data.get('actions') or []):
|
||
action_id = str(action.get('id') or '')
|
||
label_id = f'action-label-{index}'
|
||
button_id = f'action-{index}'
|
||
style = str(action.get('button_style') or '')
|
||
components.extend(
|
||
[
|
||
{
|
||
'id': button_id,
|
||
'component': 'Button',
|
||
'child': label_id,
|
||
'variant': 'primary' if style == 'primary' or (not style and index == 0) else 'default',
|
||
'action': {
|
||
'event': {
|
||
'name': 'langbot_form_action',
|
||
'context': {
|
||
'actionId': action_id,
|
||
'out_track_id': out_track_id,
|
||
},
|
||
}
|
||
},
|
||
},
|
||
{
|
||
'id': label_id,
|
||
'component': 'Text',
|
||
'text': str(action.get('title') or action_id or f'Option {index + 1}'),
|
||
},
|
||
]
|
||
)
|
||
children.append(button_id)
|
||
children.insert(0, 'langbot-brand')
|
||
components.extend(brand_components())
|
||
return data, components
|
||
|
||
|
||
class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||
bot: DingTalkClient
|
||
bot_account_id: str
|
||
message_converter: DingTalkMessageConverter = DingTalkMessageConverter()
|
||
event_converter: DingTalkEventConverter = DingTalkEventConverter()
|
||
config: dict
|
||
card_instance_id_dict: (
|
||
dict # 回复卡片消息字典,key为消息id,value为回复卡片实例id,用于在流式消息时判断是否发送到指定卡片
|
||
)
|
||
# outTrackId → form snapshot {session_key, launcher_type, launcher_id, form_token,
|
||
# workflow_run_id, actions, node_title, form_content, expires_at, open_space_id,
|
||
# user_id_hint, current_text}. Lookup keys for the data-source pull endpoint and
|
||
# the STREAM card-action callback.
|
||
card_state: dict
|
||
# session_key → out_track_id of the currently-active card for the
|
||
# conversation turn. Lets resumed-workflow chunks (which arrive on a
|
||
# synthetic event with a fresh resp_message_id) keep updating the same
|
||
# card the user clicked instead of getting a new one.
|
||
active_turn_card: dict
|
||
# session_key → accumulated streaming text for the active turn. Read
|
||
# by _paint_form_on_card so the post-pause form keeps the streamed
|
||
# context above the new prompt.
|
||
active_turn_text: dict
|
||
# event_type → callback. The abstract base class doesn't declare this,
|
||
# so we must do it here or pydantic silently drops `listeners={}` in
|
||
# super().__init__ and any access raises AttributeError.
|
||
listeners: typing.Dict[
|
||
typing.Type[platform_events.Event],
|
||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||
]
|
||
ap: typing.Any = None
|
||
bot_uuid: str = ''
|
||
|
||
def __init__(self, config: dict, logger: EventLogger):
|
||
required_keys = [
|
||
'client_id',
|
||
'client_secret',
|
||
]
|
||
missing_keys = [key for key in required_keys if key not in config]
|
||
if missing_keys:
|
||
raise Exception('钉钉缺少相关配置项,请查看文档或联系管理员')
|
||
bot = DingTalkClient(
|
||
client_id=config['client_id'],
|
||
client_secret=config['client_secret'],
|
||
robot_name=config.get('robot_name', ''),
|
||
robot_code=config.get('robot_code', ''),
|
||
markdown_card=config['markdown_card'],
|
||
logger=logger,
|
||
)
|
||
bot_account_id = config['client_id']
|
||
super().__init__(
|
||
config=config,
|
||
logger=logger,
|
||
card_instance_id_dict={},
|
||
card_state={},
|
||
active_turn_card={},
|
||
active_turn_text={},
|
||
bot_account_id=bot_account_id,
|
||
bot=bot,
|
||
listeners={},
|
||
)
|
||
self._background_tasks: set[asyncio.Task] = set()
|
||
# Wire the card-action callback after super().__init__ so we can reference
|
||
# self.* — the client's handler stores this as a soft reference and reads
|
||
# it at fire time.
|
||
self.bot.card_action_callback = self._on_card_action
|
||
|
||
def _start_background_task(self, coro) -> bool:
|
||
"""Start one bounded adapter-side auxiliary task."""
|
||
|
||
background_tasks = getattr(self, '_background_tasks', None)
|
||
if background_tasks is None:
|
||
background_tasks = set()
|
||
object.__setattr__(self, '_background_tasks', background_tasks)
|
||
for task in tuple(background_tasks):
|
||
if task.done():
|
||
background_tasks.discard(task)
|
||
if len(background_tasks) >= 100:
|
||
coro.close()
|
||
return False
|
||
task = asyncio.create_task(coro)
|
||
background_tasks.add(task)
|
||
|
||
def done(done_task: asyncio.Task) -> None:
|
||
background_tasks.discard(done_task)
|
||
if not done_task.cancelled():
|
||
done_task.exception()
|
||
|
||
task.add_done_callback(done)
|
||
return True
|
||
|
||
def _prune_card_state(self) -> None:
|
||
now = time.monotonic()
|
||
ttl_seconds = 1800
|
||
for card_id, state in tuple(self.card_state.items()):
|
||
if now - float(state.get('created_at', now)) <= ttl_seconds:
|
||
continue
|
||
self.card_state.pop(card_id, None)
|
||
for session_key, active_card_id in tuple(self.active_turn_card.items()):
|
||
if active_card_id == card_id:
|
||
self.active_turn_card.pop(session_key, None)
|
||
self.active_turn_text.pop(session_key, None)
|
||
while len(self.card_state) > 1000:
|
||
card_id = next(iter(self.card_state))
|
||
self.card_state.pop(card_id, None)
|
||
while len(self.active_turn_card) > 1000:
|
||
session_key = next(iter(self.active_turn_card))
|
||
self.active_turn_card.pop(session_key, None)
|
||
self.active_turn_text.pop(session_key, None)
|
||
card_instances = getattr(self, 'card_instance_id_dict', None)
|
||
if isinstance(card_instances, dict):
|
||
while len(card_instances) > 1000:
|
||
card_instances.pop(next(iter(card_instances)), None)
|
||
|
||
async def reply_message(
|
||
self,
|
||
message_source: platform_events.MessageEvent,
|
||
message: platform_message.MessageChain,
|
||
quote_origin: bool = False,
|
||
):
|
||
event = await DingTalkEventConverter.yiri2target(
|
||
message_source,
|
||
)
|
||
incoming_message = event.incoming_message
|
||
|
||
markdown_enabled = self.config.get('markdown_card', False)
|
||
text_message = platform_message.MessageChain(
|
||
[part for part in message if not isinstance(part, (platform_message.Image, platform_message.File))]
|
||
)
|
||
content, at = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled)
|
||
await self.bot.send_message(content, incoming_message, at)
|
||
for part in message:
|
||
if isinstance(part, (platform_message.Image, platform_message.File)):
|
||
await self.bot.send_attachment(part, incoming_message)
|
||
|
||
async def reply_message_chunk(
|
||
self,
|
||
message_source: platform_events.MessageEvent,
|
||
bot_message,
|
||
message: platform_message.MessageChain,
|
||
quote_origin: bool = False,
|
||
is_final: bool = False,
|
||
):
|
||
message_id = bot_message.resp_message_id
|
||
msg_seq = bot_message.msg_sequence
|
||
|
||
form_data = getattr(bot_message, '_form_data', None)
|
||
if is_final and self.ap is not None:
|
||
self.ap.logger.info(
|
||
f'DingTalk reply_message_chunk final: form_data_present={form_data is not None}, a2ui_enabled=True'
|
||
)
|
||
|
||
if form_data and is_final:
|
||
await self._handle_form_chunk(message_source, bot_message, message, form_data)
|
||
return
|
||
|
||
if (msg_seq - 1) % 8 == 0 or is_final:
|
||
markdown_enabled = self.config.get('markdown_card', False)
|
||
text_message = platform_message.MessageChain(
|
||
[part for part in message if not isinstance(part, (platform_message.Image, platform_message.File))]
|
||
)
|
||
content, at = await DingTalkMessageConverter.yiri2target(text_message, markdown_enabled)
|
||
if not content and bot_message.content:
|
||
content = bot_message.content # 兼容直接传入content的情况
|
||
|
||
chat_card_entry = self.card_instance_id_dict.get(message_id)
|
||
if chat_card_entry is None:
|
||
# No streaming chat card was created for this query — common
|
||
# path for synthetic events (e.g. resumed workflow after a
|
||
# button click). Lazy-create one so the resumed output streams
|
||
# into a card just like a normal conversation, instead of
|
||
# being deferred and sent in one shot on is_final.
|
||
if not content:
|
||
return # nothing to stream yet
|
||
chat_card_entry = await self._lazy_create_resume_chat_card(message_source, message_id)
|
||
if chat_card_entry is None:
|
||
# Lazy-create failed (no template configured); fall back
|
||
# to a one-shot proactive message on the final chunk.
|
||
if is_final:
|
||
await self._send_proactive_to_event(message_source, content)
|
||
return
|
||
|
||
card_instance, card_instance_id = chat_card_entry
|
||
if content:
|
||
await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)
|
||
session_key = self._session_key_from_event(message_source)
|
||
if session_key:
|
||
self.active_turn_text[session_key] = content
|
||
if is_final:
|
||
if not content:
|
||
session_key = self._session_key_from_event(message_source)
|
||
final_content = self.active_turn_text.get(session_key, '') if session_key else ''
|
||
await self.bot.send_card_message(card_instance, card_instance_id, final_content, True)
|
||
if bot_message.tool_calls is None:
|
||
self.card_instance_id_dict.pop(message_id, None)
|
||
event = await DingTalkEventConverter.yiri2target(message_source)
|
||
for part in message:
|
||
if isinstance(part, (platform_message.Image, platform_message.File)):
|
||
await self.bot.send_attachment(part, event.incoming_message)
|
||
|
||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||
markdown_enabled = self.config.get('markdown_card', False)
|
||
content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
|
||
if target_type == 'person':
|
||
await self.bot.send_proactive_message_to_one(target_id, content)
|
||
if target_type == 'group':
|
||
await self.bot.send_proactive_message_to_group(target_id, content)
|
||
|
||
async def is_stream_output_supported(self) -> bool:
|
||
return is_stream_card_configured(self.config)
|
||
|
||
async def create_message_card(self, message_id, event):
|
||
self._prune_card_state()
|
||
|
||
# Synthetic events (button clicks): look up the card already in
|
||
# active_turn_card so reply_message_chunk can stream to it.
|
||
if event is None or event.source_platform_object is None:
|
||
session_key = self._session_key_from_event(event) if event is not None else ''
|
||
carry = self.active_turn_card.get(session_key, '') if session_key else ''
|
||
if carry:
|
||
self.card_instance_id_dict[message_id] = ({'surface_id': f'langbot-{carry}'}, carry)
|
||
return True
|
||
return False
|
||
|
||
incoming_message = event.source_platform_object.incoming_message
|
||
card_instance, card_instance_id = await self.bot.create_and_card(incoming_message)
|
||
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
|
||
session_key = self._session_key_from_event(event)
|
||
if session_key:
|
||
self.active_turn_card[session_key] = card_instance_id
|
||
self.active_turn_text[session_key] = ''
|
||
return True
|
||
|
||
def _session_key_from_event(self, event) -> str:
|
||
"""Return launcher_type_launcher_id for an event, '' if unrecoverable."""
|
||
if event is None:
|
||
return ''
|
||
spo = event.source_platform_object
|
||
if spo is None:
|
||
try:
|
||
if isinstance(event, platform_events.GroupMessage):
|
||
return f'group_{event.group.id}'
|
||
return f'person_{event.sender.id}'
|
||
except Exception:
|
||
return ''
|
||
try:
|
||
inc = spo.incoming_message
|
||
if str(inc.conversation_type) == '2':
|
||
return f'group_{inc.conversation_id}'
|
||
return f'person_{inc.sender_staff_id}'
|
||
except Exception:
|
||
return ''
|
||
|
||
def register_listener(
|
||
self,
|
||
event_type: typing.Type[platform_events.Event],
|
||
callback: typing.Callable[
|
||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||
],
|
||
):
|
||
async def on_message(event: DingTalkEvent):
|
||
try:
|
||
self.bot_account_id = getattr(event.incoming_message, 'chatbot_user_id', None) or self.bot_account_id
|
||
return await callback(
|
||
await self.event_converter.target2yiri(event, self.bot_account_id),
|
||
self,
|
||
)
|
||
except Exception:
|
||
await self.logger.error(f'Error in dingtalk callback: {traceback.format_exc()}')
|
||
|
||
if event_type == platform_events.FriendMessage:
|
||
self.bot.on_message('FriendMessage')(on_message)
|
||
elif event_type == platform_events.GroupMessage:
|
||
self.bot.on_message('GroupMessage')(on_message)
|
||
|
||
async def run_async(self):
|
||
await self.bot.start()
|
||
|
||
async def kill(self) -> bool:
|
||
task_set = getattr(self, '_background_tasks', set())
|
||
background_tasks = list(task_set)
|
||
for task in background_tasks:
|
||
if not task.done():
|
||
task.cancel()
|
||
if background_tasks:
|
||
await asyncio.gather(*background_tasks, return_exceptions=True)
|
||
task_set.clear()
|
||
card_instances = getattr(self, 'card_instance_id_dict', None)
|
||
if isinstance(card_instances, dict):
|
||
card_instances.clear()
|
||
self.card_state.clear()
|
||
self.active_turn_card.clear()
|
||
self.active_turn_text.clear()
|
||
await self.bot.stop()
|
||
return True
|
||
|
||
async def is_muted(self) -> bool:
|
||
return False
|
||
|
||
async def unregister_listener(
|
||
self,
|
||
event_type: type,
|
||
callback: typing.Callable[
|
||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||
],
|
||
):
|
||
return super().unregister_listener(event_type, callback)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Dify human-input form support
|
||
# ------------------------------------------------------------------
|
||
|
||
def set_bot_uuid(self, bot_uuid: str):
|
||
"""Receive the bot uuid from the platform manager.
|
||
|
||
Used to compose the public-facing unified-webhook URL for the card
|
||
dynamic-data-source pull endpoint.
|
||
"""
|
||
self.bot_uuid = bot_uuid
|
||
|
||
def _derive_open_space(self, message_source: platform_events.MessageEvent) -> tuple[str, bool]:
|
||
"""Return (openSpaceId, is_group) for the given inbound event."""
|
||
if isinstance(message_source, platform_events.GroupMessage):
|
||
return f'dtv1.card//im_group.{message_source.group.id}', True
|
||
return f'dtv1.card//im_robot.{message_source.sender.id}', False
|
||
|
||
def _derive_session_descriptor(
|
||
self, message_source: platform_events.MessageEvent
|
||
) -> tuple[provider_session.LauncherTypes, str, str]:
|
||
"""Return (launcher_type, launcher_id, sender_user_id) for routing."""
|
||
if isinstance(message_source, platform_events.GroupMessage):
|
||
return (
|
||
provider_session.LauncherTypes.GROUP,
|
||
str(message_source.group.id),
|
||
str(message_source.sender.id),
|
||
)
|
||
return (
|
||
provider_session.LauncherTypes.PERSON,
|
||
str(message_source.sender.id),
|
||
str(message_source.sender.id),
|
||
)
|
||
|
||
async def _handle_form_chunk(
|
||
self,
|
||
message_source: platform_events.MessageEvent,
|
||
bot_message,
|
||
message: platform_message.MessageChain,
|
||
form_data: dict,
|
||
) -> None:
|
||
"""Surface a human-input prompt on the active A2UI card."""
|
||
if self.ap is not None:
|
||
self.ap.logger.info(
|
||
f'DingTalk _handle_form_chunk: actions={len(form_data.get("actions") or [])}, '
|
||
f'node_title={form_data.get("node_title", "")!r}'
|
||
)
|
||
message_id = bot_message.resp_message_id
|
||
session_key = self._session_key_from_event(message_source)
|
||
entry = self.card_instance_id_dict.get(message_id)
|
||
out_track_id = entry[1] if entry else None
|
||
if not out_track_id and session_key:
|
||
out_track_id = self.active_turn_card.get(session_key, '')
|
||
if out_track_id:
|
||
await self._paint_form_on_card(message_source, out_track_id, form_data, session_key)
|
||
else:
|
||
await self._send_form_card(message_source, form_data)
|
||
self.card_instance_id_dict.pop(message_id, None)
|
||
|
||
async def _paint_form_on_card(
|
||
self,
|
||
message_source: platform_events.MessageEvent,
|
||
out_track_id: str,
|
||
form_data: dict,
|
||
session_key: str,
|
||
) -> None:
|
||
"""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 = _dingtalk_clean_form_content(form_data)
|
||
should_show_actions = not _dingtalk_pending_input_defs(form_data)
|
||
|
||
# Record form state for the click-handler.
|
||
self._prune_card_state()
|
||
launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source)
|
||
self.card_state[out_track_id] = {
|
||
'session_key': session_key,
|
||
'launcher_type': launcher_type.value,
|
||
'launcher_id': launcher_id,
|
||
'sender_user_id': sender_user_id,
|
||
'form_token': form_data.get('form_token', ''),
|
||
'workflow_run_id': form_data.get('workflow_run_id', ''),
|
||
'pipeline_uuid': form_data.get('pipeline_uuid', ''),
|
||
'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 {},
|
||
'created_at': time.monotonic(),
|
||
}
|
||
|
||
prior = self.active_turn_text.get(session_key, '') if session_key else ''
|
||
data, components = _dingtalk_a2ui_form(form_data, out_track_id, prior_content=prior)
|
||
|
||
try:
|
||
await self.bot.update_ai_card(
|
||
out_track_id=out_track_id,
|
||
a2ui_messages=update_surface_messages(
|
||
f'langbot-{out_track_id}',
|
||
data=data,
|
||
components=components,
|
||
),
|
||
flow_status='CONFIRMING',
|
||
)
|
||
except Exception:
|
||
if self.ap is not None:
|
||
self.ap.logger.exception('DingTalk: paint form on card failed')
|
||
await self.send_message_text_form(message_source, form_data)
|
||
return
|
||
|
||
async def _send_form_card(
|
||
self,
|
||
message_source: platform_events.MessageEvent,
|
||
form_data: dict,
|
||
) -> None:
|
||
"""Deliver a new card pre-loaded with the human-input prompt + buttons."""
|
||
out_track_id = uuid.uuid4().hex
|
||
open_space_id, is_group = self._derive_open_space(message_source)
|
||
launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source)
|
||
session_key = f'{launcher_type.value}_{launcher_id}'
|
||
|
||
actions = list(form_data.get('actions') or [])
|
||
node_title = form_data.get('node_title', '') or 'Human Input Required'
|
||
form_content = _dingtalk_clean_form_content(form_data)
|
||
should_show_actions = not _dingtalk_pending_input_defs(form_data)
|
||
|
||
self._prune_card_state()
|
||
self.card_state[out_track_id] = {
|
||
'session_key': session_key,
|
||
'launcher_type': launcher_type.value,
|
||
'launcher_id': launcher_id,
|
||
'sender_user_id': sender_user_id,
|
||
'form_token': form_data.get('form_token', ''),
|
||
'workflow_run_id': form_data.get('workflow_run_id', ''),
|
||
'pipeline_uuid': form_data.get('pipeline_uuid', ''),
|
||
'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,
|
||
'created_at': time.monotonic(),
|
||
}
|
||
|
||
data, components = _dingtalk_a2ui_form(form_data, out_track_id)
|
||
|
||
try:
|
||
if self.ap is not None:
|
||
self.ap.logger.info(
|
||
f'DingTalk _send_form_card: out_track_id={out_track_id} '
|
||
f'open_space_id={open_space_id} is_group={is_group} actions={len(actions)}'
|
||
)
|
||
await self.bot.create_and_deliver_ai_card(
|
||
out_track_id=out_track_id,
|
||
open_space_id=open_space_id,
|
||
is_group=is_group,
|
||
a2ui_messages=create_surface_messages(
|
||
f'langbot-{out_track_id}',
|
||
data=data,
|
||
components=components,
|
||
),
|
||
flow_status='CONFIRMING',
|
||
summary=node_title,
|
||
callback_type='STREAM',
|
||
)
|
||
except Exception:
|
||
await self.logger.error(f'DingTalk: deliver form card failed: {traceback.format_exc()}')
|
||
await self.send_message_text_form(message_source, form_data)
|
||
self.card_state.pop(out_track_id, None)
|
||
|
||
async def _lazy_create_resume_chat_card(
|
||
self,
|
||
message_source: platform_events.MessageEvent,
|
||
message_id: str,
|
||
) -> typing.Optional[tuple]:
|
||
"""Create a new card for resumed-workflow streaming output.
|
||
|
||
Used after a button click triggers a synthetic event — the form
|
||
card stays put with the selection notice, and a fresh card is
|
||
spawned here for the LLM reply to stream into.
|
||
"""
|
||
out_track_id = uuid.uuid4().hex
|
||
open_space_id, is_group = self._derive_open_space(message_source)
|
||
surface_id = f'langbot-{out_track_id}'
|
||
try:
|
||
await self.bot.create_and_deliver_ai_card(
|
||
out_track_id=out_track_id,
|
||
open_space_id=open_space_id,
|
||
is_group=is_group,
|
||
a2ui_messages=create_text_card_messages(surface_id),
|
||
flow_status='PROCESSING',
|
||
summary='LangBot is replying',
|
||
callback_type='STREAM',
|
||
)
|
||
except Exception:
|
||
if self.ap is not None:
|
||
self.ap.logger.exception('DingTalk: lazy create resume chat card failed')
|
||
return None
|
||
entry = ({'surface_id': surface_id}, out_track_id)
|
||
self.card_instance_id_dict[message_id] = entry
|
||
# Register as the active card so any further chunks on this turn
|
||
# (and a subsequent re-pause) land on the same new card.
|
||
session_key = self._session_key_from_event(message_source)
|
||
if session_key:
|
||
self.active_turn_card[session_key] = out_track_id
|
||
self.active_turn_text[session_key] = ''
|
||
return entry
|
||
|
||
async def send_message_text_form(
|
||
self,
|
||
message_source: platform_events.MessageEvent,
|
||
form_data: dict,
|
||
) -> None:
|
||
"""Fallback: send the human-input prompt as plain text."""
|
||
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(
|
||
self,
|
||
message_source: platform_events.MessageEvent,
|
||
content: str,
|
||
) -> None:
|
||
"""Send `content` as a proactive message to the conversation behind
|
||
`message_source`. Used when no inbound chatbot message exists to
|
||
anchor a card on (e.g. resumed flows triggered by card actions).
|
||
"""
|
||
if not content:
|
||
return
|
||
if self.ap is not None:
|
||
target = (
|
||
str(message_source.group.id)
|
||
if isinstance(message_source, platform_events.GroupMessage)
|
||
else str(message_source.sender.id)
|
||
)
|
||
self.ap.logger.info(
|
||
f'DingTalk _send_proactive_to_event: target={target} '
|
||
f'is_group={isinstance(message_source, platform_events.GroupMessage)} content_len={len(content)}'
|
||
)
|
||
try:
|
||
if isinstance(message_source, platform_events.GroupMessage):
|
||
await self.bot.send_proactive_message_to_group(str(message_source.group.id), content)
|
||
else:
|
||
await self.bot.send_proactive_message_to_one(str(message_source.sender.id), content)
|
||
except Exception:
|
||
if self.ap is not None:
|
||
self.ap.logger.exception('DingTalk: send proactive message failed')
|
||
await self.logger.error(f'DingTalk: send proactive message failed: {traceback.format_exc()}')
|
||
|
||
async def _on_card_action(self, payload: dict) -> None:
|
||
"""Translate a card button click into a synthetic query.
|
||
|
||
Reads the clicked button's ``actionId`` (the real Dify action id —
|
||
the ButtonGroup template sends it back via `event.params.actionId`),
|
||
recovers the action title from ``card_state``, and enqueues a
|
||
synthetic `_dify_form_action` query the same way Lark / Telegram do.
|
||
"""
|
||
if self.ap is not None:
|
||
self.ap.logger.info(
|
||
f'DingTalk _on_card_action received: out_track_id={payload.get("out_track_id")} '
|
||
f'payload_action_id={payload.get("action_id")!r} params={payload.get("params")!r}'
|
||
)
|
||
out_track_id = payload.get('out_track_id') or ''
|
||
self._prune_card_state()
|
||
params = payload.get('params') or {}
|
||
# ButtonGroup `sendCardRequest` events surface the click id at the
|
||
# callback top level as `actionId`; fall back to `params.action_id`
|
||
# (alternate template wiring) and `params.actionId`.
|
||
raw_action_id = (
|
||
(payload.get('action_id') or '').strip()
|
||
or (params.get('action_id') or '').strip()
|
||
or (params.get('actionId') or '').strip()
|
||
or (params.get('id') or '').strip()
|
||
)
|
||
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
|
||
if raw_action_id not in known_action_ids:
|
||
await self.logger.warning(
|
||
f'DingTalk: card action_id={raw_action_id!r} is not present on out_track_id={out_track_id}'
|
||
)
|
||
return
|
||
|
||
action_id = raw_action_id
|
||
action_title = raw_action_id
|
||
for action in actions:
|
||
if str(action.get('id', '')) == raw_action_id:
|
||
action_title = action.get('title') or raw_action_id
|
||
break
|
||
|
||
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', '')
|
||
initiator_user_id = str(state.get('sender_user_id') or '')
|
||
actor_user_id = str(payload.get('user_id') or initiator_user_id or launcher_id)
|
||
if (
|
||
launcher_type == provider_session.LauncherTypes.PERSON
|
||
and initiator_user_id
|
||
and actor_user_id != initiator_user_id
|
||
):
|
||
await self.logger.warning(
|
||
f'DingTalk: user {actor_user_id} cannot act on private form created for {initiator_user_id}'
|
||
)
|
||
return
|
||
|
||
form_action_data = {
|
||
'form_token': state.get('form_token', ''),
|
||
'workflow_run_id': state.get('workflow_run_id', ''),
|
||
'action_id': action_id,
|
||
'action_title': action_title,
|
||
'node_title': state.get('node_title', ''),
|
||
'user': f'{launcher_type.value}_{launcher_id}',
|
||
'inputs': {},
|
||
}
|
||
|
||
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')])
|
||
|
||
if launcher_type == provider_session.LauncherTypes.GROUP:
|
||
synthetic_event = platform_events.GroupMessage(
|
||
sender=platform_entities.GroupMember(
|
||
id=actor_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=actor_user_id,
|
||
nickname='',
|
||
remark='',
|
||
),
|
||
message_chain=message_chain,
|
||
time=int(datetime.datetime.now().timestamp()),
|
||
source_platform_object=None,
|
||
)
|
||
|
||
bot_uuid = ''
|
||
pipeline_uuid = state.get('pipeline_uuid') or None
|
||
if self.ap is not None:
|
||
for bot in self.ap.platform_mgr.bots:
|
||
if bot.adapter is self:
|
||
bot_uuid = bot.bot_entity.uuid
|
||
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
|
||
break
|
||
|
||
try:
|
||
self.ap.logger.info(
|
||
f'DingTalk _on_card_action enqueuing form action: action_id={action_id!r} '
|
||
f'action_title={action_title!r} launcher_type={launcher_type.value} '
|
||
f'launcher_id={launcher_id} actor_user_id={actor_user_id} '
|
||
f'bot_uuid={bot_uuid} pipeline_uuid={pipeline_uuid}'
|
||
)
|
||
await self.ap.query_pool.add_query(
|
||
bot_uuid=bot_uuid,
|
||
launcher_type=launcher_type,
|
||
launcher_id=launcher_id,
|
||
sender_id=actor_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('DingTalk _on_card_action: query enqueued OK')
|
||
except Exception:
|
||
self.ap.logger.exception('DingTalk: enqueue form action query failed')
|
||
return
|
||
|
||
# Visual feedback on the form card itself: keep the prompt visible,
|
||
# add a selection line, remove the buttons. The resumed-workflow
|
||
# output lives on a separate new card (lazy-created in
|
||
# reply_message_chunk on the synthetic event), so the form card
|
||
# stays put as a record of the user's selection.
|
||
self._start_background_task(
|
||
self._mark_card_resolved(
|
||
out_track_id,
|
||
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 {},
|
||
)
|
||
)
|
||
|
||
# Crucial: do NOT leave the form card's out_track_id in
|
||
# active_turn_card — otherwise create_message_card for the
|
||
# synthetic event would reuse it for the resume output, painting
|
||
# the LLM reply on top of the selection notice. Clear it so the
|
||
# resume goes through the lazy-create path and spawns a fresh card.
|
||
session_key = state.get('session_key', '')
|
||
if session_key and self.active_turn_card.get(session_key) == out_track_id:
|
||
self.active_turn_card.pop(session_key, None)
|
||
self.active_turn_text.pop(session_key, None)
|
||
|
||
# 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', '')
|
||
initiator_user_id = str(state.get('sender_user_id') or '')
|
||
actor_user_id = str(payload.get('user_id') or initiator_user_id or launcher_id)
|
||
if (
|
||
launcher_type == provider_session.LauncherTypes.PERSON
|
||
and initiator_user_id
|
||
and actor_user_id != initiator_user_id
|
||
):
|
||
await self.logger.warning(
|
||
f'DingTalk: user {actor_user_id} cannot update private form created for {initiator_user_id}'
|
||
)
|
||
return
|
||
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=actor_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=actor_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 = state.get('pipeline_uuid') or None
|
||
for bot in self.ap.platform_mgr.bots:
|
||
if bot.adapter is self:
|
||
bot_uuid = bot.bot_entity.uuid
|
||
pipeline_uuid = pipeline_uuid or 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=actor_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,
|
||
action_title: str,
|
||
*,
|
||
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.
|
||
|
||
Keeps the original prompt visible, adds a selection notice, and
|
||
clears the buttons. The card stays as a permanent record of the
|
||
choice; the resumed workflow's output goes to a separate new card.
|
||
"""
|
||
parts: list[str] = []
|
||
if node_title:
|
||
parts.append(f'**{node_title}**')
|
||
if form_content:
|
||
parts.append(_dingtalk_card_markdown(form_content))
|
||
missing_completed_lines = _dingtalk_missing_completed_input_lines(
|
||
{
|
||
'input_defs': input_defs or [],
|
||
'inputs': inputs or {},
|
||
},
|
||
form_content,
|
||
)
|
||
if missing_completed_lines:
|
||
parts.append('<hr>' + '<br>'.join(missing_completed_lines))
|
||
parts.append(f'<hr>✅ {action_title}')
|
||
content = '<br><br>'.join(parts)
|
||
if self.ap is not None:
|
||
self.ap.logger.info(f'DingTalk _mark_card_resolved: out_track_id={out_track_id} action={action_title!r}')
|
||
try:
|
||
await self.bot.update_ai_card(
|
||
out_track_id=out_track_id,
|
||
a2ui_messages=update_surface_messages(
|
||
f'langbot-{out_track_id}',
|
||
data={'content': content},
|
||
components=[
|
||
{'id': 'root', 'component': 'Column', 'children': ['content'], 'gap': 8},
|
||
{'id': 'content', 'component': 'Markdown', 'content': binding('/content')},
|
||
],
|
||
),
|
||
flow_status='CONFIRMED',
|
||
)
|
||
except Exception:
|
||
if self.ap is not None:
|
||
self.ap.logger.exception('DingTalk: mark card resolved failed')
|