Files
LangBot/tests/unit_tests/pipeline/test_resprule.py
T
BiFangKNT b3574efd95 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.
2026-10-17 20:03:24 +08:00

285 lines
12 KiB
Python

"""
GroupRespondRuleCheckStage unit tests
Tests the actual GroupRespondRuleCheckStage implementation from pkg.pipeline.resprule
"""
import pytest
from unittest.mock import AsyncMock, Mock
from importlib import import_module
import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.platform.message as platform_message
def get_modules():
"""Lazy import to ensure proper initialization order"""
# Import pipelinemgr first to trigger proper stage registration
# pipelinemgr = import_module('langbot.pkg.pipeline.pipelinemgr')
resprule = import_module('langbot.pkg.pipeline.resprule.resprule')
entities = import_module('langbot.pkg.pipeline.entities')
rule = import_module('langbot.pkg.pipeline.resprule.rule')
rule_entities = import_module('langbot.pkg.pipeline.resprule.entities')
return resprule, entities, rule, rule_entities
@pytest.mark.asyncio
async def test_person_message_skip(mock_app, sample_query):
"""Test person message skips rule check"""
resprule, entities, rule, rule_entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.PERSON
sample_query.pipeline_config = {'trigger': {'group-respond-rules': {}}}
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
assert result.new_query == sample_query
@pytest.mark.asyncio
async def test_group_message_no_match(mock_app, sample_query):
"""Test group message with no matching rules"""
resprule, entities, rule, rule_entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.GROUP
sample_query.launcher_id = '12345'
sample_query.pipeline_config = {'trigger': {'group-respond-rules': {}}}
# Create mock rule matcher that doesn't match
mock_rule = Mock(spec=rule.GroupRespondRule)
mock_rule.match = AsyncMock(
return_value=rule_entities.RuleJudgeResult(matching=False, replacement=sample_query.message_chain)
)
stage = resprule.GroupRespondRuleCheckStage(mock_app)
await stage.initialize(sample_query.pipeline_config)
stage.rule_matchers = [mock_rule]
result = await stage.process(sample_query, 'GroupRespondRuleCheckStage')
assert result.result_type == entities.ResultType.INTERRUPT
assert result.new_query == sample_query
mock_rule.match.assert_called_once()
@pytest.mark.asyncio
async def test_group_message_match(mock_app, sample_query):
"""Test group message with matching rule"""
resprule, entities, rule, rule_entities = get_modules()
sample_query.launcher_type = provider_session.LauncherTypes.GROUP
sample_query.launcher_id = '12345'
sample_query.pipeline_config = {'trigger': {'group-respond-rules': {}}}
# Create new message chain after rule processing
new_chain = platform_message.MessageChain([platform_message.Plain(text='Processed message')])
# Create mock rule matcher that matches
mock_rule = Mock(spec=rule.GroupRespondRule)
mock_rule.match = AsyncMock(return_value=rule_entities.RuleJudgeResult(matching=True, replacement=new_chain))
stage = resprule.GroupRespondRuleCheckStage(mock_app)
await stage.initialize(sample_query.pipeline_config)
stage.rule_matchers = [mock_rule]
result = await stage.process(sample_query, 'GroupRespondRuleCheckStage')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query == sample_query
assert sample_query.message_chain == new_chain
mock_rule.match.assert_called_once()
@pytest.mark.asyncio
async def test_atbot_rule_match(mock_app, sample_query):
"""Test AtBotRule removes At component"""
resprule, entities, rule, rule_entities = get_modules()
atbot_module = import_module('langbot.pkg.pipeline.resprule.rules.atbot')
sample_query.launcher_type = provider_session.LauncherTypes.GROUP
sample_query.adapter.bot_account_id = '999'
# Create message chain with At component
message_chain = platform_message.MessageChain(
[platform_message.At(target='999'), platform_message.Plain(text='Hello bot')]
)
sample_query.message_chain = message_chain
atbot_rule = atbot_module.AtBotRule(mock_app)
await atbot_rule.initialize()
result = await atbot_rule.match(str(message_chain), message_chain, {}, sample_query)
assert result.matching is True
# At component should be removed
assert len(result.replacement.root) == 1
assert isinstance(result.replacement.root[0], platform_message.Plain)
@pytest.mark.asyncio
async def test_atbot_rule_no_match(mock_app, sample_query):
"""Test AtBotRule when no At component present"""
resprule, entities, rule, rule_entities = get_modules()
atbot_module = import_module('langbot.pkg.pipeline.resprule.rules.atbot')
sample_query.launcher_type = provider_session.LauncherTypes.GROUP
sample_query.adapter.bot_account_id = '999'
# Create message chain without At component
message_chain = platform_message.MessageChain([platform_message.Plain(text='Hello')])
sample_query.message_chain = message_chain
atbot_rule = atbot_module.AtBotRule(mock_app)
await atbot_rule.initialize()
result = await atbot_rule.match(str(message_chain), message_chain, {}, sample_query)
assert result.matching is False
@pytest.mark.asyncio
@pytest.mark.parametrize('routed', [False, True])
@pytest.mark.parametrize('kind', ['plain', 'mention', 'prefix'])
async def test_routed_group_messages_still_obey_response_rules(mock_app, sample_query, routed, kind):
resprule, entities, _, _ = get_modules()
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': True, 'prefix': ['!bot '], 'regexp': [], 'random': 0}}
}
parts = [platform_message.Plain(text='!bot hello' if kind == 'prefix' else 'hello')]
if kind == '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')
expected = entities.ResultType.INTERRUPT if kind == 'plain' else entities.ResultType.CONTINUE
assert result.result_type == expected
if kind != 'plain':
assert str(sample_query.message_chain) == 'hello'
@pytest.mark.asyncio
@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.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)
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