Compare commits

...

19 Commits

Author SHA1 Message Date
Soulter
c956894025 feat(service): remove Windows service support and update documentation 2026-05-22 20:19:46 +08:00
Soulter
85e2560bf3 feat(service): update PowerShell script for Windows service to enhance logging and execution 2026-05-22 20:13:20 +08:00
Soulter
e2c55fa740 feat(service): add UTF-8 environment variables to PowerShell script for Windows service 2026-05-22 19:49:48 +08:00
Soulter
c2bbec7683 Revert "chore: remove windows"
This reverts commit b1e1f5e6e4.
2026-05-22 19:44:14 +08:00
Soulter
aa7bd5e5ad feat: add support for resetting dashboard initial password on startup
Co-authored-by: Copilot <copilot@github.com>
2026-05-22 19:34:22 +08:00
Soulter
b1e1f5e6e4 chore: remove windows 2026-05-22 17:58:20 +08:00
Soulter
250baccf5b feat(service): update Windows service command to use PowerShell and enhance task XML configuration 2026-05-22 15:37:38 +08:00
Soulter
97f0fd3de3 feat: add CLI commands documentation and service management features
- Updated the VitePress configuration to include links to CLI commands in both English and Chinese.
- Enhanced the AstrBot deployment documentation with instructions for installing it as a system service.
- Created comprehensive CLI commands documentation covering initialization, service management, configuration, and plugin management.
- Added tests for CLI command aliases and service functionalities to ensure proper command registration and behavior.
- Implemented service log management features, including enabling application logging and controlling log visibility.
2026-05-21 23:44:28 +08:00
lingyun14
0711172fa7 Fix/stale command hints (#8245)
* Update stage.py

* fix: remove stale slash command hints

* fix: remove stale slash command hints

* fix: remove stale slash command hints

* Update openai_source.py
2026-05-21 21:45:57 +08:00
Weilong Liao
d15606d202 feat(password): add command to change AstrBot dashboard password (#8272)
closes: #8268
2026-05-21 21:37:10 +08:00
M1LKT
165933545d feat: Automate generation of the MDI icon font subset during dashboard dev and build workflows (#8264)
* feat: ignore字体集的生成文件,并在编译时自动生成

* 移除preview的前置运行

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-21 08:48:12 +08:00
Yufeng He
c4693fa68e fix: support rst and adoc knowledge uploads (#8255) 2026-05-20 14:38:16 +08:00
Jianyu Li
7a9fb33dd9 docs: fix typo of the count in FAQ deletion instructions (#8235)
Correct the number of fields to be deleted from five to six in the instructions.
2026-05-19 14:41:58 +08:00
dependabot[bot]
de0a7afdcf chore(deps): bump pnpm/action-setup in the github-actions group (#8233)
Bumps the github-actions group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `pnpm/action-setup` from 6.0.7 to 6.0.8
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6.0.7...v6.0.8)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 13:21:03 +09:00
Yufeng He
5bbcdced0f fix: skip empty llm summaries (#8195) 2026-05-19 09:38:55 +08:00
Soulter
dceacd5a87 docs: update release version instructions in AGENTS.md 2026-05-17 15:07:39 +08:00
Soulter
d609f23b71 chore: bump version to 4.25.1 2026-05-17 15:01:59 +08:00
Soulter
a1e95081be feat: add random suffix for weixin and dingtalk id 2026-05-17 14:56:15 +08:00
Midwich
b3381c6448 fix(webui): add Noto Sans to global font-family stack for Cyrillic text (#8205)
PR #8015 added 'Noto Sans' to the Google Fonts link and CJK fallback list,
but the font was placed at the end of $cjk-sans-fallback where browsers
never reach it for Cyrillic text. The global $body-font-family also lacked
'Outfit' entirely, causing Vuetify to use CJK fonts as the primary face.

Changes:
- Remove 'Noto Sans' from the end of $cjk-sans-fallback (it is not a CJK font)
- Add 'Outfit' and 'Noto Sans' to $body-font-family before CJK fallbacks
- Update .Outfit class in _container.scss to match the new stack

This ensures:
- Latin text → Outfit
- Cyrillic text → Noto Sans (loaded by vite-plugin-webfont-dl)
- CJK text → Noto Sans SC / PingFang SC etc.

Fixes follow-up to #8015.
2026-05-16 22:03:13 +08:00
48 changed files with 2722 additions and 1413 deletions

View File

@@ -13,7 +13,7 @@ jobs:
- name: checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.7
uses: pnpm/action-setup@v6.0.8
with:
version: 10.28.2
- name: Setup Node.js

View File

@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.7
uses: pnpm/action-setup@v6.0.8
with:
version: 10.28.2

View File

@@ -51,7 +51,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.7
uses: pnpm/action-setup@v6.0.8
with:
version: 10.28.2

View File

@@ -52,3 +52,10 @@ ruff check .
1. Title format: use conventional commit messages
2. Use English to write PR title and descriptions.
## Release versions
1. Replace current version name to specific version name.
2. Write changelog in `changelogs/`, you can refer to the full commit messages between the latest tag to the latest commit.
3. Make and push a commit into master branch with message format like: `chore: bump version to 4.25.0`
4. Create a tag and push the tag. For example: `git tag v4.25.0 && git push origin v4.25.0`

View File

@@ -176,7 +176,7 @@ class Main(star.Star):
if not session_curr_cid:
logger.error(
"当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 序号 切换或者 /new 创建一个会话。",
"当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /new 创建一个会话。",
)
return

View File

@@ -1 +1 @@
__version__ = "4.25.0"
__version__ = "4.25.1"

View File

@@ -5,7 +5,7 @@ import sys
import click
from . import __version__
from .commands import conf, init, plug, run
from .commands import config, init, password, plugin, run, service
logo_tmpl = r"""
___ _______.___________..______ .______ ______ .___________.
@@ -17,7 +17,23 @@ logo_tmpl = r"""
"""
@click.group()
class AstrBotCLIGroup(click.Group):
COMMAND_ALIASES = {
"conf": "config",
"plug": "plugin",
}
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
command = super().get_command(ctx, cmd_name)
if command is not None:
return command
alias_target = self.COMMAND_ALIASES.get(cmd_name)
if alias_target is None:
return None
return super().get_command(ctx, alias_target)
@click.group(cls=AstrBotCLIGroup)
@click.version_option(__version__, prog_name="AstrBot")
def cli() -> None:
"""The AstrBot CLI"""
@@ -52,8 +68,10 @@ def help(command_name: str | None) -> None:
cli.add_command(init)
cli.add_command(run)
cli.add_command(help)
cli.add_command(plug)
cli.add_command(conf)
cli.add_command(plugin)
cli.add_command(config)
cli.add_command(password)
cli.add_command(service)
if __name__ == "__main__":
cli()

View File

@@ -1,6 +1,11 @@
from .cmd_conf import conf
from .cmd_conf import conf as config
from .cmd_init import init
from .cmd_plug import plug
from .cmd_password import password
from .cmd_plug import plug as plugin
from .cmd_run import run
from .cmd_service import service
__all__ = ["conf", "init", "plug", "run"]
conf = config
plug = plugin
__all__ = ["config", "conf", "init", "password", "plugin", "plug", "run", "service"]

View File

@@ -137,7 +137,23 @@ def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
return obj
@click.group(name="conf")
def _set_dashboard_password(config: dict[str, Any], raw_password: str) -> None:
"""Set dashboard password hashes and clear password migration flags."""
_set_nested_item(
config,
"dashboard.pbkdf2_password",
hash_dashboard_password(raw_password),
)
_set_nested_item(
config,
"dashboard.password",
hash_legacy_dashboard_password(raw_password),
)
_set_nested_item(config, "dashboard.password_storage_upgraded", True)
_set_nested_item(config, "dashboard.password_change_required", False)
@click.group(name="config")
def conf() -> None:
"""Configuration management commands
@@ -171,16 +187,7 @@ def set_config(key: str, value: str) -> None:
old_value = _get_nested_item(config, key)
validated_value = CONFIG_VALIDATORS[key](value)
if key == "dashboard.password":
_set_nested_item(
config,
"dashboard.pbkdf2_password",
hash_dashboard_password(validated_value),
)
_set_nested_item(
config,
"dashboard.password",
hash_legacy_dashboard_password(validated_value),
)
_set_dashboard_password(config, validated_value)
else:
_set_nested_item(config, key, validated_value)
_save_config(config)

View File

@@ -0,0 +1,38 @@
import click
from .cmd_conf import (
_load_config,
_save_config,
_set_dashboard_password,
_set_nested_item,
_validate_dashboard_password,
_validate_dashboard_username,
)
@click.command(name="password")
@click.option(
"--username",
help="Optional dashboard username to set together with the new password.",
)
def password(username: str | None) -> None:
"""Change the AstrBot dashboard password."""
config = _load_config()
new_password = click.prompt(
"New dashboard password",
hide_input=True,
confirmation_prompt=True,
)
validated_password = _validate_dashboard_password(new_password)
if username is not None:
validated_username = _validate_dashboard_username(username.strip())
_set_nested_item(config, "dashboard.username", validated_username)
_set_dashboard_password(config, validated_password)
_save_config(config)
click.echo("Dashboard password updated.")
if username is not None:
click.echo(f"Dashboard username updated: {validated_username}")

View File

@@ -14,7 +14,7 @@ from ..utils import (
)
@click.group()
@click.group(name="plugin")
def plug() -> None:
"""Plugin management"""

View File

@@ -9,6 +9,8 @@ from filelock import FileLock, Timeout
from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_DASHBOARD_RESET_PASSWORD"
async def run_astrbot(astrbot_root: Path) -> None:
"""Run AstrBot"""
@@ -28,8 +30,13 @@ async def run_astrbot(astrbot_root: Path) -> None:
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
@click.option(
"--reset-password",
is_flag=True,
help="Force reset the dashboard initial password on startup.",
)
@click.command()
def run(reload: bool, port: str) -> None:
def run(reload: bool, port: str, reset_password: bool) -> None:
"""Run AstrBot"""
try:
os.environ["ASTRBOT_CLI"] = "1"
@@ -50,6 +57,9 @@ def run(reload: bool, port: str) -> None:
click.echo("Plugin auto-reload enabled")
os.environ["ASTRBOT_RELOAD"] = "1"
if reset_password:
os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "1"
lock_file = astrbot_root / "astrbot.lock"
lock = FileLock(lock_file, timeout=5)
with lock.acquire():

File diff suppressed because it is too large Load Diff

View File

@@ -218,11 +218,15 @@ class LLMSummaryCompressor:
# generate summary
try:
response = await self.provider.text_chat(contexts=llm_payload)
summary_content = response.completion_text
summary_content = (response.completion_text or "").strip()
except Exception as e:
logger.error(f"Failed to generate summary: {e}")
return messages
if not summary_content:
logger.warning("LLM context compression returned an empty summary.")
return messages
# build result
result = []
result.extend(system_messages)

View File

@@ -15,6 +15,7 @@ from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP
ASTRBOT_CONFIG_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
DASHBOARD_INITIAL_PASSWORD_ENV = "ASTRBOT_DASHBOARD_INITIAL_PASSWORD"
DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_DASHBOARD_RESET_PASSWORD"
logger = logging.getLogger("astrbot")
@@ -76,21 +77,21 @@ class AstrBotConfig(dict):
)
# 检查配置完整性,并插入
has_new = self.check_config_integrity(default_config, conf)
dashboard_reset_requested = self._is_dashboard_password_reset_requested()
if (
"dashboard" in conf
and isinstance(conf["dashboard"], dict)
and not conf["dashboard"].get("pbkdf2_password")
and not conf["dashboard"].get("password")
):
self._reset_generated_dashboard_password(conf)
has_new = True
elif (
"dashboard" in conf
and isinstance(conf["dashboard"], dict)
and legacy_dashboard_password_change_required
and conf["dashboard"].get("pbkdf2_password")
and (
dashboard_reset_requested
or (
not conf["dashboard"].get("pbkdf2_password")
and not conf["dashboard"].get("password")
)
)
):
self._reset_generated_dashboard_password(conf)
if dashboard_reset_requested:
os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "0"
has_new = True
self.update(conf)
if has_new:
@@ -127,6 +128,15 @@ class AstrBotConfig(dict):
validate_dashboard_password(env_password)
return env_password
@staticmethod
def _is_dashboard_password_reset_requested() -> bool:
return os.environ.get(DASHBOARD_RESET_PASSWORD_ENV, "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
def _config_schema_to_default_config(self, schema: dict) -> dict:
"""将 Schema 转换成 Config"""
conf = {}

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.25.0"
VERSION = "4.25.1"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
@@ -1068,7 +1068,7 @@ CONFIG_METADATA_2 = {
"id_whitelist": {
"type": "list",
"items": {"type": "string"},
"hint": "只处理填写的 ID 发来的消息事件,为空时不启用。可使用 /sid 指令获取在平台上的会话 ID(类似 abc:GroupMessage:123)。管理员可使用 /wl 添加白名单",
"hint": "只处理填写的 ID 发来的消息事件,为空时不启用。可使用 /sid 指令获取在平台上的会话 ID(类似 abc:GroupMessage:123)。管理员可在 WebUI 的平台设置中管理白名单",
},
"id_whitelist_log": {
"type": "bool",

View File

@@ -2,7 +2,7 @@ from .base import BaseParser
async def select_parser(ext: str) -> BaseParser:
if ext in {".md", ".txt", ".markdown", ".xlsx", ".docx", ".xls"}:
if ext in {".md", ".txt", ".markdown", ".rst", ".adoc", ".xlsx", ".docx", ".xls"}:
from .markitdown_parser import MarkitdownParser
return MarkitdownParser()

View File

@@ -368,7 +368,7 @@ class ResultDecorateStage(Stage):
return
if time.time() - render_start > 3:
logger.warning(
"文本转图片耗时超过了 3 秒,如果觉得很慢可以使用 /t2i 关闭文本转图片模式。",
"文本转图片耗时超过了 3 秒,如果觉得很慢可以在 WebUI 中关闭文本转图片模式。",
)
if url:
if url.startswith("http"):

View File

@@ -1138,8 +1138,8 @@ class ProviderOpenAIOfficial(Provider):
or ("function" in str(e).lower() and "support" in str(e).lower())
):
# openai, ollama, gemini openai, siliconcloud 的错误提示与 code 不统一,只能通过字符串匹配
logger.info(
f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。",
logger.warning(
f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。如需永久关闭,可前往 WebUI 中关闭工具调用。",
)
payloads.pop("tools", None)
return (
@@ -1153,9 +1153,6 @@ class ProviderOpenAIOfficial(Provider):
)
# logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}")
if "tool" in str(e).lower() and "support" in str(e).lower():
logger.error("疑似该模型不支持函数调用工具调用。请输入 /tool off_all")
if is_connection_error(e):
proxy = self.provider_config.get("proxy", "")
log_connection_failure("OpenAI", e, proxy)

View File

@@ -3,6 +3,9 @@
提供统一的 webhook 回调入口,支持多个平台使用同一端口接收回调。
"""
import secrets
import string
from quart import request
from astrbot.core import logger
@@ -25,6 +28,10 @@ from astrbot.core.platform.sources.weixin_oc.login_registration import (
from .route import Response, Route, RouteContext
def _random_platform_id_suffix() -> str:
return "_" + "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
class PlatformRoute(Route):
"""统一 Webhook 路由"""
@@ -233,6 +240,8 @@ class PlatformRoute(Route):
if not device_code:
return Response().error("Missing device_code").__dict__, 400
result = await poll_dingtalk_app_registration_once(device_code)
if result.get("status") == "created":
result["platform_id_suffix"] = _random_platform_id_suffix()
return Response().ok(result).__dict__
return Response().error(f"Unsupported action: {action}").__dict__, 400
@@ -269,6 +278,8 @@ class PlatformRoute(Route):
platform_config=platform_config,
qrcode=qrcode,
)
if result.get("status") == "created":
result["platform_id_suffix"] = _random_platform_id_suffix()
return Response().ok(result).__dict__
return Response().error(f"Unsupported action: {action}").__dict__, 400

26
changelogs/v4.25.1.md Normal file
View File

@@ -0,0 +1,26 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 优化
- 个人微信和钉钉扫码创建成功后,平台配置 ID 会自动追加随机 4 位小写字母后缀,例如 `_abcd`,降低多个扫码创建配置之间的 ID 冲突概率。
### 修复
- 修复 WebUI 全局字体栈未完整覆盖西里尔文字场景的问题,改善俄文等多语言文本显示效果。([#8205](https://github.com/AstrBotDevs/AstrBot/pull/8205)
<a id="english"></a>
## What's Changed (EN)
### Improvements
- Personal WeChat and DingTalk QR setup now append a random four-letter lowercase suffix to the generated platform config ID, such as `_abcd`, reducing ID conflicts across multiple scan-created configs.
### Bug Fixes
- Fixed the WebUI global font-family stack so Cyrillic text and other multilingual content render more consistently. ([#8205](https://github.com/AstrBotDevs/AstrBot/pull/8205))

View File

@@ -1,3 +1,4 @@
node_modules/
.DS_Store
dist/
dist/
src/assets/mdi-subset/

View File

@@ -4,12 +4,11 @@
"private": true,
"author": "CodedThemes",
"scripts": {
"dev": "vite --host",
"subset-icons": "node scripts/subset-mdi-font.mjs",
"dev": "node scripts/subset-mdi-font.mjs && vite --host",
"build:t2i-shiki-runtime": "node scripts/build-t2i-shiki-runtime.mjs",
"build": "vue-tsc --noEmit && vite build",
"build-stage": "vue-tsc --noEmit && vite build --base=/vue/free/stage/",
"build-prod": "vue-tsc --noEmit && vite build --base=/vue/free/",
"build": "node scripts/subset-mdi-font.mjs && vue-tsc --noEmit && vite build",
"build-stage": "node scripts/subset-mdi-font.mjs && vue-tsc --noEmit && vite build --base=/vue/free/stage/",
"build-prod": "node scripts/subset-mdi-font.mjs && vue-tsc --noEmit && vite build --base=/vue/free/",
"preview": "vite preview --port 5050",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"

File diff suppressed because it is too large Load Diff

View File

@@ -961,16 +961,48 @@ export default {
this.$emit('show-toast', { message: message, type: 'error' });
},
buildRandomPlatformIdSuffix() {
const letters = 'abcdefghijklmnopqrstuvwxyz';
let suffix = '_';
for (let i = 0; i < 4; i += 1) {
suffix += letters[Math.floor(Math.random() * letters.length)];
}
return suffix;
},
handlePlatformRegistrationCreated(data) {
if (!this.selectedPlatformConfig || !data?.bot_name) {
if (!this.selectedPlatformConfig || !data) {
return;
}
const currentId = String(this.selectedPlatformConfig.id || '').trim();
const safeBotName = String(data.bot_name || '').trim().replace(/[!:]/g, '_');
if (!currentId || !safeBotName) {
const platformType = this.selectedPlatformConfig.type;
if (!currentId) {
return;
}
const suffix = `-${safeBotName}`;
let suffix = '';
const explicitSuffix = String(data.platform_id_suffix || '').trim().replace(/[!:]/g, '_');
if (explicitSuffix) {
suffix = explicitSuffix.startsWith('_') || explicitSuffix.startsWith('-')
? explicitSuffix
: `_${explicitSuffix}`;
} else if (data.bot_name) {
const safeBotName = String(data.bot_name || '').trim().replace(/[!:]/g, '_');
if (safeBotName) {
suffix = `-${safeBotName}`;
}
} else if (platformType === 'weixin_oc' || platformType === 'dingtalk') {
suffix = this.buildRandomPlatformIdSuffix();
}
if (!suffix) {
return;
}
if ((platformType === 'weixin_oc' || platformType === 'dingtalk') && /_[a-z]{4}$/.test(currentId)) {
return;
}
this.selectedPlatformConfig.id = currentId.endsWith(suffix)
? currentId
: `${currentId}${suffix}`;

View File

@@ -49,7 +49,7 @@
"title": "Upload Document",
"selectFile": "Select File",
"dropzone": "Drop files here or click to select",
"supportedFormats": "Supported formats: .txt, .md, .pdf, .docx, .epub, .xls, .xlsx",
"supportedFormats": "Supported formats: .txt, .md, .markdown, .rst, .adoc, .pdf, .docx, .epub, .xls, .xlsx",
"maxSize": "Max file size: 128MB",
"chunkSettings": "Chunk Settings",
"batchSettings": "Batch Settings",

View File

@@ -49,7 +49,7 @@
"title": "Добавление контента",
"selectFile": "Файл",
"dropzone": "Нажмите или перетащите файл сюда",
"supportedFormats": "Форматы: .txt, .md, .pdf, .docx, .epub, .xls, .xlsx",
"supportedFormats": "Форматы: .txt, .md, .markdown, .rst, .adoc, .pdf, .docx, .epub, .xls, .xlsx",
"maxSize": "Максимум: 128MB",
"chunkSettings": "Фрагментация",
"batchSettings": "Пакетная обработка",

View File

@@ -49,7 +49,7 @@
"title": "上传文档",
"selectFile": "选择文件",
"dropzone": "拖放文件到这里或点击选择",
"supportedFormats": "支持的格式: .txt, .md, .pdf, .docx, .epub, .xls, .xlsx",
"supportedFormats": "支持的格式: .txt, .md, .markdown, .rst, .adoc, .pdf, .docx, .epub, .xls, .xlsx",
"maxSize": "最大文件大小: 128MB",
"chunkSettings": "分块设置",
"batchSettings": "批处理设置",

View File

@@ -9,7 +9,7 @@ $color-pack: false;
// Global font size and border radius
$font-size-root: 1rem;
$border-radius-root: 8px;
$cjk-sans-fallback: 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei', 'Noto Sans SC', 'Noto Sans' !default;
$cjk-sans-fallback: 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei', 'Noto Sans SC' !default;
$cjk-mono-fallback: 'PingFang SC', 'PingFang TC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei' !default;
$code-text-color: #111827 !default;
@@ -19,7 +19,7 @@ $code-text-color: #111827 !default;
--astrbot-code-color: #{$code-text-color};
}
$body-font-family: $cjk-sans-fallback, sans-serif !default;
$body-font-family: 'Outfit', 'Noto Sans', $cjk-sans-fallback, sans-serif !default;
$heading-font-family: $body-font-family !default;
$btn-font-weight: 400 !default;
$btn-letter-spacing: 0 !default;

View File

@@ -80,7 +80,7 @@ $sizes: (
body {
.Outfit {
font-family: 'Outfit', $cjk-sans-fallback, sans-serif !important;
font-family: $body-font-family !important;
}
}

View File

@@ -85,7 +85,7 @@
<p class="text-caption text-medium-emphasis mt-2">{{ t('upload.supportedFormats') }}</p>
<p class="text-caption text-medium-emphasis">{{ t('upload.maxSize') }}</p>
<p class="text-caption text-medium-emphasis">最多可上传 10 个文件</p>
<input ref="fileInput" type="file" multiple hidden accept=".txt,.md,.pdf,.docx,.epub,.xls,.xlsx"
<input ref="fileInput" type="file" multiple hidden accept=".txt,.md,.markdown,.rst,.adoc,.pdf,.docx,.epub,.xls,.xlsx"
@change="handleFileSelect" />
</div>
@@ -709,6 +709,7 @@ const getFileIcon = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'mdi-file-pdf-box'
if (type.includes('epub')) return 'mdi-book-open-page-variant'
if (type.includes('rst') || type.includes('adoc')) return 'mdi-file-document-outline'
if (type.includes('md') || type.includes('markdown')) return 'mdi-language-markdown'
if (type.includes('txt')) return 'mdi-file-document-outline'
if (type.includes('url')) return 'mdi-link-variant'
@@ -719,6 +720,7 @@ const getFileColor = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'error'
if (type.includes('epub')) return 'warning'
if (type.includes('rst') || type.includes('adoc')) return 'success'
if (type.includes('md')) return 'info'
if (type.includes('txt')) return 'success'
if (type.includes('url')) return 'primary'

View File

@@ -159,6 +159,7 @@ export default defineConfig({
base: "/use",
items: [
{ text: "WebUI", link: "/webui" },
{ text: "CLI 指令", link: "/cli" },
{ text: "插件", link: "/plugin" },
{ text: "内置指令", link: "/command" },
{ text: "工具使用 Tools", link: "/function-calling" },
@@ -403,6 +404,7 @@ export default defineConfig({
collapsed: true,
items: [
{ text: "WebUI", link: "/webui" },
{ text: "CLI Commands", link: "/cli" },
{ text: "Plugins", link: "/plugin" },
{ text: "Built-in Commands", link: "/command" },
{ text: "Tool Use", link: "/function-calling" },

View File

@@ -20,5 +20,73 @@ AstrBot requires Python 3.12 or later. Use `--python 3.12` to ensure that `uv` c
```bash
uv tool install astrbot --python 3.12
astrbot
astrbot init # Only required for the first deployment
astrbot run
```
## Install as a System Service
After initialization, install AstrBot as a user-level service so it starts with the user session:
```bash
astrbot service install --now
```
The command uses the `astrbot` executable found on `PATH` (usually generated by `uv tool install`) and uses the current directory as the AstrBot working directory. Each platform uses its native user-level service mechanism:
- Linux: `systemd --user`
- macOS: `LaunchAgent`
> [!NOTE]
> `astrbot service` is not supported on Windows. Use `astrbot run` in the foreground or another process manager.
To specify the AstrBot working directory or executable path explicitly:
```bash
astrbot service install --workdir /path/to/astrbot-root --executable /path/to/astrbot --now
```
To inspect the service state and WebUI health:
```bash
astrbot service status
```
The status output includes the service manager state, AstrBot working directory, Dashboard port, WebUI URL, WebUI accessibility, and the overall health state.
You can also manage the service lifecycle with:
```bash
astrbot service start
astrbot service stop
astrbot service restart
astrbot service uninstall
```
To view service logs:
```bash
astrbot service logs
astrbot service logs -f
```
On macOS, this shows stdout logs by default. To include stderr:
```bash
astrbot service logs --include-stderr
```
To read the AstrBot application log file at `data/logs/astrbot.log`, enable application file logging first and restart the service:
```bash
astrbot service logs enable
astrbot service restart
astrbot service logs --source app
```
To inspect or disable application file logging:
```bash
astrbot service logs status
astrbot service logs disable
```

311
docs/en/use/cli.md Normal file
View File

@@ -0,0 +1,311 @@
# CLI Commands
The AstrBot CLI initializes instances, starts AstrBot, installs background services, reads logs, 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 unless the command provides a `--workdir` option.
## 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 service` | Install and manage AstrBot as a background service. |
| `astrbot config` | Read or update common config values. |
| `astrbot password` | Change the WebUI login password interactively. |
| `astrbot plugin` | Create, install, update, remove, or search plugins. |
| `astrbot help` | Show CLI help. |
| `astrbot --version` | Show the AstrBot CLI version. |
`conf` and `plug` are compatibility aliases and still work:
```bash
astrbot conf get
astrbot plug list
```
Prefer `config` and `plugin` in new docs and scripts.
## 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. |
| `--reset-password` | Reset the WebUI initial password on startup and print the new initial password in startup logs. |
Examples:
```bash
astrbot run --port 6185
astrbot run --reload
astrbot run --reset-password
```
## Background Service
`astrbot service` installs AstrBot as a user-level background service for long-running deployments.
Each platform uses its native service manager:
| Platform | Service manager |
| --- | --- |
| Linux | `systemd --user` |
| macOS | LaunchAgent |
> [!NOTE]
> `astrbot service` is not supported on Windows. Use `astrbot run` in the foreground or another process manager.
### Install
```bash
astrbot service install --now
```
By default, this command uses the `astrbot` executable found on `PATH` and the current directory as the AstrBot working directory. `--now` starts or restarts the service after installation.
Common options:
| Option | Purpose |
| --- | --- |
| `--name <NAME>` | Service name. Default: `astrbot`. |
| `--workdir <DIR>` | AstrBot working directory. |
| `--executable <PATH>` | Path to the `astrbot` executable. |
| `--force` | Overwrite an existing service definition. |
| `--now` | Start or restart the service after installation. |
If `astrbot` is not on `PATH`, pass the executable explicitly:
```bash
astrbot service install --workdir /path/to/astrbot-root --executable /path/to/astrbot --now
```
### Manage
```bash
astrbot service start
astrbot service stop
astrbot service restart
astrbot service uninstall
```
These commands support `--name <NAME>` for non-default service names:
```bash
astrbot service restart --name astrbot-test
```
To remove a service without an interactive confirmation:
```bash
astrbot service uninstall --force
```
### Status
```bash
astrbot service status
```
The status output includes:
- Overall health.
- Current platform and service manager.
- Whether the service is installed, enabled, and running.
- AstrBot working directory.
- Dashboard port.
- WebUI URL and accessibility.
Common options:
| Option | Purpose |
| --- | --- |
| `--name <NAME>` | Service name. Default: `astrbot`. |
| `--workdir <DIR>` | AstrBot working directory used to read the port config. |
| `--timeout <SECONDS>` | WebUI health probe timeout. Default: 2 seconds. |
Example:
```bash
astrbot service status --timeout 5
```
## Logs
The CLI exposes two kinds of logs:
| Type | Command | Notes |
| --- | --- | --- |
| Service logs | `astrbot service logs` | Reads console output captured by the service manager. |
| Application log file | `astrbot service logs --source app` | Reads `data/logs/astrbot.log`; file logging must be enabled first. |
### Service Logs
```bash
astrbot service logs
astrbot service logs -n 100
astrbot service logs -f
```
Common options:
| Option | Purpose |
| --- | --- |
| `--name <NAME>` | Service name. |
| `-n, --lines <N>` | Show the latest N lines. Default: 200. |
| `-f, --follow` | Follow log output. |
| `--include-stderr` | Also show stderr logs on macOS. |
On macOS, `astrbot service logs` shows stdout logs by default, which are the `.out.log` files. Add `--include-stderr` when you also need error output.
### Application Log File
`data/logs/astrbot.log` is not written by default. Enable application file logging first, then restart AstrBot:
```bash
astrbot service logs enable
astrbot service restart
astrbot service logs --source app
```
Inspect the application log file configuration:
```bash
astrbot service logs status
```
Disable the application log file:
```bash
astrbot service logs disable
astrbot service restart
```
Use a custom application log path:
```bash
astrbot service logs enable --path logs/astrbot.log
```
Relative paths are resolved from the AstrBot data directory.
## Config
`astrbot config` reads and updates common config values.
```bash
astrbot config get
astrbot config get dashboard.port
astrbot config 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 config set dashboard.password "new-password"
```
You can also use the dedicated interactive password command:
```bash
astrbot password
astrbot password --username admin
```
## Plugins
`astrbot plugin` manages plugins under `data/plugins`.
| Command | Purpose |
| --- | --- |
| `astrbot plugin list` | List installed plugins. |
| `astrbot plugin list --all` | Also show uninstalled plugins. |
| `astrbot plugin search <QUERY>` | Search plugins. |
| `astrbot plugin install <NAME>` | Install a plugin. |
| `astrbot plugin update [NAME]` | Update one plugin, or all updatable plugins if no name is given. |
| `astrbot plugin remove <NAME>` | Remove an installed plugin. |
| `astrbot plugin new <NAME>` | Create a new plugin from the template. |
Use a GitHub proxy when installing or updating plugins:
```bash
astrbot plugin install example-plugin --proxy https://gh-proxy.example.com/
astrbot plugin update --proxy https://gh-proxy.example.com/
```
Creating a new plugin asks for the author, description, version, and repository URL:
```bash
astrbot plugin new my-plugin
```
## Help
Show general CLI help:
```bash
astrbot help
```
Show help for a specific command:
```bash
astrbot help service
astrbot service --help
astrbot service logs --help
```
Show the version:
```bash
astrbot --version
```

View File

@@ -22,3 +22,70 @@ uv tool install astrbot --python 3.12
astrbot init # 只需要在第一次部署时执行,后续启动不需要执行
astrbot run
```
## 安装为系统服务
初始化完成后,可以安装用户级服务,让 AstrBot 随用户会话自动启动:
```bash
astrbot service install --now
```
该命令会自动使用当前 `PATH` 中的 `astrbot` 可执行文件(通常由 `uv tool install` 生成),并将当前目录作为 AstrBot 工作目录。不同系统会使用对应的用户级服务机制:
- Linux`systemd --user`
- macOS`LaunchAgent`
> [!NOTE]
> Windows 暂不支持 `astrbot service`。请使用 `astrbot run` 前台启动,或使用其他进程管理工具。
如果需要指定 AstrBot 工作目录或可执行文件路径,可以使用:
```bash
astrbot service install --workdir /path/to/astrbot-root --executable /path/to/astrbot --now
```
查看服务状态和 WebUI 健康状态:
```bash
astrbot service status
```
状态输出会包含服务管理器状态、AstrBot 工作目录、Dashboard 端口、WebUI URL、WebUI 是否可访问,以及整体健康状态。
也可以使用以下命令管理服务生命周期:
```bash
astrbot service start
astrbot service stop
astrbot service restart
astrbot service uninstall
```
查看服务日志:
```bash
astrbot service logs
astrbot service logs -f
```
macOS 下默认只显示标准输出日志;如需同时查看 stderr
```bash
astrbot service logs --include-stderr
```
如果需要查看 AstrBot 应用日志文件 `data/logs/astrbot.log`,先启用应用日志文件并重启服务:
```bash
astrbot service logs enable
astrbot service restart
astrbot service logs --source app
```
查看或关闭应用日志文件:
```bash
astrbot service logs status
astrbot service logs disable
```

View File

@@ -51,7 +51,7 @@ Set dashboard.host in data/cmd_config.json to enable remote access.
},
```
删除 `username`, `password`, `pbkdf2_password`, `password_storage_upgraded`, `password_change_required`, `jwt_secret` 个字段(连同值一起),然后保存。上述片段修改类似如下:
删除 `username`, `password`, `pbkdf2_password`, `password_storage_upgraded`, `password_change_required`, `jwt_secret` 个字段(连同值一起),然后保存。上述片段修改类似如下:
```json

311
docs/zh/use/cli.md Normal file
View File

@@ -0,0 +1,311 @@
# 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 工作目录中执行,除非命令提供了 `--workdir` 选项。
## 快速开始
第一次部署时先初始化目录,再启动 AstrBot
```bash
astrbot init
astrbot run
```
`astrbot init` 会在当前目录创建 AstrBot 所需的数据目录和配置文件。初始化完成后,后续启动只需要执行 `astrbot run`。
## 顶层指令
| 指令 | 用途 |
| --- | --- |
| `astrbot init` | 初始化当前目录为 AstrBot 工作目录。 |
| `astrbot run` | 在前台启动 AstrBot。 |
| `astrbot service` | 安装和管理 AstrBot 后台服务。 |
| `astrbot config` | 查看或修改常用配置项。 |
| `astrbot password` | 交互式修改 WebUI 登录密码。 |
| `astrbot plugin` | 创建、安装、更新、删除或搜索插件。 |
| `astrbot help` | 查看 CLI 帮助。 |
| `astrbot --version` | 查看 AstrBot CLI 版本。 |
`conf` 和 `plug` 是兼容别名,仍然可用:
```bash
astrbot conf get
astrbot plug list
```
推荐在新文档和脚本中使用 `config` 和 `plugin`。
## 启动 AstrBot
```bash
astrbot run
```
常用选项:
| 选项 | 用途 |
| --- | --- |
| `-p, --port <PORT>` | 指定 WebUI 端口。 |
| `-r, --reload` | 启用插件自动重载,适合插件开发调试。 |
| `--reset-password` | 启动时重置 WebUI 初始密码,并在启动日志中打印新的初始密码。 |
示例:
```bash
astrbot run --port 6185
astrbot run --reload
astrbot run --reset-password
```
## 后台服务
`astrbot service` 可以把 AstrBot 安装为用户级后台服务,适合长期运行。
不同系统会使用对应的服务管理机制:
| 系统 | 服务管理器 |
| --- | --- |
| Linux | `systemd --user` |
| macOS | LaunchAgent |
> [!NOTE]
> Windows 暂不支持 `astrbot service`。请使用 `astrbot run` 前台启动,或使用其他进程管理工具。
### 安装服务
```bash
astrbot service install --now
```
该命令默认使用当前 `PATH` 中的 `astrbot` 可执行文件,并把当前目录作为 AstrBot 工作目录。`--now` 表示安装后立即启动或重启服务。
常用选项:
| 选项 | 用途 |
| --- | --- |
| `--name <NAME>` | 指定服务名,默认 `astrbot`。 |
| `--workdir <DIR>` | 指定 AstrBot 工作目录。 |
| `--executable <PATH>` | 指定 `astrbot` 可执行文件路径。 |
| `--force` | 覆盖已有服务定义。 |
| `--now` | 安装后立即启动或重启服务。 |
如果 `astrbot` 不在 `PATH` 中,可以显式指定可执行文件:
```bash
astrbot service install --workdir /path/to/astrbot-root --executable /path/to/astrbot --now
```
### 管理服务
```bash
astrbot service start
astrbot service stop
astrbot service restart
astrbot service uninstall
```
这些命令都支持 `--name <NAME>`,用于管理非默认服务名:
```bash
astrbot service restart --name astrbot-test
```
卸载服务时,如果不希望交互确认,可以使用:
```bash
astrbot service uninstall --force
```
### 查看服务状态
```bash
astrbot service status
```
状态输出会包含:
- 整体健康状态。
- 当前系统和服务管理器。
- 服务是否已安装、是否启用、当前运行状态。
- AstrBot 工作目录。
- Dashboard 端口。
- WebUI URL 和是否可访问。
常用选项:
| 选项 | 用途 |
| --- | --- |
| `--name <NAME>` | 指定服务名,默认 `astrbot`。 |
| `--workdir <DIR>` | 指定 AstrBot 工作目录,用于读取端口配置。 |
| `--timeout <SECONDS>` | 指定 WebUI 健康检查超时时间,默认 2 秒。 |
示例:
```bash
astrbot service status --timeout 5
```
## 日志
AstrBot CLI 中有两类日志:
| 类型 | 命令 | 说明 |
| --- | --- | --- |
| 服务日志 | `astrbot service logs` | 查看服务管理器捕获的控制台输出。 |
| 应用日志文件 | `astrbot service logs --source app` | 查看 `data/logs/astrbot.log`,需要先启用文件日志。 |
### 查看服务日志
```bash
astrbot service logs
astrbot service logs -n 100
astrbot service logs -f
```
常用选项:
| 选项 | 用途 |
| --- | --- |
| `--name <NAME>` | 指定服务名。 |
| `-n, --lines <N>` | 显示最近 N 行,默认 200。 |
| `-f, --follow` | 持续跟随日志输出。 |
| `--include-stderr` | 在 macOS 上同时显示 stderr 日志。 |
macOS 下,`astrbot service logs` 默认只显示标准输出日志,也就是 `.out.log`。如果需要同时查看错误输出,再加 `--include-stderr`。
### 启用应用日志文件
`data/logs/astrbot.log` 默认不会写入。需要先启用应用日志文件,然后重启 AstrBot
```bash
astrbot service logs enable
astrbot service restart
astrbot service logs --source app
```
查看应用日志文件配置:
```bash
astrbot service logs status
```
关闭应用日志文件:
```bash
astrbot service logs disable
astrbot service restart
```
自定义应用日志文件路径:
```bash
astrbot service logs enable --path logs/astrbot.log
```
相对路径会以 AstrBot 数据目录为基准解析。
## 配置
`astrbot config` 用于查看和修改常用配置项。
```bash
astrbot config get
astrbot config get dashboard.port
astrbot config 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 config set dashboard.password "new-password"
```
也可以使用专门的交互式密码指令:
```bash
astrbot password
astrbot password --username admin
```
## 插件
`astrbot plugin` 用于管理 `data/plugins` 下的插件。
| 指令 | 用途 |
| --- | --- |
| `astrbot plugin list` | 查看已安装插件。 |
| `astrbot plugin list --all` | 同时显示未安装插件。 |
| `astrbot plugin search <QUERY>` | 搜索插件。 |
| `astrbot plugin install <NAME>` | 安装插件。 |
| `astrbot plugin update [NAME]` | 更新指定插件;不传名称时更新所有可更新插件。 |
| `astrbot plugin remove <NAME>` | 删除已安装插件。 |
| `astrbot plugin new <NAME>` | 基于模板创建新插件。 |
安装或更新插件时可以使用 GitHub 代理:
```bash
astrbot plugin install example-plugin --proxy https://gh-proxy.example.com/
astrbot plugin update --proxy https://gh-proxy.example.com/
```
创建新插件会交互式询问作者、描述、版本和仓库地址:
```bash
astrbot plugin new my-plugin
```
## 帮助
查看全部 CLI 帮助:
```bash
astrbot help
```
查看指定指令帮助:
```bash
astrbot help service
astrbot service --help
astrbot service logs --help
```
查看版本:
```bash
astrbot --version
```

15
main.py
View File

@@ -9,6 +9,16 @@ import runtime_bootstrap
runtime_bootstrap.initialize_runtime_bootstrap()
DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_DASHBOARD_RESET_PASSWORD"
def _prime_startup_flags(argv: list[str]) -> None:
if "--reset-password" in argv:
os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "1"
_prime_startup_flags(sys.argv[1:])
from astrbot.core import LogBroker, LogManager, db_helper, logger # noqa: E402
from astrbot.core.config.default import VERSION # noqa: E402
from astrbot.core.initial_loader import InitialLoader # noqa: E402
@@ -140,6 +150,11 @@ if __name__ == "__main__":
help="Specify the directory path for WebUI static files",
default=None,
)
parser.add_argument(
"--reset-password",
action="store_true",
help="Force reset the dashboard initial password on startup.",
)
args = parser.parse_args()
check_env()

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.25.0"
version = "4.25.1"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }

View File

@@ -94,6 +94,25 @@ class TestContextManager:
assert isinstance(manager.compressor, TruncateByTurnsCompressor)
@pytest.mark.asyncio
async def test_llm_compressor_keeps_history_when_summary_is_empty(self):
from astrbot.core.agent.context.compressor import LLMSummaryCompressor
provider = MockProvider()
provider.text_chat = AsyncMock(
return_value=LLMResponse(role="assistant", completion_text=" ")
)
compressor = LLMSummaryCompressor(provider=provider, keep_recent=2) # type: ignore[arg-type]
messages = self.create_messages(6)
with patch("astrbot.core.agent.context.compressor.logger") as mock_logger:
result = await compressor(messages)
assert result == messages
mock_logger.warning.assert_called_once_with(
"LLM context compression returned an empty summary."
)
# ==================== Empty and Edge Cases ====================
@pytest.mark.asyncio

View File

@@ -0,0 +1,25 @@
from click.testing import CliRunner
from astrbot.cli.__main__ import cli
def test_top_level_help_uses_product_command_names():
result = CliRunner().invoke(cli, ["help"])
assert result.exit_code == 0
assert "config" in result.output
assert "plugin" in result.output
assert " conf " not in result.output
assert " plug " not in result.output
def test_legacy_config_and_plugin_aliases_still_work():
runner = CliRunner()
config_result = runner.invoke(cli, ["help", "conf"])
plugin_result = runner.invoke(cli, ["help", "plug"])
assert config_result.exit_code == 0
assert "Configuration management commands" in config_result.output
assert plugin_result.exit_code == 0
assert "Plugin management" in plugin_result.output

View File

@@ -0,0 +1,90 @@
import copy
import json
from click.testing import CliRunner
from astrbot.cli.commands.cmd_conf import conf
from astrbot.cli.commands.cmd_password import password
from astrbot.core.config.default import DEFAULT_CONFIG
from astrbot.core.utils.auth_password import verify_dashboard_password
def _write_config(root):
(root / ".astrbot").touch()
data_dir = root / "data"
data_dir.mkdir()
config = copy.deepcopy(DEFAULT_CONFIG)
config["dashboard"]["password_change_required"] = True
config["dashboard"]["password_storage_upgraded"] = False
config_path = data_dir / "cmd_config.json"
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
encoding="utf-8-sig",
)
return config_path
def _read_config(config_path):
return json.loads(config_path.read_text(encoding="utf-8-sig"))
def test_password_command_changes_dashboard_password(monkeypatch, tmp_path):
config_path = _write_config(tmp_path)
monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(
password,
input="AstrbotChanged123\nAstrbotChanged123\n",
)
assert result.exit_code == 0
config = _read_config(config_path)
dashboard_config = config["dashboard"]
assert verify_dashboard_password(
dashboard_config["pbkdf2_password"],
"AstrbotChanged123",
)
assert verify_dashboard_password(
dashboard_config["password"],
"AstrbotChanged123",
)
assert dashboard_config["password_storage_upgraded"] is True
assert dashboard_config["password_change_required"] is False
def test_password_command_can_update_dashboard_username(monkeypatch, tmp_path):
config_path = _write_config(tmp_path)
monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(
password,
["--username", "astrbot-admin"],
input="AstrbotChanged123\nAstrbotChanged123\n",
)
assert result.exit_code == 0
config = _read_config(config_path)
assert config["dashboard"]["username"] == "astrbot-admin"
def test_conf_set_dashboard_password_updates_password_state(monkeypatch, tmp_path):
config_path = _write_config(tmp_path)
monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(
conf,
["set", "dashboard.password", "AstrbotChanged123"],
)
assert result.exit_code == 0
config = _read_config(config_path)
dashboard_config = config["dashboard"]
assert verify_dashboard_password(
dashboard_config["pbkdf2_password"],
"AstrbotChanged123",
)
assert dashboard_config["password_storage_upgraded"] is True
assert dashboard_config["password_change_required"] is False

22
tests/test_cli_run.py Normal file
View File

@@ -0,0 +1,22 @@
import os
from click.testing import CliRunner
from astrbot.cli.commands import cmd_run
def test_run_reset_password_sets_startup_env(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv(cmd_run.DASHBOARD_RESET_PASSWORD_ENV, raising=False)
(tmp_path / ".astrbot").touch()
observed_reset_flags = []
async def fake_run_astrbot(_astrbot_root):
observed_reset_flags.append(os.environ.get(cmd_run.DASHBOARD_RESET_PASSWORD_ENV))
monkeypatch.setattr(cmd_run, "run_astrbot", fake_run_astrbot)
result = CliRunner().invoke(cmd_run.run, ["--reset-password"])
assert result.exit_code == 0
assert observed_reset_flags == ["1"]

313
tests/test_cli_service.py Normal file
View File

@@ -0,0 +1,313 @@
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from threading import Thread
from click.testing import CliRunner
from astrbot.cli.__main__ import cli
from astrbot.cli.commands import cmd_service
from astrbot.cli.commands.cmd_service import (
ServiceState,
WebUIStatus,
_build_launchd_plist,
_build_systemd_unit,
_check_webui,
_get_app_log_config,
_health_label,
_load_dashboard_port,
_load_or_init_config,
service,
)
class _HealthyHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, *_args):
return
def test_service_command_is_registered():
result = CliRunner().invoke(cli, ["help", "service"])
assert result.exit_code == 0
assert "install" in result.output
assert "logs" in result.output
assert "restart" in result.output
assert "status" in result.output
assert "start" in result.output
assert "stop" in result.output
assert "uninstall" in result.output
def test_service_logs_group_exposes_log_file_controls():
result = CliRunner().invoke(service, ["logs", "--help"])
assert result.exit_code == 0
assert "enable" in result.output
assert "disable" in result.output
assert "status" in result.output
def test_service_install_requires_initialized_root(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
result = CliRunner().invoke(service, ["install", "--executable", "astrbot"])
assert result.exit_code == 1
assert "Use 'astrbot init' before installing the service" in result.output
def test_service_install_rejects_windows(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(cmd_service.platform, "system", lambda: "Windows")
result = CliRunner().invoke(service, ["install", "--executable", "astrbot"])
assert result.exit_code == 1
assert "Unsupported platform: Windows" in result.output
def test_systemd_unit_uses_astrbot_executable_and_working_directory():
unit = _build_systemd_unit(
"astrbot",
Path("/home/astrbot/.local/bin/astrbot"),
Path("/home/astrbot/AstrBot Root"),
)
assert 'WorkingDirectory="/home/astrbot/AstrBot Root"' in unit
assert "ExecStart=/home/astrbot/.local/bin/astrbot run" in unit
assert "Environment=PYTHONUNBUFFERED=1" in unit
def test_launchd_plist_uses_astrbot_executable_and_working_directory():
plist = _build_launchd_plist(
"astrbot",
Path("/Users/astrbot/.local/bin/astrbot"),
Path("/Users/astrbot/AstrBot"),
Path("/Users/astrbot/Library/Logs/AstrBot"),
)
assert plist["Label"] == "app.astrbot.astrbot"
assert plist["ProgramArguments"] == ["/Users/astrbot/.local/bin/astrbot", "run"]
assert plist["WorkingDirectory"] == "/Users/astrbot/AstrBot"
assert plist["EnvironmentVariables"] == {"PYTHONUNBUFFERED": "1"}
def test_launch_agent_start_waits_until_loaded_before_kickstart(monkeypatch, tmp_path):
plist_path = tmp_path / "app.astrbot.astrbot.plist"
plist_path.touch()
events = []
loaded_states = [False, False, True]
monkeypatch.setattr(cmd_service.shutil, "which", lambda name: "/bin/launchctl")
monkeypatch.setattr(cmd_service, "_launch_agent_path", lambda _name: plist_path)
monkeypatch.setattr(cmd_service.time, "sleep", lambda _seconds: None)
def fake_run_capture(command):
if command[1] == "print":
events.append("print")
loaded = loaded_states.pop(0) if loaded_states else True
return cmd_service.subprocess.CompletedProcess(
command,
0 if loaded else 113,
stdout="",
stderr="not loaded",
)
if command[1] == "kickstart":
events.append("kickstart")
return cmd_service.subprocess.CompletedProcess(command, 0)
raise AssertionError(f"Unexpected capture command: {command}")
def fake_run_checked(command, _failure_message):
events.append(command[1])
monkeypatch.setattr(cmd_service, "_run_capture", fake_run_capture)
monkeypatch.setattr(cmd_service, "_run_checked", fake_run_checked)
cmd_service._start_launch_agent("astrbot")
assert "bootstrap" in events
assert "enable" in events
assert "kickstart" in events
assert events.index("bootstrap") < events.index("kickstart")
def test_load_dashboard_port_reads_cmd_config(tmp_path):
config_path = tmp_path / "data" / "cmd_config.json"
config_path.parent.mkdir()
config_path.write_text(
json.dumps({"dashboard": {"port": 7788}}),
encoding="utf-8-sig",
)
dashboard_port = _load_dashboard_port(tmp_path)
assert dashboard_port.port == 7788
assert dashboard_port.detail is None
def test_check_webui_reports_accessible_http_response():
server = ThreadingHTTPServer(("127.0.0.1", 0), _HealthyHandler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
webui_status = _check_webui(server.server_port, timeout=1.0)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=1)
assert webui_status.accessible is True
assert webui_status.status_code == 200
def test_health_label_requires_service_and_webui():
active = ServiceState(manager="systemd --user", installed=True, state="active")
inactive = ServiceState(manager="systemd --user", installed=True, state="inactive")
reachable = WebUIStatus(url="http://127.0.0.1:6185/", accessible=True)
unreachable = WebUIStatus(url="http://127.0.0.1:6185/", accessible=False)
assert _health_label(active, reachable) == "healthy"
assert _health_label(active, unreachable) == "degraded"
assert _health_label(inactive, reachable) == "degraded"
assert _health_label(inactive, unreachable) == "unhealthy"
def test_service_status_reports_port_and_webui_health(monkeypatch, tmp_path):
(tmp_path / ".astrbot").touch()
config_path = tmp_path / "data" / "cmd_config.json"
config_path.parent.mkdir()
config_path.write_text(
json.dumps({"dashboard": {"port": 7788}}),
encoding="utf-8-sig",
)
monkeypatch.setattr(
cmd_service,
"_get_service_state",
lambda _name: ServiceState(
manager="systemd --user",
installed=True,
state="active",
),
)
monkeypatch.setattr(
cmd_service,
"_check_webui",
lambda port, _timeout: WebUIStatus(
url=f"http://127.0.0.1:{port}/",
accessible=True,
status_code=200,
detail="HTTP 200",
),
)
result = CliRunner().invoke(service, ["status", "--workdir", str(tmp_path)])
assert result.exit_code == 0
assert "Health: healthy" in result.output
assert "Dashboard port: 7788" in result.output
assert "WebUI accessible: yes" in result.output
assert "WebUI HTTP status: 200" in result.output
def test_service_start_dispatches_to_platform_control(monkeypatch):
calls = []
monkeypatch.setattr(
cmd_service,
"_control_service",
lambda name, action: calls.append((name, action)),
)
result = CliRunner().invoke(service, ["start", "--name", "astrbot-test"])
assert result.exit_code == 0
assert calls == [("astrbot-test", "start")]
assert "Started service: astrbot-test" in result.output
def test_service_uninstall_requires_confirmation(monkeypatch):
calls = []
monkeypatch.setattr(
cmd_service,
"_uninstall_service",
lambda name: calls.append(name) or name,
)
result = CliRunner().invoke(service, ["uninstall"], input="n\n")
assert result.exit_code == 1
assert calls == []
def test_service_logs_source_app_reads_application_log(monkeypatch, tmp_path):
(tmp_path / ".astrbot").touch()
log_path = tmp_path / "data" / "logs" / "astrbot.log"
log_path.parent.mkdir(parents=True)
log_path.write_text("first\nsecond\nthird\n", encoding="utf-8")
result = CliRunner().invoke(
service,
["logs", "--source", "app", "--workdir", str(tmp_path), "--lines", "2"],
)
assert result.exit_code == 0
assert "first" not in result.output
assert "second" in result.output
assert "third" in result.output
def test_service_logs_hides_stderr_by_default(monkeypatch, tmp_path):
out_log = tmp_path / "astrbot.out.log"
err_log = tmp_path / "astrbot.err.log"
out_log.write_text("normal output\n", encoding="utf-8")
err_log.write_text("stderr output\n", encoding="utf-8")
monkeypatch.setattr(cmd_service.platform, "system", lambda: "Darwin")
monkeypatch.setattr(
cmd_service,
"_service_log_paths",
lambda _name: (out_log, err_log),
)
default_result = CliRunner().invoke(service, ["logs", "--lines", "10"])
stderr_result = CliRunner().invoke(
service,
["logs", "--lines", "10", "--include-stderr"],
)
assert default_result.exit_code == 0
assert "normal output" in default_result.output
assert "stderr output" not in default_result.output
assert stderr_result.exit_code == 0
assert "normal output" in stderr_result.output
assert "stderr output" in stderr_result.output
def test_service_app_log_enable_updates_config(tmp_path):
(tmp_path / ".astrbot").touch()
result = CliRunner().invoke(
service,
[
"logs",
"enable",
"--workdir",
str(tmp_path),
"--path",
"logs/custom.log",
],
)
assert result.exit_code == 0
config = _load_or_init_config(tmp_path)
log_config = _get_app_log_config(tmp_path, config)
assert log_config.enabled is True
assert log_config.configured_path == "logs/custom.log"
assert log_config.path == tmp_path / "data" / "logs" / "custom.log"

View File

@@ -6,6 +6,7 @@ import zipfile
import pytest
from astrbot.core.knowledge_base.parsers.epub_parser import EpubParser
from astrbot.core.knowledge_base.parsers.markitdown_parser import MarkitdownParser
from astrbot.core.knowledge_base.parsers.util import select_parser
@@ -174,6 +175,14 @@ async def test_select_parser_supports_epub():
assert isinstance(parser, EpubParser)
@pytest.mark.asyncio
@pytest.mark.parametrize("ext", [".rst", ".adoc"])
async def test_select_parser_supports_text_markup_formats(ext):
parser = await select_parser(ext)
assert isinstance(parser, MarkitdownParser)
@pytest.mark.asyncio
async def test_epub_parser_reads_spine_order_as_text():
result = await EpubParser().parse(_make_epub_bytes(), "book.epub")

View File

@@ -10,6 +10,8 @@ from astrbot.core.config.default import DEFAULT_VALUE_MAP
from astrbot.core.config.i18n_utils import ConfigMetadataI18n
from astrbot.core.utils.auth_password import (
DEFAULT_DASHBOARD_PASSWORD,
hash_dashboard_password,
hash_legacy_dashboard_password,
validate_dashboard_password,
verify_dashboard_password,
)
@@ -276,15 +278,20 @@ class TestAstrBotConfigLoad:
default_config=default_config,
)
def test_legacy_password_change_required_rotates_and_keeps_config_flag(
def test_password_change_required_keeps_existing_password(
self, temp_config_path
):
"""Test that the setup flag stays in dashboard config."""
"""Test that the setup flag no longer rotates the initial password."""
existing_password = "ExistingPass123"
existing_pbkdf2_password = hash_dashboard_password(existing_password)
existing_legacy_password = hash_legacy_dashboard_password(existing_password)
default_config = {
"dashboard": {
"username": "astrbot",
"password": "",
"pbkdf2_password": "",
"password_storage_upgraded": False,
"password_change_required": False,
},
}
with open(temp_config_path, "w", encoding="utf-8") as f:
@@ -292,8 +299,9 @@ class TestAstrBotConfigLoad:
{
"dashboard": {
"username": "astrbot",
"password": "",
"pbkdf2_password": "pbkdf2_sha256$600000$00$00",
"password": existing_legacy_password,
"pbkdf2_password": existing_pbkdf2_password,
"password_storage_upgraded": True,
"password_change_required": True,
}
},
@@ -306,7 +314,7 @@ class TestAstrBotConfigLoad:
)
generated_password = getattr(config, "_generated_dashboard_password", None)
assert isinstance(generated_password, str)
assert generated_password is None
assert config["dashboard"]["password_change_required"] is True
assert config["dashboard"]["password_storage_upgraded"] is True
assert (
@@ -314,12 +322,60 @@ class TestAstrBotConfigLoad:
is True
)
assert verify_dashboard_password(
config["dashboard"]["pbkdf2_password"], generated_password
config["dashboard"]["pbkdf2_password"], existing_password
)
assert verify_dashboard_password(
config["dashboard"]["password"], generated_password
config["dashboard"]["password"], existing_password
)
def test_reset_password_env_rotates_existing_password(
self, temp_config_path, monkeypatch
):
"""Test that explicit reset rotates dashboard password on startup."""
existing_password = "ExistingPass123"
reset_password = "ResetPass123"
monkeypatch.setenv("ASTRBOT_DASHBOARD_RESET_PASSWORD", "1")
monkeypatch.setenv("ASTRBOT_DASHBOARD_INITIAL_PASSWORD", reset_password)
default_config = {
"dashboard": {
"username": "astrbot",
"password": "",
"pbkdf2_password": "",
"password_storage_upgraded": False,
"password_change_required": False,
},
}
with open(temp_config_path, "w", encoding="utf-8") as f:
json.dump(
{
"dashboard": {
"username": "astrbot",
"password": hash_legacy_dashboard_password(existing_password),
"pbkdf2_password": hash_dashboard_password(existing_password),
"password_storage_upgraded": True,
"password_change_required": False,
}
},
f,
)
config = AstrBotConfig(
config_path=temp_config_path,
default_config=default_config,
)
assert getattr(config, "_generated_dashboard_password", None) == reset_password
assert verify_dashboard_password(
config["dashboard"]["pbkdf2_password"], reset_password
)
assert verify_dashboard_password(config["dashboard"]["password"], reset_password)
assert not verify_dashboard_password(
config["dashboard"]["pbkdf2_password"], existing_password
)
assert config["dashboard"]["password_change_required"] is True
assert config["dashboard"]["password_storage_upgraded"] is True
assert os.environ["ASTRBOT_DASHBOARD_RESET_PASSWORD"] == "0"
def test_legacy_astrbot_user_without_change_flag_keeps_legacy_password(
self, temp_config_path
):