mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
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
This commit is contained in:
@@ -22,7 +22,7 @@ dependencies = [
|
|||||||
"discord-py>=2.5.2",
|
"discord-py>=2.5.2",
|
||||||
"pynacl>=1.5.0", # Required for Discord voice support
|
"pynacl>=1.5.0", # Required for Discord voice support
|
||||||
"gewechat-client>=0.1.5",
|
"gewechat-client>=0.1.5",
|
||||||
"lark-oapi>=1.4.15",
|
"lark-oapi>=1.5.5",
|
||||||
"mcp>=1.25.0",
|
"mcp>=1.25.0",
|
||||||
"nakuru-project-idk>=0.0.2.1",
|
"nakuru-project-idk>=0.0.2.1",
|
||||||
"ollama>=0.4.8",
|
"ollama>=0.4.8",
|
||||||
@@ -35,6 +35,7 @@ dependencies = [
|
|||||||
"python-telegram-bot>=22.0",
|
"python-telegram-bot>=22.0",
|
||||||
"pyyaml>=6.0.2",
|
"pyyaml>=6.0.2",
|
||||||
"qq-botpy-rc>=1.2.1.6",
|
"qq-botpy-rc>=1.2.1.6",
|
||||||
|
"qrcode>=7.4",
|
||||||
"quart>=0.20.0",
|
"quart>=0.20.0",
|
||||||
"quart-cors>=0.8.0",
|
"quart-cors>=0.8.0",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import quart
|
import quart
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import asyncio
|
||||||
from ... import group
|
from ... import group
|
||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
@@ -35,3 +36,640 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
return quart.Response(
|
return quart.Response(
|
||||||
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
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/<session_id>', 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/<session_id>', 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/<session_id>', 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/<session_id>', 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/<session_id>', 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/<session_id>', 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/<session_id>', 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/<session_id>', 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={})
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ class BotService:
|
|||||||
# TODO: 检查配置信息格式
|
# TODO: 检查配置信息格式
|
||||||
bot_data['uuid'] = str(uuid.uuid4())
|
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(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
|
||||||
persistence_pipeline.LegacyPipeline.is_default == True
|
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
|
||||||
)
|
.limit(1)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/dingtalk
|
en: https://link.langbot.app/en/platforms/dingtalk
|
||||||
ja: https://link.langbot.app/ja/platforms/dingtalk
|
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||||
config:
|
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
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
en_US: Client ID
|
en_US: Client ID
|
||||||
@@ -40,6 +52,10 @@ spec:
|
|||||||
en_US: Robot Code
|
en_US: Robot Code
|
||||||
zh_Hans: 机器人代码
|
zh_Hans: 机器人代码
|
||||||
zh_Hant: 機器人代碼
|
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
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/lark
|
en: https://link.langbot.app/en/platforms/lark
|
||||||
ja: https://link.langbot.app/ja/platforms/lark
|
ja: https://link.langbot.app/ja/platforms/lark
|
||||||
config:
|
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
|
- name: app_id
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "https://ilinkai.weixin.qq.com"
|
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
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/wecombot
|
en: https://link.langbot.app/en/platforms/wecombot
|
||||||
ja: https://link.langbot.app/ja/platforms/wecombot
|
ja: https://link.langbot.app/ja/platforms/wecombot
|
||||||
config:
|
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
|
- name: BotId
|
||||||
label:
|
label:
|
||||||
en_US: BotId
|
en_US: BotId
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export default function BotForm({
|
|||||||
type: parseDynamicFormItemType(item.type),
|
type: parseDynamicFormItemType(item.type),
|
||||||
options: item.options,
|
options: item.options,
|
||||||
show_if: item.show_if,
|
show_if: item.show_if,
|
||||||
|
login_platform: item.login_platform,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { copyToClipboard } from '@/app/utils/clipboard';
|
||||||
import { systemInfo } from '@/app/infra/http';
|
import { systemInfo } from '@/app/infra/http';
|
||||||
|
|
||||||
@@ -255,7 +258,10 @@ export default function DynamicFormComponent({
|
|||||||
const editableItems = useMemo(
|
const editableItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
itemConfigList.filter(
|
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],
|
[itemConfigList],
|
||||||
);
|
);
|
||||||
@@ -449,9 +455,28 @@ export default function DynamicFormComponent({
|
|||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [form, editableItems]);
|
}, [form, editableItems]);
|
||||||
|
|
||||||
|
// State for QR code login dialog
|
||||||
|
const [qrDialogOpen, setQrDialogOpen] = useState(false);
|
||||||
|
const [qrDialogPlatform, setQrDialogPlatform] =
|
||||||
|
useState<QrLoginPlatform>('feishu');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* QR code login dialog */}
|
||||||
|
<QrCodeLoginDialog
|
||||||
|
open={qrDialogOpen}
|
||||||
|
onOpenChange={setQrDialogOpen}
|
||||||
|
platform={qrDialogPlatform}
|
||||||
|
onSuccess={(credentials) => {
|
||||||
|
for (const [key, value] of Object.entries(credentials)) {
|
||||||
|
if (value) {
|
||||||
|
form.setValue(key as keyof FormValues, value as never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{itemConfigList.map((config) => {
|
{itemConfigList.map((config) => {
|
||||||
if (config.show_if) {
|
if (config.show_if) {
|
||||||
const dependValue = resolveShowIfValue(
|
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 (
|
||||||
|
<FormItem key={config.id}>
|
||||||
|
<div
|
||||||
|
className="relative flex items-center gap-4 p-4 rounded-xl border-2 border-dashed cursor-pointer transition-all hover:border-solid hover:shadow-md group"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
'color-mix(in srgb, var(--primary) 25%, transparent)',
|
||||||
|
background:
|
||||||
|
'color-mix(in srgb, var(--primary) 3%, transparent)',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isEditing) {
|
||||||
|
setQrDialogPlatform(
|
||||||
|
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||||
|
);
|
||||||
|
setQrDialogOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center h-12 w-12 rounded-lg bg-primary/10 shrink-0">
|
||||||
|
<QrCode className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
{extractI18nObject(config.label)}
|
||||||
|
</span>
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-primary text-primary-foreground">
|
||||||
|
{t('common.recommend')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{config.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||||
|
{extractI18nObject(config.description)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
disabled={!!isEditing}
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setQrDialogPlatform(
|
||||||
|
(config.login_platform as QrLoginPlatform) || 'feishu',
|
||||||
|
);
|
||||||
|
setQrDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QrCode className="h-3.5 w-3.5 mr-1" />
|
||||||
|
{t('common.start')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Boolean fields use a special inline layout
|
// Boolean fields use a special inline layout
|
||||||
if (config.type === 'boolean') {
|
if (config.type === 'boolean') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
|||||||
description?: I18nObject;
|
description?: I18nObject;
|
||||||
options?: IDynamicFormItemOption[];
|
options?: IDynamicFormItemOption[];
|
||||||
show_if?: IShowIfCondition;
|
show_if?: IShowIfCondition;
|
||||||
|
login_platform?: string;
|
||||||
|
|
||||||
constructor(params: IDynamicFormItemSchema) {
|
constructor(params: IDynamicFormItemSchema) {
|
||||||
this.id = params.id;
|
this.id = params.id;
|
||||||
@@ -27,6 +28,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
|||||||
this.description = params.description;
|
this.description = params.description;
|
||||||
this.options = params.options;
|
this.options = params.options;
|
||||||
this.show_if = params.show_if;
|
this.show_if = params.show_if;
|
||||||
|
this.login_platform = params.login_platform;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
366
web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
Normal file
366
web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
Normal file
@@ -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<string, string>) => Record<string, string>;
|
||||||
|
successNoteKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||||
|
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<string, string>) => 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<DialogState>('connecting');
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
|
const [expireIn, setExpireIn] = useState(0);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const sessionIdRef = useRef<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t(platformConfig.titleKey)}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center py-4 space-y-4">
|
||||||
|
{/* Connecting */}
|
||||||
|
{state === 'connecting' && (
|
||||||
|
<div className="flex flex-col items-center space-y-3 py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(platformConfig.connectingKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR code area */}
|
||||||
|
{state === 'waiting' && qrDataUrl && (
|
||||||
|
<div className="flex flex-col items-center space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{t(platformConfig.scanQRCodeKey)}
|
||||||
|
</p>
|
||||||
|
<div className="border rounded-lg p-2 bg-white">
|
||||||
|
<img src={qrDataUrl} alt="QR Code" className="w-56 h-56" />
|
||||||
|
</div>
|
||||||
|
{expireIn > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(platformConfig.waitingKey)} ({formatTime(expireIn)})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success */}
|
||||||
|
{state === 'success' && (
|
||||||
|
<div className="flex flex-col items-center space-y-3 py-8">
|
||||||
|
<CheckCircle2 className="h-12 w-12 text-green-500" />
|
||||||
|
<p className="text-sm text-green-600 font-medium">
|
||||||
|
{t(platformConfig.successKey)}
|
||||||
|
</p>
|
||||||
|
{platformConfig.successNoteKey && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||||
|
{t(platformConfig.successNoteKey)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{state === 'error' && (
|
||||||
|
<div className="flex flex-col items-center space-y-3 py-8">
|
||||||
|
<XCircle className="h-12 w-12 text-red-500" />
|
||||||
|
<p className="text-sm text-red-600 text-center">
|
||||||
|
{errorMessage || t(platformConfig.failedKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state === 'error' && (
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => startLogin()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||||
|
{t(platformConfig.retryKey)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string>) => Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||||
|
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<string, string>) => 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<DialogState>('connecting');
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
|
const [expireIn, setExpireIn] = useState(0);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const sessionIdRef = useRef<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md p-0 overflow-hidden">
|
||||||
|
{/* Brand header */}
|
||||||
|
<div className="flex items-center gap-3 px-6 pt-6 pb-2">
|
||||||
|
<img
|
||||||
|
src={httpClient.getAdapterIconURL(platformConfig.adapterName)}
|
||||||
|
alt={platform}
|
||||||
|
className="h-10 w-10 rounded-lg"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-lg">
|
||||||
|
{t(platformConfig.titleKey)}
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center px-6 pb-6 space-y-4">
|
||||||
|
{/* Connecting */}
|
||||||
|
{state === 'connecting' && (
|
||||||
|
<div className="flex flex-col items-center space-y-4 py-12">
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-full animate-ping opacity-20"
|
||||||
|
style={{ backgroundColor: platformConfig.brandColor }}
|
||||||
|
/>
|
||||||
|
<Loader2
|
||||||
|
className="h-10 w-10 animate-spin relative"
|
||||||
|
style={{ color: platformConfig.brandColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground font-medium">
|
||||||
|
{t(platformConfig.connectingKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR code area */}
|
||||||
|
{state === 'waiting' && qrDataUrl && (
|
||||||
|
<div className="flex flex-col items-center space-y-4 py-2">
|
||||||
|
{/* Instruction */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${platformConfig.brandColor}10`,
|
||||||
|
color: platformConfig.brandColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScanLine className="h-4 w-4" />
|
||||||
|
{t(platformConfig.scanQRCodeKey)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code with border animation */}
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className="absolute -inset-1 rounded-2xl opacity-30 animate-pulse"
|
||||||
|
style={{ backgroundColor: platformConfig.brandColor }}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl p-3 shadow-lg">
|
||||||
|
<img src={qrDataUrl} alt="QR Code" className="w-64 h-64" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown */}
|
||||||
|
{expireIn > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 rounded-full animate-pulse"
|
||||||
|
style={{ backgroundColor: platformConfig.brandColor }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t(platformConfig.waitingKey)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-mono font-semibold tabular-nums"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
expireIn < 60 ? '#ef4444' : platformConfig.brandColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(expireIn)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success */}
|
||||||
|
{state === 'success' && (
|
||||||
|
<div className="flex flex-col items-center space-y-3 py-12">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-green-100 animate-ping opacity-30" />
|
||||||
|
<CheckCircle2 className="h-16 w-16 text-green-500 relative" />
|
||||||
|
</div>
|
||||||
|
<p className="text-base text-green-600 font-semibold">
|
||||||
|
{t(platformConfig.successKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{state === 'error' && (
|
||||||
|
<div className="flex flex-col items-center space-y-3 py-12">
|
||||||
|
<XCircle className="h-16 w-16 text-red-400" />
|
||||||
|
<p className="text-sm text-red-500 text-center max-w-xs">
|
||||||
|
{errorMessage || t(platformConfig.failedKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error footer with retry */}
|
||||||
|
{state === 'error' && (
|
||||||
|
<DialogFooter className="px-6 pb-6 pt-0">
|
||||||
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => startLogin()}
|
||||||
|
style={{ backgroundColor: platformConfig.brandColor }}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||||
|
{t(platformConfig.retryKey)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 */
|
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
accept?: string; // For file type: accepted MIME types
|
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 {
|
export enum DynamicFormItemType {
|
||||||
@@ -46,6 +47,7 @@ export enum DynamicFormItemType {
|
|||||||
TOOLS_SELECTOR = 'tools-selector',
|
TOOLS_SELECTOR = 'tools-selector',
|
||||||
WEBHOOK_URL = 'webhook-url',
|
WEBHOOK_URL = 'webhook-url',
|
||||||
EMBED_CODE = 'embed-code',
|
EMBED_CODE = 'embed-code',
|
||||||
|
QR_CODE_LOGIN = 'qr-code-login',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFileConfig {
|
export interface IFileConfig {
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ export default function WizardPage() {
|
|||||||
type: parseDynamicFormItemType(item.type),
|
type: parseDynamicFormItemType(item.type),
|
||||||
options: item.options,
|
options: item.options,
|
||||||
show_if: item.show_if,
|
show_if: item.show_if,
|
||||||
|
login_platform: item.login_platform,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [adapters, selectedAdapter]);
|
}, [adapters, selectedAdapter]);
|
||||||
@@ -247,6 +248,7 @@ export default function WizardPage() {
|
|||||||
type: parseDynamicFormItemType(item.type),
|
type: parseDynamicFormItemType(item.type),
|
||||||
options: item.options,
|
options: item.options,
|
||||||
show_if: item.show_if,
|
show_if: item.show_if,
|
||||||
|
login_platform: item.login_platform,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [selectedRunnerConfigStage]);
|
}, [selectedRunnerConfigStage]);
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const enUS = {
|
|||||||
success: 'Success',
|
success: 'Success',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
saving: 'Saving...',
|
saving: 'Saving...',
|
||||||
|
recommend: 'Recommended',
|
||||||
|
start: 'Start',
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
confirmDelete: 'Confirm Delete',
|
confirmDelete: 'Confirm Delete',
|
||||||
deleteConfirmation: 'Are you sure you want to delete this?',
|
deleteConfirmation: 'Are you sure you want to delete this?',
|
||||||
@@ -1336,6 +1338,51 @@ const enUS = {
|
|||||||
backToWorkbench: 'Back to Workbench',
|
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: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Select a plugin page from the sidebar',
|
selectFromSidebar: 'Select a plugin page from the sidebar',
|
||||||
invalidPage: 'Invalid plugin page',
|
invalidPage: 'Invalid plugin page',
|
||||||
|
|||||||
@@ -45,6 +45,8 @@
|
|||||||
success: '成功',
|
success: '成功',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
|
recommend: 'おすすめ',
|
||||||
|
start: '開始',
|
||||||
confirm: '確認',
|
confirm: '確認',
|
||||||
confirmDelete: '削除の確認',
|
confirmDelete: '削除の確認',
|
||||||
deleteConfirmation: '本当に削除しますか?',
|
deleteConfirmation: '本当に削除しますか?',
|
||||||
@@ -1343,6 +1345,46 @@
|
|||||||
backToWorkbench: 'ワークベンチに戻る',
|
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: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
||||||
invalidPage: '無効なプラグインページ',
|
invalidPage: '無効なプラグインページ',
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ const zhHans = {
|
|||||||
success: '成功',
|
success: '成功',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
|
recommend: '推荐',
|
||||||
|
start: '开始',
|
||||||
confirm: '确认',
|
confirm: '确认',
|
||||||
confirmDelete: '确认删除',
|
confirmDelete: '确认删除',
|
||||||
deleteConfirmation: '你确定要删除这个吗?',
|
deleteConfirmation: '你确定要删除这个吗?',
|
||||||
@@ -1276,6 +1278,47 @@ const zhHans = {
|
|||||||
backToWorkbench: '返回工作台',
|
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: {
|
pluginPages: {
|
||||||
selectFromSidebar: '从侧边栏选择一个插件页面',
|
selectFromSidebar: '从侧边栏选择一个插件页面',
|
||||||
invalidPage: '无效的插件页面',
|
invalidPage: '无效的插件页面',
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ const zhHant = {
|
|||||||
success: '成功',
|
success: '成功',
|
||||||
save: '儲存',
|
save: '儲存',
|
||||||
saving: '儲存中...',
|
saving: '儲存中...',
|
||||||
|
recommend: '推薦',
|
||||||
|
start: '開始',
|
||||||
confirm: '確認',
|
confirm: '確認',
|
||||||
confirmDelete: '確認刪除',
|
confirmDelete: '確認刪除',
|
||||||
deleteConfirmation: '您確定要刪除這個嗎?',
|
deleteConfirmation: '您確定要刪除這個嗎?',
|
||||||
@@ -1276,6 +1278,47 @@ const zhHant = {
|
|||||||
backToWorkbench: '返回工作台',
|
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: {
|
pluginPages: {
|
||||||
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
||||||
invalidPage: '無效的插件頁面',
|
invalidPage: '無效的插件頁面',
|
||||||
|
|||||||
Reference in New Issue
Block a user