mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-15 18:26:02 +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:
648
scripts/build_dingtalk_card_template.py
Normal file
648
scripts/build_dingtalk_card_template.py
Normal 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': '回传请求 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 base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
import urllib.parse
|
||||
from typing import Callable
|
||||
from typing import Awaitable, Callable, Optional
|
||||
import dingtalk_stream # type: ignore
|
||||
import websockets
|
||||
from .EchoHandler import EchoTextHandler
|
||||
from .card_callback import DingTalkCardActionHandler
|
||||
from .dingtalkevent import DingTalkEvent
|
||||
import httpx
|
||||
import traceback
|
||||
|
||||
|
||||
_stdout_logger = logging.getLogger('langbot.dingtalk_api')
|
||||
|
||||
|
||||
DINGTALK_OPENAPI_BASE = 'https://api.dingtalk.com'
|
||||
|
||||
|
||||
class DingTalkClient:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -21,6 +30,7 @@ class DingTalkClient:
|
||||
robot_code: str,
|
||||
markdown_card: bool,
|
||||
logger: None,
|
||||
card_action_callback: Optional[Callable[[dict], Awaitable[None]]] = None,
|
||||
):
|
||||
"""初始化 WebSocket 连接并自动启动"""
|
||||
self.credential = dingtalk_stream.Credential(client_id, client_secret)
|
||||
@@ -30,6 +40,14 @@ class DingTalkClient:
|
||||
# 在 DingTalkClient 中传入自己作为参数,避免循环导入
|
||||
self.EchoTextHandler = EchoTextHandler(self)
|
||||
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 = {
|
||||
'example': [],
|
||||
}
|
||||
@@ -41,6 +59,16 @@ class DingTalkClient:
|
||||
self.logger = logger
|
||||
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):
|
||||
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
@@ -429,18 +457,35 @@ class DingTalkClient:
|
||||
'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 = {
|
||||
'robotCode': self.robot_code,
|
||||
'robotCode': robot_code,
|
||||
'userIds': [target_id],
|
||||
'msgKey': 'sampleText',
|
||||
'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:
|
||||
async with httpx.AsyncClient() as client:
|
||||
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:
|
||||
return
|
||||
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()}')
|
||||
raise Exception(f'failed to send proactive massage to person: {traceback.format_exc()}')
|
||||
|
||||
@@ -456,7 +501,7 @@ class DingTalkClient:
|
||||
}
|
||||
|
||||
data = {
|
||||
'robotCode': self.robot_code,
|
||||
'robotCode': self.robot_code or self.key,
|
||||
'openConversationId': target_id,
|
||||
'msgKey': 'sampleText',
|
||||
'msgParam': json.dumps({'content': content}),
|
||||
@@ -477,47 +522,274 @@ class DingTalkClient:
|
||||
quote_origin: bool = False,
|
||||
card_auto_layout: bool = False,
|
||||
):
|
||||
card_data = {}
|
||||
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
|
||||
card_data['content'] = ''
|
||||
"""Create + deliver the streaming chat card for a chatbot reply.
|
||||
|
||||
# 将用户的消息内容作为卡片的查询参数,方便后续处理
|
||||
if incoming_message.message_type == 'text':
|
||||
card_data['query'] = incoming_message.get_text_list()[0]
|
||||
Replaces the old `dingtalk_stream.AICardReplier`-based path. Returns
|
||||
`(None, out_track_id)` to keep call sites compatible with the
|
||||
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:
|
||||
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)
|
||||
# print(card_instance)
|
||||
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
|
||||
card_instance_id = await card_instance.async_create_and_deliver_card(
|
||||
temp_card_id,
|
||||
card_data,
|
||||
card_param_map = {'content': ''}
|
||||
if incoming_message.message_type == 'text':
|
||||
card_param_map['query'] = incoming_message.get_text_list()[0]
|
||||
else:
|
||||
card_param_map['query'] = '...'
|
||||
|
||||
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):
|
||||
content_key = 'content'
|
||||
"""Stream a single chunk into an existing card's `content` field."""
|
||||
try:
|
||||
await card_instance.async_streaming(
|
||||
card_instance_id,
|
||||
content_key=content_key,
|
||||
await self.streaming_update_card(
|
||||
out_track_id=card_instance_id,
|
||||
content_key='content',
|
||||
content_value=content,
|
||||
append=False,
|
||||
finished=is_final,
|
||||
failed=False,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.exception(e)
|
||||
await card_instance.async_streaming(
|
||||
card_instance_id,
|
||||
content_key=content_key,
|
||||
if self.logger:
|
||||
self.logger.exception(e)
|
||||
await self.streaming_update_card(
|
||||
out_track_id=card_instance_id,
|
||||
content_key='content',
|
||||
content_value='',
|
||||
append=False,
|
||||
finished=is_final,
|
||||
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):
|
||||
"""启动 WebSocket 连接,监听消息"""
|
||||
self._stopped = False
|
||||
|
||||
96
src/langbot/libs/dingtalk_api/card_callback.py
Normal file
96
src/langbot/libs/dingtalk_api/card_callback.py
Normal file
@@ -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 typing
|
||||
import uuid
|
||||
|
||||
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
from langbot.libs.dingtalk_api.api import DingTalkClient
|
||||
import datetime
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
from langbot.pkg.provider.runners.difysvapi import _format_human_input_text
|
||||
|
||||
|
||||
class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@@ -170,6 +176,13 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
card_instance_id_dict: (
|
||||
dict # 回复卡片消息字典,key为消息id,value为回复卡片实例id,用于在流式消息时判断是否发送到指定卡片
|
||||
)
|
||||
# outTrackId → form snapshot {session_key, launcher_type, launcher_id, form_token,
|
||||
# workflow_run_id, actions, node_title, form_content, expires_at, open_space_id,
|
||||
# user_id_hint, current_text}. Lookup keys for the data-source pull endpoint and
|
||||
# the STREAM card-action callback.
|
||||
card_state: dict
|
||||
ap: typing.Any = None
|
||||
bot_uuid: str = ''
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
required_keys = [
|
||||
@@ -194,10 +207,15 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
config=config,
|
||||
logger=logger,
|
||||
card_instance_id_dict={},
|
||||
card_state={},
|
||||
bot_account_id=bot_account_id,
|
||||
bot=bot,
|
||||
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(
|
||||
self,
|
||||
@@ -222,28 +240,79 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
quote_origin: 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
|
||||
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:
|
||||
markdown_enabled = self.config.get('markdown_card', False)
|
||||
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:
|
||||
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:
|
||||
await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
# self.seq = 1 # 消息回复结束之后重置seq
|
||||
self.card_instance_id_dict.pop(message_id) # 消息回复结束之后删除卡片实例id
|
||||
if form_template_id:
|
||||
# The form template's MarkdownBlock has `isStreaming: false`
|
||||
# — the streaming endpoint (PUT /v1.0/card/streaming) does
|
||||
# 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):
|
||||
markdown_enabled = self.config.get('markdown_card', False)
|
||||
@@ -260,16 +329,56 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
return is_stream
|
||||
|
||||
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
|
||||
# message_id = incoming_message.message_id
|
||||
card_auto_layout = self.config.get('card_ auto_layout', False)
|
||||
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)
|
||||
return True
|
||||
|
||||
def _session_key_from_event(self, event) -> str:
|
||||
"""Return launcher_type_launcher_id for an event, '' if unrecoverable."""
|
||||
if event is None:
|
||||
return ''
|
||||
spo = event.source_platform_object
|
||||
if spo is None:
|
||||
try:
|
||||
if isinstance(event, platform_events.GroupMessage):
|
||||
return f'group_{event.group.id}'
|
||||
return f'person_{event.sender.id}'
|
||||
except Exception:
|
||||
return ''
|
||||
try:
|
||||
inc = spo.incoming_message
|
||||
if str(inc.conversation_type) == '2':
|
||||
return f'group_{inc.conversation_id}'
|
||||
return f'person_{inc.sender_staff_id}'
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
@@ -309,3 +418,449 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
],
|
||||
):
|
||||
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
|
||||
required: true
|
||||
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:
|
||||
python:
|
||||
path: ./dingtalk.py
|
||||
|
||||
6
src/langbot/templates/dingtalk_human_input_card.json
Normal file
6
src/langbot/templates/dingtalk_human_input_card.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user