Compare commits

..

10 Commits

Author SHA1 Message Date
Soulter a663d6509b chore: bump version to 4.1.2 2025-09-14 21:07:36 +08:00
Soulter 9ec8839efa perf: 检查服务提供商可用性时跳过未启用的提供商 2025-09-14 21:01:32 +08:00
Soulter a7a0350eb2 fix: 平台配置下的「内容安全」组无法生效 (#2751) 2025-09-14 20:25:53 +08:00
Soulter 39a7a0d960 fix: revert "feat: 兼容指令名和第一个参数之间没有空格的情况 (#2650)" for command issue
This reverts commit 9bfa726107.
2025-09-14 19:31:15 +08:00
Soulter 7740e1e131 ci: add ci stage of code format checking (#2750)
* style: ruff format

* ci(dashboard-ci): ensure GitHub Release action only runs on push events

* ci(code-format): ruff format and ruff check
2025-09-14 18:05:58 +08:00
Soulter 9dce1ed47e chore(github): revise PR template
Updated the pull request template to improve clarity and fix formatting issues.
2025-09-14 14:44:46 +08:00
Soulter e84a00d3a5 fix: 修复多配置文件配置的不同人格无法生效的问题 (#2739)
fixes: #2724
2025-09-14 14:09:46 +08:00
anka 88a944cb57 chore(github): 优化 PR 模板 2025-09-14 12:58:34 +08:00
Soulter 20c32e72cc chore: bump version to 4.1.1 2025-09-13 16:19:40 +08:00
Soulter 4788c20816 fix: model variable referenced before assignment 2025-09-13 16:18:22 +08:00
50 changed files with 349 additions and 153 deletions
+39 -12
View File
@@ -1,19 +1,46 @@
<!-- 如果有的话,指定这个 PR 解决的 ISSUE -->
解决了 #XYZ
<!-- 如果有的话,指定 PR 旨在解决的 ISSUE 编号。 -->
<!-- If applicable, please specify the ISSUE number this PR aims to resolve. -->
### Motivation
fixes #XYZ
<!--解释为什么要改动-->
---
### Modifications
### Motivation / 动机
<!--简单解释你的改动-->
<!--请描述此项更改的动机:它解决了什么问题?(例如:修复了 XX 错误,添加了 YY 功能)-->
<!--Please describe the motivation for this change: What problem does it solve? (e.g., Fixes XX bug, adds YY feature)-->
### Check
### Modifications / 改动点
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容-->
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
- [ ] 😊 我的 Commit Message 符合良好的[规范](https://www.conventionalcommits.org/en/v1.0.0/#summary)
- [ ] 👀 我的更改经过良好的测试
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt``pyproject.toml` 文件相应位置。
- [ ] 😮 我的更改没有引入恶意代码
### Verification Steps / 验证步骤
<!--请为审查者 (Reviewer) 提供清晰、可复现的验证步骤(例如:1. 导航到... 2. 点击...)。-->
<!--Please provide clear and reproducible verification steps for the Reviewer (e.g., 1. Navigate to... 2. Click...).-->
### Screenshots or Test Results / 运行截图或测试结果
<!--请粘贴截图、GIF 或测试日志,作为执行“验证步骤”的证据,证明此改动有效。-->
<!--Please paste screenshots, GIFs, or test logs here as evidence of executing the "Verification Steps" to prove this change is effective.-->
### Compatibility & Breaking Changes / 兼容性与破坏性变更
<!--请说明此变更的兼容性:哪些是破坏性变更?哪些地方做了向后兼容处理?是否提供了数据迁移方法?-->
<!--Please explain the compatibility of this change: What are the breaking changes? What backward-compatible measures were taken? Are data migration paths provided?-->
- [ ] 这是一个破坏性变更 (Breaking Change)。/ This is a breaking change.
- [ ] 这不是一个破坏性变更。/ This is NOT a breaking change.
---
### Checklist / 检查清单
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt``pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
+34
View File
@@ -0,0 +1,34 @@
name: Code Format Check
on:
pull_request:
branches: [ master ]
push:
branches: [ master ]
jobs:
format-check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install UV
run: pip install uv
- name: Install dependencies
run: uv sync
- name: Check code formatting with ruff
run: |
uv run ruff format --check .
- name: Check code style with ruff
run: |
uv run ruff check .
+1
View File
@@ -37,6 +37,7 @@ jobs:
!dist/**/*.md
- name: Create GitHub Release
if: github.event_name == 'push'
uses: ncipollo/release-action@v1
with:
tag: release-${{ github.sha }}
+22 -18
View File
@@ -124,15 +124,17 @@ def build_plug_list(plugins_dir: Path) -> list:
if metadata and all(
k in metadata for k in ["name", "desc", "version", "author", "repo"]
):
result.append({
"name": str(metadata.get("name", "")),
"desc": str(metadata.get("desc", "")),
"version": str(metadata.get("version", "")),
"author": str(metadata.get("author", "")),
"repo": str(metadata.get("repo", "")),
"status": PluginStatus.INSTALLED,
"local_path": str(plugin_dir),
})
result.append(
{
"name": str(metadata.get("name", "")),
"desc": str(metadata.get("desc", "")),
"version": str(metadata.get("version", "")),
"author": str(metadata.get("author", "")),
"repo": str(metadata.get("repo", "")),
"status": PluginStatus.INSTALLED,
"local_path": str(plugin_dir),
}
)
# 获取在线插件列表
online_plugins = []
@@ -142,15 +144,17 @@ def build_plug_list(plugins_dir: Path) -> list:
resp.raise_for_status()
data = resp.json()
for plugin_id, plugin_info in data.items():
online_plugins.append({
"name": str(plugin_id),
"desc": str(plugin_info.get("desc", "")),
"version": str(plugin_info.get("version", "")),
"author": str(plugin_info.get("author", "")),
"repo": str(plugin_info.get("repo", "")),
"status": PluginStatus.NOT_INSTALLED,
"local_path": None,
})
online_plugins.append(
{
"name": str(plugin_id),
"desc": str(plugin_info.get("desc", "")),
"version": str(plugin_info.get("version", "")),
"author": str(plugin_info.get("author", "")),
"repo": str(plugin_info.get("repo", "")),
"status": PluginStatus.NOT_INSTALLED,
"local_path": None,
}
)
except Exception as e:
click.echo(f"获取在线插件列表失败: {e}", err=True)
+1
View File
@@ -2,6 +2,7 @@ from dataclasses import dataclass
import typing as T
from astrbot.core.message.message_event_result import MessageChain
class AgentResponseData(T.TypedDict):
chain: MessageChain
+1
View File
@@ -14,4 +14,5 @@ class ContextWrapper(Generic[TContext]):
context: TContext
event: AstrMessageEvent
NoContext = ContextWrapper[None]
+11 -11
View File
@@ -6,7 +6,7 @@ import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.1.0"
VERSION = "4.1.2"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
# 默认配置
@@ -2130,41 +2130,41 @@ CONFIG_METADATA_3 = {
"description": "内容安全",
"type": "object",
"items": {
"platform_settings.content_safety.also_use_in_response": {
"content_safety.also_use_in_response": {
"description": "同时检查模型的响应内容",
"type": "bool",
},
"platform_settings.content_safety.baidu_aip.enable": {
"content_safety.baidu_aip.enable": {
"description": "使用百度内容安全审核",
"type": "bool",
"hint": "您需要手动安装 baidu-aip 库。",
},
"platform_settings.content_safety.baidu_aip.app_id": {
"content_safety.baidu_aip.app_id": {
"description": "App ID",
"type": "string",
"condition": {
"platform_settings.content_safety.baidu_aip.enable": True,
"content_safety.baidu_aip.enable": True,
},
},
"platform_settings.content_safety.baidu_aip.api_key": {
"content_safety.baidu_aip.api_key": {
"description": "API Key",
"type": "string",
"condition": {
"platform_settings.content_safety.baidu_aip.enable": True,
"content_safety.baidu_aip.enable": True,
},
},
"platform_settings.content_safety.baidu_aip.secret_key": {
"content_safety.baidu_aip.secret_key": {
"description": "Secret Key",
"type": "string",
"condition": {
"platform_settings.content_safety.baidu_aip.enable": True,
"content_safety.baidu_aip.enable": True,
},
},
"platform_settings.content_safety.internal_keywords.enable": {
"content_safety.internal_keywords.enable": {
"description": "关键词检查",
"type": "bool",
},
"platform_settings.content_safety.internal_keywords.extra_keywords": {
"content_safety.internal_keywords.extra_keywords": {
"description": "额外关键词",
"type": "list",
"items": {"type": "string"},
+1 -1
View File
@@ -53,7 +53,7 @@ async def do_migration_v4(
await migration_webchat_data(db_helper, platform_id_map)
# 执行偏好设置迁移
await migration_preferences(db_helper,platform_id_map)
await migration_preferences(db_helper, platform_id_map)
# 执行平台统计表迁移
await migration_platform_table(db_helper, platform_id_map)
@@ -5,6 +5,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
_VT = TypeVar("_VT")
class SharedPreferences:
def __init__(self, path=None):
if path is None:
@@ -42,4 +43,5 @@ class SharedPreferences:
self._data.clear()
self._save_preferences()
sp = SharedPreferences()
+2 -1
View File
@@ -4,6 +4,7 @@ from astrbot.core.db.po import Platform, Stats
from typing import Tuple, List, Dict, Any
from dataclasses import dataclass
@dataclass
class Conversation:
"""LLM 对话存储
@@ -76,7 +77,7 @@ PRAGMA encoding = 'UTF-8';
"""
class SQLiteDatabase():
class SQLiteDatabase:
def __init__(self, db_path: str) -> None:
super().__init__()
self.db_path = db_path
@@ -1,3 +1,3 @@
from .vec_db import FaissVecDB
__all__ = ["FaissVecDB"]
__all__ = ["FaissVecDB"]
+2 -1
View File
@@ -113,7 +113,8 @@ class FaissVecDB(BaseVecDB):
reranked_results, key=lambda x: x.relevance_score, reverse=True
)
top_k_results = [
top_k_results[reranked_result.index] for reranked_result in reranked_results
top_k_results[reranked_result.index]
for reranked_result in reranked_results
]
return top_k_results
+1 -1
View File
@@ -77,7 +77,7 @@ async def call_event_hook(
Returns:
bool: 如果事件被终止,返回 True
# """
#"""
handlers = star_handlers_registry.get_handlers_by_event_type(
hook_type, plugins_name=event.plugins_name
)
+1 -1
View File
@@ -24,7 +24,7 @@ from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.utils.metrics import Metric
from .astrbot_message import AstrBotMessage, Group
from .platform_metadata import PlatformMetadata
from .message_session import MessageSession, MessageSesion # noqa
from .message_session import MessageSession, MessageSesion # noqa
class AstrMessageEvent(abc.ABC):
+9 -9
View File
@@ -68,23 +68,23 @@ class PlatformManager:
)
case "lark":
from .sources.lark.lark_adapter import (
LarkPlatformAdapter,
) # noqa: F401
LarkPlatformAdapter, # noqa: F401
)
case "dingtalk":
from .sources.dingtalk.dingtalk_adapter import (
DingtalkPlatformAdapter, # noqa: F401
)
case "telegram":
from .sources.telegram.tg_adapter import (
TelegramPlatformAdapter,
) # noqa: F401
TelegramPlatformAdapter, # noqa: F401
)
case "wecom":
from .sources.wecom.wecom_adapter import (
WecomPlatformAdapter,
) # noqa: F401
WecomPlatformAdapter, # noqa: F401
)
case "weixin_official_account":
from .sources.weixin_official_account.weixin_offacc_adapter import (
WeixinOfficialAccountPlatformAdapter, # noqa
WeixinOfficialAccountPlatformAdapter, # noqa: F401
)
case "discord":
from .sources.discord.discord_platform_adapter import (
@@ -94,8 +94,8 @@ class PlatformManager:
from .sources.slack.slack_adapter import SlackAdapter # noqa: F401
case "satori":
from .sources.satori.satori_adapter import (
SatoriPlatformAdapter,
) # noqa: F401
SatoriPlatformAdapter, # noqa: F401
)
except (ImportError, ModuleNotFoundError) as e:
logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。"
@@ -321,7 +321,9 @@ class AiocqhttpAdapter(Platform):
user_id=int(m["data"]["qq"]),
no_cache=False,
)
nickname = at_info.get("nick", "") or at_info.get("nickname", "")
nickname = at_info.get("nick", "") or at_info.get(
"nickname", ""
)
is_at_self = str(m["data"]["qq"]) in {abm.self_id, "all"}
abm.message.append(
@@ -54,9 +54,9 @@ class DingtalkMessageEvent(AstrMessageEvent):
logger.debug(f"send image: {ret}")
except Exception as e:
logger.error(f"钉钉图片处理失败: {e}")
logger.warning(f"跳过图片发送: {image_path}")
logger.warning(f"钉钉图片处理失败: {e}, 跳过图片发送")
continue
async def send(self, message: MessageChain):
await self.send_with_client(self.client, message)
await super().send(message)
@@ -41,7 +41,8 @@ class DiscordBotClient(discord.Bot):
await self.on_ready_once_callback()
except Exception as e:
logger.error(
f"[Discord] on_ready_once_callback 执行失败: {e}", exc_info=True)
f"[Discord] on_ready_once_callback 执行失败: {e}", exc_info=True
)
def _create_message_data(self, message: discord.Message) -> dict:
"""从 discord.Message 创建数据字典"""
@@ -90,7 +91,6 @@ class DiscordBotClient(discord.Bot):
message_data = self._create_message_data(message)
await self.on_message_received(message_data)
def _extract_interaction_content(self, interaction: discord.Interaction) -> str:
"""从交互中提取内容"""
interaction_type = interaction.type
@@ -79,9 +79,12 @@ class DiscordButton(BaseMessageComponent):
self.url = url
self.disabled = disabled
class DiscordReference(BaseMessageComponent):
"""Discord引用组件"""
type: str = "discord_reference"
def __init__(self, message_id: str, channel_id: str):
self.message_id = message_id
self.channel_id = channel_id
@@ -98,7 +101,6 @@ class DiscordView(BaseMessageComponent):
self.components = components or []
self.timeout = timeout
def to_discord_view(self) -> discord.ui.View:
"""转换为Discord View对象"""
view = discord.ui.View(timeout=self.timeout)
@@ -53,7 +53,13 @@ class DiscordPlatformEvent(AstrMessageEvent):
# 解析消息链为 Discord 所需的对象
try:
content, files, view, embeds, reference_message_id = await self._parse_to_discord(message)
(
content,
files,
view,
embeds,
reference_message_id,
) = await self._parse_to_discord(message)
except Exception as e:
logger.error(f"[Discord] 解析消息链时失败: {e}", exc_info=True)
return
@@ -206,8 +212,7 @@ class DiscordPlatformEvent(AstrMessageEvent):
if await asyncio.to_thread(path.exists):
file_bytes = await asyncio.to_thread(path.read_bytes)
files.append(
discord.File(BytesIO(file_bytes),
filename=i.name)
discord.File(BytesIO(file_bytes), filename=i.name)
)
else:
logger.warning(
@@ -94,10 +94,15 @@ class QQOfficialMessageEvent(AstrMessageEvent):
plain_text,
image_base64,
image_path,
record_file_path
record_file_path,
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
if not plain_text and not image_base64 and not image_path and not record_file_path:
if (
not plain_text
and not image_base64
and not image_path
and not record_file_path
):
return
payload = {
@@ -118,7 +123,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path: # group record msg
if record_file_path: # group record msg
media = await self.upload_group_and_c2c_record(
record_file_path, 3, group_openid=source.group_openid
)
@@ -134,9 +139,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path: # c2c record
if record_file_path: # c2c record
media = await self.upload_group_and_c2c_record(
record_file_path, 3, openid = source.author.user_openid
record_file_path, 3, openid=source.author.user_openid
)
payload["media"] = media
payload["msg_type"] = 7
@@ -190,58 +195,55 @@ class QQOfficialMessageEvent(AstrMessageEvent):
return await self.bot.api._http.request(route, json=payload)
async def upload_group_and_c2c_record(
self,
file_source: str,
file_type: int,
srv_send_msg: bool = False,
**kwargs
self, file_source: str, file_type: int, srv_send_msg: bool = False, **kwargs
) -> Optional[Media]:
"""
上传媒体文件
"""
# 构建基础payload
payload = {
"file_type": file_type,
"srv_send_msg": srv_send_msg
}
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg}
# 处理文件数据
if os.path.exists(file_source):
# 读取本地文件
async with aiofiles.open(file_source, 'rb') as f:
async with aiofiles.open(file_source, "rb") as f:
file_content = await f.read()
# use base64 encode
payload["file_data"] = base64.b64encode(file_content).decode('utf-8')
payload["file_data"] = base64.b64encode(file_content).decode("utf-8")
else:
# 使用URL
payload["url"] = file_source
# 添加接收者信息和确定路由
if "openid" in kwargs:
payload["openid"] = kwargs["openid"]
route = Route("POST", "/v2/users/{openid}/files", openid=kwargs["openid"])
elif "group_openid" in kwargs:
payload["group_openid"] =kwargs["group_openid"]
route = Route("POST", "/v2/groups/{group_openid}/files", group_openid=kwargs["group_openid"])
payload["group_openid"] = kwargs["group_openid"]
route = Route(
"POST",
"/v2/groups/{group_openid}/files",
group_openid=kwargs["group_openid"],
)
else:
return None
try:
# 使用底层HTTP请求
result = await self.bot.api._http.request(route, json=payload)
if result:
return Media(
file_uuid=result.get("file_uuid"),
file_info=result.get("file_info"),
ttl=result.get("ttl", 0),
file_id=result.get("id", "")
file_id=result.get("id", ""),
)
except Exception as e:
logger.error(f"上传请求错误: {e}")
return None
async def post_c2c_message(
self,
openid: str,
@@ -286,19 +288,23 @@ class QQOfficialMessageEvent(AstrMessageEvent):
image_base64 = image_base64.removeprefix("base64://")
elif isinstance(i, Record):
if i.file:
record_wav_path = await i.convert_to_file_path() # wav 路径
record_wav_path = await i.convert_to_file_path() # wav 路径
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
record_tecent_silk_path = os.path.join(temp_dir, f"{uuid.uuid4()}.silk")
record_tecent_silk_path = os.path.join(
temp_dir, f"{uuid.uuid4()}.silk"
)
try:
duration = await wav_to_tencent_silk(record_wav_path, record_tecent_silk_path)
duration = await wav_to_tencent_silk(
record_wav_path, record_tecent_silk_path
)
if duration > 0:
record_file_path = record_tecent_silk_path
else:
record_file_path = None
record_file_path = None
logger.error("转换音频格式时出错:音频时长不大于0")
except Exception as e:
logger.error(f"处理语音时出错: {e}")
record_file_path = None
record_file_path = None
else:
logger.debug(f"qq_official 忽略 {i.type}")
return plain_text, image_base64, image_file_path, record_file_path
@@ -308,7 +308,9 @@ class SlackAdapter(Platform):
base64_content = base64.b64encode(content).decode("utf-8")
return base64_content
else:
logger.error(f"Failed to download slack file: {resp.status} {await resp.text()}")
logger.error(
f"Failed to download slack file: {resp.status} {await resp.text()}"
)
raise Exception(f"下载文件失败: {resp.status}")
async def run(self) -> Awaitable[Any]:
@@ -75,7 +75,13 @@ class SlackMessageEvent(AstrMessageEvent):
"text": {"type": "mrkdwn", "text": "文件上传失败"},
}
file_url = response["files"][0]["permalink"]
return {"type": "section", "text": {"type": "mrkdwn", "text": f"文件: <{file_url}|{segment.name or '文件'}>"}}
return {
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"文件: <{file_url}|{segment.name or '文件'}>",
},
}
else:
return {"type": "section", "text": {"type": "mrkdwn", "text": str(segment)}}
@@ -66,7 +66,9 @@ class TelegramPlatformEvent(AstrMessageEvent):
return chunks
@classmethod
async def send_with_client(cls, client: ExtBot, message: MessageChain, user_name: str):
async def send_with_client(
cls, client: ExtBot, message: MessageChain, user_name: str
):
image_path = None
has_reply = False
@@ -1,5 +1,6 @@
import asyncio
class WebChatQueueMgr:
def __init__(self) -> None:
self.queues = {}
@@ -30,4 +31,5 @@ class WebChatQueueMgr:
"""Check if a queue exists for the given conversation ID"""
return conversation_id in self.queues
webchat_queue_mgr = WebChatQueueMgr()
@@ -213,10 +213,10 @@ class WeChatPadProAdapter(Platform):
def _extract_auth_key(self, data):
"""Helper method to extract auth_key from response data."""
if isinstance(data, dict):
auth_keys = data.get("authKeys") # 新接口
auth_keys = data.get("authKeys") # 新接口
if isinstance(auth_keys, list) and auth_keys:
return auth_keys[0]
elif isinstance(data, list) and data: # 旧接口
elif isinstance(data, list) and data: # 旧接口
return data[0]
return None
@@ -234,7 +234,9 @@ class WeChatPadProAdapter(Platform):
try:
async with session.post(url, params=params, json=payload) as response:
if response.status != 200:
logger.error(f"生成授权码失败: {response.status}, {await response.text()}")
logger.error(
f"生成授权码失败: {response.status}, {await response.text()}"
)
return
response_data = await response.json()
@@ -245,7 +247,9 @@ class WeChatPadProAdapter(Platform):
if self.auth_key:
logger.info("成功获取授权码")
else:
logger.error(f"生成授权码成功但未找到授权码: {response_data}")
logger.error(
f"生成授权码成功但未找到授权码: {response_data}"
)
else:
logger.error(f"生成授权码失败: {response_data}")
except aiohttp.ClientConnectorError as e:
@@ -48,7 +48,12 @@ class WeChatKF(BaseWeChatAPI):
注意:可能会出现返回条数少于limit的情况,需结合返回的has_more字段判断是否继续请求。
:return: 接口调用结果
"""
data = {"token": token, "cursor": cursor, "limit": limit, "open_kfid": open_kfid}
data = {
"token": token,
"cursor": cursor,
"limit": limit,
"open_kfid": open_kfid,
}
return self._post("kf/sync_msg", data=data)
def get_service_state(self, open_kfid, external_userid):
@@ -72,7 +77,9 @@ class WeChatKF(BaseWeChatAPI):
}
return self._post("kf/service_state/get", data=data)
def trans_service_state(self, open_kfid, external_userid, service_state, servicer_userid=""):
def trans_service_state(
self, open_kfid, external_userid, service_state, servicer_userid=""
):
"""
变更会话状态
@@ -180,7 +187,9 @@ class WeChatKF(BaseWeChatAPI):
"""
return self._get("kf/customer/get_upgrade_service_config")
def upgrade_service(self, open_kfid, external_userid, service_type, member=None, groupchat=None):
def upgrade_service(
self, open_kfid, external_userid, service_type, member=None, groupchat=None
):
"""
为客户升级为专员或客户群服务
@@ -246,7 +255,9 @@ class WeChatKF(BaseWeChatAPI):
data = {"open_kfid": open_kfid, "start_time": start_time, "end_time": end_time}
return self._post("kf/get_corp_statistic", data=data)
def get_servicer_statistic(self, start_time, end_time, open_kfid=None, servicer_userid=None):
def get_servicer_statistic(
self, start_time, end_time, open_kfid=None, servicer_userid=None
):
"""
获取「客户数据统计」接待人员明细数据
@@ -26,6 +26,7 @@ from optionaldict import optionaldict
from wechatpy.client.api.base import BaseWeChatAPI
class WeChatKFMessage(BaseWeChatAPI):
"""
发送微信客服消息
@@ -125,35 +126,55 @@ class WeChatKFMessage(BaseWeChatAPI):
msg={"msgtype": "news", "link": {"link": articles_data}},
)
def send_msgmenu(self, user_id, open_kfid, head_content, menu_list, tail_content, msgid=""):
def send_msgmenu(
self, user_id, open_kfid, head_content, menu_list, tail_content, msgid=""
):
return self.send(
user_id,
open_kfid,
msgid,
msg={
"msgtype": "msgmenu",
"msgmenu": {"head_content": head_content, "list": menu_list, "tail_content": tail_content},
"msgmenu": {
"head_content": head_content,
"list": menu_list,
"tail_content": tail_content,
},
},
)
def send_location(self, user_id, open_kfid, name, address, latitude, longitude, msgid=""):
def send_location(
self, user_id, open_kfid, name, address, latitude, longitude, msgid=""
):
return self.send(
user_id,
open_kfid,
msgid,
msg={
"msgtype": "location",
"msgmenu": {"name": name, "address": address, "latitude": latitude, "longitude": longitude},
"msgmenu": {
"name": name,
"address": address,
"latitude": latitude,
"longitude": longitude,
},
},
)
def send_miniprogram(self, user_id, open_kfid, appid, title, thumb_media_id, pagepath, msgid=""):
def send_miniprogram(
self, user_id, open_kfid, appid, title, thumb_media_id, pagepath, msgid=""
):
return self.send(
user_id,
open_kfid,
msgid,
msg={
"msgtype": "miniprogram",
"msgmenu": {"appid": appid, "title": title, "thumb_media_id": thumb_media_id, "pagepath": pagepath},
"msgmenu": {
"appid": appid,
"title": title,
"thumb_media_id": thumb_media_id,
"pagepath": pagepath,
},
},
)
@@ -160,7 +160,9 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
self.wexin_event_workers[msg.id] = future
await self.convert_message(msg, future)
# I love shield so much!
result = await asyncio.wait_for(asyncio.shield(future), 60) # wait for 60s
result = await asyncio.wait_for(
asyncio.shield(future), 60
) # wait for 60s
logger.debug(f"Got future result: {result}")
self.wexin_event_workers.pop(msg.id, None)
return result # xml. see weixin_offacc_event.py
@@ -150,7 +150,6 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
return
logger.info(f"微信公众平台上传语音返回: {response}")
if active_send_mode:
self.client.message.send_voice(
message_obj.sender.user_id,
+1
View File
@@ -297,6 +297,7 @@ class LLMResponse:
)
return ret
@dataclass
class RerankResult:
index: int
+5 -1
View File
@@ -366,7 +366,10 @@ class ProviderManager:
if not self.curr_provider_inst:
self.curr_provider_inst = inst
elif provider_metadata.provider_type in [ProviderType.EMBEDDING, ProviderType.RERANK]:
elif provider_metadata.provider_type in [
ProviderType.EMBEDDING,
ProviderType.RERANK,
]:
inst = provider_metadata.cls_type(
provider_config, self.provider_settings
)
@@ -388,6 +391,7 @@ class ProviderManager:
# 和配置文件保持同步
config_ids = [provider["id"] for provider in self.providers_config]
logger.debug(f"providers in user's config: {config_ids}")
for key in list(self.inst_map.keys()):
if key not in config_ids:
await self.terminate_provider(key)
@@ -98,7 +98,7 @@ class ProviderFishAudioTTSAPI(TTSProvider):
# FishAudio的reference_id通常是32位十六进制字符串
# 例如: 626bb6d3f3364c9cbc3aa6a67300a664
pattern = r'^[a-fA-F0-9]{32}$'
pattern = r"^[a-fA-F0-9]{32}$"
return bool(re.match(pattern, reference_id.strip()))
async def _generate_request(self, text: str) -> dict:
@@ -104,6 +104,8 @@ class ProviderOpenAIOfficial(Provider):
if isinstance(custom_extra_body, dict):
extra_body.update(custom_extra_body)
model = payloads.get("model", "").lower()
# 针对 deepseek 模型的特殊处理:deepseek-reasoner调用必须移除 tools ,否则将被切换至 deepseek-chat
if model == "deepseek-reasoner" and "tools" in payloads:
del payloads["tools"]
+3 -9
View File
@@ -7,6 +7,7 @@ from astrbot.core.config import AstrBotConfig
from .custom_filter import CustomFilter
from ..star_handler import StarHandlerMetadata
class GreedyStr(str):
"""标记指令完成其他参数接收后的所有剩余文本。"""
@@ -152,17 +153,10 @@ class CommandFilter(HandlerFilter):
_full = f"{parent_command_name} {candidate}"
else:
_full = candidate
if message_str == _full:
# 完全等于命令名 → 没参数
message_str = ""
if message_str.startswith(f"{_full} ") or message_str == _full:
message_str = message_str[len(_full) :].strip()
ok = True
break
elif message_str.startswith(_full):
# 命令名后面无论是空格还是直接连参数都可以
message_str = message_str[len(_full):].lstrip()
ok = True
break
if not ok:
return False
+1 -2
View File
@@ -113,8 +113,7 @@ class CommandGroupFilter(HandlerFilter):
+ self.print_cmd_tree(self.sub_command_filters, event=event, cfg=cfg)
)
raise ValueError(
f"参数不足。{self.group_name} 指令组下有如下指令,请参考:\n"
+ tree
f"参数不足。{self.group_name} 指令组下有如下指令,请参考:\n" + tree
)
# complete_command_names = [name + " " for name in complete_command_names]
+4 -1
View File
@@ -84,7 +84,10 @@ class SessionPluginManager:
session_config["disabled_plugins"] = disabled_plugins
session_plugin_config[session_id] = session_config
sp.put(
"session_plugin_config", session_plugin_config, scope="umo", scope_id=session_id
"session_plugin_config",
session_plugin_config,
scope="umo",
scope_id=session_id,
)
logger.info(
+2 -2
View File
@@ -791,11 +791,11 @@ class PluginManager:
if star_metadata.star_cls is None:
return
if '__del__' in star_metadata.star_cls_type.__dict__:
if "__del__" in star_metadata.star_cls_type.__dict__:
asyncio.get_event_loop().run_in_executor(
None, star_metadata.star_cls.__del__
)
elif 'terminate' in star_metadata.star_cls_type.__dict__:
elif "terminate" in star_metadata.star_cls_type.__dict__:
await star_metadata.star_cls.terminate()
async def turn_on_plugin(self, plugin_name: str):
+22 -8
View File
@@ -30,8 +30,13 @@ from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.star.context import Context
from astrbot.core.star.star import star_map
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import AiocqhttpMessageEvent
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_platform_adapter import AiocqhttpAdapter
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import (
AiocqhttpMessageEvent,
)
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_platform_adapter import (
AiocqhttpAdapter,
)
class StarTools:
"""
@@ -77,7 +82,11 @@ class StarTools:
@classmethod
async def send_message_by_id(
cls, type: str, id: str, message_chain: MessageChain, platform: str = "aiocqhttp"
cls,
type: str,
id: str,
message_chain: MessageChain,
platform: str = "aiocqhttp",
):
"""
根据 id(例如qq号, 群号等) 直接, 主动地发送消息
@@ -92,7 +101,9 @@ class StarTools:
raise ValueError("StarTools not initialized")
platforms = cls._context.platform_manager.get_insts()
if platform == "aiocqhttp":
adapter = next((p for p in platforms if isinstance(p, AiocqhttpAdapter)), None)
adapter = next(
(p for p in platforms if isinstance(p, AiocqhttpAdapter)), None
)
if adapter is None:
raise ValueError("未找到适配器: AiocqhttpAdapter")
await AiocqhttpMessageEvent.send_message(
@@ -115,7 +126,7 @@ class StarTools:
message_str: str,
message_id: str = "",
raw_message: object = None,
group_id: str = ""
group_id: str = "",
) -> AstrBotMessage:
"""
创建一个AstrBot消息对象
@@ -152,7 +163,6 @@ class StarTools:
@classmethod
async def create_event(
cls, abm: AstrBotMessage, platform: str = "aiocqhttp", is_wake: bool = True
) -> None:
"""
创建并提交事件到指定平台
@@ -167,7 +177,9 @@ class StarTools:
raise ValueError("StarTools not initialized")
platforms = cls._context.platform_manager.get_insts()
if platform == "aiocqhttp":
adapter = next((p for p in platforms if isinstance(p, AiocqhttpAdapter)), None)
adapter = next(
(p for p in platforms if isinstance(p, AiocqhttpAdapter)), None
)
if adapter is None:
raise ValueError("未找到适配器: AiocqhttpAdapter")
event = AiocqhttpMessageEvent(
@@ -277,7 +289,9 @@ class StarTools:
if not plugin_name:
raise ValueError("无法获取插件名称")
data_dir = Path(os.path.join(get_astrbot_data_path(), "plugin_data", plugin_name))
data_dir = Path(
os.path.join(get_astrbot_data_path(), "plugin_data", plugin_name)
)
try:
data_dir.mkdir(parents=True, exist_ok=True)
-1
View File
@@ -1,7 +1,6 @@
import typing
import traceback
import os
import copy
from .route import Route, Response, RouteContext
from astrbot.core.provider.entities import ProviderType
from quart import request
+12 -4
View File
@@ -10,7 +10,9 @@ class LogRoute(Route):
super().__init__(context)
self.log_broker = log_broker
self.app.add_url_rule("/api/live-log", view_func=self.log, methods=["GET"])
self.app.add_url_rule("/api/log-history", view_func=self.log_history, methods=["GET"])
self.app.add_url_rule(
"/api/log-history", view_func=self.log_history, methods=["GET"]
)
async def log(self):
async def stream():
@@ -48,9 +50,15 @@ class LogRoute(Route):
"""获取日志历史"""
try:
logs = list(self.log_broker.log_cache)
return Response().ok(data={
"logs": logs,
}).__dict__
return (
Response()
.ok(
data={
"logs": logs,
}
)
.__dict__
)
except BaseException as e:
logger.error(f"获取日志历史失败: {e}")
return Response().error(f"获取日志历史失败: {e}").__dict__
-1
View File
@@ -1,4 +1,3 @@
from astrbot.core import logger
from astrbot.core.config.astrbot_config import AstrBotConfig
from dataclasses import dataclass
from quart import Quart
-1
View File
@@ -1,6 +1,5 @@
import traceback
import aiohttp
from quart import request
from astrbot.core import logger
+5
View File
@@ -0,0 +1,5 @@
# What's Changed
修复了 v4.1.0 `model referenced before assignment` 的错误。
> 如果已经使用自定义文转图模板,此次升级之后将会被覆盖,请提前备份。路径在 `astrbot/core/utils/t2i/template` 目录下。
+9
View File
@@ -0,0 +1,9 @@
# What's Changed
0. ‼️‼️‼️ fix: 修复 4.1.1 版本下,指令调用异常的问题
1. ‼️‼️ fix: 修复多配置文件配置的不同人格无法生效的问题 ([#2739](https://github.com/AstrBotDevs/AstrBot/issues/2739))
2. ‼️‼️ fix: 修复人格所选择的工具无法应用的问题 ([#2739](https://github.com/AstrBotDevs/AstrBot/issues/2739))
3. ‼️‼️ fix: 修复平台配置下的「内容安全」组无法生效 ([#2751](https://github.com/AstrBotDevs/AstrBot/issues/2751))
4. perf: 检查服务提供商可用性时跳过未启用的提供商,解决部分 `provider with id xxx not found` 的问题
fixes: [#2724](https://github.com/AstrBotDevs/AstrBot/issues/2724)
+18
View File
@@ -712,6 +712,19 @@ export default {
// 2. 为每个provider创建一个并发的测试请求
const promises = this.config_data.provider.map(p => {
if (!p.enable) {
const index = this.providerStatuses.findIndex(s => s.id === p.id);
if (index !== -1) {
const disabledStatus = {
...this.providerStatuses[index],
status: 'unavailable',
error: '该提供商未被用户启用'
};
this.providerStatuses.splice(index, 1, disabledStatus);
}
return Promise.resolve();
}
return axios.get(`/api/config/provider/check_one?id=${p.id}`)
.then(res => {
if (res.data && res.data.status === 'ok') {
@@ -887,4 +900,9 @@ export default {
.v-window {
border-radius: 4px;
}
.status-card {
height: 120px;
overflow-y: auto;
}
</style>
+3 -1
View File
@@ -35,7 +35,9 @@ class LongTermMemory:
else False
)
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
image_caption_provider_id = cfg["provider_settings"]["default_image_caption_provider_id"]
image_caption_provider_id = cfg["provider_settings"][
"default_image_caption_provider_id"
]
active_reply = cfg["provider_ltm_settings"]["active_reply"]
enable_active_reply = active_reply.get("enable", False)
ar_method = active_reply["method"]
+6 -4
View File
@@ -1232,11 +1232,13 @@ UID: {user_id} 此 ID 可用于设置管理员。
if req.conversation:
# persona inject
persona_id = req.conversation.persona_id
persona_id = req.conversation.persona_id or cfg.get("default_personality")
if not persona_id and persona_id != "[%None]": # [%None] 为用户取消人格
persona_id = self.context.persona_manager.selected_default_persona_v3[
"name"
]
default_persona = (
self.context.persona_manager.selected_default_persona_v3
)
if default_persona:
persona_id = default_persona["name"]
persona = next(
builtins.filter(
lambda persona: persona["name"] == persona_id,
+4 -2
View File
@@ -5,7 +5,7 @@ import astrbot.api.star as star
import astrbot.api.event.filter as filter
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.api.provider import ProviderRequest
from astrbot.api import llm_tool, agent, logger, AstrBotConfig
from astrbot.api import llm_tool, logger, AstrBotConfig
from astrbot.core.provider.func_tool_manager import FunctionToolManager
from .engines import SearchResult
from .engines.bing import Bing
@@ -35,7 +35,9 @@ class Main(star.Star):
if provider_settings:
tavily_key = provider_settings.get("websearch_tavily_key")
if isinstance(tavily_key, str):
logger.info("检测到旧版 websearch_tavily_key (字符串格式),自动迁移为列表格式并保存。")
logger.info(
"检测到旧版 websearch_tavily_key (字符串格式),自动迁移为列表格式并保存。"
)
if tavily_key:
provider_settings["websearch_tavily_key"] = [tavily_key]
else:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.1.0"
version = "4.1.2"
description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md"
requires-python = ">=3.10"