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''
except Exception as e:
logger.error(f"语音转换为base64失败: {e}")
-
elif isinstance(component, Reply):
return f''
-
elif isinstance(component, Video):
try:
video_path_url = await component.convert_to_file_path()
@@ -258,13 +210,9 @@ class SatoriPlatformEvent(AstrMessageEvent):
return f''
except Exception as e:
logger.error(f"视频文件转换失败: {e}")
-
elif isinstance(component, Forward):
return f''
-
- # 对于其他未处理的组件类型,返回空字符串
return ""
-
except Exception as e:
logger.error(f"转换消息组件失败: {e}")
return ""
@@ -276,28 +224,20 @@ class SatoriPlatformEvent(AstrMessageEvent):
if node.content:
for content_component in node.content:
component_content = await self._convert_component_to_satori(
- content_component,
+ content_component
)
if component_content:
content_parts.append(component_content)
-
content = "".join(content_parts)
-
- # 如果内容为空,添加默认内容
if not content.strip():
content = "[转发消息]"
-
- # 构建 Satori 格式的转发节点
author_attrs = []
if node.uin:
author_attrs.append(f'id="{node.uin}"')
if node.name:
author_attrs.append(f'name="{node.name}"')
-
author_attr_str = " ".join(author_attrs)
-
return f"{content}"
-
except Exception as e:
logger.error(f"转换转发节点失败: {e}")
return ""
@@ -313,13 +253,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()
@@ -327,12 +265,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()
@@ -340,10 +274,8 @@ class SatoriPlatformEvent(AstrMessageEvent):
return f''
except Exception as e:
logger.error(f"语音转换为base64失败: {e}")
-
elif isinstance(component, Reply):
return f''
-
elif isinstance(component, Video):
try:
video_path_url = await component.convert_to_file_path()
@@ -351,13 +283,9 @@ class SatoriPlatformEvent(AstrMessageEvent):
return f''
except Exception as e:
logger.error(f"视频文件转换失败: {e}")
-
elif isinstance(component, Forward):
return f''
-
- # 对于其他未处理的组件类型,返回空字符串
return ""
-
except Exception as e:
logger.error(f"转换消息组件失败: {e}")
return ""
@@ -370,27 +298,20 @@ class SatoriPlatformEvent(AstrMessageEvent):
if node.content:
for content_component in node.content:
component_content = await cls._convert_component_to_satori_static(
- content_component,
+ content_component
)
if component_content:
content_parts.append(component_content)
-
content = "".join(content_parts)
-
- # 如果内容为空,添加默认内容
if not content.strip():
content = "[转发消息]"
-
author_attrs = []
if node.uin:
author_attrs.append(f'id="{node.uin}"')
if node.name:
author_attrs.append(f'name="{node.name}"')
-
author_attr_str = " ".join(author_attrs)
-
return f"{content}"
-
except Exception as e:
logger.error(f"转换转发节点失败: {e}")
return ""
@@ -399,16 +320,13 @@ class SatoriPlatformEvent(AstrMessageEvent):
"""将多个转发节点转换为 Satori 格式的合并转发"""
try:
node_parts = []
-
for node in nodes.nodes:
node_content = await self._convert_node_to_satori(node)
if node_content:
node_parts.append(node_content)
-
if node_parts:
return f"{''.join(node_parts)}"
return ""
-
except Exception as e:
logger.error(f"转换合并转发消息失败: {e}")
return ""
@@ -418,16 +336,13 @@ class SatoriPlatformEvent(AstrMessageEvent):
"""将多个转发节点转换为 Satori 格式的合并转发"""
try:
node_parts = []
-
for node in nodes.nodes:
node_content = await cls._convert_node_to_satori_static(node)
if node_content:
node_parts.append(node_content)
-
if node_parts:
return f"{''.join(node_parts)}"
return ""
-
except Exception as e:
logger.error(f"转换合并转发消息失败: {e}")
return ""
diff --git a/astrbot/core/platform/sources/slack/client.py b/astrbot/core/platform/sources/slack/client.py
index c10f3cd17..a843681b6 100644
--- a/astrbot/core/platform/sources/slack/client.py
+++ b/astrbot/core/platform/sources/slack/client.py
@@ -4,7 +4,6 @@ import hmac
import json
import logging
from collections.abc import Callable
-from typing import cast
from quart import Quart, Response, request
from slack_sdk.socket_mode.aiohttp import SocketModeClient
@@ -34,14 +33,10 @@ class SlackWebhookClient:
self.port = port
self.path = path
self.event_handler = event_handler
-
self.app = Quart(__name__)
self._setup_routes()
-
- # 禁用 Quart 的默认日志输出
logging.getLogger("quart.app").setLevel(logging.WARNING)
logging.getLogger("quart.serving").setLevel(logging.WARNING)
-
self.shutdown_event = asyncio.Event()
def _setup_routes(self) -> None:
@@ -67,16 +62,12 @@ class SlackWebhookClient:
Response 对象或字典
"""
try:
- # 获取请求体和头部
- body = cast(bytes, await req.get_data())
+ body = await req.get_data()
event_data = json.loads(body.decode("utf-8"))
-
- # Verify Slack request signature
timestamp = req.headers.get("X-Slack-Request-Timestamp")
signature = req.headers.get("X-Slack-Signature")
if not timestamp or not signature:
return Response("Missing headers", status=400)
- # Calculate the HMAC signature
sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
my_signature = (
"v0="
@@ -86,21 +77,15 @@ class SlackWebhookClient:
hashlib.sha256,
).hexdigest()
)
- # Verify the signature
if not hmac.compare_digest(my_signature, signature):
logger.warning("Slack request signature verification failed")
return Response("Invalid signature", status=400)
logger.info(f"Received Slack event: {event_data}")
-
- # 处理 URL 验证事件
if event_data.get("type") == "url_verification":
return {"challenge": event_data.get("challenge")}
- # 处理事件
if self.event_handler and event_data.get("type") == "event_callback":
await self.event_handler(event_data)
-
return Response("", status=200)
-
except Exception as e:
logger.error(f"处理 Slack 事件时出错: {e}")
return Response("Internal Server Error", status=500)
@@ -108,9 +93,8 @@ class SlackWebhookClient:
async def start(self) -> None:
"""启动 Webhook 服务器"""
logger.info(
- f"Slack Webhook 服务器启动中,监听 {self.host}:{self.port}{self.path}...",
+ f"Slack Webhook 服务器启动中,监听 {self.host}:{self.port}{self.path}..."
)
-
await self.app.run_task(
host=self.host,
port=self.port,
@@ -148,29 +132,19 @@ class SlackSocketClient:
try:
if self.socket_client is None:
raise RuntimeError("Socket client is not initialized")
-
- # 确认收到事件
response = SocketModeResponse(envelope_id=req.envelope_id)
await self.socket_client.send_socket_mode_response(response)
-
- # 处理事件
if self.event_handler:
await self.event_handler(req)
-
except Exception as e:
logger.error(f"处理 Socket Mode 事件时出错: {e}")
async def start(self) -> None:
"""启动 Socket Mode 连接"""
self.socket_client = SocketModeClient(
- app_token=self.app_token,
- logger=logger,
- web_client=self.web_client,
+ app_token=self.app_token, logger=logger, web_client=self.web_client
)
-
- # 注册事件处理器
self.socket_client.socket_mode_request_listeners.append(self._handle_events)
-
logger.info("Slack Socket Mode 客户端启动中...")
await self.socket_client.connect()
diff --git a/astrbot/core/platform/sources/slack/slack_adapter.py b/astrbot/core/platform/sources/slack/slack_adapter.py
index 54c729a62..8ff272c1b 100644
--- a/astrbot/core/platform/sources/slack/slack_adapter.py
+++ b/astrbot/core/platform/sources/slack/slack_adapter.py
@@ -3,7 +3,7 @@ import base64
import re
import time
import uuid
-from typing import Any, cast
+from typing import Any
import aiohttp
from slack_sdk.socket_mode.request import SocketModeRequest
@@ -34,14 +34,10 @@ from .slack_event import SlackMessageEvent
)
class SlackAdapter(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.bot_token = platform_config.get("bot_token")
self.app_token = platform_config.get("app_token")
self.signing_secret = platform_config.get("signing_secret")
@@ -50,58 +46,42 @@ class SlackAdapter(Platform):
self.webhook_host = platform_config.get("slack_webhook_host", "0.0.0.0")
self.webhook_port = platform_config.get("slack_webhook_port", 3000)
self.webhook_path = platform_config.get(
- "slack_webhook_path",
- "/astrbot-slack-webhook/callback",
+ "slack_webhook_path", "/astrbot-slack-webhook/callback"
)
-
if not self.bot_token:
raise ValueError("Slack bot_token 是必需的")
-
- if self.connection_mode == "socket" and not self.app_token:
+ if self.connection_mode == "socket" and (not self.app_token):
raise ValueError("Socket Mode 需要 app_token")
-
- if self.connection_mode == "webhook" and not self.signing_secret:
+ if self.connection_mode == "webhook" and (not self.signing_secret):
raise ValueError("Webhook Mode 需要 signing_secret")
-
self.metadata = PlatformMetadata(
name="slack",
description="适用于 Slack 的消息平台适配器,支持 Socket Mode 和 Webhook Mode。",
- id=cast(str, self.config.get("id")),
+ id=self.config.get("id"),
support_streaming_message=False,
)
-
- # 初始化 Slack Web Client
self.web_client = AsyncWebClient(token=self.bot_token, logger=logger)
self.socket_client = None
self.webhook_client = None
-
self.bot_self_id = None
async def send_by_session(
- self,
- session: MessageSesion,
- message_chain: MessageChain,
+ self, session: MessageSesion, message_chain: MessageChain
) -> None:
blocks, text = await SlackMessageEvent._parse_slack_blocks(
- message_chain=message_chain,
- web_client=self.web_client,
+ message_chain=message_chain, web_client=self.web_client
)
-
try:
if session.message_type == MessageType.GROUP_MESSAGE:
- # 发送到频道
channel_id = (
session.session_id.split("_")[-1]
if "_" in session.session_id
else session.session_id
)
await self.web_client.chat_postMessage(
- channel=channel_id,
- text=text,
- blocks=blocks if blocks else None,
+ channel=channel_id, text=text, blocks=blocks if blocks else None
)
else:
- # 发送私信
await self.web_client.chat_postMessage(
channel=session.session_id,
text=text,
@@ -109,87 +89,64 @@ class SlackAdapter(Platform):
)
except Exception as e:
logger.error(f"Slack 发送消息失败: {e}")
-
await super().send_by_session(session, message_chain)
async def convert_message(self, event: dict) -> AstrBotMessage:
logger.debug(f"[slack] RawMessage {event}")
-
abm = AstrBotMessage()
- abm.self_id = cast(str, self.bot_self_id)
-
- # 获取用户信息
+ abm.self_id = self.bot_self_id
user_id = event.get("user", "")
try:
user_info = await self.web_client.users_info(user=user_id)
- user_data = cast(dict, user_info["user"])
+ user_data = user_info["user"]
user_name = user_data.get("real_name") or user_data.get("name", user_id)
except Exception:
user_name = user_id
-
abm.sender = MessageMember(user_id=user_id, nickname=user_name)
-
- # 判断消息类型
channel_id = event.get("channel", "")
try:
channel_info = await self.web_client.conversations_info(channel=channel_id)
- is_im = cast(dict, channel_info["channel"])["is_im"]
-
+ is_im = channel_info["channel"]["is_im"]
if is_im:
abm.type = MessageType.FRIEND_MESSAGE
else:
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = channel_id
except Exception:
- # 默认作为群组消息处理
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = channel_id
-
- # 设置会话ID
if abm.type == MessageType.GROUP_MESSAGE:
abm.session_id = abm.group_id
else:
abm.session_id = user_id
-
abm.message_id = event.get("client_msg_id", uuid.uuid4().hex)
abm.timestamp = int(float(event.get("ts", time.time())))
-
- # 处理消息内容
message_text = event.get("text", "")
abm.message_str = message_text
abm.message = []
-
- # 优先使用 blocks 字段解析消息
if event.get("blocks"):
abm.message = self._parse_blocks(event["blocks"])
- # 更新 message_str
abm.message_str = ""
for component in abm.message:
if isinstance(component, Plain):
abm.message_str += component.text
elif message_text:
- # 处理传统的文本消息
if "<@" in message_text:
- mentions = re.findall(r"<@([^>]+)>", message_text)
+ mentions = re.findall("<@([^>]+)>", message_text)
for mention in mentions:
try:
mentioned_user = await self.web_client.users_info(user=mention)
- user_data = cast(dict, mentioned_user["user"])
+ user_data = mentioned_user["user"]
user_name = user_data.get("real_name") or user_data.get(
- "name",
- mention,
+ "name", mention
)
abm.message.append(At(qq=mention, name=user_name))
except Exception:
abm.message.append(At(qq=mention, name=""))
-
- # 清理消息文本中的@标记
- if clean_text := re.sub(r"<@[^>]+>", "", message_text).strip():
+ if clean_text := re.sub("<@[^>]+>", "", message_text).strip():
abm.message.append(Plain(text=clean_text))
else:
abm.message.append(Plain(text=message_text))
-
- # 处理文件附件
if "files" in event:
for file_info in event["files"]:
file_name = file_info.get("name", "unknown")
@@ -198,69 +155,51 @@ class SlackAdapter(Platform):
file_url = await self.get_file_base64(file_url)
abm.message.append(Image.fromBase64(base64=file_url))
else:
- # TODO: 下载鉴权
abm.message.append(
- File(name=file_name, file=file_url, url=file_url),
+ File(name=file_name, file=file_url, url=file_url)
)
-
abm.raw_message = event
return abm
def _parse_blocks(self, blocks: list) -> list:
"""解析 Slack blocks 格式的消息内容"""
message_components = []
-
for block in blocks:
block_type = block.get("type", "")
-
if block_type == "rich_text":
- # 处理富文本块
elements = block.get("elements", [])
for element in elements:
if element.get("type") == "rich_text_section":
- # 处理富文本段落
section_elements = element.get("elements", [])
text_parts = []
for section_element in section_elements:
element_type = section_element.get("type", "")
-
if element_type == "text":
- # 普通文本
text_parts.append(section_element.get("text", ""))
elif element_type == "user":
- # @用户提及
user_id = section_element.get("user_id", "")
if user_id:
- # 将之前的文本内容先添加到组件中
text_content = "".join(text_parts)
if text_content.strip():
message_components.append(
- Plain(text=text_content),
+ Plain(text=text_content)
)
text_parts = []
- # 添加@提及组件
message_components.append(At(qq=user_id, name=""))
elif element_type == "channel":
- # #频道提及
channel_id = section_element.get("channel_id", "")
text_parts.append(f"#{channel_id}")
elif element_type == "link":
- # 链接
url = section_element.get("url", "")
link_text = section_element.get("text", url)
text_parts.append(f"[{link_text}]({url})")
elif element_type == "emoji":
- # 表情符号
emoji_name = section_element.get("name", "")
text_parts.append(f":{emoji_name}:")
-
text_content = "".join(text_parts)
-
if text_content.strip():
message_components.append(Plain(text=text_content))
-
elif element.get("type") == "rich_text_list":
- # 处理列表
list_items = element.get("elements", [])
list_text = ""
for item in list_items:
@@ -271,37 +210,28 @@ class SlackAdapter(Platform):
if item_element.get("type") == "text":
item_text += item_element.get("text", "")
list_text += f"• {item_text}\n"
-
if list_text.strip():
message_components.append(Plain(text=list_text.strip()))
-
elif block_type == "section":
- # 处理段落块
if "text" in block:
text_obj = block["text"]
if text_obj.get("type") == "mrkdwn":
text_content = text_obj.get("text", "")
message_components.append(Plain(text=text_content))
-
return message_components
async def _handle_socket_event(self, req: SocketModeRequest) -> None:
"""处理 Socket Mode 事件"""
if req.type == "events_api":
- # 事件 API
event = req.payload.get("event", {})
-
- # 忽略机器人自己的消息和消息编辑
if event.get("subtype") in [
"bot_message",
"message_changed",
"message_deleted",
]:
return
-
if event.get("bot_id"):
return
-
if event.get("type") in ["message", "app_mention"]:
abm = await self.convert_message(event)
if abm:
@@ -321,33 +251,24 @@ class SlackAdapter(Platform):
base64_content = base64.b64encode(content).decode("utf-8")
return base64_content
logger.error(
- f"Failed to download slack file: {resp.status} {await resp.text()}",
+ f"Failed to download slack file: {resp.status} {await resp.text()}"
)
raise Exception(f"下载文件失败: {resp.status}")
async def run(self) -> None:
self.bot_self_id = await self.get_bot_user_id()
logger.info(f"Slack auth test OK. Bot ID: {self.bot_self_id}")
-
if self.connection_mode == "socket":
if not self.app_token:
raise ValueError("Socket Mode 需要 app_token")
-
- # 创建 Socket 客户端
self.socket_client = SlackSocketClient(
- self.web_client,
- self.app_token,
- self._handle_socket_event,
+ self.web_client, self.app_token, self._handle_socket_event
)
-
logger.info("Slack 适配器 (Socket Mode) 启动中...")
await self.socket_client.start()
-
elif self.connection_mode == "webhook":
if not self.signing_secret:
raise ValueError("Webhook Mode 需要 signing_secret")
-
- # 创建 Webhook 客户端
self.webhook_client = SlackWebhookClient(
self.web_client,
self.signing_secret,
@@ -356,39 +277,31 @@ class SlackAdapter(Platform):
self.webhook_path,
self._handle_webhook_event,
)
-
- # 如果启用统一 webhook 模式,则不启动独立服务器
webhook_uuid = self.config.get("webhook_uuid")
if self.unified_webhook_mode and webhook_uuid:
log_webhook_info(f"{self.meta().id}(Slack)", webhook_uuid)
- # 保持运行状态,等待 shutdown
await self.webhook_client.shutdown_event.wait()
else:
logger.info(
- f"Slack 适配器 (Webhook Mode) 启动中,监听 {self.webhook_host}:{self.webhook_port}{self.webhook_path}...",
+ f"Slack 适配器 (Webhook Mode) 启动中,监听 {self.webhook_host}:{self.webhook_port}{self.webhook_path}..."
)
await self.webhook_client.start()
-
else:
raise ValueError(
- f"不支持的连接模式: {self.connection_mode},请使用 'socket' 或 'webhook'",
+ f"不支持的连接模式: {self.connection_mode},请使用 'socket' 或 'webhook'"
)
async def _handle_webhook_event(self, event_data: dict) -> None:
"""处理 Webhook 事件"""
event = event_data.get("event", {})
-
- # 忽略机器人自己的消息和消息编辑
if event.get("subtype") in [
"bot_message",
"message_changed",
"message_deleted",
]:
return
-
if event.get("bot_id"):
return
-
if event.get("type") in ["message", "app_mention"]:
abm = await self.convert_message(event)
if abm:
@@ -397,8 +310,7 @@ class SlackAdapter(Platform):
async def webhook_callback(self, request: Any) -> Any:
"""统一 Webhook 回调入口"""
if self.connection_mode != "webhook" or not self.webhook_client:
- return {"error": "Slack adapter is not in webhook mode"}, 400
-
+ return ({"error": "Slack adapter is not in webhook mode"}, 400)
return await self.webhook_client.handle_callback(request)
async def terminate(self) -> None:
@@ -419,7 +331,6 @@ class SlackAdapter(Platform):
session_id=message.session_id,
web_client=self.web_client,
)
-
self.commit_event(message_event)
def get_client(self):
diff --git a/astrbot/core/platform/sources/slack/slack_event.py b/astrbot/core/platform/sources/slack/slack_event.py
index bf978382e..0e116ad44 100644
--- a/astrbot/core/platform/sources/slack/slack_event.py
+++ b/astrbot/core/platform/sources/slack/slack_event.py
@@ -1,18 +1,12 @@
import asyncio
import re
-from collections.abc import AsyncGenerator, Iterable
-from typing import cast
+from collections.abc import AsyncGenerator
from slack_sdk.web.async_client import AsyncWebClient
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
-from astrbot.api.message_components import (
- BaseMessageComponent,
- File,
- Image,
- Plain,
-)
+from astrbot.api.message_components import BaseMessageComponent, File, Image, Plain
from astrbot.api.platform import Group, MessageMember
@@ -30,47 +24,34 @@ class SlackMessageEvent(AstrMessageEvent):
@staticmethod
async def _from_segment_to_slack_block(
- segment: BaseMessageComponent,
- web_client: AsyncWebClient,
+ segment: BaseMessageComponent, web_client: AsyncWebClient
) -> dict | None:
"""将消息段转换为 Slack 块格式"""
if isinstance(segment, Plain):
return {"type": "section", "text": {"type": "mrkdwn", "text": segment.text}}
if isinstance(segment, Image):
- # upload file
url = segment.url or segment.file
if url and url.startswith("http"):
- return {
- "type": "image",
- "image_url": url,
- "alt_text": "图片",
- }
+ return {"type": "image", "image_url": url, "alt_text": "图片"}
path = await segment.convert_to_file_path()
- response = await web_client.files_upload_v2(
- file=path,
- filename="image.jpg",
- )
+ response = await web_client.files_upload_v2(file=path, filename="image.jpg")
if not response["ok"]:
logger.error(f"Slack file upload failed: {response['error']}")
return {
"type": "section",
"text": {"type": "mrkdwn", "text": "图片上传失败"},
}
- image_url = cast(list, response["files"])[0]["url_private"]
+ image_url = response["files"][0]["url_private"]
logger.debug(f"Slack file upload response: {response}")
return {
"type": "image",
- "slack_file": {
- "url": image_url,
- },
+ "slack_file": {"url": image_url},
"alt_text": "图片",
}
if isinstance(segment, File):
- # upload file
url = segment.url or segment.file
response = await web_client.files_upload_v2(
- file=url,
- filename=segment.name or "file",
+ file=url, filename=segment.name or "file"
)
if not response["ok"]:
logger.error(f"Slack file upload failed: {response['error']}")
@@ -78,7 +59,7 @@ class SlackMessageEvent(AstrMessageEvent):
"type": "section",
"text": {"type": "mrkdwn", "text": "文件上传失败"},
}
- file_url = cast(list, response["files"])[0]["permalink"]
+ file_url = response["files"][0]["permalink"]
return {
"type": "section",
"text": {
@@ -90,66 +71,48 @@ class SlackMessageEvent(AstrMessageEvent):
@staticmethod
async def _parse_slack_blocks(
- message_chain: MessageChain,
- web_client: AsyncWebClient,
+ message_chain: MessageChain, web_client: AsyncWebClient
):
"""解析成 Slack 块格式"""
blocks = []
text_content = ""
-
for segment in message_chain.chain:
if isinstance(segment, Plain):
text_content += segment.text
else:
- # 如果有文本内容,先添加文本块
if text_content.strip():
blocks.append(
{
"type": "section",
"text": {"type": "mrkdwn", "text": text_content},
- },
+ }
)
text_content = ""
-
- # 添加其他类型的块
block = await SlackMessageEvent._from_segment_to_slack_block(
- segment,
- web_client,
+ segment, web_client
)
if block:
blocks.append(block)
-
- # 如果最后还有文本内容
if text_content.strip():
blocks.append(
- {"type": "section", "text": {"type": "mrkdwn", "text": text_content}},
+ {"type": "section", "text": {"type": "mrkdwn", "text": text_content}}
)
-
- return blocks, "" if blocks else text_content
+ return (blocks, "" if blocks else text_content)
async def send(self, message: MessageChain) -> None:
blocks, text = await SlackMessageEvent._parse_slack_blocks(
- message,
- self.web_client,
+ message, self.web_client
)
-
try:
if self.get_group_id():
- # 发送到频道
await self.web_client.chat_postMessage(
- channel=self.get_group_id(),
- text=text,
- blocks=blocks or None,
+ channel=self.get_group_id(), text=text, blocks=blocks or None
)
else:
- # 发送私信
await self.web_client.chat_postMessage(
- channel=self.get_sender_id(),
- text=text,
- blocks=blocks or None,
+ channel=self.get_sender_id(), text=text, blocks=blocks or None
)
except Exception:
- # 如果块发送失败,尝试只发送文本
parts = []
for segment in message.chain:
if isinstance(segment, Plain):
@@ -159,24 +122,18 @@ class SlackMessageEvent(AstrMessageEvent):
elif isinstance(segment, Image):
parts.append(" [图片] ")
fallback_text = "".join(parts)
-
if self.get_group_id():
await self.web_client.chat_postMessage(
- channel=self.get_group_id(),
- text=fallback_text,
+ channel=self.get_group_id(), text=fallback_text
)
else:
await self.web_client.chat_postMessage(
- channel=self.get_sender_id(),
- text=fallback_text,
+ channel=self.get_sender_id(), text=fallback_text
)
-
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
@@ -190,10 +147,8 @@ class SlackMessageEvent(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:
@@ -203,8 +158,7 @@ class SlackMessageEvent(AstrMessageEvent):
buffer = await self.process_buffer(buffer, pattern)
else:
await self.send(MessageChain(chain=[comp]))
- await asyncio.sleep(1.5) # 限速
-
+ await asyncio.sleep(1.5)
if buffer.strip():
await self.send(MessageChain([Plain(buffer)]))
return await super().send_streaming(generator, use_fallback)
@@ -216,38 +170,31 @@ class SlackMessageEvent(AstrMessageEvent):
channel_id = self.get_group_id()
else:
return None
-
try:
- # 获取频道信息
channel_info = await self.web_client.conversations_info(channel=channel_id)
-
- # 获取频道成员
members_response = await self.web_client.conversations_members(
- channel=channel_id,
+ channel=channel_id
)
-
members = []
- for member_id in cast(Iterable, members_response["members"]):
+ for member_id in members_response["members"]:
try:
user_info = await self.web_client.users_info(user=member_id)
- user_data = cast(dict, user_info["user"])
+ user_data = user_info["user"]
members.append(
MessageMember(
user_id=member_id,
nickname=user_data.get("real_name")
or user_data.get("name", member_id),
- ),
+ )
)
except Exception:
- # 如果获取用户信息失败,使用默认信息
members.append(MessageMember(user_id=member_id, nickname=member_id))
-
- channel_data = cast(dict, channel_info["channel"])
+ channel_data = channel_info["channel"]
return Group(
group_id=channel_id,
group_name=channel_data.get("name", ""),
group_avatar="",
- group_admins=[], # Slack 的管理员信息需要特殊权限获取
+ group_admins=[],
group_owner=channel_data.get("creator", ""),
members=members,
)
diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py
index 512f5b56a..320a5d360 100644
--- a/astrbot/core/platform/sources/telegram/tg_adapter.py
+++ b/astrbot/core/platform/sources/telegram/tg_adapter.py
@@ -3,7 +3,6 @@ import os
import re
import sys
import uuid
-from typing import cast
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from telegram import BotCommand, Update
@@ -43,42 +42,30 @@ else:
@register_platform_adapter("telegram", "telegram 适配器")
class TelegramPlatformAdapter(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.client_self_id = uuid.uuid4().hex[:8]
self.sdk_plugin_bridge = None
-
base_url = self.config.get(
- "telegram_api_base_url",
- "https://api.telegram.org/bot",
+ "telegram_api_base_url", "https://api.telegram.org/bot"
)
if not base_url:
base_url = "https://api.telegram.org/bot"
-
file_base_url = self.config.get(
- "telegram_file_base_url",
- "https://api.telegram.org/file/bot",
+ "telegram_file_base_url", "https://api.telegram.org/file/bot"
)
if not file_base_url:
file_base_url = "https://api.telegram.org/file/bot"
-
self.base_url = base_url
-
self.enable_command_register = self.config.get(
- "telegram_command_register",
- True,
+ "telegram_command_register", True
)
self.enable_command_refresh = self.config.get(
- "telegram_command_auto_refresh",
- True,
+ "telegram_command_auto_refresh", True
)
self.last_command_hash = None
-
self.application = (
ApplicationBuilder()
.token(self.config["telegram_token"])
@@ -87,13 +74,11 @@ class TelegramPlatformAdapter(Platform):
.build()
)
message_handler = TelegramMessageHandler(
- filters=filters.ALL, # receive all messages
- callback=self.message_handler,
+ filters=filters.ALL, callback=self.message_handler
)
self.application.add_handler(message_handler)
self.client = self.application.bot
logger.debug(f"Telegram base url: {self.client.base_url}")
-
self.scheduler = AsyncIOScheduler()
self._terminating = False
raw_delay = self.config.get("telegram_polling_restart_delay", 5.0)
@@ -101,42 +86,30 @@ class TelegramPlatformAdapter(Platform):
delay = float(raw_delay)
except (TypeError, ValueError):
logger.warning(
- "Invalid 'telegram_polling_restart_delay' value %r in config, "
- "falling back to default 5.0s",
+ "Invalid 'telegram_polling_restart_delay' value %r in config, falling back to default 5.0s",
raw_delay,
)
delay = 5.0
-
if delay < 0.1:
logger.warning(
- "Configured 'telegram_polling_restart_delay' (%s) is too small; "
- "enforcing minimum of 0.1s to avoid tight restart loops",
+ "Configured 'telegram_polling_restart_delay' (%s) is too small; enforcing minimum of 0.1s to avoid tight restart loops",
delay,
)
delay = 0.1
self._polling_restart_delay = delay
-
- # Media group handling
- # Cache structure: {media_group_id: {"created_at": datetime, "items": [(update, context), ...]}}
self.media_group_cache: dict[str, dict] = {}
- self.media_group_timeout = self.config.get(
- "telegram_media_group_timeout", 2.5
- ) # seconds - debounce delay between messages
+ self.media_group_timeout = self.config.get("telegram_media_group_timeout", 2.5)
self.media_group_max_wait = self.config.get(
"telegram_media_group_max_wait", 10.0
- ) # max seconds - hard cap to prevent indefinite delay
+ )
@override
async def send_by_session(
- self,
- session: MessageSesion,
- message_chain: MessageChain,
+ self, session: MessageSesion, message_chain: MessageChain
) -> None:
from_username = session.session_id
await TelegramPlatformEvent.send_with_client(
- self.client,
- message_chain,
- from_username,
+ self.client, message_chain, from_username
)
await super().send_by_session(session, message_chain)
@@ -149,10 +122,8 @@ class TelegramPlatformAdapter(Platform):
async def run(self) -> None:
await self.application.initialize()
await self.application.start()
-
if self.enable_command_register:
await self.register_commands()
-
if self.enable_command_refresh and self.enable_command_register:
self.scheduler.add_job(
self.register_commands,
@@ -162,11 +133,9 @@ class TelegramPlatformAdapter(Platform):
misfire_grace_time=60,
)
self.scheduler.start()
-
if not self.application.updater:
logger.error("Telegram Updater is not initialized. Cannot start polling.")
return
-
while not self._terminating:
try:
logger.info("Starting Telegram polling...")
@@ -174,16 +143,13 @@ class TelegramPlatformAdapter(Platform):
error_callback=self._on_polling_error
)
logger.info("Telegram Platform Adapter is running.")
- # Wait for termination or polling to stop.
_termination_event = asyncio.Event()
if self._terminating:
_termination_event.set()
await _termination_event.wait()
-
if not self._terminating:
logger.warning(
- "Telegram polling loop exited unexpectedly, "
- f"retrying in {self._polling_restart_delay}s."
+ f"Telegram polling loop exited unexpectedly, retrying in {self._polling_restart_delay}s."
)
except asyncio.CancelledError:
raise
@@ -194,11 +160,8 @@ class TelegramPlatformAdapter(Platform):
break
except Exception as e:
logger.exception(
- "Telegram polling crashed with exception: "
- f"{type(e).__name__}: {e!s}. "
- f"Retrying in {self._polling_restart_delay}s.",
+ f"Telegram polling crashed with exception: {type(e).__name__}: {e!s}. Retrying in {self._polling_restart_delay}s."
)
-
if not self._terminating:
await asyncio.sleep(self._polling_restart_delay)
@@ -212,17 +175,15 @@ class TelegramPlatformAdapter(Platform):
"""收集所有注册的指令并注册到 Telegram"""
try:
commands = self.collect_commands()
-
if commands:
current_hash = hash(
- tuple((cmd.command, cmd.description) for cmd in commands),
+ tuple((cmd.command, cmd.description) for cmd in commands)
)
if current_hash == self.last_command_hash:
return
self.last_command_hash = current_hash
await self.client.delete_my_commands()
await self.client.set_my_commands(commands)
-
except Exception as e:
logger.error(f"向 Telegram 注册指令时发生错误: {e!s}")
@@ -230,7 +191,6 @@ class TelegramPlatformAdapter(Platform):
"""从注册的处理器中收集所有指令"""
command_dict: dict[str, str] = {}
skip_commands = {"start"}
-
for handler_md in star_handlers_registry:
handler_metadata = handler_md
if not star_map[handler_metadata.handler_module_path].activated:
@@ -239,28 +199,23 @@ class TelegramPlatformAdapter(Platform):
continue
for event_filter in handler_metadata.event_filters:
cmd_info_list = self._extract_command_info(
- event_filter,
- handler_metadata,
- skip_commands,
+ event_filter, handler_metadata, skip_commands
)
if cmd_info_list:
for cmd_name, description in cmd_info_list:
if cmd_name in command_dict:
logger.warning(
- f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
- f"'{command_dict[cmd_name]}'"
+ f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: '{command_dict[cmd_name]}'"
)
command_dict.setdefault(cmd_name, description)
-
sdk_bridge = getattr(self, "sdk_plugin_bridge", None)
if sdk_bridge is not None:
for item in sdk_bridge.list_native_command_candidates("telegram"):
cmd_name = str(item.get("name", "")).strip()
if not cmd_name or cmd_name in skip_commands:
continue
- if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
+ if not re.match("^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
continue
-
description = str(item.get("description") or "").strip()
if not description:
if item.get("is_group"):
@@ -269,22 +224,17 @@ class TelegramPlatformAdapter(Platform):
description = f"Command: {cmd_name}"
if len(description) > 30:
description = description[:30] + "..."
-
if cmd_name in command_dict:
logger.warning(
- f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
- f"'{command_dict[cmd_name]}'"
+ f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: '{command_dict[cmd_name]}'"
)
command_dict.setdefault(cmd_name, description)
-
commands_a = sorted(command_dict.keys())
return [BotCommand(cmd, command_dict[cmd]) for cmd in commands_a]
@staticmethod
def _extract_command_info(
- event_filter,
- handler_metadata,
- skip_commands: set,
+ event_filter, handler_metadata, skip_commands: set
) -> list[tuple[str, str]] | None:
"""从事件过滤器中提取指令信息,包括所有别名"""
cmd_names = []
@@ -295,7 +245,6 @@ class TelegramPlatformAdapter(Platform):
and event_filter.parent_command_names != [""]
):
return None
- # 收集主命令名和所有别名
cmd_names = [event_filter.command_name]
if event_filter.alias:
cmd_names.extend(event_filter.alias)
@@ -304,55 +253,43 @@ class TelegramPlatformAdapter(Platform):
return None
cmd_names = [event_filter.group_name]
is_group = True
-
result = []
for cmd_name in cmd_names:
if not cmd_name or cmd_name in skip_commands:
continue
- if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
+ if not re.match("^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
continue
-
- # Build description.
description = handler_metadata.desc or (
f"Command group: {cmd_name}" if is_group else f"Command: {cmd_name}"
)
if len(description) > 30:
description = description[:30] + "..."
result.append((cmd_name, description))
-
return result if result else None
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not update.effective_chat:
logger.warning(
- "Received a start command without an effective chat, skipping /start reply.",
+ "Received a start command without an effective chat, skipping /start reply."
)
return
await context.bot.send_message(
- chat_id=update.effective_chat.id,
- text=self.config["start_message"],
+ chat_id=update.effective_chat.id, text=self.config["start_message"]
)
async def message_handler(
self, update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
logger.debug(f"Telegram message: {update.message}")
-
- # Handle media group messages
if update.message and update.message.media_group_id:
await self.handle_media_group_message(update, context)
return
-
- # Handle regular messages
abm = await self.convert_message(update, context)
if abm:
await self.handle_msg(abm)
async def convert_message(
- self,
- update: Update,
- context: ContextTypes.DEFAULT_TYPE,
- get_reply=True,
+ self, update: Update, context: ContextTypes.DEFAULT_TYPE, get_reply=True
) -> AstrBotMessage | None:
"""转换 Telegram 的消息对象为 AstrBotMessage 对象。
@@ -363,8 +300,6 @@ class TelegramPlatformAdapter(Platform):
if not update.message:
logger.warning("Received an update without a message.")
return None
-
- # Assign to local variable so type checker can infer non-None
msg = update.message
def _apply_caption() -> None:
@@ -381,15 +316,12 @@ class TelegramPlatformAdapter(Platform):
message = AstrBotMessage()
message.session_id = str(msg.chat.id)
-
- # 获得是群聊还是私聊
if msg.chat.type == ChatType.PRIVATE:
message.type = MessageType.FRIEND_MESSAGE
else:
message.type = MessageType.GROUP_MESSAGE
message.group_id = str(msg.chat.id)
if msg.is_topic_message and msg.message_thread_id:
- # Telegram Topic Group: include thread id to isolate per-topic sessions.
message.group_id += "#" + str(msg.message_thread_id)
message.session_id = message.group_id
message.message_id = str(msg.message_id)
@@ -398,26 +330,21 @@ class TelegramPlatformAdapter(Platform):
logger.warning("[Telegram] Received a message without a from_user.")
return None
message.sender = MessageMember(
- str(_from_user.id),
- _from_user.username or "Unknown",
+ str(_from_user.id), _from_user.username or "Unknown"
)
message.self_id = str(context.bot.username)
message.raw_message = update
message.message_str = ""
message.message = []
-
- if update.message.reply_to_message and not (
- update.message.is_topic_message
- and update.message.message_thread_id
- == update.message.reply_to_message.message_id
- ):
- # 获取回复消息
- reply_update = Update(
- update_id=1,
- message=update.message.reply_to_message,
+ if update.message.reply_to_message and (
+ not (
+ update.message.is_topic_message
+ and update.message.message_thread_id
+ == update.message.reply_to_message.message_id
)
+ ):
+ reply_update = Update(update_id=1, message=update.message.reply_to_message)
reply_abm = await self.convert_message(reply_update, context, False)
-
if reply_abm:
message.message.append(
Comp.Reply(
@@ -429,23 +356,19 @@ class TelegramPlatformAdapter(Platform):
message_str=reply_abm.message_str,
text=reply_abm.message_str,
qq=reply_abm.sender.user_id,
- ),
+ )
)
-
if update.message.text:
- # 处理文本消息
plain_text = update.message.text
if (
message.type == MessageType.GROUP_MESSAGE
and update.message
and update.message.reply_to_message
and update.message.reply_to_message.from_user
- and update.message.reply_to_message.from_user.id == context.bot.id
+ and (update.message.reply_to_message.from_user.id == context.bot.id)
):
plain_text2 = f"/@{context.bot.username} " + plain_text
plain_text = plain_text2
-
- # 群聊场景命令特殊处理
if plain_text.startswith("/"):
command_parts = plain_text.split(" ", 1)
if "@" in command_parts[0]:
@@ -454,7 +377,6 @@ class TelegramPlatformAdapter(Platform):
plain_text = command + (
f" {command_parts[1]}" if len(command_parts) > 1 else ""
)
-
if update.message.entities:
for entity in update.message.entities:
if entity.type == "mention":
@@ -462,79 +384,64 @@ class TelegramPlatformAdapter(Platform):
entity.offset + 1 : entity.offset + entity.length
]
message.message.append(Comp.At(qq=name, name=name))
- # 如果mention是当前bot则移除;否则保留
if name.lower() == context.bot.username.lower():
plain_text = (
plain_text[: entity.offset]
+ plain_text[entity.offset + entity.length :]
)
-
if plain_text:
message.message.append(Comp.Plain(plain_text))
message.message_str = plain_text
-
if message.message_str.strip() == "/start":
await self.start(update, context)
return None
-
elif update.message.voice:
file = await update.message.voice.get_file()
-
- file_basename = os.path.basename(cast(str, file.file_path))
+ file_basename = os.path.basename(file.file_path)
temp_dir = get_astrbot_temp_path()
temp_path = os.path.join(temp_dir, file_basename)
- await download_file(cast(str, file.file_path), path=temp_path)
- path_wav = os.path.join(
- temp_dir,
- f"{file_basename}.wav",
- )
+ await download_file(file.file_path, path=temp_path)
+ path_wav = os.path.join(temp_dir, f"{file_basename}.wav")
path_wav = await convert_audio_to_wav(temp_path, path_wav)
-
record = Comp.Record(file=path_wav, url=path_wav)
record.path = path_wav
message.message = [record]
-
elif update.message.photo:
- photo = update.message.photo[-1] # get the largest photo
+ photo = update.message.photo[-1]
file = await photo.get_file()
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
_apply_caption()
-
elif update.message.sticker:
- # 将sticker当作图片处理
file = await update.message.sticker.get_file()
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
if update.message.sticker.emoji:
sticker_text = f"Sticker: {update.message.sticker.emoji}"
message.message_str = sticker_text
message.message.append(Comp.Plain(sticker_text))
-
elif update.message.document:
file = await update.message.document.get_file()
file_name = update.message.document.file_name or uuid.uuid4().hex
file_path = file.file_path
if file_path is None:
logger.warning(
- f"Telegram document file_path is None, cannot save the file {file_name}.",
+ f"Telegram document file_path is None, cannot save the file {file_name}."
)
else:
message.message.append(
Comp.File(file=file_path, name=file_name, url=file_path)
)
_apply_caption()
-
elif update.message.video:
file = await update.message.video.get_file()
file_name = update.message.video.file_name or uuid.uuid4().hex
file_path = file.file_path
if file_path is None:
logger.warning(
- f"Telegram video file_path is None, cannot save the file {file_name}.",
+ f"Telegram video file_path is None, cannot save the file {file_name}."
)
else:
message.message.append(Comp.Video(file=file_path, path=file.file_path))
_apply_caption()
-
return message
async def handle_media_group_message(
@@ -550,44 +457,31 @@ class TelegramPlatformAdapter(Platform):
if not update.message:
return
-
media_group_id = update.message.media_group_id
if not media_group_id:
return
-
- # Initialize cache for this media group if needed
if media_group_id not in self.media_group_cache:
self.media_group_cache[media_group_id] = {
"created_at": datetime.now(),
"items": [],
}
logger.debug(f"Create media group cache: {media_group_id}")
-
- # Add this message to the cache
entry = self.media_group_cache[media_group_id]
entry["items"].append((update, context))
logger.debug(
- f"Add message to media group {media_group_id}, "
- f"currently has {len(entry['items'])} items.",
+ f"Add message to media group {media_group_id}, currently has {len(entry['items'])} items."
)
-
- # Calculate delay: if already waited too long, process immediately;
- # otherwise use normal debounce timeout
elapsed = (datetime.now() - entry["created_at"]).total_seconds()
if elapsed >= self.media_group_max_wait:
delay = 0
logger.debug(
- f"Media group {media_group_id} has reached max wait time "
- f"({elapsed:.1f}s >= {self.media_group_max_wait}s), processing immediately.",
+ f"Media group {media_group_id} has reached max wait time ({elapsed:.1f}s >= {self.media_group_max_wait}s), processing immediately."
)
else:
delay = self.media_group_timeout
logger.debug(
- f"Scheduled media group {media_group_id} to be processed in {delay} seconds "
- f"(already waited {elapsed:.1f}s)"
+ f"Scheduled media group {media_group_id} to be processed in {delay} seconds (already waited {elapsed:.1f}s)"
)
-
- # Schedule/reschedule processing (replace_existing=True handles debounce)
job_id = f"media_group_{media_group_id}"
self.scheduler.add_job(
self.process_media_group,
@@ -607,41 +501,29 @@ class TelegramPlatformAdapter(Platform):
if media_group_id not in self.media_group_cache:
logger.warning(f"Media group {media_group_id} not found in cache")
return
-
entry = self.media_group_cache.pop(media_group_id)
updates_and_contexts = entry["items"]
if not updates_and_contexts:
logger.warning(f"Media group {media_group_id} is empty")
return
-
logger.info(
f"Processing media group {media_group_id}, total {len(updates_and_contexts)} items"
)
-
- # Use the first update to create the base message (with reply, caption, etc.)
first_update, first_context = updates_and_contexts[0]
abm = await self.convert_message(first_update, first_context)
-
if not abm:
logger.warning(
f"Failed to convert the first message of media group {media_group_id}"
)
return
-
- # Add additional media from remaining updates by reusing convert_message
for update, context in updates_and_contexts[1:]:
- # Convert the message but skip reply chains (get_reply=False)
extra = await self.convert_message(update, context, get_reply=False)
if not extra:
continue
-
- # Merge only the message components (keep base session/meta from first)
abm.message.extend(extra.message)
logger.debug(
f"Added {len(extra.message)} components to media group {media_group_id}"
)
-
- # Process the merged message
await self.handle_msg(abm)
async def handle_msg(self, message: AstrBotMessage) -> None:
@@ -662,16 +544,11 @@ class TelegramPlatformAdapter(Platform):
self._terminating = True
if self.scheduler.running:
self.scheduler.shutdown()
-
await self.application.stop()
-
if self.enable_command_register:
await self.client.delete_my_commands()
-
- # 保险起见先判断是否存在updater对象
if self.application.updater is not None:
await self.application.updater.stop()
-
logger.info("Telegram adapter has been closed.")
except Exception as e:
logger.error(f"Error occurred while closing Telegram adapter: {e}")
diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py
index 553786ca6..045f0dc42 100644
--- a/astrbot/core/platform/sources/telegram/tg_event.py
+++ b/astrbot/core/platform/sources/telegram/tg_event.py
@@ -2,7 +2,7 @@ import asyncio
import os
import re
from collections.abc import Callable
-from typing import Any, ClassVar, cast
+from typing import Any, ClassVar
import telegramify_markdown
from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji
@@ -12,15 +12,7 @@ from telegram.ext import ExtBot
from astrbot import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
-from astrbot.api.message_components import (
- At,
- File,
- Image,
- Plain,
- Record,
- Reply,
- Video,
-)
+from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
from astrbot.core.utils.metrics import Metric
@@ -36,18 +28,14 @@ def _is_gif(path: str) -> bool:
class TelegramPlatformEvent(AstrMessageEvent):
- # Telegram 的最大消息长度限制
MAX_MESSAGE_LENGTH = 4096
-
SPLIT_PATTERNS: ClassVar[dict[str, re.Pattern[str]]] = {
- "paragraph": re.compile(r"\n\n"),
- "line": re.compile(r"\n"),
- "sentence": re.compile(r"[.!?。!?]"),
- "word": re.compile(r"\s"),
+ "paragraph": re.compile("\\n\\n"),
+ "line": re.compile("\\n"),
+ "sentence": re.compile("[.!?。!?]"),
+ "word": re.compile("\\s"),
}
-
- # sendMessageDraft 的 draft_id 类级递增计数器
- _TELEGRAM_DRAFT_ID_MAX = 2_147_483_647
+ _TELEGRAM_DRAFT_ID_MAX = 2147483647
_next_draft_id: int = 0
@classmethod
@@ -60,7 +48,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
)
return cls._next_draft_id
- # 消息类型到 chat action 的映射,用于优先级判断
ACTION_BY_TYPE: ClassVar[dict[type, str]] = {
Record: ChatAction.UPLOAD_VOICE,
Video: ChatAction.UPLOAD_VIDEO,
@@ -84,25 +71,20 @@ class TelegramPlatformEvent(AstrMessageEvent):
def _split_message(cls, text: str) -> list[str]:
if len(text) <= cls.MAX_MESSAGE_LENGTH:
return [text]
-
chunks = []
while text:
if len(text) <= cls.MAX_MESSAGE_LENGTH:
chunks.append(text)
break
-
split_point = cls.MAX_MESSAGE_LENGTH
segment = text[: cls.MAX_MESSAGE_LENGTH]
-
for _, pattern in cls.SPLIT_PATTERNS.items():
if matches := list(pattern.finditer(segment)):
last_match = matches[-1]
split_point = last_match.end()
break
-
chunks.append(text[:split_point])
text = text[split_point:].lstrip()
-
return chunks
@classmethod
@@ -142,9 +124,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
**payload: Any,
) -> None:
"""发送媒体时显示 upload action,发送完成后恢复 typing"""
- effective_thread_id = message_thread_id or cast(
- str | None, payload.get("message_thread_id")
- )
+ effective_thread_id = message_thread_id or payload.get("message_thread_id")
await cls._send_chat_action(
client, user_name, upload_action, effective_thread_id
)
@@ -186,18 +166,15 @@ class TelegramPlatformEvent(AstrMessageEvent):
client.send_voice,
user_name=user_name,
voice=path,
- **cast(Any, media_payload),
+ **media_payload,
)
else:
- await client.send_voice(voice=path, **cast(Any, payload))
+ await client.send_voice(voice=path, **payload)
except BadRequest as e:
- # python-telegram-bot raises BadRequest for Voice_messages_forbidden;
- # distinguish the voice-privacy case via the API error message.
if "Voice_messages_forbidden" not in e.message:
raise
logger.warning(
- "User privacy settings prevent receiving voice messages, falling back to sending an audio file. "
- "To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'."
+ "User privacy settings prevent receiving voice messages, falling back to sending an audio file. To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'."
)
if use_media_action:
media_payload = dict(payload)
@@ -210,19 +187,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
user_name=user_name,
document=path,
caption=caption,
- **cast(Any, media_payload),
+ **media_payload,
)
else:
- await client.send_document(
- document=path,
- caption=caption,
- **cast(Any, payload),
- )
+ await client.send_document(document=path, caption=caption, **payload)
async def _ensure_typing(
- self,
- user_name: str,
- message_thread_id: str | None = None,
+ self, user_name: str, message_thread_id: str | None = None
) -> None:
"""确保显示 typing 状态"""
await self._send_chat_action(
@@ -235,21 +206,15 @@ class TelegramPlatformEvent(AstrMessageEvent):
user_name = self.message_obj.group_id
else:
user_name = self.get_sender_id()
-
if "#" in user_name:
user_name, message_thread_id = user_name.split("#")
-
await self._ensure_typing(user_name, message_thread_id)
@classmethod
async def send_with_client(
- cls,
- client: ExtBot,
- message: MessageChain,
- user_name: str,
+ cls, client: ExtBot, message: MessageChain, user_name: str
) -> None:
image_path = None
-
has_reply = False
reply_message_id = None
at_user_id = None
@@ -259,46 +224,34 @@ class TelegramPlatformEvent(AstrMessageEvent):
reply_message_id = i.id
if isinstance(i, At):
at_user_id = i.name
-
at_flag = False
message_thread_id = None
if "#" in user_name:
- # it's a supergroup chat with message_thread_id
user_name, message_thread_id = user_name.split("#")
-
- # 根据消息链确定合适的 chat action 并发送
action = cls._get_chat_action_for_chain(message.chain)
await cls._send_chat_action(client, user_name, action, message_thread_id)
-
for i in message.chain:
- payload = {
- "chat_id": user_name,
- }
+ payload = {"chat_id": user_name}
if has_reply:
payload["reply_to_message_id"] = str(reply_message_id)
if message_thread_id:
payload["message_thread_id"] = message_thread_id
-
if isinstance(i, Plain):
- if at_user_id and not at_flag:
+ if at_user_id and (not at_flag):
i.text = f"@{at_user_id} {i.text}"
at_flag = True
chunks = cls._split_message(i.text)
for chunk in chunks:
try:
- md_text = telegramify_markdown.markdownify(
- chunk,
- )
+ md_text = telegramify_markdown.markdownify(chunk)
await client.send_message(
- text=md_text,
- parse_mode="MarkdownV2",
- **cast(Any, payload),
+ text=md_text, parse_mode="MarkdownV2", **payload
)
except Exception as e:
logger.warning(
- f"MarkdownV2 send failed: {e}. Using plain text instead.",
+ f"MarkdownV2 send failed: {e}. Using plain text instead."
)
- await client.send_message(text=chunk, **cast(Any, payload))
+ await client.send_message(text=chunk, **payload)
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
if _is_gif(image_path):
@@ -307,13 +260,11 @@ class TelegramPlatformEvent(AstrMessageEvent):
else:
send_coro = client.send_photo
media_kwarg = {"photo": image_path}
- await send_coro(**cast(Any, media_kwarg), **cast(Any, payload))
+ await send_coro(**media_kwarg, **payload)
elif isinstance(i, File):
path = await i.get_file()
name = i.name or os.path.basename(path)
- await client.send_document(
- document=path, filename=name, **cast(Any, payload)
- )
+ await client.send_document(document=path, filename=name, **payload)
elif isinstance(i, Record):
path = await i.convert_to_file_path()
await cls._send_voice_with_fallback(
@@ -326,9 +277,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
elif isinstance(i, Video):
path = await i.convert_to_file_path()
await client.send_video(
- video=path,
- caption=getattr(i, "text", None) or None,
- **cast(Any, payload),
+ video=path, caption=getattr(i, "text", None) or None, **payload
)
async def send(self, message: MessageChain) -> None:
@@ -345,27 +294,22 @@ class TelegramPlatformEvent(AstrMessageEvent):
- 取消本机器人的反应:传入 None 或空字符串
"""
try:
- # 解析 chat_id(去掉超级群的 "#" 片段)
if self.get_message_type() == MessageType.GROUP_MESSAGE:
chat_id = (self.message_obj.group_id or "").split("#")[0]
else:
chat_id = self.get_sender_id()
-
message_id = int(self.message_obj.message_id)
-
- # 组装 reaction 参数(必须是 ReactionType 的列表)
- if not emoji: # 清空本 bot 的反应
- reaction_param = [] # 空列表表示移除本 bot 的反应
- elif emoji.isdigit(): # 自定义表情:传 custom_emoji_id
+ if not emoji:
+ reaction_param = []
+ elif emoji.isdigit():
reaction_param = [ReactionTypeCustomEmoji(emoji)]
- else: # 普通 emoji
+ else:
reaction_param = [ReactionTypeEmoji(emoji)]
-
await self.client.set_message_reaction(
chat_id=chat_id,
message_id=message_id,
- reaction=reaction_param, # 注意是列表
- is_big=big, # 可选:大动画
+ reaction=reaction_param,
+ is_big=big,
)
except Exception as e:
logger.error(f"[Telegram] 添加反应失败: {e}")
@@ -394,16 +338,12 @@ class TelegramPlatformEvent(AstrMessageEvent):
kwargs["message_thread_id"] = int(message_thread_id)
if parse_mode:
kwargs["parse_mode"] = parse_mode
-
try:
logger.debug(
f"[Telegram] sendMessageDraft: chat_id={chat_id}, draft_id={draft_id}, text_len={len(text)}"
)
await self.client.send_message_draft(
- chat_id=int(chat_id),
- draft_id=draft_id,
- text=text,
- **kwargs,
+ chat_id=int(chat_id), draft_id=draft_id, text=text, **kwargs
)
except Exception as e:
logger.warning(f"[Telegram] sendMessageDraft 失败: {e!s}")
@@ -436,7 +376,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
send_coro,
user_name=user_name,
**media_kwarg,
- **cast(Any, payload),
+ **payload,
)
elif isinstance(i, File):
path = await i.get_file()
@@ -448,7 +388,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
user_name=user_name,
document=path,
filename=name,
- **cast(Any, payload),
+ **payload,
)
elif isinstance(i, Record):
path = await i.convert_to_file_path()
@@ -469,7 +409,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
self.client.send_video,
user_name=user_name,
video=path,
- **cast(Any, payload),
+ **payload,
)
else:
logger.warning(f"不支持的消息类型: {type(i)}")
@@ -477,38 +417,26 @@ class TelegramPlatformEvent(AstrMessageEvent):
async def _send_final_segment(self, delta: str, payload: dict[str, Any]) -> None:
"""将累积文本作为 MarkdownV2 真实消息发送,失败时回退到纯文本。"""
try:
- markdown_text = telegramify_markdown.markdownify(
- delta,
- )
+ markdown_text = telegramify_markdown.markdownify(delta)
await self.client.send_message(
- text=markdown_text,
- parse_mode="MarkdownV2",
- **cast(Any, payload),
+ text=markdown_text, parse_mode="MarkdownV2", **payload
)
except Exception as e:
logger.warning(f"Markdown转换失败,使用普通文本: {e!s}")
- await self.client.send_message(text=delta, **cast(Any, payload))
+ await self.client.send_message(text=delta, **payload)
async def send_streaming(self, generator, use_fallback: bool = False):
message_thread_id = None
-
if self.get_message_type() == MessageType.GROUP_MESSAGE:
user_name = self.message_obj.group_id
else:
user_name = self.get_sender_id()
-
if "#" in user_name:
- # it's a supergroup chat with message_thread_id
user_name, message_thread_id = user_name.split("#")
- payload = {
- "chat_id": user_name,
- }
+ payload = {"chat_id": user_name}
if message_thread_id:
payload["message_thread_id"] = message_thread_id
-
- # sendMessageDraft 仅支持私聊(显式检查 FRIEND_MESSAGE)
is_private = self.get_message_type() == MessageType.FRIEND_MESSAGE
-
if is_private:
logger.info("[Telegram] 流式输出: 使用 sendMessageDraft (私聊)")
await self._send_streaming_draft(
@@ -519,10 +447,8 @@ class TelegramPlatformEvent(AstrMessageEvent):
await self._send_streaming_edit(
user_name, message_thread_id, payload, generator
)
-
- # 内联父类 send_streaming 的副作用(避免传入已消费的 generator)
- asyncio.create_task( # noqa: RUF006
- Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name),
+ asyncio.create_task(
+ Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
)
self._has_send_oper = True
@@ -543,8 +469,8 @@ class TelegramPlatformEvent(AstrMessageEvent):
draft_id = self._allocate_draft_id()
delta = ""
last_sent_text = ""
- done = False # 信号:生成器已结束
- text_changed = asyncio.Event() # 有新 token 到达时触发
+ done = False
+ text_changed = asyncio.Event()
async def _draft_sender_loop() -> None:
"""信号驱动的草稿发送循环,有新内容就发,RTT 自然限流。"""
@@ -552,14 +478,11 @@ class TelegramPlatformEvent(AstrMessageEvent):
while not done:
await text_changed.wait()
text_changed.clear()
- # 发送最新的缓冲区内容(MarkdownV2 渲染,与真实消息一致)
if delta and delta != last_sent_text:
draft_text = delta[: self.MAX_MESSAGE_LENGTH]
if draft_text != last_sent_text:
try:
- md = telegramify_markdown.markdownify(
- draft_text,
- )
+ md = telegramify_markdown.markdownify(draft_text)
await self._send_message_draft(
user_name,
draft_id,
@@ -569,13 +492,9 @@ class TelegramPlatformEvent(AstrMessageEvent):
)
last_sent_text = draft_text
except Exception:
- # markdownify 对未闭合语法可能失败,回退纯文本
try:
await self._send_message_draft(
- user_name,
- draft_id,
- draft_text,
- message_thread_id,
+ user_name, draft_id, draft_text, message_thread_id
)
last_sent_text = draft_text
except Exception as e2:
@@ -588,45 +507,31 @@ class TelegramPlatformEvent(AstrMessageEvent):
def _append_text(t: str) -> None:
nonlocal delta
delta += t
- text_changed.set() # 唤醒发送循环
+ text_changed.set()
try:
async for chain in generator:
if not isinstance(chain, MessageChain):
continue
-
if chain.type == "break":
- # 分割符:发送真实消息保留内容,重置缓冲区
if delta:
- # 用 emoji 清空 draft 显示,避免 draft 和真实消息同时可见
await self._send_message_draft(
- user_name,
- draft_id,
- "\u23f3",
- message_thread_id,
+ user_name, draft_id, "⏳", message_thread_id
)
await self._send_final_segment(delta, payload)
delta = ""
last_sent_text = ""
draft_id = self._allocate_draft_id()
continue
-
await self._process_chain_items(
chain, payload, user_name, message_thread_id, _append_text
)
finally:
done = True
- text_changed.set() # 唤醒循环使其退出
+ text_changed.set()
await sender_task
-
- # 流式结束:用 emoji 清空 draft,然后发真实消息持久化
if delta:
- await self._send_message_draft(
- user_name,
- draft_id,
- "\u23f3",
- message_thread_id,
- )
+ await self._send_message_draft(user_name, draft_id, "⏳", message_thread_id)
await self._send_final_segment(delta, payload)
async def _send_streaming_edit(
@@ -640,12 +545,10 @@ class TelegramPlatformEvent(AstrMessageEvent):
delta = ""
current_content = ""
message_id = None
- last_edit_time = 0 # 上次编辑消息的时间
- throttle_interval = 0.6 # 编辑消息的间隔时间 (秒)
- last_chat_action_time = 0 # 上次发送 chat action 的时间
- chat_action_interval = 0.5 # chat action 的节流间隔 (秒)
-
- # 发送初始 typing 状态
+ last_edit_time: float = 0
+ throttle_interval = 0.6
+ last_chat_action_time: float = 0
+ chat_action_interval = 0.5
await self._ensure_typing(user_name, message_thread_id)
last_chat_action_time = asyncio.get_running_loop().time()
@@ -656,9 +559,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
async for chain in generator:
if not isinstance(chain, MessageChain):
continue
-
if chain.type == "break":
- # 分割符
if message_id:
try:
await self.client.edit_message_text(
@@ -671,16 +572,12 @@ class TelegramPlatformEvent(AstrMessageEvent):
message_id = None
delta = ""
continue
-
await self._process_chain_items(
chain, payload, user_name, message_thread_id, _append_text
)
-
- # 编辑或发送消息
if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH:
current_time = asyncio.get_running_loop().time()
time_since_last_edit = current_time - last_edit_time
-
if time_since_last_edit >= throttle_interval:
current_time = asyncio.get_running_loop().time()
if current_time - last_chat_action_time >= chat_action_interval:
@@ -702,21 +599,16 @@ class TelegramPlatformEvent(AstrMessageEvent):
await self._ensure_typing(user_name, message_thread_id)
last_chat_action_time = current_time
try:
- msg = await self.client.send_message(
- text=delta, **cast(Any, payload)
- )
+ msg = await self.client.send_message(text=delta, **payload)
current_content = delta
except Exception as e:
logger.warning(f"发送消息失败(streaming): {e!s}")
message_id = msg.message_id
last_edit_time = asyncio.get_running_loop().time()
-
try:
if delta and current_content != delta:
try:
- markdown_text = telegramify_markdown.markdownify(
- delta,
- )
+ markdown_text = telegramify_markdown.markdownify(delta)
await self.client.edit_message_text(
text=markdown_text,
chat_id=payload["chat_id"],
@@ -726,9 +618,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
except Exception as e:
logger.warning(f"Markdown转换失败,使用普通文本: {e!s}")
await self.client.edit_message_text(
- text=delta,
- chat_id=payload["chat_id"],
- message_id=message_id,
+ text=delta, chat_id=payload["chat_id"], message_id=message_id
)
except Exception as e:
logger.warning(f"编辑消息失败(streaming): {e!s}")
diff --git a/astrbot/core/platform/sources/tui/tui_adapter.py b/astrbot/core/platform/sources/tui/tui_adapter.py
index 8d4de15ca..85bbefdbb 100644
--- a/astrbot/core/platform/sources/tui/tui_adapter.py
+++ b/astrbot/core/platform/sources/tui/tui_adapter.py
@@ -2,7 +2,7 @@ import asyncio
import os
import time
from collections.abc import Callable, Coroutine
-from typing import Any, cast
+from typing import Any
from astrbot import logger
from astrbot.core import db_helper
@@ -38,10 +38,7 @@ def _extract_conversation_id(session_id: str) -> str:
class QueueListener:
def __init__(
- self,
- tui_queue_mgr: TUIQueueMgr,
- callback: Callable,
- stop_event: asyncio.Event,
+ self, tui_queue_mgr: TUIQueueMgr, callback: Callable, stop_event: asyncio.Event
) -> None:
self.tui_queue_mgr = tui_queue_mgr
self.callback = callback
@@ -59,32 +56,22 @@ class QueueListener:
@register_platform_adapter("tui", "tui")
class TUIAdapter(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.imgs_dir = os.path.join(get_astrbot_data_path(), "tui", "imgs")
self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
os.makedirs(self.imgs_dir, exist_ok=True)
os.makedirs(self.attachments_dir, exist_ok=True)
-
self.metadata = PlatformMetadata(
- name="tui",
- description="tui",
- id="tui",
- support_proactive_message=True,
+ name="tui", description="tui", id="tui", support_proactive_message=True
)
self._shutdown_event = asyncio.Event()
self._tui_queue_mgr = tui_queue_mgr
async def send_by_session(
- self,
- session: MessageSesion,
- message_chain: MessageChain,
+ self, session: MessageSesion, message_chain: MessageChain
) -> None:
conversation_id = _extract_conversation_id(session.session_id)
active_request_ids = self._tui_queue_mgr.list_back_request_ids(conversation_id)
@@ -92,18 +79,15 @@ class TUIAdapter(Platform):
req_id for req_id in active_request_ids if not req_id.startswith("ws_sub_")
]
target_request_ids = stream_request_ids or active_request_ids
-
if not target_request_ids:
try:
await self._save_proactive_message(conversation_id, message_chain)
except Exception as e:
logger.error(
- f"[TUIAdapter] Failed to save proactive message: {e}",
- exc_info=True,
+ f"[TUIAdapter] Failed to save proactive message: {e}", exc_info=True
)
await super().send_by_session(session, message_chain)
return
-
for request_id in target_request_ids:
await TUIMessageEvent._send(
request_id,
@@ -112,22 +96,17 @@ class TUIAdapter(Platform):
streaming=True,
emit_complete=True,
)
-
if not stream_request_ids:
try:
await self._save_proactive_message(conversation_id, message_chain)
except Exception as e:
logger.error(
- f"[TUIAdapter] Failed to save proactive message: {e}",
- exc_info=True,
+ f"[TUIAdapter] Failed to save proactive message: {e}", exc_info=True
)
-
await super().send_by_session(session, message_chain)
async def _save_proactive_message(
- self,
- conversation_id: str,
- message_chain: MessageChain,
+ self, conversation_id: str, message_chain: MessageChain
) -> None:
message_parts = await message_chain_to_storage_message_parts(
message_chain,
@@ -136,7 +115,6 @@ class TUIAdapter(Platform):
)
if not message_parts:
return
-
await db_helper.insert_platform_message_history(
platform_id="tui",
user_id=conversation_id,
@@ -151,10 +129,7 @@ class TUIAdapter(Platform):
return await db_helper.get_platform_message_history_by_id(message_id)
async def _parse_message_parts(
- self,
- message_parts: list,
- depth: int = 0,
- max_depth: int = 1,
+ self, message_parts: list, depth: int = 0, max_depth: int = 1
) -> tuple[list, list[str]]:
"""Parse message parts list, return message components and plain text lists."""
@@ -164,12 +139,10 @@ class TUIAdapter(Platform):
history = await self._get_message_history(message_id)
if not history or not history.content:
return None
-
reply_parts = history.content.get("message", [])
if not isinstance(reply_parts, list):
return None
-
- return reply_parts, history.sender_id, history.sender_name
+ return (reply_parts, history.sender_id, history.sender_name)
components, text_parts, _ = await parse_webchat_message_parts(
message_parts,
@@ -181,32 +154,26 @@ class TUIAdapter(Platform):
max_reply_depth=max_depth,
cast_reply_id_to_str=False,
)
- return components, text_parts
+ return (components, text_parts)
async def convert_message(self, data: tuple) -> AstrBotMessage:
username, cid, payload = data
-
abm = AstrBotMessage()
abm.self_id = "tui"
abm.sender = MessageMember(username, username)
-
abm.type = MessageType.FRIEND_MESSAGE
-
abm.session_id = f"tui!{username}!{cid}"
-
abm.message_id = payload.get("message_id")
-
message_parts = payload.get("message", [])
abm.message, message_str_parts = await self._parse_message_parts(message_parts)
-
logger.debug(f"TUIAdapter: {abm.message}")
-
abm.timestamp = int(time.time())
abm.message_str = "".join(message_str_parts)
abm.raw_message = data
return abm
def run(self) -> Coroutine[Any, Any, None]:
+
async def callback(data: tuple) -> None:
abm = await self.convert_message(data)
await self.handle_msg(abm)
@@ -224,15 +191,13 @@ class TUIAdapter(Platform):
platform_meta=self.meta(),
session_id=message.session_id,
)
-
- _, _, payload = cast(tuple[Any, Any, dict[str, Any]], message.raw_message)
+ _, _, payload = message.raw_message
message_event.set_extra("selected_provider", payload.get("selected_provider"))
message_event.set_extra("selected_model", payload.get("selected_model"))
message_event.set_extra(
"enable_streaming", payload.get("enable_streaming", True)
)
message_event.set_extra("action_type", payload.get("action_type"))
-
self.commit_event(message_event)
async def terminate(self) -> None:
diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py
index d27df7c90..30b8e98e7 100644
--- a/astrbot/core/platform/sources/webchat/webchat_adapter.py
+++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py
@@ -3,7 +3,7 @@ import os
import time
from collections.abc import Callable, Coroutine
from pathlib import Path
-from typing import Any, cast
+from typing import Any
from astrbot import logger
from astrbot.core import db_helper
@@ -60,19 +60,14 @@ class QueueListener:
@register_platform_adapter("webchat", "webchat")
class WebChatAdapter(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.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
self.attachments_dir = Path(get_astrbot_data_path()) / "attachments"
os.makedirs(self.imgs_dir, exist_ok=True)
self.attachments_dir.mkdir(parents=True, exist_ok=True)
-
self.metadata = PlatformMetadata(
name="webchat",
description="webchat",
@@ -83,9 +78,7 @@ class WebChatAdapter(Platform):
self._webchat_queue_mgr = webchat_queue_mgr
async def send_by_session(
- self,
- session: MessageSesion,
- message_chain: MessageChain,
+ self, session: MessageSesion, message_chain: MessageChain
) -> None:
conversation_id = _extract_conversation_id(session.session_id)
active_request_ids = self._webchat_queue_mgr.list_back_request_ids(
@@ -95,10 +88,7 @@ class WebChatAdapter(Platform):
req_id for req_id in active_request_ids if not req_id.startswith("ws_sub_")
]
target_request_ids = stream_request_ids or active_request_ids
-
if not target_request_ids:
- # No active streams to consume this proactive message.
- # Persist directly and return to avoid creating an unused queue.
try:
await self._save_proactive_message(conversation_id, message_chain)
except Exception as e:
@@ -108,7 +98,6 @@ class WebChatAdapter(Platform):
)
await super().send_by_session(session, message_chain)
return
-
for request_id in target_request_ids:
await WebChatMessageEvent._send(
request_id,
@@ -117,10 +106,6 @@ class WebChatAdapter(Platform):
streaming=True,
emit_complete=True,
)
-
- # If only passive subscription queues exist for this conversation,
- # keep a proactive save as a fallback since they are not tied to
- # the normal streaming persistence path.
if not stream_request_ids:
try:
await self._save_proactive_message(conversation_id, message_chain)
@@ -129,13 +114,10 @@ class WebChatAdapter(Platform):
f"[WebChatAdapter] Failed to save proactive message: {e}",
exc_info=True,
)
-
await super().send_by_session(session, message_chain)
async def _save_proactive_message(
- self,
- conversation_id: str,
- message_chain: MessageChain,
+ self, conversation_id: str, message_chain: MessageChain
) -> None:
message_parts = await message_chain_to_storage_message_parts(
message_chain,
@@ -144,7 +126,6 @@ class WebChatAdapter(Platform):
)
if not message_parts:
return
-
await db_helper.insert_platform_message_history(
platform_id="webchat",
user_id=conversation_id,
@@ -159,10 +140,7 @@ class WebChatAdapter(Platform):
return await db_helper.get_platform_message_history_by_id(message_id)
async def _parse_message_parts(
- self,
- message_parts: list,
- depth: int = 0,
- max_depth: int = 1,
+ self, message_parts: list, depth: int = 0, max_depth: int = 1
) -> tuple[list, list[str]]:
"""解析消息段列表,返回消息组件列表和纯文本列表
@@ -181,12 +159,10 @@ class WebChatAdapter(Platform):
history = await self._get_message_history(message_id)
if not history or not history.content:
return None
-
reply_parts = history.content.get("message", [])
if not isinstance(reply_parts, list):
return None
-
- return reply_parts, history.sender_id, history.sender_name
+ return (reply_parts, history.sender_id, history.sender_name)
components, text_parts, _ = await parse_webchat_message_parts(
message_parts,
@@ -198,33 +174,26 @@ class WebChatAdapter(Platform):
max_reply_depth=max_depth,
cast_reply_id_to_str=False,
)
- return components, text_parts
+ return (components, text_parts)
async def convert_message(self, data: tuple) -> AstrBotMessage:
username, cid, payload = data
-
abm = AstrBotMessage()
abm.self_id = "webchat"
abm.sender = MessageMember(username, username)
-
abm.type = MessageType.FRIEND_MESSAGE
-
abm.session_id = f"webchat!{username}!{cid}"
-
abm.message_id = payload.get("message_id")
-
- # 处理消息段列表
message_parts = payload.get("message", [])
abm.message, message_str_parts = await self._parse_message_parts(message_parts)
-
logger.debug(f"WebChatAdapter: {abm.message}")
-
abm.timestamp = int(time.time())
abm.message_str = "".join(message_str_parts)
abm.raw_message = data
return abm
def run(self) -> Coroutine[Any, Any, None]:
+
async def callback(data: tuple) -> None:
abm = await self.convert_message(data)
await self.handle_msg(abm)
@@ -242,15 +211,13 @@ class WebChatAdapter(Platform):
platform_meta=self.meta(),
session_id=message.session_id,
)
-
- _, _, payload = cast(tuple[Any, Any, dict[str, Any]], message.raw_message)
+ _, _, payload = message.raw_message
message_event.set_extra("selected_provider", payload.get("selected_provider"))
message_event.set_extra("selected_model", payload.get("selected_model"))
message_event.set_extra(
"enable_streaming", payload.get("enable_streaming", True)
)
message_event.set_extra("action_type", payload.get("action_type"))
-
self.commit_event(message_event)
async def terminate(self) -> None:
diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py
index 4d2acdc51..4723ee41d 100644
--- a/astrbot/core/platform/sources/wecom/wecom_adapter.py
+++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py
@@ -3,7 +3,7 @@ import os
import sys
import uuid
from collections.abc import Awaitable, Callable
-from typing import Any, cast
+from typing import Any
import aiofiles
import quart
@@ -42,26 +42,20 @@ else:
class WecomServer:
def __init__(self, event_queue: asyncio.Queue, config: dict) -> None:
self.server = quart.Quart(__name__)
- self.port = int(cast(str, config.get("port")))
+ self.port = int(config.get("port"))
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
self.server.add_url_rule(
- "/callback/command",
- view_func=self.verify,
- methods=["GET"],
+ "/callback/command", view_func=self.verify, methods=["GET"]
)
self.server.add_url_rule(
- "/callback/command",
- view_func=self.callback_command,
- methods=["POST"],
+ "/callback/command", view_func=self.callback_command, methods=["POST"]
)
self.event_queue = event_queue
-
self.crypto = WeChatCrypto(
config["token"].strip(),
config["encoding_aes_key"].strip(),
config["corpid"].strip(),
)
-
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
self.shutdown_event = asyncio.Event()
@@ -116,17 +110,15 @@ class WecomServer:
logger.error("解密失败,签名异常,请检查配置。")
raise
else:
- msg = cast(BaseMessage, parse_message(xml))
+ msg = parse_message(xml)
logger.info(f"解析成功: {msg}")
-
if self.callback:
await self.callback(msg)
-
return "success"
async def start_polling(self) -> None:
logger.info(
- f"将在 {self.callback_server_host}:{self.port} 端口启动 企业微信 适配器。",
+ f"将在 {self.callback_server_host}:{self.port} 端口启动 企业微信 适配器。"
)
await self.server.run_task(
host=self.callback_server_host,
@@ -141,47 +133,33 @@ class WecomServer:
@register_platform_adapter("wecom", "wecom 适配器", support_streaming_message=False)
class WecomPlatformAdapter(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.settingss = platform_settings
self.client_self_id = uuid.uuid4().hex[:8]
self.api_base_url = platform_config.get(
- "api_base_url",
- "https://qyapi.weixin.qq.com/cgi-bin/",
+ "api_base_url", "https://qyapi.weixin.qq.com/cgi-bin/"
)
self.unified_webhook_mode = platform_config.get("unified_webhook_mode", False)
-
if not self.api_base_url:
self.api_base_url = "https://qyapi.weixin.qq.com/cgi-bin/"
-
self.api_base_url = self.api_base_url.removesuffix("/")
if not self.api_base_url.endswith("/cgi-bin"):
self.api_base_url += "/cgi-bin"
-
if not self.api_base_url.endswith("/"):
self.api_base_url += "/"
-
self.server = WecomServer(self._event_queue, self.config)
self.agent_id: str | None = None
-
self.client = WeChatClient(
- self.config["corpid"].strip(),
- self.config["secret"].strip(),
+ self.config["corpid"].strip(), self.config["secret"].strip()
)
-
- # 微信客服
self.kf_name = self.config.get("kf_name", None)
if self.kf_name:
- # inject
self.wechat_kf_api = WeChatKF(client=self.client)
self.wechat_kf_message_api = WeChatKFMessage(self.client)
self.client.__setattr__("kf", self.wechat_kf_api)
self.client.__setattr__("kf_message", self.wechat_kf_message_api)
-
self.client.__setattr__("API_BASE_URL", self.api_base_url)
async def callback(msg: BaseMessage) -> None:
@@ -201,8 +179,7 @@ class WecomPlatformAdapter(Platform):
return None
msg_new = await asyncio.get_running_loop().run_in_executor(
- None,
- get_latest_msg_item,
+ None, get_latest_msg_item
)
if msg_new:
await self.convert_wechat_kf_message(msg_new)
@@ -213,22 +190,18 @@ class WecomPlatformAdapter(Platform):
@override
async def send_by_session(
- self,
- session: MessageSesion,
- message_chain: MessageChain,
+ self, session: MessageSesion, message_chain: MessageChain
) -> None:
- # 企业微信客服不支持主动发送
if hasattr(self.client, "kf_message"):
logger.warning("企业微信客服模式不支持 send_by_session 主动发送。")
await super().send_by_session(session, message_chain)
return
if not self.agent_id:
logger.warning(
- f"send_by_session 失败:无法为会话 {session.session_id} 推断 agent_id。",
+ f"send_by_session 失败:无法为会话 {session.session_id} 推断 agent_id。"
)
await super().send_by_session(session, message_chain)
return
-
message_obj = AstrBotMessage()
message_obj.self_id = self.agent_id
message_obj.session_id = session.session_id
@@ -238,7 +211,6 @@ class WecomPlatformAdapter(Platform):
message_obj.message_str = ""
message_obj.message_id = uuid.uuid4().hex
message_obj.raw_message = {"_proactive_send": True}
-
event = WecomPlatformEvent(
message_str=message_obj.message_str,
message_obj=message_obj,
@@ -266,8 +238,7 @@ class WecomPlatformAdapter(Platform):
try:
acc_list = (
await loop.run_in_executor(
- None,
- self.wechat_kf_api.get_account_list,
+ None, self.wechat_kf_api.get_account_list
)
).get("account_list", [])
logger.debug(f"获取到微信客服列表: {acc_list!s}")
@@ -288,23 +259,19 @@ class WecomPlatformAdapter(Platform):
)
).get("url", "")
logger.info(
- f"请打开以下链接,在微信扫码以获取客服微信: https://api.cl2wm.cn/api/qrcode/code?text={kf_url}",
+ f"请打开以下链接,在微信扫码以获取客服微信: https://api.cl2wm.cn/api/qrcode/code?text={kf_url}"
)
except Exception as e:
logger.error(e)
-
- # 如果启用统一 webhook 模式,则不启动独立服务器
webhook_uuid = self.config.get("webhook_uuid")
if self.unified_webhook_mode and webhook_uuid:
log_webhook_info(f"{self.meta().id}(企业微信)", webhook_uuid)
- # 保持运行状态,等待 shutdown
await self.server.shutdown_event.wait()
else:
await self.server.start_polling()
async def webhook_callback(self, request: Any) -> Any:
"""统一 Webhook 回调入口"""
- # 根据请求方法分发到不同的处理函数
if request.method == "GET":
return await self.server.handle_verify(request)
else:
@@ -313,44 +280,33 @@ class WecomPlatformAdapter(Platform):
async def convert_message(self, msg: BaseMessage) -> AstrBotMessage | None:
abm = AstrBotMessage()
if isinstance(msg, TextMessage):
- abm.message_str = cast(str, msg.content)
+ abm.message_str = msg.content
abm.self_id = str(msg.agent)
- abm.message = [Plain(cast(str, msg.content))]
+ abm.message = [Plain(msg.content)]
abm.type = MessageType.FRIEND_MESSAGE
- abm.sender = MessageMember(
- cast(str, msg.source),
- cast(str, msg.source),
- )
+ abm.sender = MessageMember(msg.source, msg.source)
abm.message_id = str(msg.id)
- abm.timestamp = int(cast(int | str, msg.time))
+ abm.timestamp = int(msg.time)
abm.session_id = abm.sender.user_id
abm.raw_message = msg
elif isinstance(msg, ImageMessage):
abm.message_str = "[图片]"
abm.self_id = str(msg.agent)
- abm.message = [
- Image(file=cast(str | None, msg.image), url=cast(str | None, msg.image))
- ]
+ abm.message = [Image(file=msg.image, url=msg.image)]
abm.type = MessageType.FRIEND_MESSAGE
- abm.sender = MessageMember(
- cast(str, msg.source),
- cast(str, msg.source),
- )
+ abm.sender = MessageMember(msg.source, msg.source)
abm.message_id = str(msg.id)
- abm.timestamp = int(cast(int | str, msg.time))
+ abm.timestamp = int(msg.time)
abm.session_id = abm.sender.user_id
abm.raw_message = msg
elif isinstance(msg, VoiceMessage):
resp = await asyncio.get_running_loop().run_in_executor(
- None,
- self.client.media.download,
- msg.media_id,
+ None, self.client.media.download, msg.media_id
)
temp_dir = get_astrbot_temp_path()
path = os.path.join(temp_dir, f"wecom_{msg.media_id}.amr")
async with aiofiles.open(path, "wb") as f:
await f.write(resp.content)
-
try:
path_wav = os.path.join(temp_dir, f"wecom_{msg.media_id}.wav")
path_wav = await convert_audio_to_wav(path, path_wav)
@@ -358,23 +314,18 @@ class WecomPlatformAdapter(Platform):
logger.error(f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。")
path_wav = path
return None
-
abm.message_str = ""
abm.self_id = str(msg.agent)
abm.message = [Record(file=path_wav, url=path_wav)]
abm.type = MessageType.FRIEND_MESSAGE
- abm.sender = MessageMember(
- cast(str, msg.source),
- cast(str, msg.source),
- )
+ abm.sender = MessageMember(msg.source, msg.source)
abm.message_id = str(msg.id)
- abm.timestamp = int(cast(int | str, msg.time))
+ abm.timestamp = int(msg.time)
abm.session_id = abm.sender.user_id
abm.raw_message = msg
else:
logger.warning(f"暂未实现的事件: {msg.type}")
return None
-
self.agent_id = abm.self_id
logger.info(f"abm: {abm}")
await self.handle_msg(abm)
@@ -382,10 +333,10 @@ class WecomPlatformAdapter(Platform):
async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None:
msgtype = msg.get("msgtype")
- external_userid = cast(str, msg.get("external_userid"))
+ external_userid = msg.get("external_userid")
abm = AstrBotMessage()
abm.raw_message = msg
- abm.raw_message["_wechat_kf_flag"] = None # 方便处理
+ abm.raw_message["_wechat_kf_flag"] = None
abm.self_id = msg["open_kfid"]
abm.sender = MessageMember(external_userid, external_userid)
abm.session_id = external_userid
@@ -399,9 +350,7 @@ class WecomPlatformAdapter(Platform):
elif msgtype == "image":
media_id = msg.get("image", {}).get("media_id", "")
resp = await asyncio.get_running_loop().run_in_executor(
- None,
- self.client.media.download,
- media_id,
+ None, self.client.media.download, media_id
)
temp_dir = get_astrbot_temp_path()
path = os.path.join(temp_dir, f"weixinkefu_{media_id}.jpg")
@@ -411,16 +360,12 @@ class WecomPlatformAdapter(Platform):
elif msgtype == "voice":
media_id = msg.get("voice", {}).get("media_id", "")
resp = await asyncio.get_running_loop().run_in_executor(
- None,
- self.client.media.download,
- media_id,
+ None, self.client.media.download, media_id
)
-
temp_dir = get_astrbot_temp_path()
path = os.path.join(temp_dir, f"weixinkefu_{media_id}.amr")
async with aiofiles.open(path, "wb") as f:
await f.write(resp.content)
-
try:
path_wav = os.path.join(temp_dir, f"weixinkefu_{media_id}.wav")
path_wav = await convert_audio_to_wav(path, path_wav)
@@ -428,7 +373,6 @@ class WecomPlatformAdapter(Platform):
logger.error(f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。")
path_wav = path
return None
-
abm.message = [Record(file=path_wav, url=path_wav)]
else:
logger.warning(f"未实现的微信客服消息事件: {msg}")
diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py
index 5ed00b231..137c058bd 100644
--- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py
+++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py
@@ -9,7 +9,7 @@ import hashlib
import time
import uuid
from collections.abc import Awaitable, Callable, Coroutine
-from typing import Any, cast
+from typing import Any
from astrbot.api import logger
from astrbot.api.event import MessageChain
@@ -47,9 +47,7 @@ class WecomAIQueueListener:
"""企业微信智能机器人队列监听器,参考webchat的QueueListener设计"""
def __init__(
- self,
- queue_mgr: WecomAIQueueMgr,
- callback: Callable[[dict], Awaitable[None]],
+ self, queue_mgr: WecomAIQueueMgr, callback: Callable[[dict], Awaitable[None]]
) -> None:
self.queue_mgr = queue_mgr
self.callback = callback
@@ -63,22 +61,16 @@ class WecomAIQueueListener:
@register_platform_adapter(
- "wecom_ai_bot",
- "企业微信智能机器人适配器,支持 HTTP 回调接收消息",
+ "wecom_ai_bot", "企业微信智能机器人适配器,支持 HTTP 回调接收消息"
)
class WecomAIBotAdapter(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.connection_mode = self.config.get(
"wecom_ai_bot_connection_mode", "webhook"
)
@@ -89,18 +81,14 @@ class WecomAIBotAdapter(Platform):
self.port = int(self.config["port"])
self.host = self.config.get("callback_server_host", "0.0.0.0")
self.bot_name = self.config.get("wecom_ai_bot_name", "")
- self.initial_respond_text = self.config.get(
- "wecomaibot_init_respond_text",
- "",
- )
+ self.initial_respond_text = self.config.get("wecomaibot_init_respond_text", "")
self.friend_message_welcome_text = self.config.get(
- "wecomaibot_friend_message_welcome_text",
- "",
+ "wecomaibot_friend_message_welcome_text", ""
)
self.unified_webhook_mode = self.config.get("unified_webhook_mode", False)
self.msg_push_webhook_url = self.config.get("msg_push_webhook_url", "").strip()
self.only_use_webhook_url_to_send = bool(
- self.config.get("only_use_webhook_url_to_send", False),
+ self.config.get("only_use_webhook_url_to_send", False)
)
self.long_connection_bot_id = self.config.get(
"wecomaibot_ws_bot_id", self.config.get("long_connection_bot_id", "")
@@ -109,25 +97,20 @@ class WecomAIBotAdapter(Platform):
"wecomaibot_ws_secret", self.config.get("long_connection_secret", "")
)
self.long_connection_ws_url = self.config.get(
- "wecomaibot_ws_url",
- "wss://openws.work.weixin.qq.com",
+ "wecomaibot_ws_url", "wss://openws.work.weixin.qq.com"
)
self.long_connection_heartbeat_interval = int(
- self.config.get("wecomaibot_heartbeat_interval", 30),
+ self.config.get("wecomaibot_heartbeat_interval", 30)
)
-
- # 平台元数据
self.metadata = PlatformMetadata(
name="wecom_ai_bot",
description="企业微信智能机器人适配器,支持 HTTP 回调和长连接模式",
id=self.config.get("id", "wecom_ai_bot"),
support_proactive_message=bool(self.msg_push_webhook_url),
)
-
self.api_client: WecomAIBotAPIClient | None = None
self.server: WecomAIBotServer | None = None
self.long_connection_client: WecomAIBotLongConnectionClient | None = None
-
if self.connection_mode == "long_connection":
if not self.long_connection_bot_id or not self.long_connection_secret:
logger.warning(
@@ -148,26 +131,16 @@ class WecomAIBotAdapter(Platform):
api_client=self.api_client,
message_handler=self._process_message,
)
-
- # 事件循环和关闭信号
self.shutdown_event = asyncio.Event()
-
- # 队列管理器
self.queue_mgr = WecomAIQueueMgr()
-
- # 队列监听器
self.queue_listener = WecomAIQueueListener(
- self.queue_mgr,
- self._handle_queued_message,
+ self.queue_mgr, self._handle_queued_message
)
self._stream_plain_cache: dict[str, str] = {}
-
self.webhook_client: WecomAIBotWebhookClient | None = None
if self.msg_push_webhook_url:
try:
- self.webhook_client = WecomAIBotWebhookClient(
- self.msg_push_webhook_url,
- )
+ self.webhook_client = WecomAIBotWebhookClient(self.msg_push_webhook_url)
except WecomAIBotWebhookError as e:
logger.error("企业微信消息推送 webhook 配置无效: %s", e)
@@ -180,9 +153,7 @@ class WecomAIBotAdapter(Platform):
logger.error(f"处理队列消息时发生异常: {e}")
async def _process_message(
- self,
- message_data: dict[str, Any],
- callback_params: dict[str, str],
+ self, message_data: dict[str, Any], callback_params: dict[str, str]
) -> str | None:
"""处理接收到的消息
@@ -203,36 +174,25 @@ class WecomAIBotAdapter(Platform):
return None
session_id = self._extract_session_id(message_data)
if msgtype in ("text", "image", "mixed"):
- # user sent a text / image / mixed message
try:
- # create a brand-new unique stream_id for this message session
stream_id = f"{session_id}_{generate_random_string(10)}"
await self._enqueue_message(
- message_data,
- callback_params,
- stream_id,
- session_id,
+ message_data, callback_params, stream_id, session_id
)
self.queue_mgr.set_pending_response(stream_id, callback_params)
-
if self.only_use_webhook_url_to_send and self.webhook_client:
return None
if self.initial_respond_text:
resp = WecomAIBotStreamMessageBuilder.make_text_stream(
- stream_id,
- self.initial_respond_text,
- False,
+ stream_id, self.initial_respond_text, False
)
return await self.api_client.encrypt_message(
- resp,
- callback_params["nonce"],
- callback_params["timestamp"],
+ resp, callback_params["nonce"], callback_params["timestamp"]
)
except Exception as e:
logger.error("处理消息时发生异常: %s", e)
return None
elif msgtype == "stream":
- # wechat server is requesting for updates of a stream
stream_id = message_data["stream"]["id"]
if not self.queue_mgr.has_back_queue(stream_id):
self._stream_plain_cache.pop(stream_id, None)
@@ -242,27 +202,19 @@ class WecomAIBotAdapter(Platform):
)
else:
logger.warning(f"Cannot find back queue for stream_id: {stream_id}")
-
- # 返回结束标志,告诉微信服务器流已结束
end_message = WecomAIBotStreamMessageBuilder.make_text_stream(
- stream_id,
- "",
- True,
+ stream_id, "", True
)
resp = await self.api_client.encrypt_message(
- end_message,
- callback_params["nonce"],
- callback_params["timestamp"],
+ end_message, callback_params["nonce"], callback_params["timestamp"]
)
return resp
queue = self.queue_mgr.get_or_create_back_queue(stream_id)
if queue.empty():
logger.debug(
- f"No new messages in back queue for stream_id: {stream_id}",
+ f"No new messages in back queue for stream_id: {stream_id}"
)
return None
-
- # aggregate all delta chains in the back queue
cached_plain_content = self._stream_plain_cache.get(stream_id, "")
latest_plain_content = cached_plain_content
image_base64 = []
@@ -272,10 +224,8 @@ class WecomAIBotAdapter(Platform):
if msg["type"] == "plain":
plain_data = msg.get("data") or ""
if msg.get("streaming", False):
- # streaming plain payload is already cumulative
cached_plain_content = plain_data
else:
- # segmented non-stream send() pushes plain chunks, needs append
cached_plain_content += plain_data
latest_plain_content = cached_plain_content
elif msg["type"] == "image":
@@ -283,48 +233,37 @@ class WecomAIBotAdapter(Platform):
elif msg["type"] == "break":
continue
elif msg["type"] in {"end", "complete"}:
- # stream end
finish = True
self.queue_mgr.remove_queues(stream_id, mark_finished=True)
self._stream_plain_cache.pop(stream_id, None)
break
-
logger.debug(
- f"Aggregated content: {latest_plain_content}, image: {len(image_base64)}, finish: {finish}",
+ f"Aggregated content: {latest_plain_content}, image: {len(image_base64)}, finish: {finish}"
)
if not finish:
self._stream_plain_cache[stream_id] = cached_plain_content
- if finish and not latest_plain_content and not image_base64:
+ if finish and (not latest_plain_content) and (not image_base64):
end_message = WecomAIBotStreamMessageBuilder.make_text_stream(
- stream_id,
- "",
- True,
+ stream_id, "", True
)
return await self.api_client.encrypt_message(
- end_message,
- callback_params["nonce"],
- callback_params["timestamp"],
+ end_message, callback_params["nonce"], callback_params["timestamp"]
)
if latest_plain_content or image_base64:
msg_items = []
if finish and image_base64:
for img_b64 in image_base64:
- # get md5 of image
img_data = base64.b64decode(img_b64)
img_md5 = hashlib.md5(img_data).hexdigest()
msg_items.append(
{
"msgtype": WecomAIBotConstants.MSG_TYPE_IMAGE,
"image": {"base64": img_b64, "md5": img_md5},
- },
+ }
)
image_base64 = []
-
plain_message = WecomAIBotStreamMessageBuilder.make_mixed_stream(
- stream_id,
- latest_plain_content,
- msg_items,
- finish,
+ stream_id, latest_plain_content, msg_items, finish
)
encrypted_message = await self.api_client.encrypt_message(
plain_message,
@@ -333,7 +272,7 @@ class WecomAIBotAdapter(Platform):
)
if encrypted_message:
logger.debug(
- f"Stream message sent successfully, stream_id: {stream_id}",
+ f"Stream message sent successfully, stream_id: {stream_id}"
)
else:
logger.error("消息加密失败")
@@ -342,15 +281,12 @@ class WecomAIBotAdapter(Platform):
elif msgtype == "event":
event = message_data.get("event")
if event == "enter_chat" and self.friend_message_welcome_text:
- # 用户进入会话,发送欢迎消息
try:
resp = WecomAIBotStreamMessageBuilder.make_text(
- self.friend_message_welcome_text,
+ self.friend_message_welcome_text
)
return await self.api_client.encrypt_message(
- resp,
- callback_params["nonce"],
- callback_params["timestamp"],
+ resp, callback_params["nonce"], callback_params["timestamp"]
)
except Exception as e:
logger.error("处理欢迎消息时发生异常: %s", e)
@@ -358,10 +294,7 @@ class WecomAIBotAdapter(Platform):
return None
return None
- async def _process_long_connection_payload(
- self,
- payload: dict[str, Any],
- ) -> None:
+ async def _process_long_connection_payload(self, payload: dict[str, Any]) -> None:
"""处理长连接回调消息。"""
cmd = payload.get("cmd")
headers = payload.get("headers") or {}
@@ -369,7 +302,6 @@ class WecomAIBotAdapter(Platform):
req_id = headers.get("req_id")
if not isinstance(body, dict):
return
-
if cmd == "aibot_msg_callback":
session_id = self._extract_session_id(body)
stream_id = f"{session_id}_{generate_random_string(10)}"
@@ -378,12 +310,8 @@ class WecomAIBotAdapter(Platform):
)
self.queue_mgr.set_pending_response(
stream_id,
- {
- "req_id": req_id or "",
- "connection_mode": "long_connection",
- },
+ {"req_id": req_id or "", "connection_mode": "long_connection"},
)
-
if self.initial_respond_text and req_id:
await self._send_long_connection_respond_msg(
req_id=req_id,
@@ -397,7 +325,6 @@ class WecomAIBotAdapter(Platform):
},
)
return
-
if cmd == "aibot_event_callback":
event = body.get("event") or {}
event_type = event.get("eventtype")
@@ -421,24 +348,18 @@ class WecomAIBotAdapter(Platform):
req_id=req_id,
body={
"msgtype": "text",
- "text": {
- "content": self.friend_message_welcome_text,
- },
+ "text": {"content": self.friend_message_welcome_text},
},
)
async def _send_long_connection_respond_msg(
- self,
- req_id: str,
- body: dict[str, Any],
+ self, req_id: str, body: dict[str, Any]
) -> bool:
client = self.long_connection_client
if not client:
return False
return await client.send_command(
- cmd="aibot_respond_msg",
- req_id=req_id,
- body=body,
+ cmd="aibot_respond_msg", req_id=req_id, body=body
)
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
@@ -476,16 +397,11 @@ class WecomAIBotAdapter(Platform):
"""转换队列中的消息数据为AstrBotMessage,类似webchat的convert_message"""
message_data = payload["message_data"]
session_id = payload["session_id"]
- # callback_params = payload["callback_params"] # 保留但暂时不使用
-
- # 解析消息内容
msgtype = message_data.get("msgtype")
content = ""
image_base64 = []
-
_img_url_to_process: list[tuple[str, str | None]] = []
msg_items: list[dict[str, Any]] = []
-
if msgtype == WecomAIBotConstants.MSG_TYPE_TEXT:
content = WecomAIBotMessageParser.parse_text_message(message_data)
elif msgtype == WecomAIBotConstants.MSG_TYPE_IMAGE:
@@ -494,11 +410,7 @@ class WecomAIBotAdapter(Platform):
if image_url:
_img_url_to_process.append((image_url, image_payload.get("aeskey")))
elif msgtype == WecomAIBotConstants.MSG_TYPE_MIXED:
- # 提取混合消息中的文本内容
- msg_items = cast(
- list[dict[str, Any]],
- WecomAIBotMessageParser.parse_mixed_message(message_data),
- )
+ msg_items = WecomAIBotMessageParser.parse_mixed_message(message_data)
text_parts = []
for item in msg_items or []:
if item.get("msgtype") == WecomAIBotConstants.MSG_TYPE_TEXT:
@@ -515,8 +427,6 @@ class WecomAIBotAdapter(Platform):
content = " ".join(text_parts) if text_parts else ""
else:
content = f"[{msgtype}消息]"
-
- # 并行处理图片下载和解密
if _img_url_to_process:
tasks = [
process_encrypted_image(url, aes_key or self.encoding_aes_key)
@@ -528,33 +438,23 @@ class WecomAIBotAdapter(Platform):
image_base64.append(result)
else:
logger.error(f"处理加密图片失败: {result}")
-
- # 构建 AstrBotMessage
abm = AstrBotMessage()
abm.self_id = self.bot_name
abm.message_str = content or "[未知消息]"
abm.message_id = str(uuid.uuid4())
abm.timestamp = int(time.time())
abm.raw_message = payload
-
- # 发送者信息
abm.sender = MessageMember(
user_id=message_data.get("from", {}).get("userid", "unknown"),
nickname=message_data.get("from", {}).get("userid", "unknown"),
)
-
- # 消息类型
abm.type = (
MessageType.GROUP_MESSAGE
if message_data.get("chattype") == "group"
else MessageType.FRIEND_MESSAGE
)
abm.session_id = session_id
-
- # 消息内容
abm.message = []
-
- # 处理 At
if self.bot_name and f"@{self.bot_name}" in abm.message_str:
abm.message_str = abm.message_str.replace(f"@{self.bot_name}", "").strip()
abm.message.append(At(qq=self.bot_name, name=self.bot_name))
@@ -562,14 +462,11 @@ class WecomAIBotAdapter(Platform):
if image_base64:
for img_b64 in image_base64:
abm.message.append(Image.fromBase64(img_b64))
-
logger.debug(f"WecomAIAdapter: {abm.message}")
return abm
async def send_by_session(
- self,
- session: MessageSesion,
- message_chain: MessageChain,
+ self, session: MessageSesion, message_chain: MessageChain
) -> None:
"""通过消息推送 webhook 发送消息。"""
if not self.webhook_client:
@@ -579,20 +476,13 @@ class WecomAIBotAdapter(Platform):
)
await super().send_by_session(session, message_chain)
return
-
try:
await self.webhook_client.send_message_chain(message_chain)
except Exception as e:
- logger.error(
- "企业微信消息推送失败(session=%s): %s",
- session.session_id,
- e,
- )
+ logger.error("企业微信消息推送失败(session=%s): %s", session.session_id, e)
await super().send_by_session(session, message_chain)
- def run(
- self,
- ) -> Coroutine[Any, Any, None]:
+ def run(self) -> Coroutine[Any, Any, None]:
"""运行适配器,同时启动HTTP服务器和队列监听器"""
async def run_both() -> None:
@@ -603,17 +493,14 @@ class WecomAIBotAdapter(Platform):
"启动企业微信智能机器人长连接模式: %s", self.long_connection_ws_url
)
await asyncio.gather(
- self.long_connection_client.start(),
- self.queue_listener.run(),
+ self.long_connection_client.start(), self.queue_listener.run()
)
else:
- # 如果启用统一 webhook 模式,则不启动独立服务器
webhook_uuid = self.config.get("webhook_uuid")
if self.unified_webhook_mode and webhook_uuid:
log_webhook_info(
f"{self.meta().id}(企业微信智能机器人)", webhook_uuid
)
- # 只运行队列监听器
await self.queue_listener.run()
else:
if not self.server:
@@ -621,10 +508,8 @@ class WecomAIBotAdapter(Platform):
logger.info(
"启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
)
- # 同时运行HTTP服务器和队列监听器
await asyncio.gather(
- self.server.start_server(),
- self.queue_listener.run(),
+ self.server.start_server(), self.queue_listener.run()
)
return run_both()
@@ -632,8 +517,7 @@ class WecomAIBotAdapter(Platform):
async def webhook_callback(self, request: Any) -> Any:
"""统一 Webhook 回调入口"""
if self.connection_mode == "long_connection" or not self.server:
- return "long_connection mode does not accept webhook callbacks", 400
- # 根据请求方法分发到不同的处理函数
+ return ("long_connection mode does not accept webhook callbacks", 400)
if request.method == "GET":
return await self.server.handle_verify(request)
else:
@@ -666,13 +550,9 @@ class WecomAIBotAdapter(Platform):
only_use_webhook_url_to_send=self.only_use_webhook_url_to_send,
long_connection_sender=self._send_long_connection_respond_msg,
)
- message_event.is_at_or_wake_command = (
- True # 企业微信智能机器人默认消息都是 at 或唤醒命令
- )
- message_event.is_wake = True # 企业微信智能机器人消息默认当做唤醒命令处理
-
+ message_event.is_at_or_wake_command = True
+ message_event.is_wake = True
self.commit_event(message_event)
-
except Exception as e:
logger.error("处理消息时发生异常: %s", e)
diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py
index 4c8da7920..4384dce11 100644
--- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py
+++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -31,7 +31,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
queue_mgr: WecomAIQueueMgr,
webhook_client: WecomAIBotWebhookClient | None = None,
only_use_webhook_url_to_send: bool = False,
- long_connection_sender: (Callable[[str, dict], Awaitable[bool]] | None) = None,
+ long_connection_sender: Callable[[str, dict], Awaitable[bool]] | None = None,
) -> None:
"""初始化消息事件
@@ -58,7 +58,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"data": "",
"streaming": False,
"session_id": stream_id,
- },
+ }
)
@staticmethod
@@ -70,17 +70,9 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
suppress_unsupported_log: bool = False,
):
back_queue = queue_mgr.get_or_create_back_queue(stream_id)
-
if not message_chain:
- await back_queue.put(
- {
- "type": "end",
- "data": "",
- "streaming": False,
- },
- )
+ await back_queue.put({"type": "end", "data": "", "streaming": False})
return ""
-
data = ""
for comp in message_chain.chain:
if isinstance(comp, At):
@@ -91,7 +83,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"data": data,
"streaming": streaming,
"session_id": stream_id,
- },
+ }
)
elif isinstance(comp, Plain):
data = comp.text
@@ -101,10 +93,9 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"data": data,
"streaming": streaming,
"session_id": stream_id,
- },
+ }
)
elif isinstance(comp, Image):
- # 处理图片消息
try:
image_base64 = await comp.convert_to_base64()
if image_base64:
@@ -114,18 +105,14 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"image_data": image_base64,
"streaming": streaming,
"session_id": stream_id,
- },
+ }
)
else:
logger.warning("图片数据为空,跳过")
except Exception as e:
logger.error("处理图片消息失败: %s", e)
- else:
- if not suppress_unsupported_log:
- logger.warning(
- f"[WecomAI] 不支持的消息组件类型: {type(comp)}, 跳过"
- )
-
+ elif not suppress_unsupported_log:
+ logger.warning(f"[WecomAI] 不支持的消息组件类型: {type(comp)}, 跳过")
return data
@staticmethod
@@ -148,14 +135,13 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
assert isinstance(raw, dict), (
"wecom_ai_bot platform event raw_message should be a dict"
)
- raw = cast(dict[str, Any], raw)
+ raw = raw
stream_id = raw.get("stream_id", self.session_id)
pending_response = self.queue_mgr.get_pending_response(stream_id) or {}
connection_mode = pending_response.get("callback_params", {}).get(
"connection_mode"
)
req_id = pending_response.get("callback_params", {}).get("req_id")
-
if (
connection_mode == "long_connection"
and self.long_connection_sender
@@ -166,40 +152,27 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
await self.webhook_client.send_message_chain(message)
await super().send(MessageChain([]))
return
-
if self.webhook_client and message:
await self.webhook_client.send_message_chain(
- message,
- unsupported_only=True,
+ message, unsupported_only=True
)
-
content = self._extract_plain_text_from_chain(message)
await self.long_connection_sender(
req_id,
{
"msgtype": "stream",
- "stream": {
- "id": stream_id,
- "finish": True,
- "content": content,
- },
+ "stream": {"id": stream_id, "finish": True, "content": content},
},
)
await super().send(MessageChain([]))
return
-
if self.only_use_webhook_url_to_send and self.webhook_client and message:
await self.webhook_client.send_message_chain(message)
await self._mark_stream_complete(stream_id)
await super().send(MessageChain([]))
return
-
if self.webhook_client and message:
- await self.webhook_client.send_message_chain(
- message,
- unsupported_only=True,
- )
-
+ await self.webhook_client.send_message_chain(message, unsupported_only=True)
await WecomAIBotMessageEvent._send(
message,
stream_id,
@@ -215,7 +188,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
assert isinstance(raw, dict), (
"wecom_ai_bot platform event raw_message should be a dict"
)
- raw = cast(dict[str, Any], raw)
+ raw = raw
stream_id = raw.get("stream_id", self.session_id)
pending_response = self.queue_mgr.get_pending_response(stream_id) or {}
connection_mode = pending_response.get("callback_params", {}).get(
@@ -223,7 +196,6 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
)
req_id = pending_response.get("callback_params", {}).get("req_id")
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
-
if (
connection_mode == "long_connection"
and self.long_connection_sender
@@ -240,25 +212,18 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
req_id,
{
"msgtype": "stream",
- "stream": {
- "id": stream_id,
- "finish": True,
- "content": "",
- },
+ "stream": {"id": stream_id, "finish": True, "content": ""},
},
)
await super().send_streaming(generator, use_fallback)
return
-
increment_plain = ""
last_stream_update_time = 0.0
async for chain in generator:
if self.webhook_client:
await self.webhook_client.send_message_chain(
- chain,
- unsupported_only=True,
+ chain, unsupported_only=True
)
-
chain.squash_plain()
chunk_text = self._extract_plain_text_from_chain(chain)
if chunk_text:
@@ -277,7 +242,6 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
},
)
last_stream_update_time = now
-
await self.long_connection_sender(
req_id,
{
@@ -291,7 +255,6 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
)
await super().send_streaming(generator, use_fallback)
return
-
if self.only_use_webhook_url_to_send and self.webhook_client:
merged_chain = MessageChain([])
async for chain in generator:
@@ -301,8 +264,6 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
await self._mark_stream_complete(stream_id)
await super().send_streaming(generator, use_fallback)
return
-
- # 企业微信智能机器人不支持增量发送,因此我们需要在这里将增量内容累积起来,按间隔推送
increment_plain = ""
last_stream_update_time = 0.0
@@ -315,7 +276,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"data": text,
"streaming": True,
"session_id": stream_id,
- },
+ }
)
async for chain in generator:
@@ -323,23 +284,20 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
await self.webhook_client.send_message_chain(
chain, unsupported_only=True
)
-
if chain.type == "break" and final_data:
if increment_plain:
await enqueue_stream_plain(increment_plain)
- # 分割符
await back_queue.put(
{
- "type": "break", # break means a segment end
+ "type": "break",
"data": final_data,
"streaming": True,
"session_id": stream_id,
- },
+ }
)
final_data = ""
increment_plain = ""
continue
-
chunk_text = self._extract_plain_text_from_chain(chain)
if chunk_text:
increment_plain += chunk_text
@@ -348,7 +306,6 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
if now - last_stream_update_time >= self.STREAM_FLUSH_INTERVAL:
await enqueue_stream_plain(increment_plain)
last_stream_update_time = now
-
for comp in chain.chain:
if isinstance(comp, (At, Plain)):
continue
@@ -359,15 +316,13 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
streaming=True,
suppress_unsupported_log=self.webhook_client is not None,
)
-
await enqueue_stream_plain(increment_plain)
-
await back_queue.put(
{
- "type": "complete", # complete means we return the final result
+ "type": "complete",
"data": final_data,
"streaming": True,
"session_id": stream_id,
- },
+ }
)
await super().send_streaming(generator, use_fallback)
diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py
index 3cb215f19..914bd8cd3 100644
--- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py
+++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py
@@ -8,7 +8,7 @@ import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING, Any
from urllib.parse import quote
import qrcode as qrcode_lib
@@ -31,7 +31,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from .weixin_oc_client import WeixinOCClient
from .weixin_oc_event import WeixinOCMessageEvent
-if TYPE_CHECKING: # pragma: no cover - typing-only helper
+if TYPE_CHECKING:
pass
@@ -60,11 +60,7 @@ class TypingSessionState:
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
-@register_platform_adapter(
- "weixin_oc",
- "个人微信",
- support_streaming_message=False,
-)
+@register_platform_adapter("weixin_oc", "个人微信", support_streaming_message=False)
class WeixinOCAdapter(Platform):
IMAGE_ITEM_TYPE = 2
VOICE_ITEM_TYPE = 3
@@ -75,42 +71,34 @@ class WeixinOCAdapter(Platform):
FILE_UPLOAD_TYPE = 3
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.base_url = str(
platform_config.get("weixin_oc_base_url", "https://ilinkai.weixin.qq.com")
).rstrip("/")
self.bot_type = str(platform_config.get("weixin_oc_bot_type", "3"))
self.qr_poll_interval = max(
- 1,
- int(platform_config.get("weixin_oc_qr_poll_interval", 1)),
+ 1, int(platform_config.get("weixin_oc_qr_poll_interval", 1))
)
self.long_poll_timeout_ms = int(
- platform_config.get("weixin_oc_long_poll_timeout_ms", 35_000),
+ platform_config.get("weixin_oc_long_poll_timeout_ms", 35000)
)
self.api_timeout_ms = int(
- platform_config.get("weixin_oc_api_timeout_ms", 15_000),
+ platform_config.get("weixin_oc_api_timeout_ms", 15000)
)
self.cdn_base_url = str(
platform_config.get(
- "weixin_oc_cdn_base_url",
- "https://novac2c.cdn.weixin.qq.com/c2c",
+ "weixin_oc_cdn_base_url", "https://novac2c.cdn.weixin.qq.com/c2c"
)
).rstrip("/")
-
self.metadata = PlatformMetadata(
name="weixin_oc",
description="个人微信",
- id=cast(str, self.config.get("id", "weixin_oc")),
+ id=self.config.get("id", "weixin_oc"),
support_streaming_message=False,
)
-
self._shutdown_event = asyncio.Event()
self._login_session: OpenClawLoginSession | None = None
self._sync_buf = ""
@@ -119,14 +107,11 @@ class WeixinOCAdapter(Platform):
self._typing_states: dict[str, TypingSessionState] = {}
self._last_inbound_error = ""
self._typing_keepalive_interval_s = max(
- 1,
- int(platform_config.get("weixin_oc_typing_keepalive_interval", 5)),
+ 1, int(platform_config.get("weixin_oc_typing_keepalive_interval", 5))
)
self._typing_ticket_ttl_s = max(
- 5,
- int(platform_config.get("weixin_oc_typing_ticket_ttl", 60)),
+ 5, int(platform_config.get("weixin_oc_typing_ticket_ttl", 60))
)
-
self.token = str(platform_config.get("weixin_oc_token", "")).strip() or None
self.account_id = (
str(platform_config.get("weixin_oc_account_id", "")).strip() or None
@@ -139,11 +124,9 @@ class WeixinOCAdapter(Platform):
api_timeout_ms=self.api_timeout_ms,
token=self.token,
)
-
if self.token:
logger.info(
- "weixin_oc adapter %s loaded with token from config.",
- self.meta().id,
+ "weixin_oc adapter %s loaded with token from config.", self.meta().id
)
def _sync_client_state(self) -> None:
@@ -173,7 +156,6 @@ class WeixinOCAdapter(Platform):
) -> None:
if task is None or task.done():
return
-
task.cancel()
try:
await task
@@ -184,22 +166,18 @@ class WeixinOCAdapter(Platform):
logger.warning(log_message, *log_args, exc_info=True)
async def _ensure_typing_ticket(
- self,
- user_id: str,
- state: TypingSessionState,
+ self, user_id: str, state: TypingSessionState
) -> str | None:
now = time.monotonic()
context_token = self._context_tokens.get(user_id)
if not context_token:
return None
-
if (
state.ticket
and state.ticket_context_token == context_token
- and state.refresh_after > now
+ and (state.refresh_after > now)
):
return state.ticket
-
payload = await self.client.get_typing_config(user_id, context_token)
if int(payload.get("ret") or 0) != 0:
logger.warning(
@@ -209,22 +187,16 @@ class WeixinOCAdapter(Platform):
payload.get("errmsg", ""),
)
return None
-
ticket = str(payload.get("typing_ticket", "")).strip()
if not ticket:
return None
-
state.ticket = ticket
state.ticket_context_token = context_token
state.refresh_after = time.monotonic() + self._typing_ticket_ttl_s
return ticket
async def _send_typing_state(
- self,
- user_id: str,
- ticket: str,
- *,
- cancel: bool,
+ self, user_id: str, ticket: str, *, cancel: bool
) -> None:
payload = await self.client.send_typing_state(user_id, ticket, cancel=cancel)
if int(payload.get("ret") or 0) != 0:
@@ -243,8 +215,8 @@ class WeixinOCAdapter(Platform):
if state is not None:
async with state.lock:
state.refresh_after = 0.0
- restart_needed = (
- bool(state.owners) and not self._shutdown_event.is_set()
+ restart_needed = bool(state.owners) and (
+ not self._shutdown_event.is_set()
)
logger.warning(
"weixin_oc(%s): typing keepalive failed for %s: %s",
@@ -257,15 +229,12 @@ class WeixinOCAdapter(Platform):
current_task = asyncio.current_task()
if state is not None and state.keepalive_task is current_task:
state.keepalive_task = None
-
if not restart_needed:
return
-
await asyncio.sleep(self._typing_keepalive_interval_s)
state = self._typing_states.get(user_id)
if state is None or self._shutdown_event.is_set():
return
-
async with state.lock:
if not state.owners or state.keepalive_task is not None:
return
@@ -279,7 +248,6 @@ class WeixinOCAdapter(Platform):
state = self._typing_states.get(user_id)
if state is None:
return
-
async with state.lock:
if not state.owners:
return
@@ -312,7 +280,6 @@ class WeixinOCAdapter(Platform):
state = self._typing_states.get(user_id)
if state is None:
return
-
current_task = asyncio.current_task()
async with state.lock:
if state.cancel_task is not current_task:
@@ -320,7 +287,6 @@ class WeixinOCAdapter(Platform):
if state.owners or state.keepalive_task is not None:
state.cancel_task = None
return
-
try:
await self._send_typing_state(user_id, ticket, cancel=True)
except asyncio.CancelledError:
@@ -348,7 +314,7 @@ class WeixinOCAdapter(Platform):
return
if not self._typing_supported_for(user_id):
return
- if state.cancel_task is not None and not state.cancel_task.done():
+ if state.cancel_task is not None and (not state.cancel_task.done()):
cancel_task = state.cancel_task
cancel_task.cancel()
state.cancel_task = None
@@ -364,12 +330,10 @@ class WeixinOCAdapter(Platform):
return
if not ticket:
return
-
state.ticket = ticket
state.owners.add(owner_id)
- if state.keepalive_task is not None and not state.keepalive_task.done():
+ if state.keepalive_task is not None and (not state.keepalive_task.done()):
return
-
try:
await self._send_typing_state(user_id, ticket, cancel=False)
except Exception as e:
@@ -380,10 +344,8 @@ class WeixinOCAdapter(Platform):
user_id,
e,
)
-
task = asyncio.create_task(self._run_typing_keepalive(user_id))
state.keepalive_task = task
-
if cancel_task is not None:
await self._cancel_task_safely(
cancel_task,
@@ -395,25 +357,20 @@ class WeixinOCAdapter(Platform):
state = self._typing_states.get(user_id)
if state is None:
return
-
task: asyncio.Task | None = None
async with state.lock:
if owner_id not in state.owners:
return
state.owners.remove(owner_id)
-
if state.owners:
return
-
task = state.keepalive_task
state.keepalive_task = None
-
await self._cancel_task_safely(
task,
log_message="weixin_oc(%s): typing keepalive stop failed for %s",
log_args=(self.meta().id, user_id),
)
-
async with state.lock:
if state.owners:
return
@@ -435,22 +392,20 @@ class WeixinOCAdapter(Platform):
):
cancels.append((user_id, state.ticket))
state.owners.clear()
- if state.keepalive_task is not None and not state.keepalive_task.done():
+ if state.keepalive_task is not None and (not state.keepalive_task.done()):
tasks.append(state.keepalive_task)
state.keepalive_task.cancel()
state.keepalive_task = None
- if state.cancel_task is not None and not state.cancel_task.done():
+ if state.cancel_task is not None and (not state.cancel_task.done()):
tasks.append(state.cancel_task)
state.cancel_task.cancel()
state.cancel_task = None
-
for task in tasks:
await self._cancel_task_safely(
task,
log_message="weixin_oc(%s): typing cleanup failed",
log_args=(self.meta().id,),
)
-
for user_id, ticket in cancels:
try:
await self._send_typing_state(user_id, ticket, cancel=True)
@@ -483,7 +438,6 @@ class WeixinOCAdapter(Platform):
self.config["weixin_oc_account_id"] = self.account_id or ""
self.config["weixin_oc_sync_buf"] = self._sync_buf
self.config["weixin_oc_base_url"] = self.base_url
-
for platform in astrbot_config.get("platform", []):
if not isinstance(platform, dict):
continue
@@ -496,7 +450,6 @@ class WeixinOCAdapter(Platform):
platform["weixin_oc_sync_buf"] = self._sync_buf
platform["weixin_oc_base_url"] = self.base_url
break
-
self._sync_client_state()
astrbot_config.save_config()
@@ -505,7 +458,7 @@ class WeixinOCAdapter(Platform):
) -> bool:
if not login_session:
return False
- return (time.time() - login_session.started_at) * 1000 < 5 * 60_000
+ return (time.time() - login_session.started_at) * 1000 < 5 * 60000
def _resolve_inbound_media_dir(self) -> Path:
media_dir = Path(get_astrbot_temp_path())
@@ -518,16 +471,10 @@ class WeixinOCAdapter(Platform):
return normalized or fallback_name
def _save_inbound_media(
- self,
- content: bytes,
- *,
- prefix: str,
- file_name: str,
- fallback_suffix: str,
+ self, content: bytes, *, prefix: str, file_name: str, fallback_suffix: str
) -> Path:
normalized_name = self._normalize_inbound_filename(
- file_name,
- f"{prefix}{fallback_suffix}",
+ file_name, f"{prefix}{fallback_suffix}"
)
stem = Path(normalized_name).stem or prefix
suffix = Path(normalized_name).suffix or fallback_suffix
@@ -540,12 +487,7 @@ class WeixinOCAdapter(Platform):
@staticmethod
def _build_plain_text_item(text: str) -> dict[str, Any]:
- return {
- "type": 1,
- "text_item": {
- "text": text,
- },
- }
+ return {"type": 1, "text_item": {"text": text}}
async def _prepare_media_item(
self,
@@ -561,7 +503,6 @@ class WeixinOCAdapter(Platform):
file_key = uuid.uuid4().hex
aes_key_hex = uuid.uuid4().bytes.hex()
ciphertext_size = self.client.aes_padded_size(raw_size)
-
payload = await self.client.request_json(
"POST",
"ilink/bot/getuploadurl",
@@ -574,9 +515,7 @@ class WeixinOCAdapter(Platform):
"filesize": ciphertext_size,
"no_need_thumb": True,
"aeskey": aes_key_hex,
- "base_info": {
- "channel_version": "astrbot",
- },
+ "base_info": {"channel_version": "astrbot"},
},
token_required=True,
timeout_ms=self.api_timeout_ms,
@@ -594,13 +533,8 @@ class WeixinOCAdapter(Platform):
)
upload_param = str(payload.get("upload_param", "")).strip()
upload_full_url = str(payload.get("upload_full_url", "")).strip()
-
encrypted_query_param = await self.client.upload_to_cdn(
- upload_full_url,
- upload_param,
- file_key,
- aes_key_hex,
- media_path,
+ upload_full_url, upload_param, file_key, aes_key_hex, media_path
)
logger.debug(
"weixin_oc(%s): prepared media item type=%s file=%s user=%s mid_size=%s upload_param_len=%s query_len=%s",
@@ -612,31 +546,22 @@ class WeixinOCAdapter(Platform):
len(upload_param),
len(encrypted_query_param),
)
-
aes_key_b64 = base64.b64encode(aes_key_hex.encode("utf-8")).decode("utf-8")
media_payload = {
"encrypt_query_param": encrypted_query_param,
"aes_key": aes_key_b64,
"encrypt_type": 1,
}
-
if item_type == self.IMAGE_ITEM_TYPE:
return {
"type": self.IMAGE_ITEM_TYPE,
- "image_item": {
- "media": media_payload,
- "mid_size": ciphertext_size,
- },
+ "image_item": {"media": media_payload, "mid_size": ciphertext_size},
}
if item_type == self.VIDEO_ITEM_TYPE:
return {
"type": self.VIDEO_ITEM_TYPE,
- "video_item": {
- "media": media_payload,
- "video_size": ciphertext_size,
- },
+ "video_item": {"media": media_payload, "video_size": ciphertext_size},
}
-
file_len = str(raw_size)
return {
"type": self.FILE_ITEM_TYPE,
@@ -648,14 +573,12 @@ class WeixinOCAdapter(Platform):
}
async def _resolve_inbound_media_component(
- self,
- item: dict[str, Any],
+ self, item: dict[str, Any]
) -> Image | Video | File | Record | None:
item_type = int(item.get("type") or 0)
-
if item_type == self.IMAGE_ITEM_TYPE:
- image_item = cast(dict[str, Any], item.get("image_item", {}) or {})
- media = cast(dict[str, Any], image_item.get("media", {}) or {})
+ image_item = item.get("image_item", {}) or {}
+ media = image_item.get("media", {}) or {}
encrypted_query_param = str(media.get("encrypt_query_param", "")).strip()
if not encrypted_query_param:
return None
@@ -668,8 +591,7 @@ class WeixinOCAdapter(Platform):
aes_key_value = str(media.get("aes_key", "")).strip()
if aes_key_value:
content = await self.client.download_and_decrypt_media(
- encrypted_query_param,
- aes_key_value,
+ encrypted_query_param, aes_key_value
)
else:
content = await self.client.download_cdn_bytes(encrypted_query_param)
@@ -680,17 +602,15 @@ class WeixinOCAdapter(Platform):
fallback_suffix=".jpg",
)
return Image.fromFileSystem(str(image_path))
-
if item_type == self.VIDEO_ITEM_TYPE:
- video_item = cast(dict[str, Any], item.get("video_item", {}) or {})
- media = cast(dict[str, Any], video_item.get("media", {}) or {})
+ video_item = item.get("video_item", {}) or {}
+ media = video_item.get("media", {}) or {}
encrypted_query_param = str(media.get("encrypt_query_param", "")).strip()
aes_key_value = str(media.get("aes_key", "")).strip()
if not encrypted_query_param or not aes_key_value:
return None
content = await self.client.download_and_decrypt_media(
- encrypted_query_param,
- aes_key_value,
+ encrypted_query_param, aes_key_value
)
video_path = self._save_inbound_media(
content,
@@ -699,21 +619,18 @@ class WeixinOCAdapter(Platform):
fallback_suffix=".mp4",
)
return Video.fromFileSystem(str(video_path))
-
if item_type == self.FILE_ITEM_TYPE:
- file_item = cast(dict[str, Any], item.get("file_item", {}) or {})
- media = cast(dict[str, Any], file_item.get("media", {}) or {})
+ file_item = item.get("file_item", {}) or {}
+ media = file_item.get("media", {}) or {}
encrypted_query_param = str(media.get("encrypt_query_param", "")).strip()
aes_key_value = str(media.get("aes_key", "")).strip()
if not encrypted_query_param or not aes_key_value:
return None
file_name = self._normalize_inbound_filename(
- str(file_item.get("file_name", "")).strip(),
- "file.bin",
+ str(file_item.get("file_name", "")).strip(), "file.bin"
)
content = await self.client.download_and_decrypt_media(
- encrypted_query_param,
- aes_key_value,
+ encrypted_query_param, aes_key_value
)
file_path = self._save_inbound_media(
content,
@@ -722,17 +639,15 @@ class WeixinOCAdapter(Platform):
fallback_suffix=".bin",
)
return File(name=file_name, file=str(file_path))
-
if item_type == self.VOICE_ITEM_TYPE:
- voice_item = cast(dict[str, Any], item.get("voice_item", {}) or {})
- media = cast(dict[str, Any], voice_item.get("media", {}) or {})
+ voice_item = item.get("voice_item", {}) or {}
+ media = voice_item.get("media", {}) or {}
encrypted_query_param = str(media.get("encrypt_query_param", "")).strip()
aes_key_value = str(media.get("aes_key", "")).strip()
if not encrypted_query_param or not aes_key_value:
return None
content = await self.client.download_and_decrypt_media(
- encrypted_query_param,
- aes_key_value,
+ encrypted_query_param, aes_key_value
)
voice_path = self._save_inbound_media(
content,
@@ -741,7 +656,6 @@ class WeixinOCAdapter(Platform):
fallback_suffix=".silk",
)
return Record.fromFileSystem(str(voice_path))
-
return None
async def _resolve_media_file_path(
@@ -757,7 +671,6 @@ class WeixinOCAdapter(Platform):
except Exception as e:
logger.warning("weixin_oc(%s): media resolve failed: %s", self.meta().id, e)
return None
-
if not path:
return None
media_path = Path(path)
@@ -768,17 +681,14 @@ class WeixinOCAdapter(Platform):
return media_path
async def _send_items_to_session(
- self,
- user_id: str,
- item_list: list[dict[str, Any]],
+ self, user_id: str, item_list: list[dict[str, Any]]
) -> bool:
if not self.token:
logger.warning("weixin_oc(%s): missing token, skip send", self.meta().id)
return False
if not item_list:
logger.warning(
- "weixin_oc(%s): empty message payload is ignored",
- self.meta().id,
+ "weixin_oc(%s): empty message payload is ignored", self.meta().id
)
return False
context_token = self._context_tokens.get(user_id)
@@ -793,9 +703,7 @@ class WeixinOCAdapter(Platform):
"POST",
"ilink/bot/sendmessage",
payload={
- "base_info": {
- "channel_version": "astrbot",
- },
+ "base_info": {"channel_version": "astrbot"},
"msg": {
"from_user_id": "",
"to_user_id": user_id,
@@ -812,10 +720,7 @@ class WeixinOCAdapter(Platform):
return True
async def _send_media_segment(
- self,
- user_id: str,
- segment: Image | Video | File,
- text: str | None = None,
+ self, user_id: str, segment: Image | Video | File, text: str | None = None
) -> bool:
if not self.token:
logger.warning(
@@ -829,7 +734,6 @@ class WeixinOCAdapter(Platform):
self.meta().id,
)
return False
-
item_type = self.IMAGE_ITEM_TYPE
upload_media_type = self.IMAGE_UPLOAD_TYPE
if isinstance(segment, Video):
@@ -838,7 +742,6 @@ class WeixinOCAdapter(Platform):
elif isinstance(segment, File):
item_type = self.FILE_ITEM_TYPE
upload_media_type = self.FILE_UPLOAD_TYPE
-
file_name = (
segment.name
if isinstance(segment, File) and segment.name
@@ -846,20 +749,14 @@ class WeixinOCAdapter(Platform):
)
try:
media_item = await self._prepare_media_item(
- user_id,
- media_path,
- upload_media_type,
- item_type,
- file_name,
+ user_id, media_path, upload_media_type, item_type, file_name
)
except Exception as e:
logger.error("weixin_oc(%s): prepare media failed: %s", self.meta().id, e)
return False
-
if text:
await self._send_items_to_session(
- user_id,
- [self._build_plain_text_item(text)],
+ user_id, [self._build_plain_text_item(text)]
)
return await self._send_items_to_session(user_id, [media_item])
@@ -868,20 +765,13 @@ class WeixinOCAdapter(Platform):
params = {"bot_type": self.bot_type}
logger.info("weixin_oc(%s): request QR code from %s", self.meta().id, endpoint)
data = await self.client.request_json(
- "GET",
- endpoint,
- params=params,
- token_required=False,
- timeout_ms=15_000,
+ "GET", endpoint, params=params, token_required=False, timeout_ms=15000
)
qrcode = str(data.get("qrcode", "")).strip()
qrcode_url = str(data.get("qrcode_img_content", "")).strip()
if not qrcode or not qrcode_url:
raise RuntimeError("qrcode response missing qrcode or qrcode_img_content")
- qr_console_url = (
- f"https://api.qrserver.com/v1/create-qr-code/?size=300x300&data="
- f"{quote(qrcode_url)}"
- )
+ qr_console_url = f"https://api.qrserver.com/v1/create-qr-code/?size=300x300&data={quote(qrcode_url)}"
logger.info(
"weixin_oc(%s): QR session started, qr_link=%s 请使用手机微信扫码登录,二维码有效期 5 分钟,过期后会自动刷新。",
self.meta().id,
@@ -943,7 +833,6 @@ class WeixinOCAdapter(Platform):
new_session = await self._start_login_session()
self._login_session = new_session
return
-
if status == "confirmed":
bot_token = data.get("bot_token")
account_id = data.get("ilink_bot_id")
@@ -1013,9 +902,7 @@ class WeixinOCAdapter(Platform):
media_component = await self._resolve_inbound_media_component(item)
except Exception as e:
logger.warning(
- "weixin_oc(%s): resolve inbound media failed: %s",
- self.meta().id,
- e,
+ "weixin_oc(%s): resolve inbound media failed: %s", self.meta().id, e
)
media_component = None
if media_component is not None:
@@ -1027,23 +914,20 @@ class WeixinOCAdapter(Platform):
if not from_user_id:
logger.debug("weixin_oc: skip message with empty from_user_id.")
return
-
context_token = str(msg.get("context_token", "")).strip()
if context_token:
self._context_tokens[from_user_id] = context_token
-
- item_list = cast(list[dict[str, Any]], msg.get("item_list", []))
+ item_list = msg.get("item_list", [])
components = await self._item_list_to_components(item_list)
text = self._message_text_from_item_list(item_list)
message_id = str(msg.get("message_id") or msg.get("msg_id") or uuid.uuid4().hex)
create_time = msg.get("create_time_ms") or msg.get("create_time")
- if isinstance(create_time, (int, float)) and create_time > 1_000_000_000_000:
+ if isinstance(create_time, (int, float)) and create_time > 1000000000000:
ts = int(float(create_time) / 1000)
elif isinstance(create_time, (int, float)):
ts = int(create_time)
else:
ts = int(time.time())
-
abm = AstrBotMessage()
abm.self_id = self.meta().id
abm.sender = MessageMember(user_id=from_user_id, nickname=from_user_id)
@@ -1054,7 +938,6 @@ class WeixinOCAdapter(Platform):
abm.message_str = text
abm.timestamp = ts
abm.raw_message = msg
-
self.commit_event(
WeixinOCMessageEvent(
message_str=text,
@@ -1070,9 +953,7 @@ class WeixinOCAdapter(Platform):
"POST",
"ilink/bot/getupdates",
payload={
- "base_info": {
- "channel_version": "astrbot",
- },
+ "base_info": {"channel_version": "astrbot"},
"get_updates_buf": self._sync_buf,
},
token_required=True,
@@ -1098,11 +979,9 @@ class WeixinOCAdapter(Platform):
self._last_inbound_error,
)
return
-
if data.get("get_updates_buf"):
self._sync_buf = str(data.get("get_updates_buf"))
await self._save_account_state()
-
for msg in data.get("msgs", []) if isinstance(data.get("msgs"), list) else []:
if self._shutdown_event.is_set():
return
@@ -1124,19 +1003,15 @@ class WeixinOCAdapter(Platform):
text = self._message_chain_to_text(MessageChain(_components or []))
if not text:
logger.warning(
- "weixin_oc(%s): message without plain text is ignored",
- self.meta().id,
+ "weixin_oc(%s): message without plain text is ignored", self.meta().id
)
return False
return await self._send_items_to_session(
- user_id,
- [self._build_plain_text_item(text)],
+ user_id, [self._build_plain_text_item(text)]
)
async def send_by_session(
- self,
- session: MessageSesion,
- message_chain: MessageChain,
+ self, session: MessageSesion, message_chain: MessageChain
) -> None:
target_user = session.session_id
pending_text = ""
@@ -1145,27 +1020,21 @@ class WeixinOCAdapter(Platform):
if isinstance(segment, Plain):
pending_text += segment.text
continue
-
if isinstance(segment, (Image, Video, File)):
has_supported_segment = True
await self._send_media_segment(
- target_user,
- segment,
- text=pending_text.strip() or None,
+ target_user, segment, text=pending_text.strip() or None
)
pending_text = ""
continue
-
logger.debug(
"weixin_oc(%s): unsupported outbound segment type %s",
self.meta().id,
type(segment).__name__,
)
-
if pending_text:
has_supported_segment = True
await self._send_to_session(target_user, pending_text.strip())
-
if not has_supported_segment:
logger.warning(
"weixin_oc(%s): outbound message ignored, no supported segments",
@@ -1192,17 +1061,14 @@ class WeixinOCAdapter(Platform):
)
await asyncio.sleep(5)
continue
-
current_login = self._login_session
if current_login is None:
continue
-
try:
await self._poll_qr_status(current_login)
except asyncio.TimeoutError:
logger.debug(
- "weixin_oc(%s): qr status long-poll timeout",
- self.meta().id,
+ "weixin_oc(%s): qr status long-poll timeout", self.meta().id
)
except Exception as e:
logger.error(
@@ -1212,7 +1078,6 @@ class WeixinOCAdapter(Platform):
)
current_login.error = str(e)
await asyncio.sleep(2)
-
if self.token:
logger.info(
"weixin_oc(%s): login confirmed, account=%s",
@@ -1220,19 +1085,16 @@ class WeixinOCAdapter(Platform):
self.account_id or "",
)
continue
-
if current_login.error:
await asyncio.sleep(2)
else:
await asyncio.sleep(self.qr_poll_interval)
continue
-
try:
await self._poll_inbound_updates()
except asyncio.TimeoutError:
logger.debug(
- "weixin_oc(%s): inbound long-poll timeout",
- self.meta().id,
+ "weixin_oc(%s): inbound long-poll timeout", self.meta().id
)
except Exception as e:
logger.error(
diff --git a/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py b/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py
index 833eafccb..92e3ddf7f 100644
--- a/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py
+++ b/astrbot/core/platform/sources/weixin_oc/weixin_oc_client.py
@@ -6,7 +6,7 @@ import hashlib
import json
import random
from pathlib import Path
-from typing import Any, cast
+from typing import Any
from urllib.parse import quote
import aiohttp
@@ -38,7 +38,7 @@ class WeixinOCClient:
self._http_session = aiohttp.ClientSession(timeout=timeout)
async def close(self) -> None:
- if self._http_session is not None and not self._http_session.closed:
+ if self._http_session is not None and (not self._http_session.closed):
await self._http_session.close()
self._http_session = None
@@ -58,24 +58,18 @@ class WeixinOCClient:
return f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
def _build_cdn_upload_url(self, upload_param: str, file_key: str) -> str:
- return (
- f"{self.cdn_base_url}/upload?"
- f"encrypted_query_param={quote(upload_param)}&filekey={quote(file_key)}"
- )
+ return f"{self.cdn_base_url}/upload?encrypted_query_param={quote(upload_param)}&filekey={quote(file_key)}"
def _build_cdn_download_url(self, encrypted_query_param: str) -> str:
- return (
- f"{self.cdn_base_url}/download?"
- f"encrypted_query_param={quote(encrypted_query_param)}"
- )
+ return f"{self.cdn_base_url}/download?encrypted_query_param={quote(encrypted_query_param)}"
@staticmethod
def aes_padded_size(size: int) -> int:
- return size + (16 - (size % 16) or 16)
+ return size + (16 - size % 16 or 16)
@staticmethod
def pkcs7_pad(data: bytes, block_size: int = 16) -> bytes:
- pad_len = block_size - (len(data) % block_size)
+ pad_len = block_size - len(data) % block_size
if pad_len == 0:
pad_len = block_size
return data + bytes([pad_len]) * pad_len
@@ -93,20 +87,16 @@ class WeixinOCClient:
@staticmethod
def _build_media_cipher(key: bytes):
- # Weixin OC CDN media transport only exchanges an `aeskey`; no IV is
- # negotiated by the upstream API, so ECB is required for compatibility.
- # This is a known limitation of the WeChat API design.
- # nosec[ECB]
return AES.new(key, AES.MODE_ECB)
@classmethod
def encrypt_cdn_payload(cls, data: bytes, key: bytes) -> bytes:
- cipher = cls._build_media_cipher(key) # nosec[ECB]
+ cipher = cls._build_media_cipher(key)
return cipher.encrypt(cls.pkcs7_pad(data))
@classmethod
def decrypt_cdn_payload(cls, encrypted: bytes, key: bytes) -> bytes:
- cipher = cls._build_media_cipher(key) # nosec[ECB]
+ cipher = cls._build_media_cipher(key)
return cls.pkcs7_unpad(cipher.decrypt(encrypted))
@staticmethod
@@ -141,7 +131,6 @@ class WeixinOCClient:
raise ValueError(
"CDN upload URL missing (need upload_full_url or upload_param)"
)
-
raw_data = await asyncio.to_thread(media_path.read_bytes)
logger.debug(
"weixin_oc(%s): prepare CDN upload file=%s size=%s md5=%s filekey=%s",
@@ -160,11 +149,9 @@ class WeixinOCClient:
len(raw_data),
len(encrypted),
)
-
await self.ensure_http_session()
assert self._http_session is not None
timeout = aiohttp.ClientTimeout(total=self.api_timeout_ms / 1000)
-
async with self._http_session.post(
cdn_url,
data=encrypted,
@@ -201,8 +188,7 @@ class WeixinOCClient:
assert self._http_session is not None
timeout = aiohttp.ClientTimeout(total=self.api_timeout_ms / 1000)
async with self._http_session.get(
- self._build_cdn_download_url(encrypted_query_param),
- timeout=timeout,
+ self._build_cdn_download_url(encrypted_query_param), timeout=timeout
) as resp:
if resp.status >= 400:
detail = await resp.text()
@@ -212,9 +198,7 @@ class WeixinOCClient:
return await resp.read()
async def download_and_decrypt_media(
- self,
- encrypted_query_param: str,
- aes_key_value: str,
+ self, encrypted_query_param: str, aes_key_value: str
) -> bytes:
encrypted = await self.download_cdn_bytes(encrypted_query_param)
key = self.parse_media_aes_key(aes_key_value)
@@ -238,7 +222,6 @@ class WeixinOCClient:
merged_headers = self._build_base_headers(token_required=token_required)
if headers:
merged_headers.update(headers)
-
async with self._http_session.request(
method,
self._resolve_url(endpoint),
@@ -252,12 +235,10 @@ class WeixinOCClient:
raise RuntimeError(f"{method} {endpoint} failed: {resp.status} {text}")
if not text:
return {}
- return cast(dict[str, Any], json.loads(text))
+ return json.loads(text)
async def get_typing_config(
- self,
- user_id: str,
- context_token: str,
+ self, user_id: str, context_token: str
) -> dict[str, Any]:
return await self.request_json(
"POST",
@@ -265,20 +246,14 @@ class WeixinOCClient:
payload={
"ilink_user_id": user_id,
"context_token": context_token,
- "base_info": {
- "channel_version": "astrbot",
- },
+ "base_info": {"channel_version": "astrbot"},
},
token_required=True,
timeout_ms=self.api_timeout_ms,
)
async def send_typing_state(
- self,
- user_id: str,
- typing_ticket: str,
- *,
- cancel: bool,
+ self, user_id: str, typing_ticket: str, *, cancel: bool
) -> dict[str, Any]:
return await self.request_json(
"POST",
@@ -287,9 +262,7 @@ class WeixinOCClient:
"ilink_user_id": user_id,
"typing_ticket": typing_ticket,
"status": 2 if cancel else 1,
- "base_info": {
- "channel_version": "astrbot",
- },
+ "base_info": {"channel_version": "astrbot"},
},
token_required=True,
timeout_ms=self.api_timeout_ms,
diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py
index 2fff26f9d..6cbb9bd33 100644
--- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py
+++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py
@@ -3,7 +3,7 @@ import os
import time
import uuid
from collections.abc import Callable, Coroutine
-from typing import Any, cast, override
+from typing import Any, override
import aiofiles
import quart
@@ -41,33 +41,26 @@ class WeixinOfficialAccountServer:
user_buffer: dict[Any, dict[str, Any]],
) -> None:
self.server = quart.Quart(__name__)
- self.port = int(cast(int | str, config.get("port")))
+ self.port = int(config.get("port"))
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
self.token = config.get("token")
self.encoding_aes_key = config.get("encoding_aes_key")
self.appid = config.get("appid")
self.server.add_url_rule(
- "/callback/command",
- view_func=self.verify,
- methods=["GET"],
+ "/callback/command", view_func=self.verify, methods=["GET"]
)
self.server.add_url_rule(
- "/callback/command",
- view_func=self.callback_command,
- methods=["POST"],
+ "/callback/command", view_func=self.callback_command, methods=["POST"]
)
self.crypto = WeChatCrypto(self.token, self.encoding_aes_key, self.appid)
-
self.event_queue = event_queue
-
self.callback: (
Callable[[BaseMessage], Coroutine[Any, Any, str | None]] | None
) = None
self.shutdown_event = asyncio.Event()
-
- self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复
- self.user_buffer: dict[str, dict[str, Any]] = user_buffer # from_user -> state
- self.active_send_mode = False # 是否启用主动发送模式,启用后 callback 将直接返回回复内容,无需等待微信回调
+ self._wx_msg_time_out = 4.0
+ self.user_buffer: dict[str, dict[str, Any]] = user_buffer
+ self.active_send_mode = False
async def verify(self):
"""内部服务器的 GET 验证入口"""
@@ -83,7 +76,6 @@ class WeixinOfficialAccountServer:
验证响应
"""
logger.info(f"验证请求有效性: {request.args}")
-
args = request.args
if not args.get("signature", None):
logger.error("未知的响应,请检查回调地址是否填写正确。")
@@ -113,8 +105,8 @@ class WeixinOfficialAccountServer:
def _preview(self, msg: BaseMessage, limit: int = 24) -> str:
"""生成消息预览文本,供占位符使用"""
if isinstance(msg, TextMessage):
- t = cast(str, msg.content).strip()
- return (t[:limit] + "...") if len(t) > limit else (t or "空消息")
+ t = msg.content.strip()
+ return t[:limit] + "..." if len(t) > limit else t or "空消息"
if isinstance(msg, ImageMessage):
return "图片"
if isinstance(msg, VoiceMessage):
@@ -145,21 +137,16 @@ class WeixinOfficialAccountServer:
logger.error("解析失败。msg为None。")
raise
logger.info(f"解析成功: {msg}")
-
if not self.callback:
return "success"
-
- # by pass passive reply logic and return active reply directly.
if self.active_send_mode:
result_xml = await self.callback(msg)
if not result_xml:
return "success"
if isinstance(result_xml, str):
return result_xml
-
- # passive reply
from_user = str(getattr(msg, "source", ""))
- msg_id = str(cast(str | int, getattr(msg, "id", "")))
+ msg_id = str(getattr(msg, "id", ""))
state = self.user_buffer.get(from_user)
def _reply_text(text: str) -> str:
@@ -167,11 +154,9 @@ class WeixinOfficialAccountServer:
reply_xml = reply_obj if isinstance(reply_obj, str) else str(reply_obj)
return self._maybe_encrypt(reply_xml, nonce, timestamp)
- # if in cached state, return cached result or placeholder
if state:
logger.debug(f"用户消息缓冲状态: user={from_user} state={state}")
cached = state.get("cached_xml")
- # send one cached each time, if cached is empty after pop, remove the buffer
if cached and len(cached) > 0:
logger.info(f"wx buffer hit on trigger: user={from_user}")
cached_xml = cached.pop(0)
@@ -182,14 +167,8 @@ class WeixinOfficialAccountServer:
return _reply_text(
cached_xml + "\n【后续消息还在缓冲中,回复任意文字继续获取】"
)
-
- task: asyncio.Task | None = cast(asyncio.Task | None, state.get("task"))
- placeholder = (
- f"【正在思考'{state.get('preview', '...')}'中,已思考"
- f"{int(time.monotonic() - state.get('started_at', time.monotonic()))}s,回复任意文字尝试获取回复】"
- )
-
- # same msgid => WeChat retry: wait a little; new msgid => user trigger: just placeholder
+ task: asyncio.Task | None = state.get("task")
+ placeholder = f"【正在思考'{state.get('preview', '...')}'中,已思考{int(time.monotonic() - state.get('started_at', time.monotonic()))}s,回复任意文字尝试获取回复】"
if task and state.get("msg_id") == msg_id:
done, _ = await asyncio.wait(
{task},
@@ -199,7 +178,6 @@ class WeixinOfficialAccountServer:
if done:
try:
cached = state.get("cached_xml")
- # send one cached each time, if cached is empty after pop, remove the buffer
if cached and len(cached) > 0:
logger.info(
f"wx buffer hit on retry window: user={from_user}"
@@ -229,35 +207,28 @@ class WeixinOfficialAccountServer:
)
self.user_buffer.pop(from_user, None)
return _reply_text("处理消息失败,请稍后再试。")
-
logger.info(
f"wx passive window timeout: user={from_user} msg_id={msg_id}"
)
return _reply_text(placeholder)
-
logger.debug(f"wx trigger while thinking: user={from_user}")
return _reply_text(placeholder)
-
- # create new trigger when state is empty, and store state in buffer
logger.debug(f"wx new trigger: user={from_user} msg_id={msg_id}")
preview = self._preview(msg)
placeholder = f"【正在思考'{preview}'中,已思考0s,回复任意文字尝试获取回复】"
logger.info(
f"wx start task: user={from_user} msg_id={msg_id} preview={preview}"
)
-
self.user_buffer[from_user] = state = {
"msg_id": msg_id,
"preview": preview,
- "task": None, # set later after task created
- "cached_xml": [], # for passive reply
+ "task": None,
+ "cached_xml": [],
"started_at": time.monotonic(),
}
self.user_buffer[from_user]["task"] = task = asyncio.create_task(
self.callback(msg)
)
-
- # immediate return if done
done, _ = await asyncio.wait(
{task},
timeout=self._wx_msg_time_out,
@@ -266,8 +237,7 @@ class WeixinOfficialAccountServer:
if done:
try:
cached = state.get("cached_xml", None)
- # send one cached each time, if cached is empty after pop, remove the buffer
- if cached and isinstance(cached, list) and len(cached) > 0:
+ if cached and isinstance(cached, list) and (len(cached) > 0):
logger.info(f"wx buffer hit immediately: user={from_user}")
cached_xml = cached.pop(0)
if len(cached) == 0:
@@ -286,13 +256,12 @@ class WeixinOfficialAccountServer:
logger.critical("wx task failed in first window", exc_info=True)
self.user_buffer.pop(from_user, None)
return _reply_text("处理消息失败,请稍后再试。")
-
logger.info(f"wx first window timeout: user={from_user} msg_id={msg_id}")
return _reply_text(placeholder)
async def start_polling(self) -> None:
logger.info(
- f"将在 {self.callback_server_host}:{self.port} 端口启动 微信公众平台 适配器。",
+ f"将在 {self.callback_server_host}:{self.port} 端口启动 微信公众平台 适配器。"
)
await self.server.run_task(
host=self.callback_server_host,
@@ -309,45 +278,31 @@ class WeixinOfficialAccountServer:
)
class WeixinOfficialAccountPlatformAdapter(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.settingss = platform_settings
self.client_self_id = uuid.uuid4().hex[:8]
self.api_base_url = platform_config.get(
- "api_base_url",
- "https://api.weixin.qq.com/cgi-bin/",
+ "api_base_url", "https://api.weixin.qq.com/cgi-bin/"
)
self.active_send_mode = self.config.get("active_send_mode", False)
self.unified_webhook_mode = platform_config.get("unified_webhook_mode", False)
-
if not self.api_base_url:
self.api_base_url = "https://api.weixin.qq.com/cgi-bin/"
-
self.api_base_url = self.api_base_url.removesuffix("/")
if not self.api_base_url.endswith("/cgi-bin"):
self.api_base_url += "/cgi-bin"
-
if not self.api_base_url.endswith("/"):
self.api_base_url += "/"
-
- self.user_buffer: dict[str, dict[str, Any]] = {} # from_user -> state
+ self.user_buffer: dict[str, dict[str, Any]] = {}
self.server = WeixinOfficialAccountServer(
self._event_queue, self.config, self.user_buffer
)
-
self.client = WeChatClient(
- self.config["appid"].strip(),
- self.config["secret"].strip(),
+ self.config["appid"].strip(), self.config["secret"].strip()
)
-
self.client.__setattr__("API_BASE_URL", self.api_base_url)
-
- # 微信公众号必须 5 秒内进行回复,否则会重试 3 次,我们需要对其进行消息排重
- # msgid -> Future
self.wexin_event_workers: dict[str, asyncio.Future] = {}
async def callback(msg: BaseMessage):
@@ -355,8 +310,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
if self.active_send_mode:
await self.convert_message(msg, None)
return None
-
- msg_id = str(cast(str | int, msg.id))
+ msg_id = str(msg.id)
future = self.wexin_event_workers.get(msg_id)
if future:
logger.debug(f"duplicate message id checked: {msg.id}")
@@ -364,11 +318,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
future = asyncio.get_running_loop().create_future()
self.wexin_event_workers[msg_id] = future
await self.convert_message(msg, future)
- # I love shield so much!
- result = await asyncio.wait_for(
- asyncio.shield(future),
- 180,
- ) # wait for 180s
+ result = await asyncio.wait_for(asyncio.shield(future), 180)
logger.debug(f"Got future result: {result}")
return result
except TimeoutError:
@@ -377,16 +327,14 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
except Exception as e:
logger.error(f"转换消息时出现异常: {e}")
finally:
- self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
+ self.wexin_event_workers.pop(str(msg.id), None)
self.server.callback = callback
self.server.active_send_mode = self.active_send_mode
@override
async def send_by_session(
- self,
- session: MessageSesion,
- message_chain: MessageChain,
+ self, session: MessageSesion, message_chain: MessageChain
) -> None:
await super().send_by_session(session, message_chain)
@@ -402,97 +350,72 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
@override
async def run(self) -> None:
- # 如果启用统一 webhook 模式,则不启动独立服务器
webhook_uuid = self.config.get("webhook_uuid")
if self.unified_webhook_mode and webhook_uuid:
log_webhook_info(f"{self.meta().id}(微信公众平台)", webhook_uuid)
- # 保持运行状态,等待 shutdown
await self.server.shutdown_event.wait()
else:
await self.server.start_polling()
async def webhook_callback(self, request: Any) -> Any:
"""统一 Webhook 回调入口"""
- # 根据请求方法分发到不同的处理函数
if request.method == "GET":
return await self.server.handle_verify(request)
else:
return await self.server.handle_callback(request)
async def convert_message(
- self,
- msg,
- future: asyncio.Future | None = None,
+ self, msg, future: asyncio.Future | None = None
) -> AstrBotMessage | None:
abm = AstrBotMessage()
if isinstance(msg, TextMessage):
- abm.message_str = cast(str, msg.content)
+ abm.message_str = msg.content
abm.self_id = str(msg.target)
- abm.message = [Plain(cast(str, msg.content))]
+ abm.message = [Plain(msg.content)]
abm.type = MessageType.FRIEND_MESSAGE
- abm.sender = MessageMember(
- cast(str, msg.source),
- cast(str, msg.source),
- )
- abm.message_id = str(cast(str | int, msg.id))
- abm.timestamp = cast(int, msg.time)
+ abm.sender = MessageMember(msg.source, msg.source)
+ abm.message_id = str(msg.id)
+ abm.timestamp = msg.time
abm.session_id = abm.sender.user_id
elif msg.type == "image":
assert isinstance(msg, ImageMessage)
abm.message_str = "[图片]"
abm.self_id = str(msg.target)
- abm.message = [Image(file=cast(str, msg.image), url=cast(str, msg.image))]
+ abm.message = [Image(file=msg.image, url=msg.image)]
abm.type = MessageType.FRIEND_MESSAGE
- abm.sender = MessageMember(
- cast(str, msg.source),
- cast(str, msg.source),
- )
- abm.message_id = str(cast(str | int, msg.id))
- abm.timestamp = cast(int, msg.time)
+ abm.sender = MessageMember(msg.source, msg.source)
+ abm.message_id = str(msg.id)
+ abm.timestamp = msg.time
abm.session_id = abm.sender.user_id
elif msg.type == "voice":
assert isinstance(msg, VoiceMessage)
-
resp: Response = await asyncio.get_running_loop().run_in_executor(
- None,
- self.client.media.download,
- msg.media_id,
+ None, self.client.media.download, msg.media_id
)
temp_dir = get_astrbot_temp_path()
path = os.path.join(temp_dir, f"weixin_offacc_{msg.media_id}.amr")
async with aiofiles.open(path, "wb") as f:
await f.write(resp.content)
-
try:
- path_wav = os.path.join(
- temp_dir,
- f"weixin_offacc_{msg.media_id}.wav",
- )
+ path_wav = os.path.join(temp_dir, f"weixin_offacc_{msg.media_id}.wav")
path_wav = await convert_audio_to_wav(path, path_wav)
except Exception as e:
- logger.error(
- f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。",
- )
+ logger.error(f"转换音频失败: {e}。如果没有安装 ffmpeg 请先安装。")
path_wav = path
return None
-
abm.message_str = ""
abm.self_id = str(msg.target)
abm.message = [Record(file=path_wav, url=path_wav)]
abm.type = MessageType.FRIEND_MESSAGE
- abm.sender = MessageMember(
- cast(str, msg.source),
- cast(str, msg.source),
- )
- abm.message_id = str(cast(str | int, msg.id))
- abm.timestamp = cast(int, msg.time)
+ abm.sender = MessageMember(msg.source, msg.source)
+ abm.message_id = str(msg.id)
+ abm.timestamp = msg.time
abm.session_id = abm.sender.user_id
else:
logger.warning(f"暂未实现的事件: {msg.type}")
if future:
future.set_result(None)
return
- # 很不优雅 :(
abm.raw_message = {
"message": msg,
"future": future,
diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py
index c12ddada0..9ac2a4940 100644
--- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py
+++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py
@@ -1,5 +1,5 @@
import asyncio
-from typing import Any, cast
+from typing import Any
import aiofiles
import anyio
@@ -29,9 +29,7 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
@staticmethod
async def send_with_client(
- client: WeChatClient,
- message: MessageChain,
- user_name: str,
+ client: WeChatClient, message: MessageChain, user_name: str
) -> None:
pass
@@ -49,12 +47,9 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
result = []
start = 0
while start < len(plain):
- # 剩下的字符串长度= len(plain):
result.append(plain[start:])
break
-
- # 向前搜索分割标点符号
end = min(start + max_length, len(plain))
cut_position = end
for i in range(end, start, -1):
@@ -71,67 +66,54 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
]:
cut_position = i
break
-
- # 没找到合适的位置分割, 直接切分
if cut_position == end and end < len(plain):
cut_position = end
-
result.append(plain[start:cut_position])
start = cut_position
-
return result
async def send(self, message: MessageChain) -> None:
message_obj = self.message_obj
- active_send_mode = cast(dict, message_obj.raw_message).get(
- "active_send_mode", False
- )
+ active_send_mode = message_obj.raw_message.get("active_send_mode", False)
for comp in message.chain:
if isinstance(comp, Plain):
- # Split long text messages if needed
plain_chunks = await self.split_plain(comp.text)
if active_send_mode:
for chunk in plain_chunks:
self.client.message.send_text(message_obj.sender.user_id, chunk)
else:
- # disable passive sending, just store the chunks in
logger.debug(
f"split plain into {len(plain_chunks)} chunks for passive reply. Message not sent."
)
self.message_out["cached_xml"] = plain_chunks
elif isinstance(comp, Image):
img_path = await comp.convert_to_file_path()
-
async with aiofiles.open(img_path, "rb") as f:
try:
response = self.client.media.upload("image", f)
except Exception as e:
logger.error(f"微信公众平台上传图片失败: {e}")
await self.send(
- MessageChain().message(f"微信公众平台上传图片失败: {e}"),
+ MessageChain().message(f"微信公众平台上传图片失败: {e}")
)
return
logger.debug(f"微信公众平台上传图片返回: {response}")
-
if active_send_mode:
self.client.message.send_image(
- message_obj.sender.user_id,
- response["media_id"],
+ message_obj.sender.user_id, response["media_id"]
)
else:
reply = ImageReply(
media_id=response["media_id"],
- message=cast(dict, self.message_obj.raw_message)["message"],
+ message=self.message_obj.raw_message["message"],
)
xml = reply.render()
- future = cast(dict, self.message_obj.raw_message)["future"]
+ future = self.message_obj.raw_message["future"]
assert isinstance(future, asyncio.Future)
future.set_result(xml)
-
elif isinstance(comp, Record):
record_path = await comp.convert_to_file_path()
record_path_amr = await convert_audio_to_amr(record_path)
-
try:
async with aiofiles.open(record_path_amr, "rb") as f:
try:
@@ -139,27 +121,21 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
except Exception as e:
logger.error(f"微信公众平台上传语音失败: {e}")
await self.send(
- MessageChain().message(
- f"微信公众平台上传语音失败: {e}"
- ),
+ MessageChain().message(f"微信公众平台上传语音失败: {e}")
)
return
logger.info(f"微信公众平台上传语音返回: {response}")
-
if active_send_mode:
self.client.message.send_voice(
- message_obj.sender.user_id,
- response["media_id"],
+ message_obj.sender.user_id, response["media_id"]
)
else:
reply = VoiceReply(
media_id=response["media_id"],
- message=cast(dict, self.message_obj.raw_message)[
- "message"
- ],
+ message=self.message_obj.raw_message["message"],
)
xml = reply.render()
- future = cast(dict, self.message_obj.raw_message)["future"]
+ future = self.message_obj.raw_message["future"]
assert isinstance(future, asyncio.Future)
future.set_result(xml)
finally:
@@ -171,10 +147,8 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
await anyio.Path(record_path_amr).unlink()
except OSError as e:
logger.warning(f"删除临时音频文件失败: {e}")
-
else:
logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}。")
-
await super().send(message)
async def send_streaming(self, generator, use_fallback: bool = False):
diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py
index b7caca877..b4a57fd63 100644
--- a/astrbot/core/provider/provider.py
+++ b/astrbot/core/provider/provider.py
@@ -2,7 +2,7 @@ import abc
import asyncio
import os
from collections.abc import AsyncGenerator
-from typing import Literal, TypeAlias, Union, cast
+from typing import Literal, TypeAlias, Union
import aiofiles
import anyio
@@ -19,11 +19,7 @@ from astrbot.core.provider.register import provider_cls_map
from astrbot.core.utils.astrbot_path import get_astrbot_path
Providers: TypeAlias = Union[
- "Provider",
- "STTProvider",
- "TTSProvider",
- "EmbeddingProvider",
- "RerankProvider",
+ "Provider", "STTProvider", "TTSProvider", "EmbeddingProvider", "RerankProvider"
]
@@ -69,11 +65,7 @@ class AbstractProvider(abc.ABC):
class Provider(AbstractProvider):
"""Chat Provider"""
- def __init__(
- self,
- provider_config: dict,
- provider_settings: dict,
- ) -> None:
+ def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config)
self.provider_settings = provider_settings
@@ -162,8 +154,8 @@ class Provider(AbstractProvider):
- 如果传入了 tools,将会使用 tools 进行 Function-calling。如果模型不支持 Function-calling,将会抛出错误。
"""
- if False: # pragma: no cover - make this an async generator for typing
- yield cast(LLMResponse, None)
+ if False:
+ yield None
raise NotImplementedError()
async def pop_record(self, context: list) -> None:
@@ -177,13 +169,11 @@ class Provider(AbstractProvider):
poped += 1
if poped == 2:
break
-
for idx in reversed(indexs_to_pop):
context.pop(idx)
def _ensure_message_to_dicts(
- self,
- messages: list[dict] | list[Message] | None,
+ self, messages: list[dict] | list[Message] | None
) -> list[dict]:
"""Convert a list of Message objects to a list of dictionaries."""
if not messages:
@@ -194,12 +184,9 @@ class Provider(AbstractProvider):
dicts.append(message.model_dump())
else:
dicts.append(message)
-
return dicts
async def test(self, test_timeout: float = 45.0) -> None:
- # Use anyio.fail_after to enforce timeout in async context.
- # This avoids direct asyncio.wait_for usage inside async functions.
with anyio.fail_after(test_timeout):
await self.text_chat(prompt="REPLY `PONG` ONLY")
@@ -217,9 +204,7 @@ class STTProvider(AbstractProvider):
async def test(self) -> None:
sample_audio_path = os.path.join(
- get_astrbot_path(),
- "samples",
- "stt_health_check.wav",
+ get_astrbot_path(), "samples", "stt_health_check.wav"
)
await self.get_text(sample_audio_path)
@@ -266,45 +251,31 @@ class TTSProvider(AbstractProvider):
- 音频数据应该是 WAV 格式的 bytes
"""
accumulated_text = ""
-
while True:
text_part = await text_queue.get()
-
if text_part is None:
- # 输入结束,处理累积的文本
if accumulated_text:
try:
- # 调用原有的 get_audio 方法获取音频文件路径
audio_path = await self.get_audio(accumulated_text)
- # 读取音频文件内容
async with aiofiles.open(audio_path, "rb") as f:
audio_data = await f.read()
await audio_queue.put((accumulated_text, audio_data))
except Exception:
- # 出错时也要发送 None 结束标记
pass
- # 发送结束标记
await audio_queue.put(None)
break
-
accumulated_text += text_part
async def test(self) -> None:
audio_path = await self.get_audio("hi")
-
- # 检查生成的音频文件是否有效
audio_path_obj = anyio.Path(audio_path)
if not await audio_path_obj.exists():
raise Exception("TTS test failed: audio file was not created")
-
file_size = (await audio_path_obj.stat()).st_size
if file_size == 0:
raise Exception(
- "TTS test failed: generated audio file is empty (0 bytes). "
- "Please check your TTS provider configuration, especially required parameters like group_id for MiniMax."
+ "TTS test failed: generated audio file is empty (0 bytes). Please check your TTS provider configuration, especially required parameters like group_id for MiniMax."
)
-
- # 清理测试文件
try:
os.remove(audio_path)
except Exception:
@@ -375,12 +346,10 @@ class EmbeddingProvider(AbstractProvider):
return
except Exception as e:
if attempt == max_retries - 1:
- # 最后一次重试失败,记录失败的批次
failed_batches.append((batch_idx, batch_texts))
raise Exception(
- f"批次 {batch_idx} 处理失败,已重试 {max_retries} 次: {e!s}",
+ f"批次 {batch_idx} 处理失败,已重试 {max_retries} 次: {e!s}"
)
- # 等待一段时间后重试,使用指数退避
await asyncio.sleep(2**attempt)
tasks = []
@@ -388,18 +357,11 @@ class EmbeddingProvider(AbstractProvider):
batch_texts = texts[i : i + batch_size]
batch_idx = i // batch_size
tasks.append(process_batch(batch_idx, batch_texts))
-
- # 收集所有任务的结果,包括失败的任务
results = await asyncio.gather(*tasks, return_exceptions=True)
-
- # 检查是否有失败的任务
errors = [r for r in results if isinstance(r, Exception)]
if errors:
- error_msg = (
- f"有 {len(errors)} 个批次处理失败: {'; '.join(str(e) for e in errors)}"
- )
+ error_msg = f"有 {len(errors)} 个批次处理失败: {'; '.join(str(e) for e in errors)}"
raise Exception(error_msg)
-
return all_embeddings
@@ -411,10 +373,7 @@ class RerankProvider(AbstractProvider):
@abc.abstractmethod
async def rerank(
- self,
- query: str,
- documents: list[str],
- top_n: int | None = None,
+ self, query: str, documents: list[str], top_n: int | None = None
) -> list[RerankResult]:
"""获取查询和文档的重排序分数"""
...
diff --git a/astrbot/core/provider/sources/dashscope_tts.py b/astrbot/core/provider/sources/dashscope_tts.py
index 35dd9651e..79acc0cd8 100644
--- a/astrbot/core/provider/sources/dashscope_tts.py
+++ b/astrbot/core/provider/sources/dashscope_tts.py
@@ -15,11 +15,9 @@ try:
)
_MultiModalConversationType: type = MultiModalConversation
-except (
- ImportError,
-): # pragma: no cover - older dashscope versions without Qwen TTS support
- MultiModalConversation = None # type: ignore[assignment]
- _MultiModalConversationType = None # type: ignore[assignment]
+except ImportError: # pragma: no cover - older dashscope versions without Qwen TTS support
+ MultiModalConversation = None # type: ignore
+ _MultiModalConversationType = None # type: ignore
from astrbot.core.provider.entities import ProviderType
from astrbot.core.provider.provider import TTSProvider
diff --git a/astrbot/core/provider/sources/edge_tts_source.py b/astrbot/core/provider/sources/edge_tts_source.py
index fcd12ee33..44b000e4e 100644
--- a/astrbot/core/provider/sources/edge_tts_source.py
+++ b/astrbot/core/provider/sources/edge_tts_source.py
@@ -4,7 +4,7 @@ import subprocess
import uuid
import anyio
-import edge_tts # type: ignore[import]
+import edge_tts # type: ignore
from astrbot.core import logger
from astrbot.core.provider.entities import ProviderType
@@ -64,7 +64,7 @@ class ProviderEdgeTTS(TTSProvider):
await communicate.save(mp3_path)
try:
- from pyffmpeg import FFmpeg # type: ignore[import]
+ from pyffmpeg import FFmpeg # type: ignore
ff = FFmpeg()
ff.convert(input_file=mp3_path, output_file=wav_path)
diff --git a/astrbot/core/provider/sources/gemini_embedding_source.py b/astrbot/core/provider/sources/gemini_embedding_source.py
index 3885d00eb..59459826c 100644
--- a/astrbot/core/provider/sources/gemini_embedding_source.py
+++ b/astrbot/core/provider/sources/gemini_embedding_source.py
@@ -1,5 +1,3 @@
-from typing import cast
-
from google import genai
from google.genai import types
from google.genai.errors import APIError
@@ -20,11 +18,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
super().__init__(provider_config, provider_settings)
self.provider_config = provider_config
self.provider_settings = provider_settings
-
api_key: str = provider_config["embedding_api_key"]
api_base: str = provider_config["embedding_api_base"]
timeout: int = int(provider_config.get("timeout", 20))
-
http_options = types.HttpOptions(timeout=timeout * 1000)
if api_base:
api_base = api_base.removesuffix("/")
@@ -33,12 +29,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
if proxy:
http_options.async_client_args = {"proxy": proxy}
logger.info(f"[Gemini Embedding] 使用代理: {proxy}")
-
self.client = genai.Client(api_key=api_key, http_options=http_options).aio
-
self.model = provider_config.get(
- "embedding_model",
- "gemini-embedding-exp-03-07",
+ "embedding_model", "gemini-embedding-exp-03-07"
)
async def get_embedding(self, text: str) -> list[float]:
@@ -47,9 +40,7 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
result = await self.client.models.embed_content(
model=self.model,
contents=text,
- config=types.EmbedContentConfig(
- output_dimensionality=self.get_dim(),
- ),
+ config=types.EmbedContentConfig(output_dimensionality=self.get_dim()),
)
assert result.embeddings is not None
assert result.embeddings[0].values is not None
@@ -62,13 +53,10 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
try:
result = await self.client.models.embed_content(
model=self.model,
- contents=cast(types.ContentListUnion, text),
- config=types.EmbedContentConfig(
- output_dimensionality=self.get_dim(),
- ),
+ contents=text,
+ config=types.EmbedContentConfig(output_dimensionality=self.get_dim()),
)
assert result.embeddings is not None
-
embeddings: list[list[float]] = []
for embedding in result.embeddings:
assert embedding.values is not None
diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py
index 676f8f48a..a3638bae8 100644
--- a/astrbot/core/provider/sources/gemini_source.py
+++ b/astrbot/core/provider/sources/gemini_source.py
@@ -4,7 +4,7 @@ import json
import logging
import random
from collections.abc import AsyncGenerator
-from typing import ClassVar, Literal, cast
+from typing import ClassVar, Literal
import aiofiles
from google import genai
@@ -15,10 +15,10 @@ import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart, ImageURLPart, Message, TextPart
+from astrbot.core.agent.tool import ToolSet
from astrbot.core.exceptions import EmptyModelOutputError
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
-from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.provider.register import register_provider_adapter
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.network_utils import is_connection_error, log_connection_failure
@@ -35,8 +35,7 @@ logging.getLogger("google_genai.types").addFilter(SuppressNonTextPartsWarning())
@register_provider_adapter(
- "googlegenai_chat_completion",
- "Google Gemini Chat Completion 提供商适配器",
+ "googlegenai_chat_completion", "Google Gemini Chat Completion 提供商适配器"
)
class ProviderGoogleGenAI(Provider):
CATEGORY_MAPPING: ClassVar[dict[str, types.HarmCategory]] = {
@@ -45,7 +44,6 @@ class ProviderGoogleGenAI(Provider):
"sexually_explicit": types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
"dangerous_content": types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
}
-
THRESHOLD_MAPPING: ClassVar[dict[str, types.HarmBlockThreshold]] = {
"BLOCK_NONE": types.HarmBlockThreshold.BLOCK_NONE,
"BLOCK_ONLY_HIGH": types.HarmBlockThreshold.BLOCK_ONLY_HIGH,
@@ -53,23 +51,14 @@ class ProviderGoogleGenAI(Provider):
"BLOCK_LOW_AND_ABOVE": types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
}
- def __init__(
- self,
- provider_config,
- provider_settings,
- ) -> None:
- super().__init__(
- provider_config,
- provider_settings,
- )
+ def __init__(self, provider_config, provider_settings) -> None:
+ super().__init__(provider_config, provider_settings)
self.api_keys: list = super().get_keys()
self.chosen_api_key: str = self.api_keys[0] if len(self.api_keys) > 0 else ""
self.timeout: int = int(provider_config.get("timeout", 180))
-
self.api_base: str | None = provider_config.get("api_base", None)
if self.api_base and self.api_base.endswith("/"):
self.api_base = self.api_base[:-1]
-
self._init_client()
self.set_model(provider_config.get("model", "unknown"))
self._init_safety_settings()
@@ -78,15 +67,13 @@ class ProviderGoogleGenAI(Provider):
"""初始化Gemini客户端"""
proxy = self.provider_config.get("proxy", "")
http_options = types.HttpOptions(
- base_url=self.api_base,
- timeout=self.timeout * 1000, # 毫秒
+ base_url=self.api_base, timeout=self.timeout * 1000
)
if proxy:
http_options.async_client_args = {"proxy": proxy}
logger.info(f"[Gemini] 使用代理: {proxy}")
self.client = genai.Client(
- api_key=self.chosen_api_key,
- http_options=http_options,
+ api_key=self.chosen_api_key, http_options=http_options
).aio
def _init_safety_settings(self) -> None:
@@ -94,8 +81,7 @@ class ProviderGoogleGenAI(Provider):
user_safety_config = self.provider_config.get("gm_safety_settings", {})
self.safety_settings = [
types.SafetySetting(
- category=harm_category,
- threshold=self.THRESHOLD_MAPPING[threshold_str],
+ category=harm_category, threshold=self.THRESHOLD_MAPPING[threshold_str]
)
for config_key, harm_category in self.CATEGORY_MAPPING.items()
if (threshold_str := user_safety_config.get(config_key))
@@ -106,26 +92,20 @@ class ProviderGoogleGenAI(Provider):
"""处理API错误,返回是否需要重试"""
if e.message is None:
e.message = ""
-
if e.code == 429 or "API key not valid" in e.message:
keys.remove(self.chosen_api_key)
if len(keys) > 0:
self.set_key(random.choice(keys))
logger.info(
- f"检测到 Key 异常({e.message}),正在尝试更换 API Key 重试...",
+ f"检测到 Key 异常({e.message}),正在尝试更换 API Key 重试..."
)
await asyncio.sleep(1)
return True
- logger.error(
- f"检测到 Key 异常({e.message}),且已没有可用的 Key。",
- )
+ logger.error(f"检测到 Key 异常({e.message}),且已没有可用的 Key。")
raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
-
- # 连接错误处理
if is_connection_error(e):
proxy = self.provider_config.get("proxy", "")
log_connection_failure("Gemini", e, proxy)
-
raise e
async def _prepare_query_config(
@@ -141,19 +121,15 @@ class ProviderGoogleGenAI(Provider):
"""准备查询配置"""
if not modalities:
modalities = ["TEXT"]
-
- # 流式输出不支持图片模态
if streaming and "IMAGE" in modalities:
logger.warning("流式输出不支持图片模态,已自动降级为文本模态")
modalities = ["TEXT"]
-
tool_list: list[types.Tool] = []
model_value = payloads.get("model", self.get_model())
model_name = model_value if isinstance(model_value, str) else self.get_model()
native_coderunner = self.provider_config.get("gm_native_coderunner", False)
native_search = self.provider_config.get("gm_native_search", False)
url_context = self.provider_config.get("gm_url_context", False)
-
if "gemini-2.5" in model_name:
if native_coderunner:
tool_list.append(types.Tool(code_execution=types.ToolCodeExecution()))
@@ -161,24 +137,22 @@ class ProviderGoogleGenAI(Provider):
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
if url_context:
logger.warning(
- "代码执行工具与URL上下文工具互斥,已忽略URL上下文工具",
+ "代码执行工具与URL上下文工具互斥,已忽略URL上下文工具"
)
else:
if native_search:
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
-
if url_context:
if hasattr(types, "UrlContext"):
tool_list.append(types.Tool(url_context=types.UrlContext()))
else:
logger.warning(
- "当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包",
+ "当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包"
)
-
elif "gemini-2.0-lite" in model_name:
if native_coderunner or native_search or url_context:
logger.warning(
- "gemini-2.0-lite 不支持代码执行、搜索工具和URL上下文,将忽略这些设置",
+ "gemini-2.0-lite 不支持代码执行、搜索工具和URL上下文,将忽略这些设置"
)
else:
if native_coderunner:
@@ -187,35 +161,28 @@ class ProviderGoogleGenAI(Provider):
logger.warning("代码执行工具与搜索工具互斥,已忽略搜索工具")
elif native_search:
tool_list.append(types.Tool(google_search=types.GoogleSearch()))
-
- if url_context and not native_coderunner:
+ if url_context and (not native_coderunner):
if hasattr(types, "UrlContext"):
tool_list.append(types.Tool(url_context=types.UrlContext()))
else:
logger.warning(
- "当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包",
+ "当前 SDK 版本不支持 URL 上下文工具,已忽略该设置,请升级 google-genai 包"
)
-
if tools and tool_list:
logger.warning("已启用原生工具,函数工具将被忽略")
elif tools and (func_desc := tools.get_func_desc_google_genai_style()):
tool_list = [
- types.Tool(function_declarations=func_desc["function_declarations"]),
+ types.Tool(function_declarations=func_desc["function_declarations"])
]
-
tool_config = None
if tools and tool_list:
tool_config = types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(
- mode=(
- types.FunctionCallingConfigMode.ANY
- if tool_choice == "required"
- else types.FunctionCallingConfigMode.AUTO
- )
+ mode=types.FunctionCallingConfigMode.ANY
+ if tool_choice == "required"
+ else types.FunctionCallingConfigMode.AUTO
)
)
-
- # oper thinking config
thinking_config = None
if model_name in [
"gemini-2.5-pro",
@@ -227,14 +194,11 @@ class ProviderGoogleGenAI(Provider):
"gemini-robotics-er-1.5-preview",
"gemini-live-2.5-flash-preview-native-audio-09-2025",
]:
- # The thinkingBudget parameter, introduced with the Gemini 2.5 series
thinking_budget = self.provider_config.get("gm_thinking_config", {}).get(
"budget", 0
)
if thinking_budget is not None:
- thinking_config = types.ThinkingConfig(
- thinking_budget=thinking_budget,
- )
+ thinking_config = types.ThinkingConfig(thinking_budget=thinking_budget)
elif model_name in [
"gemini-3-pro",
"gemini-3-pro-preview",
@@ -243,8 +207,6 @@ class ProviderGoogleGenAI(Provider):
"gemini-3-flash-lite",
"gemini-3-flash-lite-preview",
]:
- # The thinkingLevel parameter, recommended for Gemini 3 models and onwards
- # Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget instead.
thinking_level = self.provider_config.get("gm_thinking_config", {}).get(
"level", "HIGH"
)
@@ -261,7 +223,6 @@ class ProviderGoogleGenAI(Provider):
types.ThinkingConfig.thinking_level = level
else:
thinking_config.thinking_level = level
-
return types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=temperature,
@@ -279,12 +240,12 @@ class ProviderGoogleGenAI(Provider):
logprobs=payloads.get("logprobs"),
seed=payloads.get("seed"),
response_modalities=modalities,
- tools=cast(types.ToolListUnion | None, tool_list),
+ tools=tool_list,
tool_config=tool_config,
safety_settings=self.safety_settings if self.safety_settings else None,
thinking_config=thinking_config,
automatic_function_calling=types.AutomaticFunctionCallingConfig(
- disable=True,
+ disable=True
),
)
@@ -319,25 +280,21 @@ class ProviderGoogleGenAI(Provider):
[
self.provider_config.get("gm_native_coderunner", False),
self.provider_config.get("gm_native_search", False),
- ],
+ ]
)
for message in payloads["messages"]:
- role, content = message["role"], message.get("content")
-
+ role, content = (message["role"], message.get("content"))
if role == "user":
if isinstance(content, list):
parts = [
- (
- types.Part.from_text(text=item["text"] or " ")
- if item["type"] == "text"
- else process_image_url(item["image_url"])
- )
+ types.Part.from_text(text=item["text"] or " ")
+ if item["type"] == "text"
+ else process_image_url(item["image_url"])
for item in content
]
else:
parts = [create_text_part(content)]
append_or_extend(gemini_contents, parts, types.UserContent)
-
elif role == "assistant":
if isinstance(content, str):
parts = [types.Part.from_text(text=content)]
@@ -347,12 +304,10 @@ class ProviderGoogleGenAI(Provider):
thinking_signature = None
text = ""
for part in content:
- # for most cases, assistant content only contains two parts: think and text
if part.get("type") == "think":
thinking_signature = part.get("encrypted") or None
else:
text += str(part.get("text"))
-
if thinking_signature and isinstance(thinking_signature, str):
try:
thinking_signature = base64.b64decode(thinking_signature)
@@ -363,13 +318,9 @@ class ProviderGoogleGenAI(Provider):
)
thinking_signature = None
parts.append(
- types.Part(
- text=text,
- thought_signature=thinking_signature,
- )
+ types.Part(text=text, thought_signature=thinking_signature)
)
append_or_extend(gemini_contents, parts, types.ModelContent)
-
elif not native_tool_enabled and "tool_calls" in message:
parts = []
for tool in message["tool_calls"]:
@@ -377,9 +328,6 @@ class ProviderGoogleGenAI(Provider):
name=tool["function"]["name"],
args=json.loads(tool["function"]["arguments"]),
)
- # we should set thought_signature back to part if exists
- # for more info about thought_signature, see:
- # https://ai.google.dev/gemini-api/docs/thought-signatures
if tool.get("extra_content"):
ts_bs64 = (
tool["extra_content"]
@@ -394,38 +342,30 @@ class ProviderGoogleGenAI(Provider):
logger.warning("assistant 角色的消息内容为空,已添加空格占位")
if native_tool_enabled and "tool_calls" in message:
logger.warning(
- "检测到启用Gemini原生工具,且上下文中存在函数调用,建议使用 /reset 重置上下文",
+ "检测到启用Gemini原生工具,且上下文中存在函数调用,建议使用 /reset 重置上下文"
)
parts = [types.Part.from_text(text=" ")]
append_or_extend(gemini_contents, parts, types.ModelContent)
-
- elif role == "tool" and not native_tool_enabled:
+ elif role == "tool" and (not native_tool_enabled):
func_name = message.get("name", message["tool_call_id"])
part = types.Part.from_function_response(
name=func_name,
- response={
- "name": func_name,
- "content": message["content"],
- },
+ response={"name": func_name, "content": message["content"]},
)
if part.function_response:
part.function_response.id = message["tool_call_id"]
-
parts = [part]
append_or_extend(gemini_contents, parts, types.UserContent)
-
if gemini_contents and isinstance(gemini_contents[0], types.ModelContent):
gemini_contents.pop()
-
return gemini_contents
def _extract_reasoning_content(self, candidate: types.Candidate) -> str:
"""Extract reasoning content from candidate parts"""
if not candidate.content or not candidate.content.parts:
return ""
-
thought_buf: list[str] = [
- (p.text or "") for p in candidate.content.parts if p.thought
+ p.text or "" for p in candidate.content.parts if p.thought
]
return "".join(thought_buf).strip()
@@ -452,86 +392,68 @@ class ProviderGoogleGenAI(Provider):
if has_text_output or has_reasoning_output or has_tool_output:
return
raise EmptyModelOutputError(
- "Gemini completion has no usable output. "
- f"response_id={response_id}, finish_reason={finish_reason}"
+ f"Gemini completion has no usable output. response_id={response_id}, finish_reason={finish_reason}"
)
def _process_content_parts(
- self,
- candidate: types.Candidate,
- llm_response: LLMResponse,
+ self, candidate: types.Candidate, llm_response: LLMResponse
) -> MessageChain:
"""处理内容部分并构建消息链"""
if not candidate.content:
logger.warning(f"收到的 candidate.content 为空: {candidate}")
raise EmptyModelOutputError(
- "Gemini candidate content is empty. "
- f"finish_reason={candidate.finish_reason}"
+ f"Gemini candidate content is empty. finish_reason={candidate.finish_reason}"
)
-
finish_reason = candidate.finish_reason
result_parts: list[types.Part] | None = candidate.content.parts
-
if finish_reason == types.FinishReason.SAFETY:
raise Exception("模型生成内容未通过 Gemini 平台的安全检查")
-
if finish_reason in {
types.FinishReason.PROHIBITED_CONTENT,
types.FinishReason.SPII,
types.FinishReason.BLOCKLIST,
}:
raise Exception("模型生成内容违反 Gemini 平台政策")
-
- # 防止旧版本SDK不存在IMAGE_SAFETY
if hasattr(types.FinishReason, "IMAGE_SAFETY"):
if finish_reason == types.FinishReason.IMAGE_SAFETY:
raise Exception("模型生成内容违反 Gemini 平台政策")
-
if not result_parts:
logger.warning(f"收到的 candidate.content.parts 为空: {candidate}")
raise EmptyModelOutputError(
- "Gemini candidate content parts are empty. "
- f"finish_reason={candidate.finish_reason}"
+ f"Gemini candidate content parts are empty. finish_reason={candidate.finish_reason}"
)
-
- # 提取 reasoning content
reasoning = self._extract_reasoning_content(candidate)
if reasoning:
llm_response.reasoning_content = reasoning
-
chain: list[Comp.BaseMessageComponent] = []
part: types.Part
-
- # 暂时这样Fallback
if all(
- part.inline_data
- and part.inline_data.mime_type
- and part.inline_data.mime_type.startswith("image/")
- for part in result_parts
+
+ part.inline_data
+ and part.inline_data.mime_type
+ and part.inline_data.mime_type.startswith("image/")
+ for part in result_parts
+
):
chain.append(Comp.Plain("这是图片"))
for part in result_parts:
if part.text:
chain.append(Comp.Plain(part.text))
-
if (
part.function_call
and part.function_call.name is not None
- and part.function_call.args is not None
+ and (part.function_call.args is not None)
):
llm_response.role = "tool"
llm_response.tools_call_name.append(part.function_call.name)
llm_response.tools_call_args.append(part.function_call.args)
- # function_call.id might be None, use name as fallback
tool_call_id = part.function_call.id or part.function_call.name
llm_response.tools_call_ids.append(tool_call_id)
- # extra_content
if part.thought_signature:
ts_bs64 = base64.b64encode(part.thought_signature).decode("utf-8")
llm_response.tools_call_extra_content[tool_call_id] = {
"google": {"thought_signature": ts_bs64}
}
-
if (
part.inline_data
and part.inline_data.mime_type
@@ -539,9 +461,7 @@ class ProviderGoogleGenAI(Provider):
and part.inline_data.data
):
chain.append(Comp.Image.fromBytes(part.inline_data.data))
-
if ts := part.thought_signature:
- # only keep the last thinking signature
llm_response.reasoning_signature = base64.b64encode(ts).decode("utf-8")
chain_result = MessageChain(chain=chain)
llm_response.result_chain = chain_result
@@ -558,16 +478,12 @@ class ProviderGoogleGenAI(Provider):
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
None,
)
-
model = payloads.get("model", self.get_model())
-
modalities = ["TEXT"]
if self.provider_config.get("gm_resp_image_modal", False):
modalities.append("IMAGE")
-
conversation = self._prepare_conversation(payloads)
temperature = payloads.get("temperature", 0.7)
-
result: types.GenerateContentResponse | None = None
while True:
try:
@@ -581,33 +497,27 @@ class ProviderGoogleGenAI(Provider):
streaming=False,
)
result = await self.client.models.generate_content(
- model=model,
- contents=cast(types.ContentListUnion, conversation),
- config=config,
+ model=model, contents=conversation, config=config
)
logger.debug(f"genai result: {result}")
-
if not result.candidates:
logger.error(f"请求失败, 返回的 candidates 为空: {result}")
raise Exception("请求失败, 返回的 candidates 为空。")
-
if result.candidates[0].finish_reason == types.FinishReason.RECITATION:
if temperature > 2:
raise Exception("温度参数已超过最大值2,仍然发生recitation")
temperature += 0.2
logger.warning(
- f"发生了recitation,正在提高温度至{temperature:.1f}重试...",
+ f"发生了recitation,正在提高温度至{temperature:.1f}重试..."
)
continue
-
break
-
except APIError as e:
if e.message is None:
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
- f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
+ f"{model} 不支持 system prompt,已自动去除(影响人格设置)"
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
@@ -619,19 +529,15 @@ class ProviderGoogleGenAI(Provider):
in e.message
or "only supports text output" in e.message
):
- logger.warning(
- f"{model} 不支持多模态输出,降级为文本模态",
- )
+ logger.warning(f"{model} 不支持多模态输出,降级为文本模态")
modalities = ["TEXT"]
else:
raise
continue
-
llm_response = LLMResponse("assistant")
llm_response.raw_completion = result
llm_response.result_chain = self._process_content_parts(
- result.candidates[0],
- llm_response,
+ result.candidates[0], llm_response
)
llm_response.id = result.response_id
if result.usage_metadata:
@@ -639,9 +545,7 @@ class ProviderGoogleGenAI(Provider):
return llm_response
async def _query_stream(
- self,
- payloads: dict,
- tools: ToolSet | None,
+ self, payloads: dict, tools: ToolSet | None
) -> AsyncGenerator[LLMResponse, None]:
"""流式请求 Gemini API"""
system_instruction = next(
@@ -650,7 +554,6 @@ class ProviderGoogleGenAI(Provider):
)
model = payloads.get("model", self.get_model())
conversation = self._prepare_conversation(payloads)
-
result = None
while True:
try:
@@ -662,9 +565,7 @@ class ProviderGoogleGenAI(Provider):
streaming=True,
)
result = await self.client.models.generate_content_stream(
- model=model,
- contents=cast(types.ContentListUnion, conversation),
- config=config,
+ model=model, contents=conversation, config=config
)
break
except APIError as e:
@@ -672,7 +573,7 @@ class ProviderGoogleGenAI(Provider):
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
- f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
+ f"{model} 不支持 system prompt,已自动去除(影响人格设置)"
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
@@ -681,40 +582,31 @@ class ProviderGoogleGenAI(Provider):
else:
raise
continue
-
- # Accumulate the complete response text for the final response
accumulated_text = ""
accumulated_reasoning = ""
final_response = None
-
async for chunk in result:
llm_response = LLMResponse("assistant", is_chunk=True)
-
if not chunk.candidates:
logger.warning(f"收到的 chunk 中 candidates 为空: {chunk}")
continue
if not chunk.candidates[0].content:
logger.warning(f"收到的 chunk 中 content 为空: {chunk}")
continue
-
if chunk.candidates[0].content.parts and any(
part.function_call for part in chunk.candidates[0].content.parts
):
llm_response = LLMResponse("assistant", is_chunk=False)
llm_response.raw_completion = chunk
llm_response.result_chain = self._process_content_parts(
- chunk.candidates[0],
- llm_response,
+ chunk.candidates[0], llm_response
)
llm_response.id = chunk.response_id
if chunk.usage_metadata:
llm_response.usage = self._extract_usage(chunk.usage_metadata)
yield llm_response
return
-
_f = False
-
- # 提取 reasoning content
reasoning = self._extract_reasoning_content(chunk.candidates[0])
if reasoning:
_f = True
@@ -726,41 +618,30 @@ class ProviderGoogleGenAI(Provider):
llm_response.result_chain = MessageChain(chain=[Comp.Plain(chunk.text)])
if _f:
yield llm_response
-
if chunk.candidates[0].finish_reason:
- # Process the final chunk for potential tool calls or other content
if chunk.candidates[0].content.parts:
final_response = LLMResponse("assistant", is_chunk=False)
final_response.raw_completion = chunk
final_response.result_chain = self._process_content_parts(
- chunk.candidates[0],
- final_response,
+ chunk.candidates[0], final_response
)
final_response.id = chunk.response_id
if chunk.usage_metadata:
final_response.usage = self._extract_usage(chunk.usage_metadata)
break
-
- # Yield final complete response with accumulated text
if not final_response:
final_response = LLMResponse("assistant", is_chunk=False)
-
- # Set the complete accumulated reasoning in the final response
if accumulated_reasoning:
final_response.reasoning_content = accumulated_reasoning
-
- # Set the complete accumulated text in the final response
if accumulated_text:
final_response.result_chain = MessageChain(
- chain=[Comp.Plain(accumulated_text)],
+ chain=[Comp.Plain(accumulated_text)]
)
-
self._ensure_usable_response(
final_response,
response_id=getattr(final_response, "id", None),
finish_reason=None,
)
-
yield final_response
async def text_chat(
@@ -789,31 +670,22 @@ class ProviderGoogleGenAI(Provider):
context_query.append(new_record)
if system_prompt:
context_query.insert(0, {"role": "system", "content": system_prompt})
-
for part in context_query:
if "_no_save" in part:
del part["_no_save"]
-
- # tool calls result
if tool_calls_result:
if not isinstance(tool_calls_result, list):
- tcr = cast(ToolCallsResult, tool_calls_result)
+ tcr = tool_calls_result
context_query.extend(tcr.to_openai_messages())
else:
for tcr in tool_calls_result:
- context_query.extend(
- cast(ToolCallsResult, tcr).to_openai_messages()
- )
-
+ context_query.extend(tcr.to_openai_messages())
model = model or self.get_model()
-
payloads = {"messages": context_query, "model": model}
- if func_tool and not func_tool.empty():
+ if func_tool and (not func_tool.empty()):
payloads["tool_choice"] = tool_choice
-
retry = 10
keys = self.api_keys.copy()
-
for _ in range(retry):
try:
return await self._query(payloads, func_tool)
@@ -821,7 +693,6 @@ class ProviderGoogleGenAI(Provider):
if await self._handle_api_error(e, keys):
continue
break
-
raise Exception("请求失败。")
async def text_chat_stream(
@@ -850,31 +721,22 @@ class ProviderGoogleGenAI(Provider):
context_query.append(new_record)
if system_prompt:
context_query.insert(0, {"role": "system", "content": system_prompt})
-
for part in context_query:
if "_no_save" in part:
del part["_no_save"]
-
- # tool calls result
if tool_calls_result:
if not isinstance(tool_calls_result, list):
- tcr = cast(ToolCallsResult, tool_calls_result)
+ tcr = tool_calls_result
context_query.extend(tcr.to_openai_messages())
else:
for tcr in tool_calls_result:
- context_query.extend(
- cast(ToolCallsResult, tcr).to_openai_messages()
- )
-
+ context_query.extend(tcr.to_openai_messages())
model = model or self.get_model()
-
payloads = {"messages": context_query, "model": model}
- if func_tool and not func_tool.empty():
+ if func_tool and (not func_tool.empty()):
payloads["tool_choice"] = tool_choice
-
retry = 10
keys = self.api_keys.copy()
-
for _ in range(retry):
try:
async for response in self._query_stream(payloads, func_tool):
@@ -928,25 +790,15 @@ class ProviderGoogleGenAI(Provider):
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
return None
- return {
- "type": "image_url",
- "image_url": {"url": image_data},
- }
+ return {"type": "image_url", "image_url": {"url": image_data}}
- # 构建内容块列表
content_blocks = []
-
- # 1. 用户原始发言(OpenAI 建议:用户发言在前)
if text:
content_blocks.append({"type": "text", "text": text})
elif image_urls:
- # 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[图片]"})
elif extra_user_content_parts:
- # 如果只有额外内容块,也需要添加占位文本
content_blocks.append({"type": "text", "text": " "})
-
- # 2. 额外的内容块(系统提醒、指令等)
if extra_user_content_parts:
for part in extra_user_content_parts:
if isinstance(part, TextPart):
@@ -957,25 +809,19 @@ class ProviderGoogleGenAI(Provider):
content_blocks.append(image_part)
else:
raise ValueError(f"不支持的额外内容块类型: {type(part)}")
-
- # 3. 图片内容
if image_urls:
for image_url in image_urls:
image_part = await resolve_image_part(image_url)
if image_part:
content_blocks.append(image_part)
-
- # 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if (
text
- and not extra_user_content_parts
- and not image_urls
- and len(content_blocks) == 1
- and content_blocks[0]["type"] == "text"
+ and (not extra_user_content_parts)
+ and (not image_urls)
+ and (len(content_blocks) == 1)
+ and (content_blocks[0]["type"] == "text")
):
return {"role": "user", "content": content_blocks[0]["text"]}
-
- # 否则返回多模态格式
return {"role": "user", "content": content_blocks}
async def encode_image_bs64(self, image_url: str) -> str:
diff --git a/astrbot/core/provider/sources/genie_tts.py b/astrbot/core/provider/sources/genie_tts.py
index 22eca8c3b..795d48d10 100644
--- a/astrbot/core/provider/sources/genie_tts.py
+++ b/astrbot/core/provider/sources/genie_tts.py
@@ -14,7 +14,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
try:
import genie_tts as genie
except ImportError:
- genie = None # type: ignore[assignment]
+ genie = None # type: ignore
@register_provider_adapter(
@@ -39,12 +39,12 @@ class GenieTTSProvider(TTSProvider):
refer_text = provider_config.get("genie_refer_text", "")
try:
- genie.load_character( # type: ignore[attr-defined]
+ genie.load_character( # type: ignore
character_name=self.character_name,
language=language,
onnx_model_dir=model_dir,
)
- genie.set_reference_audio( # type: ignore[attr-defined]
+ genie.set_reference_audio( # type: ignore
character_name=self.character_name,
audio_path=refer_audio_path,
audio_text=refer_text,
@@ -66,7 +66,7 @@ class GenieTTSProvider(TTSProvider):
def _generate(save_path: str) -> None:
assert genie is not None
- genie.tts( # type: ignore[attr-defined]
+ genie.tts( # type: ignore
character_name=self.character_name,
text=text,
save_path=save_path,
@@ -105,7 +105,7 @@ class GenieTTSProvider(TTSProvider):
def _generate(save_path: str, t: str) -> None:
assert genie is not None
- genie.tts( # type: ignore[attr-defined]
+ genie.tts( # type: ignore
character_name=self.character_name,
text=t,
save_path=save_path,
diff --git a/astrbot/core/provider/sources/mimo_api_common.py b/astrbot/core/provider/sources/mimo_api_common.py
index 56b15bab7..5123b57f4 100644
--- a/astrbot/core/provider/sources/mimo_api_common.py
+++ b/astrbot/core/provider/sources/mimo_api_common.py
@@ -1,7 +1,6 @@
import base64
import uuid
from pathlib import Path
-from typing import Any, cast
from urllib.parse import urlparse
import httpx
@@ -19,10 +18,7 @@ DEFAULT_MIMO_TTS_MODEL = "mimo-v2-tts"
DEFAULT_MIMO_TTS_VOICE = "mimo_default"
DEFAULT_MIMO_TTS_SEED_TEXT = "Hello, MiMo, have you had lunch?"
DEFAULT_MIMO_STT_MODEL = "mimo-v2-omni"
-DEFAULT_MIMO_STT_SYSTEM_PROMPT = (
- "You are a speech transcription assistant. "
- "Transcribe the spoken content from the audio exactly and return only the transcription text."
-)
+DEFAULT_MIMO_STT_SYSTEM_PROMPT = "You are a speech transcription assistant. Transcribe the spoken content from the audio exactly and return only the transcription text."
DEFAULT_MIMO_STT_USER_PROMPT = (
"Please transcribe the content of the audio and return only the transcription text."
)
@@ -54,14 +50,11 @@ def get_temp_dir() -> Path:
def create_http_client(timeout: int | None, proxy: str) -> httpx.AsyncClient:
- client_kwargs: dict[str, object] = {
- "timeout": timeout,
- "follow_redirects": True,
- }
+ client_kwargs: dict[str, object] = {"timeout": timeout, "follow_redirects": True}
if proxy:
logger.info("[MiMo API] Using proxy: %s", proxy)
client_kwargs["proxy"] = proxy
- return httpx.AsyncClient(**cast(dict[str, Any], client_kwargs))
+ return httpx.AsyncClient(**client_kwargs)
def build_api_url(api_base: str) -> str:
@@ -74,13 +67,11 @@ def build_api_url(api_base: str) -> str:
async def _detect_audio_format(file_path: Path) -> str | None:
silk_header = b"SILK"
amr_header = b"#!AMR"
-
try:
with file_path.open("rb") as file:
file_header = file.read(8)
except FileNotFoundError:
return None
-
if silk_header in file_header:
return "silk"
if amr_header in file_header:
@@ -93,7 +84,6 @@ async def prepare_audio_input(audio_source: str) -> tuple[str, list[Path]]:
source_path = Path(audio_source)
is_remote = audio_source.startswith(("http://", "https://"))
is_tencent = "multimedia.nt.qq.com.cn" in audio_source if is_remote else False
-
if is_remote:
parsed_url = urlparse(audio_source)
suffix = Path(parsed_url.path).suffix or ".input"
@@ -101,10 +91,8 @@ async def prepare_audio_input(audio_source: str) -> tuple[str, list[Path]]:
await download_file(audio_source, str(download_path))
source_path = download_path
cleanup_paths.append(download_path)
-
if not source_path.exists():
raise FileNotFoundError(f"File does not exist: {source_path}")
-
if source_path.suffix.lower() in {".amr", ".silk"} or is_tencent:
file_format = await _detect_audio_format(source_path)
if file_format in {"silk", "amr"}:
@@ -117,9 +105,8 @@ async def prepare_audio_input(audio_source: str) -> tuple[str, list[Path]]:
logger.info("Converting amr file to wav for MiMo STT...")
await convert_to_pcm_wav(str(source_path), str(converted_path))
source_path = converted_path
-
encoded_audio = base64.b64encode(source_path.read_bytes()).decode("utf-8")
- return encoded_audio, cleanup_paths
+ return (encoded_audio, cleanup_paths)
def cleanup_files(paths: list[Path]) -> None:
diff --git a/astrbot/core/provider/sources/oai_aihubmix_source.py b/astrbot/core/provider/sources/oai_aihubmix_source.py
index b26330227..fc6b0e3c0 100644
--- a/astrbot/core/provider/sources/oai_aihubmix_source.py
+++ b/astrbot/core/provider/sources/oai_aihubmix_source.py
@@ -1,6 +1,3 @@
-from collections.abc import MutableMapping
-from typing import cast
-
from astrbot.core.provider.register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
@@ -10,13 +7,7 @@ from .openai_source import ProviderOpenAIOfficial
"aihubmix_chat_completion", "AIHubMix Chat Completion Provider Adapter"
)
class ProviderAIHubMix(ProviderOpenAIOfficial):
- def __init__(
- self,
- provider_config: dict,
- provider_settings: dict,
- ) -> None:
+ def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
- # Reference to: https://aihubmix.com/appstore
- # Use this code can enjoy 10% off prices for AIHubMix API calls.
- custom_headers = cast(MutableMapping[str, str], self.client._custom_headers)
+ custom_headers = self.client._custom_headers
custom_headers["APP-Code"] = "KRLC5702"
diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py
index fe08245c4..29775e860 100644
--- a/astrbot/core/provider/sources/openai_source.py
+++ b/astrbot/core/provider/sources/openai_source.py
@@ -1061,6 +1061,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
+ extra_user_content_parts: list[ContentPart] | None = None,
tool_choice: Literal["auto", "required"] = "auto",
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
diff --git a/astrbot/core/provider/sources/openrouter_source.py b/astrbot/core/provider/sources/openrouter_source.py
index d6660b666..d7389528e 100644
--- a/astrbot/core/provider/sources/openrouter_source.py
+++ b/astrbot/core/provider/sources/openrouter_source.py
@@ -1,6 +1,3 @@
-from collections.abc import MutableMapping
-from typing import cast
-
from astrbot.core.provider.register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
@@ -10,14 +7,9 @@ from .openai_source import ProviderOpenAIOfficial
"openrouter_chat_completion", "OpenRouter Chat Completion Provider Adapter"
)
class ProviderOpenRouter(ProviderOpenAIOfficial):
- def __init__(
- self,
- provider_config: dict,
- provider_settings: dict,
- ) -> None:
+ def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
- # Reference to: https://openrouter.ai/docs/api/reference/overview#headers
- custom_headers = cast(MutableMapping[str, str], self.client._custom_headers)
+ custom_headers = self.client._custom_headers
custom_headers["HTTP-Referer"] = "https://github.com/AstrBotDevs/AstrBot"
custom_headers["X-OpenRouter-Title"] = "AstrBot"
custom_headers["X-OpenRouter-Categories"] = "general-chat,personal-agent"
diff --git a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py
index 262c8d7a3..e33ee8ac5 100644
--- a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py
+++ b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py
@@ -9,8 +9,10 @@ from datetime import datetime
from typing import Protocol
import anyio
-from funasr_onnx import SenseVoiceSmall # type: ignore[import]
-from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess # type: ignore[import]
+from funasr_onnx import SenseVoiceSmall # type: ignore
+from funasr_onnx.utils.postprocess_utils import (
+ rich_transcription_postprocess, # type: ignore
+)
from astrbot.core import logger
from astrbot.core.provider.entities import ProviderType
diff --git a/astrbot/core/provider/sources/volcengine_tts.py b/astrbot/core/provider/sources/volcengine_tts.py
index a09bec99c..2183e18fc 100644
--- a/astrbot/core/provider/sources/volcengine_tts.py
+++ b/astrbot/core/provider/sources/volcengine_tts.py
@@ -48,6 +48,9 @@ class ProviderVolcengineTTS(TTSProvider):
cluster = app_payload.get("cluster")
if isinstance(cluster, str) and cluster:
safe_app["cluster"] = cluster
+ token = app_payload.get("token")
+ if isinstance(token, str) and token:
+ safe_app["token"] = "***"
safe_user: dict[str, Any] = {}
if isinstance(user_payload, dict):
@@ -73,7 +76,7 @@ class ProviderVolcengineTTS(TTSProvider):
safe_request[key] = value
text = request_payload.get("text")
if isinstance(text, str):
- safe_request["text_length"] = len(text)
+ safe_request["text"] = text
return {
"app": safe_app,
diff --git a/astrbot/core/provider/sources/whisper_selfhosted_source.py b/astrbot/core/provider/sources/whisper_selfhosted_source.py
index 4c7d6c3f0..b7763ceb2 100644
--- a/astrbot/core/provider/sources/whisper_selfhosted_source.py
+++ b/astrbot/core/provider/sources/whisper_selfhosted_source.py
@@ -4,7 +4,7 @@ import uuid
from typing import Protocol
import anyio
-import whisper # type: ignore[import]
+import whisper # type: ignore
from astrbot.core import logger
from astrbot.core.provider.entities import ProviderType
diff --git a/astrbot/core/provider/sources/xinference_rerank_source.py b/astrbot/core/provider/sources/xinference_rerank_source.py
index 2b2b550bb..9b5f540ed 100644
--- a/astrbot/core/provider/sources/xinference_rerank_source.py
+++ b/astrbot/core/provider/sources/xinference_rerank_source.py
@@ -1,10 +1,5 @@
-from typing import cast
-
+from xinference_client.client.restful.async_restful_client import AsyncClient as Client
from xinference_client.client.restful.async_restful_client import (
- AsyncClient as Client,
-)
-from xinference_client.client.restful.async_restful_client import (
- AsyncRESTfulModelHandle,
AsyncRESTfulRerankModelHandle,
)
@@ -15,9 +10,7 @@ from astrbot.core.provider.register import register_provider_adapter
@register_provider_adapter(
- "xinference_rerank",
- "Xinference Rerank 适配器",
- provider_type=ProviderType.RERANK,
+ "xinference_rerank", "Xinference Rerank 适配器", provider_type=ProviderType.RERANK
)
class XinferenceRerankProvider(RerankProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
@@ -30,8 +23,7 @@ class XinferenceRerankProvider(RerankProvider):
self.model_name = provider_config.get("rerank_model", "BAAI/bge-reranker-base")
self.api_key = provider_config.get("rerank_api_key")
self.launch_model_if_not_running = provider_config.get(
- "launch_model_if_not_running",
- False,
+ "launch_model_if_not_running", False
)
self.client: Client | None = None
self.model: AsyncRESTfulRerankModelHandle | None = None
@@ -45,50 +37,38 @@ class XinferenceRerankProvider(RerankProvider):
logger.info("Xinference Rerank: No API key provided.")
client = Client(self.base_url)
self.client = client
-
try:
running_models = await client.list_models()
for uid, model_spec in running_models.items():
if model_spec.get("model_name") == self.model_name:
logger.info(
- f"Model '{self.model_name}' is already running with UID: {uid}",
+ f"Model '{self.model_name}' is already running with UID: {uid}"
)
self.model_uid = uid
break
-
if self.model_uid is None:
if self.launch_model_if_not_running:
logger.info(f"Launching {self.model_name} model...")
self.model_uid = await client.launch_model(
- model_name=self.model_name,
- model_type="rerank",
+ model_name=self.model_name, model_type="rerank"
)
logger.info("Model launched.")
else:
logger.warning(
- f"Model '{self.model_name}' is not running and auto-launch is disabled. Provider will not be available.",
+ f"Model '{self.model_name}' is not running and auto-launch is disabled. Provider will not be available."
)
return
-
if self.model_uid:
- self.model = cast(
- AsyncRESTfulRerankModelHandle,
- await client.get_model(self.model_uid),
- )
-
+ self.model = await client.get_model(self.model_uid)
except Exception as e:
logger.error(f"Failed to initialize Xinference model: {e}")
logger.debug(
- f"Xinference initialization failed with exception: {e}",
- exc_info=True,
+ f"Xinference initialization failed with exception: {e}", exc_info=True
)
self.model = None
async def rerank(
- self,
- query: str,
- documents: list[str],
- top_n: int | None = None,
+ self, query: str, documents: list[str], top_n: int | None = None
) -> list[RerankResult]:
if not self.model:
logger.error("Xinference rerank model is not initialized.")
@@ -97,16 +77,13 @@ class XinferenceRerankProvider(RerankProvider):
response = await self.model.rerank(documents, query, top_n)
results = response.get("results", [])
logger.debug(f"Rerank API response: {response}")
-
if not results:
logger.warning(
- f"Rerank API returned an empty list. Original response: {response}",
+ f"Rerank API returned an empty list. Original response: {response}"
)
-
return [
RerankResult(
- index=result["index"],
- relevance_score=result["relevance_score"],
+ index=result["index"], relevance_score=result["relevance_score"]
)
for result in results
]
diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py
index 5d3454a71..314e12aca 100644
--- a/astrbot/core/star/context.py
+++ b/astrbot/core/star/context.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
from asyncio import Queue
from collections.abc import Awaitable, Callable, Coroutine
-from typing import TYPE_CHECKING, Any, Protocol, cast
+from typing import TYPE_CHECKING, Any, Protocol
from deprecated import deprecated
@@ -44,7 +44,6 @@ from .star import StarMetadata, star_registry
from .star_handler import EventType, StarHandlerMetadata, star_handlers_registry
logger = logging.getLogger("astrbot")
-
if TYPE_CHECKING:
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.cron.manager import CronJobManager
@@ -58,7 +57,9 @@ class PlatformManagerProtocol(Protocol):
class StarManagerProtocol(Protocol):
async def turn_off_plugin(self, plugin_name: str) -> None: ...
+
async def turn_on_plugin(self, plugin_name: str) -> None: ...
+
async def install_plugin(self, repo_url: str, proxy: str = "") -> dict | None: ...
@@ -66,8 +67,6 @@ class Context:
"""暴露给插件的接口上下文。"""
registered_web_apis: list | None = None
-
- # 向后兼容的变量
_register_tasks: list[Coroutine[Any, Any, Any]] | None = None
_star_manager: StarManagerProtocol | None = None
@@ -89,32 +88,28 @@ class Context:
self.registered_web_apis = []
self._register_tasks = []
self._event_queue = event_queue
- """事件队列。消息平台通过事件队列传递消息事件。"""
+ "事件队列。消息平台通过事件队列传递消息事件。"
self._config = config
- """AstrBot 默认配置"""
+ "AstrBot 默认配置"
self._db = db
- """AstrBot 数据库"""
+ "AstrBot 数据库"
self.provider_manager = provider_manager
- """模型提供商管理器"""
+ "模型提供商管理器"
self.platform_manager = platform_manager
- """平台适配器管理器"""
+ "平台适配器管理器"
self.conversation_manager = conversation_manager
- """会话管理器"""
+ "会话管理器"
self.message_history_manager = message_history_manager
- """平台消息历史管理器"""
+ "平台消息历史管理器"
self.persona_manager = persona_manager
- """人格角色设定管理器"""
+ "人格角色设定管理器"
self.astrbot_config_mgr = astrbot_config_mgr
- """配置文件管理器(非webui)"""
+ "配置文件管理器(非webui)"
self.kb_manager = knowledge_base_manager
- """知识库管理器"""
+ "知识库管理器"
self.cron_manager = cron_manager
- """Cron job manager, initialized by core lifecycle."""
+ "Cron job manager, initialized by core lifecycle."
self.subagent_orchestrator = subagent_orchestrator
-
- # Register built-in tools so they appear in WebUI and can be
- # assigned to subagents. Done here (not at module-import time)
- # to avoid circular imports.
self.provider_manager.llm_tools.register_internal_tools()
def reset_runtime_registrations(self) -> None:
@@ -206,7 +201,6 @@ class Context:
ChatProviderNotFoundError: If the specified chat provider ID is not found
Exception: For other errors during LLM generation
"""
- # Import here to avoid circular imports
from astrbot.core.agent.tool_session_manager import ToolSessionManager
from astrbot.core.astr_agent_context import (
AgentContextWrapper,
@@ -217,16 +211,13 @@ class Context:
prov = await self.provider_manager.get_provider_by_id(chat_provider_id)
if not prov or not isinstance(prov, Provider):
raise ProviderNotFoundError(f"Provider {chat_provider_id} not found")
-
agent_hooks = agent_hooks or BaseAgentRunHooks[AstrAgentContext]()
-
context_ = []
for msg in contexts or []:
if isinstance(msg, Message):
context_.append(msg.model_dump())
else:
context_.append(msg)
-
request = ProviderRequest(
prompt=prompt,
image_urls=image_urls or [],
@@ -235,13 +226,9 @@ class Context:
system_prompt=system_prompt or "",
)
if agent_context is None:
- agent_context = AstrAgentContext(
- context=self,
- event=event,
- )
+ agent_context = AstrAgentContext(context=self, event=event)
agent_runner = ToolLoopAgentRunner()
tool_executor = FunctionToolExecutor()
-
await agent_runner.reset(
provider=prov,
request=request,
@@ -320,8 +307,7 @@ class Context:
return self.provider_manager.llm_tools.deactivate_llm_tool(name)
def get_provider_by_id(
- self,
- provider_id: str,
+ self, provider_id: str
) -> (
Provider | TTSProvider | STTProvider | EmbeddingProvider | RerankProvider | None
):
@@ -337,7 +323,7 @@ class Context:
如果提供者 ID 存在但未找到提供者,会记录警告日志。
"""
prov = self.provider_manager.inst_map.get(provider_id)
- if provider_id and not prov:
+ if provider_id and (not prov):
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
@@ -373,8 +359,7 @@ class Context:
ValueError: 该会话来源配置的的对话模型(提供商)的类型不正确。
"""
prov = self.provider_manager.get_using_provider(
- provider_type=ProviderType.CHAT_COMPLETION,
- umo=umo,
+ provider_type=ProviderType.CHAT_COMPLETION, umo=umo
)
if prov is None:
return None
@@ -395,12 +380,11 @@ class Context:
ValueError: 返回的提供者不是 TTSProvider 类型。
"""
prov = self.provider_manager.get_using_provider(
- provider_type=ProviderType.TEXT_TO_SPEECH,
- umo=umo,
+ provider_type=ProviderType.TEXT_TO_SPEECH, umo=umo
)
- if prov and not isinstance(prov, TTSProvider):
+ if prov and (not isinstance(prov, TTSProvider)):
raise ValueError("返回的 Provider 不是 TTSProvider 类型")
- return cast(TTSProvider | None, prov)
+ return prov
def get_using_stt_provider(self, umo: str | None = None) -> STTProvider | None:
"""获取当前使用的用于 STT 任务的 Provider。
@@ -415,12 +399,11 @@ class Context:
ValueError: 返回的提供者不是 STTProvider 类型。
"""
prov = self.provider_manager.get_using_provider(
- provider_type=ProviderType.SPEECH_TO_TEXT,
- umo=umo,
+ provider_type=ProviderType.SPEECH_TO_TEXT, umo=umo
)
- if prov and not isinstance(prov, STTProvider):
+ if prov and (not isinstance(prov, STTProvider)):
raise ValueError("返回的 Provider 不是 STTProvider 类型")
- return cast(STTProvider | None, prov)
+ return prov
def get_config(self, umo: str | None = None) -> AstrBotConfig:
"""获取 AstrBot 的配置。
@@ -435,14 +418,11 @@ class Context:
如果不提供 umo 参数,将返回默认配置。
"""
if not umo:
- # 使用默认配置
return self._config
return self.astrbot_config_mgr.get_conf(umo)
async def send_message(
- self,
- session: str | MessageSesion,
- message_chain: MessageChain,
+ self, session: str | MessageSesion, message_chain: MessageChain
) -> bool:
"""根据 session(unified_msg_origin) 主动发送消息。
@@ -465,7 +445,6 @@ class Context:
session = MessageSesion.from_str(session)
except BaseException as e:
raise ValueError("不合法的 session 字符串: " + str(e)) from e
-
for platform in self.platform_manager.platform_insts:
if platform.meta().id == session.platform_name:
await platform.send_by_session(session, message_chain)
@@ -504,18 +483,13 @@ class Context:
logger.info(
f"plugin(module_path {module_path}) added LLM tool: {tool.name}"
)
-
if tool.name in tool_name:
logger.warning("替换已存在的 LLM 工具: " + tool.name)
self.provider_manager.llm_tools.remove_func(tool.name)
self.provider_manager.llm_tools.func_list.append(tool)
def register_web_api(
- self,
- route: str,
- view_handler: Awaitable,
- methods: list,
- desc: str,
+ self, route: str, view_handler: Awaitable, methods: list, desc: str
) -> None:
"""注册 Web API。
@@ -536,9 +510,7 @@ class Context:
return
self.registered_web_apis.append((route, view_handler, methods, desc))
- """
- 以下的方法已经不推荐使用。请从 AstrBot 文档查看更好的注册方式。
- """
+ "\n 以下的方法已经不推荐使用。请从 AstrBot 文档查看更好的注册方式。\n "
def get_event_queue(self) -> Queue:
"""获取事件队列。"""
@@ -687,7 +659,7 @@ class Context:
md.event_filters.append(RegexFilter(regex=command_name))
else:
md.event_filters.append(
- CommandFilter(command_name=command_name, handler_md=md),
+ CommandFilter(command_name=command_name, handler_md=md)
)
star_handlers_registry.append(md)
diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py
index 8e987cf47..67402fd35 100644
--- a/astrbot/core/star/register/star_handler.py
+++ b/astrbot/core/star/register/star_handler.py
@@ -38,7 +38,9 @@ def get_handler_full_name(
awaitable: Callable[..., Awaitable[Any] | AsyncGenerator[Any]],
) -> str:
"""获取 Handler 的全名"""
- return f"{awaitable.__module__}_{awaitable.__name__}"
+ return (
+ f"{getattr(awaitable, '__module__', '')}_{getattr(awaitable, '__name__', '')}"
+ )
def get_handler_or_create(
@@ -59,8 +61,8 @@ def get_handler_or_create(
md = StarHandlerMetadata(
event_type=event_type,
handler_full_name=handler_full_name,
- handler_name=handler.__name__,
- handler_module_path=handler.__module__,
+ handler_name=getattr(handler, "__name__", ""),
+ handler_module_path=getattr(handler, "__module__", ""),
handler=handler,
event_filters=[],
)
@@ -560,7 +562,7 @@ def register_llm_tool(name: str | None = None, **kwargs):
| Awaitable[MessageEventResult | str | None],
],
):
- llm_tool_name = name_ if name_ else awaitable.__name__
+ llm_tool_name = name_ if name_ else getattr(awaitable, "__name__", "")
func_doc = awaitable.__doc__ or ""
docstring = docstring_parser.parse(func_doc)
args = []
@@ -569,7 +571,7 @@ def register_llm_tool(name: str | None = None, **kwargs):
type_name = arg.type_name
if not type_name:
raise ValueError(
- f"LLM 函数工具 {awaitable.__module__}_{llm_tool_name} 的参数 {arg.arg_name} 缺少类型注释。",
+ f"LLM 函数工具 {getattr(awaitable, '__module__', '')}_{llm_tool_name} 的参数 {arg.arg_name} 缺少类型注释。",
)
# parse type_name to handle cases like "list[string]"
match = re.match(r"(\w+)\[(\w+)\]", type_name)
@@ -653,7 +655,7 @@ def register_agent(
)
handoff_tool = HandoffTool(agent=agent)
handoff_tool.handler = awaitable
- llm_tools.func_list.append(handoff_tool)
+ llm_tools.func_list.append(handoff_tool) # type: ignore[arg-type]
return RegisteringAgent(agent)
return decorator
diff --git a/astrbot/core/star/session_llm_manager.py b/astrbot/core/star/session_llm_manager.py
index acf4b9510..c3c664862 100644
--- a/astrbot/core/star/session_llm_manager.py
+++ b/astrbot/core/star/session_llm_manager.py
@@ -15,30 +15,23 @@ class SessionServiceConfig(TypedDict, total=False):
def _normalize_session_service_config(value: object) -> SessionServiceConfig:
if not isinstance(value, dict):
return {}
-
config: SessionServiceConfig = {}
- llm_enabled = value.get("llm_enabled")
+ val_dict: dict[str, object] = value
+ llm_enabled = val_dict.get("llm_enabled")
if isinstance(llm_enabled, bool):
config["llm_enabled"] = llm_enabled
-
- tts_enabled = value.get("tts_enabled")
+ tts_enabled = val_dict.get("tts_enabled")
if isinstance(tts_enabled, bool):
config["tts_enabled"] = tts_enabled
-
- session_enabled = value.get("session_enabled")
+ session_enabled = val_dict.get("session_enabled")
if isinstance(session_enabled, bool):
config["session_enabled"] = session_enabled
-
return config
class SessionServiceManager:
"""管理会话级别的服务启停状态,包括LLM和TTS"""
- # =============================================================================
- # LLM 相关方法
- # =============================================================================
-
@staticmethod
async def is_llm_enabled_for_session(session_id: str) -> bool:
"""检查LLM是否在指定会话中启用
@@ -50,7 +43,6 @@ class SessionServiceManager:
bool: True表示启用,False表示禁用
"""
- # 获取会话服务配置
session_services = _normalize_session_service_config(
await sp.get_async(
scope="umo",
@@ -59,13 +51,9 @@ class SessionServiceManager:
default={},
)
)
-
- # 如果配置了该会话的LLM状态,返回该状态
llm_enabled = session_services.get("llm_enabled")
if llm_enabled is not None:
return llm_enabled
-
- # 如果没有配置,默认为启用(兼容性考虑)
return True
@staticmethod
@@ -107,10 +95,6 @@ class SessionServiceManager:
session_id = event.unified_msg_origin
return await SessionServiceManager.is_llm_enabled_for_session(session_id)
- # =============================================================================
- # TTS 相关方法
- # =============================================================================
-
@staticmethod
async def is_tts_enabled_for_session(session_id: str) -> bool:
"""检查TTS是否在指定会话中启用
@@ -122,7 +106,6 @@ class SessionServiceManager:
bool: True表示启用,False表示禁用
"""
- # 获取会话服务配置
session_services = _normalize_session_service_config(
await sp.get_async(
scope="umo",
@@ -131,13 +114,9 @@ class SessionServiceManager:
default={},
)
)
-
- # 如果配置了该会话的TTS状态,返回该状态
tts_enabled = session_services.get("tts_enabled")
if tts_enabled is not None:
return tts_enabled
-
- # 如果没有配置,默认为启用(兼容性考虑)
return True
@staticmethod
@@ -164,9 +143,8 @@ class SessionServiceManager:
key="session_service_config",
value=session_config,
)
-
logger.info(
- f"会话 {session_id} 的TTS状态已更新为: {'启用' if enabled else '禁用'}",
+ f"会话 {session_id} 的TTS状态已更新为: {('启用' if enabled else '禁用')}"
)
@staticmethod
@@ -183,10 +161,6 @@ class SessionServiceManager:
session_id = event.unified_msg_origin
return await SessionServiceManager.is_tts_enabled_for_session(session_id)
- # =============================================================================
- # 会话整体启停相关方法
- # =============================================================================
-
@staticmethod
async def is_session_enabled(session_id: str) -> bool:
"""检查会话是否整体启用
@@ -198,7 +172,6 @@ class SessionServiceManager:
bool: True表示启用,False表示禁用
"""
- # 获取会话服务配置
session_services = _normalize_session_service_config(
await sp.get_async(
scope="umo",
@@ -207,11 +180,7 @@ class SessionServiceManager:
default={},
)
)
-
- # 如果配置了该会话的整体状态,返回该状态
session_enabled = session_services.get("session_enabled")
if session_enabled is not None:
return session_enabled
-
- # 如果没有配置,默认为启用(兼容性考虑)
return True
diff --git a/astrbot/core/star/session_plugin_manager.py b/astrbot/core/star/session_plugin_manager.py
index 7a6aba98f..eea19bb8c 100644
--- a/astrbot/core/star/session_plugin_manager.py
+++ b/astrbot/core/star/session_plugin_manager.py
@@ -14,27 +14,23 @@ class SessionPluginSettings(TypedDict, total=False):
def _normalize_session_plugin_config(value: object) -> dict[str, SessionPluginSettings]:
if not isinstance(value, dict):
return {}
-
config: dict[str, SessionPluginSettings] = {}
for session_id, raw_settings in value.items():
if not isinstance(session_id, str) or not isinstance(raw_settings, dict):
continue
-
settings: SessionPluginSettings = {}
- enabled_plugins = raw_settings.get("enabled_plugins")
+ raw_dict: dict[str, object] = raw_settings
+ enabled_plugins = raw_dict.get("enabled_plugins")
if isinstance(enabled_plugins, list) and all(
isinstance(plugin_name, str) for plugin_name in enabled_plugins
):
settings["enabled_plugins"] = enabled_plugins
-
- disabled_plugins = raw_settings.get("disabled_plugins")
+ disabled_plugins = raw_dict.get("disabled_plugins")
if isinstance(disabled_plugins, list) and all(
isinstance(plugin_name, str) for plugin_name in disabled_plugins
):
settings["disabled_plugins"] = disabled_plugins
-
config[session_id] = settings
-
return config
@@ -42,10 +38,7 @@ class SessionPluginManager:
"""管理会话级别的插件启停状态"""
@staticmethod
- async def is_plugin_enabled_for_session(
- session_id: str,
- plugin_name: str,
- ) -> bool:
+ async def is_plugin_enabled_for_session(session_id: str, plugin_name: str) -> bool:
"""检查插件是否在指定会话中启用
Args:
@@ -56,7 +49,6 @@ class SessionPluginManager:
bool: True表示启用,False表示禁用
"""
- # 获取会话插件配置
session_plugin_config = _normalize_session_plugin_config(
await sp.get_async(
scope="umo",
@@ -66,25 +58,17 @@ class SessionPluginManager:
)
)
session_config = session_plugin_config.get(session_id, {})
-
enabled_plugins = session_config.get("enabled_plugins", [])
disabled_plugins = session_config.get("disabled_plugins", [])
-
- # 如果插件在禁用列表中,返回False
if plugin_name in disabled_plugins:
return False
-
- # 如果插件在启用列表中,返回True
if plugin_name in enabled_plugins:
return True
-
- # 如果都没有配置,默认为启用(兼容性考虑)
return True
@staticmethod
async def filter_handlers_by_session(
- event: AstrMessageEvent,
- handlers: list,
+ event: AstrMessageEvent, handlers: list
) -> list:
"""根据会话配置过滤处理器列表
@@ -100,7 +84,6 @@ class SessionPluginManager:
session_id = event.unified_msg_origin
filtered_handlers = []
-
session_plugin_config = _normalize_session_plugin_config(
await sp.get_async(
scope="umo",
@@ -111,29 +94,20 @@ class SessionPluginManager:
)
session_config = session_plugin_config.get(session_id, {})
disabled_plugins = session_config.get("disabled_plugins", [])
-
for handler in handlers:
- # 获取处理器对应的插件
plugin = star_map.get(handler.handler_module_path)
if not plugin:
- # 如果找不到插件元数据,允许执行(可能是系统插件)
filtered_handlers.append(handler)
continue
-
- # 跳过保留插件(系统插件)
if plugin.reserved:
filtered_handlers.append(handler)
continue
-
if plugin.name is None:
continue
-
- # 检查插件是否在当前会话中启用
if plugin.name in disabled_plugins:
logger.debug(
- f"插件 {plugin.name} 在会话 {session_id} 中被禁用,跳过处理器 {handler.handler_name}",
+ f"插件 {plugin.name} 在会话 {session_id} 中被禁用,跳过处理器 {handler.handler_name}"
)
else:
filtered_handlers.append(handler)
-
return filtered_handlers
diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py
index 415796d6b..4d4705dc4 100644
--- a/astrbot/core/star/star_manager.py
+++ b/astrbot/core/star/star_manager.py
@@ -12,7 +12,7 @@ import sys
import tempfile
import traceback
from types import ModuleType
-from typing import Any, cast
+from typing import Any
import anyio
import yaml
@@ -20,12 +20,7 @@ from anyio import to_thread
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
-from astrbot.core import (
- DependencyConflictError,
- logger,
- pip_installer,
- sp,
-)
+from astrbot.core import DependencyConflictError, logger, pip_installer, sp
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import VERSION
@@ -39,9 +34,7 @@ from astrbot.core.utils.astrbot_path import (
)
from astrbot.core.utils.io import remove_dir
from astrbot.core.utils.metrics import Metric
-from astrbot.core.utils.requirements_utils import (
- plan_missing_requirements_install,
-)
+from astrbot.core.utils.requirements_utils import plan_missing_requirements_install
from . import StarMetadata
from .command_management import sync_command_configs
@@ -67,11 +60,7 @@ class PluginDependencyInstallError(Exception):
"""Raised when plugin dependency installation fails."""
def __init__(
- self,
- *,
- plugin_label: str,
- requirements_path: str,
- error: Exception,
+ self, *, plugin_label: str, requirements_path: str, error: Exception
) -> None:
message = f"插件 {plugin_label} 依赖安装失败: {error!s}"
super().__init__(message)
@@ -81,16 +70,10 @@ class PluginDependencyInstallError(Exception):
@contextlib.asynccontextmanager
-async def _temporary_filtered_requirements_file(
- *,
- install_lines: tuple[str, ...],
-):
+async def _temporary_filtered_requirements_file(*, install_lines: tuple[str, ...]):
filtered_requirements_path: str | None = None
temp_dir = get_astrbot_temp_path()
-
- # Create temp dir without blocking the event loop
await to_thread.run_sync(functools.partial(os.makedirs, temp_dir, exist_ok=True))
-
try:
def _create_temp():
@@ -105,7 +88,6 @@ async def _temporary_filtered_requirements_file(
return filtered_requirements_file.name
filtered_requirements_path = await to_thread.run_sync(_create_temp)
-
try:
yield filtered_requirements_path
finally:
@@ -122,29 +104,22 @@ async def _temporary_filtered_requirements_file(
filtered_requirements_path,
)
except Exception:
- # Let exceptions propagate to callers (do not swallow)
raise
async def _install_requirements_with_precheck(
- *,
- plugin_label: str,
- requirements_path: str,
+ *, plugin_label: str, requirements_path: str
) -> None:
install_plan = plan_missing_requirements_install(requirements_path)
-
if install_plan is None:
logger.info(
- f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
- f"{requirements_path}"
+ f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): {requirements_path}"
)
await pip_installer.install(requirements_path=requirements_path)
return
-
if not install_plan.missing_names:
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
return
-
if not install_plan.install_lines:
fallback_reason = install_plan.fallback_reason or "unknown reason"
logger.info(
@@ -155,14 +130,11 @@ async def _install_requirements_with_precheck(
)
await pip_installer.install(requirements_path=requirements_path)
return
-
logger.info(
- f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
- f"{requirements_path} -> {sorted(install_plan.missing_names)}"
+ f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: {requirements_path} -> {sorted(install_plan.missing_names)}"
)
-
async with _temporary_filtered_requirements_file(
- install_lines=install_plan.install_lines,
+ install_lines=install_plan.install_lines
) as filtered_requirements_path:
await pip_installer.install(requirements_path=filtered_requirements_path)
@@ -172,31 +144,26 @@ class PluginManager:
from .star_tools import StarTools
self.tasks = set()
-
self.updator = PluginUpdator()
-
self.context = context
self.context._star_manager = self
StarTools.initialize(context)
-
self.config = config
self.plugin_store_path = get_astrbot_plugin_path()
- """存储插件的路径。即 data/plugins"""
+ "存储插件的路径。即 data/plugins"
self.plugin_config_path = get_astrbot_config_path()
- """存储插件配置的路径。data/config"""
+ "存储插件配置的路径。data/config"
self.reserved_plugin_path = os.path.join(
get_astrbot_path(), "astrbot", "builtin_stars"
)
- """保留插件的路径。在 astrbot/builtin_stars 目录下"""
+ "保留插件的路径。在 astrbot/builtin_stars 目录下"
self.conf_schema_fname = "_conf_schema.json"
self.logo_fname = "logo.png"
- """插件配置 Schema 文件名"""
+ "插件配置 Schema 文件名"
self._pm_lock = asyncio.Lock()
- """StarManager操作互斥锁"""
-
+ "StarManager操作互斥锁"
self.failed_plugin_dict: dict[str, Any] = {}
- """加载失败插件的信息,用于后续可能的热重载"""
-
+ "加载失败插件的信息,用于后续可能的热重载"
self.failed_plugin_info = ""
if os.getenv("ASTRBOT_RELOAD", "0") == "1":
_watch_plugins_changes = asyncio.create_task(self._watch_plugins_changes())
@@ -212,7 +179,6 @@ class PluginManager:
watch_filter=PythonFilter(),
recursive=True,
):
- # 处理文件变化
await self._handle_file_changes(changes)
except asyncio.CancelledError:
pass
@@ -224,7 +190,6 @@ class PluginManager:
"""处理文件变化"""
logger.info(f"检测到文件变化: {changes}")
plugins_to_check = []
-
for star in star_registry:
if not star.activated:
continue
@@ -232,13 +197,11 @@ class PluginManager:
continue
if star.reserved:
plugin_dir_path = os.path.join(
- self.reserved_plugin_path,
- star.root_dir_name,
+ self.reserved_plugin_path, star.root_dir_name
)
else:
plugin_dir_path = os.path.join(
- self.plugin_store_path,
- star.root_dir_name,
+ self.plugin_store_path, star.root_dir_name
)
plugins_to_check.append((plugin_dir_path, star.name))
reloaded_plugins = set()
@@ -269,9 +232,7 @@ class PluginManager:
@staticmethod
def _get_modules(path):
modules = []
-
dirs = os.listdir(path)
- # 遍历文件夹,找到 main.py 或者和文件夹同名的文件
for d in dirs:
if os.path.isdir(os.path.join(path, d)):
if os.path.exists(os.path.join(path, d, "main.py")):
@@ -282,14 +243,14 @@ class PluginManager:
logger.info(f"插件 {d} 未找到 main.py 或者 {d}.py,跳过。")
continue
if os.path.exists(os.path.join(path, d, "main.py")) or os.path.exists(
- os.path.join(path, d, d + ".py"),
+ os.path.join(path, d, d + ".py")
):
modules.append(
{
"pname": d,
"module": module_str,
"module_path": os.path.join(path, d, module_str),
- },
+ }
)
return modules
@@ -325,18 +286,14 @@ class PluginManager:
return True
async def _ensure_plugin_requirements(
- self,
- plugin_dir_path: str,
- plugin_label: str,
+ self, plugin_dir_path: str, plugin_label: str
) -> None:
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
if not await anyio.Path(requirements_path).exists():
return
-
try:
await _install_requirements_with_precheck(
- plugin_label=plugin_label,
- requirements_path=requirements_path,
+ plugin_label=plugin_label, requirements_path=requirements_path
)
except asyncio.CancelledError:
raise
@@ -345,19 +302,13 @@ class PluginManager:
raise
except Exception as e:
dependency_error = PluginDependencyInstallError(
- plugin_label=plugin_label,
- requirements_path=requirements_path,
- error=e,
+ plugin_label=plugin_label, requirements_path=requirements_path, error=e
)
logger.exception(str(dependency_error))
raise dependency_error from e
async def _import_plugin_with_dependency_recovery(
- self,
- path: str,
- module_str: str,
- root_dir_name: str,
- requirements_path: str,
+ self, path: str, module_str: str, root_dir_name: str, requirements_path: str
) -> ModuleType:
try:
return __import__(path, fromlist=[module_str])
@@ -379,7 +330,6 @@ class PluginManager:
logger.info(
f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}"
)
-
await self._check_plugin_dept_update(target_plugin=root_dir_name)
return __import__(path, fromlist=[module_str])
@@ -390,32 +340,26 @@ class PluginManager:
Notes: 旧版本 AstrBot 插件可能使用的是 info() 函数来获取元数据。
"""
metadata = None
-
if not os.path.exists(plugin_path):
raise Exception("插件不存在。")
-
if os.path.exists(os.path.join(plugin_path, "metadata.yaml")):
with open(
- os.path.join(plugin_path, "metadata.yaml"),
- encoding="utf-8",
+ os.path.join(plugin_path, "metadata.yaml"), encoding="utf-8"
) as f:
- metadata = cast(dict[str, Any], yaml.safe_load(f))
+ metadata = yaml.safe_load(f)
elif plugin_obj and hasattr(plugin_obj, "info"):
- # 使用 info() 函数
metadata = plugin_obj.info()
-
if isinstance(metadata, dict):
if "desc" not in metadata and "description" in metadata:
metadata["desc"] = metadata["description"]
-
if (
"name" not in metadata
or "desc" not in metadata
or "version" not in metadata
- or "author" not in metadata
+ or ("author" not in metadata)
):
raise Exception(
- "插件元数据信息不完整。name, desc, version, author 是必须的字段。",
+ "插件元数据信息不完整。name, desc, version, author 是必须的字段。"
)
metadata = StarMetadata(
name=metadata["name"],
@@ -424,22 +368,17 @@ class PluginManager:
version=metadata["version"],
repo=metadata["repo"] if "repo" in metadata else None,
display_name=metadata.get("display_name", None),
- support_platforms=(
- [
- platform_id
- for platform_id in metadata["support_platforms"]
- if isinstance(platform_id, str)
- ]
- if isinstance(metadata.get("support_platforms"), list)
- else []
- ),
- astrbot_version=(
- metadata["astrbot_version"]
- if isinstance(metadata.get("astrbot_version"), str)
- else None
- ),
+ support_platforms=[
+ platform_id
+ for platform_id in metadata["support_platforms"]
+ if isinstance(platform_id, str)
+ ]
+ if isinstance(metadata.get("support_platforms"), list)
+ else [],
+ astrbot_version=metadata["astrbot_version"]
+ if isinstance(metadata.get("astrbot_version"), str)
+ else None,
)
-
return metadata
@staticmethod
@@ -462,17 +401,13 @@ class PluginManager:
metadata_path = os.path.join(plugin_path, "metadata.yaml")
if not os.path.exists(metadata_path):
raise Exception("未找到 metadata.yaml,无法获取插件目录名。")
-
with open(metadata_path, encoding="utf-8") as f:
- metadata = cast(dict[str, Any], yaml.safe_load(f))
-
+ metadata = yaml.safe_load(f)
if not isinstance(metadata, dict):
raise Exception("metadata.yaml 格式错误。")
-
plugin_name = metadata.get("name")
if not isinstance(plugin_name, str) or not plugin_name.strip():
raise Exception("metadata.yaml 中缺少 name 字段。")
-
plugin_dir_name = PluginManager._normalize_plugin_dir_name(plugin_name)
if not plugin_dir_name:
raise Exception("metadata.yaml 中 name 字段内容非法。")
@@ -484,12 +419,10 @@ class PluginManager:
version_spec: str | None,
) -> tuple[bool, str | None]:
if not version_spec:
- return True, None
-
+ return (True, None)
normalized_spec = version_spec.strip()
if not normalized_spec:
- return True, None
-
+ return (True, None)
try:
specifier = SpecifierSet(normalized_spec)
except InvalidSpecifier:
@@ -497,7 +430,6 @@ class PluginManager:
False,
"astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。",
)
-
try:
current_version = Version(VERSION)
except InvalidVersion:
@@ -505,18 +437,16 @@ class PluginManager:
False,
f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。",
)
-
if current_version not in specifier:
return (
False,
f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}",
)
- return True, None
+ return (True, None)
@staticmethod
def _get_plugin_related_modules(
- plugin_root_dir: str,
- is_reserved: bool = False,
+ plugin_root_dir: str, is_reserved: bool = False
) -> list[str]:
"""获取与指定插件相关的所有已加载模块名
@@ -559,11 +489,9 @@ class PluginManager:
if key.startswith(pattern):
del sys.modules[key]
logger.debug(f"删除模块 {key}")
-
if root_dir_name:
for module_name in self._get_plugin_related_modules(
- root_dir_name,
- is_reserved,
+ root_dir_name, is_reserved
):
try:
del sys.modules[module_name]
@@ -573,26 +501,19 @@ class PluginManager:
def _cleanup_plugin_state(self, dir_name: str) -> None:
plugin_root_name = "data.plugins."
-
- # 清理 sys.modules
for key in list(sys.modules.keys()):
if key.startswith(f"{plugin_root_name}{dir_name}"):
logger.info(f"清除了插件{dir_name}中的{key}模块")
del sys.modules[key]
-
possible_paths = [
f"{plugin_root_name}{dir_name}.main",
f"{plugin_root_name}{dir_name}.{dir_name}",
]
-
- # 清理 handlers
for path in possible_paths:
handlers = star_handlers_registry.get_handlers_by_module_name(path)
for handler in handlers:
star_handlers_registry.remove(handler)
logger.info(f"清理处理器: {handler.handler_name}")
-
- # 清理工具
for tool in list(llm_tools.func_list):
if getattr(tool, "handler_module_path", None) in possible_paths:
llm_tools.func_list.remove(tool)
@@ -629,17 +550,13 @@ class PluginManager:
}
)
except Exception as metadata_error:
- logger.debug(
- f"读取失败插件 {root_dir_name} 元数据失败: {metadata_error!s}",
- )
-
+ logger.debug(f"读取失败插件 {root_dir_name} 元数据失败: {metadata_error!s}")
return record
def _rebuild_failed_plugin_info(self) -> None:
if not self.failed_plugin_dict:
self.failed_plugin_info = ""
return
-
lines = []
for dir_name, info in self.failed_plugin_dict.items():
if isinstance(info, dict):
@@ -648,16 +565,15 @@ class PluginManager:
version = info.get("version") or info.get("astrbot_version")
if version:
lines.append(
- f"加载插件「{display_name}」(目录: {dir_name}, 版本: {version}) 时出现问题,原因:{error}。",
+ f"加载插件「{display_name}」(目录: {dir_name}, 版本: {version}) 时出现问题,原因:{error}。"
)
else:
lines.append(
- f"加载插件「{display_name}」(目录: {dir_name}) 时出现问题,原因:{error}。",
+ f"加载插件「{display_name}」(目录: {dir_name}) 时出现问题,原因:{error}。"
)
else:
error = str(info)
lines.append(f"加载插件目录 {dir_name} 时出现问题,原因:{error}。")
-
self.failed_plugin_info = "\n".join(lines) + "\n"
async def reload_failed_plugin(self, dir_name):
@@ -670,23 +586,19 @@ class PluginManager:
- success (bool): 重载是否成功
- error_message (str|None): 错误信息,成功时为 None
"""
-
async with self._pm_lock:
if dir_name not in self.failed_plugin_dict:
- return False, "插件不存在于失败列表中"
-
+ return (False, "插件不存在于失败列表中")
self._cleanup_plugin_state(dir_name)
-
plugin_path = os.path.join(self.plugin_store_path, dir_name)
await self._ensure_plugin_requirements(plugin_path, dir_name)
-
success, error = await self.load(specified_dir_name=dir_name)
if success:
self.failed_plugin_dict.pop(dir_name, None)
self._rebuild_failed_plugin_info()
- return success, None
+ return (success, None)
else:
- return False, error
+ return (False, error)
async def reload(self, specified_plugin_name=None):
"""重新加载插件
@@ -708,26 +620,21 @@ class PluginManager:
if smd.name == specified_plugin_name:
specified_module_path = smd.module_path
break
-
- # 终止插件
if not specified_module_path:
- # 重载所有插件
for smd in star_registry:
try:
await self._terminate_plugin(smd)
except Exception as e:
logger.warning(traceback.format_exc())
logger.warning(
- f"插件 {smd.name} 未被正常终止: {e!s}, 可能会导致该插件运行不正常。",
+ f"插件 {smd.name} 未被正常终止: {e!s}, 可能会导致该插件运行不正常。"
)
if smd.name and smd.module_path:
await self._unbind_plugin(smd.name, smd.module_path)
-
star_handlers_registry.clear()
star_map.clear()
star_registry.clear()
else:
- # 只重载指定插件
smd = star_map.get(specified_module_path)
if smd:
try:
@@ -735,13 +642,11 @@ class PluginManager:
except Exception as e:
logger.warning(traceback.format_exc())
logger.warning(
- f"插件 {smd.name} 未被正常终止: {e!s}, 可能会导致该插件运行不正常。",
+ f"插件 {smd.name} 未被正常终止: {e!s}, 可能会导致该插件运行不正常。"
)
if smd.name:
await self._unbind_plugin(smd.name, specified_module_path)
-
result = await self.load(specified_module_path)
-
return result
async def cleanup_loaded_plugins(self) -> None:
@@ -753,11 +658,10 @@ class PluginManager:
except Exception as e:
logger.warning(traceback.format_exc())
logger.warning(
- f"插件 {smd.name} 未被正常终止: {e!s}, 可能会导致该插件运行不正常。",
+ f"插件 {smd.name} 未被正常终止: {e!s}, 可能会导致该插件运行不正常。"
)
if smd.name and smd.module_path:
await self._unbind_plugin(smd.name, smd.module_path)
-
star_handlers_registry.clear()
star_map.clear()
star_registry.clear()
@@ -788,42 +692,28 @@ class PluginManager:
await sp.global_get("inactivated_llm_tools", []) or []
)
alter_cmd: dict[str, Any] = await sp.global_get("alter_cmd", {}) or {}
-
plugin_modules = self._get_plugin_modules()
if plugin_modules is None:
- return False, "未找到任何插件模块"
-
+ return (False, "未找到任何插件模块")
has_load_error = False
-
- # 导入插件模块,并尝试实例化插件类
for plugin_module in plugin_modules:
try:
module_str = plugin_module["module"]
- # module_path = plugin_module['module_path']
- root_dir_name = plugin_module["pname"] # 插件的目录名
- reserved = plugin_module.get(
- "reserved",
- False,
- ) # 是否是保留插件。目前在 astrbot/builtin_stars 目录下的都是保留插件。保留插件不可以卸载。
+ root_dir_name = plugin_module["pname"]
+ reserved = plugin_module.get("reserved", False)
plugin_dir_path = (
os.path.join(self.plugin_store_path, root_dir_name)
if not reserved
else os.path.join(self.reserved_plugin_path, root_dir_name)
)
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
-
path = "data.plugins." if not reserved else "astrbot.builtin_stars."
path += root_dir_name + "." + module_str
-
- # 检查是否需要载入指定的插件
if specified_module_path and path != specified_module_path:
continue
if specified_dir_name and root_dir_name != specified_dir_name:
continue
-
logger.info(f"正在载入插件 {root_dir_name} ...")
-
- # 尝试导入模块
try:
module = await self._import_plugin_with_dependency_recovery(
path=path,
@@ -851,35 +741,26 @@ class PluginManager:
if metadata in star_registry:
star_registry.remove(metadata)
continue
-
- # 检查 _conf_schema.json
plugin_config = None
plugin_schema_path = os.path.join(
- plugin_dir_path,
- self.conf_schema_fname,
+ plugin_dir_path, self.conf_schema_fname
)
if await anyio.Path(plugin_schema_path).exists():
- # 加载插件配置
async with await anyio.open_file(
plugin_schema_path, encoding="utf-8"
) as f:
plugin_config = AstrBotConfig(
config_path=os.path.join(
- self.plugin_config_path,
- f"{root_dir_name}_config.json",
+ self.plugin_config_path, f"{root_dir_name}_config.json"
),
schema=json.loads(await f.read()),
)
logo_path = os.path.join(plugin_dir_path, self.logo_fname)
-
if path in star_map:
- # 通过 __init__subclass__ 注册插件
metadata = star_map[path]
-
try:
- # yaml 文件的元数据优先
metadata_yaml = self._load_plugin_metadata(
- plugin_path=plugin_dir_path,
+ plugin_path=plugin_dir_path
)
if metadata_yaml:
metadata.name = metadata_yaml.name
@@ -892,13 +773,12 @@ class PluginManager:
metadata.astrbot_version = metadata_yaml.astrbot_version
except Exception as e:
logger.warning(
- f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。",
+ f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。"
)
-
if not ignore_version_check:
is_valid, error_message = (
self._validate_astrbot_version_specifier(
- metadata.astrbot_version,
+ metadata.astrbot_version
)
)
if not is_valid:
@@ -906,66 +786,53 @@ class PluginManager:
error_message
or "The plugin is not compatible with the current AstrBot version."
)
-
logger.info(metadata)
metadata.config = plugin_config
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (metadata.author or "unknown").lower().replace("/", "_")
plugin_id = f"{p_author}/{p_name}"
-
- # 在实例化前注入类属性,保证插件 __init__ 可读取这些值
if metadata.star_cls_type:
metadata.star_cls_type.name = p_name
metadata.star_cls_type.author = p_author
metadata.star_cls_type.plugin_id = plugin_id
-
if path not in inactivated_plugins:
- # 只有没有禁用插件时才实例化插件类
if plugin_config and metadata.star_cls_type:
try:
metadata.star_cls = metadata.star_cls_type(
- context=self.context,
- config=plugin_config,
+ context=self.context, config=plugin_config
)
except TypeError as _:
metadata.star_cls = metadata.star_cls_type(
- context=self.context,
+ context=self.context
)
elif metadata.star_cls_type:
metadata.star_cls = metadata.star_cls_type(
- context=self.context,
+ context=self.context
)
-
if metadata.star_cls:
metadata.star_cls.name = p_name
metadata.star_cls.author = p_author
metadata.star_cls.plugin_id = plugin_id
else:
logger.info(f"插件 {metadata.name} 已被禁用。")
-
metadata.module = module
metadata.root_dir_name = root_dir_name
metadata.reserved = reserved
-
assert metadata.module_path is not None, (
f"插件 {metadata.name} 的模块路径为空。"
)
assert metadata.star_cls is not None, (
f"插件 {metadata.name} 的实例为空。"
)
-
- # 绑定 handler
related_handlers = (
star_handlers_registry.get_handlers_by_module_name(
- metadata.module_path,
+ metadata.module_path
)
)
for handler in related_handlers:
handler.handler = functools.partial(
- handler.handler,
- metadata.star_cls,
+ handler.handler, metadata.star_cls
)
- # 绑定 llm_tool handler
for func_tool in llm_tools.func_list:
if isinstance(func_tool, HandoffTool):
need_apply = []
@@ -976,55 +843,42 @@ class PluginManager:
need_apply.append(sub_tool)
else:
need_apply = [func_tool]
-
for ft in need_apply:
- if (
- ft.handler
- and ft.handler.__module__ == metadata.module_path
- ):
- ft.handler_module_path = metadata.module_path # type: ignore[union-attr]
- ft.handler = functools.partial(
- ft.handler,
- metadata.star_cls,
- )
+ if isinstance(ft, FunctionTool) and ft.handler:
+ if (
+ getattr(ft.handler, "__module__", "")
+ == metadata.module_path
+ ):
+ ft.handler_module_path = metadata.module_path
+ ft.handler = functools.partial(
+ ft.handler, metadata.star_cls
+ )
if ft.name in inactivated_llm_tools:
ft.active = False
-
else:
- # v3.4.0 以前的方式注册插件
logger.debug(
- f"插件 {path} 未通过装饰器注册。尝试通过旧版本方式载入。",
+ f"插件 {path} 未通过装饰器注册。尝试通过旧版本方式载入。"
)
classes = self._get_classes(module)
-
if path not in inactivated_plugins:
- # 只有没有禁用插件时才实例化插件类
if plugin_config:
try:
obj = getattr(module, classes[0])(
- context=self.context,
- config=plugin_config,
- ) # 实例化插件类
+ context=self.context, config=plugin_config
+ )
except TypeError as _:
- obj = getattr(module, classes[0])(
- context=self.context,
- ) # 实例化插件类
+ obj = getattr(module, classes[0])(context=self.context)
else:
- obj = getattr(module, classes[0])(
- context=self.context,
- ) # 实例化插件类
-
+ obj = getattr(module, classes[0])(context=self.context)
metadata = self._load_plugin_metadata(
- plugin_path=plugin_dir_path,
- plugin_obj=obj,
+ plugin_path=plugin_dir_path, plugin_obj=obj
)
if not metadata:
raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。")
-
if not ignore_version_check:
is_valid, error_message = (
self._validate_astrbot_version_specifier(
- metadata.astrbot_version,
+ metadata.astrbot_version
)
)
if not is_valid:
@@ -1032,7 +886,6 @@ class PluginManager:
error_message
or "The plugin is not compatible with the current AstrBot version."
)
-
metadata.star_cls = obj
metadata.config = plugin_config
metadata.module = module
@@ -1042,31 +895,22 @@ class PluginManager:
metadata.module_path = path
star_map[path] = metadata
star_registry.append(metadata)
-
- # 禁用/启用插件
if metadata.module_path in inactivated_plugins:
metadata.activated = False
-
- # Plugin logo path
if await anyio.Path(logo_path).exists():
metadata.logo_path = logo_path
-
assert metadata.module_path, f"插件 {metadata.name} 模块路径为空"
-
full_names = []
for handler in star_handlers_registry.get_handlers_by_module_name(
- metadata.module_path,
+ metadata.module_path
):
full_names.append(handler.handler_full_name)
-
- # 检查并且植入自定义的权限过滤器(alter_cmd)
if (
metadata.name in alter_cmd
and handler.handler_name in alter_cmd[metadata.name]
):
cmd_type = alter_cmd[metadata.name][handler.handler_name].get(
- "permission",
- "member",
+ "permission", "member"
)
found_permission_filter = False
for filter_ in handler.event_filters:
@@ -1082,28 +926,22 @@ class PluginManager:
PermissionTypeFilter(
PermissionType.ADMIN
if cmd_type == "admin"
- else PermissionType.MEMBER,
- ),
+ else PermissionType.MEMBER
+ )
)
-
logger.debug(
- f"插入权限过滤器 {cmd_type} 到 {metadata.name} 的 {handler.handler_name} 方法。",
+ f"插入权限过滤器 {cmd_type} 到 {metadata.name} 的 {handler.handler_name} 方法。"
)
-
metadata.star_handler_full_names = full_names
-
- # 执行 initialize() 方法
if hasattr(metadata.star_cls, "initialize") and metadata.star_cls:
await metadata.star_cls.initialize()
-
- # 触发插件加载事件
handlers = star_handlers_registry.get_handlers_by_event_type(
- EventType.OnPluginLoadedEvent,
+ EventType.OnPluginLoadedEvent
)
for handler in handlers:
try:
logger.info(
- f"hook(on_plugin_loaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
+ f"hook(on_plugin_loaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
)
await handler.handler(metadata)
except Exception:
@@ -1121,7 +959,6 @@ class PluginManager:
)
except Exception as exc:
logger.warning("SDK plugin_loaded dispatch failed: %s", exc)
-
except BaseException as e:
logger.error(f"----- 插件 {root_dir_name} 载入失败 -----")
errors = traceback.format_exc()
@@ -1138,14 +975,11 @@ class PluginManager:
error_trace=errors,
)
)
- # 记录注册失败的插件名称,以便后续重载插件
if path in star_map:
logger.info("失败插件依旧在插件列表中,正在清理...")
metadata = star_map.pop(path)
if metadata in star_registry:
star_registry.remove(metadata)
-
- # 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
try:
@@ -1153,23 +987,19 @@ class PluginManager:
except Exception as e:
logger.error(f"同步指令配置失败: {e!s}")
logger.error(traceback.format_exc())
-
self._rebuild_failed_plugin_info()
if has_load_error:
- return False, self.failed_plugin_info
- return True, None
+ return (False, self.failed_plugin_info)
+ return (True, None)
async def _cleanup_failed_plugin_install(
- self,
- dir_name: str,
- plugin_path: str,
+ self, dir_name: str, plugin_path: str
) -> None:
plugin = None
for star in self.context.get_all_stars():
if star.root_dir_name == dir_name:
plugin = star
break
-
if plugin and plugin.name and plugin.module_path:
try:
await self._terminate_plugin(plugin)
@@ -1179,19 +1009,14 @@ class PluginManager:
await self._unbind_plugin(plugin.name, plugin.module_path)
except Exception:
logger.warning(traceback.format_exc())
-
if await anyio.Path(plugin_path).exists():
try:
await to_thread.run_sync(remove_dir, plugin_path)
logger.warning(f"已清理安装失败的插件目录: {plugin_path}")
except Exception as e:
- logger.warning(
- f"清理安装失败插件目录失败: {plugin_path},原因: {e!s}",
- )
-
+ logger.warning(f"清理安装失败插件目录失败: {plugin_path},原因: {e!s}")
plugin_config_path = os.path.join(
- self.plugin_config_path,
- f"{dir_name}_config.json",
+ self.plugin_config_path, f"{dir_name}_config.json"
)
if await anyio.Path(plugin_config_path).exists():
try:
@@ -1199,7 +1024,7 @@ class PluginManager:
logger.warning(f"已清理安装失败插件配置: {plugin_config_path}")
except Exception as e:
logger.warning(
- f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
+ f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}"
)
def _cleanup_plugin_optional_artifacts(
@@ -1212,8 +1037,7 @@ class PluginManager:
) -> None:
if delete_config:
config_file = os.path.join(
- self.plugin_config_path,
- f"{root_dir_name}_config.json",
+ self.plugin_config_path, f"{root_dir_name}_config.json"
)
if os.path.exists(config_file):
try:
@@ -1221,45 +1045,36 @@ class PluginManager:
logger.info(f"已删除插件 {plugin_label} 的配置文件")
except Exception as e:
logger.warning(f"删除插件配置文件失败 ({plugin_label}): {e!s}")
-
if delete_data:
data_base_dir = os.path.dirname(self.plugin_store_path)
for data_dir_name in ("plugin_data", "plugins_data"):
plugin_data_dir = os.path.join(
- data_base_dir,
- data_dir_name,
- root_dir_name,
+ data_base_dir, data_dir_name, root_dir_name
)
if os.path.exists(plugin_data_dir):
try:
remove_dir(plugin_data_dir)
logger.info(
- f"已删除插件 {plugin_label} 的持久化数据 ({data_dir_name})",
+ f"已删除插件 {plugin_label} 的持久化数据 ({data_dir_name})"
)
except Exception as e:
logger.warning(
- f"删除插件持久化数据失败 ({data_dir_name}, {plugin_label}): {e!s}",
+ f"删除插件持久化数据失败 ({data_dir_name}, {plugin_label}): {e!s}"
)
def _track_failed_install_dir(
- self,
- *,
- dir_name: str,
- plugin_path: str,
- error: Exception,
+ self, *, dir_name: str, plugin_path: str, error: Exception
) -> None:
if (
not dir_name
or not plugin_path
- or not os.path.isdir(plugin_path)
- or dir_name in self.failed_plugin_dict
+ or (not os.path.isdir(plugin_path))
+ or (dir_name in self.failed_plugin_dict)
):
return
-
for star in self.context.get_all_stars():
if star.root_dir_name == dir_name:
return
-
self.failed_plugin_dict[dir_name] = self._build_failed_plugin_record(
root_dir_name=dir_name,
plugin_dir_path=plugin_path,
@@ -1287,15 +1102,10 @@ class PluginManager:
如果找不到插件元数据则返回 None。
"""
- # this metric is for displaying plugins installation count in webui
_task_install_star = asyncio.create_task(
- Metric.upload(
- et="install_star",
- repo=repo_url,
- ),
+ Metric.upload(et="install_star", repo=repo_url)
)
self.tasks.add(_task_install_star)
-
async with self._pm_lock:
plugin_path = ""
dir_name = ""
@@ -1308,13 +1118,10 @@ class PluginManager:
f"安装失败:目录 {os.path.basename(plugin_path)} 已存在。"
)
plugin_path = await self.updator.install(repo_url, proxy)
-
- # reload the plugin
dir_name = os.path.basename(plugin_path)
metadata_dir_name = self._get_plugin_dir_name_from_metadata(plugin_path)
target_plugin_path = os.path.join(
- self.plugin_store_path,
- metadata_dir_name,
+ self.plugin_store_path, metadata_dir_name
)
if (
target_plugin_path != plugin_path
@@ -1325,10 +1132,7 @@ class PluginManager:
os.rename(plugin_path, target_plugin_path)
plugin_path = target_plugin_path
dir_name = metadata_dir_name
- await self._ensure_plugin_requirements(
- plugin_path,
- dir_name,
- )
+ await self._ensure_plugin_requirements(plugin_path, dir_name)
success, error_message = await self.load(
specified_dir_name=dir_name,
ignore_version_check=ignore_version_check,
@@ -1338,22 +1142,16 @@ class PluginManager:
error_message
or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。"
)
-
- # Get the plugin metadata to return repo info
plugin = self.context.get_registered_star(dir_name)
if not plugin:
- # Try to find by other name if directory name doesn't match plugin name
for star in self.context.get_all_stars():
if star.root_dir_name == dir_name:
plugin = star
break
-
- # Extract README.md content if exists
readme_content = None
readme_path = os.path.join(plugin_path, "README.md")
if not await anyio.Path(readme_path).exists():
readme_path = os.path.join(plugin_path, "readme.md")
-
if await anyio.Path(readme_path).exists():
try:
readme_content = await anyio.Path(readme_path).read_text(
@@ -1361,9 +1159,8 @@ class PluginManager:
)
except Exception as e:
logger.warning(
- f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}",
+ f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}"
)
-
plugin_info = None
if plugin:
plugin_info = {
@@ -1371,25 +1168,19 @@ class PluginManager:
"readme": readme_content,
"name": plugin.name,
}
-
return plugin_info
except Exception as e:
self._track_failed_install_dir(
- dir_name=dir_name,
- plugin_path=plugin_path,
- error=e,
+ dir_name=dir_name, plugin_path=plugin_path, error=e
)
if dir_name and plugin_path:
logger.warning(
- f"安装插件 {dir_name} 失败,插件安装目录:{plugin_path}",
+ f"安装插件 {dir_name} 失败,插件安装目录:{plugin_path}"
)
raise
async def uninstall_plugin(
- self,
- plugin_name: str,
- delete_config: bool = False,
- delete_data: bool = False,
+ self, plugin_name: str, delete_config: bool = False, delete_data: bool = False
) -> None:
"""卸载指定的插件。
@@ -1410,30 +1201,22 @@ class PluginManager:
raise Exception("该插件是 AstrBot 保留插件,无法卸载。")
root_dir_name = plugin.root_dir_name
ppath = self.plugin_store_path
-
- # 终止插件
try:
await self._terminate_plugin(plugin)
except Exception as e:
logger.warning(traceback.format_exc())
logger.warning(
- f"插件 {plugin_name} 未被正常终止 {e!s}, 可能会导致资源泄露等问题。",
+ f"插件 {plugin_name} 未被正常终止 {e!s}, 可能会导致资源泄露等问题。"
)
-
- # 从 star_registry 和 star_map 中删除
if plugin.module_path is None or root_dir_name is None:
raise Exception(f"插件 {plugin_name} 数据不完整,无法卸载。")
-
await self._unbind_plugin(plugin_name, plugin.module_path)
-
- # 删除插件文件夹
try:
remove_dir(os.path.join(ppath, root_dir_name))
except Exception as e:
raise Exception(
- f"移除插件成功,但是删除插件文件夹失败: {e!s}。您可以手动删除该文件夹,位于 addons/plugins/ 下。",
- )
-
+ f"移除插件成功,但是删除插件文件夹失败: {e!s}。您可以手动删除该文件夹,位于 addons/plugins/ 下。"
+ ) from e
self._cleanup_plugin_optional_artifacts(
root_dir_name=root_dir_name,
plugin_label=plugin_name,
@@ -1442,26 +1225,16 @@ class PluginManager:
)
async def uninstall_failed_plugin(
- self,
- dir_name: str,
- delete_config: bool = False,
- delete_data: bool = False,
+ self, dir_name: str, delete_config: bool = False, delete_data: bool = False
) -> None:
"""卸载加载失败的插件(按目录名)。"""
async with self._pm_lock:
failed_info = self.failed_plugin_dict.get(dir_name)
if not failed_info:
- raise Exception(
- format_plugin_error("not_found_in_failed_list"),
- )
-
+ raise Exception(format_plugin_error("not_found_in_failed_list"))
if isinstance(failed_info, dict) and failed_info.get("reserved"):
- raise Exception(
- format_plugin_error("reserved_plugin_cannot_uninstall"),
- )
-
+ raise Exception(format_plugin_error("reserved_plugin_cannot_uninstall"))
self._cleanup_plugin_state(dir_name)
-
plugin_path = os.path.join(self.plugin_store_path, dir_name)
if await anyio.Path(plugin_path).exists():
try:
@@ -1469,16 +1242,14 @@ class PluginManager:
except Exception as e:
raise Exception(
format_plugin_error(
- "failed_plugin_dir_remove_error",
- error=f"{e!s}",
- ),
- )
+ "failed_plugin_dir_remove_error", error=f"{e!s}"
+ )
+ ) from e
else:
logger.debug(
"插件目录不存在,视为已部分卸载状态,继续清理失败插件记录和可选产物: %s",
plugin_path,
)
-
plugin_label = dir_name
if isinstance(failed_info, dict):
plugin_label = (
@@ -1486,14 +1257,12 @@ class PluginManager:
or failed_info.get("name")
or dir_name
)
-
self._cleanup_plugin_optional_artifacts(
root_dir_name=dir_name,
plugin_label=plugin_label,
delete_config=delete_config,
delete_data=delete_data,
)
-
self.failed_plugin_dict.pop(dir_name, None)
self._rebuild_failed_plugin_info()
@@ -1513,51 +1282,40 @@ class PluginManager:
del star_registry[i]
break
for handler in star_handlers_registry.get_handlers_by_module_name(
- plugin_module_path,
+ plugin_module_path
):
logger.info(
- f"移除了插件 {plugin_name} 的处理函数 {handler.handler_name} ({len(star_handlers_registry)})",
+ f"移除了插件 {plugin_name} 的处理函数 {handler.handler_name} ({len(star_handlers_registry)})"
)
star_handlers_registry.remove(handler)
-
for k in [
k
for k in star_handlers_registry.star_handlers_map
if k.startswith(plugin_module_path)
]:
del star_handlers_registry.star_handlers_map[k]
-
- # llm_tools 中移除该插件的工具函数绑定
to_remove = []
for func_tool in llm_tools.func_list:
mp = getattr(func_tool, "handler_module_path", None)
if (
mp
and mp.startswith(plugin_module_path)
- and not mp.endswith(("astrbot.builtin_stars", "data.plugins"))
+ and (not mp.endswith(("astrbot.builtin_stars", "data.plugins")))
):
to_remove.append(func_tool)
for func_tool in to_remove:
llm_tools.func_list.remove(func_tool)
-
- # Unregister platform adapters registered by this plugin
- # module_path is like "data.plugins.my_plugin.main", extract prefix like "data.plugins.my_plugin"
module_prefix = ".".join(plugin_module_path.split(".")[:-1])
if module_prefix:
unregistered_adapters = unregister_platform_adapters_by_module(
module_prefix
)
for adapter_name in unregistered_adapters:
- logger.info(
- f"移除了插件 {plugin_name} 的平台适配器 {adapter_name}",
- )
-
+ logger.info(f"移除了插件 {plugin_name} 的平台适配器 {adapter_name}")
if plugin is None:
return
-
self._purge_modules(
- root_dir_name=plugin.root_dir_name,
- is_reserved=plugin.reserved,
+ root_dir_name=plugin.root_dir_name, is_reserved=plugin.reserved
)
async def update_plugin(self, plugin_name: str, proxy="") -> None:
@@ -1567,14 +1325,10 @@ class PluginManager:
raise Exception("插件不存在。")
if plugin.reserved:
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
-
- await self.updator.update(plugin, proxy=proxy)
+ await self.updator.update_plugin(plugin, proxy=proxy)
if plugin.root_dir_name:
plugin_dir_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
- await self._ensure_plugin_requirements(
- plugin_dir_path,
- plugin_name,
- )
+ await self._ensure_plugin_requirements(plugin_dir_path, plugin_name)
await self.reload(plugin_name)
async def turn_off_plugin(self, plugin_name: str) -> None:
@@ -1587,81 +1341,61 @@ class PluginManager:
plugin = self.context.get_registered_star(plugin_name)
if not plugin:
raise Exception("插件不存在。")
-
- # 调用插件的终止方法
await self._terminate_plugin(plugin)
-
- # 加入到 shared_preferences 中
inactivated_plugins: list[Any] = (
await sp.global_get("inactivated_plugins", []) or []
)
if plugin.module_path not in inactivated_plugins:
inactivated_plugins.append(plugin.module_path)
-
inactivated_llm_tools: list[Any] = list(
- set(await sp.global_get("inactivated_llm_tools", []) or []),
- ) # 后向兼容
-
- # 禁用插件启用的 llm_tool
+ set(await sp.global_get("inactivated_llm_tools", []) or [])
+ )
for func_tool in llm_tools.func_list:
mp = getattr(func_tool, "handler_module_path", None)
if (
plugin.module_path
and mp
and plugin.module_path.startswith(mp)
- and not mp.endswith(("astrbot.builtin_stars", "data.plugins"))
+ and (not mp.endswith(("astrbot.builtin_stars", "data.plugins")))
):
func_tool.active = False
if func_tool.name not in inactivated_llm_tools:
inactivated_llm_tools.append(func_tool.name)
-
await sp.global_put("inactivated_plugins", inactivated_plugins)
await sp.global_put("inactivated_llm_tools", inactivated_llm_tools)
-
plugin.activated = False
@staticmethod
async def _terminate_plugin(star_metadata: StarMetadata) -> None:
"""终止插件,调用插件的 terminate() 和 __del__() 方法"""
logger.info(f"正在终止插件 {star_metadata.name} ...")
-
if not star_metadata.activated:
- # 说明之前已经被禁用了
logger.debug(f"插件 {star_metadata.name} 未被激活,不需要终止,跳过。")
return
-
if star_metadata.star_cls is None:
return
-
if "__del__" in star_metadata.star_cls_type.__dict__:
loop = asyncio.get_running_loop()
- future = loop.run_in_executor(
- None,
- star_metadata.star_cls.__del__,
- )
+ future = loop.run_in_executor(None, star_metadata.star_cls.__del__)
def _log_del_exception(fut: asyncio.Future) -> None:
if fut.cancelled():
return
if (exc := fut.exception()) is not None:
logger.error(
- "插件 %s 在 __del__ 中抛出了异常:%r",
- star_metadata.name,
- exc,
+ "插件 %s 在 __del__ 中抛出了异常:%r", star_metadata.name, exc
)
future.add_done_callback(_log_del_exception)
elif "terminate" in star_metadata.star_cls_type.__dict__:
await star_metadata.star_cls.terminate()
-
- # 触发插件卸载事件
handlers = star_handlers_registry.get_handlers_by_event_type(
- EventType.OnPluginUnloadedEvent,
+ EventType.OnPluginUnloadedEvent
)
for handler in handlers:
try:
logger.info(
- f"hook(on_plugin_unloaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
+ f"hook(on_plugin_unloaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
)
await handler.handler(star_metadata)
except Exception:
@@ -1698,21 +1432,18 @@ class PluginManager:
if plugin.module_path in inactivated_plugins:
inactivated_plugins.remove(plugin.module_path)
await sp.global_put("inactivated_plugins", inactivated_plugins)
-
- # 启用插件启用的 llm_tool
for func_tool in llm_tools.func_list:
mp = getattr(func_tool, "handler_module_path", None)
if (
plugin.module_path
and mp
and plugin.module_path.startswith(mp)
- and not mp.endswith(("astrbot.builtin_stars", "data.plugins"))
- and func_tool.name in inactivated_llm_tools
+ and (not mp.endswith(("astrbot.builtin_stars", "data.plugins")))
+ and (func_tool.name in inactivated_llm_tools)
):
inactivated_llm_tools.remove(func_tool.name)
func_tool.active = True
await sp.global_put("inactivated_llm_tools", inactivated_llm_tools)
-
await self.reload(plugin_name)
async def install_plugin_from_file(
@@ -1723,14 +1454,10 @@ class PluginManager:
dir=self.plugin_store_path, prefix="plugin_upload_"
)
temp_desti_dir = desti_dir
-
try:
self.updator.unzip_file(zip_file_path, desti_dir)
metadata_dir_name = self._get_plugin_dir_name_from_metadata(desti_dir)
- target_plugin_path = os.path.join(
- self.plugin_store_path,
- metadata_dir_name,
- )
+ target_plugin_path = os.path.join(self.plugin_store_path, metadata_dir_name)
if (
target_plugin_path != desti_dir
and await anyio.Path(target_plugin_path).exists()
@@ -1740,38 +1467,28 @@ class PluginManager:
os.rename(desti_dir, target_plugin_path)
dir_name = metadata_dir_name
desti_dir = target_plugin_path
-
- # remove the zip
try:
os.remove(zip_file_path)
except BaseException as e:
logger.warning(f"删除插件压缩包失败: {e!s}")
await self._ensure_plugin_requirements(desti_dir, dir_name)
- # await self.reload()
success, error_message = await self.load(
- specified_dir_name=dir_name,
- ignore_version_check=ignore_version_check,
+ specified_dir_name=dir_name, ignore_version_check=ignore_version_check
)
if not success:
raise Exception(
error_message or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。"
)
-
- # Get the plugin metadata to return repo info
plugin = self.context.get_registered_star(dir_name)
if not plugin:
- # Try to find by other name if directory name doesn't match plugin name
for star in self.context.get_all_stars():
if star.root_dir_name == dir_name:
plugin = star
break
-
- # Extract README.md content if exists
readme_content = None
readme_path = os.path.join(desti_dir, "README.md")
if not await anyio.Path(readme_path).exists():
readme_path = os.path.join(desti_dir, "readme.md")
-
if await anyio.Path(readme_path).exists():
try:
readme_content = await anyio.Path(readme_path).read_text(
@@ -1779,7 +1496,6 @@ class PluginManager:
)
except Exception as e:
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}")
-
plugin_info = None
if plugin:
plugin_info = {
@@ -1787,27 +1503,18 @@ class PluginManager:
"readme": readme_content,
"name": plugin.name,
}
-
if plugin.repo:
_task_install_star_f = asyncio.create_task(
- Metric.upload(
- et="install_star_f", # install star
- repo=plugin.repo,
- ),
+ Metric.upload(et="install_star_f", repo=plugin.repo)
)
self.tasks.add(_task_install_star_f)
_task_install_star_f.add_done_callback(self.tasks.discard)
-
return plugin_info
except Exception as e:
self._track_failed_install_dir(
- dir_name=dir_name,
- plugin_path=desti_dir,
- error=e,
- )
- logger.warning(
- f"安装插件 {dir_name} 失败,插件安装目录:{desti_dir}",
+ dir_name=dir_name, plugin_path=desti_dir, error=e
)
+ logger.warning(f"安装插件 {dir_name} 失败,插件安装目录:{desti_dir}")
raise
finally:
if (
@@ -1818,5 +1525,5 @@ class PluginManager:
remove_dir(temp_desti_dir)
except Exception as e:
logger.warning(
- f"清理临时插件解压目录失败: {temp_desti_dir},原因: {e!s}",
+ f"清理临时插件解压目录失败: {temp_desti_dir},原因: {e!s}"
)
diff --git a/astrbot/core/star/updator.py b/astrbot/core/star/updator.py
index cf319e78d..3abfa240b 100644
--- a/astrbot/core/star/updator.py
+++ b/astrbot/core/star/updator.py
@@ -26,7 +26,7 @@ class PluginUpdator(RepoZipUpdator):
return plugin_path
- async def update(self, plugin: StarMetadata, proxy="") -> str:
+ async def update_plugin(self, plugin: StarMetadata, proxy="") -> str:
repo_url = plugin.repo
if not repo_url:
diff --git a/astrbot/core/tools/send_message.py b/astrbot/core/tools/send_message.py
index 399932c25..f0eb54898 100644
--- a/astrbot/core/tools/send_message.py
+++ b/astrbot/core/tools/send_message.py
@@ -8,7 +8,7 @@ from __future__ import annotations
import json
import os
import uuid
-from typing import Any, TypedDict, cast
+from typing import Any, TypedDict
import anyio
from pydantic import Field
@@ -39,7 +39,6 @@ class MessageComponent(TypedDict, total=False):
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
name: str = "send_message_to_user"
description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation."
-
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
@@ -51,7 +50,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
"type": "object",
"additionalProperties": {"type": "string"},
},
- },
+ }
},
"required": ["messages"],
}
@@ -67,34 +66,27 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
bool: indicates whether the file was downloaded from sandbox.
"""
if await anyio.Path(path).exists():
- return path, False
-
- # Try to check if the file exists in the sandbox
+ return (path, False)
try:
sb = await get_booter(
- context.context.context,
- context.context.event.unified_msg_origin,
+ context.context.context, context.context.event.unified_msg_origin
)
- # Use shell to check if the file exists in sandbox
import shlex
result = await sb.shell.exec(
f"test -f {shlex.quote(path)} && echo '_&exists_'"
)
if "_&exists_" in json.dumps(result):
- # Download the file from sandbox
name = anyio.Path(path).name
local_path = os.path.join(
get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}"
)
await sb.download_file(path, local_path)
logger.info(f"Downloaded file from sandbox: {path} -> {local_path}")
- return local_path, True
+ return (local_path, True)
except Exception as e:
logger.warning(f"Failed to check/download file from sandbox: {e}")
-
- # Return the original path (will likely fail later, but that's expected)
- return path, False
+ return (path, False)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs: Any
@@ -103,24 +95,17 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
kwargs.get("session") or context.context.event.unified_msg_origin
)
messages: list[dict[str, Any]] | None = kwargs.get("messages")
-
if not isinstance(messages, list) or not messages:
return "error: messages parameter is empty or invalid."
-
components: list[Comp.BaseMessageComponent] = []
-
for idx, msg in enumerate(messages):
if not isinstance(msg, dict):
return f"error: messages[{idx}] should be an object."
-
- msg_dict: dict[str, Any] = cast(dict[str, Any], msg)
-
+ msg_dict: dict[str, Any] = msg
if "type" not in msg_dict:
return f"error: messages[{idx}].type is required."
msg_type = str(msg_dict["type"]).lower()
-
_file_from_sandbox = False
-
try:
if msg_type == "plain":
text = str(msg_dict.get("text", "")).strip()
@@ -189,18 +174,13 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
mention_user_id = msg_dict.get("mention_user_id")
if not mention_user_id:
return f"error: messages[{idx}].mention_user_id is required for mention_user component."
- components.append(
- Comp.At(
- qq=mention_user_id,
- ),
- )
+ components.append(Comp.At(qq=mention_user_id))
else:
return (
f"error: unsupported message type '{msg_type}' at index {idx}."
)
except Exception as exc:
return f"error: failed to build messages[{idx}] component: {exc}"
-
try:
target_session = (
MessageSession.from_str(session)
@@ -209,12 +189,9 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
)
except Exception as e:
return f"error: invalid session: {e}"
-
await context.context.context.send_message(
- target_session,
- MessageChain(chain=components),
+ target_session, MessageChain(chain=components)
)
-
return f"Message sent to session {target_session}"
diff --git a/astrbot/core/umop_config_router.py b/astrbot/core/umop_config_router.py
index 609597cf1..97a9d6eaf 100644
--- a/astrbot/core/umop_config_router.py
+++ b/astrbot/core/umop_config_router.py
@@ -17,13 +17,14 @@ class UmopConfigRouter:
async def _load_routing_table(self) -> None:
"""加载路由表"""
# 从 SharedPreferences 中加载 umop_to_conf_id 映射
- sp_data = await self.sp.get_async(
+ sp_data: dict[str, str] | None = await self.sp.get_async(
key="umop_config_routing",
default={},
scope="global",
scope_id="global",
)
- self.umop_to_conf_id = sp_data
+ if sp_data is not None:
+ self.umop_to_conf_id = sp_data
@staticmethod
def _split_umo(umo: str | int | None) -> tuple[str, str, str] | None:
diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py
index 9fdd08b3a..dba0b1d12 100644
--- a/astrbot/core/updator.py
+++ b/astrbot/core/updator.py
@@ -128,7 +128,7 @@ class AstrBotUpdator(RepoZipUpdator):
logger.error(f"重启失败({executable}, {e}),请尝试手动重启。")
raise e
- async def check_update( # type: ignore[invalid-method-override]
+ async def check_update(
self,
url: str | None,
current_version: str | None,
@@ -144,7 +144,7 @@ class AstrBotUpdator(RepoZipUpdator):
async def get_releases(self) -> list:
return await self.fetch_release_info(self.ASTRBOT_RELEASE_API)
- async def update( # type: ignore[invalid-method-override]
+ async def update( # type: ignore[override]
self, reboot=False, latest=True, version=None, proxy=""
) -> None:
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
diff --git a/astrbot/core/utils/log_pipe.py b/astrbot/core/utils/log_pipe.py
index c17da746a..dac43698a 100644
--- a/astrbot/core/utils/log_pipe.py
+++ b/astrbot/core/utils/log_pipe.py
@@ -4,7 +4,7 @@ import threading
from collections.abc import Callable, Iterable
from logging import Logger
from types import TracebackType
-from typing import Self
+from typing import Any, Self
class LogPipe(threading.Thread, io.TextIOBase):
@@ -68,7 +68,7 @@ class LogPipe(threading.Thread, io.TextIOBase):
def seekable(self) -> bool:
return False
- def writelines(self, lines: Iterable[str]) -> None:
+ def writelines(self, lines: Iterable[Any]) -> None:
for line in lines:
self.write(line)
diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py
index b6c71af23..ff259fcbc 100644
--- a/astrbot/core/utils/pip_installer.py
+++ b/astrbot/core/utils/pip_installer.py
@@ -202,7 +202,7 @@ def _package_specs_override_index(package_specs: list[str]) -> bool:
class _StreamingLogWriter(io.TextIOBase):
def __init__(self, log_func, *, max_lines: int | None = None) -> None:
self._log_func = log_func
- self._lines = deque(maxlen=max_lines or _MAX_PIP_OUTPUT_LINES)
+ self._lines: deque[str] = deque(maxlen=max_lines or _MAX_PIP_OUTPUT_LINES)
self._buffer = ""
def write(self, text: str) -> int:
diff --git a/astrbot/core/utils/quoted_message/image_resolver.py b/astrbot/core/utils/quoted_message/image_resolver.py
index 5a4c21fb2..d216968e4 100644
--- a/astrbot/core/utils/quoted_message/image_resolver.py
+++ b/astrbot/core/utils/quoted_message/image_resolver.py
@@ -43,7 +43,7 @@ def _build_image_resolve_actions(
group_id = event.get_group_id()
except Exception:
group_id = None
- group_id_value = group_id
+ group_id_value: int | str | None = group_id
if isinstance(group_id, str) and group_id.isdigit():
group_id_value = int(group_id)
diff --git a/astrbot/core/utils/t2i/local_strategy.py b/astrbot/core/utils/t2i/local_strategy.py
index 67f746ac2..fd599dbd0 100644
--- a/astrbot/core/utils/t2i/local_strategy.py
+++ b/astrbot/core/utils/t2i/local_strategy.py
@@ -23,10 +23,10 @@ def _get_aiohttp():
class FontManager:
"""字体管理类,负责加载和缓存字体"""
- _font_cache = {}
+ _font_cache: dict[int, ImageFont.FreeTypeFont | ImageFont.ImageFont] = {}
@classmethod
- def get_font(cls, size: int) -> ImageFont.FreeTypeFont|ImageFont.ImageFont:
+ def get_font(cls, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""获取指定大小的字体,优先从缓存获取"""
if size in cls._font_cache:
return cls._font_cache[size]
@@ -86,7 +86,7 @@ class TextMeasurer:
text: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont, max_width: int
) -> list[str]:
"""将文本拆分为多行,确保每行不超过指定宽度"""
- lines = []
+ lines: list[str] = []
if not text:
return lines
@@ -712,7 +712,7 @@ class MarkdownParser:
@staticmethod
async def parse(text: str) -> list[MarkdownElement]:
- elements = []
+ elements: list[MarkdownElement] = []
lines = text.split("\n")
i = 0
diff --git a/astrbot/core/utils/t2i/network_strategy.py b/astrbot/core/utils/t2i/network_strategy.py
index 4c84ba330..5fe3aab90 100644
--- a/astrbot/core/utils/t2i/network_strategy.py
+++ b/astrbot/core/utils/t2i/network_strategy.py
@@ -27,7 +27,7 @@ class NetworkRenderStrategy(RenderStrategy):
self.BASE_RENDER_URL = ASTRBOT_T2I_DEFAULT_ENDPOINT
else:
self.BASE_RENDER_URL = self._clean_url(base_url)
- self.tasks = set()
+ self.tasks: set[asyncio.Task[None]] = set()
self.endpoints = [self.BASE_RENDER_URL]
self.template_manager = TemplateManager()
@@ -58,7 +58,7 @@ class NetworkRenderStrategy(RenderStrategy):
data = await resp.json()
all_endpoints: list[dict] = data.get("data", [])
self.endpoints = [
- ep.get("url")
+ ep["url"]
for ep in all_endpoints
if ep.get("active") and ep.get("url")
]
diff --git a/astrbot/core/utils/tencent_record_helper.py b/astrbot/core/utils/tencent_record_helper.py
index d0dd5c747..236c8ae3d 100644
--- a/astrbot/core/utils/tencent_record_helper.py
+++ b/astrbot/core/utils/tencent_record_helper.py
@@ -32,14 +32,14 @@ async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str:
return output_path
-async def wav_to_tencent_silk(wav_path: str, output_path: str) -> int:
+async def wav_to_tencent_silk(wav_path: str, output_path: str) -> float:
"""返回 duration"""
try:
import pilk
except (ImportError, ModuleNotFoundError) as _:
raise Exception(
"pilk 模块未安装,请前往管理面板->平台日志->安装pip库 安装 pilk 这个库",
- )
+ ) from None
# with wave.open(wav_path, 'rb') as wav:
# wav_data = wav.readframes(wav.getnframes())
# wav_data = BytesIO(wav_data)
diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py
index d26805ecd..663af5c9b 100644
--- a/astrbot/dashboard/routes/chat.py
+++ b/astrbot/dashboard/routes/chat.py
@@ -5,10 +5,9 @@ import re
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
-from typing import cast
+from typing import Any
import anyio
-from quart import Response as QuartResponse
from quart import g, make_response, request, send_file
from astrbot.core import logger, sp
@@ -29,7 +28,6 @@ from astrbot.core.utils.datetime_utils import to_utc_isoformat
from .route import Response, Route, RouteContext
-# SSE heartbeat message to keep the connection alive during long-running operations
SSE_HEARTBEAT = ": heartbeat\n\n"
@@ -46,17 +44,14 @@ async def _poll_webchat_stream_result(back_queue, username: str):
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
- # Return a sentinel so the caller can send an SSE heartbeat to
- # keep the connection alive during long-running operations (e.g.
- # context compression with reasoning models). See #6938.
- return None, False
+ return (None, False)
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
- return None, True
+ return (None, True)
except Exception as e:
logger.error(f"WebChat stream error: {e}")
- return None, False
- return result, False
+ return (None, False)
+ return (result, False)
def _resolve_path(path: str) -> Path:
@@ -94,7 +89,6 @@ class ChatRoute(Route):
self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
os.makedirs(self.attachments_dir, exist_ok=True)
-
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
self.conv_mgr = core_lifecycle.conversation_manager
mgr = core_lifecycle.platform_message_history_manager
@@ -103,40 +97,33 @@ class ChatRoute(Route):
self.db = db
self.umop_config_router = core_lifecycle.umop_config_router
assert self.umop_config_router
-
self.running_convs: dict[str, bool] = {}
async def get_file(self):
filename = request.args.get("filename")
if not filename:
return Response().error("Missing key: filename").to_json()
-
try:
file_path = os.path.join(self.attachments_dir, os.path.basename(filename))
resolved_file_path = _resolve_path(file_path)
resolved_base_dir = _resolve_path(self.attachments_dir)
-
if not await anyio.Path(resolved_file_path).exists():
- # try legacy
file_path = os.path.join(
self.legacy_img_dir, os.path.basename(filename)
)
if await anyio.Path(file_path).exists():
resolved_file_path = _resolve_path(file_path)
resolved_base_dir = _resolve_path(self.legacy_img_dir)
-
try:
resolved_file_path.relative_to(resolved_base_dir)
except ValueError:
return Response().error("Invalid file path").to_json()
-
filename_ext = os.path.splitext(filename)[1].lower()
if filename_ext == ".wav":
return await send_file(str(resolved_file_path), mimetype="audio/wav")
if filename_ext[1:] in self.supported_imgs:
return await send_file(str(resolved_file_path), mimetype="image/jpeg")
return await send_file(str(resolved_file_path))
-
except (FileNotFoundError, OSError):
return Response().error("File access error").to_json()
@@ -145,19 +132,15 @@ class ChatRoute(Route):
attachment_id = request.args.get("attachment_id")
if not attachment_id:
return Response().error("Missing key: attachment_id").to_json()
-
try:
attachment = await self.db.get_attachment_by_id(attachment_id)
if not attachment:
return Response().error("Attachment not found").to_json()
-
file_path = attachment.path
resolved_file_path = _resolve_path(file_path)
-
return await send_file(
str(resolved_file_path), mimetype=attachment.mime_type
)
-
except (FileNotFoundError, OSError):
return Response().error("File access error").to_json()
@@ -166,12 +149,9 @@ class ChatRoute(Route):
post_data = await request.files
if "file" not in post_data:
return Response().error("Missing key: file").to_json()
-
file = post_data["file"]
filename = file.filename or f"{uuid.uuid4()!s}"
content_type = file.content_type or "application/octet-stream"
-
- # 根据 content_type 判断文件类型并添加扩展名
if content_type.startswith("image"):
attach_type = "image"
elif content_type.startswith("audio"):
@@ -180,22 +160,14 @@ class ChatRoute(Route):
attach_type = "video"
else:
attach_type = "file"
-
path = os.path.join(self.attachments_dir, filename)
await file.save(path)
-
- # 创建 attachment 记录
attachment = await self.db.insert_attachment(
- path=path,
- type=attach_type,
- mime_type=content_type,
+ path=path, type=attach_type, mime_type=content_type
)
-
if not attachment:
return Response().error("Failed to create attachment").to_json()
-
filename = os.path.basename(attachment.path)
-
return (
Response()
.ok(
@@ -211,9 +183,7 @@ class ChatRoute(Route):
async def _build_user_message_parts(self, message: str | list) -> list[dict]:
"""构建用户消息的部分列表。"""
return await build_webchat_message_parts(
- message,
- get_attachment_by_id=self.db.get_attachment_by_id,
- strict=False,
+ message, get_attachment_by_id=self.db.get_attachment_by_id, strict=False
)
async def _create_attachment_from_file(
@@ -241,14 +211,12 @@ class ChatRoute(Route):
包含 used 列表的字典,记录被引用的搜索结果
"""
supported = ["web_search_tavily", "web_search_bocha"]
- # 从 accumulated_parts 中找到所有 web_search_tavily 的工具调用结果
web_search_results = {}
tool_call_parts = [
p
for p in accumulated_parts
if p.get("type") == "tool_call" and p.get("tool_calls")
]
-
for part in tool_call_parts:
for tool_call in part["tool_calls"]:
if tool_call.get("name") not in supported or not tool_call.get(
@@ -266,16 +234,11 @@ class ChatRoute(Route):
}
except (json.JSONDecodeError, KeyError):
pass
-
if not web_search_results:
return {}
-
- # 从文本中提取所有 [xxx] 标签并去重
ref_indices = {
- m.strip() for m in re.findall(r"[(.*?)]", accumulated_text)
+ m.strip() for m in re.findall("[(.*?)]", accumulated_text)
}
-
- # 构建被引用的结果列表
used_refs = []
for ref_index in ref_indices:
if ref_index not in web_search_results:
@@ -284,7 +247,6 @@ class ChatRoute(Route):
if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]):
payload["favicon"] = favicon
used_refs.append(payload)
-
return {"used": used_refs} if used_refs else {}
async def _save_bot_message(
@@ -301,15 +263,13 @@ class ChatRoute(Route):
bot_message_parts.extend(media_parts)
if text:
bot_message_parts.append({"type": "plain", "text": text})
-
- new_his = {"type": "bot", "message": bot_message_parts}
+ new_his: dict[str, Any] = {"type": "bot", "message": bot_message_parts}
if reasoning:
new_his["reasoning"] = reasoning
if agent_stats:
new_his["agent_stats"] = agent_stats
if refs:
new_his["refs"] = refs
-
record = await self.platform_history_mgr.insert(
platform_id="webchat",
user_id=webchat_conv_id,
@@ -321,31 +281,24 @@ class ChatRoute(Route):
async def chat(self, post_data: dict | None = None):
username = g.get("username", "guest")
-
if post_data is None:
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").to_json()
if "message" not in post_data and "files" not in post_data:
return Response().error("Missing key: message or files").to_json()
-
if "session_id" not in post_data and "conversation_id" not in post_data:
return (
Response().error("Missing key: session_id or conversation_id").to_json()
)
-
message = post_data["message"]
session_id = post_data.get("session_id", post_data.get("conversation_id"))
selected_provider = post_data.get("selected_provider")
selected_model = post_data.get("selected_model")
enable_streaming = post_data.get("enable_streaming", True)
-
if not session_id:
return Response().error("session_id is empty").to_json()
-
webchat_conv_id = session_id
-
- # 构建用户消息段(包含 path 用于传递给 adapter)
message_parts = await self._build_user_message_parts(message)
if not webchat_message_parts_have_content(message_parts):
return (
@@ -353,11 +306,9 @@ class ChatRoute(Route):
.error("Message content is empty (reply only is not allowed)")
.to_json()
)
-
message_id = str(uuid.uuid4())
back_queue = webchat_queue_mgr.get_or_create_back_queue(
- message_id,
- webchat_conv_id,
+ message_id, webchat_conv_id
)
async def stream():
@@ -369,14 +320,12 @@ class ChatRoute(Route):
agent_stats = {}
refs = {}
try:
- # Emit session_id first so clients can bind the stream immediately.
session_info = {
"type": "session_id",
"data": None,
"session_id": webchat_conv_id,
}
yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n"
-
async with track_conversation(self.running_convs, webchat_conv_id):
while True:
result, should_break = await _poll_webchat_stream_result(
@@ -386,25 +335,19 @@ class ChatRoute(Route):
client_disconnected = True
break
if not result:
- # Send an SSE comment as keep-alive so the client
- # doesn't time out during slow backend ops like
- # context compression with reasoning models (#6938).
if not client_disconnected:
yield SSE_HEARTBEAT
continue
-
if (
"message_id" in result
and result["message_id"] != message_id
):
logger.warning("webchat stream message_id mismatch")
continue
-
result_text = result["data"]
msg_type = result.get("type")
streaming = result.get("streaming", False)
chain_type = result.get("chain_type")
-
if chain_type == "agent_stats":
stats_info = {
"type": "agent_stats",
@@ -413,8 +356,6 @@ class ChatRoute(Route):
yield f"data: {json.dumps(stats_info, ensure_ascii=False)}\n\n"
agent_stats = stats_info["data"]
continue
-
- # 发送 SSE 数据
try:
if not client_disconnected:
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
@@ -424,22 +365,18 @@ class ChatRoute(Route):
f"[WebChat] 用户 {username} 断开聊天长连接。 {e}"
)
client_disconnected = True
-
try:
if not client_disconnected:
await asyncio.sleep(0.05)
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
client_disconnected = True
-
- # 累积消息部分
if msg_type == "plain":
chain_type = result.get("chain_type")
if chain_type == "tool_call":
tool_call = json.loads(result_text)
tool_calls[tool_call.get("id")] = tool_call
if accumulated_text:
- # 如果累积了文本,则先保存文本
accumulated_parts.append(
{"type": "plain", "text": accumulated_text}
)
@@ -478,39 +415,29 @@ class ChatRoute(Route):
if part:
accumulated_parts.append(part)
elif msg_type == "file":
- # 格式: [FILE]filename
filename = result_text.replace("[FILE]", "")
part = await self._create_attachment_from_file(
filename, "file"
)
if part:
accumulated_parts.append(part)
-
- # 消息结束处理
if msg_type == "end":
break
- elif (
- (streaming and msg_type == "complete") or not streaming
- # or msg_type == "break"
- ):
+ elif streaming and msg_type == "complete" or not streaming:
if (
chain_type == "tool_call"
or chain_type == "tool_call_result"
):
continue
-
- # 提取 web_search_tavily 引用
try:
refs = self._extract_web_search_refs(
- accumulated_text,
- accumulated_parts,
+ accumulated_text, accumulated_parts
)
except Exception as e:
logger.exception(
f"Failed to extract web search refs: {e}",
exc_info=True,
)
-
saved_record = await self._save_bot_message(
webchat_conv_id,
accumulated_text,
@@ -519,8 +446,7 @@ class ChatRoute(Route):
agent_stats,
refs,
)
- # 发送保存的消息信息给前端
- if saved_record and not client_disconnected:
+ if saved_record and (not client_disconnected):
saved_info = {
"type": "message_saved",
"data": {
@@ -537,7 +463,6 @@ class ChatRoute(Route):
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
- # tool_calls = {}
agent_stats = {}
refs = {}
except BaseException as e:
@@ -545,7 +470,6 @@ class ChatRoute(Route):
finally:
webchat_queue_mgr.remove_back_queue(message_id)
- # 将消息放入会话特定的队列
chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id)
await chat_queue.put(
(
@@ -558,11 +482,9 @@ class ChatRoute(Route):
"enable_streaming": enable_streaming,
"message_id": message_id,
},
- ),
+ )
)
-
message_parts_for_storage = strip_message_parts_path_fields(message_parts)
-
await self.platform_history_mgr.insert(
platform_id="webchat",
user_id=webchat_conv_id,
@@ -570,20 +492,16 @@ class ChatRoute(Route):
sender_id=username,
sender_name=username,
)
-
- response = cast(
- QuartResponse,
- await make_response(
- stream(),
- {
- "Content-Type": "text/event-stream",
- "Cache-Control": "no-cache",
- "Transfer-Encoding": "chunked",
- "Connection": "keep-alive",
- },
- ),
+ response = await make_response(
+ stream(),
+ {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Transfer-Encoding": "chunked",
+ "Connection": "keep-alive",
+ },
)
- response.timeout = None # fix SSE auto disconnect issue
+ response.timeout = None
return response
async def stop_session(self):
@@ -591,73 +509,58 @@ class ChatRoute(Route):
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").to_json()
-
session_id = post_data.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").to_json()
-
username = g.get("username", "guest")
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").to_json()
if session.creator != username:
return Response().error("Permission denied").to_json()
-
message_type = (
MessageType.GROUP_MESSAGE.value
if session.is_group
else MessageType.FRIEND_MESSAGE.value
)
- umo = (
- f"{session.platform_id}:{message_type}:"
- f"{session.platform_id}!{username}!{session_id}"
- )
+ umo = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
stopped_count = active_event_registry.request_agent_stop_all(umo)
-
return Response().ok(data={"stopped_count": stopped_count}).to_json()
async def _delete_session_internal(self, session, username: str) -> None:
"""Delete a single session and all its related data."""
session_id = session.session_id
-
- # 删除该会话下的所有对话
message_type = "GroupMessage" if session.is_group else "FriendMessage"
unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
- await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
-
- # 获取消息历史中的所有附件 ID 并删除附件
- history_list = await self.platform_history_mgr.get(
+ conv_mgr = self.conv_mgr
+ assert conv_mgr is not None
+ await conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
+ mgr = self.platform_history_mgr
+ assert mgr is not None
+ history_list = await mgr.get(
platform_id=session.platform_id,
user_id=session_id,
page=1,
- page_size=100000, # 获取足够多的记录
+ page_size=100000,
)
attachment_ids = self._extract_attachment_ids(history_list)
if attachment_ids:
await self._delete_attachments(attachment_ids)
-
- # 删除消息历史
- await self.platform_history_mgr.delete(
- platform_id=session.platform_id,
- user_id=session_id,
- offset_sec=99999999,
+ await mgr.delete(
+ platform_id=session.platform_id, user_id=session_id, offset_sec=99999999
)
-
- # 删除与会话关联的配置路由
try:
- await self.umop_config_router.delete_route(unified_msg_origin)
+ router = self.umop_config_router
+ assert router is not None
+ await router.delete_route(unified_msg_origin)
except ValueError as exc:
logger.warning(
"Failed to delete UMO route %s during session cleanup: %s",
unified_msg_origin,
exc,
)
-
- # 清理队列(仅对 webchat)
if session.platform_id == "webchat":
webchat_queue_mgr.remove_queues(session_id)
-
- # 删除会话
await self.db.delete_platform_session(session_id)
async def delete_webchat_session(self):
@@ -666,15 +569,12 @@ class ChatRoute(Route):
if not session_id:
return Response().error("Missing key: session_id").to_json()
username = g.get("username", "guest")
-
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").to_json()
if session.creator != username:
return Response().error("Permission denied").to_json()
-
await self._delete_session_internal(session, username)
-
return Response().ok().to_json()
async def batch_delete_sessions(self):
@@ -684,17 +584,14 @@ class ChatRoute(Route):
return Response().error("Missing JSON body").to_json()
if not isinstance(post_data, dict):
return Response().error("Invalid JSON body: expected object").to_json()
-
session_ids = post_data.get("session_ids")
if not session_ids or not isinstance(session_ids, list):
return Response().error("Missing or invalid key: session_ids").to_json()
-
username = g.get("username", "guest")
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
sessions_by_id = {session.session_id: session for session in sessions}
deleted_count = 0
failed_items = []
-
for sid in session_ids:
session = sessions_by_id.get(sid)
if not session:
@@ -703,7 +600,6 @@ class ChatRoute(Route):
if session.creator != username:
failed_items.append({"session_id": sid, "reason": "permission denied"})
continue
-
try:
await self._delete_session_internal(session, username)
deleted_count += 1
@@ -711,7 +607,6 @@ class ChatRoute(Route):
except Exception:
logger.warning("Failed to delete session %s", sid)
failed_items.append({"session_id": sid, "reason": "internal_error"})
-
return (
Response()
.ok(
@@ -752,8 +647,6 @@ class ChatRoute(Route):
)
except Exception as e:
logger.warning(f"Failed to get attachments: {e}")
-
- # 批量删除数据库记录
try:
await self.db.delete_attachments(attachment_ids)
except Exception as e:
@@ -762,17 +655,10 @@ class ChatRoute(Route):
async def new_session(self):
"""Create a new Platform session (default: webchat)."""
username = g.get("username", "guest")
-
- # 获取可选的 platform_id 参数,默认为 webchat
platform_id = request.args.get("platform_id", "webchat")
-
- # 创建新会话
session = await self.db.create_platform_session(
- creator=username,
- platform_id=platform_id,
- is_group=0,
+ creator=username, platform_id=platform_id, is_group=0
)
-
return (
Response()
.ok(
@@ -787,23 +673,17 @@ class ChatRoute(Route):
async def get_sessions(self):
"""Get all Platform sessions for the current user."""
username = g.get("username", "guest")
-
- # 获取可选的 platform_id 参数
platform_id = request.args.get("platform_id")
-
sessions, _ = await self.db.get_platform_sessions_by_creator_paginated(
creator=username,
platform_id=platform_id,
page=1,
- page_size=100, # 暂时返回前100个
+ page_size=100,
exclude_project_sessions=True,
)
-
- # 转换为字典格式
sessions_data = []
for item in sessions:
session = item["session"]
-
sessions_data.append(
{
"session_id": session.session_id,
@@ -815,7 +695,6 @@ class ChatRoute(Route):
"updated_at": to_utc_isoformat(session.updated_at),
}
)
-
return Response().ok(data=sessions_data).to_json()
async def get_session(self):
@@ -823,67 +702,46 @@ class ChatRoute(Route):
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").to_json()
-
- # 获取会话信息以确定 platform_id
session = await self.db.get_platform_session_by_id(session_id)
platform_id = session.platform_id if session else "webchat"
-
- # 获取项目信息(如果会话属于某个项目)
username = g.get("username", "guest")
project_info = await self.db.get_project_by_session(
session_id=session_id, creator=username
)
-
- # Get platform message history using session_id
- history_ls = await self.platform_history_mgr.get(
- platform_id=platform_id,
- user_id=session_id,
- page=1,
- page_size=1000,
+ mgr = self.platform_history_mgr
+ assert mgr is not None
+ history_ls = await mgr.get(
+ platform_id=platform_id, user_id=session_id, page=1, page_size=1000
)
-
history_res = [history.model_dump() for history in history_ls]
-
- response_data = {
+ response_data: dict[str, Any] = {
"history": history_res,
"is_running": self.running_convs.get(session_id, False),
}
-
- # 如果会话属于项目,添加项目信息
if project_info:
response_data["project"] = {
"project_id": project_info.project_id,
"title": project_info.title,
"emoji": project_info.emoji,
}
-
return Response().ok(data=response_data).to_json()
async def update_session_display_name(self):
"""Update a Platform session's display name."""
post_data = await request.json
-
session_id = post_data.get("session_id")
display_name = post_data.get("display_name")
-
if not session_id:
return Response().error("Missing key: session_id").to_json()
if display_name is None:
return Response().error("Missing key: display_name").to_json()
-
username = g.get("username", "guest")
-
- # 验证会话是否存在且属于当前用户
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").to_json()
if session.creator != username:
return Response().error("Permission denied").to_json()
-
- # 更新 display_name
await self.db.update_platform_session(
- session_id=session_id,
- display_name=display_name,
+ session_id=session_id, display_name=display_name
)
-
return Response().ok().to_json()
diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py
index cd9e9a974..1de11db08 100644
--- a/astrbot/dashboard/routes/config.py
+++ b/astrbot/dashboard/routes/config.py
@@ -24,9 +24,7 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry
from astrbot.core.provider import Provider
from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import StarMetadata, star_registry
-from astrbot.core.utils.astrbot_path import (
- get_astrbot_plugin_data_path,
-)
+from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path
from astrbot.core.utils.llm_metadata import LLM_METADATAS
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
@@ -55,7 +53,8 @@ def try_cast(value: Any, type_: str):
type_ == "float"
and isinstance(value, str)
and value.replace(".", "", 1).isdigit()
- ) or (type_ == "float" and isinstance(value, int)):
+ or (type_ == "float" and isinstance(value, int))
+ ):
return float(value)
elif type_ == "float":
try:
@@ -67,8 +66,7 @@ def try_cast(value: Any, type_: str):
def _expect_type(value, expected_type, path_key, errors, expected_name=None) -> bool:
if not isinstance(value, expected_type):
errors.append(
- f"错误的类型 {path_key}: 期望是 {expected_name or expected_type.__name__}, "
- f"得到了 {type(value).__name__}"
+ f"错误的类型 {path_key}: 期望是 {expected_name or expected_type.__name__}, 得到了 {type(value).__name__}"
)
return False
return True
@@ -77,31 +75,22 @@ def _expect_type(value, expected_type, path_key, errors, expected_name=None) ->
def _validate_template_list(value, meta, path_key, errors, validate_fn) -> None:
if not _expect_type(value, list, path_key, errors, "list"):
return
-
templates = meta.get("templates")
if not isinstance(templates, dict):
templates = {}
-
for idx, item in enumerate(value):
item_path = f"{path_key}[{idx}]"
if not _expect_type(item, dict, item_path, errors, "dict"):
continue
-
template_key = item.get("__template_key") or item.get("template")
if not template_key:
errors.append(f"缺少模板选择 {item_path}: 需要 __template_key")
continue
-
template_meta = templates.get(template_key)
if not template_meta:
errors.append(f"未知模板 {item_path}: {template_key}")
continue
-
- validate_fn(
- item,
- template_meta.get("items", {}),
- path=f"{item_path}.",
- )
+ validate_fn(item, template_meta.get("items", {}), path=f"{item_path}.")
def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]:
@@ -115,87 +104,77 @@ def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]
if "type" not in meta:
logger.debug(f"配置项 {path}{key} 没有类型定义, 跳过校验")
continue
- # null 转换
if value is None:
data[key] = DEFAULT_VALUE_MAP[meta["type"]]
continue
-
if meta["type"] == "template_list":
_validate_template_list(value, meta, f"{path}{key}", errors, validate)
continue
-
if meta["type"] == "file":
if not _expect_type(value, list, f"{path}{key}", errors, "list"):
continue
for idx, item in enumerate(value):
if not isinstance(item, str):
errors.append(
- f"Invalid type {path}{key}[{idx}]: expected string, got {type(item).__name__}",
+ f"Invalid type {path}{key}[{idx}]: expected string, got {type(item).__name__}"
)
continue
normalized = normalize_rel_path(item)
if not normalized or not normalized.startswith("files/"):
- errors.append(
- f"Invalid file path {path}{key}[{idx}]: {item}",
- )
+ errors.append(f"Invalid file path {path}{key}[{idx}]: {item}")
continue
key_path = f"{path}{key}"
expected_folder = config_key_to_folder(key_path)
expected_prefix = f"files/{expected_folder}/"
if not normalized.startswith(expected_prefix):
- errors.append(
- f"Invalid file path {path}{key}[{idx}]: {item}",
- )
+ errors.append(f"Invalid file path {path}{key}[{idx}]: {item}")
continue
value[idx] = normalized
continue
-
- if meta["type"] == "list" and not isinstance(value, list):
+ if meta["type"] == "list" and (not isinstance(value, list)):
errors.append(
- f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
+ f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}"
)
elif (
meta["type"] == "list"
and isinstance(value, list)
and value
- and "items" in meta
+ and ("items" in meta)
and isinstance(value[0], dict)
):
- # 当前仅针对 list[dict] 的情况进行类型校验,以适配 AstrBot 中 platform、provider 的配置
for item in value:
validate(item, meta["items"], path=f"{path}{key}.")
elif meta["type"] == "object" and isinstance(value, dict):
validate(value, meta["items"], path=f"{path}{key}.")
-
- if meta["type"] == "int" and not isinstance(value, int):
+ if meta["type"] == "int" and (not isinstance(value, int)):
casted = try_cast(value, "int")
if casted is None:
errors.append(
- f"错误的类型 {path}{key}: 期望是 int, 得到了 {type(value).__name__}",
+ f"错误的类型 {path}{key}: 期望是 int, 得到了 {type(value).__name__}"
)
data[key] = casted
- elif meta["type"] == "float" and not isinstance(value, float):
+ elif meta["type"] == "float" and (not isinstance(value, float)):
casted = try_cast(value, "float")
if casted is None:
errors.append(
- f"错误的类型 {path}{key}: 期望是 float, 得到了 {type(value).__name__}",
+ f"错误的类型 {path}{key}: 期望是 float, 得到了 {type(value).__name__}"
)
data[key] = casted
- elif meta["type"] == "bool" and not isinstance(value, bool):
+ elif meta["type"] == "bool" and (not isinstance(value, bool)):
errors.append(
- f"错误的类型 {path}{key}: 期望是 bool, 得到了 {type(value).__name__}",
+ f"错误的类型 {path}{key}: 期望是 bool, 得到了 {type(value).__name__}"
)
- elif meta["type"] in ["string", "text"] and not isinstance(value, str):
+ elif meta["type"] in ["string", "text"] and (not isinstance(value, str)):
errors.append(
- f"错误的类型 {path}{key}: 期望是 string, 得到了 {type(value).__name__}",
+ f"错误的类型 {path}{key}: 期望是 string, 得到了 {type(value).__name__}"
)
- elif meta["type"] == "list" and not isinstance(value, list):
+ elif meta["type"] == "list" and (not isinstance(value, list)):
errors.append(
- f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
+ f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}"
)
- elif meta["type"] == "object" and not isinstance(value, dict):
+ elif meta["type"] == "object" and (not isinstance(value, dict)):
errors.append(
- f"错误的类型 {path}{key}: 期望是 dict, 得到了 {type(value).__name__}",
+ f"错误的类型 {path}{key}: 期望是 dict, 得到了 {type(value).__name__}"
)
if is_core:
@@ -207,16 +186,13 @@ def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]
validate(data, meta_all)
else:
validate(data, schema)
-
- return errors, data
+ return (errors, data)
def _log_computer_config_changes(old_config: dict, new_config: dict) -> None:
"""Compare and log Computer/sandbox configuration changes."""
old_ps = old_config.get("provider_settings", {})
new_ps = new_config.get("provider_settings", {})
-
- # Check computer_use_runtime
old_runtime = old_ps.get("computer_use_runtime", "none")
new_runtime = new_ps.get("computer_use_runtime", "none")
if old_runtime != new_runtime:
@@ -225,8 +201,6 @@ def _log_computer_config_changes(old_config: dict, new_config: dict) -> None:
old_runtime,
new_runtime,
)
-
- # Check sandbox sub-keys
old_sandbox = old_ps.get("sandbox", {})
new_sandbox = new_ps.get("sandbox", {})
all_keys = set(old_sandbox.keys()) | set(new_sandbox.keys())
@@ -234,7 +208,6 @@ def _log_computer_config_changes(old_config: dict, new_config: dict) -> None:
old_val = old_sandbox.get(key)
new_val = new_sandbox.get(key)
if old_val != new_val:
- # Mask tokens/secrets in log output
if "token" in key or "secret" in key:
old_display = "***" if old_val else "(empty)"
new_display = "***" if new_val else "(empty)"
@@ -249,9 +222,7 @@ def _log_computer_config_changes(old_config: dict, new_config: dict) -> None:
)
-async def _validate_neo_connectivity(
- post_config: dict,
-) -> str | None:
+async def _validate_neo_connectivity(post_config: dict) -> str | None:
"""Check if Bay is reachable when Shipyard Neo sandbox is configured.
Returns a warning message string if Bay isn't reachable, or None if
@@ -261,46 +232,30 @@ async def _validate_neo_connectivity(
runtime = ps.get("computer_use_runtime", "none")
sandbox = ps.get("sandbox", {})
booter = sandbox.get("booter", "")
-
- # Only check when sandbox mode + shipyard_neo is selected
if runtime != "sandbox" or booter != "shipyard_neo":
return None
-
endpoint = sandbox.get("shipyard_neo_endpoint", "").rstrip("/")
if not endpoint:
return "⚠️ Shipyard Neo endpoint 未设置"
-
access_token = sandbox.get("shipyard_neo_access_token", "")
if not access_token:
- # Try auto-discovery
from astrbot.core.computer.computer_client import _discover_bay_credentials
access_token = _discover_bay_credentials(endpoint)
-
if not access_token:
- return (
- "⚠️ 未找到 Bay API Key。请填写访问令牌,"
- "或确保 Bay 的 credentials.json 可被自动发现。"
- )
-
- # Connectivity check
+ return "⚠️ 未找到 Bay API Key。请填写访问令牌,或确保 Bay 的 credentials.json 可被自动发现。"
import aiohttp
health_url = f"{endpoint}/health"
try:
async with aiohttp.ClientSession() as session:
async with session.get(
- health_url,
- timeout=aiohttp.ClientTimeout(total=5),
+ health_url, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status != 200:
- return (
- f"⚠️ Bay 健康检查失败 (HTTP {resp.status}),"
- f"请确认 Bay 正在运行: {endpoint}"
- )
+ return f"⚠️ Bay 健康检查失败 (HTTP {resp.status}),请确认 Bay 正在运行: {endpoint}"
except Exception:
return f"⚠️ 无法连接 Bay ({endpoint}),请确认 Bay 已启动。"
-
return None
@@ -310,17 +265,12 @@ def save_config(
"""验证并保存配置"""
errors = None
logger.info(f"Saving config, is_core={is_core}")
-
- # Snapshot old Computer config for change detection
if is_core:
_log_computer_config_changes(dict(config), post_config)
-
try:
if is_core:
errors, post_config = validate_config(
- post_config,
- CONFIG_METADATA_2,
- is_core,
+ post_config, CONFIG_METADATA_2, is_core
)
else:
errors, post_config = validate_config(
@@ -329,23 +279,20 @@ def save_config(
except BaseException as e:
logger.error(traceback.format_exc())
logger.warning(f"验证配置时出现异常: {e}")
- raise ValueError(f"验证配置时出现异常: {e}")
+ raise ValueError(f"验证配置时出现异常: {e}") from e
if errors:
raise ValueError(f"格式校验未通过: {errors}")
-
config.save_config(post_config)
class ConfigRoute(Route):
def __init__(
- self,
- context: RouteContext,
- core_lifecycle: AstrBotCoreLifecycle,
+ self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle
) -> None:
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.config: AstrBotConfig = core_lifecycle.astrbot_config
- self._logo_token_cache: dict[str, Any] = {} # 缓存logo token,避免重复注册
+ self._logo_token_cache: dict[str, Any] = {}
self.acm = core_lifecycle.astrbot_config_mgr
self.ucr = core_lifecycle.umop_config_router
self.routes = {
@@ -377,18 +324,9 @@ class ConfigRoute(Route):
"/config/provider/list": ("GET", self.get_provider_config_list),
"/config/provider/model_list": ("GET", self.get_provider_model_list),
"/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim),
- "/config/provider_sources/models": (
- "GET",
- self.get_provider_source_models,
- ),
- "/config/provider_sources/update": (
- "POST",
- self.update_provider_source,
- ),
- "/config/provider_sources/delete": (
- "POST",
- self.delete_provider_source,
- ),
+ "/config/provider_sources/models": ("GET", self.get_provider_source_models),
+ "/config/provider_sources/update": ("POST", self.update_provider_source),
+ "/config/provider_sources/delete": ("POST", self.delete_provider_source),
}
self.register_routes()
@@ -397,11 +335,9 @@ class ConfigRoute(Route):
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").to_json()
-
provider_source_id = post_data.get("id")
if not provider_source_id:
return Response().error("缺少 provider_source_id").to_json()
-
provider_sources = self.config.get("provider_sources", [])
target_idx = next(
(
@@ -411,27 +347,19 @@ class ConfigRoute(Route):
),
-1,
)
-
if target_idx == -1:
return Response().error("未找到对应的 provider source").to_json()
-
- # 删除 provider_source
del provider_sources[target_idx]
-
- # 写回配置
self.config["provider_sources"] = provider_sources
-
- # 删除引用了该 provider_source 的 providers
- await self.core_lifecycle.provider_manager.delete_provider(
- provider_source_id=provider_source_id
- )
-
+ pm = self.core_lifecycle.provider_manager
+ if pm is None:
+ return Response().error("Provider manager not available").to_json()
+ await pm.delete_provider(provider_source_id=provider_source_id)
try:
save_config(self.config, self.config, is_core=True)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).to_json()
-
return Response().ok(message="删除 provider source 成功").to_json()
async def update_provider_source(self):
@@ -439,93 +367,71 @@ class ConfigRoute(Route):
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").to_json()
-
new_source_config = post_data.get("config") or post_data
original_id = post_data.get("original_id")
if not original_id:
return Response().error("缺少 original_id").to_json()
-
if not isinstance(new_source_config, dict):
return Response().error("缺少或错误的配置数据").to_json()
-
- # 确保配置中有 id 字段
if not new_source_config.get("id"):
new_source_config["id"] = original_id
-
provider_sources = self.config.get("provider_sources", [])
-
for ps in provider_sources:
if ps.get("id") == new_source_config["id"] and ps.get("id") != original_id:
return (
Response()
.error(
- f"Provider source ID '{new_source_config['id']}' exists already, please try another ID.",
+ f"Provider source ID '{new_source_config['id']}' exists already, please try another ID."
)
.to_json()
)
-
- # 查找旧的 provider_source,若不存在则追加为新配置
target_idx = next(
(i for i, ps in enumerate(provider_sources) if ps.get("id") == original_id),
-1,
)
-
old_id = original_id
if target_idx == -1:
provider_sources.append(new_source_config)
else:
old_id = provider_sources[target_idx].get("id")
provider_sources[target_idx] = new_source_config
-
- # 更新引用了该 provider_source 的 providers
affected_providers = []
for provider in self.config.get("provider", []):
if provider.get("provider_source_id") == old_id:
provider["provider_source_id"] = new_source_config["id"]
affected_providers.append(provider)
-
- # 写回配置
self.config["provider_sources"] = provider_sources
-
try:
save_config(self.config, self.config, is_core=True)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).to_json()
-
- # 重载受影响的 providers,使新的 source 配置生效
reload_errors = []
prov_mgr = self.core_lifecycle.provider_manager
+ assert prov_mgr is not None
for provider in affected_providers:
try:
await prov_mgr.reload(provider)
except Exception as e:
logger.error(traceback.format_exc())
reload_errors.append(f"{provider.get('id')}: {e}")
-
if reload_errors:
return (
Response()
.error("更新成功,但部分提供商重载失败: " + ", ".join(reload_errors))
.to_json()
)
-
return Response().ok(message="更新 provider source 成功").to_json()
async def get_provider_template(self):
- provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys(
- {
- "provider_group": {
- "metadata": {
- "provider": CONFIG_METADATA_2["provider_group"]["metadata"][
- "provider"
- ]
- }
- }
- }
+ _cfg: Any = CONFIG_METADATA_2
+ _provider_group: Any = _cfg["provider_group"]
+ _provider_meta: Any = _provider_group["metadata"]["provider"]
+ converted: dict[str, Any] = ConfigMetadataI18n.convert_to_i18n_keys(
+ {"provider_group": {"metadata": {"provider": _provider_meta}}}
)
config_schema = {
- "provider": provider_metadata["provider_group"]["metadata"]["provider"]
+ "provider": converted["provider_group"]["metadata"]["provider"]
}
data = {
"config_schema": config_schema,
@@ -536,20 +442,22 @@ class ConfigRoute(Route):
async def get_uc_table(self):
"""获取 UMOP 配置路由表"""
- return Response().ok({"routing": self.ucr.umop_to_conf_id}).to_json()
+ ucr = self.ucr
+ if ucr is None:
+ return Response().error("UMOP config router not available").to_json()
+ return Response().ok({"routing": ucr.umop_to_conf_id}).to_json()
async def update_ucr_all(self):
"""更新 UMOP 配置路由表的全部内容"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").to_json()
-
new_routing = post_data.get("routing", None)
-
if not new_routing or not isinstance(new_routing, dict):
return Response().error("缺少或错误的路由表数据").to_json()
-
try:
+ if self.ucr is None:
+ return Response().error("UMOP config router not available").to_json()
await self.ucr.update_routing_data(new_routing)
return Response().ok(message="更新成功").to_json()
except Exception as e:
@@ -561,15 +469,15 @@ class ConfigRoute(Route):
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").to_json()
-
umo = post_data.get("umo", None)
conf_id = post_data.get("conf_id", None)
-
if not umo or not conf_id:
return Response().error("缺少 UMO 或配置文件 ID").to_json()
-
try:
- await self.ucr.update_route(umo, conf_id)
+ ucr = self.ucr
+ if ucr is None:
+ return Response().error("UMOP config router not available").to_json()
+ await ucr.update_route(umo, conf_id)
return Response().ok(message="更新成功").to_json()
except Exception as e:
logger.error(traceback.format_exc())
@@ -580,16 +488,16 @@ class ConfigRoute(Route):
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").to_json()
-
umo = post_data.get("umo", None)
-
if not umo:
return Response().error("缺少 UMO").to_json()
-
try:
- if umo in self.ucr.umop_to_conf_id:
- del self.ucr.umop_to_conf_id[umo]
- await self.ucr.update_routing_data(self.ucr.umop_to_conf_id)
+ ucr = self.ucr
+ if ucr is None:
+ return Response().error("UMOP config router not available").to_json()
+ if umo in ucr.umop_to_conf_id:
+ del ucr.umop_to_conf_id[umo]
+ await ucr.update_routing_data(ucr.umop_to_conf_id)
return Response().ok(message="删除成功").to_json()
except Exception as e:
logger.error(traceback.format_exc())
@@ -602,6 +510,8 @@ class ConfigRoute(Route):
async def get_abconf_list(self):
"""获取所有 AstrBot 配置文件的列表"""
+ if not self.acm:
+ return Response().error("Config manager not available").to_json()
abconf_list = self.acm.get_conf_list()
return Response().ok({"info_list": abconf_list}).to_json()
@@ -612,9 +522,11 @@ class ConfigRoute(Route):
return Response().error("缺少配置数据").to_json()
name = post_data.get("name", None)
config = post_data.get("config", DEFAULT_CONFIG)
-
try:
- conf_id = self.acm.create_conf(name=name, config=config)
+ acm = self.acm
+ if acm is None:
+ return Response().error("Config manager not available").to_json()
+ conf_id = acm.create_conf(name=name, config=config)
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
return (
Response().ok(message="创建成功", data={"conf_id": conf_id}).to_json()
@@ -627,12 +539,14 @@ class ConfigRoute(Route):
abconf_id = request.args.get("id")
system_config = request.args.get("system_config", "0").lower() == "1"
reload_from_file = request.args.get("reload_from_file", "0").lower() == "1"
- if not abconf_id and not system_config:
+ if not abconf_id and (not system_config):
return Response().error("缺少配置文件 ID").to_json()
-
try:
+ acm = self.acm
+ if acm is None:
+ return Response().error("Config manager not available").to_json()
if system_config:
- abconf = self.acm.confs["default"]
+ abconf = acm.confs["default"]
if reload_from_file:
abconf = AstrBotConfig(
config_path=abconf.config_path,
@@ -645,7 +559,7 @@ class ConfigRoute(Route):
return Response().ok({"config": abconf, "metadata": metadata}).to_json()
if abconf_id is None:
raise ValueError("abconf_id cannot be None")
- abconf = self.acm.confs[abconf_id]
+ abconf = acm.confs[abconf_id]
if reload_from_file:
abconf = AstrBotConfig(
config_path=abconf.config_path,
@@ -662,13 +576,14 @@ class ConfigRoute(Route):
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").to_json()
-
conf_id = post_data.get("id")
if not conf_id:
return Response().error("缺少配置文件 ID").to_json()
-
try:
- success = self.acm.delete_conf(conf_id)
+ acm = self.acm
+ if acm is None:
+ return Response().error("Config manager not available").to_json()
+ success = acm.delete_conf(conf_id)
if success:
self.core_lifecycle.pipeline_scheduler_mapping.pop(conf_id, None)
return Response().ok(message="删除成功").to_json()
@@ -684,14 +599,13 @@ class ConfigRoute(Route):
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").to_json()
-
conf_id = post_data.get("id")
if not conf_id:
return Response().error("缺少配置文件 ID").to_json()
-
name = post_data.get("name")
-
try:
+ if not self.acm:
+ return Response().error("Config manager not available").to_json()
success = self.acm.update_conf_info(conf_id, name=name)
if success:
return Response().ok(message="更新成功").to_json()
@@ -707,45 +621,38 @@ class ConfigRoute(Route):
meta = provider.meta()
provider_name = provider.provider_config.get("id", "Unknown Provider")
provider_capability_type = meta.provider_type
-
status_info = {
"id": getattr(meta, "id", "Unknown ID"),
"model": getattr(meta, "model", "Unknown Model"),
"type": provider_capability_type.value,
"name": provider_name,
- "status": "unavailable", # 默认为不可用
+ "status": "unavailable",
"error": None,
}
logger.debug(
- f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})",
+ f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})"
)
-
try:
await provider.test()
status_info["status"] = "available"
logger.info(
- f"Provider {status_info['name']} (ID: {status_info['id']}) is available.",
+ f"Provider {status_info['name']} (ID: {status_info['id']}) is available."
)
except Exception as e:
error_message = str(e)
status_info["error"] = error_message
logger.warning(
- f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}",
+ f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}"
)
logger.debug(
- f"Traceback for {status_info['name']}:\n{traceback.format_exc()}",
+ f"Traceback for {status_info['name']}:\n{traceback.format_exc()}"
)
-
return status_info
def _error_response(
- self,
- message: str,
- status_code: int = 500,
- log_fn=logger.error,
+ self, message: str, status_code: int = 500, log_fn=logger.error
):
log_fn(message)
- # 记录更详细的traceback信息,但只在是严重错误时
if status_code == 500:
log_fn(traceback.format_exc())
return Response().error(message).to_json()
@@ -755,38 +662,31 @@ class ConfigRoute(Route):
provider_id = request.args.get("id")
if not provider_id:
return self._error_response(
- "Missing provider_id parameter",
- 400,
- logger.warning,
+ "Missing provider_id parameter", 400, logger.warning
)
-
logger.info(f"API call: /config/provider/check_one id={provider_id}")
try:
prov_mgr = self.core_lifecycle.provider_manager
+ if prov_mgr is None:
+ return Response().error("Provider manager not available").to_json()
target = prov_mgr.inst_map.get(provider_id)
-
if not target:
logger.warning(
- f"Provider with id '{provider_id}' not found in provider_manager.",
+ f"Provider with id '{provider_id}' not found in provider_manager."
)
return (
Response()
.error(f"Provider with id '{provider_id}' not found")
.to_json()
)
-
result = await self._test_single_provider(target)
return Response().ok(result).to_json()
-
except Exception as e:
return self._error_response(
- f"Critical error checking provider {provider_id}: {e}",
- 500,
+ f"Critical error checking provider {provider_id}: {e}", 500
)
async def get_configs(self):
- # plugin_name 为空时返回 AstrBot 配置
- # 否则返回指定 plugin_name 的插件配置
plugin_name = request.args.get("plugin_name", None)
if not plugin_name:
return Response().ok(await self._get_astrbot_config()).to_json()
@@ -798,25 +698,24 @@ class ConfigRoute(Route):
return Response().error("缺少参数 provider_type").to_json()
provider_type_ls = provider_type.split(",")
provider_list = []
- ps = self.core_lifecycle.provider_manager.providers_config
+ pm = self.core_lifecycle.provider_manager
+ if pm is None:
+ return Response().error("Provider manager not available").to_json()
+ ps = pm.providers_config
p_source_pt = {
psrc["id"]: psrc.get("provider_type", "chat_completion")
- for psrc in self.core_lifecycle.provider_manager.provider_sources_config
+ for psrc in pm.provider_sources_config
}
for provider in ps:
ps_id = provider.get("provider_source_id", None)
if (
ps_id
and ps_id in p_source_pt
- and p_source_pt[ps_id] in provider_type_ls
+ and (p_source_pt[ps_id] in provider_type_ls)
):
- # chat
- prov = self.core_lifecycle.provider_manager.get_merged_provider_config(
- provider
- )
+ prov = pm.get_merged_provider_config(provider)
provider_list.append(prov)
elif not ps_id and provider.get("provider_type", "") in provider_type_ls:
- # agent runner, embedding, etc
provider_list.append(provider)
return Response().ok(provider_list).to_json()
@@ -825,8 +724,9 @@ class ConfigRoute(Route):
provider_id = request.args.get("provider_id", None)
if not provider_id:
return Response().error("缺少参数 provider_id").to_json()
-
prov_mgr = self.core_lifecycle.provider_manager
+ if prov_mgr is None:
+ return Response().error("Provider manager not available").to_json()
provider = prov_mgr.inst_map.get(provider_id, None)
if not provider:
return Response().error(f"未找到 ID 为 {provider_id} 的提供商").to_json()
@@ -836,17 +736,14 @@ class ConfigRoute(Route):
.error(f"提供商 {provider_id} 类型不支持获取模型列表")
.to_json()
)
-
try:
models = await provider.get_models()
models = models or []
-
metadata_map = {}
for model_id in models:
meta = LLM_METADATAS.get(model_id)
if meta:
metadata_map[model_id] = meta
-
ret = {
"models": models,
"provider_id": provider_id,
@@ -863,23 +760,21 @@ class ConfigRoute(Route):
provider_config = post_data.get("provider_config", None)
if not provider_config:
return Response().error("缺少参数 provider_config").to_json()
-
try:
- # 动态导入 EmbeddingProvider
from astrbot.core.provider.provider import EmbeddingProvider
from astrbot.core.provider.register import provider_cls_map
- # 获取 provider 类型
provider_type = provider_config.get("type", None)
if not provider_type:
return Response().error("provider_config 缺少 type 字段").to_json()
-
- # 首次添加某类提供商时,provider_cls_map 可能尚未注册该适配器
if provider_type not in provider_cls_map:
try:
- self.core_lifecycle.provider_manager.dynamic_import_provider(
- provider_type,
- )
+ prov_mgr = self.core_lifecycle.provider_manager
+ if prov_mgr is None:
+ return (
+ Response().error("Provider manager not available").to_json()
+ )
+ prov_mgr.dynamic_import_provider(provider_type)
except ImportError:
logger.error(traceback.format_exc())
return (
@@ -889,40 +784,27 @@ class ConfigRoute(Route):
)
.to_json()
)
-
- # 获取对应的 provider 类
if provider_type not in provider_cls_map:
return (
Response()
.error(f"未找到适用于 {provider_type} 的提供商适配器")
.to_json()
)
-
provider_metadata = provider_cls_map[provider_type]
cls_type = provider_metadata.cls_type
-
if not cls_type:
return Response().error(f"无法找到 {provider_type} 的类").to_json()
-
- # 实例化 provider
inst = cls_type(provider_config, {})
-
- # 检查是否是 EmbeddingProvider
if not isinstance(inst, EmbeddingProvider):
return Response().error("提供商不是 EmbeddingProvider 类型").to_json()
-
init_fn = getattr(inst, "initialize", None)
if inspect.iscoroutinefunction(init_fn):
await init_fn()
-
- # 通过实际请求验证当前 embedding_dimensions 是否可用
vec = await inst.get_embedding("echo")
dim = len(vec)
-
logger.info(
- f"检测到 {provider_config.get('id', 'unknown')} 的嵌入向量维度为 {dim}",
+ f"检测到 {provider_config.get('id', 'unknown')} 的嵌入向量维度为 {dim}"
)
-
return Response().ok({"embedding_dimensions": dim}).to_json()
except Exception as e:
logger.error(traceback.format_exc())
@@ -936,87 +818,65 @@ class ConfigRoute(Route):
provider_source_id = request.args.get("source_id")
if not provider_source_id:
return Response().error("缺少参数 source_id").to_json()
-
try:
from astrbot.core.provider.register import provider_cls_map
- # 从配置中查找对应的 provider_source
provider_sources = self.config.get("provider_sources", [])
provider_source = None
for ps in provider_sources:
if ps.get("id") == provider_source_id:
provider_source = ps
break
-
if not provider_source:
return (
Response()
.error(f"未找到 ID 为 {provider_source_id} 的 provider_source")
.to_json()
)
-
- # 获取 provider 类型
provider_type = provider_source.get("type", None)
if not provider_type:
return Response().error("provider_source 缺少 type 字段").to_json()
-
try:
- self.core_lifecycle.provider_manager.dynamic_import_provider(
- provider_type
- )
+ prov_mgr = self.core_lifecycle.provider_manager
+ if prov_mgr is None:
+ return Response().error("Provider manager not available").to_json()
+ prov_mgr.dynamic_import_provider(provider_type)
except ImportError as e:
logger.error(traceback.format_exc())
return Response().error(f"动态导入提供商适配器失败: {e!s}").to_json()
-
- # 获取对应的 provider 类
if provider_type not in provider_cls_map:
return (
Response()
.error(f"未找到适用于 {provider_type} 的提供商适配器")
.to_json()
)
-
provider_metadata = provider_cls_map[provider_type]
cls_type = provider_metadata.cls_type
-
if not cls_type:
return Response().error(f"无法找到 {provider_type} 的类").to_json()
-
- # 检查是否是 Provider 类型
if not issubclass(cls_type, Provider):
return (
Response()
.error(f"提供商 {provider_type} 不支持获取模型列表")
.to_json()
)
-
- # 临时实例化 provider
inst = cls_type(provider_source, {})
-
- # 如果有 initialize 方法,调用它
init_fn = getattr(inst, "initialize", None)
if inspect.iscoroutinefunction(init_fn):
await init_fn()
-
- # 获取模型列表
models = await inst.get_models()
models = models or []
-
metadata_map = {}
for model_id in models:
meta = LLM_METADATAS.get(model_id)
if meta:
metadata_map[model_id] = meta
-
- # 销毁实例(如果有 terminate 方法)
terminate_fn = getattr(inst, "terminate", None)
if inspect.iscoroutinefunction(terminate_fn):
await terminate_fn()
-
logger.info(
- f"获取到 provider_source {provider_source_id} 的模型列表: {models}",
+ f"获取到 provider_source {provider_source_id} 的模型列表: {models}"
)
-
return (
Response()
.ok({"models": models, "model_metadata": metadata_map})
@@ -1037,19 +897,16 @@ class ConfigRoute(Route):
data = await request.json
config = data.get("config", None)
conf_id = data.get("conf_id", None)
-
try:
- # 不更新 provider_sources, provider, platform
- # 这些配置有单独的接口进行更新
if conf_id == "default":
+ acm = self.acm
+ if acm is None:
+ return Response().error("Config manager not available").to_json()
no_update_keys = ["provider_sources", "provider", "platform"]
for key in no_update_keys:
- config[key] = self.acm.default_conf[key]
-
+ config[key] = acm.default_conf[key]
await self._save_astrbot_configs(config, conf_id)
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
-
- # Non-blocking Bay connectivity check
warning = await _validate_neo_connectivity(config)
if warning:
return Response().ok(None, f"保存成功。{warning}").to_json()
@@ -1063,7 +920,10 @@ class ConfigRoute(Route):
plugin_name = request.args.get("plugin_name", "unknown")
try:
await self._save_plugin_configs(post_configs, plugin_name)
- await self.core_lifecycle.plugin_manager.reload(plugin_name)
+ pm = self.core_lifecycle.plugin_manager
+ if pm is None:
+ return Response().error("Plugin manager not available").to_json()
+ await pm.reload(plugin_name)
return (
Response()
.ok(None, f"保存插件 {plugin_name} 成功~ 机器人正在热重载插件。")
@@ -1086,45 +946,36 @@ class ConfigRoute(Route):
当前支持的 scope:
- scope=plugin:name=,key=
"""
-
scope = request.args.get("scope") or "plugin"
name = request.args.get("name")
key_path = request.args.get("key")
-
if scope != "plugin":
raise ValueError(f"Unsupported scope: {scope}")
if not name or not key_path:
raise ValueError("Missing name or key parameter")
-
md = self._get_plugin_metadata_by_name(name)
if not md or not md.config:
raise ValueError(f"Plugin {name} not found or has no config")
-
- return scope, name, key_path, md, md.config
+ return (scope, name, key_path, md, md.config)
async def upload_config_file(self):
"""上传文件到插件数据目录(用于某个 file 类型配置项)。"""
-
try:
_scope, name, key_path, _md, config = self._resolve_config_file_scope()
except ValueError as e:
return Response().error(str(e)).to_json()
-
meta = get_schema_item(getattr(config, "schema", None), key_path)
if not meta or meta.get("type") != "file":
return Response().error("Config item not found or not file type").to_json()
-
file_types = meta.get("file_types")
allowed_exts: list[str] = []
if isinstance(file_types, list):
allowed_exts = [
str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip()
]
-
files = await request.files
if not files:
return Response().error("No files uploaded").to_json()
-
storage_root_path = _resolve_path(Path(get_astrbot_plugin_data_path()))
plugin_root_path = _resolve_path(storage_root_path / name)
try:
@@ -1132,7 +983,6 @@ class ConfigRoute(Route):
except ValueError:
return Response().error("Invalid name parameter").to_json()
plugin_root_path.mkdir(parents=True, exist_ok=True)
-
uploaded: list[str] = []
folder = config_key_to_folder(key_path)
errors: list[str] = []
@@ -1141,17 +991,14 @@ class ConfigRoute(Route):
if not filename:
errors.append("Invalid filename")
continue
-
file_size = getattr(file, "content_length", None)
if isinstance(file_size, int) and file_size > MAX_FILE_BYTES:
errors.append(f"File too large: {filename}")
continue
-
ext = os.path.splitext(filename)[1].lstrip(".").lower()
if allowed_exts and ext not in allowed_exts:
errors.append(f"Unsupported file type: {filename}")
continue
-
rel_path = f"files/{folder}/{filename}"
save_path = _resolve_path(plugin_root_path / rel_path)
try:
@@ -1159,7 +1006,6 @@ class ConfigRoute(Route):
except ValueError:
errors.append(f"Invalid path: {filename}")
continue
-
save_path.parent.mkdir(parents=True, exist_ok=True)
await file.save(str(save_path))
if save_path.is_file() and save_path.stat().st_size > MAX_FILE_BYTES:
@@ -1167,40 +1013,32 @@ class ConfigRoute(Route):
errors.append(f"File too large: {filename}")
continue
uploaded.append(rel_path)
-
if not uploaded:
return (
Response()
.error(
- "Upload failed: " + ", ".join(errors)
- if errors
- else "Upload failed",
+ "Upload failed: " + ", ".join(errors) if errors else "Upload failed"
)
.to_json()
)
-
return Response().ok({"uploaded": uploaded, "errors": errors}).to_json()
async def delete_config_file(self):
"""删除插件数据目录中的文件。"""
-
scope = request.args.get("scope") or "plugin"
name = request.args.get("name")
if not name:
return Response().error("Missing name parameter").to_json()
if scope != "plugin":
return Response().error(f"Unsupported scope: {scope}").to_json()
-
data = await request.get_json()
rel_path = data.get("path") if isinstance(data, dict) else None
rel_path = normalize_rel_path(rel_path)
if not rel_path or not rel_path.startswith("files/"):
return Response().error("Invalid path parameter").to_json()
-
md = self._get_plugin_metadata_by_name(name)
if not md:
return Response().error(f"Plugin {name} not found").to_json()
-
storage_root_path = _resolve_path(Path(get_astrbot_plugin_data_path()))
plugin_root_path = _resolve_path(storage_root_path / name)
try:
@@ -1214,38 +1052,31 @@ class ConfigRoute(Route):
return Response().error("Invalid path parameter").to_json()
if target_path.is_file():
target_path.unlink()
-
return Response().ok(None, "Deleted").to_json()
async def get_config_file_list(self):
"""获取配置项对应目录下的文件列表。"""
-
try:
_, name, key_path, _, config = self._resolve_config_file_scope()
except ValueError as e:
return Response().error(str(e)).to_json()
-
meta = get_schema_item(getattr(config, "schema", None), key_path)
if not meta or meta.get("type") != "file":
return Response().error("Config item not found or not file type").to_json()
-
storage_root_path = _resolve_path(Path(get_astrbot_plugin_data_path()))
plugin_root_path = _resolve_path(storage_root_path / name)
try:
plugin_root_path.relative_to(storage_root_path)
except ValueError:
return Response().error("Invalid name parameter").to_json()
-
folder = config_key_to_folder(key_path)
target_dir = _resolve_path(plugin_root_path / "files" / folder)
try:
target_dir.relative_to(plugin_root_path)
except ValueError:
return Response().error("Invalid path parameter").to_json()
-
if not target_dir.exists() or not target_dir.is_dir():
return Response().ok({"files": []}).to_json()
-
files: list[str] = []
for path in target_dir.rglob("*"):
if not path.is_file():
@@ -1256,32 +1087,29 @@ class ConfigRoute(Route):
continue
if rel_path.startswith("files/"):
files.append(rel_path)
-
return Response().ok({"files": files}).to_json()
async def post_new_platform(self):
new_platform_config = await request.json
-
- # 如果是支持统一 webhook 模式的平台,生成 webhook_uuid
ensure_platform_webhook_config(new_platform_config)
-
self.config["platform"].append(new_platform_config)
try:
save_config(self.config, self.config, is_core=True)
- await self.core_lifecycle.platform_manager.load_platform(
- new_platform_config,
- )
+ pm = self.core_lifecycle.platform_manager
+ if pm is None:
+ return Response().error("Platform manager not available").to_json()
+ await pm.load_platform(new_platform_config)
except Exception as e:
return Response().error(str(e)).to_json()
return Response().ok(None, "新增平台配置成功~").to_json()
async def post_new_provider(self):
new_provider_config = await request.json
-
try:
- await self.core_lifecycle.provider_manager.create_provider(
- new_provider_config
- )
+ pm = self.core_lifecycle.provider_manager
+ if pm is None:
+ return Response().error("Provider manager not available").to_json()
+ await pm.create_provider(new_provider_config)
except Exception as e:
return Response().error(str(e)).to_json()
return Response().ok(None, "新增服务提供商配置成功").to_json()
@@ -1292,23 +1120,21 @@ class ConfigRoute(Route):
new_config = update_platform_config.get("config", None)
if not origin_platform_id or not new_config:
return Response().error("参数错误").to_json()
-
if origin_platform_id != new_config.get("id", None):
return Response().error("机器人名称不允许修改").to_json()
-
- # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
ensure_platform_webhook_config(new_config)
-
for i, platform in enumerate(self.config["platform"]):
if platform["id"] == origin_platform_id:
self.config["platform"][i] = new_config
break
else:
return Response().error("未找到对应平台").to_json()
-
try:
save_config(self.config, self.config, is_core=True)
- await self.core_lifecycle.platform_manager.reload(new_config)
+ pm = self.core_lifecycle.platform_manager
+ if pm is None:
+ return Response().error("Platform manager not available").to_json()
+ await pm.reload(new_config)
except Exception as e:
return Response().error(str(e)).to_json()
return Response().ok(None, "更新平台配置成功~").to_json()
@@ -1319,11 +1145,11 @@ class ConfigRoute(Route):
new_config = update_provider_config.get("config", None)
if not origin_provider_id or not new_config:
return Response().error("参数错误").to_json()
-
try:
- await self.core_lifecycle.provider_manager.update_provider(
- origin_provider_id, new_config
- )
+ provider_mgr = self.core_lifecycle.provider_manager
+ if provider_mgr is None:
+ return Response().error("Provider manager not available").to_json()
+ await provider_mgr.update_provider(origin_provider_id, new_config)
except Exception as e:
return Response().error(str(e)).to_json()
return Response().ok(None, "更新成功,已经实时生效~").to_json()
@@ -1339,7 +1165,10 @@ class ConfigRoute(Route):
return Response().error("未找到对应平台").to_json()
try:
save_config(self.config, self.config, is_core=True)
- await self.core_lifecycle.platform_manager.terminate_platform(platform_id)
+ pm = self.core_lifecycle.platform_manager
+ if pm is None:
+ return Response().error("Platform manager not available").to_json()
+ await pm.terminate_platform(platform_id)
except Exception as e:
return Response().error(str(e)).to_json()
return Response().ok(None, "删除平台配置成功~").to_json()
@@ -1349,18 +1178,21 @@ class ConfigRoute(Route):
provider_id = provider_id.get("id", "")
if not provider_id:
return Response().error("缺少参数 id").to_json()
-
try:
- await self.core_lifecycle.provider_manager.delete_provider(
- provider_id=provider_id
- )
+ pm = self.core_lifecycle.provider_manager
+ if pm is None:
+ return Response().error("Provider manager not available").to_json()
+ await pm.delete_provider(provider_id=provider_id)
except Exception as e:
return Response().error(str(e)).to_json()
return Response().ok(None, "删除成功,已经实时生效。").to_json()
async def get_llm_tools(self):
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
- tool_mgr = self.core_lifecycle.provider_manager.llm_tools
+ prov_mgr = self.core_lifecycle.provider_manager
+ if prov_mgr is None:
+ return Response().error("Provider manager not available").to_json()
+ tool_mgr = prov_mgr.llm_tools
tools = tool_mgr.get_func_desc_openai_style()
return Response().ok(tools).to_json()
@@ -1368,13 +1200,10 @@ class ConfigRoute(Route):
"""注册平台logo文件并生成访问令牌"""
if not platform.logo_path:
return
-
try:
- # 检查缓存
cache_key = f"{platform.name}:{platform.logo_path}"
if cache_key in self._logo_token_cache:
cached_token = self._logo_token_cache[cache_key]
- # 确保platform_default_tmpl[platform.name]存在且为字典
if platform.name not in platform_default_tmpl or not isinstance(
platform_default_tmpl[platform.name], dict
):
@@ -1382,53 +1211,37 @@ class ConfigRoute(Route):
platform_default_tmpl[platform.name]["logo_token"] = cached_token
logger.debug(f"Using cached logo token for platform {platform.name}")
return
-
- # 获取平台适配器类
platform_cls = platform_cls_map.get(platform.name)
if not platform_cls:
logger.warning(f"Platform class not found for {platform.name}")
return
-
- # 获取插件目录路径
module_file = inspect.getfile(platform_cls)
plugin_dir = os.path.dirname(module_file)
-
- # 解析logo文件路径
logo_file_path = os.path.join(plugin_dir, platform.logo_path)
-
- # 检查文件是否存在并注册令牌
if await anyio.Path(logo_file_path).exists():
logo_token = await file_token_service.register_file(
- logo_file_path,
- expire_seconds=3600,
+ logo_file_path, expire_seconds=3600
)
-
- # 确保platform_default_tmpl[platform.name]存在且为字典
if platform.name not in platform_default_tmpl or not isinstance(
platform_default_tmpl[platform.name], dict
):
platform_default_tmpl[platform.name] = {}
-
platform_default_tmpl[platform.name]["logo_token"] = logo_token
-
- # 缓存token
self._logo_token_cache[cache_key] = logo_token
-
logger.debug(f"Logo token registered for platform {platform.name}")
else:
logger.warning(
- f"Platform {platform.name} logo file not found: {logo_file_path}",
+ f"Platform {platform.name} logo file not found: {logo_file_path}"
)
-
except (ImportError, AttributeError) as e:
logger.warning(
- f"Failed to import required modules for platform {platform.name}: {e}",
+ f"Failed to import required modules for platform {platform.name}: {e}"
)
except OSError as e:
logger.warning(f"File system error for platform {platform.name} logo: {e}")
except Exception as e:
logger.warning(
- f"Unexpected error registering logo for platform {platform.name}: {e}",
+ f"Unexpected error registering logo for platform {platform.name}: {e}"
)
def _inject_platform_metadata_with_i18n(
@@ -1437,20 +1250,16 @@ class ConfigRoute(Route):
"""将配置元数据注入到 metadata 中并处理国际化键转换。"""
metadata["platform_group"]["metadata"]["platform"].setdefault("items", {})
platform_items_to_inject = copy.deepcopy(platform.config_metadata)
-
if platform.i18n_resources:
i18n_prefix = f"platform_group.platform.{platform.name}"
-
for lang, lang_data in platform.i18n_resources.items():
platform_i18n_translations.setdefault(lang, {}).setdefault(
"platform_group", {}
).setdefault("platform", {})[platform.name] = lang_data
-
for field_key, field_value in platform_items_to_inject.items():
for key in ("description", "hint", "labels"):
if key in field_value:
field_value[key] = f"{i18n_prefix}.{field_key}.{key}"
-
metadata["platform_group"]["metadata"]["platform"]["items"].update(
platform_items_to_inject
)
@@ -1458,59 +1267,44 @@ class ConfigRoute(Route):
async def _get_astrbot_config(self):
config = self.config
metadata = copy.deepcopy(CONFIG_METADATA_2)
+ _pg: Any = metadata["platform_group"]
+ _pg_meta: Any = _pg["metadata"]
+ _platform_meta: Any = _pg_meta["platform"]
platform_i18n = ConfigMetadataI18n.convert_to_i18n_keys(
- {
- "platform_group": {
- "metadata": {
- "platform": metadata["platform_group"]["metadata"]["platform"]
- }
- }
- }
+ {"platform_group": {"metadata": {"platform": _platform_meta}}}
)
- metadata["platform_group"]["metadata"]["platform"] = platform_i18n[
- "platform_group"
- ]["metadata"]["platform"]
-
- # 平台适配器的默认配置模板注入
- platform_default_tmpl = metadata["platform_group"]["metadata"]["platform"][
- "config_template"
+ _target: Any = _pg_meta
+ _platform_i18n_dict: Any = platform_i18n
+ _target["platform"] = _platform_i18n_dict["platform_group"]["metadata"][
+ "platform"
]
-
- # 收集平台的 i18n 翻译数据
+ _pg2: Any = metadata["platform_group"]
+ _pg_meta2: Any = _pg2["metadata"]
+ _platform_tmpl: Any = _pg_meta2["platform"]
+ platform_default_tmpl = _platform_tmpl["config_template"]
platform_i18n_translations = {}
-
- # 收集需要注册logo的平台
logo_registration_tasks = []
for platform in platform_registry:
if platform.default_config_tmpl:
platform_default_tmpl[platform.name] = copy.deepcopy(
platform.default_config_tmpl
)
-
- # 注入配置元数据(在 convert_to_i18n_keys 之后,使用国际化键)
if platform.config_metadata:
self._inject_platform_metadata_with_i18n(
platform, metadata, platform_i18n_translations
)
-
- # 收集logo注册任务
if platform.logo_path:
logo_registration_tasks.append(
- self._register_platform_logo(platform, platform_default_tmpl),
+ self._register_platform_logo(platform, platform_default_tmpl)
)
-
- # 并行执行logo注册
if logo_registration_tasks:
await asyncio.gather(*logo_registration_tasks, return_exceptions=True)
-
- # 服务提供商的默认配置模板注入
- provider_default_tmpl = metadata["provider_group"]["metadata"]["provider"][
- "config_template"
- ]
+ _provider_tmpl: Any = metadata["provider_group"]
+ _provider_tmpl2: Any = _provider_tmpl["metadata"]["provider"]
+ provider_default_tmpl = _provider_tmpl2["config_template"]
for provider in provider_registry:
if provider.default_config_tmpl:
provider_default_tmpl[provider.type] = provider.default_config_tmpl
-
return {
"metadata": metadata,
"config": config,
@@ -1519,37 +1313,32 @@ class ConfigRoute(Route):
async def _get_plugin_config(self, plugin_name: str):
ret: dict = {"metadata": None, "config": None}
-
for plugin_md in star_registry:
if plugin_md.name == plugin_name:
if not plugin_md.config:
break
- ret["config"] = plugin_md.config # 这是自定义的 Dict 类(AstrBotConfig)
+ ret["config"] = plugin_md.config
ret["metadata"] = {
plugin_name: {
"description": f"{plugin_name} 配置",
"type": "object",
- "items": plugin_md.config.schema, # 初始化时通过 __setattr__ 存入了 schema
- },
+ "items": plugin_md.config.schema,
+ }
}
break
-
return ret
async def _save_astrbot_configs(
self, post_configs: dict, conf_id: str | None = None
) -> None:
try:
- if conf_id not in self.acm.confs:
+ if not self.acm or conf_id not in self.acm.confs:
raise ValueError(f"配置文件 {conf_id} 不存在")
astrbot_config = self.acm.confs[conf_id]
-
- # 保留服务端的 t2i_active_template 值
if "t2i_active_template" in astrbot_config:
post_configs["t2i_active_template"] = astrbot_config[
"t2i_active_template"
]
-
save_config(post_configs, astrbot_config, is_core=True)
except Exception as e:
raise e
@@ -1559,13 +1348,11 @@ class ConfigRoute(Route):
for plugin_md in star_registry:
if plugin_md.name == plugin_name:
md = plugin_md
-
if not md:
raise ValueError(f"插件 {plugin_name} 不存在")
if not md.config:
raise ValueError(f"插件 {plugin_name} 没有注册配置")
assert md.config is not None
-
try:
errors, post_configs = validate_config(
post_configs, getattr(md.config, "schema", {}), is_core=False
diff --git a/astrbot/dashboard/routes/conversation.py b/astrbot/dashboard/routes/conversation.py
index 74c8ac5dc..f8849eabe 100644
--- a/astrbot/dashboard/routes/conversation.py
+++ b/astrbot/dashboard/routes/conversation.py
@@ -66,6 +66,9 @@ class ConversationRoute(Route):
page_size = 20
page_size = min(page_size, 100)
+ if not self.conv_mgr:
+ return Response().error("Conversation manager not available").to_json()
+
try:
(
conversations,
@@ -114,6 +117,8 @@ class ConversationRoute(Route):
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").to_json()
+ if not self.conv_mgr:
+ return Response().error("Conversation manager not available").to_json()
conversation = await self.conv_mgr.get_conversation(
unified_msg_origin=user_id,
conversation_id=cid,
@@ -151,6 +156,8 @@ class ConversationRoute(Route):
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").to_json()
+ if not self.conv_mgr:
+ return Response().error("Conversation manager not available").to_json()
conversation = await self.conv_mgr.get_conversation(
unified_msg_origin=user_id,
conversation_id=cid,
@@ -203,7 +210,13 @@ class ConversationRoute(Route):
continue
try:
- await self.core_lifecycle.conversation_manager.delete_conversation(
+ conv_mgr = self.core_lifecycle.conversation_manager
+ if conv_mgr is None:
+ failed_items.append(
+ f"user_id:{user_id}, cid:{cid} - conversation manager not available"
+ )
+ continue
+ await conv_mgr.delete_conversation(
unified_msg_origin=user_id,
conversation_id=cid,
)
@@ -234,7 +247,10 @@ class ConversationRoute(Route):
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").to_json()
- await self.core_lifecycle.conversation_manager.delete_conversation(
+ conv_mgr = self.core_lifecycle.conversation_manager
+ if conv_mgr is None:
+ return Response().error("Conversation manager not available").to_json()
+ await conv_mgr.delete_conversation(
unified_msg_origin=user_id,
conversation_id=cid,
)
@@ -258,6 +274,9 @@ class ConversationRoute(Route):
if history is None:
return Response().error("缺少必要参数: history").to_json()
+ if not self.conv_mgr:
+ return Response().error("Conversation manager not available").to_json()
+
# 历史记录必须是合法的 JSON 字符串
try:
if isinstance(history, list):
@@ -300,6 +319,9 @@ class ConversationRoute(Route):
if not conversations_to_export:
return Response().error("导出列表不能为空").to_json()
+ if not self.conv_mgr:
+ return Response().error("Conversation manager not available").to_json()
+
# 收集所有对话的内容
jsonl_lines = []
exported_count = 0
diff --git a/astrbot/dashboard/routes/live_chat.py b/astrbot/dashboard/routes/live_chat.py
index f59f5f8fa..1d5fe049e 100644
--- a/astrbot/dashboard/routes/live_chat.py
+++ b/astrbot/dashboard/routes/live_chat.py
@@ -319,8 +319,8 @@ class LiveChatRoute(Route):
text: str,
media_parts: list,
reasoning: str,
- agent_stats: dict,
- refs: dict,
+ agent_stats: dict[str, Any],
+ refs: dict[str, Any],
):
"""保存 bot 消息到历史记录。"""
bot_message_parts = []
@@ -328,7 +328,7 @@ class LiveChatRoute(Route):
if text:
bot_message_parts.append({"type": "plain", "text": text})
- new_his = {"type": "bot", "message": bot_message_parts}
+ new_his: dict[str, Any] = {"type": "bot", "message": bot_message_parts}
if reasoning:
new_his["reasoning"] = reasoning
if agent_stats:
@@ -336,7 +336,9 @@ class LiveChatRoute(Route):
if refs:
new_his["refs"] = refs
- return await self.platform_history_mgr.insert(
+ mgr = self.platform_history_mgr
+ assert mgr is not None
+ return await mgr.insert(
platform_id="webchat",
user_id=webchat_conv_id,
content=new_his,
@@ -544,7 +546,9 @@ class LiveChatRoute(Route):
)
message_parts_for_storage = strip_message_parts_path_fields(message_parts)
- await self.platform_history_mgr.insert(
+ mgr = self.platform_history_mgr
+ assert mgr is not None
+ await mgr.insert(
platform_id="webchat",
user_id=session_id,
content={"type": "user", "message": message_parts_for_storage},
@@ -801,7 +805,14 @@ class LiveChatRoute(Route):
session.should_interrupt = False
# 1. STT - 语音转文字
- ctx = self.plugin_manager.context
+ pm = self.plugin_manager
+ if pm is None or pm.context is None:
+ logger.error("[Live Chat] Plugin manager not available")
+ await websocket.send_json(
+ {"t": "error", "data": "Plugin manager not available"}
+ )
+ return
+ ctx = pm.context
stt_provider = ctx.provider_manager.stt_provider_insts[0]
if not stt_provider:
diff --git a/astrbot/dashboard/routes/log.py b/astrbot/dashboard/routes/log.py
index 79f612af8..1dfa48cb3 100644
--- a/astrbot/dashboard/routes/log.py
+++ b/astrbot/dashboard/routes/log.py
@@ -2,7 +2,6 @@ import asyncio
import json
import time
from collections.abc import AsyncGenerator
-from typing import cast
from quart import Response as QuartResponse
from quart import make_response, request
@@ -14,10 +13,7 @@ from .route import Response, Route, RouteContext
def _format_log_sse(log: dict, ts: float) -> str:
"""辅助函数:格式化 SSE 消息"""
- payload = {
- "type": "log",
- **log,
- }
+ payload = {"type": "log", **log}
return f"id: {ts}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
@@ -38,14 +34,10 @@ class LogRoute(Route):
self.log_broker = log_broker
self.app.add_url_rule("/api/live-log", view_func=self.log, methods=["GET"])
self.app.add_url_rule(
- "/api/log-history",
- view_func=self.log_history,
- methods=["GET"],
+ "/api/log-history", view_func=self.log_history, methods=["GET"]
)
self.app.add_url_rule(
- "/api/trace/settings",
- view_func=self.get_trace_settings,
- methods=["GET"],
+ "/api/trace/settings", view_func=self.get_trace_settings, methods=["GET"]
)
self.app.add_url_rule(
"/api/trace/settings",
@@ -60,15 +52,12 @@ class LogRoute(Route):
try:
last_ts = float(last_event_id)
cached_logs = list(self.log_broker.log_cache)
-
for log_item in cached_logs:
log_ts = _coerce_log_timestamp(log_item.get("time"))
if log_ts is None:
continue
-
if log_ts > last_ts:
yield _format_log_sse(log_item, log_ts)
-
except ValueError:
pass
except Exception as e:
@@ -83,7 +72,6 @@ class LogRoute(Route):
if last_event_id:
async for event in self._replay_cached_logs(last_event_id):
yield event
-
queue = self.log_broker.register()
while True:
message = await queue.get()
@@ -91,7 +79,6 @@ class LogRoute(Route):
if current_ts is None:
current_ts = time.time()
yield _format_log_sse(message, current_ts)
-
except asyncio.CancelledError:
pass
except Exception as e:
@@ -100,17 +87,14 @@ class LogRoute(Route):
if queue:
self.log_broker.unregister(queue)
- response = cast(
- QuartResponse,
- await make_response(
- stream(),
- {
- "Content-Type": "text/event-stream",
- "Cache-Control": "no-cache",
- "Connection": "keep-alive",
- "Transfer-Encoding": "chunked",
- },
- ),
+ response = await make_response(
+ stream(),
+ {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "Transfer-Encoding": "chunked",
+ },
)
response.timeout = None
return response
@@ -119,15 +103,7 @@ class LogRoute(Route):
"""获取日志历史"""
try:
logs = list(self.log_broker.log_cache)
- return (
- Response()
- .ok(
- data={
- "logs": logs,
- },
- )
- .to_json()
- )
+ return Response().ok(data={"logs": logs}).to_json()
except Exception as e:
logger.error(f"获取日志历史失败: {e}")
return Response().error(f"获取日志历史失败: {e}").to_json()
@@ -147,12 +123,10 @@ class LogRoute(Route):
data = await request.json
if data is None:
return Response().error("请求数据为空").to_json()
-
trace_enable = data.get("trace_enable")
if trace_enable is not None:
self.config["trace_enable"] = bool(trace_enable)
self.config.save_config()
-
return Response().ok(message="Trace 设置已更新").to_json()
except Exception as e:
logger.error(f"更新 Trace 设置失败: {e}")
diff --git a/astrbot/dashboard/routes/open_api.py b/astrbot/dashboard/routes/open_api.py
index 8cd2d61de..310529f42 100644
--- a/astrbot/dashboard/routes/open_api.py
+++ b/astrbot/dashboard/routes/open_api.py
@@ -68,7 +68,10 @@ class OpenApiRoute(Route):
return username, None
def _get_chat_config_list(self) -> list[dict]:
- conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list()
+ mgr = self.core_lifecycle.astrbot_config_mgr
+ if mgr is None:
+ return []
+ conf_list = mgr.get_conf_list()
result = []
for conf_info in conf_list:
@@ -174,13 +177,16 @@ class OpenApiRoute(Route):
g.username = effective_username
if config_id:
umo = f"webchat:FriendMessage:webchat!{effective_username}!{session_id}"
+ router = self.core_lifecycle.umop_config_router
try:
- if config_id == "default":
- await self.core_lifecycle.umop_config_router.delete_route(umo)
- else:
- await self.core_lifecycle.umop_config_router.update_route(
- umo, config_id
+ if router is None:
+ return (
+ Response().error("UMOP config router not available").to_json()
)
+ if config_id == "default":
+ await router.delete_route(umo)
+ else:
+ await router.update_route(umo, config_id)
except Exception as e:
logger.error(
"Failed to update chat config route for %s with %s: %s",
@@ -269,13 +275,14 @@ class OpenApiRoute(Route):
return None
umo = f"webchat:FriendMessage:webchat!{username}!{session_id}"
+ router = self.core_lifecycle.umop_config_router
+ if router is None:
+ return "UMOP config router not available"
try:
if config_id == "default":
- await self.core_lifecycle.umop_config_router.delete_route(umo)
+ await router.delete_route(umo)
else:
- await self.core_lifecycle.umop_config_router.update_route(
- umo, config_id
- )
+ await router.update_route(umo, config_id)
except Exception as e:
logger.error(
"Failed to update chat config route for %s with %s: %s",
@@ -662,10 +669,13 @@ class OpenApiRoute(Route):
return Response().error(f"Invalid umo: {e}").to_json()
platform_id = session.platform_name
+ platform_mgr = self.platform_manager
+ if platform_mgr is None:
+ return Response().error("Platform manager not available").to_json()
platform_inst = next(
(
inst
- for inst in self.platform_manager.platform_insts
+ for inst in platform_mgr.platform_insts
if inst.meta().id == platform_id
),
None,
diff --git a/astrbot/dashboard/routes/persona.py b/astrbot/dashboard/routes/persona.py
index 8ecf22ef0..9298d4907 100644
--- a/astrbot/dashboard/routes/persona.py
+++ b/astrbot/dashboard/routes/persona.py
@@ -41,6 +41,8 @@ class PersonaRoute(Route):
async def list_personas(self):
"""获取所有人格列表"""
try:
+ if not self.persona_mgr:
+ return Response().error("Persona manager not available").to_json()
# 支持按文件夹筛选
folder_id = request.args.get("folder_id")
if folder_id is not None:
@@ -87,6 +89,9 @@ class PersonaRoute(Route):
if not persona_id:
return Response().error("缺少必要参数: persona_id").to_json()
+ if not self.persona_mgr:
+ return Response().error("Persona manager not available").to_json()
+
persona = await self.persona_mgr.get_persona(persona_id)
if not persona:
return Response().error("人格不存在").to_json()
@@ -149,6 +154,8 @@ class PersonaRoute(Route):
.to_json()
)
+ if not self.persona_mgr:
+ return Response().error("Persona manager not available").to_json()
persona = await self.persona_mgr.create_persona(
persona_id=persona_id,
system_prompt=system_prompt,
@@ -236,6 +243,9 @@ class PersonaRoute(Route):
if has_custom_error_message:
update_kwargs["custom_error_message"] = custom_error_message
+ if not self.persona_mgr:
+ return Response().error("Persona manager not available").to_json()
+
await self.persona_mgr.update_persona(**update_kwargs)
return Response().ok({"message": "人格更新成功"}).to_json()
@@ -254,7 +264,10 @@ class PersonaRoute(Route):
if not persona_id:
return Response().error("缺少必要参数: persona_id").to_json()
- await self.persona_mgr.delete_persona(persona_id)
+ mgr = self.persona_mgr
+ if mgr is None:
+ return Response().error("Persona manager not available").to_json()
+ await mgr.delete_persona(persona_id)
return Response().ok({"message": "人格删除成功"}).to_json()
except ValueError as e:
@@ -276,6 +289,9 @@ class PersonaRoute(Route):
if not new_persona_id:
return Response().error("新人格ID不能为空").to_json()
+ if not self.persona_mgr:
+ return Response().error("Persona manager not available").to_json()
+
persona = await self.persona_mgr.clone_persona(
source_persona_id=source_persona_id,
new_persona_id=new_persona_id,
@@ -322,7 +338,10 @@ class PersonaRoute(Route):
if not persona_id:
return Response().error("缺少必要参数: persona_id").to_json()
- await self.persona_mgr.move_persona_to_folder(persona_id, folder_id)
+ mgr = self.persona_mgr
+ if mgr is None:
+ return Response().error("Persona manager not available").to_json()
+ await mgr.move_persona_to_folder(persona_id, folder_id)
return Response().ok({"message": "人格移动成功"}).to_json()
except ValueError as e:
@@ -342,6 +361,8 @@ class PersonaRoute(Route):
# 空字符串视为 None(根目录)
if parent_id == "":
parent_id = None
+ if not self.persona_mgr:
+ return Response().error("Persona manager not available").to_json()
folders = await self.persona_mgr.get_folders(parent_id)
return (
Response()
@@ -372,6 +393,8 @@ class PersonaRoute(Route):
async def get_folder_tree(self):
"""获取文件夹树形结构"""
try:
+ if not self.persona_mgr:
+ return Response().error("Persona manager not available").to_json()
tree = await self.persona_mgr.get_folder_tree()
return Response().ok(tree).to_json()
except Exception as e:
@@ -387,7 +410,10 @@ class PersonaRoute(Route):
if not folder_id:
return Response().error("缺少必要参数: folder_id").to_json()
- folder = await self.persona_mgr.get_folder(folder_id)
+ mgr = self.persona_mgr
+ if mgr is None:
+ return Response().error("Persona manager not available").to_json()
+ folder = await mgr.get_folder(folder_id)
if not folder:
return Response().error("文件夹不存在").to_json()
@@ -426,7 +452,10 @@ class PersonaRoute(Route):
if not name:
return Response().error("文件夹名称不能为空").to_json()
- folder = await self.persona_mgr.create_folder(
+ mgr = self.persona_mgr
+ if mgr is None:
+ return Response().error("Persona manager not available").to_json()
+ folder = await mgr.create_folder(
name=name,
parent_id=parent_id,
description=description,
@@ -472,6 +501,9 @@ class PersonaRoute(Route):
if not folder_id:
return Response().error("缺少必要参数: folder_id").to_json()
+ if not self.persona_mgr:
+ return Response().error("Persona manager not available").to_json()
+
await self.persona_mgr.update_folder(
folder_id=folder_id,
name=name,
@@ -494,6 +526,9 @@ class PersonaRoute(Route):
if not folder_id:
return Response().error("缺少必要参数: folder_id").to_json()
+ if not self.persona_mgr:
+ return Response().error("Persona manager not available").to_json()
+
await self.persona_mgr.delete_folder(folder_id)
return Response().ok({"message": "文件夹删除成功"}).to_json()
@@ -536,6 +571,9 @@ class PersonaRoute(Route):
.to_json()
)
+ if not self.persona_mgr:
+ return Response().error("Persona manager not available").to_json()
+
await self.persona_mgr.batch_update_sort_order(items)
return Response().ok({"message": "排序更新成功"}).to_json()
diff --git a/astrbot/dashboard/routes/platform.py b/astrbot/dashboard/routes/platform.py
index f59640906..941a08d3e 100644
--- a/astrbot/dashboard/routes/platform.py
+++ b/astrbot/dashboard/routes/platform.py
@@ -80,6 +80,8 @@ class PlatformRoute(Route):
Returns:
平台适配器实例,未找到则返回 None
"""
+ if self.platform_manager is None:
+ return None
for platform in self.platform_manager.platform_insts:
if platform.config.get("webhook_uuid") == webhook_uuid:
if platform.unified_webhook():
@@ -93,7 +95,10 @@ class PlatformRoute(Route):
包含平台统计信息的响应
"""
try:
- stats = self.platform_manager.get_all_stats()
+ mgr = self.platform_manager
+ if mgr is None:
+ return Response().error("Platform manager not available").to_json()
+ stats = mgr.get_all_stats()
return Response().ok(stats).to_json()
except Exception as e:
logger.error(f"获取平台统计信息失败: {e}", exc_info=True)
diff --git a/astrbot/dashboard/routes/route.py b/astrbot/dashboard/routes/route.py
index 3849e8ff6..4c449decd 100644
--- a/astrbot/dashboard/routes/route.py
+++ b/astrbot/dashboard/routes/route.py
@@ -136,7 +136,7 @@ class Response:
self.message = message
return self
- def ok(self, data: dict | list | None = None, message: str | None = None):
+ def ok(self, data: Any = None, message: str | None = None):
self.status = "ok"
if data is None:
data = {}
diff --git a/astrbot/dashboard/routes/session_management.py b/astrbot/dashboard/routes/session_management.py
index 2acd9a223..302565fe9 100644
--- a/astrbot/dashboard/routes/session_management.py
+++ b/astrbot/dashboard/routes/session_management.py
@@ -1,4 +1,4 @@
-from typing import Any, cast
+from typing import Any
from quart import request
from sqlalchemy.ext.asyncio import AsyncSession
@@ -40,7 +40,6 @@ class SessionManagementRoute(Route):
"/session/list-all-with-status": ("GET", self.list_all_umos_with_status),
"/session/batch-update-service": ("POST", self.batch_update_service),
"/session/batch-update-provider": ("POST", self.batch_update_provider),
- # 分组管理 API
"/session/groups": ("GET", self.list_groups),
"/session/group/create": ("POST", self.create_group),
"/session/group/update": ("POST", self.update_group),
@@ -87,36 +86,25 @@ class SessionManagementRoute(Route):
umo_rules[umo_id][pref.key] = pref.value["val"][umo_id]
else:
umo_rules[umo_id][pref.key] = pref.value["val"]
-
- # 搜索过滤
if search:
search_lower = search.lower()
filtered_rules = {}
for umo_id, rules in umo_rules.items():
- # 匹配 umo
if search_lower in umo_id.lower():
filtered_rules[umo_id] = rules
continue
- # 匹配 custom_name
svc_config = rules.get("session_service_config", {})
custom_name = svc_config.get("custom_name", "") if svc_config else ""
if custom_name and search_lower in custom_name.lower():
filtered_rules[umo_id] = rules
umo_rules = filtered_rules
-
- # 获取总数
total = len(umo_rules)
-
- # 分页处理
all_umo_ids = list(umo_rules.keys())
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_umo_ids = all_umo_ids[start_idx:end_idx]
-
- # 只返回分页后的数据
paginated_rules = {umo_id: umo_rules[umo_id] for umo_id in paginated_umo_ids}
-
- return paginated_rules, total
+ return (paginated_rules, total)
async def list_session_rule(self):
"""获取所有自定义的规则(支持分页和搜索)
@@ -129,103 +117,73 @@ class SessionManagementRoute(Route):
search: 搜索关键词,匹配 umo 或 custom_name
"""
try:
- # 获取分页和搜索参数
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 10, type=int)
search = request.args.get("search", "", type=str).strip()
-
- # 参数校验
if page < 1:
page = 1
if page_size < 1:
page_size = 10
if page_size > 100:
page_size = 100
-
umo_rules, total = await self._get_umo_rules(
page=page, page_size=page_size, search=search
)
-
- # 构建规则列表
rules_list = []
for umo, rules in umo_rules.items():
- rule_info = {
- "umo": umo,
- "rules": rules,
- }
- # 解析 umo 格式: 平台:消息类型:会话ID
+ rule_info = {"umo": umo, "rules": rules}
parts = umo.split(":")
if len(parts) >= 3:
rule_info["platform"] = parts[0]
rule_info["message_type"] = parts[1]
rule_info["session_id"] = parts[2]
rules_list.append(rule_info)
-
- # 获取可用的 providers 和 personas
provider_manager = self.core_lifecycle.provider_manager
persona_mgr = self.core_lifecycle.persona_mgr
-
available_personas = [
{"name": p["name"], "prompt": p.get("prompt", "")}
- for p in persona_mgr.personas_v3
+ for p in (persona_mgr.personas_v3 if persona_mgr else [])
]
-
available_chat_providers = [
- {
- "id": p.meta().id,
- "name": p.meta().id,
- "model": p.meta().model,
- }
- for p in provider_manager.provider_insts
+ {"id": p.meta().id, "name": p.meta().id, "model": p.meta().model}
+ for p in (provider_manager.provider_insts if provider_manager else [])
]
-
available_stt_providers = [
- {
- "id": p.meta().id,
- "name": p.meta().id,
- "model": p.meta().model,
- }
- for p in provider_manager.stt_provider_insts
+ {"id": p.meta().id, "name": p.meta().id, "model": p.meta().model}
+ for p in (
+ provider_manager.stt_provider_insts if provider_manager else []
+ )
]
-
available_tts_providers = [
- {
- "id": p.meta().id,
- "name": p.meta().id,
- "model": p.meta().model,
- }
- for p in provider_manager.tts_provider_insts
+ {"id": p.meta().id, "name": p.meta().id, "model": p.meta().model}
+ for p in (
+ provider_manager.tts_provider_insts if provider_manager else []
+ )
]
-
- # 获取可用的插件列表(排除 reserved 的系统插件)
plugin_manager = self.core_lifecycle.plugin_manager
- available_plugins = [
- {
- "name": p.name,
- "display_name": p.display_name or p.name,
- "desc": p.desc,
- }
- for p in plugin_manager.context.get_all_stars()
- if not p.reserved and p.name
- ]
-
- # 获取可用的知识库列表
+ if plugin_manager is None:
+ available_plugins = []
+ else:
+ available_plugins = [
+ {
+ "name": p.name,
+ "display_name": p.display_name or p.name,
+ "desc": p.desc,
+ }
+ for p in plugin_manager.context.get_all_stars()
+ if not p.reserved and p.name
+ ]
available_kbs = []
kb_manager = self.core_lifecycle.kb_manager
if kb_manager:
try:
kbs = await kb_manager.list_kbs()
available_kbs = [
- {
- "kb_id": kb.kb_id,
- "kb_name": kb.kb_name,
- "emoji": kb.emoji,
- }
+ {"kb_id": kb.kb_id, "kb_name": kb.kb_name, "emoji": kb.emoji}
for kb in kbs
]
except Exception as e:
logger.warning(f"获取知识库列表失败: {e!s}")
-
return (
Response()
.ok(
@@ -264,22 +222,15 @@ class SessionManagementRoute(Route):
umo = data.get("umo")
rule_key = data.get("rule_key")
rule_value = data.get("rule_value")
-
if not umo:
return Response().error("缺少必要参数: umo").to_json()
if not rule_key:
return Response().error("缺少必要参数: rule_key").to_json()
if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
return Response().error(f"不支持的规则键: {rule_key}").to_json()
-
if rule_key == "session_plugin_config":
- rule_value = {
- umo: rule_value,
- }
-
- # 使用 shared preferences 更新规则
+ rule_value = {umo: rule_value}
await sp.session_put(umo, rule_key, rule_value)
-
return (
Response()
.ok({"message": f"规则 {rule_key} 已更新", "umo": umo})
@@ -302,12 +253,9 @@ class SessionManagementRoute(Route):
data = await request.get_json()
umo = data.get("umo")
rule_key = data.get("rule_key")
-
if not umo:
return Response().error("缺少必要参数: umo").to_json()
-
if rule_key:
- # 删除单个规则
if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
return Response().error(f"不支持的规则键: {rule_key}").to_json()
await sp.session_remove(umo, rule_key)
@@ -317,7 +265,6 @@ class SessionManagementRoute(Route):
.to_json()
)
else:
- # 删除该 umo 的所有规则
await sp.clear_async("umo", umo)
return (
Response().ok({"message": "所有规则已删除", "umo": umo}).to_json()
@@ -337,17 +284,13 @@ class SessionManagementRoute(Route):
"rule_key": "session_service_config" | ... (可选,不传则删除所有规则)
}
"""
-
try:
data = await request.get_json()
umos = data.get("umos", [])
scope = data.get("scope", "")
group_id = data.get("group_id", "")
rule_key = data.get("rule_key")
-
- # 如果指定了 scope,获取符合条件的所有 umo
- if scope and not umos:
- # 如果是自定义分组
+ if scope and (not umos):
if scope == "custom_group":
if not group_id:
return Response().error("请指定分组 ID").to_json()
@@ -362,7 +305,6 @@ class SessionManagementRoute(Route):
select(ConversationV2.user_id).distinct()
)
all_umos = [row[0] for row in result.fetchall()]
-
if scope == "group":
umos = [
u
@@ -377,17 +319,12 @@ class SessionManagementRoute(Route):
]
elif scope == "all":
umos = all_umos
-
if not umos:
return Response().error("缺少必要参数: umos 或有效的 scope").to_json()
-
if not isinstance(umos, list):
return Response().error("参数 umos 必须是数组").to_json()
-
if rule_key and rule_key not in AVAILABLE_SESSION_RULE_KEYS:
return Response().error(f"不支持的规则键: {rule_key}").to_json()
-
- # 批量删除
success_count = 0
failed_umos = []
for umo in umos:
@@ -400,11 +337,9 @@ class SessionManagementRoute(Route):
except Exception as e:
logger.error(f"删除 umo {umo} 的规则失败: {e!s}")
failed_umos.append(umo)
-
message = f"已删除 {success_count} 条规则"
if rule_key:
message = f"已删除 {success_count} 条 {rule_key} 规则"
-
if failed_umos:
return (
Response()
@@ -420,12 +355,7 @@ class SessionManagementRoute(Route):
else:
return (
Response()
- .ok(
- {
- "message": message,
- "success_count": success_count,
- }
- )
+ .ok({"message": message, "success_count": success_count})
.to_json()
)
except Exception as e:
@@ -438,7 +368,6 @@ class SessionManagementRoute(Route):
仅返回 umo 字符串列表,用于用户在创建规则时选择 umo
"""
try:
- # 从 Conversation 表获取所有 distinct user_id (即 umo)
async with self.db_helper.get_db() as session:
session: AsyncSession
result = await session.execute(
@@ -447,7 +376,6 @@ class SessionManagementRoute(Route):
.order_by(ConversationV2.user_id)
)
umos = [row[0] for row in result.fetchall()]
-
return Response().ok({"umos": umos}).to_json()
except Exception as e:
logger.error(f"获取 UMO 列表失败: {e!s}")
@@ -469,15 +397,12 @@ class SessionManagementRoute(Route):
search = request.args.get("search", "", type=str).strip()
message_type = request.args.get("message_type", "all", type=str)
platform = request.args.get("platform", "", type=str)
-
if page < 1:
page = 1
if page_size < 1:
page_size = 20
if page_size > 100:
page_size = 100
-
- # 从 Conversation 表获取所有 distinct user_id (即 umo)
async with self.db_helper.get_db() as session:
session: AsyncSession
result = await session.execute(
@@ -486,19 +411,13 @@ class SessionManagementRoute(Route):
.order_by(ConversationV2.user_id)
)
all_umos = [row[0] for row in result.fetchall()]
-
- # 获取所有 umo 的规则配置
umo_rules, _ = await self._get_umo_rules(page=1, page_size=99999, search="")
-
- # 构建带状态的 umo 列表
umos_with_status = []
for umo in all_umos:
parts = umo.split(":")
umo_platform = parts[0] if len(parts) >= 1 else "unknown"
umo_message_type = parts[1] if len(parts) >= 2 else "unknown"
umo_session_id = parts[2] if len(parts) >= 3 else umo
-
- # 筛选消息类型
if message_type != "all":
if message_type == "group" and umo_message_type not in [
"group",
@@ -511,15 +430,10 @@ class SessionManagementRoute(Route):
"friend",
]:
continue
-
- # 筛选平台
if platform and umo_platform != platform:
continue
-
- # 获取服务配置
rules = umo_rules.get(umo, {})
svc_config = rules.get("session_service_config", {})
-
custom_name = svc_config.get("custom_name", "") if svc_config else ""
session_enabled = (
svc_config.get("session_enabled", True) if svc_config else True
@@ -530,8 +444,6 @@ class SessionManagementRoute(Route):
tts_enabled = (
svc_config.get("tts_enabled", True) if svc_config else True
)
-
- # 搜索过滤
if search:
search_lower = search.lower()
if (
@@ -539,14 +451,11 @@ class SessionManagementRoute(Route):
and search_lower not in custom_name.lower()
):
continue
-
- # 获取 provider 配置
chat_provider_key = (
f"provider_perf_{ProviderType.CHAT_COMPLETION.value}"
)
tts_provider_key = f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}"
stt_provider_key = f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}"
-
umos_with_status.append(
{
"umo": umo,
@@ -563,31 +472,28 @@ class SessionManagementRoute(Route):
"stt_provider": rules.get(stt_provider_key),
}
)
-
- # 分页
total = len(umos_with_status)
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated = umos_with_status[start_idx:end_idx]
-
- # 获取可用的平台列表
platforms = list({u["platform"] for u in umos_with_status})
-
- # 获取可用的 providers
provider_manager = self.core_lifecycle.provider_manager
available_chat_providers = [
{"id": p.meta().id, "name": p.meta().id, "model": p.meta().model}
- for p in provider_manager.provider_insts
+ for p in (provider_manager.provider_insts if provider_manager else [])
]
available_tts_providers = [
{"id": p.meta().id, "name": p.meta().id, "model": p.meta().model}
- for p in provider_manager.tts_provider_insts
+ for p in (
+ provider_manager.tts_provider_insts if provider_manager else []
+ )
]
available_stt_providers = [
{"id": p.meta().id, "name": p.meta().id, "model": p.meta().model}
- for p in provider_manager.stt_provider_insts
+ for p in (
+ provider_manager.stt_provider_insts if provider_manager else []
+ )
]
-
return (
Response()
.ok(
@@ -629,14 +535,13 @@ class SessionManagementRoute(Route):
llm_enabled = data.get("llm_enabled")
tts_enabled = data.get("tts_enabled")
session_enabled = data.get("session_enabled")
-
- # 如果没有任何修改
- if llm_enabled is None and tts_enabled is None and session_enabled is None:
+ if (
+ llm_enabled is None
+ and tts_enabled is None
+ and (session_enabled is None)
+ ):
return Response().error("至少需要指定一个要修改的状态").to_json()
-
- # 如果指定了 scope,获取符合条件的所有 umo
- if scope and not umos:
- # 如果是自定义分组
+ if scope and (not umos):
if scope == "custom_group":
if not group_id:
return Response().error("请指定分组 ID").to_json()
@@ -651,7 +556,6 @@ class SessionManagementRoute(Route):
select(ConversationV2.user_id).distinct()
)
all_umos = [row[0] for row in result.fetchall()]
-
if scope == "group":
umos = [
u
@@ -666,31 +570,22 @@ class SessionManagementRoute(Route):
]
elif scope == "all":
umos = all_umos
-
if not umos:
return Response().error("没有找到符合条件的会话").to_json()
-
- # 批量更新
success_count = 0
failed_umos = []
-
for umo in umos:
try:
- # 获取现有配置
session_config = (
sp.get("session_service_config", {}, scope="umo", scope_id=umo)
or {}
)
-
- # 更新状态
if llm_enabled is not None:
session_config["llm_enabled"] = llm_enabled
if tts_enabled is not None:
session_config["tts_enabled"] = tts_enabled
if session_enabled is not None:
session_config["session_enabled"] = session_enabled
-
- # 保存
sp.put(
"session_service_config",
session_config,
@@ -701,15 +596,13 @@ class SessionManagementRoute(Route):
except Exception as e:
logger.error(f"更新 {umo} 服务状态失败: {e!s}")
failed_umos.append(umo)
-
status_changes = []
if llm_enabled is not None:
- status_changes.append(f"LLM={'启用' if llm_enabled else '禁用'}")
+ status_changes.append(f"LLM={('启用' if llm_enabled else '禁用')}")
if tts_enabled is not None:
- status_changes.append(f"TTS={'启用' if tts_enabled else '禁用'}")
+ status_changes.append(f"TTS={('启用' if tts_enabled else '禁用')}")
if session_enabled is not None:
- status_changes.append(f"会话={'启用' if session_enabled else '禁用'}")
-
+ status_changes.append(f"会话={('启用' if session_enabled else '禁用')}")
return (
Response()
.ok(
@@ -743,15 +636,12 @@ class SessionManagementRoute(Route):
scope = data.get("scope", "")
provider_type = data.get("provider_type")
provider_id = data.get("provider_id")
-
if not provider_type or not provider_id:
return (
Response()
.error("缺少必要参数: provider_type, provider_id")
.to_json()
)
-
- # 转换 provider_type
provider_type_map = {
"chat_completion": ProviderType.CHAT_COMPLETION,
"text_to_speech": ProviderType.TEXT_TO_SPEECH,
@@ -763,13 +653,9 @@ class SessionManagementRoute(Route):
.error(f"不支持的 provider_type: {provider_type}")
.to_json()
)
-
provider_type_enum = provider_type_map[provider_type]
-
- # 如果指定了 scope,获取符合条件的所有 umo
group_id = data.get("group_id", "")
- if scope and not umos:
- # 如果是自定义分组
+ if scope and (not umos):
if scope == "custom_group":
if not group_id:
return Response().error("请指定分组 ID").to_json()
@@ -784,7 +670,6 @@ class SessionManagementRoute(Route):
select(ConversationV2.user_id).distinct()
)
all_umos = [row[0] for row in result.fetchall()]
-
if scope == "group":
umos = [
u
@@ -799,15 +684,13 @@ class SessionManagementRoute(Route):
]
elif scope == "all":
umos = all_umos
-
if not umos:
return Response().error("没有找到符合条件的会话").to_json()
-
- # 批量更新
success_count = 0
failed_umos = []
provider_manager = self.core_lifecycle.provider_manager
-
+ if provider_manager is None:
+ return Response().error("Provider manager not available").to_json()
for umo in umos:
try:
await provider_manager.set_provider(
@@ -819,7 +702,6 @@ class SessionManagementRoute(Route):
except Exception as e:
logger.error(f"更新 {umo} Provider 失败: {e!s}")
failed_umos.append(umo)
-
return (
Response()
.ok(
@@ -836,11 +718,9 @@ class SessionManagementRoute(Route):
logger.error(f"批量更新 Provider 失败: {e!s}")
return Response().error(f"批量更新 Provider 失败: {e!s}").to_json()
- # ==================== 分组管理 API ====================
-
def _get_groups(self) -> dict[str, Any]:
"""获取所有分组"""
- return cast(dict[str, Any], sp.get("session_groups", {}))
+ return sp.get("session_groups", {})
def _save_groups(self, groups: dict) -> None:
"""保存分组"""
@@ -850,7 +730,6 @@ class SessionManagementRoute(Route):
"""获取所有分组列表"""
try:
groups = self._get_groups()
- # 转换为列表格式,方便前端使用
groups_list = []
for group_id, group_data in groups.items():
groups_list.append(
@@ -872,24 +751,14 @@ class SessionManagementRoute(Route):
data = await request.json
name = data.get("name", "").strip()
umos = data.get("umos", [])
-
if not name:
return Response().error("分组名称不能为空").to_json()
-
groups = self._get_groups()
-
- # 生成唯一 ID
import uuid
group_id = str(uuid.uuid4())[:8]
-
- groups[group_id] = {
- "name": name,
- "umos": umos,
- }
-
+ groups[group_id] = {"name": name, "umos": umos}
self._save_groups(groups)
-
return (
Response()
.ok(
@@ -918,35 +787,24 @@ class SessionManagementRoute(Route):
umos = data.get("umos")
add_umos = data.get("add_umos", [])
remove_umos = data.get("remove_umos", [])
-
if not group_id:
return Response().error("分组 ID 不能为空").to_json()
-
groups = self._get_groups()
-
if group_id not in groups:
return Response().error(f"分组 '{group_id}' 不存在").to_json()
-
group = groups[group_id]
-
- # 更新名称
if name is not None:
group["name"] = name.strip()
-
- # 直接设置 umos 列表
if umos is not None:
group["umos"] = umos
else:
- # 增量更新
current_umos = set(group.get("umos", []))
if add_umos:
current_umos.update(add_umos)
if remove_umos:
current_umos.difference_update(remove_umos)
group["umos"] = list(current_umos)
-
self._save_groups(groups)
-
return (
Response()
.ok(
@@ -971,20 +829,14 @@ class SessionManagementRoute(Route):
try:
data = await request.json
group_id = data.get("id")
-
if not group_id:
return Response().error("分组 ID 不能为空").to_json()
-
groups = self._get_groups()
-
if group_id not in groups:
return Response().error(f"分组 '{group_id}' 不存在").to_json()
-
group_name = groups[group_id].get("name", group_id)
del groups[group_id]
-
self._save_groups(groups)
-
return Response().ok({"message": f"分组 '{group_name}' 已删除"}).to_json()
except Exception as e:
logger.error(f"删除分组失败: {e!s}")
diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py
index 4497d9aef..c10074a6e 100644
--- a/astrbot/dashboard/routes/skills.py
+++ b/astrbot/dashboard/routes/skills.py
@@ -575,7 +575,7 @@ class SkillsRoute(Route):
release_json = result.get("release")
logger.info(f"[Neo] Candidate promoted: id={candidate_id}, stage={stage}")
- sync_json = result.get("sync")
+ sync_json = result.get("sync") or {}
did_sync_to_local = bool(sync_json)
if did_sync_to_local:
logger.info(
diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py
index a36acca5e..e3f015c99 100644
--- a/astrbot/dashboard/routes/stat.py
+++ b/astrbot/dashboard/routes/stat.py
@@ -14,7 +14,7 @@ import aiohttp
import anyio
import psutil
from quart import request
-from sqlmodel import select
+from sqlmodel import asc, select
from astrbot.core import DEMO_MODE, logger
from astrbot.core.config import VERSION
@@ -168,7 +168,8 @@ class StatRoute(Route):
thread_count = threading.active_count()
# 获取插件信息
- plugins = self.core_lifecycle.star_context.get_all_stars()
+ star_ctx = self.core_lifecycle.star_context
+ plugins = star_ctx.get_all_stars() if star_ctx else []
plugin_info = []
for plugin in plugins:
info = {
@@ -190,7 +191,9 @@ class StatRoute(Route):
).platform,
"message_count": self.db_helper.get_total_message_count() or 0,
"platform_count": len(
- self.core_lifecycle.platform_manager.get_insts(),
+ self.core_lifecycle.platform_manager.get_insts()
+ if self.core_lifecycle.platform_manager
+ else [],
),
"plugin_count": len(plugins),
"plugins": plugin_info,
@@ -244,7 +247,7 @@ class StatRoute(Route):
ProviderStat.agent_type == "internal",
ProviderStat.created_at >= query_start_utc,
)
- .order_by(ProviderStat.created_at.asc())
+ .order_by(asc(ProviderStat.created_at))
)
records = result.scalars().all()
diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py
index 87adeee89..655203359 100644
--- a/astrbot/dashboard/routes/subagent.py
+++ b/astrbot/dashboard/routes/subagent.py
@@ -54,8 +54,9 @@ class SubAgentRoute(Route):
# Backward/forward compatibility: ensure each agent contains provider_id.
# None means follow global/default provider settings.
- if isinstance(data.get("agents"), list):
- for a in data["agents"]:
+ agents_list = data.get("agents")
+ if isinstance(agents_list, list):
+ for a in agents_list:
if isinstance(a, dict):
a.setdefault("provider_id", None)
a.setdefault("persona_id", None)
@@ -93,7 +94,10 @@ class SubAgentRoute(Route):
UI can use this to build a multi-select list for subagent tool assignment.
"""
try:
- tool_mgr = self.core_lifecycle.provider_manager.llm_tools
+ prov_mgr = self.core_lifecycle.provider_manager
+ if prov_mgr is None:
+ return Response().error("Provider manager not available").to_json()
+ tool_mgr = prov_mgr.llm_tools
tools_dict = []
for tool in tool_mgr.func_list:
# Prevent recursive routing: subagents should not be able to select
diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py
index 1209cdc7a..62f2e1102 100644
--- a/astrbot/dashboard/routes/tools.py
+++ b/astrbot/dashboard/routes/tools.py
@@ -29,21 +29,17 @@ def _extract_mcp_server_config(mcp_servers_value: object) -> dict:
raise ValueError("mcpServers must be a JSON object")
if not mcp_servers_value:
raise EmptyMcpServersError("mcpServers configuration cannot be empty")
- key_0 = next(iter(mcp_servers_value))
- extracted = mcp_servers_value[key_0]
+ extracted = list(mcp_servers_value.values())[0]
if not isinstance(extracted, dict):
raise ValueError(
- "Invalid mcpServers format. Ensure each key in mcpServers is a server name, "
- "and each value is an object containing fields like command/url."
+ "Invalid mcpServers format. Ensure each key in mcpServers is a server name, and each value is an object containing fields like command/url."
)
return extracted
class ToolsRoute(Route):
def __init__(
- self,
- context: RouteContext,
- core_lifecycle: AstrBotCoreLifecycle,
+ self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle
) -> None:
super().__init__(context)
self.core_lifecycle = core_lifecycle
@@ -58,7 +54,10 @@ class ToolsRoute(Route):
"/tools/mcp/sync-provider": ("POST", self.sync_provider),
}
self.register_routes()
- self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools
+ provider_mgr = self.core_lifecycle.provider_manager
+ if provider_mgr is None:
+ raise RuntimeError("Provider manager not initialized")
+ self.tool_mgr = provider_mgr.llm_tools
def _rollback_mcp_server(self, name: str) -> bool:
try:
@@ -76,32 +75,24 @@ class ToolsRoute(Route):
config = self.tool_mgr.load_mcp_config()
servers = []
mcp_servers = config.get("mcpServers", {})
-
if not isinstance(mcp_servers, dict):
logger.warning(
f"Invalid MCP server config type: {type(mcp_servers).__name__}. Expected object/dict; skipped all MCP servers."
)
mcp_servers = {}
-
- # 获取所有服务器并添加它们的工具列表
for name, server_config in mcp_servers.items():
if not isinstance(server_config, dict):
logger.warning(
f"Invalid config for MCP server '{name}' (type: {type(server_config).__name__}); skipped."
)
continue
-
server_info = {
"name": name,
"active": server_config.get("active", True),
}
-
- # 复制所有配置字段
for key, value in server_config.items():
- if key != "active": # active 已经处理
+ if key != "active":
server_info[key] = value
-
- # 如果MCP客户端已初始化,从客户端获取工具名称
for name_key, runtime in self.tool_mgr.mcp_server_runtime_view.items():
if name_key == name:
mcp_client = runtime.client
@@ -110,9 +101,7 @@ class ToolsRoute(Route):
break
else:
server_info["tools"] = []
-
servers.append(server_info)
-
return Response().ok(servers).to_json()
except Exception as e:
logger.error(traceback.format_exc())
@@ -121,20 +110,13 @@ class ToolsRoute(Route):
async def add_mcp_server(self):
try:
server_data = await request.json
-
name = server_data.get("name", "")
-
- # 检查必填字段
if not name:
return Response().error("Server name cannot be empty").to_json()
-
- # 移除特殊字段并检查配置是否有效
has_valid_config = False
server_config = {"active": server_data.get("active", True)}
-
- # 复制所有配置字段
for key, value in server_data.items():
- if key not in ["name", "active", "tools", "errlogs"]: # 排除特殊字段
+ if key not in ["name", "active", "tools", "errlogs"]:
if key == "mcpServers":
try:
server_config = _extract_mcp_server_config(
@@ -145,33 +127,25 @@ class ToolsRoute(Route):
else:
server_config[key] = value
has_valid_config = True
-
if not has_valid_config:
return (
Response()
.error("A valid server configuration is required")
.to_json()
)
-
config = self.tool_mgr.load_mcp_config()
-
if name in config["mcpServers"]:
return Response().error(f"Server {name} already exists").to_json()
-
try:
await self.tool_mgr.test_mcp_server_connection(server_config)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"MCP connection test failed: {e!s}").to_json()
-
config["mcpServers"][name] = server_config
-
if self.tool_mgr.save_mcp_config(config):
try:
await self.tool_mgr.enable_mcp_server(
- name,
- server_config,
- init_timeout=30,
+ name, server_config, init_timeout=30
)
except TimeoutError:
rollback_ok = self._rollback_mcp_server(name)
@@ -199,46 +173,26 @@ class ToolsRoute(Route):
async def update_mcp_server(self):
try:
server_data = await request.json
-
name = server_data.get("name", "")
old_name = server_data.get("oldName") or name
-
if not name:
return Response().error("Server name cannot be empty").to_json()
-
config = self.tool_mgr.load_mcp_config()
-
if old_name not in config["mcpServers"]:
return Response().error(f"Server {old_name} does not exist").to_json()
-
is_rename = name != old_name
-
if name in config["mcpServers"] and is_rename:
return Response().error(f"Server {name} already exists").to_json()
-
- # 获取活动状态
old_config = config["mcpServers"][old_name]
if isinstance(old_config, dict):
old_active = old_config.get("active", True)
else:
old_active = True
active = server_data.get("active", old_active)
-
- # 创建新的配置对象
server_config = {"active": active}
-
- # 仅更新活动状态的特殊处理
only_update_active = True
-
- # 复制所有配置字段
for key, value in server_data.items():
- if key not in [
- "name",
- "active",
- "tools",
- "errlogs",
- "oldName",
- ]: # 排除特殊字段
+ if key not in ["name", "active", "tools", "errlogs", "oldName"]:
if key == "mcpServers":
try:
server_config = _extract_mcp_server_config(
@@ -249,22 +203,16 @@ class ToolsRoute(Route):
else:
server_config[key] = value
only_update_active = False
-
- # 如果只更新活动状态,保留原始配置
if only_update_active and isinstance(old_config, dict):
for key, value in old_config.items():
- if key != "active": # 除了active之外的所有字段都保留
+ if key != "active":
server_config[key] = value
-
- # config["mcpServers"][name] = server_config
if is_rename:
config["mcpServers"].pop(old_name)
config["mcpServers"][name] = server_config
else:
config["mcpServers"][name] = server_config
-
if self.tool_mgr.save_mcp_config(config):
- # 处理MCP客户端状态变化
if active:
if (
old_name in self.tool_mgr.mcp_server_runtime_view
@@ -294,9 +242,7 @@ class ToolsRoute(Route):
)
try:
await self.tool_mgr.enable_mcp_server(
- name,
- config["mcpServers"][name],
- init_timeout=30,
+ name, config["mcpServers"][name], init_timeout=30
)
except TimeoutError:
return (
@@ -311,7 +257,6 @@ class ToolsRoute(Route):
.error(f"Failed to enable MCP server {name}: {e!s}")
.to_json()
)
- # 如果要停用服务器
elif old_name in self.tool_mgr.mcp_server_runtime_view:
try:
await self.tool_mgr.disable_mcp_server(old_name, timeout=10)
@@ -328,7 +273,6 @@ class ToolsRoute(Route):
.error(f"Failed to disable MCP server {old_name}: {e!s}")
.to_json()
)
-
return (
Response()
.ok(None, f"Successfully updated MCP server {name}")
@@ -343,17 +287,12 @@ class ToolsRoute(Route):
try:
server_data = await request.json
name = server_data.get("name", "")
-
if not name:
return Response().error("Server name cannot be empty").to_json()
-
config = self.tool_mgr.load_mcp_config()
-
if name not in config["mcpServers"]:
return Response().error(f"Server {name} does not exist").to_json()
-
del config["mcpServers"][name]
-
if self.tool_mgr.save_mcp_config(config):
if name in self.tool_mgr.mcp_server_runtime_view:
try:
@@ -386,10 +325,8 @@ class ToolsRoute(Route):
try:
server_data = await request.json
config = server_data.get("mcp_server_config", None)
-
if not isinstance(config, dict) or not config:
return Response().error("Invalid MCP server configuration").to_json()
-
if "mcpServers" in config:
mcp_servers = config["mcpServers"]
if isinstance(mcp_servers, dict) and len(mcp_servers) > 1:
@@ -416,14 +353,12 @@ class ToolsRoute(Route):
.error("MCP server configuration cannot be empty")
.to_json()
)
-
tools_name = await self.tool_mgr.test_mcp_server_connection(config)
return (
Response()
.ok(data=tools_name, message="🎉 MCP server is available!")
.to_json()
)
-
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Failed to test MCP connection: {e!s}").to_json()
@@ -434,9 +369,7 @@ class ToolsRoute(Route):
tools = self.tool_mgr.func_list
tools_dict = []
for tool in tools:
- # Use the source field added to FunctionTool
source = getattr(tool, "source", "plugin")
-
if source == "mcp" and isinstance(tool, MCPTool):
origin = "mcp"
origin_name = tool.mcp_server_name
@@ -446,13 +379,13 @@ class ToolsRoute(Route):
elif getattr(tool, "handler_module_path", None) and star_map.get(
getattr(tool, "handler_module_path", None)
):
- star = star_map[getattr(tool, "handler_module_path", None)]
+ handler_path = getattr(tool, "handler_module_path", None)
+ star = star_map[handler_path]
origin = "plugin"
origin_name = star.name
else:
origin = "unknown"
origin_name = "unknown"
-
tool_info = {
"name": tool.name,
"description": tool.description,
@@ -473,28 +406,23 @@ class ToolsRoute(Route):
try:
data = await request.json
tool_name = data.get("name")
- action = data.get("activate") # True or False
-
+ action = data.get("activate")
if not tool_name or action is None:
return (
Response()
.error("Missing required parameters: name or activate")
.to_json()
)
-
- # Internal tools cannot be toggled by users
for t in self.tool_mgr.func_list:
if t.name == tool_name and getattr(t, "source", "") == "internal":
return Response().error("内置工具不支持手动启用/停用").to_json()
-
if action:
try:
- ok = self.tool_mgr.activate_llm_tool(tool_name, star_map=star_map)
+ ok = self.tool_mgr.activate_llm_tool(tool_name)
except ValueError as e:
return Response().error(f"Failed to activate tool: {e!s}").to_json()
else:
ok = self.tool_mgr.deactivate_llm_tool(tool_name)
-
if ok:
return Response().ok(None, "Operation successful.").to_json()
return (
@@ -502,7 +430,6 @@ class ToolsRoute(Route):
.error(f"Tool {tool_name} does not exist or the operation failed.")
.to_json()
)
-
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Failed to operate tool: {e!s}").to_json()
@@ -511,7 +438,7 @@ class ToolsRoute(Route):
"""Sync MCP provider configuration."""
try:
data = await request.json
- provider_name = data.get("name") # modelscope, or others
+ provider_name = data.get("name")
match provider_name:
case "modelscope":
access_token = data.get("access_token", "")
@@ -520,7 +447,6 @@ class ToolsRoute(Route):
return (
Response().error(f"Unknown provider: {provider_name}").to_json()
)
-
return Response().ok(message="Sync completed").to_json()
except Exception as e:
logger.error(traceback.format_exc())
diff --git a/astrbot/dashboard/routes/tui_chat.py b/astrbot/dashboard/routes/tui_chat.py
index 9b0b13632..eeb339083 100644
--- a/astrbot/dashboard/routes/tui_chat.py
+++ b/astrbot/dashboard/routes/tui_chat.py
@@ -4,10 +4,9 @@ import os
import uuid
from contextlib import asynccontextmanager
from pathlib import Path
-from typing import Any, cast
+from typing import Any
import anyio
-from quart import Response as QuartResponse
from quart import g, make_response, request, send_file
from astrbot.core import logger
@@ -41,14 +40,14 @@ async def _poll_tui_stream_result(back_queue, username: str):
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
- return None, False
+ return (None, False)
except asyncio.CancelledError:
logger.debug(f"[TUI] User {username} disconnected.")
- return None, True
+ return (None, True)
except Exception as e:
logger.error(f"TUI stream error: {e}")
- return None, False
- return result, False
+ return (None, False)
+ return (result, False)
def _resolve_path(path: str) -> Path:
@@ -83,40 +82,33 @@ class TUIChatRoute(Route):
self.register_routes()
self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
os.makedirs(self.attachments_dir, exist_ok=True)
-
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
self.conv_mgr = core_lifecycle.conversation_manager
self.platform_history_mgr = core_lifecycle.platform_message_history_manager
self.db = db
self.umop_config_router = core_lifecycle.umop_config_router
-
self.running_convs: dict[str, bool] = {}
async def get_file(self):
filename = request.args.get("filename")
if not filename:
return Response().error("Missing key: filename").to_json()
-
try:
file_path = os.path.join(self.attachments_dir, os.path.basename(filename))
resolved_file_path = _resolve_path(file_path)
resolved_base_dir = _resolve_path(self.attachments_dir)
-
if not await anyio.Path(resolved_file_path).exists():
return Response().error("File not found").to_json()
-
try:
resolved_file_path.relative_to(resolved_base_dir)
except ValueError:
return Response().error("Invalid file path").to_json()
-
filename_ext = os.path.splitext(filename)[1].lower()
if filename_ext == ".wav":
return await send_file(str(resolved_file_path), mimetype="audio/wav")
if filename_ext[1:] in self.supported_imgs:
return await send_file(str(resolved_file_path), mimetype="image/jpeg")
return await send_file(str(resolved_file_path))
-
except (FileNotFoundError, OSError):
return Response().error("File access error").to_json()
@@ -125,19 +117,15 @@ class TUIChatRoute(Route):
attachment_id = request.args.get("attachment_id")
if not attachment_id:
return Response().error("Missing key: attachment_id").to_json()
-
try:
attachment = await self.db.get_attachment_by_id(attachment_id)
if not attachment:
return Response().error("Attachment not found").to_json()
-
file_path = attachment.path
resolved_file_path = _resolve_path(file_path)
-
return await send_file(
str(resolved_file_path), mimetype=attachment.mime_type
)
-
except (FileNotFoundError, OSError):
return Response().error("File access error").to_json()
@@ -146,11 +134,9 @@ class TUIChatRoute(Route):
post_data = await request.files
if "file" not in post_data:
return Response().error("Missing key: file").to_json()
-
file = post_data["file"]
filename = file.filename or f"{uuid.uuid4()!s}"
content_type = file.content_type or "application/octet-stream"
-
if content_type.startswith("image"):
attach_type = "image"
elif content_type.startswith("audio"):
@@ -159,21 +145,14 @@ class TUIChatRoute(Route):
attach_type = "video"
else:
attach_type = "file"
-
path = os.path.join(self.attachments_dir, filename)
await file.save(path)
-
attachment = await self.db.insert_attachment(
- path=path,
- type=attach_type,
- mime_type=content_type,
+ path=path, type=attach_type, mime_type=content_type
)
-
if not attachment:
return Response().error("Failed to create attachment").to_json()
-
filename = os.path.basename(attachment.path)
-
return (
Response()
.ok(
@@ -189,9 +168,7 @@ class TUIChatRoute(Route):
async def _build_user_message_parts(self, message: str | list) -> list[dict]:
"""Build user message parts list."""
return await build_webchat_message_parts(
- message,
- get_attachment_by_id=self.db.get_attachment_by_id,
- strict=False,
+ message, get_attachment_by_id=self.db.get_attachment_by_id, strict=False
)
async def _create_attachment_from_file(
@@ -219,7 +196,6 @@ class TUIChatRoute(Route):
bot_message_parts.extend(media_parts)
if text:
bot_message_parts.append({"type": "plain", "text": text})
-
new_his: dict[str, Any] = {"type": "bot", "message": bot_message_parts}
if reasoning:
new_his["reasoning"] = reasoning
@@ -227,8 +203,9 @@ class TUIChatRoute(Route):
new_his["agent_stats"] = agent_stats
if refs:
new_his["refs"] = refs
-
- record = await self.platform_history_mgr.insert(
+ mgr = self.platform_history_mgr
+ assert mgr is not None
+ record = await mgr.insert(
platform_id="tui",
user_id=tui_conv_id,
content=new_his,
@@ -239,30 +216,24 @@ class TUIChatRoute(Route):
async def chat(self, post_data: dict | None = None):
username = g.get("username", "guest")
-
if post_data is None:
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").to_json()
if "message" not in post_data and "files" not in post_data:
return Response().error("Missing key: message or files").to_json()
-
if "session_id" not in post_data and "conversation_id" not in post_data:
return (
Response().error("Missing key: session_id or conversation_id").to_json()
)
-
message = post_data["message"]
session_id = post_data.get("session_id", post_data.get("conversation_id"))
selected_provider = post_data.get("selected_provider")
selected_model = post_data.get("selected_model")
enable_streaming = post_data.get("enable_streaming", True)
-
if not session_id:
return Response().error("session_id is empty").to_json()
-
tui_conv_id = session_id
-
message_parts = await self._build_user_message_parts(message)
if not webchat_message_parts_have_content(message_parts):
return (
@@ -270,12 +241,8 @@ class TUIChatRoute(Route):
.error("Message content is empty (reply only is not allowed)")
.to_json()
)
-
message_id = str(uuid.uuid4())
- back_queue = tui_queue_mgr.get_or_create_back_queue(
- message_id,
- tui_conv_id,
- )
+ back_queue = tui_queue_mgr.get_or_create_back_queue(message_id, tui_conv_id)
async def stream():
client_disconnected = False
@@ -292,7 +259,6 @@ class TUIChatRoute(Route):
"session_id": tui_conv_id,
}
yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n"
-
async with track_conversation(self.running_convs, tui_conv_id):
while True:
result, should_break = await _poll_tui_stream_result(
@@ -303,19 +269,16 @@ class TUIChatRoute(Route):
break
if not result:
continue
-
if (
"message_id" in result
and result["message_id"] != message_id
):
logger.warning("TUI stream message_id mismatch")
continue
-
result_text = result["data"]
msg_type = result.get("type")
streaming = result.get("streaming", False)
chain_type = result.get("chain_type")
-
if chain_type == "agent_stats":
stats_info = {
"type": "agent_stats",
@@ -324,7 +287,6 @@ class TUIChatRoute(Route):
yield f"data: {json.dumps(stats_info, ensure_ascii=False)}\n\n"
agent_stats = stats_info["data"]
continue
-
try:
if not client_disconnected:
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
@@ -332,14 +294,12 @@ class TUIChatRoute(Route):
if not client_disconnected:
logger.debug(f"[TUI] User {username} disconnected. {e}")
client_disconnected = True
-
try:
if not client_disconnected:
await asyncio.sleep(0.05)
except asyncio.CancelledError:
logger.debug(f"[TUI] User {username} disconnected.")
client_disconnected = True
-
if msg_type == "plain":
chain_type = result.get("chain_type")
if chain_type == "tool_call":
@@ -390,16 +350,14 @@ class TUIChatRoute(Route):
)
if part:
accumulated_parts.append(part)
-
if msg_type == "end":
break
- elif (streaming and msg_type == "complete") or not streaming:
+ elif streaming and msg_type == "complete" or not streaming:
if (
chain_type == "tool_call"
or chain_type == "tool_call_result"
):
continue
-
saved_record = await self._save_bot_message(
tui_conv_id,
accumulated_text,
@@ -408,7 +366,7 @@ class TUIChatRoute(Route):
agent_stats,
refs,
)
- if saved_record and not client_disconnected:
+ if saved_record and (not client_disconnected):
saved_info = {
"type": "message_saved",
"data": {
@@ -444,30 +402,26 @@ class TUIChatRoute(Route):
"enable_streaming": enable_streaming,
"message_id": message_id,
},
- ),
+ )
)
-
message_parts_for_storage = strip_message_parts_path_fields(message_parts)
-
- await self.platform_history_mgr.insert(
+ mgr = self.platform_history_mgr
+ assert mgr is not None
+ await mgr.insert(
platform_id="tui",
user_id=tui_conv_id,
content={"type": "user", "message": message_parts_for_storage},
sender_id=username,
sender_name=username,
)
-
- response = cast(
- QuartResponse,
- await make_response(
- stream(),
- {
- "Content-Type": "text/event-stream",
- "Cache-Control": "no-cache",
- "Transfer-Encoding": "chunked",
- "Connection": "keep-alive",
- },
- ),
+ response = await make_response(
+ stream(),
+ {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Transfer-Encoding": "chunked",
+ "Connection": "keep-alive",
+ },
)
response.timeout = None
return response
@@ -477,40 +431,35 @@ class TUIChatRoute(Route):
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").to_json()
-
session_id = post_data.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").to_json()
-
username = g.get("username", "guest")
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").to_json()
if session.creator != username:
return Response().error("Permission denied").to_json()
-
message_type = (
MessageType.GROUP_MESSAGE.value
if session.is_group
else MessageType.FRIEND_MESSAGE.value
)
- umo = (
- f"{session.platform_id}:{message_type}:"
- f"{session.platform_id}!{username}!{session_id}"
- )
+ umo = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
stopped_count = active_event_registry.request_agent_stop_all(umo)
-
return Response().ok(data={"stopped_count": stopped_count}).to_json()
async def _delete_session_internal(self, session, username: str) -> None:
"""Delete a single session and all its related data."""
session_id = session.session_id
-
message_type = "GroupMessage" if session.is_group else "FriendMessage"
unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
- await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
-
- history_list = await self.platform_history_mgr.get(
+ conv_mgr = self.conv_mgr
+ assert conv_mgr is not None
+ await conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
+ mgr = self.platform_history_mgr
+ assert mgr is not None
+ history_list = await mgr.get(
platform_id=session.platform_id,
user_id=session_id,
page=1,
@@ -519,24 +468,24 @@ class TUIChatRoute(Route):
attachment_ids = self._extract_attachment_ids(history_list)
if attachment_ids:
await self._delete_attachments(attachment_ids)
-
- await self.platform_history_mgr.delete(
- platform_id=session.platform_id,
- user_id=session_id,
- offset_sec=99999999,
+ await mgr.delete(
+ platform_id=session.platform_id, user_id=session_id, offset_sec=99999999
)
-
try:
- await self.umop_config_router.delete_route(unified_msg_origin)
+ router = self.umop_config_router
+ if router is None:
+ logger.warning(
+ "UMOP config router not available during session cleanup"
+ )
+ else:
+ await router.delete_route(unified_msg_origin)
except ValueError:
logger.warning(
"Failed to delete UMO route %s during session cleanup.",
unified_msg_origin,
)
-
if session.platform_id == "tui":
tui_queue_mgr.remove_queues(session_id)
-
await self.db.delete_platform_session(session_id)
async def delete_tui_session(self):
@@ -545,15 +494,12 @@ class TUIChatRoute(Route):
if not session_id:
return Response().error("Missing key: session_id").to_json()
username = g.get("username", "guest")
-
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").to_json()
if session.creator != username:
return Response().error("Permission denied").to_json()
-
await self._delete_session_internal(session, username)
-
return Response().ok().to_json()
async def batch_delete_sessions(self):
@@ -563,17 +509,14 @@ class TUIChatRoute(Route):
return Response().error("Missing JSON body").to_json()
if not isinstance(post_data, dict):
return Response().error("Invalid JSON body: expected object").to_json()
-
session_ids = post_data.get("session_ids")
if not session_ids or not isinstance(session_ids, list):
return Response().error("Missing or invalid key: session_ids").to_json()
-
username = g.get("username", "guest")
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
sessions_by_id = {session.session_id: session for session in sessions}
deleted_count = 0
failed_items = []
-
for sid in session_ids:
session = sessions_by_id.get(sid)
if not session:
@@ -582,7 +525,6 @@ class TUIChatRoute(Route):
if session.creator != username:
failed_items.append({"session_id": sid, "reason": "permission denied"})
continue
-
try:
await self._delete_session_internal(session, username)
deleted_count += 1
@@ -590,7 +532,6 @@ class TUIChatRoute(Route):
except Exception:
logger.warning("Failed to delete session %s", sid)
failed_items.append({"session_id": sid, "reason": "internal_error"})
-
return (
Response()
.ok(
@@ -631,7 +572,6 @@ class TUIChatRoute(Route):
)
except Exception as e:
logger.warning(f"Failed to get attachments: {e}")
-
try:
await self.db.delete_attachments(attachment_ids)
except Exception as e:
@@ -640,13 +580,9 @@ class TUIChatRoute(Route):
async def new_session(self):
"""Create a new Platform session for TUI."""
username = g.get("username", "guest")
-
session = await self.db.create_platform_session(
- creator=username,
- platform_id="tui",
- is_group=0,
+ creator=username, platform_id="tui", is_group=0
)
-
return (
Response()
.ok(
@@ -661,9 +597,7 @@ class TUIChatRoute(Route):
async def get_sessions(self):
"""Get all Platform sessions for the current user filtered by TUI platform."""
username = g.get("username", "guest")
-
platform_id = request.args.get("platform_id", "tui")
-
sessions, _ = await self.db.get_platform_sessions_by_creator_paginated(
creator=username,
platform_id=platform_id,
@@ -671,11 +605,9 @@ class TUIChatRoute(Route):
page_size=100,
exclude_project_sessions=True,
)
-
sessions_data = []
for item in sessions:
session = item["session"]
-
sessions_data.append(
{
"session_id": session.session_id,
@@ -687,7 +619,6 @@ class TUIChatRoute(Route):
"updated_at": to_utc_isoformat(session.updated_at),
}
)
-
return Response().ok(data=sessions_data).to_json()
async def get_session(self):
@@ -695,61 +626,46 @@ class TUIChatRoute(Route):
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").to_json()
-
session = await self.db.get_platform_session_by_id(session_id)
platform_id = session.platform_id if session else "tui"
-
username = g.get("username", "guest")
project_info = await self.db.get_project_by_session(
session_id=session_id, creator=username
)
-
- history_ls = await self.platform_history_mgr.get(
- platform_id=platform_id,
- user_id=session_id,
- page=1,
- page_size=1000,
+ mgr = self.platform_history_mgr
+ assert mgr is not None
+ history_ls = await mgr.get(
+ platform_id=platform_id, user_id=session_id, page=1, page_size=1000
)
-
history_res = [history.model_dump() for history in history_ls]
-
response_data: dict[str, Any] = {
"history": history_res,
"is_running": self.running_convs.get(session_id, False),
}
-
if project_info:
response_data["project"] = {
"project_id": project_info.project_id,
"title": project_info.title,
"emoji": project_info.emoji,
}
-
return Response().ok(data=response_data).to_json()
async def update_session_display_name(self):
"""Update a Platform session's display name."""
post_data = await request.json
-
session_id = post_data.get("session_id")
display_name = post_data.get("display_name")
-
if not session_id:
return Response().error("Missing key: session_id").to_json()
if display_name is None:
return Response().error("Missing key: display_name").to_json()
-
username = g.get("username", "guest")
-
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").to_json()
if session.creator != username:
return Response().error("Permission denied").to_json()
-
await self.db.update_platform_session(
- session_id=session_id,
- display_name=display_name,
+ session_id=session_id, display_name=display_name
)
-
return Response().ok().to_json()
diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py
index f93800731..e8f1132d9 100644
--- a/astrbot/dashboard/server.py
+++ b/astrbot/dashboard/server.py
@@ -768,7 +768,7 @@ class AstrBotDashboard:
local_ips: list[IPv4Address | IPv6Address] = get_local_ip_addresses()
mode_label = "WebUI + API" if enable_webui else "API Server (WebUI 已分离)"
- parts = [f"\n ✨✨✨\n AstrBot v{VERSION} {mode_label} 已启动\n\n"]
+ parts: list[str] = [f"\n ✨✨✨\n AstrBot v{VERSION} {mode_label} 已启动\n\n"]
parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n")
diff --git a/astrbot/dashboard/utils.py b/astrbot/dashboard/utils.py
index 442061152..d8ef3623b 100644
--- a/astrbot/dashboard/utils.py
+++ b/astrbot/dashboard/utils.py
@@ -1,18 +1,14 @@
import base64
import traceback
from io import BytesIO
-from typing import cast
from astrbot.api import logger
-from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
from astrbot.core.knowledge_base.kb_helper import KBHelper
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
async def generate_tsne_visualization(
- query: str,
- kb_names: list[str],
- kb_manager: KnowledgeBaseManager,
+ query: str, kb_names: list[str], kb_manager: KnowledgeBaseManager
) -> str | None:
"""生成 t-SNE 可视化图片
@@ -30,81 +26,61 @@ async def generate_tsne_visualization(
import matplotlib
import numpy as np
- matplotlib.use("Agg") # 使用非交互式后端
+ matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.manifold import TSNE
except ImportError as e:
raise Exception(
- "缺少必要的库以生成 t-SNE 可视化。请安装 matplotlib 和 scikit-learn: {e}",
+ "缺少必要的库以生成 t-SNE 可视化。请安装 matplotlib 和 scikit-learn: {e}"
) from e
-
try:
- # 获取第一个知识库的向量数据
kb_helper: KBHelper | None = None
for kb_name in kb_names:
kb_helper = await kb_manager.get_kb_by_name(kb_name)
if kb_helper:
break
-
if not kb_helper:
logger.warning("未找到知识库")
return None
-
kb = kb_helper.kb
index_path = kb_helper.kb_dir / "index.faiss"
-
- # 读取 FAISS 索引
if not index_path.exists():
logger.warning(f"FAISS 索引不存在: {index_path!s}")
return None
-
index = faiss.read_index(str(index_path))
-
if index.ntotal == 0:
logger.warning("索引为空")
return None
-
- # 提取所有向量
logger.info(f"提取 {index.ntotal} 个向量用于可视化...")
if isinstance(index, faiss.IndexIDMap):
base_index = faiss.downcast_index(index.index)
if hasattr(base_index, "reconstruct_n"):
vectors = base_index.reconstruct_n(0, index.ntotal)
else:
- vectors = np.zeros((index.ntotal, index.d), dtype=np.float32)
+ dim = getattr(base_index, "d", 0)
+ vectors = np.zeros((index.ntotal, dim), dtype=np.float32)
for i in range(index.ntotal):
base_index.reconstruct(i, vectors[i])
elif hasattr(index, "reconstruct_n"):
vectors = index.reconstruct_n(0, index.ntotal)
else:
- vectors = np.zeros((index.ntotal, index.d), dtype=np.float32)
+ dim = getattr(index, "d", 0)
+ vectors = np.zeros((index.ntotal, dim), dtype=np.float32)
for i in range(index.ntotal):
index.reconstruct(i, vectors[i])
-
- # 获取查询向量
- vec_db = cast(FaissVecDB, kb_helper.vec_db)
+ vec_db = kb_helper.vec_db
embedding_provider = vec_db.embedding_provider
query_embedding = await embedding_provider.get_embedding(query)
query_vector = np.array([query_embedding], dtype=np.float32)
-
- # 合并所有向量和查询向量
all_vectors = np.vstack([vectors, query_vector])
-
- # t-SNE 降维
logger.info("开始 t-SNE 降维...")
perplexity = min(30, all_vectors.shape[0] - 1)
tsne = TSNE(n_components=2, random_state=42, perplexity=perplexity)
vectors_2d = tsne.fit_transform(all_vectors)
-
- # 分离知识库向量和查询向量
kb_vectors_2d = vectors_2d[:-1]
query_vector_2d = vectors_2d[-1]
-
- # 可视化
logger.info("生成可视化图表...")
plt.figure(figsize=(14, 10))
-
- # 绘制知识库向量
scatter = plt.scatter(
kb_vectors_2d[:, 0],
kb_vectors_2d[:, 1],
@@ -114,8 +90,6 @@ async def generate_tsne_visualization(
cmap="viridis",
label="Knowledge Base Vectors",
)
-
- # 绘制查询向量 红色 X
plt.scatter(
query_vector_2d[0],
query_vector_2d[1],
@@ -127,8 +101,6 @@ async def generate_tsne_visualization(
label="Query",
zorder=5,
)
-
- # 添加查询文本标注
plt.annotate(
"Query",
(query_vector_2d[0], query_vector_2d[1]),
@@ -138,11 +110,9 @@ async def generate_tsne_visualization(
bbox={"boxstyle": "round,pad=0.5", "fc": "yellow", "alpha": 0.7},
arrowprops={"arrowstyle": "->", "connectionstyle": "arc3,rad=0"},
)
-
plt.colorbar(scatter, label="Vector Index")
plt.title(
- f"t-SNE Visualization: Query in Knowledge Base\n"
- f"({index.ntotal} vectors, {index.d} dimensions, KB: {kb.kb_name})",
+ f"t-SNE Visualization: Query in Knowledge Base\n({index.ntotal} vectors, {getattr(index, 'd', 0)} dimensions, KB: {kb.kb_name})",
fontsize=14,
pad=20,
)
@@ -150,15 +120,12 @@ async def generate_tsne_visualization(
plt.ylabel("t-SNE Dimension 2", fontsize=12)
plt.grid(True, alpha=0.3)
plt.legend(fontsize=10, loc="upper right")
-
- # base64 编码图片返回
buffer = BytesIO()
plt.savefig(buffer, format="png", dpi=150, bbox_inches="tight")
plt.close()
buffer.seek(0)
img_base64 = base64.b64encode(buffer.read()).decode("utf-8")
return img_base64
-
except Exception as e:
logger.error(f"生成 t-SNE 可视化时出错: {e}")
logger.error(traceback.format_exc())
diff --git a/astrbot/rust/__init__.py b/astrbot/rust/__init__.py
deleted file mode 100644
index aa4b4bdc4..000000000
--- a/astrbot/rust/__init__.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""AstrBot Rust Core module.
-
-This module exposes the Rust core functionality via PyO3 bindings.
-"""
-
-from ._core import (
- PyAbpClient,
- PyOrchestrator,
- cli,
- get_abp_client,
- get_orchestrator,
-)
-
-__all__ = [
- "PyAbpClient",
- "PyOrchestrator",
- "cli",
- "get_abp_client",
- "get_orchestrator",
-]
diff --git a/astrbot/rust/_core.pyi b/astrbot/rust/_core.pyi
deleted file mode 100644
index bc70a52e9..000000000
--- a/astrbot/rust/_core.pyi
+++ /dev/null
@@ -1,35 +0,0 @@
-from typing import Any
-
-class PyOrchestrator:
- def __init__(self) -> None: ...
- def start(self) -> None: ...
- def stop(self) -> None: ...
- def is_running(self) -> bool: ...
- def register_star(self, name: str, handler: str) -> None: ...
- def unregister_star(self, name: str) -> None: ...
- def list_stars(self) -> list[str]: ...
- def record_activity(self) -> None: ...
- def get_stats(self, py: Any) -> dict[str, Any]: ...
- def set_protocol_connected(self, protocol: str, connected: bool) -> None: ...
- def get_protocol_status(self, protocol: str, py: Any) -> dict[str, Any] | None: ...
-
-class PyAbpClient:
- def __init__(self) -> None: ...
- def is_connected(self) -> bool: ...
- def register_in_process_plugin(self, name: str, version: str) -> None: ...
- def register_out_of_process_plugin(
- self,
- name: str,
- version: str,
- command: str | None = None,
- transport: str | None = None,
- ) -> None: ...
- def unregister_plugin(self, name: str) -> None: ...
- def list_plugins(self) -> list[str]: ...
- def get_plugin_info(self, name: str, py: Any) -> dict[str, Any] | None: ...
- def health_check(self, name: str) -> bool: ...
-
-# Module-level functions
-def get_orchestrator() -> PyOrchestrator: ...
-def get_abp_client() -> PyAbpClient: ...
-def cli(args: list[str]) -> None: ...
diff --git a/astrbot/tui/__init__.py b/astrbot/tui/__init__.py
deleted file mode 100644
index a9e9928dc..000000000
--- a/astrbot/tui/__init__.py
+++ /dev/null
@@ -1,18 +0,0 @@
-"""AstrBot TUI - Terminal User Interface for AstrBot."""
-
-from astrbot.tui.message_handler import (
- ChatResponse,
- MessageType,
- ParsedMessage,
- SSEMessageParser,
-)
-from astrbot.tui.screen import Screen, run_curses
-
-__all__ = [
- "ChatResponse",
- "MessageType",
- "ParsedMessage",
- "SSEMessageParser",
- "Screen",
- "run_curses",
-]
diff --git a/astrbot/tui/__main__.py b/astrbot/tui/__main__.py
deleted file mode 100644
index 0b76b0dac..000000000
--- a/astrbot/tui/__main__.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""AstrBot TUI - Entry point for python -m astrbot.tui"""
-
-import asyncio
-import sys
-
-
-def main(stdscr):
- """Main TUI entry point when running via python -m astrbot.tui."""
- try:
- from astrbot.cli.commands.tui_async import TUIClient
- from astrbot.tui.screen import Screen
-
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- scr = Screen(stdscr)
- client = TUIClient(
- screen=scr,
- host="http://localhost:6185",
- api_key=None,
- username="astrbot",
- password="astrbot",
- debug=False,
- )
- try:
- loop.run_until_complete(client.run_event_loop(stdscr))
- finally:
- loop.close()
- except ImportError as e:
- import curses
-
- curses.curs_set(1)
- stdscr.clear()
- stdscr.addstr(0, 0, f"Error importing TUI module: {e}", curses.A_BOLD)
- stdscr.addstr(2, 0, "Press any key to exit...")
- stdscr.refresh()
- stdscr.getch()
- sys.exit(1)
-
-
-if __name__ == "__main__":
- from astrbot.tui.screen import run_curses
-
- run_curses(main)
diff --git a/astrbot/tui/i18n.py b/astrbot/tui/i18n.py
deleted file mode 100644
index 071efd3c9..000000000
--- a/astrbot/tui/i18n.py
+++ /dev/null
@@ -1,171 +0,0 @@
-"""Internationalization support for AstrBot TUI.
-
-This module provides i18n support with Chinese and English languages.
-Language is auto-detected from environment or can be set manually.
-"""
-
-from __future__ import annotations
-
-import os
-from enum import Enum
-from functools import lru_cache
-
-
-class Language(Enum):
- """Supported languages."""
-
- ZH = "zh"
- EN = "en"
-
-
-# Translation dictionaries
-_TRANSLATIONS: dict[Language, dict[str, str]] = {
- Language.ZH: {
- # Welcome messages
- "welcome_title": "欢迎使用 AstrBot TUI",
- "welcome_local_mode": "本地测试模式",
- "welcome_instructions": "输入消息后按 Enter 发送, ESC 或 Ctrl+C 退出",
- "welcome_language": "语言已自动检测为中文",
- # Status messages
- "status_ready": "就绪",
- "status_connected": "已连接",
- "status_disconnected": "未连接",
- "status_processing": "处理中...",
- "status_sending": "发送中...",
- # Message indicators
- "indicator_user": "我",
- "indicator_bot": "AI",
- "indicator_system": "系统",
- "indicator_tool": "工具",
- "indicator_reasoning": "推理",
- # Input hints
- "input_prompt": "> ",
- "input_placeholder": "输入消息...",
- # Error messages
- "error_empty_message": "消息不能为空",
- "error_send_failed": "发送失败",
- "error_connection_lost": "连接已断开",
- "error_unknown": "未知错误",
- # Tool messages
- "tool_using": "使用工具中",
- "tool_completed": "工具执行完成",
- "tool_failed": "工具执行失败",
- # Reasoning messages
- "reasoning_thinking": "思考中...",
- "reasoning_reasoning": "推理中...",
- },
- Language.EN: {
- # Welcome messages
- "welcome_title": "Welcome to AstrBot TUI",
- "welcome_local_mode": "Local Testing Mode",
- "welcome_instructions": "Type your message and press Enter to send. ESC or Ctrl+C to exit.",
- "welcome_language": "Language auto-detected as English",
- # Status messages
- "status_ready": "Ready",
- "status_connected": "Connected",
- "status_disconnected": "Disconnected",
- "status_processing": "Processing...",
- "status_sending": "Sending...",
- # Message indicators
- "indicator_user": "Me",
- "indicator_bot": "AI",
- "indicator_system": "Sys",
- "indicator_tool": "Tool",
- "indicator_reasoning": "Reason",
- # Input hints
- "input_prompt": "> ",
- "input_placeholder": "Type a message...",
- # Error messages
- "error_empty_message": "Message cannot be empty",
- "error_send_failed": "Failed to send",
- "error_connection_lost": "Connection lost",
- "error_unknown": "Unknown error",
- # Tool messages
- "tool_using": "Using tool",
- "tool_completed": "Tool completed",
- "tool_failed": "Tool failed",
- # Reasoning messages
- "reasoning_thinking": "Thinking...",
- "reasoning_reasoning": "Reasoning...",
- },
-}
-
-
-@lru_cache(maxsize=1)
-def get_current_language() -> Language:
- """Get the current language based on environment or default.
-
- Detection order:
- 1. ASTRBOT_TUI_LANG environment variable (zh/en)
- 2. LANG environment variable (if contains zh/cn)
- 3. LC_ALL environment variable (if contains zh/cn)
- 4. Default to Chinese (most users are Chinese)
- """
- # Check explicit override first
- explicit = os.environ.get("ASTRBOT_TUI_LANG", "").lower()
- if explicit in ("zh", "en"):
- return Language.ZH if explicit == "zh" else Language.EN
-
- # Check LANG/LC_ALL for Chinese
- for env_var in ("LANG", "LC_ALL"):
- lang = os.environ.get(env_var, "").lower()
- if "zh" in lang or "cn" in lang:
- return Language.ZH
-
- # Default to Chinese for broader appeal
- return Language.ZH
-
-
-def set_language(lang: Language) -> None:
- """Set the current language (clears all translation caches)."""
- get_current_language.cache_clear()
- _t_cached.cache_clear()
- # Set environment variable for persistence
- os.environ["ASTRBOT_TUI_LANG"] = lang.value
-
-
-@lru_cache(maxsize=128)
-def _t_cached(translation_key: str, lang: Language) -> str:
- """Cached translation lookup."""
- return _TRANSLATIONS.get(lang, {}).get(translation_key, translation_key)
-
-
-def t(translation_key: str) -> str:
- """Get translation for the given key in the current language.
-
- Args:
- translation_key: Translation key (e.g., "welcome_title", "status_ready")
-
- Returns:
- Translated string, or the key itself if not found
- """
- return _t_cached(translation_key, get_current_language())
-
-
-def tr(translation_key: str) -> str:
- """Get translation (alias for t())."""
- return t(translation_key)
-
-
-class TUITranslations:
- """Translation accessor class for non-function contexts.
-
- Usage:
- translations = TUITranslations()
- print(translations.WELCOME_TITLE)
- """
-
- def __getattr__(self, key: str) -> str:
- return t(key)
-
- def __getitem__(self, key: str) -> str:
- return t(key)
-
- def get(self, key: str, default: str | None = None) -> str:
- """Get translation with default."""
- result = t(key)
- return default if result == key and default else result
-
-
-# Convenience instance
-translations = TUITranslations()
diff --git a/astrbot/tui/message_handler.py b/astrbot/tui/message_handler.py
deleted file mode 100644
index 3a9fc53f7..000000000
--- a/astrbot/tui/message_handler.py
+++ /dev/null
@@ -1,311 +0,0 @@
-"""Shared SSE message handler for AstrBot clients (WebChat, TUI, etc).
-
-This module provides a unified way to parse and handle SSE messages from the
-AstrBot chat API, supporting all message types including streaming responses.
-"""
-
-from __future__ import annotations
-
-import json
-from dataclasses import dataclass, field
-from enum import Enum
-from typing import Any
-
-
-class MessageType(Enum):
- """SSE message types from AstrBot API."""
-
- SESSION_ID = "session_id"
- PLAIN = "plain"
- IMAGE = "image"
- RECORD = "record"
- FILE = "file"
- TOOL_CALL = "tool_call"
- TOOL_CALL_RESULT = "tool_call_result"
- REASONING = "reasoning"
- AGENT_STATS = "agent_stats"
- AUDIO_CHUNK = "audio_chunk"
- COMPLETE = "complete"
- END = "end"
- MESSAGE_SAVED = "message_saved"
- ERROR = "error"
-
-
-@dataclass
-class ToolCall:
- """Represents a tool call in progress."""
-
- id: str
- name: str
- arguments: str | None = None
- result: str | None = None
- finished_ts: float | None = None
-
-
-@dataclass
-class ParsedMessage:
- """A parsed SSE message with type and data."""
-
- type: MessageType
- data: str
- raw: dict[str, Any] = field(default_factory=dict)
- chain_type: str | None = None
- streaming: bool = False
- message_id: str | None = None
-
-
-@dataclass
-class ChatResponse:
- """Complete chat response accumulated from SSE stream."""
-
- text: str = ""
- reasoning: str = ""
- tool_calls: dict[str, ToolCall] = field(default_factory=dict)
- agent_stats: dict[str, Any] = field(default_factory=dict)
- refs: dict[str, Any] = field(default_factory=dict)
- media_parts: list[dict[str, Any]] = field(default_factory=list)
- complete: bool = False
- session_id: str | None = None
- saved_message_id: str | None = None
- error: str | None = None
-
- def get_display_text(self) -> str:
- """Get the main text content for display."""
- return self.text
-
- def get_reasoning_display(self) -> str:
- """Get reasoning content formatted for display."""
- if not self.reasoning:
- return ""
- return f"[Reasoning]\n{self.reasoning}"
-
- def get_tool_calls_display(self) -> list[str]:
- """Get tool calls formatted for display."""
- results = []
- for tc in self.tool_calls.values():
- if tc.result:
- results.append(f"[Tool: {tc.name}]\n{tc.result}")
- else:
- results.append(f"[Tool: {tc.name}] (running...)")
- return results
-
- def get_stats_display(self) -> str:
- """Get agent stats formatted for display."""
- if not self.agent_stats:
- return ""
- parts = []
- for key, value in self.agent_stats.items():
- parts.append(f"{key}: {value}")
- return " | ".join(parts)
-
-
-class SSEMessageParser:
- """Parse SSE messages from AstrBot chat API.
-
- Usage:
- parser = SSEMessageParser()
- async for msg in parser.parse_stream(response):
- handle_message(msg)
- """
-
- def __init__(self) -> None:
- self._tool_calls: dict[str, ToolCall] = {}
- self._accumulated_text: str = ""
- self._accumulated_reasoning: str = ""
- self._accumulated_parts: list[dict[str, Any]] = []
-
- def reset(self) -> None:
- """Reset parser state for a new stream."""
- self._tool_calls = {}
- self._accumulated_text = ""
- self._accumulated_reasoning = ""
- self._accumulated_parts = []
-
- def parse_line(self, line: str) -> ParsedMessage | None:
- """Parse a single SSE data line.
-
- Args:
- line: A line starting with "data: "
-
- Returns:
- ParsedMessage if valid, None if skip-worthy
- """
- if not line.startswith("data: "):
- return None
-
- data_str = line[6:] # Remove "data: " prefix
- if not data_str:
- return None
-
- try:
- data = json.loads(data_str)
- except json.JSONDecodeError:
- return None
-
- msg_type_str = data.get("type", "")
- msg_type = self._get_message_type(msg_type_str)
- msg_data = data.get("data", "")
- chain_type = data.get("chain_type")
- streaming = data.get("streaming", False)
- message_id = data.get("message_id")
-
- return ParsedMessage(
- type=msg_type,
- data=msg_data,
- raw=data,
- chain_type=chain_type,
- streaming=streaming,
- message_id=message_id,
- )
-
- def _get_message_type(self, type_str: str) -> MessageType:
- """Map string type to MessageType enum."""
- try:
- return MessageType(type_str)
- except ValueError:
- return MessageType.PLAIN
-
- def process_message(self, msg: ParsedMessage) -> tuple[ChatResponse, bool]:
- """Process a parsed message and update accumulated response.
-
- Args:
- msg: The parsed message
-
- Returns:
- tuple of (accumulated_response, is_complete)
- """
- response = ChatResponse()
-
- if msg.type == MessageType.SESSION_ID:
- response.session_id = msg.raw.get("session_id")
- return response, False
-
- if msg.type == MessageType.AGENT_STATS:
- try:
- response.agent_stats = json.loads(msg.data)
- except json.JSONDecodeError:
- pass
- return response, False
-
- if msg.type == MessageType.REASONING:
- self._accumulated_reasoning += msg.data
- response.reasoning = self._accumulated_reasoning
- return response, False
-
- if msg.type == MessageType.TOOL_CALL:
- try:
- tool_call = json.loads(msg.data)
- tc = ToolCall(
- id=tool_call.get("id", ""),
- name=tool_call.get("name", ""),
- arguments=tool_call.get("arguments"),
- )
- self._tool_calls[tc.id] = tc
- self._accumulated_parts.append(
- {"type": "plain", "text": self._accumulated_text}
- )
- self._accumulated_text = ""
- except json.JSONDecodeError:
- pass
- response.tool_calls = self._tool_calls
- return response, False
-
- if msg.type == MessageType.TOOL_CALL_RESULT:
- try:
- tcr = json.loads(msg.data)
- tc_id = tcr.get("id")
- if tc_id in self._tool_calls:
- self._tool_calls[tc_id].result = tcr.get("result")
- self._tool_calls[tc_id].finished_ts = tcr.get("ts")
- self._accumulated_parts.append(
- {
- "type": "tool_call",
- "tool_calls": [self._tool_calls[tc_id].__dict__],
- }
- )
- self._tool_calls.pop(tc_id, None)
- except json.JSONDecodeError:
- pass
- response.tool_calls = self._tool_calls
- return response, False
-
- if msg.type == MessageType.PLAIN:
- if msg.chain_type == "tool_call":
- pass # Already handled above
- elif msg.chain_type == "reasoning":
- self._accumulated_reasoning += msg.data
- response.reasoning = self._accumulated_reasoning
- elif msg.streaming:
- self._accumulated_text += msg.data
- else:
- self._accumulated_text = msg.data
- response.text = self._accumulated_text
- return response, False
-
- if msg.type == MessageType.IMAGE:
- filename = msg.data.replace("[IMAGE]", "")
- self._accumulated_parts.append({"type": "image", "filename": filename})
- response.media_parts = self._accumulated_parts
- return response, False
-
- if msg.type == MessageType.RECORD:
- filename = msg.data.replace("[RECORD]", "")
- self._accumulated_parts.append({"type": "record", "filename": filename})
- response.media_parts = self._accumulated_parts
- return response, False
-
- if msg.type == MessageType.FILE:
- filename = msg.data.replace("[FILE]", "")
- self._accumulated_parts.append({"type": "file", "filename": filename})
- response.media_parts = self._accumulated_parts
- return response, False
-
- if msg.type == MessageType.COMPLETE:
- response.text = self._accumulated_text
- response.reasoning = self._accumulated_reasoning
- response.tool_calls = self._tool_calls
- response.complete = True
- self.reset()
- return response, True
-
- if msg.type == MessageType.END:
- response.text = self._accumulated_text
- response.complete = True
- self.reset()
- return response, True
-
- if msg.type == MessageType.MESSAGE_SAVED:
- response.saved_message_id = msg.raw.get("data", {}).get("id")
- return response, False
-
- return response, False
-
-
-async def parse_sse_stream(async_iterable, callback) -> ChatResponse:
- """Parse SSE stream and call callback for each message update.
-
- This is a convenience function for processing SSE streams.
-
- Args:
- async_iterable: Async iterable of SSE lines (e.g., response.aiter_lines())
- callback: Async function called with (ChatResponse, is_complete)
-
- Returns:
- Final ChatResponse when stream completes
- """
- parser = SSEMessageParser()
- final_response = ChatResponse()
-
- async for line in async_iterable:
- msg = parser.parse_line(line)
- if msg is None:
- continue
-
- response, is_complete = parser.process_message(msg)
- await callback(response, is_complete)
- final_response = response
-
- if is_complete:
- break
-
- return final_response
diff --git a/astrbot/tui/screen.py b/astrbot/tui/screen.py
deleted file mode 100644
index 7676283a6..000000000
--- a/astrbot/tui/screen.py
+++ /dev/null
@@ -1,341 +0,0 @@
-"""Curses screen management for AstrBot TUI - Modern Design."""
-
-from __future__ import annotations
-
-import curses
-from collections.abc import Callable
-from enum import Enum
-
-from astrbot.tui.i18n import t
-
-
-class ColorPair(Enum):
- WHITE = 1
- CYAN = 2
- GREEN = 3
- YELLOW = 4
- RED = 5
- MAGENTA = 6
- DIM = 7
- BOLD = 8
- HEADER_BG = 9
- HEADER_FG = 10
- USER_MSG = 11
- BOT_MSG = 12
- SYSTEM_MSG = 13
- INPUT_BG = 14
- STATUS_BG = 15
- BORDER = 16
- TOOL_MSG = 17
- REASONING_MSG = 18
-
-
-_COLOR_MAP = {
- ColorPair.WHITE: curses.COLOR_WHITE,
- ColorPair.CYAN: curses.COLOR_CYAN,
- ColorPair.GREEN: curses.COLOR_GREEN,
- ColorPair.YELLOW: curses.COLOR_YELLOW,
- ColorPair.RED: curses.COLOR_RED,
- ColorPair.MAGENTA: curses.COLOR_MAGENTA,
- ColorPair.DIM: curses.COLOR_WHITE,
- ColorPair.HEADER_BG: curses.COLOR_BLUE,
- ColorPair.HEADER_FG: curses.COLOR_WHITE,
- ColorPair.USER_MSG: curses.COLOR_GREEN,
- ColorPair.BOT_MSG: curses.COLOR_CYAN,
- ColorPair.SYSTEM_MSG: curses.COLOR_YELLOW,
- ColorPair.INPUT_BG: curses.COLOR_BLACK,
- ColorPair.STATUS_BG: curses.COLOR_BLUE,
- ColorPair.BORDER: curses.COLOR_CYAN,
- ColorPair.TOOL_MSG: curses.COLOR_MAGENTA,
- ColorPair.REASONING_MSG: curses.COLOR_BLUE,
-}
-
-# Box drawing characters
-BOX_VERT = "│"
-BOX_HORIZ = "─"
-BOX_TL = "┌"
-BOX_TR = "┐"
-BOX_BL = "└"
-BOX_BR = "┘"
-BOX_LT = "├"
-BOX_RT = "┤"
-BOX_BT = "┴"
-BOX_TT = "┬"
-BOX_CROSS = "┼"
-
-
-class Screen:
- def __init__(self, stdscr: curses.window):
- self.stdscr = stdscr
- self.height, self.width = stdscr.getmaxyx()
- self._header_height = 1
- self._input_height = 2
- self._status_height = 1
- self._chat_height = (
- self.height - self._header_height - self._input_height - self._status_height
- )
- self._header_win: curses.window | None = None
- self._chat_win: curses.window | None = None
- self._input_win: curses.window | None = None
- self._status_win: curses.window | None = None
- self._running = False
- self._color_pairs: dict[int, int] = {}
-
- def setup_colors(self) -> None:
- curses.start_color()
- curses.use_default_colors()
- curses.curs_set(1)
- curses.noecho()
- curses.cbreak()
- self.stdscr.keypad(True)
-
- for i, (name, fg) in enumerate(_COLOR_MAP.items(), start=1):
- if name in (ColorPair.HEADER_BG, ColorPair.INPUT_BG, ColorPair.STATUS_BG):
- curses.init_pair(i, fg, curses.COLOR_BLACK)
- elif name == ColorPair.DIM:
- curses.init_pair(i, fg, curses.COLOR_BLACK)
- self._color_pairs[name.value] = curses.color_pair(i) | curses.A_DIM
- elif name == ColorPair.BOLD:
- curses.init_pair(i, curses.COLOR_WHITE, curses.COLOR_BLACK)
- self._color_pairs[name.value] = curses.color_pair(i) | curses.A_BOLD
- else:
- curses.init_pair(i, fg, curses.COLOR_BLACK)
- self._color_pairs[name.value] = curses.color_pair(i)
-
- def get_color(self, pair: ColorPair) -> int:
- return self._color_pairs.get(pair.value, curses.color_pair(pair.value))
-
- def layout_windows(self) -> None:
- self.height, self.width = self.stdscr.getmaxyx()
- self._chat_height = max(
- 1,
- self.height
- - self._header_height
- - self._input_height
- - self._status_height,
- )
-
- self._header_win = curses.newwin(self._header_height, self.width, 0, 0)
- self._header_win.nodelay(True)
-
- self._chat_win = curses.newwin(
- self._chat_height, self.width, self._header_height, 0
- )
- self._chat_win.scrollok(False)
- self._chat_win.idlok(True)
- self._chat_win.keypad(True)
-
- self._input_win = curses.newwin(
- self._input_height, self.width, self._header_height + self._chat_height, 0
- )
- self._input_win.keypad(True)
- self._input_win.timeout(100)
-
- self._status_win = curses.newwin(
- self._status_height, self.width, self.height - self._status_height, 0
- )
- self._status_win.nodelay(True)
-
- self._running = True
-
- @property
- def chat_win(self):
- return self._chat_win
-
- @property
- def input_win(self):
- return self._input_win
-
- @property
- def status_win(self):
- return self._status_win
-
- @property
- def header_win(self):
- return self._header_win
-
- def draw_header(self) -> None:
- if not self._header_win:
- return
- self._header_win.clear()
- title = f" {t('welcome_title')} "
-
- try:
- self._header_win.bkgdset(curses.color_pair(ColorPair.HEADER_FG.value))
- self._header_win.erase()
- title_attr = curses.color_pair(ColorPair.HEADER_FG.value) | curses.A_BOLD
- self._header_win.addstr(0, 0, title, title_attr)
-
- if self.width > len(title) + 2:
- border_attr = self.get_color(ColorPair.HEADER_FG)
- remaining = self.width - len(title)
- self._header_win.addstr(
- 0, len(title), BOX_HORIZ * remaining, border_attr
- )
- except curses.error:
- pass
- self._header_win.refresh()
-
- def draw_border_line(self) -> None:
- """Draw the border line between chat and input."""
- if not self._chat_win:
- return
- # Draw bottom border of chat window
- try:
- border = BOX_TL + BOX_HORIZ * (self.width - 2) + BOX_TR
- self._chat_win.addstr(
- self._chat_height - 1, 0, border, self.get_color(ColorPair.BORDER)
- )
- except curses.error:
- pass
-
- def draw_chat_log(self, lines: list[tuple[str, str]]) -> None:
- if not self._chat_win:
- return
- self._chat_win.clear()
-
- y = 0
- max_y = self._chat_height - 1 # Leave room for border
-
- visible_lines = lines[-max_y:] if len(lines) > max_y else lines
-
- for sender, text in visible_lines:
- if y >= max_y:
- break
-
- # Get localized indicator
- indicator_map = {
- "user": t("indicator_user"),
- "bot": t("indicator_bot"),
- "tool": t("indicator_tool"),
- "reasoning": t("indicator_reasoning"),
- "system": t("indicator_system"),
- }
- indicator = indicator_map.get(sender, t("indicator_system"))
-
- if sender == "user":
- color = self.get_color(ColorPair.USER_MSG)
- elif sender == "bot":
- color = self.get_color(ColorPair.BOT_MSG)
- elif sender == "tool":
- color = self.get_color(ColorPair.TOOL_MSG)
- elif sender == "reasoning":
- color = self.get_color(ColorPair.REASONING_MSG)
- else:
- color = self.get_color(ColorPair.SYSTEM_MSG)
-
- max_text_width = self.width - 4
- if max_text_width < 1:
- continue
-
- words = text.split()
- current_line = ""
- lines_buffer = []
-
- for word in words:
- test_line = current_line + (" " if current_line else "") + word
- if len(test_line) <= max_text_width:
- current_line = test_line
- else:
- if current_line:
- lines_buffer.append(current_line)
- current_line = word
-
- if current_line:
- lines_buffer.append(current_line)
-
- for i, line_text in enumerate(lines_buffer):
- if y >= max_y:
- break
- try:
- if i == 0:
- self._chat_win.addstr(
- y, 0, f"{indicator} ", color | curses.A_BOLD
- )
- self._chat_win.addstr(y, 2, line_text, color)
- else:
- self._chat_win.addstr(y, 0, " ", color)
- self._chat_win.addstr(y, 2, line_text, color)
- except curses.error:
- pass
- y += 1
-
- self._chat_win.refresh()
-
- def draw_input(self, text: str, cursor_x: int) -> None:
- if not self._input_win:
- return
- self._input_win.clear()
-
- prompt = t("input_prompt")
- prompt_len = len(prompt)
- max_input_width = self.width - 2
-
- try:
- self._input_win.addstr(
- 0, 0, prompt, curses.color_pair(ColorPair.GREEN.value) | curses.A_BOLD
- )
-
- display_text = text[: max_input_width - prompt_len]
- self._input_win.addstr(
- 0, prompt_len, display_text, curses.color_pair(ColorPair.WHITE.value)
- )
-
- cursor_pos = min(cursor_x + prompt_len, self.width - 1)
- self._input_win.chgat(0, cursor_pos, 1, curses.A_REVERSE)
- except curses.error:
- pass
-
- self._input_win.refresh()
-
- def draw_status(self, status: str) -> None:
- if not self._status_win:
- return
-
- self._status_win.clear()
-
- status_text = status[: self.width - 2]
-
- try:
- attr = curses.color_pair(ColorPair.HEADER_FG.value) | curses.A_BOLD
- self._status_win.addstr(0, 0, " " + status_text, attr)
-
- remaining = self.width - len(status_text) - 1
- if remaining > 0:
- self._status_win.addstr(0, len(status_text) + 1, " " * remaining, attr)
- self._status_win.chgat(0, 0, self.width, attr)
- except curses.error:
- pass
-
- self._status_win.refresh()
-
- def draw_all(
- self, lines: list[tuple[str, str]], input_text: str, cursor_x: int, status: str
- ) -> None:
- self.draw_header()
- self.draw_border_line()
- self.draw_chat_log(lines)
- self.draw_input(input_text, cursor_x)
- self.draw_status(status)
-
- def resize(self) -> bool:
- self.height, self.width = self.stdscr.getmaxyx()
- self.layout_windows()
- return True
-
- def clear_status(self) -> None:
- if not self._status_win:
- return
- try:
- attr = curses.color_pair(ColorPair.HEADER_FG.value) | curses.A_BOLD
- self._status_win.addstr(0, 0, " " * self.width, attr)
- self._status_win.refresh()
- except curses.error:
- pass
-
-
-def run_curses(main_loop: Callable[[curses.window], None]):
- def _main(stdscr: curses.window):
- main_loop(stdscr)
-
- curses.wrapper(_main)
diff --git a/astrbot/tui/tui_app.py b/astrbot/tui/tui_app.py
deleted file mode 100644
index 1d66f8eb0..000000000
--- a/astrbot/tui/tui_app.py
+++ /dev/null
@@ -1,235 +0,0 @@
-"""AstrBot TUI Application - Main chat interface (sync version for testing).
-
-This module provides a basic TUI application without network connectivity,
-useful for testing the UI components and as a reference implementation.
-"""
-
-from __future__ import annotations
-
-import curses
-from dataclasses import dataclass, field
-from enum import Enum
-
-from astrbot.tui.i18n import TUITranslations, t
-from astrbot.tui.screen import Screen
-
-
-class MessageSender(Enum):
- USER = "user"
- BOT = "bot"
- SYSTEM = "system"
- TOOL = "tool"
- REASONING = "reasoning"
-
-
-# Translation accessor for templates
-tr = TUITranslations()
-
-
-# Mapping from sender to translation key
-_SENDER_TO_KEY = {
- MessageSender.USER: "indicator_user",
- MessageSender.BOT: "indicator_bot",
- MessageSender.SYSTEM: "indicator_system",
- MessageSender.TOOL: "indicator_tool",
- MessageSender.REASONING: "indicator_reasoning",
-}
-
-
-def get_indicator(sender: MessageSender) -> str:
- """Get the localized indicator string for a message sender."""
- return t(_SENDER_TO_KEY.get(sender, "indicator_system"))
-
-
-@dataclass
-class Message:
- sender: MessageSender
- text: str
- timestamp: float | None = None
-
-
-@dataclass
-class TUIState:
- messages: list[Message] = field(default_factory=list)
- input_buffer: str = ""
- cursor_x: int = 0
- status: str = field(default_factory=lambda: t("status_ready"))
- running: bool = True
- connected: bool = False
-
-
-class AstrBotTUI:
- """Main TUI application for AstrBot (local/testing version)."""
-
- def __init__(self, screen: Screen):
- self.screen = screen
- self.state = TUIState()
- self._input_history: list[str] = []
- self._history_index: int = -1
- self._max_history: int = 100
-
- def add_message(self, sender: MessageSender, text: str) -> None:
- """Add a message to the chat log."""
- self.state.messages.append(Message(sender=sender, text=text))
- if len(self.state.messages) > 1000:
- self.state.messages = self.state.messages[-1000:]
-
- def add_system_message(self, text: str) -> None:
- """Add a system message."""
- self.add_message(MessageSender.SYSTEM, text)
-
- def handle_key(self, key: int) -> bool:
- """Handle a keypress. Returns True if the application should continue running."""
- if key in (curses.KEY_EXIT, 27): # ESC or ctrl-c
- return False
-
- if key == curses.KEY_RESIZE:
- self.screen.resize()
- return True
-
- # Handle arrow keys for navigation
- if key == curses.KEY_LEFT:
- if self.state.cursor_x > 0:
- self.state.cursor_x -= 1
- elif key == curses.KEY_RIGHT:
- if self.state.cursor_x < len(self.state.input_buffer):
- self.state.cursor_x += 1
- elif key == curses.KEY_HOME:
- self.state.cursor_x = 0
- elif key == curses.KEY_END:
- self.state.cursor_x = len(self.state.input_buffer)
-
- # Handle backspace
- elif key in (curses.KEY_BACKSPACE, 127, 8):
- if self.state.cursor_x > 0:
- self.state.input_buffer = (
- self.state.input_buffer[: self.state.cursor_x - 1]
- + self.state.input_buffer[self.state.cursor_x :]
- )
- self.state.cursor_x -= 1
-
- # Handle delete
- elif key == curses.KEY_DC:
- if self.state.cursor_x < len(self.state.input_buffer):
- self.state.input_buffer = (
- self.state.input_buffer[: self.state.cursor_x]
- + self.state.input_buffer[self.state.cursor_x + 1 :]
- )
-
- # Handle Enter/Return - submit message
- elif key in (curses.KEY_ENTER, 10, 13):
- if self.state.input_buffer.strip():
- self._submit_message()
- return True
-
- # Handle history navigation (up/down arrows)
- elif key == curses.KEY_UP:
- if (
- self._input_history
- and self._history_index < len(self._input_history) - 1
- ):
- self._history_index += 1
- self.state.input_buffer = self._input_history[self._history_index]
- self.state.cursor_x = len(self.state.input_buffer)
- elif key == curses.KEY_DOWN:
- if self._history_index > 0:
- self._history_index -= 1
- self.state.input_buffer = self._input_history[self._history_index]
- self.state.cursor_x = len(self.state.input_buffer)
- elif self._history_index == 0:
- self._history_index = -1
- self.state.input_buffer = ""
- self.state.cursor_x = 0
-
- # Regular character input
- elif 32 <= key <= 126:
- char = chr(key)
- self.state.input_buffer = (
- self.state.input_buffer[: self.state.cursor_x]
- + char
- + self.state.input_buffer[self.state.cursor_x :]
- )
- self.state.cursor_x += 1
-
- # Clear input with Ctrl+L
- elif key == 12: # Ctrl+L
- self.state.input_buffer = ""
- self.state.cursor_x = 0
-
- return True
-
- def _submit_message(self) -> None:
- """Submit the current input buffer as a user message."""
- text = self.state.input_buffer.strip()
- if not text:
- return
-
- # Add to history
- self._input_history.insert(0, text)
- if len(self._input_history) > self._max_history:
- self._input_history = self._input_history[: self._max_history]
- self._history_index = -1
-
- # Add user message to chat
- self.add_message(MessageSender.USER, text)
-
- # Clear input
- self.state.input_buffer = ""
- self.state.cursor_x = 0
-
- # Process the message (echo back for testing)
- self._process_user_message(text)
-
- def _process_user_message(self, text: str) -> None:
- """Process user message and generate bot response (echo for testing)."""
- self.add_message(MessageSender.BOT, f"Echo: {text}")
-
- def render(self) -> None:
- """Render the current state to the screen."""
- lines = [(msg.sender.value, msg.text) for msg in self.state.messages]
-
- self.screen.draw_all(
- lines=lines,
- input_text=self.state.input_buffer,
- cursor_x=self.state.cursor_x,
- status=self.state.status,
- )
-
- def run_event_loop(self, stdscr: curses.window) -> None:
- """Main event loop for the TUI."""
- # Setup
- self.screen.setup_colors()
- self.screen.layout_windows()
-
- # Welcome message
- self.add_system_message(t("welcome_title"))
- self.add_system_message(t("welcome_local_mode"))
- self.add_system_message(t("welcome_instructions"))
-
- # Initial render
- self.render()
-
- # Main event loop
- while self.state.running:
- # Get input with timeout
- self.screen.input_win.nodelay(True)
- try:
- key = self.screen.input_win.getch()
- except curses.error:
- key = -1
-
- if key != -1:
- if not self.handle_key(key):
- self.state.running = False
- break
- self.render()
-
- # Small sleep to prevent CPU hogging
- curses.napms(10)
-
-
-def run_tui(stdscr: curses.window) -> None:
- """Entry point to run the TUI application."""
- screen = Screen(stdscr)
- app = AstrBotTUI(screen)
- app.run_event_loop(stdscr)
diff --git a/docs/en/startup.md b/docs/en/startup.md
index ae45d3bfa..5e75a988c 100644
--- a/docs/en/startup.md
+++ b/docs/en/startup.md
@@ -229,7 +229,7 @@ astrbot run --debug --log-level DEBUG
## Next Steps
-- [Configure LLM Providers](../providers/llm.md)
-- [Install Plugins](../use/plugin.md)
-- [Use Skills](../use/skills.md)
+- [Configure LLM Providers](providers/llm.md)
+- [Install Plugins](use/plugin.md)
+- [Use Skills](use/skills.md)
- [Deploy to Server](./deploy/astrbot/docker.md)
diff --git a/docs/scripts/sync_docs_to_wiki.py b/docs/scripts/sync_docs_to_wiki.py
index 5717f0381..6bcab51a9 100644
--- a/docs/scripts/sync_docs_to_wiki.py
+++ b/docs/scripts/sync_docs_to_wiki.py
@@ -470,7 +470,7 @@ def extract_title(content: str, source_path: str) -> str:
def build_language_index(language: str, page_names: set[str]) -> str:
config = LANG_CONFIG[language]
- lines = [config["index_title"], "", config["index_intro"], ""]
+ lines: list[str] = [config["index_title"], "", config["index_intro"], ""]
for label, page_name in config["index_links"]:
if page_name in page_names:
@@ -481,7 +481,7 @@ def build_language_index(language: str, page_names: set[str]) -> str:
def build_home_page(language: str) -> str:
config = LANG_CONFIG[language]
- lines = ["# AstrBot Wiki", "", config["home_intro"], ""]
+ lines: list[str] = ["# AstrBot Wiki", "", config["home_intro"], ""]
for label, target in config["home_links"]:
lines.append(f"- [{label}]({target})")
return normalize_content("\n".join(lines))
diff --git a/docs/zh/startup.md b/docs/zh/startup.md
index 54b15e90d..14fed5d69 100644
--- a/docs/zh/startup.md
+++ b/docs/zh/startup.md
@@ -229,7 +229,7 @@ astrbot run --debug --log-level DEBUG
## 下一步
-- [配置模型提供商](../providers/llm.md)
-- [安装插件](../use/plugin.md)
-- [使用技能](../use/skills.md)
+- [配置模型提供商](providers/llm.md)
+- [安装插件](use/plugin.md)
+- [使用技能](use/skills.md)
- [部署到服务器](./deploy/astrbot/docker.md)
diff --git a/examples/aur_mcp_server.py b/examples/aur_mcp_server.py
new file mode 100644
index 000000000..89990bda3
--- /dev/null
+++ b/examples/aur_mcp_server.py
@@ -0,0 +1,196 @@
+import aiohttp
+import asyncio
+from datetime import datetime
+import json
+from mcp.server.fastmcp import FastMCP
+
+# 初始化 FastMCP 服务器
+mcp = FastMCP("ArchLinux-Pkg-Search")
+
+async def process_aur_info_response(task_coro):
+ try:
+ resp = await task_coro
+ resp.raise_for_status()
+ data = await resp.json()
+ if isinstance(data, dict) and "results" in data and isinstance(data["results"], list):
+ return data
+ else:
+ return None
+ except Exception:
+ return None
+
+@mcp.tool()
+async def search_pkg(pkg_name: str, repo: str = None) -> str:
+ """
+ 搜索 Arch Linux 官方仓库和 AUR 的软件包。
+
+ Args:
+ pkg_name: 要搜索的包名 (例如 linux, yay)
+ repo: 可选,指定官方仓库 (例如 core, extra)
+ """
+ if repo:
+ repo = repo[0].upper() + repo[1:]
+
+ timeout = aiohttp.ClientTimeout(total=10)
+
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ # 1. 首先尝试搜索官方仓库
+ search_url = f"https://archlinux.org/packages/search/json/?name={pkg_name}"
+ if repo:
+ search_url += f"&repo={repo}"
+
+ try:
+ async with session.get(search_url) as resp:
+ resp.raise_for_status()
+ data = await resp.json()
+ results = data.get("results", [])
+
+ if results:
+ result = results[0]
+ last_update_str = "N/A"
+ if result.get("last_update"):
+ try:
+ dt_obj = datetime.fromisoformat(result["last_update"].replace("Z", "+00:00"))
+ last_update_str = dt_obj.strftime('%Y-%m-%d %H:%M:%S')
+ except ValueError:
+ last_update_str = result.get('last_update', 'N/A').replace("T", " ").replace("Z", "")
+
+ msg = (
+ f"仓库:{result.get('repo', 'N/A')}
+"
+ f"包名:{result.get('pkgname', 'N/A')}
+"
+ f"版本:{result.get('pkgver', 'N/A')}
+"
+ f"描述:{result.get('pkgdesc', 'N/A')}
+"
+ f"打包:{result.get('packager', 'N/A')}
+"
+ f"上游:{result.get('url', 'N/A')}
+"
+ f"更新日期:{last_update_str}"
+ )
+ return msg
+
+ except Exception as e:
+ pass # 官方仓库失败则继续查 AUR
+
+ # 2. 尝试 AUR
+ aur_suggest_url = f"https://aur.archlinux.org/rpc/v5/suggest/{pkg_name}"
+ suggestions = []
+ try:
+ async with session.get(aur_suggest_url) as resp:
+ resp.raise_for_status()
+ suggestions = await resp.json()
+ if not isinstance(suggestions, list):
+ suggestions = []
+ except Exception:
+ pass
+
+ if not suggestions:
+ suggestions = [pkg_name]
+
+ aur_info_base_url = "https://aur.archlinux.org/rpc/v5/info/"
+ target_pkg_info = None
+
+ if len(suggestions) == 1 or suggestions[0] == pkg_name:
+ target_name = suggestions[0]
+ aur_info_url = f"{aur_info_base_url}{target_name}"
+ try:
+ async with session.get(aur_info_url) as resp:
+ resp.raise_for_status()
+ search_map = await resp.json()
+ if search_map.get("results") and isinstance(search_map["results"], list) and len(search_map["results"]) > 0:
+ target_pkg_info = search_map["results"][0]
+ except Exception:
+ pass
+ else:
+ fetch_tasks = []
+ for suggestion in suggestions:
+ fetch_tasks.append(process_aur_info_response(session.get(f"{aur_info_base_url}{suggestion}")))
+
+ aur_responses = await asyncio.gather(*fetch_tasks)
+
+ best_result = None
+ max_votes = -1.0
+
+ for result_data in aur_responses:
+ if isinstance(result_data, Exception) or result_data is None:
+ continue
+
+ if result_data.get("results"):
+ pkg_info = result_data["results"][0]
+ try:
+ votes = float(pkg_info.get("NumVotes", 0.0) or 0.0)
+ except (ValueError, TypeError):
+ votes = 0.0
+ if votes > max_votes:
+ max_votes = votes
+ best_result = pkg_info
+
+ if best_result:
+ target_pkg_info = best_result
+
+ # 3. 格式化并返回 AUR 结果
+ if target_pkg_info:
+ maintainer = target_pkg_info.get("Maintainer") or "孤儿包"
+ out_of_date_ts = target_pkg_info.get("OutOfDate")
+ out_of_date_str = ""
+ if out_of_date_ts:
+ try:
+ out_of_date_dt = datetime.fromtimestamp(float(out_of_date_ts))
+ out_of_date_str = f"过期时间:{out_of_date_dt.strftime('%Y-%m-%d %H:%M:%S')}
+"
+ except (ValueError, TypeError, OSError):
+ pass
+
+ upstream_url = target_pkg_info.get("URL") or "无"
+ co_maintainers = target_pkg_info.get("CoMaintainers") or []
+ co_maintainers_str = ""
+ if co_maintainers and isinstance(co_maintainers, list):
+ co_maintainers_str = f" ( {' '.join(map(str, co_maintainers))} )"
+
+ last_modified_ts = target_pkg_info.get("LastModified")
+ last_modified_str = "N/A"
+ if last_modified_ts:
+ try:
+ last_modified_dt = datetime.fromtimestamp(float(last_modified_ts))
+ last_modified_str = last_modified_dt.strftime('%Y-%m-%d %H:%M:%S')
+ except (ValueError, TypeError, OSError):
+ pass
+
+ num_votes = 0.0
+ try:
+ num_votes = float(target_pkg_info.get("NumVotes", 0.0) or 0.0)
+ except (ValueError, TypeError):
+ pass
+
+ pkg_display_name = target_pkg_info.get('Name', 'N/A')
+
+ msg = (
+ f"仓库:AUR
+"
+ f"包名:{pkg_display_name}
+"
+ f"版本:{target_pkg_info.get('Version', 'N/A')}
+"
+ f"描述:{target_pkg_info.get('Description', 'N/A')}
+"
+ f"维护者:{maintainer}{co_maintainers_str}
+"
+ f"上游:{upstream_url}
+"
+ f"{out_of_date_str}"
+ f"更新时间:{last_modified_str}
+"
+ f"投票:{num_votes:.0f}
+"
+ f"AUR 链接:https://aur.archlinux.org/packages/{pkg_display_name}"
+ )
+ return msg
+
+ return f"没有在官方仓库或 AUR 中找到名为 '{pkg_name}' 的相关软件。"
+
+if __name__ == "__main__":
+ # 以 stdio 模式运行 MCP 服务器
+ mcp.run(transport='stdio')
diff --git a/openspec/agent-message.md b/openspec/agent-message.md
index 8bea0b1db..108dabec9 100644
--- a/openspec/agent-message.md
+++ b/openspec/agent-message.md
@@ -2,2695 +2,365 @@
## 概述
-AstrBot Agent 采用**双缓冲区 + 流控**的消息处理模型,实现消息的削峰填谷、限流保护和安全处理。
+本文档定义 Agent 层消息处理的设计规范与契约(语言中立)。目标是提供一套清晰的、可实现的消息流、缓冲、流控、工具调用与安全策略,使不同语言或运行时实现都能遵循统一语义与行为。
-**核心设计**:
-- **输入缓冲区**:用户消息暂存,按频率控制消费
-- **输出缓冲区**:回复消息暂存,按策略分发
-- **流控引擎**:根据 API 限制自动调节消费速率
-- **安全层**:防注入、防泄密、防误触
+注意事项:
+- 本文档只描述设计与接口语义,不绑定任何具体实现语言或特定库。
+- 所有数据结构应为可序列化格式(例如 JSON),便于跨进程或跨语言传输与测试。
+- 实现必须清楚描述并发语义、错误处理、资源释放与边界情况。
-> **⚠️ 实现说明**:本文档中的所有 Rust 代码块(```rust)是 Rust 核心运行时的**实现规范**,
-> 实际代码位于 `astrbot/rust/`(尚未提交源码)。
-> Python 胶水层通过 FFI 调用 Rust 核心,不得在 Python 层重复实现核心逻辑。
+---
-## 实现状态
+## 目录(概要)
-| 组件 | 状态 | 说明 |
-|------|------|------|
-| 双缓冲区模型 | ⚠️ 待实现 | 输入/输出缓冲区尚未 Rust 实现 |
-| 流控引擎 | ⚠️ 待实现 | 限流策略未迁移到 Rust |
-| 安全层 | ⚠️ 待实现 | 防注入/泄密未 Rust 实现 |
-| ToolRouter | ⚠️ 待实现 | Internal/MCP/Skills 路由未 Rust 实现 |
-| ACPAgentClient | ⚠️ 待实现 | ACP 调用未 Rust 实现 |
-| SkillExecutor | ⚠️ 待实现 | Skill 执行未 Rust 实现 |
-| Agent Loop | ⚠️ 待实现 | LLM Loop 未 Rust 实现 |
-
-> 相关: [ABP](abp.md)(插件协议), [ACP](acp.md)(Agent 通信), [MCP](mcp.md)(工具协议)
-
-## 架构图
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ Platform Adapter │
-│ (QQ / Telegram / Discord / ...) │
-└────────────────────────────┬────────────────────────────────────┘
- │ commit_event()
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ Input Message Buffer │
-│ ┌─────────────────────────────────────────────────────────┐ │
-│ │ UserQueue (per user/conversation) │ │
-│ │ - metadata: user_id, platform, timestamp, session_id │ │
-│ │ - messages: [msg1, msg2, ...] │ │
-│ └─────────────────────────────────────────────────────────┘ │
-│ │ │
-│ FlowControl │
-│ (rate limiter) │
-└───────────────────────────┼─────────────────────────────────────┘
- │ pull_messages()
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ Agent Core │
-│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
-│ │ Context │───▶│ LLM Loop │───▶│ Tool Call │ │
-│ │ Manager │ │ (step loop) │ │ Executor │ │
-│ └──────────────┘ └──────────────┘ └──────────────┘ │
-└───────────────────────────┬─────────────────────────────────────┘
- │ produce_result()
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ Output Buffer │
-│ ┌─────────────────────────────────────────────────────────┐ │
-│ │ ResultQueue (per session) │ │
-│ │ - content: string / stream │ │
-│ │ - format: plain / markdown / html │ │
-│ │ - strategy: streaming / segmented / full │ │
-│ └─────────────────────────────────────────────────────────┘ │
-│ │ │
-│ DispatchStrategy │
-│ (streaming / segmented / full) │
-└───────────────────────────┼─────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ Platform Adapter │
-│ (SendResult) │
-└─────────────────────────────────────────────────────────────────┘
-```
+1. 工具、技能与 Agent 协作体系
+2. 输入缓冲区(Input Buffer)
+3. 流控引擎(Flow Control)
+4. Agent 核心(Context 管理与运行时)
+5. 工具调用策略(Tool Calling Strategy)
+6. 安全层(Security)
+7. 权限模型(Permission)
+8. 输出缓冲区(Output Buffer)
+9. 记忆管理(Memory)
+10. 平台适配(Platform Adaptation)
+11. 配置汇总
+12. 错误处理与恢复
+13. 扩展点与插件接口
---
## 1. 工具、技能与 Agent 协作体系
-### 1.1 三层架构
+设计要点:
+- 将工具(internal)、外部工具(MCP/remote)、技能(skills)视为统一的“工具集”层级。Agent 在执行时向工具集发起调用,由工具路由器(Tool Router)负责选择与调度。
+- 支持多来源工具合并:本地内建工具、注册的外部 MCP 服务器、可加载的 skills。
+- 提供工具元数据(schema、并发限制、超时、权限要求),以支持运行时决策。
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ Agent Core (LLM Loop) │
-│ │
-│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
-│ │ Internal │ │ MCP │ │ Skills │ │
-│ │ Tools │ │ Tools │ │ │ │
-│ │ (Function │ │ (MCP │ │ (Pre-built │ │
-│ │ Tool) │ │ Client) │ │ Agent │ │
-│ │ │ │ │ │ Flows) │ │
-│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
-│ │ │ │ │
-│ └───────────────────┴───────────────────┘ │
-│ │ │
-│ Tool Executor │
-└──────────────────────────────┼──────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ Agent 协作层 │
-│ │
-│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
-│ │ 本地 │ │ 远程 │ │ 子 Agent │ │
-│ │ Subagent │ │ A2A Agent │ │ (MCP/A2A) │ │
-│ │ │ │ │ │ │ │
-│ └─────────────┘ └─────────────┘ └─────────────┘ │
-│ │
-│ ┌─────────────────────────────────────────────────────┐ │
-│ │ ACP 协议 (Agent 通信) │ │
-│ └─────────────────────────────────────────────────────┘ │
-└─────────────────────────────────────────────────────────────────┘
-```
+接口示例(语言中立签名):
+- `ToolRouter.register_internal_tool(name: str, schema: dict) -> None`
+- `ToolRouter.register_mcp_server(name: str, endpoint: dict) -> None`
+- `ToolRouter.route_tool_call(call: {name: str, arguments: dict}) -> {status: str, result: any}`
-### 1.2 工具来源
+工具选择策略应考虑:
+- 优先级(internal > skill > mcp 可配置)
+- 并发与速率限制
+- 依赖关系(某些工具需按顺序调用)
+- 权限与安全策略
-| 来源 | 协议 | 说明 |
-|------|------|------|
-| **Internal Tools** | 自定义 Python | `FunctionTool`/`ToolSet`,Star 插件注册 |
-| **MCP Tools** | MCP JSON-RPC 2.0 | 外部 MCP 服务器提供的工具 |
-| **Skills** | 自定义协议 | 预构建的 Agent 执行流程模板 |
-
-### 1.3 工具调用决策
-
-```rust
-pub struct ToolRouter {
- internal: ToolSet,
- mcp: HashMap>,
- skills: HashMap>,
-}
-
-impl ToolRouter {
- pub fn new(
- internal: ToolSet,
- mcp: HashMap>,
- skills: HashMap>,
- ) -> Self {
- Self { internal, mcp, skills }
- }
-
- /// 路由工具调用
- pub async fn route_tool_call(
- &self,
- tool_name: &str,
- arguments: Value,
- context: &mut AgentContext,
- ) -> Result {
- // 1. 检查内部工具
- if let Some(internal_tool) = self.internal.get_tool(tool_name) {
- return self.call_internal(internal_tool, arguments, context).await;
- }
-
- // 2. 检查 MCP 工具
- for (_, client) in &self.mcp {
- if client.has_tool(tool_name) {
- return client.call_tool(tool_name, arguments).await;
- }
- }
-
- // 3. 检查 Skills
- if let Some(skill) = self.skills.get(tool_name) {
- return skill.execute(tool_name, arguments, context).await;
- }
-
- Err(ToolError::NotFound(format!("Tool not found: {}", tool_name)).into())
- }
-}
-```
-
-### 1.4 Agent 协作(ACP 协议)
-
-```rust
-pub struct ACPAgentClient {
- connection: ACPConnection,
-}
-
-impl ACPAgentClient {
- /// 调用远程 Agent
- pub async fn call_agent(
- &self,
- agent_name: &str,
- action: &str,
- args: Value,
- stream: bool,
- ) -> Result {
- let request = ACPRequest {
- method: "agent/call".to_string(),
- params: json!({
- "agent": agent_name,
- "action": action,
- "args": args,
- }),
- };
-
- if stream {
- Ok(AgentResult::Stream(self.connection.stream_request(request).await?))
- } else {
- self.send_request(request).await
- }
- }
-
- /// 列出可用 Agent
- pub async fn list_agents(&self) -> Result> {
- let response = self.send_request(ACPRequest {
- method: "agent/list".to_string(),
- params: json!({}),
- }).await?;
-
- let agents = response.result["agents"]
- .as_array()
- .ok_or_else(|| ACPError::InvalidResponse("agents".to_string()))?;
-
- agents.iter()
- .map(|a| serde_json::from_value(a.clone()).map_err(|e| e.into()))
- .collect()
- }
-}
-```
-
-### 1.5 Skills 执行
-
-```rust
-pub struct SkillExecutor {
- registry: SkillRegistry,
-}
-
-impl SkillExecutor {
- pub fn new(registry: SkillRegistry) -> Self {
- Self { registry }
- }
-
- /// 执行 Skill
- pub async fn execute(
- &self,
- skill_name: &str,
- input_data: Value,
- context: &mut AgentContext,
- ) -> Result {
- let skill = self.registry.get(skill_name)
- .ok_or_else(|| SkillError::NotFound(format!("Skill not found: {}", skill_name)))?;
-
- // Skill 可以包含多个步骤
- let steps = skill.get_steps();
- let mut results = Vec::new();
-
- for step in steps {
- // 每个步骤可以是工具调用或 Agent 调用
- let result = match step.step_type.as_str() {
- "tool" => self.call_tool(&step.tool, &step.args).await,
- "agent" => self.call_agent(&step.agent, &step.action, &step.args).await,
- "llm" => self.call_llm(&step.prompt, context).await,
- _ => Err(SkillError::InvalidStep(step.step_type.clone()).into()),
- }?;
-
- results.push(result);
-
- // 检查是否需要停止
- if step.on_result == "stop_if_success" && results.last().map(|r| r.success).unwrap_or(false) {
- break;
- }
- }
-
- Ok(SkillResult {
- skill_name: skill_name.to_string(),
- steps: results.clone(),
- final_output: results.last().cloned(),
- })
- }
-}
-```
-
-### 1.6 配置
-
-```yaml
-# agent.yaml
-
-# 工具配置
-tools:
- # 内部工具
- internal:
- enabled: true
- max_per_request: 128
-
- # MCP 工具
- mcp:
- enabled: true
- servers: [] # MCP 服务器配置
-
- # Skills
- skills:
- enabled: true
- registry_path: "$XDG_DATA_HOME/astrbot/skills/"
-
-# Agent 协作配置
-agent_collaboration:
- # ACP 配置
- acp:
- enabled: true
- endpoints:
- - name: "local"
- type: "unix"
- path: "/run/astrbot/acp.sock"
-
- # 子 Agent 配置
- subagents:
- enabled: true
- max_parallel: 3
- timeout: 300
-
- # Agent 发现
- discovery:
- # 自动发现同进程内的 Subagent
- auto_discover_internal: true
-
- # 定期刷新远程 Agent 列表
- refresh_interval: 60
-```
+Agent 协作(ACP):
+- 定义 Agent-to-Agent 调用契约(例如 RPC/HTTP/消息队列),包含调用接口、超时、重试与鉴权。
+- 上层应能发现已注册 Agent 实例并列出其能力(capabilities)。
---
## 2. 输入缓冲区(Input Buffer)
-### 2.1 队列结构
+目标与语义:
+- 输入缓冲负责按会话或用户分隔消息队列,支持优先级、分段消息与限流。
+- 缓冲应支持批出队和基于策略的溢出处理。
-```rust
-use serde::{Deserialize, Serialize};
-use std::collections::VecDeque;
-use std::sync::Arc;
+核心数据模型(语言中立描述):
+- InputMessage(示例字段):
+ - `message_id` (string): 全局唯一 ID
+ - `platform` (string)
+ - `user_id` (string)
+ - `conversation_id` (string)
+ - `content` (object/string)
+ - `timestamp` (ISO8601)
+ - `metadata` (dict)
+ - `priority` (int)
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct InputMessage {
- /// 全局唯一 ID
- pub message_id: String,
- /// 平台标识
- pub platform: String,
- /// 用户 ID
- pub user_id: String,
- /// 会话 ID
- pub conversation_id: String,
- /// 消息内容
- pub content: MessageContent,
- /// 到达时间
- pub timestamp: f64,
- /// 扩展元数据
- pub metadata: HashMap,
- /// 优先级(越高越先处理)
- #[serde(default)]
- pub priority: i32,
-}
+缓冲配置要点:
+- `max_queue_size`: 每用户/会话的最大消息数
+- `max_message_age`: 消息超期策略
+- `overflow_strategy`: [DropOldest, DropNewest, Block]
+- `per_user_queue` / `per_conversation_queue` 可配置
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub enum MessageContent {
- Plain(String),
- Chain(Vec),
-}
+行为契约:
+- `enqueue_message(msg: InputMessage) -> message_id`:如果队列已满,按 `overflow_strategy` 决定是丢弃、阻塞或返回错误。
+- `dequeue_messages(limit: int) -> list[InputMessage]`:批量出队以提高吞吐。
+- `get_queue_depth(session_id: str) -> int`
+- `clear_queue(session_id: str) -> None`
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct MessageSegment {
- pub segment_type: String,
- pub content: String,
- #[serde(default)]
- pub metadata: HashMap,
-}
-
-pub struct UserMessageQueue {
- pub user_id: String,
- pub session_id: String,
- messages: VecDeque,
- metadata: HashMap,
- pub created_at: f64,
- pub updated_at: f64,
- pub max_size: usize,
- pub max_age: f64,
-}
-
-impl UserMessageQueue {
- pub fn new(user_id: String, session_id: String) -> Self {
- let now = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_secs_f64();
-
- Self {
- user_id,
- session_id,
- messages: VecDeque::new(),
- metadata: HashMap::new(),
- created_at: now,
- updated_at: now,
- max_size: 1000,
- max_age: 3600.0,
- }
- }
-
- pub fn push(&mut self, msg: InputMessage) {
- self.messages.push_back(msg);
- self.updated_at = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_secs_f64();
- }
-
- pub fn pop(&mut self) -> Option {
- self.messages.pop_front()
- }
-
- pub fn len(&self) -> usize {
- self.messages.len()
- }
-
- pub fn is_empty(&self) -> bool {
- self.messages.is_empty()
- }
-}
-```
-
-### 2.2 缓冲区配置
-
-```yaml
-# agent.yaml
-input_buffer:
- # 单用户队列最大消息数
- max_queue_size: 1000
-
- # 消息最大存活时间(秒)
- max_message_age: 3600
-
- # 超出限制时的处理策略
- overflow_strategy: "drop_oldest" # drop_oldest | drop_newest | block
-
- # 丢弃消息时的提示前缀
- overflow_hint: "[消息过多,部分早期消息已丢弃]"
-
- # 是否按用户隔离队列
- per_user_queue: true
-
- # 是否按会话隔离队列
- per_conversation_queue: true
-```
-
-### 2.3 溢出保护策略
-
-| 策略 | 说明 | 适用场景 |
-|------|------|----------|
-| `drop_oldest` | 丢弃最旧的消息,保留最新的 | 高频聊天,侧重时效性 |
-| `drop_newest` | 丢弃最新的消息,保留旧的 | 重要指令,不容丢失 |
-| `block` | 阻塞输入,直到队列有空位 | 重要对话,不容任何丢弃 |
-
-**溢出时的处理**:
-
-```rust
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum OverflowStrategy {
- DropOldest,
- DropNewest,
- Block,
-}
-
-pub struct InputBuffer {
- queues: HashMap>>,
- overflow_strategy: OverflowStrategy,
- overflow_hint: String,
-}
-
-impl InputBuffer {
- /// 添加消息到队列
- pub async fn add_message(&self, queue_id: &str, message: InputMessage) -> Result<(), BufferError> {
- let queue = self.queues.get(queue_id)
- .ok_or(BufferError::QueueNotFound)?;
-
- let mut queue = queue.lock().await;
-
- if queue.messages.len() >= queue.max_size {
- match self.overflow_strategy {
- OverflowStrategy::DropOldest => {
- if let Some(old_msg) = queue.messages.pop_front() {
- // 在丢弃的消息前插入提示
- let hint = InputMessage {
- message_id: "system_hint".into(),
- content: MessageContent::Plain(format!(
- "[{} 丢弃于 {}]",
- self.overflow_hint,
- old_msg.timestamp
- )),
- ..message.clone()
- };
- queue.messages.push_front(hint);
- }
- queue.messages.push_back(message);
- }
- OverflowStrategy::DropNewest => {
- // 丢弃新消息,不插入
- }
- OverflowStrategy::Block => {
- // 等待直到队列有空位
- while queue.messages.len() >= queue.max_size {
- let queue_clone = queue.clone();
- drop(queue);
- tokio::time::sleep(std::time::Duration::from_millis(100)).await;
- queue = queue_clone.lock().await;
- }
- queue.messages.push_back(message);
- }
- }
- } else {
- queue.messages.push_back(message);
- }
-
- Ok(())
- }
-}
-```
+实现建议:
+- 使用分段锁或细粒度并发结构避免全局锁。
+- 明确队列边界与持久化选项(内存/磁盘/数据库)。
---
## 3. 流控引擎(Flow Control)
-### 3.1 速率限制配置
+目标:
+- 在全局与会话层实现速率限制,保护上游 LLM 提供方与下游平台,避免突发流量导致超限或费用高涨。
-```yaml
-# agent.yaml
-flow_control:
- # 消费速率模式
- mode: "auto" # auto | manual
+常见策略:
+- 令牌桶(Token Bucket)与漏桶
+- 按 API-key / 会话 / 平台 的分层限流
+- 自动限流(根据错误率或响应延迟自适应)
- # 手动模式:每秒处理消息数
- manual_rate: 10
+接口契约:
+- `set_rate_limit(scope: str, requests: int, period_seconds: float) -> None`
+- `acquire(scope: str) -> bool`(非阻塞)
+- `wait_for_token(scope: str, timeout_seconds: float) -> bool`(阻塞/等待)
- # 自动模式:基于 LLM API 限制计算
- auto:
- # LLM API 每分钟请求限制
- api_rpm_limit: 60
+配置细节:
+- `safety_margin`:预留给突发重试的额外容量
+- `min_interval` / `max_interval`:自适应限流的上下界
- # 每次请求预计处理消息数
- messages_per_request: 5
-
- # 安全系数(留一定余量)
- safety_margin: 0.8
-
- # 最小消费间隔(秒)
- min_interval: 0.5
-
- # 最大消费间隔(秒)
- max_interval: 10
-```
-
-### 3.2 速率计算公式
-
-```
-effective_rate = min(api_rpm_limit * messages_per_request * safety_margin, 1/min_interval)
-consume_interval = 1 / effective_rate
-```
-
-**示例**:
-- API RPM = 60
-- 每请求处理 5 条消息
-- 安全系数 = 0.8
-- 有效速率 = 60 * 5 * 0.8 = 240 消息/分钟 = 4 消息/秒
-- 消费间隔 = 0.25 秒
-
-### 3.3 令牌桶实现
-
-```rust
-use std::sync::atomic::{AtomicFloat, Ordering};
-use std::sync::Arc;
-use std::time::{Duration, Instant};
-
-pub struct TokenBucket {
- rate: f64, // 每秒令牌数
- capacity: f64, // 桶容量
- tokens: AtomicFloat,
- last_update: std::sync::Mutex,
-}
-
-impl TokenBucket {
- pub fn new(rate: f64, capacity: f64) -> Self {
- Self {
- rate,
- capacity,
- tokens: AtomicFloat::new(capacity),
- last_update: std::sync::Mutex::new(Instant::now()),
- }
- }
-
- /// 获取令牌,返回需要等待的秒数
- pub fn acquire(&self, tokens: f64) -> f64 {
- let mut last_update = self.last_update.lock().unwrap();
- let elapsed = last_update.elapsed().as_secs_f64();
- let current_tokens = self.tokens.load(Ordering::SeqCst);
-
- let new_tokens = (current_tokens + elapsed * self.rate).min(self.capacity);
- self.tokens.store(new_tokens, Ordering::SeqCst);
- *last_update = Instant::now();
-
- if new_tokens >= tokens {
- self.tokens.fetch_sub(tokens as f32, Ordering::SeqCst);
- 0.0
- } else {
- (tokens - new_tokens) / self.rate
- }
- }
-
- /// 等待直到获取令牌
- pub async fn wait_and_acquire(&self, tokens: f64) {
- let wait = self.acquire(tokens);
- if wait > 0.0 {
- tokio::time::sleep(Duration::from_secs_f64(wait)).await;
- }
- }
-}
-```
-
-### 3.4 优先级调度
-
-```rust
-use std::collections::HashMap;
-use std::sync::Arc;
-
-pub struct PriorityScheduler {
- buckets: HashMap>,
- queues: HashMap>>,
-}
-
-impl PriorityScheduler {
- pub fn new() -> Self {
- Self {
- buckets: HashMap::new(),
- queues: HashMap::new(),
- }
- }
-
- /// 获取下一条待处理消息(按优先级)
- pub async fn next_message(&self) -> Option {
- let mut candidates = Vec::new();
-
- // 1. 收集所有非空队列
- for (user_id, queue) in &self.queues {
- let queue = queue.lock().await;
- if queue.is_empty() {
- continue;
- }
-
- // 2. 计算该用户的可用速率
- let Some(bucket) = self.buckets.get(user_id) else {
- continue;
- };
-
- // 3. 获取队首消息(peek,不移除)
- let msg = queue.messages.front()?.clone();
- candidates.push((msg, Arc::clone(bucket), user_id.clone()));
- }
-
- if candidates.is_empty() {
- return None;
- }
-
- // 4. 按优先级 + 可用性排序
- // 优先级相同时,优先处理令牌充足的
- candidates.sort_by(|a, b| {
- let a_priority = a.0.priority;
- let b_priority = b.0.priority;
- let a_tokens = a.1.tokens.load(Ordering::SeqCst);
- let b_tokens = b.1.tokens.load(Ordering::SeqCst);
- let a_score = (a_priority as f64, a_tokens);
- let b_score = (b_priority as f64, b_tokens);
- b_score.partial_cmp(&a_score).unwrap_or(std::cmp::Ordering::Equal)
- });
-
- // 5. 等待最紧急消息的令牌
- let (_msg, bucket, user_id) = &candidates[0];
- bucket.wait_and_acquire(1.0).await;
-
- // 6. 移除并返回
- let queue = self.queues.get(user_id)?;
- let mut queue = queue.lock().await;
- queue.pop()
- }
-}
-```
+实现建议:
+- 在高并发场景下使用原子操作或专用速率库实现高性能令牌桶。
+- 将统计和指标暴露用于监控与告警。
---
## 4. Agent 核心(Agent Core)
-### 4.1 上下文管理(Context Manager)
+职责:
+- 管理用户会话上下文(context)、系统提示、工具与内存(memory)。
+- 执行 Agent 的消息处理循环:接收输入 → 权限检查 → 安全过滤 → 工具调用决策 → LLM 调用 → 输出分发。
-```rust
-use serde::{Deserialize, Serialize};
-use std::sync::Arc;
+核心概念:
+- AgentContext:
+ - `messages`: 历史消息列表(按时间排序)
+ - `system_prompt`: 系统级提示
+ - `tools`: 可用工具清单与元数据
+ - `memory`: 与会话关联的记忆项
+ - `metadata`: 额外上下文信息
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct AgentContext {
- /// 消息历史
- pub messages: Vec,
- /// 系统提示
- pub system_prompt: String,
- /// 可用工具
- pub tools: Vec,
- /// 记忆存储
- pub memory: Arc,
- /// 扩展元数据
- pub metadata: HashMap,
-}
+行为契约:
+- `ContextManager.build_context(agent_id, recent_messages, limit) -> context_payload`
+- 支持上下文压缩(compress)策略以控制 tokens/长度(例如 summarize、truncate_by_turns 等)
+- 明确并文档化 `max_context_length` 的单位(tokens / characters / turns)
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct Message {
- pub role: String,
- pub content: String,
- #[serde(default)]
- pub metadata: HashMap,
-}
-
-#[derive(Debug, Clone)]
-pub struct ContextConfig {
- pub max_context_tokens: usize,
- pub compress_threshold: f64,
- pub keep_recent_messages: usize,
-}
-
-pub struct ContextManager {
- config: ContextConfig,
- tool_registry: Arc,
-}
-
-impl ContextManager {
- pub fn new(config: ContextConfig, tool_registry: Arc) -> Self {
- Self { config, tool_registry }
- }
-
- /// 构建 Agent 执行上下文
- pub async fn build_context(
- &self,
- queue: &UserMessageQueue,
- memory: Arc,
- ) -> Result {
- // 1. 从队列获取消息
- let raw_messages: Vec = queue.messages.iter().cloned().collect();
-
- // 2. 应用安全过滤
- let filtered_messages = self.apply_security_filters(raw_messages)?;
-
- // 3. 构建消息列表
- let messages = self.build_message_list(filtered_messages)?;
-
- // 4. 检查是否需要压缩
- let total_tokens = self.estimate_tokens(&messages);
- let messages = if total_tokens > self.config.max_context_tokens {
- self.compress_context(messages, memory.clone()).await?
- } else {
- messages
- };
-
- // 5. 添加系统提示
- let system_prompt = self.build_system_prompt()?;
-
- // 6. 获取可用工具
- let tools = self.tool_registry.list_tools().await?;
-
- Ok(AgentContext {
- messages,
- system_prompt,
- tools,
- memory,
- metadata: HashMap::new(),
- })
- }
-
- /// 压缩上下文
- async fn compress_context(
- &self,
- messages: Vec,
- memory: Arc,
- ) -> Result, ContextError> {
- let keep = self.config.keep_recent_messages;
-
- // 保留最近 N 条消息
- let recent: Vec = messages.into_iter().rev().take(keep).collect();
- let history: Vec = messages.into_iter().rev().skip(keep).collect();
-
- // 摘要历史消息并存入记忆
- if !history.is_empty() {
- let summary = self.summarize(&history)?;
- memory.add(Message {
- role: "system".into(),
- content: format!("[历史摘要] {}", summary),
- metadata: HashMap::from([("type".into(), "summary".into())]),
- }).await?;
- }
-
- Ok(recent)
- }
-}
-```
-
-### 4.2 上下文配置
-
-```yaml
-# agent.yaml
-context:
- # 最大上下文 token 数
- max_context_tokens: 128000
-
- # 触发压缩的阈值(比例)
- compress_threshold: 0.85
-
- # 压缩后保留的最近消息数
- keep_recent_messages: 6
-
- # 压缩提供者(为空则使用主 Provider)
- compress_provider_id: ""
-
- # 压缩提示词
- compress_instruction: |
- 请简洁地总结对话要点,保留关键信息如:
- - 用户的主要需求或问题
- - 已确定的方案或结论
- - 未完成的任务
-
- # 消息保留策略
- retention:
- # 保留最近 N 小时内的原始消息
- recent_hours: 24
-
- # 超出后转为摘要存储
- summarize_after: true
-```
+实现建议:
+- 提供异步构建上下文接口(以避免阻塞事件循环)
+- 将上下文管理与存储分离(缓存 + 后端存储)
---
## 5. 工具调用策略(Tool Calling Strategy)
-### 5.1 工具调用最佳实践
+目标:
+- 定义工具调用的执行策略(并行/顺序/失败重试/超时/回退)。
+- 管理工具依赖、分组与并行度。
-```yaml
-# agent.yaml
-tool_calling:
- # 工具调用策略
- strategy: "smart" # eager | sequential | smart
+配置要点:
+- `strategy`: e.g., "sequential", "parallel", "dependency-aware"
+- `max_calls_per_request`
+- `timeout`(每个工具调用)
+- `max_retries`, `retry_backoff`
+- `parallel_calls`, `max_parallel_calls`
- # 每次请求最大工具调用数
- max_calls_per_request: 128
+行为契约:
+- `execute_tools(request_id: str, calls: list[ToolCall]) -> list[ToolResult]`
+- 工具调用结果应包含:`id`, `name`, `status`(success/fail/timeout), `result`, `error`(可序列化)
- # 工具调用超时(秒)
- timeout: 60
+工具选择策略示例:
+- 优先最近使用(recency boost)
+- 按相关度和能力(schema matching)选择 best-fit 工具
+- 避免超过并发与配额限制
- # 工具调用失败重试次数
- max_retries: 3
-
- # 是否并行调用独立工具
- parallel_calls: true
-
- # 并行调用最大数量
- max_parallel_calls: 5
-
- # 工具结果的最大 token 数(截断)
- max_result_tokens: 4096
-
- # 是否在工具调用后立即返回中间结果
- stream_intermediate: true
-```
-
-### 5.2 工具调用流程
-
-```rust
-use async_trait::async_trait;
-
-#[derive(Debug, Clone)]
-pub struct ToolCall {
- pub id: String,
- pub name: String,
- pub arguments: HashMap,
-}
-
-#[derive(Debug)]
-pub struct ToolResult {
- pub id: String,
- pub name: String,
- pub result: Result,
-}
-
-#[derive(Debug, thiserror::Error)]
-pub enum ToolError {
- #[error("Tool not found: {0}")]
- NotFound(String),
- #[error("Execution failed: {0}")]
- ExecutionFailed(String),
- #[error("Timeout")]
- Timeout,
-}
-
-pub struct ToolCallingPolicy {
- config: ToolCallingConfig,
- tool_executor: Arc,
-}
-
-impl ToolCallingPolicy {
- /// 执行工具调用
- pub async fn execute_tools(
- &self,
- llm_response: &LLMResponse,
- context: &AgentContext,
- ) -> Result, ToolError> {
- // 1. 解析工具调用请求
- let tool_calls = &llm_response.tool_calls;
-
- if tool_calls.is_empty() {
- return Ok(Vec::new());
- }
-
- // 2. 按策略分组
- let groups = self.group_by_dependency(tool_calls);
-
- let mut results = Vec::new();
-
- // 3. 按组执行
- for group in groups {
- letannels = if self.can_parallel(&group) {
- // 并行执行
- self.execute_parallel(group, context).await?
- } else {
- // 串行执行
- self.execute_sequential(group, context).await?
- };
-
- results.extend(group_results);
-
- // 4. 检查是否超过限制
- if results.len() >= self.config.max_calls_per_request {
- break;
- }
- }
-
- Ok(results)
- }
-
- /// 按依赖关系分组
- fn group_by_dependency(&self, tool_calls: &[ToolCall]) -> Vec> {
- let mut groups = Vec::new();
- let mut current_group = Vec::new();
-
- for call in tool_calls {
- // 检查是否依赖前一个工具的结果
- if !current_group.is_empty() && call.depends_on_previous {
- current_group.push(call.clone());
- } else {
- if !current_group.is_empty() {
- groups.push(current_group);
- }
- current_group = vec![call.clone()];
- }
- }
-
- if !current_group.is_empty() {
- groups.push(current_group);
- }
-
- groups
- }
-
- /// 检查是否可以并行执行
- fn can_parallel(&self, group: &[ToolCall]) -> bool {
- self.config.parallel_calls && group.iter().all(|c| !c.depends_on_previous)
- }
-
- /// 并行执行
- async fn execute_parallel(
- &self,
- calls: Vec,
- context: &AgentContext,
- ) -> Result, ToolError> {
- let futures = calls.into_iter().map(|call| {
- self.execute_single(call, context)
- });
-
- let results = futures::future::join_all(futures).await;
-
- Ok(results.into_iter().map(|r| r.unwrap()).collect())
- }
-
- /// 串行执行
- async fn execute_sequential(
- &self,
- calls: Vec,
- context: &AgentContext,
- ) -> Result, ToolError> {
- let mut results = Vec::new();
-
- for call in calls {
- let result = self.execute_single(call, context).await?;
- results.push(result);
- }
-
- Ok(results)
- }
-}
-```
-
-### 5.3 工具选择策略
-
-```rust
-pub struct ToolSelector {
- max_tools_per_request: usize,
- prefer_recent: bool,
-}
-
-impl ToolSelector {
- pub fn new(max_tools_per_request: usize, prefer_recent: bool) -> Self {
- Self {
- max_tools_per_request,
- prefer_recent,
- }
- }
-
- /// 选择最相关的工具
- pub fn select_tools(
- &self,
- available_tools: &[Tool],
- query: &str,
- context: &AgentContext,
- ) -> Vec {
- // 1. 计算工具与查询的相关性
- let mut scored: Vec<(f64, Tool)> = available_tools
- .iter()
- .map(|tool| (self.calculate_relevance(tool, query, context), tool.clone()))
- .collect();
-
- // 2. 排序并截取
- scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
- let selected: Vec = scored.into_iter().take(self.max_tools_per_request).map(|(_, t)| t).collect();
-
- // 3. 如果启用了最近使用优先
- if self.prefer_recent {
- self.boost_recent(selected, context)
- } else {
- selected
- }
- }
-
- /// 计算相关性分数
- fn calculate_relevance(&self, tool: &Tool, query: &str, context: &AgentContext) -> f64 {
- let mut score = 0.0;
-
- // 工具名称匹配
- if query.to_lowercase().split_whitespace().any(|w| tool.name.to_lowercase().contains(w)) {
- score += 0.3;
- }
-
- // 工具描述匹配
- if !tool.description.is_empty() {
- let query_words: std::collections::HashSet<&str> =
- query.to_lowercase().split_whitespace().collect();
- let desc_words: std::collections::HashSet<&str> =
- tool.description.to_lowercase().split_whitespace().collect();
- let overlap = query_words.intersection(&desc_words).count();
- score += overlap as f64 * 0.1;
- }
-
- // 最近使用过的工具加权
- if let Some(recent_tools) = context.metadata.get("recent_tools") {
- if recent_tools.contains(&tool.name) {
- score += 0.2;
- }
- }
-
- score
- }
-
- /// 最近使用优先
- fn boost_recent(&self, mut tools: Vec, context: &AgentContext) -> Vec {
- let recent_tools = context.metadata.get("recent_tools");
- tools.sort_by(|a, b| {
- let a_recent = recent_tools.map(|r| r.contains(&a.name)).unwrap_or(false);
- let b_recent = recent_tools.map(|r| r.contains(&b.name)).unwrap_or(false);
- b_recent.cmp(&a_recent)
- });
- tools
- }
-}
-```
+实现建议:
+- 提供工具调用审计日志,支持回放与重试。
+- 在并行执行时以安全方式聚合结果并保证顺序语义(如需要)。
---
## 6. 安全层(Security Layer)
-### 6.1 安全配置
+目标:
+- 在输入与输出路径实现注入检测、内容过滤、敏感信息屏蔽与泄露防护。
-```yaml
-# agent.yaml
-security:
- # 防注入配置
- injection:
- # 启用防注入
- enable: true
+策略:
+- 使用可配置的检测规则集合(正则、关键字、模式、ML-based classifier)
+- 在发现注入时根据策略选择动作:`block`, `warn`, `sanitize`, `redact`
+- 对输出执行隐私检测(例如屏蔽 secrets、标识个人信息)
- # 检测模式
- mode: "strict" # strict | moderate | permissive
+接口契约:
+- `SecurityFilter.filter_messages(messages: list, mode: str) -> {filtered_messages, detections}`
+- `SecurityFilter.filter_output(output: str) -> {safe_output, detections}`
- # 注入模式识别
- patterns:
- - name: "role_play_injection"
- regex: "(?i)(you are now|forget previous|ignore all)"
- severity: "high"
-
- - name: "system_prompt_leak"
- regex: "(?i)(repeat your? (system|initial) (prompt|instructions))"
- severity: "high"
-
- - name: "code_injection"
- regex: "(?i)(```(system|prompt|instructor))"
- severity: "medium"
-
- # 触发时的处理策略
- on_detect: "sanitize" # sanitize | block | warn
-
- # 是否记录检测日志
- log_detections: true
-
- # 内容过滤配置
- content_filter:
- # 启用内容过滤
- enable: true
-
- # 过滤级别
- level: "standard" # strict | standard | minimal
-
- # 敏感词列表(文件路径或内联)
- blocklist: []
-
- # 替换字符
- replacement: "[已过滤]"
-
- # 泄密防护
- leakage_prevention:
- # 阻止 Agent 读取敏感文件模式
- blocked_file_patterns:
- - "**/.env"
- - "**/secrets.yaml"
- - "**/*password*"
- - "**/.git/credentials"
-
- # 阻止 Agent 输出敏感信息模式
- blocked_output_patterns:
- - "(?i)api[_-]?key"
- - "(?i)secret"
- - "(?i)password"
-
- # 替换为占位符
- placeholder: "[REDACTED]"
-```
-
-### 6.2 安全过滤器实现
-
-```rust
-use regex::Regex;
-use std::collections::HashMap;
-
-#[derive(Debug, Clone)]
-pub struct Detection {
- pub name: String,
- pub severity: String,
- pub matched: Vec,
-}
-
-pub struct SecurityFilter {
- config: SecurityConfig,
- compiled_patterns: Vec<(String, Regex, String)>,
-}
-
-impl SecurityFilter {
- pub fn new(config: SecurityConfig) -> Result {
- let compiled_patterns = config
- .injection
- .patterns
- .iter()
- .map(|p| {
- let regex = Regex::new(&p.regex)?;
- Ok((p.name.clone(), regex, p.severity.clone()))
- })
- .collect::, _>>()?;
-
- Ok(Self { config, compiled_patterns })
- }
-
- /// 过滤输入消息
- pub fn filter_messages(&self, messages: Vec) -> Vec {
- let mut filtered = Vec::new();
-
- for mut msg in messages {
- // 1. 内容过滤
- if self.config.content_filter.enable {
- msg.content = self.filter_content(msg.content);
- }
-
- // 2. 注入检测
- if self.config.injection.enable {
- let detections = self.detect_injection(&msg.content);
-
- if !detections.is_empty() {
- let action = self.handle_injection(&detections, &mut msg);
- if action == "skip" {
- continue;
- }
- }
- }
-
- filtered.push(msg);
- }
-
- filtered
- }
-
- /// 过滤输出内容
- pub fn filter_output(&self, content: String, context: &AgentContext) -> String {
- // 泄密防护 - 移除敏感信息
- if let Some(ref leakage) = self.config.leakage_prevention {
- self.redact_sensitive(content, leakage)
- } else {
- content
- }
- }
-
- /// 检测注入攻击
- fn detect_injection(&self, content: &str) -> Vec {
- let mut detections = Vec::new();
-
- for (name, pattern, severity) in &self.compiled_patterns {
- if let Some(matched) = pattern.find(content) {
- detections.push(Detection {
- name: name.clone(),
- severity: severity.clone(),
- matched: pattern.captures_iter(content).map(|c| c[0].to_string()).collect(),
- });
- }
- }
-
- detections
- }
-
- /// 处理注入检测
- fn handle_injection(&self, detections: &[Detection], message: &mut InputMessage) -> &str {
- let high_severity = detections.iter().any(|d| d.severity == "high");
-
- if high_severity && self.config.injection.on_detect == "block" {
- tracing::warn!("Blocked injection: {:?}", detections);
- return "skip";
- }
-
- if self.config.injection.on_detect == "sanitize" {
- // 消毒处理
- for detection in detections {
- message.content = self.filter_content(message.content.clone());
- }
- return "sanitize";
- }
-
- "allow"
- }
-
- /// 内容过滤
- fn filter_content(&self, content: String) -> String {
- if !self.config.content_filter.enable {
- return content;
- }
-
- let mut result = content;
- for pattern in &self.config.content_filter.blocklist {
- if let Ok(regex) = Regex::new(pattern) {
- result = regex.replace_all(&result, self.config.content_filter.replacement.as_str()).to_string();
- }
- }
- result
- }
-
- /// 移除敏感信息
- fn redact_sensitive(&self, content: String, leakage: &LeakagePrevention) -> String {
- let mut result = content;
- for pattern in &leakage.blocked_output_patterns {
- if let Ok(regex) = Regex::new(pattern) {
- result = regex.replace_all(&result, leakage.placeholder.as_str()).to_string();
- }
- }
- result
- }
-}
-```
+实现建议:
+- 将规则编译为高效匹配结构,避免在高吞吐下成为瓶颈。
+- 为检测提供事件与审计日志,便于事后分析。
---
## 7. 权限模型(Permission Model)
-### 7.1 设计原则
+设计目标:
+- 提供基于角色的权限模型(RBAC),支持命令级权限、会话级白名单/黑名单与资源配额。
-遵循 **Unix 哲学**,权限模型采用类似 `rwx` 的能力(Capability)设计:
+要点:
+- 角色(Owner, Admin, Member, Guest, Blocked)
+- 权限项(capabilities)可细粒度到 API/命令/工具
+- 会话级别覆盖全局策略
-| 原则 | 说明 |
-|------|------|
-| **最小权限** | 只授予完成任务所需的最小权限集 |
-| **能力继承** | 高权限自动包含低权限的能力 |
-| **可组合** | 权限可以灵活组合,适应不同场景 |
-| **可委托** | 支持权限的委托和回收 |
+接口契约:
+- `RoleManager.get_role(user_id, conversation_id) -> Role`
+- `PermissionMiddleware.check_message(event, context) -> PermissionResult {allowed: bool, reason: str}`
-### 7.2 角色定义
-
-```rust
-/// 角色枚举,类比 Unix 用户组
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-#[repr(u8)]
-pub enum Role {
- Owner = 0o700, // 超级管理员/拥有者
- Admin = 0o600, // 普通管理员
- Member = 0o400, // 普通成员
- Guest = 0o100, // 访客(受限)
- Blocked = 0o000, // 被封禁
-}
-
-bitflags::bitflags! {
- /// 权限枚举,类比 rwx
- pub struct Permission: u16 {
- // 基础权限
- const READ = 0o400; // 读取权限
- const WRITE = 0o200; // 写入权限
- const EXECUTE = 0o100; // 执行权限
-
- // 消息权限
- const SEND_MESSAGE = 0o040; // 发送消息
- const SEND_MEDIA = 0o020; // 发送媒体
- const SEND_COMMAND = 0o010; // 发送命令
-
- // 管理权限
- const MANAGE_MEMBER = 0o004; // 管理成员
- const MANAGE_CONFIG = 0o002; // 管理配置
- const MANAGE_PERMISSION = 0o001; // 管理权限
-
- // 特殊权限
- const BOT_ADMIN = 0o700; // Bot 管理员(全权限)
- const OWNER_ONLY = 0o100; // 仅拥有者可用
- }
-}
-
-impl Role {
- /// 检查角色是否拥有指定权限
- pub fn has_permission(&self, permission: Permission) -> bool {
- let role_bits = self.bits();
- (role_bits & permission.bits()) == permission.bits()
- }
-
- /// 获取角色的权限位
- fn bits(&self) -> u16 {
- *self as u16
- }
-}
-```
-
-### 7.3 能力矩阵
-
-```
-┌──────────────────┬───────┬───────┬────────┬────────┬──────────┐
-│ 能力 │ OWNER │ ADMIN │ MEMBER │ GUEST │ BLOCKED │
-├──────────────────┼───────┼───────┼────────┼────────┼──────────┤
-│ 读取消息 │ ✓ │ ✓ │ ✓ │ ✓ │ ✗ │
-│ 发送普通消息 │ ✓ │ ✓ │ ✓ │ ✓ │ ✗ │
-│ 发送媒体 │ ✓ │ ✓ │ ✓ │ ✗ │ ✗ │
-│ 发送斜杠命令 │ ✓ │ ✓ │ ✓ │ ✗ │ ✗ │
-│ 使用管理员命令 │ ✓ │ ✓ │ ✗ │ ✗ │ ✗ │
-│ 管理成员 │ ✓ │ ✓ │ ✗ │ ✗ │ ✗ │
-│ 修改配置 │ ✓ │ ✗ │ ✗ │ ✗ │ ✗ │
-│ 转让所有权 │ ✓ │ ✗ │ ✗ │ ✗ │ ✗ │
-│ 踢出 Bot │ ✓ │ ✗ │ ✗ │ ✗ │ ✗ │
-└──────────────────┴───────┴───────┴────────┴────────┴──────────┘
-```
-
-### 7.4 权限检查流程
-
-```rust
-use async_trait::async_trait;
-
-#[async_trait]
-pub trait PermissionCheck {
- async fn check_message(
- &self,
- event: &InputMessage,
- context: &AgentContext,
- ) -> PermissionResult;
-}
-
-pub struct PermissionMiddleware {
- role_config: RoleConfig,
- command_permissions: HashMap,
-}
-
-#[derive(Debug)]
-pub struct PermissionResult {
- pub allowed: bool,
- pub reason: Option,
-}
-
-impl PermissionMiddleware {
- /// 检查消息权限
- async fn check_message(
- &self,
- event: &InputMessage,
- context: &AgentContext,
- ) -> PermissionResult {
- // 1. 获取发送者角色
- let role = self
- .get_user_role(&event.user_id, &event.conversation_id)
- .await;
-
- // 2. 检查基础消息权限
- if !role.has_permission(Permission::SEND_MESSAGE) {
- return PermissionResult {
- allowed: false,
- reason: Some("用户被禁止发送消息".into()),
- };
- }
-
- // 3. 检查媒体权限
- if event.has_media && !role.has_permission(Permission::SEND_MEDIA) {
- return PermissionResult {
- allowed: false,
- reason: Some("用户被禁止发送媒体".into()),
- };
- }
-
- // 4. 检查命令权限
- if event.is_command {
- let cmd_perm = self
- .command_permissions
- .get(&event.command_name)
- .copied()
- .unwrap_or(Permission::EXECUTE);
-
- if !role.has_permission(cmd_perm) {
- return PermissionResult {
- allowed: false,
- reason: Some(format!("用户无权执行命令: {}", event.command_name)),
- };
- }
- }
-
- PermissionResult { allowed: true, reason: None }
- }
-}
-```
-
-### 7.5 命令权限配置
-
-```yaml
-# agent.yaml
-permissions:
- # 默认角色权限
- default_role: "member"
-
- # 角色能力定义
- roles:
- owner:
- capabilities: 0o700
- inherits: ["admin"]
-
- admin:
- capabilities: 0o600
- inherits: ["member"]
-
- member:
- capabilities: 0o400
- inherits: ["guest"]
-
- guest:
- capabilities: 0o100
- inherits: []
-
- blocked:
- capabilities: 0o000
- inherits: []
-
- # 斜杠命令权限
- commands:
- # 公开命令(所有人均可使用)
- public:
- - "/help"
- - "/status"
- - "/ping"
-
- # 成员命令(member 及以上)
- member:
- - "/search"
- - "/weather"
- - "/translate"
-
- # 管理员命令(admin 及以上)
- admin:
- - "/kick"
- - "/ban"
- - "/mute"
- - "/warn"
- - "/config"
-
- # 拥有者命令(仅 owner)
- owner:
- - "/transfer"
- - "/delete"
- - "/backup"
- - "/reload"
-
- # 权限继承配置
- inheritance:
- enabled: true
- max_depth: 5 # 最大继承深度,防止循环
-```
-
-### 7.6 用户角色管理
-
-```rust
-#[async_trait]
-pub trait RoleManager: Send + Sync {
- /// 获取用户在特定会话中的角色
- async fn get_role(
- &self,
- user_id: &str,
- conversation_id: &str,
- ) -> Role;
-
- /// 设置用户角色(需要相应权限)
- async fn set_role(
- &self,
- user_id: &str,
- conversation_id: &str,
- role: Role,
- operator_id: &str,
- ) -> Result<(), PermissionDenied>;
-
- /// 转让所有权
- async fn transfer_ownership(
- &self,
- conversation_id: &str,
- new_owner_id: &str,
- ) -> Result<(), PermissionDenied>;
-}
-
-pub struct SqliteRoleManager {
- pool: SqlitePool,
-}
-
-#[derive(Debug, thiserror::Error)]
-pub enum PermissionDenied {
- #[error("权限不足: {0}")]
- Insufficient(String),
- #[error("无法设置比自己更高的权限")]
- CannotElevate,
-}
-
-#[async_trait]
-impl RoleManager for SqliteRoleManager {
- async fn get_role(
- &self,
- user_id: &str,
- conversation_id: &str,
- ) -> Role {
- // 1. 检查全局管理员
- if self.is_global_admin(user_id).await {
- return Role::Owner;
- }
-
- // 2. 检查会话特定角色
- if let Some(role_data) = self.storage.get_user_role(user_id, conversation_id).await {
- return Role::from_bits(role_data.role);
- }
-
- // 3. 返回默认角色
- Role::Member
- }
-
- async fn set_role(
- &self,
- user_id: &str,
- conversation_id: &str,
- role: Role,
- operator_id: &str,
- ) -> Result<(), PermissionDenied> {
- let operator_role = self.get_role(operator_id, conversation_id).await;
-
- // 检查操作者权限
- if role.bits() > operator_role.bits() {
- return Err(PermissionDenied::CannotElevate);
- }
-
- self.storage
- .set_user_role(user_id, conversation_id, role.bits())
- .await;
-
- Ok(())
- }
-}
-```
-
-### 7.7 会话级权限配置
-
-```rust
-#[derive(Debug, Clone)]
-pub struct ConversationPermissions {
- pub conversation_id: String,
-
- // 基础权限
- pub default_role: Role,
- pub allow_guest_read: bool,
- pub allow_guest_send: bool,
-
- // 功能开关
- pub allow_media: bool,
- pub allow_commands: bool,
- pub allow_ai_responses: bool,
-
- // 限制
- pub max_message_length: usize,
- pub max_messages_per_minute: usize,
- pub max_commands_per_minute: usize,
-
- // 白名单/黑名单
- pub whitelist: Vec,
- pub blacklist: Vec,
-}
-
-impl ConversationPermissions {
- /// 检查用户是否允许执行操作
- pub fn check_user_allowed(&self, user_id: &str, permission: Permission) -> bool {
- if self.blacklist.contains(&user_id.to_string()) {
- return false;
- }
-
- if !self.whitelist.is_empty() && !self.whitelist.contains(&user_id.to_string()) {
- return false;
- }
-
- true
- }
-}
-```
-
-### 7.8 权限事件
-
-```rust
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum PermissionEvent {
- RoleChanged,
- PermissionDenied,
- UserBanned,
- UserUnbanned,
- CommandBlocked,
- OwnershipTransferred,
-}
-
-#[derive(Debug, Clone)]
-pub struct PermissionAuditLog {
- pub event: PermissionEvent,
- pub operator_id: String,
- pub target_id: String,
- pub conversation_id: String,
- pub details: HashMap,
- pub timestamp: i64,
-}
-```
-
-### 7.9 与 Unix 的类比
-
-```
-┌─────────────────┬────────────────────────┐
-│ Unix 概念 │ AstrBot 对应 │
-├─────────────────┼────────────────────────┤
-│ 用户 (User) │ 用户 (User) │
-│ 用户组 (Group) │ 会话 (Conversation) │
-│ root 用户 │ Owner (拥有者) │
-│ sudo 用户 │ Admin (管理员) │
-│ 普通用户 │ Member (成员) │
-│ 访客 │ Guest (访客) │
-│ 文件权限 rwx │ 能力 (Capability) │
-│ chmod │ set_role │
-│ chown │ transfer_ownership │
-│ /etc/passwd │ Role Storage │
-└─────────────────┴────────────────────────┘
-```
+实现建议:
+- 提供默认策略并允许动态配置、继承与角色映射。
+- 在权限拒绝处返回可展示的用户提示语以改善用户体验。
---
## 8. 输出缓冲区(Output Buffer)
-### 8.1 队列结构
+职责:
+- 管理对外发送的消息队列与分发策略(streaming, segmented, full)。
+- 提供按会话的结果队列、流式发送以及平台适配。
-```rust
-use serde::{Deserialize, Serialize};
-use std::collections::VecDeque;
-use tokio::sync::mpsc;
+数据模型:
+- OutputMessage:
+ - `session_id`, `content`, `format` (plain/markdown/html), `strategy` (streaming/segmented/full), `metadata`
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct OutputMessage {
- pub session_id: String,
- pub content: OutputContent,
- pub format: OutputFormat,
- pub strategy: OutputStrategy,
- #[serde(default)]
- pub metadata: HashMap,
-}
+接口契约:
+- `enqueue_result(session_id, result) -> result_id`
+- `dequeue_result(session_id) -> OutputMessage | None`
+- `set_dispatch_strategy(session_id, strategy)`
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub enum OutputContent {
- Text(String),
- Stream(mpsc::Receiver),
-}
+输出策略:
+- Streaming:分片推送,适合实时交互
+- Segmented:根据语言/句子边界分段发送
+- Full:一次性发送完整响应
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "lowercase")]
-pub enum OutputFormat {
- Plain,
- Markdown,
- Html,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "lowercase")]
-pub enum OutputStrategy {
- Streaming,
- Segmented,
- Full,
-}
-
-pub struct ResultQueue {
- pub session_id: String,
- results: VecDeque,
- max_size: usize,
- allow_streaming: bool,
-}
-
-impl ResultQueue {
- pub fn new(session_id: String) -> Self {
- Self {
- session_id,
- results: VecDeque::new(),
- max_size: 100,
- allow_streaming: true,
- }
- }
-
- pub fn push(&mut self, msg: OutputMessage) {
- if self.results.len() >= self.max_size {
- self.results.pop_front();
- }
- self.results.push_back(msg);
- }
-
- pub fn pop(&mut self) -> Option {
- self.results.pop_front()
- }
-
- pub fn len(&self) -> usize {
- self.results.len()
- }
-}
-```
-
-### 8.2 输出策略
-
-```yaml
-# agent.yaml
-output:
- # 默认输出策略
- default_strategy: "streaming" # streaming | segmented | full
-
- # 流式配置
- streaming:
- # 启用流式
- enable: true
-
- # 流式 Chunk 大小(字符数)
- chunk_size: 20
-
- # Chunk 之间的间隔(秒)
- chunk_interval: 0.05
-
- # 智能分段配置
- segmented:
- # 启用智能分段
- enable: true
-
- # 触发分段的字数阈值
- threshold: 500
-
- # 分段方式
- mode: "sentence" # sentence | word_count | regex
-
- # 按句子分段时的最小长度
- min_segment_length: 50
-
- # 分段正则(当 mode=regex)
- split_regex: "[。!?;\n]+"
-
- # 段落之间的随机间隔(秒)
- random_interval: "0.5,2.0"
-
- # 是否在分段前添加省略号
- add_ellipsis: true
-
- # 平台适配
- platform_adaptation:
- # 平台与策略映射
- strategy_by_platform:
- telegram: "segmented" # Telegram 有字数限制
- discord: "segmented" # Discord 也有限制
- qq: "segmented"
- webchat: "streaming" # WebChat 支持流式
-
- # 平台消息长度限制
- max_length_by_platform:
- telegram: 4096
- discord: 2000
- qq: 500
-
- # 输出缓冲配置
- buffer:
- # 最大缓冲消息数
- max_size: 100
-
- # 消息最大存活时间(秒)
- max_age: 300
-
- # 溢出策略
- overflow_strategy: "drop_oldest"
-```
-
-### 8.3 分段器实现
-
-```rust
-use regex::Regex;
-use std::time::Duration;
-
-pub struct SmartSegmenter {
- config: SegmentedConfig,
-}
-
-impl SmartSegmenter {
- pub fn new(config: SegmentedConfig) -> Self {
- Self { config }
- }
-
- /// 将内容分段
- pub fn segment(&self, content: &str) -> Vec {
- if content.len() < self.config.threshold {
- return vec![content.to_string()];
- }
-
- match self.config.mode.as_str() {
- "sentence" => self.split_by_sentence(content),
- "word_count" => self.split_by_word_count(content),
- "regex" => self.split_by_regex(content),
- _ => vec![content.to_string()],
- }
- }
-
- /// 按句子分段
- fn split_by_sentence(&self, content: &str) -> Vec {
- let regex = Regex::new(&self.config.split_regex).unwrap_or_else(|_| Regex::new("").unwrap());
- let sentences: Vec<&str> = regex.split(content).collect();
-
- let mut segments = Vec::new();
- let mut current = Vec::new();
- let mut current_len = 0;
-
- for sentence in sentences {
- if sentence.trim().is_empty() {
- continue;
- }
-
- current.push(sentence);
- current_len += sentence.len();
-
- if current_len >= self.config.threshold {
- let segment = current.join("");
- let segment = if self.config.add_ellipsis && !segments.is_empty() {
- format!("...{}", segment.trim_start())
- } else {
- segment
- };
- segments.push(segment);
- current.clear();
- current_len = 0;
- }
- }
-
- // 处理剩余内容
- if !current.is_empty() {
- let remaining = current.join("");
- if !remaining.trim().is_empty() {
- let remaining = if self.config.add_ellipsis && !segments.is_empty() {
- format!("...{}", remaining.trim_start())
- } else {
- remaining
- };
- segments.push(remaining);
- }
- }
-
- segments
- }
-
- /// 按字数分段
- fn split_by_word_count(&self, content: &str) -> Vec {
- let chars: Vec = content.chars().collect();
- let mut segments = Vec::new();
- let mut current = String::new();
-
- for c in chars {
- current.push(c);
- if current.len() >= self.config.threshold {
- segments.push(current.clone());
- current.clear();
- }
- }
-
- if !current.is_empty() {
- segments.push(current);
- }
-
- segments
- }
-
- /// 按正则分段
- fn split_by_regex(&self, content: &str) -> Vec {
- let regex = Regex::new(&self.config.split_regex).unwrap_or_else(|_| Regex::new("").unwrap());
- regex.split(content).map(|s| s.to_string()).collect()
- }
-
- /// 生成随机间隔
- fn random_interval(&self) -> Duration {
- let parts: Vec = self.config
- .random_interval
- .split(',')
- .filter_map(|s| s.trim().parse().ok())
- .collect();
-
- if parts.len() >= 2 {
- let min = parts[0];
- let max = parts[1];
- let duration = min + (max - min) * rand::random::();
- Duration::from_secs_f64(duration)
- } else {
- Duration::from_millis(500)
- }
- }
-}
-```
-
-### 8.4 流式输出器
-
-```rust
-pub struct StreamingOutput {
- config: StreamingConfig,
-}
-
-impl StreamingOutput {
- pub fn new(config: StreamingConfig) -> Self {
- Self { config }
- }
-
- /// 流式输出内容
- pub async fn stream(&self, content: &str, mut sender: F)
- where
- F: FnMut(String) -> Fut,
- Fut: Future