mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-26 20:27:13 +00:00
feat(bots): add platform event debugger
This commit is contained in:
@@ -188,11 +188,7 @@ try {
|
||||
.getByText(/Event Routing|事件路由|イベントルーティング/)
|
||||
.first()
|
||||
.waitFor({ timeout: 15_000 });
|
||||
await page
|
||||
.getByText(
|
||||
/Events this adapter can receive|此适配器可接收的事件|このアダプターが受信できるイベント/,
|
||||
)
|
||||
.waitFor();
|
||||
await page.getByText(/Supported events|支持的事件|対応イベント/).waitFor();
|
||||
await page
|
||||
.getByText(/Message received|收到消息|メッセージを受信/)
|
||||
.first()
|
||||
@@ -237,6 +233,57 @@ try {
|
||||
.click();
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: /Listen for platform events|监听平台事件|プラットフォームイベントを監視/,
|
||||
})
|
||||
.click();
|
||||
const adapterDialog = page.getByRole("dialog");
|
||||
await adapterDialog.waitFor();
|
||||
await adapterDialog
|
||||
.getByText(/Listening|正在监听|監視中/, { exact: true })
|
||||
.waitFor({ timeout: 15_000 });
|
||||
|
||||
const inboundText = `adapter event ${paths.runId}`;
|
||||
const inbound = await apiJson(
|
||||
backendUrl,
|
||||
`/bots/${encodeURIComponent(botId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
token,
|
||||
body: {
|
||||
session_id: `adapter-debug-${paths.runId}`,
|
||||
session_type: "person",
|
||||
sender: { id: "adapter-debug-user", name: "Adapter QA" },
|
||||
message: [{ type: "Plain", text: inboundText }],
|
||||
},
|
||||
},
|
||||
);
|
||||
result.api.adapter_event_webhook = {
|
||||
http_status: inbound.status,
|
||||
code: inbound.json.code ?? null,
|
||||
};
|
||||
if (inbound.status >= 400 || inbound.json.code !== 0) {
|
||||
throw new Error(
|
||||
inbound.json.msg || "The HTTP Bot adapter rejected the inbound event.",
|
||||
);
|
||||
}
|
||||
|
||||
await adapterDialog
|
||||
.getByText(/Message received|收到消息|メッセージ受信/, { exact: true })
|
||||
.waitFor({ timeout: 15_000 });
|
||||
await adapterDialog.getByText("message.received", { exact: true }).waitFor();
|
||||
await adapterDialog.getByText(inboundText, { exact: true }).waitFor();
|
||||
result.visible_signals.push(
|
||||
"adapter-event-listening",
|
||||
"adapter-event-received",
|
||||
"adapter-event-raw-code",
|
||||
);
|
||||
await adapterDialog
|
||||
.getByRole("button", { name: /Close|关闭|閉じる/ })
|
||||
.click();
|
||||
await adapterDialog.waitFor({ state: "hidden" });
|
||||
|
||||
const text = await bodyText(page);
|
||||
if (/\bEBA event\b/.test(text)) {
|
||||
throw new Error(
|
||||
@@ -291,7 +338,7 @@ try {
|
||||
}
|
||||
result.status = "pass";
|
||||
result.reason =
|
||||
"Bot event routing, dry-run, synthetic dispatch, and visible route status passed in the WebUI.";
|
||||
"Bot event routing, dry-run, real adapter input, and visible route status passed in the WebUI.";
|
||||
} catch (error) {
|
||||
if (!["blocked", "env_issue"].includes(result.status)) result.status = "fail";
|
||||
result.reason = result.reason || error.message;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
id: bot-event-routing-product-flow
|
||||
title: "Bot event routing can be configured and tested from the WebUI"
|
||||
title: "Bot event routing and adapter input can be inspected from the WebUI"
|
||||
mode: agent-browser
|
||||
area: bot
|
||||
type: feature
|
||||
@@ -33,16 +33,18 @@ steps:
|
||||
- "Confirm the adapter capability summary, friendly event name, target, and route status are visible."
|
||||
- "Confirm overlapping routes and unmatched-event fallback behavior are explained before save."
|
||||
- "Open Test event route and run a dry-run against the current form."
|
||||
- "Run the saved runtime route with a synthetic event."
|
||||
- "Close the dialog and confirm the route card shows the latest discarded status."
|
||||
- "Open Platform event debugging and send a real inbound event through the HTTP Bot adapter."
|
||||
- "Confirm the normalized event, raw event code, payload, and latest discarded route status are visible."
|
||||
checks:
|
||||
- "UI: A user can choose a channel and add a scenario-labeled behavior during initial Bot creation."
|
||||
- "UI: Event routing uses user-facing labels and does not require the raw event name in the primary route card."
|
||||
- "UI: Definite route shadowing and unmatched-event fallback behavior are visible without opening raw logs."
|
||||
- "UI: Dry-run visibly reports that the route matched the discard processor."
|
||||
- "UI: Saved-route execution visibly succeeds, explains its side-effect boundary, and updates route status to discarded."
|
||||
- "UI: Adapter event debugging is clearly separate from route preview and starts listening only after the dialog opens."
|
||||
- "UI: A real adapter event shows its friendly name, raw code, and normalized event data."
|
||||
- "UI: The route card updates to discarded after the real inbound event is handled."
|
||||
- "Console: No unexpected frontend errors appear during the flow."
|
||||
- "Network: Bot, dry-run, route-status, and test-event requests return without 5xx responses."
|
||||
- "Network: Bot, dry-run, route-status, log, and HTTP Bot webhook requests return without 5xx responses."
|
||||
- "Cleanup: The temporary Bot is deleted after evidence is collected."
|
||||
evidence_required:
|
||||
- ui
|
||||
@@ -51,7 +53,8 @@ evidence_required:
|
||||
- api_diagnostic
|
||||
diagnostics:
|
||||
- "The fixture deliberately uses the discard processor so the product-flow test cannot invoke a model, tool, or external callback."
|
||||
- "A passing API call without the visible matched and discarded UI states is not a pass."
|
||||
- "The adapter dialog observes normalized platform events; it does not simulate route matching."
|
||||
- "A passing webhook call without the visible adapter event and discarded UI states is not a pass."
|
||||
troubleshooting:
|
||||
- backend-not-listening
|
||||
- proxy-env-mismatch
|
||||
|
||||
@@ -444,6 +444,26 @@ class RuntimeBot:
|
||||
compact[key] = value
|
||||
return compact
|
||||
|
||||
async def _record_adapter_event(
|
||||
self,
|
||||
event: platform_events.EBAEvent,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Record a normalized adapter event for the platform debugging surface."""
|
||||
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
||||
metadata = {
|
||||
'kind': 'adapter_event_received',
|
||||
'event_type': event_type,
|
||||
'event_data': self._compact_event_data(event),
|
||||
'adapter': getattr(self.bot_entity, 'adapter', None) or adapter.__class__.__name__,
|
||||
'bot_uuid': self.bot_entity.uuid,
|
||||
}
|
||||
await self.logger.info(
|
||||
f'Platform adapter received {event_type}',
|
||||
metadata=metadata,
|
||||
)
|
||||
return metadata
|
||||
|
||||
@staticmethod
|
||||
def _get_entity_id(entity: typing.Any) -> str | None:
|
||||
entity_id = getattr(entity, 'id', None)
|
||||
@@ -864,11 +884,13 @@ class RuntimeBot:
|
||||
event: platform_events.EBAEvent,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
) -> None:
|
||||
event.bot_uuid = self.bot_entity.uuid
|
||||
await self._record_adapter_event(event, adapter)
|
||||
|
||||
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
|
||||
await self._handle_interaction_submission(event, adapter)
|
||||
return
|
||||
|
||||
event.bot_uuid = self.bot_entity.uuid
|
||||
plugin_event = self._eba_event_to_plugin_event(event)
|
||||
|
||||
if plugin_event is not None:
|
||||
|
||||
@@ -95,6 +95,36 @@ class TestEventRouteTrace:
|
||||
assert metadata['target_uuid'] == 'agent-1'
|
||||
assert metadata['status'] == 'failed'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adapter_event_log_exposes_normalized_input_without_platform_object(self):
|
||||
"""Adapter debugging records the shared event shape without opaque SDK data."""
|
||||
from langbot_plugin.api.entities.builtin.platform import entities, events, message
|
||||
|
||||
bot = self._make_bot([])
|
||||
bot.bot_entity.adapter = 'test-adapter'
|
||||
event = events.MessageReceivedEvent(
|
||||
message_id='message-1',
|
||||
message_chain=message.MessageChain([message.Plain(text='hello')]),
|
||||
sender=entities.User(id='user-1', nickname='QA User'),
|
||||
chat_type=entities.ChatType.PRIVATE,
|
||||
chat_id='user-1',
|
||||
source_platform_object={'access_token': 'must-not-be-logged'},
|
||||
)
|
||||
|
||||
metadata = await bot._record_adapter_event(event, SimpleNamespace())
|
||||
|
||||
assert metadata['kind'] == 'adapter_event_received'
|
||||
assert metadata['event_type'] == 'message.received'
|
||||
assert metadata['adapter'] == 'test-adapter'
|
||||
assert metadata['bot_uuid'] == 'bot-1'
|
||||
assert metadata['event_data']['message_chain'] == [{'type': 'Plain', 'text': 'hello'}]
|
||||
assert metadata['event_data']['sender']['id'] == 'user-1'
|
||||
assert 'source_platform_object' not in metadata['event_data']
|
||||
bot.logger.info.assert_awaited_once_with(
|
||||
'Platform adapter received message.received',
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_malformed_agent_config_fails_one_event_and_processes_next(self):
|
||||
"""Persisted malformed Agent config cannot escape the per-event route boundary."""
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
RadioTower,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
import type { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
import {
|
||||
eventPatternDescription,
|
||||
eventPatternLabel,
|
||||
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||
|
||||
const POLL_INTERVAL_MS = 1200;
|
||||
const MAX_VISIBLE_EVENTS = 50;
|
||||
|
||||
interface ObservedAdapterEvent {
|
||||
seqId: number;
|
||||
timestamp: number;
|
||||
eventType: string;
|
||||
eventData: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type ListenerState = 'preparing' | 'listening' | 'error';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function observedEventFromLog(log: BotLog): ObservedAdapterEvent | null {
|
||||
const metadata = log.metadata;
|
||||
if (!isRecord(metadata) || metadata.kind !== 'adapter_event_received') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventType = metadata.event_type;
|
||||
if (typeof eventType !== 'string' || !eventType) return null;
|
||||
|
||||
return {
|
||||
seqId: log.seq_id,
|
||||
timestamp: log.timestamp,
|
||||
eventType,
|
||||
eventData: isRecord(metadata.event_data) ? metadata.event_data : {},
|
||||
};
|
||||
}
|
||||
|
||||
function findEventPreview(value: unknown, depth = 0): string | null {
|
||||
if (depth > 4 || value === null || value === undefined) return null;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const preview = findEventPreview(item, depth + 1);
|
||||
if (preview) return preview;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
for (const key of ['message_text', 'text', 'action']) {
|
||||
const candidate = value[key];
|
||||
if (typeof candidate === 'string' && candidate.trim()) {
|
||||
const trimmed = candidate.trim();
|
||||
return trimmed.length > 160 ? `${trimmed.slice(0, 160)}…` : trimmed;
|
||||
}
|
||||
}
|
||||
for (const child of Object.values(value)) {
|
||||
const preview = findEventPreview(child, depth + 1);
|
||||
if (preview) return preview;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AdapterEventDebugDialog({
|
||||
botId,
|
||||
adapterLabel,
|
||||
}: {
|
||||
botId?: string;
|
||||
adapterLabel: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [listenerState, setListenerState] =
|
||||
useState<ListenerState>('preparing');
|
||||
const [events, setEvents] = useState<ObservedAdapterEvent[]>([]);
|
||||
const baselineSeqRef = useRef<number | null>(null);
|
||||
const pollInFlightRef = useRef(false);
|
||||
|
||||
const platformName = adapterLabel || t('bots.adapterEventCurrentPlatform');
|
||||
|
||||
const pollLogs = useCallback(async () => {
|
||||
if (!botId || pollInFlightRef.current) return;
|
||||
pollInFlightRef.current = true;
|
||||
try {
|
||||
const response = await backendClient.getBotLogs(botId, {
|
||||
from_index: -1,
|
||||
max_count: 100,
|
||||
});
|
||||
const latestSeq = response.logs.reduce(
|
||||
(maximum, log) => Math.max(maximum, log.seq_id),
|
||||
-1,
|
||||
);
|
||||
|
||||
if (baselineSeqRef.current === null) {
|
||||
baselineSeqRef.current = latestSeq;
|
||||
setListenerState('listening');
|
||||
return;
|
||||
}
|
||||
|
||||
const baseline = baselineSeqRef.current;
|
||||
const newlyObserved = response.logs
|
||||
.filter((log) => log.seq_id > baseline)
|
||||
.map(observedEventFromLog)
|
||||
.filter((event): event is ObservedAdapterEvent => event !== null);
|
||||
|
||||
if (newlyObserved.length > 0) {
|
||||
setEvents((current) => {
|
||||
const bySeqId = new Map(
|
||||
[...newlyObserved, ...current].map((event) => [event.seqId, event]),
|
||||
);
|
||||
return Array.from(bySeqId.values())
|
||||
.sort((left, right) => right.seqId - left.seqId)
|
||||
.slice(0, MAX_VISIBLE_EVENTS);
|
||||
});
|
||||
}
|
||||
baselineSeqRef.current = Math.max(baseline, latestSeq);
|
||||
setListenerState('listening');
|
||||
} catch {
|
||||
setListenerState('error');
|
||||
} finally {
|
||||
pollInFlightRef.current = false;
|
||||
}
|
||||
}, [botId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !botId) return;
|
||||
|
||||
baselineSeqRef.current = null;
|
||||
pollInFlightRef.current = false;
|
||||
setEvents([]);
|
||||
setListenerState('preparing');
|
||||
void pollLogs();
|
||||
const interval = window.setInterval(
|
||||
() => void pollLogs(),
|
||||
POLL_INTERVAL_MS,
|
||||
);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [botId, open, pollLogs]);
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (listenerState === 'error') {
|
||||
return {
|
||||
text: t('bots.adapterEventListenerUnavailable'),
|
||||
dot: 'bg-destructive',
|
||||
};
|
||||
}
|
||||
if (listenerState === 'listening') {
|
||||
return {
|
||||
text: t('bots.adapterEventListening'),
|
||||
dot: 'bg-emerald-500',
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: t('bots.adapterEventPreparing'),
|
||||
dot: 'bg-amber-500',
|
||||
};
|
||||
}, [listenerState, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!botId}
|
||||
onClick={() => setOpen(true)}
|
||||
title={!botId ? t('bots.adapterEventNeedsSavedBot') : undefined}
|
||||
>
|
||||
<RadioTower className="mr-1 h-4 w-4" />
|
||||
{t('bots.adapterEventDebugAction')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex flex-wrap items-center gap-2 pr-8">
|
||||
<DialogTitle>{t('bots.adapterEventDebugTitle')}</DialogTitle>
|
||||
<Badge variant="outline" className="gap-1.5 font-normal">
|
||||
<span className={`size-2 rounded-full ${status.dot}`} />
|
||||
{status.text}
|
||||
</Badge>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
{t('bots.adapterEventDebugDescription', {
|
||||
platform: platformName,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert className="bg-muted/30">
|
||||
<Activity className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t('bots.adapterEventObserveOnly')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{listenerState === 'error' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t('bots.adapterEventLoadFailed')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t('bots.adapterEventReceivedCount', { count: events.length })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={events.length === 0}
|
||||
onClick={() => setEvents([])}
|
||||
>
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
{t('bots.adapterEventClear')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[min(52vh,420px)] rounded-lg border">
|
||||
{events.length === 0 ? (
|
||||
<div className="flex h-full min-h-64 flex-col items-center justify-center px-6 text-center">
|
||||
<RadioTower className="mb-3 h-8 w-8 text-muted-foreground" />
|
||||
<p className="font-medium">
|
||||
{t('bots.adapterEventEmptyTitle')}
|
||||
</p>
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||
{t('bots.adapterEventEmptyDescription', {
|
||||
platform: platformName,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-3">
|
||||
{events.map((event) => {
|
||||
const preview = findEventPreview(event.eventData);
|
||||
return (
|
||||
<Card key={event.seqId} className="gap-0 py-0">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
{eventPatternLabel(event.eventType, t)}
|
||||
</p>
|
||||
<code className="mt-1 block truncate text-xs text-muted-foreground">
|
||||
{event.eventType}
|
||||
</code>
|
||||
</div>
|
||||
<time className="shrink-0 text-xs text-muted-foreground">
|
||||
{new Date(
|
||||
event.timestamp * 1000,
|
||||
).toLocaleTimeString(i18n.language, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{preview ||
|
||||
eventPatternDescription(event.eventType, t)}
|
||||
</p>
|
||||
<Collapsible className="mt-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{t('bots.adapterEventData')}
|
||||
<ChevronDown className="ml-1 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed">
|
||||
{JSON.stringify(event.eventData, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -703,6 +703,11 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
||||
<EventBindingsEditor
|
||||
form={form}
|
||||
botId={initBotId}
|
||||
adapterLabel={
|
||||
adapterNameList.find(
|
||||
(adapter) => adapter.value === currentAdapter,
|
||||
)?.label ?? currentAdapter
|
||||
}
|
||||
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
||||
agentOptions={agentNameList}
|
||||
/>
|
||||
|
||||
@@ -120,6 +120,7 @@ import {
|
||||
groupEventPatterns,
|
||||
} from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||
import EventSelectOptionContent from '@/app/home/components/event-patterns/EventSelectOptionContent';
|
||||
import AdapterEventDebugDialog from './AdapterEventDebugDialog';
|
||||
|
||||
export const PIPELINE_DISCARD = '__discard__';
|
||||
|
||||
@@ -128,6 +129,7 @@ export const PIPELINE_DISCARD = '__discard__';
|
||||
interface EventBindingsEditorProps {
|
||||
form: UseFormReturn<any>;
|
||||
botId?: string;
|
||||
adapterLabel: string;
|
||||
supportedEvents: string[];
|
||||
agentOptions: Agent[];
|
||||
}
|
||||
@@ -1426,6 +1428,7 @@ function SortableBindingCard({
|
||||
export default function EventBindingsEditor({
|
||||
form,
|
||||
botId,
|
||||
adapterLabel,
|
||||
supportedEvents,
|
||||
agentOptions,
|
||||
}: EventBindingsEditorProps) {
|
||||
@@ -1797,6 +1800,7 @@ export default function EventBindingsEditor({
|
||||
eventOptions={dryRunEventOptions}
|
||||
agentOptions={agentOptions}
|
||||
/>
|
||||
<AdapterEventDebugDialog botId={botId} adapterLabel={adapterLabel} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -4,9 +4,10 @@ export interface GetBotLogsResponse {
|
||||
}
|
||||
|
||||
export interface BotLog {
|
||||
images: [];
|
||||
images: string[];
|
||||
level: string;
|
||||
message_session_id: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
seq_id: number;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
|
||||
@@ -451,6 +451,26 @@ const enUS = {
|
||||
routeFallbackIgnored:
|
||||
'Events that match no route are ignored. Add a catch-all route only when every event needs an explicit outcome.',
|
||||
testRoute: 'Check route',
|
||||
adapterEventDebugAction: 'Listen for platform events',
|
||||
adapterEventDebugTitle: 'Platform event debugging',
|
||||
adapterEventDebugDescription:
|
||||
'Trigger an event in {{platform}}. It will appear here when the adapter receives it.',
|
||||
adapterEventObserveOnly:
|
||||
'This window only observes events. Incoming events still follow the current routes.',
|
||||
adapterEventPreparing: 'Preparing',
|
||||
adapterEventListening: 'Listening',
|
||||
adapterEventListenerUnavailable: 'Listening interrupted',
|
||||
adapterEventLoadFailed:
|
||||
'Platform events could not be read. Make sure the bot is running, then try again.',
|
||||
adapterEventReceivedCount: '{{count}} events received',
|
||||
adapterEventClear: 'Clear',
|
||||
adapterEventEmptyTitle: 'Waiting for a platform event',
|
||||
adapterEventEmptyDescription:
|
||||
'Send a message or trigger an event in {{platform}}.',
|
||||
adapterEventData: 'View event data',
|
||||
adapterEventNeedsSavedBot:
|
||||
'Save the bot before listening for platform events.',
|
||||
adapterEventCurrentPlatform: 'the current platform',
|
||||
refreshRouteStatus: 'Refresh status',
|
||||
routeStatusIdle: 'No run yet',
|
||||
routeStatusRefreshFailed: 'Failed to refresh route status.',
|
||||
|
||||
@@ -458,6 +458,26 @@ const jaJP = {
|
||||
routeFallbackIgnored:
|
||||
'どのルートにも一致しないイベントは無視されます。すべてのイベントに明示的な結果が必要な場合のみ、フォールバックを追加してください。',
|
||||
testRoute: 'ルートを確認',
|
||||
adapterEventDebugAction: 'プラットフォームイベントを監視',
|
||||
adapterEventDebugTitle: 'プラットフォームイベントのデバッグ',
|
||||
adapterEventDebugDescription:
|
||||
'{{platform}} でイベントを発生させると、アダプターの受信後にここへ表示されます。',
|
||||
adapterEventObserveOnly:
|
||||
'この画面はイベントを監視するだけです。受信イベントは現在のルートで通常どおり処理されます。',
|
||||
adapterEventPreparing: '準備中',
|
||||
adapterEventListening: '監視中',
|
||||
adapterEventListenerUnavailable: '監視が中断されました',
|
||||
adapterEventLoadFailed:
|
||||
'プラットフォームイベントを取得できません。ボットが起動していることを確認して、もう一度お試しください。',
|
||||
adapterEventReceivedCount: '{{count}} 件のイベントを受信',
|
||||
adapterEventClear: 'クリア',
|
||||
adapterEventEmptyTitle: 'プラットフォームイベントを待機中',
|
||||
adapterEventEmptyDescription:
|
||||
'{{platform}} でメッセージを送るか、イベントを発生させてください。',
|
||||
adapterEventData: 'イベントデータを表示',
|
||||
adapterEventNeedsSavedBot:
|
||||
'プラットフォームイベントを監視する前にボットを保存してください。',
|
||||
adapterEventCurrentPlatform: '現在のプラットフォーム',
|
||||
refreshRouteStatus: '状態を更新',
|
||||
routeStatusIdle: '実行記録なし',
|
||||
routeStatusRefreshFailed: 'ルート状態の更新に失敗しました。',
|
||||
|
||||
@@ -430,6 +430,24 @@ const zhHans = {
|
||||
routeFallbackIgnored:
|
||||
'未命中任何路由的事件会被忽略。只有需要为每个事件指定结果时,才添加全局兜底路由。',
|
||||
testRoute: '检查路由',
|
||||
adapterEventDebugAction: '监听平台事件',
|
||||
adapterEventDebugTitle: '平台事件调试',
|
||||
adapterEventDebugDescription:
|
||||
'在 {{platform}} 中触发事件,适配器收到后会显示在这里。',
|
||||
adapterEventObserveOnly:
|
||||
'此窗口只负责观察;收到的事件仍会按当前路由正常处理。',
|
||||
adapterEventPreparing: '准备中',
|
||||
adapterEventListening: '正在监听',
|
||||
adapterEventListenerUnavailable: '监听已中断',
|
||||
adapterEventLoadFailed:
|
||||
'无法读取平台事件。请确认机器人正在运行,然后稍后重试。',
|
||||
adapterEventReceivedCount: '已收到 {{count}} 个事件',
|
||||
adapterEventClear: '清空',
|
||||
adapterEventEmptyTitle: '等待平台事件',
|
||||
adapterEventEmptyDescription: '请在 {{platform}} 中发送消息或触发事件。',
|
||||
adapterEventData: '查看事件数据',
|
||||
adapterEventNeedsSavedBot: '请先保存机器人后再监听平台事件。',
|
||||
adapterEventCurrentPlatform: '当前平台',
|
||||
refreshRouteStatus: '刷新状态',
|
||||
routeStatusIdle: '暂无运行记录',
|
||||
routeStatusRefreshFailed: '刷新路由状态失败。',
|
||||
|
||||
@@ -389,6 +389,44 @@ test.describe('bot advanced flows', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
let botLogPollCount = 0;
|
||||
await page.route('**/api/v1/platform/bots/*/logs', (route) => {
|
||||
botLogPollCount += 1;
|
||||
const logs =
|
||||
botLogPollCount === 1
|
||||
? []
|
||||
: [
|
||||
{
|
||||
seq_id: 7,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
level: 'info',
|
||||
text: 'Platform adapter received message.received',
|
||||
images: [],
|
||||
message_session_id: '',
|
||||
metadata: {
|
||||
kind: 'adapter_event_received',
|
||||
event_type: 'message.received',
|
||||
adapter: 'playwright-adapter',
|
||||
bot_uuid: 'bot-1',
|
||||
event_data: {
|
||||
type: 'message.received',
|
||||
chat_type: 'private',
|
||||
chat_id: 'test-user',
|
||||
message_chain: [{ type: 'Plain', text: 'adapter hello' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: { logs, total_count: logs.length },
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.goto('/home/bots?id=new');
|
||||
await selectPlaywrightAdapter(page);
|
||||
await page.locator('input[name="name"]').fill('Route Status Bot');
|
||||
@@ -492,6 +530,35 @@ test.describe('bot advanced flows', () => {
|
||||
const dialogBox = await routeDialog.boundingBox();
|
||||
expect(dialogBox).not.toBeNull();
|
||||
expect(dialogBox!.height).toBeLessThan(500);
|
||||
|
||||
await routeDialog.getByRole('button', { name: 'Close' }).first().click();
|
||||
await routingCard
|
||||
.getByRole('button', { name: 'Listen for platform events' })
|
||||
.click();
|
||||
const adapterDialog = page.getByRole('dialog');
|
||||
await expect(
|
||||
adapterDialog.getByText('Platform event debugging', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(adapterDialog).toContainText('Playwright Adapter');
|
||||
await expect(adapterDialog).toContainText(
|
||||
'This window only observes events. Incoming events still follow the current routes.',
|
||||
);
|
||||
await expect(
|
||||
adapterDialog.getByText('Message received', { exact: true }),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
await expect(adapterDialog.getByText('message.received')).toBeVisible();
|
||||
await expect(adapterDialog.getByText('adapter hello')).toBeVisible();
|
||||
await adapterDialog
|
||||
.getByRole('button', { name: 'View event data' })
|
||||
.click();
|
||||
await expect(
|
||||
adapterDialog.getByText(/"chat_id": "test-user"/),
|
||||
).toBeVisible();
|
||||
await adapterDialog.getByRole('button', { name: 'Clear' }).click();
|
||||
await expect(adapterDialog.getByText('0 events received')).toBeVisible();
|
||||
await expect(
|
||||
adapterDialog.getByText('Waiting for a platform event'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('toggles bot enable/disable state', async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user