Skip to content

Commit 1199b70

Browse files
authored
feat: implements support for KOOK role mentions (#7626)
* feat: 实现kook适配器响应`@`角色(role)的能力 * refactor: kook适配器处理role时,`At`组件保留`@`角色的名称而不是id * fix: kook适配器处理role时,role_id的判断问题 * refactor: 移除kook适配器中的一个# type: ignore * fix: 修复kook适配器 role mention转换成`At`组件时保留不是角色名称的bug; * unittest: 给kook适配器添加带有role mention的事件消息的单测,并添加消息组件转换判断单测 * unittest: 部分重构test_kook_event.py和test_kook_types.py 单测 * unittest: 添加kook适配器的 `user/me` `user/view` 接口响应数据验证单测 * fix: 修复kook适配器接收频道权限更新消息会报错的bug * fix: 不额外处理kook的道具消息 * fix: 使用async with self._http_client.get * refactor: kook适配器转换文本内容为消息组件时,只strip mention之间的空格 * fix: 修复 role_mention_counter 计数不正确的问题 * fix: 修复kook适配器发送卡片失败的问题;区分两类kook 数据类的to_dict to_json行为 * chore: 添加注释 * refactor: 重构kook适配器的角色缓存功能,使其无锁,性能更好且具备良好的重试机制 * refactor: kook适配器的channel_id 改为 guild_id * feat: kook适配器响应频道角色更新事件时不再清空整个角色id缓存,而是只清理特定频道的角色id缓存 * unittest: 添加kook适配器的update_role事件的数据类验证单测 * refactor: 补上了一些打印的日志消息文本 refactor: 补上了一些打印的日志消息文本 refactor: 补上了一些打印的日志消息文本 * refactor: 修复kook适配器潜在可能的类型问题 * refactor: `clean_roles_cache`重命名为`clear_guild_roles_cache`
1 parent b40bcbb commit 1199b70

13 files changed

+1145
-156
lines changed

astrbot/core/platform/sources/kook/kook_adapter.py

Lines changed: 140 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313
PlatformMetadata,
1414
register_platform_adapter,
1515
)
16-
from astrbot.core.message.components import File, Record, Video
16+
from astrbot.core.message.components import BaseMessageComponent, File, Record, Video
1717
from astrbot.core.platform.astr_message_event import MessageSesion
1818

1919
from .kook_client import KookClient
2020
from .kook_config import KookConfig
2121
from .kook_event import KookEvent
22+
from .kook_roles_record import KookRolesRecord
2223
from .kook_types import (
2324
ContainerModule,
2425
FileModule,
@@ -27,14 +28,18 @@
2728
KmarkdownElement,
2829
KookCardMessageContainer,
2930
KookChannelType,
31+
KookMarkdownMentionRolePart,
32+
KookMentionTagName,
3033
KookMessageEventData,
3134
KookMessageType,
3235
KookModuleType,
36+
KookRoleExtraType,
3337
PlainTextElement,
3438
SectionModule,
3539
)
3640

37-
KOOK_AT_SELECTOR_REGEX = re.compile(r"\(met\)([^()]+)\(met\)")
41+
KOOK_AT_SELECTOR_REGEX = re.compile(r"\((met|rol)\)([^()]+)\(\1\)")
42+
AT_MENTION_PREFIX_REGEX = re.compile(r"^@[^\s]+(\s*-\s*[^\s]+)?\s*")
3843

3944

