mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-16 18:56:02 +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
|
||||
|
||||
|
||||
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')
|
||||
class AdaptersRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@@ -650,3 +673,220 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if session and session.get('task') and not session['task'].done():
|
||||
session['task'].cancel()
|
||||
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
|
||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||
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
|
||||
label:
|
||||
en_US: App ID
|
||||
@@ -40,8 +52,12 @@ spec:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
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
|
||||
required: true
|
||||
required: false
|
||||
default: ""
|
||||
- name: enable-webhook
|
||||
label:
|
||||
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
||||
export type QrLoginPlatform =
|
||||
| 'feishu'
|
||||
| 'weixin'
|
||||
| 'dingtalk'
|
||||
| 'wecombot'
|
||||
| 'qqofficial';
|
||||
|
||||
interface PlatformConfig {
|
||||
titleKey: string;
|
||||
@@ -29,6 +34,7 @@ interface PlatformConfig {
|
||||
apiBase: string;
|
||||
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
||||
successNoteKey?: string;
|
||||
boundByKey?: string;
|
||||
}
|
||||
|
||||
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
@@ -92,6 +98,22 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
}),
|
||||
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 {
|
||||
@@ -118,6 +140,7 @@ export default function QrCodeLoginDialog({
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [expireIn, setExpireIn] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [successMeta, setSuccessMeta] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -178,6 +201,7 @@ export default function QrCodeLoginDialog({
|
||||
setQrDataUrl('');
|
||||
setExpireIn(0);
|
||||
setErrorMessage('');
|
||||
setSuccessMeta('');
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
@@ -275,6 +299,13 @@ export default function QrCodeLoginDialog({
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
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(() => {
|
||||
onSuccessRef.current(cfg.extractSuccess(rest));
|
||||
onOpenChangeRef.current(false);
|
||||
@@ -395,6 +426,11 @@ export default function QrCodeLoginDialog({
|
||||
<p className="text-sm text-green-600 font-medium">
|
||||
{t(platformConfig.successKey)}
|
||||
</p>
|
||||
{successMeta && (
|
||||
<p className="text-xs text-muted-foreground text-center max-w-xs break-all">
|
||||
{successMeta}
|
||||
</p>
|
||||
)}
|
||||
{platformConfig.successNoteKey && (
|
||||
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||
{t(platformConfig.successNoteKey)}
|
||||
|
||||
@@ -1383,6 +1383,19 @@ const enUS = {
|
||||
robotNameNote:
|
||||
'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: {
|
||||
selectFromSidebar: 'Select a plugin page from the sidebar',
|
||||
invalidPage: 'Invalid plugin page',
|
||||
|
||||
@@ -1426,6 +1426,20 @@ const esES = {
|
||||
robotNameNote:
|
||||
'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: {
|
||||
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
||||
invalidPage: 'Página de plugin no válida',
|
||||
|
||||
@@ -1385,6 +1385,19 @@
|
||||
retry: '再試行',
|
||||
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
|
||||
},
|
||||
qqofficial: {
|
||||
createBinding: 'ワンクリックで QQ 公式ボットを QR バインド',
|
||||
scanQRCode:
|
||||
'以下の QR コードをモバイル QQ でスキャンし、「QQ ボットアシスタント」でバインドを承認してください',
|
||||
waitingForScan: 'スキャン待ち',
|
||||
bindSuccess: 'バインド成功!AppID と Secret が自動入力されました',
|
||||
bindFailed: 'バインド失敗',
|
||||
connecting: 'QQ サービスに接続中...',
|
||||
retry: '再試行',
|
||||
tokenNote:
|
||||
'Token フィールドは現行アダプターでは使用しません。空欄のままで構いません。',
|
||||
boundBy: 'QQ ユーザー {{openid}} によりバインドされました',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
||||
invalidPage: '無効なプラグインページ',
|
||||
|
||||
@@ -1395,6 +1395,19 @@ const ruRU = {
|
||||
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: {
|
||||
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
||||
invalidPage: 'Недопустимая страница плагина',
|
||||
|
||||
@@ -1361,6 +1361,18 @@ const thTH = {
|
||||
retry: 'ลองใหม่',
|
||||
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: {
|
||||
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
||||
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
||||
|
||||
@@ -1385,6 +1385,19 @@ const viVN = {
|
||||
retry: 'Thử lại',
|
||||
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: {
|
||||
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
||||
invalidPage: 'Trang plugin không hợp lệ',
|
||||
|
||||
@@ -1319,6 +1319,17 @@ const zhHans = {
|
||||
retry: '重试',
|
||||
robotNameNote: '机器人名称无法自动获取,请手动填写。',
|
||||
},
|
||||
qqofficial: {
|
||||
createBinding: '一键扫码绑定 QQ 机器人',
|
||||
scanQRCode: '请使用手机 QQ 扫描以下二维码,在「QQ 机器人助手」中授权绑定',
|
||||
waitingForScan: '等待扫码中',
|
||||
bindSuccess: '绑定成功!AppID 与密钥已自动填入',
|
||||
bindFailed: '绑定失败',
|
||||
connecting: '正在连接 QQ 服务...',
|
||||
retry: '重试',
|
||||
tokenNote: 'Token 字段当前适配器未使用,留空即可。',
|
||||
boundBy: '由 QQ 用户 {{openid}} 扫码绑定',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '从侧边栏选择一个插件页面',
|
||||
invalidPage: '无效的插件页面',
|
||||
|
||||
@@ -1319,6 +1319,17 @@ const zhHant = {
|
||||
retry: '重試',
|
||||
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
|
||||
},
|
||||
qqofficial: {
|
||||
createBinding: '一鍵掃碼綁定 QQ 機器人',
|
||||
scanQRCode: '請使用手機 QQ 掃描以下 QR Code,在「QQ 機器人助手」中授權綁定',
|
||||
waitingForScan: '等待掃碼中',
|
||||
bindSuccess: '綁定成功!AppID 與密鑰已自動填入',
|
||||
bindFailed: '綁定失敗',
|
||||
connecting: '正在連線 QQ 服務...',
|
||||
retry: '重試',
|
||||
tokenNote: 'Token 欄位目前介面卡未使用,留空即可。',
|
||||
boundBy: '由 QQ 用戶 {{openid}} 掃碼綁定',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
||||
invalidPage: '無效的插件頁面',
|
||||
|
||||
Reference in New Issue
Block a user