diff --git a/scripts/build_dingtalk_card_template.py b/scripts/build_dingtalk_card_template.py new file mode 100644 index 000000000..aae64418c --- /dev/null +++ b/scripts/build_dingtalk_card_template.py @@ -0,0 +1,1111 @@ +"""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. + + Critical: `content.varType: "markdown"` must be set, otherwise DingTalk + silently fails to render the bound variable (the card body stays blank + even though the variable is supplied via cardParamMap). The working + reference template in I:\\下载\\dingtalk_1782055283543.json confirms + this — its MarkdownBlock has the same varType marker. + + isStreaming is left `false` because the adapter writes the variable via + `update_card_data` (the full-card PUT endpoint), not the streaming + `card/streaming` endpoint. Setting `isStreaming: true` here conflicts + with that path and can suppress the rendered body. + """ + return { + 'componentName': 'MarkdownBlock', + 'id': node_id, + 'props': { + 'mdVer': 0, + 'icon': {'type': 'icon', 'icon': '', 'iconType': 'emoji'}, + 'content': { + 'variable': variable, + 'variableType': 'global', + 'type': 'variableValue', + 'varType': 'markdown', + }, + 'visible': { + 'type': 'dynamicVisible', + 'value': True, + 'valueType': 'fixed', + 'condition': {'op': 'and', 'conditions': []}, + }, + 'isStreaming': False, + 'enableLinkStatPoint': False, + 'linkStatPoint': { + 'type': 'dynamicString', + 'content': 'Page_InteractiveCard__Click_markdownOpenlink', + 'i18n': False, + }, + 'linkStatPointParams': [], + 'marginTop': 6, + 'marginBottom': 6, + 'marginLeft': 12, + 'marginRight': 12, + }, + 'title': 'AI 流式富文本', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + } + + +def _dynamic_string_var(variable): + return {'type': 'dynamicString', 'content': '', 'i18n': False, 'variable': variable, 'variableType': 'global'} + + +def _dynamic_visible_var(variable): + return { + 'type': 'dynamicVisible', + 'value': True, + 'valueType': 'variable', + 'variable': variable, + 'variableType': 'global', + 'condition': {'op': 'and', 'conditions': []}, + } + + +SELECT_OPTION_LOCALES = ( + 'zh_CN', + 'zh_TW', + 'en_US', + 'ja_JP', + 'vi_VN', + 'th_TH', + 'id_ID', + 'ne_NP', + 'ms_MY', + 'ko_KR', + 'ru_RU', + 'es_EA', + 'tr_TR', + 'fr_FR', + 'pt_BR', +) + + +def _empty_select_option(): + return {'value': '', 'text': {locale: '' for locale in SELECT_OPTION_LOCALES}} + + +def _select_options_variable(): + return { + 'name': 'index_o', + 'private': False, + 'type': 'selectOptions', + 'id': 'index_o', + 'description': 'Select options', + 'editorVarType': 'variables', + 'disabled': False, + 'schema': [ + { + 'id': 'index_o.value', + 'type': 'string', + 'name': 'value', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '', + }, + { + 'id': 'index_o.text', + 'type': 'object', + 'name': 'text', + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '', + 'schema': [ + { + 'id': f'index_o.{locale}', + 'type': 'string', + 'name': locale, + 'private': False, + 'editorVarType': 'variables', + 'disabled': True, + 'description': '', + } + for locale in SELECT_OPTION_LOCALES + ], + }, + ], + } + + +def input_block(node_id): + return { + 'componentName': 'Input', + 'id': node_id, + 'props': { + 'placeholder': _dynamic_string_var('input_placeholder'), + 'currentValue': _dynamic_string_var('input_value'), + 'message': _dynamic_string_var('input_placeholder'), + 'title': _dynamic_string_var('input_title'), + 'id': {'type': 'dynamicString', 'content': 'input', 'i18n': False}, + 'params': [ + { + 'type': 'builtIn', + 'variable': '', + 'value': '', + 'name': 'input', + 'variableType': 'global', + 'id': '__built_in_inputResult__', + } + ], + 'visible': _dynamic_visible_var('input_visible'), + 'status': { + 'type': 'dynamicSelect', + 'valueType': 'fixed', + 'value': 'normal', + 'variable': '', + 'variableType': 'global', + }, + 'actionType': 'request', + 'localVarAction': {'type': 'variableValue', 'variableType': 'global', 'variable': ''}, + 'keyOfDynamicObject': {'type': 'dynamicString', 'content': '', 'i18n': False}, + 'inlineMode': False, + 'textArea': True, + 'minRows': { + 'type': 'dynamicNumber', + 'valueType': 'fixed', + 'value': 2, + 'variable': '', + 'variableType': 'global', + }, + 'maxRows': { + 'type': 'dynamicNumber', + 'valueType': 'fixed', + 'value': 6, + 'variable': '', + 'variableType': 'global', + }, + 'marginLeft': 12, + 'marginRight': 12, + 'marginTop': 6, + 'marginBottom': 6, + 'margin': 12, + 'innerOffset': 0, + }, + 'title': 'Text input', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + } + + +def select_block(node_id): + return { + 'componentName': 'SelectBlock', + 'id': node_id, + 'props': { + 'id': {'type': 'dynamicString', 'content': 'select', 'i18n': False}, + 'placeholder': _dynamic_string_var('select_placeholder'), + 'currentIndex': { + 'type': 'dynamicNumber', + 'valueType': 'variable', + 'value': -1, + 'variable': 'select_index', + 'variableType': 'global', + }, + 'options': { + 'type': 'dynamicSelectOptions', + 'valueType': 'variable', + 'value': [], + 'variable': 'index_o', + 'variableType': 'global', + }, + 'optionLabelMaxLines': 3, + 'params': [ + { + 'type': 'builtIn', + 'variable': '', + 'value': '{"index": ${index}, "value": "${value}"}', + 'name': 'select', + 'variableType': 'global', + 'id': '__built_in_selectResult__', + } + ], + 'actionType': 'request', + 'localVarAction': {'type': 'variableValue', 'variableType': 'global', 'variable': ''}, + 'keyOfDynamicObject': {'type': 'dynamicString', 'content': '', 'i18n': False}, + 'status': { + 'type': 'dynamicSelect', + 'valueType': 'fixed', + 'value': 'normal', + 'variable': '', + 'variableType': 'global', + }, + 'visible': _dynamic_visible_var('select_visible'), + 'marginLeft': 12, + 'marginRight': 12, + 'marginTop': 6, + 'marginBottom': 6, + 'pullOptionsWhileOpen': False, + 'pullOptionsRequestParams': [], + 'margin': 12, + 'innerOffset': 0, + }, + 'title': 'Select', + '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 avatar(node_id, *, name='LangBot', image_variable='bot_avatar'): + """Avatar component in `userInfo` mode — renders the bot's avatar + image and nickname as a header row above the response content. + Mirrors the layout from `I:\\下载\\dingtalk_1782120006374.json` where + Avatar sits at the top of the done-state AICardContent. + + `imageUrl` is bound to a global variable (default `bot_avatar`) so + the adapter can populate it at runtime with a DingTalk media id + (``@xxx``) obtained from the /media/upload endpoint. DingTalk's + Avatar.imageUrl resolver rejects external URLs — it only accepts + DingTalk-hosted media ids, so this binding is the only path to + a custom avatar. + """ + return { + 'componentName': 'Avatar', + 'id': node_id, + 'props': { + 'imageUrl': { + 'value': '', + 'valueType': 'variable', + 'type': 'dynamicImage', + 'variable': image_variable, + 'variableType': 'global', + }, + 'name': {'i18n': False, 'type': 'dynamicString', 'content': name}, + 'sizeType': 'Standard', + 'size': 'extraSmall', + 'customSize': 48, + 'marginLeft': 12, + 'marginRight': 12, + 'marginTop': 6, + 'marginBottom': 6, + 'visible': { + 'type': 'dynamicVisible', + 'value': True, + 'valueType': 'fixed', + 'condition': {'op': 'and', 'conditions': []}, + }, + 'mode': 'userInfo', + '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', + 'Avatar', + 'Input', + 'SelectBlock', + ] + 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': [ + avatar('node_avatar', name='LangBot'), + markdown_block('node_text_content', variable='content'), + input_block('node_input'), + select_block('node_select'), + 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, + ) + ], + } + ], + } + + # Empty containers for flowStatus=2 (writing) and flowStatus=4 (doing). + # AICardContainer expects placeholders to exist for every enabled state; + # without them, the renderer can refuse to advance to flowStatus=3 (done) + # and the card body stays empty. They render nothing visible because + # they have no content children, but their presence satisfies the + # state-machine validation. + def _empty_status_container(node_id, status): + return { + 'componentName': 'AICardStatusContainer', + 'id': node_id, + 'props': { + 'status': status, + 'marginLeft': 0, + 'marginRight': 0, + 'marginTop': 0, + 'marginBottom': 0, + 'enableExtend': False, + 'autoFoldConfig': { + 'needFold': True, + 'heightLimit': 480, + 'foldStatusLocalDataKey': '_cardFoldStatusLocalDataKey', + }, + 'innerOffset': 0, + 'enableCollapse': False, + 'margin': -2, + }, + 'title': f'状态{status}占位', + 'hidden': False, + 'isLocked': False, + 'condition': True, + 'conditionGroup': '', + 'children': [ + { + 'componentName': 'AICardContent', + 'id': f'{node_id}_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': [], + } + ], + } + + writing_state = _empty_status_container('node_status_writing', 2) + doing_state = _empty_status_container('node_status_doing', 4) + + root = { + 'componentName': 'AICardContainer', + 'id': 'node_root', + 'props': { + 'marginLeft': 0, + 'marginRight': 0, + 'marginTop': 0, + 'marginBottom': 0, + 'enablePending': True, + # writing/doing must be enabled so AICardContainer recognises + # flowStatus transitions through 2/4 — without this, the + # working reference template (I:\\下载\\dingtalk_1782055283543.json) + # never reaches the done state and the body stays empty. + 'enableWriting': True, + 'enableDoing': True, + '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, writing_state, doing_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': { + # Match the working reference template — leaving config null lets + # DingTalk pick defaults. Explicit `streaming_mode: true` would + # make the renderer wait for chunks on the streaming endpoint + # (PUT /v1.0/card/streaming), which our adapter does NOT use — + # it pushes content via update_card_data, so streaming_mode=true + # leaves the body empty. + 'config': None, + 'componentsMap': components_map, + 'componentsTree': [root], + 'i18n': {}, + 'version': '1.0.0', + }, + 'mockData': { + 'cardData': { + 'flowStatus': 3, + 'content': '请审核以下报销申请:\n\n- 申请人:张三\n- 金额:¥1,200\n- 类别:差旅', + 'input_visible': '', + 'input_title': '', + 'input_placeholder': '', + 'input_value': '', + 'select_visible': '', + 'select_placeholder': '', + 'index_o': [_empty_select_option()], + 'select_options': [], + 'select_index': -1, + '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, + }, + { + 'id': 'bot_avatar', + 'type': 'string', + 'name': 'bot_avatar', + 'description': '机器人头像 DingTalk 媒体 ID(@xxx 格式,启动时由 /media/upload 拿到)', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'input_visible', + 'type': 'string', + 'name': 'input_visible', + 'description': 'Whether to show the text input component', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'input_title', + 'type': 'string', + 'name': 'input_title', + 'description': 'Text input title', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'input_placeholder', + 'type': 'string', + 'name': 'input_placeholder', + 'description': 'Text input placeholder', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'input_value', + 'type': 'string', + 'name': 'input_value', + 'description': 'Text input current value', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'select_visible', + 'type': 'string', + 'name': 'select_visible', + 'description': 'Whether to show the select component', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'select_placeholder', + 'type': 'string', + 'name': 'select_placeholder', + 'description': 'Select placeholder', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + _select_options_variable(), + { + 'id': 'select_options', + 'type': 'array', + 'name': 'select_options', + 'description': 'Legacy select options', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + { + 'id': 'select_index', + 'type': 'number', + 'name': 'select_index', + 'description': 'Current select index', + 'private': False, + 'editorVarType': 'variables', + 'disabled': False, + }, + btns_var, + ], + 'formList': [], + 'customContextList': [], + 'expList': [], + 'localList': [], + 'hsfList': [], + 'lwpList': [], + 'pageData': {}, + 'extension': { + 'extendType': 'AI', + # All 5 statuses listed — must mirror the enableX flags on + # AICardContainer. The working reference template's extension + # includes 2 (writing) and 4 (doing); omitting them while + # enableWriting/enableDoing are true makes the renderer reject + # transitions and leaves the card body empty. + 'aiStatusList': [3, 1, 5, 2, 4], + '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() diff --git a/src/langbot/libs/dify_service_api/v1/client.py b/src/langbot/libs/dify_service_api/v1/client.py index bc0ee8fa5..a00eedbc1 100644 --- a/src/langbot/libs/dify_service_api/v1/client.py +++ b/src/langbot/libs/dify_service_api/v1/client.py @@ -109,6 +109,62 @@ class AsyncDifyServiceClient: if chunk.startswith('data:'): yield json.loads(chunk[5:]) + async def workflow_submit( + self, + form_token: str, + workflow_run_id: str, + inputs: dict[str, typing.Any], + user: str, + action: str = '', + timeout: float = 120.0, + ) -> typing.AsyncGenerator[dict[str, typing.Any], None]: + """Submit human input to resume a paused workflow, then stream events. + + 1. POST /form/human_input/{form_token} to submit the form + 2. GET /workflow/{task_id}/events to stream the resumed workflow events + """ + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json', + } + + async with httpx.AsyncClient( + base_url=self.base_url, + trust_env=True, + timeout=timeout, + ) as client: + # Step 1: Submit the form + payload: dict[str, typing.Any] = { + 'inputs': inputs if isinstance(inputs, dict) else {}, + 'user': user, + 'action': action, + } + + submit_resp = await client.post( + f'/form/human_input/{form_token}', + headers=headers, + json=payload, + ) + if submit_resp.status_code != 200: + raise DifyAPIError(f'{submit_resp.status_code} {submit_resp.text}') + + # Step 2: Stream resumed workflow events + async with client.stream( + 'GET', + f'/workflow/{workflow_run_id}/events', + headers={'Authorization': f'Bearer {self.api_key}'}, + params={'user': user}, + ) as r: + if r.status_code != 200: + body = (await r.aread()).decode(errors='replace') + raise DifyAPIError(f'{r.status_code} {body}') + async for chunk in r.aiter_lines(): + if chunk.strip() == '': + continue + if chunk.startswith('data:'): + yield json.loads(chunk[5:]) + async def upload_file( self, file: httpx._types.FileTypes, diff --git a/src/langbot/libs/dingtalk_api/api.py b/src/langbot/libs/dingtalk_api/api.py index f453d0bff..5c9df66cb 100644 --- a/src/langbot/libs/dingtalk_api/api.py +++ b/src/langbot/libs/dingtalk_api/api.py @@ -1,17 +1,48 @@ import asyncio import base64 import json +import logging +import os import time +import typing +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' + + +def _stringify_card_param_map(card_param_map: Optional[dict]) -> dict: + """DingTalk cardParamMap only accepts string values. + + Keep callers free to pass structured values for template variables such + as button groups or select options, then encode them once at the API + boundary. + """ + if not card_param_map: + return {} + result = {} + for key, value in card_param_map.items(): + if value is None: + result[key] = '' + elif isinstance(value, str): + result[key] = value + else: + result[key] = json.dumps(value, ensure_ascii=False) + return result + + class DingTalkClient: def __init__( self, @@ -21,6 +52,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 +62,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': [], } @@ -39,8 +79,24 @@ class DingTalkClient: self.access_token_expiry_time = '' self.markdown_card = markdown_card self.logger = logger + # Legacy access_token used by the OLD oapi.dingtalk.com endpoints + # (e.g. /media/upload, which is the only documented way to get an + # `@xxx` media_id usable in card Avatar.imageUrl). The new v1.0 + # token doesn't work there — different auth domain. + self.legacy_access_token = '' + self.legacy_access_token_expiry_time: typing.Optional[float] = None 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 +485,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 +529,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 +550,334 @@ 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': _stringify_card_param_map(card_param_map)} + 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': _stringify_card_param_map(card_param_map)}, + } + 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)[:1500], + ) + 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 get_legacy_access_token(self) -> Optional[str]: + """Fetch the LEGACY (oapi.dingtalk.com) access_token. This is a + different auth domain from the v1.0 token cached in + ``self.access_token`` — only the legacy token authorises the + ``/media/upload`` endpoint that returns an ``@xxx`` media_id + consumable by card components like Avatar.imageUrl. + + Returns the token string on success, None on failure. Caches + with a 60s safety margin before the documented 7200s expiry. + """ + now = time.time() + if ( + self.legacy_access_token + and self.legacy_access_token_expiry_time + and now < self.legacy_access_token_expiry_time + ): + return self.legacy_access_token + + url = 'https://oapi.dingtalk.com/gettoken' + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, params={'appkey': self.key, 'appsecret': self.secret}, timeout=15.0) + data = response.json() if response.status_code == 200 else {} + if data.get('errcode') == 0 and data.get('access_token'): + self.legacy_access_token = data['access_token'] + expires_in = int(data.get('expires_in', 7200)) + self.legacy_access_token_expiry_time = now + expires_in - 60 + return self.legacy_access_token + if self.logger: + await self.logger.error( + f'DingTalk legacy gettoken failed: status={response.status_code} body={response.text[:200]}' + ) + except Exception: + _stdout_logger.exception('DingTalk legacy gettoken error') + if self.logger: + await self.logger.error(f'DingTalk legacy gettoken error: {traceback.format_exc()}') + return None + + async def upload_image_media(self, file_path: str) -> Optional[str]: + """Upload an image file to DingTalk media storage and return the + ``@xxx`` media_id, which can be passed straight into card variables + like Avatar.imageUrl. Endpoint: + + POST https://oapi.dingtalk.com/media/upload?access_token=…&type=image + + Returns the media_id on success, None on any failure (caller + should handle a None gracefully — DingTalk falls back to a + default avatar when imageUrl is empty/unknown). + """ + if not os.path.exists(file_path): + if self.logger: + await self.logger.error(f'DingTalk upload_image_media: file not found {file_path}') + return None + + token = await self.get_legacy_access_token() + if not token: + return None + + url = 'https://oapi.dingtalk.com/media/upload' + try: + with open(file_path, 'rb') as f: + file_bytes = f.read() + file_name = os.path.basename(file_path) + # Best-effort content-type guess; DingTalk accepts the major image + # mime types and otherwise infers from the bytes. + ext = os.path.splitext(file_name)[1].lower().lstrip('.') + mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif'}.get( + ext, 'application/octet-stream' + ) + async with httpx.AsyncClient() as client: + response = await client.post( + url, + params={'access_token': token, 'type': 'image'}, + files={'media': (file_name, file_bytes, mime)}, + timeout=30.0, + ) + data = response.json() if response.status_code == 200 else {} + if data.get('errcode') == 0 and data.get('media_id'): + _stdout_logger.info('DingTalk upload_image_media OK: media_id=%s', data['media_id']) + return data['media_id'] + if self.logger: + await self.logger.error( + f'DingTalk upload_image_media failed: status={response.status_code} body={response.text[:300]}' + ) + except Exception: + _stdout_logger.exception('DingTalk upload_image_media error') + if self.logger: + await self.logger.error(f'DingTalk upload_image_media error: {traceback.format_exc()}') + return None + async def start(self): """启动 WebSocket 连接,监听消息""" self._stopped = False diff --git a/src/langbot/libs/dingtalk_api/card_callback.py b/src/langbot/libs/dingtalk_api/card_callback.py new file mode 100644 index 000000000..4634a696b --- /dev/null +++ b/src/langbot/libs/dingtalk_api/card_callback.py @@ -0,0 +1,106 @@ +"""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'), + ('actionData', 'cardPrivateData', '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 {} + + +def _merge_params(*sources: dict) -> dict: + merged = {} + for source in sources: + if isinstance(source, dict): + merged.update(source) + return merged + + +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) + content = 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 {} + params = _merge_params(_extract_params(content), _extract_params(raw)) + 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' diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py index db3194b6c..0ba1917d9 100644 --- a/src/langbot/libs/qq_official_api/api.py +++ b/src/langbot/libs/qq_official_api/api.py @@ -12,6 +12,142 @@ import traceback from cryptography.hazmat.primitives.asymmetric import ed25519 +QQ_SELECT_ACTION_PREFIX = '__langbot_select__:' + + +def get_select_field_options(form_data: dict) -> tuple[str, list[str]]: + """Return the active select field name and its display/submission values.""" + field_name = str(form_data.get('_current_input_field') or '').strip() + if not field_name: + return '', [] + + field = next( + ( + item + for item in form_data.get('input_defs') or [] + if str(item.get('output_variable_name') or '').strip() == field_name + ), + None, + ) + if not field or str(field.get('type') or '').strip().lower() != 'select': + return '', [] + + source = field.get('option_source') or {} + source_value = source.get('value') if isinstance(source, dict) else None + if isinstance(source_value, list): + return field_name, [str(item) for item in source_value] + if isinstance(source_value, str): + return field_name, [part.strip() for part in source_value.splitlines() if part.strip()] + + options = field.get('options') + if not isinstance(options, list): + return field_name, [] + values = [] + for item in options: + if isinstance(item, dict): + values.append(str(item.get('label') or item.get('value') or '')) + else: + values.append(str(item)) + return field_name, [value for value in values if value] + + +def build_keyboard_from_select_field(form_data: dict, *, buttons_per_row: int | None = None) -> dict: + """Build callback buttons for the currently active Dify select field.""" + _, options = get_select_field_options(form_data) + visible_options = options[:25] + if buttons_per_row is None: + # Keep small choices readable while fitting up to QQ's 5x5 limit. + buttons_per_row = min(5, max(2, (len(visible_options) + 4) // 5)) + selection_actions = [ + { + 'id': f'{QQ_SELECT_ACTION_PREFIX}{idx}', + 'title': option, + 'button_style': 'secondary', + } + for idx, option in enumerate(visible_options) + ] + return build_keyboard_from_form({'actions': selection_actions}, buttons_per_row=buttons_per_row) + + +def resolve_select_button_action(form_data: dict, action_id: str) -> tuple[str, str] | None: + """Resolve a select-button callback to ``(field_name, option_value)``.""" + if not action_id.startswith(QQ_SELECT_ACTION_PREFIX): + return None + try: + option_index = int(action_id[len(QQ_SELECT_ACTION_PREFIX) :]) + except ValueError: + return None + + field_name, options = get_select_field_options(form_data) + if not field_name or option_index < 0 or option_index >= len(options) or option_index >= 25: + return None + return field_name, options[option_index] + + +def build_keyboard_from_form(form_data: dict, *, buttons_per_row: int = 2) -> dict: + """Build a QQ keyboard JSON payload from a Dify human-input form_data. + + Each Dify ``action`` becomes a callback button (``action.type=1``) + whose ``data`` is set directly to the Dify ``action_id``. The + INTERACTION_CREATE event carries this back as + ``data.resolved.button_data`` so the adapter can match the click to + the originating form. + + Layout limits per spec: max 5 rows, max 5 buttons per row. We default + to 2 buttons per row for legibility; oversized button lists wrap + onto additional rows and overflow gets dropped (max 25 visible). + + Args: + form_data: Dify ``{"actions": [{"id", "title", "button_style"}, ...]}``. + buttons_per_row: 1..5. Mobile UI looks best at 2. + + Returns: + ``{"content": {"rows": [{"buttons": [...]}]}}``. + """ + actions = list(form_data.get('actions') or [])[:25] # 5×5 hard cap + buttons_per_row = max(1, min(5, buttons_per_row)) + + def _button(idx: int, action: dict) -> dict: + action_id = str(action.get('id') or '') + label = str(action.get('title') or action_id or f'选项 {idx + 1}') + style_raw = (action.get('button_style') or '').lower() + # QQ: 0 灰色线框, 1 蓝色线框. Highlight the primary / first action. + if style_raw == 'primary' or (style_raw == '' and idx == 0): + style = 1 + else: + style = 0 + return { + 'id': str(idx + 1), + 'render_data': { + 'label': label, + # Shown after the user clicks — gives local "已选择" feedback + # without a follow-up message. Style mimics DingTalk/Lark's + # in-card selection state. + 'visited_label': f'✓ {label}', + 'style': style, + }, + 'action': { + 'type': 1, # callback button + 'permission': {'type': 2}, # everyone can click + 'data': action_id, + 'unsupport_tips': '当前客户端版本不支持此按钮,请升级 QQ', + }, + } + + rows = [] + for row_start in range(0, len(actions), buttons_per_row): + row_actions = actions[row_start : row_start + buttons_per_row] + rows.append( + { + 'buttons': [_button(row_start + j, a) for j, a in enumerate(row_actions)], + } + ) + if len(rows) >= 5: + break + + return {'content': {'rows': rows}} + + class QQOfficialClient: def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False): self.unified_mode = unified_mode @@ -30,6 +166,10 @@ class QQOfficialClient: self.token = token self.app_id = app_id self._message_handlers = {} + # Single optional handler for INTERACTION_CREATE (button click). We + # don't multiplex like message handlers — only the adapter cares, + # and the click<->resume path needs a single source of truth. + self._interaction_handler: Optional[Callable[[Dict[str, Any], Optional[str]], Any]] = None self.base_url = 'https://api.sgroup.qq.com' self.access_token = '' self.access_token_expiry_time = None @@ -107,6 +247,23 @@ class QQOfficialClient: return response, 200 if payload.get('op') == 0: + # INTERACTION_CREATE (button click) skips ``get_message`` — + # that helper only flattens message-event fields and would + # drop ``data.resolved.button_data`` / ``data.button_id``. + if payload.get('t') == 'INTERACTION_CREATE': + if self._interaction_handler: + try: + d = payload.get('d') or {} + # Top-level ``id`` is the ws/event id used as + # ``event_id`` for passive replies. ``d.id`` + # is the interaction id used for ACK. Do not + # confuse the two — QQ rejects misuse with + # 40034025. + ws_event_id = payload.get('id') + await self._interaction_handler(d, ws_event_id) + except Exception: + await self.logger.error(f'Error in interaction handler: {traceback.format_exc()}') + return {'code': 0, 'message': 'success'} message_data = await self.get_message(payload) if message_data: event = QQOfficialEvent.from_payload(message_data) @@ -133,6 +290,21 @@ class QQOfficialClient: return decorator + def on_interaction(self): + """Register a single handler for INTERACTION_CREATE events. + + The handler receives ``(data_dict, interaction_id)`` — the raw + ``d`` payload plus the top-level ``id`` field (the interaction + id, needed for the PUT /interactions/{id} ack and for reuse as + an ``event_id`` on the resumed reply within 30 minutes). + """ + + def decorator(func: Callable[[Dict[str, Any], Optional[str]], Any]): + self._interaction_handler = func + return func + + return decorator + async def _handle_message(self, event: QQOfficialEvent): """处理消息事件""" msg_type = event.t @@ -177,8 +349,20 @@ class QQOfficialClient: content_type = attachment.get('content_type', '') return content_type.startswith('image/') - async def send_private_text_msg(self, user_openid: str, content: str, msg_id: str): - """发送私聊消息""" + async def send_private_text_msg( + self, + user_openid: str, + content: str, + msg_id: Optional[str] = None, + event_id: Optional[str] = None, + msg_seq: int = 1, + ): + """Send a c2c text message. + + Either ``msg_id`` (inbound user msg, free passive reply) or + ``event_id`` (e.g. INTERACTION_CREATE id, valid 30 min) is + required. Without either, the call costs the proactive-send quota. + """ if not await self.check_access_token(): await self.get_access_token() @@ -188,11 +372,15 @@ class QQOfficialClient: 'Authorization': f'QQBot {self.access_token}', 'Content-Type': 'application/json', } - data = { + data: dict[str, Any] = { 'content': content, 'msg_type': 0, - 'msg_id': msg_id, + 'msg_seq': msg_seq, } + if msg_id: + data['msg_id'] = msg_id + if event_id: + data['event_id'] = event_id response = await client.post(url, headers=headers, json=data) response_data = response.json() if response.status_code == 200: @@ -201,8 +389,19 @@ class QQOfficialClient: await self.logger.error(f'Failed to send private message: {response_data}') raise ValueError(response) - async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str): - """发送群聊消息""" + async def send_group_text_msg( + self, + group_openid: str, + content: str, + msg_id: Optional[str] = None, + event_id: Optional[str] = None, + msg_seq: int = 1, + ): + """Send a group text message. + + Either ``msg_id`` or ``event_id`` is required (see + :meth:`send_private_text_msg` for the distinction). + """ if not await self.check_access_token(): await self.get_access_token() @@ -212,11 +411,15 @@ class QQOfficialClient: 'Authorization': f'QQBot {self.access_token}', 'Content-Type': 'application/json', } - data = { + data: dict[str, Any] = { 'content': content, 'msg_type': 0, - 'msg_id': msg_id, + 'msg_seq': msg_seq, } + if msg_id: + data['msg_id'] = msg_id + if event_id: + data['event_id'] = event_id response = await client.post(url, headers=headers, json=data) if response.status_code == 200: return @@ -485,6 +688,107 @@ class QQOfficialClient: raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}') return response.json() + async def send_markdown_keyboard( + self, + target_type: str, + target_id: str, + markdown_content: str, + keyboard: Optional[dict] = None, + msg_id: Optional[str] = None, + event_id: Optional[str] = None, + msg_seq: int = 1, + ) -> dict: + """Send a ``msg_type=2`` (markdown) message carrying a keyboard. + + The keyboard ride-along is the only documented way to attach + buttons in QQ official; pure keyboard-only messages are not + accepted by the server (markdown content is required). + + Args: + target_type: 'c2c' (single chat), 'group', 'channel' (text + channel — uses POST /channels/{id}/messages instead of v2). + target_id: openid for c2c/group, channel_id for channel. + markdown_content: Plain markdown text shown above the buttons. + keyboard: ``{'content': {'rows': [{'buttons': [...]}]}}`` per + the official spec. Use :func:`build_keyboard_from_form` + to construct from Dify form_data. + msg_id: Inbound user message id; turns this into a passive + reply (preferred — no monthly quota cost). + event_id: Use ``INTERACTION_CREATE`` event id from a prior + button click to keep within the 30-minute passive window + without an inbound msg_id. + msg_seq: De-dup counter when reusing msg_id. + """ + if not await self.check_access_token(): + await self.get_access_token() + + if target_type == 'c2c': + url = f'{self.base_url}/v2/users/{target_id}/messages' + elif target_type == 'group': + url = f'{self.base_url}/v2/groups/{target_id}/messages' + elif target_type == 'channel': + url = f'{self.base_url}/channels/{target_id}/messages' + else: + raise ValueError(f'Unsupported target_type for markdown+keyboard: {target_type}') + + body: dict[str, Any] = { + 'msg_type': 2, + 'markdown': {'content': markdown_content}, + 'msg_seq': msg_seq, + } + if keyboard and keyboard.get('content', {}).get('rows'): + body['keyboard'] = keyboard + if msg_id: + body['msg_id'] = msg_id + if event_id: + body['event_id'] = event_id + + async with httpx.AsyncClient(timeout=30) as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + response = await client.post(url, headers=headers, json=body) + if response.status_code != 200: + await self.logger.error( + f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}' + ) + raise Exception(f'Failed to send markdown+keyboard: HTTP {response.status_code} {response.text}') + return response.json() + + async def ack_interaction(self, interaction_id: str, code: int = 0) -> None: + """Acknowledge a button-click INTERACTION_CREATE event. + + QQ keeps the client in a loading spinner until this ack is + received. Should be called as soon as the click is parsed, before + any heavier downstream work (the actual workflow resume can run + async). + + Args: + interaction_id: The ``id`` field from the INTERACTION_CREATE event. + code: 0=success, 1=fail, 2=rate-limited, 3=duplicate, 4=no + permission, 5=admin only. Default 0. + """ + if not interaction_id: + return + if not await self.check_access_token(): + await self.get_access_token() + + url = f'{self.base_url}/interactions/{interaction_id}' + async with httpx.AsyncClient(timeout=10) as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + try: + response = await client.put(url, headers=headers, json={'code': code}) + if response.status_code >= 400: + await self.logger.warning( + f'ack_interaction non-success: HTTP {response.status_code} {response.text}' + ) + except Exception as e: + await self.logger.warning(f'ack_interaction error (non-fatal): {e}') + async def is_token_expired(self): """检查token是否过期""" if self.access_token_expiry_time is None: @@ -653,6 +957,12 @@ class QQOfficialClient: d = payload.get('d', {}) s = payload.get('s') t = payload.get('t') + # Top-level event id, distinct from `d.id`. Per QQ + # spec this is the only value accepted as ``event_id`` + # in subsequent passive-reply send-message calls + # (``d.id`` for INTERACTION_CREATE is the interaction + # id, used solely for PUT /interactions/{id} ack). + ws_event_id = payload.get('id') if not isinstance(d, dict): d = {} @@ -731,7 +1041,22 @@ class QQOfficialClient: else: await self.logger.debug(f'Received event: {t}, seq={s}') - if on_event: + # INTERACTION_CREATE bypasses the regular + # on_event dispatcher so the adapter sees the + # top-level ws_event_id (needed as event_id + # for the resumed reply) — same shape as the + # webhook handler. + if t == 'INTERACTION_CREATE': + if self._interaction_handler: + try: + result = self._interaction_handler(d, ws_event_id) + if asyncio.iscoroutine(result): + await result + except Exception: + await self.logger.error( + f'Error in interaction handler (ws): {traceback.format_exc()}' + ) + elif on_event: try: result = on_event(t, d) if asyncio.iscoroutine(result): diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index b6f45cf22..4e2c6a28a 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -67,6 +67,16 @@ class StreamSession: # 反馈 ID,用于接收用户点赞/点踩反馈 feedback_id: Optional[str] = None + # Dify 人工输入暂停态:runner 把 _form_data 传过来时填充。 + # 一旦设置,下次企微 followup 请求时返回 button_interaction 模板卡 + # 替代 stream chunk。点击按钮会回调 template_card_event,EventKey + # 就是 Dify 的 action_id。 + pending_form: Optional[dict] = None + + # template_card task_id(企微要求 button_interaction 必填且不可重复)。 + # 创建 pending_form 时生成;按钮点击回调里用来反查 session。 + pending_form_task_id: Optional[str] = None + class StreamSessionManager: """管理 stream 会话的生命周期,并负责队列的生产消费。""" @@ -83,6 +93,9 @@ class StreamSessionManager: self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射 self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话 self._feedback_index: dict[str, str] = {} # feedback_id -> stream_id 映射 + # task_id (button_interaction template_card 的) -> stream_id 映射, + # 用于按钮点击回调里反查 pending_form。 + self._task_index: dict[str, str] = {} def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]: if not msg_id: @@ -118,6 +131,40 @@ class StreamSessionManager: if feedback_id and stream_id: self._feedback_index[feedback_id] = stream_id + def set_pending_form(self, stream_id: str, form_data: dict, task_id: str) -> None: + """把 Dify 人工输入暂停态绑定到 stream session。 + + 下一次企微 followup 请求时,adapter 检测到 pending_form, + 返回 button_interaction 模板卡而不是 stream chunk。 + """ + session = self._sessions.get(stream_id) + if not session: + return + session.pending_form = form_data + session.pending_form_task_id = task_id + if task_id: + self._task_index[task_id] = stream_id + + def get_session_by_task_id(self, task_id: str) -> Optional[StreamSession]: + """按按钮点击回调里的 TaskId 反查 session。""" + if not task_id: + return None + stream_id = self._task_index.get(task_id) + if not stream_id: + return None + return self._sessions.get(stream_id) + + def clear_pending_form(self, stream_id: str) -> None: + """按钮点击消费完后清掉 pending_form,避免重复弹卡。""" + session = self._sessions.get(stream_id) + if not session: + return + task_id = session.pending_form_task_id + session.pending_form = None + session.pending_form_task_id = None + if task_id: + self._task_index.pop(task_id, None) + def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]: """根据企业微信回调创建或获取会话。 @@ -723,8 +770,811 @@ async def parse_wecom_bot_message( return message_data +def _wecom_button_style(action: dict, *, selected: bool = False) -> int: + """Map Dify button style to WeCom button style.""" + + if not selected: + return 2 + + return 1 + + +def _wecom_field_display_name(field: dict, fallback: str = '') -> str: + label = ( + field.get('label') or field.get('title') or field.get('name') or field.get('output_variable_name') or fallback + ) + return str(label or fallback).strip() + + +def _wecom_input_hint_lines(form_data: dict) -> list[str]: + lines: list[str] = [] + current_field = str(form_data.get('_current_input_field') or '').strip() + for field in form_data.get('input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + field_type = str(field.get('type') or 'text').strip().lower() + field_label = _wecom_field_display_name(field, field_name) + if current_field and field_name != current_field: + continue + if not field_name: + continue + if field_type in {'file', 'file-list'}: + limit = field.get('number_limits') if field_type == 'file-list' else 1 + allowed_types = ', '.join(field.get('allowed_file_types') or []) + suffix = f', up to {limit}' if field_type == 'file-list' and limit else '' + allowed = f' ({allowed_types})' if allowed_types else '' + lines.append(f'- {field_label}: upload file(s){allowed}{suffix} or reply `{field_name}: `') + return lines + + +def _wecom_pending_input_defs(form_data: dict) -> list[dict]: + if form_data.get('_action_select_only'): + return [] + inputs = form_data.get('inputs') or {} + current_field = str(form_data.get('_current_input_field') or '').strip() + pending = [] + for field in form_data.get('input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + if current_field and field_name != current_field: + continue + if str(field.get('type') or '').strip().lower() in {'file', 'file-list'}: + continue + if inputs.get(field_name) in (None, '', []): + pending.append(field) + return pending + + +def _wecom_select_options(field: dict) -> list[str]: + source = field.get('option_source') or {} + options = source.get('value') if isinstance(source, dict) else [] + if not isinstance(options, list): + return [] + return [str(option) for option in options] + + +def _wecom_select_option_id(index: int) -> str: + return f'opt_{index + 1}' + + +def _wecom_pending_select_defs(form_data: dict) -> list[dict]: + return [ + field + for field in _wecom_pending_input_defs(form_data) + if str(field.get('type') or '').strip().lower() == 'select' and _wecom_select_options(field) + ] + + +def _wecom_field_title(field: dict, fallback: str) -> str: + title = _wecom_field_display_name(field, fallback) + return str(title or fallback).strip()[:13] or fallback + + +def _wecom_form_desc(form_data: dict) -> str: + form_content = _wecom_clean_form_content(form_data) + return form_content[:512] if form_content else '' + + +def build_human_input_text_prompt(form_data: dict) -> Optional[str]: + """Build a plain-text prompt for a current non-select input field.""" + + current_field = str(form_data.get('_current_input_field') or '').strip() + if not current_field: + return None + for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: + if str(field.get('output_variable_name') or '').strip() != current_field: + continue + field_type = str(field.get('type') or 'text').strip().lower() + if field_type == 'select': + return None + form_content = _wecom_clean_form_content(form_data) + if not form_content: + form_content = _wecom_field_display_name(field, current_field) + node_title = str(form_data.get('node_title') or '人工介入').strip() + return f'{node_title}\n\n{form_content}' if form_content else node_title + return None + + +def build_multiple_interaction_payload( + form_data: dict, + task_id: str, + *, + source: Optional[dict] = None, +) -> dict[str, Any]: + """Build a WeCom multiple_interaction card for pending select fields.""" + + select_fields = _wecom_pending_select_defs(form_data) + node_title = (form_data.get('node_title') or '').strip() or 'Human Input' + inputs = form_data.get('inputs') or {} + + select_list = [] + for field_index, field in enumerate(select_fields[:10]): + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + options = _wecom_select_options(field)[:10] + option_list = [ + { + 'id': _wecom_select_option_id(idx), + 'text': option_text[:10] or _wecom_select_option_id(idx), + } + for idx, option_text in enumerate(options) + ] + selected_id = _wecom_select_option_id(0) + current_value = inputs.get(field_name) + if current_value not in (None, '', []): + for idx, option_text in enumerate(options): + if str(current_value) == option_text: + selected_id = _wecom_select_option_id(idx) + break + select_list.append( + { + 'question_key': field_name, + 'title': _wecom_field_title(field, f'Select {field_index + 1}'), + 'selected_id': selected_id, + 'option_list': option_list, + } + ) + + card: dict[str, Any] = { + 'card_type': 'multiple_interaction', + 'main_title': { + 'title': node_title, + 'desc': _wecom_form_desc(form_data), + }, + 'select_list': select_list, + 'submit_button': { + 'text': 'Submit', + 'key': 'submit_human_input', + }, + 'task_id': task_id, + } + if source: + card['source'] = source + return { + 'msgtype': 'template_card', + 'template_card': card, + } + + +_SELECT_BUTTON_KEY_PREFIX = '__dify_select__' + + +def _encode_select_button_key(field_name: str, option_index: int) -> str: + data = json.dumps({'f': field_name, 'i': option_index}, ensure_ascii=False, separators=(',', ':')) + encoded = base64.urlsafe_b64encode(data.encode('utf-8')).decode('ascii').rstrip('=') + return f'{_SELECT_BUTTON_KEY_PREFIX}:{encoded}' + + +def parse_select_button_action(action_id: str, form_data: dict) -> dict[str, str]: + """Decode a select option represented as a button_interaction click.""" + + action_id = str(action_id or '').strip() + prefix = f'{_SELECT_BUTTON_KEY_PREFIX}:' + if not action_id.startswith(prefix): + return {} + encoded = action_id[len(prefix) :] + try: + padded = encoded + '=' * (-len(encoded) % 4) + data = json.loads(base64.urlsafe_b64decode(padded.encode('ascii')).decode('utf-8')) + except Exception: + return {} + field_name = str(data.get('f') or '').strip() + option_index = data.get('i') + if not field_name or not isinstance(option_index, int): + return {} + for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: + if str(field.get('output_variable_name') or '').strip() != field_name: + continue + options = _wecom_select_options(field) + if 0 <= option_index < len(options): + return {field_name: options[option_index]} + return {} + + +def build_select_button_interaction_payload( + form_data: dict, + task_id: str, + *, + source: Optional[dict] = None, +) -> dict[str, Any]: + """Build a button_interaction card that emulates a select field. + + WeCom AI Bot long-connection callbacks are reliable for button clicks, so + this is used as a fallback when multiple_interaction submit callbacks are + not delivered by the platform. + """ + + select_fields = _wecom_pending_select_defs(form_data) + field = select_fields[0] if select_fields else {} + field_name = str(field.get('output_variable_name') or '').strip() + options = _wecom_select_options(field)[:10] if field else [] + visible_options = options[:6] + overflow_options = options[6:] + + node_title = (form_data.get('node_title') or '').strip() or 'Human Input' + form_content = _wecom_clean_form_content(form_data) + + sub_title_parts: list[str] = [] + if form_content: + sub_title_parts.append(form_content) + if overflow_options: + extra_lines = [f' - {idx + 7}. {option}' for idx, option in enumerate(overflow_options)] + sub_title_parts.append( + 'More options can be entered by replying with the option text:\n' + '\n'.join(extra_lines) + ) + + button_list = [ + { + 'text': option_text[:10] or f'Option {idx + 1}', + 'style': 2 if idx == 0 else 0, + 'key': _encode_select_button_key(field_name, idx), + } + for idx, option_text in enumerate(visible_options) + ] + + card: dict[str, Any] = { + 'card_type': 'button_interaction', + 'main_title': { + 'title': node_title, + }, + 'sub_title_text': '\n\n'.join(sub_title_parts), + 'button_list': button_list, + 'task_id': task_id, + } + if source: + card['source'] = source + return { + 'msgtype': 'template_card', + 'template_card': card, + } + + +def build_human_input_template_card_payload( + form_data: dict, + task_id: str, + *, + source: Optional[dict] = None, + select_as_buttons: bool = False, +) -> dict[str, Any]: + """Build the best WeCom template card for a Dify human-input form.""" + + if _wecom_pending_select_defs(form_data): + if select_as_buttons: + return build_select_button_interaction_payload(form_data, task_id, source=source) + return build_multiple_interaction_payload(form_data, task_id, source=source) + return build_button_interaction_payload(form_data, task_id, source=source) + + +def _wecom_clean_form_content(form_data: dict) -> str: + is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only') + raw_content = str(form_data.get('raw_form_content') or '') + content = form_data.get('form_content') or raw_content + input_defs = list(form_data.get('all_input_defs') or form_data.get('input_defs') or []) + fields = { + str(field.get('output_variable_name') or '').strip(): field + for field in input_defs + if str(field.get('output_variable_name') or '').strip() + } + + if form_data.get('_action_select_only') and raw_content: + placeholders = [ + match + for match in re.finditer(r'\{\{#\$output\.([^#{}]+)#\}\}', raw_content) + if match.group(1).strip() in fields + ] + if placeholders: + content = raw_content[placeholders[-1].end() :] + + if is_field_step: + inputs = form_data.get('inputs') or {} + + def replace_placeholder(match: re.Match[str]) -> str: + field_name = match.group(1).strip() + field = fields.get(field_name) + value = inputs.get(field_name) + if not field or value in (None, '', []): + return '' + return f'✅ {field_name}:{_wecom_display_input_value(field, value)}' + + content = re.sub(r'\{\{#\$output\.([^#{}]+)#\}\}', replace_placeholder, str(content)) + + kept_lines: list[str] = [] + for line in str(content).splitlines(): + placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) + if placeholder and placeholder.group(1).strip() in fields: + continue + kept_lines.append(line) + return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip()) + + +def _wecom_display_input_value(field: dict, value: Any) -> str: + field_type = str(field.get('type') or 'text').strip().lower() + if field_type == 'file': + if isinstance(value, dict): + return str(value.get('url') or value.get('upload_file_id') or '1 file') + elif field_type == 'file-list' and isinstance(value, list): + return f'{len(value)} file(s)' + return str(value) + + +def build_button_interaction_payload( + form_data: dict, + task_id: str, + *, + source: Optional[dict] = None, +) -> dict[str, Any]: + """Build a `template_card` (button_interaction) WeCom payload. + + Shared by both the webhook-mode client (returns the payload as the + response to a stream-followup callback) and the ws_client (sends it + as a reply frame). Output shape is `{"msgtype": "template_card", + "template_card": {...}}` per the WeCom spec. + + Args: + form_data: Dify human-input form data with keys ``actions`` (list of + ``{id, title, button_style}``), ``node_title``, ``form_content``. + task_id: Unique per-card identifier. WeCom requires this for + button_interaction. The click callback returns it as TaskId so we + can find the originating session. + source: Optional source header dict ``{icon_url, desc, desc_color}`` + shown at the top of the card. WeCom accepts arbitrary HTTPS + URLs for ``icon_url`` (unlike DingTalk Avatar which requires + a uploaded media id), so the LangBot logo URL can be passed + straight through. + + Notes: + * ``button.key`` is set directly to the Dify ``action_id``. The click + callback's ``EventKey`` carries this back unchanged (1024-byte limit + per the spec, far more than we ever need). + * WeCom caps the button list at 6. Extra actions are appended to + ``sub_title_text`` so users can still reply with the id as text. + * Styles map ``primary``→1 (blue), ``danger``→2 (red), default→0 + (gray). First button is auto-promoted to primary when no style. + """ + actions = list(form_data.get('actions') or []) + node_title = (form_data.get('node_title') or '').strip() or '人工介入' + form_content = _wecom_clean_form_content(form_data) + should_show_actions = not _wecom_pending_input_defs(form_data) + + visible_actions = actions[:6] if should_show_actions else [] + overflow = actions[6:] if should_show_actions else [] + + sub_title_parts: list[str] = [] + if form_content: + sub_title_parts.append(form_content) + input_hint_lines = _wecom_input_hint_lines(form_data) + if input_hint_lines: + sub_title_parts.append('Fill these fields in chat before choosing an action:\n' + '\n'.join(input_hint_lines)) + if overflow: + extra_lines = [f' - {a.get("title") or a.get("id") or ""} (回复 id: {a.get("id") or ""})' for a in overflow] + sub_title_parts.append(f'另有 {len(overflow)} 个选项不在按钮列表中,可直接回复 id:\n' + '\n'.join(extra_lines)) + sub_title_text = '\n\n'.join(sub_title_parts) + + button_list = [] + for idx, action in enumerate(visible_actions): + action_id = str(action.get('id') or '') + title = str(action.get('title') or action_id or f'选项 {idx + 1}') + button_list.append( + { + 'text': title, + 'style': _wecom_button_style(action), + 'key': action_id, + } + ) + + card: dict[str, Any] = { + 'card_type': 'button_interaction', + 'main_title': { + 'title': node_title, + }, + 'sub_title_text': sub_title_text, + 'button_list': button_list, + 'task_id': task_id, + } + if source: + card['source'] = source + return { + 'msgtype': 'template_card', + 'template_card': card, + } + + +def extract_template_card_action(tce: dict[str, Any]) -> tuple[str, str, str]: + """Extract task id, clicked button key, and card type from a WeCom callback.""" + + task_id = tce.get('TaskId') or tce.get('task_id') or tce.get('taskid') or tce.get('taskId') or '' + event_key = ( + tce.get('EventKey') + or tce.get('event_key') + or tce.get('eventkey') + or tce.get('eventKey') + or tce.get('key') + or tce.get('Key') + or '' + ) + card_type = tce.get('CardType') or tce.get('card_type') or tce.get('cardtype') or tce.get('cardType') or '' + + for button_key in ('button', 'Button', 'selected_button', 'selectedButton'): + button = tce.get(button_key) + if isinstance(button, dict): + if not event_key: + event_key = ( + button.get('key') + or button.get('Key') + or button.get('event_key') + or button.get('EventKey') + or button.get('id') + or button.get('Id') + or '' + ) + break + + return str(task_id or ''), str(event_key or ''), str(card_type or '') + + +def extract_wecom_event_type(payload: dict[str, Any]) -> str: + """Extract eventtype from common WeCom callback wrapper shapes.""" + + event = payload.get('event') if isinstance(payload, dict) else {} + if not isinstance(event, dict): + event = {} + event_type = ( + event.get('eventtype') + or event.get('event_type') + or event.get('eventType') + or event.get('EventType') + or payload.get('eventtype') + or payload.get('event_type') + or payload.get('eventType') + or payload.get('EventType') + or '' + ) + if event_type: + return str(event_type) + + tce = extract_template_card_event_payload(payload) + task_id, event_key, card_type = extract_template_card_action(tce) + if task_id or event_key or card_type or extract_template_card_selections(tce): + return 'template_card_event' + return '' + + +def extract_template_card_event_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Extract template_card_event from common WeCom callback wrapper shapes.""" + + if not isinstance(payload, dict): + return {} + event = payload.get('event') if isinstance(payload.get('event'), dict) else {} + candidates = ( + event.get('template_card_event'), + event.get('templateCardEvent'), + event.get('TemplateCardEvent'), + event.get('template_card'), + payload.get('template_card_event'), + payload.get('templateCardEvent'), + payload.get('TemplateCardEvent'), + payload.get('template_card'), + ) + for candidate in candidates: + if isinstance(candidate, dict): + return candidate + if any( + key in payload + for key in ( + 'TaskId', + 'task_id', + 'taskId', + 'EventKey', + 'event_key', + 'eventKey', + 'CardType', + 'card_type', + 'cardType', + 'ResponseData', + 'response_data', + 'select_list', + 'SelectList', + ) + ): + return payload + if any( + key in event + for key in ( + 'TaskId', + 'task_id', + 'taskId', + 'EventKey', + 'event_key', + 'eventKey', + 'CardType', + 'card_type', + 'cardType', + 'ResponseData', + 'response_data', + 'select_list', + 'SelectList', + ) + ): + return event + return {} + + +def extract_template_card_selections(tce: dict[str, Any], form_data: Optional[dict] = None) -> dict[str, str]: + """Extract multiple_interaction select values from a WeCom callback. + + WeCom callback examples differ between webhook and websocket docs, so this + parser accepts common snake_case/camelCase/PascalCase variants and maps the + selected option id back to the Dify select option text when form_data is + available. + """ + + fields_by_name: dict[str, dict] = {} + if form_data: + for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + if field_name: + fields_by_name[field_name] = field + + def _maybe_decode_json(value: Any) -> Any: + if not isinstance(value, str): + return value + text = value.strip() + if not text or text[0] not in '[{': + return value + try: + return json.loads(text) + except json.JSONDecodeError: + return value + + def _walk(value: Any) -> list[dict]: + value = _maybe_decode_json(value) + found: list[dict] = [] + if isinstance(value, dict): + found.append(value) + for child in value.values(): + found.extend(_walk(child)) + elif isinstance(value, list): + for child in value: + found.extend(_walk(child)) + return found + + def _lookup(item: dict, *keys: str) -> Any: + for key in keys: + if key in item: + return item.get(key) + lower_map = {str(key).lower(): value for key, value in item.items()} + for key in keys: + lowered = key.lower() + if lowered in lower_map: + return lower_map[lowered] + return '' + + def _normalise_selected_value(question_key: str, selected: Any) -> str: + selected = _maybe_decode_json(selected) + if isinstance(selected, dict): + selected = _lookup( + selected, + 'selected_id', + 'SelectedId', + 'selected_option_id', + 'SelectedOptionId', + 'option_id', + 'OptionId', + 'id', + 'Id', + 'value', + 'Value', + ) + selected_id = str(selected or '').strip() + if not selected_id: + return '' + selected_value = selected_id + field = fields_by_name.get(question_key) + if field: + options = _wecom_select_options(field) + for idx, option_text in enumerate(options): + if selected_id in {_wecom_select_option_id(idx), option_text}: + selected_value = option_text + break + return selected_value + + selections: dict[str, str] = {} + for item in _walk(tce): + question_key = _lookup( + item, + 'question_key', + 'questionKey', + 'QuestionKey', + 'question', + 'Question', + 'key', + 'Key', + ) + selected_id = _lookup( + item, + 'selected_id', + 'selectedId', + 'SelectedId', + 'selected_option_id', + 'selectedOptionId', + 'SelectedOptionId', + 'option_id', + 'optionId', + 'OptionId', + 'value', + 'Value', + ) + question_key = str(question_key or '').strip() + selected_id = str(selected_id or '').strip() + if question_key not in fields_by_name or not selected_id: + continue + + selected_value = _normalise_selected_value(question_key, selected_id) + if not selected_value: + continue + selections[question_key] = selected_value + + # Some WeCom callbacks encode ResponseData as a direct mapping: + # {"xiala": "id_two"} rather than a select_list item array. + for item in _walk(tce): + if not isinstance(item, dict): + continue + for question_key, selected in item.items(): + question_key = str(question_key or '').strip() + if question_key not in fields_by_name or question_key in selections: + continue + selected_value = _normalise_selected_value(question_key, selected) + if selected_value: + selections[question_key] = selected_value + + return selections + + +def resolve_form_action_title(form_data: dict, action_id: str) -> str: + """Resolve a Dify form action title from its id.""" + + clean_action_id = str(action_id or '').strip() + for action in form_data.get('actions') or []: + if str(action.get('id', '')) == clean_action_id: + return str(action.get('title') or clean_action_id) + return clean_action_id + + +def build_button_interaction_update_card( + form_data: dict, + task_id: str, + action_id: str, + source: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """Build the template_card body used to update a clicked form card.""" + + node_title = str(form_data.get('node_title') or '').strip() or '人工介入' + form_content = _wecom_clean_form_content(form_data) + action_title = resolve_form_action_title(form_data, action_id) + clean_action_id = str(action_id or '').strip() + + button_list = [] + matched = False + for idx, action in enumerate(list(form_data.get('actions') or [])[:6]): + action_key = str(action.get('id') or '') + button_title = str(action.get('title') or action_key or f'Option {idx + 1}') + button = { + 'text': button_title, + 'style': _wecom_button_style(action), + 'key': action_key, + } + if action_key == clean_action_id: + button['style'] = _wecom_button_style(action, selected=True) + button['text'] = f'✅ {button_title}' + button['replace_text'] = f'✅ {button_title}' + matched = True + button_list.append(button) + + if clean_action_id and not matched: + button_list.append( + { + 'text': action_title or clean_action_id, + 'style': 1, + 'key': clean_action_id, + 'replace_text': f'✅ {action_title or clean_action_id}', + } + ) + + card: dict[str, Any] = { + 'card_type': 'button_interaction', + 'main_title': { + 'title': node_title, + }, + 'sub_title_text': form_content, + 'button_list': button_list, + 'task_id': task_id, + } + if source: + card['source'] = source + return card + + +def build_multiple_interaction_update_card( + form_data: dict, + task_id: str, + selections: Optional[dict[str, str]] = None, + source: Optional[dict[str, Any]] = None, +) -> dict[str, Any]: + """Build an update card that freezes submitted select values.""" + + node_title = str(form_data.get('node_title') or '').strip() or 'Human Input' + selected_values = dict(form_data.get('inputs') or {}) + selected_values.update(selections or {}) + + select_list = [] + fields = _wecom_pending_select_defs(form_data) + if not fields: + fields = [ + field + for field in (form_data.get('input_defs') or form_data.get('all_input_defs') or []) + if str(field.get('type') or '').strip().lower() == 'select' + ] + for field_index, field in enumerate(fields[:10]): + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + options = _wecom_select_options(field)[:10] + option_list = [ + { + 'id': _wecom_select_option_id(idx), + 'text': option_text[:10] or _wecom_select_option_id(idx), + } + for idx, option_text in enumerate(options) + ] + selected_id = _wecom_select_option_id(0) + current_value = selected_values.get(field_name) + if current_value not in (None, '', []): + for idx, option_text in enumerate(options): + if str(current_value) == option_text or str(current_value) == _wecom_select_option_id(idx): + selected_id = _wecom_select_option_id(idx) + break + select_list.append( + { + 'question_key': field_name, + 'title': _wecom_field_title(field, f'Select {field_index + 1}'), + 'disable': True, + 'selected_id': selected_id, + 'option_list': option_list, + } + ) + + display_form_data = dict(form_data) + display_form_data['inputs'] = selected_values + form_content = _wecom_clean_form_content(display_form_data) + + card: dict[str, Any] = { + 'card_type': 'multiple_interaction', + 'main_title': { + 'title': node_title, + 'desc': form_content, + }, + 'select_list': select_list, + 'submit_button': { + 'text': '✅', + 'key': 'submit_human_input', + }, + 'task_id': task_id, + } + if source: + card['source'] = source + return card + + class WecomBotClient: - def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False): + def __init__( + self, + Token: str, + EnCodingAESKey: str, + Corpid: str, + logger: EventLogger, + unified_mode: bool = False, + ): """企业微信智能机器人客户端。 Args: @@ -761,6 +1611,17 @@ class WecomBotClient: self.stream_poll_timeout = 0.5 self._feedback_callback: Optional[Callable] = None + self._card_action_callback: Optional[Callable] = None + self._stream_last_content: dict[str, str] = {} + # Optional `source` block injected into every interactive template_card + # the client builds. Set via `set_card_source` from the adapter after + # reading config. Format: {icon_url, desc, desc_color}. + self.card_source: Optional[dict] = None + + def set_card_source(self, source: Optional[dict]) -> None: + """Set the `source` header dict injected into every + button_interaction template_card. Pass None to clear.""" + self.card_source = source def set_feedback_callback(self, callback: Callable) -> None: """设置反馈回调函数。 @@ -770,6 +1631,19 @@ class WecomBotClient: """ self._feedback_callback = callback + def set_card_action_callback(self, callback: Callable) -> None: + """设置按钮卡片点击回调函数。 + + Signature: ``async def callback(session, action_id, task_id, raw_event) -> None`` + + ``session`` is the StreamSession the card was attached to; + ``action_id`` is the Dify action_id reflected back via the + button's ``key`` field; ``task_id`` is the card's task_id + (matches ``session.pending_form_task_id``); ``raw_event`` is the + decoded callback JSON for any extra fields the adapter wants. + """ + self._card_action_callback = callback + @staticmethod def _build_stream_payload( stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None @@ -800,6 +1674,12 @@ class WecomBotClient: 'stream': stream_payload, } + def _build_button_interaction_payload(self, form_data: dict, task_id: str) -> dict[str, Any]: + """Class-level shim — delegates to module-level builder and auto- + injects the client's configured `source` block so every card emitted + through this client carries the LangBot header.""" + return build_human_input_template_card_payload(form_data, task_id, source=self.card_source) + async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]: """对响应进行加密封装并返回给企业微信。 @@ -892,6 +1772,22 @@ class WecomBotClient: return await self._encrypt_and_reply(self._build_stream_payload('', '', True), nonce) session = self.stream_sessions.get_session(stream_id) + + # If a Dify human-input pause arrived during this stream, switch + # the response from `msgtype: stream` to `msgtype: template_card` + # (button_interaction). The session's stream is also marked + # finished so future followups aren't expected (assuming the + # WeCom client treats template_card as the terminal response — + # we'll know from the next callback whether it kept polling). + if session and session.pending_form and session.pending_form_task_id: + await self.logger.info( + f'WeComBot: returning button_interaction for stream_id={stream_id} ' + f'task_id={session.pending_form_task_id} actions={len(session.pending_form.get("actions") or [])}' + ) + card_payload = self._build_button_interaction_payload(session.pending_form, session.pending_form_task_id) + self.stream_sessions.mark_finished(stream_id) + return await self._encrypt_and_reply(card_payload, nonce) + chunk = await self.stream_sessions.consume(stream_id, timeout=self.stream_poll_timeout) if not chunk: @@ -994,17 +1890,53 @@ class WecomBotClient: msg_json = json.loads(decrypted_xml) - event = msg_json.get('event', {}) - event_type = event.get('eventtype', '') + event_type = extract_wecom_event_type(msg_json) if event_type == 'feedback_event': return await self._handle_feedback_event(msg_json, nonce) + # Button click on a button_interaction template_card. The WeCom doc + # calls this `template_card_event`; some routes wrap the button + # event payload inside `event.template_card_event`. + if event_type == 'template_card_event': + return await self._handle_template_card_event(msg_json, nonce) + if msg_json.get('msgtype') == 'stream': return await self._handle_post_followup_response(msg_json, nonce) return await self._handle_post_initial_response(msg_json, nonce) + async def _handle_template_card_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: + """Handle a button click on a button_interaction template_card. + + WeCom carries the click info in ``event.template_card_event`` with + ``TaskId`` matching the card we created and ``EventKey`` carrying + the button's ``key`` (which we set to the Dify ``action_id``). + """ + try: + tce = extract_template_card_event_payload(msg_json) + task_id, event_key, card_type = extract_template_card_action(tce) + + await self.logger.info(f'收到按钮点击: task_id={task_id} event_key={event_key!r} card_type={card_type}') + + session = self.stream_sessions.get_session_by_task_id(task_id) + if session is None: + await self.logger.warning(f'未找到 task_id={task_id} 对应的 session,按钮点击被丢弃') + else: + if self._card_action_callback is not None: + try: + await self._card_action_callback(session, event_key, task_id, msg_json) + except Exception: + await self.logger.error(f'card action callback raised: {traceback.format_exc()}') + # Drop the form so a fresh chunk/followup doesn't re-render + # the same card (and so the task_id can be GC'd). + self.stream_sessions.clear_pending_form(session.stream_id) + except Exception: + await self.logger.error(f'_handle_template_card_event error: {traceback.format_exc()}') + + # WeCom expects an empty success ack for event callbacks. + return await self._encrypt_and_reply({}, nonce) + async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]: """处理企业微信用户反馈事件(点赞/点踩)。 @@ -1108,12 +2040,50 @@ class WecomBotClient: if not stream_id: return False - chunk = StreamChunk(content=content, is_final=is_final) + previous_content = self._stream_last_content.get(msg_id, '') + if previous_content and content.startswith(previous_content): + next_content = content + elif previous_content and not content: + next_content = previous_content + else: + next_content = previous_content + content if previous_content else content + + if not is_final and next_content == previous_content: + return True + + # Follow-up responses replace the displayed stream body in WeCom. + # Publish the complete snapshot so earlier chunks remain visible. + chunk = StreamChunk(content=next_content, is_final=is_final) await self.stream_sessions.publish(stream_id, chunk) + self._stream_last_content[msg_id] = next_content if is_final: + self._stream_last_content.pop(msg_id, None) self.stream_sessions.mark_finished(stream_id) return True + async def push_form_pause( + self, msg_id: str, form_data: dict, task_id: Optional[str] = None + ) -> tuple[bool, Optional[str], Optional[str]]: + """Attach a Dify human-input pause to the active stream session. + + On the next WeCom followup poll, the response switches from + ``msgtype: stream`` to ``msgtype: template_card`` (button_interaction) + carrying the buttons. ``task_id`` is auto-generated if not provided + and is what the button-click callback uses to look the session back up. + + Returns: + ``(ok, stream_id, task_id)``. ``ok`` is False if the + adapter's msg_id maps to no stream session (e.g. non-stream mode). + """ + stream_id = self.stream_sessions.get_stream_id_by_msg(msg_id) + if not stream_id: + return False, None, None + if not task_id: + # WeCom requires task_id [A-Za-z0-9_-@], <= 128 bytes, unique per bot. + task_id = f'dify-{uuid.uuid4().hex[:24]}' + self.stream_sessions.set_pending_form(stream_id, form_data, task_id) + return True, stream_id, task_id + async def set_message(self, msg_id: str, content: str): """兼容旧逻辑:若无法流式返回则缓存最终结果。 diff --git a/src/langbot/libs/wecom_ai_bot_api/ws_client.py b/src/langbot/libs/wecom_ai_bot_api/ws_client.py index 5125a704a..32c11bd50 100644 --- a/src/langbot/libs/wecom_ai_bot_api/ws_client.py +++ b/src/langbot/libs/wecom_ai_bot_api/ws_client.py @@ -20,7 +20,19 @@ from typing import Any, Callable, Optional import aiohttp from langbot.libs.wecom_ai_bot_api import wecombotevent -from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession +from langbot.libs.wecom_ai_bot_api.api import ( + parse_wecom_bot_message, + StreamSession, + build_human_input_template_card_payload, + build_human_input_text_prompt, + build_button_interaction_update_card, + build_multiple_interaction_update_card, + extract_template_card_action, + extract_template_card_event_payload, + extract_template_card_selections, + extract_wecom_event_type, + parse_select_button_action, +) from langbot.pkg.platform.logger import EventLogger DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com' @@ -43,6 +55,10 @@ def _generate_req_id(prefix: str) -> str: return f'{prefix}_{ts}_{rand}' +def _frame_snippet(frame: dict, limit: int = 1000) -> str: + return json.dumps(frame, ensure_ascii=False, default=str)[:limit] + + class WecomBotWsClient: """WeChat Work AI Bot WebSocket long connection client. @@ -103,6 +119,22 @@ class WecomBotWsClient: # msg_id -> feedback_id (for associating feedback with message) self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id + # Dify human-input pause state for ws mode. Keys are task_id (echoed + # back in template_card_event.TaskId so we can rebuild the session + # context on click). + # task_id -> {form_data, msg_id, user_id, chat_id, stream_id, req_id} + self._pending_forms_by_task: dict[str, dict] = {} + # Reverse: msg_id -> task_id (for cleanup when stream finishes). + self._task_id_by_msg: dict[str, str] = {} + # Optional card-action callback registered by the adapter. + # Signature mirrors the http-mode WecomBotClient: + # async def callback(session, action_id, task_id, raw_event) -> None + self._card_action_callback: Optional[Callable] = None + # Optional `source` block injected into every interactive + # template_card the client builds via `push_form_pause`. Set via + # `set_card_source` from the adapter after reading config. + self.card_source: Optional[dict] = None + # ── Public API ────────────────────────────────────────────────── async def connect(self): @@ -236,6 +268,132 @@ class WecomBotWsClient: } return await self._send_reply(req_id, body) + async def reply_template_card(self, req_id: str, card_payload: dict[str, Any]) -> Optional[dict]: + """Send a template_card (button_interaction etc.) reply. + + Args: + req_id: The req_id from the original message frame. + card_payload: Body produced by ``build_button_interaction_payload``; + must contain ``msgtype`` and ``template_card`` keys. + + Returns: + ACK frame dict, or None on failure. + """ + return await self._send_reply(req_id, card_payload) + + async def update_template_card( + self, + req_id: str, + template_card: dict[str, Any], + ) -> Optional[dict]: + """Update an existing template_card via WebSocket. + + Uses the ``aibot_respond_update_msg`` command. Must be called + within 5 seconds of receiving the ``template_card_event`` callback, + using the **same req_id** from that callback. + + The ``template_card`` dict should contain ``card_type`` and the + new content fields (e.g. ``main_title``, ``button_list`` with + disabled buttons and ``replace_text``). + + Returns: + ACK frame dict, or None on failure. + """ + body: dict[str, Any] = { + 'response_type': 'update_template_card', + 'template_card': template_card, + } + return await self._send_reply(req_id, body, cmd=CMD_RESPOND_UPDATE) + + def set_card_action_callback(self, callback: Callable) -> None: + """Register the button-click handler. + + ``async def callback(session, action_id, task_id, raw_event) -> None`` + — same signature as the http-mode WecomBotClient version so the + adapter can hand both off to the same coroutine. + """ + self._card_action_callback = callback + + def set_card_source(self, source: Optional[dict]) -> None: + """Set the `source` block injected into every interactive + template_card pushed via `push_form_pause`. Pass None to clear.""" + self.card_source = source + + async def push_form_pause( + self, msg_id: str, form_data: dict, task_id: Optional[str] = None + ) -> tuple[bool, Optional[str], Optional[str]]: + """Attach a Dify human-input pause to the active stream and send + the button_interaction card immediately. + + ws mode has no notion of polled "followup" responses — each reply + is a one-shot frame send. So unlike the http path (which defers + card delivery to the next followup), here we just craft the card + and reply with it on the original req_id. The corresponding stream + session is then torn down so subsequent chunks don't re-send. + + Returns: + ``(ok, stream_id, task_id)``. ``ok=False`` if no active stream + for this msg_id (e.g. message arrived in non-stream mode). + """ + key = self._stream_ids.get(msg_id) + if not key: + return False, None, None + req_id, stream_id = key.split('|', 1) + + if not task_id: + task_id = f'dify-{secrets.token_hex(12)}' + + session_info = self._stream_sessions.get(msg_id) or {} + text_prompt = build_human_input_text_prompt(form_data) + if text_prompt: + try: + ack = await self.reply_text(req_id, text_prompt) + if ack is None: + return False, stream_id, None + except Exception: + await self.logger.error(f'Failed to send human-input text prompt: {traceback.format_exc()}') + return False, stream_id, None + + self._stream_ids.pop(msg_id, None) + self._stream_last_content.pop(msg_id, None) + self._stream_sessions.pop(msg_id, None) + return True, stream_id, None + + self._pending_forms_by_task[task_id] = { + 'form_data': form_data, + 'msg_id': msg_id, + 'user_id': session_info.get('user_id', ''), + 'chat_id': session_info.get('chat_id', ''), + 'stream_id': stream_id, + 'req_id': req_id, + } + self._task_id_by_msg[msg_id] = task_id + + card_payload = build_human_input_template_card_payload( + form_data, + task_id, + source=self.card_source, + select_as_buttons=True, + ) + try: + await self.reply_template_card(req_id, card_payload) + except Exception: + await self.logger.error(f'Failed to send button_interaction card: {traceback.format_exc()}') + # Roll back the bookkeeping so the next attempt isn't blocked. + self._pending_forms_by_task.pop(task_id, None) + self._task_id_by_msg.pop(msg_id, None) + return False, stream_id, None + + # Tear down the stream — WeCom expects either stream chunks OR a + # template_card, not both on the same req_id. Subsequent + # push_stream_chunk calls for this msg_id become no-ops. + self._stream_ids.pop(msg_id, None) + self._stream_last_content.pop(msg_id, None) + # Keep _stream_sessions so the button callback can still resolve + # user/chat context; it gets cleaned up when the click fires. + + return True, stream_id, task_id + async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]: """Proactively send a message to a specified chat. @@ -258,6 +416,23 @@ class WecomBotWsClient: body['text'] = {'content': content} return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG) + async def send_template_card(self, chat_id: str, card_payload: dict[str, Any]) -> Optional[dict]: + """Proactively push a template_card to a chat. + + Used for the resumed-workflow path (button click → new query): + synthetic events have no inbound req_id to reply against, so we + fall back to proactive ``aibot_send_msg`` instead of reply mode. + + Args: + chat_id: userid (single chat) or chatid (group chat). + card_payload: ``{"msgtype": "template_card", "template_card": {...}}`` + as produced by :func:`build_button_interaction_payload`. + """ + req_id = _generate_req_id(CMD_SEND_MSG) + body = dict(card_payload) + body['chatid'] = chat_id + return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG) + async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: """Push a streaming chunk for a given message ID. @@ -276,10 +451,31 @@ class WecomBotWsClient: return False req_id, stream_id = key.split('|', 1) try: + previous_content = self._stream_last_content.get(msg_id, '') + if previous_content and content.startswith(previous_content): + next_content = content + elif previous_content and not content: + next_content = previous_content + else: + next_content = previous_content + content if previous_content else content + # Skip sending if content hasn't changed (e.g. during tool call argument streaming) - if not is_final and content == self._stream_last_content.get(msg_id): + if not is_final and next_content == previous_content: return True + # Skip empty/whitespace-only snapshots — the runner injects a + # zero-width space ('​') as a pass-through when workflow_paused + # fires without any preceding LLM output. WeCom renders that + # as an empty bubble that sits before the form card; skip it. + # NOTE: Python str.strip() does NOT strip ​, so we use + # a regex that treats any character with Unicode category Zs + # (separator space) or Cf (format char like ZWS) as blank. + if not is_final: + import re as _re + + if not _re.sub(r'[\s​‌‍]', '', next_content): + return True + # Generate feedback_id for final chunk feedback_id = '' if is_final: @@ -290,8 +486,10 @@ class WecomBotWsClient: if session_info: self._feedback_sessions[feedback_id] = session_info - await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id) - self._stream_last_content[msg_id] = content + # WeCom replaces the displayed stream content on each refresh, so + # every frame must contain the complete snapshot, not only a delta. + await self.reply_stream(req_id, stream_id, next_content, finish=is_final, feedback_id=feedback_id) + self._stream_last_content[msg_id] = next_content if is_final: self._stream_ids.pop(msg_id, None) self._stream_last_content.pop(msg_id, None) @@ -465,7 +663,7 @@ class WecomBotWsClient: return # Unknown frame - await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}') + await self.logger.warning(f'Unknown frame: {_frame_snippet(frame)}') async def _handle_message_callback(self, frame: dict): """Handle an incoming message callback frame.""" @@ -473,6 +671,13 @@ class WecomBotWsClient: body = frame.get('body', {}) req_id = frame.get('headers', {}).get('req_id', '') + event_type = extract_wecom_event_type(body) + if event_type == 'template_card_event': + await self._handle_template_card_event_frame(frame, body) + return + if event_type: + await self.logger.debug(f'Received msg_callback event_type={event_type}: {_frame_snippet(frame)}') + # Parse message using shared logic message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger) if not message_data: @@ -506,8 +711,12 @@ class WecomBotWsClient: body = frame.get('body', {}) req_id = frame.get('headers', {}).get('req_id', '') - event_info = body.get('event', {}) - event_type = event_info.get('eventtype', '') + event_info = body.get('event', {}) if isinstance(body.get('event'), dict) else body + event_type = extract_wecom_event_type(body) + if not event_type: + await self.logger.warning(f'Received event_callback without event_type: {_frame_snippet(frame)}') + else: + await self.logger.debug(f'Received event_callback event_type={event_type}') message_data = { 'msgtype': 'event', @@ -568,6 +777,10 @@ class WecomBotWsClient: await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}') return + if event_type == 'template_card_event': + await self._handle_template_card_event_frame(frame, body) + return + event = wecombotevent.WecomBotEvent(message_data) if event_type in self._message_handlers: @@ -581,6 +794,72 @@ class WecomBotWsClient: except Exception: await self.logger.error(f'Error in event callback: {traceback.format_exc()}') + async def _handle_template_card_event_frame(self, frame: dict, body: dict): + """Handle template_card_event frames from event_callback or msg_callback.""" + tce = extract_template_card_event_payload(body) + task_id, event_key, card_type = extract_template_card_action(tce) + await self.logger.info( + f'Received template_card_event (ws): task_id={task_id} event_key={event_key!r} card_type={card_type}' + ) + + pending = self._pending_forms_by_task.get(task_id) + if pending is None: + await self.logger.warning(f'No pending_form found for task_id={task_id} (ws); card event ignored') + return + + req_id_for_update = frame.get('headers', {}).get('req_id', '') + form_data = pending.get('form_data', {}) or {} + selections = extract_template_card_selections(tce, form_data) + if not selections: + selections = parse_select_button_action(event_key, form_data) + if card_type == 'multiple_interaction' and not selections: + await self.logger.warning( + f'multiple_interaction callback has no parseable selections (ws): raw={str(tce)[:1000]}' + ) + self._drop_pending_form_task(task_id, pending) + return + + update_card = build_button_interaction_update_card( + form_data, + task_id, + event_key, + source=self.card_source, + ) + if card_type == 'multiple_interaction' or selections: + update_card = build_multiple_interaction_update_card( + form_data, + task_id, + selections, + source=self.card_source, + ) + try: + await self.update_template_card(req_id_for_update, update_card) + except Exception: + await self.logger.warning(f'Failed to update template card (ws): {traceback.format_exc()}') + + if self._card_action_callback is not None: + try: + session = StreamSession( + stream_id=pending.get('stream_id', ''), + msg_id=pending.get('msg_id', ''), + chat_id=pending.get('chat_id') or None, + user_id=pending.get('user_id') or None, + ) + session.pending_form = pending.get('form_data') + session.pending_form_task_id = task_id + await self._card_action_callback(session, event_key, task_id, body) + except Exception: + await self.logger.error(f'card action callback raised (ws): {traceback.format_exc()}') + + self._drop_pending_form_task(task_id, pending) + + def _drop_pending_form_task(self, task_id: str, pending: dict) -> None: + self._pending_forms_by_task.pop(task_id, None) + msg_id = pending.get('msg_id', '') + if msg_id: + self._task_id_by_msg.pop(msg_id, None) + self._stream_sessions.pop(msg_id, None) + async def _dispatch_event(self, event: wecombotevent.WecomBotEvent): """Dispatch a message event to registered handlers with deduplication.""" try: diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py index 435e74e8a..0e32f9d29 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -5,6 +5,29 @@ from ... import group from langbot.pkg.utils import importutil +def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str: + """Decrypt the AppSecret returned by the QQ Official QR binding endpoint. + + The base64 payload is laid out as `nonce (12 B) | ciphertext | tag (16 B)`. + `key` is the 32-byte AES-256 key locally generated when the bind task + was created and submitted as `key` to `q.qq.com/lite/create_bind_task`. + """ + import base64 + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + try: + raw = base64.b64decode(encrypted_b64) + except Exception as exc: + raise ValueError('Malformed encrypted credential') from exc + if len(key) != 32 or len(raw) <= 28: + raise ValueError('Invalid encrypted credential layout') + nonce, ciphertext, tag = raw[:12], raw[12:-16], raw[-16:] + try: + return AESGCM(key).decrypt(nonce, ciphertext + tag, None).decode('utf-8') + except Exception as exc: + raise ValueError('Failed to decrypt credential') from exc + + @group.group_class('adapters', '/api/v1/platform/adapters') class AdaptersRouterGroup(group.RouterGroup): async def initialize(self) -> None: @@ -37,6 +60,15 @@ class AdaptersRouterGroup(group.RouterGroup): importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0] ) + @self.route('/dingtalk/human-input-card-template', methods=['GET'], auth_type=group.AuthType.NONE) + async def _() -> quart.Response: + filename = 'dingtalk_human_input_card.json' + response = quart.Response( + importutil.read_resource_file_bytes(f'templates/{filename}'), mimetype='application/json' + ) + response.headers['Content-Disposition'] = f'attachment; filename={filename}' + return response + # In-memory session store for active registrations _create_app_sessions: dict = {} _SESSION_TTL = 900 # 15 minutes @@ -650,3 +682,220 @@ class AdaptersRouterGroup(group.RouterGroup): if session and session.get('task') and not session['task'].done(): session['task'].cancel() return self.success(data={}) + + # ----------------------------------------------------------------------- + # QQ Official QR Binding + # ----------------------------------------------------------------------- + + _qqofficial_sessions: dict = {} + _QQOFFICIAL_SESSION_TTL = 300 # 5 minutes (QQ bind QR validity window) + + def _cleanup_expired_qqofficial_sessions(): + import time + + now = time.time() + expired = [ + sid for sid, s in _qqofficial_sessions.items() if now - s.get('created_at', 0) > _QQOFFICIAL_SESSION_TTL + ] + for sid in expired: + session = _qqofficial_sessions.pop(sid, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + + @self.route('/qqofficial/bind', methods=['POST']) + async def _() -> str: + """Start QQ Official QR binding. Returns session_id + QR URL. + + Flow: generate a local AES-256 key, register it with + `q.qq.com/lite/create_bind_task`, then poll + `q.qq.com/lite/poll_bind_result` until the user authorizes the + bind inside the QQ Bot Assistant on mobile QQ. The encrypted + AppSecret returned by the poll endpoint is decrypted with the + same key. The key never leaves this process. + """ + import uuid + import time + import secrets + import base64 + import aiohttp + + QQ_BIND_BASE = 'https://q.qq.com' + _cleanup_expired_qqofficial_sessions() + + bind_key_bytes = secrets.token_bytes(32) + bind_key = base64.b64encode(bind_key_bytes).decode('ascii') + + session_id = str(uuid.uuid4()) + session = { + 'status': 'pending', + 'qr_url': None, + 'expire_at': None, + 'appid': None, + 'secret': None, + 'user_openid': None, + 'error': None, + 'created_at': time.time(), + 'task_id': None, + 'bind_key_bytes': bind_key_bytes, + 'interval': 2, + } + _qqofficial_sessions[session_id] = session + + async def run_qr_binding(): + try: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as http: + # Step 1: create_bind_task — register our AES key, get task_id + async with http.post( + f'{QQ_BIND_BASE}/lite/create_bind_task', + json={'key': bind_key}, + headers={'Accept': 'application/json'}, + ) as resp: + try: + data = await resp.json(content_type=None) + except (aiohttp.ContentTypeError, ValueError): + session['status'] = 'error' + session['error'] = 'Invalid response from QQ bind service' + return + if int(data.get('retcode', -1)) != 0: + session['status'] = 'error' + session['error'] = ( + data.get('msg') or data.get('message') or 'Failed to create bind task' + ) + return + task_id = str((data.get('data') or {}).get('task_id') or '').strip() + if not task_id: + session['status'] = 'error' + session['error'] = 'Missing task_id in QQ response' + return + + # The QR encodes a URL that mobile QQ opens inside the QQ Bot Assistant. + # `source=langbot` is a courtesy attribution parameter so Tencent + # can see LangBot adoption metrics, matching the convention used by + # other third-party integrations (e.g. hermes-agent uses `source=hermes`). + qr_url = f'{QQ_BIND_BASE}/qqbot/openclaw/connect.html?task_id={task_id}&_wv=2&source=langbot' + session['task_id'] = task_id + session['qr_url'] = qr_url + session['expire_at'] = time.time() + _QQOFFICIAL_SESSION_TTL + session['status'] = 'waiting' + + # Step 2: poll_bind_result until completed (status=2) or expired (3). + deadline = time.time() + _QQOFFICIAL_SESSION_TTL + while time.time() < deadline: + await asyncio.sleep(session['interval']) + + async with http.post( + f'{QQ_BIND_BASE}/lite/poll_bind_result', + json={'task_id': task_id}, + headers={'Accept': 'application/json'}, + ) as poll_resp: + try: + poll_data = await poll_resp.json(content_type=None) + except (aiohttp.ContentTypeError, ValueError): + continue + + if int(poll_data.get('retcode', -1)) != 0: + session['status'] = 'error' + session['error'] = poll_data.get('msg') or poll_data.get('message') or 'Poll failed' + return + + payload = poll_data.get('data') or {} + try: + raw_status = int(payload.get('status', 0)) + except (TypeError, ValueError): + raw_status = 0 + + if raw_status == 2: + appid = str(payload.get('bot_appid') or '').strip() + encrypted = str(payload.get('bot_encrypt_secret') or '').strip() + if not appid or not encrypted: + session['status'] = 'error' + session['error'] = 'Incomplete credential payload' + return + try: + session['secret'] = _decrypt_qqofficial_secret( + encrypted, + bind_key_bytes, + ) + except ValueError as exc: + session['status'] = 'error' + session['error'] = str(exc) + return + session['appid'] = appid + # The scanner's OpenID is returned alongside the credentials — + # surfaced to the dashboard for audit / "bound by" display. + session['user_openid'] = str(payload.get('user_openid') or '').strip() or None + session['status'] = 'success' + return + + if raw_status == 3: + session['status'] = 'expired' + session['error'] = 'QR code expired' + return + # status 0 / 1: still pending, continue polling + + session['status'] = 'expired' + session['error'] = 'QR code expired' + + except asyncio.CancelledError: + return + except Exception as e: + session['status'] = 'error' + session['error'] = str(e) + + task = asyncio.create_task(run_qr_binding()) + session['task'] = task + + # Wait up to 10s for the QR URL to be ready before responding. + for _ in range(20): + if session['qr_url'] or session['error']: + break + await asyncio.sleep(0.5) + + if session['error']: + task.cancel() + return self.http_status(502, -1, session['error']) + + if not session['qr_url']: + task.cancel() + session['status'] = 'error' + session['error'] = 'Timeout waiting for QR code' + return self.http_status(504, -1, 'Timeout waiting for QR code') + + return self.success( + data={ + 'session_id': session_id, + 'qr_url': session['qr_url'], + 'expire_at': session['expire_at'], + } + ) + + @self.route('/qqofficial/bind/status/', methods=['GET']) + async def _(session_id: str) -> str: + """Poll QQ Official QR binding status.""" + _cleanup_expired_qqofficial_sessions() + session = _qqofficial_sessions.get(session_id) + if not session: + return self.http_status(404, -1, 'Session not found') + + data = {'status': session['status']} + + if session['status'] == 'success': + data['appid'] = session['appid'] + data['secret'] = session['secret'] + if session.get('user_openid'): + data['user_openid'] = session['user_openid'] + _qqofficial_sessions.pop(session_id, None) + elif session['status'] in ('error', 'expired'): + data['error'] = session['error'] + _qqofficial_sessions.pop(session_id, None) + + return self.success(data=data) + + @self.route('/qqofficial/bind/', methods=['DELETE']) + async def _(session_id: str) -> str: + """Cancel and clean up a QQ Official QR binding session.""" + session = _qqofficial_sessions.pop(session_id, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + return self.success(data={}) diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py index adf44b524..ef189beaf 100644 --- a/src/langbot/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -168,7 +168,7 @@ class RuntimePipeline: bot_message=query.resp_messages[-1], message=result.user_notice, quote_origin=query.pipeline_config['output']['misc']['quote-origin'], - is_final=[msg.is_final for msg in query.resp_messages][0], + is_final=[msg.is_final for msg in query.resp_messages][-1], ) else: await query.adapter.reply_message( diff --git a/src/langbot/pkg/pipeline/pool.py b/src/langbot/pkg/pipeline/pool.py index d2d4563b5..55ce7fe12 100644 --- a/src/langbot/pkg/pipeline/pool.py +++ b/src/langbot/pkg/pipeline/pool.py @@ -42,9 +42,13 @@ class QueryPool: adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, pipeline_uuid: typing.Optional[str] = None, routed_by_rule: bool = False, + variables: typing.Optional[dict[str, typing.Any]] = None, ) -> pipeline_query.Query: async with self.condition: query_id = self.query_id_counter + initial_variables: dict[str, typing.Any] = {'_routed_by_rule': routed_by_rule} + if variables: + initial_variables.update(variables) query = pipeline_query.Query( bot_uuid=bot_uuid, query_id=query_id, @@ -53,7 +57,7 @@ class QueryPool: sender_id=sender_id, message_event=message_event, message_chain=message_chain, - variables={'_routed_by_rule': routed_by_rule}, + variables=initial_variables, resp_messages=[], resp_message_chain=[], adapter=adapter, diff --git a/src/langbot/pkg/pipeline/respback/respback.py b/src/langbot/pkg/pipeline/respback/respback.py index 0c85fbb45..6d8248aef 100644 --- a/src/langbot/pkg/pipeline/respback/respback.py +++ b/src/langbot/pkg/pipeline/respback/respback.py @@ -45,7 +45,7 @@ class SendResponseBackStage(stage.PipelineStage): try: if await query.adapter.is_stream_output_supported() and has_chunks: - is_final = [msg.is_final for msg in query.resp_messages][0] + is_final = [msg.is_final for msg in query.resp_messages][-1] await query.adapter.reply_message_chunk( message_source=query.message_event, bot_message=query.resp_messages[-1], diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 8e99618c3..6e995206f 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -501,6 +501,8 @@ class PlatformManager: bot_entity.adapter_config, logger, ) + if hasattr(adapter_inst, 'ap'): + adapter_inst.ap = self.ap # 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook) if hasattr(adapter_inst, 'set_bot_uuid'): diff --git a/src/langbot/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py index f47f995db..996187f08 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.py +++ b/src/langbot/pkg/platform/sources/dingtalk.py @@ -1,13 +1,21 @@ +import asyncio +import json +import pathlib +import re 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): @@ -161,6 +169,307 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter): ) +def _dingtalk_input_hint_lines(form_data: dict) -> list[str]: + lines: list[str] = [] + current_field = str(form_data.get('_current_input_field') or '').strip() + for field in form_data.get('input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + field_type = str(field.get('type') or 'text').strip().lower() + if current_field and field_name != current_field: + continue + if not field_name: + continue + if field_type == 'select': + source = field.get('option_source') or {} + options = source.get('value') if isinstance(source, dict) else [] + if isinstance(options, list) and options: + option_text = ', '.join(f'{idx}. {option}' for idx, option in enumerate(options, start=1)) + lines.append(f'- {field_name}: {option_text}') + else: + lines.append(f'- {field_name}: choose one option') + elif field_type in {'file', 'file-list'}: + limit = field.get('number_limits') if field_type == 'file-list' else 1 + allowed_types = ', '.join(field.get('allowed_file_types') or []) + suffix = f', up to {limit}' if field_type == 'file-list' and limit else '' + allowed = f' ({allowed_types})' if allowed_types else '' + lines.append(f'- {field_name}: upload file(s){allowed}{suffix} or reply `{field_name}: `') + else: + lines.append(f'- {field_name}: reply `{field_name}: `') + return lines + + +def _dingtalk_pending_input_defs(form_data: dict) -> list[dict]: + if form_data.get('_action_select_only'): + return [] + inputs = form_data.get('inputs') or {} + pending = [] + for field in form_data.get('input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + if inputs.get(field_name) in (None, '', []): + pending.append(field) + return pending + + +def _dingtalk_clean_form_content(form_data: dict) -> str: + is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only') + raw_content = str(form_data.get('raw_form_content') or '') + content = raw_content or form_data.get('form_content') or '' + input_defs = _dingtalk_form_input_defs(form_data) + field_names = {_dingtalk_field_name(field) for field in input_defs if _dingtalk_field_name(field)} + + if is_field_step and raw_content: + current_field = str(form_data.get('_current_input_field') or '').strip() + current_placeholder = next( + ( + match + for match in re.finditer(r'\{\{#\$output\.([^#{}]+)#\}\}', raw_content) + if match.group(1).strip() == current_field + ), + None, + ) + content = ( + raw_content[: current_placeholder.end()] if current_placeholder else form_data.get('form_content') or '' + ) + + if form_data.get('_action_select_only') or is_field_step: + fields = {_dingtalk_field_name(field): field for field in input_defs if _dingtalk_field_name(field)} + inputs = form_data.get('inputs') or {} + + def replace_placeholder(match: re.Match[str]) -> str: + field_name = match.group(1).strip() + field = fields.get(field_name) + if not field or inputs.get(field_name) in (None, '', []): + return '' + lines = _dingtalk_completed_input_lines( + { + 'input_defs': [field], + 'inputs': {field_name: inputs[field_name]}, + } + ) + return lines[0] if lines else '' + + content = re.sub(r'\{\{#\$output\.([^#{}]+)#\}\}', replace_placeholder, str(content)) + + kept_lines: list[str] = [] + for line in str(content).splitlines(): + placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) + if placeholder and placeholder.group(1) in field_names: + continue + kept_lines.append(line) + return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip()) + + +def _dingtalk_card_markdown(content: str) -> str: + """Preserve line breaks inside DingTalk card-template markdown slots.""" + return '
'.join(str(content or '').splitlines()) + + +def _dingtalk_form_input_defs(form_data: dict) -> list[dict]: + return list(form_data.get('all_input_defs') or form_data.get('input_defs') or []) + + +def _dingtalk_display_input_value(field: dict, value: typing.Any) -> str: + field_type = _dingtalk_field_type(field) + if field_type == 'file': + if isinstance(value, dict): + return value.get('url') or value.get('upload_file_id') or '1 file' + return str(value) + if field_type == 'file-list': + if isinstance(value, list): + return f'{len(value)} file(s)' + return str(value) + return str(value) + + +def _dingtalk_completed_input_lines(form_data: dict) -> list[str]: + inputs = form_data.get('inputs') or {} + if not isinstance(inputs, dict): + return [] + + lines: list[str] = [] + for field in _dingtalk_form_input_defs(form_data): + field_name = _dingtalk_field_name(field) + if not field_name: + continue + value = inputs.get(field_name) + if value in (None, '', []): + continue + display_value = _dingtalk_display_input_value(field, value) + lines.append(f'✅ {field_name}:{display_value}') + return lines + + +def _dingtalk_missing_completed_input_lines(form_data: dict, form_content: str) -> list[str]: + """Return completed values that are not already rendered in the form body.""" + rendered_lines = { + line.strip() + for line in re.split(r'|\r?\n', str(form_content or ''), flags=re.IGNORECASE) + if line.strip() + } + return [line for line in _dingtalk_completed_input_lines(form_data) if line.strip() not in rendered_lines] + + +def _dingtalk_supports_native_field(form_data: dict) -> bool: + current_name = str(form_data.get('_current_input_field') or '').strip() + if not current_name or form_data.get('_action_select_only'): + return False + for field in _dingtalk_form_input_defs(form_data): + if str(field.get('output_variable_name') or '').strip() != current_name: + continue + return str(field.get('type') or 'text').strip().lower() not in {'file', 'file-list'} + return False + + +def _dingtalk_field_name(field: dict) -> str: + return str(field.get('output_variable_name') or field.get('name') or field.get('id') or '').strip() + + +def _dingtalk_field_type(field: dict) -> str: + return str(field.get('type') or 'text').strip().lower() + + +def _dingtalk_select_options(field: dict) -> list[str]: + source = field.get('option_source') or {} + value = source.get('value') if isinstance(source, dict) else None + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return [part.strip() for part in value.splitlines() if part.strip()] + options = field.get('options') + if isinstance(options, list): + result = [] + for item in options: + if isinstance(item, dict): + result.append(str(item.get('label') or item.get('value') or '')) + else: + result.append(str(item)) + return [item for item in result if item] + return [] + + +def _dingtalk_select_block_options(options: list[str]) -> list[dict]: + """Build the option shape consumed by DingTalk SelectBlock templates.""" + locales = ( + 'zh_CN', + 'zh_TW', + 'en_US', + 'ja_JP', + 'vi_VN', + 'th_TH', + 'id_ID', + 'ne_NP', + 'ms_MY', + 'ko_KR', + 'ru_RU', + 'es_EA', + 'tr_TR', + 'fr_FR', + 'pt_BR', + ) + return [{'value': option, 'text': {locale: option for locale in locales}} for option in options] + + +def _dingtalk_current_input_field(form_data: dict) -> dict | None: + current_name = str(form_data.get('_current_input_field') or '').strip() + if not current_name or form_data.get('_action_select_only'): + return None + for field in _dingtalk_form_input_defs(form_data): + if _dingtalk_field_name(field) == current_name: + return field + return None + + +def _dingtalk_form_component_params(form_data: dict) -> dict: + field = _dingtalk_current_input_field(form_data) + params = { + 'input_visible': '', + 'input_title': '', + 'input_placeholder': '', + 'input_value': '', + 'select_visible': '', + 'select_placeholder': '', + 'select_options': [], + 'index_o': [], + 'test_index': [], + 'select_index': -1, + } + if not field: + return params + + field_name = _dingtalk_field_name(field) + field_type = _dingtalk_field_type(field) + value = form_data.get('inputs', {}).get(field_name, '') + if field_type == 'select': + options = _dingtalk_select_options(field) + selected_index = -1 + if value not in (None, ''): + try: + selected_index = options.index(str(value)) + except ValueError: + selected_index = -1 + params.update( + { + 'select_visible': 'true', + 'select_placeholder': field_name or 'Select', + 'select_options': options, + 'index_o': _dingtalk_select_block_options(options), + 'test_index': _dingtalk_select_block_options(options), + 'select_index': selected_index, + } + ) + elif field_type not in {'file', 'file-list'}: + params.update( + { + 'input_visible': 'true', + 'input_title': field_name or 'Input', + 'input_placeholder': field_name or 'Input', + 'input_value': '' if value is None else str(value), + } + ) + return params + + +def _dingtalk_empty_form_component_params() -> dict: + return _dingtalk_form_component_params({}) + + +def _dingtalk_extract_component_inputs(params: dict) -> dict: + """Normalize DingTalk native Input/SelectBlock callback payloads.""" + if not isinstance(params, dict): + return {} + + result = {} + input_value = params.get('input') + if input_value in (None, ''): + input_result = params.get('inputResult') + if isinstance(input_result, dict): + input_value = input_result.get('value') or input_result.get('input') + elif input_result not in (None, ''): + input_value = input_result + if input_value not in (None, ''): + if isinstance(input_value, str): + input_value = input_value.strip() + result['input'] = input_value + + select_value = params.get('select') + if select_value in (None, ''): + for key in ('selectResult', 'select_result', '__built_in_selectResult__'): + candidate = params.get(key) + if candidate not in (None, ''): + select_value = candidate + break + if isinstance(select_value, dict): + select_value = select_value.get('value') or select_value.get('label') or select_value.get('index') + if select_value not in (None, ''): + if isinstance(select_value, str): + select_value = select_value.strip() + result['select'] = select_value + + return result + + class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: DingTalkClient bot_account_id: str @@ -170,6 +479,40 @@ 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 + # session_key → out_track_id of the currently-active card for the + # conversation turn. Lets resumed-workflow chunks (which arrive on a + # synthetic event with a fresh resp_message_id) keep updating the same + # card the user clicked instead of getting a new one. + active_turn_card: dict + # session_key → accumulated streaming text for the active turn. Read + # by _paint_form_on_card so the post-pause form keeps the streamed + # context above the new prompt. + active_turn_text: dict + # event_type → callback. The abstract base class doesn't declare this, + # so we must do it here or pydantic silently drops `listeners={}` in + # super().__init__ and any access raises AttributeError. + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], + ] + ap: typing.Any = None + bot_uuid: str = '' + # DingTalk media_id (`@xxx` format) for the bot avatar image, fetched + # on adapter startup by uploading the bundled LangBot logo via the + # legacy /media/upload endpoint. Empty string when the upload hasn't + # run yet or failed — the template's Avatar then falls back to its + # default (initials of `name`). + bot_avatar_media_id: str = '' + + # Path to the LangBot logo bundled in the repo (`res/logo-blue.png`), + # resolved relative to this file. Updated to find the file even when + # LangBot is installed as a package or run from a different cwd. + _LOGO_PATH: typing.ClassVar[pathlib.Path] = pathlib.Path(__file__).resolve().parents[5] / 'res' / 'logo-blue.png' def __init__(self, config: dict, logger: EventLogger): required_keys = [ @@ -194,10 +537,17 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): config=config, logger=logger, card_instance_id_dict={}, + card_state={}, + active_turn_card={}, + active_turn_text={}, bot_account_id=bot_account_id, bot=bot, listeners={}, ) + # 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 +572,92 @@ 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 + # btns is reserved exclusively for Dify form-action buttons. + # The template renders an Avatar header above the markdown + # content; no feedback buttons get injected here. 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 card content has already been written via + # update_card_data (in _paint_form_on_card and the + # initial card creation). The streaming endpoint + # (PUT /v1.0/card/streaming) does not propagate + # updates on cards whose content was last set via + # update_card_data — they take different code paths + # on the DingTalk client. Stick with update_card_data + # for the whole turn for consistency. + try: + await self.bot.update_card_data( + out_track_id=card_instance_id, + card_param_map=self._card_params( + content=content, + btns='[]', + flowStatus='3' if is_final else '1', + **_dingtalk_empty_form_component_params(), + ), + ) + session_key = self._session_key_from_event(message_source) + if session_key: + self.active_turn_text[session_key] = content + 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=self._card_params( + flowStatus='3', + **_dingtalk_empty_form_component_params(), + ), + ) + 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 +674,85 @@ 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'] + form_template_id = (self.config.get('human_input_card_template_id') or '').strip() + legacy_template_id = self.config.get('card_template_id', '') + + # Synthetic events (button clicks): look up the card already in + # active_turn_card so reply_message_chunk can stream to it. + if event is None or event.source_platform_object is None: + if form_template_id: + session_key = self._session_key_from_event(event) if event is not None else '' + carry = self.active_turn_card.get(session_key, '') if session_key else '' + if carry: + self.card_instance_id_dict[message_id] = (None, carry) + return True + return False + + if form_template_id: + # Create one card with the form template, empty buttons, + # pending state. Streaming writes content to it; form pause + # paints buttons onto it. One card per turn, no duplication. + incoming_message = event.source_platform_object.incoming_message + 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: + open_space_id = f'dtv1.card//IM_ROBOT.{incoming_message.sender_staff_id}' + try: + await self.bot.create_and_deliver_card( + card_template_id=form_template_id, + out_track_id=out_track_id, + open_space_id=open_space_id, + is_group=is_group, + card_param_map=self._card_params( + content='', + btns='[]', + flowStatus='1', + **_dingtalk_empty_form_component_params(), + ), + callback_type='STREAM', + ) + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: create form-template card failed') + return False + self.card_instance_id_dict[message_id] = (None, out_track_id) + session_key = self._session_key_from_event(event) + if session_key: + self.active_turn_card[session_key] = out_track_id + self.active_turn_text[session_key] = '' + return True + + # Legacy chat-card path (no form template). 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_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], @@ -292,8 +775,36 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self.bot.on_message('GroupMessage')(on_message) async def run_async(self): + # Upload the bundled LangBot logo so the card Avatar can render + # via DingTalk's media CDN — external URLs (e.g. raw.githubusercontent) + # are blocked by DingTalk's Avatar.imageUrl resolver. Non-fatal if + # the upload fails: cards still render without an avatar image. + if self._LOGO_PATH.exists(): + media_id = await self.bot.upload_image_media(str(self._LOGO_PATH)) + if media_id: + self.bot_avatar_media_id = media_id + if self.ap is not None: + self.ap.logger.info(f'DingTalk bot avatar uploaded: media_id={media_id}') + else: + if self.ap is not None: + self.ap.logger.warning('DingTalk bot avatar upload failed; card will use default') + else: + if self.ap is not None: + self.ap.logger.warning(f'DingTalk bot avatar source not found: {self._LOGO_PATH}') await self.bot.start() + def _card_params(self, **extra) -> dict: + """Build a cardParamMap dict that always carries `bot_avatar` + (when uploaded) alongside whatever caller-specific params. The + bot_avatar key gets dropped on every update_card_data call — + DingTalk wipes unspecified template variables, so re-sending it + on each update is mandatory.""" + params = {} + if self.bot_avatar_media_id: + params['bot_avatar'] = self.bot_avatar_media_id + params.update(extra) + return params + async def kill(self) -> bool: await self.bot.stop() return True @@ -309,3 +820,711 @@ 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: + """Surface human-input prompt + buttons on the active card. + + In single-card mode (form_template_id configured): update the + EXISTING card with form buttons so it transitions from streaming + output to prompt+buttons on the same card. In legacy mode: + finalize the chat card and deliver a separate form card. + """ + if self.ap is not None: + self.ap.logger.info( + f'DingTalk _handle_form_chunk: actions={len(form_data.get("actions") or [])}, ' + f'node_title={form_data.get("node_title", "")!r}' + ) + message_id = bot_message.resp_message_id + template_id = (self.config.get('human_input_card_template_id') or '').strip() + + if template_id: + # Single-card mode: paint prompt + buttons onto the existing card. + session_key = self._session_key_from_event(message_source) + entry = self.card_instance_id_dict.get(message_id) + out_track_id = entry[1] if entry else None + if not out_track_id and session_key: + out_track_id = self.active_turn_card.get(session_key, '') + if out_track_id: + await self._paint_form_on_card(message_source, out_track_id, form_data, session_key) + self.card_instance_id_dict.pop(message_id, None) + return + + # No existing card (e.g. Dify paused immediately with no LLM + # output before the pause). Create a form card directly. + await self._send_form_card(message_source, form_data, template_id) + self.card_instance_id_dict.pop(message_id, None) + return + + # Legacy mode: finalize the streaming card with text fallback. + 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()}') + + await self.send_message_text_form(message_source, form_data) + + async def _paint_form_on_card( + self, + message_source: platform_events.MessageEvent, + out_track_id: str, + form_data: dict, + session_key: str, + ) -> None: + """Update an existing card's content + buttons for human-input.""" + actions = list(form_data.get('actions') or []) + node_title = form_data.get('node_title', '') or 'Human Input Required' + form_content = _dingtalk_clean_form_content(form_data) + should_show_actions = not _dingtalk_pending_input_defs(form_data) + component_params = _dingtalk_form_component_params(form_data) + native_field = _dingtalk_supports_native_field(form_data) + if self.ap is not None and component_params.get('select_visible'): + self.ap.logger.info( + f'DingTalk form select params: field={form_data.get("_current_input_field", "")!r} ' + f'options={len(component_params.get("select_options") or [])}' + ) + + # Record form state for the click-handler. + launcher_type, launcher_id, sender_user_id = self._derive_session_descriptor(message_source) + self.card_state[out_track_id] = { + 'session_key': session_key, + 'launcher_type': launcher_type.value, + 'launcher_id': launcher_id, + 'sender_user_id': sender_user_id, + 'form_token': form_data.get('form_token', ''), + 'workflow_run_id': form_data.get('workflow_run_id', ''), + 'pipeline_uuid': form_data.get('pipeline_uuid', ''), + 'actions': actions if should_show_actions else [], + 'all_actions': actions, + 'node_title': node_title, + 'form_content': form_content, + 'current_input_field': str(form_data.get('_current_input_field') or ''), + 'input_defs': _dingtalk_form_input_defs(form_data), + 'inputs': form_data.get('inputs') or {}, + } + + btns = self._build_btns(actions if should_show_actions else [], out_track_id) + parts: list[str] = [] + prior = self.active_turn_text.get(session_key, '') if session_key else '' + if prior.strip(): + parts.append(prior.rstrip()) + parts.append('
') + # DingTalk's card markdown widget strips `\n\n` paragraph breaks in + # template content slots, fusing inline siblings into a single line. + # Force visual line breaks with explicit HTML `
` tags so the + # title sits on its own line above form_content. + if node_title: + parts.append(f'**{node_title}**') + if form_content: + parts.append(_dingtalk_card_markdown(form_content)) + missing_completed_lines = _dingtalk_missing_completed_input_lines(form_data, form_content) + if missing_completed_lines: + parts.append('
' + '
'.join(missing_completed_lines)) + input_hint_lines = [] if native_field else _dingtalk_input_hint_lines(form_data) + if input_hint_lines: + parts.append('Fill these fields in chat before choosing an action:
' + '
'.join(input_hint_lines)) + display_content = '

'.join(parts) + + try: + await self.bot.update_card_data( + out_track_id=out_track_id, + card_param_map=self._card_params( + content=display_content, + btns=json.dumps(btns, ensure_ascii=False), + flowStatus='3', + **component_params, + ), + ) + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: paint form on card failed') + await self.send_message_text_form(message_source, form_data) + return + + @staticmethod + def _build_btns(actions: list, out_track_id: str) -> list: + 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}, + }, + }, + } + ) + return btns + + 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 = _dingtalk_clean_form_content(form_data) + should_show_actions = not _dingtalk_pending_input_defs(form_data) + component_params = _dingtalk_form_component_params(form_data) + native_field = _dingtalk_supports_native_field(form_data) + if self.ap is not None and component_params.get('select_visible'): + self.ap.logger.info( + f'DingTalk form select params: field={form_data.get("_current_input_field", "")!r} ' + f'options={len(component_params.get("select_options") 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', ''), + 'pipeline_uuid': form_data.get('pipeline_uuid', ''), + 'actions': actions if should_show_actions else [], + 'all_actions': actions, + 'node_title': node_title, + 'form_content': form_content, + 'current_input_field': str(form_data.get('_current_input_field') or ''), + 'input_defs': _dingtalk_form_input_defs(form_data), + 'inputs': form_data.get('inputs') or {}, + 'open_space_id': open_space_id, + 'is_group': is_group, + } + + parts = [] + if node_title: + parts.append(f'**{node_title}**') + if form_content: + parts.append(_dingtalk_card_markdown(form_content)) + missing_completed_lines = _dingtalk_missing_completed_input_lines(form_data, form_content) + if missing_completed_lines: + parts.append('
' + '
'.join(missing_completed_lines)) + input_hint_lines = [] if native_field else _dingtalk_input_hint_lines(form_data) + if input_hint_lines: + parts.append('Fill these fields in chat before choosing an action:
' + '
'.join(input_hint_lines)) + display_content = '

'.join(parts) + + btns = self._build_btns(actions if should_show_actions else [], 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=self._card_params( + content=display_content, + btns=json.dumps(btns, ensure_ascii=False), + flowStatus='3', + **component_params, + ), + callback_type='STREAM', + ) + except Exception: + await self.logger.error(f'DingTalk: deliver form card failed: {traceback.format_exc()}') + await self.send_message_text_form(message_source, form_data) + self.card_state.pop(out_track_id, None) + + async def _lazy_create_resume_chat_card( + self, + message_source: platform_events.MessageEvent, + message_id: str, + ) -> typing.Optional[tuple]: + """Create a new card for resumed-workflow streaming output. + + Used after a button click triggers a synthetic event — the form + card stays put with the selection notice, and a fresh card is + spawned here for the LLM reply to stream into. + """ + 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 form_template_id: + card_param_map = self._card_params( + content='', + btns='[]', + flowStatus='1', + **_dingtalk_empty_form_component_params(), + ) + card_data_config = None + else: + # Legacy chat-card template doesn't carry a `bot_avatar` + # variable, so don't decorate the param map here. + 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 + # Register as the active card so any further chunks on this turn + # (and a subsequent re-pause) land on the same new card. + session_key = self._session_key_from_event(message_source) + if session_key: + self.active_turn_card[session_key] = out_track_id + self.active_turn_text[session_key] = '' + return entry + + async def send_message_text_form( + self, + message_source: platform_events.MessageEvent, + form_data: dict, + ) -> None: + """Fallback: send the human-input prompt as plain text.""" + if form_data.get('_current_input_field') and not form_data.get('_action_select_only'): + parts = [] + node_title = form_data.get('node_title', '') + if node_title: + parts.append(f'[Human Input Required] {node_title}') + form_content = form_data.get('form_content') or '' + if form_content: + parts.append(form_content) + display_text = '\n\n'.join(parts) + else: + display_text = _format_human_input_text( + form_data.get('node_title', ''), + form_data.get('form_content', ''), + form_data.get('actions', []) or [], + ) + await self._send_proactive_to_event(message_source, display_text) + + async def _send_proactive_to_event( + self, + message_source: platform_events.MessageEvent, + content: str, + ) -> None: + """Send `content` as a proactive message to the conversation behind + `message_source`. Used when no inbound chatbot message exists to + anchor a card on (e.g. resumed flows triggered by card actions). + """ + if not content: + return + if self.ap is not None: + target = ( + str(message_source.group.id) + if isinstance(message_source, platform_events.GroupMessage) + else str(message_source.sender.id) + ) + self.ap.logger.info( + f'DingTalk _send_proactive_to_event: target={target} ' + f'is_group={isinstance(message_source, platform_events.GroupMessage)} content_len={len(content)}' + ) + try: + if isinstance(message_source, platform_events.GroupMessage): + await self.bot.send_proactive_message_to_group(str(message_source.group.id), content) + else: + await self.bot.send_proactive_message_to_one(str(message_source.sender.id), content) + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: send proactive message failed') + await self.logger.error(f'DingTalk: send proactive message failed: {traceback.format_exc()}') + + async def _on_card_action(self, payload: dict) -> None: + """Translate a card button click into a synthetic query. + + Reads the clicked button's ``actionId`` (the real Dify action id — + the ButtonGroup template sends it back via `event.params.actionId`), + recovers the action title from ``card_state``, and enqueues a + synthetic `_dify_form_action` query the same way Lark / Telegram do. + """ + if self.ap is not None: + self.ap.logger.info( + f'DingTalk _on_card_action received: out_track_id={payload.get("out_track_id")} ' + f'payload_action_id={payload.get("action_id")!r} params={payload.get("params")!r}' + ) + out_track_id = payload.get('out_track_id') or '' + params = payload.get('params') or {} + # ButtonGroup `sendCardRequest` events surface the click id at the + # callback top level as `actionId`; fall back to `params.action_id` + # (alternate template wiring) and `params.actionId`. + raw_action_id = ( + (payload.get('action_id') or '').strip() + or (params.get('action_id') or '').strip() + or (params.get('actionId') or '').strip() + or (params.get('id') or '').strip() + ) + state = self.card_state.get(out_track_id) + if state is None: + await self.logger.warning(f'DingTalk: card action received for unknown out_track_id={out_track_id}') + return + + actions = state.get('actions', []) or [] + known_action_ids = {str(action.get('id', '')) for action in actions} + component_inputs = _dingtalk_extract_component_inputs(params) + if component_inputs and (not raw_action_id or raw_action_id not in known_action_ids): + await self._enqueue_card_form_progress(payload, state, component_inputs) + return + if not raw_action_id: + await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}') + return + if raw_action_id not in known_action_ids: + await self.logger.warning( + f'DingTalk: card action_id={raw_action_id!r} is not present on out_track_id={out_track_id}' + ) + return + + action_id = raw_action_id + action_title = raw_action_id + for action in actions: + if str(action.get('id', '')) == raw_action_id: + action_title = action.get('title') or raw_action_id + break + + launcher_type = ( + provider_session.LauncherTypes.GROUP + if state.get('launcher_type') == provider_session.LauncherTypes.GROUP.value + else provider_session.LauncherTypes.PERSON + ) + launcher_id = state.get('launcher_id', '') + initiator_user_id = str(state.get('sender_user_id') or '') + actor_user_id = str(payload.get('user_id') or initiator_user_id or launcher_id) + if ( + launcher_type == provider_session.LauncherTypes.PERSON + and initiator_user_id + and actor_user_id != initiator_user_id + ): + await self.logger.warning( + f'DingTalk: user {actor_user_id} cannot act on private form created for {initiator_user_id}' + ) + return + + form_action_data = { + 'form_token': state.get('form_token', ''), + 'workflow_run_id': state.get('workflow_run_id', ''), + 'action_id': action_id, + 'action_title': action_title, + 'node_title': state.get('node_title', ''), + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': {}, + } + + message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')]) + + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=actor_user_id, + member_name='', + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=int(datetime.datetime.now().timestamp()), + source_platform_object=None, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=actor_user_id, + nickname='', + remark='', + ), + message_chain=message_chain, + time=int(datetime.datetime.now().timestamp()), + source_platform_object=None, + ) + + bot_uuid = '' + pipeline_uuid = state.get('pipeline_uuid') or None + if self.ap is not None: + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + + try: + self.ap.logger.info( + f'DingTalk _on_card_action enqueuing form action: action_id={action_id!r} ' + f'action_title={action_title!r} launcher_type={launcher_type.value} ' + f'launcher_id={launcher_id} actor_user_id={actor_user_id} ' + f'bot_uuid={bot_uuid} pipeline_uuid={pipeline_uuid}' + ) + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=actor_user_id, + message_event=synthetic_event, + message_chain=message_chain, + adapter=self, + pipeline_uuid=pipeline_uuid, + variables={ + '_dify_form_action': form_action_data, + '_routed_by_rule': True, + }, + ) + self.ap.logger.info('DingTalk _on_card_action: query enqueued OK') + except Exception: + self.ap.logger.exception('DingTalk: enqueue form action query failed') + return + + # Visual feedback on the form card itself: keep the prompt visible, + # add a selection line, remove the buttons. The resumed-workflow + # output lives on a separate new card (lazy-created in + # reply_message_chunk on the synthetic event), so the form card + # stays put as a record of the user's selection. + asyncio.create_task( + self._mark_card_resolved( + out_track_id, + action_title, + node_title=state.get('node_title', ''), + form_content=state.get('form_content', ''), + input_defs=state.get('input_defs') or [], + inputs=state.get('inputs') or {}, + ) + ) + + # Crucial: do NOT leave the form card's out_track_id in + # active_turn_card — otherwise create_message_card for the + # synthetic event would reuse it for the resume output, painting + # the LLM reply on top of the selection notice. Clear it so the + # resume goes through the lazy-create path and spawns a fresh card. + session_key = state.get('session_key', '') + if session_key and self.active_turn_card.get(session_key) == out_track_id: + self.active_turn_card.pop(session_key, None) + self.active_turn_text.pop(session_key, None) + + # Once consumed, drop the state — the runner clears _PENDING_FORMS too. + self.card_state.pop(out_track_id, None) + + async def _enqueue_card_form_progress( + self, + payload: dict, + state: dict, + component_inputs: dict, + ) -> None: + out_track_id = payload.get('out_track_id') or '' + launcher_type = ( + provider_session.LauncherTypes.GROUP + if state.get('launcher_type') == provider_session.LauncherTypes.GROUP.value + else provider_session.LauncherTypes.PERSON + ) + launcher_id = state.get('launcher_id', '') + initiator_user_id = str(state.get('sender_user_id') or '') + actor_user_id = str(payload.get('user_id') or initiator_user_id or launcher_id) + if ( + launcher_type == provider_session.LauncherTypes.PERSON + and initiator_user_id + and actor_user_id != initiator_user_id + ): + await self.logger.warning( + f'DingTalk: user {actor_user_id} cannot update private form created for {initiator_user_id}' + ) + return + form_action_data = { + 'form_token': state.get('form_token', ''), + 'workflow_run_id': state.get('workflow_run_id', ''), + 'action_id': '', + 'action_title': '', + 'node_title': state.get('node_title', ''), + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': component_inputs, + '_current_input_field': state.get('current_input_field', ''), + '_input_progress': True, + } + message_chain = platform_message.MessageChain([platform_message.Plain(text='[Form Input]')]) + + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=actor_user_id, + member_name='', + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=int(datetime.datetime.now().timestamp()), + source_platform_object=None, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=actor_user_id, + nickname='', + remark='', + ), + message_chain=message_chain, + time=int(datetime.datetime.now().timestamp()), + source_platform_object=None, + ) + + if self.ap is None: + return + + bot_uuid = '' + pipeline_uuid = state.get('pipeline_uuid') or None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + try: + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=actor_user_id, + message_event=synthetic_event, + message_chain=message_chain, + adapter=self, + pipeline_uuid=pipeline_uuid, + variables={ + '_dify_form_action': form_action_data, + '_routed_by_rule': True, + }, + ) + self.ap.logger.info( + f'DingTalk card form input enqueued: out_track_id={out_track_id} ' + f'field={state.get("current_input_field", "")!r}' + ) + except Exception: + self.ap.logger.exception('DingTalk: enqueue form input query failed') + + async def _mark_card_resolved( + self, + out_track_id: str, + action_title: str, + *, + node_title: str = '', + form_content: str = '', + input_defs: list | None = None, + inputs: dict | None = None, + ) -> None: + """Update the form card to acknowledge the user's selection. + + Keeps the original prompt visible, adds a selection notice, and + clears the buttons. The card stays as a permanent record of the + choice; the resumed workflow's output goes to a separate new card. + """ + parts: list[str] = [] + if node_title: + parts.append(f'**{node_title}**') + if form_content: + parts.append(_dingtalk_card_markdown(form_content)) + missing_completed_lines = _dingtalk_missing_completed_input_lines( + { + 'input_defs': input_defs or [], + 'inputs': inputs or {}, + }, + form_content, + ) + if missing_completed_lines: + parts.append('
' + '
'.join(missing_completed_lines)) + parts.append(f'
✅ {action_title}') + content = '

'.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=self._card_params( + content=content, + btns='[]', + flowStatus='3', + **_dingtalk_empty_form_component_params(), + ), + ) + except Exception: + if self.ap is not None: + self.ap.logger.exception('DingTalk: mark card resolved failed') diff --git a/src/langbot/pkg/platform/sources/dingtalk.yaml b/src/langbot/pkg/platform/sources/dingtalk.yaml index c7c25e673..261a8bf21 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.yaml +++ b/src/langbot/pkg/platform/sources/dingtalk.yaml @@ -103,6 +103,41 @@ spec: type: string required: true default: "填写你的卡片template_id" + - name: human_input_card_template_download + label: + en_US: Download Human Input Card Template + zh_Hans: 下载人工输入卡片模板 + zh_Hant: 下載人工輸入卡片範本 + description: + en_US: "Used as the only card template ID for the whole conversation turn. Download the built-in template, then import the JSON in DingTalk Open Platform > Card Platform / Card Template Management. After DingTalk creates the template, copy its template ID into the field below. The template already wires `content` (MarkdownBlock) and `btns` (ButtonGroup). Leave empty to fall back to the legacy two-card behavior." + zh_Hans: "用作整个对话回合唯一卡片的模板 ID。先下载内置模板,再到钉钉开放平台 > 卡片平台 / 卡片模板管理中导入该 JSON;钉钉生成模板后,将模板 ID 填到这里。模板已预先连好 `content` (MarkdownBlock) 与 `btns` (ButtonGroup)。留空则降级为旧的双卡行为。" + zh_Hant: "用作整個對話回合唯一卡片的範本 ID。先下載內建範本,再到釘釘開放平台 > 卡片平台 / 卡片範本管理中匯入該 JSON;釘釘產生範本後,將範本 ID 填到這裡。範本已預先連好 `content` (MarkdownBlock) 與 `btns` (ButtonGroup)。留空則降級為舊的雙卡行為。" + type: download-link + required: false + default: "" + url: /api/v1/platform/adapters/dingtalk/human-input-card-template + download_filename: dingtalk_human_input_card.json + help_links: + zh: https://open-dev.dingtalk.com/fe/card + en: https://open-dev.dingtalk.com/fe/card + ja: https://open-dev.dingtalk.com/fe/card + help_label: + en_US: Import Guide + zh_Hans: 导入指引 + zh_Hant: 匯入指引 + ja_JP: インポート手順 + - name: human_input_card_template_id + label: + en_US: Human Input Card Template ID + zh_Hans: 人工输入卡片模板ID + zh_Hant: 人工輸入卡片範本ID + description: + en_US: "Paste the template ID generated after importing the human input card template." + zh_Hans: "填写导入人工输入卡片模板后生成的模板 ID。" + zh_Hant: "填寫匯入人工輸入卡片範本後產生的範本 ID。" + type: string + required: false + default: "" execution: python: path: ./dingtalk.py diff --git a/src/langbot/pkg/platform/sources/discord.py b/src/langbot/pkg/platform/sources/discord.py index e9cc7a37e..fae65f6a0 100644 --- a/src/langbot/pkg/platform/sources/discord.py +++ b/src/langbot/pkg/platform/sources/discord.py @@ -1,6 +1,7 @@ from __future__ import annotations import discord +from discord import ui as discord_ui import typing import re @@ -8,6 +9,8 @@ import base64 import uuid import os import datetime +import time +import traceback # 使用BytesIO创建文件对象,避免路径问题 import io @@ -824,6 +827,69 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter): ) +class DiscordFormView(discord_ui.View): + """Discord ``ui.View`` that renders one button per Dify form action. + + Each button's click triggers ``adapter._on_form_button_click`` which + acks the interaction, locks the buttons in place, and enqueues a + synthetic ``_dify_form_action`` query so the runner resumes the + workflow. + """ + + # Discord button style mapping for Dify ``button_style`` values. + _STYLE_MAP: typing.ClassVar[dict] = { + 'primary': discord.ButtonStyle.primary, + 'danger': discord.ButtonStyle.danger, + 'warning': discord.ButtonStyle.danger, + 'success': discord.ButtonStyle.success, + 'default': discord.ButtonStyle.secondary, + '': discord.ButtonStyle.secondary, + } + + def __init__( + self, + adapter: 'DiscordAdapter', + session_key: str, + actions: list, + timeout: float = 1800, + ): + super().__init__(timeout=timeout) + self._adapter = adapter + self._session_key = session_key + # Discord caps a view at 25 children (5 rows × 5 buttons). Trim + # silently — most Dify forms have ≤10 actions in practice. + for idx, action in enumerate(actions[:25]): + action_id = str(action.get('id') or '') + label = str(action.get('title') or action_id or f'Option {idx + 1}') + style = self._STYLE_MAP.get( + str(action.get('button_style') or '').lower(), + discord.ButtonStyle.secondary, + ) + # custom_id must be unique within the view and ≤100 chars. + # Encode (session, idx) so we can recover the action even + # if Dify ids contain unsafe characters. + custom_id = f'lb_form:{idx}:{action_id[:80]}'[:100] + button = discord_ui.Button( + label=label[:80], # Discord label limit + style=style, + custom_id=custom_id, + ) + button.callback = self._make_callback(action_id, label) + self.add_item(button) + + def _make_callback(self, action_id: str, action_title: str): + async def _cb(interaction: discord.Interaction): + await self._adapter._on_form_button_click( + interaction=interaction, + session_key=self._session_key, + action_id=action_id, + action_title=action_title, + view=self, + ) + + return _cb + + class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: discord.Client = pydantic.Field(exclude=True) @@ -837,6 +903,10 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): voice_manager: VoiceConnectionManager | None = pydantic.Field(exclude=True, default=None) + # Injected by botmgr at construction so the form-button callback can + # enqueue a synthetic resume query (`_dify_form_action`) on the pool. + ap: typing.Any = pydantic.Field(exclude=True, default=None) + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs): bot_account_id = config['client_id'] @@ -860,8 +930,18 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): args = {} - if os.getenv('http_proxy'): - args['proxy'] = os.getenv('http_proxy') + # Proxy: config > env var > auto-detect. + # discord.py uses aiohttp which does NOT respect http_proxy env + # vars by default — we must pass proxy= explicitly. + proxy = ( + config.get('proxy') + or os.getenv('http_proxy') + or os.getenv('HTTP_PROXY') + or os.getenv('https_proxy') + or os.getenv('HTTPS_PROXY') + ) + if proxy: + args['proxy'] = proxy bot = MyClient(intents=intents, **args) @@ -875,6 +955,19 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): **kwargs, ) + # Per-resp-message-id buffer for the accumulated text yielded by + # the runner. Discord's edit-message ratelimit (5/5s) makes true + # progressive streaming impractical, so we collect chunks and + # render once on is_final. ``_form_data`` on the final chunk + # diverts to the button-view path. + self._stream_buffer: dict[str, str] = {} + # session_key -> {form_data, channel_id, thread_id, sender_id, + # posted_at, view_message_id} + # Populated when we send a form view; consumed when the user + # clicks a button so we know which workflow_run / form_token to + # resume. + self._pending_forms: dict[str, dict] = {} + # Voice functionality methods async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient: """ @@ -1068,7 +1161,12 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ): msg_to_send, files = await self.message_converter.yiri2target(message) - assert isinstance(message_source.source_platform_object, discord.Message) + # Synthetic events (button-click resume) have no inbound discord + # Message. Route via the channel we cached when the user clicked. + source = message_source.source_platform_object + if not isinstance(source, discord.Message): + await self._reply_synthetic(message_source, msg_to_send, files) + return args = { 'content': msg_to_send, @@ -1078,7 +1176,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): args['files'] = files if quote_origin: - args['reference'] = message_source.source_platform_object + args['reference'] = source has_at = False @@ -1090,7 +1188,422 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if has_at: args['mention_author'] = True - await message_source.source_platform_object.channel.send(**args) + await source.channel.send(**args) + + async def _reply_synthetic( + self, + message_source: platform_events.MessageEvent, + msg_to_send: str, + files: list, + ) -> None: + """Deliver a reply for a button-click-resumed (synthetic) event. + + We don't have an inbound discord.Message to anchor to; instead + look up the channel cached in ``_pending_forms[session_key + + '__last_channel']`` from the most recent button click. + """ + if isinstance(message_source, platform_events.GroupMessage): + # _handle_form_chunk uses channel_id alone as the session + # scope, and launcher_id was set to channel_id when + # synthesizing the event. + session_key = f'c:{message_source.group.id}' + else: + session_key = f'p:{message_source.sender.id}' + + cached = self._pending_forms.get(session_key + '__last_channel') or {} + channel = cached.get('channel') + if channel is None: + if self.ap is not None: + self.ap.logger.warning( + f'Discord: synthetic reply has no cached channel for ' + f'{session_key}; dropping content (len={len(msg_to_send)})' + ) + return + + args: dict[str, typing.Any] = {'content': msg_to_send} + if files: + args['files'] = files + try: + await channel.send(**args) + except Exception: + if self.ap is not None: + self.ap.logger.error(f'Discord: synthetic reply send failed: {traceback.format_exc()}') + + # Discord allows 5 edits per 5 seconds per message. We throttle + # to one edit per 8 runner-chunks (runner already yields every 8 + # text_chunks internally), which stays comfortably within limits. + _STREAM_EDIT_INTERVAL = 8 + + async def is_stream_output_supported(self) -> bool: + return True + + async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool: + """Set up a stream context for progressive editing. + + The first non-empty reply_message_chunk will send the initial + message; subsequent chunks edit it in place. + """ + source = event.source_platform_object + if not isinstance(source, discord.Message): + return False + self._stream_buffer[message_id] = { + 'channel': source.channel, + 'sent_message': None, # discord.Message set on first send + 'last_content': '', + 'chunk_count': 0, + } + return True + + async def reply_message_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message: typing.Any, + message: platform_message.MessageChain, + quote_origin: bool = False, + is_final: bool = False, + ): + msg_id = ( + bot_message.get('resp_message_id') + if isinstance(bot_message, dict) + else getattr(bot_message, 'resp_message_id', None) + ) + + text_parts = [m.text for m in message if isinstance(m, platform_message.Plain)] + chunk_text = '\n\n'.join(t for t in text_parts if t) + + form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None + + ctx = self._stream_buffer.get(msg_id) if msg_id else None + + # If the stream ctx was not set up (create_message_card wasn't + # called, e.g. synthetic event), or the final chunk carries a + # form, skip progressive editing entirely. + if ctx is None or form_data: + try: + if form_data and is_final: + await self._handle_form_chunk(message_source, form_data) + elif is_final and chunk_text: + await self.reply_message( + message_source, + platform_message.MessageChain([platform_message.Plain(text=chunk_text)]), + quote_origin, + ) + finally: + self._stream_buffer.pop(msg_id, None) + return + + # Progressive streaming path: send first chunk, edit subsequent. + ctx['chunk_count'] += 1 + + # Runner yields the full accumulated text on each chunk, so we + # always replace (not append). + if chunk_text: + ctx['last_content'] = chunk_text + + sent = ctx['sent_message'] + + if sent is None: + # First non-empty chunk — send the initial message. + if not ctx['last_content']: + return # No content yet, wait for next chunk. + try: + sent = await ctx['channel'].send(ctx['last_content']) + ctx['sent_message'] = sent + except Exception: + if self.ap is not None: + self.ap.logger.error(f'Discord stream send failed: {traceback.format_exc()}') + self._stream_buffer.pop(msg_id, None) + return + + if is_final: + # Final chunk — edit to the full content, then clean up. + if ctx['last_content'] and ctx['last_content'] != sent.content: + try: + await sent.edit(content=ctx['last_content'][:2000]) + except Exception: + pass # Best-effort + self._stream_buffer.pop(msg_id, None) + elif (ctx['chunk_count'] % self._STREAM_EDIT_INTERVAL) == 0: + # Intermediate edit — throttle to avoid rate limits. + if ctx['last_content'] and ctx['last_content'] != sent.content: + try: + await sent.edit(content=ctx['last_content'][:2000]) + except Exception: + pass # Rate-limited or deleted — ignore. + + async def _handle_form_chunk( + self, + message_source: platform_events.MessageEvent, + form_data: dict, + ) -> None: + """Render a Dify form pause as a Discord embed + button View. + + Mirrors the QQ / Telegram / Lark form path: the button's click + callback synthesizes a ``_dify_form_action`` query so the runner's + ``_merge_pending_form_action`` resumes the workflow. + """ + source = message_source.source_platform_object + + actions = form_data.get('actions') or [] + if not actions: + # Nothing clickable — fall back to plain text. + if source is not None: + await self.reply_message( + message_source, + platform_message.MessageChain( + [platform_message.Plain(text=str(form_data.get('node_title') or ''))] + ), + ) + return + + node_title = str(form_data.get('node_title') or 'Confirmation needed') + form_content = str(form_data.get('form_content') or '').strip() + + # Two paths: + # (a) Real message — extract channel from source. + # (b) Synthetic event (button-click resume) — no + # source_platform_object; recover the channel we cached + # when the user clicked. + if isinstance(source, discord.Message): + channel = source.channel + guild_id = str(source.guild.id) if source.guild else '' + sender_id = str(source.author.id) + channel_id = str(source.channel.id) + session_key = f'c:{channel_id}' if guild_id else f'p:{sender_id}' + else: + # Synthetic event — resolve session_key from event shape, + # then look up the cached channel from the click. + if isinstance(message_source, platform_events.GroupMessage): + # launcher_id was set to channel_id when we synthesized. + channel_id = str(message_source.group.id) + session_key = f'c:{channel_id}' + else: + session_key = f'p:{message_source.sender.id}' + channel_id = '' + + cached = self._pending_forms.get(session_key + '__last_channel') + channel = cached.get('channel') if cached else None + guild_id = (cached or {}).get('guild_id', '') + sender_id = str(message_source.sender.id) if message_source.sender else '' + if channel is None: + if self.ap is not None: + self.ap.logger.warning( + f'Discord: synthetic form chunk has no cached channel for ' + f'{session_key}; cannot render form buttons' + ) + return + + body_parts: list[str] = [] + if form_content: + body_parts.append(form_content) + embed_body = '\n\n'.join(body_parts) + # Discord embed.description has a 4096 char limit — defensive trim. + if len(embed_body) > 4000: + embed_body = embed_body[:3990] + '\n\n…(truncated)' + + embed = discord.Embed( + title=node_title[:256], + description=embed_body, + color=discord.Color.blurple(), + ) + + view = DiscordFormView( + adapter=self, + session_key=session_key, + actions=actions, + timeout=1800, # 30 min — matches Dify form_token TTL + ) + + try: + sent_msg = await channel.send(embed=embed, view=view) + except Exception: + if self.ap is not None: + self.ap.logger.error(f'Discord: form view send failed: {traceback.format_exc()}') + return + + self._pending_forms[session_key] = { + 'form_data': form_data, + 'channel_id': channel_id, + 'guild_id': guild_id, + 'sender_id': sender_id, + 'view_message_id': str(sent_msg.id), + 'posted_at': time.time(), + } + + if self.ap is not None: + self.ap.logger.info(f'Discord: form view posted session={session_key} actions={len(actions)}') + + async def _on_form_button_click( + self, + interaction: discord.Interaction, + session_key: str, + action_id: str, + action_title: str, + view: DiscordFormView, + ) -> None: + """Handle a click on a form button — ack, resume the workflow, + and disable the View buttons so the choice is visually locked in.""" + import langbot_plugin.api.entities.builtin.provider.session as provider_session + + # ACK first (3-second deadline before Discord shows "interaction failed"). + try: + await interaction.response.defer() + except discord.HTTPException: + # Already responded somehow — proceed regardless. + pass + + pending = self._pending_forms.get(session_key) + if not pending: + if self.ap is not None: + self.ap.logger.warning( + f'Discord: button click on stale session {session_key}; ignoring (action_id={action_id!r})' + ) + await self._lock_view_message(interaction, view, action_title, stale=True) + return + + form_data: dict = pending.get('form_data') or {} + guild_id = pending.get('guild_id', '') + channel_id = pending.get('channel_id', '') + initiator_id = str(pending.get('sender_id', '') or '') + actor_id = str(interaction.user.id) if interaction.user is not None else initiator_id + if not guild_id and initiator_id and actor_id != initiator_id: + if self.ap is not None: + self.ap.logger.warning( + f'Discord: user {actor_id} cannot act on private form created for {initiator_id}' + ) + await self._lock_view_message(interaction, view, action_title, stale=True) + return + + self._pending_forms.pop(session_key, None) + + # Lock the buttons in place: disable everything, mark chosen one. + await self._lock_view_message(interaction, view, action_title) + + # In group context the launcher remains the channel so Dify resumes + # the original group session. The synthetic sender is still the real + # clicker, preserving actor identity for auditing and routing rules. + if guild_id: + launcher_type = provider_session.LauncherTypes.GROUP + launcher_id = channel_id + else: + launcher_type = provider_session.LauncherTypes.PERSON + launcher_id = initiator_id or actor_id + + form_action_data = { + 'form_token': form_data.get('form_token', ''), + 'workflow_run_id': form_data.get('workflow_run_id', ''), + 'action_id': action_id, + 'action_title': action_title, + 'node_title': form_data.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}]')]) + + # Synthesize a platform event so the pipeline can run the resume + # query. source_platform_object=None signals "no inbound discord + # message" — reply_message must tolerate this (it falls through + # to channel.send via the cached interaction.channel below). + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=actor_id, + member_name=interaction.user.display_name if interaction.user else '', + permission='MEMBER', + group=platform_entities.Group( + id=launcher_id, + name=channel_id, + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=int(time.time()), + source_platform_object=None, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=actor_id, + nickname=interaction.user.display_name if interaction.user else '', + remark='', + ), + message_chain=message_chain, + time=int(time.time()), + source_platform_object=None, + ) + + if self.ap is None: + if self.logger: + await self.logger.error('Discord: ap not injected; cannot enqueue button-click query') + return + + bot_uuid = '' + pipeline_uuid = form_data.get('pipeline_uuid') or None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + + # Remember the channel so _reply_synthetic and _handle_form_chunk + # (synthetic-event path) can find a target. guild_id is needed + # to reconstruct the launcher_type on subsequent form pauses. + self._pending_forms[session_key + '__last_channel'] = { + 'channel': interaction.channel, + 'guild_id': guild_id, + 'posted_at': time.time(), + } + + try: + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=actor_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, + }, + ) + if self.ap is not None: + self.ap.logger.info( + f'Discord: button-click query enqueued action_id={action_id!r} ' + f'session={session_key} actor_id={actor_id}' + ) + except Exception: + if self.ap is not None: + self.ap.logger.error(f'Discord: enqueue button-click query failed: {traceback.format_exc()}') + + async def _lock_view_message( + self, + interaction: discord.Interaction, + view: DiscordFormView, + chosen_title: str, + stale: bool = False, + ) -> None: + """Disable all buttons on the form view and annotate the chosen + one — mirrors DingTalk/Lark's in-card selection feedback.""" + try: + for child in view.children: + if not isinstance(child, discord_ui.Button): + continue + child.disabled = True + if not stale and child.label == chosen_title: + child.style = discord.ButtonStyle.success + if not (child.label or '').startswith('✓ '): + child.label = f'✓ {child.label}' + view.stop() + if interaction.message is not None: + await interaction.message.edit(view=view) + except Exception: + if self.ap is not None: + self.ap.logger.warning(f'Discord: lock-view-message failed (non-fatal): {traceback.format_exc()}') async def is_muted(self, group_id: int) -> bool: return False diff --git a/src/langbot/pkg/platform/sources/lark.py b/src/langbot/pkg/platform/sources/lark.py index 7a2305c98..fdc5cb506 100644 --- a/src/langbot/pkg/platform/sources/lark.py +++ b/src/langbot/pkg/platform/sources/lark.py @@ -31,6 +31,215 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message 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.definition.abstract.platform.event_logger as abstract_platform_logger +import langbot_plugin.api.entities.builtin.provider.session as provider_session + + +def _lark_form_component_name(prefix: str, field_name: str, index: int) -> str: + safe_name = re.sub(r'[^A-Za-z0-9_]', '_', field_name)[:8] or 'field' + digest = hashlib.sha1(field_name.encode('utf-8')).hexdigest()[:6] + return f'{prefix}_{index}_{safe_name}_{digest}'[:32] + + +def _dify_field_name(field: dict) -> str: + return str(field.get('output_variable_name') or field.get('name') or field.get('id') or '').strip() + + +def _dify_field_type(field: dict) -> str: + return str(field.get('type') or 'text').strip().lower() + + +def _dify_select_options(field: dict) -> list[str]: + source = field.get('option_source') or {} + value = source.get('value') if isinstance(source, dict) else None + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return [part.strip() for part in value.splitlines() if part.strip()] + options = field.get('options') + if isinstance(options, list): + result: list[str] = [] + for item in options: + if isinstance(item, dict): + result.append(str(item.get('label') or item.get('value') or '')) + else: + result.append(str(item)) + return [item for item in result if item] + return [] + + +def _dify_default_value(field: dict) -> str: + default = field.get('default') + if isinstance(default, dict): + value = default.get('value') if default.get('type') == 'constant' or 'value' in default else '' + else: + value = default + return '' if value is None else str(value) + + +def _lark_clean_form_content(form_content: str, input_defs: list[dict]) -> str: + field_names = {_dify_field_name(field) for field in input_defs if _dify_field_name(field)} + kept_lines: list[str] = [] + for line in (form_content or '').splitlines(): + placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) + if placeholder and placeholder.group(1) in field_names: + continue + kept_lines.append(line) + return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip()) + + +def _lark_form_input_defs(form_data: dict) -> list[dict]: + return list(form_data.get('all_input_defs') or form_data.get('input_defs') or []) + + +def _lark_current_input_defs(form_data: dict) -> list[dict]: + """Return only the field that belongs to the current interactive step.""" + if form_data.get('_action_select_only'): + return [] + input_defs = list(form_data.get('input_defs') or []) + current_field = str(form_data.get('_current_input_field') or '').strip() + if not current_field: + return input_defs + return [field for field in input_defs if _dify_field_name(field) == current_field] + + +def _lark_should_update_stream_element( + *, + resume_from: bool, + form_data: dict | None, + msg_seq: int, + is_final: bool, +) -> bool: + """Return whether the still-open streaming element should be updated.""" + return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final) + + +def _lark_display_input_value(field: dict, value: typing.Any) -> str: + field_type = _dify_field_type(field) + if field_type == 'file': + if isinstance(value, dict): + return value.get('url') or value.get('upload_file_id') or '1 file' + return str(value) + if field_type == 'file-list': + if isinstance(value, list): + return f'{len(value)} file(s)' + return str(value) + if isinstance(value, dict): + if 'value' in value and value.get('value') not in (None, ''): + return str(value.get('value')) + text = value.get('text') + if isinstance(text, dict): + content = text.get('content') + if content not in (None, ''): + return str(content) + if text not in (None, ''): + return str(text) + if isinstance(value, list): + return ', '.join(_lark_display_input_value(field, item) for item in value if item not in (None, '')) + return str(value) + + +def _lark_visible_form_content(form_data: dict) -> str: + """Return stage content with completed values interleaved for final actions.""" + source_content = form_data.get('form_content') or '' + if form_data.get('_action_select_only'): + source_content = form_data.get('raw_form_content') or source_content + + fields = { + _dify_field_name(field): field for field in _lark_form_input_defs(form_data) if _dify_field_name(field) + } + inputs = form_data.get('inputs') or {} + + def replace_placeholder(match: re.Match[str]) -> str: + field_name = match.group(1).strip() + field = fields.get(field_name) + if not field or inputs.get(field_name) in (None, '', []): + return '' + lines = _lark_completed_input_lines( + { + 'input_defs': [field], + 'inputs': {field_name: inputs[field_name]}, + } + ) + return lines[0] if lines else '' + + source_content = re.sub( + r'\{\{#\$output\.([^#{}]+)#\}\}', + replace_placeholder, + str(source_content), + ) + return _lark_clean_form_content( + str(source_content), + _lark_form_input_defs(form_data), + ) + + +def _lark_completed_input_lines(form_data: dict) -> list[str]: + inputs = form_data.get('inputs') or {} + if not isinstance(inputs, dict): + return [] + + lines: list[str] = [] + for field in _lark_form_input_defs(form_data): + field_name = _dify_field_name(field) + if not field_name: + continue + value = inputs.get(field_name) + if value in (None, '', []): + continue + display_value = _lark_display_input_value(field, value) + lines.append(f'✅ {field_name}:{display_value}') + return lines + + +def _lark_mapping_from_value(value: typing.Any) -> dict: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except (json.JSONDecodeError, TypeError): + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _lark_action_attr(action: typing.Any, name: str) -> typing.Any: + if isinstance(action, dict): + return action.get(name) + return getattr(action, name, None) + + +def _lark_extract_action_form_inputs(action: typing.Any, action_value_obj: dict) -> dict: + input_name_map = action_value_obj.get('input_name_map', {}) + if not isinstance(input_name_map, dict): + input_name_map = {} + + form_value = _lark_mapping_from_value(_lark_action_attr(action, 'form_value')) + if not form_value: + for key in ('form_value', 'formValue', 'form_values', 'formValues'): + form_value = _lark_mapping_from_value(action_value_obj.get(key)) + if form_value: + break + + if not form_value: + action_name = _lark_action_attr(action, 'name') + input_value = _lark_action_attr(action, 'input_value') + option_value = _lark_action_attr(action, 'option') + if action_name and input_value not in (None, ''): + form_value = {action_name: input_value} + elif action_name and option_value not in (None, ''): + form_value = {action_name: option_value} + + form_inputs = {} + for component_name, value in form_value.items(): + field_name = input_name_map.get(component_name) + if not field_name and isinstance(component_name, str) and '.' in component_name: + field_name = input_name_map.get(component_name.rsplit('.', 1)[-1], component_name) + if not field_name: + field_name = component_name + if field_name and value not in (None, '', []): + form_inputs[str(field_name)] = value + return form_inputs class AESCipher(object): @@ -770,6 +979,7 @@ CARD_ID_CACHE_MAX_LIFETIME = 20 * 60 # 20分钟 class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: lark_oapi.ws.Client = pydantic.Field(exclude=True) api_client: lark_oapi.Client = pydantic.Field(exclude=True) + ap: typing.Any = pydantic.Field(exclude=True, default=None) bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识 lark_tenant_key: str = pydantic.Field(exclude=True, default='') # 飞书企业key @@ -792,6 +1002,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): pending_monitoring_msg: dict[str, str] # Final: reply Lark message ID → (monitoring_message_id, timestamp) (used by feedback callbacks) reply_to_monitoring_msg: dict[str, tuple[str, float]] + reply_message_card_ids: dict[str, str] + card_sequence_dict: dict[str, int] + # card_id → set of source message ids registered against it (for cleanup) + card_id_to_source_ids: dict[str, set[str]] + # card_id → current streaming_txt content cache (needed for full aupdate during resume transition) + card_streaming_text: dict[str, str] + # card_id → pre-pause streaming_txt text (captured when resume first chunk arrives) + card_pre_pause_text: dict[str, str] + # card_id → form_content captured when the form is first shown (for resume notice) + card_form_content: dict[str, str] + # card_id → input_defs / inputs captured for the selected-action notice + card_form_input_defs: dict[str, list[dict]] + card_form_inputs: dict[str, dict] + # set of card_ids that have already transitioned from "buttons visible" to "resume layout" + card_resume_transitioned: set[str] _MONITORING_MAPPING_TTL = 600 # 10 minutes seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识 @@ -812,11 +1037,147 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1): asyncio.create_task(on_message(event)) + def schedule_on_app_loop(coro): + """Run a coroutine on the application event loop from sync callbacks.""" + return asyncio.run_coroutine_threadsafe(coro, self.ap.event_loop) + def sync_on_card_action(event): try: - action_value_obj = getattr(getattr(event.event, 'action', None), 'value', {}) + action_value_raw = getattr(getattr(event.event, 'action', None), 'value', {}) + # Parse JSON string values (from form action buttons) + action_value_obj = _lark_mapping_from_value(action_value_raw) action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else '' + # Handle Dify form action button clicks + if isinstance(action_value_obj, dict) and action_value_obj.get('form_action'): + form_token = action_value_obj.get('form_token', '') + workflow_run_id = action_value_obj.get('workflow_run_id', '') + action_id = action_value_obj.get('action_id', '') + session_key = action_value_obj.get('session_key', '') + action = getattr(event.event, 'action', None) + form_inputs = _lark_extract_action_form_inputs(action, action_value_obj) + + if session_key.startswith('group_') or session_key.startswith('g:'): + launcher_type = provider_session.LauncherTypes.GROUP + launcher_id = ( + session_key.split(':', 1)[1] + if session_key.startswith('g:') + else session_key[len('group_') :] + ) + else: + launcher_type = provider_session.LauncherTypes.PERSON + launcher_id = ( + session_key.split(':', 1)[1] + if session_key.startswith('p:') + else session_key[len('person_') :] + ) + + # Find the bot entity to get bot_uuid and pipeline_uuid + bot_uuid = '' + pipeline_uuid = action_value_obj.get('pipeline_uuid') or None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + + form_action_data = { + 'form_token': form_token, + 'workflow_run_id': workflow_run_id, + 'action_id': action_id, + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': form_inputs, + } + if action_value_obj.get('_input_progress'): + form_action_data['_input_progress'] = True + + context = getattr(event.event, 'context', None) + open_message_id = getattr(context, 'open_message_id', None) + if open_message_id and form_inputs: + card_id = self.reply_message_card_ids.get(str(open_message_id)) + else: + card_id = None + if not card_id: + card_id = str(action_value_obj.get('card_id') or '') + if card_id and form_inputs: + cached_inputs = dict(self.card_form_inputs.get(card_id) or {}) + cached_inputs.update(form_inputs) + self.card_form_inputs[card_id] = cached_inputs + if self.ap is not None: + self.ap.logger.info( + f'Lark form action inputs cached: card_id={card_id} ' + f'open_message_id={open_message_id} keys={list(form_inputs.keys())}' + ) + source_time = datetime.datetime.now() + event_time = source_time.timestamp() + action_text = action_value_obj.get('action_id', 'confirm') + message_chain = platform_message.MessageChain( + [platform_message.Plain(text=f'[Form Action: {action_text}]')] + ) + if open_message_id: + message_chain.insert( + 0, + platform_message.Source( + id=open_message_id, + time=source_time, + ), + ) + + operator = getattr(event.event, 'operator', None) + user_id = ( + getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None) or str(launcher_id) + ) + + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=user_id, + member_name='', + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + ), + message_chain=message_chain, + time=event_time, + source_platform_object=event, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=user_id, + nickname='', + remark='', + ), + message_chain=message_chain, + time=event_time, + source_platform_object=event, + ) + + async def add_form_action_query(): + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=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, + }, + ) + + schedule_on_app_loop(add_form_action_query()) + + from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse + + return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}}) + if action_value == '有帮助': feedback_type = 1 elif action_value == '无帮助': @@ -857,17 +1218,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ) if platform_events.FeedbackEvent in self.listeners: - loop = asyncio.get_event_loop() - if loop.is_running(): - asyncio.create_task(self.listeners[platform_events.FeedbackEvent](feedback_event, self)) - else: - loop.run_until_complete(self.listeners[platform_events.FeedbackEvent](feedback_event, self)) + schedule_on_app_loop(self.listeners[platform_events.FeedbackEvent](feedback_event, self)) from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}}) except Exception: - asyncio.create_task(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')) + traceback.print_exc() + schedule_on_app_loop(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')) from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}}) @@ -894,6 +1252,15 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): card_id_dict={}, pending_monitoring_msg={}, reply_to_monitoring_msg={}, + reply_message_card_ids={}, + card_sequence_dict={}, + card_id_to_source_ids={}, + card_streaming_text={}, + card_pre_pause_text={}, + card_form_content={}, + card_form_input_defs={}, + card_form_inputs={}, + card_resume_transitioned=set(), seq=1, listeners={}, quart_app=quart_app, @@ -1148,6 +1515,36 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): for k in expired: del self.reply_to_monitoring_msg[k] + def _next_card_sequence(self, card_id: str, suggested: int = 1) -> int: + """Return the next strictly increasing sequence for a card update.""" + current = self.card_sequence_dict.get(card_id, 0) + next_seq = max(current + 1, suggested) + self.card_sequence_dict[card_id] = next_seq + return next_seq + + def _register_card_for_source(self, card_id: str, *source_ids: str) -> None: + """Register a card_id under one or more source message ids.""" + bucket = self.card_id_to_source_ids.setdefault(card_id, set()) + for sid in source_ids: + if not sid: + continue + self.reply_message_card_ids[sid] = card_id + bucket.add(sid) + + def _drop_card_state(self, card_id: str) -> None: + """Pop all per-card state for the given card_id.""" + if not card_id: + return + for sid in self.card_id_to_source_ids.pop(card_id, set()): + self.reply_message_card_ids.pop(sid, None) + self.card_sequence_dict.pop(card_id, None) + self.card_streaming_text.pop(card_id, None) + self.card_pre_pause_text.pop(card_id, None) + self.card_form_content.pop(card_id, None) + self.card_form_input_defs.pop(card_id, None) + self.card_form_inputs.pop(card_id, None) + self.card_resume_transitioned.discard(card_id) + async def create_card_id(self, message_id): try: # self.logger.debug('飞书支持stream输出,创建卡片......') @@ -1343,6 +1740,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): self.card_id_dict[message_id] = response.data.card_id card_id = response.data.card_id + self.card_sequence_dict[card_id] = 0 return card_id except Exception as e: @@ -1355,6 +1753,12 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): """ # message_id = event.message_chain.message_id + source_message_id = str(event.message_chain.message_id) + existing_card_id = self.reply_message_card_ids.get(source_message_id) + if existing_card_id: + self.card_id_dict[message_id] = existing_card_id + return True + card_id = await self.create_card_id(message_id) content = { 'type': 'card', @@ -1393,6 +1797,16 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): user_msg_id = event.message_chain.message_id reply_msg_id = getattr(response.data, 'message_id', None) monitoring_msg_id = self.pending_monitoring_msg.pop(user_msg_id, None) + # Register the card under both the user-incoming msg id (so a + # second reply_message_first_chunk for the same user message + # reuses this card) AND the bot-reply msg id (so a synthetic + # event from a form-button callback — whose Source.id equals + # the bot's card message id — hits the same card and renders + # the resume content into it). + if reply_msg_id: + self._register_card_for_source(card_id, str(user_msg_id), str(reply_msg_id)) + else: + self._register_card_for_source(card_id, str(user_msg_id)) if reply_msg_id and monitoring_msg_id: self.reply_to_monitoring_msg[reply_msg_id] = (monitoring_msg_id, time.time()) self._cleanup_monitoring_mapping() @@ -1401,6 +1815,93 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return True + async def _open_new_form_card( + self, + message_id: str, + message_source: platform_events.MessageEvent, + form_data: dict, + ) -> str | None: + """Spawn a fresh card to host a re-paused human-input prompt. + + Creates a new card_id (rebinding ``self.card_id_dict[message_id]``), + replies it to the current incoming message so it appears as the next + step in the chat, registers the new reply_msg_id so subsequent button + callbacks resolve back to it, and renders the prompt + buttons on it. + + Returns the new card_id, or ``None`` if creation failed (caller is + responsible for falling back to in-place update so the workflow + remains continuable). + """ + source_message_id = getattr(message_source.message_chain, 'message_id', None) + if not source_message_id: + await self.logger.error('Cannot open new form card: source message_id missing') + return None + + try: + new_card_id = await self.create_card_id(message_id) + except Exception: + await self.logger.error(f'Failed to create new form card: {traceback.format_exc()}') + return None + + tenant_key = ( + message_source.source_platform_object.header.tenant_key if message_source.source_platform_object else None + ) + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) + + content = { + 'type': 'card', + 'data': {'card_id': new_card_id, 'template_variable': {'content': ''}}, + } + request: ReplyMessageRequest = ( + ReplyMessageRequest.builder() + .message_id(str(source_message_id)) + .request_body( + ReplyMessageRequestBody.builder() + .content(json.dumps(content)) + .msg_type('interactive') + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + + try: + response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt) + except Exception: + await self.logger.error(f'Failed to send new form card: {traceback.format_exc()}') + return None + + if not response.success(): + await self.logger.error( + f'Failed to send new form card: code={response.code}, msg={response.msg}, ' + f'log_id={response.get_log_id()}' + ) + return None + + reply_msg_id = getattr(response.data, 'message_id', None) + if reply_msg_id: + self._register_card_for_source(new_card_id, str(source_message_id), str(reply_msg_id)) + + sequence = self._next_card_sequence(new_card_id, 1) + await self._update_card_layout( + card_id=new_card_id, + message_source=message_source, + text_message='', + sequence=sequence, + form_data=form_data, + show_form_prompt=True, + ) + return new_card_id + async def reply_message( self, message_source: platform_events.MessageEvent, @@ -1520,45 +2021,717 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ): """ 回复消息变成更新卡片消息 + + Supports Dify form-action resume: when the runner yields a chunk with + ``_resume_from_form=True``, the card transitions from buttons to a + grey selection notice and a new ``streaming_txt_resume`` element is added + for subsequent resume chunks to stream into. + + When ``_open_new_card=True`` on the final chunk, the existing card is + left as-is and the pipeline will create a new card (with fresh form + buttons) for the re-pause. """ - # self.seq += 1 message_id = bot_message.resp_message_id msg_seq = bot_message.msg_sequence - if msg_seq % 8 == 0 or is_final: - text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) - text_message = '' - if text_elements: - parts = [] - for paragraph in text_elements: - para_text = ''.join(ele['text'] for ele in paragraph if ele['tag'] in ('text', 'md')) - if para_text: - parts.append(para_text) - text_message = '\n\n'.join(parts) + form_data = getattr(bot_message, '_form_data', None) + resume_from = getattr(bot_message, '_resume_from_form', False) + action_title = getattr(bot_message, '_resume_action_title', '') + resume_node_title = getattr(bot_message, '_resume_node_title', '') + open_new_card = getattr(bot_message, '_open_new_card', False) - # content = { - # 'type': 'card_json', - # 'data': {'card_id': self.card_id_dict[message_id], 'elements': {'content': text_message}}, - # } + # ── decide whether this chunk needs a card update ──────────────────── + card_id = self.card_id_dict.get(message_id) + if not card_id: + return - request: ContentCardElementRequest = ( - ContentCardElementRequest.builder() - .card_id(self.card_id_dict[message_id]) - .element_id('streaming_txt') - .request_body( - ContentCardElementRequestBody.builder() - # .uuid("a0d69e20-1dd1-458b-k525-dfeca4015204") - .content(text_message) - .sequence(msg_seq) + if action_title: + # Build the selected notice with node_title, form_content, and action + notice_parts = [] + if resume_node_title: + notice_parts.append(f'**{resume_node_title}**') + stored_form_content = self.card_form_content.get(card_id, '') + if stored_form_content: + notice_parts.append(stored_form_content) + completed_lines = _lark_completed_input_lines( + { + 'input_defs': self.card_form_input_defs.get(card_id, []), + 'inputs': self.card_form_inputs.get(card_id, {}), + } + ) + if completed_lines and not all(line in stored_form_content for line in completed_lines): + notice_parts.append('---\n' + '\n'.join(completed_lines)) + notice_parts.append(f'---\n✅ {action_title}') + selected_notice = '\n\n'.join(notice_parts) + else: + selected_notice = '' + + # ── convert message chain → text ───────────────────────────────────── + text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) + + text_message = '' + if text_elements: + parts = [] + for paragraph in text_elements: + para_text = ''.join(ele['text'] for ele in paragraph if ele['tag'] in ('text', 'md')) + if para_text: + parts.append(para_text) + text_message = '\n\n'.join(parts) + + tenant_key = ( + message_source.source_platform_object.header.tenant_key if message_source.source_platform_object else None + ) + app_access_token = self.get_app_access_token() + tenant_access_token = self.get_tenant_access_token(tenant_key) + req_opt: RequestOption = ( + RequestOption.builder() + .app_ticket(self.app_ticket) + .tenant_key(tenant_key) + .app_access_token(app_access_token) + .tenant_access_token(tenant_access_token) + .build() + ) + + card_sequence = self._next_card_sequence(card_id, msg_seq) + + # ── RESUME: first chunk after button click ─────────────────────────── + if resume_from and card_id not in self.card_resume_transitioned: + # Transition the card from the form state into resume mode. + # Preserve the text that was shown before the pause, and seed the + # resume placeholder with the current resume content if we already + # have any on the first yielded chunk. + pre_pause_text = self.card_pre_pause_text.get(card_id) or self.card_streaming_text.get(card_id, '') + initial_resume_text = text_message or '\u200b' + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=pre_pause_text, + sequence=card_sequence, + form_data=None, + notice_text=selected_notice, + resume_placeholder_text=initial_resume_text, + ) + self.card_resume_transitioned.add(card_id) + self.card_pre_pause_text[card_id] = pre_pause_text + self.card_streaming_text[card_id] = text_message + if not is_final: + return + + # ── RESUME: subsequent chunks → full card update ───────────────────── + if resume_from and card_id in self.card_resume_transitioned: + cached = self.card_streaming_text.get(card_id, '') + if text_message != cached: + self.card_streaming_text[card_id] = text_message + pre_pause_text = self.card_pre_pause_text.get(card_id, '') + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=pre_pause_text, + sequence=card_sequence, + form_data=None, + notice_text=selected_notice, + resume_placeholder_text=text_message, + ) + if not is_final: + return + + # ── NORMAL streaming (non-resume): update streaming_txt in-place ────── + if _lark_should_update_stream_element( + resume_from=resume_from, + form_data=form_data, + msg_seq=msg_seq, + is_final=is_final, + ): + cached = self.card_streaming_text.get(card_id) + if text_message != cached: + self.card_streaming_text[card_id] = text_message + request: ContentCardElementRequest = ( + ContentCardElementRequest.builder() + .card_id(card_id) + .element_id('streaming_txt') + .request_body( + ContentCardElementRequestBody.builder().content(text_message).sequence(card_sequence).build() + ) .build() ) - .build() + response: ContentCardElementResponse = await self.api_client.cardkit.v1.card_element.acontent( + request, req_opt + ) + if not response.success(): + raise Exception( + f'client.cardkit.v1.card_element.acontent failed, code: {response.code}, ' + f'msg: {response.msg}, log_id: {response.get_log_id()}, ' + f'resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + ) + + # ── FINAL chunk: full card layout update ───────────────────────────── + if is_final: + final_seq = self._next_card_sequence(card_id, card_sequence + 1) + pre_pause = self.card_pre_pause_text.get(card_id, text_message) + resume_cached = self.card_streaming_text.get(card_id, '') + if form_data: + if open_new_card: + # The old card has already been laid out into resume mode + # by the resume-transition block above (notice + resume + # placeholder). Finalise it as a frozen step snapshot and + # spawn a brand-new card to host the next human-input + # prompt — each step stays visible as its own card in the + # chat history. + new_card_id = await self._open_new_form_card(message_id, message_source, form_data) + if new_card_id is None: + # Fallback: keep the existing in-place behaviour so the + # workflow remains continuable even if creating the + # new card failed. + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=pre_pause, + sequence=final_seq, + form_data=form_data, + resume_placeholder_text=resume_cached, + show_form_prompt=True, + ) + self.card_streaming_text.pop(card_id, None) + self.card_pre_pause_text.pop(card_id, None) + self.card_form_content.pop(card_id, None) + self.card_form_input_defs.pop(card_id, None) + self.card_form_inputs.pop(card_id, None) + else: + # The old card is now a frozen snapshot; let go of its + # streaming-side state but keep its source registrations + # intact (no _drop_card_state) so historical button + # callbacks aimed at it can still be matched if needed. + self.card_streaming_text.pop(card_id, None) + self.card_pre_pause_text.pop(card_id, None) + self.card_form_content.pop(card_id, None) + self.card_form_input_defs.pop(card_id, None) + self.card_form_inputs.pop(card_id, None) + self.card_resume_transitioned.discard(card_id) + else: + # Initial pause path: render prompt + buttons in place on + # the current card. + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=text_message, + sequence=final_seq, + form_data=form_data, + show_form_prompt=True, + ) + # Preserve the pre-pause text so the main content can be + # restored when the user clicks a button and the card + # transitions to resume mode. + self.card_pre_pause_text[card_id] = self.card_streaming_text.get(card_id, '') + self.card_streaming_text[card_id] = '' + # Store cleaned form state for the resume notice. + self.card_form_content[card_id] = _lark_visible_form_content(form_data) + self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data) + self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {}) + else: + # Normal finish: keep pre-pause + resume content visible, + # remove buttons/notice, drop the resume placeholder. + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=pre_pause, + sequence=final_seq, + form_data=None, + notice_text=selected_notice if resume_from else '', + resume_placeholder_text=resume_cached, + ) + self._drop_card_state(card_id) + self.card_id_dict.pop(message_id, None) + + # ── media (images / files) appended at the end ─────────────────────── + if is_final and media_items: + for media in media_items: + media_request: ReplyMessageRequest = ( + ReplyMessageRequest.builder() + .message_id(message_source.message_chain.message_id) + .request_body( + ReplyMessageRequestBody.builder() + .content(json.dumps(media['content'])) + .msg_type(media['msg_type']) + .reply_in_thread(False) + .uuid(str(uuid.uuid4())) + .build() + ) + .build() + ) + media_response: ReplyMessageResponse = await self.api_client.im.v1.message.areply( + media_request, req_opt + ) + if not media_response.success(): + raise Exception( + f'client.im.v1.message.reply ({media["msg_type"]}) failed, code: {media_response.code}, msg: {media_response.msg}, log_id: {media_response.get_log_id()}' + ) + + async def _add_form_buttons_to_card( + self, + card_id: str, + message_source: platform_events.MessageEvent, + form_data: dict, + text_message: str = '', + sequence: int = 1, + ): + """Update the entire card to include form action buttons. + + Uses card.aupdate to replace the card JSON with a template that + includes the streaming text content plus interactive buttons. + """ + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=text_message, + sequence=sequence, + form_data=form_data, + ) + + async def _remove_form_buttons_from_card( + self, + card_id: str, + message_source: platform_events.MessageEvent, + text_message: str = '', + sequence: int = 1, + ): + """Replace the human-input card layout with the plain final layout.""" + await self._update_card_layout( + card_id=card_id, + message_source=message_source, + text_message=text_message, + sequence=sequence, + form_data=None, + ) + + def _build_lark_form_field_elements(self, form_data: dict) -> tuple[list[dict], dict[str, str], list[str]]: + elements: list[dict] = [] + input_name_map: dict[str, str] = {} + file_help_lines: list[str] = [] + + for idx, field in enumerate(_lark_current_input_defs(form_data), start=1): + field_name = _dify_field_name(field) + if not field_name: + continue + field_type = _dify_field_type(field) + + if field_type == 'select': + options = _dify_select_options(field) + component_name = _lark_form_component_name('Select', field_name, idx) + input_name_map[component_name] = field_name + elements.append( + { + 'tag': 'select_static', + 'name': component_name, + 'label': {'tag': 'plain_text', 'content': field_name}, + 'placeholder': {'tag': 'plain_text', 'content': '请选择'}, + 'options': [ + { + 'text': {'tag': 'plain_text', 'content': option}, + 'value': option, + } + for option in options + ], + 'type': 'default', + 'width': 'fill', + 'required': False, + } + ) + elif field_type in {'file', 'file-list'}: + allowed_types = ', '.join(field.get('allowed_file_types') or []) + allowed = f' ({allowed_types})' if allowed_types else '' + if field_type == 'file-list': + limit = field.get('number_limits') + suffix = f', up to {limit}' if limit else '' + file_help_lines.append( + f'- {field_name}: upload file(s){allowed}{suffix} in chat or reply `{field_name}: `' + ) + else: + file_help_lines.append( + f'- {field_name}: upload a file{allowed} in chat or reply `{field_name}: `' + ) + else: + component_name = _lark_form_component_name('Input', field_name, idx) + input_name_map[component_name] = field_name + is_multiline = field_type in {'paragraph', 'long_text', 'multiline_text', 'textarea'} + input_element = { + 'tag': 'input', + 'name': component_name, + 'label': {'tag': 'plain_text', 'content': field_name}, + 'placeholder': {'tag': 'plain_text', 'content': '请输入'}, + 'default_value': _dify_default_value(field), + 'width': 'fill', + 'required': False, + } + if is_multiline: + input_element.update( + { + 'input_type': 'multiline_text', + 'rows': 3, + 'auto_resize': True, + 'max_rows': 6, + } + ) + elements.append(input_element) + + return elements, input_name_map, file_help_lines + + async def _update_card_layout( + self, + card_id: str, + message_source: platform_events.MessageEvent, + text_message: str = '', + sequence: int = 1, + form_data: dict | None = None, + notice_text: str = '', + resume_placeholder_text: str = '', + show_form_prompt: bool = True, + ): + """Update the entire card layout. + + • form_data → show interactive buttons (initial Dify pause) + • notice_text → replace buttons with a grey selection notice (resume transition) + • resume_placeholder_text → add a streaming_txt_resume markdown element + """ + form_data = form_data or {} + actions = form_data.get('actions', []) + form_token = form_data.get('form_token', '') + workflow_run_id = form_data.get('workflow_run_id', '') + pipeline_uuid = form_data.get('pipeline_uuid', '') + node_title = form_data.get('node_title', '') or 'Human Input Required' + form_content = form_data.get('form_content', '') + input_defs = _lark_form_input_defs(form_data) + + # When form_data is set, the visible content is rendered inside the + # interactive container, so the top streaming text should stay empty + # to avoid duplicate text above the action area. + # + # For resume notice state, keep the existing text visible in the card + # and only add the grey "selected" notice below it. + if form_data: + render_text_message = '' + else: + render_text_message = text_message + + # Determine session key from message source + if isinstance(message_source, platform_events.GroupMessage): + session_key = f'group_{message_source.group.id}' + else: + session_key = f'person_{message_source.sender.id}' + + # Build button elements matching the existing card template's thumbsup/down format + action_buttons = [] + form_field_elements, input_name_map, file_help_lines = self._build_lark_form_field_elements(form_data) + uses_form_container = bool(form_field_elements or input_name_map) + if form_data: + form_content = _lark_visible_form_content(form_data) + self.card_form_content[card_id] = form_content + self.card_form_input_defs[card_id] = input_defs + self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {}) + is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only') + if is_field_step: + actions = ( + [{'_input_progress': True, 'id': '', 'title': 'Next', 'button_style': 'primary'}] + if uses_form_container + else [] ) + for action in actions: + action_id = action.get('id', '') + action_title = action.get('title', action_id) + button_style = action.get('button_style', 'default') - if is_final and bot_message.tool_calls is None: - # self.seq = 1 # 消息回复结束之后重置seq - self.card_id_dict.pop(message_id) # 清理已经使用过的卡片 + if button_style == 'primary': + lark_button_type = 'primary' + elif button_style == 'danger': + lark_button_type = 'danger' + else: + lark_button_type = 'default' + button = { + 'tag': 'button', + 'text': {'tag': 'plain_text', 'content': action_title}, + 'type': lark_button_type, + 'width': 'fill', + 'size': 'medium', + 'hover_tips': {'tag': 'plain_text', 'content': action_title}, + 'behaviors': [ + { + 'type': 'callback', + 'value': { + 'form_action': True, + 'form_token': form_token, + 'workflow_run_id': workflow_run_id, + 'pipeline_uuid': pipeline_uuid, + 'action_id': action_id, + 'session_key': session_key, + 'card_id': card_id, + 'input_name_map': input_name_map, + '_input_progress': bool(action.get('_input_progress')), + }, + } + ], + 'margin': '0px 0px 0px 0px', + } + if uses_form_container: + button['name'] = _lark_form_component_name('Button', action_id or action_title, len(action_buttons) + 1) + button['form_action_type'] = 'submit' + action_buttons.append(button) + + interactive_elements = [] + if form_data: + if show_form_prompt: + interactive_elements = [ + { + 'tag': 'markdown', + 'content': f'**[Human Input Required] {node_title}**', + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 4px 0px', + } + ] + if form_content: + interactive_elements.append( + { + 'tag': 'markdown', + 'content': form_content, + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 8px 0px', + } + ) + completed_lines = ( + [] + if form_data.get('_action_select_only') + else _lark_completed_input_lines( + { + 'input_defs': input_defs, + 'inputs': form_data.get('inputs') or {}, + } + ) + ) + if completed_lines: + interactive_elements.append( + { + 'tag': 'markdown', + 'content': '---\n' + '\n'.join(completed_lines), + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 8px 0px', + } + ) + if file_help_lines: + interactive_elements.append( + { + 'tag': 'markdown', + 'content': '\n'.join(file_help_lines), + 'text_align': 'left', + 'text_size': 'normal', + 'text_color': 'grey', + 'margin': '0px 0px 8px 0px', + } + ) + if action_buttons: + interactive_elements.append( + { + 'tag': 'column_set', + 'horizontal_spacing': '8px', + 'horizontal_align': 'left', + 'margin': '0px 0px 0px 0px', + 'columns': [ + { + 'tag': 'column', + 'width': 'weighted', + 'elements': [btn], + 'padding': '0px 0px 0px 0px', + } + for btn in action_buttons + ], + } + ) + + # Build the full card JSON with buttons, same structure as create_card_id + # ── mid_section: either form buttons, resume notice, or empty ── + mid_section_elements = [] + if form_data: + if uses_form_container: + form_elements = interactive_elements[:-1] if action_buttons else interactive_elements[:] + form_elements.extend(form_field_elements) + if action_buttons: + form_elements.append(interactive_elements[-1]) + mid_section_elements = [ + { + 'tag': 'form', + 'name': _lark_form_component_name('Form', form_token or workflow_run_id or card_id, 1), + 'direction': 'vertical', + 'vertical_spacing': '12px', + 'margin': '12px 0px 8px 0px', + 'padding': '12px 12px 12px 12px', + 'elements': form_elements, + }, + {'tag': 'hr', 'margin': '0px 0px 0px 0px'}, + ] + else: + mid_section_elements = [ + { + 'tag': 'interactive_container', + 'margin': '12px 0px 8px 0px', + 'padding': '12px 12px 12px 12px', + 'has_border': True, + 'elements': interactive_elements, + }, + {'tag': 'hr', 'margin': '0px 0px 0px 0px'}, + ] + elif notice_text: + mid_section_elements = [ + { + 'tag': 'markdown', + 'content': notice_text, + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '8px 0px 4px 0px', + 'text_color': 'grey', + }, + {'tag': 'hr', 'margin': '0px 0px 0px 0px'}, + ] + + # ── resume placeholder element (empty, filled via acontent on each chunk) ── + resume_elements = [] + if resume_placeholder_text: + resume_elements = [ + { + 'tag': 'markdown', + 'content': resume_placeholder_text, + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 0px 0px', + 'element_id': 'streaming_txt_resume', + }, + ] + + card_data = { + 'schema': '2.0', + 'config': { + 'update_multi': True, + 'streaming_mode': False, + }, + 'body': { + 'direction': 'vertical', + 'padding': '12px 12px 12px 12px', + 'elements': [ + { + 'tag': 'div', + 'text': { + 'tag': 'plain_text', + 'content': 'LangBot', + 'text_size': 'normal', + 'text_align': 'left', + 'text_color': 'default', + }, + 'icon': { + 'tag': 'custom_icon', + 'img_key': 'img_v3_02p3_05c65d5d-9bad-440a-a2fb-c89571bfd5bg', + }, + }, + { + 'tag': 'markdown', + 'content': render_text_message, + 'text_align': 'left', + 'text_size': 'normal', + 'margin': '0px 0px 0px 0px', + 'element_id': 'streaming_txt', + }, + *mid_section_elements, + *resume_elements, + { + 'tag': 'column_set', + 'horizontal_spacing': '12px', + 'horizontal_align': 'right', + 'columns': [ + { + 'tag': 'column', + 'width': 'weighted', + 'elements': [ + { + 'tag': 'markdown', + 'content': '以上内容由 AI 生成,仅供参考。更多详细、准确信息可点击引用链接查看', + 'text_align': 'left', + 'text_size': 'notation', + 'margin': '4px 0px 0px 0px', + 'icon': { + 'tag': 'standard_icon', + 'token': 'robot_outlined', + 'color': 'grey', + }, + } + ], + 'padding': '0px 0px 0px 0px', + 'direction': 'vertical', + 'horizontal_spacing': '8px', + 'vertical_spacing': '8px', + 'horizontal_align': 'left', + 'vertical_align': 'top', + 'margin': '0px 0px 0px 0px', + 'weight': 1, + }, + *( + [] + if form_data + else [ + { + 'tag': 'column', + 'width': '20px', + 'elements': [ + { + 'tag': 'button', + 'text': {'tag': 'plain_text', 'content': ''}, + 'type': 'text', + 'width': 'fill', + 'size': 'medium', + 'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'}, + 'hover_tips': {'tag': 'plain_text', 'content': '有帮助'}, + 'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}], + 'margin': '0px 0px 0px 0px', + } + ], + 'padding': '0px 0px 0px 0px', + 'direction': 'vertical', + 'horizontal_spacing': '8px', + 'vertical_spacing': '8px', + 'horizontal_align': 'left', + 'vertical_align': 'top', + 'margin': '0px 0px 0px 0px', + }, + { + 'tag': 'column', + 'width': '30px', + 'elements': [ + { + 'tag': 'button', + 'text': {'tag': 'plain_text', 'content': ''}, + 'type': 'text', + 'width': 'default', + 'size': 'medium', + 'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'}, + 'hover_tips': {'tag': 'plain_text', 'content': '无帮助'}, + 'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}], + 'margin': '0px 0px 0px 0px', + } + ], + 'padding': '0px 0px 0px 0px', + 'vertical_spacing': '8px', + 'horizontal_align': 'left', + 'vertical_align': 'top', + 'margin': '0px 0px 0px 0px', + }, + ] + ), + ], + 'margin': '0px 0px 4px 0px', + }, + ], + }, + } + + try: tenant_key = ( message_source.source_platform_object.header.tenant_key if message_source.source_platform_object @@ -1574,39 +2747,27 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): .tenant_access_token(tenant_access_token) .build() ) - # 发起请求 - response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request, req_opt) - # 处理失败返回 - if not response.success(): - raise Exception( - f'client.im.v1.message.patch failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}' + request: UpdateCardRequest = ( + UpdateCardRequest.builder() + .card_id(card_id) + .request_body( + UpdateCardRequestBody.builder() + .sequence(sequence) + .uuid(str(uuid.uuid4())) + .card(Card.builder().type('card_json').data(json.dumps(card_data)).build()) + .build() ) - return - - # Send media messages when streaming is done - if is_final and media_items: - for media in media_items: - media_request: ReplyMessageRequest = ( - ReplyMessageRequest.builder() - .message_id(message_source.message_chain.message_id) - .request_body( - ReplyMessageRequestBody.builder() - .content(json.dumps(media['content'])) - .msg_type(media['msg_type']) - .reply_in_thread(False) - .uuid(str(uuid.uuid4())) - .build() - ) - .build() - ) - media_response: ReplyMessageResponse = await self.api_client.im.v1.message.areply( - media_request, req_opt - ) - if not media_response.success(): - raise Exception( - f'client.im.v1.message.reply ({media["msg_type"]}) failed, code: {media_response.code}, msg: {media_response.msg}, log_id: {media_response.get_log_id()}' - ) + .build() + ) + response: UpdateCardResponse = await self.api_client.cardkit.v1.card.aupdate(request, req_opt) + if not response.success(): + await self.logger.error( + f'Failed to update lark card with form buttons: code={response.code}, msg={response.msg}, ' + f'log_id={response.get_log_id()}, resp={getattr(getattr(response, "raw", None), "content", None)}' + ) + except Exception: + await self.logger.error(f'Error updating lark card with form buttons: {traceback.format_exc()}') async def is_muted(self, group_id: int) -> bool: return False @@ -1688,9 +2849,123 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): action = event_data.get('action', {}) context_data = event_data.get('context', {}) - action_value_obj = action.get('value', {}) + action_value_obj = _lark_mapping_from_value(action.get('value', {})) action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else '' + if isinstance(action_value_obj, dict) and action_value_obj.get('form_action'): + form_token = action_value_obj.get('form_token', '') + workflow_run_id = action_value_obj.get('workflow_run_id', '') + action_id = action_value_obj.get('action_id', '') + session_key = action_value_obj.get('session_key', '') + form_inputs = _lark_extract_action_form_inputs(action, action_value_obj) + + if session_key.startswith('group_') or session_key.startswith('g:'): + launcher_type = provider_session.LauncherTypes.GROUP + launcher_id = ( + session_key.split(':', 1)[1] + if session_key.startswith('g:') + else session_key[len('group_') :] + ) + else: + launcher_type = provider_session.LauncherTypes.PERSON + launcher_id = ( + session_key.split(':', 1)[1] + if session_key.startswith('p:') + else session_key[len('person_') :] + ) + + form_action_data = { + 'form_token': form_token, + 'workflow_run_id': workflow_run_id, + 'action_id': action_id, + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': form_inputs, + } + if action_value_obj.get('_input_progress'): + form_action_data['_input_progress'] = True + + open_message_id = context_data.get('open_message_id') + card_id = self.reply_message_card_ids.get(str(open_message_id)) if open_message_id else None + if not card_id: + card_id = str(action_value_obj.get('card_id') or '') + if card_id and form_inputs: + cached_inputs = dict(self.card_form_inputs.get(card_id) or {}) + cached_inputs.update(form_inputs) + self.card_form_inputs[card_id] = cached_inputs + if self.ap is not None: + self.ap.logger.info( + f'Lark form action inputs cached: card_id={card_id} ' + f'open_message_id={open_message_id} keys={list(form_inputs.keys())}' + ) + + source_time = datetime.datetime.now() + message_chain = platform_message.MessageChain( + [platform_message.Plain(text=f'[Form Action: {action_id or "confirm"}]')] + ) + if open_message_id: + message_chain.insert( + 0, + platform_message.Source( + id=open_message_id, + time=source_time, + ), + ) + + user_id = operator.get('open_id') or operator.get('user_id') or str(launcher_id) + event_time = source_time.timestamp() + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=user_id, + member_name='', + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + ), + message_chain=message_chain, + time=event_time, + source_platform_object=data, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=user_id, + nickname='', + remark='', + ), + message_chain=message_chain, + time=event_time, + source_platform_object=data, + ) + + bot_uuid = '' + pipeline_uuid = action_value_obj.get('pipeline_uuid') or None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=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, + }, + ) + + return {'toast': {'type': 'success', 'content': '操作成功'}} + if action_value == '有帮助': feedback_type = 1 elif action_value == '无帮助': diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py index 8af406972..35fd81f76 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.py +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -11,7 +11,13 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities -from langbot.libs.qq_official_api.api import QQOfficialClient +from langbot.libs.qq_official_api.api import ( + QQ_SELECT_ACTION_PREFIX, + QQOfficialClient, + build_keyboard_from_form, + build_keyboard_from_select_field, + resolve_select_button_action, +) from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent from ...utils import image from ..logger import EventLogger @@ -191,6 +197,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter enable_webhook: bool = False message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter() event_converter: QQOfficialEventConverter = QQOfficialEventConverter() + ap: typing.Any = None def __init__(self, config: dict, logger: EventLogger): enable_webhook = config.get('enable-webhook', False) @@ -216,6 +223,31 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter self._stream_ctx_ts: dict[str, float] = {} self._fallback_text: dict[str, str] = {} self._fallback_text_ts: dict[str, float] = {} + # Dify form-action bookkeeping for the human-input button flow. + # session_key = "_" where scene is c2c/group/channel and + # id is user_openid / group_openid / channel_id. + # session_key -> {form_data, msg_id, event_id, scene, target_id, + # sender_id, posted_at} + # Set when we send a markdown+keyboard card and consulted when: + # (a) INTERACTION_CREATE fires — we look up the form by + # session_key (button's `data` carries the action_id), + # (b) the resumed-workflow query needs to find a passive-reply + # event_id (INTERACTION_CREATE id, 30-min validity). + self._pending_forms: dict[str, dict] = {} + # session_key -> most recent ``INTERACTION_CREATE`` event_id, used + # as the passive event_id for the resumed query's LLM output. + self._session_event_ids: dict[str, dict] = {} + # Per-anchor msg_seq counter. QQ accepts up to 5 passive replies + # per (msg_id|event_id) within 60 min, but each reuse needs a + # fresh ``msg_seq`` — re-sending with msg_seq=1 is silently dedup'd. + self._anchor_msg_seq: dict[str, int] = {} + + # Wire button-click handler so webhook mode catches INTERACTION_CREATE. + # (ws mode is wired separately via on_event in _run_websocket so the + # raw payload bypasses get_message's message-only flattening.) + @self.bot.on_interaction() + async def _on_interaction(event_data: dict, interaction_id: typing.Optional[str]): + await self._handle_interaction_create(event_data, interaction_id) async def reply_message( self, @@ -227,6 +259,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter message_source, ) + # Synthetic event (button-click resume): no inbound platform + # object → no msg_id. Route via the cached INTERACTION_CREATE + # event_id (valid 30 min, no quota cost). + if qq_official_event is None: + await self._reply_synthetic(message_source, message) + return + content_list = await QQOfficialMessageConverter.yiri2target(message) # 确定 target_type 和 target_id @@ -376,6 +415,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter await self.logger.info('QQ Official WebSocket connected and ready') async def on_event(event_type: str, event_data: dict): + # INTERACTION_CREATE is dispatched via bot.on_interaction() + # (registered in __init__) so we get the top-level ws_event_id + # — needed as the passive-reply event_id. It never reaches here. # 只处理消息事件,忽略 READY/RESUMED 等系统事件 message_event_types = { 'C2C_MESSAGE_CREATE', @@ -437,12 +479,36 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter async def is_stream_output_supported(self) -> bool: return self.config.get('enable-stream-reply', False) + @staticmethod + def _is_form_placeholder_chunk(text: str) -> bool: + """Return True for invisible placeholder chunks used to carry forms.""" + + if not text: + return False + + cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip() + # Some Windows consoles/logs display the zero-width placeholder as + # mojibake. Treat those variants as the same non-user-facing marker. + return cleaned in {'', '鈥?', '​'} + async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool: source = event.source_platform_object + # Synthetic events (button-click resume) have no source object — + # they ride a cached INTERACTION_CREATE event_id, not a streamable + # msg_id. Skip stream setup; reply_message handles the one-shot + # send at is_final. + if source is None: + return False # Streaming API only supports C2C private chat if source.t != 'C2C_MESSAGE_CREATE': return False + # The stream endpoint still consumes msg_seq for this inbound msg_id. + # Keep the passive-reply counter in sync so a follow-up form card uses + # msg_seq=2 instead of being deduplicated by QQ as another seq=1 send. + if source.d_id: + self._anchor_msg_seq[source.d_id] = max(self._anchor_msg_seq.get(source.d_id, 0), 1) + ctx = { 'user_openid': source.user_openid, 'msg_id': source.d_id, @@ -469,12 +535,38 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter ): # Periodically clean up stale stream contexts await self._cleanup_stale_streams() + + # Dify human-input pause: when the runner attaches `_form_data` to + # the final chunk, finalize any in-flight stream session and send + # a markdown + keyboard message instead. Plain-text content from + # earlier chunks is already on the stream; we close it cleanly + # and the buttons land as a separate reply. + form_data = getattr(bot_message, '_form_data', None) if not isinstance(bot_message, dict) else None + if is_final: + _resume = getattr(bot_message, '_resume_from_form', None) if not isinstance(bot_message, dict) else None + _open_new = getattr(bot_message, '_open_new_card', None) if not isinstance(bot_message, dict) else None + if self.ap is not None: + self.ap.logger.info( + f'QQ Official reply_message_chunk final: ' + f'type={type(bot_message).__name__} ' + f'is_final={is_final} ' + f'form_data_present={form_data is not None} ' + f'resume_from_form={_resume} open_new_card={_open_new} ' + f'content_len={len(getattr(bot_message, "content", "") or "")}' + ) + if form_data and is_final: + await self._handle_form_chunk(message_source, message, form_data) + return + # 提取纯文本内容(当前 chunk 的文本) text_parts = [] for msg in message: if type(msg) is platform_message.Plain: text_parts.append(msg.text) chunk_text = '\n\n'.join(text_parts) + if self._is_form_placeholder_chunk(chunk_text): + await self.logger.debug('QQ Official: skipped invisible form placeholder chunk') + return message_id = ( bot_message.get('resp_message_id') @@ -484,7 +576,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter if not message_id or message_id not in self._stream_ctx: # 非流式场景(如群聊不支持流式),累积文本后一次性回复 if chunk_text: - self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text + # Chunks carry the latest full snapshot, not a text delta. + self._fallback_text[message_id] = chunk_text self._fallback_text_ts[message_id] = time.time() if is_final: full_text = self._fallback_text.pop(message_id, '') @@ -497,7 +590,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter # 累积文本 if chunk_text: - ctx['accumulated_text'] += chunk_text + ctx['accumulated_text'] = chunk_text # 未启动会话时,等第一个有内容的 chunk 来建立会话 if not ctx['session_started']: @@ -557,3 +650,489 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter ], ): return super().unregister_listener(event_type, callback) + + # ------------------------------------------------------------------ + # Dify human-input button-interaction support + # ------------------------------------------------------------------ + + _PENDING_FORM_TTL = 1800 # 30 min — matches QQ passive-reply window. + _MAX_REPLIES_PER_ANCHOR = 5 # QQ hard limit per msg_id / event_id. + + def _next_msg_seq(self, anchor: str) -> typing.Optional[int]: + """Return the next msg_seq for an anchor, or ``None`` if the + anchor has already been used 5 times (further sends would be + silently dropped by QQ).""" + if not anchor: + return 1 + used = self._anchor_msg_seq.get(anchor, 0) + if used >= self._MAX_REPLIES_PER_ANCHOR: + return None + self._anchor_msg_seq[anchor] = used + 1 + return used + 1 + + async def _reply_synthetic( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + ) -> None: + """Deliver a reply for a synthetic (button-click-resume) event. + + Synthetic events have ``source_platform_object=None`` and no + fresh inbound msg_id. The previous INTERACTION_CREATE id we + cached in :attr:`_session_event_ids` is a valid passive-reply + anchor (``event_id``) for up to 30 minutes — use it. + """ + if isinstance(message_source, platform_events.GroupMessage): + target_type = 'group' + group = getattr(message_source, 'group', None) or ( + message_source.sender.group if hasattr(message_source.sender, 'group') else None + ) + target_id = str(group.id) if group else None + else: + target_type = 'c2c' + target_id = str(message_source.sender.id) if message_source.sender else None + + if not target_id: + await self.logger.warning('QQ Official: synthetic reply has no target_id; dropping') + return + + session_key = f'{target_type}_{target_id}' + cached = self._session_event_ids.get(session_key) + event_id = cached.get('event_id') if cached else None + if cached and (time.time() - cached.get('posted_at', 0)) > self._PENDING_FORM_TTL: + event_id = None + + if not event_id: + await self.logger.warning( + f'QQ Official: no cached event_id for {session_key}; ' + f'cannot deliver synthetic reply within passive-reply window' + ) + return + + content_list = await QQOfficialMessageConverter.yiri2target(message) + text_parts = [c['content'] for c in content_list if c.get('type') == 'text' and c.get('content')] + if not text_parts: + await self.logger.info('QQ Official: synthetic reply has no text content; skipping') + return + text = '\n\n'.join(text_parts) + + msg_seq = self._next_msg_seq(event_id) + if msg_seq is None: + await self.logger.warning( + f'QQ Official: anchor {event_id!r} exhausted (>5 passive replies); ' + f'cannot deliver synthetic reply for {session_key}' + ) + return + + try: + if target_type == 'c2c': + await self.bot.send_private_text_msg( + user_openid=target_id, + content=text, + event_id=event_id, + msg_seq=msg_seq, + ) + elif target_type == 'group': + await self.bot.send_group_text_msg( + group_openid=target_id, + content=text, + event_id=event_id, + msg_seq=msg_seq, + ) + except Exception: + await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}') + + def _resolve_target_from_source(self, source: QQOfficialEvent) -> typing.Optional[tuple[str, str]]: + """Return ``(target_type, target_id)`` for sending a reply, or + ``None`` if the scene cannot host a markdown+keyboard message.""" + if source is None: + return None + if source.t == 'C2C_MESSAGE_CREATE': + return 'c2c', source.user_openid + if source.t == 'GROUP_AT_MESSAGE_CREATE': + return 'group', source.group_openid + if source.t == 'AT_MESSAGE_CREATE': + return 'channel', source.channel_id + # DIRECT_MESSAGE_CREATE uses the guild DM API which does not accept + # markdown+keyboard at the time of writing — caller falls back to text. + return None + + def _resolve_target_from_event( + self, message_source: platform_events.MessageEvent + ) -> typing.Optional[tuple[str, str]]: + """Resolve ``(target_type, target_id)`` from the public event. + + Prefers the platform-native source when present; falls back to + the synthesized event's sender/group fields so button-click + resume queries can still find a destination. + """ + source = message_source.source_platform_object + if source is not None: + return self._resolve_target_from_source(source) + if isinstance(message_source, platform_events.GroupMessage): + group = getattr(message_source, 'group', None) or ( + message_source.sender.group + if message_source.sender and hasattr(message_source.sender, 'group') + else None + ) + if group and getattr(group, 'id', None): + return 'group', str(group.id) + if isinstance(message_source, platform_events.FriendMessage): + if message_source.sender and getattr(message_source.sender, 'id', None): + return 'c2c', str(message_source.sender.id) + return None + + def _prune_pending_forms(self) -> None: + now = time.time() + stale = [k for k, v in self._pending_forms.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL] + for k in stale: + self._pending_forms.pop(k, None) + stale_e = [ + k for k, v in self._session_event_ids.items() if now - v.get('posted_at', 0) > self._PENDING_FORM_TTL + ] + for k in stale_e: + self._session_event_ids.pop(k, None) + + async def _handle_form_chunk( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + form_data: dict, + ) -> None: + """Send the markdown + keyboard form prompt for a Dify pause. + + Called from ``reply_message_chunk`` when the runner attaches + ``_form_data`` to the final chunk. Replaces what would otherwise + be a plain-text numbered-list fallback. + """ + if self.ap is not None: + self.ap.logger.info( + f'QQ Official _handle_form_chunk entered; ' + f'source_present={message_source.source_platform_object is not None} ' + f'form_actions={len(form_data.get("actions") or [])}' + ) + self._prune_pending_forms() + + source = message_source.source_platform_object + scene_target = self._resolve_target_from_event(message_source) + if scene_target is None: + # No rich-UI fit — fall through to existing text path. + await self.logger.info('QQ Official: form chunk on unsupported scene; falling back to text') + text_parts = [m.text for m in message if type(m) is platform_message.Plain] + fallback_msg = platform_message.MessageChain([platform_message.Plain(text='\n\n'.join(text_parts))]) + try: + await self.reply_message(message_source, fallback_msg) + except Exception: + await self.logger.error(f'QQ Official: form fallback text send failed: {traceback.format_exc()}') + return + + target_type, target_id = scene_target + session_key = f'{target_type}_{target_id}' + + # Cancel any in-flight stream / fallback ctx so plain-text prefix + # doesn't continue alongside the keyboard message. + msg_id = getattr(source, 'd_id', '') or '' if source is not None else '' + if msg_id: + self._stream_ctx.pop(msg_id, None) + self._stream_ctx_ts.pop(msg_id, None) + self._fallback_text.pop(msg_id, None) + self._fallback_text_ts.pop(msg_id, None) + + node_title = form_data.get('node_title') or 'Confirmation needed' + form_content = form_data.get('form_content') or '' + is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only') + parts = [f'### {node_title}'] + plain_parts = [node_title] + if form_content.strip(): + parts.append(form_content.strip()) + plain_parts.append(form_content.strip()) + markdown_content = '\n\n'.join(parts) + plain_content = '\n\n'.join(plain_parts) + + keyboard = build_keyboard_from_select_field(form_data) if is_field_step else None + is_text_field_step = is_field_step and not keyboard.get('content', {}).get('rows') + if is_text_field_step: + keyboard = None + if keyboard is None and not is_text_field_step: + keyboard = build_keyboard_from_form(form_data, buttons_per_row=2) + if keyboard is not None and not keyboard.get('content', {}).get('rows') and not is_text_field_step: + # No actions to render — fall back to plain text. + text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)]) + try: + await self.reply_message(message_source, text_msg) + except Exception: + await self.logger.error(f'QQ Official: empty-keyboard fallback send failed: {traceback.format_exc()}') + return + + # Prefer the inbound msg_id (no quota cost). If the source is a + # synthetic event from a prior click, the cached interaction id + # serves as event_id for up to 30 min. + event_id = None + if not msg_id: + cached = self._session_event_ids.get(session_key) + if cached and (time.time() - cached.get('posted_at', 0)) < self._PENDING_FORM_TTL: + event_id = cached.get('event_id') + + anchor = msg_id or event_id or '' + msg_seq = self._next_msg_seq(anchor) + if msg_seq is None: + await self.logger.warning( + f'QQ Official: anchor {anchor!r} exhausted (>5 passive replies); ' + f'cannot deliver form card for session={session_key}' + ) + return + + try: + await self.bot.send_markdown_keyboard( + target_type=target_type, + target_id=target_id, + markdown_content=markdown_content, + keyboard=keyboard, + msg_id=msg_id if (msg_id and not event_id) else None, + event_id=event_id, + msg_seq=msg_seq, + ) + if self.ap is not None: + self.ap.logger.info( + f'QQ Official: form card sent ' + f'target={target_type}/{target_id} ' + f'msg_id={msg_id!r} event_id={event_id!r} msg_seq={msg_seq}' + ) + except Exception: + if self.ap is not None: + self.ap.logger.error( + f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}' + ) + await self.logger.error( + f'QQ Official: send_markdown_keyboard failed, falling back to text: {traceback.format_exc()}' + ) + text_msg = platform_message.MessageChain([platform_message.Plain(text=plain_content)]) + try: + await self.reply_message(message_source, text_msg) + except Exception: + pass + return + + sender_id = '' + if source is not None: + sender_id = ( + getattr(source, 'user_openid', None) + or getattr(source, 'member_openid', None) + or getattr(source, 'd_author_id', None) + or '' + ) + if not sender_id and message_source.sender is not None: + sender_id = str(getattr(message_source.sender, 'id', '') or '') + self._pending_forms[session_key] = { + 'form_data': form_data, + 'msg_id': msg_id, + 'sender_id': sender_id, + 'target_type': target_type, + 'target_id': target_id, + 'source_event_t': source.t if source is not None else None, + 'posted_at': time.time(), + } + await self.logger.info( + f'QQ Official: form posted session={session_key} actions={len(form_data.get("actions") or [])}' + ) + + async def _handle_interaction_create( + self, + event_data: dict, + ws_event_id: typing.Optional[str] = None, + ) -> None: + """Handle a button-click INTERACTION_CREATE event. + + Two IDs at play (QQ keeps them separate): + ws_event_id top-level payload ``id`` (or webhook ``X-Bot- + Event-Id``). The ONLY value accepted as + ``event_id`` for subsequent passive replies. + d['id'] the interaction id — used for PUT + /interactions/{id} ack. Cannot be reused as + event_id (QQ returns 40034025 if you try). + + Layout (https://bot.q.qq.com/.../msg-btn.html): + chat_type 0 channel / 1 group / 2 c2c + data.resolved.button_data what we set as ``action.data`` + data.resolved.button_id ``id`` field on the button row + """ + import langbot_plugin.api.entities.builtin.provider.session as provider_session + + if self.ap is not None: + self.ap.logger.info( + f'QQ Official _handle_interaction_create entered; ' + f'ws_event_id={ws_event_id!r} ' + f'interaction_id={(event_data.get("id") if isinstance(event_data, dict) else None)!r} ' + f'chat_type={event_data.get("chat_type") if isinstance(event_data, dict) else None}' + ) + + if not isinstance(event_data, dict): + await self.logger.warning(f'QQ Official: INTERACTION_CREATE event_data is not dict: {type(event_data)}') + return + + # ACK uses the interaction id, NOT the ws event id. + interaction_id = event_data.get('id') or '' + if interaction_id: + asyncio.create_task(self.bot.ack_interaction(interaction_id, code=0)) + + resolved = (event_data.get('data') or {}).get('resolved') or {} + action_id = str(resolved.get('button_data') or resolved.get('button_id') or '').strip() + if not action_id: + await self.logger.warning('QQ Official: INTERACTION_CREATE missing button_data/button_id; ignoring') + return + + chat_type = event_data.get('chat_type') + scene_target: typing.Optional[tuple[str, str]] = None + if chat_type == 2 or event_data.get('user_openid'): + scene_target = ('c2c', event_data.get('user_openid') or '') + elif chat_type == 1 or event_data.get('group_openid'): + scene_target = ('group', event_data.get('group_openid') or '') + elif chat_type == 0 or event_data.get('channel_id'): + scene_target = ('channel', event_data.get('channel_id') or '') + + if not scene_target or not scene_target[1]: + await self.logger.warning(f'QQ Official: INTERACTION_CREATE missing scene/target; raw={event_data}') + return + + target_type, target_id = scene_target + session_key = f'{target_type}_{target_id}' + + self._prune_pending_forms() + pending = self._pending_forms.get(session_key) + if not pending: + await self.logger.warning( + f'QQ Official: no pending form for session {session_key}; click ignored (action_id={action_id!r})' + ) + return + + # Cache ws_event_id so a follow-up pause / text reply can use it + # as event_id for passive delivery (30-min window). Falls back to + # the interaction_id only if no ws_event_id was provided (e.g. + # tests / older payload shape) — QQ will reject that value but + # we log so the mismatch is debuggable. + cached_event_id = ws_event_id or interaction_id + if cached_event_id: + self._session_event_ids[session_key] = { + 'event_id': cached_event_id, + 'posted_at': time.time(), + } + # New anchor → fresh 5-reply budget. + self._anchor_msg_seq[cached_event_id] = 0 + if self.ap is not None and not ws_event_id: + self.ap.logger.warning( + 'QQ Official: INTERACTION_CREATE lacked ws_event_id; ' + 'falling back to interaction_id (passive reply may be rejected)' + ) + + form_data: dict = pending.get('form_data') or {} + actions = form_data.get('actions') or [] + select_choice = resolve_select_button_action(form_data, action_id) + if action_id.startswith(QQ_SELECT_ACTION_PREFIX) and select_choice is None: + await self.logger.warning(f'QQ Official: invalid select action_id={action_id!r} for {session_key}') + return + + matched = None + if select_choice is None: + matched = next( + (a for a in actions if str(a.get('id', '')) == action_id), + None, + ) + if matched is None: + await self.logger.warning( + f'QQ Official: action_id={action_id!r} is not present on pending form for {session_key}' + ) + return + self._pending_forms.pop(session_key, None) + action_title = select_choice[1] if select_choice else matched.get('title') or action_id + + initiator_id = str(pending.get('sender_id') or '') + actor_id = str(event_data.get('member_openid') or event_data.get('user_openid') or initiator_id) + + # Build resume payload matching the shape every other adapter uses + # (DingTalk / Lark / Telegram / WeCom). The runner's + # _merge_pending_form_action consumes this verbatim. + if target_type == 'group' or target_type == 'channel': + launcher_type = provider_session.LauncherTypes.GROUP + launcher_id = target_id + else: + launcher_type = provider_session.LauncherTypes.PERSON + launcher_id = target_id + + form_action_data = { + 'form_token': form_data.get('form_token', ''), + 'workflow_run_id': form_data.get('workflow_run_id', ''), + 'action_id': '' if select_choice else action_id, + 'action_title': action_title, + 'node_title': form_data.get('node_title', ''), + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': {'select': select_choice[1]} if select_choice else {}, + } + if select_choice: + form_action_data['_current_input_field'] = select_choice[0] + form_action_data['_input_progress'] = True + + event_label = 'Form Select' if select_choice else 'Form Action' + message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[{event_label}: {action_title}]')]) + + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=actor_id or launcher_id, + member_name='', + permission='MEMBER', + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + special_title='', + ), + message_chain=message_chain, + time=int(time.time()), + source_platform_object=None, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=actor_id or launcher_id, + nickname='', + remark='', + ), + message_chain=message_chain, + time=int(time.time()), + source_platform_object=None, + ) + + if self.ap is None: + await self.logger.error('QQ Official: ap not injected; cannot enqueue button-click query') + return + + bot_uuid = '' + pipeline_uuid = form_data.get('pipeline_uuid') or None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + + try: + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=actor_id or launcher_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, + }, + ) + await self.logger.info( + f'QQ Official: button-click query enqueued action_id={action_id!r} ' + f'session={session_key} actor_id={actor_id}' + ) + except Exception: + await self.logger.error(f'QQ Official: enqueue button-click query failed: {traceback.format_exc()}') diff --git a/src/langbot/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml index d66a770bf..c3f193be9 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.yaml +++ b/src/langbot/pkg/platform/sources/qqofficial.yaml @@ -31,6 +31,18 @@ spec: type: array[string] required: false default: [] + - name: one-click-bind + label: + en_US: One-Click QR Binding + zh_Hans: 一键扫码绑定 + zh_Hant: 一鍵掃碼綁定 + description: + en_US: Scan QR code with mobile QQ to auto-fill AppID and Secret (Token is not used and can be left blank) + zh_Hans: 使用手机 QQ 扫码绑定,自动填写 AppID 和密钥(当前未使用 Token,可留空) + zh_Hant: 使用手機 QQ 掃碼綁定,自動填寫 AppID 和密鑰(目前未使用 Token,可留空) + type: qr-code-login + login_platform: qqofficial + required: false - name: appid label: en_US: App ID @@ -52,8 +64,12 @@ spec: en_US: Token zh_Hans: 令牌 zh_Hant: 令牌 + description: + en_US: Optional. The QR binding cannot return this value; the current adapter implementation does not use it either, so it can be safely left blank. + zh_Hans: 可选。扫码绑定无法获取该字段,当前适配器实现也未使用该字段,留空即可。 + zh_Hant: 可選。掃碼綁定無法取得此欄位,目前介面卡實作亦未使用,留空即可。 type: string - required: true + required: false default: "" - name: enable-webhook label: diff --git a/src/langbot/pkg/platform/sources/telegram.py b/src/langbot/pkg/platform/sources/telegram.py index 1833975b1..e7ad626bb 100644 --- a/src/langbot/pkg/platform/sources/telegram.py +++ b/src/langbot/pkg/platform/sources/telegram.py @@ -1,15 +1,17 @@ from __future__ import annotations -import time import telegram import telegram.ext -from telegram import Update -from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters +from telegram import ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, CallbackQueryHandler, filters import telegramify_markdown import typing import traceback +import json import base64 +import time +import uuid import pydantic from langbot.pkg.utils import httpclient @@ -20,6 +22,61 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger +def _telegram_select_field_options(form_data: dict) -> tuple[str, list[str]]: + """Return the active select field and its option values.""" + field_name = str(form_data.get('_current_input_field') or '').strip() + if not field_name: + return '', [] + field = next( + ( + item + for item in form_data.get('input_defs') or [] + if str(item.get('output_variable_name') or '').strip() == field_name + ), + None, + ) + if not field or str(field.get('type') or '').strip().lower() != 'select': + return '', [] + + source = field.get('option_source') or {} + source_value = source.get('value') if isinstance(source, dict) else None + if isinstance(source_value, list): + return field_name, [str(item) for item in source_value] + if isinstance(source_value, str): + return field_name, [part.strip() for part in source_value.splitlines() if part.strip()] + + options = field.get('options') + if not isinstance(options, list): + return field_name, [] + values = [] + for item in options: + if isinstance(item, dict): + values.append(str(item.get('label') or item.get('value') or '')) + else: + values.append(str(item)) + return field_name, [value for value in values if value] + + +def _telegram_form_action_from_callback(data: dict) -> dict | None: + """Translate compact Telegram callback data into a runner form action.""" + if 'x' not in data: + return { + 'action_id': str(data.get('action_id') or data.get('a') or ''), + 'inputs': {}, + } + try: + option_index = int(data['x']) + except (TypeError, ValueError): + return None + if option_index < 0: + return None + return { + 'action_id': '', + 'inputs': {'select': {'index': option_index}}, + '_input_progress': True, + } + + class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]: @@ -167,7 +224,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): time=event.message.date.timestamp(), source_platform_object=event, ) - elif event.effective_chat.type == 'group' or 'supergroup': + elif event.effective_chat.type in ('group', 'supergroup'): return platform_events.GroupMessage( sender=platform_entities.GroupMember( id=event.effective_chat.id, @@ -189,6 +246,7 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter): class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: telegram.Bot = pydantic.Field(exclude=True) application: telegram.ext.Application = pydantic.Field(exclude=True) + ap: typing.Any = pydantic.Field(exclude=True, default=None) message_converter: TelegramMessageConverter = TelegramMessageConverter() event_converter: TelegramEventConverter = TelegramEventConverter() @@ -204,6 +262,48 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], ] = {} + _FORM_ACTION_CACHE_TTL = 30 * 60 + # callback_data -> (display title, pipeline UUID, expiration time, form group id) + _form_action_titles: typing.Dict[str, tuple[str, str, float, str]] = {} + + def _prune_form_action_titles(self, now: float | None = None) -> None: + now = time.monotonic() if now is None else now + expired = [key for key, (_, _, expires_at, _) in self._form_action_titles.items() if expires_at <= now] + for key in expired: + self._form_action_titles.pop(key, None) + + def _cache_form_action_titles( + self, + mappings: dict[str, str], + pipeline_uuid: str = '', + now: float | None = None, + ) -> None: + now = time.monotonic() if now is None else now + self._prune_form_action_titles(now) + group_id = uuid.uuid4().hex + expires_at = now + self._FORM_ACTION_CACHE_TTL + self._form_action_titles.update( + {callback_data: (title, pipeline_uuid, expires_at, group_id) for callback_data, title in mappings.items()} + ) + + def _take_form_action_context(self, callback_data: str, now: float | None = None) -> tuple[str, str] | None: + """Consume a callback and invalidate every button from the same form.""" + self._prune_form_action_titles(now) + entry = self._form_action_titles.get(callback_data) + if entry is None: + return None + title, pipeline_uuid, _, group_id = entry + group_keys = [ + key for key, (_, _, _, cached_group_id) in self._form_action_titles.items() if cached_group_id == group_id + ] + for key in group_keys: + self._form_action_titles.pop(key, None) + return title, pipeline_uuid + + def _take_form_action_title(self, callback_data: str, now: float | None = None) -> str | None: + context = self._take_form_action_context(callback_data, now) + return context[0] if context else None + def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): if update.message.from_user.is_bot: @@ -224,6 +324,117 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): telegram_callback, ) ) + + async def callback_query_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + try: + data = json.loads(query.data) + if data.get('form_action') or data.get('f'): + import langbot_plugin.api.entities.builtin.provider.session as provider_session + + # workflow_run_id is not in the callback payload (too large + # for Telegram's 64-byte limit). Only w_suffix is sent; + # the runner resolves the full run id from _PENDING_FORMS. + w_suffix = data.get('w', '') + session_key = data.get('session_key') or data.get('s', '') + callback_action = _telegram_form_action_from_callback(data) + action_context = self._take_form_action_context(query.data) if callback_action is not None else None + if callback_action is None or action_context is None: + await self.logger.warning(f'Invalid or stale Telegram form callback: {query.data!r}') + return + action_title, pipeline_uuid = action_context + # Show selected action feedback by editing the original message + try: + original_text = query.message.text or '' + selected_text = f'{original_text}\n\n✅ {action_title}' + await query.edit_message_text(text=selected_text, reply_markup=None) + except Exception: + # If edit fails (e.g. message too long), just pass + pass + + if session_key.startswith('group_') or session_key.startswith('g:'): + launcher_type = provider_session.LauncherTypes.GROUP + launcher_id = ( + session_key.split(':', 1)[1] + if session_key.startswith('g:') + else session_key[len('group_') :] + ) + else: + launcher_type = provider_session.LauncherTypes.PERSON + launcher_id = ( + session_key.split(':', 1)[1] + if session_key.startswith('p:') + else session_key[len('person_') :] + ) + + user_id = str(query.from_user.id) + + # Find bot_uuid and pipeline_uuid + bot_uuid = '' + for b in self.ap.platform_mgr.bots: + if b.adapter is self: + bot_uuid = b.bot_entity.uuid + pipeline_uuid = pipeline_uuid or b.bot_entity.use_pipeline_uuid + break + + form_action_data = { + # workflow_run_id is intentionally omitted; the runner + # resolves it from w_suffix via _PENDING_FORMS. + 'w_suffix': w_suffix, + 'user': f'{launcher_type.value}_{launcher_id}', + **callback_action, + } + + event_label = 'Form Select' if callback_action.get('_input_progress') else 'Form Action' + message_chain = platform_message.MessageChain( + [platform_message.Plain(text=f'[{event_label}: {action_title}]')] + ) + + if launcher_type == provider_session.LauncherTypes.GROUP: + synthetic_event = platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=user_id, + member_name='', + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=launcher_id, + name='', + permission=platform_entities.Permission.Member, + ), + ), + message_chain=message_chain, + source_platform_object=update, + ) + else: + synthetic_event = platform_events.FriendMessage( + sender=platform_entities.Friend( + id=user_id, + nickname='', + remark='', + ), + message_chain=message_chain, + source_platform_object=update, + ) + + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=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, + }, + ) + except Exception: + await self.logger.error(f'Error in telegram callback query: {traceback.format_exc()}') + + application.add_handler(CallbackQueryHandler(callback_query_handler)) super().__init__( config=config, logger=logger, @@ -314,23 +525,34 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): args['parse_mode'] = 'MarkdownV2' return args + async def _delete_group_stream_message(self, chat_mode: str, chat_id: int, stream_id: int | None): + if chat_mode != 'group' or stream_id is None: + return + try: + await self.bot.delete_message(chat_id=chat_id, message_id=stream_id) + except telegram.error.TelegramError: + pass + + @staticmethod + def _is_form_placeholder_chunk(text: str) -> bool: + """Return True for invisible placeholder chunks used to carry forms.""" + + if not text: + return True + + cleaned = text.replace('\u200b', '').replace('\u200c', '').replace('\u200d', '').replace('\ufeff', '').strip() + return cleaned == '' + async def create_message_card(self, message_id, event): assert isinstance(event.source_platform_object, Update) update = event.source_platform_object chat_id = update.effective_chat.id - chat_type = update.effective_chat.type - message_thread_id = update.message.message_thread_id + effective_message = update.effective_message + message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None - if chat_type == 'private': - draft_id = int(time.time() * 1000) - self.msg_stream_id[message_id] = ('private', draft_id) - - args = self._build_message_args(chat_id, 'Thinking...', message_thread_id, draft_id=draft_id) - await self.bot.send_message_draft(**args) - else: - args = self._build_message_args(chat_id, 'Thinking...', message_thread_id) - send_msg = await self.bot.send_message(**args) - self.msg_stream_id[message_id] = ('group', send_msg.message_id) + args = self._build_message_args(chat_id, 'Thinking...', message_thread_id) + send_msg = await self.bot.send_message(**args) + self.msg_stream_id[message_id] = ('message', send_msg.message_id, False) return True @@ -347,12 +569,15 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): assert isinstance(message_source.source_platform_object, Update) update = message_source.source_platform_object chat_id = update.effective_chat.id - message_thread_id = update.message.message_thread_id + effective_message = update.effective_message + message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None if message_id not in self.msg_stream_id: return - chat_mode, draft_id = self.msg_stream_id[message_id] + stream_state = self.msg_stream_id[message_id] + chat_mode, stream_id = stream_state[:2] + has_visible_content = len(stream_state) > 2 and stream_state[2] components = await TelegramMessageConverter.yiri2target(message, self.bot) if not components or components[0]['type'] != 'text': @@ -361,17 +586,68 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return content = components[0]['text'] + form_data = getattr(bot_message, '_form_data', None) + + if form_data and is_final: + if not has_visible_content: + await self._send_form_action_buttons(message_source, form_data, edit_message_id=stream_id) + else: + await self._send_form_action_buttons(message_source, form_data) + self.msg_stream_id.pop(message_id, None) + return + + if self._is_form_placeholder_chunk(content): + if is_final and bot_message.tool_calls is None and not has_visible_content: + await self._delete_group_stream_message(chat_mode, chat_id, stream_id) + self.msg_stream_id.pop(message_id, None) + return if chat_mode == 'private': - args = self._build_message_args(chat_id, content, message_thread_id, draft_id=draft_id) - await self.bot.send_message_draft(**args) + # Streaming via draft (ephemeral preview in the chat input area) + if (msg_seq - 1) % 8 == 0 or is_final: + args = self._build_message_args(chat_id, content, message_thread_id, draft_id=stream_id) + try: + await self.bot.send_message_draft(**args) + except telegram.error.BadRequest as exc: + if 'Message_too_long' in str(exc): + args['text'] = content[:4000] + '\n\n… (truncated)' + try: + await self.bot.send_message_draft(**args) + except telegram.error.RetryAfter: + pass + else: + pass # Ignore other draft errors (cosmetic) + self.msg_stream_id[message_id] = (chat_mode, stream_id, True) if is_final and bot_message.tool_calls is None: - del args['draft_id'] - await self.bot.send_message(**args) + # Finalise: send the real message, discard the draft + args = self._build_message_args(chat_id, content, message_thread_id) + try: + await self.bot.send_message(**args) + except telegram.error.BadRequest as exc: + if 'Message_too_long' in str(exc): + args['text'] = content[:4000] + '\n\n… (truncated)' + await self.bot.send_message(**args) + else: + raise self.msg_stream_id.pop(message_id) else: - stream_id = draft_id - if (msg_seq - 1) % 8 == 0 or is_final: + # Streaming via edit_message_text (persistent message) + if stream_id is None: + args = self._build_message_args(chat_id, content, message_thread_id) + try: + send_msg = await self.bot.send_message(**args) + except telegram.error.BadRequest as exc: + if 'Message_too_long' in str(exc): + args['text'] = self._process_markdown(content[:4000] + '\n\n鈥?(truncated)') + send_msg = await self.bot.send_message(**args) + else: + raise + self.msg_stream_id[message_id] = (chat_mode, send_msg.message_id, True) + if is_final and bot_message.tool_calls is None: + self.msg_stream_id.pop(message_id, None) + return + + if not has_visible_content or (msg_seq - 1) % 8 == 0 or is_final: args = { 'message_id': stream_id, 'chat_id': chat_id, @@ -379,11 +655,137 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): } if self.config.get('markdown_card', False): args['parse_mode'] = 'MarkdownV2' - await self.bot.edit_message_text(**args) + try: + await self.bot.edit_message_text(**args) + except telegram.error.BadRequest as exc: + if 'Message_too_long' in str(exc): + args['text'] = self._process_markdown(content[:4000] + '\n\n… (truncated)') + await self.bot.edit_message_text(**args) + else: + raise + self.msg_stream_id[message_id] = (chat_mode, stream_id, True) if is_final and bot_message.tool_calls is None: self.msg_stream_id.pop(message_id) + async def _send_form_action_buttons( + self, + message_source: platform_events.MessageEvent, + form_data: dict, + edit_message_id: int | None = None, + ): + """Send inline keyboard buttons for Dify form fields or actions.""" + actions = form_data.get('actions', []) + node_title = form_data.get('node_title', '') + form_content = form_data.get('form_content', '') + workflow_run_id = form_data.get('workflow_run_id', '') + # Telegram callback_data is capped at 64 bytes, so we identify the + # paused workflow by the last 8 chars of workflow_run_id (unique + # within a session with overwhelming probability). + w_suffix = workflow_run_id[-8:] if workflow_run_id else '' + + if isinstance(message_source, platform_events.GroupMessage): + session_key = f'g:{message_source.group.id}' + else: + session_key = f'p:{message_source.sender.id}' + + current_field = str(form_data.get('_current_input_field') or '').strip() + is_field_step = bool(current_field) and not form_data.get('_action_select_only') + select_field, select_options = _telegram_select_field_options(form_data) + is_select_field = bool(select_field and select_options) + if is_select_field: + choices = [(option, {'x': idx}) for idx, option in enumerate(select_options)] + elif is_field_step: + choices = [] + else: + choices = [(action.get('title', action.get('id', '')), {'a': action.get('id', '')}) for action in actions] + + keyboard = [] + pending_title_mappings: dict[str, str] = {} + oversized = False + buttons_per_row = 2 if is_select_field else 1 + current_row = [] + for title, choice_data in choices: + callback_payload = {'f': 1, **choice_data, 's': session_key} + if w_suffix: + callback_payload['w'] = w_suffix + callback_data = json.dumps(callback_payload, separators=(',', ':')) + if len(callback_data.encode('utf-8')) > 64: + oversized = True + break + pending_title_mappings[callback_data] = str(title) + current_row.append(InlineKeyboardButton(str(title), callback_data=callback_data)) + if len(current_row) == buttons_per_row: + keyboard.append(current_row) + current_row = [] + if current_row and not oversized: + keyboard.append(current_row) + + update = message_source.source_platform_object + chat_id = update.effective_chat.id + effective_message = update.effective_message + message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None + + heading = f'[{node_title}]' + text_lines = [heading] + if form_content: + text_lines.append(form_content) + + if oversized: + # callback_data exceeds Telegram's 64-byte limit — fall back to + # a plain-text numbered list so the user can reply by number. + for idx, (title, _) in enumerate(choices, start=1): + text_lines.append(f' {idx}. {title}') + args = { + 'chat_id': chat_id, + 'text': '\n\n'.join(text_lines), + } + elif keyboard: + self._cache_form_action_titles( + pending_title_mappings, + str(form_data.get('pipeline_uuid') or ''), + ) + reply_markup = InlineKeyboardMarkup(keyboard) + args = { + 'chat_id': chat_id, + 'text': '\n\n'.join(text_lines), + 'reply_markup': reply_markup, + } + elif is_field_step: + args = { + 'chat_id': chat_id, + 'text': '\n\n'.join(text_lines), + # Telegram privacy-mode bots receive replies to ForceReply + # prompts even when they cannot read ordinary group messages. + 'reply_markup': ForceReply( + selective=False, + input_field_placeholder=current_field, + ), + } + else: + args = { + 'chat_id': chat_id, + 'text': '\n\n'.join(text_lines), + } + + if message_thread_id: + args['message_thread_id'] = message_thread_id + + if edit_message_id is not None: + edit_args = { + 'chat_id': chat_id, + 'message_id': edit_message_id, + 'text': args['text'], + } + edit_args['reply_markup'] = args.get('reply_markup') + try: + await self.bot.edit_message_text(**edit_args) + return + except telegram.error.TelegramError: + await self._delete_group_stream_message('group', chat_id, edit_message_id) + + await self.bot.send_message(**args) + def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None: if not isinstance(event.source_platform_object, Update): return None diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index dd726544d..d0febad9e 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -11,7 +11,13 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities from ..logger import EventLogger from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent -from langbot.libs.wecom_ai_bot_api.api import WecomBotClient +from langbot.libs.wecom_ai_bot_api.api import ( + WecomBotClient, + extract_template_card_action, + extract_template_card_event_payload, + extract_template_card_selections, + parse_select_button_action, +) from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient @@ -296,6 +302,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): listeners: dict = {} _stream_to_monitoring_msg: dict = {} # Maps stream_id to (monitoring_message_id, timestamp) _STREAM_MAPPING_TTL = 600 # 10 minutes + ap: typing.Any = None def __init__(self, config: dict, logger: EventLogger): enable_webhook = config.get('enable-webhook', False) @@ -336,6 +343,25 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): _stream_to_monitoring_msg={}, ) + # Both WecomBotClient (webhook) and WecomBotWsClient (ws long-conn) + # expose ``set_card_action_callback``. Wire the click handler so + # Dify human-input button taps resume the workflow on either mode. + if hasattr(self.bot, 'set_card_action_callback'): + self.bot.set_card_action_callback(self._on_card_action) + + # Hand the client a `source` block so every interactive + # template_card it emits carries the LangBot logo + name at the + # top — the WeCom analogue of DingTalk's Avatar header. + # Always on; icon_url accepts plain HTTPS URLs (no upload needed). + if hasattr(self.bot, 'set_card_source'): + self.bot.set_card_source( + { + 'icon_url': 'https://raw.githubusercontent.com/RockChinQ/LangBot/master/res/logo-blue.png', + 'desc': 'LangBot', + 'desc_color': 0, + } + ) + async def reply_message( self, message_source: platform_events.MessageEvent, @@ -345,15 +371,37 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): content = await self.message_converter.yiri2target(message) _ws_mode = not self.config.get('enable-webhook', False) + event = message_source.source_platform_object + # Synthetic events (button-click resume queries) have no inbound + # platform object. Fall back to a proactive send so error + # messages and one-shot replies still reach the user. + if event is None: + if _ws_mode: + if isinstance(message_source, platform_events.GroupMessage): + chat_id = str(message_source.group.id) + else: + chat_id = str(message_source.sender.id) + try: + await self.bot.send_message(chat_id, content) + except Exception: + await self.logger.error( + f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}' + ) + else: + await self.logger.warning( + 'WeComBot webhook mode cannot reply to a synthetic event ' + '(no req_id and no proactive-send credentials); dropping.' + ) + return + if _ws_mode: - event = message_source.source_platform_object - req_id = event.get('req_id', '') + req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '') if req_id: await self.bot.reply_text(req_id, content) else: await self.bot.set_message(event.message_id, content) else: - await self.bot.set_message(message_source.source_platform_object.message_id, content) + await self.bot.set_message(event.message_id, content) async def reply_message_chunk( self, @@ -364,9 +412,56 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): is_final: bool = False, ): content = await self.message_converter.yiri2target(message) - msg_id = message_source.source_platform_object.message_id _ws_mode = not self.config.get('enable-webhook', False) + # Synthetic events (e.g. button-click triggered form resume) have + # no inbound platform message — no msg_id, no req_id, no stream + # session. The output must go via the proactive-send path instead + # of the stream/reply path. + spo = message_source.source_platform_object + if spo is None: + return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode) + + msg_id = spo.message_id + + # Dify human-input pause: when the runner attaches `_form_data` to + # the final chunk, hand the button_interaction card off to the + # underlying client. In webhook mode the card is queued for the + # next followup poll; in ws mode it's sent as a reply frame + # immediately. Falls back to plain text when the bot has no active + # stream session for this msg_id (rare). + form_data = getattr(bot_message, '_form_data', None) + if form_data and is_final: + if hasattr(self.bot, 'push_form_pause'): + ok, stream_id, task_id = await self.bot.push_form_pause(msg_id, form_data) + if ok: + await self.logger.info( + f'WeComBot: pending button_interaction registered ' + f'stream_id={stream_id} task_id={task_id} ws_mode={_ws_mode}' + ) + return {'stream': True, 'form': True, 'task_id': task_id} + await self.logger.warning( + 'WeComBot: cannot register form pause (no active stream session); falling back to plain text' + ) + try: + from langbot.pkg.provider.runners.difysvapi import _format_human_input_text + + fallback = _format_human_input_text( + form_data.get('node_title', ''), + form_data.get('form_content', ''), + form_data.get('actions', []) or [], + ) + except Exception: + fallback = content or '(人工输入)' + if _ws_mode: + event = message_source.source_platform_object + req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '') + if req_id: + await self.bot.reply_text(req_id, fallback) + else: + await self.bot.set_message(msg_id, fallback) + return {'stream': False, 'form': True, 'fallback': True} + if _ws_mode: success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final) if not success and is_final: @@ -385,6 +480,142 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): """Whether streaming output is enabled for this bot instance.""" return self.config.get('enable-stream-reply', True) + async def _handle_synthetic_chunk( + self, + message_source: platform_events.MessageEvent, + bot_message, + content: str, + is_final: bool, + ws_mode: bool, + ) -> dict: + """Handle reply_message_chunk for synthetic events (button clicks). + + Synthetic events have no inbound message → no msg_id, no req_id, + no stream session. We can't do incremental streaming, so we + buffer chunks per-conversation and flush on ``is_final`` via the + proactive send path. + + Buffer keyed by ``(launcher_type, launcher_id)`` from the + synthetic event itself. Only ws mode has a usable proactive-send + path right now (``ws_client.send_message`` / + ``ws_client.send_template_card``); webhook mode requires a + corpid/secret we don't have, so it logs and drops. + """ + if isinstance(message_source, platform_events.GroupMessage): + chat_id = str(message_source.group.id) + else: + chat_id = str(message_source.sender.id) + + form_data = getattr(bot_message, '_form_data', None) + + # Buffer streaming content until is_final. + buf_key = chat_id + if not hasattr(self, '_synthetic_buffers'): + # Attribute-not-declared trick: pydantic forbids dynamic attrs + # on the model, but plain instance dicts via object.__setattr__ + # do work. Lazy-create on first call. + object.__setattr__(self, '_synthetic_buffers', {}) + buffers: dict[str, str] = self._synthetic_buffers + if content and not form_data: + previous = buffers.get(buf_key, '') + if previous and content.startswith(previous): + buffers[buf_key] = content + elif previous and previous.endswith(content): + buffers[buf_key] = previous + else: + buffers[buf_key] = previous + content + + if not is_final: + return {'stream': True, 'synthetic': True, 'buffered': True} + + final_content = buffers.pop(buf_key, '') + if content: + if final_content and content.startswith(final_content): + final_content = content + elif final_content and final_content.endswith(content): + pass + else: + final_content = final_content + content + + if not ws_mode: + await self.logger.warning( + 'WeComBot webhook mode cannot proactively push synthetic-event ' + 'output (no corpid/secret); the resume reply is dropped. ' + f'content_len={len(final_content)} form_data_present={form_data is not None}' + ) + return {'stream': False, 'synthetic': True, 'dropped': True} + + # ws mode: proactive send. + try: + if form_data: + # Determine user_id / chat_id for the routing context of any + # subsequent click on this card. + if isinstance(message_source, platform_events.GroupMessage): + routing_chat_id = str(message_source.group.id) + routing_user_id = str(message_source.sender.id) + else: + routing_chat_id = '' + routing_user_id = str(message_source.sender.id) + payload = self._build_button_interaction_payload_from_form( + form_data, + user_id=routing_user_id, + chat_id=routing_chat_id, + ) + await self.bot.send_template_card(chat_id, payload) + await self.logger.info( + f'WeComBot ws: proactively sent template_card for synthetic event ' + f'chat_id={chat_id} form_token={form_data.get("form_token")!r} ' + f'workflow_run_id={form_data.get("workflow_run_id")!r}' + ) + elif final_content: + await self.bot.send_message(chat_id, final_content) + await self.logger.info( + f'WeComBot ws: proactively sent text for synthetic event chat_id={chat_id} len={len(final_content)}' + ) + except Exception: + await self.logger.error(f'WeComBot: synthetic event proactive send failed: {traceback.format_exc()}') + return {'stream': False, 'synthetic': True, 'error': True} + + return {'stream': True, 'synthetic': True} + + def _build_button_interaction_payload_from_form( + self, form_data: dict, *, user_id: str = '', chat_id: str = '' + ) -> dict: + """Build a button_interaction payload + track task_id for click resolution. + + Unlike the inbound-event path (where push_form_pause registers the + task_id with the active stream session), proactive sends still + need the task_id registered so button clicks find pending_form. + For ws mode we stash it directly on the ws_client's pending dict. + """ + from langbot.libs.wecom_ai_bot_api.api import build_human_input_template_card_payload + import secrets as _secrets + + task_id = f'dify-{_secrets.token_hex(12)}' + source = getattr(self.bot, 'card_source', None) + payload = build_human_input_template_card_payload( + form_data, + task_id, + source=source, + select_as_buttons=not self.config.get('enable-webhook', False), + ) + + # Register task_id → form_data so the click callback can find it. + # user_id / chat_id are required so _on_card_action can route the + # resulting synthetic query back to the right user. msg_id / req_id + # / stream_id are intentionally empty — synthetic cards have no + # inbound message to anchor on. + if hasattr(self.bot, '_pending_forms_by_task'): + self.bot._pending_forms_by_task[task_id] = { + 'form_data': form_data, + 'msg_id': '', + 'user_id': user_id, + 'chat_id': chat_id, + 'stream_id': '', + 'req_id': '', + } + return payload + async def send_message(self, target_type, target_id, message): _ws_mode = not self.config.get('enable-webhook', False) if _ws_mode: @@ -531,3 +762,191 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): async def is_muted(self, group_id: int) -> bool: pass + + # ------------------------------------------------------------------ + # Dify human-input button-interaction click handling + # ------------------------------------------------------------------ + + async def _on_card_action(self, session, action_id: str, task_id: str, raw_event: dict) -> None: + """Translate a button click on a button_interaction card into a + synthetic ``_dify_form_action`` query enqueued on the pool. + + Pattern mirrors DingTalk / Lark / Telegram so the runner's + ``_merge_pending_form_action`` path resumes the workflow. + """ + import langbot_plugin.api.entities.builtin.provider.session as provider_session + + form = session.pending_form or {} + await self.logger.info( + f'WeComBot _on_card_action: task_id={task_id} action_id={action_id!r} ' + f'form_token={form.get("form_token")!r} workflow_run_id={form.get("workflow_run_id")!r} ' + f'session.user_id={session.user_id!r} session.chat_id={session.chat_id!r}' + ) + + actions = form.get('actions') or [] + tce = extract_template_card_event_payload(raw_event) if isinstance(raw_event, dict) else {} + _, _, card_type = extract_template_card_action(tce) + selections = extract_template_card_selections(tce, form) + if not selections: + selections = parse_select_button_action(action_id, form) + await self.logger.info( + f'WeComBot template_card selections: task_id={task_id} card_type={card_type} selections={selections}' + ) + if card_type == 'multiple_interaction' and not selections: + await self.logger.warning( + f'WeComBot: multiple_interaction callback has no parseable selections; raw={str(tce)[:1000]}' + ) + return + is_select_submit = card_type == 'multiple_interaction' or bool(selections) + + clean_action_id = '' if is_select_submit else (action_id or '').strip() + action_title = clean_action_id + for a in actions: + if str(a.get('id', '')) == clean_action_id: + action_title = a.get('title') or clean_action_id + break + + inputs = dict(form.get('inputs') or {}) + inputs.update(selections) + + def _missing_fields_after_select() -> list[str]: + missing: list[str] = [] + for field in form.get('input_defs') or form.get('all_input_defs') or []: + field_name = str(field.get('output_variable_name') or '').strip() + if not field_name: + continue + if inputs.get(field_name) in (None, '', []): + missing.append(field_name) + return missing + + input_progress = False + if is_select_submit: + missing_fields = _missing_fields_after_select() + if not missing_fields and len(actions) == 1: + action = actions[0] + clean_action_id = str(action.get('id') or '').strip() + action_title = action.get('title') or clean_action_id + elif not missing_fields and len(actions) > 1: + if not self.config.get('enable-webhook', False): + action_form_data = { + 'form_content': form.get('raw_form_content') or form.get('form_content') or '', + 'raw_form_content': form.get('raw_form_content') or form.get('form_content') or '', + 'input_defs': [], + 'all_input_defs': form.get('all_input_defs') or form.get('input_defs') or [], + 'inputs': inputs, + 'actions': actions, + 'node_title': form.get('node_title', ''), + 'workflow_run_id': form.get('workflow_run_id', ''), + 'form_token': form.get('form_token', ''), + 'pipeline_uuid': form.get('pipeline_uuid', ''), + '_action_select_only': True, + } + target_chat_id = session.chat_id or session.user_id or '' + try: + payload = self._build_button_interaction_payload_from_form( + action_form_data, + user_id=session.user_id or '', + chat_id=session.chat_id or '', + ) + await self.bot.send_template_card(target_chat_id, payload) + await self.logger.info( + f'WeComBot: sent action-select button card after select submit ' + f'task_id={task_id} action_count={len(actions)}' + ) + except Exception: + await self.logger.error( + f'WeComBot: failed to send action-select button card: {traceback.format_exc()}' + ) + return + await self.logger.warning( + 'WeComBot webhook mode cannot proactively send action-select button card after select submit' + ) + return + else: + input_progress = True + action_title = 'Submit' + + launcher_id = session.user_id or session.chat_id or '' + sender_user_id = session.user_id or launcher_id + # WeCom AI bot has both single-chat and group-chat; chat_id present + # indicates group context. + if session.chat_id: + launcher_type = provider_session.LauncherTypes.GROUP + launcher_id = session.chat_id + else: + launcher_type = provider_session.LauncherTypes.PERSON + launcher_id = session.user_id or '' + + form_action_data = { + 'form_token': form.get('form_token', ''), + 'workflow_run_id': form.get('workflow_run_id', ''), + 'action_id': clean_action_id, + 'action_title': action_title, + 'node_title': form.get('node_title', ''), + 'user': f'{launcher_type.value}_{launcher_id}', + 'inputs': inputs, + } + if input_progress: + form_action_data['_input_progress'] = True + + 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(time.time()), + 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(time.time()), + source_platform_object=None, + ) + + if self.ap is None: + await self.logger.error('WeComBot: ap not injected; cannot enqueue button-click query') + return + + bot_uuid = '' + pipeline_uuid = form.get('pipeline_uuid') or None + for bot in self.ap.platform_mgr.bots: + if bot.adapter is self: + bot_uuid = bot.bot_entity.uuid + pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid + break + + try: + await self.ap.query_pool.add_query( + bot_uuid=bot_uuid, + launcher_type=launcher_type, + launcher_id=launcher_id, + sender_id=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, + }, + ) + await self.logger.info(f'WeComBot: button-click query enqueued action_id={clean_action_id!r}') + except Exception: + await self.logger.error(f'WeComBot: enqueue button-click query failed: {traceback.format_exc()}') diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index 039bf33ad..7566d20aa 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -2,20 +2,697 @@ from __future__ import annotations import typing import json +import time import uuid import base64 import mimetypes +import os +import re +from collections import OrderedDict +from urllib.parse import urlparse from langbot.pkg.provider import runner from langbot.pkg.core import app import langbot_plugin.api.entities.builtin.provider.message as provider_message +import langbot_plugin.api.entities.builtin.platform.message as platform_message from langbot.pkg.utils import image import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query from langbot.libs.dify_service_api.v1 import client, errors import httpx +# Module-level store for paused-workflow form state. The key isolates the bot, +# pipeline, adapter, and launcher; each value holds an insertion-ordered map of +# form_token -> form_data so one conversation can pause multiple workflows. +PendingFormKey = tuple[str, str, str, str, str] +_PENDING_FORMS: dict[PendingFormKey, 'OrderedDict[str, dict[str, typing.Any]]'] = {} +_PENDING_FORM_DEFAULT_TTL = 30 * 60 # 30 minutes safety cap +_STREAM_FORM_PLACEHOLDER = '\u200b' + + +def _merge_stream_text(accumulated: str, incoming: typing.Any) -> str: + """Merge either a delta chunk or a cumulative stream snapshot.""" + incoming_text = '' if incoming is None else str(incoming) + if not incoming_text: + return accumulated + if not accumulated: + return incoming_text + if len(incoming_text) > len(accumulated) and incoming_text.startswith(accumulated): + return incoming_text + return accumulated + incoming_text + + +def _dify_user_from_query(query: pipeline_query.Query) -> str: + return f'{query.session.launcher_type.value}_{query.session.launcher_id}' + + +def _session_key_from_query(query: pipeline_query.Query) -> PendingFormKey: + """Build a process-local pending-form key isolated by bot and pipeline.""" + adapter = getattr(query, 'adapter', None) + adapter_type = f'{type(adapter).__module__}.{type(adapter).__qualname__}' + return ( + str(getattr(query, 'bot_uuid', '') or ''), + str(getattr(query, 'pipeline_uuid', '') or ''), + adapter_type, + str(query.session.launcher_type.value), + str(query.session.launcher_id), + ) + + +def _prune_pending_forms(now: float | None = None) -> None: + if now is None: + now = time.time() + for session_key in list(_PENDING_FORMS.keys()): + forms = _PENDING_FORMS[session_key] + expired_tokens = [token for token, data in forms.items() if data.get('_expires_at', 0) <= now] + for token in expired_tokens: + forms.pop(token, None) + if not forms: + _PENDING_FORMS.pop(session_key, None) + + +def _set_pending_form(session_key: PendingFormKey, form_data: dict[str, typing.Any]) -> None: + _prune_pending_forms() + if isinstance(session_key, tuple) and len(session_key) > 1: + form_data['pipeline_uuid'] = session_key[1] + stored = dict(form_data) + expiration_time = stored.get('expiration_time') + try: + expiration_ts = float(expiration_time) if expiration_time is not None else 0.0 + except (TypeError, ValueError): + expiration_ts = 0.0 + stored['_expires_at'] = expiration_ts or (time.time() + _PENDING_FORM_DEFAULT_TTL) + form_token = str(stored.get('form_token') or '') + forms = _PENDING_FORMS.setdefault(session_key, OrderedDict()) + # Re-insert at the end so this becomes the "latest" entry + forms.pop(form_token, None) + forms[form_token] = stored + + +def _get_pending_form_by_token(session_key: PendingFormKey, form_token: str) -> dict[str, typing.Any] | None: + _prune_pending_forms() + forms = _PENDING_FORMS.get(session_key) + if not forms or not form_token: + return None + return forms.get(form_token) + + +def _get_pending_form_by_w_suffix(session_key: PendingFormKey, w_suffix: str) -> dict[str, typing.Any] | None: + """Look up a pending form whose workflow_run_id ends with the given suffix. + + Used by adapters (e.g. Telegram) whose callback payload is too small to + carry the full form_token / workflow_run_id. + """ + _prune_pending_forms() + forms = _PENDING_FORMS.get(session_key) + if not forms or not w_suffix: + return None + for token in reversed(forms): + form = forms[token] + if str(form.get('workflow_run_id', '')).endswith(w_suffix): + return form + return None + + +def _get_latest_pending_form(session_key: PendingFormKey) -> dict[str, typing.Any] | None: + _prune_pending_forms() + forms = _PENDING_FORMS.get(session_key) + if not forms: + return None + return forms[next(reversed(forms))] + + +def _iter_pending_forms(session_key: PendingFormKey) -> typing.Iterator[dict[str, typing.Any]]: + """Iterate pending forms for a session, newest-first.""" + _prune_pending_forms() + forms = _PENDING_FORMS.get(session_key) + if not forms: + return + for token in reversed(list(forms.keys())): + yield forms[token] + + +def _clear_pending_form(session_key: PendingFormKey, form_token: str | None = None) -> None: + """Clear one specific pending form (by token) or all forms for the session.""" + forms = _PENDING_FORMS.get(session_key) + if not forms: + return + if form_token is None: + _PENDING_FORMS.pop(session_key, None) + return + forms.pop(form_token, None) + if not forms: + _PENDING_FORMS.pop(session_key, None) + + +def _format_human_input_text( + node_title: str, + form_content: str, + actions: list[dict[str, typing.Any]], + input_defs: list[dict[str, typing.Any]] | None = None, +) -> str: + """Render a paused-workflow human-input prompt as plain text. + + Used by adapters without rich UI (no buttons/cards) so users can reply + with the option number or the option title to resume the workflow. + """ + input_defs = input_defs or [] + form_content = _strip_form_field_placeholders(form_content, input_defs) + lines: list[str] = [f'[Human Input Required] {node_title or ""}'.rstrip()] + if form_content: + lines.append('') + lines.append(form_content) + field_help = _format_human_input_fields_text(input_defs) + if field_help: + lines.append('') + lines.append(field_help) + if actions: + lines.append('') + if input_defs: + lines.append('Reply with action plus field values to continue:') + lines.append(' action: ') + else: + lines.append('Reply with the number or title to continue:') + for idx, action in enumerate(actions, start=1): + title = action.get('title') or action.get('id') or '' + lines.append(f' {idx}. {title}') + return '\n'.join(lines) + + +def _normalize_form_input_defs(raw_inputs: typing.Any) -> list[dict[str, typing.Any]]: + if not isinstance(raw_inputs, list): + return [] + normalized: list[dict[str, typing.Any]] = [] + for item in raw_inputs: + if not isinstance(item, dict): + continue + field = dict(item) + name = str( + field.get('output_variable_name') or field.get('variable') or field.get('name') or field.get('id') or '' + ).strip() + if not name: + continue + field['output_variable_name'] = name + normalized.append(field) + return normalized + + +def _field_name(field: dict[str, typing.Any]) -> str: + return str(field.get('output_variable_name') or '').strip() + + +def _field_type(field: dict[str, typing.Any]) -> str: + return str(field.get('type') or 'text').strip().lower() + + +def _select_options(field: dict[str, typing.Any]) -> list[str]: + source = field.get('option_source') or {} + value = source.get('value') if isinstance(source, dict) else None + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return [part.strip() for part in value.splitlines() if part.strip()] + options = field.get('options') + if isinstance(options, list): + result: list[str] = [] + for item in options: + if isinstance(item, dict): + result.append(str(item.get('label') or item.get('value') or '')) + else: + result.append(str(item)) + return [item for item in result if item] + return [] + + +def _normalize_select_value( + value: typing.Any, + options: list[str], + *, + allow_legacy_zero_based_index: bool = False, + allow_one_based_index: bool = False, +) -> tuple[bool, typing.Any]: + """Resolve a select input without confusing numeric values with indexes.""" + if not options: + return True, value + + parsed = value + if isinstance(value, str): + stripped = value.strip() + try: + json_value = json.loads(stripped) + except json.JSONDecodeError: + json_value = None + if isinstance(json_value, dict): + parsed = json_value + else: + parsed = stripped + + if isinstance(parsed, dict): + explicit_value = parsed.get('value') + if explicit_value not in (None, ''): + candidate = str(explicit_value).strip() + match = next((option for option in options if option.casefold() == candidate.casefold()), None) + return (True, match) if match is not None else (False, value) + explicit_index = parsed.get('index') + if isinstance(explicit_index, int) and not isinstance(explicit_index, bool): + if 0 <= explicit_index < len(options): + return True, options[explicit_index] + return False, value + return False, value + + candidate = str(parsed).strip() + match = next((option for option in options if option.casefold() == candidate.casefold()), None) + if match is not None: + return True, match + + if candidate.isdigit(): + index = int(candidate) + if allow_one_based_index and 1 <= index <= len(options): + return True, options[index - 1] + if allow_legacy_zero_based_index and 0 <= index < len(options): + return True, options[index] + + return False, value + + +def _default_field_value(field: dict[str, typing.Any]) -> typing.Any: + default = field.get('default') + if isinstance(default, dict): + if default.get('type') == 'constant': + return default.get('value') + if 'value' in default: + return default.get('value') + return default + + +def _initial_form_inputs( + input_defs: list[dict[str, typing.Any]], + resolved_default_values: typing.Any = None, +) -> dict[str, typing.Any]: + inputs: dict[str, typing.Any] = {} + if isinstance(resolved_default_values, dict): + inputs.update(resolved_default_values) + for field in input_defs: + name = _field_name(field) + if not name or name in inputs: + continue + value = _default_field_value(field) + if value not in (None, ''): + inputs[name] = value + return inputs + + +def _format_human_input_fields_text(input_defs: list[dict[str, typing.Any]]) -> str: + if not input_defs: + return '' + + lines = ['Fields:'] + for field in input_defs: + name = _field_name(field) + typ = _field_type(field) + if typ == 'select': + options = _select_options(field) + option_text = ', '.join(f'{idx}. {value}' for idx, value in enumerate(options, start=1)) + lines.append(f' - {name} (select): {option_text or "choose one option"}') + elif typ in {'file', 'file-list'}: + limit = field.get('number_limits') if typ == 'file-list' else 1 + allowed_types = ', '.join(field.get('allowed_file_types') or []) + suffix = f', up to {limit}' if typ == 'file-list' and limit else '' + allowed = f' ({allowed_types})' if allowed_types else '' + lines.append(f' - {name} ({typ}{allowed}{suffix}): upload file(s) or reply "{name}: "') + else: + lines.append(f' - {name} ({typ}): reply "{name}: "') + + lines.append('You can reply with one or more lines like "field_name: value".') + return '\n'.join(lines) + + +def _format_human_input_actions_text(actions: list[dict[str, typing.Any]], require_action_key: bool = False) -> str: + if not actions: + return '' + lines: list[str] = [] + if require_action_key: + lines.append('Actions: reply with "action: " plus field values.') + else: + lines.append('Actions:') + for idx, action in enumerate(actions, start=1): + title = action.get('title') or action.get('id') or '' + lines.append(f' {idx}. {title}') + return '\n'.join(lines) + + +def _strip_form_field_placeholders(form_content: str, input_defs: list[dict[str, typing.Any]]) -> str: + if not form_content: + return '' + + field_names = {_field_name(field) for field in input_defs if _field_name(field)} + kept_lines: list[str] = [] + for line in form_content.splitlines(): + placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) + if placeholder and placeholder.group(1) in field_names: + continue + kept_lines.append(line) + + cleaned = '\n'.join(kept_lines).strip() + return re.sub(r'\n{3,}', '\n\n', cleaned) + + +def _form_content_for_platform( + form_content: str, + input_defs: list[dict[str, typing.Any]], + actions: list[dict[str, typing.Any]], +) -> str: + del actions + form_content = _strip_form_field_placeholders(form_content, input_defs) + field_help = _format_human_input_fields_text(input_defs) + parts = [part for part in (form_content, field_help) if part] + if not parts: + return form_content + return '\n\n'.join(parts) + + +def _extract_form_snapshot( + workflow_run_id: str, + reason: dict[str, typing.Any], + user: str, +) -> tuple[dict[str, typing.Any], str, list[dict[str, typing.Any]], str]: + raw_form_content = reason.get('form_content', '') or '' + input_defs = _normalize_form_input_defs(reason.get('inputs', [])) + actions = reason.get('actions', []) + display_form_content = _form_content_for_platform(raw_form_content, input_defs, actions) + snapshot = { + 'workflow_run_id': workflow_run_id, + 'form_id': reason.get('form_id'), + 'form_token': reason.get('form_token'), + 'node_id': reason.get('node_id'), + 'node_title': reason.get('node_title', ''), + 'form_content': display_form_content, + 'raw_form_content': raw_form_content, + 'input_defs': input_defs, + 'inputs': _initial_form_inputs(input_defs, reason.get('resolved_default_values')), + 'actions': actions, + 'expiration_time': reason.get('expiration_time'), + 'user': user, + } + return snapshot, raw_form_content, input_defs, display_form_content + + +def _extract_key_value_inputs(text: str) -> dict[str, str]: + stripped = text.strip() + if not stripped: + return {} + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, dict): + return {str(k).strip(): str(v).strip() for k, v in parsed.items() if str(k).strip()} + + values: dict[str, str] = {} + for line in stripped.splitlines(): + if ':' in line: + key, value = line.split(':', 1) + elif '=' in line: + key, value = line.split('=', 1) + else: + continue + key = key.strip() + if key: + values[key] = value.strip() + return values + + +def _extract_urls(text: str) -> list[str]: + return re.findall(r'https?://[^\s,,]+', text or '') + + +def _file_type_from_mime(content_type: str) -> str: + if content_type and content_type.startswith('image/'): + return 'image' + if content_type and content_type.startswith('audio/'): + return 'audio' + if content_type and content_type.startswith('video/'): + return 'video' + return 'document' + + +def _format_partial_form_notice(pending_form: dict[str, typing.Any]) -> str: + actions = pending_form.get('actions') or [] + lines = ['Received the form value(s).'] + action_help = _format_human_input_actions_text(actions, require_action_key=True) + if action_help: + lines.append('') + lines.append(action_help) + return '\n'.join(lines) + + +def _next_missing_form_field(form: dict[str, typing.Any], inputs: dict[str, typing.Any] | None = None) -> dict | None: + values = inputs if inputs is not None else form.get('inputs') or {} + for field in form.get('input_defs') or []: + name = _field_name(field) + if not name: + continue + if values.get(name) in (None, '', []): + return field + return None + + +def _format_single_form_field_text(field: dict[str, typing.Any]) -> str: + name = _field_name(field) + typ = _field_type(field) + if typ == 'select': + options = _select_options(field) + option_text = ', '.join(f'{idx}. {value}' for idx, value in enumerate(options, start=1)) + return f'{name} (select): {option_text or "choose one option"}' + if typ in {'file', 'file-list'}: + limit = field.get('number_limits') if typ == 'file-list' else 1 + allowed_types = ', '.join(field.get('allowed_file_types') or []) + suffix = f', up to {limit}' if typ == 'file-list' and limit else '' + allowed = f' ({allowed_types})' if allowed_types else '' + return f'{name} ({typ}{allowed}{suffix}): upload file(s) or reply "{name}: "' + return f'{name} ({typ}): reply "{name}: "' + + +def _form_content_placeholder_matches(form_content: str) -> list[re.Match[str]]: + return list(re.finditer(r'\{\{#\$output\.([^#{}]+)#\}\}', form_content or '')) + + +def _form_content_for_field(form_content: str, field: dict[str, typing.Any]) -> str: + """Return the template section immediately preceding a field placeholder.""" + field_name = _field_name(field) + matches = _form_content_placeholder_matches(form_content) + for index, match in enumerate(matches): + if match.group(1).strip() != field_name: + continue + start = matches[index - 1].end() if index else 0 + return form_content[start : match.start()].strip() + return '' + + +def _form_content_for_actions(form_content: str, input_defs: list[dict[str, typing.Any]]) -> str: + """Return content after the last form-field placeholder for the action step.""" + field_names = {_field_name(field) for field in input_defs if _field_name(field)} + matches = [ + match for match in _form_content_placeholder_matches(form_content) if match.group(1).strip() in field_names + ] + if not matches: + return _strip_form_field_placeholders(form_content, input_defs) + return form_content[matches[-1].end() :].strip() + + +def _field_input_form_data(pending_form: dict[str, typing.Any], field: dict[str, typing.Any] | None) -> dict | None: + if not field: + return None + raw_form_content = pending_form.get('raw_form_content') or '' + field_content = _form_content_for_field(raw_form_content, field) + return { + 'form_content': field_content or _format_single_form_field_text(field), + 'raw_form_content': pending_form.get('raw_form_content') or pending_form.get('form_content') or '', + 'input_defs': pending_form.get('input_defs') or [], + 'all_input_defs': pending_form.get('input_defs') or [], + 'inputs': pending_form.get('inputs', {}), + 'actions': pending_form.get('actions') or [], + 'node_title': pending_form.get('node_title', ''), + 'workflow_run_id': pending_form.get('workflow_run_id', ''), + 'form_token': pending_form.get('form_token', ''), + 'pipeline_uuid': pending_form.get('pipeline_uuid', ''), + '_current_input_field': _field_name(field), + } + + +def _action_select_form_data(pending_form: dict[str, typing.Any]) -> dict[str, typing.Any] | None: + actions = pending_form.get('actions') or [] + if not actions: + return None + form_content = pending_form.get('raw_form_content') or pending_form.get('form_content') or '' + return { + 'form_content': _form_content_for_actions(form_content, pending_form.get('input_defs') or []), + 'raw_form_content': form_content, + 'input_defs': [], + 'all_input_defs': pending_form.get('input_defs') or [], + 'inputs': pending_form.get('inputs', {}), + 'actions': actions, + 'node_title': pending_form.get('node_title', ''), + 'workflow_run_id': pending_form.get('workflow_run_id', ''), + 'form_token': pending_form.get('form_token', ''), + 'pipeline_uuid': pending_form.get('pipeline_uuid', ''), + '_action_select_only': True, + } + + +def _initial_interactive_form_data(pending_form: dict[str, typing.Any]) -> dict[str, typing.Any] | None: + next_field = _next_missing_form_field(pending_form) + pending_form['current_input_field'] = _field_name(next_field) if next_field else '' + if next_field: + return _field_input_form_data(pending_form, next_field) + return _action_select_form_data(pending_form) + + +def _attach_partial_form_data(message: typing.Any, form_action: dict[str, typing.Any]) -> typing.Any: + form_data = form_action.get('_form_data') + if form_data: + message._form_data = form_data + return message + + +def _missing_required_form_fields(form: dict[str, typing.Any], inputs: dict[str, typing.Any]) -> list[str]: + missing: list[str] = [] + for field in form.get('input_defs') or []: + name = _field_name(field) + if not name: + continue + value = inputs.get(name) + if value in (None, '', []): + missing.append(name) + return missing + + +def _format_missing_form_inputs_notice(form: dict[str, typing.Any], missing: list[str]) -> str: + lines = ['Some required form fields are still missing.'] + if missing: + lines.append('') + lines.append('Missing fields: ' + ', '.join(missing)) + field_help = _format_human_input_fields_text( + [field for field in form.get('input_defs') or [] if _field_name(field) in set(missing)] + ) + if field_help: + lines.append('') + lines.append(field_help) + action_help = _format_human_input_actions_text(form.get('actions') or [], require_action_key=True) + if action_help: + lines.append('') + lines.append(action_help) + return '\n'.join(lines) + + +def _invalid_select_inputs(form: dict[str, typing.Any], user_text: str) -> list[str]: + keyed_values = _extract_key_value_inputs(user_text) + current_field_name = str(form.get('current_input_field') or '').strip() + invalid: list[str] = [] + for field in form.get('input_defs') or []: + if _field_type(field) != 'select': + continue + name = _field_name(field) + raw_value = keyed_values.get(name) + if raw_value is None and not keyed_values and current_field_name == name and user_text.strip(): + raw_value = user_text.strip() + if raw_value is None: + continue + valid, _ = _normalize_select_value( + raw_value, + _select_options(field), + allow_one_based_index=True, + ) + if not valid: + invalid.append(name) + return invalid + + +def _format_invalid_select_notice(form: dict[str, typing.Any], invalid: list[str]) -> str: + lines = ['Invalid select value.'] + invalid_names = set(invalid) + for field in form.get('input_defs') or []: + name = _field_name(field) + if name not in invalid_names: + continue + options = _select_options(field) + choices = ', '.join(f'{index}. {option}' for index, option in enumerate(options, start=1)) + lines.append(f'{name}: {choices or "choose one of the available options"}') + return '\n'.join(lines) + + +def _normalize_form_action_inputs( + pending_form: dict[str, typing.Any], + raw_inputs: dict[str, typing.Any], +) -> dict[str, typing.Any]: + if not raw_inputs: + return {} + fields = {_field_name(field): field for field in pending_form.get('input_defs') or [] if _field_name(field)} + normalized = dict(raw_inputs) + for name, value in list(normalized.items()): + field = fields.get(name) + if not field or _field_type(field) != 'select': + continue + valid, selected = _normalize_select_value( + value, + _select_options(field), + allow_legacy_zero_based_index=True, + ) + if valid: + normalized[name] = selected + else: + normalized.pop(name, None) + return normalized + + +def _build_input_progress_action( + pending_form: dict[str, typing.Any], + inputs: dict[str, typing.Any], + *, + force_partial: bool = False, +) -> dict[str, typing.Any]: + """Update a pending form after collecting field values. + + `force_partial` is used by native card controls (for example DingTalk + Input/SelectBlock): those callbacks mean "store this field value and + render the next step", not "submit a workflow action" unless there is a + single action and no further choice is required. + """ + actions = pending_form.get('actions') or [] + pending_form['inputs'] = inputs + next_field = _next_missing_form_field(pending_form, inputs) + pending_form['current_input_field'] = _field_name(next_field) if next_field else '' + form_data = ( + _field_input_form_data(pending_form, next_field) if next_field else _action_select_form_data(pending_form) + ) + + if force_partial or len(actions) > 1 or next_field: + return { + '_partial': True, + 'form_token': pending_form.get('form_token', ''), + 'workflow_run_id': pending_form.get('workflow_run_id', ''), + 'node_title': pending_form.get('node_title', ''), + 'inputs': inputs, + 'user': pending_form.get('user', ''), + 'notice': ( + form_data.get('form_content') if next_field and form_data else _format_partial_form_notice(pending_form) + ), + '_form_data': form_data, + } + + action = actions[0] if actions else {} + return { + 'form_token': pending_form.get('form_token', ''), + 'workflow_run_id': pending_form.get('workflow_run_id', ''), + 'action_id': action.get('id', ''), + 'action_title': action.get('title', action.get('id', '')), + 'node_title': pending_form.get('node_title', ''), + 'inputs': inputs, + 'user': pending_form.get('user', ''), + } + + @runner.runner_class('dify-service-api') class DifyServiceAPIRunner(runner.RequestRunner): """Dify Service API 对话请求器""" @@ -26,7 +703,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): self.ap = ap self.pipeline_config = pipeline_config - valid_app_types = ['chat', 'agent', 'workflow'] + valid_app_types = ['chat', 'agent', 'workflow', 'chatflow'] if self.pipeline_config['ai']['dify-service-api']['app-type'] not in valid_app_types: raise errors.DifyAPIError( f'不支持的 Dify 应用类型: {self.pipeline_config["ai"]["dify-service-api"]["app-type"]}' @@ -50,7 +727,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): Returns: (处理后的内容, 提取的思维链内容) """ - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') + remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think') thinking_content = '' # 从 content 中提取 标签内容 if content and '' in content and '' in content: @@ -102,7 +779,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): """ plain_text = '' upload_files: list[dict] = [] - user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}' + user_tag = _dify_user_from_query(query) async def upload_file_bytes(file_name: str, file_bytes: bytes, content_type: str) -> str: file_name = file_name or 'file' @@ -170,10 +847,193 @@ class DifyServiceAPIRunner(runner.RequestRunner): return plain_text, upload_files + async def _upload_file_bytes_for_user( + self, file_name: str, file_bytes: bytes, content_type: str, user: str + ) -> dict: + file_name = file_name or 'file' + content_type = content_type or 'application/octet-stream' + resp = await self.dify_client.upload_file((file_name, file_bytes, content_type), user) + return { + 'type': _file_type_from_mime(content_type), + 'transfer_method': 'local_file', + 'upload_file_id': resp['id'], + } + + async def _download_file_for_form(self, file_url: str) -> tuple[bytes, str, str]: + async with httpx.AsyncClient() as client_session: + resp = await client_session.get(file_url) + resp.raise_for_status() + content_type = ( + resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream' + ) + parsed = urlparse(file_url) + file_name = os.path.basename(parsed.path) or 'file' + return resp.content, content_type, file_name + + async def _platform_file_to_dify(self, item: typing.Any, user: str) -> dict | None: + try: + if isinstance(item, platform_message.Image): + file_bytes, content_type = await item.get_bytes() + ext = (content_type or 'image/jpeg').split('/')[-1] or 'jpg' + return await self._upload_file_bytes_for_user(f'image.{ext}', file_bytes, content_type, user) + if isinstance(item, platform_message.File): + file_name = item.name or 'file' + if item.base64: + header, b64_data = item.base64.split(',', 1) if ',' in item.base64 else ('', item.base64) + content_type = 'application/octet-stream' + if header.startswith('data:') and ';' in header: + content_type = header.split(';', 1)[0][5:] or content_type + return await self._upload_file_bytes_for_user( + file_name, + base64.b64decode(b64_data), + content_type, + user, + ) + if item.path: + with open(item.path, 'rb') as f: + file_bytes = f.read() + content_type = mimetypes.guess_type(str(item.path))[0] or 'application/octet-stream' + file_name = item.name or os.path.basename(str(item.path)) or 'file' + return await self._upload_file_bytes_for_user(file_name, file_bytes, content_type, user) + if item.url: + file_bytes, content_type, downloaded_name = await self._download_file_for_form(item.url) + return await self._upload_file_bytes_for_user( + file_name or downloaded_name, + file_bytes, + content_type, + user, + ) + except Exception as e: + self.ap.logger.warning(f'dify human-input file upload failed: {e}') + return None + + async def _collect_form_inputs_from_query( + self, + query: pipeline_query.Query, + pending_form: dict, + user_text: str, + ) -> dict[str, typing.Any]: + input_defs = pending_form.get('input_defs') or [] + if not input_defs: + return dict(pending_form.get('inputs') or {}) + + values = dict(pending_form.get('inputs') or {}) + keyed_values = _extract_key_value_inputs(user_text) + user = pending_form.get('user') or _dify_user_from_query(query) + current_field_name = str(pending_form.get('current_input_field') or '').strip() + + file_fields = [field for field in input_defs if _field_type(field) in {'file', 'file-list'}] + uploaded_files: list[dict] = [] + if file_fields: + for component in query.message_chain: + if isinstance(component, (platform_message.Image, platform_message.File)): + uploaded = await self._platform_file_to_dify(component, user) + if uploaded: + uploaded_files.append(uploaded) + + for field in input_defs: + name = _field_name(field) + typ = _field_type(field) + if not name: + continue + + raw_value = keyed_values.get(name) + if raw_value is None and not keyed_values and current_field_name == name and user_text.strip(): + raw_value = user_text.strip() + if raw_value is None and current_field_name == name and typ in {'file', 'file-list'} and uploaded_files: + raw_value = '' + if raw_value is None: + continue + + if typ == 'select': + options = _select_options(field) + valid, selected = _normalize_select_value( + raw_value, + options, + allow_one_based_index=True, + ) + if valid: + values[name] = selected + elif typ == 'file': + urls = _extract_urls(raw_value) + if urls: + values[name] = { + 'type': _file_type_from_mime(mimetypes.guess_type(urls[0])[0] or ''), + 'transfer_method': 'remote_url', + 'url': urls[0], + } + elif uploaded_files: + values[name] = uploaded_files.pop(0) + elif typ == 'file-list': + urls = _extract_urls(raw_value) + file_values = [ + { + 'type': _file_type_from_mime(mimetypes.guess_type(url)[0] or ''), + 'transfer_method': 'remote_url', + 'url': url, + } + for url in urls + ] + if uploaded_files: + file_values.extend(uploaded_files) + uploaded_files = [] + limit = field.get('number_limits') + if isinstance(limit, int) and limit > 0: + file_values = file_values[:limit] + if file_values: + values[name] = file_values + else: + values[name] = raw_value + + for field in file_fields: + if not uploaded_files: + break + name = _field_name(field) + if not name or values.get(name): + continue + if _field_type(field) == 'file-list': + limit = field.get('number_limits') + count = limit if isinstance(limit, int) and limit > 0 else len(uploaded_files) + values[name] = uploaded_files[:count] + uploaded_files = uploaded_files[count:] + else: + values[name] = uploaded_files.pop(0) + + text_field_names = [ + _field_name(field) + for field in input_defs + if _field_type(field) not in {'file', 'file-list', 'select'} and _field_name(field) + ] + if not current_field_name and not keyed_values and len(text_field_names) == 1 and user_text.strip(): + values[text_field_names[0]] = user_text.strip() + + return values + async def _chat_messages( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.Message, None]: """调用聊天助手""" + # Check if this is a form action resume (button click or text match) + form_action_raw = query.variables.get('_dify_form_action') + session_key = _session_key_from_query(query) + + if form_action_raw: + form_action = self._merge_pending_form_action(session_key, form_action_raw) + else: + form_action = await self._match_pending_form_action(query, session_key, str(query.message_chain)) + + if form_action: + if form_action.get('_partial'): + yield _attach_partial_form_data( + provider_message.Message(role='assistant', content=form_action.get('notice', 'Received.')), + form_action, + ) + return + async for msg in self._submit_workflow_form_blocking(form_action, session_key): + yield msg + _clear_pending_form(session_key, form_action.get('form_token') or None) + return + cov_id = query.session.using_conversation.uuid or None query.variables['conversation_id'] = cov_id @@ -201,7 +1061,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): async for chunk in self.dify_client.chat_messages( inputs=inputs, query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + user=_dify_user_from_query(query), conversation_id=cov_id, files=files, timeout=120, @@ -212,6 +1072,37 @@ class DifyServiceAPIRunner(runner.RequestRunner): mode = 'workflow' if mode == 'workflow': + if chunk['event'] == 'workflow_paused': + reasons = chunk['data'].get('reasons', []) + workflow_run_id = chunk['data'].get('workflow_run_id', '') + for reason in reasons: + if reason.get('TYPE') != 'human_input_required': + continue + user = _dify_user_from_query(query) + form_snapshot, raw_form_content, input_defs, _ = _extract_form_snapshot( + workflow_run_id, + reason, + user, + ) + actions = form_snapshot.get('actions', []) + node_title = form_snapshot.get('node_title', '') + + _set_pending_form(_session_key_from_query(query), form_snapshot) + + query.variables['_dify_form_render'] = { + 'form_content': raw_form_content, + 'input_defs': input_defs, + 'actions': actions, + 'node_title': node_title, + } + + display_text = _format_human_input_text(node_title, raw_form_content, actions, input_defs) + yield provider_message.Message( + role='assistant', + content=display_text, + ) + return + if chunk['event'] == 'node_finished': if chunk['data']['node_type'] == 'answer': answer = self._extract_dify_text_output(chunk['data']['outputs'].get('answer')) @@ -223,7 +1114,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): ) elif mode == 'basic': if chunk['event'] == 'message': - basic_mode_pending_chunk += chunk['answer'] + basic_mode_pending_chunk = _merge_stream_text(basic_mode_pending_chunk, chunk['answer']) elif chunk['event'] == 'message_end': content, _ = self._process_thinking_content(basic_mode_pending_chunk) yield provider_message.Message( @@ -268,7 +1159,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): async for chunk in self.dify_client.chat_messages( inputs=inputs, query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + user=_dify_user_from_query(query), response_mode='streaming', conversation_id=cov_id, files=files, @@ -280,7 +1171,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): continue if chunk['event'] == 'agent_message' or chunk['event'] == 'message': - pending_agent_message += chunk['answer'] + pending_agent_message = _merge_stream_text(pending_agent_message, chunk['answer']) else: if pending_agent_message.strip() != '': pending_agent_message = pending_agent_message.replace('Action:', '') @@ -335,11 +1226,321 @@ class DifyServiceAPIRunner(runner.RequestRunner): query.session.using_conversation.uuid = chunk['conversation_id'] + async def _submit_workflow_form_blocking( + self, form_action: dict, session_key: PendingFormKey + ) -> typing.AsyncGenerator[provider_message.Message, None]: + """Submit human input to resume a paused Dify workflow (non-streaming).""" + + form_token = form_action['form_token'] + workflow_run_id = form_action['workflow_run_id'] + user = form_action['user'] + action_id = form_action.get('action_id', '') + inputs = form_action.get('inputs', {}) + pending_content = '' + saw_event = False + answer_node_seen = False + + async for chunk in self.dify_client.workflow_submit( + form_token=form_token, + workflow_run_id=workflow_run_id, + inputs=inputs, + user=user, + action=action_id, + timeout=120, + ): + saw_event = True + self.ap.logger.debug('dify-workflow-submit-chunk: ' + str(chunk)) + event = chunk.get('event') + + if event == 'error': + raise errors.DifyAPIError(chunk.get('message') or 'Dify workflow resume failed') + + if event in ('message', 'agent_message') and not answer_node_seen: + pending_content = _merge_stream_text( + pending_content, + self._extract_dify_text_output(chunk.get('answer')), + ) + + if event == 'text_chunk': + pending_content = _merge_stream_text( + pending_content, + self._extract_dify_text_output(chunk.get('data', {}).get('text')), + ) + + if event == 'node_finished' and chunk.get('data', {}).get('node_type') == 'answer': + answer = self._extract_dify_text_output(chunk.get('data', {}).get('outputs', {}).get('answer')) + if answer: + # Answer-node output is the complete answer and may duplicate + # preceding message events, so prefer it over the accumulator. + pending_content = answer + answer_node_seen = True + + if event == 'workflow_finished': + data = chunk.get('data', {}) + if data.get('error'): + raise errors.DifyAPIError(data['error']) + if not pending_content: + pending_content = self._extract_dify_text_output(data.get('outputs', {}).get('summary')) + content, _ = self._process_thinking_content(pending_content) + yield provider_message.Message( + role='assistant', + content=content, + ) + return + + if event == 'workflow_paused': + reasons = chunk.get('data', {}).get('reasons', []) + new_run_id = chunk.get('data', {}).get('workflow_run_id', workflow_run_id) + for reason in reasons: + if reason.get('TYPE') != 'human_input_required': + continue + form_snapshot, raw_form_content, input_defs, _ = _extract_form_snapshot( + new_run_id, + reason, + user, + ) + actions = form_snapshot.get('actions', []) + paused_node_title = form_snapshot.get('node_title', '') + + _set_pending_form(session_key, form_snapshot) + + display_text = _format_human_input_text( + paused_node_title, + raw_form_content, + actions, + input_defs, + ) + yield provider_message.Message( + role='assistant', + content=display_text, + ) + return + + if not saw_event: + raise errors.DifyAPIError('Dify API did not return any workflow resume events') + raise errors.DifyAPIError('Dify workflow resume stream ended before a terminal event') + + def _resolve_pending_form(self, session_key: PendingFormKey, form_action: dict) -> dict | None: + """Locate the pending form this action targets. + + Tries identifiers in order of specificity: form_token, full + workflow_run_id, workflow_run_id suffix (Telegram-style compact id), + then falls back to the newest pending form for the session. + """ + form_token = form_action.get('form_token') + if form_token: + form = _get_pending_form_by_token(session_key, form_token) + if form: + return form + + workflow_run_id = form_action.get('workflow_run_id') + if workflow_run_id: + for form in _iter_pending_forms(session_key): + if form.get('workflow_run_id') == workflow_run_id: + return form + + w_suffix = form_action.get('w_suffix') + if w_suffix: + form = _get_pending_form_by_w_suffix(session_key, w_suffix) + if form: + return form + + if form_token or workflow_run_id or w_suffix: + return None + return _get_latest_pending_form(session_key) + + def _merge_pending_form_action(self, session_key: PendingFormKey, form_action: dict | None) -> dict | None: + """Backfill resume fields from the matching pending form.""" + if not form_action: + return None + + merged_action = dict(form_action) + merged_action.pop('w_suffix', None) + pending_form = self._resolve_pending_form(session_key, form_action) + if pending_form is None and any( + form_action.get(identifier) for identifier in ('form_token', 'workflow_run_id', 'w_suffix') + ): + return None + if pending_form: + merged_action['form_token'] = merged_action.get('form_token') or pending_form.get('form_token', '') + merged_action['workflow_run_id'] = merged_action.get('workflow_run_id') or pending_form.get( + 'workflow_run_id', '' + ) + inputs = dict(pending_form.get('inputs') or {}) + component_inputs = merged_action.get('inputs') or {} + current_field_name = str( + merged_action.pop('_current_input_field', None) or pending_form.get('current_input_field') or '' + ).strip() + if current_field_name and current_field_name not in component_inputs: + for component_key in ('input', 'select'): + if component_key in component_inputs: + component_inputs[current_field_name] = component_inputs.pop(component_key) + break + component_inputs = _normalize_form_action_inputs(pending_form, component_inputs) + inputs.update(component_inputs) + merged_action['inputs'] = inputs + merged_action.setdefault('user', pending_form.get('user', '')) + merged_action.setdefault('node_title', pending_form.get('node_title', '')) + if merged_action.pop('_input_progress', False): + return _build_input_progress_action(pending_form, inputs, force_partial=True) + missing_fields = _missing_required_form_fields(pending_form, inputs) + if missing_fields: + pending_form['inputs'] = inputs + next_field = _next_missing_form_field(pending_form, inputs) + pending_form['current_input_field'] = _field_name(next_field) if next_field else '' + form_data = ( + _field_input_form_data(pending_form, next_field) + if next_field + else _action_select_form_data(pending_form) + ) + merged_action['_partial'] = True + merged_action['notice'] = _format_missing_form_inputs_notice(pending_form, missing_fields) + merged_action['_form_data'] = form_data + + # Resolve clicked action's display title from the stored actions list + if 'action_title' not in merged_action: + clicked_id = merged_action.get('action_id', '') + for action in pending_form.get('actions', []): + if str(action.get('id', '')) == str(clicked_id): + merged_action['action_title'] = action.get('title', clicked_id) + break + + return merged_action + + async def _match_pending_form_action( + self, + query: pipeline_query.Query, + session_key: PendingFormKey, + user_text: str, + ) -> dict | None: + """Match plain text replies against pending Dify form actions. + + Resolution order: + 1. A pure digit reply (e.g. "1", "2") maps to the 1-indexed action of + the most recent pending form. Lets users on plain-text platforms + pick options without retyping titles. + 2. Otherwise, iterate pending forms newest-first and match each + action's title/id case-insensitively. The first hit wins, so when + two forms share a button label the newer one resolves. + """ + normalized_text = user_text.strip().lower() + latest_form = _get_latest_pending_form(session_key) + has_file_upload = bool( + latest_form + and latest_form.get('input_defs') + and any(_field_type(field) in {'file', 'file-list'} for field in latest_form.get('input_defs') or []) + and any( + isinstance(component, (platform_message.Image, platform_message.File)) + for component in query.message_chain + ) + ) + if not normalized_text and not has_file_upload: + return None + keyed_values = _extract_key_value_inputs(user_text) + requested_action = ( + keyed_values.get('action') + or keyed_values.get('Action') + or keyed_values.get('action_id') + or keyed_values.get('actionId') + ) + normalized_action = requested_action.strip().lower() if requested_action else '' + + def _build(pending_form: dict, action: dict) -> dict: + return { + 'form_token': pending_form.get('form_token', ''), + 'workflow_run_id': pending_form.get('workflow_run_id', ''), + 'action_id': action.get('id', ''), + 'action_title': action.get('title', action.get('id', '')), + 'node_title': pending_form.get('node_title', ''), + 'inputs': pending_form.get('inputs', {}), + 'user': pending_form.get('user', ''), + } + + if latest_form: + invalid_selects = _invalid_select_inputs(latest_form, user_text) + if invalid_selects: + inputs = await self._collect_form_inputs_from_query(query, latest_form, user_text) + form_action = _build_input_progress_action(latest_form, inputs, force_partial=True) + form_action['notice'] = _format_invalid_select_notice(latest_form, invalid_selects) + return form_action + + if latest_form and latest_form.get('current_input_field') and not normalized_action: + inputs = await self._collect_form_inputs_from_query(query, latest_form, user_text) + if inputs != (latest_form.get('inputs') or {}): + return _build_input_progress_action(latest_form, inputs) + + if latest_form and latest_form.get('input_defs') and not normalized_action: + current_inputs = dict(latest_form.get('inputs') or {}) + missing_fields = _missing_required_form_fields(latest_form, current_inputs) + if missing_fields: + next_field = _next_missing_form_field(latest_form, current_inputs) + if next_field: + latest_form['current_input_field'] = _field_name(next_field) + inputs = await self._collect_form_inputs_from_query(query, latest_form, user_text) + if inputs != current_inputs: + return _build_input_progress_action(latest_form, inputs) + + if normalized_text.isdigit() or normalized_action.isdigit(): + position = int(normalized_action or normalized_text) + if latest_form is not None: + actions = latest_form.get('actions', []) + if 1 <= position <= len(actions): + form_action = _build(latest_form, actions[position - 1]) + form_action['inputs'] = await self._collect_form_inputs_from_query( + query, + latest_form, + user_text, + ) + return form_action + + for pending_form in _iter_pending_forms(session_key): + for action in pending_form.get('actions', []): + titles = { + str(action.get('title', '')).strip().lower(), + str(action.get('id', '')).strip().lower(), + } + if normalized_text in titles or (normalized_action and normalized_action in titles): + form_action = _build(pending_form, action) + form_action['inputs'] = await self._collect_form_inputs_from_query( + query, + pending_form, + user_text, + ) + return form_action + + if latest_form and latest_form.get('input_defs'): + inputs = await self._collect_form_inputs_from_query(query, latest_form, user_text) + if inputs != (latest_form.get('inputs') or {}): + return _build_input_progress_action(latest_form, inputs) + + return None + async def _workflow_messages( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.Message, None]: """调用工作流""" + # Check if this is a form action resume (button click or text match) + form_action_raw = query.variables.get('_dify_form_action') + session_key = _session_key_from_query(query) + + if form_action_raw: + form_action = self._merge_pending_form_action(session_key, form_action_raw) + else: + form_action = await self._match_pending_form_action(query, session_key, str(query.message_chain)) + + if form_action: + if form_action.get('_partial'): + yield _attach_partial_form_data( + provider_message.Message(role='assistant', content=form_action.get('notice', 'Received.')), + form_action, + ) + return + async for msg in self._submit_workflow_form_blocking(form_action, session_key): + yield msg + _clear_pending_form(session_key, form_action.get('form_token') or None) + return + if not query.session.using_conversation.uuid: query.session.using_conversation.uuid = str(uuid.uuid4()) @@ -366,10 +1567,11 @@ class DifyServiceAPIRunner(runner.RequestRunner): } inputs.update(query.variables) + human_input_yielded = False async for chunk in self.dify_client.workflow_run( inputs=inputs, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + user=_dify_user_from_query(query), files=files, timeout=120, ): @@ -377,6 +1579,37 @@ class DifyServiceAPIRunner(runner.RequestRunner): if chunk['event'] in ignored_events: continue + if chunk['event'] == 'workflow_paused': + reasons = chunk['data'].get('reasons', []) + workflow_run_id = chunk['data'].get('workflow_run_id', '') + for reason in reasons: + if reason.get('TYPE') == 'human_input_required': + user = _dify_user_from_query(query) + form_snapshot, raw_form_content, input_defs, _ = _extract_form_snapshot( + workflow_run_id, + reason, + user, + ) + actions = form_snapshot.get('actions', []) + node_title = form_snapshot.get('node_title', '') + + _set_pending_form(_session_key_from_query(query), form_snapshot) + + query.variables['_dify_form_render'] = { + 'form_content': raw_form_content, + 'input_defs': input_defs, + 'actions': actions, + 'node_title': node_title, + } + + display_text = _format_human_input_text(node_title, raw_form_content, actions, input_defs) + + human_input_yielded = True + yield provider_message.Message( + role='assistant', + content=display_text, + ) + if chunk['event'] == 'node_started': if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end': continue @@ -399,6 +1632,8 @@ class DifyServiceAPIRunner(runner.RequestRunner): yield msg elif chunk['event'] == 'workflow_finished': + if human_input_yielded: + break if chunk['data']['error']: raise errors.DifyAPIError(chunk['data']['error']) content, _ = self._process_thinking_content(chunk['data']['outputs']['summary']) @@ -414,6 +1649,31 @@ class DifyServiceAPIRunner(runner.RequestRunner): self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: """调用聊天助手""" + # Check if this is a form action resume (button click or text match) + form_action_raw = query.variables.get('_dify_form_action') + session_key = _session_key_from_query(query) + + if form_action_raw: + form_action = self._merge_pending_form_action(session_key, form_action_raw) + else: + form_action = await self._match_pending_form_action(query, session_key, str(query.message_chain)) + + if form_action: + if form_action.get('_partial'): + yield _attach_partial_form_data( + provider_message.MessageChunk( + role='assistant', + content=form_action.get('notice', 'Received.'), + is_final=True, + ), + form_action, + ) + return + async for msg in self._submit_workflow_form(form_action, session_key): + yield msg + _clear_pending_form(session_key, form_action.get('form_token') or None) + return + cov_id = query.session.using_conversation.uuid or None query.variables['conversation_id'] = cov_id @@ -439,16 +1699,23 @@ class DifyServiceAPIRunner(runner.RequestRunner): chunk = None # 初始化chunk变量,防止在没有响应时引用错误 is_final = False - think_start = False - think_end = False yielded_final = False + human_input_yielded = False + pending_form_data = None - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') + remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think') + + def visible_content(content: str) -> str: + if not remove_think: + return content + if '' in content and '' not in content: + return content.split('', 1)[0].rstrip() + return self._process_thinking_content(content)[0] async for chunk in self.dify_client.chat_messages( inputs=inputs, query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + user=_dify_user_from_query(query), conversation_id=cov_id, files=files, timeout=120, @@ -457,37 +1724,67 @@ class DifyServiceAPIRunner(runner.RequestRunner): if chunk['event'] == 'workflow_started': mode = 'workflow' - elif chunk['event'] in ('node_started', 'node_finished', 'workflow_finished'): + elif chunk['event'] in ('node_started', 'node_finished', 'workflow_finished', 'workflow_paused'): # Some Dify deployments may omit workflow_started in streamed chunks. mode = 'workflow' if chunk['event'] == 'message': message_idx += 1 - if remove_think: - if '' in chunk['answer'] and not think_start: - think_start = True - continue - if '' in chunk['answer'] and not think_end: - import re - - content = re.sub(r'^\n', '', chunk['answer']) - basic_mode_pending_chunk += content - think_end = True - elif think_end: - basic_mode_pending_chunk += chunk['answer'] - if think_start: - continue - - else: - basic_mode_pending_chunk += chunk['answer'] + basic_mode_pending_chunk = _merge_stream_text(basic_mode_pending_chunk, chunk['answer']) if chunk['event'] == 'message_end': is_final = True elif chunk['event'] == 'workflow_finished': is_final = True + if human_input_yielded: + break if chunk['data'].get('error'): raise errors.DifyAPIError(chunk['data']['error']) + if mode == 'workflow' and chunk['event'] == 'workflow_paused': + reasons = chunk['data'].get('reasons', []) + workflow_run_id = chunk['data'].get('workflow_run_id', '') + for reason in reasons: + if reason.get('TYPE') != 'human_input_required': + continue + user = _dify_user_from_query(query) + form_snapshot, raw_form_content, input_defs, display_form_content = _extract_form_snapshot( + workflow_run_id, + reason, + user, + ) + actions = form_snapshot.get('actions', []) + node_title = form_snapshot.get('node_title', '') + + _set_pending_form(_session_key_from_query(query), form_snapshot) + + query.variables['_dify_form_render'] = { + 'form_content': raw_form_content, + 'input_defs': input_defs, + 'actions': actions, + 'node_title': node_title, + } + + # Use a zero-width space so ResponseWrapper lets the chunk + # propagate to SendResponseBackStage, but the adapter + # detects _form_data and renders buttons instead of the + # plain-text prompt (mirrors _workflow_messages_chunk). + if not basic_mode_pending_chunk: + basic_mode_pending_chunk = '​' + + pending_form_data = _initial_interactive_form_data(form_snapshot) or { + 'form_content': display_form_content, + 'raw_form_content': raw_form_content, + 'input_defs': input_defs, + 'inputs': form_snapshot.get('inputs', {}), + 'actions': actions, + 'node_title': node_title, + 'workflow_run_id': workflow_run_id, + 'form_token': reason.get('form_token', ''), + 'pipeline_uuid': form_snapshot.get('pipeline_uuid', ''), + } + human_input_yielded = True + if mode == 'workflow' and chunk['event'] == 'node_finished': if chunk['data'].get('node_type') == 'answer': answer = self._extract_dify_text_output(chunk['data'].get('outputs', {}).get('answer')) @@ -499,15 +1796,36 @@ class DifyServiceAPIRunner(runner.RequestRunner): and (is_final or message_idx % 8 == 0) and (basic_mode_pending_chunk != '' or is_final) ): - # content, _ = self._process_thinking_content(basic_mode_pending_chunk) - yield provider_message.MessageChunk( + final_content = visible_content(basic_mode_pending_chunk) + if not final_content.strip() and is_final and pending_form_data: + final_content = _STREAM_FORM_PLACEHOLDER + if not final_content and not is_final: + continue + msg = provider_message.MessageChunk( role='assistant', - content=basic_mode_pending_chunk, + content=final_content, is_final=is_final, ) + if is_final and pending_form_data: + msg._form_data = pending_form_data + pending_form_data = None + yield msg if is_final: yielded_final = True + # If the stream ended after workflow_paused without a + # workflow_finished event, yield a final chunk so the adapter + # can update the card and add buttons. + if human_input_yielded and not yielded_final: + final_content = visible_content(basic_mode_pending_chunk) + msg = provider_message.MessageChunk( + role='assistant', + content=final_content or _STREAM_FORM_PLACEHOLDER, + is_final=True, + ) + msg._form_data = pending_form_data + yield msg + if chunk is None: raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置') @@ -545,12 +1863,12 @@ class DifyServiceAPIRunner(runner.RequestRunner): think_start = False think_end = False - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') + remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think') async for chunk in self.dify_client.chat_messages( inputs=inputs, query=plain_text, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + user=_dify_user_from_query(query), response_mode='streaming', conversation_id=cov_id, files=files, @@ -571,15 +1889,15 @@ class DifyServiceAPIRunner(runner.RequestRunner): import re content = re.sub(r'^\n', '', chunk['answer']) - pending_agent_message += content + pending_agent_message = _merge_stream_text(pending_agent_message, content) think_end = True elif think_end or not think_start: - pending_agent_message += chunk['answer'] + pending_agent_message = _merge_stream_text(pending_agent_message, chunk['answer']) if think_start and not think_end: continue else: - pending_agent_message += chunk['answer'] + pending_agent_message = _merge_stream_text(pending_agent_message, chunk['answer']) elif chunk['event'] == 'message_end': is_final = True else: @@ -636,11 +1954,183 @@ class DifyServiceAPIRunner(runner.RequestRunner): query.session.using_conversation.uuid = chunk['conversation_id'] + async def _submit_workflow_form( + self, form_action: dict, session_key: PendingFormKey + ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: + """Submit human input to resume a paused Dify workflow.""" + + form_token = form_action['form_token'] + workflow_run_id = form_action['workflow_run_id'] + user = form_action['user'] + action_id = form_action.get('action_id', '') + action_title = form_action.get('action_title', '') or action_id + node_title = form_action.get('node_title', '') + inputs = form_action.get('inputs', {}) + + messsage_idx = 0 + is_final = False + think_start = False + think_end = False + workflow_contents = '' + repause_form_data: dict | None = None + + remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think') + async for chunk in self.dify_client.workflow_submit( + form_token=form_token, + workflow_run_id=workflow_run_id, + inputs=inputs, + user=user, + action=action_id, + timeout=120, + ): + self.ap.logger.debug('dify-workflow-submit-chunk: ' + str(chunk)) + + yield_this_iteration = False + + if chunk['event'] == 'workflow_finished': + is_final = True + yield_this_iteration = True + if chunk['data'].get('error'): + raise errors.DifyAPIError(chunk['data']['error']) + + if chunk['event'] == 'workflow_paused': + reasons = chunk['data'].get('reasons', []) + new_run_id = chunk['data'].get('workflow_run_id', workflow_run_id) + for reason in reasons: + if reason.get('TYPE') != 'human_input_required': + continue + form_snapshot, raw_form_content, input_defs, display_form_content = _extract_form_snapshot( + new_run_id, + reason, + user, + ) + actions = form_snapshot.get('actions', []) + # Use a distinct name — `node_title` (the just-resolved step) + # must keep its value so the resume notice on the previous + # card still shows which step the user acted on. + paused_node_title = form_snapshot.get('node_title', '') + + _set_pending_form(session_key, form_snapshot) + + repause_form_data = _initial_interactive_form_data(form_snapshot) or { + 'form_content': display_form_content, + 'raw_form_content': raw_form_content, + 'input_defs': input_defs, + 'inputs': form_snapshot.get('inputs', {}), + 'actions': actions, + 'node_title': paused_node_title, + 'workflow_run_id': new_run_id, + 'form_token': reason.get('form_token', ''), + 'pipeline_uuid': form_snapshot.get('pipeline_uuid', ''), + } + # Ensure the final chunk has non-empty content so + # ResponseWrapper (which skips empty-content chunks) lets it + # propagate to SendResponseBackStage. Use a zero-width space + # so neither Lark nor Telegram renders visible noise — the + # adapter substitutes its own card text from _form_data. + if not workflow_contents: + workflow_contents = '​' + is_final = True + yield_this_iteration = True + break + + if chunk['event'] == 'text_chunk': + messsage_idx += 1 + if remove_think: + if '' in chunk['data']['text'] and not think_start: + think_start = True + continue + if '' in chunk['data']['text'] and not think_end: + import re + + content = re.sub(r'^\n', '', chunk['data']['text']) + workflow_contents += content + think_end = True + elif think_end: + workflow_contents = _merge_stream_text(workflow_contents, chunk['data']['text']) + if think_start: + continue + else: + workflow_contents = _merge_stream_text(workflow_contents, chunk['data']['text']) + if messsage_idx % 8 == 0: + yield_this_iteration = True + + # Chatflow apps return answers via 'message' events (answer field), + # not 'text_chunk' events (data.text field). + if chunk['event'] == 'message': + answer = chunk.get('answer', '') + if answer: + messsage_idx += 1 + if remove_think: + if '' in answer and not think_start: + think_start = True + continue + if '' in answer and not think_end: + import re + + content = re.sub(r'^\n', '', answer) + workflow_contents += content + think_end = True + elif think_end: + workflow_contents = _merge_stream_text(workflow_contents, answer) + if think_start: + continue + else: + workflow_contents = _merge_stream_text(workflow_contents, answer) + if messsage_idx % 8 == 0: + yield_this_iteration = True + + if yield_this_iteration: + msg = provider_message.MessageChunk( + role='assistant', + content=workflow_contents, + is_final=is_final, + ) + msg._resume_from_form = True + if action_title: + msg._resume_action_title = action_title + if node_title: + msg._resume_node_title = node_title + if is_final and repause_form_data: + msg._form_data = repause_form_data + msg._open_new_card = True + yield msg + if is_final: + return + + raise errors.DifyAPIError('Dify workflow resume stream ended before a terminal event') + async def _workflow_messages_chunk( self, query: pipeline_query.Query ) -> typing.AsyncGenerator[provider_message.MessageChunk, None]: """调用工作流""" + # Check if this is a form action resume (button click or text match) + form_action_raw = query.variables.get('_dify_form_action') + session_key = _session_key_from_query(query) + + if form_action_raw: + form_action = self._merge_pending_form_action(session_key, form_action_raw) + else: + form_action = await self._match_pending_form_action(query, session_key, str(query.message_chain)) + + if form_action: + if form_action.get('_partial'): + yield _attach_partial_form_data( + provider_message.MessageChunk( + role='assistant', + content=form_action.get('notice', 'Received.'), + is_final=True, + ), + form_action, + ) + return + # Resume paused workflow via submit endpoint + async for msg in self._submit_workflow_form(form_action, session_key): + yield msg + _clear_pending_form(session_key, form_action.get('form_token') or None) + return + if not query.session.using_conversation.uuid: query.session.using_conversation.uuid = str(uuid.uuid4()) @@ -672,17 +2162,68 @@ class DifyServiceAPIRunner(runner.RequestRunner): think_start = False think_end = False workflow_contents = '' + workflow_run_id = '' + human_input_yielded = False - remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think') + # Saved form data to attach to the final MessageChunk so the adapter + # can detect it when is_final=True and render buttons. + pending_form_data = None + + remove_think = self.pipeline_config['output'].get('misc', {}).get('remove-think') async for chunk in self.dify_client.workflow_run( inputs=inputs, - user=f'{query.session.launcher_type.value}_{query.session.launcher_id}', + user=_dify_user_from_query(query), files=files, timeout=120, ): self.ap.logger.debug('dify-workflow-chunk: ' + str(chunk)) if chunk['event'] in ignored_events: + if chunk['event'] == 'workflow_started': + workflow_run_id = chunk['data'].get('workflow_run_id', '') continue + + if chunk['event'] == 'workflow_paused': + reasons = chunk['data'].get('reasons', []) + workflow_run_id = chunk['data'].get('workflow_run_id', workflow_run_id) + for reason in reasons: + if reason.get('TYPE') == 'human_input_required': + user = _dify_user_from_query(query) + form_snapshot, raw_form_content, input_defs, display_form_content = _extract_form_snapshot( + workflow_run_id, + reason, + user, + ) + actions = form_snapshot.get('actions', []) + node_title = form_snapshot.get('node_title', '') + + _set_pending_form(_session_key_from_query(query), form_snapshot) + + # Pass form render metadata to downstream stages + query.variables['_dify_form_render'] = { + 'form_content': raw_form_content, + 'input_defs': input_defs, + 'actions': actions, + 'node_title': node_title, + } + + # Save form data to attach to the final chunk later. + # We do NOT yield here — the form content will be sent + # as the final MessageChunk (with is_final=True and + # _form_data) so the adapter can update the card and + # add buttons in one pass. + pending_form_data = _initial_interactive_form_data(form_snapshot) or { + 'form_content': display_form_content, + 'raw_form_content': raw_form_content, + 'input_defs': input_defs, + 'inputs': form_snapshot.get('inputs', {}), + 'actions': actions, + 'node_title': node_title, + 'workflow_run_id': workflow_run_id, + 'form_token': reason.get('form_token', ''), + 'pipeline_uuid': form_snapshot.get('pipeline_uuid', ''), + } + human_input_yielded = True + if chunk['event'] == 'workflow_finished': is_final = True if chunk['data']['error']: @@ -701,12 +2242,12 @@ class DifyServiceAPIRunner(runner.RequestRunner): workflow_contents += content think_end = True elif think_end: - workflow_contents += chunk['data']['text'] + workflow_contents = _merge_stream_text(workflow_contents, chunk['data']['text']) if think_start: continue else: - workflow_contents += chunk['data']['text'] + workflow_contents = _merge_stream_text(workflow_contents, chunk['data']['text']) if chunk['event'] == 'node_started': if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end': @@ -730,17 +2271,39 @@ class DifyServiceAPIRunner(runner.RequestRunner): yield msg if messsage_idx % 8 == 0 or is_final: - yield provider_message.MessageChunk( + final_content = ( + workflow_contents + if workflow_contents.strip() + else (_STREAM_FORM_PLACEHOLDER if is_final and pending_form_data else '') + ) + msg = provider_message.MessageChunk( role='assistant', - content=workflow_contents, + content=final_content, is_final=is_final, ) + # Attach form data to the final chunk for the adapter + if is_final and pending_form_data: + msg._form_data = pending_form_data + pending_form_data = None + yield msg + + # If the stream ended after workflow_paused without a + # workflow_finished event, yield a final chunk so the adapter + # can update the card and add buttons. + if human_input_yielded and not is_final: + msg = provider_message.MessageChunk( + role='assistant', + content=workflow_contents if workflow_contents.strip() else _STREAM_FORM_PLACEHOLDER, + is_final=True, + ) + msg._form_data = pending_form_data + yield msg async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]: """运行请求""" if await query.adapter.is_stream_output_supported(): msg_idx = 0 - if self.pipeline_config['ai']['dify-service-api']['app-type'] == 'chat': + if self.pipeline_config['ai']['dify-service-api']['app-type'] in ('chat', 'chatflow'): async for msg in self._chat_messages_chunk(query): msg_idx += 1 msg.msg_sequence = msg_idx @@ -760,7 +2323,7 @@ class DifyServiceAPIRunner(runner.RequestRunner): f'不支持的 Dify 应用类型: {self.pipeline_config["ai"]["dify-service-api"]["app-type"]}' ) else: - if self.pipeline_config['ai']['dify-service-api']['app-type'] == 'chat': + if self.pipeline_config['ai']['dify-service-api']['app-type'] in ('chat', 'chatflow'): async for msg in self._chat_messages(query): yield msg elif self.pipeline_config['ai']['dify-service-api']['app-type'] == 'agent': diff --git a/src/langbot/templates/dingtalk_human_input_card.json b/src/langbot/templates/dingtalk_human_input_card.json new file mode 100644 index 000000000..81e891ac6 --- /dev/null +++ b/src/langbot/templates/dingtalk_human_input_card.json @@ -0,0 +1,6 @@ +{ + "editorData": "{\"schemaVersion\":\"3.0.0\",\"schema\":{\"config\":null,\"componentsMap\":[{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AIPending\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AIPending\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AICardStatusContainer\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AICardStatusContainer\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"BaseText\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"BaseText\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AICardContent\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AICardContent\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"AICardContainer\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"AICardContainer\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"ButtonGroup\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"ButtonGroup\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"MarkdownBlock\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"MarkdownBlock\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"Avatar\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"Avatar\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"Input\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"Input\"},{\"package\":\"@ali/dxComponent\",\"version\":\"1.0.0\",\"exportName\":\"SelectBlock\",\"main\":\"./src/index.tsx\",\"destructuring\":false,\"subName\":\"\",\"componentName\":\"SelectBlock\"}],\"componentsTree\":[{\"componentName\":\"AICardContainer\",\"id\":\"node_root\",\"props\":{\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enablePending\":true,\"enableWriting\":true,\"enableDoing\":true,\"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\":[{\"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\":\"\"}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_writing\",\"props\":{\"status\":2,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"状态2占位\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_status_writing_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\":[]}]},{\"componentName\":\"AICardStatusContainer\",\"id\":\"node_status_doing\",\"props\":{\"status\":4,\"marginLeft\":0,\"marginRight\":0,\"marginTop\":0,\"marginBottom\":0,\"enableExtend\":false,\"autoFoldConfig\":{\"needFold\":true,\"heightLimit\":480,\"foldStatusLocalDataKey\":\"_cardFoldStatusLocalDataKey\"},\"innerOffset\":0,\"enableCollapse\":false,\"margin\":-2},\"title\":\"状态4占位\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\",\"children\":[{\"componentName\":\"AICardContent\",\"id\":\"node_status_doing_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\":[]}]},{\"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\":[{\"componentName\":\"Avatar\",\"id\":\"node_avatar\",\"props\":{\"imageUrl\":{\"value\":\"\",\"valueType\":\"variable\",\"type\":\"dynamicImage\",\"variable\":\"bot_avatar\",\"variableType\":\"global\"},\"name\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"LangBot\"},\"sizeType\":\"Standard\",\"size\":\"extraSmall\",\"customSize\":48,\"marginLeft\":12,\"marginRight\":12,\"marginTop\":6,\"marginBottom\":6,\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"mode\":\"userInfo\",\"margin\":-2,\"innerOffset\":0},\"title\":\"头像\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"componentName\":\"MarkdownBlock\",\"id\":\"node_text_content\",\"props\":{\"mdVer\":0,\"icon\":{\"type\":\"icon\",\"icon\":\"\",\"iconType\":\"emoji\"},\"content\":{\"variable\":\"content\",\"variableType\":\"global\",\"type\":\"variableValue\",\"varType\":\"markdown\"},\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"fixed\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"isStreaming\":false,\"enableLinkStatPoint\":false,\"linkStatPoint\":{\"type\":\"dynamicString\",\"content\":\"Page_InteractiveCard__Click_markdownOpenlink\",\"i18n\":false},\"linkStatPointParams\":[],\"marginTop\":6,\"marginBottom\":6,\"marginLeft\":12,\"marginRight\":12},\"title\":\"AI 流式富文本\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"componentName\":\"Input\",\"id\":\"node_input\",\"props\":{\"placeholder\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false,\"variable\":\"input_placeholder\",\"variableType\":\"global\"},\"currentValue\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false,\"variable\":\"input_value\",\"variableType\":\"global\"},\"message\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false,\"variable\":\"input_placeholder\",\"variableType\":\"global\"},\"title\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false,\"variable\":\"input_title\",\"variableType\":\"global\"},\"id\":{\"type\":\"dynamicString\",\"content\":\"input\",\"i18n\":false},\"params\":[{\"type\":\"builtIn\",\"variable\":\"\",\"value\":\"\",\"name\":\"input\",\"variableType\":\"global\",\"id\":\"__built_in_inputResult__\"}],\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"variable\",\"variable\":\"input_visible\",\"variableType\":\"global\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"status\":{\"type\":\"dynamicSelect\",\"valueType\":\"fixed\",\"value\":\"normal\",\"variable\":\"\",\"variableType\":\"global\"},\"actionType\":\"request\",\"localVarAction\":{\"type\":\"variableValue\",\"variableType\":\"global\",\"variable\":\"\"},\"keyOfDynamicObject\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"inlineMode\":false,\"textArea\":true,\"minRows\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":2,\"variable\":\"\",\"variableType\":\"global\"},\"maxRows\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":6,\"variable\":\"\",\"variableType\":\"global\"},\"marginLeft\":12,\"marginRight\":12,\"marginTop\":6,\"marginBottom\":6,\"margin\":12,\"innerOffset\":0},\"title\":\"Text input\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"componentName\":\"SelectBlock\",\"id\":\"node_select\",\"props\":{\"id\":{\"type\":\"dynamicString\",\"content\":\"select\",\"i18n\":false},\"placeholder\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false,\"variable\":\"select_placeholder\",\"variableType\":\"global\"},\"currentIndex\":{\"type\":\"dynamicNumber\",\"valueType\":\"variable\",\"value\":-1,\"variable\":\"select_index\",\"variableType\":\"global\"},\"options\":{\"type\":\"dynamicSelectOptions\",\"valueType\":\"variable\",\"value\":[],\"variable\":\"index_o\",\"variableType\":\"global\"},\"optionLabelMaxLines\":3,\"params\":[{\"type\":\"builtIn\",\"variable\":\"\",\"value\":\"{\\\"index\\\": ${index}, \\\"value\\\": \\\"${value}\\\"}\",\"name\":\"select\",\"variableType\":\"global\",\"id\":\"__built_in_selectResult__\"}],\"actionType\":\"request\",\"localVarAction\":{\"type\":\"variableValue\",\"variableType\":\"global\",\"variable\":\"\"},\"keyOfDynamicObject\":{\"type\":\"dynamicString\",\"content\":\"\",\"i18n\":false},\"status\":{\"type\":\"dynamicSelect\",\"valueType\":\"fixed\",\"value\":\"normal\",\"variable\":\"\",\"variableType\":\"global\"},\"visible\":{\"type\":\"dynamicVisible\",\"value\":true,\"valueType\":\"variable\",\"variable\":\"select_visible\",\"variableType\":\"global\",\"condition\":{\"op\":\"and\",\"conditions\":[]}},\"marginLeft\":12,\"marginRight\":12,\"marginTop\":6,\"marginBottom\":6,\"pullOptionsWhileOpen\":false,\"pullOptionsRequestParams\":[],\"margin\":12,\"innerOffset\":0},\"title\":\"Select\",\"hidden\":false,\"isLocked\":false,\"condition\":true,\"conditionGroup\":\"\"},{\"componentName\":\"ButtonGroup\",\"id\":\"node_btn_group\",\"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\":\"\"}]}]},{\"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\":[{\"componentName\":\"BaseText\",\"id\":\"node_failed_text\",\"props\":{\"text\":{\"i18n\":false,\"type\":\"dynamicString\",\"content\":\"操作失败,请稍后重试。\"},\"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\":10,\"marginRight\":10,\"marginTop\":10,\"marginBottom\":10,\"fontColorType\":\"Standard\",\"enableHighlight\":false,\"maxLine\":{\"type\":\"dynamicNumber\",\"valueType\":\"fixed\",\"value\":2,\"variable\":\"\",\"variableType\":\"global\"},\"color\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"common_level1_base_color\",\"variable\":\"\",\"variableType\":\"global\"},\"customLightColor\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"#35404b\",\"variable\":\"\",\"variableType\":\"global\"},\"customDarkColor\":{\"type\":\"dynamicColor\",\"valueType\":\"fixed\",\"value\":\"#f6f6f6\",\"variable\":\"\",\"variableType\":\"global\"},\"gravity\":\"center\",\"fontSizeType\":\"Standard\",\"styleType\":\"custom\",\"styleToken\":\"common_body_text_style\",\"size\":\"middle\",\"customFontSize\":15,\"customFontLineHeight\":22,\"bold\":false,\"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\":\"\"}]}]}]}],\"i18n\":{},\"version\":\"1.0.0\"},\"mockData\":{\"cardData\":{\"flowStatus\":3,\"content\":\"请审核以下报销申请:\\n\\n- 申请人:张三\\n- 金额:¥1,200\\n- 类别:差旅\",\"input_visible\":\"\",\"input_title\":\"\",\"input_placeholder\":\"\",\"input_value\":\"\",\"select_visible\":\"\",\"select_placeholder\":\"\",\"index_o\":[{\"value\":\"\",\"text\":{\"zh_CN\":\"\",\"zh_TW\":\"\",\"en_US\":\"\",\"ja_JP\":\"\",\"vi_VN\":\"\",\"th_TH\":\"\",\"id_ID\":\"\",\"ne_NP\":\"\",\"ms_MY\":\"\",\"ko_KR\":\"\",\"ru_RU\":\"\",\"es_EA\":\"\",\"tr_TR\":\"\",\"fr_FR\":\"\",\"pt_BR\":\"\"}}],\"select_options\":[],\"select_index\":-1,\"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},{\"id\":\"bot_avatar\",\"type\":\"string\",\"name\":\"bot_avatar\",\"description\":\"机器人头像 DingTalk 媒体 ID(@xxx 格式,启动时由 /media/upload 拿到)\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"input_visible\",\"type\":\"string\",\"name\":\"input_visible\",\"description\":\"Whether to show the text input component\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"input_title\",\"type\":\"string\",\"name\":\"input_title\",\"description\":\"Text input title\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"input_placeholder\",\"type\":\"string\",\"name\":\"input_placeholder\",\"description\":\"Text input placeholder\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"input_value\",\"type\":\"string\",\"name\":\"input_value\",\"description\":\"Text input current value\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"select_visible\",\"type\":\"string\",\"name\":\"select_visible\",\"description\":\"Whether to show the select component\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"select_placeholder\",\"type\":\"string\",\"name\":\"select_placeholder\",\"description\":\"Select placeholder\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"name\":\"index_o\",\"private\":false,\"type\":\"selectOptions\",\"id\":\"index_o\",\"description\":\"Select options\",\"editorVarType\":\"variables\",\"disabled\":false,\"schema\":[{\"id\":\"index_o.value\",\"type\":\"string\",\"name\":\"value\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.text\",\"type\":\"object\",\"name\":\"text\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\",\"schema\":[{\"id\":\"index_o.zh_CN\",\"type\":\"string\",\"name\":\"zh_CN\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.zh_TW\",\"type\":\"string\",\"name\":\"zh_TW\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.en_US\",\"type\":\"string\",\"name\":\"en_US\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.ja_JP\",\"type\":\"string\",\"name\":\"ja_JP\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.vi_VN\",\"type\":\"string\",\"name\":\"vi_VN\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.th_TH\",\"type\":\"string\",\"name\":\"th_TH\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.id_ID\",\"type\":\"string\",\"name\":\"id_ID\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.ne_NP\",\"type\":\"string\",\"name\":\"ne_NP\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.ms_MY\",\"type\":\"string\",\"name\":\"ms_MY\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.ko_KR\",\"type\":\"string\",\"name\":\"ko_KR\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.ru_RU\",\"type\":\"string\",\"name\":\"ru_RU\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.es_EA\",\"type\":\"string\",\"name\":\"es_EA\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.tr_TR\",\"type\":\"string\",\"name\":\"tr_TR\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.fr_FR\",\"type\":\"string\",\"name\":\"fr_FR\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"},{\"id\":\"index_o.pt_BR\",\"type\":\"string\",\"name\":\"pt_BR\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":true,\"description\":\"\"}]}]},{\"id\":\"select_options\",\"type\":\"array\",\"name\":\"select_options\",\"description\":\"Legacy select options\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"id\":\"select_index\",\"type\":\"number\",\"name\":\"select_index\",\"description\":\"Current select index\",\"private\":false,\"editorVarType\":\"variables\",\"disabled\":false},{\"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)\"}]}]}]}],\"formList\":[],\"customContextList\":[],\"expList\":[],\"localList\":[],\"hsfList\":[],\"lwpList\":[],\"pageData\":{},\"extension\":{\"extendType\":\"AI\",\"aiStatusList\":[3,1,5,2,4],\"fileTypeList\":[]}}", + "widgetInfo": "", + "type": "im", + "mode": "card" +} \ No newline at end of file diff --git a/tests/unit_tests/platform/test_dingtalk_adapter.py b/tests/unit_tests/platform/test_dingtalk_adapter.py new file mode 100644 index 000000000..df2087866 --- /dev/null +++ b/tests/unit_tests/platform/test_dingtalk_adapter.py @@ -0,0 +1,254 @@ +"""Tests for DingTalk adapter helper behavior.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from langbot.pkg.platform.sources.dingtalk import ( + DingTalkAdapter, + _dingtalk_card_markdown, + _dingtalk_clean_form_content, + _dingtalk_completed_input_lines, + _dingtalk_extract_component_inputs, + _dingtalk_form_component_params, + _dingtalk_missing_completed_input_lines, + _dingtalk_pending_input_defs, +) + + +def test_dingtalk_select_component_params_expose_options(): + params = _dingtalk_form_component_params( + { + '_current_input_field': 'choice', + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + 'inputs': {}, + } + ) + + assert params['select_visible'] == 'true' + assert params['select_placeholder'] == 'choice' + assert params['select_options'] == ['A', 'B'] + assert [option['value'] for option in params['index_o']] == ['A', 'B'] + assert [option['value'] for option in params['test_index']] == ['A', 'B'] + assert params['index_o'][0]['text']['zh_CN'] == 'A' + assert params['index_o'][0]['text']['en_US'] == 'A' + assert params['select_index'] == -1 + + +def test_dingtalk_extract_select_from_builtin_result_dict(): + inputs = _dingtalk_extract_component_inputs({'selectResult': {'index': 1, 'value': 'B'}}) + + assert inputs == {'select': 'B'} + + +def test_dingtalk_extract_select_from_template_param_string(): + inputs = _dingtalk_extract_component_inputs({'select': '{"index": 1, "value": "B"}'}) + + assert inputs == {'select': '{"index": 1, "value": "B"}'} + + +def test_dingtalk_extract_input_and_select_together(): + inputs = _dingtalk_extract_component_inputs( + { + 'inputResult': {'value': 'looks good'}, + '__built_in_selectResult__': {'index': 0, 'value': 'A'}, + } + ) + + assert inputs == {'input': 'looks good', 'select': 'A'} + + +def test_dingtalk_extract_component_inputs_strips_card_line_endings(): + inputs = _dingtalk_extract_component_inputs( + { + 'inputResult': {'value': '回复我测试\r\n'}, + 'selectResult': {'value': '1\r'}, + } + ) + + assert inputs == {'input': '回复我测试', 'select': '1'} + + +def test_dingtalk_pending_input_defs_includes_file_fields(): + pending = _dingtalk_pending_input_defs( + { + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + {'output_variable_name': 'files', 'type': 'file-list'}, + ], + 'inputs': {'comment': 'ready'}, + } + ) + + assert [field['output_variable_name'] for field in pending] == ['files'] + + +def test_dingtalk_completed_input_lines_include_text_and_select_values(): + lines = _dingtalk_completed_input_lines( + { + 'all_input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + ], + 'inputs': {'comment': 'looks good', 'choice': 'B'}, + } + ) + + assert lines == ['✅ comment:looks good', '✅ choice:B'] + + +def test_dingtalk_completed_inputs_are_not_repeated_when_already_interleaved(): + form_data = { + 'all_input_defs': [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + {'output_variable_name': 'xiala', 'type': 'select'}, + ], + 'inputs': {'us_input': '回复我测试\r', 'xiala': '1'}, + } + form_content = '你好\n请输入你的问题\n✅ us_input:回复我测试\n请选择你的答案\n✅ xiala:1' + + assert _dingtalk_missing_completed_input_lines(form_data, form_content) == [] + + +def test_dingtalk_completed_inputs_are_appended_when_template_does_not_render_them(): + form_data = { + 'all_input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}], + 'inputs': {'comment': 'ready'}, + } + + assert _dingtalk_missing_completed_input_lines(form_data, 'Please review') == ['✅ comment:ready'] + + +def test_dingtalk_clean_form_content_uses_all_input_defs(): + content = _dingtalk_clean_form_content( + { + 'raw_form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n', + 'input_defs': [], + 'all_input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + {'output_variable_name': 'choice', 'type': 'select'}, + ], + } + ) + + assert content == 'Hello' + + +def test_dingtalk_field_stage_keeps_prior_prompts_and_completed_values(): + content = _dingtalk_clean_form_content( + { + '_current_input_field': 'choice', + 'raw_form_content': ('Question\n{{#$output.comment#}}\nChoose an answer\n{{#$output.choice#}}'), + 'form_content': 'Choose an answer', + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + {'output_variable_name': 'choice', 'type': 'select'}, + ], + 'inputs': {'comment': 'hello'}, + } + ) + + assert '{{#$output.' not in content + assert content.index('Question') < content.index('comment') + assert content.index('comment') < content.index('Choose an answer') + + +def test_dingtalk_final_action_stage_interleaves_prompts_and_completed_values(): + content = _dingtalk_clean_form_content( + { + '_action_select_only': True, + 'raw_form_content': ('11\nQuestion\n{{#$output.comment#}}\nChoose an answer\n{{#$output.choice#}}'), + 'all_input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + {'output_variable_name': 'choice', 'type': 'select'}, + ], + 'inputs': {'comment': 'hello', 'choice': 'B'}, + } + ) + + assert '{{#$output.' not in content + assert content.startswith('11\nQuestion') + assert content.index('Question') < content.index('comment') + assert content.index('comment') < content.index('Choose an answer') + assert content.index('Choose an answer') < content.index('choice') + + +def test_dingtalk_card_markdown_preserves_internal_line_breaks(): + assert _dingtalk_card_markdown('11\nQuestion\nCompleted') == '11
Question
Completed' + + +def _build_card_action_adapter() -> DingTalkAdapter: + adapter = DingTalkAdapter.model_construct( + card_state={ + 'card-1': { + 'session_key': 'group_group-1', + 'launcher_type': 'group', + 'launcher_id': 'group-1', + 'sender_user_id': 'initiator-1', + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'actions': [{'id': 'approve', 'title': 'Approve'}], + 'node_title': 'Review', + 'form_content': 'Please review', + 'input_defs': [], + 'inputs': {}, + } + }, + active_turn_card={}, + active_turn_text={}, + ) + adapter.logger = AsyncMock() + adapter.ap = MagicMock() + adapter.ap.platform_mgr.bots = [] + adapter.ap.query_pool.add_query = AsyncMock() + return adapter + + +@pytest.mark.asyncio +async def test_dingtalk_group_card_action_uses_clicker_as_sender(): + adapter = _build_card_action_adapter() + + with patch.object(DingTalkAdapter, '_mark_card_resolved', new=AsyncMock()) as mark_resolved: + await adapter._on_card_action( + { + 'out_track_id': 'card-1', + 'user_id': 'reviewer-2', + 'action_id': 'approve', + 'params': {}, + } + ) + await asyncio.sleep(0) + + call = adapter.ap.query_pool.add_query.await_args + assert call.kwargs['launcher_id'] == 'group-1' + assert call.kwargs['sender_id'] == 'reviewer-2' + assert call.kwargs['message_event'].sender.id == 'reviewer-2' + mark_resolved.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_dingtalk_unknown_card_action_is_rejected(): + adapter = _build_card_action_adapter() + + await adapter._on_card_action( + { + 'out_track_id': 'card-1', + 'user_id': 'reviewer-2', + 'action_id': 'not-on-card', + 'params': {}, + } + ) + + adapter.ap.query_pool.add_query.assert_not_awaited() + adapter.logger.warning.assert_awaited_once() diff --git a/tests/unit_tests/platform/test_dingtalk_api.py b/tests/unit_tests/platform/test_dingtalk_api.py new file mode 100644 index 000000000..03c84e97b --- /dev/null +++ b/tests/unit_tests/platform/test_dingtalk_api.py @@ -0,0 +1,42 @@ +"""Tests for DingTalk API payload helpers.""" + +import json + +from langbot.libs.dingtalk_api.api import _stringify_card_param_map + + +def test_dingtalk_card_param_map_stringifies_select_component_arrays(): + params = _stringify_card_param_map( + { + 'content': 'Pick one', + 'btns': json.dumps([{'text': 'OK'}], ensure_ascii=False), + 'select_options': ['A', 'B'], + 'index_o': [ + { + 'value': 'A', + 'text': {'zh_CN': 'A', 'en_US': 'A'}, + } + ], + 'test_index': [ + { + 'value': 'A', + 'text': {'zh_CN': 'A', 'en_US': 'A'}, + } + ], + 'select_index': -1, + } + ) + + assert params['content'] == 'Pick one' + assert params['btns'] == '[{"text": "OK"}]' + assert params['select_options'] == '["A", "B"]' + assert json.loads(params['index_o'])[0]['value'] == 'A' + assert json.loads(params['test_index'])[0]['value'] == 'A' + assert params['select_index'] == '-1' + + +def test_dingtalk_card_param_map_stringifies_unregistered_structures(): + params = _stringify_card_param_map({'other': ['A'], 'empty': None}) + + assert params['other'] == '["A"]' + assert params['empty'] == '' diff --git a/tests/unit_tests/platform/test_lark_adapter.py b/tests/unit_tests/platform/test_lark_adapter.py new file mode 100644 index 000000000..59b91ee60 --- /dev/null +++ b/tests/unit_tests/platform/test_lark_adapter.py @@ -0,0 +1,196 @@ +"""Tests for Lark adapter helper behavior.""" + +from langbot.pkg.platform.sources.lark import ( + LarkAdapter, + _lark_clean_form_content, + _lark_completed_input_lines, + _lark_current_input_defs, + _lark_extract_action_form_inputs, + _lark_should_update_stream_element, + _lark_visible_form_content, +) + + +def test_lark_current_input_defs_only_returns_active_stage(): + input_defs = [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + {'output_variable_name': 'xiala', 'type': 'select'}, + ] + + assert _lark_current_input_defs( + { + '_current_input_field': 'xiala', + 'input_defs': input_defs, + } + ) == [input_defs[1]] + assert ( + _lark_current_input_defs( + { + '_action_select_only': True, + 'input_defs': input_defs, + } + ) + == [] + ) + + +def test_lark_form_field_elements_only_render_active_stage(): + adapter = LarkAdapter.model_construct() + form_data = { + '_current_input_field': 'xiala', + 'input_defs': [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + { + 'output_variable_name': 'xiala', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['1', '2']}, + }, + ], + } + + elements, input_name_map, file_help_lines = adapter._build_lark_form_field_elements(form_data) + + assert len(elements) == 1 + assert elements[0]['tag'] == 'select_static' + assert elements[0]['label']['content'] == 'xiala' + assert list(input_name_map.values()) == ['xiala'] + assert file_help_lines == [] + + +def test_lark_form_stage_skips_closed_streaming_element_update(): + assert not _lark_should_update_stream_element( + resume_from=False, + form_data={'_current_input_field': 'xiala'}, + msg_seq=1, + is_final=True, + ) + assert _lark_should_update_stream_element( + resume_from=False, + form_data=None, + msg_seq=1, + is_final=True, + ) + + +def test_lark_final_action_stage_interleaves_prompts_and_completed_values(): + form_content = _lark_visible_form_content( + { + '_action_select_only': True, + 'raw_form_content': ('11\nQuestion\n{{#$output.us_input#}}\nChoose an answer\n{{#$output.xiala#}}\n'), + 'all_input_defs': [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + {'output_variable_name': 'xiala', 'type': 'select'}, + ], + 'inputs': {'us_input': 'hello', 'xiala': '2'}, + } + ) + + assert '{{#$output.' not in form_content + assert form_content.startswith('11\nQuestion') + assert form_content.index('Question') < form_content.index('us_input') + assert form_content.index('us_input') < form_content.index('Choose an answer') + assert form_content.index('Choose an answer') < form_content.index('xiala') + + +def test_lark_completed_input_lines_include_text_select_and_files(): + lines = _lark_completed_input_lines( + { + 'all_input_defs': [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + {'output_variable_name': 'xiala', 'type': 'select'}, + {'output_variable_name': 'files', 'type': 'file-list'}, + ], + 'inputs': { + 'us_input': '你好', + 'xiala': 'or', + 'files': [{'upload_file_id': 'file-1'}, {'upload_file_id': 'file-2'}], + }, + } + ) + + assert lines == [ + '✅ us_input:你好', + '✅ xiala:or', + '✅ files:2 file(s)', + ] + + +def test_lark_clean_form_content_removes_all_input_placeholders(): + content = _lark_clean_form_content( + '人工介入\n\n{{#$output.us_input#}}\n\n{{#$output.xiala#}}\n', + [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + {'output_variable_name': 'xiala', 'type': 'select'}, + ], + ) + + assert content == '人工介入' + + +def test_lark_extract_action_form_inputs_from_json_form_value(): + class Action: + form_value = '{"Input_1_us_input_abcd12": "hello", "Select_2_xiala_abcd12": "B"}' + input_value = None + option = None + name = None + + inputs = _lark_extract_action_form_inputs( + Action(), + { + 'input_name_map': { + 'Input_1_us_input_abcd12': 'us_input', + 'Select_2_xiala_abcd12': 'xiala', + } + }, + ) + + assert inputs == {'us_input': 'hello', 'xiala': 'B'} + + +def test_lark_extract_action_form_inputs_from_webhook_dict_action(): + inputs = _lark_extract_action_form_inputs( + { + 'form_value': { + 'Input_1_us_input_abcd12': 'hello', + 'Select_2_xiala_abcd12': {'value': 'B', 'text': {'content': 'Option B'}}, + } + }, + { + 'input_name_map': { + 'Input_1_us_input_abcd12': 'us_input', + 'Select_2_xiala_abcd12': 'xiala', + } + }, + ) + + assert inputs == {'us_input': 'hello', 'xiala': {'value': 'B', 'text': {'content': 'Option B'}}} + + +def test_lark_extract_action_form_inputs_maps_dotted_component_names(): + inputs = _lark_extract_action_form_inputs( + { + 'form_value': { + 'Form_1_token_abcd12.Input_1_us_input_abcd12': 'hello', + } + }, + { + 'input_name_map': { + 'Input_1_us_input_abcd12': 'us_input', + } + }, + ) + + assert inputs == {'us_input': 'hello'} + + +def test_lark_completed_input_lines_display_select_value_from_object(): + lines = _lark_completed_input_lines( + { + 'all_input_defs': [ + {'output_variable_name': 'xiala', 'type': 'select'}, + ], + 'inputs': {'xiala': {'value': 'B', 'text': {'content': 'Option B'}}}, + } + ) + + assert lines == ['✅ xiala:B'] diff --git a/tests/unit_tests/platform/test_qqofficial_api.py b/tests/unit_tests/platform/test_qqofficial_api.py new file mode 100644 index 000000000..0f791f612 --- /dev/null +++ b/tests/unit_tests/platform/test_qqofficial_api.py @@ -0,0 +1,224 @@ +"""Tests for QQ Official keyboard payload helpers.""" + +import asyncio +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import langbot_plugin.api.entities.builtin.platform.message as platform_message + +from langbot.libs.qq_official_api.api import ( + QQ_SELECT_ACTION_PREFIX, + build_keyboard_from_select_field, + get_select_field_options, + resolve_select_button_action, +) + + +def _select_form_data() -> dict: + return { + '_current_input_field': 'choice', + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B', 'C']}, + } + ], + } + + +def test_qq_select_field_builds_callback_buttons(): + keyboard = build_keyboard_from_select_field(_select_form_data(), buttons_per_row=2) + + rows = keyboard['content']['rows'] + assert [[button['render_data']['label'] for button in row['buttons']] for row in rows] == [ + ['A', 'B'], + ['C'], + ] + assert rows[0]['buttons'][0]['action']['data'] == f'{QQ_SELECT_ACTION_PREFIX}0' + assert rows[0]['buttons'][1]['action']['data'] == f'{QQ_SELECT_ACTION_PREFIX}1' + + +def test_qq_select_button_resolves_field_and_value(): + form_data = _select_form_data() + + assert get_select_field_options(form_data) == ('choice', ['A', 'B', 'C']) + assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}1') == ('choice', 'B') + assert resolve_select_button_action(form_data, f'{QQ_SELECT_ACTION_PREFIX}99') is None + + +def test_qq_select_keyboard_fits_twenty_five_options(): + form_data = _select_form_data() + form_data['input_defs'][0]['option_source']['value'] = [f'Option {idx}' for idx in range(25)] + + rows = build_keyboard_from_select_field(form_data)['content']['rows'] + + assert len(rows) == 5 + assert all(len(row['buttons']) == 5 for row in rows) + + +def test_qq_non_select_field_does_not_build_keyboard(): + form_data = { + '_current_input_field': 'comment', + 'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}], + } + + assert build_keyboard_from_select_field(form_data)['content']['rows'] == [] + + +def _stream_test_adapter(): + from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter + + adapter = QQOfficialAdapter.model_construct() + adapter.logger = AsyncMock() + adapter.bot = MagicMock() + adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'}) + adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'}) + adapter.ap = None + adapter._stream_ctx = {} + adapter._stream_ctx_ts = {} + adapter._fallback_text = {} + adapter._fallback_text_ts = {} + return adapter + + +@pytest.mark.asyncio +async def test_qq_stream_uses_cumulative_chunks_as_snapshots(): + adapter = _stream_test_adapter() + adapter._stream_ctx['message-1'] = { + 'user_openid': 'user-1', + 'msg_id': 'source-1', + 'stream_msg_id': None, + 'msg_seq': 1, + 'index': 0, + 'last_update_ts': 0, + 'accumulated_text': '', + 'sent_length': 0, + 'session_started': False, + } + adapter._stream_ctx_ts['message-1'] = time.time() + source = MagicMock() + + await adapter.reply_message_chunk( + source, + {'resp_message_id': 'message-1'}, + platform_message.MessageChain([platform_message.Plain(text='one')]), + ) + await adapter.reply_message_chunk( + source, + {'resp_message_id': 'message-1'}, + platform_message.MessageChain([platform_message.Plain(text='one two')]), + is_final=True, + ) + + assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [ + 'one', + ' two', + ] + + +@pytest.mark.asyncio +async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only(): + from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter + + adapter = _stream_test_adapter() + source = MagicMock() + + with patch.object(QQOfficialAdapter, 'reply_message', new=AsyncMock()) as reply_message: + await adapter.reply_message_chunk( + source, + {'resp_message_id': 'message-1'}, + platform_message.MessageChain([platform_message.Plain(text='Hel')]), + ) + await adapter.reply_message_chunk( + source, + {'resp_message_id': 'message-1'}, + platform_message.MessageChain([platform_message.Plain(text='Hello')]), + is_final=True, + ) + + sent_chain = reply_message.await_args.args[1] + assert str(sent_chain) == 'Hello' + + +@pytest.mark.asyncio +async def test_qq_text_field_prompt_keeps_form_content(): + from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter + + adapter = _stream_test_adapter() + adapter._pending_forms = {} + adapter._session_event_ids = {} + adapter._anchor_msg_seq = {} + source = MagicMock() + source.d_id = 'source-1' + source.t = 'C2C_MESSAGE_CREATE' + event = MagicMock() + event.source_platform_object = source + event.sender.id = 'user-1' + form_data = { + '_current_input_field': 'us_input', + 'node_title': 'Manual input', + 'form_content': '1234\nEnter your question', + 'input_defs': [{'output_variable_name': 'us_input', 'type': 'paragraph'}], + 'actions': [{'id': 'yes', 'title': 'yes'}], + } + + with patch.object(QQOfficialAdapter, '_resolve_target_from_event', return_value=('c2c', 'user-1')): + await adapter._handle_form_chunk(event, platform_message.MessageChain([]), form_data) + + send_call = adapter.bot.send_markdown_keyboard.await_args.kwargs + assert send_call['markdown_content'] == '### Manual input\n\n1234\nEnter your question' + assert send_call['keyboard'] is None + + +@pytest.mark.asyncio +async def test_qq_select_click_enqueues_input_progress_query(): + import langbot.pkg.core.app # noqa: F401 + from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter + + adapter = QQOfficialAdapter.model_construct() + adapter.logger = AsyncMock() + adapter.bot = MagicMock() + adapter.bot.ack_interaction = AsyncMock() + adapter.ap = MagicMock() + adapter.ap.platform_mgr.bots = [] + adapter.ap.query_pool.add_query = AsyncMock() + adapter._pending_forms = { + 'group_group-1': { + 'form_data': { + **_select_form_data(), + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Review', + 'actions': [{'id': 'approve', 'title': 'Approve'}], + }, + 'sender_id': 'initiator-1', + 'posted_at': time.time(), + } + } + adapter._session_event_ids = {} + adapter._anchor_msg_seq = {} + + await adapter._handle_interaction_create( + { + 'id': 'interaction-1', + 'chat_type': 1, + 'group_openid': 'group-1', + 'member_openid': 'reviewer-2', + 'data': {'resolved': {'button_data': f'{QQ_SELECT_ACTION_PREFIX}1'}}, + }, + ws_event_id='event-1', + ) + await asyncio.sleep(0) + + call = adapter.ap.query_pool.add_query.await_args + form_action = call.kwargs['variables']['_dify_form_action'] + assert call.kwargs['launcher_id'] == 'group-1' + assert call.kwargs['sender_id'] == 'reviewer-2' + assert form_action['action_id'] == '' + assert form_action['inputs'] == {'select': 'B'} + assert form_action['_current_input_field'] == 'choice' + assert form_action['_input_progress'] is True + adapter.bot.ack_interaction.assert_awaited_once_with('interaction-1', code=0) diff --git a/tests/unit_tests/platform/test_telegram_adapter.py b/tests/unit_tests/platform/test_telegram_adapter.py new file mode 100644 index 000000000..6e2262283 --- /dev/null +++ b/tests/unit_tests/platform/test_telegram_adapter.py @@ -0,0 +1,158 @@ +"""Tests for Telegram Dify form callback helpers.""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from telegram import ForceReply + +import langbot_plugin.api.entities.builtin.platform.entities as platform_entities +import langbot_plugin.api.entities.builtin.platform.events as platform_events +import langbot_plugin.api.entities.builtin.platform.message as platform_message +from langbot.pkg.platform.sources.telegram import ( + TelegramAdapter, + _telegram_form_action_from_callback, + _telegram_select_field_options, +) + + +def _select_form_data() -> dict: + return { + '_current_input_field': 'choice', + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B', 'C']}, + } + ], + } + + +def test_telegram_select_field_options_are_extracted(): + assert _telegram_select_field_options(_select_form_data()) == ('choice', ['A', 'B', 'C']) + + +def test_telegram_select_callback_becomes_input_progress(): + assert _telegram_form_action_from_callback({'f': 1, 'x': 1}) == { + 'action_id': '', + 'inputs': {'select': {'index': 1}}, + '_input_progress': True, + } + + +def test_telegram_action_callback_remains_final_action(): + assert _telegram_form_action_from_callback({'f': 1, 'a': 'approve'}) == { + 'action_id': 'approve', + 'inputs': {}, + } + + +def test_telegram_invalid_select_callback_is_rejected(): + assert _telegram_form_action_from_callback({'f': 1, 'x': -1}) is None + assert _telegram_form_action_from_callback({'f': 1, 'x': 'invalid'}) is None + + +def test_telegram_form_callback_cache_consumes_the_whole_form_group(): + adapter = TelegramAdapter.model_construct() + adapter._form_action_titles = {} + adapter._cache_form_action_titles({'callback-a': 'A', 'callback-b': 'B'}, now=100.0) + + assert adapter._take_form_action_title('callback-a', now=101.0) == 'A' + assert adapter._take_form_action_title('callback-a', now=101.0) is None + assert adapter._take_form_action_title('callback-b', now=101.0) is None + assert adapter._form_action_titles == {} + + +def test_telegram_form_callback_cache_prunes_expired_entries(): + adapter = TelegramAdapter.model_construct() + adapter._form_action_titles = {} + adapter._cache_form_action_titles({'callback-a': 'A'}, now=100.0) + + assert adapter._take_form_action_title('callback-a', now=100.0 + adapter._FORM_ACTION_CACHE_TTL) is None + assert adapter._form_action_titles == {} + + +def test_telegram_form_callback_cache_preserves_pipeline_uuid(): + adapter = TelegramAdapter.model_construct() + adapter._form_action_titles = {} + adapter._cache_form_action_titles( + {'callback-a': 'Approve'}, + pipeline_uuid='pipeline-routed', + now=100.0, + ) + + assert adapter._take_form_action_context('callback-a', now=101.0) == ( + 'Approve', + 'pipeline-routed', + ) + + +@pytest.mark.asyncio +async def test_telegram_select_field_sends_two_column_inline_keyboard(): + bot = MagicMock() + bot.send_message = AsyncMock() + adapter = TelegramAdapter.model_construct(bot=bot, config={}, msg_stream_id={}, seq=1, listeners={}) + adapter._form_action_titles = {} + + update = MagicMock() + update.effective_chat.id = 123 + update.effective_message.message_thread_id = None + event = platform_events.FriendMessage( + sender=platform_entities.Friend(id='user-1', nickname='', remark=''), + message_chain=platform_message.MessageChain([]), + source_platform_object=update, + ) + form_data = { + **_select_form_data(), + 'node_title': 'Review', + 'form_content': 'Choose one', + 'workflow_run_id': 'workflow-run-12345678', + 'actions': [{'id': 'approve', 'title': 'Approve'}], + } + + await adapter._send_form_action_buttons(event, form_data) + + args = bot.send_message.await_args.kwargs + rows = args['reply_markup'].inline_keyboard + assert [[button.text for button in row] for row in rows] == [['A', 'B'], ['C']] + callback_data = rows[0][1].callback_data + assert len(callback_data.encode('utf-8')) <= 64 + assert json.loads(callback_data)['x'] == 1 + assert callback_data in adapter._form_action_titles + + +@pytest.mark.asyncio +async def test_telegram_text_field_does_not_show_action_buttons(): + bot = MagicMock() + bot.send_message = AsyncMock() + adapter = TelegramAdapter.model_construct(bot=bot, config={}, msg_stream_id={}, seq=1, listeners={}) + adapter._form_action_titles = {} + + update = MagicMock() + update.effective_chat.id = 123 + update.effective_message.message_thread_id = None + event = platform_events.FriendMessage( + sender=platform_entities.Friend(id='user-1', nickname='', remark=''), + message_chain=platform_message.MessageChain([]), + source_platform_object=update, + ) + form_data = { + '_current_input_field': 'us_input', + 'input_defs': [{'output_variable_name': 'us_input', 'type': 'paragraph'}], + 'node_title': '人工介入', + 'form_content': 'us_input (paragraph): reply "us_input: "', + 'workflow_run_id': 'workflow-run-12345678', + 'actions': [{'id': 'yes', 'title': 'yes'}, {'id': 'no', 'title': 'no'}], + } + + await adapter._send_form_action_buttons(event, form_data) + + args = bot.send_message.await_args.kwargs + assert isinstance(args['reply_markup'], ForceReply) + assert args['reply_markup'].selective is False + assert args['reply_markup'].input_field_placeholder == 'us_input' + assert 'Please reply' not in args['text'] + assert args['text'].startswith('[人工介入]') + assert 'us_input (paragraph)' in args['text'] + assert adapter._form_action_titles == {} diff --git a/tests/unit_tests/platform/test_wecombot_template_card.py b/tests/unit_tests/platform/test_wecombot_template_card.py new file mode 100644 index 000000000..c7ebfbee3 --- /dev/null +++ b/tests/unit_tests/platform/test_wecombot_template_card.py @@ -0,0 +1,475 @@ +import sys +import types + +import pytest + + +logger_module = types.ModuleType('langbot.pkg.platform.logger') +logger_module.EventLogger = object +sys.modules.setdefault('langbot.pkg.platform.logger', logger_module) + +from langbot.libs.wecom_ai_bot_api.api import ( # noqa: E402 + WecomBotClient, + build_button_interaction_payload, + build_human_input_template_card_payload, + build_button_interaction_update_card, + build_multiple_interaction_update_card, + extract_template_card_event_payload, + extract_template_card_selections, + extract_wecom_event_type, + extract_template_card_action, + build_human_input_text_prompt, + parse_select_button_action, +) +from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient # noqa: E402 + + +def test_extract_template_card_action_supports_nested_button_key(): + task_id, event_key, card_type = extract_template_card_action( + { + 'taskId': 'task-1', + 'cardType': 'button_interaction', + 'button': {'key': 'approve'}, + } + ) + + assert task_id == 'task-1' + assert event_key == 'approve' + assert card_type == 'button_interaction' + + +def test_extract_wecom_event_type_supports_top_level_template_card_event(): + payload = { + 'eventtype': 'template_card_event', + 'template_card_event': { + 'TaskId': 'task-1', + 'CardType': 'multiple_interaction', + 'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}', + }, + } + + assert extract_wecom_event_type(payload) == 'template_card_event' + assert extract_template_card_event_payload(payload)['TaskId'] == 'task-1' + + +def test_extract_wecom_event_type_infers_template_card_event_from_top_level_card_fields(): + payload = { + 'TaskId': 'task-1', + 'CardType': 'button_interaction', + 'EventKey': 'approve', + } + + assert extract_wecom_event_type(payload) == 'template_card_event' + assert extract_template_card_event_payload(payload)['EventKey'] == 'approve' + + +def test_build_button_interaction_update_card_marks_clicked_button(): + card = build_button_interaction_update_card( + { + 'node_title': 'Manual Review', + 'form_content': 'Please choose one action.', + 'actions': [ + {'id': 'approve', 'title': 'Approve', 'button_style': 'primary'}, + {'id': 'reject', 'title': 'Reject', 'button_style': 'danger'}, + ], + }, + task_id='task-1', + action_id='reject', + source={'desc': 'LangBot'}, + ) + + assert card['main_title'] == {'title': 'Manual Review'} + assert card['sub_title_text'] == 'Please choose one action.' + assert card['button_list'][0] == {'text': 'Approve', 'style': 2, 'key': 'approve'} + assert card['button_list'][1] == { + 'text': '✅ Reject', + 'style': 1, + 'key': 'reject', + 'replace_text': '✅ Reject', + } + assert card['source'] == {'desc': 'LangBot'} + + +def test_build_button_interaction_payload_uses_preselected_button_styles_before_click(): + payload = build_button_interaction_payload( + { + 'node_title': 'Manual Review', + 'actions': [ + {'id': 'approve', 'title': 'Approve', 'button_style': 'primary'}, + {'id': 'reject', 'title': 'Reject', 'button_style': 'danger'}, + ], + }, + task_id='task-1', + ) + + assert payload['template_card']['button_list'] == [ + {'text': 'Approve', 'style': 2, 'key': 'approve'}, + {'text': 'Reject', 'style': 2, 'key': 'reject'}, + ] + + +def test_build_payload_uses_multiple_interaction_for_pending_select_field(): + payload = build_human_input_template_card_payload( + { + 'node_title': 'Manual Review', + 'form_content': 'Choose a label\n\n{{#$output.choice#}}', + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + 'inputs': {}, + 'actions': [{'id': 'yes', 'title': 'Yes'}], + }, + task_id='task-1', + source={'desc': 'LangBot'}, + ) + + card = payload['template_card'] + assert card['card_type'] == 'multiple_interaction' + assert card['source'] == {'desc': 'LangBot'} + assert card['select_list'] == [ + { + 'question_key': 'choice', + 'title': 'choice', + 'selected_id': 'opt_1', + 'option_list': [ + {'id': 'opt_1', 'text': 'A'}, + {'id': 'opt_2', 'text': 'B'}, + ], + } + ] + assert card['submit_button'] == {'text': 'Submit', 'key': 'submit_human_input'} + + +def test_build_payload_can_emulate_select_as_buttons_for_wecombot_ws(): + form_data = { + 'node_title': 'Manual Review', + 'form_content': 'Choose a label\n\n{{#$output.choice#}}', + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + 'inputs': {}, + 'actions': [{'id': 'yes', 'title': 'Yes'}], + } + payload = build_human_input_template_card_payload( + form_data, + task_id='task-1', + select_as_buttons=True, + ) + + card = payload['template_card'] + assert card['card_type'] == 'button_interaction' + assert card['button_list'][0]['text'] == 'A' + assert parse_select_button_action(card['button_list'][1]['key'], form_data) == {'choice': 'B'} + + +def test_text_input_card_uses_current_stage_content_without_direct_reply_prompt(): + payload = build_human_input_template_card_payload( + { + 'node_title': '人工介入', + 'form_content': '11\n请输入你的问题\n\n{{#$output.us_input#}}', + 'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'), + 'input_defs': [ + { + 'output_variable_name': 'us_input', + 'type': 'paragraph', + 'label': '请输入你的问题', + } + ], + 'inputs': {}, + 'actions': [{'id': 'yes', 'title': 'yes'}], + '_current_input_field': 'us_input', + }, + task_id='task-1', + ) + + card = payload['template_card'] + assert 'desc' not in card['main_title'] + assert card['sub_title_text'] == '11\n请输入你的问题' + assert card['button_list'] == [] + + +def test_build_human_input_text_prompt_for_current_text_field(): + prompt = build_human_input_text_prompt( + { + 'node_title': '人工介入', + 'form_content': '11\n请输入你的问题\n{{#$output.us_input#}}', + 'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'), + 'input_defs': [ + { + 'output_variable_name': 'us_input', + 'type': 'paragraph', + 'label': '请输入你的问题', + } + ], + '_current_input_field': 'us_input', + } + ) + + assert prompt == '人工介入\n\n11\n请输入你的问题' + + +@pytest.mark.asyncio +async def test_ws_push_form_pause_sends_text_prompt_without_empty_card(): + client = WecomBotWsClient('bot-id', 'secret', object()) + client._stream_ids['msg-1'] = 'req-1|stream-1' + client._stream_sessions['msg-1'] = {'user_id': 'user-1'} + sent = [] + + async def fake_reply_text(req_id, content): + sent.append((req_id, content)) + return {} + + client.reply_text = fake_reply_text + + ok, stream_id, task_id = await client.push_form_pause( + 'msg-1', + { + 'node_title': '人工介入', + 'form_content': '11\n请输入你的问题\n{{#$output.us_input#}}', + 'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'), + 'input_defs': [ + { + 'output_variable_name': 'us_input', + 'type': 'paragraph', + 'label': '请输入你的问题', + } + ], + '_current_input_field': 'us_input', + }, + ) + + assert ok is True + assert stream_id == 'stream-1' + assert task_id is None + assert sent == [('req-1', '人工介入\n\n11\n请输入你的问题')] + assert client._pending_forms_by_task == {} + assert 'msg-1' not in client._stream_ids + + +@pytest.mark.asyncio +async def test_ws_stream_sends_cumulative_snapshots_to_wecom(): + client = WecomBotWsClient('bot-id', 'secret', object()) + client._stream_ids['msg-1'] = 'req-1|stream-1' + client._stream_sessions['msg-1'] = {} + sent = [] + + async def fake_reply_stream(req_id, stream_id, content, finish=False, feedback_id=''): + sent.append((req_id, stream_id, content, finish)) + return {} + + client.reply_stream = fake_reply_stream + + assert await client.push_stream_chunk('msg-1', '你', is_final=False) + assert await client.push_stream_chunk('msg-1', '你好', is_final=False) + assert await client.push_stream_chunk('msg-1', '你好', is_final=True) + + assert sent == [ + ('req-1', 'stream-1', '你', False), + ('req-1', 'stream-1', '你好', False), + ('req-1', 'stream-1', '你好', True), + ] + + +@pytest.mark.asyncio +async def test_webhook_stream_queues_cumulative_snapshots_for_followups(): + client = WecomBotClient('', '', '', object(), unified_mode=True) + session, _ = client.stream_sessions.create_or_get({'msgid': 'msg-1', 'chatid': '', 'from': {'userid': 'user-1'}}) + + assert await client.push_stream_chunk('msg-1', '你', is_final=False) + assert await client.push_stream_chunk('msg-1', '你好', is_final=False) + assert await client.push_stream_chunk('msg-1', '你好', is_final=True) + + chunks = [ + await client.stream_sessions.consume(session.stream_id), + await client.stream_sessions.consume(session.stream_id), + await client.stream_sessions.consume(session.stream_id), + ] + assert [(chunk.content, chunk.is_final) for chunk in chunks] == [ + ('你', False), + ('你好', False), + ('你好', True), + ] + + +def test_human_input_payload_keeps_action_select_stage_as_buttons(): + payload = build_human_input_template_card_payload( + { + 'node_title': 'Manual Review', + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + 'inputs': {'choice': 'B'}, + 'actions': [ + {'id': 'approve', 'title': 'Approve'}, + {'id': 'reject', 'title': 'Reject'}, + ], + '_action_select_only': True, + }, + task_id='task-1', + ) + + card = payload['template_card'] + assert card['card_type'] == 'button_interaction' + assert card['button_list'] == [ + {'text': 'Approve', 'style': 2, 'key': 'approve'}, + {'text': 'Reject', 'style': 2, 'key': 'reject'}, + ] + + +def test_extract_template_card_selections_maps_selected_id_to_option_text(): + selections = extract_template_card_selections( + { + 'SelectedItems': [ + {'QuestionKey': 'choice', 'SelectedId': 'opt_2'}, + ], + }, + { + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + }, + ) + + assert selections == {'choice': 'B'} + + +def test_extract_template_card_selections_reads_nested_response_data_json(): + selections = extract_template_card_selections( + { + 'CardType': 'multiple_interaction', + 'ResponseData': '{"select_list":[{"question_key":"choice","option_id":"opt_2"}]}', + }, + { + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + }, + ) + + assert selections == {'choice': 'B'} + + +def test_extract_template_card_selections_reads_response_data_direct_mapping(): + selections = extract_template_card_selections( + { + 'CardType': 'multiple_interaction', + 'EventKey': 'submit_human_input', + 'ResponseData': '{"choice":"opt_2"}', + }, + { + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + }, + ) + + assert selections == {'choice': 'B'} + + +def test_build_multiple_interaction_update_card_disables_selected_value_without_submitted_text(): + card = build_multiple_interaction_update_card( + { + 'node_title': 'Manual Review', + 'form_content': 'Choose a label\n{{#$output.choice#}}', + 'raw_form_content': 'Choose a label\n{{#$output.choice#}}', + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + '_current_input_field': 'choice', + }, + task_id='task-1', + selections={'choice': 'B'}, + ) + + assert card['card_type'] == 'multiple_interaction' + assert card['main_title']['desc'] == 'Choose a label\n✅ choice:B' + assert card['submit_button']['text'] == '✅' + assert card['select_list'][0]['disable'] is True + assert card['select_list'][0]['selected_id'] == 'opt_2' + + +def test_select_stage_only_shows_current_prompt_in_a_separate_message(): + raw_content = '11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}' + payload = build_human_input_template_card_payload( + { + 'node_title': '人工介入', + 'form_content': '请选择你的答案\n{{#$output.xiala#}}', + 'raw_form_content': raw_content, + 'input_defs': [ + { + 'output_variable_name': 'xiala', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['1', '2']}, + } + ], + 'all_input_defs': [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + { + 'output_variable_name': 'xiala', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['1', '2']}, + }, + ], + 'inputs': {'us_input': '你叫啥'}, + '_current_input_field': 'xiala', + }, + task_id='task-1', + ) + + assert payload['template_card']['main_title']['desc'] == '请选择你的答案' + + +def test_action_stage_only_shows_content_after_fields_without_placeholders(): + raw_content = '11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}\n请选择操作' + payload = build_human_input_template_card_payload( + { + 'node_title': '人工介入', + 'form_content': raw_content, + 'raw_form_content': raw_content, + 'input_defs': [], + 'all_input_defs': [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + {'output_variable_name': 'xiala', 'type': 'select'}, + ], + 'inputs': {'us_input': '你叫啥', 'xiala': '2'}, + 'actions': [ + {'id': 'yes', 'title': 'yes'}, + {'id': 'no', 'title': 'no'}, + ], + '_action_select_only': True, + }, + task_id='task-1', + ) + + card = payload['template_card'] + assert card['sub_title_text'] == '请选择操作' + assert '{{#$output.' not in card['sub_title_text'] + assert [button['text'] for button in card['button_list']] == ['yes', 'no'] diff --git a/tests/unit_tests/provider/runners/test_difysvapi_runner.py b/tests/unit_tests/provider/runners/test_difysvapi_runner.py index 366ef6d87..f75938209 100644 --- a/tests/unit_tests/provider/runners/test_difysvapi_runner.py +++ b/tests/unit_tests/provider/runners/test_difysvapi_runner.py @@ -6,6 +6,65 @@ Tests the helper methods that don't require real Dify API calls. from __future__ import annotations import pytest +from unittest.mock import AsyncMock, MagicMock + +import langbot_plugin.api.entities.builtin.platform.message as platform_message + + +class TestDifyWorkflowSubmitClient: + @pytest.mark.asyncio + async def test_rejects_empty_error_response_before_iterating_sse(self, monkeypatch): + from langbot.libs.dify_service_api.v1 import client, errors + + class FakeResponse: + status_code = 503 + + async def aread(self): + return b'' + + async def aiter_lines(self): + raise AssertionError('error responses must not enter the SSE loop') + yield + + class FakeStreamContext: + async def __aenter__(self): + return FakeResponse() + + async def __aexit__(self, exc_type, exc, traceback): + return False + + class FakeClient: + def __init__(self, **kwargs): + del kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback): + return False + + async def post(self, *args, **kwargs): + del args, kwargs + response = MagicMock() + response.status_code = 200 + return response + + def stream(self, *args, **kwargs): + del args, kwargs + return FakeStreamContext() + + monkeypatch.setattr(client.httpx, 'AsyncClient', FakeClient) + dify_client = client.AsyncDifyServiceClient('test-key', 'https://dify.example/v1') + + with pytest.raises(errors.DifyAPIError, match='503'): + await anext( + dify_client.workflow_submit( + form_token='token-1', + workflow_run_id='run-1', + inputs={}, + user='person_user-1', + ) + ) class TestDifyExtractTextOutput: @@ -125,7 +184,7 @@ class TestDifyRunnerConfigValidation: mock_app = MagicMock() - for app_type in ['chat', 'agent', 'workflow']: + for app_type in ['chat', 'agent', 'workflow', 'chatflow']: pipeline_config = { 'ai': { 'dify-service-api': { @@ -167,3 +226,964 @@ class TestDifyRunnerInit: assert runner.pipeline_config == pipeline_config assert runner.ap == mock_app + + +class TestDifyHumanInputForms: + """Tests for Dify human-input form helpers.""" + + def _create_runner(self): + from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner + + mock_app = MagicMock() + mock_app.logger = MagicMock() + pipeline_config = { + 'ai': { + 'dify-service-api': { + 'app-type': 'workflow', + 'api-key': 'test-key', + 'base-url': 'https://api.dify.ai', + 'base-prompt': '', + } + }, + 'output': {'misc': {}}, + } + runner = DifyServiceAPIRunner(mock_app, pipeline_config) + runner.dify_client = MagicMock() + runner.dify_client.upload_file = AsyncMock(return_value={'id': 'upload-1'}) + return runner + + def test_pending_forms_are_isolated_by_bot_and_pipeline(self): + from langbot.pkg.provider.runners import difysvapi + + query_a = MagicMock() + query_a.bot_uuid = 'bot-a' + query_a.pipeline_uuid = 'pipeline-a' + query_a.session.launcher_type.value = 'person' + query_a.session.launcher_id = 'shared-user' + + query_b = MagicMock() + query_b.bot_uuid = 'bot-b' + query_b.pipeline_uuid = 'pipeline-a' + query_b.session.launcher_type.value = 'person' + query_b.session.launcher_id = 'shared-user' + + query_c = MagicMock() + query_c.bot_uuid = 'bot-a' + query_c.pipeline_uuid = 'pipeline-b' + query_c.session.launcher_type.value = 'person' + query_c.session.launcher_id = 'shared-user' + + key_a = difysvapi._session_key_from_query(query_a) + key_b = difysvapi._session_key_from_query(query_b) + key_c = difysvapi._session_key_from_query(query_c) + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form(key_a, {'form_token': 'token-a', 'workflow_run_id': 'run-a'}) + difysvapi._set_pending_form(key_b, {'form_token': 'token-b', 'workflow_run_id': 'run-b'}) + difysvapi._set_pending_form(key_c, {'form_token': 'token-c', 'workflow_run_id': 'run-c'}) + + assert key_a != key_b + assert key_a != key_c + assert difysvapi._get_pending_form_by_token(key_a, 'token-a') is not None + assert difysvapi._get_pending_form_by_token(key_a, 'token-b') is None + assert difysvapi._get_pending_form_by_token(key_a, 'token-c') is None + assert difysvapi._get_pending_form_by_token(key_b, 'token-b') is not None + assert difysvapi._get_pending_form_by_token(key_c, 'token-c') is not None + assert difysvapi._get_latest_pending_form(key_a)['workflow_run_id'] == 'run-a' + assert difysvapi._get_latest_pending_form(key_b)['workflow_run_id'] == 'run-b' + assert difysvapi._get_latest_pending_form(key_c)['workflow_run_id'] == 'run-c' + assert difysvapi._get_latest_pending_form(key_a)['pipeline_uuid'] == 'pipeline-a' + assert difysvapi._get_latest_pending_form(key_c)['pipeline_uuid'] == 'pipeline-b' + assert difysvapi._dify_user_from_query(query_a) == difysvapi._dify_user_from_query(query_b) + assert difysvapi._dify_user_from_query(query_a) == difysvapi._dify_user_from_query(query_c) + difysvapi._PENDING_FORMS.clear() + + def test_interactive_form_data_preserves_pipeline_uuid(self): + from langbot.pkg.provider.runners import difysvapi + + pending_form = { + 'pipeline_uuid': 'pipeline-routed', + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}], + 'actions': [{'id': 'approve', 'title': 'Approve'}], + } + + field_form = difysvapi._field_input_form_data(pending_form, pending_form['input_defs'][0]) + action_form = difysvapi._action_select_form_data(pending_form) + + assert field_form['pipeline_uuid'] == 'pipeline-routed' + assert action_form['pipeline_uuid'] == 'pipeline-routed' + + def test_explicit_stale_form_identifiers_do_not_fall_back_to_latest(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + {'form_token': 'current-token', 'workflow_run_id': 'current-run'}, + ) + + assert runner._resolve_pending_form(session_key, {'form_token': 'stale-token'}) is None + assert runner._resolve_pending_form(session_key, {'workflow_run_id': 'stale-run'}) is None + assert runner._resolve_pending_form(session_key, {'w_suffix': 'stale-suffix'}) is None + assert runner._merge_pending_form_action(session_key, {'form_token': 'stale-token'}) is None + assert runner._resolve_pending_form(session_key, {})['form_token'] == 'current-token' + + difysvapi._PENDING_FORMS.clear() + + def test_format_human_input_text_includes_field_help(self): + from langbot.pkg.provider.runners.difysvapi import _format_human_input_text + + text = _format_human_input_text( + 'Manual Review', + 'Please fill fields.', + [{'id': 'yes', 'title': 'Yes'}], + [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + {'output_variable_name': 'attachment', 'type': 'file-list', 'number_limits': 2}, + ], + ) + + assert 'comment: ' in text + assert 'choice (select): 1. A, 2. B' in text + assert 'attachment (file-list' in text + assert 'action: ' in text + + def test_form_snapshot_for_platform_omits_action_text_and_placeholders(self): + from langbot.pkg.provider.runners.difysvapi import _extract_form_snapshot + + snapshot, _, _, display_form_content = _extract_form_snapshot( + 'run-1', + { + 'form_token': 'token-1', + 'node_title': 'Manual Review', + 'form_content': 'Hello\n\n{{#$output.comment#}}\n\n{{#$output.choice#}}\n', + 'inputs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + ], + 'actions': [{'id': 'yes', 'title': 'Yes'}], + }, + 'person_user-1', + ) + + assert '{{#$output.comment#}}' not in display_form_content + assert 'Actions:' not in display_form_content + assert 'comment (paragraph)' in display_form_content + assert snapshot['form_content'] == display_form_content + + def test_interactive_form_content_is_split_by_field_placeholders(self): + from langbot.pkg.provider.runners.difysvapi import ( + _action_select_form_data, + _field_input_form_data, + ) + + input_defs = [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + { + 'output_variable_name': 'xiala', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['1', '2']}, + }, + ] + pending_form = { + 'raw_form_content': ( + '1\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}\n提交前请确认' + ), + 'input_defs': input_defs, + 'actions': [{'id': 'yes', 'title': 'yes'}], + 'inputs': {}, + } + + first_step = _field_input_form_data(pending_form, input_defs[0]) + second_step = _field_input_form_data(pending_form, input_defs[1]) + action_step = _action_select_form_data(pending_form) + + assert first_step['form_content'] == '1\n请输入你的问题' + assert second_step['form_content'] == '请选择你的答案' + assert action_step['form_content'] == '提交前请确认' + + def test_interactive_form_content_without_placeholder_uses_compatibility_fallback(self): + from langbot.pkg.provider.runners.difysvapi import _field_input_form_data + + field = {'output_variable_name': 'comment', 'type': 'paragraph'} + + form_data = _field_input_form_data({'raw_form_content': 'Please review', 'input_defs': [field]}, field) + + assert form_data['form_content'] == 'comment (paragraph): reply "comment: "' + + @pytest.mark.asyncio + async def test_match_pending_form_collects_select_and_text_inputs(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Manual Review', + 'actions': [{'id': 'yes', 'title': 'Yes'}], + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + ], + 'inputs': {}, + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + action = await runner._match_pending_form_action( + query, + session_key, + 'action: yes\ncomment: looks good\nchoice: 2', + ) + + assert action['action_id'] == 'yes' + assert action['inputs'] == {'comment': 'looks good', 'choice': 'B'} + + @pytest.mark.asyncio + async def test_collect_form_inputs_uploads_files(self): + runner = self._create_runner() + image = platform_message.Image(base64='data:image/png;base64,aGVsbG8=') + query = MagicMock() + query.message_chain = platform_message.MessageChain([image]) + + inputs = await runner._collect_form_inputs_from_query( + query, + { + 'input_defs': [{'output_variable_name': 'photo', 'type': 'file'}], + 'inputs': {}, + 'user': 'person_user-1', + }, + '', + ) + + assert inputs['photo'] == { + 'type': 'image', + 'transfer_method': 'local_file', + 'upload_file_id': 'upload-1', + } + + @pytest.mark.asyncio + async def test_partial_input_with_multiple_actions_waits_for_missing_fields(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Manual Review', + 'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}], + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + ], + 'inputs': {}, + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + action = await runner._match_pending_form_action(query, session_key, 'comment: ready') + + assert action['_partial'] is True + assert action['inputs'] == {'comment': 'ready'} + assert action['_form_data']['_current_input_field'] == 'choice' + assert 'choice (select)' in action['notice'] + assert difysvapi._get_latest_pending_form(session_key)['inputs'] == {'comment': 'ready'} + + @pytest.mark.asyncio + async def test_complete_partial_input_with_multiple_actions_renders_action_form(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Manual Review', + 'raw_form_content': 'Please review\n\n{{#$output.comment#}}\n', + 'form_content': 'Please review\n\nFields:\n - comment (paragraph): reply "comment: "', + 'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}], + 'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}], + 'inputs': {}, + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + action = await runner._match_pending_form_action(query, session_key, 'comment: ready') + + assert action['_partial'] is True + assert action['inputs'] == {'comment': 'ready'} + assert 'action: ' in action['notice'] + assert action['_form_data']['_action_select_only'] is True + assert action['_form_data']['input_defs'] == [] + assert action['_form_data']['actions'] == [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}] + assert '{{#$output.comment#}}' not in action['_form_data']['form_content'] + assert difysvapi._get_latest_pending_form(session_key)['inputs'] == {'comment': 'ready'} + + @pytest.mark.asyncio + async def test_sequential_field_collection_advances_one_field_at_a_time(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Manual Review', + 'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}], + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + ], + 'inputs': {}, + 'current_input_field': 'comment', + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + first = await runner._match_pending_form_action(query, session_key, 'looks good') + + assert first['_partial'] is True + assert first['inputs'] == {'comment': 'looks good'} + assert first['_form_data']['_current_input_field'] == 'choice' + assert first['_form_data']['input_defs'][0]['output_variable_name'] == 'comment' + assert 'choice (select)' in first['_form_data']['form_content'] + + second = await runner._match_pending_form_action(query, session_key, '2') + + assert second['_partial'] is True + assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'} + assert second['_form_data']['_action_select_only'] is True + + @pytest.mark.asyncio + async def test_digit_reply_fills_missing_select_before_matching_action_number(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Manual Review', + 'actions': [ + {'id': 'yes', 'title': 'yes'}, + {'id': 'no', 'title': 'no'}, + {'id': 'or', 'title': 'or'}, + {'id': 'but', 'title': 'but'}, + ], + 'input_defs': [ + {'output_variable_name': 'us_input', 'type': 'paragraph'}, + { + 'output_variable_name': 'xiala', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['1', '2']}, + }, + ], + 'inputs': {'us_input': 'hello'}, + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + action = await runner._match_pending_form_action(query, session_key, '2') + + assert action['_partial'] is True + assert action['inputs'] == {'us_input': 'hello', 'xiala': '2'} + assert action['_form_data']['_action_select_only'] is True + + @pytest.mark.asyncio + async def test_invalid_select_reply_keeps_the_current_form_field(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'actions': [{'id': 'yes', 'title': 'Yes'}], + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ], + 'inputs': {}, + 'current_input_field': 'choice', + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + action = await runner._match_pending_form_action(query, session_key, 'C') + + assert action['_partial'] is True + assert action['inputs'] == {} + assert action['_form_data']['_current_input_field'] == 'choice' + assert 'choice: 1. A, 2. B' in action['notice'] + assert difysvapi._get_latest_pending_form(session_key)['inputs'] == {} + + @pytest.mark.asyncio + async def test_workflow_pause_without_text_yields_form_chunk(self): + from langbot.pkg.provider.runners import difysvapi + + async def workflow_run(**kwargs): + del kwargs + yield { + 'event': 'workflow_started', + 'data': {'workflow_run_id': 'run-1'}, + } + yield { + 'event': 'workflow_paused', + 'data': { + 'workflow_run_id': 'run-1', + 'reasons': [ + { + 'TYPE': 'human_input_required', + 'form_token': 'token-1', + 'node_title': 'Manual Review', + 'form_content': 'Please review\n\n{{#$output.comment#}}\n', + 'inputs': [{'output_variable_name': 'comment', 'type': 'paragraph'}], + 'actions': [{'id': 'yes', 'title': 'Yes'}], + } + ], + }, + } + + runner = self._create_runner() + runner.dify_client.workflow_run = workflow_run + query = MagicMock() + query.session.launcher_type.value = 'person' + query.session.launcher_id = 'user-1' + query.session.using_conversation.uuid = 'conversation-1' + query.variables = { + 'session_id': 'session-1', + 'conversation_id': 'conversation-1', + 'msg_create_time': '0', + } + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='hello')]) + + difysvapi._PENDING_FORMS.clear() + chunks = [chunk async for chunk in runner._workflow_messages_chunk(query)] + + assert chunks[-1].is_final is True + assert chunks[-1].content == difysvapi._STREAM_FORM_PLACEHOLDER + assert chunks[-1]._form_data['form_token'] == 'token-1' + assert chunks[-1]._form_data['_current_input_field'] == 'comment' + + @pytest.mark.asyncio + async def test_action_after_partial_input_reuses_saved_inputs(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'node_title': 'Manual Review', + 'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}], + 'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}], + 'inputs': {}, + 'user': session_key, + }, + ) + query = MagicMock() + query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')]) + + await runner._match_pending_form_action(query, session_key, 'comment: ready') + action = await runner._match_pending_form_action(query, session_key, 'action: yes') + + assert action.get('_partial') is not True + assert action['action_id'] == 'yes' + assert action['inputs'] == {'comment': 'ready'} + + def test_form_action_merges_card_inputs_with_saved_inputs(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'actions': [{'id': 'yes', 'title': 'Yes'}], + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + {'output_variable_name': 'photo', 'type': 'file'}, + ], + 'inputs': {'photo': {'type': 'image', 'transfer_method': 'local_file', 'upload_file_id': 'upload-1'}}, + 'user': session_key, + }, + ) + + action = runner._merge_pending_form_action( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'action_id': 'yes', + 'inputs': {'comment': 'ready'}, + }, + ) + + assert action['inputs'] == { + 'comment': 'ready', + 'photo': {'type': 'image', 'transfer_method': 'local_file', 'upload_file_id': 'upload-1'}, + } + + def test_form_action_with_missing_required_file_fields_stays_partial(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'actions': [{'id': 'yes', 'title': 'Yes'}], + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + {'output_variable_name': 'file', 'type': 'file'}, + {'output_variable_name': 'files', 'type': 'file-list', 'number_limits': 5}, + ], + 'inputs': {}, + 'user': session_key, + }, + ) + + action = runner._merge_pending_form_action( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'action_id': 'yes', + 'inputs': {'comment': 'ready'}, + }, + ) + + assert action['_partial'] is True + assert action['inputs'] == {'comment': 'ready'} + assert 'file, files' in action['notice'] + assert action['_form_data']['_current_input_field'] == 'file' + assert action['_form_data']['input_defs'][1]['output_variable_name'] == 'file' + + def test_card_component_input_progress_maps_to_current_field(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'actions': [{'id': 'yes', 'title': 'Yes'}, {'id': 'no', 'title': 'No'}], + 'input_defs': [ + {'output_variable_name': 'comment', 'type': 'paragraph'}, + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + }, + ], + 'inputs': {}, + 'current_input_field': 'comment', + 'user': session_key, + }, + ) + + first = runner._merge_pending_form_action( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'inputs': {'input': 'looks good'}, + '_current_input_field': 'comment', + '_input_progress': True, + }, + ) + + assert first['_partial'] is True + assert first['inputs'] == {'comment': 'looks good'} + assert first['_form_data']['_current_input_field'] == 'choice' + + second = runner._merge_pending_form_action( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'inputs': {'select': '{"index": 1, "value": "B"}'}, + '_current_input_field': 'choice', + '_input_progress': True, + }, + ) + + assert second['_partial'] is True + assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'} + assert second['_form_data']['_action_select_only'] is True + + def test_card_select_preserves_numeric_option_values(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + session_key = 'person_user-1' + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'actions': [{'id': 'yes', 'title': 'Yes'}], + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['1', '2']}, + } + ], + 'inputs': {}, + 'current_input_field': 'choice', + 'user': session_key, + }, + ) + + action = runner._merge_pending_form_action( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'inputs': {'select': '1'}, + '_current_input_field': 'choice', + '_input_progress': True, + }, + ) + + assert action['_partial'] is True + assert action['inputs'] == {'choice': '1'} + + def test_invalid_card_select_value_is_not_saved(self): + from langbot.pkg.provider.runners import difysvapi + + form = { + 'input_defs': [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'type': 'constant', 'value': ['A', 'B']}, + } + ] + } + + assert difysvapi._normalize_form_action_inputs(form, {'choice': 'C'}) == {} + + @pytest.mark.asyncio + async def test_blocking_resume_uses_chatflow_answer_node_output(self): + runner = self._create_runner() + + async def workflow_submit(**kwargs): + del kwargs + yield {'event': 'message', 'answer': 'partial'} + yield { + 'event': 'node_finished', + 'data': { + 'node_type': 'answer', + 'outputs': {'answer': {'content': 'Final chatflow answer'}}, + }, + } + yield {'event': 'workflow_finished', 'data': {'error': None, 'outputs': {}}} + + runner.dify_client.workflow_submit = workflow_submit + + messages = [ + message + async for message in runner._submit_workflow_form_blocking( + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'user': 'person_user-1', + 'action_id': 'yes', + 'inputs': {}, + }, + ('bot-1', 'pipeline-1', 'adapter.Type', 'person', 'user-1'), + ) + ] + + assert [message.content for message in messages] == ['Final chatflow answer'] + + @pytest.mark.asyncio + async def test_successful_blocking_resume_clears_pending_form(self): + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + difysvapi._PENDING_FORMS.clear() + + async def workflow_submit(**kwargs): + del kwargs + yield { + 'event': 'workflow_finished', + 'data': {'error': None, 'outputs': {'summary': 'Completed'}}, + } + + runner.dify_client.workflow_submit = workflow_submit + query = MagicMock() + query.session.launcher_type.value = 'person' + query.session.launcher_id = 'user-1' + query.bot_uuid = 'bot-1' + query.pipeline_uuid = 'pipeline-1' + session_key = difysvapi._session_key_from_query(query) + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'actions': [{'id': 'yes', 'title': 'Yes'}], + 'inputs': {}, + 'user': 'person_user-1', + }, + ) + query.variables = { + '_dify_form_action': { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'action_id': 'yes', + 'user': 'person_user-1', + 'inputs': {}, + } + } + + messages = [message async for message in runner._workflow_messages(query)] + + assert [message.content for message in messages] == ['Completed'] + assert difysvapi._get_pending_form_by_token(session_key, 'token-1') is None + + @pytest.mark.asyncio + async def test_incomplete_blocking_resume_keeps_pending_form_for_retry(self): + from langbot.libs.dify_service_api.v1.errors import DifyAPIError + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + difysvapi._PENDING_FORMS.clear() + + async def workflow_submit(**kwargs): + del kwargs + yield {'event': 'message', 'answer': 'partial answer'} + + runner.dify_client.workflow_submit = workflow_submit + query = MagicMock() + query.session.launcher_type.value = 'person' + query.session.launcher_id = 'user-1' + query.bot_uuid = 'bot-1' + query.pipeline_uuid = 'pipeline-1' + session_key = difysvapi._session_key_from_query(query) + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'actions': [{'id': 'yes', 'title': 'Yes'}], + 'inputs': {}, + 'user': 'person_user-1', + }, + ) + query.variables = { + '_dify_form_action': { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'action_id': 'yes', + 'user': 'person_user-1', + 'inputs': {}, + } + } + + with pytest.raises(DifyAPIError, match='before a terminal event'): + _ = [message async for message in runner._workflow_messages(query)] + + assert difysvapi._get_pending_form_by_token(session_key, 'token-1') is not None + difysvapi._PENDING_FORMS.clear() + + @pytest.mark.asyncio + async def test_incomplete_streaming_resume_keeps_pending_form_for_retry(self): + from langbot.libs.dify_service_api.v1.errors import DifyAPIError + from langbot.pkg.provider.runners import difysvapi + + runner = self._create_runner() + query = MagicMock() + query.session.launcher_type.value = 'person' + query.session.launcher_id = 'user-1' + query.bot_uuid = 'bot-1' + query.pipeline_uuid = 'pipeline-1' + query.variables = { + '_dify_form_action': { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'action_id': 'yes', + 'user': 'person_user-1', + 'inputs': {}, + } + } + session_key = difysvapi._session_key_from_query(query) + difysvapi._PENDING_FORMS.clear() + difysvapi._set_pending_form( + session_key, + { + 'form_token': 'token-1', + 'workflow_run_id': 'run-1', + 'actions': [{'id': 'yes', 'title': 'Yes'}], + 'inputs': {}, + 'user': 'person_user-1', + }, + ) + + async def workflow_submit(**kwargs): + del kwargs + yield {'event': 'text_chunk', 'data': {'text': 'partial answer'}} + + runner.dify_client.workflow_submit = workflow_submit + + with pytest.raises(DifyAPIError, match='before a terminal event'): + _ = [message async for message in runner._workflow_messages_chunk(query)] + + assert difysvapi._get_pending_form_by_token(session_key, 'token-1') is not None + difysvapi._PENDING_FORMS.clear() + + +class TestDifyCumulativeStreaming: + def _create_runner(self, *, remove_think: bool = False): + from langbot.pkg.provider.runners.difysvapi import DifyServiceAPIRunner + + mock_app = MagicMock() + mock_app.logger = MagicMock() + runner = DifyServiceAPIRunner( + mock_app, + { + 'ai': { + 'dify-service-api': { + 'app-type': 'chat', + 'api-key': 'test-key', + 'base-url': 'https://api.dify.ai', + 'base-prompt': '', + } + }, + 'output': {'misc': {'remove-think': remove_think}}, + }, + ) + runner.dify_client = MagicMock() + return runner + + @staticmethod + def _query(): + query = MagicMock() + query.session.launcher_type.value = 'person' + query.session.launcher_id = 'user-1' + query.session.using_conversation.uuid = 'conversation-1' + query.variables = {} + query.user_message.content = 'hello' + return query + + def test_merge_stream_text_accepts_deltas_and_snapshots(self): + from langbot.pkg.provider.runners.difysvapi import _merge_stream_text + + assert _merge_stream_text('Hello', ' world') == 'Hello world' + assert _merge_stream_text('one', 'one two') == 'one two' + assert _merge_stream_text('ha', 'ha') == 'haha' + + @pytest.mark.asyncio + async def test_chat_stream_deduplicates_cumulative_snapshots(self): + runner = self._create_runner() + snapshots = [f'Reasoning{"." * idx}' for idx in range(1, 10)] + snapshots.append(f'{snapshots[-1]}\nHello!') + + async def chat_messages(**kwargs): + del kwargs + for snapshot in snapshots: + yield {'event': 'message', 'answer': snapshot, 'conversation_id': 'conversation-2'} + yield {'event': 'message_end', 'conversation_id': 'conversation-2'} + + runner.dify_client.chat_messages = chat_messages + + chunks = [chunk async for chunk in runner._chat_messages_chunk(self._query())] + + assert chunks[-1].content == snapshots[-1] + assert chunks[-1].content.count('') == 1 + + @pytest.mark.asyncio + async def test_chat_stream_removes_think_from_cumulative_snapshots(self): + runner = self._create_runner(remove_think=True) + + async def chat_messages(**kwargs): + del kwargs + yield {'event': 'message', 'answer': 'Reasoning', 'conversation_id': 'conversation-2'} + yield { + 'event': 'message', + 'answer': 'Reasoning complete\nHello!', + 'conversation_id': 'conversation-2', + } + yield {'event': 'message_end', 'conversation_id': 'conversation-2'} + + runner.dify_client.chat_messages = chat_messages + + chunks = [chunk async for chunk in runner._chat_messages_chunk(self._query())] + + assert chunks[-1].content == 'Hello!' + assert all('' not in chunk.content for chunk in chunks if isinstance(chunk.content, str)) diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 3b3aafb6b..1dd03a640 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -269,6 +269,10 @@ export default function BotForm({ options: item.options, show_if: item.show_if, login_platform: item.login_platform, + url: item.url, + download_filename: item.download_filename, + help_links: item.help_links, + help_label: item.help_label, }), ), ); diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index da1860213..2c91e93f1 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -24,7 +24,15 @@ import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { Copy, Check, Globe, Info, QrCode } from 'lucide-react'; +import { + Copy, + Check, + Globe, + Info, + QrCode, + Download, + ExternalLink, +} from 'lucide-react'; import { copyToClipboard } from '@/app/utils/clipboard'; import { Tooltip, @@ -33,6 +41,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { systemInfo } from '@/app/infra/http'; +import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; /** * Resolve the value referenced by a `show_if.field` string. @@ -291,6 +300,52 @@ function WebhookUrlField({ ); } +function DownloadLinkField({ + label, + description, + url, + filename, + helpUrl, + helpLabel, +}: { + label: string; + description?: string; + url: string; + filename?: string; + helpUrl?: string | null; + helpLabel: string; +}) { + const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; + const downloadUrl = url.startsWith('http') ? url : `${baseUrl}${url}`; + + return ( + + {label} +
+ + {helpUrl && ( + + )} +
+ {description && ( +

+ {description} +

+ )} +
+ ); +} + /** * Display-only component for `__system.*` fields (e.g. the deployment's * outbound IPs that the operator must add to a platform's trusted-IP list). @@ -405,7 +460,7 @@ export default function DynamicFormComponent({ }) { const isInitialMount = useRef(true); const previousInitialValues = useRef(initialValues); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); // Normalize a form value according to its field type. // This ensures legacy/malformed data (e.g. a plain string for @@ -460,6 +515,7 @@ export default function DynamicFormComponent({ item.type !== 'webhook-url' && item.type !== 'embed-code' && item.type !== 'qr-code-login' && + item.type !== 'download-link' && !item.name.startsWith(SYSTEM_FIELD_PREFIX), ), [itemConfigList], @@ -777,6 +833,30 @@ export default function DynamicFormComponent({ ); } + if (config.type === 'download-link') { + if (!config.url) return null; + + return ( + + ); + } + // QR code login button (e.g. Feishu one-click create, WeChat scan login) if (config.type === 'qr-code-login') { return ( diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts index 9a1fd4371..f370f8923 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts @@ -18,6 +18,10 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema { options?: IDynamicFormItemOption[]; show_if?: IShowIfCondition; login_platform?: string; + url?: string; + download_filename?: string; + help_links?: Record; + help_label?: I18nObject; constructor(params: IDynamicFormItemSchema) { this.id = params.id; @@ -30,6 +34,10 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema { this.options = params.options; this.show_if = params.show_if; this.login_platform = params.login_platform; + this.url = params.url; + this.download_filename = params.download_filename; + this.help_links = params.help_links; + this.help_label = params.help_label; } } diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx index 2cc14493f..5865dbb4a 100644 --- a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx +++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx @@ -16,7 +16,12 @@ import { } from 'lucide-react'; import QRCode from 'qrcode'; -export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot'; +export type QrLoginPlatform = + | 'feishu' + | 'weixin' + | 'dingtalk' + | 'wecombot' + | 'qqofficial'; interface PlatformConfig { titleKey: string; @@ -29,6 +34,7 @@ interface PlatformConfig { apiBase: string; extractSuccess: (data: Record) => Record; successNoteKey?: string; + boundByKey?: string; } const PLATFORM_CONFIGS: Record = { @@ -92,6 +98,22 @@ const PLATFORM_CONFIGS: Record = { }), successNoteKey: 'wecombot.robotNameNote', }, + qqofficial: { + titleKey: 'qqofficial.createBinding', + connectingKey: 'qqofficial.connecting', + scanQRCodeKey: 'qqofficial.scanQRCode', + waitingKey: 'qqofficial.waitingForScan', + successKey: 'qqofficial.bindSuccess', + failedKey: 'qqofficial.bindFailed', + retryKey: 'qqofficial.retry', + apiBase: '/api/v1/platform/adapters/qqofficial/bind', + extractSuccess: (data) => ({ + appid: data.appid, + secret: data.secret, + }), + successNoteKey: 'qqofficial.tokenNote', + boundByKey: 'qqofficial.boundBy', + }, }; interface QrCodeLoginDialogProps { @@ -118,6 +140,7 @@ export default function QrCodeLoginDialog({ const [qrDataUrl, setQrDataUrl] = useState(''); const [expireIn, setExpireIn] = useState(0); const [errorMessage, setErrorMessage] = useState(''); + const [successMeta, setSuccessMeta] = useState(''); const pollTimerRef = useRef | null>(null); const countdownRef = useRef | null>(null); const checkExpiredRef = useRef | null>(null); @@ -178,6 +201,7 @@ export default function QrCodeLoginDialog({ setQrDataUrl(''); setExpireIn(0); setErrorMessage(''); + setSuccessMeta(''); const token = localStorage.getItem('token'); const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; @@ -275,6 +299,13 @@ export default function QrCodeLoginDialog({ sessionIdRef.current = null; cleanup(); setState('success'); + // Platform may return extra audit metadata (e.g. QQ Official returns + // the scanner's user_openid) — surface it briefly before the dialog closes. + if (rest.user_openid && cfg.boundByKey) { + setSuccessMeta( + tRef.current(cfg.boundByKey, { openid: rest.user_openid }), + ); + } setTimeout(() => { onSuccessRef.current(cfg.extractSuccess(rest)); onOpenChangeRef.current(false); @@ -395,6 +426,11 @@ export default function QrCodeLoginDialog({

{t(platformConfig.successKey)}

+ {successMeta && ( +

+ {successMeta} +

+ )} {platformConfig.successNoteKey && (

{t(platformConfig.successNoteKey)} diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index a97bd9992..6f945b678 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -44,6 +44,10 @@ export interface IDynamicFormItemSchema { scopes?: string[]; accept?: string; // For file type: accepted MIME types login_platform?: string; // For qr-code-login type: platform identifier (e.g. 'feishu', 'weixin') + url?: string; // For download-link type: relative or absolute download URL + download_filename?: string; // Optional filename for download-link type + help_links?: Record; // Optional docs links for display-only fields + help_label?: I18nObject; // Optional label for help_links } export enum DynamicFormItemType { @@ -72,6 +76,7 @@ export enum DynamicFormItemType { WEBHOOK_URL = 'webhook-url', EMBED_CODE = 'embed-code', QR_CODE_LOGIN = 'qr-code-login', + DOWNLOAD_LINK = 'download-link', } export interface IFileConfig { diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index a3afd07c4..04414fbd4 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -229,6 +229,10 @@ export default function WizardPage() { options: item.options, show_if: item.show_if, login_platform: item.login_platform, + url: item.url, + download_filename: item.download_filename, + help_links: item.help_links, + help_label: item.help_label, }), ); }, [adapters, selectedAdapter]); @@ -249,6 +253,10 @@ export default function WizardPage() { options: item.options, show_if: item.show_if, login_platform: item.login_platform, + url: item.url, + download_filename: item.download_filename, + help_links: item.help_links, + help_label: item.help_label, }), ); }, [selectedRunnerConfigStage]); diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 1d2d58ae2..2f9a71077 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1822,6 +1822,19 @@ const enUS = { robotNameNote: 'Robot Name cannot be obtained automatically. Please fill it in manually.', }, + qqofficial: { + createBinding: 'One-Click QR Binding for QQ Official Bot', + scanQRCode: + 'Scan the QR code below with mobile QQ and authorize the binding in QQ Bot Assistant', + waitingForScan: 'Waiting for scan', + bindSuccess: 'Bound successfully! AppID and Secret have been filled in', + bindFailed: 'Binding failed', + connecting: 'Connecting to QQ service...', + retry: 'Retry', + tokenNote: + 'The Token field is not used by the current adapter — you can leave it blank.', + boundBy: 'Bound by QQ user {{openid}}', + }, pluginPages: { selectFromSidebar: 'Select a plugin page from the sidebar', invalidPage: 'Invalid plugin page', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index a39c69c6e..6d25d0112 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -1740,6 +1740,20 @@ const esES = { robotNameNote: 'El nombre del robot no puede obtenerse automáticamente. Introdúcelo manualmente.', }, + qqofficial: { + createBinding: 'Vinculación QR con un clic para el bot oficial de QQ', + scanQRCode: + 'Escanea el código QR siguiente con QQ móvil y autoriza la vinculación en «QQ Bot Assistant»', + waitingForScan: 'Esperando escaneo', + bindSuccess: + '¡Vinculación correcta! AppID y Secret se han rellenado automáticamente', + bindFailed: 'Error en la vinculación', + connecting: 'Conectando con el servicio de QQ...', + retry: 'Reintentar', + tokenNote: + 'El campo Token no es utilizado por el adaptador actual; puedes dejarlo vacío.', + boundBy: 'Vinculado por el usuario QQ {{openid}}', + }, pluginPages: { selectFromSidebar: 'Selecciona una página de plugin en la barra lateral', invalidPage: 'Página de plugin no válida', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 0d6ca7363..e3d59c55f 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1733,6 +1733,19 @@ const jaJP = { retry: '再試行', robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。', }, + qqofficial: { + createBinding: 'ワンクリックで QQ 公式ボットを QR バインド', + scanQRCode: + '以下の QR コードをモバイル QQ でスキャンし、「QQ ボットアシスタント」でバインドを承認してください', + waitingForScan: 'スキャン待ち', + bindSuccess: 'バインド成功!AppID と Secret が自動入力されました', + bindFailed: 'バインド失敗', + connecting: 'QQ サービスに接続中...', + retry: '再試行', + tokenNote: + 'Token フィールドは現行アダプターでは使用しません。空欄のままで構いません。', + boundBy: 'QQ ユーザー {{openid}} によりバインドされました', + }, pluginPages: { selectFromSidebar: 'サイドバーからプラグインページを選択してください', invalidPage: '無効なプラグインページ', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 5c9a38791..0f6072c49 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1711,6 +1711,19 @@ const ruRU = { robotNameNote: 'Имя бота нельзя получить автоматически. Пожалуйста, введите его вручную.', }, + qqofficial: { + createBinding: 'Привязка официального бота QQ по QR-коду', + scanQRCode: + 'Отсканируйте QR-код ниже мобильным QQ и подтвердите привязку в «QQ Bot Assistant»', + waitingForScan: 'Ожидание сканирования', + bindSuccess: 'Привязка успешна! AppID и Secret заполнены автоматически', + bindFailed: 'Не удалось выполнить привязку', + connecting: 'Подключение к сервису QQ...', + retry: 'Повторить', + tokenNote: + 'Поле Token не используется текущим адаптером — его можно оставить пустым.', + boundBy: 'Привязано пользователем QQ {{openid}}', + }, pluginPages: { selectFromSidebar: 'Выберите страницу плагина на боковой панели', invalidPage: 'Недопустимая страница плагина', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index e714ef5e5..704daf9cd 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1672,6 +1672,18 @@ const thTH = { retry: 'ลองใหม่', robotNameNote: 'ไม่สามารถดึงชื่อบอตได้โดยอัตโนมัติ กรุณากรอกด้วยตนเอง', }, + qqofficial: { + createBinding: 'ผูกบอต QQ Official ด้วย QR คลิกเดียว', + scanQRCode: + 'สแกนคิวอาร์โค้ดด้านล่างด้วย QQ มือถือ แล้วอนุญาตการผูกใน «QQ Bot Assistant»', + waitingForScan: 'กำลังรอสแกน', + bindSuccess: 'ผูกสำเร็จ! AppID และ Secret ถูกกรอกอัตโนมัติแล้ว', + bindFailed: 'การผูกล้มเหลว', + connecting: 'กำลังเชื่อมต่อบริการ QQ...', + retry: 'ลองใหม่', + tokenNote: 'อะแดปเตอร์ปัจจุบันไม่ได้ใช้ฟิลด์ Token จึงเว้นว่างไว้ได้', + boundBy: 'ผูกโดยผู้ใช้ QQ {{openid}}', + }, pluginPages: { selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง', invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 99a39b2c2..b9770314e 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -1701,6 +1701,19 @@ const viVN = { retry: 'Thử lại', robotNameNote: 'Không thể tự động lấy tên bot. Vui lòng điền thủ công.', }, + qqofficial: { + createBinding: 'Liên kết bot QQ Official bằng QR một chạm', + scanQRCode: + 'Quét mã QR bên dưới bằng QQ trên di động và xác nhận liên kết trong «QQ Bot Assistant»', + waitingForScan: 'Đang chờ quét', + bindSuccess: 'Liên kết thành công! AppID và Secret đã được điền tự động', + bindFailed: 'Liên kết thất bại', + connecting: 'Đang kết nối tới dịch vụ QQ...', + retry: 'Thử lại', + tokenNote: + 'Bộ chuyển đổi hiện tại không dùng trường Token; có thể để trống.', + boundBy: 'Được liên kết bởi người dùng QQ {{openid}}', + }, pluginPages: { selectFromSidebar: 'Chọn một trang plugin từ thanh bên', invalidPage: 'Trang plugin không hợp lệ', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 972ec041d..a4a04e0de 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1741,6 +1741,17 @@ const zhHans = { retry: '重试', robotNameNote: '机器人名称无法自动获取,请手动填写。', }, + qqofficial: { + createBinding: '一键扫码绑定 QQ 机器人', + scanQRCode: '请使用手机 QQ 扫描以下二维码,在「QQ 机器人助手」中授权绑定', + waitingForScan: '等待扫码中', + bindSuccess: '绑定成功!AppID 与密钥已自动填入', + bindFailed: '绑定失败', + connecting: '正在连接 QQ 服务...', + retry: '重试', + tokenNote: 'Token 字段当前适配器未使用,留空即可。', + boundBy: '由 QQ 用户 {{openid}} 扫码绑定', + }, pluginPages: { selectFromSidebar: '从侧边栏选择一个插件页面', invalidPage: '无效的插件页面', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 61ecc4ea2..1ae98f758 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1650,6 +1650,17 @@ const zhHant = { retry: '重試', robotNameNote: '機器人名稱無法自動取得,請手動填寫。', }, + qqofficial: { + createBinding: '一鍵掃碼綁定 QQ 機器人', + scanQRCode: '請使用手機 QQ 掃描以下 QR Code,在「QQ 機器人助手」中授權綁定', + waitingForScan: '等待掃碼中', + bindSuccess: '綁定成功!AppID 與密鑰已自動填入', + bindFailed: '綁定失敗', + connecting: '正在連線 QQ 服務...', + retry: '重試', + tokenNote: 'Token 欄位目前介面卡未使用,留空即可。', + boundBy: '由 QQ 用戶 {{openid}} 掃碼綁定', + }, pluginPages: { selectFromSidebar: '從側邊欄選擇一個插件頁面', invalidPage: '無效的插件頁面',