Compare commits

..

23 Commits

Author SHA1 Message Date
dadachann 70ad04ce25 Merge master into deploy/prod 2026-08-24 06:54:21 +00:00
Hyu f0ee57c1e0 style(email): use generic LangBot invitation branding (#2466)
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-08-24 14:53:59 +08:00
dadachann 0bab5ddf4c Merge master into deploy/prod 2026-08-24 06:15:09 +00:00
Hyu a45e27e76e style(cloud): redesign workspace invitation email (#2464)
* style(cloud): redesign workspace invitation email

* fix(email): harden Outlook spacing and text contrast

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2026-08-24 14:14:56 +08:00
dadachann b791caeb98 Merge master into deploy/prod 2026-08-24 04:53:51 +00:00
Hyu 536fcdf29f fix(web): restore plugin page SDK loading (#2435)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-24 12:40:50 +08:00
dadachann dbea5150af Merge master into deploy/prod 2026-08-24 04:35:30 +00:00
QuasarRyan c87548c0b9 feat(qqofficial): add markdown reply rendering (#2459) 2026-08-24 11:59:23 +08:00
dadachann 0c06c05938 Merge master into deploy/prod 2026-08-24 03:54:53 +00:00
QuasarRyan 79634772da fix(qqofficial): send complete stream snapshots (#2458) 2026-08-24 11:49:22 +08:00
dadachann 7e0a72b104 Merge master into deploy/prod for Cloud rollout 2026-08-24 03:18:22 +00:00
Hyu bb366779af fix(auth): align invitation password fields (#2461)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-24 10:53:05 +08:00
Hyu 1336f47cb4 fix(auth): enable local registration for OSS invitations (#2460)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-08-24 10:28:45 +08:00
RockChinQ 962366c507 fix(deps): make SeekDB optional for native installs 2026-08-21 12:07:36 +08:00
Hyu 23875b240f Merge pull request #2452 from langbot-app/ci/immutable-core-images
ci: publish immutable Core image tags
2026-08-21 10:19:58 +08:00
dadachann e699358a5a ci: publish immutable Core image tags 2026-08-21 02:16:34 +00:00
Hyu 14277d129c Merge pull request #2451 from langbot-app/fix/release-4.10.8-cloud
fix(cloud): carry runtime readiness fixes into 4.10.8
2026-08-21 03:12:12 +08:00
dadachann 6bf1546df2 style: format Cloud runtime readiness fixes 2026-08-20 19:08:41 +00:00
dadachann 0bec72a3f9 fix(cloud): run runtime initialization outside transaction gate 2026-08-20 18:55:24 +00:00
dadachann f36542135a fix(cloud): start API before runtime reconcile 2026-08-20 18:55:24 +00:00
dadachann 693c59b726 fix(cloud): import lifecycle task scope 2026-08-20 18:55:24 +00:00
dadachann c3fe312a43 fix(cloud): refresh directory during plugin startup 2026-08-20 18:55:24 +00:00
dadachann c4bad508d2 fix(runtime): honor configured cold reconcile timeout 2026-08-20 18:55:24 +00:00
33 changed files with 975 additions and 167 deletions
+32 -13
View File
@@ -7,23 +7,42 @@ on:
jobs:
build-dev-image:
runs-on: ubuntu-latest
# 如果是tag则跳过
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Generate Tag
id: generate_tag
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate image metadata
id: image
shell: bash
run: |
# 获取分支名称,把/替换为-
echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g'
echo ::set-output name=tag::$(echo ${{ github.ref }} | sed 's/refs\/heads\///g' | sed 's/\//-/g')
- name: Login to Registry
run: docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker Image
run: |
docker buildx create --name mybuilder --use
docker build -t rockchin/langbot:${{ steps.generate_tag.outputs.tag }} . --push
set -euo pipefail
branch_tag="${GITHUB_REF#refs/heads/}"
branch_tag="${branch_tag//\//-}"
echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT"
echo "sha_tag=sha-${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push immutable Core image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
rockchin/langbot:${{ steps.image.outputs.branch_tag }}
rockchin/langbot:${{ steps.image.outputs.sha_tag }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
+1 -1
View File
@@ -62,7 +62,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -f /tmp/nodesource_setup.sh \
&& python -m pip install --no-cache-dir uv \
&& uv sync \
&& uv sync --extra seekdb \
&& apt-get purge -y --auto-remove curl git gnupg \
&& rm -rf /var/lib/apt/lists/* \
&& touch /.dockerenv
+18 -1
View File
@@ -10,6 +10,19 @@ uvx langbot
This will automatically download and run the latest version of LangBot.
SeekDB support is optional and is not installed by the command above. If you
want to use the SeekDB vector database or the built-in SeekDB embedding model,
run LangBot with the `seekdb` extra:
```bash
uvx --from 'langbot[seekdb]@latest' langbot
```
The extra includes native dependencies whose supported operating systems may
be narrower than LangBot's. In particular, the current Apple Silicon wheels
require macOS 15 or later. The default Chroma backend does not have this
requirement.
## Install with pip/uv
You can also install LangBot as a regular Python package:
@@ -20,6 +33,10 @@ pip install langbot
# Using uv
uv pip install langbot
# Include optional SeekDB support
pip install 'langbot[seekdb]'
# or: uv pip install 'langbot[seekdb]'
```
Then run it:
@@ -101,7 +118,7 @@ uvx langbot
## System Requirements
- Python 3.10.1 or higher
- Python 3.11 or higher (lower than Python 4)
- Operating System: Linux, macOS, or Windows
## Differences from Source Installation
+34 -43
View File
@@ -16,12 +16,20 @@ This document describes how to use OceanBase SeekDB as the vector database backe
## Installation
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
SeekDB is an optional LangBot feature. A normal LangBot installation uses
Chroma by default and does not install `pyseekdb` or its native bindings.
If you need to install it manually:
Choose the command that matches how you run LangBot:
```bash
pip install pyseekdb
# PyPI / uvx
uvx --from 'langbot[seekdb]@latest' langbot
# Installed package
pip install 'langbot[seekdb]'
# Source checkout
uv sync --extra seekdb
```
## ⚠️ Platform Compatibility
@@ -30,31 +38,36 @@ pip install pyseekdb
| Platform | Status | Notes |
|----------|--------|-------|
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
| Linux x86_64 / ARM64 | ✅ Supported | Full embedded mode support via `pylibseekdb` |
| macOS 15+ on Apple Silicon | ✅ Supported | Requires the macOS ARM64 `pylibseekdb` wheel |
| macOS 14 or earlier on Apple Silicon | ❌ Not currently supported | The published native wheel requires macOS 15+; follow [oceanbase/seekdb#1324](https://github.com/oceanbase/seekdb/issues/1324) |
| macOS on Intel | ❌ Not currently supported | No embedded binding is selected by `pyseekdb` |
| Windows | ❌ Not currently supported | No Windows `pylibseekdb` wheel is published |
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
**Important**: Embedded mode requires a compatible `pylibseekdb` wheel. Do not
force-install or retag a wheel built for a newer macOS release: the bundled
binaries also declare macOS 15 as their minimum deployment target.
### Server Mode (Docker)
| Platform | Status | Notes |
|----------|--------|-------|
| Linux | ✅ Supported | Full Docker support |
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
| Windows | ⚠️ Untested | Should work but not yet tested |
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
- Using ChromaDB or Qdrant as alternatives
- Connecting to a remote SeekDB server on Linux if available
| macOS | ✅ Supported by Docker Desktop | The previous slow-disk startup issue was fixed upstream in [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36) |
| Windows | ⚠️ Depends on the container runtime | Use a Linux container and follow the upstream image documentation |
### Server Mode (Remote Connection)
| Platform | Status | Notes |
|----------|--------|-------|
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
| Linux | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
| macOS 15+ on Apple Silicon | ✅ Supported | Install the `seekdb` extra and connect to the remote server |
| macOS 14 or earlier on Apple Silicon | ⚠️ Blocked by upstream packaging | `pyseekdb` currently requires the unavailable native wheel even for server-only use; follow [#1324](https://github.com/oceanbase/seekdb/issues/1324) |
| macOS on Intel / Windows | ✅ Server mode only | Embedded bindings are not available |
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
Remote server mode does not use embedded storage at runtime. However, whether
the Python client can be installed still depends on `pyseekdb`'s package
metadata for the current platform.
## Configuration
@@ -170,22 +183,23 @@ Key methods:
### Import Error
If you see: `ImportError: pyseekdb is not installed`
If you see: `SeekDB support is not installed`
Solution:
```bash
pip install pyseekdb
uv sync --extra seekdb
# or: uvx --from 'langbot[seekdb]@latest' langbot
```
### Embedded Mode Error on macOS/Windows
### Embedded Mode Is Unavailable on the Current Platform
**Error**:
```
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
```
**Cause**: `pylibseekdb` is only available on Linux platforms.
**Cause**: No compatible `pylibseekdb` wheel is installed for the current OS,
CPU architecture, Python version, and macOS deployment target.
**Solution**: Use server mode instead:
1. Deploy SeekDB on a Linux server or VM
@@ -208,29 +222,6 @@ vdb:
use: chroma # or qdrant
```
### Docker Container Fails on macOS
**Symptoms**:
```bash
docker run -d -p 2881:2881 oceanbase/seekdb:latest
# Container exits immediately with code 30
```
**Error in logs**:
```
[ERROR] Code: Agent.SeekDB.Not.Exists
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
```
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
**Status**: Under investigation by OceanBase team.
**Workaround Options**:
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
3. **Wait for fix**: Monitor the GitHub issue for updates
### Connection Error (Server Mode)
If SeekDB server is not reachable, check:
+5 -1
View File
@@ -70,7 +70,6 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.5.5",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
@@ -108,6 +107,11 @@ classifiers = [
"Topic :: Communications :: Chat",
]
[project.optional-dependencies]
seekdb = [
"pyseekdb==1.1.0.post3",
]
[project.urls]
Homepage = "https://langbot.app"
Documentation = "https://docs.langbot.app"
+63
View File
@@ -422,6 +422,69 @@ class QQOfficialClient:
await self.logger.error(f'Failed to send private message: {response_data}')
raise ValueError(response)
async def _send_markdown_msg(
self,
target_type: str,
target_id: str,
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
) -> None:
"""Send a Markdown message to a C2C user or QQ group."""
if not await self.check_access_token():
await self.get_access_token()
if target_type == 'c2c':
url = f'{self.base_url}/v2/users/{target_id}/messages'
elif target_type == 'group':
url = f'{self.base_url}/v2/groups/{target_id}/messages'
else:
raise ValueError(f'Unsupported Markdown target type: {target_type}')
data: dict[str, Any] = {
'msg_type': 2,
'markdown': {'content': content},
'msg_seq': msg_seq,
}
if msg_id:
data['msg_id'] = msg_id
if event_id:
data['event_id'] = event_id
async with self._http_client_context() as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
response = await client.post(url, headers=headers, json=data)
if response.status_code != 200:
response_data = await httpclient.parse_json_response(response)
await self.logger.error(f'Failed to send Markdown message: {response_data}')
raise ValueError(response)
async def send_private_markdown_msg(
self,
user_openid: str,
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
) -> None:
"""Send a Markdown C2C message."""
await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq)
async def send_group_markdown_msg(
self,
group_openid: str,
content: str,
msg_id: Optional[str] = None,
event_id: Optional[str] = None,
msg_seq: int = 1,
) -> None:
"""Send a Markdown QQ group message."""
await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq)
async def send_group_text_msg(
self,
group_openid: str,
@@ -322,6 +322,7 @@ class UserRouterGroup(group.RouterGroup):
if cloud_mode:
capabilities['password_login_enabled'] = False
capabilities['authenticated_invitation_acceptance_enabled'] = cloud_mode
capabilities['invitation_registration_enabled'] = not cloud_mode
return self.success(data={'initialized': True, **capabilities})
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
+33 -3
View File
@@ -301,11 +301,36 @@ class Application:
async def initialize(self):
pass
async def _initialize_plugin_runtime(self) -> None:
try:
await self.plugin_connector.initialize()
except asyncio.CancelledError:
raise
except Exception as exc:
self.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
self.plugin_connector.schedule_reconnect()
def _start_plugin_runtime_initialization(self) -> asyncio.Task | None:
task = getattr(self, '_plugin_runtime_initialization_task', None)
if task is not None and not task.done():
return task
# This is application lifecycle work, not a request side effect. It must
# not wait on PersistenceManager's after-commit gate at boot.
task = asyncio.create_task(
self._initialize_plugin_runtime(),
name='plugin-runtime-initialization',
)
self._plugin_runtime_initialization_task = task
return task
async def run(self):
self.event_loop_monitor.start()
try:
if self.directory_projection_service is not None:
self.task_mgr.create_task(
if (
self.directory_projection_service is not None
and getattr(self, 'directory_projection_task', None) is None
):
self.directory_projection_task = self.task_mgr.create_task(
self.directory_projection_service.run(),
name='cloud-directory-projection',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
@@ -322,7 +347,6 @@ class Application:
name='cloud-manifest-refresh',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
await self.plugin_connector.initialize_plugins()
# 后续可能会允许动态重启其他任务
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
@@ -348,6 +372,7 @@ class Application:
name='http-api-controller',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
self._start_plugin_runtime_initialization()
# Telemetry instance heartbeat (startup + daily); respects
# space.disable_telemetry via TelemetryManager.send().
@@ -529,6 +554,11 @@ class Application:
if self.task_mgr is not None:
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
plugin_runtime_task = getattr(self, '_plugin_runtime_initialization_task', None)
if plugin_runtime_task is not None and not plugin_runtime_task.done():
plugin_runtime_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await plugin_runtime_task
with contextlib.suppress(Exception):
await self.event_loop_monitor.stop()
mcp_mount = getattr(self.http_ctrl, 'mcp_mount', None)
+11 -8
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from .. import stage, app
from .. import stage, app, entities as core_entities
from ...utils import version, proxy, constants
from ...pipeline import pool, controller, pipelinemgr
from ...pipeline import aggregator as message_aggregator
@@ -292,14 +292,17 @@ class BuildAppStage(stage.BootingStage):
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
connector.schedule_reconnect()
if ap.directory_projection_service is not None:
# Keep the projection fresh while shared Runtime cold restore runs.
# BuildApp initializes the connector before Application.run() starts
# its long-lived tasks, so start the single refresh task here.
ap.directory_projection_task = ap.task_mgr.create_task(
ap.directory_projection_service.run(),
name='cloud-directory-projection',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
try:
await plugin_connector_inst.initialize()
except Exception as exc:
# Keep the API/UI available while an external or managed runtime is
# starting, then recover in the background with bounded backoff.
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
plugin_connector_inst.schedule_reconnect()
ap.plugin_connector = plugin_connector_inst
workspace_service_inst.release_startup_execution_bindings()
+50 -29
View File
@@ -329,17 +329,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
content_type = content.get('type', 'text')
if content_type == 'text':
if target_type == 'c2c':
await self.bot.send_private_text_msg(
if target_type in {'c2c', 'group'}:
await self._send_c2c_or_group_text_reply(
target_type,
target_id,
content['content'],
qq_official_event.d_id,
)
elif target_type == 'group':
await self.bot.send_group_text_msg(
target_id,
content['content'],
qq_official_event.d_id,
msg_id=qq_official_event.d_id,
)
elif content_type == 'image':
@@ -383,6 +378,39 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
async def _send_c2c_or_group_text_reply(
self,
target_type: str,
target_id: str,
content: str,
*,
msg_id: typing.Optional[str] = None,
event_id: typing.Optional[str] = None,
msg_seq: int = 1,
) -> None:
"""Send a text reply using the configured C2C/group render mode."""
use_markdown = self.config.get('enable-markdown-rendering', False)
if target_type == 'c2c':
send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg
await send(
user_openid=target_id,
content=content,
msg_id=msg_id,
event_id=event_id,
msg_seq=msg_seq,
)
elif target_type == 'group':
send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg
await send(
group_openid=target_id,
content=content,
msg_id=msg_id,
event_id=event_id,
msg_seq=msg_seq,
)
else:
raise ValueError(f'Unsupported QQ Official text reply target: {target_type}')
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
@@ -650,13 +678,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
ctx['session_started'] = True
# 发送内容 = 全量累积文本
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
# - 首次:发送全部文本,建立会话
# - 后续:只能发送新增部分(append 行为)
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
if not content_to_send and not is_final:
# `replace` mode requires every update to contain the previously
# delivered content as its prefix. `sent_length` only tells us whether
# a non-final snapshot has new content; it must not truncate the
# content sent to QQ.
if len(ctx['accumulated_text']) <= ctx['sent_length'] and not is_final:
return
content_to_send = ctx['accumulated_text']
input_state = 10 if is_final else 1
@@ -778,20 +806,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
return
try:
if target_type == 'c2c':
await self.bot.send_private_text_msg(
user_openid=target_id,
content=text,
event_id=event_id,
msg_seq=msg_seq,
)
elif target_type == 'group':
await self.bot.send_group_text_msg(
group_openid=target_id,
content=text,
event_id=event_id,
msg_seq=msg_seq,
)
await self._send_c2c_or_group_text_reply(
target_type,
target_id,
text,
event_id=event_id,
msg_seq=msg_seq,
)
except Exception:
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
@@ -95,6 +95,18 @@ spec:
type: boolean
required: true
default: false
- name: enable-markdown-rendering
label:
en_US: Enable Markdown Rendering
zh_Hans: 启用 Markdown 渲染
zh_Hant: 啟用 Markdown 渲染
description:
en_US: Render non-stream C2C and QQ group text replies as Markdown. Channel messages always use plain text and are not affected by this setting.
zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。频道消息始终以纯文本发送,不受此设置影响。
zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。頻道訊息一律以純文字傳送,不受此設定影響。
type: boolean
required: true
default: false
- name: webhook_url
label:
en_US: Webhook Callback URL
+14 -2
View File
@@ -701,7 +701,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
}
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values()))
reconcile_timeout_seconds = max(
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
)
result = await runtime_handler.reconcile_plugin_installations(
tuple(self._known_desired_states.values()),
timeout=reconcile_timeout_seconds,
)
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
self._record_reconcile_failures(self._known_desired_states, result)
@@ -736,7 +742,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if state.binding.installation_uuid in all_states:
raise ValueError('Duplicate plugin installation UUID across projected Workspaces')
all_states[state.binding.installation_uuid] = state
result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values()))
reconcile_timeout_seconds = max(
300.0, self._runtime_connect_timeout(self.ap.instance_config.data.get('plugin', {}))
)
result = await runtime_handler.reconcile_plugin_installations(
tuple(all_states.values()),
timeout=reconcile_timeout_seconds,
)
await self._repair_reconcile_missing_artifacts(all_states, result)
self._record_reconcile_failures(all_states, result)
for installation_uuid, previous in tuple(self._known_desired_states.items()):
+3 -1
View File
@@ -1677,13 +1677,15 @@ class RuntimeConnectionHandler(handler.Handler):
async def reconcile_plugin_installations(
self,
installations: tuple[PluginInstallationDesiredState, ...],
*,
timeout: float = 300,
) -> dict[str, Any]:
request = ReconcilePluginInstallationsRequest(installations=installations)
with self.installation_scope(None):
return await self.call_action(
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
request.model_dump(),
timeout=300,
timeout=timeout,
)
async def apply_plugin_installation(
@@ -24,7 +24,10 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
try:
import pyseekdb
except ImportError:
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
raise ImportError(
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
)
self._embedding_function = pyseekdb.get_default_embedding_function()
+4 -1
View File
@@ -42,7 +42,10 @@ class SeekDBVectorDatabase(VectorDatabase):
def __init__(self, ap: app.Application):
if not SEEKDB_AVAILABLE:
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
raise ImportError(
"SeekDB support is not installed. Install LangBot with the 'seekdb' extra: "
"uv sync --extra seekdb (source) or uvx --from 'langbot[seekdb]@latest' langbot (PyPI)."
)
self.ap = ap
config = self.ap.instance_config.data['vdb']['seekdb']
@@ -240,7 +240,7 @@ class InvitationDeliveryService:
@staticmethod
def _plain_text(workspace_name: str, invitation_link: str) -> str:
return (
'You have been invited to LangBot Cloud\n\n'
'You have been invited to join a Workspace in LangBot\n\n'
f'Join the Workspace “{workspace_name}” to collaborate with your team.\n\n'
f'Accept invitation: {invitation_link}\n\n'
'This secure invitation expires in 7 days and can only be accepted by the email address '
@@ -258,30 +258,77 @@ class InvitationDeliveryService:
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Join {escaped_workspace} on LangBot Cloud</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Join {escaped_workspace} in LangBot</title>
</head>
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
<tr><td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">Youre invited</div>
</td></tr>
<tr><td style="padding:36px;">
<p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
<div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
<a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
</td></tr></table>
<p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
<p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
</td></tr>
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
</table>
</td></tr>
<body style="margin:0;padding:0;background:#f4f7fb;color:#111827;font-family:Arial,'Helvetica Neue',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} in LangBot.</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;">
<tr>
<td align="center" style="padding:48px 16px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
<tr>
<td style="padding:0 4px 20px;">
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
</td>
</tr>
<tr>
<td style="background:#ffffff;border-radius:10px;overflow:hidden;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding:42px 42px 38px;">
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div>
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.25;font-weight:700;letter-spacing:-.025em;color:#111827;">Youre invited to collaborate</h1>
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#526173;">Join your team in LangBot and start building together in this Workspace.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#f6f8fb;border-radius:8px;">
<tr>
<td style="padding:16px 18px;">
<div style="margin:0 0 4px;font-size:11px;line-height:1.4;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#5f6f84;">Workspace</div>
<div style="font-size:18px;line-height:1.4;font-weight:700;color:#111827;">{escaped_workspace}</div>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr><td height="28" style="height:28px;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="background:#2563eb;border-radius:8px;">
<a href="{escaped_link}" target="_blank" style="display:inline-block;padding:13px 22px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">Accept invitation</a>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr><td height="32" style="height:32px;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border-top:1px solid #e8edf4;">
<tr>
<td style="padding-top:22px;">
<p style="margin:0 0 10px;font-size:13px;line-height:1.6;color:#5f6f84;">For your security, this invitation expires in 7 days and only works for the email address that received it.</p>
<a href="{escaped_link}" target="_blank" style="font-size:13px;line-height:1.6;font-weight:600;color:#2563eb;text-decoration:none;">Open invitation link&nbsp;&rarr;</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding:20px 24px 0;font-size:12px;line-height:1.6;color:#5f6f84;">
Sent by LangBot<br>
If you were not expecting this invitation, you can safely ignore this email.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'''
+5
View File
@@ -181,6 +181,11 @@ vdb:
host: localhost
port: 6333
api_key: ''
# SeekDB is optional. Native/package installs need the `seekdb` extra:
# `uv sync --extra seekdb` (source) or
# `uvx --from 'langbot[seekdb]@latest' langbot` (PyPI).
# The official Docker image already includes it.
# Embedded-mode platform support depends on the native pylibseekdb wheels.
seekdb:
mode: embedded # 'embedded' or 'server'
# Embedded mode options:
+23
View File
@@ -307,6 +307,7 @@ class TestUserInitEndpoint:
assert data['data'] == {
'initialized': True,
'authenticated_invitation_acceptance_enabled': False,
'invitation_registration_enabled': True,
'password_login_enabled': True,
'space_login_enabled': False,
}
@@ -330,6 +331,28 @@ class TestUserInitEndpoint:
assert data['data'] == {
'initialized': True,
'authenticated_invitation_acceptance_enabled': True,
'invitation_registration_enabled': False,
'password_login_enabled': False,
'space_login_enabled': True,
}
@pytest.mark.asyncio
async def test_account_info_enables_local_invitation_registration_for_oauth_only_oss(
self, quart_test_client, fake_api_app
):
fake_api_app.user_service.is_initialized.return_value = True
fake_api_app.user_service.get_login_capabilities = AsyncMock(
return_value={'password_login_enabled': False, 'space_login_enabled': True}
)
response = await quart_test_client.get('/api/v1/user/account-info')
assert response.status_code == 200
data = await response.get_json()
assert data['data'] == {
'initialized': True,
'authenticated_invitation_acceptance_enabled': False,
'invitation_registration_enabled': True,
'password_login_enabled': False,
'space_login_enabled': True,
}
@@ -312,6 +312,29 @@ async def test_space_credits_are_resolved_from_workspace_owner(space_oauth_api):
application.space_service.get_credits.assert_awaited_once_with('owner@example.com')
@pytest.mark.asyncio
async def test_oss_local_only_owner_requires_space_binding_for_langbot_models(space_oauth_api):
application, client = space_oauth_api
application.user_service.get_workspace_owner = AsyncMock(
return_value=SimpleNamespace(user='owner@example.com', space_account_uuid=None)
)
application.space_service.get_credits = AsyncMock()
response = await client.get(
'/api/v1/user/space-credits',
headers={'Authorization': 'Bearer account-token', 'X-Workspace-Id': WORKSPACE_UUID},
)
payload = await response.get_json()
assert response.status_code == 200
assert payload['data'] == {
'credits': None,
'owner_space_bound': False,
'is_workspace_owner': True,
}
application.space_service.get_credits.assert_not_awaited()
@pytest.mark.asyncio
async def test_cloud_workspace_owner_is_always_space_bound_after_login(space_oauth_api):
application, client = space_oauth_api
@@ -144,3 +144,39 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
assert stats['models']['providers'] == 1
assert stats['runtimes']['plugin_installations'] == 1
assert stats['runtimes']['plugin_runtime_connected'] is True
@pytest.mark.asyncio
async def test_start_plugin_runtime_initialization_bypasses_after_commit_gate() -> None:
app = Application()
app.plugin_connector = SimpleNamespace(initialize=AsyncMock())
app.task_mgr = SimpleNamespace(create_task=AsyncMock())
task = app._start_plugin_runtime_initialization()
await task
app.plugin_connector.initialize.assert_awaited_once_with()
app.task_mgr.create_task.assert_not_called()
@pytest.mark.asyncio
async def test_shutdown_cancels_plugin_runtime_initialization_task() -> None:
app = Application()
app._plugin_runtime_initialization_task = asyncio.create_task(asyncio.sleep(60))
app.task_mgr = SimpleNamespace(cancel_by_scope=lambda *_: None, tasks=[])
app.event_loop_monitor = SimpleNamespace(stop=AsyncMock())
app.http_ctrl = SimpleNamespace(mcp_mount=None)
app.platform_mgr = None
app.tool_mgr = None
app.model_mgr = None
app.box_service = None
app.plugin_connector = None
app.telemetry = None
app.vector_db_mgr = None
app.storage_mgr = None
app.persistence_mgr = SimpleNamespace(db=SimpleNamespace(engine=SimpleNamespace(dispose=AsyncMock())))
app.deployment = None
await app.shutdown()
assert app._plugin_runtime_initialization_task.cancelled()
@@ -1,9 +1,11 @@
"""Tests for QQ Official keyboard payload helpers."""
"""Tests for QQ Official message and keyboard payload helpers."""
import asyncio
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -99,6 +101,12 @@ def _stream_test_adapter():
adapter.bot = MagicMock()
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'})
adapter.bot.send_private_text_msg = AsyncMock()
adapter.bot.send_group_text_msg = AsyncMock()
adapter.bot.send_private_markdown_msg = AsyncMock()
adapter.bot.send_group_markdown_msg = AsyncMock()
adapter.bot.send_channle_group_text_msg = AsyncMock()
adapter.bot.send_channle_private_text_msg = AsyncMock()
adapter.ap = None
adapter._stream_ctx = {}
adapter._stream_ctx_ts = {}
@@ -108,7 +116,7 @@ def _stream_test_adapter():
@pytest.mark.asyncio
async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
async def test_qq_stream_replace_mode_sends_complete_snapshots():
adapter = _stream_test_adapter()
adapter._stream_ctx['message-1'] = {
'user_openid': 'user-1',
@@ -138,10 +146,109 @@ async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
'<think>one',
' two',
'<think>one two',
]
@pytest.mark.asyncio
async def test_qq_markdown_messages_use_markdown_payloads():
requests = []
def capture_request(request: httpx.Request) -> httpx.Response:
requests.append((str(request.url), json.loads(request.content)))
return httpx.Response(200, json={})
client = QQOfficialClient('secret', 'token', 'app-id', AsyncMock())
client.access_token = 'access-token'
client.access_token_expiry_time = time.time() + 3600
client._http_clients[None] = httpx.AsyncClient(transport=httpx.MockTransport(capture_request))
try:
await client.send_private_markdown_msg('user-1', '# Hello', msg_id='message-1', msg_seq=2)
await client.send_group_markdown_msg('group-1', '* Hello', event_id='event-1', msg_seq=3)
finally:
await client.close()
assert requests == [
(
'https://api.sgroup.qq.com/v2/users/user-1/messages',
{'msg_type': 2, 'markdown': {'content': '# Hello'}, 'msg_seq': 2, 'msg_id': 'message-1'},
),
(
'https://api.sgroup.qq.com/v2/groups/group-1/messages',
{'msg_type': 2, 'markdown': {'content': '* Hello'}, 'msg_seq': 3, 'event_id': 'event-1'},
),
]
@pytest.mark.asyncio
async def test_qq_markdown_rendering_switches_c2c_and_group_text_replies():
adapter = _stream_test_adapter()
adapter.config = {'enable-markdown-rendering': True}
await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', '# Hello', msg_id='message-1')
await adapter._send_c2c_or_group_text_reply('group', 'group-1', '* Hello', event_id='event-1')
adapter.bot.send_private_markdown_msg.assert_awaited_once_with(
user_openid='user-1',
content='# Hello',
msg_id='message-1',
event_id=None,
msg_seq=1,
)
adapter.bot.send_group_markdown_msg.assert_awaited_once_with(
group_openid='group-1',
content='* Hello',
msg_id=None,
event_id='event-1',
msg_seq=1,
)
adapter.bot.send_private_text_msg.assert_not_awaited()
adapter.bot.send_group_text_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_qq_markdown_rendering_defaults_to_plain_text_replies():
adapter = _stream_test_adapter()
adapter.config = {}
await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', 'Hello')
await adapter._send_c2c_or_group_text_reply('group', 'group-1', 'Hello')
adapter.bot.send_private_text_msg.assert_awaited_once()
adapter.bot.send_group_text_msg.assert_awaited_once()
adapter.bot.send_private_markdown_msg.assert_not_awaited()
adapter.bot.send_group_markdown_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_qq_markdown_rendering_does_not_affect_channel_messages():
adapter = _stream_test_adapter()
adapter.config = {'enable-markdown-rendering': True}
message = platform_message.MessageChain([platform_message.Plain(text='# Hello')])
channel_source = MagicMock()
channel_source.t = 'AT_MESSAGE_CREATE'
channel_source.channel_id = 'channel-1'
channel_source.d_id = 'message-1'
channel_event = MagicMock()
channel_event.source_platform_object = channel_source
await adapter.reply_message(channel_event, message)
dm_source = MagicMock()
dm_source.t = 'DIRECT_MESSAGE_CREATE'
dm_source.guild_id = 'guild-1'
dm_source.d_id = 'message-2'
dm_event = MagicMock()
dm_event.source_platform_object = dm_source
await adapter.reply_message(dm_event, message)
adapter.bot.send_channle_group_text_msg.assert_awaited_once_with('channel-1', '# Hello', 'message-1')
adapter.bot.send_channle_private_text_msg.assert_awaited_once_with('guild-1', '# Hello', 'message-2')
adapter.bot.send_private_markdown_msg.assert_not_awaited()
adapter.bot.send_group_markdown_msg.assert_not_awaited()
@pytest.mark.asyncio
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
@@ -107,6 +107,19 @@ def shared_connector(
return connector
@pytest.mark.asyncio
async def test_shared_reconcile_uses_configured_cold_start_timeout():
binding = execution_binding("workspace-a")
setting = plugin_setting("01", "a" * 64)
connector = shared_connector([[binding]], {"workspace-a": [setting]})
connector.ap.instance_config.data["plugin"]["connect_timeout_seconds"] = 900
connector.handler = runtime_handler()
await connector._prepare_connected_runtime()
assert connector.handler.reconcile_plugin_installations.await_args.kwargs["timeout"] == 900
@pytest.mark.asyncio
async def test_shared_reconnect_replays_two_workspaces_and_removes_missing_projection():
binding_a = execution_binding('workspace-a')
@@ -150,7 +163,7 @@ async def test_empty_projected_workspaces_do_not_retain_installation_sets():
assert connector._workspace_installations == {}
assert connector._known_desired_states == {}
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
connector.handler.reconcile_plugin_installations.assert_awaited_once_with((), timeout=300.0)
@pytest.mark.asyncio
+12
View File
@@ -81,6 +81,18 @@ async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish(
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 300
@pytest.mark.asyncio
async def test_reconcile_plugin_installations_accepts_configured_cold_start_timeout():
runtime_handler = make_handler(SimpleNamespace())
runtime_handler.call_action = AsyncMock(return_value={})
binding = next(iter(runtime_handler._installation_bindings.values()))[0]
desired = PluginInstallationDesiredState(binding=binding, enabled=True)
await runtime_handler.reconcile_plugin_installations((desired,), timeout=900)
assert runtime_handler.call_action.await_args.kwargs["timeout"] == 900
class TestHandlerQueryVariables:
"""Tests for handler query variable logic."""
@@ -0,0 +1,15 @@
from __future__ import annotations
import tomllib
from pathlib import Path
def test_seekdb_is_only_declared_as_an_optional_dependency() -> None:
project_root = Path(__file__).resolve().parents[2]
with (project_root / 'pyproject.toml').open('rb') as pyproject_file:
pyproject = tomllib.load(pyproject_file)
project = pyproject['project']
base_dependencies = project['dependencies']
assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies)
assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3']
@@ -0,0 +1,34 @@
from __future__ import annotations
import importlib
from unittest.mock import MagicMock
import pytest
from tests.utils.import_isolation import isolated_sys_modules
_INSTALL_HINT = "Install LangBot with the 'seekdb' extra"
def test_seekdb_vector_backend_reports_missing_optional_extra() -> None:
module_name = 'langbot.pkg.vector.vdbs.seekdb'
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
seekdb_module = importlib.import_module(module_name)
assert seekdb_module.SEEKDB_AVAILABLE is False
with pytest.raises(ImportError, match=_INSTALL_HINT):
seekdb_module.SeekDBVectorDatabase(MagicMock())
@pytest.mark.asyncio
async def test_seekdb_embedding_reports_missing_optional_extra() -> None:
module_name = 'langbot.pkg.provider.modelmgr.requesters.seekdbembed'
with isolated_sys_modules({'pyseekdb': None}, clear=[module_name]):
seekdb_embedding_module = importlib.import_module(module_name)
requester = seekdb_embedding_module.SeekDBEmbedding.__new__(seekdb_embedding_module.SeekDBEmbedding)
with pytest.raises(ImportError, match=_INSTALL_HINT):
await requester.initialize()
@@ -88,14 +88,15 @@ async def test_environment_mapping_enables_provider_without_leaking_secret(monke
assert service.capability() == {'enabled': True, 'provider': 'smtp'}
async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry_copy():
async def test_invitation_email_has_generic_langbot_brand_plain_fallback_and_expiry_copy():
service = InvitationDeliveryService(_app({}))
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret&next=<unsafe>'
text = service._plain_text('Research & Development', link)
html = service._html('Research & Development', link)
assert 'LangBot Cloud' in text
assert 'LangBot' in text
assert 'LangBot Cloud' not in text
assert 'Research & Development' in text
assert '7 days' in text
assert link in text
@@ -103,3 +104,55 @@ async def test_cloud_invitation_email_has_branded_html_plain_fallback_and_expiry
assert 'Research &amp; Development' in html
assert 'expires in 7 days' in html
assert 'lbi_secret&amp;next=&lt;unsafe&gt;' in html
assert 'LangBot Cloud' not in html
async def test_invitation_email_uses_quiet_brand_lockup_and_compact_fallback_link():
service = InvitationDeliveryService(_app({}))
link = 'https://cloud.langbot.app/invitations/accept#token=lbi_secret'
html = service._html("RockChinQ's Workspace", link)
assert 'https://docs.langbot.app/langbot-logo.png' in html
assert '>LangBot<' in html
assert 'Workspace invitation' in html
assert 'Open invitation link' in html
assert 'linear-gradient' not in html
assert 'box-shadow' not in html
assert 'border-top:4px solid' not in html
assert 'border:1px solid #dfe6f0' not in html
assert 'height="28"' in html
assert 'height="32"' in html
assert 'margin-top:32px' not in html
assert f'>{link}<' not in html
async def test_oss_smtp_configuration_delivers_the_generic_invitation_email():
service = InvitationDeliveryService(
_app(
{
'workspace': {
'invitations': {
'email': {
'provider': 'smtp',
'from': 'LangBot <noreply@example.com>',
'smtp': {'host': 'smtp.example.com'},
}
}
}
}
)
)
service._send_smtp = AsyncMock(return_value=True)
link = 'https://self-hosted.example/invitations/accept#token=lbi_secret'
result = await service.deliver_invitation(
recipient_email='member@example.com',
workspace_name='Self-hosted Workspace',
invitation_link=link,
)
assert result == InvitationDeliveryResult(status='sent', provider='smtp')
service._send_smtp.assert_awaited_once()
assert 'LangBot Cloud' not in service._plain_text('Self-hosted Workspace', link)
assert 'LangBot Cloud' not in service._html('Self-hosted Workspace', link)
Generated
+9 -4
View File
@@ -9,10 +9,10 @@ resolution-markers = [
"python_full_version == '3.13.*' and sys_platform == 'emscripten'",
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'win32'",
"python_full_version < '3.12' and sys_platform == 'win32'",
"python_full_version == '3.12.*' and sys_platform == 'emscripten'",
"python_full_version < '3.12' and sys_platform == 'emscripten'",
"python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version < '3.12' and sys_platform == 'win32'",
"python_full_version < '3.12' and sys_platform == 'emscripten'",
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
@@ -2063,7 +2063,6 @@ dependencies = [
{ name = "pymilvus" },
{ name = "pynacl" },
{ name = "pypdf2" },
{ name = "pyseekdb" },
{ name = "python-docx" },
{ name = "python-multipart" },
{ name = "python-socks" },
@@ -2089,6 +2088,11 @@ dependencies = [
{ name = "websockets" },
]
[package.optional-dependencies]
seekdb = [
{ name = "pyseekdb" },
]
[package.dev-dependencies]
dev = [
{ name = "moto" },
@@ -2153,7 +2157,7 @@ requires-dist = [
{ name = "pymilvus", specifier = ">=2.6.4" },
{ name = "pynacl", specifier = ">=1.5.0" },
{ name = "pypdf2", specifier = ">=3.0.1" },
{ name = "pyseekdb", specifier = "==1.1.0.post3" },
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
{ name = "python-docx", specifier = ">=1.1.0" },
{ name = "python-multipart", specifier = ">=0.0.27" },
{ name = "python-socks", specifier = ">=2.7.1" },
@@ -2178,6 +2182,7 @@ requires-dist = [
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
{ name = "websockets", specifier = ">=15.0.1" },
]
provides-extras = ["seekdb"]
[package.metadata.requires-dev]
dev = [
+25 -2
View File
@@ -710,11 +710,32 @@ export class BackendClient extends BaseHttpClient {
);
}
private async getAuthenticatedObjectURL(path: string): Promise<string> {
private async getAuthenticatedObjectURL(
path: string,
rewritePluginPageSdk = false,
): Promise<string> {
const response = await this.instance.get<Blob>(path, {
responseType: 'blob',
});
return URL.createObjectURL(response.data);
let blob = response.data;
if (rewritePluginPageSdk && blob.type.startsWith('text/html')) {
const apiBase =
this.instance.defaults.baseURL === '/'
? window.location.origin
: this.instance.defaults.baseURL?.replace(/\/$/, '');
const pageSdkUrl = `${apiBase}/api/v1/plugins/_sdk/page-sdk.js`;
const html = await blob.text();
blob = new Blob(
[
html.replace(
/(<script\b[^>]*\bsrc\s*=\s*)(["'])\/api\/v1\/plugins\/_sdk\/page-sdk\.js\2/gi,
`$1$2${pageSdkUrl}$2`,
),
],
{ type: blob.type },
);
}
return URL.createObjectURL(blob);
}
public getAuthenticatedPluginAssetURL(
@@ -724,6 +745,7 @@ export class BackendClient extends BaseHttpClient {
): Promise<string> {
return this.getAuthenticatedObjectURL(
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
true,
);
}
@@ -1181,6 +1203,7 @@ export class BackendClient extends BaseHttpClient {
public getAccountInfo(): Promise<{
initialized: boolean;
authenticated_invitation_acceptance_enabled?: boolean;
invitation_registration_enabled?: boolean;
password_login_enabled?: boolean;
space_login_enabled?: boolean;
}> {
+28 -22
View File
@@ -91,7 +91,9 @@ export default function AcceptInvitationPage() {
const [errorMessage, setErrorMessage] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
const [invitationRegistrationEnabled, setInvitationRegistrationEnabled] =
useState(false);
const [invitationCapabilitiesLoaded, setInvitationCapabilitiesLoaded] =
useState(false);
const [
authenticatedInvitationAcceptanceEnabled,
@@ -116,12 +118,16 @@ export default function AcceptInvitationPage() {
backendClient
.getAccountInfo()
.then((info) => {
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
setInvitationRegistrationEnabled(
info.invitation_registration_enabled ??
info.password_login_enabled !== false,
);
setAuthenticatedInvitationAcceptanceEnabled(
info.authenticated_invitation_acceptance_enabled === true,
);
})
.catch(() => setPasswordRegistrationEnabled(false));
.catch(() => setInvitationRegistrationEnabled(false))
.finally(() => setInvitationCapabilitiesLoaded(true));
if (!invitationToken) {
setErrorMessage(t('workspace.invitationMissing'));
setStatus('error');
@@ -311,7 +317,11 @@ export default function AcceptInvitationPage() {
</div>
)}
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
{!invitationCapabilitiesLoaded ? (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin" />
</div>
) : hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
<Button
className="w-full"
disabled={status === 'submitting'}
@@ -331,7 +341,7 @@ export default function AcceptInvitationPage() {
{t('workspace.logoutAndReturn')}
</Button>
</div>
) : passwordRegistrationEnabled ? (
) : invitationRegistrationEnabled ? (
<>
<div className="space-y-2">
<label
@@ -376,15 +386,19 @@ export default function AcceptInvitationPage() {
>
{t('workspace.confirmPassword')}
</label>
<Input
id="invite-password-confirm"
type="password"
value={confirmPassword}
onChange={(event) =>
setConfirmPassword(event.target.value)
}
autoComplete="new-password"
/>
<div className="relative">
<Lock className="absolute left-3 top-3 size-4 text-muted-foreground" />
<Input
id="invite-password-confirm"
type="password"
value={confirmPassword}
onChange={(event) =>
setConfirmPassword(event.target.value)
}
className="pl-10"
autoComplete="new-password"
/>
</div>
</div>
<Button
className="w-full"
@@ -396,14 +410,6 @@ export default function AcceptInvitationPage() {
)}
{t('workspace.registerAndAccept')}
</Button>
<Button
variant="ghost"
className="w-full"
disabled={status === 'submitting'}
onClick={() => navigate('/login?invitation=1')}
>
{t('workspace.alreadyHaveAccount')}
</Button>
</>
) : (
<Button
+2
View File
@@ -503,6 +503,8 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
if (path === '/api/v1/user/account-info') {
return fulfillJson(route, {
initialized: true,
authenticated_invitation_acceptance_enabled: false,
invitation_registration_enabled: true,
password_login_enabled: true,
space_login_enabled: false,
});
+107 -1
View File
@@ -93,7 +93,7 @@ test('login preserves an explicit invitation email mismatch error', async ({
});
await page.goto('/invitations/accept#token=mismatch-invitation');
await page.getByRole('button', { name: 'I already have an account' }).click();
await page.goto('/login?invitation=1');
await page.getByPlaceholder('Enter email address').fill('other@example.com');
await page.getByPlaceholder('Enter password').fill('password');
await page.getByRole('button', { name: 'Login with password' }).click();
@@ -107,6 +107,111 @@ test('login preserves an explicit invitation email mismatch error', async ({
await expect(page.getByText('Login successful')).toHaveCount(0);
});
test('an OAuth-only OSS instance registers the invited email with a local password', async ({
page,
}) => {
let registration: { email?: string; password?: string } | undefined;
await installLangBotApiMocks(page, { authenticated: false });
await page.route('**/api/v1/user/account-info', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 800));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
initialized: true,
authenticated_invitation_acceptance_enabled: false,
invitation_registration_enabled: true,
password_login_enabled: false,
space_login_enabled: true,
},
msg: 'ok',
}),
});
});
await page.route('**/api/v1/invitations/inspect', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
invitation: {
uuid: 'oss-local-registration',
workspace_uuid: 'workspace-playwright',
normalized_email: 'invited@example.com',
role: 'viewer',
status: 'pending',
},
workspace: {
uuid: 'workspace-playwright',
name: 'Playwright Workspace',
},
},
msg: 'ok',
}),
});
});
await page.route('**/api/v1/invitations/accept', async (route) => {
const body = JSON.parse(route.request().postData() || '{}') as {
registration?: { email?: string; password?: string };
};
registration = body.registration;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
login_required: true,
workspace_uuid: 'workspace-playwright',
},
msg: 'ok',
}),
});
});
await page.goto('/invitations/accept#token=oss-local-registration');
await expect(page.getByText('Playwright Workspace')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Login with LangBot Account' }),
).toHaveCount(0);
await expect(page.locator('#invite-email')).toHaveValue(
'invited@example.com',
);
await expect(page.locator('#invite-email')).toHaveAttribute('readonly', '');
await expect(page.locator('#invite-password')).toBeVisible();
await expect(page.locator('#invite-password-confirm')).toBeVisible();
for (const inputId of ['invite-password', 'invite-password-confirm']) {
const input = page.locator(`#${inputId}`);
const field = input.locator('xpath=..');
await expect(field).toHaveClass(/relative/);
await expect(field.locator('svg')).toBeVisible();
await expect(input).toHaveClass(/pl-10/);
}
await expect(
page.getByRole('button', { name: 'Create account and accept' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'I already have an account' }),
).toHaveCount(0);
await expect(
page.getByRole('button', { name: 'Login with LangBot Account' }),
).toHaveCount(0);
await page.locator('#invite-password').fill('invite-password-123');
await page.locator('#invite-password-confirm').fill('invite-password-123');
await page.getByRole('button', { name: 'Create account and accept' }).click();
await expect(page).toHaveURL(/\/login\?invitation=1$/);
expect(registration).toEqual({
email: 'invited@example.com',
password: 'invite-password-123',
});
});
test('an authenticated OSS invitation requires logout before registration', async ({
page,
}) => {
@@ -185,6 +290,7 @@ test('an authenticated Cloud Account can accept its invitation directly', async
data: {
initialized: true,
authenticated_invitation_acceptance_enabled: true,
invitation_registration_enabled: false,
password_login_enabled: false,
space_login_enabled: true,
},
+63
View File
@@ -0,0 +1,63 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test('an OSS local-only owner is prompted to bind before using LangBot Models', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route('**/api/v1/user/space-credits', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
credits: null,
owner_space_bound: false,
is_workspace_owner: true,
},
msg: 'ok',
}),
}),
);
await page.route('**/api/v1/provider/providers', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
code: 0,
data: {
providers: [
{
uuid: 'langbot-models-provider',
name: 'LangBot Models',
requester: 'space-chat-completions',
base_url: '',
api_keys: [],
},
],
},
msg: 'ok',
}),
}),
);
await page.route('**/api/v1/provider/requesters**', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 0, data: { requesters: [] }, msg: 'ok' }),
}),
);
await page.route('**/api/v1/provider/models/**', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ code: 0, data: { models: [] }, msg: 'ok' }),
}),
);
await page.goto('/home?action=showModelSettings');
await expect(
page.getByRole('button', {
name: 'The Workspace owner must connect a LangBot Account for LangBot Models.',
}),
).toBeVisible();
});
+58 -4
View File
@@ -69,7 +69,55 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
await route.fulfill({
status: 200,
contentType: 'text/html',
body: '<!doctype html><html><body><h1>LangRAG Observability</h1></body></html>',
body: `<!doctype html>
<html>
<body>
<h1>LangRAG Observability</h1>
<button id="save">Save</button>
<script src="/api/v1/plugins/_sdk/page-sdk.js"></script>
<script>
document.querySelector('#save').addEventListener('click', async () => {
await window.langbot.api('/settings', { enabled: true }, 'POST');
document.body.dataset.saved = 'true';
});
</script>
</body>
</html>`,
});
},
);
let pageSdkRequests = 0;
await page.route('**/api/v1/plugins/_sdk/page-sdk.js', async (route) => {
pageSdkRequests += 1;
await route.fulfill({
status: 200,
contentType: 'application/javascript',
body: `window.langbot = {
api(endpoint, body, method) {
return new Promise((resolve) => {
const requestId = 'request-' + Date.now();
const handler = (event) => {
if (event.data?.type === 'langbot:api:response' && event.data.requestId === requestId) {
window.removeEventListener('message', handler);
resolve(event.data.data);
}
};
window.addEventListener('message', handler);
window.parent.postMessage({ type: 'langbot:api', requestId, endpoint, body, method }, '*');
});
},
};`,
});
});
let pageApiRequests = 0;
await page.route(
'**/api/v1/plugins/langbot-team/LangRAG/page-api',
async (route) => {
pageApiRequests += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: wrapped({ saved: true }),
});
},
);
@@ -78,11 +126,17 @@ test('loads a Cloud plugin page through the authenticated asset route', async ({
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
);
const pluginFrame = page.frameLocator('iframe');
await expect(
page
.frameLocator('iframe')
.getByRole('heading', { name: 'LangRAG Observability' }),
pluginFrame.getByRole('heading', { name: 'LangRAG Observability' }),
).toBeVisible();
await pluginFrame.getByRole('button', { name: 'Save' }).click();
await expect(pluginFrame.locator('body')).toHaveAttribute(
'data-saved',
'true',
);
expect(authenticatedAssetRequests).toBeGreaterThan(0);
expect(pageSdkRequests).toBe(1);
expect(pageApiRequests).toBe(1);
await expect(page.getByText('Loading...')).toHaveCount(0);
});