mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-15 06:50:58 +00:00
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:
@@ -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': '回传请求 id(type=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()
|
||||||
@@ -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'
|
||||||
@@ -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
Reference in New Issue
Block a user