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