feat: Add pipeline_uuid handling across multiple adapters and update related tests

This commit is contained in:
fdc310
2026-07-12 19:40:59 +08:00
parent cdfde83e39
commit 1bdf371b5d
10 changed files with 114 additions and 31 deletions
+6 -4
View File
@@ -939,6 +939,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'sender_user_id': sender_user_id,
'form_token': form_data.get('form_token', ''),
'workflow_run_id': form_data.get('workflow_run_id', ''),
'pipeline_uuid': form_data.get('pipeline_uuid', ''),
'actions': actions if should_show_actions else [],
'all_actions': actions,
'node_title': node_title,
@@ -1046,6 +1047,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'sender_user_id': sender_user_id,
'form_token': form_data.get('form_token', ''),
'workflow_run_id': form_data.get('workflow_run_id', ''),
'pipeline_uuid': form_data.get('pipeline_uuid', ''),
'actions': actions if should_show_actions else [],
'all_actions': actions,
'node_title': node_title,
@@ -1318,12 +1320,12 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
)
bot_uuid = ''
pipeline_uuid = None
pipeline_uuid = state.get('pipeline_uuid') or None
if self.ap is not None:
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
break
try:
@@ -1451,11 +1453,11 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return
bot_uuid = ''
pipeline_uuid = None
pipeline_uuid = state.get('pipeline_uuid') or None
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
break
try:
await self.ap.query_pool.add_query(
+2 -2
View File
@@ -1540,11 +1540,11 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return
bot_uuid = ''
pipeline_uuid = None
pipeline_uuid = form_data.get('pipeline_uuid') or None
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
break
# Remember the channel so _reply_synthetic and _handle_form_chunk
+6 -4
View File
@@ -1074,11 +1074,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Find the bot entity to get bot_uuid and pipeline_uuid
bot_uuid = ''
pipeline_uuid = None
pipeline_uuid = action_value_obj.get('pipeline_uuid') or None
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
break
form_action_data = {
@@ -2396,6 +2396,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
actions = form_data.get('actions', [])
form_token = form_data.get('form_token', '')
workflow_run_id = form_data.get('workflow_run_id', '')
pipeline_uuid = form_data.get('pipeline_uuid', '')
node_title = form_data.get('node_title', '') or 'Human Input Required'
form_content = form_data.get('form_content', '')
input_defs = _lark_form_input_defs(form_data)
@@ -2459,6 +2460,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'form_action': True,
'form_token': form_token,
'workflow_run_id': workflow_run_id,
'pipeline_uuid': pipeline_uuid,
'action_id': action_id,
'session_key': session_key,
'card_id': card_id,
@@ -2940,11 +2942,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
)
bot_uuid = ''
pipeline_uuid = None
pipeline_uuid = action_value_obj.get('pipeline_uuid') or None
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
break
await self.ap.query_pool.add_query(
@@ -1108,11 +1108,11 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
return
bot_uuid = ''
pipeline_uuid = None
pipeline_uuid = form_data.get('pipeline_uuid') or None
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
break
try:
@@ -37,9 +37,9 @@ spec:
zh_Hans: 一键扫码绑定
zh_Hant: 一鍵掃碼綁定
description:
en_US: Scan QR code with mobile QQ to auto-fill AppID and Secret (Token still needs to be filled manually)
zh_Hans: 使用手机 QQ 扫码绑定,自动填写 AppID 和密钥(Token 仍需手动填写
zh_Hant: 使用手機 QQ 掃碼綁定,自動填寫 AppID 和密鑰(Token 仍需手動填寫
en_US: Scan QR code with mobile QQ to auto-fill AppID and Secret (Token is not used and can be left blank)
zh_Hans: 使用手机 QQ 扫码绑定,自动填写 AppID 和密钥(当前未使用 Token,可留空
zh_Hant: 使用手機 QQ 掃碼綁定,自動填寫 AppID 和密鑰(目前未使用 Token,可留空
type: qr-code-login
login_platform: qqofficial
required: false
+26 -14
View File
@@ -263,37 +263,46 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
] = {}
_FORM_ACTION_CACHE_TTL = 30 * 60
# callback_data -> (display title, expiration monotonic time, form group id)
_form_action_titles: typing.Dict[str, tuple[str, float, str]] = {}
# callback_data -> (display title, pipeline UUID, expiration time, form group id)
_form_action_titles: typing.Dict[str, tuple[str, str, float, str]] = {}
def _prune_form_action_titles(self, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
expired = [key for key, (_, expires_at, _) in self._form_action_titles.items() if expires_at <= now]
expired = [key for key, (_, _, expires_at, _) in self._form_action_titles.items() if expires_at <= now]
for key in expired:
self._form_action_titles.pop(key, None)
def _cache_form_action_titles(self, mappings: dict[str, str], now: float | None = None) -> None:
def _cache_form_action_titles(
self,
mappings: dict[str, str],
pipeline_uuid: str = '',
now: float | None = None,
) -> None:
now = time.monotonic() if now is None else now
self._prune_form_action_titles(now)
group_id = uuid.uuid4().hex
expires_at = now + self._FORM_ACTION_CACHE_TTL
self._form_action_titles.update(
{callback_data: (title, expires_at, group_id) for callback_data, title in mappings.items()}
{callback_data: (title, pipeline_uuid, expires_at, group_id) for callback_data, title in mappings.items()}
)
def _take_form_action_title(self, callback_data: str, now: float | None = None) -> str | None:
def _take_form_action_context(self, callback_data: str, now: float | None = None) -> tuple[str, str] | None:
"""Consume a callback and invalidate every button from the same form."""
self._prune_form_action_titles(now)
entry = self._form_action_titles.get(callback_data)
if entry is None:
return None
title, _, group_id = entry
title, pipeline_uuid, _, group_id = entry
group_keys = [
key for key, (_, _, cached_group_id) in self._form_action_titles.items() if cached_group_id == group_id
key for key, (_, _, _, cached_group_id) in self._form_action_titles.items() if cached_group_id == group_id
]
for key in group_keys:
self._form_action_titles.pop(key, None)
return title
return title, pipeline_uuid
def _take_form_action_title(self, callback_data: str, now: float | None = None) -> str | None:
context = self._take_form_action_context(callback_data, now)
return context[0] if context else None
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -330,10 +339,11 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
w_suffix = data.get('w', '')
session_key = data.get('session_key') or data.get('s', '')
callback_action = _telegram_form_action_from_callback(data)
action_title = self._take_form_action_title(query.data) if callback_action is not None else None
if callback_action is None or action_title is None:
action_context = self._take_form_action_context(query.data) if callback_action is not None else None
if callback_action is None or action_context is None:
await self.logger.warning(f'Invalid or stale Telegram form callback: {query.data!r}')
return
action_title, pipeline_uuid = action_context
# Show selected action feedback by editing the original message
try:
original_text = query.message.text or ''
@@ -362,11 +372,10 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Find bot_uuid and pipeline_uuid
bot_uuid = ''
pipeline_uuid = None
for b in self.ap.platform_mgr.bots:
if b.adapter is self:
bot_uuid = b.bot_entity.uuid
pipeline_uuid = b.bot_entity.use_pipeline_uuid
pipeline_uuid = pipeline_uuid or b.bot_entity.use_pipeline_uuid
break
form_action_data = {
@@ -732,7 +741,10 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'text': '\n\n'.join(text_lines),
}
elif keyboard:
self._cache_form_action_titles(pending_title_mappings)
self._cache_form_action_titles(
pending_title_mappings,
str(form_data.get('pipeline_uuid') or ''),
)
reply_markup = InlineKeyboardMarkup(keyboard)
args = {
'chat_id': chat_id,
+3 -2
View File
@@ -838,6 +838,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'node_title': form.get('node_title', ''),
'workflow_run_id': form.get('workflow_run_id', ''),
'form_token': form.get('form_token', ''),
'pipeline_uuid': form.get('pipeline_uuid', ''),
'_action_select_only': True,
}
target_chat_id = session.chat_id or session.user_id or ''
@@ -924,11 +925,11 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return
bot_uuid = ''
pipeline_uuid = None
pipeline_uuid = form.get('pipeline_uuid') or None
for bot in self.ap.platform_mgr.bots:
if bot.adapter is self:
bot_uuid = bot.bot_entity.uuid
pipeline_uuid = bot.bot_entity.use_pipeline_uuid
pipeline_uuid = pipeline_uuid or bot.bot_entity.use_pipeline_uuid
break
try:
@@ -74,6 +74,8 @@ def _prune_pending_forms(now: float | None = None) -> None:
def _set_pending_form(session_key: PendingFormKey, form_data: dict[str, typing.Any]) -> None:
_prune_pending_forms()
if isinstance(session_key, tuple) and len(session_key) > 1:
form_data['pipeline_uuid'] = session_key[1]
stored = dict(form_data)
expiration_time = stored.get('expiration_time')
try:
@@ -514,6 +516,7 @@ def _field_input_form_data(pending_form: dict[str, typing.Any], field: dict[str,
'node_title': pending_form.get('node_title', ''),
'workflow_run_id': pending_form.get('workflow_run_id', ''),
'form_token': pending_form.get('form_token', ''),
'pipeline_uuid': pending_form.get('pipeline_uuid', ''),
'_current_input_field': _field_name(field),
}
@@ -533,6 +536,7 @@ def _action_select_form_data(pending_form: dict[str, typing.Any]) -> dict[str, t
'node_title': pending_form.get('node_title', ''),
'workflow_run_id': pending_form.get('workflow_run_id', ''),
'form_token': pending_form.get('form_token', ''),
'pipeline_uuid': pending_form.get('pipeline_uuid', ''),
'_action_select_only': True,
}
@@ -1341,6 +1345,8 @@ class DifyServiceAPIRunner(runner.RequestRunner):
if form:
return form
if form_token or workflow_run_id or w_suffix:
return None
return _get_latest_pending_form(session_key)
def _merge_pending_form_action(self, session_key: PendingFormKey, form_action: dict | None) -> dict | None:
@@ -1351,6 +1357,10 @@ class DifyServiceAPIRunner(runner.RequestRunner):
merged_action = dict(form_action)
merged_action.pop('w_suffix', None)
pending_form = self._resolve_pending_form(session_key, form_action)
if pending_form is None and any(
form_action.get(identifier) for identifier in ('form_token', 'workflow_run_id', 'w_suffix')
):
return None
if pending_form:
merged_action['form_token'] = merged_action.get('form_token') or pending_form.get('form_token', '')
merged_action['workflow_run_id'] = merged_action.get('workflow_run_id') or pending_form.get(
@@ -1771,6 +1781,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
'node_title': node_title,
'workflow_run_id': workflow_run_id,
'form_token': reason.get('form_token', ''),
'pipeline_uuid': form_snapshot.get('pipeline_uuid', ''),
}
human_input_yielded = True
@@ -2010,6 +2021,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
'node_title': paused_node_title,
'workflow_run_id': new_run_id,
'form_token': reason.get('form_token', ''),
'pipeline_uuid': form_snapshot.get('pipeline_uuid', ''),
}
# Ensure the final chunk has non-empty content so
# ResponseWrapper (which skips empty-content chunks) lets it
@@ -2208,6 +2220,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
'node_title': node_title,
'workflow_run_id': workflow_run_id,
'form_token': reason.get('form_token', ''),
'pipeline_uuid': form_snapshot.get('pipeline_uuid', ''),
}
human_input_yielded = True
@@ -73,6 +73,21 @@ def test_telegram_form_callback_cache_prunes_expired_entries():
assert adapter._form_action_titles == {}
def test_telegram_form_callback_cache_preserves_pipeline_uuid():
adapter = TelegramAdapter.model_construct()
adapter._form_action_titles = {}
adapter._cache_form_action_titles(
{'callback-a': 'Approve'},
pipeline_uuid='pipeline-routed',
now=100.0,
)
assert adapter._take_form_action_context('callback-a', now=101.0) == (
'Approve',
'pipeline-routed',
)
@pytest.mark.asyncio
async def test_telegram_select_field_sends_two_column_inline_keyboard():
bot = MagicMock()
@@ -291,10 +291,48 @@ class TestDifyHumanInputForms:
assert difysvapi._get_latest_pending_form(key_a)['workflow_run_id'] == 'run-a'
assert difysvapi._get_latest_pending_form(key_b)['workflow_run_id'] == 'run-b'
assert difysvapi._get_latest_pending_form(key_c)['workflow_run_id'] == 'run-c'
assert difysvapi._get_latest_pending_form(key_a)['pipeline_uuid'] == 'pipeline-a'
assert difysvapi._get_latest_pending_form(key_c)['pipeline_uuid'] == 'pipeline-b'
assert difysvapi._dify_user_from_query(query_a) == difysvapi._dify_user_from_query(query_b)
assert difysvapi._dify_user_from_query(query_a) == difysvapi._dify_user_from_query(query_c)
difysvapi._PENDING_FORMS.clear()
def test_interactive_form_data_preserves_pipeline_uuid(self):
from langbot.pkg.provider.runners import difysvapi
pending_form = {
'pipeline_uuid': 'pipeline-routed',
'form_token': 'token-1',
'workflow_run_id': 'run-1',
'input_defs': [{'output_variable_name': 'comment', 'type': 'paragraph'}],
'actions': [{'id': 'approve', 'title': 'Approve'}],
}
field_form = difysvapi._field_input_form_data(pending_form, pending_form['input_defs'][0])
action_form = difysvapi._action_select_form_data(pending_form)
assert field_form['pipeline_uuid'] == 'pipeline-routed'
assert action_form['pipeline_uuid'] == 'pipeline-routed'
def test_explicit_stale_form_identifiers_do_not_fall_back_to_latest(self):
from langbot.pkg.provider.runners import difysvapi
runner = self._create_runner()
session_key = 'person_user-1'
difysvapi._PENDING_FORMS.clear()
difysvapi._set_pending_form(
session_key,
{'form_token': 'current-token', 'workflow_run_id': 'current-run'},
)
assert runner._resolve_pending_form(session_key, {'form_token': 'stale-token'}) is None
assert runner._resolve_pending_form(session_key, {'workflow_run_id': 'stale-run'}) is None
assert runner._resolve_pending_form(session_key, {'w_suffix': 'stale-suffix'}) is None
assert runner._merge_pending_form_action(session_key, {'form_token': 'stale-token'}) is None
assert runner._resolve_pending_form(session_key, {})['form_token'] == 'current-token'
difysvapi._PENDING_FORMS.clear()
def test_format_human_input_text_includes_field_help(self):
from langbot.pkg.provider.runners.difysvapi import _format_human_input_text