mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 20:14:36 +00:00
* feat: add web_page_bot adapter and embed widget - Implemented a new `web_page_bot` adapter for embedding chat widgets on websites. - Created a new YAML configuration file for `web_page_bot` with necessary metadata and execution details. - Developed the `WebPageBotAdapter` class to handle message events and manage listeners. - Added a JavaScript widget for embedding the chat interface, including styles and functionality for user interaction. - Updated WebSocket handling to support the new bot adapter and manage connections. - Enhanced the bot form to include pipeline UUID and adapter configuration in the system context. - Introduced a new dynamic form item type for embed code in the form entity. * feat(embed): add feedback submission and image upload functionality to embed widget * feat(embed): add reset session endpoint for embed widget and improve WebSocket image handling * feat(widget): remove typing indicator display logic from message handling * fix(embed): security hardening for embed widget - Add UUID format validation for pipeline_uuid parameters - Add Cloudflare Turnstile integration for bot protection (optional) - Add HMAC-signed session tokens for /messages, /reset, /feedback endpoints - Sanitize error responses (remove internal exception details) - Sanitize base_url before JS injection - Fix XSS in markdown link rendering (only allow http/https protocols) - Fix XSS in image URL extraction (only allow http/https/data protocols) - Escape widget title in embed code snippet (HTML entity encoding) - Remove class-level mutable default in WebPageBotAdapter - Remove duplicate config line and console.log in widget.js - Add turnstile_site_key and turnstile_secret_key config fields * style: fix prettier formatting for chained replace calls * fix(embed): declare listeners as Pydantic field in WebPageBotAdapter The base class is a Pydantic BaseModel, so listeners must be declared as a field (with default_factory) rather than assigned in __init__. Also keep the __init__ to convert positional args to keyword args for Pydantic compatibility with botmgr's calling convention. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(embed): use bot_uuid instead of pipeline_uuid in all embed URLs Replace pipeline_uuid with bot_uuid in all user-facing embed widget URLs so internal pipeline identifiers are never exposed. The server resolves bot_uuid to the owning web_page_bot, validates it is enabled and has a pipeline bound, then routes internally using pipeline_uuid. Add a dedicated WebSocket endpoint at /api/v1/embed/<bot_uuid>/ws/connect instead of reusing the pipeline debug path. Wire WebPageBotAdapter to proxy reply_message calls through the WebSocket adapter so dashboard shows the correct adapter name while replies are still delivered. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(embed): improve Turnstile config field descriptions Add guidance on where to obtain the keys (Cloudflare dashboard) and clarify that leaving them empty disables the feature. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(embed): add multi-language support for embed widget Add a language selector to the web_page_bot config with 8 locales (en, zh-Hans, zh-Hant, ja, es, ru, th, vi). The backend injects the locale into widget.js which uses a built-in i18n dictionary for all user-facing strings (welcome message, placeholder, aria labels, error messages, powered-by footer). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(embed): use correct select option format for language selector Options must use name/label (i18n object) format, not value/label (plain string), to match the dynamic form renderer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(embed): adjust footer padding and link to langbot.app Increase footer padding for more breathing room from the bottom edge. Change powered-by link from GitHub repo to langbot.app. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(embed): ignore Enter key during IME composition Check e.isComposing before treating Enter as send, so confirming an IME candidate (e.g. Chinese/Japanese input) does not also fire the message. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(embed): center bubble icon and fill entire circle Make .lb-chat-icon span fill the full bubble area so the logo image covers the circle completely without exposing the blue background. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(embed): add bubble icon presets selector Add 6 bubble icon options (LangBot logo, chat bubble, robot, headset, sparkle, message) configurable in the bot settings. Icons are inline SVGs in widget.js, selected via a config field injected by the backend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: RockChinQ <rockchinq@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
385 lines
17 KiB
Python
385 lines
17 KiB
Python
"""Embed widget routes - serve embeddable chat widget for external websites.
|
|
|
|
All user-facing URLs are keyed by **bot_uuid** (not pipeline_uuid) so that
|
|
internal pipeline identifiers are never exposed to end-users. Each handler
|
|
resolves the bot_uuid to the owning ``web_page_bot`` RuntimeBot and extracts
|
|
the bound pipeline_uuid for internal routing.
|
|
"""
|
|
|
|
import asyncio
|
|
import datetime
|
|
import json
|
|
import logging
|
|
import uuid
|
|
import hmac
|
|
import hashlib
|
|
import time
|
|
import re
|
|
import httpx
|
|
|
|
import quart
|
|
|
|
from ... import group
|
|
from ......utils import paths
|
|
from ......platform.sources.websocket_manager import ws_connection_manager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Cache the widget template content
|
|
_widget_template_cache: str | None = None
|
|
_logo_bytes_cache: bytes | None = None
|
|
|
|
|
|
def _is_valid_uuid(s: str) -> bool:
|
|
return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', s))
|
|
|
|
|
|
def _get_widget_template() -> str:
|
|
"""Load and cache the widget JS template."""
|
|
global _widget_template_cache
|
|
if _widget_template_cache is None:
|
|
template_path = paths.get_resource_path('templates/embed/widget.js')
|
|
with open(template_path, 'r', encoding='utf-8') as f:
|
|
_widget_template_cache = f.read()
|
|
return _widget_template_cache
|
|
|
|
|
|
def _get_logo_bytes() -> bytes:
|
|
"""Load and cache the logo image."""
|
|
global _logo_bytes_cache
|
|
if _logo_bytes_cache is None:
|
|
logo_path = paths.get_resource_path('templates/embed/logo.webp')
|
|
with open(logo_path, 'rb') as f:
|
|
_logo_bytes_cache = f.read()
|
|
return _logo_bytes_cache
|
|
|
|
|
|
@group.group_class('embed', '/api/v1/embed')
|
|
class EmbedRouterGroup(group.RouterGroup):
|
|
# -- helpers -------------------------------------------------------------
|
|
|
|
def _resolve_bot(self, bot_uuid: str):
|
|
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
|
|
|
|
Returns ``(None, None)`` when the bot does not exist, is not a
|
|
``web_page_bot``, is disabled, or has no pipeline bound.
|
|
"""
|
|
for bot in self.ap.platform_mgr.bots:
|
|
if (
|
|
bot.bot_entity.uuid == bot_uuid
|
|
and bot.bot_entity.adapter == 'web_page_bot'
|
|
and bot.bot_entity.enable
|
|
and bot.bot_entity.use_pipeline_uuid
|
|
):
|
|
return bot, bot.bot_entity.use_pipeline_uuid
|
|
return None, None
|
|
|
|
def _get_bot_config(self, bot_uuid: str) -> dict:
|
|
for bot in self.ap.platform_mgr.bots:
|
|
if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot':
|
|
return bot.bot_entity.adapter_config
|
|
return {}
|
|
|
|
async def _verify_session_token(self, request, bot_uuid: str) -> bool:
|
|
config = self._get_bot_config(bot_uuid)
|
|
secret = config.get('turnstile_secret_key', '')
|
|
if not secret:
|
|
return True
|
|
auth_header = request.headers.get('Authorization', '')
|
|
if not auth_header.startswith('Bearer '):
|
|
return False
|
|
token = auth_header[7:]
|
|
try:
|
|
ts_str, mac = token.split('.', 1)
|
|
ts = float(ts_str)
|
|
if time.time() - ts > 86400:
|
|
return False
|
|
expected_mac = hmac.new(secret.encode(), f'{ts_str}'.encode(), hashlib.sha256).hexdigest()
|
|
return hmac.compare_digest(mac, expected_mac)
|
|
except Exception:
|
|
return False
|
|
|
|
# -- routes --------------------------------------------------------------
|
|
|
|
async def initialize(self) -> None:
|
|
@self.route('/<bot_uuid>/turnstile/verify', methods=['POST'], auth_type=group.AuthType.NONE)
|
|
async def verify_turnstile(bot_uuid: str) -> str:
|
|
if not _is_valid_uuid(bot_uuid):
|
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
|
if runtime_bot is None:
|
|
return self.http_status(404, -1, 'Bot not found or not available')
|
|
try:
|
|
data = await quart.request.get_json()
|
|
token = data.get('token')
|
|
if not token:
|
|
return self.http_status(400, -1, 'Token is required')
|
|
|
|
config = self._get_bot_config(bot_uuid)
|
|
secret = config.get('turnstile_secret_key', '')
|
|
if not secret:
|
|
ts = time.time()
|
|
return self.success(data={'token': f'{ts}.dummy'})
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.post(
|
|
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
|
data={'secret': secret, 'response': token},
|
|
)
|
|
result = resp.json()
|
|
|
|
if not result.get('success'):
|
|
return self.http_status(403, -1, 'Turnstile verification failed')
|
|
|
|
ts = time.time()
|
|
mac = hmac.new(secret.encode(), f'{ts}'.encode(), hashlib.sha256).hexdigest()
|
|
session_token = f'{ts}.{mac}'
|
|
|
|
return self.success(data={'token': session_token})
|
|
|
|
except Exception as e:
|
|
logger.error(f'Turnstile verify failed: {e}', exc_info=True)
|
|
return self.http_status(500, -1, 'Internal server error')
|
|
|
|
@self.route('/<bot_uuid>/widget.js', methods=['GET'], auth_type=group.AuthType.NONE)
|
|
async def serve_widget(bot_uuid: str) -> quart.Response:
|
|
"""Serve the embed widget JavaScript with injected configuration."""
|
|
if not _is_valid_uuid(bot_uuid):
|
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
|
if runtime_bot is None:
|
|
return quart.Response(
|
|
'// Bot not found or not available', status=404, content_type='application/javascript'
|
|
)
|
|
try:
|
|
template = _get_widget_template()
|
|
except FileNotFoundError:
|
|
return quart.Response('// Widget template not found', status=404, content_type='application/javascript')
|
|
|
|
base_url = quart.request.host_url.rstrip('/')
|
|
webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '')
|
|
if webhook_prefix:
|
|
base_url = webhook_prefix.rstrip('/')
|
|
|
|
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
|
|
base_url = quart.request.host_url.rstrip('/')
|
|
|
|
config = self._get_bot_config(bot_uuid)
|
|
site_key = config.get('turnstile_site_key', '')
|
|
locale = config.get('language', 'en_US') or 'en_US'
|
|
bubble_icon = config.get('bubble_icon', 'logo') or 'logo'
|
|
widget_js = template.replace('__LANGBOT_TURNSTILE_SITE_KEY__', site_key)
|
|
widget_js = widget_js.replace('__LANGBOT_BOT_UUID__', bot_uuid)
|
|
widget_js = widget_js.replace('__LANGBOT_BASE_URL__', base_url)
|
|
widget_js = widget_js.replace('__LANGBOT_LOCALE__', locale)
|
|
widget_js = widget_js.replace('__LANGBOT_BUBBLE_ICON__', bubble_icon)
|
|
|
|
response = quart.Response(widget_js, content_type='application/javascript; charset=utf-8')
|
|
response.headers['Cache-Control'] = 'public, max-age=300'
|
|
return response
|
|
|
|
@self.route('/logo', methods=['GET'], auth_type=group.AuthType.NONE)
|
|
async def serve_logo() -> quart.Response:
|
|
"""Serve the LangBot logo for the embed widget."""
|
|
try:
|
|
logo_data = _get_logo_bytes()
|
|
except FileNotFoundError:
|
|
return quart.Response('', status=404)
|
|
|
|
response = quart.Response(logo_data, content_type='image/webp')
|
|
response.headers['Cache-Control'] = 'public, max-age=86400'
|
|
return response
|
|
|
|
@self.route('/<bot_uuid>/messages/<session_type>', methods=['GET'], auth_type=group.AuthType.NONE)
|
|
async def get_embed_messages(bot_uuid: str, session_type: str) -> str:
|
|
if not _is_valid_uuid(bot_uuid):
|
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
|
if runtime_bot is None:
|
|
return self.http_status(404, -1, 'Bot not found or not available')
|
|
if not await self._verify_session_token(quart.request, bot_uuid):
|
|
return self.http_status(403, -1, 'Unauthorized or session expired')
|
|
try:
|
|
if session_type not in ['person', 'group']:
|
|
return self.http_status(400, -1, 'session_type must be person or group')
|
|
|
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
|
if not websocket_adapter:
|
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
|
|
|
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
|
|
return self.success(data={'messages': messages})
|
|
|
|
except Exception as e:
|
|
logger.error(f'Failed to get embed messages: {e}', exc_info=True)
|
|
return self.http_status(500, -1, 'Internal server error')
|
|
|
|
@self.route('/<bot_uuid>/reset/<session_type>', methods=['POST'], auth_type=group.AuthType.NONE)
|
|
async def reset_embed_session(bot_uuid: str, session_type: str) -> str:
|
|
if not _is_valid_uuid(bot_uuid):
|
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
|
if runtime_bot is None:
|
|
return self.http_status(404, -1, 'Bot not found or not available')
|
|
if not await self._verify_session_token(quart.request, bot_uuid):
|
|
return self.http_status(403, -1, 'Unauthorized or session expired')
|
|
try:
|
|
if session_type not in ['person', 'group']:
|
|
return self.http_status(400, -1, 'session_type must be person or group')
|
|
|
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
|
if not websocket_adapter:
|
|
return self.http_status(404, -1, 'WebSocket adapter not found')
|
|
|
|
websocket_adapter.reset_session(pipeline_uuid, session_type)
|
|
return self.success(data={'message': 'Session reset successfully'})
|
|
|
|
except Exception as e:
|
|
logger.error(f'Failed to reset embed session: {e}', exc_info=True)
|
|
return self.http_status(500, -1, 'Internal server error')
|
|
|
|
@self.route('/<bot_uuid>/feedback', methods=['POST'], auth_type=group.AuthType.NONE)
|
|
async def submit_feedback(bot_uuid: str) -> str:
|
|
if not _is_valid_uuid(bot_uuid):
|
|
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
|
if runtime_bot is None:
|
|
return self.http_status(404, -1, 'Bot not found or not available')
|
|
if not await self._verify_session_token(quart.request, bot_uuid):
|
|
return self.http_status(403, -1, 'Unauthorized or session expired')
|
|
try:
|
|
data = await quart.request.get_json()
|
|
message_id = data.get('message_id', '')
|
|
feedback_type = data.get('feedback_type')
|
|
|
|
if feedback_type not in (1, 2, 3):
|
|
return self.http_status(400, -1, 'feedback_type must be 1 (like), 2 (dislike), or 3 (cancel)')
|
|
|
|
feedback_id = f'embed_{uuid.uuid4().hex[:12]}'
|
|
|
|
await self.ap.monitoring_service.record_feedback(
|
|
feedback_id=feedback_id,
|
|
feedback_type=feedback_type,
|
|
bot_id=runtime_bot.bot_entity.uuid,
|
|
bot_name=runtime_bot.bot_entity.name or bot_uuid,
|
|
pipeline_id=pipeline_uuid,
|
|
message_id=str(message_id),
|
|
platform='web_page_bot',
|
|
)
|
|
|
|
return self.success(data={'feedback_id': feedback_id})
|
|
|
|
except Exception as e:
|
|
logger.error(f'Failed to record feedback: {e}', exc_info=True)
|
|
return self.http_status(500, -1, 'Internal server error')
|
|
|
|
# -- Embed WebSocket endpoint ----------------------------------------
|
|
|
|
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
|
|
async def embed_websocket_connect(bot_uuid: str):
|
|
"""WebSocket connection for embed widget, keyed by bot_uuid."""
|
|
if not _is_valid_uuid(bot_uuid):
|
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
|
|
return
|
|
|
|
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
|
if runtime_bot is None:
|
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
|
|
return
|
|
|
|
session_type = quart.websocket.args.get('session_type', 'person')
|
|
if session_type not in ['person', 'group']:
|
|
await quart.websocket.send(
|
|
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
|
)
|
|
return
|
|
|
|
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
|
if not websocket_adapter:
|
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
|
return
|
|
|
|
try:
|
|
connection = await ws_connection_manager.add_connection(
|
|
websocket=quart.websocket._get_current_object(),
|
|
pipeline_uuid=pipeline_uuid,
|
|
session_type=session_type,
|
|
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
|
)
|
|
|
|
await quart.websocket.send(
|
|
json.dumps(
|
|
{
|
|
'type': 'connected',
|
|
'connection_id': connection.connection_id,
|
|
'bot_uuid': bot_uuid,
|
|
'session_type': session_type,
|
|
'timestamp': connection.created_at.isoformat(),
|
|
}
|
|
)
|
|
)
|
|
|
|
logger.debug(
|
|
f'Embed WebSocket connected: {connection.connection_id} '
|
|
f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})'
|
|
)
|
|
|
|
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot))
|
|
send_task = asyncio.create_task(self._handle_send(connection))
|
|
|
|
try:
|
|
await asyncio.gather(receive_task, send_task)
|
|
except Exception as e:
|
|
logger.error(f'Embed WebSocket task error: {e}')
|
|
finally:
|
|
await ws_connection_manager.remove_connection(connection.connection_id)
|
|
|
|
except Exception as e:
|
|
logger.error(f'Embed WebSocket connection error: {e}', exc_info=True)
|
|
try:
|
|
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
|
except Exception:
|
|
pass
|
|
|
|
# -- WebSocket receive/send helpers --------------------------------------
|
|
|
|
async def _handle_receive(self, connection, websocket_adapter, owner_bot):
|
|
try:
|
|
while connection.is_active:
|
|
message = await quart.websocket.receive()
|
|
await ws_connection_manager.update_activity(connection.connection_id)
|
|
|
|
try:
|
|
data = json.loads(message)
|
|
message_type = data.get('type', 'message')
|
|
|
|
if message_type == 'ping':
|
|
await connection.send_queue.put(
|
|
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
|
)
|
|
elif message_type == 'message':
|
|
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
|
elif message_type == 'disconnect':
|
|
break
|
|
|
|
except json.JSONDecodeError:
|
|
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
|
|
|
except Exception as e:
|
|
logger.error(f'Embed receive error: {e}', exc_info=True)
|
|
finally:
|
|
connection.is_active = False
|
|
|
|
async def _handle_send(self, connection):
|
|
try:
|
|
while connection.is_active:
|
|
try:
|
|
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
|
await quart.websocket.send(json.dumps(message))
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
except Exception as e:
|
|
logger.error(f'Embed send error: {e}', exc_info=True)
|
|
finally:
|
|
connection.is_active = False
|