Compare commits

..

5 Commits

Author SHA1 Message Date
Soulter
53296fcc73 fix(docs): restore cli command pages 2026-06-16 23:47:30 +08:00
Soulter
e84e94f39e chore: bump version to 4.26.0-beta.3 2026-06-16 19:12:35 +08:00
Weilong Liao
f1854df620 feat: add qq official qr binding
Add QQ Official Bot QR binding registration flow for the WebSocket adapter, wire dashboard credential autofill, and mark the WebSocket template as recommended.
2026-06-16 19:08:41 +08:00
Weilong Liao
898c800c96 fix: upload skills with generated multipart body
Fixes #8794.
2026-06-16 17:00:23 +08:00
Haoran Xu
f66215b365 fix(chat): prevent IME composition character loss at non-terminal cur… (#8811) 2026-06-16 13:45:33 +08:00
16 changed files with 904 additions and 16 deletions

View File

@@ -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,

View 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)

View File

@@ -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,

View 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))

View File

@@ -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) {

View File

@@ -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 } = {}) {

View File

@@ -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);

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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 настройку",

View File

@@ -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
View 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
View 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
```

View File

@@ -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" }

View 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)