From b3574efd95f1cfe131bdc99ca01afca1e301c136 Mon Sep 17 00:00:00 2001 From: BiFangKNT <1320414964@qq.com> Date: Sat, 17 Oct 2026 20:03:24 +0800 Subject: [PATCH] fix(pipeline): preserve form callbacks and verify all group response rules Allow routed Dify form callbacks to resume without another mention or prefix while keeping ordinary messages subject to group response rules. Cover mention, prefix, regex, random probability, and combined rules. --- src/langbot/pkg/pipeline/resprule/resprule.py | 11 +- tests/unit_tests/pipeline/test_resprule.py | 111 +++++++++++++++++- 2 files changed, 115 insertions(+), 7 deletions(-) diff --git a/src/langbot/pkg/pipeline/resprule/resprule.py b/src/langbot/pkg/pipeline/resprule/resprule.py index 5f7d74524..3e8c16a96 100644 --- a/src/langbot/pkg/pipeline/resprule/resprule.py +++ b/src/langbot/pkg/pipeline/resprule/resprule.py @@ -37,10 +37,13 @@ class GroupRespondRuleCheckStage(stage.PipelineStage): if query.launcher_type.value != 'group': # 只处理群消息 return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) - # Verified interaction callbacks resume the original run without a new - # mention or prefix. Routing ordinary messages only selects a pipeline; - # it must not bypass that pipeline's group response rules. - if query.variables and query.variables.get('_interaction_submission'): + # Interaction submissions and routed platform form callbacks resume the + # original run without another mention or prefix. Ordinary routing only + # selects a pipeline and must still obey its group response rules. + if query.variables and ( + query.variables.get('_interaction_submission') + or (query.variables.get('_routed_by_rule') and query.variables.get('_dify_form_action')) + ): return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) rules = query.pipeline_config['trigger']['group-respond-rules'] diff --git a/tests/unit_tests/pipeline/test_resprule.py b/tests/unit_tests/pipeline/test_resprule.py index 3bee08d6f..4032ad6ff 100644 --- a/tests/unit_tests/pipeline/test_resprule.py +++ b/tests/unit_tests/pipeline/test_resprule.py @@ -167,13 +167,118 @@ async def test_routed_group_messages_still_obey_response_rules(mock_app, sample_ @pytest.mark.asyncio -async def test_interaction_submission_does_not_require_another_mention(mock_app, sample_query): +@pytest.mark.parametrize('routed', [False, True]) +@pytest.mark.parametrize( + 'overrides,text,mention,draw,matches,expected_text', + [ + pytest.param({'regexp': ['^hello$']}, 'hello', False, 0.9, True, 'hello', id='regexp-match'), + pytest.param({'regexp': ['^hello$']}, 'bye', False, 0.9, False, 'bye', id='regexp-miss'), + pytest.param({'regexp': ['^bye$', '^hello$']}, 'hello', False, 0.9, True, 'hello', id='second-regexp'), + pytest.param({'regexp': ['[']}, 'hello', False, 0.9, False, 'hello', id='invalid-regexp'), + pytest.param({'random': 0}, 'hello', False, 0.0, False, 'hello', id='random-disabled'), + pytest.param({'random': 1}, 'hello', False, 0.999, True, 'hello', id='random-always'), + pytest.param({'random': 0.25}, 'hello', False, 0.249, True, 'hello', id='random-hit'), + pytest.param({'random': 0.25}, 'hello', False, 0.25, False, 'hello', id='random-boundary'), + pytest.param({}, 'hello', True, 0.0, False, 'hello', id='mention-disabled'), + pytest.param({}, '!bot hello', False, 0.0, False, '!bot hello', id='prefix-disabled'), + pytest.param({'prefix': ['!other ', '!bot ']}, '!bot hello', False, 0.9, True, 'hello', id='second-prefix'), + pytest.param( + {'at': True, 'prefix': ['!bot '], 'regexp': ['^hello$'], 'random': 0.25}, + 'hello', + False, + 0.9, + True, + 'hello', + id='combined-regexp-match', + ), + pytest.param( + {'at': True, 'prefix': ['!bot '], 'regexp': ['^bye$'], 'random': 0.25}, + 'hello', + False, + 0.1, + True, + 'hello', + id='combined-random-fallback', + ), + pytest.param( + {'at': True, 'prefix': ['!bot '], 'regexp': ['^bye$'], 'random': 0.25}, + 'hello', + False, + 0.9, + False, + 'hello', + id='combined-all-miss', + ), + ], +) +async def test_group_response_rule_configuration( + mock_app, sample_query, monkeypatch, routed, overrides, text, mention, draw, matches, expected_text +): + resprule, entities, _, _ = get_modules() + random_rule = import_module('langbot.pkg.pipeline.resprule.rules.random') + monkeypatch.setattr(random_rule.random, 'random', lambda: draw) + sample_query.launcher_type = provider_session.LauncherTypes.GROUP + sample_query.adapter.bot_account_id = '999' + sample_query.variables = {'_routed_by_rule': routed} + sample_query.pipeline_config = { + 'trigger': {'group-respond-rules': {'at': False, 'prefix': [], 'regexp': [], 'random': 0, **overrides}} + } + parts = [platform_message.Plain(text=text)] + if mention: + parts.insert(0, platform_message.At(target='999')) + sample_query.message_chain = platform_message.MessageChain(parts) + stage = resprule.GroupRespondRuleCheckStage(mock_app) + await stage.initialize(sample_query.pipeline_config) + + result = await stage.process(sample_query, 'GroupRespondRuleCheckStage') + + assert result.result_type == (entities.ResultType.CONTINUE if matches else entities.ResultType.INTERRUPT) + assert str(result.new_query.message_chain) == expected_text + + +@pytest.mark.asyncio +@pytest.mark.parametrize('callback_key', ['_interaction_submission', '_dify_form_action']) +async def test_interaction_callbacks_do_not_require_another_mention(mock_app, sample_query, callback_key): resprule, entities, _, _ = get_modules() sample_query.launcher_type = provider_session.LauncherTypes.GROUP - sample_query.variables = {'_routed_by_rule': True, '_interaction_submission': {'interaction_id': 'form-1'}} + sample_query.adapter.bot_account_id = '999' + submission = {'action_id': 'approve'} + sample_query.variables = {'_routed_by_rule': True, callback_key: submission} + sample_query.message_chain = platform_message.MessageChain([platform_message.Plain(text='[Form Action: Approve]')]) + sample_query.pipeline_config = { + 'trigger': {'group-respond-rules': {'at': True, 'prefix': ['!bot '], 'regexp': [], 'random': 0}} + } stage = resprule.GroupRespondRuleCheckStage(mock_app) - stage.rule_matchers = [] + await stage.initialize(sample_query.pipeline_config) result = await stage.process(sample_query, 'GroupRespondRuleCheckStage') assert result.result_type == entities.ResultType.CONTINUE + assert str(result.new_query.message_chain) == '[Form Action: Approve]' + assert result.new_query.variables[callback_key] == submission + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'variables', + [ + {'_routed_by_rule': True, '_dify_form_action': None}, + {'_routed_by_rule': True, '_dify_form_action': {}}, + {'_routed_by_rule': False, '_dify_form_action': {'action_id': 'approve'}}, + ], +) +async def test_incomplete_form_callback_does_not_bypass_response_rules(mock_app, sample_query, variables): + resprule, entities, _, _ = get_modules() + sample_query.launcher_type = provider_session.LauncherTypes.GROUP + sample_query.adapter.bot_account_id = '999' + sample_query.variables = variables + sample_query.message_chain = platform_message.MessageChain([platform_message.Plain(text='[Form Action: Approve]')]) + sample_query.pipeline_config = { + 'trigger': {'group-respond-rules': {'at': True, 'prefix': ['!bot '], 'regexp': [], 'random': 0}} + } + stage = resprule.GroupRespondRuleCheckStage(mock_app) + await stage.initialize(sample_query.pipeline_config) + + result = await stage.process(sample_query, 'GroupRespondRuleCheckStage') + + assert result.result_type == entities.ResultType.INTERRUPT