feat(kook): add eba adapter

This commit is contained in:
wangcham
2026-06-04 18:29:46 +08:00
parent b68ff1956c
commit 1f67ff2e8d
12 changed files with 1336 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
# KOOK EBA Adapter
## Status
KOOK has been migrated to the EBA adapter directory:
```text
src/langbot/pkg/platform/adapters/kook/
├── adapter.py
├── api_impl.py
├── event_converter.py
├── manifest.yaml
├── message_converter.py
├── platform_api.py
└── types.py
```
The adapter is registered as `kook-eba`.
## Configuration
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `token` | Yes | `""` | KOOK bot token. |
| `enable-stream-reply` | Yes | `false` | Reserved for shared platform configuration compatibility. |
## Events
| Event | Evidence | Notes |
|-------|----------|-------|
| `message.received` | `plugin-e2e-ui` | Real KOOK UI channel message reached `EBAEventProbe` as `MessageReceivedEvent`. |
| `platform.specific` | `plugin-e2e-ui` | KOOK gateway event without a common EBA mapping reached `EBAEventProbe` as `PlatformSpecificEventReceived`. |
## Common APIs
| API | Evidence | Notes |
|-----|----------|-------|
| `send_message` | `plugin-e2e-outbound` | Probe plugin sent channel messages through SDK `send_message`; KOOK returned message IDs. |
| `reply_message` | `unit` | Supports `reply_msg_id` and optional quoted replies when the source message ID is available. |
| `get_message` | `plugin-e2e-outbound` | Probe plugin fetched the cached triggering message. |
| `get_group_info` | `plugin-e2e-outbound` | Probe plugin received cached KOOK channel info. |
| `get_group_list` | `plugin-e2e-outbound` | Probe plugin received cached channel/group entities observed by the adapter. |
| `get_group_member_info` | `plugin-e2e-outbound` | Probe plugin received cached sender info as a group member. |
| `get_user_info` | `plugin-e2e-outbound` | Probe plugin received cached sender user info. |
| `get_friend_list` | `plugin-e2e-outbound` | Probe plugin received cached users. |
| `upload_file` | `unit` | Uses KOOK `asset/create` and returns URL/ID. |
| `get_file_url` | `unit` | KOOK media IDs are URL-like in the adapter path; returns the ID unchanged. |
| `delete_message` | `unit` | Calls KOOK delete endpoints. Live permission verification is still required. |
| `forward_message` | `plugin-e2e-outbound` | Probe plugin sent flattened forward content through SDK `send_message`. |
| `call_platform_api` | `plugin-e2e-outbound` | Probe plugin called safe KOOK platform-specific APIs through SDK `call_platform_api`. |
## Platform-Specific APIs
| Action | Evidence | Notes |
|--------|----------|-------|
| `get_current_user` | `plugin-e2e-outbound` | Probe plugin called `user/me`. |
| `get_user` | `plugin-e2e-outbound` | Probe plugin called `user/view` for the triggering sender. |
| `get_channel` | `plugin-e2e-outbound` | Probe plugin called `channel/view` for the triggering channel. |
| `get_guild` | `plugin-e2e-outbound` | Probe plugin called `guild/view`; gateway URLs redact token query values. |
| `get_gateway` | `plugin-e2e-outbound` | Probe plugin called `gateway/index`; returned token query values are redacted. |
| `send_direct_message` | `unit` | Calls `direct-message/create`. |
## Components
| Component | Receive Evidence | Send Evidence | Notes |
|-----------|------------------|---------------|-------|
| `Source` | `plugin-e2e-ui` | N/A | KOOK message ID and timestamp are preserved. |
| `Plain` | `plugin-e2e-ui` | `plugin-e2e-outbound` | Text and KMarkdown are represented as plain common text. |
| `At` | `plugin-e2e-ui` | `plugin-e2e-outbound` | KOOK `(met)<id>(met)` mentions map to common `At`. |
| `AtAll` | `unit` | `plugin-e2e-outbound` | KOOK `(met)all(met)` maps to common `AtAll`; real inbound UI AtAll was not tested. |
| `Image` | `unit` | `unit` | URL/image ID based path only; live rendering still needs verification. |
| `Voice` | `unit` | `unit` | URL based path only; live rendering still needs verification. |
| `File` | `unit` | `unit` | URL based path only; upload API is exposed separately. |
| `Forward` | `unit` | `unit` | Outbound forwards are flattened; inbound structured forwards are not exposed by current legacy implementation. |
| `Unknown` | `unit` | N/A | Unsupported KOOK message types become `Unknown` or `PlatformSpecificEvent`. |
## Acceptance Record
Test date: June 4, 2026.
Plugin E2E verified on June 4, 2026 with `EBAEventProbe`, SDK standalone runtime, KOOK WebSocket adapter, and a real KOOK channel UI message.
Evidence:
- JSONL: `data/temp/kook_eba_plugin_probe.jsonl`
- Plugin log: `data/logs/eba-probe-kook.log`
Observed and verified:
- A real KOOK UI channel message reached the plugin as `MessageReceived` with `bot_uuid=7ab5b065-6e4e-4def-95f0-3c265366e26f`, `adapter_name=kook`, common sender/group/chat fields, and common `MessageChain` components.
- KOOK gateway-specific event reached the plugin as `PlatformSpecificEventReceived`.
- Probe plugin called SDK `send_message`; KOOK returned message IDs for text, At, AtAll, image URL/base64 fallback path, quote fallback, file fallback, and flattened forward cases.
- Probe plugin called common API methods through the SDK path: `get_message`, `get_user_info`, `get_friend_list`, `get_group_info`, `get_group_list`, and `get_group_member_info`.
- Probe plugin called safe KOOK platform-specific APIs through SDK `call_platform_api`: `get_current_user`, `get_user`, `get_channel`, `get_gateway`, and `get_guild`.
Run:
```bash
uv run pytest tests/unit_tests/platform/test_kook_eba_adapter.py
git diff --check
```
Blocked or partial items:
- `plugin-e2e-ui` inbound coverage for image, file, voice, AtAll, quote, and forward.
- `plugin-e2e-outbound` visual verification in KOOK UI for image/file/voice rendering. KOOK returned message IDs, but UI inspection was not performed in this run.
- `reply_message` and `delete_message` live permission verification.
- Destructive or permission-sensitive APIs were not declared beyond delete; KOOK mute/kick/leave remain explicit `NotSupportedError` paths until a safe fixture is available.

