diff --git a/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py index 55867f189..fbbc5d879 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/bots.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -113,6 +113,24 @@ class BotsRouterGroup(group.RouterGroup): ) return self.success(data={'sent': True}) + @self.route( + '//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( '//admins', methods=['GET'], diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 9d9211be3..42bb35338 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -1,6 +1,7 @@ from __future__ import annotations import uuid +import json import sqlalchemy 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 ....workspace.errors import WorkspaceNotFoundError from .tenant import TenantContext, require_workspace_uuid, scope_statement +from ....utils import httpclient +from ....platform.sources import http_bot_signing class BotService: @@ -80,6 +83,7 @@ class BotService: 'wecomcs', 'LINE', 'lark', + 'http_bot', ]: 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', '') @@ -216,6 +220,53 @@ class BotService: 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( self, context: TenantContext, diff --git a/tests/unit_tests/api/service/test_bot_service.py b/tests/unit_tests/api/service/test_bot_service.py index dea5763f2..55869fc0a 100644 --- a/tests/unit_tests/api/service/test_bot_service.py +++ b/tests/unit_tests/api/service/test_bot_service.py @@ -9,8 +9,9 @@ Source: src/langbot/pkg/api/http/service/bot.py from __future__ import annotations import pytest -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from types import SimpleNamespace +import json import uuid 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_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): """Returns no webhook URL for non-webhook adapters like telegram.""" # Setup @@ -605,6 +629,77 @@ class TestBotServiceListEventLogs: 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: """Tests for send_message method.""" diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 42825987c..3c5db7ae4 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -461,6 +461,15 @@ export class BackendClient extends BaseHttpClient { 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( botId: string, limit: number = 100, diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index 36a7bc950..49142e089 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -14,6 +14,9 @@ import { Cable, Settings2, Blocks, + Copy, + Send, + Webhook, } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; @@ -45,6 +48,7 @@ import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import i18n from 'i18next'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Card, CardContent, @@ -996,6 +1000,10 @@ function StepBotConfig({ extraWebhookUrl: string; }) { const { t } = useTranslation(); + const [testMessage, setTestMessage] = useState( + t('wizard.botConfig.httpTestDefaultMessage'), + ); + const [isSendingTest, setIsSendingTest] = useState(false); const adapterLabel = useMemo(() => { 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 (
@@ -1019,6 +1050,100 @@ function StepBotConfig({

+ {botSaved && ( +
+
+
+ {messageReceived ? ( + + ) : selectedAdapterName === 'http_bot' ? ( + + ) : webhookUrl ? ( + + ) : ( + + )} +
+
+

+ {messageReceived + ? t('wizard.botConfig.messageReceived') + : selectedAdapterName === 'http_bot' + ? t('wizard.botConfig.httpTestPrompt') + : webhookUrl + ? t('wizard.botConfig.webhookTestPrompt') + : t('wizard.botConfig.waitingForMessage')} +

+ + {!messageReceived && webhookUrl && ( +
+
+ + {webhookUrl} + + +
+ + {selectedAdapterName === 'http_bot' && ( +
+ setTestMessage(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void sendHttpBotTest(); + }} + className="bg-background" + /> + +
+ )} +
+ )} +
+
+
+ )} +
{/* Left column: Adapter config form */}
@@ -1082,20 +1207,6 @@ function StepBotConfig({ )} - - {/* Bot and inbound-message verification status */} - {botSaved && ( -
-
- -
- - {messageReceived - ? t('wizard.botConfig.messageReceived') - : t('wizard.botConfig.waitingForMessage')} - -
- )}
{/* Right column: Bot logs */} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 279c3dd47..20b35ae5e 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -1831,6 +1831,17 @@ const enUS = { 'The bot is enabled. Send it a message from your IM platform to continue.', messageReceived: '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', logsDescription: 'Monitor bot activity to verify the platform connection is working.', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 7cab749ff..cd1d41202 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1748,6 +1748,17 @@ const jaJP = { 'ボットが有効になりました。続行するには IM からメッセージを送信してください。', messageReceived: 'ボットが IM メッセージを受信しました。次のステップに進めます。', + webhookTestPrompt: + 'コールバック URL の準備ができました。外部プラットフォームに設定し、ボットへ実際のメッセージを送信してください。', + httpTestPrompt: + 'HTTP Bot が有効になりました。実際の受信メッセージを送信して接続を確認できます。', + httpTestDefaultMessage: 'こんにちは。これは接続テストメッセージです。', + sendHttpTest: 'テストメッセージを送信', + httpTestAccepted: + 'テストメッセージを受け付けました。まもなくログに表示されます。', + httpTestMissingSecret: + '受信署名シークレットを入力し、先に設定を保存してください。', + httpTestFailed: 'テストメッセージの送信に失敗しました:{{error}}', logsTitle: 'ボットログ', logsDescription: 'ボットの活動を監視して、プラットフォーム接続が正常に動作していることを確認します。', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 6794b036d..ef6c93af9 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1751,6 +1751,14 @@ const zhHans = { botSaved: '机器人配置已保存并启用,请查看日志确认连接正常。', waitingForMessage: '机器人已启用。请在 IM 中向机器人发送一条消息以继续。', messageReceived: '机器人已成功收到 IM 消息,可以进入下一步。', + webhookTestPrompt: + '回调地址已就绪。将它配置到外部平台,然后向机器人发送一条真实消息。', + httpTestPrompt: 'HTTP Bot 已启用。可直接发送一条真实入站消息验证连接。', + httpTestDefaultMessage: '你好,这是一条连接测试消息。', + sendHttpTest: '发送测试消息', + httpTestAccepted: '测试消息已被机器人接收,请稍候查看日志。', + httpTestMissingSecret: '请先填写入站签名密钥并重新保存。', + httpTestFailed: '测试消息发送失败:{{error}}', logsTitle: '机器人日志', logsDescription: '监控机器人活动,确认平台连接是否正常工作。', }, diff --git a/web/vite.config.ts b/web/vite.config.ts index d2899a62f..b0f6ece18 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -26,6 +26,10 @@ export default defineConfig(({ mode }) => { target: apiProxyTarget, changeOrigin: true, }, + '/bots': { + target: apiProxyTarget, + changeOrigin: true, + }, }, }, build: {