mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 20:14:36 +00:00
feat: migrate aiocqhttp adapter to eba
This commit is contained in:
@@ -9,10 +9,13 @@ This directory records adapter-level migration details for the Event-Based Agent
|
||||
|
||||
## Adapter Documents
|
||||
|
||||
General acceptance checklist: [EBA Adapter Acceptance Checklist](./acceptance-checklist.md)
|
||||
|
||||
| Adapter | Status | Document |
|
||||
|---------|--------|----------|
|
||||
| Telegram | Migrated and live-tested | [Telegram](./telegram.md) |
|
||||
| Discord | Migrated and live-tested | [Discord](./discord.md) |
|
||||
| OneBot v11 / aiocqhttp | Migrated; Matcha-tested where supported | [OneBot v11 / aiocqhttp](./aiocqhttp.md) |
|
||||
|
||||
## Documentation Checklist
|
||||
|
||||
|
||||
195
docs/event-based-agents/adapters/acceptance-checklist.md
Normal file
195
docs/event-based-agents/adapters/acceptance-checklist.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# EBA Adapter Acceptance Checklist
|
||||
|
||||
This checklist is the architecture-level acceptance standard for every Event-Based Agents platform adapter. It is not platform-specific. Adapter migration is not complete until the adapter has a written result against this checklist.
|
||||
|
||||
## Evidence Levels
|
||||
|
||||
Use these evidence levels consistently in adapter records:
|
||||
|
||||
| Level | Meaning | Can Mark Complete |
|
||||
|-------|---------|-------------------|
|
||||
| `plugin-e2e` | Real SDK plugin running through standalone runtime, LangBot core, the migrated adapter, and a real or simulator platform endpoint. | Yes |
|
||||
| `adapter-live` | Direct adapter probe connected to a real or simulator platform endpoint, bypassing plugin runtime. | No, auxiliary only |
|
||||
| `unit` | Unit/API-shape tests with mocked platform SDK objects or mocked APIs. | No, auxiliary only |
|
||||
| `not-supported` | Platform protocol or SDK has no equivalent capability. Must include reason and source. | Yes, as explicitly unsupported |
|
||||
| `blocked` | Intended capability could not be verified because of credentials, permissions, endpoint gaps, or simulator gaps. | No |
|
||||
|
||||
The primary acceptance path must be `plugin-e2e`. `adapter-live` and `unit` tests are useful, but they do not prove the EBA architecture path.
|
||||
|
||||
## Required Architecture Path
|
||||
|
||||
Every adapter must prove this full path:
|
||||
|
||||
```text
|
||||
Real platform / simulator UI
|
||||
-> platform SDK native event
|
||||
-> adapter event converter
|
||||
-> unified EBA event/entity/message types
|
||||
-> LangBot core event dispatch
|
||||
-> standalone SDK runtime
|
||||
-> real test plugin listener
|
||||
-> plugin calls platform APIs through SDK
|
||||
-> LangBot core API dispatch
|
||||
-> adapter API implementation
|
||||
-> real platform / simulator UI
|
||||
```
|
||||
|
||||
The test plugin must record JSONL evidence containing:
|
||||
|
||||
- event class and `event.type`
|
||||
- adapter name
|
||||
- chat type and chat ID
|
||||
- sender/user/group IDs with secrets redacted
|
||||
- message component list for received messages
|
||||
- API action name, input summary, result or error
|
||||
- raw unsupported/blocked reason when an item is skipped
|
||||
|
||||
## Required Message Receive Tests
|
||||
|
||||
For every adapter, inbound message conversion must be tested through `plugin-e2e` for each component the platform can receive. If the platform cannot create a component from the UI/simulator, record it as `blocked` with the endpoint limitation.
|
||||
|
||||
| Component | Required Receive Assertion |
|
||||
|-----------|----------------------------|
|
||||
| `Source` | Message ID and timestamp are present and stable enough for reply/get/delete APIs. |
|
||||
| `Plain` | Text is preserved exactly, including spaces and multi-line content. |
|
||||
| `At` | Mentioned user ID is converted to common `At.target`. |
|
||||
| `AtAll` | Broadcast mention is converted to common `AtAll`, if platform supports it. |
|
||||
| `Image` | Image ID, URL, path, or base64 is represented without leaking platform-native segment shape. |
|
||||
| `Voice` | Voice/audio component is represented as `Voice` when the platform exposes it. |
|
||||
| `File` | File name, ID/URL, and size are represented as `File` when available. |
|
||||
| `Quote` | Reply/quote source ID and origin content are represented when the platform exposes it. |
|
||||
| `Face` | Native emoji/sticker/dice/rps-like components are represented as `Face` or documented as platform-specific. |
|
||||
| `Forward` | Merged/forwarded messages are represented as `Forward` when the platform exposes structured content. |
|
||||
| `Unknown` | Unsupported native segments become `Unknown` or `PlatformSpecificEvent` data, not crashes. |
|
||||
| Mixed chain | A message containing multiple component types preserves order. |
|
||||
|
||||
The plugin must subscribe to `MessageReceivedEvent` and assert that `message_chain` contains common `langbot_plugin.api.entities.builtin.platform.message` components, not platform-native SDK objects.
|
||||
|
||||
## Required Message Send Tests
|
||||
|
||||
For every adapter, outbound message conversion must be tested through `plugin-e2e` by having the plugin call SDK platform APIs and verifying the platform UI/simulator receives the expected message.
|
||||
|
||||
| Component | Required Send Assertion |
|
||||
|-----------|-------------------------|
|
||||
| `Plain` | Text appears exactly on the platform. |
|
||||
| `At` | User mention renders as a mention or platform equivalent. |
|
||||
| `AtAll` | Broadcast mention renders or is explicitly unsupported. |
|
||||
| `Image` | URL, path, or base64 image sends and renders/downloads correctly. |
|
||||
| `Voice` | Voice/audio sends when supported. |
|
||||
| `File` | File sends with name and content/link when supported. |
|
||||
| `Quote` | Quoted reply points to the original message when supported. |
|
||||
| `Face` | Native emoji/sticker/dice/rps sends or is explicitly unsupported. |
|
||||
| `Forward` | Forward/merged-forward sends when supported; otherwise fallback behavior is documented. |
|
||||
| Mixed chain | A mixed chain preserves component order as closely as the platform allows. |
|
||||
|
||||
If a platform supports a component only in one direction, the adapter record must say so explicitly.
|
||||
|
||||
## Required Event Tests
|
||||
|
||||
The plugin must subscribe to every event declared in `manifest.yaml -> spec.supported_events` and record one of `plugin-e2e`, `not-supported`, or `blocked`.
|
||||
|
||||
| Event | Required Assertion |
|
||||
|-------|--------------------|
|
||||
| `message.received` | Real message reaches plugin as `MessageReceivedEvent`. |
|
||||
| `message.edited` | Edited message reaches plugin with message ID and new content, if declared. |
|
||||
| `message.deleted` | Deleted/recalled message reaches plugin with message ID and operator when available, if declared. |
|
||||
| `message.reaction` | Reaction add/remove reaches plugin with message ID, user, reaction, and direction, if declared. |
|
||||
| `feedback.received` | Feedback payload reaches plugin with feedback type and message/session IDs, if declared. |
|
||||
| `group.member_joined` | Join event reaches plugin with group and member. |
|
||||
| `group.member_left` | Leave/kick event reaches plugin with group, member, and kick flag. |
|
||||
| `group.member_banned` | Mute/ban event reaches plugin with group, member, operator, and duration. |
|
||||
| `group.info_updated` | Group metadata update reaches plugin with changed fields, if declared. |
|
||||
| `friend.request_received` | Friend request reaches plugin with request ID and message. |
|
||||
| `friend.added` | Friend-added event reaches plugin. |
|
||||
| `friend.removed` | Friend-removed event reaches plugin, if declared. |
|
||||
| `bot.invited_to_group` | Bot invite/join request reaches plugin with group and inviter/request ID. |
|
||||
| `bot.removed_from_group` | Bot removal reaches plugin with group and operator when available. |
|
||||
| `bot.muted` | Bot mute reaches plugin with duration. |
|
||||
| `bot.unmuted` | Bot unmute reaches plugin. |
|
||||
| `platform.specific` | At least one unmapped native event is delivered as structured platform-specific data, if declared. |
|
||||
|
||||
Do not declare an event in the manifest unless there is an implementation path and an acceptance entry.
|
||||
|
||||
## Required Common API Tests
|
||||
|
||||
The plugin must call every common API declared in `manifest.yaml -> spec.supported_apis.required` and `optional`. Each call must be recorded with input summary and result.
|
||||
|
||||
| API | Required Assertion |
|
||||
|-----|--------------------|
|
||||
| `send_message` | Plugin sends to private and group/channel targets where supported. |
|
||||
| `reply_message` | Plugin replies to the triggering message, with quoted mode tested when supported. |
|
||||
| `edit_message` | Plugin edits a bot-sent message, if declared. |
|
||||
| `delete_message` | Plugin deletes/recalls a bot-sent message, if declared and permissions allow. |
|
||||
| `forward_message` | Plugin forwards or emulates forwarding a real message, if declared. |
|
||||
| `get_message` | Plugin retrieves a real message and receives common `MessageReceivedEvent` shape. |
|
||||
| `get_group_info` | Plugin receives `UserGroup` with ID/name/count where available. |
|
||||
| `get_group_list` | Plugin receives joined groups/channels list where supported. |
|
||||
| `get_group_member_list` | Plugin receives list of `UserGroupMember` where supported. |
|
||||
| `get_group_member_info` | Plugin receives one member with role/display name where available. |
|
||||
| `set_group_name` | Plugin changes and restores a disposable group name, if declared. |
|
||||
| `mute_member` | Plugin mutes a disposable target, if declared. |
|
||||
| `unmute_member` | Plugin unmutes the same target, if declared. |
|
||||
| `kick_member` | Plugin kicks a disposable target only in destructive test mode, if declared. |
|
||||
| `leave_group` | Plugin leaves only in destructive test mode and only at the end, if declared. |
|
||||
| `get_user_info` | Plugin receives common `User` shape. |
|
||||
| `get_friend_list` | Plugin receives friend/contact list where supported. |
|
||||
| `approve_friend_request` | Plugin accepts/rejects a disposable friend request, if declared. |
|
||||
| `approve_group_invite` | Plugin accepts/rejects a disposable group invite, if declared. |
|
||||
| `upload_file` | Plugin uploads a real small file, if declared. |
|
||||
| `get_file_url` | Plugin resolves a real file ID to a URL, if declared. |
|
||||
| `call_platform_api` | Plugin calls every declared platform-specific action with safe parameters. |
|
||||
|
||||
Destructive APIs must be opt-in and documented with the exact target used.
|
||||
|
||||
## Platform-Specific API Tests
|
||||
|
||||
Every action listed in `manifest.yaml -> spec.platform_specific_apis` must have one acceptance entry:
|
||||
|
||||
- `plugin-e2e`: called by the plugin against the live/simulator endpoint.
|
||||
- `not-supported`: removed from manifest or explained if the platform SDK exposes it but this adapter intentionally does not.
|
||||
- `blocked`: endpoint did not implement it, permissions missing, or safe fixture unavailable.
|
||||
|
||||
Do not leave a platform-specific API in the manifest without a corresponding test record.
|
||||
|
||||
## Required Compatibility Tests
|
||||
|
||||
Each migrated adapter must also prove:
|
||||
|
||||
- Manifest supported events match `adapter.get_supported_events()`.
|
||||
- Manifest supported APIs match `adapter.get_supported_apis()`.
|
||||
- Manifest platform-specific actions match `PLATFORM_API_MAP`.
|
||||
- Legacy `FriendMessage` / `GroupMessage` listeners still work when the core registers them.
|
||||
- EBA listener dispatch prefers the most specific event class, then `EBAEvent`, then base `Event`.
|
||||
- Self-message filtering prevents bot echo loops without dropping edit/delete/moderation events needed for API tests.
|
||||
- `source_platform_object` is present for reply/debug but not required by plugins for common behavior.
|
||||
|
||||
## Required Documentation Per Adapter
|
||||
|
||||
Each adapter document must include:
|
||||
|
||||
- adapter directory and manifest name
|
||||
- config table
|
||||
- supported event table with evidence level per event
|
||||
- supported common API table with evidence level per API
|
||||
- platform-specific API table with evidence level per action
|
||||
- receive component table with evidence level per component
|
||||
- send component table with evidence level per component
|
||||
- exact test date
|
||||
- exact platform endpoint or simulator used
|
||||
- standalone runtime command
|
||||
- plugin path/name used for testing
|
||||
- evidence JSONL path
|
||||
- destructive operations performed or explicitly skipped
|
||||
- blocked items and reasons
|
||||
|
||||
## Acceptance Rule
|
||||
|
||||
An adapter can be marked migrated only when:
|
||||
|
||||
1. All declared events have `plugin-e2e` or `not-supported` evidence.
|
||||
2. All declared APIs have `plugin-e2e` or `not-supported` evidence.
|
||||
3. All platform-supported receive/send message components have `plugin-e2e` evidence.
|
||||
4. Unit tests cover conversion and API-shape boundaries.
|
||||
5. The adapter document lists every blocked or skipped item honestly.
|
||||
|
||||
If any declared capability is only covered by `adapter-live` or `unit`, the adapter status must remain partial.
|
||||
137
docs/event-based-agents/adapters/aiocqhttp.md
Normal file
137
docs/event-based-agents/adapters/aiocqhttp.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# OneBot v11 / aiocqhttp EBA Adapter
|
||||
|
||||
## Status
|
||||
|
||||
OneBot v11 has been migrated to the EBA adapter directory:
|
||||
|
||||
```text
|
||||
src/langbot/pkg/platform/adapters/aiocqhttp/
|
||||
├── adapter.py
|
||||
├── api_impl.py
|
||||
├── event_converter.py
|
||||
├── manifest.yaml
|
||||
├── message_converter.py
|
||||
├── platform_api.py
|
||||
├── types.py
|
||||
└── onebot.svg
|
||||
```
|
||||
|
||||
The EBA adapter is registered as `aiocqhttp-eba`. The legacy adapter remains at `src/langbot/pkg/platform/sources/aiocqhttp.py`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `host` | Yes | `0.0.0.0` | Host for the reverse WebSocket server that the OneBot endpoint connects to. |
|
||||
| `port` | Yes | `2280` | Reverse WebSocket listen port. |
|
||||
| `access-token` | No | `""` | OneBot access token, if the endpoint is configured to use one. |
|
||||
|
||||
## Events
|
||||
|
||||
The adapter declares these EBA events:
|
||||
|
||||
- `message.received`
|
||||
- `message.deleted`
|
||||
- `group.member_joined`
|
||||
- `group.member_left`
|
||||
- `group.member_banned`
|
||||
- `friend.request_received`
|
||||
- `friend.added`
|
||||
- `bot.invited_to_group`
|
||||
- `bot.removed_from_group`
|
||||
- `bot.muted`
|
||||
- `bot.unmuted`
|
||||
- `platform.specific`
|
||||
|
||||
`platform.specific` is used for OneBot notice/request/meta events that do not yet have a common EBA event type, such as group admin changes, group file uploads, pokes, honor changes, and group join requests from non-bot users.
|
||||
|
||||
## Common APIs
|
||||
|
||||
| API | Status | Notes |
|
||||
|-----|--------|-------|
|
||||
| `send_message` | Supported | Supports private and group text, mentions, images, voice, files, faces, and flattened forwards. Group merged forwards are sent through OneBot forward APIs when possible. |
|
||||
| `reply_message` | Supported | Uses the original OneBot event and can prepend a reply segment. |
|
||||
| `edit_message` | Not supported | OneBot v11 has no standard message edit action. |
|
||||
| `delete_message` | Supported | Uses `delete_msg`; permission depends on endpoint and group role. |
|
||||
| `forward_message` | Supported | Emulates forward by fetching the source message with `get_msg` and sending its content to the target chat. |
|
||||
| `get_message` | Supported | Uses `get_msg` and converts the response into `MessageReceivedEvent`. |
|
||||
| `get_group_info` | Supported | Uses `get_group_info`. |
|
||||
| `get_group_list` | Supported | Uses `get_group_list`. |
|
||||
| `get_group_member_list` | Supported | Uses `get_group_member_list`. |
|
||||
| `get_group_member_info` | Supported | Uses `get_group_member_info`. |
|
||||
| `set_group_name` | Supported | Uses `set_group_name`; may be unsupported by mock endpoints. |
|
||||
| `get_user_info` | Supported | Uses `get_stranger_info`. |
|
||||
| `get_friend_list` | Supported | Uses `get_friend_list`. |
|
||||
| `approve_friend_request` | Supported | Uses `set_friend_add_request`. |
|
||||
| `approve_group_invite` | Supported | Uses `set_group_add_request` with `sub_type=invite`. |
|
||||
| `upload_file` | Not supported | OneBot v11 has endpoint-specific file upload extensions but no portable standalone upload action. |
|
||||
| `get_file_url` | Not supported | OneBot v11 file URL resolution is endpoint-specific. Use `call_platform_api("get_image")`, `get_record`, or endpoint extensions when available. |
|
||||
| `mute_member` | Supported | Uses `set_group_ban`. |
|
||||
| `unmute_member` | Supported | Uses `set_group_ban` with duration `0`. |
|
||||
| `kick_member` | Supported | Destructive; test only with disposable members. |
|
||||
| `leave_group` | Supported | Destructive; should run last in live tests. |
|
||||
| `call_platform_api` | Supported | See below. |
|
||||
|
||||
## Platform-Specific APIs
|
||||
|
||||
`call_platform_api(action, params)` supports:
|
||||
|
||||
- `get_login_info`
|
||||
- `get_status`
|
||||
- `get_version_info`
|
||||
- `get_group_honor_info`
|
||||
- `set_group_card`
|
||||
- `set_group_special_title`
|
||||
- `set_group_admin`
|
||||
- `set_group_whole_ban`
|
||||
- `send_group_forward_msg`
|
||||
- `get_forward_msg`
|
||||
- `get_record`
|
||||
- `get_image`
|
||||
- `can_send_image`
|
||||
- `can_send_record`
|
||||
|
||||
## Message Conversion Notes
|
||||
|
||||
Incoming OneBot segments are converted into common `MessageChain` components before LangBot core/plugin dispatch:
|
||||
|
||||
- `text` -> `Plain`
|
||||
- `at` -> `At` / `AtAll`
|
||||
- `image` -> `Image` or `Face` for OneBot emoji-package images
|
||||
- `record` -> `Voice`
|
||||
- `file` -> `File`
|
||||
- `reply` -> `Quote`
|
||||
- `face`, `rps`, `dice` -> `Face`
|
||||
- unsupported segments -> `Unknown`
|
||||
|
||||
Outgoing `MessageChain` components are converted back into `aiocqhttp.Message` segments. Base64 media strings are normalized to OneBot `base64://...` format.
|
||||
|
||||
## Live Test Record
|
||||
|
||||
The direct live probe is:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=/Users/qinjunyan/code/projects/langbot/langbot-plugin-sdk/src \
|
||||
uv run python tests/e2e/live_aiocqhttp_eba_probe.py --host 127.0.0.1 --port 2280
|
||||
```
|
||||
|
||||
It starts the reverse WebSocket adapter directly, records observed EBA events to `data/temp/aiocqhttp_eba_live_probe.jsonl`, waits for a real Matcha or OneBot message, then tries reply/send/get/delete/group/user/platform API calls as far as the endpoint supports them.
|
||||
|
||||
Verified on May 10, 2026 with local Matcha connected to `ws://127.0.0.1:2280/ws`:
|
||||
|
||||
- Real inbound group message converted to `MessageReceivedEvent`.
|
||||
- Real lifecycle connection converted to `PlatformSpecificEvent`.
|
||||
- Real reply API succeeded and rendered a quoted bot reply in Matcha.
|
||||
- Real proactive send API succeeded and rendered a bot group message in Matcha.
|
||||
- Real outgoing component sweep succeeded for text, `At`, `AtAll`, `Face`, and base64 `Image`.
|
||||
- Real `get_message`, `get_group_info`, `get_login_info`, `get_status`, `get_version_info`, `can_send_image`, and `can_send_record` calls succeeded against Matcha.
|
||||
- Unit conversion and API-shape tests passed for `Plain`, `At`, `AtAll`, `Image`, `Voice`, `File`, `Quote`, `Face`, `rps`, `dice`, `Forward`, `Unknown`, private/group message events, delete notices, group join/leave/ban notices, bot mute notices, friend requests, group invites, friend added notices, dispatch specificity, send, reply, delete, forward, get message, group APIs, user APIs, request approval APIs, moderation APIs, leave group, unsupported file APIs, and all declared `call_platform_api` actions.
|
||||
|
||||
Skipped or residual live-test items:
|
||||
|
||||
- `edit_message`: not implemented because OneBot v11 has no standard edit action.
|
||||
- `upload_file` and `get_file_url`: not implemented as common APIs because portable OneBot v11 file upload/download URL semantics are endpoint-specific.
|
||||
- `kick_member` and `leave_group`: destructive; run only with explicit `--destructive` and disposable Matcha/OneBot state.
|
||||
- `group.info_updated`, message reactions, and message edits are not declared because OneBot v11 does not provide standard equivalents for them.
|
||||
- Matcha returned `ActionFailed` for outgoing `File` segment rendering and did not support merged-forward actions in this run. The adapter keeps the conversion/API implementations because they are valid OneBot/NapCat-style capabilities, but the Matcha live probe records them as skipped.
|
||||
- Matcha returned an empty `get_group_member_list` for the test group, so `get_group_member_info`, mute/unmute, kick, and leave were covered by unit/API-shape tests only in this run.
|
||||
5
src/langbot/pkg/platform/adapters/aiocqhttp/__init__.py
Normal file
5
src/langbot/pkg/platform/adapters/aiocqhttp/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter
|
||||
|
||||
__all__ = ['AiocqhttpAdapter']
|
||||
172
src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py
Normal file
172
src/langbot/pkg/platform/adapters/aiocqhttp/adapter.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
import pydantic
|
||||
|
||||
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.aiocqhttp.api_impl import AiocqhttpAPIMixin
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.platform_api import PLATFORM_API_MAP
|
||||
from langbot_plugin.api.entities.builtin.platform import events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
|
||||
class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
|
||||
bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True)
|
||||
|
||||
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
|
||||
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()
|
||||
|
||||
config: dict
|
||||
listeners: dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
run_config = dict(config)
|
||||
|
||||
async def shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
run_config['shutdown_trigger'] = shutdown_trigger_placeholder
|
||||
access_token = run_config.pop('access-token', '') or None
|
||||
bot = aiocqhttp.CQHttp(access_token=access_token)
|
||||
|
||||
super().__init__(
|
||||
config=run_config,
|
||||
logger=logger,
|
||||
bot=bot,
|
||||
bot_account_id='',
|
||||
listeners={},
|
||||
)
|
||||
self._register_native_handlers()
|
||||
|
||||
def get_supported_events(self) -> list[str]:
|
||||
return [
|
||||
'message.received',
|
||||
'message.deleted',
|
||||
'group.member_joined',
|
||||
'group.member_left',
|
||||
'group.member_banned',
|
||||
'friend.request_received',
|
||||
'friend.added',
|
||||
'bot.invited_to_group',
|
||||
'bot.removed_from_group',
|
||||
'bot.muted',
|
||||
'bot.unmuted',
|
||||
'platform.specific',
|
||||
]
|
||||
|
||||
def get_supported_apis(self) -> list[str]:
|
||||
return [
|
||||
'send_message',
|
||||
'reply_message',
|
||||
'delete_message',
|
||||
'forward_message',
|
||||
'get_message',
|
||||
'get_group_info',
|
||||
'get_group_list',
|
||||
'get_group_member_list',
|
||||
'get_group_member_info',
|
||||
'set_group_name',
|
||||
'get_user_info',
|
||||
'get_friend_list',
|
||||
'approve_friend_request',
|
||||
'approve_group_invite',
|
||||
'mute_member',
|
||||
'unmute_member',
|
||||
'kick_member',
|
||||
'leave_group',
|
||||
'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.bot, 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):
|
||||
await self.bot._server_app.run_task(**self.config)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
return False
|
||||
|
||||
def _register_native_handlers(self):
|
||||
@self.bot.on_message()
|
||||
async def on_message(event: aiocqhttp.Event):
|
||||
await self._handle_native_event(event)
|
||||
|
||||
@self.bot.on_notice()
|
||||
async def on_notice(event: aiocqhttp.Event):
|
||||
await self._handle_native_event(event)
|
||||
|
||||
@self.bot.on_request()
|
||||
async def on_request(event: aiocqhttp.Event):
|
||||
await self._handle_native_event(event)
|
||||
|
||||
@self.bot.on_websocket_connection
|
||||
async def on_websocket_connection(event: aiocqhttp.Event):
|
||||
self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id)
|
||||
await self.logger.info(f'WebSocket connection established, bot id: {self.bot_account_id}')
|
||||
await self._dispatch_native_event(event)
|
||||
|
||||
async def _handle_native_event(self, event: aiocqhttp.Event):
|
||||
self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id)
|
||||
if getattr(event, 'type', None) == 'message' and str(getattr(event, 'user_id', '')) == self.bot_account_id:
|
||||
return
|
||||
try:
|
||||
if getattr(event, 'type', None) == 'message' and (
|
||||
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners
|
||||
):
|
||||
legacy_event = await self.event_converter.target2legacy(event, self.bot)
|
||||
if legacy_event:
|
||||
callback = self.listeners.get(type(legacy_event))
|
||||
if callback:
|
||||
await callback(legacy_event, self)
|
||||
await self._dispatch_native_event(event)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in aiocqhttp native event: {traceback.format_exc()}')
|
||||
|
||||
async def _dispatch_native_event(self, event: aiocqhttp.Event):
|
||||
eba_event = await self.event_converter.target2yiri(event, self.bot, self.bot_account_id)
|
||||
if eba_event:
|
||||
await self._dispatch_eba_event(eba_event)
|
||||
|
||||
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
|
||||
238
src/langbot/pkg/platform/adapters/aiocqhttp/api_impl.py
Normal file
238
src/langbot/pkg/platform/adapters/aiocqhttp/api_impl.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
|
||||
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_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
|
||||
class AiocqhttpAPIMixin:
|
||||
bot: aiocqhttp.CQHttp
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
) -> platform_events.MessageResult:
|
||||
forward = message.get_first(platform_message.Forward)
|
||||
if forward and target_type == 'group':
|
||||
raw = await self._send_forward_message(int(target_id), typing.cast(platform_message.Forward, forward))
|
||||
return platform_events.MessageResult(message_id=raw.get('message_id'), raw=raw)
|
||||
|
||||
aiocq_msg, _, _ = await AiocqhttpMessageConverter.yiri2target(message)
|
||||
if target_type == 'group':
|
||||
raw = await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg)
|
||||
elif target_type in ('person', 'private'):
|
||||
raw = await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg)
|
||||
else:
|
||||
raise ValueError(f'Unsupported aiocqhttp target_type: {target_type}')
|
||||
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
) -> platform_events.MessageResult:
|
||||
assert isinstance(message_source.source_platform_object, aiocqhttp.Event)
|
||||
aiocq_msg, _, _ = await AiocqhttpMessageConverter.yiri2target(message)
|
||||
if quote_origin:
|
||||
source_id = getattr(message_source, 'message_id', None) or message_source.message_chain.message_id
|
||||
aiocq_msg = aiocqhttp.MessageSegment.reply(source_id) + aiocq_msg
|
||||
raw = await self.bot.send(message_source.source_platform_object, aiocq_msg)
|
||||
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> None:
|
||||
await self.bot.delete_msg(message_id=int(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:
|
||||
raw_message = await self.bot.get_msg(message_id=int(message_id))
|
||||
target_message = aiocqhttp.Message(raw_message.get('message', []))
|
||||
if to_chat_type == 'group':
|
||||
raw = await self.bot.send_group_msg(group_id=int(to_chat_id), message=target_message)
|
||||
elif to_chat_type in ('person', 'private'):
|
||||
raw = await self.bot.send_private_msg(user_id=int(to_chat_id), message=target_message)
|
||||
else:
|
||||
raise ValueError(f'Unsupported aiocqhttp to_chat_type: {to_chat_type}')
|
||||
return platform_events.MessageResult(message_id=(raw or {}).get('message_id'), raw=raw or {})
|
||||
|
||||
async def get_message(
|
||||
self,
|
||||
chat_type: str,
|
||||
chat_id: typing.Union[int, str],
|
||||
message_id: typing.Union[int, str],
|
||||
) -> platform_events.MessageReceivedEvent:
|
||||
raw = await self.bot.get_msg(message_id=int(message_id))
|
||||
message_type = raw.get('message_type') or chat_type
|
||||
event = aiocqhttp.Event.from_payload(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group' if message_type == 'group' else 'private',
|
||||
'sub_type': raw.get('sub_type', 'normal'),
|
||||
'time': raw.get('time', 0),
|
||||
'self_id': self.bot_account_id or 0,
|
||||
'message_id': raw.get('message_id', message_id),
|
||||
'user_id': raw.get('sender', {}).get('user_id') or raw.get('user_id') or chat_id,
|
||||
'group_id': raw.get('group_id') or (chat_id if message_type == 'group' else None),
|
||||
'message': raw.get('message', []),
|
||||
'raw_message': raw.get('raw_message', ''),
|
||||
'sender': raw.get('sender', {}),
|
||||
}
|
||||
)
|
||||
return await AiocqhttpEventConverter.message_to_eba(event, self.bot)
|
||||
|
||||
async def get_group_info(self, group_id: typing.Union[int, str]) -> platform_entities.UserGroup:
|
||||
raw = await self.bot.get_group_info(group_id=int(group_id))
|
||||
return platform_entities.UserGroup(
|
||||
id=raw.get('group_id', group_id),
|
||||
name=raw.get('group_name', ''),
|
||||
member_count=raw.get('member_count'),
|
||||
)
|
||||
|
||||
async def get_group_list(self) -> list[platform_entities.UserGroup]:
|
||||
raw_list = await self.bot.get_group_list()
|
||||
return [
|
||||
platform_entities.UserGroup(
|
||||
id=item.get('group_id', ''),
|
||||
name=item.get('group_name', ''),
|
||||
member_count=item.get('member_count'),
|
||||
)
|
||||
for item in raw_list
|
||||
]
|
||||
|
||||
async def get_group_member_list(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
) -> list[platform_entities.UserGroupMember]:
|
||||
raw_list = await self.bot.get_group_member_list(group_id=int(group_id))
|
||||
return [self._member_to_entity(item, group_id) for item in raw_list]
|
||||
|
||||
async def get_group_member_info(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
) -> platform_entities.UserGroupMember:
|
||||
raw = await self.bot.get_group_member_info(group_id=int(group_id), user_id=int(user_id), no_cache=True)
|
||||
return self._member_to_entity(raw, group_id)
|
||||
|
||||
async def set_group_name(self, group_id: typing.Union[int, str], name: str) -> None:
|
||||
await self.bot.set_group_name(group_id=int(group_id), group_name=name)
|
||||
|
||||
async def mute_member(
|
||||
self,
|
||||
group_id: typing.Union[int, str],
|
||||
user_id: typing.Union[int, str],
|
||||
duration: int = 0,
|
||||
) -> None:
|
||||
await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=int(duration))
|
||||
|
||||
async def unmute_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None:
|
||||
await self.bot.set_group_ban(group_id=int(group_id), user_id=int(user_id), duration=0)
|
||||
|
||||
async def kick_member(self, group_id: typing.Union[int, str], user_id: typing.Union[int, str]) -> None:
|
||||
await self.bot.set_group_kick(group_id=int(group_id), user_id=int(user_id), reject_add_request=False)
|
||||
|
||||
async def leave_group(self, group_id: typing.Union[int, str]) -> None:
|
||||
await self.bot.set_group_leave(group_id=int(group_id), is_dismiss=False)
|
||||
|
||||
async def get_user_info(self, user_id: typing.Union[int, str]) -> platform_entities.User:
|
||||
raw = await self.bot.get_stranger_info(user_id=int(user_id), no_cache=True)
|
||||
return platform_entities.User(
|
||||
id=raw.get('user_id', user_id),
|
||||
nickname=raw.get('nickname', ''),
|
||||
avatar_url=raw.get('avatar_url'),
|
||||
)
|
||||
|
||||
async def get_friend_list(self) -> list[platform_entities.User]:
|
||||
raw_list = await self.bot.get_friend_list()
|
||||
return [
|
||||
platform_entities.User(
|
||||
id=item.get('user_id', ''),
|
||||
nickname=item.get('nickname', ''),
|
||||
remark=item.get('remark'),
|
||||
)
|
||||
for item in raw_list
|
||||
]
|
||||
|
||||
async def approve_friend_request(
|
||||
self,
|
||||
request_id: typing.Union[int, str],
|
||||
approve: bool = True,
|
||||
remark: typing.Optional[str] = None,
|
||||
) -> None:
|
||||
await self.bot.set_friend_add_request(flag=str(request_id), approve=approve, remark=remark or '')
|
||||
|
||||
async def approve_group_invite(self, request_id: typing.Union[int, str], approve: bool = True) -> None:
|
||||
await self.bot.set_group_add_request(flag=str(request_id), sub_type='invite', approve=approve, reason='')
|
||||
|
||||
async def upload_file(self, file_data: bytes, filename: str) -> str:
|
||||
raise NotSupportedError('upload_file')
|
||||
|
||||
async def get_file_url(self, file_id: str) -> str:
|
||||
raise NotSupportedError('get_file_url')
|
||||
|
||||
@staticmethod
|
||||
def _member_to_entity(raw: dict, group_id: typing.Union[int, str]) -> platform_entities.UserGroupMember:
|
||||
role = platform_entities.MemberRole.MEMBER
|
||||
if raw.get('role') == 'owner':
|
||||
role = platform_entities.MemberRole.OWNER
|
||||
elif raw.get('role') == 'admin':
|
||||
role = platform_entities.MemberRole.ADMIN
|
||||
return platform_entities.UserGroupMember(
|
||||
user=platform_entities.User(
|
||||
id=raw.get('user_id', ''),
|
||||
nickname=raw.get('nickname', ''),
|
||||
remark=raw.get('card') or raw.get('remark'),
|
||||
),
|
||||
group_id=group_id,
|
||||
role=role,
|
||||
display_name=raw.get('card') or raw.get('nickname'),
|
||||
joined_at=float(raw['join_time']) if raw.get('join_time') else None,
|
||||
title=raw.get('title'),
|
||||
)
|
||||
|
||||
async def _send_forward_message(self, group_id: int, forward: platform_message.Forward) -> dict:
|
||||
messages = []
|
||||
for node in forward.node_list:
|
||||
if not node.message_chain:
|
||||
continue
|
||||
content, _, _ = await AiocqhttpMessageConverter.yiri2target(node.message_chain)
|
||||
if not content:
|
||||
continue
|
||||
messages.append(
|
||||
{
|
||||
'type': 'node',
|
||||
'data': {
|
||||
'user_id': str(node.sender_id or self.bot_account_id or '10000'),
|
||||
'nickname': node.sender_name or 'LangBot',
|
||||
'content': list(content),
|
||||
},
|
||||
}
|
||||
)
|
||||
if not messages:
|
||||
return {}
|
||||
try:
|
||||
return await self.bot.call_action(
|
||||
'send_forward_msg', group_id=group_id, user_id=str(self.bot_account_id), messages=messages
|
||||
)
|
||||
except Exception:
|
||||
return await self.bot.call_action('send_group_forward_msg', group_id=group_id, messages=messages)
|
||||
244
src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py
Normal file
244
src/langbot/pkg/platform/adapters/aiocqhttp/event_converter.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
|
||||
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 AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.Event, bot_account_id: int | str | None = None):
|
||||
return getattr(event, 'source_platform_object', None)
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
event: aiocqhttp.Event,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
bot_user_id: int | str | None = None,
|
||||
) -> platform_events.Event | None:
|
||||
event_type = getattr(event, 'type', None)
|
||||
if event_type == 'message':
|
||||
return await AiocqhttpEventConverter.message_to_eba(event, bot)
|
||||
if event_type == 'notice':
|
||||
return AiocqhttpEventConverter.notice_to_eba(event, bot_user_id)
|
||||
if event_type == 'request':
|
||||
return AiocqhttpEventConverter.request_to_eba(event)
|
||||
if event_type == 'meta_event':
|
||||
return AiocqhttpEventConverter.platform_specific(event, f'meta.{getattr(event, "detail_type", "")}')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def target2legacy(
|
||||
event: aiocqhttp.Event,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
|
||||
eba_event = await AiocqhttpEventConverter.message_to_eba(event, bot)
|
||||
if eba_event:
|
||||
return eba_event.to_legacy_event()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def message_to_eba(
|
||||
event: aiocqhttp.Event,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
) -> platform_events.MessageReceivedEvent:
|
||||
message_chain = await AiocqhttpMessageConverter.target2yiri(
|
||||
getattr(event, 'message', []),
|
||||
getattr(event, 'message_id', -1),
|
||||
getattr(event, 'time', None),
|
||||
bot,
|
||||
)
|
||||
message_type = getattr(event, 'message_type', getattr(event, 'detail_type', 'private'))
|
||||
group = None
|
||||
chat_type = platform_entities.ChatType.PRIVATE
|
||||
chat_id = getattr(event, 'user_id', '')
|
||||
if message_type == 'group':
|
||||
chat_type = platform_entities.ChatType.GROUP
|
||||
chat_id = getattr(event, 'group_id', '')
|
||||
group = AiocqhttpEventConverter.group_from_event(event)
|
||||
|
||||
return platform_events.MessageReceivedEvent(
|
||||
type='message.received',
|
||||
adapter_name='aiocqhttp',
|
||||
message_id=getattr(event, 'message_id', ''),
|
||||
message_chain=message_chain,
|
||||
sender=AiocqhttpEventConverter.user_from_sender(event),
|
||||
chat_type=chat_type,
|
||||
chat_id=chat_id,
|
||||
group=group,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def notice_to_eba(
|
||||
event: aiocqhttp.Event,
|
||||
bot_user_id: int | str | None = None,
|
||||
) -> platform_events.EBAEvent:
|
||||
notice_type = getattr(event, 'notice_type', getattr(event, 'detail_type', ''))
|
||||
if notice_type in ('group_recall', 'friend_recall'):
|
||||
return platform_events.MessageDeletedEvent(
|
||||
type='message.deleted',
|
||||
adapter_name='aiocqhttp',
|
||||
message_id=getattr(event, 'message_id', ''),
|
||||
operator=AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)),
|
||||
chat_type=platform_entities.ChatType.GROUP
|
||||
if notice_type == 'group_recall'
|
||||
else platform_entities.ChatType.PRIVATE,
|
||||
chat_id=getattr(event, 'group_id', getattr(event, 'user_id', '')),
|
||||
group=AiocqhttpEventConverter.group_from_event(event) if notice_type == 'group_recall' else None,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
if notice_type == 'group_increase':
|
||||
group = AiocqhttpEventConverter.group_from_event(event)
|
||||
user = AiocqhttpEventConverter.user(getattr(event, 'user_id', ''))
|
||||
inviter_id = getattr(event, 'operator_id', None)
|
||||
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
type='bot.invited_to_group',
|
||||
adapter_name='aiocqhttp',
|
||||
group=group,
|
||||
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
return platform_events.MemberJoinedEvent(
|
||||
type='group.member_joined',
|
||||
adapter_name='aiocqhttp',
|
||||
group=group,
|
||||
member=user,
|
||||
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
|
||||
join_type=getattr(event, 'sub_type', None) or 'direct',
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
if notice_type == 'group_decrease':
|
||||
group = AiocqhttpEventConverter.group_from_event(event)
|
||||
operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None))
|
||||
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
|
||||
return platform_events.BotRemovedFromGroupEvent(
|
||||
type='bot.removed_from_group',
|
||||
adapter_name='aiocqhttp',
|
||||
group=group,
|
||||
operator=operator,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
return platform_events.MemberLeftEvent(
|
||||
type='group.member_left',
|
||||
adapter_name='aiocqhttp',
|
||||
group=group,
|
||||
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
is_kicked=getattr(event, 'sub_type', '') in ('kick', 'kick_me'),
|
||||
operator=operator,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
if notice_type == 'group_ban':
|
||||
group = AiocqhttpEventConverter.group_from_event(event)
|
||||
duration = int(getattr(event, 'duration', 0) or 0)
|
||||
operator = AiocqhttpEventConverter.user(getattr(event, 'operator_id', None))
|
||||
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
|
||||
event_cls = platform_events.BotMutedEvent if duration > 0 else platform_events.BotUnmutedEvent
|
||||
kwargs: dict[str, typing.Any] = {
|
||||
'type': 'bot.muted' if duration > 0 else 'bot.unmuted',
|
||||
'adapter_name': 'aiocqhttp',
|
||||
'group': group,
|
||||
'operator': operator,
|
||||
'timestamp': float(getattr(event, 'time', 0) or 0),
|
||||
'source_platform_object': event,
|
||||
}
|
||||
if duration > 0:
|
||||
kwargs['duration'] = duration
|
||||
return event_cls(**kwargs)
|
||||
if duration > 0:
|
||||
return platform_events.MemberBannedEvent(
|
||||
type='group.member_banned',
|
||||
adapter_name='aiocqhttp',
|
||||
group=group,
|
||||
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
operator=operator,
|
||||
duration=duration,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
if notice_type == 'friend_add':
|
||||
return platform_events.FriendAddedEvent(
|
||||
type='friend.added',
|
||||
adapter_name='aiocqhttp',
|
||||
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
return AiocqhttpEventConverter.platform_specific(event, f'notice.{notice_type}')
|
||||
|
||||
@staticmethod
|
||||
def request_to_eba(event: aiocqhttp.Event) -> platform_events.EBAEvent:
|
||||
request_type = getattr(event, 'request_type', getattr(event, 'detail_type', ''))
|
||||
if request_type == 'friend':
|
||||
return platform_events.FriendRequestReceivedEvent(
|
||||
type='friend.request_received',
|
||||
adapter_name='aiocqhttp',
|
||||
request_id=getattr(event, 'flag', ''),
|
||||
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
message=getattr(event, 'comment', None),
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
if request_type == 'group' and getattr(event, 'sub_type', '') == 'invite':
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
type='bot.invited_to_group',
|
||||
adapter_name='aiocqhttp',
|
||||
group=AiocqhttpEventConverter.group_from_event(event),
|
||||
inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
request_id=getattr(event, 'flag', ''),
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
return AiocqhttpEventConverter.platform_specific(event, f'request.{request_type}')
|
||||
|
||||
@staticmethod
|
||||
def user_from_sender(event: aiocqhttp.Event) -> platform_entities.User:
|
||||
sender = getattr(event, 'sender', {}) or {}
|
||||
nickname = sender.get('card') or sender.get('nickname') or ''
|
||||
return platform_entities.User(
|
||||
id=sender.get('user_id', getattr(event, 'user_id', '')),
|
||||
nickname=nickname,
|
||||
remark=sender.get('remark'),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def user(user_id: typing.Union[int, str, None], nickname: str = '') -> platform_entities.User | None:
|
||||
if user_id is None or user_id == '':
|
||||
return None
|
||||
return platform_entities.User(id=user_id, nickname=nickname)
|
||||
|
||||
@staticmethod
|
||||
def group_from_event(event: aiocqhttp.Event) -> platform_entities.UserGroup:
|
||||
return platform_entities.UserGroup(
|
||||
id=getattr(event, 'group_id', ''),
|
||||
name=getattr(event, 'group_name', '') or '',
|
||||
member_count=getattr(event, 'member_count', None),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def platform_specific(event: aiocqhttp.Event, action: str) -> platform_events.PlatformSpecificEvent:
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='aiocqhttp',
|
||||
action=action,
|
||||
data={key: value for key, value in dict(event).items() if key not in {'message'}},
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_bot_user(user_id: typing.Any, bot_user_id: typing.Any, event: aiocqhttp.Event) -> bool:
|
||||
candidate = bot_user_id or getattr(event, 'self_id', None)
|
||||
return candidate is not None and user_id is not None and str(user_id) == str(candidate)
|
||||
131
src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml
Normal file
131
src/langbot/pkg/platform/adapters/aiocqhttp/manifest.yaml
Normal file
@@ -0,0 +1,131 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: aiocqhttp-eba
|
||||
label:
|
||||
en_US: OneBot v11 (EBA)
|
||||
zh_Hans: OneBot v11 (EBA)
|
||||
zh_Hant: OneBot v11 (EBA)
|
||||
description:
|
||||
en_US: OneBot v11 adapter for QQ-compatible protocol endpoints (EBA architecture)
|
||||
zh_Hans: OneBot v11 适配器,用于接入 QQ 兼容协议端(EBA 架构版本)
|
||||
zh_Hant: OneBot v11 適配器,用於接入 QQ 相容協定端(EBA 架構版本)
|
||||
icon: onebot.svg
|
||||
|
||||
spec:
|
||||
categories:
|
||||
- protocol
|
||||
help_links:
|
||||
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
||||
en: https://link.langbot.app/en/platforms/aiocqhttp
|
||||
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
||||
config:
|
||||
- name: host
|
||||
label:
|
||||
en_US: Host
|
||||
zh_Hans: 主机
|
||||
zh_Hant: 主機
|
||||
description:
|
||||
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
|
||||
zh_Hans: OneBot v11 反向 WebSocket 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
||||
zh_Hant: OneBot v11 反向 WebSocket 監聽主機,除非你知道自己在做什麼,否則請填 0.0.0.0
|
||||
type: string
|
||||
required: true
|
||||
default: 0.0.0.0
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 端口
|
||||
zh_Hant: 連接埠
|
||||
description:
|
||||
en_US: Reverse WebSocket listen port
|
||||
zh_Hans: 反向 WebSocket 监听端口
|
||||
zh_Hant: 反向 WebSocket 監聽連接埠
|
||||
type: integer
|
||||
required: true
|
||||
default: 2280
|
||||
- name: access-token
|
||||
label:
|
||||
en_US: Access Token
|
||||
zh_Hans: 访问令牌
|
||||
zh_Hant: 存取令牌
|
||||
description:
|
||||
en_US: Custom connection token for the protocol endpoint. Leave empty if the endpoint has no token configured
|
||||
zh_Hans: 自定义的协议端连接令牌;若协议端未设置,则不填
|
||||
zh_Hant: 自訂的協定端連線令牌;若協定端未設定,則不填
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
supported_events:
|
||||
- message.received
|
||||
- message.deleted
|
||||
- group.member_joined
|
||||
- group.member_left
|
||||
- group.member_banned
|
||||
- friend.request_received
|
||||
- friend.added
|
||||
- bot.invited_to_group
|
||||
- bot.removed_from_group
|
||||
- bot.muted
|
||||
- bot.unmuted
|
||||
- platform.specific
|
||||
|
||||
supported_apis:
|
||||
required:
|
||||
- send_message
|
||||
- reply_message
|
||||
optional:
|
||||
- delete_message
|
||||
- forward_message
|
||||
- get_message
|
||||
- get_group_info
|
||||
- get_group_list
|
||||
- get_group_member_list
|
||||
- get_group_member_info
|
||||
- set_group_name
|
||||
- get_user_info
|
||||
- get_friend_list
|
||||
- approve_friend_request
|
||||
- approve_group_invite
|
||||
- mute_member
|
||||
- unmute_member
|
||||
- kick_member
|
||||
- leave_group
|
||||
- call_platform_api
|
||||
|
||||
platform_specific_apis:
|
||||
- action: get_login_info
|
||||
description: { en_US: "Get current bot account information", zh_Hans: "获取当前机器人账号信息" }
|
||||
- action: get_status
|
||||
description: { en_US: "Get endpoint status", zh_Hans: "获取协议端状态" }
|
||||
- action: get_version_info
|
||||
description: { en_US: "Get endpoint version information", zh_Hans: "获取协议端版本信息" }
|
||||
- action: get_group_honor_info
|
||||
description: { en_US: "Get group honor information", zh_Hans: "获取群荣誉信息" }
|
||||
- action: set_group_card
|
||||
description: { en_US: "Set a member group card", zh_Hans: "设置群名片" }
|
||||
- action: set_group_special_title
|
||||
description: { en_US: "Set a member special title", zh_Hans: "设置群专属头衔" }
|
||||
- action: set_group_admin
|
||||
description: { en_US: "Set group administrator status", zh_Hans: "设置群管理员" }
|
||||
- action: set_group_whole_ban
|
||||
description: { en_US: "Enable or disable whole-group mute", zh_Hans: "设置全员禁言" }
|
||||
- action: send_group_forward_msg
|
||||
description: { en_US: "Send a merged forward message", zh_Hans: "发送合并转发消息" }
|
||||
- action: get_forward_msg
|
||||
description: { en_US: "Get merged forward message content", zh_Hans: "获取合并转发消息内容" }
|
||||
- action: get_record
|
||||
description: { en_US: "Get voice file", zh_Hans: "获取语音文件" }
|
||||
- action: get_image
|
||||
description: { en_US: "Get image file", zh_Hans: "获取图片文件" }
|
||||
- action: can_send_image
|
||||
description: { en_US: "Check whether images can be sent", zh_Hans: "检查是否可以发送图片" }
|
||||
- action: can_send_record
|
||||
description: { en_US: "Check whether voice messages can be sent", zh_Hans: "检查是否可以发送语音" }
|
||||
|
||||
execution:
|
||||
python:
|
||||
path: ./adapter.py
|
||||
attr: AiocqhttpAdapter
|
||||
251
src/langbot/pkg/platform/adapters/aiocqhttp/message_converter.py
Normal file
251
src/langbot/pkg/platform/adapters/aiocqhttp/message_converter.py
Normal file
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
|
||||
FACE_NAMES = {
|
||||
'14': '微笑',
|
||||
'21': '可爱',
|
||||
'23': '傲慢',
|
||||
'24': '饥饿',
|
||||
'25': '困',
|
||||
'26': '惊恐',
|
||||
'27': '流汗',
|
||||
'28': '憨笑',
|
||||
'29': '悠闲',
|
||||
'30': '奋斗',
|
||||
'32': '疑问',
|
||||
'33': '嘘',
|
||||
'34': '晕',
|
||||
'38': '敲打',
|
||||
'39': '再见',
|
||||
'42': '爱情',
|
||||
'43': '跳跳',
|
||||
'49': '拥抱',
|
||||
'53': '蛋糕',
|
||||
'63': '玫瑰',
|
||||
'66': '爱心',
|
||||
'74': '太阳',
|
||||
'75': '月亮',
|
||||
'76': '赞',
|
||||
'78': '握手',
|
||||
'79': '胜利',
|
||||
'85': '飞吻',
|
||||
'89': '西瓜',
|
||||
'96': '冷汗',
|
||||
'97': '擦汗',
|
||||
'98': '抠鼻',
|
||||
'99': '鼓掌',
|
||||
'100': '糗大了',
|
||||
'101': '坏笑',
|
||||
'102': '左哼哼',
|
||||
'103': '右哼哼',
|
||||
'104': '哈欠',
|
||||
'106': '委屈',
|
||||
'111': '可怜',
|
||||
'120': '拳头',
|
||||
'122': '爱你',
|
||||
'123': 'NO',
|
||||
'124': 'OK',
|
||||
'129': '挥手',
|
||||
'144': '喝彩',
|
||||
'147': '棒棒糖',
|
||||
'171': '茶',
|
||||
'173': '泪奔',
|
||||
'174': '无奈',
|
||||
'175': '卖萌',
|
||||
'179': 'doge',
|
||||
'180': '惊喜',
|
||||
'182': '笑哭',
|
||||
'201': '点赞',
|
||||
'203': '托脸',
|
||||
'212': '托腮',
|
||||
'264': '捂脸',
|
||||
'271': '吃瓜',
|
||||
'285': '摸鱼',
|
||||
}
|
||||
|
||||
|
||||
class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
message_chain: platform_message.MessageChain,
|
||||
) -> tuple[aiocqhttp.Message, typing.Union[int, str, None], datetime.datetime | None]:
|
||||
target = aiocqhttp.Message()
|
||||
source_id: typing.Union[int, str, None] = None
|
||||
source_time: datetime.datetime | None = None
|
||||
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Source):
|
||||
source_id = component.id
|
||||
source_time = component.time
|
||||
elif isinstance(component, platform_message.Plain):
|
||||
target.append(aiocqhttp.MessageSegment.text(component.text))
|
||||
elif isinstance(component, platform_message.At):
|
||||
target.append(aiocqhttp.MessageSegment.at(component.target))
|
||||
elif isinstance(component, platform_message.AtAll):
|
||||
target.append(aiocqhttp.MessageSegment.at('all'))
|
||||
elif isinstance(component, platform_message.Image):
|
||||
file_arg = AiocqhttpMessageConverter._file_arg(component)
|
||||
if file_arg:
|
||||
target.append(aiocqhttp.MessageSegment.image(file_arg))
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
file_arg = AiocqhttpMessageConverter._file_arg(component)
|
||||
if file_arg:
|
||||
target.append(aiocqhttp.MessageSegment.record(file_arg))
|
||||
elif isinstance(component, platform_message.File):
|
||||
file_arg = component.url or component.path or component.base64 or component.id
|
||||
target.append({'type': 'file', 'data': {'file': file_arg, 'name': component.name or 'file'}})
|
||||
elif isinstance(component, platform_message.Face):
|
||||
if component.face_type == 'rps':
|
||||
target.append(aiocqhttp.MessageSegment.rps())
|
||||
elif component.face_type == 'dice':
|
||||
target.append(aiocqhttp.MessageSegment.dice())
|
||||
else:
|
||||
target.append(aiocqhttp.MessageSegment.face(component.face_id))
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
if node.message_chain:
|
||||
node_message, _, _ = await AiocqhttpMessageConverter.yiri2target(node.message_chain)
|
||||
target.extend(node_message)
|
||||
elif isinstance(component, platform_message.Quote) and component.id is not None:
|
||||
target.append(aiocqhttp.MessageSegment.reply(component.id))
|
||||
else:
|
||||
target.append(aiocqhttp.MessageSegment.text(str(component)))
|
||||
|
||||
return target, source_id, source_time
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
message: typing.Any,
|
||||
message_id: typing.Union[int, str] = -1,
|
||||
timestamp: float | None = None,
|
||||
bot: aiocqhttp.CQHttp | None = None,
|
||||
) -> platform_message.MessageChain:
|
||||
target = aiocqhttp.Message(message)
|
||||
message_time = datetime.datetime.fromtimestamp(timestamp) if timestamp else datetime.datetime.now()
|
||||
components: list[platform_message.MessageComponent] = [
|
||||
platform_message.Source(id=message_id, time=message_time),
|
||||
]
|
||||
|
||||
for segment in target:
|
||||
if segment.type == 'text':
|
||||
components.append(platform_message.Plain(text=segment.data.get('text', '')))
|
||||
elif segment.type == 'at':
|
||||
qq = str(segment.data.get('qq', ''))
|
||||
components.append(platform_message.AtAll() if qq == 'all' else platform_message.At(target=qq))
|
||||
elif segment.type == 'image':
|
||||
if segment.data.get('emoji_package_id'):
|
||||
components.append(
|
||||
platform_message.Face(
|
||||
face_id=int(segment.data.get('emoji_package_id') or 0),
|
||||
face_name=segment.data.get('summary', ''),
|
||||
)
|
||||
)
|
||||
else:
|
||||
components.append(
|
||||
platform_message.Image(
|
||||
image_id=str(segment.data.get('file', '')),
|
||||
url=segment.data.get('url') or segment.data.get('file') or '',
|
||||
)
|
||||
)
|
||||
elif segment.type == 'record':
|
||||
components.append(
|
||||
platform_message.Voice(
|
||||
voice_id=str(segment.data.get('file', '')),
|
||||
url=segment.data.get('url') or segment.data.get('file') or '',
|
||||
)
|
||||
)
|
||||
elif segment.type == 'file':
|
||||
components.append(
|
||||
platform_message.File(
|
||||
id=str(segment.data.get('file_id') or segment.data.get('file') or ''),
|
||||
name=segment.data.get('name') or segment.data.get('file') or '',
|
||||
size=int(segment.data.get('size') or segment.data.get('file_size') or 0),
|
||||
url=segment.data.get('url') or segment.data.get('file_url') or '',
|
||||
)
|
||||
)
|
||||
elif segment.type == 'reply':
|
||||
quote = await AiocqhttpMessageConverter._quote_from_reply_segment(segment, bot)
|
||||
components.append(quote)
|
||||
elif segment.type == 'face':
|
||||
face_id = str(segment.data.get('id', 0))
|
||||
face_name = ''
|
||||
raw = segment.data.get('raw')
|
||||
if isinstance(raw, dict):
|
||||
face_name = str(raw.get('faceText') or '')
|
||||
components.append(
|
||||
platform_message.Face(
|
||||
face_id=int(face_id or 0),
|
||||
face_name=face_name.replace('/', '') or FACE_NAMES.get(face_id, ''),
|
||||
)
|
||||
)
|
||||
elif segment.type == 'rps':
|
||||
components.append(
|
||||
platform_message.Face(
|
||||
face_type='rps',
|
||||
face_id=int(segment.data.get('result') or 0),
|
||||
face_name='猜拳',
|
||||
)
|
||||
)
|
||||
elif segment.type == 'dice':
|
||||
components.append(
|
||||
platform_message.Face(
|
||||
face_type='dice',
|
||||
face_id=int(segment.data.get('result') or 0),
|
||||
face_name='骰子',
|
||||
)
|
||||
)
|
||||
else:
|
||||
components.append(platform_message.Unknown(text=f'{segment.type}:{segment.data}'))
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
@staticmethod
|
||||
def _file_arg(component: platform_message.Image | platform_message.Voice) -> str:
|
||||
if component.base64:
|
||||
_, _, payload = component.base64.partition(',')
|
||||
return f'base64://{payload or component.base64}'
|
||||
if component.url:
|
||||
return component.url
|
||||
if component.path:
|
||||
return str(component.path)
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
async def _quote_from_reply_segment(
|
||||
segment: aiocqhttp.MessageSegment,
|
||||
bot: aiocqhttp.CQHttp | None,
|
||||
) -> platform_message.Quote:
|
||||
reply_id = segment.data.get('id')
|
||||
origin = platform_message.MessageChain([])
|
||||
sender_id = None
|
||||
group_id = None
|
||||
target_id = None
|
||||
if bot is not None and reply_id is not None:
|
||||
try:
|
||||
message_data = await bot.get_msg(message_id=int(reply_id))
|
||||
sender_id = message_data.get('sender', {}).get('user_id') or message_data.get('user_id')
|
||||
group_id = message_data.get('group_id')
|
||||
target_id = group_id or sender_id
|
||||
origin = await AiocqhttpMessageConverter.target2yiri(
|
||||
message_data.get('message', []),
|
||||
message_data.get('message_id', reply_id),
|
||||
message_data.get('time'),
|
||||
bot=None,
|
||||
)
|
||||
except Exception:
|
||||
origin = platform_message.MessageChain([])
|
||||
return platform_message.Quote(
|
||||
id=reply_id,
|
||||
group_id=group_id,
|
||||
sender_id=sender_id,
|
||||
target_id=target_id,
|
||||
origin=origin,
|
||||
)
|
||||
7
src/langbot/pkg/platform/adapters/aiocqhttp/onebot.svg
Normal file
7
src/langbot/pkg/platform/adapters/aiocqhttp/onebot.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="96" height="96" rx="20" fill="#16A34A"/>
|
||||
<path d="M24 33C24 25.268 30.268 19 38 19H58C65.732 19 72 25.268 72 33V51C72 58.732 65.732 65 58 65H41.5L29 77V64.059C26.024 61.514 24 57.729 24 51V33Z" fill="white"/>
|
||||
<circle cx="39" cy="42" r="5" fill="#16A34A"/>
|
||||
<circle cx="57" cy="42" r="5" fill="#16A34A"/>
|
||||
<path d="M39 53C44.5 57 51.5 57 57 53" stroke="#16A34A" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 527 B |
84
src/langbot/pkg/platform/adapters/aiocqhttp/platform_api.py
Normal file
84
src/langbot/pkg/platform/adapters/aiocqhttp/platform_api.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
|
||||
|
||||
async def _call(bot: aiocqhttp.CQHttp, action: str, params: dict[str, typing.Any]) -> dict:
|
||||
result = await bot.call_action(action, **params)
|
||||
return result or {}
|
||||
|
||||
|
||||
async def get_login_info(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_login_info', params)
|
||||
|
||||
|
||||
async def get_status(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_status', params)
|
||||
|
||||
|
||||
async def get_version_info(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_version_info', params)
|
||||
|
||||
|
||||
async def get_group_honor_info(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_group_honor_info', params)
|
||||
|
||||
|
||||
async def set_group_card(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'set_group_card', params)
|
||||
|
||||
|
||||
async def set_group_special_title(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'set_group_special_title', params)
|
||||
|
||||
|
||||
async def set_group_admin(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'set_group_admin', params)
|
||||
|
||||
|
||||
async def set_group_whole_ban(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'set_group_whole_ban', params)
|
||||
|
||||
|
||||
async def send_group_forward_msg(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'send_group_forward_msg', params)
|
||||
|
||||
|
||||
async def get_forward_msg(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_forward_msg', params)
|
||||
|
||||
|
||||
async def get_record(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_record', params)
|
||||
|
||||
|
||||
async def get_image(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'get_image', params)
|
||||
|
||||
|
||||
async def can_send_image(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'can_send_image', params)
|
||||
|
||||
|
||||
async def can_send_record(bot: aiocqhttp.CQHttp, params: dict) -> dict:
|
||||
return await _call(bot, 'can_send_record', params)
|
||||
|
||||
|
||||
PLATFORM_API_MAP = {
|
||||
'get_login_info': get_login_info,
|
||||
'get_status': get_status,
|
||||
'get_version_info': get_version_info,
|
||||
'get_group_honor_info': get_group_honor_info,
|
||||
'set_group_card': set_group_card,
|
||||
'set_group_special_title': set_group_special_title,
|
||||
'set_group_admin': set_group_admin,
|
||||
'set_group_whole_ban': set_group_whole_ban,
|
||||
'send_group_forward_msg': send_group_forward_msg,
|
||||
'get_forward_msg': get_forward_msg,
|
||||
'get_record': get_record,
|
||||
'get_image': get_image,
|
||||
'can_send_image': can_send_image,
|
||||
'can_send_record': can_send_record,
|
||||
}
|
||||
9
src/langbot/pkg/platform/adapters/aiocqhttp/types.py
Normal file
9
src/langbot/pkg/platform/adapters/aiocqhttp/types.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
|
||||
|
||||
TargetMessage = typing.Union[str, list, dict, aiocqhttp.Message]
|
||||
OneBotResponse = dict[str, typing.Any] | None
|
||||
233
tests/e2e/live_aiocqhttp_eba_probe.py
Normal file
233
tests/e2e/live_aiocqhttp_eba_probe.py
Normal file
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
||||
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 ProbeLogger(AbstractEventLogger):
|
||||
async def info(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
print(f'[info] {text}')
|
||||
|
||||
async def debug(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
print(f'[debug] {text}')
|
||||
|
||||
async def warning(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
print(f'[warn] {text}')
|
||||
|
||||
async def error(self, text, images=None, message_session_id=None, no_throw=True):
|
||||
print(f'[error] {text}')
|
||||
|
||||
|
||||
def dump_event(event: platform_events.Event) -> dict:
|
||||
data = event.model_dump(exclude={'source_platform_object'})
|
||||
data['event_class'] = type(event).__name__
|
||||
return data
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description='Live OneBot v11 / aiocqhttp EBA probe for Matcha or a real endpoint.')
|
||||
parser.add_argument('--host', default='127.0.0.1')
|
||||
parser.add_argument('--port', type=int, default=2280)
|
||||
parser.add_argument('--access-token', default='')
|
||||
parser.add_argument('--timeout', type=int, default=120)
|
||||
parser.add_argument('--target-type', choices=['private', 'group'], default=None)
|
||||
parser.add_argument('--target-id', default=None)
|
||||
parser.add_argument(
|
||||
'--component-sweep', action='store_true', help='Send text, mention, image, file, face, and forward samples.'
|
||||
)
|
||||
parser.add_argument('--destructive', action='store_true', help='Enable delete/mute/kick/leave style APIs.')
|
||||
parser.add_argument('--out', default='data/temp/aiocqhttp_eba_live_probe.jsonl')
|
||||
args = parser.parse_args()
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_fp = out_path.open('a', encoding='utf-8')
|
||||
|
||||
adapter = AiocqhttpAdapter(
|
||||
{'host': args.host, 'port': args.port, 'access-token': args.access_token},
|
||||
ProbeLogger(),
|
||||
)
|
||||
|
||||
observed: list[platform_events.Event] = []
|
||||
first_message = asyncio.Event()
|
||||
|
||||
async def listener(event, adapter):
|
||||
observed.append(event)
|
||||
out_fp.write(json.dumps(dump_event(event), ensure_ascii=False, default=str) + '\n')
|
||||
out_fp.flush()
|
||||
print(f'[event] {type(event).__name__} {event.type}')
|
||||
if isinstance(event, platform_events.MessageReceivedEvent):
|
||||
first_message.set()
|
||||
|
||||
adapter.register_listener(platform_events.EBAEvent, listener)
|
||||
|
||||
async def call_api(name: str, awaitable, timeout: int = 8):
|
||||
try:
|
||||
return await asyncio.wait_for(awaitable, timeout=timeout)
|
||||
except Exception as exc:
|
||||
api_results[name] = f'skip:{type(exc).__name__}:{exc}'
|
||||
return None
|
||||
|
||||
task = asyncio.create_task(adapter.run_async())
|
||||
print(f'Listening on ws://{args.host}:{args.port}/ws/ . Trigger events from Matcha now.')
|
||||
|
||||
api_results: dict[str, str] = {}
|
||||
try:
|
||||
try:
|
||||
await asyncio.wait_for(first_message.wait(), timeout=args.timeout)
|
||||
first = next(event for event in observed if isinstance(event, platform_events.MessageReceivedEvent))
|
||||
target_type = args.target_type or ('group' if first.chat_type.value == 'group' else 'private')
|
||||
target_id = args.target_id or str(first.chat_id)
|
||||
|
||||
reply = await call_api(
|
||||
'reply_message',
|
||||
adapter.reply_message(
|
||||
first,
|
||||
platform_message.MessageChain([platform_message.Plain(text='aiocqhttp EBA reply probe')]),
|
||||
quote_origin=True,
|
||||
),
|
||||
)
|
||||
if reply:
|
||||
api_results['reply_message'] = f'ok:{reply.message_id}'
|
||||
|
||||
sent = await call_api(
|
||||
'send_message',
|
||||
adapter.send_message(
|
||||
target_type,
|
||||
target_id,
|
||||
platform_message.MessageChain([platform_message.Plain(text='aiocqhttp EBA send probe')]),
|
||||
),
|
||||
)
|
||||
if sent:
|
||||
api_results['send_message'] = f'ok:{sent.message_id}'
|
||||
|
||||
if args.component_sweep:
|
||||
png_base64 = base64.b64encode(
|
||||
base64.b64decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFeAJ5mZtH5QAAAABJRU5ErkJggg=='
|
||||
)
|
||||
).decode()
|
||||
component_cases = {
|
||||
'component:text_at_face': platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='component sweep '),
|
||||
platform_message.At(target=str(first.sender.id)),
|
||||
platform_message.Plain(text=' '),
|
||||
platform_message.AtAll(),
|
||||
platform_message.Plain(text=' '),
|
||||
platform_message.Face(face_id=14, face_name='微笑'),
|
||||
]
|
||||
),
|
||||
'component:image_base64': platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='image component '),
|
||||
platform_message.Image(base64=f'data:image/png;base64,{png_base64}'),
|
||||
]
|
||||
),
|
||||
'component:file': platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Plain(text='file component '),
|
||||
platform_message.File(name='probe.txt', url='https://example.com/probe.txt'),
|
||||
]
|
||||
),
|
||||
}
|
||||
if target_type == 'group':
|
||||
component_cases['component:forward'] = platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Forward(
|
||||
node_list=[
|
||||
platform_message.ForwardMessageNode(
|
||||
sender_id=adapter.bot_account_id or '960164003',
|
||||
sender_name='LangBot',
|
||||
message_chain=platform_message.MessageChain(
|
||||
[platform_message.Plain(text='forward node 1')]
|
||||
),
|
||||
),
|
||||
platform_message.ForwardMessageNode(
|
||||
sender_id=str(first.sender.id),
|
||||
sender_name=first.sender.nickname or 'Matcha',
|
||||
message_chain=platform_message.MessageChain(
|
||||
[platform_message.Plain(text='forward node 2')]
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
for name, chain in component_cases.items():
|
||||
result = await call_api(name, adapter.send_message(target_type, target_id, chain))
|
||||
if result:
|
||||
api_results[name] = f'ok:{result.message_id}'
|
||||
|
||||
if sent and sent.message_id:
|
||||
fetched = await call_api('get_message', adapter.get_message(target_type, target_id, sent.message_id))
|
||||
if fetched:
|
||||
api_results['get_message'] = f'ok:{fetched.message_id}'
|
||||
if args.destructive:
|
||||
deleted = await call_api(
|
||||
'delete_message',
|
||||
adapter.delete_message(target_type, target_id, sent.message_id),
|
||||
)
|
||||
if deleted is not None:
|
||||
api_results['delete_message'] = 'ok'
|
||||
|
||||
if target_type == 'group':
|
||||
group = await call_api('get_group_info', adapter.get_group_info(target_id))
|
||||
if group:
|
||||
api_results['get_group_info'] = f'ok:{group.id}'
|
||||
members = await call_api('get_group_member_list', adapter.get_group_member_list(target_id))
|
||||
if members is not None:
|
||||
api_results['get_group_member_list'] = f'ok:{len(members)}'
|
||||
if members:
|
||||
member = await call_api(
|
||||
'get_group_member_info',
|
||||
adapter.get_group_member_info(target_id, members[0].user.id),
|
||||
)
|
||||
if member:
|
||||
api_results['get_group_member_info'] = f'ok:{member.user.id}'
|
||||
|
||||
for action in ('get_login_info', 'get_status', 'get_version_info', 'can_send_image', 'can_send_record'):
|
||||
result = await call_api(
|
||||
f'call_platform_api:{action}',
|
||||
adapter.call_platform_api(action, {}),
|
||||
)
|
||||
if result is not None:
|
||||
api_results[f'call_platform_api:{action}'] = 'ok'
|
||||
except asyncio.TimeoutError:
|
||||
api_results['first_message'] = 'timeout'
|
||||
finally:
|
||||
task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=3)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
out_fp.close()
|
||||
|
||||
counts = Counter(event.type for event in observed)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
'output': str(out_path),
|
||||
'observed_events': counts,
|
||||
'api_results': api_results,
|
||||
'duration_seconds': round(time.monotonic(), 3),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
551
tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py
Normal file
551
tests/unit_tests/platform/test_aiocqhttp_eba_adapter.py
Normal file
@@ -0,0 +1,551 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import datetime
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import aiocqhttp
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.event_converter import AiocqhttpEventConverter
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.message_converter import AiocqhttpMessageConverter
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.platform_api import PLATFORM_API_MAP
|
||||
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
|
||||
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
|
||||
|
||||
|
||||
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() -> AiocqhttpAdapter:
|
||||
return AiocqhttpAdapter({'host': '127.0.0.1', 'port': 2280, 'access-token': ''}, DummyLogger())
|
||||
|
||||
|
||||
def manifest() -> dict:
|
||||
manifest_path = (
|
||||
pathlib.Path(__file__).parents[3]
|
||||
/ 'src'
|
||||
/ 'langbot'
|
||||
/ 'pkg'
|
||||
/ 'platform'
|
||||
/ 'adapters'
|
||||
/ 'aiocqhttp'
|
||||
/ 'manifest.yaml'
|
||||
)
|
||||
return yaml.safe_load(manifest_path.read_text())
|
||||
|
||||
|
||||
def onebot_event(payload: dict) -> aiocqhttp.Event:
|
||||
return aiocqhttp.Event.from_payload(payload)
|
||||
|
||||
|
||||
def test_aiocqhttp_supported_events_match_manifest():
|
||||
assert make_adapter().get_supported_events() == manifest()['spec']['supported_events']
|
||||
|
||||
|
||||
def test_aiocqhttp_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_aiocqhttp_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_aiocqhttp_adapter_dispatches_most_specific_eba_listener():
|
||||
adapter = make_adapter()
|
||||
calls: list[str] = []
|
||||
|
||||
async def wildcard_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, wildcard_listener)
|
||||
adapter.register_listener(platform_events.EBAEvent, eba_listener)
|
||||
adapter.register_listener(platform_events.MessageReceivedEvent, message_listener)
|
||||
|
||||
event = platform_events.MessageReceivedEvent(
|
||||
message_id=1,
|
||||
message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]),
|
||||
sender={'id': 1},
|
||||
chat_id=1,
|
||||
)
|
||||
|
||||
await adapter._dispatch_eba_event(event)
|
||||
|
||||
assert calls == ['message']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aiocqhttp_message_converter_maps_chain_to_onebot_segments():
|
||||
target, source_id, _ = await AiocqhttpMessageConverter.yiri2target(
|
||||
platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Source(id=42, time=datetime.datetime.now()),
|
||||
platform_message.Plain(text='hi '),
|
||||
platform_message.At(target='10001'),
|
||||
platform_message.AtAll(),
|
||||
platform_message.Image(base64='data:image/png;base64,AAAA'),
|
||||
platform_message.Face(face_id=14, face_name='微笑'),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert source_id == 42
|
||||
assert [segment.type for segment in target] == ['text', 'at', 'at', 'image', 'face']
|
||||
assert target[3].data['file'] == 'base64://AAAA'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aiocqhttp_message_converter_maps_media_file_quote_and_face_to_onebot_segments():
|
||||
target, _, _ = await AiocqhttpMessageConverter.yiri2target(
|
||||
platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Quote(id=123, origin=platform_message.MessageChain([])),
|
||||
platform_message.Image(url='https://example.test/a.png'),
|
||||
platform_message.Voice(base64='data:audio/silk;base64,BBBB'),
|
||||
platform_message.File(name='doc.txt', url='https://example.test/doc.txt'),
|
||||
platform_message.Face(face_type='rps', face_id=1, face_name='猜拳'),
|
||||
platform_message.Face(face_type='dice', face_id=6, face_name='骰子'),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert [segment.type for segment in target] == ['reply', 'image', 'record', 'file', 'rps', 'dice']
|
||||
assert target[0].data['id'] == '123'
|
||||
assert target[1].data['file'] == 'https://example.test/a.png'
|
||||
assert target[2].data['file'] == 'base64://BBBB'
|
||||
assert target[3].data['name'] == 'doc.txt'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aiocqhttp_message_converter_flattens_forward_nodes():
|
||||
target, _, _ = await AiocqhttpMessageConverter.yiri2target(
|
||||
platform_message.MessageChain(
|
||||
[
|
||||
platform_message.Forward(
|
||||
node_list=[
|
||||
platform_message.ForwardMessageNode(
|
||||
sender_id='10001',
|
||||
sender_name='Alice',
|
||||
message_chain=platform_message.MessageChain([platform_message.Plain(text='node 1')]),
|
||||
),
|
||||
platform_message.ForwardMessageNode(
|
||||
sender_id='10002',
|
||||
sender_name='Bob',
|
||||
message_chain=platform_message.MessageChain([platform_message.At(target='999')]),
|
||||
),
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert [segment.type for segment in target] == ['text', 'at']
|
||||
assert target[0].data['text'] == 'node 1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aiocqhttp_message_converter_maps_onebot_segments_to_chain():
|
||||
chain = await AiocqhttpMessageConverter.target2yiri(
|
||||
[
|
||||
{'type': 'text', 'data': {'text': 'hello '}},
|
||||
{'type': 'at', 'data': {'qq': 'all'}},
|
||||
{'type': 'at', 'data': {'qq': '10001'}},
|
||||
{'type': 'image', 'data': {'file': 'abc.image', 'url': 'https://example.test/a.png'}},
|
||||
{'type': 'image', 'data': {'emoji_package_id': '14', 'summary': '微笑'}},
|
||||
{'type': 'record', 'data': {'file': 'voice.silk', 'url': 'https://example.test/a.silk'}},
|
||||
{'type': 'file', 'data': {'file_id': 'file-1', 'name': 'doc.txt', 'size': '5'}},
|
||||
{'type': 'reply', 'data': {'id': '99'}},
|
||||
{'type': 'face', 'data': {'id': '14', 'raw': {'faceText': '/微笑'}}},
|
||||
{'type': 'rps', 'data': {'result': '2'}},
|
||||
{'type': 'dice', 'data': {'result': '6'}},
|
||||
{'type': 'json', 'data': {'data': '{}'}},
|
||||
],
|
||||
message_id=123,
|
||||
timestamp=1710000000,
|
||||
)
|
||||
|
||||
assert isinstance(chain[0], platform_message.Source)
|
||||
assert isinstance(chain[1], platform_message.Plain)
|
||||
assert isinstance(chain[2], platform_message.AtAll)
|
||||
assert isinstance(chain[3], platform_message.At)
|
||||
assert isinstance(chain[4], platform_message.Image)
|
||||
assert chain[4].url == 'https://example.test/a.png'
|
||||
assert isinstance(chain[5], platform_message.Face)
|
||||
assert isinstance(chain[6], platform_message.Voice)
|
||||
assert isinstance(chain[7], platform_message.File)
|
||||
assert isinstance(chain[8], platform_message.Quote)
|
||||
assert isinstance(chain[9], platform_message.Face)
|
||||
assert isinstance(chain[10], platform_message.Face)
|
||||
assert chain[10].face_type == 'rps'
|
||||
assert isinstance(chain[11], platform_message.Face)
|
||||
assert chain[11].face_type == 'dice'
|
||||
assert isinstance(chain[12], platform_message.Unknown)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aiocqhttp_message_converter_fetches_reply_origin_when_bot_available():
|
||||
bot = AsyncMock()
|
||||
bot.get_msg.return_value = {
|
||||
'message_id': 99,
|
||||
'user_id': 10001,
|
||||
'group_id': 20001,
|
||||
'time': 1710000000,
|
||||
'message': [{'type': 'text', 'data': {'text': 'origin'}}],
|
||||
}
|
||||
|
||||
chain = await AiocqhttpMessageConverter.target2yiri(
|
||||
[{'type': 'reply', 'data': {'id': '99'}}],
|
||||
message_id=123,
|
||||
bot=bot,
|
||||
)
|
||||
|
||||
quote = chain[1]
|
||||
assert isinstance(quote, platform_message.Quote)
|
||||
assert quote.sender_id == 10001
|
||||
assert quote.group_id == 20001
|
||||
assert str(quote.origin) == 'origin'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aiocqhttp_event_converter_maps_private_and_group_messages():
|
||||
private = onebot_event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'private',
|
||||
'sub_type': 'friend',
|
||||
'time': 1710000000,
|
||||
'self_id': 999,
|
||||
'message_id': 11,
|
||||
'user_id': 10001,
|
||||
'message': [{'type': 'text', 'data': {'text': 'hello'}}],
|
||||
'raw_message': 'hello',
|
||||
'sender': {'user_id': 10001, 'nickname': 'Alice'},
|
||||
}
|
||||
)
|
||||
private_event = await AiocqhttpEventConverter.target2yiri(private)
|
||||
|
||||
assert isinstance(private_event, platform_events.MessageReceivedEvent)
|
||||
assert private_event.type == 'message.received'
|
||||
assert private_event.adapter_name == 'aiocqhttp'
|
||||
assert private_event.chat_type == platform_entities.ChatType.PRIVATE
|
||||
assert private_event.chat_id == 10001
|
||||
assert private_event.sender.nickname == 'Alice'
|
||||
|
||||
group = onebot_event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'sub_type': 'normal',
|
||||
'time': 1710000000,
|
||||
'self_id': 999,
|
||||
'message_id': 12,
|
||||
'group_id': 20001,
|
||||
'user_id': 10001,
|
||||
'message': [{'type': 'at', 'data': {'qq': '999'}}, {'type': 'text', 'data': {'text': ' ping'}}],
|
||||
'raw_message': '[CQ:at,qq=999] ping',
|
||||
'sender': {'user_id': 10001, 'nickname': 'Alice', 'card': 'Alice Card', 'role': 'member'},
|
||||
}
|
||||
)
|
||||
group_event = await AiocqhttpEventConverter.target2yiri(group)
|
||||
|
||||
assert isinstance(group_event, platform_events.MessageReceivedEvent)
|
||||
assert group_event.chat_type == platform_entities.ChatType.GROUP
|
||||
assert group_event.chat_id == 20001
|
||||
assert group_event.group.id == 20001
|
||||
assert isinstance(group_event.message_chain[1], platform_message.At)
|
||||
|
||||
|
||||
def test_aiocqhttp_event_converter_maps_notice_and_request_events():
|
||||
deleted = AiocqhttpEventConverter.notice_to_eba(
|
||||
onebot_event(
|
||||
{
|
||||
'post_type': 'notice',
|
||||
'notice_type': 'group_recall',
|
||||
'time': 1710000000,
|
||||
'self_id': 999,
|
||||
'group_id': 20001,
|
||||
'user_id': 10001,
|
||||
'operator_id': 10002,
|
||||
'message_id': 33,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert isinstance(deleted, platform_events.MessageDeletedEvent)
|
||||
assert deleted.message_id == 33
|
||||
|
||||
joined = AiocqhttpEventConverter.notice_to_eba(
|
||||
onebot_event(
|
||||
{
|
||||
'post_type': 'notice',
|
||||
'notice_type': 'group_increase',
|
||||
'sub_type': 'invite',
|
||||
'time': 1710000000,
|
||||
'self_id': 999,
|
||||
'group_id': 20001,
|
||||
'operator_id': 10002,
|
||||
'user_id': 10003,
|
||||
}
|
||||
),
|
||||
bot_user_id=999,
|
||||
)
|
||||
assert isinstance(joined, platform_events.MemberJoinedEvent)
|
||||
assert joined.join_type == 'invite'
|
||||
|
||||
bot_muted = AiocqhttpEventConverter.notice_to_eba(
|
||||
onebot_event(
|
||||
{
|
||||
'post_type': 'notice',
|
||||
'notice_type': 'group_ban',
|
||||
'sub_type': 'ban',
|
||||
'time': 1710000000,
|
||||
'self_id': 999,
|
||||
'group_id': 20001,
|
||||
'operator_id': 10002,
|
||||
'user_id': 999,
|
||||
'duration': 60,
|
||||
}
|
||||
),
|
||||
bot_user_id=999,
|
||||
)
|
||||
assert isinstance(bot_muted, platform_events.BotMutedEvent)
|
||||
assert bot_muted.duration == 60
|
||||
|
||||
friend_request = AiocqhttpEventConverter.request_to_eba(
|
||||
onebot_event(
|
||||
{
|
||||
'post_type': 'request',
|
||||
'request_type': 'friend',
|
||||
'time': 1710000000,
|
||||
'self_id': 999,
|
||||
'user_id': 10004,
|
||||
'comment': 'please',
|
||||
'flag': 'flag-1',
|
||||
}
|
||||
)
|
||||
)
|
||||
assert isinstance(friend_request, platform_events.FriendRequestReceivedEvent)
|
||||
assert friend_request.request_id == 'flag-1'
|
||||
|
||||
group_invite = AiocqhttpEventConverter.request_to_eba(
|
||||
onebot_event(
|
||||
{
|
||||
'post_type': 'request',
|
||||
'request_type': 'group',
|
||||
'sub_type': 'invite',
|
||||
'time': 1710000000,
|
||||
'self_id': 999,
|
||||
'group_id': 20001,
|
||||
'user_id': 10004,
|
||||
'flag': 'group-flag',
|
||||
}
|
||||
)
|
||||
)
|
||||
assert isinstance(group_invite, platform_events.BotInvitedToGroupEvent)
|
||||
assert group_invite.request_id == 'group-flag'
|
||||
|
||||
member_left = AiocqhttpEventConverter.notice_to_eba(
|
||||
onebot_event(
|
||||
{
|
||||
'post_type': 'notice',
|
||||
'notice_type': 'group_decrease',
|
||||
'sub_type': 'kick',
|
||||
'time': 1710000000,
|
||||
'self_id': 999,
|
||||
'group_id': 20001,
|
||||
'operator_id': 10002,
|
||||
'user_id': 10003,
|
||||
}
|
||||
),
|
||||
bot_user_id=999,
|
||||
)
|
||||
assert isinstance(member_left, platform_events.MemberLeftEvent)
|
||||
assert member_left.is_kicked is True
|
||||
|
||||
friend_added = AiocqhttpEventConverter.notice_to_eba(
|
||||
onebot_event(
|
||||
{
|
||||
'post_type': 'notice',
|
||||
'notice_type': 'friend_add',
|
||||
'time': 1710000000,
|
||||
'self_id': 999,
|
||||
'user_id': 10003,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert isinstance(friend_added, platform_events.FriendAddedEvent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aiocqhttp_send_reply_and_common_api_call_shapes():
|
||||
adapter = make_adapter()
|
||||
bot = AsyncMock()
|
||||
bot.send_group_msg.return_value = {'message_id': 1}
|
||||
bot.send_private_msg.return_value = {'message_id': 3}
|
||||
bot.send.return_value = {'message_id': 2}
|
||||
bot.delete_msg.return_value = {}
|
||||
bot.get_msg.return_value = {
|
||||
'message_id': 77,
|
||||
'message_type': 'group',
|
||||
'time': 1710000000,
|
||||
'group_id': 20001,
|
||||
'sender': {'user_id': 10001, 'nickname': 'Alice'},
|
||||
'message': [{'type': 'text', 'data': {'text': 'fetched'}}],
|
||||
}
|
||||
bot.get_group_info.return_value = {'group_id': 20001, 'group_name': 'Group', 'member_count': 3}
|
||||
bot.get_group_list.return_value = [{'group_id': 20001, 'group_name': 'Group', 'member_count': 3}]
|
||||
bot.get_group_member_list.return_value = [
|
||||
{
|
||||
'group_id': 20001,
|
||||
'user_id': 10001,
|
||||
'nickname': 'Alice',
|
||||
'card': 'Alice Card',
|
||||
'role': 'admin',
|
||||
'join_time': 1710000000,
|
||||
}
|
||||
]
|
||||
bot.get_group_member_info.return_value = {
|
||||
'group_id': 20001,
|
||||
'user_id': 10001,
|
||||
'nickname': 'Alice',
|
||||
'card': 'Alice Card',
|
||||
'role': 'admin',
|
||||
'join_time': 1710000000,
|
||||
}
|
||||
bot.get_stranger_info.return_value = {'user_id': 10001, 'nickname': 'Alice'}
|
||||
bot.get_friend_list.return_value = [{'user_id': 10001, 'nickname': 'Alice', 'remark': 'A'}]
|
||||
object.__setattr__(adapter, 'bot', bot)
|
||||
|
||||
result = await adapter.send_message(
|
||||
'group',
|
||||
'20001',
|
||||
platform_message.MessageChain([platform_message.Plain(text='hello')]),
|
||||
)
|
||||
assert result.message_id == 1
|
||||
bot.send_group_msg.assert_awaited_once()
|
||||
assert bot.send_group_msg.await_args.kwargs['group_id'] == 20001
|
||||
|
||||
source_event = onebot_event(
|
||||
{
|
||||
'post_type': 'message',
|
||||
'message_type': 'group',
|
||||
'sub_type': 'normal',
|
||||
'time': 1710000000,
|
||||
'self_id': 999,
|
||||
'message_id': 12,
|
||||
'group_id': 20001,
|
||||
'user_id': 10001,
|
||||
'message': [],
|
||||
'raw_message': '',
|
||||
'sender': {'user_id': 10001, 'nickname': 'Alice'},
|
||||
}
|
||||
)
|
||||
eba_source = await AiocqhttpEventConverter.message_to_eba(source_event)
|
||||
reply = await adapter.reply_message(
|
||||
eba_source,
|
||||
platform_message.MessageChain([platform_message.Plain(text='pong')]),
|
||||
quote_origin=True,
|
||||
)
|
||||
assert reply.message_id == 2
|
||||
assert bot.send.await_args.args[0] is source_event
|
||||
|
||||
await adapter.delete_message('group', 20001, 12)
|
||||
bot.delete_msg.assert_awaited_once_with(message_id=12)
|
||||
|
||||
group = await adapter.get_group_info(20001)
|
||||
assert group.name == 'Group'
|
||||
|
||||
groups = await adapter.get_group_list()
|
||||
assert groups[0].id == 20001
|
||||
|
||||
members = await adapter.get_group_member_list(20001)
|
||||
assert members[0].display_name == 'Alice Card'
|
||||
|
||||
member = await adapter.get_group_member_info(20001, 10001)
|
||||
assert member.role == platform_entities.MemberRole.ADMIN
|
||||
|
||||
user = await adapter.get_user_info(10001)
|
||||
assert user.nickname == 'Alice'
|
||||
|
||||
friends = await adapter.get_friend_list()
|
||||
assert friends[0].remark == 'A'
|
||||
|
||||
fetched = await adapter.get_message('group', 20001, 77)
|
||||
assert fetched.message_id == 77
|
||||
assert str(fetched.message_chain) == 'fetched'
|
||||
|
||||
forwarded = await adapter.forward_message('group', 20001, 77, 'private', 10001)
|
||||
assert forwarded.message_id == 3
|
||||
bot.send_private_msg.assert_awaited_once()
|
||||
|
||||
await adapter.set_group_name(20001, 'New Name')
|
||||
bot.set_group_name.assert_awaited_once_with(group_id=20001, group_name='New Name')
|
||||
|
||||
await adapter.mute_member(20001, 10001, 60)
|
||||
bot.set_group_ban.assert_awaited_with(group_id=20001, user_id=10001, duration=60)
|
||||
|
||||
await adapter.unmute_member(20001, 10001)
|
||||
bot.set_group_ban.assert_awaited_with(group_id=20001, user_id=10001, duration=0)
|
||||
|
||||
await adapter.kick_member(20001, 10001)
|
||||
bot.set_group_kick.assert_awaited_once_with(group_id=20001, user_id=10001, reject_add_request=False)
|
||||
|
||||
await adapter.leave_group(20001)
|
||||
bot.set_group_leave.assert_awaited_once_with(group_id=20001, is_dismiss=False)
|
||||
|
||||
await adapter.approve_friend_request('flag-1', True, 'Alice')
|
||||
bot.set_friend_add_request.assert_awaited_once_with(flag='flag-1', approve=True, remark='Alice')
|
||||
|
||||
await adapter.approve_group_invite('flag-2', False)
|
||||
bot.set_group_add_request.assert_awaited_once_with(flag='flag-2', sub_type='invite', approve=False, reason='')
|
||||
|
||||
with pytest.raises(NotSupportedError):
|
||||
await adapter.upload_file(b'data', 'a.txt')
|
||||
|
||||
with pytest.raises(NotSupportedError):
|
||||
await adapter.get_file_url('file-1')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aiocqhttp_platform_specific_api_calls_all_declared_actions():
|
||||
adapter = make_adapter()
|
||||
bot = AsyncMock()
|
||||
bot.call_action.return_value = {'ok': True}
|
||||
object.__setattr__(adapter, 'bot', bot)
|
||||
|
||||
for action in PLATFORM_API_MAP:
|
||||
result = await adapter.call_platform_api(action, {'x': 1})
|
||||
assert result == {'ok': True}
|
||||
|
||||
called_actions = [call.args[0] for call in bot.call_action.await_args_list]
|
||||
assert called_actions == list(PLATFORM_API_MAP)
|
||||
|
||||
with pytest.raises(NotSupportedError):
|
||||
await adapter.call_platform_api('missing_action', {})
|
||||
Reference in New Issue
Block a user