mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-14 16:36:07 +00:00
feat: Add pipeline_uuid handling across multiple adapters and update related tests
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user