From 116c66b5b75db72697d8e2580251534a089b8194 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 8 May 2026 08:35:24 +0800 Subject: [PATCH 01/51] fix: skip KB retrieval for blank prompts (#8073) --- astrbot/core/astr_main_agent.py | 2 +- tests/unit/test_astr_main_agent.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 3916215e5..fd1a9aeb8 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -229,7 +229,7 @@ async def _apply_kb( config: MainAgentBuildConfig, ) -> None: if not config.kb_agentic_mode: - if req.prompt is None: + if req.prompt is None or not req.prompt.strip(): return try: kb_result = await retrieve_knowledge_base( diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 1ba2f3a2a..bf6adb38e 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -342,6 +342,23 @@ class TestApplyKb: assert req.system_prompt == "System" + @pytest.mark.asyncio + @pytest.mark.parametrize("prompt", ["", " \n\t"]) + async def test_apply_kb_blank_prompt(self, prompt, mock_event, mock_context): + """Test applying knowledge base when prompt is blank.""" + module = ama + req = ProviderRequest(prompt=prompt, system_prompt="System") + config = module.MainAgentBuildConfig( + tool_call_timeout=60, kb_agentic_mode=False + ) + retrieve = AsyncMock(return_value="KB result") + + with patch("astrbot.core.astr_main_agent.retrieve_knowledge_base", retrieve): + await module._apply_kb(mock_event, req, mock_context, config) + + retrieve.assert_not_awaited() + assert req.system_prompt == "System" + @pytest.mark.asyncio async def test_apply_kb_no_result(self, mock_event, mock_context): """Test applying knowledge base when no result is returned.""" From 49cd4d2a207b53be563738968715113d542696b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=82=A4=E3=82=AB=E3=82=AF?= <1259085392z@gmail.com> Date: Fri, 8 May 2026 09:41:59 +0900 Subject: [PATCH 02/51] feat(cua): expire idle sandbox sessions (#8074) * feat(cua): expire idle sandbox sessions * fix(cua): simplify idle timeout state --- astrbot/core/computer/computer_client.py | 72 +++++++++ astrbot/core/config/default.py | 1 + tests/unit/test_cua_computer_use.py | 191 +++++++++++++++++++++++ 3 files changed, 264 insertions(+) diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py index a50549fba..9be646265 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -1,7 +1,10 @@ +import asyncio import json import os import shutil +import time import uuid +from dataclasses import dataclass from pathlib import Path from astrbot.api import logger @@ -20,6 +23,70 @@ local_booter: ComputerBooter | None = None _MANAGED_SKILLS_FILE = ".astrbot_managed_skills.json" +@dataclass(slots=True) +class _CUAIdleState: + expires_at: float + task: asyncio.Task + + +cua_idle_state: dict[str, _CUAIdleState] = {} + + +def _get_cua_idle_timeout(config: dict) -> float: + sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {}) + value = sandbox_cfg.get("cua_idle_timeout", 0) + try: + timeout = float(value) + except (TypeError, ValueError): + return 0.0 + return max(timeout, 0.0) + + +def _clear_cua_idle_state(session_id: str) -> None: + state = cua_idle_state.pop(session_id, None) + if state is not None and not state.task.done(): + state.task.cancel() + + +def _schedule_cua_idle_cleanup(session_id: str, timeout: float) -> None: + _clear_cua_idle_state(session_id) + if timeout <= 0: + return + expires_at = time.monotonic() + timeout + + async def _expire_when_idle() -> None: + try: + remaining = expires_at - time.monotonic() + if remaining > 0: + await asyncio.sleep(remaining) + + state = cua_idle_state.get(session_id) + if state is None or state.expires_at != expires_at: + return + + booter = session_booter.get(session_id) + if booter is not None: + try: + await booter.shutdown() + except Exception as shutdown_err: + logger.warning( + "[Computer] Failed to shutdown idle CUA sandbox for session %s: %s", + session_id, + shutdown_err, + ) + finally: + session_booter.pop(session_id, None) + except asyncio.CancelledError: + raise + finally: + state = cua_idle_state.get(session_id) + if state is not None and state.expires_at == expires_at: + cua_idle_state.pop(session_id, None) + + task = asyncio.create_task(_expire_when_idle()) + cua_idle_state[session_id] = _CUAIdleState(expires_at=expires_at, task=task) + + def _list_local_skill_dirs(skills_root: Path) -> list[Path]: skills: list[Path] = [] for entry in sorted(skills_root.iterdir()): @@ -486,6 +553,7 @@ async def get_booter( sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {}) booter_type = sandbox_cfg.get("booter", "shipyard_neo") + cua_idle_timeout = _get_cua_idle_timeout(config) if booter_type == "cua" else 0.0 if session_id in session_booter: booter = session_booter[session_id] @@ -506,6 +574,7 @@ async def get_booter( session_id, shutdown_err, ) + _clear_cua_idle_state(session_id) session_booter.pop(session_id, None) if session_id not in session_booter: uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex @@ -579,9 +648,12 @@ async def get_booter( session_id, shutdown_error, ) + _clear_cua_idle_state(session_id) raise e session_booter[session_id] = client + if booter_type == "cua": + _schedule_cua_idle_cleanup(session_id, cua_idle_timeout) return session_booter[session_id] diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index f495a8017..7d030e88d 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -179,6 +179,7 @@ DEFAULT_CONFIG = { "cua_image": CUA_DEFAULT_CONFIG["image"], "cua_os_type": CUA_DEFAULT_CONFIG["os_type"], "cua_ttl": CUA_DEFAULT_CONFIG["ttl"], + "cua_idle_timeout": 0, "cua_telemetry_enabled": CUA_DEFAULT_CONFIG["telemetry_enabled"], "cua_local": CUA_DEFAULT_CONFIG["local"], "cua_api_key": CUA_DEFAULT_CONFIG["api_key"], diff --git a/tests/unit/test_cua_computer_use.py b/tests/unit/test_cua_computer_use.py index 0724a2b65..d7d25df74 100644 --- a/tests/unit/test_cua_computer_use.py +++ b/tests/unit/test_cua_computer_use.py @@ -20,6 +20,13 @@ class FakeContext: return self._config +def _clear_cua_session_state(computer_client, session_id: str) -> None: + computer_client.session_booter.pop(session_id, None) + state = getattr(computer_client, "cua_idle_state", {}).pop(session_id, None) + if state is not None and not state.task.done(): + state.task.cancel() + + class FakeShell: def __init__(self): self.commands = [] @@ -267,6 +274,7 @@ def test_cua_default_config_matches_booter_defaults(): assert sandbox_defaults["cua_image"] == CUA_DEFAULT_CONFIG["image"] assert sandbox_defaults["cua_os_type"] == CUA_DEFAULT_CONFIG["os_type"] assert sandbox_defaults["cua_ttl"] == CUA_DEFAULT_CONFIG["ttl"] + assert sandbox_defaults["cua_idle_timeout"] == 0 assert ( sandbox_defaults["cua_telemetry_enabled"] == CUA_DEFAULT_CONFIG["telemetry_enabled"] @@ -367,6 +375,189 @@ async def test_get_booter_shuts_down_client_when_skill_sync_fails(monkeypatch): assert "cua-sync-fail" not in computer_client.session_booter +@pytest.mark.asyncio +async def test_cua_idle_timeout_shuts_down_session_proactively(monkeypatch): + from astrbot.core.computer import computer_client + + shutdowns = [] + + class FakeCuaBooter: + def __init__(self, **kwargs): + self.kwargs = kwargs + + async def boot(self, session_id: str): + self.session_id = session_id + + async def available(self): + return True + + async def shutdown(self): + shutdowns.append(self.session_id) + + monkeypatch.setattr( + computer_client, "_sync_skills_to_sandbox", lambda booter: asyncio.sleep(0) + ) + monkeypatch.setattr( + "astrbot.core.computer.booters.cua.CuaBooter", + FakeCuaBooter, + raising=False, + ) + _clear_cua_session_state(computer_client, "cua-idle-expire") + + ctx = FakeContext( + { + "provider_settings": { + "computer_use_runtime": "sandbox", + "sandbox": { + "booter": "cua", + "cua_idle_timeout": 0.1, + }, + } + } + ) + + booter = await computer_client.get_booter(ctx, "cua-idle-expire") + await asyncio.sleep(0.2) + + assert shutdowns == [booter.session_id] + assert "cua-idle-expire" not in computer_client.session_booter + + +@pytest.mark.asyncio +async def test_cua_idle_timeout_refreshes_on_reuse(monkeypatch): + from astrbot.core.computer import computer_client + + shutdowns = [] + + class FakeCuaBooter: + def __init__(self, **kwargs): + self.kwargs = kwargs + + async def boot(self, session_id: str): + self.session_id = session_id + + async def available(self): + return True + + async def shutdown(self): + shutdowns.append(self.session_id) + + monkeypatch.setattr( + computer_client, "_sync_skills_to_sandbox", lambda booter: asyncio.sleep(0) + ) + monkeypatch.setattr( + "astrbot.core.computer.booters.cua.CuaBooter", + FakeCuaBooter, + raising=False, + ) + _clear_cua_session_state(computer_client, "cua-idle-refresh") + + ctx = FakeContext( + { + "provider_settings": { + "computer_use_runtime": "sandbox", + "sandbox": { + "booter": "cua", + "cua_idle_timeout": 0.2, + }, + } + } + ) + + booter1 = await computer_client.get_booter(ctx, "cua-idle-refresh") + await asyncio.sleep(0.05) + booter2 = await computer_client.get_booter(ctx, "cua-idle-refresh") + await asyncio.sleep(0.05) + + assert booter2 is booter1 + assert shutdowns == [] + + await asyncio.sleep(0.25) + + assert shutdowns == [booter1.session_id] + assert "cua-idle-refresh" not in computer_client.session_booter + + +@pytest.mark.asyncio +async def test_cua_idle_timeout_zero_disables_proactive_shutdown(monkeypatch): + from astrbot.core.computer import computer_client + + shutdowns = [] + + class FakeCuaBooter: + def __init__(self, **kwargs): + self.kwargs = kwargs + + async def boot(self, session_id: str): + self.session_id = session_id + + async def available(self): + return True + + async def shutdown(self): + shutdowns.append(self.session_id) + + monkeypatch.setattr( + computer_client, "_sync_skills_to_sandbox", lambda booter: asyncio.sleep(0) + ) + monkeypatch.setattr( + "astrbot.core.computer.booters.cua.CuaBooter", + FakeCuaBooter, + raising=False, + ) + _clear_cua_session_state(computer_client, "cua-idle-disabled") + + ctx = FakeContext( + { + "provider_settings": { + "computer_use_runtime": "sandbox", + "sandbox": { + "booter": "cua", + "cua_idle_timeout": 0, + }, + } + } + ) + + await computer_client.get_booter(ctx, "cua-idle-disabled") + await asyncio.sleep(0.05) + + assert shutdowns == [] + assert "cua-idle-disabled" in computer_client.session_booter + assert "cua-idle-disabled" not in computer_client.cua_idle_state + + +@pytest.mark.asyncio +async def test_non_cua_booter_does_not_schedule_idle_cleanup(monkeypatch): + from astrbot.core.computer import computer_client + + class FakeShipyardBooter: + async def available(self): + return True + + _clear_cua_session_state(computer_client, "shipyard-session") + computer_client.session_booter["shipyard-session"] = FakeShipyardBooter() + + ctx = FakeContext( + { + "provider_settings": { + "computer_use_runtime": "sandbox", + "sandbox": { + "booter": "shipyard", + "shipyard_endpoint": "http://localhost:8080", + "shipyard_access_token": "token", + "cua_idle_timeout": 0.01, + }, + } + } + ) + + booter = await computer_client.get_booter(ctx, "shipyard-session") + + assert isinstance(booter, FakeShipyardBooter) + assert "shipyard-session" not in computer_client.cua_idle_state + + @pytest.mark.asyncio async def test_cua_components_map_sdk_results(tmp_path): from astrbot.core.computer.booters.cua import ( From f02845ebdc117159917957268e71d593438f9490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=82=A4=E3=82=AB=E3=82=AF?= <1259085392z@gmail.com> Date: Fri, 8 May 2026 10:08:53 +0900 Subject: [PATCH 03/51] fix(config): expose cua idle timeout in dashboard (#8075) * fix(config): expose cua idle timeout in dashboard * fix(config): remove exposed cua ttl setting * fix(config): centralize cua idle timeout default --- astrbot/core/computer/booters/cua_defaults.py | 1 + astrbot/core/config/default.py | 9 ++++----- .../src/i18n/locales/en-US/features/config-metadata.json | 6 +++--- .../src/i18n/locales/ru-RU/features/config-metadata.json | 6 +++--- .../src/i18n/locales/zh-CN/features/config-metadata.json | 6 +++--- tests/unit/test_cua_computer_use.py | 7 ++++--- 6 files changed, 18 insertions(+), 17 deletions(-) diff --git a/astrbot/core/computer/booters/cua_defaults.py b/astrbot/core/computer/booters/cua_defaults.py index 4c506154a..a36c6e654 100644 --- a/astrbot/core/computer/booters/cua_defaults.py +++ b/astrbot/core/computer/booters/cua_defaults.py @@ -2,6 +2,7 @@ CUA_DEFAULT_CONFIG = { "image": "linux", "os_type": "linux", "ttl": 3600, + "idle_timeout": 0, "telemetry_enabled": False, "local": True, "api_key": "", diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 7d030e88d..8df7ae27b 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -178,8 +178,7 @@ DEFAULT_CONFIG = { "shipyard_neo_ttl": 3600, "cua_image": CUA_DEFAULT_CONFIG["image"], "cua_os_type": CUA_DEFAULT_CONFIG["os_type"], - "cua_ttl": CUA_DEFAULT_CONFIG["ttl"], - "cua_idle_timeout": 0, + "cua_idle_timeout": CUA_DEFAULT_CONFIG["idle_timeout"], "cua_telemetry_enabled": CUA_DEFAULT_CONFIG["telemetry_enabled"], "cua_local": CUA_DEFAULT_CONFIG["local"], "cua_api_key": CUA_DEFAULT_CONFIG["api_key"], @@ -3348,10 +3347,10 @@ CONFIG_METADATA_3 = { "provider_settings.sandbox.booter": "cua", }, }, - "provider_settings.sandbox.cua_ttl": { - "description": "CUA Sandbox TTL", + "provider_settings.sandbox.cua_idle_timeout": { + "description": "CUA Idle Timeout", "type": "int", - "hint": "CUA 沙箱生存时间(秒)。当前作为会话配置保存,具体生效取决于 CUA SDK。", + "hint": "Idle timeout for CUA sandbox sessions in seconds. When greater than 0, AstrBot proactively shuts down an idle CUA sandbox after that amount of inactivity; 0 disables it.", "condition": { "provider_settings.computer_use_runtime": "sandbox", "provider_settings.sandbox.booter": "cua", diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index de4872fc4..bfb46ab6a 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -194,9 +194,9 @@ "description": "CUA OS Type", "hint": "CUA sandbox operating system type. Defaults to linux." }, - "cua_ttl": { - "description": "CUA Sandbox TTL", - "hint": "CUA sandbox time-to-live in seconds. Actual behavior depends on the installed CUA SDK." + "cua_idle_timeout": { + "description": "CUA Idle Timeout", + "hint": "Idle timeout for CUA sandbox sessions in seconds. When greater than 0, AstrBot proactively shuts down an idle CUA sandbox after that amount of inactivity; 0 disables it." }, "cua_telemetry_enabled": { "description": "CUA Telemetry", diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 3b620a514..9f593fc50 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -194,9 +194,9 @@ "description": "Тип ОС CUA", "hint": "Тип операционной системы песочницы CUA. По умолчанию linux." }, - "cua_ttl": { - "description": "TTL песочницы CUA", - "hint": "Время жизни песочницы CUA в секундах. Фактическое поведение зависит от установленного CUA SDK." + "cua_idle_timeout": { + "description": "Таймаут простоя CUA", + "hint": "Таймаут простоя сессии песочницы CUA в секундах. Если значение больше 0, AstrBot автоматически завершит неактивную песочницу CUA после указанного времени бездействия; 0 отключает автоочистку." }, "cua_telemetry_enabled": { "description": "Телеметрия CUA", diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 0f45706cf..59c0104de 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -196,9 +196,9 @@ "description": "CUA 操作系统类型", "hint": "CUA 沙箱操作系统类型,默认 linux。" }, - "cua_ttl": { - "description": "CUA Sandbox 存活时间(秒)", - "hint": "CUA 沙箱生存时间(秒)。当前作为会话配置保存,具体生效取决于 CUA SDK。" + "cua_idle_timeout": { + "description": "CUA 空闲超时(秒)", + "hint": "CUA 沙箱空闲超时时间(秒)。大于 0 时,AstrBot 会在会话空闲达到该时长后主动关闭 CUA 沙箱;0 表示禁用。" }, "cua_telemetry_enabled": { "description": "CUA 遥测", diff --git a/tests/unit/test_cua_computer_use.py b/tests/unit/test_cua_computer_use.py index d7d25df74..3a092146c 100644 --- a/tests/unit/test_cua_computer_use.py +++ b/tests/unit/test_cua_computer_use.py @@ -273,8 +273,8 @@ def test_cua_default_config_matches_booter_defaults(): assert booter.api_key == CUA_DEFAULT_CONFIG["api_key"] assert sandbox_defaults["cua_image"] == CUA_DEFAULT_CONFIG["image"] assert sandbox_defaults["cua_os_type"] == CUA_DEFAULT_CONFIG["os_type"] - assert sandbox_defaults["cua_ttl"] == CUA_DEFAULT_CONFIG["ttl"] - assert sandbox_defaults["cua_idle_timeout"] == 0 + assert "cua_ttl" not in sandbox_defaults + assert sandbox_defaults["cua_idle_timeout"] == CUA_DEFAULT_CONFIG["idle_timeout"] assert ( sandbox_defaults["cua_telemetry_enabled"] == CUA_DEFAULT_CONFIG["telemetry_enabled"] @@ -1383,7 +1383,8 @@ def test_cua_is_exposed_in_sandbox_config_metadata(): assert "CUA" in booter["labels"] assert "provider_settings.sandbox.cua_image" in items assert "provider_settings.sandbox.cua_os_type" in items - assert "provider_settings.sandbox.cua_ttl" in items + assert "provider_settings.sandbox.cua_ttl" not in items + assert "provider_settings.sandbox.cua_idle_timeout" in items assert "provider_settings.sandbox.cua_telemetry_enabled" in items assert "provider_settings.sandbox.cua_local" in items assert "provider_settings.sandbox.cua_api_key" in items From f29b339ea270522b66b5b92448b04985f04087fe Mon Sep 17 00:00:00 2001 From: Ruochen Pan Date: Fri, 8 May 2026 15:30:52 +0800 Subject: [PATCH 04/51] fix(t2i): validate template content to prevent Jinja2 SSTI injection (#8077) * fix(t2i): validate template content to prevent Jinja2 SSTI injection * fix(t2i): add error feedback * fix(test): update assertion to match previous commits * style: format code --- astrbot/core/utils/t2i/template_manager.py | 44 +++++++++++++++++++ .../components/shared/T2ITemplateEditor.vue | 19 +++++--- tests/test_dashboard.py | 8 ++-- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/astrbot/core/utils/t2i/template_manager.py b/astrbot/core/utils/t2i/template_manager.py index c4d72f3e4..f655fc6f8 100644 --- a/astrbot/core/utils/t2i/template_manager.py +++ b/astrbot/core/utils/t2i/template_manager.py @@ -1,10 +1,52 @@ # astrbot/core/utils/t2i/template_manager.py +import logging import os +import re import shutil from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_path +logger = logging.getLogger("astrbot") + +_ALLOWED_VARS = frozenset({"text", "version", "shiki_runtime"}) + +_SSTI_BLACKLIST: list[tuple[str, re.Pattern]] = [ + ( + "dunder_chain", + re.compile( + r"__\s*(class|globals|init|mro|base|bases|subclasses|reduce|getitem|builtins|import|self|func|code|reduce_ex)__" + ), + ), + ( + "dangerous_builtins", + re.compile( + r"\b(import\s+(?!url)|os\.\w+|subprocess\.|\.popen\(|eval\(|exec\()" + ), + ), + ("flask_context", re.compile(r"\{\{.*?\b(config|request|session|g)\b.*?\}\}")), +] + +_VAR_RE = re.compile(r"\{\{\s*(\w+)\s*(\|[^}]*)?\}\}") + + +def validate_template_content(content: str, *, strict: bool = False) -> None: + for label, pattern in _SSTI_BLACKLIST: + if pattern.search(content): + logger.warning(f"SSTI validation blocked template: matched rule [{label}]") + raise ValueError(f"Template contains forbidden pattern ({label}).") + if strict: + for m in _VAR_RE.finditer(content): + var = m.group(1) + if var not in _ALLOWED_VARS: + logger.warning( + f"SSTI validation blocked template: unauthorized variable '{var}'" + ) + raise ValueError( + f"Unauthorized Jinja2 variable '{var}'; " + f"allowed: {', '.join(sorted(_ALLOWED_VARS))}." + ) + class TemplateManager: """负责管理 t2i HTML 模板的 CRUD 和重置操作。 @@ -86,6 +128,7 @@ class TemplateManager: def create_template(self, name: str, content: str) -> None: """在用户目录中创建一个新的模板文件。""" + validate_template_content(content, strict=True) path = self._get_user_template_path(name) if os.path.exists(path): raise FileExistsError("同名模板已存在。") @@ -97,6 +140,7 @@ class TemplateManager: 如果更新的是一个内置模板,此操作实际上会在用户目录中创建一个修改后的副本, 从而实现对内置模板的“覆盖”。 """ + validate_template_content(content, strict=True) path = self._get_user_template_path(name) with open(path, "w", encoding="utf-8") as f: f.write(content) diff --git a/dashboard/src/components/shared/T2ITemplateEditor.vue b/dashboard/src/components/shared/T2ITemplateEditor.vue index 485032dbc..9d67699c3 100644 --- a/dashboard/src/components/shared/T2ITemplateEditor.vue +++ b/dashboard/src/components/shared/T2ITemplateEditor.vue @@ -243,10 +243,12 @@ import { ref, computed, nextTick, watch } from 'vue' import { VueMonacoEditor } from '@guolao/vue-monaco-editor' import { useI18n, useModuleI18n } from '@/i18n/composables' +import { useToast } from '@/utils/toast' import axios from 'axios' const { t } = useI18n() const { tm } = useModuleI18n('core.shared') +const toast = useToast() // --- 响应式数据 --- const dialog = ref(false) @@ -448,8 +450,9 @@ const saveTemplate = async () => { }) } } catch (error) { - console.error('保存模板失败:', error) - // 可以在此添加错误提示 + const msg = error?.response?.data?.message || error?.message || String(error) + console.error('保存模板失败:', msg) + toast.error(msg) } finally { saveLoading.value = false } @@ -461,7 +464,9 @@ const setActiveTemplate = async (name) => { await axios.post('/api/t2i/templates/set_active', { name }) activeTemplate.value = name } catch (error) { - console.error(`应用模板 '${name}' 失败:`, error) + const msg = error?.response?.data?.message || error?.message || String(error) + console.error(`应用模板 '${name}' 失败:`, msg) + toast.error(msg) } finally { applyLoading.value = false } @@ -482,7 +487,9 @@ const confirmDelete = async () => { await loadInitialData() selectedTemplate.value = 'base' } catch (error) { - console.error(`删除模板 '${selectedTemplate.value}' 失败:`, error) + const msg = error?.response?.data?.message || error?.message || String(error) + console.error(`删除模板失败:`, msg) + toast.error(msg) } finally { saveLoading.value = false } @@ -500,7 +507,9 @@ const confirmReset = async () => { await setActiveTemplate('base') } } catch (error) { - console.error('重置模板失败:', error) + const msg = error?.response?.data?.message || error?.message || String(error) + console.error('重置模板失败:', msg) + toast.error(msg) } finally { resetLoading.value = false } diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 016bd58aa..13d5ac0fc 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -1026,7 +1026,7 @@ async def test_t2i_set_active_template_syncs_all_configs( "/api/t2i/templates/create", json={ "name": template_name, - "content": "{{ content }}", + "content": "{{ text }}", }, headers=authenticated_header, ) @@ -1093,7 +1093,7 @@ async def test_t2i_reset_default_template_syncs_all_configs( "/api/t2i/templates/create", json={ "name": template_name, - "content": "{{ content }} reset", + "content": "{{ text }} reset", }, headers=authenticated_header, ) @@ -1166,7 +1166,7 @@ async def test_t2i_update_active_template_reloads_all_schedulers( "/api/t2i/templates/create", json={ "name": template_name, - "content": "{{ content }} v1", + "content": "{{ text }} v1", }, headers=authenticated_header, ) @@ -1187,7 +1187,7 @@ async def test_t2i_update_active_template_reloads_all_schedulers( response = await test_client.put( f"/api/t2i/templates/{template_name}", - json={"content": "{{ content }} v2"}, + json={"content": "{{ text }} v2"}, headers=authenticated_header, ) assert response.status_code == 200 From 77fa0e466c278e792cf295b291860c03523f5ee0 Mon Sep 17 00:00:00 2001 From: AstrBot Date: Fri, 8 May 2026 15:41:56 +0800 Subject: [PATCH 05/51] docs: update Trendshift badge to AstrBotDevs repo (#21369) for all README languages (#8079) Co-authored-by: AstrBot --- README.md | 2 +- README_fr.md | 2 +- README_ja.md | 2 +- README_ru.md | 2 +- README_zh-TW.md | 2 +- README_zh.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b566b454d..3a6b56554 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@
-Soulter%2FAstrBot | Trendshift +AstrBotDevs%2FAstrBot | Trendshift Featured|HelloGitHub
diff --git a/README_fr.md b/README_fr.md index 4b9e978d4..a77c65721 100644 --- a/README_fr.md +++ b/README_fr.md @@ -11,7 +11,7 @@
-Soulter%2FAstrBot | Trendshift +AstrBotDevs%2FAstrBot | Trendshift Featured|HelloGitHub
diff --git a/README_ja.md b/README_ja.md index 849fab695..c78b57106 100644 --- a/README_ja.md +++ b/README_ja.md @@ -11,7 +11,7 @@
-Soulter%2FAstrBot | Trendshift +AstrBotDevs%2FAstrBot | Trendshift Featured|HelloGitHub
diff --git a/README_ru.md b/README_ru.md index 044120320..476ff6d7c 100644 --- a/README_ru.md +++ b/README_ru.md @@ -11,7 +11,7 @@
-Soulter%2FAstrBot | Trendshift +AstrBotDevs%2FAstrBot | Trendshift Featured|HelloGitHub
diff --git a/README_zh-TW.md b/README_zh-TW.md index 2aaedfabb..fdeedfbc8 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -11,7 +11,7 @@
-Soulter%2FAstrBot | Trendshift +AstrBotDevs%2FAstrBot | Trendshift Featured|HelloGitHub
diff --git a/README_zh.md b/README_zh.md index 9b36e4af2..425719fab 100644 --- a/README_zh.md +++ b/README_zh.md @@ -9,7 +9,7 @@ Русский
-Soulter%2FAstrBot | Trendshift +AstrBotDevs%2FAstrBot | Trendshift Featured|HelloGitHub
From f9cbe790994e4108d38e3b84fb3887a6fe28988a Mon Sep 17 00:00:00 2001 From: M1LKT <144798909+M1LKT@users.noreply.github.com> Date: Sat, 9 May 2026 08:41:26 +0800 Subject: [PATCH 06/51] fix(ui): always show actions btn instead of on hover in OutlinedActionListItem (#8081) --- dashboard/src/components/shared/OutlinedActionListItem.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/dashboard/src/components/shared/OutlinedActionListItem.vue b/dashboard/src/components/shared/OutlinedActionListItem.vue index 7e2f9047c..d9de46f0a 100644 --- a/dashboard/src/components/shared/OutlinedActionListItem.vue +++ b/dashboard/src/components/shared/OutlinedActionListItem.vue @@ -118,7 +118,6 @@ const handleClick = (event) => { align-items: center; display: flex; gap: 4px; - opacity: 0; pointer-events: none; transition: opacity 0.16s ease; } From 224915fbc883698ce8f9d979054e2cf5f9f43468 Mon Sep 17 00:00:00 2001 From: AstrBot Date: Sat, 9 May 2026 22:00:15 +0800 Subject: [PATCH 07/51] docs: add 16MB size limit note for plugin publishing (#8108) Add documentation to clarify the 16MB zip size limit for plugin marketplace submissions, along with practical recommendations: - Compress static assets like images - Clean up unnecessary files (.git, __pycache__, etc.) - Optimize dependency sizes - Use .gitattributes or release branches Also mention the option to contact maintainers for manual bypass when the limit cannot be met. Co-authored-by: Seio --- docs/en/dev/star/plugin-publish.md | 11 +++++++++++ docs/zh/dev/star/plugin-publish.md | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/docs/en/dev/star/plugin-publish.md b/docs/en/dev/star/plugin-publish.md index 29864fd32..4bb2a68e9 100644 --- a/docs/en/dev/star/plugin-publish.md +++ b/docs/en/dev/star/plugin-publish.md @@ -7,3 +7,14 @@ AstrBot uses GitHub to host plugins, so you'll need to push your plugin code to You can submit your plugin by visiting the [AstrBot Plugin Marketplace](https://plugins.astrbot.app). Once on the website, click the `+` button in the bottom-right corner, fill in the basic information, author details, repository information, and other required fields. Then click the `Submit to GITHUB` button. You will be redirected to the AstrBot repository's Issue submission page. Please verify that all information is correct, then click the `Create` button to complete the plugin publication process. ![fill out the form](https://files.astrbot.app/docs/source/images/plugin-publish/image.png) + +> ⚠️ **Size Limit**: The plugin zip package submitted to the marketplace **must not exceed 16MB**. If it exceeds this limit, the CI/CD pipeline will automatically reject the submission. +> +> To ensure your plugin passes review and publication smoothly, we recommend the following: +> +> - **Compress static assets**: Compress images, audio, and other resource files in your plugin to reduce their size. +> - **Clean up unnecessary files**: Avoid including directories like `.git`, `__pycache__`, `node_modules`, or development configuration files in your plugin repository. Add a `.gitignore` file to your repository root to exclude them. +> - **Optimize dependency size**: If your plugin depends on large libraries, consider trimming them down or importing only what is needed. +> - **Use `.gitattributes` or a release branch**: Reduce the zip package size by including only the files necessary for distribution. +> +> If your plugin truly cannot be compressed to under 16MB due to business requirements, please contact the maintainer to manually bypass this limit. diff --git a/docs/zh/dev/star/plugin-publish.md b/docs/zh/dev/star/plugin-publish.md index 45997acc5..57f2f0a38 100644 --- a/docs/zh/dev/star/plugin-publish.md +++ b/docs/zh/dev/star/plugin-publish.md @@ -7,3 +7,14 @@ AstrBot 使用 GitHub 托管插件,因此你需要先将插件代码推送到 你可以前往 [AstrBot 插件市场](https://plugins.astrbot.app) 提交你的插件。进入该网站后,点击右下角的 `+` 按钮,填写好基本信息、作者信息、仓库信息等内容后,点击 `提交到 GITHUB` 按钮,你将会被导航到 AstrBot 仓库的 Issue 提交页面,请确认信息无误后点击 `Create` 按钮提交,即可完成插件发布。 ![fill out the form](https://files.astrbot.app/docs/source/images/plugin-publish/image.png) + +> ⚠️ **大小限制**:发布到插件市场的插件压缩包(zip)大小**不得超过 16MB**。如果超过此限制,CI/CD 流水线将自动拒绝该发布请求。 +> +> 为确保你的插件能顺利通过审核和发布,建议采取以下措施: +> +> - **压缩图片等静态资源**:对插件中的图片、音频等资源文件进行压缩,减小体积。 +> - **清理不必要的文件**:避免将 `.git` 目录、`__pycache__`、`node_modules`、开发用配置文件等非必需文件提交到插件仓库中。建议在仓库根目录添加 `.gitignore` 来排除它们。 +> - **优化依赖体积**:如果插件包含体积较大的依赖库,可考虑精简或按需引入。 +> - **使用 `.gitattributes` 或发布分支**:通过只包含发布所需文件的策略来减小 zip 包体积。 +> +> 如果插件确实因业务需要无法压缩到 16MB 以内,可以联系维护者手动 bypass 此限制。 From 4bcaaab44f42698cf58d5480195bb949f72df4e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 22:16:30 +0800 Subject: [PATCH 08/51] chore(deps): bump pnpm/action-setup in the github-actions group (#8004) Bumps the github-actions group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup). Updates `pnpm/action-setup` from 6.0.3 to 6.0.5 - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/v6.0.3...v6.0.5) --- updated-dependencies: - dependency-name: pnpm/action-setup dependency-version: 6.0.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-docs.yml | 2 +- .github/workflows/dashboard_ci.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index ed25cd058..8074558b8 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -13,7 +13,7 @@ jobs: - name: checkout uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v6.0.3 + uses: pnpm/action-setup@v6.0.5 with: version: 10.28.2 - name: Setup Node.js diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml index 58d8896ec..8934697d8 100644 --- a/.github/workflows/dashboard_ci.yml +++ b/.github/workflows/dashboard_ci.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v6.0.3 + uses: pnpm/action-setup@v6.0.5 with: version: 10.28.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1765bf75d..04ebadf2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Setup pnpm - uses: pnpm/action-setup@v6.0.3 + uses: pnpm/action-setup@v6.0.5 with: version: 10.28.2 From bd9aade842e85e8f4dcd829f95ab8ee2c75dcd74 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Sat, 9 May 2026 22:17:47 +0800 Subject: [PATCH 09/51] =?UTF-8?q?fix(docs):=E5=A4=9A=E4=BB=BD=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=B1=89=E8=AF=91=E8=8B=B1=E5=B9=B6=E6=95=B4=E7=90=86?= =?UTF-8?q?=20(#8001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(en): translate plugin-platform-adapter.md from Chinese to English * docs(en): translate plugin-platform-adapter.md from Chinese to English * Update ppio.md * Update provider-lmstudio.md * Update function-calling.md * Update skills.md * Update ai.md * Update simple.md * Update mcp.md * Update config.mjs kook * fix(docs): fix MessageSesion import path in platform adapter example Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/.vitepress/config.mjs | 2 +- docs/en/dev/plugin-platform-adapter.md | 99 +++++++++++++------------- docs/en/dev/star/guides/ai.md | 4 +- docs/en/dev/star/guides/simple.md | 16 ----- docs/en/providers/302ai.md | 22 +++--- docs/en/providers/ppio.md | 36 +++++----- docs/en/providers/provider-lmstudio.md | 37 +++++----- docs/en/use/function-calling.md | 2 +- docs/en/use/mcp.md | 2 +- docs/en/use/skills.md | 2 +- 10 files changed, 102 insertions(+), 120 deletions(-) diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index cad68ec06..458bc8dab 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -343,6 +343,7 @@ export default defineConfig({ { text: "Mattermost", link: "/mattermost" }, { text: "Misskey", link: "/misskey" }, { text: "Discord", link: "/discord" }, + { text: "KOOK", link: "/kook" }, { text: "Satori", base: "/en/platform/satori", @@ -357,7 +358,6 @@ export default defineConfig({ collapsed: false, items: [ { text: "Matrix", link: "/matrix" }, - { text: "KOOK", link: "/kook" }, { text: "VoceChat", link: "/vocechat" }, ], }, diff --git a/docs/en/dev/plugin-platform-adapter.md b/docs/en/dev/plugin-platform-adapter.md index 8e65528e2..4772b6e18 100644 --- a/docs/en/dev/plugin-platform-adapter.md +++ b/docs/en/dev/plugin-platform-adapter.md @@ -2,23 +2,23 @@ outline: deep --- -# 开发一个平台适配器 +# Developing a Platform Adapter -AstrBot 支持以插件的形式接入平台适配器,你可以自行接入 AstrBot 没有的平台。如飞书、钉钉甚至是哔哩哔哩私信、Minecraft。 +AstrBot supports integrating platform adapters in plugin form, allowing you to connect platforms that AstrBot does not natively support — such as Lark, DingTalk, Bilibili private messages, or even Minecraft. -我们以一个平台 `FakePlatform` 为例展开讲解。 +We will use a platform called `FakePlatform` as an example. -首先,在插件目录下新增 `fake_platform_adapter.py` 和 `fake_platform_event.py` 文件。前者主要是平台适配器的实现,后者是平台事件的定义。 +First, add `fake_platform_adapter.py` and `fake_platform_event.py` to your plugin directory. The former handles the platform adapter implementation, while the latter defines the platform event. -## 平台适配器 +## Platform Adapter -假设 FakePlatform 的客户端 SDK 是这样: +Assume FakePlatform's client SDK looks like this: ```py import asyncio class FakeClient(): - '''模拟一个消息平台,这里 5 秒钟下发一个消息''' + '''Simulates a messaging platform that sends a message every 5 seconds''' def __init__(self, token: str, username: str): self.token = token self.username = username @@ -29,7 +29,7 @@ class FakeClient(): await asyncio.sleep(5) await getattr(self, 'on_message_received')({ 'bot_id': '123', - 'content': '新消息', + 'content': 'new message', 'username': 'zhangsan', 'userid': '123', 'message_id': 'asdhoashd', @@ -37,28 +37,28 @@ class FakeClient(): }) async def send_text(self, to: str, message: str): - print('发了消息:', to, message) + print('Message sent:', to, message) async def send_image(self, to: str, image_path: str): - print('发了消息:', to, image_path) + print('Image sent:', to, image_path) ``` -我们创建 `fake_platform_adapter.py`: +Now create `fake_platform_adapter.py`: ```py import asyncio from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, PlatformMetadata, MessageType from astrbot.api.event import MessageChain -from astrbot.api.message_components import Plain, Image, Record # 消息链中的组件,可以根据需要导入 -from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.api.message_components import Plain, Image, Record # Message chain components, import as needed +from astrbot.core.platform.message_session import MessageSesion from astrbot.api.platform import register_platform_adapter from astrbot import logger from .client import FakeClient from .fake_platform_event import FakePlatformEvent -# 注册平台适配器。第一个参数为平台名,第二个为描述。第三个为默认配置。 -@register_platform_adapter("fake", "fake 适配器", default_config_tmpl={ +# Register the platform adapter. First param: platform name, second: description, third: default config. +@register_platform_adapter("fake", "fake adapter", default_config_tmpl={ "token": "your_token", "username": "bot_username" }) @@ -66,52 +66,53 @@ class FakePlatformAdapter(Platform): def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None: super().__init__(event_queue) - self.config = platform_config # 上面的默认配置,用户填写后会传到这里 - self.settings = platform_settings # platform_settings 平台设置。 + self.config = platform_config # The default config above; filled in by the user and passed here + self.settings = platform_settings # platform_settings: platform settings async def send_by_session(self, session: MessageSesion, message_chain: MessageChain): - # 必须实现 + # Must be implemented await super().send_by_session(session, message_chain) def meta(self) -> PlatformMetadata: - # 必须实现,直接像下面一样返回即可。 + # Must be implemented. Simply return as shown below. return PlatformMetadata( "fake", - "fake 适配器", + "fake adapter", ) async def run(self): - # 必须实现,这里是主要逻辑。 + # Must be implemented. This is the main logic. - # FakeClient 是我们自己定义的,这里只是示例。这个是其回调函数 + # FakeClient is defined by us — this is just an example. This is its callback function. async def on_received(data): logger.info(data) - abm = await self.convert_message(data=data) # 转换成 AstrBotMessage + abm = await self.convert_message(data=data) # Convert to AstrBotMessage await self.handle_msg(abm) - # 初始化 FakeClient + # Initialize FakeClient self.client = FakeClient(self.config['token'], self.config['username']) self.client.on_message_received = on_received - await self.client.start_polling() # 持续监听消息,这是个堵塞方法。 + await self.client.start_polling() # Continuously listens for messages; this is a blocking call. async def convert_message(self, data: dict) -> AstrBotMessage: - # 将平台消息转换成 AstrBotMessage - # 这里就体现了适配程度,不同平台的消息结构不一样,这里需要根据实际情况进行转换。 + # Convert the platform message to AstrBotMessage. + # The degree of adaptation is reflected here. Different platforms have different message + # structures; convert accordingly. abm = AstrBotMessage() - abm.type = MessageType.GROUP_MESSAGE # 还有 friend_message,对应私聊。具体平台具体分析。重要! - abm.group_id = data['group_id'] # 如果是私聊,这里可以不填 - abm.message_str = data['content'] # 纯文本消息。重要! - abm.sender = MessageMember(user_id=data['userid'], nickname=data['username']) # 发送者。重要! - abm.message = [Plain(text=data['content'])] # 消息链。如果有其他类型的消息,直接 append 即可。重要! - abm.raw_message = data # 原始消息。 + abm.type = MessageType.GROUP_MESSAGE # Also friend_message for private chats. Analyze per platform. Important! + abm.group_id = data['group_id'] # Can be omitted for private chats + abm.message_str = data['content'] # Plain text message. Important! + abm.sender = MessageMember(user_id=data['userid'], nickname=data['username']) # Sender. Important! + abm.message = [Plain(text=data['content'])] # Message chain. Append other message types as needed. Important! + abm.raw_message = data # Raw message. abm.self_id = data['bot_id'] - abm.session_id = data['userid'] # 会话 ID。重要! - abm.message_id = data['message_id'] # 消息 ID。 + abm.session_id = data['userid'] # Session ID. Important! + abm.message_id = data['message_id'] # Message ID. return abm async def handle_msg(self, message: AstrBotMessage): - # 处理消息 + # Handle the message message_event = FakePlatformEvent( message_str=message.message_str, message_obj=message, @@ -119,11 +120,11 @@ class FakePlatformAdapter(Platform): session_id=message.session_id, client=self.client ) - self.commit_event(message_event) # 提交事件到事件队列。不要忘记! + self.commit_event(message_event) # Submit the event to the event queue. Don't forget this! ``` -`fake_platform_event.py`: +`fake_platform_event.py`: ```py from astrbot.api.event import AstrMessageEvent, MessageChain @@ -138,13 +139,13 @@ class FakePlatformEvent(AstrMessageEvent): self.client = client async def send(self, message: MessageChain): - for i in message.chain: # 遍历消息链 - if isinstance(i, Plain): # 如果是文字类型的 + for i in message.chain: # Iterate over the message chain + if isinstance(i, Plain): # If it's a text message await self.client.send_text(to=self.get_sender_id(), message=i.text) - elif isinstance(i, Image): # 如果是图片类型的 + elif isinstance(i, Image): # If it's an image img_url = i.file img_path = "" - # 下面的三个条件可以直接参考一下。 + # The three conditions below can be used as a reference. if img_url.startswith("file:///"): img_path = img_url[8:] elif i.file and i.file.startswith("http"): @@ -152,14 +153,14 @@ class FakePlatformEvent(AstrMessageEvent): else: img_path = img_url - # 请善于 Debug! + # Make good use of debugging! await self.client.send_image(to=self.get_sender_id(), image_path=img_path) - await super().send(message) # 需要最后加上这一段,执行父类的 send 方法。 + await super().send(message) # Must be called at the end to invoke the parent class's send method. ``` -最后,main.py 只需这样,在初始化的时候导入 fake_platform_adapter 模块。装饰器会自动注册。 +Finally, in `main.py`, simply import the `fake_platform_adapter` module during initialization. The decorator will handle registration automatically. ```py from astrbot.api.star import Context, Star @@ -169,17 +170,17 @@ class MyPlugin(Star): from .fake_platform_adapter import FakePlatformAdapter # noqa ``` -搞好后,运行 AstrBot: +Once set up, run AstrBot: ![image](https://files.astrbot.app/docs/source/images/plugin-platform-adapter/QQ_1738155926221.png) -这里出现了我们创建的 fake。 +The `fake` adapter we created now appears here. ![image](https://files.astrbot.app/docs/source/images/plugin-platform-adapter/QQ_1738155982211.png) -启动后,可以看到正常工作: +After starting, you can see it working correctly: ![image](https://files.astrbot.app/docs/source/images/plugin-platform-adapter/QQ_1738156166893.png) -有任何疑问欢迎加群询问~ \ No newline at end of file +If you have any questions, feel free to join the community group and ask~ diff --git a/docs/en/dev/star/guides/ai.md b/docs/en/dev/star/guides/ai.md index 361ac55c4..8e09da134 100644 --- a/docs/en/dev/star/guides/ai.md +++ b/docs/en/dev/star/guides/ai.md @@ -257,7 +257,7 @@ curr_cid = await conv_mgr.get_curr_conversation_id(uid) conversation = await conv_mgr.get_conversation(uid, curr_cid) # Conversation ``` -::: details Conversation 类型定义 +::: details Conversation Type Definition ```py @dataclass @@ -438,7 +438,7 @@ persona_mgr = self.context.persona_manager - **Returns** `Personality` – Default persona object in v3 format -::: details Persona / Personality 类型定义 +::: details Persona / Personality Type Definition ```py diff --git a/docs/en/dev/star/guides/simple.md b/docs/en/dev/star/guides/simple.md index f2dc11be9..d7ed40f95 100644 --- a/docs/en/dev/star/guides/simple.md +++ b/docs/en/dev/star/guides/simple.md @@ -40,19 +40,3 @@ Explanation: All handler functions must be written within the plugin class. To keep content concise, in subsequent sections, we may omit the plugin class definition. ``` - -解释如下: - -- 插件需要继承 `Star` 类。 -- `Context` 类用于插件与 AstrBot Core 交互,可以由此调用 AstrBot Core 提供的各种 API。 -- 具体的处理函数 `Handler` 在插件类中定义,如这里的 `helloworld` 函数。 -- `AstrMessageEvent` 是 AstrBot 的消息事件对象,存储了消息发送者、消息内容等信息。 -- `AstrBotMessage` 是 AstrBot 的消息对象,存储了消息平台下发的消息的具体内容。可以通过 `event.message_obj` 获取。 - -> [!TIP] -> -> `Handler` 一定需要在插件类中注册,前两个参数必须为 `self` 和 `event`。如果文件行数过长,可以将服务写在外部,然后在 `Handler` 中调用。 -> -> 插件类所在的文件名需要命名为 `main.py`。 - -所有的处理函数都需写在插件类中。为了精简内容,在之后的章节中,我们可能会忽略插件类的定义。 diff --git a/docs/en/providers/302ai.md b/docs/en/providers/302ai.md index 50a3abe77..8685e3b05 100644 --- a/docs/en/providers/302ai.md +++ b/docs/en/providers/302ai.md @@ -1,21 +1,21 @@ -# 接入 302.AI +# Connect 302.AI -302.AI 是企业级 AI 应用平台,支持快捷接入全球各类 AI 模型。 +[302.AI](https://302.ai) is an enterprise-grade AI application platform that provides quick access to a wide range of AI models worldwide. -## 使用 +## Getting Started -点击[此链接](https://share.302.ai/rr1M3l) 注册账户。 +Click [this link](https://share.302.ai/rr1M3l) to register an account. -注册完毕之后,点击[此链接](https://302.ai/apis/)选择需要接入的模型。 +After registering, click [this link](https://302.ai/apis/) to select the model you want to use. -根据需求,进入[此链接](https://dash.302.ai/charge) 充值对应的金额。 +If needed, visit [this link](https://dash.302.ai/charge) to top up your account balance. -## 接入 +## Connect -打开 AstrBot 控制台 -> 服务提供商页面,点击新增提供商,找到并点击 `302.AI`(需要版本 >= 3.5.18) +Open the AstrBot dashboard → Service Providers page, click **Add Provider**, find and click `302.AI` (requires version >= 3.5.18). -修改 ID,并将 API Key 和模型名称填入对话框表单,点击保存,即可完成创建。 +Set an ID, fill in the API Key and model name in the dialog form, then click **Save** to complete the setup. -## 使用 +## Usage -对机器人输入 `/provider` 指令,将提供商切换到刚刚添加的 302.AI 提供商,即可使用。 +Send the `/provider` command to the bot to switch to the 302.AI provider you just added. diff --git a/docs/en/providers/ppio.md b/docs/en/providers/ppio.md index fceb61dd1..db9ed03b4 100644 --- a/docs/en/providers/ppio.md +++ b/docs/en/providers/ppio.md @@ -1,43 +1,41 @@ -# 接入 PPIO 派欧云 +# Connect PPIO Cloud -PPIO 派欧云是中国领先的独立分布式云计算服务商,您可以在派欧云上使用稳定、低价甚至免费的模型服务。 +PPIO Cloud is a leading independent distributed cloud computing provider in China, offering stable, affordable, and even free model services. -## 准备 +## Preparation -打开 [PPIO 派欧云官网](https://ppio.cn/user/register?invited_by=AIOONE),并注册账户(通过此链接注册的账户将会获得 15 元人民币的代金券)。 +Open the [PPIO Cloud website](https://ppio.cn/user/register?invited_by=AIOONE) and register an account (accounts registered through this link will receive a ¥15 voucher). -进入 [模型 API 服务](https://ppio.cn/model-api/console),找到你想接入的模型。你可以通过筛选器选择不同厂商或者免费的模型。 +Go to [Model API Service](https://ppio.cn/model-api/console) and find the model you want to use. You can filter by provider or select free models. ![image](https://files.astrbot.app/docs/source/images/ppio/image-1.png) -找到你想要接入的模型后,点击模型卡片,侧边会展开一个模型详情卡片,找到下方的 API 接入指南,如果您还没创建过 Key 可以点击创建。 +Once you find the model, click its card to expand a detail panel on the right. Scroll down to the API integration guide — if you haven't created a key yet, click to create one. ![image](https://files.astrbot.app/docs/source/images/ppio/image-3.png) -打开 AstrBot 控制台 -> 服务提供商页面,点击新增提供商,找到并点击 `PPIO派欧云`(需要版本 >= 3.5.10,旧版本也可使用,见下文)。 +Open the AstrBot dashboard → Service Providers page, click **Add Provider**, find and click `PPIO Cloud` (requires version >= 3.5.10; older versions are also supported, see below). ![image](https://files.astrbot.app/docs/source/images/ppio/image.png) -将 API Key 和模型名称填入对话框表单,点击保存,即可完成创建。 +Fill in the API Key and model name in the dialog form, then click **Save** to complete the setup. > [!TIP] -> 如果您是 AstrBot 旧版本(< 3.5.10)的用户,请打开 AstrBot 控制台 -> 服务提供商页面,点击新增提供商,找到 `OpenAI`,点击进入。 -> 1. 将 ID 命名为 `ppio`(随意) -> 2. 然后将 `API Base URL` 设置为 `https://api.ppinfra.com/v3/openai` -> 3. 然后将 API Key 和模型名称填入对话框表单,点击保存,即可完成创建。 +> If you are using an older version of AstrBot (< 3.5.10), open the AstrBot dashboard → Service Providers page, click **Add Provider**, find `OpenAI`, and click to enter. +> 1. Set the ID to `ppio` (any name works) +> 2. Set `API Base URL` to `https://api.ppinfra.com/v3/openai` +> 3. Fill in the API Key and model name in the dialog form, then click **Save** to complete the setup. +## Usage -## 使用 +Send the `/provider` command to the bot to switch to the PPIO Cloud provider you just added. -对机器人输入 `/provider` 指令,将提供商切换到刚刚添加的 PPIO 派欧云提供商,即可使用。 +## FAQ -## 常见问题 - -#### 显示 `400` 错误 +#### `400` Error ```log Error code: 400 - {'code': 400, 'message': '"auto" tool choice requires --enable-auto-tool-choice and --tool-call-parser to be set', 'type': 'BadRequestError'} ``` - -请暂时使用 `/tool off_all` 禁用所有的函数调用工具即可使用,或者换用其他模型。 \ No newline at end of file +Temporarily disable all function calling tools with `/tool off_all`, or switch to a different model. diff --git a/docs/en/providers/provider-lmstudio.md b/docs/en/providers/provider-lmstudio.md index 969f7d7c8..4c0d99a7c 100644 --- a/docs/en/providers/provider-lmstudio.md +++ b/docs/en/providers/provider-lmstudio.md @@ -1,38 +1,37 @@ -# 接入 LM Studio 使用 DeepSeek-R1 等模型 +# Connect LM Studio to Use DeepSeek-R1 and Other Models -LMStudio 允许在本地电脑上部署模型(需要电脑硬件配置符合要求) +LM Studio allows you to deploy models locally on your computer (hardware requirements must be met). -### 下载并安装 LMStudio +### Download and Install LM Studio -https://lmstudio.ai/download + -### 下载并运行模型 +### Download and Run a Model -https://lmstudio.ai/models + -跟随 LMStudio 下载并运行想要的模型,如 deepseek-r1-qwen-7b: +Follow the LM Studio instructions to download and run your desired model, e.g. `deepseek-r1-qwen-7b`: ```bash lms get deepseek-r1-qwen-7b ``` -### 配置 AstrBot +### Configure AstrBot -在 AstrBot 上: +In AstrBot: -点击 配置->服务提供商配置->加号->openai +Go to **Configuration → Service Providers → + → OpenAI** -API Base URL 填写 `http://localhost:1234/v1` +Set `API Base URL` to `http://localhost:1234/v1` -API Key 填写 `lm-studio` +Set `API Key` to `lm-studio` -> 对于 Mac/Windows 使用 Docker Desktop 部署 AstrBot 部署的用户,API Base URL 请填写为 `http://host.docker.internal:1234/v1`。 -> 对于 Linux 使用 Docker 部署 AstrBot 部署的用户,API Base URL 请填写为 `http://172.17.0.1:1234/v1`,或者将 `172.17.0.1` 替换为你的公网 IP IP(确保宿主机系统放行了 1234 端口)。 +> For users deploying AstrBot via Docker Desktop on Mac or Windows, set `API Base URL` to `http://host.docker.internal:1234/v1`. +> +> For users deploying AstrBot via Docker on Linux, set `API Base URL` to `http://172.17.0.1:1234/v1`, or replace `172.17.0.1` with your server's public IP (make sure port 1234 is open on the host). -如果 LM Studio 使用了 Docker 部署,请确保 1234 端口已经映射到宿主机。 +If LM Studio itself is deployed in Docker, ensure port 1234 is mapped to the host. -模型名填写上一步选好的 +Set the model name to the one you selected in the previous step, then save the configuration. -保存配置即可。 - -> 输入 /provider 查看 AstrBot 配置的模型 \ No newline at end of file +> Run `/provider` to view the models configured in AstrBot. diff --git a/docs/en/use/function-calling.md b/docs/en/use/function-calling.md index 1526cbab9..2208ddd11 100644 --- a/docs/en/use/function-calling.md +++ b/docs/en/use/function-calling.md @@ -51,4 +51,4 @@ Below are some common tool calling demos: ## MCP -Please refer to this documentation: [AstrBot - MCP](/use/mcp). +Please refer to this documentation: [AstrBot - MCP](/en/use/mcp). diff --git a/docs/en/use/mcp.md b/docs/en/use/mcp.md index 1681d3132..a2b95f562 100644 --- a/docs/en/use/mcp.md +++ b/docs/en/use/mcp.md @@ -99,4 +99,4 @@ That's it. Reference links: 1. Learn how to use MCP here: [Model Context Protocol](https://modelcontextprotocol.io/introduction) -2. Get commonly used MCP servers here: [awesome-mcp-servers](https://github.com/punkpeye/awesome-mcp-servers/blob/main/README-zh.md#what-is-mcp), [Model Context Protocol servers](https://github.com/modelcontextprotocol/servers), [MCP.so](https://mcp.so) +2. Get commonly used MCP servers here: [awesome-mcp-servers](https://github.com/punkpeye/awesome-mcp-servers/blob/main/README.md#what-is-mcp), [Model Context Protocol servers](https://github.com/modelcontextprotocol/servers), [MCP.so](https://mcp.so) diff --git a/docs/en/use/skills.md b/docs/en/use/skills.md index a57a5b12c..4221cc047 100644 --- a/docs/en/use/skills.md +++ b/docs/en/use/skills.md @@ -20,7 +20,7 @@ You can upload Skills with the following requirements: 1. The upload must be a `.zip` archive. 2. **After extraction, it must contain a single Skill folder. The folder name will be used as the identifier for the Skill in AstrBot—please name it using English characters.** -3. The Skill folder must include a file named `SKILL.md`, and its contents should preferably follow the Anthropic Skills specification. You can refer to Anthropic's documentation: https://code.claude.com/docs/zh-CN/skills +3. The Skill folder must include a file named `SKILL.md`, and its contents should preferably follow the Anthropic Skills specification. You can refer to Anthropic's documentation: https://code.claude.com/docs/en/skills ## Using Skills in AstrBot From c9182c27a2e0a3404da112d6124a912340820b88 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Sat, 9 May 2026 22:18:21 +0800 Subject: [PATCH 10/51] fix: fix console log level alignment and mobile layout issue (#7988) * fix: fix console log level alignment and mobile layout issue * Update ConsoleDisplayer.vue --- .../src/components/shared/ConsoleDisplayer.vue | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/dashboard/src/components/shared/ConsoleDisplayer.vue b/dashboard/src/components/shared/ConsoleDisplayer.vue index 8789925ca..6c69ae66f 100644 --- a/dashboard/src/components/shared/ConsoleDisplayer.vue +++ b/dashboard/src/components/shared/ConsoleDisplayer.vue @@ -362,6 +362,7 @@ export default { border-radius: 8px; height: 100%; overflow-y: auto; + overflow-x: auto; padding: 16px; } @@ -379,7 +380,7 @@ export default { :deep(.console-log-line--structured) { display: grid; - grid-template-columns: max-content 10ch minmax(0, 1fr); + grid-template-columns: max-content max-content minmax(0, 1fr); column-gap: 8px; align-items: start; white-space: normal; @@ -400,6 +401,16 @@ export default { overflow-wrap: anywhere; } +@media (max-width: 768px) { + :deep(.console-log-line--structured) { + grid-template-columns: 1fr; + } + :deep(.console-log-prefix:empty), + :deep(.console-log-level:empty) { + display: none; + } +} + :deep(.fade-in) { animation: fadeIn 0.3s; } From ad516950f2fe9b0ff154ee97109f7da19c223759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=A8=E3=82=A4=E3=82=AB=E3=82=AF?= <1259085392z@gmail.com> Date: Sun, 10 May 2026 00:20:36 +0900 Subject: [PATCH 11/51] fix(provider): force Gemini chat client to use managed httpx client (#8112) When both aiohttp and httpx are installed, google-genai prefers aiohttp as the async HTTP backend. In error response paths, the aiohttp backend returns raw aiohttp.ClientResponse objects that google-genai cannot handle, masking real API errors with: Unsupported response type: This fix explicitly creates an httpx.AsyncClient and passes it via HttpOptions.httpx_async_client, ensuring the chat provider always uses the httpx backend. The managed client is closed in terminate(). - Preserve HTTP_PROXY/HTTPS_PROXY support via trust_env=True. - Preserve provider-level proxy via httpx.AsyncClient(proxy=...). - Avoid logging full proxy URLs for security. Fixes #7564 --- .../core/provider/sources/gemini_source.py | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index a942c56e4..f38fcfc35 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Literal, cast from urllib.parse import urlparse +import httpx from google import genai from google.genai import types from google.genai.errors import APIError @@ -75,6 +76,8 @@ class ProviderGoogleGenAI(Provider): if self.api_base and self.api_base.endswith("/"): self.api_base = self.api_base[:-1] + self._http_client: httpx.AsyncClient | None = None + self._stale_http_clients: list[httpx.AsyncClient] = [] self._init_client() self.set_model(provider_config.get("model", "unknown")) self._init_safety_settings() @@ -86,9 +89,29 @@ class ProviderGoogleGenAI(Provider): base_url=self.api_base, timeout=self.timeout * 1000, # 毫秒 ) + + # 强制使用 httpx 作为异步 HTTP 后端,避免 aiohttp 响应类型兼容问题 (#7564) + # httpx.AsyncClient 的 timeout 单位为秒(与 HttpOptions 的毫秒不同) + async_client_kwargs: dict = { + "base_url": self.api_base, + "timeout": self.timeout, + } if proxy: - http_options.async_client_args = {"proxy": proxy} - logger.info(f"[Gemini] 使用代理: {proxy}") + async_client_kwargs["proxy"] = proxy + async_client_kwargs["trust_env"] = False + logger.info("[Gemini] 使用代理") + else: + async_client_kwargs["trust_env"] = True + + # Track the previous client so it can be closed in terminate() instead + # of leaking when _init_client is called again (e.g. via set_key). + # Only the most recent stale client is kept to avoid unbounded growth. + if self._http_client is not None: + self._stale_http_clients = [self._http_client] + + self._http_client = httpx.AsyncClient(**async_client_kwargs) + http_options.httpx_async_client = self._http_client + self.client = genai.Client( api_key=self.chosen_api_key, http_options=http_options, @@ -1067,6 +1090,30 @@ class ProviderGoogleGenAI(Provider): image_bs64 = base64.b64encode(f.read()).decode("utf-8") return "data:image/jpeg;base64," + image_bs64 + async def _close_httpx_client(self, client: httpx.AsyncClient | None) -> None: + """Safely close an httpx.AsyncClient, swallowing errors for idempotency.""" + if client is None: + return + try: + await client.aclose() + except Exception as e: + # Idempotent: ignore errors from already-closed or broken clients, + # but log at debug to aid diagnosing unexpected shutdown issues. + logger.debug(f"[Gemini] Ignored error while closing httpx client: {e}") + async def terminate(self) -> None: - if self.client: - await self.client.aclose() + # Close the active Gemini client (external httpx client is managed + # separately so genai.Client.aclose skips it). + if self.client is not None: + try: + await self.client.aclose() + except Exception: + pass + self.client = None + + # Close all tracked httpx clients (stale + current). + for client in self._stale_http_clients: + await self._close_httpx_client(client) + self._stale_http_clients.clear() + await self._close_httpx_client(self._http_client) + self._http_client = None From 041c35c35bc9b2c2e6b853a880121751e22f348b Mon Sep 17 00:00:00 2001 From: NayukiMeko Date: Mon, 11 May 2026 16:36:03 +0800 Subject: [PATCH 12/51] Fix: Fix issue where temporary directory is not cleaned and error tracking false positives when repeatedly installing plugins (#8148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(star): 修复重复安装插件时临时目录未清理及错误追踪误报问题 - 引入 skip_failed_tracking 标志,当目标目录已存在时跳过失败安装追踪,避免将预期冲突错误误记为安装失败 - 修复 finally 块中临时目录清理逻辑,确保冲突场景下 plugin_upload_* 临时目录也能被正确删除 - 新增测试用例 test_install_plugin_from_file_conflict_keeps_failed_plugins_clean,验证重复安装同名插件时 failed_plugin_dict 为空且无残留临时目录 Ref #8122 * style(star): ruff 格式化 --- astrbot/core/star/star_manager.py | 17 ++++++++----- tests/test_plugin_manager.py | 41 +++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index c25233c7c..58546c0ac 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -1798,6 +1798,7 @@ class PluginManager: dir=self.plugin_store_path, prefix="plugin_upload_" ) temp_desti_dir = desti_dir + skip_failed_tracking = False try: self.updator.unzip_file(zip_file_path, desti_dir) @@ -1807,6 +1808,7 @@ class PluginManager: metadata_dir_name, ) if target_plugin_path != desti_dir and os.path.exists(target_plugin_path): + skip_failed_tracking = True raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。") if target_plugin_path != desti_dir: os.rename(desti_dir, target_plugin_path) @@ -1870,17 +1872,20 @@ class PluginManager: return plugin_info except Exception as e: - self._track_failed_install_dir( - dir_name=dir_name, - plugin_path=desti_dir, - error=e, - ) + if not skip_failed_tracking: + self._track_failed_install_dir( + dir_name=dir_name, + plugin_path=desti_dir, + error=e, + ) logger.warning( f"安装插件 {dir_name} 失败,插件安装目录:{desti_dir}", ) raise finally: - if temp_desti_dir != desti_dir and os.path.isdir(temp_desti_dir): + if (skip_failed_tracking or temp_desti_dir != desti_dir) and os.path.isdir( + temp_desti_dir + ): try: remove_dir(temp_desti_dir) except Exception as e: diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 6b287d052..f6e9ebbd8 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -28,13 +28,15 @@ class MockStar: self.info = {"repo": TEST_PLUGIN_REPO, "readme": ""} -def _write_local_test_plugin(plugin_path: Path, repo_url: str): +def _write_local_test_plugin( + plugin_path: Path, repo_url: str, version: str = "1.0.0" +): """Creates a minimal valid plugin structure.""" plugin_path.mkdir(parents=True, exist_ok=True) metadata = { "name": TEST_PLUGIN_NAME, "repo": repo_url, - "version": "1.0.0", + "version": version, "author": "AstrBot Team", "desc": "Local test plugin", "short_desc": "Local test short description", @@ -386,6 +388,41 @@ async def test_install_plugin_from_file_dependency_install_flow( assert ("load", TEST_PLUGIN_DIR) in events +@pytest.mark.asyncio +async def test_install_plugin_from_file_conflict_keeps_failed_plugins_clean( + plugin_manager_pm: PluginManager, + local_updator: Path, + monkeypatch, + tmp_path: Path, +): + zip_file_path = tmp_path / "plugin_upload_helloworld_v2.zip" + zip_file_path.write_text("placeholder", encoding="utf-8") + plugin_store_path = Path(plugin_manager_pm.plugin_store_path) + existing_upload_dirs = set(plugin_store_path.glob("plugin_upload_*")) + + def mock_unzip_file(zip_path: str, target_dir: str) -> None: + assert zip_path == str(zip_file_path) + _write_local_test_plugin( + Path(target_dir), + TEST_PLUGIN_REPO, + version="2.0.0", + ) + + assert local_updator.is_dir() + monkeypatch.setattr(plugin_manager_pm.updator, "unzip_file", mock_unzip_file) + + with pytest.raises(Exception, match=f"安装失败:目录 {TEST_PLUGIN_DIR} 已存在。"): + await plugin_manager_pm.install_plugin_from_file(str(zip_file_path)) + + new_upload_dirs = [ + upload_dir + for upload_dir in plugin_store_path.glob("plugin_upload_*") + if upload_dir not in existing_upload_dirs + ] + assert plugin_manager_pm.failed_plugin_dict == {} + assert new_upload_dirs == [] + + @pytest.mark.asyncio @pytest.mark.parametrize("dependency_install_fails", [False, True]) async def test_reload_failed_plugin_dependency_install_flow( From f6a99a25b9dd8ba506cb033dc7e732c28f0e2b9a Mon Sep 17 00:00:00 2001 From: jdjfjdsfj <152496519+jdjfjdsfj@users.noreply.github.com> Date: Tue, 12 May 2026 08:43:53 +0800 Subject: [PATCH 13/51] Update API Key reference in knowledge-base.md (#8129) * Update API Key reference in knowledge-base.md * Update docs/zh/use/knowledge-base.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/zh/use/knowledge-base.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/zh/use/knowledge-base.md b/docs/zh/use/knowledge-base.md index 5f4088e51..383c89078 100644 --- a/docs/zh/use/knowledge-base.md +++ b/docs/zh/use/knowledge-base.md @@ -55,6 +55,6 @@ AstrBot 支持多知识库管理。在聊天时,您可以**自由指定知识 1. 打开 [硅基流动官网](https://cloud.siliconflow.cn/i/zMCYMSt2),注册账户并完成实名认证。 2. 打开 [API 密钥](https://cloud.siliconflow.cn/me/account/ak)。 5. 填写 AstrBot OpenAI Embedding 模型提供商配置: - 1. API Key 为刚刚申请的 PPIO 的 API Key + 1. API Key 为刚刚申请的硅基流动的 API Key 2. embedding api base 填写 `https://api.siliconflow.cn/v1` 3. model 填写你选择的模型,此例子中为 `BAAI/bge-m3`。 From 1d3f54ca49c9f67feb38879003eb698838c1abd1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 23:16:19 +0800 Subject: [PATCH 14/51] chore(deps): bump pnpm/action-setup in the github-actions group (#8156) Bumps the github-actions group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup). Updates `pnpm/action-setup` from 6.0.5 to 6.0.7 - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/v6.0.5...v6.0.7) --- updated-dependencies: - dependency-name: pnpm/action-setup dependency-version: 6.0.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-docs.yml | 2 +- .github/workflows/dashboard_ci.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 8074558b8..54cf0779f 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -13,7 +13,7 @@ jobs: - name: checkout uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v6.0.5 + uses: pnpm/action-setup@v6.0.7 with: version: 10.28.2 - name: Setup Node.js diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml index 8934697d8..385fa46d8 100644 --- a/.github/workflows/dashboard_ci.yml +++ b/.github/workflows/dashboard_ci.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v6.0.5 + uses: pnpm/action-setup@v6.0.7 with: version: 10.28.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04ebadf2b..f422444c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Setup pnpm - uses: pnpm/action-setup@v6.0.5 + uses: pnpm/action-setup@v6.0.7 with: version: 10.28.2 From f86de988a47f6637f7d6fdf24ac030f923ab4d4d Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Tue, 12 May 2026 23:28:23 +0800 Subject: [PATCH 15/51] fix: keep Discord startup alive on command quota (#8061) * fix: keep Discord startup alive on command quota * Update discord_platform_adapter.py --------- Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com> --- .../discord/discord_platform_adapter.py | 18 ++++- tests/test_discord_command_sync.py | 71 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 tests/test_discord_command_sync.py diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 4c0b8eaf9..e5e023d3e 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -427,8 +427,22 @@ class DiscordPlatformAdapter(Platform): # 使用 Pycord 的方法同步指令 # 注意:这可能需要一些时间,并且有频率限制 - await self.client.sync_commands() - logger.info("[Discord] Command synchronization completed.") + try: + await self.client.sync_commands() + logger.info("[Discord] Command synchronization completed.") + except discord.HTTPException as e: + if self._is_daily_command_quota_error(e): + logger.warning( + "[Discord] Daily application command create quota reached " + "(30034); command sync skipped. Existing commands should " + "continue to work until the quota resets.", + ) + return + logger.warning(f"[Discord] Sync commands failed: {e}") + + @staticmethod + def _is_daily_command_quota_error(error: discord.HTTPException) -> bool: + return getattr(error, "code", None) == 30034 def _create_dynamic_callback(self, cmd_name: str): """为每个指令动态创建一个异步回调函数""" diff --git a/tests/test_discord_command_sync.py b/tests/test_discord_command_sync.py new file mode 100644 index 000000000..dee2db89d --- /dev/null +++ b/tests/test_discord_command_sync.py @@ -0,0 +1,71 @@ +import asyncio +from unittest.mock import Mock + +import pytest + +from tests.fixtures.mocks.discord import ( + MockDiscordBuilder, + mock_discord_modules, # noqa: F401 +) + + +class DiscordSyncError(Exception): + def __init__(self, message: str, code: int | None = None) -> None: + super().__init__(message) + self.code = code + + +def _build_adapter(monkeypatch: pytest.MonkeyPatch): + from astrbot.core.platform.sources.discord import discord_platform_adapter + from astrbot.core.platform.sources.discord.discord_platform_adapter import ( + DiscordPlatformAdapter, + ) + + monkeypatch.setattr(discord_platform_adapter, "star_handlers_registry", []) + monkeypatch.setattr( + discord_platform_adapter.discord, + "HTTPException", + DiscordSyncError, + raising=False, + ) + + adapter = DiscordPlatformAdapter( + {"discord_command_register": True}, + {}, + asyncio.Queue(), + ) + adapter.client = MockDiscordBuilder.create_client() + return adapter + + +@pytest.mark.asyncio +async def test_discord_command_sync_ignores_daily_quota(monkeypatch): + from astrbot.core.platform.sources.discord import discord_platform_adapter + + adapter = _build_adapter(monkeypatch) + warning = Mock() + monkeypatch.setattr(discord_platform_adapter.logger, "warning", warning) + adapter.client.sync_commands.side_effect = DiscordSyncError( + "Max number of daily application command creates reached", + code=30034, + ) + + await adapter._collect_and_register_commands() + + adapter.client.sync_commands.assert_awaited_once() + warning.assert_called_once() + assert "30034" in warning.call_args.args[0] + + +@pytest.mark.asyncio +async def test_discord_command_sync_keeps_other_errors_fatal(monkeypatch): + adapter = _build_adapter(monkeypatch) + adapter.client.sync_commands.side_effect = DiscordSyncError( + "Missing Access", + code=50001, + ) + + with pytest.raises(DiscordSyncError): + await adapter._collect_and_register_commands() + + adapter.client.sync_commands.assert_awaited_once() From 7ddf6371b9d57e70e23e0d6f16b5616be0491493 Mon Sep 17 00:00:00 2001 From: Weilong Liao <37870767+Soulter@users.noreply.github.com> Date: Tue, 12 May 2026 23:46:57 +0800 Subject: [PATCH 16/51] fix(webui): enforce 12-char dashboard password policy with backend+frontend validation (#7338) * fix(webui): enforce 12-char dashboard password policy with backend+frontend validation * fix(i18n): update password policy hints and validation rules for improved security * test: adapt dashboard auth fixtures for hashed default password * fix(security): increase PBKDF2 iterations * feat(auth): implement secure login challenge and proof verification * chore: ruff format * fix(auth): update md5 import syntax for consistency * feat(dashboard): implement random password generation for empty dashboard password * feat(auth): enforce plaintext password requirement for legacy MD5 hashes * fix(i18n): update password hint texts to reflect auto-generated initial passwords * feat(dashboard): implement password change requirement and reset logic * feat(auth): implement account setup flow and password change requirements * feat: Implement legacy password support and upgrade mechanism - Added `hash_legacy_dashboard_password` function for MD5 hashing of passwords. - Updated configuration handling to store both PBKDF2 and legacy password hashes. - Introduced logic to check if password storage has been upgraded and if a password change is required. - Modified dashboard authentication routes to handle legacy password checks and prompts for upgrades. - Updated frontend to display warnings for legacy password storage and required upgrades. - Enhanced tests to cover scenarios for legacy password handling and migration to new storage format. * fix(logo): update text color styles to use CSS variables for consistency * feat(dashboard): upgrade password storage and enforce change requirement * fix(dashboard): update minimum password length from 12 to 10 characters * fix(dashboard): update minimum password length from 10 to 8 characters --------- Co-authored-by: RC-CHN <1051989940@qq.com> --- astrbot/cli/commands/cmd_conf.py | 29 +- astrbot/core/config/astrbot_config.py | 54 +- astrbot/core/config/default.py | 5 +- astrbot/core/utils/auth_password.py | 126 +++++ astrbot/dashboard/password_state.py | 94 ++++ astrbot/dashboard/routes/auth.py | 204 +++++++- astrbot/dashboard/routes/stat.py | 51 +- astrbot/dashboard/server.py | 28 +- dashboard/package.json | 2 +- dashboard/pnpm-lock.yaml | 34 +- .../mdi-subset/materialdesignicons-subset.css | 14 +- .../materialdesignicons-webfont-subset.woff | Bin 18600 -> 18772 bytes .../materialdesignicons-webfont-subset.woff2 | Bin 14984 -> 15112 bytes dashboard/src/components/shared/Logo.vue | 21 +- .../src/i18n/locales/en-US/core/header.json | 11 +- .../src/i18n/locales/en-US/features/auth.json | 24 +- .../locales/en-US/messages/validation.json | 2 +- .../src/i18n/locales/ru-RU/core/header.json | 15 +- .../src/i18n/locales/ru-RU/features/auth.json | 24 +- .../locales/ru-RU/messages/validation.json | 2 +- .../src/i18n/locales/zh-CN/core/header.json | 13 +- .../src/i18n/locales/zh-CN/features/auth.json | 24 +- .../locales/zh-CN/messages/validation.json | 2 +- .../full/vertical-header/VerticalHeader.vue | 97 +++- dashboard/src/router/AuthRoutes.ts | 5 + dashboard/src/router/index.ts | 2 +- dashboard/src/stores/auth.ts | 71 ++- .../views/authentication/auth/LoginPage.vue | 14 + .../views/authentication/auth/SetupPage.vue | 124 +++++ .../authentication/authForms/AuthLogin.vue | 14 +- .../authentication/authForms/AuthSetup.vue | 130 +++++ tests/test_api_key_open_api.py | 36 +- tests/test_dashboard.py | 479 +++++++++++++++++- tests/test_kb_import.py | 36 +- tests/unit/test_config.py | 129 +++++ 35 files changed, 1793 insertions(+), 123 deletions(-) create mode 100644 astrbot/core/utils/auth_password.py create mode 100644 astrbot/dashboard/password_state.py create mode 100644 dashboard/src/views/authentication/auth/SetupPage.vue create mode 100644 dashboard/src/views/authentication/authForms/AuthSetup.vue diff --git a/astrbot/cli/commands/cmd_conf.py b/astrbot/cli/commands/cmd_conf.py index 5a39cb2f7..cf583b9d5 100644 --- a/astrbot/cli/commands/cmd_conf.py +++ b/astrbot/cli/commands/cmd_conf.py @@ -1,4 +1,3 @@ -import hashlib import json import zoneinfo from collections.abc import Callable @@ -6,6 +5,12 @@ from typing import Any import click +from astrbot.core.utils.auth_password import ( + hash_dashboard_password, + hash_legacy_dashboard_password, + validate_dashboard_password, +) + from ..utils import check_astrbot_root, get_astrbot_root @@ -39,9 +44,11 @@ def _validate_dashboard_username(value: str) -> str: def _validate_dashboard_password(value: str) -> str: """Validate Dashboard password""" - if not value: - raise click.ClickException("Password cannot be empty") - return hashlib.md5(value.encode()).hexdigest() + try: + validate_dashboard_password(value) + except ValueError as e: + raise click.ClickException(str(e)) + return value def _validate_timezone(value: str) -> str: @@ -163,7 +170,19 @@ def set_config(key: str, value: str) -> None: try: old_value = _get_nested_item(config, key) validated_value = CONFIG_VALIDATORS[key](value) - _set_nested_item(config, key, validated_value) + if key == "dashboard.password": + _set_nested_item( + config, + "dashboard.pbkdf2_password", + hash_dashboard_password(validated_value), + ) + _set_nested_item( + config, + "dashboard.password", + hash_legacy_dashboard_password(validated_value), + ) + else: + _set_nested_item(config, key, validated_value) _save_config(config) click.echo(f"Config updated: {key}") diff --git a/astrbot/core/config/astrbot_config.py b/astrbot/core/config/astrbot_config.py index 63236c1d8..e23c5961e 100644 --- a/astrbot/core/config/astrbot_config.py +++ b/astrbot/core/config/astrbot_config.py @@ -4,6 +4,11 @@ import logging import os from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.auth_password import ( + generate_dashboard_password, + hash_dashboard_password, + hash_legacy_dashboard_password, +) from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP @@ -56,15 +61,62 @@ class AstrBotConfig(dict): if conf_str.startswith("\ufeff"): conf_str = conf_str[1:] conf = json.loads(conf_str) - + dashboard_conf = conf.get("dashboard") + legacy_dashboard_password_change_required = bool( + isinstance(dashboard_conf, dict) + and dashboard_conf.get("password_change_required", False) + ) + if legacy_dashboard_password_change_required: + object.__setattr__( + self, + "_dashboard_password_change_required_from_config", + True, + ) # 检查配置完整性,并插入 has_new = self.check_config_integrity(default_config, conf) + 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") + ): + self._reset_generated_dashboard_password(conf) + has_new = True self.update(conf) if has_new: self.save_config() self.update(conf) + def _reset_generated_dashboard_password(self, conf: dict) -> None: + generated_password = generate_dashboard_password() + conf["dashboard"]["pbkdf2_password"] = hash_dashboard_password( + generated_password + ) + conf["dashboard"]["password"] = hash_legacy_dashboard_password( + generated_password + ) + conf["dashboard"]["password_storage_upgraded"] = True + conf["dashboard"]["password_change_required"] = True + object.__setattr__( + self, + "_generated_dashboard_password", + generated_password, + ) + object.__setattr__( + self, + "_generated_dashboard_password_change_required", + True, + ) + def _config_schema_to_default_config(self, schema: dict) -> dict: """将 Schema 转换成 Config""" conf = {} diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 8df7ae27b..763ca6361 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -244,7 +244,10 @@ DEFAULT_CONFIG = { "dashboard": { "enable": True, "username": "astrbot", - "password": "77b90590a8945a7d36c963981a307dc9", + "password": "", + "pbkdf2_password": "", + "password_storage_upgraded": False, + "password_change_required": False, "jwt_secret": "", "host": "0.0.0.0", "port": 6185, diff --git a/astrbot/core/utils/auth_password.py b/astrbot/core/utils/auth_password.py new file mode 100644 index 000000000..bbb5ff046 --- /dev/null +++ b/astrbot/core/utils/auth_password.py @@ -0,0 +1,126 @@ +"""Utilities for dashboard password hashing and verification.""" + +import hashlib +import hmac +import re +import secrets +import string + +_PBKDF2_ITERATIONS = 600_000 +_PBKDF2_SALT_BYTES = 16 +_PBKDF2_ALGORITHM = "pbkdf2_sha256" +_PBKDF2_FORMAT = f"{_PBKDF2_ALGORITHM}$" +_LEGACY_MD5_LENGTH = 32 +_DASHBOARD_PASSWORD_MIN_LENGTH = 8 +_GENERATED_DASHBOARD_PASSWORD_LENGTH = 24 +DEFAULT_DASHBOARD_PASSWORD = "astrbot" + + +def generate_dashboard_password() -> str: + """Generate a strong dashboard password that satisfies the complexity policy.""" + alphabet = string.ascii_letters + string.digits + password_chars = [ + secrets.choice(string.ascii_uppercase), + secrets.choice(string.ascii_lowercase), + secrets.choice(string.digits), + *( + secrets.choice(alphabet) + for _ in range(_GENERATED_DASHBOARD_PASSWORD_LENGTH - 3) + ), + ] + secrets.SystemRandom().shuffle(password_chars) + return "".join(password_chars) + + +def hash_dashboard_password(raw_password: str) -> str: + """Return a salted hash for dashboard password using PBKDF2-HMAC-SHA256.""" + if not isinstance(raw_password, str) or raw_password == "": + raise ValueError("Password cannot be empty") + + salt = secrets.token_hex(_PBKDF2_SALT_BYTES) + digest = hashlib.pbkdf2_hmac( + "sha256", + raw_password.encode("utf-8"), + bytes.fromhex(salt), + _PBKDF2_ITERATIONS, + ).hex() + return f"{_PBKDF2_FORMAT}{_PBKDF2_ITERATIONS}${salt}${digest}" + + +def hash_legacy_dashboard_password(raw_password: str) -> str: + """Return legacy MD5 hash for downgrade compatibility only.""" + if not isinstance(raw_password, str) or raw_password == "": + raise ValueError("Password cannot be empty") + return hashlib.md5(raw_password.encode("utf-8")).hexdigest() + + +def validate_dashboard_password(raw_password: str) -> None: + """Validate whether dashboard password meets the minimal complexity policy.""" + if not isinstance(raw_password, str) or raw_password == "": + raise ValueError("Password cannot be empty") + if len(raw_password) < _DASHBOARD_PASSWORD_MIN_LENGTH: + raise ValueError( + f"Password must be at least {_DASHBOARD_PASSWORD_MIN_LENGTH} characters long" + ) + + if not re.search(r"[A-Z]", raw_password): + raise ValueError("Password must include at least one uppercase letter") + if not re.search(r"[a-z]", raw_password): + raise ValueError("Password must include at least one lowercase letter") + if not re.search(r"\d", raw_password): + raise ValueError("Password must include at least one digit") + + +def _is_legacy_md5_hash(stored: str) -> bool: + return ( + isinstance(stored, str) + and len(stored) == _LEGACY_MD5_LENGTH + and all(c in "0123456789abcdefABCDEF" for c in stored) + ) + + +def _is_pbkdf2_hash(stored: str) -> bool: + return isinstance(stored, str) and stored.startswith(_PBKDF2_FORMAT) + + +def verify_dashboard_password(stored_hash: str, candidate_password: str) -> bool: + """Verify password against legacy md5 or new PBKDF2-SHA256 format.""" + if not isinstance(stored_hash, str) or not isinstance(candidate_password, str): + return False + + if _is_legacy_md5_hash(stored_hash): + # Keep compatibility with existing MD5-based deployments while requiring + # the real plaintext password, not the stored MD5 value itself. + candidate_md5 = hashlib.md5(candidate_password.encode("utf-8")).hexdigest() + return hmac.compare_digest(stored_hash.lower(), candidate_md5.lower()) + + if _is_pbkdf2_hash(stored_hash): + parts: list[str] = stored_hash.split("$") + if len(parts) != 4: + return False + _, iterations_s, salt, digest = parts + try: + iterations = int(iterations_s) + stored_key = bytes.fromhex(digest) + salt_bytes = bytes.fromhex(salt) + except (TypeError, ValueError): + return False + candidate_key = hashlib.pbkdf2_hmac( + "sha256", + candidate_password.encode("utf-8"), + salt_bytes, + iterations, + ) + return hmac.compare_digest(stored_key, candidate_key) + + return False + + +def is_default_dashboard_password(stored_hash: str) -> bool: + """Check whether the password still equals the built-in default value.""" + return verify_dashboard_password(stored_hash, DEFAULT_DASHBOARD_PASSWORD) + + +def is_legacy_dashboard_password(stored_hash: str) -> bool: + """Check whether the password is still stored with legacy MD5.""" + return _is_legacy_md5_hash(stored_hash) diff --git a/astrbot/dashboard/password_state.py b/astrbot/dashboard/password_state.py new file mode 100644 index 000000000..b55c0866a --- /dev/null +++ b/astrbot/dashboard/password_state.py @@ -0,0 +1,94 @@ +from astrbot.core.config.astrbot_config import AstrBotConfig +from astrbot.core.db import BaseDatabase +from astrbot.core.utils.auth_password import ( + hash_dashboard_password, + hash_legacy_dashboard_password, + is_legacy_dashboard_password, +) + +PASSWORD_STORAGE_UPGRADED_KEY = "password_storage_upgraded" +PASSWORD_CHANGE_REQUIRED_KEY = "password_change_required" + + +def _set_dashboard_flag(config: AstrBotConfig, key: str, value: bool) -> None: + if config["dashboard"].get(key) == bool(value): + return + config["dashboard"][key] = bool(value) + config.save_config() + + +def _has_usable_pbkdf2_password(config: AstrBotConfig) -> bool: + password = config["dashboard"].get("pbkdf2_password", "") + if not isinstance(password, str) or not password.startswith("pbkdf2_sha256$"): + return False + + parts = password.split("$") + if len(parts) != 4: + return False + + _, iterations, salt, digest = parts + try: + int(iterations) + bytes.fromhex(salt) + bytes.fromhex(digest) + except ValueError: + return False + return True + + +async def is_password_storage_upgraded( + db: BaseDatabase, + config: AstrBotConfig, +) -> bool: + config_upgraded = _has_usable_pbkdf2_password(config) + if config["dashboard"].get(PASSWORD_STORAGE_UPGRADED_KEY) != config_upgraded: + _set_dashboard_flag(config, PASSWORD_STORAGE_UPGRADED_KEY, config_upgraded) + return config_upgraded + + +async def set_password_storage_upgraded( + db: BaseDatabase, + config: AstrBotConfig, + upgraded: bool, +) -> None: + _set_dashboard_flag(config, PASSWORD_STORAGE_UPGRADED_KEY, upgraded) + + +async def is_password_change_required( + db: BaseDatabase, + config: AstrBotConfig, +) -> bool: + stored = config["dashboard"].get(PASSWORD_CHANGE_REQUIRED_KEY, None) + if stored is not None: + return bool(stored) + + required = bool( + getattr(config, "_generated_dashboard_password_change_required", False) + or getattr(config, "_dashboard_password_change_required_from_config", False) + ) + if required: + _set_dashboard_flag(config, PASSWORD_CHANGE_REQUIRED_KEY, True) + return required + + +async def set_password_change_required( + db: BaseDatabase, + config: AstrBotConfig, + required: bool, +) -> None: + _set_dashboard_flag(config, PASSWORD_CHANGE_REQUIRED_KEY, required) + + +def get_dashboard_password_hash(config: AstrBotConfig, *, upgraded: bool) -> str: + if upgraded and _has_usable_pbkdf2_password(config): + return config["dashboard"].get("pbkdf2_password", "") + + legacy_password = config["dashboard"].get("password", "") + if upgraded and not is_legacy_dashboard_password(legacy_password): + return "" + return legacy_password + + +def set_dashboard_password_hashes(config: AstrBotConfig, raw_password: str) -> None: + config["dashboard"]["pbkdf2_password"] = hash_dashboard_password(raw_password) + config["dashboard"]["password"] = hash_legacy_dashboard_password(raw_password) diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py index 6dc530b4b..20195a858 100644 --- a/astrbot/dashboard/routes/auth.py +++ b/astrbot/dashboard/routes/auth.py @@ -1,47 +1,169 @@ import asyncio import datetime +import os import jwt -from quart import current_app, jsonify, make_response, request +from quart import current_app, g, jsonify, make_response, request from astrbot import logger from astrbot.core import DEMO_MODE +from astrbot.core.utils.auth_password import ( + is_default_dashboard_password, + is_legacy_dashboard_password, + validate_dashboard_password, + verify_dashboard_password, +) +from astrbot.dashboard.password_state import ( + get_dashboard_password_hash, + is_password_change_required, + is_password_storage_upgraded, + set_dashboard_password_hashes, + set_password_change_required, + set_password_storage_upgraded, +) from .route import Response, Route, RouteContext DASHBOARD_JWT_COOKIE_NAME = "astrbot_dashboard_jwt" DASHBOARD_JWT_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 +SKIP_DEFAULT_PASSWORD_AUTH_ENV = "ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH" +SKIP_DEFAULT_PASSWORD_AUTH_ENV_LEGACY = "DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH" +LOCAL_DASHBOARD_HOSTS = {"127.0.0.1", "localhost", "::1"} class AuthRoute(Route): - def __init__(self, context: RouteContext) -> None: + def __init__(self, context: RouteContext, db) -> None: super().__init__(context) + self.db = db self.routes = { "/auth/login": ("POST", self.login), "/auth/logout": ("POST", self.logout), + "/auth/setup-status": ("GET", self.setup_status), + "/auth/setup": ("POST", self.setup), + "/auth/setup-authenticated": ("POST", self.setup_authenticated), "/auth/account/edit": ("POST", self.edit_account), } self.register_routes() + async def setup_status(self): + return ( + Response() + .ok( + { + "setup_required": await self._is_setup_required(), + "skip_default_password_auth": self._can_skip_default_password_auth(), + "password_upgrade_required": not await is_password_storage_upgraded( + self.db, + self.config, + ), + } + ) + .__dict__ + ) + + async def setup(self): + if not self._can_skip_default_password_auth(): + return Response().error("Setup without password is not enabled").__dict__ + if not await self._is_setup_required(): + return Response().error("Setup is not required").__dict__ + + return await self._complete_setup() + + async def setup_authenticated(self): + if not await self._is_setup_required(): + return Response().error("Setup is not required").__dict__ + if not isinstance(getattr(g, "username", None), str): + return Response().error("未授权").__dict__ + + return await self._complete_setup() + + async def _complete_setup(self): + post_data = await request.json + if not isinstance(post_data, dict): + return Response().error("Invalid request payload").__dict__ + + new_username = post_data.get("username") + new_password = post_data.get("password") + confirm_password = post_data.get("confirm_password") + if not isinstance(new_username, str) or len(new_username.strip()) < 3: + return Response().error("用户名长度至少3位").__dict__ + if not isinstance(new_password, str): + return Response().error("新密码无效").__dict__ + if not isinstance(confirm_password, str) or confirm_password != new_password: + return Response().error("两次输入的新密码不一致").__dict__ + + try: + validate_dashboard_password(new_password) + except ValueError as e: + return Response().error(str(e)).__dict__ + + username = new_username.strip() + self.config["dashboard"]["username"] = username + set_dashboard_password_hashes(self.config, new_password) + await set_password_storage_upgraded(self.db, self.config, True) + await set_password_change_required(self.db, self.config, False) + self.config.save_config() + + token = self.generate_jwt(username) + payload = Response().ok( + { + "token": token, + "username": username, + "change_pwd_hint": False, + "legacy_pwd_hint": False, + "password_upgrade_required": False, + }, + "Setup completed successfully", + ) + response = await make_response(jsonify(payload.__dict__)) + self._set_dashboard_jwt_cookie(response, token) + return response + async def login(self): username = self.config["dashboard"]["username"] - password = self.config["dashboard"]["password"] + storage_upgraded = await is_password_storage_upgraded(self.db, self.config) + password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded) post_data = await request.json - if post_data["username"] == username and post_data["password"] == password: + + req_username = ( + post_data.get("username") if isinstance(post_data, dict) else None + ) + req_password = ( + post_data.get("password") if isinstance(post_data, dict) else None + ) + if not isinstance(req_username, str) or not isinstance(req_password, str): + return Response().error("Invalid request payload").__dict__ + + login_verified = req_username == username and verify_dashboard_password( + password, req_password + ) + + if login_verified: change_pwd_hint = False + legacy_pwd_hint = is_legacy_dashboard_password(password) + password_change_required = await is_password_change_required( + self.db, + self.config, + ) if ( - username == "astrbot" - and password == "77b90590a8945a7d36c963981a307dc9" + storage_upgraded + and username == "astrbot" + and is_default_dashboard_password(password) and not DEMO_MODE ): change_pwd_hint = True + legacy_pwd_hint = True logger.warning("为了保证安全,请尽快修改默认密码。") + if password_change_required and not DEMO_MODE: + change_pwd_hint = True token = self.generate_jwt(username) payload = Response().ok( { "token": token, "username": username, "change_pwd_hint": change_pwd_hint, + "legacy_pwd_hint": legacy_pwd_hint, + "password_upgrade_required": not storage_upgraded, }, ) response = await make_response(jsonify(payload.__dict__)) @@ -65,29 +187,50 @@ class AuthRoute(Route): .__dict__ ) - password = self.config["dashboard"]["password"] + storage_upgraded = await is_password_storage_upgraded(self.db, self.config) + password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded) post_data = await request.json + if not isinstance(post_data, dict): + return Response().error("Invalid request payload").__dict__ - if post_data["password"] != password: + req_password = post_data.get("password") + if not isinstance(req_password, str): + return Response().error("Invalid request payload").__dict__ + + if not verify_dashboard_password(password, req_password): return Response().error("原密码错误").__dict__ new_pwd = post_data.get("new_password", None) new_username = post_data.get("new_username", None) + password_change_required = await is_password_change_required( + self.db, + self.config, + ) + if (not storage_upgraded or password_change_required) and not new_pwd: + return Response().error("请设置新密码以完成安全升级").__dict__ if not new_pwd and not new_username: return Response().error("新用户名和新密码不能同时为空").__dict__ # Verify password confirmation if new_pwd: + if not isinstance(new_pwd, str): + return Response().error("新密码无效").__dict__ confirm_pwd = post_data.get("confirm_password", None) - if confirm_pwd != new_pwd: + if not isinstance(confirm_pwd, str) or confirm_pwd != new_pwd: return Response().error("两次输入的新密码不一致").__dict__ - self.config["dashboard"]["password"] = new_pwd + try: + validate_dashboard_password(new_pwd) + except ValueError as e: + return Response().error(str(e)).__dict__ + set_dashboard_password_hashes(self.config, new_pwd) + await set_password_storage_upgraded(self.db, self.config, True) + await set_password_change_required(self.db, self.config, False) if new_username: self.config["dashboard"]["username"] = new_username self.config.save_config() - return Response().ok(None, "修改成功").__dict__ + return Response().ok(None, "Updated account successfully").__dict__ def generate_jwt(self, username): payload = { @@ -101,6 +244,45 @@ class AuthRoute(Route): token = jwt.encode(payload, jwt_token, algorithm="HS256") return token + async def _is_setup_required(self) -> bool: + if DEMO_MODE: + return False + + dashboard_config = self.config["dashboard"] + password_change_required = await is_password_change_required( + self.db, + self.config, + ) + if password_change_required: + return True + + storage_upgraded = await is_password_storage_upgraded(self.db, self.config) + if not storage_upgraded: + return False + + return dashboard_config.get( + "username" + ) == "astrbot" and is_default_dashboard_password( + dashboard_config.get("pbkdf2_password", "") + ) + + def _can_skip_default_password_auth(self) -> bool: + if not self._env_flag_enabled(SKIP_DEFAULT_PASSWORD_AUTH_ENV): + return False + host = ( + os.environ.get("DASHBOARD_HOST") + or os.environ.get("ASTRBOT_DASHBOARD_HOST") + or self.config["dashboard"].get("host", "") + ) + return str(host).strip().lower() in LOCAL_DASHBOARD_HOSTS + + @staticmethod + def _env_flag_enabled(name: str) -> bool: + value = os.environ.get(name) + if value is None and name == SKIP_DEFAULT_PASSWORD_AUTH_ENV: + value = os.environ.get(SKIP_DEFAULT_PASSWORD_AUTH_ENV_LEGACY) + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + @staticmethod def _use_secure_dashboard_jwt_cookie() -> bool: return bool( diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py index b02091d5d..060e4c4e2 100644 --- a/astrbot/dashboard/routes/stat.py +++ b/astrbot/dashboard/routes/stat.py @@ -12,7 +12,7 @@ from pathlib import Path import aiohttp import psutil from quart import request -from sqlmodel import select +from sqlmodel import col, select from astrbot.core import DEMO_MODE, logger from astrbot.core.config import VERSION @@ -21,9 +21,18 @@ from astrbot.core.db import BaseDatabase from astrbot.core.db.migration.helper import check_migration_needed_v4 from astrbot.core.db.po import ProviderStat from astrbot.core.utils.astrbot_path import get_astrbot_path +from astrbot.core.utils.auth_password import ( + is_default_dashboard_password, + is_legacy_dashboard_password, +) from astrbot.core.utils.io import get_dashboard_version from astrbot.core.utils.storage_cleaner import StorageCleaner from astrbot.core.utils.version_comparator import VersionComparator +from astrbot.dashboard.password_state import ( + get_dashboard_password_hash, + is_password_change_required, + is_password_storage_upgraded, +) from .route import Response, Route, RouteContext @@ -71,17 +80,37 @@ class StatRoute(Route): hours, minutes = divmod(minutes, 60) return {"hours": hours, "minutes": minutes, "seconds": seconds} - def is_default_cred(self): - username = self.config["dashboard"]["username"] - password = self.config["dashboard"]["password"] - return ( - username == "astrbot" - and password == "77b90590a8945a7d36c963981a307dc9" - and not DEMO_MODE + async def is_default_cred(self): + password_change_required = await is_password_change_required( + self.db_helper, + self.config, ) + if password_change_required: + return not DEMO_MODE + + storage_upgraded = await is_password_storage_upgraded( + self.db_helper, + self.config, + ) + if not storage_upgraded: + return False + + username = self.config["dashboard"]["username"] + password = get_dashboard_password_hash(self.config, upgraded=True) + return ( + username == "astrbot" and is_default_dashboard_password(password) + ) and not DEMO_MODE async def get_version(self): need_migration = await check_migration_needed_v4(self.core_lifecycle.db) + storage_upgraded = await is_password_storage_upgraded( + self.db_helper, + self.config, + ) + password = get_dashboard_password_hash( + self.config, + upgraded=storage_upgraded, + ) return ( Response() @@ -89,7 +118,9 @@ class StatRoute(Route): { "version": VERSION, "dashboard_version": await get_dashboard_version(), - "change_pwd_hint": self.is_default_cred(), + "change_pwd_hint": await self.is_default_cred(), + "legacy_pwd_hint": is_legacy_dashboard_password(password), + "password_upgrade_required": not storage_upgraded, "need_migration": need_migration, }, ) @@ -226,7 +257,7 @@ class StatRoute(Route): ProviderStat.agent_type == "internal", ProviderStat.created_at >= query_start_utc, ) - .order_by(ProviderStat.created_at.asc()) + .order_by(col(ProviderStat.created_at).asc()) ) records = result.scalars().all() diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index d926cb06d..1211d4f75 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -153,7 +153,7 @@ class AstrBotDashboard: self.cr = ConfigRoute(self.context, core_lifecycle) self.lr = LogRoute(self.context, core_lifecycle.log_broker) self.sfr = StaticFileRoute(self.context) - self.ar = AuthRoute(self.context) + self.ar = AuthRoute(self.context, db) self.api_key_route = ApiKeyRoute(self.context, db) self.chat_route = ChatRoute(self.context, db, core_lifecycle) self.open_api_route = OpenApiRoute( @@ -241,15 +241,21 @@ class AstrBotDashboard: await self.db.touch_api_key(api_key.key_id) return None - allowed_endpoints = [ + allowed_exact_endpoints = { "/api/auth/login", "/api/auth/logout", + "/api/auth/setup-status", + "/api/auth/setup", + } + allowed_endpoint_prefixes = [ "/api/file", "/api/platform/webhook", "/api/stat/start-time", "/api/backup/download", # 备份下载使用 URL 参数传递 token ] - if any(request.path.startswith(prefix) for prefix in allowed_endpoints): + if request.path in allowed_exact_endpoints or any( + request.path.startswith(prefix) for prefix in allowed_endpoint_prefixes + ): return None is_plugin_page_path = PluginPageAuth.is_protected_path(request.path) token = self._extract_dashboard_jwt() @@ -373,6 +379,20 @@ class AstrBotDashboard: logger.info("Initialized random JWT secret for dashboard.") self._jwt_secret = self.config["dashboard"]["jwt_secret"] + def _build_dashboard_credentials_display(self) -> str: + username = self.config["dashboard"].get("username", "astrbot") + generated_password = getattr(self.config, "_generated_dashboard_password", None) + if not generated_password: + return f" ➜ Username: {username}\n ✨✨✨\n" + + credentials_display = ( + f" ➜ Initial username: {username}\n" + f" ➜ Initial password: {generated_password}\n" + " ➜ Change it after logging in\n ✨✨✨\n" + ) + object.__setattr__(self.config, "_generated_dashboard_password", None) + return credentials_display + @staticmethod def _resolve_dashboard_ssl_config( ssl_config: dict, @@ -492,7 +512,7 @@ class AstrBotDashboard: parts.append(f" ➜ Local: {scheme}://localhost:{port}\n") for ip in ip_addr: parts.append(f" ➜ Network: {scheme}://{ip}:{port}\n") - parts.append(" ➜ Default username/password: astrbot / astrbot\n ✨✨✨\n") + parts.append(self._build_dashboard_credentials_display()) display = "".join(parts) if not ip_addr: diff --git a/dashboard/package.json b/dashboard/package.json index 7770debea..709e381cc 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -25,7 +25,7 @@ "date-fns": "2.30.0", "dompurify": "^3.3.2", "event-source-polyfill": "^1.0.31", - "js-md5": "^0.8.3", + "highlight.js": "11.11.1", "katex": "^0.16.27", "lodash": "4.17.23", "markdown-it": "^14.1.1", diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index c60569271..df5c50503 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -5,8 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: - immutable: "4.3.8" - lodash-es: "4.17.23" + immutable: 4.3.8 + lodash-es: 4.17.23 importers: @@ -42,9 +42,9 @@ importers: event-source-polyfill: specifier: ^1.0.31 version: 1.0.31 - js-md5: - specifier: ^0.8.3 - version: 0.8.3 + highlight.js: + specifier: 11.11.1 + version: 11.11.1 katex: specifier: ^0.16.27 version: 0.16.28 @@ -537,66 +537,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1903,6 +1916,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -1985,9 +2002,6 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} - js-md5@0.8.3: - resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -4913,6 +4927,8 @@ snapshots: he@1.2.0: {} + highlight.js@11.11.1: {} + hookified@1.15.1: {} html-void-elements@3.0.0: {} @@ -4975,8 +4991,6 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - js-md5@0.8.3: {} - js-yaml@4.1.1: dependencies: argparse: 2.0.1 diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 187348bca..a3507d2ab 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -1,4 +1,4 @@ -/* Auto-generated MDI subset – 260 icons */ +/* Auto-generated MDI subset – 263 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -28,6 +28,10 @@ content: "\F0009"; } +.mdi-account-edit::before { + content: "\F06BC"; +} + .mdi-account-edit-outline::before { content: "\F0FFB"; } @@ -652,6 +656,10 @@ content: "\F033E"; } +.mdi-lock-check::before { + content: "\F139A"; +} + .mdi-lock-check-outline::before { content: "\F16A8"; } @@ -660,6 +668,10 @@ content: "\F0341"; } +.mdi-lock-plus::before { + content: "\F05FB"; +} + .mdi-lock-plus-outline::before { content: "\F16B2"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index e8c5b375dd4cc3ed4bcf7a1e227531f3baaf88e2..7549c05d40fdc4a562d4084e2f74307b83774ce1 100644 GIT binary patch delta 18116 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 delta 17970 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 diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index 2ebc5cc5115e36a31318b490a0d2824c8a1af746..a749e1d378d226a9cc632e6beb904552829c81cb 100644 GIT binary patch literal 15112 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 literal 14984 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 S928VpEWIw3UWYuroH;fcy(i%S diff --git a/dashboard/src/components/shared/Logo.vue b/dashboard/src/components/shared/Logo.vue index 4ff5889e2..49e59ed97 100644 --- a/dashboard/src/components/shared/Logo.vue +++ b/dashboard/src/components/shared/Logo.vue @@ -2,16 +2,13 @@
- AstrBot Logo + AstrBot Logo

- -

{{ subtitle || t('core.header.accountDialog.title') }}

+

{{ subtitle || t('core.header.accountDialog.title') }}

@@ -44,7 +41,7 @@ const formatTitle = (title: string) => { diff --git a/dashboard/src/i18n/locales/en-US/core/header.json b/dashboard/src/i18n/locales/en-US/core/header.json index 4da98b8dd..07e913e4c 100644 --- a/dashboard/src/i18n/locales/en-US/core/header.json +++ b/dashboard/src/i18n/locales/en-US/core/header.json @@ -80,20 +80,25 @@ }, "accountDialog": { "title": "Modify Account", - "securityWarning": "Security Reminder: Please change the default password to ensure account security", + "securityWarning": "Security Reminder: Please change your password to secure your account", + "securityWarningLegacy": "The new AstrBot version has improved security. Please change your password.", + "securityWarningUpgrade": "Legacy password storage detected. Please change your password to complete the security upgrade.", "form": { "currentPassword": "Current Password", "newPassword": "New Password", "confirmPassword": "Confirm New Password", "newUsername": "New Username (Optional)", - "passwordHint": "Password must be at least 8 characters", + "passwordHint": "At least 8 characters, including uppercase, lowercase letters, and digits", "confirmPasswordHint": "Please enter new password again to confirm", "usernameHint": "Leave blank to keep current username", - "defaultCredentials": "Default username and password are both astrbot" + "defaultCredentials": "The default username is astrbot. An initial password is generated on first startup and shown in the console." }, "validation": { "passwordRequired": "Please enter password", "passwordMinLength": "Password must be at least 8 characters", + "passwordUppercase": "Password must include at least one uppercase letter", + "passwordLowercase": "Password must include at least one lowercase letter", + "passwordDigit": "Password must include at least one digit", "passwordMatch": "Passwords do not match", "usernameMinLength": "Username must be at least 3 characters" }, diff --git a/dashboard/src/i18n/locales/en-US/features/auth.json b/dashboard/src/i18n/locales/en-US/features/auth.json index 5c44558a0..23651a52f 100644 --- a/dashboard/src/i18n/locales/en-US/features/auth.json +++ b/dashboard/src/i18n/locales/en-US/features/auth.json @@ -2,7 +2,27 @@ "login": "Login", "username": "Username", "password": "Password", - "defaultHint": "Default username and password: astrbot", + "defaultHint": "If this is your first login, check the logs for the default password.", + "setup": { + "title": "Set Up Account", + "subtitle": "Create the account used to manage AstrBot", + "username": "New username", + "password": "New password", + "confirmPassword": "Confirm new password", + "passwordHint": "Use at least 8 characters with uppercase, lowercase, and a number.", + "submit": "Complete Setup", + "validation": { + "usernameRequired": "Enter a username", + "usernameMinLength": "Username must be at least 3 characters", + "passwordRequired": "Enter a password", + "passwordMinLength": "Password must be at least 8 characters", + "passwordUppercase": "Password must include at least one uppercase letter", + "passwordLowercase": "Password must include at least one lowercase letter", + "passwordDigit": "Password must include at least one number", + "confirmPasswordRequired": "Confirm the password", + "passwordMatch": "Passwords do not match" + } + }, "logo": { "title": "AstrBot Dashboard", "subtitle": "Welcome" @@ -11,4 +31,4 @@ "switchToDark": "Switch to Dark Theme", "switchToLight": "Switch to Light Theme" } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/en-US/messages/validation.json b/dashboard/src/i18n/locales/en-US/messages/validation.json index 407f638c5..c9e9a172c 100644 --- a/dashboard/src/i18n/locales/en-US/messages/validation.json +++ b/dashboard/src/i18n/locales/en-US/messages/validation.json @@ -22,4 +22,4 @@ "upload_failed": "File upload failed", "network_error": "Network connection error, please try again", "operation_cannot_be_undone": "⚠️ This operation cannot be undone, please choose carefully!" -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/ru-RU/core/header.json b/dashboard/src/i18n/locales/ru-RU/core/header.json index a23f3dfd3..eb2ce7f23 100644 --- a/dashboard/src/i18n/locales/ru-RU/core/header.json +++ b/dashboard/src/i18n/locales/ru-RU/core/header.json @@ -1,4 +1,4 @@ -{ +{ "logoTitle": "Панель управления AstrBot", "version": { "hasNewVersion": "Доступна новая версия AstrBot!", @@ -80,20 +80,25 @@ }, "accountDialog": { "title": "Изменить аккаунт", - "securityWarning": "Безопасность: Пожалуйста, смените пароль по умолчанию для защиты аккаунта", + "securityWarning": "Безопасность: Пожалуйста, смените пароль для защиты аккаунта", + "securityWarningLegacy": "Новая версия AstrBot улучшила безопасность. Пожалуйста, измените пароль.", + "securityWarningUpgrade": "Обнаружено устаревшее хранение пароля. Измените пароль, чтобы завершить обновление безопасности.", "form": { "currentPassword": "Текущий пароль", "newPassword": "Новый пароль", "confirmPassword": "Подтвердите новый пароль", "newUsername": "Новое имя пользователя (опционально)", - "passwordHint": "Пароль должен быть не менее 8 символов", + "passwordHint": "Не менее 8 символов, включая заглавные и строчные буквы, а также цифры", "confirmPasswordHint": "Введите новый пароль еще раз", "usernameHint": "Оставьте пустым, если не хотите менять имя пользователя", - "defaultCredentials": "Логин и пароль по умолчанию: astrbot" + "defaultCredentials": "Имя пользователя по умолчанию — astrbot. Начальный пароль генерируется при первом запуске и отображается в консоли." }, "validation": { "passwordRequired": "Введите пароль", "passwordMinLength": "Пароль должен быть не менее 8 символов", + "passwordUppercase": "Пароль должен содержать хотя бы одну заглавную букву", + "passwordLowercase": "Пароль должен содержать хотя бы одну строчную букву", + "passwordDigit": "Пароль должен содержать хотя бы одну цифру", "passwordMatch": "Паролы не совпадают", "usernameMinLength": "Имя пользователя должно быть не менее 3 символов" }, @@ -105,4 +110,4 @@ "updateFailed": "Ошибка обновления, попробуйте еще раз" } } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/ru-RU/features/auth.json b/dashboard/src/i18n/locales/ru-RU/features/auth.json index d6ba05dc3..8465dea5a 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/auth.json +++ b/dashboard/src/i18n/locales/ru-RU/features/auth.json @@ -2,7 +2,27 @@ "login": "Вход", "username": "Имя пользователя", "password": "Пароль", - "defaultHint": "Логин и пароль по умолчанию: astrbot", + "defaultHint": "Если это ваш первый вход, проверьте пароль по умолчанию в логах.", + "setup": { + "title": "Настройка аккаунта", + "subtitle": "Создайте аккаунт для управления AstrBot", + "username": "Новое имя пользователя", + "password": "Новый пароль", + "confirmPassword": "Подтвердите новый пароль", + "passwordHint": "Минимум 8 символов, включая заглавную букву, строчную букву и цифру.", + "submit": "Завершить настройку", + "validation": { + "usernameRequired": "Введите имя пользователя", + "usernameMinLength": "Имя пользователя должно содержать минимум 3 символа", + "passwordRequired": "Введите пароль", + "passwordMinLength": "Пароль должен содержать минимум 8 символов", + "passwordUppercase": "Пароль должен содержать хотя бы одну заглавную букву", + "passwordLowercase": "Пароль должен содержать хотя бы одну строчную букву", + "passwordDigit": "Пароль должен содержать хотя бы одну цифру", + "confirmPasswordRequired": "Подтвердите пароль", + "passwordMatch": "Пароли не совпадают" + } + }, "logo": { "title": "Панель управления AstrBot", "subtitle": "Добро пожаловать" @@ -11,4 +31,4 @@ "switchToDark": "Перейти на темную тему", "switchToLight": "Перейти на светлую тему" } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/ru-RU/messages/validation.json b/dashboard/src/i18n/locales/ru-RU/messages/validation.json index 07d061579..41f1de81d 100644 --- a/dashboard/src/i18n/locales/ru-RU/messages/validation.json +++ b/dashboard/src/i18n/locales/ru-RU/messages/validation.json @@ -22,4 +22,4 @@ "upload_failed": "Загрузка не удалась", "network_error": "Ошибка сети, попробуйте снова", "operation_cannot_be_undone": "⚠️ Это действие нельзя отменить, будьте осторожны!" -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/zh-CN/core/header.json b/dashboard/src/i18n/locales/zh-CN/core/header.json index 3bb985033..9be4af2a5 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/header.json +++ b/dashboard/src/i18n/locales/zh-CN/core/header.json @@ -1,5 +1,5 @@ { - "logoTitle": "AstrBot 仪表盘", + "logoTitle": "AstrBot WebUI", "version": { "hasNewVersion": "AstrBot 有新版本!", "dashboardHasNewVersion": "WebUI 有新版本!" @@ -80,20 +80,25 @@ }, "accountDialog": { "title": "修改账户", - "securityWarning": "安全提醒: 请修改默认密码以确保账户安全", + "securityWarning": "安全提醒: 请修改密码以确保账户安全", + "securityWarningLegacy": "新的 AstrBot 版本强化了安全策略,请重新修改密码", + "securityWarningUpgrade": "检测到旧版本密码存储方式,请修改密码以完成安全升级", "form": { "currentPassword": "当前密码", "newPassword": "新密码", "confirmPassword": "确认新密码", "newUsername": "新用户名 (可选)", - "passwordHint": "密码长度至少 8 位", + "passwordHint": "长度至少 8 位,且包含大写字母、小写字母和数字", "confirmPasswordHint": "请再次输入新密码以确认", "usernameHint": "留空表示不修改用户名", - "defaultCredentials": "默认用户名和密码均为 astrbot" + "defaultCredentials": "默认用户名为 astrbot,初始密码会在首次启动时自动生成并显示在控制台中。" }, "validation": { "passwordRequired": "请输入密码", "passwordMinLength": "密码长度至少 8 位", + "passwordUppercase": "密码必须包含至少一个大写字母", + "passwordLowercase": "密码必须包含至少一个小写字母", + "passwordDigit": "密码必须包含至少一个数字", "passwordMatch": "两次输入的密码不一致", "usernameMinLength": "用户名长度至少3位" }, diff --git a/dashboard/src/i18n/locales/zh-CN/features/auth.json b/dashboard/src/i18n/locales/zh-CN/features/auth.json index d6da99943..c6cc4efe3 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/auth.json +++ b/dashboard/src/i18n/locales/zh-CN/features/auth.json @@ -2,7 +2,27 @@ "login": "登录", "username": "用户名", "password": "密码", - "defaultHint": "默认账户和密码均为:astrbot", + "defaultHint": "如果是第一次登录,请留意日志输出的默认密码", + "setup": { + "title": "设置账户", + "subtitle": "创建用于管理 AstrBot 的账户", + "username": "新用户名", + "password": "新密码", + "confirmPassword": "确认新密码", + "passwordHint": "长度至少 8 位,且包含大写字母、小写字母和数字", + "submit": "完成设置", + "validation": { + "usernameRequired": "请输入用户名", + "usernameMinLength": "用户名长度至少3位", + "passwordRequired": "请输入密码", + "passwordMinLength": "密码长度至少 8 位", + "passwordUppercase": "密码必须包含至少一个大写字母", + "passwordLowercase": "密码必须包含至少一个小写字母", + "passwordDigit": "密码必须包含至少一个数字", + "confirmPasswordRequired": "请确认密码", + "passwordMatch": "两次输入的密码不一致" + } + }, "logo": { "title": "AstrBot WebUI", "subtitle": "欢迎使用" @@ -11,4 +31,4 @@ "switchToDark": "切换到深色主题", "switchToLight": "切换到浅色主题" } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/zh-CN/messages/validation.json b/dashboard/src/i18n/locales/zh-CN/messages/validation.json index da12ae79b..7810fe65d 100644 --- a/dashboard/src/i18n/locales/zh-CN/messages/validation.json +++ b/dashboard/src/i18n/locales/zh-CN/messages/validation.json @@ -22,4 +22,4 @@ "upload_failed": "文件上传失败", "network_error": "网络连接错误,请重试", "operation_cannot_be_undone": "⚠️ 此操作无法撤销,请谨慎选择!" -} \ No newline at end of file +} diff --git a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue index c01ef4d46..a1fb67eb8 100644 --- a/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue +++ b/dashboard/src/layouts/full/vertical-header/VerticalHeader.vue @@ -3,12 +3,12 @@ import { ref, computed, watch, onMounted } from "vue"; import { useCustomizerStore } from "@/stores/customizer"; import axios from "axios"; import Logo from "@/components/shared/Logo.vue"; -import { md5 } from "js-md5"; import { useAuthStore } from "@/stores/auth"; import { useCommonStore } from "@/stores/common"; import { MarkdownRender, enableKatex, enableMermaid } from "markstream-vue"; import "markstream-vue/index.css"; import "katex/dist/katex.min.css"; +import "highlight.js/styles/github.css"; import { useI18n } from "@/i18n/composables"; import { router } from "@/router"; import { useRoute } from "vue-router"; @@ -31,6 +31,8 @@ const LAST_BOT_ROUTE_KEY = "astrbot:last_bot_route"; const LAST_CHAT_ROUTE_KEY = "astrbot:last_chat_route"; let dialog = ref(false); let accountWarning = ref(false); +let accountWarningLegacy = ref(false); +let accountWarningUpgrade = ref(false); let updateStatusDialog = ref(false); let aboutDialog = ref(false); const username = localStorage.getItem("user"); @@ -107,6 +109,14 @@ const passwordRules = computed(() => [ (v: string) => v.length >= 8 || t("core.header.accountDialog.validation.passwordMinLength"), + (v: string) => + /[A-Z]/.test(v) || + t("core.header.accountDialog.validation.passwordUppercase"), + (v: string) => + /[a-z]/.test(v) || + t("core.header.accountDialog.validation.passwordLowercase"), + (v: string) => + /\d/.test(v) || t("core.header.accountDialog.validation.passwordDigit"), ]); const confirmPasswordRules = computed(() => [ (v: string) => @@ -250,17 +260,15 @@ function accountEdit() { accountEditStatus.value.error = false; accountEditStatus.value.success = false; - const passwordHash = password.value ? md5(password.value) : ""; - const newPasswordHash = newPassword.value ? md5(newPassword.value) : ""; - const confirmPasswordHash = confirmPassword.value - ? md5(confirmPassword.value) - : ""; + const currentPasswordValue = password.value ? password.value : ""; + const newPasswordValue = newPassword.value ? newPassword.value : ""; + const confirmPasswordValue = confirmPassword.value ? confirmPassword.value : ""; axios .post("/api/auth/account/edit", { - password: passwordHash, - new_password: newPasswordHash, - confirm_password: confirmPasswordHash, + password: currentPasswordValue, + new_password: newPasswordValue, + confirm_password: confirmPasswordValue, new_username: newUsername.value ? newUsername.value : username, }) .then((res) => { @@ -306,13 +314,37 @@ function getVersion() { res.data.data.version, res.data.data?.dashboard_version, ); - let change_pwd_hint = res.data.data?.change_pwd_hint; - if (change_pwd_hint) { + const change_pwd_hint = res.data.data?.change_pwd_hint; + const legacy_pwd_hint = res.data.data?.legacy_pwd_hint; + const password_upgrade_required = + res.data.data?.password_upgrade_required; + if (change_pwd_hint || legacy_pwd_hint || password_upgrade_required) { dialog.value = true; accountWarning.value = true; - localStorage.setItem("change_pwd_hint", "true"); + accountWarningUpgrade.value = !!password_upgrade_required; + accountWarningLegacy.value = + !!legacy_pwd_hint && !password_upgrade_required; + if (change_pwd_hint || (legacy_pwd_hint && !password_upgrade_required)) { + localStorage.setItem("change_pwd_hint", "true"); + } else { + localStorage.removeItem("change_pwd_hint"); + } + if (legacy_pwd_hint && !password_upgrade_required) { + localStorage.setItem("legacy_pwd_hint", "true"); + } else { + localStorage.removeItem("legacy_pwd_hint"); + } + if (password_upgrade_required) { + localStorage.setItem("password_upgrade_required", "true"); + } else { + localStorage.removeItem("password_upgrade_required"); + } } else { + accountWarningLegacy.value = false; + accountWarningUpgrade.value = false; localStorage.removeItem("change_pwd_hint"); + localStorage.removeItem("legacy_pwd_hint"); + localStorage.removeItem("password_upgrade_required"); } }) .catch((err) => { @@ -320,6 +352,20 @@ function getVersion() { }); } +function initPasswordWarningFromStorage() { + const hasChangePwdHint = localStorage.getItem("change_pwd_hint") === "true"; + const hasLegacyPwdHint = localStorage.getItem("legacy_pwd_hint") === "true"; + const hasPasswordUpgradeRequired = + localStorage.getItem("password_upgrade_required") === "true"; + if (hasChangePwdHint || hasLegacyPwdHint || hasPasswordUpgradeRequired) { + dialog.value = true; + accountWarning.value = true; + accountWarningUpgrade.value = hasPasswordUpgradeRequired; + accountWarningLegacy.value = + hasLegacyPwdHint && !hasPasswordUpgradeRequired; + } +} + function checkUpdate() { updateStatus.value = t("core.header.updateDialog.status.checking"); axios @@ -435,6 +481,7 @@ function handleLogoClick() { getVersion(); checkUpdate(); +initPasswordWarningFromStorage(); commonStore.createEventSource(); // log commonStore.getStartTime(); @@ -1116,7 +1163,7 @@ onMounted(async () => { >