Compare commits

..

3 Commits

Author SHA1 Message Date
Soulter 4c957ffe35 fix: improve logging for invalid embedding_dimensions configuration 2026-03-17 17:51:38 +08:00
jnMetaCode 41a7a660c8 fix: handle invalid dimensions config and align get_dim return
- Add try-except around int() conversion in _embedding_kwargs to
  gracefully handle invalid embedding_dimensions config values
- Update get_dim() to return 0 when embedding_dimensions is not
  explicitly configured, so callers know dimensions weren't specified
  and can handle it accordingly
- Both methods now share consistent logic for reading the config

Signed-off-by: JiangNan <1394485448@qq.com>
2026-03-16 18:06:26 +08:00
jiangnan 44c8c63899 fix: only pass dimensions param when explicitly configured
Models like bge-m3 don't support the dimensions parameter in the
embedding API, causing HTTP 400 errors. Previously dimensions was
always sent with a default value of 1024, even when the user never
configured it. Now dimensions is only included in the request when
embedding_dimensions is explicitly set in provider config.

Closes #6421

Signed-off-by: JiangNan <1394485448@qq.com>
2026-03-16 17:42:35 +08:00
49 changed files with 471 additions and 1668 deletions
+18 -9
View File
@@ -3,8 +3,8 @@
### Modifications / 改动点 ### Modifications / 改动点
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?--> <!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。 - [x] This is NOT a breaking change. / 这不是一个破坏性变更。
<!-- If your changes is a breaking change, please uncheck the checkbox above --> <!-- If your changes is a breaking change, please uncheck the checkbox above -->
@@ -21,14 +21,23 @@
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.--> <!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。--> <!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
- [ ] 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc. - [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
/ 如果 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**. - [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
/ 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。 / My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
- [ ] 🤓 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`. - [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt` `pyproject.toml` 文件相应位置。
/ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `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. - [ ] 😮 我的更改没有引入恶意代码。
/ 我的更改没有引入恶意代码。 / My changes do not introduce malicious code.
- [ ] ⚠️ 我已认真阅读并理解以上所有内容,确保本次提交符合规范。
/ I have read and understood all the above and confirm this PR follows the rules.
- [ ] 🚀 我确保本次开发**基于 dev 分支**,并将代码合并至**开发分支**(除非极其紧急,才允许合并到主分支)。
/ I confirm that this development is **based on the dev branch** and will be merged into the **development branch**, unless it is extremely urgent to merge into the main branch.
- [ ] ⚠️ 我**没有**认真阅读以上内容,直接提交。
/ I **did not** read the above carefully before submitting.
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Create GitHub Release - name: Create GitHub Release
if: github.event_name == 'push' if: github.event_name == 'push'
uses: ncipollo/release-action@v1.21.0 uses: ncipollo/release-action@v1.20.0
with: with:
tag: release-${{ github.sha }} tag: release-${{ github.sha }}
owner: AstrBotDevs owner: AstrBotDevs
+45
View File
@@ -0,0 +1,45 @@
name: PR Checklist Check
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
jobs:
check:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Check checklist
id: check
uses: actions/github-script@v7
with:
script: |
const body = context.payload.pull_request.body || "";
const regex = /-\s*\[\s*x\s*\].*没有.*认真阅读/i;
const bad = regex.test(body);
core.setOutput("bad", bad);
- name: Close PR
if: steps.check.outputs.bad == 'true'
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: `检测到你勾选了“我没有认真阅读”,PR 已关闭。`
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: "closed"
});
-53
View File
@@ -1,53 +0,0 @@
name: PR Title Check
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
jobs:
title-format:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Validate PR title
uses: actions/github-script@v8
with:
script: |
const title = (context.payload.pull_request.title || "").trim();
// allow only:
// feat: xxx
// feat(scope): xxx
const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i;
const isValid = pattern.test(title);
const isSameRepo =
context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
if (!isValid) {
if (isSameRepo) {
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: [
"⚠️ PR title format check failed.",
"Required formats:",
"- `feat: xxx`",
"- `feat(scope): xxx`",
"Please update your PR title and push again."
].join("\n")
});
} catch (e) {
core.warning(`Failed to post PR title comment: ${e.message}`);
}
} else {
core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
}
}
if (!isValid) {
core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx.");
}
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.20.1" __version__ = "4.20.0"
+1 -1
View File
@@ -62,4 +62,4 @@ class HandoffTool(FunctionTool, Generic[TContext]):
def default_description(self, agent_name: str | None) -> str: def default_description(self, agent_name: str | None) -> str:
agent_name = agent_name or "another" agent_name = agent_name or "another"
return f"Delegate tasks to {agent_name} agent to handle the request." return f"Delegate tasks to {self.name} agent to handle the request."
+8 -3
View File
@@ -390,9 +390,14 @@ async def _ensure_persona_and_skills(
persona_tools = None persona_tools = None
pid = a.get("persona_id") pid = a.get("persona_id")
if pid: if pid:
persona = plugin_context.persona_manager.get_persona_v3_by_id(pid) persona_tools = next(
if persona is not None: (
persona_tools = persona.get("tools") p.get("tools")
for p in plugin_context.persona_manager.personas_v3
if p["name"] == pid
),
None,
)
tools = a.get("tools", []) tools = a.get("tools", [])
if persona_tools is not None: if persona_tools is not None:
tools = persona_tools tools = persona_tools
+7 -18
View File
@@ -213,24 +213,13 @@ def parse_description(text: str) -> str:
break break
if end_idx is None: if end_idx is None:
return "" return ""
for line in lines[1:end_idx]:
frontmatter = "\n".join(lines[1:end_idx]) if ":" not in line:
try: continue
import yaml key, value = line.split(":", 1)
except ImportError: if key.strip().lower() == "description":
return "" return value.strip().strip('"').strip("'")
return ""
try:
payload = yaml.safe_load(frontmatter) or dict()
except yaml.YAMLError:
return ""
if not isinstance(payload, dict):
return ""
description = payload.get("description", "")
if not isinstance(description, str):
return ""
return description.strip()
def load_managed_skills() -> list[str]: def load_managed_skills() -> list[str]:
+7 -1
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.20.1" VERSION = "4.20.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [ WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -463,6 +463,7 @@ CONFIG_METADATA_2 = {
"type": "kook", "type": "kook",
"enable": False, "enable": False,
"kook_bot_token": "", "kook_bot_token": "",
"kook_bot_nickname": "",
"kook_reconnect_delay": 1, "kook_reconnect_delay": 1,
"kook_max_reconnect_delay": 60, "kook_max_reconnect_delay": 60,
"kook_max_retry_delay": 60, "kook_max_retry_delay": 60,
@@ -874,6 +875,11 @@ CONFIG_METADATA_2 = {
"type": "string", "type": "string",
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。", "hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
}, },
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息以避免广播风暴。",
},
"kook_reconnect_delay": { "kook_reconnect_delay": {
"description": "重连延迟", "description": "重连延迟",
"type": "int", "type": "int",
-8
View File
@@ -33,18 +33,10 @@ class BaseDatabase(abc.ABC):
DATABASE_URL = "" DATABASE_URL = ""
def __init__(self) -> None: def __init__(self) -> None:
# SQLite only supports a single writer at a time. Without a busy
# timeout the driver raises "database is locked" instantly when a
# second write is attempted. Setting timeout=30 tells SQLite to
# wait up to 30 s for the lock, which is enough to ride out brief
# write bursts from concurrent agent/metrics/session operations.
is_sqlite = "sqlite" in self.DATABASE_URL
connect_args = {"timeout": 30} if is_sqlite else {}
self.engine = create_async_engine( self.engine = create_async_engine(
self.DATABASE_URL, self.DATABASE_URL,
echo=False, echo=False,
future=True, future=True,
connect_args=connect_args,
) )
self.AsyncSessionLocal = async_sessionmaker( self.AsyncSessionLocal = async_sessionmaker(
self.engine, self.engine,
+6 -17
View File
@@ -44,22 +44,6 @@ class PersonaManager:
raise ValueError(f"Persona with ID {persona_id} does not exist.") raise ValueError(f"Persona with ID {persona_id} does not exist.")
return persona return persona
def get_persona_v3_by_id(self, persona_id: str | None) -> Personality | None:
"""Resolve a v3 persona object by id.
- None/empty id returns None.
- "default" maps to in-memory DEFAULT_PERSONALITY.
- Otherwise search in personas_v3 by persona name.
"""
if not persona_id:
return None
if persona_id == "default":
return DEFAULT_PERSONALITY
return next(
(persona for persona in self.personas_v3 if persona["name"] == persona_id),
None,
)
async def get_default_persona_v3( async def get_default_persona_v3(
self, self,
umo: str | MessageSession | None = None, umo: str | MessageSession | None = None,
@@ -70,7 +54,12 @@ class PersonaManager:
"default_personality", "default_personality",
"default", "default",
) )
return self.get_persona_v3_by_id(default_persona_id) or DEFAULT_PERSONALITY if not default_persona_id or default_persona_id == "default":
return DEFAULT_PERSONALITY
try:
return next(p for p in self.personas_v3 if p["name"] == default_persona_id)
except Exception:
return DEFAULT_PERSONALITY
async def resolve_selected_persona( async def resolve_selected_persona(
self, self,
@@ -13,28 +13,11 @@ from astrbot.api.platform import (
PlatformMetadata, PlatformMetadata,
register_platform_adapter, register_platform_adapter,
) )
from astrbot.core.message.components import File, Record, Video
from astrbot.core.platform.astr_message_event import MessageSesion from astrbot.core.platform.astr_message_event import MessageSesion
from .kook_client import KookClient from .kook_client import KookClient
from .kook_config import KookConfig from .kook_config import KookConfig
from .kook_event import KookEvent from .kook_event import KookEvent
from .kook_types import (
ContainerModule,
FileModule,
HeaderModule,
ImageGroupModule,
KmarkdownElement,
KookCardMessageContainer,
KookChannelType,
KookMessageEventData,
KookMessageType,
KookModuleType,
PlainTextElement,
SectionModule,
)
KOOK_AT_SELECTOR_REGEX = re.compile(r"\(met\)([^()]+)\(met\)")
@register_platform_adapter( @register_platform_adapter(
@@ -74,26 +57,35 @@ class KookPlatformAdapter(Platform):
name="kook", description="KOOK 适配器", id=self.kook_config.id name="kook", description="KOOK 适配器", id=self.kook_config.id
) )
def _should_ignore_event_by_bot_nickname(self, author_id: str) -> bool: def _should_ignore_event_by_bot_nickname(self, payload: dict) -> bool:
return self.client.bot_id == author_id bot_nickname = self.kook_config.bot_nickname.strip()
if not bot_nickname:
return False
async def _on_received(self, event: KookMessageEventData): author = payload.get("extra", {}).get("author", {})
logger.debug( if not isinstance(author, dict):
f'[KOOK] 收到来自"{event.channel_type.name}"渠道的消息, 消息类型为: {event.type.name}({event.type.value})' return False
)
event_type = event.type author_nickname = author.get("nickname") or author.get("username") or ""
if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD): if not isinstance(author_nickname, str):
if self._should_ignore_event_by_bot_nickname(event.author_id): author_nickname = str(author_nickname)
logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息")
return return author_nickname.strip().casefold() == bot_nickname.casefold()
try:
abm = await self.convert_message(event) async def _on_received(self, data: dict):
await self.handle_msg(abm) logger.debug(f"KOOK 收到数据: {data}")
except Exception as e: if "d" in data and data["s"] == 0:
logger.error(f"[KOOK] 消息处理异常: {e}") payload = data["d"]
elif event_type == KookMessageType.SYSTEM: event_type = payload.get("type")
logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"') # 支持type=9(文本)和type=10(卡片)
logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}") if event_type in (9, 10):
if self._should_ignore_event_by_bot_nickname(payload):
return
try:
abm = await self.convert_message(payload)
await self.handle_msg(abm)
except Exception as e:
logger.error(f"[KOOK] 消息处理异常: {e}")
async def run(self): async def run(self):
"""主运行循环""" """主运行循环"""
@@ -192,26 +184,18 @@ class KookPlatformAdapter(Platform):
logger.info("[KOOK] 资源清理完成") logger.info("[KOOK] 资源清理完成")
def _parse_kmarkdown_text_message( def _parse_kmarkdown_text_message(
self, data: KookMessageEventData, self_id: str self, data: dict, self_id: str
) -> tuple[list, str]: ) -> tuple[list, str]:
kmarkdown = data.extra.kmarkdown kmarkdown = data.get("extra", {}).get("kmarkdown", {})
content = data.content or "" content = data.get("content") or ""
if kmarkdown is None: raw_content = kmarkdown.get("raw_content") or content
logger.error(
f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段'
)
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
return [], ""
raw_content = kmarkdown.raw_content or content
if not isinstance(content, str): if not isinstance(content, str):
content = str(content) content = str(content)
if not isinstance(raw_content, str): if not isinstance(raw_content, str):
raw_content = str(raw_content) raw_content = str(raw_content)
# TODO 后面的pydantic类型替换,以后再来探索吧 :(
mention_name_map: dict[str, str] = {} mention_name_map: dict[str, str] = {}
mention_part = kmarkdown.mention_part mention_part = kmarkdown.get("mention_part", [])
if isinstance(mention_part, list): if isinstance(mention_part, list):
for item in mention_part: for item in mention_part:
if not isinstance(item, dict): if not isinstance(item, dict):
@@ -223,7 +207,7 @@ class KookPlatformAdapter(Platform):
components = [] components = []
cursor = 0 cursor = 0
for match in KOOK_AT_SELECTOR_REGEX.finditer(content): for match in re.finditer(r"\(met\)([^()]+)\(met\)", content):
if match.start() > cursor: if match.start() > cursor:
plain_text = content[cursor : match.start()] plain_text = content[cursor : match.start()]
if plain_text: if plain_text:
@@ -270,109 +254,77 @@ class KookPlatformAdapter(Platform):
return components, message_str return components, message_str
def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]: def _parse_card_message(self, data: dict) -> tuple[list, str]:
content = data.content content = data.get("content", "[]")
if not isinstance(content, str): if not isinstance(content, str):
content = str(content) content = str(content)
card_list = json.loads(content)
card_list = KookCardMessageContainer.from_dict(json.loads(content))
text_parts: list[str] = [] text_parts: list[str] = []
images: list[str] = [] images: list[str] = []
files: list[tuple[KookModuleType, str, str]] = []
for card in card_list: for card in card_list:
for module in card.modules: if not isinstance(card, dict):
match module: continue
case SectionModule(): for module in card.get("modules", []):
if content := self._handle_section_text(module): if not isinstance(module, dict):
text_parts.append(content) continue
case ContainerModule() | ImageGroupModule(): module_type = module.get("type")
urls = self._handle_image_group(module) if module_type == "section":
images.extend(urls) section_text = module.get("text", {}).get("content", "")
text_parts.append(" [image]" * len(urls)) if section_text:
text_parts.append(str(section_text))
continue
case HeaderModule(): if module_type != "container":
text_parts.append(module.text.content) continue
case FileModule(): for element in module.get("elements", []):
files.append((module.type, module.title, module.src)) if not isinstance(element, dict):
text_parts.append(f" [{module.type.value}]") continue
if element.get("type") != "image":
continue
case _: image_src = element.get("src")
logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}") if not isinstance(image_src, str):
logger.warning(
f'[KOOK] 处理卡片中的图片时发生错误,图片url "{image_src}" 应该为str类型, 而不是 "{type(image_src)}" '
)
continue
if not image_src.startswith(("http://", "https://")):
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
continue
images.append(image_src)
text = "".join(text_parts) text = "".join(text_parts)
message = [] message = []
if text: if text:
for search in KOOK_AT_SELECTOR_REGEX.finditer(text):
search_text = search.group(1).strip()
if search_text == "all":
message.append(AtAll())
continue
message.append(At(qq=search_text))
text = text.replace(f"(met){search_text}(met)", "")
message.append(Plain(text=text)) message.append(Plain(text=text))
for img_url in images: for img_url in images:
message.append(Image(file=img_url)) message.append(Image(file=img_url))
for file in files:
file_type = file[0]
file_name = file[1]
file_url = file[2]
if file_type == KookModuleType.FILE:
message.append(File(name=file_name, file=file_url))
elif file_type == KookModuleType.VIDEO:
message.append(Video(file=file_url))
elif file_type == KookModuleType.AUDIO:
message.append(Record(file=file_url))
else:
logger.warning(f"[KOOK] 跳过未知文件类型: {file_type.name}")
return message, text return message, text
def _handle_section_text(self, module: SectionModule) -> str: async def convert_message(self, data: dict) -> AstrBotMessage:
"""专门处理 Section 里的文本提取"""
if isinstance(module.text, (KmarkdownElement, PlainTextElement)):
return module.text.content or ""
return ""
def _handle_image_group(
self, module: ContainerModule | ImageGroupModule
) -> list[str]:
"""专门处理图片组/容器里的合法 URL 提取"""
valid_urls = []
for el in module.elements:
image_src = el.src
if not el.src.startswith(("http://", "https://")):
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
continue
valid_urls.append(el.src)
return valid_urls
async def convert_message(self, data: KookMessageEventData) -> AstrBotMessage:
abm = AstrBotMessage() abm = AstrBotMessage()
abm.raw_message = data.to_dict() abm.raw_message = data
abm.self_id = self.client.bot_id abm.self_id = self.client.bot_id
channel_type = data.channel_type channel_type = data.get("channel_type")
author_id = data.author_id author_id = data.get("author_id", "unknown")
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction # channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
match channel_type: match channel_type:
case KookChannelType.GROUP: case "GROUP":
session_id = data.target_id or "unknown" session_id = data.get("target_id") or "unknown"
abm.type = MessageType.GROUP_MESSAGE abm.type = MessageType.GROUP_MESSAGE
abm.group_id = session_id abm.group_id = session_id
abm.session_id = session_id abm.session_id = session_id
case KookChannelType.PERSON: case "PERSON":
abm.type = MessageType.FRIEND_MESSAGE abm.type = MessageType.FRIEND_MESSAGE
abm.group_id = "" abm.group_id = ""
abm.session_id = data.author_id or "unknown" abm.session_id = data.get("author_id", "unknown")
case KookChannelType.BROADCAST: case "BROADCAST":
session_id = data.target_id or "unknown" session_id = data.get("target_id") or "unknown"
abm.type = MessageType.OTHER_MESSAGE abm.type = MessageType.OTHER_MESSAGE
abm.group_id = session_id abm.group_id = session_id
abm.session_id = session_id abm.session_id = session_id
@@ -381,25 +333,28 @@ class KookPlatformAdapter(Platform):
abm.sender = MessageMember( abm.sender = MessageMember(
user_id=author_id, user_id=author_id,
nickname=data.extra.author.username if data.extra.author else "unknown", nickname=data.get("extra", {}).get("author", {}).get("username", ""),
) )
abm.message_id = data.msg_id or "unknown" abm.message_id = data.get("msg_id", "unknown")
if data.type == KookMessageType.KMARKDOWN: # 普通文本消息
message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id) if data.get("type") == 9:
message, message_str = self._parse_kmarkdown_text_message(
data, str(abm.self_id)
)
abm.message = message abm.message = message
abm.message_str = message_str abm.message_str = message_str
elif data.type == KookMessageType.CARD: # 卡片消息
elif data.get("type") == 10:
try: try:
abm.message, abm.message_str = self._parse_card_message(data) abm.message, abm.message_str = self._parse_card_message(data)
except Exception as exp: except Exception as exp:
logger.error(f"[KOOK] 卡片消息解析失败: {exp}") logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
abm.message_str = "[卡片消息解析失败]" abm.message_str = "[卡片消息解析失败]"
abm.message = [Plain(text="[卡片消息解析失败]")] abm.message = [Plain(text="[卡片消息解析失败]")]
else: else:
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.type.name}"') logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.get("type")}"')
abm.message_str = "[不支持的消息类型]" abm.message_str = "[不支持的消息类型]"
abm.message = [Plain(text="[不支持的消息类型]")] abm.message = [Plain(text="[不支持的消息类型]")]
+56 -103
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import base64 import base64
import json
import os import os
import random import random
import time import time
@@ -8,23 +9,13 @@ from pathlib import Path
import aiofiles import aiofiles
import aiohttp import aiohttp
import pydantic
import websockets import websockets
from astrbot import logger from astrbot import logger
from astrbot.core.platform.message_type import MessageType from astrbot.core.platform.message_type import MessageType
from .kook_config import KookConfig from .kook_config import KookConfig
from .kook_types import ( from .kook_types import KookApiPaths, KookMessageType
KookApiPaths,
KookGatewayIndexResponse,
KookHelloEventData,
KookMessageSignal,
KookMessageType,
KookResumeAckEventData,
KookUserMeResponse,
KookWebsocketEvent,
)
class KookClient: class KookClient:
@@ -32,8 +23,7 @@ class KookClient:
# 数据字段 # 数据字段
self.config = config self.config = config
self._bot_id = "" self._bot_id = ""
self._bot_username = "" self._bot_name = ""
self._bot_nickname = ""
# 资源字段 # 资源字段
self._http_client = aiohttp.ClientSession( self._http_client = aiohttp.ClientSession(
@@ -58,50 +48,37 @@ class KookClient:
return self._bot_id return self._bot_id
@property @property
def bot_nickname(self): def bot_name(self):
return self._bot_nickname return self._bot_name
@property async def get_bot_info(self) -> str:
def bot_username(self): """获取机器人账号ID"""
return self._bot_username
async def get_bot_info(self) -> None:
"""获取机器人账号信息"""
url = KookApiPaths.USER_ME url = KookApiPaths.USER_ME
try: try:
async with self._http_client.get(url) as resp: async with self._http_client.get(url) as resp:
if resp.status != 200: if resp.status != 200:
logger.error( logger.error(f"[KOOK] 获取机器人账号ID失败,状态码: {resp.status}")
f"[KOOK] 获取机器人账号信息失败,状态码: {resp.status} , {await resp.text()}" return ""
)
return
try:
resp_content = KookUserMeResponse.from_dict(await resp.json())
except pydantic.ValidationError as e:
logger.error(
f"[KOOK] 获取机器人账号信息失败, 响应数据格式错误: \n{e}"
)
logger.error(f"[KOOK] 响应内容: {await resp.text()}")
return
if not resp_content.success(): data = await resp.json()
logger.error( if data.get("code") != 0:
f"[KOOK] 获取机器人账号信息失败: {resp_content.model_dump_json()}" logger.error(f"[KOOK] 获取机器人账号ID失败: {data}")
) return ""
return
bot_id: str = resp_content.data.id bot_id: str = data["data"]["id"]
self._bot_id = bot_id self._bot_id = bot_id
logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}") logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}")
self._bot_nickname = resp_content.data.nickname bot_name: str = data["data"]["nickname"] or data["data"]["username"]
self._bot_username = resp_content.data.username self._bot_name = bot_name
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_nickname}") logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_name}")
return bot_id
except Exception as e: except Exception as e:
logger.error(f"[KOOK] 获取机器人账号信息异常: {e}") logger.error(f"[KOOK] 获取机器人账号ID异常: {e}")
return ""
async def get_gateway_url(self, resume=False, sn=0, session_id=None) -> str | None: async def get_gateway_url(self, resume=False, sn=0, session_id=None):
"""获取网关连接地址""" """获取网关连接地址"""
url = KookApiPaths.GATEWAY_INDEX url = KookApiPaths.GATEWAY_INDEX
@@ -119,20 +96,14 @@ class KookClient:
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}") logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
return None return None
resp_content = KookGatewayIndexResponse.from_dict(await resp.json()) data = await resp.json()
if not resp_content.success(): if data.get("code") != 0:
logger.error(f"[KOOK] 获取gateway失败: {resp_content}") logger.error(f"[KOOK] 获取gateway失败: {data}")
return None return None
gateway_url: str = resp_content.data.url gateway_url: str = data["data"]["url"]
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}") logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
return gateway_url return gateway_url
except pydantic.ValidationError as e:
logger.error(f"[KOOK] 获取gateway失败, 响应数据格式错误: \n{e}")
logger.error(f"[KOOK] 原始响应内容: {await resp.text()}")
return None
except Exception as e: except Exception as e:
logger.error(f"[KOOK] 获取gateway异常: {e}") logger.error(f"[KOOK] 获取gateway异常: {e}")
return None return None
@@ -185,11 +156,7 @@ class KookClient:
try: try:
while self.running: while self.running:
try: try:
if self.ws is None: msg = await asyncio.wait_for(self.ws.recv(), timeout=10) # type: ignore
logger.error("[KOOK] WebSocket 对象丢失,结束监听流程。")
break
msg = await asyncio.wait_for(self.ws.recv(), timeout=10)
if isinstance(msg, bytes): if isinstance(msg, bytes):
try: try:
@@ -199,15 +166,10 @@ class KookClient:
continue continue
msg = msg.decode("utf-8") msg = msg.decode("utf-8")
event = KookWebsocketEvent.from_json(msg) data = json.loads(msg)
# 处理不同类型的信令 # 处理不同类型的信令
await self._handle_signal(event) await self._handle_signal(data)
except pydantic.ValidationError as e:
logger.error(f"[KOOK] 解析WebSocket事件数据格式失败: \n{e}")
logger.error(f"[KOOK] 原始响应内容: {msg}")
continue
except asyncio.TimeoutError: except asyncio.TimeoutError:
# 超时检查,继续循环 # 超时检查,继续循环
@@ -225,41 +187,38 @@ class KookClient:
self.running = False self.running = False
self._stop_event.set() self._stop_event.set()
async def _handle_signal(self, event: KookWebsocketEvent): async def _handle_signal(self, data):
"""处理不同类型的信令""" """处理不同类型的信令"""
data = event.data signal_type = data.get("s")
match event.signal: if signal_type == 0: # 事件消息
case KookMessageSignal.MESSAGE: # 更新消息序号
if event.sn is not None: if "sn" in data:
self.last_sn = event.sn self.last_sn = data["sn"]
await self.event_callback(data) await self.event_callback(data)
case KookMessageSignal.HELLO: elif signal_type == 1: # HELLO握手
assert isinstance(data, KookHelloEventData) await self._handle_hello(data)
await self._handle_hello(data)
case KookMessageSignal.RESUME_ACK: elif signal_type == 3: # PONG心跳响应
assert isinstance(data, KookResumeAckEventData) await self._handle_pong(data)
await self._handle_resume_ack(data)
case KookMessageSignal.PONG: elif signal_type == 5: # RECONNECT重连指令
await self._handle_pong() await self._handle_reconnect(data)
case KookMessageSignal.RECONNECT: elif signal_type == 6: # RESUME ACK
await self._handle_reconnect() await self._handle_resume_ack(data)
case _: else:
logger.debug( logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}")
f"[KOOK] 未处理的信令类型: {event.signal.name}({event.signal.value})"
)
async def _handle_hello(self, data: KookHelloEventData): async def _handle_hello(self, data):
"""处理HELLO握手""" """处理HELLO握手"""
code = data.code hello_data = data.get("d", {})
code = hello_data.get("code", 0)
if code == 0: if code == 0:
self.session_id = data.session_id self.session_id = hello_data.get("session_id")
logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}") logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}")
# TODO 重置重连延迟 # TODO 重置重连延迟
# self.reconnect_delay = 1 # self.reconnect_delay = 1
@@ -269,12 +228,12 @@ class KookClient:
logger.error("[KOOK] Token已过期,需要重新获取") logger.error("[KOOK] Token已过期,需要重新获取")
self.running = False self.running = False
async def _handle_pong(self): async def _handle_pong(self, data):
"""处理PONG心跳响应""" """处理PONG心跳响应"""
self.last_heartbeat_time = time.time() self.last_heartbeat_time = time.time()
self.heartbeat_failed_count = 0 self.heartbeat_failed_count = 0
async def _handle_reconnect(self): async def _handle_reconnect(self, data):
"""处理重连指令""" """处理重连指令"""
logger.warning("[KOOK] 收到重连指令") logger.warning("[KOOK] 收到重连指令")
# 清空本地状态 # 清空本地状态
@@ -282,9 +241,10 @@ class KookClient:
self.session_id = None self.session_id = None
self.running = False self.running = False
async def _handle_resume_ack(self, data: KookResumeAckEventData): async def _handle_resume_ack(self, data):
"""处理RESUME确认""" """处理RESUME确认"""
self.session_id = data.session_id resume_data = data.get("d", {})
self.session_id = resume_data.get("session_id")
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}") logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
async def _heartbeat_loop(self): async def _heartbeat_loop(self):
@@ -332,16 +292,9 @@ class KookClient:
async def _send_ping(self): async def _send_ping(self):
"""发送心跳PING""" """发送心跳PING"""
if self.ws is None:
logger.warning("[KOOK] 尚未连接kook WebSocket服务器, 跳过发送心跳包流程")
return
try: try:
ping_data = KookWebsocketEvent( ping_data = {"s": 2, "sn": self.last_sn}
signal=KookMessageSignal.PING, await self.ws.send(json.dumps(ping_data)) # type: ignore
data=None,
sn=self.last_sn,
)
await self.ws.send(ping_data.to_json())
except Exception as e: except Exception as e:
logger.error(f"[KOOK] 发送心跳失败: {e}") logger.error(f"[KOOK] 发送心跳失败: {e}")
@@ -9,6 +9,7 @@ class KookConfig:
# 基础配置 # 基础配置
token: str token: str
bot_nickname: str = ""
enable: bool = False enable: bool = False
id: str = "kook" id: str = "kook"
@@ -40,6 +41,7 @@ class KookConfig:
# id=config_dict.get("id", "kook"), # id=config_dict.get("id", "kook"),
enable=config_dict.get("enable", False), enable=config_dict.get("enable", False),
token=config_dict.get("kook_bot_token", ""), token=config_dict.get("kook_bot_token", ""),
bot_nickname=config_dict.get("kook_bot_nickname", ""),
reconnect_delay=config_dict.get( reconnect_delay=config_dict.get(
"kook_reconnect_delay", "kook_reconnect_delay",
KookConfig.reconnect_delay, KookConfig.reconnect_delay,
@@ -27,7 +27,6 @@ from .kook_types import (
KookCardMessage, KookCardMessage,
KookCardMessageContainer, KookCardMessageContainer,
KookMessageType, KookMessageType,
KookModuleType,
OrderMessage, OrderMessage,
) )
@@ -112,7 +111,7 @@ class KookEvent(AstrMessageEvent):
KookCardMessage( KookCardMessage(
modules=[ modules=[
FileModule( FileModule(
type=KookModuleType.AUDIO, type="audio",
title=title, title=title,
src=url, src=url,
) )
@@ -183,7 +182,7 @@ class KookEvent(AstrMessageEvent):
if item.reply_id: if item.reply_id:
reply_id = item.reply_id reply_id = item.reply_id
if not item.text: if not item.text:
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type.name}"') logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"')
continue continue
try: try:
await self.client.send_text( await self.client.send_text(
+55 -319
View File
@@ -1,8 +1,10 @@
import json import json
from enum import IntEnum, StrEnum from dataclasses import field
from typing import Annotated, Any, Literal from enum import IntEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict
from pydantic.dataclasses import dataclass
class KookApiPaths: class KookApiPaths:
@@ -23,9 +25,8 @@ class KookApiPaths:
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create" DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
# 定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction
class KookMessageType(IntEnum): class KookMessageType(IntEnum):
"""定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction"""
TEXT = 1 TEXT = 1
IMAGE = 2 IMAGE = 2
VIDEO = 3 VIDEO = 3
@@ -36,26 +37,6 @@ class KookMessageType(IntEnum):
SYSTEM = 255 SYSTEM = 255
class KookModuleType(StrEnum):
PLAIN_TEXT = "plain-text"
KMARKDOWN = "kmarkdown"
IMAGE = "image"
BUTTON = "button"
HEADER = "header"
SECTION = "section"
IMAGE_GROUP = "image-group"
CONTAINER = "container"
ACTION_GROUP = "action-group"
CONTEXT = "context"
DIVIDER = "divider"
FILE = "file"
AUDIO = "audio"
VIDEO = "video"
COUNTDOWN = "countdown"
INVITE = "invite"
CARD = "card"
ThemeType = Literal[ ThemeType = Literal[
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible" "primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
] ]
@@ -67,81 +48,43 @@ SectionMode = Literal["left", "right"]
CountdownMode = Literal["day", "hour", "second"] CountdownMode = Literal["day", "hour", "second"]
class KookBaseDataClass(BaseModel): class KookCardColor(str):
model_config = ConfigDict( """16 进制色值"""
extra="allow",
arbitrary_types_allowed=True,
populate_by_name=True,
)
@classmethod
def from_dict(cls, raw_data: dict):
return cls.model_validate(raw_data)
@classmethod
def from_json(cls, raw_data: str | bytes | bytearray):
return cls.model_validate_json(raw_data)
def to_dict(
self,
mode: Literal["json", "python"] | str = "python",
by_alias=True,
exclude_none=True,
exclude_unset=False,
) -> dict:
return self.model_dump(
by_alias=by_alias,
exclude_none=exclude_none,
mode=mode,
exclude_unset=exclude_unset,
)
def to_json(
self,
indent: int | None = None,
ensure_ascii=False,
by_alias=True,
exclude_none=True,
exclude_unset=False,
) -> str:
return self.model_dump_json(
indent=indent,
ensure_ascii=ensure_ascii,
by_alias=by_alias,
exclude_none=exclude_none,
exclude_unset=exclude_unset,
)
class KookCardModelBase(KookBaseDataClass): class KookCardModelBase:
"""卡片模块基类""" """卡片模块基类"""
type: str type: str
@dataclass
class PlainTextElement(KookCardModelBase): class PlainTextElement(KookCardModelBase):
content: str content: str
type: Literal[KookModuleType.PLAIN_TEXT] = KookModuleType.PLAIN_TEXT type: str = "plain-text"
emoji: bool = True emoji: bool = True
@dataclass
class KmarkdownElement(KookCardModelBase): class KmarkdownElement(KookCardModelBase):
content: str content: str
type: Literal[KookModuleType.KMARKDOWN] = KookModuleType.KMARKDOWN type: str = "kmarkdown"
@dataclass
class ImageElement(KookCardModelBase): class ImageElement(KookCardModelBase):
src: str src: str
type: Literal[KookModuleType.IMAGE] = KookModuleType.IMAGE type: str = "image"
alt: str = "" alt: str = ""
size: SizeType = "lg" size: SizeType = "lg"
circle: bool = False circle: bool = False
fallbackUrl: str | None = None fallbackUrl: str | None = None
@dataclass
class ButtonElement(KookCardModelBase): class ButtonElement(KookCardModelBase):
text: str text: str
type: Literal[KookModuleType.BUTTON] = KookModuleType.BUTTON type: str = "button"
theme: ThemeType = "primary" theme: ThemeType = "primary"
value: str = "" value: str = ""
"""当为 link 时,会跳转到 value 代表的链接; """当为 link 时,会跳转到 value 代表的链接;
@@ -153,88 +96,93 @@ class ButtonElement(KookCardModelBase):
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
@dataclass
class ParagraphStructure(KookCardModelBase): class ParagraphStructure(KookCardModelBase):
fields: list[PlainTextElement | KmarkdownElement] fields: list[PlainTextElement | KmarkdownElement]
type: Literal["paragraph"] = "paragraph" type: str = "paragraph"
cols: int = 1 cols: int = 1
"""范围是 1-3 , 移动端忽略此参数""" """范围是 1-3 , 移动端忽略此参数"""
@dataclass
class HeaderModule(KookCardModelBase): class HeaderModule(KookCardModelBase):
text: PlainTextElement text: PlainTextElement
type: Literal[KookModuleType.HEADER] = KookModuleType.HEADER type: str = "header"
@dataclass
class SectionModule(KookCardModelBase): class SectionModule(KookCardModelBase):
text: PlainTextElement | KmarkdownElement | ParagraphStructure text: PlainTextElement | KmarkdownElement | ParagraphStructure
type: Literal[KookModuleType.SECTION] = KookModuleType.SECTION type: str = "section"
mode: SectionMode = "left" mode: SectionMode = "left"
accessory: ImageElement | ButtonElement | None = None accessory: ImageElement | ButtonElement | None = None
@dataclass
class ImageGroupModule(KookCardModelBase): class ImageGroupModule(KookCardModelBase):
"""1 到多张图片的组合""" """1 到多张图片的组合"""
elements: list[ImageElement] elements: list[ImageElement]
type: Literal[KookModuleType.IMAGE_GROUP] = KookModuleType.IMAGE_GROUP type: str = "image-group"
@dataclass
class ContainerModule(KookCardModelBase): class ContainerModule(KookCardModelBase):
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。""" """1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
elements: list[ImageElement] elements: list[ImageElement]
type: Literal[KookModuleType.CONTAINER] = KookModuleType.CONTAINER type: str = "container"
@dataclass
class ActionGroupModule(KookCardModelBase): class ActionGroupModule(KookCardModelBase):
"""用来放按钮的模块"""
elements: list[ButtonElement] elements: list[ButtonElement]
type: Literal[KookModuleType.ACTION_GROUP] = KookModuleType.ACTION_GROUP type: str = "action-group"
@dataclass
class ContextModule(KookCardModelBase): class ContextModule(KookCardModelBase):
elements: list[PlainTextElement | KmarkdownElement | ImageElement] elements: list[PlainTextElement | KmarkdownElement | ImageElement]
"""最多包含10个元素""" """最多包含10个元素"""
type: Literal[KookModuleType.CONTEXT] = KookModuleType.CONTEXT type: str = "context"
@dataclass
class DividerModule(KookCardModelBase): class DividerModule(KookCardModelBase):
"""展示分割线用的""" type: str = "divider"
type: Literal[KookModuleType.DIVIDER] = KookModuleType.DIVIDER
@dataclass
class FileModule(KookCardModelBase): class FileModule(KookCardModelBase):
src: str src: str
title: str = "" title: str = ""
type: Literal[KookModuleType.FILE, KookModuleType.AUDIO, KookModuleType.VIDEO] = ( type: Literal["file", "audio", "video"] = "file"
KookModuleType.FILE
)
cover: str | None = None cover: str | None = None
"""cover 仅音频有效, 是音频的封面图""" """cover 仅音频有效, 是音频的封面图"""
@dataclass
class CountdownModule(KookCardModelBase): class CountdownModule(KookCardModelBase):
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。""" """startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
endTime: int endTime: int
"""毫秒时间戳""" """毫秒时间戳"""
type: Literal[KookModuleType.COUNTDOWN] = KookModuleType.COUNTDOWN type: str = "countdown"
startTime: int | None = None startTime: int | None = None
"""毫秒时间戳, 仅当mode为second才有这个字段""" """毫秒时间戳, 仅当mode为second才有这个字段"""
mode: CountdownMode = "day" mode: CountdownMode = "day"
"""mode 主要是倒计时的样式""" """mode 主要是倒计时的样式"""
@dataclass
class InviteModule(KookCardModelBase): class InviteModule(KookCardModelBase):
code: str code: str
"""邀请链接或者邀请码""" """邀请链接或者邀请码"""
type: Literal[KookModuleType.INVITE] = KookModuleType.INVITE type: str = "invite"
# 所有模块的联合类型 # 所有模块的联合类型
AnyModule = Annotated[ AnyModule = (
HeaderModule HeaderModule
| SectionModule | SectionModule
| ImageGroupModule | ImageGroupModule
@@ -244,29 +192,34 @@ AnyModule = Annotated[
| DividerModule | DividerModule
| FileModule | FileModule
| CountdownModule | CountdownModule
| InviteModule, | InviteModule
Field(discriminator="type"), )
]
class KookCardMessage(KookBaseDataClass): class KookCardMessage(BaseModel):
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage """卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表** 此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
若要发送卡片消息请使用KookCardMessageContainer 若要发送卡片消息请使用KookCardMessageContainer
""" """
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
type: Literal[KookModuleType.CARD] = KookModuleType.CARD type: str = "card"
theme: ThemeType | None = None theme: ThemeType | None = None
size: SizeType | None = None size: SizeType | None = None
color: str | None = None color: KookCardColor | None = None
"""16 进制色值""" modules: list[AnyModule] = field(default_factory=list)
modules: list[AnyModule] = Field(default_factory=list)
"""单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50""" """单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50"""
def add_module(self, module: AnyModule): def add_module(self, module: AnyModule):
self.modules.append(module) self.modules.append(module)
def to_dict(self, exclude_none: bool = True):
"""exclude_none:去掉值为 None 字段,保留结构"""
return self.model_dump(exclude_none=exclude_none)
def to_json(self, indent: int | None = None, ensure_ascii: bool = True):
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=ensure_ascii)
class KookCardMessageContainer(list[KookCardMessage]): class KookCardMessageContainer(list[KookCardMessage]):
"""卡片消息容器(列表),此类型可以直接to_json后发送出去""" """卡片消息容器(列表),此类型可以直接to_json后发送出去"""
@@ -279,227 +232,10 @@ class KookCardMessageContainer(list[KookCardMessage]):
[i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii [i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii
) )
@classmethod
def from_dict(cls, raw_data: list[dict[str, Any]]):
return cls(KookCardMessage.from_dict(item) for item in raw_data)
@dataclass
class OrderMessage(BaseModel): class OrderMessage:
index: int index: int
text: str text: str
type: KookMessageType type: KookMessageType
reply_id: str | int = "" reply_id: str | int = ""
class KookMessageSignal(IntEnum):
"""KOOK WebSocket 信令类型
ws文档: https://developer.kookapp.cn/doc/websocket""" # noqa: W291
MESSAGE = 0
"""server->client 消息(s包含聊天和通知消息)"""
HELLO = 1
"""server->client 客户端连接 ws 时, 服务端返回握手结果"""
PING = 2
"""client->server 心跳,ping"""
PONG = 3
"""server->client 心跳,pong"""
RESUME = 4
"""client->server resume, 恢复会话"""
RECONNECT = 5
"""server->client reconnect, 要求客户端断开当前连接重新连接"""
RESUME_ACK = 6
"""server->client resume ack"""
class KookChannelType(StrEnum):
GROUP = "GROUP"
PERSON = "PERSON"
BROADCAST = "BROADCAST"
class KookAuthor(KookBaseDataClass):
id: str
username: str
identify_num: str
nickname: str
bot: bool
online: bool
avatar: str | None = None
vip_avatar: str | None = None
status: int
roles: list[int] = Field(default_factory=list)
class KookKMarkdown(KookBaseDataClass):
raw_content: str
mention_part: list[Any] = Field(default_factory=list)
mention_role_part: list[Any] = Field(default_factory=list)
class KookExtra(KookBaseDataClass):
type: int | str
code: str | None = None
body: dict[str, Any] | None = None
author: KookAuthor | None = None
kmarkdown: KookKMarkdown | None = None
last_msg_content: str | None = None
mention: list[str] = Field(default_factory=list)
mention_all: bool = False
mention_here: bool = False
class KookMessageEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.MESSAGE] = Field(
KookMessageSignal.MESSAGE, exclude=True
)
"""only for type hint"""
channel_type: KookChannelType
type: KookMessageType
target_id: str
author_id: str
content: str | dict[str, Any]
msg_id: str
msg_timestamp: int
nonce: str
from_type: int
extra: KookExtra
class KookHelloEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.HELLO] = Field(
KookMessageSignal.HELLO, exclude=True
)
"""only for type hint"""
code: int
session_id: str
class KookPingEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.PING] = Field(
KookMessageSignal.PING, exclude=True
)
"""only for type hint"""
class KookPongEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.PONG] = Field(
KookMessageSignal.PONG, exclude=True
)
"""only for type hint"""
class KookResumeEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.RESUME] = Field(
KookMessageSignal.RESUME, exclude=True
)
"""only for type hint"""
class KookReconnectEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.RECONNECT] = Field(
KookMessageSignal.RECONNECT, exclude=True
)
"""only for type hint"""
code: int
err: str
class KookResumeAckEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.RESUME_ACK] = Field(
KookMessageSignal.RESUME_ACK, exclude=True
)
"""only for type hint"""
session_id: str
class KookWebsocketEvent(KookBaseDataClass):
"""KOOK WebSocket 原始推送结构"""
signal: KookMessageSignal = Field(
..., validation_alias="s", serialization_alias="s"
)
"""信令类型"""
data: Annotated[
KookMessageEventData
| KookHelloEventData
| KookPingEventData
| KookPongEventData
| KookResumeEventData
| KookReconnectEventData
| KookResumeAckEventData
| None,
Field(discriminator="signal"),
] = Field(None, validation_alias="d", serialization_alias="d")
"""数据事件主体,对应原字段是'd'"""
sn: int | None = None
"""消息序号 , 用来确定消息顺序和ws重连时使用
详见ws连接流程文档: https://developer.kookapp.cn/doc/websocket#%E8%BF%9E%E6%8E%A5%E6%B5%81%E7%A8%8B""" # noqa: W291
@model_validator(mode="before")
@classmethod
def _inject_signal_into_data(cls, data: Any) -> Any:
"""在解析前,把外层的 s 同步到内层的 d 中,供 discriminator 使用"""
if isinstance(data, dict):
s_value = data.get("s")
d_value = data.get("d")
if s_value is not None and isinstance(d_value, dict):
d_value["signal"] = s_value
return data
class KookUserTag(KookBaseDataClass):
color: str
bg_color: str
text: str
class KookApiResponseBase(KookBaseDataClass):
code: int
message: str
data: Any
def success(self) -> bool:
return self.code == 0
class KookUserMeData(KookBaseDataClass):
"""USER_ME 接口返回的 'data' 字段主体"""
id: str
username: str
identify_num: str
nickname: str
bot: bool
online: bool
status: int
bot_status: int
avatar: str
vip_avatar: str | None = None
banner: str | None = None
roles: list[Any] = Field(default_factory=list)
is_vip: bool
vip_amp: bool
wealth_level: int
mobile_verified: bool
client_id: str
tag_info: KookUserTag | None = None
class KookUserMeResponse(KookApiResponseBase):
"""USER_ME 完整响应结构"""
data: KookUserMeData
class KookGatewayIndexData(KookBaseDataClass):
url: str
class KookGatewayIndexResponse(KookApiResponseBase):
"""USER_ME 完整响应结构"""
data: KookGatewayIndexData
@@ -16,7 +16,4 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
"https://github.com/AstrBotDevs/AstrBot" "https://github.com/AstrBotDevs/AstrBot"
) )
self.client._custom_headers["X-OpenRouter-Title"] = "AstrBot" # type: ignore self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
self.client._custom_headers["X-OpenRouter-Categories"] = (
"general-chat,personal-agent" # type: ignore
)
+8 -16
View File
@@ -11,8 +11,6 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
import yaml
from astrbot.core.utils.astrbot_path import ( from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path, get_astrbot_data_path,
get_astrbot_skills_path, get_astrbot_skills_path,
@@ -71,19 +69,13 @@ def _parse_frontmatter_description(text: str) -> str:
break break
if end_idx is None: if end_idx is None:
return "" return ""
for line in lines[1:end_idx]:
frontmatter = "\n".join(lines[1:end_idx]) if ":" not in line:
try: continue
payload = yaml.safe_load(frontmatter) or {} key, value = line.split(":", 1)
except yaml.YAMLError: if key.strip().lower() == "description":
return "" return value.strip().strip('"').strip("'")
if not isinstance(payload, dict): return ""
return ""
description = payload.get("description", "")
if not isinstance(description, str):
return ""
return description.strip()
# Regex for sanitizing paths used in prompt examples — only allow # Regex for sanitizing paths used in prompt examples — only allow
@@ -136,7 +128,7 @@ def _build_skill_read_command_example(path: str) -> str:
return f"cat {path}" return f"cat {path}"
if _is_windows_prompt_path(path): if _is_windows_prompt_path(path):
command = "type" command = "type"
path_arg = f'"{os.path.normpath(path)}"' path_arg = f'"{path}"'
else: else:
command = "cat" command = "cat"
path_arg = shlex.quote(path) path_arg = shlex.quote(path)
+8 -5
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from collections.abc import AsyncGenerator, Awaitable, Callable from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import Any from typing import TYPE_CHECKING, Any
import docstring_parser import docstring_parser
@@ -15,6 +15,9 @@ from astrbot.core.message.message_event_result import MessageEventResult
from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES
from astrbot.core.provider.register import llm_tools from astrbot.core.provider.register import llm_tools
if TYPE_CHECKING:
from astrbot.core.astr_agent_context import AstrAgentContext
from ..filter.command import CommandFilter from ..filter.command import CommandFilter
from ..filter.command_group import CommandGroupFilter from ..filter.command_group import CommandGroupFilter
from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr
@@ -616,7 +619,7 @@ class RegisteringAgent:
kwargs["registering_agent"] = self kwargs["registering_agent"] = self
return register_llm_tool(*args, **kwargs) return register_llm_tool(*args, **kwargs)
def __init__(self, agent: Agent[Any]) -> None: def __init__(self, agent: Agent[AstrAgentContext]) -> None:
self._agent = agent self._agent = agent
@@ -624,7 +627,7 @@ def register_agent(
name: str, name: str,
instruction: str, instruction: str,
tools: list[str | FunctionTool] | None = None, tools: list[str | FunctionTool] | None = None,
run_hooks: BaseAgentRunHooks[Any] | None = None, run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None,
): ):
"""注册一个 Agent """注册一个 Agent
@@ -638,12 +641,12 @@ def register_agent(
tools_ = tools or [] tools_ = tools or []
def decorator(awaitable: Callable[..., Awaitable[Any]]): def decorator(awaitable: Callable[..., Awaitable[Any]]):
AstrAgent = Agent[Any] AstrAgent = Agent[AstrAgentContext]
agent = AstrAgent( agent = AstrAgent(
name=name, name=name,
instructions=instruction, instructions=instruction,
tools=tools_, tools=tools_,
run_hooks=run_hooks or BaseAgentRunHooks[Any](), run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
) )
handoff_tool = HandoffTool(agent=agent) handoff_tool = HandoffTool(agent=agent)
handoff_tool.handler = awaitable handoff_tool.handler = awaitable
+16 -22
View File
@@ -1,16 +1,13 @@
from __future__ import annotations from __future__ import annotations
import copy from typing import Any
from typing import TYPE_CHECKING, Any
from astrbot import logger from astrbot import logger
from astrbot.core.agent.agent import Agent from astrbot.core.agent.agent import Agent
from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.provider.func_tool_manager import FunctionToolManager from astrbot.core.provider.func_tool_manager import FunctionToolManager
if TYPE_CHECKING:
from astrbot.core.persona_mgr import PersonaManager
class SubAgentOrchestrator: class SubAgentOrchestrator:
"""Loads subagent definitions from config and registers handoff tools. """Loads subagent definitions from config and registers handoff tools.
@@ -46,14 +43,15 @@ class SubAgentOrchestrator:
continue continue
persona_id = item.get("persona_id") persona_id = item.get("persona_id")
if persona_id is not None: persona_data = None
persona_id = str(persona_id).strip() or None if persona_id:
persona_data = self._persona_mgr.get_persona_v3_by_id(persona_id) try:
if persona_id and persona_data is None: persona_data = await self._persona_mgr.get_persona(persona_id)
logger.warning( except StopIteration:
"SubAgent persona %s not found, fallback to inline prompt.", logger.warning(
persona_id, "SubAgent persona %s not found, fallback to inline prompt.",
) persona_id,
)
instructions = str(item.get("system_prompt", "")).strip() instructions = str(item.get("system_prompt", "")).strip()
public_description = str(item.get("public_description", "")).strip() public_description = str(item.get("public_description", "")).strip()
@@ -64,15 +62,11 @@ class SubAgentOrchestrator:
begin_dialogs = None begin_dialogs = None
if persona_data: if persona_data:
prompt = str(persona_data.get("prompt", "")).strip() instructions = persona_data.system_prompt or instructions
if prompt: begin_dialogs = persona_data.begin_dialogs
instructions = prompt tools = persona_data.tools
begin_dialogs = copy.deepcopy( if public_description == "" and persona_data.system_prompt:
persona_data.get("_begin_dialogs_processed") public_description = persona_data.system_prompt[:120]
)
tools = persona_data.get("tools")
if public_description == "" and prompt:
public_description = prompt[:120]
if tools is None: if tools is None:
tools = None tools = None
elif not isinstance(tools, list): elif not isinstance(tools, list):
-93
View File
@@ -1,93 +0,0 @@
## What's Changed
### 新增
- 补充 MiniMax Provider。([#6318](https://github.com/AstrBotDevs/AstrBot/pull/6318)
- 新增 WebUI ChatUI 页面的会话批量删除功能。([#6160](https://github.com/AstrBotDevs/AstrBot/pull/6160)
- 新增 WebUI ChatUI 配置发送快捷键。([#6272](https://github.com/AstrBotDevs/AstrBot/pull/6272)
### 优化
- 优化 UMO 处理兼容性。([#5996](https://github.com/AstrBotDevs/AstrBot/pull/5996)
- 重构 `_extract_session_id`,改进聊天类型分支处理。(#5775
- 优化聊天组件行为,使用 `shiki` 进行代码块渲染。([#6286](https://github.com/AstrBotDevs/AstrBot/pull/6286)
- 优化 WebUI 主题配色与视觉体验。([#6263](https://github.com/AstrBotDevs/AstrBot/pull/6263)
- 优化 OneBot @ 组件后处理,避免消息文本解析空格问题。([#6238](https://github.com/AstrBotDevs/AstrBot/pull/6238)
### 修复
- 修复创建新 Provider 后未同步 `providers_config` 的问题。([#6388](https://github.com/AstrBotDevs/AstrBot/pull/6388)
- 修复 API 返回 `null choices` 时的 `TypeError`。([#6313](https://github.com/AstrBotDevs/AstrBot/pull/6313)
- 修复 QQ Webhook 重试回调重复触发的问题。([#6320](https://github.com/AstrBotDevs/AstrBot/pull/6320)
- 修复流式模式下 `delta``None` 导致工具调用时报错的问题。([#6365](https://github.com/AstrBotDevs/AstrBot/pull/6365)
- 修复模型服务链接说明文字错误。([#6296](https://github.com/AstrBotDevs/AstrBot/pull/6296)
- 修复 AI 在 tool-calling 模式设为 `skills-like` 时发送媒体失败的问题。([#6317](https://github.com/AstrBotDevs/AstrBot/pull/6317)
- 修复 Telegram 适配器中 GIF 被错误转成静态图的问题。([#6329](https://github.com/AstrBotDevs/AstrBot/pull/6329)
- 将 Provider 图标来源替换为 jsDelivr CDN 地址,修复部分环境下图标加载问题。([#6340](https://github.com/AstrBotDevs/AstrBot/pull/6340)
- 修复 QQ 官方表情消息未解析为可读文本的问题。([#6355](https://github.com/AstrBotDevs/AstrBot/pull/6355)
- 修复 WebChat 队列异常时流式结果页面崩溃的问题。([#6123](https://github.com/AstrBotDevs/AstrBot/pull/6123)
- 修复子代理 handoff 工具在插件过滤时丢失的问题。([#6155](https://github.com/AstrBotDevs/AstrBot/pull/6155)
- 修复 Cron 提示文案缺少空格及 `utcnow()` 的弃用警告问题。([#6192](https://github.com/AstrBotDevs/AstrBot/pull/6192)
- 修复 WebUI 启动时 Sidebar hash 导航抖动/定位问题。([#6159](https://github.com/AstrBotDevs/AstrBot/pull/6159)
- 修复启动重试过程中移除已移除 API Key 的 `ValueError` 报错。([#6193](https://github.com/AstrBotDevs/AstrBot/pull/6193)
- 修复 README 启动命令引用更新为 `astrbot run`。([#6189](https://github.com/AstrBotDevs/AstrBot/pull/6189)
- 修复 `Plain.toDict()``@` 提及场景下空白字符丢失的问题。([#6244](https://github.com/AstrBotDevs/AstrBot/pull/6244)
- 修复 provider 依赖重复定义问题。([#6247](https://github.com/AstrBotDevs/AstrBot/pull/6247)
- 修复 Telegram 中普通回复被误判为线程的处理问题。([#6174](https://github.com/AstrBotDevs/AstrBot/pull/6174)
### 其他
- 调整 `astrbot.service` 及 CI 配置,升级 GitHub Actions 版本。
---
## What's Changed (EN)
### New Features
- Added OpenRouter chat completion provider adapter with support for custom headers ([#6436](https://github.com/AstrBotDevs/AstrBot/pull/6436)).
- Added MiniMax provider ([#6318](https://github.com/AstrBotDevs/AstrBot/pull/6318)).
- Added batch conversation deletion in WebChat ([#6160](https://github.com/AstrBotDevs/AstrBot/pull/6160)).
- Added send shortcut settings and localization support for WebChat input ([#6272](https://github.com/AstrBotDevs/AstrBot/pull/6272)).
- Added local temporary directory binding in YAML config ([#6191](https://github.com/AstrBotDevs/AstrBot/pull/6191)).
### Improvements
- Improved UMO processing compatibility ([#5996](https://github.com/AstrBotDevs/AstrBot/pull/5996)).
- Refactored `_extract_session_id` for chat type handling (#5775).
- Improved chat component behavior and uses `shiki` for code-block rendering ([#6286](https://github.com/AstrBotDevs/AstrBot/pull/6286)).
- Improved WebUI theme color and visual behavior ([#6263](https://github.com/AstrBotDevs/AstrBot/pull/6263)).
- Improved OneBot `@` component spacing handling ([#6238](https://github.com/AstrBotDevs/AstrBot/pull/6238)).
- Improved PR checklist validation and closure messaging.
### Bug Fixes
- Fixed missing `providers_config` sync after creating new providers ([#6388](https://github.com/AstrBotDevs/AstrBot/pull/6388)).
- Fixed `TypeError` when API returns null choices ([#6313](https://github.com/AstrBotDevs/AstrBot/pull/6313)).
- Fixed repeated QQ webhook retry callbacks ([#6320](https://github.com/AstrBotDevs/AstrBot/pull/6320)).
- Fixed tool-calling streaming null `delta` handling to prevent `AttributeError` ([#6365](https://github.com/AstrBotDevs/AstrBot/pull/6365)).
- Fixed model service link wording in docs/config ([#6296](https://github.com/AstrBotDevs/AstrBot/pull/6296)).
- Fixed AI media sending failure when tool-calling mode is set to `skills-like` ([#6317](https://github.com/AstrBotDevs/AstrBot/pull/6317)).
- Fixed GIF being sent as static image in Telegram adapter ([#6329](https://github.com/AstrBotDevs/AstrBot/pull/6329)).
- Replaced npm registry URLs with jsDelivr CDN for provider icons ([#6340](https://github.com/AstrBotDevs/AstrBot/pull/6340)).
- Fixed QQ official face message parsing to readable text ([#6355](https://github.com/AstrBotDevs/AstrBot/pull/6355)).
- Fixed WebChat stream-result crash on queue errors ([#6123](https://github.com/AstrBotDevs/AstrBot/pull/6123)).
- Preserved subagent handoff tools during plugin filtering ([#6155](https://github.com/AstrBotDevs/AstrBot/pull/6155)).
- Fixed cron prompt spacing and deprecated `utcnow()` usage ([#6192](https://github.com/AstrBotDevs/AstrBot/pull/6192)).
- Fixed unstable sidebar hash navigation on startup ([#6159](https://github.com/AstrBotDevs/AstrBot/pull/6159)).
- Fixed `ValueError` in retry loop when removing an already removed API key ([#6193](https://github.com/AstrBotDevs/AstrBot/pull/6193)).
- Updated startup command to `astrbot run` across READMEs ([#6189](https://github.com/AstrBotDevs/AstrBot/pull/6189)).
- Preserved whitespace in `Plain.toDict()` for @ mentions ([#6244](https://github.com/AstrBotDevs/AstrBot/pull/6244)).
- Removed duplicate dependencies entries ([#6247](https://github.com/AstrBotDevs/AstrBot/pull/6247)).
- Fixed Telegram normal reply being treated as topic thread ([#6174](https://github.com/AstrBotDevs/AstrBot/pull/6174)).
### Documentation
- Updated `rainyun` backup/access documentation ([#6427](https://github.com/AstrBotDevs/AstrBot/pull/6427)).
- Updated `package.md` and platform docs, including Matrix and Wecom AI bot documentation.
- Fixed Discord invite link in community docs.
### Chores
- Updated PR templates/checklist workflow, repository service config, and automated checks.
- Refreshed repository automation and formatting maintenance, and removed obsolete changelog scripts.
+2
View File
@@ -1,3 +1,5 @@
version: '3.8'
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml # 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
services: services:
@@ -619,6 +619,11 @@
"type": "string", "type": "string",
"hint": "Required. The Bot Token obtained from the KOOK Developer Platform." "hint": "Required. The Bot Token obtained from the KOOK Developer Platform."
}, },
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "Optional. If the sender nickname matches this value, the message will be ignored to prevent broadcast storms."
},
"kook_reconnect_delay": { "kook_reconnect_delay": {
"description": "Reconnect Delay", "description": "Reconnect Delay",
"type": "int", "type": "int",
@@ -846,7 +851,7 @@
}, },
"interval_method": { "interval_method": {
"description": "Interval Method", "description": "Interval Method",
"hint": "random uses a random delay. log calculates delay by message length: $y=log_{log\\_base}(x)$, where x is word count and y is in seconds." "hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。"
}, },
"interval": { "interval": {
"description": "Random Interval Time", "description": "Random Interval Time",
@@ -94,7 +94,7 @@
"title": "Confirm Batch Delete", "title": "Confirm Batch Delete",
"message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion." "message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion."
}, },
"batchOperations": { "batchOperations": {
"title": "Batch Operations", "title": "Batch Operations",
"hint": "Quick batch modify session settings", "hint": "Quick batch modify session settings",
"scope": "Apply to", "scope": "Apply to",
@@ -108,24 +108,23 @@
"ttsProvider": "TTS Model", "ttsProvider": "TTS Model",
"apply": "Apply Changes" "apply": "Apply Changes"
}, },
"groups": { "status": {
"title": "Group Management", "enabled": "Enabled",
"count": "{count} groups", "disabled": "Disabled"
"addToGroup": "Add to Group", },
"create": "Create Group", "batchOperations": {
"edit": "Edit Group", "title": "Batch Operations",
"name": "Group Name", "hint": "Quick batch modify session settings",
"sessionsCount": "{count} sessions", "scope": "Apply to",
"empty": "No groups yet. Click 'Create Group' to create one.", "scopeSelected": "Selected sessions",
"availableSessions": "Available Sessions ({count})", "scopeAll": "All sessions",
"selectedSessions": "Selected Sessions ({count})", "scopeGroup": "All groups",
"searchPlaceholder": "Search...", "scopePrivate": "All private chats",
"noMatch": "No matches", "llmStatus": "LLM Status",
"noMembers": "No members", "ttsStatus": "TTS Status",
"customGroupDivider": "── Custom Groups ──", "chatProvider": "Chat Model",
"customGroupOption": "📁 {name} ({count})", "ttsProvider": "TTS Model",
"groupOption": "{name} ({count} sessions)", "apply": "Apply Changes"
"deleteConfirm": "Are you sure you want to delete group \"{name}\"?"
}, },
"status": { "status": {
"enabled": "Enabled", "enabled": "Enabled",
@@ -143,16 +142,7 @@
"noChanges": "No changes to save", "noChanges": "No changes to save",
"batchDeleteSuccess": "Batch delete successful", "batchDeleteSuccess": "Batch delete successful",
"batchDeleteError": "Batch delete failed", "batchDeleteError": "Batch delete failed",
"selectSessionsFirst": "Please select sessions first",
"selectAtLeastOneConfig": "Please select at least one setting to modify",
"batchUpdateSuccess": "Batch update successful",
"partialUpdateFailed": "Some updates failed",
"batchUpdateError": "Batch update failed", "batchUpdateError": "Batch update failed",
"groupNameRequired": "Group name cannot be empty", "batchUpdateSuccess": "Batch update success"
"saveGroupError": "Failed to save group",
"deleteGroupError": "Failed to delete group",
"selectSessionsToAddFirst": "Please select sessions to add first",
"addToGroupSuccess": "Added {count} sessions to the group",
"addToGroupError": "Failed to add to group"
} }
} }
@@ -108,25 +108,6 @@
"ttsProvider": "TTS-модель", "ttsProvider": "TTS-модель",
"apply": "Применить" "apply": "Применить"
}, },
"groups": {
"title": "Управление группами",
"count": "групп: {count}",
"addToGroup": "Добавить в группу",
"create": "Создать группу",
"edit": "Изменить группу",
"name": "Имя группы",
"sessionsCount": "сессий: {count}",
"empty": "Пока нет групп. Нажмите «Создать группу», чтобы добавить.",
"availableSessions": "Доступные сессии ({count})",
"selectedSessions": "Выбранные сессии ({count})",
"searchPlaceholder": "Поиск...",
"noMatch": "Нет совпадений",
"noMembers": "Нет участников",
"customGroupDivider": "── Пользовательские группы ──",
"customGroupOption": "📁 {name} ({count})",
"groupOption": "{name} (сессий: {count})",
"deleteConfirm": "Вы уверены, что хотите удалить группу \"{name}\"?"
},
"status": { "status": {
"enabled": "Включено", "enabled": "Включено",
"disabled": "Выключено" "disabled": "Выключено"
@@ -143,16 +124,7 @@
"noChanges": "Изменений не обнаружено", "noChanges": "Изменений не обнаружено",
"batchDeleteSuccess": "Массовое удаление выполнено", "batchDeleteSuccess": "Массовое удаление выполнено",
"batchDeleteError": "Ошибка массового удаления", "batchDeleteError": "Ошибка массового удаления",
"selectSessionsFirst": "Пожалуйста, сначала выберите сессии",
"selectAtLeastOneConfig": "Пожалуйста, выберите хотя бы одну настройку для изменения",
"batchUpdateSuccess": "Пакетное обновление успешно выполнено",
"partialUpdateFailed": "Некоторые обновления не выполнены",
"batchUpdateError": "Ошибка пакетного обновления", "batchUpdateError": "Ошибка пакетного обновления",
"groupNameRequired": "Имя группы не может быть пустым", "batchUpdateSuccess": "Пакетное обновление успешно выполнено"
"saveGroupError": "Ошибка сохранения группы",
"deleteGroupError": "Ошибка удаления группы",
"selectSessionsToAddFirst": "Пожалуйста, сначала выберите сессии для добавления",
"addToGroupSuccess": "Добавлено сессий в группу: {count}",
"addToGroupError": "Ошибка добавления в группу"
} }
} }
@@ -621,6 +621,11 @@
"type": "string", "type": "string",
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token" "hint": "必填项。从 KOOK 开发者平台获取的机器人 Token"
}, },
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息。"
},
"kook_reconnect_delay": { "kook_reconnect_delay": {
"description": "重连延迟", "description": "重连延迟",
"type": "int", "type": "int",
@@ -108,25 +108,6 @@
"ttsProvider": "TTS 模型", "ttsProvider": "TTS 模型",
"apply": "应用更改" "apply": "应用更改"
}, },
"groups": {
"title": "分组管理",
"count": "{count} 个分组",
"addToGroup": "添加到分组",
"create": "新建分组",
"edit": "编辑分组",
"name": "分组名称",
"sessionsCount": "{count} 个会话",
"empty": "暂无分组,点击「新建分组」创建",
"availableSessions": "可选会话 ({count})",
"selectedSessions": "已选会话 ({count})",
"searchPlaceholder": "搜索...",
"noMatch": "无匹配项",
"noMembers": "暂无成员",
"customGroupDivider": "── 自定义分组 ──",
"customGroupOption": "📁 {name} ({count})",
"groupOption": "{name} ({count} 个会话)",
"deleteConfirm": "确定要删除分组 \"{name}\" 吗?"
},
"status": { "status": {
"enabled": "启用", "enabled": "启用",
"disabled": "禁用" "disabled": "禁用"
@@ -142,17 +123,6 @@
"deleteError": "删除失败", "deleteError": "删除失败",
"noChanges": "没有需要保存的更改", "noChanges": "没有需要保存的更改",
"batchDeleteSuccess": "批量删除成功", "batchDeleteSuccess": "批量删除成功",
"batchDeleteError": "批量删除失败", "batchDeleteError": "批量删除失败"
"selectSessionsFirst": "请先选择要操作的会话",
"selectAtLeastOneConfig": "请至少选择一项要修改的配置",
"batchUpdateSuccess": "批量更新成功",
"partialUpdateFailed": "部分更新失败",
"batchUpdateError": "批量更新失败",
"groupNameRequired": "分组名称不能为空",
"saveGroupError": "保存分组失败",
"deleteGroupError": "删除分组失败",
"selectSessionsToAddFirst": "请先选择要添加的会话",
"addToGroupSuccess": "已添加 {count} 个会话到分组",
"addToGroupError": "添加失败"
} }
} }
+32 -35
View File
@@ -156,24 +156,24 @@
<!-- 分组管理面板 --> <!-- 分组管理面板 -->
<v-card flat class="mt-4"> <v-card flat class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4"> <v-card-title class="d-flex align-center py-3 px-4">
<span class="text-h6">{{ tm('groups.title') }}</span> <span class="text-h6">分组管理</span>
<v-chip size="small" class="ml-2" color="secondary" variant="outlined"> <v-chip size="small" class="ml-2" color="secondary" variant="outlined">
{{ tm('groups.count', { count: groups.length }) }} {{ groups.length }} 个分组
</v-chip> </v-chip>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn v-if="selectedItems.length > 0 && groups.length > 0" color="info" variant="tonal" size="small" class="mr-2"> <v-btn v-if="selectedItems.length > 0 && groups.length > 0" color="info" variant="tonal" size="small" class="mr-2">
<v-icon start>mdi-folder-plus</v-icon> <v-icon start>mdi-folder-plus</v-icon>
{{ tm('groups.addToGroup') }} 添加到分组
<v-menu activator="parent"> <v-menu activator="parent">
<v-list density="compact"> <v-list density="compact">
<v-list-item v-for="g in groups" :key="g.id" @click="addSelectedToGroup(g.id)"> <v-list-item v-for="g in groups" :key="g.id" @click="addSelectedToGroup(g.id)">
<v-list-item-title>{{ tm('groups.customGroupOption', { name: g.name, count: g.umo_count }) }}</v-list-item-title> <v-list-item-title>{{ g.name }} ({{ g.umo_count }})</v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
</v-btn> </v-btn>
<v-btn color="success" variant="tonal" size="small" @click="openCreateGroupDialog" prepend-icon="mdi-folder-plus"> <v-btn color="success" variant="tonal" size="small" @click="openCreateGroupDialog" prepend-icon="mdi-folder-plus">
{{ tm('groups.create') }} 新建分组
</v-btn> </v-btn>
</v-card-title> </v-card-title>
<v-card-text v-if="groups.length > 0"> <v-card-text v-if="groups.length > 0">
@@ -183,7 +183,7 @@
<div class="d-flex align-center justify-space-between"> <div class="d-flex align-center justify-space-between">
<div> <div>
<div class="font-weight-bold">{{ group.name }}</div> <div class="font-weight-bold">{{ group.name }}</div>
<div class="text-caption text-grey">{{ tm('groups.sessionsCount', { count: group.umo_count }) }}</div> <div class="text-caption text-grey">{{ group.umo_count }} 个会话</div>
</div> </div>
<div> <div>
<v-btn icon size="small" variant="text" @click="openEditGroupDialog(group)"> <v-btn icon size="small" variant="text" @click="openEditGroupDialog(group)">
@@ -199,7 +199,7 @@
</v-row> </v-row>
</v-card-text> </v-card-text>
<v-card-text v-else class="text-center text-grey py-6"> <v-card-text v-else class="text-center text-grey py-6">
{{ tm('groups.empty') }} 暂无分组点击新建分组创建
</v-card-text> </v-card-text>
</v-card> </v-card>
@@ -207,15 +207,15 @@
<v-dialog v-model="groupDialog" max-width="800" @after-enter="loadAvailableUmos"> <v-dialog v-model="groupDialog" max-width="800" @after-enter="loadAvailableUmos">
<v-card> <v-card>
<v-card-title class="py-3 px-4"> <v-card-title class="py-3 px-4">
{{ groupDialogMode === 'create' ? tm('groups.create') : tm('groups.edit') }} {{ groupDialogMode === 'create' ? '新建分组' : '编辑分组' }}
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<v-text-field v-model="editingGroup.name" :label="tm('groups.name')" variant="outlined" hide-details class="mb-4"></v-text-field> <v-text-field v-model="editingGroup.name" label="分组名称" variant="outlined" hide-details class="mb-4"></v-text-field>
<v-row dense> <v-row dense>
<!-- 左侧可选会话 --> <!-- 左侧可选会话 -->
<v-col cols="5"> <v-col cols="5">
<div class="text-subtitle-2 mb-2">{{ tm('groups.availableSessions', { count: unselectedUmos.length }) }}</div> <div class="text-subtitle-2 mb-2">可选会话 ({{ unselectedUmos.length }})</div>
<v-text-field v-model="groupMemberSearch" :placeholder="tm('groups.searchPlaceholder')" variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field> <v-text-field v-model="groupMemberSearch" placeholder="搜索..." variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
<v-list density="compact" class="transfer-list" lines="one"> <v-list density="compact" class="transfer-list" lines="one">
<v-list-item v-for="umo in filteredUnselectedUmos" :key="umo" @click="addToGroup(umo)" class="transfer-item"> <v-list-item v-for="umo in filteredUnselectedUmos" :key="umo" @click="addToGroup(umo)" class="transfer-item">
<template v-slot:prepend> <template v-slot:prepend>
@@ -224,7 +224,7 @@
<v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title> <v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item v-if="filteredUnselectedUmos.length === 0 && !loadingUmos"> <v-list-item v-if="filteredUnselectedUmos.length === 0 && !loadingUmos">
<v-list-item-title class="text-caption text-grey text-center">{{ tm('groups.noMatch') }}</v-list-item-title> <v-list-item-title class="text-caption text-grey text-center">无匹配项</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item v-if="loadingUmos"> <v-list-item v-if="loadingUmos">
<v-list-item-title class="text-center"><v-progress-circular indeterminate size="20"></v-progress-circular></v-list-item-title> <v-list-item-title class="text-center"><v-progress-circular indeterminate size="20"></v-progress-circular></v-list-item-title>
@@ -242,8 +242,8 @@
</v-col> </v-col>
<!-- 右侧已选会话 --> <!-- 右侧已选会话 -->
<v-col cols="5"> <v-col cols="5">
<div class="text-subtitle-2 mb-2">{{ tm('groups.selectedSessions', { count: editingGroup.umos.length }) }}</div> <div class="text-subtitle-2 mb-2">已选会话 ({{ editingGroup.umos.length }})</div>
<v-text-field v-model="groupSelectedSearch" :placeholder="tm('groups.searchPlaceholder')" variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field> <v-text-field v-model="groupSelectedSearch" placeholder="搜索..." variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
<v-list density="compact" class="transfer-list" lines="one"> <v-list density="compact" class="transfer-list" lines="one">
<v-list-item v-for="umo in filteredSelectedUmos" :key="umo" @click="removeFromGroup(umo)" class="transfer-item"> <v-list-item v-for="umo in filteredSelectedUmos" :key="umo" @click="removeFromGroup(umo)" class="transfer-item">
<template v-slot:prepend> <template v-slot:prepend>
@@ -252,7 +252,7 @@
<v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title> <v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item v-if="editingGroup.umos.length === 0"> <v-list-item v-if="editingGroup.umos.length === 0">
<v-list-item-title class="text-caption text-grey text-center">{{ tm('groups.noMembers') }}</v-list-item-title> <v-list-item-title class="text-caption text-grey text-center">暂无成员</v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-col> </v-col>
@@ -260,8 +260,8 @@
</v-card-text> </v-card-text>
<v-card-actions class="px-4 pb-4"> <v-card-actions class="px-4 pb-4">
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn variant="text" @click="groupDialog = false">{{ tm('buttons.cancel') }}</v-btn> <v-btn variant="text" @click="groupDialog = false">取消</v-btn>
<v-btn color="primary" variant="tonal" @click="saveGroup">{{ tm('buttons.save') }}</v-btn> <v-btn color="primary" variant="tonal" @click="saveGroup">保存</v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
@@ -721,12 +721,9 @@ export default {
] ]
// //
if (this.groups.length > 0) { if (this.groups.length > 0) {
options.push({ label: this.tm('groups.customGroupDivider'), value: '_divider', disabled: true }) options.push({ label: '── 自定义分组 ──', value: '_divider', disabled: true })
this.groups.forEach(g => { this.groups.forEach(g => {
options.push({ options.push({ label: `📁 ${g.name} (${g.umo_count})`, value: `custom_group:${g.id}` })
label: this.tm('groups.customGroupOption', { name: g.name, count: g.umo_count }),
value: `custom_group:${g.id}`
})
}) })
} }
return options return options
@@ -734,7 +731,7 @@ export default {
groupOptions() { groupOptions() {
return this.groups.map(g => ({ return this.groups.map(g => ({
label: this.tm('groups.groupOption', { name: g.name, count: g.umo_count }), label: `${g.name} (${g.umo_count} 个会话)`,
value: g.id value: g.id
})) }))
}, },
@@ -1334,7 +1331,7 @@ export default {
if (scope === 'selected') { if (scope === 'selected') {
umos = this.selectedItems.map(item => item.umo) umos = this.selectedItems.map(item => item.umo)
if (umos.length === 0) { if (umos.length === 0) {
this.showError(this.tm('messages.selectSessionsFirst')) this.showError('请先选择要操作的会话')
this.batchUpdating = false this.batchUpdating = false
return return
} }
@@ -1374,7 +1371,7 @@ export default {
} }
if (tasks.length === 0) { if (tasks.length === 0) {
this.showError(this.tm('messages.selectAtLeastOneConfig')) this.showError('请至少选择一项要修改的配置')
this.batchUpdating = false this.batchUpdating = false
return return
} }
@@ -1383,17 +1380,17 @@ export default {
const allOk = results.every(r => r.data.status === 'ok') const allOk = results.every(r => r.data.status === 'ok')
if (allOk) { if (allOk) {
this.showSuccess(this.tm('messages.batchUpdateSuccess')) this.showSuccess('批量更新成功')
this.batchLlmStatus = null this.batchLlmStatus = null
this.batchTtsStatus = null this.batchTtsStatus = null
this.batchChatProvider = null this.batchChatProvider = null
this.batchTtsProvider = null this.batchTtsProvider = null
await this.loadData() await this.loadData()
} else { } else {
this.showError(this.tm('messages.partialUpdateFailed')) this.showError('部分更新失败')
} }
} catch (error) { } catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.batchUpdateError')) this.showError(error.response?.data?.message || '批量更新失败')
} }
this.batchUpdating = false this.batchUpdating = false
}, },
@@ -1480,7 +1477,7 @@ export default {
async saveGroup() { async saveGroup() {
if (!this.editingGroup.name.trim()) { if (!this.editingGroup.name.trim()) {
this.showError(this.tm('messages.groupNameRequired')) this.showError('分组名称不能为空')
return return
} }
@@ -1507,12 +1504,12 @@ export default {
this.showError(response.data.message) this.showError(response.data.message)
} }
} catch (error) { } catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.saveGroupError')) this.showError(error.response?.data?.message || '保存分组失败')
} }
}, },
async deleteGroup(group) { async deleteGroup(group) {
const message = this.tm('groups.deleteConfirm', { name: group.name }) const message = `确定要删除分组 "${group.name}" 吗?`
if (!(await askForConfirmationDialog(message, this.confirmDialog))) return if (!(await askForConfirmationDialog(message, this.confirmDialog))) return
try { try {
@@ -1524,7 +1521,7 @@ export default {
this.showError(response.data.message) this.showError(response.data.message)
} }
} catch (error) { } catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.deleteGroupError')) this.showError(error.response?.data?.message || '删除分组失败')
} }
}, },
@@ -1535,7 +1532,7 @@ export default {
async addSelectedToGroup(groupId) { async addSelectedToGroup(groupId) {
if (this.selectedItems.length === 0) { if (this.selectedItems.length === 0) {
this.showError(this.tm('messages.selectSessionsToAddFirst')) this.showError('请先选择要添加的会话')
return return
} }
@@ -1545,13 +1542,13 @@ export default {
add_umos: this.selectedItems.map(item => item.umo) add_umos: this.selectedItems.map(item => item.umo)
}) })
if (response.data.status === 'ok') { if (response.data.status === 'ok') {
this.showSuccess(this.tm('messages.addToGroupSuccess', { count: this.selectedItems.length })) this.showSuccess(`已添加 ${this.selectedItems.length} 个会话到分组`)
await this.loadGroups() await this.loadGroups()
} else { } else {
this.showError(response.data.message) this.showError(response.data.message)
} }
} catch (error) { } catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.addToGroupError')) this.showError(error.response?.data?.message || '添加失败')
} }
}, },
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "AstrBot" name = "AstrBot"
version = "4.20.1" version = "4.20.0"
description = "Easy-to-use multi-platform LLM chatbot and development framework" description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
-5
View File
@@ -1,7 +1,5 @@
# user service
[Unit] [Unit]
Description=AstrBot Service Description=AstrBot Service
Documentation=https://github.com/AstrBotDevs/AstrBot
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
@@ -11,9 +9,6 @@ WorkingDirectory=%h/.local/share/astrbot
ExecStart=/usr/bin/sh -c '/usr/bin/astrbot run || { /usr/bin/astrbot init && /usr/bin/astrbot run; }' ExecStart=/usr/bin/sh -c '/usr/bin/astrbot run || { /usr/bin/astrbot init && /usr/bin/astrbot run; }'
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=astrbot-%u
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
[Install] [Install]
-48
View File
@@ -1,5 +1,4 @@
import asyncio import asyncio
import copy
import io import io
import os import os
import sys import sys
@@ -108,53 +107,6 @@ async def test_get_stat(app: Quart, authenticated_header: dict):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_subagent_config_accepts_default_persona(
app: Quart,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
):
test_client = app.test_client()
old_cfg = copy.deepcopy(
core_lifecycle_td.astrbot_config.get("subagent_orchestrator", {})
)
payload = {
"main_enable": True,
"remove_main_duplicate_tools": True,
"agents": [
{
"name": "planner",
"persona_id": "default",
"public_description": "planner",
"system_prompt": "",
"enabled": True,
}
],
}
try:
response = await test_client.post(
"/api/subagent/config",
json=payload,
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
get_response = await test_client.get(
"/api/subagent/config", headers=authenticated_header
)
assert get_response.status_code == 200
get_data = await get_response.get_json()
assert get_data["status"] == "ok"
assert get_data["data"]["agents"][0]["persona_id"] == "default"
finally:
await test_client.post(
"/api/subagent/config",
json=old_cfg,
headers=authenticated_header,
)
@pytest.mark.parametrize("payload", [[], "x"]) @pytest.mark.parametrize("payload", [[], "x"])
async def test_batch_delete_sessions_rejects_non_object_payload( async def test_batch_delete_sessions_rejects_non_object_payload(
app: Quart, authenticated_header: dict, payload app: Quart, authenticated_header: dict, payload
+27 -27
View File
@@ -4,97 +4,97 @@
"size": "lg", "size": "lg",
"modules": [ "modules": [
{ {
"type": "header",
"text": { "text": {
"type": "plain-text",
"content": "test1", "content": "test1",
"type": "plain-text",
"emoji": true "emoji": true
} },
"type": "header"
}, },
{ {
"type": "section",
"text": { "text": {
"type": "kmarkdown", "content": "test2",
"content": "test2" "type": "kmarkdown"
}, },
"type": "section",
"mode": "left" "mode": "left"
}, },
{ {
"type": "divider" "type": "divider"
}, },
{ {
"type": "section",
"text": { "text": {
"type": "paragraph",
"fields": [ "fields": [
{ {
"type": "kmarkdown", "content": "test3",
"content": "test3" "type": "kmarkdown"
}, },
{ {
"type": "kmarkdown", "content": "**test4**",
"content": "**test4**" "type": "kmarkdown"
} }
], ],
"type": "paragraph",
"cols": 2 "cols": 2
}, },
"type": "section",
"mode": "left" "mode": "left"
}, },
{ {
"type": "image-group",
"elements": [ "elements": [
{ {
"type": "image",
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg", "src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
"type": "image",
"alt": "", "alt": "",
"size": "lg", "size": "lg",
"circle": false "circle": false
} }
] ],
"type": "image-group"
}, },
{ {
"type": "file",
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg", "src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
"title": "test5" "title": "test5",
"type": "file"
}, },
{ {
"type": "countdown",
"endTime": 1772343427360, "endTime": 1772343427360,
"type": "countdown",
"startTime": 1772343378259, "startTime": 1772343378259,
"mode": "second" "mode": "second"
}, },
{ {
"type": "action-group",
"elements": [ "elements": [
{ {
"type": "button",
"text": "点我测试回调", "text": "点我测试回调",
"type": "button",
"theme": "primary", "theme": "primary",
"value": "btn_clicked", "value": "btn_clicked",
"click": "return-val" "click": "return-val"
}, },
{ {
"type": "button",
"text": "访问官网", "text": "访问官网",
"type": "button",
"theme": "danger", "theme": "danger",
"value": "https://www.kookapp.cn", "value": "https://www.kookapp.cn",
"click": "link" "click": "link"
} }
] ],
"type": "action-group"
}, },
{ {
"type": "context",
"elements": [ "elements": [
{ {
"type": "plain-text",
"content": "test6", "content": "test6",
"type": "plain-text",
"emoji": true "emoji": true
} }
] ],
"type": "context"
}, },
{ {
"type": "invite", "code": "test7",
"code": "test7" "type": "invite"
} }
] ]
} }
@@ -1,119 +0,0 @@
{
"s": 0,
"d": {
"channel_type": "GROUP",
"type": 9,
"target_id": "2732467349811313213",
"author_id": "7324688132731983",
"content": "done!",
"extra": {
"quote": {
"id": "69a788adb0cfb9ece50eae1c",
"rong_id": "7baef72c-0cd7-49ad-9592-1615236136cb",
"type": 9,
"content": "/am 1",
"interact_res": null,
"create_at": 1772587180973,
"author": {
"id": "2701973210937821093781",
"username": "some_username",
"identify_num": "4198",
"online": true,
"os": "Websocket",
"status": 1,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"banner": "",
"nickname": "some_username",
"roles": [
63724577
],
"is_vip": false,
"vip_amp": false,
"bot": false,
"nameplate": [],
"kpm_vip": null,
"wealth_level": 0,
"decorations_id_map": null,
"mobile_verified": true,
"is_sys": false,
"joined_at": 1772259607000,
"active_time": 1772587181304
},
"can_jump": true,
"preview_content": null,
"kmarkdown": {
"mention_part": [],
"mention_role_part": [],
"channel_part": [],
"item_part": []
}
},
"type": 9,
"code": "",
"guild_id": "273902183210983210983",
"guild_type": 0,
"channel_name": "聊天大厅",
"author": {
"id": "7324688132731983",
"username": "Bot_Test",
"identify_num": "9561",
"online": true,
"os": "Websocket",
"status": 0,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"banner": "",
"nickname": "Bot_Test",
"roles": [
63725384
],
"is_vip": false,
"vip_amp": false,
"bot": true,
"nameplate": [],
"kpm_vip": null,
"wealth_level": 0,
"bot_status": 0,
"tag_info": {
"color": "#0096FF",
"bg_color": "#0096FF33",
"text": "机器人"
},
"is_sys": false,
"client_id": "sAdiIHoGhdSFUOA",
"verified": false
},
"visible_only": "",
"mention": [],
"mention_no_at": [],
"mention_all": false,
"mention_roles": [],
"mention_here": false,
"nav_channels": [],
"kmarkdown": {
"raw_content": "done!",
"mention_part": [],
"mention_role_part": [],
"channel_part": [],
"spl": []
},
"emoji": [],
"preview_content": "",
"channel_type": 1,
"last_msg_content": "Bot_Testdone!",
"send_msg_device": 0
},
"msg_id": "c51a8761-63bv-5l2a-5681-0ac16e140a1b",
"msg_timestamp": 1772587182234,
"nonce": "",
"from_type": 1
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 3
}
@@ -1,8 +0,0 @@
{
"s": 1,
"d": {
"sessionId": "67d7d497-2b10-4849-9c2c-dda2fe58ed60",
"session_id": "67d7d497-2b10-4849-9c2c-dda2fe58ed60",
"code": 0
}
}
@@ -1,72 +0,0 @@
{
"s": 0,
"d": {
"channel_type": "PERSON",
"type": 10,
"target_id": "2732467349811313213",
"author_id": "7324688132731983",
"content": "[{\"theme\":\"primary\",\"color\":\"\",\"size\":\"lg\",\"expand\":false,\"modules\":[{\"type\":\"audio\",\"cover\":\"\",\"duration\":0,\"title\":\"dancing_shot5.wav\",\"src\":\"https:\\/\\/img.kookapp.cn\\/attachments\\/2026-03\\/03\\/69a6841c3125d.wav\",\"external\":false,\"size\":443414,\"canDownload\":true,\"elements\":[]}],\"type\":\"card\"}]",
"extra": {
"type": 10,
"code": "1738914789hd8fd91098he809h19y491",
"author": {
"id": "7324688132731983",
"username": "Bot_Test",
"identify_num": "9561",
"online": true,
"os": "Websocket",
"status": 0,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"banner": "",
"nickname": "Bot_Test",
"roles": [],
"is_vip": false,
"vip_amp": false,
"bot": true,
"nameplate": [],
"kpm_vip": null,
"wealth_level": 0,
"bot_status": 0,
"tag_info": {
"color": "#0096FF",
"bg_color": "#0096FF33",
"text": "机器人"
},
"is_sys": false,
"client_id": "u109u3108h8ds0qsdaHUIOS",
"verified": false
},
"visible_only": "",
"mention": [],
"mention_no_at": [],
"mention_all": false,
"mention_roles": [],
"mention_here": false,
"nav_channels": [],
"emoji": [],
"kmarkdown": {
"raw_content": "[音频]dancing_shot5.wav",
"mention_part": [],
"mention_role_part": [],
"channel_part": []
},
"editable": false,
"preview_content": "[音频]dancing_shot5.wav",
"preview_content_search": "[音频]dancing_shot5.wav",
"last_msg_content": "[音频]dancing_shot5.wav",
"send_msg_device": 0
},
"msg_id": "82c0b042-79b4-4066-a0f4-6c7a95c74e67",
"msg_timestamp": 1772587223043,
"nonce": "",
"from_type": 1
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 5
}
@@ -1,79 +0,0 @@
{
"s": 0,
"d": {
"channel_type": "GROUP",
"type": 10,
"target_id": "2723723449021809",
"author_id": "1237198731983",
"content": "[{\"theme\":\"invisible\",\"color\":\"\",\"size\":\"lg\",\"expand\":false,\"modules\":[{\"type\":\"section\",\"mode\":\"left\",\"accessory\":null,\"text\":{\"type\":\"kmarkdown\",\"content\":\"(met)(met) (met)all(met) #hello \\\\*\\\\*world\\\\*\\\\* \",\"elements\":[]},\"elements\":[]},{\"type\":\"audio\",\"cover\":\"\",\"duration\":0,\"title\":\"dancing_shot5.wav\",\"src\":\"https:\\/\\/img.kookapp.cn\\/attachments\\/2026-03\\/03\\/69a6841c3125d.wav\",\"external\":false,\"size\":443414,\"canDownload\":true,\"elements\":[]},{\"type\":\"section\",\"mode\":\"left\",\"accessory\":null,\"text\":{\"type\":\"kmarkdown\",\"content\":\"\\n😆 \",\"elements\":[]},\"elements\":[]}],\"type\":\"card\"}]",
"msg_id": "ec4046e9-ea43-4907-9fc3-8c6d0bd4ec56",
"msg_timestamp": 1772600762056,
"nonce": "sy8f91y248yda",
"from_type": 1,
"extra": {
"type": 10,
"code": "",
"author": {
"id": "1237198731983",
"username": "some_username",
"identify_num": "4198",
"nickname": "some_username",
"bot": false,
"online": true,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"status": 1,
"roles": [
12783219731984
],
"os": "Websocket",
"banner": "",
"is_vip": false,
"vip_amp": false,
"nameplate": [],
"wealth_level": 0,
"is_sys": false
},
"kmarkdown": {
"raw_content": "@Bot_Test @全体成员 #hello **world**[音频]dancing_shot5.wav😆",
"mention_part": [
{
"id": "",
"username": "Bot_Test",
"full_name": "Bot_Test#9561",
"avatar": "https://example.com",
"wealth_level": 0
}
],
"mention_role_part": [],
"channel_part": []
},
"last_msg_content": "some_username@Bot_Test @ 全体成员 #hello **world**[音频]dancing_shot5.wav😆",
"mention": [
""
],
"mention_all": true,
"mention_here": false,
"guild_id": "28321098321093",
"guild_type": 0,
"channel_name": "聊天大厅",
"visible_only": "",
"mention_no_at": [],
"mention_roles": [],
"nav_channels": [],
"emoji": [],
"editable": true,
"preview_content": "@Bot_Test @全体成员 #hello **world**[音频]dancing_shot5.wav😆",
"preview_content_search": "@Bot_Test @全体成员 #hello **world**[音频]dancing_shot5.wav😆",
"channel_type": 1,
"send_msg_device": 0
}
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 5
}
@@ -1,4 +0,0 @@
{
"s": 2,
"sn": 0
}
@@ -1,3 +0,0 @@
{
"s": 3
}
@@ -1,64 +0,0 @@
{
"s": 0,
"d": {
"channel_type": "PERSON",
"type": 9,
"target_id": "7324688132731983",
"author_id": "2732467349811313213",
"content": "/help",
"extra": {
"type": 9,
"code": "1738914789hd8fd91098he809h19y491",
"author": {
"id": "2732467349811313213",
"username": "shuiping233",
"identify_num": "4198",
"online": true,
"os": "Websocket",
"status": 1,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"banner": "",
"nickname": "shuiping233",
"roles": [],
"is_vip": false,
"vip_amp": false,
"bot": false,
"nameplate": [],
"kpm_vip": null,
"wealth_level": 0,
"decorations_id_map": null,
"is_sys": false
},
"visible_only": "",
"mention": [],
"mention_no_at": [],
"mention_all": false,
"mention_roles": [],
"mention_here": false,
"nav_channels": [],
"kmarkdown": {
"raw_content": "/help",
"mention_part": [],
"mention_role_part": [],
"channel_part": [],
"spl": []
},
"emoji": [],
"preview_content": "",
"last_msg_content": "/help",
"send_msg_device": 0
},
"msg_id": "b0f57b9e-2cd4-4e07-8f0e-9c1ecfeaa837",
"msg_timestamp": 1772587358662,
"nonce": "6AwzUe5YjgyC8pAfxcLGjewL",
"from_type": 1
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 19
}
@@ -1,31 +0,0 @@
{
"s": 0,
"d": {
"channel_type": "PERSON",
"type": 255,
"target_id": "7324688132731983",
"author_id": "1",
"content": "[系统消息]",
"extra": {
"type": "guild_member_offline",
"body": {
"user_id": "2732467349811313213",
"event_time": 1772589748914,
"guilds": [
"78941897317309873120973"
]
}
},
"msg_id": "e91b4451-75ce-47bd-bda6-e4498ed8d30d",
"msg_timestamp": 1772589748933,
"nonce": "",
"from_type": 1
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 1
}
@@ -1,7 +0,0 @@
{
"s": 5,
"d": {
"code": 40108,
"err": "Invalid SN"
}
}
@@ -1,4 +0,0 @@
{
"s": 4,
"sn": 100
}
@@ -1,6 +0,0 @@
{
"s": 6,
"d": {
"session_id": "xxxx-xxxxxx-xxx-xxx"
}
}
+1 -2
View File
@@ -1,5 +1,4 @@
from pathlib import Path from pathlib import Path
CURRENT_DIR = Path(__file__).parent TEST_DATA_DIR = Path(__file__).parent / "data"
TEST_DATA_DIR = CURRENT_DIR / "data"
+47 -12
View File
@@ -60,7 +60,7 @@ def mock_astrbot_message():
Image("test image"), Image("test image"),
"test image", "test image",
OrderMessage( OrderMessage(
index=1, 1,
text="test image", text="test image",
type=KookMessageType.IMAGE, type=KookMessageType.IMAGE,
), ),
@@ -70,7 +70,7 @@ def mock_astrbot_message():
Video("test video"), Video("test video"),
"test video", "test video",
OrderMessage( OrderMessage(
index=1, 1,
text="test video", text="test video",
type=KookMessageType.VIDEO, type=KookMessageType.VIDEO,
), ),
@@ -80,7 +80,7 @@ def mock_astrbot_message():
mock_file_message("test file"), mock_file_message("test file"),
"test file", "test file",
OrderMessage( OrderMessage(
index=1, 1,
text="test file", text="test file",
type=KookMessageType.FILE, type=KookMessageType.FILE,
), ),
@@ -90,8 +90,8 @@ def mock_astrbot_message():
mock_record_message("./tests/file.wav"), mock_record_message("./tests/file.wav"),
"./tests/file.wav", "./tests/file.wav",
OrderMessage( OrderMessage(
index=1, 1,
text='[{"type": "card", "modules": [{"type": "audio", "src": "./tests/file.wav", "title": "./tests/file.wav"}]}]', text='[{"type": "card", "modules": [{"src": "./tests/file.wav", "title": "./tests/file.wav", "type": "audio"}]}]',
type=KookMessageType.CARD, type=KookMessageType.CARD,
), ),
None, None,
@@ -100,7 +100,7 @@ def mock_astrbot_message():
Plain("test plain"), Plain("test plain"),
"test plain", "test plain",
OrderMessage( OrderMessage(
index=1, 1,
text="test plain", text="test plain",
type=KookMessageType.KMARKDOWN, type=KookMessageType.KMARKDOWN,
), ),
@@ -110,7 +110,7 @@ def mock_astrbot_message():
At(qq="test at"), At(qq="test at"),
"test at", "test at",
OrderMessage( OrderMessage(
index=1, 1,
text="(met)test at(met)", text="(met)test at(met)",
type=KookMessageType.KMARKDOWN, type=KookMessageType.KMARKDOWN,
), ),
@@ -120,7 +120,7 @@ def mock_astrbot_message():
AtAll(qq="all"), AtAll(qq="all"),
"test atAll", "test atAll",
OrderMessage( OrderMessage(
index=1, 1,
text="(met)all(met)", text="(met)all(met)",
type=KookMessageType.KMARKDOWN, type=KookMessageType.KMARKDOWN,
), ),
@@ -130,7 +130,7 @@ def mock_astrbot_message():
Reply(id="test reply"), Reply(id="test reply"),
"test reply", "test reply",
OrderMessage( OrderMessage(
index=1, 1,
text="", text="",
type=KookMessageType.KMARKDOWN, type=KookMessageType.KMARKDOWN,
reply_id="test reply", reply_id="test reply",
@@ -141,7 +141,7 @@ def mock_astrbot_message():
Json(data={"test": "json"}), Json(data={"test": "json"}),
"test json", "test json",
OrderMessage( OrderMessage(
index=1, 1,
text='[{"test": "json"}]', text='[{"test": "json"}]',
type=KookMessageType.CARD, type=KookMessageType.CARD,
), ),
@@ -159,7 +159,7 @@ async def test_kook_event_warp_message(
input_message: BaseMessageComponent, input_message: BaseMessageComponent,
upload_asset_return: str, upload_asset_return: str,
expected_output: OrderMessage, expected_output: OrderMessage,
expected_error: type[BaseException] | None, expected_error: type[Exception] | None,
): ):
client = await mock_kook_client( client = await mock_kook_client(
upload_asset_return, upload_asset_return,
@@ -185,4 +185,39 @@ async def test_kook_event_warp_message(
result = await event._wrap_message(1, input_message) result = await event._wrap_message(1, input_message)
assert result == expected_output assert result == expected_output
# @pytest.mark.asyncio
# @pytest.mark.parametrize(
# "message_chain,send_text_expected_output,expected_error",
# [
# (
# MessageChain(
# chain=[
# Image(file="test image"),
# Plain(text="test plain"),
# ],
# ),
# ""
# ),
# ],
# )
# async def test_kook_event_send():
# client = await mock_kook_client(
# "",
# "",
# )
# event = KookEvent(
# "",
# mock_astrbot_message(),
# PlatformMetadata(
# name="test",
# id="test",
# description="test",
# ),
# "",
# client,
# )
# await event.send(message=mock_astrbot_message())
+1 -42
View File
@@ -16,9 +16,6 @@ from astrbot.core.platform.sources.kook.kook_types import (
InviteModule, InviteModule,
KmarkdownElement, KmarkdownElement,
KookCardMessage, KookCardMessage,
KookMessageSignal,
KookModuleType,
KookWebsocketEvent,
ParagraphStructure, ParagraphStructure,
PlainTextElement, PlainTextElement,
SectionModule, SectionModule,
@@ -80,7 +77,7 @@ def test_all_kook_card_type():
FileModule( FileModule(
src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg", src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
title="test5", title="test5",
type=KookModuleType.FILE, type="file",
), ),
CountdownModule( CountdownModule(
endTime=1772343427360, endTime=1772343427360,
@@ -108,41 +105,3 @@ def test_all_kook_card_type():
], ],
).to_json(indent=4, ensure_ascii=False) ).to_json(indent=4, ensure_ascii=False)
assert json_output == expect_json_data assert json_output == expect_json_data
@pytest.mark.parametrize(
"expected_json_data_filename",
[
("kook_ws_event_group_message.json"),
("kook_ws_event_hello.json"),
("kook_ws_event_message_with_card_1.json"),
("kook_ws_event_message_with_card_2.json"),
("kook_ws_event_ping.json"),
("kook_ws_event_pong.json"),
("kook_ws_event_private_message.json"),
("kook_ws_event_private_system_message.json"),
("kook_ws_event_reconnect_err.json"),
("kook_ws_event_resume_ack.json"),
("kook_ws_event_resume.json"),
],
)
def test_websocket_event_type_parse(expected_json_data_filename:str):
expected_json_data_str =(TEST_DATA_DIR / expected_json_data_filename).read_text(encoding="utf-8")
event = KookWebsocketEvent.from_json(
expected_json_data_str,
)
event_dict = event.to_dict(mode="json",exclude_unset=True,exclude_none=False)
assert event_dict == json.loads(expected_json_data_str)
def test_websocket_event_create():
ping_data = KookWebsocketEvent(
signal=KookMessageSignal.PING,
data=None,
sn=0,
)
assert ping_data.to_dict(mode="json")== {
"s": KookMessageSignal.PING.value,
"sn": 0,
}
-33
View File
@@ -49,39 +49,6 @@ def test_parse_frontmatter_quoted_description():
assert _parse_frontmatter_description(text) == "quoted value" assert _parse_frontmatter_description(text) == "quoted value"
def test_parse_frontmatter_multiline_literal_description():
text = (
"---\n"
"name: humanizer-zh\n"
"description: |\n"
" 去除文本中的 AI 生成痕迹。\n"
" 适用于编辑或审阅文本,使其听起来更自然。\n"
"---\n"
)
assert _parse_frontmatter_description(text) == (
"去除文本中的 AI 生成痕迹。\n适用于编辑或审阅文本,使其听起来更自然。"
)
def test_parse_frontmatter_multiline_folded_description():
text = (
"---\n"
"name: humanizer-zh\n"
"description: >\n"
" 去除文本中的 AI 生成痕迹。\n"
" 适用于编辑或审阅文本,使其听起来更自然。\n"
"---\n"
)
assert _parse_frontmatter_description(text) == (
"去除文本中的 AI 生成痕迹。 适用于编辑或审阅文本,使其听起来更自然。"
)
def test_parse_frontmatter_invalid_yaml_returns_empty():
text = "---\ndescription: [broken\n---\n"
assert _parse_frontmatter_description(text) == ""
# ---------- build_skills_prompt tests ---------- # ---------- build_skills_prompt tests ----------
-58
View File
@@ -39,7 +39,6 @@ def mock_context():
ctx.persona_manager.resolve_selected_persona = AsyncMock( ctx.persona_manager.resolve_selected_persona = AsyncMock(
return_value=(None, None, None, False) return_value=(None, None, None, False)
) )
ctx.persona_manager.get_persona_v3_by_id = MagicMock(return_value=None)
ctx.get_llm_tool_manager.return_value = MagicMock() ctx.get_llm_tool_manager.return_value = MagicMock()
ctx.subagent_orchestrator = None ctx.subagent_orchestrator = None
return ctx return ctx
@@ -539,63 +538,6 @@ class TestEnsurePersonaAndSkills:
assert req.func_tool is not None assert req.func_tool is not None
@pytest.mark.asyncio
async def test_subagent_dedupe_uses_default_persona_tools(
self, mock_event, mock_context
):
"""Test dedupe uses resolved default persona tools in subagent mode."""
module = ama
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
return_value=(None, None, None, False)
)
mock_context.persona_manager.get_persona_v3_by_id = MagicMock(
return_value={"name": "default", "tools": ["tool_a"]}
)
tool_a = FunctionTool(
name="tool_a",
parameters={"type": "object", "properties": {}},
description="tool a",
)
tool_b = FunctionTool(
name="tool_b",
parameters={"type": "object", "properties": {}},
description="tool b",
)
tmgr = mock_context.get_llm_tool_manager.return_value
tmgr.func_list = [tool_a, tool_b]
tmgr.get_full_tool_set.return_value = ToolSet([tool_a, tool_b])
tmgr.get_func.side_effect = lambda name: {"tool_a": tool_a, "tool_b": tool_b}.get(
name
)
handoff = MagicMock()
handoff.name = "transfer_to_planner"
mock_context.subagent_orchestrator = MagicMock(handoffs=[handoff])
mock_context.get_config.return_value = {
"subagent_orchestrator": {
"main_enable": True,
"remove_main_duplicate_tools": True,
"agents": [
{
"name": "planner",
"enabled": True,
"persona_id": "default",
}
],
}
}
req = ProviderRequest()
req.conversation = MagicMock(persona_id=None)
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
assert req.func_tool is not None
assert "transfer_to_planner" in req.func_tool.names()
assert "tool_a" not in req.func_tool.names()
assert "tool_b" in req.func_tool.names()
class TestDecorateLlmRequest: class TestDecorateLlmRequest:
"""Tests for _decorate_llm_request function.""" """Tests for _decorate_llm_request function."""
-110
View File
@@ -1,110 +0,0 @@
from copy import deepcopy
from unittest.mock import MagicMock, patch
import pytest
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
def _build_cfg(agent_overrides: dict) -> dict:
agent = {
"name": "planner",
"enabled": True,
"persona_id": None,
"system_prompt": "inline prompt",
"public_description": "",
"tools": ["tool_a", " ", "tool_b"],
}
agent.update(agent_overrides)
return {"agents": [agent]}
@pytest.mark.asyncio
async def test_reload_from_config_default_persona_is_resolved():
tool_mgr = MagicMock()
persona_mgr = MagicMock()
default_persona = {
"name": "default",
"prompt": "You are a helpful and friendly assistant.",
"tools": None,
"_begin_dialogs_processed": [],
}
persona_mgr.get_persona_v3_by_id.return_value = deepcopy(default_persona)
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
await orchestrator.reload_from_config(_build_cfg({"persona_id": "default"}))
assert len(orchestrator.handoffs) == 1
handoff = orchestrator.handoffs[0]
assert handoff.agent.instructions == default_persona["prompt"]
assert handoff.agent.tools is None
assert handoff.agent.begin_dialogs == default_persona["_begin_dialogs_processed"]
@pytest.mark.asyncio
async def test_reload_from_config_missing_persona_falls_back_to_inline_and_warns():
tool_mgr = MagicMock()
persona_mgr = MagicMock()
persona_mgr.get_persona_v3_by_id.return_value = None
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
with patch("astrbot.core.subagent_orchestrator.logger") as mock_logger:
await orchestrator.reload_from_config(_build_cfg({"persona_id": "not_exists"}))
assert len(orchestrator.handoffs) == 1
handoff = orchestrator.handoffs[0]
assert handoff.agent.instructions == "inline prompt"
assert handoff.agent.tools == ["tool_a", "tool_b"]
assert handoff.agent.begin_dialogs is None
mock_logger.warning.assert_called_once_with(
"SubAgent persona %s not found, fallback to inline prompt.",
"not_exists",
)
@pytest.mark.asyncio
async def test_reload_from_config_uses_processed_begin_dialogs_and_deepcopy():
tool_mgr = MagicMock()
persona_mgr = MagicMock()
processed_dialogs = [{"role": "user", "content": "hello", "_no_save": True}]
persona_mgr.get_persona_v3_by_id.return_value = {
"name": "custom",
"prompt": "persona prompt",
"tools": ["tool_from_persona"],
"_begin_dialogs_processed": processed_dialogs,
}
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
await orchestrator.reload_from_config(_build_cfg({"persona_id": "custom"}))
processed_dialogs[0]["content"] = "mutated"
handoff = orchestrator.handoffs[0]
assert handoff.agent.instructions == "persona prompt"
assert handoff.agent.tools == ["tool_from_persona"]
assert handoff.agent.begin_dialogs[0]["content"] == "hello"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("raw_tools", "expected_tools"),
[
(None, None),
([], []),
("not-a-list", []),
],
)
async def test_reload_from_config_tool_normalization(raw_tools, expected_tools):
tool_mgr = MagicMock()
persona_mgr = MagicMock()
persona_mgr.get_persona_v3_by_id.return_value = {
"name": "custom",
"prompt": "persona prompt",
"tools": raw_tools,
"_begin_dialogs_processed": [],
}
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
await orchestrator.reload_from_config(_build_cfg({"persona_id": "custom"}))
handoff = orchestrator.handoffs[0]
assert handoff.agent.tools == expected_tools