4045
@register_platform_adapter(
@@ -53,6 +58,7 @@ def __init__(
5358
self._reconnect_task = None
5459
self.running = False
5560
self._main_task = None
61+
self._roles_cache = KookRolesRecord("", self.client.http_client)
5662

5763
async def send_by_session(
5864
self, session: MessageSesion, message_chain: MessageChain
@@ -84,16 +90,26 @@ async def _on_received(self, event: KookMessageEventData):
8490
event_type = event.type
8591
if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD):
8692
if self._should_ignore_event_by_bot_nickname(event.author_id):
87-
logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息")
93+
logger.debug("[KOOK] 判断此消息为来自机器人自身的消息, 忽略此消息")
8894
return
8995
try:
9096
abm = await self.convert_message(event)
9197
await self.handle_msg(abm)
9298
except Exception as e:
9399
logger.error(f"[KOOK] 消息处理异常: {e}")
94100
elif event_type == KookMessageType.SYSTEM:
95-
logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"')
96-
logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}")
101+
match event.extra.type:
102+
case KookRoleExtraType():
103+
# 此时 target_id 就是频道id(guild_id)
104+
guild_id = event.target_id
105+
logger.info(
106+
f'[KOOK] 收到频道"{guild_id}"的角色更新通知, 类型为"{event.extra.type.value}", 刷新角色id缓存'
107+
)
108+
self._roles_cache.clear_guild_roles_cache(int(guild_id))
109+
case _:
110+
logger.debug(
111+
f'[KOOK] 判断此消息为"{event.extra.type}"类型的系统通知, 因未实现此消息的处理流程而忽略此消息, 原始消息数据: {event.to_json()}'
112+
)
97113

98114
async def run(self):
99115
"""主运行循环"""
@@ -124,6 +140,8 @@ async def _main_loop(self):
124140
logger.info("[KOOK] 尝试连接KOOK服务器...")
125141

126142
# 尝试连接
143+
await self.client.get_bot_info()
144+
self._roles_cache.set_bot_id(self.client.bot_id)
127145
success = await self.client.connect()
128146

129147
if success:
@@ -191,47 +209,86 @@ async def _cleanup(self):
191209

192210
logger.info("[KOOK] 资源清理完成")
193211

194-
def _parse_kmarkdown_text_message(
195-
self, data: KookMessageEventData, self_id: str
196-
) -> tuple[list, str]:
197-
kmarkdown = data.extra.kmarkdown
198-
content = data.content or ""
199-
if kmarkdown is None:
200-
logger.error(
201-
f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段'
202-
)
203-
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
204-
return [], ""
212+
async def _convert_text_message_to_component(
213+
self,
214+
content: str,
215+
raw_content: str,
216+
mention_role_part: list[KookMarkdownMentionRolePart] | None = None,
217+
guild_id: str | None = None,
218+
mention_name_map: dict[str, str] | None = None,
219+
) -> tuple[list[BaseMessageComponent], str]:
220+
# kook平台有一个角色(role)的概念,他表示拥有某一类权限的许多用户
221+
# 且角色本身也有一个自己的id,与正常用户id不同
222+
# 而在频道中是可以`@`角色的,而想要知道bot是否属于某个角色
223+
# 需要通过 `/user/view` 接口获取当前bot账号的某个频道下所属角色的id
224+
# 为了解决 https://github.com/AstrBotDevs/AstrBot/issues/7539
225+
# 在确定机器人需要响应某个`(rol)xxx(rol)`时,需要将角色id替换装当前的bot id
226+
# 包装成`At`机器人自己,而`At`的name就保留角色名称
227+
# 如果没有查询到角色id或者bot不属于某类角色, 则不处理此`(rol)xxx(rol)`
228+
# 暂时想不到能在不修改原有消息内容的情况下处理这个角色mention的方案
205229

206-
raw_content = kmarkdown.raw_content or content
207-
if not isinstance(content, str):
208-
content = str(content)
209-
if not isinstance(raw_content, str):
210-
raw_content = str(raw_content)
230+
message_str = raw_content
231+
bot_id = self.client.bot_id
232+
bot_nickname = self.client.bot_nickname
233+
bot_username = self.client.bot_username
234+
components: list[BaseMessageComponent] = []
235+
if mention_name_map is None:
236+
mention_name_map = {}
237+
cursor = 0
211238

212-
# TODO 后面的pydantic类型替换,以后再来探索吧 :(
213-
mention_name_map: dict[str, str] = {}
214-
mention_part = kmarkdown.mention_part
215-
if isinstance(mention_part, list):
216-
for item in mention_part:
217-
if not isinstance(item, dict):
218-
continue
219-
mention_id = item.get("id")
220-
if mention_id is None:
221-
continue
222-
mention_name_map[str(mention_id)] = str(item.get("username", ""))
239+
role_mention_counter = -1
223240

224-
components = []
225-
cursor = 0
226241
for match in KOOK_AT_SELECTOR_REGEX.finditer(content):
227242
if match.start() > cursor:
228-
plain_text = content[cursor : match.start()]
243+
plain_text = content[cursor : match.start()].strip(" ")
229244
if plain_text:
230245
components.append(Plain(text=plain_text))
231246

232-
mention_target = match.group(1).strip()
233-
if mention_target == "all":
247+
tag_name = match.group(1)
248+
mention_target = match.group(2).strip()
249+
if tag_name == KookMentionTagName.MENTION and mention_target == "all":
234250
components.append(AtAll())
251+
elif tag_name == KookMentionTagName.ROLE:
252+
role_mention_counter += 1
253+
role_id = 0
254+
role_mention_name = mention_target
255+
if mention_role_part is not None:
256+
if len(mention_role_part) > role_mention_counter:
257+
role_mention_name = mention_role_part[role_mention_counter].name
258+
role_id = mention_role_part[role_mention_counter].role_id
259+
if (
260+
bot_nickname == role_mention_name
261+
or bot_username == role_mention_name
262+
):
263+
components.append(
264+
At(
265+
qq=bot_id,
266+
name=role_mention_name, # 保留角色名称
267+
)
268+
)
269+
continue
270+
if not mention_target.isdigit() and role_id == 0:
271+
continue
272+
273+
role_id = role_id or int(mention_target)
274+
if not guild_id:
275+
continue
276+
277+
if not guild_id.isdigit():
278+
continue
279+
280+
if not await self._roles_cache.has_role_in_channel(
281+
role_id, int(guild_id)
282+
):
283+
continue
284+
285+
components.append(
286+
At(
287+
qq=bot_id,
288+
name=role_mention_name, # 保留角色名称
289+
)
290+
)
291+
235292
elif mention_target:
236293
components.append(
237294
At(
@@ -242,21 +299,20 @@ def _parse_kmarkdown_text_message(
242299
cursor = match.end()
243300

244301
if cursor < len(content):
245-
tail_text = content[cursor:]
302+
tail_text = content[cursor:].strip(" ")
246303
if tail_text:
247304
components.append(Plain(text=tail_text))
248305

249-
message_str = raw_content
306+
message_str = raw_content.strip()
250307
if components:
251308
for comp in components:
252309
if isinstance(comp, Plain):
253310
if not comp.text.strip():
254311
continue
255312
break
256313
if isinstance(comp, At):
257-
if str(comp.qq) == str(self_id):
258-
message_str = re.sub(
259-
r"^@[^\s]+(\s*-\s*[^\s]+)?\s*",
314+
if str(comp.qq) == str(self.client.bot_id):
315+
message_str = AT_MENTION_PREFIX_REGEX.sub(
260316
"",
261317
message_str,
262318
count=1,
@@ -270,10 +326,44 @@ def _parse_kmarkdown_text_message(
270326

271327
return components, message_str
272328

273-
def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]:
329+
async def _parse_kmarkdown_message(
330+
self, data: KookMessageEventData
331+
) -> tuple[list[BaseMessageComponent], str]:
332+
kmarkdown = data.extra.kmarkdown
333+
guild_id = data.extra.guild_id
334+
mention_role_part = None
335+
if kmarkdown:
336+
mention_role_part = kmarkdown.mention_role_part
337+
# 无法处理可能会收到的道具消息content,只能保留原样
338+
content = str(data.content) or ""
339+
if kmarkdown is None:
340+
logger.error(
341+
f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段'
342+
)
343+
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
344+
return [], ""
345+
346+
raw_content = kmarkdown.raw_content or content
347+
348+
mention_name_map: dict[str, str] = {}
349+
mention_part = kmarkdown.mention_part
350+
for item in mention_part:
351+
mention_id = item.id
352+
if mention_id is None:
353+
continue
354+
mention_name_map[str(mention_id)] = str(item.username)
355+
356+
return await self._convert_text_message_to_component(
357+
content, raw_content, mention_role_part, guild_id, mention_name_map
358+
)
359+
360+
async def _parse_card_message(
361+
self, data: KookMessageEventData
362+
) -> tuple[list[BaseMessageComponent], str]:
274363
content = data.content
275364
if not isinstance(content, str):
276365
content = str(content)
366+
guild_id = data.extra.guild_id
277367

278368
card_list = KookCardMessageContainer.from_dict(json.loads(content))
279369

@@ -304,18 +394,13 @@ def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]:
304394
logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}")
305395

306396
text = "".join(text_parts)
307-
message = []
397+
message: list[BaseMessageComponent] = []
308398

309399
if text:
310-
for search in KOOK_AT_SELECTOR_REGEX.finditer(text):
311-
search_text = search.group(1).strip()
312-
if search_text == "all":
313-
message.append(AtAll())
314-
continue
315-
message.append(At(qq=search_text))
316-
text = text.replace(f"(met){search_text}(met)", "")
317-
318-
message.append(Plain(text=text))
400+
component_parts, text = await self._convert_text_message_to_component(
401+
text, text, guild_id=guild_id
402+
)
403+
message.extend(component_parts)
319404

320405
for img_url in images:
321406
message.append(Image(file=img_url))
@@ -387,12 +472,10 @@ async def convert_message(self, data: KookMessageEventData) -> AstrBotMessage:
387472
abm.message_id = data.msg_id or "unknown"
388473

389474
if data.type == KookMessageType.KMARKDOWN:
390-
message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id)
391-
abm.message = message
392-
abm.message_str = message_str
475+
abm.message, abm.message_str = await self._parse_kmarkdown_message(data)
393476
elif data.type == KookMessageType.CARD:
394477
try:
395-
abm.message, abm.message_str = self._parse_card_message(data)
478+
abm.message, abm.message_str = await self._parse_card_message(data)
396479
except Exception as exp:
397480
logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
398481
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")

astrbot/core/platform/sources/kook/kook_client.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import random
55
import time
6+
import traceback
67
import zlib
78
from pathlib import Path
89

@@ -59,12 +60,18 @@ def bot_id(self):
5960

6061
@property
6162
def bot_nickname(self):
63+
"""机器人昵称"""
6264
return self._bot_nickname
6365

6466
@property
6567
def bot_username(self):
68+
"""机器人名称"""
6669
return self._bot_username
6770

71+
@property
72+
def http_client(self):
73+
return self._http_client
74+
6875
async def get_bot_info(self) -> None:
6976
"""获取机器人账号信息"""
7077
url = KookApiPaths.USER_ME
@@ -151,7 +158,6 @@ async def connect(self, resume=False):
151158
gateway_url = await self.get_gateway_url(
152159
resume=resume, sn=self.last_sn, session_id=self.session_id
153160
)
154-
await self.get_bot_info()
155161

156162
if not gateway_url:
157163
return False
@@ -215,8 +221,8 @@ async def listen(self):
215221
except websockets.exceptions.ConnectionClosed:
216222
logger.warning("[KOOK] WebSocket连接已关闭")
217223
break
218-
except Exception as e:
219-
logger.error(f"[KOOK] 消息处理异常: {e}")
224+
except Exception:
225+
logger.error(f"[KOOK] 消息处理异常: {traceback.format_exc()}")
220226
break
221227

222228
except Exception as e:
@@ -236,11 +242,15 @@ async def _handle_signal(self, event: KookWebsocketEvent):
236242
await self.event_callback(data)
237243

238244
case KookMessageSignal.HELLO:
239-
assert isinstance(data, KookHelloEventData)
245+
assert isinstance(data, KookHelloEventData), (
246+
f"期望 data 为 {KookHelloEventData.__name__}, 实际为 {type(data).__name__},"
247+
)
240248
await self._handle_hello(data)
241249

242250
case KookMessageSignal.RESUME_ACK:
243-
assert isinstance(data, KookResumeAckEventData)
251+
assert isinstance(data, KookResumeAckEventData), (
252+
f"期望 data 为 {KookResumeAckEventData.__name__}, 实际为 {type(data).__name__},"
253+
)
244254
await self._handle_resume_ack(data)
245255

246256
case KookMessageSignal.PONG:
@@ -367,8 +377,8 @@ async def send_text(
367377
"type": kook_message_type,
368378
}
369379
if reply_message_id:
370-
payload["quote"] = reply_message_id
371-
payload["reply_msg_id"] = reply_message_id
380+
payload["quote"] = str(reply_message_id)
381+
payload["reply_msg_id"] = str(reply_message_id)
372382

373383
try:
374384
async with self._http_client.post(url, json=payload) as resp:

0 commit comments

Comments
 (0)