mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-27 20:57:13 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b121c66f18 | |||
| 536534865e | |||
| ff528e8e0b | |||
| f7914a8900 | |||
| c18fc9dfe3 | |||
| bc40418bda | |||
| 45ed6efceb | |||
| c67a503532 | |||
| 8b14d2ab8e | |||
| 97428310b9 | |||
| 338ee733cb | |||
| c3299fd1a6 |
@@ -1,59 +0,0 @@
|
||||
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
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,162 +0,0 @@
|
||||
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:
|
||||
+1
-2
@@ -22,7 +22,6 @@ 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",
|
||||
@@ -72,7 +71,7 @@ dependencies = [
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@7b559da430a50f80a7d30c9d3d66f088503ddbb3",
|
||||
"langbot-plugin @ git+https://github.com/langbot-app/langbot-plugin-sdk.git@555a58e5db3de28e977b08dd4cd116b332848a19",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import mimetypes
|
||||
import os
|
||||
|
||||
import quart
|
||||
|
||||
@@ -1134,224 +1133,3 @@ 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={})
|
||||
|
||||
@@ -69,8 +69,6 @@ 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 [
|
||||
|
||||
@@ -368,6 +368,10 @@ 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)
|
||||
|
||||
@@ -249,6 +249,10 @@ 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(
|
||||
|
||||
@@ -1,771 +0,0 @@
|
||||
"""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
|
||||
@@ -1,75 +0,0 @@
|
||||
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
|
||||
@@ -264,6 +264,11 @@ 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(
|
||||
|
||||
@@ -1579,7 +1579,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
return await self.call_action(
|
||||
LangBotToRuntimeAction.RECONCILE_PLUGIN_INSTALLATIONS,
|
||||
request.model_dump(),
|
||||
timeout=120,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
async def apply_plugin_installation(
|
||||
|
||||
@@ -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 = ''):
|
||||
def make_app(logger: Mock, runtime_endpoint: str = '', *, cloud: bool = False):
|
||||
return SimpleNamespace(
|
||||
logger=logger,
|
||||
workspace_service=SimpleNamespace(instance_uuid='instance-a'),
|
||||
@@ -42,6 +42,7 @@ def make_app(logger: Mock, runtime_endpoint: str = ''):
|
||||
}
|
||||
}
|
||||
),
|
||||
deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'),
|
||||
)
|
||||
|
||||
|
||||
@@ -315,6 +316,14 @@ 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'))
|
||||
|
||||
@@ -87,7 +87,10 @@ 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()})
|
||||
app.plugin_connector = SimpleNamespace(
|
||||
_known_desired_states={'installation': object()},
|
||||
_runtime_available=lambda: True,
|
||||
)
|
||||
app.persistence_mgr = SimpleNamespace(
|
||||
get_resource_stats=lambda: {
|
||||
'configured_capacity': 20,
|
||||
@@ -140,3 +143,4 @@ 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
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
"""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': '@MyBot hello 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
|
||||
@@ -15,7 +15,7 @@ from langbot_plugin.runtime.security import (
|
||||
)
|
||||
|
||||
|
||||
def make_connector() -> PluginRuntimeConnector:
|
||||
def make_connector(*, cloud: bool = False) -> PluginRuntimeConnector:
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
instance_config=SimpleNamespace(
|
||||
@@ -34,6 +34,7 @@ def make_connector() -> PluginRuntimeConnector:
|
||||
'space': {'url': ''},
|
||||
}
|
||||
),
|
||||
deployment=SimpleNamespace(mode='cloud' if cloud else 'oss'),
|
||||
)
|
||||
return PluginRuntimeConnector(app, AsyncMock())
|
||||
|
||||
@@ -332,6 +333,14 @@ 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()
|
||||
|
||||
@@ -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 PluginToRuntimeAction
|
||||
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding
|
||||
from langbot_plugin.entities.io.actions.enums import LangBotToRuntimeAction, PluginToRuntimeAction
|
||||
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginInstallationDesiredState
|
||||
|
||||
|
||||
def make_handler(app):
|
||||
@@ -67,6 +67,20 @@ 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
|
||||
|
||||
|
||||
class TestHandlerQueryVariables:
|
||||
"""Tests for handler query variable logic."""
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.11, <4.0"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
@@ -1814,19 +1814,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itchat-uos"
|
||||
version = "1.5.0.dev0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pypng" },
|
||||
{ name = "pyqrcode" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/2b/0be7e46195dc3c461518b046a6fb9c34c97cd2fd44847b840a0011686575/itchat_uos-1.5.0.dev0-py3-none-any.whl", hash = "sha256:0293b77cab31fa8c9c2144ea8b5636d43836f47855beba0c603ad3fb1ff89625", size = 52507, upload-time = "2022-07-14T02:38:32.507Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
@@ -2048,7 +2035,6 @@ dependencies = [
|
||||
{ name = "ebooklib" },
|
||||
{ name = "gewechat-client" },
|
||||
{ name = "html2text" },
|
||||
{ name = "itchat-uos" },
|
||||
{ name = "langbot-plugin" },
|
||||
{ name = "langchain" },
|
||||
{ name = "langchain-core" },
|
||||
@@ -2139,8 +2125,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "itchat-uos", specifier = ">=1.5.0.dev0" },
|
||||
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=7b559da430a50f80a7d30c9d3d66f088503ddbb3" },
|
||||
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=555a58e5db3de28e977b08dd4cd116b332848a19" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2206,8 +2191,8 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.5.1"
|
||||
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=7b559da430a50f80a7d30c9d3d66f088503ddbb3#7b559da430a50f80a7d30c9d3d66f088503ddbb3" }
|
||||
version = "0.5.3"
|
||||
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=555a58e5db3de28e977b08dd4cd116b332848a19#555a58e5db3de28e977b08dd4cd116b332848a19" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
@@ -4532,12 +4517,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyqrcode"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/61/f07226075c347897937d4086ef8e55f0a62ae535e28069884ac68d979316/PyQRCode-1.2.1.tar.gz", hash = "sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5", size = 36989, upload-time = "2016-06-20T03:28:03.411Z" }
|
||||
|
||||
[[package]]
|
||||
name = "pyreadline3"
|
||||
version = "3.5.4"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
@@ -27,79 +26,9 @@ import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-ses
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Settings,
|
||||
FileText,
|
||||
Users,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
CircleCheck,
|
||||
CircleAlert,
|
||||
Loader2,
|
||||
CircleOff,
|
||||
} from 'lucide-react';
|
||||
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import type { Bot, BotAdapterRuntimeStatus } from '@/app/infra/entities/api';
|
||||
|
||||
function getBotRuntimeStatus(bot: Bot | null): BotAdapterRuntimeStatus | null {
|
||||
return bot?.adapter_runtime_values?.runtime_status ?? null;
|
||||
}
|
||||
|
||||
function RuntimeStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: BotAdapterRuntimeStatus | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!status) return null;
|
||||
|
||||
const value = status?.connection_status ?? 'disconnected';
|
||||
|
||||
const config = {
|
||||
connected: {
|
||||
label: t('bots.runtimeConnected'),
|
||||
className: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700',
|
||||
icon: CircleCheck,
|
||||
},
|
||||
connecting: {
|
||||
label: t('bots.runtimeConnecting'),
|
||||
className: 'border-amber-500/30 bg-amber-500/10 text-amber-700',
|
||||
icon: Loader2,
|
||||
},
|
||||
disconnected: {
|
||||
label: t('bots.runtimeDisconnected'),
|
||||
className: 'border-muted-foreground/20 bg-muted text-muted-foreground',
|
||||
icon: CircleOff,
|
||||
},
|
||||
error: {
|
||||
label: t('bots.runtimeError'),
|
||||
className: 'border-destructive/30 bg-destructive/10 text-destructive',
|
||||
icon: CircleAlert,
|
||||
},
|
||||
}[value];
|
||||
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('h-6 gap-1.5 px-2 text-xs', config.className)}
|
||||
>
|
||||
<Icon
|
||||
className={cn('size-3.5', value === 'connecting' && 'animate-spin')}
|
||||
/>
|
||||
{config.label}
|
||||
</Badge>
|
||||
{status?.connection_error && (
|
||||
<span className="max-w-[360px] truncate text-xs text-destructive">
|
||||
{status.connection_error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function BotDetailContent({ id }: { id: string }) {
|
||||
@@ -135,24 +64,16 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
// Enable state managed here so the header switch works
|
||||
const [botEnabled, setBotEnabled] = useState(true);
|
||||
const [enableLoaded, setEnableLoaded] = useState(false);
|
||||
const [botDetail, setBotDetail] = useState<Bot | null>(null);
|
||||
|
||||
const fetchBotDetail = useCallback(async () => {
|
||||
if (isCreateMode) return;
|
||||
const res = await httpClient.getBot(id);
|
||||
setBotDetail(res.bot);
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
setEnableLoaded(true);
|
||||
}, [id, isCreateMode]);
|
||||
|
||||
// Fetch bot enable state
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) {
|
||||
fetchBotDetail();
|
||||
const timer = window.setInterval(fetchBotDetail, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
setEnableLoaded(true);
|
||||
});
|
||||
}
|
||||
}, [fetchBotDetail, isCreateMode]);
|
||||
}, [id, isCreateMode]);
|
||||
|
||||
const handleEnableToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
@@ -180,7 +101,9 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
|
||||
function handleFormSubmit() {
|
||||
// Re-sync enable state after form save (form may update enable too)
|
||||
fetchBotDetail();
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
});
|
||||
refreshBots();
|
||||
}
|
||||
|
||||
@@ -261,7 +184,6 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<RuntimeStatusBadge status={getBotRuntimeStatus(botDetail)} />
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button
|
||||
|
||||
@@ -688,10 +688,7 @@ export default function DynamicFormComponent({
|
||||
onSuccess={(credentials) => {
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (value) {
|
||||
form.setValue(key as keyof FormValues, value as never, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue(key as keyof FormValues, value as never);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -22,7 +22,6 @@ export type QrLoginPlatform =
|
||||
| 'weixin'
|
||||
| 'dingtalk'
|
||||
| 'wecombot'
|
||||
| 'itchat'
|
||||
| 'qqofficial';
|
||||
|
||||
interface PlatformConfig {
|
||||
@@ -100,20 +99,6 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
}),
|
||||
successNoteKey: 'wecombot.robotNameNote',
|
||||
},
|
||||
itchat: {
|
||||
titleKey: 'itchat.scanLogin',
|
||||
connectingKey: 'itchat.connecting',
|
||||
scanQRCodeKey: 'itchat.scanQRCode',
|
||||
waitingKey: 'itchat.waitingForScan',
|
||||
successKey: 'itchat.loginSuccess',
|
||||
failedKey: 'itchat.loginFailed',
|
||||
retryKey: 'itchat.retry',
|
||||
apiBase: '/api/v1/platform/adapters/itchat/login',
|
||||
extractSuccess: (data) => ({
|
||||
account_id: data.wxid || '',
|
||||
nickname: data.nickname || '',
|
||||
}),
|
||||
},
|
||||
qqofficial: {
|
||||
titleKey: 'qqofficial.createBinding',
|
||||
connectingKey: 'qqofficial.connecting',
|
||||
@@ -154,7 +139,6 @@ export default function QrCodeLoginDialog({
|
||||
|
||||
const [state, setState] = useState<DialogState>('connecting');
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const qrDataUrlRef = useRef('');
|
||||
const [expireIn, setExpireIn] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [successMeta, setSuccessMeta] = useState('');
|
||||
@@ -223,7 +207,6 @@ export default function QrCodeLoginDialog({
|
||||
cleanedRef.current = false;
|
||||
setState('connecting');
|
||||
setQrDataUrl('');
|
||||
qrDataUrlRef.current = '';
|
||||
setExpireIn(0);
|
||||
setErrorMessage('');
|
||||
setSuccessMeta('');
|
||||
@@ -259,14 +242,12 @@ export default function QrCodeLoginDialog({
|
||||
|
||||
if (qr_data_url) {
|
||||
setQrDataUrl(qr_data_url);
|
||||
qrDataUrlRef.current = qr_data_url;
|
||||
} else if (qr_url) {
|
||||
const dataUrl = await QRCode.toDataURL(qr_url, {
|
||||
width: 224,
|
||||
margin: 2,
|
||||
});
|
||||
setQrDataUrl(dataUrl);
|
||||
qrDataUrlRef.current = dataUrl;
|
||||
}
|
||||
setState('waiting');
|
||||
|
||||
@@ -366,19 +347,6 @@ export default function QrCodeLoginDialog({
|
||||
cleanup();
|
||||
setExpireIn(0);
|
||||
setState('expired');
|
||||
} else if (status === 'waiting') {
|
||||
// Update QR data URL if regenerated (e.g. itchat QR expiry)
|
||||
if (rest.qr_data_url && rest.qr_data_url !== qrDataUrlRef.current) {
|
||||
setQrDataUrl(rest.qr_data_url);
|
||||
qrDataUrlRef.current = rest.qr_data_url;
|
||||
}
|
||||
if (rest.expire_at) {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
Math.floor(rest.expire_at - Date.now() / 1000),
|
||||
);
|
||||
setExpireIn(remaining);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors
|
||||
|
||||
@@ -203,28 +203,6 @@ export interface ApiRespPlatformBot {
|
||||
bot: Bot;
|
||||
}
|
||||
|
||||
export type BotAdapterConnectionStatus =
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'error';
|
||||
|
||||
export interface BotAdapterRuntimeStatus {
|
||||
connection_status?: BotAdapterConnectionStatus;
|
||||
connection_error?: string;
|
||||
last_connected_at?: number | null;
|
||||
last_disconnected_at?: number | null;
|
||||
}
|
||||
|
||||
export interface BotAdapterRuntimeValues {
|
||||
bot_account_id?: string;
|
||||
webhook_url?: string | null;
|
||||
webhook_full_url?: string | null;
|
||||
extra_webhook_full_url?: string | null;
|
||||
runtime_status?: BotAdapterRuntimeStatus;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Bot {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
@@ -237,7 +215,7 @@ export interface Bot {
|
||||
pipeline_routing_rules?: PipelineRoutingRule[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
adapter_runtime_values?: BotAdapterRuntimeValues;
|
||||
adapter_runtime_values?: object;
|
||||
}
|
||||
|
||||
export type RoutingRuleOperator =
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
userInfo,
|
||||
systemInfo,
|
||||
bootstrapWorkspaceSession,
|
||||
initializeSystemInfo,
|
||||
@@ -490,24 +489,6 @@ export default function WizardPage() {
|
||||
t,
|
||||
]);
|
||||
|
||||
// ---- Space auth redirect ----
|
||||
|
||||
const handleSpaceAuth = useCallback(async () => {
|
||||
try {
|
||||
const callbackUrl = `${window.location.origin}/auth/space/callback`;
|
||||
const resp = await httpClient.getSpaceAuthorizeUrl(callbackUrl);
|
||||
window.location.href = resp.authorize_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get space authorize URL', err);
|
||||
toast.error(t('wizard.spaceAuthError'));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// ---- Check if local account ----
|
||||
// Re-evaluated after remote data fetch (when userInfo is populated)
|
||||
const isLocalAccount =
|
||||
!isLoading && (!userInfo || userInfo.account_type === 'local');
|
||||
|
||||
// ---- Skip handler ----
|
||||
const [showSkipConfirm, setShowSkipConfirm] = useState(false);
|
||||
const [isSkipping, setIsSkipping] = useState(false);
|
||||
@@ -662,8 +643,6 @@ export default function WizardPage() {
|
||||
runnerOptions={runnerOptions}
|
||||
selected={selectedRunner}
|
||||
onSelect={handleSelectRunner}
|
||||
isLocalAccount={isLocalAccount}
|
||||
onSpaceAuth={handleSpaceAuth}
|
||||
runnerConfigItems={selectedRunnerConfigItems}
|
||||
runnerConfigValues={runnerConfig}
|
||||
onRunnerConfigChange={setRunnerConfig}
|
||||
@@ -1006,8 +985,6 @@ function StepAIEngine({
|
||||
runnerOptions,
|
||||
selected,
|
||||
onSelect,
|
||||
isLocalAccount,
|
||||
onSpaceAuth,
|
||||
runnerConfigItems,
|
||||
runnerConfigValues,
|
||||
onRunnerConfigChange,
|
||||
@@ -1015,8 +992,6 @@ function StepAIEngine({
|
||||
runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[];
|
||||
selected: string | null;
|
||||
onSelect: (name: string) => void;
|
||||
isLocalAccount: boolean;
|
||||
onSpaceAuth: () => void;
|
||||
runnerConfigItems: IDynamicFormItemSchema[];
|
||||
runnerConfigValues: Record<string, unknown>;
|
||||
onRunnerConfigChange: (v: Record<string, unknown>) => void;
|
||||
@@ -1127,28 +1102,6 @@ function StepAIEngine({
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Space promotion banner */}
|
||||
{selected === 'local-agent' && isLocalAccount && (
|
||||
<div className="animate-in fade-in slide-in-from-left-2 duration-300">
|
||||
<div className="relative rounded-lg p-[2px] bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500">
|
||||
<div className="rounded-[calc(0.5rem-2px)] bg-background p-3 flex flex-col items-center gap-2 text-center">
|
||||
<Sparkles className="w-6 h-6 text-purple-500 shrink-0" />
|
||||
<p className="text-xs font-medium">
|
||||
{t('wizard.spaceBanner.message')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSpaceAuth}
|
||||
className="w-full"
|
||||
>
|
||||
{t('wizard.spaceBanner.action')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -372,10 +372,6 @@ const enUS = {
|
||||
log: 'Log',
|
||||
configuration: 'Configuration',
|
||||
logs: 'Logs',
|
||||
runtimeConnected: 'Connected',
|
||||
runtimeConnecting: 'Connecting',
|
||||
runtimeDisconnected: 'Disconnected',
|
||||
runtimeError: 'Connection error',
|
||||
basicInfo: 'Basic Information',
|
||||
basicInfoDescription: 'Set the bot name and description',
|
||||
routingConnection: 'Routing & Connection',
|
||||
@@ -1811,7 +1807,6 @@ const enUS = {
|
||||
botCreateSuccess: 'Bot created successfully!',
|
||||
botSaveSuccess: 'Bot configuration saved and enabled!',
|
||||
createError: 'Failed to create resources',
|
||||
spaceAuthError: 'Failed to initiate Space authorization',
|
||||
skipSaveError: 'Failed to save skip status. Please try again.',
|
||||
completeSaveError: 'Failed to save completion status. Please try again.',
|
||||
step: {
|
||||
@@ -1840,11 +1835,6 @@ const enUS = {
|
||||
description:
|
||||
"Choose the AI engine that will power your bot's intelligence.",
|
||||
},
|
||||
spaceBanner: {
|
||||
message:
|
||||
'Connect to LangBot Space for free trial model credits and zero-config instant setup!',
|
||||
action: 'Authorize with Space',
|
||||
},
|
||||
config: {
|
||||
botInfo: 'Bot Information',
|
||||
botNamePlaceholder: 'Enter bot name',
|
||||
@@ -1929,16 +1919,6 @@ const enUS = {
|
||||
waitingForScan: 'Waiting for scan',
|
||||
retry: 'Retry',
|
||||
},
|
||||
itchat: {
|
||||
scanLogin: 'Scan QR Login',
|
||||
scanQRCode: 'Scan the QR code below with WeChat to login',
|
||||
loginSuccess:
|
||||
'Login successful! Session cached. Save config to start using.',
|
||||
loginFailed: 'Login failed',
|
||||
connecting: 'Starting WeChat login...',
|
||||
waitingForScan: 'Waiting for scan',
|
||||
retry: 'Retry',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'One-Click Create DingTalk App',
|
||||
scanQRCode:
|
||||
|
||||
@@ -1672,7 +1672,6 @@ const esES = {
|
||||
botCreateSuccess: '¡Bot creado correctamente!',
|
||||
botSaveSuccess: '¡Configuración del Bot guardada y activada!',
|
||||
createError: 'Error al crear los recursos',
|
||||
spaceAuthError: 'Error al iniciar la autorización de Space',
|
||||
skipSaveError:
|
||||
'Error al guardar el estado de omisión. Por favor, inténtalo de nuevo.',
|
||||
completeSaveError:
|
||||
@@ -1705,11 +1704,6 @@ const esES = {
|
||||
description:
|
||||
'Elige el motor de IA que impulsará la inteligencia de tu Bot.',
|
||||
},
|
||||
spaceBanner: {
|
||||
message:
|
||||
'¡Conéctate a LangBot Space para obtener créditos de prueba gratuitos y configuración instantánea sin esfuerzo!',
|
||||
action: 'Autorizar con Space',
|
||||
},
|
||||
config: {
|
||||
botInfo: 'Información del Bot',
|
||||
botNamePlaceholder: 'Introduce el nombre del Bot',
|
||||
|
||||
@@ -378,10 +378,6 @@ const jaJP = {
|
||||
log: 'ログ',
|
||||
configuration: '設定',
|
||||
logs: 'ログ',
|
||||
runtimeConnected: '接続済み',
|
||||
runtimeConnecting: '接続中',
|
||||
runtimeDisconnected: '切断済み',
|
||||
runtimeError: '接続エラー',
|
||||
basicInfo: '基本情報',
|
||||
basicInfoDescription: 'ボットの名前と説明を設定',
|
||||
routingConnection: 'ルーティングと接続',
|
||||
@@ -1726,7 +1722,6 @@ const jaJP = {
|
||||
botCreateSuccess: 'ボットが正常に作成されました!',
|
||||
botSaveSuccess: 'ボット設定が保存され、有効になりました!',
|
||||
createError: 'リソースの作成に失敗しました',
|
||||
spaceAuthError: 'Space 認証の開始に失敗しました',
|
||||
skipSaveError: 'スキップ状態の保存に失敗しました。もう一度お試しください。',
|
||||
completeSaveError: '完了状態の保存に失敗しました。もう一度お試しください。',
|
||||
step: {
|
||||
@@ -1757,11 +1752,6 @@ const jaJP = {
|
||||
description:
|
||||
'ボットのインテリジェンスを駆動するAIエンジンを選択してください。',
|
||||
},
|
||||
spaceBanner: {
|
||||
message:
|
||||
'LangBot Spaceに接続して、無料トライアルモデルクレジットとゼロ設定の即時セットアップを入手!',
|
||||
action: 'Spaceで認証',
|
||||
},
|
||||
config: {
|
||||
botInfo: 'ボット情報',
|
||||
botNamePlaceholder: 'ボット名を入力',
|
||||
|
||||
@@ -1645,7 +1645,6 @@ const ruRU = {
|
||||
botCreateSuccess: 'Бот успешно создан!',
|
||||
botSaveSuccess: 'Конфигурация бота сохранена и включена!',
|
||||
createError: 'Не удалось создать ресурсы',
|
||||
spaceAuthError: 'Не удалось инициировать авторизацию через Space',
|
||||
skipSaveError: 'Не удалось сохранить статус пропуска. Повторите попытку.',
|
||||
completeSaveError:
|
||||
'Не удалось сохранить статус завершения. Повторите попытку.',
|
||||
@@ -1675,11 +1674,6 @@ const ruRU = {
|
||||
description:
|
||||
'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.',
|
||||
},
|
||||
spaceBanner: {
|
||||
message:
|
||||
'Подключитесь к LangBot Space для бесплатных пробных кредитов и мгновенной настройки!',
|
||||
action: 'Авторизация через Space',
|
||||
},
|
||||
config: {
|
||||
botInfo: 'Информация о боте',
|
||||
botNamePlaceholder: 'Введите имя бота',
|
||||
|
||||
@@ -1612,7 +1612,6 @@ const thTH = {
|
||||
botCreateSuccess: 'สร้าง Bot สำเร็จ!',
|
||||
botSaveSuccess: 'บันทึกและเปิดใช้งาน Bot สำเร็จ!',
|
||||
createError: 'ไม่สามารถสร้างทรัพยากรได้',
|
||||
spaceAuthError: 'ไม่สามารถเริ่มต้นการยืนยันสิทธิ์ Space ได้',
|
||||
skipSaveError: 'ไม่สามารถบันทึกสถานะการข้ามได้ กรุณาลองใหม่',
|
||||
completeSaveError: 'ไม่สามารถบันทึกสถานะการเสร็จสิ้นได้ กรุณาลองใหม่',
|
||||
step: {
|
||||
@@ -1640,11 +1639,6 @@ const thTH = {
|
||||
title: 'เลือกเครื่องมือ AI',
|
||||
description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot',
|
||||
},
|
||||
spaceBanner: {
|
||||
message:
|
||||
'เชื่อมต่อกับ LangBot Space เพื่อรับเครดิตทดลองใช้โมเดลฟรีและตั้งค่าทันทีโดยไม่ต้องกำหนดค่า!',
|
||||
action: 'ยืนยันสิทธิ์กับ Space',
|
||||
},
|
||||
config: {
|
||||
botInfo: 'ข้อมูล Bot',
|
||||
botNamePlaceholder: 'กรอกชื่อ Bot',
|
||||
|
||||
@@ -1638,7 +1638,6 @@ const viVN = {
|
||||
botCreateSuccess: 'Tạo Bot thành công!',
|
||||
botSaveSuccess: 'Cấu hình Bot đã lưu và bật!',
|
||||
createError: 'Tạo tài nguyên thất bại',
|
||||
spaceAuthError: 'Khởi tạo ủy quyền Space thất bại',
|
||||
skipSaveError: 'Lưu trạng thái bỏ qua thất bại. Vui lòng thử lại.',
|
||||
completeSaveError: 'Lưu trạng thái hoàn tất thất bại. Vui lòng thử lại.',
|
||||
step: {
|
||||
@@ -1666,11 +1665,6 @@ const viVN = {
|
||||
title: 'Chọn công cụ AI',
|
||||
description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.',
|
||||
},
|
||||
spaceBanner: {
|
||||
message:
|
||||
'Kết nối với LangBot Space để nhận tín dụng dùng thử mô hình miễn phí và thiết lập tức thì không cần cấu hình!',
|
||||
action: 'Ủy quyền với Space',
|
||||
},
|
||||
config: {
|
||||
botInfo: 'Thông tin Bot',
|
||||
botNamePlaceholder: 'Nhập tên Bot',
|
||||
|
||||
@@ -355,10 +355,6 @@ const zhHans = {
|
||||
log: '日志',
|
||||
configuration: '配置',
|
||||
logs: '日志',
|
||||
runtimeConnected: '已连接',
|
||||
runtimeConnecting: '连接中',
|
||||
runtimeDisconnected: '已掉线',
|
||||
runtimeError: '连接错误',
|
||||
basicInfo: '基础信息',
|
||||
basicInfoDescription: '设置机器人名称和描述',
|
||||
routingConnection: '路由与连接',
|
||||
@@ -1734,7 +1730,6 @@ const zhHans = {
|
||||
botCreateSuccess: '机器人创建成功!',
|
||||
botSaveSuccess: '机器人配置已保存并启用!',
|
||||
createError: '创建资源失败',
|
||||
spaceAuthError: '无法发起 Space 授权',
|
||||
skipSaveError: '保存跳过状态失败,请重试。',
|
||||
completeSaveError: '保存完成状态失败,请重试。',
|
||||
step: {
|
||||
@@ -1760,10 +1755,6 @@ const zhHans = {
|
||||
title: '选择 AI 引擎',
|
||||
description: '选择驱动机器人智能的 AI 引擎。',
|
||||
},
|
||||
spaceBanner: {
|
||||
message: '接入 LangBot Space,获取免费试用模型额度,零配置极速开箱!',
|
||||
action: '前往授权登录',
|
||||
},
|
||||
config: {
|
||||
botInfo: '机器人信息',
|
||||
botNamePlaceholder: '请输入机器人名称',
|
||||
@@ -1842,15 +1833,6 @@ const zhHans = {
|
||||
waitingForScan: '等待扫码中',
|
||||
retry: '重试',
|
||||
},
|
||||
itchat: {
|
||||
scanLogin: '扫码登录微信',
|
||||
scanQRCode: '请使用微信扫描以下二维码登录',
|
||||
loginSuccess: '登录成功!会话已缓存,保存配置后即可使用',
|
||||
loginFailed: '登录失败',
|
||||
connecting: '正在启动微信登录...',
|
||||
waitingForScan: '等待扫码中',
|
||||
retry: '重试',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: '一键创建钉钉应用',
|
||||
scanQRCode: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据',
|
||||
|
||||
@@ -1566,7 +1566,6 @@ const zhHant = {
|
||||
botCreateSuccess: '機器人建立成功!',
|
||||
botSaveSuccess: '機器人配置已儲存並啟用!',
|
||||
createError: '建立資源失敗',
|
||||
spaceAuthError: '無法發起 Space 授權',
|
||||
skipSaveError: '儲存跳過狀態失敗,請重試。',
|
||||
completeSaveError: '儲存完成狀態失敗,請重試。',
|
||||
step: {
|
||||
@@ -1592,10 +1591,6 @@ const zhHant = {
|
||||
title: '選擇 AI 引擎',
|
||||
description: '選擇驅動機器人智慧的 AI 引擎。',
|
||||
},
|
||||
spaceBanner: {
|
||||
message: '接入 LangBot Space,取得免費試用模型額度,零配置極速開箱!',
|
||||
action: '前往授權登入',
|
||||
},
|
||||
config: {
|
||||
botInfo: '機器人資訊',
|
||||
botNamePlaceholder: '請輸入機器人名稱',
|
||||
|
||||
Reference in New Issue
Block a user