mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-02 10:40:15 +08:00
Compare commits
5 Commits
v4.26.0-be
...
codex/rest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53296fcc73 | ||
|
|
e84e94f39e | ||
|
|
f1854df620 | ||
|
|
898c800c96 | ||
|
|
f66215b365 |
@@ -5,7 +5,7 @@ import os
|
||||
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.26.0-beta.2"
|
||||
VERSION = "4.26.0-beta.3"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
PERSONAL_WECHAT_CONFIG_METADATA = {
|
||||
"weixin_oc_base_url": {
|
||||
@@ -328,7 +328,7 @@ CONFIG_METADATA_2 = {
|
||||
"description": "消息平台适配器",
|
||||
"type": "list",
|
||||
"config_template": {
|
||||
"QQ 官方机器人(WebSocket)": {
|
||||
"QQ 官方机器人(Websocket, 推荐)": {
|
||||
"id": "default",
|
||||
"type": "qq_official",
|
||||
"enable": True,
|
||||
|
||||
272
astrbot/core/platform/sources/qqofficial/login_registration.py
Normal file
272
astrbot/core/platform/sources/qqofficial/login_registration.py
Normal file
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
DEFAULT_QQOFFICIAL_BIND_HOST = "q.qq.com"
|
||||
DEFAULT_QQOFFICIAL_QR_POLL_INTERVAL = 2
|
||||
DEFAULT_QQOFFICIAL_API_TIMEOUT_MS = 10_000
|
||||
|
||||
QQOFFICIAL_BIND_STATUS_NONE = 0
|
||||
QQOFFICIAL_BIND_STATUS_PENDING = 1
|
||||
QQOFFICIAL_BIND_STATUS_COMPLETED = 2
|
||||
QQOFFICIAL_BIND_STATUS_EXPIRED = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class QQOfficialLoginRegistration:
|
||||
task_id: str
|
||||
bind_key: str
|
||||
qrcode: str
|
||||
interval: int
|
||||
|
||||
|
||||
def _string_field(data: dict[str, Any], key: str) -> str:
|
||||
value = data.get(key)
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _int_config(value: Any, default: int, minimum: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
parsed = default
|
||||
return max(parsed, minimum)
|
||||
|
||||
|
||||
def _bind_host(platform_config: dict[str, Any]) -> str:
|
||||
host = _string_field(platform_config, "qqofficial_bind_host")
|
||||
if not host:
|
||||
host = DEFAULT_QQOFFICIAL_BIND_HOST
|
||||
host = host.removeprefix("https://").removeprefix("http://").rstrip("/")
|
||||
return host or DEFAULT_QQOFFICIAL_BIND_HOST
|
||||
|
||||
|
||||
def _connect_url(task_id: str, host: str) -> str:
|
||||
return (
|
||||
f"https://{host}/qqbot/openclaw/connect.html"
|
||||
f"?task_id={quote(task_id, safe='')}&_wv=2"
|
||||
)
|
||||
|
||||
|
||||
async def _post_json(
|
||||
*,
|
||||
url: str,
|
||||
payload: dict[str, Any],
|
||||
timeout_ms: int,
|
||||
) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=timeout_ms / 1000) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("QQ 机器人绑定接口响应格式异常")
|
||||
retcode = data.get("retcode")
|
||||
if retcode is not None:
|
||||
try:
|
||||
retcode_ok = int(retcode) == 0
|
||||
except (TypeError, ValueError):
|
||||
retcode_ok = False
|
||||
if retcode_ok:
|
||||
return data
|
||||
message = (
|
||||
_string_field(data, "msg")
|
||||
or _string_field(data, "message")
|
||||
or "QQ 机器人绑定接口返回失败"
|
||||
)
|
||||
raise RuntimeError(message)
|
||||
return data
|
||||
|
||||
|
||||
def generate_qqofficial_bind_key() -> str:
|
||||
"""Generate a base64 AES-256 key for QQ bot binding.
|
||||
|
||||
Returns:
|
||||
A base64-encoded 32-byte key.
|
||||
"""
|
||||
|
||||
return base64.b64encode(secrets.token_bytes(32)).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_qqofficial_secret(encrypted_secret: str, bind_key: str) -> str:
|
||||
"""Decrypt the AppSecret returned by QQ bot QR binding.
|
||||
|
||||
Args:
|
||||
encrypted_secret: Base64 payload containing 12-byte nonce, ciphertext,
|
||||
and 16-byte GCM tag.
|
||||
bind_key: Base64 AES-256 key sent when creating the bind task.
|
||||
|
||||
Returns:
|
||||
The decrypted QQ bot AppSecret.
|
||||
|
||||
Raises:
|
||||
ValueError: If the encrypted payload is malformed or decryption fails.
|
||||
"""
|
||||
|
||||
try:
|
||||
key = base64.b64decode(bind_key)
|
||||
raw = base64.b64decode(encrypted_secret)
|
||||
except Exception as exc:
|
||||
raise ValueError("QQ 机器人凭证解码失败") from exc
|
||||
if len(key) != 32 or len(raw) <= 28:
|
||||
raise ValueError("QQ 机器人凭证密文格式异常")
|
||||
|
||||
nonce = raw[:12]
|
||||
tag = raw[-16:]
|
||||
ciphertext = raw[12:-16]
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
||||
try:
|
||||
return cipher.decrypt_and_verify(ciphertext, tag).decode("utf-8")
|
||||
except Exception as exc:
|
||||
raise ValueError("QQ 机器人凭证解密失败") from exc
|
||||
|
||||
|
||||
def qqofficial_login_result(data: dict[str, Any], *, bind_key: str) -> dict[str, Any]:
|
||||
"""Map QQ bot bind polling payloads to AstrBot registration statuses.
|
||||
|
||||
Args:
|
||||
data: Response data from `/lite/poll_bind_result`.
|
||||
bind_key: Base64 AES-256 key originally used for the bind task.
|
||||
|
||||
Returns:
|
||||
A registration status payload for the dashboard polling flow.
|
||||
"""
|
||||
|
||||
payload = data.get("data")
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
try:
|
||||
raw_status = int(payload.get("status", QQOFFICIAL_BIND_STATUS_NONE))
|
||||
except (TypeError, ValueError):
|
||||
raw_status = QQOFFICIAL_BIND_STATUS_NONE
|
||||
|
||||
if raw_status == QQOFFICIAL_BIND_STATUS_COMPLETED:
|
||||
appid = str(payload.get("bot_appid") or "").strip()
|
||||
encrypted_secret = str(payload.get("bot_encrypt_secret") or "").strip()
|
||||
if not appid or not encrypted_secret:
|
||||
return {
|
||||
"status": "error",
|
||||
"qr_status": raw_status,
|
||||
"message": "扫码成功但未返回完整 QQ 机器人凭证",
|
||||
}
|
||||
try:
|
||||
secret = decrypt_qqofficial_secret(encrypted_secret, bind_key)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"qr_status": raw_status,
|
||||
"message": str(exc),
|
||||
}
|
||||
return {
|
||||
"status": "created",
|
||||
"qr_status": raw_status,
|
||||
"appid": appid,
|
||||
"secret": secret,
|
||||
"platform_id_suffix": f"_{appid}",
|
||||
}
|
||||
|
||||
if raw_status == QQOFFICIAL_BIND_STATUS_EXPIRED:
|
||||
return {
|
||||
"status": "expired",
|
||||
"qr_status": raw_status,
|
||||
"message": "二维码已过期",
|
||||
}
|
||||
|
||||
return {"status": "pending", "qr_status": raw_status}
|
||||
|
||||
|
||||
async def request_qqofficial_login_qr(
|
||||
platform_config: dict[str, Any],
|
||||
) -> QQOfficialLoginRegistration:
|
||||
"""Request a QR binding task for QQ Official Bot credentials.
|
||||
|
||||
Args:
|
||||
platform_config: Platform configuration from the dashboard.
|
||||
|
||||
Returns:
|
||||
QR binding registration data used by the dashboard.
|
||||
"""
|
||||
|
||||
host = _bind_host(platform_config)
|
||||
timeout_ms = _int_config(
|
||||
platform_config.get("qqofficial_api_timeout_ms"),
|
||||
DEFAULT_QQOFFICIAL_API_TIMEOUT_MS,
|
||||
1_000,
|
||||
)
|
||||
interval = _int_config(
|
||||
platform_config.get("qqofficial_qr_poll_interval"),
|
||||
DEFAULT_QQOFFICIAL_QR_POLL_INTERVAL,
|
||||
1,
|
||||
)
|
||||
bind_key = generate_qqofficial_bind_key()
|
||||
data = await _post_json(
|
||||
url=f"https://{host}/lite/create_bind_task",
|
||||
payload={"key": bind_key},
|
||||
timeout_ms=timeout_ms,
|
||||
)
|
||||
|
||||
payload = data.get("data")
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
task_id = str(payload.get("task_id") or "").strip()
|
||||
if not task_id:
|
||||
raise RuntimeError("QQ 机器人绑定任务响应缺少 task_id")
|
||||
|
||||
return QQOfficialLoginRegistration(
|
||||
task_id=task_id,
|
||||
bind_key=bind_key,
|
||||
qrcode=_connect_url(task_id, host),
|
||||
interval=interval,
|
||||
)
|
||||
|
||||
|
||||
async def poll_qqofficial_login_once(
|
||||
*,
|
||||
platform_config: dict[str, Any],
|
||||
task_id: str,
|
||||
bind_key: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Poll a QQ Official Bot QR binding task once.
|
||||
|
||||
Args:
|
||||
platform_config: Platform configuration from the dashboard.
|
||||
task_id: Task ID returned by `request_qqofficial_login_qr`.
|
||||
bind_key: Base64 AES-256 key returned with the task.
|
||||
|
||||
Returns:
|
||||
A registration status payload for the dashboard polling flow.
|
||||
|
||||
Raises:
|
||||
ValueError: If `task_id` or `bind_key` is missing.
|
||||
"""
|
||||
|
||||
if not task_id:
|
||||
raise ValueError("Missing task_id")
|
||||
if not bind_key:
|
||||
raise ValueError("Missing bind_key")
|
||||
|
||||
host = _bind_host(platform_config)
|
||||
timeout_ms = _int_config(
|
||||
platform_config.get("qqofficial_api_timeout_ms"),
|
||||
DEFAULT_QQOFFICIAL_API_TIMEOUT_MS,
|
||||
1_000,
|
||||
)
|
||||
data = await _post_json(
|
||||
url=f"https://{host}/lite/poll_bind_result",
|
||||
payload={"task_id": task_id},
|
||||
timeout_ms=timeout_ms,
|
||||
)
|
||||
return qqofficial_login_result(data, bind_key=bind_key)
|
||||
@@ -15,6 +15,10 @@ from astrbot.core.platform.sources.lark.app_registration import (
|
||||
request_app_registration,
|
||||
)
|
||||
from astrbot.core.platform.sources.lark.bot_info import request_lark_bot_info
|
||||
from astrbot.core.platform.sources.qqofficial.login_registration import (
|
||||
poll_qqofficial_login_once,
|
||||
request_qqofficial_login_qr,
|
||||
)
|
||||
from astrbot.core.platform.sources.weixin_oc.login_registration import (
|
||||
poll_weixin_oc_login_once,
|
||||
request_weixin_oc_login_qr,
|
||||
@@ -95,6 +99,12 @@ class PlatformService:
|
||||
)
|
||||
if platform_type == "dingtalk":
|
||||
return await self._handle_dingtalk_registration(action, payload)
|
||||
if platform_type == "qq_official":
|
||||
return await self._handle_qqofficial_registration(
|
||||
action,
|
||||
payload,
|
||||
platform_config,
|
||||
)
|
||||
|
||||
raise PlatformServiceError(
|
||||
f"Unsupported platform registration: {platform_type}",
|
||||
@@ -185,6 +195,41 @@ class PlatformService:
|
||||
|
||||
raise PlatformServiceError(f"Unsupported action: {action}", 400)
|
||||
|
||||
async def _handle_qqofficial_registration(
|
||||
self,
|
||||
action: str,
|
||||
payload: dict,
|
||||
platform_config: dict,
|
||||
) -> dict:
|
||||
if action == "start":
|
||||
registration = await request_qqofficial_login_qr(platform_config)
|
||||
return {
|
||||
"status": "pending",
|
||||
"registration_code": registration.task_id,
|
||||
"task_id": registration.task_id,
|
||||
"bind_key": registration.bind_key,
|
||||
"qrcode": registration.qrcode,
|
||||
"qrcode_img_content": registration.qrcode,
|
||||
"interval": registration.interval,
|
||||
}
|
||||
|
||||
if action == "poll":
|
||||
task_id = str(
|
||||
payload.get("task_id") or payload.get("registration_code") or ""
|
||||
).strip()
|
||||
bind_key = str(payload.get("bind_key") or "").strip()
|
||||
if not task_id:
|
||||
raise PlatformServiceError("Missing task_id", 400)
|
||||
if not bind_key:
|
||||
raise PlatformServiceError("Missing bind_key", 400)
|
||||
return await poll_qqofficial_login_once(
|
||||
platform_config=platform_config,
|
||||
task_id=task_id,
|
||||
bind_key=bind_key,
|
||||
)
|
||||
|
||||
raise PlatformServiceError(f"Unsupported action: {action}", 400)
|
||||
|
||||
async def _handle_weixin_oc_registration(
|
||||
self,
|
||||
action: str,
|
||||
|
||||
28
changelogs/v4.26.0-beta.3.md
Normal file
28
changelogs/v4.26.0-beta.3.md
Normal file
@@ -0,0 +1,28 @@
|
||||
- [更新日志(简体中文)](#chinese)
|
||||
- [Changelog(English)](#english)
|
||||
|
||||
<a id="chinese"></a>
|
||||
|
||||
## What's Changed
|
||||
|
||||
### 重点更新
|
||||
|
||||
- 新增 QQ 官方机器人 WebSocket 适配器扫码绑定流程,可通过 WebUI 一键扫码获取并回填 AppID 与 Secret,同时将 WebSocket 模板标记为推荐。([#8821](https://github.com/AstrBotDevs/AstrBot/pull/8821))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复上传 skills 时 multipart 请求体生成方式不正确的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/898c800c9))
|
||||
- 修复聊天输入框在非末尾位置使用输入法组合输入时可能丢失字符的问题。([#8811](https://github.com/AstrBotDevs/AstrBot/pull/8811))
|
||||
|
||||
<a id="english"></a>
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### Highlights
|
||||
|
||||
- Added a QR binding flow for the QQ Official Bot WebSocket adapter, allowing WebUI one-click QR setup to fetch and autofill AppID and Secret, and marked the WebSocket template as recommended. ([#8821](https://github.com/AstrBotDevs/AstrBot/pull/8821))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed skills uploads by generating the multipart request body correctly. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/898c800c9))
|
||||
- Fixed possible IME composition character loss when typing at a non-terminal cursor position in the chat input. ([#8811](https://github.com/AstrBotDevs/AstrBot/pull/8811))
|
||||
@@ -1322,9 +1322,9 @@ export const skillApi = {
|
||||
list(params?: { enabled?: boolean; source?: string }) {
|
||||
return typed<any>(openApiV1.listSkills({ query: params }));
|
||||
},
|
||||
uploadBatch(formData: FormData) {
|
||||
uploadBatch(files: File[]) {
|
||||
return typed<any>(
|
||||
openApiV1.uploadSkillsBatch({ body: generatedFormData(formData) }),
|
||||
openApiV1.uploadSkillsBatch({ body: { files } }),
|
||||
);
|
||||
},
|
||||
setEnabled(skillName: string, enabled: boolean) {
|
||||
|
||||
@@ -523,7 +523,14 @@ const filteredCommands = computed(() => {
|
||||
|
||||
const localPrompt = computed({
|
||||
get: () => props.prompt,
|
||||
set: (value) => emit("update:prompt", value),
|
||||
set: (value) => {
|
||||
// Suppress v-model sync during IME composition to avoid a reactive
|
||||
// feedback loop. Vue's :value binding overwrites the native textarea
|
||||
// DOM state mid-composition, which interferes with IME insertion at
|
||||
// non-terminal cursor positions (alternating character loss).
|
||||
// The final value is synced manually in handleCompositionEnd.
|
||||
if (!isComposing.value) emit("update:prompt", value);
|
||||
},
|
||||
});
|
||||
|
||||
const sessionPlatformId = computed(
|
||||
@@ -768,6 +775,30 @@ function handleCompositionStart() {
|
||||
function handleCompositionEnd(e: CompositionEvent) {
|
||||
lastCompositionEndAt.value = e.timeStamp;
|
||||
clearCompositionState({ keepLastEndAt: true });
|
||||
|
||||
// Manually sync the final composited text to the parent component
|
||||
// after the IME commits. The v-model setter is suppressed during
|
||||
// composition (see localPrompt computed), so we must explicitly
|
||||
// propagate the DOM value once composition ends.
|
||||
//
|
||||
// Capture the DOM value at compositionend to guard against a race
|
||||
// where props.prompt is externally updated between now and nextTick.
|
||||
const endValue = inputField.value?.value;
|
||||
|
||||
nextTick(() => {
|
||||
const el = inputField.value;
|
||||
// Only sync if the DOM hasn't been changed externally in the meantime.
|
||||
if (el && el.value === endValue && el.value !== props.prompt) {
|
||||
emit("update:prompt", el.value);
|
||||
// Re-evaluate command suggestions that were suppressed during IME
|
||||
// composition (handleInput checks isComposing). Only needed when
|
||||
// the value actually changed. Runs in a nested nextTick so
|
||||
// props.prompt reflects the emit above.
|
||||
nextTick(() => {
|
||||
handleInput();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearCompositionState({ keepLastEndAt = false } = {}) {
|
||||
|
||||
@@ -1195,12 +1195,9 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
for (const item of attemptedItems) {
|
||||
formData.append("files", item.file);
|
||||
}
|
||||
|
||||
const res = await skillApi.uploadBatch(formData);
|
||||
const res = await skillApi.uploadBatch(
|
||||
attemptedItems.map((item) => item.file),
|
||||
);
|
||||
|
||||
const payload = res?.data?.data || {};
|
||||
applyUploadResults(attemptedItems, payload);
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
<PlatformRegistrationAction
|
||||
:platform-config="selectedPlatformConfig"
|
||||
:active="dingtalkCreationMode === 'scan'"
|
||||
@created="handlePlatformRegistrationCreated"
|
||||
@success="showSuccess"
|
||||
@error="showError"
|
||||
/>
|
||||
@@ -170,6 +171,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isQqOfficialPlatform">
|
||||
<div class="creation-mode-title mt-4 mb-1">
|
||||
{{ tm("registrationAction.mode.title") }}
|
||||
</div>
|
||||
<v-radio-group
|
||||
v-model="qqOfficialCreationMode"
|
||||
class="creation-mode-group"
|
||||
hide-details
|
||||
>
|
||||
<v-radio
|
||||
value="scan"
|
||||
:label="tm('registrationAction.mode.scan')"
|
||||
></v-radio>
|
||||
<v-radio
|
||||
value="manual"
|
||||
:label="tm('registrationAction.mode.manual')"
|
||||
></v-radio>
|
||||
</v-radio-group>
|
||||
|
||||
<div
|
||||
v-if="qqOfficialCreationMode === 'scan'"
|
||||
class="registration-inline mt-3"
|
||||
>
|
||||
<PlatformRegistrationAction
|
||||
:platform-config="selectedPlatformConfig"
|
||||
:active="qqOfficialCreationMode === 'scan'"
|
||||
@created="handlePlatformRegistrationCreated"
|
||||
@success="showSuccess"
|
||||
@error="showError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="qqOfficialCreationMode === 'manual'"
|
||||
class="mt-2"
|
||||
>
|
||||
<div class="platform-action-row">
|
||||
<v-btn
|
||||
color="info"
|
||||
variant="tonal"
|
||||
@click="openTutorial"
|
||||
class="mt-2"
|
||||
>
|
||||
<v-icon start>mdi-book-open-variant</v-icon>
|
||||
{{ tm("dialog.viewTutorial") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<AstrBotConfig
|
||||
:iterable="selectedPlatformConfig"
|
||||
:metadata="metadata['platform_group']?.metadata"
|
||||
metadataKey="platform"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="isWeixinOcPlatform"
|
||||
class="weixin-oc-registration-inline mt-4"
|
||||
@@ -777,6 +833,7 @@ export default {
|
||||
selectedPlatformConfig: null,
|
||||
larkCreationMode: "",
|
||||
dingtalkCreationMode: "",
|
||||
qqOfficialCreationMode: "",
|
||||
|
||||
aBConfigRadioVal: "0",
|
||||
selectedAbConfId: "default",
|
||||
@@ -878,6 +935,19 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isQqOfficialPlatform && !this.qqOfficialCreationMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isQqOfficialPlatform && this.qqOfficialCreationMode === "scan") {
|
||||
if (
|
||||
!this.selectedPlatformConfig?.appid ||
|
||||
!this.selectedPlatformConfig?.secret
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.isWeixinOcPlatform &&
|
||||
!this.selectedPlatformConfig?.weixin_oc_token
|
||||
@@ -974,6 +1044,9 @@ export default {
|
||||
isDingtalkPlatform() {
|
||||
return this.selectedPlatformConfig?.type === "dingtalk";
|
||||
},
|
||||
isQqOfficialPlatform() {
|
||||
return this.selectedPlatformConfig?.type === "qq_official";
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedPlatformType(newType) {
|
||||
@@ -983,10 +1056,12 @@ export default {
|
||||
);
|
||||
this.larkCreationMode = "";
|
||||
this.dingtalkCreationMode = "";
|
||||
this.qqOfficialCreationMode = "";
|
||||
} else {
|
||||
this.selectedPlatformConfig = null;
|
||||
this.larkCreationMode = "";
|
||||
this.dingtalkCreationMode = "";
|
||||
this.qqOfficialCreationMode = "";
|
||||
}
|
||||
},
|
||||
selectedAbConfId(newConfigId) {
|
||||
@@ -1073,6 +1148,7 @@ export default {
|
||||
this.selectedPlatformConfig = null;
|
||||
this.larkCreationMode = "";
|
||||
this.dingtalkCreationMode = "";
|
||||
this.qqOfficialCreationMode = "";
|
||||
|
||||
this.aBConfigRadioVal = "0";
|
||||
this.selectedAbConfId = "default";
|
||||
|
||||
@@ -70,6 +70,13 @@ const REGISTRATION_ACTIONS = {
|
||||
scanTitleKey: 'registrationAction.dingtalk.scanTitle',
|
||||
successKey: 'registrationAction.dingtalk.created',
|
||||
},
|
||||
qq_official: {
|
||||
icon: 'mdi-qrcode',
|
||||
titleKey: 'registrationAction.qqOfficial.title',
|
||||
scanTitleKey: 'registrationAction.qqOfficial.scanTitle',
|
||||
successKey: 'registrationAction.qqOfficial.created',
|
||||
statusKeyPrefix: 'registrationAction.qqOfficial.status',
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
@@ -202,12 +209,19 @@ export default {
|
||||
if (!this.action || !this.flow.registration_code) {
|
||||
return;
|
||||
}
|
||||
const pollPayload = {
|
||||
registration_code: this.flow.registration_code,
|
||||
};
|
||||
if (this.flow.task_id) {
|
||||
pollPayload.task_id = this.flow.task_id;
|
||||
}
|
||||
if (this.flow.bind_key) {
|
||||
pollPayload.bind_key = this.flow.bind_key;
|
||||
}
|
||||
try {
|
||||
const res = await botApi.registration(
|
||||
this.platformConfig.type,
|
||||
this.buildPayload('poll', {
|
||||
registration_code: this.flow.registration_code,
|
||||
}),
|
||||
this.buildPayload('poll', pollPayload),
|
||||
);
|
||||
if (res.data.status !== 'ok') {
|
||||
throw new Error(res.data.message || this.tm('registrationAction.pollFailed'));
|
||||
@@ -254,6 +268,12 @@ export default {
|
||||
if (data.app_secret) {
|
||||
this.platformConfig.app_secret = data.app_secret;
|
||||
}
|
||||
if (data.appid) {
|
||||
this.platformConfig.appid = data.appid;
|
||||
}
|
||||
if (data.secret) {
|
||||
this.platformConfig.secret = data.secret;
|
||||
}
|
||||
if (data.domain) {
|
||||
this.platformConfig.domain = data.domain;
|
||||
}
|
||||
|
||||
@@ -163,6 +163,20 @@
|
||||
"scanTitle": "Scan with DingTalk on your phone",
|
||||
"created": "Setup Complete"
|
||||
},
|
||||
"qqOfficial": {
|
||||
"title": "QQ Official Bot QR Binding",
|
||||
"scanTitle": "Scan with QQ on your phone",
|
||||
"created": "Binding Complete",
|
||||
"status": {
|
||||
"idle": "Not Started",
|
||||
"starting": "Getting QR Code",
|
||||
"pending": "Waiting for Scan",
|
||||
"created": "Binding Complete - remember to click the save button!",
|
||||
"denied": "Canceled",
|
||||
"expired": "Expired",
|
||||
"error": "Binding Failed"
|
||||
}
|
||||
},
|
||||
"start": "Start Setup",
|
||||
"created": "Setup Complete",
|
||||
"startFailed": "Failed to start QR setup",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"title": "Боты",
|
||||
"subtitle": "Управление адаптерами платформ для подключения к мессенджерам",
|
||||
"adapters": "Адаптеры платформ",
|
||||
@@ -163,6 +163,20 @@
|
||||
"scanTitle": "Отсканируйте в мобильном DingTalk",
|
||||
"created": "Настройка завершена"
|
||||
},
|
||||
"qqOfficial": {
|
||||
"title": "Привязка QQ Official Bot по QR",
|
||||
"scanTitle": "Отсканируйте в мобильном QQ",
|
||||
"created": "Привязка завершена",
|
||||
"status": {
|
||||
"idle": "Не начато",
|
||||
"starting": "Получение QR",
|
||||
"pending": "Ожидание сканирования",
|
||||
"created": "Привязка завершена - не забудьте нажать кнопку сохранения!",
|
||||
"denied": "Отменено",
|
||||
"expired": "Истекло",
|
||||
"error": "Ошибка привязки"
|
||||
}
|
||||
},
|
||||
"start": "Начать настройку",
|
||||
"created": "Настройка завершена",
|
||||
"startFailed": "Не удалось начать QR настройку",
|
||||
|
||||
@@ -163,6 +163,20 @@
|
||||
"scanTitle": "请使用手机钉钉扫码",
|
||||
"created": "创建成功"
|
||||
},
|
||||
"qqOfficial": {
|
||||
"title": "QQ 官方机器人扫码绑定",
|
||||
"scanTitle": "请使用手机 QQ 扫码",
|
||||
"created": "绑定成功",
|
||||
"status": {
|
||||
"idle": "未开始",
|
||||
"starting": "正在获取二维码",
|
||||
"pending": "等待扫码确认",
|
||||
"created": "绑定成功,记得点击下方保存按钮!",
|
||||
"denied": "用户取消",
|
||||
"expired": "已过期",
|
||||
"error": "绑定失败"
|
||||
}
|
||||
},
|
||||
"start": "开始创建",
|
||||
"created": "创建成功",
|
||||
"startFailed": "发起扫码创建失败",
|
||||
|
||||
153
docs/en/use/cli.md
Normal file
153
docs/en/use/cli.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# CLI Commands
|
||||
|
||||
The AstrBot CLI initializes instances, starts AstrBot, updates common config values, and manages plugins.
|
||||
|
||||
If you install AstrBot with `uv`:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
```
|
||||
|
||||
`uv` creates the `astrbot` executable and puts it on `PATH`. You can inspect the path with:
|
||||
|
||||
::: code-group
|
||||
|
||||
```bash [Linux / macOS]
|
||||
which astrbot
|
||||
```
|
||||
|
||||
```powershell [Windows]
|
||||
where.exe astrbot
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
> [!TIP]
|
||||
> Run the commands below from the AstrBot working directory.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Initialize the directory once, then start AstrBot:
|
||||
|
||||
```bash
|
||||
astrbot init
|
||||
astrbot run
|
||||
```
|
||||
|
||||
`astrbot init` creates the data directories and configuration files required by AstrBot. After initialization, use `astrbot run` for later starts.
|
||||
|
||||
## Top-Level Commands
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `astrbot init` | Initialize the current directory as an AstrBot working directory. |
|
||||
| `astrbot run` | Start AstrBot in the foreground. |
|
||||
| `astrbot conf` | Read or update common config values. |
|
||||
| `astrbot password` | Change the WebUI login password interactively. |
|
||||
| `astrbot plug` | Create, install, update, remove, or search plugins. |
|
||||
| `astrbot help` | Show CLI help. |
|
||||
| `astrbot --version` | Show the AstrBot CLI version. |
|
||||
|
||||
## Start AstrBot
|
||||
|
||||
```bash
|
||||
astrbot run
|
||||
```
|
||||
|
||||
Common options:
|
||||
|
||||
| Option | Purpose |
|
||||
| --- | --- |
|
||||
| `-p, --port <PORT>` | Set the WebUI port. |
|
||||
| `-r, --reload` | Enable plugin auto-reload for plugin development. |
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
astrbot run --port 6185
|
||||
astrbot run --reload
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
`astrbot conf` reads and updates common config values.
|
||||
|
||||
```bash
|
||||
astrbot conf get
|
||||
astrbot conf get dashboard.port
|
||||
astrbot conf set dashboard.port 6185
|
||||
```
|
||||
|
||||
Supported keys:
|
||||
|
||||
| Key | Description |
|
||||
| --- | --- |
|
||||
| `timezone` | Time zone, for example `Asia/Shanghai`. |
|
||||
| `log_level` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL`. |
|
||||
| `dashboard.port` | WebUI port. |
|
||||
| `dashboard.username` | WebUI username. |
|
||||
| `dashboard.password` | WebUI password. |
|
||||
| `callback_api_base` | Callback API base URL. Must start with `http://` or `https://`. |
|
||||
|
||||
Changing the dashboard password writes the current password hashes automatically:
|
||||
|
||||
```bash
|
||||
astrbot conf set dashboard.password "new-password"
|
||||
```
|
||||
|
||||
You can also use the dedicated interactive password command:
|
||||
|
||||
```bash
|
||||
astrbot password
|
||||
astrbot password --username admin
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
`astrbot plug` manages plugins under `data/plugins`.
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `astrbot plug list` | List installed plugins. |
|
||||
| `astrbot plug list --all` | Also show uninstalled plugins. |
|
||||
| `astrbot plug search <QUERY>` | Search plugins. |
|
||||
| `astrbot plug install <NAME>` | Install a plugin. |
|
||||
| `astrbot plug update [NAME]` | Update one plugin, or all updatable plugins if no name is given. |
|
||||
| `astrbot plug remove <NAME>` | Remove an installed plugin. |
|
||||
| `astrbot plug new <NAME>` | Create a new plugin from the template. |
|
||||
|
||||
Use a GitHub proxy when installing or updating plugins:
|
||||
|
||||
```bash
|
||||
astrbot plug install example-plugin --proxy https://gh-proxy.example.com/
|
||||
astrbot plug update --proxy https://gh-proxy.example.com/
|
||||
```
|
||||
|
||||
Creating a new plugin asks for the author, description, version, and repository URL:
|
||||
|
||||
```bash
|
||||
astrbot plug new my-plugin
|
||||
```
|
||||
|
||||
## Help
|
||||
|
||||
Show general CLI help:
|
||||
|
||||
```bash
|
||||
astrbot help
|
||||
```
|
||||
|
||||
Show help for a specific command:
|
||||
|
||||
```bash
|
||||
astrbot help run
|
||||
astrbot run --help
|
||||
astrbot help conf
|
||||
astrbot plug --help
|
||||
```
|
||||
|
||||
Show the version:
|
||||
|
||||
```bash
|
||||
astrbot --version
|
||||
```
|
||||
153
docs/zh/use/cli.md
Normal file
153
docs/zh/use/cli.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# CLI 指令
|
||||
|
||||
AstrBot CLI 用于初始化实例、启动 AstrBot、修改常用配置和管理插件。
|
||||
|
||||
如果你使用 `uv` 安装:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot --python 3.12
|
||||
```
|
||||
|
||||
`uv` 会生成 `astrbot` 可执行文件,并把它放到 `PATH` 中。可以用下面的命令确认路径:
|
||||
|
||||
::: code-group
|
||||
|
||||
```bash [Linux / macOS]
|
||||
which astrbot
|
||||
```
|
||||
|
||||
```powershell [Windows]
|
||||
where.exe astrbot
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
> [!TIP]
|
||||
> 下面的命令都需要在 AstrBot 工作目录中执行。
|
||||
|
||||
## 快速开始
|
||||
|
||||
第一次部署时先初始化目录,再启动 AstrBot:
|
||||
|
||||
```bash
|
||||
astrbot init
|
||||
astrbot run
|
||||
```
|
||||
|
||||
`astrbot init` 会在当前目录创建 AstrBot 所需的数据目录和配置文件。初始化完成后,后续启动只需要执行 `astrbot run`。
|
||||
|
||||
## 顶层指令
|
||||
|
||||
| 指令 | 用途 |
|
||||
| --- | --- |
|
||||
| `astrbot init` | 初始化当前目录为 AstrBot 工作目录。 |
|
||||
| `astrbot run` | 在前台启动 AstrBot。 |
|
||||
| `astrbot conf` | 查看或修改常用配置项。 |
|
||||
| `astrbot password` | 交互式修改 WebUI 登录密码。 |
|
||||
| `astrbot plug` | 创建、安装、更新、删除或搜索插件。 |
|
||||
| `astrbot help` | 查看 CLI 帮助。 |
|
||||
| `astrbot --version` | 查看 AstrBot CLI 版本。 |
|
||||
|
||||
## 启动 AstrBot
|
||||
|
||||
```bash
|
||||
astrbot run
|
||||
```
|
||||
|
||||
常用选项:
|
||||
|
||||
| 选项 | 用途 |
|
||||
| --- | --- |
|
||||
| `-p, --port <PORT>` | 指定 WebUI 端口。 |
|
||||
| `-r, --reload` | 启用插件自动重载,适合插件开发调试。 |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
astrbot run --port 6185
|
||||
astrbot run --reload
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
`astrbot conf` 用于查看和修改常用配置项。
|
||||
|
||||
```bash
|
||||
astrbot conf get
|
||||
astrbot conf get dashboard.port
|
||||
astrbot conf set dashboard.port 6185
|
||||
```
|
||||
|
||||
支持的配置项:
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| --- | --- |
|
||||
| `timezone` | 时区,例如 `Asia/Shanghai`。 |
|
||||
| `log_level` | 日志等级:`DEBUG`、`INFO`、`WARNING`、`ERROR`、`CRITICAL`。 |
|
||||
| `dashboard.port` | WebUI 端口。 |
|
||||
| `dashboard.username` | WebUI 用户名。 |
|
||||
| `dashboard.password` | WebUI 密码。 |
|
||||
| `callback_api_base` | 回调 API 基础地址,需要以 `http://` 或 `https://` 开头。 |
|
||||
|
||||
修改密码时会自动写入新版密码哈希:
|
||||
|
||||
```bash
|
||||
astrbot conf set dashboard.password "new-password"
|
||||
```
|
||||
|
||||
也可以使用专门的交互式密码指令:
|
||||
|
||||
```bash
|
||||
astrbot password
|
||||
astrbot password --username admin
|
||||
```
|
||||
|
||||
## 插件
|
||||
|
||||
`astrbot plug` 用于管理 `data/plugins` 下的插件。
|
||||
|
||||
| 指令 | 用途 |
|
||||
| --- | --- |
|
||||
| `astrbot plug list` | 查看已安装插件。 |
|
||||
| `astrbot plug list --all` | 同时显示未安装插件。 |
|
||||
| `astrbot plug search <QUERY>` | 搜索插件。 |
|
||||
| `astrbot plug install <NAME>` | 安装插件。 |
|
||||
| `astrbot plug update [NAME]` | 更新指定插件;不传名称时更新所有可更新插件。 |
|
||||
| `astrbot plug remove <NAME>` | 删除已安装插件。 |
|
||||
| `astrbot plug new <NAME>` | 基于模板创建新插件。 |
|
||||
|
||||
安装或更新插件时可以使用 GitHub 代理:
|
||||
|
||||
```bash
|
||||
astrbot plug install example-plugin --proxy https://gh-proxy.example.com/
|
||||
astrbot plug update --proxy https://gh-proxy.example.com/
|
||||
```
|
||||
|
||||
创建新插件会交互式询问作者、描述、版本和仓库地址:
|
||||
|
||||
```bash
|
||||
astrbot plug new my-plugin
|
||||
```
|
||||
|
||||
## 帮助
|
||||
|
||||
查看全部 CLI 帮助:
|
||||
|
||||
```bash
|
||||
astrbot help
|
||||
```
|
||||
|
||||
查看指定指令帮助:
|
||||
|
||||
```bash
|
||||
astrbot help run
|
||||
astrbot run --help
|
||||
astrbot help conf
|
||||
astrbot plug --help
|
||||
```
|
||||
|
||||
查看版本:
|
||||
|
||||
```bash
|
||||
astrbot --version
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.26.0-beta.2"
|
||||
version = "4.26.0-beta.3"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
|
||||
71
tests/test_qqofficial_login_registration.py
Normal file
71
tests/test_qqofficial_login_registration.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from astrbot.core.platform.sources.qqofficial.login_registration import (
|
||||
QQOFFICIAL_BIND_STATUS_COMPLETED,
|
||||
QQOFFICIAL_BIND_STATUS_EXPIRED,
|
||||
QQOFFICIAL_BIND_STATUS_PENDING,
|
||||
decrypt_qqofficial_secret,
|
||||
generate_qqofficial_bind_key,
|
||||
qqofficial_login_result,
|
||||
)
|
||||
|
||||
|
||||
def test_generate_qqofficial_bind_key_returns_base64_aes_key():
|
||||
bind_key = generate_qqofficial_bind_key()
|
||||
|
||||
assert len(base64.b64decode(bind_key)) == 32
|
||||
|
||||
|
||||
def test_qqofficial_login_result_maps_completed_payload():
|
||||
bind_key = base64.b64encode(bytes(range(32))).decode("ascii")
|
||||
nonce = b"123456789012"
|
||||
cipher = AES.new(base64.b64decode(bind_key), AES.MODE_GCM, nonce=nonce)
|
||||
ciphertext, tag = cipher.encrypt_and_digest(b"secret-value")
|
||||
encrypted_secret = base64.b64encode(nonce + ciphertext + tag).decode("ascii")
|
||||
|
||||
result = qqofficial_login_result(
|
||||
{
|
||||
"data": {
|
||||
"status": QQOFFICIAL_BIND_STATUS_COMPLETED,
|
||||
"bot_appid": "123456789",
|
||||
"bot_encrypt_secret": encrypted_secret,
|
||||
},
|
||||
},
|
||||
bind_key=bind_key,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": "created",
|
||||
"qr_status": QQOFFICIAL_BIND_STATUS_COMPLETED,
|
||||
"appid": "123456789",
|
||||
"secret": "secret-value",
|
||||
"platform_id_suffix": "_123456789",
|
||||
}
|
||||
|
||||
|
||||
def test_qqofficial_login_result_maps_pending_and_expired_payloads():
|
||||
bind_key = base64.b64encode(bytes(range(32))).decode("ascii")
|
||||
|
||||
assert qqofficial_login_result(
|
||||
{"data": {"status": QQOFFICIAL_BIND_STATUS_PENDING}},
|
||||
bind_key=bind_key,
|
||||
) == {"status": "pending", "qr_status": QQOFFICIAL_BIND_STATUS_PENDING}
|
||||
|
||||
assert qqofficial_login_result(
|
||||
{"data": {"status": QQOFFICIAL_BIND_STATUS_EXPIRED}},
|
||||
bind_key=bind_key,
|
||||
) == {
|
||||
"status": "expired",
|
||||
"qr_status": QQOFFICIAL_BIND_STATUS_EXPIRED,
|
||||
"message": "二维码已过期",
|
||||
}
|
||||
|
||||
|
||||
def test_decrypt_qqofficial_secret_rejects_invalid_payload():
|
||||
bind_key = base64.b64encode(bytes(range(32))).decode("ascii")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
decrypt_qqofficial_secret("invalid", bind_key)
|
||||
Reference in New Issue
Block a user