From 1f67ff2e8d9d88675cbef37d2c42ed6b007f362e Mon Sep 17 00:00:00 2001 From: wangcham Date: Thu, 4 Jun 2026 18:29:46 +0800 Subject: [PATCH] feat(kook): add eba adapter --- docs/event-based-agents/adapters/kook.md | 108 ++++++ .../pkg/platform/adapters/kook/__init__.py | 5 + .../pkg/platform/adapters/kook/adapter.py | 318 ++++++++++++++++++ .../pkg/platform/adapters/kook/api_impl.py | 211 ++++++++++++ .../pkg/platform/adapters/kook/errors.py | 13 + .../platform/adapters/kook/event_converter.py | 111 ++++++ .../pkg/platform/adapters/kook/kook.png | Bin 0 -> 14891 bytes .../pkg/platform/adapters/kook/manifest.yaml | 79 +++++ .../adapters/kook/message_converter.py | 139 ++++++++ .../platform/adapters/kook/platform_api.py | 60 ++++ .../pkg/platform/adapters/kook/types.py | 17 + .../platform/test_kook_eba_adapter.py | 275 +++++++++++++++ 12 files changed, 1336 insertions(+) create mode 100644 docs/event-based-agents/adapters/kook.md create mode 100644 src/langbot/pkg/platform/adapters/kook/__init__.py create mode 100644 src/langbot/pkg/platform/adapters/kook/adapter.py create mode 100644 src/langbot/pkg/platform/adapters/kook/api_impl.py create mode 100644 src/langbot/pkg/platform/adapters/kook/errors.py create mode 100644 src/langbot/pkg/platform/adapters/kook/event_converter.py create mode 100644 src/langbot/pkg/platform/adapters/kook/kook.png create mode 100644 src/langbot/pkg/platform/adapters/kook/manifest.yaml create mode 100644 src/langbot/pkg/platform/adapters/kook/message_converter.py create mode 100644 src/langbot/pkg/platform/adapters/kook/platform_api.py create mode 100644 src/langbot/pkg/platform/adapters/kook/types.py create mode 100644 tests/unit_tests/platform/test_kook_eba_adapter.py diff --git a/docs/event-based-agents/adapters/kook.md b/docs/event-based-agents/adapters/kook.md new file mode 100644 index 00000000..25a5e4ae --- /dev/null +++ b/docs/event-based-agents/adapters/kook.md @@ -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)(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. diff --git a/src/langbot/pkg/platform/adapters/kook/__init__.py b/src/langbot/pkg/platform/adapters/kook/__init__.py new file mode 100644 index 00000000..b740b995 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from langbot.pkg.platform.adapters.kook.adapter import KookAdapter + +__all__ = ['KookAdapter'] diff --git a/src/langbot/pkg/platform/adapters/kook/adapter.py b/src/langbot/pkg/platform/adapters/kook/adapter.py new file mode 100644 index 00000000..00eb3fc8 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/adapter.py @@ -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) diff --git a/src/langbot/pkg/platform/adapters/kook/api_impl.py b/src/langbot/pkg/platform/adapters/kook/api_impl.py new file mode 100644 index 00000000..1d48b36d --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/api_impl.py @@ -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') diff --git a/src/langbot/pkg/platform/adapters/kook/errors.py b/src/langbot/pkg/platform/adapters/kook/errors.py new file mode 100644 index 00000000..ad800e7d --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/errors.py @@ -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'] diff --git a/src/langbot/pkg/platform/adapters/kook/event_converter.py b/src/langbot/pkg/platform/adapters/kook/event_converter.py new file mode 100644 index 00000000..5f29defa --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/event_converter.py @@ -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'), + } diff --git a/src/langbot/pkg/platform/adapters/kook/kook.png b/src/langbot/pkg/platform/adapters/kook/kook.png new file mode 100644 index 0000000000000000000000000000000000000000..ba6ea15d87bc3de9a88784202814a321141f5b64 GIT binary patch literal 14891 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[^()]+)\(met\)|\(rol\)(?P[^()]+)\(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) diff --git a/src/langbot/pkg/platform/adapters/kook/platform_api.py b/src/langbot/pkg/platform/adapters/kook/platform_api.py new file mode 100644 index 00000000..af1b0625 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/platform_api.py @@ -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, '' 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)) diff --git a/src/langbot/pkg/platform/adapters/kook/types.py b/src/langbot/pkg/platform/adapters/kook/types.py new file mode 100644 index 00000000..ec437114 --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/types.py @@ -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 diff --git a/tests/unit_tests/platform/test_kook_eba_adapter.py b/tests/unit_tests/platform/test_kook_eba_adapter.py new file mode 100644 index 00000000..bc097acd --- /dev/null +++ b/tests/unit_tests/platform/test_kook_eba_adapter.py @@ -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