feat(wizard): add inbound bot verification

This commit is contained in:
langbot-dev
2026-08-14 23:14:22 +08:00
parent 97b176aef2
commit f2ba540ffb
9 changed files with 333 additions and 15 deletions
@@ -113,6 +113,24 @@ class BotsRouterGroup(group.RouterGroup):
) )
return self.success(data={'sent': True}) return self.success(data={'sent': True})
@self.route(
'/<bot_uuid>/test-inbound',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_MANAGE,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.get_json(silent=True) or {}
try:
result = await self.ap.bot_service.send_http_bot_test_message(
request_context,
bot_uuid,
str(json_data.get('message') or ''),
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data=result)
@self.route( @self.route(
'/<bot_uuid>/admins', '/<bot_uuid>/admins',
methods=['GET'], methods=['GET'],
+51
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
import json
import sqlalchemy import sqlalchemy
from ....core import app from ....core import app
@@ -8,6 +9,8 @@ from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline from ....entity.persistence import pipeline as persistence_pipeline
from ....workspace.errors import WorkspaceNotFoundError from ....workspace.errors import WorkspaceNotFoundError
from .tenant import TenantContext, require_workspace_uuid, scope_statement from .tenant import TenantContext, require_workspace_uuid, scope_statement
from ....utils import httpclient
from ....platform.sources import http_bot_signing
class BotService: class BotService:
@@ -80,6 +83,7 @@ class BotService:
'wecomcs', 'wecomcs',
'LINE', 'LINE',
'lark', 'lark',
'http_bot',
]: ]:
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300') webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '') extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
@@ -216,6 +220,53 @@ class BotService:
return [log.to_json() for log in logs], total_count return [log.to_json() for log in logs], total_count
async def send_http_bot_test_message(
self,
context: TenantContext,
bot_uuid: str,
message: str,
) -> dict:
"""Send a signed test message through the HTTP Bot public ingress."""
bot = await self.get_bot(context, bot_uuid, include_secret=True)
if bot is None:
raise WorkspaceNotFoundError('Bot not found')
if bot.get('adapter') != 'http_bot':
raise ValueError('Inbound test is only available for HTTP Bot')
if not bot.get('enable'):
raise ValueError('Bot must be enabled before sending a test message')
text = message.strip()
if not text or len(text) > 2000:
raise ValueError('Test message must contain 1 to 2000 characters')
payload = {
'session_id': f'wizard-{uuid.uuid4().hex}',
'sender': {'id': 'wizard-user', 'name': 'Wizard Test'},
'message': [{'type': 'Plain', 'text': text}],
}
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
config = bot.get('adapter_config') or {}
headers = {'Content-Type': 'application/json'}
if config.get('signature_required', True):
secret = str(config.get('inbound_secret') or '')
if not secret:
raise ValueError('HTTP Bot inbound signing secret is required')
timestamp, signature = http_bot_signing.sign(secret, body)
headers[http_bot_signing.HEADER_TIMESTAMP] = timestamp
headers[http_bot_signing.HEADER_SIGNATURE] = signature
port = int(self.ap.instance_config.data.get('api', {}).get('port', 5300))
session = httpclient.get_session()
async with session.post(
f'http://127.0.0.1:{port}/bots/{bot_uuid}',
data=body,
headers=headers,
) as response:
result = await httpclient.read_json_limited(response)
if response.status not in {200, 202}:
raise ValueError(result.get('msg') or f'HTTP Bot test failed with status {response.status}')
return result.get('data') or {}
async def send_message( async def send_message(
self, self,
context: TenantContext, context: TenantContext,
@@ -9,8 +9,9 @@ Source: src/langbot/pkg/api/http/service/bot.py
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
from types import SimpleNamespace from types import SimpleNamespace
import json
import uuid import uuid
from langbot.pkg.api.http.service.bot import BotService from langbot.pkg.api.http.service.bot import BotService
@@ -241,6 +242,29 @@ class TestBotServiceGetRuntimeBotInfo:
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid' assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid' assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
async def test_get_runtime_bot_info_returns_webhook_for_http_bot(self):
ap = SimpleNamespace(
instance_config=SimpleNamespace(
data={'api': {'webhook_prefix': 'https://bot.example.com'}}
),
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
)
service = BotService(ap)
service.get_bot = AsyncMock(
return_value={
'uuid': 'http-bot-uuid',
'name': 'HTTP Bot',
'adapter': 'http_bot',
'adapter_config': {},
}
)
result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'http-bot-uuid')
assert result['adapter_runtime_values']['webhook_full_url'] == (
'https://bot.example.com/bots/http-bot-uuid'
)
async def test_get_runtime_bot_info_no_webhook_for_telegram(self): async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
"""Returns no webhook URL for non-webhook adapters like telegram.""" """Returns no webhook URL for non-webhook adapters like telegram."""
# Setup # Setup
@@ -605,6 +629,77 @@ class TestBotServiceListEventLogs:
assert total == 5 assert total == 5
class TestBotServiceHttpBotInboundTest:
async def test_sends_signed_message_through_public_ingress(self):
ap = SimpleNamespace(
instance_config=SimpleNamespace(data={'api': {'port': 5300}}),
)
service = BotService(ap)
service.get_bot = AsyncMock(
return_value={
'uuid': 'http-bot-uuid',
'adapter': 'http_bot',
'adapter_config': {
'signature_required': True,
'inbound_secret': 'test-secret',
},
'enable': True,
}
)
response = MagicMock(status=202)
session = MagicMock()
session.post.return_value.__aenter__ = AsyncMock(return_value=response)
session.post.return_value.__aexit__ = AsyncMock(return_value=None)
with (
patch('langbot.pkg.api.http.service.bot.httpclient.get_session', return_value=session),
patch(
'langbot.pkg.api.http.service.bot.httpclient.read_json_limited',
new=AsyncMock(
return_value={
'code': 0,
'data': {
'session_id': 'wizard-session',
'accepted_message_id': 'in-message',
},
}
),
),
):
result = await service.send_http_bot_test_message(
WORKSPACE_UUID,
'http-bot-uuid',
'hello',
)
assert result['accepted_message_id'] == 'in-message'
request = session.post.call_args
assert request.args[0] == 'http://127.0.0.1:5300/bots/http-bot-uuid'
payload = json.loads(request.kwargs['data'])
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
headers = request.kwargs['headers']
assert headers['X-LB-Timestamp']
assert headers['X-LB-Signature'].startswith('sha256=')
async def test_rejects_non_http_bot(self):
service = BotService(SimpleNamespace())
service.get_bot = AsyncMock(
return_value={
'uuid': 'telegram-bot',
'adapter': 'telegram',
'adapter_config': {},
'enable': True,
}
)
with pytest.raises(ValueError, match='only available for HTTP Bot'):
await service.send_http_bot_test_message(
WORKSPACE_UUID,
'telegram-bot',
'hello',
)
class TestBotServiceSendMessage: class TestBotServiceSendMessage:
"""Tests for send_message method.""" """Tests for send_message method."""
+9
View File
@@ -461,6 +461,15 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/platform/bots/${botId}/logs`, request); return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
} }
public testHttpBotInbound(
botId: string,
message: string,
): Promise<{ session_id: string; accepted_message_id: string }> {
return this.post(`/api/v1/platform/bots/${botId}/test-inbound`, {
message,
});
}
public getBotSessions( public getBotSessions(
botId: string, botId: string,
limit: number = 100, limit: number = 100,
+125 -14
View File
@@ -14,6 +14,9 @@ import {
Cable, Cable,
Settings2, Settings2,
Blocks, Blocks,
Copy,
Send,
Webhook,
} from 'lucide-react'; } from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
@@ -45,6 +48,7 @@ import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import i18n from 'i18next'; import i18n from 'i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { import {
Card, Card,
CardContent, CardContent,
@@ -996,6 +1000,10 @@ function StepBotConfig({
extraWebhookUrl: string; extraWebhookUrl: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [testMessage, setTestMessage] = useState(
t('wizard.botConfig.httpTestDefaultMessage'),
);
const [isSendingTest, setIsSendingTest] = useState(false);
const adapterLabel = useMemo(() => { const adapterLabel = useMemo(() => {
const a = adapters.find((ad) => ad.name === selectedAdapterName); const a = adapters.find((ad) => ad.name === selectedAdapterName);
@@ -1010,6 +1018,29 @@ function StepBotConfig({
[], [],
); );
const copyWebhookUrl = useCallback(async () => {
if (!webhookUrl) return;
await navigator.clipboard.writeText(webhookUrl);
toast.success(t('common.copySuccess'));
}, [t, webhookUrl]);
const sendHttpBotTest = useCallback(async () => {
if (!createdBotUuid || !testMessage.trim()) return;
setIsSendingTest(true);
try {
await httpClient.testHttpBotInbound(createdBotUuid, testMessage.trim());
toast.success(t('wizard.botConfig.httpTestAccepted'));
} catch (error) {
toast.error(
t('wizard.botConfig.httpTestFailed', {
error: error instanceof Error ? error.message : String(error),
}),
);
} finally {
setIsSendingTest(false);
}
}, [createdBotUuid, testMessage, t]);
return ( return (
<div className="max-w-5xl mx-auto space-y-6"> <div className="max-w-5xl mx-auto space-y-6">
<div className="text-center"> <div className="text-center">
@@ -1019,6 +1050,100 @@ function StepBotConfig({
</p> </p>
</div> </div>
{botSaved && (
<div
className={cn(
'border px-4 py-3',
messageReceived
? 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30'
: 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30',
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full',
messageReceived ? 'bg-green-500' : 'bg-amber-500',
)}
>
{messageReceived ? (
<Check className="size-3 text-white" />
) : selectedAdapterName === 'http_bot' ? (
<Send className="size-3 text-white" />
) : webhookUrl ? (
<Webhook className="size-3 text-white" />
) : (
<Loader2 className="size-3 animate-spin text-white" />
)}
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
'text-sm font-medium',
messageReceived
? 'text-green-800 dark:text-green-200'
: 'text-amber-800 dark:text-amber-200',
)}
>
{messageReceived
? t('wizard.botConfig.messageReceived')
: selectedAdapterName === 'http_bot'
? t('wizard.botConfig.httpTestPrompt')
: webhookUrl
? t('wizard.botConfig.webhookTestPrompt')
: t('wizard.botConfig.waitingForMessage')}
</p>
{!messageReceived && webhookUrl && (
<div className="mt-3 space-y-3">
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap border bg-background px-2.5 py-2 text-xs">
{webhookUrl}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="size-9 shrink-0"
onClick={copyWebhookUrl}
title={t('common.copy')}
>
<Copy className="size-4" />
</Button>
</div>
{selectedAdapterName === 'http_bot' && (
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={testMessage}
onChange={(event) => setTestMessage(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void sendHttpBotTest();
}}
className="bg-background"
/>
<Button
type="button"
onClick={() => void sendHttpBotTest()}
disabled={isSendingTest || !testMessage.trim()}
className="shrink-0"
>
{isSendingTest ? (
<Loader2 className="mr-1.5 size-4 animate-spin" />
) : (
<Send className="mr-1.5 size-4" />
)}
{t('wizard.botConfig.sendHttpTest')}
</Button>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2"> <div className="grid gap-6 grid-cols-1 lg:grid-cols-2">
{/* Left column: Adapter config form */} {/* Left column: Adapter config form */}
<div className="space-y-4"> <div className="space-y-4">
@@ -1082,20 +1207,6 @@ function StepBotConfig({
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Bot and inbound-message verification status */}
{botSaved && (
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30">
<div className="w-5 h-5 rounded-full bg-green-500 flex items-center justify-center shrink-0">
<Check className="w-3 h-3 text-white" />
</div>
<span className="text-sm text-green-700 dark:text-green-300">
{messageReceived
? t('wizard.botConfig.messageReceived')
: t('wizard.botConfig.waitingForMessage')}
</span>
</div>
)}
</div> </div>
{/* Right column: Bot logs */} {/* Right column: Bot logs */}
+11
View File
@@ -1831,6 +1831,17 @@ const enUS = {
'The bot is enabled. Send it a message from your IM platform to continue.', 'The bot is enabled. Send it a message from your IM platform to continue.',
messageReceived: messageReceived:
'The bot received an IM message. You can continue to the next step.', 'The bot received an IM message. You can continue to the next step.',
webhookTestPrompt:
'The callback URL is ready. Configure it on the external platform, then send the bot a real message.',
httpTestPrompt:
'HTTP Bot is enabled. Send a real inbound message here to verify the connection.',
httpTestDefaultMessage: 'Hello, this is a connection test message.',
sendHttpTest: 'Send Test Message',
httpTestAccepted:
'The test message was accepted. It will appear in the log shortly.',
httpTestMissingSecret:
'Enter an inbound signing secret and save the configuration first.',
httpTestFailed: 'Failed to send the test message: {{error}}',
logsTitle: 'Bot Logs', logsTitle: 'Bot Logs',
logsDescription: logsDescription:
'Monitor bot activity to verify the platform connection is working.', 'Monitor bot activity to verify the platform connection is working.',
+11
View File
@@ -1748,6 +1748,17 @@ const jaJP = {
'ボットが有効になりました。続行するには IM からメッセージを送信してください。', 'ボットが有効になりました。続行するには IM からメッセージを送信してください。',
messageReceived: messageReceived:
'ボットが IM メッセージを受信しました。次のステップに進めます。', 'ボットが IM メッセージを受信しました。次のステップに進めます。',
webhookTestPrompt:
'コールバック URL の準備ができました。外部プラットフォームに設定し、ボットへ実際のメッセージを送信してください。',
httpTestPrompt:
'HTTP Bot が有効になりました。実際の受信メッセージを送信して接続を確認できます。',
httpTestDefaultMessage: 'こんにちは。これは接続テストメッセージです。',
sendHttpTest: 'テストメッセージを送信',
httpTestAccepted:
'テストメッセージを受け付けました。まもなくログに表示されます。',
httpTestMissingSecret:
'受信署名シークレットを入力し、先に設定を保存してください。',
httpTestFailed: 'テストメッセージの送信に失敗しました:{{error}}',
logsTitle: 'ボットログ', logsTitle: 'ボットログ',
logsDescription: logsDescription:
'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。', 'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。',
+8
View File
@@ -1751,6 +1751,14 @@ const zhHans = {
botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。', botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。',
waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。', waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。',
messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。', messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。',
webhookTestPrompt:
'回调地址已就绪。将它配置到外部平台,然后向机器人发送一条真实消息。',
httpTestPrompt: 'HTTP Bot 已启用。可直接发送一条真实入站消息验证连接。',
httpTestDefaultMessage: '你好,这是一条连接测试消息。',
sendHttpTest: '发送测试消息',
httpTestAccepted: '测试消息已被机器人接收,请稍候查看日志。',
httpTestMissingSecret: '请先填写入站签名密钥并重新保存。',
httpTestFailed: '测试消息发送失败:{{error}}',
logsTitle: '机器人日志', logsTitle: '机器人日志',
logsDescription: '监控机器人活动,确认平台连接是否正常工作。', logsDescription: '监控机器人活动,确认平台连接是否正常工作。',
}, },
+4
View File
@@ -26,6 +26,10 @@ export default defineConfig(({ mode }) => {
target: apiProxyTarget, target: apiProxyTarget,
changeOrigin: true, changeOrigin: true,
}, },
'/bots': {
target: apiProxyTarget,
changeOrigin: true,
},
}, },
}, },
build: { build: {