Compare commits

...

4 Commits

Author SHA1 Message Date
Soulter
ada757bbb3 fix: improve type checking for tool calls in Google Gemini provider 2026-06-17 10:45:40 +08:00
Soulter
d1b52d15b8 fix: remove unnecessary logging and improve log messages in Gemini source 2026-06-17 10:40:52 +08:00
Soulter
92fc2b592e fix: tool definition does not pass back to gemini properly, causing repeated tool calls.
fixes: #8789
fixes: #8773
fixes: #7111
fixes: #6402
fixes: #7684
2026-06-17 10:03:03 +08:00
Weilong Liao
4f5075e608 feat: add startup reset password flag 2026-06-17 00:32:37 +08:00
11 changed files with 600 additions and 104 deletions

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_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():

View File

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

View File

@@ -186,7 +186,6 @@ class ResultDecorateStage(Stage):
# 流式输出不执行下面的逻辑
if is_stream:
logger.info("流式输出已启用,跳过结果装饰阶段")
return
# 需要再获取一次。插件可能直接对 chain 进行了替换。

View File

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

View File

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

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

View File

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

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_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
):