View File

@@ -0,0 +1,5 @@
from __future__ import annotations
from langbot.pkg.platform.adapters.kook.adapter import KookAdapter
__all__ = ['KookAdapter']

View File

@@ -0,0 +1,318 @@
from __future__ import annotations
import asyncio
import json
import traceback
import typing
import zlib
import aiohttp
import pydantic
import websockets
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.platform.adapters.kook.api_impl import KookAPIMixin
from langbot.pkg.platform.adapters.kook.event_converter import KookEventConverter
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
from langbot.pkg.platform.adapters.kook.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.kook.errors import NotSupportedError
from langbot.pkg.utils import httpclient
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
BasePlatformAdapter = getattr(
abstract_platform_adapter,
'AbstractPlatformAdapter',
abstract_platform_adapter.AbstractMessagePlatformAdapter,
)
class KookAdapter(KookAPIMixin, BasePlatformAdapter):
message_converter: KookMessageConverter = KookMessageConverter()
event_converter: KookEventConverter = KookEventConverter()
config: dict
listeners: dict[
typing.Type[platform_events.Event],
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = {}
ws: typing.Any = pydantic.Field(exclude=True, default=None)
ws_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
running: bool = pydantic.Field(exclude=True, default=False)
session_id: str = pydantic.Field(exclude=True, default='')
current_sn: int = pydantic.Field(exclude=True, default=0)
gateway_url: str = pydantic.Field(exclude=True, default='')
http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None)
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
_user_cache: dict[str, platform_entities.User] = {}
_group_cache: dict[str, platform_entities.UserGroup] = {}
class Config:
arbitrary_types_allowed = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
if not config.get('token'):
raise Exception('KOOK adapter requires "token" in config')
super().__init__(
config=config,
logger=logger,
bot_account_id='',
listeners={},
running=False,
session_id='',
current_sn=0,
gateway_url='',
http_session=None,
_message_cache={},
_user_cache={},
_group_cache={},
**kwargs,
)
def get_supported_events(self) -> list[str]:
return [
'message.received',
'platform.specific',
]
def get_supported_apis(self) -> list[str]:
return [
'send_message',
'reply_message',
'get_message',
'get_group_info',
'get_group_list',
'get_group_member_info',
'get_user_info',
'get_friend_list',
'upload_file',
'get_file_url',
'delete_message',
'forward_message',
'call_platform_api',
]
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
return await handler(self, params)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
None,
],
):
self.listeners[event_type] = callback
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
None,
],
):
registered = self.listeners.get(event_type)
if registered is callback:
self.listeners.pop(event_type, None)
async def run_async(self):
self.running = True
self.http_session = httpclient.get_session()
await self.logger.info('KOOK EBA adapter starting')
try:
bot_info = await self._get_bot_user_info()
self.bot_account_id = str(bot_info.get('id') or '')
except Exception as e:
await self.logger.error(f'Failed to get KOOK bot user info: {e}')
self.ws_task = asyncio.create_task(self._websocket_loop())
try:
await self.ws_task
finally:
self.running = False
async def kill(self) -> bool:
self.running = False
for task in (self.heartbeat_task, self.ws_task):
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
if self.ws:
await self.ws.close()
await self.logger.info('KOOK EBA adapter stopped')
return True
async def is_muted(self, group_id: int | None = None) -> bool:
return False
async def _handle_hello(self, data: dict):
self.session_id = str(data.get('session_id') or '')
await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {self.session_id}')
async def _handle_event(self, data: dict, sn: int):
self.current_sn = max(self.current_sn, sn)
event_type = int(data.get('type', 0) or 0)
channel_type = data.get('channel_type')
author_id = str(data.get('author_id') or '')
is_message_event = event_type in KookEventConverter.MESSAGE_TYPES and channel_type in {'GROUP', 'PERSON'}
if is_message_event and self.bot_account_id and author_id == self.bot_account_id:
return
try:
if is_message_event and (
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners
):
legacy_event = await self.event_converter.target2legacy(data, self.bot_account_id)
callback = self.listeners.get(type(legacy_event))
if callback:
await callback(legacy_event, self)
eba_event = await self.event_converter.target2yiri(data, self.bot_account_id)
if eba_event:
self._cache_event(eba_event)
await self._dispatch_eba_event(eba_event)
except Exception:
await self.logger.error(f'Error handling KOOK event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
await callback(event, self)
return
def _cache_event(self, event: platform_events.Event):
if not isinstance(event, platform_events.MessageReceivedEvent):
return
self._message_cache[str(event.message_id)] = event
self._user_cache[str(event.sender.id)] = event.sender
if event.group:
self._group_cache[str(event.group.id)] = event.group
async def _websocket_loop(self):
retry_count = 0
max_retries = int(self.config.get('max_retries', 3))
while self.running and retry_count < max_retries:
try:
if not self.gateway_url:
self.gateway_url = await self._get_gateway_url()
async with websockets.connect(self.gateway_url) as ws:
self.ws = ws
await self.logger.info('Connected to KOOK WebSocket')
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0)
hello_data = json.loads(self._decode_ws_message(hello_msg))
if hello_data.get('s') != 1:
raise Exception(f'Expected KOOK HELLO signal, got {hello_data.get("s")}')
await self._handle_hello(hello_data.get('d') or {})
retry_count = 0
async for message in ws:
msg_data = json.loads(self._decode_ws_message(message))
signal = msg_data.get('s')
if signal == 0:
await self._handle_event(msg_data.get('d') or {}, int(msg_data.get('sn') or 0))
elif signal == 5:
break
except websockets.exceptions.ConnectionClosed:
retry_count += 1
await self.logger.warning('KOOK WebSocket connection closed, reconnecting')
await asyncio.sleep(min(2**retry_count, 30))
except asyncio.CancelledError:
raise
except Exception:
retry_count += 1
await self.logger.error(f'KOOK WebSocket error: {traceback.format_exc()}')
await asyncio.sleep(min(2**retry_count, 30))
finally:
if self.heartbeat_task:
self.heartbeat_task.cancel()
try:
await self.heartbeat_task
except asyncio.CancelledError:
pass
self.ws = None
if retry_count >= max_retries:
await self.logger.error(f'Failed to connect to KOOK after {max_retries} retries')
async def _heartbeat_loop(self):
try:
while self.running and self.ws:
await asyncio.sleep(30)
if self.ws:
await self.ws.send(json.dumps({'s': 2, 'sn': self.current_sn}))
except asyncio.CancelledError:
pass
except Exception as e:
await self.logger.error(f'KOOK heartbeat error: {e}')
async def _get_gateway_url(self) -> str:
raw = await self._request('GET', '/gateway/index', params={'compress': 1})
return str(raw['data']['url'])
async def _get_bot_user_info(self) -> dict:
raw = await self._request('GET', '/user/me')
return raw.get('data') or {}
async def _request(
self,
method: str,
endpoint: str,
*,
params: dict | None = None,
json: dict | None = None,
data: dict | None = None,
filename: str | None = None,
) -> dict:
session = self.http_session or httpclient.get_session()
self.http_session = session
url = f'https://www.kookapp.cn/api/v3{endpoint}'
headers = {'Authorization': f'Bot {self.config["token"]}'}
request_kwargs: dict[str, typing.Any] = {'params': params, 'headers': headers}
if json is not None:
request_kwargs['json'] = json
if data is not None and filename is not None:
form = aiohttp.FormData()
form.add_field('file', data['file'], filename=filename)
request_kwargs['data'] = form
elif data is not None:
request_kwargs['data'] = data
async with session.request(method, url, **request_kwargs) as response:
payload = await response.json(content_type=None)
if response.status != 200:
raise Exception(f'KOOK API HTTP {response.status}: {payload}')
if payload.get('code') != 0:
raise Exception(f'KOOK API error {payload.get("code")}: {payload.get("message")}')
return payload
@staticmethod
def _decode_ws_message(message) -> str:
if isinstance(message, bytes):
try:
return zlib.decompress(message).decode('utf-8')
except Exception:
return message.decode('utf-8')
return str(message)

