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 { extractI18nObject } from '@/i18n/I18nProvider';
import {
eventPatternCovers,
eventPatternLabel,
processorEventCompatibility,
} from '@/app/home/components/event-patterns/event-pattern-groups';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon';
@@ -45,7 +45,7 @@ import {
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
function EventCompatibilityWarning({
function ProcessorEvents({
patterns = [],
supportedEvents,
}: {
@@ -53,28 +53,45 @@ function EventCompatibilityWarning({
supportedEvents: string[];
}) {
const { t } = useTranslation();
// Legacy adapters only emit message.received when no events are declared.
const supported = supportedEvents.length
? supportedEvents
: ['message.received'];
const unsupported = [...new Set(patterns)].filter(
(pattern) => !supported.some((event) => eventPatternCovers(event, pattern)),
const { entries, matchingEvents } = processorEventCompatibility(
patterns,
supportedEvents,
);
if (!unsupported.length) return null;
const incomplete = entries.some((entry) => !entry.supported);
return (
<span
role="status"
className="mt-2 flex items-start gap-1.5 whitespace-normal text-xs text-amber-700 dark:text-amber-400"
>
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
<span>
{t('bots.pluginSubscriptions.incompleteEvents', {
events: unsupported
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · '),
})}
<span className="block text-xs">
<span
role="list"
className="flex flex-wrap gap-x-2 gap-y-1 whitespace-normal"
>
{entries.map(({ pattern, supported }) => (
<span
role="listitem"
key={pattern}
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>
{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>
);
}
@@ -225,17 +242,7 @@ export default function PluginProcessorBindings({
>
{agent?.component_ref?.replace('plugin:', '')}
</p>
<p
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
<ProcessorEvents
patterns={agent?.supported_event_patterns}
supportedEvents={supportedEvents}
/>
@@ -374,12 +381,7 @@ export default function PluginProcessorBindings({
<span className="block truncate text-sm text-muted-foreground">
{agent.component_ref?.replace('plugin:', '')}
</span>
<span className="block truncate text-xs text-muted-foreground">
{(agent.supported_event_patterns ?? [])
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
</span>
<EventCompatibilityWarning
<ProcessorEvents
patterns={agent.supported_event_patterns}
supportedEvents={supportedEvents}
/>
@@ -441,12 +443,7 @@ export default function PluginProcessorBindings({
<span className="block truncate text-sm text-muted-foreground">
{descriptor.plugin_author}/{descriptor.plugin_name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{descriptor.supported_event_patterns
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
</span>
<EventCompatibilityWarning
<ProcessorEvents
patterns={descriptor.supported_event_patterns}
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) {
if (pattern === '*') return '*';
return pattern.split('.')[0] || pattern;
+1 -1
View File
@@ -357,7 +357,7 @@ 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.',
'This bot only supports some of the subscribed events ({{events}}). The processor may not behave as intended in all cases.',
description:
'Automatically receive events declared by the plugin, independently of the routes above.',
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.",
},
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: {
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:
'Recibe automáticamente los eventos declarados por el plugin, de forma independiente de las rutas anteriores.',
empty: 'No hay procesadores vinculados.',
+1 -1
View File
@@ -363,7 +363,7 @@ const jaJP = {
bots: {
pluginSubscriptions: {
incompleteEvents:
'このボットは次の監視イベントに完全には対応していません:{{events}}。一部の機能が動作しない可能性がありますが、選択して登録できます。',
'このボットは監視対象の一部のイベント({{events}})にのみ対応しています。プロセッサーが完全には動作しない可能性があります。',
description:
'プラグインが宣言したイベントを自動で受信し、上のルートとは独立して実行します。',
empty: 'プラグインプロセッサーは未登録です。',
+21
View File
@@ -360,7 +360,28 @@ const ruRU = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
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: {
incompleteEvents:
'Этот бот поддерживает только часть отслеживаемых событий ({{events}}). Обработчик может работать не во всех случаях так, как ожидается.',
description:
'Автоматически получает события, объявленные плагином, независимо от маршрутов выше.',
empty: 'Обработчики плагинов не привязаны.',
+21
View File
@@ -347,7 +347,28 @@ const thTH = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
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: {
incompleteEvents:
'บอตนี้รองรับเพียงบางเหตุการณ์ที่ตัวประมวลผลติดตาม ({{events}}) ตัวประมวลผลอาจทำงานได้ไม่ครบถ้วน',
description:
'รับเหตุการณ์ที่ปลั๊กอินประกาศไว้โดยอัตโนมัติ และทำงานแยกจากเส้นทางด้านบน',
empty: 'ยังไม่ได้เชื่อมโยงตัวประมวลผลปลั๊กอิน',
+21
View File
@@ -356,7 +356,28 @@ const viVN = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
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: {
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:
'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.',
+1 -1
View File
@@ -342,7 +342,7 @@ const zhHans = {
bots: {
pluginSubscriptions: {
incompleteEvents:
'当前机器人无法完整支持以下监听事件{{events}}处理器表现可能不完整。',
'当前机器人仅支持部分事件{{events}}),处理器表现可能不完整。',
description: '自动接收插件声明的事件,与上方事件路由独立执行。',
empty: '尚未绑定插件处理器。',
add: '添加插件处理器',
+21
View File
@@ -336,7 +336,28 @@ const zhHant = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
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: {
incompleteEvents:
'目前機器人僅支援部分事件({{events}}),處理器表現可能不完整。',
description: '自動接收外掛宣告的事件,與上方事件路由獨立執行。',
empty: '尚未綁定外掛處理器。',
add: '新增外掛處理器',
@@ -1,14 +1,14 @@
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 warning = /This bot only supports some of the subscribed events/;
const definitions = [
{ name: 'Messages only', patterns: ['message.received'] },
{
name: 'Community helper',
patterns: [
'message.received',
'group.member_joined',
'message.received',
'friend.request_received',
],
},
@@ -102,11 +102,28 @@ test('partial support warns in existing choices and saved bindings, and changes
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(community.getByRole('status')).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(
dialog
.locator('label')
@@ -122,6 +139,15 @@ test('partial support warns in existing choices and saved bindings, and changes
).toHaveCount(1);
await page.getByRole('button', { name: 'Save', exact: true }).click();
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(
page.getByRole('status').filter({ hasText: warning }),
).toHaveCount(1);
@@ -159,6 +185,21 @@ test('new component choices warn for missing and wildcard events but allow creat
.getByRole('status'),
).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(
dialog.getByRole('button', { name: /Messages only/ }).getByRole('status'),
).toHaveCount(0);
+57 -1
View File
@@ -14,7 +14,10 @@ const exports = {};
new Function(
'exports',
ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS },
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
)(exports);
@@ -34,3 +37,56 @@ for (const [supported, required, covered] of [
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, []);
});