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",
"enable": False,
"kook_bot_token": "",
"kook_bot_nickname": "",
"kook_reconnect_delay": 1,
"kook_max_reconnect_delay": 60,
"kook_max_retry_delay": 60,
@@ -875,11 +874,6 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
},
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息以避免广播风暴。",
},
"kook_reconnect_delay": {
"description": "重连延迟",
"type": "int",
@@ -13,11 +13,28 @@ from astrbot.api.platform import (
PlatformMetadata,
register_platform_adapter,
)
from astrbot.core.message.components import File, Record, Video
from astrbot.core.platform.astr_message_event import MessageSesion
from .kook_client import KookClient
from .kook_config import KookConfig
from .kook_event import KookEvent
from .kook_types import (
ContainerModule,
FileModule,
HeaderModule,
ImageGroupModule,
KmarkdownElement,
KookCardMessageContainer,
KookChannelType,
KookMessageEventData,
KookMessageType,
KookModuleType,
PlainTextElement,
SectionModule,
)
KOOK_AT_SELECTOR_REGEX = re.compile(r"\(met\)([^()]+)\(met\)")
@register_platform_adapter(
@@ -57,35 +74,26 @@ class KookPlatformAdapter(Platform):
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
def _should_ignore_event_by_bot_nickname(self, author_id: str) -> bool:
return self.client.bot_id == author_id
author = payload.get("extra", {}).get("author", {})
if not isinstance(author, dict):
return False
author_nickname = author.get("nickname") or author.get("username") or ""
if not isinstance(author_nickname, str):
author_nickname = str(author_nickname)
return author_nickname.strip().casefold() == bot_nickname.casefold()
async def _on_received(self, data: dict):
logger.debug(f"KOOK 收到数据: {data}")
if "d" in data and data["s"] == 0:
payload = data["d"]
event_type = payload.get("type")
# 支持type=9(文本)和type=10(卡片)
if event_type in (9, 10):
if self._should_ignore_event_by_bot_nickname(payload):
return
try:
abm = await self.convert_message(payload)
await self.handle_msg(abm)
except Exception as e:
logger.error(f"[KOOK] 消息处理异常: {e}")
async def _on_received(self, event: KookMessageEventData):
logger.debug(
f'[KOOK] 收到来自"{event.channel_type.name}"渠道的消息, 消息类型为: {event.type.name}({event.type.value})'
)
event_type = event.type
if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD):
if self._should_ignore_event_by_bot_nickname(event.author_id):
logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息")
return
try:
abm = await self.convert_message(event)
await self.handle_msg(abm)
except Exception as e:
logger.error(f"[KOOK] 消息处理异常: {e}")
elif event_type == KookMessageType.SYSTEM:
logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"')
logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}")
async def run(self):
"""主运行循环"""
@@ -184,18 +192,26 @@ class KookPlatformAdapter(Platform):
logger.info("[KOOK] 资源清理完成")
def _parse_kmarkdown_text_message(
self, data: dict, self_id: str
self, data: KookMessageEventData, 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
kmarkdown = data.extra.kmarkdown
content = data.content or ""
if kmarkdown is None:
logger.error(
f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段'
)
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
return [], ""
raw_content = kmarkdown.raw_content or content
if not isinstance(content, str):
content = str(content)
if not isinstance(raw_content, str):
raw_content = str(raw_content)
# TODO 后面的pydantic类型替换,以后再来探索吧 :(
mention_name_map: dict[str, str] = {}
mention_part = kmarkdown.get("mention_part", [])
mention_part = kmarkdown.mention_part
if isinstance(mention_part, list):
for item in mention_part:
if not isinstance(item, dict):
@@ -207,7 +223,7 @@ class KookPlatformAdapter(Platform):
components = []
cursor = 0
for match in re.finditer(r"\(met\)([^()]+)\(met\)", content):
for match in KOOK_AT_SELECTOR_REGEX.finditer(content):
if match.start() > cursor:
plain_text = content[cursor : match.start()]
if plain_text:
@@ -254,77 +270,109 @@ class KookPlatformAdapter(Platform):
return components, message_str
def _parse_card_message(self, data: dict) -> tuple[list, str]:
content = data.get("content", "[]")
def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]:
content = data.content
if not isinstance(content, str):
content = str(content)
card_list = json.loads(content)
card_list = KookCardMessageContainer.from_dict(json.loads(content))
text_parts: list[str] = []
images: list[str] = []
files: list[tuple[KookModuleType, str, str]] = []
for card in card_list:
if not isinstance(card, dict):
continue
for module in card.get("modules", []):
if not isinstance(module, dict):
continue
for module in card.modules:
match module:
case SectionModule():
if content := self._handle_section_text(module):
text_parts.append(content)
module_type = module.get("type")
if module_type == "section":
section_text = module.get("text", {}).get("content", "")
if section_text:
text_parts.append(str(section_text))
continue
case ContainerModule() | ImageGroupModule():
urls = self._handle_image_group(module)
images.extend(urls)
text_parts.append(" [image]" * len(urls))
if module_type != "container":
continue
case HeaderModule():
text_parts.append(module.text.content)
for element in module.get("elements", []):
if not isinstance(element, dict):
continue
if element.get("type") != "image":
continue
case FileModule():
files.append((module.type, module.title, module.src))
text_parts.append(f" [{module.type.value}]")
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)
case _:
logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}")
text = "".join(text_parts)
message = []
if text:
for search in KOOK_AT_SELECTOR_REGEX.finditer(text):
search_text = search.group(1).strip()
if search_text == "all":
message.append(AtAll())
continue
message.append(At(qq=search_text))
text = text.replace(f"(met){search_text}(met)", "")
message.append(Plain(text=text))
for img_url in images:
message.append(Image(file=img_url))
for file in files:
file_type = file[0]
file_name = file[1]
file_url = file[2]
if file_type == KookModuleType.FILE:
message.append(File(name=file_name, file=file_url))
elif file_type == KookModuleType.VIDEO:
message.append(Video(file=file_url))
elif file_type == KookModuleType.AUDIO:
message.append(Record(file=file_url))
else:
logger.warning(f"[KOOK] 跳过未知文件类型: {file_type.name}")
return message, text
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.raw_message = data
abm.raw_message = data.to_dict()
abm.self_id = self.client.bot_id
channel_type = data.get("channel_type")
author_id = data.get("author_id", "unknown")
channel_type = data.channel_type
author_id = data.author_id
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
match channel_type:
case "GROUP":
session_id = data.get("target_id") or "unknown"
case KookChannelType.GROUP:
session_id = data.target_id or "unknown"
abm.type = MessageType.GROUP_MESSAGE
abm.group_id = session_id
abm.session_id = session_id
case "PERSON":
case KookChannelType.PERSON:
abm.type = MessageType.FRIEND_MESSAGE
abm.group_id = ""
abm.session_id = data.get("author_id", "unknown")
case "BROADCAST":
session_id = data.get("target_id") or "unknown"
abm.session_id = data.author_id or "unknown"
case KookChannelType.BROADCAST:
session_id = data.target_id or "unknown"
abm.type = MessageType.OTHER_MESSAGE
abm.group_id = session_id
abm.session_id = session_id
@@ -333,28 +381,25 @@ class KookPlatformAdapter(Platform):
abm.sender = MessageMember(
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.get("type") == 9:
message, message_str = self._parse_kmarkdown_text_message(
data, str(abm.self_id)
)
if data.type == KookMessageType.KMARKDOWN:
message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id)
abm.message = message
abm.message_str = message_str
# 卡片消息
elif data.get("type") == 10:
elif data.type == KookMessageType.CARD:
try:
abm.message, abm.message_str = self._parse_card_message(data)
except Exception as exp:
logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
abm.message_str = "[卡片消息解析失败]"
abm.message = [Plain(text="[卡片消息解析失败]")]
else:
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.get("type")}"')
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.type.name}"')
abm.message_str = "[不支持的消息类型]"
abm.message = [Plain(text="[不支持的消息类型]")]
+103 -56
View File
@@ -1,6 +1,5 @@
import asyncio
import base64
import json
import os
import random
import time
@@ -9,13 +8,23 @@ from pathlib import Path
import aiofiles
import aiohttp
import pydantic
import websockets
from astrbot import logger
from astrbot.core.platform.message_type import MessageType
from .kook_config import KookConfig
from .kook_types import KookApiPaths, KookMessageType
from .kook_types import (
KookApiPaths,
KookGatewayIndexResponse,
KookHelloEventData,
KookMessageSignal,
KookMessageType,
KookResumeAckEventData,
KookUserMeResponse,
KookWebsocketEvent,
)
class KookClient:
@@ -23,7 +32,8 @@ class KookClient:
# 数据字段
self.config = config
self._bot_id = ""
self._bot_name = ""
self._bot_username = ""
self._bot_nickname = ""
# 资源字段
self._http_client = aiohttp.ClientSession(
@@ -48,37 +58,50 @@ class KookClient:
return self._bot_id
@property
def bot_name(self):
return self._bot_name
def bot_nickname(self):
return self._bot_nickname
async def get_bot_info(self) -> str:
"""获取机器人账号ID"""
@property
def bot_username(self):
return self._bot_username
async def get_bot_info(self) -> None:
"""获取机器人账号信息"""
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 ""
logger.error(
f"[KOOK] 获取机器人账号信息失败,状态码: {resp.status} , {await resp.text()}"
)
return
try:
resp_content = KookUserMeResponse.from_dict(await resp.json())
except pydantic.ValidationError as e:
logger.error(
f"[KOOK] 获取机器人账号信息失败, 响应数据格式错误: \n{e}"
)
logger.error(f"[KOOK] 响应内容: {await resp.text()}")
return
data = await resp.json()
if data.get("code") != 0:
logger.error(f"[KOOK] 获取机器人账号ID失败: {data}")
return ""
if not resp_content.success():
logger.error(
f"[KOOK] 获取机器人账号信息失败: {resp_content.model_dump_json()}"
)
return
bot_id: str = data["data"]["id"]
bot_id: str = resp_content.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}")
self._bot_nickname = resp_content.data.nickname
self._bot_username = resp_content.data.username
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_nickname}")
return bot_id
except Exception as e:
logger.error(f"[KOOK] 获取机器人账号ID异常: {e}")
return ""
logger.error(f"[KOOK] 获取机器人账号信息异常: {e}")
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
@@ -96,14 +119,20 @@ class KookClient:
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
return None
data = await resp.json()
if data.get("code") != 0:
logger.error(f"[KOOK] 获取gateway失败: {data}")
resp_content = KookGatewayIndexResponse.from_dict(await resp.json())
if not resp_content.success():
logger.error(f"[KOOK] 获取gateway失败: {resp_content}")
return None
gateway_url: str = data["data"]["url"]
gateway_url: str = resp_content.data.url
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
return gateway_url
except pydantic.ValidationError as e:
logger.error(f"[KOOK] 获取gateway失败, 响应数据格式错误: \n{e}")
logger.error(f"[KOOK] 原始响应内容: {await resp.text()}")
return None
except Exception as e:
logger.error(f"[KOOK] 获取gateway异常: {e}")
return None
@@ -156,7 +185,11 @@ class KookClient:
try:
while self.running:
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):
try:
@@ -166,10 +199,15 @@ class KookClient:
continue
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:
# 超时检查,继续循环
@@ -187,38 +225,41 @@ class KookClient:
self.running = False
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: # 事件消息
# 更新消息序号
if "sn" in data:
self.last_sn = data["sn"]
await self.event_callback(data)
match event.signal:
case KookMessageSignal.MESSAGE:
if event.sn is not None:
self.last_sn = event.sn
await self.event_callback(data)
elif signal_type == 1: # HELLO握手
await self._handle_hello(data)
case KookMessageSignal.HELLO:
assert isinstance(data, KookHelloEventData)
await self._handle_hello(data)
elif signal_type == 3: # PONG心跳响应
await self._handle_pong(data)
case KookMessageSignal.RESUME_ACK:
assert isinstance(data, KookResumeAckEventData)
await self._handle_resume_ack(data)
elif signal_type == 5: # RECONNECT重连指令
await self._handle_reconnect(data)
case KookMessageSignal.PONG:
await self._handle_pong()
elif signal_type == 6: # RESUME ACK
await self._handle_resume_ack(data)
case KookMessageSignal.RECONNECT:
await self._handle_reconnect()
else:
logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}")
case _:
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_data = data.get("d", {})
code = hello_data.get("code", 0)
code = data.code
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}")
# TODO 重置重连延迟
# self.reconnect_delay = 1
@@ -228,12 +269,12 @@ class KookClient:
logger.error("[KOOK] Token已过期,需要重新获取")
self.running = False
async def _handle_pong(self, data):
async def _handle_pong(self):
"""处理PONG心跳响应"""
self.last_heartbeat_time = time.time()
self.heartbeat_failed_count = 0
async def _handle_reconnect(self, data):
async def _handle_reconnect(self):
"""处理重连指令"""
logger.warning("[KOOK] 收到重连指令")
# 清空本地状态
@@ -241,10 +282,9 @@ class KookClient:
self.session_id = None
self.running = False
async def _handle_resume_ack(self, data):
async def _handle_resume_ack(self, data: KookResumeAckEventData):
"""处理RESUME确认"""
resume_data = data.get("d", {})
self.session_id = resume_data.get("session_id")
self.session_id = data.session_id
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
async def _heartbeat_loop(self):
@@ -292,9 +332,16 @@ class KookClient:
async def _send_ping(self):
"""发送心跳PING"""
if self.ws is None:
logger.warning("[KOOK] 尚未连接kook WebSocket服务器, 跳过发送心跳包流程")
return
try:
ping_data = {"s": 2, "sn": self.last_sn}
await self.ws.send(json.dumps(ping_data)) # type: ignore
ping_data = KookWebsocketEvent(
signal=KookMessageSignal.PING,
data=None,
sn=self.last_sn,
)
await self.ws.send(ping_data.to_json())
except Exception as e:
logger.error(f"[KOOK] 发送心跳失败: {e}")
@@ -9,7 +9,6 @@ class KookConfig:
# 基础配置
token: str
bot_nickname: str = ""
enable: bool = False
id: str = "kook"
@@ -41,7 +40,6 @@ class KookConfig:
# id=config_dict.get("id", "kook"),
enable=config_dict.get("enable", False),
token=config_dict.get("kook_bot_token", ""),
bot_nickname=config_dict.get("kook_bot_nickname", ""),
reconnect_delay=config_dict.get(
"kook_reconnect_delay",
KookConfig.reconnect_delay,
@@ -27,6 +27,7 @@ from .kook_types import (
KookCardMessage,
KookCardMessageContainer,
KookMessageType,
KookModuleType,
OrderMessage,
)
@@ -111,7 +112,7 @@ class KookEvent(AstrMessageEvent):
KookCardMessage(
modules=[
FileModule(
type="audio",
type=KookModuleType.AUDIO,
title=title,
src=url,
)
@@ -182,7 +183,7 @@ class KookEvent(AstrMessageEvent):
if item.reply_id:
reply_id = item.reply_id
if not item.text:
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"')
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type.name}"')
continue
try:
await self.client.send_text(
+319 -55
View File
@@ -1,10 +1,8 @@
import json
from dataclasses import field
from enum import IntEnum
from typing import Literal
from enum import IntEnum, StrEnum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict
from pydantic.dataclasses import dataclass
from pydantic import BaseModel, ConfigDict, Field, model_validator
class KookApiPaths:
@@ -25,8 +23,9 @@ class KookApiPaths:
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
# 定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction
class KookMessageType(IntEnum):
"""定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction"""
TEXT = 1
IMAGE = 2
VIDEO = 3
@@ -37,6 +36,26 @@ class KookMessageType(IntEnum):
SYSTEM = 255
class KookModuleType(StrEnum):
PLAIN_TEXT = "plain-text"
KMARKDOWN = "kmarkdown"
IMAGE = "image"
BUTTON = "button"
HEADER = "header"
SECTION = "section"
IMAGE_GROUP = "image-group"
CONTAINER = "container"
ACTION_GROUP = "action-group"
CONTEXT = "context"
DIVIDER = "divider"
FILE = "file"
AUDIO = "audio"
VIDEO = "video"
COUNTDOWN = "countdown"
INVITE = "invite"
CARD = "card"
ThemeType = Literal[
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
]
@@ -48,43 +67,81 @@ SectionMode = Literal["left", "right"]
CountdownMode = Literal["day", "hour", "second"]
class KookCardColor(str):
"""16 进制色值"""
class KookBaseDataClass(BaseModel):
model_config = ConfigDict(
extra="allow",
arbitrary_types_allowed=True,
populate_by_name=True,
)
@classmethod
def from_dict(cls, raw_data: dict):
return cls.model_validate(raw_data)
@classmethod
def from_json(cls, raw_data: str | bytes | bytearray):
return cls.model_validate_json(raw_data)
def to_dict(
self,
mode: Literal["json", "python"] | str = "python",
by_alias=True,
exclude_none=True,
exclude_unset=False,
) -> dict:
return self.model_dump(
by_alias=by_alias,
exclude_none=exclude_none,
mode=mode,
exclude_unset=exclude_unset,
)
def to_json(
self,
indent: int | None = None,
ensure_ascii=False,
by_alias=True,
exclude_none=True,
exclude_unset=False,
) -> str:
return self.model_dump_json(
indent=indent,
ensure_ascii=ensure_ascii,
by_alias=by_alias,
exclude_none=exclude_none,
exclude_unset=exclude_unset,
)
class KookCardModelBase:
class KookCardModelBase(KookBaseDataClass):
"""卡片模块基类"""
type: str
@dataclass
class PlainTextElement(KookCardModelBase):
content: str
type: str = "plain-text"
type: Literal[KookModuleType.PLAIN_TEXT] = KookModuleType.PLAIN_TEXT
emoji: bool = True
@dataclass
class KmarkdownElement(KookCardModelBase):
content: str
type: str = "kmarkdown"
type: Literal[KookModuleType.KMARKDOWN] = KookModuleType.KMARKDOWN
@dataclass
class ImageElement(KookCardModelBase):
src: str
type: str = "image"
type: Literal[KookModuleType.IMAGE] = KookModuleType.IMAGE
alt: str = ""
size: SizeType = "lg"
circle: bool = False
fallbackUrl: str | None = None
@dataclass
class ButtonElement(KookCardModelBase):
text: str
type: str = "button"
type: Literal[KookModuleType.BUTTON] = KookModuleType.BUTTON
theme: ThemeType = "primary"
value: str = ""
"""当为 link 时,会跳转到 value 代表的链接;
@@ -96,93 +153,88 @@ class ButtonElement(KookCardModelBase):
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
@dataclass
class ParagraphStructure(KookCardModelBase):
fields: list[PlainTextElement | KmarkdownElement]
type: str = "paragraph"
type: Literal["paragraph"] = "paragraph"
cols: int = 1
"""范围是 1-3 , 移动端忽略此参数"""
@dataclass
class HeaderModule(KookCardModelBase):
text: PlainTextElement
type: str = "header"
type: Literal[KookModuleType.HEADER] = KookModuleType.HEADER
@dataclass
class SectionModule(KookCardModelBase):
text: PlainTextElement | KmarkdownElement | ParagraphStructure
type: str = "section"
type: Literal[KookModuleType.SECTION] = KookModuleType.SECTION
mode: SectionMode = "left"
accessory: ImageElement | ButtonElement | None = None
@dataclass
class ImageGroupModule(KookCardModelBase):
"""1 到多张图片的组合"""
elements: list[ImageElement]
type: str = "image-group"
type: Literal[KookModuleType.IMAGE_GROUP] = KookModuleType.IMAGE_GROUP
@dataclass
class ContainerModule(KookCardModelBase):
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
elements: list[ImageElement]
type: str = "container"
type: Literal[KookModuleType.CONTAINER] = KookModuleType.CONTAINER
@dataclass
class ActionGroupModule(KookCardModelBase):
"""用来放按钮的模块"""
elements: list[ButtonElement]
type: str = "action-group"
type: Literal[KookModuleType.ACTION_GROUP] = KookModuleType.ACTION_GROUP
@dataclass
class ContextModule(KookCardModelBase):
elements: list[PlainTextElement | KmarkdownElement | ImageElement]
"""最多包含10个元素"""
type: str = "context"
type: Literal[KookModuleType.CONTEXT] = KookModuleType.CONTEXT
@dataclass
class DividerModule(KookCardModelBase):
type: str = "divider"
"""展示分割线用的"""
type: Literal[KookModuleType.DIVIDER] = KookModuleType.DIVIDER
@dataclass
class FileModule(KookCardModelBase):
src: str
title: str = ""
type: Literal["file", "audio", "video"] = "file"
type: Literal[KookModuleType.FILE, KookModuleType.AUDIO, KookModuleType.VIDEO] = (
KookModuleType.FILE
)
cover: str | None = None
"""cover 仅音频有效, 是音频的封面图"""
@dataclass
class CountdownModule(KookCardModelBase):
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
endTime: int
"""毫秒时间戳"""
type: str = "countdown"
type: Literal[KookModuleType.COUNTDOWN] = KookModuleType.COUNTDOWN
startTime: int | None = None
"""毫秒时间戳, 仅当mode为second才有这个字段"""
mode: CountdownMode = "day"
"""mode 主要是倒计时的样式"""
@dataclass
class InviteModule(KookCardModelBase):
code: str
"""邀请链接或者邀请码"""
type: str = "invite"
type: Literal[KookModuleType.INVITE] = KookModuleType.INVITE
# 所有模块的联合类型
AnyModule = (
AnyModule = Annotated[
HeaderModule
| SectionModule
| ImageGroupModule
@@ -192,34 +244,29 @@ AnyModule = (
| DividerModule
| FileModule
| CountdownModule
| InviteModule
)
| InviteModule,
Field(discriminator="type"),
]
class KookCardMessage(BaseModel):
class KookCardMessage(KookBaseDataClass):
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
若要发送卡片消息请使用KookCardMessageContainer
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
type: str = "card"
type: Literal[KookModuleType.CARD] = KookModuleType.CARD
theme: ThemeType | None = None
size: SizeType | None = None
color: KookCardColor | None = None
modules: list[AnyModule] = field(default_factory=list)
color: str | None = None
"""16 进制色值"""
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后发送出去"""
@@ -232,10 +279,227 @@ class KookCardMessageContainer(list[KookCardMessage]):
[i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii
)
@classmethod
def from_dict(cls, raw_data: list[dict[str, Any]]):
return cls(KookCardMessage.from_dict(item) for item in raw_data)
@dataclass
class OrderMessage:
class OrderMessage(BaseModel):
index: int
text: str
type: KookMessageType
reply_id: str | int = ""
class KookMessageSignal(IntEnum):
"""KOOK WebSocket 信令类型
ws文档: https://developer.kookapp.cn/doc/websocket""" # noqa: W291
MESSAGE = 0
"""server->client 消息(s包含聊天和通知消息)"""
HELLO = 1
"""server->client 客户端连接 ws 时, 服务端返回握手结果"""
PING = 2
"""client->server 心跳,ping"""
PONG = 3
"""server->client 心跳,pong"""
RESUME = 4
"""client->server resume, 恢复会话"""
RECONNECT = 5
"""server->client reconnect, 要求客户端断开当前连接重新连接"""
RESUME_ACK = 6
"""server->client resume ack"""
class KookChannelType(StrEnum):
GROUP = "GROUP"
PERSON = "PERSON"
BROADCAST = "BROADCAST"
class KookAuthor(KookBaseDataClass):
id: str
username: str
identify_num: str
nickname: str
bot: bool
online: bool
avatar: str | None = None
vip_avatar: str | None = None
status: int
roles: list[int] = Field(default_factory=list)
class KookKMarkdown(KookBaseDataClass):
raw_content: str
mention_part: list[Any] = Field(default_factory=list)
mention_role_part: list[Any] = Field(default_factory=list)
class KookExtra(KookBaseDataClass):
type: int | str
code: str | None = None
body: dict[str, Any] | None = None
author: KookAuthor | None = None
kmarkdown: KookKMarkdown | None = None
last_msg_content: str | None = None
mention: list[str] = Field(default_factory=list)
mention_all: bool = False
mention_here: bool = False
class KookMessageEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.MESSAGE] = Field(
KookMessageSignal.MESSAGE, exclude=True
)
"""only for type hint"""
channel_type: KookChannelType
type: KookMessageType
target_id: str
author_id: str
content: str | dict[str, Any]
msg_id: str
msg_timestamp: int
nonce: str
from_type: int
extra: KookExtra
class KookHelloEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.HELLO] = Field(
KookMessageSignal.HELLO, exclude=True
)
"""only for type hint"""
code: int
session_id: str
class KookPingEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.PING] = Field(
KookMessageSignal.PING, exclude=True
)
"""only for type hint"""
class KookPongEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.PONG] = Field(
KookMessageSignal.PONG, exclude=True
)
"""only for type hint"""
class KookResumeEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.RESUME] = Field(
KookMessageSignal.RESUME, exclude=True
)
"""only for type hint"""
class KookReconnectEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.RECONNECT] = Field(
KookMessageSignal.RECONNECT, exclude=True
)
"""only for type hint"""
code: int
err: str
class KookResumeAckEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.RESUME_ACK] = Field(
KookMessageSignal.RESUME_ACK, exclude=True
)
"""only for type hint"""
session_id: str
class KookWebsocketEvent(KookBaseDataClass):
"""KOOK WebSocket 原始推送结构"""
signal: KookMessageSignal = Field(
..., validation_alias="s", serialization_alias="s"
)
"""信令类型"""
data: Annotated[
KookMessageEventData
| KookHelloEventData
| KookPingEventData
| KookPongEventData
| KookResumeEventData
| KookReconnectEventData
| KookResumeAckEventData
| None,
Field(discriminator="signal"),
] = Field(None, validation_alias="d", serialization_alias="d")
"""数据事件主体,对应原字段是'd'"""
sn: int | None = None
"""消息序号 , 用来确定消息顺序和ws重连时使用
详见ws连接流程文档: https://developer.kookapp.cn/doc/websocket#%E8%BF%9E%E6%8E%A5%E6%B5%81%E7%A8%8B""" # noqa: W291
@model_validator(mode="before")
@classmethod
def _inject_signal_into_data(cls, data: Any) -> Any:
"""在解析前,把外层的 s 同步到内层的 d 中,供 discriminator 使用"""
if isinstance(data, dict):
s_value = data.get("s")
d_value = data.get("d")
if s_value is not None and isinstance(d_value, dict):
d_value["signal"] = s_value
return data
class KookUserTag(KookBaseDataClass):
color: str
bg_color: str
text: str
class KookApiResponseBase(KookBaseDataClass):
code: int
message: str
data: Any
def success(self) -> bool:
return self.code == 0
class KookUserMeData(KookBaseDataClass):
"""USER_ME 接口返回的 'data' 字段主体"""
id: str
username: str
identify_num: str
nickname: str
bot: bool
online: bool
status: int
bot_status: int
avatar: str
vip_avatar: str | None = None
banner: str | None = None
roles: list[Any] = Field(default_factory=list)
is_vip: bool
vip_amp: bool
wealth_level: int
mobile_verified: bool
client_id: str
tag_info: KookUserTag | None = None
class KookUserMeResponse(KookApiResponseBase):
"""USER_ME 完整响应结构"""
data: KookUserMeData
class KookGatewayIndexData(KookBaseDataClass):
url: str
class KookGatewayIndexResponse(KookApiResponseBase):
"""USER_ME 完整响应结构"""
data: KookGatewayIndexData
@@ -619,11 +619,6 @@
"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",
@@ -621,11 +621,6 @@
"type": "string",
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token"
},
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息。"
},
"kook_reconnect_delay": {
"description": "重连延迟",
"type": "int",
+28 -28
View File
@@ -4,97 +4,97 @@
"size": "lg",
"modules": [
{
"type": "header",
"text": {
"content": "test1",
"type": "plain-text",
"content": "test1",
"emoji": true
},
"type": "header"
}
},
{
"text": {
"content": "test2",
"type": "kmarkdown"
},
"type": "section",
"text": {
"type": "kmarkdown",
"content": "test2"
},
"mode": "left"
},
{
"type": "divider"
},
{
"type": "section",
"text": {
"type": "paragraph",
"fields": [
{
"content": "test3",
"type": "kmarkdown"
"type": "kmarkdown",
"content": "test3"
},
{
"content": "**test4**",
"type": "kmarkdown"
"type": "kmarkdown",
"content": "**test4**"
}
],
"type": "paragraph",
"cols": 2
},
"type": "section",
"mode": "left"
},
{
"type": "image-group",
"elements": [
{
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
"type": "image",
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
"alt": "",
"size": "lg",
"circle": false
}
],
"type": "image-group"
]
},
{
"type": "file",
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
"title": "test5",
"type": "file"
"title": "test5"
},
{
"endTime": 1772343427360,
"type": "countdown",
"endTime": 1772343427360,
"startTime": 1772343378259,
"mode": "second"
},
{
"type": "action-group",
"elements": [
{
"text": "点我测试回调",
"type": "button",
"text": "点我测试回调",
"theme": "primary",
"value": "btn_clicked",
"click": "return-val"
},
{
"text": "访问官网",
"type": "button",
"text": "访问官网",
"theme": "danger",
"value": "https://www.kookapp.cn",
"click": "link"
}
],
"type": "action-group"
]
},
{
"type": "context",
"elements": [
{
"content": "test6",
"type": "plain-text",
"content": "test6",
"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
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"),
"test image",
OrderMessage(
1,
index=1,
text="test image",
type=KookMessageType.IMAGE,
),
@@ -70,7 +70,7 @@ def mock_astrbot_message():
Video("test video"),
"test video",
OrderMessage(
1,
index=1,
text="test video",
type=KookMessageType.VIDEO,
),
@@ -80,7 +80,7 @@ def mock_astrbot_message():
mock_file_message("test file"),
"test file",
OrderMessage(
1,
index=1,
text="test file",
type=KookMessageType.FILE,
),
@@ -90,8 +90,8 @@ def mock_astrbot_message():
mock_record_message("./tests/file.wav"),
"./tests/file.wav",
OrderMessage(
1,
text='[{"type": "card", "modules": [{"src": "./tests/file.wav", "title": "./tests/file.wav", "type": "audio"}]}]',
index=1,
text='[{"type": "card", "modules": [{"type": "audio", "src": "./tests/file.wav", "title": "./tests/file.wav"}]}]',
type=KookMessageType.CARD,
),
None,
@@ -100,7 +100,7 @@ def mock_astrbot_message():
Plain("test plain"),
"test plain",
OrderMessage(
1,
index=1,
text="test plain",
type=KookMessageType.KMARKDOWN,
),
@@ -110,7 +110,7 @@ def mock_astrbot_message():
At(qq="test at"),
"test at",
OrderMessage(
1,
index=1,
text="(met)test at(met)",
type=KookMessageType.KMARKDOWN,
),
@@ -120,7 +120,7 @@ def mock_astrbot_message():
AtAll(qq="all"),
"test atAll",
OrderMessage(
1,
index=1,
text="(met)all(met)",
type=KookMessageType.KMARKDOWN,
),
@@ -130,7 +130,7 @@ def mock_astrbot_message():
Reply(id="test reply"),
"test reply",
OrderMessage(
1,
index=1,
text="",
type=KookMessageType.KMARKDOWN,
reply_id="test reply",
@@ -141,7 +141,7 @@ def mock_astrbot_message():
Json(data={"test": "json"}),
"test json",
OrderMessage(
1,
index=1,
text='[{"test": "json"}]',
type=KookMessageType.CARD,
),
@@ -159,7 +159,7 @@ async def test_kook_event_warp_message(
input_message: BaseMessageComponent,
upload_asset_return: str,
expected_output: OrderMessage,
expected_error: type[Exception] | None,
expected_error: type[BaseException] | None,
):
client = await mock_kook_client(
upload_asset_return,
@@ -185,39 +185,4 @@ async def test_kook_event_warp_message(
result = await event._wrap_message(1, input_message)
assert result == expected_output
# @pytest.mark.asyncio
# @pytest.mark.parametrize(
# "message_chain,send_text_expected_output,expected_error",
# [
# (
# MessageChain(
# chain=[
# Image(file="test image"),
# Plain(text="test plain"),
# ],
# ),
# ""
# ),
# ],
# )
# async def test_kook_event_send():
# client = await mock_kook_client(
# "",
# "",
# )
# event = KookEvent(
# "",
# mock_astrbot_message(),
# PlatformMetadata(
# name="test",
# id="test",
# description="test",
# ),
# "",
# client,
# )
# await event.send(message=mock_astrbot_message())
+42 -1
View File
@@ -16,6 +16,9 @@ from astrbot.core.platform.sources.kook.kook_types import (
InviteModule,
KmarkdownElement,
KookCardMessage,
KookMessageSignal,
KookModuleType,
KookWebsocketEvent,
ParagraphStructure,
PlainTextElement,
SectionModule,
@@ -77,7 +80,7 @@ def test_all_kook_card_type():
FileModule(
src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
title="test5",
type="file",
type=KookModuleType.FILE,
),
CountdownModule(
endTime=1772343427360,
@@ -105,3 +108,41 @@ def test_all_kook_card_type():
],
).to_json(indent=4, ensure_ascii=False)
assert json_output == expect_json_data
@pytest.mark.parametrize(
"expected_json_data_filename",
[
("kook_ws_event_group_message.json"),
("kook_ws_event_hello.json"),
("kook_ws_event_message_with_card_1.json"),
("kook_ws_event_message_with_card_2.json"),
("kook_ws_event_ping.json"),
("kook_ws_event_pong.json"),
("kook_ws_event_private_message.json"),
("kook_ws_event_private_system_message.json"),
("kook_ws_event_reconnect_err.json"),
("kook_ws_event_resume_ack.json"),
("kook_ws_event_resume.json"),
],
)
def test_websocket_event_type_parse(expected_json_data_filename:str):
expected_json_data_str =(TEST_DATA_DIR / expected_json_data_filename).read_text(encoding="utf-8")
event = KookWebsocketEvent.from_json(
expected_json_data_str,
)
event_dict = event.to_dict(mode="json",exclude_unset=True,exclude_none=False)
assert event_dict == json.loads(expected_json_data_str)
def test_websocket_event_create():
ping_data = KookWebsocketEvent(
signal=KookMessageSignal.PING,
data=None,
sn=0,
)
assert ping_data.to_dict(mode="json")== {
"s": KookMessageSignal.PING.value,
"sn": 0,
}