fix(bots): highlight unsupported processor events and show overlap

This commit is contained in:
Hyu
2026-09-15 15:05:34 +08:00
parent 8b7621cb29
commit 11f73ddf12
12 changed files with 280 additions and 53 deletions
@@ -19,8 +19,8 @@ import type {
import { httpClient } from '@/app/infra/http'; import { httpClient } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { import {
eventPatternCovers,
eventPatternLabel, eventPatternLabel,
processorEventCompatibility,
} from '@/app/home/components/event-patterns/event-pattern-groups'; } from '@/app/home/components/event-patterns/event-pattern-groups';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon'; import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon';
@@ -45,7 +45,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
function EventCompatibilityWarning({ function ProcessorEvents({
patterns = [], patterns = [],
supportedEvents, supportedEvents,
}: { }: {
@@ -53,28 +53,45 @@ function EventCompatibilityWarning({
supportedEvents: string[]; supportedEvents: string[];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Legacy adapters only emit message.received when no events are declared. const { entries, matchingEvents } = processorEventCompatibility(
const supported = supportedEvents.length patterns,
? supportedEvents supportedEvents,
: ['message.received'];
const unsupported = [...new Set(patterns)].filter(
(pattern) => !supported.some((event) => eventPatternCovers(event, pattern)),
); );
if (!unsupported.length) return null; const incomplete = entries.some((entry) => !entry.supported);
return ( return (
<span <span className="block text-xs">
role="status" <span
className="mt-2 flex items-start gap-1.5 whitespace-normal text-xs text-amber-700 dark:text-amber-400" role="list"
> className="flex flex-wrap gap-x-2 gap-y-1 whitespace-normal"
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" /> >
<span> {entries.map(({ pattern, supported }) => (
{t('bots.pluginSubscriptions.incompleteEvents', { <span
events: unsupported role="listitem"
.map((pattern) => eventPatternLabel(pattern, t)) key={pattern}
.join(' · '), title={pattern}
})} className={`inline-flex items-center gap-1 ${supported ? 'text-muted-foreground' : 'text-amber-700 dark:text-amber-400'}`}
>
{!supported && (
<TriangleAlert className="size-3.5 shrink-0" aria-hidden="true" />
)}
{eventPatternLabel(pattern, t)}
</span>
))}
</span> </span>
{incomplete && (
<span
role="status"
className="mt-2 block whitespace-normal text-amber-700 dark:text-amber-400"
>
{t('bots.pluginSubscriptions.incompleteEvents', {
events:
matchingEvents
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ') || t('common.none'),
})}
</span>
)}
</span> </span>
); );
} }
@@ -225,17 +242,7 @@ export default function PluginProcessorBindings({
> >
{agent?.component_ref?.replace('plugin:', '')} {agent?.component_ref?.replace('plugin:', '')}
</p> </p>
<p <ProcessorEvents
className="truncate text-xs text-muted-foreground"
title={(agent?.supported_event_patterns ?? [])
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
>
{(agent?.supported_event_patterns ?? [])
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ') || t('agents.eventProcessor.unavailable')}
</p>
<EventCompatibilityWarning
patterns={agent?.supported_event_patterns} patterns={agent?.supported_event_patterns}
supportedEvents={supportedEvents} supportedEvents={supportedEvents}
/> />
@@ -374,12 +381,7 @@ export default function PluginProcessorBindings({
<span className="block truncate text-sm text-muted-foreground"> <span className="block truncate text-sm text-muted-foreground">
{agent.component_ref?.replace('plugin:', '')} {agent.component_ref?.replace('plugin:', '')}
</span> </span>
<span className="block truncate text-xs text-muted-foreground"> <ProcessorEvents
{(agent.supported_event_patterns ?? [])
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
</span>
<EventCompatibilityWarning
patterns={agent.supported_event_patterns} patterns={agent.supported_event_patterns}
supportedEvents={supportedEvents} supportedEvents={supportedEvents}
/> />
@@ -441,12 +443,7 @@ export default function PluginProcessorBindings({
<span className="block truncate text-sm text-muted-foreground"> <span className="block truncate text-sm text-muted-foreground">
{descriptor.plugin_author}/{descriptor.plugin_name} {descriptor.plugin_author}/{descriptor.plugin_name}
</span> </span>
<span className="block truncate text-xs text-muted-foreground"> <ProcessorEvents
{descriptor.supported_event_patterns
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
</span>
<EventCompatibilityWarning
patterns={descriptor.supported_event_patterns} patterns={descriptor.supported_event_patterns}
supportedEvents={supportedEvents} supportedEvents={supportedEvents}
/> />
@@ -12,6 +12,34 @@ export function eventPatternCovers(supported: string, required: string) {
); );
} }
export function processorEventCompatibility(
patterns: string[],
supportedEvents: string[],
) {
// Legacy adapters only emit message.received when no events are declared.
const supported = supportedEvents.length
? supportedEvents
: ['message.received'];
const entries = [...new Set(patterns)]
.map((pattern) => ({
pattern,
supported: supported.some((event) => eventPatternCovers(event, pattern)),
}))
.sort((left, right) => Number(right.supported) - Number(left.supported));
const matchingEvents = [
...new Set(
patterns.flatMap((pattern) =>
supported.flatMap((event) => {
if (eventPatternCovers(pattern, event)) return [event];
if (eventPatternCovers(event, pattern)) return [pattern];
return [];
}),
),
),
];
return { entries, matchingEvents };
}
function eventPatternNamespace(pattern: string) { function eventPatternNamespace(pattern: string) {
if (pattern === '*') return '*'; if (pattern === '*') return '*';
return pattern.split('.')[0] || pattern; return pattern.split('.')[0] || pattern;
+1 -1
View File
@@ -357,7 +357,7 @@ const enUS = {
bots: { bots: {
pluginSubscriptions: { pluginSubscriptions: {
incompleteEvents: incompleteEvents:
'This bot cannot fully cover these subscribed events: {{events}}. The processor may work only partially; you can still select and bind it.', 'This bot only supports some of the subscribed events ({{events}}). The processor may not behave as intended in all cases.',
description: description:
'Automatically receive events declared by the plugin, independently of the routes above.', 'Automatically receive events declared by the plugin, independently of the routes above.',
empty: 'No plugin processors are bound.', empty: 'No plugin processors are bound.',
+21
View File
@@ -363,7 +363,28 @@ const esES = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
eventWildcard: 'Todos los eventos',
eventNamespaceWildcard: '{{namespace}}.*',
eventNames: {
message_received: 'Mensaje recibido',
message_edited: 'Mensaje editado',
message_deleted: 'Mensaje eliminado',
message_reaction: 'Reacción a un mensaje',
feedback_received: 'Comentarios recibidos',
friend_request_received: 'Solicitud de amistad recibida',
friend_added: 'Amigo añadido',
group_member_joined: 'Miembro unido al grupo',
group_member_left: 'Miembro salió del grupo',
group_member_banned: 'Miembro bloqueado',
bot_invited_to_group: 'Bot invitado al grupo',
bot_removed_from_group: 'Bot eliminado del grupo',
bot_muted: 'Bot silenciado',
bot_unmuted: 'Bot ya no silenciado',
platform_specific: 'Evento específico de la plataforma',
},
pluginSubscriptions: { pluginSubscriptions: {
incompleteEvents:
'Este bot solo admite algunos de los eventos suscritos ({{events}}). El procesador podría no comportarse como se espera en todos los casos.',
description: description:
'Recibe automáticamente los eventos declarados por el plugin, de forma independiente de las rutas anteriores.', 'Recibe automáticamente los eventos declarados por el plugin, de forma independiente de las rutas anteriores.',
empty: 'No hay procesadores vinculados.', empty: 'No hay procesadores vinculados.',
+1 -1
View File
@@ -363,7 +363,7 @@ const jaJP = {
bots: { bots: {
pluginSubscriptions: { pluginSubscriptions: {
incompleteEvents: incompleteEvents:
'このボットは次の監視イベントに完全には対応していません:{{events}}。一部の機能が動作しない可能性がありますが、選択して登録できます。', 'このボットは監視対象の一部のイベント({{events}})にのみ対応しています。プロセッサーが完全には動作しない可能性があります。',
description: description:
'プラグインが宣言したイベントを自動で受信し、上のルートとは独立して実行します。', 'プラグインが宣言したイベントを自動で受信し、上のルートとは独立して実行します。',
empty: 'プラグインプロセッサーは未登録です。', empty: 'プラグインプロセッサーは未登録です。',
+21
View File
@@ -360,7 +360,28 @@ const ruRU = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
eventWildcard: 'Все события',
eventNamespaceWildcard: '{{namespace}}.*',
eventNames: {
message_received: 'Получено сообщение',
message_edited: 'Сообщение изменено',
message_deleted: 'Сообщение удалено',
message_reaction: 'Реакция на сообщение',
feedback_received: 'Получен отзыв',
friend_request_received: 'Получен запрос в друзья',
friend_added: 'Друг добавлен',
group_member_joined: 'Участник вступил в группу',
group_member_left: 'Участник покинул группу',
group_member_banned: 'Участник заблокирован',
bot_invited_to_group: 'Бот приглашён в группу',
bot_removed_from_group: 'Бот удалён из группы',
bot_muted: 'Боту запрещено отправлять сообщения',
bot_unmuted: 'Боту разрешено отправлять сообщения',
platform_specific: 'Событие конкретной платформы',
},
pluginSubscriptions: { pluginSubscriptions: {
incompleteEvents:
'Этот бот поддерживает только часть отслеживаемых событий ({{events}}). Обработчик может работать не во всех случаях так, как ожидается.',
description: description:
'Автоматически получает события, объявленные плагином, независимо от маршрутов выше.', 'Автоматически получает события, объявленные плагином, независимо от маршрутов выше.',
empty: 'Обработчики плагинов не привязаны.', empty: 'Обработчики плагинов не привязаны.',
+21
View File
@@ -347,7 +347,28 @@ const thTH = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
eventWildcard: 'ทุกเหตุการณ์',
eventNamespaceWildcard: '{{namespace}}.*',
eventNames: {
message_received: 'ได้รับข้อความ',
message_edited: 'แก้ไขข้อความ',
message_deleted: 'ลบข้อความ',
message_reaction: 'การแสดงความรู้สึกต่อข้อความ',
feedback_received: 'ได้รับข้อเสนอแนะ',
friend_request_received: 'ได้รับคำขอเป็นเพื่อน',
friend_added: 'เพิ่มเพื่อน',
group_member_joined: 'สมาชิกเข้าร่วมกลุ่ม',
group_member_left: 'สมาชิกออกจากกลุ่ม',
group_member_banned: 'สมาชิกถูกแบน',
bot_invited_to_group: 'บอตได้รับเชิญเข้ากลุ่ม',
bot_removed_from_group: 'บอตถูกนำออกจากกลุ่ม',
bot_muted: 'บอตถูกปิดสิทธิ์ส่งข้อความ',
bot_unmuted: 'บอตได้รับสิทธิ์ส่งข้อความคืน',
platform_specific: 'เหตุการณ์เฉพาะแพลตฟอร์ม',
},
pluginSubscriptions: { pluginSubscriptions: {
incompleteEvents:
'บอตนี้รองรับเพียงบางเหตุการณ์ที่ตัวประมวลผลติดตาม ({{events}}) ตัวประมวลผลอาจทำงานได้ไม่ครบถ้วน',
description: description:
'รับเหตุการณ์ที่ปลั๊กอินประกาศไว้โดยอัตโนมัติ และทำงานแยกจากเส้นทางด้านบน', 'รับเหตุการณ์ที่ปลั๊กอินประกาศไว้โดยอัตโนมัติ และทำงานแยกจากเส้นทางด้านบน',
empty: 'ยังไม่ได้เชื่อมโยงตัวประมวลผลปลั๊กอิน', empty: 'ยังไม่ได้เชื่อมโยงตัวประมวลผลปลั๊กอิน',
+21
View File
@@ -356,7 +356,28 @@ const viVN = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
eventWildcard: 'Tất cả sự kiện',
eventNamespaceWildcard: '{{namespace}}.*',
eventNames: {
message_received: 'Nhận tin nhắn',
message_edited: 'Tin nhắn được chỉnh sửa',
message_deleted: 'Tin nhắn bị xóa',
message_reaction: 'Cảm xúc cho tin nhắn',
feedback_received: 'Nhận phản hồi',
friend_request_received: 'Nhận lời mời kết bạn',
friend_added: 'Đã thêm bạn',
group_member_joined: 'Thành viên tham gia nhóm',
group_member_left: 'Thành viên rời nhóm',
group_member_banned: 'Thành viên bị cấm tham gia nhóm',
bot_invited_to_group: 'Bot được mời vào nhóm',
bot_removed_from_group: 'Bot bị xóa khỏi nhóm',
bot_muted: 'Bot bị cấm gửi tin nhắn',
bot_unmuted: 'Bot được phép gửi tin nhắn trở lại',
platform_specific: 'Sự kiện riêng của nền tảng',
},
pluginSubscriptions: { pluginSubscriptions: {
incompleteEvents:
'Bot hiện chỉ hỗ trợ một số sự kiện mà bộ xử lý theo dõi ({{events}}). Bộ xử lý có thể không hoạt động đầy đủ trong mọi trường hợp.',
description: description:
'Tự động nhận sự kiện do plugin khai báo, hoạt động độc lập với các tuyến ở trên.', 'Tự động nhận sự kiện do plugin khai báo, hoạt động độc lập với các tuyến ở trên.',
empty: 'Chưa liên kết bộ xử lý plugin.', empty: 'Chưa liên kết bộ xử lý plugin.',
+1 -1
View File
@@ -342,7 +342,7 @@ const zhHans = {
bots: { bots: {
pluginSubscriptions: { pluginSubscriptions: {
incompleteEvents: incompleteEvents:
'当前机器人无法完整支持以下监听事件{{events}}处理器表现可能不完整。', '当前机器人仅支持部分事件{{events}}),处理器表现可能不完整。',
description: '自动接收插件声明的事件,与上方事件路由独立执行。', description: '自动接收插件声明的事件,与上方事件路由独立执行。',
empty: '尚未绑定插件处理器。', empty: '尚未绑定插件处理器。',
add: '添加插件处理器', add: '添加插件处理器',
+21
View File
@@ -336,7 +336,28 @@ const zhHant = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
eventWildcard: '全部事件',
eventNamespaceWildcard: '{{namespace}}.*',
eventNames: {
message_received: '收到訊息',
message_edited: '訊息已編輯',
message_deleted: '訊息已刪除',
message_reaction: '訊息表情回應',
feedback_received: '收到回饋',
friend_request_received: '收到好友請求',
friend_added: '新增好友',
group_member_joined: '成員加入群組',
group_member_left: '成員離開群組',
group_member_banned: '成員被封禁',
bot_invited_to_group: '機器人被邀請加入群組',
bot_removed_from_group: '機器人被移出群組',
bot_muted: '機器人被禁言',
bot_unmuted: '機器人被解除禁言',
platform_specific: '平台特定事件',
},
pluginSubscriptions: { pluginSubscriptions: {
incompleteEvents:
'目前機器人僅支援部分事件({{events}}),處理器表現可能不完整。',
description: '自動接收外掛宣告的事件,與上方事件路由獨立執行。', description: '自動接收外掛宣告的事件,與上方事件路由獨立執行。',
empty: '尚未綁定外掛處理器。', empty: '尚未綁定外掛處理器。',
add: '新增外掛處理器', add: '新增外掛處理器',
@@ -1,14 +1,14 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api'; import { installLangBotApiMocks } from './fixtures/langbot-api';
const warning = /This bot cannot fully cover these subscribed events:/; const warning = /This bot only supports some of the subscribed events/;
const definitions = [ const definitions = [
{ name: 'Messages only', patterns: ['message.received'] }, { name: 'Messages only', patterns: ['message.received'] },
{ {
name: 'Community helper', name: 'Community helper',
patterns: [ patterns: [
'message.received',
'group.member_joined', 'group.member_joined',
'message.received',
'friend.request_received', 'friend.request_received',
], ],
}, },
@@ -102,11 +102,28 @@ test('partial support warns in existing choices and saved bindings, and changes
const community = dialog const community = dialog
.locator('label') .locator('label')
.filter({ hasText: 'Community helper' }); .filter({ hasText: 'Community helper' });
await expect(community.getByRole('status')).toContainText('Member joined'); await expect(community.getByRole('status')).toContainText(
await expect(community.getByRole('status')).toContainText('Friend request'); '(Message received)',
await expect(community.getByRole('status')).not.toContainText(
'Message received',
); );
await expect(community.getByRole('status')).not.toContainText(
'Member joined',
);
await expect(community.getByRole('listitem')).toHaveText([
'Message received',
'Member joined group',
'Friend request received',
]);
for (const pattern of ['group.member_joined', 'friend.request_received']) {
const event = community.getByTitle(pattern, { exact: true });
await expect(event).toHaveClass(/text-amber-/);
await expect(event.locator('svg')).toHaveCount(1);
}
await expect(
community.getByTitle('message.received', { exact: true }),
).not.toHaveClass(/text-amber-/);
await expect(
community.getByTitle('message.received', { exact: true }).locator('svg'),
).toHaveCount(0);
await expect( await expect(
dialog dialog
.locator('label') .locator('label')
@@ -122,6 +139,15 @@ test('partial support warns in existing choices and saved bindings, and changes
).toHaveCount(1); ).toHaveCount(1);
await page.getByRole('button', { name: 'Save', exact: true }).click(); await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.reload(); await page.reload();
await expect(
page
.getByRole('region', { name: 'Plugin processor', exact: true })
.getByRole('listitem'),
).toHaveText([
'Message received',
'Member joined group',
'Friend request received',
]);
await expect( await expect(
page.getByRole('status').filter({ hasText: warning }), page.getByRole('status').filter({ hasText: warning }),
).toHaveCount(1); ).toHaveCount(1);
@@ -159,6 +185,21 @@ test('new component choices warn for missing and wildcard events but allow creat
.getByRole('status'), .getByRole('status'),
).toContainText(warning); ).toContainText(warning);
} }
await expect(
dialog.getByRole('button', { name: /Group observer/ }).getByRole('status'),
).toContainText('(None)');
await expect(
dialog.getByRole('button', { name: /All observer/ }).getByRole('status'),
).toContainText('(Message received)');
const community = dialog.getByRole('button', { name: /Community helper/ });
await expect(community.getByRole('listitem')).toHaveText([
'Message received',
'Member joined group',
'Friend request received',
]);
await expect(
community.getByTitle('group.member_joined', { exact: true }).locator('svg'),
).toHaveCount(1);
await expect( await expect(
dialog.getByRole('button', { name: /Messages only/ }).getByRole('status'), dialog.getByRole('button', { name: /Messages only/ }).getByRole('status'),
).toHaveCount(0); ).toHaveCount(0);
+57 -1
View File
@@ -14,7 +14,10 @@ const exports = {};
new Function( new Function(
'exports', 'exports',
ts.transpileModule(source, { ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS }, compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText, }).outputText,
)(exports); )(exports);
@@ -34,3 +37,56 @@ for (const [supported, required, covered] of [
assert.equal(exports.eventPatternCovers(supported, required), covered); assert.equal(exports.eventPatternCovers(supported, required), covered);
}); });
} }
test('processor events put covered declarations first and report only the intersection', () => {
const result = exports.processorEventCompatibility(
[
'group.member_joined',
'message.received',
'friend.request_received',
'message.received',
],
['message.received', 'message.edited'],
);
assert.deepEqual(result.entries, [
{ pattern: 'message.received', supported: true },
{ pattern: 'group.member_joined', supported: false },
{ pattern: 'friend.request_received', supported: false },
]);
assert.deepEqual(result.matchingEvents, ['message.received']);
});
test('wildcard subscriptions show matching concrete adapter events without claiming full coverage', () => {
const result = exports.processorEventCompatibility(
['group.*'],
['group.member_joined', 'message.received'],
);
assert.deepEqual(result.entries, [{ pattern: 'group.*', supported: false }]);
assert.deepEqual(result.matchingEvents, ['group.member_joined']);
assert.deepEqual(
exports.processorEventCompatibility(['*'], ['message.received'])
.matchingEvents,
['message.received'],
);
});
test('adapter wildcards cover concrete subscriptions without expanding the intersection', () => {
const result = exports.processorEventCompatibility(
['group.member_joined'],
['group.*'],
);
assert.equal(result.entries[0].supported, true);
assert.deepEqual(result.matchingEvents, ['group.member_joined']);
});
test('legacy adapters default to messages and unmatched subscriptions have an empty intersection', () => {
assert.deepEqual(
exports.processorEventCompatibility(['*'], []).matchingEvents,
['message.received'],
);
assert.deepEqual(
exports.processorEventCompatibility(['group.*'], []).matchingEvents,
[],
);
assert.deepEqual(exports.processorEventCompatibility([], []).entries, []);
});