From 16c81a4f3026e73a14c478a584e9cc25e8696020 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 14:43:19 +0800 Subject: [PATCH 01/36] =?UTF-8?q?feat:=20=E5=B0=86kook=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=E6=8F=92=E4=BB=B6=E5=B9=B6=E5=85=A5astrbot=E5=AE=98?= =?UTF-8?q?=E6=96=B9=E9=80=82=E9=85=8D=E5=99=A8=E7=9B=AE=E5=BD=95=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 6 + astrbot/core/platform/manager.py | 4 + astrbot/core/platform/sources/kook/config.py | 75 ++++ .../platform/sources/kook/kook_adapter.py | 261 +++++++++++ .../core/platform/sources/kook/kook_client.py | 415 ++++++++++++++++++ .../core/platform/sources/kook/kook_event.py | 181 ++++++++ .../core/platform/sources/kook/kook_types.py | 241 ++++++++++ 7 files changed, 1183 insertions(+) create mode 100644 astrbot/core/platform/sources/kook/config.py create mode 100644 astrbot/core/platform/sources/kook/kook_adapter.py create mode 100644 astrbot/core/platform/sources/kook/kook_client.py create mode 100644 astrbot/core/platform/sources/kook/kook_event.py create mode 100644 astrbot/core/platform/sources/kook/kook_types.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 3ba68fa898..0cc28509d7 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -449,6 +449,12 @@ class ChatProviderTemplate(TypedDict): "satori_heartbeat_interval": 10, "satori_reconnect_delay": 5, }, + "kook": { + "id": "kook", + "type": "kook", + "enable": False, + "token": "", + }, # "WebChat": { # "id": "webchat", # "type": "webchat", diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 0238779dad..68737b2bcf 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -180,6 +180,10 @@ async def load_platform(self, platform_config: dict) -> None: from .sources.line.line_adapter import ( LinePlatformAdapter, # noqa: F401 ) + case "kook": + from .sources.kook.kook_adapter import ( + KookPlatformAdapter, # noqa: F401 + ) except (ImportError, ModuleNotFoundError) as e: logger.error( f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。", diff --git a/astrbot/core/platform/sources/kook/config.py b/astrbot/core/platform/sources/kook/config.py new file mode 100644 index 0000000000..8bae6c74da --- /dev/null +++ b/astrbot/core/platform/sources/kook/config.py @@ -0,0 +1,75 @@ +""" +KOOK适配器配置文件 +包含连接参数、重连策略、心跳设置等配置项 +""" + +# 连接配置 +CONNECTION_CONFIG = { + # 心跳配置 + "heartbeat_interval": 30, # 心跳间隔(秒) + "heartbeat_timeout": 6, # 心跳超时时间(秒) + "max_heartbeat_failures": 3, # 最大心跳失败次数 + # 重连配置 + "initial_reconnect_delay": 1, # 初始重连延迟(秒) + "max_reconnect_delay": 60, # 最大重连延迟(秒) + "max_consecutive_failures": 5, # 最大连续失败次数 + # WebSocket配置 + "websocket_timeout": 10, # WebSocket接收超时(秒) + "connection_timeout": 30, # 连接超时(秒) + # 消息处理配置 + "enable_compression": True, # 是否启用消息压缩 + "max_message_size": 1024 * 1024, # 最大消息大小(字节) +} + +# 日志配置 +LOGGING_CONFIG = { + "level": "INFO", # 日志级别:DEBUG, INFO, WARNING, ERROR + "format": "[KOOK] %(message)s", + "enable_heartbeat_logs": False, # 是否启用心跳日志 + "enable_message_logs": False, # 是否启用消息日志 +} + +# 错误处理配置 +ERROR_HANDLING_CONFIG = { + "retry_on_network_error": True, # 网络错误时是否重试 + "retry_on_token_expired": True, # Token过期时是否重试 + "max_retry_attempts": 3, # 最大重试次数 + "retry_delay_base": 2, # 重试延迟基数(秒) +} + +# 性能配置 +PERFORMANCE_CONFIG = { + "enable_message_buffering": True, # 是否启用消息缓冲 + "buffer_size": 100, # 缓冲区大小 + "enable_connection_pooling": True, # 是否启用连接池 + "max_concurrent_requests": 10, # 最大并发请求数 +} + +# 安全配置 +SECURITY_CONFIG = { + "verify_ssl": True, # 是否验证SSL证书 + "enable_rate_limiting": True, # 是否启用速率限制 + "rate_limit_requests": 100, # 速率限制请求数 + "rate_limit_window": 60, # 速率限制窗口(秒) +} + + +def get_config(): + """获取完整配置""" + return { + "connection": CONNECTION_CONFIG, + "logging": LOGGING_CONFIG, + "error_handling": ERROR_HANDLING_CONFIG, + "performance": PERFORMANCE_CONFIG, + "security": SECURITY_CONFIG, + } + + +def get_connection_config(): + """获取连接配置""" + return CONNECTION_CONFIG + + +def get_logging_config(): + """获取日志配置""" + return LOGGING_CONFIG diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py new file mode 100644 index 0000000000..a1ee71c0d4 --- /dev/null +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -0,0 +1,261 @@ +import asyncio +import json +import re + +from astrbot import logger +from astrbot.api.event import MessageChain +from astrbot.api.message_components import Image, Plain +from astrbot.api.platform import ( + AstrBotMessage, + MessageMember, + MessageType, + Platform, + PlatformMetadata, + register_platform_adapter, +) +from astrbot.core.platform.astr_message_event import MessageSesion + +from .kook_client import KookClient +from .kook_event import KookEvent + + +@register_platform_adapter( + "kook", "KOOK 适配器", default_config_tmpl={"token": "你kook获取到的机器人token"} +) +class KookPlatformAdapter(Platform): + def __init__( + self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue + ) -> None: + super().__init__(platform_config, event_queue) + self.config = platform_config + self.settings = platform_settings + self.client = None + self._reconnect_task = None + self.running = False + self._main_task = None + self._bot_id = "" + + async def send_by_session( + self, session: MessageSesion, message_chain: MessageChain + ): + inner_message = AstrBotMessage() + inner_message.session_id = session.session_id + inner_message.type = session.message_type + message_event = KookEvent( + message_str="kook", + message_obj=inner_message, + platform_meta=self.meta(), + session_id=session.session_id, + client=self.client, + ) + await message_event.send(message_chain) + await super().send_by_session(session, message_chain) + + def meta(self) -> PlatformMetadata: + return PlatformMetadata( + name="kook", description="KOOK 适配器", id=self.config.get("id") + ) + + async def run(self): + """主运行循环""" + self.running = True + logger.info("[KOOK] 启动KOOK适配器") + + async def on_received(data): + logger.debug(f"KOOK 收到数据: {data}") + if "d" in data and data["s"] == 0: + event_type = data["d"].get("type") + # 支持type=9(文本)和type=10(卡片) + if event_type in (9, 10): + try: + abm = await self.convert_message(data["d"]) + await self.handle_msg(abm) + except Exception as e: + logger.error(f"[KOOK] 消息处理异常: {e}") + + self.client = KookClient(self.config["token"], on_received) + + # 启动主循环 + self._main_task = asyncio.create_task(self._main_loop()) + + try: + await self._main_task + except asyncio.CancelledError: + logger.info("[KOOK] 适配器被取消") + except Exception as e: + logger.error(f"[KOOK] 适配器运行异常: {e}") + finally: + self.running = False + await self._cleanup() + + async def _main_loop(self): + """主循环,处理连接和重连""" + consecutive_failures = 0 + max_consecutive_failures = 5 + + while self.running: + try: + logger.info("[KOOK] 尝试连接KOOK服务器...") + + # 尝试连接 + success = await self.client.connect() + + if success: + logger.info("[KOOK] 连接成功,开始监听消息") + consecutive_failures = 0 # 重置失败计数 + + # 等待连接结束(可能是正常关闭或异常) + while self.client.running and self.running: + try: + # 等待 client 内部触发 _stop_event,或者超时 1 秒后重试 + # 使用 wait_for 配合 timeout 是为了防止极端情况下 self.running 变化没被察觉 + await asyncio.wait_for( + self.client.wait_until_closed(), timeout=1.0 + ) + except asyncio.TimeoutError: + # 正常超时,继续下一轮 while 检查 + continue + + if self.running: + logger.warning("[KOOK] 连接断开,准备重连") + + else: + consecutive_failures += 1 + logger.error( + f"[KOOK] 连接失败,连续失败次数: {consecutive_failures}" + ) + + if consecutive_failures >= max_consecutive_failures: + logger.error("[KOOK] 连续失败次数过多,停止重连") + break + + # 等待一段时间后重试 + wait_time = min(2**consecutive_failures, 60) # 指数退避,最大60秒 + logger.info(f"[KOOK] 等待 {wait_time} 秒后重试...") + await asyncio.sleep(wait_time) + + except Exception as e: + consecutive_failures += 1 + logger.error(f"[KOOK] 主循环异常: {e}") + + if consecutive_failures >= max_consecutive_failures: + logger.error("[KOOK] 连续异常次数过多,停止重连") + break + + await asyncio.sleep(5) + + async def _cleanup(self): + """清理资源""" + logger.info("[KOOK] 开始清理资源") + + if self.client: + try: + await self.client.close() + except Exception as e: + logger.error(f"[KOOK] 关闭客户端异常: {e}") + + if self._main_task and not self._main_task.done(): + self._main_task.cancel() + try: + await self._main_task + except asyncio.CancelledError: + pass + + logger.info("[KOOK] 资源清理完成") + + async def convert_message(self, data: dict) -> AstrBotMessage: + abm = AstrBotMessage() + abm.raw_message = data + abm.self_id = self.client.bot_id + + channel_type = data.get("channel_type") + # channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction + match channel_type: + case "GROUP": + abm.type = MessageType.GROUP_MESSAGE + abm.group_id = data.get("target_id") + abm.session_id = data.get("target_id") + case "PERSON": + abm.type = MessageType.FRIEND_MESSAGE + abm.group_id = "" + abm.session_id = data.get("author_id") + case "BROADCAST": + abm.type = MessageType.OTHER_MESSAGE + abm.group_id = data.get("target_id") + abm.session_id = data.get("target_id") + case _: + raise ValueError(f"[KOOK] 不支持的频道类型: {channel_type}") + + abm.sender = MessageMember( + user_id=data.get("author_id"), + nickname=data.get("extra", {}).get("author", {}).get("username", ""), + ) + + abm.message_id = data.get("msg_id") + + # 普通文本消息 + if data.get("type") == 9: + raw_content = ( + data.get("extra", {}) + .get("kmarkdown", {}) + .get("raw_content", data.get("content")) + ) + + raw_content = re.sub( + r"^@[^\s]+(\s*-\s*[^\s]+)?\s*", "", raw_content + ) # 删除@前缀 + abm.message_str = raw_content + abm.message = [Plain(text=raw_content)] + # 卡片消息 + elif data.get("type") == 10: + content = data.get("content") + try: + card_list = json.loads(content) + text = "" + images = [] + for card in card_list: + for module in card.get("modules", []): + if module.get("type") == "section": + text += module.get("text", {}).get("content", "") + elif module.get("type") == "container": + for element in module.get("elements", []): + if element.get("type") == "image": + images.append(element.get("src")) + abm.message_str = text + abm.message = [] + if text: + abm.message.append(Plain(text=text)) + for img_url in images: + abm.message.append(Image(file=img_url)) + except Exception: + abm.message_str = "[卡片消息解析失败]" + abm.message = [Plain(text="[卡片消息解析失败]")] + else: + abm.message_str = "[不支持的消息类型]" + abm.message = [Plain(text="[不支持的消息类型]")] + + return abm + + async def handle_msg(self, message: AstrBotMessage): + message_event = KookEvent( + message_str=message.message_str, + message_obj=message, + platform_meta=self.meta(), + session_id=message.session_id, + client=self.client, + ) + raw = message.raw_message + is_at = False + # 检查kmarkdown.mention_role_part + kmarkdown = raw.get("extra", {}).get("kmarkdown", {}) + mention_role_part = kmarkdown.get("mention_role_part", []) + raw_content = kmarkdown.get("raw_content", "") + bot_nickname = "astrbot" + if mention_role_part: + is_at = True + elif f"@{bot_nickname}" in raw_content: + is_at = True + if is_at: + message_event.is_wake = True + message_event.is_at_or_wake_command = True + self.commit_event(message_event) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py new file mode 100644 index 0000000000..14d21124a7 --- /dev/null +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -0,0 +1,415 @@ +import asyncio +import base64 +import json +import random +import time +import zlib +from pathlib import Path + +import aiofiles +import aiohttp +import websockets + +from astrbot import logger +from astrbot.core.platform.message_type import MessageType + +from .kook_types import KookApiPaths, KookMessageType + + +class KookClient: + def __init__(self, token, event_callback): + self._bot_id = "" + self._http_client = aiohttp.ClientSession( + headers={ + "Authorization": f"Bot {token}", + } + ) + self.event_callback = event_callback # 回调函数,用于处理接收到的事件 + self.ws = None + self.running = False + self._stop_event = asyncio.Event() # 用于通知连接结束 + self.session_id = None + self.last_sn = 0 # 记录最后处理的消息序号 + self.heartbeat_task = None + self.reconnect_delay = 1 # 重连延迟,指数退避 + self.max_reconnect_delay = 60 # 最大重连延迟 + self.heartbeat_interval = 30 # 心跳间隔 + self.heartbeat_timeout = 6 # 心跳超时时间 + self.last_heartbeat_time = 0 + self.heartbeat_failed_count = 0 + self.max_heartbeat_failures = 3 # 最大心跳失败次数 + + @property + def bot_id(self): + return self._bot_id + + async def get_bot_id(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] 获取机器人账号ID失败,状态码: {resp.status}") + return "" + + data = await resp.json() + if data.get("code") != 0: + logger.error(f"[KOOK] 获取机器人账号ID失败: {data}") + return "" + + bot_id: str = data["data"]["id"] + logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}") + return bot_id + except Exception as e: + logger.error(f"[KOOK] 获取机器人账号ID异常: {e}") + return "" + + async def get_gateway_url(self, resume=False, sn=0, session_id=None): + """获取网关连接地址""" + url = KookApiPaths.GATEWAY_INDEX + + # 构建连接参数 + params = {} + if resume: + params["resume"] = 1 + params["sn"] = sn + if session_id: + params["session_id"] = session_id + + try: + async with self._http_client.get(url, params=params) as resp: + if resp.status != 200: + logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}") + return None + + data = await resp.json() + if data.get("code") != 0: + logger.error(f"[KOOK] 获取gateway失败: {data}") + return None + + gateway_url = data["data"]["url"] + logger.info(f"[KOOK] 获取gateway成功: {gateway_url}") + return gateway_url + except Exception as e: + logger.error(f"[KOOK] 获取gateway异常: {e}") + return None + + async def connect(self, resume=False): + """连接WebSocket""" + self._stop_event.clear() + try: + # 获取gateway地址 + gateway_url = await self.get_gateway_url( + resume=resume, sn=self.last_sn, session_id=self.session_id + ) + bot_id = await self.get_bot_id() + + if not gateway_url: + return False + if not bot_id: + return False + + self._bot_id = bot_id + + # 连接WebSocket + self.ws = await websockets.connect(gateway_url) + self.running = True + logger.info("[KOOK] WebSocket 连接成功") + + # 启动心跳任务 + if self.heartbeat_task: + self.heartbeat_task.cancel() + self.heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + + # 开始监听消息 + await self.listen() + return True + + except Exception as e: + logger.error(f"[KOOK] WebSocket 连接失败: {e}") + return False + + async def listen(self): + """监听WebSocket消息""" + try: + while self.running: + try: + msg = await asyncio.wait_for(self.ws.recv(), timeout=10) # type: ignore + + if isinstance(msg, bytes): + try: + msg = zlib.decompress(msg) + except Exception as e: + logger.error(f"[KOOK] 解压消息失败: {e}") + continue + msg = msg.decode("utf-8") + + logger.debug(f"[KOOK] 收到原始消息: {msg}") + data = json.loads(msg) + + # 处理不同类型的信令 + await self._handle_signal(data) + + except asyncio.TimeoutError: + # 超时检查,继续循环 + continue + except websockets.exceptions.ConnectionClosed: + logger.warning("[KOOK] WebSocket连接已关闭") + break + except Exception as e: + logger.error(f"[KOOK] 消息处理异常: {e}") + break + + except Exception as e: + logger.error(f"[KOOK] WebSocket 监听异常: {e}") + finally: + self.running = False + self._stop_event.set() + + async def _handle_signal(self, data): + """处理不同类型的信令""" + signal_type = data.get("s") + + if signal_type == 0: # 事件消息 + # 更新消息序号 + if "sn" in data: + self.last_sn = data["sn"] + await self.event_callback(data) + + elif signal_type == 1: # HELLO握手 + await self._handle_hello(data) + + elif signal_type == 3: # PONG心跳响应 + await self._handle_pong(data) + + elif signal_type == 5: # RECONNECT重连指令 + await self._handle_reconnect(data) + + elif signal_type == 6: # RESUME ACK + await self._handle_resume_ack(data) + + else: + logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}") + + async def _handle_hello(self, data): + """处理HELLO握手""" + hello_data = data.get("d", {}) + code = hello_data.get("code", 0) + + if code == 0: + self.session_id = hello_data.get("session_id") + logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}") + # 重置重连延迟 + self.reconnect_delay = 1 + else: + logger.error(f"[KOOK] 握手失败,错误码: {code}") + if code == 40103: # token过期 + logger.error("[KOOK] Token已过期,需要重新获取") + self.running = False + + async def _handle_pong(self, data): + """处理PONG心跳响应""" + self.last_heartbeat_time = time.time() + self.heartbeat_failed_count = 0 + logger.debug("[KOOK] 收到心跳响应") + + async def _handle_reconnect(self, data): + """处理重连指令""" + logger.warning("[KOOK] 收到重连指令") + # 清空本地状态 + self.last_sn = 0 + self.session_id = None + self.running = False + + async def _handle_resume_ack(self, data): + """处理RESUME确认""" + 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): + """心跳循环""" + while self.running: + try: + # 随机化心跳间隔 (30±5秒) + interval = self.heartbeat_interval + random.randint(-5, 5) + await asyncio.sleep(interval) + + if not self.running: + break + + # 发送心跳 + await self._send_ping() + + # 等待PONG响应 + await asyncio.sleep(self.heartbeat_timeout) + + # 检查是否收到PONG响应 + if time.time() - self.last_heartbeat_time > self.heartbeat_timeout: + self.heartbeat_failed_count += 1 + logger.warning( + f"[KOOK] 心跳超时,失败次数: {self.heartbeat_failed_count}" + ) + + if self.heartbeat_failed_count >= self.max_heartbeat_failures: + logger.error("[KOOK] 心跳失败次数过多,准备重连") + self.running = False + break + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"[KOOK] 心跳异常: {e}") + self.heartbeat_failed_count += 1 + + async def _send_ping(self): + """发送心跳PING""" + try: + ping_data = {"s": 2, "sn": self.last_sn} + await self.ws.send(json.dumps(ping_data)) # type: ignore + logger.debug(f"[KOOK] 发送心跳,sn: {self.last_sn}") + except Exception as e: + logger.error(f"[KOOK] 发送心跳失败: {e}") + + async def reconnect(self): + """重连方法""" + logger.info(f"[KOOK] 开始重连,延迟: {self.reconnect_delay}秒") + await asyncio.sleep(self.reconnect_delay) + + # 关闭当前连接 + await self.close() + + # 尝试重连 + success = await self.connect(resume=True) + + if success: + # 重连成功,重置延迟 + self.reconnect_delay = 1 + logger.info("[KOOK] 重连成功") + else: + # 重连失败,增加延迟(指数退避) + self.reconnect_delay = min( + self.reconnect_delay * 2, self.max_reconnect_delay + ) + logger.warning(f"[KOOK] 重连失败,下次延迟: {self.reconnect_delay}秒") + + return success + + async def send_text( + self, + target_id: str, + content: str, + astrbot_message_type: MessageType, + kook_message_type: KookMessageType, + reply_message_id: str | int = "", + ): + """发送文本消息 + 消息发送接口文档参见: https://developer.kookapp.cn/doc/http/message#%E5%8F%91%E9%80%81%E9%A2%91%E9%81%93%E8%81%8A%E5%A4%A9%E6%B6%88%E6%81%AF + KMarkdown格式参见: https://developer.kookapp.cn/doc/kmarkdown-desc + """ + url = KookApiPaths.CHANNEL_MESSAGE_CREATE + if astrbot_message_type == MessageType.FRIEND_MESSAGE: + url = KookApiPaths.DIRECT_MESSAGE_CREATE + + payload = { + "target_id": target_id, + "content": content, + "type": kook_message_type, + } + if reply_message_id: + payload["quote"] = reply_message_id + payload["reply_msg_id"] = reply_message_id + + try: + async with self._http_client.post(url, json=payload) as resp: + if resp.status == 200: + result = await resp.json() + if result.get("code") == 0: + logger.info("[KOOK] 发送消息成功") + else: + logger.error( + f'[KOOK] 发送kook消息类型 "{kook_message_type.name}" 失败: {result}' + ) + else: + logger.error( + f'[KOOK] 发送kook消息类型 "{kook_message_type.name}" HTTP错误: {resp.status} , 响应内容 : {await resp.text()}' + ) + except Exception as e: + logger.error( + f'[KOOK] 发送kook消息类型 "{kook_message_type.name}" 异常: {e}' + ) + + async def upload_asset(self, file_url: str | None) -> str: + """上传文件到kook,获得远端资源url + 接口定义参见: https://developer.kookapp.cn/doc/http/asset + """ + if file_url is None: + return "" + + bytes_data: bytes | None = None + filename = "unknown" + if file_url.startswith(("http://", "https://")): + filename = file_url.split("/")[-1] + return file_url + + elif file_url.startswith(("base64://", "base64:///")): + # b64_str = file_url.replace("base64:///", "") + b64_str = file_url.replace("base64://", "") + bytes_data = base64.b64decode(b64_str) + + else: + file_url = file_url.replace("file:///", "") + file_url = file_url.replace("file://", "") + filename = Path(file_url).name + async with aiofiles.open(file_url, "rb") as f: + bytes_data = await f.read() + + data = aiohttp.FormData() + data.add_field("file", bytes_data, filename=filename) + + url = KookApiPaths.ASSET_CREATE + try: + async with self._http_client.post(url, data=data) as resp: + if resp.status == 200: + result: dict = await resp.json() + if result.get("code") == 0: + logger.info("[KOOK] 发送文件消息成功") + remote_url = result["data"]["url"] + logger.debug(f"[KOOK] 文件远端URL: {remote_url}") + return remote_url + else: + logger.error(f"[KOOK] 发送文件消息失败: {result}") + else: + logger.error(f"[KOOK] 发送文件消息HTTP错误: {resp.status}") + except Exception as e: + logger.error(f"[KOOK] 发送文件消息异常: {e}") + + return "" + + async def wait_until_closed(self): + """提供给外部调用的等待方法""" + await self._stop_event.wait() + + async def close(self): + """关闭连接""" + self.running = False + self._stop_event.set() + + if self.heartbeat_task: + self.heartbeat_task.cancel() + try: + await self.heartbeat_task + except asyncio.CancelledError: + pass + + if self.ws: + try: + await self.ws.close() + except Exception as e: + logger.error(f"[KOOK] 关闭WebSocket异常: {e}") + + if self._http_client: + await self._http_client.close() + + logger.info("[KOOK] 连接已关闭") diff --git a/astrbot/core/platform/sources/kook/kook_event.py b/astrbot/core/platform/sources/kook/kook_event.py new file mode 100644 index 0000000000..09a9d666b7 --- /dev/null +++ b/astrbot/core/platform/sources/kook/kook_event.py @@ -0,0 +1,181 @@ +import asyncio +import json +from collections.abc import Coroutine +from pathlib import Path +from typing import Any + +from astrbot import logger +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.platform import AstrBotMessage, PlatformMetadata +from astrbot.core.message.components import ( + At, + AtAll, + BaseMessageComponent, + File, + Image, + Json, + Plain, + Record, + Reply, + Video, +) +from astrbot.core.platform import MessageType + +from .kook_client import KookClient +from .kook_types import ( + FileModule, + KookCardMessage, + KookCardMessageContainer, + KookMessageType, + OrderMessage, +) + + +class KookEvent(AstrMessageEvent): + def __init__( + self, + message_str: str, + message_obj: AstrBotMessage, + platform_meta: PlatformMetadata, + session_id: str, + client: KookClient, + ): + super().__init__(message_str, message_obj, platform_meta, session_id) + self.client = client + self.channel_id = message_obj.group_id or message_obj.session_id + self.astrbot_message_type: MessageType = message_obj.type + self._file_message_counter = 0 + + def _warp_message( + self, index: int, message_component: BaseMessageComponent + ) -> Coroutine[Any, Any, OrderMessage]: + async def wrap_upload( + index: int, message_type: KookMessageType, upload_coro + ) -> OrderMessage: + url = await upload_coro + return OrderMessage(index=index, text=url, type=message_type) + + async def handle_plain( + index: int, + text: str | None, + reply_id: str | int = "", + type: KookMessageType = KookMessageType.KMARKDOWN, + ): + if not text: + text = "" + return OrderMessage( + index=index, + text=text, + type=type, + reply_id=reply_id, + ) + + match message_component: + case Image(): + self._file_message_counter += 1 + return wrap_upload( + index, + KookMessageType.IMAGE, + self.client.upload_asset(message_component.file), + ) + + case Video(): + self._file_message_counter += 1 + return wrap_upload( + index, + KookMessageType.VIDEO, + self.client.upload_asset(message_component.file), + ) + case File(): + + async def handle_file(index: int, f_item: File): + f_data = await f_item.get_file() + url = await self.client.upload_asset(f_data) + return OrderMessage( + index=index, text=url, type=KookMessageType.FILE + ) + + self._file_message_counter += 1 + return handle_file(index, message_component) + + case Record(): + + async def handle_audio(index: int, f_item: Record): + file_path = await f_item.convert_to_file_path() + url = await self.client.upload_asset(file_path) + title = f_item.text or Path(file_path).name + return OrderMessage( + index=index, + text=KookCardMessageContainer( + [ + KookCardMessage( + modules=[ + FileModule( + type="audio", + title=title, + src=url, + ) + ] + ) + ] + ).to_json(), + type=KookMessageType.CARD, + ) + + return handle_audio(index, message_component) + case Plain(): + return handle_plain(index, message_component.text) + case At(): + return handle_plain(index, f"(met){message_component.qq}(met)") + case AtAll(): + return handle_plain(index, "(met)all(met)") + case Reply(): + return handle_plain(index, "", reply_id=message_component.id) + case Json(): + json_data = message_component.data + # kook卡片json外层得是一个列表 + if isinstance(json_data, dict): + json_data = [json_data] + return handle_plain( + index, + # 考虑到kook可能会更改消息结构,为了能让插件开发者 + # 自行根据kook文档描述填卡片json内容,故不做模型校验 + # KookCardMessage().model_validate(message_component.data).to_json(), + text=json.dumps(json_data), + type=KookMessageType.CARD, + ) + case _: + raise NotImplementedError( + f'kook适配器尚未实现对 "{message_component.type}" 消息类型的支持' + ) + + async def send(self, message: MessageChain): + file_upload_tasks: list[Coroutine[Any, Any, OrderMessage]] = [] + for index, item in enumerate(message.chain): + file_upload_tasks.append(self._warp_message(index, item)) + + if self._file_message_counter > 0: + logger.debug("[Kook] 正在向kook服务器上传文件") + order_messages = await asyncio.gather(*file_upload_tasks) + order_messages.sort(key=lambda x: x.index) + + # 考虑到reply可能多次出现在消息链中(虽然大概率不会有人这么用) + # 这里还是不对reply进行排序了 + # order_messages.sort(key=lambda x: 0 if x.reply_id else 1) + + reply_id: str | int = "" + for item in order_messages: + if item.reply_id: + reply_id = item.reply_id + if not item.text: + logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"') + continue + await self.client.send_text( + self.channel_id, + item.text, + self.astrbot_message_type, + item.type, + reply_id, + ) + + await super().send(message) diff --git a/astrbot/core/platform/sources/kook/kook_types.py b/astrbot/core/platform/sources/kook/kook_types.py new file mode 100644 index 0000000000..dd18ac00f1 --- /dev/null +++ b/astrbot/core/platform/sources/kook/kook_types.py @@ -0,0 +1,241 @@ +import json +from dataclasses import field +from enum import IntEnum +from typing import Literal + +from pydantic import BaseModel, ConfigDict +from pydantic.dataclasses import dataclass + + +class KookApiPaths: + """Kook Api 路径""" + + BASE_URL = "https://www.kookapp.cn" + API_VERSION_PATH = "/api/v3" + + # 初始化相关 + USER_ME = f"{BASE_URL}{API_VERSION_PATH}/user/me" + GATEWAY_INDEX = f"{BASE_URL}{API_VERSION_PATH}/gateway/index" + + # 消息相关 + ASSET_CREATE = f"{BASE_URL}{API_VERSION_PATH}/asset/create" + ## 频道消息 + CHANNEL_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/message/create" + ## 私聊消息 + DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create" + + +# 定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction +class KookMessageType(IntEnum): + TEXT = 1 + IMAGE = 2 + VIDEO = 3 + FILE = 4 + AUDIO = 8 + KMARKDOWN = 9 + CARD = 10 + SYSTEM = 255 + + +ThemeType = Literal[ + "primary", "success", "danger", "warning", "info", "secondary", "none", "invisible" +] +"""主题,可选的值为:primary, success, danger, warning, info, secondary, none.默认为 primary,为 none 时不显示侧边框。""" +SizeType = Literal["xs", "sm", "md", "lg"] +"""大小,可选值为:xs, sm, md, lg, 一般默认为 lg""" + +SectionMode = Literal["left", "right"] +CountdownMode = Literal["day", "hour", "second"] + + +class KookCardColor(str): + """16 进制色值""" + + +class KookCardModelBase: + """卡片模块基类""" + + type: str + + +@dataclass +class PlainTextElement(KookCardModelBase): + content: str + type: str = "plain-text" + emoji: bool = True + + +@dataclass +class KmarkdownElement(KookCardModelBase): + content: str + type: str = "kmarkdown" + + +@dataclass +class ImageElement(KookCardModelBase): + src: str + type: str = "image" + alt: str = "" + size: SizeType = "lg" + circle: bool = False + fallbackUrl: str | None = None + + +@dataclass +class ButtonElement(KookCardModelBase): + text: str + type: str = "button" + theme: ThemeType = "primary" + value: str = "" + """当为 link 时,会跳转到 value 代表的链接; +当为 return-val 时,系统会通过系统消息将消息 id,点击用户 id 和 value 发回给发送者,发送者可以根据自己的需求进行处理,消息事件参见button 点击事件。私聊和频道内均可使用按钮点击事件。""" + click: Literal["", "link", "return-val"] = "" + """click 代表用户点击的事件,默认为"",代表无任何事件。""" + + +AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str + + +@dataclass +class ParagraphStructure(KookCardModelBase): + fields: list[PlainTextElement | KmarkdownElement] + type: str = "paragraph" + cols: int = 1 + """范围是 1-3 , 移动端忽略此参数""" + + +@dataclass +class HeaderModule(KookCardModelBase): + text: PlainTextElement + type: str = "header" + + +@dataclass +class SectionModule(KookCardModelBase): + text: PlainTextElement | KmarkdownElement | ParagraphStructure + type: str = "section" + mode: SectionMode = "left" + accessory: ImageElement | ButtonElement | None = None + + +@dataclass +class ImageGroupModule(KookCardModelBase): + """1 到多张图片的组合""" + + elements: list[ImageElement] + type: str = "image-group" + + +@dataclass +class ContainerModule(KookCardModelBase): + """1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。""" + + elements: list[ImageElement] + type: str = "container" + + +@dataclass +class ActionGroupModule(KookCardModelBase): + elements: list[ButtonElement] + type: str = "action-group" + + +@dataclass +class ContextModule(KookCardModelBase): + elements: list[PlainTextElement | KmarkdownElement | ImageElement] + """最多包含10个元素""" + type: str = "context" + + +@dataclass +class DividerModule(KookCardModelBase): + type: str = "divider" + + +@dataclass +class FileModule(KookCardModelBase): + src: str + title: str = "" + type: Literal["file", "audio", "video"] = "file" + cover: str | None = None + """cover 仅音频有效, 是音频的封面图""" + + +@dataclass +class CountdownModule(KookCardModelBase): + """startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。""" + + endTime: int + """毫秒时间戳""" + type: str = "countdown" + startTime: int | None = None + """毫秒时间戳, 仅当mode为second才有这个字段""" + mode: CountdownMode = "day" + """mode 主要是倒计时的样式""" + + +@dataclass +class InviteModule(KookCardModelBase): + code: str + """邀请链接或者邀请码""" + type: str = "invite" + + +# 所有模块的联合类型 +AnyModule = ( + HeaderModule + | SectionModule + | ImageGroupModule + | ContainerModule + | ActionGroupModule + | ContextModule + | DividerModule + | FileModule + | CountdownModule + | InviteModule +) + + +class KookCardMessage(BaseModel): + """卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage + 此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表** + 若要发送卡片消息,请使用KookCardMessageContainer + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + type: str = "card" + theme: ThemeType | None = None + size: SizeType | None = None + 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后发送出去""" + + def append(self, object: KookCardMessage) -> None: + return super().append(object) + + def to_json(self, indent: int | None = None, ensure_ascii: bool = True) -> str: + return json.dumps( + [i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii + ) + + +@dataclass +class OrderMessage: + index: int + text: str + type: KookMessageType + reply_id: str | int = "" From 864a8817ca4320a3cb276b9f246adc6588645cb2 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 15:13:37 +0800 Subject: [PATCH 02/36] =?UTF-8?q?refactor:=20=E9=87=8D=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=90=8D=E4=B8=BA=20=5Fwarp=5Fmessage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_event.py b/astrbot/core/platform/sources/kook/kook_event.py index 09a9d666b7..bd2e22407c 100644 --- a/astrbot/core/platform/sources/kook/kook_event.py +++ b/astrbot/core/platform/sources/kook/kook_event.py @@ -46,7 +46,7 @@ def __init__( self.astrbot_message_type: MessageType = message_obj.type self._file_message_counter = 0 - def _warp_message( + def _wrap_message( self, index: int, message_component: BaseMessageComponent ) -> Coroutine[Any, Any, OrderMessage]: async def wrap_upload( @@ -152,7 +152,7 @@ async def handle_audio(index: int, f_item: Record): async def send(self, message: MessageChain): file_upload_tasks: list[Coroutine[Any, Any, OrderMessage]] = [] for index, item in enumerate(message.chain): - file_upload_tasks.append(self._warp_message(index, item)) + file_upload_tasks.append(self._wrap_message(index, item)) if self._file_message_counter > 0: logger.debug("[Kook] 正在向kook服务器上传文件") From fd9b6a69d3995c7c558a6a4a5a5dc1a821fe143f Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 15:29:46 +0800 Subject: [PATCH 03/36] =?UTF-8?q?refactor:=20=E4=BD=BF=E7=94=A8Protocol?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2Union=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/platform/sources/kook/kook_types.py | 66 +++++++++---------- 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_types.py b/astrbot/core/platform/sources/kook/kook_types.py index dd18ac00f1..bd2765d2f2 100644 --- a/astrbot/core/platform/sources/kook/kook_types.py +++ b/astrbot/core/platform/sources/kook/kook_types.py @@ -1,7 +1,7 @@ import json from dataclasses import field from enum import IntEnum -from typing import Literal +from typing import Literal, Protocol from pydantic import BaseModel, ConfigDict from pydantic.dataclasses import dataclass @@ -52,27 +52,27 @@ class KookCardColor(str): """16 进制色值""" -class KookCardModelBase: - """卡片模块基类""" +class KookCardElement(Protocol): + """卡片元素协议""" type: str @dataclass -class PlainTextElement(KookCardModelBase): +class PlainTextElement(KookCardElement): content: str type: str = "plain-text" emoji: bool = True @dataclass -class KmarkdownElement(KookCardModelBase): +class KmarkdownElement(KookCardElement): content: str type: str = "kmarkdown" @dataclass -class ImageElement(KookCardModelBase): +class ImageElement(KookCardElement): src: str type: str = "image" alt: str = "" @@ -82,7 +82,7 @@ class ImageElement(KookCardModelBase): @dataclass -class ButtonElement(KookCardModelBase): +class ButtonElement(KookCardElement): text: str type: str = "button" theme: ThemeType = "primary" @@ -93,25 +93,34 @@ class ButtonElement(KookCardModelBase): """click 代表用户点击的事件,默认为"",代表无任何事件。""" -AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str +class KookCardStructure(Protocol): + """卡片结构协议""" + + type: str @dataclass -class ParagraphStructure(KookCardModelBase): +class ParagraphStructure(KookCardStructure): fields: list[PlainTextElement | KmarkdownElement] type: str = "paragraph" cols: int = 1 """范围是 1-3 , 移动端忽略此参数""" +class KookCardModule(Protocol): + """卡片模块协议""" + + type: str + + @dataclass -class HeaderModule(KookCardModelBase): +class HeaderModule(KookCardModule): text: PlainTextElement type: str = "header" @dataclass -class SectionModule(KookCardModelBase): +class SectionModule(KookCardModule): text: PlainTextElement | KmarkdownElement | ParagraphStructure type: str = "section" mode: SectionMode = "left" @@ -119,7 +128,7 @@ class SectionModule(KookCardModelBase): @dataclass -class ImageGroupModule(KookCardModelBase): +class ImageGroupModule(KookCardModule): """1 到多张图片的组合""" elements: list[ImageElement] @@ -127,7 +136,7 @@ class ImageGroupModule(KookCardModelBase): @dataclass -class ContainerModule(KookCardModelBase): +class ContainerModule(KookCardModule): """1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。""" elements: list[ImageElement] @@ -135,25 +144,25 @@ class ContainerModule(KookCardModelBase): @dataclass -class ActionGroupModule(KookCardModelBase): +class ActionGroupModule(KookCardModule): elements: list[ButtonElement] type: str = "action-group" @dataclass -class ContextModule(KookCardModelBase): +class ContextModule(KookCardModule): elements: list[PlainTextElement | KmarkdownElement | ImageElement] """最多包含10个元素""" type: str = "context" @dataclass -class DividerModule(KookCardModelBase): +class DividerModule(KookCardModule): type: str = "divider" @dataclass -class FileModule(KookCardModelBase): +class FileModule(KookCardModule): src: str title: str = "" type: Literal["file", "audio", "video"] = "file" @@ -162,7 +171,7 @@ class FileModule(KookCardModelBase): @dataclass -class CountdownModule(KookCardModelBase): +class CountdownModule(KookCardModule): """startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。""" endTime: int @@ -175,27 +184,12 @@ class CountdownModule(KookCardModelBase): @dataclass -class InviteModule(KookCardModelBase): +class InviteModule(KookCardModule): code: str """邀请链接或者邀请码""" type: str = "invite" -# 所有模块的联合类型 -AnyModule = ( - HeaderModule - | SectionModule - | ImageGroupModule - | ContainerModule - | ActionGroupModule - | ContextModule - | DividerModule - | FileModule - | CountdownModule - | InviteModule -) - - class KookCardMessage(BaseModel): """卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage 此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表** @@ -207,10 +201,10 @@ class KookCardMessage(BaseModel): theme: ThemeType | None = None size: SizeType | None = None color: KookCardColor | None = None - modules: list[AnyModule] = field(default_factory=list) + modules: list[KookCardModule] = field(default_factory=list) """单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50""" - def add_module(self, module: AnyModule): + def add_module(self, module: KookCardModule): self.modules.append(module) def to_dict(self, exclude_none: bool = True): From 2b3c86cb3aee89abff695f434789928327d5d8e9 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 15:32:23 +0800 Subject: [PATCH 04/36] =?UTF-8?q?bugfix:=20=E4=BF=AE=E5=A4=8Dbase64?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E5=A4=84=E7=90=86=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_client.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index 14d21124a7..81063aa235 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -353,9 +353,11 @@ async def upload_asset(self, file_url: str | None) -> str: filename = file_url.split("/")[-1] return file_url - elif file_url.startswith(("base64://", "base64:///")): - # b64_str = file_url.replace("base64:///", "") - b64_str = file_url.replace("base64://", "") + elif file_url.startswith("base64://"): + if file_url.startswith("base64:///"): + b64_str = file_url.removeprefix("base64:///") + else: + b64_str = file_url.removeprefix("base64://") bytes_data = base64.b64decode(b64_str) else: From 33a2f5dea3c993b070dec1f7d9a42465a88ded19 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 16:06:06 +0800 Subject: [PATCH 05/36] =?UTF-8?q?refactor:=20=E6=8A=9B=E5=87=BA=E7=9A=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E4=B8=8D=E5=86=8D=E5=8C=85=E5=90=AB"[kook]"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index a1ee71c0d4..5ccf3aa690 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -184,7 +184,7 @@ async def convert_message(self, data: dict) -> AstrBotMessage: abm.group_id = data.get("target_id") abm.session_id = data.get("target_id") case _: - raise ValueError(f"[KOOK] 不支持的频道类型: {channel_type}") + raise ValueError(f"不支持的频道类型: {channel_type}") abm.sender = MessageMember( user_id=data.get("author_id"), From 162aab0c50337541eb6e805a489de0ae633d556f Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 16:10:11 +0800 Subject: [PATCH 06/36] =?UTF-8?q?refactor:=20=E6=B7=BB=E5=8A=A0=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E6=9C=AC=E5=9C=B0=E6=96=87=E4=BB=B6=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=AE=89=E5=85=A8=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/platform/sources/kook/kook_client.py | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index 81063aa235..307ef50f22 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -12,9 +12,12 @@ from astrbot import logger from astrbot.core.platform.message_type import MessageType +from astrbot.core.utils.astrbot_path import get_astrbot_data_path from .kook_types import KookApiPaths, KookMessageType +ALLOWED_ASSETS_DIR = Path(get_astrbot_data_path()).resolve() + class KookClient: def __init__(self, token, event_callback): @@ -344,7 +347,7 @@ async def upload_asset(self, file_url: str | None) -> str: """上传文件到kook,获得远端资源url 接口定义参见: https://developer.kookapp.cn/doc/http/asset """ - if file_url is None: + if not file_url: return "" bytes_data: bytes | None = None @@ -360,13 +363,42 @@ async def upload_asset(self, file_url: str | None) -> str: b64_str = file_url.removeprefix("base64://") bytes_data = base64.b64decode(b64_str) - else: - file_url = file_url.replace("file:///", "") - file_url = file_url.replace("file://", "") - filename = Path(file_url).name - async with aiofiles.open(file_url, "rb") as f: + elif file_url.startswith("file://"): + if file_url.startswith("file:///"): + file_url = file_url.removeprefix("file:///") + else: + file_url = file_url.removeprefix("file://") + + try: + target_path = Path(file_url) + target_path = target_path.resolve() + except Exception as exp: + logger.error( + f'[KOOK] 获取文件 "{target_path.as_posix()}" 绝对路径失败: "{exp}"' + ) + raise FileNotFoundError( + f'获取文件 "{target_path.name}" 绝对路径失败: "{exp}"' + ) + + # 安全验证 + if not target_path.is_relative_to(ALLOWED_ASSETS_DIR): + logger.error( + f'[KOOK] 拒绝访问: "{target_path.as_posix()}" 不在允许的目录范围内' + ) + raise PermissionError( + f'拒绝访问: "{target_path.name}"文件路径不在允许的目录范围内' + ) + + if not target_path.is_file(): + raise FileNotFoundError(f"文件不存在: {target_path.name}") + + filename = target_path.name + async with aiofiles.open(target_path, "rb") as f: bytes_data = await f.read() + else: + raise ValueError(f'[KOOK] 不支持的文件资源类型: "{file_url}"') + data = aiohttp.FormData() data.add_field("file", bytes_data, filename=filename) From 6f3172cc24f0ca01fc66ff4596be187afbb1b699 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 16:13:07 +0800 Subject: [PATCH 07/36] =?UTF-8?q?refactor:=20=E5=8D=A1=E7=89=87=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5=E6=97=B6=E4=BC=9A?= =?UTF-8?q?=E6=89=93=E5=8D=B0=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_adapter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index 5ccf3aa690..47eb3dad6a 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -227,7 +227,8 @@ async def convert_message(self, data: dict) -> AstrBotMessage: abm.message.append(Plain(text=text)) for img_url in images: abm.message.append(Image(file=img_url)) - except Exception: + except Exception as exp: + logger.error(f"[KOOK] 卡片消息解析失败: {exp}") abm.message_str = "[卡片消息解析失败]" abm.message = [Plain(text="[卡片消息解析失败]")] else: From 5a3f2d1aa24e8e19707c4d3cc2603698bd0e5799 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 16:31:39 +0800 Subject: [PATCH 08/36] =?UTF-8?q?refactor:=20=E6=B7=BB=E5=8A=A0=E5=A4=84?= =?UTF-8?q?=E7=90=86=E6=8E=A5=E6=94=B6=E5=8D=A1=E7=89=87=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=86=85=E7=9A=84=E5=9B=BE=E7=89=87url=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/platform/sources/kook/kook_adapter.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index 47eb3dad6a..eef3f3f598 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -220,7 +220,21 @@ async def convert_message(self, data: dict) -> AstrBotMessage: elif module.get("type") == "container": for element in module.get("elements", []): if element.get("type") == "image": - images.append(element.get("src")) + 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) + abm.message_str = text abm.message = [] if text: From deaa162a0b589ff845007f05bef811ab0c44c67a Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 16:44:50 +0800 Subject: [PATCH 09/36] =?UTF-8?q?refactor:=20=E5=AE=89=E5=85=A8=E5=A4=84?= =?UTF-8?q?=E7=90=86ws=E9=9C=80=E8=A6=81=E9=87=8D=E8=BF=9E=E7=9A=84?= =?UTF-8?q?=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/platform/sources/kook/kook_client.py | 36 +++++++------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index 307ef50f22..d8da4956bf 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -100,6 +100,12 @@ async def get_gateway_url(self, resume=False, sn=0, session_id=None): async def connect(self, resume=False): """连接WebSocket""" + if self.ws: + try: + await self.ws.close() + except Exception: + pass + self.ws = None self._stop_event.clear() try: # 获取gateway地址 @@ -131,6 +137,12 @@ async def connect(self, resume=False): except Exception as e: logger.error(f"[KOOK] WebSocket 连接失败: {e}") + if self.ws: + try: + await self.ws.close() + except Exception: + pass + self.ws = None return False async def listen(self): @@ -275,30 +287,6 @@ async def _send_ping(self): except Exception as e: logger.error(f"[KOOK] 发送心跳失败: {e}") - async def reconnect(self): - """重连方法""" - logger.info(f"[KOOK] 开始重连,延迟: {self.reconnect_delay}秒") - await asyncio.sleep(self.reconnect_delay) - - # 关闭当前连接 - await self.close() - - # 尝试重连 - success = await self.connect(resume=True) - - if success: - # 重连成功,重置延迟 - self.reconnect_delay = 1 - logger.info("[KOOK] 重连成功") - else: - # 重连失败,增加延迟(指数退避) - self.reconnect_delay = min( - self.reconnect_delay * 2, self.max_reconnect_delay - ) - logger.warning(f"[KOOK] 重连失败,下次延迟: {self.reconnect_delay}秒") - - return success - async def send_text( self, target_id: str, From e22914c484406dd87a5f83614217474ead458aa5 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 16:55:13 +0800 Subject: [PATCH 10/36] =?UTF-8?q?Revert=20"refactor:=20=E4=BD=BF=E7=94=A8P?= =?UTF-8?q?rotocol=E6=9B=BF=E6=8D=A2Union=E7=B1=BB=E5=9E=8B"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 58e0dceeb20c3d7dddb16f623fd3bbdcfa632173. --- .../core/platform/sources/kook/kook_types.py | 66 ++++++++++--------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_types.py b/astrbot/core/platform/sources/kook/kook_types.py index bd2765d2f2..dd18ac00f1 100644 --- a/astrbot/core/platform/sources/kook/kook_types.py +++ b/astrbot/core/platform/sources/kook/kook_types.py @@ -1,7 +1,7 @@ import json from dataclasses import field from enum import IntEnum -from typing import Literal, Protocol +from typing import Literal from pydantic import BaseModel, ConfigDict from pydantic.dataclasses import dataclass @@ -52,27 +52,27 @@ class KookCardColor(str): """16 进制色值""" -class KookCardElement(Protocol): - """卡片元素协议""" +class KookCardModelBase: + """卡片模块基类""" type: str @dataclass -class PlainTextElement(KookCardElement): +class PlainTextElement(KookCardModelBase): content: str type: str = "plain-text" emoji: bool = True @dataclass -class KmarkdownElement(KookCardElement): +class KmarkdownElement(KookCardModelBase): content: str type: str = "kmarkdown" @dataclass -class ImageElement(KookCardElement): +class ImageElement(KookCardModelBase): src: str type: str = "image" alt: str = "" @@ -82,7 +82,7 @@ class ImageElement(KookCardElement): @dataclass -class ButtonElement(KookCardElement): +class ButtonElement(KookCardModelBase): text: str type: str = "button" theme: ThemeType = "primary" @@ -93,34 +93,25 @@ class ButtonElement(KookCardElement): """click 代表用户点击的事件,默认为"",代表无任何事件。""" -class KookCardStructure(Protocol): - """卡片结构协议""" - - type: str +AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str @dataclass -class ParagraphStructure(KookCardStructure): +class ParagraphStructure(KookCardModelBase): fields: list[PlainTextElement | KmarkdownElement] type: str = "paragraph" cols: int = 1 """范围是 1-3 , 移动端忽略此参数""" -class KookCardModule(Protocol): - """卡片模块协议""" - - type: str - - @dataclass -class HeaderModule(KookCardModule): +class HeaderModule(KookCardModelBase): text: PlainTextElement type: str = "header" @dataclass -class SectionModule(KookCardModule): +class SectionModule(KookCardModelBase): text: PlainTextElement | KmarkdownElement | ParagraphStructure type: str = "section" mode: SectionMode = "left" @@ -128,7 +119,7 @@ class SectionModule(KookCardModule): @dataclass -class ImageGroupModule(KookCardModule): +class ImageGroupModule(KookCardModelBase): """1 到多张图片的组合""" elements: list[ImageElement] @@ -136,7 +127,7 @@ class ImageGroupModule(KookCardModule): @dataclass -class ContainerModule(KookCardModule): +class ContainerModule(KookCardModelBase): """1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。""" elements: list[ImageElement] @@ -144,25 +135,25 @@ class ContainerModule(KookCardModule): @dataclass -class ActionGroupModule(KookCardModule): +class ActionGroupModule(KookCardModelBase): elements: list[ButtonElement] type: str = "action-group" @dataclass -class ContextModule(KookCardModule): +class ContextModule(KookCardModelBase): elements: list[PlainTextElement | KmarkdownElement | ImageElement] """最多包含10个元素""" type: str = "context" @dataclass -class DividerModule(KookCardModule): +class DividerModule(KookCardModelBase): type: str = "divider" @dataclass -class FileModule(KookCardModule): +class FileModule(KookCardModelBase): src: str title: str = "" type: Literal["file", "audio", "video"] = "file" @@ -171,7 +162,7 @@ class FileModule(KookCardModule): @dataclass -class CountdownModule(KookCardModule): +class CountdownModule(KookCardModelBase): """startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。""" endTime: int @@ -184,12 +175,27 @@ class CountdownModule(KookCardModule): @dataclass -class InviteModule(KookCardModule): +class InviteModule(KookCardModelBase): code: str """邀请链接或者邀请码""" type: str = "invite" +# 所有模块的联合类型 +AnyModule = ( + HeaderModule + | SectionModule + | ImageGroupModule + | ContainerModule + | ActionGroupModule + | ContextModule + | DividerModule + | FileModule + | CountdownModule + | InviteModule +) + + class KookCardMessage(BaseModel): """卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage 此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表** @@ -201,10 +207,10 @@ class KookCardMessage(BaseModel): theme: ThemeType | None = None size: SizeType | None = None color: KookCardColor | None = None - modules: list[KookCardModule] = field(default_factory=list) + modules: list[AnyModule] = field(default_factory=list) """单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50""" - def add_module(self, module: KookCardModule): + def add_module(self, module: AnyModule): self.modules.append(module) def to_dict(self, exclude_none: bool = True): From 91dc1ad04080e76727cad2ba5b411bce207f474f Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 17:06:46 +0800 Subject: [PATCH 11/36] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E6=9C=BA=E5=99=A8=E4=BA=BA=E5=90=8D=E7=A7=B0=E7=9A=84?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/platform/sources/kook/kook_adapter.py | 3 +-- .../core/platform/sources/kook/kook_client.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index eef3f3f598..c81c62840f 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -33,7 +33,6 @@ def __init__( self._reconnect_task = None self.running = False self._main_task = None - self._bot_id = "" async def send_by_session( self, session: MessageSesion, message_chain: MessageChain @@ -265,7 +264,7 @@ async def handle_msg(self, message: AstrBotMessage): kmarkdown = raw.get("extra", {}).get("kmarkdown", {}) mention_role_part = kmarkdown.get("mention_role_part", []) raw_content = kmarkdown.get("raw_content", "") - bot_nickname = "astrbot" + bot_nickname = self.client.bot_name if mention_role_part: is_at = True elif f"@{bot_nickname}" in raw_content: diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index d8da4956bf..fd3acf82b0 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -22,6 +22,7 @@ class KookClient: def __init__(self, token, event_callback): self._bot_id = "" + self._bot_name = "" self._http_client = aiohttp.ClientSession( headers={ "Authorization": f"Bot {token}", @@ -46,7 +47,11 @@ def __init__(self, token, event_callback): def bot_id(self): return self._bot_id - async def get_bot_id(self) -> str: + @property + def bot_name(self): + return self._bot_name + + async def get_bot_info(self) -> str: """获取机器人账号ID""" url = KookApiPaths.USER_ME @@ -62,7 +67,12 @@ async def get_bot_id(self) -> str: return "" bot_id: str = data["data"]["id"] + self._bot_id = bot_id logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}") + 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] 获取机器人账号ID异常: {e}") @@ -112,14 +122,10 @@ async def connect(self, resume=False): gateway_url = await self.get_gateway_url( resume=resume, sn=self.last_sn, session_id=self.session_id ) - bot_id = await self.get_bot_id() + await self.get_bot_info() if not gateway_url: return False - if not bot_id: - return False - - self._bot_id = bot_id # 连接WebSocket self.ws = await websockets.connect(gateway_url) From 8de9b260412dd82c99b1675d11a99cbb4ef1967d Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 17:09:50 +0800 Subject: [PATCH 12/36] =?UTF-8?q?refactor:=20=E8=AE=A9send=5Fby=5Fsession?= =?UTF-8?q?=E5=8F=91=E9=80=81=E4=B8=BB=E5=8A=A8=E6=B6=88=E6=81=AF=E6=97=B6?= =?UTF-8?q?=E6=AD=A3=E7=A1=AE=E4=BC=A0=E5=85=A5=E5=BD=93=E5=89=8D=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=93=BE=E7=9A=84=E6=96=87=E6=9C=AC=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index c81c62840f..681f2185c4 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -41,7 +41,7 @@ async def send_by_session( inner_message.session_id = session.session_id inner_message.type = session.message_type message_event = KookEvent( - message_str="kook", + message_str=message_chain.get_plain_text(), message_obj=inner_message, platform_meta=self.meta(), session_id=session.session_id, From 88c1e6df49bacd77d69dd706a47fe4d5ae8d0c9b Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 18:27:14 +0800 Subject: [PATCH 13/36] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=E5=A4=84?= =?UTF-8?q?=E7=90=86=E9=80=82=E9=85=8D=E5=99=A8=E9=85=8D=E7=BD=AE=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=86=85=E5=AE=B9,=E5=A4=84=E7=90=86=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E5=87=BA=E4=BC=A0=E5=85=A5=E9=85=8D=E7=BD=AE?= =?UTF-8?q?,=E5=B9=B6=E6=B7=BB=E5=8A=A0=E4=BB=AA=E8=A1=A8=E7=9B=98?= =?UTF-8?q?=E7=9A=84kook=E9=80=82=E9=85=8D=E5=99=A8=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E7=9A=84i18n=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 49 ++++++- astrbot/core/platform/sources/kook/config.py | 75 ---------- .../platform/sources/kook/kook_adapter.py | 20 ++- .../core/platform/sources/kook/kook_client.py | 38 ++++-- .../core/platform/sources/kook/kook_config.py | 129 ++++++++++++++++++ .../en-US/features/config-metadata.json | 42 +++++- .../zh-CN/features/config-metadata.json | 42 +++++- 7 files changed, 297 insertions(+), 98 deletions(-) delete mode 100644 astrbot/core/platform/sources/kook/config.py create mode 100644 astrbot/core/platform/sources/kook/kook_config.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 0cc28509d7..ec6bc423b4 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -453,7 +453,14 @@ class ChatProviderTemplate(TypedDict): "id": "kook", "type": "kook", "enable": False, - "token": "", + "kook_bot_token": "", + "kook_reconnect_delay": 1, + "kook_max_reconnect_delay": 60, + "kook_max_retry_delay": 60, + "kook_heartbeat_interval": 30, + "kook_heartbeat_timeout": 6, + "kook_max_heartbeat_failures": 3, + "kook_max_consecutive_failures": 5, }, # "WebChat": { # "id": "webchat", @@ -796,6 +803,46 @@ class ChatProviderTemplate(TypedDict): "type": "string", "hint": "统一 Webhook 模式下的唯一标识符,创建平台时自动生成。", }, + "kook_bot_token": { + "description": "机器人 Token", + "type": "string", + "hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。", + }, + "kook_reconnect_delay": { + "description": "重连延迟", + "type": "int", + "hint": "重连延迟时间(秒),使用指数退避策略。", + }, + "kook_max_reconnect_delay": { + "description": "最大重连延迟", + "type": "int", + "hint": "重连延迟的最大值(秒)。", + }, + "kook_max_retry_delay": { + "description": "最大重试延迟", + "type": "int", + "hint": "重试的最大延迟时间(秒)。", + }, + "kook_heartbeat_interval": { + "description": "心跳间隔", + "type": "int", + "hint": "心跳检测间隔时间(秒)。", + }, + "kook_heartbeat_timeout": { + "description": "心跳超时时间", + "type": "int", + "hint": "心跳检测超时时间(秒)。", + }, + "kook_max_heartbeat_failures": { + "description": "最大心跳失败次数", + "type": "int", + "hint": "允许的最大心跳失败次数,超过后断开连接。", + }, + "kook_max_consecutive_failures": { + "description": "最大连续失败次数", + "type": "int", + "hint": "允许的最大连续失败次数,超过后停止重试。", + }, }, }, "platform_settings": { diff --git a/astrbot/core/platform/sources/kook/config.py b/astrbot/core/platform/sources/kook/config.py deleted file mode 100644 index 8bae6c74da..0000000000 --- a/astrbot/core/platform/sources/kook/config.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -KOOK适配器配置文件 -包含连接参数、重连策略、心跳设置等配置项 -""" - -# 连接配置 -CONNECTION_CONFIG = { - # 心跳配置 - "heartbeat_interval": 30, # 心跳间隔(秒) - "heartbeat_timeout": 6, # 心跳超时时间(秒) - "max_heartbeat_failures": 3, # 最大心跳失败次数 - # 重连配置 - "initial_reconnect_delay": 1, # 初始重连延迟(秒) - "max_reconnect_delay": 60, # 最大重连延迟(秒) - "max_consecutive_failures": 5, # 最大连续失败次数 - # WebSocket配置 - "websocket_timeout": 10, # WebSocket接收超时(秒) - "connection_timeout": 30, # 连接超时(秒) - # 消息处理配置 - "enable_compression": True, # 是否启用消息压缩 - "max_message_size": 1024 * 1024, # 最大消息大小(字节) -} - -# 日志配置 -LOGGING_CONFIG = { - "level": "INFO", # 日志级别:DEBUG, INFO, WARNING, ERROR - "format": "[KOOK] %(message)s", - "enable_heartbeat_logs": False, # 是否启用心跳日志 - "enable_message_logs": False, # 是否启用消息日志 -} - -# 错误处理配置 -ERROR_HANDLING_CONFIG = { - "retry_on_network_error": True, # 网络错误时是否重试 - "retry_on_token_expired": True, # Token过期时是否重试 - "max_retry_attempts": 3, # 最大重试次数 - "retry_delay_base": 2, # 重试延迟基数(秒) -} - -# 性能配置 -PERFORMANCE_CONFIG = { - "enable_message_buffering": True, # 是否启用消息缓冲 - "buffer_size": 100, # 缓冲区大小 - "enable_connection_pooling": True, # 是否启用连接池 - "max_concurrent_requests": 10, # 最大并发请求数 -} - -# 安全配置 -SECURITY_CONFIG = { - "verify_ssl": True, # 是否验证SSL证书 - "enable_rate_limiting": True, # 是否启用速率限制 - "rate_limit_requests": 100, # 速率限制请求数 - "rate_limit_window": 60, # 速率限制窗口(秒) -} - - -def get_config(): - """获取完整配置""" - return { - "connection": CONNECTION_CONFIG, - "logging": LOGGING_CONFIG, - "error_handling": ERROR_HANDLING_CONFIG, - "performance": PERFORMANCE_CONFIG, - "security": SECURITY_CONFIG, - } - - -def get_connection_config(): - """获取连接配置""" - return CONNECTION_CONFIG - - -def get_logging_config(): - """获取日志配置""" - return LOGGING_CONFIG diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index 681f2185c4..ffce76e077 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -16,11 +16,13 @@ from astrbot.core.platform.astr_message_event import MessageSesion from .kook_client import KookClient +from .kook_config import KookConfig from .kook_event import KookEvent @register_platform_adapter( - "kook", "KOOK 适配器", default_config_tmpl={"token": "你kook获取到的机器人token"} + "kook", + "KOOK 适配器", ) class KookPlatformAdapter(Platform): def __init__( @@ -28,6 +30,10 @@ def __init__( ) -> None: super().__init__(platform_config, event_queue) self.config = platform_config + """给astrbot内部用""" + self.kook_config = KookConfig.from_dict(platform_config) + logger.debug(f"[KOOK] 配置: {self.kook_config.pretty_jsons()}") + # self.config = platform_config self.settings = platform_settings self.client = None self._reconnect_task = None @@ -52,7 +58,7 @@ async def send_by_session( def meta(self) -> PlatformMetadata: return PlatformMetadata( - name="kook", description="KOOK 适配器", id=self.config.get("id") + name="kook", description="KOOK 适配器", id=self.kook_config.id ) async def run(self): @@ -72,7 +78,7 @@ async def on_received(data): except Exception as e: logger.error(f"[KOOK] 消息处理异常: {e}") - self.client = KookClient(self.config["token"], on_received) + self.client = KookClient(self.kook_config, on_received) # 启动主循环 self._main_task = asyncio.create_task(self._main_loop()) @@ -90,7 +96,8 @@ async def on_received(data): async def _main_loop(self): """主循环,处理连接和重连""" consecutive_failures = 0 - max_consecutive_failures = 5 + max_consecutive_failures = self.kook_config.max_consecutive_failures + max_retry_delay = self.kook_config.max_retry_delay while self.running: try: @@ -129,7 +136,9 @@ async def _main_loop(self): break # 等待一段时间后重试 - wait_time = min(2**consecutive_failures, 60) # 指数退避,最大60秒 + wait_time = min( + 2**consecutive_failures, max_retry_delay + ) # 指数退避,最大60秒 logger.info(f"[KOOK] 等待 {wait_time} 秒后重试...") await asyncio.sleep(wait_time) @@ -245,6 +254,7 @@ async def convert_message(self, data: dict) -> AstrBotMessage: abm.message_str = "[卡片消息解析失败]" abm.message = [Plain(text="[卡片消息解析失败]")] else: + logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.get("type")}"') abm.message_str = "[不支持的消息类型]" abm.message = [Plain(text="[不支持的消息类型]")] diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index fd3acf82b0..4fd2b912a5 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -14,34 +14,36 @@ from astrbot.core.platform.message_type import MessageType from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from .kook_config import KookConfig from .kook_types import KookApiPaths, KookMessageType ALLOWED_ASSETS_DIR = Path(get_astrbot_data_path()).resolve() class KookClient: - def __init__(self, token, event_callback): + def __init__(self, config: KookConfig, event_callback): + # 数据字段 + self.config = config self._bot_id = "" self._bot_name = "" + + # 资源字段 self._http_client = aiohttp.ClientSession( headers={ - "Authorization": f"Bot {token}", + "Authorization": f"Bot {self.config.token}", } ) self.event_callback = event_callback # 回调函数,用于处理接收到的事件 self.ws = None - self.running = False + self.heartbeat_task = None self._stop_event = asyncio.Event() # 用于通知连接结束 + + # 状态/计算字段 + self.running = False self.session_id = None self.last_sn = 0 # 记录最后处理的消息序号 - self.heartbeat_task = None - self.reconnect_delay = 1 # 重连延迟,指数退避 - self.max_reconnect_delay = 60 # 最大重连延迟 - self.heartbeat_interval = 30 # 心跳间隔 - self.heartbeat_timeout = 6 # 心跳超时时间 self.last_heartbeat_time = 0 self.heartbeat_failed_count = 0 - self.max_heartbeat_failures = 3 # 最大心跳失败次数 @property def bot_id(self): @@ -221,8 +223,8 @@ async def _handle_hello(self, data): if code == 0: self.session_id = hello_data.get("session_id") logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}") - # 重置重连延迟 - self.reconnect_delay = 1 + # TODO 重置重连延迟 + # self.reconnect_delay = 1 else: logger.error(f"[KOOK] 握手失败,错误码: {code}") if code == 40103: # token过期 @@ -254,7 +256,7 @@ async def _heartbeat_loop(self): while self.running: try: # 随机化心跳间隔 (30±5秒) - interval = self.heartbeat_interval + random.randint(-5, 5) + interval = self.config.heartbeat_interval + random.randint(-5, 5) await asyncio.sleep(interval) if not self.running: @@ -264,16 +266,22 @@ async def _heartbeat_loop(self): await self._send_ping() # 等待PONG响应 - await asyncio.sleep(self.heartbeat_timeout) + await asyncio.sleep(self.config.heartbeat_timeout) # 检查是否收到PONG响应 - if time.time() - self.last_heartbeat_time > self.heartbeat_timeout: + if ( + time.time() - self.last_heartbeat_time + > self.config.heartbeat_timeout + ): self.heartbeat_failed_count += 1 logger.warning( f"[KOOK] 心跳超时,失败次数: {self.heartbeat_failed_count}" ) - if self.heartbeat_failed_count >= self.max_heartbeat_failures: + if ( + self.heartbeat_failed_count + >= self.config.max_heartbeat_failures + ): logger.error("[KOOK] 心跳失败次数过多,准备重连") self.running = False break diff --git a/astrbot/core/platform/sources/kook/kook_config.py b/astrbot/core/platform/sources/kook/kook_config.py new file mode 100644 index 0000000000..a6e0166b99 --- /dev/null +++ b/astrbot/core/platform/sources/kook/kook_config.py @@ -0,0 +1,129 @@ +import json +from dataclasses import asdict, dataclass +from typing import Any + + +@dataclass +class KookConfig: + """KOOK 适配器配置类""" + + # 基础配置 + token: str + enable: bool = False + id: str = "kook" + + # 重连配置 + reconnect_delay: int = 1 + """重连延迟基数(秒),指数退避""" + max_reconnect_delay: int = 60 + """最大重连延迟(秒)""" + max_retry_delay: int = 60 + """最大重试延迟(秒)""" + + # 心跳配置 + heartbeat_interval: int = 30 + """心跳间隔(秒)""" + heartbeat_timeout: int = 6 + """心跳超时时间(秒)""" + max_heartbeat_failures: int = 3 + """最大心跳失败次数""" + + # 失败处理 + max_consecutive_failures: int = 5 + """最大连续失败次数""" + + @classmethod + def from_dict(cls, config_dict: dict) -> "KookConfig": + """从字典创建配置对象""" + return cls( + # 适配器id 应该是不能改的 + # id=config_dict.get("id", "kook"), + enable=config_dict.get("enable", False), + token=config_dict.get("kook_bot_token", ""), + reconnect_delay=config_dict.get( + "kook_reconnect_delay", + ) + or KookConfig.reconnect_delay, + max_reconnect_delay=config_dict.get( + "kook_max_reconnect_delay", + ) + or KookConfig.max_reconnect_delay, + max_retry_delay=config_dict.get( + "kook_max_retry_delay", + ) + or KookConfig.max_retry_delay, + heartbeat_interval=config_dict.get( + "kook_heartbeat_interval", + ) + or KookConfig.heartbeat_interval, + heartbeat_timeout=config_dict.get( + "kook_heartbeat_timeout", + ) + or KookConfig.heartbeat_timeout, + max_heartbeat_failures=config_dict.get( + "kook_max_heartbeat_failures", + ) + or KookConfig.max_heartbeat_failures, + max_consecutive_failures=config_dict.get( + "kook_max_consecutive_failures", + ) + or KookConfig.max_consecutive_failures, + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + def pretty_jsons(self, indent=2) -> str: + return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False) + + +# TODO 没用上的config配置,未来有空会实现这些配置描述的功能? +# # 连接配置 +# CONNECTION_CONFIG = { +# # 心跳配置 +# "heartbeat_interval": 30, # 心跳间隔(秒) +# "heartbeat_timeout": 6, # 心跳超时时间(秒) +# "max_heartbeat_failures": 3, # 最大心跳失败次数 +# # 重连配置 +# "initial_reconnect_delay": 1, # 初始重连延迟(秒) +# "max_reconnect_delay": 60, # 最大重连延迟(秒) +# "max_consecutive_failures": 5, # 最大连续失败次数 +# # WebSocket配置 +# "websocket_timeout": 10, # WebSocket接收超时(秒) +# "connection_timeout": 30, # 连接超时(秒) +# # 消息处理配置 +# "enable_compression": True, # 是否启用消息压缩 +# "max_message_size": 1024 * 1024, # 最大消息大小(字节) +# } + +# # 日志配置 +# LOGGING_CONFIG = { +# "level": "INFO", # 日志级别:DEBUG, INFO, WARNING, ERROR +# "format": "[KOOK] %(message)s", +# "enable_heartbeat_logs": False, # 是否启用心跳日志 +# "enable_message_logs": False, # 是否启用消息日志 +# } + +# # 错误处理配置 +# ERROR_HANDLING_CONFIG = { +# "retry_on_network_error": True, # 网络错误时是否重试 +# "retry_on_token_expired": True, # Token过期时是否重试 +# "max_retry_attempts": 3, # 最大重试次数 +# "retry_delay_base": 2, # 重试延迟基数(秒) +# } + +# # 性能配置 +# PERFORMANCE_CONFIG = { +# "enable_message_buffering": True, # 是否启用消息缓冲 +# "buffer_size": 100, # 缓冲区大小 +# "enable_connection_pooling": True, # 是否启用连接池 +# "max_concurrent_requests": 10, # 最大并发请求数 +# } + +# # 安全配置 +# SECURITY_CONFIG = { +# "verify_ssl": True, # 是否验证SSL证书 +# "enable_rate_limiting": True, # 是否启用速率限制 +# "rate_limit_requests": 100, # 速率限制请求数 +# "rate_limit_window": 60, # 速率限制窗口(秒) +# } diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index b8473dae63..7e98e16d2c 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -584,6 +584,46 @@ "only_use_webhook_url_to_send": { "description": "Send Replies via Webhook Only", "hint": "When enabled, all WeCom AI Bot replies are sent through msg_push_webhook_url. The message push webhook supports more message types (such as images, files, etc.). If you do not need the typing effect, it is strongly recommended to use this option. " + }, + "kook_bot_token": { + "description": "Bot Token", + "type": "string", + "hint": "Required. The Bot Token obtained from the KOOK Developer Platform." + }, + "kook_reconnect_delay": { + "description": "Reconnect Delay", + "type": "int", + "hint": "Delay time for reconnection (seconds), using an exponential backoff strategy." + }, + "kook_max_reconnect_delay": { + "description": "Max Reconnect Delay", + "type": "int", + "hint": "The maximum value for reconnection delay (seconds)." + }, + "kook_max_retry_delay": { + "description": "Max Retry Delay", + "type": "int", + "hint": "The maximum delay time for retries (seconds)." + }, + "kook_heartbeat_interval": { + "description": "Heartbeat Interval", + "type": "int", + "hint": "The interval time for heartbeat detection (seconds)." + }, + "kook_heartbeat_timeout": { + "description": "Heartbeat Timeout", + "type": "int", + "hint": "The timeout duration for heartbeat detection (seconds)." + }, + "kook_max_heartbeat_failures": { + "description": "Max Heartbeat Failures", + "type": "int", + "hint": "Maximum allowed heartbeat failures; the connection will be dropped if exceeded." + }, + "kook_max_consecutive_failures": { + "description": "Max Consecutive Failures", + "type": "int", + "hint": "Maximum allowed consecutive failures; retries will stop if exceeded." } }, "general": { @@ -1448,4 +1488,4 @@ "helpMiddle": "or", "helpSuffix": "." } -} +} \ No newline at end of file diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index e3a52258f3..2619db6c05 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -587,6 +587,46 @@ "only_use_webhook_url_to_send": { "description": "仅使用 Webhook 发送消息", "hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。" + }, + "kook_bot_token": { + "description": "机器人 Token", + "type": "string", + "hint": "必填项。从 KOOK 开发者平台获取的机器人 Token" + }, + "kook_reconnect_delay": { + "description": "重连延迟", + "type": "int", + "hint": "重连延迟时间(秒),使用指数退避策略" + }, + "kook_max_reconnect_delay": { + "description": "最大重连延迟", + "type": "int", + "hint": "重连延迟的最大值(秒)" + }, + "kook_max_retry_delay": { + "description": "最大重试延迟", + "type": "int", + "hint": "重试的最大延迟时间(秒)" + }, + "kook_heartbeat_interval": { + "description": "心跳间隔", + "type": "int", + "hint": "心跳检测间隔时间(秒)" + }, + "kook_heartbeat_timeout": { + "description": "心跳超时时间", + "type": "int", + "hint": "心跳检测超时时间(秒)" + }, + "kook_max_heartbeat_failures": { + "description": "最大心跳失败次数", + "type": "int", + "hint": "允许的最大心跳失败次数,超过后断开连接" + }, + "kook_max_consecutive_failures": { + "description": "最大连续失败次数", + "type": "int", + "hint": "允许的最大连续失败次数,超过后停止重试" } }, "general": { @@ -1451,4 +1491,4 @@ "helpMiddle": "或", "helpSuffix": "。" } -} +} \ No newline at end of file From a96d81971da1a8d94a9ac26e0680f34a01af75c8 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 18:37:34 +0800 Subject: [PATCH 14/36] =?UTF-8?q?unittest:=20=E6=B7=BB=E5=8A=A0kook?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E7=9A=84=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95,=E8=99=BD=E7=84=B6=E6=B2=A1=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=A4=9A=E5=B0=91=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_kook/shared.py | 4 + tests/test_kook/test_kook_event.py | 223 +++++++++++++++++++++++++++++ tests/test_kook/test_kook_types.py | 107 ++++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 tests/test_kook/shared.py create mode 100644 tests/test_kook/test_kook_event.py create mode 100644 tests/test_kook/test_kook_types.py diff --git a/tests/test_kook/shared.py b/tests/test_kook/shared.py new file mode 100644 index 0000000000..103d8e93d5 --- /dev/null +++ b/tests/test_kook/shared.py @@ -0,0 +1,4 @@ +from pathlib import Path + + +TEST_DATA_DIR = Path("./tests/test_kook/data") diff --git a/tests/test_kook/test_kook_event.py b/tests/test_kook/test_kook_event.py new file mode 100644 index 0000000000..253839506e --- /dev/null +++ b/tests/test_kook/test_kook_event.py @@ -0,0 +1,223 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata, Unknown +from astrbot.api.event import MessageChain +from astrbot.core.message.components import ( + File, + Image, + Plain, + Video, + At, + AtAll, + BaseMessageComponent, + Json, + Record, + Reply, +) + + +from astrbot.core.platform.sources.kook.kook_event import KookEvent +from astrbot.core.platform.sources.kook.kook_types import KookMessageType, OrderMessage + + +async def mock_kook_client(upload_asset_return: str, send_text_return: str): + # 1. Mock 掉整个 KookClient 类 + client = MagicMock() + + client.upload_asset = AsyncMock(return_value=upload_asset_return) + client.send_text = AsyncMock(return_value=send_text_return) + return client + + +def mock_file_message(input: str): + message = MagicMock(spec=File) + message.get_file = AsyncMock(return_value=input) + return message + + +def mock_record_message(input: str): + message = MagicMock(spec=Record) + message.text = input + message.convert_to_file_path = AsyncMock(return_value=input) + return message + + +def mock_astrbot_message(): + message = AstrBotMessage() + message.type = MessageType.OTHER_MESSAGE + message.group_id = "test" + message.session_id = "test" + message.message_id = "test" + return message + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "input_message,upload_asset_return, expected_output, expected_error", + [ + ( + Image("test image"), + "test image", + OrderMessage( + 1, + text="test image", + type=KookMessageType.IMAGE, + ), + None, + ), + ( + Video("test video"), + "test video", + OrderMessage( + 1, + text="test video", + type=KookMessageType.VIDEO, + ), + None, + ), + ( + mock_file_message("test file"), + "test file", + OrderMessage( + 1, + text="test file", + type=KookMessageType.FILE, + ), + None, + ), + ( + mock_record_message("./tests/file.wav"), + "./tests/file.wav", + OrderMessage( + 1, + text='[{"type": "card", "modules": [{"src": "./tests/file.wav", "title": "./tests/file.wav", "type": "audio"}]}]', + type=KookMessageType.CARD, + ), + None, + ), + ( + Plain("test plain"), + "test plain", + OrderMessage( + 1, + text="test plain", + type=KookMessageType.KMARKDOWN, + ), + None, + ), + ( + At(qq="test at"), + "test at", + OrderMessage( + 1, + text="(met)test at(met)", + type=KookMessageType.KMARKDOWN, + ), + None, + ), + ( + AtAll(qq="all"), + "test atAll", + OrderMessage( + 1, + text="(met)all(met)", + type=KookMessageType.KMARKDOWN, + ), + None, + ), + ( + Reply(id="test reply"), + "test reply", + OrderMessage( + 1, + text="", + type=KookMessageType.KMARKDOWN, + reply_id="test reply", + ), + None, + ), + ( + Json(data={"test": "json"}), + "test json", + OrderMessage( + 1, + text='[{"test": "json"}]', + type=KookMessageType.CARD, + ), + None, + ), + ( + Unknown(text="test unknown"), + "test unknown", + None, + NotImplementedError, + ), + ], +) +async def test_kook_event_warp_message( + input_message: BaseMessageComponent, + upload_asset_return: str, + expected_output: OrderMessage, + expected_error: type[Exception] | None, +): + client = await mock_kook_client( + upload_asset_return, + "", + ) + + event = KookEvent( + "", + mock_astrbot_message(), + PlatformMetadata( + name="test", + id="test", + description="test", + ), + "", + client, + ) + + if expected_error: + with pytest.raises(expected_error): + await event._wrap_message(1, input_message) + return + + 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()) diff --git a/tests/test_kook/test_kook_types.py b/tests/test_kook/test_kook_types.py new file mode 100644 index 0000000000..760e36c596 --- /dev/null +++ b/tests/test_kook/test_kook_types.py @@ -0,0 +1,107 @@ +import json +from pathlib import Path + +import pytest + +from astrbot.core.platform.sources.kook.kook_types import ( + ActionGroupModule, + ButtonElement, + ContextModule, + CountdownModule, + DividerModule, + FileModule, + HeaderModule, + ImageElement, + ImageGroupModule, + InviteModule, + KmarkdownElement, + KookCardMessage, + ParagraphStructure, + PlainTextElement, + SectionModule, + KookCardMessageContainer, +) +from tests.test_kook.shared import TEST_DATA_DIR + + +def test_kook_card_message_container_append(): + container = KookCardMessageContainer() + container.append(KookCardMessage()) + assert len(container) == 1 + + +@pytest.mark.parametrize( + "input, expect_container_length", + [ + ([KookCardMessage()], 1), + ([KookCardMessage()] * 2, 2), + ], +) +def test_kook_card_message_container_to_json( + input: list[KookCardMessage], expect_container_length: int +): + container = KookCardMessageContainer(input) + json_output = container.to_json() + output = json.loads(json_output) + assert isinstance(output, list) + assert len(output) == expect_container_length + + +def test_all_kook_card_type(): + expect_json_data = Path(TEST_DATA_DIR / "kook_card_data.json").read_text( + encoding="utf-8" + ) + json_output = KookCardMessage( + theme="info", + size="lg", + modules=[ + HeaderModule(text=PlainTextElement(content="test1")), + SectionModule(text=KmarkdownElement(content="test2")), + DividerModule(), + SectionModule( + text=ParagraphStructure( + cols=2, + fields=[ + KmarkdownElement(content="test3"), + KmarkdownElement(content="**test4**"), + ], + ) + ), + ImageGroupModule( + elements=[ + ImageElement( + src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg" + ) + ] + ), + FileModule( + src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg", + title="test5", + type="file", + ), + CountdownModule( + endTime=1772343427360, + startTime=1772343378259, + mode="second", + ), + ActionGroupModule( + elements=[ + ButtonElement( + value="btn_clicked", + text="点我测试回调", + click="return-val", + theme="primary", + ), + ButtonElement( + value="https://www.kookapp.cn", + text="访问官网", + click="link", + theme="danger", + ), + ] + ), + ContextModule(elements=[PlainTextElement(content="test6")]), + InviteModule(code="test7"), + ], + ).to_json(indent=4, ensure_ascii=False) + assert json_output == expect_json_data From c556240c5ae6b49119d74c6e477880d5cc61e697 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 19:06:24 +0800 Subject: [PATCH 15/36] =?UTF-8?q?unittest:=20TEST=5FDATA=5FDIR=E7=94=A8?= =?UTF-8?q?=E6=9B=B4=E5=AE=89=E5=85=A8=E7=9A=84=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_kook/shared.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_kook/shared.py b/tests/test_kook/shared.py index 103d8e93d5..5c5c9da86c 100644 --- a/tests/test_kook/shared.py +++ b/tests/test_kook/shared.py @@ -1,4 +1,4 @@ from pathlib import Path -TEST_DATA_DIR = Path("./tests/test_kook/data") +TEST_DATA_DIR = Path(__file__).parent / "data" From 68402f595679c6997ad8316c07ad16e40c3f764a Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 19:10:26 +0800 Subject: [PATCH 16/36] =?UTF-8?q?refactor:=20KookConfig=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E4=BA=86=E6=9B=B4=E5=A5=BD=E7=9A=84=E9=BB=98=E8=AE=A4=E5=80=BC?= =?UTF-8?q?=E5=A4=84=E7=90=86=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/platform/sources/kook/kook_config.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_config.py b/astrbot/core/platform/sources/kook/kook_config.py index a6e0166b99..54aeff4aee 100644 --- a/astrbot/core/platform/sources/kook/kook_config.py +++ b/astrbot/core/platform/sources/kook/kook_config.py @@ -42,32 +42,32 @@ def from_dict(cls, config_dict: dict) -> "KookConfig": token=config_dict.get("kook_bot_token", ""), reconnect_delay=config_dict.get( "kook_reconnect_delay", - ) - or KookConfig.reconnect_delay, + KookConfig.reconnect_delay, + ), max_reconnect_delay=config_dict.get( "kook_max_reconnect_delay", - ) - or KookConfig.max_reconnect_delay, + KookConfig.max_reconnect_delay, + ), max_retry_delay=config_dict.get( "kook_max_retry_delay", - ) - or KookConfig.max_retry_delay, + KookConfig.max_retry_delay, + ), heartbeat_interval=config_dict.get( "kook_heartbeat_interval", - ) - or KookConfig.heartbeat_interval, + KookConfig.heartbeat_interval, + ), heartbeat_timeout=config_dict.get( "kook_heartbeat_timeout", - ) - or KookConfig.heartbeat_timeout, + KookConfig.heartbeat_timeout, + ), max_heartbeat_failures=config_dict.get( "kook_max_heartbeat_failures", - ) - or KookConfig.max_heartbeat_failures, + KookConfig.max_heartbeat_failures, + ), max_consecutive_failures=config_dict.get( "kook_max_consecutive_failures", - ) - or KookConfig.max_consecutive_failures, + KookConfig.max_consecutive_failures, + ), ) def to_dict(self) -> dict[str, Any]: From ce88a8b5613d61a5623cdc83fef433c0766b032f Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 19:11:54 +0800 Subject: [PATCH 17/36] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4kook=5Fadap?= =?UTF-8?q?ter=20=E7=9A=84config=E5=AD=97=E6=AE=B5=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_adapter.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index ffce76e077..c7fc1151f4 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -29,8 +29,6 @@ def __init__( self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue ) -> None: super().__init__(platform_config, event_queue) - self.config = platform_config - """给astrbot内部用""" self.kook_config = KookConfig.from_dict(platform_config) logger.debug(f"[KOOK] 配置: {self.kook_config.pretty_jsons()}") # self.config = platform_config From 64ebec532ce4a9dd5480ee5531a68847364bec2f Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 19:14:10 +0800 Subject: [PATCH 18/36] =?UTF-8?q?refactor:=20=E9=9A=90=E8=97=8F=E8=8E=B7?= =?UTF-8?q?=E5=8F=96kook=20gateway=E6=97=B6url=E9=87=8C=E7=9A=84token,?= =?UTF-8?q?=E9=98=B2=E6=AD=A2=E6=8A=8Atoken=E6=89=93=E5=8D=B0=E5=87=BA?= =?UTF-8?q?=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index 4fd2b912a5..8e8c4f2150 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -103,8 +103,8 @@ async def get_gateway_url(self, resume=False, sn=0, session_id=None): logger.error(f"[KOOK] 获取gateway失败: {data}") return None - gateway_url = data["data"]["url"] - logger.info(f"[KOOK] 获取gateway成功: {gateway_url}") + gateway_url: str = data["data"]["url"] + logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}") return gateway_url except Exception as e: logger.error(f"[KOOK] 获取gateway异常: {e}") From 55ce04839a7e7e4a1aac1f66d646ac0053e31e03 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 19:19:51 +0800 Subject: [PATCH 19/36] =?UTF-8?q?refactor:=20KookConfig.pretty=5Fjsons?= =?UTF-8?q?=E4=BD=BF=E7=94=A8*=E6=9D=A5=E5=B1=8F=E8=94=BDtoken=E5=86=85?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/kook/kook_config.py b/astrbot/core/platform/sources/kook/kook_config.py index 54aeff4aee..0b9d180a29 100644 --- a/astrbot/core/platform/sources/kook/kook_config.py +++ b/astrbot/core/platform/sources/kook/kook_config.py @@ -74,7 +74,9 @@ def to_dict(self) -> dict[str, Any]: return asdict(self) def pretty_jsons(self, indent=2) -> str: - return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False) + dict_config = self.to_dict() + dict_config["token"] = "*" * len(self.token) if self.token else "MISSING" + return json.dumps(dict_config, indent=indent, ensure_ascii=False) # TODO 没用上的config配置,未来有空会实现这些配置描述的功能? From 36f2f6ce7f31fc3ed88eba29fc3de985a5e614ea Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 19:23:48 +0800 Subject: [PATCH 20/36] =?UTF-8?q?bugfix:=20=E4=BF=AE=E5=A4=8D=E4=B8=BB?= =?UTF-8?q?=E5=8A=A8=E5=8F=91=E9=80=81=E6=B6=88=E6=81=AF=E6=97=B6,?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E4=BA=86=E7=88=B6=E6=96=B9=E6=B3=95`send=5Fb?= =?UTF-8?q?y=5Fsession`=E5=8F=AF=E8=83=BD=E5=AF=BC=E8=87=B4=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E8=A2=AB=E9=87=8D=E5=A4=8D=E4=B8=8A=E4=BC=A0=E7=9A=84?= =?UTF-8?q?bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_adapter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index c7fc1151f4..52a52cf857 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -52,7 +52,6 @@ async def send_by_session( client=self.client, ) await message_event.send(message_chain) - await super().send_by_session(session, message_chain) def meta(self) -> PlatformMetadata: return PlatformMetadata( From 07b3840964c987b9392c6b8599d191360c478b0f Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 19:30:47 +0800 Subject: [PATCH 21/36] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96upload=5Fas?= =?UTF-8?q?set=E7=9A=84=E8=B7=AF=E5=BE=84=E5=A4=84=E7=90=86=E6=8A=A5?= =?UTF-8?q?=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_client.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index 8e8c4f2150..eb5846a833 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -372,15 +372,12 @@ async def upload_asset(self, file_url: str | None) -> str: file_url = file_url.removeprefix("file://") try: - target_path = Path(file_url) - target_path = target_path.resolve() + target_path = Path(file_url).resolve() except Exception as exp: - logger.error( - f'[KOOK] 获取文件 "{target_path.as_posix()}" 绝对路径失败: "{exp}"' - ) + logger.error(f'[KOOK] 获取文件 "{file_url}" 绝对路径失败: "{exp}"') raise FileNotFoundError( - f'获取文件 "{target_path.name}" 绝对路径失败: "{exp}"' - ) + f'获取文件 "{file_url}" 绝对路径失败: "{exp}"' + ) from exp # 安全验证 if not target_path.is_relative_to(ALLOWED_ASSETS_DIR): From 831afcadeb8d743acf86eb9f4ad7ca7f0d878077 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 19:42:08 +0800 Subject: [PATCH 22/36] =?UTF-8?q?bugfix:=20=E4=BF=AE=E5=A4=8Dkook=20ws?= =?UTF-8?q?=E5=BF=83=E8=B7=B3=E9=97=B4=E9=9A=94=E5=8F=AF=E8=83=BD=E4=BC=9A?= =?UTF-8?q?=E5=87=BA=E7=8E=B0=E8=B4=9F=E6=95=B0=E6=97=B6=E9=97=B4=E7=9A=84?= =?UTF-8?q?bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_adapter.py | 2 +- astrbot/core/platform/sources/kook/kook_client.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index 52a52cf857..7e8121800b 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -135,7 +135,7 @@ async def _main_loop(self): # 等待一段时间后重试 wait_time = min( 2**consecutive_failures, max_retry_delay - ) # 指数退避,最大60秒 + ) # 指数退避 logger.info(f"[KOOK] 等待 {wait_time} 秒后重试...") await asyncio.sleep(wait_time) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index eb5846a833..9ad34d4d11 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -255,8 +255,10 @@ async def _heartbeat_loop(self): """心跳循环""" while self.running: try: - # 随机化心跳间隔 (30±5秒) - interval = self.config.heartbeat_interval + random.randint(-5, 5) + # 随机化心跳间隔 (±5秒) + interval = max( + 1, self.config.heartbeat_interval + random.randint(-5, 5) + ) await asyncio.sleep(interval) if not self.running: From aacc2a1bbedc8a55b388bf756d71c9e70cf315f4 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 19:48:38 +0800 Subject: [PATCH 23/36] =?UTF-8?q?refactor:=20KookClient=E7=A7=BB=E5=88=B0K?= =?UTF-8?q?ookPlatformAdapter.=5F=5Finit=5F=5F=E9=87=8C=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform/sources/kook/kook_adapter.py | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index 7e8121800b..0ada5d8741 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -33,7 +33,7 @@ def __init__( logger.debug(f"[KOOK] 配置: {self.kook_config.pretty_jsons()}") # self.config = platform_config self.settings = platform_settings - self.client = None + self.client = KookClient(self.kook_config, self._on_received) self._reconnect_task = None self.running = False self._main_task = None @@ -58,25 +58,23 @@ def meta(self) -> PlatformMetadata: name="kook", description="KOOK 适配器", id=self.kook_config.id ) + async def _on_received(self, data: dict): + logger.debug(f"KOOK 收到数据: {data}") + if "d" in data and data["s"] == 0: + event_type = data["d"].get("type") + # 支持type=9(文本)和type=10(卡片) + if event_type in (9, 10): + try: + abm = await self.convert_message(data["d"]) + await self.handle_msg(abm) + except Exception as e: + logger.error(f"[KOOK] 消息处理异常: {e}") + async def run(self): """主运行循环""" self.running = True logger.info("[KOOK] 启动KOOK适配器") - async def on_received(data): - logger.debug(f"KOOK 收到数据: {data}") - if "d" in data and data["s"] == 0: - event_type = data["d"].get("type") - # 支持type=9(文本)和type=10(卡片) - if event_type in (9, 10): - try: - abm = await self.convert_message(data["d"]) - await self.handle_msg(abm) - except Exception as e: - logger.error(f"[KOOK] 消息处理异常: {e}") - - self.client = KookClient(self.kook_config, on_received) - # 启动主循环 self._main_task = asyncio.create_task(self._main_loop()) From c5be9d3dcf68ac743bc21fe12e1a246335da665d Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 19:54:25 +0800 Subject: [PATCH 24/36] =?UTF-8?q?bugfix:=20=E4=BF=AE=E5=A4=8D=E5=A4=84?= =?UTF-8?q?=E7=90=86base64=20url=20=E5=A4=9A=E6=9B=BF=E6=8D=A2=E4=BA=86/?= =?UTF-8?q?=E8=80=8C=E6=8A=A5=E9=94=99=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_client.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index 9ad34d4d11..4aa0b9be66 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -360,11 +360,9 @@ async def upload_asset(self, file_url: str | None) -> str: filename = file_url.split("/")[-1] return file_url - elif file_url.startswith("base64://"): - if file_url.startswith("base64:///"): - b64_str = file_url.removeprefix("base64:///") - else: - b64_str = file_url.removeprefix("base64://") + elif file_url.startswith("base64:///"): + # b64decode的时候得开头留一个'/'的, 不然会报错 + b64_str = file_url.removeprefix("base64://") bytes_data = base64.b64decode(b64_str) elif file_url.startswith("file://"): From cb2d83ad25777e5312380c3feba1ab0b78041969 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 20:21:03 +0800 Subject: [PATCH 25/36] =?UTF-8?q?refactor:=20kook=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6,=E4=BC=9A=E6=8A=9B=E5=87=BA=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_client.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index 4aa0b9be66..cf7b91e0bd 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -407,16 +407,20 @@ async def upload_asset(self, file_url: str | None) -> str: if resp.status == 200: result: dict = await resp.json() if result.get("code") == 0: - logger.info("[KOOK] 发送文件消息成功") + logger.info("[KOOK] 上传文件到kook服务器成功") remote_url = result["data"]["url"] logger.debug(f"[KOOK] 文件远端URL: {remote_url}") return remote_url else: - logger.error(f"[KOOK] 发送文件消息失败: {result}") + raise RuntimeError(f"上传文件到kook服务器失败: {result}") else: - logger.error(f"[KOOK] 发送文件消息HTTP错误: {resp.status}") + raise RuntimeError( + f"上传文件到kook服务器 HTTP错误: {resp.status} , {await resp.text()}" + ) + except RuntimeError: + raise except Exception as e: - logger.error(f"[KOOK] 发送文件消息异常: {e}") + raise RuntimeError(f"上传文件到kook服务器异常: {e}") from e return "" From dace3f00d60b61f8af3296357228232040419371 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 20:24:05 +0800 Subject: [PATCH 26/36] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_adapter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index 0ada5d8741..0bc067f9c0 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -31,7 +31,6 @@ def __init__( super().__init__(platform_config, event_queue) self.kook_config = KookConfig.from_dict(platform_config) logger.debug(f"[KOOK] 配置: {self.kook_config.pretty_jsons()}") - # self.config = platform_config self.settings = platform_settings self.client = KookClient(self.kook_config, self._on_received) self._reconnect_task = None From 6c245c08ffcd680f2f0d8016a87fd8ca534eb1e3 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 22:44:14 +0800 Subject: [PATCH 27/36] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E6=B2=A1?= =?UTF-8?q?=E7=94=A8=E7=9A=84return?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index cf7b91e0bd..1da4487357 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -422,8 +422,6 @@ async def upload_asset(self, file_url: str | None) -> str: except Exception as e: raise RuntimeError(f"上传文件到kook服务器异常: {e}") from e - return "" - async def wait_until_closed(self): """提供给外部调用的等待方法""" await self._stop_event.wait() From 9a9560709782ad309644947aca908fd902e78159 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 23:28:24 +0800 Subject: [PATCH 28/36] =?UTF-8?q?refactor:=20=E5=8D=B3=E4=BD=BF=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=93=BE=E4=B8=AD=E6=9C=89=E6=B6=88=E6=81=AF=E5=8F=91?= =?UTF-8?q?=E9=80=81=E5=A4=B1=E8=B4=A5=E4=BA=86,=E4=B9=9F=E5=B0=BD?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E5=B0=86=E5=85=B6=E4=BB=96=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=8F=91=E9=80=81=E5=87=BA=E5=8E=BB,=E5=B9=B6=E6=8A=8A?= =?UTF-8?q?=E6=8A=A5=E9=94=99=E4=BF=A1=E6=81=AF=E4=B9=9F=E5=8F=91=E9=80=81?= =?UTF-8?q?=E5=87=BA=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/platform/sources/kook/kook_client.py | 16 +++++----- .../core/platform/sources/kook/kook_event.py | 31 ++++++++++++++----- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index 1da4487357..af168e9e62 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -332,16 +332,18 @@ async def send_text( async with self._http_client.post(url, json=payload) as resp: if resp.status == 200: result = await resp.json() - if result.get("code") == 0: - logger.info("[KOOK] 发送消息成功") - else: - logger.error( - f'[KOOK] 发送kook消息类型 "{kook_message_type.name}" 失败: {result}' + if result.get("code") != 0: + raise RuntimeError( + f'发送kook消息类型 "{kook_message_type.name}" 失败: {result}' ) + # else: + # logger.info("[KOOK] 发送消息成功") else: - logger.error( - f'[KOOK] 发送kook消息类型 "{kook_message_type.name}" HTTP错误: {resp.status} , 响应内容 : {await resp.text()}' + raise RuntimeError( + f'发送kook消息类型 "{kook_message_type.name}" HTTP错误: {resp.status} , 响应内容 : {await resp.text()}' ) + except RuntimeError: + raise except Exception as e: logger.error( f'[KOOK] 发送kook消息类型 "{kook_message_type.name}" 异常: {e}' diff --git a/astrbot/core/platform/sources/kook/kook_event.py b/astrbot/core/platform/sources/kook/kook_event.py index bd2e22407c..cbf6696f06 100644 --- a/astrbot/core/platform/sources/kook/kook_event.py +++ b/astrbot/core/platform/sources/kook/kook_event.py @@ -164,18 +164,35 @@ async def send(self, message: MessageChain): # order_messages.sort(key=lambda x: 0 if x.reply_id else 1) reply_id: str | int = "" + # TODO 暂时用不了 ExceptionGroup, + # 因为pyproject的target-version是"py3.10" + errors: list[Exception] = [] for item in order_messages: if item.reply_id: reply_id = item.reply_id if not item.text: logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"') continue - await self.client.send_text( - self.channel_id, - item.text, - self.astrbot_message_type, - item.type, - reply_id, - ) + try: + await self.client.send_text( + self.channel_id, + item.text, + self.astrbot_message_type, + item.type, + reply_id, + ) + except RuntimeError as exp: + await self.client.send_text( + self.channel_id, + str(exp), + self.astrbot_message_type, + KookMessageType.TEXT, + reply_id, + ) + errors.append(exp) + + if errors: + err_msg = "\n".join([str(err) for err in errors]) + logger.error(f"[kook] {err_msg}") await super().send(message) From 1ee409b3517564e09a09bbd6cd2261db16262d3c Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 23:48:00 +0800 Subject: [PATCH 29/36] =?UTF-8?q?refactor:=20=E5=A2=9E=E5=BC=BA=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E4=BB=BB=E5=8A=A1=E5=A4=B1=E8=B4=A5=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86,=E4=BD=BF=E5=85=B6?= =?UTF-8?q?=E5=8F=91=E7=94=9F=E9=94=99=E8=AF=AF=E6=97=B6=E5=B0=BD=E5=8A=9B?= =?UTF-8?q?=E8=80=8C=E4=B8=BA=E5=8F=91=E9=80=81=E5=85=B6=E4=BD=99=E6=B6=88?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/platform/sources/kook/kook_event.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/kook/kook_event.py b/astrbot/core/platform/sources/kook/kook_event.py index cbf6696f06..8412eacdb0 100644 --- a/astrbot/core/platform/sources/kook/kook_event.py +++ b/astrbot/core/platform/sources/kook/kook_event.py @@ -156,7 +156,31 @@ async def send(self, message: MessageChain): if self._file_message_counter > 0: logger.debug("[Kook] 正在向kook服务器上传文件") - order_messages = await asyncio.gather(*file_upload_tasks) + # asyncio.gather 的返回结果竟然是有序的,真神奇,但是以防万一 + # OrderMessage还是有index字段的 + # https://docs.python.org/3/library/asyncio-task.html#asyncio.gather + tasks_result = await asyncio.gather(*file_upload_tasks, return_exceptions=True) + order_messages: list[OrderMessage] = [] + + # 这里如果上传文件的任务有几个报错的 + # 那么就拿不到message index了 + # 那只能按结果列表的index来填进去了 + # 虽然自定义一个exception,里边加一个index字段也不是不行 + # 但是先这样吧 + for index, result in enumerate(tasks_result): + if isinstance(result, BaseException): + logger.error(f"[Kook] {result}") + # 构造一个虚假的 OrderMessage,让用户知道这里本来有张图但坏了 + # 这样后面的 for 循环就能把它当成普通文本发出去 + err_node = OrderMessage( + index=index, + text=f"⚠️ 图片上传失败: {result}", + type=KookMessageType.TEXT, + ) + order_messages.append(err_node) + else: + order_messages.append(result) + order_messages.sort(key=lambda x: x.index) # 考虑到reply可能多次出现在消息链中(虽然大概率不会有人这么用) From 3e17643a8effd2aa18839ded4250d486b167fc1b Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 23:53:56 +0800 Subject: [PATCH 30/36] =?UTF-8?q?refactor:=20=E5=8F=91=E9=80=81=E5=88=B0?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=A2=91=E9=81=93=E7=9A=84=E6=8A=A5=E9=94=99?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=8A=A0=E4=BA=86=E4=B8=AA=E2=9A=A0=EF=B8=8F?= =?UTF-8?q?,=E5=B0=8F=E5=B7=A7=E6=80=9D=E8=BF=99=E5=9D=97=3F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_event.py b/astrbot/core/platform/sources/kook/kook_event.py index 8412eacdb0..6d3cabebfd 100644 --- a/astrbot/core/platform/sources/kook/kook_event.py +++ b/astrbot/core/platform/sources/kook/kook_event.py @@ -174,7 +174,7 @@ async def send(self, message: MessageChain): # 这样后面的 for 循环就能把它当成普通文本发出去 err_node = OrderMessage( index=index, - text=f"⚠️ 图片上传失败: {result}", + text=f"⚠️ {result}", type=KookMessageType.TEXT, ) order_messages.append(err_node) @@ -208,7 +208,7 @@ async def send(self, message: MessageChain): except RuntimeError as exp: await self.client.send_text( self.channel_id, - str(exp), + f"⚠️ {str(exp)}", self.astrbot_message_type, KookMessageType.TEXT, reply_id, From e72aa75c36cd2e3b2de20f3f410372934e40e914 Mon Sep 17 00:00:00 2001 From: shuiping233 <1944680304@qq.com> Date: Mon, 2 Mar 2026 23:56:12 +0800 Subject: [PATCH 31/36] =?UTF-8?q?refactor:=20=E5=92=B1=E4=BB=AC=E5=9C=A8?= =?UTF-8?q?=E5=86=99=E9=80=82=E9=85=8D=E5=99=A8=E5=95=8A,=E8=A6=81?= =?UTF-8?q?=E4=BB=80=E4=B9=88=E5=B0=8F=E5=B7=A7=E6=80=9D=E5=91=A2,?= =?UTF-8?q?=E5=B0=8F=E5=B7=A7=E6=80=9D=E7=BB=99=E4=B8=8A=E6=B8=B8=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=BC=80=E5=8F=91=E5=BC=84=E4=B8=8D=E5=A5=BD=E4=B9=88?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/kook/kook_event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_event.py b/astrbot/core/platform/sources/kook/kook_event.py index 6d3cabebfd..6347661408 100644 --- a/astrbot/core/platform/sources/kook/kook_event.py +++ b/astrbot/core/platform/sources/kook/kook_event.py @@ -174,7 +174,7 @@ async def send(self, message: MessageChain): # 这样后面的 for 循环就能把它当成普通文本发出去 err_node = OrderMessage( index=index, - text=f"⚠️ {result}", + text=str(result), type=KookMessageType.TEXT, ) order_messages.append(err_node) @@ -208,7 +208,7 @@ async def send(self, message: MessageChain): except RuntimeError as exp: await self.client.send_text( self.channel_id, - f"⚠️ {str(exp)}", + str(exp), self.astrbot_message_type, KookMessageType.TEXT, reply_id, From 86e5eaf50b63afbc361b10f9959394fbe89526de Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 3 Mar 2026 14:30:46 +0800 Subject: [PATCH 32/36] refactor: enhance Kook adapter with kmarkdown parsing and improve file URL handling --- .../platform/sources/kook/kook_adapter.py | 120 +++++++++++++----- .../core/platform/sources/kook/kook_client.py | 22 +--- .../core/platform/sources/kook/kook_event.py | 4 +- 3 files changed, 92 insertions(+), 54 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index 0bc067f9c0..70de7b44a9 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -4,7 +4,7 @@ from astrbot import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import Image, Plain +from astrbot.api.message_components import At, AtAll, Image, Plain from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -165,52 +165,120 @@ async def _cleanup(self): logger.info("[KOOK] 资源清理完成") + def _parse_kmarkdown_text_message( + self, data: dict, self_id: str + ) -> tuple[list, str]: + 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) + + mention_name_map: dict[str, str] = {} + mention_part = kmarkdown.get("mention_part", []) + if isinstance(mention_part, list): + for item in mention_part: + if not isinstance(item, dict): + continue + mention_id = item.get("id") + if mention_id is None: + continue + mention_name_map[str(mention_id)] = str(item.get("username", "")) + + components = [] + cursor = 0 + for match in re.finditer(r"\(met\)([^()]+)\(met\)", content): + if match.start() > cursor: + plain_text = content[cursor : match.start()] + if plain_text: + components.append(Plain(text=plain_text)) + + mention_target = match.group(1).strip() + if mention_target == "all": + components.append(AtAll()) + elif mention_target: + components.append( + At( + qq=mention_target, + name=mention_name_map.get(mention_target, ""), + ) + ) + cursor = match.end() + + if cursor < len(content): + tail_text = content[cursor:] + if tail_text: + components.append(Plain(text=tail_text)) + + message_str = raw_content + if components: + for comp in components: + if isinstance(comp, Plain): + if not comp.text.strip(): + continue + break + if isinstance(comp, At): + if str(comp.qq) == str(self_id): + message_str = re.sub( + r"^@[^\s]+(\s*-\s*[^\s]+)?\s*", + "", + message_str, + count=1, + ).strip() + break + if not components: + if message_str: + components = [Plain(text=message_str)] + else: + components = [] + + return components, message_str + async def convert_message(self, data: dict) -> AstrBotMessage: abm = AstrBotMessage() abm.raw_message = data abm.self_id = self.client.bot_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 "GROUP": + session_id = data.get("target_id") or "unknown" abm.type = MessageType.GROUP_MESSAGE - abm.group_id = data.get("target_id") - abm.session_id = data.get("target_id") + abm.group_id = session_id + abm.session_id = session_id case "PERSON": abm.type = MessageType.FRIEND_MESSAGE abm.group_id = "" - abm.session_id = data.get("author_id") + 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 = data.get("target_id") - abm.session_id = data.get("target_id") + abm.group_id = session_id + abm.session_id = session_id case _: raise ValueError(f"不支持的频道类型: {channel_type}") abm.sender = MessageMember( - user_id=data.get("author_id"), + user_id=author_id, nickname=data.get("extra", {}).get("author", {}).get("username", ""), ) - abm.message_id = data.get("msg_id") + abm.message_id = data.get("msg_id", "unknown") # 普通文本消息 if data.get("type") == 9: - raw_content = ( - data.get("extra", {}) - .get("kmarkdown", {}) - .get("raw_content", data.get("content")) + message, message_str = self._parse_kmarkdown_text_message( + data, str(abm.self_id) ) - - raw_content = re.sub( - r"^@[^\s]+(\s*-\s*[^\s]+)?\s*", "", raw_content - ) # 删除@前缀 - abm.message_str = raw_content - abm.message = [Plain(text=raw_content)] + abm.message = message + abm.message_str = message_str # 卡片消息 elif data.get("type") == 10: - content = data.get("content") + content = data.get("content", {}) try: card_list = json.loads(content) text = "" @@ -262,18 +330,4 @@ async def handle_msg(self, message: AstrBotMessage): session_id=message.session_id, client=self.client, ) - raw = message.raw_message - is_at = False - # 检查kmarkdown.mention_role_part - kmarkdown = raw.get("extra", {}).get("kmarkdown", {}) - mention_role_part = kmarkdown.get("mention_role_part", []) - raw_content = kmarkdown.get("raw_content", "") - bot_nickname = self.client.bot_name - if mention_role_part: - is_at = True - elif f"@{bot_nickname}" in raw_content: - is_at = True - if is_at: - message_event.is_wake = True - message_event.is_at_or_wake_command = True self.commit_event(message_event) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index af168e9e62..d5bed65d4f 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -1,6 +1,7 @@ import asyncio import base64 import json +import os import random import time import zlib @@ -12,13 +13,10 @@ from astrbot import logger from astrbot.core.platform.message_type import MessageType -from astrbot.core.utils.astrbot_path import get_astrbot_data_path from .kook_config import KookConfig from .kook_types import KookApiPaths, KookMessageType -ALLOWED_ASSETS_DIR = Path(get_astrbot_data_path()).resolve() - class KookClient: def __init__(self, config: KookConfig, event_callback): @@ -362,16 +360,13 @@ async def upload_asset(self, file_url: str | None) -> str: filename = file_url.split("/")[-1] return file_url - elif file_url.startswith("base64:///"): + if file_url.startswith("base64:///"): # b64decode的时候得开头留一个'/'的, 不然会报错 b64_str = file_url.removeprefix("base64://") bytes_data = base64.b64decode(b64_str) - elif file_url.startswith("file://"): - if file_url.startswith("file:///"): - file_url = file_url.removeprefix("file:///") - else: - file_url = file_url.removeprefix("file://") + elif file_url.startswith("file://") or os.path.exists(file_url): + file_url = file_url.removeprefix("file://") try: target_path = Path(file_url).resolve() @@ -381,15 +376,6 @@ async def upload_asset(self, file_url: str | None) -> str: f'获取文件 "{file_url}" 绝对路径失败: "{exp}"' ) from exp - # 安全验证 - if not target_path.is_relative_to(ALLOWED_ASSETS_DIR): - logger.error( - f'[KOOK] 拒绝访问: "{target_path.as_posix()}" 不在允许的目录范围内' - ) - raise PermissionError( - f'拒绝访问: "{target_path.name}"文件路径不在允许的目录范围内' - ) - if not target_path.is_file(): raise FileNotFoundError(f"文件不存在: {target_path.name}") diff --git a/astrbot/core/platform/sources/kook/kook_event.py b/astrbot/core/platform/sources/kook/kook_event.py index 6347661408..0c723f98bc 100644 --- a/astrbot/core/platform/sources/kook/kook_event.py +++ b/astrbot/core/platform/sources/kook/kook_event.py @@ -156,9 +156,7 @@ async def send(self, message: MessageChain): if self._file_message_counter > 0: logger.debug("[Kook] 正在向kook服务器上传文件") - # asyncio.gather 的返回结果竟然是有序的,真神奇,但是以防万一 - # OrderMessage还是有index字段的 - # https://docs.python.org/3/library/asyncio-task.html#asyncio.gather + tasks_result = await asyncio.gather(*file_upload_tasks, return_exceptions=True) order_messages: list[OrderMessage] = [] From 877f591ed7aa3f9dc4cb965347ff2015d04d0cc1 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 3 Mar 2026 14:33:22 +0800 Subject: [PATCH 33/36] refactor: extract card message parsing logic into a separate method --- .../platform/sources/kook/kook_adapter.py | 84 ++++++++++++------- 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index 70de7b44a9..c9a91c544b 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -236,6 +236,57 @@ def _parse_kmarkdown_text_message( return components, message_str + def _parse_card_message(self, data: dict) -> tuple[list, str]: + content = data.get("content", "[]") + if not isinstance(content, str): + content = str(content) + card_list = json.loads(content) + + text_parts: list[str] = [] + images: list[str] = [] + + for card in card_list: + if not isinstance(card, dict): + continue + for module in card.get("modules", []): + if not isinstance(module, dict): + continue + + 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 + + if module_type != "container": + continue + + for element in module.get("elements", []): + if not isinstance(element, dict): + continue + if element.get("type") != "image": + continue + + 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: + message.append(Plain(text=text)) + for img_url in images: + message.append(Image(file=img_url)) + return message, text + async def convert_message(self, data: dict) -> AstrBotMessage: abm = AstrBotMessage() abm.raw_message = data @@ -278,39 +329,8 @@ async def convert_message(self, data: dict) -> AstrBotMessage: abm.message_str = message_str # 卡片消息 elif data.get("type") == 10: - content = data.get("content", {}) try: - card_list = json.loads(content) - text = "" - images = [] - for card in card_list: - for module in card.get("modules", []): - if module.get("type") == "section": - text += module.get("text", {}).get("content", "") - elif module.get("type") == "container": - for element in module.get("elements", []): - if element.get("type") == "image": - 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) - - abm.message_str = text - abm.message = [] - if text: - abm.message.append(Plain(text=text)) - for img_url in images: - abm.message.append(Image(file=img_url)) + abm.message, abm.message_str = self._parse_card_message(data) except Exception as exp: logger.error(f"[KOOK] 卡片消息解析失败: {exp}") abm.message_str = "[卡片消息解析失败]" From 04292fd3088b726ce5cb17a8f11d2c22e6cf82a0 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 3 Mar 2026 14:48:50 +0800 Subject: [PATCH 34/36] feat: add kook_bot_nickname configuration to ignore messages from specific nicknames --- astrbot/core/config/default.py | 6 +++++ .../platform/sources/kook/kook_adapter.py | 22 +++++++++++++++++-- .../core/platform/sources/kook/kook_client.py | 1 + .../core/platform/sources/kook/kook_config.py | 2 ++ .../en-US/features/config-metadata.json | 7 +++++- .../zh-CN/features/config-metadata.json | 7 +++++- 6 files changed, 41 insertions(+), 4 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index ec6bc423b4..cbadb5c18f 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -454,6 +454,7 @@ class ChatProviderTemplate(TypedDict): "type": "kook", "enable": False, "kook_bot_token": "", + "kook_bot_nickname": "", "kook_reconnect_delay": 1, "kook_max_reconnect_delay": 60, "kook_max_retry_delay": 60, @@ -808,6 +809,11 @@ class ChatProviderTemplate(TypedDict): "type": "string", "hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。", }, + "kook_bot_nickname": { + "description": "Bot Nickname", + "type": "string", + "hint": "可选项。若发送者昵称与此值一致,将忽略该消息以避免广播风暴。", + }, "kook_reconnect_delay": { "description": "重连延迟", "type": "int", diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py index c9a91c544b..1124c6841d 100644 --- a/astrbot/core/platform/sources/kook/kook_adapter.py +++ b/astrbot/core/platform/sources/kook/kook_adapter.py @@ -57,14 +57,32 @@ def meta(self) -> PlatformMetadata: name="kook", description="KOOK 适配器", id=self.kook_config.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 + + 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: - event_type = data["d"].get("type") + 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(data["d"]) + abm = await self.convert_message(payload) await self.handle_msg(abm) except Exception as e: logger.error(f"[KOOK] 消息处理异常: {e}") diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index d5bed65d4f..0372e44557 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -394,6 +394,7 @@ async def upload_asset(self, file_url: str | None) -> str: async with self._http_client.post(url, data=data) as resp: if resp.status == 200: result: dict = await resp.json() + logger.debug(f"[KOOK] 上传文件响应: {result}") if result.get("code") == 0: logger.info("[KOOK] 上传文件到kook服务器成功") remote_url = result["data"]["url"] diff --git a/astrbot/core/platform/sources/kook/kook_config.py b/astrbot/core/platform/sources/kook/kook_config.py index 0b9d180a29..21f2547b03 100644 --- a/astrbot/core/platform/sources/kook/kook_config.py +++ b/astrbot/core/platform/sources/kook/kook_config.py @@ -9,6 +9,7 @@ class KookConfig: # 基础配置 token: str + bot_nickname: str = "" enable: bool = False id: str = "kook" @@ -40,6 +41,7 @@ def from_dict(cls, config_dict: dict) -> "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, diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 7e98e16d2c..a143678c23 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -590,6 +590,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", @@ -1488,4 +1493,4 @@ "helpMiddle": "or", "helpSuffix": "." } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 2619db6c05..015ce3082c 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -593,6 +593,11 @@ "type": "string", "hint": "必填项。从 KOOK 开发者平台获取的机器人 Token" }, + "kook_bot_nickname": { + "description": "Bot Nickname", + "type": "string", + "hint": "可选项。若发送者昵称与此值一致,将忽略该消息。" + }, "kook_reconnect_delay": { "description": "重连延迟", "type": "int", @@ -1491,4 +1496,4 @@ "helpMiddle": "或", "helpSuffix": "。" } -} \ No newline at end of file +} From a63fa83cf06268759b7a8f0147b3a23315b713db Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 3 Mar 2026 14:56:06 +0800 Subject: [PATCH 35/36] refactor: remove commented-out code and clean up file upload error handling --- astrbot/core/platform/sources/kook/kook_event.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/astrbot/core/platform/sources/kook/kook_event.py b/astrbot/core/platform/sources/kook/kook_event.py index 0c723f98bc..12f72a9790 100644 --- a/astrbot/core/platform/sources/kook/kook_event.py +++ b/astrbot/core/platform/sources/kook/kook_event.py @@ -160,11 +160,6 @@ async def send(self, message: MessageChain): tasks_result = await asyncio.gather(*file_upload_tasks, return_exceptions=True) order_messages: list[OrderMessage] = [] - # 这里如果上传文件的任务有几个报错的 - # 那么就拿不到message index了 - # 那只能按结果列表的index来填进去了 - # 虽然自定义一个exception,里边加一个index字段也不是不行 - # 但是先这样吧 for index, result in enumerate(tasks_result): if isinstance(result, BaseException): logger.error(f"[Kook] {result}") @@ -181,13 +176,7 @@ async def send(self, message: MessageChain): order_messages.sort(key=lambda x: x.index) - # 考虑到reply可能多次出现在消息链中(虽然大概率不会有人这么用) - # 这里还是不对reply进行排序了 - # order_messages.sort(key=lambda x: 0 if x.reply_id else 1) - reply_id: str | int = "" - # TODO 暂时用不了 ExceptionGroup, - # 因为pyproject的target-version是"py3.10" errors: list[Exception] = [] for item in order_messages: if item.reply_id: From e6ba594f0ed40a1212bd1de1f6510dbe09245856 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Tue, 3 Mar 2026 15:06:23 +0800 Subject: [PATCH 36/36] fix: remove redundant prefix handling for file URLs in asset upload --- astrbot/core/platform/sources/kook/kook_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/astrbot/core/platform/sources/kook/kook_client.py b/astrbot/core/platform/sources/kook/kook_client.py index 0372e44557..a48a6fb658 100644 --- a/astrbot/core/platform/sources/kook/kook_client.py +++ b/astrbot/core/platform/sources/kook/kook_client.py @@ -366,6 +366,7 @@ async def upload_asset(self, file_url: str | None) -> str: bytes_data = base64.b64decode(b64_str) elif file_url.startswith("file://") or os.path.exists(file_url): + file_url = file_url.removeprefix("file:///") file_url = file_url.removeprefix("file://") try: