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
This commit is contained in:
LIghtJUNction
2026-03-23 12:49:13 +08:00
parent f2c0c2a9de
commit f55016bcb2
17 changed files with 87 additions and 68 deletions
+8 -13
View File
@@ -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
@@ -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)
+3 -1
View File
@@ -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,
+15 -6
View File
@@ -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):
+18 -16
View File
@@ -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(
+2 -2
View File
@@ -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)
+3
View File
@@ -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
@@ -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)
@@ -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",
@@ -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:
@@ -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))
@@ -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,
)
@@ -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,
)
@@ -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
+2 -2
View File
@@ -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:
+3 -3
View File
@@ -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 (
+3 -3
View File
@@ -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),
}