feat(dingtalk): implement human input card support and card action handling

- Add a new module `card_callback.py` to handle card action button clicks from DingTalk.
- Introduce `DingTalkCardActionHandler` to process card action callbacks and extract parameters.
- Update `DingTalkAdapter` to manage card state and handle form input through a single card template.
- Add configuration for `human_input_card_template_id` in `dingtalk.yaml` to specify the template for human input.
- Create a new card template `dingtalk_human_input_card.json` for rendering human input prompts and buttons.
This commit is contained in:
fdc310
2026-06-15 15:51:52 +08:00
parent e08b5db625
commit 83b0d26e99
6 changed files with 1630 additions and 41 deletions
+648
View File
@@ -0,0 +1,648 @@
"""Generate the DingTalk human-input card template JSON.
The output is wrapped in the {editorData, widgetInfo, type, mode} envelope
the DingTalk card builder expects on import. editorData is itself a JSON
string (NOT a nested object), matching real exports from the builder.
Run from the repo root: python scripts/build_dingtalk_card_template.py
"""
from __future__ import annotations
import json
from pathlib import Path
OUTPUT = Path('src/langbot/templates/dingtalk_human_input_card.json')
def markdown_block(node_id, variable='content'):
"""A MarkdownBlock whose content is bound to a global variable.
Unlike BaseText (whose `text` field requires editor-side manual binding),
MarkdownBlock's `content` accepts `variableValue` binding at JSON load
time — the imported template renders the variable straight away.
"""
return {
'componentName': 'MarkdownBlock',
'id': node_id,
'props': {
'mdVer': 0,
'icon': {'type': 'icon', 'icon': '', 'iconType': 'emoji'},
'content': {'variable': variable, 'variableType': 'global', 'type': 'variableValue'},
'visible': {
'type': 'dynamicVisible',
'value': True,
'valueType': 'fixed',
'condition': {'op': 'and', 'conditions': []},
},
'isStreaming': False,
'enableLinkStatPoint': False,
'linkStatPoint': {'type': 'dynamicString', 'content': '', 'i18n': False},
'linkStatPointParams': [],
'marginTop': 6,
'marginBottom': 6,
'marginLeft': 12,
'marginRight': 12,
},
'title': 'AI 流式富文本',
'hidden': False,
'isLocked': False,
'condition': True,
'conditionGroup': '',
}
def text_block(
node_id,
text,
*,
bold=False,
gravity='left',
font_size=14,
line_height=22,
max_lines=20,
ml=12,
mr=12,
mt=4,
mb=4,
color_token='common_level1_base_color',
style_token='common_body_text_style',
):
return {
'componentName': 'BaseText',
'id': node_id,
'props': {
'text': {'i18n': False, 'type': 'dynamicString', 'content': text},
'hoverText': {'type': 'dynamicString', 'content': '', 'i18n': False},
'iconType': 'iconCode',
'iconFont': {'type': 'icon', 'icon': '', 'iconType': 'ddIcon'},
'icon': {
'type': 'dynamicLink',
'value': '',
'valueType': 'fixed',
'variable': '',
'variableType': 'global',
},
'darkIcon': {
'type': 'dynamicLink',
'value': '',
'valueType': 'fixed',
'variable': '',
'variableType': 'global',
},
'autoWidth': False,
'maxWidth': {
'type': 'dynamicNumber',
'valueType': 'fixed',
'value': 0,
'variable': '',
'variableType': 'global',
},
'fixedWidth': {
'type': 'dynamicNumber',
'valueType': 'fixed',
'value': 0,
'variable': '',
'variableType': 'global',
},
'marginLeft': ml,
'marginRight': mr,
'marginTop': mt,
'marginBottom': mb,
'fontColorType': 'Standard',
'enableHighlight': False,
'maxLine': {
'type': 'dynamicNumber',
'valueType': 'fixed',
'value': max_lines,
'variable': '',
'variableType': 'global',
},
'color': {
'type': 'dynamicColor',
'valueType': 'fixed',
'value': color_token,
'variable': '',
'variableType': 'global',
},
'customLightColor': {
'type': 'dynamicColor',
'valueType': 'fixed',
'value': '#35404b',
'variable': '',
'variableType': 'global',
},
'customDarkColor': {
'type': 'dynamicColor',
'valueType': 'fixed',
'value': '#f6f6f6',
'variable': '',
'variableType': 'global',
},
'gravity': gravity,
'fontSizeType': 'Standard',
'styleType': 'custom',
'styleToken': style_token,
'size': 'middle',
'customFontSize': font_size,
'customFontLineHeight': line_height,
'bold': bold,
'italic': False,
'strikeThrough': False,
'lineHeight': 'normal',
'visible': {
'type': 'dynamicVisible',
'value': True,
'valueType': 'fixed',
'condition': {'op': 'and', 'conditions': []},
},
'autoMaxWidth': False,
'innerOffset': 0,
'enableIcon': False,
'widthMode': 'match_parent',
'margin': -2,
},
'title': '基础文本',
'hidden': False,
'isLocked': False,
'condition': True,
'conditionGroup': '',
}
def button_group(node_id):
return {
'componentName': 'ButtonGroup',
'id': node_id,
'props': {
'dynamicButtons': {'type': 'variableValue', 'variableType': 'global', 'variable': 'btns'},
'marginLeft': 12,
'marginRight': 12,
'marginTop': 6,
'marginBottom': 12,
'visible': {
'type': 'dynamicVisible',
'value': True,
'valueType': 'fixed',
'condition': {'op': 'and', 'conditions': []},
},
'responsiveLayoutWidth': 350,
'buttonsSource': 'variable',
'fixedButtonIds': [],
'fixedButtons': [],
'enableResponsiveLayout': False,
'matchContent': False,
'buttonSpacing': 8,
'margin': -2,
'innerOffset': 0,
},
'title': '按钮组',
'hidden': False,
'isLocked': False,
'condition': True,
'conditionGroup': '',
}
def build_editor_data():
component_names = [
'AIPending',
'AICardStatusContainer',
'BaseText',
'AICardContent',
'AICardContainer',
'ButtonGroup',
'MarkdownBlock',
]
components_map = [
{
'package': '@ali/dxComponent',
'version': '1.0.0',
'exportName': n,
'main': './src/index.tsx',
'destructuring': False,
'subName': '',
'componentName': n,
}
for n in component_names
]
pending_state = {
'componentName': 'AICardStatusContainer',
'id': 'node_status_pending',
'props': {
'status': 1,
'marginLeft': 0,
'marginRight': 0,
'marginTop': 0,
'marginBottom': 0,
'enableExtend': False,
'autoFoldConfig': {
'needFold': True,
'heightLimit': 480,
'foldStatusLocalDataKey': '_cardFoldStatusLocalDataKey',
},
'innerOffset': 0,
'enableCollapse': False,
'margin': -2,
},
'title': '处理中状态',
'hidden': False,
'isLocked': False,
'condition': True,
'conditionGroup': '',
'children': [
{
'componentName': 'AIPending',
'id': 'node_pending_inner',
'props': {
'marginLeft': 0,
'marginRight': 0,
'marginTop': 0,
'marginBottom': 0,
'pendingTip': {'type': 'dynamicString', 'content': '处理中...', 'i18n': False},
'style': 'embed',
'hideIcon': False,
},
'hidden': False,
'title': '',
'isLocked': False,
'condition': True,
'conditionGroup': '',
}
],
}
done_state = {
'componentName': 'AICardStatusContainer',
'id': 'node_status_done',
'props': {
'status': 3,
'marginLeft': 0,
'marginRight': 0,
'marginTop': 0,
'marginBottom': 0,
'enableExtend': False,
'autoFoldConfig': {
'needFold': True,
'heightLimit': 480,
'foldStatusLocalDataKey': '_cardFoldStatusLocalDataKey',
},
'innerOffset': 0,
'enableCollapse': False,
'margin': -2,
},
'title': '完成状态',
'hidden': False,
'isLocked': False,
'condition': True,
'conditionGroup': '',
'children': [
{
'componentName': 'AICardContent',
'id': 'node_done_content',
'props': {
'marginLeft': 0,
'marginRight': 0,
'marginTop': 0,
'marginBottom': 0,
'visible': {
'type': 'dynamicVisible',
'value': True,
'valueType': 'fixed',
'condition': {'op': 'and', 'conditions': []},
},
'innerOffset': 0,
'disabledWhileForward': False,
'statPoint': {'type': 'dynamicString', 'content': '', 'i18n': False},
'statPointParams': [
{'type': 'fixed', 'variable': '', 'value': '', 'name': '', 'variableType': 'global', 'id': '1'}
],
'margin': -2,
'transformToEventChain': False,
'enableStatPoint': False,
},
'hidden': False,
'title': '',
'isLocked': False,
'condition': True,
'conditionGroup': '',
'children': [
markdown_block('node_text_content', variable='content'),
button_group('node_btn_group'),
],
}
],
}
failed_state = {
'componentName': 'AICardStatusContainer',
'id': 'node_status_failed',
'props': {
'status': 5,
'marginLeft': 0,
'marginRight': 0,
'marginTop': 0,
'marginBottom': 0,
'enableExtend': False,
'autoFoldConfig': {
'needFold': True,
'heightLimit': 480,
'foldStatusLocalDataKey': '_cardFoldStatusLocalDataKey',
},
'innerOffset': 0,
'enableCollapse': False,
'margin': -2,
},
'title': '失败状态',
'hidden': False,
'isLocked': False,
'condition': True,
'conditionGroup': '',
'children': [
{
'componentName': 'AICardContent',
'id': 'node_failed_content',
'props': {
'visible': {
'type': 'dynamicVisible',
'value': True,
'valueType': 'fixed',
'condition': {'op': 'and', 'conditions': []},
},
'marginLeft': 0,
'marginRight': 0,
'marginTop': 0,
'marginBottom': 0,
'innerOffset': 0,
'disabledWhileForward': False,
'statPoint': {'type': 'dynamicString', 'content': '', 'i18n': False},
'statPointParams': [
{'type': 'fixed', 'variable': '', 'value': '', 'name': '', 'variableType': 'global', 'id': '1'}
],
'margin': -2,
'transformToEventChain': False,
'enableStatPoint': False,
},
'hidden': False,
'title': '',
'isLocked': False,
'condition': True,
'conditionGroup': '',
'children': [
text_block(
'node_failed_text',
'操作失败,请稍后重试。',
gravity='center',
mt=10,
mb=10,
ml=10,
mr=10,
max_lines=2,
font_size=15,
)
],
}
],
}
root = {
'componentName': 'AICardContainer',
'id': 'node_root',
'props': {
'marginLeft': 0,
'marginRight': 0,
'marginTop': 0,
'marginBottom': 0,
'enablePending': True,
'enableWriting': False,
'enableDoing': False,
'enableFailed': True,
'summaryContent': {'type': 'variableValue', 'variableType': 'global', 'variable': ''},
'enableTitle': False,
'flowStatusVar': {'type': 'variableValue', 'variableType': 'global', 'variable': 'flowStatus'},
'operationPenalType': 'custom',
'enableFlowAbort': True,
'innerOffset': 0,
'enableGradientBorder': True,
'cardSizeMode': 'adaptive',
'cardSizeHeightMode': 'adaptive',
'cardSizeWidthMode': 'adaptive',
'cardSizeHeight': {
'type': 'dynamicNumber',
'valueType': 'fixed',
'value': 226,
'variable': '',
'variableType': 'global',
},
'hasBackground': False,
'backgroundType': 'Standard',
'standardBackgroundColor': 'gray',
'backgroundColor': '#F6F6F6',
'darkModeBackgroundColor': '#3C3C3C',
'enableEngineUpgrade': False,
'enableExposeStatPoint': False,
'enableDebugTool': False,
},
'hidden': False,
'title': '',
'isLocked': False,
'condition': True,
'conditionGroup': '',
'children': [pending_state, done_state, failed_state],
}
btns_var = {
'name': 'btns',
'private': False,
'type': 'buttonGroup',
'id': 'btns',
'description': '动态按钮列表(Dify actions',
'editorVarType': 'variables',
'disabled': False,
'schema': [
{
'id': 'btns.text',
'type': 'string',
'name': 'text',
'private': False,
'editorVarType': 'variables',
'disabled': True,
'description': '按钮文案',
},
{
'id': 'btns.color',
'type': 'string',
'name': 'color',
'private': False,
'editorVarType': 'variables',
'disabled': True,
'description': '按钮颜色',
},
{
'id': 'btns.status',
'type': 'string',
'name': 'status',
'private': False,
'editorVarType': 'variables',
'disabled': True,
'description': '按钮状态',
},
{
'id': 'btns.event',
'type': 'dynamicEvent',
'name': 'event',
'private': False,
'editorVarType': 'variables',
'disabled': True,
'description': '按钮点击事件',
'schema': [
{
'id': 'btns.type',
'type': 'string',
'name': 'type',
'private': False,
'editorVarType': 'variables',
'disabled': True,
'description': '事件类型:openLink / sendCardRequest',
},
{
'id': 'btns.params',
'type': 'object',
'name': 'params',
'private': False,
'editorVarType': 'variables',
'disabled': True,
'description': '事件参数',
'schema': [
{
'id': 'btns.url',
'type': 'string',
'name': 'url',
'private': False,
'editorVarType': 'variables',
'disabled': True,
'description': '点击跳转链接(type=openLink',
},
{
'id': 'btns.actionId',
'type': 'string',
'name': 'actionId',
'private': False,
'editorVarType': 'variables',
'disabled': True,
'description': '回传请求 idtype=sendCardRequest',
},
{
'id': 'btns.params',
'type': 'object',
'name': 'params',
'private': False,
'editorVarType': 'variables',
'disabled': True,
'description': '回传请求参数(type=sendCardRequest',
},
],
},
],
},
],
}
return {
'schemaVersion': '3.0.0',
'schema': {
'componentsMap': components_map,
'componentsTree': [root],
'i18n': {},
'version': '1.0.0',
},
'mockData': {
'cardData': {
'flowStatus': 3,
'content': '请审核以下报销申请:\n\n- 申请人:张三\n- 金额:¥1,200\n- 类别:差旅',
'btns': [
{
'text': '通过',
'color': 'blue',
'status': 'normal',
'event': {
'type': 'sendCardRequest',
'params': {'actionId': 'approve', 'params': {'action_id': 'approve'}},
},
},
{
'text': '驳回',
'color': 'gray',
'status': 'normal',
'event': {
'type': 'sendCardRequest',
'params': {'actionId': 'reject', 'params': {'action_id': 'reject'}},
},
},
{
'text': '补充资料',
'color': 'gray',
'status': 'normal',
'event': {
'type': 'sendCardRequest',
'params': {'actionId': 'more_info', 'params': {'action_id': 'more_info'}},
},
},
],
},
'cardPrivateData': {},
'localData': {'flowStatus': '', '_cardFoldStatusLocalDataKey': ''},
'richTextData': {},
},
'renderContext': {'regenerateEnabled': '1', 'regenerateIndex': '2', 'regenerateTotal': '5'},
'editVersion': 0,
'customWidgetInfo': '',
'useCustomWidgetInfo': False,
'variableList': [
{
'id': 'content',
'type': 'markdown',
'name': 'content',
'description': '人工输入提示词(Dify form_content 含可选 node_title 前缀)',
'private': False,
'editorVarType': 'variables',
'disabled': False,
},
{
'id': 'flowStatus',
'type': 'string',
'name': 'flowStatus',
'description': 'AI卡片状态:pending(1)、writing(2)、done(3)、failed(5)',
'private': False,
'editorVarType': 'variables',
'disabled': True,
'visible': False,
},
btns_var,
],
'formList': [],
'customContextList': [],
'expList': [],
'localList': [],
'hsfList': [],
'lwpList': [],
'pageData': {},
'extension': {'extendType': 'AI', 'aiStatusList': [3, 1, 5], 'fileTypeList': []},
}
def main():
editor_data = build_editor_data()
wrapper = {
'editorData': json.dumps(editor_data, ensure_ascii=False, separators=(',', ':')),
'widgetInfo': '',
'type': 'im',
'mode': 'card',
}
OUTPUT.write_text(json.dumps(wrapper, ensure_ascii=False, indent=2), encoding='utf-8')
print(f'wrote {OUTPUT}')
if __name__ == '__main__':
main()
+297 -25
View File
@@ -1,17 +1,26 @@
import asyncio import asyncio
import base64 import base64
import json import json
import logging
import time import time
import uuid
import urllib.parse import urllib.parse
from typing import Callable from typing import Awaitable, Callable, Optional
import dingtalk_stream # type: ignore import dingtalk_stream # type: ignore
import websockets import websockets
from .EchoHandler import EchoTextHandler from .EchoHandler import EchoTextHandler
from .card_callback import DingTalkCardActionHandler
from .dingtalkevent import DingTalkEvent from .dingtalkevent import DingTalkEvent
import httpx import httpx
import traceback import traceback
_stdout_logger = logging.getLogger('langbot.dingtalk_api')
DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com'
class DingTalkClient: class DingTalkClient:
def __init__( def __init__(
self, self,
@@ -21,6 +30,7 @@ class DingTalkClient:
robot_code: str, robot_code: str,
markdown_card: bool, markdown_card: bool,
logger: None, logger: None,
card_action_callback: Optional[Callable[[dict], Awaitable[None]]] = None,
): ):
"""初始化 WebSocket 连接并自动启动""" """初始化 WebSocket 连接并自动启动"""
self.credential = dingtalk_stream.Credential(client_id, client_secret) self.credential = dingtalk_stream.Credential(client_id, client_secret)
@@ -30,6 +40,14 @@ class DingTalkClient:
# 在 DingTalkClient 中传入自己作为参数,避免循环导入 # 在 DingTalkClient 中传入自己作为参数,避免循环导入
self.EchoTextHandler = EchoTextHandler(self) self.EchoTextHandler = EchoTextHandler(self)
self.client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self.EchoTextHandler) self.client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self.EchoTextHandler)
# STREAM-mode card action button click handler. Forwards parsed payload
# to the adapter so it can resume paused Dify workflows.
self.card_action_callback = card_action_callback
self.card_action_handler = DingTalkCardActionHandler(self.client, self._on_card_action)
self.client.register_callback_handler(
dingtalk_stream.handlers.CallbackHandler.TOPIC_CARD_CALLBACK,
self.card_action_handler,
)
self._message_handlers = { self._message_handlers = {
'example': [], 'example': [],
} }
@@ -41,6 +59,16 @@ class DingTalkClient:
self.logger = logger self.logger = logger
self._stopped = False # Flag to control the event loop self._stopped = False # Flag to control the event loop
async def _on_card_action(self, payload: dict) -> None:
"""Dispatch a parsed card-action payload to the adapter callback."""
if self.card_action_callback is None:
return
try:
await self.card_action_callback(payload)
except Exception:
if self.logger:
await self.logger.error(f'DingTalk card action callback error: {traceback.format_exc()}')
async def get_access_token(self): async def get_access_token(self):
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken' url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
headers = {'Content-Type': 'application/json'} headers = {'Content-Type': 'application/json'}
@@ -429,18 +457,35 @@ class DingTalkClient:
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
# For enterprise-internal robots, robotCode == AppKey (client_id).
# The dedicated robot_code field is only required for scenario-group
# robots or third-party robots; fall back to client_id when empty so
# the common single-bot setup keeps working without manual config.
robot_code = self.robot_code or self.key
data = { data = {
'robotCode': self.robot_code, 'robotCode': robot_code,
'userIds': [target_id], 'userIds': [target_id],
'msgKey': 'sampleText', 'msgKey': 'sampleText',
'msgParam': json.dumps({'content': content}), 'msgParam': json.dumps({'content': content}),
} }
_stdout_logger.info(
'DingTalk send_proactive_message_to_one request: robotCode=%s target_id=%s content_len=%d',
robot_code,
target_id,
len(content),
)
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, json=data) response = await client.post(url, headers=headers, json=data)
_stdout_logger.info(
'DingTalk send_proactive_message_to_one response: status=%d body=%s',
response.status_code,
response.text[:500],
)
if response.status_code == 200: if response.status_code == 200:
return return
except Exception: except Exception:
_stdout_logger.exception('DingTalk send_proactive_message_to_one error')
await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}') await self.logger.error(f'failed to send proactive massage to person: {traceback.format_exc()}')
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}') raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
@@ -456,7 +501,7 @@ class DingTalkClient:
} }
data = { data = {
'robotCode': self.robot_code, 'robotCode': self.robot_code or self.key,
'openConversationId': target_id, 'openConversationId': target_id,
'msgKey': 'sampleText', 'msgKey': 'sampleText',
'msgParam': json.dumps({'content': content}), 'msgParam': json.dumps({'content': content}),
@@ -477,47 +522,274 @@ class DingTalkClient:
quote_origin: bool = False, quote_origin: bool = False,
card_auto_layout: bool = False, card_auto_layout: bool = False,
): ):
card_data = {} """Create + deliver the streaming chat card for a chatbot reply.
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
card_data['content'] = ''
# 将用户的消息内容作为卡片的查询参数,方便后续处理 Replaces the old `dingtalk_stream.AICardReplier`-based path. Returns
if incoming_message.message_type == 'text': `(None, out_track_id)` to keep call sites compatible with the
card_data['query'] = incoming_message.get_text_list()[0] previous `(card_instance, card_instance_id)` shape — the first slot
is unused now that everything is driven by out_track_id.
"""
out_track_id = uuid.uuid4().hex
is_group = str(incoming_message.conversation_type) == '2'
if is_group:
open_space_id = f'dtv1.card//IM_GROUP.{incoming_message.conversation_id}'
else: else:
card_data['query'] = '...' open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}'
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message) card_param_map = {'content': ''}
# print(card_instance) if incoming_message.message_type == 'text':
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards card_param_map['query'] = incoming_message.get_text_list()[0]
card_instance_id = await card_instance.async_create_and_deliver_card( else:
temp_card_id, card_param_map['query'] = '...'
card_data,
await self.create_and_deliver_card(
card_template_id=temp_card_id,
out_track_id=out_track_id,
open_space_id=open_space_id,
is_group=is_group,
card_param_map=card_param_map,
card_data_config={'autoLayout': card_auto_layout},
) )
return card_instance, card_instance_id return None, out_track_id
async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool): async def send_card_message(self, card_instance, card_instance_id: str, content: str, is_final: bool):
content_key = 'content' """Stream a single chunk into an existing card's `content` field."""
try: try:
await card_instance.async_streaming( await self.streaming_update_card(
card_instance_id, out_track_id=card_instance_id,
content_key=content_key, content_key='content',
content_value=content, content_value=content,
append=False, append=False,
finished=is_final, finished=is_final,
failed=False, failed=False,
) )
except Exception as e: except Exception as e:
self.logger.exception(e) if self.logger:
await card_instance.async_streaming( self.logger.exception(e)
card_instance_id, await self.streaming_update_card(
content_key=content_key, out_track_id=card_instance_id,
content_key='content',
content_value='', content_value='',
append=False, append=False,
finished=is_final, finished=is_final,
failed=True, failed=True,
) )
async def create_and_deliver_card(
self,
*,
card_template_id: str,
out_track_id: str,
open_space_id: str,
is_group: bool,
card_param_map: Optional[dict] = None,
callback_type: str = 'STREAM',
callback_route_key: Optional[str] = None,
support_forward: bool = True,
dynamic_data_source_configs: Optional[list] = None,
card_data_config: Optional[dict] = None,
at_user_ids: Optional[dict] = None,
recipients: Optional[list] = None,
) -> bool:
"""POST /v1.0/card/instances/createAndDeliver.
Mirrors the SDK's `async_create_and_deliver_card` shape but exposes
the dynamic-data-source config slot so we can register a pull URL
for variable-length button lists.
"""
if not await self.check_access_token():
await self.get_access_token()
cardData: dict = {'cardParamMap': card_param_map or {}}
if card_data_config is not None:
cardData['config'] = json.dumps(card_data_config)
body: dict = {
'cardTemplateId': card_template_id,
'outTrackId': out_track_id,
'cardData': cardData,
'callbackType': callback_type,
'openSpaceId': open_space_id,
'imGroupOpenSpaceModel': {'supportForward': support_forward},
'imRobotOpenSpaceModel': {'supportForward': support_forward},
}
if callback_type == 'HTTP' and callback_route_key:
body['callbackRouteKey'] = callback_route_key
if is_group:
deliver: dict = {'robotCode': self.robot_code or self.key}
if at_user_ids:
deliver['atUserIds'] = at_user_ids
if recipients is not None:
deliver['recipients'] = recipients
body['imGroupOpenDeliverModel'] = deliver
else:
body['imRobotOpenDeliverModel'] = {'spaceType': 'IM_ROBOT'}
if dynamic_data_source_configs:
body['openDynamicDataConfig'] = {'dynamicDataSourceConfigs': dynamic_data_source_configs}
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances/createAndDeliver'
headers = {
'x-acs-dingtalk-access-token': self.access_token,
'Content-Type': 'application/json',
}
try:
_stdout_logger.info(
'DingTalk createAndDeliver request body: %s',
json.dumps(body, ensure_ascii=False)[:1500],
)
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, json=body, timeout=30.0)
if response.status_code == 200:
_stdout_logger.info(
'DingTalk createAndDeliver response: %s',
response.text[:500],
)
return True
_stdout_logger.error(
'DingTalk createAndDeliver failed: status=%s body=%s',
response.status_code,
response.text,
)
if self.logger:
await self.logger.error(
f'DingTalk createAndDeliver failed: status={response.status_code} body={response.text}'
)
return False
except Exception:
_stdout_logger.exception('DingTalk createAndDeliver error')
if self.logger:
await self.logger.error(f'DingTalk createAndDeliver error: {traceback.format_exc()}')
return False
async def streaming_update_card(
self,
*,
out_track_id: str,
content_key: str,
content_value: str,
append: bool,
finished: bool,
failed: bool = False,
) -> bool:
"""PUT /v1.0/card/streaming.
Replaces `dingtalk_stream.AICardReplier.async_streaming` — same body
shape (outTrackId / guid / key / content / isFull / isFinalize /
isError) per the SDK source.
"""
if not await self.check_access_token():
await self.get_access_token()
body = {
'outTrackId': out_track_id,
'guid': uuid.uuid4().hex,
'key': content_key,
'content': content_value,
'isFull': not append,
'isFinalize': finished,
'isError': failed,
}
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/streaming'
headers = {
'x-acs-dingtalk-access-token': self.access_token,
'Content-Type': 'application/json',
}
try:
async with httpx.AsyncClient() as client:
response = await client.put(url, headers=headers, json=body, timeout=30.0)
if response.status_code == 200:
return True
if self.logger:
await self.logger.error(
f'DingTalk card streaming failed: status={response.status_code} body={response.text}'
)
return False
except Exception:
if self.logger:
await self.logger.error(f'DingTalk card streaming error: {traceback.format_exc()}')
return False
async def update_card_data(
self,
*,
out_track_id: str,
card_param_map: Optional[dict] = None,
private_data: Optional[dict] = None,
) -> bool:
"""PUT /v1.0/card/instances — non-streaming card content update."""
if not await self.check_access_token():
await self.get_access_token()
body: dict = {
'outTrackId': out_track_id,
'cardData': {'cardParamMap': card_param_map or {}},
}
if private_data:
body['privateData'] = private_data
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances'
headers = {
'x-acs-dingtalk-access-token': self.access_token,
'Content-Type': 'application/json',
}
try:
_stdout_logger.info(
'DingTalk update_card_data request: out_track_id=%s body=%s',
out_track_id,
json.dumps(body, ensure_ascii=False)[:500],
)
async with httpx.AsyncClient() as client:
response = await client.put(url, headers=headers, json=body, timeout=30.0)
_stdout_logger.info(
'DingTalk update_card_data response: status=%d body=%s',
response.status_code,
response.text[:300],
)
if response.status_code == 200:
return True
if self.logger:
await self.logger.error(
f'DingTalk update card failed: status={response.status_code} body={response.text}'
)
return False
except Exception:
_stdout_logger.exception('DingTalk update_card_data error')
if self.logger:
await self.logger.error(f'DingTalk update card error: {traceback.format_exc()}')
return False
async def delete_card(self, *, out_track_id: str) -> bool:
"""POST /v1.0/card/instances/delete — recall a delivered card.
Used to retroactively remove the initial streaming chat card when
the workflow turns out to be paused for human input — the prompt
and buttons then live entirely on the dedicated form card.
"""
if not await self.check_access_token():
await self.get_access_token()
url = f'{DINGTALK_OPENAPI_BASE}/v1.0/card/instances/delete'
headers = {
'x-acs-dingtalk-access-token': self.access_token,
'Content-Type': 'application/json',
}
body = {'outTrackId': out_track_id, 'userIdType': 1}
try:
_stdout_logger.info('DingTalk delete_card request: out_track_id=%s', out_track_id)
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, json=body, timeout=30.0)
_stdout_logger.info(
'DingTalk delete_card response: status=%d body=%s',
response.status_code,
response.text[:300],
)
return response.status_code == 200
except Exception:
_stdout_logger.exception('DingTalk delete_card error')
return False
async def start(self): async def start(self):
"""启动 WebSocket 连接,监听消息""" """启动 WebSocket 连接,监听消息"""
self._stopped = False self._stopped = False
@@ -0,0 +1,96 @@
"""STREAM-mode handler for DingTalk card action button clicks.
DingTalk delivers card-action callbacks over the same WebSocket stream used
for chatbot messages, under the topic `/v1.0/card/instances/callback`. This
module subclasses `dingtalk_stream.CallbackHandler` and forwards the parsed
payload to a coroutine the adapter registers, so the resume-paused-workflow
logic stays in the platform adapter where it belongs.
The `CardCallbackMessage` returned by `from_dict` exposes:
* `card_instance_id` (from `outTrackId`) — the card whose button was clicked
* `user_id` — the clicker's userId
* `content` — parsed JSON; the click params live here. Where exactly inside
`content` they sit depends on the template binding. We probe
the common paths.
* `extension` — parsed JSON; any extra data we set when delivering the card.
"""
from __future__ import annotations
from typing import Awaitable, Callable, Optional
import dingtalk_stream # type: ignore
from dingtalk_stream import AckMessage
from dingtalk_stream.card_callback import CardCallbackMessage
_PARAM_PATHS = (
('params',),
('cardPrivateData', 'params'),
('userPrivateData', 'params'),
)
def _extract_params(content: dict) -> dict:
"""Return the action params dict regardless of where the template put it."""
for path in _PARAM_PATHS:
node = content
for key in path:
if not isinstance(node, dict):
node = None
break
node = node.get(key)
if node is None:
break
if isinstance(node, dict) and node:
return node
return {}
class DingTalkCardActionHandler(dingtalk_stream.CallbackHandler):
def __init__(
self,
dingtalk_stream_client,
on_action: Optional[Callable[[dict], Awaitable[None]]] = None,
):
super().__init__()
self.dingtalk_client = dingtalk_stream_client
self.on_action = on_action
async def process(self, callback: dingtalk_stream.CallbackMessage):
try:
message = CardCallbackMessage.from_dict(callback.data)
params = _extract_params(message.content if isinstance(message.content, dict) else {})
# `CardCallbackMessage.from_dict` does not surface `actionId` (the
# top-level field that ButtonGroup's sendCardRequest event puts
# there). Pull it from the raw callback.data instead.
raw = callback.data if isinstance(callback.data, dict) else {}
action_id = raw.get('actionId') or ''
if not action_id:
# Some templates nest it under actionData / cardPrivateData.
action_data = raw.get('actionData') or {}
if isinstance(action_data, dict):
action_id = action_data.get('actionId') or action_id
if not action_id:
cpd = action_data.get('cardPrivateData') or {}
if isinstance(cpd, dict):
ids = cpd.get('actionIds')
if isinstance(ids, list) and ids:
action_id = str(ids[0])
payload = {
'out_track_id': message.card_instance_id,
'user_id': message.user_id,
'corp_id': message.corp_id,
'action_id': action_id,
'params': params,
'raw_content': message.content,
'extension': message.extension if isinstance(message.extension, dict) else {},
}
if self.on_action is not None:
await self.on_action(payload)
except Exception as e:
self.logger.error(f'DingTalkCardActionHandler.process error: {e}')
return AckMessage.STATUS_OK, 'OK'
+571 -16
View File
@@ -1,13 +1,19 @@
import asyncio
import json
import traceback import traceback
import typing import typing
import uuid
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
import langbot_plugin.api.entities.builtin.platform.message as platform_message 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.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.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.libs.dingtalk_api.api import DingTalkClient from langbot.libs.dingtalk_api.api import DingTalkClient
import datetime import datetime
from langbot.pkg.platform.logger import EventLogger from langbot.pkg.platform.logger import EventLogger
from langbot.pkg.provider.runners.difysvapi import _format_human_input_text
class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@@ -170,6 +176,13 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
card_instance_id_dict: ( card_instance_id_dict: (
dict # 回复卡片消息字典,key为消息id,value为回复卡片实例id,用于在流式消息时判断是否发送到指定卡片 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
ap: typing.Any = None
bot_uuid: str = ''
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger):
required_keys = [ required_keys = [
@@ -194,10 +207,15 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
config=config, config=config,
logger=logger, logger=logger,
card_instance_id_dict={}, card_instance_id_dict={},
card_state={},
bot_account_id=bot_account_id, bot_account_id=bot_account_id,
bot=bot, bot=bot,
listeners={}, listeners={},
) )
# 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
async def reply_message( async def reply_message(
self, self,
@@ -222,28 +240,79 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
quote_origin: bool = False, quote_origin: bool = False,
is_final: bool = False, is_final: bool = False,
): ):
# event = await DingTalkEventConverter.yiri2target(
# message_source,
# )
# incoming_message = event.incoming_message
# msg_id = incoming_message.message_id
message_id = bot_message.resp_message_id message_id = bot_message.resp_message_id
msg_seq = bot_message.msg_sequence msg_seq = bot_message.msg_sequence
form_template_id = (self.config.get('human_input_card_template_id') or '').strip()
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}, '
f'form_template_configured={bool(form_template_id)}'
)
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: if (msg_seq - 1) % 8 == 0 or is_final:
markdown_enabled = self.config.get('markdown_card', False) markdown_enabled = self.config.get('markdown_card', False)
content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled) content, at = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
card_instance, card_instance_id = self.card_instance_id_dict[message_id]
if not content and bot_message.content: if not content and bot_message.content:
content = bot_message.content # 兼容直接传入content的情况 content = bot_message.content # 兼容直接传入content的情况
# print(card_instance_id)
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: if content:
await self.bot.send_card_message(card_instance, card_instance_id, content, is_final) if form_template_id:
if is_final and bot_message.tool_calls is None: # The form template's MarkdownBlock has `isStreaming: false`
# self.seq = 1 # 消息回复结束之后重置seq # — the streaming endpoint (PUT /v1.0/card/streaming) does
self.card_instance_id_dict.pop(message_id) # 消息回复结束之后删除卡片实例id # not propagate to non-streaming components. Use the full
# update_card_data PUT instead so the content actually
# appears in the card body.
try:
await self.bot.update_card_data(
out_track_id=card_instance_id,
card_param_map={
'content': content,
'btns': '[]',
'flowStatus': '3' if is_final else '1',
},
)
except Exception:
if self.ap is not None:
self.ap.logger.exception('DingTalk: update card content failed')
else:
await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)
if is_final:
if form_template_id and not content:
# Empty final chunk still needs to leave the card with
# flowStatus=3 so the spinner stops.
try:
await self.bot.update_card_data(
out_track_id=card_instance_id,
card_param_map={'flowStatus': '3'},
)
except Exception:
pass
if bot_message.tool_calls is None:
self.card_instance_id_dict.pop(message_id, None)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
markdown_enabled = self.config.get('markdown_card', False) markdown_enabled = self.config.get('markdown_card', False)
@@ -260,16 +329,56 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return is_stream return is_stream
async def create_message_card(self, message_id, event): async def create_message_card(self, message_id, event):
card_template_id = self.config['card_template_id'] # When a form template is configured, every card in the conversation
# uses it (chat output, form prompts, post-click states). The chat
# template fallback only kicks in if no form template is configured.
form_template_id = (self.config.get('human_input_card_template_id') or '').strip()
legacy_template_id = self.config.get('card_template_id', '')
# Synthetic events (e.g. card button clicks) have no inbound chatbot
# message — skip card creation. The lazy-create path in
# reply_message_chunk will spawn a fresh card when the first
# non-empty resume chunk arrives.
if event is None or event.source_platform_object is None:
return False
if form_template_id:
# Defer card creation to the first non-empty chunk. If the Dify
# workflow pauses immediately for human input without producing
# any LLM text first, no chat card is created at all — only the
# form card gets delivered. Lazy-create lives in
# reply_message_chunk → _lazy_create_resume_chat_card.
return False
# Legacy chat-card path (no form template configured).
incoming_message = event.source_platform_object.incoming_message incoming_message = event.source_platform_object.incoming_message
# message_id = incoming_message.message_id
card_auto_layout = self.config.get('card_ auto_layout', False) card_auto_layout = self.config.get('card_ auto_layout', False)
card_instance, card_instance_id = await self.bot.create_and_card( card_instance, card_instance_id = await self.bot.create_and_card(
card_template_id, incoming_message, card_auto_layout=card_auto_layout legacy_template_id, incoming_message, card_auto_layout=card_auto_layout
) )
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id) self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
return True 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( def register_listener(
self, self,
event_type: typing.Type[platform_events.Event], event_type: typing.Type[platform_events.Event],
@@ -309,3 +418,449 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
], ],
): ):
return super().unregister_listener(event_type, callback) 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:
"""Finalize the current chat card and deliver a new form card.
Multi-card flow: every Dify pause spawns its own card. The card the
chat was streaming into (if any) is closed out via streaming_update
with finished=True so its spinner stops; a fresh card is then
delivered carrying the prompt + buttons.
"""
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
template_id = (self.config.get('human_input_card_template_id') or '').strip()
# Finalize the previous chat card so its spinner stops. Use the
# already-streamed text as the final content (or zero-width space
# when nothing streamed, to satisfy any non-empty-content guards).
chat_card_entry = self.card_instance_id_dict.pop(message_id, None)
if chat_card_entry is not None:
_, chat_out_track_id = chat_card_entry
markdown_enabled = self.config.get('markdown_card', False)
text_content, _ = await DingTalkMessageConverter.yiri2target(message, markdown_enabled)
if not text_content and bot_message.content:
text_content = bot_message.content
try:
await self.bot.send_card_message(None, chat_out_track_id, text_content or '', True)
except Exception:
await self.logger.error(f'DingTalk: finalize chat card before form failed: {traceback.format_exc()}')
# When the chat card uses the form template, also flip flowStatus
# to 3 so it leaves the pending state visibly.
if template_id:
try:
await self.bot.update_card_data(
out_track_id=chat_out_track_id,
card_param_map={'flowStatus': '3'},
)
except Exception:
pass
if not template_id:
# No form template configured — fall back to plain text so users
# can still reply with the option number or title.
await self.send_message_text_form(message_source, form_data)
return
await self._send_form_card(message_source, form_data, template_id)
async def _send_form_card(
self,
message_source: platform_events.MessageEvent,
form_data: dict,
template_id: str,
) -> 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 = form_data.get('form_content', '') or ''
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', ''),
'actions': actions,
'node_title': node_title,
'form_content': form_content,
'open_space_id': open_space_id,
'is_group': is_group,
}
parts = []
if node_title:
parts.append(f'**{node_title}**')
if form_content:
parts.append(form_content)
display_content = '\n\n'.join(parts) or '请选择一个操作以继续。'
btns = []
for idx, action in enumerate(actions):
action_id = str(action.get('id') or '')
title = str(action.get('title') or action_id or f'选项 {idx + 1}')
style = (action.get('button_style') or '').lower()
if style == 'primary' or (style == '' and idx == 0):
color = 'blue'
elif style == 'danger':
color = 'red'
else:
color = 'gray'
btns.append(
{
'text': title,
'color': color,
'status': 'normal',
'event': {
'type': 'sendCardRequest',
'params': {
'actionId': action_id,
'params': {'action_id': action_id, 'out_track_id': out_track_id},
},
},
}
)
try:
if self.ap is not None:
self.ap.logger.info(
f'DingTalk _send_form_card: out_track_id={out_track_id} template_id={template_id} '
f'open_space_id={open_space_id} is_group={is_group} btns={len(btns)}'
)
await self.bot.create_and_deliver_card(
card_template_id=template_id,
out_track_id=out_track_id,
open_space_id=open_space_id,
is_group=is_group,
card_param_map={
'content': display_content,
'btns': json.dumps(btns, ensure_ascii=False),
'flowStatus': '3',
},
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 — no inbound
chatbot message means no card was created upstream, so we spin one
up here when the first non-empty chunk arrives. Prefers the form
template (so empty `btns` keep the layout consistent across the
whole conversation); falls back to the legacy chat template.
"""
form_template_id = (self.config.get('human_input_card_template_id') or '').strip()
legacy_template_id = (self.config.get('card_template_id') or '').strip()
template_id = form_template_id or legacy_template_id
if not template_id:
return None
out_track_id = uuid.uuid4().hex
open_space_id, is_group = self._derive_open_space(message_source)
if self.ap is not None:
self.ap.logger.info(
f'DingTalk _lazy_create_resume_chat_card: out_track_id={out_track_id} '
f'open_space_id={open_space_id} is_group={is_group} '
f'using_form_template={bool(form_template_id)}'
)
if form_template_id:
card_param_map = {'content': '', 'btns': '[]', 'flowStatus': '1'}
card_data_config = None
else:
card_param_map = {'content': '', 'query': '...'}
card_data_config = {'autoLayout': self.config.get('card_auto_layout', False)}
try:
success = await self.bot.create_and_deliver_card(
card_template_id=template_id,
out_track_id=out_track_id,
open_space_id=open_space_id,
is_group=is_group,
card_param_map=card_param_map,
card_data_config=card_data_config,
callback_type='STREAM',
)
except Exception:
if self.ap is not None:
self.ap.logger.exception('DingTalk: lazy create resume chat card failed')
return None
if not success:
return None
entry = (None, out_track_id)
self.card_instance_id_dict[message_id] = entry
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."""
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 ''
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
if not raw_action_id:
await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}')
return
actions = state.get('actions', []) or []
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', '')
sender_user_id = state.get('sender_user_id') or payload.get('user_id') or launcher_id
form_action_data = {
'form_token': state.get('form_token', ''),
'workflow_run_id': state.get('workflow_run_id', ''),
'action_id': action_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=sender_user_id,
member_name='',
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=launcher_id,
name='',
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=int(datetime.datetime.now().timestamp()),
source_platform_object=None,
)
else:
synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend(
id=sender_user_id,
nickname='',
remark='',
),
message_chain=message_chain,
time=int(datetime.datetime.now().timestamp()),
source_platform_object=None,
)
bot_uuid = ''
pipeline_uuid = 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 = 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} 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=sender_user_id,
message_event=synthetic_event,
message_chain=message_chain,
adapter=self,
pipeline_uuid=pipeline_uuid,
variables={
'_dify_form_action': form_action_data,
'_routed_by_rule': True,
},
)
self.ap.logger.info('DingTalk _on_card_action: query enqueued OK')
except Exception:
self.ap.logger.exception('DingTalk: enqueue form action query failed')
return
# Visual feedback: collapse the form card to a "已选择" notice so
# the user knows the click registered while the workflow resumes.
asyncio.create_task(
self._mark_card_resolved(
out_track_id,
action_title,
node_title=state.get('node_title', ''),
form_content=state.get('form_content', ''),
)
)
# Once consumed, drop the state — the runner clears _PENDING_FORMS too.
self.card_state.pop(out_track_id, None)
async def _mark_card_resolved(
self,
out_track_id: str,
action_title: str,
*,
node_title: str = '',
form_content: str = '',
) -> None:
"""Update the form card to acknowledge the user's selection.
We rewrite the card content with the original prompt + a green tick
marker, and explicitly clear ``btns`` so the buttons are removed
once chosen. ``flowStatus`` is re-sent because some DingTalk clients
treat the PUT update as a partial *replace* of cardParamMap rather
than a merge — without it, the AICardContainer status containers
would all gate to ``gone`` and the whole card would blank out.
"""
parts: list[str] = []
if node_title:
parts.append(f'**{node_title}**')
if form_content:
parts.append(form_content)
parts.append(f'---\n✅ 已选择:**{action_title}**')
content = '\n\n'.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_card_data(
out_track_id=out_track_id,
card_param_map={
'content': content,
'btns': '[]',
'flowStatus': '3',
},
)
except Exception:
await self.logger.error(f'DingTalk: update form card after click failed: {traceback.format_exc()}')
@@ -103,6 +103,18 @@ spec:
type: string type: string
required: true required: true
default: "填写你的卡片template_id" default: "填写你的卡片template_id"
- name: human_input_card_template_id
label:
en_US: Human Input Card Template ID
zh_Hans: 人工输入卡片模板ID
zh_Hant: 人工輸入卡片範本ID
description:
en_US: "Template ID used as the SINGLE card for the whole conversation turn. Streamed LLM text fills the `content` markdown variable; on a Dify human-input pause the `btns` buttonGroup variable is populated so action buttons appear on the SAME card; after the user clicks a button the buttons disappear and resumed streaming continues into the same card. Use the bundled `src/langbot/templates/dingtalk_human_input_card.json` — it ships with `content` (MarkdownBlock) and `btns` (ButtonGroup) already wired. Leave empty to fall back to the legacy two-card behaviour (chat card streaming text + plain-text human-input prompts)."
zh_Hans: "用作整个对话回合**唯一**卡片的模板ID。流式 LLM 文本写入 `content` markdown 变量;Dify 人工输入暂停时同一张卡的 `btns` buttonGroup 变量被填上、按钮浮现;用户点击后按钮消失、恢复的流式内容继续追加到同一张卡。可使用项目附带的 `src/langbot/templates/dingtalk_human_input_card.json`——已经预先连好 `content` (MarkdownBlock) 与 `btns` (ButtonGroup)。留空则降级为旧的双卡行为(聊天卡走流式 + 人工输入走纯文本)。"
zh_Hant: "用作整個對話回合**唯一**卡片的範本ID。流式 LLM 文字寫入 `content` markdown 變數;Dify 人工輸入暫停時同一張卡的 `btns` buttonGroup 變數被填上、按鈕浮現;使用者點擊後按鈕消失、恢復的流式內容繼續追加到同一張卡。可使用專案附帶的 `src/langbot/templates/dingtalk_human_input_card.json`——已經預先連好 `content` (MarkdownBlock) 與 `btns` (ButtonGroup)。留空則降級為舊的雙卡行為(聊天卡走流式 + 人工輸入走純文字)。"
type: string
required: false
default: ""
execution: execution:
python: python:
path: ./dingtalk.py path: ./dingtalk.py
File diff suppressed because one or more lines are too long