perf: Implement Pydantic data models for the KOOK adapter to enhance data retrieval and message schema validation (#5719)

* refactor: 给kook适配器添加kook事件数据类

* format: 使用StrEnum替换kook适配器中的(str,enum)
This commit is contained in:
shuiping233
2026-03-17 18:05:58 +08:00
committed by GitHub
parent dcffb5269a
commit f5ba1a026a
23 changed files with 1036 additions and 293 deletions
-6
View File
@@ -463,7 +463,6 @@ CONFIG_METADATA_2 = {
"type": "kook", "type": "kook",
"enable": False, "enable": False,
"kook_bot_token": "", "kook_bot_token": "",
"kook_bot_nickname": "",
"kook_reconnect_delay": 1, "kook_reconnect_delay": 1,
"kook_max_reconnect_delay": 60, "kook_max_reconnect_delay": 60,
"kook_max_retry_delay": 60, "kook_max_retry_delay": 60,
@@ -875,11 +874,6 @@ CONFIG_METADATA_2 = {
"type": "string", "type": "string",
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。", "hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
}, },
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息以避免广播风暴。",
},
"kook_reconnect_delay": { "kook_reconnect_delay": {
"description": "重连延迟", "description": "重连延迟",
"type": "int", "type": "int",
@@ -13,11 +13,28 @@ from astrbot.api.platform import (
PlatformMetadata, PlatformMetadata,
register_platform_adapter, register_platform_adapter,
) )
from astrbot.core.message.components import File, Record, Video
from astrbot.core.platform.astr_message_event import MessageSesion from astrbot.core.platform.astr_message_event import MessageSesion
from .kook_client import KookClient from .kook_client import KookClient
from .kook_config import KookConfig from .kook_config import KookConfig
from .kook_event import KookEvent from .kook_event import KookEvent
from .kook_types import (
ContainerModule,
FileModule,
HeaderModule,
ImageGroupModule,
KmarkdownElement,
KookCardMessageContainer,
KookChannelType,
KookMessageEventData,
KookMessageType,
KookModuleType,
PlainTextElement,
SectionModule,
)
KOOK_AT_SELECTOR_REGEX = re.compile(r"\(met\)([^()]+)\(met\)")
@register_platform_adapter( @register_platform_adapter(
@@ -57,35 +74,26 @@ class KookPlatformAdapter(Platform):
name="kook", description="KOOK 适配器", id=self.kook_config.id name="kook", description="KOOK 适配器", id=self.kook_config.id
) )
def _should_ignore_event_by_bot_nickname(self, payload: dict) -> bool: def _should_ignore_event_by_bot_nickname(self, author_id: str) -> bool:
bot_nickname = self.kook_config.bot_nickname.strip() return self.client.bot_id == author_id
if not bot_nickname:
return False
author = payload.get("extra", {}).get("author", {}) async def _on_received(self, event: KookMessageEventData):
if not isinstance(author, dict): logger.debug(
return False f'[KOOK] 收到来自"{event.channel_type.name}"渠道的消息, 消息类型为: {event.type.name}({event.type.value})'
)
author_nickname = author.get("nickname") or author.get("username") or "" event_type = event.type
if not isinstance(author_nickname, str): if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD):
author_nickname = str(author_nickname) if self._should_ignore_event_by_bot_nickname(event.author_id):
logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息")
return author_nickname.strip().casefold() == bot_nickname.casefold() return
try:
async def _on_received(self, data: dict): abm = await self.convert_message(event)
logger.debug(f"KOOK 收到数据: {data}") await self.handle_msg(abm)
if "d" in data and data["s"] == 0: except Exception as e:
payload = data["d"] logger.error(f"[KOOK] 消息处理异常: {e}")
event_type = payload.get("type") elif event_type == KookMessageType.SYSTEM:
# 支持type=9(文本)和type=10(卡片) logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"')
if event_type in (9, 10): logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}")
if self._should_ignore_event_by_bot_nickname(payload):
return
try:
abm = await self.convert_message(payload)
await self.handle_msg(abm)
except Exception as e:
logger.error(f"[KOOK] 消息处理异常: {e}")
async def run(self): async def run(self):
"""主运行循环""" """主运行循环"""
@@ -184,18 +192,26 @@ class KookPlatformAdapter(Platform):
logger.info("[KOOK] 资源清理完成") logger.info("[KOOK] 资源清理完成")
def _parse_kmarkdown_text_message( def _parse_kmarkdown_text_message(
self, data: dict, self_id: str self, data: KookMessageEventData, self_id: str
) -> tuple[list, str]: ) -> tuple[list, str]:
kmarkdown = data.get("extra", {}).get("kmarkdown", {}) kmarkdown = data.extra.kmarkdown
content = data.get("content") or "" content = data.content or ""
raw_content = kmarkdown.get("raw_content") or content if kmarkdown is None:
logger.error(
f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段'
)
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
return [], ""
raw_content = kmarkdown.raw_content or content
if not isinstance(content, str): if not isinstance(content, str):
content = str(content) content = str(content)
if not isinstance(raw_content, str): if not isinstance(raw_content, str):
raw_content = str(raw_content) raw_content = str(raw_content)
# TODO 后面的pydantic类型替换,以后再来探索吧 :(
mention_name_map: dict[str, str] = {} mention_name_map: dict[str, str] = {}
mention_part = kmarkdown.get("mention_part", []) mention_part = kmarkdown.mention_part
if isinstance(mention_part, list): if isinstance(mention_part, list):
for item in mention_part: for item in mention_part:
if not isinstance(item, dict): if not isinstance(item, dict):
@@ -207,7 +223,7 @@ class KookPlatformAdapter(Platform):
components = [] components = []
cursor = 0 cursor = 0
for match in re.finditer(r"\(met\)([^()]+)\(met\)", content): for match in KOOK_AT_SELECTOR_REGEX.finditer(content):
if match.start() > cursor: if match.start() > cursor:
plain_text = content[cursor : match.start()] plain_text = content[cursor : match.start()]
if plain_text: if plain_text:
@@ -254,77 +270,109 @@ class KookPlatformAdapter(Platform):
return components, message_str return components, message_str
def _parse_card_message(self, data: dict) -> tuple[list, str]: def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]:
content = data.get("content", "[]") content = data.content
if not isinstance(content, str): if not isinstance(content, str):
content = str(content) content = str(content)
card_list = json.loads(content)
card_list = KookCardMessageContainer.from_dict(json.loads(content))
text_parts: list[str] = [] text_parts: list[str] = []
images: list[str] = [] images: list[str] = []
files: list[tuple[KookModuleType, str, str]] = []
for card in card_list: for card in card_list:
if not isinstance(card, dict): for module in card.modules:
continue match module:
for module in card.get("modules", []): case SectionModule():
if not isinstance(module, dict): if content := self._handle_section_text(module):
continue text_parts.append(content)
module_type = module.get("type") case ContainerModule() | ImageGroupModule():
if module_type == "section": urls = self._handle_image_group(module)
section_text = module.get("text", {}).get("content", "") images.extend(urls)
if section_text: text_parts.append(" [image]" * len(urls))
text_parts.append(str(section_text))
continue
if module_type != "container": case HeaderModule():
continue text_parts.append(module.text.content)
for element in module.get("elements", []): case FileModule():
if not isinstance(element, dict): files.append((module.type, module.title, module.src))
continue text_parts.append(f" [{module.type.value}]")
if element.get("type") != "image":
continue
image_src = element.get("src") case _:
if not isinstance(image_src, str): logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}")
logger.warning(
f'[KOOK] 处理卡片中的图片时发生错误,图片url "{image_src}" 应该为str类型, 而不是 "{type(image_src)}" '
)
continue
if not image_src.startswith(("http://", "https://")):
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
continue
images.append(image_src)
text = "".join(text_parts) text = "".join(text_parts)
message = [] message = []
if text: if text:
for search in KOOK_AT_SELECTOR_REGEX.finditer(text):
search_text = search.group(1).strip()
if search_text == "all":
message.append(AtAll())
continue
message.append(At(qq=search_text))
text = text.replace(f"(met){search_text}(met)", "")
message.append(Plain(text=text)) message.append(Plain(text=text))
for img_url in images: for img_url in images:
message.append(Image(file=img_url)) message.append(Image(file=img_url))
for file in files:
file_type = file[0]
file_name = file[1]
file_url = file[2]
if file_type == KookModuleType.FILE:
message.append(File(name=file_name, file=file_url))
elif file_type == KookModuleType.VIDEO:
message.append(Video(file=file_url))
elif file_type == KookModuleType.AUDIO:
message.append(Record(file=file_url))
else:
logger.warning(f"[KOOK] 跳过未知文件类型: {file_type.name}")
return message, text return message, text
async def convert_message(self, data: dict) -> AstrBotMessage: def _handle_section_text(self, module: SectionModule) -> str:
"""专门处理 Section 里的文本提取"""
if isinstance(module.text, (KmarkdownElement, PlainTextElement)):
return module.text.content or ""
return ""
def _handle_image_group(
self, module: ContainerModule | ImageGroupModule
) -> list[str]:
"""专门处理图片组/容器里的合法 URL 提取"""
valid_urls = []
for el in module.elements:
image_src = el.src
if not el.src.startswith(("http://", "https://")):
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
continue
valid_urls.append(el.src)
return valid_urls
async def convert_message(self, data: KookMessageEventData) -> AstrBotMessage:
abm = AstrBotMessage() abm = AstrBotMessage()
abm.raw_message = data abm.raw_message = data.to_dict()
abm.self_id = self.client.bot_id abm.self_id = self.client.bot_id
channel_type = data.get("channel_type") channel_type = data.channel_type
author_id = data.get("author_id", "unknown") author_id = data.author_id
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction # channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
match channel_type: match channel_type:
case "GROUP": case KookChannelType.GROUP:
session_id = data.get("target_id") or "unknown" session_id = data.target_id or "unknown"
abm.type = MessageType.GROUP_MESSAGE abm.type = MessageType.GROUP_MESSAGE
abm.group_id = session_id abm.group_id = session_id
abm.session_id = session_id abm.session_id = session_id
case "PERSON": case KookChannelType.PERSON:
abm.type = MessageType.FRIEND_MESSAGE abm.type = MessageType.FRIEND_MESSAGE
abm.group_id = "" abm.group_id = ""
abm.session_id = data.get("author_id", "unknown") abm.session_id = data.author_id or "unknown"
case "BROADCAST": case KookChannelType.BROADCAST:
session_id = data.get("target_id") or "unknown" session_id = data.target_id or "unknown"
abm.type = MessageType.OTHER_MESSAGE abm.type = MessageType.OTHER_MESSAGE
abm.group_id = session_id abm.group_id = session_id
abm.session_id = session_id abm.session_id = session_id
@@ -333,28 +381,25 @@ class KookPlatformAdapter(Platform):
abm.sender = MessageMember( abm.sender = MessageMember(
user_id=author_id, user_id=author_id,
nickname=data.get("extra", {}).get("author", {}).get("username", ""), nickname=data.extra.author.username if data.extra.author else "unknown",
) )
abm.message_id = data.get("msg_id", "unknown") abm.message_id = data.msg_id or "unknown"
# 普通文本消息 if data.type == KookMessageType.KMARKDOWN:
if data.get("type") == 9: message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id)
message, message_str = self._parse_kmarkdown_text_message(
data, str(abm.self_id)
)
abm.message = message abm.message = message
abm.message_str = message_str abm.message_str = message_str
# 卡片消息 elif data.type == KookMessageType.CARD:
elif data.get("type") == 10:
try: try:
abm.message, abm.message_str = self._parse_card_message(data) abm.message, abm.message_str = self._parse_card_message(data)
except Exception as exp: except Exception as exp:
logger.error(f"[KOOK] 卡片消息解析失败: {exp}") logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
abm.message_str = "[卡片消息解析失败]" abm.message_str = "[卡片消息解析失败]"
abm.message = [Plain(text="[卡片消息解析失败]")] abm.message = [Plain(text="[卡片消息解析失败]")]
else: else:
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.get("type")}"') logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.type.name}"')
abm.message_str = "[不支持的消息类型]" abm.message_str = "[不支持的消息类型]"
abm.message = [Plain(text="[不支持的消息类型]")] abm.message = [Plain(text="[不支持的消息类型]")]
+103 -56
View File
@@ -1,6 +1,5 @@
import asyncio import asyncio
import base64 import base64
import json
import os import os
import random import random
import time import time
@@ -9,13 +8,23 @@ from pathlib import Path
import aiofiles import aiofiles
import aiohttp import aiohttp
import pydantic
import websockets import websockets
from astrbot import logger from astrbot import logger
from astrbot.core.platform.message_type import MessageType from astrbot.core.platform.message_type import MessageType
from .kook_config import KookConfig from .kook_config import KookConfig
from .kook_types import KookApiPaths, KookMessageType from .kook_types import (
KookApiPaths,
KookGatewayIndexResponse,
KookHelloEventData,
KookMessageSignal,
KookMessageType,
KookResumeAckEventData,
KookUserMeResponse,
KookWebsocketEvent,
)
class KookClient: class KookClient:
@@ -23,7 +32,8 @@ class KookClient:
# 数据字段 # 数据字段
self.config = config self.config = config
self._bot_id = "" self._bot_id = ""
self._bot_name = "" self._bot_username = ""
self._bot_nickname = ""
# 资源字段 # 资源字段
self._http_client = aiohttp.ClientSession( self._http_client = aiohttp.ClientSession(
@@ -48,37 +58,50 @@ class KookClient:
return self._bot_id return self._bot_id
@property @property
def bot_name(self): def bot_nickname(self):
return self._bot_name return self._bot_nickname
async def get_bot_info(self) -> str: @property
"""获取机器人账号ID""" def bot_username(self):
return self._bot_username
async def get_bot_info(self) -> None:
"""获取机器人账号信息"""
url = KookApiPaths.USER_ME url = KookApiPaths.USER_ME
try: try:
async with self._http_client.get(url) as resp: async with self._http_client.get(url) as resp:
if resp.status != 200: if resp.status != 200:
logger.error(f"[KOOK] 获取机器人账号ID失败,状态码: {resp.status}") logger.error(
return "" f"[KOOK] 获取机器人账号信息失败,状态码: {resp.status} , {await resp.text()}"
)
return
try:
resp_content = KookUserMeResponse.from_dict(await resp.json())
except pydantic.ValidationError as e:
logger.error(
f"[KOOK] 获取机器人账号信息失败, 响应数据格式错误: \n{e}"
)
logger.error(f"[KOOK] 响应内容: {await resp.text()}")
return
data = await resp.json() if not resp_content.success():
if data.get("code") != 0: logger.error(
logger.error(f"[KOOK] 获取机器人账号ID失败: {data}") f"[KOOK] 获取机器人账号信息失败: {resp_content.model_dump_json()}"
return "" )
return
bot_id: str = data["data"]["id"] bot_id: str = resp_content.data.id
self._bot_id = bot_id self._bot_id = bot_id
logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}") logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}")
bot_name: str = data["data"]["nickname"] or data["data"]["username"] self._bot_nickname = resp_content.data.nickname
self._bot_name = bot_name self._bot_username = resp_content.data.username
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_name}") logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_nickname}")
return bot_id
except Exception as e: except Exception as e:
logger.error(f"[KOOK] 获取机器人账号ID异常: {e}") logger.error(f"[KOOK] 获取机器人账号信息异常: {e}")
return ""
async def get_gateway_url(self, resume=False, sn=0, session_id=None): async def get_gateway_url(self, resume=False, sn=0, session_id=None) -> str | None:
"""获取网关连接地址""" """获取网关连接地址"""
url = KookApiPaths.GATEWAY_INDEX url = KookApiPaths.GATEWAY_INDEX
@@ -96,14 +119,20 @@ class KookClient:
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}") logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
return None return None
data = await resp.json() resp_content = KookGatewayIndexResponse.from_dict(await resp.json())
if data.get("code") != 0: if not resp_content.success():
logger.error(f"[KOOK] 获取gateway失败: {data}") logger.error(f"[KOOK] 获取gateway失败: {resp_content}")
return None return None
gateway_url: str = data["data"]["url"] gateway_url: str = resp_content.data.url
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}") logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
return gateway_url return gateway_url
except pydantic.ValidationError as e:
logger.error(f"[KOOK] 获取gateway失败, 响应数据格式错误: \n{e}")
logger.error(f"[KOOK] 原始响应内容: {await resp.text()}")
return None
except Exception as e: except Exception as e:
logger.error(f"[KOOK] 获取gateway异常: {e}") logger.error(f"[KOOK] 获取gateway异常: {e}")
return None return None
@@ -156,7 +185,11 @@ class KookClient:
try: try:
while self.running: while self.running:
try: try:
msg = await asyncio.wait_for(self.ws.recv(), timeout=10) # type: ignore if self.ws is None:
logger.error("[KOOK] WebSocket 对象丢失,结束监听流程。")
break
msg = await asyncio.wait_for(self.ws.recv(), timeout=10)
if isinstance(msg, bytes): if isinstance(msg, bytes):
try: try:
@@ -166,10 +199,15 @@ class KookClient:
continue continue
msg = msg.decode("utf-8") msg = msg.decode("utf-8")
data = json.loads(msg) event = KookWebsocketEvent.from_json(msg)
# 处理不同类型的信令 # 处理不同类型的信令
await self._handle_signal(data) await self._handle_signal(event)
except pydantic.ValidationError as e:
logger.error(f"[KOOK] 解析WebSocket事件数据格式失败: \n{e}")
logger.error(f"[KOOK] 原始响应内容: {msg}")
continue
except asyncio.TimeoutError: except asyncio.TimeoutError:
# 超时检查,继续循环 # 超时检查,继续循环
@@ -187,38 +225,41 @@ class KookClient:
self.running = False self.running = False
self._stop_event.set() self._stop_event.set()
async def _handle_signal(self, data): async def _handle_signal(self, event: KookWebsocketEvent):
"""处理不同类型的信令""" """处理不同类型的信令"""
signal_type = data.get("s") data = event.data
if signal_type == 0: # 事件消息 match event.signal:
# 更新消息序号 case KookMessageSignal.MESSAGE:
if "sn" in data: if event.sn is not None:
self.last_sn = data["sn"] self.last_sn = event.sn
await self.event_callback(data) await self.event_callback(data)
elif signal_type == 1: # HELLO握手 case KookMessageSignal.HELLO:
await self._handle_hello(data) assert isinstance(data, KookHelloEventData)
await self._handle_hello(data)
elif signal_type == 3: # PONG心跳响应 case KookMessageSignal.RESUME_ACK:
await self._handle_pong(data) assert isinstance(data, KookResumeAckEventData)
await self._handle_resume_ack(data)
elif signal_type == 5: # RECONNECT重连指令 case KookMessageSignal.PONG:
await self._handle_reconnect(data) await self._handle_pong()
elif signal_type == 6: # RESUME ACK case KookMessageSignal.RECONNECT:
await self._handle_resume_ack(data) await self._handle_reconnect()
else: case _:
logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}") logger.debug(
f"[KOOK] 未处理的信令类型: {event.signal.name}({event.signal.value})"
)
async def _handle_hello(self, data): async def _handle_hello(self, data: KookHelloEventData):
"""处理HELLO握手""" """处理HELLO握手"""
hello_data = data.get("d", {}) code = data.code
code = hello_data.get("code", 0)
if code == 0: if code == 0:
self.session_id = hello_data.get("session_id") self.session_id = data.session_id
logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}") logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}")
# TODO 重置重连延迟 # TODO 重置重连延迟
# self.reconnect_delay = 1 # self.reconnect_delay = 1
@@ -228,12 +269,12 @@ class KookClient:
logger.error("[KOOK] Token已过期,需要重新获取") logger.error("[KOOK] Token已过期,需要重新获取")
self.running = False self.running = False
async def _handle_pong(self, data): async def _handle_pong(self):
"""处理PONG心跳响应""" """处理PONG心跳响应"""
self.last_heartbeat_time = time.time() self.last_heartbeat_time = time.time()
self.heartbeat_failed_count = 0 self.heartbeat_failed_count = 0
async def _handle_reconnect(self, data): async def _handle_reconnect(self):
"""处理重连指令""" """处理重连指令"""
logger.warning("[KOOK] 收到重连指令") logger.warning("[KOOK] 收到重连指令")
# 清空本地状态 # 清空本地状态
@@ -241,10 +282,9 @@ class KookClient:
self.session_id = None self.session_id = None
self.running = False self.running = False
async def _handle_resume_ack(self, data): async def _handle_resume_ack(self, data: KookResumeAckEventData):
"""处理RESUME确认""" """处理RESUME确认"""
resume_data = data.get("d", {}) self.session_id = data.session_id
self.session_id = resume_data.get("session_id")
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}") logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
async def _heartbeat_loop(self): async def _heartbeat_loop(self):
@@ -292,9 +332,16 @@ class KookClient:
async def _send_ping(self): async def _send_ping(self):
"""发送心跳PING""" """发送心跳PING"""
if self.ws is None:
logger.warning("[KOOK] 尚未连接kook WebSocket服务器, 跳过发送心跳包流程")
return
try: try:
ping_data = {"s": 2, "sn": self.last_sn} ping_data = KookWebsocketEvent(
await self.ws.send(json.dumps(ping_data)) # type: ignore signal=KookMessageSignal.PING,
data=None,
sn=self.last_sn,
)
await self.ws.send(ping_data.to_json())
except Exception as e: except Exception as e:
logger.error(f"[KOOK] 发送心跳失败: {e}") logger.error(f"[KOOK] 发送心跳失败: {e}")
@@ -9,7 +9,6 @@ class KookConfig:
# 基础配置 # 基础配置
token: str token: str
bot_nickname: str = ""
enable: bool = False enable: bool = False
id: str = "kook" id: str = "kook"
@@ -41,7 +40,6 @@ class KookConfig:
# id=config_dict.get("id", "kook"), # id=config_dict.get("id", "kook"),
enable=config_dict.get("enable", False), enable=config_dict.get("enable", False),
token=config_dict.get("kook_bot_token", ""), token=config_dict.get("kook_bot_token", ""),
bot_nickname=config_dict.get("kook_bot_nickname", ""),
reconnect_delay=config_dict.get( reconnect_delay=config_dict.get(
"kook_reconnect_delay", "kook_reconnect_delay",
KookConfig.reconnect_delay, KookConfig.reconnect_delay,
@@ -27,6 +27,7 @@ from .kook_types import (
KookCardMessage, KookCardMessage,
KookCardMessageContainer, KookCardMessageContainer,
KookMessageType, KookMessageType,
KookModuleType,
OrderMessage, OrderMessage,
) )
@@ -111,7 +112,7 @@ class KookEvent(AstrMessageEvent):
KookCardMessage( KookCardMessage(
modules=[ modules=[
FileModule( FileModule(
type="audio", type=KookModuleType.AUDIO,
title=title, title=title,
src=url, src=url,
) )
@@ -182,7 +183,7 @@ class KookEvent(AstrMessageEvent):
if item.reply_id: if item.reply_id:
reply_id = item.reply_id reply_id = item.reply_id
if not item.text: if not item.text:
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"') logger.debug(f'[Kook] 跳过空消息,类型为"{item.type.name}"')
continue continue
try: try:
await self.client.send_text( await self.client.send_text(
+319 -55
View File
@@ -1,10 +1,8 @@
import json import json
from dataclasses import field from enum import IntEnum, StrEnum
from enum import IntEnum from typing import Annotated, Any, Literal
from typing import Literal
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic.dataclasses import dataclass
class KookApiPaths: class KookApiPaths:
@@ -25,8 +23,9 @@ class KookApiPaths:
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create" DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
# 定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction
class KookMessageType(IntEnum): class KookMessageType(IntEnum):
"""定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction"""
TEXT = 1 TEXT = 1
IMAGE = 2 IMAGE = 2
VIDEO = 3 VIDEO = 3
@@ -37,6 +36,26 @@ class KookMessageType(IntEnum):
SYSTEM = 255 SYSTEM = 255
class KookModuleType(StrEnum):
PLAIN_TEXT = "plain-text"
KMARKDOWN = "kmarkdown"
IMAGE = "image"
BUTTON = "button"
HEADER = "header"
SECTION = "section"
IMAGE_GROUP = "image-group"
CONTAINER = "container"
ACTION_GROUP = "action-group"
CONTEXT = "context"
DIVIDER = "divider"
FILE = "file"
AUDIO = "audio"
VIDEO = "video"
COUNTDOWN = "countdown"
INVITE = "invite"
CARD = "card"
ThemeType = Literal[ ThemeType = Literal[
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible" "primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
] ]
@@ -48,43 +67,81 @@ SectionMode = Literal["left", "right"]
CountdownMode = Literal["day", "hour", "second"] CountdownMode = Literal["day", "hour", "second"]
class KookCardColor(str): class KookBaseDataClass(BaseModel):
"""16 进制色值""" model_config = ConfigDict(
extra="allow",
arbitrary_types_allowed=True,
populate_by_name=True,
)
@classmethod
def from_dict(cls, raw_data: dict):
return cls.model_validate(raw_data)
@classmethod
def from_json(cls, raw_data: str | bytes | bytearray):
return cls.model_validate_json(raw_data)
def to_dict(
self,
mode: Literal["json", "python"] | str = "python",
by_alias=True,
exclude_none=True,
exclude_unset=False,
) -> dict:
return self.model_dump(
by_alias=by_alias,
exclude_none=exclude_none,
mode=mode,
exclude_unset=exclude_unset,
)
def to_json(
self,
indent: int | None = None,
ensure_ascii=False,
by_alias=True,
exclude_none=True,
exclude_unset=False,
) -> str:
return self.model_dump_json(
indent=indent,
ensure_ascii=ensure_ascii,
by_alias=by_alias,
exclude_none=exclude_none,
exclude_unset=exclude_unset,
)
class KookCardModelBase: class KookCardModelBase(KookBaseDataClass):
"""卡片模块基类""" """卡片模块基类"""
type: str type: str
@dataclass
class PlainTextElement(KookCardModelBase): class PlainTextElement(KookCardModelBase):
content: str content: str
type: str = "plain-text" type: Literal[KookModuleType.PLAIN_TEXT] = KookModuleType.PLAIN_TEXT
emoji: bool = True emoji: bool = True
@dataclass
class KmarkdownElement(KookCardModelBase): class KmarkdownElement(KookCardModelBase):
content: str content: str
type: str = "kmarkdown" type: Literal[KookModuleType.KMARKDOWN] = KookModuleType.KMARKDOWN
@dataclass
class ImageElement(KookCardModelBase): class ImageElement(KookCardModelBase):
src: str src: str
type: str = "image" type: Literal[KookModuleType.IMAGE] = KookModuleType.IMAGE
alt: str = "" alt: str = ""
size: SizeType = "lg" size: SizeType = "lg"
circle: bool = False circle: bool = False
fallbackUrl: str | None = None fallbackUrl: str | None = None
@dataclass
class ButtonElement(KookCardModelBase): class ButtonElement(KookCardModelBase):
text: str text: str
type: str = "button" type: Literal[KookModuleType.BUTTON] = KookModuleType.BUTTON
theme: ThemeType = "primary" theme: ThemeType = "primary"
value: str = "" value: str = ""
"""当为 link 时,会跳转到 value 代表的链接; """当为 link 时,会跳转到 value 代表的链接;
@@ -96,93 +153,88 @@ class ButtonElement(KookCardModelBase):
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
@dataclass
class ParagraphStructure(KookCardModelBase): class ParagraphStructure(KookCardModelBase):
fields: list[PlainTextElement | KmarkdownElement] fields: list[PlainTextElement | KmarkdownElement]
type: str = "paragraph" type: Literal["paragraph"] = "paragraph"
cols: int = 1 cols: int = 1
"""范围是 1-3 , 移动端忽略此参数""" """范围是 1-3 , 移动端忽略此参数"""
@dataclass
class HeaderModule(KookCardModelBase): class HeaderModule(KookCardModelBase):
text: PlainTextElement text: PlainTextElement
type: str = "header" type: Literal[KookModuleType.HEADER] = KookModuleType.HEADER
@dataclass
class SectionModule(KookCardModelBase): class SectionModule(KookCardModelBase):
text: PlainTextElement | KmarkdownElement | ParagraphStructure text: PlainTextElement | KmarkdownElement | ParagraphStructure
type: str = "section" type: Literal[KookModuleType.SECTION] = KookModuleType.SECTION
mode: SectionMode = "left" mode: SectionMode = "left"
accessory: ImageElement | ButtonElement | None = None accessory: ImageElement | ButtonElement | None = None
@dataclass
class ImageGroupModule(KookCardModelBase): class ImageGroupModule(KookCardModelBase):
"""1 到多张图片的组合""" """1 到多张图片的组合"""
elements: list[ImageElement] elements: list[ImageElement]
type: str = "image-group" type: Literal[KookModuleType.IMAGE_GROUP] = KookModuleType.IMAGE_GROUP
@dataclass
class ContainerModule(KookCardModelBase): class ContainerModule(KookCardModelBase):
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。""" """1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
elements: list[ImageElement] elements: list[ImageElement]
type: str = "container" type: Literal[KookModuleType.CONTAINER] = KookModuleType.CONTAINER
@dataclass
class ActionGroupModule(KookCardModelBase): class ActionGroupModule(KookCardModelBase):
"""用来放按钮的模块"""
elements: list[ButtonElement] elements: list[ButtonElement]
type: str = "action-group" type: Literal[KookModuleType.ACTION_GROUP] = KookModuleType.ACTION_GROUP
@dataclass
class ContextModule(KookCardModelBase): class ContextModule(KookCardModelBase):
elements: list[PlainTextElement | KmarkdownElement | ImageElement] elements: list[PlainTextElement | KmarkdownElement | ImageElement]
"""最多包含10个元素""" """最多包含10个元素"""
type: str = "context" type: Literal[KookModuleType.CONTEXT] = KookModuleType.CONTEXT
@dataclass
class DividerModule(KookCardModelBase): class DividerModule(KookCardModelBase):
type: str = "divider" """展示分割线用的"""
type: Literal[KookModuleType.DIVIDER] = KookModuleType.DIVIDER
@dataclass
class FileModule(KookCardModelBase): class FileModule(KookCardModelBase):
src: str src: str
title: str = "" title: str = ""
type: Literal["file", "audio", "video"] = "file" type: Literal[KookModuleType.FILE, KookModuleType.AUDIO, KookModuleType.VIDEO] = (
KookModuleType.FILE
)
cover: str | None = None cover: str | None = None
"""cover 仅音频有效, 是音频的封面图""" """cover 仅音频有效, 是音频的封面图"""
@dataclass
class CountdownModule(KookCardModelBase): class CountdownModule(KookCardModelBase):
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。""" """startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
endTime: int endTime: int
"""毫秒时间戳""" """毫秒时间戳"""
type: str = "countdown" type: Literal[KookModuleType.COUNTDOWN] = KookModuleType.COUNTDOWN
startTime: int | None = None startTime: int | None = None
"""毫秒时间戳, 仅当mode为second才有这个字段""" """毫秒时间戳, 仅当mode为second才有这个字段"""
mode: CountdownMode = "day" mode: CountdownMode = "day"
"""mode 主要是倒计时的样式""" """mode 主要是倒计时的样式"""
@dataclass
class InviteModule(KookCardModelBase): class InviteModule(KookCardModelBase):
code: str code: str
"""邀请链接或者邀请码""" """邀请链接或者邀请码"""
type: str = "invite" type: Literal[KookModuleType.INVITE] = KookModuleType.INVITE
# 所有模块的联合类型 # 所有模块的联合类型
AnyModule = ( AnyModule = Annotated[
HeaderModule HeaderModule
| SectionModule | SectionModule
| ImageGroupModule | ImageGroupModule
@@ -192,34 +244,29 @@ AnyModule = (
| DividerModule | DividerModule
| FileModule | FileModule
| CountdownModule | CountdownModule
| InviteModule | InviteModule,
) Field(discriminator="type"),
]
class KookCardMessage(BaseModel): class KookCardMessage(KookBaseDataClass):
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage """卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表** 此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
若要发送卡片消息,请使用KookCardMessageContainer 若要发送卡片消息,请使用KookCardMessageContainer
""" """
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
type: str = "card" type: Literal[KookModuleType.CARD] = KookModuleType.CARD
theme: ThemeType | None = None theme: ThemeType | None = None
size: SizeType | None = None size: SizeType | None = None
color: KookCardColor | None = None color: str | None = None
modules: list[AnyModule] = field(default_factory=list) """16 进制色值"""
modules: list[AnyModule] = Field(default_factory=list)
"""单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50""" """单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50"""
def add_module(self, module: AnyModule): def add_module(self, module: AnyModule):
self.modules.append(module) self.modules.append(module)
def to_dict(self, exclude_none: bool = True):
"""exclude_none:去掉值为 None 字段,保留结构"""
return self.model_dump(exclude_none=exclude_none)
def to_json(self, indent: int | None = None, ensure_ascii: bool = True):
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=ensure_ascii)
class KookCardMessageContainer(list[KookCardMessage]): class KookCardMessageContainer(list[KookCardMessage]):
"""卡片消息容器(列表),此类型可以直接to_json后发送出去""" """卡片消息容器(列表),此类型可以直接to_json后发送出去"""
@@ -232,10 +279,227 @@ class KookCardMessageContainer(list[KookCardMessage]):
[i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii [i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii
) )
@classmethod
def from_dict(cls, raw_data: list[dict[str, Any]]):
return cls(KookCardMessage.from_dict(item) for item in raw_data)
@dataclass
class OrderMessage: class OrderMessage(BaseModel):
index: int index: int
text: str text: str
type: KookMessageType type: KookMessageType
reply_id: str | int = "" reply_id: str | int = ""
class KookMessageSignal(IntEnum):
"""KOOK WebSocket 信令类型
ws文档: https://developer.kookapp.cn/doc/websocket""" # noqa: W291
MESSAGE = 0
"""server->client 消息(s包含聊天和通知消息)"""
HELLO = 1
"""server->client 客户端连接 ws 时, 服务端返回握手结果"""
PING = 2
"""client->server 心跳,ping"""
PONG = 3
"""server->client 心跳,pong"""
RESUME = 4
"""client->server resume, 恢复会话"""
RECONNECT = 5
"""server->client reconnect, 要求客户端断开当前连接重新连接"""
RESUME_ACK = 6
"""server->client resume ack"""
class KookChannelType(StrEnum):
GROUP = "GROUP"
PERSON = "PERSON"
BROADCAST = "BROADCAST"
class KookAuthor(KookBaseDataClass):
id: str
username: str
identify_num: str
nickname: str
bot: bool
online: bool
avatar: str | None = None
vip_avatar: str | None = None
status: int
roles: list[int] = Field(default_factory=list)
class KookKMarkdown(KookBaseDataClass):
raw_content: str
mention_part: list[Any] = Field(default_factory=list)
mention_role_part: list[Any] = Field(default_factory=list)
class KookExtra(KookBaseDataClass):
type: int | str
code: str | None = None
body: dict[str, Any] | None = None
author: KookAuthor | None = None
kmarkdown: KookKMarkdown | None = None
last_msg_content: str | None = None
mention: list[str] = Field(default_factory=list)
mention_all: bool = False
mention_here: bool = False
class KookMessageEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.MESSAGE] = Field(
KookMessageSignal.MESSAGE, exclude=True
)
"""only for type hint"""
channel_type: KookChannelType
type: KookMessageType
target_id: str
author_id: str
content: str | dict[str, Any]
msg_id: str
msg_timestamp: int
nonce: str
from_type: int
extra: KookExtra
class KookHelloEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.HELLO] = Field(
KookMessageSignal.HELLO, exclude=True
)
"""only for type hint"""
code: int
session_id: str
class KookPingEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.PING] = Field(
KookMessageSignal.PING, exclude=True
)
"""only for type hint"""
class KookPongEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.PONG] = Field(
KookMessageSignal.PONG, exclude=True
)
"""only for type hint"""
class KookResumeEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.RESUME] = Field(
KookMessageSignal.RESUME, exclude=True
)
"""only for type hint"""
class KookReconnectEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.RECONNECT] = Field(
KookMessageSignal.RECONNECT, exclude=True
)
"""only for type hint"""
code: int
err: str
class KookResumeAckEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.RESUME_ACK] = Field(
KookMessageSignal.RESUME_ACK, exclude=True
)
"""only for type hint"""
session_id: str
class KookWebsocketEvent(KookBaseDataClass):
"""KOOK WebSocket 原始推送结构"""
signal: KookMessageSignal = Field(
..., validation_alias="s", serialization_alias="s"
)
"""信令类型"""
data: Annotated[
KookMessageEventData
| KookHelloEventData
| KookPingEventData
| KookPongEventData
| KookResumeEventData
| KookReconnectEventData
| KookResumeAckEventData
| None,
Field(discriminator="signal"),
] = Field(None, validation_alias="d", serialization_alias="d")
"""数据事件主体,对应原字段是'd'"""
sn: int | None = None
"""消息序号 , 用来确定消息顺序和ws重连时使用
详见ws连接流程文档: https://developer.kookapp.cn/doc/websocket#%E8%BF%9E%E6%8E%A5%E6%B5%81%E7%A8%8B""" # noqa: W291
@model_validator(mode="before")
@classmethod
def _inject_signal_into_data(cls, data: Any) -> Any:
"""在解析前,把外层的 s 同步到内层的 d 中,供 discriminator 使用"""
if isinstance(data, dict):
s_value = data.get("s")
d_value = data.get("d")
if s_value is not None and isinstance(d_value, dict):
d_value["signal"] = s_value
return data
class KookUserTag(KookBaseDataClass):
color: str
bg_color: str
text: str
class KookApiResponseBase(KookBaseDataClass):
code: int
message: str
data: Any
def success(self) -> bool:
return self.code == 0
class KookUserMeData(KookBaseDataClass):
"""USER_ME 接口返回的 'data' 字段主体"""
id: str
username: str
identify_num: str
nickname: str
bot: bool
online: bool
status: int
bot_status: int
avatar: str
vip_avatar: str | None = None
banner: str | None = None
roles: list[Any] = Field(default_factory=list)
is_vip: bool
vip_amp: bool
wealth_level: int
mobile_verified: bool
client_id: str
tag_info: KookUserTag | None = None
class KookUserMeResponse(KookApiResponseBase):
"""USER_ME 完整响应结构"""
data: KookUserMeData
class KookGatewayIndexData(KookBaseDataClass):
url: str
class KookGatewayIndexResponse(KookApiResponseBase):
"""USER_ME 完整响应结构"""
data: KookGatewayIndexData
@@ -619,11 +619,6 @@
"type": "string", "type": "string",
"hint": "Required. The Bot Token obtained from the KOOK Developer Platform." "hint": "Required. The Bot Token obtained from the KOOK Developer Platform."
}, },
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "Optional. If the sender nickname matches this value, the message will be ignored to prevent broadcast storms."
},
"kook_reconnect_delay": { "kook_reconnect_delay": {
"description": "Reconnect Delay", "description": "Reconnect Delay",
"type": "int", "type": "int",
@@ -621,11 +621,6 @@
"type": "string", "type": "string",
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token" "hint": "必填项。从 KOOK 开发者平台获取的机器人 Token"
}, },
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息。"
},
"kook_reconnect_delay": { "kook_reconnect_delay": {
"description": "重连延迟", "description": "重连延迟",
"type": "int", "type": "int",
+28 -28
View File
@@ -4,97 +4,97 @@
"size": "lg", "size": "lg",
"modules": [ "modules": [
{ {
"type": "header",
"text": { "text": {
"content": "test1",
"type": "plain-text", "type": "plain-text",
"content": "test1",
"emoji": true "emoji": true
}, }
"type": "header"
}, },
{ {
"text": {
"content": "test2",
"type": "kmarkdown"
},
"type": "section", "type": "section",
"text": {
"type": "kmarkdown",
"content": "test2"
},
"mode": "left" "mode": "left"
}, },
{ {
"type": "divider" "type": "divider"
}, },
{ {
"type": "section",
"text": { "text": {
"type": "paragraph",
"fields": [ "fields": [
{ {
"content": "test3", "type": "kmarkdown",
"type": "kmarkdown" "content": "test3"
}, },
{ {
"content": "**test4**", "type": "kmarkdown",
"type": "kmarkdown" "content": "**test4**"
} }
], ],
"type": "paragraph",
"cols": 2 "cols": 2
}, },
"type": "section",
"mode": "left" "mode": "left"
}, },
{ {
"type": "image-group",
"elements": [ "elements": [
{ {
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
"type": "image", "type": "image",
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
"alt": "", "alt": "",
"size": "lg", "size": "lg",
"circle": false "circle": false
} }
], ]
"type": "image-group"
}, },
{ {
"type": "file",
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg", "src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
"title": "test5", "title": "test5"
"type": "file"
}, },
{ {
"endTime": 1772343427360,
"type": "countdown", "type": "countdown",
"endTime": 1772343427360,
"startTime": 1772343378259, "startTime": 1772343378259,
"mode": "second" "mode": "second"
}, },
{ {
"type": "action-group",
"elements": [ "elements": [
{ {
"text": "点我测试回调",
"type": "button", "type": "button",
"text": "点我测试回调",
"theme": "primary", "theme": "primary",
"value": "btn_clicked", "value": "btn_clicked",
"click": "return-val" "click": "return-val"
}, },
{ {
"text": "访问官网",
"type": "button", "type": "button",
"text": "访问官网",
"theme": "danger", "theme": "danger",
"value": "https://www.kookapp.cn", "value": "https://www.kookapp.cn",
"click": "link" "click": "link"
} }
], ]
"type": "action-group"
}, },
{ {
"type": "context",
"elements": [ "elements": [
{ {
"content": "test6",
"type": "plain-text", "type": "plain-text",
"content": "test6",
"emoji": true "emoji": true
} }
], ]
"type": "context"
}, },
{ {
"code": "test7", "type": "invite",
"type": "invite" "code": "test7"
} }
] ]
} }
@@ -0,0 +1,119 @@
{
"s": 0,
"d": {
"channel_type": "GROUP",
"type": 9,
"target_id": "2732467349811313213",
"author_id": "7324688132731983",
"content": "done!",
"extra": {
"quote": {
"id": "69a788adb0cfb9ece50eae1c",
"rong_id": "7baef72c-0cd7-49ad-9592-1615236136cb",
"type": 9,
"content": "/am 1",
"interact_res": null,
"create_at": 1772587180973,
"author": {
"id": "2701973210937821093781",
"username": "some_username",
"identify_num": "4198",
"online": true,
"os": "Websocket",
"status": 1,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"banner": "",
"nickname": "some_username",
"roles": [
63724577
],
"is_vip": false,
"vip_amp": false,
"bot": false,
"nameplate": [],
"kpm_vip": null,
"wealth_level": 0,
"decorations_id_map": null,
"mobile_verified": true,
"is_sys": false,
"joined_at": 1772259607000,
"active_time": 1772587181304
},
"can_jump": true,
"preview_content": null,
"kmarkdown": {
"mention_part": [],
"mention_role_part": [],
"channel_part": [],
"item_part": []
}
},
"type": 9,
"code": "",
"guild_id": "273902183210983210983",
"guild_type": 0,
"channel_name": "聊天大厅",
"author": {
"id": "7324688132731983",
"username": "Bot_Test",
"identify_num": "9561",
"online": true,
"os": "Websocket",
"status": 0,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"banner": "",
"nickname": "Bot_Test",
"roles": [
63725384
],
"is_vip": false,
"vip_amp": false,
"bot": true,
"nameplate": [],
"kpm_vip": null,
"wealth_level": 0,
"bot_status": 0,
"tag_info": {
"color": "#0096FF",
"bg_color": "#0096FF33",
"text": "机器人"
},
"is_sys": false,
"client_id": "sAdiIHoGhdSFUOA",
"verified": false
},
"visible_only": "",
"mention": [],
"mention_no_at": [],
"mention_all": false,
"mention_roles": [],
"mention_here": false,
"nav_channels": [],
"kmarkdown": {
"raw_content": "done!",
"mention_part": [],
"mention_role_part": [],
"channel_part": [],
"spl": []
},
"emoji": [],
"preview_content": "",
"channel_type": 1,
"last_msg_content": "Bot_Testdone!",
"send_msg_device": 0
},
"msg_id": "c51a8761-63bv-5l2a-5681-0ac16e140a1b",
"msg_timestamp": 1772587182234,
"nonce": "",
"from_type": 1
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 3
}
@@ -0,0 +1,8 @@
{
"s": 1,
"d": {
"sessionId": "67d7d497-2b10-4849-9c2c-dda2fe58ed60",
"session_id": "67d7d497-2b10-4849-9c2c-dda2fe58ed60",
"code": 0
}
}
@@ -0,0 +1,72 @@
{
"s": 0,
"d": {
"channel_type": "PERSON",
"type": 10,
"target_id": "2732467349811313213",
"author_id": "7324688132731983",
"content": "[{\"theme\":\"primary\",\"color\":\"\",\"size\":\"lg\",\"expand\":false,\"modules\":[{\"type\":\"audio\",\"cover\":\"\",\"duration\":0,\"title\":\"dancing_shot5.wav\",\"src\":\"https:\\/\\/img.kookapp.cn\\/attachments\\/2026-03\\/03\\/69a6841c3125d.wav\",\"external\":false,\"size\":443414,\"canDownload\":true,\"elements\":[]}],\"type\":\"card\"}]",
"extra": {
"type": 10,
"code": "1738914789hd8fd91098he809h19y491",
"author": {
"id": "7324688132731983",
"username": "Bot_Test",
"identify_num": "9561",
"online": true,
"os": "Websocket",
"status": 0,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"banner": "",
"nickname": "Bot_Test",
"roles": [],
"is_vip": false,
"vip_amp": false,
"bot": true,
"nameplate": [],
"kpm_vip": null,
"wealth_level": 0,
"bot_status": 0,
"tag_info": {
"color": "#0096FF",
"bg_color": "#0096FF33",
"text": "机器人"
},
"is_sys": false,
"client_id": "u109u3108h8ds0qsdaHUIOS",
"verified": false
},
"visible_only": "",
"mention": [],
"mention_no_at": [],
"mention_all": false,
"mention_roles": [],
"mention_here": false,
"nav_channels": [],
"emoji": [],
"kmarkdown": {
"raw_content": "[音频]dancing_shot5.wav",
"mention_part": [],
"mention_role_part": [],
"channel_part": []
},
"editable": false,
"preview_content": "[音频]dancing_shot5.wav",
"preview_content_search": "[音频]dancing_shot5.wav",
"last_msg_content": "[音频]dancing_shot5.wav",
"send_msg_device": 0
},
"msg_id": "82c0b042-79b4-4066-a0f4-6c7a95c74e67",
"msg_timestamp": 1772587223043,
"nonce": "",
"from_type": 1
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 5
}
@@ -0,0 +1,79 @@
{
"s": 0,
"d": {
"channel_type": "GROUP",
"type": 10,
"target_id": "2723723449021809",
"author_id": "1237198731983",
"content": "[{\"theme\":\"invisible\",\"color\":\"\",\"size\":\"lg\",\"expand\":false,\"modules\":[{\"type\":\"section\",\"mode\":\"left\",\"accessory\":null,\"text\":{\"type\":\"kmarkdown\",\"content\":\"(met)(met) (met)all(met) #hello \\\\*\\\\*world\\\\*\\\\* \",\"elements\":[]},\"elements\":[]},{\"type\":\"audio\",\"cover\":\"\",\"duration\":0,\"title\":\"dancing_shot5.wav\",\"src\":\"https:\\/\\/img.kookapp.cn\\/attachments\\/2026-03\\/03\\/69a6841c3125d.wav\",\"external\":false,\"size\":443414,\"canDownload\":true,\"elements\":[]},{\"type\":\"section\",\"mode\":\"left\",\"accessory\":null,\"text\":{\"type\":\"kmarkdown\",\"content\":\"\\n😆 \",\"elements\":[]},\"elements\":[]}],\"type\":\"card\"}]",
"msg_id": "ec4046e9-ea43-4907-9fc3-8c6d0bd4ec56",
"msg_timestamp": 1772600762056,
"nonce": "sy8f91y248yda",
"from_type": 1,
"extra": {
"type": 10,
"code": "",
"author": {
"id": "1237198731983",
"username": "some_username",
"identify_num": "4198",
"nickname": "some_username",
"bot": false,
"online": true,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"status": 1,
"roles": [
12783219731984
],
"os": "Websocket",
"banner": "",
"is_vip": false,
"vip_amp": false,
"nameplate": [],
"wealth_level": 0,
"is_sys": false
},
"kmarkdown": {
"raw_content": "@Bot_Test @全体成员 #hello **world**[音频]dancing_shot5.wav😆",
"mention_part": [
{
"id": "",
"username": "Bot_Test",
"full_name": "Bot_Test#9561",
"avatar": "https://example.com",
"wealth_level": 0
}
],
"mention_role_part": [],
"channel_part": []
},
"last_msg_content": "some_username@Bot_Test @ 全体成员 #hello **world**[音频]dancing_shot5.wav😆",
"mention": [
""
],
"mention_all": true,
"mention_here": false,
"guild_id": "28321098321093",
"guild_type": 0,
"channel_name": "聊天大厅",
"visible_only": "",
"mention_no_at": [],
"mention_roles": [],
"nav_channels": [],
"emoji": [],
"editable": true,
"preview_content": "@Bot_Test @全体成员 #hello **world**[音频]dancing_shot5.wav😆",
"preview_content_search": "@Bot_Test @全体成员 #hello **world**[音频]dancing_shot5.wav😆",
"channel_type": 1,
"send_msg_device": 0
}
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 5
}
@@ -0,0 +1,4 @@
{
"s": 2,
"sn": 0
}
@@ -0,0 +1,3 @@
{
"s": 3
}
@@ -0,0 +1,64 @@
{
"s": 0,
"d": {
"channel_type": "PERSON",
"type": 9,
"target_id": "7324688132731983",
"author_id": "2732467349811313213",
"content": "/help",
"extra": {
"type": 9,
"code": "1738914789hd8fd91098he809h19y491",
"author": {
"id": "2732467349811313213",
"username": "shuiping233",
"identify_num": "4198",
"online": true,
"os": "Websocket",
"status": 1,
"avatar": "https://example.com",
"vip_avatar": "https://example.com",
"banner": "",
"nickname": "shuiping233",
"roles": [],
"is_vip": false,
"vip_amp": false,
"bot": false,
"nameplate": [],
"kpm_vip": null,
"wealth_level": 0,
"decorations_id_map": null,
"is_sys": false
},
"visible_only": "",
"mention": [],
"mention_no_at": [],
"mention_all": false,
"mention_roles": [],
"mention_here": false,
"nav_channels": [],
"kmarkdown": {
"raw_content": "/help",
"mention_part": [],
"mention_role_part": [],
"channel_part": [],
"spl": []
},
"emoji": [],
"preview_content": "",
"last_msg_content": "/help",
"send_msg_device": 0
},
"msg_id": "b0f57b9e-2cd4-4e07-8f0e-9c1ecfeaa837",
"msg_timestamp": 1772587358662,
"nonce": "6AwzUe5YjgyC8pAfxcLGjewL",
"from_type": 1
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 19
}
@@ -0,0 +1,31 @@
{
"s": 0,
"d": {
"channel_type": "PERSON",
"type": 255,
"target_id": "7324688132731983",
"author_id": "1",
"content": "[系统消息]",
"extra": {
"type": "guild_member_offline",
"body": {
"user_id": "2732467349811313213",
"event_time": 1772589748914,
"guilds": [
"78941897317309873120973"
]
}
},
"msg_id": "e91b4451-75ce-47bd-bda6-e4498ed8d30d",
"msg_timestamp": 1772589748933,
"nonce": "",
"from_type": 1
},
"extra": {
"verifyToken": "kW4FH_ASHio1hosd",
"encryptKey": "",
"callbackUrl": "",
"intent": 255
},
"sn": 1
}
@@ -0,0 +1,7 @@
{
"s": 5,
"d": {
"code": 40108,
"err": "Invalid SN"
}
}
@@ -0,0 +1,4 @@
{
"s": 4,
"sn": 100
}
@@ -0,0 +1,6 @@
{
"s": 6,
"d": {
"session_id": "xxxx-xxxxxx-xxx-xxx"
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
TEST_DATA_DIR = Path(__file__).parent / "data" CURRENT_DIR = Path(__file__).parent
TEST_DATA_DIR = CURRENT_DIR / "data"
+12 -47
View File
@@ -60,7 +60,7 @@ def mock_astrbot_message():
Image("test image"), Image("test image"),
"test image", "test image",
OrderMessage( OrderMessage(
1, index=1,
text="test image", text="test image",
type=KookMessageType.IMAGE, type=KookMessageType.IMAGE,
), ),
@@ -70,7 +70,7 @@ def mock_astrbot_message():
Video("test video"), Video("test video"),
"test video", "test video",
OrderMessage( OrderMessage(
1, index=1,
text="test video", text="test video",
type=KookMessageType.VIDEO, type=KookMessageType.VIDEO,
), ),
@@ -80,7 +80,7 @@ def mock_astrbot_message():
mock_file_message("test file"), mock_file_message("test file"),
"test file", "test file",
OrderMessage( OrderMessage(
1, index=1,
text="test file", text="test file",
type=KookMessageType.FILE, type=KookMessageType.FILE,
), ),
@@ -90,8 +90,8 @@ def mock_astrbot_message():
mock_record_message("./tests/file.wav"), mock_record_message("./tests/file.wav"),
"./tests/file.wav", "./tests/file.wav",
OrderMessage( OrderMessage(
1, index=1,
text='[{"type": "card", "modules": [{"src": "./tests/file.wav", "title": "./tests/file.wav", "type": "audio"}]}]', text='[{"type": "card", "modules": [{"type": "audio", "src": "./tests/file.wav", "title": "./tests/file.wav"}]}]',
type=KookMessageType.CARD, type=KookMessageType.CARD,
), ),
None, None,
@@ -100,7 +100,7 @@ def mock_astrbot_message():
Plain("test plain"), Plain("test plain"),
"test plain", "test plain",
OrderMessage( OrderMessage(
1, index=1,
text="test plain", text="test plain",
type=KookMessageType.KMARKDOWN, type=KookMessageType.KMARKDOWN,
), ),
@@ -110,7 +110,7 @@ def mock_astrbot_message():
At(qq="test at"), At(qq="test at"),
"test at", "test at",
OrderMessage( OrderMessage(
1, index=1,
text="(met)test at(met)", text="(met)test at(met)",
type=KookMessageType.KMARKDOWN, type=KookMessageType.KMARKDOWN,
), ),
@@ -120,7 +120,7 @@ def mock_astrbot_message():
AtAll(qq="all"), AtAll(qq="all"),
"test atAll", "test atAll",
OrderMessage( OrderMessage(
1, index=1,
text="(met)all(met)", text="(met)all(met)",
type=KookMessageType.KMARKDOWN, type=KookMessageType.KMARKDOWN,
), ),
@@ -130,7 +130,7 @@ def mock_astrbot_message():
Reply(id="test reply"), Reply(id="test reply"),
"test reply", "test reply",
OrderMessage( OrderMessage(
1, index=1,
text="", text="",
type=KookMessageType.KMARKDOWN, type=KookMessageType.KMARKDOWN,
reply_id="test reply", reply_id="test reply",
@@ -141,7 +141,7 @@ def mock_astrbot_message():
Json(data={"test": "json"}), Json(data={"test": "json"}),
"test json", "test json",
OrderMessage( OrderMessage(
1, index=1,
text='[{"test": "json"}]', text='[{"test": "json"}]',
type=KookMessageType.CARD, type=KookMessageType.CARD,
), ),
@@ -159,7 +159,7 @@ async def test_kook_event_warp_message(
input_message: BaseMessageComponent, input_message: BaseMessageComponent,
upload_asset_return: str, upload_asset_return: str,
expected_output: OrderMessage, expected_output: OrderMessage,
expected_error: type[Exception] | None, expected_error: type[BaseException] | None,
): ):
client = await mock_kook_client( client = await mock_kook_client(
upload_asset_return, upload_asset_return,
@@ -185,39 +185,4 @@ async def test_kook_event_warp_message(
result = await event._wrap_message(1, input_message) result = await event._wrap_message(1, input_message)
assert result == expected_output assert result == expected_output
# @pytest.mark.asyncio
# @pytest.mark.parametrize(
# "message_chain,send_text_expected_output,expected_error",
# [
# (
# MessageChain(
# chain=[
# Image(file="test image"),
# Plain(text="test plain"),
# ],
# ),
# ""
# ),
# ],
# )
# async def test_kook_event_send():
# client = await mock_kook_client(
# "",
# "",
# )
# event = KookEvent(
# "",
# mock_astrbot_message(),
# PlatformMetadata(
# name="test",
# id="test",
# description="test",
# ),
# "",
# client,
# )
# await event.send(message=mock_astrbot_message())
+42 -1
View File
@@ -16,6 +16,9 @@ from astrbot.core.platform.sources.kook.kook_types import (
InviteModule, InviteModule,
KmarkdownElement, KmarkdownElement,
KookCardMessage, KookCardMessage,
KookMessageSignal,
KookModuleType,
KookWebsocketEvent,
ParagraphStructure, ParagraphStructure,
PlainTextElement, PlainTextElement,
SectionModule, SectionModule,
@@ -77,7 +80,7 @@ def test_all_kook_card_type():
FileModule( FileModule(
src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg", src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
title="test5", title="test5",
type="file", type=KookModuleType.FILE,
), ),
CountdownModule( CountdownModule(
endTime=1772343427360, endTime=1772343427360,
@@ -105,3 +108,41 @@ def test_all_kook_card_type():
], ],
).to_json(indent=4, ensure_ascii=False) ).to_json(indent=4, ensure_ascii=False)
assert json_output == expect_json_data assert json_output == expect_json_data
@pytest.mark.parametrize(
"expected_json_data_filename",
[
("kook_ws_event_group_message.json"),
("kook_ws_event_hello.json"),
("kook_ws_event_message_with_card_1.json"),
("kook_ws_event_message_with_card_2.json"),
("kook_ws_event_ping.json"),
("kook_ws_event_pong.json"),
("kook_ws_event_private_message.json"),
("kook_ws_event_private_system_message.json"),
("kook_ws_event_reconnect_err.json"),
("kook_ws_event_resume_ack.json"),
("kook_ws_event_resume.json"),
],
)
def test_websocket_event_type_parse(expected_json_data_filename:str):
expected_json_data_str =(TEST_DATA_DIR / expected_json_data_filename).read_text(encoding="utf-8")
event = KookWebsocketEvent.from_json(
expected_json_data_str,
)
event_dict = event.to_dict(mode="json",exclude_unset=True,exclude_none=False)
assert event_dict == json.loads(expected_json_data_str)
def test_websocket_event_create():
ping_data = KookWebsocketEvent(
signal=KookMessageSignal.PING,
data=None,
sn=0,
)
assert ping_data.to_dict(mode="json")== {
"s": KookMessageSignal.PING.value,
"sn": 0,
}