View File

@@ -0,0 +1,211 @@
from __future__ import annotations
import typing
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot.pkg.platform.adapters.kook.errors import NotSupportedError
class KookAPIMixin:
_message_cache: dict[str, platform_events.MessageReceivedEvent]
_user_cache: dict[str, platform_entities.User]
_group_cache: dict[str, platform_entities.UserGroup]
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> platform_events.MessageResult:
content, msg_type = await KookMessageConverter.yiri2target(message)
endpoint = '/message/create' if target_type.lower() in {'group', 'channel'} else '/direct-message/create'
raw = await self._request(
'POST',
endpoint,
json={
'target_id': str(target_id),
'content': content,
'type': msg_type,
},
)
data = raw.get('data') or {}
return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> platform_events.MessageResult:
content, msg_type = await KookMessageConverter.yiri2target(message)
kook_event = message_source.source_platform_object or {}
channel_type = kook_event.get('channel_type')
msg_id = kook_event.get('msg_id')
if channel_type == 'GROUP':
endpoint = '/message/create'
payload = {
'target_id': str(kook_event.get('target_id') or message_source.chat_id),
'content': content,
'type': msg_type,
}
else:
endpoint = '/direct-message/create'
extra = kook_event.get('extra') or {}
payload = {
'content': content,
'type': msg_type,
}
if extra.get('code'):
payload['chat_code'] = extra['code']
else:
payload['target_id'] = str(kook_event.get('author_id') or message_source.chat_id)
if msg_id:
payload['reply_msg_id'] = msg_id
if quote_origin:
payload['quote'] = msg_id
raw = await self._request('POST', endpoint, json=payload)
data = raw.get('data') or {}
return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw)
async def get_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> platform_events.MessageReceivedEvent:
event = self._message_cache.get(str(message_id))
if event is None:
raise NotSupportedError('get_message:message_not_cached')
return event
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
cached = self._group_cache.get(str(group_id))
if cached:
return cached
raw = await self._request('GET', '/channel/view', params={'target_id': str(group_id)})
data = raw.get('data') or {}
return platform_entities.UserGroup(
id=str(data.get('id') or group_id),
name=str(data.get('name') or ''),
member_count=data.get('user_count'),
)
async def get_group_list(self) -> list[platform_entities.UserGroup]:
return list(self._group_cache.values())
async def get_group_member_list(
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
raise NotSupportedError('get_group_member_list')
async def get_group_member_info(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> platform_entities.UserGroupMember:
user = self._user_cache.get(str(user_id))
if user is None:
raw = await self._request('GET', '/user/view', params={'user_id': str(user_id)})
data = raw.get('data') or {}
user = platform_entities.User(
id=str(data.get('id') or user_id),
nickname=str(data.get('nickname') or data.get('username') or ''),
username=data.get('username'),
avatar_url=data.get('avatar'),
is_bot=bool(data.get('bot', False)),
)
return platform_entities.UserGroupMember(
user=user,
group_id=str(group_id),
role=platform_entities.MemberRole.MEMBER,
display_name=user.nickname,
)
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
cached = self._user_cache.get(str(user_id))
if cached:
return cached
raw = await self._request('GET', '/user/view', params={'user_id': str(user_id)})
data = raw.get('data') or {}
return platform_entities.User(
id=str(data.get('id') or user_id),
nickname=str(data.get('nickname') or data.get('username') or ''),
username=data.get('username'),
avatar_url=data.get('avatar'),
is_bot=bool(data.get('bot', False)),
)
async def get_friend_list(self) -> list[platform_entities.User]:
return list(self._user_cache.values())
async def upload_file(self, file_data: bytes, filename: str) -> str:
data = {'file': file_data}
raw = await self._request('POST', '/asset/create', data=data, filename=filename)
result = raw.get('data') or {}
return str(result.get('url') or result.get('id') or '')
async def get_file_url(self, file_id: str) -> str:
return file_id
async def edit_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
new_content: platform_message.MessageChain,
) -> None:
raise NotSupportedError('edit_message')
async def delete_message(
self,
chat_type: str,
chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
) -> None:
endpoint = '/message/delete' if str(chat_type).lower() in {'group', 'channel'} else '/direct-message/delete'
await self._request('POST', endpoint, json={'msg_id': str(message_id)})
async def forward_message(
self,
from_chat_type: str,
from_chat_id: typing.Union[int, str],
message_id: typing.Union[int, str],
to_chat_type: str,
to_chat_id: typing.Union[int, str],
) -> platform_events.MessageResult:
cached = self._message_cache.get(str(message_id))
if cached is None:
raise NotSupportedError('forward_message:message_not_cached')
return await self.send_message(to_chat_type, str(to_chat_id), cached.message_chain)
async def mute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
duration: int = 0,
) -> None:
raise NotSupportedError('mute_member')
async def unmute_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
raise NotSupportedError('unmute_member')
async def kick_member(
self,
group_id: typing.Union[int, str],
user_id: typing.Union[int, str],
) -> None:
raise NotSupportedError('kick_member')
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
raise NotSupportedError('leave_group')

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
try:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
except ModuleNotFoundError:
class NotSupportedError(Exception):
def __init__(self, api_name: str, *args):
super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
self.api_name = api_name
__all__ = ['NotSupportedError']

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
import time
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
MESSAGE_TYPES = {1, 2, 4, 8, 9, 10}
@staticmethod
async def yiri2target(event: platform_events.Event):
raise NotImplementedError
@staticmethod
async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.Event | None:
event_type = int(kook_event.get('type', 0) or 0)
channel_type = kook_event.get('channel_type')
if event_type in KookEventConverter.MESSAGE_TYPES and channel_type in {'GROUP', 'PERSON'}:
return await KookEventConverter.message_to_eba(kook_event, bot_account_id)
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='kook',
action=str(kook_event.get('type') or 'gateway_event'),
data=KookEventConverter._compact_data(kook_event),
timestamp=KookEventConverter._timestamp(kook_event),
source_platform_object=kook_event,
)
@staticmethod
async def message_to_eba(kook_event: dict, bot_account_id: str = '') -> platform_events.MessageReceivedEvent:
channel_type = kook_event.get('channel_type')
author = KookEventConverter._author(kook_event)
chat_type = platform_entities.ChatType.PRIVATE if channel_type == 'PERSON' else platform_entities.ChatType.GROUP
chat_id = KookEventConverter._chat_id(kook_event)
group = None
if chat_type == platform_entities.ChatType.GROUP:
group = KookEventConverter._group(kook_event)
return platform_events.MessageReceivedEvent(
type='message.received',
adapter_name='kook',
message_id=str(kook_event.get('msg_id') or ''),
message_chain=await KookMessageConverter.target2yiri(kook_event, bot_account_id),
sender=author,
chat_type=chat_type,
chat_id=chat_id,
group=group,
timestamp=KookEventConverter._timestamp(kook_event),
source_platform_object=kook_event,
)
@staticmethod
async def target2legacy(
kook_event: dict, bot_account_id: str = ''
) -> platform_events.FriendMessage | platform_events.GroupMessage:
eba_event = await KookEventConverter.message_to_eba(kook_event, bot_account_id)
return eba_event.to_legacy_event()
@staticmethod
def _author(kook_event: dict) -> platform_entities.User:
extra = kook_event.get('extra') or {}
author = extra.get('author') or {}
user_id = str(kook_event.get('author_id') or author.get('id') or '')
return platform_entities.User(
id=user_id,
nickname=str(author.get('nickname') or author.get('username') or user_id),
username=author.get('username'),
avatar_url=author.get('avatar'),
is_bot=bool(author.get('bot', False)),
remark=user_id,
)
@staticmethod
def _group(kook_event: dict) -> platform_entities.UserGroup:
extra = kook_event.get('extra') or {}
return platform_entities.UserGroup(
id=str(kook_event.get('target_id') or ''),
name=str(extra.get('channel_name') or kook_event.get('target_id') or ''),
description=extra.get('guild_name'),
owner_id=extra.get('guild_id'),
)
@staticmethod
def _chat_id(kook_event: dict) -> str:
if kook_event.get('channel_type') == 'PERSON':
extra = kook_event.get('extra') or {}
return str(extra.get('code') or kook_event.get('author_id') or kook_event.get('target_id') or '')
return str(kook_event.get('target_id') or '')
@staticmethod
def _timestamp(kook_event: dict) -> float:
raw_timestamp = kook_event.get('msg_timestamp') or time.time()
timestamp = float(raw_timestamp)
if timestamp > 10_000_000_000:
timestamp = timestamp / 1000.0
return timestamp
@staticmethod
def _compact_data(kook_event: dict) -> dict:
return {
'type': kook_event.get('type'),
'channel_type': kook_event.get('channel_type'),
'target_id': kook_event.get('target_id'),
'author_id': kook_event.get('author_id'),
'msg_id': kook_event.get('msg_id'),
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,79 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: kook-eba
label:
en_US: KOOK (EBA)
zh_Hans: KOOK (EBA)
zh_Hant: KOOK (EBA)
description:
en_US: KOOK adapter (EBA architecture), supporting channel and direct messages.
zh_Hans: KOOK 适配器EBA 架构版本),支持频道消息和私聊消息。
zh_Hant: KOOK 適配器EBA 架構版本),支援頻道訊息和私聊訊息。
icon: kook.png
docs:
zh: https://link.langbot.app/zh/platforms/kook
en: https://link.langbot.app/en/platforms/kook
ja: https://link.langbot.app/ja/platforms/kook
spec:
categories:
- global
config:
- name: token
label:
en_US: Bot Token
zh_Hans: Bot Token
zh_Hant: Bot Token
type: string
required: true
default: ""
- name: enable-stream-reply
label:
en_US: Enable stream reply
zh_Hans: 启用流式回复
zh_Hant: 啟用串流回覆
type: boolean
required: true
default: false
supported_events:
- message.received
- platform.specific
supported_apis:
required:
- send_message
- reply_message
optional:
- get_message
- get_group_info
- get_group_list
- get_group_member_info
- get_user_info
- get_friend_list
- upload_file
- get_file_url
- delete_message
- forward_message
- call_platform_api
platform_specific_apis:
- action: get_current_user
description: { en_US: "Get current bot user", zh_Hans: "获取当前机器人用户" }
- action: get_user
description: { en_US: "Get user information", zh_Hans: "获取用户信息" }
- action: get_channel
description: { en_US: "Get channel information", zh_Hans: "获取频道信息" }
- action: get_guild
description: { en_US: "Get guild information", zh_Hans: "获取服务器信息" }
- action: get_gateway
description: { en_US: "Get WebSocket gateway URL", zh_Hans: "获取 WebSocket 网关地址" }
- action: send_direct_message
description: { en_US: "Send a direct KOOK message", zh_Hans: "发送 KOOK 私聊消息" }
execution:
python:
path: ./adapter.py
attr: KookAdapter

View File

@@ -0,0 +1,139 @@
from __future__ import annotations
import datetime
import re
from langbot_plugin.api.entities.builtin.platform import message as platform_message
MENTION_PATTERN = re.compile(r'(\(met\)(?P<met>[^()]+)\(met\)|\(rol\)(?P<role>[^()]+)\(rol\))')
class KookMessageConverter:
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain) -> tuple[str, int]:
content_parts: list[str] = []
message_type = 1
for component in message_chain:
if isinstance(component, platform_message.Source):
continue
if isinstance(component, platform_message.Plain):
content_parts.append(component.text)
elif isinstance(component, platform_message.At):
if component.target:
content_parts.append(f'(met){component.target}(met)')
elif isinstance(component, platform_message.AtAll):
content_parts.append('(met)all(met)')
elif isinstance(component, platform_message.Image):
if component.url:
content_parts.append(component.url)
message_type = 2
elif component.image_id:
content_parts.append(component.image_id)
message_type = 2
elif isinstance(component, platform_message.File):
if component.url:
content_parts.append(component.url)
message_type = 4
elif isinstance(component, platform_message.Voice):
if component.url:
content_parts.append(component.url)
message_type = 8
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
forward_content, _ = await KookMessageConverter.yiri2target(node.message_chain)
content_parts.append(forward_content)
return ''.join(content_parts), message_type
@staticmethod
async def target2yiri(kook_message: dict, bot_account_id: str = '') -> platform_message.MessageChain:
components: list[platform_message.MessageComponent] = []
msg_id = kook_message.get('msg_id') or kook_message.get('id') or ''
timestamp = KookMessageConverter._timestamp(kook_message.get('msg_timestamp'))
if msg_id:
components.append(platform_message.Source(id=str(msg_id), time=timestamp))
msg_type = int(kook_message.get('type', 1) or 1)
content = str(kook_message.get('content') or '')
extra = kook_message.get('extra') or {}
if msg_type in (1, 9):
components.extend(KookMessageConverter._parse_text_components(content, extra, bot_account_id))
elif msg_type == 2:
if content:
components.append(platform_message.Image(url=content))
elif msg_type == 4:
attachments = extra.get('attachments') or {}
components.append(
platform_message.File(
id=str(attachments.get('id') or ''),
name=str(attachments.get('name') or 'file'),
size=int(attachments.get('size') or 0),
url=content,
)
)
elif msg_type == 8:
attachments = extra.get('attachments') or {}
components.append(platform_message.Voice(url=content, length=int(attachments.get('duration') or 0)))
elif msg_type == 10:
components.append(platform_message.Unknown(text=content or '[KOOK card message]'))
else:
components.append(platform_message.Unknown(text=content or f'Unsupported KOOK message type: {msg_type}'))
if len(components) == 1 and isinstance(components[0], platform_message.Source):
components.append(platform_message.Plain(text=''))
return platform_message.MessageChain(components)
@staticmethod
def _parse_text_components(
content: str,
extra: dict,
bot_account_id: str,
) -> list[platform_message.MessageComponent]:
components: list[platform_message.MessageComponent] = []
mention_all = bool(extra.get('mention_all', False))
mentions = {str(item) for item in extra.get('mention', [])}
mention_roles = {str(item) for item in extra.get('mention_roles', [])}
last = 0
for match in MENTION_PATTERN.finditer(content):
if match.start() > last:
components.append(platform_message.Plain(text=content[last : match.start()]))
met = match.group('met')
role = match.group('role')
if met == 'all':
components.append(platform_message.AtAll())
elif met:
components.append(platform_message.At(target=met))
mentions.discard(str(met))
elif role:
mention_roles.discard(str(role))
if bot_account_id:
components.append(platform_message.At(target=bot_account_id))
last = match.end()
if last < len(content):
components.append(platform_message.Plain(text=content[last:]))
if mention_all and not any(isinstance(item, platform_message.AtAll) for item in components):
components.insert(0, platform_message.AtAll())
for mention_id in sorted(mentions):
components.insert(0, platform_message.At(target=mention_id))
if mention_roles and bot_account_id:
components.insert(0, platform_message.At(target=bot_account_id))
return components
@staticmethod
def _timestamp(raw_timestamp) -> datetime.datetime:
if raw_timestamp is None:
return datetime.datetime.now()
timestamp = float(raw_timestamp)
if timestamp > 10_000_000_000:
timestamp = timestamp / 1000.0
return datetime.datetime.fromtimestamp(timestamp)

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import typing
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
async def get_current_user(adapter, params: dict) -> dict:
return await adapter._request('GET', '/user/me')
async def get_user(adapter, params: dict) -> dict:
return await adapter._request('GET', '/user/view', params={'user_id': params['user_id']})
async def get_channel(adapter, params: dict) -> dict:
return await adapter._request('GET', '/channel/view', params={'target_id': params['target_id']})
async def get_guild(adapter, params: dict) -> dict:
return await adapter._request('GET', '/guild/view', params={'guild_id': params['guild_id']})
async def get_gateway(adapter, params: dict) -> dict:
raw = await adapter._request('GET', '/gateway/index', params={'compress': int(params.get('compress', 1))})
data = raw.get('data')
if isinstance(data, dict) and data.get('url'):
data = {**data, 'url': _redact_url_token(str(data['url']))}
raw = {**raw, 'data': data}
return raw
async def send_direct_message(adapter, params: dict) -> dict:
payload = {
'content': params['content'],
'type': params.get('type', 1),
}
if params.get('chat_code'):
payload['chat_code'] = params['chat_code']
else:
payload['target_id'] = params['target_id']
return await adapter._request('POST', '/direct-message/create', json=payload)
PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = {
'get_current_user': get_current_user,
'get_user': get_user,
'get_channel': get_channel,
'get_guild': get_guild,
'get_gateway': get_gateway,
'send_direct_message': send_direct_message,
}
def _redact_url_token(url: str) -> str:
parts = urlsplit(url)
query = urlencode(
[(key, '<redacted>' if key.lower() == 'token' else value) for key, value in parse_qsl(parts.query)],
doseq=True,
)
return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from enum import Enum
class KookChannelType(str, Enum):
GROUP = 'GROUP'
PERSON = 'PERSON'
class KookMessageType(int, Enum):
TEXT = 1
IMAGE = 2
FILE = 4
AUDIO = 8
KMARKDOWN = 9
CARD = 10

