From f412127fb0c9d22e33cc9d4ae53c600fe596c31d Mon Sep 17 00:00:00 2001 From: Dongchuan Fu <2213070223@qq.com> Date: Sun, 10 May 2026 22:31:31 +0800 Subject: [PATCH] feat: add one-click app creation for Feishu/dingding/wexin/wecombot with QR code support (#2165) * feat: add one-click app creation for Feishu with QR code support * feat: implement WeChat QR code login functionality and update related configurations * feat: add qrcode dependency for QR code generation support * feat: enhance QR code login UI and add internationalization support for new labels * feat: new ui back * feat: add DingTalk one-click app creation and QR code login support * feat: add WeComBot one-click creation support and enhance QR code login functionality * feat: Update the robot creation function and bind the most recently updated pipeline --- pyproject.toml | 3 +- .../controller/groups/platform/adapters.py | 638 ++++++++++++++++++ src/langbot/pkg/api/http/service/bot.py | 8 +- .../pkg/platform/sources/dingtalk.yaml | 16 + src/langbot/pkg/platform/sources/lark.yaml | 14 + .../pkg/platform/sources/openclaw_weixin.yaml | 14 + .../pkg/platform/sources/wecombot.yaml | 12 + .../home/bots/components/bot-form/BotForm.tsx | 1 + .../dynamic-form/DynamicFormComponent.tsx | 89 ++- .../dynamic-form/DynamicFormItemConfig.ts | 2 + .../qrcode-login/QrCodeLoginDialog.tsx | 366 ++++++++++ .../qrcode-login/QrCodeLoginDialog.tsx.back | 393 +++++++++++ web/src/app/infra/entities/form/dynamic.ts | 2 + web/src/app/wizard/page.tsx | 2 + web/src/i18n/locales/en-US.ts | 47 ++ web/src/i18n/locales/ja-JP.ts | 42 ++ web/src/i18n/locales/zh-Hans.ts | 43 ++ web/src/i18n/locales/zh-Hant.ts | 43 ++ 18 files changed, 1728 insertions(+), 7 deletions(-) create mode 100644 web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx create mode 100644 web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx.back diff --git a/pyproject.toml b/pyproject.toml index 3dfe9ec3..f37494d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "discord-py>=2.5.2", "pynacl>=1.5.0", # Required for Discord voice support "gewechat-client>=0.1.5", - "lark-oapi>=1.4.15", + "lark-oapi>=1.5.5", "mcp>=1.25.0", "nakuru-project-idk>=0.0.2.1", "ollama>=0.4.8", @@ -35,6 +35,7 @@ dependencies = [ "python-telegram-bot>=22.0", "pyyaml>=6.0.2", "qq-botpy-rc>=1.2.1.6", + "qrcode>=7.4", "quart>=0.20.0", "quart-cors>=0.8.0", "requests>=2.32.3", diff --git a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py index b46e5263..2e034bfc 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/adapters.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/adapters.py @@ -1,5 +1,6 @@ import quart import mimetypes +import asyncio from ... import group from langbot.pkg.utils import importutil @@ -35,3 +36,640 @@ class AdaptersRouterGroup(group.RouterGroup): return quart.Response( importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0] ) + + # In-memory session store for active registrations + _create_app_sessions: dict = {} + _SESSION_TTL = 900 # 15 minutes + + def _cleanup_expired_sessions(): + """Remove sessions that have exceeded their TTL.""" + import time + + now = time.time() + expired = [sid for sid, s in _create_app_sessions.items() if now - s.get('created_at', 0) > _SESSION_TTL] + for sid in expired: + session = _create_app_sessions.pop(sid, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + + @self.route('/lark/create-app', methods=['POST']) + async def _() -> str: + """Start Feishu one-click app registration. Returns session_id + QR code URL.""" + import uuid + import time + import lark_oapi as lark + from lark_oapi.scene.registration.errors import AppAccessDeniedError, AppExpiredError + + _cleanup_expired_sessions() + + session_id = str(uuid.uuid4()) + loop = asyncio.get_running_loop() + + session = { + 'status': 'pending', + 'qr_url': None, + 'expire_at': None, + 'app_id': None, + 'app_secret': None, + 'error': None, + 'created_at': time.time(), + } + _create_app_sessions[session_id] = session + + def on_qr_code(info): + # May be called from a background thread by the SDK; + # use call_soon_threadsafe to safely update session state. + def _update(): + session['qr_url'] = info['url'] + session['expire_at'] = time.time() + 600 # 10 minutes + session['status'] = 'waiting' + + loop.call_soon_threadsafe(_update) + + async def run_registration(): + try: + result = await lark.aregister_app( + on_qr_code=on_qr_code, + source='langbot', + ) + session['status'] = 'success' + session['app_id'] = result['client_id'] + session['app_secret'] = result['client_secret'] + except AppAccessDeniedError: + session['status'] = 'error' + session['error'] = 'User denied authorization' + except AppExpiredError: + session['status'] = 'error' + session['error'] = 'QR code expired' + except Exception as e: + session['status'] = 'error' + session['error'] = str(e) + + task = asyncio.create_task(run_registration()) + session['task'] = task + + # Wait for QR code to be ready (max 10 seconds) + for _ in range(20): + if session['qr_url']: + break + await asyncio.sleep(0.5) + + if not session['qr_url']: + task.cancel() + 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_url': session['qr_url'], + 'expire_at': session['expire_at'], + } + ) + + @self.route('/lark/create-app/status/', methods=['GET']) + async def _(session_id: str) -> str: + """Poll registration status.""" + session = _create_app_sessions.get(session_id) + if not session: + return self.http_status(404, -1, 'Session not found') + + data = {'status': session['status']} + + if session['status'] == 'success': + data['app_id'] = session['app_id'] + data['app_secret'] = session['app_secret'] + _create_app_sessions.pop(session_id, None) + elif session['status'] == 'error': + data['error'] = session['error'] + _create_app_sessions.pop(session_id, None) + + return self.success(data=data) + + @self.route('/lark/create-app/', methods=['DELETE']) + async def _(session_id: str) -> str: + """Cancel and clean up a registration session.""" + session = _create_app_sessions.pop(session_id, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + return self.success(data={}) + + # ----------------------------------------------------------------------- + # WeChat QR Code Login + # ----------------------------------------------------------------------- + + _weixin_login_sessions: dict = {} + _WEIXIN_SESSION_TTL = 600 # 10 minutes (3 retries × 3 min QR validity) + + def _cleanup_expired_weixin_sessions(): + import time + + now = time.time() + expired = [ + sid for sid, s in _weixin_login_sessions.items() if now - s.get('created_at', 0) > _WEIXIN_SESSION_TTL + ] + for sid in expired: + session = _weixin_login_sessions.pop(sid, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + + @self.route('/weixin/login', methods=['POST']) + async def _() -> str: + """Start WeChat QR code login. Returns session_id + QR code data URL.""" + import uuid + import time + import io + import base64 + + from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL + + _cleanup_expired_weixin_sessions() + + session_id = str(uuid.uuid4()) + loop = asyncio.get_running_loop() + + session = { + 'status': 'pending', + 'qr_data_url': None, + 'expire_at': None, + 'token': None, + 'base_url': None, + 'account_id': None, + 'error': None, + 'created_at': time.time(), + } + _weixin_login_sessions[session_id] = session + + client = OpenClawWeixinClient( + base_url=DEFAULT_BASE_URL, + token='', + ) + + async def run_login(): + try: + import qrcode as qr_lib + + for _attempt in range(3): + qr_resp = await client.fetch_qrcode() + if not qr_resp.qrcode or not qr_resp.qrcode_img_content: + raise Exception('Failed to get QR code from server') + + # Generate QR code image locally + qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L) + qr.add_data(qr_resp.qrcode_img_content) + qr.make(fit=True) + img = qr.make_image(fill_color='black', back_color='white') + buf = io.BytesIO() + img.save(buf, format='PNG') + b64 = base64.b64encode(buf.getvalue()).decode('utf-8') + data_url = f'data:image/png;base64,{b64}' + + def _update_qr(): + session['qr_data_url'] = data_url + session['expire_at'] = time.time() + 480 # 8 minutes + session['status'] = 'waiting' + + loop.call_soon_threadsafe(_update_qr) + + # Poll for scan status + deadline = loop.time() + 180 + while loop.time() < deadline: + try: + status_resp = await client.poll_qrcode_status(qr_resp.qrcode) + except Exception: + await asyncio.sleep(2) + continue + + if status_resp.status == 'confirmed' and status_resp.bot_token: + session['status'] = 'success' + session['token'] = status_resp.bot_token + session['base_url'] = status_resp.baseurl or client.base_url + session['account_id'] = status_resp.ilink_bot_id or '' + return + + if status_resp.status == 'expired': + break # retry with new QR code + + await asyncio.sleep(1) + else: + pass # timeout, retry + + # All retries exhausted + session['status'] = 'error' + session['error'] = 'QR code login failed: max retries exceeded' + + except Exception as e: + session['status'] = 'error' + session['error'] = str(e) + finally: + await client.close() + + task = asyncio.create_task(run_login()) + session['task'] = task + + # Wait for QR code to be ready (max 10 seconds) + for _ in range(20): + if session['qr_data_url']: + break + await asyncio.sleep(0.5) + + if not session['qr_data_url']: + task.cancel() + 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('/weixin/login/status/', methods=['GET']) + async def _(session_id: str) -> str: + """Poll WeChat login status.""" + session = _weixin_login_sessions.get(session_id) + if not session: + return self.http_status(404, -1, 'Session not found') + + data = {'status': session['status']} + + if session['status'] == 'success': + data['token'] = session['token'] + data['base_url'] = session['base_url'] + data['account_id'] = session['account_id'] + _weixin_login_sessions.pop(session_id, None) + elif session['status'] == 'error': + data['error'] = session['error'] + _weixin_login_sessions.pop(session_id, None) + + return self.success(data=data) + + @self.route('/weixin/login/', methods=['DELETE']) + async def _(session_id: str) -> str: + """Cancel and clean up a WeChat login session.""" + session = _weixin_login_sessions.pop(session_id, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + return self.success(data={}) + + # ----------------------------------------------------------------------- + # DingTalk Device Flow QR Code Login + # ----------------------------------------------------------------------- + + _dingtalk_sessions: dict = {} + _DINGTALK_SESSION_TTL = 600 # 10 minutes (QR code validity window) + + def _cleanup_expired_dingtalk_sessions(): + import time + + now = time.time() + expired = [ + sid for sid, s in _dingtalk_sessions.items() if now - s.get('created_at', 0) > _DINGTALK_SESSION_TTL + ] + for sid in expired: + session = _dingtalk_sessions.pop(sid, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + + @self.route('/dingtalk/create-app', methods=['POST']) + async def _() -> str: + """Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL.""" + import uuid + import time + import aiohttp + + DINGTALK_BASE_URL = 'https://oapi.dingtalk.com' + + _cleanup_expired_dingtalk_sessions() + + session_id = str(uuid.uuid4()) + + session = { + 'status': 'pending', + 'qr_url': None, + 'expire_at': None, + 'client_id': None, + 'client_secret': None, + 'error': None, + 'created_at': time.time(), + 'device_code': None, + 'interval': 5, + } + _dingtalk_sessions[session_id] = session + + async def run_device_flow(): + try: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as http: + # Step 1: Init — get nonce + async with http.post( + f'{DINGTALK_BASE_URL}/app/registration/init', + json={'source': 'langbot'}, + ) as resp: + try: + data = await resp.json() + except (aiohttp.ContentTypeError, ValueError): + session['status'] = 'error' + session['error'] = 'Invalid response from DingTalk service' + return + if data.get('errcode', -1) != 0: + session['status'] = 'error' + session['error'] = data.get('errmsg', 'Failed to init') + return + nonce = data['nonce'] + + # Step 2: Begin — get device_code + QR URL + async with http.post( + f'{DINGTALK_BASE_URL}/app/registration/begin', + json={'nonce': nonce}, + ) as resp: + try: + data = await resp.json() + except (aiohttp.ContentTypeError, ValueError): + session['status'] = 'error' + session['error'] = 'Invalid response from DingTalk service' + return + if data.get('errcode', -1) != 0: + session['status'] = 'error' + session['error'] = data.get('errmsg', 'Failed to begin authorization') + return + + device_code = data['device_code'] + verification_uri_complete = data.get('verification_uri_complete', '') + expires_in = data.get('expires_in', 7200) + interval = data.get('interval', 5) + + session['device_code'] = device_code + session['interval'] = interval + session['qr_url'] = verification_uri_complete + session['expire_at'] = time.time() + 600 # QR code valid for ~10 min + session['status'] = 'waiting' + + # Step 3: Poll for authorization result + deadline = time.time() + expires_in + while time.time() < deadline: + await asyncio.sleep(interval) + + async with http.post( + f'{DINGTALK_BASE_URL}/app/registration/poll', + json={'device_code': device_code}, + ) as poll_resp: + try: + poll_data = await poll_resp.json() + except (aiohttp.ContentTypeError, ValueError): + continue + + if poll_data.get('errcode', -1) != 0: + session['status'] = 'error' + session['error'] = poll_data.get('errmsg', 'Poll failed') + return + + status = poll_data.get('status', '') + + if status == 'SUCCESS': + session['status'] = 'success' + session['client_id'] = poll_data.get('client_id', '') + session['client_secret'] = poll_data.get('client_secret', '') + return + elif status == 'FAIL': + session['status'] = 'error' + session['error'] = poll_data.get('fail_reason', 'Authorization failed') + return + elif status == 'EXPIRED': + session['status'] = 'error' + session['error'] = 'QR code expired' + return + # status == 'WAITING': continue polling + + # Timeout + session['status'] = 'error' + session['error'] = 'QR code expired' + + except asyncio.CancelledError: + return + except Exception as e: + session['status'] = 'error' + session['error'] = str(e) + + task = asyncio.create_task(run_device_flow()) + session['task'] = task + + # Wait for QR code to be ready (max 10 seconds) + for _ in range(20): + if session['qr_url'] or session['error']: + break + await asyncio.sleep(0.5) + + if session['error']: + task.cancel() + return self.http_status(502, -1, session['error']) + + if not session['qr_url']: + task.cancel() + 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_url': session['qr_url'], + 'expire_at': session['expire_at'], + } + ) + + @self.route('/dingtalk/create-app/status/', methods=['GET']) + async def _(session_id: str) -> str: + """Poll DingTalk Device Flow status.""" + _cleanup_expired_dingtalk_sessions() + session = _dingtalk_sessions.get(session_id) + if not session: + return self.http_status(404, -1, 'Session not found') + + data = {'status': session['status']} + + if session['status'] == 'success': + data['client_id'] = session['client_id'] + data['client_secret'] = session['client_secret'] + _dingtalk_sessions.pop(session_id, None) + elif session['status'] == 'error': + data['error'] = session['error'] + _dingtalk_sessions.pop(session_id, None) + + return self.success(data=data) + + @self.route('/dingtalk/create-app/', methods=['DELETE']) + async def _(session_id: str) -> str: + """Cancel and clean up a DingTalk Device Flow session.""" + session = _dingtalk_sessions.pop(session_id, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + return self.success(data={}) + + # ----------------------------------------------------------------------- + # WeComBot QR Code One-Click Create + # ----------------------------------------------------------------------- + + _wecombot_sessions: dict = {} + _WECOMBOT_SESSION_TTL = 300 # 5 minutes (WeCom QR validity window) + + def _cleanup_expired_wecombot_sessions(): + import time + + now = time.time() + expired = [ + sid for sid, s in _wecombot_sessions.items() if now - s.get('created_at', 0) > _WECOMBOT_SESSION_TTL + ] + for sid in expired: + session = _wecombot_sessions.pop(sid, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + + @self.route('/wecombot/create-bot', methods=['POST']) + async def _() -> str: + """Start WeComBot one-click creation via QR code. Returns session_id + QR code URL.""" + import uuid + import time + import aiohttp + + WECOM_QC_GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate' + WECOM_QC_QUERY_URL = 'https://work.weixin.qq.com/ai/qc/query_result' + + _cleanup_expired_wecombot_sessions() + + session_id = str(uuid.uuid4()) + + session = { + 'status': 'pending', + 'qr_url': None, + 'expire_at': None, + 'botid': None, + 'secret': None, + 'error': None, + 'created_at': time.time(), + 'scode': None, + 'task': None, + } + _wecombot_sessions[session_id] = session + + async def run_qr_flow(): + try: + timeout = aiohttp.ClientTimeout(total=10) + async with aiohttp.ClientSession(timeout=timeout) as http: + # Step 1: Generate QR code + async with http.get( + f'{WECOM_QC_GENERATE_URL}?source=langbot&plat=0', + ) as resp: + try: + data = await resp.json() + except (aiohttp.ContentTypeError, ValueError): + session['status'] = 'error' + session['error'] = 'Invalid response from WeCom service' + return + if not data.get('data', {}).get('scode') or not data.get('data', {}).get('auth_url'): + session['status'] = 'error' + session['error'] = data.get('errmsg', 'Failed to generate QR code') + return + + scode = data['data']['scode'] + auth_url = data['data']['auth_url'] + + session['scode'] = scode + session['qr_url'] = auth_url + session['expire_at'] = time.time() + _WECOMBOT_SESSION_TTL + session['status'] = 'waiting' + + # Step 2: Poll for scan result + deadline = time.time() + _WECOMBOT_SESSION_TTL + while time.time() < deadline: + await asyncio.sleep(3) + + async with http.get( + f'{WECOM_QC_QUERY_URL}?scode={scode}', + ) as poll_resp: + try: + poll_data = await poll_resp.json() + except (aiohttp.ContentTypeError, ValueError): + continue + + status = poll_data.get('data', {}).get('status', '') + if status == 'success': + bot_info = poll_data.get('data', {}).get('bot_info', {}) + if bot_info.get('botid') and bot_info.get('secret'): + session['status'] = 'success' + session['botid'] = bot_info['botid'] + session['secret'] = bot_info['secret'] + return + else: + session['status'] = 'error' + session['error'] = 'Scan succeeded but bot info is incomplete' + return + + # Timeout + session['status'] = 'error' + session['error'] = 'QR code expired' + + except asyncio.CancelledError: + return + except Exception as e: + session['status'] = 'error' + session['error'] = str(e) + + task = asyncio.create_task(run_qr_flow()) + session['task'] = task + + # Wait for QR code to be ready (max 10 seconds) + for _ in range(20): + if session['qr_url'] or session['error']: + break + await asyncio.sleep(0.5) + + if session['error']: + task.cancel() + return self.http_status(502, -1, session['error']) + + if not session['qr_url']: + task.cancel() + 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_url': session['qr_url'], + 'expire_at': session['expire_at'], + } + ) + + @self.route('/wecombot/create-bot/status/', methods=['GET']) + async def _(session_id: str) -> str: + """Poll WeComBot creation status.""" + _cleanup_expired_wecombot_sessions() + session = _wecombot_sessions.get(session_id) + if not session: + return self.http_status(404, -1, 'Session not found') + + data = {'status': session['status']} + + if session['status'] == 'success': + data['botid'] = session['botid'] + data['secret'] = session['secret'] + _wecombot_sessions.pop(session_id, None) + elif session['status'] == 'error': + data['error'] = session['error'] + _wecombot_sessions.pop(session_id, None) + + return self.success(data=data) + + @self.route('/wecombot/create-bot/', methods=['DELETE']) + async def _(session_id: str) -> str: + """Cancel and clean up a WeComBot creation session.""" + session = _wecombot_sessions.pop(session_id, None) + if session and session.get('task') and not session['task'].done(): + session['task'].cancel() + return self.success(data={}) diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 332c8ec7..8cdb701d 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -99,11 +99,11 @@ class BotService: # TODO: 检查配置信息格式 bot_data['uuid'] = str(uuid.uuid4()) - # checkout the default pipeline + # bind the most recently updated pipeline if any exist result = await self.ap.persistence_mgr.execute_async( - sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( - persistence_pipeline.LegacyPipeline.is_default == True - ) + sqlalchemy.select(persistence_pipeline.LegacyPipeline) + .order_by(persistence_pipeline.LegacyPipeline.updated_at.desc()) + .limit(1) ) pipeline = result.first() if pipeline is not None: diff --git a/src/langbot/pkg/platform/sources/dingtalk.yaml b/src/langbot/pkg/platform/sources/dingtalk.yaml index 8b2fc955..c7c25e67 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.yaml +++ b/src/langbot/pkg/platform/sources/dingtalk.yaml @@ -19,6 +19,18 @@ spec: en: https://link.langbot.app/en/platforms/dingtalk ja: https://link.langbot.app/ja/platforms/dingtalk config: + - name: one-click-create + label: + en_US: One-Click Create App + zh_Hans: 一键创建应用 + zh_Hant: 一鍵建立應用 + description: + en_US: "Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be obtained automatically, you need to copy it from the DingTalk Developer Backend manually." + zh_Hans: "使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。" + zh_Hant: "使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。" + type: qr-code-login + login_platform: dingtalk + required: false - name: client_id label: en_US: Client ID @@ -40,6 +52,10 @@ spec: en_US: Robot Code zh_Hans: 机器人代码 zh_Hant: 機器人代碼 + description: + en_US: "Required for image recognition, file upload and other features. Get it from DingTalk Developer Backend > Robot Configuration." + zh_Hans: "识图、上传文件等功能必填。请前往钉钉开发者后台 > 机器人配置中获取。" + zh_Hant: "識圖、上傳檔案等功能必填。請前往釘釘開發者後台 > 機器人設定中取得。" type: string required: true default: "" diff --git a/src/langbot/pkg/platform/sources/lark.yaml b/src/langbot/pkg/platform/sources/lark.yaml index 0e6093cb..bf2fe3fb 100644 --- a/src/langbot/pkg/platform/sources/lark.yaml +++ b/src/langbot/pkg/platform/sources/lark.yaml @@ -23,6 +23,20 @@ spec: en: https://link.langbot.app/en/platforms/lark ja: https://link.langbot.app/ja/platforms/lark config: + - name: one-click-create + label: + en_US: One-Click Create App + zh_Hans: 一键创建应用 + zh_Hant: 一鍵建立應用 + ja_JP: ワンクリックでアプリ作成 + description: + en_US: Scan QR code to automatically create a Feishu app and fill in credentials + zh_Hans: 扫码自动创建飞书应用并填写凭据 + zh_Hant: 掃碼自動建立飛書應用並填寫憑證 + ja_JP: QRコードをスキャンしてFeishuアプリを自動作成し、認証情報を入力 + type: qr-code-login + login_platform: feishu + required: false - name: app_id label: en_US: App ID diff --git a/src/langbot/pkg/platform/sources/openclaw_weixin.yaml b/src/langbot/pkg/platform/sources/openclaw_weixin.yaml index 5d3d3925..a8dec644 100644 --- a/src/langbot/pkg/platform/sources/openclaw_weixin.yaml +++ b/src/langbot/pkg/platform/sources/openclaw_weixin.yaml @@ -32,6 +32,20 @@ spec: type: string required: true default: "https://ilinkai.weixin.qq.com" + - name: qr-login + label: + en_US: Scan QR Login + zh_Hans: 扫码登录 + zh_Hant: 掃碼登入 + ja_JP: QRコードでログイン + description: + en_US: Scan QR code with WeChat to authorize and automatically fill in the token + zh_Hans: 使用微信扫码授权,自动填写令牌 + zh_Hant: 使用微信掃碼授權,自動填寫令牌 + ja_JP: WeChatでQRコードをスキャンし、トークンを自動入力 + type: qr-code-login + login_platform: weixin + required: false - name: token label: en_US: Token diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index 08b7315a..5f65dea6 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -19,6 +19,18 @@ spec: en: https://link.langbot.app/en/platforms/wecombot ja: https://link.langbot.app/ja/platforms/wecombot config: + - name: one-click-create + label: + en_US: One-Click Create Bot + zh_Hans: 一键创建机器人 + zh_Hant: 一鍵建立機器人 + description: + en_US: "Scan QR code with WeCom to automatically create a bot and fill in BotId and Secret. Note: Robot Name needs to be filled in manually." + zh_Hans: "使用企业微信扫码自动创建机器人并填写 BotId 和 Secret。注意:机器人名称需手动填写。" + zh_Hant: "使用企業微信掃碼自動建立機器人並填寫 BotId 和 Secret。注意:機器人名稱需手動填寫。" + type: qr-code-login + login_platform: wecombot + required: false - name: BotId label: en_US: BotId diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 3b0ec3de..c81225de 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -267,6 +267,7 @@ export default function BotForm({ type: parseDynamicFormItemType(item.type), options: item.options, show_if: item.show_if, + login_platform: item.login_platform, }), ), ); diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index f52f7cd3..ffea18d6 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -11,13 +11,16 @@ import { FormMessage, } from '@/components/ui/form'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; +import QrCodeLoginDialog, { + QrLoginPlatform, +} from '@/app/home/components/qrcode-login/QrCodeLoginDialog'; import { useEffect, useMemo, useRef, useState } from 'react'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; -import { Copy, Check, Globe } from 'lucide-react'; +import { Copy, Check, Globe, QrCode } from 'lucide-react'; import { copyToClipboard } from '@/app/utils/clipboard'; import { systemInfo } from '@/app/infra/http'; @@ -255,7 +258,10 @@ export default function DynamicFormComponent({ const editableItems = useMemo( () => itemConfigList.filter( - (item) => item.type !== 'webhook-url' && item.type !== 'embed-code', + (item) => + item.type !== 'webhook-url' && + item.type !== 'embed-code' && + item.type !== 'qr-code-login', ), [itemConfigList], ); @@ -449,9 +455,28 @@ export default function DynamicFormComponent({ return () => subscription.unsubscribe(); }, [form, editableItems]); + // State for QR code login dialog + const [qrDialogOpen, setQrDialogOpen] = useState(false); + const [qrDialogPlatform, setQrDialogPlatform] = + useState('feishu'); + return (
+ {/* QR code login dialog */} + { + for (const [key, value] of Object.entries(credentials)) { + if (value) { + form.setValue(key as keyof FormValues, value as never); + } + } + }} + /> + {itemConfigList.map((config) => { if (config.show_if) { const dependValue = resolveShowIfValue( @@ -538,6 +563,66 @@ export default function DynamicFormComponent({ ); } + // QR code login button (e.g. Feishu one-click create, WeChat scan login) + if (config.type === 'qr-code-login') { + return ( + +
{ + if (!isEditing) { + setQrDialogPlatform( + (config.login_platform as QrLoginPlatform) || 'feishu', + ); + setQrDialogOpen(true); + } + }} + > +
+ +
+
+
+ + {extractI18nObject(config.label)} + + + {t('common.recommend')} + +
+ {config.description && ( +

+ {extractI18nObject(config.description)} +

+ )} +
+ +
+
+ ); + } + // Boolean fields use a special inline layout if (config.type === 'boolean') { return ( diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts index 18ff3a0b..50ac578a 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemConfig.ts @@ -16,6 +16,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema { description?: I18nObject; options?: IDynamicFormItemOption[]; show_if?: IShowIfCondition; + login_platform?: string; constructor(params: IDynamicFormItemSchema) { this.id = params.id; @@ -27,6 +28,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema { this.description = params.description; this.options = params.options; this.show_if = params.show_if; + this.login_platform = params.login_platform; } } diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx new file mode 100644 index 00000000..766dafb0 --- /dev/null +++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx @@ -0,0 +1,366 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { useTranslation } from 'react-i18next'; +import { Loader2, RefreshCw, CheckCircle2, XCircle } from 'lucide-react'; +import QRCode from 'qrcode'; + +export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot'; + +interface PlatformConfig { + titleKey: string; + connectingKey: string; + scanQRCodeKey: string; + waitingKey: string; + successKey: string; + failedKey: string; + retryKey: string; + apiBase: string; + extractSuccess: (data: Record) => Record; + successNoteKey?: string; +} + +const PLATFORM_CONFIGS: Record = { + feishu: { + titleKey: 'feishu.createApp', + connectingKey: 'feishu.connecting', + scanQRCodeKey: 'feishu.scanQRCode', + waitingKey: 'feishu.waitingForScan', + successKey: 'feishu.createSuccess', + failedKey: 'feishu.createFailed', + retryKey: 'feishu.retry', + apiBase: '/api/v1/platform/adapters/lark/create-app', + extractSuccess: (data) => ({ + app_id: data.app_id, + app_secret: data.app_secret, + ...(data.app_name ? { app_name: data.app_name } : {}), + }), + }, + weixin: { + titleKey: 'weixin.scanLogin', + connectingKey: 'feishu.connecting', + scanQRCodeKey: 'weixin.scanQRCode', + waitingKey: 'feishu.waitingForScan', + successKey: 'weixin.loginSuccess', + failedKey: 'weixin.loginFailed', + retryKey: 'feishu.retry', + apiBase: '/api/v1/platform/adapters/weixin/login', + extractSuccess: (data) => ({ + token: data.token, + base_url: data.base_url, + ...(data.account_id ? { account_id: data.account_id } : {}), + }), + }, + dingtalk: { + titleKey: 'dingtalk.createApp', + connectingKey: 'dingtalk.connecting', + scanQRCodeKey: 'dingtalk.scanQRCode', + waitingKey: 'dingtalk.waitingForScan', + successKey: 'dingtalk.createSuccess', + failedKey: 'dingtalk.createFailed', + retryKey: 'dingtalk.retry', + apiBase: '/api/v1/platform/adapters/dingtalk/create-app', + extractSuccess: (data) => ({ + client_id: data.client_id, + client_secret: data.client_secret, + }), + successNoteKey: 'dingtalk.robotCodeNote', + }, + wecombot: { + titleKey: 'wecombot.createBot', + connectingKey: 'wecombot.connecting', + scanQRCodeKey: 'wecombot.scanQRCode', + waitingKey: 'wecombot.waitingForScan', + successKey: 'wecombot.createSuccess', + failedKey: 'wecombot.createFailed', + retryKey: 'wecombot.retry', + apiBase: '/api/v1/platform/adapters/wecombot/create-bot', + extractSuccess: (data) => ({ + BotId: data.botid, + Secret: data.secret, + }), + successNoteKey: 'wecombot.robotNameNote', + }, +}; + +interface QrCodeLoginDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + platform: QrLoginPlatform; + onSuccess: (credentials: Record) => void; +} + +type DialogState = 'connecting' | 'waiting' | 'success' | 'error'; + +const POLL_INTERVAL_MS = 3000; + +export default function QrCodeLoginDialog({ + open, + onOpenChange, + platform, + onSuccess, +}: QrCodeLoginDialogProps) { + const { t } = useTranslation(); + const platformConfig = PLATFORM_CONFIGS[platform]; + + const [state, setState] = useState('connecting'); + const [qrDataUrl, setQrDataUrl] = useState(''); + const [expireIn, setExpireIn] = useState(0); + const [errorMessage, setErrorMessage] = useState(''); + const pollTimerRef = useRef | null>(null); + const countdownRef = useRef | null>(null); + const abortRef = useRef(null); + const sessionIdRef = useRef(null); + const cleanedRef = useRef(false); + + const onSuccessRef = useRef(onSuccess); + onSuccessRef.current = onSuccess; + const onOpenChangeRef = useRef(onOpenChange); + onOpenChangeRef.current = onOpenChange; + const tRef = useRef(t); + tRef.current = t; + const platformConfigRef = useRef(platformConfig); + platformConfigRef.current = platformConfig; + + const cleanup = useCallback(() => { + if (cleanedRef.current) return; + cleanedRef.current = true; + + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + if (abortRef.current) { + abortRef.current.abort(); + abortRef.current = null; + } + // Cancel backend session + if (sessionIdRef.current) { + const token = localStorage.getItem('token'); + const baseUrl = + import.meta.env.VITE_API_BASE_URL || window.location.origin; + fetch( + `${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + keepalive: true, + }, + ).catch(() => {}); + sessionIdRef.current = null; + } + }, []); + + const startLogin = useCallback(async () => { + cleanup(); + cleanedRef.current = false; + setState('connecting'); + setQrDataUrl(''); + setExpireIn(0); + setErrorMessage(''); + + const token = localStorage.getItem('token'); + const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; + const cfg = platformConfigRef.current; + + try { + const controller = new AbortController(); + abortRef.current = controller; + + const res = await fetch(`${baseUrl}${cfg.apiBase}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const json = await res.json(); + if (json.code !== 0) throw new Error(json.msg || 'Request failed'); + + const { session_id, qr_data_url, qr_url, expire_at } = json.data; + sessionIdRef.current = session_id; + + // qr_data_url is a pre-rendered data URL (WeChat); + // qr_url is a plain URL string (Feishu) that needs local QR generation. + if (qr_data_url) { + setQrDataUrl(qr_data_url); + } else if (qr_url) { + const dataUrl = await QRCode.toDataURL(qr_url, { + width: 224, + margin: 2, + }); + setQrDataUrl(dataUrl); + } + setState('waiting'); + + // Calculate remaining seconds + const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000)); + setExpireIn(remaining); + + // Start countdown + countdownRef.current = setInterval(() => { + setExpireIn((prev) => { + if (prev <= 1) { + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + return 0; + } + return prev - 1; + }); + }, 1000); + + // Start polling + pollTimerRef.current = setInterval(async () => { + try { + const pollRes = await fetch( + `${baseUrl}${cfg.apiBase}/status/${session_id}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!pollRes.ok) return; + + const pollJson = await pollRes.json(); + if (pollJson.code !== 0) return; + + const { status, error, ...rest } = pollJson.data; + + if (status === 'success') { + sessionIdRef.current = null; // backend already cleaned up + cleanup(); + setState('success'); + setTimeout(() => { + onSuccessRef.current(cfg.extractSuccess(rest)); + onOpenChangeRef.current(false); + }, 1500); + } else if (status === 'error') { + sessionIdRef.current = null; + cleanup(); + setState('error'); + setErrorMessage(error || tRef.current(cfg.failedKey)); + } + } catch { + // ignore poll errors, will retry next interval + } + }, POLL_INTERVAL_MS); + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') return; + setState('error'); + setErrorMessage( + err instanceof Error ? err.message : tRef.current(cfg.failedKey), + ); + } + }, [cleanup]); + + useEffect(() => { + if (open) { + startLogin(); + } + return () => { + cleanup(); + }; + }, [open, startLogin, cleanup]); + + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + cleanup(); + } + onOpenChange(newOpen); + }; + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + if (m > 0) { + return `${m}m${s.toString().padStart(2, '0')}s`; + } + return `${s}s`; + }; + + return ( + + + + {t(platformConfig.titleKey)} + + +
+ {/* Connecting */} + {state === 'connecting' && ( +
+ +

+ {t(platformConfig.connectingKey)} +

+
+ )} + + {/* QR code area */} + {state === 'waiting' && qrDataUrl && ( +
+

+ {t(platformConfig.scanQRCodeKey)} +

+
+ QR Code +
+ {expireIn > 0 && ( +

+ {t(platformConfig.waitingKey)} ({formatTime(expireIn)}) +

+ )} +
+ )} + + {/* Success */} + {state === 'success' && ( +
+ +

+ {t(platformConfig.successKey)} +

+ {platformConfig.successNoteKey && ( +

+ {t(platformConfig.successNoteKey)} +

+ )} +
+ )} + + {/* Error */} + {state === 'error' && ( +
+ +

+ {errorMessage || t(platformConfig.failedKey)} +

+
+ )} +
+ + {state === 'error' && ( + + + + + )} +
+
+ ); +} diff --git a/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx.back b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx.back new file mode 100644 index 00000000..0a90cacc --- /dev/null +++ b/web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx.back @@ -0,0 +1,393 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { + Dialog, + DialogContent, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { useTranslation } from 'react-i18next'; +import { + Loader2, + RefreshCw, + CheckCircle2, + XCircle, + ScanLine, +} from 'lucide-react'; +import QRCode from 'qrcode'; +import { httpClient } from '@/app/infra/http/HttpClient'; + +export type QrLoginPlatform = 'feishu' | 'weixin'; + +interface PlatformConfig { + titleKey: string; + connectingKey: string; + scanQRCodeKey: string; + waitingKey: string; + successKey: string; + failedKey: string; + retryKey: string; + apiBase: string; + brandColor: string; + adapterName: string; + extractSuccess: (data: Record) => Record; +} + +const PLATFORM_CONFIGS: Record = { + feishu: { + titleKey: 'feishu.createApp', + connectingKey: 'feishu.connecting', + scanQRCodeKey: 'feishu.scanQRCode', + waitingKey: 'feishu.waitingForScan', + successKey: 'feishu.createSuccess', + failedKey: 'feishu.createFailed', + retryKey: 'feishu.retry', + apiBase: '/api/v1/platform/adapters/lark/create-app', + brandColor: '#3370ff', + adapterName: 'lark', + extractSuccess: (data) => ({ + app_id: data.app_id, + app_secret: data.app_secret, + ...(data.app_name ? { app_name: data.app_name } : {}), + }), + }, + weixin: { + titleKey: 'weixin.scanLogin', + connectingKey: 'feishu.connecting', + scanQRCodeKey: 'weixin.scanQRCode', + waitingKey: 'feishu.waitingForScan', + successKey: 'weixin.loginSuccess', + failedKey: 'weixin.loginFailed', + retryKey: 'feishu.retry', + apiBase: '/api/v1/platform/adapters/weixin/login', + brandColor: '#07c160', + adapterName: 'openclaw-weixin', + extractSuccess: (data) => ({ + token: data.token, + base_url: data.base_url, + ...(data.account_id ? { account_id: data.account_id } : {}), + }), + }, +}; + +interface QrCodeLoginDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + platform: QrLoginPlatform; + onSuccess: (credentials: Record) => void; +} + +type DialogState = 'connecting' | 'waiting' | 'success' | 'error'; + +const POLL_INTERVAL_MS = 3000; + +export default function QrCodeLoginDialog({ + open, + onOpenChange, + platform, + onSuccess, +}: QrCodeLoginDialogProps) { + const { t } = useTranslation(); + const platformConfig = PLATFORM_CONFIGS[platform]; + + const [state, setState] = useState('connecting'); + const [qrDataUrl, setQrDataUrl] = useState(''); + const [expireIn, setExpireIn] = useState(0); + const [errorMessage, setErrorMessage] = useState(''); + const pollTimerRef = useRef | null>(null); + const countdownRef = useRef | null>(null); + const abortRef = useRef(null); + const sessionIdRef = useRef(null); + + const onSuccessRef = useRef(onSuccess); + onSuccessRef.current = onSuccess; + const onOpenChangeRef = useRef(onOpenChange); + onOpenChangeRef.current = onOpenChange; + const tRef = useRef(t); + tRef.current = t; + const platformConfigRef = useRef(platformConfig); + platformConfigRef.current = platformConfig; + + const cleanup = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + if (abortRef.current) { + abortRef.current.abort(); + abortRef.current = null; + } + if (sessionIdRef.current) { + const token = localStorage.getItem('token'); + const baseUrl = + import.meta.env.VITE_API_BASE_URL || window.location.origin; + fetch( + `${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }, + ).catch(() => {}); + sessionIdRef.current = null; + } + }, []); + + const startLogin = useCallback(async () => { + cleanup(); + setState('connecting'); + setQrDataUrl(''); + setExpireIn(0); + setErrorMessage(''); + + const token = localStorage.getItem('token'); + const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin; + const cfg = platformConfigRef.current; + + try { + const controller = new AbortController(); + abortRef.current = controller; + + const res = await fetch(`${baseUrl}${cfg.apiBase}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + }); + + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const json = await res.json(); + if (json.code !== 0) throw new Error(json.msg || 'Request failed'); + + const { session_id, qr_data_url, qr_url, expire_at } = json.data; + sessionIdRef.current = session_id; + + if (qr_data_url) { + setQrDataUrl(qr_data_url); + } else if (qr_url) { + const dataUrl = await QRCode.toDataURL(qr_url, { + width: 280, + margin: 2, + color: { + dark: '#000000', + light: '#ffffff', + }, + }); + setQrDataUrl(dataUrl); + } + setState('waiting'); + + const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000)); + setExpireIn(remaining); + + countdownRef.current = setInterval(() => { + setExpireIn((prev) => { + if (prev <= 1) { + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + return 0; + } + return prev - 1; + }); + }, 1000); + + pollTimerRef.current = setInterval(async () => { + try { + const pollRes = await fetch( + `${baseUrl}${cfg.apiBase}/status/${session_id}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!pollRes.ok) return; + + const pollJson = await pollRes.json(); + if (pollJson.code !== 0) return; + + const { status, error, ...rest } = pollJson.data; + + if (status === 'success') { + sessionIdRef.current = null; + cleanup(); + setState('success'); + setTimeout(() => { + onSuccessRef.current(cfg.extractSuccess(rest)); + onOpenChangeRef.current(false); + }, 1500); + } else if (status === 'error') { + sessionIdRef.current = null; + cleanup(); + setState('error'); + setErrorMessage(error || tRef.current(cfg.failedKey)); + } + } catch { + // ignore poll errors + } + }, POLL_INTERVAL_MS); + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') return; + setState('error'); + setErrorMessage( + err instanceof Error ? err.message : tRef.current(cfg.failedKey), + ); + } + }, [cleanup]); + + useEffect(() => { + if (open) { + startLogin(); + } + return () => { + cleanup(); + }; + }, [open, startLogin, cleanup]); + + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + cleanup(); + } + onOpenChange(newOpen); + }; + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + if (m > 0) { + return `${m}:${s.toString().padStart(2, '0')}`; + } + return `0:${s.toString().padStart(2, '0')}`; + }; + + return ( + + + {/* Brand header */} +
+ {platform} +
+ + {t(platformConfig.titleKey)} + +
+
+ +
+ {/* Connecting */} + {state === 'connecting' && ( +
+
+
+ +
+

+ {t(platformConfig.connectingKey)} +

+
+ )} + + {/* QR code area */} + {state === 'waiting' && qrDataUrl && ( +
+ {/* Instruction */} +
+ + {t(platformConfig.scanQRCodeKey)} +
+ + {/* QR Code with border animation */} +
+
+
+ QR Code +
+
+ + {/* Countdown */} + {expireIn > 0 && ( +
+
+ + {t(platformConfig.waitingKey)} + + + {formatTime(expireIn)} + +
+ )} +
+ )} + + {/* Success */} + {state === 'success' && ( +
+
+
+ +
+

+ {t(platformConfig.successKey)} +

+
+ )} + + {/* Error */} + {state === 'error' && ( +
+ +

+ {errorMessage || t(platformConfig.failedKey)} +

+
+ )} +
+ + {/* Error footer with retry */} + {state === 'error' && ( + + + + + )} + +
+ ); +} diff --git a/web/src/app/infra/entities/form/dynamic.ts b/web/src/app/infra/entities/form/dynamic.ts index 854f50b1..6976b6a4 100644 --- a/web/src/app/infra/entities/form/dynamic.ts +++ b/web/src/app/infra/entities/form/dynamic.ts @@ -21,6 +21,7 @@ export interface IDynamicFormItemSchema { /** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */ scopes?: string[]; accept?: string; // For file type: accepted MIME types + login_platform?: string; // For qr-code-login type: platform identifier (e.g. 'feishu', 'weixin') } export enum DynamicFormItemType { @@ -46,6 +47,7 @@ export enum DynamicFormItemType { TOOLS_SELECTOR = 'tools-selector', WEBHOOK_URL = 'webhook-url', EMBED_CODE = 'embed-code', + QR_CODE_LOGIN = 'qr-code-login', } export interface IFileConfig { diff --git a/web/src/app/wizard/page.tsx b/web/src/app/wizard/page.tsx index 0dc6594a..e823fd29 100644 --- a/web/src/app/wizard/page.tsx +++ b/web/src/app/wizard/page.tsx @@ -228,6 +228,7 @@ export default function WizardPage() { type: parseDynamicFormItemType(item.type), options: item.options, show_if: item.show_if, + login_platform: item.login_platform, }), ); }, [adapters, selectedAdapter]); @@ -247,6 +248,7 @@ export default function WizardPage() { type: parseDynamicFormItemType(item.type), options: item.options, show_if: item.show_if, + login_platform: item.login_platform, }), ); }, [selectedRunnerConfigStage]); diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index fc95f41a..762728c3 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -44,6 +44,8 @@ const enUS = { success: 'Success', save: 'Save', saving: 'Saving...', + recommend: 'Recommended', + start: 'Start', confirm: 'Confirm', confirmDelete: 'Confirm Delete', deleteConfirmation: 'Are you sure you want to delete this?', @@ -1336,6 +1338,51 @@ const enUS = { backToWorkbench: 'Back to Workbench', }, }, + feishu: { + createApp: 'One-Click Create Feishu App', + scanQRCode: + 'Scan the QR code below with Feishu to authorize and automatically create the app', + waitingForScan: 'Waiting for scan', + createSuccess: 'App created successfully! Credentials have been filled in', + createFailed: 'Creation failed', + connecting: 'Connecting to Feishu service...', + expired: 'QR code expired, please try again', + denied: 'Authorization denied by user', + connectionLost: 'Connection lost, please try again', + reconnecting: 'Reconnecting...', + retry: 'Retry', + }, + weixin: { + scanLogin: 'Scan QR Login', + scanQRCode: + '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', + }, + dingtalk: { + createApp: 'One-Click Create DingTalk App', + scanQRCode: + 'Scan the QR code below with DingTalk to authorize and automatically create the app', + waitingForScan: 'Waiting for scan', + createSuccess: 'App created successfully! Credentials have been filled in', + createFailed: 'Creation failed', + connecting: 'Connecting to DingTalk service...', + retry: 'Retry', + robotCodeNote: + 'Robot Code cannot be obtained automatically. Please go to DingTalk Developer Backend > Robot Configuration to copy it manually. It is required for features like image recognition and file upload.', + }, + wecombot: { + createBot: 'One-Click Create WeCom Bot', + scanQRCode: + 'Scan the QR code below with WeCom to authorize and automatically create the bot', + waitingForScan: 'Waiting for scan', + createSuccess: 'Bot created successfully! Credentials have been filled in', + createFailed: 'Creation failed', + connecting: 'Connecting to WeCom service...', + retry: 'Retry', + robotNameNote: + 'Robot Name cannot be obtained automatically. Please fill it in manually.', + }, pluginPages: { selectFromSidebar: 'Select a plugin page from the sidebar', invalidPage: 'Invalid plugin page', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 1c2e0d18..a0797f31 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -45,6 +45,8 @@ success: '成功', save: '保存', saving: '保存中...', + recommend: 'おすすめ', + start: '開始', confirm: '確認', confirmDelete: '削除の確認', deleteConfirmation: '本当に削除しますか?', @@ -1343,6 +1345,46 @@ backToWorkbench: 'ワークベンチに戻る', }, }, + feishu: { + createApp: 'ワンクリックでFeishuアプリ作成', + scanQRCode: '以下のQRコードをFeishuでスキャンし、アプリを自動作成', + waitingForScan: 'スキャン待ち', + createSuccess: 'アプリ作成成功!認証情報が自動入力されました', + createFailed: '作成失敗', + connecting: 'Feishuサービスに接続中...', + expired: 'QRコードの有効期限が切れました。もう一度お試しください', + denied: 'ユーザーが承認を拒否しました', + connectionLost: '接続が切断されました。もう一度お試しください', + reconnecting: '再接続中...', + retry: '再試行', + }, + weixin: { + scanLogin: 'QRコードでWeChatログイン', + scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力', + loginSuccess: 'ログイン成功!トークンが自動入力されました', + loginFailed: 'ログイン失敗', + }, + dingtalk: { + createApp: 'ワンクリックでDingTalkアプリ作成', + scanQRCode: '以下のQRコードをDingTalkでスキャンし、アプリを自動作成', + waitingForScan: 'スキャン待ち', + createSuccess: 'アプリ作成成功!認証情報が自動入力されました', + createFailed: '作成失敗', + connecting: 'DingTalkサービスに接続中...', + retry: '再試行', + robotCodeNote: + 'ロボットコードは自動取得できません。DingTalk開発者バックエンド > ロボット設定から手動でコピーしてください。画像認識やファイルアップロードなどの機能に必要です。', + }, + wecombot: { + createBot: 'ワンクリックでWeComボット作成', + scanQRCode: '以下のQRコードをWeComでスキャンし、ボットを自動作成', + waitingForScan: 'スキャン待ち', + createSuccess: 'ボット作成成功!認証情報が自動入力されました', + createFailed: '作成失敗', + connecting: 'WeComサービスに接続中...', + retry: '再試行', + robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。', + }, pluginPages: { selectFromSidebar: 'サイドバーからプラグインページを選択してください', invalidPage: '無効なプラグインページ', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 75910035..c0b337fb 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -43,6 +43,8 @@ const zhHans = { success: '成功', save: '保存', saving: '保存中...', + recommend: '推荐', + start: '开始', confirm: '确认', confirmDelete: '确认删除', deleteConfirmation: '你确定要删除这个吗?', @@ -1276,6 +1278,47 @@ const zhHans = { backToWorkbench: '返回工作台', }, }, + feishu: { + createApp: '一键创建飞书应用', + scanQRCode: '请使用飞书扫描以下二维码,授权后将自动创建应用并填写凭据', + waitingForScan: '等待扫码中', + createSuccess: '应用创建成功!凭据已自动填入', + createFailed: '创建失败', + connecting: '正在连接飞书服务...', + expired: '二维码已过期,请重试', + denied: '用户已拒绝授权', + connectionLost: '连接已断开,请重试', + reconnecting: '正在重新连接...', + retry: '重试', + }, + weixin: { + scanLogin: '扫码登录微信', + scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌', + loginSuccess: '登录成功!令牌已自动填入', + loginFailed: '登录失败', + }, + dingtalk: { + createApp: '一键创建钉钉应用', + scanQRCode: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据', + waitingForScan: '等待扫码中', + createSuccess: '应用创建成功!凭据已自动填入', + createFailed: '创建失败', + connecting: '正在连接钉钉服务...', + retry: '重试', + robotCodeNote: + '机器人代码无法自动获取,请前往钉钉开发者后台 > 机器人配置中手动复制。识图、上传文件等功能需要填写此字段。', + }, + wecombot: { + createBot: '一键创建企业微信机器人', + scanQRCode: + '请使用企业微信扫描以下二维码,授权后将自动创建机器人并填写凭据', + waitingForScan: '等待扫码中', + createSuccess: '机器人创建成功!凭据已自动填入', + createFailed: '创建失败', + connecting: '正在连接企业微信服务...', + retry: '重试', + robotNameNote: '机器人名称无法自动获取,请手动填写。', + }, pluginPages: { selectFromSidebar: '从侧边栏选择一个插件页面', invalidPage: '无效的插件页面', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 6f3a1cd6..36dbca76 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -43,6 +43,8 @@ const zhHant = { success: '成功', save: '儲存', saving: '儲存中...', + recommend: '推薦', + start: '開始', confirm: '確認', confirmDelete: '確認刪除', deleteConfirmation: '您確定要刪除這個嗎?', @@ -1276,6 +1278,47 @@ const zhHant = { backToWorkbench: '返回工作台', }, }, + feishu: { + createApp: '一鍵建立飛書應用', + scanQRCode: '請使用飛書掃描以下 QR Code,授權後將自動建立應用並填寫憑證', + waitingForScan: '等待掃描中', + createSuccess: '應用建立成功!憑證已自動填入', + createFailed: '建立失敗', + connecting: '正在連線飛書服務...', + expired: 'QR Code 已過期,請重試', + denied: '使用者已拒絕授權', + connectionLost: '連線已斷開,請重試', + reconnecting: '正在重新連線...', + retry: '重試', + }, + weixin: { + scanLogin: '掃碼登入微信', + scanQRCode: '請使用微信掃描以下 QR Code,授權後將自動登入並填寫令牌', + loginSuccess: '登入成功!令牌已自動填入', + loginFailed: '登入失敗', + }, + dingtalk: { + createApp: '一鍵建立釘釘應用', + scanQRCode: '請使用釘釘掃描以下 QR Code,授權後將自動建立應用並填寫憑證', + waitingForScan: '等待掃碼中', + createSuccess: '應用建立成功!憑證已自動填入', + createFailed: '建立失敗', + connecting: '正在連線釘釘服務...', + retry: '重試', + robotCodeNote: + '機器人代碼無法自動取得,請前往釘釘開發者後台 > 機器人設定中手動複製。識圖、上傳檔案等功能需要填寫此欄位。', + }, + wecombot: { + createBot: '一鍵建立企業微信機器人', + scanQRCode: + '請使用企業微信掃描以下 QR Code,授權後將自動建立機器人並填寫憑證', + waitingForScan: '等待掃碼中', + createSuccess: '機器人建立成功!憑證已自動填入', + createFailed: '建立失敗', + connecting: '正在連線企業微信服務...', + retry: '重試', + robotNameNote: '機器人名稱無法自動取得,請手動填寫。', + }, pluginPages: { selectFromSidebar: '從側邊欄選擇一個插件頁面', invalidPage: '無效的插件頁面',