Compare commits

..

6 Commits

Author SHA1 Message Date
fdc310 e211d3aae6 fix(itchat): correct group/private message handling and sender resolution
- filter the bot's own messages to prevent reply loops
- use startswith('@@') for group detection
- use ActualUserName as stable GroupMember id
- fill Friend.remark from RemarkName
- strip @mention prefix with a regex
2026-08-13 11:18:09 +08:00
fdc310 42f1f00772 Merge remote-tracking branch 'origin/master' into feature/itchat-adapter
# Conflicts:
#	src/langbot/pkg/api/http/controller/groups/platform/adapters.py
#	src/langbot/pkg/plugin/connector.py
#	uv.lock
#	web/src/app/home/bots/BotDetailContent.tsx
#	web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx
#	web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
2026-08-12 16:58:58 +08:00
fdc310 1ed107c9d5 feat(dynamic-form): enhance select handling with empty option support and UUID filtering 2026-07-06 13:36:58 +08:00
fdc310 0cce418956 feat(itchat): improve login session handling and error reporting 2026-07-02 14:40:13 +08:00
fdc310 d4e8ccd161 feat: enhance itchat adapter with runtime status tracking and UI integration 2026-07-02 13:56:24 +08:00
fdc310 78fb40a28a feat: add itchat-uos WeChat adapter with QR code login
- Add itchat-uos adapter supporting personal WeChat via QR code login
- Implement message/event converters for text, image, voice, sharing types
- Bridge sync itchat callbacks to async LangBot pipeline via asyncio
- Add QR login API endpoints with session management
- Add frontend QR code login dialog integration
- Fix plugin connector handler attribute check
- Use fresh Core instance per login to avoid singleton state pollution
2026-07-01 18:03:26 +08:00
140 changed files with 2716 additions and 6267 deletions
+13 -32
View File
@@ -7,42 +7,23 @@ 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@v4
uses: actions/checkout@v2
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate image metadata
id: image
shell: bash
- name: Generate Tag
id: generate_tag
run: |
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 }}
# 获取分支名称,把/替换为-
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
+59
View File
@@ -0,0 +1,59 @@
name: Build and deploy production
on:
push:
branches: [deploy/prod]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: langbot-production
cancel-in-progress: false
env:
CORE_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot
CLOUD_IMAGE: ${{ secrets.DOCKER_USERNAME }}/langbot-cloud-core
SPACE_REF: 58253c53933f95d81b035fbe2efedb55b6c1a82b
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build exact Core image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
${{ env.CORE_IMAGE }}:deploy-prod
cache-from: type=gha,scope=core-prod
cache-to: type=gha,mode=max,scope=core-prod
- name: Checkout production Cloud adapter
uses: actions/checkout@v4
with:
repository: langbot-app/langbot-space
ref: ${{ env.SPACE_REF }}
token: ${{ secrets.CLA_PAT }}
path: .space
- name: Build exact Cloud Core image
uses: docker/build-push-action@v6
with:
context: .space
file: .space/Dockerfile.cloud
push: true
build-args: LANGBOT_CORE_IMAGE=${{ env.CORE_IMAGE }}:prod-${{ github.sha }}
tags: |
${{ env.CLOUD_IMAGE }}:prod-${{ github.sha }}
${{ env.CLOUD_IMAGE }}:deploy-prod
cache-from: type=gha,scope=cloud-core-prod
cache-to: type=gha,mode=max,scope=cloud-core-prod
+2 -2
View File
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:22-alpine AS node
FROM node:22-alpine AS node
WORKDIR /app
@@ -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 --extra seekdb \
&& uv sync \
&& apt-get purge -y --auto-remove curl git gnupg \
&& rm -rf /var/lib/apt/lists/* \
&& touch /.dockerenv
-1
View File
@@ -83,7 +83,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### One-Click Cloud Deploy
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-1
View File
@@ -83,7 +83,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### 一键云部署
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
-1
View File
@@ -82,7 +82,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### Despliegue en la Nube con un Clic
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-1
View File
@@ -82,7 +82,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### Déploiement Cloud en un Clic
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-1
View File
@@ -82,7 +82,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### ワンクリッククラウドデプロイ
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-1
View File
@@ -82,7 +82,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### 원클릭 클라우드 배포
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-1
View File
@@ -82,7 +82,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### Облачное развертывание одним кликом
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
-1
View File
@@ -84,7 +84,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### 一鍵雲端部署
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/zh-CN/templates/ZKTBDH)
-1
View File
@@ -82,7 +82,6 @@ cd LangBot/docker
docker compose --profile all up -d
```
### Triển khai đám mây một cú nhấp
[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH)
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd /opt/langbot-cloud-prod
TAG=${1:?usage: deploy.sh prod-<40-char-sha>}
[[ "$TAG" =~ ^prod-[0-9a-f]{40}$ ]] || { echo 'invalid immutable image tag' >&2; exit 2; }
[[ -s .env ]] || { echo '/opt/langbot-cloud-prod/.env is missing' >&2; exit 3; }
rendered_compose=$(docker compose config)
grep -Fq 'LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app' <<<"$rendered_compose" || {
echo 'Cloud control-plane URL must be https://space.langbot.app' >&2
exit 4
}
grep -Fq 'SPACE__URL: https://space.langbot.app' <<<"$rendered_compose" || {
echo 'Cloud user-facing Space URL must be https://space.langbot.app' >&2
exit 5
}
grep -Eq 'LANGBOT_TELEMETRY_INGEST_TOKEN: .+' <<<"$rendered_compose" || {
echo 'Cloud telemetry ingest token must be configured' >&2
exit 6
}
update_env() {
local key=$1 value=$2
python3 - "$key" "$value" <<'PY'
from pathlib import Path
import os
import sys
path = Path('.env')
key, value = sys.argv[1:]
lines = path.read_text().splitlines()
updated = False
for index, line in enumerate(lines):
if line.startswith(f'{key}='):
lines[index] = f'{key}={value}'
updated = True
break
if not updated:
lines.append(f'{key}={value}')
temporary = Path('.env.tmp')
temporary.write_text('\n'.join(lines) + '\n')
os.chmod(temporary, 0o600)
temporary.replace(path)
PY
}
update_env LANGBOT_IMAGE_TAG "$TAG"
set -a
. ./.env
set +a
: "${CLOUD_V2_CONTROL_PLANE_TOKEN:?CLOUD_V2_CONTROL_PLANE_TOKEN is required}"
for attempt in 1 2 3 4 5; do
if docker compose pull postgres redis migrate plugin-runtime core; then
break
fi
if [ "$attempt" -eq 5 ]; then
echo "docker compose pull failed after $attempt attempts" >&2
exit 1
fi
delay=$((attempt * 10))
echo "docker compose pull failed (attempt $attempt/5); retrying in ${delay}s" >&2
sleep "$delay"
done
docker compose up -d postgres redis
for _ in $(seq 1 60); do
if docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null 2>&1; then break; fi
sleep 2
done
docker compose exec -T postgres pg_isready -U langbot_operator -d langbot >/dev/null
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U langbot_operator -d langbot \
-v runtime_password="$POSTGRES_RUNTIME_PASSWORD" <<'SQL'
SELECT format('CREATE ROLE langbot_runtime LOGIN PASSWORD %L', :'runtime_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'langbot_runtime')\gexec
ALTER ROLE langbot_runtime PASSWORD :'runtime_password';
GRANT CONNECT ON DATABASE langbot TO langbot_runtime;
REVOKE CREATE ON SCHEMA public FROM PUBLIC, langbot_runtime;
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM langbot_runtime;
REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM langbot_runtime;
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON TABLES FROM langbot_runtime;
ALTER DEFAULT PRIVILEGES FOR ROLE langbot_operator IN SCHEMA public REVOKE ALL ON SEQUENCES FROM langbot_runtime;
GRANT USAGE ON SCHEMA public TO langbot_runtime;
SQL
docker compose --profile tools run --rm migrate
docker compose up -d --remove-orphans plugin-runtime core
for _ in $(seq 1 90); do
if docker compose exec -T core python -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:5300/healthz", timeout=3)' >/dev/null 2>&1; then
docker compose ps
exit 0
fi
sleep 2
done
docker compose logs --tail=200 core plugin-runtime >&2
exit 1
+162
View File
@@ -0,0 +1,162 @@
services:
postgres:
image: pgvector/pgvector:pg17
container_name: langbot-cloud-postgres
restart: unless-stopped
environment:
POSTGRES_DB: langbot
POSTGRES_USER: langbot_operator
POSTGRES_PASSWORD: ${POSTGRES_OPERATOR_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: [CMD-SHELL, "pg_isready -U langbot_operator -d langbot"]
interval: 5s
timeout: 5s
retries: 30
networks: [internal]
redis:
image: redis:7.4-alpine
container_name: langbot-cloud-redis
restart: unless-stopped
command: [redis-server, --appendonly, "yes", --requirepass, "${REDIS_PASSWORD}"]
volumes:
- redis-data:/data
healthcheck:
test: [CMD-SHELL, "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep PONG"]
interval: 5s
timeout: 5s
retries: 20
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
networks: [internal]
migrate:
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
profiles: [tools]
command: [uv, run, langbot, migrate, --cloud]
environment: &core-env
TZ: Asia/Shanghai
SYSTEM__INSTANCE_ID: ${CLOUD_V2_INSTANCE_UUID}
SYSTEM__EDITION: cloud
SYSTEM__RECOVERY_KEY: ${SYSTEM_RECOVERY_KEY}
SYSTEM__JWT__SECRET: ${JWT_SECRET}
SYSTEM__LIMITATION__MAX_BOTS: "2"
SYSTEM__LIMITATION__MAX_PIPELINES: "3"
SYSTEM__LIMITATION__MAX_EXTENSIONS: "3"
SYSTEM__LIMITATION__MAX_KNOWLEDGE_BASES: "2"
API__WEBHOOK_PREFIX: https://cloud.langbot.app
API__WEBUI_URL: https://cloud.langbot.app
WORKSPACE__INVITATIONS__PUBLIC_WEB_URL: https://cloud.langbot.app
DATABASE__USE: postgresql
DATABASE__POSTGRESQL__URL: postgresql+asyncpg://langbot_runtime:${POSTGRES_RUNTIME_PASSWORD}@postgres:5432/langbot
DATABASE__CLOUD_MIGRATION__OPERATOR_DSN_ENV: LANGBOT_CLOUD_MIGRATION_DSN
LANGBOT_CLOUD_MIGRATION_DSN: postgresql://langbot_operator:${POSTGRES_OPERATOR_PASSWORD}@postgres:5432/langbot
VDB__USE: pgvector
VDB__PGVECTOR__USE_BUSINESS_DATABASE: "true"
VDB__PGVECTOR__ALLOWED_DIMENSIONS: "384,512,768,1024,1536"
PLUGIN__ENABLE: "true"
PLUGIN__RUNTIME_WS_URL: ws://plugin-runtime:5400/control/ws
PLUGIN__DISPLAY_PLUGIN_DEBUG_URL: wss://cloud.langbot.app/plugin/debug/ws
PLUGIN__WORKER__MAX_CPUS: "0.25"
PLUGIN__WORKER__MAX_MEMORY_MB: "256"
PLUGIN__WORKER__MAX_PIDS: "128"
PLUGIN__WORKER__MAX_WORKERS: "16"
PLUGIN__WORKER__MAX_TOTAL_CPUS: "4.0"
PLUGIN__WORKER__MAX_TOTAL_MEMORY_MB: "4096"
PLUGIN__WORKER__REQUIRE_HARD_LIMITS: "true"
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
# Cloud v2 currently grants no managed Box capability. Keep the shared
# runtime deployed but disable Core integration until a hard-quota-capable
# backend can satisfy the fail-closed Cloud readiness contract.
BOX__ENABLED: "false"
BOX__BACKEND: nsjail
BOX__RUNTIME__ENDPOINT: ws://box:5410
BOX__ADMISSION__REQUIRED: "true"
BOX__ADMISSION__LOGICAL_SESSION_ID: global
BOX__ADMISSION__REQUIRED_BACKEND: nsjail
BOX__ADMISSION__MAX_SESSIONS: "1"
BOX__ADMISSION__MAX_MANAGED_PROCESSES: "0"
BOX__ADMISSION__CPUS: "0.25"
BOX__ADMISSION__MEMORY_MB: "256"
BOX__ADMISSION__WORKSPACE_QUOTA_MB: "256"
BOX__LOCAL__HOST_ROOT: /app/data/box
BOX__LOCAL__DEFAULT_WORKSPACE: /app/data/box
BOX__LOCAL__ALLOWED_MOUNT_ROOTS: /app/data/box
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
MCP__STDIO__ENABLED: "false"
LANGBOT_SPACE_CONTROL_PLANE_URL: https://space.langbot.app
LANGBOT_SPACE_CONTROL_PLANE_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
LANGBOT_TELEMETRY_INGEST_TOKEN: ${CLOUD_V2_CONTROL_PLANE_TOKEN}
LANGBOT_SPACE_CONTROL_PLANE_PUBLIC_KEY: ${CLOUD_V2_MANIFEST_PUBLIC_KEY}
LANGBOT_SPACE_CONTROL_PLANE_KEY_ID: ${CLOUD_V2_MANIFEST_KEY_ID}
SPACE__URL: https://space.langbot.app
depends_on:
postgres: {condition: service_healthy}
networks: [internal]
plugin-runtime:
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
container_name: langbot-cloud-plugin-runtime
restart: unless-stopped
command: [uv, run, python, -m, langbot_plugin.cli.__init__, rt]
environment:
LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN: ${PLUGIN_RUNTIME_CONTROL_TOKEN}
volumes:
- plugin-data:/app/data
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroup: host
privileged: true
expose: ["5400"]
networks: [internal]
box:
image: rockchin/langbot:${LANGBOT_IMAGE_TAG}
container_name: langbot-cloud-box
restart: unless-stopped
command: [uv, run, lbp, box, --host, 0.0.0.0, --ws-control-port, "5410"]
environment:
LANGBOT_BOX_CONTROL_TOKEN: ${BOX_CONTROL_TOKEN}
LANGBOT_BOX_ROOT: /app/data/box
volumes:
- box-data:/app/data/box
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroup: host
privileged: true
expose: ["5410"]
networks: [internal]
core:
image: rockchin/langbot-cloud-core:${LANGBOT_IMAGE_TAG}
container_name: langbot-cloud-core
restart: unless-stopped
environment: *core-env
volumes:
- core-data:/app/data
- box-data:/app/data/box
depends_on:
postgres: {condition: service_healthy}
redis: {condition: service_healthy}
plugin-runtime: {condition: service_started}
box: {condition: service_started}
expose: ["5300"]
healthcheck:
test: [CMD-SHELL, "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:5300/healthz\", timeout=3)'" ]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
networks: [internal, shared-network]
networks:
internal:
shared-network:
external: true
volumes:
postgres-data:
redis-data:
plugin-data:
box-data:
core-data:
+7 -7
View File
@@ -47,10 +47,11 @@ services:
restart: on-failure
environment:
- TZ=Asia/Shanghai
# Optional shared control-plane secret used to authenticate both the RPC
# socket and managed-process relay. Leave unset on both OSS services, or
# generate one with ``openssl rand -hex 32`` and set the same value on
# both ends. Strongly recommended when the deployment is Internet-accessible.
# Shared control-plane secret used to authenticate both the RPC socket
# and managed-process relay. Generate once (for example with
# ``openssl rand -hex 32``) and export it before enabling this profile.
# An empty value is accepted by Compose so Box can remain optional, but
# the Box runtime itself fails closed when the profile is started.
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
# Box has its own process-wide blocking-work budget.
- LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS=${LANGBOT_BLOCKING_EXECUTOR_MAX_WORKERS:-8}
@@ -78,9 +79,8 @@ services:
- TZ=Asia/Shanghai
# Optional. Leave unset on both OSS services, or match plugin Runtime.
- LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN=${LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN:-}
# When set, this must match langbot_box. If both ends leave it unset,
# OSS permits the connection without token authentication. The token is
# sent only in WebSocket handshake headers, never in URLs or payloads.
# Must match the value supplied to langbot_box. The token is sent only
# in WebSocket handshake headers, never in URLs or action payloads.
- LANGBOT_BOX_CONTROL_TOKEN=${LANGBOT_BOX_CONTROL_TOKEN:-}
# Core process-wide blocking-work admission. These are native config
# overrides and are persisted with the effective data/config.yaml.
+1 -18
View File
@@ -10,19 +10,6 @@ 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:
@@ -33,10 +20,6 @@ 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:
@@ -118,7 +101,7 @@ uvx langbot
## System Requirements
- Python 3.11 or higher (lower than Python 4)
- Python 3.10.1 or higher
- Operating System: Linux, macOS, or Windows
## Differences from Source Installation
+43 -34
View File
@@ -16,20 +16,12 @@ This document describes how to use OceanBase SeekDB as the vector database backe
## Installation
SeekDB is an optional LangBot feature. A normal LangBot installation uses
Chroma by default and does not install `pyseekdb` or its native bindings.
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
Choose the command that matches how you run LangBot:
If you need to install it manually:
```bash
# PyPI / uvx
uvx --from 'langbot[seekdb]@latest' langbot
# Installed package
pip install 'langbot[seekdb]'
# Source checkout
uv sync --extra seekdb
pip install pyseekdb
```
## ⚠️ Platform Compatibility
@@ -38,36 +30,31 @@ uv sync --extra seekdb
| Platform | Status | Notes |
|----------|--------|-------|
| 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 |
| 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 |
**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.
**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.
### Server Mode (Docker)
| Platform | Status | Notes |
|----------|--------|-------|
| Linux | ✅ Supported | Full Docker support |
| 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 |
| 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
### Server Mode (Remote Connection)
| Platform | Status | Notes |
|----------|--------|-------|
| 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 |
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
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.
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
## Configuration
@@ -183,23 +170,22 @@ Key methods:
### Import Error
If you see: `SeekDB support is not installed`
If you see: `ImportError: pyseekdb is not installed`
Solution:
```bash
uv sync --extra seekdb
# or: uvx --from 'langbot[seekdb]@latest' langbot
pip install pyseekdb
```
### Embedded Mode Is Unavailable on the Current Platform
### Embedded Mode Error on macOS/Windows
**Error**:
```
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
```
**Cause**: No compatible `pylibseekdb` wheel is installed for the current OS,
CPU architecture, Python version, and macOS deployment target.
**Cause**: `pylibseekdb` is only available on Linux platforms.
**Solution**: Use server mode instead:
1. Deploy SeekDB on a Linux server or VM
@@ -222,6 +208,29 @@ 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:
+4 -7
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.10.8"
version = "4.10.7"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,6 +22,7 @@ dependencies = [
"discord-py>=2.5.2",
"pynacl>=1.5.0", # Required for Discord voice support
"gewechat-client>=0.1.5",
"itchat-uos>=1.5.0.dev",
"lark-oapi>=1.5.5",
"mcp>=1.25.0,<2.0.0",
"nakuru-project-idk>=0.0.2.1",
@@ -70,7 +71,8 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.5",
"pyseekdb==1.1.0.post3",
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@7b559da430a50f80a7d30c9d3d66f088503ddbb3",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -107,11 +109,6 @@ classifiers = [
"Topic :: Communications :: Chat",
]
[project.optional-dependencies]
seekdb = [
"pyseekdb==1.1.0.post3",
]
[project.urls]
Homepage = "https://langbot.app"
Documentation = "https://docs.langbot.app"
+1 -2
View File
@@ -1349,8 +1349,7 @@
"local-agent",
"tools",
"e2b",
"nsjail",
"host"
"nsjail"
],
"automation": "",
"setup_automation": [],
+1 -7
View File
@@ -63,7 +63,7 @@ Key settings:
| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. |
| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) |
| `box.enabled` | Master switch for the Box sandbox runtime |
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b` / explicit unsafe `host`; env override `BOX__BACKEND` |
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; env override `BOX__BACKEND` |
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed |
Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
@@ -75,10 +75,6 @@ Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
with `--standalone-runtime`.
- Box has a parallel `--standalone-box` flag; the Docker box host is
`langbot_box:5410`.
- `box.backend: host` runs commands directly as the Box Runtime system user.
It is never auto-selected, provides no sandbox isolation, and is only for
trusted local development. A WebSocket-controlled host backend requires
`LANGBOT_BOX_CONTROL_TOKEN`; local stdio control is allowed.
## Global API key — enabling for agents/automation
@@ -97,7 +93,5 @@ login session. See `langbot-mcp-ops` for using it, and `docs/API_KEY_AUTH.md`.
- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
usually means the user isn't in the `docker` group →
`sudo usermod -aG docker <user>` and restart in a new shell.
- Do not use `box.backend: host` as a production fallback. It cannot enforce
image, filesystem, network, PID, CPU, memory, or storage isolation.
- Box root host/container path mismatch breaks sandbox container creation.
- Don't commit a non-empty `api.global_api_key` to version control.
-2
View File
@@ -75,8 +75,6 @@ shape as the corresponding HTTP API request body. Discover resources with the
`list_*` / `get_*` tools before mutating; identifiers are UUIDs. Reads require
`resource.view`; mutations require `resource.manage`. All service calls inherit
the immutable Workspace context authenticated at the MCP transport boundary.
Pass `is_default: true` to `create_pipeline` only when the Workspace does not
already have a default pipeline.
## How to use
@@ -13,7 +13,6 @@ tags:
- tools
- e2b
- nsjail
- host
skills:
- langbot-env-setup
- langbot-testing
@@ -24,7 +23,7 @@ env:
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
preconditions:
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
- "LangBot is started with the Box backend intended for this run, such as e2b, nsjail, or explicit host development mode."
- "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail."
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
steps:
- "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend."
@@ -34,7 +33,7 @@ steps:
checks:
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
- "Logs: The selected backend name is the expected one, such as e2b, nsjail, or host."
- "Logs: The selected backend name is the expected one, such as e2b or nsjail."
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
- "Box status: recent_error_count is 0 after the run."
evidence_required:
@@ -4,7 +4,7 @@
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
This flow applies to Docker, nsjail, E2B, and the explicit host development backend. Host runs commands directly as the Box Runtime user and must never be treated as sandbox-isolation coverage. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
This flow applies to Docker, nsjail, and E2B backends. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
## Preconditions
@@ -13,7 +13,6 @@ This flow applies to Docker, nsjail, E2B, and the explicit host development back
- `BOX_BACKEND=e2b` when validating E2B.
- `BOX_BACKEND=nsjail` when validating nsjail.
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
- `BOX_BACKEND=host` only when validating explicit, trusted local direct execution.
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
4. Confirm Debug Chat uses a model with function-calling ability.
5. Confirm backend logs say native sandbox tools are available.
@@ -72,7 +71,7 @@ Backend logs should show:
- `register_skill`
- `activate`
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
- `backend=e2b`, `backend=nsjail`, `backend=host`, or the expected local backend
- `backend=e2b`, `backend=nsjail`, or the expected local backend
After the run, verify the skill store through the UI or API:
@@ -126,8 +125,6 @@ For E2B raw HTTP diagnostics, include a valid template id such as `base`; a miss
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
- The host backend does not honor sandbox image, network, rootfs, process, or
resource isolation. Use a disposable workspace and low-privilege account.
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
## Related Troubleshooting
@@ -3,7 +3,7 @@ title: "Native sandbox tools are unavailable even though a backend is configured
date: 2026-05-18
symptoms:
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
- "The Box runtime later reports that E2B, nsjail, Docker, or explicit host mode is configured."
- "The Box runtime later reports that E2B, nsjail, or Docker is configured."
- "Debug Chat does not expose exec, register_skill, or activate as usable tools."
patterns:
- "Native sandbox tools ... are NOT available"
@@ -19,7 +19,6 @@ fix_steps:
- "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty."
- "For E2B, verify the key without printing it and confirm any required template setting."
- "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process."
- "For trusted local development only, explicitly set box.backend=host; never use host as a production sandbox fallback."
verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend."
related_cases:
- sandbox-skill-authoring-e2e
+11 -38
View File
@@ -1,14 +1,13 @@
from __future__ import annotations
import asyncio
import json
import os
import typing
from pathlib import Path
import httpx
import typing
import json
from .errors import DifyAPIError
from pathlib import Path
import os
_MAX_DIFY_RESPONSE_BYTES = 1024 * 1024
_MAX_DIFY_SSE_LINE_BYTES = 1024 * 1024
@@ -16,32 +15,6 @@ _MAX_DIFY_STREAM_BYTES = 16 * 1024 * 1024
_MAX_DIFY_UPLOAD_BYTES = 10 * 1024 * 1024
def _decode_sse_data(line: bytes) -> dict[str, typing.Any] | None:
data = line[5:].strip()
if not data or data == b'[DONE]':
return None
try:
payload = json.loads(data.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise DifyAPIError('Dify SSE data line is not valid JSON') from exc
if not isinstance(payload, dict):
raise DifyAPIError('Dify SSE event is not a JSON object')
return payload
def _decode_upload_response(body: bytes) -> dict[str, typing.Any]:
try:
response = json.loads(body)
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
raise DifyAPIError('Dify upload response is not valid JSON') from exc
if not isinstance(response, dict):
raise DifyAPIError('Dify upload response is not a JSON object')
payload = response.get('data', response)
if not isinstance(payload, dict) or not isinstance(payload.get('id'), str) or not payload['id']:
raise DifyAPIError('Dify upload response does not contain a valid file id')
return payload
async def _read_limited_response(
response: httpx.Response,
*,
@@ -83,16 +56,16 @@ async def _iter_sse_json(
line = raw_line.rstrip(b'\r').strip()
if not line or not line.startswith(b'data:'):
continue
payload = _decode_sse_data(line)
if payload is not None:
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
if isinstance(payload, dict):
yield payload
if len(buffer) > _MAX_DIFY_SSE_LINE_BYTES:
raise DifyAPIError('Dify SSE event exceeds the runtime limit')
line = bytes(buffer).rstrip(b'\r').strip()
if line.startswith(b'data:'):
payload = _decode_sse_data(line)
if payload is not None:
payload = json.loads(line[5:].decode('utf-8', errors='replace'))
if isinstance(payload, dict):
yield payload
@@ -269,7 +242,7 @@ class AsyncDifyServiceClient:
file: httpx._types.FileTypes,
user: str,
timeout: float = 30.0,
) -> dict[str, typing.Any]:
) -> str:
# 处理 Path 对象
if isinstance(file, Path):
if not file.exists():
@@ -298,6 +271,6 @@ class AsyncDifyServiceClient:
timeout=timeout,
) as response:
body = await _read_limited_response(response)
if response.status_code not in (200, 201):
if response.status_code != 201:
raise DifyAPIError(f'{response.status_code} {body.decode(errors="replace")}')
return _decode_upload_response(body)
return json.loads(body)
-63
View File
@@ -422,69 +422,6 @@ 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,14 +46,6 @@ 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
@@ -503,145 +495,6 @@ 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.
@@ -936,13 +789,6 @@ class WecomBotWsClient:
'chat_type': message_data.get('type', 'single'),
}
self._prune_stream_state()
# Send an initial empty stream frame so the WeCom client
# shows its built-in loading spinner while the pipeline
# processes the message (e.g. RAG retrieval).
try:
await self.reply_stream(req_id, stream_id, '', finish=False)
except Exception:
await self.logger.warning(f'Failed to send initial stream frame: {traceback.format_exc()}')
message_data['stream_id'] = stream_id
message_data['req_id'] = req_id
@@ -295,34 +295,6 @@ class WecomCSClient:
raise Exception('Failed to send message')
return data
@_bounded_token_retry
async def send_image_msg(self, open_kfid: str, external_userid: str, msgid: str, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = f'{self.base_url}/kf/send_msg?access_token={self.access_token}'
payload = {
'touser': external_userid,
'open_kfid': open_kfid,
'msgid': msgid,
'msgtype': 'image',
'image': {
'media_id': media_id,
},
}
async with self._http_client_context() as client:
response = await client.post(url, json=payload)
data = await httpclient.parse_json_response(response)
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_image_msg(open_kfid, external_userid, msgid, media_id)
if data['errcode'] != 0:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image message')
return data
async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request)。"""
return await self._handle_callback_internal(request)
@@ -39,13 +39,7 @@ class PipelinesRouterGroup(group.RouterGroup):
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
pipeline_data = await quart.request.json
create_as_default = pipeline_data.get('is_default') is True
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
request_context,
pipeline_data,
default=create_as_default,
)
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(request_context, await quart.request.json)
return self.success(data={'uuid': pipeline_uuid})
@self.route(
@@ -1,6 +1,7 @@
import asyncio
import dataclasses
import mimetypes
import os
import quart
@@ -1133,3 +1134,224 @@ class AdaptersRouterGroup(group.RouterGroup):
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
# -----------------------------------------------------------------------
# Itchat WeChat QR Code Login
# -----------------------------------------------------------------------
_itchat_login_sessions: dict = {}
_ITCHAT_SESSION_TTL = 600 # 10 minutes (allows multiple QR regenerations)
def _cleanup_expired_itchat_sessions():
import time
now = time.time()
expired = [
sid for sid, s in _itchat_login_sessions.items() if now - s.get('created_at', 0) > _ITCHAT_SESSION_TTL
]
for sid in expired:
session = _itchat_login_sessions.pop(sid, None)
if session:
core = session.get('core')
if core:
try:
core.alive = False
core.isLogging = False
except Exception:
pass
@self.route('/itchat/login', methods=['POST'])
async def _() -> str:
"""Start itchat WeChat QR code login. Returns session_id + QR code data URL."""
import uuid
import time
import base64
import threading
_cleanup_expired_itchat_sessions()
session_id = str(uuid.uuid4())
loop = asyncio.get_running_loop()
status_dir = os.path.join('data', 'itchat')
os.makedirs(status_dir, exist_ok=True)
qr_path = os.path.join(status_dir, f'{session_id}-QR.png')
session = {
'status': 'pending',
'qr_data_url': None,
'expire_at': None,
'nickname': None,
'error': None,
'created_at': time.time(),
'thread': None,
'logged_in': threading.Event(),
'core': None,
}
_itchat_login_sessions[session_id] = session
def _run_itchat_login():
try:
from itchat.core import Core
from itchat.content import TEXT as _TEXT
from langbot.pkg.platform.sources.itchat import ItchatAdapter
for f in (qr_path,):
try:
os.remove(f)
except OSError:
pass
_core = Core()
session['core'] = _core
def on_login():
try:
_core.get_friends(update=True)
user_info = _core.loginInfo.get('User', {})
nick = ItchatAdapter._get_obj_value(user_info, 'NickName', 'unknown')
wxid = ItchatAdapter._get_obj_value(user_info, 'UserName')
except Exception:
nick = 'unknown'
wxid = ''
session['nickname'] = nick
session['wxid'] = wxid
print(f'[itchat-login] Login success: {nick}', flush=True)
# Dump login status so the adapter can hot-reload it
try:
if not wxid:
raise ValueError('Unable to detect WeChat wxid after login')
account_status_path = ItchatAdapter.login_status_path_for_account(wxid)
_core.dump_login_status(account_status_path)
session['login_status_path'] = account_status_path
session['status'] = 'success'
print(f'[itchat-login] Session saved to {account_status_path}', flush=True)
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
print(f'[itchat-login] Failed to save session: {e}', flush=True)
finally:
session['logged_in'].set()
# Stop the message loop - we only needed the session for QR login
_core.alive = False
def on_qr(**kwargs):
qr_bytes = kwargs.get('qrcode', b'')
status = kwargs.get('status', '')
print(f'[itchat-login] QR callback: status={status}, bytes={len(qr_bytes)}', flush=True)
if status == '200':
return
# Only update QR image on new QR generation (status='0')
# or when status changes to '408' (timeout, QR may refresh)
if qr_bytes and status == '0':
b64 = base64.b64encode(qr_bytes).decode('utf-8')
def _update():
session['qr_data_url'] = f'data:image/png;base64,{b64}'
session['expire_at'] = time.time() + 120
session['status'] = 'waiting'
loop.call_soon_threadsafe(_update)
# Register a dummy text handler
@_core.msg_register([_TEXT])
def _dummy(msg):
pass
print('[itchat-login] Step 3: Calling auto_login...', flush=True)
_core.auto_login(
hotReload=False,
loginCallback=on_login,
qrCallback=on_qr,
)
print('[itchat-login] Step 4: auto_login returned, starting run...', flush=True)
_core.run(blockThread=True)
print('[itchat-login] Step 5: run() returned', flush=True)
except SystemExit as e:
print(f'[itchat-login] SystemExit: {e}', flush=True)
session['status'] = 'error'
session['error'] = f'itchat exited: {e}'
session['logged_in'].set()
except Exception as e:
import traceback
print(f'[itchat-login] Exception: {traceback.format_exc()}', flush=True)
session['status'] = 'error'
session['error'] = str(e)
session['logged_in'].set()
t = threading.Thread(target=_run_itchat_login, daemon=True)
t.start()
session['thread'] = t
# Wait for QR code to be ready (max 15 seconds)
for _ in range(30):
if session['qr_data_url'] or session['error'] or session['status'] == 'success':
break
await asyncio.sleep(0.5)
if session['error']:
return self.http_status(502, -1, session['error'])
if session['status'] == 'success':
return self.success(
data={
'session_id': session_id,
'status': 'success',
'nickname': session['nickname'],
'wxid': session.get('wxid', ''),
}
)
if not session['qr_data_url']:
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_data_url': session['qr_data_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/itchat/login/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll itchat login status."""
session = _itchat_login_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {
'status': session['status'],
'qr_data_url': session['qr_data_url'],
'expire_at': session['expire_at'],
}
if session['status'] == 'success':
data['nickname'] = session.get('nickname', '')
data['wxid'] = session.get('wxid', '')
_itchat_login_sessions.pop(session_id, None)
elif session['status'] == 'error':
data['error'] = session['error']
_itchat_login_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/itchat/login/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up an itchat login session."""
session = _itchat_login_sessions.pop(session_id, None)
if session:
core = session.get('core')
if core:
try:
core.alive = False
core.isLogging = False
except Exception:
pass
thread = session.get('thread')
if thread and thread.is_alive():
# Thread is daemon, will die with the process
pass
return self.success(data={})
@@ -113,24 +113,6 @@ class BotsRouterGroup(group.RouterGroup):
)
return self.success(data={'sent': True})
@self.route(
'/<bot_uuid>/test-inbound',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_MANAGE,
)
async def _(bot_uuid: str, request_context: RequestContext) -> str:
json_data = await quart.request.get_json(silent=True) or {}
try:
result = await self.ap.bot_service.send_http_bot_test_message(
request_context,
bot_uuid,
str(json_data.get('message') or ''),
)
except ValueError as exc:
return self.http_status(400, -1, str(exc))
return self.success(data=result)
@self.route(
'/<bot_uuid>/admins',
methods=['GET'],
@@ -206,20 +206,6 @@ class SystemRouterGroup(group.RouterGroup):
return self.success(data={})
@self.route(
'/wizard/recommended-model',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_MANAGE,
)
async def _(request_context: RequestContext) -> str:
"""Resolve Space's best available chat model to this Workspace."""
try:
model = await self.ap.space_service.get_recommended_chat_model(request_context)
except ValueError as exc:
return self.http_status(503, -1, str(exc))
return self.success(data=model)
@self.route(
'/tasks',
methods=['GET'],
@@ -14,6 +14,13 @@ from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrati
@group.group_class('user', '/api/v1/user')
class UserRouterGroup(group.RouterGroup):
@staticmethod
def _origin(value: str) -> tuple[str, str, int | None] | None:
parsed = urlsplit(value)
if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
return None
return parsed.scheme, parsed.hostname.casefold(), parsed.port
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
parsed = urlsplit(redirect_uri)
if (
@@ -31,8 +38,17 @@ class UserRouterGroup(group.RouterGroup):
if query != {'mode': ['bind']}:
raise ValueError('Invalid Space binding redirect_uri')
elif query:
raise ValueError('Invalid LangBot Account login redirect_uri')
raise ValueError('Invalid Space login redirect_uri')
redirect_origin = self._origin(redirect_uri)
api_config = self.ap.instance_config.data.get('api', {})
trusted_origins = {
self._origin(str(api_config.get(config_key, '') or '').strip())
for config_key in ('webui_url', 'webhook_prefix')
}
trusted_origins.discard(None)
if redirect_origin not in trusted_origins:
raise ValueError('Untrusted redirect_uri origin')
return redirect_uri
async def initialize(self) -> None:
@@ -322,7 +338,6 @@ 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)
@@ -401,7 +416,7 @@ class UserRouterGroup(group.RouterGroup):
'Bind the LangBot Account with the same email as this local Account',
)
except ValueError:
return self.http_status(400, -1, 'LangBot Account binding failed')
return self.http_status(400, -1, 'Space account binding failed')
except Exception:
raise
+2 -51
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import uuid
import json
import sqlalchemy
from ....core import app
@@ -9,8 +8,6 @@ from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline
from ....workspace.errors import WorkspaceNotFoundError
from .tenant import TenantContext, require_workspace_uuid, scope_statement
from ....utils import httpclient
from ....platform.sources import http_bot_signing
class BotService:
@@ -72,6 +69,8 @@ class BotService:
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is not None:
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
if hasattr(runtime_bot.adapter, 'get_runtime_status'):
adapter_runtime_values['runtime_status'] = runtime_bot.adapter.get_runtime_status()
# Webhook URL for unified webhook adapters (independent of bot running state)
if persistence_bot['adapter'] in [
@@ -83,7 +82,6 @@ class BotService:
'wecomcs',
'LINE',
'lark',
'http_bot',
]:
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
@@ -220,53 +218,6 @@ class BotService:
return [log.to_json() for log in logs], total_count
async def send_http_bot_test_message(
self,
context: TenantContext,
bot_uuid: str,
message: str,
) -> dict:
"""Send a signed test message through the HTTP Bot public ingress."""
bot = await self.get_bot(context, bot_uuid, include_secret=True)
if bot is None:
raise WorkspaceNotFoundError('Bot not found')
if bot.get('adapter') != 'http_bot':
raise ValueError('Inbound test is only available for HTTP Bot')
if not bot.get('enable'):
raise ValueError('Bot must be enabled before sending a test message')
text = message.strip()
if not text or len(text) > 2000:
raise ValueError('Test message must contain 1 to 2000 characters')
payload = {
'session_id': f'wizard-{uuid.uuid4().hex}',
'sender': {'id': 'wizard-user', 'name': 'Wizard Test'},
'message': [{'type': 'Plain', 'text': text}],
}
body = json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode()
config = bot.get('adapter_config') or {}
headers = {'Content-Type': 'application/json'}
if config.get('signature_required', True):
secret = str(config.get('inbound_secret') or '')
if not secret:
raise ValueError('HTTP Bot inbound signing secret is required')
timestamp, signature = http_bot_signing.sign(secret, body)
headers[http_bot_signing.HEADER_TIMESTAMP] = timestamp
headers[http_bot_signing.HEADER_SIGNATURE] = signature
port = int(self.ap.instance_config.data.get('api', {}).get('port', 5300))
session = httpclient.get_session()
async with session.post(
f'http://127.0.0.1:{port}/bots/{bot_uuid}',
data=body,
headers=headers,
) as response:
result = await httpclient.read_json_limited(response)
if response.status not in {200, 202}:
raise ValueError(result.get('msg') or f'HTTP Bot test failed with status {response.status}')
return result.get('data') or {}
async def send_message(
self,
context: TenantContext,
-76
View File
@@ -11,9 +11,6 @@ import sqlalchemy
from ....core import app
from ....entity.persistence import user
from ....entity.dto.space_model import SpaceModel
from ....entity.dto.space_model import SpaceModelSelection
from ....entity.persistence import model as persistence_model
from ....cloud.model_catalog import LANGBOT_MODELS_PROVIDER_REQUESTER
_CREDITS_CACHE_TTL_SECONDS = 60
@@ -241,76 +238,3 @@ class SpaceService:
raise ValueError(f'Failed to get models: {data.get("msg")}')
models_data = data.get('data', {}).get('models', [])
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
"""Return Space models in the availability-ranked selection order."""
space_url = self._get_space_config()['url']
session = httpclient.get_session()
async with session.get(
f'{space_url}/api/v1/models/selection',
params={'category': category},
) as response:
if response.status != 200:
error = await httpclient.read_text_limited(response)
raise ValueError(f'Failed to get model selection: {error}')
payload = await httpclient.read_json_limited(response)
if payload.get('code') != 0:
raise ValueError(f'Failed to get model selection: {payload.get("msg")}')
data = payload.get('data', [])
if isinstance(data, dict):
data = data.get('models', data.get('items', []))
if not isinstance(data, list):
raise ValueError('Failed to get model selection: invalid response')
models = []
for selection in data:
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
models.append(selection['model'])
else:
models.append(selection)
return [SpaceModelSelection.model_validate(model) for model in models]
async def get_recommended_chat_model(self, context: typing.Any) -> dict:
"""Resolve Space's first ranked chat model to a local Workspace model."""
selection = await self.get_model_selection('chat')
if not selection:
raise ValueError('No recommended chat model is available')
recommended = selection[0]
async def find_local_model():
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.LLMModel)
.join(
persistence_model.ModelProvider,
sqlalchemy.and_(
persistence_model.ModelProvider.workspace_uuid == persistence_model.LLMModel.workspace_uuid,
persistence_model.ModelProvider.uuid == persistence_model.LLMModel.provider_uuid,
),
)
.where(
persistence_model.LLMModel.workspace_uuid == context.workspace_uuid,
persistence_model.ModelProvider.requester == LANGBOT_MODELS_PROVIDER_REQUESTER,
sqlalchemy.or_(
persistence_model.LLMModel.uuid == recommended.uuid,
persistence_model.LLMModel.name == recommended.model_id,
),
)
)
return result.first()
local_model = await find_local_model()
if local_model is None:
# OSS synchronizes the public catalog locally. Refresh once in case
# the recommendation was published after this process started.
from ..context import ExecutionContext
try:
await self.ap.model_mgr.sync_new_models_from_space(ExecutionContext.from_request(context))
except Exception:
pass
local_model = await find_local_model()
if local_model is None:
raise ValueError('Recommended chat model is not available in this Workspace')
return {'uuid': local_model.uuid, 'name': local_model.name}
+4 -4
View File
@@ -114,7 +114,7 @@ class UserService:
if purpose == 'login' and account_uuid is not None:
raise ValueError('Login state cannot be bound to an Account')
if purpose != 'login' and launch_workspace_uuid is not None:
raise ValueError('Launch Workspace state is only valid for LangBot Account login')
raise ValueError('Launch Workspace state is only valid for Space login')
if ttl_seconds <= 0:
raise ValueError('OAuth state lifetime must be positive')
@@ -327,7 +327,7 @@ class UserService:
normalized_email = normalize_email(user_email)
if self._uses_control_plane_directory():
raise ControlPlaneDirectoryRequiredError(
'Cloud invitation registration must use a LangBot Account to preserve control-plane identity'
'Cloud invitation registration must use a Space account to preserve control-plane identity'
)
invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token)
if invitation.normalized_email != normalized_email:
@@ -394,7 +394,7 @@ class UserService:
# Check if this user has a local password set
if not user_obj.password:
raise ValueError('请使用 LangBot登录')
raise ValueError('请使用 Space登录')
await self._verify_password(user_obj.password, password)
@@ -825,7 +825,7 @@ class UserService:
# Check if this Space account is already bound to another user
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_space_user and existing_space_user.normalized_email != normalize_email(user_email):
raise ValueError('This LangBot Account is already bound to another user')
raise ValueError('This Space account is already bound to another user')
# Update local account to Space account
normalized_email = normalize_email(user_email)
+1 -10
View File
@@ -147,16 +147,7 @@ class LangBotMCPServer:
)
async def create_pipeline(pipeline_data: dict) -> str:
context = _authorized(Permission.RESOURCE_MANAGE)
create_as_default = pipeline_data.get('is_default') is True
return _dump(
{
'uuid': await ap.pipeline_service.create_pipeline(
context,
pipeline_data,
default=create_as_default,
)
}
)
return _dump({'uuid': await ap.pipeline_service.create_pipeline(context, pipeline_data)})
@mcp.tool(description='Update a pipeline by UUID. `pipeline_data` matches the PUT body.')
async def update_pipeline(pipeline_uuid: str, pipeline_data: dict) -> str:
-4
View File
@@ -368,10 +368,6 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48)
if not self._control_token:
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
raise BoxRuntimeUnavailableError(
f'{BOX_CONTROL_TOKEN_ENV} must be configured with a strong shared secret for a Cloud Box runtime'
)
return ''
try:
self._control_token = validate_control_token(self._control_token)
+11 -27
View File
@@ -455,9 +455,7 @@ class BoxService:
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
if not self._available:
raise BoxError(
'Box runtime is not available. Configure an available Box backend before using Box features.'
)
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
if self._cloud_managed:
if self._admission is None:
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
@@ -567,9 +565,7 @@ class BoxService:
skip_host_mount_validation: bool = False,
) -> dict:
if not self._available:
raise BoxError(
'Box runtime is not available. Configure an available Box backend before using Box features.'
)
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
execution_context = await self._validated_execution_context(self._query_execution_context(query))
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
await self._require_validated_workspace_sandbox(execution_context)
@@ -1214,9 +1210,8 @@ 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).
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.
Note: exec stdout is truncated by ``output_limit_chars``, so this path
only reliably transfers small files. The host path is preferred.
"""
import json as _json
@@ -1270,22 +1265,14 @@ class BoxService:
' break\n'
'print(json.dumps(out))\n'
)
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:
result = await self.execute_tool(
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 120},
query,
)
if not result.get('ok'):
return []
try:
return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
return _json.loads(str(result.get('stdout') or '').strip().splitlines()[-1])
except Exception:
return []
@@ -2146,8 +2133,5 @@ class BoxService:
if backend_name:
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
else:
payload['connector_error'] = (
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
'Trusted local development may explicitly select the unsafe host backend.'
)
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
return payload
+1 -9
View File
@@ -3,7 +3,6 @@ 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
@@ -67,14 +66,7 @@ class CommandManager:
require_context = getattr(self.ap.plugin_connector, 'require_workspace_context', None)
if require_context is not None:
result = require_context(
ExecutionContext(
instance_uuid=context.instance_uuid,
workspace_uuid=context.workspace_uuid,
placement_generation=context.placement_generation,
query_uuid=context.query_uuid,
)
)
result = require_context(context)
if inspect.isawaitable(result):
await result
+3 -37
View File
@@ -249,10 +249,6 @@ class Application:
{},
)
),
'plugin_runtime_connected': bool(
self.plugin_connector is not None
and getattr(self.plugin_connector, '_runtime_available', lambda: False)()
),
}
mcp_loader = getattr(self.tool_mgr, 'mcp_tool_loader', None)
runtime_stats.update(
@@ -301,36 +297,11 @@ 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
and getattr(self, 'directory_projection_task', None) is None
):
self.directory_projection_task = self.task_mgr.create_task(
if self.directory_projection_service is not None:
self.task_mgr.create_task(
self.directory_projection_service.run(),
name='cloud-directory-projection',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
@@ -347,6 +318,7 @@ class Application:
name='cloud-manifest-refresh',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
await self.plugin_connector.initialize_plugins()
# 后续可能会允许动态重启其他任务
# 故为了防止程序在非 Ctrl-C 情况下退出,这里创建一个不会结束的协程
@@ -372,7 +344,6 @@ 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().
@@ -554,11 +525,6 @@ 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)
+8 -11
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from .. import stage, app, entities as core_entities
from .. import stage, app
from ...utils import version, proxy, constants
from ...pipeline import pool, controller, pipelinemgr
from ...pipeline import aggregator as message_aggregator
@@ -292,17 +292,14 @@ 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()
@@ -47,10 +47,3 @@ class SpaceModel(pydantic.BaseModel):
status: str
created_at: str | None = None
updated_at: str | None = None
class SpaceModelSelection(pydantic.BaseModel):
"""Minimal model identity returned by the ranked selection endpoint."""
uuid: str
model_id: str
+1 -1
View File
@@ -17,4 +17,4 @@ class SpaceAccountBindingRequiredError(AccountEmailMismatchError):
code = 'space_account_binding_required'
def __str__(self) -> str:
return 'This local account must bind a LangBot Account from Account settings before LangBot Account login'
return 'This local Account must bind Space from Account settings before Space login'
+1 -12
View File
@@ -177,6 +177,7 @@ 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()
@@ -184,7 +185,6 @@ 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,17 +373,6 @@ 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()
@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
import contextlib
import dataclasses
import datetime
import json
@@ -13,7 +12,6 @@ import re
import secrets
import sqlite3
import tempfile
import time
import typing
from sqlalchemy.ext.asyncio import AsyncEngine
@@ -83,7 +81,7 @@ def _verify_connection(connection: sqlite3.Connection, expected_revision: str) -
def _verify_file(path: pathlib.Path, expected_revision: str) -> None:
with contextlib.closing(_open_read_only(path)) as connection:
with _open_read_only(path) as connection:
_verify_connection(connection, expected_revision)
@@ -119,23 +117,8 @@ def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.
temporary_path.unlink(missing_ok=True)
def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
"""Sync a file, tolerating delayed visibility after replace on bind mounts.
Uses O_RDWR so os.fsync works on Windows (where _commit requires write
access to the file descriptor).
"""
descriptor: int | None = None
for attempt in range(reopen_attempts):
try:
descriptor = os.open(path, os.O_RDWR)
break
except FileNotFoundError:
if attempt + 1 >= reopen_attempts:
raise
time.sleep(0.05)
assert descriptor is not None
def _fsync_file(path: pathlib.Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
@@ -143,37 +126,13 @@ def _fsync_file(path: pathlib.Path, *, reopen_attempts: int = 20) -> None:
def _fsync_directory(path: pathlib.Path) -> None:
if os.name == 'nt':
# Windows cannot fsync directory handles opened through os.open.
return
descriptor = os.open(path, os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0))
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _remove_stale_temporary_files(
directory: pathlib.Path,
*,
prefix: str,
suffix: str,
) -> None:
"""Remove temporary files left by an interrupted backup or restore."""
for candidate in directory.iterdir():
if candidate.is_dir() or not candidate.name.startswith(prefix) or not candidate.name.endswith(suffix):
continue
try:
candidate.unlink()
except FileNotFoundError:
continue
except PermissionError:
# Another process may still own this file. Do not turn harmless
# cleanup into a migration failure; its unique name cannot collide.
continue
def _create_backup(
database_path: pathlib.Path,
source_revision: str,
@@ -182,11 +141,6 @@ def _create_backup(
backup_directory = database_path.parent / 'migration-backups'
backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(backup_directory, 0o700)
_remove_stale_temporary_files(
backup_directory,
prefix=f'.{database_path.stem}-pre-',
suffix='.creating',
)
created_at = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H-%M-%S.%fZ')
stem = (
f'{database_path.stem}-pre-{_safe_label(target_revision)}-'
@@ -203,8 +157,11 @@ def _create_backup(
temporary_path = pathlib.Path(temporary_name)
try:
with (
contextlib.closing(_open_read_only(database_path)) as source,
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
_open_read_only(database_path) as source,
sqlite3.connect(
temporary_path,
timeout=30,
) as destination,
):
source.execute('PRAGMA busy_timeout = 30000')
source.backup(destination)
@@ -252,11 +209,6 @@ async def create_verified_backup(
def _restore_backup(backup: SQLiteMigrationBackup) -> None:
_verify_file(backup.backup_path, backup.source_revision)
_remove_stale_temporary_files(
backup.database_path.parent,
prefix=f'.{backup.database_path.name}.',
suffix='.restoring',
)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f'.{backup.database_path.name}.',
suffix='.restoring',
@@ -266,8 +218,11 @@ def _restore_backup(backup: SQLiteMigrationBackup) -> None:
temporary_path = pathlib.Path(temporary_name)
try:
with (
contextlib.closing(_open_read_only(backup.backup_path)) as source,
contextlib.closing(sqlite3.connect(temporary_path, timeout=30)) as destination,
_open_read_only(backup.backup_path) as source,
sqlite3.connect(
temporary_path,
timeout=30,
) as destination,
):
source.backup(destination)
destination.commit()
+1 -1
View File
@@ -209,7 +209,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
'now': sqlalchemy.sql.functions.now,
'sum': sqlalchemy.sql.functions.sum,
}
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'strftime'})
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif'})
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
_ALLOWED_SCOPED_STATEMENT_TYPES = (
sqlalchemy.sql.dml.UpdateBase,
@@ -5,11 +5,6 @@ from .. import entities
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
from ....utils.safe_regex import SafeRegexError, mask_patterns
# Legacy sensitive-words.json files shipped ~70 rules, which exceeds the
# default safe_regex per-call cap of 64 and used to fail-close every message.
# Keep one 50ms CPU budget for the whole list; only raise the pattern cap.
_MAX_SENSITIVE_WORD_PATTERNS = 256
@filter_model.filter_class('ban-word-filter')
class BanWordFilter(filter_model.ContentFilter):
@@ -19,17 +14,12 @@ class BanWordFilter(filter_model.ContentFilter):
pass
async def process(self, query: pipeline_query.Query, message: str) -> entities.FilterResult:
words = self.ap.sensitive_meta.data.get('words') or []
mask = self.ap.sensitive_meta.data['mask']
mask_word = self.ap.sensitive_meta.data['mask_word']
try:
found, current = await mask_patterns(
words,
found, message = await mask_patterns(
self.ap.sensitive_meta.data['words'],
message,
mask=mask,
mask_word=mask_word,
max_pattern_count=_MAX_SENSITIVE_WORD_PATTERNS,
mask=self.ap.sensitive_meta.data['mask'],
mask_word=self.ap.sensitive_meta.data['mask_word'],
)
except SafeRegexError as exc:
return entities.FilterResult(
@@ -41,7 +31,7 @@ class BanWordFilter(filter_model.ContentFilter):
return entities.FilterResult(
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
replacement=current,
replacement=message,
user_notice='消息中存在不合适的内容, 请修改' if found else '',
console_notice='',
)
@@ -158,18 +158,6 @@ 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]
+771
View File
@@ -0,0 +1,771 @@
"""itchat-uos adapter for LangBot.
Uses the itchat-uos WeChat Web library to integrate personal WeChat accounts
with LangBot via QR code login.
Reference: https://github.com/littlecodersh/ItChat
UOS fork: https://github.com/why2lyj/ItChat-uos
"""
from __future__ import annotations
import asyncio
import base64
import os
import re
import tempfile
import threading
import time
import traceback
import typing
from itchat.content import TEXT, PICTURE, RECORDING, VIDEO, SHARING
import pydantic
from itchat.core import Core as ItchatCore
try:
import queue
except ImportError:
import Queue as queue
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.logger import EventLogger
class ItchatMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
"""Converts between LangBot MessageChain and itchat message dicts."""
@staticmethod
async def yiri2target(
message_chain: platform_message.MessageChain,
) -> list[dict]:
"""LangBot MessageChain -> list of itchat-sendable items.
Each item is a dict with 'type' and the relevant content field.
The adapter's send_message() will call itchat.send() accordingly.
"""
items: list[dict] = []
for component in message_chain:
if isinstance(component, platform_message.Plain):
if component.text:
items.append({'type': 'text', 'content': component.text})
elif isinstance(component, platform_message.Image):
if component.base64:
items.append({'type': 'image', 'base64': component.base64})
elif component.url:
items.append({'type': 'image', 'url': component.url})
elif isinstance(component, platform_message.Voice):
if component.base64:
items.append({'type': 'voice', 'base64': component.base64})
elif component.url:
items.append({'type': 'voice', 'url': component.url})
elif isinstance(component, platform_message.File):
if component.base64:
items.append({'type': 'file', 'base64': component.base64, 'name': component.name or 'file'})
elif component.url:
items.append({'type': 'file', 'url': component.url, 'name': component.name or 'file'})
elif isinstance(component, platform_message.At):
items.append({'type': 'text', 'content': f'@{component.target} '})
elif isinstance(component, platform_message.AtAll):
items.append({'type': 'text', 'content': '@所有人 '})
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
items.extend(await ItchatMessageConverter.yiri2target(node.message_chain))
elif isinstance(component, platform_message.Unknown):
pass # skip unknown outbound
return items
@staticmethod
def target2yiri(msg: dict) -> platform_message.MessageChain:
"""Convert an itchat msg dict to a LangBot MessageChain."""
components: list[platform_message.MessageComponent] = []
msg_type = msg.get('Type', '')
if msg_type == 'Text':
text = msg.get('Text', '')
if text:
components.append(platform_message.Plain(text=text))
elif msg_type == 'Picture':
try:
temp_dir = tempfile.gettempdir()
file_path = os.path.join(temp_dir, msg.get('FileName', 'image.jpg'))
msg.download(file_path)
if os.path.exists(file_path):
with open(file_path, 'rb') as f:
img_bytes = f.read()
b64 = base64.b64encode(img_bytes).decode('utf-8')
components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}'))
os.remove(file_path)
else:
components.append(platform_message.Unknown(text='[Image download failed]'))
except Exception:
components.append(platform_message.Unknown(text='[Image download failed]'))
elif msg_type == 'Recording':
try:
temp_dir = tempfile.gettempdir()
file_path = os.path.join(temp_dir, msg.get('FileName', 'voice.mp3'))
msg.download(file_path)
if os.path.exists(file_path):
with open(file_path, 'rb') as f:
voice_bytes = f.read()
b64 = base64.b64encode(voice_bytes).decode('utf-8')
components.append(platform_message.Voice(base64=b64))
os.remove(file_path)
else:
components.append(platform_message.Unknown(text='[Voice download failed]'))
except Exception:
components.append(platform_message.Unknown(text='[Voice download failed]'))
elif msg_type == 'Sharing':
text = msg.get('Text', '')
url = msg.get('Url', '')
content = text
if url and url not in text:
content = f'{text}\n{url}' if text else url
if content:
components.append(platform_message.Plain(text=content))
elif msg_type == 'Video':
components.append(platform_message.Unknown(text='[Video]'))
elif msg_type == 'Map':
components.append(platform_message.Unknown(text='[Location]'))
elif msg_type == 'Card':
components.append(platform_message.Unknown(text='[Contact Card]'))
elif msg_type == 'Note':
text = msg.get('Text', '')
if text:
components.append(platform_message.Unknown(text=f'[Note: {text}]'))
else:
text = msg.get('Text', '')
if text:
components.append(platform_message.Plain(text=text))
else:
components.append(platform_message.Unknown(text=f'[Unsupported message type: {msg_type}]'))
return platform_message.MessageChain(components)
class ItchatEventConverter(abstract_platform_adapter.AbstractEventConverter):
"""Converts itchat msg dicts to LangBot events."""
def __init__(self, adapter_ref: typing.Callable[[], typing.Any]):
"""adapter_ref is a callable returning the ItchatAdapter instance."""
self._get_adapter = adapter_ref
@staticmethod
async def yiri2target(event: platform_events.MessageEvent) -> dict:
return event.source_platform_object
def target2yiri(self, msg: dict) -> typing.Optional[platform_events.MessageEvent]:
"""Convert itchat msg to FriendMessage or GroupMessage."""
from_user = msg.get('FromUserName', '')
if not from_user:
return None
adapter = self._get_adapter()
bot_account_id = adapter.bot_account_id
bot_nickname = adapter._bot_nickname
# Ignore the bot's own messages to avoid reply loops
bot_user_name = getattr(adapter._core.storageClass, 'userName', '')
if from_user == bot_account_id or (bot_user_name and from_user == bot_user_name):
return None
message_chain = ItchatMessageConverter.target2yiri(msg)
if not message_chain:
return None
# Determine if this is a group message
# itchat uses '@@' prefix for chatroom IDs (not '@chatroom' suffix)
is_group = from_user.startswith('@@')
timestamp = msg.get('CreateTime', 0)
if is_group:
# Actual sender within the group
actual_user = msg.get('ActualUserName', '')
actual_nick = msg.get('ActualNickName', '') or actual_user
if not actual_user:
return None
# Prepend @bot if the bot was mentioned
# itchat uses 'IsAt' (capital I, capital A) in produce_group_chat
if msg.get('IsAt', False):
# Strip @bot_nickname from the text content to avoid LLM confusion
if bot_nickname:
at_re = re.compile(re.escape('@' + bot_nickname) + r'[ ]?')
for component in message_chain:
if isinstance(component, platform_message.Plain):
component.text = at_re.sub('', component.text, count=1)
break
message_chain = platform_message.MessageChain(
[platform_message.At(target=bot_account_id)] + list(message_chain)
)
# Try to get group display name
group_obj = msg.get('User', {})
group_name = ''
if hasattr(group_obj, 'NickName'):
group_name = group_obj.NickName
elif isinstance(group_obj, dict):
group_name = group_obj.get('NickName', '')
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=actual_user,
member_name=actual_nick,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=from_user,
name=group_name or from_user,
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=message_chain,
time=timestamp,
source_platform_object=msg,
)
else:
# Private / friend message
user_obj = msg.get('User', {})
sender_nick = adapter._get_obj_value(user_obj, 'NickName')
sender_remark = adapter._get_obj_value(user_obj, 'RemarkName')
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=from_user,
nickname=sender_nick or from_user,
remark=sender_remark,
),
message_chain=message_chain,
time=timestamp,
source_platform_object=msg,
)
class ItchatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""LangBot adapter for itchat-uos (WeChat Web)."""
name: str = 'itchat'
config: dict
logger: EventLogger
message_converter: ItchatMessageConverter
event_converter: ItchatEventConverter
listeners: typing.Dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
_loop: typing.Optional[asyncio.AbstractEventLoop] = pydantic.PrivateAttr(default=None)
_logged_in: typing.Optional[threading.Event] = pydantic.PrivateAttr(default=None)
_itchat_thread: typing.Optional[threading.Thread] = pydantic.PrivateAttr(default=None)
_core: typing.Optional[ItchatCore] = pydantic.PrivateAttr(default=None)
_bot_nickname: str = pydantic.PrivateAttr(default='')
_bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None)
_startup_error: typing.Optional[str] = pydantic.PrivateAttr(default=None)
_connection_status: str = pydantic.PrivateAttr(default='disconnected')
_connection_error: str = pydantic.PrivateAttr(default='')
_last_connected_at: typing.Optional[float] = pydantic.PrivateAttr(default=None)
_last_disconnected_at: typing.Optional[float] = pydantic.PrivateAttr(default=None)
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
message_converter = ItchatMessageConverter()
# Event converter needs a reference to self for bot_account_id + nickname
event_converter = ItchatEventConverter(adapter_ref=lambda: self)
super().__init__(
config=config,
logger=logger,
message_converter=message_converter,
event_converter=event_converter,
listeners={},
bot_account_id='',
)
# Initialize private attributes (can't be class-level defaults due to pickle)
self._loop = None
self._logged_in = threading.Event()
self._itchat_thread = None
self._core = ItchatCore()
self._startup_error = None
self._connection_status = 'disconnected'
self._connection_error = ''
self._last_connected_at = None
self._last_disconnected_at = None
@staticmethod
def _get_obj_value(obj: typing.Any, key: str, default: str = '') -> str:
if isinstance(obj, dict):
return obj.get(key, default) or default
return getattr(obj, key, default) or default
@staticmethod
def _safe_status_name(value: str) -> str:
cleaned = re.sub(r'[^A-Za-z0-9_.@-]+', '_', value.strip())
cleaned = cleaned.strip('._')
return cleaned
@staticmethod
def login_status_dir() -> str:
path = os.path.join('data', 'itchat')
os.makedirs(path, exist_ok=True)
return path
@classmethod
def login_status_path_for_account(cls, account_id: str) -> str:
safe_name = cls._safe_status_name(account_id)
if not safe_name:
raise ValueError('account_id is required for itchat login status')
filename = f'{safe_name}.pkl'
return os.path.join(cls.login_status_dir(), filename)
def _login_status_path(self) -> str:
configured_path = self.config.get('login_status_path', '').strip()
if configured_path:
return configured_path
account_id = self.config.get('account_id', '').strip()
if not account_id:
raise ValueError('account_id is required. Please scan the QR code and save this bot first.')
return self.login_status_path_for_account(account_id)
def set_bot_uuid(self, bot_uuid: str):
self._bot_uuid = bot_uuid
def _set_connection_status(self, status: str, error: str = ''):
self._connection_status = status
self._connection_error = error
now = time.time()
if status == 'connected':
self._last_connected_at = now
elif status in {'disconnected', 'error'}:
self._last_disconnected_at = now
def get_runtime_status(self) -> dict:
return {
'connection_status': self._connection_status,
'connection_error': self._connection_error,
'last_connected_at': self._last_connected_at,
'last_disconnected_at': self._last_disconnected_at,
}
def _on_login(self):
"""Called by itchat after successful QR code login."""
try:
# Refresh contacts
self._core.get_friends(update=True)
self._core.get_chatrooms(update=True)
# Get bot's own WeChat info from loginInfo['User']
user_info = self._core.loginInfo.get('User', {})
nick_name = self._get_obj_value(user_info, 'NickName')
user_name = self._get_obj_value(user_info, 'UserName')
# bot_account_id: config override or auto-detected wxid
# Used by AtBotRule for matching At.target
configured_id = self.config.get('account_id', '').strip()
self.bot_account_id = configured_id or user_name or nick_name or 'itchat-bot'
# _bot_nickname: config override or auto-detected nickname
configured_nick = self.config.get('nickname', '').strip()
self._bot_nickname = configured_nick or nick_name
self._set_connection_status('connected')
try:
chatrooms = self._core.search_chatrooms() or []
group_names = []
for c in chatrooms:
name = self._get_obj_value(c, 'NickName', str(c))
if name:
group_names.append(name)
if group_names:
self._log_sync(
f'itchat login as {nick_name} ({user_name}) | Groups ({len(group_names)}): {", ".join(group_names[:10])}{"..." if len(group_names) > 10 else ""}'
)
else:
self._log_sync(f'itchat login as {nick_name} ({user_name}) | No groups found')
except Exception as e:
self._log_sync(f'itchat login as {nick_name} ({user_name}) | Failed to list groups: {e}', 'warning')
except Exception as e:
self.bot_account_id = f'WeChat Bot (Error: {e})'
self._set_connection_status('error', str(e))
finally:
self._logged_in.set()
def _log_sync(self, msg: str, level: str = 'info'):
"""Thread-safe logging from itchat's sync thread."""
try:
if self._loop and not self._loop.is_closed():
log_fn = getattr(self.logger, level)
asyncio.run_coroutine_threadsafe(log_fn(msg), self._loop)
except Exception:
pass
def _drain_msglist(self):
"""Clear all stale messages from the msgList queue.
itchat's load_login_status fetches old messages via get_msg() and
pushes them into msgList. We drain them to avoid replaying history.
"""
try:
q = self._core.msgList
while True:
q.get_nowait()
except queue.Empty:
pass
def _on_qr_callback(self, **kwargs):
"""Called by itchat when QR code is generated or status changes.
Args:
uuid: QR code uuid
status: '200' = logged in, '201' = confirmed on phone, '408' = timeout
qrcode: raw bytes of the QR code PNG image
"""
status = kwargs.get('status', '')
qr_bytes = kwargs.get('qrcode', b'')
if status == '200':
# Login success, no need to show QR
return
# Only show QR on new QR generation (status='0') to avoid spamming
if not qr_bytes or status != '0':
return
try:
b64 = base64.b64encode(qr_bytes).decode('utf-8')
if self._loop and not self._loop.is_closed():
asyncio.run_coroutine_threadsafe(
self.logger.info(
'Please scan the QR code to login WeChat:',
images=[platform_message.Image(base64=f'data:image/png;base64,{b64}')],
),
self._loop,
)
except Exception:
pass
def _on_exit(self):
"""Called by itchat on exit."""
self._set_connection_status('disconnected')
self._log_sync('itchat session exited')
def _register_itchat_handlers(self):
"""Register itchat message decorators by re-registering handlers."""
@self._core.msg_register([TEXT])
def _on_text(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([TEXT], isGroupChat=True)
def _on_group_text(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([PICTURE])
def _on_picture(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([PICTURE], isGroupChat=True)
def _on_group_picture(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([RECORDING])
def _on_recording(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([RECORDING], isGroupChat=True)
def _on_group_recording(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([SHARING])
def _on_sharing(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([SHARING], isGroupChat=True)
def _on_group_sharing(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([VIDEO])
def _on_video(msg):
self._dispatch_itchat_message(msg)
@self._core.msg_register([VIDEO], isGroupChat=True)
def _on_group_video(msg):
self._dispatch_itchat_message(msg)
def _dispatch_itchat_message(self, msg: dict):
"""Bridge itchat callback (sync, in itchat thread) to async listener."""
try:
event = self.event_converter.target2yiri(msg)
if event is None:
return
event_type = type(event)
if event_type in self.listeners and self._loop:
callback = self.listeners[event_type]
asyncio.run_coroutine_threadsafe(
callback(event, self),
self._loop,
)
except Exception:
self._log_sync(f'Error dispatching itchat message: {traceback.format_exc()}', 'error')
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
):
"""Send a message to a user or group via itchat."""
items = await self.message_converter.yiri2target(message)
loop = asyncio.get_event_loop()
# Merge consecutive text items to avoid splitting messages
merged = []
for item in items:
if item['type'] == 'text' and merged and merged[-1]['type'] == 'text':
merged[-1]['content'] += item['content']
else:
merged.append(item)
for item in merged:
try:
if item['type'] == 'text':
await loop.run_in_executor(None, self._core.send, item['content'], target_id)
elif item['type'] == 'image':
# Save to temp file then send
temp_path = self._save_to_temp(item, 'image')
if temp_path:
await loop.run_in_executor(None, self._core.send, f'@img@{temp_path}', target_id)
self._cleanup_temp(temp_path)
elif item['type'] == 'voice':
temp_path = self._save_to_temp(item, 'voice')
if temp_path:
await loop.run_in_executor(None, self._core.send, f'@fil@{temp_path}', target_id)
self._cleanup_temp(temp_path)
elif item['type'] == 'file':
temp_path = self._save_to_temp(item, 'file')
if temp_path:
await loop.run_in_executor(None, self._core.send, f'@fil@{temp_path}', target_id)
self._cleanup_temp(temp_path)
except Exception:
await self.logger.error(f'Failed to send itchat message: {traceback.format_exc()}')
def _save_to_temp(self, item: dict, prefix: str) -> typing.Optional[str]:
"""Save base64 or URL data to a temp file and return the path."""
try:
if 'base64' in item:
b64_data = item['base64']
# Strip data URI prefix if present
if ',' in b64_data:
b64_data = b64_data.split(',', 1)[1]
file_bytes = base64.b64decode(b64_data)
suffix = '.jpg' if prefix == 'image' else ('.mp3' if prefix == 'voice' else '.bin')
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix=f'itchat_{prefix}_')
with os.fdopen(fd, 'wb') as f:
f.write(file_bytes)
return temp_path
elif 'url' in item:
import requests
resp = requests.get(item['url'], timeout=30)
if resp.status_code == 200:
suffix = '.jpg' if prefix == 'image' else ('.mp3' if prefix == 'voice' else '.bin')
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix=f'itchat_{prefix}_')
with os.fdopen(fd, 'wb') as f:
f.write(resp.content)
return temp_path
except Exception:
self._log_sync(f'Failed to save temp file: {traceback.format_exc()}', 'error')
return None
def _cleanup_temp(self, path: str):
"""Remove a temp file."""
try:
if os.path.exists(path):
os.remove(path)
except OSError:
pass
def _prepare_reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
) -> platform_message.MessageChain:
"""Render group sender mentions with display names while keeping internal IDs stable."""
if not isinstance(message_source, platform_events.GroupMessage):
return message
source_msg = message_source.source_platform_object or {}
actual_user = source_msg.get('ActualUserName', '')
actual_nick = source_msg.get('ActualNickName', '')
if not actual_user or not actual_nick:
return message
components: list[platform_message.MessageComponent] = []
changed = False
for component in message:
if isinstance(component, platform_message.At) and str(component.target) == str(actual_user):
components.append(platform_message.Plain(text=f'@{actual_nick} '))
changed = True
else:
components.append(component)
if not changed:
return message
return platform_message.MessageChain(components)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
"""Reply to a received message."""
source_msg = message_source.source_platform_object
if not source_msg:
return
# For group messages, reply to the group; for private, reply to the sender
from_user = source_msg.get('FromUserName', '')
if not from_user:
return
await self.send_message('friend', from_user, self._prepare_reply_message(message_source, message))
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners.pop(event_type, None)
async def run_async(self):
"""Start the itchat adapter.
If an account-specific cached session file exists from a previous QR login,
itchat will reuse data/itchat/<account_id>.pkl without requiring a new QR scan.
"""
self._loop = asyncio.get_running_loop()
self._logged_in.clear()
self._startup_error = None
self._set_connection_status('connecting')
await self.logger.info('itchat adapter starting...')
# Register itchat message handlers BEFORE calling itchat.auto_login()
self._register_itchat_handlers()
# Run itchat in a daemon thread (it blocks)
def _run_itchat():
try:
status_path = self._login_status_path()
if not os.path.exists(status_path):
self._startup_error = (
f'No cached WeChat session found at {status_path}. '
'Please scan the QR code in the bot config page first.'
)
self._set_connection_status('error', self._startup_error)
self._log_sync(self._startup_error, 'error')
self._logged_in.set()
return
# Use hotReload to reuse the cached session from QR login
# If no cache exists, fail fast instead of triggering QR login
result = self._core.load_login_status(
status_path, loginCallback=self._on_login, exitCallback=self._on_exit
)
if result.get('BaseResponse', {}).get('Ret') != 0:
self._startup_error = (
f'Cached WeChat session at {status_path} is invalid. '
'Please scan the QR code in the bot config page again.'
)
self._set_connection_status('error', self._startup_error)
self._log_sync(self._startup_error, 'error')
self._logged_in.set()
return
# Session loaded, start message loop
self._log_sync(f'WeChat session loaded from cache: {status_path}')
# Clear stale messages that itchat fetched during hot-reload
self._drain_msglist()
self._core.run(blockThread=True)
self._set_connection_status('disconnected')
self._log_sync('itchat message loop stopped', 'error')
except Exception as e:
error = f'itchat run error: {e}'
self._set_connection_status('error', error)
self._log_sync(error, 'error')
self._logged_in.set()
self._itchat_thread = threading.Thread(target=_run_itchat, daemon=True, name='itchat-thread')
self._itchat_thread.start()
# Wait for login to complete (with timeout)
await asyncio.get_event_loop().run_in_executor(None, lambda: self._logged_in.wait(timeout=300))
if not self._logged_in.is_set():
raise RuntimeError('itchat login timed out (300s)')
if self._startup_error:
raise RuntimeError(self._startup_error)
await self.logger.info(f'itchat adapter running, bot: {self.bot_account_id}')
# Keep the adapter alive
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
pass
async def kill(self) -> bool:
"""Stop the itchat adapter."""
try:
self._core.alive = False
self._core.isLogging = False
except Exception:
pass
self._set_connection_status('disconnected')
await self.logger.info('itchat adapter stopped')
return True
@@ -0,0 +1,75 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: itchat
label:
en_US: Itchat WeChat
zh_Hans: 个人微信 (itchat)
zh_Hant: 個人微信 (itchat)
ja_JP: 個人WeChat (itchat)
description:
en_US: Personal WeChat adapter via itchat-uos, supports QR code login and text/image/voice messages
zh_Hans: 基于 itchat-uos 的个人微信适配器,扫码登录,支持文本/图片/语音消息
zh_Hant: 基於 itchat-uos 的個人微信適配器,掃碼登入,支援文字/圖片/語音訊息
icon: wechat.png
spec:
categories:
- china
help_links:
zh: https://github.com/littlecodersh/ItChat
en: https://github.com/littlecodersh/ItChat
config:
- name: qr-login
label:
en_US: Scan QR Login
zh_Hans: 扫码登录
zh_Hant: 掃碼登入
description:
en_US: Scan QR code with WeChat to login. The session will be cached for the adapter to reuse.
zh_Hans: 使用微信扫码登录,登录状态将被缓存供适配器复用
zh_Hant: 使用微信掃碼登入,登入狀態將被快取供適配器復用
type: qr-code-login
login_platform: itchat
required: false
- name: account_id
label:
en_US: Bot Account ID
zh_Hans: 机器人账号标识
zh_Hant: 機器人帳號標識
ja_JP: ボットアカウントID
description:
en_US: Auto-filled after QR login with the WeChat wxid. Used for @-mention matching and to load data/itchat/<account_id>.pkl; do not change it to a nickname.
zh_Hans: 扫码登录后自动填入微信 wxid。用于群聊 @ 匹配,并加载 data/itchat/<account_id>.pkl;不要改成昵称。
zh_Hant: 掃碼登入後自動填入微信 wxid。用於群聊 @ 匹配,並載入 data/itchat/<account_id>.pkl;不要改成暱稱。
ja_JP: QRログイン後にWeChatのwxidが自動入力されます。@メンション判定と data/itchat/<account_id>.pkl の読み込みに使うため、ニックネームへ変更しないでください。
type: string
required: true
default: ""
- name: nickname
label:
en_US: Bot Nickname
zh_Hans: 机器人昵称
zh_Hant: 機器人暱稱
description:
en_US: The display nickname of the bot. Used to strip @nickname from incoming group messages. Auto-filled after QR login.
zh_Hans: 机器人的微信昵称。用于删除群聊消息中的 @昵称 前缀。扫码登录后自动填入
zh_Hant: 機器人的微信暱稱。用於刪除群聊訊息中的 @暱稱 前綴。掃碼登入後自動填入
type: string
required: false
default: ""
- name: hot_reload
label:
en_US: Hot Reload
zh_Hans: 登录缓存
zh_Hant: 登入快取
description:
en_US: Persist login session to avoid repeated QR code scans on restart
zh_Hans: 保存登录状态到本地,重启后无需重新扫码
zh_Hant: 儲存登入狀態到本機,重啟後無需重新掃碼
type: boolean
required: false
default: true
execution:
python:
path: ./itchat.py
attr: ItchatAdapter
+12 -61
View File
@@ -25,7 +25,6 @@ from linebot.v3.webhooks import (
ImageMessageContent,
VideoMessageContent,
AudioMessageContent,
UserMentionee,
)
# from linebot import WebhookParser
@@ -59,19 +58,15 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
return content_list
def __init__(self, bot_account_id: str = ''):
self.bot_account_id = bot_account_id
async def target2yiri(self, message, bot_client) -> platform_message.MessageChain:
@staticmethod
async def target2yiri(message, bot_client) -> platform_message.MessageChain:
lb_msg_list = []
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
if isinstance(message.message, TextMessageContent):
lb_msg_list.extend(
self._build_text_components(message.message.text, getattr(message.message, 'mention', None))
)
lb_msg_list.append(platform_message.Plain(text=message.message.text))
elif isinstance(message.message, AudioMessageContent):
pass
elif isinstance(message.message, VideoMessageContent):
@@ -91,60 +86,22 @@ class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
lb_msg_list.append(platform_message.Image(base64=data_uri))
return platform_message.MessageChain(lb_msg_list)
def _build_text_components(self, text: str, mention) -> list:
"""Build message components from text, inserting At components for mentions.
LINE provides mention positions (index/length) and is_self per mentionee in the
webhook payload. Mapping the bot mention to At(target=bot_account_id) makes the
'at-bot' group respond rule work for LINE, consistent with other adapters.
"""
components: list = []
if not mention or not mention.mentionees:
if text:
components.append(platform_message.Plain(text=text))
return components
segments: list[tuple[int, int, object]] = sorted((m.index, m.index + m.length, m) for m in mention.mentionees)
cursor = 0
for start, end, mentionee in segments:
if start < cursor:
start, end = cursor, min(end, len(text))
if start < cursor or end <= start or end > len(text):
continue
if start > cursor:
components.append(platform_message.Plain(text=text[cursor:start]))
if isinstance(mentionee, UserMentionee):
target = self.bot_account_id if mentionee.is_self else mentionee.user_id
if not target:
target = text[start:end]
else:
target = text[start:end]
# At.__str__ already prepends '@', so strip one from the LINE text token.
display = text[start:end].lstrip('@')
components.append(platform_message.At(target=str(target), display=display))
cursor = end
if cursor < len(text):
components.append(platform_message.Plain(text=text[cursor:]))
return components
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
def __init__(self, bot_account_id: str = ''):
self.bot_account_id = bot_account_id
self.message_converter = LINEMessageConverter(bot_account_id)
@staticmethod
async def yiri2target(
event: platform_events.MessageEvent,
) -> MessageEvent:
pass
async def target2yiri(self, event, bot_client) -> platform_events.Event:
message_chain = await self.message_converter.target2yiri(event, bot_client)
@staticmethod
async def target2yiri(event, bot_client) -> platform_events.Event:
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
if event.source.type == 'user':
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=event.source.user_id,
id=event.message.id,
nickname=event.source.user_id,
remark='',
),
@@ -153,19 +110,13 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
source_platform_object=event,
)
else:
# 'group' and 'room' sources carry the stable chat id under different
# field names; user_id may be absent for some members, so fall back
# to the group/room id rather than the per-message id.
group_id = event.source.group_id if event.source.type == 'group' else event.source.room_id
member_id = event.source.user_id or group_id
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=member_id,
member_name=member_id,
id=event.event.sender.sender_id.open_id,
member_name=event.event.sender.sender_id.union_id,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=group_id,
id=event.message.id,
name='',
permission=platform_entities.Permission.Member,
),
@@ -212,8 +163,8 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
listeners={},
card_id_dict={},
seq=1,
event_converter=LINEEventConverter(bot_account_id),
message_converter=LINEMessageConverter(bot_account_id),
event_converter=LINEEventConverter(),
message_converter=LINEMessageConverter(),
line_webhook=line_webhook,
parser=parser,
configuration=configuration,
+29 -50
View File
@@ -329,12 +329,17 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
content_type = content.get('type', 'text')
if content_type == 'text':
if target_type in {'c2c', 'group'}:
await self._send_c2c_or_group_text_reply(
target_type,
if target_type == 'c2c':
await self.bot.send_private_text_msg(
target_id,
content['content'],
msg_id=qq_official_event.d_id,
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,
)
elif content_type == 'image':
@@ -378,39 +383,6 @@ 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],
@@ -678,13 +650,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
ctx['session_started'] = True
# `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:
# 发送内容 = 全量累积文本
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
# - 首次:发送全部文本,建立会话
# - 后续:只能发送新增部分(append 行为)
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
if not content_to_send and not is_final:
return
content_to_send = ctx['accumulated_text']
input_state = 10 if is_final else 1
@@ -806,13 +778,20 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
return
try:
await self._send_c2c_or_group_text_reply(
target_type,
target_id,
text,
event_id=event_id,
msg_seq=msg_seq,
)
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,
)
except Exception:
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
@@ -95,18 +95,6 @@ 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
+19 -107
View File
@@ -3,10 +3,8 @@ 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
@@ -26,24 +24,11 @@ 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):
"""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] = []
content = ''
for msg in message_chain:
if type(msg) is platform_message.Plain:
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
content += msg.text
return content
@staticmethod
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
@@ -377,76 +362,13 @@ 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,
):
items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
content = await self.message_converter.yiri2target(message)
_ws_mode = not self.config.get('enable-webhook', False)
event = message_source.source_platform_object
@@ -460,7 +382,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
else:
chat_id = str(message_source.sender.id)
try:
await self.bot.send_message(chat_id, text)
await self.bot.send_message(chat_id, content)
except Exception:
await self.logger.error(
f'WeComBot: proactive reply for synthetic event failed: {traceback.format_exc()}'
@@ -474,15 +396,12 @@ 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 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)
if req_id:
await self.bot.reply_text(req_id, content)
else:
await self.bot.set_message(event.message_id, content)
else:
await self.bot.set_message(event.message_id, text)
await self.bot.set_message(event.message_id, content)
async def reply_message_chunk(
self,
@@ -492,8 +411,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
quote_origin: bool = False,
is_final: bool = False,
):
items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
content = await self.message_converter.yiri2target(message)
_ws_mode = not self.config.get('enable-webhook', False)
# Synthetic events (e.g. button-click triggered form resume) have
@@ -502,7 +420,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, text, is_final, _ws_mode)
return await self._handle_synthetic_chunk(message_source, bot_message, content, is_final, _ws_mode)
msg_id = spo.message_id
@@ -534,7 +452,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
form_data.get('actions', []) or [],
)
except Exception:
fallback = text or '(人工输入)'
fallback = content 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', '')
@@ -545,22 +463,17 @@ 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, text, is_final=is_final)
success = await self.bot.push_stream_chunk(msg_id, content, 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, 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)
await self.bot.reply_text(req_id, content)
return {'stream': success}
else:
success = await self.bot.push_stream_chunk(msg_id, text, is_final=is_final)
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
if not success and is_final:
await self.bot.set_message(msg_id, text)
await self.bot.set_message(msg_id, content)
return {'stream': success}
async def is_stream_output_supported(self) -> bool:
@@ -714,9 +627,8 @@ 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:
items = await self.message_converter.yiri2target(message)
text = self._join_text_components(items)
await self.bot.send_message(target_id, text)
content = await self.message_converter.yiri2target(message)
await self.bot.send_message(target_id, content)
else:
pass
+3 -10
View File
@@ -107,7 +107,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
if event.type == 'text':
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
friend = platform_entities.Friend(
id=f'{event.receiver_id}|u{event.user_id}',
id=f'u{event.user_id}',
nickname=nickname,
remark='',
)
@@ -117,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
)
elif event.type == 'image':
friend = platform_entities.Friend(
id=f'{event.receiver_id}|u{event.user_id}',
id=f'u{event.user_id}',
nickname=nickname,
remark='',
)
@@ -197,7 +197,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
for content in content_list:
msgid = f'{uuid.uuid4().hex}'
msgid = f'langbot_{uuid.uuid4().hex}'
if content['type'] == 'text':
await self.bot.send_text_msg(
open_kfid=open_kfid,
@@ -205,13 +205,6 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
msgid=msgid,
content=content['content'],
)
elif content['type'] == 'image':
await self.bot.send_image_msg(
open_kfid=open_kfid,
external_userid=external_userid,
msgid=msgid,
media_id=content['media_id'],
)
def set_bot_uuid(self, bot_uuid: str):
"""设置 bot UUID(用于生成 webhook URL"""
+2 -19
View File
@@ -264,11 +264,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
if not self._control_token and allow_generate:
self._control_token = secrets.token_urlsafe(48)
if not self._control_token:
if self.runtime_profile == 'shared':
raise PluginRuntimeNotConnectedError(
f'{PLUGIN_RUNTIME_CONTROL_TOKEN_ENV} must be configured with a strong shared secret '
'for a Cloud Plugin Runtime'
)
return {}
try:
self._control_token = validate_runtime_secret(
@@ -701,13 +696,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
}
self._known_desired_states.update({state.binding.installation_uuid: state for state in desired_states})
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,
)
result = await runtime_handler.reconcile_plugin_installations(tuple(self._known_desired_states.values()))
await self._repair_reconcile_missing_artifacts(self._known_desired_states, result)
self._record_reconcile_failures(self._known_desired_states, result)
@@ -742,13 +731,7 @@ 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
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,
)
result = await runtime_handler.reconcile_plugin_installations(tuple(all_states.values()))
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()):
+14 -120
View File
@@ -11,8 +11,6 @@ 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
@@ -433,19 +431,6 @@ 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,
@@ -911,82 +896,25 @@ 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 storage is not None:
if result.first() 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)
)
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,
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,
)
)
.on_conflict_do_update(
index_elements=['workspace_uuid', 'unique_key'],
set_={'value': value},
)
)
return handler.ActionResponse.success(
data={},
@@ -1018,29 +946,6 @@ 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',
@@ -1076,19 +981,10 @@ 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.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)
.where(persistence_bstorage.BinaryStorage.unique_key == unique_key)
)
return handler.ActionResponse.success(
@@ -1116,7 +1012,7 @@ class RuntimeConnectionHandler(handler.Handler):
return handler.ActionResponse.success(
data={
'keys': list(dict.fromkeys(result.scalars().all())),
'keys': result.scalars().all(),
},
)
@@ -1677,15 +1573,13 @@ 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=timeout,
timeout=120,
)
async def apply_plugin_installation(
@@ -747,24 +747,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
converted_parts = []
for part in content:
if isinstance(part, dict) and part.get('type') == 'image_base64':
# History trimming (SessionManager) clears image_base64
# on past turns and exclude_none serialization drops
# the key entirely, so the replayed part may carry no
# payload. Prefer the base64 payload; fall back to an
# image_url that survived on the same element; drop
# hollow parts instead of raising KeyError (#2469).
image_b64 = part.get('image_base64')
fallback_url = None
if not image_b64:
raw_image_url = part.get('image_url')
if isinstance(raw_image_url, dict):
fallback_url = raw_image_url.get('url')
if image_b64 or fallback_url:
part['image_url'] = {'url': image_b64 or fallback_url}
part['type'] = 'image_url'
part.pop('image_base64', None)
else:
continue
part['image_url'] = {'url': part['image_base64']}
part['type'] = 'image_url'
del part['image_base64']
# OpenAI-compatible chat models reject non-image file parts
# (audio/document base64 or url). These originate from Voice /
# File attachments — including ones replayed from conversation
@@ -24,10 +24,7 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
try:
import pyseekdb
except ImportError:
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)."
)
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
self._embedding_function = pyseekdb.get_default_embedding_function()
@@ -619,9 +619,7 @@ class LocalAgentRunner(runner.RequestRunner):
and len(func_ret) > 0
and isinstance(func_ret[0], provider_message.ContentElement)
):
# OpenAI-compatible APIs require tool-message content to be a
# string; a raw list of ContentElement causes HTTP 500 (#2457).
tool_content = '\n'.join(str(ce) for ce in func_ret)
tool_content = func_ret
else:
tool_content = json.dumps(func_ret, ensure_ascii=False)
@@ -222,7 +222,6 @@ class NativeToolLoader(loader.ToolLoader):
self.ap.logger.warning(
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
'No sandbox backend (Docker/nsjail/E2B) is ready. '
'Trusted local development may explicitly select box.backend=host. '
'The LLM will not have access to code execution or file operation tools.'
)
@@ -42,8 +42,7 @@ class SkillToolLoader(loader.ToolLoader):
else:
self.ap.logger.info(
'Skill tools (activate/register_skill) are NOT available. '
'No sandbox backend (Docker/nsjail/E2B) is ready. '
'Trusted local development may explicitly select box.backend=host.'
'No sandbox backend (Docker/nsjail/E2B) is ready.'
)
async def _check_sandbox_available(self) -> bool:
+4 -13
View File
@@ -27,16 +27,10 @@ class SafeRegexTimeoutError(SafeRegexError):
"""Raised when the regex engine exhausts the operation CPU budget."""
def _validate_patterns(
patterns: Sequence[str],
*,
max_pattern_count: int = MAX_PATTERN_COUNT,
) -> tuple[str, ...]:
if max_pattern_count < 1:
raise ValueError('max_pattern_count must be positive')
if len(patterns) > max_pattern_count:
raise SafeRegexLimitError(f'At most {max_pattern_count} regex patterns are allowed')
def _validate_patterns(patterns: Sequence[str]) -> tuple[str, ...]:
normalized = tuple(patterns)
if len(normalized) > MAX_PATTERN_COUNT:
raise SafeRegexLimitError(f'At most {MAX_PATTERN_COUNT} regex patterns are allowed')
for pattern in normalized:
if not isinstance(pattern, str):
raise SafeRegexError('Regex patterns must be strings')
@@ -121,9 +115,8 @@ def _mask_patterns_sync(
mask: str,
mask_word: str,
timeout_seconds: float,
max_pattern_count: int,
) -> tuple[bool, str]:
normalized_patterns = _validate_patterns(patterns, max_pattern_count=max_pattern_count)
normalized_patterns = _validate_patterns(patterns)
_validate_input(value)
if len(mask) > MAX_REPLACEMENT_CHARS or len(mask_word) > MAX_REPLACEMENT_CHARS:
raise SafeRegexLimitError(f'Regex replacements may contain at most {MAX_REPLACEMENT_CHARS} characters')
@@ -169,7 +162,6 @@ async def mask_patterns(
mask: str,
mask_word: str,
timeout_seconds: float = DEFAULT_OPERATION_TIMEOUT_SECONDS,
max_pattern_count: int = MAX_PATTERN_COUNT,
) -> tuple[bool, str]:
"""Apply untrusted masking patterns with bounded CPU and output growth."""
@@ -182,5 +174,4 @@ async def mask_patterns(
mask=mask,
mask_word=mask_word,
timeout_seconds=timeout_seconds,
max_pattern_count=max_pattern_count,
)
+1 -4
View File
@@ -42,10 +42,7 @@ class SeekDBVectorDatabase(VectorDatabase):
def __init__(self, ap: app.Application):
if not SEEKDB_AVAILABLE:
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)."
)
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
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 join a Workspace in LangBot\n\n'
'You have been invited to LangBot Cloud\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,77 +258,30 @@ class InvitationDeliveryService:
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Join {escaped_workspace} in LangBot</title>
<title>Join {escaped_workspace} on LangBot Cloud</title>
</head>
<body style="margin:0;padding:0;background:#f4f7fb;color:#111827;font-family:Arial,'Helvetica Neue',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} in LangBot.</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;background:#f4f7fb;">
<tr>
<td align="center" style="padding:48px 16px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width:100%;max-width:600px;">
<tr>
<td style="padding:0 4px 20px;">
<img src="https://docs.langbot.app/langbot-logo.png" alt="LangBot" width="34" height="34" style="display:inline-block;width:34px;height:34px;border:0;vertical-align:middle;">
<span style="display:inline-block;margin-left:10px;vertical-align:middle;font-size:18px;font-weight:700;letter-spacing:-.01em;">LangBot</span>
</td>
</tr>
<tr>
<td style="background:#ffffff;border-radius:10px;overflow:hidden;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding:42px 42px 38px;">
<div style="margin:0 0 12px;font-size:13px;line-height:1.4;font-weight:600;color:#5f6f84;">Workspace invitation</div>
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.25;font-weight:700;letter-spacing:-.025em;color:#111827;">Youre invited to collaborate</h1>
<p style="margin:0 0 28px;font-size:15px;line-height:1.7;color:#526173;">Join your team in LangBot and start building together in this Workspace.</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#f6f8fb;border-radius:8px;">
<tr>
<td style="padding:16px 18px;">
<div style="margin:0 0 4px;font-size:11px;line-height:1.4;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#5f6f84;">Workspace</div>
<div style="font-size:18px;line-height:1.4;font-weight:700;color:#111827;">{escaped_workspace}</div>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr><td height="28" style="height:28px;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="background:#2563eb;border-radius:8px;">
<a href="{escaped_link}" target="_blank" style="display:inline-block;padding:13px 22px;font-size:15px;line-height:1.2;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">Accept invitation</a>
</td>
</tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr><td height="32" style="height:32px;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="border-top:1px solid #e8edf4;">
<tr>
<td style="padding-top:22px;">
<p style="margin:0 0 10px;font-size:13px;line-height:1.6;color:#5f6f84;">For your security, this invitation expires in 7 days and only works for the email address that received it.</p>
<a href="{escaped_link}" target="_blank" style="font-size:13px;line-height:1.6;font-weight:600;color:#2563eb;text-decoration:none;">Open invitation link&nbsp;&rarr;</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding:20px 24px 0;font-size:12px;line-height:1.6;color:#5f6f84;">
Sent by LangBot<br>
If you were not expecting this invitation, you can safely ignore this email.
</td>
</tr>
</table>
</td>
</tr>
<body style="margin:0;background:#f4f7fb;color:#152033;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">You have been invited to join {escaped_workspace} on LangBot Cloud.</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:40px 16px;">
<tr><td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border:1px solid #e5eaf2;border-radius:16px;overflow:hidden;box-shadow:0 12px 32px rgba(20,49,93,.08);">
<tr><td style="padding:28px 36px;background:linear-gradient(135deg,#0f172a,#1d4ed8);color:#ffffff;">
<div style="font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;opacity:.78;">LangBot Cloud</div>
<div style="font-size:26px;font-weight:700;margin-top:8px;line-height:1.25;">Youre invited</div>
</td></tr>
<tr><td style="padding:36px;">
<p style="margin:0 0 18px;font-size:16px;line-height:1.65;color:#475569;">You have been invited to collaborate in this Workspace:</p>
<div style="margin:0 0 26px;padding:18px 20px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;font-size:18px;font-weight:700;color:#0f172a;">{escaped_workspace}</div>
<table role="presentation" cellspacing="0" cellpadding="0"><tr><td style="border-radius:9px;background:#2563eb;">
<a href="{escaped_link}" style="display:inline-block;padding:13px 22px;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Accept invitation</a>
</td></tr></table>
<p style="margin:26px 0 8px;font-size:14px;line-height:1.6;color:#64748b;">This invitation expires in 7 days and is bound to the email address that received it.</p>
<p style="margin:0 0 8px;font-size:13px;line-height:1.6;color:#94a3b8;">If the button does not work, copy and paste this URL into your browser:</p>
<p style="margin:0;padding:12px;background:#f8fafc;border-radius:8px;word-break:break-all;font-size:12px;line-height:1.55;color:#475569;">{escaped_link}</p>
</td></tr>
<tr><td style="padding:20px 36px;border-top:1px solid #eef2f7;font-size:12px;line-height:1.6;color:#94a3b8;">If you were not expecting this invitation, you can safely ignore this email.</td></tr>
</table>
</td></tr>
</table>
</body>
</html>'''
+1 -9
View File
@@ -181,11 +181,6 @@ 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:
@@ -331,10 +326,7 @@ box:
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
# be listed read-only and http/sse MCP servers continue to work.
enabled: true
# 'host' runs commands directly as the Box Runtime user without sandbox
# isolation. It is never auto-selected and is only for trusted local
# development. Can be written via BOX__BACKEND.
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', 'e2b', or explicit unsafe 'host'.
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
runtime:
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
# runtimes. To protect an exposed endpoint, set the same strong secret
-20
View File
@@ -7,9 +7,6 @@
// Read config from script tag data attributes
var scriptEl = document.currentScript;
var scriptTitle = scriptEl ? scriptEl.getAttribute("data-title") : null;
var scriptTestNotice = scriptEl
? scriptEl.getAttribute("data-test-notice")
: null;
// ========== i18n ==========
var I18N = {
@@ -195,7 +192,6 @@
.lb-header-btn { background: none; border: none; color: #fff; cursor: pointer; padding: 4px; border-radius: 6px; display: flex; align-items: center; justify-content: center; opacity: 0.8; transition: opacity 0.15s; }\
.lb-header-btn:hover { opacity: 1; }\
.lb-header-btn svg { width: 18px; height: 18px; fill: currentColor; }\
.lb-test-notice { padding: 8px 16px; border-bottom: 1px solid #fde68a; background: #fffbeb; color: #92400e; font-size: 12px; line-height: 1.5; text-align: center; flex-shrink: 0; }\
.lb-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; scroll-behavior: smooth; }\
.lb-messages::-webkit-scrollbar { width: 6px; }\
.lb-messages::-webkit-scrollbar-track { background: transparent; }\
@@ -1244,14 +1240,6 @@
// Root container
var root = document.createElement("div");
root.id = "langbot-widget-root";
root.langbotDestroy = function () {
wsDisconnect();
if (state.historyReloadTimer) {
clearTimeout(state.historyReloadTimer);
state.historyReloadTimer = null;
}
root.remove();
};
document.body.appendChild(root);
var shadow = root.attachShadow({ mode: "open" });
@@ -1340,14 +1328,6 @@
header.appendChild(headerActions);
panel.appendChild(header);
if (scriptTestNotice) {
var testNotice = document.createElement("div");
testNotice.className = "lb-test-notice";
testNotice.setAttribute("role", "note");
testNotice.textContent = scriptTestNotice;
panel.appendChild(testNotice);
}
// Messages area
var messages = document.createElement("div");
messages.className = "lb-messages";
@@ -325,7 +325,7 @@ stages:
zh_Hans: API 密钥
type: string
required: true
default: ''
default: 'your-api-key'
- name: n8n-service-api
label:
en_US: n8n Workflow API
-16
View File
@@ -254,22 +254,6 @@ class TestPipelinesCRUDEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
@pytest.mark.asyncio
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
fake_pipeline_app.pipeline_service.create_pipeline.reset_mock()
response = await quart_test_client.post(
'/api/v1/pipelines',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'Default Pipeline', 'config': {}, 'is_default': True},
)
assert response.status_code == 200
call = fake_pipeline_app.pipeline_service.create_pipeline.await_args
assert call.kwargs == {'default': True}
assert call.args[1]['is_default'] is True
@pytest.mark.asyncio
async def test_update_pipeline_success(self, quart_test_client):
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
-23
View File
@@ -307,7 +307,6 @@ class TestUserInitEndpoint:
assert data['data'] == {
'initialized': True,
'authenticated_invitation_acceptance_enabled': False,
'invitation_registration_enabled': True,
'password_login_enabled': True,
'space_login_enabled': False,
}
@@ -331,28 +330,6 @@ 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,
}
+25 -65
View File
@@ -165,51 +165,34 @@ async def test_bind_state_is_account_bound_and_requires_authentication(space_oau
@pytest.mark.asyncio
async def test_redirect_allows_any_http_or_https_origin(space_oauth_api):
async def test_redirect_origin_and_callback_path_are_restricted(space_oauth_api):
_, client = space_oauth_api
responses = [
await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': redirect_uri},
headers={'Origin': 'https://irrelevant.example'},
)
for redirect_uri in (
'https://langbot.example/auth/space/callback',
'https://gateway.example:8443/auth/space/callback',
'https://192.0.2.10/auth/space/callback',
'http://localhost:5300/auth/space/callback',
'http://127.0.0.1:5300/auth/space/callback',
'http://[::1]:5300/auth/space/callback',
'http://langbot.example/auth/space/callback',
'http://192.0.2.10:5300/auth/space/callback',
)
]
wrong_origin = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Origin': 'http://localhost'},
)
wrong_path = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'http://localhost/arbitrary'},
headers={'Origin': 'http://localhost'},
)
forged_origin = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Origin': 'https://evil.example'},
)
forged_host = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Host': 'evil.example'},
)
assert all(response.status_code == 200 for response in responses)
payloads = [await response.get_json() for response in responses]
assert all(payload['code'] == 0 for payload in payloads)
@pytest.mark.asyncio
async def test_redirect_rejects_invalid_callback_shape(space_oauth_api):
_, client = space_oauth_api
responses = [
await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': redirect_uri},
)
for redirect_uri in (
'https://langbot.example/arbitrary',
'https://langbot.example/auth/space/callback?next=https://evil.example',
'https://user@langbot.example/auth/space/callback',
'https://langbot.example/auth/space/callback#fragment',
)
]
payloads = [await response.get_json() for response in responses]
assert all(payload['code'] == 1 for payload in payloads)
assert (await wrong_origin.get_json())['code'] == 1
assert (await wrong_path.get_json())['code'] == 1
assert (await forged_origin.get_json())['code'] == 1
assert (await forged_host.get_json())['code'] == 1
@pytest.mark.asyncio
@@ -312,29 +295,6 @@ 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,7 +81,6 @@ 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,
@@ -211,13 +210,7 @@ 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',
value=b'legacy-plugin-value',
)
binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo')
)
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'))
@@ -115,7 +115,6 @@ class _CapacityPluginRuntimeHandler:
def __init__(self) -> None:
self.bindings: dict[str, typing.Any] = {}
self.reconciled: tuple[typing.Any, ...] = ()
self.reconcile_timeout: float | None = None
def register_installation_binding(
self,
@@ -133,14 +132,8 @@ class _CapacityPluginRuntimeHandler:
def unregister_installation_binding(self, binding) -> None:
self.bindings.pop(binding.installation_uuid, None)
async def reconcile_plugin_installations(
self,
desired_states,
*,
timeout: float | None = None,
) -> dict:
async def reconcile_plugin_installations(self, desired_states) -> dict:
self.reconciled = tuple(desired_states)
self.reconcile_timeout = timeout
return {
'applied': [],
'removed': [],
@@ -1041,7 +1034,6 @@ class TestPostgreSQLTenantRuntime:
assert not mcp_loader._hosted_mcp_tasks
assert len(plugin_handler.reconciled) == workspace_count
assert len(plugin_handler.bindings) == workspace_count
assert plugin_handler.reconcile_timeout == 300.0
assert all(count == workspace_count for count in statement_counts.values()), statement_counts
if max_elapsed is not None:
assert elapsed <= max_elapsed
@@ -76,26 +76,6 @@ 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(
@@ -229,8 +209,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, value) '
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo', X'')"
'(workspace_uuid, unique_key, key, owner_type, owner) '
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')"
),
{'workspace_uuid': second_workspace_uuid},
)
@@ -2,7 +2,6 @@ from __future__ import annotations
import json
import logging
import os
import pathlib
import sqlite3
@@ -10,7 +9,7 @@ import pytest
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.persistence import alembic_runner, sqlite_migration_backup
from langbot.pkg.persistence import alembic_runner
from langbot.pkg.persistence.mgr import PersistenceManager
from .resource_migration_support import create_legacy_resource_schema
@@ -39,35 +38,6 @@ def _assert_verified_backup(payload: dict) -> None:
assert connection.execute('SELECT version_num FROM alembic_version').fetchone()[0] == payload['source_revision']
def _temporary_sqlite_files(root: pathlib.Path) -> list[pathlib.Path]:
return [*root.rglob('*.creating'), *root.rglob('*.restoring')]
async def test_backup_removes_stale_temporary_file_from_interrupted_run(tmp_path):
database_path = tmp_path / 'legacy-stale-backup.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
try:
await create_legacy_resource_schema(engine, instance_uuid='stale-backup')
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
backup_directory = tmp_path / 'migration-backups'
backup_directory.mkdir()
stale_path = backup_directory / '.legacy-stale-backup-pre-0009-old.creating'
unrelated_path = backup_directory / '.another-database-pre-0009-old.creating'
stale_path.write_bytes(b'interrupted backup')
unrelated_path.write_bytes(b'unrelated backup')
await sqlite_migration_backup.create_verified_backup(
engine,
source_revision='0008_mcp_resource_prefs',
target_revision='0009_workspace_tenancy',
)
assert not stale_path.exists()
assert unrelated_path.read_bytes() == b'unrelated backup'
finally:
await engine.dispose()
async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
database_path = tmp_path / 'legacy-with-backups.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
@@ -88,7 +58,6 @@ async def test_tenancy_migrations_retain_verified_boundary_backups(tmp_path):
}
for payload in payloads:
_assert_verified_backup(payload)
assert _temporary_sqlite_files(tmp_path) == []
finally:
await engine.dispose()
@@ -130,73 +99,9 @@ async def test_failed_tenancy_migration_restores_backup_and_revision(
assert restored[0]['status'] == 'restored_after_failure'
assert restored[0]['source_revision'] == '0009_workspace_tenancy'
_assert_verified_backup(restored[0])
assert _temporary_sqlite_files(tmp_path) == []
monkeypatch.setattr(alembic_runner, 'run_alembic_upgrade', real_upgrade)
await _manager(engine)._run_alembic_migrations()
assert await alembic_runner.get_alembic_current(engine) == alembic_runner.get_alembic_head()
finally:
await engine.dispose()
async def test_restore_publish_failure_preserves_current_database(tmp_path, monkeypatch):
database_path = tmp_path / 'restore-publish-failure.db'
engine = create_async_engine(f'sqlite+aiosqlite:///{database_path}')
try:
await create_legacy_resource_schema(engine, instance_uuid='restore-publish-failure')
await alembic_runner.run_alembic_stamp(engine, '0008_mcp_resource_prefs')
backup = await sqlite_migration_backup.create_verified_backup(
engine,
source_revision='0008_mcp_resource_prefs',
target_revision='0009_workspace_tenancy',
)
stale_restore_path = tmp_path / f'.{database_path.name}.interrupted.restoring'
stale_restore_path.write_bytes(b'interrupted restore')
async with engine.begin() as connection:
await connection.execute(sa.text("UPDATE alembic_version SET version_num = 'failed-revision'"))
await engine.dispose()
database_before_restore = database_path.read_bytes()
real_replace = os.replace
def fail_restore_publish(source, destination):
if pathlib.Path(destination) == database_path:
raise OSError('simulated atomic publish failure')
return real_replace(source, destination)
monkeypatch.setattr(sqlite_migration_backup.os, 'replace', fail_restore_publish)
with pytest.raises(OSError, match='atomic publish failure'):
await sqlite_migration_backup.restore_verified_backup(engine, backup)
assert database_path.read_bytes() == database_before_restore
assert _temporary_sqlite_files(tmp_path) == []
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,17 +179,13 @@ 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 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 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')
@@ -197,8 +193,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()
@@ -415,45 +411,6 @@ 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:
@@ -468,7 +425,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},
@@ -476,7 +433,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},
)
@@ -485,16 +442,12 @@ 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()
@@ -9,9 +9,8 @@ Source: src/langbot/pkg/api/http/service/bot.py
from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from unittest.mock import AsyncMock, Mock, patch
from types import SimpleNamespace
import json
import uuid
from langbot.pkg.api.http.service.bot import BotService
@@ -242,29 +241,6 @@ class TestBotServiceGetRuntimeBotInfo:
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
async def test_get_runtime_bot_info_returns_webhook_for_http_bot(self):
ap = SimpleNamespace(
instance_config=SimpleNamespace(
data={'api': {'webhook_prefix': 'https://bot.example.com'}}
),
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
)
service = BotService(ap)
service.get_bot = AsyncMock(
return_value={
'uuid': 'http-bot-uuid',
'name': 'HTTP Bot',
'adapter': 'http_bot',
'adapter_config': {},
}
)
result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'http-bot-uuid')
assert result['adapter_runtime_values']['webhook_full_url'] == (
'https://bot.example.com/bots/http-bot-uuid'
)
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
"""Returns no webhook URL for non-webhook adapters like telegram."""
# Setup
@@ -629,77 +605,6 @@ class TestBotServiceListEventLogs:
assert total == 5
class TestBotServiceHttpBotInboundTest:
async def test_sends_signed_message_through_public_ingress(self):
ap = SimpleNamespace(
instance_config=SimpleNamespace(data={'api': {'port': 5300}}),
)
service = BotService(ap)
service.get_bot = AsyncMock(
return_value={
'uuid': 'http-bot-uuid',
'adapter': 'http_bot',
'adapter_config': {
'signature_required': True,
'inbound_secret': 'test-secret',
},
'enable': True,
}
)
response = MagicMock(status=202)
session = MagicMock()
session.post.return_value.__aenter__ = AsyncMock(return_value=response)
session.post.return_value.__aexit__ = AsyncMock(return_value=None)
with (
patch('langbot.pkg.api.http.service.bot.httpclient.get_session', return_value=session),
patch(
'langbot.pkg.api.http.service.bot.httpclient.read_json_limited',
new=AsyncMock(
return_value={
'code': 0,
'data': {
'session_id': 'wizard-session',
'accepted_message_id': 'in-message',
},
}
),
),
):
result = await service.send_http_bot_test_message(
WORKSPACE_UUID,
'http-bot-uuid',
'hello',
)
assert result['accepted_message_id'] == 'in-message'
request = session.post.call_args
assert request.args[0] == 'http://127.0.0.1:5300/bots/http-bot-uuid'
payload = json.loads(request.kwargs['data'])
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
headers = request.kwargs['headers']
assert headers['X-LB-Timestamp']
assert headers['X-LB-Signature'].startswith('sha256=')
async def test_rejects_non_http_bot(self):
service = BotService(SimpleNamespace())
service.get_bot = AsyncMock(
return_value={
'uuid': 'telegram-bot',
'adapter': 'telegram',
'adapter_config': {},
'enable': True,
}
)
with pytest.raises(ValueError, match='only available for HTTP Bot'):
await service.send_http_bot_test_message(
WORKSPACE_UUID,
'telegram-bot',
'hello',
)
class TestBotServiceSendMessage:
"""Tests for send_message method."""
@@ -820,100 +820,6 @@ class TestSpaceServiceGetModels:
await service.get_models()
class TestSpaceServiceGetModelSelection:
"""Tests for availability-ranked model selection."""
@pytest.mark.parametrize('response_shape', ['direct', 'models-envelope', 'availability-wrapper'])
async def test_preserves_selection_order_and_category_query(self, response_shape):
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
service = SpaceService(ap)
models = [
{
'uuid': 'best-model',
'model_id': 'best-chat-model',
'provider': 'provider-1',
'category': 'chat',
'status': 'active',
},
{
'uuid': 'fallback-model',
'model_id': 'fallback-chat-model',
'provider': 'provider-2',
'category': 'chat',
'status': 'active',
},
]
if response_shape == 'models-envelope':
data = {'models': models}
elif response_shape == 'availability-wrapper':
data = [
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
for index, model in enumerate(models)
]
else:
data = models
payload = {'code': 0, 'data': data}
mock_response = MagicMock(status=200)
with (
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
patch(
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
new=AsyncMock(return_value=payload),
),
):
session = MagicMock()
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
get_session.return_value = session
result = await service.get_model_selection('chat')
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
session.get.assert_called_once_with(
'https://space.langbot.app/api/v1/models/selection',
params={'category': 'chat'},
)
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
persistence = SimpleNamespace(
execute_async=AsyncMock(
side_effect=[
_create_mock_result(first_item=None),
_create_mock_result(first_item=local_model),
]
)
)
model_mgr = SimpleNamespace(sync_new_models_from_space=AsyncMock())
ap = SimpleNamespace(
instance_config=SimpleNamespace(data={}),
persistence_mgr=persistence,
model_mgr=model_mgr,
)
service = SpaceService(ap)
service.get_model_selection = AsyncMock(
return_value=[
SimpleNamespace(uuid='best-upstream-uuid', model_id='best-chat-model'),
SimpleNamespace(uuid='fallback-upstream-uuid', model_id='fallback-chat-model'),
]
)
context = SimpleNamespace(
instance_uuid='instance',
workspace_uuid='workspace',
placement_generation=1,
principal=SimpleNamespace(),
entitlement_revision=0,
)
result = await service.get_recommended_chat_model(context)
assert result == {'uuid': 'local-model-uuid', 'name': 'best-chat-model'}
service.get_model_selection.assert_awaited_once_with('chat')
model_mgr.sync_new_models_from_space.assert_awaited_once()
assert persistence.execute_async.await_count == 2
class TestSpaceServiceCreditsCache:
"""Tests for credits cache behavior."""
@@ -377,7 +377,7 @@ class TestUserServiceAuthenticate:
service = UserService(ap)
# Execute & Verify
with pytest.raises(ValueError, match='请使用 LangBot登录'):
with pytest.raises(ValueError, match='请使用 Space登录'):
await service.authenticate('space@example.com', 'password')
@@ -726,7 +726,7 @@ class TestUserServiceCreateOrUpdateSpaceUser:
)
service = UserService(ap)
with pytest.raises(ControlPlaneDirectoryRequiredError, match='LangBot Account'):
with pytest.raises(ControlPlaneDirectoryRequiredError, match='Space account'):
await service.register_invited_account('invite-token', 'member@example.com', 'password')
async def test_create_or_update_new_space_user_first_init(self):
+1 -10
View File
@@ -24,7 +24,7 @@ from langbot.pkg.box.connector import BoxRuntimeConnector
_CONTROL_TOKEN = 'box-control-token-that-is-longer-than-32-bytes'
def make_app(logger: Mock, runtime_endpoint: str = '', *, cloud: bool = False):
def make_app(logger: Mock, runtime_endpoint: str = ''):
return SimpleNamespace(
logger=logger,
workspace_service=SimpleNamespace(instance_uuid='instance-a'),
@@ -42,7 +42,6 @@ def make_app(logger: Mock, runtime_endpoint: str = '', *, cloud: bool = False):
}
}
),
deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'),
)
@@ -316,14 +315,6 @@ def test_external_box_runtime_control_headers_are_tokenless_when_secret_is_unset
assert connector.get_control_headers() == {BOX_INSTANCE_HEADER: 'instance-a'}
def test_cloud_box_runtime_rejects_missing_control_secret(monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410', cloud=True))
with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV):
connector.get_control_headers()
def test_external_box_runtime_rejects_invalid_configured_control_token(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv(BOX_CONTROL_TOKEN_ENV, 'too-short')
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
+18 -48
View File
@@ -2163,38 +2163,25 @@ class TestInboundOutboundRoundTrip:
calls = []
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,
)
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': '',
}
# the rm -rf cleanup call
return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='',
duration_ms=10,
)
return {'ok': True, 'stdout': '', 'stderr': ''}
service.client.execute = AsyncMock(side_effect=fake_client_execute)
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
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
service.execute_tool.assert_awaited_once()
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
assert any('rm -rf' in c for c in calls)
@pytest.mark.asyncio
async def test_collect_outbound_empty_still_clears(self):
@@ -2206,33 +2193,16 @@ class TestInboundOutboundRoundTrip:
calls = []
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,
)
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': ''}
service.client.execute = AsyncMock(side_effect=fake_client_execute)
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
assert await service.collect_outbound_attachments(query) == []
# cleanup (rm -rf) is issued unconditionally now
service.execute_tool.assert_awaited_once()
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
assert any('rm -rf' in c for c in calls)
@pytest.mark.asyncio
async def test_passthrough_noop_when_unavailable(self):
-27
View File
@@ -11,7 +11,6 @@ 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
@@ -394,32 +393,6 @@ 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."""
+1 -41
View File
@@ -87,10 +87,7 @@ async def test_runtime_resource_stats_are_aggregate_and_constant_time() -> None:
app.platform_mgr = SimpleNamespace(_bots_by_key={})
app.pipeline_mgr = SimpleNamespace(_pipelines_by_key={})
app.rag_mgr = SimpleNamespace(knowledge_bases={})
app.plugin_connector = SimpleNamespace(
_known_desired_states={'installation': object()},
_runtime_available=lambda: True,
)
app.plugin_connector = SimpleNamespace(_known_desired_states={'installation': object()})
app.persistence_mgr = SimpleNamespace(
get_resource_stats=lambda: {
'configured_capacity': 20,
@@ -143,40 +140,3 @@ 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()
@@ -964,7 +964,6 @@ async def test_scoped_session_rejects_raw_or_unapproved_sql(
sa.func.date_trunc('hour', sa.column('timestamp')),
sa.func.length(sa.literal('value')),
sa.func.nullif(sa.literal('value'), sa.literal('')),
sa.func.strftime('%Y-%m-%d %H:00', sa.column('timestamp')),
),
sa.select(sa.column('embedding').op('<=>')(sa.literal([0.1]))),
sa.select(sa.cast(sa.column('embedding'), Vector(384))),
@@ -978,19 +977,6 @@ async def test_scoped_sql_structure_allows_only_the_production_vocabulary(statem
_validate_scoped_statement_call((statement,), {})
async def test_scoped_session_executes_sqlite_strftime() -> None:
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
try:
async with TenantUnitOfWork(engine, 'workspace-a') as uow:
result = await uow.session.execute(
sa.select(sa.func.strftime('%Y-%m-%d %H:00', sa.literal('2026-08-28 03:45:00')))
)
assert result.scalar_one() == '2026-08-28 03:00'
finally:
await engine.dispose()
async def test_scoped_sql_rejects_public_execution_options() -> None:
statement = sa.select(sa.literal(1))
with pytest.raises(ScopedSessionTransactionError, match='execution options'):
-113
View File
@@ -1,113 +0,0 @@
"""BanWordFilter regression tests for legacy sensitive-word lists.
v4.10.7 introduced a 64-pattern cap in safe_regex. Older installs still carry
the previous default list (~70 patterns). The filter must keep applying those
rules instead of blocking every message.
"""
from __future__ import annotations
from importlib import import_module
from unittest.mock import Mock
import pytest
from tests.factories import FakeApp
def _load_banwords():
import_module('langbot.pkg.pipeline.pipelinemgr')
banwords = import_module('langbot.pkg.pipeline.cntfilter.filters.banwords')
entities = import_module('langbot.pkg.pipeline.cntfilter.entities')
safe_regex = import_module('langbot.pkg.utils.safe_regex')
return banwords, entities, safe_regex
def _filter_with_words(words: list[str], *, mask: str = '*', mask_word: str = ''):
banwords, entities, _ = _load_banwords()
app = FakeApp()
app.sensitive_meta = Mock()
app.sensitive_meta.data = {
'words': words,
'mask': mask,
'mask_word': mask_word,
}
return banwords.BanWordFilter(app), entities, app
@pytest.mark.asyncio
async def test_legacy_word_list_over_pattern_cap_does_not_block_clean_message():
"""A pre-v4.10.7 word list must not fail closed on every message."""
_, _, safe_regex = _load_banwords()
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT + 6)]
filt, entities, _ = _filter_with_words(words)
result = await filt.process(Mock(), 'hello there, nothing banned')
assert result.level == entities.ResultLevel.PASS
assert result.replacement == 'hello there, nothing banned'
assert result.user_notice == ''
@pytest.mark.asyncio
async def test_legacy_word_list_still_masks_match_beyond_first_batch():
"""Words past the first 64-pattern batch must still be applied."""
_, _, safe_regex = _load_banwords()
words = [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)] + ['secret-token']
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
result = await filt.process(Mock(), 'please hide secret-token now')
assert result.level == entities.ResultLevel.MASKED
assert 'secret-token' not in result.replacement
assert '[hidden]' in result.replacement
@pytest.mark.asyncio
async def test_legacy_word_list_masks_match_in_first_batch():
_, _, safe_regex = _load_banwords()
words = ['alpha-secret'] + [f'word{i}' for i in range(safe_regex.MAX_PATTERN_COUNT)]
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
result = await filt.process(Mock(), 'alpha-secret is here')
assert result.level == entities.ResultLevel.MASKED
assert result.replacement == '[hidden] is here'
@pytest.mark.asyncio
async def test_invalid_sensitive_word_regex_still_blocks():
filt, entities, _ = _filter_with_words(['(unclosed'])
result = await filt.process(Mock(), 'any message')
assert result.level == entities.ResultLevel.BLOCK
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
assert 'rejected' in result.console_notice.lower() or 'invalid' in result.console_notice.lower()
@pytest.mark.asyncio
async def test_oversized_word_list_is_blocked():
"""Configured rules must never be silently skipped when the list is oversized."""
banwords, _, _ = _load_banwords()
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS + 10)]
filt, entities, _ = _filter_with_words(words)
result = await filt.process(Mock(), 'hello there, nothing banned')
assert result.level == entities.ResultLevel.BLOCK
assert result.replacement == ''
assert result.user_notice == '内容检查规则执行失败,请联系管理员'
assert 'at most 256 regex patterns are allowed' in result.console_notice.lower()
@pytest.mark.asyncio
async def test_match_beyond_total_cap_cannot_bypass_filter():
banwords, _, _ = _load_banwords()
words = [f'word{i}' for i in range(banwords._MAX_SENSITIVE_WORD_PATTERNS)] + ['late-secret']
filt, entities, _ = _filter_with_words(words, mask_word='[hidden]')
result = await filt.process(Mock(), 'please hide late-secret now')
assert result.level == entities.ResultLevel.BLOCK
assert result.replacement == ''
@@ -0,0 +1,130 @@
"""Tests for itchat adapter group/private message conversion."""
from __future__ import annotations
from types import SimpleNamespace
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
from langbot.pkg.platform.sources.itchat import ItchatAdapter, ItchatEventConverter
def _make_adapter(bot_account_id: str = '@bot_wxid', bot_nickname: str = 'MyBot'):
adapter = SimpleNamespace(
bot_account_id=bot_account_id,
_bot_nickname=bot_nickname,
_core=SimpleNamespace(storageClass=SimpleNamespace(userName=bot_account_id)),
_get_obj_value=ItchatAdapter._get_obj_value,
)
return adapter
def _make_converter(adapter) -> ItchatEventConverter:
return ItchatEventConverter(adapter_ref=lambda: adapter)
def test_group_text_becomes_group_message():
converter = _make_converter(_make_adapter())
msg = {
'FromUserName': '@@group_wxid',
'Type': 'Text',
'Text': 'hello',
'ActualUserName': '@member_wxid',
'ActualNickName': 'MemberNick',
'IsAt': False,
'CreateTime': 123456,
'User': SimpleNamespace(NickName='Group Name'),
}
event = converter.target2yiri(msg)
assert isinstance(event, platform_events.GroupMessage)
assert isinstance(event.sender, platform_entities.GroupMember)
assert event.sender.id == '@member_wxid'
assert event.sender.member_name == 'MemberNick'
assert event.sender.group.id == '@@group_wxid'
assert event.sender.group.name == 'Group Name'
components = list(event.message_chain)
assert len(components) == 1
assert isinstance(components[0], platform_message.Plain)
assert components[0].text == 'hello'
def test_private_text_becomes_friend_message():
converter = _make_converter(_make_adapter())
msg = {
'FromUserName': '@friend_wxid',
'Type': 'Text',
'Text': 'hi',
'CreateTime': 123456,
'User': SimpleNamespace(NickName='FriendNick', RemarkName='FriendRemark'),
}
event = converter.target2yiri(msg)
assert isinstance(event, platform_events.FriendMessage)
assert isinstance(event.sender, platform_entities.Friend)
assert event.sender.id == '@friend_wxid'
assert event.sender.nickname == 'FriendNick'
assert event.sender.remark == 'FriendRemark'
def test_bot_own_message_is_ignored():
converter = _make_converter(_make_adapter(bot_account_id='@bot_wxid'))
msg = {
'FromUserName': '@bot_wxid',
'Type': 'Text',
'Text': 'self echo',
}
assert converter.target2yiri(msg) is None
def test_group_at_bot_strips_prefix_and_adds_at():
converter = _make_converter(_make_adapter(bot_account_id='@bot_wxid', bot_nickname='MyBot'))
msg = {
'FromUserName': '@@group_wxid',
'Type': 'Text',
'Text': '@MyBothello world',
'ActualUserName': '@member_wxid',
'ActualNickName': 'MemberNick',
'IsAt': True,
'CreateTime': 123456,
'User': SimpleNamespace(NickName='Group Name'),
}
event = converter.target2yiri(msg)
assert isinstance(event, platform_events.GroupMessage)
components = list(event.message_chain)
assert len(components) == 2
assert isinstance(components[0], platform_message.At)
assert components[0].target == '@bot_wxid'
assert isinstance(components[1], platform_message.Plain)
assert components[1].text == 'hello world'
def test_group_message_without_sender_is_ignored():
converter = _make_converter(_make_adapter())
msg = {
'FromUserName': '@@group_wxid',
'Type': 'Text',
'Text': 'system note',
'ActualUserName': '',
'ActualNickName': '',
'IsAt': False,
'CreateTime': 123456,
'User': SimpleNamespace(NickName='Group Name'),
}
assert converter.target2yiri(msg) is None
@@ -1,259 +0,0 @@
from __future__ import annotations
import pytest
from unittest.mock import MagicMock
from linebot.v3.webhooks import TextMessageContent, UserMentionee, AllMentionee
from langbot.pkg.platform import botmgr as _botmgr # noqa: F401
from langbot.pkg.platform.sources import line
import langbot_plugin.api.entities.builtin.platform.message as platform_message
BOT_ACCOUNT_ID = 'line-bot-account'
def _make_event(
*, source_type: str, user_id, group_id=None, room_id=None, message_id: str, text: str = 'hi', mention=None
):
event = MagicMock()
event.timestamp = 1700000000000
message = MagicMock(spec=TextMessageContent)
message.id = message_id
message.text = text
message.mention = mention
event.message = message
event.message.webhook_event_id = f'webhook-{message_id}'
event.message.timestamp = event.timestamp
source = MagicMock()
source.type = source_type
source.user_id = user_id
if group_id is not None:
source.group_id = group_id
if room_id is not None:
source.room_id = room_id
event.source = source
return event
def _make_converter(bot_account_id: str = BOT_ACCOUNT_ID) -> line.LINEEventConverter:
return line.LINEEventConverter(bot_account_id=bot_account_id)
@pytest.mark.asyncio
async def test_user_message_launcher_id_stable_across_messages() -> None:
"""Two distinct messages from the same LINE user must resolve to the same
sender id, otherwise every message starts a brand new session (context loss).
"""
converter = _make_converter()
event1 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-1')
event2 = _make_event(source_type='user', user_id='U-stable-user', message_id='msg-2')
result1 = await converter.target2yiri(event1, bot_client=None)
result2 = await converter.target2yiri(event2, bot_client=None)
assert result1.sender.id == 'U-stable-user'
assert result1.sender.id == result2.sender.id
assert result1.sender.id != event1.message.id
@pytest.mark.asyncio
async def test_group_message_uses_group_id_not_message_id() -> None:
converter = _make_converter()
event1 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-1')
event2 = _make_event(source_type='group', user_id='U-member', group_id='G-stable-group', message_id='msg-2')
result1 = await converter.target2yiri(event1, bot_client=None)
result2 = await converter.target2yiri(event2, bot_client=None)
assert result1.sender.group.id == 'G-stable-group'
assert result1.sender.group.id == result2.sender.group.id
assert result1.sender.id == 'U-member'
@pytest.mark.asyncio
async def test_room_message_uses_room_id_and_falls_back_when_user_id_missing() -> None:
converter = _make_converter()
event = _make_event(source_type='room', user_id=None, room_id='R-stable-room', message_id='msg-1')
result = await converter.target2yiri(event, bot_client=None)
assert result.sender.group.id == 'R-stable-room'
assert result.sender.id == 'R-stable-room'
def _plain_texts(chain: platform_message.MessageChain) -> list[str]:
return [c.text for c in chain if isinstance(c, platform_message.Plain)]
def _ats(chain: platform_message.MessageChain) -> list[platform_message.At]:
return [c for c in chain if isinstance(c, platform_message.At)]
@pytest.mark.asyncio
async def test_no_mention_keeps_plain_text() -> None:
converter = _make_converter()
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello world')
chain = await converter.message_converter.target2yiri(event, bot_client=None)
assert _plain_texts(chain) == ['hello world']
assert _ats(chain) == []
@pytest.mark.asyncio
async def test_bot_mention_maps_to_at_with_bot_account_id() -> None:
"""A @bot mention must become At(target=bot_account_id) so the 'at-bot'
group respond rule matches (previously the mention was lost and the message
was silently dropped in groups with at-only rules).
"""
mention = MagicMock()
mention.mentionees = [
UserMentionee(type='user', index=0, length=4, userId='U-bot-user-id', isSelf=True),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@BOT hey',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
ats = _ats(chain)
assert len(ats) == 1
assert ats[0].target == BOT_ACCOUNT_ID
assert _plain_texts(chain) == [' hey']
@pytest.mark.asyncio
async def test_other_user_mention_keeps_display_text() -> None:
"""Mentions of other users keep their display text in the message string,
so prefix/regexp rules that match the raw '@Name ...' text still work.
"""
mention = MagicMock()
mention.mentionees = [
UserMentionee(type='user', index=0, length=6, userId='U-other', isSelf=False),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@Alice hello',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
ats = _ats(chain)
assert len(ats) == 1
assert ats[0].target == 'U-other'
# str() of the At component falls back to display when set
assert str(chain) == '@Alice hello'
@pytest.mark.asyncio
async def test_bot_mention_triggers_atbot_rule() -> None:
"""End-to-end: a group message that @mentions the bot must be accepted by
the at-bot respond rule (this is the regression that silently dropped
'@bot' messages in LINE groups).
"""
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
mention = MagicMock()
mention.mentionees = [
UserMentionee(type='user', index=0, length=6, userId='U-bot-user-id', isSelf=True),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@RAIQt hi',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
query = MagicMock()
query.adapter = MagicMock()
query.adapter.bot_account_id = BOT_ACCOUNT_ID
rule = AtBotRule(ap=MagicMock())
result = await rule.match(str(chain), chain, {'at': True}, query)
assert result.matching is True
@pytest.mark.asyncio
async def test_group_without_bot_mention_still_dropped_by_atbot_rule() -> None:
from langbot.pkg.pipeline.resprule.rules.atbot import AtBotRule
converter = _make_converter()
event = _make_event(source_type='group', user_id='U-member', group_id='G1', message_id='m1', text='hello')
chain = await converter.message_converter.target2yiri(event, bot_client=None)
query = MagicMock()
query.adapter = MagicMock()
query.adapter.bot_account_id = BOT_ACCOUNT_ID
rule = AtBotRule(ap=MagicMock())
result = await rule.match(str(chain), chain, {'at': True}, query)
assert result.matching is False
@pytest.mark.asyncio
async def test_at_all_mention_preserved_as_at_component() -> None:
mention = MagicMock()
mention.mentionees = [
AllMentionee(type='all', index=0, length=4),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@All hello',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
ats = _ats(chain)
assert len(ats) == 1
assert str(chain) == '@All hello'
@pytest.mark.asyncio
async def test_multiple_mentions_sorted_by_position() -> None:
mention = MagicMock()
# Intentionally out of order to exercise sorting
mention.mentionees = [
UserMentionee(type='user', index=9, length=4, userId='U-b', isSelf=False),
UserMentionee(type='user', index=0, length=4, userId='U-a', isSelf=False),
]
converter = _make_converter()
event = _make_event(
source_type='group',
user_id='U-member',
group_id='G1',
message_id='m1',
text='@aaa mid @bbb tail',
mention=mention,
)
chain = await converter.message_converter.target2yiri(event, bot_client=None)
ats = _ats(chain)
assert [a.target for a in ats] == ['U-a', 'U-b']
assert str(chain) == '@aaa mid @bbb tail'
@@ -1,11 +1,9 @@
"""Tests for QQ Official message and keyboard payload helpers."""
"""Tests for QQ Official 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
@@ -101,12 +99,6 @@ 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 = {}
@@ -116,7 +108,7 @@ def _stream_test_adapter():
@pytest.mark.asyncio
async def test_qq_stream_replace_mode_sends_complete_snapshots():
async def test_qq_stream_uses_cumulative_chunks_as_snapshots():
adapter = _stream_test_adapter()
adapter._stream_ctx['message-1'] = {
'user_openid': 'user-1',
@@ -146,109 +138,10 @@ async def test_qq_stream_replace_mode_sends_complete_snapshots():
assert [call.kwargs['content'] for call in adapter.bot.send_stream_msg.await_args_list] == [
'<think>one',
'<think>one two',
' 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
@@ -1,127 +0,0 @@
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'),
]
@@ -44,86 +44,6 @@ def test_webhook_dispatch_tasks_are_bounded():
assert len(client._dispatch_tasks) == 100
@pytest.mark.asyncio
async def test_ws_initial_stream_frame_precedes_pipeline_dispatch(monkeypatch):
from langbot.libs.wecom_ai_bot_api import ws_client as ws_client_module
order = []
logger = types.SimpleNamespace(
debug=Mock(),
error=Mock(),
warning=Mock(),
)
client = WecomBotWsClient('bot-id', 'secret', logger)
async def parse_message(*args, **kwargs):
del args, kwargs
return {'msgid': 'msg-1', 'type': 'single', 'userid': 'user-1'}
async def reply_stream(*args, **kwargs):
del args, kwargs
order.append('initial-frame')
return {}
async def dispatch_event(event):
del event
order.append('pipeline-dispatch')
monkeypatch.setattr(ws_client_module, 'parse_wecom_bot_message', parse_message)
monkeypatch.setattr(ws_client_module.wecombotevent, 'WecomBotEvent', lambda data: data)
client.reply_stream = reply_stream
client._dispatch_event = dispatch_event
await client._handle_message_callback({'headers': {'req_id': 'req-1'}, 'body': {}})
assert order == ['initial-frame', 'pipeline-dispatch']
@pytest.mark.asyncio
async def test_ws_initial_stream_failure_still_dispatches_message(monkeypatch):
from langbot.libs.wecom_ai_bot_api import ws_client as ws_client_module
dispatched = []
class Logger:
def __init__(self):
self.warnings = []
async def debug(self, message):
del message
async def error(self, message):
raise AssertionError(message)
async def warning(self, message):
self.warnings.append(message)
logger = Logger()
client = WecomBotWsClient('bot-id', 'secret', logger)
async def parse_message(*args, **kwargs):
del args, kwargs
return {'msgid': 'msg-1', 'type': 'single', 'userid': 'user-1'}
async def reply_stream(*args, **kwargs):
del args, kwargs
raise ConnectionError('simulated reply failure')
async def dispatch_event(event):
dispatched.append(event)
monkeypatch.setattr(ws_client_module, 'parse_wecom_bot_message', parse_message)
monkeypatch.setattr(ws_client_module.wecombotevent, 'WecomBotEvent', lambda data: data)
client.reply_stream = reply_stream
client._dispatch_event = dispatch_event
await client._handle_message_callback({'headers': {'req_id': 'req-1'}, 'body': {}})
assert len(dispatched) == 1
assert len(logger.warnings) == 1
assert 'simulated reply failure' in logger.warnings[0]
def test_extract_template_card_action_supports_nested_button_key():
task_id, event_key, card_type = extract_template_card_action(
{
@@ -1,4 +1,3 @@
import uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -50,29 +49,7 @@ async def test_send_message_sends_text_to_customer_service_user():
assert kwargs['open_kfid'] == 'kf-test'
assert kwargs['external_userid'] == 'external-user'
assert kwargs['content'] == 'hello'
assert len(kwargs['msgid'].encode()) <= 32
assert uuid.UUID(hex=kwargs['msgid']).hex == kwargs['msgid']
@pytest.mark.asyncio
async def test_send_message_sends_image_to_customer_service_user():
adapter = make_adapter()
adapter.bot_account_id = 'kf-test'
adapter.bot = SimpleNamespace(
get_media_id=AsyncMock(return_value='media-id'),
send_image_msg=AsyncMock(),
)
message = platform_message.MessageChain([platform_message.Image(base64='aW1hZ2U=')])
await adapter.send_message('person', 'uexternal-user', message)
adapter.bot.send_image_msg.assert_awaited_once()
kwargs = adapter.bot.send_image_msg.await_args.kwargs
assert kwargs['open_kfid'] == 'kf-test'
assert kwargs['external_userid'] == 'external-user'
assert kwargs['media_id'] == 'media-id'
assert len(kwargs['msgid'].encode()) <= 32
assert kwargs['msgid'].startswith('langbot_')
@pytest.mark.asyncio
@@ -1,47 +0,0 @@
from __future__ import annotations
import httpx
import pytest
from langbot.libs.wecom_customer_service_api.api import WecomCSClient
@pytest.mark.asyncio
async def test_send_image_msg_posts_customer_service_image_payload() -> None:
captured_request: httpx.Request | None = None
def handle_request(request: httpx.Request) -> httpx.Response:
nonlocal captured_request
captured_request = request
return httpx.Response(200, json={'errcode': 0})
client = WecomCSClient(
corpid='corp-id',
secret='secret',
token='token',
EncodingAESKey='encoding-key',
logger=None,
unified_mode=True,
)
client.access_token = 'access-token'
client._http_client = httpx.AsyncClient(transport=httpx.MockTransport(handle_request))
try:
await client.send_image_msg(
open_kfid='kf-test',
external_userid='external-user',
msgid='a' * 32,
media_id='media-id',
)
finally:
await client.close()
assert captured_request is not None
assert captured_request.url.path == '/cgi-bin/kf/send_msg'
assert captured_request.url.params['access_token'] == 'access-token'
assert captured_request.method == 'POST'
assert captured_request.read().decode() == (
'{"touser":"external-user","open_kfid":"kf-test","msgid":"'
+ 'a' * 32
+ '","msgtype":"image","image":{"media_id":"media-id"}}'
)
+1 -10
View File
@@ -15,7 +15,7 @@ from langbot_plugin.runtime.security import (
)
def make_connector(*, cloud: bool = False) -> PluginRuntimeConnector:
def make_connector() -> PluginRuntimeConnector:
app = SimpleNamespace(
logger=Mock(),
instance_config=SimpleNamespace(
@@ -34,7 +34,6 @@ def make_connector(*, cloud: bool = False) -> PluginRuntimeConnector:
'space': {'url': ''},
}
),
deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'),
)
return PluginRuntimeConnector(app, AsyncMock())
@@ -333,14 +332,6 @@ def test_external_runtime_control_headers_are_empty_when_secret_is_unset(monkeyp
assert connector._control_headers(allow_generate=False) == {}
def test_cloud_runtime_rejects_missing_control_secret(monkeypatch):
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
connector = make_connector(cloud=True)
with pytest.raises(PluginRuntimeNotConnectedError, match=PLUGIN_RUNTIME_CONTROL_TOKEN_ENV):
connector._control_headers(allow_generate=False)
def test_local_runtime_control_headers_generate_ephemeral_secret(monkeypatch):
monkeypatch.delenv(PLUGIN_RUNTIME_CONTROL_TOKEN_ENV, raising=False)
connector = make_connector()
@@ -107,19 +107,6 @@ 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')
@@ -163,7 +150,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((), timeout=300.0)
connector.handler.reconcile_plugin_installations.assert_awaited_once_with(())
@pytest.mark.asyncio
+2 -28
View File
@@ -9,8 +9,8 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
from langbot_plugin.entities.io.actions.enums import LangBotToRuntimeAction, PluginToRuntimeAction
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginInstallationDesiredState
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
def make_handler(app):
@@ -67,32 +67,6 @@ def make_handler(app):
return runtime_handler
@pytest.mark.asyncio
async def test_reconcile_plugin_installations_allows_cloud_cold_start_to_finish():
app = SimpleNamespace()
runtime_handler = make_handler(app)
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,))
assert runtime_handler.call_action.await_args.args[0] == LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS
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."""
+6 -136
View File
@@ -234,7 +234,6 @@ 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
@@ -271,8 +270,8 @@ class TestSetBinaryStorage:
)
assert response.code == 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 app.persistence_mgr.execute_async.await_count == 2
insert_params = compiled_params(app.persistence_mgr.execute_async.await_args_list[1].args[0])
assert insert_params['workspace_uuid'] == 'workspace-a'
assert insert_params['unique_key'] == canonical_binary_key(
'plugin',
@@ -302,69 +301,6 @@ 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."""
@@ -589,46 +525,6 @@ 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."""
@@ -671,47 +567,21 @@ class TestDeleteAndListBinaryStorage:
assert response.code == 0
statement_params = compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
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 'workspace-a' in statement_params.values()
assert (
canonical_binary_key(
'plugin',
'test-author/test-plugin',
'test-key',
)
in flat_values
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
assert 'forged-owner' not in statement_params.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', 'first']
result.scalars.return_value.all.return_value = ['first', 'second']
app.persistence_mgr.execute_async.return_value = result
runtime_handler = make_handler(app)
@@ -79,32 +79,6 @@ class TestDifyWorkflowSubmitClient:
with pytest.raises(errors.DifyAPIError, match='SSE event exceeds'):
await anext(client._iter_sse_json(FakeResponse()))
@pytest.mark.asyncio
async def test_sse_parser_skips_empty_data_and_done_lines(self):
from langbot.libs.dify_service_api.v1 import client
class FakeResponse:
async def aiter_bytes(self, chunk_size=None):
del chunk_size
yield b'data:\n\ndata: {"event":"message",'
yield b'"answer":"ok"}\n\ndata: [DONE]\n'
events = [event async for event in client._iter_sse_json(FakeResponse())]
assert events == [{'event': 'message', 'answer': 'ok'}]
@pytest.mark.asyncio
async def test_sse_parser_rejects_malformed_nonempty_data(self):
from langbot.libs.dify_service_api.v1 import client, errors
class FakeResponse:
async def aiter_bytes(self, chunk_size=None):
del chunk_size
yield b'data: not-json\n'
with pytest.raises(errors.DifyAPIError, match='not valid JSON'):
await anext(client._iter_sse_json(FakeResponse()))
@pytest.mark.asyncio
async def test_upload_rejects_oversized_local_file(self, tmp_path):
from langbot.libs.dify_service_api.v1 import client
@@ -119,62 +93,6 @@ class TestDifyWorkflowSubmitClient:
with pytest.raises(ValueError, match='exceeds the size limit'):
await dify_client.upload_file(file_path, 'person_user-1')
@pytest.mark.asyncio
@pytest.mark.parametrize(
('status_code', 'body', 'expected_id'),
[
(200, b'{"data":{"id":"wrapped-id"}}', 'wrapped-id'),
(201, b'{"id":"flat-id"}', 'flat-id'),
],
)
async def test_upload_accepts_supported_success_responses(self, status_code, body, expected_id):
from langbot.libs.dify_service_api.v1 import client
class FakeResponse:
headers = {}
def __init__(self):
self.status_code = status_code
async def aiter_bytes(self, chunk_size=None):
del chunk_size
yield body
class FakeStreamContext:
async def __aenter__(self):
return FakeResponse()
async def __aexit__(self, exc_type, exc, traceback):
del exc_type, exc, traceback
return False
class FakeClient:
def stream(self, *args, **kwargs):
del args, kwargs
return FakeStreamContext()
dify_client = client.AsyncDifyServiceClient('test-key', 'https://dify.example/v1')
dify_client._client = FakeClient()
response = await dify_client.upload_file(('hello.txt', b'hello', 'text/plain'), 'person_user-1')
assert response['id'] == expected_id
@pytest.mark.parametrize(
'body',
[
b'not-json',
b'[]',
b'{"data":null}',
b'{"data":{}}',
],
)
def test_upload_rejects_invalid_success_payload(self, body):
from langbot.libs.dify_service_api.v1 import client, errors
with pytest.raises(errors.DifyAPIError):
client._decode_upload_response(body)
class TestDifyExtractTextOutput:
"""Tests for _extract_dify_text_output method."""
@@ -91,42 +91,3 @@ def test_convert_messages_plain_string_content_untouched():
msg = provider_message.Message(role='user', content='just text')
out = req._convert_messages([msg])
assert out[0]['content'] == 'just text'
def test_convert_messages_replayed_image_without_base64_does_not_crash():
"""Replayed image parts hollowed out by history trimming must not raise KeyError (#2469).
SessionManager clears image_base64 on past turns, and URL-less platform
images never had a URL, so the replayed part serializes as
{'type': 'image_base64'} with no payload keys. The hollow part should be
dropped while the sibling text part survives.
"""
req = _make_requester()
image = provider_message.ContentElement.from_image_base64('data:image/jpeg;base64,AAAA')
# Simulate SessionManager.trim_conversation_messages clearing binary payloads.
image.image_base64 = None
msg = provider_message.Message(
role='user',
content=[
provider_message.ContentElement.from_text('describe the photo'),
image,
],
)
out = req._convert_messages([msg])
assert [p.get('type') for p in out[0]['content']] == ['text']
def test_convert_messages_replayed_image_with_url_falls_back_to_url():
"""When base64 was trimmed but image_url survived, rebuild the OpenAI image_url part from the URL."""
req = _make_requester()
image = provider_message.ContentElement(
type='image_base64',
image_base64=None,
image_url=provider_message.ImageURLContentObject(url='https://example.com/pic.jpg'),
)
msg = provider_message.Message(role='user', content=[image])
out = req._convert_messages([msg])
parts = out[0]['content']
assert [p.get('type') for p in parts] == ['image_url']
assert parts[0]['image_url'] == {'url': 'https://example.com/pic.jpg'}
assert 'image_base64' not in parts[0]
@@ -1,208 +0,0 @@
"""Regression tests for tool-message content serialization (#2457).
MCP tools return ``list[ContentElement]`` from ``execute_func_call``.
The runner must serialize that list to a string before placing it in a
``role='tool'`` message, because the OpenAI chat-completions spec
requires tool-message content to be a string. Sending the raw list
causes OpenAI-compatible endpoints to return HTTP 500.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.pkg.api.http.context import ExecutionContext, PrincipalContext, PrincipalType
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
class _ToolCallProvider:
"""Non-streaming provider: round 1 issues a tool call, round 2 returns text."""
def __init__(self):
self.requests: list[dict] = []
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
self.requests.append({'messages': list(messages)})
if len(self.requests) == 1:
return provider_message.Message(
role='assistant',
content='Let me search that.',
tool_calls=[
provider_message.ToolCall(
id='call-mcp-1',
type='function',
function=provider_message.FunctionCall(
name='duckduckgo_search',
arguments=json.dumps({'query': 'swift'}),
),
)
],
)
return provider_message.Message(role='assistant', content='Done.')
class _ToolCallStreamProvider:
"""Streaming variant of _ToolCallProvider."""
def __init__(self):
self.requests: list[dict] = []
def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None):
self.requests.append({'messages': list(messages)})
async def _stream():
if len(self.requests) == 1:
yield provider_message.MessageChunk(
role='assistant',
content='Let me search that.',
tool_calls=[
provider_message.ToolCall(
id='call-mcp-1',
type='function',
function=provider_message.FunctionCall(
name='duckduckgo_search',
arguments=json.dumps({'query': 'swift'}),
),
)
],
is_final=True,
)
return
yield provider_message.MessageChunk(
role='assistant',
content='Done.',
is_final=True,
)
return _stream()
def _make_query(stream: bool = False) -> pipeline_query.Query:
adapter = AsyncMock()
adapter.is_stream_output_supported = AsyncMock(return_value=stream)
query = pipeline_query.Query.model_construct(
query_id='mcp-tool-query',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_chain=[],
message_event=None,
adapter=adapter,
pipeline_uuid='pipeline-uuid',
bot_uuid='bot-uuid',
pipeline_config={
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
},
'output': {'misc': {'remove-think': False}},
},
prompt=SimpleNamespace(messages=[]),
messages=[],
user_message=provider_message.Message(role='user', content='search swift'),
use_funcs=[SimpleNamespace(name='duckduckgo_search')],
use_llm_model_uuid='test-model-uuid',
variables={},
)
object.__setattr__(
query,
'_execution_context',
ExecutionContext(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
),
)
return query
def _make_app(provider, func_ret) -> SimpleNamespace:
"""Build a minimal app whose tool_mgr returns *func_ret*."""
model = SimpleNamespace(
provider=provider,
model_entity=SimpleNamespace(
uuid='test-model-uuid',
name='test-model',
abilities=['func_call'],
extra_args={},
),
)
return SimpleNamespace(
logger=Mock(),
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock(return_value=func_ret)),
rag_mgr=SimpleNamespace(),
box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')),
skill_mgr=SimpleNamespace(
get_skills_for_pipeline=AsyncMock(return_value=[]),
detect_skill_activation=AsyncMock(return_value=None),
build_activation_prompt=Mock(return_value=None),
),
)
# The actual shape returned by MCP tools: a list of ContentElement objects.
_MCP_FUNC_RET = [
provider_message.ContentElement.from_text('Title: Swift - Wikipedia\nURL: https://en.wikipedia.org/wiki/Swift'),
provider_message.ContentElement.from_text('Title: Swift Programming Language\nURL: https://swift.org'),
]
@pytest.mark.asyncio
async def test_tool_message_content_is_string_not_list():
"""Non-streaming: tool message content must be a string (#2457).
Before the fix, ``func_ret`` (a ``list[ContentElement]``) was assigned
to ``tool_content`` as-is, so the tool message carried a list instead
of a string, causing OpenAI-compatible APIs to return 500.
"""
provider = _ToolCallProvider()
app = _make_app(provider, _MCP_FUNC_RET)
runner = LocalAgentRunner(app, pipeline_config={})
query = _make_query(stream=False)
results = [msg async for msg in runner.run(query)]
tool_msgs = [m for m in results if m.role == 'tool']
assert len(tool_msgs) == 1
# The content must be a string, not a list.
assert isinstance(tool_msgs[0].content, str), (
f'tool message content should be str, got {type(tool_msgs[0].content).__name__}'
)
# And it should contain the text of both ContentElements.
assert 'Swift - Wikipedia' in tool_msgs[0].content
assert 'Swift Programming Language' in tool_msgs[0].content
@pytest.mark.asyncio
async def test_tool_message_content_is_string_in_stream():
"""Streaming: same regression check for the streaming path (#2457)."""
provider = _ToolCallStreamProvider()
app = _make_app(provider, _MCP_FUNC_RET)
runner = LocalAgentRunner(app, pipeline_config={})
query = _make_query(stream=True)
results = [msg async for msg in runner.run(query)]
tool_msgs = [m for m in results if m.role == 'tool']
assert len(tool_msgs) == 1
assert isinstance(tool_msgs[0].content, str), (
f'tool message content should be str, got {type(tool_msgs[0].content).__name__}'
)
assert 'Swift - Wikipedia' in tool_msgs[0].content
assert 'Swift Programming Language' in tool_msgs[0].content

Some files were not shown because too many files have changed in this diff Show More