From f55016bcb22e34ba0f7429d7df604a3f29ce51af Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 23 Mar 2026 12:49:13 +0800 Subject: [PATCH] refactor: type annotations and i18n improvements - Add i18n support to cmd_conf.py (config validators) - Fix BaseFunctionToolExecutor to be an ABC - Add explicit type annotations for dict payloads - Various type annotation improvements --- astrbot/cli/commands/cmd_conf.py | 21 +++++------- .../agent/runners/tool_loop_agent_runner.py | 4 +-- astrbot/core/agent/tool_executor.py | 4 ++- astrbot/core/astr_main_agent.py | 21 ++++++++---- astrbot/core/astr_main_agent_resources.py | 34 ++++++++++--------- astrbot/core/astrbot_config_mgr.py | 4 +-- astrbot/core/computer/tools/browser.py | 3 ++ .../core/pipeline/process_stage/follow_up.py | 29 ++++++++++------ .../aiocqhttp/aiocqhttp_platform_adapter.py | 4 +-- .../core/platform/sources/kook/kook_client.py | 4 +-- .../sources/misskey/misskey_adapter.py | 5 +-- .../sources/xinference_rerank_source.py | 2 +- .../sources/xinference_stt_provider.py | 2 +- astrbot/core/provider/sources/zhipu_source.py | 2 +- astrbot/core/star/context.py | 4 +-- astrbot/dashboard/routes/tools.py | 6 ++-- astrbot/dashboard/routes/tui_chat.py | 6 ++-- 17 files changed, 87 insertions(+), 68 deletions(-) diff --git a/astrbot/cli/commands/cmd_conf.py b/astrbot/cli/commands/cmd_conf.py index def0738a4..ed6215e7b 100644 --- a/astrbot/cli/commands/cmd_conf.py +++ b/astrbot/cli/commands/cmd_conf.py @@ -20,6 +20,7 @@ import argon2.exceptions as argon2_exceptions import click from argon2 import PasswordHasher +from astrbot.cli.i18n import t from astrbot.core.config.default import DEFAULT_CONFIG from astrbot.core.utils.astrbot_path import astrbot_paths @@ -111,9 +112,7 @@ def _validate_log_level(value: str) -> str: value_up = value.upper() allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} if value_up not in allowed: - raise click.ClickException( - "Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL" - ) + raise click.ClickException(t("config_log_level_invalid")) return value_up @@ -121,21 +120,21 @@ def _validate_dashboard_port(value: str) -> int: try: port = int(value) except ValueError: - raise click.ClickException("Port must be a number") + raise click.ClickException(t("config_port_must_be_number")) if port < 1 or port > 65535: - raise click.ClickException("Port must be in range 1-65535") + raise click.ClickException(t("config_port_range_invalid")) return port def _validate_dashboard_username(value: str) -> str: if value is None or value.strip() == "": - raise click.ClickException("Username cannot be empty") + raise click.ClickException(t("config_username_empty")) return value.strip() def _validate_dashboard_password(value: str) -> str: if value is None or value == "": - raise click.ClickException("Password cannot be empty") + raise click.ClickException(t("config_password_empty")) # Return the canonical stored representation. return hash_dashboard_password_secure(value) @@ -144,17 +143,13 @@ def _validate_timezone(value: str) -> str: try: zoneinfo.ZoneInfo(value) except Exception: - raise click.ClickException( - f"Invalid timezone: {value}. Please use a valid IANA timezone name" - ) + raise click.ClickException(t("config_timezone_invalid", value=value)) return value def _validate_callback_api_base(value: str) -> str: if not (value.startswith("http://") or value.startswith("https://")): - raise click.ClickException( - "Callback API base must start with http:// or https://" - ) + raise click.ClickException(t("config_callback_invalid")) return value diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index ffa3aa5a1..9d7d9616e 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -224,7 +224,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): self, *, include_model: bool = True ) -> AsyncGenerator[LLMResponse, None]: """Yields chunks *and* a final LLMResponse.""" - payload = { + payload: dict[str, Any] = { "contexts": self.run_context.messages, # list[Message] "func_tool": self.req.func_tool, "session_id": self.req.session_id, @@ -236,7 +236,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): payload["model"] = self.req.model if self.streaming: stream = self.provider.text_chat_stream(**payload) - async for resp in stream: # type: ignore + async for resp in stream: yield resp else: yield await self.provider.text_chat(**payload) diff --git a/astrbot/core/agent/tool_executor.py b/astrbot/core/agent/tool_executor.py index 2704119d4..d7a52a404 100644 --- a/astrbot/core/agent/tool_executor.py +++ b/astrbot/core/agent/tool_executor.py @@ -1,5 +1,6 @@ from collections.abc import AsyncGenerator from typing import Any, Generic +import abc import mcp @@ -7,8 +8,9 @@ from .run_context import ContextWrapper, TContext from .tool import FunctionTool -class BaseFunctionToolExecutor(Generic[TContext]): +class BaseFunctionToolExecutor(abc.ABC, Generic[TContext]): @classmethod + @abc.abstractmethod async def execute( cls, tool: FunctionTool, diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 71946cffe..a341d8540 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -499,8 +499,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: Any = provider_settings.get("image_compress_options", {}) + if isinstance(raw_options, dict): + options = dict(raw_options) + else: + options: dict[str, Any] = {} max_size = options.get("max_size", IMAGE_COMPRESS_DEFAULT_MAX_SIZE) if not isinstance(max_size, int): @@ -539,8 +542,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: Any = provider_settings.get("image_compress_options", {}) + if isinstance(raw_options, dict): + options = dict(raw_options) + else: + options: dict[str, Any] = {} max_size = options.get("max_size", IMAGE_COMPRESS_DEFAULT_MAX_SIZE) if not isinstance(max_size, int): @@ -590,8 +596,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: Any = provider_settings.get("image_compress_options", {}) + if isinstance(raw_options, dict): + options = dict(raw_options) + else: + options: dict[str, Any] = {} max_size = options.get("max_size", IMAGE_COMPRESS_DEFAULT_MAX_SIZE) if not isinstance(max_size, int): diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py index 887a2f047..d92fda533 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -2,6 +2,7 @@ import base64 import json import os import uuid +from typing import Any import anyio from pydantic import Field @@ -167,21 +168,22 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): return path, False async def call( - self, context: ContextWrapper[AstrAgentContext], **kwargs + self, context: ContextWrapper[AstrAgentContext], **kwargs: Any ) -> ToolExecResult: session = kwargs.get("session") or context.context.event.unified_msg_origin - messages = kwargs.get("messages") + messages_raw: list[dict[str, Any]] | None = kwargs.get("messages") - if not isinstance(messages, list) or not messages: + if not isinstance(messages_raw, list) or not messages_raw: return "error: messages parameter is empty or invalid." components: list[Comp.BaseMessageComponent] = [] - for idx, msg in enumerate(messages): + for idx, msg in enumerate(messages_raw): if not isinstance(msg, dict): return f"error: messages[{idx}] should be an object." - msg_type = str(msg.get("type", "")).lower() + msg_dict: dict[str, Any] = msg # type: ignore[assignment] + msg_type = str(msg_dict.get("type", "")).lower() if not msg_type: return f"error: messages[{idx}].type is required." @@ -189,13 +191,13 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): try: if msg_type == "plain": - text = str(msg.get("text", "")).strip() + text = str(msg_dict.get("text", "")).strip() if not text: return f"error: messages[{idx}].text is required for plain component." components.append(Comp.Plain(text=text)) elif msg_type == "image": - path = msg.get("path") - url = msg.get("url") + path = msg_dict.get("path") + url = msg_dict.get("url") if path: ( local_path, @@ -207,8 +209,8 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): else: return f"error: messages[{idx}] must include path or url for image component." elif msg_type == "record": - path = msg.get("path") - url = msg.get("url") + path = msg_dict.get("path") + url = msg_dict.get("url") if path: ( local_path, @@ -220,8 +222,8 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): else: return f"error: messages[{idx}] must include path or url for record component." elif msg_type == "video": - path = msg.get("path") - url = msg.get("url") + path = msg_dict.get("path") + url = msg_dict.get("url") if path: ( local_path, @@ -233,10 +235,10 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): else: return f"error: messages[{idx}] must include path or url for video component." elif msg_type == "file": - path = msg.get("path") - url = msg.get("url") + path = msg_dict.get("path") + url = msg_dict.get("url") name = ( - msg.get("text") + msg_dict.get("text") or (os.path.basename(path) if path else "") or (os.path.basename(url) if url else "") or "file" @@ -252,7 +254,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): else: return f"error: messages[{idx}] must include path or url for file component." elif msg_type == "mention_user": - mention_user_id = msg.get("mention_user_id") + mention_user_id = msg_dict.get("mention_user_id") if not mention_user_id: return f"error: messages[{idx}].mention_user_id is required for mention_user component." components.append( diff --git a/astrbot/core/astrbot_config_mgr.py b/astrbot/core/astrbot_config_mgr.py index c42418319..2fa83cf60 100644 --- a/astrbot/core/astrbot_config_mgr.py +++ b/astrbot/core/astrbot_config_mgr.py @@ -266,8 +266,8 @@ class AstrBotConfigManager: self, umo: str | None = None, key: str | None = None, - default: _VT = None, - ) -> _VT: + default: _VT | None = None, + ) -> _VT | None: """获取配置项。umo 为 None 时使用默认配置""" if umo is None: return self.confs["default"].get(key, default) diff --git a/astrbot/core/computer/tools/browser.py b/astrbot/core/computer/tools/browser.py index 45591b068..b8ad9c1ee 100644 --- a/astrbot/core/computer/tools/browser.py +++ b/astrbot/core/computer/tools/browser.py @@ -76,6 +76,7 @@ class BrowserExecTool(FunctionTool): tags: str | None = None, learn: bool = False, include_trace: bool = False, + **kwargs: Any, ) -> ToolExecResult: if err := _ensure_admin(context): return err @@ -139,6 +140,7 @@ class BrowserBatchExecTool(FunctionTool): tags: str | None = None, learn: bool = False, include_trace: bool = False, + **kwargs: Any, ) -> ToolExecResult: if err := _ensure_admin(context): return err @@ -186,6 +188,7 @@ class RunBrowserSkillTool(FunctionTool): include_trace: bool = False, description: str | None = None, tags: str | None = None, + **kwargs: Any, ) -> ToolExecResult: if err := _ensure_admin(context): return err diff --git a/astrbot/core/pipeline/process_stage/follow_up.py b/astrbot/core/pipeline/process_stage/follow_up.py index 79ec16a85..6fd49c72a 100644 --- a/astrbot/core/pipeline/process_stage/follow_up.py +++ b/astrbot/core/pipeline/process_stage/follow_up.py @@ -2,14 +2,23 @@ from __future__ import annotations import asyncio from dataclasses import dataclass +from typing import TypedDict from astrbot import logger from astrbot.core.agent.runners.tool_loop_agent_runner import FollowUpTicket from astrbot.core.astr_agent_run_util import AgentRunner from astrbot.core.platform.astr_message_event import AstrMessageEvent + +class _FollowUpStatusDict(TypedDict): + statuses: dict[int, str] + next_order: int + next_turn: int + condition: asyncio.Condition + + _ACTIVE_AGENT_RUNNERS: dict[str, AgentRunner] = {} -_FOLLOW_UP_ORDER_STATE: dict[str, dict[str, object]] = {} +_FOLLOW_UP_ORDER_STATE: dict[str, _FollowUpStatusDict] = {} """UMO-level follow-up order state. State fields: @@ -43,28 +52,26 @@ def unregister_active_runner(umo: str, runner: AgentRunner) -> None: _ACTIVE_AGENT_RUNNERS.pop(umo, None) -def _get_follow_up_order_state(umo: str) -> dict[str, object]: +def _get_follow_up_order_state(umo: str) -> _FollowUpStatusDict: state = _FOLLOW_UP_ORDER_STATE.get(umo) if state is None: - state = { - "condition": asyncio.Condition(), + state = _FollowUpStatusDict( + condition=asyncio.Condition(), # Sequence status map for strict in-order resume after unresolved follow-ups. - "statuses": {}, + statuses={}, # Stable allocator for arrival order; never decreases for the same UMO state. - "next_order": 0, + next_order=0, # The sequence currently allowed to continue main internal flow. - "next_turn": 0, - } + next_turn=0, + ) _FOLLOW_UP_ORDER_STATE[umo] = state return state -def _advance_follow_up_turn_locked(state: dict[str, object]) -> None: +def _advance_follow_up_turn_locked(state: _FollowUpStatusDict) -> None: # Skip slots that are already handled, and stop at the first unfinished slot. statuses = state["statuses"] - assert isinstance(statuses, dict) next_turn = state["next_turn"] - assert isinstance(next_turn, int) while True: curr = statuses.get(next_turn) diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index 8f98ed2b3..a89f027c3 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -4,7 +4,7 @@ import itertools import logging import time import uuid -from collections.abc import Awaitable +from collections.abc import Awaitable, Coroutine from typing import Any, cast from aiocqhttp import CQHttp, Event @@ -423,7 +423,7 @@ class AiocqhttpAdapter(Platform): return abm - def run(self) -> Awaitable[Any]: + def run(self) -> Coroutine[Any, Any, None]: if not self.host or not self.port: logger.warning( "aiocqhttp: 未配置 ws_reverse_host 或 ws_reverse_port,将使用默认值:http://0.0.0.0:6199", diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index 984d7a69d..c7d1544b6 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -363,8 +363,8 @@ class KookClient: "type": kook_message_type, } if reply_message_id: - payload["quote"] = reply_message_id - payload["reply_msg_id"] = reply_message_id + payload["quote"] = str(reply_message_id) + payload["reply_msg_id"] = str(reply_message_id) try: async with self._http_client.post(url, json=payload) as resp: diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index d1b9354d5..aae986f3a 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -200,7 +200,8 @@ class MisskeyPlatformAdapter(Platform): try: if not isinstance(message.raw_message, dict): message.raw_message = {} - message.raw_message["poll"] = poll + raw_msg: dict[str, Any] = message.raw_message # type: ignore[assignment] + raw_msg["poll"] = poll message.__setattr__("poll", poll) except Exception: pass @@ -542,7 +543,7 @@ class MisskeyPlatformAdapter(Platform): for r in results: if not r: continue - if isinstance(r, dict) and r.get("fallback_url"): + if isinstance(r, dict) and r.get("fallback_url"): # type: ignore[assignment] url = r.get("fallback_url") if url: fallback_urls.append(str(url)) diff --git a/astrbot/core/provider/sources/xinference_rerank_source.py b/astrbot/core/provider/sources/xinference_rerank_source.py index 276b57c67..f21bd1ae8 100644 --- a/astrbot/core/provider/sources/xinference_rerank_source.py +++ b/astrbot/core/provider/sources/xinference_rerank_source.py @@ -1,7 +1,7 @@ from typing import cast from astrbot.core.entities import ProviderType, RerankResult -from astrbot.core.register import register_provider_adapter +from astrbot.core.provider.register import register_provider_adapter from xinference_client.client.restful.async_restful_client import ( AsyncClient as Client, ) diff --git a/astrbot/core/provider/sources/xinference_stt_provider.py b/astrbot/core/provider/sources/xinference_stt_provider.py index 72680651d..25df439fb 100644 --- a/astrbot/core/provider/sources/xinference_stt_provider.py +++ b/astrbot/core/provider/sources/xinference_stt_provider.py @@ -4,7 +4,7 @@ import aiofiles import aiohttp import anyio from astrbot.core.entities import ProviderType -from astrbot.core.register import register_provider_adapter +from astrbot.core.provider.register import register_provider_adapter from xinference_client.client.restful.async_restful_client import ( AsyncClient as Client, ) diff --git a/astrbot/core/provider/sources/zhipu_source.py b/astrbot/core/provider/sources/zhipu_source.py index 0bedbb946..365a5a24c 100644 --- a/astrbot/core/provider/sources/zhipu_source.py +++ b/astrbot/core/provider/sources/zhipu_source.py @@ -2,7 +2,7 @@ # It is no longer specifically adapted to Zhipu's models. To ensure compatibility, this -from astrbot.core.register import register_provider_adapter +from astrbot.core.provider.register import register_provider_adapter from .openai_source import ProviderOpenAIOfficial diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index e7a01ebc4..4d0efccbd 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -66,7 +66,7 @@ class Context: registered_web_apis: list | None = None # 向后兼容的变量 - _register_tasks: list[Awaitable[Any]] | None = None + _register_tasks: list[Coroutine[Any, Any, Any]] | None = None _star_manager: StarManagerProtocol | None = None def __init__( @@ -676,7 +676,7 @@ class Context: ) star_handlers_registry.append(md) - def register_task(self, task: Awaitable[Any], desc: str) -> None: + def register_task(self, task: Coroutine[Any, Any, Any], desc: str) -> None: """[DEPRECATED]注册一个异步任务。 Args: diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py index 067139804..4fc24bbf0 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -170,7 +170,7 @@ class ToolsRoute(Route): await self.tool_mgr.enable_mcp_server( name, server_config, - timeout=30, + init_timeout=30, ) except TimeoutError: rollback_ok = self._rollback_mcp_server(name) @@ -271,7 +271,7 @@ class ToolsRoute(Route): or is_rename ): try: - await self.tool_mgr.disable_mcp_server(old_name, timeout=10) + await self.tool_mgr.disable_mcp_server(old_name, shutdown_timeout=10) except TimeoutError as e: return ( Response() @@ -293,7 +293,7 @@ class ToolsRoute(Route): await self.tool_mgr.enable_mcp_server( name, config["mcpServers"][name], - timeout=30, + init_timeout=30, ) except TimeoutError: return ( diff --git a/astrbot/dashboard/routes/tui_chat.py b/astrbot/dashboard/routes/tui_chat.py index 560cac17b..e1c84e65f 100644 --- a/astrbot/dashboard/routes/tui_chat.py +++ b/astrbot/dashboard/routes/tui_chat.py @@ -4,7 +4,7 @@ import os import uuid from contextlib import asynccontextmanager from pathlib import Path -from typing import cast +from typing import Any, cast import anyio from quart import Response as QuartResponse @@ -220,7 +220,7 @@ class TUIChatRoute(Route): if text: bot_message_parts.append({"type": "plain", "text": text}) - new_his = {"type": "bot", "message": bot_message_parts} + new_his: dict[str, Any] = {"type": "bot", "message": bot_message_parts} if reasoning: new_his["reasoning"] = reasoning if agent_stats: @@ -713,7 +713,7 @@ class TUIChatRoute(Route): history_res = [history.model_dump() for history in history_ls] - response_data = { + response_data: dict[str, Any] = { "history": history_res, "is_running": self.running_convs.get(session_id, False), }