Files
LangBot/src/langbot/pkg/platform/sources/dingtalk.py
T

1426 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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![image]({msg.url})\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![image]({msg.base64})\n'
else:
return f'\n![image](data:image/png;base64,{msg.base64})\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')