mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 08:55:44 +08:00
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.
285 lines
12 KiB
Python
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
|