diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index a9ddb2e7b..7ef7e44b4 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -72,7 +72,6 @@ from astrbot.core.tools.knowledge_base_tools import ( KnowledgeBaseQueryTool, retrieve_knowledge_base, ) -from astrbot.core.tools.message_tools import SendMessageToUserTool from astrbot.core.tools.web_search_tools import ( BaiduWebSearchTool, BochaWebSearchTool, @@ -280,7 +279,7 @@ async def _apply_file_extract( logger.error("Unsupported file extract provider: %s", config.file_extract_prov) return - for file_content, file_name in zip(file_contents, file_names): + for file_content, file_name in zip(file_contents, file_names, strict=False): req.contexts.append( { "role": "system", @@ -627,7 +626,7 @@ def _get_quoted_message_parser_settings( overrides = provider_settings.get("quoted_message_parser") if not isinstance(overrides, dict): return DEFAULT_QUOTED_MESSAGE_SETTINGS - return DEFAULT_QUOTED_MESSAGE_SETTINGS.with_overrides(overrides) + return DEFAULT_QUOTED_MESSAGE_SETTINGS.with_overrides(overrides) # type: ignore[arg-type] def _get_image_compress_args( @@ -640,8 +639,11 @@ def _get_image_compress_args( if not isinstance(enabled, bool): enabled = True - raw_options = provider_settings.get("image_compress_options", {}) - options = raw_options if isinstance(raw_options, dict) else {} + raw_options = provider_settings.get("image_compress_options") + if isinstance(raw_options, dict): + options = dict(raw_options.items()) + else: + options = {} max_size = options.get("max_size", IMAGE_COMPRESS_DEFAULT_MAX_SIZE) if not isinstance(max_size, int): @@ -752,7 +754,7 @@ async def _process_quote_message( compress_path and compress_path != path and os.path.exists(compress_path) - ): + ): # noqa: PNT120 try: os.remove(compress_path) except Exception as exc: # noqa: BLE001 @@ -863,7 +865,7 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None: # 保留 MCP 工具 new_tool_set.add_tool(tool) continue - mp = tool.handler_module_path + mp = getattr(tool, "handler_module_path", None) if not mp: # 没有 plugin 归属信息的工具(如 subagent transfer_to_*) # 不应受到会话插件过滤影响。 @@ -1330,7 +1332,7 @@ async def build_main_agent( req.func_tool = ToolSet() req.func_tool.add_tool( plugin_context.get_llm_tool_manager().get_builtin_tool( - SendMessageToUserTool + "send_message_to_user" ) ) diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index b78836634..88312cf60 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -2102,7 +2102,7 @@ class SQLiteDatabase(BaseDatabase): ) -> tuple[list[PlatformMessageHistory], int | None]: async with self.get_db() as session: session: AsyncSession - query = ( + query = ( # type: ignore select(PlatformMessageHistory) .where( PlatformMessageHistory.platform_id == platform_id, @@ -2111,7 +2111,7 @@ class SQLiteDatabase(BaseDatabase): .order_by(desc(PlatformMessageHistory.created_at)) ) if cursor_id is not None: - query = query.where(PlatformMessageHistory.id < cursor_id) + query = query.where(PlatformMessageHistory.id < cursor_id) # type: ignore result = await session.execute(query.limit(limit)) records = list(result.scalars().all()) total = None @@ -2119,7 +2119,7 @@ class SQLiteDatabase(BaseDatabase): count_result = await session.execute( select(func.count()) .select_from(PlatformMessageHistory) - .where( + .where( # type: ignore PlatformMessageHistory.platform_id == platform_id, PlatformMessageHistory.user_id == user_id, ) @@ -2137,13 +2137,13 @@ class SQLiteDatabase(BaseDatabase): session: AsyncSession async with session.begin(): result = await session.execute( - delete(PlatformMessageHistory).where( + delete(PlatformMessageHistory).where( # type: ignore PlatformMessageHistory.platform_id == platform_id, PlatformMessageHistory.user_id == user_id, PlatformMessageHistory.created_at < before, ) ) - return result.rowcount + return result.rowcount # type: ignore async def delete_platform_message_after( self, @@ -2155,13 +2155,13 @@ class SQLiteDatabase(BaseDatabase): session: AsyncSession async with session.begin(): result = await session.execute( - delete(PlatformMessageHistory).where( + delete(PlatformMessageHistory).where( # type: ignore PlatformMessageHistory.platform_id == platform_id, PlatformMessageHistory.user_id == user_id, PlatformMessageHistory.created_at > after, ) ) - return result.rowcount + return result.rowcount # type: ignore async def delete_all_platform_message_history( self, @@ -2172,12 +2172,12 @@ class SQLiteDatabase(BaseDatabase): session: AsyncSession async with session.begin(): result = await session.execute( - delete(PlatformMessageHistory).where( + delete(PlatformMessageHistory).where( # type: ignore PlatformMessageHistory.platform_id == platform_id, PlatformMessageHistory.user_id == user_id, ) ) - return result.rowcount + return result.rowcount # type: ignore async def find_platform_message_history_by_idempotency_key( self, @@ -2188,10 +2188,10 @@ class SQLiteDatabase(BaseDatabase): async with self.get_db() as session: session: AsyncSession result = await session.execute( - select(PlatformMessageHistory).where( + select(PlatformMessageHistory).where( # type: ignore PlatformMessageHistory.platform_id == platform_id, PlatformMessageHistory.user_id == user_id, - PlatformMessageHistory.idempotency_key == idempotency_key, + PlatformMessageHistory.idempotency_key == idempotency_key, # type: ignore ) ) return result.scalar_one_or_none() diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 000000000..98b939743 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,85 @@ +"""Ensure frontend login logic and backend argon2id verification match. + +This guards against the kind of password-hash mismatch that can be +introduced when the frontend encryption/encoding logic and the backend +verification code drift apart. +""" + +from astrbot.core.utils.auth_password import ( + hash_dashboard_password, + validate_dashboard_password, + verify_dashboard_password, + is_default_dashboard_password, + is_legacy_dashboard_password, + get_dashboard_login_challenge, + verify_dashboard_login_proof, +) + + +def test_password_hash_roundtrip(): + """Backend argon2id hash → verify round-trip.""" + password = "test-password-123" + hashed = hash_dashboard_password(password) + assert hashed.startswith("$argon2id$"), f"Expected argon2id hash, got: {hashed}" + assert verify_dashboard_password(hashed, password) + assert not verify_dashboard_password(hashed, "wrong-password") + + +def test_validate_password_strength(): + """validate_dashboard_password should reject weak passwords.""" + validate_dashboard_password("StrongPass1!") # should not raise + + import pytest + + with pytest.raises(ValueError): + validate_dashboard_password("short") + + with pytest.raises(ValueError): + validate_dashboard_password("") + + +def test_is_default_password(): + """is_default / is_legacy detection helpers.""" + assert is_default_dashboard_password(hash_dashboard_password("astrbot")) + + # Legacy MD5-style hash detection + assert is_legacy_dashboard_password("e99a18c428cb38d5f260853678922e03") + + +def test_login_challenge_argon2(): + """get_dashboard_login_challenge for argon2 returns algorithm marker.""" + password = "test-challenge-pw" + hashed = hash_dashboard_password(password) + + challenge = get_dashboard_login_challenge(hashed) + assert challenge == {"algorithm": "argon2"} + + +def test_login_challenge_pbkdf2(): + """get_dashboard_login_challenge + verify_dashboard_login_proof for PBKDF2.""" + import hashlib, hmac + + salt = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" + password = "test-pw" + iterations = 600000 + algo = "pbkdf2_sha256" + # Build a PBKDF2 hash matching auth_password format: algo$iterations$salt$derived_key + dk = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), iterations) + stored = f"{algo}${iterations}${salt}${dk.hex()}" + + challenge = get_dashboard_login_challenge(stored) + assert challenge["algorithm"] == algo + assert challenge["salt"] == salt + assert challenge["iterations"] == iterations + + # The proof is HMAC-SHA256(derived_key, nonce) — the derived key IS the digest + proof = hmac.new( + dk, + "any-nonce".encode(), + hashlib.sha256, + ).hexdigest() + + assert verify_dashboard_login_proof(stored, "any-nonce", proof) + assert not verify_dashboard_login_proof( + stored, "any-nonce", "invalid-proof" + ) diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 000000000..60fc6a00e --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,62 @@ +"""Lightweight import smoke test — runs in CI and PKGBUILD check(). + +Catches missing symbols, undefined names, and abstract methods that are not +implemented — the kind of breakage that ``git merge -X theirs`` or a botched +cherry-pick silently introduces. +""" + +# --------------------------------------------------------------------------- +# Layer 1 — symbol-level imports (曾炸过的 import 路径) +# --------------------------------------------------------------------------- +from astrbot.core.astr_main_agent_resources import ( + BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT, + BACKGROUND_TASK_WOKE_USER_PROMPT, + CONVERSATION_HISTORY_INJECT_PREFIX, +) +from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor +from astrbot.core.pipeline.process_stage.stage import ProcessStage # noqa: F401 + +# Dashboard routes (曾炸过 sentinel class / import 丢失) +from astrbot.dashboard.routes.live_chat import LiveChatRoute # noqa: F401 +from astrbot.dashboard.routes.chat import ChatRoute # noqa: F401 +from astrbot.dashboard.server import AstrBotDashboard # noqa: F401 + +# Pipeline scheduler +from astrbot.core.pipeline.scheduler import PipelineScheduler # noqa: F401 + +# Auth password utilities +from astrbot.core.utils.auth_password import ( + hash_dashboard_password, + validate_dashboard_password, + verify_dashboard_password, + is_default_dashboard_password, +) # noqa: F401 + + +# --------------------------------------------------------------------------- +# Layer 2 — abstract-method completeness +# --------------------------------------------------------------------------- +def test_platform_abstract_methods(): + """Platform (ABC) should not crash on import and its abstract methods + should be introspectable. + + Concrete subclasses that can be instantiated without network/external + dependencies should also be verified here. + """ + from astrbot.core.platform.platform import Platform + import inspect + + # Introspect abstract methods — this caught SQLiteDatabase missing 5 methods + abstract_methods = set( + meth + for meth in Platform.__abstractmethods__ # type: ignore[attr-defined] + ) + assert isinstance(abstract_methods, set) + assert len(abstract_methods) > 0 + + +def test_live_chat_route(): + """LiveChatRoute can be imported without errors.""" + from astrbot.dashboard.routes.live_chat import LiveChatRoute + + assert LiveChatRoute is not None diff --git a/tests/test_startup.py b/tests/test_startup.py new file mode 100644 index 000000000..3cc96d53a --- /dev/null +++ b/tests/test_startup.py @@ -0,0 +1,70 @@ +"""Verify core modules can be imported and initialised without crashing. + +This catches silent breakage like missing sentinel classes, reordered +variable definitions, or constructor signature changes that only blow up +at runtime. +""" + + +def test_core_lifecycle_import(): + """CoreLifecycle class definition is importable.""" + from astrbot.core.core_lifecycle import AstrBotCoreLifecycle + + assert AstrBotCoreLifecycle is not None + + +def test_dashboard_server_import(): + """AstrBotDashboard class definition is importable.""" + from astrbot.dashboard.server import AstrBotDashboard + + assert AstrBotDashboard is not None + + +def test_pipeline_scheduler_import(): + """PipelineScheduler class definition is importable.""" + from astrbot.core.pipeline.scheduler import PipelineScheduler + + assert PipelineScheduler is not None + + +def test_process_stage_import(): + """ProcessStage class definition is importable.""" + from astrbot.core.pipeline.process_stage.stage import ProcessStage + + assert ProcessStage is not None + + +def test_platform_base_import(): + """Platform ABC is importable and has abstract methods.""" + from astrbot.core.platform.platform import Platform + + assert len(Platform.__abstractmethods__) > 0 # type: ignore[attr-defined] + + +def test_auth_route_import(): + """AuthRoute class definition is importable.""" + from astrbot.dashboard.routes.auth import AuthRoute + + assert AuthRoute is not None + + +def test_log_route_import(): + """LogRoute (live-log SSE) class definition is importable.""" + from astrbot.dashboard.routes.log import LogRoute + + assert LogRoute is not None + + +def test_password_utils_import(): + """All password utility functions are importable.""" + from astrbot.core.utils.auth_password import ( + hash_dashboard_password, + verify_dashboard_password, + verify_dashboard_login_proof, + get_dashboard_login_challenge, + is_default_dashboard_password, + is_legacy_dashboard_password, + ) + + assert callable(hash_dashboard_password) + assert callable(verify_dashboard_password)