mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-02 02:30:16 +08:00
Compare commits
4 Commits
codex/rest
...
fix/repeat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ada757bbb3 | ||
|
|
d1b52d15b8 | ||
|
|
92fc2b592e | ||
|
|
4f5075e608 |
@@ -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_RESET_DASHBOARD_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="Reset dashboard initial password on startup",
|
||||
)
|
||||
@click.command()
|
||||
def run(reload: bool, port: str) -> None:
|
||||
def run(reload: bool, port: str | None, 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():
|
||||
|
||||
@@ -16,6 +16,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_RESET_DASHBOARD_PASSWORD"
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
@@ -77,7 +78,11 @@ class AstrBotConfig(dict):
|
||||
)
|
||||
# 检查配置完整性,并插入
|
||||
has_new = self.check_config_integrity(default_config, conf)
|
||||
if (
|
||||
reset_dashboard_password = self._consume_reset_dashboard_password_flag()
|
||||
if reset_dashboard_password and "dashboard" in conf:
|
||||
self._reset_generated_dashboard_password(conf)
|
||||
has_new = True
|
||||
elif (
|
||||
"dashboard" in conf
|
||||
and isinstance(conf["dashboard"], dict)
|
||||
and not conf["dashboard"].get("pbkdf2_password")
|
||||
@@ -118,6 +123,11 @@ class AstrBotConfig(dict):
|
||||
True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _consume_reset_dashboard_password_flag() -> bool:
|
||||
raw_value = os.environ.pop(DASHBOARD_RESET_PASSWORD_ENV, "")
|
||||
return raw_value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_initial_dashboard_password() -> str:
|
||||
env_password = os.environ.get(DASHBOARD_INITIAL_PASSWORD_ENV)
|
||||
|
||||
@@ -186,7 +186,6 @@ class ResultDecorateStage(Stage):
|
||||
|
||||
# 流式输出不执行下面的逻辑
|
||||
if is_stream:
|
||||
logger.info("流式输出已启用,跳过结果装饰阶段")
|
||||
return
|
||||
|
||||
# 需要再获取一次。插件可能直接对 chain 进行了替换。
|
||||
|
||||
@@ -90,7 +90,7 @@ class PipelineScheduler:
|
||||
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
|
||||
await event.send(None)
|
||||
|
||||
logger.debug("pipeline 执行完毕。")
|
||||
logger.debug("pipeline execution completed.")
|
||||
finally:
|
||||
event.cleanup_temporary_local_files()
|
||||
active_event_registry.unregister(event)
|
||||
|
||||
@@ -97,7 +97,6 @@ class ProviderGoogleGenAI(Provider):
|
||||
if proxy:
|
||||
async_client_kwargs["proxy"] = proxy
|
||||
async_client_kwargs["trust_env"] = False
|
||||
logger.info("[Gemini] 使用代理")
|
||||
else:
|
||||
async_client_kwargs["trust_env"] = True
|
||||
|
||||
@@ -137,15 +136,18 @@ class ProviderGoogleGenAI(Provider):
|
||||
keys.remove(self.chosen_api_key)
|
||||
if len(keys) > 0:
|
||||
self.set_key(random.choice(keys))
|
||||
logger.info(
|
||||
f"检测到 Key 异常({e.message}),正在尝试更换 API Key 重试... 当前 Key: {self.chosen_api_key[:12]}...",
|
||||
logger.warning(
|
||||
"Retrying with a different API key due to detected key issue: %s. Current key: %s...",
|
||||
e.message,
|
||||
self.chosen_api_key[:12],
|
||||
)
|
||||
await asyncio.sleep(1)
|
||||
return True
|
||||
logger.error(
|
||||
f"检测到 Key 异常({e.message}),且已没有可用的 Key。 当前 Key: {self.chosen_api_key[:12]}...",
|
||||
"No valid API keys remaining. Current key: %s...",
|
||||
self.chosen_api_key[:12],
|
||||
)
|
||||
raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
|
||||
raise Exception("Gemini API rate limit reached or API key issue detected.")
|
||||
|
||||
# 连接错误处理
|
||||
if is_connection_error(e):
|
||||
@@ -172,7 +174,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
self.provider_settings.get("streaming_response", False)
|
||||
and "IMAGE" in modalities
|
||||
):
|
||||
logger.warning("流式输出不支持图片模态,已自动降级为文本模态")
|
||||
logger.warning(
|
||||
"Streaming responses do not support IMAGE modality, falling back to TEXT modality."
|
||||
)
|
||||
modalities = ["TEXT"]
|
||||
|
||||
tool_list: list[types.Tool] | None = []
|
||||
@@ -181,60 +185,28 @@ class ProviderGoogleGenAI(Provider):
|
||||
native_search = self.provider_config.get("gm_native_search", False)
|
||||
url_context = self.provider_config.get("gm_url_context", False)
|
||||
|
||||
if "gemini-2.5" in model_name:
|
||||
if native_coderunner:
|
||||
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
|
||||
if native_search:
|
||||
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
|
||||
if url_context:
|
||||
logger.warning(
|
||||
"代码执行工具与URL上下文工具互斥,已忽略URL上下文工具",
|
||||
)
|
||||
else:
|
||||
if native_search:
|
||||
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
|
||||
|
||||
if url_context:
|
||||
if hasattr(types, "UrlContext"):
|
||||
tool_list.append(types.Tool(url_context=types.UrlContext()))
|
||||
else:
|
||||
logger.warning(
|
||||
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包",
|
||||
)
|
||||
|
||||
elif "gemini-2.0-lite" in model_name:
|
||||
if "gemini-2.0-lite" in model_name:
|
||||
if native_coderunner or native_search or url_context:
|
||||
logger.warning(
|
||||
"gemini-2.0-lite 不支持代码执行、搜索工具和URL上下文,将忽略这些设置",
|
||||
"gemini-2.0-lite does not support native code execution, search, or URL context tools. These settings will be ignored.",
|
||||
)
|
||||
tool_list = None
|
||||
|
||||
else:
|
||||
if native_coderunner:
|
||||
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
|
||||
if native_search:
|
||||
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
|
||||
elif native_search:
|
||||
if native_search:
|
||||
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
|
||||
if url_context:
|
||||
tool_list.append(types.Tool(url_context=types.UrlContext()))
|
||||
|
||||
if url_context and not native_coderunner:
|
||||
if hasattr(types, "UrlContext"):
|
||||
tool_list.append(types.Tool(url_context=types.UrlContext()))
|
||||
else:
|
||||
logger.warning(
|
||||
"当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包",
|
||||
)
|
||||
if tools:
|
||||
func_desc = tools.get_func_desc_google_genai_style()
|
||||
tool_list.append(
|
||||
types.Tool(function_declarations=func_desc["function_declarations"]),
|
||||
)
|
||||
|
||||
if not tool_list:
|
||||
tool_list = None
|
||||
|
||||
if tools and tool_list:
|
||||
logger.warning("已启用原生工具,函数工具将被忽略")
|
||||
elif tools and (func_desc := tools.get_func_desc_google_genai_style()):
|
||||
tool_list = [
|
||||
types.Tool(function_declarations=func_desc["function_declarations"]),
|
||||
]
|
||||
|
||||
tool_config = None
|
||||
has_func_decl = tool_list and any(t.function_declarations for t in tool_list)
|
||||
if has_func_decl:
|
||||
@@ -322,7 +294,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
def create_text_part(text: str) -> types.Part:
|
||||
content_a = text if text else " "
|
||||
if not text:
|
||||
logger.warning("文本内容为空,已添加空格占位")
|
||||
logger.warning("Text content is empty, added a space as placeholder.")
|
||||
return types.Part.from_text(text=content_a)
|
||||
|
||||
def process_image_url(image_url_dict: dict) -> types.Part:
|
||||
@@ -349,12 +321,6 @@ class ProviderGoogleGenAI(Provider):
|
||||
contents.append(content_cls(parts=part))
|
||||
|
||||
gemini_contents: list[types.Content] = []
|
||||
native_tool_enabled = any(
|
||||
[
|
||||
self.provider_config.get("gm_native_coderunner", False),
|
||||
self.provider_config.get("gm_native_search", False),
|
||||
],
|
||||
)
|
||||
for message in payloads["messages"]:
|
||||
role, content = message["role"], message.get("content")
|
||||
|
||||
@@ -377,11 +343,10 @@ class ProviderGoogleGenAI(Provider):
|
||||
append_or_extend(gemini_contents, parts, types.UserContent)
|
||||
|
||||
elif role == "assistant":
|
||||
parts = []
|
||||
if isinstance(content, str):
|
||||
parts = [types.Part.from_text(text=content)]
|
||||
append_or_extend(gemini_contents, parts, types.ModelContent)
|
||||
parts.append(types.Part.from_text(text=content))
|
||||
elif isinstance(content, list):
|
||||
parts = []
|
||||
thinking_signature = None
|
||||
text = ""
|
||||
for part in content:
|
||||
@@ -400,16 +365,31 @@ class ProviderGoogleGenAI(Provider):
|
||||
exc_info=True,
|
||||
)
|
||||
thinking_signature = None
|
||||
parts.append(
|
||||
types.Part(
|
||||
text=text,
|
||||
thought_signature=thinking_signature,
|
||||
)
|
||||
)
|
||||
append_or_extend(gemini_contents, parts, types.ModelContent)
|
||||
|
||||
elif not native_tool_enabled and "tool_calls" in message:
|
||||
parts = []
|
||||
if (
|
||||
not text
|
||||
and thinking_signature
|
||||
and "tool_calls" in message
|
||||
and any(
|
||||
isinstance(tool, dict)
|
||||
and isinstance(tool.get("extra_content"), dict)
|
||||
and isinstance(tool["extra_content"].get("google"), dict)
|
||||
and tool["extra_content"]["google"].get("thought_signature")
|
||||
for tool in message["tool_calls"]
|
||||
)
|
||||
):
|
||||
# If the main content is empty but tool calls have thought signatures,
|
||||
# skip adding an empty text part to deduplicate the thinking signature in the main content and tool calls.
|
||||
pass
|
||||
else:
|
||||
parts.append(
|
||||
types.Part(
|
||||
text=text,
|
||||
thought_signature=thinking_signature,
|
||||
)
|
||||
)
|
||||
|
||||
if "tool_calls" in message:
|
||||
for tool in message["tool_calls"]:
|
||||
part = types.Part.from_function_call(
|
||||
name=tool["function"]["name"],
|
||||
@@ -427,17 +407,13 @@ class ProviderGoogleGenAI(Provider):
|
||||
if ts_bs64:
|
||||
part.thought_signature = base64.b64decode(ts_bs64)
|
||||
parts.append(part)
|
||||
append_or_extend(gemini_contents, parts, types.ModelContent)
|
||||
else:
|
||||
logger.warning("assistant 角色的消息内容为空,已添加空格占位")
|
||||
if native_tool_enabled and "tool_calls" in message:
|
||||
logger.warning(
|
||||
"检测到启用Gemini原生工具,且上下文中存在函数调用,建议使用 /reset 重置上下文",
|
||||
)
|
||||
parts = [types.Part.from_text(text=" ")]
|
||||
append_or_extend(gemini_contents, parts, types.ModelContent)
|
||||
|
||||
elif role == "tool" and not native_tool_enabled:
|
||||
if not parts:
|
||||
parts = [types.Part.from_text(text=" ")]
|
||||
|
||||
append_or_extend(gemini_contents, parts, types.ModelContent)
|
||||
|
||||
elif role == "tool":
|
||||
func_name = message.get("name", message["tool_call_id"])
|
||||
part = types.Part.from_function_response(
|
||||
name=func_name,
|
||||
@@ -501,7 +477,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
) -> MessageChain:
|
||||
"""处理内容部分并构建消息链"""
|
||||
if not candidate.content:
|
||||
logger.warning(f"收到的 candidate.content 为空: {candidate}")
|
||||
logger.warning(f"Gemini candidate.content is empty: {candidate}")
|
||||
if validate_output:
|
||||
raise EmptyModelOutputError(
|
||||
"Gemini candidate content is empty. "
|
||||
@@ -514,22 +490,22 @@ class ProviderGoogleGenAI(Provider):
|
||||
result_parts: list[types.Part] | None = candidate.content.parts
|
||||
|
||||
if finish_reason == types.FinishReason.SAFETY:
|
||||
raise Exception("模型生成内容未通过 Gemini 平台的安全检查")
|
||||
raise Exception("The model output failed Gemini platform safety checks.")
|
||||
|
||||
if finish_reason in {
|
||||
types.FinishReason.PROHIBITED_CONTENT,
|
||||
types.FinishReason.SPII,
|
||||
types.FinishReason.BLOCKLIST,
|
||||
}:
|
||||
raise Exception("模型生成内容违反 Gemini 平台政策")
|
||||
raise Exception("The model output violates Gemini platform policy.")
|
||||
|
||||
# 防止旧版本SDK不存在IMAGE_SAFETY
|
||||
if hasattr(types.FinishReason, "IMAGE_SAFETY"):
|
||||
if finish_reason == types.FinishReason.IMAGE_SAFETY:
|
||||
raise Exception("模型生成内容违反 Gemini 平台政策")
|
||||
raise Exception("The model output violates Gemini platform policy.")
|
||||
|
||||
if not result_parts:
|
||||
logger.warning(f"收到的 candidate.content.parts 为空: {candidate}")
|
||||
logger.warning(f"Gemini candidate.content.parts is empty: {candidate}")
|
||||
if validate_output:
|
||||
raise EmptyModelOutputError(
|
||||
"Gemini candidate content parts are empty. "
|
||||
@@ -636,15 +612,19 @@ class ProviderGoogleGenAI(Provider):
|
||||
logger.debug(f"genai result: {result}")
|
||||
|
||||
if not result.candidates:
|
||||
logger.error(f"请求失败, 返回的 candidates 为空: {result}")
|
||||
raise Exception("请求失败, 返回的 candidates 为空。")
|
||||
logger.error(
|
||||
f"Gemini request failed: candidates is empty: {result}"
|
||||
)
|
||||
raise Exception("Gemini request failed: candidates is empty.")
|
||||
|
||||
if result.candidates[0].finish_reason == types.FinishReason.RECITATION:
|
||||
if temperature > 2:
|
||||
raise Exception("温度参数已超过最大值2,仍然发生recitation")
|
||||
raise Exception(
|
||||
"Temperature exceeded the maximum value of 2, but Gemini recitation still occurred."
|
||||
)
|
||||
temperature += 0.2
|
||||
logger.warning(
|
||||
f"发生了recitation,正在提高温度至{temperature:.1f}重试...",
|
||||
f"Gemini recitation detected; increasing temperature to {temperature:.1f} and retrying...",
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -655,11 +635,13 @@ class ProviderGoogleGenAI(Provider):
|
||||
e.message = ""
|
||||
if "Developer instruction is not enabled" in e.message:
|
||||
logger.warning(
|
||||
f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
|
||||
f"{model} does not support system prompts; removing it automatically. This may affect persona settings.",
|
||||
)
|
||||
system_instruction = None
|
||||
elif "Function calling is not enabled" in e.message:
|
||||
logger.warning(f"{model} 不支持函数调用,已自动去除")
|
||||
logger.warning(
|
||||
f"{model} does not support function calling; removing tools automatically."
|
||||
)
|
||||
tools = None
|
||||
elif (
|
||||
"Multi-modal output is not supported" in e.message
|
||||
@@ -668,7 +650,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
or "only supports text output" in e.message
|
||||
):
|
||||
logger.warning(
|
||||
f"{model} 不支持多模态输出,降级为文本模态",
|
||||
f"{model} does not support multimodal output; falling back to TEXT modality.",
|
||||
)
|
||||
modalities = ["TEXT"]
|
||||
else:
|
||||
@@ -719,11 +701,13 @@ class ProviderGoogleGenAI(Provider):
|
||||
e.message = ""
|
||||
if "Developer instruction is not enabled" in e.message:
|
||||
logger.warning(
|
||||
f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
|
||||
f"{model} does not support system prompts; removing it automatically. This may affect persona settings.",
|
||||
)
|
||||
system_instruction = None
|
||||
elif "Function calling is not enabled" in e.message:
|
||||
logger.warning(f"{model} 不支持函数调用,已自动去除")
|
||||
logger.warning(
|
||||
f"{model} does not support function calling; removing tools automatically."
|
||||
)
|
||||
tools = None
|
||||
else:
|
||||
raise
|
||||
@@ -738,10 +722,10 @@ class ProviderGoogleGenAI(Provider):
|
||||
llm_response = LLMResponse("assistant", is_chunk=True)
|
||||
|
||||
if not chunk.candidates:
|
||||
logger.warning(f"收到的 chunk 中 candidates 为空: {chunk}")
|
||||
logger.warning(f"Gemini stream chunk has empty candidates: {chunk}")
|
||||
continue
|
||||
if not chunk.candidates[0].content:
|
||||
logger.warning(f"收到的 chunk 中 content 为空: {chunk}")
|
||||
logger.warning(f"Gemini stream chunk has empty content: {chunk}")
|
||||
continue
|
||||
|
||||
if chunk.candidates[0].content.parts and any(
|
||||
@@ -872,7 +856,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
continue
|
||||
break
|
||||
|
||||
raise Exception("请求失败。")
|
||||
raise Exception("Gemini request failed.")
|
||||
|
||||
async def text_chat_stream(
|
||||
self,
|
||||
@@ -947,7 +931,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
and m.name
|
||||
]
|
||||
except APIError as e:
|
||||
raise Exception(f"获取模型列表失败: {e.message}")
|
||||
raise Exception(f"Failed to fetch Gemini model list: {e.message}")
|
||||
|
||||
def get_current_key(self) -> str:
|
||||
return self.chosen_api_key
|
||||
@@ -974,7 +958,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
media_type="image",
|
||||
)
|
||||
if not image_data:
|
||||
logger.warning("图片预处理结果为空,将忽略。")
|
||||
logger.warning("Image preprocessing returned no data; ignoring it.")
|
||||
return None
|
||||
return {
|
||||
"type": "image_url",
|
||||
@@ -989,11 +973,13 @@ class ProviderGoogleGenAI(Provider):
|
||||
strict=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("音频预处理失败,将忽略。错误: %s", exc)
|
||||
logger.warning(
|
||||
"Audio preprocessing failed; ignoring it. Error: %s", exc
|
||||
)
|
||||
return None
|
||||
|
||||
if not audio_data:
|
||||
logger.warning("音频预处理结果为空,将忽略。")
|
||||
logger.warning("Audio preprocessing returned no data; ignoring it.")
|
||||
return None
|
||||
return {
|
||||
"type": "audio_url",
|
||||
@@ -1029,7 +1015,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
if audio_part:
|
||||
content_blocks.append(audio_part)
|
||||
else:
|
||||
raise ValueError(f"不支持的额外内容块类型: {type(part)}")
|
||||
raise ValueError(
|
||||
f"Unsupported extra content part type: {type(part)}"
|
||||
)
|
||||
|
||||
# 3. 图片内容
|
||||
if image_urls:
|
||||
|
||||
169
docs/en/use/cli.md
Normal file
169
docs/en/use/cli.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# 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. |
|
||||
| `--reset-password` | Reset the WebUI initial password on startup and print the new password in startup logs. |
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
astrbot run --port 6185
|
||||
astrbot run --reload
|
||||
astrbot run --reset-password
|
||||
```
|
||||
|
||||
If you forget the WebUI login password, run this from the AstrBot working directory:
|
||||
|
||||
```bash
|
||||
astrbot run --reset-password
|
||||
```
|
||||
|
||||
AstrBot regenerates the initial password during startup and prints it in startup logs. After logging in, change the password in the WebUI immediately.
|
||||
|
||||
When starting directly from source, you can also run:
|
||||
|
||||
```bash
|
||||
python main.py --reset-password
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
169
docs/zh/use/cli.md
Normal file
169
docs/zh/use/cli.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# 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` | 启用插件自动重载,适合插件开发调试。 |
|
||||
| `--reset-password` | 启动时重置 WebUI 初始密码,并在启动日志中打印新密码。 |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
astrbot run --port 6185
|
||||
astrbot run --reload
|
||||
astrbot run --reset-password
|
||||
```
|
||||
|
||||
如果你忘记了 WebUI 登录密码,可以在 AstrBot 工作目录中执行:
|
||||
|
||||
```bash
|
||||
astrbot run --reset-password
|
||||
```
|
||||
|
||||
AstrBot 会在启动时重新生成初始密码,并在启动日志中打印。登录后请立即在 WebUI 中修改密码。
|
||||
|
||||
使用源码方式直接启动时,也可以执行:
|
||||
|
||||
```bash
|
||||
python main.py --reset-password
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
`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
|
||||
```
|
||||
30
main.py
30
main.py
@@ -9,6 +9,28 @@ import runtime_bootstrap
|
||||
|
||||
runtime_bootstrap.initialize_runtime_bootstrap()
|
||||
|
||||
DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_RESET_DASHBOARD_PASSWORD"
|
||||
|
||||
|
||||
def _apply_startup_env_flags(argv: list[str]) -> None:
|
||||
"""Apply startup flags that must take effect before core imports.
|
||||
|
||||
Args:
|
||||
argv: Command-line arguments excluding the executable name.
|
||||
"""
|
||||
|
||||
if "-h" in argv or "--help" in argv:
|
||||
return
|
||||
|
||||
startup_parser = argparse.ArgumentParser(add_help=False)
|
||||
startup_parser.add_argument("--reset-password", action="store_true")
|
||||
startup_args, _ = startup_parser.parse_known_args(argv)
|
||||
if startup_args.reset_password:
|
||||
os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "1"
|
||||
|
||||
|
||||
_apply_startup_env_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
|
||||
@@ -141,6 +163,14 @@ if __name__ == "__main__":
|
||||
help="Specify the directory path for WebUI static files",
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reset-password",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Reset the dashboard initial password on startup and print it in "
|
||||
"startup logs"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
check_env()
|
||||
|
||||
43
tests/test_cli_run.py
Normal file
43
tests/test_cli_run.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from astrbot.cli.commands import cmd_run
|
||||
|
||||
|
||||
def test_run_reset_password_sets_startup_env(monkeypatch, tmp_path):
|
||||
(tmp_path / ".astrbot").touch()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv(cmd_run.DASHBOARD_RESET_PASSWORD_ENV, raising=False)
|
||||
original_env = {
|
||||
"ASTRBOT_CLI": os.environ.get("ASTRBOT_CLI"),
|
||||
"ASTRBOT_ROOT": os.environ.get("ASTRBOT_ROOT"),
|
||||
cmd_run.DASHBOARD_RESET_PASSWORD_ENV: os.environ.get(
|
||||
cmd_run.DASHBOARD_RESET_PASSWORD_ENV
|
||||
),
|
||||
}
|
||||
original_sys_path = list(sys.path)
|
||||
|
||||
called = False
|
||||
|
||||
async def fake_run_astrbot(astrbot_root):
|
||||
nonlocal called
|
||||
called = True
|
||||
assert astrbot_root == tmp_path
|
||||
assert os.environ[cmd_run.DASHBOARD_RESET_PASSWORD_ENV] == "1"
|
||||
|
||||
monkeypatch.setattr(cmd_run, "run_astrbot", fake_run_astrbot)
|
||||
|
||||
try:
|
||||
result = CliRunner().invoke(cmd_run.run, ["--reset-password"])
|
||||
finally:
|
||||
for key, value in original_env.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
sys.path[:] = original_sys_path
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert called is True
|
||||
@@ -10,7 +10,12 @@ from unittest import mock
|
||||
import pytest
|
||||
|
||||
from astrbot.core.utils.io import should_use_bundled_dashboard_dist
|
||||
from main import check_dashboard_files, check_env
|
||||
from main import (
|
||||
DASHBOARD_RESET_PASSWORD_ENV,
|
||||
_apply_startup_env_flags,
|
||||
check_dashboard_files,
|
||||
check_env,
|
||||
)
|
||||
|
||||
|
||||
class _version_info:
|
||||
@@ -62,6 +67,30 @@ def test_check_env(monkeypatch):
|
||||
check_env()
|
||||
|
||||
|
||||
def test_apply_startup_env_flags_sets_reset_password_env(monkeypatch):
|
||||
monkeypatch.delenv(DASHBOARD_RESET_PASSWORD_ENV, raising=False)
|
||||
|
||||
_apply_startup_env_flags(["--webui-dir", "/tmp/webui", "--reset-password"])
|
||||
|
||||
assert os.environ[DASHBOARD_RESET_PASSWORD_ENV] == "1"
|
||||
|
||||
|
||||
def test_apply_startup_env_flags_ignores_unrelated_args(monkeypatch):
|
||||
monkeypatch.delenv(DASHBOARD_RESET_PASSWORD_ENV, raising=False)
|
||||
|
||||
_apply_startup_env_flags(["--webui-dir", "/tmp/webui"])
|
||||
|
||||
assert DASHBOARD_RESET_PASSWORD_ENV not in os.environ
|
||||
|
||||
|
||||
def test_apply_startup_env_flags_does_not_reset_for_help(monkeypatch):
|
||||
monkeypatch.delenv(DASHBOARD_RESET_PASSWORD_ENV, raising=False)
|
||||
|
||||
_apply_startup_env_flags(["--reset-password", "--help"])
|
||||
|
||||
assert DASHBOARD_RESET_PASSWORD_ENV not in os.environ
|
||||
|
||||
|
||||
def test_check_env_appends_user_site_packages_after_runtime_paths(monkeypatch):
|
||||
astrbot_root = "/tmp/astrbot-root"
|
||||
site_packages_path = "/tmp/astrbot-site-packages"
|
||||
|
||||
@@ -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_md5_dashboard_password,
|
||||
validate_dashboard_password,
|
||||
verify_dashboard_password,
|
||||
)
|
||||
@@ -320,6 +322,53 @@ class TestAstrBotConfigLoad:
|
||||
config["dashboard"]["password"], generated_password
|
||||
)
|
||||
|
||||
def test_reset_dashboard_password_env_rotates_existing_password(
|
||||
self, temp_config_path, monkeypatch
|
||||
):
|
||||
"""Test startup reset flag rotates an already configured dashboard password."""
|
||||
old_password = "OldPassword123"
|
||||
default_config = {
|
||||
"dashboard": {
|
||||
"username": "astrbot",
|
||||
"password": "",
|
||||
"pbkdf2_password": "",
|
||||
},
|
||||
}
|
||||
with open(temp_config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"dashboard": {
|
||||
"username": "astrbot",
|
||||
"password": hash_md5_dashboard_password(old_password),
|
||||
"pbkdf2_password": hash_dashboard_password(old_password),
|
||||
"password_change_required": False,
|
||||
"password_storage_upgraded": True,
|
||||
}
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("ASTRBOT_RESET_DASHBOARD_PASSWORD", "1")
|
||||
config = AstrBotConfig(
|
||||
config_path=temp_config_path,
|
||||
default_config=default_config,
|
||||
)
|
||||
generated_password = getattr(config, "_generated_dashboard_password", None)
|
||||
|
||||
assert isinstance(generated_password, str)
|
||||
assert config["dashboard"]["password_change_required"] is True
|
||||
assert config["dashboard"]["password_storage_upgraded"] is True
|
||||
assert "ASTRBOT_RESET_DASHBOARD_PASSWORD" not in os.environ
|
||||
assert verify_dashboard_password(
|
||||
config["dashboard"]["pbkdf2_password"], generated_password
|
||||
)
|
||||
assert not verify_dashboard_password(
|
||||
config["dashboard"]["pbkdf2_password"], old_password
|
||||
)
|
||||
assert verify_dashboard_password(
|
||||
config["dashboard"]["password"], generated_password
|
||||
)
|
||||
|
||||
def test_legacy_astrbot_user_without_change_flag_keeps_legacy_password(
|
||||
self, temp_config_path
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user