fix: bot routing setup and busy-session response checks (#2728)

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]>
This commit is contained in:
RockChinQ
2026-10-20 21:22:53 +08:00
committed by GitHub
co-authored by multica-agent
parent f8e32ef14f
commit d13ae56ea5
35 changed files with 1064 additions and 81 deletions
@@ -232,3 +232,35 @@ class BotsRouterGroup(group.RouterGroup):
async def _(bot_uuid: str, admin_id: int, request_context: RequestContext) -> str:
await self.ap.bot_service.delete_bot_admin(request_context, bot_uuid, admin_id)
return self.success()
@self.route(
'/<bot_uuid>/group-filter-migration',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_VIEW,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
try:
result = await self.ap.bot_service.preview_group_filter_migration(request_context, bot_uuid)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data=result)
@self.route(
'/<bot_uuid>/group-filter-migration',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
permission=Permission.RESOURCE_MANAGE,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.get_json(silent=True) or {}
disable_pipeline_at_bot = bool(json_data.get('disable_pipeline_at_bot', True))
try:
result = await self.ap.bot_service.apply_group_filter_migration(
request_context, bot_uuid, disable_pipeline_at_bot=disable_pipeline_at_bot
)
except BotApplyError as exc:
return self._apply_error_response(exc)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data=result)
@@ -437,7 +437,6 @@ class AgentService:
'name': agent_data.get('name') or t('New Pipeline'),
'description': agent_data.get('description') or '',
'emoji': agent_data.get('emoji') or '⚙️',
'config': {},
},
)
return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE}
+241 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import uuid
import typing
import json
import asyncio
import sqlalchemy
from ....core import app
@@ -18,6 +19,7 @@ from ....platform.sources import http_bot_signing
from ....platform.adapter_names import canonical_adapter_name
from ....cloud.plan_policy import require_workspace_platform
from ....agent.runner.errors import RunnerNotFoundError
from ....i18n import t
class BotService:
@@ -43,6 +45,7 @@ class BotService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
self._default_pipeline_lock = asyncio.Lock()
def _get_adapter_component(self, adapter_name: str) -> engine.Component | None:
"""Return the discovered platform adapter component for an adapter name."""
@@ -202,6 +205,22 @@ class BotService:
'diagnostic_details': diagnostic_steps or [],
}
@staticmethod
def _extract_dry_run_bot_account_id(
context: typing.Any,
event_data: typing.Any,
) -> str | None:
"""Extract bot_account_id from dry-run context for is_at_bot filter support."""
if isinstance(context, dict):
val = context.get('bot_account_id')
if val is not None:
return str(val)
if isinstance(event_data, dict):
val = event_data.get('bot_account_id')
if val is not None:
return str(val)
return None
@staticmethod
def _build_dry_run_event(event_type: str, event_data: typing.Any, context: typing.Any) -> dict[str, typing.Any]:
event: dict[str, typing.Any] = {}
@@ -354,6 +373,7 @@ class BotService:
bindings,
event,
event_type,
bot_account_id=self._extract_dry_run_bot_account_id(context, event_data),
)
if selected_binding is None:
return self._diagnostic_result(
@@ -490,6 +510,60 @@ class BotService:
],
)
async def _build_default_event_bindings(self, context: TenantContext) -> list[dict]:
"""Build the default event bindings for a new bot.
Two bindings route message.received to the first available pipeline:
- private chat: always respond
- group chat: require an @mention of the bot (is_at_bot=true)
The group @mention check runs at the routing layer, so messages that
do not match never create a pipeline task. Existing bots keep their
current bindings; this only applies to bots created without any.
"""
# Serialize first-bot setup without retaining an unbounded lock per Workspace.
async with self._default_pipeline_lock:
pipelines = await self.ap.pipeline_service.get_pipelines(context)
if pipelines:
selected = next((item for item in pipelines if item.get('is_default')), pipelines[0])
pipeline_uuid = selected.get('uuid')
else:
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
context, {'name': t('New Pipeline'), 'description': '', 'emoji': '⚙️'}, default=True
)
if not pipeline_uuid:
return []
return [
{
'id': str(uuid.uuid4()),
'event_pattern': 'message.received',
'target_type': 'pipeline',
'target_uuid': pipeline_uuid,
'filters': [
{'field': 'chat_type', 'operator': 'eq', 'value': 'person'},
],
'priority': 0,
'enabled': True,
'description': 'Private chat: respond directly',
'order': 0,
},
{
'id': str(uuid.uuid4()),
'event_pattern': 'message.received',
'target_type': 'pipeline',
'target_uuid': pipeline_uuid,
'filters': [
{'field': 'chat_type', 'operator': 'eq', 'value': 'group'},
{'field': 'is_at_bot', 'operator': 'eq', 'value': True},
],
'priority': 0,
'enabled': True,
'description': 'Group chat: respond only when the bot is @mentioned',
'order': 1,
},
]
async def _normalize_event_bindings(self, context: TenantContext, bindings: list[dict] | None) -> list[dict]:
"""Validate and normalize Bot event bindings."""
if not bindings:
@@ -700,7 +774,10 @@ class BotService:
await require_workspace_platform(self.ap, workspace_uuid, bot_data['adapter'])
bot_data['uuid'] = str(uuid.uuid4())
bot_data['workspace_uuid'] = workspace_uuid
bot_data.setdefault('event_bindings', [])
if not bot_data.get('event_bindings'):
bot_data['event_bindings'] = await self._build_default_event_bindings(context)
else:
bot_data['event_bindings'] = await self._normalize_event_bindings(context, bot_data['event_bindings'])
bot_data.setdefault('plugin_processors', [])
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
@@ -860,6 +937,169 @@ class BotService:
'stale_routes': stale_routes,
}
async def preview_group_filter_migration(self, context: TenantContext, bot_uuid: str) -> dict[str, typing.Any]:
"""Preview the effect of promoting the pipeline at-bot rule to a routing-layer filter.
Detects bots whose pipeline trigger still requires at-bot, and whose event
bindings do not already filter group messages by is_at_bot. Returns the
proposed binding changes plus a per-pipeline summary. Does not persist.
"""
from ....platform.botmgr import RuntimeBot
bot = await self.get_bot(context, bot_uuid, include_secret=False)
if bot is None:
raise WorkspaceNotFoundError('Bot not found')
bindings = RuntimeBot._get_event_bindings_from_value(bot.get('event_bindings') or [])
pipeline_uuids = {
str(b.get('target_uuid')) for b in bindings if b.get('target_type') == 'pipeline' and b.get('target_uuid')
}
# Load each pipeline's trigger config to detect at-bot still enabled.
pipeline_flags: dict[str, dict[str, typing.Any]] = {}
for pipeline_uuid in pipeline_uuids:
pipeline = await self.ap.pipeline_service.get_pipeline(context, pipeline_uuid)
trigger = (pipeline or {}).get('config', {}).get('trigger', {}) if pipeline else {}
group_rules = trigger.get('group-respond-rules', {}) if isinstance(trigger, dict) else {}
pipeline_flags[pipeline_uuid] = {
'at_bot_enabled': bool(group_rules.get('at')) if isinstance(group_rules, dict) else False,
'prefix': group_rules.get('prefix', []) if isinstance(group_rules, dict) else [],
'regexp': group_rules.get('regexp', []) if isinstance(group_rules, dict) else [],
'random': group_rules.get('random', 0.0) if isinstance(group_rules, dict) else 0.0,
}
# A binding "already has the at-bot filter" if it filters chat_type=group
# AND requires is_at_bot=true (or non-false).
def _has_group_at_filter(binding: dict) -> bool:
filters = binding.get('filters') or []
if not isinstance(filters, list):
return False
has_group = any(
isinstance(f, dict)
and f.get('field') == 'chat_type'
and f.get('operator', 'eq') == 'eq'
and f.get('value') == 'group'
for f in filters
)
has_at = any(
isinstance(f, dict)
and f.get('field') == 'is_at_bot'
and f.get('operator', 'eq') == 'eq'
and f.get('value') is True
for f in filters
)
return has_group and has_at
needs_migration = [pipeline_uuid for pipeline_uuid, flags in pipeline_flags.items() if flags['at_bot_enabled']]
bindings_with_at_filter = [b for b in bindings if _has_group_at_filter(b)]
bindings_missing_at_filter = [
b
for b in bindings
if b.get('target_type') == 'pipeline'
and str(b.get('target_uuid') or '') in needs_migration
and not _has_group_at_filter(b)
]
proposed_bindings: list[dict[str, typing.Any]] = []
for binding in bindings_missing_at_filter:
new_binding = json.loads(json.dumps(binding))
filters = list(new_binding.get('filters') or [])
if not any(
isinstance(f, dict) and f.get('field') == 'chat_type' and f.get('value') == 'group' for f in filters
):
filters.append({'field': 'chat_type', 'operator': 'eq', 'value': 'group'})
if not any(isinstance(f, dict) and f.get('field') == 'is_at_bot' for f in filters):
filters.append({'field': 'is_at_bot', 'operator': 'eq', 'value': True})
new_binding['filters'] = filters
proposed_bindings.append(new_binding)
return {
'bot_uuid': bot_uuid,
'needs_migration': bool(bindings_missing_at_filter),
'pipelines_with_at_bot': sorted(needs_migration),
'pipeline_flags': pipeline_flags,
'bindings_already_filtered': [b.get('id') for b in bindings_with_at_filter],
'bindings_to_update': [
{
'binding_id': b.get('id'),
'event_pattern': b.get('event_pattern'),
'target_type': b.get('target_type'),
'target_uuid': b.get('target_uuid'),
'current_filters': b.get('filters') or [],
'proposed_filters': next(
(p.get('filters') for p in proposed_bindings if p.get('id') == b.get('id')),
b.get('filters') or [],
),
}
for b in bindings_missing_at_filter
],
}
async def apply_group_filter_migration(
self,
context: TenantContext,
bot_uuid: str,
*,
disable_pipeline_at_bot: bool = True,
) -> dict[str, typing.Any]:
"""Apply the group filter migration previewed by preview_group_filter_migration.
Updates the bot's event_bindings to add is_at_bot=true filter on group
bindings whose pipeline still has at-bot enabled. Optionally disables the
pipeline's at-bot trigger rule so the filter is not checked twice. Existing
prefix/regexp/random rules remain untouched. Idempotent.
"""
from ....platform.botmgr import RuntimeBot
preview = await self.preview_group_filter_migration(context, bot_uuid)
if not preview['needs_migration']:
return {'bot_uuid': bot_uuid, 'applied': False, 'reason': 'no_migration_needed'}
bot = await self.get_bot(context, bot_uuid, include_secret=False)
if bot is None:
raise WorkspaceNotFoundError('Bot not found')
bindings = RuntimeBot._get_event_bindings_from_value(bot.get('event_bindings') or [])
bindings_by_id = {b.get('id'): b for b in bindings if b.get('id')}
for item in preview['bindings_to_update']:
binding_id = item['binding_id']
existing = bindings_by_id.get(binding_id)
if existing is None:
continue
existing['filters'] = item['proposed_filters']
update_payload = {
'event_bindings': await self._normalize_event_bindings(context, bindings),
}
await self.update_bot(context, bot_uuid, update_payload)
updated_pipelines: list[str] = []
if disable_pipeline_at_bot:
for pipeline_uuid in preview['pipelines_with_at_bot']:
pipeline = await self.ap.pipeline_service.get_pipeline(context, pipeline_uuid, include_secret=True)
if not pipeline:
continue
config = pipeline.get('config') or {}
trigger = config.get('trigger') or {}
group_rules = trigger.get('group-respond-rules') or {}
if not isinstance(group_rules, dict) or not group_rules.get('at'):
continue
new_config = json.loads(json.dumps(config))
new_config.setdefault('trigger', {}).setdefault('group-respond-rules', {})['at'] = False
await self.ap.pipeline_service.update_pipeline(
context,
pipeline_uuid,
{'config': new_config},
)
updated_pipelines.append(pipeline_uuid)
return {
'bot_uuid': bot_uuid,
'applied': True,
'updated_bindings': [item['binding_id'] for item in preview['bindings_to_update']],
'disabled_pipeline_at_bot': updated_pipelines,
}
async def send_http_bot_test_message(
self,
context: TenantContext,
+1 -1
View File
@@ -189,7 +189,7 @@ class PipelineService:
pipeline_data['stages'] = default_stage_order.copy()
pipeline_data['is_default'] = default
if 'config' not in pipeline_data:
if 'config' not in pipeline_data or pipeline_data['config'] == {}:
pipeline_data['config'] = await self.get_default_pipeline_config(context)
RunnerConfigResolver.validate_pipeline_config(pipeline_data['config'])
+29
View File
@@ -387,6 +387,35 @@ class LangBotMCPServer:
async def list_bot_event_route_statuses(bot_uuid: str) -> str:
return _dump(await ap.bot_service.list_event_route_statuses(bot_uuid))
@mcp.tool(
description=(
'Preview promoting the pipeline at-bot trigger rule to a routing-layer '
'is_at_bot filter. Reports which bindings and pipelines need migration '
'without persisting changes.'
)
)
async def preview_bot_group_filter_migration(bot_uuid: str) -> str:
context = _authorized(Permission.RESOURCE_VIEW)
return _dump(await ap.bot_service.preview_group_filter_migration(context, bot_uuid))
@mcp.tool(
description=(
'Apply the group filter migration previewed by '
'preview_bot_group_filter_migration. Adds is_at_bot=true filter to group '
'message bindings and, when disable_pipeline_at_bot is true (default), '
'turns off the pipeline trigger at-bot rule. Idempotent.'
)
)
async def apply_bot_group_filter_migration(bot_uuid: str, disable_pipeline_at_bot: bool = True) -> str:
context = _authorized(Permission.RESOURCE_MANAGE)
return _dump(
await ap.bot_service.apply_group_filter_migration(
context,
bot_uuid,
disable_pipeline_at_bot=disable_pipeline_at_bot,
)
)
# ----- Pipelines ----------------------------------------------- #
@mcp.tool(description='List all pipelines.')
async def list_pipelines() -> str:
+17
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import copy
import traceback
from ..core import app
@@ -59,6 +60,22 @@ class Controller:
query.pipeline_config = pipeline.pipeline_entity.config
query.variables['_pipeline_bound_plugins'] = pipeline.bound_plugins
query.variables['_pipeline_bound_mcp_servers'] = pipeline.bound_mcp_servers
if getattr(getattr(query, 'launcher_type', None), 'value', None) == 'group':
from .resprule.resprule import GroupRespondRuleCheckStage
from .entities import ResultType
candidate = copy.copy(query)
candidate.message_chain = query.message_chain.model_copy(deep=True)
candidate.variables = dict(query.variables)
gate = GroupRespondRuleCheckStage(self.ap)
await gate.initialize(query.pipeline_config)
result = await gate.process(candidate, 'GroupRespondRuleCheckStage')
random_key = '_group_response_random_draw'
if random_key in candidate.variables:
query.variables[random_key] = candidate.variables[random_key]
if result.result_type == ResultType.INTERRUPT:
return False
query = candidate
return await self.ap.agent_run_orchestrator.try_claim_steering_from_query(query)
except Exception as exc:
self.ap.logger.warning(
@@ -18,4 +18,7 @@ class RandomRespRule(rule_model.GroupRespondRule):
) -> entities.RuleJudgeResult:
random_rate = rule_dict['random']
return entities.RuleJudgeResult(matching=random.random() < random_rate, replacement=message_chain)
key = '_group_response_random_draw'
if key not in query.variables:
query.variables[key] = random.random()
return entities.RuleJudgeResult(matching=query.variables[key] < random_rate, replacement=message_chain)
+20 -4
View File
@@ -229,6 +229,7 @@ class RuntimeBot:
def _augment_event_data(
cls,
event_data: dict[str, typing.Any],
bot_account_id: str | None = None,
) -> dict[str, typing.Any]:
"""Inject virtual computed fields to simplify common filter patterns."""
message_chain = event_data.get('message_chain')
@@ -240,6 +241,13 @@ class RuntimeBot:
event_data['message_element_types'] = [
comp.get('type', '') for comp in message_chain if isinstance(comp, dict)
]
if bot_account_id:
event_data['is_at_bot'] = any(
isinstance(comp, dict)
and comp.get('type') == 'At'
and str(comp.get('target', '')) == str(bot_account_id)
for comp in message_chain
)
if 'group' in event_data:
event_data['chat_type'] = 'group' if event_data.get('group') is not None else 'person'
return event_data
@@ -249,13 +257,14 @@ class RuntimeBot:
cls,
event: platform_events.EBAEvent,
filters: typing.Any,
bot_account_id: str | None = None,
) -> bool:
if not filters:
return True
if not isinstance(filters, list):
return False
event_data = cls._augment_event_data(cls._safe_model_dump(event))
event_data = cls._augment_event_data(cls._safe_model_dump(event), bot_account_id=bot_account_id)
return all(
cls._match_event_filter(event_data, event_filter)
for event_filter in filters
@@ -283,6 +292,7 @@ class RuntimeBot:
bindings: list[dict[str, typing.Any]],
event: platform_events.EBAEvent,
event_type: str,
bot_account_id: str | None = None,
) -> tuple[dict[str, typing.Any] | None, list[dict[str, typing.Any]]]:
"""Evaluate Bot event bindings with the same precedence used at runtime."""
matched: list[tuple[int, int, dict[str, typing.Any], dict[str, typing.Any]]] = []
@@ -325,7 +335,7 @@ class RuntimeBot:
diagnostic_steps.append(step)
continue
if not cls._match_event_filters(event, binding.get('filters')):
if not cls._match_event_filters(event, binding.get('filters'), bot_account_id=bot_account_id):
step['failure_code'] = 'filters_mismatch'
step['reason'] = 'Event data does not satisfy binding filters'
diagnostic_steps.append(step)
@@ -356,7 +366,10 @@ class RuntimeBot:
event_type: str,
) -> dict[str, typing.Any] | None:
"""Resolve the highest priority Bot event binding for a platform event."""
selected, _ = self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
bot_account_id = getattr(getattr(self, 'adapter', None), 'bot_account_id', None) or None
selected, _ = self._evaluate_eba_event_bindings(
self._get_event_bindings(), event, event_type, bot_account_id=bot_account_id
)
return selected
def diagnose_eba_event_binding(
@@ -365,7 +378,10 @@ class RuntimeBot:
event_type: str,
) -> tuple[dict[str, typing.Any] | None, list[dict[str, typing.Any]]]:
"""Return the selected event binding plus per-binding diagnostic steps."""
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
bot_account_id = getattr(getattr(self, 'adapter', None), 'bot_account_id', None) or None
return self._evaluate_eba_event_bindings(
self._get_event_bindings(), event, event_type, bot_account_id=bot_account_id
)
@staticmethod
def _route_ref(