mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 01:15:24 +08:00
Apply group response rules before busy-session steering. Initialize usable private-message and group-mention routes for new bots, including Wizard-created pipelines, and preserve member-configured rules across saves and retries. Fix empty pipeline configuration, refresh the processor sidebar after automatic creation, and show creation feedback. Align routing regressions and use Node 24 for the browser CI job. Correct the task's erroneous agent attribution while preserving every code tree and the original human contribution. Co-authored-by: multica-agent <[email protected]>
299 lines
13 KiB
Python
299 lines
13 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_random_response_draw_is_reused_across_queue_rechecks(sample_query, mock_app, monkeypatch):
|
|
from langbot.pkg.pipeline.resprule.rules.random import RandomRespRule
|
|
from unittest.mock import Mock
|
|
|
|
draw = Mock(side_effect=[0.9, 0.1])
|
|
monkeypatch.setattr('langbot.pkg.pipeline.resprule.rules.random.random.random', draw)
|
|
matcher = RandomRespRule(mock_app)
|
|
for _ in range(3):
|
|
result = await matcher.match('hello', sample_query.message_chain, {'random': 0.5}, sample_query)
|
|
assert result.matching is False
|
|
draw.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
|