diff --git a/.gitignore b/.gitignore index 82b7e4ee4..8b8532bbb 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,6 @@ astrbot.lock chroma venv/* pytest.ini -AGENTS.md IFLOW.md # genie_tts data diff --git a/CLAUDE.md b/CLAUDE.md index 0de35236e..bc7df4872 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,43 @@ AgentContextWrapper(session_manager=ToolSessionManager()) 3. Coverage target: `uv run pytest --cov=astrbot tests/` 4. Test files: `test_*.py` or `*_test.py` +### Code Quality Scoring Test + +The project enforces a **code quality score** via `tests/test_code_quality_typing.py`. All agents must treat this as a hard constraint when modifying code. + +**Run the test:** +```bash +uv run pytest tests/test_code_quality_typing.py -v +``` + +**Scoring rules (target: 100/100, threshold for PASS: 80/100):** + +| Pattern | Cost | +|---------|------| +| `cast(Any, ...)` | -1 pt each | +| `# type: ignore` | -0.5 pt each | +| **BAD** `# type: ignore[...]` (unresolved-import, class-alias, no-name-module, attr-defined, etc.) | **-3 pt each** | +| `bare except:` (no exception type) | -0.5 pt each | +| Duplicate code block (5+ identical lines, ≥2 occurrences) | -2 pt each | + +**Why bad type: ignore is heavily penalized:** +- `# type: ignore[unresolved-import]` — hides missing module/stub issues +- `# type: ignore[class-alias]` — hides improper type alias patterns +- `# type: ignore[attr-defined]` — hides missing attribute errors +- These are **workarounds, not fixes** — they paper over real type errors + +**Scoring formula:** +``` +score = max(0, 100 - cast_any - type_ignore*0.5 - bad_type_ignore*3 - bare_except*0.5 - dup_blocks*2) +``` + +**Agent rules when modifying code:** +1. **Do not add** `# type: ignore[unresolved-import]` or `# type: ignore[class-alias]` — fix the underlying issue instead +2. **Do not use** `cast(Any, ...)` to suppress type errors — use proper type annotations +3. **Do not add** bare `except:` clauses — use `except SomeSpecificException:` +4. **Do not copy-paste** 5+ line blocks — extract to a shared helper function +5. Before committing, run the scoring test and ensure score ≥ 80 + ## Git Conventions ### Commit Messages diff --git a/astrbot/_internal/abc/abp/base_astrbot_abp_client.py b/astrbot/_internal/abc/_abp/base_astrbot_abp_client.py similarity index 100% rename from astrbot/_internal/abc/abp/base_astrbot_abp_client.py rename to astrbot/_internal/abc/_abp/base_astrbot_abp_client.py diff --git a/astrbot/_internal/abc/acp/base_astrbot_acp_client.py b/astrbot/_internal/abc/_acp/base_astrbot_acp_client.py similarity index 100% rename from astrbot/_internal/abc/acp/base_astrbot_acp_client.py rename to astrbot/_internal/abc/_acp/base_astrbot_acp_client.py diff --git a/astrbot/_internal/abc/acp/base_astrbot_acp_server.py b/astrbot/_internal/abc/_acp/base_astrbot_acp_server.py similarity index 100% rename from astrbot/_internal/abc/acp/base_astrbot_acp_server.py rename to astrbot/_internal/abc/_acp/base_astrbot_acp_server.py diff --git a/astrbot/_internal/abc/lsp/base_astrbot_lsp_client.py b/astrbot/_internal/abc/_lsp/base_astrbot_lsp_client.py similarity index 100% rename from astrbot/_internal/abc/lsp/base_astrbot_lsp_client.py rename to astrbot/_internal/abc/_lsp/base_astrbot_lsp_client.py diff --git a/astrbot/_internal/abc/mcp/base_astrbot_mcp_client.py b/astrbot/_internal/abc/_mcp/base_astrbot_mcp_client.py similarity index 100% rename from astrbot/_internal/abc/mcp/base_astrbot_mcp_client.py rename to astrbot/_internal/abc/_mcp/base_astrbot_mcp_client.py diff --git a/astrbot/_internal/abc/base_astrbot_orchestrator.py b/astrbot/_internal/abc/base_astrbot_orchestrator.py index c6c17ee89..a60358f16 100644 --- a/astrbot/_internal/abc/base_astrbot_orchestrator.py +++ b/astrbot/_internal/abc/base_astrbot_orchestrator.py @@ -156,7 +156,8 @@ if TYPE_CHECKING: from astrbot._internal.protocols.abp.client import AstrbotAbpClient from astrbot._internal.protocols.acp.client import AstrbotAcpClient from astrbot._internal.protocols.lsp.client import AstrbotLspClient - from astrbot._internal.protocols.mcp.client import McpClient + + from astrbot._internal.protocols._mcp.client import McpClient #: Default heartbeat interval for run_loop() diff --git a/astrbot/_internal/geteway/server.py b/astrbot/_internal/geteway/server.py index e77862ef0..5868a0029 100644 --- a/astrbot/_internal/geteway/server.py +++ b/astrbot/_internal/geteway/server.py @@ -9,7 +9,7 @@ from __future__ import annotations import json from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from astrbot import logger from astrbot._internal.abc.base_astrbot_gateway import BaseAstrbotGateway @@ -23,10 +23,9 @@ else: from fastapi import FastAPI, WebSocket, WebSocketDisconnect except ImportError: logger.warning("FastAPI not installed, gateway unavailable.") - FastAPI = cast(Any, None) - WebSocket = cast(Any, None) - WebSocketDisconnect = cast(Any, None) - + FastAPI = None + WebSocket = None + WebSocketDisconnect = None from fastapi.middleware.cors import CORSMiddleware log = logger @@ -57,15 +56,12 @@ class AstrbotGateway(BaseAstrbotGateway): """ if FastAPI is None: raise RuntimeError("FastAPI is not installed") - log.info(f"Starting AstrBot Gateway on {self._host}:{self._port}") @asynccontextmanager async def lifespan(app: FastAPI): - # Startup log.info("Gateway server started.") yield - # Shutdown await self.ws_manager.broadcast({"type": "server_shutdown"}) log.info("Gateway server stopped.") @@ -75,27 +71,18 @@ class AstrbotGateway(BaseAstrbotGateway): version="1.0.0", lifespan=lifespan, ) - - # CORS middleware self._app.add_middleware( - cast(Any, CORSMiddleware), + CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) - - # Include routers self._setup_routes() - - # Run with uvicorn import uvicorn config = uvicorn.Config( - self._app, - host=self._host, - port=self._port, - log_level="info", + self._app, host=self._host, port=self._port, log_level="info" ) server = uvicorn.Server(config) await server.serve() @@ -104,15 +91,12 @@ class AstrbotGateway(BaseAstrbotGateway): """Set up API routes.""" if self._app is None: return - from fastapi import APIRouter - # Health check @self._app.get("/health") async def health(): return {"status": "ok"} - # WebSocket endpoint @self._app.websocket("/ws") async def websocket_endpoint(ws: WebSocket): await self.ws_manager.connect(ws) @@ -129,7 +113,6 @@ class AstrbotGateway(BaseAstrbotGateway): except WebSocketDisconnect: self.ws_manager.disconnect(ws) - # Stats router stats_router = APIRouter(prefix="/api/stats", tags=["stats"]) @stats_router.get("/overview") @@ -137,8 +120,6 @@ class AstrbotGateway(BaseAstrbotGateway): return await self._get_stats_overview() self._app.include_router(stats_router) - - # Inspector router inspector_router = APIRouter(prefix="/api/inspector", tags=["inspector"]) @inspector_router.get("/stars") @@ -150,8 +131,6 @@ class AstrbotGateway(BaseAstrbotGateway): return await self._get_star_detail(star_name) self._app.include_router(inspector_router) - - # Memory router memory_router = APIRouter(prefix="/api/memory", tags=["memory"]) @memory_router.get("/") @@ -174,16 +153,12 @@ class AstrbotGateway(BaseAstrbotGateway): """ msg_type = message.get("type") data = message.get("data", {}) - if msg_type == "ping": return {"type": "pong", "data": {}} - if msg_type == "call_tool": return await self._handle_call_tool(data) - if msg_type == "get_stars": return {"type": "stars_list", "data": await self._list_stars()} - return { "type": "error", "data": {"message": f"Unknown message type: {msg_type}"}, @@ -194,13 +169,11 @@ class AstrbotGateway(BaseAstrbotGateway): star_name = data.get("star") tool_name = data.get("tool") arguments = data.get("arguments", {}) - if not star_name or not tool_name: return { "type": "tool_result", "data": {"error": "Missing star or tool name"}, } - try: result = await self.orchestrator.abp.call_star_tool( star_name, tool_name, arguments @@ -237,10 +210,7 @@ class AstrbotGateway(BaseAstrbotGateway): import gc gc.collect() - return { - "gc_objects": len(gc.get_objects()), - "python_memory": "N/A", # Would need psutil for actual values - } + return {"gc_objects": len(gc.get_objects()), "python_memory": "N/A"} def set_listen_address(self, host: str, port: int) -> None: """Set the listen address for the gateway server.""" diff --git a/astrbot/_internal/geteway/ws_manager.py b/astrbot/_internal/geteway/ws_manager.py index d06510da7..bcfd95a1b 100644 --- a/astrbot/_internal/geteway/ws_manager.py +++ b/astrbot/_internal/geteway/ws_manager.py @@ -4,7 +4,7 @@ WebSocket connection manager for the AstrBot gateway. from __future__ import annotations -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import anyio @@ -17,8 +17,7 @@ else: from fastapi import WebSocket except ImportError: logger.warning("FastAPI not installed, WebSocketManager unavailable.") - WebSocket = cast(Any, None) - + WebSocket = None log = logger @@ -69,7 +68,6 @@ class WebSocketManager: """ async with self._lock: connections = list(self._connections) - for conn in connections: try: await conn.send_json(data) diff --git a/astrbot/_internal/protocols/abp/__init__.py b/astrbot/_internal/protocols/_abp/__init__.py similarity index 100% rename from astrbot/_internal/protocols/abp/__init__.py rename to astrbot/_internal/protocols/_abp/__init__.py diff --git a/astrbot/_internal/protocols/abp/client.py b/astrbot/_internal/protocols/_abp/client.py similarity index 97% rename from astrbot/_internal/protocols/abp/client.py rename to astrbot/_internal/protocols/_abp/client.py index 4c0725829..a62c47157 100644 --- a/astrbot/_internal/protocols/abp/client.py +++ b/astrbot/_internal/protocols/_abp/client.py @@ -10,7 +10,7 @@ from __future__ import annotations from typing import Any from astrbot import logger -from astrbot._internal.abc.abp.base_astrbot_abp_client import BaseAstrbotAbpClient +from astrbot._internal.abc._abp.base_astrbot_abp_client import BaseAstrbotAbpClient log = logger diff --git a/astrbot/_internal/protocols/acp/__init__.py b/astrbot/_internal/protocols/_acp/__init__.py similarity index 100% rename from astrbot/_internal/protocols/acp/__init__.py rename to astrbot/_internal/protocols/_acp/__init__.py diff --git a/astrbot/_internal/protocols/acp/client.py b/astrbot/_internal/protocols/_acp/client.py similarity index 98% rename from astrbot/_internal/protocols/acp/client.py rename to astrbot/_internal/protocols/_acp/client.py index 6eaf1e316..c56da2e51 100644 --- a/astrbot/_internal/protocols/acp/client.py +++ b/astrbot/_internal/protocols/_acp/client.py @@ -12,7 +12,7 @@ import json from typing import Any from astrbot import logger -from astrbot._internal.abc.acp.base_astrbot_acp_client import BaseAstrbotAcpClient +from astrbot._internal.abc._acp.base_astrbot_acp_client import BaseAstrbotAcpClient log = logger diff --git a/astrbot/_internal/protocols/acp/server.py b/astrbot/_internal/protocols/_acp/server.py similarity index 98% rename from astrbot/_internal/protocols/acp/server.py rename to astrbot/_internal/protocols/_acp/server.py index 9f3159b45..349c19dc0 100644 --- a/astrbot/_internal/protocols/acp/server.py +++ b/astrbot/_internal/protocols/_acp/server.py @@ -13,7 +13,7 @@ from collections.abc import Callable from typing import Any from astrbot import logger -from astrbot._internal.abc.acp.base_astrbot_acp_server import BaseAstrbotAcpServer +from astrbot._internal.abc._acp.base_astrbot_acp_server import BaseAstrbotAcpServer log = logger diff --git a/astrbot/_internal/protocols/lsp/__init__.py b/astrbot/_internal/protocols/_lsp/__init__.py similarity index 100% rename from astrbot/_internal/protocols/lsp/__init__.py rename to astrbot/_internal/protocols/_lsp/__init__.py diff --git a/astrbot/_internal/protocols/lsp/client.py b/astrbot/_internal/protocols/_lsp/client.py similarity index 98% rename from astrbot/_internal/protocols/lsp/client.py rename to astrbot/_internal/protocols/_lsp/client.py index 74d7a7180..6a535a0fd 100644 --- a/astrbot/_internal/protocols/lsp/client.py +++ b/astrbot/_internal/protocols/_lsp/client.py @@ -14,7 +14,7 @@ import anyio from anyio.abc import ByteReceiveStream, ByteSendStream, Process from astrbot import logger -from astrbot._internal.abc.lsp.base_astrbot_lsp_client import BaseAstrbotLspClient +from astrbot._internal.abc._lsp.base_astrbot_lsp_client import BaseAstrbotLspClient log = logger diff --git a/astrbot/_internal/protocols/mcp/__init__.py b/astrbot/_internal/protocols/_mcp/__init__.py similarity index 100% rename from astrbot/_internal/protocols/mcp/__init__.py rename to astrbot/_internal/protocols/_mcp/__init__.py diff --git a/astrbot/_internal/protocols/mcp/client.py b/astrbot/_internal/protocols/_mcp/client.py similarity index 81% rename from astrbot/_internal/protocols/mcp/client.py rename to astrbot/_internal/protocols/_mcp/client.py index 8e2acbdab..cad225a65 100644 --- a/astrbot/_internal/protocols/mcp/client.py +++ b/astrbot/_internal/protocols/_mcp/client.py @@ -6,7 +6,7 @@ import os import sys from contextlib import AsyncExitStack from datetime import timedelta -from typing import Any, cast +from typing import Any from tenacity import ( before_sleep_log, @@ -16,7 +16,7 @@ from tenacity import ( wait_exponential, ) -from astrbot._internal.abc.mcp.base_astrbot_mcp_client import ( +from astrbot._internal.abc._mcp.base_astrbot_mcp_client import ( BaseAstrbotMcpClient, McpServerConfig, McpToolInfo, @@ -24,23 +24,19 @@ from astrbot._internal.abc.mcp.base_astrbot_mcp_client import ( from astrbot.core.utils.log_pipe import LogPipe logger = logging.getLogger("astrbot") - - try: import anyio - import mcp from mcp.client.sse import sse_client except (ModuleNotFoundError, ImportError): logger.warning( "Warning: Missing 'mcp' dependency, MCP services will be unavailable." ) - try: from mcp.client.streamable_http import streamablehttp_client except (ModuleNotFoundError, ImportError): logger.warning( - "Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.", + "Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable." ) @@ -57,11 +53,9 @@ def _prepare_stdio_env(config: dict) -> dict: """Preserve Windows executable resolution for stdio subprocesses.""" if sys.platform != "win32": return config - pathext = os.environ.get("PATHEXT") if not pathext: return config - prepared = config.copy() env = dict(prepared.get("env") or {}) env.setdefault("PATHEXT", pathext) @@ -74,11 +68,9 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: import aiohttp cfg = _prepare_config(config.copy()) - url = cfg["url"] headers = cfg.get("headers", {}) timeout = cfg.get("timeout", 10) - try: if "transport" in cfg: transport_type = cfg["transport"] @@ -86,7 +78,6 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: transport_type = cfg["type"] else: raise Exception("MCP connection config missing transport or type field") - async with aiohttp.ClientSession() as session: if transport_type == "streamable_http": test_payload = { @@ -110,8 +101,8 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: timeout=aiohttp.ClientTimeout(total=timeout), ) as response: if response.status == 200: - return True, "" - return False, f"HTTP {response.status}: {response.reason}" + return (True, "") + return (False, f"HTTP {response.status}: {response.reason}") else: async with session.get( url, @@ -122,34 +113,29 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: timeout=aiohttp.ClientTimeout(total=timeout), ) as response: if response.status == 200: - return True, "" - return False, f"HTTP {response.status}: {response.reason}" - + return (True, "") + return (False, f"HTTP {response.status}: {response.reason}") except asyncio.TimeoutError: - return False, f"Connection timeout: {timeout} seconds" + return (False, f"Connection timeout: {timeout} seconds") except Exception as e: - return False, f"{e!s}" + return (False, f"{e!s}") class McpClient(BaseAstrbotMcpClient): def __init__(self) -> None: - # Initialize session and client objects self.session: mcp.ClientSession | None = None self.exit_stack = AsyncExitStack() - self._old_exit_stacks: list[AsyncExitStack] = [] # Track old stacks for cleanup - + self._old_exit_stacks: list[AsyncExitStack] = [] self.name: str | None = None self.active: bool = True self.tools: list[mcp.Tool] = [] self.server_errlogs: list[str] = [] self.running_event = anyio.Event() self.process_pid: int | None = None - - # Store connection config for reconnection self._mcp_server_config: McpServerConfig | None = None self._server_name: str | None = None - self._reconnect_lock = anyio.Lock() # Lock for thread-safe reconnection - self._reconnecting: bool = False # For logging and debugging + self._reconnect_lock = anyio.Lock() + self._reconnecting: bool = False async def connect(self) -> None: """Initialize the MCP client connection. @@ -157,8 +143,6 @@ class McpClient(BaseAstrbotMcpClient): Note: Actual server connections are made via connect_to_server(). This method prepares the client for use. """ - # MCP client is initialized on-demand via connect_to_server - # This is a no-op stub to satisfy BaseAstrbotMcpClient logger.debug("MCP client initialized.") @property @@ -171,7 +155,7 @@ class McpClient(BaseAstrbotMcpClient): if not self.session: return [] result = await self.list_tools_and_save() - tools = [ + tools: list[McpToolInfo] = [ { "name": tool.name, "description": tool.description or "", @@ -179,13 +163,10 @@ class McpClient(BaseAstrbotMcpClient): } for tool in result.tools ] - return cast(list[McpToolInfo], tools) + return tools async def call_tool( - self, - name: str, - arguments: dict[str, Any], - read_timeout_seconds: int = 60, + self, name: str, arguments: dict[str, Any], read_timeout_seconds: int = 60 ) -> Any: """Call a tool on the MCP server with reconnection support.""" return await self.call_tool_with_reconnect( @@ -224,17 +205,14 @@ class McpClient(BaseAstrbotMcpClient): config: Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server """ - # Store config for reconnection self._mcp_server_config = config self._server_name = name self.process_pid = None - cfg = _prepare_config(dict(config)) def logging_callback( msg: str | mcp.types.LoggingMessageNotificationParams, ) -> None: - # Handle MCP service error logs if isinstance(msg, mcp.types.LoggingMessageNotificationParams): if msg.level in ("warning", "error", "critical", "alert", "emergency"): log_msg = f"[{msg.level.upper()}] {msg.data!s}" @@ -244,16 +222,13 @@ class McpClient(BaseAstrbotMcpClient): success, error_msg = await _quick_test_mcp_connection(cfg) if not success: raise Exception(error_msg) - if "transport" in cfg: transport_type = cfg["transport"] elif "type" in cfg: transport_type = cfg["type"] else: raise Exception("MCP connection config missing transport or type field") - if transport_type != "streamable_http": - # SSE transport method self._streams_context = sse_client( url=cfg["url"], headers=cfg.get("headers", {}), @@ -261,22 +236,20 @@ class McpClient(BaseAstrbotMcpClient): sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5), ) streams = await self.exit_stack.enter_async_context( - self._streams_context, + self._streams_context ) - - # Create a new client session read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60)) self.session = await self.exit_stack.enter_async_context( mcp.ClientSession( *streams, read_timeout_seconds=read_timeout, - logging_callback=cast(Any, logging_callback), - ), + logging_callback=logging_callback, + ) ) else: timeout = timedelta(seconds=cfg.get("timeout", 30)) sse_read_timeout = timedelta( - seconds=cfg.get("sse_read_timeout", 60 * 5), + seconds=cfg.get("sse_read_timeout", 60 * 5) ) self._streams_context = streamablehttp_client( url=cfg["url"], @@ -286,28 +259,22 @@ class McpClient(BaseAstrbotMcpClient): terminate_on_close=cfg.get("terminate_on_close", True), ) read_s, write_s, _ = await self.exit_stack.enter_async_context( - self._streams_context, + self._streams_context ) - - # Create a new client session read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60)) self.session = await self.exit_stack.enter_async_context( mcp.ClientSession( read_stream=read_s, write_stream=write_s, read_timeout_seconds=read_timeout, - logging_callback=logging_callback, # type: ignore - ), + logging_callback=logging_callback, + ) ) - else: cfg = _prepare_stdio_env(cfg) - server_params = mcp.StdioServerParameters( - **cfg, - ) + server_params = mcp.StdioServerParameters(**cfg) def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None: - # Handle MCP service error logs if isinstance(msg, mcp.types.LoggingMessageNotificationParams): if msg.level in ( "warning", @@ -322,22 +289,17 @@ class McpClient(BaseAstrbotMcpClient): stdio_transport = await self.exit_stack.enter_async_context( mcp.stdio_client( server_params, - errlog=cast( - Any, - LogPipe( - level=logging.INFO, - logger=logger, - identifier=f"MCPServer-{name}", - callback=callback, - ), + errlog=LogPipe( + level=logging.INFO, + logger=logger, + identifier=f"MCPServer-{name}", + callback=callback, ), - ), + ) ) self.process_pid = self._extract_stdio_process_pid(stdio_transport) - - # Create a new client session self.session = await self.exit_stack.enter_async_context( - mcp.ClientSession(*stdio_transport), + mcp.ClientSession(*stdio_transport) ) await self.session.initialize() @@ -358,36 +320,24 @@ class McpClient(BaseAstrbotMcpClient): Exception: raised when reconnection fails """ async with self._reconnect_lock: - # Check if already reconnecting (useful for logging) if self._reconnecting: logger.debug( f"MCP Client {self._server_name} is already reconnecting, skipping" ) return - if not self._mcp_server_config or not self._server_name: raise Exception("Cannot reconnect: missing connection configuration") - self._reconnecting = True try: logger.info( f"Attempting to reconnect to MCP server {self._server_name}..." ) - - # Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues) if self.exit_stack: self._old_exit_stacks.append(self.exit_stack) - - # Mark old session as invalid self.session = None - - # Create new exit stack for new connection self.exit_stack = AsyncExitStack() - - # Reconnect using stored config await self.connect_to_server(self._mcp_server_config, self._server_name) await self.list_tools_and_save() - logger.info( f"Successfully reconnected to MCP server {self._server_name}" ) @@ -400,10 +350,7 @@ class McpClient(BaseAstrbotMcpClient): self._reconnecting = False async def call_tool_with_reconnect( - self, - tool_name: str, - arguments: dict, - read_timeout_seconds: timedelta, + self, tool_name: str, arguments: dict, read_timeout_seconds: timedelta ) -> mcp.types.CallToolResult: """Call MCP tool with automatic reconnection on failure, max 2 retries. @@ -424,13 +371,12 @@ class McpClient(BaseAstrbotMcpClient): retry=retry_if_exception_type(anyio.ClosedResourceError), stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=3), - before_sleep=before_sleep_log(logger, logging.WARNING), # type: ignore[arg-type] + before_sleep=before_sleep_log(logger, logging.WARNING), reraise=True, ) async def _call_with_retry(): if not self.session: raise ValueError("MCP session is not available for MCP function tools.") - try: return await self.session.call_tool( name=tool_name, @@ -441,26 +387,17 @@ class McpClient(BaseAstrbotMcpClient): logger.warning( f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect..." ) - # Attempt to reconnect await self._reconnect() - # Reraise the exception to trigger tenacity retry raise return await _call_with_retry() async def cleanup(self) -> None: """Clean up resources including old exit stacks from reconnections""" - # Close current exit stack try: await self.exit_stack.aclose() except Exception as e: logger.debug(f"Error closing current exit stack: {e}") - - # Don't close old exit stacks as they may be in different task contexts - # They will be garbage collected naturally - # Just clear the list to release references self._old_exit_stacks.clear() - - # Set running_event first to unblock any waiting tasks self.running_event.set() self.process_pid = None diff --git a/astrbot/_internal/protocols/mcp/config.py b/astrbot/_internal/protocols/_mcp/config.py similarity index 95% rename from astrbot/_internal/protocols/mcp/config.py rename to astrbot/_internal/protocols/_mcp/config.py index 0ea528948..7faeb3477 100644 --- a/astrbot/_internal/protocols/mcp/config.py +++ b/astrbot/_internal/protocols/_mcp/config.py @@ -5,7 +5,7 @@ import os from astrbot.core.utils.astrbot_path import get_astrbot_data_path -DEFAULT_MCP_CONFIG = {"mcpServers": {}} +DEFAULT_MCP_CONFIG: dict[str, dict[str, object]] = {"mcpServers": {}} def get_mcp_config_path() -> str: diff --git a/astrbot/_internal/protocols/mcp/tool.py b/astrbot/_internal/protocols/_mcp/tool.py similarity index 92% rename from astrbot/_internal/protocols/mcp/tool.py rename to astrbot/_internal/protocols/_mcp/tool.py index eae357c55..b059ac6e4 100644 --- a/astrbot/_internal/protocols/mcp/tool.py +++ b/astrbot/_internal/protocols/_mcp/tool.py @@ -6,15 +6,16 @@ from datetime import timedelta from typing import TYPE_CHECKING, Any try: - import mcp + import mcp as _mcp except (ModuleNotFoundError, ImportError): - mcp: Any = None + _mcp: Any = None -from astrbot._internal.tools.base import FunctionTool from mcp.types import Tool as MCPTool_T +from astrbot._internal.tools.base import FunctionTool + if TYPE_CHECKING: - from astrbot._internal.protocols.mcp.client import McpClient + from astrbot._internal.protocols._mcp.client import McpClient class MCPTool(FunctionTool): diff --git a/astrbot/_internal/runtime/orchestrator.py b/astrbot/_internal/runtime/orchestrator.py index 8211fe0c4..5d1a9a5a2 100644 --- a/astrbot/_internal/runtime/orchestrator.py +++ b/astrbot/_internal/runtime/orchestrator.py @@ -13,10 +13,10 @@ import anyio from astrbot import logger from astrbot._internal.abc.base_astrbot_orchestrator import BaseAstrbotOrchestrator -from astrbot._internal.protocols.abp.client import AstrbotAbpClient -from astrbot._internal.protocols.acp.client import AstrbotAcpClient -from astrbot._internal.protocols.lsp.client import AstrbotLspClient -from astrbot._internal.protocols.mcp.client import McpClient +from astrbot._internal.protocols._mcp.client import McpClient +from astrbot._internal.protocols._abp.client import AstrbotAbpClient +from astrbot._internal.protocols._acp.client import AstrbotAcpClient +from astrbot._internal.protocols._lsp.client import AstrbotLspClient from astrbot._internal.stars import RuntimeStatusStar log = logger diff --git a/astrbot/_internal/tools/base.py b/astrbot/_internal/tools/base.py index deca00a49..4eea09c63 100644 --- a/astrbot/_internal/tools/base.py +++ b/astrbot/_internal/tools/base.py @@ -156,7 +156,7 @@ class ToolSet: def get_light_tool_set(self) -> "ToolSet": """Return a light tool set with only name/description.""" - light_tools = [] + light_tools: list[ToolSchema] = [] for tool in self.tools: if hasattr(tool, "active") and not tool.active: continue @@ -172,7 +172,7 @@ class ToolSet: def get_param_only_tool_set(self) -> "ToolSet": """Return a tool set with name/parameters only (no description).""" - param_tools = [] + param_tools: list[ToolSchema] = [] for tool in self.tools: if hasattr(tool, "active") and not tool.active: continue diff --git a/astrbot/_internal/tools/registry.py b/astrbot/_internal/tools/registry.py index d6e29d640..682d807e6 100644 --- a/astrbot/_internal/tools/registry.py +++ b/astrbot/_internal/tools/registry.py @@ -23,22 +23,41 @@ __all__ = [ # MCP config constants (re-exported from protocols) +DEFAULT_MCP_CONFIG: Any = {} +MCPAllServicesFailedError: Any = Exception +MCPInitError: Any = Exception +MCPInitSummary: Any = dict +MCPInitTimeoutError: Any = TimeoutError +MCPShutdownTimeoutError: Any = TimeoutError + try: - from astrbot._internal.protocols.mcp import ( - DEFAULT_MCP_CONFIG, - MCPAllServicesFailedError, - MCPInitError, - MCPInitSummary, - MCPInitTimeoutError, - MCPShutdownTimeoutError, + from astrbot._internal.protocols._mcp import ( + DEFAULT_MCP_CONFIG as _imported_default_mcp_config, ) + from astrbot._internal.protocols._mcp import ( + MCPAllServicesFailedError as _imported_mcp_all_services_failed_error, + ) + from astrbot._internal.protocols._mcp import ( + MCPInitError as _imported_mcp_init_error, + ) + from astrbot._internal.protocols._mcp import ( + MCPInitSummary as _imported_mcp_init_summary, + ) + from astrbot._internal.protocols._mcp import ( + MCPInitTimeoutError as _imported_mcp_init_timeout_error, + ) + from astrbot._internal.protocols._mcp import ( + MCPShutdownTimeoutError as _imported_mcp_shutdown_timeout_error, + ) + + DEFAULT_MCP_CONFIG = _imported_default_mcp_config + MCPAllServicesFailedError = _imported_mcp_all_services_failed_error + MCPInitError = _imported_mcp_init_error + MCPInitSummary = _imported_mcp_init_summary + MCPInitTimeoutError = _imported_mcp_init_timeout_error + MCPShutdownTimeoutError = _imported_mcp_shutdown_timeout_error except ImportError: - DEFAULT_MCP_CONFIG: dict[str, Any] = {} - MCPAllServicesFailedError: type[Exception] = Exception - MCPInitError: type[Exception] = Exception - MCPInitSummary: type[dict] = dict - MCPInitTimeoutError: type[TimeoutError] = TimeoutError - MCPShutdownTimeoutError: type[TimeoutError] = TimeoutError + pass ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_TIMEOUT_ENABLED" MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT" @@ -123,7 +142,7 @@ class FunctionToolManager: """Test MCP server connection (stub).""" return False, "Not implemented" - async def sync_modelscope_mcp_servers(self) -> None: + async def sync_modelscope_mcp_servers(self, access_token: str = "") -> None: """Sync ModelScope MCP servers (stub).""" pass @@ -137,10 +156,18 @@ class FunctionToolManager: def activate_llm_tool(self, name: str) -> bool: """Activate an LLM tool (stub).""" + tool = self.get_func(name) + if tool is None: + return False + tool.active = True return True def deactivate_llm_tool(self, name: str) -> bool: """Deactivate an LLM tool (stub).""" + tool = self.get_func(name) + if tool is None: + return False + tool.active = False return True @property @@ -201,6 +228,32 @@ class FuncCall(FunctionToolManager): ) self.add(func) + def spec_to_func( + self, + name: str, + func_args: list[dict[str, Any]], + desc: str, + handler: Any, + ) -> FunctionTool: + """Create and return a FunctionTool (for registering agent tools).""" + params: dict[str, Any] = { + "type": "object", + "properties": {}, + } + for param in func_args: + params["properties"][param["name"]] = { + "type": param.get("type", "string"), + "description": param.get("description", ""), + } + func = FunctionTool( + name=name, + parameters=params, + description=desc, + handler=handler, + ) + self.add(func) + return func + def remove_func(self, name: str) -> None: """Remove a function tool by name (deprecated, use remove() instead).""" self.remove(name) @@ -244,21 +297,13 @@ class FuncCall(FunctionToolManager): """Save MCP configuration (stub implementation).""" return True - def activate_llm_tool(self, name: str) -> bool: - """Activate an LLM tool (stub implementation).""" - return True - - def deactivate_llm_tool(self, name: str) -> bool: - """Deactivate an LLM tool (stub implementation).""" - return True - async def test_mcp_server_connection( self, config: dict[str, Any] ) -> tuple[bool, str]: """Test MCP server connection (stub implementation).""" # Import the actual test function if available try: - from astrbot._internal.protocols.mcp.client import ( + from astrbot._internal.protocols._mcp.client import ( _quick_test_mcp_connection, ) @@ -269,7 +314,7 @@ class FuncCall(FunctionToolManager): except Exception as e: raise Exception(f"MCP connection test failed: {e!s}") from e - async def sync_modelscope_mcp_servers(self) -> None: + async def sync_modelscope_mcp_servers(self, access_token: str = "") -> None: """Sync ModelScope MCP servers (stub implementation).""" pass diff --git a/astrbot/api/mcp.py b/astrbot/api/mcp.py index 6e632be92..190a0dd18 100644 --- a/astrbot/api/mcp.py +++ b/astrbot/api/mcp.py @@ -31,8 +31,8 @@ from typing import Any # Import from _internal package (the canonical source) # TODO: fix path - should be protocols.mcp.client -from astrbot._internal.protocols.mcp.client import McpClient as MCPClient -from astrbot._internal.protocols.mcp.tool import MCPTool +from astrbot._internal.protocols._mcp.client import McpClient as MCPClient +from astrbot._internal.protocols._mcp.tool import MCPTool __all__ = [ "MCPClient", diff --git a/astrbot/builtin_stars/builtin_commands/commands/conversation.py b/astrbot/builtin_stars/builtin_commands/commands/conversation.py index a239f0cfe..ad69b8cb5 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/conversation.py +++ b/astrbot/builtin_stars/builtin_commands/commands/conversation.py @@ -1,5 +1,5 @@ import datetime -from typing import Any, TypedDict, cast +from typing import TypedDict from astrbot.api import sp, star from astrbot.api.event import AstrMessageEvent, MessageEventResult @@ -35,25 +35,21 @@ class AlterCmdPluginConfig(TypedDict, total=False): def _normalize_alter_cmd_config(value: object) -> dict[str, AlterCmdPluginConfig]: if not isinstance(value, dict): return {} - config: dict[str, AlterCmdPluginConfig] = {} for plugin_name, raw_plugin_config in value.items(): if not isinstance(plugin_name, str) or not isinstance(raw_plugin_config, dict): continue - - plugin_config: AlterCmdPluginConfig = cast(AlterCmdPluginConfig, {}) - raw_reset = cast(dict[str, Any], raw_plugin_config).get("reset") + plugin_config: AlterCmdPluginConfig = {} + raw_reset = raw_plugin_config.get("reset") if isinstance(raw_reset, dict): - reset_config: ResetPermissionConfig = cast(ResetPermissionConfig, {}) + reset_config: ResetPermissionConfig = {} for key in ("group_unique_on", "group_unique_off", "private"): permission = raw_reset.get(key) if isinstance(permission, str): reset_config[key] = permission if reset_config: plugin_config["reset"] = reset_config - config[plugin_name] = plugin_config - return config @@ -63,13 +59,12 @@ class ConversationCommands: async def _get_current_persona_id(self, session_id): curr = await self.context.conversation_manager.get_curr_conversation_id( - session_id, + session_id ) if not curr: return None conv = await self.context.conversation_manager.get_conversation( - session_id, - curr, + session_id, curr ) if not conv: return None @@ -81,29 +76,22 @@ class ConversationCommands: cfg = self.context.get_config(umo=message.unified_msg_origin) is_unique_session = cfg["platform_settings"]["unique_session"] is_group = bool(message.get_group_id()) - scene = RstScene.get_scene(is_group, is_unique_session) - alter_cmd_cfg = _normalize_alter_cmd_config( await sp.get_async("global", "global", "alter_cmd", {}) ) plugin_config = alter_cmd_cfg.get("astrbot", {}) reset_cfg = plugin_config.get("reset", {}) - required_perm = reset_cfg.get( - scene.key, - "admin" if is_group and not is_unique_session else "member", + scene.key, "admin" if is_group and (not is_unique_session) else "member" ) - if required_perm == "admin" and message.role != "admin": message.set_result( MessageEventResult().message( - f"在{scene.name}场景下,reset命令需要管理员权限," - f"您 (ID {message.get_sender_id()}) 不是管理员,无法执行此操作。", - ), + f"在{scene.name}场景下,reset命令需要管理员权限,您 (ID {message.get_sender_id()}) 不是管理员,无法执行此操作。" + ) ) return - agent_runner_type = cfg["provider_settings"]["agent_runner_type"] if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: active_event_registry.stop_all(umo, exclude=message) @@ -114,35 +102,23 @@ class ConversationCommands: ) message.set_result(MessageEventResult().message("重置对话成功。")) return - if not self.context.get_using_provider(umo): message.set_result( - MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"), + MessageEventResult().message("未找到任何 LLM 提供商。请先配置。") ) return - cid = await self.context.conversation_manager.get_curr_conversation_id(umo) - if not cid: message.set_result( MessageEventResult().message( - "当前未处于对话状态,请 /switch 切换或者 /new 创建。", - ), + "当前未处于对话状态,请 /switch 切换或者 /new 创建。" + ) ) return - active_event_registry.stop_all(umo, exclude=message) - - await self.context.conversation_manager.update_conversation( - umo, - cid, - [], - ) - + await self.context.conversation_manager.update_conversation(umo, cid, []) ret = "清除聊天历史成功!" - message.set_extra("_clean_ltm_session", True) - message.set_result(MessageEventResult().message(ret)) async def stop(self, message: AstrMessageEvent) -> None: @@ -150,15 +126,12 @@ class ConversationCommands: cfg = self.context.get_config(umo=message.unified_msg_origin) agent_runner_type = cfg["provider_settings"]["agent_runner_type"] umo = message.unified_msg_origin - if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: stopped_count = active_event_registry.stop_all(umo, exclude=message) else: stopped_count = active_event_registry.request_agent_stop_all( - umo, - exclude=message, + umo, exclude=message ) - if stopped_count > 0: message.set_result( MessageEventResult().message( @@ -166,50 +139,33 @@ class ConversationCommands: ) ) return - message.set_result(MessageEventResult().message("当前会话没有运行中的任务。")) async def his(self, message: AstrMessageEvent, page: int = 1) -> None: """查看对话记录""" if not self.context.get_using_provider(message.unified_msg_origin): message.set_result( - MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"), + MessageEventResult().message("未找到任何 LLM 提供商。请先配置。") ) return - size_per_page = 6 - conv_mgr = self.context.conversation_manager umo = message.unified_msg_origin session_curr_cid = await conv_mgr.get_curr_conversation_id(umo) - if not session_curr_cid: session_curr_cid = await conv_mgr.new_conversation( - umo, - message.get_platform_id(), + umo, message.get_platform_id() ) - contexts, total_pages = await conv_mgr.get_human_readable_context( - umo, - session_curr_cid, - page, - size_per_page, + umo, session_curr_cid, page, size_per_page ) - parts = [] for context in contexts: if len(context) > 150: context = context[:150] + "..." parts.append(f"{context}\n") - history = "".join(parts) - ret = ( - f"当前对话历史记录:" - f"{history or '无历史记录'}\n\n" - f"第 {page} 页 | 共 {total_pages} 页\n" - f"*输入 /history 2 跳转到第 2 页" - ) - + ret = f"当前对话历史记录:{history or '无历史记录'}\n\n第 {page} 页 | 共 {total_pages} 页\n*输入 /history 2 跳转到第 2 页" message.set_result(MessageEventResult().message(ret).use_t2i(False)) async def convs(self, message: AstrMessageEvent, page: int = 1) -> None: @@ -219,36 +175,32 @@ class ConversationCommands: if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: message.set_result( MessageEventResult().message( - f"{THIRD_PARTY_AGENT_RUNNER_STR} 对话列表功能暂不支持。", - ), + f"{THIRD_PARTY_AGENT_RUNNER_STR} 对话列表功能暂不支持。" + ) ) return - size_per_page = 6 - """获取所有对话列表""" + "获取所有对话列表" conversations_all = await self.context.conversation_manager.get_conversations( - message.unified_msg_origin, + message.unified_msg_origin ) - """计算总页数""" + "计算总页数" total_pages = (len(conversations_all) + size_per_page - 1) // size_per_page - """确保页码有效""" + "确保页码有效" page = max(1, min(page, total_pages)) - """分页处理""" + "分页处理" start_idx = (page - 1) * size_per_page end_idx = start_idx + size_per_page conversations_paged = conversations_all[start_idx:end_idx] - parts = ["对话列表:\n---\n"] - """全局序号从当前页的第一个开始""" + "全局序号从当前页的第一个开始" global_index = start_idx + 1 - - """生成所有对话的标题字典""" + "生成所有对话的标题字典" _titles = {} for conv in conversations_all: title = conv.title if conv.title else "新对话" _titles[conv.cid] = title - - """遍历分页后的对话生成列表显示""" + "遍历分页后的对话生成列表显示" provider_settings = cfg.get("provider_settings", {}) platform_name = message.get_platform_name() for conv in conversations_paged: @@ -269,38 +221,32 @@ class ConversationCommands: persona_name = persona_id else: persona_name = "无" - if force_applied_persona_id: persona_name = f"{persona_name} (自定义规则)" - title = _titles.get(conv.cid, "新对话") parts.append( f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n" ) global_index += 1 - parts.append("---\n") ret = "".join(parts) curr_cid = await self.context.conversation_manager.get_curr_conversation_id( - message.unified_msg_origin, + message.unified_msg_origin ) if curr_cid: - """从所有对话的标题字典中获取标题""" + "从所有对话的标题字典中获取标题" title = _titles.get(curr_cid, "新对话") ret += f"\n当前对话: {title}({curr_cid[:4]})" else: ret += "\n当前对话: 无" - cfg = self.context.get_config(umo=message.unified_msg_origin) unique_session = cfg["platform_settings"]["unique_session"] if unique_session: ret += "\n会话隔离粒度: 个人" else: ret += "\n会话隔离粒度: 群聊" - ret += f"\n第 {page} 页 | 共 {total_pages} 页" ret += "\n*输入 /ls 2 跳转到第 2 页" - message.set_result(MessageEventResult().message(ret).use_t2i(False)) return @@ -317,19 +263,14 @@ class ConversationCommands: ) message.set_result(MessageEventResult().message("已创建新对话。")) return - active_event_registry.stop_all(message.unified_msg_origin, exclude=message) cpersona = await self._get_current_persona_id(message.unified_msg_origin) cid = await self.context.conversation_manager.new_conversation( - message.unified_msg_origin, - message.get_platform_id(), - persona_id=cpersona, + message.unified_msg_origin, message.get_platform_id(), persona_id=cpersona ) - message.set_extra("_clean_ltm_session", True) - message.set_result( - MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"), + MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。") ) async def groupnew_conv(self, message: AstrMessageEvent, sid: str = "") -> None: @@ -340,62 +281,55 @@ class ConversationCommands: platform_name=message.platform_meta.id, message_type=MessageType("GroupMessage"), session_id=sid, - ), + ) ) - cpersona = await self._get_current_persona_id(session) cid = await self.context.conversation_manager.new_conversation( - session, - message.get_platform_id(), - persona_id=cpersona, + session, message.get_platform_id(), persona_id=cpersona ) message.set_result( MessageEventResult().message( - f"群聊 {session} 已切换到新对话: 新对话({cid[:4]})。", - ), + f"群聊 {session} 已切换到新对话: 新对话({cid[:4]})。" + ) ) else: message.set_result( - MessageEventResult().message("请输入群聊 ID。/groupnew 群聊ID。"), + MessageEventResult().message("请输入群聊 ID。/groupnew 群聊ID。") ) async def switch_conv( - self, - message: AstrMessageEvent, - index: int | None = None, + self, message: AstrMessageEvent, index: int | None = None ) -> None: """通过 /ls 前面的序号切换对话""" if not isinstance(index, int): message.set_result( - MessageEventResult().message("类型错误,请输入数字对话序号。"), + MessageEventResult().message("类型错误,请输入数字对话序号。") ) return - if index is None: message.set_result( MessageEventResult().message( - "请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话", - ), + "请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话" + ) ) return conversations = await self.context.conversation_manager.get_conversations( - message.unified_msg_origin, + message.unified_msg_origin ) if index > len(conversations) or index < 1: message.set_result( - MessageEventResult().message("对话序号错误,请使用 /ls 查看"), + MessageEventResult().message("对话序号错误,请使用 /ls 查看") ) else: conversation = conversations[index - 1] title = conversation.title if conversation.title else "新对话" await self.context.conversation_manager.switch_conversation( - message.unified_msg_origin, - conversation.cid, + message.unified_msg_origin, conversation.cid ) message.set_result( MessageEventResult().message( - f"切换到对话: {title}({conversation.cid[:4]})。", - ), + f"切换到对话: {title}({conversation.cid[:4]})。" + ) ) async def rename_conv(self, message: AstrMessageEvent, new_name: str = "") -> None: @@ -404,8 +338,7 @@ class ConversationCommands: message.set_result(MessageEventResult().message("请输入新的对话名称。")) return await self.context.conversation_manager.update_conversation_title( - message.unified_msg_origin, - new_name, + message.unified_msg_origin, new_name ) message.set_result(MessageEventResult().message("重命名对话成功。")) @@ -414,15 +347,17 @@ class ConversationCommands: umo = message.unified_msg_origin cfg = self.context.get_config(umo=umo) is_unique_session = cfg["platform_settings"]["unique_session"] - if message.get_group_id() and not is_unique_session and message.role != "admin": - # 群聊,没开独立会话,发送人不是管理员 + if ( + message.get_group_id() + and (not is_unique_session) + and (message.role != "admin") + ): message.set_result( MessageEventResult().message( - f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限删除当前对话。", - ), + f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限删除当前对话。" + ) ) return - agent_runner_type = cfg["provider_settings"]["agent_runner_type"] if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: active_event_registry.stop_all(umo, exclude=message) @@ -433,26 +368,20 @@ class ConversationCommands: ) message.set_result(MessageEventResult().message("重置对话成功。")) return - session_curr_cid = ( await self.context.conversation_manager.get_curr_conversation_id(umo) ) - if not session_curr_cid: message.set_result( MessageEventResult().message( - "当前未处于对话状态,请 /switch 序号 切换或 /new 创建。", - ), + "当前未处于对话状态,请 /switch 序号 切换或 /new 创建。" + ) ) return - active_event_registry.stop_all(umo, exclude=message) - await self.context.conversation_manager.delete_conversation( - umo, - session_curr_cid, + umo, session_curr_cid ) - ret = "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。" message.set_extra("_clean_ltm_session", True) message.set_result(MessageEventResult().message(ret)) diff --git a/astrbot/builtin_stars/web_searcher/engines/__init__.py b/astrbot/builtin_stars/web_searcher/engines/__init__.py index d1a0ada15..87f9b474b 100644 --- a/astrbot/builtin_stars/web_searcher/engines/__init__.py +++ b/astrbot/builtin_stars/web_searcher/engines/__init__.py @@ -3,7 +3,7 @@ import urllib.parse from collections.abc import Callable from dataclasses import dataclass -from aiohttp import ClientSession +from aiohttp import ClientSession, ClientTimeout from bs4 import BeautifulSoup, Tag HEADERS = { @@ -43,7 +43,7 @@ class SearchEngine: """搜索引擎爬虫基类""" def __init__(self) -> None: - self.TIMEOUT = 10 + self.TIMEOUT = ClientTimeout(total=10) self.page = 1 self.headers = HEADERS diff --git a/astrbot/builtin_stars/web_searcher/engines/comet.py b/astrbot/builtin_stars/web_searcher/engines/comet.py index 7b276a4ca..642db7bd9 100644 --- a/astrbot/builtin_stars/web_searcher/engines/comet.py +++ b/astrbot/builtin_stars/web_searcher/engines/comet.py @@ -1,4 +1,3 @@ -from typing import cast from urllib.parse import unquote, urlencode, urlparse from bs4 import Tag @@ -25,10 +24,7 @@ class Comet(SearchEngine): "url": "a[href^='http'], a[href^='//']", "title": "main h1, main h2, main h3, h3, h2", "text": "main article, main div[role='article'], main section, main p, p", - "links": ( - "main article, main div[role='article'], main li, main div.result, " - "article, div[role='article'], li, div.result" - ), + "links": "main article, main div[role='article'], main li, main div.result, article, div[role='article'], li, div.result", "next": "", } return selectors[selector] @@ -38,7 +34,7 @@ class Comet(SearchEngine): return await self._get_html(url, None) def _get_url(self, tag: Tag) -> str: - href = cast(str, tag.get("href") or "") + href = str(tag.get("href") or "") if href.startswith("//"): return f"https:{href}" return href @@ -52,7 +48,6 @@ class Comet(SearchEngine): return False if not lowered.startswith(("http://", "https://")): return False - netloc = urlparse(lowered).netloc if not netloc: return False diff --git a/astrbot/builtin_stars/web_searcher/engines/duckduckgo.py b/astrbot/builtin_stars/web_searcher/engines/duckduckgo.py index c82da2a61..9589fec34 100644 --- a/astrbot/builtin_stars/web_searcher/engines/duckduckgo.py +++ b/astrbot/builtin_stars/web_searcher/engines/duckduckgo.py @@ -1,5 +1,4 @@ import urllib.parse -from typing import cast from bs4 import Tag @@ -29,7 +28,7 @@ class DuckDuckGo(SearchEngine): return await self._get_html(url, None) def _get_url(self, tag: Tag) -> str: - href = cast(str, tag.get("href") or "") + href = str(tag.get("href") or "") if "duckduckgo.com/l/?" in href: parsed = urllib.parse.urlparse(href) target = urllib.parse.parse_qs(parsed.query).get("uddg", [""])[0] diff --git a/astrbot/builtin_stars/web_searcher/engines/google.py b/astrbot/builtin_stars/web_searcher/engines/google.py index 96a3424e3..b53c934c8 100644 --- a/astrbot/builtin_stars/web_searcher/engines/google.py +++ b/astrbot/builtin_stars/web_searcher/engines/google.py @@ -1,5 +1,4 @@ import urllib.parse -from typing import cast from bs4 import Tag @@ -35,7 +34,7 @@ class Google(SearchEngine): return await self._get_html(url, None) def _get_url(self, tag: Tag) -> str: - href = cast(str, tag.get("href") or "") + href = str(tag.get("href") or "") if href.startswith("/url?"): parsed = urllib.parse.urlparse(href) q = urllib.parse.parse_qs(parsed.query).get("q", [""])[0] diff --git a/astrbot/builtin_stars/web_searcher/engines/sogo.py b/astrbot/builtin_stars/web_searcher/engines/sogo.py index b5026c9e8..a809efbac 100644 --- a/astrbot/builtin_stars/web_searcher/engines/sogo.py +++ b/astrbot/builtin_stars/web_searcher/engines/sogo.py @@ -1,6 +1,5 @@ import random import re -from typing import cast from bs4 import BeautifulSoup, Tag @@ -30,7 +29,7 @@ class Sogo(SearchEngine): return await self._get_html(url, None) def _get_url(self, tag: Tag) -> str: - return cast(str, tag.get("href")) + return str(tag.get("href") or "") async def search(self, query: str, num_results: int) -> list[SearchResult]: results = await super().search(query, num_results) @@ -48,7 +47,7 @@ class Sogo(SearchEngine): script_text = ( script.string if script.string is not None else script.get_text() ) - match = re.search(r'window.location.replace\("(.+?)"\)', script_text) + match = re.search('window.location.replace\\("(.+?)"\\)', script_text) if match: url = match.group(1) return url diff --git a/astrbot/builtin_stars/web_searcher/main.py b/astrbot/builtin_stars/web_searcher/main.py index 72f24962b..fcf3d7df2 100644 --- a/astrbot/builtin_stars/web_searcher/main.py +++ b/astrbot/builtin_stars/web_searcher/main.py @@ -597,7 +597,7 @@ class Main(star.Star): tool_set = req.func_tool if isinstance(tool_set, FunctionToolManager): - req.func_tool = tool_set.get_full_tool_set() # type: ignore[assignment] + req.func_tool = tool_set.get_full_tool_set() # type: ignore tool_set = req.func_tool if not tool_set: diff --git a/astrbot/cli/commands/cmd_init.py b/astrbot/cli/commands/cmd_init.py index ca4e8267a..ed1f17876 100644 --- a/astrbot/cli/commands/cmd_init.py +++ b/astrbot/cli/commands/cmd_init.py @@ -3,7 +3,6 @@ import json import os import re from pathlib import Path -from typing import Any, cast import click from filelock import FileLock, Timeout @@ -12,10 +11,7 @@ from astrbot.cli.utils import DashboardManager from astrbot.core.config.default import DEFAULT_CONFIG from astrbot.core.utils.astrbot_path import astrbot_paths -from .cmd_conf import ( - ensure_config_file, - set_dashboard_credentials, -) +from .cmd_conf import ensure_config_file, set_dashboard_credentials async def initialize_astrbot( @@ -28,7 +24,6 @@ async def initialize_astrbot( ) -> None: """Execute AstrBot initialization logic""" dot_astrbot = astrbot_root / ".astrbot" - if not dot_astrbot.exists(): if yes or click.confirm( f"Install AstrBot to this directory? {astrbot_root}", @@ -37,7 +32,6 @@ async def initialize_astrbot( ): dot_astrbot.touch() click.echo(f"Created {dot_astrbot}") - paths = { "data": astrbot_root / "data", "config": astrbot_root / "data" / "config", @@ -45,13 +39,11 @@ async def initialize_astrbot( "temp": astrbot_root / "data" / "temp", "skills": astrbot_root / "data" / "skills", } - for name, path in paths.items(): path.mkdir(parents=True, exist_ok=True) click.echo( - f"{'Created' if not path.exists() else f'{name} Directory exists'}: {path}" + f"{('Created' if not path.exists() else f'{name} Directory exists')}: {path}" ) - config_path = astrbot_root / "data" / "cmd_config.json" if not config_path.exists(): config_path.write_text( @@ -59,16 +51,11 @@ async def initialize_astrbot( encoding="utf-8-sig", ) click.echo(f"Created config file: {config_path}") - - # Generate an .env for this instance from the bundled config.template (if available). - # The generated file will be written to ASTRBOT_ROOT/.env and will be automatically - # loaded by `astrbot run` (service-config/.env precedence applies). ASTRBOT_ROOT = astrbot_root env_file = ASTRBOT_ROOT / ".env" if not env_file.exists(): tmpl_candidates = [ Path("/opt/astrbot/config.template"), - # project_root may point to the installed package directory; try it as well getattr(astrbot_paths, "project_root", Path.cwd()) / "config.template", Path.cwd() / "config.template", ] @@ -83,60 +70,46 @@ async def initialize_astrbot( if tmpl is not None: try: txt = tmpl.read_text(encoding="utf-8") - # Determine instance name for template replacement (fallback to directory name) instance_name = astrbot_root.name or "astrbot" - # Substitute ${VAR} and ${VAR:-default} for INSTANCE_NAME, PORT, ASTRBOT_ROOT - txt = re.sub(r"\$\{INSTANCE_NAME(:-[^}]*)?\}", instance_name, txt) + txt = re.sub("\\$\\{INSTANCE_NAME(:-[^}]*)?\\}", instance_name, txt) port_val = ( os.environ.get("ASTRBOT_PORT") or os.environ.get("PORT") or "8000" ) - txt = re.sub(r"\$\{PORT(:-[^}]*)?\}", str(port_val), txt) - txt = re.sub(r"\$\{ASTRBOT_ROOT(:-[^}]*)?\}", str(ASTRBOT_ROOT), txt) - header = ( - f"# Generated from config.template by astrbot init for instance: {instance_name}\n" - "# This file will be auto-loaded by 'astrbot run'\n\n" - ) + txt = re.sub("\\$\\{PORT(:-[^}]*)?\\}", str(port_val), txt) + txt = re.sub("\\$\\{ASTRBOT_ROOT(:-[^}]*)?\\}", str(ASTRBOT_ROOT), txt) + header = f"# Generated from config.template by astrbot init for instance: {instance_name}\n# This file will be auto-loaded by 'astrbot run'\n\n" env_file.write_text(header + txt, encoding="utf-8") - env_file.chmod(0o644) + env_file.chmod(420) click.echo(f"Created environment file from template: {env_file}") except Exception as e: click.echo(f"Warning: failed to generate .env from template: {e!s}") else: click.echo("No config.template found; skipping .env generation") - if admin_password is not None: raise click.ClickException( - "--admin-password is no longer supported during init. " - "Run 'astrbot conf admin' after initialization." + "--admin-password is no longer supported during init. Run 'astrbot conf admin' after initialization." ) - effective_admin_username = ( admin_username.strip() if admin_username - else str(cast(dict[str, Any], DEFAULT_CONFIG)["dashboard"]["username"]) + else str(DEFAULT_CONFIG["dashboard"]["username"]) ) if admin_username: config = ensure_config_file() set_dashboard_credentials( - config, - username=effective_admin_username, - password_hash=None, + config, username=effective_admin_username, password_hash=None ) config_path.write_text( - json.dumps(config, ensure_ascii=False, indent=2), - encoding="utf-8-sig", + json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8-sig" ) click.echo(f"Configured dashboard admin username: {effective_admin_username}") click.echo( - "Dashboard password is not initialized for interactive use. " - "Run 'astrbot conf admin' before the first login." + "Dashboard password is not initialized for interactive use. Run 'astrbot conf admin' before the first login." ) - if not backend_only and ( yes or click.confirm( - "是否需要集成式 WebUI?(个人电脑推荐,服务器不推荐)", - default=True, + "是否需要集成式 WebUI?(个人电脑推荐,服务器不推荐)", default=True ) ): await DashboardManager().ensure_installed(astrbot_root) @@ -175,7 +148,6 @@ def init( ) -> None: """Initialize AstrBot""" click.echo("Initializing AstrBot...") - if os.environ.get("ASTRBOT_SYSTEMD") == "1": yes = True from astrbot.core.utils.astrbot_path import astrbot_paths @@ -183,7 +155,6 @@ def init( astrbot_root = Path(root) if root else astrbot_paths.root lock_file = astrbot_root / "astrbot.lock" lock = FileLock(lock_file, timeout=5) - try: with lock.acquire(): asyncio.run( @@ -195,7 +166,6 @@ def init( admin_password=admin_password, ) ) - if backup: from .cmd_bk import import_data_command @@ -203,12 +173,10 @@ def init( click.get_current_context().invoke( import_data_command, backup_file=backup, yes=True ) - click.echo("Done! You can now run 'astrbot run' to start AstrBot") except Timeout: raise click.ClickException( "Cannot acquire lock file. Please check if another instance is running" ) - except Exception as e: raise click.ClickException(f"Initialization failed: {e!s}") diff --git a/astrbot/cli/utils/plugin.py b/astrbot/cli/utils/plugin.py index c06dda350..4086b31c2 100644 --- a/astrbot/cli/utils/plugin.py +++ b/astrbot/cli/utils/plugin.py @@ -3,6 +3,7 @@ import tempfile from enum import Enum from io import BytesIO from pathlib import Path +from typing import Any from zipfile import ZipFile import click @@ -83,7 +84,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None: shutil.rmtree(temp_dir, ignore_errors=True) -def load_yaml_metadata(plugin_dir: Path) -> dict: +def load_yaml_metadata(plugin_dir: Path) -> dict[str, Any]: """Load plugin metadata from metadata.yaml file Args: @@ -96,7 +97,10 @@ def load_yaml_metadata(plugin_dir: Path) -> dict: yaml_path = plugin_dir / "metadata.yaml" if yaml_path.exists(): try: - return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {} + data = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + return dict[str, Any](data) + return {} except Exception as e: click.echo(f"Failed to read {yaml_path}: {e}", err=True) return {} @@ -172,8 +176,8 @@ def build_plug_list(plugins_dir: Path) -> list: ) if ( VersionComparator.compare_version( - local_plugin["version"], - online_plugin["version"], + local_plugin["version"] or "", + online_plugin["version"] or "", ) < 0 ): @@ -185,7 +189,10 @@ def build_plug_list(plugins_dir: Path) -> list: # Add uninstalled online plugins for online_plugin in online_plugins: if not any(plugin["name"] == online_plugin["name"] for plugin in result): - result.append(online_plugin) + clean: dict[str, str] = { + k: v for k, v in online_plugin.items() if v is not None + } + result.append(clean) return result diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 9796d4f1c..3123e9823 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -38,6 +38,7 @@ from astrbot.core.agent.runners.base import AgentResponse, AgentState, BaseAgent from astrbot.core.agent.tool import ToolSet from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor from astrbot.core.agent.tool_image_cache import tool_image_cache +from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.message.components import Json from astrbot.core.message.message_event_result import ( MessageChain, @@ -291,6 +292,64 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): if has_stream_output: return + except EmptyModelOutputError as exc: + last_exception = exc + # Retry on the same provider for empty output errors + retry_count = 0 + should_retry = True + while ( + retry_count < self.EMPTY_OUTPUT_RETRY_ATTEMPTS - 1 and should_retry + ): + retry_count += 1 + wait_time = min( + self.EMPTY_OUTPUT_RETRY_WAIT_MIN_S + + ( + self.EMPTY_OUTPUT_RETRY_WAIT_MAX_S + - self.EMPTY_OUTPUT_RETRY_WAIT_MIN_S + ) + * ( + (retry_count - 1) + / max(self.EMPTY_OUTPUT_RETRY_ATTEMPTS - 1, 1) + ), + self.EMPTY_OUTPUT_RETRY_WAIT_MAX_S, + ) + logger.warning( + "Chat Model %s returned empty output (attempt %d/%d). Retrying in %.1fs...", + candidate_id, + retry_count, + self.EMPTY_OUTPUT_RETRY_ATTEMPTS, + wait_time, + ) + if self._is_stop_requested(): + should_retry = False + break + await asyncio.sleep(wait_time) + try: + async for resp in self._iter_llm_responses( + include_model=idx == 0 + ): + if resp.is_chunk: + has_stream_output = True + yield resp + continue + if ( + resp.role == "err" + and not has_stream_output + and (not is_last_candidate) + ): + last_err_response = resp + should_retry = False + break + yield resp + return + if has_stream_output: + return + except EmptyModelOutputError as retry_exc: + last_exception = retry_exc + if retry_count >= self.EMPTY_OUTPUT_RETRY_ATTEMPTS: + should_retry = False + # All retries exhausted, move to fallback + continue except Exception as exc: last_exception = exc logger.warning( @@ -690,6 +749,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): llm_response.tools_call_name, llm_response.tools_call_args, llm_response.tools_call_ids, + strict=True, ): yield _HandleFunctionToolsResult.from_message_chain( MessageChain( @@ -767,7 +827,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): except Exception as e: logger.error(f"Error in on_tool_start hook: {e}", exc_info=True) - executor = await self.tool_executor.execute( + executor = self.tool_executor.execute( tool=func_tool, run_context=self.run_context, session_manager=self.run_context.session_manager, diff --git a/astrbot/core/agent/tool.py b/astrbot/core/agent/tool.py index 0b09818ec..365e24475 100644 --- a/astrbot/core/agent/tool.py +++ b/astrbot/core/agent/tool.py @@ -33,7 +33,7 @@ class ToolSchema: description: str """The description of the tool.""" - parameters: ParametersType = field(default_factory=dict) + parameters: ParametersType | None = None """The parameters of the tool, in JSON Schema format.""" active: bool = True @@ -42,9 +42,10 @@ class ToolSchema: @model_validator(mode="after") def validate_parameters(self) -> "ToolSchema": - jsonschema.validate( - self.parameters, jsonschema.Draft202012Validator.META_SCHEMA - ) + if self.parameters is not None: + jsonschema.validate( + self.parameters, jsonschema.Draft202012Validator.META_SCHEMA + ) return self diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index baed437ec..615bbd436 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -5,7 +5,7 @@ import traceback import uuid from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence from collections.abc import Set as AbstractSet -from typing import Any, cast +from typing import Any import mcp @@ -14,7 +14,7 @@ from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPTool from astrbot.core.agent.message import Message from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.agent.tool import FunctionTool, ToolSet +from astrbot.core.agent.tool import FunctionTool, ToolSchema, ToolSet from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.cron.events import CronMessageEvent @@ -44,15 +44,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): def _collect_image_urls_from_args(cls, image_urls_raw: Any) -> list[str]: if image_urls_raw is None: return [] - if isinstance(image_urls_raw, str): return [image_urls_raw] - - if isinstance(image_urls_raw, (Sequence, AbstractSet)) and not isinstance( - image_urls_raw, (str, bytes, bytearray) + if isinstance(image_urls_raw, (Sequence, AbstractSet)) and ( + not isinstance(image_urls_raw, (str, bytes, bytearray)) ): return [item for item in image_urls_raw if isinstance(item, str)] - logger.debug( "Unsupported image_urls type in handoff tool args: %s", type(image_urls_raw).__name__, @@ -86,14 +83,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): @classmethod async def _collect_handoff_image_urls( - cls, - run_context: ContextWrapper[AstrAgentContext], - image_urls_raw: Any, + cls, run_context: ContextWrapper[AstrAgentContext], image_urls_raw: Any ) -> list[str]: candidates: list[str] = [] candidates.extend(cls._collect_image_urls_from_args(image_urls_raw)) candidates.extend(await cls._collect_image_urls_from_message(run_context)) - normalized = normalize_and_dedupe_strings(candidates) extensionless_local_roots = (get_astrbot_temp_path(),) sanitized = [ @@ -139,51 +133,38 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): async for r in cls._execute_handoff(tool, run_context, **tool_args): yield r return - elif isinstance(tool, MCPTool): async for r in cls._execute_mcp(tool, run_context, **tool_args): yield r return - elif tool.is_background_task: task_id = uuid.uuid4().hex async def _run_in_background() -> None: try: await cls._execute_background( - tool=tool, - run_context=run_context, - task_id=task_id, - **tool_args, + tool=tool, run_context=run_context, task_id=task_id, **tool_args ) except Exception as e: logger.error( - f"Background task {task_id} failed: {e!s}", - exc_info=True, + f"Background task {task_id} failed: {e!s}", exc_info=True ) - asyncio.create_task(_run_in_background()) # noqa: RUF006 + asyncio.create_task(_run_in_background()) text_content = mcp.types.TextContent( - type="text", - text=f"Background task submitted. task_id={task_id}", + type="text", text=f"Background task submitted. task_id={task_id}" ) yield mcp.types.CallToolResult(content=[text_content]) - return else: - # Guard: reject sandbox tools whose capability is unavailable. - # Tools are always injected (for schema stability / prefix caching), - # but execution is blocked when the sandbox lacks the capability. rejection = cls._check_sandbox_capability(tool, run_context) if rejection is not None: yield rejection return - async for r in cls._execute_local(tool, run_context, **tool_args): yield r return - # Browser tool names that require the "browser" sandbox capability. _BROWSER_TOOL_NAMES: frozenset[str] = frozenset( { "astrbot_execute_browser", @@ -194,57 +175,38 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): @classmethod def _check_sandbox_capability( - cls, - tool: FunctionTool, - run_context: ContextWrapper[AstrAgentContext], + cls, tool: FunctionTool, run_context: ContextWrapper[AstrAgentContext] ) -> mcp.types.CallToolResult | None: """Return a rejection result if the tool requires a sandbox capability that is not available, or None if the tool may proceed.""" if tool.name not in cls._BROWSER_TOOL_NAMES: return None - from astrbot.core.computer.computer_client import get_sandbox_capabilities session_id = run_context.context.event.unified_msg_origin caps = get_sandbox_capabilities(session_id) - - # Sandbox not yet booted — allow through (boot will happen on first - # shell/python call; browser tools will fail naturally if truly unavailable). if caps is None: return None - if "browser" not in caps: - msg = ( - f"Tool '{tool.name}' requires browser capability, but the current " - f"sandbox profile does not include it (capabilities: {list(caps)}). " - "Please ask the administrator to switch to a sandbox profile with " - "browser support, or use shell/python tools instead." - ) + msg = f"Tool '{tool.name}' requires browser capability, but the current sandbox profile does not include it (capabilities: {list(caps)}). Please ask the administrator to switch to a sandbox profile with browser support, or use shell/python tools instead." logger.warning( "[ToolExec] capability_rejected tool=%s caps=%s", tool.name, list(caps) ) return mcp.types.CallToolResult( - content=[mcp.types.TextContent(type="text", text=msg)], - isError=True, + content=[mcp.types.TextContent(type="text", text=msg)], isError=True ) - return None @classmethod def _get_runtime_computer_tools( - cls, - runtime: str, - sandbox_cfg: dict | None = None, - session_id: str = "", + cls, runtime: str, sandbox_cfg: dict | None = None, session_id: str = "" ) -> dict[str, ToolSchema]: from astrbot.core.computer.computer_tool_provider import ComputerToolProvider from astrbot.core.tool_provider import ToolProviderContext provider = ComputerToolProvider() ctx = ToolProviderContext( - computer_use_runtime=runtime, - sandbox_cfg=sandbox_cfg, - session_id=session_id, + computer_use_runtime=runtime, sandbox_cfg=sandbox_cfg, session_id=session_id ) tools = provider.get_tools(ctx) result = {tool.name: tool for tool in tools} @@ -269,33 +231,26 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): runtime = str(provider_settings.get("computer_use_runtime", "local")) sandbox_cfg = provider_settings.get("sandbox", {}) runtime_computer_tools = cls._get_runtime_computer_tools( - runtime, - sandbox_cfg=sandbox_cfg, - session_id=event.unified_msg_origin, + runtime, sandbox_cfg=sandbox_cfg, session_id=event.unified_msg_origin ) - - # Keep persona semantics aligned with the main agent: tools=None means - # "all tools", including runtime computer-use tools. if tools is None: toolset = ToolSet() for registered_tool in llm_tools.func_list: if isinstance(registered_tool, HandoffTool): continue if registered_tool.active: - toolset.add_tool(registered_tool) # type: ignore[arg-type] + toolset.add_tool(registered_tool) for runtime_tool in runtime_computer_tools.values(): toolset.add_tool(runtime_tool) return None if toolset.empty() else toolset - if not tools: return None - toolset = ToolSet() for tool_name_or_obj in tools: if isinstance(tool_name_or_obj, str): registered_tool = llm_tools.get_func(tool_name_or_obj) if registered_tool and registered_tool.active: - toolset.add_tool(registered_tool) # type: ignore[arg-type] + toolset.add_tool(registered_tool) continue runtime_tool = runtime_computer_tools.get(tool_name_or_obj) if runtime_tool: @@ -307,8 +262,8 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): @classmethod async def _execute_handoff( cls, - tool: HandoffTool, - run_context: ContextWrapper[AstrAgentContext], + tool: HandoffTool[Any], + run_context: ContextWrapper[Any], *, image_urls_prepared: bool = False, **tool_args: Any, @@ -327,25 +282,16 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): image_urls = [] else: image_urls = await cls._collect_handoff_image_urls( - run_context, - tool_args.get("image_urls"), + run_context, tool_args.get("image_urls") ) tool_args["image_urls"] = image_urls - - # Build handoff toolset from registered tools plus runtime computer tools. toolset = cls._build_handoff_toolset(run_context, tool.agent.tools) - ctx = run_context.context.context event = run_context.context.event umo = event.unified_msg_origin - - # Use per-subagent provider override if configured; otherwise fall back - # to the current/default provider resolution. prov_id = getattr( tool, "provider_id", None ) or await ctx.get_current_chat_provider_id(umo) - - # prepare begin dialogs contexts = None dialogs = tool.agent.begin_dialogs if dialogs: @@ -359,7 +305,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): ) except Exception: continue - prov_settings: dict = ctx.get_config(umo=umo).get("provider_settings", {}) agent_max_step = int(prov_settings.get("max_agent_step", 3)) stream = prov_settings.get("streaming_response", False) @@ -399,10 +344,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): async def _run_handoff_in_background() -> None: try: await cls._do_handoff_background( - tool=tool, - run_context=run_context, - task_id=task_id, - **tool_args, + tool=tool, run_context=run_context, task_id=task_id, **tool_args ) except Exception as e: logger.error( @@ -410,15 +352,10 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): exc_info=True, ) - asyncio.create_task(_run_handoff_in_background()) # noqa: RUF006 - + asyncio.create_task(_run_handoff_in_background()) text_content = mcp.types.TextContent( type="text", - text=( - f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. " - f"The subagent '{tool.agent.name}' is working on the task on behalf of you. " - f"You will be notified when it finishes." - ), + text=f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. The subagent '{tool.agent.name}' is working on the task on behalf of you. You will be notified when it finishes.", ) yield mcp.types.CallToolResult(content=[text_content]) @@ -434,15 +371,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): result_text = "" tool_args = dict(tool_args) tool_args["image_urls"] = await cls._collect_handoff_image_urls( - run_context, - tool_args.get("image_urls"), + run_context, tool_args.get("image_urls") ) try: async for r in cls._execute_handoff( - tool, - run_context, - image_urls_prepared=True, - **tool_args, + tool, run_context, image_urls_prepared=True, **tool_args ): if isinstance(r, mcp.types.CallToolResult): for content in r.content: @@ -452,19 +385,15 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): result_text = ( f"error: Background task execution failed, internal error: {e!s}" ) - event = run_context.context.event - await cls._wake_main_agent_for_background_result( run_context=run_context, task_id=task_id, tool_name=tool.name, result_text=result_text, tool_args=tool_args, - note=( - event.get_extra("background_note") - or f"Background task for subagent '{tool.agent.name}' finished." - ), + note=event.get_extra("background_note") + or f"Background task for subagent '{tool.agent.name}' finished.", summary_name=f"Dedicated to subagent `{tool.agent.name}`", extra_result_fields={"subagent_name": tool.agent.name}, ) @@ -477,13 +406,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): task_id: str, **tool_args, ) -> None: - # run the tool result_text = "" try: async for r in cls._execute_local( tool, run_context, tool_call_timeout=3600, **tool_args ): - # collect results, currently we just collect the text results if isinstance(r, mcp.types.CallToolResult): result_text = "" for content in r.content: @@ -493,19 +420,15 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): result_text = ( f"error: Background task execution failed, internal error: {e!s}" ) - event = run_context.context.event - await cls._wake_main_agent_for_background_result( run_context=run_context, task_id=task_id, tool_name=tool.name, result_text=result_text, tool_args=tool_args, - note=( - event.get_extra("background_note") - or f"Background task {tool.name} finished." - ), + note=event.get_extra("background_note") + or f"Background task {tool.name} finished.", summary_name=tool.name, ) @@ -530,7 +453,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): event = run_context.context.event ctx = run_context.context.context - task_result = { "task_id": task_id, "tool_name": tool_name, @@ -540,7 +462,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): if extra_result_fields: task_result.update(extra_result_fields) extras = {"background_task_result": task_result} - session = MessageSession.from_str(event.unified_msg_origin) cron_event = CronMessageEvent( context=ctx, @@ -559,7 +480,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): .get("stream", False), tool_providers=[ComputerToolProvider()], ) - req = ProviderRequest() conv = await _get_session_conv(event=cron_event, plugin_context=ctx) req.conversation = conv @@ -569,7 +489,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): context_dump = req._print_friendly_context() req.contexts = [] req.system_prompt += CONVERSATION_HISTORY_INJECT_PREFIX + context_dump - bg = json.dumps(extras["background_task_result"], ensure_ascii=False) req.system_prompt += BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT.format( background_task_result=bg @@ -578,25 +497,18 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): if not req.func_tool: req.func_tool = ToolSet() req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL) - result = await build_main_agent( event=cron_event, plugin_context=ctx, config=config, req=req ) if not result: logger.error(f"Failed to build main agent for background task {tool_name}.") return - runner = result.agent_runner async for _ in runner.step_until_done(3): - # agent will send message to user via using tools pass llm_resp = runner.get_final_llm_resp() task_meta = extras.get("background_task_result", {}) - summary_note = ( - f"[BackgroundTask] {summary_name} " - f"(task_id={task_meta.get('task_id', task_id)}) finished. " - f"Result: {task_meta.get('result') or result_text or 'no content'}" - ) + summary_note = f"[BackgroundTask] {summary_name} (task_id={task_meta.get('task_id', task_id)}) finished. Result: {task_meta.get('result') or result_text or 'no content'}" if llm_resp and llm_resp.completion_text: summary_note += ( f"I finished the task, here is the result: {llm_resp.completion_text}" @@ -623,17 +535,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): event = run_context.context.event if not event: raise ValueError("Event must be provided for local function tools.") - is_override_call = False for ty in type(tool).mro(): if "call" in ty.__dict__ and ty.__dict__["call"] is not FunctionTool.call: is_override_call = True break - - # 检查 tool 下有没有 run 方法 - if not tool.handler and not hasattr(tool, "run") and not is_override_call: + if not tool.handler and (not hasattr(tool, "run")) and (not is_override_call): raise ValueError("Tool must have a valid handler or override 'run' method.") - awaitable = None method_name = "" if tool.handler: @@ -647,7 +555,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): method_name = "run" if awaitable is None: raise ValueError("Tool must have a valid handler or override 'run' method.") - sdk_plugin_bridge = getattr( run_context.context.context, "sdk_plugin_bridge", None ) @@ -665,19 +572,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): ) except Exception as exc: logger.warning("SDK calling_func_tool dispatch failed: %s", exc) - - # awaitable is guaranteed non-None after line 648 check. - # cast needed: pyright cannot narrow type through the conditional chain. _HandlerType = Callable[ ..., Awaitable[MessageEventResult | mcp.types.CallToolResult | str | None] | AsyncGenerator[MessageEventResult | CommandResult | str | None, None], ] wrapper = call_local_llm_tool( - context=run_context, - handler=cast(_HandlerType, awaitable), - method_name=method_name, - **tool_args, + context=run_context, handler=awaitable, method_name=method_name, **tool_args ) while True: try: @@ -690,26 +591,18 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): yield resp else: text_content = mcp.types.TextContent( - type="text", - text=str(resp), + type="text", text=str(resp) ) yield mcp.types.CallToolResult(content=[text_content]) else: - # NOTE: Tool 在这里直接请求发送消息给用户 res = run_context.context.event.get_result() if res and res.chain: try: await event.send( - MessageChain( - chain=res.chain, - type="tool_direct_result", - ) + MessageChain(chain=res.chain, type="tool_direct_result") ) except Exception as e: - logger.error( - f"Tool 直接发送消息失败: {e}", - exc_info=True, - ) + logger.error(f"Tool 直接发送消息失败: {e}", exc_info=True) yield None else: yield mcp.types.CallToolResult( @@ -722,7 +615,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): ) except asyncio.TimeoutError: raise Exception( - f"tool {tool.name} execution timeout after {tool_call_timeout or run_context.tool_call_timeout} seconds.", + f"tool {tool.name} execution timeout after {tool_call_timeout or run_context.tool_call_timeout} seconds." ) from None except StopAsyncIteration: break @@ -752,12 +645,9 @@ async def call_local_llm_tool( **kwargs, ) -> AsyncGenerator[Any, None]: """执行本地 LLM 工具的处理函数并处理其返回结果""" - ready_to_call = None # 一个协程或者异步生成器 - + ready_to_call = None trace_ = None - event = context.context.event - try: if method_name == "run" or method_name == "decorator_handler": ready_to_call = handler(event, *args, **kwargs) @@ -768,19 +658,15 @@ async def call_local_llm_tool( except ValueError as e: raise Exception(f"Tool execution ValueError: {e}") from e except TypeError as e: - # 获取函数的签名(包括类型),除了第一个 event/context 参数。 try: sig = inspect.signature(handler) params = list(sig.parameters.values()) - # 跳过第一个参数(event 或 context) if params: params = params[1:] - param_strs = [] for param in params: param_str = param.name if param.annotation != inspect.Parameter.empty: - # 获取类型注解的字符串表示 if isinstance(param.annotation, type): type_str = param.annotation.__name__ else: @@ -789,46 +675,35 @@ async def call_local_llm_tool( if param.default != inspect.Parameter.empty: param_str += f" = {param.default!r}" param_strs.append(param_str) - handler_param_str = ( ", ".join(param_strs) if param_strs else "(no additional parameters)" ) except Exception: handler_param_str = "(unable to inspect signature)" - raise Exception( f"Tool handler parameter mismatch, please check the handler definition. Handler parameters: {handler_param_str}" ) from e except Exception as e: trace_ = traceback.format_exc() raise Exception(f"Tool execution error: {e}. Traceback: {trace_}") from e - if not ready_to_call: return - if inspect.isasyncgen(ready_to_call): _has_yielded = False try: async for ret in ready_to_call: - # 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码 - # 返回值只能是 MessageEventResult 或者 None(无返回值) _has_yielded = True if isinstance(ret, MessageEventResult | CommandResult): - # 如果返回值是 MessageEventResult, 设置结果并继续 event.set_result(ret) yield else: - # 如果返回值是 None, 则不设置结果并继续 - # 继续执行后续阶段 yield ret if not _has_yielded: - # 如果这个异步生成器没有执行到 yield 分支 yield except Exception as e: logger.error(f"Previous Error: {trace_}") raise e elif inspect.iscoroutine(ready_to_call): - # 如果只是一个协程, 直接执行 ret = await ready_to_call if isinstance(ret, MessageEventResult | CommandResult): event.set_result(ret) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 489a7f929..d0c615824 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -8,7 +8,7 @@ import os import zoneinfo from collections.abc import Coroutine, Mapping from dataclasses import dataclass, field -from typing import Any, cast +from typing import Any from astrbot.core import logger, sp from astrbot.core.agent.handoff import HandoffTool @@ -60,9 +60,7 @@ from astrbot.core.utils.media_utils import ( from astrbot.core.utils.quoted_message.settings import ( SETTINGS as DEFAULT_QUOTED_MESSAGE_SETTINGS, ) -from astrbot.core.utils.quoted_message.settings import ( - QuotedMessageParserSettings, -) +from astrbot.core.utils.quoted_message.settings import QuotedMessageParserSettings from astrbot.core.utils.quoted_message_parser import ( extract_quoted_message_images, extract_quoted_message_text, @@ -76,59 +74,50 @@ class MainAgentBuildConfig: Most of the configs can be found in the cmd_config.json""" tool_call_timeout: int - """The timeout (in seconds) for a tool call. - When the tool call exceeds this time, - a timeout error as a tool result will be returned. - """ + "The timeout (in seconds) for a tool call.\n When the tool call exceeds this time,\n a timeout error as a tool result will be returned.\n " tool_schema_mode: str = "full" - """The tool schema mode, can be 'full' or 'lazy_load'.""" + "The tool schema mode, can be 'full' or 'lazy_load'." provider_wake_prefix: str = "" - """The wake prefix for the provider. If the user message does not start with this prefix, - the main agent will not be triggered.""" + "The wake prefix for the provider. If the user message does not start with this prefix,\n the main agent will not be triggered." streaming_response: bool = True - """Whether to use streaming response.""" + "Whether to use streaming response." sanitize_context_by_modalities: bool = False - """Whether to sanitize the context based on the provider's supported modalities. - This will remove unsupported message types(e.g. image) from the context to prevent issues.""" + "Whether to sanitize the context based on the provider's supported modalities.\n This will remove unsupported message types(e.g. image) from the context to prevent issues." kb_agentic_mode: bool = False - """Whether to use agentic mode for knowledge base retrieval. - This will inject the knowledge base query tool into the main agent's toolset to allow dynamic querying.""" + "Whether to use agentic mode for knowledge base retrieval.\n This will inject the knowledge base query tool into the main agent's toolset to allow dynamic querying." file_extract_enabled: bool = False - """Whether to enable file content extraction for uploaded files.""" + "Whether to enable file content extraction for uploaded files." file_extract_prov: str = "moonshotai" - """The file extraction provider.""" + "The file extraction provider." file_extract_msh_api_key: str = "" - """The API key for Moonshot AI file extraction provider.""" + "The API key for Moonshot AI file extraction provider." context_limit_reached_strategy: str = "truncate_by_turns" - """The strategy to handle context length limit reached.""" + "The strategy to handle context length limit reached." llm_compress_instruction: str = "" - """The instruction for compression in llm_compress strategy.""" + "The instruction for compression in llm_compress strategy." llm_compress_keep_recent: int = 6 - """The number of most recent turns to keep during llm_compress strategy.""" + "The number of most recent turns to keep during llm_compress strategy." llm_compress_provider_id: str = "" - """The provider ID for the LLM used in context compression.""" + "The provider ID for the LLM used in context compression." max_context_length: int = -1 - """The maximum number of turns to keep in context. -1 means no limit. - This enforce max turns before compression""" + "The maximum number of turns to keep in context. -1 means no limit.\n This enforce max turns before compression" dequeue_context_length: int = 1 - """The number of oldest turns to remove when context length limit is reached.""" + "The number of oldest turns to remove when context length limit is reached." llm_safety_mode: bool = True - """This will inject healthy and safe system prompt into the main agent, - to prevent LLM output harmful information""" + "This will inject healthy and safe system prompt into the main agent,\n to prevent LLM output harmful information" safety_mode_strategy: str = "system_prompt" computer_use_runtime: str = "local" - """The runtime for agent computer use: none, local, or sandbox.""" + "The runtime for agent computer use: none, local, or sandbox." sandbox_cfg: dict = field(default_factory=dict) tool_providers: list[ToolProvider] = field(default_factory=list) - """Decoupled tool providers injected by the caller. - Each provider is queried for tools and system-prompt addons at build time.""" + "Decoupled tool providers injected by the caller.\n Each provider is queried for tools and system-prompt addons at build time." add_cron_tools: bool = True - """This will add cron job management tools to the main agent for proactive cron job execution.""" + "This will add cron job management tools to the main agent for proactive cron job execution." provider_settings: dict = field(default_factory=dict) subagent_orchestrator: dict = field(default_factory=dict) timezone: str | None = None max_quoted_fallback_images: int = 20 - """Maximum number of images injected from quoted-message fallback extraction.""" + "Maximum number of images injected from quoted-message fallback extraction." @dataclass(slots=True) @@ -187,9 +176,7 @@ async def _apply_kb( return try: kb_result = await retrieve_knowledge_base( - query=req.prompt, - umo=event.unified_msg_origin, - context=plugin_context, + query=req.prompt, umo=event.unified_msg_origin, context=plugin_context ) if not kb_result: return @@ -206,9 +193,7 @@ async def _apply_kb( async def _apply_file_extract( - event: AstrMessageEvent, - req: ProviderRequest, - config: MainAgentBuildConfig, + event: AstrMessageEvent, req: ProviderRequest, config: MainAgentBuildConfig ) -> None: file_paths = [] file_names = [] @@ -231,26 +216,21 @@ async def _apply_file_extract( return file_contents = await asyncio.gather( *[ - extract_file_moonshotai( - file_path, - config.file_extract_msh_api_key, - ) + extract_file_moonshotai(file_path, config.file_extract_msh_api_key) for file_path in file_paths ] ) else: logger.error("Unsupported file extract provider: %s", config.file_extract_prov) return - for file_content, file_name in zip(file_contents, file_names, strict=True): req.contexts.append( { "role": "system", "content": FILE_EXTRACT_CONTEXT_TEMPLATE.format( - file_content=file_content, - file_name=file_name or "Unknown", + file_content=file_content, file_name=file_name or "Unknown" ), - }, + } ) @@ -264,20 +244,12 @@ def _apply_prompt_prefix(req: ProviderRequest, cfg: dict) -> None: req.prompt = f"{prefix}{req.prompt}" -# Computer-use tools are now provided by ComputerToolProvider. -# See astrbot.core.computer.computer_tool_provider for details. - - async def _ensure_persona_and_skills( - req: ProviderRequest, - cfg: dict, - plugin_context: Context, - event: AstrMessageEvent, + req: ProviderRequest, cfg: dict, plugin_context: Context, event: AstrMessageEvent ) -> None: """Ensure persona and skills are applied to the request's system prompt or user prompt.""" if not req.conversation: return - ( persona_id, persona, @@ -289,25 +261,19 @@ async def _ensure_persona_and_skills( platform_name=event.get_platform_name(), provider_settings=cfg, ) - set_persona_custom_error_message_on_event( event, extract_persona_custom_error_message_from_persona(persona) ) - if persona: - # Inject persona system prompt if prompt := persona["prompt"]: req.system_prompt += f"\n# Persona Instructions\n\n{prompt}\n" if begin_dialogs := copy.deepcopy(persona.get("_begin_dialogs_processed")): req.contexts[:0] = begin_dialogs elif use_webchat_special_default: req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT - - # Inject skills prompt runtime = cfg.get("computer_use_runtime", "local") skill_manager = SkillManager() skills = skill_manager.list_skills(active_only=True, runtime=runtime) - if skills: if persona and persona.get("skills") is not None: if not persona["skills"]: @@ -320,9 +286,7 @@ async def _ensure_persona_and_skills( if runtime == "none": req.system_prompt += COMPUTER_USE_DISABLED_PROMPT tmgr = plugin_context.get_llm_tool_manager() - - # inject toolset in the persona - if (persona and persona.get("tools") is None) or not persona: + if persona and persona.get("tools") is None or not persona: persona_toolset = tmgr.get_full_tool_set() for tool in list(persona_toolset): if not tool.active: @@ -333,18 +297,15 @@ async def _ensure_persona_and_skills( for tool_name in persona["tools"]: tool = tmgr.get_func(tool_name) if tool and tool.active: - persona_toolset.add_tool(tool) # type: ignore[arg-type] + persona_toolset.add_tool(tool) if not req.func_tool: - req.func_tool = persona_toolset # type: ignore[assignment] + req.func_tool = persona_toolset else: - req.func_tool.merge(persona_toolset) # type: ignore[arg-type] - - # sub agents integration + req.func_tool.merge(persona_toolset) orch_cfg = plugin_context.get_config().get("subagent_orchestrator", {}) so = plugin_context.subagent_orchestrator if orch_cfg.get("main_enable", False) and so: remove_dup = bool(orch_cfg.get("remove_main_duplicate_tools", False)) - assigned_tools: set[str] = set() agents = orch_cfg.get("agents", []) if isinstance(agents, list): @@ -377,27 +338,22 @@ async def _ensure_persona_and_skills( name = str(t).strip() if name: assigned_tools.add(name) - if req.func_tool is None: req.func_tool = ToolSet() - - # add subagent handoff tools for tool in so.handoffs: req.func_tool.add_tool(tool) - - # check duplicates if remove_dup: handoff_names = {tool.name for tool in so.handoffs} for tool_name in assigned_tools: if tool_name in handoff_names: continue req.func_tool.remove_tool(tool_name) - router_prompt = ( plugin_context.get_config() .get("subagent_orchestrator", {}) .get("router_system_prompt", "") - ).strip() + .strip() + ) if router_prompt: req.system_prompt += f"\n{router_prompt}\n" try: @@ -411,30 +367,20 @@ async def _ensure_persona_and_skills( async def _request_img_caption( - provider_id: str, - cfg: dict, - image_urls: list[str], - plugin_context: Context, + provider_id: str, cfg: dict, image_urls: list[str], plugin_context: Context ) -> str: prov = plugin_context.get_provider_by_id(provider_id) if prov is None: raise ValueError( - f"Cannot get image caption because provider `{provider_id}` is not exist.", + f"Cannot get image caption because provider `{provider_id}` is not exist." ) if not isinstance(prov, Provider): raise ValueError( - f"Cannot get image caption because provider `{provider_id}` is not a valid Provider, it is {type(prov)}.", + f"Cannot get image caption because provider `{provider_id}` is not a valid Provider, it is {type(prov)}." ) - - img_cap_prompt = cfg.get( - "image_caption_prompt", - IMAGE_CAPTION_DEFAULT_PROMPT, - ) + img_cap_prompt = cfg.get("image_caption_prompt", IMAGE_CAPTION_DEFAULT_PROMPT) logger.debug("Processing image caption with provider: %s", provider_id) - llm_resp = await prov.text_chat( - prompt=img_cap_prompt, - image_urls=image_urls, - ) + llm_resp = await prov.text_chat(prompt=img_cap_prompt, image_urls=image_urls) return llm_resp.completion_text @@ -453,10 +399,7 @@ async def _ensure_img_caption( if _is_generated_compressed_image_path(url, compressed_url): event.track_temporary_local_file(compressed_url) caption = await _request_img_caption( - image_caption_provider, - cfg, - compressed_urls, - plugin_context, + image_caption_provider, cfg, compressed_urls, plugin_context ) if caption: req.extra_user_content_parts.append( @@ -482,46 +425,37 @@ def _get_quoted_message_parser_settings( if not isinstance(provider_settings, dict): return DEFAULT_QUOTED_MESSAGE_SETTINGS overrides = provider_settings.get("quoted_message_parser") - # Narrow to a Mapping so the typed .with_overrides() accepts it. if not isinstance(overrides, Mapping): return DEFAULT_QUOTED_MESSAGE_SETTINGS - return DEFAULT_QUOTED_MESSAGE_SETTINGS.with_overrides( - cast(Mapping[str, Any], overrides) - ) + return DEFAULT_QUOTED_MESSAGE_SETTINGS.with_overrides(overrides) def _get_image_compress_args( provider_settings: dict[str, object] | None, ) -> tuple[bool, int, int]: if not isinstance(provider_settings, dict): - return True, IMAGE_COMPRESS_DEFAULT_MAX_SIZE, IMAGE_COMPRESS_DEFAULT_QUALITY - + return (True, IMAGE_COMPRESS_DEFAULT_MAX_SIZE, IMAGE_COMPRESS_DEFAULT_QUALITY) enabled = provider_settings.get("image_compress_enabled", True) if not isinstance(enabled, bool): enabled = True - 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): max_size = IMAGE_COMPRESS_DEFAULT_MAX_SIZE max_size = max(max_size, 1) - quality = options.get("quality", IMAGE_COMPRESS_DEFAULT_QUALITY) if not isinstance(quality, int): quality = IMAGE_COMPRESS_DEFAULT_QUALITY quality = min(max(quality, 1), 100) - - return enabled, max_size, quality + return (enabled, max_size, quality) async def _compress_image_for_provider( - url_or_path: str, - provider_settings: dict[str, object] | None, + url_or_path: str, provider_settings: dict[str, object] | None ) -> str: try: enabled, max_size, quality = _get_image_compress_args(provider_settings) @@ -537,34 +471,28 @@ def _get_image_compress_args( provider_settings: dict[str, object] | None, ) -> tuple[bool, int, int]: if not isinstance(provider_settings, dict): - return True, IMAGE_COMPRESS_DEFAULT_MAX_SIZE, IMAGE_COMPRESS_DEFAULT_QUALITY - + return (True, IMAGE_COMPRESS_DEFAULT_MAX_SIZE, IMAGE_COMPRESS_DEFAULT_QUALITY) enabled = provider_settings.get("image_compress_enabled", True) if not isinstance(enabled, bool): enabled = True - 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): max_size = IMAGE_COMPRESS_DEFAULT_MAX_SIZE max_size = max(max_size, 1) - quality = options.get("quality", IMAGE_COMPRESS_DEFAULT_QUALITY) if not isinstance(quality, int): quality = IMAGE_COMPRESS_DEFAULT_QUALITY quality = min(max(quality, 1), 100) - - return enabled, max_size, quality + return (enabled, max_size, quality) async def _compress_image_for_provider( - url_or_path: str, - provider_settings: dict[str, object] | None, + url_or_path: str, provider_settings: dict[str, object] | None ) -> str: try: enabled, max_size, quality = _get_image_compress_args(provider_settings) @@ -577,8 +505,7 @@ async def _compress_image_for_provider( def _is_generated_compressed_image_path( - original_path: str, - compressed_path: str | None, + original_path: str, compressed_path: str | None ) -> bool: if not compressed_path or compressed_path == original_path: return False @@ -591,34 +518,28 @@ def _get_image_compress_args( provider_settings: dict[str, object] | None, ) -> tuple[bool, int, int]: if not isinstance(provider_settings, dict): - return True, IMAGE_COMPRESS_DEFAULT_MAX_SIZE, IMAGE_COMPRESS_DEFAULT_QUALITY - + return (True, IMAGE_COMPRESS_DEFAULT_MAX_SIZE, IMAGE_COMPRESS_DEFAULT_QUALITY) enabled = provider_settings.get("image_compress_enabled", True) if not isinstance(enabled, bool): enabled = True - 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): max_size = IMAGE_COMPRESS_DEFAULT_MAX_SIZE max_size = max(max_size, 1) - quality = options.get("quality", IMAGE_COMPRESS_DEFAULT_QUALITY) if not isinstance(quality, int): quality = IMAGE_COMPRESS_DEFAULT_QUALITY quality = min(max(quality, 1), 100) - - return enabled, max_size, quality + return (enabled, max_size, quality) async def _compress_image_for_provider( - url_or_path: str, - provider_settings: dict[str, object] | None, + url_or_path: str, provider_settings: dict[str, object] | None ) -> str: try: enabled, max_size, quality = _get_image_compress_args(provider_settings) @@ -631,8 +552,7 @@ async def _compress_image_for_provider( def _is_generated_compressed_image_path( - original_path: str, - compressed_path: str | None, + original_path: str, compressed_path: str | None ) -> bool: if not compressed_path or compressed_path == original_path: return False @@ -656,27 +576,22 @@ async def _process_quote_message( break if not quote: return - content_parts = [] sender_info = f"({quote.sender_nickname}): " if quote.sender_nickname else "" message_str = ( await extract_quoted_message_text( - event, - quote, - settings=quoted_message_settings, + event, quote, settings=quoted_message_settings ) or quote.message_str or "[Empty Text]" ) content_parts.append(f"{sender_info}{message_str}") - image_seg = None if quote.chain: for comp in quote.chain: if isinstance(comp, Image): image_seg = comp break - if image_seg: try: prov = None @@ -686,12 +601,10 @@ async def _process_quote_message( prov = plugin_context.get_provider_by_id(img_cap_prov_id) if prov is None: prov = plugin_context.get_using_provider(event.unified_msg_origin) - if prov and isinstance(prov, Provider): path = await image_seg.convert_to_file_path() compress_path = await _compress_image_for_provider( - path, - config.provider_settings if config else None, + path, config.provider_settings if config else None ) if path and _is_generated_compressed_image_path(path, compress_path): event.track_temporary_local_file(compress_path) @@ -714,24 +627,19 @@ async def _process_quote_message( await asyncio.to_thread(os.remove, compress_path) except Exception as exc: logger.warning("Fail to remove temporary compressed image: %s", exc) - quoted_content = "\n".join(content_parts) quoted_text = f"\n{quoted_content}\n" req.extra_user_content_parts.append(TextPart(text=quoted_text)) def _append_system_reminders( - event: AstrMessageEvent, - req: ProviderRequest, - cfg: dict, - timezone: str | None, + event: AstrMessageEvent, req: ProviderRequest, cfg: dict, timezone: str | None ) -> None: system_parts: list[str] = [] if cfg.get("identifier"): user_id = event.message_obj.sender.user_id user_nickname = event.message_obj.sender.nickname system_parts.append(f"User ID: {user_id}, Nickname: {user_nickname}") - if cfg.get("group_name_display") and event.message_obj.group_id: if not event.message_obj.group: logger.error( @@ -742,7 +650,6 @@ def _append_system_reminders( group_name = event.message_obj.group.group_name if group_name: system_parts.append(f"Group name: {group_name}") - if cfg.get("datetime_system_prompt"): current_time = None if timezone: @@ -756,7 +663,6 @@ def _append_system_reminders( datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)") ) system_parts.append(f"Current datetime: {current_time}") - if system_parts: system_content = ( "" + "\n".join(system_parts) + "" @@ -773,33 +679,17 @@ async def _decorate_llm_request( cfg = config.provider_settings or plugin_context.get_config( umo=event.unified_msg_origin ).get("provider_settings", {}) - _apply_prompt_prefix(req, cfg) - if req.conversation: await _ensure_persona_and_skills(req, cfg, plugin_context, event) - img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or "" if img_cap_prov_id and req.image_urls: - await _ensure_img_caption( - event, - req, - cfg, - plugin_context, - img_cap_prov_id, - ) - + await _ensure_img_caption(event, req, cfg, plugin_context, img_cap_prov_id) img_cap_prov_id = cfg.get("default_image_caption_provider_id") or "" quoted_message_settings = _get_quoted_message_parser_settings(cfg) await _process_quote_message( - event, - req, - img_cap_prov_id, - plugin_context, - quoted_message_settings, - config, + event, req, img_cap_prov_id, plugin_context, quoted_message_settings, config ) - tz = config.timezone if tz is None: tz = plugin_context.get_config().get("timezone") @@ -830,9 +720,7 @@ def _modalities_fix(provider: Provider, req: ProviderRequest) -> None: def _sanitize_context_by_modalities( - config: MainAgentBuildConfig, - provider: Provider, - req: ProviderRequest, + config: MainAgentBuildConfig, provider: Provider, req: ProviderRequest ) -> None: if not config.sanitize_context_by_modalities: return @@ -845,19 +733,16 @@ def _sanitize_context_by_modalities( supports_tool_use = bool("tool_use" in modalities) if supports_image and supports_tool_use: return - sanitized_contexts: list[dict] = [] removed_image_blocks = 0 removed_tool_messages = 0 removed_tool_calls = 0 - for msg in req.contexts: if not isinstance(msg, dict): continue role = msg.get("role") if not role: continue - new_msg = msg if not supports_tool_use: if role == "tool": @@ -868,7 +753,6 @@ def _sanitize_context_by_modalities( removed_tool_calls += 1 new_msg.pop("tool_calls", None) new_msg.pop("tool_call_id", None) - if not supports_image: content = new_msg.get("content") if isinstance(content, list): @@ -884,22 +768,18 @@ def _sanitize_context_by_modalities( filtered_parts.append(part) if removed_any_image: new_msg["content"] = filtered_parts - if role == "assistant": content = new_msg.get("content") has_tool_calls = bool(new_msg.get("tool_calls")) if not has_tool_calls: if not content: continue - if isinstance(content, str) and not content.strip(): + if isinstance(content, str) and (not content.strip()): continue - sanitized_contexts.append(new_msg) - if removed_image_blocks or removed_tool_messages or removed_tool_calls: logger.debug( - "sanitize_context_by_modalities applied: " - "removed_image_blocks=%s, removed_tool_messages=%s, removed_tool_calls=%s", + "sanitize_context_by_modalities applied: removed_image_blocks=%s, removed_tool_messages=%s, removed_tool_calls=%s", removed_image_blocks, removed_tool_messages, removed_tool_calls, @@ -919,23 +799,18 @@ def _model_outputs_image(provider: Provider, req: ProviderRequest) -> bool: def _should_disable_streaming_for_webchat_output( - event: AstrMessageEvent, - provider: Provider, - req: ProviderRequest, + event: AstrMessageEvent, provider: Provider, req: ProviderRequest ) -> bool: if event.get_platform_name() != "webchat": return False - provider_cfg = provider.provider_config provider_type = provider_cfg.get("type", "") if provider_type == "googlegenai_chat_completion" and provider_cfg.get( "gm_resp_image_modal", False ): return True - if _model_outputs_image(provider, req): return not bool(provider_cfg.get("supports_streaming_output_modalities", False)) - return False @@ -949,18 +824,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None: new_tool_set = ToolSet() for tool in req.func_tool.tools: if isinstance(tool, MCPTool): - # 保留 MCP 工具 new_tool_set.add_tool(tool) continue mp = getattr(tool, "handler_module_path", None) if not mp: - # 没有 plugin 归属信息的工具(如 subagent transfer_to_*) - # 不应受到会话插件过滤影响。 new_tool_set.add_tool(tool) continue plugin = star_map.get(mp) if not plugin: - # 无法解析插件归属时,保守保留工具,避免误过滤。 new_tool_set.add_tool(tool) continue if plugin.name in event.plugins_name or plugin.reserved: @@ -976,10 +847,13 @@ async def _handle_webchat( chatui_session_id = event.session_id.split("!")[-1] user_prompt = req.prompt session = await db_helper.get_platform_session_by_id(chatui_session_id) - - if not user_prompt or not chatui_session_id or not session or session.display_name: + if ( + not user_prompt + or not chatui_session_id + or (not session) + or session.display_name + ): return - try: llm_resp = await prov.text_chat( system_prompt=WEBCHAT_TITLE_GENERATOR_SYSTEM_PROMPT, @@ -987,9 +861,7 @@ async def _handle_webchat( ) except Exception as e: logger.exception( - "Failed to generate webchat title for session %s: %s", - chatui_session_id, - e, + "Failed to generate webchat title for session %s: %s", chatui_session_id, e ) return if llm_resp and llm_resp.completion_text: @@ -1000,8 +872,7 @@ async def _handle_webchat( "Generated chatui title for session %s: %s", chatui_session_id, title ) await db_helper.update_platform_session( - session_id=chatui_session_id, - display_name=title, + session_id=chatui_session_id, display_name=title ) @@ -1010,15 +881,10 @@ def _apply_llm_safety_mode(config: MainAgentBuildConfig, req: ProviderRequest) - req.system_prompt = f"{LLM_SAFETY_MODE_SYSTEM_PROMPT}\n\n{req.system_prompt}" else: logger.warning( - "Unsupported llm_safety_mode strategy: %s.", - config.safety_mode_strategy, + "Unsupported llm_safety_mode strategy: %s.", config.safety_mode_strategy ) -# _apply_sandbox_tools has been moved to ComputerToolProvider. -# See astrbot.core.computer.computer_tool_provider for details. - - def _get_compress_provider( config: MainAgentBuildConfig, plugin_context: Context ) -> Provider | None: @@ -1029,8 +895,7 @@ def _get_compress_provider( provider = plugin_context.get_provider_by_id(config.llm_compress_provider_id) if provider is None: logger.warning( - "未找到指定的上下文压缩模型 %s,将跳过压缩。", - config.llm_compress_provider_id, + "未找到指定的上下文压缩模型 %s,将跳过压缩。", config.llm_compress_provider_id ) return None if not isinstance(provider, Provider): @@ -1051,11 +916,9 @@ def _get_fallback_chat_providers( "fallback_chat_models setting is not a list, skip fallback providers." ) return [] - provider_id = str(provider.provider_config.get("id", "")) seen_provider_ids: set[str] = {provider_id} if provider_id else set() fallbacks: list[Provider] = [] - for fallback_id in fallback_ids: if not isinstance(fallback_id, str) or not fallback_id: continue @@ -1094,7 +957,6 @@ async def build_main_agent( if provider is None: logger.info("未找到任何对话模型(提供商),跳过 LLM 请求处理。") return None - if req is None: if event.get_extra("provider_request"): req = event.get_extra("provider_request") @@ -1109,20 +971,16 @@ async def build_main_agent( req.image_urls = [] if sel_model := event.get_extra("selected_model"): req.model = sel_model - if config.provider_wake_prefix and not event.message_str.startswith( - config.provider_wake_prefix + if config.provider_wake_prefix and ( + not event.message_str.startswith(config.provider_wake_prefix) ): return None - req.prompt = event.message_str[len(config.provider_wake_prefix) :] - - # media files attachments for comp in event.message_obj.message: if isinstance(comp, Image): path = await comp.convert_to_file_path() image_path = await _compress_image_for_provider( - path, - config.provider_settings, + path, config.provider_settings ) if _is_generated_compressed_image_path(path, image_path): event.track_temporary_local_file(image_path) @@ -1138,7 +996,6 @@ async def build_main_agent( text=f"[File Attachment: name {file_name}, path {file_path}]" ) ) - # quoted message attachments reply_comps = [ comp for comp in event.message_obj.message if isinstance(comp, Reply) ] @@ -1154,8 +1011,7 @@ async def build_main_agent( has_embedded_image = True path = await reply_comp.convert_to_file_path() image_path = await _compress_image_for_provider( - path, - config.provider_settings, + path, config.provider_settings ) if _is_generated_compressed_image_path(path, image_path): event.track_temporary_local_file(image_path) @@ -1166,22 +1022,14 @@ async def build_main_agent( file_name = reply_comp.name or os.path.basename(file_path) req.extra_user_content_parts.append( TextPart( - text=( - f"[File Attachment in quoted message: " - f"name {file_name}, path {file_path}]" - ) + text=f"[File Attachment in quoted message: name {file_name}, path {file_path}]" ) ) - - # Fallback quoted image extraction for reply-id-only payloads, or when - # embedded reply chain only contains placeholders (e.g. [Forward Message], [Image]). if not has_embedded_image: try: fallback_images = normalize_and_dedupe_strings( await extract_quoted_message_images( - event, - comp, - settings=quoted_message_settings, + event, comp, settings=quoted_message_settings ) ) remaining_limit = max( @@ -1219,52 +1067,38 @@ async def build_main_agent( exc, exc_info=True, ) - conversation = await _get_session_conv(event, plugin_context) req.conversation = conversation req.contexts = json.loads(conversation.history) event.set_extra("provider_request", req) - if isinstance(req.contexts, str): req.contexts = json.loads(req.contexts) req.image_urls = normalize_and_dedupe_strings(req.image_urls) - if config.file_extract_enabled: try: await _apply_file_extract(event, req, config) except Exception as exc: logger.error("Error occurred while applying file extract: %s", exc) - - if not req.prompt and not req.image_urls: + if not req.prompt and (not req.image_urls): if not event.get_group_id() and req.extra_user_content_parts: req.prompt = "" else: return None - await _decorate_llm_request(event, req, plugin_context, config) - await _apply_kb(event, req, plugin_context, config) - if not req.session_id: req.session_id = event.unified_msg_origin - _modalities_fix(provider, req) _plugin_tool_fix(event, req) _sanitize_context_by_modalities(config, provider, req) - if config.llm_safety_mode: _apply_llm_safety_mode(config, req) - - # Decoupled tool providers — each provider injects its tools and prompt addons if config.tool_providers: _provider_ctx = ToolProviderContext( computer_use_runtime=config.computer_use_runtime, sandbox_cfg=config.sandbox_cfg, session_id=req.session_id or "", ) - # Respect WebUI tool enable/disable settings. - # Internal tools (source='internal') bypass this check — they are - # not user-togglable in the WebUI, so legacy entries must not block them. _inactivated: set[str] = set( str(sp.get("inactivated_llm_tools", [], scope="global", scope_id="global")) ) @@ -1280,44 +1114,31 @@ async def build_main_agent( _tp_addon = _tp.get_system_prompt_addon(_provider_ctx) if _tp_addon: req.system_prompt = f"{req.system_prompt or ''}{_tp_addon}" - agent_runner = AgentRunner() - astr_agent_ctx = AstrAgentContext( - context=plugin_context, - event=event, - ) - + astr_agent_ctx = AstrAgentContext(context=plugin_context, event=event) if event.platform_meta.support_proactive_message: if req.func_tool is None: req.func_tool = ToolSet() req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL) - if provider.provider_config.get("max_context_tokens", 0) <= 0: model = provider.get_model() if model_info := LLM_METADATAS.get(model): provider.provider_config["max_context_tokens"] = model_info["limit"][ "context" ] - if event.get_platform_name() == "webchat": asyncio.create_task(_handle_webchat(event, req, provider)) - if req.func_tool and req.func_tool.tools: - # Sort tools by name for deterministic serialization so that - # LLM provider prefix caching can match across requests. req.func_tool.normalize() - tool_prompt = ( TOOL_CALL_PROMPT if config.tool_schema_mode == "full" else TOOL_CALL_PROMPT_LAZY_LOAD_MODE ) req.system_prompt += f"\n{tool_prompt}\n" - action_type = event.get_extra("action_type") if action_type == "live": req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n" - streaming_response = config.streaming_response if streaming_response and _should_disable_streaming_for_webchat_output( event, provider, req @@ -1328,7 +1149,6 @@ async def build_main_agent( req.model or provider.get_model(), ) streaming_response = False - reset_coro = agent_runner.reset( provider=provider, request=req, @@ -1350,10 +1170,8 @@ async def build_main_agent( provider, plugin_context, config.provider_settings ), ) - if apply_reset: await reset_coro - return MainAgentBuildResult( agent_runner=agent_runner, provider_request=req, diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py index b76649c27..cc1115ad8 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -183,7 +183,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]): if not isinstance(msg, dict): return f"error: messages[{idx}] should be an object." - msg_dict: dict[str, Any] = msg # type: ignore[assignment] + msg_dict: dict[str, Any] = msg # type: ignore msg_type = str(msg_dict.get("type", "")).lower() if not msg_type: return f"error: messages[{idx}].type is required." diff --git a/astrbot/core/astrbot_config_mgr.py b/astrbot/core/astrbot_config_mgr.py index 80f707df2..6289fc55d 100644 --- a/astrbot/core/astrbot_config_mgr.py +++ b/astrbot/core/astrbot_config_mgr.py @@ -1,6 +1,6 @@ import os import uuid -from typing import TypedDict, TypeVar +from typing import Any, TypedDict, TypeVar from astrbot.core import AstrBotConfig, logger from astrbot.core.config.astrbot_config import ASTRBOT_CONFIG_PATH @@ -107,14 +107,15 @@ class AstrBotConfigManager: abconf_name: str | None = None, ) -> None: """保存配置文件的映射关系""" - abconf_data = self.sp.get( + raw_abconf: dict[str, Any] | None = self.sp.get( "abconf_mapping", {}, scope="global", scope_id="global", ) + abconf_data: dict[str, dict[str, str]] = raw_abconf if raw_abconf else {} random_word = abconf_name or uuid.uuid4().hex[:8] - abconf_data[abconf_id] = { # type: ignore[index] + abconf_data[abconf_id] = { "path": abconf_path, "name": random_word, } @@ -191,7 +192,7 @@ class AstrBotConfigManager: raise ValueError("不能删除默认配置文件") # 从映射中移除 - abconf_data = ( + abconf_data: dict[str, dict[str, str]] = ( self.sp.get( "abconf_mapping", {}, @@ -245,7 +246,7 @@ class AstrBotConfigManager: if conf_id == "default": raise ValueError("不能更新默认配置文件的信息") - abconf_data = ( + abconf_data: dict[str, dict[str, str]] = ( self.sp.get( "abconf_mapping", {}, diff --git a/astrbot/core/backup/importer.py b/astrbot/core/backup/importer.py index f0af47b03..20d31c4b0 100644 --- a/astrbot/core/backup/importer.py +++ b/astrbot/core/backup/importer.py @@ -942,9 +942,10 @@ class AstrBotImporter: # 获取模型的 datetime 字段 from sqlalchemy import inspect as sa_inspect + from sqlalchemy.orm import Mapper try: - mapper = sa_inspect(model_class) + mapper: Mapper[Any] = sa_inspect(model_class) for column in mapper.columns: if column.name in result and result[column.name] is not None: # 检查是否是 datetime 类型的列 diff --git a/astrbot/core/computer/booters/shipyard_neo.py b/astrbot/core/computer/booters/shipyard_neo.py index 35baec245..863cc032c 100644 --- a/astrbot/core/computer/booters/shipyard_neo.py +++ b/astrbot/core/computer/booters/shipyard_neo.py @@ -3,7 +3,7 @@ from __future__ import annotations import functools import os import shlex -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import anyio @@ -11,7 +11,6 @@ from astrbot.api import logger if TYPE_CHECKING: from astrbot.core.agent.tool import ToolSchema - from astrbot.core.computer.booters.base import ComputerBooter from astrbot.core.computer.olayer import ( BrowserComponent, @@ -42,29 +41,23 @@ class NeoPythonComponent(PythonComponent): timeout: int = 30, silent: bool = False, ) -> dict[str, Any]: - _ = kernel_id # Bay runtime does not expose kernel_id in current SDK. + _ = kernel_id with anyio.fail_after(timeout): result = await self._sandbox.python.exec(code) payload = _maybe_model_dump(result) - output_text = payload.get("output", "") or "" error_text = payload.get("error", "") or "" data = payload.get("data") if isinstance(payload.get("data"), dict) else {} - rich_output = (data.get("output") or {}) if isinstance(data, dict) else {} + rich_output = data.get("output") or {} if isinstance(data, dict) else {} if not isinstance(rich_output.get("images"), list): rich_output["images"] = [] if "text" not in rich_output: rich_output["text"] = output_text - if silent: rich_output["text"] = "" - return { "success": bool(payload.get("success", error_text == "")), - "data": { - "output": rich_output, - "error": error_text, - }, + "data": {"output": rich_output, "error": error_text}, "execution_id": payload.get("execution_id"), "execution_time_ms": payload.get("execution_time_ms"), "code": payload.get("code"), @@ -93,21 +86,17 @@ class NeoShellComponent(ShellComponent): "exit_code": 2, "success": False, } - run_command = command if env: env_prefix = " ".join( - f"{k}={shlex.quote(str(v))}" for k, v in sorted(env.items()) + (f"{k}={shlex.quote(str(v))}" for k, v in sorted(env.items())) ) run_command = f"{env_prefix} {run_command}" - if background: run_command = f"nohup sh -lc {shlex.quote(run_command)} >/tmp/astrbot_bg.log 2>&1 & echo $!" - with anyio.fail_after(timeout or 30): result = await self._sandbox.shell.exec(run_command, cwd=cwd) payload = _maybe_model_dump(result) - stdout = payload.get("output", "") or "" stderr = payload.get("error", "") or "" exit_code = payload.get("exit_code") @@ -127,7 +116,6 @@ class NeoShellComponent(ShellComponent): "execution_time_ms": payload.get("execution_time_ms"), "command": payload.get("command"), } - return { "stdout": stdout, "stderr": stderr, @@ -144,10 +132,7 @@ class NeoFileSystemComponent(FileSystemComponent): self._sandbox = sandbox async def create_file( - self, - path: str, - content: str = "", - mode: int = 0o644, + self, path: str, content: str = "", mode: int = 420 ) -> dict[str, Any]: _ = mode await self._sandbox.filesystem.write_file(path, content) @@ -159,11 +144,7 @@ class NeoFileSystemComponent(FileSystemComponent): return {"success": True, "path": path, "content": content} async def write_file( - self, - path: str, - content: str, - mode: str = "w", - encoding: str = "utf-8", + self, path: str, content: str, mode: str = "w", encoding: str = "utf-8" ) -> dict[str, Any]: _ = mode _ = encoding @@ -175,9 +156,7 @@ class NeoFileSystemComponent(FileSystemComponent): return {"success": True, "path": path} async def list_dir( - self, - path: str = ".", - show_hidden: bool = False, + self, path: str = ".", show_hidden: bool = False ) -> dict[str, Any]: entries = await self._sandbox.filesystem.list_dir(path) data = [] @@ -277,7 +256,7 @@ class ShipyardNeoBooter(ComputerBooter): self._ttl = ttl self._client: Any = None self._sandbox: Any = None - self._bay_manager: Any = None # BayContainerManager when auto-started + self._bay_manager: Any = None self._fs: FileSystemComponent | None = None self._python: PythonComponent | None = None self._shell: ShellComponent | None = None @@ -310,64 +289,45 @@ class ShipyardNeoBooter(ComputerBooter): async def boot(self, session_id: str) -> None: _ = session_id - - # --- Auto-start Bay if needed --- if self.is_auto_mode: from .bay_manager import BayContainerManager - # Clean up previous manager if re-booting if self._bay_manager is not None: await self._bay_manager.close_client() - logger.info("[Computer] bay_autostart status=starting") self._bay_manager = BayContainerManager() self._endpoint_url = await self._bay_manager.ensure_running() await self._bay_manager.wait_healthy() - # Read auto-provisioned credentials if not self._access_token: self._access_token = await self._bay_manager.read_credentials() logger.info( - "[Computer] bay_autostart status=ready endpoint=%s", - self._endpoint_url, + "[Computer] bay_autostart status=ready endpoint=%s", self._endpoint_url ) - if not self._endpoint_url or not self._access_token: if self._bay_manager is not None: raise ValueError( - "Bay container started but credentials could not be read. " - "Ensure Bay generated credentials.json, or set access_token manually." + "Bay container started but credentials could not be read. Ensure Bay generated credentials.json, or set access_token manually." ) raise ValueError( - "Shipyard Neo sandbox configuration is incomplete. " - "Set endpoint (default http://127.0.0.1:8114) and access token, " - "or ensure Bay's credentials.json is accessible for auto-discovery." + "Shipyard Neo sandbox configuration is incomplete. Set endpoint (default http://127.0.0.1:8114) and access token, or ensure Bay's credentials.json is accessible for auto-discovery." ) - from shipyard_neo import BayClient self._client = BayClient( - endpoint_url=self._endpoint_url, - access_token=self._access_token, + endpoint_url=self._endpoint_url, access_token=self._access_token ) await self._client.__aenter__() - - # Resolve profile: user-specified > smart selection > default resolved_profile = await self._resolve_profile(self._client) - self._sandbox = await self._client.create_sandbox( - profile=resolved_profile, - ttl=self._ttl, + profile=resolved_profile, ttl=self._ttl ) - self._fs = NeoFileSystemComponent(self._sandbox) self._python = NeoPythonComponent(self._sandbox) self._shell = NeoShellComponent(self._sandbox) - caps = self.capabilities or () self._browser = ( NeoBrowserComponent(self._sandbox) if "browser" in caps else None ) - logger.info( "[Computer] sandbox_created booter=shipyard_neo sandbox_id=%s profile=%s capabilities=%s auto=%s", self._sandbox.id, @@ -389,22 +349,18 @@ class ShipyardNeoBooter(ComputerBooter): misconfigured token, and silently falling back would just delay the real failure to ``create_sandbox``. """ - # User explicitly set a profile → honour it if self._profile and self._profile != self.DEFAULT_PROFILE: logger.info( - "[Computer] profile_selected mode=user profile=%s", - self._profile, + "[Computer] profile_selected mode=user profile=%s", self._profile ) return self._profile - - # Query Bay for available profiles from shipyard_neo.errors import ForbiddenError, UnauthorizedError try: profile_list = await client.list_profiles() profiles = profile_list.items except (UnauthorizedError, ForbiddenError): - raise # auth errors must not be silenced + raise except Exception as exc: logger.warning( "[Computer] profile_selection_fallback reason=query_failed fallback=%s error=%s", @@ -412,7 +368,6 @@ class ShipyardNeoBooter(ComputerBooter): exc, ) return self.DEFAULT_PROFILE - if not profiles: return self.DEFAULT_PROFILE @@ -423,7 +378,6 @@ class ShipyardNeoBooter(ComputerBooter): best = max(profiles, key=_score) chosen = getattr(best, "id", self.DEFAULT_PROFILE) - if chosen != self.DEFAULT_PROFILE: caps = getattr(best, "capabilities", []) logger.info( @@ -431,7 +385,6 @@ class ShipyardNeoBooter(ComputerBooter): chosen, caps, ) - return chosen async def shutdown(self) -> None: @@ -448,10 +401,6 @@ class ShipyardNeoBooter(ComputerBooter): "[Computer] booter_shutdown booter=shipyard_neo sandbox_id=%s status=done", sandbox_id, ) - - # NOTE: We intentionally do NOT stop the Bay container here. - # It stays running for reuse by future sessions. The user can - # stop it manually or via ``BayContainerManager.stop()``. if self._bay_manager is not None: await self._bay_manager.close_client() @@ -485,8 +434,7 @@ class ShipyardNeoBooter(ComputerBooter): remote_path = file_name.lstrip("/") await self._sandbox.filesystem.upload(remote_path, content) logger.info( - "[Computer] file_upload booter=shipyard_neo remote_path=%s", - remote_path, + "[Computer] file_upload booter=shipyard_neo remote_path=%s", remote_path ) return { "success": True, @@ -502,7 +450,7 @@ class ShipyardNeoBooter(ComputerBooter): if local_dir: await anyio.Path(local_dir).mkdir(parents=True, exist_ok=True) async with await anyio.open_file(local_path, "wb") as f: - await f.write(cast(bytes, content)) + await f.write(content) logger.info( "[Computer] file_download booter=shipyard_neo remote_path=%s local_path=%s", remote_path, @@ -530,11 +478,9 @@ class ShipyardNeoBooter(ComputerBooter): ) return False - # ── Tool / prompt self-description ──────────────────────────── - @classmethod @functools.cache - def _base_tools(cls) -> tuple[ToolSchema, ...]: + def _base_tools(cls): """4 base + 11 Neo lifecycle = 15 tools (all Neo profiles).""" from astrbot.core.computer.tools import ( AnnotateExecutionTool, @@ -574,7 +520,7 @@ class ShipyardNeoBooter(ComputerBooter): @classmethod @functools.cache - def _browser_tools(cls) -> tuple[ToolSchema, ...]: + def _browser_tools(cls): from astrbot.core.computer.tools import ( BrowserBatchExecTool, BrowserExecTool, diff --git a/astrbot/core/computer/computer_tool_provider.py b/astrbot/core/computer/computer_tool_provider.py index 482bbcfb9..b558105e2 100644 --- a/astrbot/core/computer/computer_tool_provider.py +++ b/astrbot/core/computer/computer_tool_provider.py @@ -19,7 +19,7 @@ from astrbot.api import logger from astrbot.core.tool_provider import ToolProviderContext if TYPE_CHECKING: - from astrbot.core.agent.tool import FunctionTool, ToolSchema + from astrbot.core.agent.tool import ToolSchema # --------------------------------------------------------------------------- @@ -108,7 +108,7 @@ class ComputerToolProvider: SyncSkillReleaseTool, ) - all_tools: list[ToolSchema] = [ # type: ignore[assignment] + all_tools: list[ToolSchema] = [ # type: ignore ExecuteShellTool(), PythonTool(), FileUploadTool(), diff --git a/astrbot/core/config/astrbot_config.py b/astrbot/core/config/astrbot_config.py index f1114a744..625751512 100644 --- a/astrbot/core/config/astrbot_config.py +++ b/astrbot/core/config/astrbot_config.py @@ -2,6 +2,7 @@ import enum import json import logging import os +from typing import Any from astrbot.core.utils.astrbot_path import get_astrbot_data_path @@ -67,7 +68,7 @@ class AstrBotConfig(dict): def _config_schema_to_default_config(self, schema: dict) -> dict: """将 Schema 转换成 Config""" - conf = {} + conf: dict[str, Any] = {} def _parse_schema(schema: dict, conf: dict) -> None: for k, v in schema.items(): diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index c00dc563d..81dd75dbd 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -55,7 +55,7 @@ WEBHOOK_SUPPORTED_PLATFORMS = [ ] # 默认配置 -DEFAULT_CONFIG = { +DEFAULT_CONFIG: dict[str, Any] = { "config_version": 2, "platform_settings": { "unique_session": False, diff --git a/astrbot/core/config/i18n_utils.py b/astrbot/core/config/i18n_utils.py index 43eba41f6..e1ae41670 100644 --- a/astrbot/core/config/i18n_utils.py +++ b/astrbot/core/config/i18n_utils.py @@ -13,7 +13,7 @@ def _is_str_keyed_dict(value: object) -> TypeGuard[dict[str, object]]: class I18nGroup(TypedDict): name: str - metadata: dict[str, object] + metadata: dict[str, Any] class ConfigMetadataI18n: diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index adf7ea26d..a045baff7 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -34,7 +34,7 @@ class CronJobManager: self._started = False async def start(self, ctx: "Context") -> None: - self.ctx: Context = ctx # star context + self.ctx: Context = ctx async with self._lock: if self._started: return @@ -110,7 +110,6 @@ class CronJobManager: run_once: bool = False, run_at: datetime | None = None, ) -> CronJob: - # If run_once with run_at, store run_at in payload for later reference. if run_once and run_at: payload = {**payload, "run_at": run_at.isoformat()} job = await self.db.create_cron_job( @@ -182,14 +181,10 @@ class CronJobManager: if isinstance(payload_interval, int): interval_seconds = payload_interval if interval_seconds is not None: - trigger = IntervalTrigger( - seconds=interval_seconds, - timezone=tzinfo, - ) + trigger = IntervalTrigger(seconds=interval_seconds, timezone=tzinfo) else: trigger = CronTrigger.from_crontab( - job.cron_expression, - timezone=tzinfo, + job.cron_expression, timezone=tzinfo ) self.scheduler.add_job( self._run_job, @@ -199,7 +194,7 @@ class CronJobManager: replace_existing=True, misfire_grace_time=30, ) - asyncio.create_task( # noqa: RUF006 + asyncio.create_task( self.db.update_cron_job( job.job_id, next_run_time=self._get_next_run_time(job.job_id) ) @@ -242,7 +237,6 @@ class CronJobManager: next_run_time=next_run, ) if job.run_once: - # one-shot: remove after execution regardless of success await self.delete_job(job_id) async def _run_basic_job(self, job: CronJob) -> None: @@ -260,7 +254,6 @@ class CronJobManager: if not session_str: raise ValueError("ActiveAgentCronJob missing session.") note = payload.get("note") or job.description or job.name - extras = { "cron_job": { "id": job.job_id, @@ -270,25 +263,18 @@ class CronJobManager: "description": job.description, "note": note, "run_started_at": start_time.isoformat(), - "run_at": ( - job.payload.get("run_at") if isinstance(job.payload, dict) else None - ), + "run_at": job.payload.get("run_at") + if isinstance(job.payload, dict) + else None, }, "cron_payload": payload, } - await self._woke_main_agent( - message=note, - session_str=session_str, - extras=extras, + message=note, session_str=session_str, extras=extras ) async def _woke_main_agent( - self, - *, - message: str, - session_str: str, - extras: dict, + self, *, message: str, session_str: str, extras: dict ) -> None: """Woke the main agent to handle the cron job message.""" from astrbot.core.astr_main_agent import ( @@ -312,7 +298,6 @@ class CronJobManager: except Exception as e: logger.error(f"Invalid session for cron job: {e}") return - cron_event = CronMessageEvent( context=self.ctx, session=session, @@ -320,8 +305,6 @@ class CronJobManager: extras=extras or {}, message_type=session.message_type, ) - - # judge user's role umo = cron_event.unified_msg_origin cfg = self.ctx.get_config(umo=umo) cron_payload = extras.get("cron_payload", {}) if extras else {} @@ -331,7 +314,6 @@ class CronJobManager: cron_event.role = "admin" if sender_id in admin_ids else "member" if cron_payload.get("origin", "tool") == "api": cron_event.role = "admin" - from astrbot.core.computer.computer_tool_provider import ComputerToolProvider tool_call_timeout = cfg.get("provider_settings", {}).get( @@ -346,7 +328,6 @@ class CronJobManager: req = ProviderRequest() conv = await _get_session_conv(event=cron_event, plugin_context=self.ctx) req.conversation = conv - # finetine the messages context = json.loads(conv.history) if context: req.contexts = context @@ -363,29 +344,22 @@ class CronJobManager: if not req.func_tool: req.func_tool = ToolSet() req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL) - result = await build_main_agent( event=cron_event, plugin_context=self.ctx, config=config, req=req ) if not result: logger.error("Failed to build main agent for cron job.") return - runner = result.agent_runner async for _ in runner.step_until_done(30): - # agent will send message to user via using tools pass llm_resp = runner.get_final_llm_resp() cron_meta = extras.get("cron_job", {}) if extras else {} - summary_note = ( - f"[CronJob] {cron_meta.get('name') or cron_meta.get('id', 'unknown')}: {cron_meta.get('description', '')} " - f" triggered at {cron_meta.get('run_started_at', 'unknown time')}, " - ) + summary_note = f"[CronJob] {cron_meta.get('name') or cron_meta.get('id', 'unknown')}: {cron_meta.get('description', '')} triggered at {cron_meta.get('run_started_at', 'unknown time')}, " if llm_resp and llm_resp.role == "assistant": summary_note += ( f"I finished this job, here is the result: {llm_resp.completion_text}" ) - await persist_agent_history( self.ctx.conversation_manager, event=cron_event, diff --git a/astrbot/core/db/migration/migra_3_to_4.py b/astrbot/core/db/migration/migra_3_to_4.py index 8f1b006ea..a47b29b39 100644 --- a/astrbot/core/db/migration/migra_3_to_4.py +++ b/astrbot/core/db/migration/migra_3_to_4.py @@ -1,6 +1,5 @@ import datetime import json -from typing import Any, cast from sqlalchemy import text @@ -14,15 +13,11 @@ from astrbot.core.platform.astr_message_event import MessageSesion from .shared_preferences_v3 import sp as sp_v3 from .sqlite_v3 import SQLiteDatabase as SQLiteV3DatabaseV3 -""" -1. 迁移旧的 webchat_conversation 表到新的 conversation 表。 -2. 迁移旧的 platform 到新的 platform_stats 表。 -""" +"\n1. 迁移旧的 webchat_conversation 表到新的 conversation 表。\n2. 迁移旧的 platform 到新的 platform_stats 表。\n" def get_platform_id( - platform_id_map: dict[str, dict[str, str]], - old_platform_name: str, + platform_id_map: dict[str, dict[str, str]], old_platform_name: str ) -> str: return platform_id_map.get( old_platform_name, @@ -31,8 +26,7 @@ def get_platform_id( def get_platform_type( - platform_id_map: dict[str, dict[str, str]], - old_platform_name: str, + platform_id_map: dict[str, dict[str, str]], old_platform_name: str ) -> str: return platform_id_map.get( old_platform_name, @@ -41,18 +35,15 @@ def get_platform_type( async def migration_conversation_table( - db_helper: BaseDatabase, - platform_id_map: dict[str, dict[str, str]], + db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]] ) -> None: db_helper_v3 = SQLiteV3DatabaseV3( - db_path=DB_PATH.replace("data_v4.db", "data_v3.db"), + db_path=DB_PATH.replace("data_v4.db", "data_v3.db") ) conversations, total_cnt = db_helper_v3.get_all_conversations( - page=1, - page_size=10000000, + page=1, page_size=10000000 ) logger.info(f"迁移 {total_cnt} 条旧的会话数据到新的表中...") - async with db_helper.get_db() as dbsession: async with dbsession.begin(): for idx, conversation in enumerate(conversations): @@ -67,17 +58,16 @@ async def migration_conversation_table( ) if not conv: logger.info( - f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。", + f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。" ) continue if ":" not in conv.user_id: continue session = MessageSesion.from_str(session_str=conv.user_id) platform_id = get_platform_id( - platform_id_map, - session.platform_name, + platform_id_map, session.platform_name ) - session.platform_id = platform_id # 更新平台名称为新的 ID + session.platform_id = platform_id conv_v2 = ConversationV2( user_id=str(session), content=json.loads(conv.history) if conv.history else [], @@ -98,11 +88,10 @@ async def migration_conversation_table( async def migration_platform_table( - db_helper: BaseDatabase, - platform_id_map: dict[str, dict[str, str]], + db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]] ) -> None: db_helper_v3 = SQLiteV3DatabaseV3( - db_path=DB_PATH.replace("data_v4.db", "data_v3.db"), + db_path=DB_PATH.replace("data_v4.db", "data_v3.db") ) secs_from_2023_4_10_to_now = ( datetime.datetime.now(datetime.timezone.utc) @@ -113,18 +102,14 @@ async def migration_platform_table( stats = db_helper_v3.get_base_stats(offset_sec=offset_sec) logger.info(f"迁移 {len(stats.platform)} 条旧的平台数据到新的表中...") platform_stats_v3 = stats.platform - if not platform_stats_v3: logger.info("没有找到旧平台数据,跳过迁移。") return - first_time_stamp = platform_stats_v3[0].timestamp end_time_stamp = platform_stats_v3[-1].timestamp - start_time = first_time_stamp - (first_time_stamp % 3600) # 向下取整到小时 - end_time = end_time_stamp + (3600 - (end_time_stamp % 3600)) # 向上取整到小时 - + start_time = first_time_stamp - first_time_stamp % 3600 + end_time = end_time_stamp + (3600 - end_time_stamp % 3600) idx = 0 - async with db_helper.get_db() as dbsession: async with dbsession.begin(): total_buckets = (end_time - start_time) // 3600 @@ -142,25 +127,19 @@ async def migration_platform_table( if cnt == 0: continue platform_id = get_platform_id( - platform_id_map, - platform_stats_v3[idx].name, + platform_id_map, platform_stats_v3[idx].name ) platform_type = get_platform_type( - platform_id_map, - platform_stats_v3[idx].name, + platform_id_map, platform_stats_v3[idx].name ) try: await dbsession.execute( - text(""" - INSERT INTO platform_stats (timestamp, platform_id, platform_type, count) - VALUES (:timestamp, :platform_id, :platform_type, :count) - ON CONFLICT(timestamp, platform_id, platform_type) DO UPDATE SET - count = platform_stats.count + EXCLUDED.count - """), + text( + "\n INSERT INTO platform_stats (timestamp, platform_id, platform_type, count)\n VALUES (:timestamp, :platform_id, :platform_type, :count)\n ON CONFLICT(timestamp, platform_id, platform_type) DO UPDATE SET\n count = platform_stats.count + EXCLUDED.count\n " + ), { "timestamp": datetime.datetime.fromtimestamp( - bucket_end, - tz=datetime.timezone.utc, + bucket_end, tz=datetime.timezone.utc ), "platform_id": platform_id, "platform_type": platform_type, @@ -176,19 +155,16 @@ async def migration_platform_table( async def migration_webchat_data( - db_helper: BaseDatabase, - platform_id_map: dict[str, dict[str, str]], + db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]] ) -> None: """迁移 WebChat 的历史记录到新的 PlatformMessageHistory 表中""" db_helper_v3 = SQLiteV3DatabaseV3( - db_path=DB_PATH.replace("data_v4.db", "data_v3.db"), + db_path=DB_PATH.replace("data_v4.db", "data_v3.db") ) conversations, total_cnt = db_helper_v3.get_all_conversations( - page=1, - page_size=10000000, + page=1, page_size=10000000 ) logger.info(f"迁移 {total_cnt} 条旧的 WebChat 会话数据到新的表中...") - async with db_helper.get_db() as dbsession: async with dbsession.begin(): for idx, conversation in enumerate(conversations): @@ -203,7 +179,7 @@ async def migration_webchat_data( ) if not conv: logger.info( - f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。", + f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。" ) continue if ":" in conv.user_id: @@ -211,28 +187,25 @@ async def migration_webchat_data( platform_id = "webchat" history = json.loads(conv.history) if conv.history else [] for msg in history: - type_ = msg.get("type") # user type, "bot" or "user" + type_ = msg.get("type") new_history = PlatformMessageHistory( platform_id=platform_id, - user_id=conv.cid, # we use conv.cid as user_id for webchat + user_id=conv.cid, content=msg, sender_id=type_, sender_name=type_, ) dbsession.add(new_history) - except Exception: logger.error( f"迁移旧 WebChat 会话 {conversation.get('cid', 'unknown')} 失败", exc_info=True, ) - logger.info(f"成功迁移 {total_cnt} 条旧的 WebChat 会话数据到新表。") async def migration_persona_data( - db_helper: BaseDatabase, - astrbot_config: AstrBotConfig, + db_helper: BaseDatabase, astrbot_config: AstrBotConfig ) -> None: """迁移 Persona 数据到新的表中。 旧的 Persona 数据存储在 preference 中,新的 Persona 数据存储在 persona 表中。 @@ -240,7 +213,6 @@ async def migration_persona_data( v3_persona_config: list[dict] = astrbot_config.get("persona", []) total_personas = len(v3_persona_config) logger.info(f"迁移 {total_personas} 个 Persona 配置到新表中...") - for idx, persona in enumerate(v3_persona_config): if total_personas > 0 and (idx + 1) % max(1, total_personas // 10) == 0: progress = int((idx + 1) / total_personas * 100) @@ -267,17 +239,15 @@ async def migration_persona_data( begin_dialogs=begin_dialogs, ) logger.info( - f"迁移 Persona {persona['name']}({persona_new.system_prompt[:30]}...) 到新表成功。", + f"迁移 Persona {persona['name']}({persona_new.system_prompt[:30]}...) 到新表成功。" ) except Exception as e: logger.error(f"解析 Persona 配置失败:{e}") async def migration_preferences( - db_helper: BaseDatabase, - platform_id_map: dict[str, dict[str, str]], + db_helper: BaseDatabase, platform_id_map: dict[str, dict[str, str]] ) -> None: - # 1. global scope migration keys = [ "inactivated_llm_tools", "inactivated_plugins", @@ -291,11 +261,7 @@ async def migration_preferences( if value is not None: await sp.put_async("global", "global", key, value) logger.info(f"迁移全局偏好设置 {key} 成功,值: {value}") - - # 2. umo scope migration - session_conversation = cast( - dict[str, Any], sp_v3.get("session_conversation", default={}) - ) + session_conversation = sp_v3.get("session_conversation", default={}) for umo, conversation_id in session_conversation.items(): if not umo or not conversation_id: continue @@ -307,10 +273,7 @@ async def migration_preferences( logger.info(f"迁移会话 {umo} 的对话数据到新表成功,平台 ID: {platform_id}") except Exception as e: logger.error(f"迁移会话 {umo} 的对话数据失败: {e}", exc_info=True) - - session_service_config = cast( - dict[str, Any], sp_v3.get("session_service_config", default={}) - ) + session_service_config = sp_v3.get("session_service_config", default={}) for umo, config in session_service_config.items(): if not umo or not config: continue @@ -318,14 +281,11 @@ async def migration_preferences( session = MessageSesion.from_str(session_str=umo) platform_id = get_platform_id(platform_id_map, session.platform_name) session.platform_id = platform_id - await sp.put_async("umo", str(session), "session_service_config", config) - logger.info(f"迁移会话 {umo} 的服务配置到新表成功,平台 ID: {platform_id}") except Exception as e: logger.error(f"迁移会话 {umo} 的服务配置失败: {e}", exc_info=True) - - session_variables = cast(dict[str, Any], sp_v3.get("session_variables", default={})) + session_variables = sp_v3.get("session_variables", default={}) for umo, variables in session_variables.items(): if not umo or not variables: continue @@ -336,10 +296,7 @@ async def migration_preferences( await sp.put_async("umo", str(session), "session_variables", variables) except Exception as e: logger.error(f"迁移会话 {umo} 的变量失败: {e}", exc_info=True) - - session_provider_perf = cast( - dict[str, Any], sp_v3.get("session_provider_perf", default={}) - ) + session_provider_perf = sp_v3.get("session_provider_perf", default={}) for umo, perf in session_provider_perf.items(): if not umo or not perf: continue @@ -347,17 +304,11 @@ async def migration_preferences( session = MessageSesion.from_str(session_str=umo) platform_id = get_platform_id(platform_id_map, session.platform_name) session.platform_id = platform_id - - perf_dict = cast(dict[str, Any], perf) + perf_dict = perf for provider_type, provider_id in perf_dict.items(): await sp.put_async( - "umo", - str(session), - f"provider_perf_{provider_type}", - provider_id, + "umo", str(session), f"provider_perf_{provider_type}", provider_id ) - logger.info( - f"迁移会话 {umo} 的提供商偏好到新表成功,平台 ID: {platform_id}", - ) + logger.info(f"迁移会话 {umo} 的提供商偏好到新表成功,平台 ID: {platform_id}") except Exception as e: logger.error(f"迁移会话 {umo} 的提供商偏好失败: {e}", exc_info=True) diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 7c91a9c47..56f9de2e6 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -2,9 +2,9 @@ import asyncio import threading from collections.abc import Awaitable, Callable, Sequence from datetime import datetime, timedelta, timezone -from typing import Any, TypeVar, cast +from typing import Any, TypeVar -from sqlalchemy import CursorResult, Row +from sqlalchemy import Row from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import col, delete, desc, func, or_, select, text, update @@ -27,12 +27,8 @@ from astrbot.core.db.po import ( SessionProjectRelation, SQLModel, ) -from astrbot.core.db.po import ( - Platform as DeprecatedPlatformStat, -) -from astrbot.core.db.po import ( - Stats as DeprecatedStats, -) +from astrbot.core.db.po import Platform as DeprecatedPlatformStat +from astrbot.core.db.po import Stats as DeprecatedStats from astrbot.core.sentinels import NOT_GIVEN TxResult = TypeVar("TxResult") @@ -56,7 +52,6 @@ class SQLiteDatabase(BaseDatabase): await conn.execute(text("PRAGMA temp_store=MEMORY")) await conn.execute(text("PRAGMA mmap_size=134217728")) await conn.execute(text("PRAGMA optimize")) - # 确保 personas 表有 folder_id、sort_order、skills 列(前向兼容) await self._ensure_persona_folder_columns(conn) await self._ensure_persona_skills_column(conn) await self._ensure_persona_custom_error_message_column(conn) @@ -70,7 +65,6 @@ class SQLiteDatabase(BaseDatabase): """ result = await conn.execute(text("PRAGMA table_info(personas)")) columns = {row[1] for row in result.fetchall()} - if "folder_id" not in columns: await conn.execute( text( @@ -90,7 +84,6 @@ class SQLiteDatabase(BaseDatabase): """ result = await conn.execute(text("PRAGMA table_info(personas)")) columns = {row[1] for row in result.fetchall()} - if "skills" not in columns: await conn.execute(text("ALTER TABLE personas ADD COLUMN skills JSON")) @@ -98,40 +91,26 @@ class SQLiteDatabase(BaseDatabase): """确保 personas 表有 custom_error_message 列。""" result = await conn.execute(text("PRAGMA table_info(personas)")) columns = {row[1] for row in result.fetchall()} - if "custom_error_message" not in columns: await conn.execute( text("ALTER TABLE personas ADD COLUMN custom_error_message TEXT") ) - # ==== - # Platform Statistics - # ==== - async def insert_platform_stats( - self, - platform_id, - platform_type, - count=1, - timestamp=None, + self, platform_id, platform_type, count=1, timestamp=None ) -> None: """Insert a new platform statistic record.""" async with self.get_db() as session: async with session.begin(): if timestamp is None: timestamp = datetime.now().replace( - minute=0, - second=0, - microsecond=0, + minute=0, second=0, microsecond=0 ) current_hour = timestamp await session.execute( - text(""" - INSERT INTO platform_stats (timestamp, platform_id, platform_type, count) - VALUES (:timestamp, :platform_id, :platform_type, :count) - ON CONFLICT(timestamp, platform_id, platform_type) DO UPDATE SET - count = platform_stats.count + EXCLUDED.count - """), + text( + "\n INSERT INTO platform_stats (timestamp, platform_id, platform_type, count)\n VALUES (:timestamp, :platform_id, :platform_type, :count)\n ON CONFLICT(timestamp, platform_id, platform_type) DO UPDATE SET\n count = platform_stats.count + EXCLUDED.count\n " + ), { "timestamp": current_hour, "platform_id": platform_id, @@ -145,8 +124,8 @@ class SQLiteDatabase(BaseDatabase): async with self.get_db() as session: result = await session.execute( select(func.count(col(PlatformStat.platform_id))).select_from( - PlatformStat, - ), + PlatformStat + ) ) count = result.scalar_one_or_none() return count if count is not None else 0 @@ -157,12 +136,9 @@ class SQLiteDatabase(BaseDatabase): now = datetime.now() start_time = now - timedelta(seconds=offset_sec) result = await session.execute( - text(""" - SELECT * FROM platform_stats - WHERE timestamp >= :start_time - GROUP BY platform_id - ORDER BY timestamp DESC - """), + text( + "\n SELECT * FROM platform_stats\n WHERE timestamp >= :start_time\n GROUP BY platform_id\n ORDER BY timestamp DESC\n " + ), {"start_time": start_time}, ) return list(result.scalars().all()) @@ -181,15 +157,12 @@ class SQLiteDatabase(BaseDatabase): """Insert a provider stat record for a single agent response.""" stats = stats or {} token_usage = stats.get("token_usage", {}) - token_input_other = int(token_usage.get("input_other", 0) or 0) token_input_cached = int(token_usage.get("input_cached", 0) or 0) token_output = int(token_usage.get("output", 0) or 0) - start_time = float(stats.get("start_time", 0.0) or 0.0) end_time = float(stats.get("end_time", 0.0) or 0.0) time_to_first_token = float(stats.get("time_to_first_token", 0.0) or 0.0) - async with self.get_db() as session: async with session.begin(): record = ProviderStat( @@ -211,22 +184,15 @@ class SQLiteDatabase(BaseDatabase): await session.refresh(record) return record - # ==== - # Conversation Management - # ==== - async def get_conversations(self, user_id=None, platform_id=None): async with self.get_db() as session: query = select(ConversationV2) - if user_id: query = query.where(ConversationV2.user_id == user_id) if platform_id: query = query.where(ConversationV2.platform_id == platform_id) - # order by query = query.order_by(desc(ConversationV2.created_at)) result = await session.execute(query) - return result.scalars().all() async def get_conversation_by_id(self, cid): @@ -242,25 +208,18 @@ class SQLiteDatabase(BaseDatabase): select(ConversationV2) .order_by(desc(ConversationV2.created_at)) .offset(offset) - .limit(page_size), + .limit(page_size) ) return result.scalars().all() async def get_filtered_conversations( - self, - page=1, - page_size=20, - platform_ids=None, - search_query="", - **kwargs, + self, page=1, page_size=20, platform_ids=None, search_query="", **kwargs ): async with self.get_db() as session: - # Build the base query with filters base_query = select(ConversationV2) - if platform_ids: base_query = base_query.where( - col(ConversationV2.platform_id).in_(platform_ids), + col(ConversationV2.platform_id).in_(platform_ids) ) if search_query: search_query = search_query.encode("unicode_escape").decode("utf-8") @@ -270,24 +229,20 @@ class SQLiteDatabase(BaseDatabase): col(ConversationV2.content).ilike(f"%{search_query}%"), col(ConversationV2.user_id).ilike(f"%{search_query}%"), col(ConversationV2.conversation_id).ilike(f"%{search_query}%"), - ), + ) ) if "message_types" in kwargs and len(kwargs["message_types"]) > 0: for msg_type in kwargs["message_types"]: base_query = base_query.where( - col(ConversationV2.user_id).ilike(f"%:{msg_type}:%"), + col(ConversationV2.user_id).ilike(f"%:{msg_type}:%") ) if "platforms" in kwargs and len(kwargs["platforms"]) > 0: base_query = base_query.where( - col(ConversationV2.platform_id).in_(kwargs["platforms"]), + col(ConversationV2.platform_id).in_(kwargs["platforms"]) ) - - # Get total count matching the filters count_query = select(func.count()).select_from(base_query.subquery()) total_count = await session.execute(count_query) total = total_count.scalar_one() - - # Get paginated results offset = (page - 1) * page_size result_query = ( base_query.order_by(desc(ConversationV2.created_at)) @@ -296,8 +251,7 @@ class SQLiteDatabase(BaseDatabase): ) result = await session.execute(result_query) conversations = result.scalars().all() - - return conversations, total + return (conversations, total) async def create_conversation( self, @@ -342,7 +296,7 @@ class SQLiteDatabase(BaseDatabase): async with self.get_db() as session: async with session.begin(): query = update(ConversationV2).where( - col(ConversationV2.conversation_id) == cid, + col(ConversationV2.conversation_id) == cid ) values = {} if title is not None: @@ -366,35 +320,28 @@ class SQLiteDatabase(BaseDatabase): async with session.begin(): await session.execute( delete(ConversationV2).where( - col(ConversationV2.conversation_id) == cid, - ), + col(ConversationV2.conversation_id) == cid + ) ) async def delete_conversations_by_user_id(self, user_id: str) -> None: async with self.get_db() as session: async with session.begin(): await session.execute( - delete(ConversationV2).where( - col(ConversationV2.user_id) == user_id - ), + delete(ConversationV2).where(col(ConversationV2.user_id) == user_id) ) async def get_session_conversations( - self, - page=1, - page_size=20, - search_query=None, - platform=None, + self, page=1, page_size=20, search_query=None, platform=None ) -> tuple[list[dict], int]: """Get paginated session conversations with joined conversation and persona details.""" async with self.get_db() as session: offset = (page - 1) * page_size - base_query = ( select( col(Preference.scope_id).label("session_id"), func.json_extract(Preference.value, "$.val").label( - "conversation_id", + "conversation_id" ), col(ConversationV2.persona_id).label("persona_id"), col(ConversationV2.title).label("title"), @@ -407,13 +354,10 @@ class SQLiteDatabase(BaseDatabase): == ConversationV2.conversation_id, ) .outerjoin( - Persona, - col(ConversationV2.persona_id) == Persona.persona_id, + Persona, col(ConversationV2.persona_id) == Persona.persona_id ) .where(Preference.scope == "umo", Preference.key == "sel_conv_id") ) - - # 搜索筛选 if search_query: search_pattern = f"%{search_query}%" base_query = base_query.where( @@ -421,25 +365,17 @@ class SQLiteDatabase(BaseDatabase): col(Preference.scope_id).ilike(search_pattern), col(ConversationV2.title).ilike(search_pattern), col(Persona.persona_id).ilike(search_pattern), - ), + ) ) - - # 平台筛选 if platform: platform_pattern = f"{platform}:%" base_query = base_query.where( - col(Preference.scope_id).like(platform_pattern), + col(Preference.scope_id).like(platform_pattern) ) - - # 排序 base_query = base_query.order_by(Preference.scope_id) - - # 分页结果 result_query = base_query.offset(offset).limit(page_size) result = await session.execute(result_query) rows = result.fetchall() - - # 查询总数(应用相同的筛选条件) count_base_query = ( select(func.count(col(Preference.scope_id))) .select_from(Preference) @@ -449,13 +385,10 @@ class SQLiteDatabase(BaseDatabase): == ConversationV2.conversation_id, ) .outerjoin( - Persona, - col(ConversationV2.persona_id) == Persona.persona_id, + Persona, col(ConversationV2.persona_id) == Persona.persona_id ) .where(Preference.scope == "umo", Preference.key == "sel_conv_id") ) - - # 应用相同的搜索和平台筛选条件到计数查询 if search_query: search_pattern = f"%{search_query}%" count_base_query = count_base_query.where( @@ -463,18 +396,15 @@ class SQLiteDatabase(BaseDatabase): col(Preference.scope_id).ilike(search_pattern), col(ConversationV2.title).ilike(search_pattern), col(Persona.persona_id).ilike(search_pattern), - ), + ) ) - if platform: platform_pattern = f"{platform}:%" count_base_query = count_base_query.where( - col(Preference.scope_id).like(platform_pattern), + col(Preference.scope_id).like(platform_pattern) ) - total_result = await session.execute(count_base_query) total = total_result.scalar() or 0 - sessions_data = [ { "session_id": row.session_id, @@ -485,15 +415,10 @@ class SQLiteDatabase(BaseDatabase): } for row in rows ] - return sessions_data, total + return (sessions_data, total) async def insert_platform_message_history( - self, - platform_id, - user_id, - content, - sender_id=None, - sender_name=None, + self, platform_id, user_id, content, sender_id=None, sender_name=None ): """Insert a new platform message history record.""" async with self.get_db() as session: @@ -509,10 +434,7 @@ class SQLiteDatabase(BaseDatabase): return new_history async def delete_platform_message_offset( - self, - platform_id, - user_id, - offset_sec=86400, + self, platform_id, user_id, offset_sec=86400 ) -> None: """Delete platform message history records newer than the specified offset.""" async with self.get_db() as session: @@ -524,15 +446,11 @@ class SQLiteDatabase(BaseDatabase): col(PlatformMessageHistory.platform_id) == platform_id, col(PlatformMessageHistory.user_id) == user_id, col(PlatformMessageHistory.created_at) >= cutoff_time, - ), + ) ) async def get_platform_message_history( - self, - platform_id, - user_id, - page=1, - page_size=20, + self, platform_id, user_id, page=1, page_size=20 ): """Get platform message history records.""" async with self.get_db() as session: @@ -549,12 +467,7 @@ class SQLiteDatabase(BaseDatabase): return result.scalars().all() async def list_sdk_platform_message_history( - self, - platform_id, - user_id, - cursor_id=None, - limit=50, - include_total=False, + self, platform_id, user_id, cursor_id=None, limit=50, include_total=False ): """List SDK message history records ordered by descending id.""" async with self.get_db() as session: @@ -581,14 +494,9 @@ class SQLiteDatabase(BaseDatabase): ) total_result = await session.execute(total_query) total = int(total_result.scalar() or 0) - return list(result.scalars().all()), total + return (list(result.scalars().all()), total) - async def delete_platform_message_before( - self, - platform_id, - user_id, - before, - ) -> int: + async def delete_platform_message_before(self, platform_id, user_id, before) -> int: """Delete platform message history records strictly older than the boundary.""" async with self.get_db() as session: async with session.begin(): @@ -597,16 +505,11 @@ class SQLiteDatabase(BaseDatabase): col(PlatformMessageHistory.platform_id) == platform_id, col(PlatformMessageHistory.user_id) == user_id, col(PlatformMessageHistory.created_at) < before, - ), + ) ) return int(getattr(result, "rowcount", 0) or 0) - async def delete_platform_message_after( - self, - platform_id, - user_id, - after, - ) -> int: + async def delete_platform_message_after(self, platform_id, user_id, after) -> int: """Delete platform message history records strictly newer than the boundary.""" async with self.get_db() as session: async with session.begin(): @@ -615,15 +518,11 @@ class SQLiteDatabase(BaseDatabase): col(PlatformMessageHistory.platform_id) == platform_id, col(PlatformMessageHistory.user_id) == user_id, col(PlatformMessageHistory.created_at) > after, - ), + ) ) return int(getattr(result, "rowcount", 0) or 0) - async def delete_all_platform_message_history( - self, - platform_id, - user_id, - ) -> int: + async def delete_all_platform_message_history(self, platform_id, user_id) -> int: """Delete all platform message history records for a specific user.""" async with self.get_db() as session: async with session.begin(): @@ -631,15 +530,12 @@ class SQLiteDatabase(BaseDatabase): delete(PlatformMessageHistory).where( col(PlatformMessageHistory.platform_id) == platform_id, col(PlatformMessageHistory.user_id) == user_id, - ), + ) ) return int(getattr(result, "rowcount", 0) or 0) async def find_platform_message_history_by_idempotency_key( - self, - platform_id, - user_id, - idempotency_key, + self, platform_id, user_id, idempotency_key ) -> PlatformMessageHistory | None: """Find a SDK message history record by its idempotency key.""" async with self.get_db() as session: @@ -673,11 +569,7 @@ class SQLiteDatabase(BaseDatabase): """Insert a new attachment record.""" async with self.get_db() as session: async with session.begin(): - new_attachment = Attachment( - path=path, - type=type, - mime_type=mime_type, - ) + new_attachment = Attachment(path=path, type=type, mime_type=mime_type) session.add(new_attachment) return new_attachment @@ -709,7 +601,7 @@ class SQLiteDatabase(BaseDatabase): query = delete(Attachment).where( col(Attachment.attachment_id) == attachment_id ) - result = cast(CursorResult, await session.execute(query)) + result = await session.execute(query) return getattr(result, "rowcount", 0) > 0 async def delete_attachments(self, attachment_ids: list[str]) -> int: @@ -724,7 +616,7 @@ class SQLiteDatabase(BaseDatabase): query = delete(Attachment).where( col(Attachment.attachment_id).in_(attachment_ids) ) - result = cast(CursorResult, await session.execute(query)) + result = await session.execute(query) return getattr(result, "rowcount", 0) async def create_api_key( @@ -787,7 +679,7 @@ class SQLiteDatabase(BaseDatabase): await session.execute( update(ApiKey) .where(col(ApiKey.key_id) == key_id) - .values(last_used_at=datetime.now(timezone.utc)), + .values(last_used_at=datetime.now(timezone.utc)) ) async def revoke_api_key(self, key_id: str) -> bool: @@ -799,18 +691,15 @@ class SQLiteDatabase(BaseDatabase): .where(col(ApiKey.key_id) == key_id) .values(revoked_at=datetime.now(timezone.utc)) ) - result = cast(CursorResult, await session.execute(query)) + result = await session.execute(query) return getattr(result, "rowcount", 0) > 0 async def delete_api_key(self, key_id: str) -> bool: """Delete an API key.""" async with self.get_db() as session: async with session.begin(): - result = cast( - CursorResult, - await session.execute( - delete(ApiKey).where(col(ApiKey.key_id) == key_id) - ), + result = await session.execute( + delete(ApiKey).where(col(ApiKey.key_id) == key_id) ) return getattr(result, "rowcount", 0) > 0 @@ -892,13 +781,9 @@ class SQLiteDatabase(BaseDatabase): async with self.get_db() as session: async with session.begin(): await session.execute( - delete(Persona).where(col(Persona.persona_id) == persona_id), + delete(Persona).where(col(Persona.persona_id) == persona_id) ) - # ==== - # Persona Folder Management - # ==== - async def insert_persona_folder( self, name: str, @@ -938,7 +823,6 @@ class SQLiteDatabase(BaseDatabase): """ async with self.get_db() as session: if parent_id is None: - # Get root folders (parent_id is NULL) query = ( select(PersonaFolder) .where(col(PersonaFolder.parent_id).is_(None)) @@ -999,17 +883,15 @@ class SQLiteDatabase(BaseDatabase): """ async with self.get_db() as session: async with session.begin(): - # Move personas to root directory await session.execute( update(Persona) .where(col(Persona.folder_id) == folder_id) .values(folder_id=None) ) - # Delete the folder await session.execute( delete(PersonaFolder).where( col(PersonaFolder.folder_id) == folder_id - ), + ) ) async def move_persona_to_folder( @@ -1049,10 +931,7 @@ class SQLiteDatabase(BaseDatabase): result = await session.execute(query) return list(result.scalars().all()) - async def batch_update_sort_order( - self, - items: list[dict], - ) -> None: + async def batch_update_sort_order(self, items: list[dict]) -> None: """Batch update sort_order for personas and/or folders. Args: @@ -1063,17 +942,14 @@ class SQLiteDatabase(BaseDatabase): """ if not items: return - async with self.get_db() as session: async with session.begin(): for item in items: item_id = item.get("id") item_type = item.get("type") sort_order = item.get("sort_order") - if item_id is None or item_type is None or sort_order is None: continue - if item_type == "persona": await session.execute( update(Persona) @@ -1102,10 +978,7 @@ class SQLiteDatabase(BaseDatabase): existing_preference.value = value else: new_preference = Preference( - scope=scope, - scope_id=scope_id, - key=key, - value=value, + scope=scope, scope_id=scope_id, key=key, value=value ) session.add(new_preference) return existing_preference or new_preference @@ -1141,7 +1014,7 @@ class SQLiteDatabase(BaseDatabase): col(Preference.scope) == scope, col(Preference.scope_id) == scope_id, col(Preference.key) == key, - ), + ) ) await session.commit() @@ -1153,17 +1026,12 @@ class SQLiteDatabase(BaseDatabase): delete(Preference).where( col(Preference.scope) == scope, col(Preference.scope_id) == scope_id, - ), + ) ) await session.commit() - # ==== - # Command Configuration & Conflict Tracking - # ==== - async def _run_in_tx( - self, - fn: Callable[[AsyncSession], Awaitable[TxResult]], + self, fn: Callable[[AsyncSession], Awaitable[TxResult]] ) -> TxResult: async with self.get_db() as session: async with session.begin(): @@ -1238,10 +1106,7 @@ class SQLiteDatabase(BaseDatabase): result = await session.execute(select(CommandConfig)) return list(result.scalars().all()) - async def get_command_config( - self, - handler_full_name: str, - ) -> CommandConfig | None: + async def get_command_config(self, handler_full_name: str) -> CommandConfig | None: async with self.get_db() as session: return await session.get(CommandConfig, handler_full_name) @@ -1261,6 +1126,7 @@ class SQLiteDatabase(BaseDatabase): extra_data: dict | None = None, auto_managed: bool | None = None, ) -> CommandConfig: + async def _op(session: AsyncSession) -> CommandConfig: config = await session.get(CommandConfig, handler_full_name) if not config: @@ -1310,15 +1176,14 @@ class SQLiteDatabase(BaseDatabase): async def _op(session: AsyncSession) -> None: await session.execute( delete(CommandConfig).where( - col(CommandConfig.handler_full_name).in_(handler_full_names), - ), + col(CommandConfig.handler_full_name).in_(handler_full_names) + ) ) await self._run_in_tx(_op) async def list_command_conflicts( - self, - status: str | None = None, + self, status: str | None = None ) -> list[CommandConflict]: async with self.get_db() as session: query = select(CommandConflict) @@ -1340,12 +1205,13 @@ class SQLiteDatabase(BaseDatabase): extra_data: dict | None = None, auto_generated: bool | None = None, ) -> CommandConflict: + async def _op(session: AsyncSession) -> CommandConflict: result = await session.execute( select(CommandConflict).where( CommandConflict.conflict_key == conflict_key, CommandConflict.handler_full_name == handler_full_name, - ), + ) ) record = result.scalar_one_or_none() if not record: @@ -1384,15 +1250,11 @@ class SQLiteDatabase(BaseDatabase): async def _op(session: AsyncSession) -> None: await session.execute( - delete(CommandConflict).where(col(CommandConflict.id).in_(ids)), + delete(CommandConflict).where(col(CommandConflict.id).in_(ids)) ) await self._run_in_tx(_op) - # ==== - # Deprecated Methods - # ==== - def get_base_stats(self, offset_sec=86400): """Get base statistics within the specified offset in seconds.""" @@ -1401,7 +1263,7 @@ class SQLiteDatabase(BaseDatabase): now = datetime.now() start_time = now - timedelta(seconds=offset_sec) result = await session.execute( - select(PlatformStat).where(PlatformStat.timestamp >= start_time), + select(PlatformStat).where(PlatformStat.timestamp >= start_time) ) all_datas = result.scalars().all() deprecated_stats = DeprecatedStats() @@ -1411,7 +1273,7 @@ class SQLiteDatabase(BaseDatabase): name=data.platform_id, count=data.count, timestamp=int(data.timestamp.timestamp()), - ), + ) ) return deprecated_stats @@ -1432,7 +1294,7 @@ class SQLiteDatabase(BaseDatabase): async def _inner(): async with self.get_db() as session: result = await session.execute( - select(func.sum(PlatformStat.count)).select_from(PlatformStat), + select(func.sum(PlatformStat.count)).select_from(PlatformStat) ) total_count = result.scalar_one_or_none() return total_count if total_count is not None else 0 @@ -1449,7 +1311,7 @@ class SQLiteDatabase(BaseDatabase): return result def get_grouped_base_stats(self, offset_sec=86400): - # group by platform_id + async def _inner(): async with self.get_db() as session: now = datetime.now() @@ -1457,7 +1319,7 @@ class SQLiteDatabase(BaseDatabase): result = await session.execute( select(PlatformStat.platform_id, func.sum(PlatformStat.count)) .where(PlatformStat.timestamp >= start_time) - .group_by(PlatformStat.platform_id), + .group_by(PlatformStat.platform_id) ) grouped_stats = result.all() deprecated_stats = DeprecatedStats() @@ -1467,7 +1329,7 @@ class SQLiteDatabase(BaseDatabase): name=platform_id, count=count, timestamp=int(start_time.timestamp()), - ), + ) ) return deprecated_stats @@ -1482,10 +1344,6 @@ class SQLiteDatabase(BaseDatabase): t.join() return result - # ==== - # Platform Session Management - # ==== - async def create_platform_session( self, creator: str, @@ -1498,7 +1356,6 @@ class SQLiteDatabase(BaseDatabase): kwargs = {} if session_id: kwargs["session_id"] = session_id - async with self.get_db() as session: async with session.begin(): new_session = PlatformSession( @@ -1519,7 +1376,7 @@ class SQLiteDatabase(BaseDatabase): """Get a Platform session by its ID.""" async with self.get_db() as session: query = select(PlatformSession).where( - PlatformSession.session_id == session_id, + PlatformSession.session_id == session_id ) result = await session.execute(query) return result.scalar_one_or_none() @@ -1530,7 +1387,6 @@ class SQLiteDatabase(BaseDatabase): """Get platform sessions by IDs.""" if not session_ids: return [] - async with self.get_db() as session: query = select(PlatformSession).where( col(PlatformSession.session_id).in_(session_ids) @@ -1585,12 +1441,10 @@ class SQLiteDatabase(BaseDatabase): ) .where(col(PlatformSession.creator) == creator) ) - if platform_id: query = query.where(PlatformSession.platform_id == platform_id) if exclude_project_sessions: query = query.where(col(ChatUIProject.project_id).is_(None)) - return query @staticmethod @@ -1601,7 +1455,6 @@ class SQLiteDatabase(BaseDatabase): project_id = row[1] project_title = row[2] project_emoji = row[3] - session_dict = { "session": platform_session, "project_id": project_id, @@ -1609,7 +1462,6 @@ class SQLiteDatabase(BaseDatabase): "project_emoji": project_emoji, } sessions_with_projects.append(session_dict) - return sessions_with_projects async def get_platform_sessions_by_creator_paginated( @@ -1623,32 +1475,26 @@ class SQLiteDatabase(BaseDatabase): """Get paginated Platform sessions for a creator with total count.""" async with self.get_db() as session: offset = (page - 1) * page_size - base_query = self._build_platform_sessions_query( creator=creator, platform_id=platform_id, exclude_project_sessions=exclude_project_sessions, ) - total_result = await session.execute( select(func.count()).select_from(base_query.subquery()) ) total = int(total_result.scalar_one() or 0) - result_query = ( base_query.order_by(desc(PlatformSession.updated_at)) .offset(offset) .limit(page_size) ) result = await session.execute(result_query) - sessions_with_projects = self._rows_to_session_dicts(result.all()) - return sessions_with_projects, total + return (sessions_with_projects, total) async def update_platform_session( - self, - session_id: str, - display_name: str | None = None, + self, session_id: str, display_name: str | None = None ) -> None: """Update a Platform session's updated_at timestamp and optionally display_name.""" async with self.get_db() as session: @@ -1656,11 +1502,10 @@ class SQLiteDatabase(BaseDatabase): values: dict[str, Any] = {"updated_at": datetime.now(timezone.utc)} if display_name is not None: values["display_name"] = display_name - await session.execute( update(PlatformSession) .where(col(PlatformSession.session_id) == session_id) - .values(**values), + .values(**values) ) async def delete_platform_session(self, session_id: str) -> None: @@ -1669,14 +1514,10 @@ class SQLiteDatabase(BaseDatabase): async with session.begin(): await session.execute( delete(PlatformSession).where( - col(PlatformSession.session_id) == session_id, - ), + col(PlatformSession.session_id) == session_id + ) ) - # ==== - # ChatUI Project Management - # ==== - async def create_chatui_project( self, creator: str, @@ -1688,10 +1529,7 @@ class SQLiteDatabase(BaseDatabase): async with self.get_db() as session: async with session.begin(): project = ChatUIProject( - creator=creator, - title=title, - emoji=emoji, - description=description, + creator=creator, title=title, emoji=emoji, description=description ) session.add(project) await session.flush() @@ -1702,17 +1540,12 @@ class SQLiteDatabase(BaseDatabase): """Get a ChatUI project by its ID.""" async with self.get_db() as session: result = await session.execute( - select(ChatUIProject).where( - col(ChatUIProject.project_id) == project_id, - ), + select(ChatUIProject).where(col(ChatUIProject.project_id) == project_id) ) return result.scalar_one_or_none() async def get_chatui_projects_by_creator( - self, - creator: str, - page: int = 1, - page_size: int = 100, + self, creator: str, page: int = 1, page_size: int = 100 ) -> list[ChatUIProject]: """Get all ChatUI projects for a specific creator.""" async with self.get_db() as session: @@ -1722,7 +1555,7 @@ class SQLiteDatabase(BaseDatabase): .where(col(ChatUIProject.creator) == creator) .order_by(desc(ChatUIProject.updated_at)) .limit(page_size) - .offset(offset), + .offset(offset) ) return list(result.scalars().all()) @@ -1743,48 +1576,40 @@ class SQLiteDatabase(BaseDatabase): values["emoji"] = emoji if description is not None: values["description"] = description - await session.execute( update(ChatUIProject) .where(col(ChatUIProject.project_id) == project_id) - .values(**values), + .values(**values) ) async def delete_chatui_project(self, project_id: str) -> None: """Delete a ChatUI project by its ID.""" async with self.get_db() as session: async with session.begin(): - # First remove all session relations await session.execute( delete(SessionProjectRelation).where( - col(SessionProjectRelation.project_id) == project_id, - ), + col(SessionProjectRelation.project_id) == project_id + ) ) - # Then delete the project await session.execute( delete(ChatUIProject).where( - col(ChatUIProject.project_id) == project_id, - ), + col(ChatUIProject.project_id) == project_id + ) ) async def add_session_to_project( - self, - session_id: str, - project_id: str, + self, session_id: str, project_id: str ) -> SessionProjectRelation: """Add a session to a project.""" async with self.get_db() as session: async with session.begin(): - # First remove existing relation if any await session.execute( delete(SessionProjectRelation).where( - col(SessionProjectRelation.session_id) == session_id, - ), + col(SessionProjectRelation.session_id) == session_id + ) ) - # Then create new relation relation = SessionProjectRelation( - session_id=session_id, - project_id=project_id, + session_id=session_id, project_id=project_id ) session.add(relation) await session.flush() @@ -1797,15 +1622,12 @@ class SQLiteDatabase(BaseDatabase): async with session.begin(): await session.execute( delete(SessionProjectRelation).where( - col(SessionProjectRelation.session_id) == session_id, - ), + col(SessionProjectRelation.session_id) == session_id + ) ) async def get_project_sessions( - self, - project_id: str, - page: int = 1, - page_size: int = 100, + self, project_id: str, page: int = 1, page_size: int = 100 ) -> list[PlatformSession]: """Get all sessions in a project.""" async with self.get_db() as session: @@ -1820,7 +1642,7 @@ class SQLiteDatabase(BaseDatabase): .where(col(SessionProjectRelation.project_id) == project_id) .order_by(desc(PlatformSession.updated_at)) .limit(page_size) - .offset(offset), + .offset(offset) ) return list(result.scalars().all()) @@ -1839,14 +1661,10 @@ class SQLiteDatabase(BaseDatabase): .where( col(SessionProjectRelation.session_id) == session_id, col(ChatUIProject.creator) == creator, - ), + ) ) return result.scalar_one_or_none() - # ==== - # Cron Job Management - # ==== - async def create_cron_job( self, name: str, @@ -1920,7 +1738,6 @@ class SQLiteDatabase(BaseDatabase): if val is CRON_FIELD_NOT_SET: continue updates[key] = val - stmt = ( update(CronJob) .where(col(CronJob.job_id) == job_id) diff --git a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py index 32d9d34d0..9d650140d 100644 --- a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py @@ -3,7 +3,7 @@ try: except ModuleNotFoundError: raise ImportError( "faiss 未安装。请使用 'pip install faiss-cpu' 或 'pip install faiss-gpu' 安装。", - ) + ) from None import os import numpy as np diff --git a/astrbot/core/file_token_service.py b/astrbot/core/file_token_service.py index eca4d65d4..b7e96f8e5 100644 --- a/astrbot/core/file_token_service.py +++ b/astrbot/core/file_token_service.py @@ -12,7 +12,9 @@ class FileTokenService: def __init__(self, default_timeout: float = 300) -> None: self.lock = asyncio.Lock() - self.staged_files = {} # token: (file_path, expire_time) + self.staged_files: dict[ + str, tuple[str, float] + ] = {} # token: (file_path, expire_time) self.default_timeout = default_timeout async def _cleanup_expired_tokens(self) -> None: diff --git a/astrbot/core/initial_loader.py b/astrbot/core/initial_loader.py index 56c39c733..208be907e 100644 --- a/astrbot/core/initial_loader.py +++ b/astrbot/core/initial_loader.py @@ -7,7 +7,6 @@ import asyncio import traceback -from typing import cast from astrbot.core import LogBroker, logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle diff --git a/astrbot/core/knowledge_base/chunking/recursive.py b/astrbot/core/knowledge_base/chunking/recursive.py index 542af8063..0d7f9acbd 100644 --- a/astrbot/core/knowledge_base/chunking/recursive.py +++ b/astrbot/core/knowledge_base/chunking/recursive.py @@ -75,7 +75,7 @@ class RecursiveCharacterChunker(BaseChunker): # 递归合并分割后的文本块 final_chunks = [] - current_chunk = [] + current_chunk: list[str] = [] current_chunk_length = 0 for split in splits: diff --git a/astrbot/core/knowledge_base/kb_db_sqlite.py b/astrbot/core/knowledge_base/kb_db_sqlite.py index 8dd3bcd52..21d580b43 100644 --- a/astrbot/core/knowledge_base/kb_db_sqlite.py +++ b/astrbot/core/knowledge_base/kb_db_sqlite.py @@ -83,7 +83,6 @@ class KBSQLiteDatabase: 创建所有必要的索引以优化查询性能 """ async with self.get_db() as session: - session: AsyncSession async with session.begin(): # 创建知识库表索引 await session.execute( diff --git a/astrbot/core/knowledge_base/parsers/url_parser.py b/astrbot/core/knowledge_base/parsers/url_parser.py index 84b965215..c0526ea76 100644 --- a/astrbot/core/knowledge_base/parsers/url_parser.py +++ b/astrbot/core/knowledge_base/parsers/url_parser.py @@ -1,6 +1,7 @@ import asyncio import aiohttp +from aiohttp import ClientTimeout class URLExtractor: @@ -64,7 +65,9 @@ class URLExtractor: api_url, json=payload, headers=headers, - timeout=30.0, # 增加超时时间,因为内容提取可能需要更长时间 + timeout=ClientTimeout( + total=30 + ), # 增加超时时间,因为内容提取可能需要更长时间 ) as response: if response.status != 200: reason = await response.text() diff --git a/astrbot/core/log.py b/astrbot/core/log.py index c208a8128..3999da930 100644 --- a/astrbot/core/log.py +++ b/astrbot/core/log.py @@ -7,7 +7,7 @@ import sys import time from asyncio import Queue from collections import deque -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from loguru import logger as _raw_loguru_logger @@ -153,11 +153,11 @@ class LogBroker: """日志代理类,用于缓存和分发日志消息。""" def __init__(self) -> None: - self.log_cache = deque(maxlen=CACHED_SIZE) + self.log_cache: deque[dict[str, Any]] = deque(maxlen=CACHED_SIZE) self.subscribers: list[Queue] = [] def register(self) -> Queue: - q = Queue(maxsize=CACHED_SIZE + 10) + q: Queue[dict[str, Any]] = Queue(maxsize=CACHED_SIZE + 10) self.subscribers.append(q) return q diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 125db5d48..fe689249b 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -28,6 +28,7 @@ import os import sys import uuid from enum import Enum +from typing import Any import anyio @@ -666,7 +667,7 @@ class Nodes(BaseMessageComponent): async def to_dict(self) -> dict: """将 Nodes 转换为字典格式,适用于 OneBot JSON 格式""" - ret = {"messages": []} + ret: dict[str, list[dict[str, Any]]] = {"messages": []} for node in self.nodes: d = await node.to_dict() ret["messages"].append(d) diff --git a/astrbot/core/persona_mgr.py b/astrbot/core/persona_mgr.py index 07686968c..b28e5d1f7 100644 --- a/astrbot/core/persona_mgr.py +++ b/astrbot/core/persona_mgr.py @@ -1,5 +1,3 @@ -from typing import Any, cast - from astrbot import logger from astrbot.api import sp from astrbot.core.astrbot_config_mgr import AstrBotConfigManager @@ -29,7 +27,6 @@ class PersonaManager: self.default_persona: str = default_ps.get("default_personality", "default") self.personas: list[Persona] = [] self.selected_default_persona: Persona | None = None - self.personas_v3: list[Personality] = [] self.selected_default_persona_v3: Personality | None = None self.persona_v3_config: list[dict] = [] @@ -63,14 +60,12 @@ class PersonaManager: ) async def get_default_persona_v3( - self, - umo: str | MessageSession | None = None, + self, umo: str | MessageSession | None = None ) -> Personality: """获取默认 persona""" cfg = self.acm.get_conf(umo) default_persona_id = cfg.get("provider_settings", {}).get( - "default_personality", - "default", + "default_personality", "default" ) return self.get_persona_v3_by_id(default_persona_id) or DEFAULT_PERSONALITY @@ -93,34 +88,25 @@ class PersonaManager: """ session_service_config = ( await sp.get_async( - scope="umo", - scope_id=str(umo), - key="session_service_config", - default={}, + scope="umo", scope_id=str(umo), key="session_service_config", default={} ) or {} ) - force_applied_persona_id = session_service_config.get("persona_id") persona_id = force_applied_persona_id - if not persona_id: persona_id = conversation_persona_id if persona_id == "[%None]": pass elif persona_id is None: persona_id = (provider_settings or {}).get("default_personality") - persona = next( - (item for item in self.personas_v3 if item["name"] == persona_id), - None, + (item for item in self.personas_v3 if item["name"] == persona_id), None ) - use_webchat_special_default = False - if not persona and platform_name == "webchat" and persona_id != "[%None]": + if not persona and platform_name == "webchat" and (persona_id != "[%None]"): persona_id = "_chatui_default_" use_webchat_special_default = True - return ( persona_id, persona, @@ -156,12 +142,8 @@ class PersonaManager: update_kwargs["skills"] = skills if custom_error_message is not NOT_GIVEN: update_kwargs["custom_error_message"] = custom_error_message - persona = await self.db.update_persona( - persona_id, - system_prompt, - begin_dialogs, - **update_kwargs, + persona_id, system_prompt, begin_dialogs, **update_kwargs ) if persona: for i, p in enumerate(self.personas): @@ -202,10 +184,6 @@ class PersonaManager: break return persona - # ==== - # Persona Folder Management - # ==== - async def create_folder( self, name: str, @@ -271,7 +249,6 @@ class PersonaManager: - sort_order: 新的排序顺序值 """ await self.db.batch_update_sort_order(items) - # 刷新缓存 self.personas = await self.get_all_personas() self.get_v3_persona_data() @@ -283,8 +260,6 @@ class PersonaManager: """ all_folders = await self.get_all_folders() folder_map: dict[str, dict] = {} - - # 创建文件夹字典 for folder in all_folders: folder_map[folder.folder_id] = { "folder_id": folder.folder_id, @@ -294,8 +269,6 @@ class PersonaManager: "sort_order": folder.sort_order, "children": [], } - - # 构建树形结构 root_folders = [] for _folder_id, folder_data in folder_map.items(): parent_id = folder_data["parent_id"] @@ -304,7 +277,6 @@ class PersonaManager: elif parent_id in folder_map: folder_map[parent_id]["children"].append(folder_data) - # 递归排序 def sort_folders(folders: list[dict]) -> list[dict]: folders.sort(key=lambda f: (f["sort_order"], f["name"])) for folder in folders: @@ -353,9 +325,7 @@ class PersonaManager: return new_persona async def clone_persona( - self, - source_persona_id: str, - new_persona_id: str, + self, source_persona_id: str, new_persona_id: str ) -> Persona: """Clone an existing persona with a new ID. @@ -369,10 +339,8 @@ class PersonaManager: source_persona = await self.db.get_persona_by_id(source_persona_id) if not source_persona: raise ValueError(f"Persona with ID {source_persona_id} does not exist.") - if await self.db.get_persona_by_id(new_persona_id): raise ValueError(f"Persona with ID {new_persona_id} already exists.") - new_persona = await self.db.insert_persona( new_persona_id, source_persona.system_prompt, @@ -387,9 +355,7 @@ class PersonaManager: self.get_v3_persona_data() return new_persona - def get_v3_persona_data( - self, - ) -> tuple[list[dict], list[Personality], Personality]: + def get_v3_persona_data(self) -> tuple[list[dict], list[Personality], Personality]: """获取 AstrBot <4.0.0 版本的 persona 数据。 Returns: @@ -403,24 +369,22 @@ class PersonaManager: "prompt": persona.system_prompt, "name": persona.persona_id, "begin_dialogs": persona.begin_dialogs or [], - "mood_imitation_dialogs": [], # deprecated + "mood_imitation_dialogs": [], "tools": persona.tools, "skills": persona.skills, "custom_error_message": persona.custom_error_message, } for persona in self.personas ] - personas_v3: list[Personality] = [] selected_default_persona: Personality | None = None - for persona_cfg in v3_persona_config: begin_dialogs = persona_cfg.get("begin_dialogs", []) bd_processed = [] if begin_dialogs: if len(begin_dialogs) % 2 != 0: logger.error( - f"{persona_cfg['name']} 人格情景预设对话格式不对,条数应该为偶数。", + f"{persona_cfg['name']} 人格情景预设对话格式不对,条数应该为偶数。" ) begin_dialogs = [] user_turn = True @@ -429,34 +393,26 @@ class PersonaManager: { "role": "user" if user_turn else "assistant", "content": dialog, - "_no_save": True, # 不持久化到 db - }, + "_no_save": True, + } ) user_turn = not user_turn - try: - persona = cast( - Personality, - { - **persona_cfg, - "_begin_dialogs_processed": bd_processed, - "_mood_imitation_dialogs_processed": "", # deprecated - }, - ) + persona = { + **persona_cfg, + "_begin_dialogs_processed": bd_processed, + "_mood_imitation_dialogs_processed": "", + } if persona["name"] == self.default_persona: selected_default_persona = persona personas_v3.append(persona) except Exception as e: logger.error(f"解析 Persona 配置失败:{e}") - if not selected_default_persona and len(personas_v3) > 0: - # 默认选择第一个 selected_default_persona = personas_v3[0] - if not selected_default_persona: selected_default_persona = DEFAULT_PERSONALITY personas_v3.append(selected_default_persona) - self.personas_v3 = personas_v3 self.selected_default_persona_v3 = selected_default_persona self.persona_v3_config = v3_persona_config @@ -468,5 +424,4 @@ class PersonaManager: skills=selected_default_persona["skills"] or None, custom_error_message=selected_default_persona["custom_error_message"], ) - - return v3_persona_config, personas_v3, selected_default_persona + return (v3_persona_config, personas_v3, selected_default_persona) diff --git a/astrbot/core/pipeline/content_safety_check/strategies/baidu_aip.py b/astrbot/core/pipeline/content_safety_check/strategies/baidu_aip.py index 20ecc5b37..21099d16d 100644 --- a/astrbot/core/pipeline/content_safety_check/strategies/baidu_aip.py +++ b/astrbot/core/pipeline/content_safety_check/strategies/baidu_aip.py @@ -1,6 +1,6 @@ """使用此功能应该先 pip install baidu-aip""" -from typing import Any, TypedDict, TypeGuard, cast +from typing import TypedDict, TypeGuard from . import ContentSafetyStrategy @@ -15,9 +15,9 @@ def _is_violation_list(value: object) -> TypeGuard[list[BaiduAipViolation]]: for item in value: if not isinstance(item, dict): return False - raw = cast(dict[str, Any], item) + raw = item message = raw.get("msg") - if message is not None and not isinstance(message, str): + if message is not None and (not isinstance(message, str)): return False return True @@ -35,22 +35,20 @@ class BaiduAipStrategy(ContentSafetyStrategy): res = self.client.textCensorUserDefined(content) conclusion_type = res.get("conclusionType") if not isinstance(conclusion_type, int): - return False, "" + return (False, "") if conclusion_type == 1: - return True, "" - + return (True, "") data = res.get("data") conclusion = res.get("conclusion") if not _is_violation_list(data) or not isinstance(conclusion, str): - return False, "" - + return (False, "") count = len(data) parts = [f"百度审核服务发现 {count} 处违规:\n"] for item in data: - raw_item = cast(dict[str, Any], item) + raw_item = item message = raw_item.get("msg") if message: parts.append(f"{message};\n") parts.append("\n判断结果:" + conclusion) info = "".join(parts) - return False, info + return (False, info) diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index 6c0731871..00adcf780 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -34,10 +34,7 @@ from astrbot.core.pipeline.process_stage.follow_up import ( ) from astrbot.core.pipeline.stage import Stage from astrbot.core.platform.astr_message_event import AstrMessageEvent -from astrbot.core.provider.entities import ( - LLMResponse, - ProviderRequest, -) +from astrbot.core.provider.entities import LLMResponse, ProviderRequest from astrbot.core.star.star_handler import EventType from astrbot.core.tool_provider import ToolProvider from astrbot.core.utils.astrbot_path import get_astrbot_root, get_astrbot_skills_path @@ -66,25 +63,21 @@ class InternalAgentSubStage(Stage): self.tool_schema_mode, ) self.tool_schema_mode = "full" - if isinstance(self.max_step, bool): # workaround: #2622 + if isinstance(self.max_step, bool): self.max_step = 30 self.show_tool_use: bool = settings.get("show_tool_use_status", True) self.show_tool_call_result: bool = settings.get("show_tool_call_result", False) self.show_reasoning = settings.get("display_reasoning_text", False) self.sanitize_context_by_modalities: bool = settings.get( - "sanitize_context_by_modalities", - False, + "sanitize_context_by_modalities", False ) self.kb_agentic_mode: bool = conf.get("kb_agentic_mode", False) - file_extract_conf: dict = settings.get("file_extract", {}) self.file_extract_enabled: bool = file_extract_conf.get("enable", False) self.file_extract_prov: str = file_extract_conf.get("provider", "moonshotai") self.file_extract_msh_api_key: str = file_extract_conf.get( "moonshotai_api_key", "" ) - - # 上下文管理相关 self.context_limit_reached_strategy: str = settings.get( "context_limit_reached_strategy", "truncate_by_turns" ) @@ -95,36 +88,27 @@ class InternalAgentSubStage(Stage): self.llm_compress_provider_id: str = settings.get( "llm_compress_provider_id", "" ) - self.max_context_length = settings["max_context_length"] # int + self.max_context_length = settings["max_context_length"] self.dequeue_context_length: int = min( - max(1, settings["dequeue_context_length"]), - self.max_context_length - 1, + max(1, settings["dequeue_context_length"]), self.max_context_length - 1 ) if self.dequeue_context_length <= 0: self.dequeue_context_length = 1 - self.llm_safety_mode = settings.get("llm_safety_mode", True) self.safety_mode_strategy = settings.get( "safety_mode_strategy", "system_prompt" ) - self.computer_use_runtime = settings.get("computer_use_runtime") self.sandbox_cfg = settings.get("sandbox", {}) - - # Proactive capability configuration proactive_cfg = settings.get("proactive_capability", {}) self.add_cron_tools = proactive_cfg.get("add_cron_tools", True) - self.conv_manager = ctx.plugin_manager.context.conversation_manager - - # Build decoupled tool providers from astrbot.core.computer.computer_tool_provider import ComputerToolProvider from astrbot.core.cron.cron_tool_provider import CronToolProvider _tool_providers: list[ToolProvider] = [ComputerToolProvider()] if self.add_cron_tools: _tool_providers.append(CronToolProvider()) - self.main_agent_cfg = MainAgentBuildConfig( tool_call_timeout=self.tool_call_timeout, tool_schema_mode=self.tool_schema_mode, @@ -151,10 +135,7 @@ class InternalAgentSubStage(Stage): max_quoted_fallback_images=settings.get("max_quoted_fallback_images", 20), ) - async def process( - self, - event: AstrMessageEvent, - ) -> AsyncGenerator[None, None]: + async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None, None]: follow_up_capture: FollowUpCapture | None = None follow_up_consumed_marked = False follow_up_activated = False @@ -163,21 +144,18 @@ class InternalAgentSubStage(Stage): streaming_response = self.streaming_response if (enable_streaming := event.get_extra("enable_streaming")) is not None: streaming_response = bool(enable_streaming) - has_provider_request = event.get_extra("provider_request") is not None has_valid_message = bool(event.message_str and event.message_str.strip()) has_media_content = any( isinstance(comp, Image | File) for comp in event.message_obj.message ) - if ( not has_provider_request - and not has_valid_message - and not has_media_content + and (not has_valid_message) + and (not has_media_content) ): logger.debug("skip llm request: empty message and no provider_request") return - logger.debug("ready to request llm provider") follow_up_capture = try_capture_follow_up(event) if follow_up_capture: @@ -192,7 +170,6 @@ class InternalAgentSubStage(Stage): follow_up_capture.ticket.seq, ) return - try: typing_requested = True await event.send_typing() @@ -205,15 +182,10 @@ class InternalAgentSubStage(Stage): if sdk_plugin_bridge is not None: try: await sdk_plugin_bridge.dispatch_message_event( - "waiting_llm_request", - event, + "waiting_llm_request", event ) except Exception as exc: - logger.warning( - "SDK waiting_llm_request dispatch failed: %s", - exc, - ) - + logger.warning("SDK waiting_llm_request dispatch failed: %s", exc) async with session_lock_manager.acquire_lock(event.unified_msg_origin): logger.debug("acquired session lock for llm request") agent_runner: AgentRunner | None = None @@ -224,22 +196,18 @@ class InternalAgentSubStage(Stage): provider_wake_prefix=self.provider_wake_prefix, streaming_response=streaming_response, ) - build_result: MainAgentBuildResult | None = await build_main_agent( event=event, plugin_context=self.ctx.plugin_manager.context, config=build_cfg, apply_reset=False, ) - if build_result is None: return - agent_runner = build_result.agent_runner req = build_result.provider_request provider = build_result.provider reset_coro = build_result.reset_coro - api_base = provider.provider_config.get("api_base", "") for host in decoded_blocked: if host in api_base: @@ -248,12 +216,10 @@ class InternalAgentSubStage(Stage): api_base, ) return - stream_to_general = ( self.unsupported_streaming_strategy == "turn_off" - and not event.platform_meta.support_streaming_message + and (not event.platform_meta.support_streaming_message) ) - if await call_event_hook(event, EventType.OnLLMRequestEvent, req): if reset_coro: reset_coro.close() @@ -271,17 +237,12 @@ class InternalAgentSubStage(Stage): ) except Exception as exc: logger.warning("SDK llm_request dispatch failed: %s", exc) - - # apply reset if reset_coro: await reset_coro - effective_streaming_response = bool(agent_runner.streaming) - register_active_runner(event.unified_msg_origin, agent_runner) runner_registered = True action_type = event.get_extra("action_type") - event.trace.record( "astr_agent_prepare", system_prompt=req.system_prompt, @@ -292,25 +253,17 @@ class InternalAgentSubStage(Stage): "model": provider.get_model(), }, ) - - # 检测 Live Mode if action_type == "live": - # Live Mode: 使用 run_live_agent logger.info("[Internal Agent] 检测到 Live Mode,启用 TTS 处理") - - # 获取 TTS Provider tts_provider = ( self.ctx.plugin_manager.context.get_using_tts_provider( event.unified_msg_origin ) ) - if not tts_provider: logger.warning( "[Live Mode] TTS Provider 未配置,将使用普通流式模式" ) - - # 使用 run_live_agent,总是使用流式响应 event.set_result( MessageEventResult() .set_result_content_type(ResultContentType.STREAMING_RESULT) @@ -322,12 +275,10 @@ class InternalAgentSubStage(Stage): self.show_tool_use, self.show_tool_call_result, show_reasoning=self.show_reasoning, - ), - ), + ) + ) ) yield None - - # 保存历史记录 if agent_runner.done() and ( not event.is_stopped() or agent_runner.was_aborted() ): @@ -339,9 +290,7 @@ class InternalAgentSubStage(Stage): agent_runner.stats, user_aborted=agent_runner.was_aborted(), ) - - elif effective_streaming_response and not stream_to_general: - # 流式响应 + elif effective_streaming_response and (not stream_to_general): event.set_result( MessageEventResult() .set_result_content_type(ResultContentType.STREAMING_RESULT) @@ -352,8 +301,8 @@ class InternalAgentSubStage(Stage): self.show_tool_use, self.show_tool_call_result, show_reasoning=self.show_reasoning, - ), - ), + ) + ) ) yield None if agent_runner.done(): @@ -372,7 +321,7 @@ class InternalAgentSubStage(Stage): MessageEventResult( chain=chain, result_content_type=ResultContentType.STREAMING_FINISH, - ), + ) ) else: async for _ in run_agent( @@ -384,25 +333,17 @@ class InternalAgentSubStage(Stage): show_reasoning=self.show_reasoning, ): yield None - final_resp = agent_runner.get_final_llm_resp() - event.trace.record( "astr_agent_complete", stats=agent_runner.stats.to_dict(), resp=final_resp.completion_text if final_resp else None, ) - asyncio.create_task( _record_internal_agent_stats( - event, - req, - agent_runner, - final_resp, + event, req, agent_runner, final_resp ) ) - - # 检查事件是否被停止,如果被停止则不保存历史记录 if not event.is_stopped() or agent_runner.was_aborted(): await self._save_to_history( event, @@ -412,18 +353,16 @@ class InternalAgentSubStage(Stage): agent_runner.stats, user_aborted=agent_runner.was_aborted(), ) - - asyncio.create_task( # noqa: RUF006 + asyncio.create_task( Metric.upload( llm_tick=1, model_name=agent_runner.provider.get_model(), provider_type=agent_runner.provider.meta().type, - ), + ) ) finally: if runner_registered and agent_runner is not None: unregister_active_runner(event.unified_msg_origin, agent_runner) - except Exception as e: logger.exception( "Error occurred while processing agent. root=%s skills=%s", @@ -433,8 +372,9 @@ class InternalAgentSubStage(Stage): custom_error_message = extract_persona_custom_error_message_from_event( event ) - error_text = custom_error_message or ( - f"Error occurred while processing agent request: {e}" + error_text = ( + custom_error_message + or f"Error occurred while processing agent request: {e}" ) await event.send(MessageChain().message(error_text)) finally: @@ -461,51 +401,35 @@ class InternalAgentSubStage(Stage): ) -> None: if not req or not req.conversation: return - - if not llm_response and not user_aborted: + if not llm_response and (not user_aborted): return - if llm_response and llm_response.role != "assistant": if not user_aborted: return llm_response = LLMResponse( - role="assistant", - completion_text=llm_response.completion_text or "", + role="assistant", completion_text=llm_response.completion_text or "" ) elif llm_response is None: llm_response = LLMResponse(role="assistant", completion_text="") - if ( not llm_response.completion_text - and not req.tool_calls_result - and not user_aborted + and (not req.tool_calls_result) + and (not user_aborted) ): logger.debug("LLM 响应为空,不保存记录。") return - message_to_save = [] skipped_initial_system = False for message in all_messages: - if message.role == "system" and not skipped_initial_system: + if message.role == "system" and (not skipped_initial_system): skipped_initial_system = True continue if message.role in ["assistant", "user"] and message._no_save: continue message_to_save.append(message.model_dump()) - - # if user_aborted: - # message_to_save.append( - # Message( - # role="assistant", - # content="[User aborted this request. Partial output before abort was preserved.]", - # ).model_dump() - # ) - token_usage = None if runner_stats: - # token_usage = runner_stats.token_usage.total token_usage = llm_response.usage.total if llm_response.usage else None - await self.conv_manager.update_conversation( event.unified_msg_origin, req.conversation.cid, @@ -523,13 +447,11 @@ async def _record_internal_agent_stats( from astrbot.core import db_helper status = "aborted" if agent_runner.was_aborted() else "completed" - if llm_response is None and not agent_runner.was_aborted(): + if llm_response is None and (not agent_runner.was_aborted()): status = "error" - provider_id = str(agent_runner.provider.provider_config.get("id", "") or "unknown") provider_model = agent_runner.provider.get_model() or None conversation_id = req.conversation.cid if req.conversation else None - try: await db_helper.insert_provider_stat( agent_type="internal", @@ -544,7 +466,5 @@ async def _record_internal_agent_stats( logger.warning("record internal agent stats failed", exc_info=True) -# we prevent astrbot from connecting to known malicious hosts -# these hosts are base64 encoded BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"} decoded_blocked = [base64.b64decode(b).decode("utf-8") for b in BLOCKED] diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index a3108dcd0..9546b6cd5 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -84,9 +84,9 @@ class ResultDecorateStage(Stage): ].get("split_words", ["。", "?", "!", "~", "…"]) self.split_words_pattern: re.Pattern[str] | None if self.split_words: - escaped_words = sorted( - [re.escape(word) for word in self.split_words], key=len, reverse=True - ) + escaped_words_list = [re.escape(word) for word in self.split_words] + escaped_words_list.sort(key=len, reverse=True) + escaped_words = escaped_words_list self.split_words_pattern = re.compile( f"(.*?({'|'.join(escaped_words)})|.+$)", re.DOTALL ) diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index 4edc40517..ae63370f6 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -5,21 +5,14 @@ import logging import time import uuid from collections.abc import Awaitable, Coroutine -from typing import Any, cast +from typing import Any from aiocqhttp import CQHttp, Event from aiocqhttp.exceptions import ActionFailed from astrbot.api import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import ( - At, - ComponentTypes, - File, - Plain, - Poke, - Reply, -) +from astrbot.api.message_components import At, ComponentTypes, File, Plain, Poke, Reply from astrbot.api.platform import ( AstrBotMessage, Group, @@ -41,31 +34,23 @@ from .aiocqhttp_message_event import AiocqhttpMessageEvent ) class AiocqhttpAdapter(Platform): def __init__( - self, - platform_config: dict, - platform_settings: dict, - event_queue: asyncio.Queue, + self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue ) -> None: super().__init__(platform_config, event_queue) - self.settings = platform_settings self.host = platform_config["ws_reverse_host"] self.port = platform_config["ws_reverse_port"] - self.metadata = PlatformMetadata( name="aiocqhttp", description="适用于 OneBot 标准的消息平台适配器,支持反向 WebSockets。", - id=cast(str, self.config.get("id")), + id=self.config.get("id"), support_streaming_message=False, ) - self.bot = CQHttp( use_ws_reverse=True, import_name="aiocqhttp", api_timeout_sec=180, - access_token=platform_config.get( - "ws_reverse_token", - ), # 以防旧版本配置不存在 + access_token=platform_config.get("ws_reverse_token"), ) @self.bot.on_request() @@ -114,9 +99,7 @@ class AiocqhttpAdapter(Platform): logger.info("aiocqhttp(OneBot v11) 适配器已连接。") async def send_by_session( - self, - session: MessageSesion, - message_chain: MessageChain, + self, session: MessageSesion, message_chain: MessageChain ) -> None: is_group = session.message_type == MessageType.GROUP_MESSAGE if is_group: @@ -126,7 +109,7 @@ class AiocqhttpAdapter(Platform): await AiocqhttpMessageEvent.send_message( bot=self.bot, message_chain=message_chain, - event=None, # 这里不需要 event,因为是通过 session 发送的 + event=None, is_group=is_group, session_id=session_id, ) @@ -134,17 +117,14 @@ class AiocqhttpAdapter(Platform): async def convert_message(self, event: Event) -> AstrBotMessage | None: logger.debug(f"[aiocqhttp] RawMessage {event}") - if event["post_type"] == "message": abm = await self._convert_handle_message_event(event) if abm.sender.user_id == "2854196310": - # 屏蔽 QQ 管家的消息 return None elif event["post_type"] == "notice": abm = await self._convert_handle_notice_event(event) elif event["post_type"] == "request": abm = await self._convert_handle_request_event(event) - return abm async def _convert_handle_request_event(self, event: Event) -> AstrBotMessage: @@ -195,17 +175,13 @@ class AiocqhttpAdapter(Platform): abm.raw_message = event abm.timestamp = int(time.time()) abm.message_id = uuid.uuid4().hex - if "sub_type" in event: if event["sub_type"] == "poke" and "target_id" in event: abm.message.append(Poke(id=str(event["target_id"]))) - return abm async def _convert_handle_message_event( - self, - event: Event, - get_reply=True, + self, event: Event, get_reply=True ) -> AstrBotMessage: """OneBot V11 消息类事件 @@ -231,10 +207,8 @@ class AiocqhttpAdapter(Platform): if abm.type == MessageType.GROUP_MESSAGE else abm.sender.user_id ) - abm.message_id = str(event.message_id) abm.message = [] - message_str = "" if not isinstance(event.message, list): err = f"aiocqhttp: 无法识别的消息类型: {event.message!s},此条消息将被忽略。如果您在使用 go-cqhttp,请将其配置文件中的 message.post-format 更改为 array。" @@ -244,25 +218,19 @@ class AiocqhttpAdapter(Platform): except BaseException as e: logger.error(f"回复消息失败: {e}") raise ValueError(err) - - # 按消息段类型类型适配 for t, m_group in itertools.groupby(event.message, key=lambda x: x["type"]): a = None if t == "text": current_text = "".join(m["data"]["text"] for m in m_group).strip() if not current_text: - # 如果文本段为空,则跳过 continue message_str += current_text a = Plain(text=current_text) abm.message.append(a) - elif t == "file": for m in m_group: if m["data"].get("url") and m["data"].get("url").startswith("http"): - # Lagrange logger.info("guessing lagrange") - # 检查多个可能的文件名字段 file_name = ( m["data"].get("file_name", "") or m["data"].get("name", "") @@ -272,7 +240,6 @@ class AiocqhttpAdapter(Platform): abm.message.append(File(name=file_name, url=m["data"]["url"])) else: try: - # Napcat ret = None if abm.type == MessageType.GROUP_MESSAGE: ret = await self.bot.call_action( @@ -286,8 +253,7 @@ class AiocqhttpAdapter(Platform): file_id=event.message[0]["data"]["file_id"], ) if ret and "url" in ret: - file_url = ret["url"] # https - # 优先从 API 返回值获取文件名,其次从原始消息数据获取 + file_url = ret["url"] file_name = ( ret.get("file_name", "") or ret.get("name", "") @@ -298,12 +264,10 @@ class AiocqhttpAdapter(Platform): abm.message.append(a) else: logger.error(f"获取文件失败: {ret}") - except ActionFailed as e: logger.error(f"获取文件失败: {e},此消息段将被忽略。") except BaseException as e: logger.error(f"获取文件失败: {e},此消息段将被忽略。") - elif t == "reply": for m in m_group: if not get_reply: @@ -312,22 +276,18 @@ class AiocqhttpAdapter(Platform): else: try: reply_event_data = await self.bot.call_action( - action="get_msg", - message_id=int(m["data"]["id"]), + action="get_msg", message_id=int(m["data"]["id"]) ) - # 添加必要的 post_type 字段,防止 Event.from_payload 报错 reply_event_data["post_type"] = "message" new_event = Event.from_payload(reply_event_data) if not new_event: logger.error( - f"无法从回复消息数据构造 Event 对象: {reply_event_data}", + f"无法从回复消息数据构造 Event 对象: {reply_event_data}" ) continue abm_reply = await self._convert_handle_message_event( - new_event, - get_reply=False, + new_event, get_reply=False ) - reply_seg = Reply( id=abm_reply.message_id, chain=abm_reply.message, @@ -335,10 +295,9 @@ class AiocqhttpAdapter(Platform): sender_nickname=abm_reply.sender.nickname, time=abm_reply.timestamp, message_str=abm_reply.message_str, - text=abm_reply.message_str, # for compatibility - qq=abm_reply.sender.user_id, # for compatibility + text=abm_reply.message_str, + qq=abm_reply.sender.user_id, ) - abm.message.append(reply_seg) except BaseException as e: logger.error(f"获取引用消息失败: {e}。") @@ -346,15 +305,12 @@ class AiocqhttpAdapter(Platform): abm.message.append(a) elif t == "at": first_at_self_processed = False - # Accumulate @ mention text for efficient concatenation at_parts = [] - for m in m_group: try: if m["data"]["qq"] == "all": abm.message.append(At(qq="all", name="全体成员")) continue - at_info = await self.bot.call_action( action="get_group_member_info", group_id=event.group_id, @@ -370,23 +326,13 @@ class AiocqhttpAdapter(Platform): no_cache=False, ) nickname = at_info.get("nick", "") or at_info.get( - "nickname", - "", + "nickname", "" ) is_at_self = str(m["data"]["qq"]) in {abm.self_id, "all"} - - abm.message.append( - At( - qq=m["data"]["qq"], - name=nickname, - ), - ) - - if is_at_self and not first_at_self_processed: - # 第一个@是机器人,不添加到message_str + abm.message.append(At(qq=m["data"]["qq"], name=nickname)) + if is_at_self and (not first_at_self_processed): first_at_self_processed = True else: - # 非第一个@机器人或@其他用户,添加到message_str at_parts.append(f" @{nickname}({m['data']['qq']}) ") else: abm.message.append(At(qq=str(m["data"]["qq"]), name="")) @@ -394,7 +340,6 @@ class AiocqhttpAdapter(Platform): logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。") except BaseException as e: logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。") - message_str += "".join(at_parts) elif t == "markdown": for m in m_group: @@ -416,27 +361,23 @@ class AiocqhttpAdapter(Platform): f"消息段解析失败: type={t}, data={m['data']}. {e}" ) continue - abm.timestamp = int(time.time()) abm.message_str = message_str abm.raw_message = event - return abm 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", + "aiocqhttp: 未配置 ws_reverse_host 或 ws_reverse_port,将使用默认值:http://0.0.0.0:6199" ) self.host = "0.0.0.0" self.port = 6199 - coro = self.bot.run_task( host=self.host, port=int(self.port), shutdown_trigger=self.shutdown_trigger_placeholder, ) - for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) logging.getLogger("aiocqhttp").setLevel(logging.ERROR) @@ -451,13 +392,11 @@ class AiocqhttpAdapter(Platform): async def _close_reverse_ws_connections(self) -> None: api_clients = getattr(self.bot, "_wsr_api_clients", None) event_clients = getattr(self.bot, "_wsr_event_clients", None) - ws_clients: set[Any] = set() if isinstance(api_clients, dict): ws_clients.update(api_clients.values()) if isinstance(event_clients, set): ws_clients.update(event_clients) - close_tasks: list[Awaitable[Any]] = [] for ws in ws_clients: close_func = getattr(ws, "close", None) @@ -469,13 +408,10 @@ class AiocqhttpAdapter(Platform): close_result = close_func() except Exception: continue - if inspect.isawaitable(close_result): close_tasks.append(close_result) - if close_tasks: await asyncio.gather(*close_tasks, return_exceptions=True) - if isinstance(api_clients, dict): api_clients.clear() if isinstance(event_clients, set): @@ -496,7 +432,6 @@ class AiocqhttpAdapter(Platform): session_id=message.session_id, bot=self.bot, ) - self.commit_event(message_event) def get_client(self) -> CQHttp: diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index 18a5f8f71..7991a5394 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -3,7 +3,7 @@ import json import threading import uuid from pathlib import Path -from typing import Literal, NoReturn, cast +from typing import Literal, NoReturn import aiohttp import anyio @@ -37,7 +37,7 @@ from .dingtalk_event import DingtalkMessageEvent class MyEventHandler(dingtalk_stream.EventHandler): async def process(self, event: dingtalk_stream.EventMessage): - return AckMessage.STATUS_OK, "OK" + return (AckMessage.STATUS_OK, "OK") @register_platform_adapter( @@ -45,16 +45,11 @@ class MyEventHandler(dingtalk_stream.EventHandler): ) class DingtalkPlatformAdapter(Platform): def __init__( - self, - platform_config: dict, - platform_settings: dict, - event_queue: asyncio.Queue, + self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue ) -> None: super().__init__(platform_config, event_queue) - self.client_id = platform_config["client_id"] self.client_secret = platform_config["client_secret"] - outer_self = self class AstrCallbackClient(dingtalk_stream.ChatbotHandler): @@ -63,19 +58,16 @@ class DingtalkPlatformAdapter(Platform): im = dingtalk_stream.ChatbotMessage.from_dict(message.data) abm = await outer_self.convert_msg(im) await outer_self.handle_msg(abm) - - return AckMessage.STATUS_OK, "OK" + return (AckMessage.STATUS_OK, "OK") self.client = AstrCallbackClient() - credential = dingtalk_stream.Credential(self.client_id, self.client_secret) client = dingtalk_stream.DingTalkStreamClient(credential, logger=logger) client.register_all_event_handler(MyEventHandler()) client.register_callback_handler( - dingtalk_stream.ChatbotMessage.TOPIC, - self.client, + dingtalk_stream.ChatbotMessage.TOPIC, self.client ) - self.client_ = client # 用于 websockets 的 client + self.client_ = client self._shutdown_event: threading.Event | None = None def _id_to_sid(self, dingtalk_id: str | None) -> str: @@ -87,12 +79,9 @@ class DingtalkPlatformAdapter(Platform): return dingtalk_id or "unknown" async def send_by_session( - self, - session: MessageSesion, - message_chain: MessageChain, + self, session: MessageSesion, message_chain: MessageChain ) -> None: robot_code = self.client_id - if session.message_type == MessageType.GROUP_MESSAGE: open_conversation_id = session.session_id await self.send_message_chain_to_group( @@ -104,64 +93,52 @@ class DingtalkPlatformAdapter(Platform): staff_id = await self._get_sender_staff_id(session) if not staff_id: logger.warning( - "钉钉私聊会话缺少 staff_id 映射,回退使用 session_id 作为 userId 发送", + "钉钉私聊会话缺少 staff_id 映射,回退使用 session_id 作为 userId 发送" ) staff_id = session.session_id await self.send_message_chain_to_user( - staff_id=staff_id, - robot_code=robot_code, - message_chain=message_chain, + staff_id=staff_id, robot_code=robot_code, message_chain=message_chain ) - await super().send_by_session(session, message_chain) async def send_with_session( - self, - session: MessageSesion, - message_chain: MessageChain, + self, session: MessageSesion, message_chain: MessageChain ) -> None: await self.send_by_session(session, message_chain) async def send_with_sesison( - self, - session: MessageSesion, - message_chain: MessageChain, + self, session: MessageSesion, message_chain: MessageChain ) -> None: - # backward typo compatibility await self.send_by_session(session, message_chain) def meta(self) -> PlatformMetadata: return PlatformMetadata( name="dingtalk", description="钉钉机器人官方 API 适配器", - id=cast(str, self.config.get("id")), + id=self.config.get("id"), support_streaming_message=True, support_proactive_message=True, ) async def convert_msg( - self, - message: dingtalk_stream.ChatbotMessage, + self, message: dingtalk_stream.ChatbotMessage ) -> AstrBotMessage: abm = AstrBotMessage() abm.message = [] abm.message_str = "" - abm.timestamp = int(cast(int, message.create_at) / 1000) + abm.timestamp = int(message.create_at / 1000) abm.type = ( MessageType.GROUP_MESSAGE if message.conversation_type == "2" else MessageType.FRIEND_MESSAGE ) abm.sender = MessageMember( - user_id=self._id_to_sid(message.sender_id), - nickname=message.sender_nick, + user_id=self._id_to_sid(message.sender_id), nickname=message.sender_nick ) abm.self_id = self._id_to_sid(message.chatbot_user_id) - abm.message_id = cast(str, message.message_id) + abm.message_id = message.message_id abm.raw_message = message - if abm.type == MessageType.GROUP_MESSAGE: - # 处理所有被 @ 的用户(包括机器人自己,因 at_users 已包含) if message.at_users: for user in message.at_users: if id := self._id_to_sid(user.dingtalk_id): @@ -170,10 +147,9 @@ class DingtalkPlatformAdapter(Platform): abm.session_id = abm.group_id else: abm.session_id = abm.sender.user_id - - message_type: str = cast(str, message.message_type) - robot_code = cast(str, message.robot_code or "") - raw_content = cast(dict, message.extensions.get("content") or {}) + message_type: str = message.message_type + robot_code = message.robot_code or "" + raw_content = message.extensions.get("content") or {} if not isinstance(raw_content, dict): raw_content = {} match message_type: @@ -185,39 +161,32 @@ class DingtalkPlatformAdapter(Platform): logger.error("钉钉图片消息解析失败: 回调中缺少 robotCode") await self._remember_sender_binding(message, abm) return abm - image_content = cast( - dingtalk_stream.ImageContent | None, - message.image_content, - ) - download_code = cast( - str, (image_content.download_code if image_content else "") or "" - ) + image_content = message.image_content + download_code = ( + image_content.download_code if image_content else "" + ) or "" if not download_code: logger.warning("钉钉图片消息缺少 downloadCode,已跳过") else: f_path = await self.download_ding_file( - download_code, - robot_code, - "jpg", + download_code, robot_code, "jpg" ) if f_path: abm.message.append(Image.fromFileSystem(f_path)) else: logger.warning("钉钉图片消息下载失败,无法解析为图片") case "richText": - rtc: dingtalk_stream.RichTextContent = cast( - dingtalk_stream.RichTextContent, message.rich_text_content - ) - contents: list[dict] = cast(list[dict], rtc.rich_text_list) + rtc: dingtalk_stream.RichTextContent = message.rich_text_content + contents: list[dict] = rtc.rich_text_list plain_parts: list[str] = [] for content in contents: if "text" in content: - plain_text = cast(str, content.get("text") or "") + plain_text = content.get("text") or "" if plain_text: plain_parts.append(plain_text) abm.message.append(Plain(plain_text)) elif "type" in content and content["type"] == "picture": - download_code = cast(str, content.get("downloadCode") or "") + download_code = content.get("downloadCode") or "" if not download_code: logger.warning("钉钉富文本图片消息缺少 downloadCode,已跳过") continue @@ -227,66 +196,57 @@ class DingtalkPlatformAdapter(Platform): ) continue f_path = await self.download_ding_file( - download_code, - robot_code, - "jpg", + download_code, robot_code, "jpg" ) if f_path: abm.message.append(Image.fromFileSystem(f_path)) abm.message_str = "".join(plain_parts).strip() case "audio" | "voice": - download_code = cast(str, raw_content.get("downloadCode") or "") + download_code = raw_content.get("downloadCode") or "" if not download_code: logger.warning("钉钉语音消息缺少 downloadCode,已跳过") elif not robot_code: logger.error("钉钉语音消息解析失败: 回调中缺少 robotCode") else: - voice_ext = cast(str, raw_content.get("fileExtension") or "") + voice_ext = raw_content.get("fileExtension") or "" if not voice_ext: voice_ext = "amr" voice_ext = voice_ext.lstrip(".") f_path = await self.download_ding_file( - download_code, - robot_code, - voice_ext, + download_code, robot_code, voice_ext ) if f_path: abm.message.append(Record.fromFileSystem(f_path)) case "file": - download_code = cast(str, raw_content.get("downloadCode") or "") + download_code = raw_content.get("downloadCode") or "" if not download_code: logger.warning("钉钉文件消息缺少 downloadCode,已跳过") elif not robot_code: logger.error("钉钉文件消息解析失败: 回调中缺少 robotCode") else: - file_name = cast(str, raw_content.get("fileName") or "") + file_name = raw_content.get("fileName") or "" file_ext = Path(file_name).suffix.lstrip(".") if file_name else "" if not file_ext: - file_ext = cast(str, raw_content.get("fileExtension") or "") + file_ext = raw_content.get("fileExtension") or "" if not file_ext: file_ext = "file" f_path = await self.download_ding_file( - download_code, - robot_code, - file_ext, + download_code, robot_code, file_ext ) if f_path: if not file_name: file_name = Path(f_path).name abm.message.append(File(name=file_name, file=f_path)) - await self._remember_sender_binding(message, abm) - return abm # 别忘了返回转换后的消息对象 + return abm async def _remember_sender_binding( - self, - message: dingtalk_stream.ChatbotMessage, - abm: AstrBotMessage, + self, message: dingtalk_stream.ChatbotMessage, abm: AstrBotMessage ) -> None: try: if abm.type == MessageType.FRIEND_MESSAGE: sender_id = abm.sender.user_id - sender_staff_id = cast(str, message.sender_staff_id or "") + sender_staff_id = message.sender_staff_id or "" if sender_staff_id: umo = str( MessageSesion( @@ -296,19 +256,13 @@ class DingtalkPlatformAdapter(Platform): ) ) await sp.put_async( - "global", - umo, - "dingtalk_staffid", - sender_staff_id, + "global", umo, "dingtalk_staffid", sender_staff_id ) except Exception as e: logger.warning(f"保存钉钉会话映射失败: {e}") async def download_ding_file( - self, - download_code: str, - robot_code: str, - ext: str, + self, download_code: str, robot_code: str, ext: str ) -> str: """下载钉钉文件 @@ -319,13 +273,8 @@ class DingtalkPlatformAdapter(Platform): :return: 文件路径 """ access_token = await self.get_access_token() - headers = { - "x-acs-dingtalk-access-token": access_token, - } - payload = { - "downloadCode": download_code, - "robotCode": robot_code, - } + headers = {"x-acs-dingtalk-access-token": access_token} + payload = {"downloadCode": download_code, "robotCode": robot_code} temp_dir = anyio.Path(get_astrbot_temp_path()) await temp_dir.mkdir(parents=True, exist_ok=True) f_path = temp_dir / f"dingtalk_{uuid.uuid4()}.{ext}" @@ -338,18 +287,13 @@ class DingtalkPlatformAdapter(Platform): ) as resp, ): if resp.status != 200: - logger.error( - f"下载钉钉文件失败: {resp.status}, {await resp.text()}", - ) + logger.error(f"下载钉钉文件失败: {resp.status}, {await resp.text()}") return "" resp_data = await resp.json() - download_url = cast( - str, - ( - resp_data.get("downloadUrl") - or resp_data.get("data", {}).get("downloadUrl") - or "" - ), + download_url = ( + resp_data.get("downloadUrl") + or resp_data.get("data", {}).get("downloadUrl") + or "" ) if not download_url: logger.error(f"下载钉钉文件失败: 未找到 downloadUrl, 响应: {resp_data}") @@ -360,53 +304,42 @@ class DingtalkPlatformAdapter(Platform): async def get_access_token(self) -> str: try: access_token = await asyncio.get_running_loop().run_in_executor( - None, - self.client_.get_access_token, + None, self.client_.get_access_token ) if access_token: return access_token except Exception as e: logger.warning(f"通过 dingtalk_stream 获取 access_token 失败: {e}") - payload = {"appKey": self.client_id, "appSecret": self.client_secret} async with aiohttp.ClientSession() as session: async with session.post( - "https://api.dingtalk.com/v1.0/oauth2/accessToken", - json=payload, + "https://api.dingtalk.com/v1.0/oauth2/accessToken", json=payload ) as resp: if resp.status != 200: logger.error( - f"获取钉钉机器人 access_token 失败: {resp.status}, {await resp.text()}", + f"获取钉钉机器人 access_token 失败: {resp.status}, {await resp.text()}" ) return "" data = await resp.json() - return cast(str, data.get("data", {}).get("accessToken", "")) + return data.get("data", {}).get("accessToken", "") async def _get_sender_staff_id(self, session: MessageSesion) -> str: try: staff_id = await sp.get_async( - "global", - str(session), - "dingtalk_staffid", - "", + "global", str(session), "dingtalk_staffid", "" ) - return cast(str, staff_id or "") + return staff_id or "" except Exception as e: logger.warning(f"读取钉钉 staff_id 映射失败: {e}") return "" async def _send_group_message( - self, - open_conversation_id: str, - robot_code: str, - msg_key: str, - msg_param: dict, + self, open_conversation_id: str, robot_code: str, msg_key: str, msg_param: dict ) -> None: access_token = await self.get_access_token() if not access_token: logger.error("钉钉群消息发送失败: access_token 为空") return - payload = { "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False), @@ -425,21 +358,16 @@ class DingtalkPlatformAdapter(Platform): ) as resp: if resp.status != 200: logger.error( - f"钉钉群消息发送失败: {resp.status}, {await resp.text()}", + f"钉钉群消息发送失败: {resp.status}, {await resp.text()}" ) async def _send_private_message( - self, - staff_id: str, - robot_code: str, - msg_key: str, - msg_param: dict, + self, staff_id: str, robot_code: str, msg_key: str, msg_param: dict ) -> None: access_token = await self.get_access_token() if not access_token: logger.error("钉钉私聊消息发送失败: access_token 为空") return - payload = { "robotCode": robot_code, "userIds": [staff_id], @@ -458,7 +386,7 @@ class DingtalkPlatformAdapter(Platform): ) as resp: if resp.status != 200: logger.error( - f"钉钉私聊消息发送失败: {resp.status}, {await resp.text()}", + f"钉钉私聊消息发送失败: {resp.status}, {await resp.text()}" ) def _safe_remove_file(self, file_path: str | None) -> None: @@ -475,15 +403,14 @@ class DingtalkPlatformAdapter(Platform): """优先转换为 OGG(Opus),不可用时回退 AMR。""" lower_path = input_path.lower() if lower_path.endswith((".amr", ".ogg")): - return input_path, False - + return (input_path, False) try: converted = await convert_audio_format(input_path, "ogg") - return converted, converted != input_path + return (converted, converted != input_path) except Exception as e: logger.warning(f"钉钉语音转 OGG 失败,回退 AMR: {e}") converted = await convert_audio_format(input_path, "amr") - return converted, converted != input_path + return (converted, converted != input_path) async def upload_media(self, file_path: str, media_type: str) -> str: media_file_path = anyio.Path(file_path) @@ -491,7 +418,6 @@ class DingtalkPlatformAdapter(Platform): if not access_token: logger.error("钉钉媒体上传失败: access_token 为空") return "" - form = aiohttp.FormData() form.add_field( "media", @@ -513,7 +439,7 @@ class DingtalkPlatformAdapter(Platform): if data.get("errcode") != 0: logger.error(f"钉钉媒体上传失败: {data}") return "" - return cast(str, data.get("media_id", "")) + return data.get("media_id", "") async def upload_image(self, image: Image) -> str: image_file_path = await image.convert_to_file_path() @@ -527,6 +453,7 @@ class DingtalkPlatformAdapter(Platform): message_chain: MessageChain, at_str: str = "", ) -> None: + async def send_message(msg_key: str, msg_param: dict) -> None: if target_type == "group": await self._send_group_message( @@ -546,14 +473,11 @@ class DingtalkPlatformAdapter(Platform): for segment in message_chain.chain: if isinstance(segment, Plain): text = segment.text.strip() - if not text and not at_str: + if not text and (not at_str): continue await send_message( msg_key="sampleMarkdown", - msg_param={ - "title": "AstrBot", - "text": f"{at_str} {text}".strip(), - }, + msg_param={"title": "AstrBot", "text": f"{at_str} {text}".strip()}, ) elif isinstance(segment, Image): photo_url = segment.file or segment.url or "" @@ -564,8 +488,7 @@ class DingtalkPlatformAdapter(Platform): if not photo_url: continue await send_message( - msg_key="sampleImageMsg", - msg_param={"photoURL": photo_url}, + msg_key="sampleImageMsg", msg_param={"photoURL": photo_url} ) elif isinstance(segment, Record): converted_audio = None @@ -683,29 +606,14 @@ class DingtalkPlatformAdapter(Platform): message_chain: MessageChain, ) -> None: robot_code = self.client_id - - # at_list: list[str] = [] - sender_id = cast(str, incoming_message.sender_id or "") - sender_staff_id = cast(str, incoming_message.sender_staff_id or "") + sender_id = incoming_message.sender_id or "" + sender_staff_id = incoming_message.sender_staff_id or "" normalized_sender_id = self._id_to_sid(sender_id) - # 现在用的发消息接口不支持 at - # for segment in message_chain.chain: - # if isinstance(segment, At): - # if ( - # str(segment.qq) in {sender_id, normalized_sender_id} - # and sender_staff_id - # ): - # at_list.append(f"@{sender_staff_id}") - # else: - # at_list.append(f"@{segment.qq}") - # at_str = " ".join(at_list) - if incoming_message.conversation_type == "2": await self.send_message_chain_to_group( - open_conversation_id=cast(str, incoming_message.conversation_id), + open_conversation_id=incoming_message.conversation_id, robot_code=robot_code, message_chain=message_chain, - # at_str=at_str, ) else: session = MessageSesion( @@ -718,10 +626,7 @@ class DingtalkPlatformAdapter(Platform): logger.error("钉钉私聊回复失败: 缺少 sender_staff_id") return await self.send_message_chain_to_user( - staff_id=staff_id, - robot_code=robot_code, - message_chain=message_chain, - # at_str=at_str, + staff_id=staff_id, robot_code=robot_code, message_chain=message_chain ) async def handle_msg(self, abm: AstrBotMessage) -> None: @@ -733,12 +638,10 @@ class DingtalkPlatformAdapter(Platform): client=self.client, adapter=self, ) - self._event_queue.put_nowait(event) async def run(self) -> None: - # await self.client_.start() - # 钉钉的 SDK 并没有实现真正的异步,start() 里面有堵塞方法。 + def start_client(loop: asyncio.AbstractEventLoop) -> None: try: self._shutdown_event = threading.Event() @@ -756,11 +659,12 @@ class DingtalkPlatformAdapter(Platform): await loop.run_in_executor(None, start_client, loop) async def terminate(self) -> None: + def monkey_patch_close() -> NoReturn: raise KeyboardInterrupt("Graceful shutdown") if self.client_.websocket is not None: - self.client_.open_connection = monkey_patch_close # type: ignore[assignment] + self.client_.open_connection = monkey_patch_close await self.client_.websocket.close(code=1000, reason="Graceful shutdown") if self._shutdown_event is not None: self._shutdown_event.set() diff --git a/astrbot/core/platform/sources/discord/discord_platform_event.py b/astrbot/core/platform/sources/discord/discord_platform_event.py index c6b795d8e..11343d582 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_event.py +++ b/astrbot/core/platform/sources/discord/discord_platform_event.py @@ -4,10 +4,8 @@ import binascii from collections.abc import AsyncGenerator from io import BytesIO from pathlib import Path -from typing import cast import discord -from discord.types.interactions import ComponentInteractionData from astrbot import logger from astrbot.api.event import AstrMessageEvent, MessageChain @@ -24,7 +22,6 @@ from .client import DiscordBotClient from .components import DiscordEmbed, DiscordView -# 自定义Discord视图组件(兼容旧版本) class DiscordViewComponent(BaseMessageComponent): type: str = "discord_view" @@ -48,7 +45,6 @@ class DiscordPlatformEvent(AstrMessageEvent): async def send(self, message: MessageChain) -> None: """发送消息到Discord平台""" - # 解析消息链为 Discord 所需的对象 try: ( content, @@ -60,7 +56,6 @@ class DiscordPlatformEvent(AstrMessageEvent): except Exception as e: logger.error(f"[Discord] 解析消息链时失败: {e}", exc_info=True) return - kwargs = {} if content: kwargs["content"] = content @@ -70,19 +65,14 @@ class DiscordPlatformEvent(AstrMessageEvent): kwargs["view"] = view if embeds: kwargs["embeds"] = embeds - if reference_message_id and not self.interaction_followup_webhook: + if reference_message_id and (not self.interaction_followup_webhook): kwargs["reference"] = self.client.get_message(int(reference_message_id)) if not kwargs: logger.debug("[Discord] 尝试发送空消息,已忽略。") return - - # 根据上下文执行发送/回复操作 try: - # -- 斜杠指令/交互上下文 -- if self.interaction_followup_webhook: await self.interaction_followup_webhook.send(**kwargs) - - # -- 常规消息上下文 -- else: channel = await self._get_channel() if not channel: @@ -91,10 +81,8 @@ class DiscordPlatformEvent(AstrMessageEvent): logger.error(f"[Discord] 频道 {channel.id} 不是可发送消息的类型") return await channel.send(**kwargs) - except Exception as e: logger.error(f"[Discord] 发送消息时发生未知错误: {e}", exc_info=True) - await super().send(message) async def send_streaming( @@ -119,15 +107,14 @@ class DiscordPlatformEvent(AstrMessageEvent): try: channel_id = int(self.session_id) return self.client.get_channel( - channel_id, + channel_id ) or await self.client.fetch_channel(channel_id) except (ValueError, discord.errors.NotFound, discord.errors.Forbidden): logger.error(f"[Discord] 无法获取频道 {self.session_id}") return None async def _parse_to_discord( - self, - message: MessageChain, + self, message: MessageChain ) -> tuple[ str, list[discord.File], @@ -141,8 +128,8 @@ class DiscordPlatformEvent(AstrMessageEvent): view = None embeds = [] reference_message_id = None - for i in message.chain: # 遍历消息链 - if isinstance(i, Plain): # 如果是文字类型的 + for i in message.chain: + if isinstance(i, Plain): content_parts.append(i.text) elif isinstance(i, Reply): reference_message_id = i.id @@ -153,34 +140,25 @@ class DiscordPlatformEvent(AstrMessageEvent): try: filename = getattr(i, "filename", None) file_content = getattr(i, "file", None) - if not file_content: logger.warning(f"[Discord] Image 组件没有 file 属性: {i}") continue - discord_file = None - - # 1. URL if file_content.startswith("http"): logger.debug(f"[Discord] 处理 URL 图片: {file_content}") embed = discord.Embed().set_image(url=file_content) embeds.append(embed) continue - - # 2. File URI if file_content.startswith("file:///"): logger.debug(f"[Discord] 处理 File URI: {file_content}") path = Path(file_content[8:]) if await asyncio.to_thread(path.exists): file_bytes = await asyncio.to_thread(path.read_bytes) discord_file = discord.File( - BytesIO(file_bytes), - filename=filename or path.name, + BytesIO(file_bytes), filename=filename or path.name ) else: logger.warning(f"[Discord] 图片文件不存在: {path}") - - # 3. Base64 URI elif file_content.startswith("base64://"): logger.debug("[Discord] 处理 Base64 URI") b64_data = file_content.split("base64://", 1)[1] @@ -189,11 +167,8 @@ class DiscordPlatformEvent(AstrMessageEvent): b64_data += "=" * (4 - missing_padding) img_bytes = base64.b64decode(b64_data) discord_file = discord.File( - BytesIO(img_bytes), - filename=filename or "image.png", + BytesIO(img_bytes), filename=filename or "image.png" ) - - # 4. 裸 Base64 或本地路径 else: try: logger.debug("[Discord] 尝试作为裸 Base64 处理") @@ -203,28 +178,23 @@ class DiscordPlatformEvent(AstrMessageEvent): b64_data += "=" * (4 - missing_padding) img_bytes = base64.b64decode(b64_data) discord_file = discord.File( - BytesIO(img_bytes), - filename=filename or "image.png", + BytesIO(img_bytes), filename=filename or "image.png" ) except (ValueError, TypeError, binascii.Error): logger.debug( - f"[Discord] 裸 Base64 解码失败,作为本地路径处理: {file_content}", + f"[Discord] 裸 Base64 解码失败,作为本地路径处理: {file_content}" ) path = Path(file_content) if await asyncio.to_thread(path.exists): file_bytes = await asyncio.to_thread(path.read_bytes) discord_file = discord.File( - BytesIO(file_bytes), - filename=filename or path.name, + BytesIO(file_bytes), filename=filename or path.name ) else: logger.warning(f"[Discord] 图片文件不存在: {path}") - if discord_file: files.append(discord_file) - except Exception: - # 使用 getattr 来安全地访问 i.file,以防 i 本身就是问题 file_info = getattr(i, "file", "未知") logger.error( f"[Discord] 处理图片时发生未知严重错误: {file_info}", @@ -238,45 +208,38 @@ class DiscordPlatformEvent(AstrMessageEvent): if await asyncio.to_thread(path.exists): file_bytes = await asyncio.to_thread(path.read_bytes) files.append( - discord.File(BytesIO(file_bytes), filename=i.name), + discord.File(BytesIO(file_bytes), filename=i.name) ) else: logger.warning( - f"[Discord] 获取文件失败,路径不存在: {file_path_str}", + f"[Discord] 获取文件失败,路径不存在: {file_path_str}" ) else: logger.warning(f"[Discord] 获取文件失败: {i.name}") except Exception as e: logger.warning(f"[Discord] 处理文件失败: {i.name}, 错误: {e}") elif isinstance(i, DiscordEmbed): - # Discord Embed消息 embeds.append(i.to_discord_embed()) elif isinstance(i, DiscordView): - # Discord视图组件(按钮、选择菜单等) view = i.to_discord_view() elif isinstance(i, DiscordViewComponent): - # 如果消息链中包含Discord视图组件(兼容旧版本) if isinstance(i.view, discord.ui.View): view = i.view else: logger.debug(f"[Discord] 忽略了不支持的消息组件: {i.type}") - content = "".join(content_parts) if len(content) > 2000: logger.warning("[Discord] 消息内容超过2000字符,将被截断。") content = content[:2000] - return content, files, view, embeds, reference_message_id + return (content, files, view, embeds, reference_message_id) async def react(self, emoji: str) -> None: """对原消息添加反应""" try: if hasattr(self.message_obj, "raw_message") and hasattr( - self.message_obj.raw_message, - "add_reaction", + self.message_obj.raw_message, "add_reaction" ): - await cast(discord.Message, self.message_obj.raw_message).add_reaction( - emoji - ) + await self.message_obj.raw_message.add_reaction(emoji) except Exception as e: logger.error(f"[Discord] 添加反应失败: {e}") @@ -285,8 +248,10 @@ class DiscordPlatformEvent(AstrMessageEvent): return ( hasattr(self.message_obj, "raw_message") and hasattr(self.message_obj.raw_message, "type") - and cast(discord.Interaction, self.message_obj.raw_message).type - == discord.InteractionType.application_command + and ( + self.message_obj.raw_message.type + == discord.InteractionType.application_command + ) ) def is_button_interaction(self) -> bool: @@ -294,18 +259,14 @@ class DiscordPlatformEvent(AstrMessageEvent): return ( hasattr(self.message_obj, "raw_message") and hasattr(self.message_obj.raw_message, "type") - and cast(discord.Interaction, self.message_obj.raw_message).type - == discord.InteractionType.component + and (self.message_obj.raw_message.type == discord.InteractionType.component) ) def get_interaction_custom_id(self) -> str: """获取交互组件的custom_id""" if self.is_button_interaction(): try: - return cast( - ComponentInteractionData, - cast(discord.Interaction, self.message_obj.raw_message).data, - ).get("custom_id", "") + return self.message_obj.raw_message.data.get("custom_id", "") except Exception: pass return "" @@ -313,22 +274,20 @@ class DiscordPlatformEvent(AstrMessageEvent): def is_mentioned(self) -> bool: """判断机器人是否被@""" if hasattr(self.message_obj, "raw_message") and hasattr( - self.message_obj.raw_message, - "mentions", + self.message_obj.raw_message, "mentions" ): return any( - mention.id == int(self.message_obj.self_id) - for mention in cast( - discord.Message, self.message_obj.raw_message - ).mentions + + mention.id == int(self.message_obj.self_id) + for mention in self.message_obj.raw_message.mentions + ) return False def get_mention_clean_content(self) -> str: """获取去除@后的清洁内容""" if hasattr(self.message_obj, "raw_message") and hasattr( - self.message_obj.raw_message, - "clean_content", + self.message_obj.raw_message, "clean_content" ): - return cast(discord.Message, self.message_obj.raw_message).clean_content + return self.message_obj.raw_message.clean_content return self.message_str diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py index e8e41a0f2..36eeab2c8 100644 --- a/astrbot/core/platform/sources/lark/lark_adapter.py +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -4,15 +4,12 @@ import json import re import time from pathlib import Path -from typing import Any, cast +from typing import Any from uuid import uuid4 import anyio import lark_oapi as lark -from lark_oapi.api.im.v1 import ( - GetMessageRequest, - GetMessageResourceRequest, -) +from lark_oapi.api.im.v1 import GetMessageRequest, GetMessageResourceRequest from lark_oapi.api.im.v1.processor import P2ImMessageReceiveV1Processor import astrbot.api.message_components as Comp @@ -39,39 +36,29 @@ from .server import LarkWebhookServer ) class LarkPlatformAdapter(Platform): def __init__( - self, - platform_config: dict, - platform_settings: dict, - event_queue: asyncio.Queue, + self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue ) -> None: super().__init__(platform_config, event_queue) - self.appid = platform_config["app_id"] self.appsecret = platform_config["app_secret"] self.domain = platform_config.get("domain", lark.FEISHU_DOMAIN) self.bot_name = platform_config.get("lark_bot_name", "astrbot") - - # socket or webhook self.connection_mode = platform_config.get("lark_connection_mode", "socket") - if not self.bot_name: logger.warning("未设置飞书机器人名称,@ 机器人可能得不到回复。") - # 初始化 WebSocket 长连接相关配置 async def on_msg_event_recv(event: lark.im.v1.P2ImMessageReceiveV1) -> None: await self.convert_msg(event) def do_v2_msg_event(event: lark.im.v1.P2ImMessageReceiveV1) -> None: - asyncio.create_task(on_msg_event_recv(event)) # noqa: RUF006 + asyncio.create_task(on_msg_event_recv(event)) self.event_handler = ( lark.EventDispatcherHandler.builder("", "") .register_p2_im_message_receive_v1(do_v2_msg_event) .build() ) - self.do_v2_msg_event = do_v2_msg_event - self.client = lark.ws.Client( app_id=self.appid, app_secret=self.appsecret, @@ -79,7 +66,6 @@ class LarkPlatformAdapter(Platform): domain=self.domain, event_handler=self.event_handler, ) - self.lark_api = ( lark.Client.builder() .app_id(self.appid) @@ -88,25 +74,18 @@ class LarkPlatformAdapter(Platform): .domain(self.domain) .build() ) - self.webhook_server = None if self.connection_mode == "webhook": self.webhook_server = LarkWebhookServer(platform_config, event_queue) self.webhook_server.set_callback(self.handle_webhook_event) - self.event_id_timestamps: dict[str, float] = {} async def _download_message_resource( - self, - *, - message_id: str, - file_key: str, - resource_type: str, + self, *, message_id: str, file_key: str, resource_type: str ) -> bytes | None: if self.lark_api.im is None: logger.error("[Lark] API Client im 模块未初始化") return None - request = ( GetMessageResourceRequest.builder() .message_id(message_id) @@ -117,15 +96,12 @@ class LarkPlatformAdapter(Platform): response = await self.lark_api.im.v1.message_resource.aget(request) if not response.success(): logger.error( - f"[Lark] 下载消息资源失败 type={resource_type}, key={file_key}, " - f"code={response.code}, msg={response.msg}", + f"[Lark] 下载消息资源失败 type={resource_type}, key={file_key}, code={response.code}, msg={response.msg}" ) return None - if response.file is None: logger.error(f"[Lark] 消息资源响应中不包含文件流: {file_key}") return None - return response.file.read() @staticmethod @@ -150,7 +126,6 @@ class LarkPlatformAdapter(Platform): parts.append("[audio]") elif isinstance(comp, Comp.Video): parts.append("[video]") - return " ".join(parts).strip() @staticmethod @@ -170,12 +145,10 @@ class LarkPlatformAdapter(Platform): at_map: dict[str, Comp.At] = {} if not mentions: return at_map - for mention in mentions: key = getattr(mention, "key", None) if not key: continue - mention_id = getattr(mention, "id", None) open_id = "" if mention_id is not None: @@ -183,10 +156,8 @@ class LarkPlatformAdapter(Platform): open_id = getattr(mention_id, "open_id", "") or "" else: open_id = str(mention_id) - mention_name = str(getattr(mention, "name", "") or "") at_map[key] = Comp.At(qq=open_id, name=mention_name) - return at_map async def _parse_message_components( @@ -198,10 +169,9 @@ class LarkPlatformAdapter(Platform): at_map: dict[str, Comp.At], ) -> list[Comp.BaseMessageComponent]: components: list[Comp.BaseMessageComponent] = [] - if message_type == "text": message_str_raw = str(content.get("text", "")) - at_pattern = r"(@_user_\d+)" + at_pattern = "(@_user_\\d+)" parts = re.split(at_pattern, message_str_raw) for part in parts: segment = part.strip() @@ -212,18 +182,11 @@ class LarkPlatformAdapter(Platform): else: components.append(Comp.Plain(segment)) return components - if message_type in ("post", "image"): if message_type == "image": - comp_list = [ - { - "tag": "img", - "image_key": content.get("image_key"), - }, - ] + comp_list = [{"tag": "img", "image_key": content.get("image_key")}] else: comp_list = self._parse_post_content(content) - for comp in comp_list: tag = comp.get("tag") if tag == "at": @@ -249,9 +212,7 @@ class LarkPlatformAdapter(Platform): logger.error("[Lark] 图片消息缺少 message_id") continue image_bytes = await self._download_message_resource( - message_id=message_id, - file_key=image_key, - resource_type="image", + message_id=message_id, file_key=image_key, resource_type="image" ) if image_bytes is None: continue @@ -276,9 +237,7 @@ class LarkPlatformAdapter(Platform): ) if file_path: components.append(Comp.Video(file=file_path, path=file_path)) - return components - if message_type == "file": file_key = str(content.get("file_key", "")).strip() file_name = str(content.get("file_name", "")).strip() or "lark_file" @@ -297,7 +256,6 @@ class LarkPlatformAdapter(Platform): if file_path: components.append(Comp.File(name=file_name, file=file_path)) return components - if message_type == "audio": file_key = str(content.get("file_key", "")).strip() if not message_id: @@ -315,7 +273,6 @@ class LarkPlatformAdapter(Platform): if file_path: components.append(Comp.Record(file=file_path, url=file_path)) return components - if message_type == "media": file_key = str(content.get("file_key", "")).strip() file_name = str(content.get("file_name", "")).strip() or "lark_media.mp4" @@ -335,32 +292,24 @@ class LarkPlatformAdapter(Platform): if file_path: components.append(Comp.Video(file=file_path, path=file_path)) return components - return components async def _build_reply_from_parent_id( - self, - parent_message_id: str, + self, parent_message_id: str ) -> Comp.Reply | None: if self.lark_api.im is None: logger.error("[Lark] API Client im 模块未初始化") return None - request = GetMessageRequest.builder().message_id(parent_message_id).build() response = await self.lark_api.im.v1.message.aget(request) if not response.success(): logger.error( - f"[Lark] 获取引用消息失败 id={parent_message_id}, " - f"code={response.code}, msg={response.msg}", + f"[Lark] 获取引用消息失败 id={parent_message_id}, code={response.code}, msg={response.msg}" ) return None - if response.data is None or not response.data.items: - logger.error( - f"[Lark] 引用消息响应为空 id={parent_message_id}", - ) + logger.error(f"[Lark] 引用消息响应为空 id={parent_message_id}") return None - parent_message = response.data.items[0] quoted_message_id = parent_message.message_id or parent_message_id quoted_sender_id = ( @@ -385,10 +334,7 @@ class LarkPlatformAdapter(Platform): if isinstance(parsed, dict): quoted_content_json = parsed except json.JSONDecodeError: - logger.warning( - f"[Lark] 解析引用消息内容失败 id={quoted_message_id}", - ) - + logger.warning(f"[Lark] 解析引用消息内容失败 id={quoted_message_id}") quoted_at_map = self._build_at_map(parent_message.mentions) quoted_chain = await self._parse_message_components( message_id=quoted_message_id, @@ -400,7 +346,6 @@ class LarkPlatformAdapter(Platform): sender_nickname = ( quoted_sender_id[:8] if quoted_sender_id != "unknown" else "unknown" ) - return Comp.Reply( id=quoted_message_id, chain=quoted_chain, @@ -421,13 +366,10 @@ class LarkPlatformAdapter(Platform): default_suffix: str = ".bin", ) -> str | None: file_bytes = await self._download_message_resource( - message_id=message_id, - file_key=file_key, - resource_type="file", + message_id=message_id, file_key=file_key, resource_type="file" ) if file_bytes is None: return None - suffix = Path(file_name).suffix if file_name else default_suffix temp_dir = anyio.Path(get_astrbot_temp_path()) await temp_dir.mkdir(parents=True, exist_ok=True) @@ -464,9 +406,7 @@ class LarkPlatformAdapter(Platform): return False async def send_by_session( - self, - session: MessageSesion, - message_chain: MessageChain, + self, session: MessageSesion, message_chain: MessageChain ) -> None: if session.message_type == MessageType.GROUP_MESSAGE: id_type = "chat_id" @@ -476,22 +416,16 @@ class LarkPlatformAdapter(Platform): else: id_type = "open_id" receive_id = session.session_id - - # 复用 LarkMessageEvent 中的通用发送逻辑 await LarkMessageEvent.send_message_chain( - message_chain, - self.lark_api, - receive_id=receive_id, - receive_id_type=id_type, + message_chain, self.lark_api, receive_id=receive_id, receive_id_type=id_type ) - await super().send_by_session(session, message_chain) def meta(self) -> PlatformMetadata: return PlatformMetadata( name="lark", description="飞书机器人官方 API 适配器", - id=cast(str, self.config.get("id")), + id=self.config.get("id"), support_streaming_message=True, ) @@ -503,9 +437,7 @@ class LarkPlatformAdapter(Platform): if message is None: logger.debug("[Lark] 事件中没有消息体(message is None)") return - abm = AstrBotMessage() - if message.create_time: abm.timestamp = int(message.create_time) // 1000 else: @@ -520,39 +452,31 @@ class LarkPlatformAdapter(Platform): abm.group_id = message.chat_id abm.self_id = self.bot_name abm.message_str = "" - at_list = {} if message.parent_id: reply_seg = await self._build_reply_from_parent_id(message.parent_id) if reply_seg: abm.message.append(reply_seg) - if message.mentions: for m in message.mentions: if m.id is None: continue - # 飞书 open_id 可能是 None,这里做个防护 open_id = m.id.open_id if m.id.open_id else "" at_list[m.key] = Comp.At(qq=open_id, name=m.name) - if m.name == self.bot_name: if m.id.open_id is not None: abm.self_id = m.id.open_id - if message.content is None: logger.warning("[Lark] 消息内容为空") return - try: content_json_b = json.loads(message.content) except json.JSONDecodeError: logger.error(f"[Lark] 解析消息内容失败: {message.content}") return - if not isinstance(content_json_b, dict): logger.error(f"[Lark] 消息内容不是 JSON Object: {message.content}") return - logger.debug(f"[Lark] 解析消息内容: {content_json_b}") parsed_components = await self._parse_message_components( message_id=message.message_id, @@ -562,11 +486,9 @@ class LarkPlatformAdapter(Platform): ) abm.message.extend(parsed_components) abm.message_str = self._build_message_str_from_components(parsed_components) - if message.message_id is None: logger.error("[Lark] 消息缺少 message_id") return - if ( event.event.sender is None or event.event.sender.sender_id is None @@ -574,7 +496,6 @@ class LarkPlatformAdapter(Platform): ): logger.error("[Lark] 消息发送者信息不完整") return - abm.message_id = message.message_id abm.raw_message = message abm.sender = MessageMember( @@ -585,7 +506,6 @@ class LarkPlatformAdapter(Platform): abm.session_id = abm.group_id else: abm.session_id = abm.sender.user_id - await self.handle_msg(abm) async def handle_msg(self, abm: AstrBotMessage) -> None: @@ -596,7 +516,6 @@ class LarkPlatformAdapter(Platform): session_id=abm.session_id, bot=self.lark_api, ) - self._event_queue.put_nowait(event) async def handle_webhook_event(self, event_data: dict) -> None: @@ -614,7 +533,7 @@ class LarkPlatformAdapter(Platform): event_type = header.get("event_type", "") if event_type == "im.message.receive_v1": processor = P2ImMessageReceiveV1Processor(self.do_v2_msg_event) - data = (processor.type())(event_data) + data = processor.type()(event_data) processor.do(data) else: logger.debug(f"[Lark Webhook] 未处理的事件类型: {event_type}") @@ -623,25 +542,21 @@ class LarkPlatformAdapter(Platform): async def run(self) -> None: if self.connection_mode == "webhook": - # Webhook 模式 if self.webhook_server is None: logger.error("[Lark] Webhook 模式已启用,但 webhook_server 未初始化") return - webhook_uuid = self.config.get("webhook_uuid") if webhook_uuid: log_webhook_info(f"{self.meta().id}(飞书 Webhook)", webhook_uuid) else: logger.warning("[Lark] Webhook 模式已启用,但未配置 webhook_uuid") else: - # 长连接模式 await self.client._connect() async def webhook_callback(self, request: Any) -> Any: """统一 Webhook 回调入口""" if not self.webhook_server: - return {"error": "Webhook server not initialized"}, 500 - + return ({"error": "Webhook server not initialized"}, 500) return await self.webhook_server.handle_callback(request) async def terminate(self) -> None: diff --git a/astrbot/core/platform/sources/line/line_adapter.py b/astrbot/core/platform/sources/line/line_adapter.py index c6f834200..3359d97f3 100644 --- a/astrbot/core/platform/sources/line/line_adapter.py +++ b/astrbot/core/platform/sources/line/line_adapter.py @@ -3,7 +3,7 @@ import mimetypes import time import uuid from pathlib import Path -from typing import Any, cast +from typing import Any from astrbot.api import logger from astrbot.api.event import MessageChain @@ -36,7 +36,6 @@ LINE_CONFIG_METADATA = { "hint": "用于校验 LINE Webhook 签名。", }, } - LINE_I18N_RESOURCES = { "zh-CN": { "channel_access_token": { @@ -70,10 +69,7 @@ LINE_I18N_RESOURCES = { ) class LinePlatformAdapter(Platform): def __init__( - self, - platform_config: dict, - platform_settings: dict, - event_queue: asyncio.Queue, + self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue ) -> None: super().__init__(platform_config, event_queue) self.config["unified_webhook_mode"] = True @@ -81,23 +77,16 @@ class LinePlatformAdapter(Platform): self.settings = platform_settings self._event_id_timestamps: dict[str, float] = {} self.shutdown_event = asyncio.Event() - channel_access_token = str(platform_config.get("channel_access_token", "")) channel_secret = str(platform_config.get("channel_secret", "")) if not channel_access_token or not channel_secret: - raise ValueError( - "LINE 适配器需要 channel_access_token 和 channel_secret。", - ) - + raise ValueError("LINE 适配器需要 channel_access_token 和 channel_secret。") self.line_api = LineAPIClient( - channel_access_token=channel_access_token, - channel_secret=channel_secret, + channel_access_token=channel_access_token, channel_secret=channel_secret ) async def send_by_session( - self, - session: MessageSesion, - message_chain: MessageChain, + self, session: MessageSesion, message_chain: MessageChain ) -> None: messages = await LineMessageEvent.build_line_messages(message_chain) if messages: @@ -108,7 +97,7 @@ class LinePlatformAdapter(Platform): return PlatformMetadata( name="line", description="LINE Messaging API 适配器", - id=cast(str, self.config.get("id", "line")), + id=self.config.get("id", "line"), support_streaming_message=False, ) @@ -129,38 +118,31 @@ class LinePlatformAdapter(Platform): signature = request.headers.get("x-line-signature") if not self.line_api.verify_signature(raw_body, signature): logger.warning("[LINE] invalid webhook signature") - return "invalid signature", 400 - + return ("invalid signature", 400) try: payload = await request.get_json(force=True, silent=False) except Exception as e: logger.warning("[LINE] invalid webhook body: %s", e) - return "bad request", 400 - + return ("bad request", 400) if not isinstance(payload, dict): - return "bad request", 400 - + return ("bad request", 400) await self.handle_webhook_event(payload) - return "ok", 200 + return ("ok", 200) async def handle_webhook_event(self, payload: dict[str, Any]) -> None: destination = str(payload.get("destination", "")).strip() if destination: self.destination = destination - events = payload.get("events") if not isinstance(events, list): return - for event in events: if not isinstance(event, dict): continue - event_id = str(event.get("webhookEventId", "")) if event_id and self._is_duplicate_event(event_id): logger.debug("[LINE] duplicate event skipped: %s", event_id) continue - abm = await self.convert_message(event) if abm is None: continue @@ -171,20 +153,16 @@ class LinePlatformAdapter(Platform): return None if str(event.get("mode", "active")) == "standby": return None - source = event.get("source", {}) if not isinstance(source, dict): return None - message = event.get("message", {}) if not isinstance(message, dict): return None - source_type = str(source.get("type", "")) user_id = str(source.get("userId", "")).strip() group_id = str(source.get("groupId", "")).strip() room_id = str(source.get("roomId", "")).strip() - abm = AstrBotMessage() abm.self_id = self.destination or self.meta().id abm.message = [] @@ -195,17 +173,15 @@ class LinePlatformAdapter(Platform): or event.get("deliveryContext", {}).get("deliveryId", "") or uuid.uuid4().hex ) - event_timestamp = event.get("timestamp") if isinstance(event_timestamp, int): abm.timestamp = ( event_timestamp // 1000 - if event_timestamp > 1_000_000_000_000 + if event_timestamp > 1000000000000 else event_timestamp ) else: abm.timestamp = int(time.time()) - if source_type in {"group", "room"}: abm.type = MessageType.GROUP_MESSAGE container_id = group_id or room_id @@ -220,9 +196,7 @@ class LinePlatformAdapter(Platform): abm.type = MessageType.OTHER_MESSAGE abm.session_id = user_id or group_id or room_id or "unknown" sender_id = abm.session_id - abm.sender = MessageMember(user_id=sender_id, nickname=sender_id[:8]) - components = await self._parse_line_message_components(message) if not components: return None @@ -230,46 +204,35 @@ class LinePlatformAdapter(Platform): abm.message_str = self._build_message_str(components) return abm - async def _parse_line_message_components( - self, - message: dict[str, Any], - ) -> list: + async def _parse_line_message_components(self, message: dict[str, Any]) -> list: msg_type = str(message.get("type", "")) message_id = str(message.get("id", "")).strip() - if msg_type == "text": text = str(message.get("text", "")) mention = message.get("mention") if isinstance(mention, dict): return self._parse_text_with_mentions(text, mention) return [Plain(text=text)] if text else [] - if msg_type == "image": image_component = await self._build_image_component(message_id, message) return [image_component] if image_component else [Plain(text="[image]")] - if msg_type == "video": video_component = await self._build_video_component(message_id, message) return [video_component] if video_component else [Plain(text="[video]")] - if msg_type == "audio": audio_component = await self._build_audio_component(message_id, message) return [audio_component] if audio_component else [Plain(text="[audio]")] - if msg_type == "file": file_component = await self._build_file_component(message_id, message) return [file_component] if file_component else [Plain(text="[file]")] - if msg_type == "sticker": return [Plain(text="[sticker]")] - return [Plain(text=f"[{msg_type}]")] def _parse_text_with_mentions(self, text: str, mention_obj: dict[str, Any]) -> list: mentions = mention_obj.get("mentionees", []) if not isinstance(mentions, list) or not mentions: return [Plain(text=text)] if text else [] - normalized = [] for item in mentions: if not isinstance(item, dict): @@ -280,7 +243,6 @@ class LinePlatformAdapter(Platform): continue normalized.append((start, length, item)) normalized.sort(key=lambda x: x[0]) - ret = [] cursor = 0 for start, length, item in normalized: @@ -288,7 +250,6 @@ class LinePlatformAdapter(Platform): part = text[cursor:start] if part: ret.append(Plain(text=part)) - label = text[start : start + length] or "@user" mention_type = str(item.get("type", "")) if mention_type == "user": @@ -297,7 +258,6 @@ class LinePlatformAdapter(Platform): else: ret.append(Plain(text=label)) cursor = max(cursor, start + length) - if cursor < len(text): tail = text[cursor:] if tail: @@ -305,14 +265,11 @@ class LinePlatformAdapter(Platform): return ret async def _build_image_component( - self, - message_id: str, - message: dict[str, Any], + self, message_id: str, message: dict[str, Any] ) -> Image | None: external_url = self._get_external_content_url(message) if external_url: return Image.fromURL(external_url) - content = await self.line_api.get_message_content(message_id) if not content: return None @@ -320,14 +277,11 @@ class LinePlatformAdapter(Platform): return Image.fromBytes(content_bytes) async def _build_video_component( - self, - message_id: str, - message: dict[str, Any], + self, message_id: str, message: dict[str, Any] ) -> Video | None: external_url = self._get_external_content_url(message) if external_url: return Video.fromURL(external_url) - content = await self.line_api.get_message_content(message_id) if not content: return None @@ -337,14 +291,11 @@ class LinePlatformAdapter(Platform): return Video(file=file_path, path=file_path) async def _build_audio_component( - self, - message_id: str, - message: dict[str, Any], + self, message_id: str, message: dict[str, Any] ) -> Record | None: external_url = self._get_external_content_url(message) if external_url: return Record.fromURL(external_url) - content = await self.line_api.get_message_content(message_id) if not content: return None @@ -354,9 +305,7 @@ class LinePlatformAdapter(Platform): return Record(file=file_path, url=file_path) async def _build_file_component( - self, - message_id: str, - message: dict[str, Any], + self, message_id: str, message: dict[str, Any] ) -> File | None: content = await self.line_api.get_message_content(message_id) if not content: @@ -366,11 +315,7 @@ class LinePlatformAdapter(Platform): suffix = Path(default_name).suffix or self._guess_suffix(content_type, ".bin") final_name = filename or default_name file_path = self._store_temp_content( - "file", - message_id, - content_bytes, - suffix, - original_name=final_name, + "file", message_id, content_bytes, suffix, original_name=final_name ) return File(name=final_name, file=file_path, url=file_path) @@ -407,7 +352,10 @@ class LinePlatformAdapter(Platform): if original_name: safe_stem = Path(original_name).stem.strip() safe_stem = "".join( - ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in safe_stem + + ch if ch.isalnum() or ch in ("-", "_", ".") else "_" + for ch in safe_stem + ) safe_stem = safe_stem.strip("._") if safe_stem: diff --git a/astrbot/core/platform/sources/line/line_event.py b/astrbot/core/platform/sources/line/line_event.py index 49321701b..f0cb16b52 100644 --- a/astrbot/core/platform/sources/line/line_event.py +++ b/astrbot/core/platform/sources/line/line_event.py @@ -2,7 +2,6 @@ import asyncio import re import uuid from collections.abc import AsyncGenerator -from typing import Any, cast import anyio @@ -44,13 +43,11 @@ class LineMessageEvent(AstrMessageEvent): if not text: return None return {"type": "text", "text": text[:5000]} - if isinstance(segment, At): name = str(segment.name or segment.qq or "").strip() if not name: return None return {"type": "text", "text": f"@{name}"[:5000]} - if isinstance(segment, Image): image_url = await LineMessageEvent._resolve_image_url(segment) if not image_url: @@ -60,7 +57,6 @@ class LineMessageEvent(AstrMessageEvent): "originalContentUrl": image_url, "previewImageUrl": image_url, } - if isinstance(segment, Record): audio_url = await LineMessageEvent._resolve_record_url(segment) if not audio_url: @@ -71,7 +67,6 @@ class LineMessageEvent(AstrMessageEvent): "originalContentUrl": audio_url, "duration": duration, } - if isinstance(segment, Video): video_url = await LineMessageEvent._resolve_video_url(segment) if not video_url: @@ -84,7 +79,6 @@ class LineMessageEvent(AstrMessageEvent): "originalContentUrl": video_url, "previewImageUrl": preview_url, } - if isinstance(segment, File): file_url = await LineMessageEvent._resolve_file_url(segment) if not file_url: @@ -99,7 +93,6 @@ class LineMessageEvent(AstrMessageEvent): "fileSize": file_size, "originalContentUrl": file_url, } - return None @staticmethod @@ -151,20 +144,17 @@ class LineMessageEvent(AstrMessageEvent): cover_candidate = (segment.cover or "").strip() if cover_candidate.startswith("https://"): return cover_candidate - if cover_candidate: try: cover_seg = Image(file=cover_candidate) return await cover_seg.register_to_file_service() except Exception as e: logger.debug("[LINE] resolve video cover failed: %s", e) - try: video_path = await segment.convert_to_file_path() temp_dir = anyio.Path(get_astrbot_temp_path()) await temp_dir.mkdir(parents=True, exist_ok=True) thumb_path = temp_dir / f"line_video_preview_{uuid.uuid4().hex}.jpg" - process = await asyncio.create_subprocess_exec( "ffmpeg", "-y", @@ -181,7 +171,6 @@ class LineMessageEvent(AstrMessageEvent): await process.communicate() if process.returncode != 0 or not await thumb_path.exists(): return "" - cover_seg = Image.fromFileSystem(str(thumb_path)) return await cover_seg.register_to_file_service() except Exception as e: @@ -215,10 +204,8 @@ class LineMessageEvent(AstrMessageEvent): obj = await cls._component_to_message_object(segment) if obj: messages.append(obj) - if not messages: return [] - if len(messages) > 5: logger.warning( "[LINE] message count exceeds 5, extra segments will be dropped." @@ -230,28 +217,22 @@ class LineMessageEvent(AstrMessageEvent): messages = await self.build_line_messages(message) if not messages: return - raw = self.message_obj.raw_message reply_token = "" if isinstance(raw, dict): - raw_dict = cast(dict[str, Any], raw) + raw_dict = raw reply_token = str(raw_dict.get("replyToken") or "") - sent = False if reply_token: sent = await self.line_api.reply_message(reply_token, messages) - if not sent: target_id = self.get_group_id() or self.get_sender_id() if target_id: await self.line_api.push_message(target_id, messages) - await super().send(message) async def send_streaming( - self, - generator: AsyncGenerator, - use_fallback: bool = False, + self, generator: AsyncGenerator, use_fallback: bool = False ): if not use_fallback: buffer = None @@ -265,10 +246,8 @@ class LineMessageEvent(AstrMessageEvent): buffer.squash_plain() await self.send(buffer) return await super().send_streaming(generator, use_fallback) - buffer = "" - pattern = re.compile(r"[^。?!~…]+[。?!~…]+") - + pattern = re.compile("[^。?!~…]+[。?!~…]+") async for chain in generator: if isinstance(chain, MessageChain): for comp in chain.chain: @@ -279,7 +258,6 @@ class LineMessageEvent(AstrMessageEvent): else: await self.send(MessageChain(chain=[comp])) await asyncio.sleep(1.5) - if buffer.strip(): await self.send(MessageChain([Plain(buffer)])) return await super().send_streaming(generator, use_fallback) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index dd7319087..d2eec37ca 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -1,6 +1,6 @@ import asyncio import random -from typing import Any, cast +from typing import Any import anyio @@ -18,10 +18,9 @@ from astrbot.core.platform.astr_message_event import MessageSession from .misskey_api import MisskeyAPI try: - import magic # type: ignore[assignment] + import magic except Exception: magic = None - from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from .misskey_event import MisskeyPlatformEvent @@ -40,7 +39,6 @@ from .misskey_utils import ( serialize_message_chain, ) -# Constants MAX_FILE_UPLOAD_COUNT = 16 DEFAULT_UPLOAD_CONCURRENCY = 3 @@ -50,10 +48,7 @@ DEFAULT_UPLOAD_CONCURRENCY = 3 ) class MisskeyPlatformAdapter(Platform): def __init__( - self, - platform_config: dict, - platform_settings: dict, - event_queue: asyncio.Queue, + self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue ) -> None: super().__init__(platform_config or {}, event_queue) self.settings = platform_settings or {} @@ -61,37 +56,30 @@ class MisskeyPlatformAdapter(Platform): self.access_token = self.config.get("misskey_token", "") self.max_message_length = self.config.get("max_message_length", 3000) self.default_visibility = self.config.get( - "misskey_default_visibility", - "public", + "misskey_default_visibility", "public" ) self.local_only = self.config.get("misskey_local_only", False) self.enable_chat = self.config.get("misskey_enable_chat", True) self.enable_file_upload = self.config.get("misskey_enable_file_upload", True) self.upload_folder = self.config.get("misskey_upload_folder") - - # download / security related options (exposed to platform_config) self.allow_insecure_downloads = bool( - self.config.get("misskey_allow_insecure_downloads", False), + self.config.get("misskey_allow_insecure_downloads", False) ) - # parse download timeout and chunk size safely _dt = self.config.get("misskey_download_timeout") try: self.download_timeout = int(_dt) if _dt is not None else 15 except Exception: self.download_timeout = 15 - _chunk = self.config.get("misskey_download_chunk_size") try: self.download_chunk_size = int(_chunk) if _chunk is not None else 64 * 1024 except Exception: self.download_chunk_size = 64 * 1024 - # parse max download bytes safely _md_bytes = self.config.get("misskey_max_download_bytes") try: self.max_download_bytes = int(_md_bytes) if _md_bytes is not None else None except Exception: self.max_download_bytes = None - self.api: MisskeyAPI | None = None self._running = False self.client_self_id = "" @@ -106,14 +94,12 @@ class MisskeyPlatformAdapter(Platform): "misskey_default_visibility": "public", "misskey_local_only": False, "misskey_enable_chat": True, - # download / security options "misskey_allow_insecure_downloads": False, "misskey_download_timeout": 15, "misskey_download_chunk_size": 65536, "misskey_max_download_bytes": None, } default_config.update(self.config) - return PlatformMetadata( name="misskey", description="Misskey 平台适配器", @@ -126,7 +112,6 @@ class MisskeyPlatformAdapter(Platform): if not self.instance_url or not self.access_token: logger.error("[Misskey] 配置不完整,无法启动") return - self.api = MisskeyAPI( self.instance_url, self.access_token, @@ -136,45 +121,36 @@ class MisskeyPlatformAdapter(Platform): max_download_bytes=self.max_download_bytes, ) self._running = True - try: user_info = await self.api.get_current_user() self.client_self_id = str(user_info.get("id", "")) self._bot_username = user_info.get("username", "") logger.info( - f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.client_self_id})", + f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.client_self_id})" ) except Exception as e: logger.error(f"[Misskey] 获取用户信息失败: {e}") self._running = False return - await self._start_websocket_connection() def _register_event_handlers(self, streaming) -> None: """注册事件处理器""" streaming.add_message_handler("notification", self._handle_notification) streaming.add_message_handler("main:notification", self._handle_notification) - if self.enable_chat: streaming.add_message_handler("newChatMessage", self._handle_chat_message) streaming.add_message_handler( - "messaging:newChatMessage", - self._handle_chat_message, + "messaging:newChatMessage", self._handle_chat_message ) streaming.add_message_handler("_debug", self._debug_handler) async def _send_text_only_message( - self, - session_id: str, - text: str, - session, - message_chain, + self, session_id: str, text: str, session, message_chain ): """发送纯文本消息(无文件上传)""" if not self.api: return await super().send_by_session(session, message_chain) - if session_id and is_valid_user_session_id(session_id): from .misskey_utils import extract_user_id_from_session_id @@ -187,25 +163,20 @@ class MisskeyPlatformAdapter(Platform): room_id = extract_room_id_from_session_id(session_id) payload = {"toRoomId": room_id, "text": text} await self.api.send_room_message(payload) - return await super().send_by_session(session, message_chain) def _process_poll_data( - self, - message: AstrBotMessage, - poll: dict[str, Any], - message_parts: list[str], + self, message: AstrBotMessage, poll: dict[str, Any], message_parts: list[str] ) -> None: """处理投票数据,将其添加到消息中""" try: if not isinstance(message.raw_message, dict): message.raw_message = {} - raw_message_dict = cast(dict, message.raw_message) + raw_message_dict = message.raw_message raw_message_dict["poll"] = poll message.__setattr__("poll", poll) except Exception: pass - poll_text = format_poll(poll) if poll_text: message.message.append(Comp.Plain(poll_text)) @@ -214,15 +185,12 @@ class MisskeyPlatformAdapter(Platform): def _extract_additional_fields(self, session, message_chain) -> dict[str, Any]: """从会话和消息链中提取额外字段""" fields = {"cw": None, "poll": None, "renote_id": None, "channel_id": None} - for comp in message_chain.chain: if hasattr(comp, "cw") and getattr(comp, "cw", None): fields["cw"] = comp.cw break - if hasattr(session, "extra_data") and isinstance( - getattr(session, "extra_data", None), - dict, + getattr(session, "extra_data", None), dict ): extra_data = session.extra_data fields.update( @@ -230,9 +198,8 @@ class MisskeyPlatformAdapter(Platform): "poll": extra_data.get("poll"), "renote_id": extra_data.get("renote_id"), "channel_id": extra_data.get("channel_id"), - }, + } ) - return fields async def _start_websocket_connection(self) -> None: @@ -240,20 +207,17 @@ class MisskeyPlatformAdapter(Platform): max_backoff = 300.0 backoff_multiplier = 1.5 connection_attempts = 0 - while self._running: try: connection_attempts += 1 if not self.api: logger.error("[Misskey] API 客户端未初始化") break - streaming = self.api.get_streaming_client() self._register_event_handlers(streaming) - if await streaming.connect(): logger.info( - f"[Misskey] WebSocket 已连接 (尝试 #{connection_attempts})", + f"[Misskey] WebSocket 已连接 (尝试 #{connection_attempts})" ) connection_attempts = 0 await streaming.subscribe_channel("main") @@ -261,24 +225,21 @@ class MisskeyPlatformAdapter(Platform): await streaming.subscribe_channel("messaging") await streaming.subscribe_channel("messagingIndex") logger.info("[Misskey] 聊天频道已订阅") - backoff_delay = 1.0 await streaming.listen() else: logger.error( - f"[Misskey] WebSocket 连接失败 (尝试 #{connection_attempts})", + f"[Misskey] WebSocket 连接失败 (尝试 #{connection_attempts})" ) - except Exception as e: logger.error( - f"[Misskey] WebSocket 异常 (尝试 #{connection_attempts}): {e}", + f"[Misskey] WebSocket 异常 (尝试 #{connection_attempts}): {e}" ) - if self._running: jitter = random.uniform(0, 1.0) sleep_time = backoff_delay + jitter logger.info( - f"[Misskey] {sleep_time:.1f}秒后重连 (下次尝试 #{connection_attempts + 1})", + f"[Misskey] {sleep_time:.1f}秒后重连 (下次尝试 #{connection_attempts + 1})" ) await asyncio.sleep(sleep_time) backoff_delay = min(backoff_delay * backoff_multiplier, max_backoff) @@ -287,13 +248,13 @@ class MisskeyPlatformAdapter(Platform): try: notification_type = data.get("type") logger.debug( - f"[Misskey] 收到通知事件: type={notification_type}, user_id={data.get('userId', 'unknown')}", + f"[Misskey] 收到通知事件: type={notification_type}, user_id={data.get('userId', 'unknown')}" ) if notification_type in ["mention", "reply", "quote"]: note = data.get("note") if note and self._is_bot_mentioned(note): logger.info( - f"[Misskey] 处理贴文提及: {note.get('text', '')[:50]}...", + f"[Misskey] 处理贴文提及: {note.get('text', '')[:50]}..." ) message = await self.convert_message(note) event = MisskeyPlatformEvent( @@ -310,27 +271,24 @@ class MisskeyPlatformAdapter(Platform): async def _handle_chat_message(self, data: dict[str, Any]) -> None: try: sender_id = str( - data.get("fromUserId", "") or data.get("fromUser", {}).get("id", ""), + data.get("fromUserId", "") or data.get("fromUser", {}).get("id", "") ) room_id = data.get("toRoomId") logger.debug( - f"[Misskey] 收到聊天事件: sender_id={sender_id}, room_id={room_id}, is_self={sender_id == self.client_self_id}", + f"[Misskey] 收到聊天事件: sender_id={sender_id}, room_id={room_id}, is_self={sender_id == self.client_self_id}" ) if sender_id == self.client_self_id: return - if room_id: raw_text = data.get("text", "") logger.debug( - f"[Misskey] 检查群聊消息: '{raw_text}', 机器人用户名: '{self._bot_username}'", + f"[Misskey] 检查群聊消息: '{raw_text}', 机器人用户名: '{self._bot_username}'" ) - message = await self.convert_room_message(data) logger.info(f"[Misskey] 处理群聊消息: {message.message_str[:50]}...") else: message = await self.convert_chat_message(data) logger.info(f"[Misskey] 处理私聊消息: {message.message_str[:50]}...") - event = MisskeyPlatformEvent( message_str=message.message_str, message_obj=message, @@ -345,95 +303,77 @@ class MisskeyPlatformAdapter(Platform): async def _debug_handler(self, data: dict[str, Any]) -> None: event_type = data.get("type", "unknown") logger.debug( - f"[Misskey] 收到未处理事件: type={event_type}, channel={data.get('channel', 'unknown')}", + f"[Misskey] 收到未处理事件: type={event_type}, channel={data.get('channel', 'unknown')}" ) def _is_bot_mentioned(self, note: dict[str, Any]) -> bool: text = note.get("text", "") if not text: return False - mentions = note.get("mentions", []) if self._bot_username and f"@{self._bot_username}" in text: return True if self.client_self_id in [str(uid) for uid in mentions]: return True - reply = note.get("reply") if reply and isinstance(reply, dict): reply_user_id = str(reply.get("user", {}).get("id", "")) if reply_user_id == self.client_self_id: return bool(self._bot_username and f"@{self._bot_username}" in text) - return False async def send_by_session( - self, - session: MessageSession, - message_chain: MessageChain, + self, session: MessageSession, message_chain: MessageChain ) -> None: if not self.api: logger.error("[Misskey] API 客户端未初始化") return await super().send_by_session(session, message_chain) - try: session_id = session.session_id - text, has_at_user = serialize_message_chain(message_chain.chain) - if not has_at_user and session_id: - # 从session_id中提取用户ID用于缓存查询 - # session_id格式为: "chat%" 或 "room%" 或 "note%" user_id_for_cache = None if "%" in session_id: parts = session_id.split("%") if len(parts) >= 2: user_id_for_cache = parts[1] - user_info = None if user_id_for_cache: user_info = self._user_cache.get(user_id_for_cache) - text = add_at_mention_if_needed(text, user_info, has_at_user) - - # 检查是否有文件组件 has_file_components = any( - isinstance(comp, Comp.Image) - or isinstance(comp, Comp.File) - or hasattr(comp, "convert_to_file_path") - or hasattr(comp, "get_file") - or any( - hasattr(comp, a) for a in ("file", "url", "path", "src", "source") - ) - for comp in message_chain.chain - ) + isinstance(comp, Comp.Image) + or isinstance(comp, Comp.File) + or hasattr(comp, "convert_to_file_path") + or hasattr(comp, "get_file") + or any( + + hasattr(comp, a) + for a in ("file", "url", "path", "src", "source") + + ) + for comp in message_chain.chain + + ) if not text or not text.strip(): if not has_file_components: logger.warning("[Misskey] 消息内容为空且无文件组件,跳过发送") return await super().send_by_session(session, message_chain) text = "" - if len(text) > self.max_message_length: text = text[: self.max_message_length] + "..." - file_ids: list[str] = [] fallback_urls: list[str] = [] - if not self.enable_file_upload: return await self._send_text_only_message( - session_id, - text, - session, - message_chain, + session_id, text, session, message_chain ) - MAX_UPLOAD_CONCURRENCY = 10 upload_concurrency = int( self.config.get( - "misskey_upload_concurrency", - DEFAULT_UPLOAD_CONCURRENCY, - ), + "misskey_upload_concurrency", DEFAULT_UPLOAD_CONCURRENCY + ) ) upload_concurrency = min(upload_concurrency, MAX_UPLOAD_CONCURRENCY) sem = asyncio.Semaphore(upload_concurrency) @@ -450,22 +390,14 @@ class MisskeyPlatformAdapter(Platform): async with sem: if not self.api: return None - - # 解析组件的 URL 或本地路径 url_candidate, local_path = await resolve_component_url_or_path( - comp, + comp ) - - if not url_candidate and not local_path: + if not url_candidate and (not local_path): return None - preferred_name = getattr(comp, "name", None) or getattr( - comp, - "file", - None, + comp, "file", None ) - - # URL 上传:下载后本地上传 if url_candidate: result = await self.api.upload_and_find_file( str(url_candidate), @@ -474,8 +406,6 @@ class MisskeyPlatformAdapter(Platform): ) if isinstance(result, dict) and result.get("id"): return str(result["id"]) - - # 本地文件上传 if local_path: file_id = await upload_local_with_retries( self.api, @@ -485,8 +415,6 @@ class MisskeyPlatformAdapter(Platform): ) if file_id: return file_id - - # 所有上传都失败,尝试获取 URL 作为回退 if hasattr(comp, "register_to_file_service"): try: url = await comp.register_to_file_service() @@ -494,11 +422,8 @@ class MisskeyPlatformAdapter(Platform): return {"fallback_url": url} except Exception: pass - return None - finally: - # 清理临时文件 if local_path and isinstance(local_path, str): data_temp = get_astrbot_temp_path() if ( @@ -511,7 +436,6 @@ class MisskeyPlatformAdapter(Platform): except Exception: pass - # 收集所有可能包含文件/URL信息的组件:支持异步接口或同步字段 file_components = [] for comp in message_chain.chain: try: @@ -521,30 +445,28 @@ class MisskeyPlatformAdapter(Platform): or hasattr(comp, "convert_to_file_path") or hasattr(comp, "get_file") or any( - hasattr(comp, a) - for a in ("file", "url", "path", "src", "source") + + hasattr(comp, a) + for a in ("file", "url", "path", "src", "source") + ) ): file_components.append(comp) except Exception: - # 保守跳过无法访问属性的组件 continue - if len(file_components) > MAX_FILE_UPLOAD_COUNT: logger.warning( - f"[Misskey] 文件数量超过限制 ({len(file_components)} > {MAX_FILE_UPLOAD_COUNT}),只上传前{MAX_FILE_UPLOAD_COUNT}个文件", + f"[Misskey] 文件数量超过限制 ({len(file_components)} > {MAX_FILE_UPLOAD_COUNT}),只上传前{MAX_FILE_UPLOAD_COUNT}个文件" ) file_components = file_components[:MAX_FILE_UPLOAD_COUNT] - upload_tasks = [_upload_comp(comp) for comp in file_components] - try: results = await asyncio.gather(*upload_tasks) if upload_tasks else [] for r in results: if not r: continue if isinstance(r, dict): - r_dict = cast(dict, r) + r_dict = r url = r_dict.get("fallback_url") if url: fallback_urls.append(str(url)) @@ -557,7 +479,6 @@ class MisskeyPlatformAdapter(Platform): pass except Exception: logger.debug("[Misskey] 并发上传过程中出现异常,继续发送文本") - if session_id and is_valid_room_session_id(session_id): from .misskey_utils import extract_room_id_from_session_id @@ -582,23 +503,17 @@ class MisskeyPlatformAdapter(Platform): text = (text or "") + appended payload = {"toUserId": user_id, "text": text} if file_ids: - # 聊天消息只支持单个文件,使用 fileId 而不是 fileIds payload["fileId"] = file_ids[0] if len(file_ids) > 1: logger.warning( - f"[Misskey] 聊天消息只支持单个文件,忽略其余 {len(file_ids) - 1} 个文件", + f"[Misskey] 聊天消息只支持单个文件,忽略其余 {len(file_ids) - 1} 个文件" ) await self.api.send_message(payload) else: - # 回退到发帖逻辑 - # 去掉 session_id 中的 note% 前缀以匹配 user_cache 的键格式 user_id_for_cache = ( session_id.split("%")[1] if "%" in session_id else session_id ) - - # 获取用户缓存信息(包含reply_to_note_id) user_info_for_reply = self._user_cache.get(user_id_for_cache, {}) - visibility, visible_user_ids = resolve_message_visibility( user_id=user_id_for_cache, user_cache=self._user_cache, @@ -606,68 +521,48 @@ class MisskeyPlatformAdapter(Platform): default_visibility=self.default_visibility, ) logger.debug( - f"[Misskey] 解析可见性: visibility={visibility}, visible_user_ids={visible_user_ids}, session_id={session_id}, user_id_for_cache={user_id_for_cache}", + f"[Misskey] 解析可见性: visibility={visibility}, visible_user_ids={visible_user_ids}, session_id={session_id}, user_id_for_cache={user_id_for_cache}" ) - fields = self._extract_additional_fields(session, message_chain) if fallback_urls: appended = "\n" + "\n".join(fallback_urls) text = (text or "") + appended - - # 从缓存中获取原消息ID作为reply_id reply_id = user_info_for_reply.get("reply_to_note_id") - await self.api.create_note( text=text, visibility=visibility, visible_user_ids=visible_user_ids, file_ids=file_ids or None, local_only=self.local_only, - reply_id=reply_id, # 添加reply_id参数 + reply_id=reply_id, cw=fields["cw"], poll=fields["poll"], renote_id=fields["renote_id"], channel_id=fields["channel_id"], ) - except Exception as e: logger.error(f"[Misskey] 发送消息失败: {e}") - return await super().send_by_session(session, message_chain) async def convert_message(self, raw_data: dict[str, Any]) -> AstrBotMessage: """将 Misskey 贴文数据转换为 AstrBotMessage 对象""" sender_info = extract_sender_info(raw_data, is_chat=False) message = create_base_message( - raw_data, - sender_info, - self.client_self_id, - is_chat=False, + raw_data, sender_info, self.client_self_id, is_chat=False ) cache_user_info( - self._user_cache, - sender_info, - raw_data, - self.client_self_id, - is_chat=False, + self._user_cache, sender_info, raw_data, self.client_self_id, is_chat=False ) - message_parts = [] raw_text = raw_data.get("text", "") - if raw_text: text_parts, _processed_text = process_at_mention( - message, - raw_text, - self._bot_username, - self.client_self_id, + message, raw_text, self._bot_username, self.client_self_id ) message_parts.extend(text_parts) - files = raw_data.get("files", []) file_parts = process_files(message, files) message_parts.extend(file_parts) - poll = raw_data.get("poll") or ( raw_data.get("note", {}).get("poll") if isinstance(raw_data.get("note"), dict) @@ -675,7 +570,6 @@ class MisskeyPlatformAdapter(Platform): ) if poll and isinstance(poll, dict): self._process_poll_data(message, poll, message_parts) - message.message_str = ( " ".join(part for part in message_parts if part.strip()) if message_parts @@ -687,26 +581,16 @@ class MisskeyPlatformAdapter(Platform): """将 Misskey 聊天消息数据转换为 AstrBotMessage 对象""" sender_info = extract_sender_info(raw_data, is_chat=True) message = create_base_message( - raw_data, - sender_info, - self.client_self_id, - is_chat=True, + raw_data, sender_info, self.client_self_id, is_chat=True ) cache_user_info( - self._user_cache, - sender_info, - raw_data, - self.client_self_id, - is_chat=True, + self._user_cache, sender_info, raw_data, self.client_self_id, is_chat=True ) - raw_text = raw_data.get("text", "") if raw_text: message.message.append(Comp.Plain(raw_text)) - files = raw_data.get("files", []) process_files(message, files, include_text_parts=False) - message.message_str = raw_text if raw_text else "" return message @@ -715,42 +599,26 @@ class MisskeyPlatformAdapter(Platform): sender_info = extract_sender_info(raw_data, is_chat=True) room_id = raw_data.get("toRoomId", "") message = create_base_message( - raw_data, - sender_info, - self.client_self_id, - is_chat=False, - room_id=room_id, + raw_data, sender_info, self.client_self_id, is_chat=False, room_id=room_id ) - cache_user_info( - self._user_cache, - sender_info, - raw_data, - self.client_self_id, - is_chat=False, + self._user_cache, sender_info, raw_data, self.client_self_id, is_chat=False ) cache_room_info(self._user_cache, raw_data, self.client_self_id) - raw_text = raw_data.get("text", "") message_parts = [] - if raw_text: if self._bot_username and f"@{self._bot_username}" in raw_text: text_parts, _processed_text = process_at_mention( - message, - raw_text, - self._bot_username, - self.client_self_id, + message, raw_text, self._bot_username, self.client_self_id ) message_parts.extend(text_parts) else: message.message.append(Comp.Plain(raw_text)) message_parts.append(raw_text) - files = raw_data.get("files", []) file_parts = process_files(message, files) message_parts.extend(file_parts) - message.message_str = ( " ".join(part for part in message_parts if part.strip()) if message_parts diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index a8ae7399e..611d95c0e 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -3,7 +3,7 @@ import base64 import os import random import uuid -from typing import Any, cast +from typing import Any import aiofiles import anyio @@ -33,12 +33,11 @@ def _patch_qq_botpy_formdata() -> None: aiohttp.FormData to have a private flag named _is_processed, which is no longer present in newer aiohttp versions. """ - try: from botpy.http import _FormData if not hasattr(_FormData, "_is_processed"): - _FormData._is_processed = False # type: ignore[invalid-assignment] + _FormData._is_processed = False except Exception: logger.debug("[QQOfficial] Skip botpy FormData patch.") @@ -72,36 +71,26 @@ class QQOfficialMessageEvent(AstrMessageEvent): async def send_streaming(self, generator, use_fallback: bool = False): """流式输出仅支持消息列表私聊(C2C),其他消息源退化为普通发送""" - # 先标记事件层“已执行发送操作”,避免异常路径遗漏 await super().send_streaming(generator, use_fallback) - # QQ C2C 流式协议:开始/中间分片使用 state=1,结束分片使用 state=10 stream_payload: dict[str, Any] = { "state": 1, "id": None, "index": 0, "reset": False, } - last_edit_time = 0 # 上次发送分片的时间 - throttle_interval = 1 # 分片间最短间隔 (秒) + last_edit_time = 0 + throttle_interval = 1 ret = None - source = ( - self.message_obj.raw_message - ) # 提前获取,避免 generator 为空时 NameError + source = self.message_obj.raw_message try: async for chain in generator: source = self.message_obj.raw_message - if not isinstance(source, botpy.message.C2CMessage): - # 非 C2C 场景:直接累积,最后统一发 if not self.send_buffer: self.send_buffer = chain else: self.send_buffer.chain.extend(chain.chain) continue - - # ---- C2C 流式场景 ---- - - # tool_call break 信号:工具开始执行,先把已有 buffer 以 state=10 结束当前流式段 if chain.type == "break": if self.send_buffer: stream_payload["state"] = 10 @@ -109,7 +98,6 @@ class QQOfficialMessageEvent(AstrMessageEvent): ret_id = self._extract_response_message_id(ret) if ret_id is not None: stream_payload["id"] = ret_id - # 重置 stream_payload,为下一段流式做准备 stream_payload = { "state": 1, "id": None, @@ -118,40 +106,27 @@ class QQOfficialMessageEvent(AstrMessageEvent): } last_edit_time = 0 continue - - # 累积内容 if not self.send_buffer: self.send_buffer = chain else: self.send_buffer.chain.extend(chain.chain) - - # 节流:按时间间隔发送中间分片 current_time = asyncio.get_running_loop().time() if current_time - last_edit_time >= throttle_interval: - ret = cast( - message.Message, - await self._post_send(stream=stream_payload), - ) + ret = await self._post_send(stream=stream_payload) stream_payload["index"] += 1 ret_id = self._extract_response_message_id(ret) if ret_id is not None: stream_payload["id"] = ret_id last_edit_time = asyncio.get_running_loop().time() - self.send_buffer = None # 清空已发送的分片,避免下次重复发送旧内容 - + self.send_buffer = None if isinstance(source, botpy.message.C2CMessage): - # 结束流式对话,发送 buffer 中剩余内容 stream_payload["state"] = 10 ret = await self._post_send(stream=stream_payload) else: ret = await self._post_send() - except Exception as e: logger.error(f"发送流式消息时出错: {e}", exc_info=True) - # 避免累计内容在异常后被整包重复发送:仅清理缓存,不做非流式整包兜底 - # 如需兜底,应该只发送未发送 delta(后续可继续优化) self.send_buffer = None - return None @staticmethod @@ -168,9 +143,7 @@ class QQOfficialMessageEvent(AstrMessageEvent): async def _post_send(self, stream: dict | None = None): if not self.send_buffer: return None - source = self.message_obj.raw_message - if not isinstance( source, botpy.message.Message @@ -180,7 +153,6 @@ class QQOfficialMessageEvent(AstrMessageEvent): ): logger.warning(f"[QQOfficial] 不支持的消息源类型: {type(source)}") return None - ( plain_text, image_base64, @@ -190,51 +162,38 @@ class QQOfficialMessageEvent(AstrMessageEvent): file_source, file_name, ) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer) - - # C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。 if stream and (image_base64 or record_file_path): logger.debug("[QQOfficial] 检测到富媒体,降级为非流式发送。") stream = None - if ( not plain_text - and not image_base64 - and not image_path - and not record_file_path - and not video_file_source - and not file_source + and (not image_base64) + and (not image_path) + and (not record_file_path) + and (not video_file_source) + and (not file_source) ): return None - - # QQ C2C 流式 API 说明: - # - 开始/中间分片(state=1):增量追加内容,不需要 \n(加了会导致强制换行) - # - 最终分片(state=10):结束流,content 必须以 \n 结尾(QQ API 要求) if ( stream and stream.get("state") == 10 and plain_text - and not plain_text.endswith("\n") + and (not plain_text.endswith("\n")) ): plain_text = plain_text + "\n" - payload: dict = { - # "content": plain_text, "markdown": MarkdownPayload(content=plain_text) if plain_text else None, "msg_type": 2, "msg_id": self.message_obj.message_id, } - if not isinstance(source, botpy.message.Message | botpy.message.DirectMessage): payload["msg_seq"] = random.randint(1, 10000) - ret = None - match source: case botpy.message.GroupMessage(): if not source.group_openid: logger.error("[QQOfficial] GroupMessage 缺少 group_openid") return None - if image_base64: media = await self.upload_group_and_c2c_image( image_base64, @@ -245,7 +204,7 @@ class QQOfficialMessageEvent(AstrMessageEvent): payload["msg_type"] = 7 payload.pop("markdown", None) payload["content"] = plain_text or None - if record_file_path: # group record msg + if record_file_path: media = await self.upload_group_and_c2c_media( record_file_path, self.VOICE_FILE_TYPE, @@ -281,14 +240,12 @@ class QQOfficialMessageEvent(AstrMessageEvent): payload["content"] = plain_text or None ret = await self._send_with_markdown_fallback( send_func=lambda retry_payload: self.bot.api.post_group_message( - group_openid=source.group_openid or "", - **retry_payload, + group_openid=source.group_openid or "", **retry_payload ), payload=payload, plain_text=plain_text, stream=stream, ) - case botpy.message.C2CMessage(): if image_base64: media = await self.upload_group_and_c2c_image( @@ -300,7 +257,7 @@ class QQOfficialMessageEvent(AstrMessageEvent): payload["msg_type"] = 7 payload.pop("markdown", None) payload["content"] = plain_text or None - if record_file_path: # c2c record + if record_file_path: media = await self.upload_group_and_c2c_media( record_file_path, self.VOICE_FILE_TYPE, @@ -348,91 +305,69 @@ class QQOfficialMessageEvent(AstrMessageEvent): else: ret = await self._send_with_markdown_fallback( send_func=lambda retry_payload: self.post_c2c_message( - openid=source.author.user_openid, - **retry_payload, + openid=source.author.user_openid, **retry_payload ), payload=payload, plain_text=plain_text, stream=stream, ) logger.debug(f"Message sent to C2C: {ret}") - case botpy.message.Message(): if image_path: payload["file_image"] = image_path - # Guild text-channel send API (/channels/{channel_id}/messages) does not use v2 msg_type. payload.pop("msg_type", None) ret = await self._send_with_markdown_fallback( send_func=lambda retry_payload: self.bot.api.post_message( - channel_id=source.channel_id, - **retry_payload, + channel_id=source.channel_id, **retry_payload ), payload=payload, plain_text=plain_text, stream=stream, ) - case botpy.message.DirectMessage(): if image_path: payload["file_image"] = image_path - # Guild DM send API (/dms/{guild_id}/messages) does not use v2 msg_type. payload.pop("msg_type", None) ret = await self._send_with_markdown_fallback( send_func=lambda retry_payload: self.bot.api.post_dms( - guild_id=source.guild_id, - **retry_payload, + guild_id=source.guild_id, **retry_payload ), payload=payload, plain_text=plain_text, stream=stream, ) - case _: pass - await super().send(self.send_buffer) - self.send_buffer = None - return ret async def _send_with_markdown_fallback( - self, - send_func, - payload: dict, - plain_text: str, - stream: dict | None = None, + self, send_func, payload: dict, plain_text: str, stream: dict | None = None ): try: return await send_func(payload) except botpy.errors.ServerError as err: - # QQ 流式 markdown 分片校验:内容必须以换行结尾。 - # 某些边界场景服务端仍可能判定失败,这里做一次修正重试。 if stream and self.STREAM_MARKDOWN_NEWLINE_ERROR in str(err): retry_payload = payload.copy() - markdown_payload = retry_payload.get("markdown") if isinstance(markdown_payload, dict): - md_content = cast(str, markdown_payload.get("content", "") or "") - if md_content and not md_content.endswith("\n"): + md_content = markdown_payload.get("content", "") or "" + if md_content and (not md_content.endswith("\n")): retry_payload["markdown"] = {"content": md_content + "\n"} - - content = cast(str | None, retry_payload.get("content")) - if content and not content.endswith("\n"): + content = retry_payload.get("content") + if content and (not content.endswith("\n")): retry_payload["content"] = content + "\n" - logger.warning( "[QQOfficial] 流式 markdown 分片换行校验失败,已修正后重试一次。" ) return await send_func(retry_payload) - if ( self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err) or not payload.get("markdown") - or not plain_text + or (not plain_text) ): raise - logger.warning("[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。") fallback_payload = payload.copy() fallback_payload.pop("markdown", None) @@ -440,23 +375,19 @@ class QQOfficialMessageEvent(AstrMessageEvent): if fallback_payload.get("msg_type") == 2: fallback_payload["msg_type"] = 0 if stream: - fallback_content = cast(str, fallback_payload.get("content") or "") - if fallback_content and not fallback_content.endswith("\n"): + fallback_content = fallback_payload.get("content") or "" + if fallback_content and (not fallback_content.endswith("\n")): fallback_payload["content"] = fallback_content + "\n" return await send_func(fallback_payload) async def upload_group_and_c2c_image( - self, - image_base64: str, - file_type: int, - **kwargs, + self, image_base64: str, file_type: int, **kwargs ) -> botpy.types.message.Media: payload = { "file_data": image_base64, "file_type": file_type, "srv_send_msg": False, } - result = None if "openid" in kwargs: payload["openid"] = kwargs["openid"] @@ -472,12 +403,10 @@ class QQOfficialMessageEvent(AstrMessageEvent): result = await self.bot.api._http.request(route, json=payload) else: raise ValueError("Invalid upload parameters") - if not isinstance(result, dict): raise RuntimeError( f"Failed to upload image, response is not dict: {result}" ) - return Media( file_uuid=result["file_uuid"], file_info=result["file_info"], @@ -493,24 +422,16 @@ class QQOfficialMessageEvent(AstrMessageEvent): **kwargs, ) -> Media | None: """上传媒体文件""" - # 构建基础payload payload: dict[str, Any] = {"file_type": file_type, "srv_send_msg": srv_send_msg} if file_name: payload["file_name"] = file_name - - # 处理文件数据 file_source_obj = anyio.Path(file_source) if await file_source_obj.exists(): - # 读取本地文件 async with aiofiles.open(file_source, "rb") as f: file_content = await f.read() - # use base64 encode payload["file_data"] = base64.b64encode(file_content).decode("utf-8") else: - # 使用URL payload["url"] = file_source - - # 添加接收者信息和确定路由 if "openid" in kwargs: payload["openid"] = kwargs["openid"] route = Route("POST", "/v2/users/{openid}/files", openid=kwargs["openid"]) @@ -523,16 +444,12 @@ class QQOfficialMessageEvent(AstrMessageEvent): ) else: return None - try: - # 使用底层HTTP请求 result = await self.bot.api._http.request(route, json=payload) - if result: if not isinstance(result, dict): logger.error(f"上传文件响应格式错误: {result}") return None - return Media( file_uuid=result["file_uuid"], file_info=result["file_info"], @@ -540,7 +457,6 @@ class QQOfficialMessageEvent(AstrMessageEvent): ) except Exception as e: logger.error(f"上传请求错误: {e}") - return None async def post_c2c_message( @@ -561,7 +477,6 @@ class QQOfficialMessageEvent(AstrMessageEvent): ) -> message.Message | None: payload = locals() payload.pop("self", None) - # QQ API does not accept stream.id=None; remove it when not yet assigned if "stream" in payload and payload["stream"] is not None: stream_data = dict(payload["stream"]) if stream_data.get("id") is None: @@ -569,20 +484,18 @@ class QQOfficialMessageEvent(AstrMessageEvent): payload["stream"] = stream_data route = Route("POST", "/v2/users/{openid}/messages", openid=openid) result = await self.bot.api._http.request(route, json=payload) - if result is None: logger.warning("[QQOfficial] post_c2c_message: API 返回 None,跳过本次发送") return None if not isinstance(result, dict): logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}") return None - - return message.Message(**cast(dict[str, Any], result)) # type: ignore[typeddict-item] + return message.Message(**result) @staticmethod async def _parse_to_qqofficial(message: MessageChain): plain_text = "" - image_base64 = None # only one img supported + image_base64 = None image_file_path = None record_file_path = None video_file_source = None @@ -591,7 +504,7 @@ class QQOfficialMessageEvent(AstrMessageEvent): for i in message.chain: if isinstance(i, Plain): plain_text += i.text - elif isinstance(i, Image) and not image_base64: + elif isinstance(i, Image) and (not image_base64): if i.file and i.file.startswith("file:///"): image_base64 = file_to_base64(i.file[8:]) image_file_path = i.file[8:] @@ -607,16 +520,14 @@ class QQOfficialMessageEvent(AstrMessageEvent): image_base64 = image_base64.removeprefix("base64://") elif isinstance(i, Record): if i.file: - record_wav_path = await i.convert_to_file_path() # wav 路径 + record_wav_path = await i.convert_to_file_path() temp_dir = get_astrbot_temp_path() record_tecent_silk_path = os.path.join( - temp_dir, - f"qqofficial_{uuid.uuid4()}.silk", + temp_dir, f"qqofficial_{uuid.uuid4()}.silk" ) try: duration = await wav_to_tencent_silk( - record_wav_path, - record_tecent_silk_path, + record_wav_path, record_tecent_silk_path ) if duration > 0: record_file_path = record_tecent_silk_path @@ -626,12 +537,12 @@ class QQOfficialMessageEvent(AstrMessageEvent): except Exception as e: logger.error(f"处理语音时出错: {e}") record_file_path = None - elif isinstance(i, Video) and not video_file_source: + elif isinstance(i, Video) and (not video_file_source): if i.file.startswith("file:///"): video_file_source = i.file[8:] else: video_file_source = i.file - elif isinstance(i, File) and not file_source: + elif isinstance(i, File) and (not file_source): file_name = i.name if i.file_: file_path = i.file_ diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 8b9244d12..8a3a40289 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -8,7 +8,7 @@ import time import uuid from pathlib import Path from types import SimpleNamespace -from typing import Any, cast +from typing import Any import anyio import botpy @@ -33,57 +33,47 @@ from astrbot.core.utils.io import download_file from .qqofficial_message_event import QQOfficialMessageEvent -# remove logger handler for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) -# QQ 机器人官方框架 class botClient(Client): def set_platform(self, platform: QQOfficialPlatformAdapter) -> None: self.platform = platform - # 收到群消息 async def on_group_at_message_create( self, message: botpy.message.GroupMessage ) -> None: abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( - message, - MessageType.GROUP_MESSAGE, + message, MessageType.GROUP_MESSAGE ) - abm.group_id = cast(str, message.group_openid) + abm.group_id = message.group_openid abm.session_id = abm.group_id self.platform.remember_session_scene(abm.session_id, "group") self._commit(abm) - # 收到频道消息 async def on_at_message_create(self, message: botpy.message.Message) -> None: abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( - message, - MessageType.GROUP_MESSAGE, + message, MessageType.GROUP_MESSAGE ) abm.group_id = message.channel_id abm.session_id = abm.group_id self.platform.remember_session_scene(abm.session_id, "channel") self._commit(abm) - # 收到私聊消息 async def on_direct_message_create( self, message: botpy.message.DirectMessage ) -> None: abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( - message, - MessageType.FRIEND_MESSAGE, + message, MessageType.FRIEND_MESSAGE ) abm.session_id = abm.sender.user_id self.platform.remember_session_scene(abm.session_id, "friend") self._commit(abm) - # 收到 C2C 消息 async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None: abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( - message, - MessageType.FRIEND_MESSAGE, + message, MessageType.FRIEND_MESSAGE ) abm.session_id = abm.sender.user_id self.platform.remember_session_scene(abm.session_id, "friend") @@ -98,25 +88,20 @@ class botClient(Client): self.platform.meta(), abm.session_id, self.platform.client, - ), + ) ) @register_platform_adapter("qq_official", "QQ 机器人官方 API 适配器") class QQOfficialPlatformAdapter(Platform): def __init__( - self, - platform_config: dict, - platform_settings: dict, - event_queue: asyncio.Queue, + self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue ) -> None: super().__init__(platform_config, event_queue) - self.appid = platform_config["appid"] self.secret = platform_config["secret"] qq_group = platform_config["enable_group_c2c"] guild_dm = platform_config["enable_guild_direct_message"] - if qq_group: self.intents = botpy.Intents( public_messages=True, @@ -125,33 +110,21 @@ class QQOfficialPlatformAdapter(Platform): ) else: self.intents = botpy.Intents( - public_guild_messages=True, - direct_message=guild_dm, + public_guild_messages=True, direct_message=guild_dm ) - self.client = botClient( - intents=self.intents, - bot_log=False, - timeout=20, - ) - + self.client = botClient(intents=self.intents, bot_log=False, timeout=20) self.client.set_platform(self) - self._session_last_message_id: dict[str, str] = {} self._session_scene: dict[str, str] = {} - self.test_mode = os.environ.get("TEST_MODE", "off") == "on" async def send_by_session( - self, - session: MessageSesion, - message_chain: MessageChain, + self, session: MessageSesion, message_chain: MessageChain ) -> None: await self._send_by_session_common(session, message_chain) async def _send_by_session_common( - self, - session: MessageSesion, - message_chain: MessageChain, + self, session: MessageSesion, message_chain: MessageChain ) -> None: ( plain_text, @@ -164,14 +137,13 @@ class QQOfficialPlatformAdapter(Platform): ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain) if ( not plain_text - and not image_path - and not image_base64 - and not record_file_path - and not video_file_source - and not file_source + and (not image_path) + and (not image_base64) + and (not record_file_path) + and (not video_file_source) + and (not file_source) ): return - msg_id = self._session_last_message_id.get(session.session_id) if not msg_id: logger.warning( @@ -179,11 +151,9 @@ class QQOfficialPlatformAdapter(Platform): session.session_id, ) return - payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id} ret: Any = None - send_helper = cast(Any, SimpleNamespace(bot=self.client)) - + send_helper = SimpleNamespace(bot=self.client) if session.message_type == MessageType.GROUP_MESSAGE: scene = self._session_scene.get(session.session_id) if scene == "group": @@ -231,20 +201,15 @@ class QQOfficialPlatformAdapter(Platform): payload["msg_type"] = 7 payload.pop("msg_id", None) ret = await self.client.api.post_group_message( - group_openid=session.session_id, - **payload, + group_openid=session.session_id, **payload ) else: if image_path: payload["file_image"] = image_path ret = await self.client.api.post_message( - channel_id=session.session_id, - **payload, + channel_id=session.session_id, **payload ) - elif session.message_type == MessageType.FRIEND_MESSAGE: - # 参考 https://bot.q.qq.com/wiki/develop/pythonsdk/api/message/post_message.html - # msg_id 缺失时认为是主动推送,而似乎至少在私聊上主动推送是没有被限制的,这里直接移除 msg_id 可以避免越权或 msg_id 不可用的bug payload.pop("msg_id", None) payload["msg_seq"] = random.randint(1, 10000) if image_base64: @@ -287,11 +252,8 @@ class QQOfficialPlatformAdapter(Platform): if media: payload["media"] = media payload["msg_type"] = 7 - ret = await QQOfficialMessageEvent.post_c2c_message( - send_helper, - openid=session.session_id, - **payload, + send_helper, openid=session.session_id, **payload ) else: logger.warning( @@ -299,7 +261,6 @@ class QQOfficialPlatformAdapter(Platform): session.message_type, ) return - sent_message_id = self._extract_message_id(ret) if sent_message_id: self.remember_session_message_id(session.session_id, sent_message_id) @@ -328,7 +289,7 @@ class QQOfficialPlatformAdapter(Platform): return PlatformMetadata( name="qq_official", description="QQ 机器人官方 API 适配器", - id=cast(str, self.config.get("id")), + id=self.config.get("id"), support_proactive_message=True, ) @@ -341,72 +302,45 @@ class QQOfficialPlatformAdapter(Platform): return f"https://{url}" @staticmethod - async def _prepare_audio_attachment( - url: str, - filename: str, - ) -> Record: + async def _prepare_audio_attachment(url: str, filename: str) -> Record: temp_dir = anyio.Path(get_astrbot_temp_path()) await temp_dir.mkdir(parents=True, exist_ok=True) - ext = Path(filename).suffix.lower() source_ext = ext or ".audio" source_path = temp_dir / f"qqofficial_{uuid.uuid4().hex}{source_ext}" await download_file(url, str(source_path)) - return Record(file=str(source_path), url=str(source_path)) @staticmethod async def _append_attachments( - msg: list[BaseMessageComponent], - attachments: list | None, + msg: list[BaseMessageComponent], attachments: list | None ) -> None: if not attachments: return - for attachment in attachments: - content_type = cast( - str, - getattr(attachment, "content_type", "") or "", - ).lower() + content_type = (getattr(attachment, "content_type", "") or "").lower() url = QQOfficialPlatformAdapter._normalize_attachment_url( - cast(str | None, getattr(attachment, "url", None)) + getattr(attachment, "url", None) ) if not url: continue - if content_type.startswith("image"): msg.append(Image.fromURL(url)) else: - filename = cast( - str, + filename = ( getattr(attachment, "filename", None) or getattr(attachment, "name", None) - or "attachment", + or "attachment" ) ext = Path(filename).suffix.lower() image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} - audio_exts = { - ".mp3", - ".wav", - ".ogg", - ".m4a", - ".amr", - ".silk", - } - video_exts = { - ".mp4", - ".mov", - ".avi", - ".mkv", - ".webm", - } - + audio_exts = {".mp3", ".wav", ".ogg", ".m4a", ".amr", ".silk"} + video_exts = {".mp4", ".mov", ".avi", ".mkv", ".webm"} if content_type.startswith("voice") or ext in audio_exts: try: msg.append( await QQOfficialPlatformAdapter._prepare_audio_attachment( - url, - filename, + url, filename ) ) except Exception as e: @@ -445,12 +379,10 @@ class QQOfficialPlatformAdapter(Platform): def replace_face(match): face_tag = match.group(0) - # Extract ext field from the face tag - ext_match = re.search(r'ext="([^"]*)"', face_tag) + ext_match = re.search('ext="([^"]*)"', face_tag) if ext_match: try: ext_encoded = ext_match.group(1) - # Decode base64 and parse JSON ext_decoded = base64.b64decode(ext_encoded).decode("utf-8") ext_data = json.loads(ext_decoded) emoji_text = ext_data.get("text", "") @@ -458,11 +390,9 @@ class QQOfficialPlatformAdapter(Platform): return f"[表情:{emoji_text}]" except Exception: pass - # Fallback if parsing fails return "[表情]" - # Match face tags: - return re.sub(r"]*>", replace_face, content) + return re.sub("]*>", replace_face, content) @staticmethod async def _parse_from_qqofficial( @@ -477,19 +407,15 @@ class QQOfficialPlatformAdapter(Platform): abm.timestamp = int(time.time()) abm.raw_message = message abm.message_id = message.id - # abm.tag = "qq_official" msg: list[BaseMessageComponent] = [] - if isinstance(message, botpy.message.GroupMessage) or isinstance( - message, - botpy.message.C2CMessage, + message, botpy.message.C2CMessage ): if isinstance(message, botpy.message.GroupMessage): abm.sender = MessageMember(message.author.member_openid, "") abm.group_id = message.group_openid else: abm.sender = MessageMember(message.author.user_openid, "") - # Parse face messages to readable text abm.message_str = QQOfficialPlatformAdapter._parse_face_message( message.content.strip() ) @@ -500,35 +426,26 @@ class QQOfficialPlatformAdapter(Platform): msg, message.attachments ) abm.message = msg - elif isinstance(message, botpy.message.Message) or isinstance( - message, - botpy.message.DirectMessage, + message, botpy.message.DirectMessage ): if isinstance(message, botpy.message.Message): abm.self_id = str(message.mentions[0].id) else: abm.self_id = "" - plain_content = QQOfficialPlatformAdapter._parse_face_message( - message.content.replace( - "<@!" + str(abm.self_id) + ">", - "", - ).strip() + message.content.replace("<@!" + str(abm.self_id) + ">", "").strip() ) - await QQOfficialPlatformAdapter._append_attachments( msg, message.attachments ) abm.message = msg abm.message_str = plain_content abm.sender = MessageMember( - str(message.author.id), - str(message.author.username), + str(message.author.id), str(message.author.username) ) msg.append(At(qq="qq_official")) msg.append(Plain(plain_content)) - if isinstance(message, botpy.message.Message): abm.group_id = message.channel_id else: diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py index 11c1fc846..968434fd5 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -1,6 +1,6 @@ import asyncio import logging -from typing import Any, cast +from typing import Any import botpy import botpy.message @@ -19,57 +19,47 @@ from astrbot.core.utils.webhook_utils import log_webhook_info from .qo_webhook_event import QQOfficialWebhookMessageEvent from .qo_webhook_server import QQOfficialWebhook -# remove logger handler for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) -# QQ 机器人官方框架 class botClient(Client): def set_platform(self, platform: "QQOfficialWebhookPlatformAdapter") -> None: self.platform = platform - # 收到群消息 async def on_group_at_message_create( self, message: botpy.message.GroupMessage ) -> None: abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( - message, - MessageType.GROUP_MESSAGE, + message, MessageType.GROUP_MESSAGE ) - abm.group_id = cast(str, message.group_openid) + abm.group_id = message.group_openid abm.session_id = abm.group_id self.platform.remember_session_scene(abm.session_id, "group") self._commit(abm) - # 收到频道消息 async def on_at_message_create(self, message: botpy.message.Message) -> None: abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( - message, - MessageType.GROUP_MESSAGE, + message, MessageType.GROUP_MESSAGE ) abm.group_id = message.channel_id abm.session_id = abm.group_id self.platform.remember_session_scene(abm.session_id, "channel") self._commit(abm) - # 收到私聊消息 async def on_direct_message_create( self, message: botpy.message.DirectMessage ) -> None: abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( - message, - MessageType.FRIEND_MESSAGE, + message, MessageType.FRIEND_MESSAGE ) abm.session_id = abm.sender.user_id self.platform.remember_session_scene(abm.session_id, "friend") self._commit(abm) - # 收到 C2C 消息 async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None: abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( - message, - MessageType.FRIEND_MESSAGE, + message, MessageType.FRIEND_MESSAGE ) abm.session_id = abm.sender.user_id self.platform.remember_session_scene(abm.session_id, "friend") @@ -79,53 +69,34 @@ class botClient(Client): self.platform.remember_session_message_id(abm.session_id, abm.message_id) self.platform.commit_event( QQOfficialWebhookMessageEvent( - abm.message_str, - abm, - self.platform.meta(), - abm.session_id, - self, - ), + abm.message_str, abm, self.platform.meta(), abm.session_id, self + ) ) @register_platform_adapter("qq_official_webhook", "QQ 机器人官方 API 适配器(Webhook)") class QQOfficialWebhookPlatformAdapter(Platform): def __init__( - self, - platform_config: dict, - platform_settings: dict, - event_queue: asyncio.Queue, + self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue ) -> None: super().__init__(platform_config, event_queue) - self.appid = platform_config["appid"] self.secret = platform_config["secret"] self.unified_webhook_mode = platform_config.get("unified_webhook_mode", False) - intents = botpy.Intents( - public_messages=True, - public_guild_messages=True, - direct_message=True, - ) - self.client = botClient( - intents=intents, # 已经无用 - bot_log=False, - timeout=20, + public_messages=True, public_guild_messages=True, direct_message=True ) + self.client = botClient(intents=intents, bot_log=False, timeout=20) self.client.set_platform(self) self.webhook_helper = None self._session_last_message_id: dict[str, str] = {} self._session_scene: dict[str, str] = {} async def send_by_session( - self, - session: MessageSesion, - message_chain: MessageChain, + self, session: MessageSesion, message_chain: MessageChain ) -> None: await QQOfficialPlatformAdapter._send_by_session_common( - cast(Any, self), - session, - message_chain, + self, session, message_chain ) def remember_session_message_id(self, session_id: str, message_id: str) -> None: @@ -151,23 +122,18 @@ class QQOfficialWebhookPlatformAdapter(Platform): return PlatformMetadata( name="qq_official_webhook", description="QQ 机器人官方 API 适配器", - id=cast(str, self.config.get("id")), + id=self.config.get("id"), support_proactive_message=True, ) async def run(self) -> None: self.webhook_helper = QQOfficialWebhook( - self.config, - self._event_queue, - self.client, + self.config, self._event_queue, self.client ) await self.webhook_helper.initialize() - - # 如果启用统一 webhook 模式,则不启动独立服务器 webhook_uuid = self.config.get("webhook_uuid") if self.unified_webhook_mode and webhook_uuid: log_webhook_info(f"{self.meta().id}(QQ 官方机器人 Webhook)", webhook_uuid) - # 保持运行状态,等待 shutdown await self.webhook_helper.shutdown_event.wait() else: await self.webhook_helper.start_polling() @@ -178,16 +144,14 @@ class QQOfficialWebhookPlatformAdapter(Platform): async def webhook_callback(self, request: Any) -> Any: """统一 Webhook 回调入口""" if not self.webhook_helper: - return {"error": "Webhook helper not initialized"}, 500 - - # 复用 webhook_helper 的回调处理逻辑 + return ({"error": "Webhook helper not initialized"}, 500) return await self.webhook_helper.handle_callback(request) async def terminate(self) -> None: if self.webhook_helper: self.webhook_helper.shutdown_event.set() await self.client.close() - if self.webhook_helper and not self.unified_webhook_mode: + if self.webhook_helper and (not self.unified_webhook_mode): try: await self.webhook_helper.server.shutdown() except Exception as exc: diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py index 5009c854b..eb92b6c79 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -1,7 +1,6 @@ import asyncio import logging import time -from typing import cast import quart from botpy import BotAPI, BotHttp, BotWebSocket, Client, ConnectionSession, Token @@ -9,7 +8,6 @@ from cryptography.hazmat.primitives.asymmetric import ed25519 from astrbot.api import logger -# remove logger handler for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) @@ -23,32 +21,25 @@ class QQOfficialWebhook: self.port = config.get("port", 6196) self.is_sandbox = config.get("is_sandbox", False) self.callback_server_host = config.get("callback_server_host", "0.0.0.0") - if isinstance(self.port, str): self.port = int(self.port) - self.http: BotHttp = BotHttp(timeout=300, is_sandbox=self.is_sandbox) self.api: BotAPI = BotAPI(http=self.http) self.token = Token(self.appid, self.secret) - self.server = quart.Quart(__name__) self.server.add_url_rule( - "/astrbot-qo-webhook/callback", - view_func=self.callback, - methods=["POST"], + "/astrbot-qo-webhook/callback", view_func=self.callback, methods=["POST"] ) self.client = botpy_client self.event_queue = event_queue self.shutdown_event = asyncio.Event() - # Deduplication cache for webhook retry callbacks. self._seen_event_ids: dict[str, float] = {} - self._dedup_ttl: int = 60 # seconds + self._dedup_ttl: int = 60 async def initialize(self) -> None: logger.info("正在登录到 QQ 官方机器人...") self.user = await self.http.login(self.token) logger.info(f"已登录 QQ 官方机器人账号: {self.user}") - # 直接注入到 botpy 的 Client,移花接木! self.client.api = self.api self.client.http = self.http @@ -73,10 +64,8 @@ class QQOfficialWebhook: seed = await self.repeat_seed(self.secret) private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed) msg = validation_payload.get("event_ts", "") + validation_payload.get( - "plain_token", - "", + "plain_token", "" ) - # sign signature = private_key.sign(msg.encode()).hex() response = { "plain_token": validation_payload.get("plain_token"), @@ -99,20 +88,15 @@ class QQOfficialWebhook: """ msg: dict = await request.json logger.debug(f"收到 qq_official_webhook 回调: {msg}") - event = msg.get("t") opcode = msg.get("op") data = msg.get("d") - if opcode == 13: - # validation - signed = await self.webhook_validation(cast(dict, data)) + signed = await self.webhook_validation(data) return signed - event_id = msg.get("id") if event_id: now = time.monotonic() - # Lazily evict expired entries to prevent unbounded growth. expired = [ k for k, ts in self._seen_event_ids.items() @@ -124,7 +108,6 @@ class QQOfficialWebhook: logger.debug(f"Duplicate webhook event {event_id!r}, skipping.") return {"opcode": 12} self._seen_event_ids[event_id] = now - if event and opcode == BotWebSocket.WS_DISPATCH_EVENT: event = msg["t"].lower() try: @@ -133,12 +116,11 @@ class QQOfficialWebhook: logger.error("_parser unknown event %s.", event) else: func(msg) - return {"opcode": 12} async def start_polling(self) -> None: logger.info( - f"将在 {self.callback_server_host}:{self.port} 端口启动 QQ 官方机器人 webhook 适配器。", + f"将在 {self.callback_server_host}:{self.port} 端口启动 QQ 官方机器人 webhook 适配器。" ) await self.server.run_task( host=self.callback_server_host, diff --git a/astrbot/core/platform/sources/satori/satori_event.py b/astrbot/core/platform/sources/satori/satori_event.py index 414a876e3..6773cce51 100644 --- a/astrbot/core/platform/sources/satori/satori_event.py +++ b/astrbot/core/platform/sources/satori/satori_event.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain @@ -29,7 +29,6 @@ class SatoriPlatformEvent(AstrMessageEvent): session_id: str, adapter: "SatoriPlatformAdapter", ) -> None: - # 更新平台元数据 if adapter and hasattr(adapter, "logins") and adapter.logins: current_login = adapter.logins[0] platform_name = current_login.get("platform", "satori") @@ -37,7 +36,6 @@ class SatoriPlatformEvent(AstrMessageEvent): user_id = user.get("id", "") if user else "" if not platform_meta.id and user_id: platform_meta.id = f"{platform_name}({user_id})" - super().__init__(message_str, message_obj, platform_meta, session_id) self.adapter = adapter self.platform = None @@ -47,7 +45,7 @@ class SatoriPlatformEvent(AstrMessageEvent): and message_obj.raw_message and isinstance(message_obj.raw_message, dict) ): - raw_message = cast(dict[str, Any], message_obj.raw_message) + raw_message = message_obj.raw_message login = raw_message.get("login", {}) self.platform = login.get("platform") user = login.get("user", {}) @@ -55,58 +53,40 @@ class SatoriPlatformEvent(AstrMessageEvent): @classmethod async def send_with_adapter( - cls, - adapter: "SatoriPlatformAdapter", - message: MessageChain, - session_id: str, + cls, adapter: "SatoriPlatformAdapter", message: MessageChain, session_id: str ): try: content_parts = [] - for component in message.chain: component_content = await cls._convert_component_to_satori_static( - component, + component ) if component_content: content_parts.append(component_content) - - # 特殊处理 Node 和 Nodes 组件 if isinstance(component, Node): - # 单个转发节点 node_content = await cls._convert_node_to_satori_static(component) if node_content: content_parts.append(node_content) - elif isinstance(component, Nodes): - # 合并转发消息 node_content = await cls._convert_nodes_to_satori_static(component) if node_content: content_parts.append(node_content) - content = "".join(content_parts) channel_id = session_id data = {"channel_id": channel_id, "content": content} - platform = None user_id = None - if hasattr(adapter, "logins") and adapter.logins: current_login = adapter.logins[0] platform = current_login.get("platform", "") user = current_login.get("user", {}) user_id = user.get("id", "") if user else "" - result = await adapter.send_http_request( - "POST", - "/message.create", - data, - platform, - user_id, + "POST", "/message.create", data, platform, user_id ) if result: return result return None - except Exception as e: logger.error(f"Satori 消息发送异常: {e}") return None @@ -114,57 +94,41 @@ class SatoriPlatformEvent(AstrMessageEvent): async def send(self, message: MessageChain) -> None: platform = getattr(self, "platform", None) user_id = getattr(self, "user_id", None) - if not platform or not user_id: if hasattr(self.adapter, "logins") and self.adapter.logins: current_login = self.adapter.logins[0] platform = current_login.get("platform", "") user = current_login.get("user", {}) user_id = user.get("id", "") if user else "" - try: content_parts = [] - for component in message.chain: component_content = await self._convert_component_to_satori(component) if component_content: content_parts.append(component_content) - - # 特殊处理 Node 和 Nodes 组件 if isinstance(component, Node): - # 单个转发节点 node_content = await self._convert_node_to_satori(component) if node_content: content_parts.append(node_content) - elif isinstance(component, Nodes): - # 合并转发消息 node_content = await self._convert_nodes_to_satori(component) if node_content: content_parts.append(node_content) - content = "".join(content_parts) channel_id = self.session_id data = {"channel_id": channel_id, "content": content} - result = await self.adapter.send_http_request( - "POST", - "/message.create", - data, - platform, - user_id, + "POST", "/message.create", data, platform, user_id ) if not result: logger.error("Satori 消息发送失败") except Exception as e: logger.error(f"Satori 消息发送异常: {e}") - await super().send(message) async def send_streaming(self, generator, use_fallback: bool = False): try: content_parts: list[str] = [] - async for chain in generator: if isinstance(chain, MessageChain): if chain.type == "break": @@ -174,7 +138,6 @@ class SatoriPlatformEvent(AstrMessageEvent): await self.send(temp_chain) content_parts = [] continue - for component in chain.chain: if isinstance(component, Plain): content_parts.append(component.text) @@ -190,24 +153,21 @@ class SatoriPlatformEvent(AstrMessageEvent): img_chain = MessageChain( [ Plain( - text=f'', - ), - ], + text=f'' + ) + ] ) await self.send(img_chain) except Exception as e: logger.error(f"图片转换为base64失败: {e}") else: content_parts.append(str(component)) - if content_parts: content = "".join(content_parts) temp_chain = MessageChain([Plain(text=content)]) await self.send(temp_chain) - except Exception as e: logger.error(f"Satori 流式消息发送异常: {e}") - return await super().send_streaming(generator, use_fallback) async def _convert_component_to_satori(self, component) -> str: @@ -220,13 +180,11 @@ class SatoriPlatformEvent(AstrMessageEvent): .replace(">", ">") ) return text - if isinstance(component, At): if component.qq: return f'' if component.name: return f'' - elif isinstance(component, Image): try: image_base64 = await component.convert_to_base64() @@ -234,12 +192,8 @@ class SatoriPlatformEvent(AstrMessageEvent): return f'' except Exception as e: logger.error(f"图片转换为base64失败: {e}") - elif isinstance(component, File): - return ( - f'' - ) - + return f"""""" elif isinstance(component, Record): try: record_base64 = await component.convert_to_base64() @@ -247,10 +201,8 @@ class SatoriPlatformEvent(AstrMessageEvent): return f'