feat: Enhance Telegram and QQ Official adapters with select field handling and form action processing

- Added support for select fields in Telegram adapter, including option extraction and callback handling.
- Implemented form action processing for Telegram callbacks, improving user interaction feedback.
- Introduced new helper functions for building keyboards and resolving select button actions in QQ Official adapter.
- Enhanced DifyServiceAPIRunner to handle cumulative streaming responses and improve error handling during workflow resumes.
- Added unit tests for new functionalities in Telegram and QQ Official adapters, ensuring robust behavior for select fields and form actions.
This commit is contained in:
fdc310
2026-07-11 11:16:59 +08:00
parent 98a633c712
commit 0cde4c71cd
11 changed files with 1181 additions and 108 deletions
+290
View File
@@ -68,6 +68,210 @@ def markdown_block(node_id, variable='content'):
} }
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( def text_block(
node_id, node_id,
text, text,
@@ -280,6 +484,8 @@ def build_editor_data():
'ButtonGroup', 'ButtonGroup',
'MarkdownBlock', 'MarkdownBlock',
'Avatar', 'Avatar',
'Input',
'SelectBlock',
] ]
components_map = [ components_map = [
{ {
@@ -396,6 +602,8 @@ def build_editor_data():
'conditionGroup': '', 'conditionGroup': '',
'children': [ 'children': [
avatar('node_avatar', name='LangBot'), avatar('node_avatar', name='LangBot'),
input_block('node_input'),
select_block('node_select'),
markdown_block('node_text_content', variable='content'), markdown_block('node_text_content', variable='content'),
button_group('node_btn_group'), button_group('node_btn_group'),
], ],
@@ -716,6 +924,15 @@ def build_editor_data():
'cardData': { 'cardData': {
'flowStatus': 3, 'flowStatus': 3,
'content': '请审核以下报销申请:\n\n- 申请人:张三\n- 金额:¥1,200\n- 类别:差旅', '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': [ 'btns': [
{ {
'text': '通过', 'text': '通过',
@@ -783,6 +1000,79 @@ def build_editor_data():
'editorVarType': 'variables', 'editorVarType': 'variables',
'disabled': False, '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, btns_var,
], ],
'formList': [], 'formList': [],
+72
View File
@@ -12,6 +12,78 @@ import traceback
from cryptography.hazmat.primitives.asymmetric import ed25519 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: 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. """Build a QQ keyboard JSON payload from a Dify human-input form_data.
+35 -9
View File
@@ -1203,6 +1203,11 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if not raw_action_id: if not raw_action_id:
await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}') await self.logger.warning(f'DingTalk: card action with no action_id, payload={payload}')
return 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_id = raw_action_id
action_title = raw_action_id action_title = raw_action_id
@@ -1217,7 +1222,17 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else provider_session.LauncherTypes.PERSON else provider_session.LauncherTypes.PERSON
) )
launcher_id = state.get('launcher_id', '') launcher_id = state.get('launcher_id', '')
sender_user_id = state.get('sender_user_id') or payload.get('user_id') or 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_action_data = {
'form_token': state.get('form_token', ''), 'form_token': state.get('form_token', ''),
@@ -1234,7 +1249,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if launcher_type == provider_session.LauncherTypes.GROUP: if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event = platform_events.GroupMessage( synthetic_event = platform_events.GroupMessage(
sender=platform_entities.GroupMember( sender=platform_entities.GroupMember(
id=sender_user_id, id=actor_user_id,
member_name='', member_name='',
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
group=platform_entities.Group( group=platform_entities.Group(
@@ -1251,7 +1266,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else: else:
synthetic_event = platform_events.FriendMessage( synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend( sender=platform_entities.Friend(
id=sender_user_id, id=actor_user_id,
nickname='', nickname='',
remark='', remark='',
), ),
@@ -1273,13 +1288,14 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.ap.logger.info( self.ap.logger.info(
f'DingTalk _on_card_action enqueuing form action: action_id={action_id!r} ' 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'action_title={action_title!r} launcher_type={launcher_type.value} '
f'launcher_id={launcher_id} bot_uuid={bot_uuid} pipeline_uuid={pipeline_uuid}' 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( await self.ap.query_pool.add_query(
bot_uuid=bot_uuid, bot_uuid=bot_uuid,
launcher_type=launcher_type, launcher_type=launcher_type,
launcher_id=launcher_id, launcher_id=launcher_id,
sender_id=sender_user_id, sender_id=actor_user_id,
message_event=synthetic_event, message_event=synthetic_event,
message_chain=message_chain, message_chain=message_chain,
adapter=self, adapter=self,
@@ -1336,7 +1352,17 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else provider_session.LauncherTypes.PERSON else provider_session.LauncherTypes.PERSON
) )
launcher_id = state.get('launcher_id', '') launcher_id = state.get('launcher_id', '')
sender_user_id = state.get('sender_user_id') or payload.get('user_id') or 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_action_data = {
'form_token': state.get('form_token', ''), 'form_token': state.get('form_token', ''),
'workflow_run_id': state.get('workflow_run_id', ''), 'workflow_run_id': state.get('workflow_run_id', ''),
@@ -1353,7 +1379,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if launcher_type == provider_session.LauncherTypes.GROUP: if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event = platform_events.GroupMessage( synthetic_event = platform_events.GroupMessage(
sender=platform_entities.GroupMember( sender=platform_entities.GroupMember(
id=sender_user_id, id=actor_user_id,
member_name='', member_name='',
permission=platform_entities.Permission.Member, permission=platform_entities.Permission.Member,
group=platform_entities.Group( group=platform_entities.Group(
@@ -1370,7 +1396,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else: else:
synthetic_event = platform_events.FriendMessage( synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend( sender=platform_entities.Friend(
id=sender_user_id, id=actor_user_id,
nickname='', nickname='',
remark='', remark='',
), ),
@@ -1394,7 +1420,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_uuid=bot_uuid, bot_uuid=bot_uuid,
launcher_type=launcher_type, launcher_type=launcher_type,
launcher_id=launcher_id, launcher_id=launcher_id,
sender_id=sender_user_id, sender_id=actor_user_id,
message_event=synthetic_event, message_event=synthetic_event,
message_chain=message_chain, message_chain=message_chain,
adapter=self, adapter=self,
+24 -14
View File
@@ -1453,7 +1453,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Already responded somehow — proceed regardless. # Already responded somehow — proceed regardless.
pass pass
pending = self._pending_forms.pop(session_key, None) pending = self._pending_forms.get(session_key)
if not pending: if not pending:
if self.ap is not None: if self.ap is not None:
self.ap.logger.warning( self.ap.logger.warning(
@@ -1462,24 +1462,33 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self._lock_view_message(interaction, view, action_title, stale=True) await self._lock_view_message(interaction, view, action_title, stale=True)
return return
# Lock the buttons in place: disable everything, mark chosen one.
await self._lock_view_message(interaction, view, action_title)
form_data: dict = pending.get('form_data') or {} form_data: dict = pending.get('form_data') or {}
guild_id = pending.get('guild_id', '') guild_id = pending.get('guild_id', '')
channel_id = pending.get('channel_id', '') channel_id = pending.get('channel_id', '')
sender_id = pending.get('sender_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
# In group context the launcher is the CHANNEL (not the user who self._pending_forms.pop(session_key, None)
# clicked) — matches how the original message was routed through
# the pipeline. Using the clicker's user id would mismatch the # Lock the buttons in place: disable everything, mark chosen one.
# Dify session and produce "Workflow run not found". 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: if guild_id:
launcher_type = provider_session.LauncherTypes.GROUP launcher_type = provider_session.LauncherTypes.GROUP
launcher_id = channel_id launcher_id = channel_id
else: else:
launcher_type = provider_session.LauncherTypes.PERSON launcher_type = provider_session.LauncherTypes.PERSON
launcher_id = sender_id or str(interaction.user.id) launcher_id = initiator_id or actor_id
form_action_data = { form_action_data = {
'form_token': form_data.get('form_token', ''), 'form_token': form_data.get('form_token', ''),
@@ -1500,7 +1509,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if launcher_type == provider_session.LauncherTypes.GROUP: if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage( synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
sender=platform_entities.GroupMember( sender=platform_entities.GroupMember(
id=sender_id, id=actor_id,
member_name=interaction.user.display_name if interaction.user else '', member_name=interaction.user.display_name if interaction.user else '',
permission='MEMBER', permission='MEMBER',
group=platform_entities.Group( group=platform_entities.Group(
@@ -1517,7 +1526,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else: else:
synthetic_event = platform_events.FriendMessage( synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend( sender=platform_entities.Friend(
id=sender_id, id=actor_id,
nickname=interaction.user.display_name if interaction.user else '', nickname=interaction.user.display_name if interaction.user else '',
remark='', remark='',
), ),
@@ -1553,7 +1562,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_uuid=bot_uuid, bot_uuid=bot_uuid,
launcher_type=launcher_type, launcher_type=launcher_type,
launcher_id=launcher_id, launcher_id=launcher_id,
sender_id=sender_id, sender_id=actor_id,
message_event=synthetic_event, message_event=synthetic_event,
message_chain=message_chain, message_chain=message_chain,
adapter=self, adapter=self,
@@ -1565,7 +1574,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
) )
if self.ap is not None: if self.ap is not None:
self.ap.logger.info( self.ap.logger.info(
f'Discord: button-click query enqueued action_id={action_id!r} session={session_key}' f'Discord: button-click query enqueued action_id={action_id!r} '
f'session={session_key} actor_id={actor_id}'
) )
except Exception: except Exception:
if self.ap is not None: if self.ap is not None:
+45 -18
View File
@@ -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.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
from langbot.libs.qq_official_api.api import QQOfficialClient, build_keyboard_from_form 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 langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
from ...utils import image from ...utils import image
from ..logger import EventLogger from ..logger import EventLogger
@@ -840,7 +846,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
parts.append('请点击下方按钮选择:') parts.append('请点击下方按钮选择:')
markdown_content = '\n\n'.join(parts) markdown_content = '\n\n'.join(parts)
if is_field_step: keyboard = build_keyboard_from_select_field(form_data) if is_field_step else None
if is_field_step and not keyboard.get('content', {}).get('rows'):
field_parts = parts[:-1] if len(parts) > 1 else parts field_parts = parts[:-1] if len(parts) > 1 else parts
text_msg = platform_message.MessageChain([platform_message.Plain(text='\n\n'.join(field_parts))]) text_msg = platform_message.MessageChain([platform_message.Plain(text='\n\n'.join(field_parts))])
try: try:
@@ -874,7 +881,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
) )
return return
keyboard = build_keyboard_from_form(form_data, buttons_per_row=2) if keyboard is None:
keyboard = build_keyboard_from_form(form_data, buttons_per_row=2)
if not keyboard.get('content', {}).get('rows'): if not keyboard.get('content', {}).get('rows'):
# No actions to render — fall back to plain text. # No actions to render — fall back to plain text.
text_msg = platform_message.MessageChain([platform_message.Plain(text=markdown_content)]) text_msg = platform_message.MessageChain([platform_message.Plain(text=markdown_content)])
@@ -1018,7 +1026,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
session_key = f'{target_type}_{target_id}' session_key = f'{target_type}_{target_id}'
self._prune_pending_forms() self._prune_pending_forms()
pending = self._pending_forms.pop(session_key, None) pending = self._pending_forms.get(session_key)
if not pending: if not pending:
await self.logger.warning( await self.logger.warning(
f'QQ Official: no pending form for session {session_key}; click ignored (action_id={action_id!r})' f'QQ Official: no pending form for session {session_key}; click ignored (action_id={action_id!r})'
@@ -1046,13 +1054,27 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
form_data: dict = pending.get('form_data') or {} form_data: dict = pending.get('form_data') or {}
actions = form_data.get('actions') or [] actions = form_data.get('actions') or []
matched = next( select_choice = resolve_select_button_action(form_data, action_id)
(a for a in actions if str(a.get('id', '')) == action_id), if action_id.startswith(QQ_SELECT_ACTION_PREFIX) and select_choice is None:
None, await self.logger.warning(f'QQ Official: invalid select action_id={action_id!r} for {session_key}')
) return
action_title = (matched or {}).get('title') or action_id
sender_id = pending.get('sender_id') or event_data.get('user_openid') or event_data.get('member_openid') or '' 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 # Build resume payload matching the shape every other adapter uses
# (DingTalk / Lark / Telegram / WeCom). The runner's # (DingTalk / Lark / Telegram / WeCom). The runner's
@@ -1062,24 +1084,28 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
launcher_id = target_id launcher_id = target_id
else: else:
launcher_type = provider_session.LauncherTypes.PERSON launcher_type = provider_session.LauncherTypes.PERSON
launcher_id = sender_id or target_id launcher_id = target_id
form_action_data = { form_action_data = {
'form_token': form_data.get('form_token', ''), 'form_token': form_data.get('form_token', ''),
'workflow_run_id': form_data.get('workflow_run_id', ''), 'workflow_run_id': form_data.get('workflow_run_id', ''),
'action_id': action_id, 'action_id': '' if select_choice else action_id,
'action_title': action_title, 'action_title': action_title,
'node_title': form_data.get('node_title', ''), 'node_title': form_data.get('node_title', ''),
'user': f'{launcher_type.value}_{launcher_id}', 'user': f'{launcher_type.value}_{launcher_id}',
'inputs': {}, '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
message_chain = platform_message.MessageChain([platform_message.Plain(text=f'[Form Action: {action_title}]')]) 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: if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage( synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
sender=platform_entities.GroupMember( sender=platform_entities.GroupMember(
id=sender_id or launcher_id, id=actor_id or launcher_id,
member_name='', member_name='',
permission='MEMBER', permission='MEMBER',
group=platform_entities.Group( group=platform_entities.Group(
@@ -1096,7 +1122,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
else: else:
synthetic_event = platform_events.FriendMessage( synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend( sender=platform_entities.Friend(
id=sender_id or launcher_id, id=actor_id or launcher_id,
nickname='', nickname='',
remark='', remark='',
), ),
@@ -1122,7 +1148,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
bot_uuid=bot_uuid, bot_uuid=bot_uuid,
launcher_type=launcher_type, launcher_type=launcher_type,
launcher_id=launcher_id, launcher_id=launcher_id,
sender_id=sender_id or launcher_id, sender_id=actor_id or launcher_id,
message_event=synthetic_event, message_event=synthetic_event,
message_chain=message_chain, message_chain=message_chain,
adapter=self, adapter=self,
@@ -1133,7 +1159,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
}, },
) )
await self.logger.info( await self.logger.info(
f'QQ Official: button-click query enqueued action_id={action_id!r} session={session_key}' f'QQ Official: button-click query enqueued action_id={action_id!r} '
f'session={session_key} actor_id={actor_id}'
) )
except Exception: except Exception:
await self.logger.error(f'QQ Official: enqueue button-click query failed: {traceback.format_exc()}') await self.logger.error(f'QQ Official: enqueue button-click query failed: {traceback.format_exc()}')
+117 -23
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import telegram import telegram
import telegram.ext import telegram.ext
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram import ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, CallbackQueryHandler, filters from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, CallbackQueryHandler, filters
import telegramify_markdown import telegramify_markdown
import typing import typing
@@ -20,6 +20,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 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': str(option_index)},
'_input_progress': True,
}
class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter): class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod @staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]: async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:
@@ -205,7 +260,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None], typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {} ] = {}
_form_action_titles: typing.Dict[str, str] = {} # action_id -> action_title mapping _form_action_titles: typing.Dict[str, str] = {} # callback_data -> display title
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -240,11 +295,13 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# for Telegram's 64-byte limit). Only w_suffix is sent; # for Telegram's 64-byte limit). Only w_suffix is sent;
# the runner resolves the full run id from _PENDING_FORMS. # the runner resolves the full run id from _PENDING_FORMS.
w_suffix = data.get('w', '') w_suffix = data.get('w', '')
action_id = data.get('action_id') or data.get('a', '')
session_key = data.get('session_key') or data.get('s', '') session_key = data.get('session_key') or data.get('s', '')
callback_action = _telegram_form_action_from_callback(data)
if callback_action is None or query.data not in self._form_action_titles:
await self.logger.warning(f'Invalid or stale Telegram form callback: {query.data!r}')
return
# Show selected action feedback by editing the original message # Show selected action feedback by editing the original message
action_title = self._form_action_titles.get(action_id, action_id) action_title = self._form_action_titles[query.data]
try: try:
original_text = query.message.text or '' original_text = query.message.text or ''
selected_text = f'{original_text}\n\n✅ Selected: {action_title}' selected_text = f'{original_text}\n\n✅ Selected: {action_title}'
@@ -254,7 +311,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
pass pass
finally: finally:
# Clean up the stored title # Clean up the stored title
self._form_action_titles.pop(action_id, None) self._form_action_titles.pop(query.data, None)
if session_key.startswith('group_') or session_key.startswith('g:'): if session_key.startswith('group_') or session_key.startswith('g:'):
launcher_type = provider_session.LauncherTypes.GROUP launcher_type = provider_session.LauncherTypes.GROUP
@@ -286,13 +343,13 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# workflow_run_id is intentionally omitted; the runner # workflow_run_id is intentionally omitted; the runner
# resolves it from w_suffix via _PENDING_FORMS. # resolves it from w_suffix via _PENDING_FORMS.
'w_suffix': w_suffix, 'w_suffix': w_suffix,
'action_id': action_id,
'user': f'{launcher_type.value}_{launcher_id}', 'user': f'{launcher_type.value}_{launcher_id}',
'inputs': {}, **callback_action,
} }
event_label = 'Form Select' if callback_action.get('_input_progress') else 'Form Action'
message_chain = platform_message.MessageChain( message_chain = platform_message.MessageChain(
[platform_message.Plain(text=f'[Form Action: {action_id}]')] [platform_message.Plain(text=f'[{event_label}: {action_title}]')]
) )
if launcher_type == provider_session.LauncherTypes.GROUP: if launcher_type == provider_session.LauncherTypes.GROUP:
@@ -578,7 +635,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
form_data: dict, form_data: dict,
edit_message_id: int | None = None, edit_message_id: int | None = None,
): ):
"""Send inline keyboard buttons for Dify human_input_required form actions.""" """Send inline keyboard buttons for Dify form fields or actions."""
actions = form_data.get('actions', []) actions = form_data.get('actions', [])
node_title = form_data.get('node_title', '') node_title = form_data.get('node_title', '')
form_content = form_data.get('form_content', '') form_content = form_data.get('form_content', '')
@@ -593,48 +650,86 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else: else:
session_key = f'p:{message_source.sender.id}' 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 = [] keyboard = []
pending_title_mappings: dict[str, str] = {}
oversized = False oversized = False
for action in actions: buttons_per_row = 2 if is_select_field else 1
action_id = action.get('id', '') current_row = []
action_title = action.get('title', action_id) for title, choice_data in choices:
# Store action_id -> title mapping for displaying selection feedback callback_payload = {'f': 1, **choice_data, 's': session_key}
self._form_action_titles[action_id] = action_title
callback_payload = {'f': 1, 'a': action_id, 's': session_key}
if w_suffix: if w_suffix:
callback_payload['w'] = w_suffix callback_payload['w'] = w_suffix
callback_data = json.dumps(callback_payload, separators=(',', ':')) callback_data = json.dumps(callback_payload, separators=(',', ':'))
if len(callback_data.encode('utf-8')) > 64: if len(callback_data.encode('utf-8')) > 64:
oversized = True oversized = True
break break
keyboard.append([InlineKeyboardButton(action_title, callback_data=callback_data)]) 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 update = message_source.source_platform_object
chat_id = update.effective_chat.id chat_id = update.effective_chat.id
effective_message = update.effective_message effective_message = update.effective_message
message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None message_thread_id = getattr(effective_message, 'message_thread_id', None) if effective_message else None
text_lines = [f'[{node_title}] Please select an action:'] if is_select_field:
prompt = f'Please select {select_field}:'
elif is_field_step:
prompt = f'Please reply with a value for {current_field}.'
else:
prompt = 'Please select an action:'
text_lines = [f'[{node_title}] {prompt}']
if form_content: if form_content:
text_lines.insert(0, form_content) text_lines.insert(0, form_content)
if oversized: if oversized:
# callback_data exceeds Telegram's 64-byte limit — fall back to # callback_data exceeds Telegram's 64-byte limit — fall back to
# a plain-text numbered list so the user can reply by number. # a plain-text numbered list so the user can reply by number.
for idx, action in enumerate(actions, start=1): for idx, (title, _) in enumerate(choices, start=1):
title = action.get('title') or action.get('id') or ''
text_lines.append(f' {idx}. {title}') text_lines.append(f' {idx}. {title}')
args = { args = {
'chat_id': chat_id, 'chat_id': chat_id,
'text': '\n\n'.join(text_lines), 'text': '\n\n'.join(text_lines),
} }
else: elif keyboard:
self._form_action_titles.update(pending_title_mappings)
reply_markup = InlineKeyboardMarkup(keyboard) reply_markup = InlineKeyboardMarkup(keyboard)
args = { args = {
'chat_id': chat_id, 'chat_id': chat_id,
'text': '\n\n'.join(text_lines), 'text': '\n\n'.join(text_lines),
'reply_markup': reply_markup, '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=f'Enter {current_field}',
),
}
else:
args = {
'chat_id': chat_id,
'text': '\n\n'.join(text_lines),
}
if message_thread_id: if message_thread_id:
args['message_thread_id'] = message_thread_id args['message_thread_id'] = message_thread_id
@@ -645,8 +740,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'message_id': edit_message_id, 'message_id': edit_message_id,
'text': args['text'], 'text': args['text'],
} }
if 'reply_markup' in args: edit_args['reply_markup'] = args.get('reply_markup')
edit_args['reply_markup'] = args['reply_markup']
try: try:
await self.bot.edit_message_text(**edit_args) await self.bot.edit_message_text(**edit_args)
return return
+86 -43
View File
@@ -31,6 +31,18 @@ _PENDING_FORM_DEFAULT_TTL = 30 * 60 # 30 minutes safety cap
_STREAM_FORM_PLACEHOLDER = '\u200b' _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 _session_key_from_query(query: pipeline_query.Query) -> str: def _session_key_from_query(query: pipeline_query.Query) -> str:
return f'{query.session.launcher_type.value}_{query.session.launcher_id}' return f'{query.session.launcher_type.value}_{query.session.launcher_id}'
@@ -892,9 +904,9 @@ class DifyServiceAPIRunner(runner.RequestRunner):
form_action, form_action,
) )
return return
_clear_pending_form(session_key, form_action.get('form_token') or None)
async for msg in self._submit_workflow_form_blocking(form_action): async for msg in self._submit_workflow_form_blocking(form_action):
yield msg yield msg
_clear_pending_form(session_key, form_action.get('form_token') or None)
return return
cov_id = query.session.using_conversation.uuid or None cov_id = query.session.using_conversation.uuid or None
@@ -977,7 +989,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
) )
elif mode == 'basic': elif mode == 'basic':
if chunk['event'] == 'message': 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': elif chunk['event'] == 'message_end':
content, _ = self._process_thinking_content(basic_mode_pending_chunk) content, _ = self._process_thinking_content(basic_mode_pending_chunk)
yield provider_message.Message( yield provider_message.Message(
@@ -1034,7 +1046,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
continue continue
if chunk['event'] == 'agent_message' or chunk['event'] == 'message': 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: else:
if pending_agent_message.strip() != '': if pending_agent_message.strip() != '':
pending_agent_message = pending_agent_message.replace('</details>Action:', '</details>') pending_agent_message = pending_agent_message.replace('</details>Action:', '</details>')
@@ -1099,6 +1111,9 @@ class DifyServiceAPIRunner(runner.RequestRunner):
user = form_action['user'] user = form_action['user']
action_id = form_action.get('action_id', '') action_id = form_action.get('action_id', '')
inputs = form_action.get('inputs', {}) inputs = form_action.get('inputs', {})
pending_content = ''
saw_event = False
answer_node_seen = False
async for chunk in self.dify_client.workflow_submit( async for chunk in self.dify_client.workflow_submit(
form_token=form_token, form_token=form_token,
@@ -1108,21 +1123,49 @@ class DifyServiceAPIRunner(runner.RequestRunner):
action=action_id, action=action_id,
timeout=120, timeout=120,
): ):
saw_event = True
self.ap.logger.debug('dify-workflow-submit-chunk: ' + str(chunk)) self.ap.logger.debug('dify-workflow-submit-chunk: ' + str(chunk))
event = chunk.get('event')
if chunk['event'] == 'workflow_finished': if event == 'error':
if chunk['data'].get('error'): raise errors.DifyAPIError(chunk.get('message') or 'Dify workflow resume failed')
raise errors.DifyAPIError(chunk['data']['error'])
content, _ = self._process_thinking_content(chunk['data']['outputs']['summary']) 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( yield provider_message.Message(
role='assistant', role='assistant',
content=content, content=content,
) )
return return
if chunk['event'] == 'workflow_paused': if event == 'workflow_paused':
reasons = chunk['data'].get('reasons', []) reasons = chunk.get('data', {}).get('reasons', [])
new_run_id = chunk['data'].get('workflow_run_id', workflow_run_id) new_run_id = chunk.get('data', {}).get('workflow_run_id', workflow_run_id)
for reason in reasons: for reason in reasons:
if reason.get('TYPE') != 'human_input_required': if reason.get('TYPE') != 'human_input_required':
continue continue
@@ -1148,6 +1191,12 @@ class DifyServiceAPIRunner(runner.RequestRunner):
) )
return return
if not saw_event:
raise errors.DifyAPIError('Dify API did not return any workflow resume events')
if pending_content:
content, _ = self._process_thinking_content(pending_content)
yield provider_message.Message(role='assistant', content=content)
def _resolve_pending_form(self, session_key: str, form_action: dict) -> dict | None: def _resolve_pending_form(self, session_key: str, form_action: dict) -> dict | None:
"""Locate the pending form this action targets. """Locate the pending form this action targets.
@@ -1350,9 +1399,9 @@ class DifyServiceAPIRunner(runner.RequestRunner):
form_action, form_action,
) )
return return
_clear_pending_form(session_key, form_action.get('form_token') or None)
async for msg in self._submit_workflow_form_blocking(form_action): async for msg in self._submit_workflow_form_blocking(form_action):
yield msg yield msg
_clear_pending_form(session_key, form_action.get('form_token') or None)
return return
if not query.session.using_conversation.uuid: if not query.session.using_conversation.uuid:
@@ -1483,9 +1532,9 @@ class DifyServiceAPIRunner(runner.RequestRunner):
form_action, form_action,
) )
return return
_clear_pending_form(session_key, form_action.get('form_token') or None)
async for msg in self._submit_workflow_form(form_action): async for msg in self._submit_workflow_form(form_action):
yield msg yield msg
_clear_pending_form(session_key, form_action.get('form_token') or None)
return return
cov_id = query.session.using_conversation.uuid or None cov_id = query.session.using_conversation.uuid or None
@@ -1513,14 +1562,19 @@ class DifyServiceAPIRunner(runner.RequestRunner):
chunk = None # 初始化chunk变量,防止在没有响应时引用错误 chunk = None # 初始化chunk变量,防止在没有响应时引用错误
is_final = False is_final = False
think_start = False
think_end = False
yielded_final = False yielded_final = False
human_input_yielded = False human_input_yielded = False
pending_form_data = None 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 '<think>' in content and '</think>' not in content:
return content.split('<think>', 1)[0].rstrip()
return self._process_thinking_content(content)[0]
async for chunk in self.dify_client.chat_messages( async for chunk in self.dify_client.chat_messages(
inputs=inputs, inputs=inputs,
query=plain_text, query=plain_text,
@@ -1539,23 +1593,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
if chunk['event'] == 'message': if chunk['event'] == 'message':
message_idx += 1 message_idx += 1
if remove_think: basic_mode_pending_chunk = _merge_stream_text(basic_mode_pending_chunk, chunk['answer'])
if '<think>' in chunk['answer'] and not think_start:
think_start = True
continue
if '</think>' in chunk['answer'] and not think_end:
import re
content = re.sub(r'^\n</think>', '', 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']
if chunk['event'] == 'message_end': if chunk['event'] == 'message_end':
is_final = True is_final = True
@@ -1620,7 +1658,11 @@ class DifyServiceAPIRunner(runner.RequestRunner):
and (is_final or message_idx % 8 == 0) and (is_final or message_idx % 8 == 0)
and (basic_mode_pending_chunk != '' or is_final) and (basic_mode_pending_chunk != '' or is_final)
): ):
final_content = basic_mode_pending_chunk if basic_mode_pending_chunk.strip() else '' 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( msg = provider_message.MessageChunk(
role='assistant', role='assistant',
content=final_content, content=final_content,
@@ -1637,9 +1679,10 @@ class DifyServiceAPIRunner(runner.RequestRunner):
# workflow_finished event, yield a final chunk so the adapter # workflow_finished event, yield a final chunk so the adapter
# can update the card and add buttons. # can update the card and add buttons.
if human_input_yielded and not yielded_final: if human_input_yielded and not yielded_final:
final_content = visible_content(basic_mode_pending_chunk)
msg = provider_message.MessageChunk( msg = provider_message.MessageChunk(
role='assistant', role='assistant',
content=basic_mode_pending_chunk or '', content=final_content or _STREAM_FORM_PLACEHOLDER,
is_final=True, is_final=True,
) )
msg._form_data = pending_form_data msg._form_data = pending_form_data
@@ -1708,15 +1751,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
import re import re
content = re.sub(r'^\n</think>', '', chunk['answer']) content = re.sub(r'^\n</think>', '', chunk['answer'])
pending_agent_message += content pending_agent_message = _merge_stream_text(pending_agent_message, content)
think_end = True think_end = True
elif think_end or not think_start: 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: if think_start and not think_end:
continue continue
else: else:
pending_agent_message += chunk['answer'] pending_agent_message = _merge_stream_text(pending_agent_message, chunk['answer'])
elif chunk['event'] == 'message_end': elif chunk['event'] == 'message_end':
is_final = True is_final = True
else: else:
@@ -1865,11 +1908,11 @@ class DifyServiceAPIRunner(runner.RequestRunner):
workflow_contents += content workflow_contents += content
think_end = True think_end = True
elif think_end: elif think_end:
workflow_contents += chunk['data']['text'] workflow_contents = _merge_stream_text(workflow_contents, chunk['data']['text'])
if think_start: if think_start:
continue continue
else: else:
workflow_contents += chunk['data']['text'] workflow_contents = _merge_stream_text(workflow_contents, chunk['data']['text'])
if messsage_idx % 8 == 0: if messsage_idx % 8 == 0:
yield_this_iteration = True yield_this_iteration = True
@@ -1890,11 +1933,11 @@ class DifyServiceAPIRunner(runner.RequestRunner):
workflow_contents += content workflow_contents += content
think_end = True think_end = True
elif think_end: elif think_end:
workflow_contents += answer workflow_contents = _merge_stream_text(workflow_contents, answer)
if think_start: if think_start:
continue continue
else: else:
workflow_contents += answer workflow_contents = _merge_stream_text(workflow_contents, answer)
if messsage_idx % 8 == 0: if messsage_idx % 8 == 0:
yield_this_iteration = True yield_this_iteration = True
@@ -1941,10 +1984,10 @@ class DifyServiceAPIRunner(runner.RequestRunner):
form_action, form_action,
) )
return return
_clear_pending_form(session_key, form_action.get('form_token') or None)
# Resume paused workflow via submit endpoint # Resume paused workflow via submit endpoint
async for msg in self._submit_workflow_form(form_action): async for msg in self._submit_workflow_form(form_action):
yield msg yield msg
_clear_pending_form(session_key, form_action.get('form_token') or None)
return return
if not query.session.using_conversation.uuid: if not query.session.using_conversation.uuid:
@@ -2057,12 +2100,12 @@ class DifyServiceAPIRunner(runner.RequestRunner):
workflow_contents += content workflow_contents += content
think_end = True think_end = True
elif think_end: elif think_end:
workflow_contents += chunk['data']['text'] workflow_contents = _merge_stream_text(workflow_contents, chunk['data']['text'])
if think_start: if think_start:
continue continue
else: else:
workflow_contents += chunk['data']['text'] workflow_contents = _merge_stream_text(workflow_contents, chunk['data']['text'])
if chunk['event'] == 'node_started': if chunk['event'] == 'node_started':
if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end': if chunk['data']['node_type'] == 'start' or chunk['data']['node_type'] == 'end':
@@ -1,6 +1,12 @@
"""Tests for DingTalk adapter helper behavior.""" """Tests for DingTalk adapter helper behavior."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langbot.pkg.platform.sources.dingtalk import ( from langbot.pkg.platform.sources.dingtalk import (
DingTalkAdapter,
_dingtalk_clean_form_content, _dingtalk_clean_form_content,
_dingtalk_completed_input_lines, _dingtalk_completed_input_lines,
_dingtalk_extract_component_inputs, _dingtalk_extract_component_inputs,
@@ -102,3 +108,69 @@ def test_dingtalk_clean_form_content_uses_all_input_defs():
) )
assert content == 'Hello' assert content == 'Hello'
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()
@@ -0,0 +1,117 @@
"""Tests for QQ Official keyboard payload helpers."""
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
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'] == []
@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)
@@ -0,0 +1,120 @@
"""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': '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
@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: <value>"',
'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 'Please reply with a value for us_input.' in args['text']
assert adapter._form_action_titles == {}
@@ -128,7 +128,7 @@ class TestDifyRunnerConfigValidation:
mock_app = MagicMock() mock_app = MagicMock()
for app_type in ['chat', 'agent', 'workflow']: for app_type in ['chat', 'agent', 'workflow', 'chatflow']:
pipeline_config = { pipeline_config = {
'ai': { 'ai': {
'dify-service-api': { 'dify-service-api': {
@@ -669,3 +669,205 @@ class TestDifyHumanInputForms:
assert second['_partial'] is True assert second['_partial'] is True
assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'} assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'}
assert second['_form_data']['_action_select_only'] is True assert second['_form_data']['_action_select_only'] is True
@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': {},
}
)
]
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()
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'}],
'inputs': {},
'user': session_key,
},
)
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.variables = {
'_dify_form_action': {
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'action_id': 'yes',
'user': session_key,
'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_failed_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()
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'}],
'inputs': {},
'user': session_key,
},
)
async def workflow_submit(**kwargs):
del kwargs
raise DifyAPIError('temporary failure')
yield
runner.dify_client.workflow_submit = workflow_submit
query = MagicMock()
query.session.launcher_type.value = 'person'
query.session.launcher_id = 'user-1'
query.variables = {
'_dify_form_action': {
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'action_id': 'yes',
'user': session_key,
'inputs': {},
}
}
with pytest.raises(DifyAPIError, match='temporary failure'):
_ = [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()
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('<think>one', '<think>one two') == '<think>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'<think>Reasoning{"." * idx}' for idx in range(1, 10)]
snapshots.append(f'{snapshots[-1]}</think>\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('<think>') == 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': '<think>Reasoning', 'conversation_id': 'conversation-2'}
yield {
'event': 'message',
'answer': '<think>Reasoning complete</think>\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('<think>' not in chunk.content for chunk in chunks if isinstance(chunk.content, str))