mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-25 19:47:18 +00:00
Compare commits
34 Commits
v4.10.7
...
deploy/prod
| Author | SHA1 | Date | |
|---|---|---|---|
| 70ad04ce25 | |||
| f0ee57c1e0 | |||
| 0bab5ddf4c | |||
| a45e27e76e | |||
| b791caeb98 | |||
| 536fcdf29f | |||
| dbea5150af | |||
| c87548c0b9 | |||
| 0c06c05938 | |||
| 79634772da | |||
| 7e0a72b104 | |||
| bb366779af | |||
| 1336f47cb4 | |||
| 962366c507 | |||
| 23875b240f | |||
| e699358a5a | |||
| 14277d129c | |||
| 6bf1546df2 | |||
| 0bec72a3f9 | |||
| f36542135a | |||
| 693c59b726 | |||
| c3fe312a43 | |||
| c4bad508d2 | |||
| 3d4a726cd8 | |||
| e934f08adf | |||
| 7803d56254 | |||
| 54c96a18e1 | |||
| 579e3556e4 | |||
| a08a177a11 | |||
| c224f61c8c | |||
| b62cc9da45 | |||
| 700104c015 | |||
| 717bd4b8bf | |||
| 0cc0e1b02d |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.7"
|
||||
version = "4.10.8"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -70,8 +70,7 @@ 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.3",
|
||||
"langbot-plugin==0.5.5",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,6 +46,14 @@ CMD_RESPOND_MSG = 'aibot_respond_msg'
|
||||
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
||||
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
|
||||
CMD_SEND_MSG = 'aibot_send_msg'
|
||||
# Media upload protocol (3 steps: init -> chunk * N -> finish). The
|
||||
# command names below match the WeCom AI Bot long-connection protocol.
|
||||
CMD_UPLOAD_INIT = 'aibot_upload_media_init'
|
||||
CMD_UPLOAD_CHUNK = 'aibot_upload_media_chunk'
|
||||
CMD_UPLOAD_FINISH = 'aibot_upload_media_finish'
|
||||
|
||||
# Default upload chunk size: 512 KB before base64 encoding.
|
||||
_UPLOAD_CHUNK_SIZE = 512 * 1024
|
||||
|
||||
_DEDUP_CACHE_MAX = 4096
|
||||
_STREAM_CACHE_MAX = 1024
|
||||
@@ -495,6 +503,145 @@ class WecomBotWsClient:
|
||||
body['chatid'] = chat_id
|
||||
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Media upload (image / voice / file)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def upload_media(
|
||||
self,
|
||||
data: bytes,
|
||||
filename: str = 'attachment',
|
||||
media_type: str = 'file',
|
||||
) -> Optional[dict]:
|
||||
"""Upload *data* to the WeCom AI Bot CDN and return the parsed ACK.
|
||||
|
||||
Implements the three-step protocol documented for the WeCom
|
||||
AI Bot:
|
||||
|
||||
1. ``aibot_upload_media_init`` — declare media type, file name,
|
||||
size, MD5 and chunk count; receive ``upload_id``.
|
||||
2. ``aibot_upload_media_chunk`` — send each chunk (base64-encoded
|
||||
bytes) until done; receive per-chunk ACK.
|
||||
3. ``aibot_upload_media_finish`` — finalize the upload; receive
|
||||
``media_id``.
|
||||
|
||||
Returns a dict with the final ``media_id`` (and the raw
|
||||
``finish`` ACK) on success, or ``None`` on any failure. The
|
||||
caller is expected to ignore the result and continue
|
||||
gracefully — the framework will keep working without media
|
||||
delivery.
|
||||
"""
|
||||
import base64 as _b64
|
||||
import hashlib as _hl
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
file_size = len(data)
|
||||
file_md5 = _hl.md5(data).hexdigest()
|
||||
total_chunks = (file_size + _UPLOAD_CHUNK_SIZE - 1) // _UPLOAD_CHUNK_SIZE
|
||||
if total_chunks == 0:
|
||||
total_chunks = 1
|
||||
|
||||
# Step 1: init.
|
||||
init_req_id = _generate_req_id(CMD_UPLOAD_INIT)
|
||||
init_body = {
|
||||
'type': media_type,
|
||||
'filename': filename,
|
||||
'total_size': file_size,
|
||||
'total_chunks': total_chunks,
|
||||
'md5': file_md5,
|
||||
}
|
||||
init_ack = await self._send_reply(
|
||||
init_req_id,
|
||||
init_body,
|
||||
cmd=CMD_UPLOAD_INIT,
|
||||
)
|
||||
if not init_ack or init_ack.get('errcode', 0) != 0:
|
||||
await self.logger.warning(f'upload_media init failed: ack={init_ack!r}')
|
||||
return None
|
||||
upload_id = (
|
||||
init_ack.get('upload_id')
|
||||
or init_ack.get('body', {}).get('upload_id')
|
||||
or init_ack.get('data', {}).get('upload_id')
|
||||
)
|
||||
if not upload_id:
|
||||
await self.logger.warning(f'upload_media init returned no upload_id: ack={init_ack!r}')
|
||||
return None
|
||||
|
||||
# Step 2: chunks.
|
||||
for index in range(total_chunks):
|
||||
start = index * _UPLOAD_CHUNK_SIZE
|
||||
end = min(start + _UPLOAD_CHUNK_SIZE, file_size)
|
||||
chunk_bytes = data[start:end]
|
||||
chunk_req_id = _generate_req_id(CMD_UPLOAD_CHUNK)
|
||||
chunk_body = {
|
||||
'upload_id': upload_id,
|
||||
'chunk_index': index,
|
||||
'base64_data': _b64.b64encode(chunk_bytes).decode('ascii'),
|
||||
}
|
||||
chunk_ack = await self._send_reply(
|
||||
chunk_req_id,
|
||||
chunk_body,
|
||||
cmd=CMD_UPLOAD_CHUNK,
|
||||
)
|
||||
if not chunk_ack or chunk_ack.get('errcode', 0) != 0:
|
||||
await self.logger.warning(f'upload_media chunk {index} failed: ack={chunk_ack!r}')
|
||||
return None
|
||||
|
||||
# Step 3: finish.
|
||||
finish_req_id = _generate_req_id(CMD_UPLOAD_FINISH)
|
||||
finish_body = {'upload_id': upload_id}
|
||||
finish_ack = await self._send_reply(
|
||||
finish_req_id,
|
||||
finish_body,
|
||||
cmd=CMD_UPLOAD_FINISH,
|
||||
)
|
||||
if not finish_ack or finish_ack.get('errcode', 0) != 0:
|
||||
await self.logger.warning(f'upload_media finish failed: ack={finish_ack!r}')
|
||||
return None
|
||||
|
||||
media_id = (
|
||||
finish_ack.get('media_id')
|
||||
or finish_ack.get('body', {}).get('media_id')
|
||||
or finish_ack.get('data', {}).get('media_id')
|
||||
)
|
||||
if not media_id:
|
||||
await self.logger.warning(f'upload_media finish returned no media_id: ack={finish_ack!r}')
|
||||
return None
|
||||
return {'media_id': media_id, 'ack': finish_ack}
|
||||
|
||||
async def _reply_media(
|
||||
self,
|
||||
req_id: str,
|
||||
media_id: str,
|
||||
kind: str,
|
||||
) -> Optional[dict]:
|
||||
"""Send a media reply (image / voice / file) referencing *media_id*.
|
||||
|
||||
``kind`` is one of ``'image'``, ``'voice'``, ``'file'``. Uses
|
||||
the standard ``aibot_respond_msg`` command with a per-kind
|
||||
body key (matches the convention documented for the WeCom
|
||||
AI Bot SDK).
|
||||
"""
|
||||
if kind not in {'image', 'voice', 'file'}:
|
||||
await self.logger.warning(f'_reply_media called with unknown kind={kind!r}')
|
||||
return None
|
||||
body = {
|
||||
'msgtype': kind,
|
||||
kind: {'media_id': media_id},
|
||||
}
|
||||
return await self._send_reply(req_id, body, cmd=CMD_RESPOND_MSG)
|
||||
|
||||
async def reply_image(self, req_id: str, media_id: str) -> Optional[dict]:
|
||||
return await self._reply_media(req_id, media_id, 'image')
|
||||
|
||||
async def reply_file(self, req_id: str, media_id: str) -> Optional[dict]:
|
||||
return await self._reply_media(req_id, media_id, 'file')
|
||||
|
||||
async def reply_voice(self, req_id: str, media_id: str) -> Optional[dict]:
|
||||
return await self._reply_media(req_id, media_id, 'voice')
|
||||
|
||||
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
||||
"""Push a streaming chunk for a given message ID.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1210,8 +1210,9 @@ class BoxService:
|
||||
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Fallback: read the outbox over the exec channel (E2B / remote).
|
||||
|
||||
Note: exec stdout is truncated by ``output_limit_chars``, so this path
|
||||
only reliably transfers small files. The host path is preferred.
|
||||
Uses ``client.execute`` directly (bypassing ``_serialize_result``)
|
||||
so stdout is NOT truncated by ``output_limit_chars`` - the raw
|
||||
base64 payload can be far larger than the 4000-char display limit.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
@@ -1265,14 +1266,22 @@ class BoxService:
|
||||
' break\n'
|
||||
'print(json.dumps(out))\n'
|
||||
)
|
||||
result = await self.execute_tool(
|
||||
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
|
||||
query,
|
||||
)
|
||||
if not result.get('ok'):
|
||||
spec_payload: dict = {
|
||||
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
|
||||
'timeout_sec': 120,
|
||||
'session_id': self.resolve_box_session_id(query),
|
||||
}
|
||||
if 'extra_mounts' not in spec_payload:
|
||||
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
||||
try:
|
||||
spec = self.build_spec(spec_payload)
|
||||
result = await self.client.execute(spec)
|
||||
except Exception:
|
||||
return []
|
||||
if not result.ok:
|
||||
return []
|
||||
try:
|
||||
return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1])
|
||||
return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import typing
|
||||
import inspect
|
||||
|
||||
from ..api.http.context import ExecutionContext
|
||||
from ..core import app
|
||||
from . import operator
|
||||
from ..utils import importutil
|
||||
@@ -66,7 +67,14 @@ class CommandManager:
|
||||
|
||||
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
|
||||
if require_context is not None:
|
||||
result = require_context(context)
|
||||
result = require_context(
|
||||
ExecutionContext(
|
||||
instance_uuid=context.instance_uuid,
|
||||
workspace_uuid=context.workspace_uuid,
|
||||
placement_generation=context.placement_generation,
|
||||
query_uuid=context.query_uuid,
|
||||
)
|
||||
)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -177,7 +177,6 @@ class PersistenceManager:
|
||||
await self._validate_cloud_runtime()
|
||||
return
|
||||
|
||||
self._enable_sqlite_foreign_keys()
|
||||
if self.mode == PersistenceMode.RELEASE_MIGRATION:
|
||||
async with self._release_migration_lock():
|
||||
await self._initialize_managed_schema()
|
||||
@@ -185,6 +184,7 @@ class PersistenceManager:
|
||||
return
|
||||
|
||||
await self._initialize_managed_schema()
|
||||
await self._enable_sqlite_foreign_keys_after_migration()
|
||||
|
||||
if self.mode == PersistenceMode.OSS_COMPAT:
|
||||
await self.write_space_model_providers()
|
||||
@@ -373,6 +373,17 @@ class PersistenceManager:
|
||||
sqlalchemy.event.listen(self.get_db_engine().sync_engine, 'begin', set_oss_tenant_scope)
|
||||
self._oss_tenant_scope_listener_installed = True
|
||||
|
||||
async def _enable_sqlite_foreign_keys_after_migration(self) -> None:
|
||||
"""Enable SQLite FK enforcement only after table-rebuilding migrations."""
|
||||
engine = self.get_db_engine()
|
||||
if engine.dialect.name != 'sqlite':
|
||||
return
|
||||
await engine.dispose()
|
||||
self._enable_sqlite_foreign_keys()
|
||||
# Dispose again so every runtime connection is opened through the new
|
||||
# listener instead of reusing a pre-migration pooled connection.
|
||||
await engine.dispose()
|
||||
|
||||
def _enable_sqlite_foreign_keys(self) -> None:
|
||||
"""Enable SQLite FK enforcement for every pooled runtime connection."""
|
||||
engine = self.get_db_engine()
|
||||
|
||||
@@ -12,6 +12,7 @@ import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
import typing
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
@@ -117,8 +118,19 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _fsync_file(path: pathlib.Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
|
||||
"""Sync a file, tolerating delayed visibility after replace on bind mounts."""
|
||||
|
||||
descriptor: int | None = None
|
||||
for attempt in range(reopen_attempts):
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
if attempt + 1 >= reopen_attempts:
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
assert descriptor is not None
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
|
||||
@@ -158,6 +158,18 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
result_type=entities.ResultType.CONTINUE,
|
||||
new_query=query,
|
||||
)
|
||||
elif (
|
||||
isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
|
||||
):
|
||||
# Final streaming chunk with no text content but
|
||||
# possibly carrying sandbox outbox attachments.
|
||||
reply_chain = platform_message.MessageChain([])
|
||||
await self._append_outbound_attachments(query, reply_chain)
|
||||
query.resp_message_chain.append(reply_chain)
|
||||
yield entities.StageProcessResult(
|
||||
result_type=entities.ResultType.CONTINUE,
|
||||
new_query=query,
|
||||
)
|
||||
|
||||
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
||||
function_names = [tc.function.name for tc in result.tool_calls]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,8 +3,10 @@ import typing
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
import base64
|
||||
|
||||
import datetime
|
||||
|
||||
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
|
||||
@@ -24,11 +26,24 @@ from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
||||
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain):
|
||||
content = ''
|
||||
"""Convert a MessageChain into a list of component dicts.
|
||||
|
||||
Each dict has a ``type`` key (``'text'``, ``'image'``,
|
||||
``'voice'``, ``'file'``). Text items carry ``text``; media
|
||||
items carry ``base64`` (may include a ``data:...;base64,``
|
||||
prefix) and optionally ``name``.
|
||||
"""
|
||||
items: list[dict] = []
|
||||
for msg in message_chain:
|
||||
if type(msg) is platform_message.Plain:
|
||||
content += msg.text
|
||||
return content
|
||||
items.append({'type': 'text', 'text': msg.text})
|
||||
elif type(msg) is platform_message.Image:
|
||||
items.append({'type': 'image', 'base64': msg.base64 or ''})
|
||||
elif type(msg) is platform_message.Voice:
|
||||
items.append({'type': 'voice', 'base64': msg.base64 or ''})
|
||||
elif type(msg) is platform_message.File:
|
||||
items.append({'type': 'file', 'base64': msg.base64 or '', 'name': msg.name or ''})
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
|
||||
@@ -362,13 +377,76 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _join_text_components(items: list[dict]) -> str:
|
||||
"""Concatenate ``text`` items in order, leaving media items alone."""
|
||||
return ''.join(item['text'] for item in items if item.get('type') == 'text')
|
||||
|
||||
@staticmethod
|
||||
def _iter_media_components(items: list[dict]):
|
||||
"""Yield non-text items in order."""
|
||||
for item in items:
|
||||
if item.get('type') in {'image', 'voice', 'file'}:
|
||||
yield item
|
||||
|
||||
@staticmethod
|
||||
async def _send_media(
|
||||
bot,
|
||||
req_id: str,
|
||||
item: dict,
|
||||
) -> bool:
|
||||
"""Upload *item* to the WeCom AI Bot CDN and send it as a media reply.
|
||||
|
||||
Returns True on success. Falls back to a no-op (with a warning log)
|
||||
if the SDK does not yet implement ``upload_media`` /
|
||||
``reply_image`` / ``reply_file`` / ``reply_voice`` — the framework
|
||||
will keep working, just without image delivery.
|
||||
"""
|
||||
kind = item.get('type')
|
||||
upload = getattr(bot, 'upload_media', None)
|
||||
if upload is None:
|
||||
return False
|
||||
b64_text = item.get('base64') or ''
|
||||
if not b64_text:
|
||||
return False
|
||||
if b64_text.startswith('data:') and ',' in b64_text:
|
||||
b64_text = b64_text.split(',', 1)[1]
|
||||
try:
|
||||
data = base64.b64decode(b64_text, validate=False)
|
||||
except Exception:
|
||||
return False
|
||||
if not data:
|
||||
return False
|
||||
try:
|
||||
upload_result = await upload(data, item.get('name') or f'attachment.{kind}', media_type=kind)
|
||||
except Exception:
|
||||
return False
|
||||
media_id = getattr(upload_result, 'media_id', None) or (
|
||||
isinstance(upload_result, dict) and upload_result.get('media_id')
|
||||
)
|
||||
if not media_id:
|
||||
return False
|
||||
reply_fn = {
|
||||
'image': getattr(bot, 'reply_image', None),
|
||||
'file': getattr(bot, 'reply_file', None),
|
||||
'voice': getattr(bot, 'reply_voice', None),
|
||||
}.get(kind)
|
||||
if reply_fn is None:
|
||||
return False
|
||||
try:
|
||||
await reply_fn(req_id, media_id)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
content = await self.message_converter.yiri2target(message)
|
||||
items = await self.message_converter.yiri2target(message)
|
||||
text = self._join_text_components(items)
|
||||
_ws_mode = not self.config.get('enable-webhook', False)
|
||||
|
||||
event = message_source.source_platform_object
|
||||
@@ -382,7 +460,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
else:
|
||||
chat_id = str(message_source.sender.id)
|
||||
try:
|
||||
await self.bot.send_message(chat_id, content)
|
||||
await self.bot.send_message(chat_id, text)
|
||||
except Exception:
|
||||
await self.logger.error(
|
||||
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
|
||||
@@ -396,12 +474,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
if _ws_mode:
|
||||
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
||||
if req_id:
|
||||
await self.bot.reply_text(req_id, content)
|
||||
else:
|
||||
await self.bot.set_message(event.message_id, content)
|
||||
if text:
|
||||
if req_id:
|
||||
await self.bot.reply_text(req_id, text)
|
||||
else:
|
||||
await self.bot.set_message(event.message_id, text)
|
||||
for item in self._iter_media_components(items):
|
||||
await self._send_media(self.bot, req_id, item)
|
||||
else:
|
||||
await self.bot.set_message(event.message_id, content)
|
||||
await self.bot.set_message(event.message_id, text)
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
@@ -411,7 +492,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
content = await self.message_converter.yiri2target(message)
|
||||
items = await self.message_converter.yiri2target(message)
|
||||
text = self._join_text_components(items)
|
||||
_ws_mode = not self.config.get('enable-webhook', False)
|
||||
|
||||
# Synthetic events (e.g. button-click triggered form resume) have
|
||||
@@ -420,7 +502,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
# of the stream/reply path.
|
||||
spo = message_source.source_platform_object
|
||||
if spo is None:
|
||||
return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode)
|
||||
return await self._handle_synthetic_chunk(message_source, bot_message, text, is_final, _ws_mode)
|
||||
|
||||
msg_id = spo.message_id
|
||||
|
||||
@@ -452,7 +534,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
form_data.get('actions', []) or [],
|
||||
)
|
||||
except Exception:
|
||||
fallback = content or '(人工输入)'
|
||||
fallback = text or '(人工输入)'
|
||||
if _ws_mode:
|
||||
event = message_source.source_platform_object
|
||||
req_id = event.get('req_id', '') if isinstance(event, dict) else getattr(event, 'req_id', '')
|
||||
@@ -463,17 +545,22 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
return {'stream': False, 'form': True, 'fallback': True}
|
||||
|
||||
if _ws_mode:
|
||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
|
||||
if not success and is_final:
|
||||
event = message_source.source_platform_object
|
||||
req_id = event.get('req_id', '')
|
||||
if req_id:
|
||||
await self.bot.reply_text(req_id, content)
|
||||
await self.bot.reply_text(req_id, text)
|
||||
if is_final:
|
||||
event = message_source.source_platform_object
|
||||
req_id = event.get('req_id', '')
|
||||
for item in self._iter_media_components(items):
|
||||
await self._send_media(self.bot, req_id, item)
|
||||
return {'stream': success}
|
||||
else:
|
||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
|
||||
if not success and is_final:
|
||||
await self.bot.set_message(msg_id, content)
|
||||
await self.bot.set_message(msg_id, text)
|
||||
return {'stream': success}
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
@@ -627,8 +714,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
async def send_message(self, target_type, target_id, message):
|
||||
_ws_mode = not self.config.get('enable-webhook', False)
|
||||
if _ws_mode:
|
||||
content = await self.message_converter.yiri2target(message)
|
||||
await self.bot.send_message(target_id, content)
|
||||
items = await self.message_converter.yiri2target(message)
|
||||
text = self._join_text_components(items)
|
||||
await self.bot.send_message(target_id, text)
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
@@ -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()):
|
||||
|
||||
@@ -11,6 +11,8 @@ import traceback
|
||||
from dataclasses import dataclass
|
||||
|
||||
import sqlalchemy
|
||||
import sqlalchemy.dialects.postgresql
|
||||
import sqlalchemy.dialects.sqlite
|
||||
|
||||
from langbot_plugin.runtime.io import handler
|
||||
from langbot_plugin.runtime.io.connection import Connection
|
||||
@@ -431,6 +433,19 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
return f'{identity.plugin_author}/{identity.plugin_name}'
|
||||
raise ValueError(f'Unsupported binary storage owner_type {owner_type!r}')
|
||||
|
||||
@staticmethod
|
||||
def _legacy_binary_storage_key(
|
||||
action_context: ActionContext,
|
||||
*,
|
||||
owner_type: str,
|
||||
owner: str,
|
||||
key: str,
|
||||
) -> str:
|
||||
"""Return the pre-tenancy key shape for a row already scoped to this Workspace."""
|
||||
|
||||
legacy_owner = action_context.workspace_uuid if owner_type == 'workspace' else owner
|
||||
return f'{owner_type}:{legacy_owner}:{key}'
|
||||
|
||||
@classmethod
|
||||
def _binary_storage_key(
|
||||
cls,
|
||||
@@ -896,25 +911,82 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||
)
|
||||
storage = result.first()
|
||||
if storage is None:
|
||||
legacy_key = self._legacy_binary_storage_key(
|
||||
action_context,
|
||||
owner_type=owner_type,
|
||||
owner=owner,
|
||||
key=key,
|
||||
)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
||||
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||
)
|
||||
storage = result.first()
|
||||
if storage is not None:
|
||||
update_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
||||
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||
.values(unique_key=unique_key, value=value)
|
||||
)
|
||||
if update_result.rowcount:
|
||||
return handler.ActionResponse.success(data={})
|
||||
canonical_update = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||
.values(value=value)
|
||||
)
|
||||
if canonical_update.rowcount:
|
||||
return handler.ActionResponse.success(data={})
|
||||
storage = None
|
||||
|
||||
if result.first() is not None:
|
||||
if storage is not None:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_bstorage.BinaryStorage)
|
||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||
.values(value=value)
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_bstorage.BinaryStorage).values(
|
||||
workspace_uuid=action_context.workspace_uuid,
|
||||
unique_key=unique_key,
|
||||
key=key,
|
||||
owner_type=owner_type,
|
||||
owner=owner,
|
||||
value=value,
|
||||
)
|
||||
return handler.ActionResponse.success(data={})
|
||||
|
||||
dialect_name = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||
insert = {
|
||||
'postgresql': sqlalchemy.dialects.postgresql.insert,
|
||||
'sqlite': sqlalchemy.dialects.sqlite.insert,
|
||||
}.get(dialect_name)
|
||||
if insert is None:
|
||||
return handler.ActionResponse.error(message=f'Unsupported storage database dialect: {dialect_name}')
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
insert(persistence_bstorage.BinaryStorage)
|
||||
.values(
|
||||
workspace_uuid=action_context.workspace_uuid,
|
||||
unique_key=unique_key,
|
||||
key=key,
|
||||
owner_type=owner_type,
|
||||
owner=owner,
|
||||
value=value,
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=['workspace_uuid', 'unique_key'],
|
||||
set_={'value': value},
|
||||
)
|
||||
)
|
||||
|
||||
return handler.ActionResponse.success(
|
||||
data={},
|
||||
@@ -946,6 +1018,29 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
)
|
||||
|
||||
storage = result.first()
|
||||
if storage is None:
|
||||
legacy_key = self._legacy_binary_storage_key(
|
||||
action_context,
|
||||
owner_type=owner_type,
|
||||
owner=owner,
|
||||
key=key,
|
||||
)
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||
.where(persistence_bstorage.BinaryStorage.unique_key == legacy_key)
|
||||
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||
)
|
||||
storage = result.first()
|
||||
if storage is None:
|
||||
retry_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||
)
|
||||
storage = retry_result.first()
|
||||
if storage is None:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Storage with key {key} not found',
|
||||
@@ -981,10 +1076,19 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
message=str(e),
|
||||
)
|
||||
|
||||
legacy_key = self._legacy_binary_storage_key(
|
||||
action_context,
|
||||
owner_type=owner_type,
|
||||
owner=owner,
|
||||
key=key,
|
||||
)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_bstorage.BinaryStorage)
|
||||
.where(persistence_bstorage.BinaryStorage.workspace_uuid == action_context.workspace_uuid)
|
||||
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
|
||||
.where(persistence_bstorage.BinaryStorage.unique_key.in_((unique_key, legacy_key)))
|
||||
.where(persistence_bstorage.BinaryStorage.key == key)
|
||||
.where(persistence_bstorage.BinaryStorage.owner_type == owner_type)
|
||||
.where(persistence_bstorage.BinaryStorage.owner == owner)
|
||||
)
|
||||
|
||||
return handler.ActionResponse.success(
|
||||
@@ -1012,7 +1116,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
return handler.ActionResponse.success(
|
||||
data={
|
||||
'keys': result.scalars().all(),
|
||||
'keys': list(dict.fromkeys(result.scalars().all())),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1573,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()
|
||||
|
||||
|
||||
@@ -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;">You’re 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;">You’re 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;"> </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;"> </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 →</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>'''
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -81,6 +81,7 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
||||
sa.Column('key', sa.String(255), nullable=False),
|
||||
sa.Column('owner_type', sa.String(255), nullable=False),
|
||||
sa.Column('owner', sa.String(255), nullable=False),
|
||||
sa.Column('value', sa.LargeBinary, nullable=False),
|
||||
)
|
||||
mcp_servers = _uuid_table(
|
||||
metadata,
|
||||
@@ -210,7 +211,13 @@ async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
||||
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
|
||||
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
|
||||
await conn.execute(
|
||||
binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo')
|
||||
binary_storages.insert().values(
|
||||
unique_key='plugin:demo:key',
|
||||
key='key',
|
||||
owner_type='plugin',
|
||||
owner='demo',
|
||||
value=b'legacy-plugin-value',
|
||||
)
|
||||
)
|
||||
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
|
||||
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
|
||||
|
||||
@@ -76,6 +76,26 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
|
||||
)
|
||||
assert legacy_kb['collection_id'] == 'collection-1'
|
||||
assert legacy_kb['legacy_vector_collection'] == 1
|
||||
legacy_binary_storage = (
|
||||
(
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'SELECT workspace_uuid, unique_key, key, owner_type, owner, value '
|
||||
"FROM binary_storages WHERE owner_type = 'plugin' AND owner = 'demo'"
|
||||
)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
assert legacy_binary_storage == {
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'unique_key': 'plugin:demo:key',
|
||||
'key': 'key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'demo',
|
||||
'value': b'legacy-plugin-value',
|
||||
}
|
||||
assert (
|
||||
await conn.scalar(
|
||||
sa.text(
|
||||
@@ -209,8 +229,8 @@ async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspac
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
'INSERT INTO binary_storages '
|
||||
'(workspace_uuid, unique_key, key, owner_type, owner) '
|
||||
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')"
|
||||
'(workspace_uuid, unique_key, key, owner_type, owner, value) '
|
||||
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')"
|
||||
),
|
||||
{'workspace_uuid': second_workspace_uuid},
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
|
||||
@@ -9,7 +10,7 @@ import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.persistence import alembic_runner
|
||||
from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
|
||||
from .resource_migration_support import create_legacy_resource_schema
|
||||
@@ -105,3 +106,31 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
|
||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_backup_retries_transient_reopen_failure_after_replace(tmp_path, monkeypatch):
|
||||
database_path = tmp_path / 'legacy-bind-mount.db'
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||
real_open = os.open
|
||||
transient_failures = 0
|
||||
|
||||
def transient_open(path, flags, *args, **kwargs):
|
||||
nonlocal transient_failures
|
||||
candidate = pathlib.Path(path)
|
||||
if candidate.suffix == '.sqlite3' and candidate.parent.name == 'migration-backups' and transient_failures == 0:
|
||||
transient_failures += 1
|
||||
raise FileNotFoundError(2, 'simulated delayed bind-mount visibility', str(candidate))
|
||||
return real_open(path, flags, *args, **kwargs)
|
||||
|
||||
try:
|
||||
await create_legacy_resource_schema(engine, instance_uuid='backup-bind-mount')
|
||||
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||
monkeypatch.setattr(sqlite_migration_backup.os, 'open', transient_open)
|
||||
|
||||
await _manager(engine)._run_alembic_migrations()
|
||||
|
||||
assert transient_failures == 1
|
||||
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
|
||||
assert len(_manifest_payloads(tmp_path / 'migration-backups')) == 2
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -179,13 +179,17 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(schema.create_all)
|
||||
await conn.execute(sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id})
|
||||
await conn.execute(
|
||||
sa.text("INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"),
|
||||
sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}
|
||||
)
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO workspaces (uuid, instance_uuid, slug, source) VALUES (:uuid, :instance, 'default', 'local')"
|
||||
),
|
||||
{'uuid': old_workspace_uuid, 'instance': instance_id},
|
||||
)
|
||||
await conn.execute(
|
||||
sa.text("INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)"),
|
||||
sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'),
|
||||
{'uuid': old_workspace_uuid},
|
||||
)
|
||||
await run_alembic_stamp(engine, '0016_support_admin_sessions')
|
||||
@@ -193,8 +197,8 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
|
||||
await run_alembic_upgrade(engine, 'head')
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.execute(sa.text("SELECT uuid FROM workspaces"))).scalar_one() == canonical_uuid
|
||||
assert (await conn.execute(sa.text("SELECT workspace_uuid FROM tenant_rows"))).scalar_one() == canonical_uuid
|
||||
assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid
|
||||
assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -411,6 +415,45 @@ async def test_persistence_startup_defers_workspace_tables_until_account_upgrade
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_persistence_startup_preserves_legacy_workspace_membership_with_foreign_keys(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
database_path = tmp_path / 'startup-foreign-keys.db'
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
|
||||
try:
|
||||
await _create_legacy_schema(engine)
|
||||
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
monkeypatch.setattr(constants, 'instance_id', 'instance_migration_test')
|
||||
application = type('Application', (), {})()
|
||||
application.logger = logging.getLogger('workspace-startup-foreign-keys-test')
|
||||
application.instance_config = type(
|
||||
'InstanceConfig',
|
||||
(),
|
||||
{'data': {'database': {'use': 'sqlite', 'sqlite': {'path': str(database_path)}}}},
|
||||
)()
|
||||
manager = PersistenceManager(application)
|
||||
|
||||
await manager.initialize()
|
||||
try:
|
||||
async with manager.get_db_engine().connect() as conn:
|
||||
workspace = (
|
||||
(await conn.execute(sa.text("SELECT * FROM workspaces WHERE source = 'local'"))).mappings().one()
|
||||
)
|
||||
membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
|
||||
foreign_keys = await conn.scalar(sa.text('PRAGMA foreign_keys'))
|
||||
|
||||
assert workspace['created_by_account_uuid'] == membership['account_uuid']
|
||||
assert membership['role'] == 'owner'
|
||||
assert membership['status'] == 'active'
|
||||
assert foreign_keys == 1
|
||||
finally:
|
||||
await manager.shutdown()
|
||||
|
||||
|
||||
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
|
||||
try:
|
||||
@@ -425,7 +468,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||
assert instance_uuid
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO workspace_metadata (workspace_uuid, key, value) "
|
||||
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
|
||||
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
|
||||
),
|
||||
{'workspace_uuid': old_uuid},
|
||||
@@ -433,7 +476,7 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||
await conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
),
|
||||
{'workspace_uuid': old_uuid},
|
||||
)
|
||||
@@ -442,12 +485,16 @@ async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
|
||||
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
|
||||
async with engine.connect() as conn:
|
||||
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
|
||||
assert await conn.scalar(
|
||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
||||
) == expected_uuid
|
||||
assert await conn.scalar(
|
||||
sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'")
|
||||
) == expected_uuid
|
||||
assert (
|
||||
await conn.scalar(
|
||||
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
|
||||
)
|
||||
== expected_uuid
|
||||
)
|
||||
assert (
|
||||
await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
|
||||
== expected_uuid
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -2163,25 +2163,38 @@ class TestInboundOutboundRoundTrip:
|
||||
|
||||
calls = []
|
||||
|
||||
async def fake_execute_tool(parameters, q):
|
||||
calls.append(parameters['command'])
|
||||
if 'os.scandir' in parameters['command']:
|
||||
return {
|
||||
'ok': True,
|
||||
'stdout': '[{"name": "out.png", "b64": "QUJD"}]',
|
||||
'stderr': '',
|
||||
}
|
||||
async def fake_client_execute(spec):
|
||||
cmd = spec.cmd
|
||||
calls.append(cmd)
|
||||
if 'os.scandir' in cmd:
|
||||
return BoxExecutionResult(
|
||||
session_id='s',
|
||||
backend_name='test',
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=0,
|
||||
stdout='[{"name": "out.png", "b64": "QUJD"}]',
|
||||
duration_ms=10,
|
||||
)
|
||||
# the rm -rf cleanup call
|
||||
return {'ok': True, 'stdout': '', 'stderr': ''}
|
||||
return BoxExecutionResult(
|
||||
session_id='s',
|
||||
backend_name='test',
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=0,
|
||||
stdout='',
|
||||
duration_ms=10,
|
||||
)
|
||||
|
||||
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
||||
service.client.execute = AsyncMock(side_effect=fake_client_execute)
|
||||
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
|
||||
|
||||
attachments = await service.collect_outbound_attachments(query)
|
||||
assert len(attachments) == 1
|
||||
assert attachments[0]['type'] == 'Image'
|
||||
assert attachments[0]['name'] == 'out.png'
|
||||
# cleanup (rm -rf) must have been issued after a successful collection
|
||||
assert any('rm -rf' in c for c in calls)
|
||||
service.execute_tool.assert_awaited_once()
|
||||
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_outbound_empty_still_clears(self):
|
||||
@@ -2193,16 +2206,33 @@ class TestInboundOutboundRoundTrip:
|
||||
|
||||
calls = []
|
||||
|
||||
async def fake_execute_tool(parameters, q):
|
||||
calls.append(parameters['command'])
|
||||
if 'os.scandir' in parameters['command']:
|
||||
return {'ok': True, 'stdout': '[]', 'stderr': ''}
|
||||
return {'ok': True, 'stdout': '', 'stderr': ''}
|
||||
async def fake_client_execute(spec):
|
||||
cmd = spec.cmd
|
||||
calls.append(cmd)
|
||||
if 'os.scandir' in cmd:
|
||||
return BoxExecutionResult(
|
||||
session_id='s',
|
||||
backend_name='test',
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=0,
|
||||
stdout='[]',
|
||||
duration_ms=10,
|
||||
)
|
||||
return BoxExecutionResult(
|
||||
session_id='s',
|
||||
backend_name='test',
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=0,
|
||||
stdout='',
|
||||
duration_ms=10,
|
||||
)
|
||||
|
||||
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
|
||||
service.client.execute = AsyncMock(side_effect=fake_client_execute)
|
||||
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
|
||||
assert await service.collect_outbound_attachments(query) == []
|
||||
# cleanup (rm -rf) is issued unconditionally now
|
||||
assert any('rm -rf' in c for c in calls)
|
||||
service.execute_tool.assert_awaited_once()
|
||||
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passthrough_noop_when_unavailable(self):
|
||||
|
||||
@@ -11,6 +11,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from langbot.pkg.command import operator
|
||||
from langbot.pkg.command.cmdmgr import CommandManager
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from tests.factories import FakeApp, command_query
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
@@ -393,6 +394,32 @@ class TestCommandManagerInternalExecute:
|
||||
assert len(results) == 1
|
||||
assert results[0].text == 'plugin response'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_selects_workspace_with_trusted_context(self):
|
||||
"""Plugin command discovery receives the typed runtime scope."""
|
||||
|
||||
fake_app = FakeApp()
|
||||
mgr = CommandManager(fake_app)
|
||||
mgr.cmd_list = []
|
||||
fake_app.plugin_connector.require_workspace_context = AsyncMock()
|
||||
fake_app.plugin_connector.list_commands = AsyncMock(return_value=[])
|
||||
|
||||
ctx = self._create_context(command='help')
|
||||
ctx.instance_uuid = 'instance-a'
|
||||
ctx.workspace_uuid = 'workspace-a'
|
||||
ctx.placement_generation = 4
|
||||
ctx.query_uuid = 'query-a'
|
||||
|
||||
async for _ in mgr._execute(ctx, mgr.cmd_list):
|
||||
pass
|
||||
|
||||
selected = fake_app.plugin_connector.require_workspace_context.await_args.args[0]
|
||||
assert isinstance(selected, ExecutionContext)
|
||||
assert selected.instance_uuid == 'instance-a'
|
||||
assert selected.workspace_uuid == 'workspace-a'
|
||||
assert selected.placement_generation == 4
|
||||
assert selected.query_uuid == 'query-a'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_with_bound_plugins(self):
|
||||
"""_execute passes bound_plugins to plugin connector."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot.pkg.core.app # noqa: F401
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from langbot.libs.wecom_ai_bot_api.ws_client import _UPLOAD_CHUNK_SIZE, WecomBotWsClient
|
||||
from langbot.pkg.platform.sources.wecombot import WecomBotAdapter, WecomBotMessageConverter
|
||||
|
||||
|
||||
class Logger:
|
||||
def __init__(self):
|
||||
self.warnings = []
|
||||
self.errors = []
|
||||
|
||||
async def warning(self, message):
|
||||
self.warnings.append(message)
|
||||
|
||||
async def error(self, message):
|
||||
self.errors.append(message)
|
||||
|
||||
async def info(self, message):
|
||||
return None
|
||||
|
||||
|
||||
class UploadClient(WecomBotWsClient):
|
||||
def __init__(self):
|
||||
super().__init__(bot_id='bot', secret='secret', logger=Logger())
|
||||
self.frames = []
|
||||
|
||||
async def _send_reply(self, req_id: str, body: dict, cmd: str = 'aibot_respond_msg'):
|
||||
self.frames.append((cmd, body))
|
||||
if cmd == 'aibot_upload_media_init':
|
||||
return {'errcode': 0, 'body': {'upload_id': 'upload-1'}}
|
||||
if cmd == 'aibot_upload_media_finish':
|
||||
return {'errcode': 0, 'body': {'media_id': 'media-1'}}
|
||||
return {'errcode': 0}
|
||||
|
||||
|
||||
class Bot:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def upload_media(self, data, filename='attachment', media_type='file'):
|
||||
self.calls.append(('upload_media', media_type, filename, data))
|
||||
return {'media_id': 'media-1'}
|
||||
|
||||
async def reply_text(self, req_id, content):
|
||||
self.calls.append(('reply_text', req_id, content))
|
||||
|
||||
async def reply_image(self, req_id, media_id):
|
||||
self.calls.append(('reply_image', req_id, media_id))
|
||||
|
||||
async def send_message(self, target_id, content):
|
||||
self.calls.append(('send_message', target_id, content))
|
||||
|
||||
|
||||
def make_adapter(bot):
|
||||
return WecomBotAdapter.model_construct(
|
||||
bot=bot,
|
||||
config={'enable-webhook': False},
|
||||
logger=Logger(),
|
||||
message_converter=WecomBotMessageConverter(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_client_upload_media_uses_chunk_protocol():
|
||||
client = UploadClient()
|
||||
data = b'a' * (_UPLOAD_CHUNK_SIZE + 1)
|
||||
|
||||
upload_result = await client.upload_media(data, 'image.png', media_type='image')
|
||||
|
||||
assert upload_result['media_id'] == 'media-1'
|
||||
assert [cmd for cmd, _ in client.frames] == [
|
||||
'aibot_upload_media_init',
|
||||
'aibot_upload_media_chunk',
|
||||
'aibot_upload_media_chunk',
|
||||
'aibot_upload_media_finish',
|
||||
]
|
||||
init_body = client.frames[0][1]
|
||||
assert init_body['type'] == 'image'
|
||||
assert init_body['filename'] == 'image.png'
|
||||
assert init_body['total_size'] == len(data)
|
||||
assert init_body['total_chunks'] == 2
|
||||
assert client.frames[1][1]['chunk_index'] == 0
|
||||
assert base64.b64decode(client.frames[1][1]['base64_data']) == b'a' * _UPLOAD_CHUNK_SIZE
|
||||
assert client.frames[2][1]['chunk_index'] == 1
|
||||
assert base64.b64decode(client.frames[2][1]['base64_data']) == b'a'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_message_uploads_and_replies_image_media():
|
||||
bot = Bot()
|
||||
adapter = make_adapter(bot)
|
||||
png_data = b'\x89PNG\r\n\x1a\nimage'
|
||||
image_b64 = base64.b64encode(png_data).decode('utf-8')
|
||||
chain = platform_message.MessageChain([platform_message.Image(base64=f'data:image/png;base64,{image_b64}')])
|
||||
|
||||
items = await WecomBotMessageConverter.yiri2target(chain)
|
||||
await adapter._send_media(bot, 'req-1', items[0])
|
||||
|
||||
assert bot.calls == [
|
||||
('upload_media', 'image', 'attachment.image', png_data),
|
||||
('reply_image', 'req-1', 'media-1'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_sends_text_and_skips_proactive_image():
|
||||
bot = Bot()
|
||||
adapter = make_adapter(bot)
|
||||
jpg_data = b'\xff\xd8\xffimage'
|
||||
image_b64 = base64.b64encode(jpg_data).decode('utf-8')
|
||||
chain = platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='before'),
|
||||
platform_message.Image(base64=f'data:image/jpeg;base64,{image_b64}'),
|
||||
platform_message.Plain(text='after'),
|
||||
]
|
||||
)
|
||||
|
||||
await adapter.send_message('group', 'chat-1', chain)
|
||||
|
||||
assert bot.calls == [
|
||||
('send_message', 'chat-1', 'beforeafter'),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -234,6 +234,7 @@ class TestSetBinaryStorage:
|
||||
},
|
||||
}
|
||||
mock_app.persistence_mgr = Mock()
|
||||
mock_app.persistence_mgr.get_db_engine.return_value = SimpleNamespace(dialect=SimpleNamespace(name='sqlite'))
|
||||
mock_app.persistence_mgr.execute_async = AsyncMock(return_value=make_result())
|
||||
mock_app.logger = Mock()
|
||||
return mock_app
|
||||
@@ -270,8 +271,8 @@ class TestSetBinaryStorage:
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
|
||||
assert app.persistence_mgr.execute_async.await_count == 3
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||
assert insert_params['workspace_uuid'] == 'workspace-a'
|
||||
assert insert_params['unique_key'] == canonical_binary_key(
|
||||
'plugin',
|
||||
@@ -301,6 +302,69 @@ class TestSetBinaryStorage:
|
||||
assert expected_key in update_params.values()
|
||||
assert update_params['value'] == b'new'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adopts_legacy_storage_before_updating(self, app):
|
||||
"""A migrated pre-tenancy row is updated in place rather than duplicated."""
|
||||
runtime_handler = make_handler(app)
|
||||
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||
adopted = SimpleNamespace(rowcount=1)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
make_result(legacy_storage),
|
||||
adopted,
|
||||
]
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 3
|
||||
adoption_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||
expected_key = canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
|
||||
assert expected_key in adoption_params.values()
|
||||
assert adoption_params['value'] == b'new'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_adoption_race_updates_winning_canonical_row(self, app):
|
||||
runtime_handler = make_handler(app)
|
||||
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||
lost_race = SimpleNamespace(rowcount=0)
|
||||
canonical_winner = SimpleNamespace(rowcount=1)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
make_result(legacy_storage),
|
||||
lost_race,
|
||||
canonical_winner,
|
||||
]
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 4
|
||||
winner_update = compiled_params(app.persistence_mgr.execute_async.await_args_list[3].args[0])
|
||||
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in winner_update.values()
|
||||
assert winner_update['value'] == b'new'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_adoption_lost_to_delete_inserts_new_value(self, app):
|
||||
runtime_handler = make_handler(app)
|
||||
legacy_storage = SimpleNamespace(unique_key='plugin:test-author/test-plugin:test-key')
|
||||
lost_race = SimpleNamespace(rowcount=0)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
make_result(legacy_storage),
|
||||
lost_race,
|
||||
SimpleNamespace(rowcount=0),
|
||||
make_result(),
|
||||
]
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.SET_BINARY_STORAGE.value](self.payload(b'new'))
|
||||
|
||||
assert response.code == 0
|
||||
assert app.persistence_mgr.execute_async.await_count == 5
|
||||
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[4].args[0])
|
||||
assert insert_params['unique_key'] == canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key')
|
||||
assert insert_params['value'] == b'new'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_max_value_bytes_falls_back_to_default_limit(self, app):
|
||||
"""Invalid max_value_bytes uses the 10MB default limit."""
|
||||
@@ -525,6 +589,46 @@ class TestGetBinaryStorage:
|
||||
in statement_params.values()
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reads_legacy_storage_without_mutating_key(self, app):
|
||||
runtime_handler = make_handler(app)
|
||||
legacy_storage = SimpleNamespace(
|
||||
unique_key='plugin:test-author/test-plugin:test-key',
|
||||
value=b'legacy bytes',
|
||||
)
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
make_result(legacy_storage),
|
||||
]
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
||||
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert base64.b64decode(response.data['value_base64']) == b'legacy bytes'
|
||||
assert app.persistence_mgr.execute_async.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retries_canonical_after_concurrent_legacy_adoption(self, app):
|
||||
runtime_handler = make_handler(app)
|
||||
canonical_storage = SimpleNamespace(value=b'adopted bytes')
|
||||
app.persistence_mgr.execute_async.side_effect = [
|
||||
make_result(),
|
||||
make_result(),
|
||||
make_result(canonical_storage),
|
||||
]
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.GET_BINARY_STORAGE.value](
|
||||
{'key': 'test-key', 'owner_type': 'plugin', 'owner': 'ignored'}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
assert base64.b64decode(response.data['value_base64']) == b'adopted bytes'
|
||||
assert app.persistence_mgr.execute_async.await_count == 3
|
||||
retry_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[2].args[0])
|
||||
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in retry_params.values()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_error_when_not_found(self, app):
|
||||
"""Missing binary storage rows return an error response."""
|
||||
@@ -567,21 +671,47 @@ class TestDeleteAndListBinaryStorage:
|
||||
|
||||
assert response.code == 0
|
||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||
assert 'workspace-a' in statement_params.values()
|
||||
flat_values = [
|
||||
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
|
||||
]
|
||||
assert 'workspace-a' in flat_values
|
||||
assert (
|
||||
canonical_binary_key(
|
||||
'plugin',
|
||||
'test-author/test-plugin',
|
||||
'test-key',
|
||||
)
|
||||
in statement_params.values()
|
||||
in flat_values
|
||||
)
|
||||
assert 'forged-owner' not in statement_params.values()
|
||||
assert 'forged-owner' not in flat_values
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_canonical_and_legacy_scoped_keys(self, app):
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
response = await runtime_handler.actions[RuntimeToLangBotAction.DELETE_BINARY_STORAGE.value](
|
||||
{
|
||||
'key': 'test-key',
|
||||
'owner_type': 'plugin',
|
||||
'owner': 'forged-owner',
|
||||
}
|
||||
)
|
||||
|
||||
assert response.code == 0
|
||||
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||
values = [
|
||||
item for value in statement_params.values() for item in (value if isinstance(value, list) else [value])
|
||||
]
|
||||
assert 'workspace-a' in values
|
||||
assert canonical_binary_key('plugin', 'test-author/test-plugin', 'test-key') in values
|
||||
assert 'plugin:test-author/test-plugin:test-key' in values
|
||||
assert 'test-author/test-plugin' in values
|
||||
assert 'forged-owner' not in values
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_keys_uses_trusted_plugin_owner(self, app):
|
||||
result = Mock()
|
||||
result.scalars.return_value.all.return_value = ['first', 'second']
|
||||
result.scalars.return_value.all.return_value = ['first', 'second', 'first']
|
||||
app.persistence_mgr.execute_async.return_value = result
|
||||
runtime_handler = make_handler(app)
|
||||
|
||||
|
||||
@@ -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 & Development' in html
|
||||
assert 'expires in 7 days' in html
|
||||
assert 'lbi_secret&next=<unsafe>' 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)
|
||||
|
||||
@@ -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'",
|
||||
]
|
||||
|
||||
@@ -2008,7 +2008,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.10.7"
|
||||
version = "4.10.8"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -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" },
|
||||
@@ -2125,7 +2129,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.5.3" },
|
||||
{ name = "langbot-plugin", specifier = "==0.5.5" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -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 = [
|
||||
@@ -2191,7 +2196,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.5.3"
|
||||
version = "0.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2212,9 +2217,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/1d/a54daa3bc699f5186b9970946c2ecf0e9cf219f77738934e04aaf5a0c20a/langbot_plugin-0.5.3.tar.gz", hash = "sha256:2324b1f7e1f55e3692e75c8b1e427ea497474b0150ec6ca83b49b5d77ec224c6", size = 472149, upload-time = "2026-08-13T10:17:45.529Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/be/1bbdf959d8c16b625e3721cde586b3bb22eaa22dd8c22d072c04f9b491ba/langbot_plugin-0.5.5.tar.gz", hash = "sha256:ea31b0ddf64c2ef8fdec012273b2d3dee6f0d140475f07694f31ea685be40695", size = 472639, upload-time = "2026-08-16T17:33:27.783Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/5f/ae6ed59773cc9d941fbb28b4ac07ce2a29e689d693e29ad71c40c5b39aa5/langbot_plugin-0.5.3-py3-none-any.whl", hash = "sha256:75dea1b6fb79ec6087ec3284f6698fb701d5feebf5f0318a7ae51fbd17a2f41f", size = 304559, upload-time = "2026-08-13T10:17:44.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/30/72caa601b571542fa4de5f2a3461d6f601f75c52d484d9fc95ebb82ce30c/langbot_plugin-0.5.5-py3-none-any.whl", hash = "sha256:a55d20a0c015414ef85d783b493f83d27b64f1d662887de94330df9d3d4ab64e", size = 304643, upload-time = "2026-08-16T17:33:26.687Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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;
|
||||
}> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user