View File

@@ -0,0 +1,275 @@
from __future__ import annotations
import pathlib
from unittest.mock import AsyncMock
import pytest
import yaml
from langbot.pkg.platform.adapters.kook.adapter import KookAdapter
from langbot.pkg.platform.adapters.kook.event_converter import KookEventConverter
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
from langbot.pkg.platform.adapters.kook.platform_api import PLATFORM_API_MAP, get_gateway
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
class DummyLogger(AbstractEventLogger):
async def info(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
pass
async def error(self, text, images=None, message_session_id=None, no_throw=True):
pass
def make_adapter() -> KookAdapter:
return KookAdapter({'token': 'fake', 'enable-stream-reply': False}, DummyLogger())
def manifest() -> dict:
manifest_path = (
pathlib.Path(__file__).parents[3]
/ 'src'
/ 'langbot'
/ 'pkg'
/ 'platform'
/ 'adapters'
/ 'kook'
/ 'manifest.yaml'
)
return yaml.safe_load(manifest_path.read_text())
def fake_kook_message(**overrides):
event = {
'channel_type': 'GROUP',
'type': 9,
'author_id': 'u1',
'target_id': 'c1',
'msg_id': 'm1',
'msg_timestamp': 1_775_000_000_000,
'content': 'hi (met)u2(met) and (met)all(met)',
'extra': {
'channel_name': 'general',
'guild_id': 'g1',
'guild_name': 'Guild',
'author': {
'id': 'u1',
'username': 'alice',
'nickname': 'Alice',
'avatar': 'https://example/avatar.png',
},
'mention': ['u2'],
'mention_all': True,
},
}
event.update(overrides)
return event
def test_kook_supported_events_match_manifest():
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
def test_kook_supported_apis_match_manifest():
supported_apis = make_adapter().get_supported_apis()
manifest_apis = manifest()['spec']['supported_apis']
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
def test_kook_platform_api_map_matches_manifest():
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
assert set(PLATFORM_API_MAP) == manifest_actions
@pytest.mark.asyncio
async def test_kook_adapter_dispatches_most_specific_eba_listener():
adapter = make_adapter()
calls: list[str] = []
async def event_listener(event, adapter):
calls.append('event')
async def eba_listener(event, adapter):
calls.append('eba')
async def message_listener(event, adapter):
calls.append('message')
adapter.register_listener(platform_events.Event, event_listener)
adapter.register_listener(platform_events.EBAEvent, eba_listener)
adapter.register_listener(platform_events.MessageReceivedEvent, message_listener)
event = platform_events.MessageReceivedEvent(
message_id='m1',
message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]),
sender=platform_entities.User(id='u1'),
chat_id='c1',
)
await adapter._dispatch_eba_event(event)
assert calls == ['message']
@pytest.mark.asyncio
async def test_kook_message_converter_maps_target_text_mentions_and_source():
chain = await KookMessageConverter.target2yiri(fake_kook_message(), bot_account_id='bot')
assert isinstance(chain[0], platform_message.Source)
assert chain[0].id == 'm1'
assert isinstance(chain[1], platform_message.Plain)
assert chain[1].text == 'hi '
assert isinstance(chain[2], platform_message.At)
assert chain[2].target == 'u2'
assert isinstance(chain[4], platform_message.AtAll)
@pytest.mark.asyncio
async def test_kook_message_converter_maps_media_components():
image = await KookMessageConverter.target2yiri(fake_kook_message(type=2, content='https://example/image.png'))
assert isinstance(image[1], platform_message.Image)
assert image[1].url == 'https://example/image.png'
file_chain = await KookMessageConverter.target2yiri(
fake_kook_message(type=4, content='https://example/file.bin', extra={'attachments': {'name': 'file.bin'}})
)
assert isinstance(file_chain[1], platform_message.File)
assert file_chain[1].name == 'file.bin'
voice = await KookMessageConverter.target2yiri(fake_kook_message(type=8, content='https://example/voice.mp3'))
assert isinstance(voice[1], platform_message.Voice)
@pytest.mark.asyncio
async def test_kook_message_converter_maps_common_components_to_target():
content, msg_type = await KookMessageConverter.yiri2target(
platform_message.MessageChain(
[
platform_message.Plain(text='hi '),
platform_message.At(target='u1'),
platform_message.Plain(text=' all '),
platform_message.AtAll(),
]
)
)
assert content == 'hi (met)u1(met) all (met)all(met)'
assert msg_type == 1
@pytest.mark.asyncio
async def test_kook_event_converter_maps_group_private_and_platform_specific_events():
group_event = await KookEventConverter.target2yiri(fake_kook_message(), bot_account_id='bot')
assert isinstance(group_event, platform_events.MessageReceivedEvent)
assert group_event.type == 'message.received'
assert group_event.adapter_name == 'kook'
assert group_event.chat_type == platform_entities.ChatType.GROUP
assert group_event.chat_id == 'c1'
assert group_event.group.id == 'c1'
assert group_event.sender.id == 'u1'
private_event = await KookEventConverter.target2yiri(
fake_kook_message(channel_type='PERSON', target_id='u1', extra={'code': 'chat-code'}),
bot_account_id='bot',
)
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
assert private_event.chat_id == 'chat-code'
assert private_event.group is None
specific = await KookEventConverter.target2yiri({'type': 255, 'target_id': 'raw'}, bot_account_id='bot')
assert isinstance(specific, platform_events.PlatformSpecificEvent)
assert specific.action == '255'
@pytest.mark.asyncio
async def test_kook_event_converter_maps_legacy_events():
legacy_group = await KookEventConverter.target2legacy(fake_kook_message(), bot_account_id='bot')
assert isinstance(legacy_group, platform_events.GroupMessage)
assert legacy_group.sender.group.id == 'c1'
legacy_private = await KookEventConverter.target2legacy(
fake_kook_message(channel_type='PERSON', target_id='u1'),
bot_account_id='bot',
)
assert isinstance(legacy_private, platform_events.FriendMessage)
@pytest.mark.asyncio
async def test_kook_send_and_reply_pass_expected_payloads():
adapter = make_adapter()
adapter._request = AsyncMock(return_value={'code': 0, 'data': {'msg_id': 'sent'}})
result = await adapter.send_message(
'group',
'c1',
platform_message.MessageChain([platform_message.Plain(text='hello')]),
)
assert result.message_id == 'sent'
adapter._request.assert_awaited_with(
'POST',
'/message/create',
json={'target_id': 'c1', 'content': 'hello', 'type': 1},
)
source = await KookEventConverter.target2yiri(fake_kook_message(), bot_account_id='bot')
await adapter.reply_message(source, platform_message.MessageChain([platform_message.Plain(text='reply')]), True)
assert adapter._request.await_args_list[-1].args == ('POST', '/message/create')
payload = adapter._request.await_args_list[-1].kwargs['json']
assert payload['reply_msg_id'] == 'm1'
assert payload['quote'] == 'm1'
assert payload['content'] == 'reply'
@pytest.mark.asyncio
async def test_kook_get_gateway_redacts_token_in_platform_api_result():
adapter = make_adapter()
adapter._request = AsyncMock(
return_value={
'code': 0,
'data': {
'url': 'wss://example.invalid/gateway?compress=1&token=secret-token',
},
}
)
result = await get_gateway(adapter, {'compress': 1})
assert result['data']['url'] == 'wss://example.invalid/gateway?compress=1&token=%3Credacted%3E'
assert 'secret-token' not in result['data']['url']
@pytest.mark.asyncio
async def test_kook_handle_event_dispatches_eba_and_legacy_then_caches():
adapter = make_adapter()
adapter.bot_account_id = 'bot'
calls: list[str] = []
async def legacy_listener(event, adapter):
calls.append(type(event).__name__)
async def eba_listener(event, adapter):
calls.append(event.type)
adapter.register_listener(platform_events.GroupMessage, legacy_listener)
adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener)
await adapter._handle_event(fake_kook_message(), 7)
assert calls == ['GroupMessage', 'message.received']
assert adapter.current_sn == 7
assert 'm1' in adapter._message_cache
assert 'u1' in adapter._user_cache
assert 'c1' in adapter._group_cache