mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-04 03:30:15 +08:00
Compare commits
6 Commits
dev
...
feat/servi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c956894025 | ||
|
|
85e2560bf3 | ||
|
|
e2c55fa740 | ||
|
|
c2bbec7683 | ||
|
|
aa7bd5e5ad | ||
|
|
b1e1f5e6e4 |
@@ -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():
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import base64
|
||||
import copy
|
||||
import getpass
|
||||
import json
|
||||
@@ -8,7 +7,6 @@ import plistlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
@@ -16,7 +14,6 @@ from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import click
|
||||
|
||||
@@ -27,7 +24,6 @@ DEFAULT_DASHBOARD_PORT = 6185
|
||||
DEFAULT_STATUS_TIMEOUT_SECONDS = 2.0
|
||||
DEFAULT_LOG_LINES = 200
|
||||
MACOS_LABEL_PREFIX = "app.astrbot"
|
||||
WINDOWS_TASK_XML_NS = "http://schemas.microsoft.com/windows/2004/02/mit/task"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -244,8 +240,6 @@ def _service_log_paths(service_name: str) -> tuple[Path, Path]:
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
log_dir = _macos_log_dir()
|
||||
elif system == "Windows":
|
||||
return _windows_service_log_paths(service_name)
|
||||
else:
|
||||
log_dir = get_astrbot_root() / "data" / "logs"
|
||||
return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log"
|
||||
@@ -309,188 +303,6 @@ def _install_launch_agent(
|
||||
return plist_path
|
||||
|
||||
|
||||
def _task_element(
|
||||
parent: ElementTree.Element,
|
||||
name: str,
|
||||
text: str | None = None,
|
||||
attrib: dict[str, str] | None = None,
|
||||
) -> ElementTree.Element:
|
||||
child = ElementTree.SubElement(
|
||||
parent, f"{{{WINDOWS_TASK_XML_NS}}}{name}", attrib or {}
|
||||
)
|
||||
if text is not None:
|
||||
child.text = text
|
||||
return child
|
||||
|
||||
|
||||
def _windows_log_dir() -> Path:
|
||||
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||
if local_app_data:
|
||||
return Path(local_app_data) / "AstrBot" / "Logs"
|
||||
return Path.home() / "AppData" / "Local" / "AstrBot" / "Logs"
|
||||
|
||||
|
||||
def _windows_service_log_paths(service_name: str) -> tuple[Path, Path]:
|
||||
log_dir = _windows_log_dir()
|
||||
return log_dir / f"{service_name}.out.log", log_dir / f"{service_name}.err.log"
|
||||
|
||||
|
||||
def _windows_powershell_executable() -> str:
|
||||
return "powershell.exe"
|
||||
|
||||
|
||||
def _quote_windows_cmd_arg(value: Path | str) -> str:
|
||||
escaped = str(value).replace('"', '""')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def _quote_powershell_literal(value: Path | str) -> str:
|
||||
escaped = str(value).replace("'", "''")
|
||||
return f"'{escaped}'"
|
||||
|
||||
|
||||
def _build_windows_cmd_line(service_name: str, executable: Path) -> str:
|
||||
out_log, err_log = _windows_service_log_paths(service_name)
|
||||
return (
|
||||
f"{_quote_windows_cmd_arg(executable)} run "
|
||||
f">> {_quote_windows_cmd_arg(out_log)} "
|
||||
f"2>> {_quote_windows_cmd_arg(err_log)}"
|
||||
)
|
||||
|
||||
|
||||
def _build_windows_powershell_arguments(
|
||||
service_name: str,
|
||||
executable: Path,
|
||||
workdir: Path,
|
||||
) -> str:
|
||||
script = (
|
||||
"$ErrorActionPreference = 'Stop'\n"
|
||||
"$env:PYTHONUNBUFFERED = '1'\n"
|
||||
"$cmdExe = if ($env:COMSPEC) { $env:COMSPEC } else { 'cmd.exe' }\n"
|
||||
f"$astrbotCommand = {_quote_powershell_literal(_build_windows_cmd_line(service_name, executable))}\n"
|
||||
"$process = Start-Process "
|
||||
"-FilePath $cmdExe "
|
||||
"-ArgumentList @('/d', '/c', $astrbotCommand) "
|
||||
f"-WorkingDirectory {_quote_powershell_literal(workdir)} "
|
||||
"-WindowStyle Hidden "
|
||||
"-PassThru "
|
||||
"-Wait\n"
|
||||
"exit $process.ExitCode\n"
|
||||
)
|
||||
encoded_script = base64.b64encode(script.encode("utf-16le")).decode("ascii")
|
||||
return (
|
||||
"-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass "
|
||||
f"-WindowStyle Hidden -EncodedCommand {encoded_script}"
|
||||
)
|
||||
|
||||
|
||||
def _build_windows_task_xml(
|
||||
service_name: str,
|
||||
executable: Path,
|
||||
workdir: Path,
|
||||
) -> bytes:
|
||||
ElementTree.register_namespace("", WINDOWS_TASK_XML_NS)
|
||||
task = ElementTree.Element(
|
||||
f"{{{WINDOWS_TASK_XML_NS}}}Task",
|
||||
{"version": "1.4"},
|
||||
)
|
||||
|
||||
registration_info = _task_element(task, "RegistrationInfo")
|
||||
_task_element(registration_info, "Description", "AstrBot Service")
|
||||
|
||||
triggers = _task_element(task, "Triggers")
|
||||
logon_trigger = _task_element(triggers, "LogonTrigger")
|
||||
_task_element(logon_trigger, "Enabled", "true")
|
||||
|
||||
principals = _task_element(task, "Principals")
|
||||
principal = _task_element(principals, "Principal", attrib={"id": "Author"})
|
||||
_task_element(principal, "LogonType", "InteractiveToken")
|
||||
_task_element(principal, "RunLevel", "LeastPrivilege")
|
||||
|
||||
settings = _task_element(task, "Settings")
|
||||
_task_element(settings, "MultipleInstancesPolicy", "IgnoreNew")
|
||||
_task_element(settings, "DisallowStartIfOnBatteries", "false")
|
||||
_task_element(settings, "StopIfGoingOnBatteries", "false")
|
||||
_task_element(settings, "AllowHardTerminate", "true")
|
||||
_task_element(settings, "StartWhenAvailable", "true")
|
||||
_task_element(settings, "RunOnlyIfNetworkAvailable", "false")
|
||||
_task_element(settings, "AllowStartOnDemand", "true")
|
||||
_task_element(settings, "Enabled", "true")
|
||||
_task_element(settings, "Hidden", "true")
|
||||
_task_element(settings, "RunOnlyIfIdle", "false")
|
||||
_task_element(settings, "WakeToRun", "false")
|
||||
_task_element(settings, "ExecutionTimeLimit", "PT0S")
|
||||
_task_element(settings, "Priority", "7")
|
||||
restart = _task_element(settings, "RestartOnFailure")
|
||||
_task_element(restart, "Interval", "PT1M")
|
||||
_task_element(restart, "Count", "3")
|
||||
|
||||
actions = _task_element(task, "Actions", attrib={"Context": "Author"})
|
||||
exec_action = _task_element(actions, "Exec")
|
||||
_task_element(exec_action, "Command", _windows_powershell_executable())
|
||||
_task_element(
|
||||
exec_action,
|
||||
"Arguments",
|
||||
_build_windows_powershell_arguments(service_name, executable, workdir),
|
||||
)
|
||||
_task_element(exec_action, "WorkingDirectory", str(workdir))
|
||||
|
||||
ElementTree.indent(task, space=" ")
|
||||
return ElementTree.tostring(task, encoding="utf-16", xml_declaration=True)
|
||||
|
||||
|
||||
def _windows_task_exists(service_name: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["schtasks", "/Query", "/TN", service_name],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _install_windows_task(
|
||||
service_name: str,
|
||||
executable: Path,
|
||||
workdir: Path,
|
||||
*,
|
||||
force: bool,
|
||||
now: bool,
|
||||
) -> None:
|
||||
if platform.system() != "Windows":
|
||||
raise click.ClickException(
|
||||
"Windows scheduled task installation is only available on Windows"
|
||||
)
|
||||
if shutil.which("schtasks") is None:
|
||||
raise click.ClickException("schtasks was not found")
|
||||
if _windows_task_exists(service_name) and not force:
|
||||
raise click.ClickException(
|
||||
f"Scheduled task {service_name} already exists. Use --force to overwrite"
|
||||
)
|
||||
|
||||
_windows_log_dir().mkdir(parents=True, exist_ok=True)
|
||||
task_xml = _build_windows_task_xml(service_name, executable, workdir)
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as f:
|
||||
temp_path = Path(f.name)
|
||||
f.write(task_xml)
|
||||
|
||||
command = ["schtasks", "/Create", "/TN", service_name, "/XML", str(temp_path)]
|
||||
if force:
|
||||
command.append("/F")
|
||||
_run_checked(command, "Failed to create the Windows scheduled task")
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
if now:
|
||||
_run_checked(
|
||||
["schtasks", "/Run", "/TN", service_name],
|
||||
"Failed to start the Windows scheduled task",
|
||||
)
|
||||
|
||||
|
||||
def _first_output_line(result: subprocess.CompletedProcess[str]) -> str | None:
|
||||
text = (result.stdout or result.stderr).strip()
|
||||
if not text:
|
||||
@@ -595,58 +407,12 @@ def _get_launchd_state(service_name: str) -> ServiceState:
|
||||
)
|
||||
|
||||
|
||||
def _parse_schtasks_field(output: str, field_name: str) -> str | None:
|
||||
prefix = f"{field_name}:"
|
||||
for line in output.splitlines():
|
||||
if line.startswith(prefix):
|
||||
return line.removeprefix(prefix).strip()
|
||||
return None
|
||||
|
||||
|
||||
def _get_windows_task_state(service_name: str) -> ServiceState:
|
||||
if shutil.which("schtasks") is None:
|
||||
return ServiceState(
|
||||
manager="Task Scheduler",
|
||||
installed=False,
|
||||
state="unknown",
|
||||
detail="schtasks was not found",
|
||||
)
|
||||
|
||||
result = _run_capture(
|
||||
["schtasks", "/Query", "/TN", service_name, "/FO", "LIST", "/V"]
|
||||
)
|
||||
if result is None:
|
||||
return ServiceState(
|
||||
manager="Task Scheduler",
|
||||
installed=False,
|
||||
state="unknown",
|
||||
detail="schtasks was not found",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ServiceState(
|
||||
manager="Task Scheduler",
|
||||
installed=False,
|
||||
state="not-installed",
|
||||
detail=_first_output_line(result),
|
||||
)
|
||||
|
||||
status = _parse_schtasks_field(result.stdout or "", "Status") or "unknown"
|
||||
return ServiceState(
|
||||
manager="Task Scheduler",
|
||||
installed=True,
|
||||
state=status.lower(),
|
||||
detail=_parse_schtasks_field(result.stdout or "", "Task To Run"),
|
||||
)
|
||||
|
||||
|
||||
def _get_service_state(service_name: str) -> ServiceState:
|
||||
system = platform.system()
|
||||
if system == "Linux":
|
||||
return _get_systemd_state(service_name)
|
||||
if system == "Darwin":
|
||||
return _get_launchd_state(service_name)
|
||||
if system == "Windows":
|
||||
return _get_windows_task_state(service_name)
|
||||
return ServiceState(
|
||||
manager="unknown",
|
||||
installed=False,
|
||||
@@ -836,25 +602,6 @@ def _stop_launch_agent(service_name: str, *, allow_missing: bool = False) -> Non
|
||||
_wait_for_launch_agent_state(service_name, loaded=False)
|
||||
|
||||
|
||||
def _control_windows_task(service_name: str, action: str) -> None:
|
||||
if shutil.which("schtasks") is None:
|
||||
raise click.ClickException("schtasks was not found")
|
||||
if not _windows_task_exists(service_name):
|
||||
raise click.ClickException(
|
||||
f"Scheduled task {service_name} does not exist. Run 'service install' first"
|
||||
)
|
||||
|
||||
match action:
|
||||
case "start":
|
||||
command = ["schtasks", "/Run", "/TN", service_name]
|
||||
case "stop":
|
||||
command = ["schtasks", "/End", "/TN", service_name]
|
||||
case _:
|
||||
raise click.ClickException(f"Unsupported Windows task action: {action}")
|
||||
|
||||
_run_checked(command, f"Failed to {action} the Windows scheduled task")
|
||||
|
||||
|
||||
def _control_service(service_name: str, action: str) -> None:
|
||||
system = platform.system()
|
||||
if system == "Linux":
|
||||
@@ -874,17 +621,6 @@ def _control_service(service_name: str, action: str) -> None:
|
||||
raise click.ClickException(f"Unsupported launchd action: {action}")
|
||||
return
|
||||
|
||||
if system == "Windows":
|
||||
match action:
|
||||
case "start" | "stop":
|
||||
_control_windows_task(service_name, action)
|
||||
case "restart":
|
||||
_control_windows_task(service_name, "stop")
|
||||
_control_windows_task(service_name, "start")
|
||||
case _:
|
||||
raise click.ClickException(f"Unsupported Windows task action: {action}")
|
||||
return
|
||||
|
||||
raise click.ClickException(f"Unsupported platform: {system}")
|
||||
|
||||
|
||||
@@ -925,27 +661,12 @@ def _uninstall_launch_agent(service_name: str) -> Path:
|
||||
return plist_path
|
||||
|
||||
|
||||
def _uninstall_windows_task(service_name: str) -> str:
|
||||
if shutil.which("schtasks") is None:
|
||||
raise click.ClickException("schtasks was not found")
|
||||
if not _windows_task_exists(service_name):
|
||||
raise click.ClickException(f"Scheduled task {service_name} does not exist")
|
||||
|
||||
_run_checked(
|
||||
["schtasks", "/Delete", "/TN", service_name, "/F"],
|
||||
"Failed to delete the Windows scheduled task",
|
||||
)
|
||||
return service_name
|
||||
|
||||
|
||||
def _uninstall_service(service_name: str) -> Path | str:
|
||||
def _uninstall_service(service_name: str) -> Path:
|
||||
system = platform.system()
|
||||
if system == "Linux":
|
||||
return _uninstall_systemd_service(service_name)
|
||||
if system == "Darwin":
|
||||
return _uninstall_launch_agent(service_name)
|
||||
if system == "Windows":
|
||||
return _uninstall_windows_task(service_name)
|
||||
raise click.ClickException(f"Unsupported platform: {system}")
|
||||
|
||||
|
||||
@@ -1131,7 +852,7 @@ def _show_service_logs(
|
||||
_show_journal_logs(service_name, lines, follow)
|
||||
return
|
||||
|
||||
if system in {"Darwin", "Windows"}:
|
||||
if system == "Darwin":
|
||||
out_log, err_log = _service_log_paths(service_name)
|
||||
paths = [out_log]
|
||||
if include_stderr:
|
||||
@@ -1172,9 +893,12 @@ def install(
|
||||
) -> None:
|
||||
"""Install AstrBot as a user-level background service."""
|
||||
service_name = _validate_service_name(name)
|
||||
system = platform.system()
|
||||
if system not in {"Linux", "Darwin"}:
|
||||
raise click.ClickException(f"Unsupported platform: {system}")
|
||||
|
||||
astrbot_root = _resolve_workdir(workdir)
|
||||
astrbot_executable = _resolve_astrbot_executable(executable)
|
||||
system = platform.system()
|
||||
|
||||
if system == "Linux":
|
||||
service_path = _install_systemd_user_service(
|
||||
@@ -1204,18 +928,6 @@ def install(
|
||||
click.echo(f"LaunchAgent label: {_macos_label(service_name)}")
|
||||
return
|
||||
|
||||
if system == "Windows":
|
||||
_install_windows_task(
|
||||
service_name,
|
||||
astrbot_executable,
|
||||
astrbot_root,
|
||||
force=force,
|
||||
now=now,
|
||||
)
|
||||
click.echo(f"Installed Windows scheduled task: {service_name}")
|
||||
click.echo(f"Manage it with: schtasks /Query /TN {service_name}")
|
||||
return
|
||||
|
||||
raise click.ClickException(f"Unsupported platform: {system}")
|
||||
|
||||
|
||||
@@ -1320,7 +1032,7 @@ def uninstall(name: str, force: bool) -> None:
|
||||
@click.option(
|
||||
"--include-stderr",
|
||||
is_flag=True,
|
||||
help="Also show stderr logs on macOS and Windows.",
|
||||
help="Also show stderr logs on macOS.",
|
||||
)
|
||||
def logs(
|
||||
ctx: click.Context,
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -36,7 +36,9 @@ The command uses the `astrbot` executable found on `PATH` (usually generated by
|
||||
|
||||
- Linux: `systemd --user`
|
||||
- macOS: `LaunchAgent`
|
||||
- Windows: Task Scheduler
|
||||
|
||||
> [!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:
|
||||
|
||||
@@ -68,7 +70,7 @@ astrbot service logs
|
||||
astrbot service logs -f
|
||||
```
|
||||
|
||||
On macOS and Windows, this shows stdout logs by default. To include stderr:
|
||||
On macOS, this shows stdout logs by default. To include stderr:
|
||||
|
||||
```bash
|
||||
astrbot service logs --include-stderr
|
||||
|
||||
@@ -70,12 +70,14 @@ Common options:
|
||||
| --- | --- |
|
||||
| `-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
|
||||
@@ -88,7 +90,9 @@ Each platform uses its native service manager:
|
||||
| --- | --- |
|
||||
| Linux | `systemd --user` |
|
||||
| macOS | LaunchAgent |
|
||||
| Windows | Task Scheduler |
|
||||
|
||||
> [!NOTE]
|
||||
> `astrbot service` is not supported on Windows. Use `astrbot run` in the foreground or another process manager.
|
||||
|
||||
### Install
|
||||
|
||||
@@ -188,9 +192,9 @@ Common options:
|
||||
| `--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 and Windows. |
|
||||
| `--include-stderr` | Also show stderr logs on macOS. |
|
||||
|
||||
On macOS and Windows, `astrbot service logs` shows stdout logs by default, which are the `.out.log` files. Add `--include-stderr` when you also need error output.
|
||||
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
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ astrbot service install --now
|
||||
|
||||
- Linux:`systemd --user`
|
||||
- macOS:`LaunchAgent`
|
||||
- Windows:任务计划程序
|
||||
|
||||
> [!NOTE]
|
||||
> Windows 暂不支持 `astrbot service`。请使用 `astrbot run` 前台启动,或使用其他进程管理工具。
|
||||
|
||||
如果需要指定 AstrBot 工作目录或可执行文件路径,可以使用:
|
||||
|
||||
@@ -67,7 +69,7 @@ astrbot service logs
|
||||
astrbot service logs -f
|
||||
```
|
||||
|
||||
macOS 和 Windows 下默认只显示标准输出日志;如需同时查看 stderr:
|
||||
macOS 下默认只显示标准输出日志;如需同时查看 stderr:
|
||||
|
||||
```bash
|
||||
astrbot service logs --include-stderr
|
||||
|
||||
@@ -70,12 +70,14 @@ astrbot run
|
||||
| --- | --- |
|
||||
| `-p, --port <PORT>` | 指定 WebUI 端口。 |
|
||||
| `-r, --reload` | 启用插件自动重载,适合插件开发调试。 |
|
||||
| `--reset-password` | 启动时重置 WebUI 初始密码,并在启动日志中打印新的初始密码。 |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
astrbot run --port 6185
|
||||
astrbot run --reload
|
||||
astrbot run --reset-password
|
||||
```
|
||||
|
||||
## 后台服务
|
||||
@@ -88,7 +90,9 @@ astrbot run --reload
|
||||
| --- | --- |
|
||||
| Linux | `systemd --user` |
|
||||
| macOS | LaunchAgent |
|
||||
| Windows | 任务计划程序 |
|
||||
|
||||
> [!NOTE]
|
||||
> Windows 暂不支持 `astrbot service`。请使用 `astrbot run` 前台启动,或使用其他进程管理工具。
|
||||
|
||||
### 安装服务
|
||||
|
||||
@@ -188,9 +192,9 @@ astrbot service logs -f
|
||||
| `--name <NAME>` | 指定服务名。 |
|
||||
| `-n, --lines <N>` | 显示最近 N 行,默认 200。 |
|
||||
| `-f, --follow` | 持续跟随日志输出。 |
|
||||
| `--include-stderr` | 在 macOS 和 Windows 上同时显示 stderr 日志。 |
|
||||
| `--include-stderr` | 在 macOS 上同时显示 stderr 日志。 |
|
||||
|
||||
macOS 和 Windows 下,`astrbot service logs` 默认只显示标准输出日志,也就是 `.out.log`。如果需要同时查看错误输出,再加 `--include-stderr`。
|
||||
macOS 下,`astrbot service logs` 默认只显示标准输出日志,也就是 `.out.log`。如果需要同时查看错误输出,再加 `--include-stderr`。
|
||||
|
||||
### 启用应用日志文件
|
||||
|
||||
|
||||
15
main.py
15
main.py
@@ -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()
|
||||
|
||||
22
tests/test_cli_run.py
Normal file
22
tests/test_cli_run.py
Normal 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"]
|
||||
@@ -1,4 +1,3 @@
|
||||
import base64
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
@@ -13,7 +12,6 @@ from astrbot.cli.commands.cmd_service import (
|
||||
WebUIStatus,
|
||||
_build_launchd_plist,
|
||||
_build_systemd_unit,
|
||||
_build_windows_task_xml,
|
||||
_check_webui,
|
||||
_get_app_log_config,
|
||||
_health_label,
|
||||
@@ -23,12 +21,6 @@ from astrbot.cli.commands.cmd_service import (
|
||||
)
|
||||
|
||||
|
||||
def _decode_windows_encoded_command(task_xml: str) -> str:
|
||||
marker = "-EncodedCommand "
|
||||
encoded_command = task_xml.split(marker, 1)[1].split("<", 1)[0]
|
||||
return base64.b64decode(encoded_command).decode("utf-16le")
|
||||
|
||||
|
||||
class _HealthyHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
@@ -70,6 +62,16 @@ def test_service_install_requires_initialized_root(monkeypatch, tmp_path):
|
||||
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",
|
||||
@@ -135,28 +137,6 @@ def test_launch_agent_start_waits_until_loaded_before_kickstart(monkeypatch, tmp
|
||||
assert events.index("bootstrap") < events.index("kickstart")
|
||||
|
||||
|
||||
def test_windows_task_xml_uses_astrbot_executable_and_working_directory():
|
||||
task_xml = _build_windows_task_xml(
|
||||
"astrbot",
|
||||
Path("C:\\Users\\astrbot\\.local\\bin\\astrbot.exe"),
|
||||
Path("C:\\Users\\astrbot\\AstrBot"),
|
||||
).decode("utf-16")
|
||||
powershell_script = _decode_windows_encoded_command(task_xml)
|
||||
|
||||
assert "<Command>powershell.exe</Command>" in task_xml
|
||||
assert "<Hidden>true</Hidden>" in task_xml
|
||||
assert "-WindowStyle Hidden" in task_xml
|
||||
assert "Start-Process" in powershell_script
|
||||
assert "-WindowStyle Hidden" in powershell_script
|
||||
assert "C:\\Users\\astrbot\\.local\\bin\\astrbot.exe" in powershell_script
|
||||
assert "run" in powershell_script
|
||||
assert "astrbot.out.log" in powershell_script
|
||||
assert "astrbot.err.log" in powershell_script
|
||||
assert (
|
||||
"<WorkingDirectory>C:\\Users\\astrbot\\AstrBot</WorkingDirectory>" in task_xml
|
||||
)
|
||||
|
||||
|
||||
def test_load_dashboard_port_reads_cmd_config(tmp_path):
|
||||
config_path = tmp_path / "data" / "cmd_config.json"
|
||||
config_path.parent.mkdir()
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user