mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-22 18:27:12 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e211d3aae6 | |||
| 42f1f00772 | |||
| be8478e940 | |||
| 96a535827f | |||
| 9b81a0b606 | |||
| bbc912d0ef | |||
| 20710df9cb | |||
| 1ed107c9d5 | |||
| 0cce418956 | |||
| d4e8ccd161 | |||
| 78fb40a28a |
@@ -103,11 +103,11 @@ This log records implementation choices made while delivering the Workspace arch
|
||||
- Decision: New Core JWTs require `iss=langbot-core`, an audience derived from the immutable instance UUID, and an expiry. Legacy community tokens are accepted only when they have the historical issuer, carry no audience, and the active policy is the OSS singleton policy.
|
||||
- Reason: A token issued by one instance must not authenticate against another instance that happens to share a secret, and a compatibility decoder must not become an alternate path around the SaaS trust boundary.
|
||||
|
||||
### Runtime control transports authenticate before protocol dispatch
|
||||
### Runtime control transports support opt-in shared-secret authentication
|
||||
|
||||
- Decision: External Plugin Runtime and Box WebSocket control channels require independent strong shared secrets in handshake headers. Locally managed child processes receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally binds the first authenticated control channel to one trusted instance. Plugin Runtime debug and control credentials remain separate.
|
||||
- Reason: Workspace context inside an RPC payload is not trustworthy until the transport peer itself is authenticated. Separating control and debug credentials also limits accidental privilege reuse.
|
||||
- Deployment consequence: Docker Compose and Kubernetes wire one shared secret to each host/runtime pair. An empty external-runtime secret fails startup instead of silently exposing an unauthenticated socket.
|
||||
- Decision: OSS external Plugin Runtime and Box WebSocket control channels preserve tokenless standalone compatibility when the corresponding control token is unset. When a Runtime configures a token, it validates the independent shared secret in the handshake before protocol dispatch. Locally managed child processes still receive ephemeral secrets through their environment; secrets are not placed in URLs, process arguments, request payloads, or logs. Box additionally pins the first control channel to one declared instance identity. Plugin Runtime debug and control credentials remain separate.
|
||||
- Reason: Local OSS development must remain backward compatible, while exposed or shared Runtime endpoints can opt into transport authentication. Separating control and debug credentials also limits accidental privilege reuse.
|
||||
- Deployment consequence: Docker Compose and Kubernetes should wire one strong shared secret to each host/runtime pair. Both sides must use the same value for protection to be effective; a Runtime configured with a token rejects clients that omit it or send a different value.
|
||||
|
||||
### Dashboard WebSocket sessions are tenant runtime objects
|
||||
|
||||
|
||||
+2
-1
@@ -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",
|
||||
@@ -71,7 +72,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@9d216208cdfb41f0cb7fcb64632e2a46816d6dc6",
|
||||
"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",
|
||||
|
||||
@@ -27,10 +27,11 @@ The `all` / `box` profile starts three services:
|
||||
- `langbot_box` — Box sandbox runtime (`:5410`). Uses the host Docker socket to
|
||||
spawn sandbox containers, so the **Box root host path and in-container path
|
||||
must be identical** (`BOX__LOCAL__HOST_ROOT=${LANGBOT_BOX_ROOT:-${PWD}/data/box}`).
|
||||
Its RPC and managed-process relay require a shared
|
||||
`LANGBOT_BOX_CONTROL_TOKEN` (at least 32 non-whitespace characters) in both
|
||||
the LangBot and Box containers. Generate it once with `openssl rand -hex 32`;
|
||||
never put it in `box.runtime.endpoint` or commit it to config.
|
||||
OSS allows its RPC and managed-process relay to run without a token when both
|
||||
sides leave `LANGBOT_BOX_CONTROL_TOKEN` unset. For an exposed endpoint, set
|
||||
the same value of at least 32 non-whitespace characters in both the LangBot
|
||||
and Box containers. Generate it once with `openssl rand -hex 32`; never put
|
||||
it in `box.runtime.endpoint` or commit it to config.
|
||||
|
||||
A Compose deployment may optionally set
|
||||
`LANGBOT_PLUGIN_RUNTIME_CONTROL_TOKEN` on both `langbot` and
|
||||
|
||||
@@ -6,7 +6,8 @@ description: Browse and search the LangBot Space marketplaces (plugins, MCP serv
|
||||
# LangBot Space MCP Operations
|
||||
|
||||
LangBot Space (space.langbot.app) exposes an **MCP server** so user-facing AI
|
||||
agents can browse and search the marketplaces (plugins, MCP servers, skills).
|
||||
agents can browse and search the marketplaces (plugins, MCP servers, skills) and
|
||||
rank live models for automated setup.
|
||||
|
||||
## Endpoint
|
||||
|
||||
@@ -46,10 +47,12 @@ Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
|
||||
| `list_plugins` / `search_plugins` / `get_plugin` | Plugin marketplace |
|
||||
| `list_mcp_servers` / `search_mcp_servers` / `get_mcp_server` | MCP-server marketplace |
|
||||
| `list_skills` / `search_skills` / `get_skill` | Skill marketplace |
|
||||
| `select_models` | Live best-first model list for setup wizards; optional `category` filter |
|
||||
|
||||
`list_*` and `search_*` are paged (`page`, `page_size`). `get_*` takes
|
||||
`author` + `name`. The tool surface mirrors the REST endpoints under
|
||||
`/api/v1/marketplace/*` and is read/browse only.
|
||||
`/api/v1/marketplace/*`; `select_models` mirrors `/api/v1/models/selection`.
|
||||
All tools are read-only.
|
||||
|
||||
## How to use
|
||||
|
||||
@@ -58,12 +61,16 @@ Authorization: Bearer lbpat_...uests without a valid PAT get `401 Unauthorized`.
|
||||
3. Use `search_plugins` / `search_mcp_servers` / `search_skills` to find items,
|
||||
then `get_*` for details (e.g. to obtain author/name for installation in
|
||||
LangBot itself).
|
||||
4. For automatic local-agent setup, call `select_models` (optionally with
|
||||
`category`) and choose the first compatible item. Ordering is latest probe
|
||||
state (available, unprobed, unavailable), then Space recommendation. Each
|
||||
item includes `availability.up`, `last_probed_at`, latency, and HTTP status.
|
||||
|
||||
## Implementation & maintenance (for Space developers)
|
||||
|
||||
- Server: `internal/controller/mcp/server.go` (official Go MCP SDK
|
||||
`github.com/modelcontextprotocol/go-sdk`). Tools call the service layer
|
||||
(`PluginService`, `MCPService`, `SkillService`) directly.
|
||||
(`PluginService`, `MCPService`, `SkillService`, `ModelStatusService`) directly.
|
||||
- Mount: `internal/controller/api.go` at `/mcp` and `/mcp/*any`.
|
||||
- Auth: PAT via `AccountService.ValidatePersonalAccessToken`.
|
||||
- Docs: `docs/MCP_SERVER.md`.
|
||||
|
||||
@@ -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={})
|
||||
|
||||
@@ -398,7 +398,16 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
# Get debug URL from config
|
||||
plugin_config = self.ap.instance_config.data.get('plugin', {})
|
||||
debug_url = plugin_config.get('display_plugin_debug_url', 'http://localhost:5401')
|
||||
debug_url = plugin_config.get(
|
||||
'display_plugin_debug_url',
|
||||
'ws://localhost:5401/plugin/debug/ws',
|
||||
)
|
||||
parsed_debug_url = urlparse(debug_url)
|
||||
if parsed_debug_url.scheme in {'http', 'https'}:
|
||||
debug_url = parsed_debug_url._replace(
|
||||
scheme='wss' if parsed_debug_url.scheme == 'https' else 'ws',
|
||||
path=parsed_debug_url.path or '/plugin/debug/ws',
|
||||
).geturl()
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
|
||||
@@ -69,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 [
|
||||
|
||||
@@ -367,6 +367,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
def _ensure_control_token(self, *, allow_generate: bool) -> str:
|
||||
if not self._control_token and allow_generate:
|
||||
self._control_token = secrets.token_urlsafe(48)
|
||||
if not self._control_token:
|
||||
return ''
|
||||
try:
|
||||
self._control_token = validate_control_token(self._control_token)
|
||||
except ValueError as exc:
|
||||
@@ -376,19 +378,19 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
return self._control_token
|
||||
|
||||
def get_control_headers(self) -> dict[str, str]:
|
||||
"""Headers for the instance-authenticated RPC control handshake."""
|
||||
"""Return instance-scoped RPC headers and the optional shared secret."""
|
||||
|
||||
self._ensure_control_token(allow_generate=False)
|
||||
return {
|
||||
BOX_CONTROL_TOKEN_HEADER: self._control_token,
|
||||
BOX_INSTANCE_HEADER: self._trusted_instance_uuid,
|
||||
}
|
||||
headers = {BOX_INSTANCE_HEADER: self._trusted_instance_uuid}
|
||||
if self._control_token:
|
||||
headers[BOX_CONTROL_TOKEN_HEADER] = self._control_token
|
||||
return headers
|
||||
|
||||
def get_relay_headers(
|
||||
self,
|
||||
action_context: ActionContext,
|
||||
) -> dict[str, str]:
|
||||
"""Return authenticated, placement-scoped relay handshake headers."""
|
||||
"""Return instance- and placement-scoped relay handshake headers."""
|
||||
|
||||
context = ActionContext.model_validate(action_context).without_installation()
|
||||
if context.instance_uuid != self._trusted_instance_uuid:
|
||||
|
||||
@@ -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
|
||||
@@ -1962,11 +1962,16 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
async def get_debug_info(self, execution_context: ExecutionContext) -> dict[str, Any]:
|
||||
"""Get debug information including debug key and WS URL"""
|
||||
action_context = ActionContext(
|
||||
instance_uuid=execution_context.instance_uuid,
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
placement_generation=execution_context.placement_generation,
|
||||
)
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.GET_DEBUG_INFO,
|
||||
{},
|
||||
timeout=10,
|
||||
action_context=execution_context,
|
||||
action_context=action_context,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -328,8 +328,9 @@ box:
|
||||
enabled: true
|
||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
||||
runtime:
|
||||
# External WebSocket runtimes also require LANGBOT_BOX_CONTROL_TOKEN in
|
||||
# both LangBot and Box. Keep the shared secret out of this config file.
|
||||
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
|
||||
# runtimes. To protect an exposed endpoint, set the same strong secret
|
||||
# in both LangBot and Box. Keep it out of this config file.
|
||||
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
|
||||
limits:
|
||||
max_sessions: 64
|
||||
|
||||
@@ -235,13 +235,24 @@ async def test_debug_key_requires_resource_manage_permission(plugin_security_api
|
||||
assert operator_denied.status_code == 403
|
||||
assert allowed.status_code == 200
|
||||
assert (await allowed.get_json())['data'] == {
|
||||
'debug_url': 'http://localhost:5401',
|
||||
'debug_url': 'ws://localhost:5401/plugin/debug/ws',
|
||||
'plugin_debug_key': 'runtime-debug-secret',
|
||||
'expires_at': '2026-08-04T12:00:00Z',
|
||||
}
|
||||
application.plugin_connector.get_debug_info.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_info_uses_websocket_endpoint_for_legacy_config(plugin_security_api):
|
||||
application, client, _ = plugin_security_api
|
||||
application.instance_config.data['plugin'].pop('display_plugin_debug_url')
|
||||
|
||||
response = await client.get('/api/v1/plugins/debug-info', headers=_headers('manager-token'))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['debug_url'] == 'ws://localhost:5401/plugin/debug/ws'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api):
|
||||
application, client, _ = plugin_security_api
|
||||
|
||||
@@ -306,10 +306,19 @@ def test_box_runtime_connector_rejects_relay_context_from_other_instance(
|
||||
)
|
||||
|
||||
|
||||
def test_external_box_runtime_fails_closed_without_control_token(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_external_box_runtime_control_headers_are_tokenless_when_secret_is_unset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.delenv(BOX_CONTROL_TOKEN_ENV, raising=False)
|
||||
connector = BoxRuntimeConnector(make_app(Mock(), runtime_endpoint='http://box-runtime:5410'))
|
||||
|
||||
assert connector.get_control_headers() == {BOX_INSTANCE_HEADER: 'instance-a'}
|
||||
|
||||
|
||||
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'))
|
||||
|
||||
with pytest.raises(BoxRuntimeUnavailableError, match=BOX_CONTROL_TOKEN_ENV):
|
||||
connector.get_control_headers()
|
||||
|
||||
|
||||
@@ -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': '@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
|
||||
@@ -444,3 +444,23 @@ async def test_host_to_runtime_action_carries_trusted_connector_context():
|
||||
'runtime_id': 'runtime-a',
|
||||
}
|
||||
assert request.get('context') is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_debug_info_converts_execution_context_to_sdk_action_context():
|
||||
runtime_handler, _app, _installation_context = make_handler()
|
||||
runtime_handler.call_action = AsyncMock(return_value={'plugin_debug_key': 'debug-key'})
|
||||
execution_context = ExecutionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=7,
|
||||
)
|
||||
|
||||
result = await runtime_handler.get_debug_info(execution_context)
|
||||
|
||||
assert result == {'plugin_debug_key': 'debug-key'}
|
||||
assert runtime_handler.call_action.await_args.kwargs['action_context'] == ActionContext(
|
||||
instance_uuid='instance-a',
|
||||
workspace_uuid='workspace-a',
|
||||
placement_generation=7,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 2
|
||||
requires-python = ">=3.11, <4.0"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
@@ -1018,7 +1018,7 @@ name = "cuda-bindings"
|
||||
version = "13.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cuda-pathfinder" },
|
||||
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
|
||||
@@ -1051,34 +1051,34 @@ wheels = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
cudart = [
|
||||
{ name = "nvidia-cuda-runtime" },
|
||||
{ name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
cufft = [
|
||||
{ name = "nvidia-cufft" },
|
||||
{ name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
cufile = [
|
||||
{ name = "nvidia-cufile" },
|
||||
{ name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
cupti = [
|
||||
{ name = "nvidia-cuda-cupti" },
|
||||
{ name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
curand = [
|
||||
{ name = "nvidia-curand" },
|
||||
{ name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
cusolver = [
|
||||
{ name = "nvidia-cusolver" },
|
||||
{ name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
cusparse = [
|
||||
{ name = "nvidia-cusparse" },
|
||||
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
nvjitlink = [
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
nvrtc = [
|
||||
{ name = "nvidia-cuda-nvrtc" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
nvtx = [
|
||||
{ name = "nvidia-nvtx" },
|
||||
{ name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1814,6 +1814,19 @@ 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"
|
||||
@@ -2035,6 +2048,7 @@ dependencies = [
|
||||
{ name = "ebooklib" },
|
||||
{ name = "gewechat-client" },
|
||||
{ name = "html2text" },
|
||||
{ name = "itchat-uos" },
|
||||
{ name = "langbot-plugin" },
|
||||
{ name = "langchain" },
|
||||
{ name = "langchain-core" },
|
||||
@@ -2125,7 +2139,8 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=9d216208cdfb41f0cb7fcb64632e2a46816d6dc6" },
|
||||
{ name = "itchat-uos", specifier = ">=1.5.0.dev0" },
|
||||
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=7b559da430a50f80a7d30c9d3d66f088503ddbb3" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2191,8 +2206,8 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.5.0"
|
||||
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=9d216208cdfb41f0cb7fcb64632e2a46816d6dc6#9d216208cdfb41f0cb7fcb64632e2a46816d6dc6" }
|
||||
version = "0.5.1"
|
||||
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk.git?rev=7b559da430a50f80a7d30c9d3d66f088503ddbb3#7b559da430a50f80a7d30c9d3d66f088503ddbb3" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
@@ -3238,7 +3253,7 @@ name = "nvidia-cublas"
|
||||
version = "13.1.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cuda-nvrtc" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
|
||||
@@ -3277,7 +3292,7 @@ name = "nvidia-cudnn-cu13"
|
||||
version = "9.20.0.48"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas" },
|
||||
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
|
||||
@@ -3289,7 +3304,7 @@ name = "nvidia-cufft"
|
||||
version = "12.0.0.61"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
||||
@@ -3319,9 +3334,9 @@ name = "nvidia-cusolver"
|
||||
version = "12.0.4.66"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas" },
|
||||
{ name = "nvidia-cusparse" },
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
||||
@@ -3333,7 +3348,7 @@ name = "nvidia-cusparse"
|
||||
version = "12.6.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
||||
@@ -4517,6 +4532,12 @@ 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"
|
||||
@@ -5163,10 +5184,10 @@ name = "scikit-learn"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "joblib" },
|
||||
{ name = "numpy" },
|
||||
{ name = "scipy" },
|
||||
{ name = "threadpoolctl" },
|
||||
{ name = "joblib", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "scipy", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "threadpoolctl", marker = "python_full_version >= '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
|
||||
wheels = [
|
||||
@@ -5213,7 +5234,7 @@ name = "scipy"
|
||||
version = "1.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
||||
wheels = [
|
||||
@@ -5284,14 +5305,14 @@ name = "sentence-transformers"
|
||||
version = "5.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "numpy" },
|
||||
{ name = "scikit-learn" },
|
||||
{ name = "scipy" },
|
||||
{ name = "torch" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "transformers" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "scikit-learn", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "scipy", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "torch", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "transformers", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
|
||||
wheels = [
|
||||
@@ -5664,21 +5685,21 @@ name = "torch"
|
||||
version = "2.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cuda-bindings", marker = "sys_platform == 'linux'" },
|
||||
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
|
||||
{ name = "filelock" },
|
||||
{ name = "fsspec" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "networkx" },
|
||||
{ name = "nvidia-cublas", marker = "sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
|
||||
{ name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
|
||||
{ name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "sympy" },
|
||||
{ name = "triton", marker = "sys_platform == 'linux'" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
{ name = "filelock", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "fsspec", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "jinja2", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "networkx", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
{ name = "setuptools", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "sympy", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" },
|
||||
@@ -5720,15 +5741,15 @@ name = "transformers"
|
||||
version = "5.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "regex" },
|
||||
{ name = "safetensors" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typer" },
|
||||
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "pyyaml", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "regex", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "safetensors", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "tokenizers", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "typer", marker = "python_full_version >= '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
|
||||
wheels = [
|
||||
@@ -5989,9 +6010,9 @@ name = "valkey-glide"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "anyio", marker = "sys_platform != 'win32'" },
|
||||
{ name = "protobuf", marker = "sys_platform != 'win32'" },
|
||||
{ name = "sniffio", marker = "sys_platform != 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
|
||||
wheels = [
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 {
|
||||
@@ -26,9 +27,79 @@ 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 } from 'lucide-react';
|
||||
import {
|
||||
Settings,
|
||||
FileText,
|
||||
Users,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
CircleCheck,
|
||||
CircleAlert,
|
||||
Loader2,
|
||||
CircleOff,
|
||||
} 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 }) {
|
||||
@@ -64,16 +135,24 @@ 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) {
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
setEnableLoaded(true);
|
||||
});
|
||||
fetchBotDetail();
|
||||
const timer = window.setInterval(fetchBotDetail, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}
|
||||
}, [id, isCreateMode]);
|
||||
}, [fetchBotDetail, isCreateMode]);
|
||||
|
||||
const handleEnableToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
@@ -101,9 +180,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
|
||||
function handleFormSubmit() {
|
||||
// Re-sync enable state after form save (form may update enable too)
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
});
|
||||
fetchBotDetail();
|
||||
refreshBots();
|
||||
}
|
||||
|
||||
@@ -184,6 +261,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<RuntimeStatusBadge status={getBotRuntimeStatus(botDetail)} />
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button
|
||||
|
||||
@@ -688,7 +688,10 @@ 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);
|
||||
form.setValue(key as keyof FormValues, value as never, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -15,12 +15,14 @@ import {
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
|
||||
|
||||
export type QrLoginPlatform =
|
||||
| 'feishu'
|
||||
| 'weixin'
|
||||
| 'dingtalk'
|
||||
| 'wecombot'
|
||||
| 'itchat'
|
||||
| 'qqofficial';
|
||||
|
||||
interface PlatformConfig {
|
||||
@@ -55,12 +57,12 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
},
|
||||
weixin: {
|
||||
titleKey: 'weixin.scanLogin',
|
||||
connectingKey: 'feishu.connecting',
|
||||
connectingKey: 'weixin.connecting',
|
||||
scanQRCodeKey: 'weixin.scanQRCode',
|
||||
waitingKey: 'feishu.waitingForScan',
|
||||
waitingKey: 'weixin.waitingForScan',
|
||||
successKey: 'weixin.loginSuccess',
|
||||
failedKey: 'weixin.loginFailed',
|
||||
retryKey: 'feishu.retry',
|
||||
retryKey: 'weixin.retry',
|
||||
apiBase: '/api/v1/platform/adapters/weixin/login',
|
||||
extractSuccess: (data) => ({
|
||||
token: data.token,
|
||||
@@ -98,6 +100,20 @@ 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',
|
||||
@@ -138,6 +154,7 @@ 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('');
|
||||
@@ -146,6 +163,8 @@ export default function QrCodeLoginDialog({
|
||||
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const sessionWorkspaceUuidRef = useRef<string | null>(null);
|
||||
const sessionApiBaseRef = useRef('');
|
||||
const baseUrlRef = useRef('');
|
||||
const cleanedRef = useRef(false);
|
||||
|
||||
@@ -180,18 +199,23 @@ export default function QrCodeLoginDialog({
|
||||
}
|
||||
if (sessionIdRef.current) {
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
const workspaceUuid = sessionWorkspaceUuidRef.current;
|
||||
fetch(
|
||||
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
|
||||
`${baseUrlRef.current}${sessionApiBaseRef.current}/${sessionIdRef.current}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
|
||||
},
|
||||
keepalive: true,
|
||||
},
|
||||
).catch(() => {});
|
||||
sessionIdRef.current = null;
|
||||
}
|
||||
sessionWorkspaceUuidRef.current = null;
|
||||
sessionApiBaseRef.current = '';
|
||||
baseUrlRef.current = '';
|
||||
}, []);
|
||||
|
||||
const startLogin = useCallback(async () => {
|
||||
@@ -199,11 +223,13 @@ export default function QrCodeLoginDialog({
|
||||
cleanedRef.current = false;
|
||||
setState('connecting');
|
||||
setQrDataUrl('');
|
||||
qrDataUrlRef.current = '';
|
||||
setExpireIn(0);
|
||||
setErrorMessage('');
|
||||
setSuccessMeta('');
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const workspaceUuid = getActiveWorkspaceUuid();
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
baseUrlRef.current = baseUrl;
|
||||
const cfg = platformConfigRef.current;
|
||||
@@ -214,7 +240,10 @@ export default function QrCodeLoginDialog({
|
||||
|
||||
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -225,15 +254,19 @@ export default function QrCodeLoginDialog({
|
||||
|
||||
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||
sessionIdRef.current = session_id;
|
||||
sessionWorkspaceUuidRef.current = workspaceUuid;
|
||||
sessionApiBaseRef.current = cfg.apiBase;
|
||||
|
||||
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');
|
||||
|
||||
@@ -270,11 +303,19 @@ export default function QrCodeLoginDialog({
|
||||
`${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(workspaceUuid
|
||||
? { 'X-Workspace-Id': workspaceUuid }
|
||||
: {}),
|
||||
},
|
||||
keepalive: true,
|
||||
},
|
||||
).catch(() => {});
|
||||
sessionIdRef.current = null;
|
||||
sessionWorkspaceUuidRef.current = null;
|
||||
sessionApiBaseRef.current = '';
|
||||
baseUrlRef.current = '';
|
||||
}
|
||||
setState('expired');
|
||||
}
|
||||
@@ -286,7 +327,12 @@ export default function QrCodeLoginDialog({
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!pollRes.ok) return;
|
||||
|
||||
@@ -320,6 +366,19 @@ 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,6 +203,28 @@ 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;
|
||||
@@ -215,7 +237,7 @@ export interface Bot {
|
||||
pipeline_routing_rules?: PipelineRoutingRule[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
adapter_runtime_values?: object;
|
||||
adapter_runtime_values?: BotAdapterRuntimeValues;
|
||||
}
|
||||
|
||||
export type RoutingRuleOperator =
|
||||
|
||||
@@ -372,6 +372,10 @@ 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',
|
||||
@@ -1921,6 +1925,19 @@ const enUS = {
|
||||
'Scan the QR code below with WeChat to authorize and automatically fill in the token',
|
||||
loginSuccess: 'Login successful! Token has been filled in',
|
||||
loginFailed: 'Login failed',
|
||||
connecting: 'Connecting to WeChat service...',
|
||||
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',
|
||||
|
||||
@@ -220,6 +220,19 @@ const esES = {
|
||||
selectModelAbilities: 'Seleccionar capacidades del modelo',
|
||||
visionAbility: 'Capacidad de visión',
|
||||
functionCallAbility: 'Llamada a funciones',
|
||||
reasoningAbility: 'Razonamiento',
|
||||
reasoningLevel: 'Nivel de razonamiento',
|
||||
reasoningLevels: {
|
||||
providerDefault: 'Predeterminado del proveedor',
|
||||
disabled: 'Desactivado',
|
||||
enabled: 'Activado',
|
||||
minimal: 'Mínimo',
|
||||
low: 'Bajo',
|
||||
medium: 'Medio',
|
||||
high: 'Alto',
|
||||
xhigh: 'Extra alto',
|
||||
max: 'Máximo',
|
||||
},
|
||||
contextLength: 'Ventana de contexto',
|
||||
contextLengthPlaceholder: 'Desconocido',
|
||||
contextLengthInvalid: 'La ventana de contexto debe ser un entero positivo',
|
||||
@@ -1747,6 +1760,9 @@ const esES = {
|
||||
loginSuccess:
|
||||
'¡Inicio de sesión correcto! El token se ha rellenado automáticamente',
|
||||
loginFailed: 'Error al iniciar sesión',
|
||||
connecting: 'Conectando con el servicio de WeChat...',
|
||||
waitingForScan: 'Esperando escaneo',
|
||||
retry: 'Reintentar',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'Crear aplicación de DingTalk con un clic',
|
||||
|
||||
@@ -378,6 +378,10 @@ const jaJP = {
|
||||
log: 'ログ',
|
||||
configuration: '設定',
|
||||
logs: 'ログ',
|
||||
runtimeConnected: '接続済み',
|
||||
runtimeConnecting: '接続中',
|
||||
runtimeDisconnected: '切断済み',
|
||||
runtimeError: '接続エラー',
|
||||
basicInfo: '基本情報',
|
||||
basicInfoDescription: 'ボットの名前と説明を設定',
|
||||
routingConnection: 'ルーティングと接続',
|
||||
@@ -1837,6 +1841,9 @@ const jaJP = {
|
||||
scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力',
|
||||
loginSuccess: 'ログイン成功!トークンが自動入力されました',
|
||||
loginFailed: 'ログイン失敗',
|
||||
connecting: 'WeChatサービスに接続中...',
|
||||
waitingForScan: 'スキャン待ち',
|
||||
retry: '再試行',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'ワンクリックでDingTalkアプリ作成',
|
||||
|
||||
@@ -217,6 +217,19 @@ const ruRU = {
|
||||
selectModelAbilities: 'Выберите возможности модели',
|
||||
visionAbility: 'Распознавание изображений',
|
||||
functionCallAbility: 'Вызов функций',
|
||||
reasoningAbility: 'Рассуждение',
|
||||
reasoningLevel: 'Уровень рассуждений',
|
||||
reasoningLevels: {
|
||||
providerDefault: 'По умолчанию провайдера',
|
||||
disabled: 'Выключено',
|
||||
enabled: 'Включено',
|
||||
minimal: 'Минимальный',
|
||||
low: 'Низкий',
|
||||
medium: 'Средний',
|
||||
high: 'Высокий',
|
||||
xhigh: 'Очень высокий',
|
||||
max: 'Максимальный',
|
||||
},
|
||||
contextLength: 'Контекстное окно',
|
||||
contextLengthPlaceholder: 'Неизвестно',
|
||||
contextLengthInvalid:
|
||||
@@ -1717,6 +1730,9 @@ const ruRU = {
|
||||
'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен',
|
||||
loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически',
|
||||
loginFailed: 'Не удалось выполнить вход',
|
||||
connecting: 'Подключение к сервису WeChat...',
|
||||
waitingForScan: 'Ожидание сканирования',
|
||||
retry: 'Повторить',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'Создать приложение DingTalk в один клик',
|
||||
|
||||
@@ -213,6 +213,19 @@ const thTH = {
|
||||
selectModelAbilities: 'เลือกความสามารถของโมเดล',
|
||||
visionAbility: 'ความสามารถด้านภาพ',
|
||||
functionCallAbility: 'การเรียกฟังก์ชัน',
|
||||
reasoningAbility: 'ความสามารถในการให้เหตุผล',
|
||||
reasoningLevel: 'ระดับการให้เหตุผล',
|
||||
reasoningLevels: {
|
||||
providerDefault: 'ค่าเริ่มต้นของผู้ให้บริการ',
|
||||
disabled: 'ปิด',
|
||||
enabled: 'เปิด',
|
||||
minimal: 'ต่ำสุด',
|
||||
low: 'ต่ำ',
|
||||
medium: 'ปานกลาง',
|
||||
high: 'สูง',
|
||||
xhigh: 'สูงมาก',
|
||||
max: 'สูงสุด',
|
||||
},
|
||||
contextLength: 'หน้าต่างบริบท',
|
||||
contextLengthPlaceholder: 'ไม่ทราบ',
|
||||
contextLengthInvalid: 'หน้าต่างบริบทต้องเป็นจำนวนเต็มบวก',
|
||||
@@ -1680,6 +1693,9 @@ const thTH = {
|
||||
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ',
|
||||
loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว',
|
||||
loginFailed: 'เข้าสู่ระบบไม่สำเร็จ',
|
||||
connecting: 'กำลังเชื่อมต่อบริการ WeChat...',
|
||||
waitingForScan: 'กำลังรอการสแกน',
|
||||
retry: 'ลองอีกครั้ง',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว',
|
||||
|
||||
@@ -217,6 +217,19 @@ const viVN = {
|
||||
selectModelAbilities: 'Chọn khả năng mô hình',
|
||||
visionAbility: 'Khả năng thị giác',
|
||||
functionCallAbility: 'Gọi hàm',
|
||||
reasoningAbility: 'Khả năng suy luận',
|
||||
reasoningLevel: 'Mức độ suy luận',
|
||||
reasoningLevels: {
|
||||
providerDefault: 'Mặc định của nhà cung cấp',
|
||||
disabled: 'Tắt',
|
||||
enabled: 'Bật',
|
||||
minimal: 'Tối thiểu',
|
||||
low: 'Thấp',
|
||||
medium: 'Trung bình',
|
||||
high: 'Cao',
|
||||
xhigh: 'Rất cao',
|
||||
max: 'Tối đa',
|
||||
},
|
||||
contextLength: 'Cửa sổ ngữ cảnh',
|
||||
contextLengthPlaceholder: 'Không rõ',
|
||||
contextLengthInvalid: 'Cửa sổ ngữ cảnh phải là số nguyên dương',
|
||||
@@ -1708,6 +1721,9 @@ const viVN = {
|
||||
'Quét mã QR bên dưới bằng WeChat để ủy quyền và tự động điền token',
|
||||
loginSuccess: 'Đăng nhập thành công! Token đã được điền tự động',
|
||||
loginFailed: 'Đăng nhập thất bại',
|
||||
connecting: 'Đang kết nối tới dịch vụ WeChat...',
|
||||
waitingForScan: 'Đang chờ quét mã',
|
||||
retry: 'Thử lại',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'Tạo ứng dụng DingTalk chỉ với một lần nhấp',
|
||||
|
||||
@@ -355,6 +355,10 @@ const zhHans = {
|
||||
log: '日志',
|
||||
configuration: '配置',
|
||||
logs: '日志',
|
||||
runtimeConnected: '已连接',
|
||||
runtimeConnecting: '连接中',
|
||||
runtimeDisconnected: '已掉线',
|
||||
runtimeError: '连接错误',
|
||||
basicInfo: '基础信息',
|
||||
basicInfoDescription: '设置机器人名称和描述',
|
||||
routingConnection: '路由与连接',
|
||||
@@ -1834,6 +1838,18 @@ const zhHans = {
|
||||
scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌',
|
||||
loginSuccess: '登录成功!令牌已自动填入',
|
||||
loginFailed: '登录失败',
|
||||
connecting: '正在连接微信服务...',
|
||||
waitingForScan: '等待扫码中',
|
||||
retry: '重试',
|
||||
},
|
||||
itchat: {
|
||||
scanLogin: '扫码登录微信',
|
||||
scanQRCode: '请使用微信扫描以下二维码登录',
|
||||
loginSuccess: '登录成功!会话已缓存,保存配置后即可使用',
|
||||
loginFailed: '登录失败',
|
||||
connecting: '正在启动微信登录...',
|
||||
waitingForScan: '等待扫码中',
|
||||
retry: '重试',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: '一键创建钉钉应用',
|
||||
|
||||
@@ -205,6 +205,19 @@ const zhHant = {
|
||||
selectModelAbilities: '選擇模型能力',
|
||||
visionAbility: '視覺能力',
|
||||
functionCallAbility: '函數呼叫',
|
||||
reasoningAbility: '思考能力',
|
||||
reasoningLevel: '思考等級',
|
||||
reasoningLevels: {
|
||||
providerDefault: '供應商預設',
|
||||
disabled: '關閉',
|
||||
enabled: '開啟',
|
||||
minimal: '最低',
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
xhigh: '極高',
|
||||
max: '最大',
|
||||
},
|
||||
contextLength: '上下文視窗',
|
||||
contextLengthPlaceholder: '未知',
|
||||
contextLengthInvalid: '上下文視窗必須是正整數',
|
||||
@@ -1657,6 +1670,9 @@ const zhHant = {
|
||||
scanQRCode: '請使用微信掃描以下 QR Code,授權後將自動登入並填寫令牌',
|
||||
loginSuccess: '登入成功!令牌已自動填入',
|
||||
loginFailed: '登入失敗',
|
||||
connecting: '正在連接微信服務...',
|
||||
waitingForScan: '等待掃碼中',
|
||||
retry: '重試',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: '一鍵建立釘釘應用',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const dialogPath = path.join(
|
||||
root,
|
||||
'src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx',
|
||||
);
|
||||
const localeDir = path.join(root, 'src/i18n/locales');
|
||||
|
||||
const dialogSource = fs.readFileSync(dialogPath, 'utf8');
|
||||
|
||||
test('QR credential exchanges preserve the active Workspace scope', () => {
|
||||
assert.match(dialogSource, /getActiveWorkspaceUuid/);
|
||||
assert.match(
|
||||
dialogSource,
|
||||
/sessionWorkspaceUuidRef\.current = workspaceUuid/,
|
||||
);
|
||||
assert.match(
|
||||
dialogSource,
|
||||
/const workspaceUuid = sessionWorkspaceUuidRef\.current/,
|
||||
);
|
||||
assert.match(dialogSource, /sessionApiBaseRef\.current = cfg\.apiBase/);
|
||||
assert.match(
|
||||
dialogSource,
|
||||
/`\$\{baseUrlRef\.current\}\$\{sessionApiBaseRef\.current\}\/\$\{sessionIdRef\.current\}`/,
|
||||
);
|
||||
assert.match(dialogSource, /'X-Workspace-Id': workspaceUuid/);
|
||||
|
||||
const workspaceHeaderUses = dialogSource.match(
|
||||
/'X-Workspace-Id': workspaceUuid/g,
|
||||
);
|
||||
assert.equal(
|
||||
workspaceHeaderUses?.length,
|
||||
4,
|
||||
'start, poll, expiry cleanup, and dialog cleanup must all retain Workspace scope',
|
||||
);
|
||||
});
|
||||
|
||||
test('WeChat QR login never reuses Feishu progress copy', () => {
|
||||
const weixinConfig = dialogSource.match(
|
||||
/weixin:\s*\{[\s\S]*?apiBase:\s*'\/api\/v1\/platform\/adapters\/weixin\/login'/,
|
||||
)?.[0];
|
||||
assert.ok(weixinConfig, 'WeChat platform config is missing');
|
||||
assert.match(weixinConfig, /connectingKey:\s*'weixin\.connecting'/);
|
||||
assert.match(weixinConfig, /waitingKey:\s*'weixin\.waitingForScan'/);
|
||||
assert.match(weixinConfig, /retryKey:\s*'weixin\.retry'/);
|
||||
assert.doesNotMatch(weixinConfig, /feishu\./);
|
||||
|
||||
for (const locale of [
|
||||
'en-US.ts',
|
||||
'es-ES.ts',
|
||||
'ja-JP.ts',
|
||||
'ru-RU.ts',
|
||||
'th-TH.ts',
|
||||
'vi-VN.ts',
|
||||
'zh-Hans.ts',
|
||||
'zh-Hant.ts',
|
||||
]) {
|
||||
const source = fs.readFileSync(path.join(localeDir, locale), 'utf8');
|
||||
const block = source.match(/weixin:\s*\{[\s\S]*?\n\s*\},/)?.[0];
|
||||
assert.ok(block, `${locale} is missing the WeChat locale block`);
|
||||
for (const key of ['connecting', 'waitingForScan', 'retry']) {
|
||||
assert.match(
|
||||
block,
|
||||
new RegExp(`\\b${key}:`),
|
||||
`${locale} is missing weixin.${key}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user