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 ( + + + ); +} + 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); + }); +}