mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 12:56:02 +00:00
feat(kook): add eba adapter
This commit is contained in:
108
docs/event-based-agents/adapters/kook.md
Normal file
108
docs/event-based-agents/adapters/kook.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# KOOK EBA Adapter
|
||||
|
||||
## Status
|
||||
|
||||
KOOK has been migrated to the EBA adapter directory:
|
||||
|
||||
```text
|
||||
src/langbot/pkg/platform/adapters/kook/
|
||||
├── adapter.py
|
||||
├── api_impl.py
|
||||
├── event_converter.py
|
||||
├── manifest.yaml
|
||||
├── message_converter.py
|
||||
├── platform_api.py
|
||||
└── types.py
|
||||
```
|
||||
|
||||
The adapter is registered as `kook-eba`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `token` | Yes | `""` | KOOK bot token. |
|
||||
| `enable-stream-reply` | Yes | `false` | Reserved for shared platform configuration compatibility. |
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Evidence | Notes |
|
||||
|-------|----------|-------|
|
||||
| `message.received` | `plugin-e2e-ui` | Real KOOK UI channel message reached `EBAEventProbe` as `MessageReceivedEvent`. |
|
||||
| `platform.specific` | `plugin-e2e-ui` | KOOK gateway event without a common EBA mapping reached `EBAEventProbe` as `PlatformSpecificEventReceived`. |
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Evidence | Notes |
|
||||
|-----|----------|-------|
|
||||
| `send_message` | `plugin-e2e-outbound` | Probe plugin sent channel messages through SDK `send_message`; KOOK returned message IDs. |
|
||||
| `reply_message` | `unit` | Supports `reply_msg_id` and optional quoted replies when the source message ID is available. |
|
||||
| `get_message` | `plugin-e2e-outbound` | Probe plugin fetched the cached triggering message. |
|
||||
| `get_group_info` | `plugin-e2e-outbound` | Probe plugin received cached KOOK channel info. |
|
||||
| `get_group_list` | `plugin-e2e-outbound` | Probe plugin received cached channel/group entities observed by the adapter. |
|
||||
| `get_group_member_info` | `plugin-e2e-outbound` | Probe plugin received cached sender info as a group member. |
|
||||
| `get_user_info` | `plugin-e2e-outbound` | Probe plugin received cached sender user info. |
|
||||
| `get_friend_list` | `plugin-e2e-outbound` | Probe plugin received cached users. |
|
||||
| `upload_file` | `unit` | Uses KOOK `asset/create` and returns URL/ID. |
|
||||
| `get_file_url` | `unit` | KOOK media IDs are URL-like in the adapter path; returns the ID unchanged. |
|
||||
| `delete_message` | `unit` | Calls KOOK delete endpoints. Live permission verification is still required. |
|
||||
| `forward_message` | `plugin-e2e-outbound` | Probe plugin sent flattened forward content through SDK `send_message`. |
|
||||
| `call_platform_api` | `plugin-e2e-outbound` | Probe plugin called safe KOOK platform-specific APIs through SDK `call_platform_api`. |
|
||||
|
||||
## Platform-Specific APIs
|
||||
|
||||
| Action | Evidence | Notes |
|
||||
|--------|----------|-------|
|
||||
| `get_current_user` | `plugin-e2e-outbound` | Probe plugin called `user/me`. |
|
||||
| `get_user` | `plugin-e2e-outbound` | Probe plugin called `user/view` for the triggering sender. |
|
||||
| `get_channel` | `plugin-e2e-outbound` | Probe plugin called `channel/view` for the triggering channel. |
|
||||
| `get_guild` | `plugin-e2e-outbound` | Probe plugin called `guild/view`; gateway URLs redact token query values. |
|
||||
| `get_gateway` | `plugin-e2e-outbound` | Probe plugin called `gateway/index`; returned token query values are redacted. |
|
||||
| `send_direct_message` | `unit` | Calls `direct-message/create`. |
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Receive Evidence | Send Evidence | Notes |
|
||||
|-----------|------------------|---------------|-------|
|
||||
| `Source` | `plugin-e2e-ui` | N/A | KOOK message ID and timestamp are preserved. |
|
||||
| `Plain` | `plugin-e2e-ui` | `plugin-e2e-outbound` | Text and KMarkdown are represented as plain common text. |
|
||||
| `At` | `plugin-e2e-ui` | `plugin-e2e-outbound` | KOOK `(met)<id>(met)` mentions map to common `At`. |
|
||||
| `AtAll` | `unit` | `plugin-e2e-outbound` | KOOK `(met)all(met)` maps to common `AtAll`; real inbound UI AtAll was not tested. |
|
||||
| `Image` | `unit` | `unit` | URL/image ID based path only; live rendering still needs verification. |
|
||||
| `Voice` | `unit` | `unit` | URL based path only; live rendering still needs verification. |
|
||||
| `File` | `unit` | `unit` | URL based path only; upload API is exposed separately. |
|
||||
| `Forward` | `unit` | `unit` | Outbound forwards are flattened; inbound structured forwards are not exposed by current legacy implementation. |
|
||||
| `Unknown` | `unit` | N/A | Unsupported KOOK message types become `Unknown` or `PlatformSpecificEvent`. |
|
||||
|
||||
## Acceptance Record
|
||||
|
||||
Test date: June 4, 2026.
|
||||
|
||||
Plugin E2E verified on June 4, 2026 with `EBAEventProbe`, SDK standalone runtime, KOOK WebSocket adapter, and a real KOOK channel UI message.
|
||||
|
||||
Evidence:
|
||||
|
||||
- JSONL: `data/temp/kook_eba_plugin_probe.jsonl`
|
||||
- Plugin log: `data/logs/eba-probe-kook.log`
|
||||
|
||||
Observed and verified:
|
||||
|
||||
- A real KOOK UI channel message reached the plugin as `MessageReceived` with `bot_uuid=7ab5b065-6e4e-4def-95f0-3c265366e26f`, `adapter_name=kook`, common sender/group/chat fields, and common `MessageChain` components.
|
||||
- KOOK gateway-specific event reached the plugin as `PlatformSpecificEventReceived`.
|
||||
- Probe plugin called SDK `send_message`; KOOK returned message IDs for text, At, AtAll, image URL/base64 fallback path, quote fallback, file fallback, and flattened forward cases.
|
||||
- Probe plugin called common API methods through the SDK path: `get_message`, `get_user_info`, `get_friend_list`, `get_group_info`, `get_group_list`, and `get_group_member_info`.
|
||||
- Probe plugin called safe KOOK platform-specific APIs through SDK `call_platform_api`: `get_current_user`, `get_user`, `get_channel`, `get_gateway`, and `get_guild`.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/unit_tests/platform/test_kook_eba_adapter.py
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Blocked or partial items:
|
||||
|
||||
- `plugin-e2e-ui` inbound coverage for image, file, voice, AtAll, quote, and forward.
|
||||
- `plugin-e2e-outbound` visual verification in KOOK UI for image/file/voice rendering. KOOK returned message IDs, but UI inspection was not performed in this run.
|
||||
- `reply_message` and `delete_message` live permission verification.
|
||||
- Destructive or permission-sensitive APIs were not declared beyond delete; KOOK mute/kick/leave remain explicit `NotSupportedError` paths until a safe fixture is available.
|
||||
5
src/langbot/pkg/platform/adapters/kook/__init__.py
Normal file
5
src/langbot/pkg/platform/adapters/kook/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.platform.adapters.kook.adapter import KookAdapter
|
||||
|
||||
__all__ = ['KookAdapter']
|
||||
318
src/langbot/pkg/platform/adapters/kook/adapter.py
Normal file
318
src/langbot/pkg/platform/adapters/kook/adapter.py
Normal file
@@ -0,0 +1,318 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import traceback
|
||||
import typing
|
||||
import zlib
|
||||
|
||||
import aiohttp
|
||||
import pydantic
|
||||
import websockets
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from langbot.pkg.platform.adapters.kook.api_impl import KookAPIMixin
|
||||
from langbot.pkg.platform.adapters.kook.event_converter import KookEventConverter
|
||||
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
|
||||
from langbot.pkg.platform.adapters.kook.platform_api import PLATFORM_API_MAP
|
||||
from langbot.pkg.platform.adapters.kook.errors import NotSupportedError
|
||||
from langbot.pkg.utils import httpclient
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
BasePlatformAdapter = getattr(
|
||||
abstract_platform_adapter,
|
||||
'AbstractPlatformAdapter',
|
||||
abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
)
|
||||
|
||||
|
||||
class KookAdapter(KookAPIMixin, BasePlatformAdapter):
|
||||
message_converter: KookMessageConverter = KookMessageConverter()
|
||||
event_converter: KookEventConverter = KookEventConverter()
|
||||
|
||||
config: dict
|
||||
listeners: dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
ws: typing.Any = pydantic.Field(exclude=True, default=None)
|
||||
ws_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
|
||||
heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
|
||||
running: bool = pydantic.Field(exclude=True, default=False)
|
||||
session_id: str = pydantic.Field(exclude=True, default='')
|
||||
current_sn: int = pydantic.Field(exclude=True, default=0)
|
||||
gateway_url: str = pydantic.Field(exclude=True, default='')
|
||||
http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None)
|
||||
|
||||
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
|
||||
_user_cache: dict[str, platform_entities.User] = {}
|
||||
_group_cache: dict[str, platform_entities.UserGroup] = {}
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
if not config.get('token'):
|
||||
raise Exception('KOOK adapter requires "token" in config')
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot_account_id='',
|
||||
listeners={},
|
||||
running=False,
|
||||
session_id='',
|
||||
current_sn=0,
|
||||
gateway_url='',
|
||||
http_session=None,
|
||||
_message_cache={},
|
||||
_user_cache={},
|
||||
_group_cache={},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def get_supported_events(self) -> list[str]:
|
||||
return [
|
||||
'message.received',
|
||||
'platform.specific',
|
||||
]
|
||||
|
||||
def get_supported_apis(self) -> list[str]:
|
||||
return [
|
||||
'send_message',
|
||||
'reply_message',
|
||||
'get_message',
|
||||
'get_group_info',
|
||||
'get_group_list',
|
||||
'get_group_member_info',
|
||||
'get_user_info',
|
||||
'get_friend_list',
|
||||
'upload_file',
|
||||
'get_file_url',
|
||||
'delete_message',
|
||||
'forward_message',
|
||||
'call_platform_api',
|
||||
]
|
||||
|
||||
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
|
||||
handler = PLATFORM_API_MAP.get(action)
|
||||
if handler is None:
|
||||
raise NotSupportedError(f'call_platform_api:{action}')
|
||||
return await handler(self, params)
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
|
||||
None,
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
|
||||
None,
|
||||
],
|
||||
):
|
||||
registered = self.listeners.get(event_type)
|
||||
if registered is callback:
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def run_async(self):
|
||||
self.running = True
|
||||
self.http_session = httpclient.get_session()
|
||||
await self.logger.info('KOOK EBA adapter starting')
|
||||
|
||||
try:
|
||||
bot_info = await self._get_bot_user_info()
|
||||
self.bot_account_id = str(bot_info.get('id') or '')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to get KOOK bot user info: {e}')
|
||||
|
||||
self.ws_task = asyncio.create_task(self._websocket_loop())
|
||||
try:
|
||||
await self.ws_task
|
||||
finally:
|
||||
self.running = False
|
||||
|
||||
async def kill(self) -> bool:
|
||||
self.running = False
|
||||
for task in (self.heartbeat_task, self.ws_task):
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self.ws:
|
||||
await self.ws.close()
|
||||
await self.logger.info('KOOK EBA adapter stopped')
|
||||
return True
|
||||
|
||||
async def is_muted(self, group_id: int | None = None) -> bool:
|
||||
return False
|
||||
|
||||
async def _handle_hello(self, data: dict):
|
||||
self.session_id = str(data.get('session_id') or '')
|
||||
await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {self.session_id}')
|
||||
|
||||
async def _handle_event(self, data: dict, sn: int):
|
||||
self.current_sn = max(self.current_sn, sn)
|
||||
|
||||
event_type = int(data.get('type', 0) or 0)
|
||||
channel_type = data.get('channel_type')
|
||||
author_id = str(data.get('author_id') or '')
|
||||
is_message_event = event_type in KookEventConverter.MESSAGE_TYPES and channel_type in {'GROUP', 'PERSON'}
|
||||
|
||||
if is_message_event and self.bot_account_id and author_id == self.bot_account_id:
|
||||
return
|
||||
|
||||
try:
|
||||
if is_message_event and (
|
||||
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners
|
||||
):
|
||||
legacy_event = await self.event_converter.target2legacy(data, self.bot_account_id)
|
||||
callback = self.listeners.get(type(legacy_event))
|
||||
if callback:
|
||||
await callback(legacy_event, self)
|
||||
|
||||
eba_event = await self.event_converter.target2yiri(data, self.bot_account_id)
|
||||
if eba_event:
|
||||
self._cache_event(eba_event)
|
||||
await self._dispatch_eba_event(eba_event)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error handling KOOK event: {traceback.format_exc()}')
|
||||
|
||||
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
|
||||
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
|
||||
callback = self.listeners.get(event_type)
|
||||
if callback:
|
||||
await callback(event, self)
|
||||
return
|
||||
|
||||
def _cache_event(self, event: platform_events.Event):
|
||||
if not isinstance(event, platform_events.MessageReceivedEvent):
|
||||
return
|
||||
self._message_cache[str(event.message_id)] = event
|
||||
self._user_cache[str(event.sender.id)] = event.sender
|
||||
if event.group:
|
||||
self._group_cache[str(event.group.id)] = event.group
|
||||
|
||||
async def _websocket_loop(self):
|
||||
retry_count = 0
|
||||
max_retries = int(self.config.get('max_retries', 3))
|
||||
|
||||
while self.running and retry_count < max_retries:
|
||||
try:
|
||||
if not self.gateway_url:
|
||||
self.gateway_url = await self._get_gateway_url()
|
||||
|
||||
async with websockets.connect(self.gateway_url) as ws:
|
||||
self.ws = ws
|
||||
await self.logger.info('Connected to KOOK WebSocket')
|
||||
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0)
|
||||
hello_data = json.loads(self._decode_ws_message(hello_msg))
|
||||
if hello_data.get('s') != 1:
|
||||
raise Exception(f'Expected KOOK HELLO signal, got {hello_data.get("s")}')
|
||||
await self._handle_hello(hello_data.get('d') or {})
|
||||
retry_count = 0
|
||||
|
||||
async for message in ws:
|
||||
msg_data = json.loads(self._decode_ws_message(message))
|
||||
signal = msg_data.get('s')
|
||||
if signal == 0:
|
||||
await self._handle_event(msg_data.get('d') or {}, int(msg_data.get('sn') or 0))
|
||||
elif signal == 5:
|
||||
break
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
retry_count += 1
|
||||
await self.logger.warning('KOOK WebSocket connection closed, reconnecting')
|
||||
await asyncio.sleep(min(2**retry_count, 30))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
retry_count += 1
|
||||
await self.logger.error(f'KOOK WebSocket error: {traceback.format_exc()}')
|
||||
await asyncio.sleep(min(2**retry_count, 30))
|
||||
finally:
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
if retry_count >= max_retries:
|
||||
await self.logger.error(f'Failed to connect to KOOK after {max_retries} retries')
|
||||
|
||||
async def _heartbeat_loop(self):
|
||||
try:
|
||||
while self.running and self.ws:
|
||||
await asyncio.sleep(30)
|
||||
if self.ws:
|
||||
await self.ws.send(json.dumps({'s': 2, 'sn': self.current_sn}))
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
await self.logger.error(f'KOOK heartbeat error: {e}')
|
||||
|
||||
async def _get_gateway_url(self) -> str:
|
||||
raw = await self._request('GET', '/gateway/index', params={'compress': 1})
|
||||
return str(raw['data']['url'])
|
||||
|
||||
async def _get_bot_user_info(self) -> dict:
|
||||
raw = await self._request('GET', '/user/me')
|
||||
return raw.get('data') or {}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
params: dict | None = None,
|
||||
json: dict | None = None,
|
||||
data: dict | None = None,
|
||||
filename: str | None = None,
|
||||
) -> dict:
|
||||
session = self.http_session or httpclient.get_session()
|
||||
self.http_session = session
|
||||
url = f'https://www.kookapp.cn/api/v3{endpoint}'
|
||||
headers = {'Authorization': f'Bot {self.config["token"]}'}
|
||||
|
||||
request_kwargs: dict[str, typing.Any] = {'params': params, 'headers': headers}
|
||||
if json is not None:
|
||||
request_kwargs['json'] = json
|
||||
if data is not None and filename is not None:
|
||||
form = aiohttp.FormData()
|
||||
form.add_field('file', data['file'], filename=filename)
|
||||
request_kwargs['data'] = form
|
||||
elif data is not None:
|
||||
request_kwargs['data'] = data
|
||||
|
||||
async with session.request(method, url, **request_kwargs) as response:
|
||||
payload = await response.json(content_type=None)
|
||||
if response.status != 200:
|
||||
raise Exception(f'KOOK API HTTP {response.status}: {payload}')
|
||||
if payload.get('code') != 0:
|
||||
raise Exception(f'KOOK API error {payload.get("code")}: {payload.get("message")}')
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _decode_ws_message(message) -> str:
|
||||
if isinstance(message, bytes):
|
||||
try:
|
||||
return zlib.decompress(message).decode('utf-8')
|
||||
except Exception:
|
||||
return message.decode('utf-8')
|
||||
return str(message)
|
||||
211
src/langbot/pkg/platform/adapters/kook/api_impl.py
Normal file
211
src/langbot/pkg/platform/adapters/kook/api_impl.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
from langbot.pkg.platform.adapters.kook.errors import NotSupportedError
|
||||
|
||||
|
||||
class KookAPIMixin:
|
||||
_message_cache: dict[str, platform_events.MessageReceivedEvent]
|
||||
_user_cache: dict[str, platform_entities.User]
|
||||
_group_cache: dict[str, platform_entities.UserGroup]
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
) -> platform_events.MessageResult:
|
||||
content, msg_type = await KookMessageConverter.yiri2target(message)
|
||||
endpoint = '/message/create' if target_type.lower() in {'group', 'channel'} else '/direct-message/create'
|
||||
raw = await self._request(
|
||||
'POST',
|
||||
endpoint,
|
||||
json={
|
||||
'target_id': str(target_id),
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
},
|
||||
)
|
||||
data = raw.get('data') or {}
|
||||
return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
) -> platform_events.MessageResult:
|
||||
content, msg_type = await KookMessageConverter.yiri2target(message)
|
||||
kook_event = message_source.source_platform_object or {}
|
||||
channel_type = kook_event.get('channel_type')
|
||||
msg_id = kook_event.get('msg_id')
|
||||
|
||||
if channel_type == 'GROUP':
|
||||
endpoint = '/message/create'
|
||||
payload = {
|
||||
'target_id': str(kook_event.get('target_id') or message_source.chat_id),
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
else:
|
||||
endpoint = '/direct-message/create'
|
||||
extra = kook_event.get('extra') or {}
|
||||
payload = {
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
if extra.get('code'):
|
||||
payload['chat_code'] = extra['code']
|
||||
else:
|
||||
payload['target_id'] = str(kook_event.get('author_id') or message_source.chat_id)
|
||||
|
||||
if msg_id:
|
||||
payload['reply_msg_id'] = msg_id
|
||||
if quote_origin:
|
||||
payload['quote'] = msg_id
|
||||
|
||||
raw = await self._request('POST', endpoint, json=payload)
|
||||
data = raw.get('data') or {}
|
||||
return platform_events.MessageResult(message_id=data.get('msg_id'), raw=raw)
|
||||
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> platform_events.MessageReceivedEvent:
|
||||
event = self._message_cache.get(str(message_id))
|
||||
if event is None:
|
||||
raise NotSupportedError('get_message:message_not_cached')
|
||||
return event
|
||||
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
cached = self._group_cache.get(str(group_id))
|
||||
if cached:
|
||||
return cached
|
||||
raw = await self._request('GET', '/channel/view', params={'target_id': str(group_id)})
|
||||
data = raw.get('data') or {}
|
||||
return platform_entities.UserGroup(
|
||||
id=str(data.get('id') or group_id),
|
||||
name=str(data.get('name') or ''),
|
||||
member_count=data.get('user_count'),
|
||||
)
|
||||
|
||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||
return list(self._group_cache.values())
|
||||
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[platform_entities.UserGroupMember]:
|
||||
raise NotSupportedError('get_group_member_list')
|
||||
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> platform_entities.UserGroupMember:
|
||||
user = self._user_cache.get(str(user_id))
|
||||
if user is None:
|
||||
raw = await self._request('GET', '/user/view', params={'user_id': str(user_id)})
|
||||
data = raw.get('data') or {}
|
||||
user = platform_entities.User(
|
||||
id=str(data.get('id') or user_id),
|
||||
nickname=str(data.get('nickname') or data.get('username') or ''),
|
||||
username=data.get('username'),
|
||||
avatar_url=data.get('avatar'),
|
||||
is_bot=bool(data.get('bot', False)),
|
||||
)
|
||||
return platform_entities.UserGroupMember(
|
||||
user=user,
|
||||
group_id=str(group_id),
|
||||
role=platform_entities.MemberRole.MEMBER,
|
||||
display_name=user.nickname,
|
||||
)
|
||||
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
cached = self._user_cache.get(str(user_id))
|
||||
if cached:
|
||||
return cached
|
||||
raw = await self._request('GET', '/user/view', params={'user_id': str(user_id)})
|
||||
data = raw.get('data') or {}
|
||||
return platform_entities.User(
|
||||
id=str(data.get('id') or user_id),
|
||||
nickname=str(data.get('nickname') or data.get('username') or ''),
|
||||
username=data.get('username'),
|
||||
avatar_url=data.get('avatar'),
|
||||
is_bot=bool(data.get('bot', False)),
|
||||
)
|
||||
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
return list(self._user_cache.values())
|
||||
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
data = {'file': file_data}
|
||||
raw = await self._request('POST', '/asset/create', data=data, filename=filename)
|
||||
result = raw.get('data') or {}
|
||||
return str(result.get('url') or result.get('id') or '')
|
||||
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
return file_id
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
new_content: platform_message.MessageChain,
|
||||
) -> None:
|
||||
raise NotSupportedError('edit_message')
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
endpoint = '/message/delete' if str(chat_type).lower() in {'group', 'channel'} else '/direct-message/delete'
|
||||
await self._request('POST', endpoint, json={'msg_id': str(message_id)})
|
||||
|
||||
async def forward_message(
|
||||
self,
|
||||
from_chat_type: str,
|
||||
from_chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
to_chat_type: str,
|
||||
to_chat_id: typing.Union[int, str],
|
||||
) -> platform_events.MessageResult:
|
||||
cached = self._message_cache.get(str(message_id))
|
||||
if cached is None:
|
||||
raise NotSupportedError('forward_message:message_not_cached')
|
||||
return await self.send_message(to_chat_type, str(to_chat_id), cached.message_chain)
|
||||
|
||||
async def mute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
duration: int = 0,
|
||||
) -> None:
|
||||
raise NotSupportedError('mute_member')
|
||||
|
||||
async def unmute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
raise NotSupportedError('unmute_member')
|
||||
|
||||
async def kick_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
raise NotSupportedError('kick_member')
|
||||
|
||||
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||
raise NotSupportedError('leave_group')
|
||||
13
src/langbot/pkg/platform/adapters/kook/errors.py
Normal file
13
src/langbot/pkg/platform/adapters/kook/errors.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
except ModuleNotFoundError:
|
||||
|
||||
class NotSupportedError(Exception):
|
||||
def __init__(self, api_name: str, *args):
|
||||
super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
|
||||
self.api_name = api_name
|
||||
|
||||
|
||||
__all__ = ['NotSupportedError']
|
||||
111
src/langbot/pkg/platform/adapters/kook/event_converter.py
Normal file
111
src/langbot/pkg/platform/adapters/kook/event_converter.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
|
||||
|
||||
class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
MESSAGE_TYPES = {1, 2, 4, 8, 9, 10}
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.Event):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.Event | None:
|
||||
event_type = int(kook_event.get('type', 0) or 0)
|
||||
channel_type = kook_event.get('channel_type')
|
||||
if event_type in KookEventConverter.MESSAGE_TYPES and channel_type in {'GROUP', 'PERSON'}:
|
||||
return await KookEventConverter.message_to_eba(kook_event, bot_account_id)
|
||||
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='kook',
|
||||
action=str(kook_event.get('type') or 'gateway_event'),
|
||||
data=KookEventConverter._compact_data(kook_event),
|
||||
timestamp=KookEventConverter._timestamp(kook_event),
|
||||
source_platform_object=kook_event,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def message_to_eba(kook_event: dict, bot_account_id: str = '') -> platform_events.MessageReceivedEvent:
|
||||
channel_type = kook_event.get('channel_type')
|
||||
author = KookEventConverter._author(kook_event)
|
||||
chat_type = platform_entities.ChatType.PRIVATE if channel_type == 'PERSON' else platform_entities.ChatType.GROUP
|
||||
chat_id = KookEventConverter._chat_id(kook_event)
|
||||
group = None
|
||||
if chat_type == platform_entities.ChatType.GROUP:
|
||||
group = KookEventConverter._group(kook_event)
|
||||
|
||||
return platform_events.MessageReceivedEvent(
|
||||
type='message.received',
|
||||
adapter_name='kook',
|
||||
message_id=str(kook_event.get('msg_id') or ''),
|
||||
message_chain=await KookMessageConverter.target2yiri(kook_event, bot_account_id),
|
||||
sender=author,
|
||||
chat_type=chat_type,
|
||||
chat_id=chat_id,
|
||||
group=group,
|
||||
timestamp=KookEventConverter._timestamp(kook_event),
|
||||
source_platform_object=kook_event,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def target2legacy(
|
||||
kook_event: dict, bot_account_id: str = ''
|
||||
) -> platform_events.FriendMessage | platform_events.GroupMessage:
|
||||
eba_event = await KookEventConverter.message_to_eba(kook_event, bot_account_id)
|
||||
return eba_event.to_legacy_event()
|
||||
|
||||
@staticmethod
|
||||
def _author(kook_event: dict) -> platform_entities.User:
|
||||
extra = kook_event.get('extra') or {}
|
||||
author = extra.get('author') or {}
|
||||
user_id = str(kook_event.get('author_id') or author.get('id') or '')
|
||||
return platform_entities.User(
|
||||
id=user_id,
|
||||
nickname=str(author.get('nickname') or author.get('username') or user_id),
|
||||
username=author.get('username'),
|
||||
avatar_url=author.get('avatar'),
|
||||
is_bot=bool(author.get('bot', False)),
|
||||
remark=user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _group(kook_event: dict) -> platform_entities.UserGroup:
|
||||
extra = kook_event.get('extra') or {}
|
||||
return platform_entities.UserGroup(
|
||||
id=str(kook_event.get('target_id') or ''),
|
||||
name=str(extra.get('channel_name') or kook_event.get('target_id') or ''),
|
||||
description=extra.get('guild_name'),
|
||||
owner_id=extra.get('guild_id'),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _chat_id(kook_event: dict) -> str:
|
||||
if kook_event.get('channel_type') == 'PERSON':
|
||||
extra = kook_event.get('extra') or {}
|
||||
return str(extra.get('code') or kook_event.get('author_id') or kook_event.get('target_id') or '')
|
||||
return str(kook_event.get('target_id') or '')
|
||||
|
||||
@staticmethod
|
||||
def _timestamp(kook_event: dict) -> float:
|
||||
raw_timestamp = kook_event.get('msg_timestamp') or time.time()
|
||||
timestamp = float(raw_timestamp)
|
||||
if timestamp > 10_000_000_000:
|
||||
timestamp = timestamp / 1000.0
|
||||
return timestamp
|
||||
|
||||
@staticmethod
|
||||
def _compact_data(kook_event: dict) -> dict:
|
||||
return {
|
||||
'type': kook_event.get('type'),
|
||||
'channel_type': kook_event.get('channel_type'),
|
||||
'target_id': kook_event.get('target_id'),
|
||||
'author_id': kook_event.get('author_id'),
|
||||
'msg_id': kook_event.get('msg_id'),
|
||||
}
|
||||
BIN
src/langbot/pkg/platform/adapters/kook/kook.png
Normal file
BIN
src/langbot/pkg/platform/adapters/kook/kook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
79
src/langbot/pkg/platform/adapters/kook/manifest.yaml
Normal file
79
src/langbot/pkg/platform/adapters/kook/manifest.yaml
Normal file
@@ -0,0 +1,79 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: kook-eba
|
||||
label:
|
||||
en_US: KOOK (EBA)
|
||||
zh_Hans: KOOK (EBA)
|
||||
zh_Hant: KOOK (EBA)
|
||||
description:
|
||||
en_US: KOOK adapter (EBA architecture), supporting channel and direct messages.
|
||||
zh_Hans: KOOK 适配器(EBA 架构版本),支持频道消息和私聊消息。
|
||||
zh_Hant: KOOK 適配器(EBA 架構版本),支援頻道訊息和私聊訊息。
|
||||
icon: kook.png
|
||||
docs:
|
||||
zh: https://link.langbot.app/zh/platforms/kook
|
||||
en: https://link.langbot.app/en/platforms/kook
|
||||
ja: https://link.langbot.app/ja/platforms/kook
|
||||
|
||||
spec:
|
||||
categories:
|
||||
- global
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
en_US: Bot Token
|
||||
zh_Hans: Bot Token
|
||||
zh_Hant: Bot Token
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: enable-stream-reply
|
||||
label:
|
||||
en_US: Enable stream reply
|
||||
zh_Hans: 启用流式回复
|
||||
zh_Hant: 啟用串流回覆
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
supported_events:
|
||||
- message.received
|
||||
- platform.specific
|
||||
|
||||
supported_apis:
|
||||
required:
|
||||
- send_message
|
||||
- reply_message
|
||||
optional:
|
||||
- get_message
|
||||
- get_group_info
|
||||
- get_group_list
|
||||
- get_group_member_info
|
||||
- get_user_info
|
||||
- get_friend_list
|
||||
- upload_file
|
||||
- get_file_url
|
||||
- delete_message
|
||||
- forward_message
|
||||
- call_platform_api
|
||||
|
||||
platform_specific_apis:
|
||||
- action: get_current_user
|
||||
description: { en_US: "Get current bot user", zh_Hans: "获取当前机器人用户" }
|
||||
- action: get_user
|
||||
description: { en_US: "Get user information", zh_Hans: "获取用户信息" }
|
||||
- action: get_channel
|
||||
description: { en_US: "Get channel information", zh_Hans: "获取频道信息" }
|
||||
- action: get_guild
|
||||
description: { en_US: "Get guild information", zh_Hans: "获取服务器信息" }
|
||||
- action: get_gateway
|
||||
description: { en_US: "Get WebSocket gateway URL", zh_Hans: "获取 WebSocket 网关地址" }
|
||||
- action: send_direct_message
|
||||
description: { en_US: "Send a direct KOOK message", zh_Hans: "发送 KOOK 私聊消息" }
|
||||
|
||||
execution:
|
||||
python:
|
||||
path: ./adapter.py
|
||||
attr: KookAdapter
|
||||
139
src/langbot/pkg/platform/adapters/kook/message_converter.py
Normal file
139
src/langbot/pkg/platform/adapters/kook/message_converter.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
|
||||
MENTION_PATTERN = re.compile(r'(\(met\)(?P<met>[^()]+)\(met\)|\(rol\)(?P<role>[^()]+)\(rol\))')
|
||||
|
||||
|
||||
class KookMessageConverter:
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> tuple[str, int]:
|
||||
content_parts: list[str] = []
|
||||
message_type = 1
|
||||
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Source):
|
||||
continue
|
||||
if isinstance(component, platform_message.Plain):
|
||||
content_parts.append(component.text)
|
||||
elif isinstance(component, platform_message.At):
|
||||
if component.target:
|
||||
content_parts.append(f'(met){component.target}(met)')
|
||||
elif isinstance(component, platform_message.AtAll):
|
||||
content_parts.append('(met)all(met)')
|
||||
elif isinstance(component, platform_message.Image):
|
||||
if component.url:
|
||||
content_parts.append(component.url)
|
||||
message_type = 2
|
||||
elif component.image_id:
|
||||
content_parts.append(component.image_id)
|
||||
message_type = 2
|
||||
elif isinstance(component, platform_message.File):
|
||||
if component.url:
|
||||
content_parts.append(component.url)
|
||||
message_type = 4
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
if component.url:
|
||||
content_parts.append(component.url)
|
||||
message_type = 8
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
if node.message_chain:
|
||||
forward_content, _ = await KookMessageConverter.yiri2target(node.message_chain)
|
||||
content_parts.append(forward_content)
|
||||
|
||||
return ''.join(content_parts), message_type
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(kook_message: dict, bot_account_id: str = '') -> platform_message.MessageChain:
|
||||
components: list[platform_message.MessageComponent] = []
|
||||
|
||||
msg_id = kook_message.get('msg_id') or kook_message.get('id') or ''
|
||||
timestamp = KookMessageConverter._timestamp(kook_message.get('msg_timestamp'))
|
||||
if msg_id:
|
||||
components.append(platform_message.Source(id=str(msg_id), time=timestamp))
|
||||
|
||||
msg_type = int(kook_message.get('type', 1) or 1)
|
||||
content = str(kook_message.get('content') or '')
|
||||
extra = kook_message.get('extra') or {}
|
||||
|
||||
if msg_type in (1, 9):
|
||||
components.extend(KookMessageConverter._parse_text_components(content, extra, bot_account_id))
|
||||
elif msg_type == 2:
|
||||
if content:
|
||||
components.append(platform_message.Image(url=content))
|
||||
elif msg_type == 4:
|
||||
attachments = extra.get('attachments') or {}
|
||||
components.append(
|
||||
platform_message.File(
|
||||
id=str(attachments.get('id') or ''),
|
||||
name=str(attachments.get('name') or 'file'),
|
||||
size=int(attachments.get('size') or 0),
|
||||
url=content,
|
||||
)
|
||||
)
|
||||
elif msg_type == 8:
|
||||
attachments = extra.get('attachments') or {}
|
||||
components.append(platform_message.Voice(url=content, length=int(attachments.get('duration') or 0)))
|
||||
elif msg_type == 10:
|
||||
components.append(platform_message.Unknown(text=content or '[KOOK card message]'))
|
||||
else:
|
||||
components.append(platform_message.Unknown(text=content or f'Unsupported KOOK message type: {msg_type}'))
|
||||
|
||||
if len(components) == 1 and isinstance(components[0], platform_message.Source):
|
||||
components.append(platform_message.Plain(text=''))
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
@staticmethod
|
||||
def _parse_text_components(
|
||||
content: str,
|
||||
extra: dict,
|
||||
bot_account_id: str,
|
||||
) -> list[platform_message.MessageComponent]:
|
||||
components: list[platform_message.MessageComponent] = []
|
||||
mention_all = bool(extra.get('mention_all', False))
|
||||
mentions = {str(item) for item in extra.get('mention', [])}
|
||||
mention_roles = {str(item) for item in extra.get('mention_roles', [])}
|
||||
|
||||
last = 0
|
||||
for match in MENTION_PATTERN.finditer(content):
|
||||
if match.start() > last:
|
||||
components.append(platform_message.Plain(text=content[last : match.start()]))
|
||||
met = match.group('met')
|
||||
role = match.group('role')
|
||||
if met == 'all':
|
||||
components.append(platform_message.AtAll())
|
||||
elif met:
|
||||
components.append(platform_message.At(target=met))
|
||||
mentions.discard(str(met))
|
||||
elif role:
|
||||
mention_roles.discard(str(role))
|
||||
if bot_account_id:
|
||||
components.append(platform_message.At(target=bot_account_id))
|
||||
last = match.end()
|
||||
|
||||
if last < len(content):
|
||||
components.append(platform_message.Plain(text=content[last:]))
|
||||
|
||||
if mention_all and not any(isinstance(item, platform_message.AtAll) for item in components):
|
||||
components.insert(0, platform_message.AtAll())
|
||||
for mention_id in sorted(mentions):
|
||||
components.insert(0, platform_message.At(target=mention_id))
|
||||
if mention_roles and bot_account_id:
|
||||
components.insert(0, platform_message.At(target=bot_account_id))
|
||||
|
||||
return components
|
||||
|
||||
@staticmethod
|
||||
def _timestamp(raw_timestamp) -> datetime.datetime:
|
||||
if raw_timestamp is None:
|
||||
return datetime.datetime.now()
|
||||
timestamp = float(raw_timestamp)
|
||||
if timestamp > 10_000_000_000:
|
||||
timestamp = timestamp / 1000.0
|
||||
return datetime.datetime.fromtimestamp(timestamp)
|
||||
60
src/langbot/pkg/platform/adapters/kook/platform_api.py
Normal file
60
src/langbot/pkg/platform/adapters/kook/platform_api.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
|
||||
async def get_current_user(adapter, params: dict) -> dict:
|
||||
return await adapter._request('GET', '/user/me')
|
||||
|
||||
|
||||
async def get_user(adapter, params: dict) -> dict:
|
||||
return await adapter._request('GET', '/user/view', params={'user_id': params['user_id']})
|
||||
|
||||
|
||||
async def get_channel(adapter, params: dict) -> dict:
|
||||
return await adapter._request('GET', '/channel/view', params={'target_id': params['target_id']})
|
||||
|
||||
|
||||
async def get_guild(adapter, params: dict) -> dict:
|
||||
return await adapter._request('GET', '/guild/view', params={'guild_id': params['guild_id']})
|
||||
|
||||
|
||||
async def get_gateway(adapter, params: dict) -> dict:
|
||||
raw = await adapter._request('GET', '/gateway/index', params={'compress': int(params.get('compress', 1))})
|
||||
data = raw.get('data')
|
||||
if isinstance(data, dict) and data.get('url'):
|
||||
data = {**data, 'url': _redact_url_token(str(data['url']))}
|
||||
raw = {**raw, 'data': data}
|
||||
return raw
|
||||
|
||||
|
||||
async def send_direct_message(adapter, params: dict) -> dict:
|
||||
payload = {
|
||||
'content': params['content'],
|
||||
'type': params.get('type', 1),
|
||||
}
|
||||
if params.get('chat_code'):
|
||||
payload['chat_code'] = params['chat_code']
|
||||
else:
|
||||
payload['target_id'] = params['target_id']
|
||||
return await adapter._request('POST', '/direct-message/create', json=payload)
|
||||
|
||||
|
||||
PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable[dict]]] = {
|
||||
'get_current_user': get_current_user,
|
||||
'get_user': get_user,
|
||||
'get_channel': get_channel,
|
||||
'get_guild': get_guild,
|
||||
'get_gateway': get_gateway,
|
||||
'send_direct_message': send_direct_message,
|
||||
}
|
||||
|
||||
|
||||
def _redact_url_token(url: str) -> str:
|
||||
parts = urlsplit(url)
|
||||
query = urlencode(
|
||||
[(key, '<redacted>' if key.lower() == 'token' else value) for key, value in parse_qsl(parts.query)],
|
||||
doseq=True,
|
||||
)
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
|
||||
17
src/langbot/pkg/platform/adapters/kook/types.py
Normal file
17
src/langbot/pkg/platform/adapters/kook/types.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class KookChannelType(str, Enum):
|
||||
GROUP = 'GROUP'
|
||||
PERSON = 'PERSON'
|
||||
|
||||
|
||||
class KookMessageType(int, Enum):
|
||||
TEXT = 1
|
||||
IMAGE = 2
|
||||
FILE = 4
|
||||
AUDIO = 8
|
||||
KMARKDOWN = 9
|
||||
CARD = 10
|
||||
275
tests/unit_tests/platform/test_kook_eba_adapter.py
Normal file
275
tests/unit_tests/platform/test_kook_eba_adapter.py
Normal file
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from langbot.pkg.platform.adapters.kook.adapter import KookAdapter
|
||||
from langbot.pkg.platform.adapters.kook.event_converter import KookEventConverter
|
||||
from langbot.pkg.platform.adapters.kook.message_converter import KookMessageConverter
|
||||
from langbot.pkg.platform.adapters.kook.platform_api import PLATFORM_API_MAP, get_gateway
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
||||
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
|
||||
class DummyLogger(AbstractEventLogger):
|
||||
async def info(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
pass
|
||||
|
||||
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
pass
|
||||
|
||||
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
pass
|
||||
|
||||
async def error(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
pass
|
||||
|
||||
|
||||
def make_adapter() -> KookAdapter:
|
||||
return KookAdapter({'token': 'fake', 'enable-stream-reply': False}, DummyLogger())
|
||||
|
||||
|
||||
def manifest() -> dict:
|
||||
manifest_path = (
|
||||
pathlib.Path(__file__).parents[3]
|
||||
/ 'src'
|
||||
/ 'langbot'
|
||||
/ 'pkg'
|
||||
/ 'platform'
|
||||
/ 'adapters'
|
||||
/ 'kook'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(manifest_path.read_text())
|
||||
|
||||
|
||||
def fake_kook_message(**overrides):
|
||||
event = {
|
||||
'channel_type': 'GROUP',
|
||||
'type': 9,
|
||||
'author_id': 'u1',
|
||||
'target_id': 'c1',
|
||||
'msg_id': 'm1',
|
||||
'msg_timestamp': 1_775_000_000_000,
|
||||
'content': 'hi (met)u2(met) and (met)all(met)',
|
||||
'extra': {
|
||||
'channel_name': 'general',
|
||||
'guild_id': 'g1',
|
||||
'guild_name': 'Guild',
|
||||
'author': {
|
||||
'id': 'u1',
|
||||
'username': 'alice',
|
||||
'nickname': 'Alice',
|
||||
'avatar': 'https://example/avatar.png',
|
||||
},
|
||||
'mention': ['u2'],
|
||||
'mention_all': True,
|
||||
},
|
||||
}
|
||||
event.update(overrides)
|
||||
return event
|
||||
|
||||
|
||||
def test_kook_supported_events_match_manifest():
|
||||
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
|
||||
|
||||
|
||||
def test_kook_supported_apis_match_manifest():
|
||||
supported_apis = make_adapter().get_supported_apis()
|
||||
manifest_apis = manifest()['spec']['supported_apis']
|
||||
|
||||
assert supported_apis == manifest_apis['required'] + manifest_apis['optional']
|
||||
|
||||
|
||||
def test_kook_platform_api_map_matches_manifest():
|
||||
manifest_actions = {item['action'] for item in manifest()['spec']['platform_specific_apis']}
|
||||
|
||||
assert set(PLATFORM_API_MAP) == manifest_actions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kook_adapter_dispatches_most_specific_eba_listener():
|
||||
adapter = make_adapter()
|
||||
calls: list[str] = []
|
||||
|
||||
async def event_listener(event, adapter):
|
||||
calls.append('event')
|
||||
|
||||
async def eba_listener(event, adapter):
|
||||
calls.append('eba')
|
||||
|
||||
async def message_listener(event, adapter):
|
||||
calls.append('message')
|
||||
|
||||
adapter.register_listener(platform_events.Event, event_listener)
|
||||
adapter.register_listener(platform_events.EBAEvent, eba_listener)
|
||||
adapter.register_listener(platform_events.MessageReceivedEvent, message_listener)
|
||||
|
||||
event = platform_events.MessageReceivedEvent(
|
||||
message_id='m1',
|
||||
message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]),
|
||||
sender=platform_entities.User(id='u1'),
|
||||
chat_id='c1',
|
||||
)
|
||||
|
||||
await adapter._dispatch_eba_event(event)
|
||||
|
||||
assert calls == ['message']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kook_message_converter_maps_target_text_mentions_and_source():
|
||||
chain = await KookMessageConverter.target2yiri(fake_kook_message(), bot_account_id='bot')
|
||||
|
||||
assert isinstance(chain[0], platform_message.Source)
|
||||
assert chain[0].id == 'm1'
|
||||
assert isinstance(chain[1], platform_message.Plain)
|
||||
assert chain[1].text == 'hi '
|
||||
assert isinstance(chain[2], platform_message.At)
|
||||
assert chain[2].target == 'u2'
|
||||
assert isinstance(chain[4], platform_message.AtAll)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kook_message_converter_maps_media_components():
|
||||
image = await KookMessageConverter.target2yiri(fake_kook_message(type=2, content='https://example/image.png'))
|
||||
assert isinstance(image[1], platform_message.Image)
|
||||
assert image[1].url == 'https://example/image.png'
|
||||
|
||||
file_chain = await KookMessageConverter.target2yiri(
|
||||
fake_kook_message(type=4, content='https://example/file.bin', extra={'attachments': {'name': 'file.bin'}})
|
||||
)
|
||||
assert isinstance(file_chain[1], platform_message.File)
|
||||
assert file_chain[1].name == 'file.bin'
|
||||
|
||||
voice = await KookMessageConverter.target2yiri(fake_kook_message(type=8, content='https://example/voice.mp3'))
|
||||
assert isinstance(voice[1], platform_message.Voice)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kook_message_converter_maps_common_components_to_target():
|
||||
content, msg_type = await KookMessageConverter.yiri2target(
|
||||
platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='hi '),
|
||||
platform_message.At(target='u1'),
|
||||
platform_message.Plain(text=' all '),
|
||||
platform_message.AtAll(),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert content == 'hi (met)u1(met) all (met)all(met)'
|
||||
assert msg_type == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kook_event_converter_maps_group_private_and_platform_specific_events():
|
||||
group_event = await KookEventConverter.target2yiri(fake_kook_message(), bot_account_id='bot')
|
||||
assert isinstance(group_event, platform_events.MessageReceivedEvent)
|
||||
assert group_event.type == 'message.received'
|
||||
assert group_event.adapter_name == 'kook'
|
||||
assert group_event.chat_type == platform_entities.ChatType.GROUP
|
||||
assert group_event.chat_id == 'c1'
|
||||
assert group_event.group.id == 'c1'
|
||||
assert group_event.sender.id == 'u1'
|
||||
|
||||
private_event = await KookEventConverter.target2yiri(
|
||||
fake_kook_message(channel_type='PERSON', target_id='u1', extra={'code': 'chat-code'}),
|
||||
bot_account_id='bot',
|
||||
)
|
||||
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
|
||||
assert private_event.chat_id == 'chat-code'
|
||||
assert private_event.group is None
|
||||
|
||||
specific = await KookEventConverter.target2yiri({'type': 255, 'target_id': 'raw'}, bot_account_id='bot')
|
||||
assert isinstance(specific, platform_events.PlatformSpecificEvent)
|
||||
assert specific.action == '255'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kook_event_converter_maps_legacy_events():
|
||||
legacy_group = await KookEventConverter.target2legacy(fake_kook_message(), bot_account_id='bot')
|
||||
assert isinstance(legacy_group, platform_events.GroupMessage)
|
||||
assert legacy_group.sender.group.id == 'c1'
|
||||
|
||||
legacy_private = await KookEventConverter.target2legacy(
|
||||
fake_kook_message(channel_type='PERSON', target_id='u1'),
|
||||
bot_account_id='bot',
|
||||
)
|
||||
assert isinstance(legacy_private, platform_events.FriendMessage)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kook_send_and_reply_pass_expected_payloads():
|
||||
adapter = make_adapter()
|
||||
adapter._request = AsyncMock(return_value={'code': 0, 'data': {'msg_id': 'sent'}})
|
||||
|
||||
result = await adapter.send_message(
|
||||
'group',
|
||||
'c1',
|
||||
platform_message.MessageChain([platform_message.Plain(text='hello')]),
|
||||
)
|
||||
|
||||
assert result.message_id == 'sent'
|
||||
adapter._request.assert_awaited_with(
|
||||
'POST',
|
||||
'/message/create',
|
||||
json={'target_id': 'c1', 'content': 'hello', 'type': 1},
|
||||
)
|
||||
|
||||
source = await KookEventConverter.target2yiri(fake_kook_message(), bot_account_id='bot')
|
||||
await adapter.reply_message(source, platform_message.MessageChain([platform_message.Plain(text='reply')]), True)
|
||||
|
||||
assert adapter._request.await_args_list[-1].args == ('POST', '/message/create')
|
||||
payload = adapter._request.await_args_list[-1].kwargs['json']
|
||||
assert payload['reply_msg_id'] == 'm1'
|
||||
assert payload['quote'] == 'm1'
|
||||
assert payload['content'] == 'reply'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kook_get_gateway_redacts_token_in_platform_api_result():
|
||||
adapter = make_adapter()
|
||||
adapter._request = AsyncMock(
|
||||
return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'url': 'wss://example.invalid/gateway?compress=1&token=secret-token',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
result = await get_gateway(adapter, {'compress': 1})
|
||||
|
||||
assert result['data']['url'] == 'wss://example.invalid/gateway?compress=1&token=%3Credacted%3E'
|
||||
assert 'secret-token' not in result['data']['url']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kook_handle_event_dispatches_eba_and_legacy_then_caches():
|
||||
adapter = make_adapter()
|
||||
adapter.bot_account_id = 'bot'
|
||||
calls: list[str] = []
|
||||
|
||||
async def legacy_listener(event, adapter):
|
||||
calls.append(type(event).__name__)
|
||||
|
||||
async def eba_listener(event, adapter):
|
||||
calls.append(event.type)
|
||||
|
||||
adapter.register_listener(platform_events.GroupMessage, legacy_listener)
|
||||
adapter.register_listener(platform_events.MessageReceivedEvent, eba_listener)
|
||||
|
||||
await adapter._handle_event(fake_kook_message(), 7)
|
||||
|
||||
assert calls == ['GroupMessage', 'message.received']
|
||||
assert adapter.current_sn == 7
|
||||
assert 'm1' in adapter._message_cache
|
||||
assert 'u1' in adapter._user_cache
|
||||
assert 'c1' in adapter._group_cache
|
||||
Reference in New Issue
Block a user