diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx
index a861ebc6e..bb99984ef 100644
--- a/web/src/app/home/bots/components/bot-form/BotForm.tsx
+++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx
@@ -782,6 +782,7 @@ const BotForm = forwardRef(function BotForm(
)}
/>
form.setValue('plugin_processors', value, {
diff --git a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx
index 9538ffd65..a750c02c6 100644
--- a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx
+++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx
@@ -118,6 +118,7 @@ import { backendClient } from '@/app/infra/http';
import {
eventGroupLabel,
eventNamespaces,
+ eventPatternCovers,
groupEventPatterns,
} from '@/app/home/components/event-patterns/event-pattern-groups';
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
@@ -220,17 +221,6 @@ function isMessageEventPattern(p: string) {
return p === 'message.*' || p.startsWith('message.');
}
-function eventPatternCovers(sup: string, bind: string) {
- if (sup === '*') return true;
- if (sup === bind) return true;
- if (bind === '*') return false;
- if (sup.endsWith('.*')) {
- const ns = sup.replace('.*', '');
- return bind === `${ns}.*` || bind.startsWith(`${ns}.`);
- }
- return false;
-}
-
interface RouteConflict {
winnerIndex: number;
shadowedIndex: number;
@@ -1753,45 +1743,46 @@ export default function EventBindingsEditor({
);
})}
-
-
-
-
-
- {t('bots.behaviorCustom')}
-
- {t('bots.behaviorCustomDescription')}
-
-
-
-
- {otherEventGroups.map((group, groupIndex) => (
-
- {groupIndex > 0 && }
-
- {eventGroupLabel(group.namespace, t)}
-
- {group.patterns.map((event) => (
- addBinding(event)}
- >
-
- {eventLabel(event, t)}
-
- {eventDescription(event, t)}
-
-
-
+ {otherEventGroups.length > 0 && (
+ <>
+
+
+
+
+
+ {t('bots.behaviorCustom')}
+
+ {t('bots.behaviorCustomDescription')}
+
+
+
+
+ {otherEventGroups.map((group, groupIndex) => (
+
+ {groupIndex > 0 && }
+
+ {eventGroupLabel(group.namespace, t)}
+
+ {group.patterns.map((event) => (
+ addBinding(event)}
+ >
+
+ {eventLabel(event, t)}
+
+ {eventDescription(event, t)}
+
+
+
+ ))}
+
))}
-
- ))}
-
-
+
+
+ >
+ )}
!supported.some((event) => eventPatternCovers(event, pattern)),
+ );
+ if (!unsupported.length) return null;
+
+ return (
+
+
+
+ {t('bots.pluginSubscriptions.incompleteEvents', {
+ events: unsupported
+ .map((pattern) => eventPatternLabel(pattern, t))
+ .join(' · '),
+ })}
+
+
+ );
+}
+
export default function PluginProcessorBindings({
value,
onChange,
agents,
onCreated,
+ supportedEvents,
}: {
value: PluginProcessorBinding[];
onChange: (value: PluginProcessorBinding[]) => void;
agents: Agent[];
onCreated: (agent: Agent) => void;
+ supportedEvents: string[];
}) {
const { t } = useTranslation();
const { refreshPipelines } = useSidebarData();
@@ -189,6 +235,10 @@ export default function PluginProcessorBindings({
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ') || t('agents.eventProcessor.unavailable')}
+
{agent &&
@@ -329,6 +379,10 @@ export default function PluginProcessorBindings({
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
+
))}
@@ -392,6 +446,10 @@ export default function PluginProcessorBindings({
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
+
{componentRef === descriptor.id && (
diff --git a/web/src/app/home/components/event-patterns/event-pattern-groups.ts b/web/src/app/home/components/event-patterns/event-pattern-groups.ts
index 3b6092357..1780d1ef4 100644
--- a/web/src/app/home/components/event-patterns/event-pattern-groups.ts
+++ b/web/src/app/home/components/event-patterns/event-pattern-groups.ts
@@ -5,6 +5,13 @@ export interface EventPatternGroup {
patterns: string[];
}
+export function eventPatternCovers(supported: string, required: string) {
+ if (supported === '*' || supported === required) return true;
+ return (
+ supported.endsWith('.*') && required.startsWith(supported.slice(0, -1))
+ );
+}
+
function eventPatternNamespace(pattern: string) {
if (pattern === '*') return '*';
return pattern.split('.')[0] || pattern;
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts
index 72c075cbd..02b3601cd 100644
--- a/web/src/i18n/locales/en-US.ts
+++ b/web/src/i18n/locales/en-US.ts
@@ -356,6 +356,8 @@ const enUS = {
},
bots: {
pluginSubscriptions: {
+ incompleteEvents:
+ 'This bot cannot fully cover these subscribed events: {{events}}. The processor may work only partially; you can still select and bind it.',
description:
'Automatically receive events declared by the plugin, independently of the routes above.',
empty: 'No plugin processors are bound.',
diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts
index d3681edfd..6d511d6ff 100644
--- a/web/src/i18n/locales/ja-JP.ts
+++ b/web/src/i18n/locales/ja-JP.ts
@@ -362,6 +362,8 @@ const jaJP = {
},
bots: {
pluginSubscriptions: {
+ incompleteEvents:
+ 'このボットは次の監視イベントに完全には対応していません:{{events}}。一部の機能が動作しない可能性がありますが、選択して登録できます。',
description:
'プラグインが宣言したイベントを自動で受信し、上のルートとは独立して実行します。',
empty: 'プラグインプロセッサーは未登録です。',
diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts
index c648bef75..97455e7eb 100644
--- a/web/src/i18n/locales/zh-Hans.ts
+++ b/web/src/i18n/locales/zh-Hans.ts
@@ -341,6 +341,8 @@ const zhHans = {
},
bots: {
pluginSubscriptions: {
+ incompleteEvents:
+ '当前机器人无法完整支持以下监听事件:{{events}}。处理器的功能可能不完整,仍可选择并绑定。',
description: '自动接收插件声明的事件,与上方事件路由独立执行。',
empty: '尚未绑定插件处理器。',
add: '添加插件处理器',
diff --git a/web/tests/e2e/bot-processor-compatibility.spec.ts b/web/tests/e2e/bot-processor-compatibility.spec.ts
new file mode 100644
index 000000000..0cb983540
--- /dev/null
+++ b/web/tests/e2e/bot-processor-compatibility.spec.ts
@@ -0,0 +1,245 @@
+import { expect, test, type Page } from '@playwright/test';
+import { installLangBotApiMocks } from './fixtures/langbot-api';
+
+const warning = /This bot cannot fully cover these subscribed events:/;
+const definitions = [
+ { name: 'Messages only', patterns: ['message.received'] },
+ {
+ name: 'Community helper',
+ patterns: [
+ 'message.received',
+ 'group.member_joined',
+ 'friend.request_received',
+ ],
+ },
+ { name: 'Group observer', patterns: ['group.*'] },
+ { name: 'All observer', patterns: ['*'] },
+];
+
+async function setup(page: Page, events?: string[]) {
+ await installLangBotApiMocks(page, { authenticated: true });
+ const components = definitions.map(({ name, patterns }, index) => ({
+ id: `plugin:qa/events/component-${index}`,
+ label: { en_US: name },
+ supported_event_patterns: patterns,
+ config_schema: [],
+ plugin_author: 'qa',
+ plugin_name: 'events',
+ }));
+ const agents = components.map((component, index) => ({
+ uuid: `processor-${index}`,
+ name: definitions[index].name,
+ kind: 'event_processor',
+ component_ref: component.id,
+ supported_event_patterns: component.supported_event_patterns,
+ config: {},
+ }));
+ await page.route('**/api/v1/agents**', async (route) => {
+ const path = new URL(route.request().url()).pathname;
+ let data: unknown;
+ if (path.endsWith('/_/metadata')) data = { event_processors: components };
+ else if (path === '/api/v1/agents' && route.request().method() === 'POST') {
+ const payload = route.request().postDataJSON();
+ agents.push({
+ ...payload,
+ uuid: 'processor-new',
+ supported_event_patterns: components.find(
+ (item) => item.id === payload.component_ref,
+ )!.supported_event_patterns,
+ });
+ data = { uuid: 'processor-new', kind: 'event_processor' };
+ } else if (path === '/api/v1/agents') data = { agents };
+ else return route.fallback();
+ await route.fulfill({ json: { code: 0, data } });
+ });
+ await page.route('**/api/v1/platform/adapters', (route) =>
+ route.fulfill({
+ json: {
+ code: 0,
+ data: {
+ adapters: [
+ {
+ name: 'playwright-adapter',
+ label: { en_US: 'Message adapter' },
+ description: { en_US: 'Messages' },
+ spec: {
+ config: [],
+ ...(events ? { supported_events: events } : {}),
+ },
+ },
+ {
+ name: 'full-adapter',
+ label: { en_US: 'Community adapter' },
+ description: { en_US: 'Community events' },
+ spec: {
+ config: [],
+ supported_events: [
+ 'message.received',
+ 'group.member_joined',
+ 'friend.request_received',
+ ],
+ },
+ },
+ ],
+ },
+ },
+ }),
+ );
+ await page.goto('/home/bots?id=compatibility-bot');
+ await expect(
+ page.getByRole('button', { name: 'Add plugin processor', exact: true }),
+ ).toBeVisible();
+}
+
+test('partial support warns in existing choices and saved bindings, and changes with adapter', async ({
+ page,
+}) => {
+ await setup(page, ['message.received']);
+ await page
+ .getByRole('button', { name: 'Add plugin processor', exact: true })
+ .click();
+ const dialog = page.getByRole('dialog');
+ const community = dialog
+ .locator('label')
+ .filter({ hasText: 'Community helper' });
+ await expect(community.getByRole('status')).toContainText('Member joined');
+ await expect(community.getByRole('status')).toContainText('Friend request');
+ await expect(community.getByRole('status')).not.toContainText(
+ 'Message received',
+ );
+ await expect(
+ dialog
+ .locator('label')
+ .filter({ hasText: 'Messages only' })
+ .getByRole('status'),
+ ).toHaveCount(0);
+ await community.getByRole('checkbox').check();
+ await dialog
+ .getByRole('button', { name: 'Add plugin processor', exact: true })
+ .click();
+ await expect(
+ page.getByRole('status').filter({ hasText: warning }),
+ ).toHaveCount(1);
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await page.reload();
+ await expect(
+ page.getByRole('status').filter({ hasText: warning }),
+ ).toHaveCount(1);
+ await page.getByRole('combobox').first().click();
+ await page
+ .getByRole('option', { name: 'Community adapter', exact: true })
+ .click();
+ await expect(
+ page.getByRole('status').filter({ hasText: warning }),
+ ).toHaveCount(0);
+ await page.getByRole('combobox').first().click();
+ await page
+ .getByRole('option', { name: 'Message adapter', exact: true })
+ .click();
+ await expect(
+ page.getByRole('status').filter({ hasText: warning }),
+ ).toHaveCount(1);
+});
+
+test('new component choices warn for missing and wildcard events but allow creation and binding', async ({
+ page,
+}, testInfo) => {
+ await setup(page);
+ await page
+ .getByRole('button', { name: 'Add plugin processor', exact: true })
+ .click();
+ const dialog = page.getByRole('dialog');
+ await dialog
+ .getByRole('tab', { name: 'New configuration', exact: true })
+ .click();
+ for (const name of ['Community helper', 'Group observer', 'All observer']) {
+ await expect(
+ dialog
+ .getByRole('button', { name: new RegExp(name) })
+ .getByRole('status'),
+ ).toContainText(warning);
+ }
+ await expect(
+ dialog.getByRole('button', { name: /Messages only/ }).getByRole('status'),
+ ).toHaveCount(0);
+ await dialog.getByRole('button', { name: /Community helper/ }).click();
+ await dialog.screenshot({
+ path: testInfo.outputPath('compatibility-dialog.png'),
+ });
+ await dialog
+ .getByRole('button', { name: 'Create and bind', exact: true })
+ .click();
+ await expect(dialog).toHaveCount(0);
+ await expect(
+ page.getByRole('status').filter({ hasText: warning }),
+ ).toHaveCount(1);
+ await page
+ .locator('section[aria-labelledby="plugin-subscriptions-title"]')
+ .screenshot({ path: testInfo.outputPath('compatibility-bindings.png') });
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await page.reload();
+ await expect(
+ page.getByRole('status').filter({ hasText: warning }),
+ ).toHaveCount(1);
+});
+
+test('fully supported processor choices have no compatibility warning', async ({
+ page,
+}) => {
+ await setup(page, [
+ 'message.received',
+ 'group.member_joined',
+ 'friend.request_received',
+ ]);
+ await page
+ .getByRole('button', { name: 'Add plugin processor', exact: true })
+ .click();
+ const dialog = page.getByRole('dialog');
+ await expect(
+ dialog
+ .locator('label')
+ .filter({ hasText: 'Community helper' })
+ .getByRole('status'),
+ ).toHaveCount(0);
+ await dialog
+ .getByRole('tab', { name: 'New configuration', exact: true })
+ .click();
+ await expect(
+ dialog.getByRole('button', { name: /Community helper/ }),
+ ).toBeVisible();
+ await expect(
+ dialog
+ .getByRole('button', { name: /Community helper/ })
+ .getByRole('status'),
+ ).toHaveCount(0);
+});
+
+for (const events of [
+ undefined,
+ ['message.received'],
+ ['message.received', 'message.edited'],
+]) {
+ test(`other events entry follows adapter events: ${events?.join(',') ?? 'legacy'}`, async ({
+ page,
+ }) => {
+ await setup(page, events);
+ await page
+ .getByRole('button', { name: 'Add behavior', exact: true })
+ .click();
+ const entry = page.getByRole('menuitem', {
+ name: /Configure another event/,
+ });
+ if (events?.includes('message.edited')) {
+ await expect(entry).toBeVisible();
+ await entry.hover();
+ await expect(
+ page.getByRole('menuitem', { name: /Message edited/ }),
+ ).toBeVisible();
+ } else {
+ await expect(entry).toHaveCount(0);
+ await expect(
+ page.getByRole('menuitem', { name: /Reply to messages/ }),
+ ).toBeVisible();
+ }
+ });
+}
diff --git a/web/tests/unit/event-pattern-coverage.test.mjs b/web/tests/unit/event-pattern-coverage.test.mjs
new file mode 100644
index 000000000..b6b0a9dbb
--- /dev/null
+++ b/web/tests/unit/event-pattern-coverage.test.mjs
@@ -0,0 +1,36 @@
+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/components/event-patterns/event-pattern-groups.ts',
+ import.meta.url,
+ ),
+ 'utf8',
+);
+const exports = {};
+new Function(
+ 'exports',
+ ts.transpileModule(source, {
+ compilerOptions: { module: ts.ModuleKind.CommonJS },
+ }).outputText,
+)(exports);
+
+for (const [supported, required, covered] of [
+ ['message.received', 'message.received', true],
+ ['message.received', 'group.member_joined', false],
+ ['message.received', 'message.*', false],
+ ['message.received', '*', false],
+ ['group.*', 'group.member_joined', true],
+ ['group.*', 'group.*', true],
+ ['group.*', 'groupish.member_joined', false],
+ ['group.*', '*', false],
+ ['*', 'group.*', true],
+ ['*', '*', true],
+]) {
+ test(`${supported} ${covered ? 'covers' : 'does not cover'} ${required}`, () => {
+ assert.equal(exports.eventPatternCovers(supported, required), covered);
+ });
+}