Compare commits

...

12 Commits

Author SHA1 Message Date
shuiping233 f5ba1a026a perf: Implement Pydantic data models for the KOOK adapter to enhance data retrieval and message schema validation (#5719)
* refactor: 给kook适配器添加kook事件数据类

* format: 使用StrEnum替换kook适配器中的(str,enum)
2026-03-17 18:05:58 +08:00
jnMetaCode dcffb5269a fix: only pass dimensions when explicitly configured in embedding config (#6432)
* 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>

* 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>

* fix: improve logging for invalid embedding_dimensions configuration

---------

Signed-off-by: JiangNan <1394485448@qq.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-17 17:53:03 +08:00
whatevertogo ebd232ec8e fix: register_agent decorator NameError (#5765)
* fix: 修改 register_agent 以避免运行时导入 AstrAgentContext

* test: improve register_agent test robustness

- Add fixture for llm_tools cleanup to avoid test interference
- Use multiple import patterns to make guard more robust to refactors
- Add assertion to verify decorated coroutine is wired as handoff handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 删除测试文件: 移除 register_agent 装饰器的运行时行为测试

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-17 16:07:30 +08:00
whatevertogo 1fd3d4ce0e fix: subagent lookup failure when using default persona (#5672)
* fix: resolve subagent persona lookup for 'default' and unify resolution logic

- Add PersonaManager.get_persona_v3_by_id() to centralize v3 persona resolution
- Handle 'default' persona_id mapping to DEFAULT_PERSONALITY in subagent orchestrator
- Fix HandoffTool.default_description using agent_name parameter correctly
- Add tests for default persona in subagent config and tool deduplication

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: simplify get_default_persona_v3 using get_persona_v3_by_id

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-03-17 15:42:15 +08:00
linzhengtian 26d69c96d1 fix: reading skills on Windows (#6490)
There is an issue with reading the skill directory on the Windows system, which results in a high probability of files under the skill directory being unrecognizable, now fix it.
2026-03-17 15:12:02 +08:00
YYMa 3dcdb8b29c chore: remove deprecated version field from compose.yml (#5495)
The version field is no longer required in Docker Compose v2 and has been deprecated.
2026-03-17 14:20:35 +08:00
dependabot[bot] 437adead28 chore(deps): bump the github-actions group with 2 updates (#6461)
Bumps the github-actions group with 2 updates: [ncipollo/release-action](https://github.com/ncipollo/release-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `ncipollo/release-action` from 1.20.0 to 1.21.0
- [Release notes](https://github.com/ncipollo/release-action/releases)
- [Commits](https://github.com/ncipollo/release-action/compare/v1.20.0...v1.21.0)

Updates `actions/github-script` from 7 to 8
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

---
updated-dependencies:
- dependency-name: ncipollo/release-action
  dependency-version: 1.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 13:53:37 +08:00
Rhonin Wang d5b98b353c fix: parse multiline frontmatter description in SKILL.md (#6460)
* fix(skills): support multiline frontmatter descriptions

* fix(skills): 修复多行 frontmatter 描述解析

* style(skills): clean up frontmatter parser follow-ups

---------

Co-authored-by: RhoninSeiei <RhoninSeiei@users.noreply.github.com>
2026-03-17 13:53:16 +08:00
Yufeng He acbc5150cf fix: SQLite 'database is locked' by adding busy timeout (#6474)
The async engine is created without a busy timeout, so concurrent
writes (agent responses, metrics, session updates) fail instantly
with 'database is locked' instead of waiting for the lock.

Add connect_args={'timeout': 30} for SQLite engines so the driver
waits up to 30 seconds for the write lock. Combined with the existing
WAL journal mode, this handles the typical concurrent write bursts
from agent + metrics + session operations.

Fixes #6443
2026-03-17 12:56:34 +08:00
Ruochen Pan 85cfd62014 feat: localize session management group & interval method texts (#6471)
* fix(ui): localize session management group texts

Replace hardcoded Chinese strings in SessionManagementPage with i18n
lookups for group management labels, dialogs, and action feedback.

Add and align translation keys in en-US, ru-RU, and zh-CN for group
management and batch operation messages to ensure consistent multilingual
UI behavior.

* fix(ui): localize interval method hint text
2026-03-17 10:21:55 +08:00
LIghtJUNction 1c7c2ee0cd chore: Delete .github/workflows/pr-checklist-check.yml 2026-03-17 10:18:08 +08:00
Soulter ed47420678 ci: add pr check 2026-03-17 01:07:22 +08:00
44 changed files with 1579 additions and 453 deletions
+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.20.0 uses: ncipollo/release-action@v1.21.0
with: with:
tag: release-${{ github.sha }} tag: release-${{ github.sha }}
owner: AstrBotDevs owner: AstrBotDevs
-45
View File
@@ -1,45 +0,0 @@
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
@@ -0,0 +1,53 @@
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
@@ -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 {self.name} agent to handle the request." return f"Delegate tasks to {agent_name} agent to handle the request."
+3 -8
View File
@@ -390,14 +390,9 @@ 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_tools = next( persona = plugin_context.persona_manager.get_persona_v3_by_id(pid)
( if persona is not None:
p.get("tools") persona_tools = persona.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
+18 -7
View File
@@ -213,13 +213,24 @@ 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]:
if ":" not in line: frontmatter = "\n".join(lines[1:end_idx])
continue try:
key, value = line.split(":", 1) import yaml
if key.strip().lower() == "description": except ImportError:
return value.strip().strip('"').strip("'") return ""
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]:
-6
View File
@@ -463,7 +463,6 @@ 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,
@@ -875,11 +874,6 @@ 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,10 +33,18 @@ 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,
+17 -6
View File
@@ -44,6 +44,22 @@ 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,
@@ -54,12 +70,7 @@ class PersonaManager:
"default_personality", "default_personality",
"default", "default",
) )
if not default_persona_id or default_persona_id == "default": return self.get_persona_v3_by_id(default_persona_id) or DEFAULT_PERSONALITY
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,11 +13,28 @@ 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(
@@ -57,35 +74,26 @@ 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, payload: dict) -> bool: def _should_ignore_event_by_bot_nickname(self, author_id: str) -> bool:
bot_nickname = self.kook_config.bot_nickname.strip() return self.client.bot_id == author_id
if not bot_nickname:
return False
author = payload.get("extra", {}).get("author", {}) async def _on_received(self, event: KookMessageEventData):
if not isinstance(author, dict): logger.debug(
return False f'[KOOK] 收到来自"{event.channel_type.name}"渠道的消息, 消息类型为: {event.type.name}({event.type.value})'
)
author_nickname = author.get("nickname") or author.get("username") or "" event_type = event.type
if not isinstance(author_nickname, str): if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD):
author_nickname = str(author_nickname) if self._should_ignore_event_by_bot_nickname(event.author_id):
logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息")
return author_nickname.strip().casefold() == bot_nickname.casefold() return
try:
async def _on_received(self, data: dict): abm = await self.convert_message(event)
logger.debug(f"KOOK 收到数据: {data}") await self.handle_msg(abm)
if "d" in data and data["s"] == 0: except Exception as e:
payload = data["d"] logger.error(f"[KOOK] 消息处理异常: {e}")
event_type = payload.get("type") elif event_type == KookMessageType.SYSTEM:
# 支持type=9(文本)和type=10(卡片) logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"')
if event_type in (9, 10): logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}")
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):
"""主运行循环""" """主运行循环"""
@@ -184,18 +192,26 @@ class KookPlatformAdapter(Platform):
logger.info("[KOOK] 资源清理完成") logger.info("[KOOK] 资源清理完成")
def _parse_kmarkdown_text_message( def _parse_kmarkdown_text_message(
self, data: dict, self_id: str self, data: KookMessageEventData, self_id: str
) -> tuple[list, str]: ) -> tuple[list, str]:
kmarkdown = data.get("extra", {}).get("kmarkdown", {}) kmarkdown = data.extra.kmarkdown
content = data.get("content") or "" content = data.content or ""
raw_content = kmarkdown.get("raw_content") or content if kmarkdown is None:
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.get("mention_part", []) mention_part = kmarkdown.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):
@@ -207,7 +223,7 @@ class KookPlatformAdapter(Platform):
components = [] components = []
cursor = 0 cursor = 0
for match in re.finditer(r"\(met\)([^()]+)\(met\)", content): for match in KOOK_AT_SELECTOR_REGEX.finditer(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:
@@ -254,77 +270,109 @@ class KookPlatformAdapter(Platform):
return components, message_str return components, message_str
def _parse_card_message(self, data: dict) -> tuple[list, str]: def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]:
content = data.get("content", "[]") content = data.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:
if not isinstance(card, dict): for module in card.modules:
continue match module:
for module in card.get("modules", []): case SectionModule():
if not isinstance(module, dict): if content := self._handle_section_text(module):
continue text_parts.append(content)
module_type = module.get("type") case ContainerModule() | ImageGroupModule():
if module_type == "section": urls = self._handle_image_group(module)
section_text = module.get("text", {}).get("content", "") images.extend(urls)
if section_text: text_parts.append(" [image]" * len(urls))
text_parts.append(str(section_text))
continue
if module_type != "container": case HeaderModule():
continue text_parts.append(module.text.content)
for element in module.get("elements", []): case FileModule():
if not isinstance(element, dict): files.append((module.type, module.title, module.src))
continue text_parts.append(f" [{module.type.value}]")
if element.get("type") != "image":
continue
image_src = element.get("src") case _:
if not isinstance(image_src, str): logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}")
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
async def convert_message(self, data: dict) -> AstrBotMessage: def _handle_section_text(self, module: SectionModule) -> str:
"""专门处理 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 abm.raw_message = data.to_dict()
abm.self_id = self.client.bot_id abm.self_id = self.client.bot_id
channel_type = data.get("channel_type") channel_type = data.channel_type
author_id = data.get("author_id", "unknown") author_id = data.author_id
# 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 "GROUP": case KookChannelType.GROUP:
session_id = data.get("target_id") or "unknown" session_id = data.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 "PERSON": case KookChannelType.PERSON:
abm.type = MessageType.FRIEND_MESSAGE abm.type = MessageType.FRIEND_MESSAGE
abm.group_id = "" abm.group_id = ""
abm.session_id = data.get("author_id", "unknown") abm.session_id = data.author_id or "unknown"
case "BROADCAST": case KookChannelType.BROADCAST:
session_id = data.get("target_id") or "unknown" session_id = data.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
@@ -333,28 +381,25 @@ class KookPlatformAdapter(Platform):
abm.sender = MessageMember( abm.sender = MessageMember(
user_id=author_id, user_id=author_id,
nickname=data.get("extra", {}).get("author", {}).get("username", ""), nickname=data.extra.author.username if data.extra.author else "unknown",
) )
abm.message_id = data.get("msg_id", "unknown") abm.message_id = data.msg_id or "unknown"
# 普通文本消息 if data.type == KookMessageType.KMARKDOWN:
if data.get("type") == 9: message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id)
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.get("type")}"') logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.type.name}"')
abm.message_str = "[不支持的消息类型]" abm.message_str = "[不支持的消息类型]"
abm.message = [Plain(text="[不支持的消息类型]")] abm.message = [Plain(text="[不支持的消息类型]")]
+103 -56
View File
@@ -1,6 +1,5 @@
import asyncio import asyncio
import base64 import base64
import json
import os import os
import random import random
import time import time
@@ -9,13 +8,23 @@ 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 KookApiPaths, KookMessageType from .kook_types import (
KookApiPaths,
KookGatewayIndexResponse,
KookHelloEventData,
KookMessageSignal,
KookMessageType,
KookResumeAckEventData,
KookUserMeResponse,
KookWebsocketEvent,
)
class KookClient: class KookClient:
@@ -23,7 +32,8 @@ class KookClient:
# 数据字段 # 数据字段
self.config = config self.config = config
self._bot_id = "" self._bot_id = ""
self._bot_name = "" self._bot_username = ""
self._bot_nickname = ""
# 资源字段 # 资源字段
self._http_client = aiohttp.ClientSession( self._http_client = aiohttp.ClientSession(
@@ -48,37 +58,50 @@ class KookClient:
return self._bot_id return self._bot_id
@property @property
def bot_name(self): def bot_nickname(self):
return self._bot_name return self._bot_nickname
async def get_bot_info(self) -> str: @property
"""获取机器人账号ID""" def bot_username(self):
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(f"[KOOK] 获取机器人账号ID失败,状态码: {resp.status}") logger.error(
return "" f"[KOOK] 获取机器人账号信息失败,状态码: {resp.status} , {await resp.text()}"
)
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
data = await resp.json() if not resp_content.success():
if data.get("code") != 0: logger.error(
logger.error(f"[KOOK] 获取机器人账号ID失败: {data}") f"[KOOK] 获取机器人账号信息失败: {resp_content.model_dump_json()}"
return "" )
return
bot_id: str = data["data"]["id"] bot_id: str = resp_content.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}")
bot_name: str = data["data"]["nickname"] or data["data"]["username"] self._bot_nickname = resp_content.data.nickname
self._bot_name = bot_name self._bot_username = resp_content.data.username
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_name}") logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_nickname}")
return bot_id
except Exception as e: except Exception as e:
logger.error(f"[KOOK] 获取机器人账号ID异常: {e}") logger.error(f"[KOOK] 获取机器人账号信息异常: {e}")
return ""
async def get_gateway_url(self, resume=False, sn=0, session_id=None): async def get_gateway_url(self, resume=False, sn=0, session_id=None) -> str | None:
"""获取网关连接地址""" """获取网关连接地址"""
url = KookApiPaths.GATEWAY_INDEX url = KookApiPaths.GATEWAY_INDEX
@@ -96,14 +119,20 @@ class KookClient:
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}") logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
return None return None
data = await resp.json() resp_content = KookGatewayIndexResponse.from_dict(await resp.json())
if data.get("code") != 0: if not resp_content.success():
logger.error(f"[KOOK] 获取gateway失败: {data}") logger.error(f"[KOOK] 获取gateway失败: {resp_content}")
return None return None
gateway_url: str = data["data"]["url"] gateway_url: str = resp_content.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
@@ -156,7 +185,11 @@ class KookClient:
try: try:
while self.running: while self.running:
try: try:
msg = await asyncio.wait_for(self.ws.recv(), timeout=10) # type: ignore if self.ws is None:
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:
@@ -166,10 +199,15 @@ class KookClient:
continue continue
msg = msg.decode("utf-8") msg = msg.decode("utf-8")
data = json.loads(msg) event = KookWebsocketEvent.from_json(msg)
# 处理不同类型的信令 # 处理不同类型的信令
await self._handle_signal(data) await self._handle_signal(event)
except pydantic.ValidationError as e:
logger.error(f"[KOOK] 解析WebSocket事件数据格式失败: \n{e}")
logger.error(f"[KOOK] 原始响应内容: {msg}")
continue
except asyncio.TimeoutError: except asyncio.TimeoutError:
# 超时检查,继续循环 # 超时检查,继续循环
@@ -187,38 +225,41 @@ class KookClient:
self.running = False self.running = False
self._stop_event.set() self._stop_event.set()
async def _handle_signal(self, data): async def _handle_signal(self, event: KookWebsocketEvent):
"""处理不同类型的信令""" """处理不同类型的信令"""
signal_type = data.get("s") data = event.data
if signal_type == 0: # 事件消息 match event.signal:
# 更新消息序号 case KookMessageSignal.MESSAGE:
if "sn" in data: if event.sn is not None:
self.last_sn = data["sn"] self.last_sn = event.sn
await self.event_callback(data) await self.event_callback(data)
elif signal_type == 1: # HELLO握手 case KookMessageSignal.HELLO:
await self._handle_hello(data) assert isinstance(data, KookHelloEventData)
await self._handle_hello(data)
elif signal_type == 3: # PONG心跳响应 case KookMessageSignal.RESUME_ACK:
await self._handle_pong(data) assert isinstance(data, KookResumeAckEventData)
await self._handle_resume_ack(data)
elif signal_type == 5: # RECONNECT重连指令 case KookMessageSignal.PONG:
await self._handle_reconnect(data) await self._handle_pong()
elif signal_type == 6: # RESUME ACK case KookMessageSignal.RECONNECT:
await self._handle_resume_ack(data) await self._handle_reconnect()
else: case _:
logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}") logger.debug(
f"[KOOK] 未处理的信令类型: {event.signal.name}({event.signal.value})"
)
async def _handle_hello(self, data): async def _handle_hello(self, data: KookHelloEventData):
"""处理HELLO握手""" """处理HELLO握手"""
hello_data = data.get("d", {}) code = data.code
code = hello_data.get("code", 0)
if code == 0: if code == 0:
self.session_id = hello_data.get("session_id") self.session_id = data.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
@@ -228,12 +269,12 @@ class KookClient:
logger.error("[KOOK] Token已过期,需要重新获取") logger.error("[KOOK] Token已过期,需要重新获取")
self.running = False self.running = False
async def _handle_pong(self, data): async def _handle_pong(self):
"""处理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, data): async def _handle_reconnect(self):
"""处理重连指令""" """处理重连指令"""
logger.warning("[KOOK] 收到重连指令") logger.warning("[KOOK] 收到重连指令")
# 清空本地状态 # 清空本地状态
@@ -241,10 +282,9 @@ class KookClient:
self.session_id = None self.session_id = None
self.running = False self.running = False
async def _handle_resume_ack(self, data): async def _handle_resume_ack(self, data: KookResumeAckEventData):
"""处理RESUME确认""" """处理RESUME确认"""
resume_data = data.get("d", {}) self.session_id = data.session_id
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):
@@ -292,9 +332,16 @@ 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 = {"s": 2, "sn": self.last_sn} ping_data = KookWebsocketEvent(
await self.ws.send(json.dumps(ping_data)) # type: ignore signal=KookMessageSignal.PING,
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,7 +9,6 @@ class KookConfig:
# 基础配置 # 基础配置
token: str token: str
bot_nickname: str = ""
enable: bool = False enable: bool = False
id: str = "kook" id: str = "kook"
@@ -41,7 +40,6 @@ 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,6 +27,7 @@ from .kook_types import (
KookCardMessage, KookCardMessage,
KookCardMessageContainer, KookCardMessageContainer,
KookMessageType, KookMessageType,
KookModuleType,
OrderMessage, OrderMessage,
) )
@@ -111,7 +112,7 @@ class KookEvent(AstrMessageEvent):
KookCardMessage( KookCardMessage(
modules=[ modules=[
FileModule( FileModule(
type="audio", type=KookModuleType.AUDIO,
title=title, title=title,
src=url, src=url,
) )
@@ -182,7 +183,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}"') logger.debug(f'[Kook] 跳过空消息,类型为"{item.type.name}"')
continue continue
try: try:
await self.client.send_text( await self.client.send_text(
+319 -55
View File
@@ -1,10 +1,8 @@
import json import json
from dataclasses import field from enum import IntEnum, StrEnum
from enum import IntEnum from typing import Annotated, Any, Literal
from typing import Literal
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic.dataclasses import dataclass
class KookApiPaths: class KookApiPaths:
@@ -25,8 +23,9 @@ 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
@@ -37,6 +36,26 @@ 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"
] ]
@@ -48,43 +67,81 @@ SectionMode = Literal["left", "right"]
CountdownMode = Literal["day", "hour", "second"] CountdownMode = Literal["day", "hour", "second"]
class KookCardColor(str): class KookBaseDataClass(BaseModel):
"""16 进制色值""" model_config = ConfigDict(
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: class KookCardModelBase(KookBaseDataClass):
"""卡片模块基类""" """卡片模块基类"""
type: str type: str
@dataclass
class PlainTextElement(KookCardModelBase): class PlainTextElement(KookCardModelBase):
content: str content: str
type: str = "plain-text" type: Literal[KookModuleType.PLAIN_TEXT] = KookModuleType.PLAIN_TEXT
emoji: bool = True emoji: bool = True
@dataclass
class KmarkdownElement(KookCardModelBase): class KmarkdownElement(KookCardModelBase):
content: str content: str
type: str = "kmarkdown" type: Literal[KookModuleType.KMARKDOWN] = KookModuleType.KMARKDOWN
@dataclass
class ImageElement(KookCardModelBase): class ImageElement(KookCardModelBase):
src: str src: str
type: str = "image" type: Literal[KookModuleType.IMAGE] = KookModuleType.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: str = "button" type: Literal[KookModuleType.BUTTON] = KookModuleType.BUTTON
theme: ThemeType = "primary" theme: ThemeType = "primary"
value: str = "" value: str = ""
"""当为 link 时,会跳转到 value 代表的链接; """当为 link 时,会跳转到 value 代表的链接;
@@ -96,93 +153,88 @@ 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: str = "paragraph" type: Literal["paragraph"] = "paragraph"
cols: int = 1 cols: int = 1
"""范围是 1-3 , 移动端忽略此参数""" """范围是 1-3 , 移动端忽略此参数"""
@dataclass
class HeaderModule(KookCardModelBase): class HeaderModule(KookCardModelBase):
text: PlainTextElement text: PlainTextElement
type: str = "header" type: Literal[KookModuleType.HEADER] = KookModuleType.HEADER
@dataclass
class SectionModule(KookCardModelBase): class SectionModule(KookCardModelBase):
text: PlainTextElement | KmarkdownElement | ParagraphStructure text: PlainTextElement | KmarkdownElement | ParagraphStructure
type: str = "section" type: Literal[KookModuleType.SECTION] = KookModuleType.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: str = "image-group" type: Literal[KookModuleType.IMAGE_GROUP] = KookModuleType.IMAGE_GROUP
@dataclass
class ContainerModule(KookCardModelBase): class ContainerModule(KookCardModelBase):
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。""" """1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
elements: list[ImageElement] elements: list[ImageElement]
type: str = "container" type: Literal[KookModuleType.CONTAINER] = KookModuleType.CONTAINER
@dataclass
class ActionGroupModule(KookCardModelBase): class ActionGroupModule(KookCardModelBase):
"""用来放按钮的模块"""
elements: list[ButtonElement] elements: list[ButtonElement]
type: str = "action-group" type: Literal[KookModuleType.ACTION_GROUP] = KookModuleType.ACTION_GROUP
@dataclass
class ContextModule(KookCardModelBase): class ContextModule(KookCardModelBase):
elements: list[PlainTextElement | KmarkdownElement | ImageElement] elements: list[PlainTextElement | KmarkdownElement | ImageElement]
"""最多包含10个元素""" """最多包含10个元素"""
type: str = "context" type: Literal[KookModuleType.CONTEXT] = KookModuleType.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["file", "audio", "video"] = "file" type: Literal[KookModuleType.FILE, KookModuleType.AUDIO, KookModuleType.VIDEO] = (
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: str = "countdown" type: Literal[KookModuleType.COUNTDOWN] = KookModuleType.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: str = "invite" type: Literal[KookModuleType.INVITE] = KookModuleType.INVITE
# 所有模块的联合类型 # 所有模块的联合类型
AnyModule = ( AnyModule = Annotated[
HeaderModule HeaderModule
| SectionModule | SectionModule
| ImageGroupModule | ImageGroupModule
@@ -192,34 +244,29 @@ AnyModule = (
| DividerModule | DividerModule
| FileModule | FileModule
| CountdownModule | CountdownModule
| InviteModule | InviteModule,
) Field(discriminator="type"),
]
class KookCardMessage(BaseModel): class KookCardMessage(KookBaseDataClass):
"""卡片定义文档详见 : 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: str = "card" type: Literal[KookModuleType.CARD] = KookModuleType.CARD
theme: ThemeType | None = None theme: ThemeType | None = None
size: SizeType | None = None size: SizeType | None = None
color: KookCardColor | None = None color: str | None = None
modules: list[AnyModule] = field(default_factory=list) """16 进制色值"""
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后发送出去"""
@@ -232,10 +279,227 @@ 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: class OrderMessage(BaseModel):
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
@@ -40,25 +40,46 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
async def get_embedding(self, text: str) -> list[float]: async def get_embedding(self, text: str) -> list[float]:
"""获取文本的嵌入""" """获取文本的嵌入"""
kwargs = self._embedding_kwargs()
embedding = await self.client.embeddings.create( embedding = await self.client.embeddings.create(
input=text, input=text,
model=self.model, model=self.model,
dimensions=self.get_dim(), **kwargs,
) )
return embedding.data[0].embedding return embedding.data[0].embedding
async def get_embeddings(self, text: list[str]) -> list[list[float]]: async def get_embeddings(self, text: list[str]) -> list[list[float]]:
"""批量获取文本的嵌入""" """批量获取文本的嵌入"""
kwargs = self._embedding_kwargs()
embeddings = await self.client.embeddings.create( embeddings = await self.client.embeddings.create(
input=text, input=text,
model=self.model, model=self.model,
dimensions=self.get_dim(), **kwargs,
) )
return [item.embedding for item in embeddings.data] return [item.embedding for item in embeddings.data]
def _embedding_kwargs(self) -> dict:
"""构建嵌入请求的可选参数"""
kwargs = {}
if "embedding_dimensions" in self.provider_config:
try:
kwargs["dimensions"] = int(self.provider_config["embedding_dimensions"])
except (ValueError, TypeError):
logger.warning(
f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored."
)
return kwargs
def get_dim(self) -> int: def get_dim(self) -> int:
"""获取向量的维度""" """获取向量的维度"""
return int(self.provider_config.get("embedding_dimensions", 1024)) if "embedding_dimensions" in self.provider_config:
try:
return int(self.provider_config["embedding_dimensions"])
except (ValueError, TypeError):
logger.warning(
f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored."
)
return 0
async def terminate(self): async def terminate(self):
if self.client: if self.client:
+16 -8
View File
@@ -11,6 +11,8 @@ 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,
@@ -69,13 +71,19 @@ 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]:
if ":" not in line: frontmatter = "\n".join(lines[1:end_idx])
continue try:
key, value = line.split(":", 1) payload = yaml.safe_load(frontmatter) or {}
if key.strip().lower() == "description": except yaml.YAMLError:
return value.strip().strip('"').strip("'") return ""
return "" if not isinstance(payload, dict):
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
@@ -128,7 +136,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'"{path}"' path_arg = f'"{os.path.normpath(path)}"'
else: else:
command = "cat" command = "cat"
path_arg = shlex.quote(path) path_arg = shlex.quote(path)
+5 -8
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 TYPE_CHECKING, Any from typing import Any
import docstring_parser import docstring_parser
@@ -15,9 +15,6 @@ 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
@@ -619,7 +616,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[AstrAgentContext]) -> None: def __init__(self, agent: Agent[Any]) -> None:
self._agent = agent self._agent = agent
@@ -627,7 +624,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[AstrAgentContext] | None = None, run_hooks: BaseAgentRunHooks[Any] | None = None,
): ):
"""注册一个 Agent """注册一个 Agent
@@ -641,12 +638,12 @@ def register_agent(
tools_ = tools or [] tools_ = tools or []
def decorator(awaitable: Callable[..., Awaitable[Any]]): def decorator(awaitable: Callable[..., Awaitable[Any]]):
AstrAgent = Agent[AstrAgentContext] AstrAgent = Agent[Any]
agent = AstrAgent( agent = AstrAgent(
name=name, name=name,
instructions=instruction, instructions=instruction,
tools=tools_, tools=tools_,
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](), run_hooks=run_hooks or BaseAgentRunHooks[Any](),
) )
handoff_tool = HandoffTool(agent=agent) handoff_tool = HandoffTool(agent=agent)
handoff_tool.handler = awaitable handoff_tool.handler = awaitable
+22 -16
View File
@@ -1,13 +1,16 @@
from __future__ import annotations from __future__ import annotations
from typing import Any import copy
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.
@@ -43,15 +46,14 @@ class SubAgentOrchestrator:
continue continue
persona_id = item.get("persona_id") persona_id = item.get("persona_id")
persona_data = None if persona_id is not None:
if persona_id: persona_id = str(persona_id).strip() or None
try: persona_data = self._persona_mgr.get_persona_v3_by_id(persona_id)
persona_data = await self._persona_mgr.get_persona(persona_id) if persona_id and persona_data is None:
except StopIteration: logger.warning(
logger.warning( "SubAgent persona %s not found, fallback to inline prompt.",
"SubAgent persona %s not found, fallback to inline prompt.", persona_id,
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()
@@ -62,11 +64,15 @@ class SubAgentOrchestrator:
begin_dialogs = None begin_dialogs = None
if persona_data: if persona_data:
instructions = persona_data.system_prompt or instructions prompt = str(persona_data.get("prompt", "")).strip()
begin_dialogs = persona_data.begin_dialogs if prompt:
tools = persona_data.tools instructions = prompt
if public_description == "" and persona_data.system_prompt: begin_dialogs = copy.deepcopy(
public_description = persona_data.system_prompt[:120] persona_data.get("_begin_dialogs_processed")
)
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):
-2
View File
@@ -1,5 +1,3 @@
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,11 +619,6 @@
"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",
@@ -851,7 +846,7 @@
}, },
"interval_method": { "interval_method": {
"description": "Interval Method", "description": "Interval Method",
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。" "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."
}, },
"interval": { "interval": {
"description": "Random Interval Time", "description": "Random Interval Time",
@@ -93,24 +93,6 @@
"batchDeleteConfirm": { "batchDeleteConfirm": {
"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": {
"title": "Batch Operations",
"hint": "Quick batch modify session settings",
"scope": "Apply to",
"scopeSelected": "Selected sessions",
"scopeAll": "All sessions",
"scopeGroup": "All groups",
"scopePrivate": "All private chats",
"llmStatus": "LLM Status",
"ttsStatus": "TTS Status",
"chatProvider": "Chat Model",
"ttsProvider": "TTS Model",
"apply": "Apply Changes"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
}, },
"batchOperations": { "batchOperations": {
"title": "Batch Operations", "title": "Batch Operations",
@@ -126,6 +108,25 @@
"ttsProvider": "TTS Model", "ttsProvider": "TTS Model",
"apply": "Apply Changes" "apply": "Apply Changes"
}, },
"groups": {
"title": "Group Management",
"count": "{count} groups",
"addToGroup": "Add to Group",
"create": "Create Group",
"edit": "Edit Group",
"name": "Group Name",
"sessionsCount": "{count} sessions",
"empty": "No groups yet. Click 'Create Group' to create one.",
"availableSessions": "Available Sessions ({count})",
"selectedSessions": "Selected Sessions ({count})",
"searchPlaceholder": "Search...",
"noMatch": "No matches",
"noMembers": "No members",
"customGroupDivider": "── Custom Groups ──",
"customGroupOption": "📁 {name} ({count})",
"groupOption": "{name} ({count} sessions)",
"deleteConfirm": "Are you sure you want to delete group \"{name}\"?"
},
"status": { "status": {
"enabled": "Enabled", "enabled": "Enabled",
"disabled": "Disabled" "disabled": "Disabled"
@@ -142,7 +143,16 @@
"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",
"batchUpdateSuccess": "Batch update success" "groupNameRequired": "Group name cannot be empty",
"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,6 +108,25 @@
"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": "Выключено"
@@ -124,7 +143,16 @@
"noChanges": "Изменений не обнаружено", "noChanges": "Изменений не обнаружено",
"batchDeleteSuccess": "Массовое удаление выполнено", "batchDeleteSuccess": "Массовое удаление выполнено",
"batchDeleteError": "Ошибка массового удаления", "batchDeleteError": "Ошибка массового удаления",
"selectSessionsFirst": "Пожалуйста, сначала выберите сессии",
"selectAtLeastOneConfig": "Пожалуйста, выберите хотя бы одну настройку для изменения",
"batchUpdateSuccess": "Пакетное обновление успешно выполнено",
"partialUpdateFailed": "Некоторые обновления не выполнены",
"batchUpdateError": "Ошибка пакетного обновления", "batchUpdateError": "Ошибка пакетного обновления",
"batchUpdateSuccess": "Пакетное обновление успешно выполнено" "groupNameRequired": "Имя группы не может быть пустым",
"saveGroupError": "Ошибка сохранения группы",
"deleteGroupError": "Ошибка удаления группы",
"selectSessionsToAddFirst": "Пожалуйста, сначала выберите сессии для добавления",
"addToGroupSuccess": "Добавлено сессий в группу: {count}",
"addToGroupError": "Ошибка добавления в группу"
} }
} }
@@ -621,11 +621,6 @@
"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,6 +108,25 @@
"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": "禁用"
@@ -123,6 +142,17 @@
"deleteError": "删除失败", "deleteError": "删除失败",
"noChanges": "没有需要保存的更改", "noChanges": "没有需要保存的更改",
"batchDeleteSuccess": "批量删除成功", "batchDeleteSuccess": "批量删除成功",
"batchDeleteError": "批量删除失败" "batchDeleteError": "批量删除失败",
"selectSessionsFirst": "请先选择要操作的会话",
"selectAtLeastOneConfig": "请至少选择一项要修改的配置",
"batchUpdateSuccess": "批量更新成功",
"partialUpdateFailed": "部分更新失败",
"batchUpdateError": "批量更新失败",
"groupNameRequired": "分组名称不能为空",
"saveGroupError": "保存分组失败",
"deleteGroupError": "删除分组失败",
"selectSessionsToAddFirst": "请先选择要添加的会话",
"addToGroupSuccess": "已添加 {count} 个会话到分组",
"addToGroupError": "添加失败"
} }
} }
+35 -32
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">分组管理</span> <span class="text-h6">{{ tm('groups.title') }}</span>
<v-chip size="small" class="ml-2" color="secondary" variant="outlined"> <v-chip size="small" class="ml-2" color="secondary" variant="outlined">
{{ groups.length }} 个分组 {{ tm('groups.count', { count: 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>{{ g.name }} ({{ g.umo_count }})</v-list-item-title> <v-list-item-title>{{ tm('groups.customGroupOption', { name: g.name, count: 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">{{ group.umo_count }} 个会话</div> <div class="text-caption text-grey">{{ tm('groups.sessionsCount', { count: 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' ? '新建分组' : '编辑分组' }} {{ groupDialogMode === 'create' ? tm('groups.create') : tm('groups.edit') }}
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<v-text-field v-model="editingGroup.name" label="分组名称" variant="outlined" hide-details class="mb-4"></v-text-field> <v-text-field v-model="editingGroup.name" :label="tm('groups.name')" 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">可选会话 ({{ unselectedUmos.length }})</div> <div class="text-subtitle-2 mb-2">{{ tm('groups.availableSessions', { count: unselectedUmos.length }) }}</div>
<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-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-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">无匹配项</v-list-item-title> <v-list-item-title class="text-caption text-grey text-center">{{ tm('groups.noMatch') }}</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">已选会话 ({{ editingGroup.umos.length }})</div> <div class="text-subtitle-2 mb-2">{{ tm('groups.selectedSessions', { count: editingGroup.umos.length }) }}</div>
<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-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-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">暂无成员</v-list-item-title> <v-list-item-title class="text-caption text-grey text-center">{{ tm('groups.noMembers') }}</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">取消</v-btn> <v-btn variant="text" @click="groupDialog = false">{{ tm('buttons.cancel') }}</v-btn>
<v-btn color="primary" variant="tonal" @click="saveGroup">保存</v-btn> <v-btn color="primary" variant="tonal" @click="saveGroup">{{ tm('buttons.save') }}</v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
@@ -721,9 +721,12 @@ export default {
] ]
// //
if (this.groups.length > 0) { if (this.groups.length > 0) {
options.push({ label: '── 自定义分组 ──', value: '_divider', disabled: true }) options.push({ label: this.tm('groups.customGroupDivider'), value: '_divider', disabled: true })
this.groups.forEach(g => { this.groups.forEach(g => {
options.push({ label: `📁 ${g.name} (${g.umo_count})`, value: `custom_group:${g.id}` }) options.push({
label: this.tm('groups.customGroupOption', { name: g.name, count: g.umo_count }),
value: `custom_group:${g.id}`
})
}) })
} }
return options return options
@@ -731,7 +734,7 @@ export default {
groupOptions() { groupOptions() {
return this.groups.map(g => ({ return this.groups.map(g => ({
label: `${g.name} (${g.umo_count} 个会话)`, label: this.tm('groups.groupOption', { name: g.name, count: g.umo_count }),
value: g.id value: g.id
})) }))
}, },
@@ -1331,7 +1334,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.showError(this.tm('messages.selectSessionsFirst'))
this.batchUpdating = false this.batchUpdating = false
return return
} }
@@ -1371,7 +1374,7 @@ export default {
} }
if (tasks.length === 0) { if (tasks.length === 0) {
this.showError('请至少选择一项要修改的配置') this.showError(this.tm('messages.selectAtLeastOneConfig'))
this.batchUpdating = false this.batchUpdating = false
return return
} }
@@ -1380,17 +1383,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.showSuccess(this.tm('messages.batchUpdateSuccess'))
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.showError(this.tm('messages.partialUpdateFailed'))
} }
} catch (error) { } catch (error) {
this.showError(error.response?.data?.message || '批量更新失败') this.showError(error.response?.data?.message || this.tm('messages.batchUpdateError'))
} }
this.batchUpdating = false this.batchUpdating = false
}, },
@@ -1477,7 +1480,7 @@ export default {
async saveGroup() { async saveGroup() {
if (!this.editingGroup.name.trim()) { if (!this.editingGroup.name.trim()) {
this.showError('分组名称不能为空') this.showError(this.tm('messages.groupNameRequired'))
return return
} }
@@ -1504,12 +1507,12 @@ export default {
this.showError(response.data.message) this.showError(response.data.message)
} }
} catch (error) { } catch (error) {
this.showError(error.response?.data?.message || '保存分组失败') this.showError(error.response?.data?.message || this.tm('messages.saveGroupError'))
} }
}, },
async deleteGroup(group) { async deleteGroup(group) {
const message = `确定要删除分组 "${group.name}" 吗?` const message = this.tm('groups.deleteConfirm', { name: group.name })
if (!(await askForConfirmationDialog(message, this.confirmDialog))) return if (!(await askForConfirmationDialog(message, this.confirmDialog))) return
try { try {
@@ -1521,7 +1524,7 @@ export default {
this.showError(response.data.message) this.showError(response.data.message)
} }
} catch (error) { } catch (error) {
this.showError(error.response?.data?.message || '删除分组失败') this.showError(error.response?.data?.message || this.tm('messages.deleteGroupError'))
} }
}, },
@@ -1532,7 +1535,7 @@ export default {
async addSelectedToGroup(groupId) { async addSelectedToGroup(groupId) {
if (this.selectedItems.length === 0) { if (this.selectedItems.length === 0) {
this.showError('请先选择要添加的会话') this.showError(this.tm('messages.selectSessionsToAddFirst'))
return return
} }
@@ -1542,13 +1545,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.selectedItems.length} 个会话到分组`) this.showSuccess(this.tm('messages.addToGroupSuccess', { count: 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.showError(error.response?.data?.message || this.tm('messages.addToGroupError'))
} }
}, },
}, },
+48
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import copy
import io import io
import os import os
import sys import sys
@@ -107,6 +108,53 @@ 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
+28 -28
View File
@@ -4,97 +4,97 @@
"size": "lg", "size": "lg",
"modules": [ "modules": [
{ {
"type": "header",
"text": { "text": {
"content": "test1",
"type": "plain-text", "type": "plain-text",
"content": "test1",
"emoji": true "emoji": true
}, }
"type": "header"
}, },
{ {
"text": {
"content": "test2",
"type": "kmarkdown"
},
"type": "section", "type": "section",
"text": {
"type": "kmarkdown",
"content": "test2"
},
"mode": "left" "mode": "left"
}, },
{ {
"type": "divider" "type": "divider"
}, },
{ {
"type": "section",
"text": { "text": {
"type": "paragraph",
"fields": [ "fields": [
{ {
"content": "test3", "type": "kmarkdown",
"type": "kmarkdown" "content": "test3"
}, },
{ {
"content": "**test4**", "type": "kmarkdown",
"type": "kmarkdown" "content": "**test4**"
} }
], ],
"type": "paragraph",
"cols": 2 "cols": 2
}, },
"type": "section",
"mode": "left" "mode": "left"
}, },
{ {
"type": "image-group",
"elements": [ "elements": [
{ {
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
"type": "image", "type": "image",
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
"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"
}, },
{ {
"endTime": 1772343427360,
"type": "countdown", "type": "countdown",
"endTime": 1772343427360,
"startTime": 1772343378259, "startTime": 1772343378259,
"mode": "second" "mode": "second"
}, },
{ {
"type": "action-group",
"elements": [ "elements": [
{ {
"text": "点我测试回调",
"type": "button", "type": "button",
"text": "点我测试回调",
"theme": "primary", "theme": "primary",
"value": "btn_clicked", "value": "btn_clicked",
"click": "return-val" "click": "return-val"
}, },
{ {
"text": "访问官网",
"type": "button", "type": "button",
"text": "访问官网",
"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": [
{ {
"content": "test6",
"type": "plain-text", "type": "plain-text",
"content": "test6",
"emoji": true "emoji": true
} }
], ]
"type": "context"
}, },
{ {
"code": "test7", "type": "invite",
"type": "invite" "code": "test7"
} }
] ]
} }
@@ -0,0 +1,119 @@
{
"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
}
@@ -0,0 +1,8 @@
{
"s": 1,
"d": {
"sessionId": "67d7d497-2b10-4849-9c2c-dda2fe58ed60",
"session_id": "67d7d497-2b10-4849-9c2c-dda2fe58ed60",
"code": 0
}
}
@@ -0,0 +1,72 @@
{
"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
}
@@ -0,0 +1,79 @@
{
"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
}
@@ -0,0 +1,4 @@
{
"s": 2,
"sn": 0
}
@@ -0,0 +1,3 @@
{
"s": 3
}
@@ -0,0 +1,64 @@
{
"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
}
@@ -0,0 +1,31 @@
{
"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
}
@@ -0,0 +1,7 @@
{
"s": 5,
"d": {
"code": 40108,
"err": "Invalid SN"
}
}
@@ -0,0 +1,4 @@
{
"s": 4,
"sn": 100
}
@@ -0,0 +1,6 @@
{
"s": 6,
"d": {
"session_id": "xxxx-xxxxxx-xxx-xxx"
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
TEST_DATA_DIR = Path(__file__).parent / "data" CURRENT_DIR = Path(__file__).parent
TEST_DATA_DIR = CURRENT_DIR / "data"
+12 -47
View File
@@ -60,7 +60,7 @@ def mock_astrbot_message():
Image("test image"), Image("test image"),
"test image", "test image",
OrderMessage( OrderMessage(
1, index=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(
1, index=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(
1, index=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(
1, index=1,
text='[{"type": "card", "modules": [{"src": "./tests/file.wav", "title": "./tests/file.wav", "type": "audio"}]}]', text='[{"type": "card", "modules": [{"type": "audio", "src": "./tests/file.wav", "title": "./tests/file.wav"}]}]',
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(
1, index=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(
1, index=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(
1, index=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(
1, index=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(
1, index=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[Exception] | None, expected_error: type[BaseException] | None,
): ):
client = await mock_kook_client( client = await mock_kook_client(
upload_asset_return, upload_asset_return,
@@ -185,39 +185,4 @@ 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())
+42 -1
View File
@@ -16,6 +16,9 @@ from astrbot.core.platform.sources.kook.kook_types import (
InviteModule, InviteModule,
KmarkdownElement, KmarkdownElement,
KookCardMessage, KookCardMessage,
KookMessageSignal,
KookModuleType,
KookWebsocketEvent,
ParagraphStructure, ParagraphStructure,
PlainTextElement, PlainTextElement,
SectionModule, SectionModule,
@@ -77,7 +80,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="file", type=KookModuleType.FILE,
), ),
CountdownModule( CountdownModule(
endTime=1772343427360, endTime=1772343427360,
@@ -105,3 +108,41 @@ 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,6 +49,39 @@ 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,6 +39,7 @@ 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
@@ -538,6 +539,63 @@ 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
@@ -0,0 +1,110 @@
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