Compare commits

..

2 Commits

Author SHA1 Message Date
Soulter 394dcf3199 chore: ruff format 2025-12-04 14:24:03 +08:00
Soulter e6deb46332 feat: integrate MySQL support and enhance database management
- Added MySQL database implementation with connection settings and session management.
- Introduced a new AstrBotMySQLSettings class for configuration.
- Updated database helper functions to support both SQLite and MySQL.
- Enhanced platform statistics retrieval with time series data for both database types.
- Refactored existing SQLite methods to align with new database structure and functionality.

closes: #3848
2025-12-04 14:22:54 +08:00
278 changed files with 5593 additions and 14301 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
zip -r dist.zip dist
- name: Archive production artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v5
with:
name: dist-without-markdown
path: |
-58
View File
@@ -1,58 +0,0 @@
name: Smoke Test
on:
push:
branches:
- master
paths-ignore:
- 'README*.md'
- 'changelogs/**'
- 'dashboard/**'
pull_request:
workflow_dispatch:
jobs:
smoke-test:
name: Run smoke tests
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install UV package manager
run: |
pip install uv
- name: Install dependencies
run: |
uv sync
timeout-minutes: 15
- name: Run smoke tests
run: |
uv run main.py &
APP_PID=$!
echo "Waiting for application to start..."
for i in {1..60}; do
if curl -f http://localhost:6185 > /dev/null 2>&1; then
echo "Application started successfully!"
kill $APP_PID
exit 0
fi
sleep 1
done
echo "Application failed to start within 30 seconds"
kill $APP_PID 2>/dev/null || true
exit 1
timeout-minutes: 2
-90
View File
@@ -1,90 +0,0 @@
# CONTRIBUTING
## 贡献指南
首先,感谢您花时间做出贡献!❤️
所有类型的贡献都受到鼓励和重视。有关不同的帮助方式和处理方式的详细信息,请参阅[目录](#目录)。在做出贡献之前,请确保阅读相关部分。这将使我们维护人员的工作变得更加容易,并为所有参与者带来顺畅的体验。社区期待您的贡献。🎉
### 目录
- [报告问题](#报告问题)
- [提交代码更改](#提交代码更改)
### 报告问题
如果您在使用 AstrBot 时遇到任何问题,请按照以下步骤报告:
1. **检查现有问题**:在提交新问题之前,请先检查 [Issues](https://github.com/AstrBotDevs/AstrBot/issues) 中是否已经存在类似的问题。
2. **创建新问题**:如果没有类似的问题,请创建一个新问题。请确保提供以下信息:
- 问题的简要描述
- 重现问题的步骤
- 预期结果和实际结果
- 相关日志或错误消息
### 提交代码更改
#### 分支命名
我们使用 `fix/` 前缀来修复错误,使用 `feat/` 前缀来添加新功能。对于 `fix/` 分支,请使用简短的描述,或者直接使用 Issue 编号。例如:`fix/1234` 或者 `fix/1234-login-typo`。对于 `feat/` 分支,请使用简短的描述,例如:`feat/add-user-profile`
#### PR 描述
- 请使用英文描述您的 PR。
- 标题请使用 `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` 等语义化前缀,并简要描述更改内容。如:`fix: correct login page typo`
#### 代码规范
##### Core
我们使用 Ruff 作为代码格式化和静态分析工具。在提交代码之前,请运行以下命令以确保代码符合规范:
```bash
ruff format .
ruff check .
```
如果您使用 VSCode,可以安装 `Ruff` 插件。
## Contributing Guide
First off, thanks for taking the time to contribute! ❤️
All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
### Table of Contents
- [Reporting Issues](#reporting-issues)
- [Pull Requests](#pull-requests)
### Reporting Issues
If you encounter any issues while using AstrBot, please follow these steps to report them:
1. **Check Existing Issues**: Before submitting a new issue, please check if a similar issue already exists in the [Issues](https://github.com/AstrBotDevs/AstrBot/issues) section of the repository.
2. **Create a New Issue**: If no similar issue exists, please create a new issue. Make sure to provide the following information:
- A brief description of the issue
- Steps to reproduce the issue
- Expected and actual results
- Relevant logs or error messages
### Pull Requests
#### Branch Naming
We use the `fix/` prefix for bug fixes and the `feat/` prefix for new features. For `fix/` branches, please use a short description or directly use the Issue number, e.g., `fix/1234` or `fix/1234-login-typo`. For `feat/` branches, please use a short description, e.g., `feat/add-user-profile`.
#### PR Description
- Please use English to describe your PR.
- Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`.
#### Code Style
##### Core
We use Ruff as our code formatter and static analysis tool. Before submitting your code, please run the following commands to ensure your code adheres to the style guidelines:
```bash
ruff format .
ruff check .
```
+1 -9
View File
@@ -1,4 +1,4 @@
![astrbot-banner-xmas](https://github.com/user-attachments/assets/bf2341de-ec7a-45a7-a04a-02ad36450e99)
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
<div align="center">
@@ -20,7 +20,6 @@
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
@@ -207,7 +206,6 @@ pre-commit install
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 7 群:743746109
- 开发者群:975206796
### Telegram 群组
@@ -243,10 +241,4 @@ pre-commit install
</details>
<div align="center">
_私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.10.2"
__version__ = "4.7.4"
+35 -2
View File
@@ -1,8 +1,10 @@
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
from astrbot.core.config import AstrBotConfig
from astrbot.core.config.default import DB_PATH
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.db.sqlite import BaseDatabase
from astrbot.core.file_token_service import FileTokenService
from astrbot.core.utils.pip_installer import PipInstaller
from astrbot.core.utils.shared_preferences import SharedPreferences
@@ -14,13 +16,44 @@ from .utils.astrbot_path import get_astrbot_data_path
# 初始化数据存储文件夹
os.makedirs(get_astrbot_data_path(), exist_ok=True)
class AstrBotMySQLSettings(BaseSettings):
host: str = "localhost"
port: int = 3306
user: str = "root"
password: str = ""
database: str = "astrbot"
charset: str = "utf8mb4"
model_config = SettingsConfigDict(env_file=".env", env_prefix="ASTR_MYSQL_")
def get_db_helper() -> BaseDatabase:
db_type = os.getenv("ASTR_DB_TYPE", "sqlite")
match db_type:
case "sqlite":
from astrbot.core.db.sqlite import SQLiteDatabase
return SQLiteDatabase(DB_PATH)
case "mysql":
from astrbot.core.db.mysql import MySQLDatabase
mysql_settings = AstrBotMySQLSettings()
return MySQLDatabase(**mysql_settings.model_dump())
case _:
from astrbot.core.db.sqlite import SQLiteDatabase
return SQLiteDatabase(DB_PATH)
DEMO_MODE = os.getenv("DEMO_MODE", False)
astrbot_config = AstrBotConfig()
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
html_renderer = HtmlRenderer(t2i_base_url)
logger = LogManager.GetLogger(log_name="astrbot")
db_helper = SQLiteDatabase(DB_PATH)
db_helper = get_db_helper()
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
sp = SharedPreferences(db_helper=db_helper)
# 文件令牌服务
+4 -6
View File
@@ -3,7 +3,7 @@
from typing import Any, ClassVar, Literal, cast
from pydantic import BaseModel, GetCoreSchemaHandler, model_serializer, model_validator
from pydantic import BaseModel, GetCoreSchemaHandler, model_validator
from pydantic_core import core_schema
@@ -122,12 +122,10 @@ class ToolCall(BaseModel):
extra_content: dict[str, Any] | None = None
"""Extra metadata for the tool call."""
@model_serializer(mode="wrap")
def serialize(self, handler):
data = handler(self)
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
if self.extra_content is None:
data.pop("extra_content", None)
return data
kwargs.setdefault("exclude", set()).add("extra_content")
return super().model_dump(**kwargs)
class ToolCallPart(BaseModel):
+1 -22
View File
@@ -1,8 +1,7 @@
import typing as T
from dataclasses import dataclass, field
from dataclasses import dataclass
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import TokenUsage
class AgentResponseData(T.TypedDict):
@@ -13,23 +12,3 @@ class AgentResponseData(T.TypedDict):
class AgentResponse:
type: str
data: AgentResponseData
@dataclass
class AgentStats:
token_usage: TokenUsage = field(default_factory=TokenUsage)
start_time: float = 0.0
end_time: float = 0.0
time_to_first_token: float = 0.0
@property
def duration(self) -> float:
return self.end_time - self.start_time
def to_dict(self) -> dict:
return {
"token_usage": self.token_usage.__dict__,
"start_time": self.start_time,
"end_time": self.end_time,
"time_to_first_token": self.time_to_first_token,
}
+1 -1
View File
@@ -9,7 +9,7 @@ from .message import Message
TContext = TypeVar("TContext", default=Any)
@dataclass
@dataclass(config={"arbitrary_types_allowed": True})
class ContextWrapper(Generic[TContext]):
"""A context for running an agent, which can be used to pass additional data or state."""
@@ -1,5 +1,4 @@
import sys
import time
import traceback
import typing as T
@@ -13,7 +12,6 @@ from mcp.types import (
)
from astrbot import logger
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import (
MessageChain,
)
@@ -26,7 +24,7 @@ from astrbot.core.provider.provider import Provider
from ..hooks import BaseAgentRunHooks
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
from ..response import AgentResponseData, AgentStats
from ..response import AgentResponseData
from ..run_context import ContextWrapper, TContext
from ..tool_executor import BaseFunctionToolExecutor
from .base import AgentResponse, AgentState, BaseAgentRunner
@@ -71,25 +69,14 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
self.run_context.messages = messages
self.stats = AgentStats()
self.stats.start_time = time.time()
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
payload = {
"contexts": self.run_context.messages, # list[Message]
"func_tool": self.req.func_tool,
"model": self.req.model, # NOTE: in fact, this arg is None in most cases
"session_id": self.req.session_id,
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
}
if self.streaming:
stream = self.provider.text_chat_stream(**payload)
stream = self.provider.text_chat_stream(**self.req.__dict__)
async for resp in stream: # type: ignore
yield resp
else:
yield await self.provider.text_chat(**payload)
yield await self.provider.text_chat(**self.req.__dict__)
@override
async def step(self):
@@ -110,11 +97,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
llm_resp_result = None
async for llm_response in self._iter_llm_responses():
assert isinstance(llm_response, LLMResponse)
if llm_response.is_chunk:
# update ttft
if self.stats.time_to_first_token == 0:
self.stats.time_to_first_token = time.time() - self.stats.start_time
if llm_response.result_chain:
yield AgentResponse(
type="streaming_delta",
@@ -138,10 +122,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
continue
llm_resp_result = llm_response
if not llm_response.is_chunk and llm_response.usage:
# only count the token usage of the final response for computation purpose
self.stats.token_usage += llm_response.usage
break # got final response
if not llm_resp_result:
@@ -153,7 +133,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if llm_resp.role == "err":
# 如果 LLM 响应错误,转换到错误状态
self.final_llm_resp = llm_resp
self.stats.end_time = time.time()
self._transition_state(AgentState.ERROR)
yield AgentResponse(
type="err",
@@ -168,12 +147,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 如果没有工具调用,转换到完成状态
self.final_llm_resp = llm_resp
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
# record the final assistant message
self.run_context.messages.append(
Message(
role="assistant",
content=llm_resp.completion_text or "*No response*",
content=llm_resp.completion_text or "",
),
)
try:
@@ -198,19 +176,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 如果有工具调用,还需处理工具调用
if llm_resp.tools_call_name:
tool_call_result_blocks = []
for tool_call_name in llm_resp.tools_call_name:
yield AgentResponse(
type="tool_call",
data=AgentResponseData(
chain=MessageChain(type="tool_call").message(
f"🔨 调用工具: {tool_call_name}"
),
),
)
async for result in self._handle_function_tools(self.req, llm_resp):
if isinstance(result, list):
tool_call_result_blocks = result
elif isinstance(result, MessageChain):
if result.type is None:
# should not happen
continue
if result.type == "tool_direct_result":
ar_type = "tool_call_result"
else:
ar_type = result.type
result.type = "tool_call_result"
yield AgentResponse(
type=ar_type,
type="tool_call_result",
data=AgentResponseData(chain=result),
)
# 将结果添加到上下文中
@@ -238,25 +219,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async for resp in self.step():
yield resp
# 如果循环结束了但是 agent 还没有完成,说明是达到了 max_step
if not self.done():
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
# 拔掉所有工具
if self.req:
self.req.func_tool = None
# 注入提示词
self.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
)
# 再执行最后一步
async for resp in self.step():
yield resp
async def _handle_function_tools(
self,
req: ProviderRequest,
@@ -272,19 +234,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
llm_response.tools_call_args,
llm_response.tools_call_ids,
):
yield MessageChain(
type="tool_call",
chain=[
Json(
data={
"id": func_tool_id,
"name": func_tool_name,
"args": func_tool_args,
"ts": time.time(),
}
)
],
)
try:
if not req.func_tool:
return
@@ -358,6 +307,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content=res.content[0].text,
),
)
yield MessageChain().message(res.content[0].text)
elif isinstance(res.content[0], ImageContent):
tool_call_result_blocks.append(
ToolCallMessageSegment(
@@ -379,6 +329,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content=resource.text,
),
)
yield MessageChain().message(resource.text)
elif (
isinstance(resource, BlobResourceContents)
and resource.mimeType
@@ -402,34 +353,20 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content="返回的数据类型不受支持",
),
)
yield MessageChain().message("返回的数据类型不受支持。")
elif resp is None:
# Tool 直接请求发送消息给用户
# 这里我们将直接结束 Agent Loop。
# 发送消息逻辑在 ToolExecutor 中处理了。
logger.warning(
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户。"
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中"
)
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*工具没有返回值或者将结果直接发送给了用户*",
),
)
else:
# 不应该出现其他类型
logger.warning(
f"Tool 返回了不支持的类型: {type(resp)}",
)
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*工具返回了不支持的类型,请告诉用户检查这个工具的定义和实现。*",
),
f"Tool 返回了不支持的类型: {type(resp)},将忽略",
)
try:
@@ -451,22 +388,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
)
# yield the last tool call result
if tool_call_result_blocks:
last_tcr_content = str(tool_call_result_blocks[-1].content)
yield MessageChain(
type="tool_call_result",
chain=[
Json(
data={
"id": func_tool_id,
"ts": time.time(),
"result": last_tcr_content,
}
)
],
)
# 处理函数调用响应
if tool_call_result_blocks:
yield tool_call_result_blocks
+2 -7
View File
@@ -1,4 +1,4 @@
from collections.abc import AsyncGenerator, Awaitable, Callable
from collections.abc import Awaitable, Callable
from typing import Any, Generic
import jsonschema
@@ -7,8 +7,6 @@ from deprecated import deprecated
from pydantic import Field, model_validator
from pydantic.dataclasses import dataclass
from astrbot.core.message.message_event_result import MessageEventResult
from .run_context import ContextWrapper, TContext
ParametersType = dict[str, Any]
@@ -40,10 +38,7 @@ class ToolSchema:
class FunctionTool(ToolSchema, Generic[TContext]):
"""A callable tool, for function calling."""
handler: (
Callable[..., Awaitable[str | None] | AsyncGenerator[MessageEventResult, None]]
| None
) = None
handler: Callable[..., Awaitable[Any]] | None = None
"""a callable that implements the tool's functionality. It should be an async function."""
handler_module_path: str | None = None
+1 -3
View File
@@ -6,10 +6,8 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.context import Context
@dataclass
@dataclass(config={"arbitrary_types_allowed": True})
class AstrAgentContext:
__pydantic_config__ = {"arbitrary_types_allowed": True}
context: Context
"""The star context instance"""
event: AstrMessageEvent
+4 -57
View File
@@ -2,16 +2,13 @@ import traceback
from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.agent.message import Message
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import (
MessageChain,
MessageEventResult,
ResultContentType,
)
from astrbot.core.provider.entities import LLMResponse
AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
@@ -25,25 +22,8 @@ async def run_agent(
) -> AsyncGenerator[MessageChain | None, None]:
step_idx = 0
astr_event = agent_runner.run_context.context.event
while step_idx < max_step + 1:
while step_idx < max_step:
step_idx += 1
if step_idx == max_step + 1:
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
if not agent_runner.done():
# 拔掉所有工具
if agent_runner.req:
agent_runner.req.func_tool = None
# 注入提示词
agent_runner.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
)
try:
async for resp in agent_runner.step():
if astr_event.is_stopped():
@@ -52,27 +32,16 @@ async def run_agent(
msg_chain = resp.data["chain"]
if msg_chain.type == "tool_direct_result":
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
await astr_event.send(msg_chain)
await astr_event.send(resp.data["chain"])
continue
if astr_event.get_platform_id() == "webchat":
await astr_event.send(msg_chain)
# 对于其他情况,暂时先不处理
continue
elif resp.type == "tool_call":
if agent_runner.streaming:
# 用来标记流式响应需要分节
yield MessageChain(chain=[], type="break")
if astr_event.get_platform_name() == "webchat":
if show_tool_use:
await astr_event.send(resp.data["chain"])
elif show_tool_use:
json_comp = resp.data["chain"].chain[0]
if isinstance(json_comp, Json):
m = f"🔨 调用工具: {json_comp.data.get('name')}"
else:
m = "🔨 调用工具..."
chain = MessageChain(type="tool_call").message(m)
await astr_event.send(chain)
continue
if stream_to_general and resp.type == "streaming_delta":
@@ -99,33 +68,11 @@ async def run_agent(
continue
yield resp.data["chain"] # MessageChain
if agent_runner.done():
# send agent stats to webchat
if astr_event.get_platform_name() == "webchat":
await astr_event.send(
MessageChain(
type="agent_stats",
chain=[Json(data=agent_runner.stats.to_dict())],
)
)
break
except Exception as e:
logger.error(traceback.format_exc())
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
error_llm_response = LLMResponse(
role="err",
completion_text=err_msg,
)
try:
await agent_runner.agent_hooks.on_agent_done(
agent_runner.run_context, error_llm_response
)
except Exception:
logger.exception("Error in on_agent_done hook")
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在控制台查看和分享错误详情。\n"
if agent_runner.streaming:
yield MessageChain().message(err_msg)
else:
+5 -39
View File
@@ -185,11 +185,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
async def call_local_llm_tool(
context: ContextWrapper[AstrAgentContext],
handler: T.Callable[
...,
T.Awaitable[MessageEventResult | mcp.types.CallToolResult | str | None]
| T.AsyncGenerator[MessageEventResult | CommandResult | str | None, None],
],
handler: T.Callable[..., T.Awaitable[T.Any]],
method_name: str,
*args,
**kwargs,
@@ -209,42 +205,12 @@ async def call_local_llm_tool(
else:
raise ValueError(f"未知的方法名: {method_name}")
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:
type_str = str(param.annotation)
param_str += f": {type_str}"
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
logger.error(f"调用本地 LLM 工具时出错: {e}", exc_info=True)
except TypeError:
logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True)
except Exception as e:
trace_ = traceback.format_exc()
raise Exception(f"Tool execution error: {e}. Traceback: {trace_}") from e
logger.error(f"调用本地 LLM 工具时出错: {e}\n{trace_}")
if not ready_to_call:
return
-4
View File
@@ -24,10 +24,6 @@ class AstrBotConfig(dict):
- 如果传入了 schema,将会通过 schema 解析出 default_config,此时传入的 default_config 会被忽略。
"""
config_path: str
default_config: dict
schema: dict | None
def __init__(
self,
config_path: str = ASTRBOT_CONFIG_PATH,
+190 -204
View File
@@ -1,11 +1,10 @@
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
import os
from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.10.2"
VERSION = "4.7.4"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -14,7 +13,6 @@ WEBHOOK_SUPPORTED_PLATFORMS = [
"wecom",
"wecom_ai_bot",
"slack",
"lark",
]
# 默认配置
@@ -44,15 +42,7 @@ DEFAULT_CONFIG = {
"interval": "1.5,3.5",
"log_base": 2.6,
"words_count_threshold": 150,
"split_mode": "regex", # regex 或 words
"regex": ".*?[。?!~…]+|.+$",
"split_words": [
"",
"",
"",
"~",
"",
], # 当 split_mode 为 words 时使用
"content_cleanup_rule": "",
},
"no_permission_reply": True,
@@ -62,8 +52,7 @@ DEFAULT_CONFIG = {
"ignore_bot_self_message": False,
"ignore_at_all": False,
},
"provider_sources": [], # provider sources
"provider": [], # models from provider_sources
"provider": [],
"provider_settings": {
"enable": True,
"default_provider_id": "",
@@ -110,7 +99,6 @@ DEFAULT_CONFIG = {
"provider_id": "",
"dual_output": False,
"use_file_service": False,
"trigger_probability": 1.0,
},
"provider_ltm_settings": {
"group_icl_enable": False,
@@ -169,26 +157,9 @@ DEFAULT_CONFIG = {
"kb_fusion_top_k": 20, # 知识库检索融合阶段返回结果数量
"kb_final_top_k": 5, # 知识库检索最终返回结果数量
"kb_agentic_mode": False,
"disable_builtin_commands": False,
}
class ChatProviderTemplate(TypedDict):
id: str
provider_source_id: str
model: str
modalities: list
custom_extra_body: dict[str, Any]
CHAT_PROVIDER_TEMPLATE = {
"id": "",
"provide_source_id": "",
"model": "",
"modalities": [],
"custom_extra_body": {},
}
"""
AstrBot v3 时代的配置元数据,目前仅承担以下功能:
@@ -227,7 +198,7 @@ CONFIG_METADATA_2 = {
"callback_server_host": "0.0.0.0",
"port": 6196,
},
"OneBot v11": {
"QQ 个人号(OneBot v11)": {
"id": "default",
"type": "aiocqhttp",
"enable": False,
@@ -297,10 +268,6 @@ CONFIG_METADATA_2 = {
"app_id": "",
"app_secret": "",
"domain": "https://open.feishu.cn",
"lark_connection_mode": "socket", # webhook, socket
"webhook_uuid": "",
"lark_encrypt_key": "",
"lark_verification_token": "",
},
"钉钉(DingTalk)": {
"id": "dingtalk",
@@ -394,28 +361,6 @@ CONFIG_METADATA_2 = {
# "type": "string",
# "options": ["fullscreen", "embedded"],
# },
"lark_connection_mode": {
"description": "订阅方式",
"type": "string",
"options": ["socket", "webhook"],
"labels": ["长连接模式", "推送至服务器模式"],
},
"lark_encrypt_key": {
"description": "Encrypt Key",
"type": "string",
"hint": "用于解密飞书回调数据的加密密钥",
"condition": {
"lark_connection_mode": "webhook",
},
},
"lark_verification_token": {
"description": "Verification Token",
"type": "string",
"hint": "用于验证飞书回调请求的令牌",
"condition": {
"lark_connection_mode": "webhook",
},
},
"is_sandbox": {
"description": "沙箱模式",
"type": "bool",
@@ -862,7 +807,6 @@ CONFIG_METADATA_2 = {
"metadata": {
"provider": {
"type": "list",
# provider sources templates
"config_template": {
"OpenAI": {
"id": "openai",
@@ -873,49 +817,26 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.openai.com/v1",
"timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
"hint": "也兼容所有与 OpenAI API 兼容的服务。",
},
"Google Gemini": {
"id": "google_gemini",
"provider": "google",
"type": "googlegenai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/",
"timeout": 120,
"gm_resp_image_modal": False,
"gm_native_search": False,
"gm_native_coderunner": False,
"gm_url_context": False,
"gm_safety_settings": {
"harassment": "BLOCK_MEDIUM_AND_ABOVE",
"hate_speech": "BLOCK_MEDIUM_AND_ABOVE",
"sexually_explicit": "BLOCK_MEDIUM_AND_ABOVE",
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
},
"gm_thinking_config": {"budget": 0, "level": "HIGH"},
},
"Anthropic": {
"id": "anthropic",
"provider": "anthropic",
"type": "anthropic_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.anthropic.com/v1",
"timeout": 120,
},
"Moonshot": {
"id": "moonshot",
"provider": "moonshot",
"Azure OpenAI": {
"id": "azure",
"provider": "azure",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"api_version": "2024-05-01-preview",
"key": [],
"api_base": "",
"timeout": 120,
"api_base": "https://api.moonshot.cn/v1",
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"xAI": {
"id": "xai",
@@ -926,52 +847,42 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.x.ai/v1",
"timeout": 120,
"model_config": {"model": "grok-2-latest", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"xai_native_search": False,
"modalities": ["text", "image", "tool_use"],
},
"DeepSeek": {
"id": "deepseek",
"provider": "deepseek",
"type": "openai_chat_completion",
"Anthropic": {
"hint": "注意Claude系列模型的温度调节范围为0到1.0,超出可能导致报错",
"id": "claude",
"provider": "anthropic",
"type": "anthropic_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.deepseek.com/v1",
"api_base": "https://api.anthropic.com/v1",
"timeout": 120,
"custom_headers": {},
},
"Zhipu": {
"id": "zhipu",
"provider": "zhipu",
"type": "zhipu_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"custom_headers": {},
},
"Azure OpenAI": {
"id": "azure_openai",
"provider": "azure",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"api_version": "2024-05-01-preview",
"key": [],
"api_base": "",
"timeout": 120,
"custom_headers": {},
"model_config": {
"model": "claude-3-5-sonnet-latest",
"max_tokens": 4096,
"temperature": 0.2,
},
"modalities": ["text", "image", "tool_use"],
},
"Ollama": {
"id": "ollama",
"hint": "启用前请确保已正确安装并运行 Ollama 服务端,Ollama默认不带鉴权,无需修改key",
"id": "ollama_default",
"provider": "ollama",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["ollama"], # ollama 的 key 默认是 ollama
"api_base": "http://127.0.0.1:11434/v1",
"api_base": "http://localhost:11434/v1",
"model_config": {"model": "llama3.1-8b", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"LM Studio": {
"id": "lm_studio",
@@ -980,22 +891,16 @@ CONFIG_METADATA_2 = {
"provider_type": "chat_completion",
"enable": True,
"key": ["lmstudio"],
"api_base": "http://127.0.0.1:1234/v1",
"api_base": "http://localhost:1234/v1",
"model_config": {
"model": "llama-3.1-8b",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"ModelStack": {
"id": "modelstack",
"provider": "modelstack",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://modelstack.app/v1",
"timeout": 120,
"custom_headers": {},
},
"Gemini_OpenAI_API": {
"id": "google_gemini_openai",
"Gemini(OpenAI兼容)": {
"id": "gemini_default",
"provider": "google",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
@@ -1003,10 +908,58 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
"timeout": 120,
"model_config": {
"model": "gemini-1.5-flash",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"Gemini": {
"id": "gemini_default",
"provider": "google",
"type": "googlegenai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/",
"timeout": 120,
"model_config": {
"model": "gemini-2.0-flash-exp",
"temperature": 0.4,
},
"gm_resp_image_modal": False,
"gm_native_search": False,
"gm_native_coderunner": False,
"gm_url_context": False,
"gm_safety_settings": {
"harassment": "BLOCK_MEDIUM_AND_ABOVE",
"hate_speech": "BLOCK_MEDIUM_AND_ABOVE",
"sexually_explicit": "BLOCK_MEDIUM_AND_ABOVE",
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
},
"gm_thinking_config": {
"budget": 0,
},
"modalities": ["text", "image", "tool_use"],
},
"DeepSeek": {
"id": "deepseek_default",
"provider": "deepseek",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.deepseek.com/v1",
"timeout": 120,
"model_config": {"model": "deepseek-chat", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "tool_use"],
},
"Groq": {
"id": "groq",
"id": "groq_default",
"provider": "groq",
"type": "groq_chat_completion",
"provider_type": "chat_completion",
@@ -1014,7 +967,13 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.groq.com/openai/v1",
"timeout": 120,
"model_config": {
"model": "openai/gpt-oss-20b",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "tool_use"],
},
"302.AI": {
"id": "302ai",
@@ -1025,9 +984,12 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.302.ai/v1",
"timeout": 120,
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"SiliconFlow": {
"硅基流动": {
"id": "siliconflow",
"provider": "siliconflow",
"type": "openai_chat_completion",
@@ -1036,9 +998,15 @@ CONFIG_METADATA_2 = {
"key": [],
"timeout": 120,
"api_base": "https://api.siliconflow.cn/v1",
"model_config": {
"model": "deepseek-ai/DeepSeek-V3",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"PPIO": {
"PPIO派欧云": {
"id": "ppio",
"provider": "ppio",
"type": "openai_chat_completion",
@@ -1047,9 +1015,14 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.ppinfra.com/v3/openai",
"timeout": 120,
"model_config": {
"model": "deepseek/deepseek-r1",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
},
"TokenPony": {
"小马算力": {
"id": "tokenpony",
"provider": "tokenpony",
"type": "openai_chat_completion",
@@ -1058,9 +1031,14 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.tokenpony.cn/v1",
"timeout": 120,
"model_config": {
"model": "kimi-k2-instruct-0905",
"temperature": 0.7,
},
"custom_headers": {},
"custom_extra_body": {},
},
"Compshare": {
"优云智算": {
"id": "compshare",
"provider": "compshare",
"type": "openai_chat_completion",
@@ -1069,18 +1047,42 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.modelverse.cn/v1",
"timeout": 120,
"model_config": {
"model": "moonshotai/Kimi-K2-Instruct",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"ModelScope": {
"id": "modelscope",
"provider": "modelscope",
"Kimi": {
"id": "moonshot",
"provider": "moonshot",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api-inference.modelscope.cn/v1",
"api_base": "https://api.moonshot.cn/v1",
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"智谱 AI": {
"id": "zhipu_default",
"provider": "zhipu",
"type": "zhipu_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"model_config": {
"model": "glm-4-flash",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"Dify": {
"id": "dify_app_default",
@@ -1095,6 +1097,7 @@ CONFIG_METADATA_2 = {
"dify_query_input_key": "astrbot_text_query",
"variables": {},
"timeout": 60,
"hint": "请确保你在 AstrBot 里设置的 APP 类型和 Dify 里面创建的应用的类型一致!",
},
"Coze": {
"id": "coze",
@@ -1125,6 +1128,20 @@ CONFIG_METADATA_2 = {
"variables": {},
"timeout": 60,
},
"ModelScope": {
"id": "modelscope",
"provider": "modelscope",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api-inference.modelscope.cn/v1",
"model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"FastGPT": {
"id": "fastgpt",
"provider": "fastgpt",
@@ -1148,6 +1165,7 @@ CONFIG_METADATA_2 = {
"model": "whisper-1",
},
"Whisper(Local)": {
"hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cudaCPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
"provider": "openai",
"type": "openai_whisper_selfhost",
"provider_type": "speech_to_text",
@@ -1156,6 +1174,7 @@ CONFIG_METADATA_2 = {
"model": "tiny",
},
"SenseVoice(Local)": {
"hint": "启用前请 pip 安装 funasr、funasr_onnx、torchaudio、torch、modelscope、jieba 库(默认使用CPU,大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
"type": "sensevoice_stt_selfhost",
"provider": "sensevoice",
"provider_type": "speech_to_text",
@@ -1177,6 +1196,7 @@ CONFIG_METADATA_2 = {
"timeout": "20",
},
"Edge TTS": {
"hint": "提示:使用这个服务前需要安装有 ffmpeg,并且可以直接在终端调用 ffmpeg 指令。",
"id": "edge_tts",
"provider": "microsoft",
"type": "edge_tts",
@@ -1392,10 +1412,6 @@ CONFIG_METADATA_2 = {
},
},
"items": {
"provider_source_id": {
"invisible": True,
"type": "string",
},
"xai_native_search": {
"description": "启用原生搜索功能",
"type": "bool",
@@ -1766,24 +1782,13 @@ CONFIG_METADATA_2 = {
},
},
"gm_thinking_config": {
"description": "Thinking Config",
"description": "Gemini思考设置",
"type": "object",
"items": {
"budget": {
"description": "Thinking Budget",
"description": "思考预算",
"type": "int",
"hint": "Guides the model on the specific number of thinking tokens to use for reasoning. See: https://ai.google.dev/gemini-api/docs/thinking#set-budget",
},
"level": {
"description": "Thinking Level",
"type": "string",
"hint": "Recommended for Gemini 3 models and onwards, lets you control reasoning behavior.See: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels",
"options": [
"MINIMAL",
"LOW",
"MEDIUM",
"HIGH",
],
"hint": "模型应该生成的思考Token的数量,设为0关闭思考。除gemini-2.5-flash外的模型会静默忽略此参数。",
},
},
},
@@ -1964,6 +1969,7 @@ CONFIG_METADATA_2 = {
"id": {
"description": "ID",
"type": "string",
"hint": "模型提供商名字。",
},
"type": {
"description": "模型提供商种类",
@@ -1983,15 +1989,29 @@ CONFIG_METADATA_2 = {
"description": "API Key",
"type": "list",
"items": {"type": "string"},
"hint": "提供商 API Key。",
},
"api_base": {
"description": "API Base URL",
"type": "string",
"hint": "API Base URL 请在模型提供商处获得。如出现 404 报错,尝试在地址末尾加上 /v1",
},
"model": {
"description": "模型 ID",
"type": "string",
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
"model_config": {
"description": "模型配置",
"type": "object",
"items": {
"model": {
"description": "模型名称",
"type": "string",
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
},
"max_tokens": {
"description": "模型最大输出长度(tokens",
"type": "int",
},
"temperature": {"description": "温度", "type": "float"},
"top_p": {"description": "Top P值", "type": "float"},
},
},
"dify_api_key": {
"description": "API Key",
@@ -2153,9 +2173,6 @@ CONFIG_METADATA_2 = {
"use_file_service": {
"type": "bool",
},
"trigger_probability": {
"type": "float",
},
},
},
"provider_ltm_settings": {
@@ -2366,14 +2383,6 @@ CONFIG_METADATA_3 = {
"provider_tts_settings.enable": True,
},
},
"provider_tts_settings.trigger_probability": {
"description": "TTS 触发概率",
"type": "float",
"slider": {"min": 0, "max": 1, "step": 0.05},
"condition": {
"provider_tts_settings.enable": True,
},
},
"provider_settings.image_caption_prompt": {
"description": "图片转述提示词",
"type": "text",
@@ -2652,11 +2661,6 @@ CONFIG_METADATA_3 = {
"description": "只 @ 机器人是否触发等待",
"type": "bool",
},
"disable_builtin_commands": {
"description": "禁用自带指令",
"type": "bool",
"hint": "禁用所有 AstrBot 的自带指令,如 help, provider, model 等。",
},
},
},
"whitelist": {
@@ -2871,26 +2875,9 @@ CONFIG_METADATA_3 = {
"description": "分段回复字数阈值",
"type": "int",
},
"platform_settings.segmented_reply.split_mode": {
"description": "分段模式",
"type": "string",
"options": ["regex", "words"],
"labels": ["正则表达式", "分段词列表"],
},
"platform_settings.segmented_reply.regex": {
"description": "分段正则表达式",
"type": "string",
"condition": {
"platform_settings.segmented_reply.split_mode": "regex",
},
},
"platform_settings.segmented_reply.split_words": {
"description": "分段词列表",
"type": "list",
"hint": "检测到列表中的任意词时进行分段,如:。、?、!等",
"condition": {
"platform_settings.segmented_reply.split_mode": "words",
},
},
"platform_settings.segmented_reply.content_cleanup_rule": {
"description": "内容过滤正则表达式",
@@ -2941,7 +2928,6 @@ CONFIG_METADATA_3 = {
"description": "回复概率",
"type": "float",
"hint": "0.0-1.0 之间的数值",
"slider": {"min": 0, "max": 1, "step": 0.05},
"condition": {
"provider_ltm_settings.active_reply.enable": True,
},
-1
View File
@@ -79,7 +79,6 @@ class ConfigMetadataI18n:
"_special",
"invisible",
"options",
"slider",
]:
if attr in field_data:
field_result[attr] = field_data[attr]
+1 -4
View File
@@ -33,7 +33,6 @@ from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
from astrbot.core.umop_config_router import UmopConfigRouter
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.llm_metadata import update_llm_metadata
from astrbot.core.utils.migra_helper import migra
from . import astrbot_config, html_renderer
@@ -186,8 +185,6 @@ class AstrBotCoreLifecycle:
# 初始化关闭控制面板的事件
self.dashboard_shutdown_event = asyncio.Event()
asyncio.create_task(update_llm_metadata())
def _load(self) -> None:
"""加载事件总线和任务并初始化."""
# 创建一个异步任务来执行事件总线的 dispatch() 方法
@@ -200,7 +197,7 @@ class AstrBotCoreLifecycle:
# 把插件中注册的所有协程函数注册到事件总线中并执行
extra_tasks = []
for task in self.star_context._register_tasks:
extra_tasks.append(asyncio.create_task(task, name=task.__name__)) # type: ignore
extra_tasks.append(asyncio.create_task(task, name=task.__name__))
tasks_ = [event_bus_task, *extra_tasks]
for task in tasks_:
+21 -75
View File
@@ -3,14 +3,14 @@ import datetime
import typing as T
from contextlib import asynccontextmanager
from dataclasses import dataclass
from enum import Enum
from deprecated import deprecated
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from astrbot.core.db.po import (
Attachment,
CommandConfig,
CommandConflict,
ConversationV2,
Persona,
PlatformMessageHistory,
@@ -21,11 +21,17 @@ from astrbot.core.db.po import (
)
class DatabaseType(Enum):
SQLITE = "sqlite"
MYSQL = "mysql"
@dataclass
class BaseDatabase(abc.ABC):
"""数据库基类"""
DATABASE_URL = ""
database_type: DatabaseType
def __init__(self) -> None:
self.engine = create_async_engine(
@@ -33,7 +39,7 @@ class BaseDatabase(abc.ABC):
echo=False,
future=True,
)
self.AsyncSessionLocal = async_sessionmaker(
self.AsyncSessionLocal = sessionmaker(
self.engine,
class_=AsyncSession,
expire_on_commit=False,
@@ -84,7 +90,7 @@ class BaseDatabase(abc.ABC):
@abc.abstractmethod
async def count_platform_stats(self) -> int:
"""Count the number of platform statistics records."""
"""Sum the count of platform statistics records."""
...
@abc.abstractmethod
@@ -92,6 +98,16 @@ class BaseDatabase(abc.ABC):
"""Get platform statistics within the specified offset in seconds and group by platform_id."""
...
@abc.abstractmethod
async def get_platform_stats_time_series(
self, offset_sec: int = 86400
) -> list[tuple[int, int]]:
"""Get platform statistics time series data grouped by hour.
Returns a list of tuples (hour_timestamp, count) sorted by timestamp ascending.
"""
...
@abc.abstractmethod
async def get_conversations(
self,
@@ -316,76 +332,6 @@ class BaseDatabase(abc.ABC):
"""Clear all preferences for a specific scope ID."""
...
@abc.abstractmethod
async def get_command_configs(self) -> list[CommandConfig]:
"""Get all stored command configurations."""
...
@abc.abstractmethod
async def get_command_config(self, handler_full_name: str) -> CommandConfig | None:
"""Fetch a single command configuration by handler."""
...
@abc.abstractmethod
async def upsert_command_config(
self,
handler_full_name: str,
plugin_name: str,
module_path: str,
original_command: str,
*,
resolved_command: str | None = None,
enabled: bool | None = None,
keep_original_alias: bool | None = None,
conflict_key: str | None = None,
resolution_strategy: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_managed: bool | None = None,
) -> CommandConfig:
"""Create or update a command configuration."""
...
@abc.abstractmethod
async def delete_command_config(self, handler_full_name: str) -> None:
"""Delete a single command configuration."""
...
@abc.abstractmethod
async def delete_command_configs(self, handler_full_names: list[str]) -> None:
"""Bulk delete command configurations."""
...
@abc.abstractmethod
async def list_command_conflicts(
self,
status: str | None = None,
) -> list[CommandConflict]:
"""List recorded command conflict entries."""
...
@abc.abstractmethod
async def upsert_command_conflict(
self,
conflict_key: str,
handler_full_name: str,
plugin_name: str,
*,
status: str | None = None,
resolution: str | None = None,
resolved_command: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_generated: bool | None = None,
) -> CommandConflict:
"""Create or update a conflict record."""
...
@abc.abstractmethod
async def delete_command_conflicts(self, ids: list[int]) -> None:
"""Delete conflict records."""
...
# @abc.abstractmethod
# async def insert_llm_message(
# self,
+5 -1
View File
@@ -2,7 +2,7 @@ import os
from astrbot.api import logger, sp
from astrbot.core.config import AstrBotConfig
from astrbot.core.db import BaseDatabase
from astrbot.core.db import BaseDatabase, DatabaseType
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .migra_3_to_4 import (
@@ -24,6 +24,10 @@ async def check_migration_needed_v4(db_helper: BaseDatabase) -> bool:
if not os.path.exists(data_v3_db):
return False
if db_helper.database_type == DatabaseType.MYSQL:
return False
migration_done = await db_helper.get_preference(
"global",
"global",
@@ -70,7 +70,6 @@ async def migration_conversation_table(
logger.info(
f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。",
)
continue
if ":" not in conv.user_id:
continue
session = MessageSesion.from_str(session_str=conv.user_id)
@@ -208,7 +207,6 @@ async def migration_webchat_data(
logger.info(
f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。",
)
continue
if ":" in conv.user_id:
continue
platform_id = "webchat"
@@ -38,7 +38,7 @@ async def migrate_webchat_session(db_helper: BaseDatabase):
query = (
select(
col(PlatformMessageHistory.user_id),
col(PlatformMessageHistory.sender_name),
func.max(PlatformMessageHistory.sender_name).label("sender_name"),
func.min(PlatformMessageHistory.created_at).label("earliest"),
func.max(PlatformMessageHistory.updated_at).label("latest"),
)
+4 -6
View File
@@ -127,7 +127,7 @@ class SQLiteDatabase:
conn.text_factory = str
return conn
def _exec_sql(self, sql: str, params: tuple | None = None):
def _exec_sql(self, sql: str, params: tuple = None):
conn = self.conn
try:
c = self.conn.cursor()
@@ -224,11 +224,9 @@ class SQLiteDatabase:
c.close()
return Stats(platform)
return Stats(platform, [], [])
def get_conversation_by_user_id(
self, user_id: str, cid: str
) -> Conversation | None:
def get_conversation_by_user_id(self, user_id: str, cid: str) -> Conversation:
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
@@ -260,7 +258,7 @@ class SQLiteDatabase:
(user_id, cid, history, updated_at, created_at),
)
def get_conversations(self, user_id: str) -> list[Conversation]:
def get_conversations(self, user_id: str) -> tuple:
try:
c = self.conn.cursor()
except sqlite3.ProgrammingError:
+875
View File
@@ -0,0 +1,875 @@
import asyncio
import typing as T
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlmodel import col, delete, desc, func, or_, select, text, update
from astrbot.core.db import BaseDatabase, DatabaseType
from astrbot.core.db.po import (
Attachment,
ConversationV2,
Persona,
PlatformMessageHistory,
PlatformSession,
PlatformStat,
Preference,
SQLModel,
)
from astrbot.core.db.po import Stats as DeprecatedStats
NOT_GIVEN = T.TypeVar("NOT_GIVEN")
class MySQLDatabase(BaseDatabase):
"""MySQL 数据库实现
使用方式:
db = MySQLDatabase(
host="localhost",
port=3306,
user="root",
password="password",
database="astrbot"
)
await db.initialize()
"""
database_type = DatabaseType.MYSQL
def __init__(
self,
host: str = "localhost",
port: int = 3306,
user: str = "root",
password: str = "",
database: str = "astrbot",
charset: str = "utf8mb4",
) -> None:
self.host = host
self.port = port
self.user = user
self.password = password
self.database = database
self.charset = charset
self.DATABASE_URL = (
f"mysql+aiomysql://{user}:{password}@{host}:{port}/{database}"
f"?charset={charset}"
)
self.inited = False
self._current_loop: asyncio.AbstractEventLoop | None = None
super().__init__()
def _recreate_engine(self) -> None:
"""重新创建数据库引擎和会话工厂,用于处理事件循环切换的情况"""
self.engine = create_async_engine(
self.DATABASE_URL,
echo=False,
future=True,
)
self.AsyncSessionLocal = sessionmaker(
self.engine,
class_=AsyncSession,
expire_on_commit=False,
)
@asynccontextmanager
async def get_db(self) -> T.AsyncGenerator[AsyncSession, None]:
"""Get a database session.
此方法会检查当前事件循环,如果事件循环发生变化会重新创建引擎,
以解决 aiomysql 的 "attached to a different loop" 问题。
"""
try:
current_loop = asyncio.get_running_loop()
except RuntimeError:
current_loop = None
# 检查事件循环是否变化,如果变化则重新创建引擎
if current_loop is not None and self._current_loop != current_loop:
self._recreate_engine()
self._current_loop = current_loop
self.inited = False # 需要重新初始化
if not self.inited:
await self.initialize()
self.inited = True
async with self.AsyncSessionLocal() as session:
yield session
async def initialize(self) -> None:
"""Initialize the database by creating tables if they do not exist."""
async with self.engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
await conn.commit()
# ====
# Platform Statistics
# ====
async def insert_platform_stats(
self,
platform_id,
platform_type,
count=1,
timestamp=None,
) -> None:
"""Insert a new platform statistic record."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
if timestamp is None:
timestamp = datetime.now().replace(
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 DUPLICATE KEY UPDATE
count = count + VALUES(count)
"""),
{
"timestamp": current_hour,
"platform_id": platform_id,
"platform_type": platform_type,
"count": count,
},
)
async def count_platform_stats(self) -> int:
"""Count the number of platform statistics records."""
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
select(func.sum(PlatformStat.count)).select_from(PlatformStat),
)
count = result.scalar_one_or_none()
return count or 0
async def get_platform_stats(self, offset_sec: int = 86400) -> list[PlatformStat]:
"""Get platform statistics within the specified offset in seconds and group by platform_id."""
async with self.get_db() as session:
session: AsyncSession
now = datetime.now()
start_time = now - timedelta(seconds=offset_sec)
result = await session.execute(
text("""
SELECT platform_id, platform_type, SUM(count) as total_count, MAX(timestamp) as latest_ts
FROM platform_stats
WHERE timestamp >= :start_time
GROUP BY platform_id, platform_type
ORDER BY latest_ts DESC
"""),
{"start_time": start_time},
)
rows = result.fetchall()
return [
PlatformStat(
id=0,
platform_id=row.platform_id,
platform_type=row.platform_type,
count=row.total_count,
timestamp=row.latest_ts,
)
for row in rows
]
async def get_platform_stats_time_series(
self, offset_sec: int = 86400
) -> list[tuple[int, int]]:
"""Get platform statistics time series data grouped by hour."""
async with self.get_db() as session:
session: AsyncSession
now = datetime.now()
start_time = now - timedelta(seconds=offset_sec)
result = await session.execute(
text("""
SELECT UNIX_TIMESTAMP(DATE_FORMAT(timestamp, '%Y-%m-%d %H:00:00')) as hour_ts, SUM(count) as total_count
FROM platform_stats
WHERE timestamp >= :start_time
GROUP BY hour_ts
ORDER BY hour_ts ASC
"""),
{"start_time": start_time},
)
rows = result.fetchall()
return [(int(row.hour_ts), row.total_count) for row in rows]
# ====
# Conversation Management
# ====
async def get_conversations(self, user_id=None, platform_id=None):
async with self.get_db() as session:
session: AsyncSession
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):
async with self.get_db() as session:
session: AsyncSession
query = select(ConversationV2).where(ConversationV2.conversation_id == cid)
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_all_conversations(self, page=1, page_size=20):
async with self.get_db() as session:
session: AsyncSession
offset = (page - 1) * page_size
result = await session.execute(
select(ConversationV2)
.order_by(desc(ConversationV2.created_at))
.offset(offset)
.limit(page_size),
)
return result.scalars().all()
async def get_filtered_conversations(
self,
page=1,
page_size=20,
platform_ids=None,
search_query="",
**kwargs,
):
async with self.get_db() as session:
session: AsyncSession
# 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),
)
if search_query:
search_query = search_query.encode("unicode_escape").decode("utf-8")
base_query = base_query.where(
or_(
col(ConversationV2.title).ilike(f"%{search_query}%"),
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}:%"),
)
if "platforms" in kwargs and len(kwargs["platforms"]) > 0:
base_query = base_query.where(
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))
.offset(offset)
.limit(page_size)
)
result = await session.execute(result_query)
conversations = result.scalars().all()
return conversations, total
async def create_conversation(
self,
user_id,
platform_id,
content=None,
title=None,
persona_id=None,
cid=None,
created_at=None,
updated_at=None,
):
kwargs = {}
if cid:
kwargs["conversation_id"] = cid
if created_at:
kwargs["created_at"] = created_at
if updated_at:
kwargs["updated_at"] = updated_at
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
new_conversation = ConversationV2(
user_id=user_id,
content=content or [],
platform_id=platform_id,
title=title,
persona_id=persona_id,
**kwargs,
)
session.add(new_conversation)
return new_conversation
async def update_conversation(self, cid, title=None, persona_id=None, content=None):
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
query = update(ConversationV2).where(
col(ConversationV2.conversation_id) == cid,
)
values = {}
if title is not None:
values["title"] = title
if persona_id is not None:
values["persona_id"] = persona_id
if content is not None:
values["content"] = content
if not values:
return None
query = query.values(**values)
await session.execute(query)
return await self.get_conversation_by_id(cid)
async def delete_conversation(self, cid):
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
delete(ConversationV2).where(
col(ConversationV2.conversation_id) == cid,
),
)
async def delete_conversations_by_user_id(self, user_id: str) -> None:
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
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,
) -> tuple[list[dict], int]:
"""Get paginated session conversations with joined conversation and persona details."""
async with self.get_db() as session:
session: AsyncSession
offset = (page - 1) * page_size
# MySQL 使用 JSON_EXTRACT 函数(与 SQLite 的 json_extract 兼容)
base_query = (
select(
col(Preference.scope_id).label("session_id"),
func.json_extract(Preference.value, "$.val").label(
"conversation_id",
), # type: ignore
col(ConversationV2.persona_id).label("persona_id"),
col(ConversationV2.title).label("title"),
col(Persona.persona_id).label("persona_name"),
)
.select_from(Preference)
.outerjoin(
ConversationV2,
func.json_extract(Preference.value, "$.val")
== ConversationV2.conversation_id,
)
.outerjoin(
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(
or_(
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),
)
# 排序
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)
.outerjoin(
ConversationV2,
func.json_extract(Preference.value, "$.val")
== ConversationV2.conversation_id,
)
.outerjoin(
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(
or_(
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),
)
total_result = await session.execute(count_base_query)
total = total_result.scalar() or 0
sessions_data = [
{
"session_id": row.session_id,
"conversation_id": row.conversation_id,
"persona_id": row.persona_id,
"title": row.title,
"persona_name": row.persona_name,
}
for row in rows
]
return sessions_data, total
async def insert_platform_message_history(
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:
session: AsyncSession
async with session.begin():
new_history = PlatformMessageHistory(
platform_id=platform_id,
user_id=user_id,
content=content,
sender_id=sender_id,
sender_name=sender_name,
)
session.add(new_history)
return new_history
async def delete_platform_message_offset(
self,
platform_id,
user_id,
offset_sec=86400,
):
"""Delete platform message history records newer than the specified offset."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
now = datetime.now()
cutoff_time = now - timedelta(seconds=offset_sec)
await session.execute(
delete(PlatformMessageHistory).where(
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,
):
"""Get platform message history records."""
async with self.get_db() as session:
session: AsyncSession
offset = (page - 1) * page_size
query = (
select(PlatformMessageHistory)
.where(
PlatformMessageHistory.platform_id == platform_id,
PlatformMessageHistory.user_id == user_id,
)
.order_by(desc(PlatformMessageHistory.created_at))
)
result = await session.execute(query.offset(offset).limit(page_size))
return result.scalars().all()
async def get_platform_message_history_by_id(
self, message_id: int
) -> PlatformMessageHistory | None:
"""Get a platform message history record by its ID."""
async with self.get_db() as session:
session: AsyncSession
query = select(PlatformMessageHistory).where(
PlatformMessageHistory.id == message_id
)
result = await session.execute(query)
return result.scalar_one_or_none()
async def insert_attachment(self, path, type, mime_type):
"""Insert a new attachment record."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
new_attachment = Attachment(
path=path,
type=type,
mime_type=mime_type,
)
session.add(new_attachment)
return new_attachment
async def get_attachment_by_id(self, attachment_id):
"""Get an attachment by its ID."""
async with self.get_db() as session:
session: AsyncSession
query = select(Attachment).where(Attachment.attachment_id == attachment_id)
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_attachments(self, attachment_ids: list[str]) -> list:
"""Get multiple attachments by their IDs."""
if not attachment_ids:
return []
async with self.get_db() as session:
session: AsyncSession
query = select(Attachment).where(
Attachment.attachment_id.in_(attachment_ids)
)
result = await session.execute(query)
return list(result.scalars().all())
async def delete_attachment(self, attachment_id: str) -> bool:
"""Delete an attachment by its ID.
Returns True if the attachment was deleted, False if it was not found.
"""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
query = delete(Attachment).where(
col(Attachment.attachment_id) == attachment_id
)
result = await session.execute(query)
return result.rowcount > 0
async def delete_attachments(self, attachment_ids: list[str]) -> int:
"""Delete multiple attachments by their IDs.
Returns the number of attachments deleted.
"""
if not attachment_ids:
return 0
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
query = delete(Attachment).where(
col(Attachment.attachment_id).in_(attachment_ids)
)
result = await session.execute(query)
return result.rowcount
async def insert_persona(
self,
persona_id,
system_prompt,
begin_dialogs=None,
tools=None,
):
"""Insert a new persona record."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
new_persona = Persona(
persona_id=persona_id,
system_prompt=system_prompt,
begin_dialogs=begin_dialogs or [],
tools=tools,
)
session.add(new_persona)
return new_persona
async def get_persona_by_id(self, persona_id):
"""Get a persona by its ID."""
async with self.get_db() as session:
session: AsyncSession
query = select(Persona).where(Persona.persona_id == persona_id)
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_personas(self):
"""Get all personas for a specific bot."""
async with self.get_db() as session:
session: AsyncSession
query = select(Persona)
result = await session.execute(query)
return result.scalars().all()
async def update_persona(
self,
persona_id,
system_prompt=None,
begin_dialogs=None,
tools=NOT_GIVEN,
):
"""Update a persona's system prompt or begin dialogs."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
query = update(Persona).where(col(Persona.persona_id) == persona_id)
values = {}
if system_prompt is not None:
values["system_prompt"] = system_prompt
if begin_dialogs is not None:
values["begin_dialogs"] = begin_dialogs
if tools is not NOT_GIVEN:
values["tools"] = tools
if not values:
return None
query = query.values(**values)
await session.execute(query)
return await self.get_persona_by_id(persona_id)
async def delete_persona(self, persona_id):
"""Delete a persona by its ID."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
delete(Persona).where(col(Persona.persona_id) == persona_id),
)
async def insert_preference_or_update(self, scope, scope_id, key, value):
"""Insert a new preference record or update if it exists."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
query = select(Preference).where(
Preference.scope == scope,
Preference.scope_id == scope_id,
Preference.key == key,
)
result = await session.execute(query)
existing_preference = result.scalar_one_or_none()
if existing_preference:
existing_preference.value = value
else:
new_preference = Preference(
scope=scope,
scope_id=scope_id,
key=key,
value=value,
)
session.add(new_preference)
return existing_preference or new_preference
async def get_preference(self, scope, scope_id, key):
"""Get a preference by key."""
async with self.get_db() as session:
session: AsyncSession
query = select(Preference).where(
Preference.scope == scope,
Preference.scope_id == scope_id,
Preference.key == key,
)
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_preferences(self, scope, scope_id=None, key=None):
"""Get all preferences for a specific scope ID or key."""
async with self.get_db() as session:
session: AsyncSession
query = select(Preference).where(Preference.scope == scope)
if scope_id is not None:
query = query.where(Preference.scope_id == scope_id)
if key is not None:
query = query.where(Preference.key == key)
result = await session.execute(query)
return result.scalars().all()
async def remove_preference(self, scope, scope_id, key):
"""Remove a preference by scope ID and key."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
delete(Preference).where(
col(Preference.scope) == scope,
col(Preference.scope_id) == scope_id,
col(Preference.key) == key,
),
)
await session.commit()
async def clear_preferences(self, scope, scope_id):
"""Clear all preferences for a specific scope ID."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
delete(Preference).where(
col(Preference.scope) == scope,
col(Preference.scope_id) == scope_id,
),
)
await session.commit()
# ====
# Platform Session Management
# ====
async def create_platform_session(
self,
creator: str,
platform_id: str = "webchat",
session_id: str | None = None,
display_name: str | None = None,
is_group: int = 0,
) -> PlatformSession:
"""Create a new Platform session."""
kwargs = {}
if session_id:
kwargs["session_id"] = session_id
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
new_session = PlatformSession(
creator=creator,
platform_id=platform_id,
display_name=display_name,
is_group=is_group,
**kwargs,
)
session.add(new_session)
await session.flush()
await session.refresh(new_session)
return new_session
async def get_platform_session_by_id(
self, session_id: str
) -> PlatformSession | None:
"""Get a Platform session by its ID."""
async with self.get_db() as session:
session: AsyncSession
query = select(PlatformSession).where(
PlatformSession.session_id == session_id,
)
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_platform_sessions_by_creator(
self,
creator: str,
platform_id: str | None = None,
page: int = 1,
page_size: int = 20,
) -> list[PlatformSession]:
"""Get all Platform sessions for a specific creator (username) and optionally platform."""
async with self.get_db() as session:
session: AsyncSession
offset = (page - 1) * page_size
query = select(PlatformSession).where(PlatformSession.creator == creator)
if platform_id:
query = query.where(PlatformSession.platform_id == platform_id)
query = (
query.order_by(desc(PlatformSession.updated_at))
.offset(offset)
.limit(page_size)
)
result = await session.execute(query)
return list(result.scalars().all())
async def update_platform_session(
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:
session: AsyncSession
async with session.begin():
values: dict[str, T.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),
)
async def delete_platform_session(self, session_id: str) -> None:
"""Delete a Platform session by its ID."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
delete(PlatformSession).where(
col(PlatformSession.session_id) == session_id,
),
)
# ====
# Deprecated Methods
# ====
def get_base_stats(self, offset_sec=86400):
"""Get base statistics within the specified offset in seconds."""
return DeprecatedStats()
def get_total_message_count(self):
"""Get the total message count from platform statistics."""
return 0
def get_grouped_base_stats(self, offset_sec=86400):
# group by platform_id
return DeprecatedStats()
+15 -75
View File
@@ -12,7 +12,7 @@ class PlatformStat(SQLModel, table=True):
Note: In astrbot v4, we moved `platform` table to here.
"""
__tablename__: str = "platform_stats"
__tablename__ = "platform_stats" # type: ignore
id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True})
timestamp: datetime = Field(nullable=False)
@@ -31,10 +31,9 @@ class PlatformStat(SQLModel, table=True):
class ConversationV2(SQLModel, table=True):
__tablename__: str = "conversations"
__tablename__ = "conversations" # type: ignore
inner_conversation_id: int | None = Field(
default=None,
inner_conversation_id: int = Field(
primary_key=True,
sa_column_kwargs={"autoincrement": True},
)
@@ -69,7 +68,7 @@ class Persona(SQLModel, table=True):
It can be used to customize the behavior of LLMs.
"""
__tablename__: str = "personas"
__tablename__ = "personas" # type: ignore
id: int | None = Field(
primary_key=True,
@@ -99,7 +98,7 @@ class Persona(SQLModel, table=True):
class Preference(SQLModel, table=True):
"""This class represents preferences for bots."""
__tablename__: str = "preferences"
__tablename__ = "preferences" # type: ignore
id: int | None = Field(
default=None,
@@ -135,7 +134,7 @@ class PlatformMessageHistory(SQLModel, table=True):
or platform-specific messages.
"""
__tablename__: str = "platform_message_history"
__tablename__ = "platform_message_history" # type: ignore
id: int | None = Field(
primary_key=True,
@@ -163,7 +162,7 @@ class PlatformSession(SQLModel, table=True):
Each session can have multiple conversations (对话) associated with it.
"""
__tablename__: str = "platform_sessions"
__tablename__ = "platform_sessions" # type: ignore
inner_id: int | None = Field(
primary_key=True,
@@ -204,7 +203,7 @@ class Attachment(SQLModel, table=True):
Attachments can be images, files, or other media types.
"""
__tablename__: str = "attachments"
__tablename__ = "attachments" # type: ignore
inner_attachment_id: int | None = Field(
primary_key=True,
@@ -234,65 +233,6 @@ class Attachment(SQLModel, table=True):
)
class CommandConfig(SQLModel, table=True):
"""Per-command configuration overrides for dashboard management."""
__tablename__ = "command_configs" # type: ignore
handler_full_name: str = Field(
primary_key=True,
max_length=512,
)
plugin_name: str = Field(nullable=False, max_length=255)
module_path: str = Field(nullable=False, max_length=255)
original_command: str = Field(nullable=False, max_length=255)
resolved_command: str | None = Field(default=None, max_length=255)
enabled: bool = Field(default=True, nullable=False)
keep_original_alias: bool = Field(default=False, nullable=False)
conflict_key: str | None = Field(default=None, max_length=255)
resolution_strategy: str | None = Field(default=None, max_length=64)
note: str | None = Field(default=None, sa_type=Text)
extra_data: dict | None = Field(default=None, sa_type=JSON)
auto_managed: bool = Field(default=False, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
class CommandConflict(SQLModel, table=True):
"""Conflict tracking for duplicated command names."""
__tablename__ = "command_conflicts" # type: ignore
id: int | None = Field(
default=None, primary_key=True, sa_column_kwargs={"autoincrement": True}
)
conflict_key: str = Field(nullable=False, max_length=255)
handler_full_name: str = Field(nullable=False, max_length=512)
plugin_name: str = Field(nullable=False, max_length=255)
status: str = Field(default="pending", max_length=32)
resolution: str | None = Field(default=None, max_length=64)
resolved_command: str | None = Field(default=None, max_length=255)
note: str | None = Field(default=None, sa_type=Text)
extra_data: dict | None = Field(default=None, sa_type=JSON)
auto_generated: bool = Field(default=False, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
"conflict_key",
"handler_full_name",
name="uix_conflict_handler",
),
)
@dataclass
class Conversation:
"""LLM 对话类
@@ -321,17 +261,17 @@ class Personality(TypedDict):
v4.0.0 版本及之后推荐使用上面的 Persona 并且 mood_imitation_dialogs 字段已被废弃
"""
prompt: str
name: str
begin_dialogs: list[str]
mood_imitation_dialogs: list[str]
prompt: str = ""
name: str = ""
begin_dialogs: list[str] = []
mood_imitation_dialogs: list[str] = []
"""情感模拟对话预设。在 v4.0.0 版本及之后,已被废弃。"""
tools: list[str] | None
tools: list[str] | None = None
"""工具列表。None 表示使用所有工具,空列表表示不使用任何工具"""
# cache
_begin_dialogs_processed: list[dict]
_mood_imitation_dialogs_processed: str
_begin_dialogs_processed: list[dict] = []
_mood_imitation_dialogs_processed: str = ""
# ====
+140 -349
View File
@@ -1,18 +1,14 @@
import asyncio
import threading
import typing as T
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
from sqlalchemy import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col, delete, desc, func, or_, select, text, update
from astrbot.core.db import BaseDatabase
from astrbot.core.db import BaseDatabase, DatabaseType
from astrbot.core.db.po import (
Attachment,
CommandConfig,
CommandConflict,
ConversationV2,
Persona,
PlatformMessageHistory,
@@ -29,10 +25,11 @@ from astrbot.core.db.po import (
)
NOT_GIVEN = T.TypeVar("NOT_GIVEN")
TxResult = T.TypeVar("TxResult")
class SQLiteDatabase(BaseDatabase):
database_type = DatabaseType.SQLITE
def __init__(self, db_path: str) -> None:
self.db_path = db_path
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
@@ -93,12 +90,10 @@ class SQLiteDatabase(BaseDatabase):
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
select(func.count(col(PlatformStat.platform_id))).select_from(
PlatformStat,
),
select(func.sum(PlatformStat.count)).select_from(PlatformStat),
)
count = result.scalar_one_or_none()
return count if count is not None else 0
return count or 0
async def get_platform_stats(self, offset_sec: int = 86400) -> list[PlatformStat]:
"""Get platform statistics within the specified offset in seconds and group by platform_id."""
@@ -108,14 +103,46 @@ class SQLiteDatabase(BaseDatabase):
start_time = now - timedelta(seconds=offset_sec)
result = await session.execute(
text("""
SELECT * FROM platform_stats
SELECT platform_id, platform_type, SUM(count) as total_count, MAX(timestamp) as latest_ts
FROM platform_stats
WHERE timestamp >= :start_time
GROUP BY platform_id
ORDER BY timestamp DESC
GROUP BY platform_id, platform_type
ORDER BY latest_ts DESC
"""),
{"start_time": start_time},
)
return list(result.scalars().all())
rows = result.fetchall()
return [
PlatformStat(
id=0,
platform_id=row.platform_id,
platform_type=row.platform_type,
count=row.total_count,
timestamp=row.latest_ts,
)
for row in rows
]
async def get_platform_stats_time_series(
self, offset_sec: int = 86400
) -> list[tuple[int, int]]:
"""Get platform statistics time series data grouped by hour."""
async with self.get_db() as session:
session: AsyncSession
now = datetime.now()
start_time = now - timedelta(seconds=offset_sec)
result = await session.execute(
text("""
SELECT strftime('%s', datetime(timestamp, 'start of hour')) as hour_ts, SUM(count) as total_count
FROM platform_stats
WHERE timestamp >= :start_time
GROUP BY hour_ts
ORDER BY hour_ts ASC
"""),
{"start_time": start_time},
)
rows = result.fetchall()
return [(int(row.hour_ts), row.total_count) for row in rows]
# ====
# Conversation Management
@@ -494,7 +521,7 @@ class SQLiteDatabase(BaseDatabase):
async with self.get_db() as session:
session: AsyncSession
query = select(Attachment).where(
col(Attachment.attachment_id).in_(attachment_ids)
Attachment.attachment_id.in_(attachment_ids)
)
result = await session.execute(query)
return list(result.scalars().all())
@@ -510,7 +537,7 @@ class SQLiteDatabase(BaseDatabase):
query = delete(Attachment).where(
col(Attachment.attachment_id) == attachment_id
)
result = T.cast(CursorResult, await session.execute(query))
result = await session.execute(query)
return result.rowcount > 0
async def delete_attachments(self, attachment_ids: list[str]) -> int:
@@ -526,7 +553,7 @@ class SQLiteDatabase(BaseDatabase):
query = delete(Attachment).where(
col(Attachment.attachment_id).in_(attachment_ids)
)
result = T.cast(CursorResult, await session.execute(query))
result = await session.execute(query)
return result.rowcount
async def insert_persona(
@@ -674,338 +701,6 @@ class SQLiteDatabase(BaseDatabase):
)
await session.commit()
# ====
# Command Configuration & Conflict Tracking
# ====
async def _run_in_tx(
self,
fn: Callable[[AsyncSession], Awaitable[TxResult]],
) -> TxResult:
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
return await fn(session)
@staticmethod
def _apply_updates(model, **updates) -> None:
for field, value in updates.items():
if value is not None:
setattr(model, field, value)
@staticmethod
def _new_command_config(
handler_full_name: str,
plugin_name: str,
module_path: str,
original_command: str,
*,
resolved_command: str | None = None,
enabled: bool | None = None,
keep_original_alias: bool | None = None,
conflict_key: str | None = None,
resolution_strategy: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_managed: bool | None = None,
) -> CommandConfig:
return CommandConfig(
handler_full_name=handler_full_name,
plugin_name=plugin_name,
module_path=module_path,
original_command=original_command,
resolved_command=resolved_command,
enabled=True if enabled is None else enabled,
keep_original_alias=False
if keep_original_alias is None
else keep_original_alias,
conflict_key=conflict_key or original_command,
resolution_strategy=resolution_strategy,
note=note,
extra_data=extra_data,
auto_managed=bool(auto_managed),
)
@staticmethod
def _new_command_conflict(
conflict_key: str,
handler_full_name: str,
plugin_name: str,
*,
status: str | None = None,
resolution: str | None = None,
resolved_command: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_generated: bool | None = None,
) -> CommandConflict:
return CommandConflict(
conflict_key=conflict_key,
handler_full_name=handler_full_name,
plugin_name=plugin_name,
status=status or "pending",
resolution=resolution,
resolved_command=resolved_command,
note=note,
extra_data=extra_data,
auto_generated=bool(auto_generated),
)
async def get_command_configs(self) -> list[CommandConfig]:
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(select(CommandConfig))
return list(result.scalars().all())
async def get_command_config(
self,
handler_full_name: str,
) -> CommandConfig | None:
async with self.get_db() as session:
session: AsyncSession
return await session.get(CommandConfig, handler_full_name)
async def upsert_command_config(
self,
handler_full_name: str,
plugin_name: str,
module_path: str,
original_command: str,
*,
resolved_command: str | None = None,
enabled: bool | None = None,
keep_original_alias: bool | None = None,
conflict_key: str | None = None,
resolution_strategy: str | None = None,
note: str | None = None,
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:
config = self._new_command_config(
handler_full_name,
plugin_name,
module_path,
original_command,
resolved_command=resolved_command,
enabled=enabled,
keep_original_alias=keep_original_alias,
conflict_key=conflict_key,
resolution_strategy=resolution_strategy,
note=note,
extra_data=extra_data,
auto_managed=auto_managed,
)
session.add(config)
else:
self._apply_updates(
config,
plugin_name=plugin_name,
module_path=module_path,
original_command=original_command,
resolved_command=resolved_command,
enabled=enabled,
keep_original_alias=keep_original_alias,
conflict_key=conflict_key,
resolution_strategy=resolution_strategy,
note=note,
extra_data=extra_data,
auto_managed=auto_managed,
)
await session.flush()
await session.refresh(config)
return config
return await self._run_in_tx(_op)
async def delete_command_config(self, handler_full_name: str) -> None:
await self.delete_command_configs([handler_full_name])
async def delete_command_configs(self, handler_full_names: list[str]) -> None:
if not handler_full_names:
return
async def _op(session: AsyncSession) -> None:
await session.execute(
delete(CommandConfig).where(
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,
) -> list[CommandConflict]:
async with self.get_db() as session:
session: AsyncSession
query = select(CommandConflict)
if status:
query = query.where(CommandConflict.status == status)
result = await session.execute(query)
return list(result.scalars().all())
async def upsert_command_conflict(
self,
conflict_key: str,
handler_full_name: str,
plugin_name: str,
*,
status: str | None = None,
resolution: str | None = None,
resolved_command: str | None = None,
note: str | None = None,
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:
record = self._new_command_conflict(
conflict_key,
handler_full_name,
plugin_name,
status=status,
resolution=resolution,
resolved_command=resolved_command,
note=note,
extra_data=extra_data,
auto_generated=auto_generated,
)
session.add(record)
else:
self._apply_updates(
record,
plugin_name=plugin_name,
status=status,
resolution=resolution,
resolved_command=resolved_command,
note=note,
extra_data=extra_data,
auto_generated=auto_generated,
)
await session.flush()
await session.refresh(record)
return record
return await self._run_in_tx(_op)
async def delete_command_conflicts(self, ids: list[int]) -> None:
if not ids:
return
async def _op(session: AsyncSession) -> None:
await session.execute(
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."""
async def _inner():
async with self.get_db() as session:
session: AsyncSession
now = datetime.now()
start_time = now - timedelta(seconds=offset_sec)
result = await session.execute(
select(PlatformStat).where(PlatformStat.timestamp >= start_time),
)
all_datas = result.scalars().all()
deprecated_stats = DeprecatedStats()
for data in all_datas:
deprecated_stats.platform.append(
DeprecatedPlatformStat(
name=data.platform_id,
count=data.count,
timestamp=int(data.timestamp.timestamp()),
),
)
return deprecated_stats
result = None
def runner():
nonlocal result
result = asyncio.run(_inner())
t = threading.Thread(target=runner)
t.start()
t.join()
return result
def get_total_message_count(self):
"""Get the total message count from platform statistics."""
async def _inner():
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
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
result = None
def runner():
nonlocal result
result = asyncio.run(_inner())
t = threading.Thread(target=runner)
t.start()
t.join()
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:
session: AsyncSession
now = datetime.now()
start_time = now - timedelta(seconds=offset_sec)
result = await session.execute(
select(PlatformStat.platform_id, func.sum(PlatformStat.count))
.where(PlatformStat.timestamp >= start_time)
.group_by(PlatformStat.platform_id),
)
grouped_stats = result.all()
deprecated_stats = DeprecatedStats()
for platform_id, count in grouped_stats:
deprecated_stats.platform.append(
DeprecatedPlatformStat(
name=platform_id,
count=count,
timestamp=int(start_time.timestamp()),
),
)
return deprecated_stats
result = None
def runner():
nonlocal result
result = asyncio.run(_inner())
t = threading.Thread(target=runner)
t.start()
t.join()
return result
# ====
# Platform Session Management
# ====
@@ -1103,3 +798,99 @@ class SQLiteDatabase(BaseDatabase):
col(PlatformSession.session_id) == session_id,
),
)
# ====
# Deprecated Methods
# ====
def get_base_stats(self, offset_sec=86400):
"""Get base statistics within the specified offset in seconds."""
async def _inner():
async with self.get_db() as session:
session: AsyncSession
now = datetime.now()
start_time = now - timedelta(seconds=offset_sec)
result = await session.execute(
select(PlatformStat).where(PlatformStat.timestamp >= start_time),
)
all_datas = result.scalars().all()
deprecated_stats = DeprecatedStats()
for data in all_datas:
deprecated_stats.platform.append(
DeprecatedPlatformStat(
name=data.platform_id,
count=data.count,
timestamp=int(data.timestamp.timestamp()),
),
)
return deprecated_stats
result = None
def runner():
nonlocal result
result = asyncio.run(_inner())
t = threading.Thread(target=runner)
t.start()
t.join()
return result
def get_total_message_count(self):
"""Get the total message count from platform statistics."""
async def _inner():
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(
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
result = None
def runner():
nonlocal result
result = asyncio.run(_inner())
t = threading.Thread(target=runner)
t.start()
t.join()
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:
session: AsyncSession
now = datetime.now()
start_time = now - timedelta(seconds=offset_sec)
result = await session.execute(
select(PlatformStat.platform_id, func.sum(PlatformStat.count))
.where(PlatformStat.timestamp >= start_time)
.group_by(PlatformStat.platform_id),
)
grouped_stats = result.all()
deprecated_stats = DeprecatedStats()
for platform_id, count in grouped_stats:
deprecated_stats.platform.append(
DeprecatedPlatformStat(
name=platform_id,
count=count,
timestamp=int(start_time.timestamp()),
),
)
return deprecated_stats
result = None
def runner():
nonlocal result
result = asyncio.run(_inner())
t = threading.Thread(target=runner)
t.start()
t.join()
return result
@@ -90,6 +90,4 @@ class EmbeddingStorage:
path (str): 保存索引的路径
"""
if self.index is None:
return
faiss.write_index(self.index, self.path)
+1 -6
View File
@@ -27,7 +27,7 @@ class EventBus:
self,
event_queue: Queue,
pipeline_scheduler_mapping: dict[str, PipelineScheduler],
astrbot_config_mgr: AstrBotConfigManager,
astrbot_config_mgr: AstrBotConfigManager = None,
):
self.event_queue = event_queue # 事件队列
# abconf uuid -> scheduler
@@ -40,11 +40,6 @@ class EventBus:
conf_info = self.astrbot_config_mgr.get_conf_info(event.unified_msg_origin)
self._print_event(event, conf_info["name"])
scheduler = self.pipeline_scheduler_mapping.get(conf_info["id"])
if not scheduler:
logger.error(
f"PipelineScheduler not found for id: {conf_info['id']}, event ignored."
)
continue
asyncio.create_task(scheduler.execute(event))
def _print_event(self, event: AstrMessageEvent, conf_name: str):
@@ -166,11 +166,7 @@ class RetrievalManager:
# 5. Rerank
first_rerank = None
for kb_id in kb_ids:
vec_db = kb_options[kb_id]["vec_db"]
if not isinstance(vec_db, FaissVecDB):
logger.warning(f"vec_db for kb_id {kb_id} is not FaissVecDB")
continue
vec_db: FaissVecDB = kb_options[kb_id]["vec_db"]
rerank_pi = kb_options[kb_id]["rerank_provider_id"]
if (
vec_db
+1 -2
View File
@@ -24,7 +24,6 @@ import asyncio
import logging
import os
import sys
import time
from asyncio import Queue
from collections import deque
@@ -149,7 +148,7 @@ class LogQueueHandler(logging.Handler):
self.log_broker.publish(
{
"level": record.levelname,
"time": time.time(),
"time": record.asctime,
"data": log_entry,
},
)
+8 -13
View File
@@ -66,9 +66,6 @@ class ComponentType(str, Enum):
class BaseMessageComponent(BaseModel):
type: ComponentType
def __init__(self, **kwargs):
super().__init__(**kwargs)
def toDict(self):
data = {}
for k, v in self.__dict__.items():
@@ -554,7 +551,7 @@ class Node(BaseMessageComponent):
id: int | None = 0 # 忽略
name: str | None = "" # qq昵称
uin: str | None = "0" # qq号
content: list[BaseMessageComponent] = []
content: list[BaseMessageComponent] | None = []
seq: str | list | None = "" # 忽略
time: int | None = 0 # 忽略
@@ -618,7 +615,7 @@ class Nodes(BaseMessageComponent):
ret["messages"].append(d)
return ret
async def to_dict(self) -> dict:
async def to_dict(self):
"""将 Nodes 转换为字典格式,适用于 OneBot JSON 格式"""
ret = {"messages": []}
for node in self.nodes:
@@ -629,11 +626,12 @@ class Nodes(BaseMessageComponent):
class Json(BaseMessageComponent):
type = ComponentType.Json
data: dict
data: str | dict
resid: int | None = 0
def __init__(self, data: str | dict, **_):
if isinstance(data, str):
data = json.loads(data)
def __init__(self, data, **_):
if isinstance(data, dict):
data = json.dumps(data)
super().__init__(data=data, **_)
@@ -716,15 +714,12 @@ class File(BaseMessageComponent):
if self.url:
await self._download_file()
if self.file_:
return os.path.abspath(self.file_)
return os.path.abspath(self.file_)
return ""
async def _download_file(self):
"""下载文件"""
if not self.url:
raise ValueError("Download failed: No URL provided in File component.")
download_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(download_dir, exist_ok=True)
if self.name:
+2 -2
View File
@@ -98,8 +98,8 @@ class PersonaManager:
self,
persona_id: str,
system_prompt: str,
begin_dialogs: list[str] | None = None,
tools: list[str] | None = None,
begin_dialogs: list[str] = None,
tools: list[str] = None,
) -> Persona:
"""创建新的 persona。tools 参数为 None 时表示使用所有工具,空列表表示不使用任何工具"""
if await self.db.get_persona_by_id(persona_id):
@@ -24,7 +24,7 @@ class ContentSafetyCheckStage(Stage):
self,
event: AstrMessageEvent,
check_text: str | None = None,
) -> AsyncGenerator[None, None]:
) -> None | AsyncGenerator[None, None]:
"""检查内容安全"""
text = check_text if check_text else event.get_message_str()
ok, info = self.strategy_selector.check(text)
+1 -2
View File
@@ -11,7 +11,7 @@ from astrbot.core.star.star_handler import EventType, star_handlers_registry
async def call_handler(
event: AstrMessageEvent,
handler: T.Callable[..., T.Awaitable[T.Any] | T.AsyncGenerator[T.Any, None]],
handler: T.Callable[..., T.Awaitable[T.Any]],
*args,
**kwargs,
) -> T.AsyncGenerator[T.Any, None]:
@@ -91,7 +91,6 @@ async def call_event_hook(
)
for handler in handlers:
try:
assert inspect.iscoroutinefunction(handler.handler)
logger.debug(
f"hook({hook_type.name}) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
)
@@ -321,12 +321,7 @@ class InternalAgentSubStage(Stage):
elif isinstance(req.tool_calls_result, list):
for tcr in req.tool_calls_result:
messages.extend(tcr.to_openai_messages())
messages.append(
{
"role": "assistant",
"content": llm_response.completion_text or "*No response*",
}
)
messages.append({"role": "assistant", "content": llm_response.completion_text})
messages = list(filter(lambda item: "_no_save" not in item, messages))
await self.conv_manager.update_conversation(
event.unified_msg_origin,
@@ -57,7 +57,7 @@ async def run_third_party_agent(
logger.error(f"Third party agent runner error: {e}")
err_msg = (
f"\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n"
f"错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
f"错误信息: {e!s}\n\n请在控制台查看和分享错误详情。\n"
)
yield MessageChain().message(err_msg)
@@ -16,6 +16,7 @@ from ..stage import Stage
class StarRequestSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.curr_provider = ctx.plugin_manager.context.get_using_provider()
self.prompt_prefix = ctx.astrbot_config["provider_settings"]["prompt_prefix"]
self.identifier = ctx.astrbot_config["provider_settings"]["identifier"]
self.ctx = ctx
@@ -23,7 +24,7 @@ class StarRequestSubStage(Stage):
async def process(
self,
event: AstrMessageEvent,
) -> AsyncGenerator[Any, None]:
) -> AsyncGenerator[None, None]:
activated_handlers: list[StarHandlerMetadata] = event.get_extra(
"activated_handlers",
)
+1 -1
View File
@@ -60,7 +60,7 @@ class ProcessStage(Stage):
):
# 是否有过发送操作 and 是否是被 @ 或者通过唤醒前缀
if (
event.get_result() and not event.is_stopped()
event.get_result() and not event.get_result().is_stopped()
) or not event.get_result():
async for _ in self.agent_sub_stage.process(event):
yield
+2 -8
View File
@@ -117,9 +117,7 @@ class RespondStage(Stage):
if not self.enable_seg:
return False
if (result := event.get_result()) is None:
return False
if self.only_llm_result and not result.is_llm_result():
if self.only_llm_result and not event.get_result().is_llm_result():
return False
if event.get_platform_name() in [
@@ -158,11 +156,7 @@ class RespondStage(Stage):
result = event.get_result()
if result is None:
return
if event.get_extra("_streaming_finished", False):
# prevent some plugin make result content type to LLM_RESULT after streaming finished, lead to send again
return
if result.result_content_type == ResultContentType.STREAMING_FINISH:
event.set_extra("_streaming_finished", True)
return
logger.info(
@@ -191,7 +185,7 @@ class RespondStage(Stage):
if isinstance(component, Comp.File) and component.file:
# 支持 File 消息段的路径映射。
component.file = path_Mapping(mappings, component.file)
result.chain[idx] = component
event.get_result().chain[idx] = component
# 检查消息链是否为空
try:
+22 -89
View File
@@ -1,4 +1,3 @@
import random
import re
import time
import traceback
@@ -7,7 +6,6 @@ from collections.abc import AsyncGenerator
from astrbot.core import file_token_service, html_renderer, logger
from astrbot.core.message.components import At, File, Image, Node, Plain, Record, Reply
from astrbot.core.message.message_event_result import ResultContentType
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.message_type import MessageType
from astrbot.core.star.session_llm_manager import SessionServiceManager
@@ -43,18 +41,6 @@ class ResultDecorateStage(Stage):
"forward_threshold"
]
trigger_probability = ctx.astrbot_config["provider_tts_settings"].get(
"trigger_probability",
1,
)
try:
self.tts_trigger_probability = max(
0.0,
min(float(trigger_probability), 1.0),
)
except (TypeError, ValueError):
self.tts_trigger_probability = 1.0
# 分段回复
self.words_count_threshold = int(
ctx.astrbot_config["platform_settings"]["segmented_reply"][
@@ -67,22 +53,7 @@ class ResultDecorateStage(Stage):
self.only_llm_result = ctx.astrbot_config["platform_settings"][
"segmented_reply"
]["only_llm_result"]
self.split_mode = ctx.astrbot_config["platform_settings"][
"segmented_reply"
].get("split_mode", "regex")
self.regex = ctx.astrbot_config["platform_settings"]["segmented_reply"]["regex"]
self.split_words = ctx.astrbot_config["platform_settings"][
"segmented_reply"
].get("split_words", ["", "", "", "~", ""])
if self.split_words:
escaped_words = sorted(
[re.escape(word) for word in self.split_words], key=len, reverse=True
)
self.split_words_pattern = re.compile(
f"(.*?({'|'.join(escaped_words)})|.+$)", re.DOTALL
)
else:
self.split_words_pattern = None
self.content_cleanup_rule = ctx.astrbot_config["platform_settings"][
"segmented_reply"
]["content_cleanup_rule"]
@@ -98,28 +69,6 @@ class ResultDecorateStage(Stage):
self.content_safe_check_stage = stage_cls()
await self.content_safe_check_stage.initialize(ctx)
def _split_text_by_words(self, text: str) -> list[str]:
"""使用分段词列表分段文本"""
if not self.split_words_pattern:
return [text]
segments = self.split_words_pattern.findall(text)
result = []
for seg in segments:
if isinstance(seg, tuple):
content = seg[0]
if not isinstance(content, str):
continue
for word in self.split_words:
if content.endswith(word):
content = content[: -len(word)]
break
if content.strip():
result.append(content)
elif seg and seg.strip():
result.append(seg)
return result if result else [text]
async def process(
self,
event: AstrMessageEvent,
@@ -144,13 +93,11 @@ class ResultDecorateStage(Stage):
for comp in result.chain:
if isinstance(comp, Plain):
text += comp.text
if isinstance(self.content_safe_check_stage, ContentSafetyCheckStage):
async for _ in self.content_safe_check_stage.process(
event,
check_text=text,
):
yield
async for _ in self.content_safe_check_stage.process(
event,
check_text=text,
):
yield
# 发送消息前事件钩子
handlers = star_handlers_registry.get_handlers_by_event_type(
@@ -167,8 +114,7 @@ class ResultDecorateStage(Stage):
"启用流式输出时,依赖发送消息前事件钩子的插件可能无法正常工作",
)
await handler.handler(event)
if (result := event.get_result()) is None or not result.chain:
if event.get_result() is None or not event.get_result().chain:
logger.debug(
f"hook(on_decorating_result) -> {star_map[handler.handler_module_path].name} - {handler.handler_name} 将消息结果清空。",
)
@@ -215,27 +161,21 @@ class ResultDecorateStage(Stage):
# 不分段回复
new_chain.append(comp)
continue
# 根据 split_mode 选择分段方式
if self.split_mode == "words":
split_response = self._split_text_by_words(comp.text)
else: # regex 模式
try:
split_response = re.findall(
self.regex,
comp.text,
re.DOTALL | re.MULTILINE,
)
except re.error:
logger.error(
f"分段回复正则表达式错误,使用默认分段方式: {traceback.format_exc()}",
)
split_response = re.findall(
r".*?[。?!~…]+|.+$",
comp.text,
re.DOTALL | re.MULTILINE,
)
try:
split_response = re.findall(
self.regex,
comp.text,
re.DOTALL | re.MULTILINE,
)
except re.error:
logger.error(
f"分段回复正则表达式错误,使用默认分段方式: {traceback.format_exc()}",
)
split_response = re.findall(
r".*?[。?!~…]+|.+$",
comp.text,
re.DOTALL | re.MULTILINE,
)
if not split_response:
new_chain.append(comp)
continue
@@ -259,14 +199,7 @@ class ResultDecorateStage(Stage):
and result.is_llm_result()
and SessionServiceManager.should_process_tts_request(event)
):
should_tts = self.tts_trigger_probability >= 1.0 or (
self.tts_trigger_probability > 0.0
and random.random() <= self.tts_trigger_probability
)
if not should_tts:
logger.debug("跳过 TTS:触发概率未命中。")
elif not tts_provider:
if not tts_provider:
logger.warning(
f"会话 {event.unified_msg_origin} 未配置文本转语音模型。",
)
+1 -5
View File
@@ -2,10 +2,6 @@ from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.platform import AstrMessageEvent
from astrbot.core.platform.sources.webchat.webchat_event import WebChatMessageEvent
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import (
WecomAIBotMessageEvent,
)
from . import STAGES_ORDER
from .context import PipelineContext
@@ -82,7 +78,7 @@ class PipelineScheduler:
await self._process_stages(event)
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
if isinstance(event, (WebChatMessageEvent, WecomAIBotMessageEvent)):
if event.get_platform_name() in ["webchat", "wecom_ai_bot"]:
await event.send(None)
logger.debug("pipeline 执行完毕。")
@@ -50,9 +50,6 @@ class WakingCheckStage(Stage):
"ignore_at_all",
False,
)
self.disable_builtin_commands = self.ctx.astrbot_config.get(
"disable_builtin_commands", False
)
async def process(
self,
@@ -134,13 +131,6 @@ class WakingCheckStage(Stage):
EventType.AdapterMessageEvent,
plugins_name=event.plugins_name,
):
if (
self.disable_builtin_commands
and handler.handler_module_path == "packages.builtin_commands.main"
):
logger.debug("skipping builtin command")
continue
# filter 需满足 AND 逻辑关系
passed = True
permission_not_pass = False
+3 -5
View File
@@ -153,9 +153,7 @@ class AstrMessageEvent(abc.ABC):
def get_sender_name(self) -> str:
"""获取消息发送者的名称。(可能会返回空字符串)"""
if isinstance(self.message_obj.sender.nickname, str):
return self.message_obj.sender.nickname
return ""
return self.message_obj.sender.nickname
def set_extra(self, key, value):
"""设置额外的信息。"""
@@ -272,7 +270,7 @@ class AstrMessageEvent(abc.ABC):
"""
self.call_llm = call_llm
def get_result(self) -> MessageEventResult | None:
def get_result(self) -> MessageEventResult:
"""获取消息事件的结果。"""
return self._result
@@ -322,7 +320,7 @@ class AstrMessageEvent(abc.ABC):
self,
prompt: str,
func_tool_manager=None,
session_id: str = "",
session_id: str = None,
image_urls: list[str] | None = None,
contexts: list | None = None,
system_prompt: str = "",
+2 -2
View File
@@ -54,7 +54,7 @@ class AstrBotMessage:
self_id: str # 机器人的识别id
session_id: str # 会话id。取决于 unique_session 的设置。
message_id: str # 消息id
group: Group | None # 群组
group: Group # 群组
sender: MessageMember # 发送者
message: list[BaseMessageComponent] # 消息链使用 Nakuru 的消息链格式
message_str: str # 最直观的纯文本消息字符串
@@ -78,7 +78,7 @@ class AstrBotMessage:
return ""
@group_id.setter
def group_id(self, value: str | None):
def group_id(self, value: str):
"""设置 group_id"""
if value:
if self.group:
+1 -5
View File
@@ -5,7 +5,6 @@ from asyncio import Queue
from astrbot.core import logger
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
from .platform import Platform, PlatformStatus
from .register import platform_cls_map
@@ -19,7 +18,6 @@ class PlatformManager:
self._inst_map: dict[str, dict] = {}
self.astrbot_config = config
self.platforms_config = config["platform"]
self.settings = config["platform_settings"]
"""NOTE: 这里是 default 的配置文件,以保证最大的兼容性;
@@ -31,8 +29,6 @@ class PlatformManager:
"""初始化所有平台适配器"""
for platform in self.platforms_config:
try:
if ensure_platform_webhook_config(platform):
self.astrbot_config.save_config()
await self.load_platform(platform)
except Exception as e:
logger.error(f"初始化 {platform} 平台适配器失败: {e}")
@@ -114,7 +110,7 @@ class PlatformManager:
)
except (ImportError, ModuleNotFoundError) as e:
logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。",
)
except Exception as e:
logger.error(f"加载平台适配器 {platform_config['type']} 失败,原因:{e}")
+3 -11
View File
@@ -1,7 +1,7 @@
import abc
import uuid
from asyncio import Queue
from collections.abc import Coroutine
from collections.abc import Awaitable
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
@@ -80,13 +80,6 @@ class Platform(abc.ABC):
if self._status == PlatformStatus.ERROR:
self._status = PlatformStatus.RUNNING
def unified_webhook(self) -> bool:
"""是否正在使用统一 Webhook 模式"""
return bool(
self.config.get("unified_webhook_mode", False)
and self.config.get("webhook_uuid")
)
def get_stats(self) -> dict:
"""获取平台统计信息"""
meta = self.meta()
@@ -104,11 +97,10 @@ class Platform(abc.ABC):
}
if self.last_error
else None,
"unified_webhook": self.unified_webhook(),
}
@abc.abstractmethod
def run(self) -> Coroutine[Any, Any, None]:
def run(self) -> Awaitable[Any]:
"""得到一个平台的运行实例,需要返回一个协程对象。"""
raise NotImplementedError
@@ -124,7 +116,7 @@ class Platform(abc.ABC):
self,
session: MessageSesion,
message_chain: MessageChain,
) -> None:
):
"""通过会话发送消息。该方法旨在让插件能够直接通过**可持久化的会话数据**发送消息,而不需要保存 event 对象。
异步方法
+1 -1
View File
@@ -7,7 +7,7 @@ class PlatformMetadata:
"""平台的名称,即平台的类型,如 aiocqhttp, discord, slack"""
description: str
"""平台的描述"""
id: str
id: str | None = None
"""平台的唯一标识符,用于配置中识别特定平台"""
default_config_tmpl: dict | None = None
-1
View File
@@ -40,7 +40,6 @@ def register_platform_adapter(
pm = PlatformMetadata(
name=adapter_name,
description=desc,
id=adapter_name,
default_config_tmpl=default_config_tmpl,
adapter_display_name=adapter_display_name,
logo_path=logo_path,
@@ -70,18 +70,16 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
bot: CQHttp,
event: Event | None,
is_group: bool,
session_id: str | None,
session_id: str,
messages: list[dict],
):
# session_id 必须是纯数字字符串
session_id_int = (
int(session_id) if session_id and session_id.isdigit() else None
)
session_id = int(session_id) if session_id.isdigit() else None
if is_group and isinstance(session_id_int, int):
await bot.send_group_msg(group_id=session_id_int, message=messages)
elif not is_group and isinstance(session_id_int, int):
await bot.send_private_msg(user_id=session_id_int, message=messages)
if is_group and isinstance(session_id, int):
await bot.send_group_msg(group_id=session_id, message=messages)
elif not is_group and isinstance(session_id, int):
await bot.send_private_msg(user_id=session_id, message=messages)
elif isinstance(event, Event): # 最后兜底
await bot.send(event=event, message=messages)
else:
@@ -4,7 +4,7 @@ import logging
import time
import uuid
from collections.abc import Awaitable
from typing import Any, cast
from typing import Any
from aiocqhttp import CQHttp, Event
from aiocqhttp.exceptions import ActionFailed
@@ -48,7 +48,7 @@ class AiocqhttpAdapter(Platform):
self.metadata = PlatformMetadata(
name="aiocqhttp",
description="适用于 OneBot 标准的消息平台适配器,支持反向 WebSockets。",
id=cast(str, self.config.get("id")),
id=self.config.get("id"),
support_streaming_message=False,
)
@@ -127,9 +127,7 @@ class AiocqhttpAdapter(Platform):
"""OneBot V11 请求类事件"""
abm = AstrBotMessage()
abm.self_id = str(event.self_id)
abm.sender = MessageMember(
user_id=str(event.user_id), nickname=str(event.user_id)
)
abm.sender = MessageMember(user_id=str(event.user_id), nickname=event.user_id)
abm.type = MessageType.OTHER_MESSAGE
if event.get("group_id"):
abm.type = MessageType.GROUP_MESSAGE
@@ -196,7 +194,6 @@ class AiocqhttpAdapter(Platform):
@param event: 事件对象
@param get_reply: 是否获取回复消息这个参数是为了防止多个回复嵌套
"""
assert event.sender is not None
abm = AstrBotMessage()
abm.self_id = str(event.self_id)
abm.sender = MessageMember(
@@ -206,7 +203,6 @@ class AiocqhttpAdapter(Platform):
if event["message_type"] == "group":
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = str(event.group_id)
abm.group = Group(str(event.group_id))
abm.group.group_name = event.get("group_name", "N/A")
elif event["message_type"] == "private":
abm.type = MessageType.FRIEND_MESSAGE
@@ -232,7 +228,7 @@ class AiocqhttpAdapter(Platform):
await self.bot.send(event, err)
except BaseException as e:
logger.error(f"回复消息失败: {e}")
raise ValueError(err)
return None
# 按消息段类型类型适配
for t, m_group in itertools.groupby(event.message, key=lambda x: x["type"]):
@@ -385,25 +381,10 @@ class AiocqhttpAdapter(Platform):
logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。")
message_str += "".join(at_parts)
elif t == "markdown":
text = m["data"].get("markdown") or m["data"].get("content", "")
abm.message.append(Plain(text=text))
message_str += text
else:
for m in m_group:
try:
if t not in ComponentTypes:
logger.warning(
f"不支持的消息段类型,已忽略: {t}, data={m['data']}"
)
continue
a = ComponentTypes[t](**m["data"])
abm.message.append(a)
except Exception as e:
logger.exception(
f"消息段解析失败: type={t}, data={m['data']}. {e}"
)
continue
a = ComponentTypes[t](**m["data"])
abm.message.append(a)
abm.timestamp = int(time.time())
abm.message_str = message_str
@@ -436,7 +417,7 @@ class AiocqhttpAdapter(Platform):
async def shutdown_trigger_placeholder(self):
await self.shutdown_event.wait()
logger.info("aiocqhttp 适配器已被关闭")
logger.info("aiocqhttp 适配器已被优雅地关闭")
def meta(self) -> PlatformMetadata:
return self.metadata
@@ -2,7 +2,6 @@ import asyncio
import os
import threading
import uuid
from typing import cast
import aiohttp
import dingtalk_stream
@@ -55,14 +54,12 @@ class DingtalkPlatformAdapter(Platform):
self.client_id = platform_config["client_id"]
self.client_secret = platform_config["client_secret"]
outer_self = self
class AstrCallbackClient(dingtalk_stream.ChatbotHandler):
async def process(self, message: dingtalk_stream.CallbackMessage):
async def process(self_, message: dingtalk_stream.CallbackMessage):
logger.debug(f"dingtalk: {message.data}")
im = dingtalk_stream.ChatbotMessage.from_dict(message.data)
abm = await outer_self.convert_msg(im)
await outer_self.handle_msg(abm)
abm = await self.convert_msg(im)
await self.handle_msg(abm)
return AckMessage.STATUS_OK, "OK"
@@ -76,7 +73,6 @@ class DingtalkPlatformAdapter(Platform):
self.client,
)
self.client_ = client # 用于 websockets 的 client
self._shutdown_event: threading.Event | None = None
def _id_to_sid(self, dingtalk_id: str | None) -> str:
if not dingtalk_id:
@@ -97,7 +93,7 @@ class DingtalkPlatformAdapter(Platform):
return PlatformMetadata(
name="dingtalk",
description="钉钉机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
id=self.config.get("id"),
support_streaming_message=False,
)
@@ -108,7 +104,7 @@ class DingtalkPlatformAdapter(Platform):
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"
@@ -119,7 +115,7 @@ class DingtalkPlatformAdapter(Platform):
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:
@@ -136,16 +132,14 @@ class DingtalkPlatformAdapter(Platform):
else:
abm.session_id = abm.sender.user_id
message_type: str = cast(str, message.message_type)
message_type: str = message.message_type
match message_type:
case "text":
abm.message_str = message.text.content.strip()
abm.message.append(Plain(abm.message_str))
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
for content in contents:
plains = ""
if "text" in content:
@@ -154,7 +148,7 @@ class DingtalkPlatformAdapter(Platform):
elif "type" in content and content["type"] == "picture":
f_path = await self.download_ding_file(
content["downloadCode"],
cast(str, message.robot_code),
message.robot_code,
"jpg",
)
abm.message.append(Image.fromFileSystem(f_path))
@@ -199,7 +193,7 @@ class DingtalkPlatformAdapter(Platform):
logger.error(
f"下载钉钉文件失败: {resp.status}, {await resp.text()}",
)
return ""
return None
resp_data = await resp.json()
download_url = resp_data["data"]["downloadUrl"]
await download_file(download_url, f_path)
@@ -219,7 +213,7 @@ class DingtalkPlatformAdapter(Platform):
logger.error(
f"获取钉钉机器人 access_token 失败: {resp.status}, {await resp.text()}",
)
return ""
return None
return (await resp.json())["data"]["accessToken"]
async def handle_msg(self, abm: AstrBotMessage):
@@ -245,7 +239,7 @@ class DingtalkPlatformAdapter(Platform):
task.result()
except Exception as e:
if "Graceful shutdown" in str(e):
logger.info("钉钉适配器已被关闭")
logger.info("钉钉适配器已被优雅地关闭")
return
logger.error(f"钉钉机器人启动失败: {e}")
@@ -256,11 +250,9 @@ class DingtalkPlatformAdapter(Platform):
def monkey_patch_close():
raise KeyboardInterrupt("Graceful shutdown")
if self.client_.websocket is not None:
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()
self.client_.open_connection = monkey_patch_close
await self.client_.websocket.close(code=1000, reason="Graceful shutdown")
self._shutdown_event.set()
def get_client(self):
return self.client
@@ -1,5 +1,4 @@
import asyncio
from typing import cast
import dingtalk_stream
@@ -33,7 +32,7 @@ class DingtalkMessageEvent(AstrMessageEvent):
client.reply_markdown,
segment.text,
segment.text,
cast(dingtalk_stream.ChatbotMessage, self.message_obj.raw_message),
self.message_obj.raw_message,
)
elif isinstance(segment, Comp.Image):
markdown_str = ""
@@ -54,9 +53,7 @@ class DingtalkMessageEvent(AstrMessageEvent):
client.reply_markdown,
"😄",
markdown_str,
cast(
dingtalk_stream.ChatbotMessage, self.message_obj.raw_message
),
self.message_obj.raw_message,
)
logger.debug(f"send image: {ret}")
@@ -1,5 +1,4 @@
import sys
from collections.abc import Awaitable, Callable
import discord
@@ -28,16 +27,13 @@ class DiscordBotClient(discord.Bot):
super().__init__(intents=intents, proxy=proxy)
# 回调函数
self.on_message_received: Callable[[dict], Awaitable[None]] | None = None
self.on_ready_once_callback: Callable[[], Awaitable[None]] | None = None
self.on_message_received = None
self.on_ready_once_callback = None
self._ready_once_fired = False
@override
async def on_ready(self):
"""当机器人成功连接并准备就绪时触发"""
if self.user is None:
logger.error("[Discord] 客户端未正确加载用户信息 (self.user is None)")
return
logger.info(f"[Discord] 已作为 {self.user} (ID: {self.user.id}) 登录")
logger.info("[Discord] 客户端已准备就绪。")
@@ -53,9 +49,6 @@ class DiscordBotClient(discord.Bot):
def _create_message_data(self, message: discord.Message) -> dict:
"""从 discord.Message 创建数据字典"""
if self.user is None:
raise RuntimeError("Bot is not ready: self.user is None")
is_mentioned = self.user in message.mentions
return {
"message": message,
@@ -73,12 +66,6 @@ class DiscordBotClient(discord.Bot):
def _create_interaction_data(self, interaction: discord.Interaction) -> dict:
"""从 discord.Interaction 创建数据字典"""
if self.user is None:
raise RuntimeError("Bot is not ready: self.user is None")
if interaction.user is None:
raise ValueError("Interaction received without a valid user")
return {
"interaction": interaction,
"bot_id": str(self.user.id),
@@ -93,6 +80,7 @@ class DiscordBotClient(discord.Bot):
"type": "interaction",
}
@override
async def on_message(self, message: discord.Message):
"""当接收到消息时触发"""
if message.author.bot:
@@ -97,8 +97,8 @@ class DiscordView(BaseMessageComponent):
def __init__(
self,
components: list[BaseMessageComponent] | None = None,
timeout: float | None = None,
components: list[BaseMessageComponent] = None,
timeout: float = None,
):
self.components = components or []
self.timeout = timeout
@@ -1,10 +1,10 @@
import asyncio
import re
import sys
from typing import Any, cast
from typing import Any
import discord
from discord.abc import GuildChannel, Messageable, PrivateChannel
from discord.abc import Messageable
from discord.channel import DMChannel
from astrbot import logger
@@ -46,7 +46,7 @@ class DiscordPlatformAdapter(Platform):
) -> None:
super().__init__(platform_config, event_queue)
self.settings = platform_settings
self.client_self_id: str | None = None
self.client_self_id = None
self.registered_handlers = []
# 指令注册相关
self.enable_command_register = self.config.get("discord_command_register", True)
@@ -62,12 +62,6 @@ class DiscordPlatformAdapter(Platform):
message_chain: MessageChain,
):
"""通过会话发送消息"""
if self.client.user is None:
logger.error(
"[Discord] 客户端未就绪 (self.client.user is None),无法发送消息"
)
return
# 创建一个 message_obj 以便在 event 中使用
message_obj = AstrBotMessage()
if "_" in session.session_id:
@@ -95,7 +89,7 @@ class DiscordPlatformAdapter(Platform):
user_id=str(self.client_self_id),
nickname=self.client.user.display_name,
)
message_obj.self_id = cast(str, self.client_self_id)
message_obj.self_id = self.client_self_id
message_obj.session_id = session.session_id
message_obj.message = message_chain.chain
@@ -116,7 +110,7 @@ class DiscordPlatformAdapter(Platform):
return PlatformMetadata(
"discord",
"Discord 适配器",
id=cast(str, self.config.get("id")),
id=self.config.get("id"),
default_config_tmpl=self.config,
support_streaming_message=False,
)
@@ -166,7 +160,7 @@ class DiscordPlatformAdapter(Platform):
def _get_message_type(
self,
channel: Messageable | GuildChannel | PrivateChannel,
channel: Messageable,
guild_id: int | None = None,
) -> MessageType:
"""根据 channel 对象和 guild_id 判断消息类型"""
@@ -176,15 +170,13 @@ class DiscordPlatformAdapter(Platform):
return MessageType.FRIEND_MESSAGE
return MessageType.GROUP_MESSAGE
def _get_channel_id(
self, channel: Messageable | GuildChannel | PrivateChannel
) -> str:
def _get_channel_id(self, channel: Messageable) -> str:
"""根据 channel 对象获取ID"""
return str(getattr(channel, "id", None))
def _convert_message_to_abm(self, data: dict) -> AstrBotMessage:
"""将普通消息转换为 AstrBotMessage"""
message = data["message"]
message: discord.Message = data["message"]
content = message.content
@@ -241,7 +233,7 @@ class DiscordPlatformAdapter(Platform):
)
abm.message = message_chain
abm.raw_message = message
abm.self_id = cast(str, self.client_self_id)
abm.self_id = self.client_self_id
abm.session_id = str(message.channel.id)
abm.message_id = str(message.id)
return abm
@@ -262,52 +254,32 @@ class DiscordPlatformAdapter(Platform):
interaction_followup_webhook=followup_webhook,
)
if self.client.user is None:
logger.error(
"[Discord] 客户端未就绪 (self.client.user is None),无法处理消息"
)
return
# 检查是否为斜杠指令
is_slash_command = message_event.interaction_followup_webhook is not None
# 1. 优先处理斜杠指令
if is_slash_command:
message_event.is_wake = True
message_event.is_at_or_wake_command = True
self.commit_event(message_event)
return
# 2. 处理普通消息(提及检测)
# 确保 raw_message 是 discord.Message 类型,以便静态检查通过
raw_message = message.raw_message
if not isinstance(raw_message, discord.Message):
logger.warning(
f"[Discord] 收到非 Message 类型的消息: {type(raw_message)},已忽略。"
)
return
# 检查是否被@User Mention 或 Bot 拥有的 Role Mention
is_mention = False
# User Mention
# 此时 Pylance 知道 raw_message 是 discord.Message,具有 mentions 属性
if self.client.user in raw_message.mentions:
is_mention = True
if (
self.client
and self.client.user
and hasattr(message.raw_message, "mentions")
):
if self.client.user in message.raw_message.mentions:
is_mention = True
# Role MentionBot 拥有的角色被提及)
if not is_mention and raw_message.role_mentions:
if not is_mention and hasattr(message.raw_message, "role_mentions"):
bot_member = None
if raw_message.guild:
if hasattr(message.raw_message, "guild") and message.raw_message.guild:
try:
bot_member = raw_message.guild.get_member(
bot_member = message.raw_message.guild.get_member(
self.client.user.id,
)
except Exception:
bot_member = None
if bot_member and hasattr(bot_member, "roles"):
bot_roles = set(bot_member.roles)
mentioned_roles = set(raw_message.role_mentions)
mentioned_roles = set(message.raw_message.role_mentions)
if (
bot_roles
and mentioned_roles
@@ -315,8 +287,8 @@ class DiscordPlatformAdapter(Platform):
):
is_mention = True
# 如果是被@的消息,设置为唤醒状态
if is_mention:
# 如果是斜杠指令或被@的消息,设置为唤醒状态
if is_slash_command or is_mention:
message_event.is_wake = True
message_event.is_at_or_wake_command = True
@@ -452,7 +424,7 @@ class DiscordPlatformAdapter(Platform):
)
abm.message = [Plain(text=message_str_for_filter)]
abm.raw_message = ctx.interaction
abm.self_id = cast(str, self.client_self_id)
abm.self_id = self.client_self_id
abm.session_id = str(ctx.channel_id)
abm.message_id = str(ctx.interaction.id)
@@ -465,7 +437,7 @@ class DiscordPlatformAdapter(Platform):
def _extract_command_info(
event_filter: Any,
handler_metadata: StarHandlerMetadata,
) -> tuple[str, str, CommandFilter | None] | None:
) -> tuple[str, str, CommandFilter] | None:
"""从事件过滤器中提取指令信息"""
cmd_name = None
# is_group = False
@@ -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
@@ -87,9 +85,6 @@ class DiscordPlatformEvent(AstrMessageEvent):
channel = await self._get_channel()
if not channel:
return
if not isinstance(channel, discord.abc.Messageable):
logger.error(f"[Discord] 频道 {channel.id} 不是可发送消息的类型")
return
await channel.send(**kwargs)
except Exception as e:
@@ -112,9 +107,7 @@ class DiscordPlatformEvent(AstrMessageEvent):
await self.send(buffer)
return await super().send_streaming(generator, use_fallback)
async def _get_channel(
self,
) -> discord.Thread | discord.abc.GuildChannel | discord.abc.PrivateChannel | None:
async def _get_channel(self) -> discord.abc.Messageable | None:
"""获取当前事件对应的频道对象"""
try:
channel_id = int(self.session_id)
@@ -128,13 +121,7 @@ class DiscordPlatformEvent(AstrMessageEvent):
async def _parse_to_discord(
self,
message: MessageChain,
) -> tuple[
str,
list[discord.File],
discord.ui.View | None,
list[discord.Embed],
str | int | None,
]:
) -> tuple[str, list[discord.File], discord.ui.View | None, list[discord.Embed]]:
"""将 MessageChain 解析为 Discord 发送所需的内容"""
content_parts = []
files = []
@@ -274,9 +261,7 @@ class DiscordPlatformEvent(AstrMessageEvent):
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,7 +270,7 @@ 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
and self.message_obj.raw_message.type
== discord.InteractionType.application_command
)
@@ -294,18 +279,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 ""
@@ -318,9 +299,7 @@ class DiscordPlatformEvent(AstrMessageEvent):
):
return any(
mention.id == int(self.message_obj.self_id)
for mention in cast(
discord.Message, self.message_obj.raw_message
).mentions
for mention in self.message_obj.raw_message.mentions
)
return False
@@ -330,5 +309,5 @@ class DiscordPlatformEvent(AstrMessageEvent):
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
@@ -2,17 +2,10 @@ import asyncio
import base64
import json
import re
import time
import uuid
from typing import Any, cast
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
CreateMessageRequestBody,
GetMessageResourceRequest,
)
from lark_oapi.api.im.v1.processor import P2ImMessageReceiveV1Processor
from lark_oapi.api.im.v1 import *
import astrbot.api.message_components as Comp
from astrbot import logger
@@ -25,11 +18,9 @@ from astrbot.api.platform import (
PlatformMetadata,
)
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.utils.webhook_utils import log_webhook_info
from ...register import register_platform_adapter
from .lark_event import LarkMessageEvent
from .server import LarkWebhookServer
@register_platform_adapter(
@@ -51,13 +42,9 @@ class LarkPlatformAdapter(Platform):
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):
await self.convert_msg(event)
@@ -70,8 +57,6 @@ class LarkPlatformAdapter(Platform):
.build()
)
self.do_v2_msg_event = do_v2_msg_event
self.client = lark.ws.Client(
app_id=self.appid,
app_secret=self.appsecret,
@@ -81,56 +66,14 @@ class LarkPlatformAdapter(Platform):
)
self.lark_api = (
lark.Client.builder()
.app_id(self.appid)
.app_secret(self.appsecret)
.log_level(lark.LogLevel.ERROR)
.domain(self.domain)
.build()
lark.Client.builder().app_id(self.appid).app_secret(self.appsecret).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] = {}
def _clean_expired_events(self):
"""清理超过 30 分钟的事件记录"""
current_time = time.time()
expired_keys = [
event_id
for event_id, timestamp in self.event_id_timestamps.items()
if current_time - timestamp > 1800
]
for event_id in expired_keys:
del self.event_id_timestamps[event_id]
def _is_duplicate_event(self, event_id: str) -> bool:
"""检查事件是否重复
Args:
event_id: 事件ID
Returns:
True 表示重复事件False 表示新事件
"""
self._clean_expired_events()
if event_id in self.event_id_timestamps:
return True
self.event_id_timestamps[event_id] = time.time()
return False
async def send_by_session(
self,
session: MessageSesion,
message_chain: MessageChain,
):
if self.lark_api.im is None:
logger.error("[Lark] API Client im 模块未初始化,无法发送消息")
return
res = await LarkMessageEvent._convert_to_lark(message_chain, self.lark_api)
wrapped = {
"zh_cn": {
@@ -171,25 +114,14 @@ class LarkPlatformAdapter(Platform):
return PlatformMetadata(
name="lark",
description="飞书机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
id=self.config.get("id"),
support_streaming_message=False,
)
async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1):
if event.event is None:
logger.debug("[Lark] 收到空事件(event.event is None)")
return
message = event.event.message
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:
abm.timestamp = int(time.time())
abm.timestamp = int(message.create_time) / 1000
abm.message = []
abm.type = (
MessageType.GROUP_MESSAGE
@@ -204,28 +136,14 @@ class LarkPlatformAdapter(Platform):
at_list = {}
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)
at_list[m.key] = Comp.At(qq=m.id.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
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
content_json_b = json.loads(message.content)
if message.message_type == "text":
message_str_raw = content_json_b.get("text", "") # 带有 @ 的消息
message_str_raw = content_json_b["text"] # 带有 @ 的消息
at_pattern = r"(@_user_\d+)" # 可以根据需求修改正则
# at_users = re.findall(at_pattern, message_str_raw)
# 拆分文本,去掉AT符号部分
@@ -250,47 +168,27 @@ class LarkPlatformAdapter(Platform):
content_json_b = _ls
elif message.message_type == "image":
content_json_b = [
{
"tag": "img",
"image_key": content_json_b.get("image_key"),
"style": [],
},
{"tag": "img", "image_key": content_json_b["image_key"], "style": []},
]
if message.message_type in ("post", "image"):
for comp in content_json_b:
if comp.get("tag") == "at":
user_id = comp.get("user_id")
if user_id in at_list:
abm.message.append(at_list[user_id])
elif comp.get("tag") == "text" and comp.get("text", "").strip():
if comp["tag"] == "at":
abm.message.append(at_list[comp["user_id"]])
elif comp["tag"] == "text" and comp["text"].strip():
abm.message.append(Comp.Plain(comp["text"].strip()))
elif comp.get("tag") == "img":
image_key = comp.get("image_key")
if not image_key:
continue
elif comp["tag"] == "img":
image_key = comp["image_key"]
request = (
GetMessageResourceRequest.builder()
.message_id(cast(str, message.message_id))
.message_id(message.message_id)
.file_key(image_key)
.type("image")
.build()
)
if self.lark_api.im is None:
logger.error("[Lark] API Client im 模块未初始化")
continue
response = await self.lark_api.im.v1.message_resource.aget(request)
if not response.success():
logger.error(f"无法下载飞书图片: {image_key}")
continue
if response.file is None:
logger.error(f"飞书图片响应中不包含文件流: {image_key}")
continue
image_bytes = response.file.read()
image_base64 = base64.b64encode(image_bytes).decode()
abm.message.append(Comp.Image.fromBase64(image_base64))
@@ -298,19 +196,6 @@ class LarkPlatformAdapter(Platform):
for comp in abm.message:
if isinstance(comp, Comp.Plain):
abm.message_str += comp.text
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
or event.event.sender.sender_id.open_id is None
):
logger.error("[Lark] 消息发送者信息不完整")
return
abm.message_id = message.message_id
abm.raw_message = message
abm.sender = MessageMember(
@@ -342,61 +227,13 @@ class LarkPlatformAdapter(Platform):
self._event_queue.put_nowait(event)
async def handle_webhook_event(self, event_data: dict):
"""处理 Webhook 事件
Args:
event_data: Webhook 事件数据
"""
try:
header = event_data.get("header", {})
event_id = header.get("event_id", "")
if event_id and self._is_duplicate_event(event_id):
logger.debug(f"[Lark Webhook] 跳过重复事件: {event_id}")
return
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)
processor.do(data)
else:
logger.debug(f"[Lark Webhook] 未处理的事件类型: {event_type}")
except Exception as e:
logger.error(f"[Lark Webhook] 处理事件失败: {e}", exc_info=True)
async def run(self):
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 await self.webhook_server.handle_callback(request)
# self.client.start()
await self.client._connect()
async def terminate(self):
if self.connection_mode == "socket":
await self.client._disconnect()
logger.info("飞书(Lark) 适配器已关闭")
await self.client._disconnect()
logger.info("飞书(Lark) 适配器已被优雅地关闭")
def get_client(self) -> lark.ws.Client:
def get_client(self) -> lark.Client:
return self.client
def unified_webhook(self) -> bool:
return bool(
self.config.get("lark_connection_mode", "") == "webhook"
and self.config.get("webhook_uuid")
)
@@ -5,15 +5,7 @@ import uuid
from io import BytesIO
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateImageRequest,
CreateImageRequestBody,
CreateMessageReactionRequest,
CreateMessageReactionRequestBody,
Emoji,
ReplyMessageRequest,
ReplyMessageRequestBody,
)
from lark_oapi.api.im.v1 import *
from astrbot import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -52,7 +44,7 @@ class LarkMessageEvent(AstrMessageEvent):
file_path = comp.file.replace("file:///", "")
elif comp.file and comp.file.startswith("http"):
image_file_path = await download_image_by_url(comp.file)
file_path = image_file_path if image_file_path else ""
file_path = image_file_path
elif comp.file and comp.file.startswith("base64://"):
base64_str = comp.file.removeprefix("base64://")
image_data = base64.b64decode(base64_str)
@@ -62,17 +54,10 @@ class LarkMessageEvent(AstrMessageEvent):
with open(file_path, "wb") as f:
f.write(BytesIO(image_data).getvalue())
else:
file_path = comp.file if comp.file else ""
file_path = comp.file
if image_file is None:
if not file_path:
logger.error("[Lark] 图片路径为空,无法上传")
continue
try:
image_file = open(file_path, "rb")
except Exception as e:
logger.error(f"[Lark] 无法打开图片文件: {e}")
continue
image_file = open(file_path, "rb")
request = (
CreateImageRequest.builder()
@@ -84,20 +69,9 @@ class LarkMessageEvent(AstrMessageEvent):
)
.build()
)
if lark_client.im is None:
logger.error("[Lark] API Client im 模块未初始化,无法上传图片")
continue
response = await lark_client.im.v1.image.acreate(request)
if not response.success():
logger.error(f"无法上传飞书图片({response.code}): {response.msg}")
continue
if response.data is None:
logger.error("[Lark] 上传图片成功但未返回数据(data is None)")
continue
image_key = response.data.image_key
logger.debug(image_key)
ret.append(_stage)
@@ -133,10 +107,6 @@ class LarkMessageEvent(AstrMessageEvent):
.build()
)
if self.bot.im is None:
logger.error("[Lark] API Client im 模块未初始化,无法回复消息")
return
response = await self.bot.im.v1.message.areply(request)
if not response.success():
@@ -145,10 +115,6 @@ class LarkMessageEvent(AstrMessageEvent):
await super().send(message)
async def react(self, emoji: str):
if self.bot.im is None:
logger.error("[Lark] API Client im 模块未初始化,无法发送表情")
return
request = (
CreateMessageReactionRequest.builder()
.message_id(self.message_obj.message_id)
@@ -159,7 +125,6 @@ class LarkMessageEvent(AstrMessageEvent):
)
.build()
)
response = await self.bot.im.v1.message_reaction.acreate(request)
if not response.success():
logger.error(f"发送飞书表情回应失败({response.code}): {response.msg}")
@@ -1,206 +0,0 @@
"""飞书(Lark) Webhook 服务器实现
实现飞书事件订阅的 Webhook 模式支持:
1. 请求 URL 验证 (challenge 验证)
2. 事件加密/解密 (AES-256-CBC)
3. 签名校验 (SHA256)
4. 事件接收和处理
"""
import asyncio
import base64
import hashlib
import json
from collections.abc import Awaitable, Callable
from Crypto.Cipher import AES
from astrbot.api import logger
class AESCipher:
"""AES 加密/解密工具类"""
def __init__(self, key: str):
self.bs = AES.block_size
self.key = hashlib.sha256(self.str_to_bytes(key)).digest()
@staticmethod
def str_to_bytes(data):
u_type = type(b"".decode("utf8"))
if isinstance(data, u_type):
return data.encode("utf8")
return data
@staticmethod
def _unpad(s):
return s[: -ord(s[len(s) - 1 :])]
def decrypt(self, enc):
iv = enc[: AES.block_size]
cipher = AES.new(self.key, AES.MODE_CBC, iv)
return self._unpad(cipher.decrypt(enc[AES.block_size :]))
def decrypt_string(self, enc):
enc = base64.b64decode(enc)
return self.decrypt(enc).decode("utf8")
class LarkWebhookServer:
"""飞书 Webhook 服务器
仅支持统一 Webhook 模式
"""
def __init__(self, config: dict, event_queue: asyncio.Queue):
"""初始化 Webhook 服务器
Args:
config: 飞书配置
event_queue: 事件队列
"""
self.app_id = config["app_id"]
self.app_secret = config["app_secret"]
self.encrypt_key = config.get("lark_encrypt_key", "")
self.verification_token = config.get("lark_verification_token", "")
self.event_queue = event_queue
self.callback: Callable[[dict], Awaitable[None]] | None = None
# 初始化加密工具
self.cipher = None
if self.encrypt_key:
self.cipher = AESCipher(self.encrypt_key)
def verify_signature(
self,
timestamp: str,
nonce: str,
encrypt_key: str,
body: bytes,
signature: str,
) -> bool:
"""验证签名
Args:
timestamp: 请求时间戳
nonce: 随机数
encrypt_key: 加密密钥
body: 请求体
signature: 签名
Returns:
签名是否有效
"""
# 拼接字符串: timestamp + nonce + encrypt_key + body
bytes_b1 = (timestamp + nonce + encrypt_key).encode("utf-8")
bytes_b = bytes_b1 + body
h = hashlib.sha256(bytes_b)
calculated_signature = h.hexdigest()
return calculated_signature == signature
def decrypt_event(self, encrypted_data: str) -> dict:
"""解密事件数据
Args:
encrypted_data: 加密的事件数据
Returns:
解密后的事件字典
"""
if not self.cipher:
raise ValueError("未配置 encrypt_key,无法解密事件")
decrypted_str = self.cipher.decrypt_string(encrypted_data)
return json.loads(decrypted_str)
async def handle_challenge(self, event_data: dict) -> dict:
"""处理 challenge 验证请求
Args:
event_data: 事件数据
Returns:
包含 challenge 的响应
"""
challenge = event_data.get("challenge", "")
logger.info(f"[Lark Webhook] 收到 challenge 验证请求: {challenge}")
return {"challenge": challenge}
async def handle_callback(self, request) -> tuple[dict, int] | dict:
"""处理 webhook 回调,可被统一 webhook 入口复用
Args:
request: Quart 请求对象
Returns:
响应数据
"""
# 获取原始请求体
body = await request.get_data()
try:
event_data = await request.json
except Exception as e:
logger.error(f"[Lark Webhook] 解析请求体失败: {e}")
return {"error": "Invalid JSON"}, 400
if not event_data:
logger.error("[Lark Webhook] 请求体为空")
return {"error": "Empty request body"}, 400
# 如果配置了 encrypt_key,进行签名验证
if self.encrypt_key:
timestamp = request.headers.get("X-Lark-Request-Timestamp", "")
nonce = request.headers.get("X-Lark-Request-Nonce", "")
signature = request.headers.get("X-Lark-Signature", "")
if timestamp and nonce and signature:
if not self.verify_signature(
timestamp, nonce, self.encrypt_key, body, signature
):
logger.error("[Lark Webhook] 签名验证失败")
return {"error": "Invalid signature"}, 401
# 检查是否是加密事件
if "encrypt" in event_data:
try:
event_data = self.decrypt_event(event_data["encrypt"])
logger.debug(f"[Lark Webhook] 解密后的事件: {event_data}")
except Exception as e:
logger.error(f"[Lark Webhook] 解密事件失败: {e}")
return {"error": "Decryption failed"}, 400
# 验证 token
if self.verification_token:
header = event_data.get("header", {})
if header:
token = header.get("token", "")
else:
token = event_data.get("token", "")
if token != self.verification_token:
logger.error("[Lark Webhook] Verification Token 不匹配。")
return {"error": "Invalid verification token"}, 401
# 处理 URL 验证 (challenge)
if event_data.get("type") == "url_verification":
return await self.handle_challenge(event_data)
# 调用回调函数处理事件
if self.callback:
try:
await self.callback(event_data)
except Exception as e:
logger.error(f"[Lark Webhook] 处理事件回调失败: {e}", exc_info=True)
return {"error": "Event processing failed"}, 500
return {}
def set_callback(self, callback: Callable[[dict], Awaitable[None]]):
"""设置事件回调函数
Args:
callback: 处理事件的异步函数
"""
self.callback = callback
@@ -1,6 +1,7 @@
import asyncio
import os
import random
from collections.abc import Awaitable
from typing import Any
import astrbot.api.message_components as Comp
@@ -202,7 +203,7 @@ class MisskeyPlatformAdapter(Platform):
if not isinstance(message.raw_message, dict):
message.raw_message = {}
message.raw_message["poll"] = poll
message.__setattr__("poll", poll)
message.poll = poll
except Exception:
pass
@@ -371,7 +372,7 @@ class MisskeyPlatformAdapter(Platform):
self,
session: MessageSession,
message_chain: MessageChain,
) -> None:
) -> Awaitable[Any]:
if not self.api:
logger.error("[Misskey] API 客户端未初始化")
return await super().send_by_session(session, message_chain)
@@ -3,7 +3,6 @@ import base64
import os
import random
import uuid
from typing import cast
import aiofiles
import botpy
@@ -61,10 +60,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
time_since_last_edit = current_time - last_edit_time
if time_since_last_edit >= 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
stream_payload["id"] = ret["id"]
last_edit_time = asyncio.get_event_loop().time()
@@ -87,8 +83,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
return None
source = self.message_obj.raw_message
if not isinstance(
assert isinstance(
source,
(
botpy.message.Message,
@@ -96,9 +91,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
botpy.message.DirectMessage,
botpy.message.C2CMessage,
),
):
logger.warning(f"[QQOfficial] 不支持的消息源类型: {type(source)}")
return None
)
(
plain_text,
@@ -115,7 +108,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
):
return None
payload: dict = {
payload = {
"content": plain_text,
"msg_id": self.message_obj.message_id,
}
@@ -125,12 +118,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
ret = None
match source:
case botpy.message.GroupMessage():
if not source.group_openid:
logger.error("[QQOfficial] GroupMessage 缺少 group_openid")
return None
match type(source):
case botpy.message.GroupMessage:
if image_base64:
media = await self.upload_group_and_c2c_image(
image_base64,
@@ -151,8 +140,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
group_openid=source.group_openid,
**payload,
)
case botpy.message.C2CMessage():
case botpy.message.C2CMessage:
if image_base64:
media = await self.upload_group_and_c2c_image(
image_base64,
@@ -181,23 +169,18 @@ class QQOfficialMessageEvent(AstrMessageEvent):
**payload,
)
logger.debug(f"Message sent to C2C: {ret}")
case botpy.message.Message():
case botpy.message.Message:
if image_path:
payload["file_image"] = image_path
ret = await self.bot.api.post_message(
channel_id=source.channel_id,
**payload,
)
case botpy.message.DirectMessage():
case botpy.message.DirectMessage:
if image_path:
payload["file_image"] = image_path
ret = await self.bot.api.post_dms(guild_id=source.guild_id, **payload)
case _:
pass
await super().send(self.send_buffer)
self.send_buffer = None
@@ -215,33 +198,18 @@ class QQOfficialMessageEvent(AstrMessageEvent):
"file_type": file_type,
"srv_send_msg": False,
}
result = None
if "openid" in kwargs:
payload["openid"] = kwargs["openid"]
route = Route("POST", "/v2/users/{openid}/files", openid=kwargs["openid"])
result = await self.bot.api._http.request(route, json=payload)
elif "group_openid" in kwargs:
return await self.bot.api._http.request(route, json=payload)
if "group_openid" in kwargs:
payload["group_openid"] = kwargs["group_openid"]
route = Route(
"POST",
"/v2/groups/{group_openid}/files",
group_openid=kwargs["group_openid"],
)
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"],
ttl=result.get("ttl", 0),
)
return await self.bot.api._http.request(route, json=payload)
async def upload_group_and_c2c_record(
self,
@@ -284,14 +252,11 @@ class QQOfficialMessageEvent(AstrMessageEvent):
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"],
file_uuid=result.get("file_uuid"),
file_info=result.get("file_info"),
ttl=result.get("ttl", 0),
file_id=result.get("id", ""),
)
except Exception as e:
logger.error(f"上传请求错误: {e}")
@@ -308,7 +273,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
message_reference: message.Reference | None = None,
media: message.Media | None = None,
msg_id: str | None = None,
msg_seq: int | None = 1,
msg_seq: str = 1,
event_id: str | None = None,
markdown: message.MarkdownPayload | None = None,
keyboard: message.Keyboard | None = None,
@@ -317,14 +282,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload = locals()
payload.pop("self", None)
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
result = await self.bot.api._http.request(route, json=payload)
if not isinstance(result, dict):
raise RuntimeError(
f"Failed to post c2c message, response is not dict: {result}"
)
return message.Message(**result)
return await self.bot.api._http.request(route, json=payload)
@staticmethod
async def _parse_to_qqofficial(message: MessageChain):
@@ -344,10 +302,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
image_base64 = file_to_base64(image_file_path)
elif i.file and i.file.startswith("base64://"):
image_base64 = i.file
elif i.file:
image_base64 = file_to_base64(i.file)
else:
raise ValueError("Unsupported image file format")
image_base64 = file_to_base64(i.file)
image_base64 = image_base64.removeprefix("base64://")
elif isinstance(i, Record):
if i.file:
@@ -4,7 +4,6 @@ import asyncio
import logging
import os
import time
from typing import cast
import botpy
import botpy.message
@@ -45,9 +44,7 @@ class botClient(Client):
MessageType.GROUP_MESSAGE,
)
abm.session_id = (
abm.sender.user_id
if self.platform.unique_session
else cast(str, message.group_openid)
abm.sender.user_id if self.platform.unique_session else message.group_openid
)
self._commit(abm)
@@ -104,7 +101,7 @@ class QQOfficialPlatformAdapter(Platform):
self.appid = platform_config["appid"]
self.secret = platform_config["secret"]
self.unique_session: bool = platform_settings["unique_session"]
self.unique_session = platform_settings["unique_session"]
qq_group = platform_config["enable_group_c2c"]
guild_dm = platform_config["enable_guild_direct_message"]
@@ -140,15 +137,12 @@ class QQOfficialPlatformAdapter(Platform):
return PlatformMetadata(
name="qq_official",
description="QQ 机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
id=self.config.get("id"),
)
@staticmethod
def _parse_from_qqofficial(
message: botpy.message.Message
| botpy.message.GroupMessage
| botpy.message.DirectMessage
| botpy.message.C2CMessage,
message: botpy.message.Message | botpy.message.GroupMessage,
message_type: MessageType,
):
abm = AstrBotMessage()
@@ -156,7 +150,7 @@ class QQOfficialPlatformAdapter(Platform):
abm.timestamp = int(time.time())
abm.raw_message = message
abm.message_id = message.id
# abm.tag = "qq_official"
abm.tag = "qq_official"
msg: list[BaseMessageComponent] = []
if isinstance(message, botpy.message.GroupMessage) or isinstance(
@@ -186,9 +180,9 @@ class QQOfficialPlatformAdapter(Platform):
message,
botpy.message.DirectMessage,
):
if isinstance(message, botpy.message.Message):
try:
abm.self_id = str(message.mentions[0].id)
else:
except BaseException as _:
abm.self_id = ""
plain_content = message.content.replace(
@@ -1,6 +1,6 @@
import asyncio
import logging
from typing import Any, cast
from typing import Any
import botpy
import botpy.message
@@ -36,9 +36,7 @@ class botClient(Client):
MessageType.GROUP_MESSAGE,
)
abm.session_id = (
abm.sender.user_id
if self.platform.unique_session
else cast(str, message.group_openid)
abm.sender.user_id if self.platform.unique_session else message.group_openid
)
self._commit(abm)
@@ -122,7 +120,7 @@ class QQOfficialWebhookPlatformAdapter(Platform):
return PlatformMetadata(
name="qq_official_webhook",
description="QQ 机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
id=self.config.get("id"),
)
async def run(self):
@@ -1,6 +1,5 @@
import asyncio
import logging
from typing import cast
import quart
from botpy import BotAPI, BotHttp, BotWebSocket, Client, ConnectionSession, Token
@@ -100,7 +99,7 @@ class QQOfficialWebhook:
if opcode == 13:
# validation
signed = await self.webhook_validation(cast(dict, data))
signed = await self.webhook_validation(data)
print(signed)
return signed
@@ -4,11 +4,9 @@ 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
from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient
from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.socket_mode.response import SocketModeResponse
from slack_sdk.web.async_client import AsyncWebClient
@@ -68,7 +66,7 @@ class SlackWebhookClient:
"""
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
@@ -141,14 +139,9 @@ class SlackSocketClient:
self.event_handler = event_handler
self.socket_client = None
async def _handle_events(
self, _: AsyncBaseSocketModeClient, req: SocketModeRequest
):
async def _handle_events(self, _: SocketModeClient, req: SocketModeRequest):
"""处理 Socket Mode 事件"""
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)
@@ -3,7 +3,8 @@ import base64
import re
import time
import uuid
from typing import Any, cast
from collections.abc import Awaitable
from typing import Any
import aiohttp
from slack_sdk.socket_mode.request import SocketModeRequest
@@ -67,7 +68,7 @@ class SlackAdapter(Platform):
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,
)
@@ -117,13 +118,13 @@ class SlackAdapter(Platform):
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
@@ -134,7 +135,7 @@ class SlackAdapter(Platform):
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
@@ -177,7 +178,7 @@ class SlackAdapter(Platform):
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,
@@ -328,7 +329,7 @@ class SlackAdapter(Platform):
)
raise Exception(f"下载文件失败: {resp.status}")
async def run(self) -> None:
async def run(self) -> Awaitable[Any]:
self.bot_self_id = await self.get_bot_user_id()
logger.info(f"Slack auth test OK. Bot ID: {self.bot_self_id}")
@@ -409,7 +410,7 @@ class SlackAdapter(Platform):
await self.socket_client.stop()
if self.webhook_client:
await self.webhook_client.stop()
logger.info("Slack 适配器已被关闭")
logger.info("Slack 适配器已被优雅地关闭")
def meta(self) -> PlatformMetadata:
return self.metadata
@@ -427,10 +428,3 @@ class SlackAdapter(Platform):
def get_client(self):
return self.web_client
def unified_webhook(self) -> bool:
return bool(
self.config.get("unified_webhook_mode", False)
and self.config.get("slack_connection_mode", "") == "webhook"
and self.config.get("webhook_uuid")
)
@@ -1,7 +1,6 @@
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
@@ -39,7 +38,7 @@ class SlackMessageEvent(AstrMessageEvent):
if isinstance(segment, Image):
# upload file
url = segment.url or segment.file
if url and url.startswith("http"):
if url.startswith("http"):
return {
"type": "image",
"image_url": url,
@@ -56,7 +55,7 @@ class SlackMessageEvent(AstrMessageEvent):
"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",
@@ -78,7 +77,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": {
@@ -226,10 +225,10 @@ class SlackMessageEvent(AstrMessageEvent):
)
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,
@@ -241,7 +240,7 @@ class SlackMessageEvent(AstrMessageEvent):
# 如果获取用户信息失败,使用默认信息
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", ""),
@@ -424,6 +424,6 @@ class TelegramPlatformAdapter(Platform):
if self.application.updater is not None:
await self.application.updater.stop()
logger.info("Telegram 适配器已被关闭")
logger.info("Telegram 适配器已被优雅地关闭")
except Exception as e:
logger.error(f"Telegram 适配器关闭时出错: {e}")
@@ -1,7 +1,6 @@
import asyncio
import os
import re
from typing import Any, cast
import telegramify_markdown
from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji
@@ -18,6 +17,8 @@ from astrbot.api.message_components import (
Reply,
)
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import download_file
class TelegramPlatformEvent(AstrMessageEvent):
@@ -96,7 +97,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
"chat_id": user_name,
}
if has_reply:
payload["reply_to_message_id"] = str(reply_message_id)
payload["reply_to_message_id"] = reply_message_id
if message_thread_id:
payload["message_thread_id"] = message_thread_id
@@ -109,30 +110,33 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
md_text = telegramify_markdown.markdownify(
chunk,
max_line_length=None,
normalize_whitespace=False,
)
await client.send_message(
text=md_text,
parse_mode="MarkdownV2",
**cast(Any, payload),
**payload,
)
except Exception as e:
logger.warning(
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()
await client.send_photo(photo=image_path, **cast(Any, payload))
await client.send_photo(photo=image_path, **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)
)
if i.file.startswith("https://"):
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, i.name)
await download_file(i.file, path)
i.file = path
await client.send_document(document=i.file, filename=i.name, **payload)
elif isinstance(i, Record):
path = await i.convert_to_file_path()
await client.send_voice(voice=path, **cast(Any, payload))
await client.send_voice(voice=path, **payload)
async def send(self, message: MessageChain):
if self.get_message_type() == MessageType.GROUP_MESSAGE:
@@ -200,15 +204,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
if isinstance(chain, MessageChain):
if chain.type == "break":
# 分割符
if message_id:
try:
await self.client.edit_message_text(
text=delta,
chat_id=payload["chat_id"],
message_id=message_id,
)
except Exception as e:
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
message_id = None # 重置消息 ID
delta = "" # 重置 delta
continue
@@ -219,23 +214,24 @@ class TelegramPlatformEvent(AstrMessageEvent):
delta += i.text
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
await self.client.send_photo(
photo=image_path, **cast(Any, payload)
)
await self.client.send_photo(photo=image_path, **payload)
continue
elif isinstance(i, File):
path = await i.get_file()
name = i.name or os.path.basename(path)
if i.file.startswith("https://"):
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, i.name)
await download_file(i.file, path)
i.file = path
await self.client.send_document(
document=path,
filename=name,
**cast(Any, payload),
document=i.file,
filename=i.name,
**payload,
)
continue
elif isinstance(i, Record):
path = await i.convert_to_file_path()
await self.client.send_voice(voice=path, **cast(Any, payload))
await self.client.send_voice(voice=path, **payload)
continue
else:
logger.warning(f"不支持的消息类型: {type(i)}")
@@ -264,9 +260,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
else:
# delta 长度一般不会大于 4096,因此这里直接发送
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}")
@@ -280,6 +274,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
markdown_text = telegramify_markdown.markdownify(
delta,
max_line_length=None,
normalize_whitespace=False,
)
await self.client.edit_message_text(
@@ -2,7 +2,7 @@ import asyncio
import os
import time
import uuid
from collections.abc import Callable, Coroutine
from collections.abc import Awaitable, Callable
from typing import Any
from astrbot import logger
@@ -207,7 +207,7 @@ class WebChatAdapter(Platform):
abm.raw_message = data
return abm
def run(self) -> Coroutine[Any, Any, None]:
def run(self) -> Awaitable[Any]:
async def callback(data: tuple):
abm = await self.convert_message(data)
await self.handle_msg(abm)
@@ -1,12 +1,11 @@
import base64
import json
import os
import shutil
import uuid
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import File, Image, Json, Plain, Record
from astrbot.api.message_components import File, Image, Plain, Record
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .webchat_queue_mgr import webchat_queue_mgr
@@ -42,20 +41,12 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{
"type": "plain",
"cid": cid,
"data": data,
"streaming": streaming,
"chain_type": message.type,
},
)
elif isinstance(comp, Json):
await web_chat_back_queue.put(
{
"type": "plain",
"data": json.dumps(comp.data, ensure_ascii=False),
"streaming": streaming,
"chain_type": message.type,
},
)
elif isinstance(comp, Image):
# save image to local
filename = f"{str(uuid.uuid4())}.jpg"
@@ -67,6 +58,7 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{
"type": "image",
"cid": cid,
"data": data,
"streaming": streaming,
},
@@ -82,6 +74,7 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{
"type": "record",
"cid": cid,
"data": data,
"streaming": streaming,
},
@@ -98,6 +91,7 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{
"type": "file",
"cid": cid,
"data": data,
"streaming": streaming,
},
@@ -107,9 +101,9 @@ class WebChatMessageEvent(AstrMessageEvent):
return data
async def send(self, message: MessageChain | None):
async def send(self, message: MessageChain):
await WebChatMessageEvent._send(message, session_id=self.session_id)
await super().send(MessageChain([]))
await super().send(message)
async def send_streaming(self, generator, use_fallback: bool = False):
final_data = ""
@@ -117,17 +111,18 @@ class WebChatMessageEvent(AstrMessageEvent):
cid = self.session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
async for chain in generator:
# if chain.type == "break" and final_data:
# # 分割符
# await web_chat_back_queue.put(
# {
# "type": "break", # break means a segment end
# "data": final_data,
# "streaming": True,
# },
# )
# final_data = ""
# continue
if chain.type == "break" and final_data:
# 分割符
await web_chat_back_queue.put(
{
"type": "break", # break means a segment end
"data": final_data,
"streaming": True,
"cid": cid,
},
)
final_data = ""
continue
r = await WebChatMessageEvent._send(
chain,
@@ -147,6 +142,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"data": final_data,
"reasoning": reasoning_content,
"streaming": True,
"cid": cid,
},
)
await super().send_streaming(generator, use_fallback)
@@ -4,7 +4,6 @@ import json
import os
import time
import traceback
from typing import cast
import aiohttp
import anyio
@@ -70,7 +69,7 @@ class WeChatPadProAdapter(Platform):
)
self.base_url = f"http://{self.host}:{self.port}"
self.auth_key = None # 用于保存生成的授权码
self.wxid: str | None = None # 用于保存登录成功后的 wxid
self.wxid = None # 用于保存登录成功后的 wxid
self.credentials_file = os.path.join(
get_astrbot_data_path(),
"wechatpadpro_credentials.json",
@@ -399,7 +398,7 @@ class WeChatPadProAdapter(Platform):
)
await asyncio.sleep(5)
async def handle_websocket_message(self, message: str | bytes):
async def handle_websocket_message(self, message: str):
"""处理从 WebSocket 接收到的消息。"""
logger.debug(f"收到 WebSocket 消息: {message}")
try:
@@ -431,13 +430,10 @@ class WeChatPadProAdapter(Platform):
async def convert_message(self, raw_message: dict) -> AstrBotMessage | None:
"""将 WeChatPadPro 原始消息转换为 AstrBotMessage。"""
if self.wxid is None:
logger.error("WeChatPadPro 适配器未登录或未获取到 wxid,无法处理消息。")
return None
abm = AstrBotMessage()
abm.raw_message = raw_message
abm.message_id = str(raw_message.get("msg_id"))
abm.timestamp = cast(int, raw_message.get("create_time"))
abm.timestamp = raw_message.get("create_time")
abm.self_id = self.wxid
if int(time.time()) - abm.timestamp > 180:
@@ -450,7 +446,7 @@ class WeChatPadProAdapter(Platform):
to_user_name = raw_message.get("to_user_name", {}).get("str", "")
content = raw_message.get("content", {}).get("str", "")
push_content = raw_message.get("push_content", "")
msg_type = cast(int, raw_message.get("msg_type"))
msg_type = raw_message.get("msg_type")
abm.message_str = ""
abm.message = []
@@ -578,7 +574,7 @@ class WeChatPadProAdapter(Platform):
from_user_name: str,
to_user_name: str,
msg_id: int,
) -> dict | None:
):
"""下载原始图片。"""
url = f"{self.base_url}/message/GetMsgBigImg"
params = {"key": self.auth_key}
@@ -729,15 +725,12 @@ class WeChatPadProAdapter(Platform):
# 图片消息
from_user_name = raw_message.get("from_user_name", {}).get("str", "")
to_user_name = raw_message.get("to_user_name", {}).get("str", "")
msg_id = cast(int, raw_message.get("msg_id"))
msg_id = raw_message.get("msg_id")
image_resp = await self._download_raw_image(
from_user_name,
to_user_name,
msg_id,
)
if image_resp is None:
logger.error(f"下载图片失败: msg_id={msg_id}")
return
image_bs64_data = (
image_resp.get("Data", {}).get("Data", {}).get("Buffer", None)
)
@@ -778,9 +771,6 @@ class WeChatPadProAdapter(Platform):
bufid = 0
to_user_name = raw_message.get("to_user_name", {}).get("str", "")
new_msg_id = raw_message.get("new_msg_id")
if new_msg_id is None:
logger.error("语音消息缺少 new_msg_id")
return
data_parser = GeweDataParser(
content=content,
is_private_chat=(abm.type != MessageType.GROUP_MESSAGE),
@@ -788,9 +778,6 @@ class WeChatPadProAdapter(Platform):
)
voicemsg = data_parser._format_to_xml().find("voicemsg")
if voicemsg is None:
logger.error("无法从 XML 解析 voicemsg 节点")
return
bufid = voicemsg.get("bufid") or "0"
length = int(voicemsg.get("length") or 0)
voice_resp = await self.download_voice(
@@ -799,9 +786,6 @@ class WeChatPadProAdapter(Platform):
bufid=bufid,
length=length,
)
if voice_resp is None:
logger.error(f"下载语音失败: new_msg_id={new_msg_id}")
return
voice_bs64_data = voice_resp.get("Data", {}).get("Base64", None)
if voice_bs64_data:
voice_bs64_data = base64.b64decode(voice_bs64_data)
@@ -843,8 +827,7 @@ class WeChatPadProAdapter(Platform):
try:
if self.ws_handle_task:
self.ws_handle_task.cancel()
if self._shutdown_event is not None:
self._shutdown_event.set()
self._shutdown_event.set()
except Exception:
pass
@@ -911,8 +894,8 @@ class WeChatPadProAdapter(Platform):
async def get_contact_details_list(
self,
room_wx_id_list: list[str] | None = None,
user_names: list[str] | None = None,
room_wx_id_list: list[str] = None,
user_names: list[str] = None,
) -> dict | None:
"""获取联系人详情列表。"""
if room_wx_id_list is None:
@@ -2,8 +2,7 @@ import asyncio
import os
import sys
import uuid
from collections.abc import Awaitable, Callable
from typing import Any, cast
from typing import Any
import quart
from requests import Response
@@ -41,7 +40,7 @@ else:
class WecomServer:
def __init__(self, event_queue: asyncio.Queue, config: dict):
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",
@@ -61,7 +60,7 @@ class WecomServer:
config["corpid"].strip(),
)
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
self.callback = None
self.shutdown_event = asyncio.Event()
async def verify(self):
@@ -115,7 +114,7 @@ class WecomServer:
logger.error("解密失败,签名异常,请检查配置。")
raise
else:
msg = cast(BaseMessage, parse_message(xml))
msg = parse_message(xml)
logger.info(f"解析成功: {msg}")
if self.callback:
@@ -177,10 +176,10 @@ class WecomPlatformAdapter(Platform):
# 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.kf = self.wechat_kf_api
self.client.kf_message = self.wechat_kf_message_api
self.client.__setattr__("API_BASE_URL", self.api_base_url)
self.client.API_BASE_URL = self.api_base_url
async def callback(msg: BaseMessage):
if msg.type == "unknown" and msg._data["Event"] == "kf_msg_or_event":
@@ -279,33 +278,37 @@ class WecomPlatformAdapter(Platform):
async def convert_message(self, msg: BaseMessage) -> AstrBotMessage | None:
abm = AstrBotMessage()
if isinstance(msg, TextMessage):
if msg.type == "text":
assert isinstance(msg, TextMessage)
abm.message_str = msg.content
abm.self_id = str(msg.agent)
abm.message = [Plain(msg.content)]
abm.type = MessageType.FRIEND_MESSAGE
abm.sender = MessageMember(
cast(str, msg.source),
cast(str, msg.source),
msg.source,
msg.source,
)
abm.message_id = str(msg.id)
abm.timestamp = int(cast(int | str, msg.time))
abm.message_id = msg.id
abm.timestamp = msg.time
abm.session_id = abm.sender.user_id
abm.raw_message = msg
elif isinstance(msg, ImageMessage):
elif msg.type == "image":
assert isinstance(msg, ImageMessage)
abm.message_str = "[图片]"
abm.self_id = str(msg.agent)
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),
msg.source,
msg.source,
)
abm.message_id = str(msg.id)
abm.timestamp = int(cast(int | str, msg.time))
abm.message_id = msg.id
abm.timestamp = msg.time
abm.session_id = abm.sender.user_id
abm.raw_message = msg
elif isinstance(msg, VoiceMessage):
elif msg.type == "voice":
assert isinstance(msg, VoiceMessage)
resp: Response = await asyncio.get_event_loop().run_in_executor(
None,
self.client.media.download,
@@ -332,11 +335,11 @@ class WecomPlatformAdapter(Platform):
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),
msg.source,
msg.source,
)
abm.message_id = str(msg.id)
abm.timestamp = int(cast(int | str, msg.time))
abm.message_id = msg.id
abm.timestamp = msg.time
abm.session_id = abm.sender.user_id
abm.raw_message = msg
else:
@@ -348,7 +351,7 @@ 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 # 方便处理
@@ -422,4 +425,4 @@ class WecomPlatformAdapter(Platform):
await self.server.server.shutdown()
except Exception as _:
pass
logger.info("企业微信 适配器已被关闭")
logger.info("企业微信 适配器已被优雅地关闭")
@@ -16,7 +16,7 @@ try:
import pydub
except Exception:
logger.warning(
"检测到 pydub 库未安装,企业微信将无法语音收发。如需使用语音,请前往管理面板 -> 平台日志 -> 安装 Pip 库安装 pydub。",
"检测到 pydub 库未安装,企业微信将无法语音收发。如需使用语音,请前往管理面板 -> 控制台 -> 安装 Pip 库安装 pydub。",
)
@@ -93,10 +93,10 @@ class WecomPlatformEvent(AstrMessageEvent):
if is_wechat_kf:
# 微信客服
kf_message_api = getattr(self.client, "kf_message", None)
if not isinstance(kf_message_api, WeChatKFMessage):
if not kf_message_api:
logger.warning("未找到微信客服发送消息方法。")
return
assert isinstance(kf_message_api, WeChatKFMessage)
user_id = self.get_sender_id()
for comp in message.chain:
if isinstance(comp, Plain):
@@ -39,7 +39,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
@staticmethod
async def _send(
message_chain: MessageChain | None,
message_chain: MessageChain,
stream_id: str,
queue_mgr: WecomAIQueueMgr,
streaming: bool = False,
@@ -90,7 +90,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
return data
async def send(self, message: MessageChain | None):
async def send(self, message: MessageChain):
"""发送消息"""
raw = self.message_obj.raw_message
assert isinstance(raw, dict), (
@@ -98,7 +98,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
)
stream_id = raw.get("stream_id", self.session_id)
await WecomAIBotMessageEvent._send(message, stream_id, self.queue_mgr)
await super().send(MessageChain([]))
await super().send(message)
async def send_streaming(self, generator, use_fallback=False):
"""流式发送消息,参考webchat的send_streaming设计"""
@@ -1,8 +1,7 @@
import asyncio
import sys
import uuid
from collections.abc import Awaitable, Callable
from typing import Any, cast
from typing import Any
import quart
from requests import Response
@@ -37,7 +36,7 @@ else:
class WeixinOfficialAccountServer:
def __init__(self, event_queue: asyncio.Queue, config: dict):
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")
@@ -56,7 +55,7 @@ class WeixinOfficialAccountServer:
self.event_queue = event_queue
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
self.callback = None
self.shutdown_event = asyncio.Event()
async def verify(self):
@@ -115,9 +114,6 @@ class WeixinOfficialAccountServer:
raise
else:
msg = parse_message(xml)
if not msg:
logger.error("解析失败。msg为None。")
raise
logger.info(f"解析成功: {msg}")
if self.callback:
@@ -180,7 +176,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
self.config["secret"].strip(),
)
self.client.__setattr__("API_BASE_URL", self.api_base_url)
self.client.API_BASE_URL = self.api_base_url
# 微信公众号必须 5 秒内进行回复,否则会重试 3 次,我们需要对其进行消息排重
# msgid -> Future
@@ -192,11 +188,11 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
await self.convert_message(msg, None)
else:
if msg.id in self.wexin_event_workers:
future = self.wexin_event_workers[str(cast(str | int, msg.id))]
future = self.wexin_event_workers[msg.id]
logger.debug(f"duplicate message id checked: {msg.id}")
else:
future = asyncio.get_event_loop().create_future()
self.wexin_event_workers[str(cast(str | int, msg.id))] = future
self.wexin_event_workers[msg.id] = future
await self.convert_message(msg, future)
# I love shield so much!
result = await asyncio.wait_for(
@@ -204,7 +200,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
60,
) # wait for 60s
logger.debug(f"Got future result: {result}")
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
self.wexin_event_workers.pop(msg.id, None)
return result # xml. see weixin_offacc_event.py
except asyncio.TimeoutError:
pass
@@ -252,33 +248,33 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
async def convert_message(
self,
msg,
future: asyncio.Future | None = None,
future: asyncio.Future = 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),
msg.source,
msg.source,
)
abm.message_id = str(cast(str | int, msg.id))
abm.timestamp = cast(int, msg.time)
abm.message_id = 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),
msg.source,
msg.source,
)
abm.message_id = str(cast(str | int, msg.id))
abm.timestamp = cast(int, msg.time)
abm.message_id = msg.id
abm.timestamp = msg.time
abm.session_id = abm.sender.user_id
elif msg.type == "voice":
assert isinstance(msg, VoiceMessage)
@@ -310,16 +306,15 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
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),
msg.source,
msg.source,
)
abm.message_id = str(cast(str | int, msg.id))
abm.timestamp = cast(int, msg.time)
abm.message_id = 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)
future.set_result(None)
return
# 很不优雅 :(
abm.raw_message = {
@@ -349,4 +344,4 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
await self.server.server.shutdown()
except Exception as _:
pass
logger.info("微信公众平台 适配器已被关闭")
logger.info("微信公众平台 适配器已被优雅地关闭")
@@ -1,6 +1,5 @@
import asyncio
import uuid
from typing import cast
from wechatpy import WeChatClient
from wechatpy.replies import ImageReply, TextReply, VoiceReply
@@ -14,7 +13,7 @@ try:
import pydub
except Exception:
logger.warning(
"检测到 pydub 库未安装,微信公众平台将无法语音收发。如需使用语音,请前往管理面板 -> 平台日志 -> 安装 Pip 库安装 pydub。",
"检测到 pydub 库未安装,微信公众平台将无法语音收发。如需使用语音,请前往管理面板 -> 控制台 -> 安装 Pip 库安装 pydub。",
)
@@ -86,9 +85,7 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
async def send(self, message: MessageChain):
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
@@ -99,10 +96,10 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
else:
reply = TextReply(
content=chunk,
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)
await asyncio.sleep(0.5) # Avoid sending too fast
@@ -128,10 +125,10 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
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)
@@ -163,10 +160,10 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
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)
+9 -73
View File
@@ -1,5 +1,3 @@
from __future__ import annotations
import base64
import enum
import json
@@ -14,7 +12,6 @@ import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core.agent.message import (
AssistantMessageSegment,
ContentPart,
ToolCall,
ToolCallMessageSegment,
)
@@ -93,8 +90,6 @@ class ProviderRequest:
"""会话 ID"""
image_urls: list[str] = field(default_factory=list)
"""图片 URL 列表"""
extra_user_content_parts: list[ContentPart] = field(default_factory=list)
"""额外的用户消息内容部分列表,用于在用户消息后添加额外的内容块(如系统提醒、指令等)。"""
func_tool: ToolSet | None = None
"""可用的函数工具"""
contexts: list[dict] = field(default_factory=list)
@@ -169,23 +164,13 @@ class ProviderRequest:
async def assemble_context(self) -> dict:
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
# 构建内容块列表
content_blocks = []
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
if self.prompt and self.prompt.strip():
content_blocks.append({"type": "text", "text": self.prompt})
elif self.image_urls:
# 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[图片]"})
# 2. 额外的内容块(系统提醒、指令等)
if self.extra_user_content_parts:
for part in self.extra_user_content_parts:
content_blocks.append(part.model_dump())
# 3. 图片内容
if self.image_urls:
user_content = {
"role": "user",
"content": [
{"type": "text", "text": self.prompt if self.prompt else "[图片]"},
],
}
for image_url in self.image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
@@ -198,21 +183,11 @@ class ProviderRequest:
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
content_blocks.append(
user_content["content"].append(
{"type": "image_url", "image_url": {"url": image_data}},
)
# 只有当只有一个来自 prompt 的文本块且没有额外内容块时,才降级为简单格式以保持向后兼容
if (
len(content_blocks) == 1
and content_blocks[0]["type"] == "text"
and not self.extra_user_content_parts
and not self.image_urls
):
return {"role": "user", "content": content_blocks[0]["text"]}
# 否则返回多模态格式
return {"role": "user", "content": content_blocks}
return user_content
return {"role": "user", "content": self.prompt}
async def _encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
@@ -224,38 +199,6 @@ class ProviderRequest:
return ""
@dataclass
class TokenUsage:
input_other: int = 0
"""The number of input tokens, excluding cached tokens."""
input_cached: int = 0
"""The number of input cached tokens."""
output: int = 0
"""The number of output tokens."""
@property
def total(self) -> int:
return self.input_other + self.input_cached + self.output
@property
def input(self) -> int:
return self.input_other + self.input_cached
def __add__(self, other: TokenUsage) -> TokenUsage:
return TokenUsage(
input_other=self.input_other + other.input_other,
input_cached=self.input_cached + other.input_cached,
output=self.output + other.output,
)
def __sub__(self, other: TokenUsage) -> TokenUsage:
return TokenUsage(
input_other=self.input_other - other.input_other,
input_cached=self.input_cached - other.input_cached,
output=self.output - other.output,
)
@dataclass
class LLMResponse:
role: str
@@ -284,11 +227,6 @@ class LLMResponse:
is_chunk: bool = False
"""Indicates if the response is a chunked response."""
id: str | None = None
"""The ID of the response. For chunked responses, it's the ID of the chunk; for non-chunked responses, it's the ID of the response."""
usage: TokenUsage | None = None
"""The usage of the response. For chunked responses, it's the usage of the chunk; for non-chunked responses, it's the usage of the response."""
def __init__(
self,
role: str,
@@ -303,8 +241,6 @@ class LLMResponse:
| AnthropicMessage
| None = None,
is_chunk: bool = False,
id: str | None = None,
usage: TokenUsage | None = None,
):
"""初始化 LLMResponse
+3 -3
View File
@@ -4,7 +4,7 @@ import asyncio
import copy
import json
import os
from collections.abc import AsyncGenerator, Awaitable, Callable
from collections.abc import Awaitable, Callable
from typing import Any
import aiohttp
@@ -118,7 +118,7 @@ class FunctionToolManager:
name: str,
func_args: list[dict],
desc: str,
handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any]],
handler: Callable[..., Awaitable[Any]],
) -> FuncTool:
params = {
"type": "object", # hard-coded here
@@ -140,7 +140,7 @@ class FunctionToolManager:
name: str,
func_args: list,
desc: str,
handler: Callable[..., Awaitable[Any] | AsyncGenerator[Any]],
handler: Callable[..., Awaitable[Any]],
) -> None:
"""添加函数调用工具
+163 -329
View File
@@ -1,7 +1,5 @@
import asyncio
import copy
import traceback
from typing import Protocol, runtime_checkable
from astrbot.core import astrbot_config, logger, sp
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
@@ -12,7 +10,6 @@ from .entities import ProviderType
from .provider import (
EmbeddingProvider,
Provider,
Providers,
RerankProvider,
STTProvider,
TTSProvider,
@@ -20,11 +17,6 @@ from .provider import (
from .register import llm_tools, provider_cls_map
@runtime_checkable
class HasInitialize(Protocol):
async def initialize(self) -> None: ...
class ProviderManager:
def __init__(
self,
@@ -33,12 +25,10 @@ class ProviderManager:
persona_mgr: PersonaManager,
):
self.reload_lock = asyncio.Lock()
self.resource_lock = asyncio.Lock()
self.persona_mgr = persona_mgr
self.acm = acm
config = acm.confs["default"]
self.providers_config: list = config["provider"]
self.provider_sources_config: list = config.get("provider_sources", [])
self.provider_settings: dict = config["provider_settings"]
self.provider_stt_settings: dict = config.get("provider_stt_settings", {})
self.provider_tts_settings: dict = config.get("provider_tts_settings", {})
@@ -58,7 +48,7 @@ class ProviderManager:
"""加载的 Rerank Provider 的实例"""
self.inst_map: dict[
str,
Providers,
Provider | STTProvider | TTSProvider | EmbeddingProvider | RerankProvider,
] = {}
"""Provider 实例映射. key: provider_id, value: Provider 实例"""
self.llm_tools = llm_tools
@@ -133,13 +123,15 @@ class ProviderManager:
self.curr_provider_inst = prov
sp.put("curr_provider", provider_id, scope="global", scope_id="global")
async def get_provider_by_id(self, provider_id: str) -> Providers | None:
async def get_provider_by_id(self, provider_id: str) -> Provider | None:
"""根据提供商 ID 获取提供商实例"""
return self.inst_map.get(provider_id)
def get_using_provider(
self, provider_type: ProviderType, umo=None
) -> Providers | None:
self,
provider_type: ProviderType,
umo=None,
) -> Provider | STTProvider | TTSProvider | None:
"""获取正在使用的提供商实例。
Args:
@@ -151,7 +143,6 @@ class ProviderManager:
"""
provider = None
provider_id = None
if umo:
provider_id = sp.get(
f"provider_perf_{provider_type.value}",
@@ -189,12 +180,6 @@ class ProviderManager:
)
else:
raise ValueError(f"Unknown provider type: {provider_type}")
if not provider and provider_id:
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
return provider
async def initialize(self):
@@ -206,6 +191,7 @@ class ProviderManager:
logger.error(traceback.format_exc())
logger.error(e)
# 设置默认提供商
selected_provider_id = sp.get(
"curr_provider",
self.provider_settings.get("default_provider_id"),
@@ -224,173 +210,22 @@ class ProviderManager:
scope="global",
scope_id="global",
)
temp_provider = (
self.inst_map.get(selected_provider_id)
if isinstance(selected_provider_id, str)
else None
)
self.curr_provider_inst = (
temp_provider if isinstance(temp_provider, Provider) else None
)
self.curr_provider_inst = self.inst_map.get(selected_provider_id)
if not self.curr_provider_inst and self.provider_insts:
self.curr_provider_inst = self.provider_insts[0]
temp_stt = (
self.inst_map.get(selected_stt_provider_id)
if isinstance(selected_stt_provider_id, str)
else None
)
self.curr_stt_provider_inst = (
temp_stt if isinstance(temp_stt, STTProvider) else None
)
self.curr_stt_provider_inst = self.inst_map.get(selected_stt_provider_id)
if not self.curr_stt_provider_inst and self.stt_provider_insts:
self.curr_stt_provider_inst = self.stt_provider_insts[0]
temp_tts = (
self.inst_map.get(selected_tts_provider_id)
if isinstance(selected_tts_provider_id, str)
else None
)
self.curr_tts_provider_inst = (
temp_tts if isinstance(temp_tts, TTSProvider) else None
)
self.curr_tts_provider_inst = self.inst_map.get(selected_tts_provider_id)
if not self.curr_tts_provider_inst and self.tts_provider_insts:
self.curr_tts_provider_inst = self.tts_provider_insts[0]
# 初始化 MCP Client 连接
asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients")
def dynamic_import_provider(self, type: str):
"""动态导入提供商适配器模块
Args:
type (str): 提供商请求类型
Raises:
ImportError: 如果提供商类型未知或无法导入对应模块则抛出异常
"""
match type:
case "openai_chat_completion":
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "googlegenai_chat_completion":
from .sources.gemini_source import (
ProviderGoogleGenAI as ProviderGoogleGenAI,
)
case "sensevoice_stt_selfhost":
from .sources.sensevoice_selfhosted_source import (
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
)
case "openai_whisper_api":
from .sources.whisper_api_source import (
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
)
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import (
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
)
case "xinference_stt":
from .sources.xinference_stt_provider import (
ProviderXinferenceSTT as ProviderXinferenceSTT,
)
case "openai_tts_api":
from .sources.openai_tts_api_source import (
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
)
case "edge_tts":
from .sources.edge_tts_source import (
ProviderEdgeTTS as ProviderEdgeTTS,
)
case "gsv_tts_selfhost":
from .sources.gsv_selfhosted_source import (
ProviderGSVTTS as ProviderGSVTTS,
)
case "gsvi_tts_api":
from .sources.gsvi_tts_source import (
ProviderGSVITTS as ProviderGSVITTS,
)
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import (
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
)
case "dashscope_tts":
from .sources.dashscope_tts import (
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
)
case "azure_tts":
from .sources.azure_tts_source import (
AzureTTSProvider as AzureTTSProvider,
)
case "minimax_tts_api":
from .sources.minimax_tts_api_source import (
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
)
case "volcengine_tts":
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "gemini_tts":
from .sources.gemini_tts_source import (
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
case "gemini_embedding":
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
)
case "xinference_rerank":
from .sources.xinference_rerank_source import (
XinferenceRerankProvider as XinferenceRerankProvider,
)
case "bailian_rerank":
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
def get_merged_provider_config(self, provider_config: dict) -> dict:
"""获取 provider 配置和 provider_source 配置合并后的结果
Returns:
dict: 合并后的 provider 配置key provider idvalue 为合并后的配置字典
"""
pc = copy.deepcopy(provider_config)
provider_source_id = pc.get("provider_source_id", "")
if provider_source_id:
provider_source = None
for ps in self.provider_sources_config:
if ps.get("id") == provider_source_id:
provider_source = ps
break
if provider_source:
# 合并配置,provider 的配置优先级更高
merged_config = {**provider_source, **pc}
# 保持 id 为 provider 的 id,而不是 source 的 id
merged_config["id"] = pc["id"]
pc = merged_config
return pc
async def load_provider(self, provider_config: dict):
# 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并
provider_config = self.get_merged_provider_config(provider_config)
if not provider_config["enable"]:
logger.info(f"Provider {provider_config['id']} is disabled, skipping")
return
@@ -403,7 +238,99 @@ class ProviderManager:
# 动态导入
try:
self.dynamic_import_provider(provider_config["type"])
match provider_config["type"]:
case "openai_chat_completion":
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "googlegenai_chat_completion":
from .sources.gemini_source import (
ProviderGoogleGenAI as ProviderGoogleGenAI,
)
case "sensevoice_stt_selfhost":
from .sources.sensevoice_selfhosted_source import (
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
)
case "openai_whisper_api":
from .sources.whisper_api_source import (
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
)
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import (
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
)
case "xinference_stt":
from .sources.xinference_stt_provider import (
ProviderXinferenceSTT as ProviderXinferenceSTT,
)
case "openai_tts_api":
from .sources.openai_tts_api_source import (
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
)
case "edge_tts":
from .sources.edge_tts_source import (
ProviderEdgeTTS as ProviderEdgeTTS,
)
case "gsv_tts_selfhost":
from .sources.gsv_selfhosted_source import (
ProviderGSVTTS as ProviderGSVTTS,
)
case "gsvi_tts_api":
from .sources.gsvi_tts_source import (
ProviderGSVITTS as ProviderGSVITTS,
)
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import (
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
)
case "dashscope_tts":
from .sources.dashscope_tts import (
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
)
case "azure_tts":
from .sources.azure_tts_source import (
AzureTTSProvider as AzureTTSProvider,
)
case "minimax_tts_api":
from .sources.minimax_tts_api_source import (
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
)
case "volcengine_tts":
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "gemini_tts":
from .sources.gemini_tts_source import (
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
case "gemini_embedding":
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
)
case "xinference_rerank":
from .sources.xinference_rerank_source import (
XinferenceRerankProvider as XinferenceRerankProvider,
)
case "bailian_rerank":
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
except (ImportError, ModuleNotFoundError) as e:
logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。",
@@ -431,103 +358,73 @@ class ProviderManager:
provider_metadata.id = provider_config["id"]
match provider_metadata.provider_type:
case ProviderType.SPEECH_TO_TEXT:
# STT 任务
if not issubclass(cls_type, STTProvider):
raise TypeError(
f"Provider class {cls_type} is not a subclass of STTProvider"
)
inst = cls_type(provider_config, self.provider_settings)
if provider_metadata.provider_type == ProviderType.SPEECH_TO_TEXT:
# STT 任务
inst = cls_type(provider_config, self.provider_settings)
if isinstance(inst, HasInitialize):
await inst.initialize()
if getattr(inst, "initialize", None):
await inst.initialize()
self.stt_provider_insts.append(inst)
if (
self.provider_stt_settings.get("provider_id")
== provider_config["id"]
):
self.curr_stt_provider_inst = inst
logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前语音转文本提供商适配器。",
)
if not self.curr_stt_provider_inst:
self.curr_stt_provider_inst = inst
case ProviderType.TEXT_TO_SPEECH:
# TTS 任务
if not issubclass(cls_type, TTSProvider):
raise TypeError(
f"Provider class {cls_type} is not a subclass of TTSProvider"
)
inst = cls_type(provider_config, self.provider_settings)
if isinstance(inst, HasInitialize):
await inst.initialize()
self.tts_provider_insts.append(inst)
if (
self.provider_settings.get("provider_id")
== provider_config["id"]
):
self.curr_tts_provider_inst = inst
logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前文本转语音提供商适配器。",
)
if not self.curr_tts_provider_inst:
self.curr_tts_provider_inst = inst
case ProviderType.CHAT_COMPLETION:
# 文本生成任务
if not issubclass(cls_type, Provider):
raise TypeError(
f"Provider class {cls_type} is not a subclass of Provider"
)
inst = cls_type(
provider_config,
self.provider_settings,
self.stt_provider_insts.append(inst)
if (
self.provider_stt_settings.get("provider_id")
== provider_config["id"]
):
self.curr_stt_provider_inst = inst
logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前语音转文本提供商适配器。",
)
if not self.curr_stt_provider_inst:
self.curr_stt_provider_inst = inst
if isinstance(inst, HasInitialize):
await inst.initialize()
elif provider_metadata.provider_type == ProviderType.TEXT_TO_SPEECH:
# TTS 任务
inst = cls_type(provider_config, self.provider_settings)
self.provider_insts.append(inst)
if (
self.provider_settings.get("default_provider_id")
== provider_config["id"]
):
self.curr_provider_inst = inst
logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前提供商适配器。",
)
if not self.curr_provider_inst:
self.curr_provider_inst = inst
if getattr(inst, "initialize", None):
await inst.initialize()
case ProviderType.EMBEDDING:
if not issubclass(cls_type, EmbeddingProvider):
raise TypeError(
f"Provider class {cls_type} is not a subclass of EmbeddingProvider"
)
inst = cls_type(provider_config, self.provider_settings)
if isinstance(inst, HasInitialize):
await inst.initialize()
self.embedding_provider_insts.append(inst)
case ProviderType.RERANK:
if not issubclass(cls_type, RerankProvider):
raise TypeError(
f"Provider class {cls_type} is not a subclass of RerankProvider"
)
inst = cls_type(provider_config, self.provider_settings)
if isinstance(inst, HasInitialize):
await inst.initialize()
self.rerank_provider_insts.append(inst)
case _:
# 未知供应商抛出异常,确保inst初始化
# Should be unreachable
raise Exception(
f"未知的提供商类型:{provider_metadata.provider_type}"
self.tts_provider_insts.append(inst)
if self.provider_settings.get("provider_id") == provider_config["id"]:
self.curr_tts_provider_inst = inst
logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前文本转语音提供商适配器。",
)
if not self.curr_tts_provider_inst:
self.curr_tts_provider_inst = inst
elif provider_metadata.provider_type == ProviderType.CHAT_COMPLETION:
# 文本生成任务
inst = cls_type(
provider_config,
self.provider_settings,
)
if getattr(inst, "initialize", None):
await inst.initialize()
self.provider_insts.append(inst)
if (
self.provider_settings.get("default_provider_id")
== provider_config["id"]
):
self.curr_provider_inst = inst
logger.info(
f"已选择 {provider_config['type']}({provider_config['id']}) 作为当前提供商适配器。",
)
if not self.curr_provider_inst:
self.curr_provider_inst = inst
elif provider_metadata.provider_type == ProviderType.EMBEDDING:
inst = cls_type(provider_config, self.provider_settings)
if getattr(inst, "initialize", None):
await inst.initialize()
self.embedding_provider_insts.append(inst)
elif provider_metadata.provider_type == ProviderType.RERANK:
inst = cls_type(provider_config, self.provider_settings)
if getattr(inst, "initialize", None):
await inst.initialize()
self.rerank_provider_insts.append(inst)
self.inst_map[provider_config["id"]] = inst
except Exception as e:
@@ -546,7 +443,6 @@ class ProviderManager:
# 和配置文件保持同步
self.providers_config = astrbot_config["provider"]
self.provider_sources_config = astrbot_config.get("provider_sources", [])
config_ids = [provider["id"] for provider in self.providers_config]
logger.info(f"providers in user's config: {config_ids}")
for key in list(self.inst_map.keys()):
@@ -618,68 +514,6 @@ class ProviderManager:
)
del self.inst_map[provider_id]
async def delete_provider(
self, provider_id: str | None = None, provider_source_id: str | None = None
):
"""Delete provider and/or provider source from config and terminate the instances. Config will be saved after deletion."""
async with self.resource_lock:
# delete from config
target_prov_ids = []
if provider_id:
target_prov_ids.append(provider_id)
else:
for prov in self.providers_config:
if prov.get("provider_source_id") == provider_source_id:
target_prov_ids.append(prov.get("id"))
config = self.acm.default_conf
for tpid in target_prov_ids:
await self.terminate_provider(tpid)
config["provider"] = [
prov for prov in config["provider"] if prov.get("id") != tpid
]
config.save_config()
logger.info(f"Provider {target_prov_ids} 已从配置中删除。")
async def update_provider(self, origin_provider_id: str, new_config: dict):
"""Update provider config and reload the instance. Config will be saved after update."""
async with self.resource_lock:
npid = new_config.get("id", None)
if not npid:
raise ValueError("New provider config must have an 'id' field")
config = self.acm.default_conf
for provider in config["provider"]:
if (
provider.get("id", None) == npid
and provider.get("id", None) != origin_provider_id
):
raise ValueError(f"Provider ID {npid} already exists")
# update config
for idx, provider in enumerate(config["provider"]):
if provider.get("id", None) == origin_provider_id:
config["provider"][idx] = new_config
break
else:
raise ValueError(f"Provider ID {origin_provider_id} not found")
config.save_config()
# reload instance
await self.reload(new_config)
async def create_provider(self, new_config: dict):
"""Add new provider config and load the instance. Config will be saved after addition."""
async with self.resource_lock:
npid = new_config.get("id", None)
if not npid:
raise ValueError("New provider config must have an 'id' field")
config = self.acm.default_conf
for provider in config["provider"]:
if provider.get("id", None) == npid:
raise ValueError(f"Provider ID {npid} already exists")
# add to config
config["provider"].append(new_config)
config.save_config()
# load instance
await self.load_provider(new_config)
async def terminate(self):
for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"):
+2 -17
View File
@@ -2,9 +2,8 @@ import abc
import asyncio
import os
from collections.abc import AsyncGenerator
from typing import TypeAlias, Union
from astrbot.core.agent.message import ContentPart, Message
from astrbot.core.agent.message import Message
from astrbot.core.agent.tool import ToolSet
from astrbot.core.provider.entities import (
LLMResponse,
@@ -15,14 +14,6 @@ from astrbot.core.provider.entities import (
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",
]
class AbstractProvider(abc.ABC):
"""Provider Abstract Class"""
@@ -103,7 +94,6 @@ class Provider(AbstractProvider):
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs,
) -> LLMResponse:
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -115,7 +105,6 @@ class Provider(AbstractProvider):
tools: tool set
contexts: 上下文 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果参考: https://platform.openai.com/docs/guides/function-calling
extra_user_content_parts: 额外的用户内容块列表用于在用户消息后添加额外的文本块如系统提醒指令等
kwargs: 其他参数
Notes:
@@ -135,7 +124,6 @@ class Provider(AbstractProvider):
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。
@@ -147,7 +135,6 @@ class Provider(AbstractProvider):
tools: tool set
contexts: 上下文 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果参考: https://platform.openai.com/docs/guides/function-calling
extra_user_content_parts: 额外的用户内容块列表用于在用户消息后添加额外的文本块如系统提醒指令等
kwargs: 其他参数
Notes:
@@ -155,9 +142,7 @@ class Provider(AbstractProvider):
- 如果传入了 tools将会使用 tools 进行 Function-calling如果模型不支持 Function-calling将会抛出错误
"""
if False: # pragma: no cover - make this an async generator for typing
yield None # type: ignore
raise NotImplementedError()
...
async def pop_record(self, context: list):
"""弹出 context 第一条非系统提示词对话记录"""
+47 -166
View File
@@ -6,13 +6,10 @@ from mimetypes import guess_type
import anthropic
from anthropic import AsyncAnthropic
from anthropic.types import Message
from anthropic.types.message_delta_usage import MessageDeltaUsage
from anthropic.types.usage import Usage
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url
@@ -48,7 +45,7 @@ class ProviderAnthropic(Provider):
base_url=self.base_url,
)
self.set_model(provider_config.get("model", "unknown"))
self.set_model(provider_config["model_config"]["model"])
def _prepare_payload(self, messages: list[dict]):
"""准备 Anthropic API 的请求 payload
@@ -110,32 +107,12 @@ class ProviderAnthropic(Provider):
return system_prompt, new_messages
def _extract_usage(self, usage: Usage) -> TokenUsage:
# https://docs.claude.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance
return TokenUsage(
input_other=usage.input_tokens or 0,
input_cached=usage.cache_read_input_tokens or 0,
output=usage.output_tokens,
)
def _update_usage(self, token_usage: TokenUsage, usage: MessageDeltaUsage) -> None:
if usage.input_tokens is not None:
token_usage.input_other = usage.input_tokens
if usage.cache_read_input_tokens is not None:
token_usage.input_cached = usage.cache_read_input_tokens
if usage.output_tokens is not None:
token_usage.output = usage.output_tokens
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools:
if tool_list := tools.get_func_desc_anthropic_style():
payloads["tools"] = tool_list
extra_body = self.provider_config.get("custom_extra_body", {})
completion = await self.client.messages.create(
**payloads, stream=False, extra_body=extra_body
)
completion = await self.client.messages.create(**payloads, stream=False)
assert isinstance(completion, Message)
logger.debug(f"completion: {completion}")
@@ -154,10 +131,6 @@ class ProviderAnthropic(Provider):
llm_response.tools_call_args.append(content_block.input)
llm_response.tools_call_name.append(content_block.name)
llm_response.tools_call_ids.append(content_block.id)
llm_response.id = completion.id
llm_response.usage = self._extract_usage(completion.usage)
# TODO(Soulter): 处理 end_turn 情况
if not llm_response.completion_text and not llm_response.tools_call_args:
raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}")
@@ -178,19 +151,10 @@ class ProviderAnthropic(Provider):
# 用于累积最终结果
final_text = ""
final_tool_calls = []
id = None
usage = TokenUsage()
extra_body = self.provider_config.get("custom_extra_body", {})
async with self.client.messages.stream(
**payloads, extra_body=extra_body
) as stream:
async with self.client.messages.stream(**payloads) as stream:
assert isinstance(stream, anthropic.AsyncMessageStream)
async for event in stream:
if event.type == "message_start":
# the usage contains input token usage
id = event.message.id
usage = self._extract_usage(event.message.usage)
if event.type == "content_block_start":
if event.content_block.type == "text":
# 文本块开始
@@ -198,8 +162,6 @@ class ProviderAnthropic(Provider):
role="assistant",
completion_text="",
is_chunk=True,
usage=usage,
id=id,
)
elif event.content_block.type == "tool_use":
# 工具使用块开始,初始化缓冲区
@@ -217,8 +179,6 @@ class ProviderAnthropic(Provider):
role="assistant",
completion_text=event.delta.text,
is_chunk=True,
usage=usage,
id=id,
)
elif event.delta.type == "input_json_delta":
# 工具调用参数增量
@@ -255,8 +215,6 @@ class ProviderAnthropic(Provider):
tools_call_name=[tool_info["name"]],
tools_call_ids=[tool_info["id"]],
is_chunk=True,
usage=usage,
id=id,
)
except json.JSONDecodeError:
# JSON 解析失败,跳过这个工具调用
@@ -265,17 +223,11 @@ class ProviderAnthropic(Provider):
# 清理缓冲区
del tool_use_buffer[event.index]
elif event.type == "message_delta":
if event.usage:
self._update_usage(usage, event.usage)
# 返回最终的完整结果
final_response = LLMResponse(
role="assistant",
completion_text=final_text,
is_chunk=False,
usage=usage,
id=id,
)
if final_tool_calls:
@@ -297,16 +249,13 @@ class ProviderAnthropic(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
new_record = await self.assemble_context(prompt, image_urls)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -328,9 +277,10 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query)
model = model or self.get_model()
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": new_messages, "model": model}
payloads = {"messages": new_messages, **model_config}
# Anthropic has a different way of handling system prompts
if system_prompt:
@@ -340,6 +290,7 @@ class ProviderAnthropic(Provider):
try:
llm_response = await self._query(payloads, func_tool)
except Exception as e:
# logger.error(f"发生了错误。Provider 配置如下: {model_config}")
raise e
return llm_response
@@ -354,16 +305,13 @@ class ProviderAnthropic(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
):
if contexts is None:
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
new_record = await self.assemble_context(prompt, image_urls)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -384,9 +332,10 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query)
model = model or self.get_model()
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": new_messages, "model": model}
payloads = {"messages": new_messages, **model_config}
# Anthropic has a different way of handling system prompts
if system_prompt:
@@ -395,116 +344,48 @@ class ProviderAnthropic(Provider):
async for llm_response in self._query_stream(payloads, func_tool):
yield llm_response
async def assemble_context(
self,
text: str,
image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
):
async def assemble_context(self, text: str, image_urls: list[str] | None = None):
"""组装上下文,支持文本和图片"""
if not image_urls:
return {"role": "user", "content": text}
content = []
content.append({"type": "text", "text": text})
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
if text:
content.append({"type": "text", "text": text})
elif image_urls:
# 如果没有文本但有图片,添加占位文本
content.append({"type": "text", "text": "[图片]"})
elif extra_user_content_parts:
# 如果只有额外内容块,也需要添加占位文本
content.append({"type": "text", "text": " "})
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self.encode_image_bs64(image_path)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
image_data = await self.encode_image_bs64(image_path)
else:
image_data = await self.encode_image_bs64(image_url)
# 2. 额外的内容块(系统提醒、指令等)
if extra_user_content_parts:
for block in extra_user_content_parts:
block_type = block.get("type")
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
if block_type == "text":
# 文本直接添加
content.append(block)
# Get mime type for the image
mime_type, _ = guess_type(image_url)
if not mime_type:
mime_type = "image/jpeg" # Default to JPEG if can't determine
elif block_type == "image_url":
# 转换 OpenAI 格式的图片为 Anthropic 格式
image_url_data = block.get("image_url", {})
if isinstance(image_url_data, dict):
url = image_url_data.get("url", "")
else:
# 兼容直接传 URL 字符串的情况
url = str(image_url_data)
if url and url.startswith("data:"):
try:
# 提取 MIME 类型和 base64 数据
mime_type = url.split(":")[1].split(";")[0]
base64_data = (
url.split("base64,")[1] if "base64," in url else url
)
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": base64_data,
},
}
)
except Exception as e:
logger.warning(f"转换 image_url 到 Anthropic 格式失败: {e}")
else:
logger.warning(f"image_url 不是有效的 data URI: {url[:50]}...")
else:
# 其他类型(如 audio_urlAnthropic 不支持,记录警告
logger.debug(f"Anthropic 不支持的内容类型 '{block_type}',已忽略")
# 3. 图片内容
if image_urls:
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self.encode_image_bs64(image_path)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
image_data = await self.encode_image_bs64(image_path)
else:
image_data = await self.encode_image_bs64(image_url)
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
# Get mime type for the image
mime_type, _ = guess_type(image_url)
if not mime_type:
mime_type = "image/jpeg" # Default to JPEG if can't determine
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": (
image_data.split("base64,")[1]
if "base64," in image_data
else image_data
),
},
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": (
image_data.split("base64,")[1]
if "base64," in image_data
else image_data
),
},
)
},
)
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if (
text
and not extra_user_content_parts
and not image_urls
and len(content) == 1
and content[0]["type"] == "text"
):
return {"role": "user", "content": content[0]["text"]}
# 否则返回多模态格式
return {"role": "user", "content": content}
async def encode_image_bs64(self, image_url: str) -> str:
@@ -29,24 +29,15 @@ class OTTSProvider:
self.last_sync_time = 0
self.timeout = Timeout(10.0)
self.retry_count = 3
self._client: AsyncClient | None = None
@property
def client(self) -> AsyncClient:
if self._client is None:
raise RuntimeError(
"Client not initialized. Please use 'async with' context."
)
return self._client
self.client = None
async def __aenter__(self):
self._client = AsyncClient(timeout=self.timeout)
self.client = AsyncClient(timeout=self.timeout)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self._client:
await self._client.aclose()
self._client = None
if self.client:
await self.client.aclose()
async def _sync_time(self):
try:
@@ -99,7 +90,6 @@ class OTTSProvider:
if attempt == self.retry_count - 1:
raise RuntimeError(f"OTTS请求失败: {e!s}") from e
await asyncio.sleep(0.5 * (attempt + 1))
raise RuntimeError("OTTS未返回音频文件")
class AzureNativeProvider(TTSProvider):
@@ -115,7 +105,7 @@ class AzureNativeProvider(TTSProvider):
self.endpoint = (
f"https://{self.region}.tts.speech.microsoft.com/cognitiveservices/v1"
)
self._client: AsyncClient | None = None
self.client = None
self.token = None
self.token_expire = 0
self.voice_params = {
@@ -126,16 +116,8 @@ class AzureNativeProvider(TTSProvider):
"volume": provider_config.get("azure_tts_volume", "100"),
}
@property
def client(self) -> AsyncClient:
if self._client is None:
raise RuntimeError(
"Client not initialized. Please use 'async with' context."
)
return self._client
async def __aenter__(self):
self._client = AsyncClient(
self.client = AsyncClient(
headers={
"User-Agent": f"AstrBot/{VERSION}",
"Content-Type": "application/ssml+xml",
@@ -145,9 +127,8 @@ class AzureNativeProvider(TTSProvider):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self._client:
await self._client.aclose()
self._client = None
if self.client:
await self.client.aclose()
async def _refresh_token(self):
token_url = (
@@ -200,11 +181,8 @@ class AzureTTSProvider(TTSProvider):
key_value = provider_config.get("azure_tts_subscription_key", "")
self.provider = self._parse_provider(key_value, provider_config)
def _parse_provider(
self, key_value: str, config: dict
) -> OTTSProvider | AzureNativeProvider:
def _parse_provider(self, key_value: str, config: dict) -> TTSProvider:
if key_value.lower().startswith("other["):
json_str = ""
try:
match = re.match(r"other\[(.*)\]", key_value, re.DOTALL)
if not match:
@@ -177,10 +177,6 @@ class BailianRerankProvider(RerankProvider):
Returns:
重排序结果列表
"""
if not self.client:
logger.error("百炼 Rerank 客户端会话已关闭,返回空结果")
return []
if not documents:
logger.warning("文档列表为空,返回空结果")
return []
@@ -36,7 +36,7 @@ class ProviderDashscopeTTSAPI(TTSProvider):
super().__init__(provider_config, provider_settings)
self.chosen_api_key: str = provider_config.get("api_key", "")
self.voice: str = provider_config.get("dashscope_tts_voice", "loongstella")
self.set_model(provider_config["model"])
self.set_model(provider_config.get("model"))
self.timeout_ms = float(provider_config.get("timeout", 20)) * 1000
dashscope.api_key = self.chosen_api_key
@@ -71,10 +71,9 @@ class ProviderDashscopeTTSAPI(TTSProvider):
kwargs = {
"model": model,
"messages": None,
"text": text,
"api_key": self.chosen_api_key,
"voice": self.voice or "Cherry",
"text": text,
}
if not self.voice:
logging.warning(
@@ -67,7 +67,7 @@ class ProviderEdgeTTS(TTSProvider):
from pyffmpeg import FFmpeg
ff = FFmpeg()
ff.convert(input_file=mp3_path, output_file=wav_path)
ff.convert(input=mp3_path, output=wav_path)
except Exception as e:
logger.debug(f"pyffmpeg 转换失败: {e}, 尝试使用 ffmpeg 命令行进行转换")
# use ffmpeg command line
@@ -59,9 +59,9 @@ class ProviderFishAudioTTSAPI(TTSProvider):
self.headers = {
"Authorization": f"Bearer {self.chosen_api_key}",
}
self.set_model(provider_config["model"])
self.set_model(provider_config.get("model"))
async def _get_reference_id_by_character(self, character: str) -> str | None:
async def _get_reference_id_by_character(self, character: str) -> str:
"""获取角色的reference_id
Args:
@@ -109,7 +109,7 @@ class ProviderFishAudioTTSAPI(TTSProvider):
pattern = r"^[a-fA-F0-9]{32}$"
return bool(re.match(pattern, reference_id.strip()))
async def _generate_request(self, text: str) -> ServeTTSRequest:
async def _generate_request(self, text: str) -> dict:
# 向前兼容逻辑:优先使用reference_id,如果没有则使用角色名称查询
if self.reference_id and self.reference_id.strip():
# 验证reference_id格式
@@ -146,6 +146,5 @@ class ProviderFishAudioTTSAPI(TTSProvider):
async for chunk in response.aiter_bytes():
f.write(chunk)
return path
body = await response.aread()
text = body.decode("utf-8", errors="replace")
text = await response.aread()
raise Exception(f"Fish Audio API请求失败: {text}")
@@ -1,5 +1,3 @@
from typing import cast
from google import genai
from google.genai import types
from google.genai.errors import APIError
@@ -20,8 +18,8 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
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"]
api_key: str = provider_config.get("embedding_api_key")
api_base: str = provider_config.get("embedding_api_base")
timeout: int = int(provider_config.get("timeout", 20))
http_options = types.HttpOptions(timeout=timeout * 1000)
@@ -43,26 +41,18 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
model=self.model,
contents=text,
)
assert result.embeddings is not None
assert result.embeddings[0].values is not None
return result.embeddings[0].values
except APIError as e:
raise Exception(f"Gemini Embedding API请求失败: {e.message}")
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
async def get_embeddings(self, texts: list[str]) -> list[list[float]]:
"""批量获取文本的嵌入"""
try:
result = await self.client.models.embed_content(
model=self.model,
contents=cast(types.ContentListUnion, text),
contents=texts,
)
assert result.embeddings is not None
embeddings: list[list[float]] = []
for embedding in result.embeddings:
assert embedding.values is not None
embeddings.append(embedding.values)
return embeddings
return [embedding.values for embedding in result.embeddings]
except APIError as e:
raise Exception(f"Gemini Embedding API批量请求失败: {e.message}")
+53 -144
View File
@@ -4,7 +4,6 @@ import json
import logging
import random
from collections.abc import AsyncGenerator
from typing import cast
from google import genai
from google.genai import types
@@ -13,9 +12,8 @@ from google.genai.errors import APIError
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url
@@ -69,7 +67,7 @@ class ProviderGoogleGenAI(Provider):
self.api_base = self.api_base[:-1]
self._init_client()
self.set_model(provider_config.get("model", "unknown"))
self.set_model(provider_config["model_config"]["model"])
self._init_safety_settings()
def _init_client(self) -> None:
@@ -128,18 +126,18 @@ class ProviderGoogleGenAI(Provider):
) -> types.GenerateContentConfig:
"""准备查询配置"""
if not modalities:
modalities = ["TEXT"]
modalities = ["Text"]
# 流式输出不支持图片模态
if (
self.provider_settings.get("streaming_response", False)
and "IMAGE" in modalities
and "Image" in modalities
):
logger.warning("流式输出不支持图片模态,已自动降级为文本模态")
modalities = ["TEXT"]
modalities = ["Text"]
tool_list: list[types.Tool] | None = []
model_name = cast(str, payloads.get("model", self.get_model()))
tool_list = []
model_name = 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)
@@ -198,53 +196,6 @@ class ProviderGoogleGenAI(Provider):
types.Tool(function_declarations=func_desc["function_declarations"]),
]
# oper thinking config
thinking_config = None
if model_name in [
"gemini-2.5-pro",
"gemini-2.5-pro-preview",
"gemini-2.5-flash",
"gemini-2.5-flash-preview",
"gemini-2.5-flash-lite",
"gemini-2.5-flash-lite-preview",
"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,
)
elif model_name in [
"gemini-3-pro",
"gemini-3-pro-preview",
"gemini-3-flash",
"gemini-3-flash-preview",
"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"
)
if thinking_level and isinstance(thinking_level, str):
thinking_level = thinking_level.upper()
if thinking_level not in ["MINIMAL", "LOW", "MEDIUM", "HIGH"]:
logger.warning(
f"Invalid thinking level: {thinking_level}, using HIGH"
)
thinking_level = "HIGH"
level = types.ThinkingLevel(thinking_level)
thinking_config = types.ThinkingConfig()
if not hasattr(types.ThinkingConfig, "thinking_level"):
setattr(types.ThinkingConfig, "thinking_level", level)
else:
thinking_config.thinking_level = level
return types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=temperature,
@@ -262,9 +213,24 @@ class ProviderGoogleGenAI(Provider):
logprobs=payloads.get("logprobs"),
seed=payloads.get("seed"),
response_modalities=modalities,
tools=cast(types.ToolListUnion | None, tool_list),
tools=tool_list,
safety_settings=self.safety_settings if self.safety_settings else None,
thinking_config=thinking_config,
thinking_config=(
types.ThinkingConfig(
thinking_budget=min(
int(
self.provider_config.get("gm_thinking_config", {}).get(
"budget",
0,
),
),
24576,
),
)
if "gemini-2.5-flash" in self.get_model()
and hasattr(types.ThinkingConfig, "thinking_budget")
else None
),
automatic_function_calling=types.AutomaticFunctionCallingConfig(
disable=True,
),
@@ -291,7 +257,6 @@ class ProviderGoogleGenAI(Provider):
content_cls: type[types.Content],
) -> None:
if contents and isinstance(contents[-1], content_cls):
assert contents[-1].parts is not None
contents[-1].parts.extend(part)
else:
contents.append(content_cls(parts=part))
@@ -380,16 +345,6 @@ class ProviderGoogleGenAI(Provider):
]
return "".join(thought_buf).strip()
def _extract_usage(
self, usage_metadata: types.GenerateContentResponseUsageMetadata
) -> TokenUsage:
"""Extract usage from candidate"""
return TokenUsage(
input_other=usage_metadata.prompt_token_count or 0,
input_cached=usage_metadata.cached_content_token_count or 0,
output=usage_metadata.candidates_token_count or 0,
)
def _process_content_parts(
self,
candidate: types.Candidate,
@@ -474,11 +429,9 @@ class ProviderGoogleGenAI(Provider):
None,
)
model = payloads.get("model", self.get_model())
modalities = ["TEXT"]
modalities = ["Text"]
if self.provider_config.get("gm_resp_image_modal", False):
modalities.append("IMAGE")
modalities.append("Image")
conversation = self._prepare_conversation(payloads)
temperature = payloads.get("temperature", 0.7)
@@ -494,8 +447,8 @@ class ProviderGoogleGenAI(Provider):
temperature,
)
result = await self.client.models.generate_content(
model=model,
contents=cast(types.ContentListUnion, conversation),
model=self.get_model(),
contents=conversation,
config=config,
)
logger.debug(f"genai result: {result}")
@@ -520,11 +473,11 @@ class ProviderGoogleGenAI(Provider):
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)",
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
logger.warning(f"{model} 不支持函数调用,已自动去除")
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
tools = None
elif (
"Multi-modal output is not supported" in e.message
@@ -533,9 +486,9 @@ class ProviderGoogleGenAI(Provider):
or "only supports text output" in e.message
):
logger.warning(
f"{model} 不支持多模态输出,降级为文本模态",
f"{self.get_model()} 不支持多模态输出,降级为文本模态",
)
modalities = ["TEXT"]
modalities = ["Text"]
else:
raise
continue
@@ -546,9 +499,6 @@ class ProviderGoogleGenAI(Provider):
result.candidates[0],
llm_response,
)
llm_response.id = result.response_id
if result.usage_metadata:
llm_response.usage = self._extract_usage(result.usage_metadata)
return llm_response
async def _query_stream(
@@ -561,7 +511,7 @@ class ProviderGoogleGenAI(Provider):
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
None,
)
model = payloads.get("model", self.get_model())
conversation = self._prepare_conversation(payloads)
result = None
@@ -573,8 +523,8 @@ class ProviderGoogleGenAI(Provider):
system_instruction,
)
result = await self.client.models.generate_content_stream(
model=model,
contents=cast(types.ContentListUnion, conversation),
model=self.get_model(),
contents=conversation,
config=config,
)
break
@@ -583,11 +533,11 @@ class ProviderGoogleGenAI(Provider):
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)",
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
logger.warning(f"{model} 不支持函数调用,已自动去除")
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
tools = None
else:
raise
@@ -617,9 +567,6 @@ class ProviderGoogleGenAI(Provider):
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
@@ -647,9 +594,6 @@ class ProviderGoogleGenAI(Provider):
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
@@ -681,16 +625,13 @@ class ProviderGoogleGenAI(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
new_record = await self.assemble_context(prompt, image_urls)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -709,9 +650,10 @@ class ProviderGoogleGenAI(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
model = model or self.get_model()
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, "model": model}
payloads = {"messages": context_query, **model_config}
retry = 10
keys = self.api_keys.copy()
@@ -736,16 +678,13 @@ class ProviderGoogleGenAI(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
if contexts is None:
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
new_record = await self.assemble_context(prompt, image_urls)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -764,9 +703,10 @@ class ProviderGoogleGenAI(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
model = model or self.get_model()
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, "model": model}
payloads = {"messages": context_query, **model_config}
retry = 10
keys = self.api_keys.copy()
@@ -804,33 +744,13 @@ class ProviderGoogleGenAI(Provider):
self.chosen_api_key = key
self._init_client()
async def assemble_context(
self,
text: str,
image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
):
async def assemble_context(self, text: str, image_urls: list[str] | None = None):
"""组装上下文。"""
# 构建内容块列表
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:
content_blocks.append(part.model_dump())
# 3. 图片内容
if image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": text if text else "[图片]"}],
}
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
@@ -843,25 +763,14 @@ class ProviderGoogleGenAI(Provider):
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
content_blocks.append(
user_content["content"].append(
{
"type": "image_url",
"image_url": {"url": image_data},
},
)
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if (
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}
return user_content
return {"role": "user", "content": text}
async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
@@ -87,7 +87,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
return json.dumps(dict_body)
async def _call_tts_stream(self, text: str) -> AsyncIterator[str]:
async def _call_tts_stream(self, text: str) -> AsyncIterator[bytes]:
"""进行流式请求"""
try:
async with (
@@ -117,9 +117,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
data = json.loads(message[6:])
if "extra_info" in data:
continue
audio: str | None = data.get("data", {}).get(
"audio"
)
audio = data.get("data", {}).get("audio")
if audio is not None:
yield audio
except json.JSONDecodeError:
@@ -30,9 +30,9 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
embedding = await self.client.embeddings.create(input=text, model=self.model)
return embedding.data[0].embedding
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
async def get_embeddings(self, texts: list[str]) -> list[list[float]]:
"""批量获取文本的嵌入"""
embeddings = await self.client.embeddings.create(input=text, model=self.model)
embeddings = await self.client.embeddings.create(input=texts, model=self.model)
return [item.embedding for item in embeddings.data]
def get_dim(self) -> int:
+15 -68
View File
@@ -12,15 +12,14 @@ from openai._exceptions import NotFoundError
from openai.lib.streaming.chat._completions import ChatCompletionStreamState
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.completion_usage import CompletionUsage
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart, Message
from astrbot.core.agent.message import Message
from astrbot.core.agent.tool import ToolSet
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult
from astrbot.core.utils.io import download_image_by_url
from ..register import register_provider_adapter
@@ -69,7 +68,8 @@ class ProviderOpenAIOfficial(Provider):
self.client.chat.completions.create,
).parameters.keys()
model = provider_config.get("model", "unknown")
model_config = provider_config.get("model_config", {})
model = model_config.get("model", "unknown")
self.set_model(model)
self.reasoning_key = "reasoning_content"
@@ -208,7 +208,6 @@ class ProviderOpenAIOfficial(Provider):
# handle the content delta
reasoning = self._extract_reasoning_content(chunk)
_y = False
llm_response.id = chunk.id
if reasoning:
llm_response.reasoning_content = reasoning
_y = True
@@ -218,8 +217,6 @@ class ProviderOpenAIOfficial(Provider):
chain=[Comp.Plain(completion_text)],
)
_y = True
if chunk.usage:
llm_response.usage = self._extract_usage(chunk.usage)
if _y:
yield llm_response
@@ -248,15 +245,6 @@ class ProviderOpenAIOfficial(Provider):
reasoning_text = str(reasoning_attr)
return reasoning_text
def _extract_usage(self, usage: CompletionUsage) -> TokenUsage:
ptd = usage.prompt_tokens_details
cached = ptd.cached_tokens if ptd and ptd.cached_tokens else 0
return TokenUsage(
input_other=usage.prompt_tokens - cached,
input_cached=ptd.cached_tokens if ptd and ptd.cached_tokens else 0,
output=usage.completion_tokens,
)
async def _parse_openai_completion(
self, completion: ChatCompletion, tools: ToolSet | None
) -> LLMResponse:
@@ -296,10 +284,6 @@ class ProviderOpenAIOfficial(Provider):
if isinstance(tool_call, str):
# workaround for #1359
tool_call = json.loads(tool_call)
if tools is None:
# 工具集未提供
# Should be unreachable
raise Exception("工具集未提供")
for tool in tools.func_list:
if (
tool_call.type == "function"
@@ -333,10 +317,6 @@ class ProviderOpenAIOfficial(Provider):
raise Exception(f"API 返回的 completion 无法解析:{completion}")
llm_response.raw_completion = completion
llm_response.id = completion.id
if completion.usage:
llm_response.usage = self._extract_usage(completion.usage)
return llm_response
@@ -348,7 +328,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs,
) -> tuple:
"""准备聊天所需的有效载荷和上下文"""
@@ -356,9 +335,7 @@ class ProviderOpenAIOfficial(Provider):
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
new_record = await self.assemble_context(prompt, image_urls)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -377,9 +354,10 @@ class ProviderOpenAIOfficial(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
model = model or self.get_model()
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, "model": model}
payloads = {"messages": context_query, **model_config}
# xAI origin search tool inject
self._maybe_inject_xai_search(payloads, **kwargs)
@@ -479,7 +457,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> LLMResponse:
payloads, context_query = await self._prepare_chat_payload(
@@ -489,7 +466,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt,
tool_calls_result,
model=model,
extra_user_content_parts=extra_user_content_parts,
**kwargs,
)
@@ -544,7 +520,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""流式对话,与服务商交互并逐步返回结果"""
@@ -555,7 +530,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt,
tool_calls_result,
model=model,
extra_user_content_parts=extra_user_content_parts,
**kwargs,
)
@@ -631,29 +605,13 @@ class ProviderOpenAIOfficial(Provider):
self,
text: str,
image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
# 构建内容块列表
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:
content_blocks.append(part.model_dump())
# 3. 图片内容
if image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": text if text else "[图片]"}],
}
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
@@ -666,25 +624,14 @@ class ProviderOpenAIOfficial(Provider):
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
content_blocks.append(
user_content["content"].append(
{
"type": "image_url",
"image_url": {"url": image_data},
},
)
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if (
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}
return user_content
return {"role": "user", "content": text}
async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
@@ -7,7 +7,6 @@ import asyncio
import os
import re
from datetime import datetime
from typing import cast
from funasr_onnx import SenseVoiceSmall
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess
@@ -33,7 +32,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
self.set_model(provider_config["stt_model"])
self.set_model(provider_config.get("stt_model"))
self.model = None
self.is_emotion = provider_config.get("is_emotion", False)
@@ -87,9 +86,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
loop = asyncio.get_event_loop()
res = await loop.run_in_executor(
None, # 使用默认的线程池
lambda: cast(SenseVoiceSmall, self.model)(
audio_url, language="auto", use_itn=True
),
lambda: self.model(audio_url, language="auto", use_itn=True),
)
# res = self.model(audio_url, language="auto", use_itn=True)
@@ -44,7 +44,6 @@ class VLLMRerankProvider(RerankProvider):
}
if top_n is not None:
payload["top_n"] = top_n
assert self.client is not None
async with self.client.post(
f"{self.base_url}/v1/rerank",
json=payload,
@@ -6,10 +6,7 @@ from openai import NOT_GIVEN, AsyncOpenAI
from astrbot.core import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import download_file
from astrbot.core.utils.tencent_record_helper import (
convert_to_pcm_wav,
tencent_silk_to_wav,
)
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
from ..entities import ProviderType
from ..provider import STTProvider
@@ -36,30 +33,20 @@ class ProviderOpenAIWhisperAPI(STTProvider):
timeout=provider_config.get("timeout", NOT_GIVEN),
)
self.set_model(provider_config["model"])
self.set_model(provider_config.get("model"))
async def _get_audio_format(self, file_path):
# 定义要检测的头部字节
async def _is_silk_file(self, file_path):
silk_header = b"SILK"
amr_header = b"#!AMR"
try:
with open(file_path, "rb") as f:
file_header = f.read(8)
except FileNotFoundError:
return None
with open(file_path, "rb") as f:
file_header = f.read(8)
if silk_header in file_header:
return "silk"
if amr_header in file_header:
return "amr"
return None
return True
return False
async def get_text(self, audio_url: str) -> str:
"""Only supports mp3, mp4, mpeg, m4a, wav, webm"""
is_tencent = False
output_path = None
if audio_url.startswith("http"):
if "multimedia.nt.qq.com.cn" in audio_url:
@@ -75,35 +62,16 @@ class ProviderOpenAIWhisperAPI(STTProvider):
raise FileNotFoundError(f"文件不存在: {audio_url}")
if audio_url.endswith(".amr") or audio_url.endswith(".silk") or is_tencent:
file_format = await self._get_audio_format(audio_url)
# 判断是否需要转换
if file_format in ["silk", "amr"]:
is_silk = await self._is_silk_file(audio_url)
if is_silk:
logger.info("Converting silk file to wav ...")
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav")
if file_format == "silk":
logger.info(
"Converting silk file to wav using tencent_silk_to_wav..."
)
await tencent_silk_to_wav(audio_url, output_path)
elif file_format == "amr":
logger.info(
"Converting amr file to wav using convert_to_pcm_wav..."
)
await convert_to_pcm_wav(audio_url, output_path)
await tencent_silk_to_wav(audio_url, output_path)
audio_url = output_path
result = await self.client.audio.transcriptions.create(
model=self.model_name,
file=("audio.wav", open(audio_url, "rb")),
)
# remove temp file
if output_path and os.path.exists(output_path):
try:
os.remove(audio_url)
except Exception as e:
logger.error(f"Failed to remove temp file {audio_url}: {e}")
return result.text
@@ -1,7 +1,6 @@
import asyncio
import os
import uuid
from typing import cast
import whisper
@@ -27,7 +26,7 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
provider_settings: dict,
) -> None:
super().__init__(provider_config, provider_settings)
self.set_model(provider_config["model"])
self.set_model(provider_config.get("model"))
self.model = None
async def initialize(self):
@@ -76,8 +75,5 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
await tencent_silk_to_wav(audio_url, output_path)
audio_url = output_path
if not self.model:
raise RuntimeError("Whisper 模型未初始化")
result = await loop.run_in_executor(None, self.model.transcribe, audio_url)
return cast(str, result["text"])
return result["text"]
@@ -1,11 +1,6 @@
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 (
AsyncRESTfulRerankModelHandle,
)
from astrbot import logger
@@ -34,7 +29,7 @@ class XinferenceRerankProvider(RerankProvider):
False,
)
self.client = None
self.model: AsyncRESTfulRerankModelHandle | None = None
self.model = None
self.model_uid = None
async def initialize(self):
@@ -70,10 +65,7 @@ class XinferenceRerankProvider(RerankProvider):
return
if self.model_uid:
self.model = cast(
AsyncRESTfulRerankModelHandle,
await self.client.get_model(self.model_uid),
)
self.model = await self.client.get_model(self.model_uid)
except Exception as e:
logger.error(f"Failed to initialize Xinference model: {e}")
+1 -5
View File
@@ -2,19 +2,15 @@ from astrbot.core import html_renderer
from astrbot.core.provider import Provider
from astrbot.core.star.star_tools import StarTools
from astrbot.core.utils.command_parser import CommandParserMixin
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
from .context import Context
from .star import StarMetadata, star_map, star_registry
from .star_manager import PluginManager
class Star(CommandParserMixin, PluginKVStoreMixin):
class Star(CommandParserMixin):
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
author: str
name: str
def __init__(self, context: Context, config: dict | None = None):
StarTools.initialize(context)
self.context = context
-496
View File
@@ -1,496 +0,0 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any
from astrbot.core import db_helper, logger
from astrbot.core.db.po import CommandConfig
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionType, PermissionTypeFilter
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
@dataclass
class CommandDescriptor:
handler: StarHandlerMetadata = field(repr=False)
filter_ref: CommandFilter | CommandGroupFilter | None = field(
default=None,
repr=False,
)
handler_full_name: str = ""
handler_name: str = ""
plugin_name: str = ""
plugin_display_name: str | None = None
module_path: str = ""
description: str = ""
command_type: str = "command" # "command" | "group" | "sub_command"
raw_command_name: str | None = None
current_fragment: str | None = None
parent_signature: str = ""
parent_group_handler: str = ""
original_command: str | None = None
effective_command: str | None = None
aliases: list[str] = field(default_factory=list)
permission: str = "everyone"
enabled: bool = True
is_group: bool = False
is_sub_command: bool = False
reserved: bool = False
config: CommandConfig | None = None
has_conflict: bool = False
sub_commands: list[CommandDescriptor] = field(default_factory=list)
async def sync_command_configs() -> None:
"""同步指令配置,清理过期配置。"""
descriptors = _collect_descriptors(include_sub_commands=False)
config_records = await db_helper.get_command_configs()
config_map = _bind_configs_to_descriptors(descriptors, config_records)
live_handlers = {desc.handler_full_name for desc in descriptors}
stale_configs = [key for key in config_map if key not in live_handlers]
if stale_configs:
await db_helper.delete_command_configs(stale_configs)
async def toggle_command(handler_full_name: str, enabled: bool) -> CommandDescriptor:
descriptor = _build_descriptor_by_full_name(handler_full_name)
if not descriptor:
raise ValueError("指定的处理函数不存在或不是指令。")
existing_cfg = await db_helper.get_command_config(handler_full_name)
config = await db_helper.upsert_command_config(
handler_full_name=handler_full_name,
plugin_name=descriptor.plugin_name or "",
module_path=descriptor.module_path,
original_command=descriptor.original_command or descriptor.handler_name,
resolved_command=(
existing_cfg.resolved_command
if existing_cfg
else descriptor.current_fragment
),
enabled=enabled,
keep_original_alias=False,
conflict_key=existing_cfg.conflict_key
if existing_cfg and existing_cfg.conflict_key
else descriptor.original_command,
resolution_strategy=existing_cfg.resolution_strategy if existing_cfg else None,
note=existing_cfg.note if existing_cfg else None,
extra_data=existing_cfg.extra_data if existing_cfg else None,
auto_managed=False,
)
_bind_descriptor_with_config(descriptor, config)
await sync_command_configs()
return descriptor
async def rename_command(
handler_full_name: str,
new_fragment: str,
aliases: list[str] | None = None,
) -> CommandDescriptor:
descriptor = _build_descriptor_by_full_name(handler_full_name)
if not descriptor:
raise ValueError("指定的处理函数不存在或不是指令。")
new_fragment = new_fragment.strip()
if not new_fragment:
raise ValueError("指令名不能为空。")
# 校验主指令名
candidate_full = _compose_command(descriptor.parent_signature, new_fragment)
if _is_command_in_use(handler_full_name, candidate_full):
raise ValueError(f"指令名 '{candidate_full}' 已被其他指令占用。")
# 校验别名
if aliases:
for alias in aliases:
alias = alias.strip()
if not alias:
continue
alias_full = _compose_command(descriptor.parent_signature, alias)
if _is_command_in_use(handler_full_name, alias_full):
raise ValueError(f"别名 '{alias_full}' 已被其他指令占用。")
existing_cfg = await db_helper.get_command_config(handler_full_name)
merged_extra = dict(existing_cfg.extra_data or {}) if existing_cfg else {}
merged_extra["resolved_aliases"] = aliases or []
config = await db_helper.upsert_command_config(
handler_full_name=handler_full_name,
plugin_name=descriptor.plugin_name or "",
module_path=descriptor.module_path,
original_command=descriptor.original_command or descriptor.handler_name,
resolved_command=new_fragment,
enabled=True if descriptor.enabled else False,
keep_original_alias=False,
conflict_key=descriptor.original_command,
resolution_strategy="manual_rename",
note=None,
extra_data=merged_extra,
auto_managed=False,
)
_bind_descriptor_with_config(descriptor, config)
await sync_command_configs()
return descriptor
async def list_commands() -> list[dict[str, Any]]:
descriptors = _collect_descriptors(include_sub_commands=True)
config_records = await db_helper.get_command_configs()
_bind_configs_to_descriptors(descriptors, config_records)
conflict_groups = _group_conflicts(descriptors)
conflict_handler_names: set[str] = {
d.handler_full_name for group in conflict_groups.values() for d in group
}
# 分类,设置冲突标志,将子指令挂载到父指令组
group_map: dict[str, CommandDescriptor] = {}
sub_commands: list[CommandDescriptor] = []
root_commands: list[CommandDescriptor] = []
for desc in descriptors:
desc.has_conflict = desc.handler_full_name in conflict_handler_names
if desc.is_group:
group_map[desc.handler_full_name] = desc
elif desc.is_sub_command:
sub_commands.append(desc)
else:
root_commands.append(desc)
for sub in sub_commands:
if sub.parent_group_handler and sub.parent_group_handler in group_map:
group_map[sub.parent_group_handler].sub_commands.append(sub)
else:
root_commands.append(sub)
# 指令组 + 普通指令,按 effective_command 字母排序
all_commands = list(group_map.values()) + root_commands
all_commands.sort(key=lambda d: (d.effective_command or "").lower())
result = [_descriptor_to_dict(desc) for desc in all_commands]
return result
async def list_command_conflicts() -> list[dict[str, Any]]:
"""列出所有冲突的指令组。"""
descriptors = _collect_descriptors(include_sub_commands=False)
config_records = await db_helper.get_command_configs()
_bind_configs_to_descriptors(descriptors, config_records)
conflict_groups = _group_conflicts(descriptors)
details = [
{
"conflict_key": key,
"handlers": [
{
"handler_full_name": item.handler_full_name,
"plugin": item.plugin_name,
"current_name": item.effective_command,
}
for item in group
],
}
for key, group in conflict_groups.items()
]
return details
# Internal helpers ----------------------------------------------------------
def _collect_descriptors(include_sub_commands: bool) -> list[CommandDescriptor]:
"""收集指令,按需包含子指令。"""
descriptors: list[CommandDescriptor] = []
for handler in star_handlers_registry:
try:
desc = _build_descriptor(handler)
if not desc:
continue
if not include_sub_commands and desc.is_sub_command:
continue
descriptors.append(desc)
except Exception as e:
logger.warning(
f"解析指令处理函数 {handler.handler_full_name} 失败,跳过该指令。原因: {e!s}"
)
continue
return descriptors
def _build_descriptor(handler: StarHandlerMetadata) -> CommandDescriptor | None:
filter_ref = _locate_primary_filter(handler)
if filter_ref is None:
return None
plugin_meta = star_map.get(handler.handler_module_path)
plugin_name = (
plugin_meta.name if plugin_meta else None
) or handler.handler_module_path
plugin_display = plugin_meta.display_name if plugin_meta else None
is_sub_command = bool(handler.extras_configs.get("sub_command"))
parent_group_handler = ""
if isinstance(filter_ref, CommandFilter):
raw_fragment = getattr(
filter_ref, "_original_command_name", filter_ref.command_name
)
current_fragment = filter_ref.command_name
parent_signature = (filter_ref.parent_command_names or [""])[0].strip()
# 如果是子指令,尝试找到父指令组的 handler_full_name
if is_sub_command and parent_signature:
parent_group_handler = _find_parent_group_handler(
handler.handler_module_path, parent_signature
)
else:
raw_fragment = getattr(
filter_ref, "_original_group_name", filter_ref.group_name
)
current_fragment = filter_ref.group_name
parent_signature = _resolve_group_parent_signature(filter_ref)
original_command = _compose_command(parent_signature, raw_fragment)
effective_command = _compose_command(parent_signature, current_fragment)
# 确定 command_type
if isinstance(filter_ref, CommandGroupFilter):
command_type = "group"
elif is_sub_command:
command_type = "sub_command"
else:
command_type = "command"
descriptor = CommandDescriptor(
handler=handler,
filter_ref=filter_ref,
handler_full_name=handler.handler_full_name,
handler_name=handler.handler_name,
plugin_name=plugin_name,
plugin_display_name=plugin_display,
module_path=handler.handler_module_path,
description=handler.desc or "",
command_type=command_type,
raw_command_name=raw_fragment,
current_fragment=current_fragment,
parent_signature=parent_signature,
parent_group_handler=parent_group_handler,
original_command=original_command,
effective_command=effective_command,
aliases=sorted(getattr(filter_ref, "alias", set())),
permission=_determine_permission(handler),
enabled=handler.enabled,
is_group=isinstance(filter_ref, CommandGroupFilter),
is_sub_command=is_sub_command,
reserved=plugin_meta.reserved if plugin_meta else False,
)
return descriptor
def _build_descriptor_by_full_name(full_name: str) -> CommandDescriptor | None:
handler = star_handlers_registry.get_handler_by_full_name(full_name)
if not handler:
return None
return _build_descriptor(handler)
def _locate_primary_filter(
handler: StarHandlerMetadata,
) -> CommandFilter | CommandGroupFilter | None:
for filter_ref in handler.event_filters:
if isinstance(filter_ref, (CommandFilter, CommandGroupFilter)):
return filter_ref
return None
def _determine_permission(handler: StarHandlerMetadata) -> str:
for filter_ref in handler.event_filters:
if isinstance(filter_ref, PermissionTypeFilter):
return (
"admin"
if filter_ref.permission_type == PermissionType.ADMIN
else "member"
)
return "everyone"
def _resolve_group_parent_signature(group_filter: CommandGroupFilter) -> str:
signatures: list[str] = []
parent = group_filter.parent_group
while parent:
signatures.append(getattr(parent, "_original_group_name", parent.group_name))
parent = parent.parent_group
return " ".join(reversed(signatures)).strip()
def _find_parent_group_handler(module_path: str, parent_signature: str) -> str:
"""根据模块路径和父级签名,找到对应的指令组 handler_full_name。"""
parent_sig_normalized = parent_signature.strip()
for handler in star_handlers_registry:
if handler.handler_module_path != module_path:
continue
filter_ref = _locate_primary_filter(handler)
if not isinstance(filter_ref, CommandGroupFilter):
continue
# 检查该指令组的完整指令名是否匹配 parent_signature
group_names = filter_ref.get_complete_command_names()
if parent_sig_normalized in group_names:
return handler.handler_full_name
return ""
def _compose_command(parent_signature: str, fragment: str | None) -> str:
fragment = (fragment or "").strip()
parent_signature = parent_signature.strip()
if not parent_signature:
return fragment
if not fragment:
return parent_signature
return f"{parent_signature} {fragment}"
def _bind_descriptor_with_config(
descriptor: CommandDescriptor,
config: CommandConfig,
) -> None:
_apply_config_to_descriptor(descriptor, config)
_apply_config_to_runtime(descriptor, config)
def _apply_config_to_descriptor(
descriptor: CommandDescriptor,
config: CommandConfig,
) -> None:
descriptor.config = config
descriptor.enabled = config.enabled
if config.original_command:
descriptor.original_command = config.original_command
new_fragment = config.resolved_command or descriptor.current_fragment
descriptor.current_fragment = new_fragment
descriptor.effective_command = _compose_command(
descriptor.parent_signature,
new_fragment,
)
extra = config.extra_data or {}
resolved_aliases = extra.get("resolved_aliases")
if isinstance(resolved_aliases, list):
descriptor.aliases = [str(x) for x in resolved_aliases if str(x).strip()]
def _apply_config_to_runtime(
descriptor: CommandDescriptor,
config: CommandConfig,
) -> None:
descriptor.handler.enabled = config.enabled
if descriptor.filter_ref:
if descriptor.current_fragment:
_set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment)
extra = config.extra_data or {}
resolved_aliases = extra.get("resolved_aliases")
if isinstance(resolved_aliases, list):
_set_filter_aliases(
descriptor.filter_ref,
[str(x) for x in resolved_aliases if str(x).strip()],
)
def _bind_configs_to_descriptors(
descriptors: list[CommandDescriptor],
config_records: list[CommandConfig],
) -> dict[str, CommandConfig]:
config_map = {cfg.handler_full_name: cfg for cfg in config_records}
for desc in descriptors:
if cfg := config_map.get(desc.handler_full_name):
_bind_descriptor_with_config(desc, cfg)
return config_map
def _group_conflicts(
descriptors: list[CommandDescriptor],
) -> dict[str, list[CommandDescriptor]]:
conflicts: dict[str, list[CommandDescriptor]] = defaultdict(list)
for desc in descriptors:
if desc.effective_command and desc.enabled:
conflicts[desc.effective_command].append(desc)
return {k: v for k, v in conflicts.items() if len(v) > 1}
def _set_filter_fragment(
filter_ref: CommandFilter | CommandGroupFilter,
fragment: str,
) -> None:
attr = (
"group_name" if isinstance(filter_ref, CommandGroupFilter) else "command_name"
)
current_value = getattr(filter_ref, attr)
if fragment == current_value:
return
setattr(filter_ref, attr, fragment)
if hasattr(filter_ref, "_cmpl_cmd_names"):
filter_ref._cmpl_cmd_names = None
def _set_filter_aliases(
filter_ref: CommandFilter | CommandGroupFilter,
aliases: list[str],
) -> None:
current_aliases = getattr(filter_ref, "alias", set())
if set(aliases) == current_aliases:
return
setattr(filter_ref, "alias", set(aliases))
if hasattr(filter_ref, "_cmpl_cmd_names"):
filter_ref._cmpl_cmd_names = None
def _is_command_in_use(
target_handler_full_name: str,
candidate_full_command: str,
) -> bool:
candidate = candidate_full_command.strip()
for handler in star_handlers_registry:
if handler.handler_full_name == target_handler_full_name:
continue
filter_ref = _locate_primary_filter(handler)
if not filter_ref:
continue
names = {name.strip() for name in filter_ref.get_complete_command_names()}
if candidate in names:
return True
return False
def _descriptor_to_dict(desc: CommandDescriptor) -> dict[str, Any]:
result = {
"handler_full_name": desc.handler_full_name,
"handler_name": desc.handler_name,
"plugin": desc.plugin_name,
"plugin_display_name": desc.plugin_display_name,
"module_path": desc.module_path,
"description": desc.description,
"type": desc.command_type,
"parent_signature": desc.parent_signature,
"parent_group_handler": desc.parent_group_handler,
"original_command": desc.original_command,
"current_fragment": desc.current_fragment,
"effective_command": desc.effective_command,
"aliases": desc.aliases,
"permission": desc.permission,
"enabled": desc.enabled,
"is_group": desc.is_group,
"has_conflict": desc.has_conflict,
"reserved": desc.reserved,
}
# 如果是指令组,包含子指令列表
if desc.is_group and desc.sub_commands:
result["sub_commands"] = [_descriptor_to_dict(sub) for sub in desc.sub_commands]
else:
result["sub_commands"] = []
return result
+2 -6
View File
@@ -267,10 +267,6 @@ class Context:
):
"""通过 ID 获取对应的 LLM Provider。"""
prov = self.provider_manager.inst_map.get(provider_id)
if provider_id and not prov:
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
return prov
def get_all_providers(self) -> list[Provider]:
@@ -289,7 +285,7 @@ class Context:
"""获取所有用于 Embedding 任务的 Provider。"""
return self.provider_manager.embedding_provider_insts
def get_using_provider(self, umo: str | None = None) -> Provider:
def get_using_provider(self, umo: str | None = None) -> Provider | None:
"""获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。通过 /provider 指令切换。
Args:
@@ -300,7 +296,7 @@ class Context:
provider_type=ProviderType.CHAT_COMPLETION,
umo=umo,
)
if not isinstance(prov, Provider):
if prov and not isinstance(prov, Provider):
raise ValueError("返回的 Provider 不是 Provider 类型")
return prov

Some files were not shown because too many files have changed in this diff Show More