mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-30 22:27:13 +00:00
feat(qqofficial): implement one-click QR binding and enhance localization support
This commit is contained in:
@@ -5,6 +5,29 @@ from ... import group
|
|||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str:
|
||||||
|
"""Decrypt the AppSecret returned by the QQ Official QR binding endpoint.
|
||||||
|
|
||||||
|
The base64 payload is laid out as `nonce (12 B) | ciphertext | tag (16 B)`.
|
||||||
|
`key` is the 32-byte AES-256 key locally generated when the bind task
|
||||||
|
was created and submitted as `key` to `q.qq.com/lite/create_bind_task`.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(encrypted_b64)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError('Malformed encrypted credential') from exc
|
||||||
|
if len(key) != 32 or len(raw) <= 28:
|
||||||
|
raise ValueError('Invalid encrypted credential layout')
|
||||||
|
nonce, ciphertext, tag = raw[:12], raw[12:-16], raw[-16:]
|
||||||
|
try:
|
||||||
|
return AESGCM(key).decrypt(nonce, ciphertext + tag, None).decode('utf-8')
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError('Failed to decrypt credential') from exc
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('adapters', '/api/v1/platform/adapters')
|
@group.group_class('adapters', '/api/v1/platform/adapters')
|
||||||
class AdaptersRouterGroup(group.RouterGroup):
|
class AdaptersRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@@ -650,3 +673,220 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
if session and session.get('task') and not session['task'].done():
|
if session and session.get('task') and not session['task'].done():
|
||||||
session['task'].cancel()
|
session['task'].cancel()
|
||||||
return self.success(data={})
|
return self.success(data={})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# QQ Official QR Binding
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
_qqofficial_sessions: dict = {}
|
||||||
|
_QQOFFICIAL_SESSION_TTL = 300 # 5 minutes (QQ bind QR validity window)
|
||||||
|
|
||||||
|
def _cleanup_expired_qqofficial_sessions():
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
expired = [
|
||||||
|
sid for sid, s in _qqofficial_sessions.items() if now - s.get('created_at', 0) > _QQOFFICIAL_SESSION_TTL
|
||||||
|
]
|
||||||
|
for sid in expired:
|
||||||
|
session = _qqofficial_sessions.pop(sid, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
|
||||||
|
@self.route('/qqofficial/bind', methods=['POST'])
|
||||||
|
async def _() -> str:
|
||||||
|
"""Start QQ Official QR binding. Returns session_id + QR URL.
|
||||||
|
|
||||||
|
Flow: generate a local AES-256 key, register it with
|
||||||
|
`q.qq.com/lite/create_bind_task`, then poll
|
||||||
|
`q.qq.com/lite/poll_bind_result` until the user authorizes the
|
||||||
|
bind inside the QQ Bot Assistant on mobile QQ. The encrypted
|
||||||
|
AppSecret returned by the poll endpoint is decrypted with the
|
||||||
|
same key. The key never leaves this process.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import secrets
|
||||||
|
import base64
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
QQ_BIND_BASE = 'https://q.qq.com'
|
||||||
|
_cleanup_expired_qqofficial_sessions()
|
||||||
|
|
||||||
|
bind_key_bytes = secrets.token_bytes(32)
|
||||||
|
bind_key = base64.b64encode(bind_key_bytes).decode('ascii')
|
||||||
|
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
session = {
|
||||||
|
'status': 'pending',
|
||||||
|
'qr_url': None,
|
||||||
|
'expire_at': None,
|
||||||
|
'appid': None,
|
||||||
|
'secret': None,
|
||||||
|
'user_openid': None,
|
||||||
|
'error': None,
|
||||||
|
'created_at': time.time(),
|
||||||
|
'task_id': None,
|
||||||
|
'bind_key_bytes': bind_key_bytes,
|
||||||
|
'interval': 2,
|
||||||
|
}
|
||||||
|
_qqofficial_sessions[session_id] = session
|
||||||
|
|
||||||
|
async def run_qr_binding():
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as http:
|
||||||
|
# Step 1: create_bind_task — register our AES key, get task_id
|
||||||
|
async with http.post(
|
||||||
|
f'{QQ_BIND_BASE}/lite/create_bind_task',
|
||||||
|
json={'key': bind_key},
|
||||||
|
headers={'Accept': 'application/json'},
|
||||||
|
) as resp:
|
||||||
|
try:
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Invalid response from QQ bind service'
|
||||||
|
return
|
||||||
|
if int(data.get('retcode', -1)) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = (
|
||||||
|
data.get('msg') or data.get('message') or 'Failed to create bind task'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
task_id = str((data.get('data') or {}).get('task_id') or '').strip()
|
||||||
|
if not task_id:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Missing task_id in QQ response'
|
||||||
|
return
|
||||||
|
|
||||||
|
# The QR encodes a URL that mobile QQ opens inside the QQ Bot Assistant.
|
||||||
|
# `source=langbot` is a courtesy attribution parameter so Tencent
|
||||||
|
# can see LangBot adoption metrics, matching the convention used by
|
||||||
|
# other third-party integrations (e.g. hermes-agent uses `source=hermes`).
|
||||||
|
qr_url = f'{QQ_BIND_BASE}/qqbot/openclaw/connect.html?task_id={task_id}&_wv=2&source=langbot'
|
||||||
|
session['task_id'] = task_id
|
||||||
|
session['qr_url'] = qr_url
|
||||||
|
session['expire_at'] = time.time() + _QQOFFICIAL_SESSION_TTL
|
||||||
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
|
# Step 2: poll_bind_result until completed (status=2) or expired (3).
|
||||||
|
deadline = time.time() + _QQOFFICIAL_SESSION_TTL
|
||||||
|
while time.time() < deadline:
|
||||||
|
await asyncio.sleep(session['interval'])
|
||||||
|
|
||||||
|
async with http.post(
|
||||||
|
f'{QQ_BIND_BASE}/lite/poll_bind_result',
|
||||||
|
json={'task_id': task_id},
|
||||||
|
headers={'Accept': 'application/json'},
|
||||||
|
) as poll_resp:
|
||||||
|
try:
|
||||||
|
poll_data = await poll_resp.json(content_type=None)
|
||||||
|
except (aiohttp.ContentTypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if int(poll_data.get('retcode', -1)) != 0:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = poll_data.get('msg') or poll_data.get('message') or 'Poll failed'
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = poll_data.get('data') or {}
|
||||||
|
try:
|
||||||
|
raw_status = int(payload.get('status', 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raw_status = 0
|
||||||
|
|
||||||
|
if raw_status == 2:
|
||||||
|
appid = str(payload.get('bot_appid') or '').strip()
|
||||||
|
encrypted = str(payload.get('bot_encrypt_secret') or '').strip()
|
||||||
|
if not appid or not encrypted:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = 'Incomplete credential payload'
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
session['secret'] = _decrypt_qqofficial_secret(
|
||||||
|
encrypted,
|
||||||
|
bind_key_bytes,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = str(exc)
|
||||||
|
return
|
||||||
|
session['appid'] = appid
|
||||||
|
# The scanner's OpenID is returned alongside the credentials —
|
||||||
|
# surfaced to the dashboard for audit / "bound by" display.
|
||||||
|
session['user_openid'] = str(payload.get('user_openid') or '').strip() or None
|
||||||
|
session['status'] = 'success'
|
||||||
|
return
|
||||||
|
|
||||||
|
if raw_status == 3:
|
||||||
|
session['status'] = 'expired'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
return
|
||||||
|
# status 0 / 1: still pending, continue polling
|
||||||
|
|
||||||
|
session['status'] = 'expired'
|
||||||
|
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_binding())
|
||||||
|
session['task'] = task
|
||||||
|
|
||||||
|
# Wait up to 10s for the QR URL to be ready before responding.
|
||||||
|
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('/qqofficial/bind/status/<session_id>', methods=['GET'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Poll QQ Official QR binding status."""
|
||||||
|
_cleanup_expired_qqofficial_sessions()
|
||||||
|
session = _qqofficial_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['appid'] = session['appid']
|
||||||
|
data['secret'] = session['secret']
|
||||||
|
if session.get('user_openid'):
|
||||||
|
data['user_openid'] = session['user_openid']
|
||||||
|
_qqofficial_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] in ('error', 'expired'):
|
||||||
|
data['error'] = session['error']
|
||||||
|
_qqofficial_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
return self.success(data=data)
|
||||||
|
|
||||||
|
@self.route('/qqofficial/bind/<session_id>', methods=['DELETE'])
|
||||||
|
async def _(session_id: str) -> str:
|
||||||
|
"""Cancel and clean up a QQ Official QR binding session."""
|
||||||
|
session = _qqofficial_sessions.pop(session_id, None)
|
||||||
|
if session and session.get('task') and not session['task'].done():
|
||||||
|
session['task'].cancel()
|
||||||
|
return self.success(data={})
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ spec:
|
|||||||
en: https://link.langbot.app/en/platforms/qqofficial
|
en: https://link.langbot.app/en/platforms/qqofficial
|
||||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||||
config:
|
config:
|
||||||
|
- name: one-click-bind
|
||||||
|
label:
|
||||||
|
en_US: One-Click QR Binding
|
||||||
|
zh_Hans: 一键扫码绑定
|
||||||
|
zh_Hant: 一鍵掃碼綁定
|
||||||
|
description:
|
||||||
|
en_US: Scan QR code with mobile QQ to auto-fill AppID and Secret (Token still needs to be filled manually)
|
||||||
|
zh_Hans: 使用手机 QQ 扫码绑定,自动填写 AppID 和密钥(Token 仍需手动填写)
|
||||||
|
zh_Hant: 使用手機 QQ 掃碼綁定,自動填寫 AppID 和密鑰(Token 仍需手動填寫)
|
||||||
|
type: qr-code-login
|
||||||
|
login_platform: qqofficial
|
||||||
|
required: false
|
||||||
- name: appid
|
- name: appid
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
@@ -40,8 +52,12 @@ spec:
|
|||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
zh_Hant: 令牌
|
zh_Hant: 令牌
|
||||||
|
description:
|
||||||
|
en_US: Optional. The QR binding cannot return this value; the current adapter implementation does not use it either, so it can be safely left blank.
|
||||||
|
zh_Hans: 可选。扫码绑定无法获取该字段,当前适配器实现也未使用该字段,留空即可。
|
||||||
|
zh_Hant: 可選。掃碼綁定無法取得此欄位,目前介面卡實作亦未使用,留空即可。
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
- name: enable-webhook
|
- name: enable-webhook
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
export type QrLoginPlatform =
|
||||||
|
| 'feishu'
|
||||||
|
| 'weixin'
|
||||||
|
| 'dingtalk'
|
||||||
|
| 'wecombot'
|
||||||
|
| 'qqofficial';
|
||||||
|
|
||||||
interface PlatformConfig {
|
interface PlatformConfig {
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
@@ -29,6 +34,7 @@ interface PlatformConfig {
|
|||||||
apiBase: string;
|
apiBase: string;
|
||||||
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
||||||
successNoteKey?: string;
|
successNoteKey?: string;
|
||||||
|
boundByKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||||
@@ -92,6 +98,22 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
|||||||
}),
|
}),
|
||||||
successNoteKey: 'wecombot.robotNameNote',
|
successNoteKey: 'wecombot.robotNameNote',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
titleKey: 'qqofficial.createBinding',
|
||||||
|
connectingKey: 'qqofficial.connecting',
|
||||||
|
scanQRCodeKey: 'qqofficial.scanQRCode',
|
||||||
|
waitingKey: 'qqofficial.waitingForScan',
|
||||||
|
successKey: 'qqofficial.bindSuccess',
|
||||||
|
failedKey: 'qqofficial.bindFailed',
|
||||||
|
retryKey: 'qqofficial.retry',
|
||||||
|
apiBase: '/api/v1/platform/adapters/qqofficial/bind',
|
||||||
|
extractSuccess: (data) => ({
|
||||||
|
appid: data.appid,
|
||||||
|
secret: data.secret,
|
||||||
|
}),
|
||||||
|
successNoteKey: 'qqofficial.tokenNote',
|
||||||
|
boundByKey: 'qqofficial.boundBy',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface QrCodeLoginDialogProps {
|
interface QrCodeLoginDialogProps {
|
||||||
@@ -118,6 +140,7 @@ export default function QrCodeLoginDialog({
|
|||||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
const [expireIn, setExpireIn] = useState(0);
|
const [expireIn, setExpireIn] = useState(0);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [successMeta, setSuccessMeta] = useState('');
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -178,6 +201,7 @@ export default function QrCodeLoginDialog({
|
|||||||
setQrDataUrl('');
|
setQrDataUrl('');
|
||||||
setExpireIn(0);
|
setExpireIn(0);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
|
setSuccessMeta('');
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||||
@@ -275,6 +299,13 @@ export default function QrCodeLoginDialog({
|
|||||||
sessionIdRef.current = null;
|
sessionIdRef.current = null;
|
||||||
cleanup();
|
cleanup();
|
||||||
setState('success');
|
setState('success');
|
||||||
|
// Platform may return extra audit metadata (e.g. QQ Official returns
|
||||||
|
// the scanner's user_openid) — surface it briefly before the dialog closes.
|
||||||
|
if (rest.user_openid && cfg.boundByKey) {
|
||||||
|
setSuccessMeta(
|
||||||
|
tRef.current(cfg.boundByKey, { openid: rest.user_openid }),
|
||||||
|
);
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onSuccessRef.current(cfg.extractSuccess(rest));
|
onSuccessRef.current(cfg.extractSuccess(rest));
|
||||||
onOpenChangeRef.current(false);
|
onOpenChangeRef.current(false);
|
||||||
@@ -395,6 +426,11 @@ export default function QrCodeLoginDialog({
|
|||||||
<p className="text-sm text-green-600 font-medium">
|
<p className="text-sm text-green-600 font-medium">
|
||||||
{t(platformConfig.successKey)}
|
{t(platformConfig.successKey)}
|
||||||
</p>
|
</p>
|
||||||
|
{successMeta && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center max-w-xs break-all">
|
||||||
|
{successMeta}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{platformConfig.successNoteKey && (
|
{platformConfig.successNoteKey && (
|
||||||
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||||
{t(platformConfig.successNoteKey)}
|
{t(platformConfig.successNoteKey)}
|
||||||
|
|||||||
@@ -1383,6 +1383,19 @@ const enUS = {
|
|||||||
robotNameNote:
|
robotNameNote:
|
||||||
'Robot Name cannot be obtained automatically. Please fill it in manually.',
|
'Robot Name cannot be obtained automatically. Please fill it in manually.',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: 'One-Click QR Binding for QQ Official Bot',
|
||||||
|
scanQRCode:
|
||||||
|
'Scan the QR code below with mobile QQ and authorize the binding in QQ Bot Assistant',
|
||||||
|
waitingForScan: 'Waiting for scan',
|
||||||
|
bindSuccess: 'Bound successfully! AppID and Secret have been filled in',
|
||||||
|
bindFailed: 'Binding failed',
|
||||||
|
connecting: 'Connecting to QQ service...',
|
||||||
|
retry: 'Retry',
|
||||||
|
tokenNote:
|
||||||
|
'The Token field is not used by the current adapter — you can leave it blank.',
|
||||||
|
boundBy: 'Bound by QQ user {{openid}}',
|
||||||
|
},
|
||||||
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',
|
||||||
|
|||||||
@@ -1426,6 +1426,20 @@ const esES = {
|
|||||||
robotNameNote:
|
robotNameNote:
|
||||||
'El nombre del robot no puede obtenerse automáticamente. Introdúcelo manualmente.',
|
'El nombre del robot no puede obtenerse automáticamente. Introdúcelo manualmente.',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: 'Vinculación QR con un clic para el bot oficial de QQ',
|
||||||
|
scanQRCode:
|
||||||
|
'Escanea el código QR siguiente con QQ móvil y autoriza la vinculación en «QQ Bot Assistant»',
|
||||||
|
waitingForScan: 'Esperando escaneo',
|
||||||
|
bindSuccess:
|
||||||
|
'¡Vinculación correcta! AppID y Secret se han rellenado automáticamente',
|
||||||
|
bindFailed: 'Error en la vinculación',
|
||||||
|
connecting: 'Conectando con el servicio de QQ...',
|
||||||
|
retry: 'Reintentar',
|
||||||
|
tokenNote:
|
||||||
|
'El campo Token no es utilizado por el adaptador actual; puedes dejarlo vacío.',
|
||||||
|
boundBy: 'Vinculado por el usuario QQ {{openid}}',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
||||||
invalidPage: 'Página de plugin no válida',
|
invalidPage: 'Página de plugin no válida',
|
||||||
|
|||||||
@@ -1385,6 +1385,19 @@
|
|||||||
retry: '再試行',
|
retry: '再試行',
|
||||||
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
|
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: 'ワンクリックで QQ 公式ボットを QR バインド',
|
||||||
|
scanQRCode:
|
||||||
|
'以下の QR コードをモバイル QQ でスキャンし、「QQ ボットアシスタント」でバインドを承認してください',
|
||||||
|
waitingForScan: 'スキャン待ち',
|
||||||
|
bindSuccess: 'バインド成功!AppID と Secret が自動入力されました',
|
||||||
|
bindFailed: 'バインド失敗',
|
||||||
|
connecting: 'QQ サービスに接続中...',
|
||||||
|
retry: '再試行',
|
||||||
|
tokenNote:
|
||||||
|
'Token フィールドは現行アダプターでは使用しません。空欄のままで構いません。',
|
||||||
|
boundBy: 'QQ ユーザー {{openid}} によりバインドされました',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
||||||
invalidPage: '無効なプラグインページ',
|
invalidPage: '無効なプラグインページ',
|
||||||
|
|||||||
@@ -1395,6 +1395,19 @@ const ruRU = {
|
|||||||
robotNameNote:
|
robotNameNote:
|
||||||
'Имя бота нельзя получить автоматически. Пожалуйста, введите его вручную.',
|
'Имя бота нельзя получить автоматически. Пожалуйста, введите его вручную.',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: 'Привязка официального бота QQ по QR-коду',
|
||||||
|
scanQRCode:
|
||||||
|
'Отсканируйте QR-код ниже мобильным QQ и подтвердите привязку в «QQ Bot Assistant»',
|
||||||
|
waitingForScan: 'Ожидание сканирования',
|
||||||
|
bindSuccess: 'Привязка успешна! AppID и Secret заполнены автоматически',
|
||||||
|
bindFailed: 'Не удалось выполнить привязку',
|
||||||
|
connecting: 'Подключение к сервису QQ...',
|
||||||
|
retry: 'Повторить',
|
||||||
|
tokenNote:
|
||||||
|
'Поле Token не используется текущим адаптером — его можно оставить пустым.',
|
||||||
|
boundBy: 'Привязано пользователем QQ {{openid}}',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
||||||
invalidPage: 'Недопустимая страница плагина',
|
invalidPage: 'Недопустимая страница плагина',
|
||||||
|
|||||||
@@ -1361,6 +1361,18 @@ const thTH = {
|
|||||||
retry: 'ลองใหม่',
|
retry: 'ลองใหม่',
|
||||||
robotNameNote: 'ไม่สามารถดึงชื่อบอตได้โดยอัตโนมัติ กรุณากรอกด้วยตนเอง',
|
robotNameNote: 'ไม่สามารถดึงชื่อบอตได้โดยอัตโนมัติ กรุณากรอกด้วยตนเอง',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: 'ผูกบอต QQ Official ด้วย QR คลิกเดียว',
|
||||||
|
scanQRCode:
|
||||||
|
'สแกนคิวอาร์โค้ดด้านล่างด้วย QQ มือถือ แล้วอนุญาตการผูกใน «QQ Bot Assistant»',
|
||||||
|
waitingForScan: 'กำลังรอสแกน',
|
||||||
|
bindSuccess: 'ผูกสำเร็จ! AppID และ Secret ถูกกรอกอัตโนมัติแล้ว',
|
||||||
|
bindFailed: 'การผูกล้มเหลว',
|
||||||
|
connecting: 'กำลังเชื่อมต่อบริการ QQ...',
|
||||||
|
retry: 'ลองใหม่',
|
||||||
|
tokenNote: 'อะแดปเตอร์ปัจจุบันไม่ได้ใช้ฟิลด์ Token จึงเว้นว่างไว้ได้',
|
||||||
|
boundBy: 'ผูกโดยผู้ใช้ QQ {{openid}}',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
||||||
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
||||||
|
|||||||
@@ -1385,6 +1385,19 @@ const viVN = {
|
|||||||
retry: 'Thử lại',
|
retry: 'Thử lại',
|
||||||
robotNameNote: 'Không thể tự động lấy tên bot. Vui lòng điền thủ công.',
|
robotNameNote: 'Không thể tự động lấy tên bot. Vui lòng điền thủ công.',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: 'Liên kết bot QQ Official bằng QR một chạm',
|
||||||
|
scanQRCode:
|
||||||
|
'Quét mã QR bên dưới bằng QQ trên di động và xác nhận liên kết trong «QQ Bot Assistant»',
|
||||||
|
waitingForScan: 'Đang chờ quét',
|
||||||
|
bindSuccess: 'Liên kết thành công! AppID và Secret đã được điền tự động',
|
||||||
|
bindFailed: 'Liên kết thất bại',
|
||||||
|
connecting: 'Đang kết nối tới dịch vụ QQ...',
|
||||||
|
retry: 'Thử lại',
|
||||||
|
tokenNote:
|
||||||
|
'Bộ chuyển đổi hiện tại không dùng trường Token; có thể để trống.',
|
||||||
|
boundBy: 'Được liên kết bởi người dùng QQ {{openid}}',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
||||||
invalidPage: 'Trang plugin không hợp lệ',
|
invalidPage: 'Trang plugin không hợp lệ',
|
||||||
|
|||||||
@@ -1319,6 +1319,17 @@ const zhHans = {
|
|||||||
retry: '重试',
|
retry: '重试',
|
||||||
robotNameNote: '机器人名称无法自动获取,请手动填写。',
|
robotNameNote: '机器人名称无法自动获取,请手动填写。',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: '一键扫码绑定 QQ 机器人',
|
||||||
|
scanQRCode: '请使用手机 QQ 扫描以下二维码,在「QQ 机器人助手」中授权绑定',
|
||||||
|
waitingForScan: '等待扫码中',
|
||||||
|
bindSuccess: '绑定成功!AppID 与密钥已自动填入',
|
||||||
|
bindFailed: '绑定失败',
|
||||||
|
connecting: '正在连接 QQ 服务...',
|
||||||
|
retry: '重试',
|
||||||
|
tokenNote: 'Token 字段当前适配器未使用,留空即可。',
|
||||||
|
boundBy: '由 QQ 用户 {{openid}} 扫码绑定',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: '从侧边栏选择一个插件页面',
|
selectFromSidebar: '从侧边栏选择一个插件页面',
|
||||||
invalidPage: '无效的插件页面',
|
invalidPage: '无效的插件页面',
|
||||||
|
|||||||
@@ -1319,6 +1319,17 @@ const zhHant = {
|
|||||||
retry: '重試',
|
retry: '重試',
|
||||||
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
|
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
|
||||||
},
|
},
|
||||||
|
qqofficial: {
|
||||||
|
createBinding: '一鍵掃碼綁定 QQ 機器人',
|
||||||
|
scanQRCode: '請使用手機 QQ 掃描以下 QR Code,在「QQ 機器人助手」中授權綁定',
|
||||||
|
waitingForScan: '等待掃碼中',
|
||||||
|
bindSuccess: '綁定成功!AppID 與密鑰已自動填入',
|
||||||
|
bindFailed: '綁定失敗',
|
||||||
|
connecting: '正在連線 QQ 服務...',
|
||||||
|
retry: '重試',
|
||||||
|
tokenNote: 'Token 欄位目前介面卡未使用,留空即可。',
|
||||||
|
boundBy: '由 QQ 用戶 {{openid}} 掃碼綁定',
|
||||||
|
},
|
||||||
pluginPages: {
|
pluginPages: {
|
||||||
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
||||||
invalidPage: '無效的插件頁面',
|
invalidPage: '無效的插件頁面',
|
||||||
|
|||||||
Reference in New Issue
Block a user