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 00000000..ba6ea15d Binary files /dev/null and b/src/langbot/pkg/platform/adapters/kook/kook.png differ diff --git a/src/langbot/pkg/platform/adapters/kook/manifest.yaml b/src/langbot/pkg/platform/adapters/kook/manifest.yaml new file mode 100644 index 00000000..a0c23c0a --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/manifest.yaml @@ -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 diff --git a/src/langbot/pkg/platform/adapters/kook/message_converter.py b/src/langbot/pkg/platform/adapters/kook/message_converter.py new file mode 100644 index 00000000..b494b75d --- /dev/null +++ b/src/langbot/pkg/platform/adapters/kook/message_converter.py @@ -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\)|\(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