Feat/webpage adapter (#2135)

* 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>
This commit is contained in:
fdc310
2026-04-24 15:36:14 +08:00
committed by GitHub
parent 58c1916712
commit 564d829e25
11 changed files with 2067 additions and 21 deletions

View File

@@ -0,0 +1,177 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: web_page_bot
label:
en_US: "Page Bot"
zh_Hans: "页面机器人"
zh_Hant: "頁面機器人"
ja_JP: "ページボット"
th_TH: "บอทหน้าเว็บ"
vi_VN: "Bot trang web"
es_ES: "Bot de página"
description:
en_US: "Embed a chat widget on any website with a simple script tag"
zh_Hans: "通过一行脚本标签将聊天组件嵌入到任何网站"
zh_Hant: "透過一行腳本標籤將聊天元件嵌入到任何網站"
ja_JP: "シンプルなスクリプトタグで任意のウェブサイトにチャットウィジェットを埋め込みます"
th_TH: "ฝังวิดเจ็ตแชทในเว็บไซต์ใดก็ได้ด้วยแท็กสคริปต์"
vi_VN: "Nhúng widget trò chuyện vào bất kỳ trang web nào bằng thẻ script"
es_ES: "Incrusta un widget de chat en cualquier sitio web con una etiqueta de script"
icon: "webpage.webp"
spec:
categories:
- popular
config:
- name: title
label:
en_US: Widget Title
zh_Hans: 组件标题
zh_Hant: 元件標題
ja_JP: ウィジェットタイトル
th_TH: ชื่อวิดเจ็ต
vi_VN: Tiêu đề widget
es_ES: Título del widget
description:
en_US: The title displayed in the chat widget header
zh_Hans: 显示在聊天组件顶部的标题
zh_Hant: 顯示在聊天元件頂部的標題
ja_JP: チャットウィジェットのヘッダーに表示されるタイトル
th_TH: ชื่อที่แสดงในส่วนหัวของวิดเจ็ตแชท
vi_VN: Tiêu đề hiển thị trong đầu widget trò chuyện
es_ES: El título que se muestra en el encabezado del widget de chat
type: string
required: false
default: "LangBot"
- name: bubble_icon
label:
en_US: Bubble Icon
zh_Hans: 气泡图标
zh_Hant: 氣泡圖示
ja_JP: バブルアイコン
th_TH: ไอคอนบับเบิล
vi_VN: Biểu tượng bong bóng
es_ES: Icono de burbuja
ru_RU: Иконка пузырька
description:
en_US: "Icon displayed on the floating chat bubble"
zh_Hans: "浮动聊天气泡上显示的图标"
type: select
required: false
default: "logo"
options:
- name: "logo"
label:
en_US: "LangBot Logo"
zh_Hans: "LangBot 图标"
- name: "chat"
label:
en_US: "Chat Bubble"
zh_Hans: "聊天气泡"
- name: "robot"
label:
en_US: "Robot"
zh_Hans: "机器人"
- name: "headset"
label:
en_US: "Headset"
zh_Hans: "客服耳机"
- name: "sparkle"
label:
en_US: "Sparkle"
zh_Hans: "星光"
- name: "message"
label:
en_US: "Message"
zh_Hans: "消息"
- name: language
label:
en_US: Widget Language
zh_Hans: 组件语言
zh_Hant: 元件語言
ja_JP: ウィジェット言語
th_TH: ภาษาวิดเจ็ต
vi_VN: Ngôn ngữ widget
es_ES: Idioma del widget
ru_RU: Язык виджета
description:
en_US: "Display language of the chat widget"
zh_Hans: "聊天组件的显示语言"
zh_Hant: "聊天元件的顯示語言"
ja_JP: "チャットウィジェットの表示言語"
th_TH: "ภาษาแสดงผลของวิดเจ็ตแชท"
vi_VN: "Ngôn ngữ hiển thị của widget trò chuyện"
es_ES: "Idioma de visualización del widget de chat"
ru_RU: "Язык отображения виджета чата"
type: select
required: false
default: "en_US"
options:
- name: "en_US"
label:
en_US: "English"
- name: "zh_Hans"
label:
en_US: "简体中文"
- name: "zh_Hant"
label:
en_US: "繁體中文"
- name: "ja_JP"
label:
en_US: "日本語"
- name: "es_ES"
label:
en_US: "Español"
- name: "ru_RU"
label:
en_US: "Русский"
- name: "th_TH"
label:
en_US: "ไทย"
- name: "vi_VN"
label:
en_US: "Tiếng Việt"
- name: embed_code
label:
en_US: Embed Code
zh_Hans: 嵌入代码
zh_Hant: 嵌入代碼
ja_JP: 埋め込みコード
th_TH: โค้ดฝังตัว
vi_VN: Mã nhúng
es_ES: Código de incrustación
description:
en_US: "Copy this code and paste it into your website HTML. The code will be generated after saving."
zh_Hans: "复制此代码并粘贴到你的网站 HTML 中。保存后将自动生成。"
zh_Hant: "複製此代碼並貼到你的網站 HTML 中。儲存後將自動生成。"
ja_JP: "このコードをコピーしてウェブサイトのHTMLに貼り付けてください。保存後に自動生成されます。"
th_TH: "คัดลอกโค้ดนี้และวางในHTML ของเว็บไซต์ของคุณ จะสร้างอัตโนมัติหลังจากบันทึก"
vi_VN: "Sao chép mã này và dán vào HTML trang web của bạn. Mã sẽ được tạo tự động sau khi lưu."
es_ES: "Copia este código y pégalo en el HTML de tu sitio web. El código se generará después de guardar."
type: embed-code
required: false
default: ""
- name: turnstile_site_key
label:
en_US: Turnstile Site Key
zh_Hans: Turnstile 站点密钥
description:
en_US: "Cloudflare Turnstile site key for bot protection. Get it from the Cloudflare dashboard (Turnstile > Add Site). Leave empty to disable."
zh_Hans: "Cloudflare Turnstile 站点密钥,用于防止机器人滥用。在 Cloudflare 控制台Turnstile > 添加站点)中获取。留空则不启用。"
type: string
required: false
default: ""
- name: turnstile_secret_key
label:
en_US: Turnstile Secret Key
zh_Hans: Turnstile 服务端密钥
description:
en_US: "Cloudflare Turnstile secret key for server-side token verification. Found alongside the site key in the Cloudflare dashboard. Required if site key is set."
zh_Hans: "Cloudflare Turnstile 服务端密钥,用于服务端验证令牌。与站点密钥一起在 Cloudflare 控制台中获取。设置了站点密钥时必填。"
type: string
required: false
default: ""
execution:
python:
path: "web_page_bot_adapter.py"
attr: "WebPageBotAdapter"

View File

@@ -0,0 +1,97 @@
"""Web Page Bot adapter - lightweight adapter for embeddable chat widget"""
import typing
import pydantic
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Lightweight adapter for the embeddable page bot.
This adapter does not handle messages itself. The actual WebSocket
communication is handled by the singleton websocket_proxy_bot.
This adapter stores event listeners so that RuntimeBot can register
its handlers, which are then called by the websocket adapter when
a message arrives for this bot's pipeline.
Message sending/replying is delegated to the websocket_proxy_bot's
adapter so that replies are actually delivered over the WebSocket
connection while the dashboard correctly shows this adapter's name.
"""
listeners: dict = pydantic.Field(default_factory=dict, exclude=True)
_ws_adapter: typing.Any = None
class Config:
arbitrary_types_allowed = True
# Allow private attributes
underscore_attrs_are_private = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
super().__init__(config=config, logger=logger, **kwargs)
def set_ws_adapter(self, ws_adapter) -> None:
"""Set the underlying WebSocket adapter used for actual message delivery."""
object.__setattr__(self, '_ws_adapter', ws_adapter)
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> dict:
if self._ws_adapter is not None:
return await self._ws_adapter.send_message(target_type, target_id, message)
return {}
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> dict:
if self._ws_adapter is not None:
return await self._ws_adapter.reply_message(message_source, message, quote_origin)
return {}
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
) -> dict:
if self._ws_adapter is not None:
return await self._ws_adapter.reply_message_chunk(
message_source, bot_message, message, quote_origin, is_final
)
return {}
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
func: typing.Callable,
):
self.listeners[event_type] = func
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
func: typing.Callable,
):
self.listeners.pop(event_type, None)
async def is_muted(self, group_id: int) -> bool:
return False
async def run_async(self):
pass
async def kill(self):
pass

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -312,7 +312,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
async def _process_image_components(self, message_chain_obj: list):
"""
处理消息链中的图片组件将path转换为base64
处理消息链中的图片和文件组件将path转换为base64
Args:
message_chain_obj: 消息链对象列表
@@ -322,16 +322,18 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
storage_mgr = self.ap.storage_mgr
for component in message_chain_obj:
if component.get('type') == 'Image' and component.get('path'):
try:
# 从storage读取文件
file_content = await storage_mgr.storage_provider.load(component['path'])
comp_type = component.get('type', '')
comp_path = component.get('path', '')
# 转换为base64
if not comp_path:
continue
if comp_type == 'Image':
try:
file_content = await storage_mgr.storage_provider.load(comp_path)
base64_str = base64.b64encode(file_content).decode('utf-8')
# 添加data URI前缀根据文件扩展名判断MIME类型
file_key = component['path']
file_key = comp_path
if file_key.lower().endswith(('.jpg', '.jpeg')):
mime_type = 'image/jpeg'
elif file_key.lower().endswith('.png'):
@@ -341,19 +343,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
elif file_key.lower().endswith('.webp'):
mime_type = 'image/webp'
else:
mime_type = 'image/png' # 默认
mime_type = 'image/png'
component['base64'] = f'data:{mime_type};base64,{base64_str}'
await storage_mgr.storage_provider.delete(component['path'])
await storage_mgr.storage_provider.delete(comp_path)
component['path'] = ''
# 保留path字段用于后端处理前端使用base64显示
except Exception as e:
await self.logger.error(f'加载图片文件失败 {component["path"]}: {e}')
await self.logger.error(f'Failed to load image file {comp_path}: {e}')
async def handle_websocket_message(
self,
connection: WebSocketConnection,
message_data: dict,
owner_bot=None,
):
"""
处理从WebSocket接收的消息
@@ -366,6 +368,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
message_data: 消息数据,包含:
- message: 消息链
- stream: 是否启用流式输出 (可选默认True)
owner_bot: Optional RuntimeBot that owns this pipeline (e.g. a web_page_bot).
When provided, its identity is used for logging and session tracking.
"""
pipeline_uuid = connection.pipeline_uuid
session_type = connection.session_type
@@ -435,12 +439,26 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
)
# 设置流水线UUID
# 设置流水线UUID (proxy bot always needs it for reply_message routing)
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
if owner_bot is not None:
owner_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
# 异步触发事件处理(不等待结果)
if event.__class__ in self.listeners:
asyncio.create_task(self.listeners[event.__class__](event, self))
# 异步触发事件处理
# Use owner_bot's listeners if available, otherwise fall back to proxy bot
listeners = (
owner_bot.adapter.listeners
if (owner_bot and hasattr(owner_bot.adapter, 'listeners') and owner_bot.adapter.listeners)
else self.listeners
)
# Pass owner_bot's adapter so that downstream logging / dashboard
# attributes the message to the correct bot adapter name.
# Wire the ws adapter into the owner so replies are actually delivered.
if owner_bot and hasattr(owner_bot.adapter, 'set_ws_adapter'):
owner_bot.adapter.set_ws_adapter(self)
callback_adapter = owner_bot.adapter if (owner_bot and hasattr(owner_bot, 'adapter')) else self
if event.__class__ in listeners:
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
"""获取消息历史"""