mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-12 23:47:22 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user