mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-27 02:45:24 +08:00
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:
co-authored by
multica-agent
parent
f8e32ef14f
commit
d13ae56ea5
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user