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
+72
View File
@@ -12,6 +12,78 @@ 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.
+35 -9
View File
@@ -1203,6 +1203,11 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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
@@ -1217,7 +1222,17 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else provider_session.LauncherTypes.PERSON
)
launcher_id = state.get('launcher_id', '')
sender_user_id = state.get('sender_user_id') or payload.get('user_id') or launcher_id
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', ''),
@@ -1234,7 +1249,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=sender_user_id,
id=actor_user_id,
member_name='',
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
@@ -1251,7 +1266,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else:
synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend(
id=sender_user_id,
id=actor_user_id,
nickname='',
remark='',
),
@@ -1273,13 +1288,14 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.ap.logger.info(
f'DingTalk _on_card_action enqueuing form action: action_id={action_id!r} '
f'action_title={action_title!r} launcher_type={launcher_type.value} '
f'launcher_id={launcher_id} bot_uuid={bot_uuid} pipeline_uuid={pipeline_uuid}'
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=sender_user_id,
sender_id=actor_user_id,
message_event=synthetic_event,
message_chain=message_chain,
adapter=self,
@@ -1336,7 +1352,17 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else provider_session.LauncherTypes.PERSON
)
launcher_id = state.get('launcher_id', '')
sender_user_id = state.get('sender_user_id') or payload.get('user_id') or launcher_id
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', ''),
@@ -1353,7 +1379,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=sender_user_id,
id=actor_user_id,
member_name='',
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
@@ -1370,7 +1396,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else:
synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend(
id=sender_user_id,
id=actor_user_id,
nickname='',
remark='',
),
@@ -1394,7 +1420,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=sender_user_id,
sender_id=actor_user_id,
message_event=synthetic_event,
message_chain=message_chain,
adapter=self,
+24 -14
View File
@@ -1453,7 +1453,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Already responded somehow — proceed regardless.
pass
pending = self._pending_forms.pop(session_key, None)
pending = self._pending_forms.get(session_key)
if not pending:
if self.ap is not None:
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)
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 {}
guild_id = pending.get('guild_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
# clicked) — matches how the original message was routed through
# the pipeline. Using the clicker's user id would mismatch the
# Dify session and produce "Workflow run not found".
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 = sender_id or str(interaction.user.id)
launcher_id = initiator_id or actor_id
form_action_data = {
'form_token': form_data.get('form_token', ''),
@@ -1500,7 +1509,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if launcher_type == provider_session.LauncherTypes.GROUP:
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=sender_id,
id=actor_id,
member_name=interaction.user.display_name if interaction.user else '',
permission='MEMBER',
group=platform_entities.Group(
@@ -1517,7 +1526,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else:
synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend(
id=sender_id,
id=actor_id,
nickname=interaction.user.display_name if interaction.user else '',
remark='',
),
@@ -1553,7 +1562,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=sender_id,
sender_id=actor_id,
message_event=synthetic_event,
message_chain=message_chain,
adapter=self,
@@ -1565,7 +1574,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
)
if self.ap is not None:
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:
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.events as platform_events
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 ...utils import image
from ..logger import EventLogger
@@ -840,7 +846,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
parts.append('请点击下方按钮选择:')
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
text_msg = platform_message.MessageChain([platform_message.Plain(text='\n\n'.join(field_parts))])
try:
@@ -874,7 +881,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
)
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'):
# No actions to render — fall back to plain text.
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}'
self._prune_pending_forms()
pending = self._pending_forms.pop(session_key, None)
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})'
@@ -1046,13 +1054,27 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
form_data: dict = pending.get('form_data') or {}
actions = form_data.get('actions') or []
matched = next(
(a for a in actions if str(a.get('id', '')) == action_id),
None,
)
action_title = (matched or {}).get('title') or action_id
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
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
# (DingTalk / Lark / Telegram / WeCom). The runner's
@@ -1062,24 +1084,28 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
launcher_id = target_id
else:
launcher_type = provider_session.LauncherTypes.PERSON
launcher_id = sender_id or target_id
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': action_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': {},
'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:
synthetic_event: platform_events.MessageEvent = platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=sender_id or launcher_id,
id=actor_id or launcher_id,
member_name='',
permission='MEMBER',
group=platform_entities.Group(
@@ -1096,7 +1122,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
else:
synthetic_event = platform_events.FriendMessage(
sender=platform_entities.Friend(
id=sender_id or launcher_id,
id=actor_id or launcher_id,
nickname='',
remark='',
),
@@ -1122,7 +1148,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
sender_id=sender_id or launcher_id,
sender_id=actor_id or launcher_id,
message_event=synthetic_event,
message_chain=message_chain,
adapter=self,
@@ -1133,7 +1159,8 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
},
)
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:
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.ext
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram import ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, CallbackQueryHandler, filters
import telegramify_markdown
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
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):
@staticmethod
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],
] = {}
_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):
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;
# the runner resolves the full run id from _PENDING_FORMS.
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', '')
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
action_title = self._form_action_titles.get(action_id, action_id)
action_title = self._form_action_titles[query.data]
try:
original_text = query.message.text or ''
selected_text = f'{original_text}\n\n✅ Selected: {action_title}'
@@ -254,7 +311,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
pass
finally:
# 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:'):
launcher_type = provider_session.LauncherTypes.GROUP
@@ -286,13 +343,13 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# workflow_run_id is intentionally omitted; the runner
# resolves it from w_suffix via _PENDING_FORMS.
'w_suffix': w_suffix,
'action_id': action_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(
[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:
@@ -578,7 +635,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
form_data: dict,
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', [])
node_title = form_data.get('node_title', '')
form_content = form_data.get('form_content', '')
@@ -593,48 +650,86 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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
for action in actions:
action_id = action.get('id', '')
action_title = action.get('title', action_id)
# Store action_id -> title mapping for displaying selection feedback
self._form_action_titles[action_id] = action_title
callback_payload = {'f': 1, 'a': action_id, 's': session_key}
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
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
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
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:
text_lines.insert(0, 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, action in enumerate(actions, start=1):
title = action.get('title') or action.get('id') or ''
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),
}
else:
elif keyboard:
self._form_action_titles.update(pending_title_mappings)
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=f'Enter {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
@@ -645,8 +740,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'message_id': edit_message_id,
'text': args['text'],
}
if 'reply_markup' in args:
edit_args['reply_markup'] = args['reply_markup']
edit_args['reply_markup'] = args.get('reply_markup')
try:
await self.bot.edit_message_text(**edit_args)
return
+86 -43
View File
@@ -31,6 +31,18 @@ _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 _session_key_from_query(query: pipeline_query.Query) -> str:
return f'{query.session.launcher_type.value}_{query.session.launcher_id}'
@@ -892,9 +904,9 @@ class DifyServiceAPIRunner(runner.RequestRunner):
form_action,
)
return
_clear_pending_form(session_key, form_action.get('form_token') or None)
async for msg in self._submit_workflow_form_blocking(form_action):
yield msg
_clear_pending_form(session_key, form_action.get('form_token') or None)
return
cov_id = query.session.using_conversation.uuid or None
@@ -977,7 +989,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(
@@ -1034,7 +1046,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('</details>Action:', '</details>')
@@ -1099,6 +1111,9 @@ class DifyServiceAPIRunner(runner.RequestRunner):
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,
@@ -1108,21 +1123,49 @@ class DifyServiceAPIRunner(runner.RequestRunner):
action=action_id,
timeout=120,
):
saw_event = True
self.ap.logger.debug('dify-workflow-submit-chunk: ' + str(chunk))
event = chunk.get('event')
if chunk['event'] == 'workflow_finished':
if chunk['data'].get('error'):
raise errors.DifyAPIError(chunk['data']['error'])
content, _ = self._process_thinking_content(chunk['data']['outputs']['summary'])
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 chunk['event'] == 'workflow_paused':
reasons = chunk['data'].get('reasons', [])
new_run_id = chunk['data'].get('workflow_run_id', workflow_run_id)
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
@@ -1148,6 +1191,12 @@ class DifyServiceAPIRunner(runner.RequestRunner):
)
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:
"""Locate the pending form this action targets.
@@ -1350,9 +1399,9 @@ class DifyServiceAPIRunner(runner.RequestRunner):
form_action,
)
return
_clear_pending_form(session_key, form_action.get('form_token') or None)
async for msg in self._submit_workflow_form_blocking(form_action):
yield msg
_clear_pending_form(session_key, form_action.get('form_token') or None)
return
if not query.session.using_conversation.uuid:
@@ -1483,9 +1532,9 @@ class DifyServiceAPIRunner(runner.RequestRunner):
form_action,
)
return
_clear_pending_form(session_key, form_action.get('form_token') or None)
async for msg in self._submit_workflow_form(form_action):
yield msg
_clear_pending_form(session_key, form_action.get('form_token') or None)
return
cov_id = query.session.using_conversation.uuid or None
@@ -1513,14 +1562,19 @@ 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')
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(
inputs=inputs,
query=plain_text,
@@ -1539,23 +1593,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
if chunk['event'] == 'message':
message_idx += 1
if remove_think:
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']
basic_mode_pending_chunk = _merge_stream_text(basic_mode_pending_chunk, chunk['answer'])
if chunk['event'] == 'message_end':
is_final = True
@@ -1620,7 +1658,11 @@ class DifyServiceAPIRunner(runner.RequestRunner):
and (is_final or message_idx % 8 == 0)
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(
role='assistant',
content=final_content,
@@ -1637,9 +1679,10 @@ class DifyServiceAPIRunner(runner.RequestRunner):
# 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=basic_mode_pending_chunk or '',
content=final_content or _STREAM_FORM_PLACEHOLDER,
is_final=True,
)
msg._form_data = pending_form_data
@@ -1708,15 +1751,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
import re
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
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:
@@ -1865,11 +1908,11 @@ 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 messsage_idx % 8 == 0:
yield_this_iteration = True
@@ -1890,11 +1933,11 @@ class DifyServiceAPIRunner(runner.RequestRunner):
workflow_contents += content
think_end = True
elif think_end:
workflow_contents += answer
workflow_contents = _merge_stream_text(workflow_contents, answer)
if think_start:
continue
else:
workflow_contents += answer
workflow_contents = _merge_stream_text(workflow_contents, answer)
if messsage_idx % 8 == 0:
yield_this_iteration = True
@@ -1941,10 +1984,10 @@ class DifyServiceAPIRunner(runner.RequestRunner):
form_action,
)
return
_clear_pending_form(session_key, form_action.get('form_token') or None)
# Resume paused workflow via submit endpoint
async for msg in self._submit_workflow_form(form_action):
yield msg
_clear_pending_form(session_key, form_action.get('form_token') or None)
return
if not query.session.using_conversation.uuid:
@@ -2057,12 +2100,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':