Compare commits

..

1 Commits

Author SHA1 Message Date
Soulter 2778edbf4b feat: add send shortcut configuration and localization support for chat input 2026-03-14 21:24:18 +08:00
77 changed files with 749 additions and 2072 deletions
+5 -12
View File
@@ -3,8 +3,8 @@
### Modifications / 改动点
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。
<!-- If your changes is a breaking change, please uncheck the checkbox above -->
@@ -21,14 +21,7 @@
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
- [ ] 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
- [ ] 👀 My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
/ 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
- [ ] 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
/ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt``pyproject.toml` 文件相应位置。
- [ ] 😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt``pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Create GitHub Release
if: github.event_name == 'push'
uses: ncipollo/release-action@v1.21.0
uses: ncipollo/release-action@v1.20.0
with:
tag: release-${{ github.sha }}
owner: AstrBotDevs
-53
View File
@@ -1,53 +0,0 @@
name: PR Title Check
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
jobs:
title-format:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Validate PR title
uses: actions/github-script@v8
with:
script: |
const title = (context.payload.pull_request.title || "").trim();
// allow only:
// feat: xxx
// feat(scope): xxx
const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i;
const isValid = pattern.test(title);
const isSameRepo =
context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
if (!isValid) {
if (isSameRepo) {
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: [
"⚠️ PR title format check failed.",
"Required formats:",
"- `feat: xxx`",
"- `feat(scope): xxx`",
"Please update your PR title and push again."
].join("\n")
});
} catch (e) {
core.warning(`Failed to post PR title comment: ${e.message}`);
}
} else {
core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
}
}
if (!isValid) {
core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx.");
}
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.20.1"
__version__ = "4.20.0"
+1 -1
View File
@@ -62,4 +62,4 @@ class HandoffTool(FunctionTool, Generic[TContext]):
def default_description(self, agent_name: str | None) -> str:
agent_name = agent_name or "another"
return f"Delegate tasks to {agent_name} agent to handle the request."
return f"Delegate tasks to {self.name} agent to handle the request."
+8 -3
View File
@@ -390,9 +390,14 @@ async def _ensure_persona_and_skills(
persona_tools = None
pid = a.get("persona_id")
if pid:
persona = plugin_context.persona_manager.get_persona_v3_by_id(pid)
if persona is not None:
persona_tools = persona.get("tools")
persona_tools = next(
(
p.get("tools")
for p in plugin_context.persona_manager.personas_v3
if p["name"] == pid
),
None,
)
tools = a.get("tools", [])
if persona_tools is not None:
tools = persona_tools
+1 -6
View File
@@ -188,12 +188,7 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
@dataclass
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
name: str = "send_message_to_user"
description: str = (
"Send message to the user. "
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
)
description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation."
parameters: dict = Field(
default_factory=lambda: {
+7 -18
View File
@@ -213,24 +213,13 @@ def parse_description(text: str) -> str:
break
if end_idx is None:
return ""
frontmatter = "\n".join(lines[1:end_idx])
try:
import yaml
except ImportError:
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()
for line in lines[1:end_idx]:
if ":" not in line:
continue
key, value = line.split(":", 1)
if key.strip().lower() == "description":
return value.strip().strip('"').strip("'")
return ""
def load_managed_skills() -> list[str]:
+1 -4
View File
@@ -164,10 +164,7 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
"type": "object",
"properties": {
"payload": {
"anyOf": [
{"type": "object"},
{"type": "array", "items": {"type": "object"}},
],
"anyOf": [{"type": "object"}, {"type": "array"}],
"description": (
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
"This only stores content and returns payload_ref; it does not create a candidate or release."
+7 -13
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.20.1"
VERSION = "4.20.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -463,6 +463,7 @@ CONFIG_METADATA_2 = {
"type": "kook",
"enable": False,
"kook_bot_token": "",
"kook_bot_nickname": "",
"kook_reconnect_delay": 1,
"kook_max_reconnect_delay": 60,
"kook_max_retry_delay": 60,
@@ -874,6 +875,11 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
},
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息以避免广播风暴。",
},
"kook_reconnect_delay": {
"description": "重连延迟",
"type": "int",
@@ -1126,18 +1132,6 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"MiniMax": {
"id": "minimax",
"provider": "minimax",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.minimaxi.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"xAI": {
"id": "xai",
"provider": "xai",
-8
View File
@@ -33,18 +33,10 @@ class BaseDatabase(abc.ABC):
DATABASE_URL = ""
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.DATABASE_URL,
echo=False,
future=True,
connect_args=connect_args,
)
self.AsyncSessionLocal = async_sessionmaker(
self.engine,
+6 -17
View File
@@ -44,22 +44,6 @@ class PersonaManager:
raise ValueError(f"Persona with ID {persona_id} does not exist.")
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(
self,
umo: str | MessageSession | None = None,
@@ -70,7 +54,12 @@ class PersonaManager:
"default_personality",
"default",
)
return self.get_persona_v3_by_id(default_persona_id) or DEFAULT_PERSONALITY
if not default_persona_id or default_persona_id == "default":
return DEFAULT_PERSONALITY
try:
return next(p for p in self.personas_v3 if p["name"] == default_persona_id)
except Exception:
return DEFAULT_PERSONALITY
async def resolve_selected_persona(
self,
@@ -13,28 +13,11 @@ from astrbot.api.platform import (
PlatformMetadata,
register_platform_adapter,
)
from astrbot.core.message.components import File, Record, Video
from astrbot.core.platform.astr_message_event import MessageSesion
from .kook_client import KookClient
from .kook_config import KookConfig
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(
@@ -74,26 +57,35 @@ class KookPlatformAdapter(Platform):
name="kook", description="KOOK 适配器", id=self.kook_config.id
)
def _should_ignore_event_by_bot_nickname(self, author_id: str) -> bool:
return self.client.bot_id == author_id
def _should_ignore_event_by_bot_nickname(self, payload: dict) -> bool:
bot_nickname = self.kook_config.bot_nickname.strip()
if not bot_nickname:
return False
async def _on_received(self, event: KookMessageEventData):
logger.debug(
f'[KOOK] 收到来自"{event.channel_type.name}"渠道的消息, 消息类型为: {event.type.name}({event.type.value})'
)
event_type = event.type
if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD):
if self._should_ignore_event_by_bot_nickname(event.author_id):
logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息")
return
try:
abm = await self.convert_message(event)
await self.handle_msg(abm)
except Exception as e:
logger.error(f"[KOOK] 消息处理异常: {e}")
elif event_type == KookMessageType.SYSTEM:
logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"')
logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}")
author = payload.get("extra", {}).get("author", {})
if not isinstance(author, dict):
return False
author_nickname = author.get("nickname") or author.get("username") or ""
if not isinstance(author_nickname, str):
author_nickname = str(author_nickname)
return author_nickname.strip().casefold() == bot_nickname.casefold()
async def _on_received(self, data: dict):
logger.debug(f"KOOK 收到数据: {data}")
if "d" in data and data["s"] == 0:
payload = data["d"]
event_type = payload.get("type")
# 支持type=9(文本)和type=10(卡片)
if event_type in (9, 10):
if self._should_ignore_event_by_bot_nickname(payload):
return
try:
abm = await self.convert_message(payload)
await self.handle_msg(abm)
except Exception as e:
logger.error(f"[KOOK] 消息处理异常: {e}")
async def run(self):
"""主运行循环"""
@@ -192,26 +184,18 @@ class KookPlatformAdapter(Platform):
logger.info("[KOOK] 资源清理完成")
def _parse_kmarkdown_text_message(
self, data: KookMessageEventData, self_id: str
self, data: dict, self_id: str
) -> tuple[list, str]:
kmarkdown = data.extra.kmarkdown
content = data.content or ""
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
kmarkdown = data.get("extra", {}).get("kmarkdown", {})
content = data.get("content") or ""
raw_content = kmarkdown.get("raw_content") or content
if not isinstance(content, str):
content = str(content)
if not isinstance(raw_content, str):
raw_content = str(raw_content)
# TODO 后面的pydantic类型替换,以后再来探索吧 :(
mention_name_map: dict[str, str] = {}
mention_part = kmarkdown.mention_part
mention_part = kmarkdown.get("mention_part", [])
if isinstance(mention_part, list):
for item in mention_part:
if not isinstance(item, dict):
@@ -223,7 +207,7 @@ class KookPlatformAdapter(Platform):
components = []
cursor = 0
for match in KOOK_AT_SELECTOR_REGEX.finditer(content):
for match in re.finditer(r"\(met\)([^()]+)\(met\)", content):
if match.start() > cursor:
plain_text = content[cursor : match.start()]
if plain_text:
@@ -270,109 +254,77 @@ class KookPlatformAdapter(Platform):
return components, message_str
def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]:
content = data.content
def _parse_card_message(self, data: dict) -> tuple[list, str]:
content = data.get("content", "[]")
if not isinstance(content, str):
content = str(content)
card_list = KookCardMessageContainer.from_dict(json.loads(content))
card_list = json.loads(content)
text_parts: list[str] = []
images: list[str] = []
files: list[tuple[KookModuleType, str, str]] = []
for card in card_list:
for module in card.modules:
match module:
case SectionModule():
if content := self._handle_section_text(module):
text_parts.append(content)
if not isinstance(card, dict):
continue
for module in card.get("modules", []):
if not isinstance(module, dict):
continue
case ContainerModule() | ImageGroupModule():
urls = self._handle_image_group(module)
images.extend(urls)
text_parts.append(" [image]" * len(urls))
module_type = module.get("type")
if module_type == "section":
section_text = module.get("text", {}).get("content", "")
if section_text:
text_parts.append(str(section_text))
continue
case HeaderModule():
text_parts.append(module.text.content)
if module_type != "container":
continue
case FileModule():
files.append((module.type, module.title, module.src))
text_parts.append(f" [{module.type.value}]")
for element in module.get("elements", []):
if not isinstance(element, dict):
continue
if element.get("type") != "image":
continue
case _:
logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}")
image_src = element.get("src")
if not isinstance(image_src, str):
logger.warning(
f'[KOOK] 处理卡片中的图片时发生错误,图片url "{image_src}" 应该为str类型, 而不是 "{type(image_src)}" '
)
continue
if not image_src.startswith(("http://", "https://")):
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
continue
images.append(image_src)
text = "".join(text_parts)
message = []
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))
for img_url in images:
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
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:
async def convert_message(self, data: dict) -> AstrBotMessage:
abm = AstrBotMessage()
abm.raw_message = data.to_dict()
abm.raw_message = data
abm.self_id = self.client.bot_id
channel_type = data.channel_type
author_id = data.author_id
channel_type = data.get("channel_type")
author_id = data.get("author_id", "unknown")
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
match channel_type:
case KookChannelType.GROUP:
session_id = data.target_id or "unknown"
case "GROUP":
session_id = data.get("target_id") or "unknown"
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = session_id
abm.session_id = session_id
case KookChannelType.PERSON:
case "PERSON":
abm.type = MessageType.FRIEND_MESSAGE
abm.group_id = ""
abm.session_id = data.author_id or "unknown"
case KookChannelType.BROADCAST:
session_id = data.target_id or "unknown"
abm.session_id = data.get("author_id", "unknown")
case "BROADCAST":
session_id = data.get("target_id") or "unknown"
abm.type = MessageType.OTHER_MESSAGE
abm.group_id = session_id
abm.session_id = session_id
@@ -381,25 +333,28 @@ class KookPlatformAdapter(Platform):
abm.sender = MessageMember(
user_id=author_id,
nickname=data.extra.author.username if data.extra.author else "unknown",
nickname=data.get("extra", {}).get("author", {}).get("username", ""),
)
abm.message_id = data.msg_id or "unknown"
abm.message_id = data.get("msg_id", "unknown")
if data.type == KookMessageType.KMARKDOWN:
message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id)
# 普通文本消息
if data.get("type") == 9:
message, message_str = self._parse_kmarkdown_text_message(
data, str(abm.self_id)
)
abm.message = message
abm.message_str = message_str
elif data.type == KookMessageType.CARD:
# 卡片消息
elif data.get("type") == 10:
try:
abm.message, abm.message_str = self._parse_card_message(data)
except Exception as exp:
logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
abm.message_str = "[卡片消息解析失败]"
abm.message = [Plain(text="[卡片消息解析失败]")]
else:
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.type.name}"')
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.get("type")}"')
abm.message_str = "[不支持的消息类型]"
abm.message = [Plain(text="[不支持的消息类型]")]
+56 -103
View File
@@ -1,5 +1,6 @@
import asyncio
import base64
import json
import os
import random
import time
@@ -8,23 +9,13 @@ from pathlib import Path
import aiofiles
import aiohttp
import pydantic
import websockets
from astrbot import logger
from astrbot.core.platform.message_type import MessageType
from .kook_config import KookConfig
from .kook_types import (
KookApiPaths,
KookGatewayIndexResponse,
KookHelloEventData,
KookMessageSignal,
KookMessageType,
KookResumeAckEventData,
KookUserMeResponse,
KookWebsocketEvent,
)
from .kook_types import KookApiPaths, KookMessageType
class KookClient:
@@ -32,8 +23,7 @@ class KookClient:
# 数据字段
self.config = config
self._bot_id = ""
self._bot_username = ""
self._bot_nickname = ""
self._bot_name = ""
# 资源字段
self._http_client = aiohttp.ClientSession(
@@ -58,50 +48,37 @@ class KookClient:
return self._bot_id
@property
def bot_nickname(self):
return self._bot_nickname
def bot_name(self):
return self._bot_name
@property
def bot_username(self):
return self._bot_username
async def get_bot_info(self) -> None:
"""获取机器人账号信息"""
async def get_bot_info(self) -> str:
"""获取机器人账号ID"""
url = KookApiPaths.USER_ME
try:
async with self._http_client.get(url) as resp:
if resp.status != 200:
logger.error(
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
logger.error(f"[KOOK] 获取机器人账号ID失败,状态码: {resp.status}")
return ""
if not resp_content.success():
logger.error(
f"[KOOK] 获取机器人账号信息失败: {resp_content.model_dump_json()}"
)
return
data = await resp.json()
if data.get("code") != 0:
logger.error(f"[KOOK] 获取机器人账号ID失败: {data}")
return ""
bot_id: str = resp_content.data.id
bot_id: str = data["data"]["id"]
self._bot_id = bot_id
logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}")
self._bot_nickname = resp_content.data.nickname
self._bot_username = resp_content.data.username
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_nickname}")
bot_name: str = data["data"]["nickname"] or data["data"]["username"]
self._bot_name = bot_name
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_name}")
return bot_id
except Exception as e:
logger.error(f"[KOOK] 获取机器人账号信息异常: {e}")
logger.error(f"[KOOK] 获取机器人账号ID异常: {e}")
return ""
async def get_gateway_url(self, resume=False, sn=0, session_id=None) -> str | None:
async def get_gateway_url(self, resume=False, sn=0, session_id=None):
"""获取网关连接地址"""
url = KookApiPaths.GATEWAY_INDEX
@@ -119,20 +96,14 @@ class KookClient:
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
return None
resp_content = KookGatewayIndexResponse.from_dict(await resp.json())
if not resp_content.success():
logger.error(f"[KOOK] 获取gateway失败: {resp_content}")
data = await resp.json()
if data.get("code") != 0:
logger.error(f"[KOOK] 获取gateway失败: {data}")
return None
gateway_url: str = resp_content.data.url
gateway_url: str = data["data"]["url"]
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
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:
logger.error(f"[KOOK] 获取gateway异常: {e}")
return None
@@ -185,11 +156,7 @@ class KookClient:
try:
while self.running:
try:
if self.ws is None:
logger.error("[KOOK] WebSocket 对象丢失,结束监听流程。")
break
msg = await asyncio.wait_for(self.ws.recv(), timeout=10)
msg = await asyncio.wait_for(self.ws.recv(), timeout=10) # type: ignore
if isinstance(msg, bytes):
try:
@@ -199,15 +166,10 @@ class KookClient:
continue
msg = msg.decode("utf-8")
event = KookWebsocketEvent.from_json(msg)
data = json.loads(msg)
# 处理不同类型的信令
await self._handle_signal(event)
except pydantic.ValidationError as e:
logger.error(f"[KOOK] 解析WebSocket事件数据格式失败: \n{e}")
logger.error(f"[KOOK] 原始响应内容: {msg}")
continue
await self._handle_signal(data)
except asyncio.TimeoutError:
# 超时检查,继续循环
@@ -225,41 +187,38 @@ class KookClient:
self.running = False
self._stop_event.set()
async def _handle_signal(self, event: KookWebsocketEvent):
async def _handle_signal(self, data):
"""处理不同类型的信令"""
data = event.data
signal_type = data.get("s")
match event.signal:
case KookMessageSignal.MESSAGE:
if event.sn is not None:
self.last_sn = event.sn
await self.event_callback(data)
if signal_type == 0: # 事件消息
# 更新消息序号
if "sn" in data:
self.last_sn = data["sn"]
await self.event_callback(data)
case KookMessageSignal.HELLO:
assert isinstance(data, KookHelloEventData)
await self._handle_hello(data)
elif signal_type == 1: # HELLO握手
await self._handle_hello(data)
case KookMessageSignal.RESUME_ACK:
assert isinstance(data, KookResumeAckEventData)
await self._handle_resume_ack(data)
elif signal_type == 3: # PONG心跳响应
await self._handle_pong(data)
case KookMessageSignal.PONG:
await self._handle_pong()
elif signal_type == 5: # RECONNECT重连指令
await self._handle_reconnect(data)
case KookMessageSignal.RECONNECT:
await self._handle_reconnect()
elif signal_type == 6: # RESUME ACK
await self._handle_resume_ack(data)
case _:
logger.debug(
f"[KOOK] 未处理的信令类型: {event.signal.name}({event.signal.value})"
)
else:
logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}")
async def _handle_hello(self, data: KookHelloEventData):
async def _handle_hello(self, data):
"""处理HELLO握手"""
code = data.code
hello_data = data.get("d", {})
code = hello_data.get("code", 0)
if code == 0:
self.session_id = data.session_id
self.session_id = hello_data.get("session_id")
logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}")
# TODO 重置重连延迟
# self.reconnect_delay = 1
@@ -269,12 +228,12 @@ class KookClient:
logger.error("[KOOK] Token已过期,需要重新获取")
self.running = False
async def _handle_pong(self):
async def _handle_pong(self, data):
"""处理PONG心跳响应"""
self.last_heartbeat_time = time.time()
self.heartbeat_failed_count = 0
async def _handle_reconnect(self):
async def _handle_reconnect(self, data):
"""处理重连指令"""
logger.warning("[KOOK] 收到重连指令")
# 清空本地状态
@@ -282,9 +241,10 @@ class KookClient:
self.session_id = None
self.running = False
async def _handle_resume_ack(self, data: KookResumeAckEventData):
async def _handle_resume_ack(self, data):
"""处理RESUME确认"""
self.session_id = data.session_id
resume_data = data.get("d", {})
self.session_id = resume_data.get("session_id")
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
async def _heartbeat_loop(self):
@@ -332,16 +292,9 @@ class KookClient:
async def _send_ping(self):
"""发送心跳PING"""
if self.ws is None:
logger.warning("[KOOK] 尚未连接kook WebSocket服务器, 跳过发送心跳包流程")
return
try:
ping_data = KookWebsocketEvent(
signal=KookMessageSignal.PING,
data=None,
sn=self.last_sn,
)
await self.ws.send(ping_data.to_json())
ping_data = {"s": 2, "sn": self.last_sn}
await self.ws.send(json.dumps(ping_data)) # type: ignore
except Exception as e:
logger.error(f"[KOOK] 发送心跳失败: {e}")
@@ -9,6 +9,7 @@ class KookConfig:
# 基础配置
token: str
bot_nickname: str = ""
enable: bool = False
id: str = "kook"
@@ -40,6 +41,7 @@ class KookConfig:
# id=config_dict.get("id", "kook"),
enable=config_dict.get("enable", False),
token=config_dict.get("kook_bot_token", ""),
bot_nickname=config_dict.get("kook_bot_nickname", ""),
reconnect_delay=config_dict.get(
"kook_reconnect_delay",
KookConfig.reconnect_delay,
@@ -27,7 +27,6 @@ from .kook_types import (
KookCardMessage,
KookCardMessageContainer,
KookMessageType,
KookModuleType,
OrderMessage,
)
@@ -112,7 +111,7 @@ class KookEvent(AstrMessageEvent):
KookCardMessage(
modules=[
FileModule(
type=KookModuleType.AUDIO,
type="audio",
title=title,
src=url,
)
@@ -183,7 +182,7 @@ class KookEvent(AstrMessageEvent):
if item.reply_id:
reply_id = item.reply_id
if not item.text:
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type.name}"')
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"')
continue
try:
await self.client.send_text(
+55 -319
View File
@@ -1,8 +1,10 @@
import json
from enum import IntEnum, StrEnum
from typing import Annotated, Any, Literal
from dataclasses import field
from enum import IntEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict
from pydantic.dataclasses import dataclass
class KookApiPaths:
@@ -23,9 +25,8 @@ class KookApiPaths:
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
# 定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction
class KookMessageType(IntEnum):
"""定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction"""
TEXT = 1
IMAGE = 2
VIDEO = 3
@@ -36,26 +37,6 @@ class KookMessageType(IntEnum):
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[
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
]
@@ -67,81 +48,43 @@ SectionMode = Literal["left", "right"]
CountdownMode = Literal["day", "hour", "second"]
class KookBaseDataClass(BaseModel):
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 KookCardColor(str):
"""16 进制色值"""
class KookCardModelBase(KookBaseDataClass):
class KookCardModelBase:
"""卡片模块基类"""
type: str
@dataclass
class PlainTextElement(KookCardModelBase):
content: str
type: Literal[KookModuleType.PLAIN_TEXT] = KookModuleType.PLAIN_TEXT
type: str = "plain-text"
emoji: bool = True
@dataclass
class KmarkdownElement(KookCardModelBase):
content: str
type: Literal[KookModuleType.KMARKDOWN] = KookModuleType.KMARKDOWN
type: str = "kmarkdown"
@dataclass
class ImageElement(KookCardModelBase):
src: str
type: Literal[KookModuleType.IMAGE] = KookModuleType.IMAGE
type: str = "image"
alt: str = ""
size: SizeType = "lg"
circle: bool = False
fallbackUrl: str | None = None
@dataclass
class ButtonElement(KookCardModelBase):
text: str
type: Literal[KookModuleType.BUTTON] = KookModuleType.BUTTON
type: str = "button"
theme: ThemeType = "primary"
value: str = ""
"""当为 link 时,会跳转到 value 代表的链接;
@@ -153,88 +96,93 @@ class ButtonElement(KookCardModelBase):
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
@dataclass
class ParagraphStructure(KookCardModelBase):
fields: list[PlainTextElement | KmarkdownElement]
type: Literal["paragraph"] = "paragraph"
type: str = "paragraph"
cols: int = 1
"""范围是 1-3 , 移动端忽略此参数"""
@dataclass
class HeaderModule(KookCardModelBase):
text: PlainTextElement
type: Literal[KookModuleType.HEADER] = KookModuleType.HEADER
type: str = "header"
@dataclass
class SectionModule(KookCardModelBase):
text: PlainTextElement | KmarkdownElement | ParagraphStructure
type: Literal[KookModuleType.SECTION] = KookModuleType.SECTION
type: str = "section"
mode: SectionMode = "left"
accessory: ImageElement | ButtonElement | None = None
@dataclass
class ImageGroupModule(KookCardModelBase):
"""1 到多张图片的组合"""
elements: list[ImageElement]
type: Literal[KookModuleType.IMAGE_GROUP] = KookModuleType.IMAGE_GROUP
type: str = "image-group"
@dataclass
class ContainerModule(KookCardModelBase):
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
elements: list[ImageElement]
type: Literal[KookModuleType.CONTAINER] = KookModuleType.CONTAINER
type: str = "container"
@dataclass
class ActionGroupModule(KookCardModelBase):
"""用来放按钮的模块"""
elements: list[ButtonElement]
type: Literal[KookModuleType.ACTION_GROUP] = KookModuleType.ACTION_GROUP
type: str = "action-group"
@dataclass
class ContextModule(KookCardModelBase):
elements: list[PlainTextElement | KmarkdownElement | ImageElement]
"""最多包含10个元素"""
type: Literal[KookModuleType.CONTEXT] = KookModuleType.CONTEXT
type: str = "context"
@dataclass
class DividerModule(KookCardModelBase):
"""展示分割线用的"""
type: Literal[KookModuleType.DIVIDER] = KookModuleType.DIVIDER
type: str = "divider"
@dataclass
class FileModule(KookCardModelBase):
src: str
title: str = ""
type: Literal[KookModuleType.FILE, KookModuleType.AUDIO, KookModuleType.VIDEO] = (
KookModuleType.FILE
)
type: Literal["file", "audio", "video"] = "file"
cover: str | None = None
"""cover 仅音频有效, 是音频的封面图"""
@dataclass
class CountdownModule(KookCardModelBase):
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
endTime: int
"""毫秒时间戳"""
type: Literal[KookModuleType.COUNTDOWN] = KookModuleType.COUNTDOWN
type: str = "countdown"
startTime: int | None = None
"""毫秒时间戳, 仅当mode为second才有这个字段"""
mode: CountdownMode = "day"
"""mode 主要是倒计时的样式"""
@dataclass
class InviteModule(KookCardModelBase):
code: str
"""邀请链接或者邀请码"""
type: Literal[KookModuleType.INVITE] = KookModuleType.INVITE
type: str = "invite"
# 所有模块的联合类型
AnyModule = Annotated[
AnyModule = (
HeaderModule
| SectionModule
| ImageGroupModule
@@ -244,29 +192,34 @@ AnyModule = Annotated[
| DividerModule
| FileModule
| CountdownModule
| InviteModule,
Field(discriminator="type"),
]
| InviteModule
)
class KookCardMessage(KookBaseDataClass):
class KookCardMessage(BaseModel):
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
若要发送卡片消息,请使用KookCardMessageContainer
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
type: Literal[KookModuleType.CARD] = KookModuleType.CARD
type: str = "card"
theme: ThemeType | None = None
size: SizeType | None = None
color: str | None = None
"""16 进制色值"""
modules: list[AnyModule] = Field(default_factory=list)
color: KookCardColor | None = None
modules: list[AnyModule] = field(default_factory=list)
"""单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50"""
def add_module(self, module: AnyModule):
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]):
"""卡片消息容器(列表),此类型可以直接to_json后发送出去"""
@@ -279,227 +232,10 @@ class KookCardMessageContainer(list[KookCardMessage]):
[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)
class OrderMessage(BaseModel):
@dataclass
class OrderMessage:
index: int
text: str
type: KookMessageType
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
@@ -391,47 +391,6 @@ class QQOfficialPlatformAdapter(Platform):
else:
msg.append(File(name=filename, file=url, url=url))
@staticmethod
def _parse_face_message(content: str) -> str:
"""Parse QQ official face message format and convert to readable text.
QQ official face message format:
<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">
The ext field contains base64-encoded JSON with a 'text' field
describing the emoji (e.g., '[满头问号]').
Args:
content: The message content that may contain face tags.
Returns:
Content with face tags replaced by readable emoji descriptions.
"""
import base64
import json
import re
def replace_face(match):
face_tag = match.group(0)
# Extract ext field from the face tag
ext_match = re.search(r'ext="([^"]*)"', face_tag)
if ext_match:
try:
ext_encoded = ext_match.group(1)
# Decode base64 and parse JSON
ext_decoded = base64.b64decode(ext_encoded).decode("utf-8")
ext_data = json.loads(ext_decoded)
emoji_text = ext_data.get("text", "")
if emoji_text:
return f"[表情:{emoji_text}]"
except Exception:
pass
# Fallback if parsing fails
return "[表情]"
# Match face tags: <faceType=...>
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
@staticmethod
def _parse_from_qqofficial(
message: botpy.message.Message
@@ -457,10 +416,7 @@ class QQOfficialPlatformAdapter(Platform):
abm.group_id = message.group_openid
else:
abm.sender = MessageMember(message.author.user_openid, "")
# Parse face messages to readable text
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
message.content.strip()
)
abm.message_str = message.content.strip()
abm.self_id = "unknown_selfid"
msg.append(At(qq="qq_official"))
msg.append(Plain(abm.message_str))
@@ -476,12 +432,10 @@ class QQOfficialPlatformAdapter(Platform):
else:
abm.self_id = ""
plain_content = QQOfficialPlatformAdapter._parse_face_message(
message.content.replace(
"<@!" + str(abm.self_id) + ">",
"",
).strip()
)
plain_content = message.content.replace(
"<@!" + str(abm.self_id) + ">",
"",
).strip()
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
abm.message = msg
@@ -1,6 +1,5 @@
import asyncio
import logging
import time
from typing import cast
import quart
@@ -40,9 +39,6 @@ class QQOfficialWebhook:
self.client = botpy_client
self.event_queue = event_queue
self.shutdown_event = asyncio.Event()
# Deduplication cache for webhook retry callbacks.
self._seen_event_ids: dict[str, float] = {}
self._dedup_ttl: int = 60 # seconds
async def initialize(self) -> None:
logger.info("正在登录到 QQ 官方机器人...")
@@ -110,22 +106,6 @@ class QQOfficialWebhook:
print(signed)
return signed
event_id = msg.get("id")
if event_id:
now = time.monotonic()
# Lazily evict expired entries to prevent unbounded growth.
expired = [
k
for k, ts in self._seen_event_ids.items()
if now - ts > self._dedup_ttl
]
for k in expired:
del self._seen_event_ids[k]
if event_id in self._seen_event_ids:
logger.debug(f"Duplicate webhook event {event_id!r}, skipping.")
return {"opcode": 12}
self._seen_event_ids[event_id] = now
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
event = msg["t"].lower()
try:
@@ -25,16 +25,6 @@ from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
from astrbot.core.utils.metrics import Metric
def _is_gif(path: str) -> bool:
if path.lower().endswith(".gif"):
return True
try:
with open(path, "rb") as f:
return f.read(6) in (b"GIF87a", b"GIF89a")
except OSError:
return False
class TelegramPlatformEvent(AstrMessageEvent):
# Telegram 的最大消息长度限制
MAX_MESSAGE_LENGTH = 4096
@@ -301,13 +291,7 @@ class TelegramPlatformEvent(AstrMessageEvent):
await client.send_message(text=chunk, **cast(Any, payload))
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
if _is_gif(image_path):
send_coro = client.send_animation
media_kwarg = {"animation": image_path}
else:
send_coro = client.send_photo
media_kwarg = {"photo": image_path}
await send_coro(**media_kwarg, **cast(Any, payload))
await client.send_photo(photo=image_path, **cast(Any, payload))
elif isinstance(i, File):
path = await i.get_file()
name = i.name or os.path.basename(path)
@@ -422,20 +406,12 @@ class TelegramPlatformEvent(AstrMessageEvent):
on_text(i.text)
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
if _is_gif(image_path):
action = ChatAction.UPLOAD_VIDEO
send_coro = self.client.send_animation
media_kwarg = {"animation": image_path}
else:
action = ChatAction.UPLOAD_PHOTO
send_coro = self.client.send_photo
media_kwarg = {"photo": image_path}
await self._send_media_with_action(
self.client,
action,
send_coro,
ChatAction.UPLOAD_PHOTO,
self.client.send_photo,
user_name=user_name,
**media_kwarg,
photo=image_path,
**cast(Any, payload),
)
elif isinstance(i, File):
@@ -440,16 +440,9 @@ class WecomAIBotAdapter(Platform):
)
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
"""从消息数据中提取会话ID
群聊使用 chatid单聊使用 userid
"""
chattype = message_data.get("chattype", "single")
if chattype == "group":
chat_id = message_data.get("chatid", "default_group")
return format_session_id("wecomai", chat_id)
else:
user_id = message_data.get("from", {}).get("userid", "default_user")
return format_session_id("wecomai", user_id)
"""从消息数据中提取会话ID"""
user_id = message_data.get("from", {}).get("userid", "default_user")
return format_session_id("wecomai", user_id)
async def _enqueue_message(
self,
-2
View File
@@ -808,8 +808,6 @@ class ProviderManager:
config.save_config()
# load instance
await self.load_provider(new_config)
# sync in-memory config for API queries (e.g., embedding provider list)
self.providers_config = astrbot_config["provider"]
async def terminate(self) -> None:
if self._mcp_init_task and not self._mcp_init_task.done():
@@ -13,11 +13,3 @@ class ProviderGroq(ProviderOpenAIOfficial):
) -> None:
super().__init__(provider_config, provider_settings)
self.reasoning_key = "reasoning"
def _finally_convert_payload(self, payloads: dict) -> None:
"""Groq rejects assistant history items that include reasoning_content."""
super()._finally_convert_payload(payloads)
for message in payloads.get("messages", []):
if message.get("role") == "assistant":
message.pop("reasoning_content", None)
message.pop("reasoning", None)
@@ -40,46 +40,25 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
async def get_embedding(self, text: str) -> list[float]:
"""获取文本的嵌入"""
kwargs = self._embedding_kwargs()
embedding = await self.client.embeddings.create(
input=text,
model=self.model,
**kwargs,
dimensions=self.get_dim(),
)
return embedding.data[0].embedding
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
"""批量获取文本的嵌入"""
kwargs = self._embedding_kwargs()
embeddings = await self.client.embeddings.create(
input=text,
model=self.model,
**kwargs,
dimensions=self.get_dim(),
)
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:
"""获取向量的维度"""
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
return int(self.provider_config.get("embedding_dimensions", 1024))
async def terminate(self):
if self.client:
@@ -311,7 +311,7 @@ class ProviderOpenAIOfficial(Provider):
state.handle_chunk(chunk)
except Exception as e:
logger.warning("Saving chunk state error: " + str(e))
if not chunk.choices:
if len(chunk.choices) == 0:
continue
delta = chunk.choices[0].delta
# logger.debug(f"chunk delta: {delta}")
@@ -322,7 +322,7 @@ class ProviderOpenAIOfficial(Provider):
if reasoning:
llm_response.reasoning_content = reasoning
_y = True
if delta and delta.content:
if delta.content:
# Don't strip streaming chunks to preserve spaces between words
completion_text = self._normalize_content(delta.content, strip=False)
llm_response.result_chain = MessageChain(
@@ -345,7 +345,7 @@ class ProviderOpenAIOfficial(Provider):
) -> str:
"""Extract reasoning content from OpenAI ChatCompletion if available."""
reasoning_text = ""
if not completion.choices:
if len(completion.choices) == 0:
return reasoning_text
if isinstance(completion, ChatCompletion):
choice = completion.choices[0]
@@ -468,7 +468,7 @@ class ProviderOpenAIOfficial(Provider):
"""Parse OpenAI ChatCompletion into LLMResponse"""
llm_response = LLMResponse("assistant")
if not completion.choices:
if len(completion.choices) == 0:
raise Exception("API 返回的 completion 为空。")
choice = completion.choices[0]
@@ -16,7 +16,4 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
"https://github.com/AstrBotDevs/AstrBot"
)
self.client._custom_headers["X-OpenRouter-Title"] = "AstrBot" # type: ignore
self.client._custom_headers["X-OpenRouter-Categories"] = (
"general-chat,personal-agent" # type: ignore
)
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
+8 -16
View File
@@ -11,8 +11,6 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
import yaml
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_skills_path,
@@ -71,19 +69,13 @@ def _parse_frontmatter_description(text: str) -> str:
break
if end_idx is None:
return ""
frontmatter = "\n".join(lines[1:end_idx])
try:
payload = yaml.safe_load(frontmatter) or {}
except yaml.YAMLError:
return ""
if not isinstance(payload, dict):
return ""
description = payload.get("description", "")
if not isinstance(description, str):
return ""
return description.strip()
for line in lines[1:end_idx]:
if ":" not in line:
continue
key, value = line.split(":", 1)
if key.strip().lower() == "description":
return value.strip().strip('"').strip("'")
return ""
# Regex for sanitizing paths used in prompt examples — only allow
@@ -136,7 +128,7 @@ def _build_skill_read_command_example(path: str) -> str:
return f"cat {path}"
if _is_windows_prompt_path(path):
command = "type"
path_arg = f'"{os.path.normpath(path)}"'
path_arg = f'"{path}"'
else:
command = "cat"
path_arg = shlex.quote(path)
+8 -5
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import Any
from typing import TYPE_CHECKING, Any
import docstring_parser
@@ -15,6 +15,9 @@ from astrbot.core.message.message_event_result import MessageEventResult
from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES
from astrbot.core.provider.register import llm_tools
if TYPE_CHECKING:
from astrbot.core.astr_agent_context import AstrAgentContext
from ..filter.command import CommandFilter
from ..filter.command_group import CommandGroupFilter
from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr
@@ -616,7 +619,7 @@ class RegisteringAgent:
kwargs["registering_agent"] = self
return register_llm_tool(*args, **kwargs)
def __init__(self, agent: Agent[Any]) -> None:
def __init__(self, agent: Agent[AstrAgentContext]) -> None:
self._agent = agent
@@ -624,7 +627,7 @@ def register_agent(
name: str,
instruction: str,
tools: list[str | FunctionTool] | None = None,
run_hooks: BaseAgentRunHooks[Any] | None = None,
run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None,
):
"""注册一个 Agent
@@ -638,12 +641,12 @@ def register_agent(
tools_ = tools or []
def decorator(awaitable: Callable[..., Awaitable[Any]]):
AstrAgent = Agent[Any]
AstrAgent = Agent[AstrAgentContext]
agent = AstrAgent(
name=name,
instructions=instruction,
tools=tools_,
run_hooks=run_hooks or BaseAgentRunHooks[Any](),
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
)
handoff_tool = HandoffTool(agent=agent)
handoff_tool.handler = awaitable
+16 -22
View File
@@ -1,16 +1,13 @@
from __future__ import annotations
import copy
from typing import TYPE_CHECKING, Any
from typing import Any
from astrbot import logger
from astrbot.core.agent.agent import Agent
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.provider.func_tool_manager import FunctionToolManager
if TYPE_CHECKING:
from astrbot.core.persona_mgr import PersonaManager
class SubAgentOrchestrator:
"""Loads subagent definitions from config and registers handoff tools.
@@ -46,14 +43,15 @@ class SubAgentOrchestrator:
continue
persona_id = item.get("persona_id")
if persona_id is not None:
persona_id = str(persona_id).strip() or None
persona_data = self._persona_mgr.get_persona_v3_by_id(persona_id)
if persona_id and persona_data is None:
logger.warning(
"SubAgent persona %s not found, fallback to inline prompt.",
persona_id,
)
persona_data = None
if persona_id:
try:
persona_data = await self._persona_mgr.get_persona(persona_id)
except StopIteration:
logger.warning(
"SubAgent persona %s not found, fallback to inline prompt.",
persona_id,
)
instructions = str(item.get("system_prompt", "")).strip()
public_description = str(item.get("public_description", "")).strip()
@@ -64,15 +62,11 @@ class SubAgentOrchestrator:
begin_dialogs = None
if persona_data:
prompt = str(persona_data.get("prompt", "")).strip()
if prompt:
instructions = prompt
begin_dialogs = copy.deepcopy(
persona_data.get("_begin_dialogs_processed")
)
tools = persona_data.get("tools")
if public_description == "" and prompt:
public_description = prompt[:120]
instructions = persona_data.system_prompt or instructions
begin_dialogs = persona_data.begin_dialogs
tools = persona_data.tools
if public_description == "" and persona_data.system_prompt:
public_description = persona_data.system_prompt[:120]
if tools is None:
tools = None
elif not isinstance(tools, list):
+6 -16
View File
@@ -25,22 +25,12 @@ class UmopConfigRouter:
)
self.umop_to_conf_id = sp_data
@staticmethod
def _split_umo(umo: str) -> tuple[str, str, str] | None:
"""将 UMO 拆分为 3 个部分,同时保留 session_id 中的 ':'"""
if not isinstance(umo, str):
return None
parts = umo.split(":", 2)
if len(parts) != 3:
return None
return parts[0], parts[1], parts[2]
def _is_umo_match(self, p1: str, p2: str) -> bool:
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
p1_ls = self._split_umo(p1)
p2_ls = self._split_umo(p2)
p1_ls = p1.split(":")
p2_ls = p2.split(":")
if p1_ls is None or p2_ls is None:
if len(p1_ls) != 3 or len(p2_ls) != 3:
return False # 非法格式
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls))
@@ -72,7 +62,7 @@ class UmopConfigRouter:
"""
for part in new_routing:
if self._split_umo(part) is None:
if not isinstance(part, str) or len(part.split(":")) != 3:
raise ValueError(
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
@@ -91,7 +81,7 @@ class UmopConfigRouter:
ValueError: 如果 umo 格式不正确
"""
if self._split_umo(umo) is None:
if not isinstance(umo, str) or len(umo.split(":")) != 3:
raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
@@ -109,7 +99,7 @@ class UmopConfigRouter:
ValueError: umo 格式不正确时抛出
"""
if self._split_umo(umo) is None:
if not isinstance(umo, str) or len(umo.split(":")) != 3:
raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
+9 -19
View File
@@ -36,20 +36,6 @@ async def track_conversation(convs: dict, conv_id: str):
convs.pop(conv_id, None)
async def _poll_webchat_stream_result(back_queue, username: str):
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
return None, False
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
return None, True
except Exception as e:
logger.error(f"WebChat stream error: {e}")
return None, False
return result, False
class ChatRoute(Route):
def __init__(
self,
@@ -357,12 +343,16 @@ class ChatRoute(Route):
async with track_conversation(self.running_convs, webchat_conv_id):
while True:
result, should_break = await _poll_webchat_stream_result(
back_queue, username
)
if should_break:
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
client_disconnected = True
break
except Exception as e:
logger.error(f"WebChat stream error: {e}")
if not result:
continue
-93
View File
@@ -1,93 +0,0 @@
## What's Changed
### 新增
- 补充 MiniMax Provider。([#6318](https://github.com/AstrBotDevs/AstrBot/pull/6318)
- 新增 WebUI ChatUI 页面的会话批量删除功能。([#6160](https://github.com/AstrBotDevs/AstrBot/pull/6160)
- 新增 WebUI ChatUI 配置发送快捷键。([#6272](https://github.com/AstrBotDevs/AstrBot/pull/6272)
### 优化
- 优化 UMO 处理兼容性。([#5996](https://github.com/AstrBotDevs/AstrBot/pull/5996)
- 重构 `_extract_session_id`,改进聊天类型分支处理。(#5775
- 优化聊天组件行为,使用 `shiki` 进行代码块渲染。([#6286](https://github.com/AstrBotDevs/AstrBot/pull/6286)
- 优化 WebUI 主题配色与视觉体验。([#6263](https://github.com/AstrBotDevs/AstrBot/pull/6263)
- 优化 OneBot @ 组件后处理,避免消息文本解析空格问题。([#6238](https://github.com/AstrBotDevs/AstrBot/pull/6238)
### 修复
- 修复创建新 Provider 后未同步 `providers_config` 的问题。([#6388](https://github.com/AstrBotDevs/AstrBot/pull/6388)
- 修复 API 返回 `null choices` 时的 `TypeError`。([#6313](https://github.com/AstrBotDevs/AstrBot/pull/6313)
- 修复 QQ Webhook 重试回调重复触发的问题。([#6320](https://github.com/AstrBotDevs/AstrBot/pull/6320)
- 修复流式模式下 `delta``None` 导致工具调用时报错的问题。([#6365](https://github.com/AstrBotDevs/AstrBot/pull/6365)
- 修复模型服务链接说明文字错误。([#6296](https://github.com/AstrBotDevs/AstrBot/pull/6296)
- 修复 AI 在 tool-calling 模式设为 `skills-like` 时发送媒体失败的问题。([#6317](https://github.com/AstrBotDevs/AstrBot/pull/6317)
- 修复 Telegram 适配器中 GIF 被错误转成静态图的问题。([#6329](https://github.com/AstrBotDevs/AstrBot/pull/6329)
- 将 Provider 图标来源替换为 jsDelivr CDN 地址,修复部分环境下图标加载问题。([#6340](https://github.com/AstrBotDevs/AstrBot/pull/6340)
- 修复 QQ 官方表情消息未解析为可读文本的问题。([#6355](https://github.com/AstrBotDevs/AstrBot/pull/6355)
- 修复 WebChat 队列异常时流式结果页面崩溃的问题。([#6123](https://github.com/AstrBotDevs/AstrBot/pull/6123)
- 修复子代理 handoff 工具在插件过滤时丢失的问题。([#6155](https://github.com/AstrBotDevs/AstrBot/pull/6155)
- 修复 Cron 提示文案缺少空格及 `utcnow()` 的弃用警告问题。([#6192](https://github.com/AstrBotDevs/AstrBot/pull/6192)
- 修复 WebUI 启动时 Sidebar hash 导航抖动/定位问题。([#6159](https://github.com/AstrBotDevs/AstrBot/pull/6159)
- 修复启动重试过程中移除已移除 API Key 的 `ValueError` 报错。([#6193](https://github.com/AstrBotDevs/AstrBot/pull/6193)
- 修复 README 启动命令引用更新为 `astrbot run`。([#6189](https://github.com/AstrBotDevs/AstrBot/pull/6189)
- 修复 `Plain.toDict()``@` 提及场景下空白字符丢失的问题。([#6244](https://github.com/AstrBotDevs/AstrBot/pull/6244)
- 修复 provider 依赖重复定义问题。([#6247](https://github.com/AstrBotDevs/AstrBot/pull/6247)
- 修复 Telegram 中普通回复被误判为线程的处理问题。([#6174](https://github.com/AstrBotDevs/AstrBot/pull/6174)
### 其他
- 调整 `astrbot.service` 及 CI 配置,升级 GitHub Actions 版本。
---
## What's Changed (EN)
### New Features
- Added OpenRouter chat completion provider adapter with support for custom headers ([#6436](https://github.com/AstrBotDevs/AstrBot/pull/6436)).
- Added MiniMax provider ([#6318](https://github.com/AstrBotDevs/AstrBot/pull/6318)).
- Added batch conversation deletion in WebChat ([#6160](https://github.com/AstrBotDevs/AstrBot/pull/6160)).
- Added send shortcut settings and localization support for WebChat input ([#6272](https://github.com/AstrBotDevs/AstrBot/pull/6272)).
- Added local temporary directory binding in YAML config ([#6191](https://github.com/AstrBotDevs/AstrBot/pull/6191)).
### Improvements
- Improved UMO processing compatibility ([#5996](https://github.com/AstrBotDevs/AstrBot/pull/5996)).
- Refactored `_extract_session_id` for chat type handling (#5775).
- Improved chat component behavior and uses `shiki` for code-block rendering ([#6286](https://github.com/AstrBotDevs/AstrBot/pull/6286)).
- Improved WebUI theme color and visual behavior ([#6263](https://github.com/AstrBotDevs/AstrBot/pull/6263)).
- Improved OneBot `@` component spacing handling ([#6238](https://github.com/AstrBotDevs/AstrBot/pull/6238)).
- Improved PR checklist validation and closure messaging.
### Bug Fixes
- Fixed missing `providers_config` sync after creating new providers ([#6388](https://github.com/AstrBotDevs/AstrBot/pull/6388)).
- Fixed `TypeError` when API returns null choices ([#6313](https://github.com/AstrBotDevs/AstrBot/pull/6313)).
- Fixed repeated QQ webhook retry callbacks ([#6320](https://github.com/AstrBotDevs/AstrBot/pull/6320)).
- Fixed tool-calling streaming null `delta` handling to prevent `AttributeError` ([#6365](https://github.com/AstrBotDevs/AstrBot/pull/6365)).
- Fixed model service link wording in docs/config ([#6296](https://github.com/AstrBotDevs/AstrBot/pull/6296)).
- Fixed AI media sending failure when tool-calling mode is set to `skills-like` ([#6317](https://github.com/AstrBotDevs/AstrBot/pull/6317)).
- Fixed GIF being sent as static image in Telegram adapter ([#6329](https://github.com/AstrBotDevs/AstrBot/pull/6329)).
- Replaced npm registry URLs with jsDelivr CDN for provider icons ([#6340](https://github.com/AstrBotDevs/AstrBot/pull/6340)).
- Fixed QQ official face message parsing to readable text ([#6355](https://github.com/AstrBotDevs/AstrBot/pull/6355)).
- Fixed WebChat stream-result crash on queue errors ([#6123](https://github.com/AstrBotDevs/AstrBot/pull/6123)).
- Preserved subagent handoff tools during plugin filtering ([#6155](https://github.com/AstrBotDevs/AstrBot/pull/6155)).
- Fixed cron prompt spacing and deprecated `utcnow()` usage ([#6192](https://github.com/AstrBotDevs/AstrBot/pull/6192)).
- Fixed unstable sidebar hash navigation on startup ([#6159](https://github.com/AstrBotDevs/AstrBot/pull/6159)).
- Fixed `ValueError` in retry loop when removing an already removed API key ([#6193](https://github.com/AstrBotDevs/AstrBot/pull/6193)).
- Updated startup command to `astrbot run` across READMEs ([#6189](https://github.com/AstrBotDevs/AstrBot/pull/6189)).
- Preserved whitespace in `Plain.toDict()` for @ mentions ([#6244](https://github.com/AstrBotDevs/AstrBot/pull/6244)).
- Removed duplicate dependencies entries ([#6247](https://github.com/AstrBotDevs/AstrBot/pull/6247)).
- Fixed Telegram normal reply being treated as topic thread ([#6174](https://github.com/AstrBotDevs/AstrBot/pull/6174)).
### Documentation
- Updated `rainyun` backup/access documentation ([#6427](https://github.com/AstrBotDevs/AstrBot/pull/6427)).
- Updated `package.md` and platform docs, including Matrix and Wecom AI bot documentation.
- Fixed Discord invite link in community docs.
### Chores
- Updated PR templates/checklist workflow, repository service config, and automated checks.
- Refreshed repository automation and formatting maintenance, and removed obsolete changelog scripts.
+2
View File
@@ -1,3 +1,5 @@
version: '3.8'
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
services:
+1
View File
@@ -36,6 +36,7 @@
"remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.17",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "2.1.3",
"vue": "3.3.4",
+4 -4
View File
@@ -81,6 +81,9 @@ importers:
stream-markdown:
specifier: ^0.0.13
version: 0.0.13(shiki@3.22.0)
stream-monaco:
specifier: ^0.0.17
version: 0.0.17(monaco-editor@0.52.2)
vee-validate:
specifier: 4.11.3
version: 4.11.3(vue@3.3.4)
@@ -3297,7 +3300,6 @@ snapshots:
'@shikijs/core': 3.22.0
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
optional: true
'@shikijs/themes@3.22.0':
dependencies:
@@ -3990,8 +3992,7 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
alien-signals@2.0.8:
optional: true
alien-signals@2.0.8: {}
ansi-regex@5.0.1: {}
@@ -5442,7 +5443,6 @@ snapshots:
alien-signals: 2.0.8
monaco-editor: 0.52.2
shiki: 3.22.0
optional: true
stringify-entities@4.0.4:
dependencies:
+3 -21
View File
@@ -74,7 +74,7 @@
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="false"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
@@ -106,7 +106,7 @@
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="false"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
@@ -137,7 +137,7 @@
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="false"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
@@ -348,12 +348,6 @@ function setSendShortcut(mode: SendShortcut) {
localStorage.setItem(SEND_SHORTCUT_STORAGE_KEY, mode);
}
function focusChatInput() {
nextTick(() => {
chatInputRef.value?.focusInput?.();
});
}
//
function checkMobile() {
isMobile.value = window.innerWidth <= 768;
@@ -511,7 +505,6 @@ async function handleSelectConversation(sessionIds: string[]) {
nextTick(() => {
messageList.value?.scrollToBottom();
});
focusChatInput();
}
function handleNewChat() {
@@ -521,7 +514,6 @@ function handleNewChat() {
// 退
selectedProjectId.value = null;
projectSessions.value = [];
focusChatInput();
}
async function handleDeleteConversation(sessionId: string) {
@@ -679,11 +671,6 @@ async function handleSendMessage() {
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
await sendMsg(
promptToSend,
filesToSend,
@@ -693,11 +680,6 @@ async function handleSendMessage() {
replyToSend
);
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
//
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
await addSessionToProject(currSessionId.value, currentProjectId);
+2 -8
View File
@@ -95,7 +95,7 @@
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
</v-tooltip>
</v-btn>
<v-btn icon v-if="isRunning && !canSend" @click="$emit('stop')" variant="tonal" color="primary" class="send-btn">
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="primary" class="send-btn">
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top">
{{ tm('input.stopGenerating') }}
@@ -373,11 +373,6 @@ function getCurrentSelection() {
return providerModelMenuRef.value?.getCurrentSelection();
}
function focusInput() {
if (!inputField.value) return;
inputField.value.focus();
}
onMounted(() => {
if (inputField.value) {
inputField.value.addEventListener('paste', handlePaste);
@@ -393,8 +388,7 @@ onBeforeUnmount(() => {
});
defineExpose({
getCurrentSelection,
focusInput
getCurrentSelection
});
</script>
@@ -180,7 +180,7 @@
<script>
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { enableKatex, enableMermaid, MarkdownCodeBlockNode, setCustomComponents } from 'markstream-vue'
import { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css';
@@ -194,11 +194,8 @@ import ActionRef from './message_list_comps/ActionRef.vue';
enableKatex();
enableMermaid();
// message-list + Shiki
setCustomComponents('message-list', {
ref: RefNode,
code_block: MarkdownCodeBlockNode
});
// ref
setCustomComponents('message-list', { ref: RefNode });
export default {
name: 'MessageList',
@@ -22,7 +22,7 @@
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="false"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
@@ -63,9 +63,8 @@
<!-- Text (Markdown) -->
<MarkdownRender
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
class="markdown-content" :is-dark="isDark" />
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
<!-- Image -->
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
@@ -9,7 +9,7 @@
</span>
</div>
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender :key="`reasoning-${isDark ? 'dark' : 'light'}`" :content="reasoning" class="reasoning-text markdown-content"
<MarkdownRender :content="reasoning" class="reasoning-text markdown-content"
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
</div>
</div>
@@ -619,6 +619,11 @@
"type": "string",
"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": {
"description": "Reconnect Delay",
"type": "int",
@@ -846,7 +851,7 @@
},
"interval_method": {
"description": "Interval Method",
"hint": "random uses a random delay. log calculates delay by message length: $y=log_{log\\_base}(x)$, where x is word count and y is in seconds."
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。"
},
"interval": {
"description": "Random Interval Time",
@@ -94,7 +94,7 @@
"title": "Confirm Batch Delete",
"message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion."
},
"batchOperations": {
"batchOperations": {
"title": "Batch Operations",
"hint": "Quick batch modify session settings",
"scope": "Apply to",
@@ -108,24 +108,23 @@
"ttsProvider": "TTS Model",
"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": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"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",
@@ -143,16 +142,7 @@
"noChanges": "No changes to save",
"batchDeleteSuccess": "Batch delete successful",
"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",
"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"
"batchUpdateSuccess": "Batch update success"
}
}
@@ -108,25 +108,6 @@
"ttsProvider": "TTS-модель",
"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": {
"enabled": "Включено",
"disabled": "Выключено"
@@ -143,16 +124,7 @@
"noChanges": "Изменений не обнаружено",
"batchDeleteSuccess": "Массовое удаление выполнено",
"batchDeleteError": "Ошибка массового удаления",
"selectSessionsFirst": "Пожалуйста, сначала выберите сессии",
"selectAtLeastOneConfig": "Пожалуйста, выберите хотя бы одну настройку для изменения",
"batchUpdateSuccess": "Пакетное обновление успешно выполнено",
"partialUpdateFailed": "Некоторые обновления не выполнены",
"batchUpdateError": "Ошибка пакетного обновления",
"groupNameRequired": "Имя группы не может быть пустым",
"saveGroupError": "Ошибка сохранения группы",
"deleteGroupError": "Ошибка удаления группы",
"selectSessionsToAddFirst": "Пожалуйста, сначала выберите сессии для добавления",
"addToGroupSuccess": "Добавлено сессий в группу: {count}",
"addToGroupError": "Ошибка добавления в группу"
"batchUpdateSuccess": "Пакетное обновление успешно выполнено"
}
}
}
@@ -621,6 +621,11 @@
"type": "string",
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token"
},
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息。"
},
"kook_reconnect_delay": {
"description": "重连延迟",
"type": "int",
@@ -108,25 +108,6 @@
"ttsProvider": "TTS 模型",
"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": {
"enabled": "启用",
"disabled": "禁用"
@@ -142,17 +123,6 @@
"deleteError": "删除失败",
"noChanges": "没有需要保存的更改",
"batchDeleteSuccess": "批量删除成功",
"batchDeleteError": "批量删除失败",
"selectSessionsFirst": "请先选择要操作的会话",
"selectAtLeastOneConfig": "请至少选择一项要修改的配置",
"batchUpdateSuccess": "批量更新成功",
"partialUpdateFailed": "部分更新失败",
"batchUpdateError": "批量更新失败",
"groupNameRequired": "分组名称不能为空",
"saveGroupError": "保存分组失败",
"deleteGroupError": "删除分组失败",
"selectSessionsToAddFirst": "请先选择要添加的会话",
"addToGroupSuccess": "已添加 {count} 个会话到分组",
"addToGroupError": "添加失败"
"batchDeleteError": "批量删除失败"
}
}
+26 -26
View File
@@ -9,33 +9,33 @@
*/
export function getProviderIcon(type) {
const icons = {
'openai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openai.svg',
'azure': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/azure.svg',
'xai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/xai.svg',
'anthropic': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/anthropic.svg',
'ollama': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ollama.svg',
'google': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/gemini-color.svg',
'deepseek': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/deepseek.svg',
'modelscope': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/modelscope.svg',
'zhipu': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/zhipu.svg',
'nvidia': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/nvidia-color.svg',
'siliconflow': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/siliconcloud.svg',
'moonshot': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg',
'ppio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ppio.svg',
'dify': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/dify-color.svg',
"coze": "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.66.0/icons/coze.svg",
'dashscope': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/alibabacloud-color.svg',
'openai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg',
'azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg',
'xai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/xai.svg',
'anthropic': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg',
'ollama': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ollama.svg',
'google': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg',
'deepseek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg',
'modelscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/modelscope.svg',
'zhipu': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg',
'nvidia': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/nvidia-color.svg',
'siliconflow': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg',
'moonshot': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg',
'ppio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg',
'dify': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dify-color.svg',
"coze": "https://registry.npmmirror.com/@lobehub/icons-static-svg/1.66.0/files/icons/coze.svg",
'dashscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/alibabacloud-color.svg',
'deerflow': 'https://cdn.jsdelivr.net/gh/bytedance/deer-flow@main/frontend/public/images/deer.svg',
'fastgpt': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/fastgpt-color.svg',
'lm_studio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/lmstudio.svg',
'fishaudio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/fishaudio.svg',
'minimax': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/minimax.svg',
'302ai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.53.0/icons/ai302-color.svg',
'microsoft': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/microsoft.svg',
'vllm': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/vllm.svg',
'groq': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/groq.svg',
'aihubmix': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/aihubmix-color.svg',
'openrouter': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openrouter.svg',
'fastgpt': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fastgpt-color.svg',
'lm_studio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/lmstudio.svg',
'fishaudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg',
'minimax': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg',
'302ai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/1.53.0/files/icons/ai302-color.svg',
'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg',
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg',
'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg',
'aihubmix': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/aihubmix-color.svg',
'openrouter': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openrouter.svg',
"tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png",
"compshare": "https://compshare.cn/favicon.ico"
};
+32 -35
View File
@@ -156,24 +156,24 @@
<!-- 分组管理面板 -->
<v-card flat class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
<span class="text-h6">{{ tm('groups.title') }}</span>
<span class="text-h6">分组管理</span>
<v-chip size="small" class="ml-2" color="secondary" variant="outlined">
{{ tm('groups.count', { count: groups.length }) }}
{{ groups.length }} 个分组
</v-chip>
<v-spacer></v-spacer>
<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>
{{ tm('groups.addToGroup') }}
添加到分组
<v-menu activator="parent">
<v-list density="compact">
<v-list-item v-for="g in groups" :key="g.id" @click="addSelectedToGroup(g.id)">
<v-list-item-title>{{ tm('groups.customGroupOption', { name: g.name, count: g.umo_count }) }}</v-list-item-title>
<v-list-item-title>{{ g.name }} ({{ g.umo_count }})</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn color="success" variant="tonal" size="small" @click="openCreateGroupDialog" prepend-icon="mdi-folder-plus">
{{ tm('groups.create') }}
新建分组
</v-btn>
</v-card-title>
<v-card-text v-if="groups.length > 0">
@@ -183,7 +183,7 @@
<div class="d-flex align-center justify-space-between">
<div>
<div class="font-weight-bold">{{ group.name }}</div>
<div class="text-caption text-grey">{{ tm('groups.sessionsCount', { count: group.umo_count }) }}</div>
<div class="text-caption text-grey">{{ group.umo_count }} 个会话</div>
</div>
<div>
<v-btn icon size="small" variant="text" @click="openEditGroupDialog(group)">
@@ -199,7 +199,7 @@
</v-row>
</v-card-text>
<v-card-text v-else class="text-center text-grey py-6">
{{ tm('groups.empty') }}
暂无分组点击新建分组创建
</v-card-text>
</v-card>
@@ -207,15 +207,15 @@
<v-dialog v-model="groupDialog" max-width="800" @after-enter="loadAvailableUmos">
<v-card>
<v-card-title class="py-3 px-4">
{{ groupDialogMode === 'create' ? tm('groups.create') : tm('groups.edit') }}
{{ groupDialogMode === 'create' ? '新建分组' : '编辑分组' }}
</v-card-title>
<v-card-text>
<v-text-field v-model="editingGroup.name" :label="tm('groups.name')" variant="outlined" hide-details class="mb-4"></v-text-field>
<v-text-field v-model="editingGroup.name" label="分组名称" variant="outlined" hide-details class="mb-4"></v-text-field>
<v-row dense>
<!-- 左侧可选会话 -->
<v-col cols="5">
<div class="text-subtitle-2 mb-2">{{ tm('groups.availableSessions', { count: unselectedUmos.length }) }}</div>
<v-text-field v-model="groupMemberSearch" :placeholder="tm('groups.searchPlaceholder')" variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
<div class="text-subtitle-2 mb-2">可选会话 ({{ 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-list density="compact" class="transfer-list" lines="one">
<v-list-item v-for="umo in filteredUnselectedUmos" :key="umo" @click="addToGroup(umo)" class="transfer-item">
<template v-slot:prepend>
@@ -224,7 +224,7 @@
<v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="filteredUnselectedUmos.length === 0 && !loadingUmos">
<v-list-item-title class="text-caption text-grey text-center">{{ tm('groups.noMatch') }}</v-list-item-title>
<v-list-item-title class="text-caption text-grey text-center">无匹配项</v-list-item-title>
</v-list-item>
<v-list-item v-if="loadingUmos">
<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 cols="5">
<div class="text-subtitle-2 mb-2">{{ tm('groups.selectedSessions', { count: editingGroup.umos.length }) }}</div>
<v-text-field v-model="groupSelectedSearch" :placeholder="tm('groups.searchPlaceholder')" variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
<div class="text-subtitle-2 mb-2">已选会话 ({{ 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-list density="compact" class="transfer-list" lines="one">
<v-list-item v-for="umo in filteredSelectedUmos" :key="umo" @click="removeFromGroup(umo)" class="transfer-item">
<template v-slot:prepend>
@@ -252,7 +252,7 @@
<v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="editingGroup.umos.length === 0">
<v-list-item-title class="text-caption text-grey text-center">{{ tm('groups.noMembers') }}</v-list-item-title>
<v-list-item-title class="text-caption text-grey text-center">暂无成员</v-list-item-title>
</v-list-item>
</v-list>
</v-col>
@@ -260,8 +260,8 @@
</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="groupDialog = false">{{ tm('buttons.cancel') }}</v-btn>
<v-btn color="primary" variant="tonal" @click="saveGroup">{{ tm('buttons.save') }}</v-btn>
<v-btn variant="text" @click="groupDialog = false">取消</v-btn>
<v-btn color="primary" variant="tonal" @click="saveGroup">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -721,12 +721,9 @@ export default {
]
//
if (this.groups.length > 0) {
options.push({ label: this.tm('groups.customGroupDivider'), value: '_divider', disabled: true })
options.push({ label: '── 自定义分组 ──', value: '_divider', disabled: true })
this.groups.forEach(g => {
options.push({
label: this.tm('groups.customGroupOption', { name: g.name, count: g.umo_count }),
value: `custom_group:${g.id}`
})
options.push({ label: `📁 ${g.name} (${g.umo_count})`, value: `custom_group:${g.id}` })
})
}
return options
@@ -734,7 +731,7 @@ export default {
groupOptions() {
return this.groups.map(g => ({
label: this.tm('groups.groupOption', { name: g.name, count: g.umo_count }),
label: `${g.name} (${g.umo_count} 个会话)`,
value: g.id
}))
},
@@ -1334,7 +1331,7 @@ export default {
if (scope === 'selected') {
umos = this.selectedItems.map(item => item.umo)
if (umos.length === 0) {
this.showError(this.tm('messages.selectSessionsFirst'))
this.showError('请先选择要操作的会话')
this.batchUpdating = false
return
}
@@ -1374,7 +1371,7 @@ export default {
}
if (tasks.length === 0) {
this.showError(this.tm('messages.selectAtLeastOneConfig'))
this.showError('请至少选择一项要修改的配置')
this.batchUpdating = false
return
}
@@ -1383,17 +1380,17 @@ export default {
const allOk = results.every(r => r.data.status === 'ok')
if (allOk) {
this.showSuccess(this.tm('messages.batchUpdateSuccess'))
this.showSuccess('批量更新成功')
this.batchLlmStatus = null
this.batchTtsStatus = null
this.batchChatProvider = null
this.batchTtsProvider = null
await this.loadData()
} else {
this.showError(this.tm('messages.partialUpdateFailed'))
this.showError('部分更新失败')
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.batchUpdateError'))
this.showError(error.response?.data?.message || '批量更新失败')
}
this.batchUpdating = false
},
@@ -1480,7 +1477,7 @@ export default {
async saveGroup() {
if (!this.editingGroup.name.trim()) {
this.showError(this.tm('messages.groupNameRequired'))
this.showError('分组名称不能为空')
return
}
@@ -1507,12 +1504,12 @@ export default {
this.showError(response.data.message)
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.saveGroupError'))
this.showError(error.response?.data?.message || '保存分组失败')
}
},
async deleteGroup(group) {
const message = this.tm('groups.deleteConfirm', { name: group.name })
const message = `确定要删除分组 "${group.name}" 吗?`
if (!(await askForConfirmationDialog(message, this.confirmDialog))) return
try {
@@ -1524,7 +1521,7 @@ export default {
this.showError(response.data.message)
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.deleteGroupError'))
this.showError(error.response?.data?.message || '删除分组失败')
}
},
@@ -1535,7 +1532,7 @@ export default {
async addSelectedToGroup(groupId) {
if (this.selectedItems.length === 0) {
this.showError(this.tm('messages.selectSessionsToAddFirst'))
this.showError('请先选择要添加的会话')
return
}
@@ -1545,13 +1542,13 @@ export default {
add_umos: this.selectedItems.map(item => item.umo)
})
if (response.data.status === 'ok') {
this.showSuccess(this.tm('messages.addToGroupSuccess', { count: this.selectedItems.length }))
this.showSuccess(`已添加 ${this.selectedItems.length} 个会话到分组`)
await this.loadGroups()
} else {
this.showError(response.data.message)
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.addToGroupError'))
this.showError(error.response?.data?.message || '添加失败')
}
},
},
+1 -1
View File
@@ -6,7 +6,7 @@ This documentation may not cover all features comprehensively. If you have any q
### Discord
<https://discord.gg/hAVk6tgV36>
<https://discord.gg/PxgzhmxJ>
### GitHub
+1 -1
View File
@@ -21,7 +21,7 @@
### Discord
https://discord.gg/hAVk6tgV36
https://discord.gg/PxgzhmxJ
### Astrbook
+1 -1
View File
@@ -13,5 +13,5 @@
```bash
uv tool install astrbot
astrbot init # 只需要在第一次部署时执行,后续启动不需要执行
astrbot run
astrbot
```
+1 -1
View File
@@ -41,4 +41,4 @@ AstrBot 已经上架至雨云的预装软件列表,支持**一键安装** Astr
![创建NAT端口映射规则](https://files.astrbot.app/docs/source/images/rainyun/image-2.png)
然后,内网端口填写 `6185`,点击 `创建映射规则`,这样就可以通过 `http://IP:上面设置好的外网端口` 访问 AstrBot 的管理面板了。如果无法打开,请点击`备用地址`,通过备用地址访问管理面板。
然后,内网端口填写 `6185`,点击 `创建映射规则`,这样就可以通过 `http://IP:上面设置好的外网端口` 访问 AstrBot 的管理面板了。
+1 -1
View File
@@ -23,7 +23,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
- 部署 AstrBot:阅读部署指南,快速在本地机器或云服务器上部署 AstrBot。
- 连接 IM 平台:按照说明将 AstrBot 连接到您喜欢的 IM 平台,如 Discord、Telegram、Slack 等。
- 配置 AI 模型:AstrBot 支持各种 AI 模型。请参阅 [连接模型服务](/providers/start)
- 配置 AI 模型:AstrBot 支持各种 AI 模型。请参阅 [连接模型服务](/config/providers/start)
## 它是如何实现的?
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.20.1"
version = "4.20.0"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"
-5
View File
@@ -1,7 +1,5 @@
# user service
[Unit]
Description=AstrBot Service
Documentation=https://github.com/AstrBotDevs/AstrBot
After=network-online.target
Wants=network-online.target
@@ -11,9 +9,6 @@ WorkingDirectory=%h/.local/share/astrbot
ExecStart=/usr/bin/sh -c '/usr/bin/astrbot run || { /usr/bin/astrbot init && /usr/bin/astrbot run; }'
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=astrbot-%u
Environment=PYTHONUNBUFFERED=1
[Install]
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""
Auto-generate changelog from git commits using LLM.
Usage: python scripts/generate_changelog.py [--version VERSION]
"""
import argparse
import os
import re
import subprocess
import sys
from pathlib import Path
def get_latest_tag():
"""Get the latest git tag."""
result = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def get_commits_since_tag(tag):
"""Get all commit messages since the specified tag."""
result = subprocess.run(
["git", "log", f"{tag}..HEAD", "--pretty=format:%H|%s|%b"],
capture_output=True,
text=True,
check=True,
)
commits = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("|", 2)
if len(parts) >= 2:
commit_hash = parts[0]
subject = parts[1]
body = parts[2] if len(parts) > 2 else ""
commits.append({"hash": commit_hash[:7], "subject": subject, "body": body})
return commits
def extract_issue_number(text):
"""Extract issue number from commit message."""
# Match #1234 or (#1234)
match = re.search(r"#(\d+)", text)
return match.group(1) if match else None
def call_llm_for_changelog(commits, version):
"""Call LLM to generate changelog from commits."""
try:
# Try to use OpenAI API or other LLM providers
import openai
# Build prompt
commits_text = "\n".join([f"- {c['subject']}" for c in commits])
prompt = f"""Based on the following git commit messages, generate a changelog document in BOTH Chinese and English.
Commit messages:
{commits_text}
Please organize the changes into these categories:
- 新增 (New Features)
- 修复 (Bug Fixes)
- 优化 (Improvements)
- 其他 (Others)
Format requirements:
1. Start with Chinese version under "## What's Changed"
2. Follow with English version under "## What's Changed (EN)"
3. Use markdown format with proper bullet points
4. Keep descriptions concise and user-friendly
5. If a commit mentions an issue number (#1234), include it in the format ([#1234](https://github.com/AstrBotDevs/AstrBot/issues/1234))
Example format:
## What's Changed
### 新增
- 支持某某功能 ([#1234](https://github.com/AstrBotDevs/AstrBot/issues/1234))
### 修复
- 修复某某问题
## What's Changed (EN)
### New Features
- Add support for something ([#1234](https://github.com/AstrBotDevs/AstrBot/issues/1234))
### Bug Fixes
- Fix something
"""
client = openai.OpenAI(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)
response = client.chat.completions.create(
model=os.getenv("OPENAI_MODEL", "gpt-4"),
messages=[
{
"role": "system",
"content": "You are a helpful assistant that generates well-structured changelogs.",
},
{"role": "user", "content": prompt},
],
temperature=0.3,
)
return response.choices[0].message.content
except ImportError:
print(
"Warning: openai package not installed. Install it with: pip install openai"
)
return generate_simple_changelog(commits)
except Exception as e:
print(f"Warning: Failed to call LLM API: {e}")
print("Falling back to simple changelog generation...")
return generate_simple_changelog(commits)
def generate_simple_changelog(commits):
"""Generate a simple changelog without LLM."""
sections = {
"feat": ("新增", "New Features", []),
"fix": ("修复", "Bug Fixes", []),
"perf": ("优化", "Improvements", []),
"docs": ("文档", "Documentation", []),
"refactor": ("重构", "Refactoring", []),
"test": ("测试", "Tests", []),
"chore": ("其他", "Chore", []),
"other": ("其他", "Others", []),
}
# Categorize commits by conventional commit type
for commit in commits:
subject = commit["subject"]
issue_num = extract_issue_number(subject)
issue_link = (
f" ([#{issue_num}](https://github.com/AstrBotDevs/AstrBot/issues/{issue_num}))"
if issue_num
else ""
)
# Detect conventional commit type
matched = False
for prefix in ["feat", "fix", "perf", "docs", "refactor", "test", "chore"]:
if subject.lower().startswith(f"{prefix}:") or subject.lower().startswith(
f"{prefix}("
):
# Remove prefix for display
clean_subject = re.sub(
r"^[a-z]+(\([^)]+\))?:\s*", "", subject, flags=re.IGNORECASE
)
sections[prefix][2].append(f"- {clean_subject}{issue_link}")
matched = True
break
if not matched:
sections["other"][2].append(f"- {subject}{issue_link}")
# Build Chinese version
changelog_zh = "## What's Changed\n\n"
for section_key in ["feat", "fix", "perf", "docs", "refactor", "test", "other"]:
zh_title, _, items = sections[section_key]
if items:
changelog_zh += f"### {zh_title}\n\n"
changelog_zh += "\n".join(items) + "\n\n"
# Build English version
changelog_en = "## What's Changed (EN)\n\n"
for section_key in ["feat", "fix", "perf", "docs", "refactor", "test", "other"]:
_, en_title, items = sections[section_key]
if items:
changelog_en += f"### {en_title}\n\n"
changelog_en += "\n".join(items) + "\n\n"
return changelog_zh + changelog_en
def main() -> None:
parser = argparse.ArgumentParser(description="Generate changelog from git commits")
parser.add_argument(
"--version", help="Version number for the changelog (e.g., v4.13.3)"
)
parser.add_argument(
"--use-llm",
action="store_true",
help="Use LLM to generate changelog (requires OpenAI API key)",
)
args = parser.parse_args()
# Get latest tag
try:
latest_tag = get_latest_tag()
print(f"Latest tag: {latest_tag}")
except subprocess.CalledProcessError:
print("Error: No tags found in repository")
sys.exit(1)
# Get commits since tag
commits = get_commits_since_tag(latest_tag)
if not commits:
print(f"No commits found since {latest_tag}")
sys.exit(0)
print(f"Found {len(commits)} commits since {latest_tag}")
# Determine version
if args.version:
version = args.version
else:
# Auto-increment patch version
match = re.match(r"v(\d+)\.(\d+)\.(\d+)", latest_tag)
if match:
major, minor, patch = map(int, match.groups())
version = f"v{major}.{minor}.{patch + 1}"
else:
print(f"Warning: Could not parse version from tag {latest_tag}")
version = "vX.X.X"
print(f"Generating changelog for {version}...")
# Generate changelog
if args.use_llm:
changelog_content = call_llm_for_changelog(commits, version)
else:
changelog_content = generate_simple_changelog(commits)
# Save to file
changelog_dir = Path(__file__).parent.parent / "changelogs"
changelog_dir.mkdir(exist_ok=True)
changelog_file = changelog_dir / f"{version}.md"
with open(changelog_file, "w", encoding="utf-8") as f:
f.write(changelog_content)
print(f"\n✓ Changelog generated: {changelog_file}")
print("\nPreview:")
print("=" * 80)
print(changelog_content)
print("=" * 80)
if __name__ == "__main__":
main()
-56
View File
@@ -1,56 +0,0 @@
import asyncio
import pytest
from astrbot.dashboard.routes.chat import _poll_webchat_stream_result
class _QueueThatRaises:
def __init__(self, exc: BaseException):
self._exc = exc
async def get(self):
raise self._exc
class _QueueWithResult:
def __init__(self, result):
self._result = result
async def get(self):
return self._result
@pytest.mark.asyncio
async def test_poll_webchat_stream_result_breaks_on_cancelled_error():
result, should_break = await _poll_webchat_stream_result(
_QueueThatRaises(asyncio.CancelledError()),
"alice",
)
assert result is None
assert should_break is True
@pytest.mark.asyncio
async def test_poll_webchat_stream_result_continues_on_generic_exception():
result, should_break = await _poll_webchat_stream_result(
_QueueThatRaises(RuntimeError("boom")),
"alice",
)
assert result is None
assert should_break is False
@pytest.mark.asyncio
async def test_poll_webchat_stream_result_returns_queue_payload():
payload = {"type": "end", "data": ""}
result, should_break = await _poll_webchat_stream_result(
_QueueWithResult(payload),
"alice",
)
assert result == payload
assert should_break is False
-48
View File
@@ -1,5 +1,4 @@
import asyncio
import copy
import io
import os
import sys
@@ -108,53 +107,6 @@ async def test_get_stat(app: Quart, authenticated_header: dict):
@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"])
async def test_batch_delete_sessions_rejects_non_object_payload(
app: Quart, authenticated_header: dict, payload
+27 -27
View File
@@ -4,97 +4,97 @@
"size": "lg",
"modules": [
{
"type": "header",
"text": {
"type": "plain-text",
"content": "test1",
"type": "plain-text",
"emoji": true
}
},
"type": "header"
},
{
"type": "section",
"text": {
"type": "kmarkdown",
"content": "test2"
"content": "test2",
"type": "kmarkdown"
},
"type": "section",
"mode": "left"
},
{
"type": "divider"
},
{
"type": "section",
"text": {
"type": "paragraph",
"fields": [
{
"type": "kmarkdown",
"content": "test3"
"content": "test3",
"type": "kmarkdown"
},
{
"type": "kmarkdown",
"content": "**test4**"
"content": "**test4**",
"type": "kmarkdown"
}
],
"type": "paragraph",
"cols": 2
},
"type": "section",
"mode": "left"
},
{
"type": "image-group",
"elements": [
{
"type": "image",
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
"type": "image",
"alt": "",
"size": "lg",
"circle": false
}
]
],
"type": "image-group"
},
{
"type": "file",
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
"title": "test5"
"title": "test5",
"type": "file"
},
{
"type": "countdown",
"endTime": 1772343427360,
"type": "countdown",
"startTime": 1772343378259,
"mode": "second"
},
{
"type": "action-group",
"elements": [
{
"type": "button",
"text": "点我测试回调",
"type": "button",
"theme": "primary",
"value": "btn_clicked",
"click": "return-val"
},
{
"type": "button",
"text": "访问官网",
"type": "button",
"theme": "danger",
"value": "https://www.kookapp.cn",
"click": "link"
}
]
],
"type": "action-group"
},
{
"type": "context",
"elements": [
{
"type": "plain-text",
"content": "test6",
"type": "plain-text",
"emoji": true
}
]
],
"type": "context"
},
{
"type": "invite",
"code": "test7"
"code": "test7",
"type": "invite"
}
]
}
@@ -1,119 +0,0 @@
{
"s": 0,
"d": {
"channel_type": "GROUP",
"type": 9,
"target_id": "2732467349811313213",
"author_id": "7324688132731983",
"content": "done!",
"extra": {
"quote": {
"id": "69a788adb0cfb9ece50eae1c",
"rong_id": "7baef72c-0cd7-49ad-9592-1615236136cb",
"type": 9,
"content": "/am 1",
"interact_res": null,
"create_at": 1772587180973,
"author": {
"id": "2701973210937821093781",
"username": "some_username",
"identify_num": "4198",
"online": true,
"os": "Websocket",
"status": 1,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"banner": "",
"nickname": "some_username",
"roles": [
63724577
],
"is_vip": false,
"vip_amp": false,
"bot": false,
"nameplate": [],
"kpm_vip": null,
"wealth_level": 0,
"decorations_id_map": null,
"mobile_verified": true,
"is_sys": false,
"joined_at": 1772259607000,
"active_time": 1772587181304
},
"can_jump": true,
"preview_content": null,
"kmarkdown": {
"mention_part": [],
"mention_role_part": [],
"channel_part": [],
"item_part": []
}
},
"type": 9,
"code": "",
"guild_id": "273902183210983210983",
"guild_type": 0,
"channel_name": "聊天大厅",
"author": {
"id": "7324688132731983",
"username": "Bot_Test",
"identify_num": "9561",
"online": true,
"os": "Websocket",
"status": 0,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"banner": "",
"nickname": "Bot_Test",
"roles": [
63725384
],
"is_vip": false,
"vip_amp": false,
"bot": true,
"nameplate": [],
"kpm_vip": null,
"wealth_level": 0,
"bot_status": 0,
"tag_info": {
"color": "#0096FF",
"bg_color": "#0096FF33",
"text": "机器人"
},
"is_sys": false,
"client_id": "sAdiIHoGhdSFUOA",
"verified": false
},
"visible_only": "",
"mention": [],
"mention_no_at": [],
"mention_all": false,
"mention_roles": [],
"mention_here": false,
"nav_channels": [],
"kmarkdown": {
"raw_content": "done!",
"mention_part": [],
"mention_role_part": [],
"channel_part": [],
"spl": []
},
"emoji": [],
"preview_content": "",
"channel_type": 1,
"last_msg_content": "Bot_Testdone!",
"send_msg_device": 0
},
"msg_id": "c51a8761-63bv-5l2a-5681-0ac16e140a1b",
"msg_timestamp": 1772587182234,
"nonce": "",
"from_type": 1
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 3
}
@@ -1,8 +0,0 @@
{
"s": 1,
"d": {
"sessionId": "67d7d497-2b10-4849-9c2c-dda2fe58ed60",
"session_id": "67d7d497-2b10-4849-9c2c-dda2fe58ed60",
"code": 0
}
}
@@ -1,72 +0,0 @@
{
"s": 0,
"d": {
"channel_type": "PERSON",
"type": 10,
"target_id": "2732467349811313213",
"author_id": "7324688132731983",
"content": "[{\"theme\":\"primary\",\"color\":\"\",\"size\":\"lg\",\"expand\":false,\"modules\":[{\"type\":\"audio\",\"cover\":\"\",\"duration\":0,\"title\":\"dancing_shot5.wav\",\"src\":\"https:\\/\\/img.kookapp.cn\\/attachments\\/2026-03\\/03\\/69a6841c3125d.wav\",\"external\":false,\"size\":443414,\"canDownload\":true,\"elements\":[]}],\"type\":\"card\"}]",
"extra": {
"type": 10,
"code": "1738914789hd8fd91098he809h19y491",
"author": {
"id": "7324688132731983",
"username": "Bot_Test",
"identify_num": "9561",
"online": true,
"os": "Websocket",
"status": 0,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"banner": "",
"nickname": "Bot_Test",
"roles": [],
"is_vip": false,
"vip_amp": false,
"bot": true,
"nameplate": [],
"kpm_vip": null,
"wealth_level": 0,
"bot_status": 0,
"tag_info": {
"color": "#0096FF",
"bg_color": "#0096FF33",
"text": "机器人"
},
"is_sys": false,
"client_id": "u109u3108h8ds0qsdaHUIOS",
"verified": false
},
"visible_only": "",
"mention": [],
"mention_no_at": [],
"mention_all": false,
"mention_roles": [],
"mention_here": false,
"nav_channels": [],
"emoji": [],
"kmarkdown": {
"raw_content": "[音频]dancing_shot5.wav",
"mention_part": [],
"mention_role_part": [],
"channel_part": []
},
"editable": false,
"preview_content": "[音频]dancing_shot5.wav",
"preview_content_search": "[音频]dancing_shot5.wav",
"last_msg_content": "[音频]dancing_shot5.wav",
"send_msg_device": 0
},
"msg_id": "82c0b042-79b4-4066-a0f4-6c7a95c74e67",
"msg_timestamp": 1772587223043,
"nonce": "",
"from_type": 1
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 5
}
@@ -1,79 +0,0 @@
{
"s": 0,
"d": {
"channel_type": "GROUP",
"type": 10,
"target_id": "2723723449021809",
"author_id": "1237198731983",
"content": "[{\"theme\":\"invisible\",\"color\":\"\",\"size\":\"lg\",\"expand\":false,\"modules\":[{\"type\":\"section\",\"mode\":\"left\",\"accessory\":null,\"text\":{\"type\":\"kmarkdown\",\"content\":\"(met)(met) (met)all(met) #hello \\\\*\\\\*world\\\\*\\\\* \",\"elements\":[]},\"elements\":[]},{\"type\":\"audio\",\"cover\":\"\",\"duration\":0,\"title\":\"dancing_shot5.wav\",\"src\":\"https:\\/\\/img.kookapp.cn\\/attachments\\/2026-03\\/03\\/69a6841c3125d.wav\",\"external\":false,\"size\":443414,\"canDownload\":true,\"elements\":[]},{\"type\":\"section\",\"mode\":\"left\",\"accessory\":null,\"text\":{\"type\":\"kmarkdown\",\"content\":\"\\n😆 \",\"elements\":[]},\"elements\":[]}],\"type\":\"card\"}]",
"msg_id": "ec4046e9-ea43-4907-9fc3-8c6d0bd4ec56",
"msg_timestamp": 1772600762056,
"nonce": "sy8f91y248yda",
"from_type": 1,
"extra": {
"type": 10,
"code": "",
"author": {
"id": "1237198731983",
"username": "some_username",
"identify_num": "4198",
"nickname": "some_username",
"bot": false,
"online": true,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"status": 1,
"roles": [
12783219731984
],
"os": "Websocket",
"banner": "",
"is_vip": false,
"vip_amp": false,
"nameplate": [],
"wealth_level": 0,
"is_sys": false
},
"kmarkdown": {
"raw_content": "@Bot_Test @全体成员 #hello **world**[音频]dancing_shot5.wav😆",
"mention_part": [
{
"id": "",
"username": "Bot_Test",
"full_name": "Bot_Test#9561",
"avatar": "https://example.com",
"wealth_level": 0
}
],
"mention_role_part": [],
"channel_part": []
},
"last_msg_content": "some_username@Bot_Test @ 全体成员 #hello **world**[音频]dancing_shot5.wav😆",
"mention": [
""
],
"mention_all": true,
"mention_here": false,
"guild_id": "28321098321093",
"guild_type": 0,
"channel_name": "聊天大厅",
"visible_only": "",
"mention_no_at": [],
"mention_roles": [],
"nav_channels": [],
"emoji": [],
"editable": true,
"preview_content": "@Bot_Test @全体成员 #hello **world**[音频]dancing_shot5.wav😆",
"preview_content_search": "@Bot_Test @全体成员 #hello **world**[音频]dancing_shot5.wav😆",
"channel_type": 1,
"send_msg_device": 0
}
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 5
}
@@ -1,4 +0,0 @@
{
"s": 2,
"sn": 0
}
@@ -1,3 +0,0 @@
{
"s": 3
}
@@ -1,64 +0,0 @@
{
"s": 0,
"d": {
"channel_type": "PERSON",
"type": 9,
"target_id": "7324688132731983",
"author_id": "2732467349811313213",
"content": "/help",
"extra": {
"type": 9,
"code": "1738914789hd8fd91098he809h19y491",
"author": {
"id": "2732467349811313213",
"username": "shuiping233",
"identify_num": "4198",
"online": true,
"os": "Websocket",
"status": 1,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"banner": "",
"nickname": "shuiping233",
"roles": [],
"is_vip": false,
"vip_amp": false,
"bot": false,
"nameplate": [],
"kpm_vip": null,
"wealth_level": 0,
"decorations_id_map": null,
"is_sys": false
},
"visible_only": "",
"mention": [],
"mention_no_at": [],
"mention_all": false,
"mention_roles": [],
"mention_here": false,
"nav_channels": [],
"kmarkdown": {
"raw_content": "/help",
"mention_part": [],
"mention_role_part": [],
"channel_part": [],
"spl": []
},
"emoji": [],
"preview_content": "",
"last_msg_content": "/help",
"send_msg_device": 0
},
"msg_id": "b0f57b9e-2cd4-4e07-8f0e-9c1ecfeaa837",
"msg_timestamp": 1772587358662,
"nonce": "6AwzUe5YjgyC8pAfxcLGjewL",
"from_type": 1
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 19
}
@@ -1,31 +0,0 @@
{
"s": 0,
"d": {
"channel_type": "PERSON",
"type": 255,
"target_id": "7324688132731983",
"author_id": "1",
"content": "[系统消息]",
"extra": {
"type": "guild_member_offline",
"body": {
"user_id": "2732467349811313213",
"event_time": 1772589748914,
"guilds": [
"78941897317309873120973"
]
}
},
"msg_id": "e91b4451-75ce-47bd-bda6-e4498ed8d30d",
"msg_timestamp": 1772589748933,
"nonce": "",
"from_type": 1
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 1
}
@@ -1,7 +0,0 @@
{
"s": 5,
"d": {
"code": 40108,
"err": "Invalid SN"
}
}
@@ -1,4 +0,0 @@
{
"s": 4,
"sn": 100
}
@@ -1,6 +0,0 @@
{
"s": 6,
"d": {
"session_id": "xxxx-xxxxxx-xxx-xxx"
}
}
+1 -2
View File
@@ -1,5 +1,4 @@
from pathlib import Path
CURRENT_DIR = Path(__file__).parent
TEST_DATA_DIR = CURRENT_DIR / "data"
TEST_DATA_DIR = Path(__file__).parent / "data"
+47 -12
View File
@@ -60,7 +60,7 @@ def mock_astrbot_message():
Image("test image"),
"test image",
OrderMessage(
index=1,
1,
text="test image",
type=KookMessageType.IMAGE,
),
@@ -70,7 +70,7 @@ def mock_astrbot_message():
Video("test video"),
"test video",
OrderMessage(
index=1,
1,
text="test video",
type=KookMessageType.VIDEO,
),
@@ -80,7 +80,7 @@ def mock_astrbot_message():
mock_file_message("test file"),
"test file",
OrderMessage(
index=1,
1,
text="test file",
type=KookMessageType.FILE,
),
@@ -90,8 +90,8 @@ def mock_astrbot_message():
mock_record_message("./tests/file.wav"),
"./tests/file.wav",
OrderMessage(
index=1,
text='[{"type": "card", "modules": [{"type": "audio", "src": "./tests/file.wav", "title": "./tests/file.wav"}]}]',
1,
text='[{"type": "card", "modules": [{"src": "./tests/file.wav", "title": "./tests/file.wav", "type": "audio"}]}]',
type=KookMessageType.CARD,
),
None,
@@ -100,7 +100,7 @@ def mock_astrbot_message():
Plain("test plain"),
"test plain",
OrderMessage(
index=1,
1,
text="test plain",
type=KookMessageType.KMARKDOWN,
),
@@ -110,7 +110,7 @@ def mock_astrbot_message():
At(qq="test at"),
"test at",
OrderMessage(
index=1,
1,
text="(met)test at(met)",
type=KookMessageType.KMARKDOWN,
),
@@ -120,7 +120,7 @@ def mock_astrbot_message():
AtAll(qq="all"),
"test atAll",
OrderMessage(
index=1,
1,
text="(met)all(met)",
type=KookMessageType.KMARKDOWN,
),
@@ -130,7 +130,7 @@ def mock_astrbot_message():
Reply(id="test reply"),
"test reply",
OrderMessage(
index=1,
1,
text="",
type=KookMessageType.KMARKDOWN,
reply_id="test reply",
@@ -141,7 +141,7 @@ def mock_astrbot_message():
Json(data={"test": "json"}),
"test json",
OrderMessage(
index=1,
1,
text='[{"test": "json"}]',
type=KookMessageType.CARD,
),
@@ -159,7 +159,7 @@ async def test_kook_event_warp_message(
input_message: BaseMessageComponent,
upload_asset_return: str,
expected_output: OrderMessage,
expected_error: type[BaseException] | None,
expected_error: type[Exception] | None,
):
client = await mock_kook_client(
upload_asset_return,
@@ -185,4 +185,39 @@ async def test_kook_event_warp_message(
result = await event._wrap_message(1, input_message)
assert result == expected_output
# @pytest.mark.asyncio
# @pytest.mark.parametrize(
# "message_chain,send_text_expected_output,expected_error",
# [
# (
# MessageChain(
# chain=[
# Image(file="test image"),
# Plain(text="test plain"),
# ],
# ),
# ""
# ),
# ],
# )
# async def test_kook_event_send():
# client = await mock_kook_client(
# "",
# "",
# )
# event = KookEvent(
# "",
# mock_astrbot_message(),
# PlatformMetadata(
# name="test",
# id="test",
# description="test",
# ),
# "",
# client,
# )
# await event.send(message=mock_astrbot_message())
+1 -42
View File
@@ -16,9 +16,6 @@ from astrbot.core.platform.sources.kook.kook_types import (
InviteModule,
KmarkdownElement,
KookCardMessage,
KookMessageSignal,
KookModuleType,
KookWebsocketEvent,
ParagraphStructure,
PlainTextElement,
SectionModule,
@@ -80,7 +77,7 @@ def test_all_kook_card_type():
FileModule(
src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
title="test5",
type=KookModuleType.FILE,
type="file",
),
CountdownModule(
endTime=1772343427360,
@@ -108,41 +105,3 @@ def test_all_kook_card_type():
],
).to_json(indent=4, ensure_ascii=False)
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,
}
-67
View File
@@ -2,7 +2,6 @@ from types import SimpleNamespace
import pytest
from astrbot.core.provider.sources.groq_source import ProviderGroq
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
@@ -33,21 +32,6 @@ def _make_provider(overrides: dict | None = None) -> ProviderOpenAIOfficial:
)
def _make_groq_provider(overrides: dict | None = None) -> ProviderGroq:
provider_config = {
"id": "test-groq",
"type": "groq_chat_completion",
"model": "qwen/qwen3-32b",
"key": ["test-key"],
}
if overrides:
provider_config.update(overrides)
return ProviderGroq(
provider_config=provider_config,
provider_settings={},
)
@pytest.mark.asyncio
async def test_handle_api_error_content_moderated_removes_images():
provider = _make_provider(
@@ -214,57 +198,6 @@ def test_extract_error_text_candidates_truncates_long_response_text():
)
@pytest.mark.asyncio
async def test_openai_payload_keeps_reasoning_content_in_assistant_history():
provider = _make_provider()
try:
payloads = {
"messages": [
{
"role": "assistant",
"content": [
{"type": "think", "think": "step 1"},
{"type": "text", "text": "final answer"},
],
}
]
}
provider._finally_convert_payload(payloads)
assistant_message = payloads["messages"][0]
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
assert assistant_message["reasoning_content"] == "step 1"
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_groq_payload_drops_reasoning_content_from_assistant_history():
provider = _make_groq_provider()
try:
payloads = {
"messages": [
{
"role": "assistant",
"content": [
{"type": "think", "think": "step 1"},
{"type": "text", "text": "final answer"},
],
}
]
}
provider._finally_convert_payload(payloads)
assistant_message = payloads["messages"][0]
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
assert "reasoning_content" not in assistant_message
assert "reasoning" not in assistant_message
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_handle_api_error_content_moderated_without_images_raises():
provider = _make_provider(
-33
View File
@@ -49,39 +49,6 @@ def test_parse_frontmatter_quoted_description():
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 ----------
-58
View File
@@ -39,7 +39,6 @@ def mock_context():
ctx.persona_manager.resolve_selected_persona = AsyncMock(
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.subagent_orchestrator = None
return ctx
@@ -539,63 +538,6 @@ class TestEnsurePersonaAndSkills:
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:
"""Tests for _decorate_llm_request function."""
-110
View File
@@ -1,110 +0,0 @@
from copy import deepcopy
from unittest.mock import MagicMock, patch
import pytest
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
def _build_cfg(agent_overrides: dict) -> dict:
agent = {
"name": "planner",
"enabled": True,
"persona_id": None,
"system_prompt": "inline prompt",
"public_description": "",
"tools": ["tool_a", " ", "tool_b"],
}
agent.update(agent_overrides)
return {"agents": [agent]}
@pytest.mark.asyncio
async def test_reload_from_config_default_persona_is_resolved():
tool_mgr = MagicMock()
persona_mgr = MagicMock()
default_persona = {
"name": "default",
"prompt": "You are a helpful and friendly assistant.",
"tools": None,
"_begin_dialogs_processed": [],
}
persona_mgr.get_persona_v3_by_id.return_value = deepcopy(default_persona)
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
await orchestrator.reload_from_config(_build_cfg({"persona_id": "default"}))
assert len(orchestrator.handoffs) == 1
handoff = orchestrator.handoffs[0]
assert handoff.agent.instructions == default_persona["prompt"]
assert handoff.agent.tools is None
assert handoff.agent.begin_dialogs == default_persona["_begin_dialogs_processed"]
@pytest.mark.asyncio
async def test_reload_from_config_missing_persona_falls_back_to_inline_and_warns():
tool_mgr = MagicMock()
persona_mgr = MagicMock()
persona_mgr.get_persona_v3_by_id.return_value = None
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
with patch("astrbot.core.subagent_orchestrator.logger") as mock_logger:
await orchestrator.reload_from_config(_build_cfg({"persona_id": "not_exists"}))
assert len(orchestrator.handoffs) == 1
handoff = orchestrator.handoffs[0]
assert handoff.agent.instructions == "inline prompt"
assert handoff.agent.tools == ["tool_a", "tool_b"]
assert handoff.agent.begin_dialogs is None
mock_logger.warning.assert_called_once_with(
"SubAgent persona %s not found, fallback to inline prompt.",
"not_exists",
)
@pytest.mark.asyncio
async def test_reload_from_config_uses_processed_begin_dialogs_and_deepcopy():
tool_mgr = MagicMock()
persona_mgr = MagicMock()
processed_dialogs = [{"role": "user", "content": "hello", "_no_save": True}]
persona_mgr.get_persona_v3_by_id.return_value = {
"name": "custom",
"prompt": "persona prompt",
"tools": ["tool_from_persona"],
"_begin_dialogs_processed": processed_dialogs,
}
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
await orchestrator.reload_from_config(_build_cfg({"persona_id": "custom"}))
processed_dialogs[0]["content"] = "mutated"
handoff = orchestrator.handoffs[0]
assert handoff.agent.instructions == "persona prompt"
assert handoff.agent.tools == ["tool_from_persona"]
assert handoff.agent.begin_dialogs[0]["content"] == "hello"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("raw_tools", "expected_tools"),
[
(None, None),
([], []),
("not-a-list", []),
],
)
async def test_reload_from_config_tool_normalization(raw_tools, expected_tools):
tool_mgr = MagicMock()
persona_mgr = MagicMock()
persona_mgr.get_persona_v3_by_id.return_value = {
"name": "custom",
"prompt": "persona prompt",
"tools": raw_tools,
"_begin_dialogs_processed": [],
}
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
await orchestrator.reload_from_config(_build_cfg({"persona_id": "custom"}))
handoff = orchestrator.handoffs[0]
assert handoff.agent.tools == expected_tools