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
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '25'
node-version: '24'
- name: Install pnpm
uses: pnpm/action-setup@v4
+82
View File
@@ -0,0 +1,82 @@
# Message routing acceptance
Use an isolated worktree, data directory, ports and Plugin Runtime. Preserve the
member's database and bot configuration. Back up SQLite with its backup API before
repairing data; do not copy a live WAL database with a plain file copy.
## Reproduction and repair
The product processor form sends `POST /api/v1/agents` with `kind=pipeline`.
Previously `AgentService` forwarded `config={}`, bypassing the Pipeline service's
default-config branch. The row was inserted, then stage initialization raised
`KeyError: safety`. Repeated failed creates could leave empty-config rows.
Pipeline creation now omits this empty override and the Pipeline service also
handles explicit `{}` as a request for defaults. Existing non-empty configs are
not replaced. For affected historical rows, back up first, inspect their config,
and repair only rows whose entire config is exactly `{}` with the version's
default Pipeline template. Preserve UUID, name and all other columns. Do not
silently reset partial or non-empty configurations.
New bots previously had no routes if there was no Pipeline. First-bot setup now
creates one default Pipeline and reuses it, serialized within the instance.
Existing bots are never rewritten. The old message preset added one unfiltered
route; it now adds separate private and group-mention routes, with an explicit
boolean mention condition in the editor. Reapplying preserves manual targets and
disabled routes.
## Acceptance sequence
1. Use the real WebUI to create a Pipeline, verify success, refresh its page, and
inspect persisted `trigger`, `safety`, `ai`, `output` sections. Correlate the
creation request with the HTTP access log.
2. Create a separate test bot. Confirm that the two message routes already
exist. Expand conditions and verify the private/group split and boolean
`is_at_bot=true`.
3. On the test bot only, remove the generated routes, use the message template,
save, wait for success, then refresh. Click the template again and check that
it neither duplicates conditions nor replaces manual targets.
4. Connect a local OneBot Universal protocol simulator using its own token and
fake bot/group/user IDs. Never attach the simulator to a real QQ account.
5. Send a bounded sequence: unmentioned group, mention of another user, private,
mention of the bot. Capture per-case task counters, Core logs, and the WebUI
bot log/matching records. The first two must create no Pipeline tasks; the
latter two must route to the selected Pipeline.
6. Verify actual replies separately with an authorized model. A matched route
followed by a missing-model error proves routing, not successful AI replies.
Label local protocol simulation separately from real-platform acceptance.
7. Confirm original member rows/configs are unchanged, capture service ownership
and health, and keep the isolated dev services available for manual review.
Use existing `skills/.env` conventions for machine-specific URLs. Apply resource
stop thresholds before bounded tests; do not treat a short idle sample as a soak
test or a health endpoint as feature acceptance.
## Wizard regression coverage
The Wizard creates a dedicated Pipeline before creating its disabled Bot, and
passes the same explicit private/group-mention template used by manual setup.
Returning to the same platform reuses the Bot and retains unsaved form values.
A successful Pipeline remains in the in-page draft if Bot creation fails, so a
retry reuses it. Create/save operations have a synchronous reentry guard.
Saving adapter settings reads the current Bot and only initializes routes when
none exist; it never replaces nonempty custom rules or re-enables disabled ones.
For real UI acceptance, use a separate empty data directory and ports; never
reset the member's database or Wizard progress. Complete registration, platform
selection, save/enable, receipt of an isolated protocol message, AI choice and
finish. Refresh the completion page and compare persisted Bot/Pipeline UUIDs.
Return to the platform step and submit again; assert one Bot, one Pipeline and
unchanged route IDs. Remove/reapply the message template through the Bot UI and
repeat the private/unmentioned-group/other-mention/bot-mention protocol probes.
Disable a rule through the UI, restore a QA-only step-1 Wizard draft pointing to
the existing Bot/Pipeline, save through the UI, and compare every binding field.
Verify the disabled rule also stays inactive at runtime.
The 2026-10-20 Mac run completed this flow against real Core/RT using native
Codex Computer Use. Both default and manual templates created tasks only for
private messages and mentions of this bot. After custom group-route disablement
and Wizard resave, even a mention created no task. Model execution failed with
missing credentials; successful AI replies and real long-running busy model
sessions are not certified by this run. Busy-session controller/response-rule
unit tests are a separate regression result, not a substitute for that evidence.
+16
View File
@@ -180,6 +180,22 @@ state as well; it is not the same operation as disconnecting an account.
- The global key is plaintext in config.yaml — only enable it on trusted/internal
deployments and serve over HTTPS.
## Default message routes
`create_bot` without non-empty `event_bindings` adds two explicit message routes:
private chat (`chat_type eq person`) and group chat (`chat_type eq group` plus
`is_at_bot eq true`). Both use the Workspace's default Pipeline, or the first
available Pipeline. If none exists, first-bot setup creates one with the full
default config. Creating a Pipeline via `create_agent(kind="pipeline")` also uses
that full default config. Configure an authorized model before testing replies.
Explicit routes on create are preserved; `update_bot` never automatically
replaces manual routes, including an intentionally empty list. The WebUI message
template uses the same private/group conditions and preserves existing routes
when applied repeatedly. Unmentioned group messages do not enter the Pipeline.
An existing broad route can still match first; inspect route order and diagnostics
before changing a member's configuration.
## Event processors
Create a processor with `kind: "event_processor"` and basic information. Without
@@ -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(
@@ -16,8 +16,16 @@ stages:
en_US: At
zh_Hans: '@'
description:
en_US: Whether to trigger when the message mentions the bot
zh_Hans: 是否在消息@机器人时触发
en_US: >-
Whether to trigger when the message @-mentions the bot (fallback filter
inside the pipeline). For new bots this check is handled earlier at the
routing layer via the is_at_bot filter — keeping it enabled here applies
it a second time. Use the group-filter migration assistant to move the
check to the routing layer and disable this option.
zh_Hans: >-
是否仅响应 @机器人 的群消息(流水线内部过滤,兜底用)。新建机器人的路由层已通过
is_at_bot 条件提前过滤,再启用此项会重复检查。建议使用群消息过滤迁移助手将过滤
前移到路由层后,再关闭此开关。
type: boolean
required: true
default: false
@@ -778,7 +778,6 @@ class TestAgentServiceCreateUpdateDelete:
'name': 'Pipeline Agent',
'description': 'Legacy pipeline',
'emoji': 'P',
'config': {},
},
)
app.pipeline_service.update_pipeline.assert_awaited_once_with(
@@ -0,0 +1,84 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
from langbot.pkg.api.http.service.bot import BotService
async def test_concurrent_first_bots_share_one_default_pipeline():
pipelines = []
async def create(context, data, default=False):
await asyncio.sleep(0)
assert default
pipelines.append({'uuid': 'p', 'is_default': True})
return 'p'
ap = SimpleNamespace(
pipeline_service=SimpleNamespace(
get_pipelines=AsyncMock(side_effect=lambda context: list(pipelines)),
create_pipeline=AsyncMock(side_effect=create),
)
)
service = BotService(ap)
first, second = await asyncio.gather(
service._build_default_event_bindings('workspace'),
service._build_default_event_bindings('workspace'),
)
ap.pipeline_service.create_pipeline.assert_awaited_once()
for bindings in (first, second):
assert len(bindings) == 2
assert all(binding['target_uuid'] == 'p' for binding in bindings)
assert bindings[0]['filters'] == [{'field': 'chat_type', 'operator': 'eq', 'value': 'person'}]
assert bindings[1]['filters'][-1] == {'field': 'is_at_bot', 'operator': 'eq', 'value': True}
async def test_existing_default_pipeline_preferred_without_creating_another():
ap = SimpleNamespace(
pipeline_service=SimpleNamespace(
get_pipelines=AsyncMock(
return_value=[
{'uuid': 'recent'},
{'uuid': 'default', 'is_default': True},
]
),
create_pipeline=AsyncMock(),
)
)
routes = await BotService(ap)._build_default_event_bindings('workspace')
assert all(route['target_uuid'] == 'default' for route in routes)
ap.pipeline_service.create_pipeline.assert_not_awaited()
async def test_explicit_manual_route_does_not_create_or_replace_defaults():
ap = SimpleNamespace(
instance_config=SimpleNamespace(data={}),
persistence_mgr=SimpleNamespace(execute_async=AsyncMock()),
pipeline_service=SimpleNamespace(get_pipelines=AsyncMock(), create_pipeline=AsyncMock()),
)
service = BotService(ap)
service.get_bot = AsyncMock(return_value={'enable': False})
route = {
'id': 'manual-route',
'event_pattern': 'message.received',
'target_type': 'discard',
'target_uuid': '',
'filters': [{'field': 'chat_id', 'operator': 'eq', 'value': 'manual-group'}],
'priority': 10,
'enabled': False,
'description': 'Keep this manual rule',
'order': 0,
}
await service.create_bot(
'workspace',
{
'name': 'Manual',
'adapter': 'onebot-v11',
'adapter_config': {},
'event_bindings': [route],
},
)
inserted = ap.persistence_mgr.execute_async.await_args_list[0].args[0].compile().params
assert inserted['event_bindings'] == [route]
ap.pipeline_service.get_pipelines.assert_not_awaited()
ap.pipeline_service.create_pipeline.assert_not_awaited()
@@ -364,6 +364,7 @@ class TestBotServiceCreateBot:
return_value={'uuid': 'uuid-1', 'name': 'Bot 1', 'adapter': 'telegram'}
)
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'default-pipeline'}]))
service = BotService(ap)
# Execute & Verify
@@ -409,6 +410,7 @@ class TestBotServiceCreateBot:
return_value={'uuid': 'new-uuid', 'name': 'New Bot', 'adapter': 'telegram', 'enable': False}
)
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'default-pipeline'}]))
service = BotService(ap)
# Execute
@@ -429,10 +431,12 @@ class TestBotServiceCreateBot:
'name': 'New Bot',
'adapter': 'telegram',
'adapter_config': {},
'event_bindings': [],
'plugin_processors': [],
}
assert {key: values.get(key) for key in expected} == expected
assert len(values['event_bindings']) == 2
assert all(b['target_uuid'] == 'default-pipeline' for b in values['event_bindings'])
assert values['event_bindings'][1]['filters'][-1] == {'field': 'is_at_bot', 'operator': 'eq', 'value': True}
ap.platform_mgr.load_bot.assert_not_awaited()
async def test_create_bot_scopes_insert_without_legacy_pipeline_fields(self):
@@ -467,6 +471,7 @@ class TestBotServiceCreateBot:
}
)
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'default-pipeline'}]))
service = BotService(ap)
# Execute
@@ -509,6 +514,7 @@ class TestBotServiceCreateBot:
)
ap.persistence_mgr = PersistenceManager(ap)
ap.persistence_mgr.db = SimpleNamespace(get_engine=lambda: engine)
ap.pipeline_service = SimpleNamespace(get_pipelines=AsyncMock(return_value=[{'uuid': 'default-pipeline'}]))
service = BotService(ap)
try:
async with engine.begin() as connection:
@@ -249,7 +249,8 @@ class TestPipelineServiceCreatePipeline:
with pytest.raises(ValueError, match='Maximum number of pipelines'):
await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})
async def test_create_pipeline_no_limit(self):
@pytest.mark.parametrize('config_fields', [{}, {'config': {}}])
async def test_create_pipeline_no_limit(self, config_fields):
"""Creates pipeline without limit when max_pipelines=-1."""
# Setup
ap = SimpleNamespace()
@@ -276,7 +277,7 @@ class TestPipelineServiceCreatePipeline:
with patch(
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
):
bot_uuid = await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})
bot_uuid = await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline', **config_fields})
# Verify
assert bot_uuid is not None
@@ -81,6 +81,39 @@ async def test_try_claim_steering_sets_pipeline_context_before_claiming():
app.agent_run_orchestrator.try_claim_steering_from_query.assert_awaited_once_with(query)
@pytest.mark.asyncio
@pytest.mark.parametrize('text,expected', [('ordinary chat', False), ('qa: hello', True)])
@pytest.mark.parametrize('claim_result', [True, False])
async def test_busy_group_steering_obeys_response_rules(sample_query, text, expected, claim_result):
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes, Session
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
app = make_app()
app.sess_mgr.get_session.return_value = Session(
instance_uuid='test-instance',
workspace_uuid='test-workspace',
placement_generation=1,
launcher_type=LauncherTypes.GROUP,
launcher_id=sample_query.launcher_id,
)
pipeline = make_pipeline()
pipeline.pipeline_entity.config['trigger'] = {
'group-respond-rules': {'at': True, 'prefix': ['qa:'], 'regexp': [], 'random': 0}
}
app.pipeline_mgr.get_pipeline_by_uuid.return_value = pipeline
app.agent_run_orchestrator.try_claim_steering_from_query.return_value = claim_result
sample_query.launcher_type = LauncherTypes.GROUP
sample_query.message_chain = MessageChain([Plain(text=text)])
claimed = await Controller(app)._try_claim_steering_before_session_slot(sample_query)
assert claimed is (expected and claim_result), app.logger.warning.call_args
assert str(sample_query.message_chain) == text
if expected:
forwarded = app.agent_run_orchestrator.try_claim_steering_from_query.await_args.args[0]
assert str(forwarded.message_chain) == ' hello'
else:
app.agent_run_orchestrator.try_claim_steering_from_query.assert_not_awaited()
@pytest.mark.asyncio
async def test_consumer_transfers_query_from_queue_to_running_task():
app = make_app()
@@ -65,6 +65,20 @@ async def test_group_message_no_match(mock_app, 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"""
@@ -23,6 +23,7 @@ import { UUID } from 'uuidjs';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { Agent, Bot } from '@/app/infra/entities/api';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import {
@@ -220,6 +221,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
ref,
) {
const { t } = useTranslation();
const { refreshPipelines } = useSidebarData();
const formSchema = getFormSchema(t);
const form = useForm<z.infer<typeof formSchema>>({
@@ -668,8 +670,30 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
};
try {
// A failed metadata refresh must not prevent creating the bot.
const before = await httpClient.getPipelines().catch(() => null);
const res = await httpClient.createBot(newBot);
toast.success(t('bots.createSuccess'));
const [created] = await Promise.all([
httpClient.getBot(res.uuid).catch(() => null),
refreshPipelines(),
]);
const automaticallyCreatedPipeline =
before !== null &&
created?.bot.event_bindings?.some(
(binding) =>
binding.event_pattern === 'message.received' &&
binding.target_type === 'pipeline' &&
!before.pipelines.some(
(pipeline) => pipeline.uuid === binding.target_uuid,
),
);
toast.success(
t(
automaticallyCreatedPipeline
? 'bots.autoCreatedPipeline'
: 'bots.createSuccess',
),
);
onNewBotCreated(res.uuid);
} catch (error) {
const err = error as CustomApiError & {
@@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
import styles from './EventBindingsEditor.module.css';
import { appendMessageRoutingTemplate } from './message-routing-template';
import type { TFunction } from 'i18next';
import { UseFormReturn } from 'react-hook-form';
import {
@@ -144,17 +145,22 @@ interface EventBindingsEditorProps {
}
type FilterField =
'chat_type' | 'chat_id' | 'message_text' | 'message_element_types';
| 'chat_type'
| 'chat_id'
| 'message_text'
| 'message_element_types'
| 'is_at_bot';
type FilterOperator =
'eq' | 'neq' | 'contains' | 'not_contains' | 'starts_with' | 'regex';
interface FilterRow {
field: FilterField;
operator: FilterOperator;
value: string;
value: string | boolean;
}
const OPERATORS_BY_FIELD: Record<FilterField, FilterOperator[]> = {
is_at_bot: ['eq', 'neq'],
chat_type: ['eq', 'neq'],
chat_id: ['eq', 'neq', 'contains', 'not_contains', 'regex'],
message_text: [
@@ -443,7 +449,7 @@ function samplePayloadForEvent(eventType: string): Record<string, unknown> {
if (eventType === 'message.received') {
return {
message_text: 'Hello',
chat_type: 'private',
chat_type: 'person',
chat_id: 'test-user',
user_id: 'test-user',
};
@@ -727,7 +733,7 @@ function FilterConditionsPanel({
next[i] = { ...next[i], ...patch };
if ('field' in patch) {
next[i].operator = OPERATORS_BY_FIELD[patch.field as FilterField][0];
next[i].value = '';
next[i].value = patch.field === 'is_at_bot' ? true : '';
}
onChange(next);
}
@@ -755,6 +761,9 @@ function FilterConditionsPanel({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="is_at_bot">
{t('bots.filterIsAtBot')}
</SelectItem>
<SelectItem value="chat_type">
{t('bots.filterChatType')}
</SelectItem>
@@ -784,9 +793,22 @@ function FilterConditionsPanel({
</SelectContent>
</Select>
{f.field === 'chat_type' ? (
{f.field === 'is_at_bot' ? (
<Select
value={f.value}
value={String(f.value)}
onValueChange={(v) => update(i, { value: v === 'true' })}
>
<SelectTrigger className="h-8 w-[90px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">{t('bots.mentionYes')}</SelectItem>
<SelectItem value="false">{t('bots.mentionNo')}</SelectItem>
</SelectContent>
</Select>
) : f.field === 'chat_type' ? (
<Select
value={String(f.value)}
onValueChange={(v) => update(i, { value: v })}
>
<SelectTrigger className="h-8 w-[90px] text-xs">
@@ -803,7 +825,7 @@ function FilterConditionsPanel({
</Select>
) : f.field === 'message_element_types' ? (
<Select
value={f.value}
value={String(f.value)}
onValueChange={(v) => update(i, { value: v })}
>
<SelectTrigger className="h-8 w-[100px] text-xs">
@@ -825,7 +847,7 @@ function FilterConditionsPanel({
? t('bots.ruleValueLauncherIdPlaceholder')
: t('bots.ruleValueMessagePlaceholder')
}
value={f.value}
value={String(f.value)}
onChange={(e) => update(i, { value: e.target.value })}
/>
)}
@@ -1600,6 +1622,18 @@ export default function EventBindingsEditor({
}
function addBinding(eventPattern = 'message.received') {
if (eventPattern === 'message.received') {
const target =
agentOptions.find((agent) => agent.kind === 'pipeline') ??
agentOptions.find((agent) => agent.kind === 'agent');
updateBindings(
appendMessageRoutingTemplate(bindings, {
target_type: target?.kind === 'pipeline' ? 'pipeline' : 'agent',
target_uuid: target?.uuid ?? '',
}),
);
return;
}
updateBindings([
...bindings,
{
@@ -0,0 +1,53 @@
import type { EventBinding } from '@/app/infra/entities/api';
/** Add explicit private/group routes, preserving all existing user routes. */
export function appendMessageRoutingTemplate(
bindings: EventBinding[],
target: Pick<EventBinding, 'target_type' | 'target_uuid'>,
): EventBinding[] {
const routes: EventBinding[] = [
{
event_pattern: 'message.received',
...target,
priority: 0,
enabled: true,
filters: [{ field: 'chat_type', operator: 'eq', value: 'person' }],
},
{
event_pattern: 'message.received',
...target,
priority: 0,
enabled: true,
filters: [
{ field: 'chat_type', operator: 'eq', value: 'group' },
{ field: 'is_at_bot', operator: 'eq', value: true },
],
},
];
const sameFilters = (a: EventBinding, b: EventBinding) => {
const left = a.filters ?? [];
const right = b.filters ?? [];
return (
left.length === right.length &&
left.every((filter) =>
right.some(
(other) =>
filter.field === other.field &&
filter.operator === other.operator &&
filter.value === other.value,
),
)
);
};
return [
...bindings,
...routes.filter(
(route) =>
!bindings.some(
(binding) =>
binding.event_pattern === route.event_pattern &&
sameFilters(binding, route),
),
),
];
}
+64
View File
@@ -0,0 +1,64 @@
import type { Bot } from '@/app/infra/entities/api';
import { appendMessageRoutingTemplate } from '@/app/home/bots/components/bot-form/message-routing-template';
export interface WizardDraft {
adapter: string;
botUuid: string | null;
pipelineUuid: string | null;
}
interface SetupClient {
getBot(uuid: string): Promise<{ bot: Bot }>;
createBot(bot: Bot): Promise<{ uuid: string }>;
createPipeline(pipeline: {
name: string;
description: string;
config: Record<string, unknown>;
}): Promise<{ uuid: string }>;
}
/** Keep successful resources in the draft so a retry never recreates them. */
export async function prepareWizardBot(
client: SetupClient,
draft: WizardDraft,
bot: Bot,
pipelineName: string,
) {
if (draft.botUuid) {
return {
uuid: draft.botUuid,
bot: (await client.getBot(draft.botUuid)).bot,
};
}
if (!draft.pipelineUuid) {
draft.pipelineUuid = (
await client.createPipeline({
name: pipelineName,
description: bot.description ?? '',
config: {},
})
).uuid;
}
const configuredBot: Bot = {
...bot,
event_bindings: appendMessageRoutingTemplate([], {
target_type: 'pipeline',
target_uuid: draft.pipelineUuid,
}),
};
const { uuid } = await client.createBot(configuredBot);
draft.botUuid = uuid;
return { uuid, bot: configuredBot };
}
/** Saving adapter settings must not replace or re-enable a member's routes. */
export function wizardBindingsIfMissing(bot: Bot, pipelineUuid: string) {
return bot.event_bindings?.length
? {}
: {
event_bindings: appendMessageRoutingTemplate([], {
target_type: 'pipeline',
target_uuid: pipelineUuid,
}),
};
}
+48 -19
View File
@@ -81,6 +81,11 @@ import {
import OwnModelSetup, {
type OwnModelSelection,
} from '@/app/wizard/components/OwnModelSetup';
import {
prepareWizardBot,
wizardBindingsIfMissing,
type WizardDraft,
} from '@/app/wizard/bot-setup';
type RunnerInstallProgress = { stage: InstallStage; percent: number };
@@ -153,6 +158,8 @@ export default function WizardPage() {
);
const [runnerConfig, setRunnerConfig] = useState<Record<string, unknown>>({});
const [createdBotUuid, setCreatedBotUuid] = useState<string | null>(null);
const botDraft = useRef<WizardDraft | null>(null);
const botOperationPending = useRef(false);
const [createdPipelineUuid, setCreatedPipelineUuid] = useState<string | null>(
null,
);
@@ -699,10 +706,20 @@ export default function WizardPage() {
// Creates a disabled bot using the adapter label as name.
const handleCreateBot = useCallback(async () => {
if (!selectedAdapter) return;
if (!selectedAdapter || botOperationPending.current) return;
botOperationPending.current = true;
setIsCreatingBot(true);
try {
if (createdBotUuid) {
const existing = (await httpClient.getBot(createdBotUuid)).bot;
if (existing.adapter === selectedAdapter) {
// Returning to the platform step must retain unsaved form values too.
setCurrentStep(1);
saveProgress({ step: 1 });
return;
}
}
// Use adapter label as default bot name
const adapter = adapters.find((a) => a.name === selectedAdapter);
const defaultName = adapter
@@ -726,9 +743,25 @@ export default function WizardPage() {
adapter_config: initialConfig,
enable: false,
};
const resp = await httpClient.createBot(bot);
if (!botDraft.current || botDraft.current.adapter !== selectedAdapter) {
botDraft.current = {
adapter: selectedAdapter,
botUuid: null,
pipelineUuid: null,
};
}
const resp = await prepareWizardBot(
httpClient,
botDraft.current,
bot,
`${defaultName} ${t('agents.pipelineType')}`,
);
setBotName(resp.bot.name);
setAdapterConfig(
(resp.bot.adapter_config ?? initialConfig) as Record<string, unknown>,
);
setCreatedBotUuid(resp.uuid);
setCreatedPipelineUuid(null);
setCreatedPipelineUuid(botDraft.current.pipelineUuid);
// Fetch runtime info to get webhook URL(s)
try {
@@ -752,7 +785,7 @@ export default function WizardPage() {
selected_scenario: null,
selected_adapter: selectedAdapter,
created_bot_uuid: resp.uuid,
created_pipeline_uuid: null,
created_pipeline_uuid: botDraft.current.pipelineUuid,
bot_saved: false,
message_received: false,
selected_runner: null,
@@ -763,15 +796,18 @@ export default function WizardPage() {
t('wizard.createError') + (apiErr?.msg ? `: ${apiErr.msg}` : ''),
);
} finally {
botOperationPending.current = false;
setIsCreatingBot(false);
}
}, [selectedAdapter, adapters, t, saveProgress]);
}, [selectedAdapter, adapters, createdBotUuid, t, saveProgress]);
// ---- Save Bot Config & Enable (Step 1) ----
// Updates the bot's adapter config and enables it.
const handleSaveBot = useCallback(async () => {
if (!createdBotUuid || !selectedAdapter) return;
if (!createdBotUuid || !selectedAdapter || botOperationPending.current)
return;
botOperationPending.current = true;
setIsSavingBot(true);
setBotSaved(false);
let previewPipelineUuid = createdPipelineUuid;
@@ -871,19 +907,11 @@ export default function WizardPage() {
adapter_config: configToSave,
enable: true,
};
if (previewPipelineUuid) {
botUpdate.event_bindings = [
{
event_pattern: 'message.received',
target_type: 'pipeline',
target_uuid: previewPipelineUuid,
filters: [],
priority: 0,
enabled: true,
description: '',
},
];
}
const currentBot = (await httpClient.getBot(createdBotUuid)).bot;
Object.assign(
botUpdate,
wizardBindingsIfMissing(currentBot, previewPipelineUuid),
);
await httpClient.updateBot(createdBotUuid, botUpdate);
setDefaultPreparationProgress(100);
@@ -929,6 +957,7 @@ export default function WizardPage() {
}
toast.error(`${t('wizard.createError')}: ${getErrorMessage(err)}`);
} finally {
botOperationPending.current = false;
setPreparingDefault(false);
setIsSavingBot(false);
}
+7 -1
View File
@@ -951,6 +951,8 @@ const enUS = {
saveSuccess: 'Saved successfully',
saveError: 'Save failed: ',
createSuccess: 'Created successfully. Continue configuring the bot',
autoCreatedPipeline:
'Automatically created a pipeline and bound message events',
createError: 'Creation failed: ',
deleteSuccess: 'Deleted successfully',
deleteError: 'Delete failed: ',
@@ -1018,7 +1020,8 @@ const enUS = {
commonScenarios: 'Common scenarios',
dragEventRoute: 'Drag route {{index}}',
behaviorReplyMessages: 'Reply to messages',
behaviorReplyMessagesDescription: 'Send incoming messages to a processor.',
behaviorReplyMessagesDescription:
'Handle private messages and group messages that mention this bot.',
behaviorWelcomeMembers: 'Welcome new members',
behaviorWelcomeMembersDescription:
'Run a processor when someone joins a group.',
@@ -1242,6 +1245,9 @@ const enUS = {
'All conditions must match to trigger this binding. Leave empty to always trigger.',
conditionsEmpty: 'No conditions — always triggers.',
addFilter: 'Add condition',
filterIsAtBot: 'Mentions this bot',
mentionYes: 'Yes',
mentionNo: 'No',
filterChatType: 'Session type',
filterChatId: 'Session ID',
filterMessageText: 'Message text',
+6 -1
View File
@@ -965,6 +965,8 @@ const esES = {
saveSuccess: 'Guardado correctamente',
saveError: 'Error al guardar: ',
createSuccess: 'Creado correctamente. Configura el enrutamiento de eventos',
autoCreatedPipeline:
'Se ha creado un pipeline automáticamente y se han vinculado los eventos de mensajes',
createError: 'Error al crear: ',
deleteSuccess: 'Eliminado correctamente',
deleteError: 'Error al eliminar: ',
@@ -1034,7 +1036,7 @@ const esES = {
dragEventRoute: 'Arrastrar ruta {{index}}',
behaviorReplyMessages: 'Responder a mensajes',
behaviorReplyMessagesDescription:
'Envía los mensajes entrantes a un procesador.',
'Procesa mensajes privados y mensajes de grupo que mencionen a este bot.',
behaviorWelcomeMembers: 'Dar la bienvenida a nuevos miembros',
behaviorWelcomeMembersDescription:
'Ejecuta un procesador cuando alguien se une a un grupo.',
@@ -1261,6 +1263,9 @@ const esES = {
'Deben cumplirse todas las condiciones. Déjalo vacío para activar siempre.',
conditionsEmpty: 'Sin condiciones: siempre se activa.',
addFilter: 'Añadir condición',
filterIsAtBot: 'Menciona a este bot',
mentionYes: 'Sí',
mentionNo: 'No',
filterChatType: 'Tipo de sesión',
filterChatId: 'ID de sesión',
filterMessageText: 'Texto del mensaje',
+6 -1
View File
@@ -952,6 +952,8 @@ const jaJP = {
saveSuccess: '保存に成功しました',
saveError: '保存に失敗しました:',
createSuccess: '作成が完了しました。イベントルーティングを設定してください',
autoCreatedPipeline:
'パイプラインを自動作成し、メッセージイベントに関連付けました',
createError: '作成に失敗しました:',
deleteSuccess: '削除に成功しました',
deleteError: '削除に失敗しました:',
@@ -1020,7 +1022,7 @@ const jaJP = {
dragEventRoute: 'ルート {{index}} をドラッグ',
behaviorReplyMessages: '受信メッセージに返信',
behaviorReplyMessagesDescription:
'受信メッセージをプロセッサーに渡します。',
'個別メッセージと、このボットにメンションしたグループメッセージを処理します。',
behaviorWelcomeMembers: '新しいメンバーを歓迎',
behaviorWelcomeMembersDescription:
'グループへの参加時にプロセッサーを実行します。',
@@ -1247,6 +1249,9 @@ const jaJP = {
'すべての条件に一致した場合のみこのバインディングが発火します。空の場合は常に発火します。',
conditionsEmpty: '条件なし。常に発火します。',
addFilter: '条件を追加',
filterIsAtBot: 'このボットへのメンション',
mentionYes: 'はい',
mentionNo: 'いいえ',
filterChatType: 'セッションタイプ',
filterChatId: 'セッション ID',
filterMessageText: 'メッセージ本文',
+6 -1
View File
@@ -960,6 +960,8 @@ const ruRU = {
saveSuccess: 'Успешно сохранено',
saveError: 'Ошибка сохранения: ',
createSuccess: 'Успешно создано. Настройте маршрутизацию событий',
autoCreatedPipeline:
'Пайплайн автоматически создан и привязан к событиям сообщений',
createError: 'Ошибка создания: ',
deleteSuccess: 'Успешно удалено',
deleteError: 'Ошибка удаления: ',
@@ -1029,7 +1031,7 @@ const ruRU = {
dragEventRoute: 'Перетащить маршрут {{index}}',
behaviorReplyMessages: 'Отвечать на сообщения',
behaviorReplyMessagesDescription:
'Передавайте входящие сообщения обработчику.',
'Обрабатывать личные сообщения и сообщения группы с упоминанием этого бота.',
behaviorWelcomeMembers: 'Приветствовать новых участников',
behaviorWelcomeMembersDescription:
'Запускайте обработчик при вступлении участника в группу.',
@@ -1259,6 +1261,9 @@ const ruRU = {
'Для срабатывания должны выполняться все условия. Пустой список означает срабатывание всегда.',
conditionsEmpty: 'Нет условий — срабатывает всегда.',
addFilter: 'Добавить условие',
filterIsAtBot: 'Упоминает этого бота',
mentionYes: 'Да',
mentionNo: 'Нет',
filterChatType: 'Тип сеанса',
filterChatId: 'ID сеанса',
filterMessageText: 'Текст сообщения',
+6 -1
View File
@@ -932,6 +932,7 @@ const thTH = {
saveSuccess: 'บันทึกสำเร็จ',
saveError: 'บันทึกล้มเหลว: ',
createSuccess: 'สร้างสำเร็จ กรุณากำหนดเส้นทางเหตุการณ์',
autoCreatedPipeline: 'สร้างไปป์ไลน์และผูกเหตุการณ์ข้อความโดยอัตโนมัติแล้ว',
createError: 'สร้างล้มเหลว: ',
deleteSuccess: 'ลบสำเร็จ',
deleteError: 'ลบล้มเหลว: ',
@@ -999,7 +1000,8 @@ const thTH = {
commonScenarios: 'สถานการณ์ทั่วไป',
dragEventRoute: 'ลากเส้นทาง {{index}}',
behaviorReplyMessages: 'ตอบข้อความ',
behaviorReplyMessagesDescription: 'ส่งข้อความขาเข้าไปยังตัวประมวลผล',
behaviorReplyMessagesDescription:
'ประมวลผลข้อความส่วนตัวและข้อความกลุ่มที่กล่าวถึงบอตนี้',
behaviorWelcomeMembers: 'ต้อนรับสมาชิกใหม่',
behaviorWelcomeMembersDescription: 'เรียกตัวประมวลผลเมื่อมีคนเข้ากลุ่ม',
behaviorHandleDepartures: 'จัดการสมาชิกออกจากกลุ่ม',
@@ -1213,6 +1215,9 @@ const thTH = {
'ต้องตรงทุกเงื่อนไขจึงจะทำงาน เว้นว่างเพื่อให้ทำงานเสมอ',
conditionsEmpty: 'ไม่มีเงื่อนไข — ทำงานเสมอ',
addFilter: 'เพิ่มเงื่อนไข',
filterIsAtBot: 'กล่าวถึงบอตนี้',
mentionYes: 'ใช่',
mentionNo: 'ไม่',
filterChatType: 'ประเภทเซสชัน',
filterChatId: 'ID เซสชัน',
filterMessageText: 'ข้อความ',
+7 -1
View File
@@ -949,6 +949,8 @@ const viVN = {
saveSuccess: 'Lưu thành công',
saveError: 'Lưu thất bại: ',
createSuccess: 'Tạo thành công. Vui lòng cấu hình định tuyến sự kiện',
autoCreatedPipeline:
'Đã tự động tạo pipeline và liên kết các sự kiện tin nhắn',
createError: 'Tạo thất bại: ',
deleteSuccess: 'Xóa thành công',
deleteError: 'Xóa thất bại: ',
@@ -1017,7 +1019,8 @@ const viVN = {
commonScenarios: 'Tình huống phổ biến',
dragEventRoute: 'Kéo tuyến {{index}}',
behaviorReplyMessages: 'Trả lời tin nhắn',
behaviorReplyMessagesDescription: 'Gửi tin nhắn đến bộ xử lý.',
behaviorReplyMessagesDescription:
'Xử lý tin nhắn riêng và tin nhắn nhóm nhắc đến bot này.',
behaviorWelcomeMembers: 'Chào mừng thành viên mới',
behaviorWelcomeMembersDescription:
'Chạy bộ xử lý khi có người tham gia nhóm.',
@@ -1235,6 +1238,9 @@ const viVN = {
'Phải khớp mọi điều kiện để kích hoạt. Để trống để luôn kích hoạt.',
conditionsEmpty: 'Không có điều kiện — luôn kích hoạt.',
addFilter: 'Thêm điều kiện',
filterIsAtBot: 'Nhắc đến bot này',
mentionYes: 'Có',
mentionNo: 'Không',
filterChatType: 'Loại phiên',
filterChatId: 'ID phiên',
filterMessageText: 'Nội dung tin nhắn',
+6 -1
View File
@@ -896,6 +896,7 @@ const zhHans = {
saveSuccess: '保存成功',
saveError: '保存失败:',
createSuccess: '创建成功,请继续配置机器人',
autoCreatedPipeline: '已自动创建流水线并绑定消息处理事件',
createError: '创建失败:',
deleteSuccess: '删除成功',
deleteError: '删除失败:',
@@ -960,7 +961,8 @@ const zhHans = {
commonScenarios: '常用场景',
dragEventRoute: '拖动第 {{index}} 条路由',
behaviorReplyMessages: '回复收到的消息',
behaviorReplyMessagesDescription: '把收到的消息交给处理器。',
behaviorReplyMessagesDescription:
'私聊直接处理;群聊仅处理 @ 本机器人的消息。',
behaviorWelcomeMembers: '欢迎新成员',
behaviorWelcomeMembersDescription: '有人加入群组时运行处理器。',
behaviorHandleDepartures: '处理成员离群',
@@ -1163,6 +1165,9 @@ const zhHans = {
conditionsDescription: '满足所有条件时才触发此绑定,不添加则无条件触发。',
conditionsEmpty: '无条件,始终触发。',
addFilter: '添加条件',
filterIsAtBot: '是否 @ 本机器人',
mentionYes: '是',
mentionNo: '否',
filterChatType: '会话类型',
filterChatId: '会话 ID',
filterMessageText: '消息文本',
+6 -1
View File
@@ -893,6 +893,7 @@ const zhHant = {
saveSuccess: '儲存成功',
saveError: '儲存失敗:',
createSuccess: '建立成功,請設定事件路由',
autoCreatedPipeline: '已自動建立流水線並綁定訊息處理事件',
createError: '建立失敗:',
deleteSuccess: '刪除成功',
deleteError: '刪除失敗:',
@@ -958,7 +959,8 @@ const zhHant = {
commonScenarios: '常用場景',
dragEventRoute: '拖動第 {{index}} 條路由',
behaviorReplyMessages: '回覆收到的訊息',
behaviorReplyMessagesDescription: '把收到的訊息交給處理器。',
behaviorReplyMessagesDescription:
'私聊直接處理;群聊僅處理 @ 本機器人的訊息。',
behaviorWelcomeMembers: '歡迎新成員',
behaviorWelcomeMembersDescription: '有人加入羣組時運行處理器。',
behaviorHandleDepartures: '處理成員離羣',
@@ -1161,6 +1163,9 @@ const zhHant = {
conditionsDescription: '滿足所有條件時才觸發此綁定,不添加則無條件觸發。',
conditionsEmpty: '無條件,始終觸發。',
addFilter: '添加條件',
filterIsAtBot: '是否 @ 本機器人',
mentionYes: '是',
mentionNo: '否',
filterChatType: '會話類型',
filterChatId: '會話 ID',
filterMessageText: '訊息文本',
+20 -24
View File
@@ -852,18 +852,16 @@ test.describe('bot advanced flows', () => {
await expect(saveButton).toBeDisabled();
await page.getByRole('button', { name: 'Add behavior' }).click();
await page.getByRole('menuitem', { name: /^Reply to messages/ }).click();
await page
.getByRole('combobox')
.filter({ hasText: 'Select processor' })
.click();
await page.getByRole('option', { name: /Dirty state target/ }).click();
await expect(
page.getByRole('combobox').filter({ hasText: 'Dirty state target' }),
).toHaveCount(2);
await expect(saveButton).toBeEnabled();
await save(page);
await expect(saveButton).toBeDisabled();
await page.reload();
await expect(
page.getByRole('combobox').filter({ hasText: 'Dirty state target' }),
).toBeVisible();
).toHaveCount(2);
});
test('shows validation error when bot name is empty', async ({ page }) => {
@@ -1587,9 +1585,10 @@ test.describe('cross-resource flows', () => {
await page.getByRole('menuitem', { name: /^Welcome new members/ }).click();
const routeCards = page.locator('[data-testid^="event-route-"]');
await expect(routeCards).toHaveCount(2);
await expect(routeCards).toHaveCount(3);
await expect(routeCards.nth(0)).toContainText('Message received');
await expect(routeCards.nth(1)).toContainText('Member joined group');
await expect(routeCards.nth(1)).toContainText('Message received');
await expect(routeCards.nth(2)).toContainText('Member joined group');
await page.getByRole('button', { name: 'Add behavior' }).click();
await page
@@ -1607,14 +1606,14 @@ test.describe('cross-resource flows', () => {
await eventSubmenu
.getByRole('menuitem', { name: /^Message edited/ })
.click();
await expect(routeCards).toHaveCount(3);
await expect(routeCards.nth(2)).toContainText('Message edited');
await expect(routeCards).toHaveCount(4);
await expect(routeCards.nth(3)).toContainText('Message edited');
const firstHandle = page.getByRole('button', { name: 'Drag route 1' });
const secondCard = routeCards.nth(1);
await firstHandle.scrollIntoViewIfNeeded();
const handleBox = await firstHandle.boundingBox();
const targetBox = await secondCard.boundingBox();
const memberHandle = page.getByRole('button', { name: 'Drag route 3' });
const firstCard = routeCards.nth(0);
await memberHandle.scrollIntoViewIfNeeded();
const handleBox = await memberHandle.boundingBox();
const targetBox = await firstCard.boundingBox();
expect(handleBox).not.toBeNull();
expect(targetBox).not.toBeNull();
@@ -1645,7 +1644,8 @@ test.describe('cross-resource flows', () => {
const savedRouteCards = page.locator('[data-testid^="event-route-"]');
await expect(savedRouteCards.nth(0)).toContainText('Member joined group');
await expect(savedRouteCards.nth(1)).toContainText('Message received');
await expect(savedRouteCards.nth(2)).toContainText('Message edited');
await expect(savedRouteCards.nth(2)).toContainText('Message received');
await expect(savedRouteCards.nth(3)).toContainText('Message edited');
});
test('creates a pipeline then binds it to a bot', async ({ page }) => {
@@ -1672,13 +1672,9 @@ test.describe('cross-resource flows', () => {
await page.getByRole('button', { name: 'Add behavior' }).click();
await page.getByRole('menuitem', { name: /^Reply to messages/ }).click();
await page
.getByRole('combobox')
.filter({ hasText: 'Select processor' })
.click();
// Select the pipeline option
await page.getByRole('option', { name: /Production Pipeline/ }).click();
await expect(
page.getByRole('combobox').filter({ hasText: 'Production Pipeline' }),
).toHaveCount(2);
// Save the bot
await save(page);
@@ -1688,6 +1684,6 @@ test.describe('cross-resource flows', () => {
// The pipeline name should appear in the select trigger (not in sidebar or options)
await expect(
page.getByRole('combobox').filter({ hasText: 'Production Pipeline' }),
).toBeVisible();
).toHaveCount(2);
});
});
@@ -212,8 +212,10 @@ test.describe('wizard and QR platform regressions', () => {
pipelineCreateCount += 1;
}
if (
request.method() === 'PUT' &&
url.pathname === '/api/v1/platform/bots/bot-1'
(request.method() === 'POST' &&
url.pathname === '/api/v1/platform/bots') ||
(request.method() === 'PUT' &&
url.pathname === '/api/v1/platform/bots/bot-1')
) {
const body = request.postDataJSON() as {
event_bindings?: Array<{
@@ -227,7 +229,9 @@ test.describe('wizard and QR platform regressions', () => {
item.event_pattern === 'message.received' &&
item.target_type === 'pipeline',
);
boundPipelineUuid = binding?.target_uuid ?? null;
if (body.event_bindings) {
boundPipelineUuid = binding?.target_uuid ?? null;
}
}
});
@@ -413,8 +417,10 @@ test.describe('wizard and QR platform regressions', () => {
pipelineCreateCount += 1;
}
if (
request.method() === 'PUT' &&
url.pathname === '/api/v1/platform/bots/bot-1'
(request.method() === 'POST' &&
url.pathname === '/api/v1/platform/bots') ||
(request.method() === 'PUT' &&
url.pathname === '/api/v1/platform/bots/bot-1')
) {
const body = request.postDataJSON() as {
adapter_config?: { inbound_secret?: string };
@@ -430,7 +436,9 @@ test.describe('wizard and QR platform regressions', () => {
item.event_pattern === 'message.received' &&
item.target_type === 'pipeline',
);
boundPipelineUuid = binding?.target_uuid ?? null;
if (body.event_bindings) {
boundPipelineUuid = binding?.target_uuid ?? null;
}
}
});
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = fs.readFileSync(
new URL(
'../../src/app/home/bots/components/bot-form/message-routing-template.ts',
import.meta.url,
),
'utf8',
);
const exports = {};
new Function(
'exports',
ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText,
)(exports);
const append = exports.appendMessageRoutingTemplate;
test('message template explicitly separates private messages and mentions of this bot', () => {
const routes = append([], { target_type: 'pipeline', target_uuid: 'p' });
assert.equal(routes.length, 2);
assert.deepEqual(routes[0].filters, [
{ field: 'chat_type', operator: 'eq', value: 'person' },
]);
assert.deepEqual(routes[1].filters, [
{ field: 'chat_type', operator: 'eq', value: 'group' },
{ field: 'is_at_bot', operator: 'eq', value: true },
]);
assert.ok(routes.every((r) => r.target_uuid === 'p' && r.enabled));
});
test('reapplying preserves manual destinations, disabled rules and filter order without duplication', () => {
const routes = append([], { target_type: 'pipeline', target_uuid: 'p' });
routes[0].target_uuid = 'manual';
routes[1].filters.reverse();
routes[1].enabled = false;
const manual = {
event_pattern: '*',
target_type: 'discard',
target_uuid: '',
filters: [],
};
routes.unshift(manual);
const before = structuredClone(routes);
assert.deepEqual(
append(routes, { target_type: 'agent', target_uuid: 'other' }),
before,
);
assert.deepEqual(routes, before);
});
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
function load(path, deps = {}) {
const mod = { exports: {} };
new Function(
'require',
'exports',
ts.transpileModule(
fs.readFileSync(new URL('../../src/' + path, import.meta.url), 'utf8'),
{ compilerOptions: { module: ts.ModuleKind.CommonJS } },
).outputText,
)((id) => deps[id], mod.exports);
return mod.exports;
}
const template = load(
'app/home/bots/components/bot-form/message-routing-template.ts',
);
const { prepareWizardBot, wizardBindingsIfMissing } = load(
'app/wizard/bot-setup.ts',
{ '@/app/home/bots/components/bot-form/message-routing-template': template },
);
const bot = {
name: 'Wizard QA',
description: '',
adapter: 'onebot',
adapter_config: {},
enable: false,
};
test('fresh wizard binds both rules to its own pipeline and back navigation reuses the bot', async () => {
const calls = [];
let saved;
const client = {
createPipeline: async (p) => {
calls.push('pipeline');
assert.deepEqual(p.config, {});
return { uuid: 'p' };
},
createBot: async (b) => {
calls.push('bot');
saved = b;
return { uuid: 'b' };
},
getBot: async () => ({ bot: saved }),
};
const draft = { adapter: 'onebot', botUuid: null, pipelineUuid: null };
await prepareWizardBot(client, draft, bot, 'QA pipeline');
assert.deepEqual(calls, ['pipeline', 'bot']);
assert.equal(saved.event_bindings.length, 2);
assert.ok(saved.event_bindings.every((b) => b.target_uuid === 'p'));
assert.deepEqual(saved.event_bindings[1].filters[1], {
field: 'is_at_bot',
operator: 'eq',
value: true,
});
saved.adapter_config = { token: 'edited' };
assert.equal(
(await prepareWizardBot(client, draft, bot, 'QA pipeline')).bot
.adapter_config.token,
'edited',
);
assert.deepEqual(calls, ['pipeline', 'bot']);
});
test('retry after bot failure reuses the successful pipeline', async () => {
let pipelines = 0,
bots = 0;
const client = {
createPipeline: async () => ({ uuid: 'p' + ++pipelines }),
createBot: async () => {
if (++bots === 1) throw new Error('temporary failure');
return { uuid: 'b' };
},
};
const draft = { adapter: 'onebot', botUuid: null, pipelineUuid: null };
await assert.rejects(prepareWizardBot(client, draft, bot, 'p'));
await prepareWizardBot(client, draft, bot, 'p');
assert.equal(pipelines, 1);
assert.equal(bots, 2);
});
test('existing pipeline rules preserve disabled flags, filters and manual destinations', () => {
const custom = {
...bot,
event_bindings: [
{
id: 'manual',
event_pattern: '*',
target_type: 'pipeline',
target_uuid: 'existing',
filters: [],
enabled: false,
},
],
};
const before = structuredClone(custom);
assert.deepEqual(wizardBindingsIfMissing(custom, 'new'), {});
assert.deepEqual(custom, before);
const patch = wizardBindingsIfMissing(bot, 'p');
assert.equal(patch.event_bindings.length, 2);
assert.deepEqual(wizardBindingsIfMissing({ ...bot, ...patch }, 'p'), {});
});