From 690cb96199e162db81a65d7577f692fb7652de4a Mon Sep 17 00:00:00 2001 From: a0gent Date: Sun, 1 Feb 2026 12:21:03 +0800 Subject: [PATCH 1/4] feat: forward ws --- FORWARD_WEBSOCKET.md | 256 ++++++++++++++++++++++++++++++++++++++ IMPLEMENTATION_SUMMARY.md | 78 ++++++++++++ aiocqhttp/__init__.py | 36 +++++- aiocqhttp/api_impl.py | 199 ++++++++++++++++++++++++++++- demo_forward_ws.py | 95 ++++++++++++++ setup.py | 3 +- test_forward_ws.py | 85 +++++++++++++ test_stability.py | 78 ++++++++++++ 8 files changed, 820 insertions(+), 10 deletions(-) create mode 100644 FORWARD_WEBSOCKET.md create mode 100644 IMPLEMENTATION_SUMMARY.md create mode 100644 demo_forward_ws.py create mode 100644 test_forward_ws.py create mode 100644 test_stability.py diff --git a/FORWARD_WEBSOCKET.md b/FORWARD_WEBSOCKET.md new file mode 100644 index 0000000..2ff8fb2 --- /dev/null +++ b/FORWARD_WEBSOCKET.md @@ -0,0 +1,256 @@ +# aiocqhttp Forward WebSocket Support + +This document describes the forward WebSocket support added to aiocqhttp, enabling the library to work as a WebSocket client connecting to OneBot servers. + +## Overview + +aiocqhttp now supports three connection modes: + +1. **HTTP** - Traditional HTTP API calls (existing) +2. **Reverse WebSocket** - OneBot connects to your server (existing) +3. **Forward WebSocket** - Your bot connects to OneBot server (**NEW**) + +## Quick Start + +### Installation + +To use forward WebSocket functionality, install the websockets dependency: + +```bash +pip install 'aiocqhttp[forward-ws]' +# or for all optional dependencies: +pip install 'aiocqhttp[all]' +``` + +### Basic Usage + +Simply change your `api_root` from `http://` to `ws://`: + +```python +from aiocqhttp import CQHttp + +# Before: HTTP mode +# bot = CQHttp(api_root="http://127.0.0.1:5700") + +# After: Forward WebSocket mode +bot = CQHttp(api_root="ws://127.0.0.1:3001") + +@bot.on_message('private') +async def handle_private_message(event): + await bot.send(event, f"You said: {event.message}") + +# Start the server (for reverse WebSocket/HTTP endpoints) +bot.run(host="127.0.0.1", port=8080) +``` + +That's it! No other code changes needed. + +## Connection Priority + +When multiple connection methods are available, aiocqhttp uses this priority: + +1. **Forward WebSocket** (highest priority) +2. **Reverse WebSocket** +3. **HTTP** (lowest priority) + +This ensures the most efficient connection method is used. + +## Features + +### API Calls + +All existing API methods work exactly the same: + +```python +# These work identically in all connection modes +await bot.send_private_msg(user_id=123456, message="Hello") +await bot.get_friend_list() +await bot.set_group_ban(group_id=789, user_id=123456, duration=60) +``` + +### Event Handling + +Events are received and processed through the same event system: + +```python +@bot.on_message('group') +async def handle_group_message(event): + if event.message.startswith('/ping'): + await bot.send(event, 'Pong!') + +@bot.on_notice('group_increase') +async def welcome_new_member(event): + await bot.send_group_msg( + group_id=event.group_id, + message=f"Welcome {event.user_id}!" + ) +``` + +### Connection Events + +Monitor WebSocket connection status: + +```python +@bot.on_websocket_connection +async def on_connected(event): + print("Forward WebSocket connected!") + # Optionally test the connection + try: + login_info = await bot.get_login_info() + print(f"Connected as: {login_info.get('nickname')}") + except Exception as e: + print(f"Connection test failed: {e}") +``` + +## Configuration + +### URL Format + +- **WebSocket**: `ws://host:port/path` +- **Secure WebSocket**: `wss://host:port/path` +- **With authentication**: Include `access_token` parameter + +```python +# Basic WebSocket +bot = CQHttp(api_root="ws://127.0.0.1:3001") + +# Secure WebSocket +bot = CQHttp(api_root="wss://your-server.com:443/ws") + +# With authentication +bot = CQHttp( + api_root="ws://127.0.0.1:3001", + access_token="your_token_here" +) +``` + +### Timeout Configuration + +Control connection and API call timeouts: + +```python +bot = CQHttp( + api_root="ws://127.0.0.1:3001", + api_timeout_sec=30 # 30 second timeout for API calls +) +``` + +## OneBot Server Configuration + +Configure your OneBot implementation to enable forward WebSocket: + +### go-cqhttp + +```yaml +servers: + - ws: + host: 127.0.0.1 + port: 3001 +``` + +### NapCat + +```json +{ + "http": { + "enable": false + }, + "ws": { + "enable": true, + "host": "127.0.0.1", + "port": 3001 + } +} +``` + +## Migration Guide + +### From HTTP + +```python +# Old: HTTP mode +bot = CQHttp(api_root="http://127.0.0.1:5700") + +# New: Forward WebSocket mode +bot = CQHttp(api_root="ws://127.0.0.1:3001") +``` + +### From Reverse WebSocket Only + +If you were only using reverse WebSocket (no `api_root`), you can now add forward WebSocket: + +```python +# Old: Reverse WebSocket only (for events) +bot = CQHttp() # No API calls possible + +# New: Forward WebSocket (events + API calls) +bot = CQHttp(api_root="ws://127.0.0.1:3001") +``` + +## Troubleshooting + +### Import Error + +``` +ImportError: websockets package is required for forward WebSocket support +``` + +**Solution**: Install the websockets dependency: +```bash +pip install 'aiocqhttp[forward-ws]' +``` + +### Connection Failed + +``` +NetworkError: WebSocket connection failed +``` + +**Solutions**: +1. Verify OneBot server is running and listening on the specified port +2. Check firewall settings +3. Verify URL format (ws:// or wss://) +4. Check access_token if authentication is required + +### No Events Received + +**Check**: +1. OneBot server is configured to send events to WebSocket clients +2. Bot has proper permissions in groups/chats +3. Event handlers are properly registered + +## Technical Details + +### Message Routing + +The forward WebSocket implementation automatically routes messages: + +- **API Responses**: Messages with `echo` field → API response handling +- **OneBot Events**: Messages with `post_type` field → Event handling + +### Connection Management + +- Automatic connection on first API call +- Connection reuse for subsequent calls +- Graceful reconnection on connection loss +- Proper cleanup on bot shutdown + +### Integration + +Forward WebSocket integrates seamlessly with existing aiocqhttp features: + +- Event bus system +- Message classes +- Error handling +- Synchronous API wrapper +- Before-sending hooks + +## Examples + +See `demo_forward_ws.py` for complete examples. + +## Compatibility + +- Python 3.7+ +- OneBot v11/v12 compatible servers +- Works alongside existing HTTP and reverse WebSocket functionality \ No newline at end of file diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..3268358 --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,78 @@ +# Forward WebSocket Implementation Summary + +## Files Modified + +### 1. `aiocqhttp/api_impl.py` +- Added imports: `uuid`, `logging`, `websockets` +- Added `_is_websocket_url()` utility function +- **NEW**: `WebSocketForwardApi` class - implements forward WebSocket client +- Updated `UnifiedApi` class to support forward WebSocket with priority: Forward WS > Reverse WS > HTTP + +### 2. `aiocqhttp/__init__.py` +- Updated imports to include `WebSocketForwardApi` and `_is_websocket_url` +- Modified `_configure()` method to detect WebSocket URLs and create appropriate API +- **SAFE APPROACH**: Kept existing `UnifiedApi()` initialization, updated instance attributes instead of replacing + +### 3. `setup.py` +- Added `websockets>=8.0` to `extras_require` +- Added new `forward-ws` extra for minimal WebSocket support +- Updated `all` extra to include websockets + +## Key Features Implemented + +### WebSocketForwardApi Class +- **Connection Management**: Automatic connection with reconnection support +- **Dual Message Routing**: + - API responses (with `echo` field) → Response handling + - OneBot events (with `post_type` field) → Event handling +- **Echo Matching**: UUID-based request-response matching +- **Authentication**: Bearer token support +- **Error Handling**: Proper timeout and connection error handling +- **Lifecycle**: Graceful connection and cleanup + +### URL-Based Detection +- `ws://` or `wss://` URLs automatically create WebSocketForwardApi +- `http://` or `https://` URLs continue to create HttpApi +- Maintains complete backward compatibility + +### Priority System +1. Forward WebSocket (if configured) +2. Reverse WebSocket (if available) +3. HTTP (fallback) + +## Usage + +```python +# Simple migration from HTTP to forward WebSocket +# Old: +bot = CQHttp(api_root="http://127.0.0.1:5700") +# New: +bot = CQHttp(api_root="ws://127.0.0.1:3001") +``` + +## Test Files Created + +- `test_forward_ws.py` - Basic functionality tests +- `demo_forward_ws.py` - Usage examples and demo +- `FORWARD_WEBSOCKET.md` - Complete documentation + +## Installation + +```bash +# For forward WebSocket support: +pip install 'aiocqhttp[forward-ws]' + +# For all features: +pip install 'aiocqhttp[all]' +``` + +## Verification + +All tests pass: +✓ Backward compatibility maintained +✓ HTTP mode works +✓ WebSocket URL detection works +✓ WSS (secure) URL detection works +✓ No syntax errors + +The implementation successfully adds forward WebSocket support while maintaining full backward compatibility with existing HTTP and reverse WebSocket functionality. \ No newline at end of file diff --git a/aiocqhttp/__init__.py b/aiocqhttp/__init__.py index 97d7292..e1d4ec3 100644 --- a/aiocqhttp/__init__.py +++ b/aiocqhttp/__init__.py @@ -20,7 +20,7 @@ from .api import AsyncApi, SyncApi from .api_impl import (SyncWrapperApi, HttpApi, WebSocketReverseApi, - UnifiedApi, ResultStore) + UnifiedApi, ResultStore, WebSocketForwardApi, _is_websocket_url) from .bus import EventBus from .exceptions import Error, TimingError from .event import Event @@ -155,12 +155,38 @@ def _configure(self, api_timeout_sec = api_timeout_sec or 60 # wait for 60 secs by default self._access_token = access_token self._secret = secret - self._api._http_api = HttpApi(api_root, access_token, api_timeout_sec) + + # Configure API implementations based on api_root type + http_api = None + wsf_api = None + + if _is_websocket_url(api_root): + # Forward WebSocket mode + try: + wsf_api = WebSocketForwardApi( + ws_url=api_root, + access_token=access_token, + timeout_sec=api_timeout_sec, + event_handler=self._handle_event + ) + except ImportError as e: + self.logger.error(f"Failed to create WebSocketForwardApi: {e}") + raise + else: + # HTTP mode + http_api = HttpApi(api_root, access_token, api_timeout_sec) + + # Always configure reverse WebSocket (independent) self._wsr_api_clients = {} # connected wsr api clients self._wsr_event_clients = set() - self._api._wsr_api = WebSocketReverseApi(self._wsr_api_clients, - self._wsr_event_clients, - api_timeout_sec) + wsr_api = WebSocketReverseApi(self._wsr_api_clients, + self._wsr_event_clients, + api_timeout_sec) + + # Update the existing UnifiedApi instance instead of creating a new one + self._api._http_api = http_api + self._api._wsr_api = wsr_api + self._api._wsf_api = wsf_api async def _before_serving(self): self._loop = asyncio.get_running_loop() diff --git a/aiocqhttp/api_impl.py b/aiocqhttp/api_impl.py index 8faaada..551b3cd 100644 --- a/aiocqhttp/api_impl.py +++ b/aiocqhttp/api_impl.py @@ -4,6 +4,8 @@ import asyncio import sys +import uuid +import logging from typing import Callable, Dict, Any, Optional, Set, Union, Awaitable from .api import Api, AsyncApi, SyncApi @@ -13,6 +15,11 @@ except ImportError: import json +try: + import websockets +except ImportError: + websockets = None + import httpx from quart import websocket as event_ws from quart.wrappers.websocket import Websocket @@ -158,28 +165,39 @@ class UnifiedApi(AsyncApi): """ 统一 API 实现类。 - 同时维护 `HttpApi` 和 `WebSocketReverseApi` 对象,根据可用情况,选择两者中的某个使用。 + 同时维护 `HttpApi`、`WebSocketReverseApi` 和 `WebSocketForwardApi` 对象,根据可用情况选择使用。 """ def __init__(self, http_api: Optional[AsyncApi] = None, - wsr_api: Optional[AsyncApi] = None): + wsr_api: Optional[AsyncApi] = None, + wsf_api: Optional[AsyncApi] = None): super().__init__() self._http_api = http_api self._wsr_api = wsr_api + self._wsf_api = wsf_api async def call_action(self, action: str, **params) -> Any: result = None succeeded = False - if self._wsr_api: - # WebSocket is preferred + # Try forward WebSocket first (highest priority) + if self._wsf_api: + try: + result = await self._wsf_api.call_action(action, **params) + succeeded = True + except ApiNotAvailable: + pass + + # Try reverse WebSocket second + if not succeeded and self._wsr_api: try: result = await self._wsr_api.call_action(action, **params) succeeded = True except ApiNotAvailable: pass + # Try HTTP last if not succeeded and self._http_api: try: result = await self._http_api.call_action(action, **params) @@ -192,6 +210,179 @@ async def call_action(self, action: str, **params) -> Any: return result +def _is_websocket_url(url: str) -> bool: + """Check if URL is a WebSocket URL.""" + return url and (url.startswith('ws://') or url.startswith('wss://')) + + +class WebSocketForwardApi(AsyncApi): + """ + 正向 WebSocket API 实现类。 + + 实现作为 WebSocket 客户端主动连接到 OneBot 服务器。 + """ + + def __init__(self, ws_url: str, access_token: Optional[str], + timeout_sec: float, event_handler: Optional[Callable]): + super().__init__() + if not websockets: + raise ImportError("websockets package is required for forward WebSocket support") + + self._ws_url = ws_url + self._access_token = access_token + self._timeout_sec = timeout_sec + self._event_handler = event_handler + self._connection = None + self._response_futures: Dict[str, asyncio.Future] = {} + self._running = False + self._connect_lock = asyncio.Lock() + self._logger = logging.getLogger(__name__) + self._auto_connect_task = None + self._schedule_auto_connect() + + def _schedule_auto_connect(self) -> None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = None + + if not loop: + self._logger.warning('No event loop available for forward WebSocket auto-connect') + return + + try: + self._auto_connect_task = loop.create_task(self._auto_connect()) + except Exception as e: + self._logger.error(f'Failed to schedule forward WebSocket auto-connect: {e}') + + async def _auto_connect(self) -> None: + try: + await self._ensure_connected() + except Exception as e: + self._logger.error(f'Forward WebSocket auto-connect failed: {e}') + + async def call_action(self, action: str, **params) -> Any: + await self._ensure_connected() + if not self._connection: + raise ApiNotAvailable + + echo = str(uuid.uuid4()) + future = asyncio.get_event_loop().create_future() + self._response_futures[echo] = future + + request = { + 'action': action, + 'params': params, + 'echo': echo + } + + try: + await self._connection.send(json.dumps(request, ensure_ascii=False)) + response = await asyncio.wait_for(future, timeout=self._timeout_sec) + return _handle_api_result(response) + except asyncio.TimeoutError: + raise NetworkError('WebSocket API call timeout') + finally: + self._response_futures.pop(echo, None) + + async def _ensure_connected(self): + if self._is_connection_closed(): + async with self._connect_lock: + if self._is_connection_closed(): + await self._connect() + + def _is_connection_closed(self) -> bool: + if not self._connection: + return True + if hasattr(self._connection, "closed"): + return bool(self._connection.closed) + if hasattr(self._connection, "close_code"): + return self._connection.close_code is not None + state = getattr(self._connection, "state", None) + if state is not None: + return str(state).lower() in {"closing", "closed"} + return False + + async def _connect(self): + headers = {} + if self._access_token: + headers['Authorization'] = f'Bearer {self._access_token}' + + try: + try: + self._connection = await websockets.connect( + self._ws_url, + additional_headers=headers, + open_timeout=self._timeout_sec + ) + except Exception as e: + if "additional_headers" not in str(e) and "open_timeout" not in str(e): + raise + try: + self._connection = await websockets.connect( + self._ws_url, + headers=headers, + open_timeout=self._timeout_sec + ) + except Exception as e2: + if "headers" not in str(e2) and "open_timeout" not in str(e2): + raise + self._connection = await websockets.connect( + self._ws_url, + extra_headers=headers, + timeout=self._timeout_sec + ) + self._running = True + asyncio.create_task(self._message_loop()) + self._logger.info(f'Forward WebSocket connected to {self._ws_url}') + except Exception as e: + self._logger.error(f'Failed to connect to {self._ws_url}: {e}') + raise NetworkError(f'WebSocket connection failed: {e}') + + async def _message_loop(self): + try: + while self._running and self._connection: + try: + message = await self._connection.recv() + try: + data = json.loads(message) + await self._handle_message(data) + except json.JSONDecodeError: + self._logger.warning(f'Received invalid JSON: {message}') + except websockets.exceptions.ConnectionClosed: + self._logger.info('Forward WebSocket connection closed') + break + except Exception as e: + self._logger.error(f'Error in message loop: {e}') + finally: + self._running = False + self._connection = None + + async def _handle_message(self, data: Dict[str, Any]): + # API responses have 'echo' field + if 'echo' in data and data['echo'] in self._response_futures: + future = self._response_futures[data['echo']] + if not future.done(): + future.set_result(data) + # OneBot events have 'post_type' field + elif 'post_type' in data and self._event_handler: + async def _run_handler(): + try: + await self._event_handler(data) + except Exception as e: + self._logger.error(f'Error handling event: {e}') + asyncio.create_task(_run_handler()) + + async def close(self): + self._running = False + if self._connection: + await self._connection.close() + self._connection = None + + class SyncWrapperApi(SyncApi): """ 封装 `AsyncApi` 对象,使其可同步地调用。 diff --git a/demo_forward_ws.py b/demo_forward_ws.py new file mode 100644 index 0000000..9fbd0f2 --- /dev/null +++ b/demo_forward_ws.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +Example usage of aiocqhttp with forward WebSocket support. +""" + +import asyncio +import logging +from aiocqhttp import CQHttp + +# Configure logging to see what's happening +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +async def demo_forward_websocket(): + """ + Demo showing how to use forward WebSocket with aiocqhttp. + """ + print("=== aiocqhttp Forward WebSocket Demo ===\n") + + # Create bot with forward WebSocket + # Replace with your actual OneBot server URL + bot = CQHttp(api_root="ws://127.0.0.1:3001") + + @bot.on_message('private') + async def handle_private_message(event): + logger.info(f"Received private message: {event.message}") + # Echo the message back + await bot.send(event, f"You said: {event.message}") + + @bot.on_message('group') + async def handle_group_message(event): + logger.info(f"Received group message: {event.message}") + if str(event.message).startswith("/echo"): + await bot.send(event, f"Echo: {str(event.message)[5:]}") + + @bot.on_websocket_connection + async def on_connected(event): + logger.info("Forward WebSocket connected!") + # Test API call + try: + login_info = await bot.get_login_info() + logger.info(f"Bot login info: {login_info}") + except Exception as e: + logger.error(f"Failed to get login info: {e}") + + print("Bot configured with forward WebSocket!") + print("Features:") + print("- Automatically connects to OneBot server as WebSocket client") + print("- Receives OneBot events (messages, notices, etc.)") + print("- Can make API calls through the same WebSocket connection") + print("- Falls back to reverse WebSocket/HTTP if forward WebSocket fails") + print("\nNote: This demo requires a running OneBot server at ws://127.0.0.1:3001") + print("Examples: go-cqhttp, NapCat, or other OneBot implementations") + + # In a real application, you would start the bot server: + # await bot.run_task(host="127.0.0.1", port=8080) + +async def demo_migration_example(): + """ + Show how existing code can easily migrate to forward WebSocket. + """ + print("\n=== Migration Example ===\n") + + print("Before (HTTP):") + print('bot = CQHttp(api_root="http://127.0.0.1:5700")') + + print("\nAfter (Forward WebSocket):") + print('bot = CQHttp(api_root="ws://127.0.0.1:3001")') + + print("\nThat's it! No other code changes needed.") + print("- Same API methods (bot.send_private_msg, etc.)") + print("- Same event handlers (@bot.on_message, etc.)") + print("- Same configuration options") + +async def demo_unified_api(): + """ + Show how UnifiedApi prioritizes different connection types. + """ + print("\n=== UnifiedApi Priority Demo ===\n") + + # This would try forward WebSocket, then reverse WebSocket, then HTTP + # (In practice, you'd only configure one at a time) + print("Priority order:") + print("1. Forward WebSocket (ws://... or wss://...)") + print("2. Reverse WebSocket (when OneBot connects to your server)") + print("3. HTTP (http://... or https://...)") + print("\nThis ensures the best available connection method is used.") + +if __name__ == "__main__": + async def main(): + await demo_forward_websocket() + await demo_migration_example() + await demo_unified_api() + + asyncio.run(main()) \ No newline at end of file diff --git a/setup.py b/setup.py index 7b7b4f6..82b77b4 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,8 @@ }, install_requires=['Quart>=0.17,<1.0', 'httpx>=0.11,<1.0'], extras_require={ - 'all': ['ujson'], + 'all': ['ujson', 'websockets>=8.0'], + 'forward-ws': ['websockets>=8.0'], }, python_requires='>=3.7', platforms='any', diff --git a/test_forward_ws.py b/test_forward_ws.py new file mode 100644 index 0000000..d3bd493 --- /dev/null +++ b/test_forward_ws.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Basic test script for forward WebSocket functionality. +""" + +import asyncio +import logging +from aiocqhttp import CQHttp + +# Configure logging +logging.basicConfig(level=logging.DEBUG) + +async def test_http_mode(): + """Test that HTTP mode still works.""" + print("Testing HTTP mode...") + bot = CQHttp(api_root="http://127.0.0.1:5700") + assert hasattr(bot._api, '_http_api') + assert bot._api._http_api is not None + assert bot._api._wsf_api is None + print("✓ HTTP mode configuration works") + +async def test_websocket_detection(): + """Test that WebSocket URL detection works.""" + print("Testing WebSocket URL detection...") + + try: + # This should create WebSocketForwardApi + bot = CQHttp(api_root="ws://127.0.0.1:8080") + assert hasattr(bot._api, '_wsf_api') + assert bot._api._wsf_api is not None + assert bot._api._http_api is None + print("✓ WebSocket mode configuration works") + + # Clean up + if bot._api._wsf_api: + await bot._api._wsf_api.close() + + except ImportError as e: + print(f"! WebSocket functionality requires 'websockets' package: {e}") + print(" Install with: pip install 'aiocqhttp[forward-ws]'") + +async def test_wss_detection(): + """Test that WSS (secure WebSocket) detection works.""" + print("Testing WSS URL detection...") + + try: + bot = CQHttp(api_root="wss://example.com:8080") + assert hasattr(bot._api, '_wsf_api') + assert bot._api._wsf_api is not None + print("✓ WSS mode configuration works") + + # Clean up + if bot._api._wsf_api: + await bot._api._wsf_api.close() + + except ImportError as e: + print(f"! WebSocket functionality requires 'websockets' package: {e}") + +async def test_backward_compatibility(): + """Test that existing functionality isn't broken.""" + print("Testing backward compatibility...") + + # Test default (no api_root) + bot = CQHttp() + assert hasattr(bot._api, '_http_api') + print("✓ Default configuration works") + + # Test None api_root + bot2 = CQHttp(api_root=None) + assert hasattr(bot2._api, '_http_api') + print("✓ None api_root works") + +async def main(): + """Run all tests.""" + print("Testing aiocqhttp forward WebSocket implementation...\n") + + await test_backward_compatibility() + await test_http_mode() + await test_websocket_detection() + await test_wss_detection() + + print("\n✓ All tests completed!") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/test_stability.py b/test_stability.py new file mode 100644 index 0000000..c3b0854 --- /dev/null +++ b/test_stability.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +Test for timing and attribute access safety. +""" + +import asyncio +from aiocqhttp import CQHttp + +async def test_early_api_access(): + """Test that self.api is always accessible, even before _configure.""" + print("Testing early API access safety...") + + # This should work without errors + bot = CQHttp() + + # Should be accessible immediately after __init__ + api = bot.api + assert api is not None + print("✓ bot.api accessible immediately after __init__") + + # Should handle calls gracefully (though they may fail due to no config) + try: + # This will likely fail due to no api_root, but should not crash + await bot.get_login_info() + except Exception as e: + # Expected - no api_root configured + print(f"✓ API call fails gracefully: {type(e).__name__}") + + print("✓ Early API access safety test passed") + +async def test_inheritance_safety(): + """Test that inheritance scenarios work.""" + print("Testing inheritance safety...") + + class CustomBot(CQHttp): + def __init__(self): + super().__init__() + # This should work - accessing api in child __init__ + self.my_api = self.api + assert self.my_api is not None + + bot = CustomBot() + print("✓ Inheritance with early API access works") + +async def test_reconfiguration(): + """Test that API can be reconfigured multiple times.""" + print("Testing reconfiguration safety...") + + bot = CQHttp() + original_api = bot.api + + # Reconfigure to HTTP + bot._configure(api_root="http://127.0.0.1:5700") + assert bot.api is original_api # Same object + assert bot.api._http_api is not None + print("✓ HTTP reconfiguration works") + + # Reconfigure to WebSocket + bot._configure(api_root="ws://127.0.0.1:3001") + assert bot.api is original_api # Still same object + assert bot.api._wsf_api is not None + print("✓ WebSocket reconfiguration works") + + # Clean up + if bot.api._wsf_api: + await bot.api._wsf_api.close() + +async def main(): + print("Testing SDK stability and timing safety...\n") + + await test_early_api_access() + await test_inheritance_safety() + await test_reconfiguration() + + print("\n✓ All stability tests passed!") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file From 3ce00ec4a3b259aa0833282855ea65cf58fa7c91 Mon Sep 17 00:00:00 2001 From: a0gent Date: Sun, 1 Feb 2026 15:05:00 +0800 Subject: [PATCH 2/4] chore: clean ai files --- FORWARD_WEBSOCKET.md | 256 -------------------------------------- IMPLEMENTATION_SUMMARY.md | 78 ------------ demo_forward_ws.py | 95 -------------- test_forward_ws.py | 85 ------------- test_stability.py | 78 ------------ 5 files changed, 592 deletions(-) delete mode 100644 FORWARD_WEBSOCKET.md delete mode 100644 IMPLEMENTATION_SUMMARY.md delete mode 100644 demo_forward_ws.py delete mode 100644 test_forward_ws.py delete mode 100644 test_stability.py diff --git a/FORWARD_WEBSOCKET.md b/FORWARD_WEBSOCKET.md deleted file mode 100644 index 2ff8fb2..0000000 --- a/FORWARD_WEBSOCKET.md +++ /dev/null @@ -1,256 +0,0 @@ -# aiocqhttp Forward WebSocket Support - -This document describes the forward WebSocket support added to aiocqhttp, enabling the library to work as a WebSocket client connecting to OneBot servers. - -## Overview - -aiocqhttp now supports three connection modes: - -1. **HTTP** - Traditional HTTP API calls (existing) -2. **Reverse WebSocket** - OneBot connects to your server (existing) -3. **Forward WebSocket** - Your bot connects to OneBot server (**NEW**) - -## Quick Start - -### Installation - -To use forward WebSocket functionality, install the websockets dependency: - -```bash -pip install 'aiocqhttp[forward-ws]' -# or for all optional dependencies: -pip install 'aiocqhttp[all]' -``` - -### Basic Usage - -Simply change your `api_root` from `http://` to `ws://`: - -```python -from aiocqhttp import CQHttp - -# Before: HTTP mode -# bot = CQHttp(api_root="http://127.0.0.1:5700") - -# After: Forward WebSocket mode -bot = CQHttp(api_root="ws://127.0.0.1:3001") - -@bot.on_message('private') -async def handle_private_message(event): - await bot.send(event, f"You said: {event.message}") - -# Start the server (for reverse WebSocket/HTTP endpoints) -bot.run(host="127.0.0.1", port=8080) -``` - -That's it! No other code changes needed. - -## Connection Priority - -When multiple connection methods are available, aiocqhttp uses this priority: - -1. **Forward WebSocket** (highest priority) -2. **Reverse WebSocket** -3. **HTTP** (lowest priority) - -This ensures the most efficient connection method is used. - -## Features - -### API Calls - -All existing API methods work exactly the same: - -```python -# These work identically in all connection modes -await bot.send_private_msg(user_id=123456, message="Hello") -await bot.get_friend_list() -await bot.set_group_ban(group_id=789, user_id=123456, duration=60) -``` - -### Event Handling - -Events are received and processed through the same event system: - -```python -@bot.on_message('group') -async def handle_group_message(event): - if event.message.startswith('/ping'): - await bot.send(event, 'Pong!') - -@bot.on_notice('group_increase') -async def welcome_new_member(event): - await bot.send_group_msg( - group_id=event.group_id, - message=f"Welcome {event.user_id}!" - ) -``` - -### Connection Events - -Monitor WebSocket connection status: - -```python -@bot.on_websocket_connection -async def on_connected(event): - print("Forward WebSocket connected!") - # Optionally test the connection - try: - login_info = await bot.get_login_info() - print(f"Connected as: {login_info.get('nickname')}") - except Exception as e: - print(f"Connection test failed: {e}") -``` - -## Configuration - -### URL Format - -- **WebSocket**: `ws://host:port/path` -- **Secure WebSocket**: `wss://host:port/path` -- **With authentication**: Include `access_token` parameter - -```python -# Basic WebSocket -bot = CQHttp(api_root="ws://127.0.0.1:3001") - -# Secure WebSocket -bot = CQHttp(api_root="wss://your-server.com:443/ws") - -# With authentication -bot = CQHttp( - api_root="ws://127.0.0.1:3001", - access_token="your_token_here" -) -``` - -### Timeout Configuration - -Control connection and API call timeouts: - -```python -bot = CQHttp( - api_root="ws://127.0.0.1:3001", - api_timeout_sec=30 # 30 second timeout for API calls -) -``` - -## OneBot Server Configuration - -Configure your OneBot implementation to enable forward WebSocket: - -### go-cqhttp - -```yaml -servers: - - ws: - host: 127.0.0.1 - port: 3001 -``` - -### NapCat - -```json -{ - "http": { - "enable": false - }, - "ws": { - "enable": true, - "host": "127.0.0.1", - "port": 3001 - } -} -``` - -## Migration Guide - -### From HTTP - -```python -# Old: HTTP mode -bot = CQHttp(api_root="http://127.0.0.1:5700") - -# New: Forward WebSocket mode -bot = CQHttp(api_root="ws://127.0.0.1:3001") -``` - -### From Reverse WebSocket Only - -If you were only using reverse WebSocket (no `api_root`), you can now add forward WebSocket: - -```python -# Old: Reverse WebSocket only (for events) -bot = CQHttp() # No API calls possible - -# New: Forward WebSocket (events + API calls) -bot = CQHttp(api_root="ws://127.0.0.1:3001") -``` - -## Troubleshooting - -### Import Error - -``` -ImportError: websockets package is required for forward WebSocket support -``` - -**Solution**: Install the websockets dependency: -```bash -pip install 'aiocqhttp[forward-ws]' -``` - -### Connection Failed - -``` -NetworkError: WebSocket connection failed -``` - -**Solutions**: -1. Verify OneBot server is running and listening on the specified port -2. Check firewall settings -3. Verify URL format (ws:// or wss://) -4. Check access_token if authentication is required - -### No Events Received - -**Check**: -1. OneBot server is configured to send events to WebSocket clients -2. Bot has proper permissions in groups/chats -3. Event handlers are properly registered - -## Technical Details - -### Message Routing - -The forward WebSocket implementation automatically routes messages: - -- **API Responses**: Messages with `echo` field → API response handling -- **OneBot Events**: Messages with `post_type` field → Event handling - -### Connection Management - -- Automatic connection on first API call -- Connection reuse for subsequent calls -- Graceful reconnection on connection loss -- Proper cleanup on bot shutdown - -### Integration - -Forward WebSocket integrates seamlessly with existing aiocqhttp features: - -- Event bus system -- Message classes -- Error handling -- Synchronous API wrapper -- Before-sending hooks - -## Examples - -See `demo_forward_ws.py` for complete examples. - -## Compatibility - -- Python 3.7+ -- OneBot v11/v12 compatible servers -- Works alongside existing HTTP and reverse WebSocket functionality \ No newline at end of file diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 3268358..0000000 --- a/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,78 +0,0 @@ -# Forward WebSocket Implementation Summary - -## Files Modified - -### 1. `aiocqhttp/api_impl.py` -- Added imports: `uuid`, `logging`, `websockets` -- Added `_is_websocket_url()` utility function -- **NEW**: `WebSocketForwardApi` class - implements forward WebSocket client -- Updated `UnifiedApi` class to support forward WebSocket with priority: Forward WS > Reverse WS > HTTP - -### 2. `aiocqhttp/__init__.py` -- Updated imports to include `WebSocketForwardApi` and `_is_websocket_url` -- Modified `_configure()` method to detect WebSocket URLs and create appropriate API -- **SAFE APPROACH**: Kept existing `UnifiedApi()` initialization, updated instance attributes instead of replacing - -### 3. `setup.py` -- Added `websockets>=8.0` to `extras_require` -- Added new `forward-ws` extra for minimal WebSocket support -- Updated `all` extra to include websockets - -## Key Features Implemented - -### WebSocketForwardApi Class -- **Connection Management**: Automatic connection with reconnection support -- **Dual Message Routing**: - - API responses (with `echo` field) → Response handling - - OneBot events (with `post_type` field) → Event handling -- **Echo Matching**: UUID-based request-response matching -- **Authentication**: Bearer token support -- **Error Handling**: Proper timeout and connection error handling -- **Lifecycle**: Graceful connection and cleanup - -### URL-Based Detection -- `ws://` or `wss://` URLs automatically create WebSocketForwardApi -- `http://` or `https://` URLs continue to create HttpApi -- Maintains complete backward compatibility - -### Priority System -1. Forward WebSocket (if configured) -2. Reverse WebSocket (if available) -3. HTTP (fallback) - -## Usage - -```python -# Simple migration from HTTP to forward WebSocket -# Old: -bot = CQHttp(api_root="http://127.0.0.1:5700") -# New: -bot = CQHttp(api_root="ws://127.0.0.1:3001") -``` - -## Test Files Created - -- `test_forward_ws.py` - Basic functionality tests -- `demo_forward_ws.py` - Usage examples and demo -- `FORWARD_WEBSOCKET.md` - Complete documentation - -## Installation - -```bash -# For forward WebSocket support: -pip install 'aiocqhttp[forward-ws]' - -# For all features: -pip install 'aiocqhttp[all]' -``` - -## Verification - -All tests pass: -✓ Backward compatibility maintained -✓ HTTP mode works -✓ WebSocket URL detection works -✓ WSS (secure) URL detection works -✓ No syntax errors - -The implementation successfully adds forward WebSocket support while maintaining full backward compatibility with existing HTTP and reverse WebSocket functionality. \ No newline at end of file diff --git a/demo_forward_ws.py b/demo_forward_ws.py deleted file mode 100644 index 9fbd0f2..0000000 --- a/demo_forward_ws.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -""" -Example usage of aiocqhttp with forward WebSocket support. -""" - -import asyncio -import logging -from aiocqhttp import CQHttp - -# Configure logging to see what's happening -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -async def demo_forward_websocket(): - """ - Demo showing how to use forward WebSocket with aiocqhttp. - """ - print("=== aiocqhttp Forward WebSocket Demo ===\n") - - # Create bot with forward WebSocket - # Replace with your actual OneBot server URL - bot = CQHttp(api_root="ws://127.0.0.1:3001") - - @bot.on_message('private') - async def handle_private_message(event): - logger.info(f"Received private message: {event.message}") - # Echo the message back - await bot.send(event, f"You said: {event.message}") - - @bot.on_message('group') - async def handle_group_message(event): - logger.info(f"Received group message: {event.message}") - if str(event.message).startswith("/echo"): - await bot.send(event, f"Echo: {str(event.message)[5:]}") - - @bot.on_websocket_connection - async def on_connected(event): - logger.info("Forward WebSocket connected!") - # Test API call - try: - login_info = await bot.get_login_info() - logger.info(f"Bot login info: {login_info}") - except Exception as e: - logger.error(f"Failed to get login info: {e}") - - print("Bot configured with forward WebSocket!") - print("Features:") - print("- Automatically connects to OneBot server as WebSocket client") - print("- Receives OneBot events (messages, notices, etc.)") - print("- Can make API calls through the same WebSocket connection") - print("- Falls back to reverse WebSocket/HTTP if forward WebSocket fails") - print("\nNote: This demo requires a running OneBot server at ws://127.0.0.1:3001") - print("Examples: go-cqhttp, NapCat, or other OneBot implementations") - - # In a real application, you would start the bot server: - # await bot.run_task(host="127.0.0.1", port=8080) - -async def demo_migration_example(): - """ - Show how existing code can easily migrate to forward WebSocket. - """ - print("\n=== Migration Example ===\n") - - print("Before (HTTP):") - print('bot = CQHttp(api_root="http://127.0.0.1:5700")') - - print("\nAfter (Forward WebSocket):") - print('bot = CQHttp(api_root="ws://127.0.0.1:3001")') - - print("\nThat's it! No other code changes needed.") - print("- Same API methods (bot.send_private_msg, etc.)") - print("- Same event handlers (@bot.on_message, etc.)") - print("- Same configuration options") - -async def demo_unified_api(): - """ - Show how UnifiedApi prioritizes different connection types. - """ - print("\n=== UnifiedApi Priority Demo ===\n") - - # This would try forward WebSocket, then reverse WebSocket, then HTTP - # (In practice, you'd only configure one at a time) - print("Priority order:") - print("1. Forward WebSocket (ws://... or wss://...)") - print("2. Reverse WebSocket (when OneBot connects to your server)") - print("3. HTTP (http://... or https://...)") - print("\nThis ensures the best available connection method is used.") - -if __name__ == "__main__": - async def main(): - await demo_forward_websocket() - await demo_migration_example() - await demo_unified_api() - - asyncio.run(main()) \ No newline at end of file diff --git a/test_forward_ws.py b/test_forward_ws.py deleted file mode 100644 index d3bd493..0000000 --- a/test_forward_ws.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 -""" -Basic test script for forward WebSocket functionality. -""" - -import asyncio -import logging -from aiocqhttp import CQHttp - -# Configure logging -logging.basicConfig(level=logging.DEBUG) - -async def test_http_mode(): - """Test that HTTP mode still works.""" - print("Testing HTTP mode...") - bot = CQHttp(api_root="http://127.0.0.1:5700") - assert hasattr(bot._api, '_http_api') - assert bot._api._http_api is not None - assert bot._api._wsf_api is None - print("✓ HTTP mode configuration works") - -async def test_websocket_detection(): - """Test that WebSocket URL detection works.""" - print("Testing WebSocket URL detection...") - - try: - # This should create WebSocketForwardApi - bot = CQHttp(api_root="ws://127.0.0.1:8080") - assert hasattr(bot._api, '_wsf_api') - assert bot._api._wsf_api is not None - assert bot._api._http_api is None - print("✓ WebSocket mode configuration works") - - # Clean up - if bot._api._wsf_api: - await bot._api._wsf_api.close() - - except ImportError as e: - print(f"! WebSocket functionality requires 'websockets' package: {e}") - print(" Install with: pip install 'aiocqhttp[forward-ws]'") - -async def test_wss_detection(): - """Test that WSS (secure WebSocket) detection works.""" - print("Testing WSS URL detection...") - - try: - bot = CQHttp(api_root="wss://example.com:8080") - assert hasattr(bot._api, '_wsf_api') - assert bot._api._wsf_api is not None - print("✓ WSS mode configuration works") - - # Clean up - if bot._api._wsf_api: - await bot._api._wsf_api.close() - - except ImportError as e: - print(f"! WebSocket functionality requires 'websockets' package: {e}") - -async def test_backward_compatibility(): - """Test that existing functionality isn't broken.""" - print("Testing backward compatibility...") - - # Test default (no api_root) - bot = CQHttp() - assert hasattr(bot._api, '_http_api') - print("✓ Default configuration works") - - # Test None api_root - bot2 = CQHttp(api_root=None) - assert hasattr(bot2._api, '_http_api') - print("✓ None api_root works") - -async def main(): - """Run all tests.""" - print("Testing aiocqhttp forward WebSocket implementation...\n") - - await test_backward_compatibility() - await test_http_mode() - await test_websocket_detection() - await test_wss_detection() - - print("\n✓ All tests completed!") - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/test_stability.py b/test_stability.py deleted file mode 100644 index c3b0854..0000000 --- a/test_stability.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -""" -Test for timing and attribute access safety. -""" - -import asyncio -from aiocqhttp import CQHttp - -async def test_early_api_access(): - """Test that self.api is always accessible, even before _configure.""" - print("Testing early API access safety...") - - # This should work without errors - bot = CQHttp() - - # Should be accessible immediately after __init__ - api = bot.api - assert api is not None - print("✓ bot.api accessible immediately after __init__") - - # Should handle calls gracefully (though they may fail due to no config) - try: - # This will likely fail due to no api_root, but should not crash - await bot.get_login_info() - except Exception as e: - # Expected - no api_root configured - print(f"✓ API call fails gracefully: {type(e).__name__}") - - print("✓ Early API access safety test passed") - -async def test_inheritance_safety(): - """Test that inheritance scenarios work.""" - print("Testing inheritance safety...") - - class CustomBot(CQHttp): - def __init__(self): - super().__init__() - # This should work - accessing api in child __init__ - self.my_api = self.api - assert self.my_api is not None - - bot = CustomBot() - print("✓ Inheritance with early API access works") - -async def test_reconfiguration(): - """Test that API can be reconfigured multiple times.""" - print("Testing reconfiguration safety...") - - bot = CQHttp() - original_api = bot.api - - # Reconfigure to HTTP - bot._configure(api_root="http://127.0.0.1:5700") - assert bot.api is original_api # Same object - assert bot.api._http_api is not None - print("✓ HTTP reconfiguration works") - - # Reconfigure to WebSocket - bot._configure(api_root="ws://127.0.0.1:3001") - assert bot.api is original_api # Still same object - assert bot.api._wsf_api is not None - print("✓ WebSocket reconfiguration works") - - # Clean up - if bot.api._wsf_api: - await bot.api._wsf_api.close() - -async def main(): - print("Testing SDK stability and timing safety...\n") - - await test_early_api_access() - await test_inheritance_safety() - await test_reconfiguration() - - print("\n✓ All stability tests passed!") - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file From 5341d4d5536175d98d0f43cea8f110fb003d1fd1 Mon Sep 17 00:00:00 2001 From: a0gent Date: Sun, 1 Feb 2026 15:16:17 +0800 Subject: [PATCH 3/4] chore: follow dev instruction --- README.md | 2 +- aiocqhttp/__init__.py | 23 +- aiocqhttp/api.pyi | 1248 ++++++++++++++++++-------------- aiocqhttp/api_impl.py | 43 +- aiocqhttp/message.py | 32 +- docs/getting-started.md | 26 +- scripts/gen_cqhttp_api_stub.py | 33 +- 7 files changed, 816 insertions(+), 591 deletions(-) diff --git a/README.md b/README.md index a265fe8..48ff474 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![Python Version](https://img.shields.io/badge/python-3.7+-blue.svg) ![OneBot Version](https://img.shields.io/badge/OneBot-v10,v11-black.svg) -**aiocqhttp** 是 [OneBot](https://github.com/howmanybots/onebot) (原 [酷Q](https://cqp.cc) 的 [CQHTTP 插件](https://cqhttp.cc)) 的 Python SDK,采用异步 I/O,封装了 web 服务器相关的代码,支持 OneBot 的 HTTP 和反向 WebSocket 两种通信方式,让使用 Python 的开发者能方便地开发插件。 +**aiocqhttp** 是 [OneBot](https://github.com/howmanybots/onebot) (原 [酷Q](https://cqp.cc) 的 [CQHTTP 插件](https://cqhttp.cc)) 的 Python SDK,采用异步 I/O,封装了 web 服务器相关的代码,支持 OneBot 的 HTTP、正向 WebSocket 和反向 WebSocket 三种通信方式,让使用 Python 的开发者能方便地开发插件。 本 SDK 要求使用 Python 3.7 或更高版本,以及建议搭配支持 OneBot v11 的 OneBot 实现。 diff --git a/aiocqhttp/__init__.py b/aiocqhttp/__init__.py index e1d4ec3..936ea05 100644 --- a/aiocqhttp/__init__.py +++ b/aiocqhttp/__init__.py @@ -19,8 +19,8 @@ from quart import Quart, request, abort, jsonify, websocket, Response from .api import AsyncApi, SyncApi -from .api_impl import (SyncWrapperApi, HttpApi, WebSocketReverseApi, - UnifiedApi, ResultStore, WebSocketForwardApi, _is_websocket_url) +from .api_impl import (SyncWrapperApi, HttpApi, WebSocketReverseApi, UnifiedApi, + ResultStore, WebSocketForwardApi, _is_websocket_url) from .bus import EventBus from .exceptions import Error, TimingError from .event import Event @@ -105,8 +105,10 @@ def __init__(self, ``import_name`` 参数为当前模块(使用 `CQHttp` 的模块)的导入名,通常传入 ``__name__`` 或不传入。 - ``api_root`` 参数为 OneBot API 的 URL,``access_token`` 和 - ``secret`` 参数为 OneBot 配置中填写的对应项。 + ``api_root`` 参数为 OneBot API 的地址,支持 HTTP URL(如 + ``http://127.0.0.1:5700``)或正向 WebSocket URL(如 + ``ws://127.0.0.1:6700/``)。``access_token`` 和 ``secret`` 参数为 OneBot + 配置中填写的对应项。 ``message_class`` 参数为要用来对 `Event.message` 进行转换的消息类,可使用 `Message`,例如: @@ -163,12 +165,10 @@ def _configure(self, if _is_websocket_url(api_root): # Forward WebSocket mode try: - wsf_api = WebSocketForwardApi( - ws_url=api_root, - access_token=access_token, - timeout_sec=api_timeout_sec, - event_handler=self._handle_event - ) + wsf_api = WebSocketForwardApi(ws_url=api_root, + access_token=access_token, + timeout_sec=api_timeout_sec, + event_handler=self._handle_event) except ImportError as e: self.logger.error(f"Failed to create WebSocketForwardApi: {e}") raise @@ -180,8 +180,7 @@ def _configure(self, self._wsr_api_clients = {} # connected wsr api clients self._wsr_event_clients = set() wsr_api = WebSocketReverseApi(self._wsr_api_clients, - self._wsr_event_clients, - api_timeout_sec) + self._wsr_event_clients, api_timeout_sec) # Update the existing UnifiedApi instance instead of creating a new one self._api._http_api = http_api diff --git a/aiocqhttp/api.pyi b/aiocqhttp/api.pyi index ef54831..fdd86e3 100644 --- a/aiocqhttp/api.pyi +++ b/aiocqhttp/api.pyi @@ -3,9 +3,9 @@ from typing import Union, Awaitable, Any, Callable, Optional, Dict, List from aiocqhttp.typing import Message_T - if sys.version_info >= (3, 8, 0): from typing import TypedDict + class _send_private_msg_ret(TypedDict): message_id: int @@ -149,23 +149,27 @@ else: class Api: + def call_action( - self, - action: str, - **params, - ) -> Union[Awaitable[Any], Any]: ... + self, + action: str, + **params, + ) -> Union[Awaitable[Any], Any]: + ... def __getattr__( - self, - item: str, - ) -> Callable[..., Union[Awaitable[Any], Any]]: ... + self, + item: str, + ) -> Callable[..., Union[Awaitable[Any], Any]]: + ... def send_private_msg( - self, *, - user_id: int, - message: Message_T, - auto_escape: bool = False, - self_id: Optional[int] = None, + self, + *, + user_id: int, + message: Message_T, + auto_escape: bool = False, + self_id: Optional[int] = None, ) -> Union[Awaitable[_send_private_msg_ret], _send_private_msg_ret]: """ 发送私聊消息。 @@ -178,11 +182,12 @@ class Api: """ def send_group_msg( - self, *, - group_id: int, - message: Message_T, - auto_escape: bool = False, - self_id: Optional[int] = None, + self, + *, + group_id: int, + message: Message_T, + auto_escape: bool = False, + self_id: Optional[int] = None, ) -> Union[Awaitable[_send_group_msg_ret], _send_group_msg_ret]: """ 发送群消息。 @@ -195,13 +200,14 @@ class Api: """ def send_msg( - self, *, - message_type: Optional[str] = None, - user_id: Optional[int] = None, - group_id: Optional[int] = None, - message: Message_T, - auto_escape: bool = False, - self_id: Optional[int] = None, + self, + *, + message_type: Optional[str] = None, + user_id: Optional[int] = None, + group_id: Optional[int] = None, + message: Message_T, + auto_escape: bool = False, + self_id: Optional[int] = None, ) -> Union[Awaitable[_send_msg_ret], _send_msg_ret]: """ 发送消息。 @@ -216,9 +222,10 @@ class Api: """ def delete_msg( - self, *, - message_id: int, - self_id: Optional[int] = None, + self, + *, + message_id: int, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 撤回消息。 @@ -229,9 +236,10 @@ class Api: """ def get_msg( - self, *, - message_id: int, - self_id: Optional[int] = None, + self, + *, + message_id: int, + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_msg_ret], _get_msg_ret]: """ 获取消息。 @@ -242,9 +250,10 @@ class Api: """ def get_forward_msg( - self, *, - id: str, - self_id: Optional[int] = None, + self, + *, + id: str, + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_forward_msg_ret], _get_forward_msg_ret]: """ 获取合并转发消息。 @@ -255,10 +264,11 @@ class Api: """ def send_like( - self, *, - user_id: int, - times: int = 1, - self_id: Optional[int] = None, + self, + *, + user_id: int, + times: int = 1, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 发送好友赞。 @@ -270,11 +280,12 @@ class Api: """ def set_group_kick( - self, *, - group_id: int, - user_id: int, - reject_add_request: bool = False, - self_id: Optional[int] = None, + self, + *, + group_id: int, + user_id: int, + reject_add_request: bool = False, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 群组踢人。 @@ -287,11 +298,12 @@ class Api: """ def set_group_ban( - self, *, - group_id: int, - user_id: int, - duration: int = 30 * 60, - self_id: Optional[int] = None, + self, + *, + group_id: int, + user_id: int, + duration: int = 30 * 60, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 群组单人禁言。 @@ -304,12 +316,13 @@ class Api: """ def set_group_anonymous_ban( - self, *, - group_id: int, - anonymous: Optional[Dict[str, Any]] = None, - anonymous_flag: Optional[str] = None, - duration: int = 30 * 60, - self_id: Optional[int] = None, + self, + *, + group_id: int, + anonymous: Optional[Dict[str, Any]] = None, + anonymous_flag: Optional[str] = None, + duration: int = 30 * 60, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 群组匿名用户禁言。 @@ -323,10 +336,11 @@ class Api: """ def set_group_whole_ban( - self, *, - group_id: int, - enable: bool = True, - self_id: Optional[int] = None, + self, + *, + group_id: int, + enable: bool = True, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 群组全员禁言。 @@ -338,11 +352,12 @@ class Api: """ def set_group_admin( - self, *, - group_id: int, - user_id: int, - enable: bool = True, - self_id: Optional[int] = None, + self, + *, + group_id: int, + user_id: int, + enable: bool = True, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 群组设置管理员。 @@ -355,10 +370,11 @@ class Api: """ def set_group_anonymous( - self, *, - group_id: int, - enable: bool = True, - self_id: Optional[int] = None, + self, + *, + group_id: int, + enable: bool = True, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 群组匿名。 @@ -370,11 +386,12 @@ class Api: """ def set_group_card( - self, *, - group_id: int, - user_id: int, - card: str = '', - self_id: Optional[int] = None, + self, + *, + group_id: int, + user_id: int, + card: str = '', + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 设置群名片(群备注)。 @@ -387,10 +404,11 @@ class Api: """ def set_group_name( - self, *, - group_id: int, - group_name: str, - self_id: Optional[int] = None, + self, + *, + group_id: int, + group_name: str, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 设置群名。 @@ -402,10 +420,11 @@ class Api: """ def set_group_leave( - self, *, - group_id: int, - is_dismiss: bool = False, - self_id: Optional[int] = None, + self, + *, + group_id: int, + is_dismiss: bool = False, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 退出群组。 @@ -417,12 +436,13 @@ class Api: """ def set_group_special_title( - self, *, - group_id: int, - user_id: int, - special_title: str = '', - duration: int = -1, - self_id: Optional[int] = None, + self, + *, + group_id: int, + user_id: int, + special_title: str = '', + duration: int = -1, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 设置群组专属头衔。 @@ -436,11 +456,12 @@ class Api: """ def set_friend_add_request( - self, *, - flag: str, - approve: bool = True, - remark: str = '', - self_id: Optional[int] = None, + self, + *, + flag: str, + approve: bool = True, + remark: str = '', + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 处理加好友请求。 @@ -453,12 +474,13 @@ class Api: """ def set_group_add_request( - self, *, - flag: str, - sub_type: str, - approve: bool = True, - reason: str = '', - self_id: Optional[int] = None, + self, + *, + flag: str, + sub_type: str, + approve: bool = True, + reason: str = '', + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 处理加群请求/邀请。 @@ -472,8 +494,9 @@ class Api: """ def get_login_info( - self, *, - self_id: Optional[int] = None, + self, + *, + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_login_info_ret], _get_login_info_ret]: """ 获取登录号信息。 @@ -483,10 +506,11 @@ class Api: """ def get_stranger_info( - self, *, - user_id: int, - no_cache: bool = False, - self_id: Optional[int] = None, + self, + *, + user_id: int, + no_cache: bool = False, + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_stranger_info_ret], _get_stranger_info_ret]: """ 获取陌生人信息。 @@ -498,9 +522,11 @@ class Api: """ def get_friend_list( - self, *, - self_id: Optional[int] = None, - ) -> Union[Awaitable[List[_get_friend_list_ret]], List[_get_friend_list_ret]]: + self, + *, + self_id: Optional[int] = None, + ) -> Union[Awaitable[List[_get_friend_list_ret]], + List[_get_friend_list_ret]]: """ 获取好友列表。 @@ -509,10 +535,11 @@ class Api: """ def get_group_info( - self, *, - group_id: int, - no_cache: bool = False, - self_id: Optional[int] = None, + self, + *, + group_id: int, + no_cache: bool = False, + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_group_info_ret], _get_group_info_ret]: """ 获取群信息。 @@ -524,8 +551,9 @@ class Api: """ def get_group_list( - self, *, - self_id: Optional[int] = None, + self, + *, + self_id: Optional[int] = None, ) -> Union[Awaitable[List[_get_group_list_ret]], List[_get_group_list_ret]]: """ 获取群列表。 @@ -535,12 +563,14 @@ class Api: """ def get_group_member_info( - self, *, - group_id: int, - user_id: int, - no_cache: bool = False, - self_id: Optional[int] = None, - ) -> Union[Awaitable[_get_group_member_info_ret], _get_group_member_info_ret]: + self, + *, + group_id: int, + user_id: int, + no_cache: bool = False, + self_id: Optional[int] = None, + ) -> Union[Awaitable[_get_group_member_info_ret], + _get_group_member_info_ret]: """ 获取群成员信息。 @@ -552,10 +582,12 @@ class Api: """ def get_group_member_list( - self, *, - group_id: int, - self_id: Optional[int] = None, - ) -> Union[Awaitable[List[_get_group_member_list_ret]], List[_get_group_member_list_ret]]: + self, + *, + group_id: int, + self_id: Optional[int] = None, + ) -> Union[Awaitable[List[_get_group_member_list_ret]], + List[_get_group_member_list_ret]]: """ 获取群成员列表。 @@ -565,10 +597,11 @@ class Api: """ def get_group_honor_info( - self, *, - group_id: int, - type: str, - self_id: Optional[int] = None, + self, + *, + group_id: int, + type: str, + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_group_honor_info_ret], _get_group_honor_info_ret]: """ 获取群荣誉信息。 @@ -580,9 +613,10 @@ class Api: """ def get_cookies( - self, *, - domain: str = '', - self_id: Optional[int] = None, + self, + *, + domain: str = '', + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_cookies_ret], _get_cookies_ret]: """ 获取 Cookies。 @@ -593,8 +627,9 @@ class Api: """ def get_csrf_token( - self, *, - self_id: Optional[int] = None, + self, + *, + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_csrf_token_ret], _get_csrf_token_ret]: """ 获取 CSRF Token。 @@ -604,9 +639,10 @@ class Api: """ def get_credentials( - self, *, - domain: str = '', - self_id: Optional[int] = None, + self, + *, + domain: str = '', + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_credentials_ret], _get_credentials_ret]: """ 获取 QQ 相关接口凭证。 @@ -617,10 +653,11 @@ class Api: """ def get_record( - self, *, - file: str, - out_format: str, - self_id: Optional[int] = None, + self, + *, + file: str, + out_format: str, + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_record_ret], _get_record_ret]: """ 获取语音。 @@ -632,9 +669,10 @@ class Api: """ def get_image( - self, *, - file: str, - self_id: Optional[int] = None, + self, + *, + file: str, + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_image_ret], _get_image_ret]: """ 获取图片。 @@ -645,8 +683,9 @@ class Api: """ def can_send_image( - self, *, - self_id: Optional[int] = None, + self, + *, + self_id: Optional[int] = None, ) -> Union[Awaitable[_can_send_image_ret], _can_send_image_ret]: """ 检查是否可以发送图片。 @@ -656,8 +695,9 @@ class Api: """ def can_send_record( - self, *, - self_id: Optional[int] = None, + self, + *, + self_id: Optional[int] = None, ) -> Union[Awaitable[_can_send_record_ret], _can_send_record_ret]: """ 检查是否可以发送语音。 @@ -667,8 +707,9 @@ class Api: """ def get_status( - self, *, - self_id: Optional[int] = None, + self, + *, + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_status_ret], _get_status_ret]: """ 获取运行状态。 @@ -678,8 +719,9 @@ class Api: """ def get_version_info( - self, *, - self_id: Optional[int] = None, + self, + *, + self_id: Optional[int] = None, ) -> Union[Awaitable[_get_version_info_ret], _get_version_info_ret]: """ 获取版本信息。 @@ -689,9 +731,10 @@ class Api: """ def set_restart( - self, *, - delay: int = 0, - self_id: Optional[int] = None, + self, + *, + delay: int = 0, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 重启 OneBot 实现。 @@ -702,8 +745,9 @@ class Api: """ def clean_cache( - self, *, - self_id: Optional[int] = None, + self, + *, + self_id: Optional[int] = None, ) -> Union[Awaitable[None], None]: """ 清理缓存。 @@ -715,531 +759,687 @@ class Api: # definition to avoid union return types class AsyncApi(Api): + async def call_action( - self, - action: str, - **params, - ) -> Any: ... + self, + action: str, + **params, + ) -> Any: + ... async def send_private_msg( - self, *, - user_id: int, - message: Message_T, - auto_escape: bool = False, - self_id: Optional[int] = None, - ) -> _send_private_msg_ret: ... + self, + *, + user_id: int, + message: Message_T, + auto_escape: bool = False, + self_id: Optional[int] = None, + ) -> _send_private_msg_ret: + ... async def send_group_msg( - self, *, - group_id: int, - message: Message_T, - auto_escape: bool = False, - self_id: Optional[int] = None, - ) -> _send_group_msg_ret: ... + self, + *, + group_id: int, + message: Message_T, + auto_escape: bool = False, + self_id: Optional[int] = None, + ) -> _send_group_msg_ret: + ... async def send_msg( - self, *, - message_type: Optional[str] = None, - user_id: Optional[int] = None, - group_id: Optional[int] = None, - message: Message_T, - auto_escape: bool = False, - self_id: Optional[int] = None, - ) -> _send_msg_ret: ... + self, + *, + message_type: Optional[str] = None, + user_id: Optional[int] = None, + group_id: Optional[int] = None, + message: Message_T, + auto_escape: bool = False, + self_id: Optional[int] = None, + ) -> _send_msg_ret: + ... async def delete_msg( - self, *, - message_id: int, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + message_id: int, + self_id: Optional[int] = None, + ) -> None: + ... async def get_msg( - self, *, - message_id: int, - self_id: Optional[int] = None, - ) -> _get_msg_ret: ... + self, + *, + message_id: int, + self_id: Optional[int] = None, + ) -> _get_msg_ret: + ... async def get_forward_msg( - self, *, - id: str, - self_id: Optional[int] = None, - ) -> _get_forward_msg_ret: ... + self, + *, + id: str, + self_id: Optional[int] = None, + ) -> _get_forward_msg_ret: + ... async def send_like( - self, *, - user_id: int, - times: int = 1, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + user_id: int, + times: int = 1, + self_id: Optional[int] = None, + ) -> None: + ... async def set_group_kick( - self, *, - group_id: int, - user_id: int, - reject_add_request: bool = False, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + user_id: int, + reject_add_request: bool = False, + self_id: Optional[int] = None, + ) -> None: + ... async def set_group_ban( - self, *, - group_id: int, - user_id: int, - duration: int = 30 * 60, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + user_id: int, + duration: int = 30 * 60, + self_id: Optional[int] = None, + ) -> None: + ... async def set_group_anonymous_ban( - self, *, - group_id: int, - anonymous: Optional[Dict[str, Any]] = None, - anonymous_flag: Optional[str] = None, - duration: int = 30 * 60, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + anonymous: Optional[Dict[str, Any]] = None, + anonymous_flag: Optional[str] = None, + duration: int = 30 * 60, + self_id: Optional[int] = None, + ) -> None: + ... async def set_group_whole_ban( - self, *, - group_id: int, - enable: bool = True, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + enable: bool = True, + self_id: Optional[int] = None, + ) -> None: + ... async def set_group_admin( - self, *, - group_id: int, - user_id: int, - enable: bool = True, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + user_id: int, + enable: bool = True, + self_id: Optional[int] = None, + ) -> None: + ... async def set_group_anonymous( - self, *, - group_id: int, - enable: bool = True, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + enable: bool = True, + self_id: Optional[int] = None, + ) -> None: + ... async def set_group_card( - self, *, - group_id: int, - user_id: int, - card: str = '', - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + user_id: int, + card: str = '', + self_id: Optional[int] = None, + ) -> None: + ... async def set_group_name( - self, *, - group_id: int, - group_name: str, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + group_name: str, + self_id: Optional[int] = None, + ) -> None: + ... async def set_group_leave( - self, *, - group_id: int, - is_dismiss: bool = False, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + is_dismiss: bool = False, + self_id: Optional[int] = None, + ) -> None: + ... async def set_group_special_title( - self, *, - group_id: int, - user_id: int, - special_title: str = '', - duration: int = -1, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + user_id: int, + special_title: str = '', + duration: int = -1, + self_id: Optional[int] = None, + ) -> None: + ... async def set_friend_add_request( - self, *, - flag: str, - approve: bool = True, - remark: str = '', - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + flag: str, + approve: bool = True, + remark: str = '', + self_id: Optional[int] = None, + ) -> None: + ... async def set_group_add_request( - self, *, - flag: str, - sub_type: str, - approve: bool = True, - reason: str = '', - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + flag: str, + sub_type: str, + approve: bool = True, + reason: str = '', + self_id: Optional[int] = None, + ) -> None: + ... async def get_login_info( - self, *, - self_id: Optional[int] = None, - ) -> _get_login_info_ret: ... + self, + *, + self_id: Optional[int] = None, + ) -> _get_login_info_ret: + ... async def get_stranger_info( - self, *, - user_id: int, - no_cache: bool = False, - self_id: Optional[int] = None, - ) -> _get_stranger_info_ret: ... + self, + *, + user_id: int, + no_cache: bool = False, + self_id: Optional[int] = None, + ) -> _get_stranger_info_ret: + ... async def get_friend_list( - self, *, - self_id: Optional[int] = None, - ) -> List[_get_friend_list_ret]: ... + self, + *, + self_id: Optional[int] = None, + ) -> List[_get_friend_list_ret]: + ... async def get_group_info( - self, *, - group_id: int, - no_cache: bool = False, - self_id: Optional[int] = None, - ) -> _get_group_info_ret: ... + self, + *, + group_id: int, + no_cache: bool = False, + self_id: Optional[int] = None, + ) -> _get_group_info_ret: + ... async def get_group_list( - self, *, - self_id: Optional[int] = None, - ) -> List[_get_group_list_ret]: ... + self, + *, + self_id: Optional[int] = None, + ) -> List[_get_group_list_ret]: + ... async def get_group_member_info( - self, *, - group_id: int, - user_id: int, - no_cache: bool = False, - self_id: Optional[int] = None, - ) -> _get_group_member_info_ret: ... + self, + *, + group_id: int, + user_id: int, + no_cache: bool = False, + self_id: Optional[int] = None, + ) -> _get_group_member_info_ret: + ... async def get_group_member_list( - self, *, - group_id: int, - self_id: Optional[int] = None, - ) -> List[_get_group_member_list_ret]: ... + self, + *, + group_id: int, + self_id: Optional[int] = None, + ) -> List[_get_group_member_list_ret]: + ... async def get_group_honor_info( - self, *, - group_id: int, - type: str, - self_id: Optional[int] = None, - ) -> _get_group_honor_info_ret: ... + self, + *, + group_id: int, + type: str, + self_id: Optional[int] = None, + ) -> _get_group_honor_info_ret: + ... async def get_cookies( - self, *, - domain: str = '', - self_id: Optional[int] = None, - ) -> _get_cookies_ret: ... + self, + *, + domain: str = '', + self_id: Optional[int] = None, + ) -> _get_cookies_ret: + ... async def get_csrf_token( - self, *, - self_id: Optional[int] = None, - ) -> _get_csrf_token_ret: ... + self, + *, + self_id: Optional[int] = None, + ) -> _get_csrf_token_ret: + ... async def get_credentials( - self, *, - domain: str = '', - self_id: Optional[int] = None, - ) -> _get_credentials_ret: ... + self, + *, + domain: str = '', + self_id: Optional[int] = None, + ) -> _get_credentials_ret: + ... async def get_record( - self, *, - file: str, - out_format: str, - self_id: Optional[int] = None, - ) -> _get_record_ret: ... + self, + *, + file: str, + out_format: str, + self_id: Optional[int] = None, + ) -> _get_record_ret: + ... async def get_image( - self, *, - file: str, - self_id: Optional[int] = None, - ) -> _get_image_ret: ... + self, + *, + file: str, + self_id: Optional[int] = None, + ) -> _get_image_ret: + ... async def can_send_image( - self, *, - self_id: Optional[int] = None, - ) -> _can_send_image_ret: ... + self, + *, + self_id: Optional[int] = None, + ) -> _can_send_image_ret: + ... async def can_send_record( - self, *, - self_id: Optional[int] = None, - ) -> _can_send_record_ret: ... + self, + *, + self_id: Optional[int] = None, + ) -> _can_send_record_ret: + ... async def get_status( - self, *, - self_id: Optional[int] = None, - ) -> _get_status_ret: ... + self, + *, + self_id: Optional[int] = None, + ) -> _get_status_ret: + ... async def get_version_info( - self, *, - self_id: Optional[int] = None, - ) -> _get_version_info_ret: ... + self, + *, + self_id: Optional[int] = None, + ) -> _get_version_info_ret: + ... async def set_restart( - self, *, - delay: int = 0, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + delay: int = 0, + self_id: Optional[int] = None, + ) -> None: + ... async def clean_cache( - self, *, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + self_id: Optional[int] = None, + ) -> None: + ... # definition to avoid union return types class SyncApi(Api): + def call_action( - self, - action: str, - **params, - ) -> Any: ... + self, + action: str, + **params, + ) -> Any: + ... def send_private_msg( - self, *, - user_id: int, - message: Message_T, - auto_escape: bool = False, - self_id: Optional[int] = None, - ) -> _send_private_msg_ret: ... + self, + *, + user_id: int, + message: Message_T, + auto_escape: bool = False, + self_id: Optional[int] = None, + ) -> _send_private_msg_ret: + ... def send_group_msg( - self, *, - group_id: int, - message: Message_T, - auto_escape: bool = False, - self_id: Optional[int] = None, - ) -> _send_group_msg_ret: ... + self, + *, + group_id: int, + message: Message_T, + auto_escape: bool = False, + self_id: Optional[int] = None, + ) -> _send_group_msg_ret: + ... def send_msg( - self, *, - message_type: Optional[str] = None, - user_id: Optional[int] = None, - group_id: Optional[int] = None, - message: Message_T, - auto_escape: bool = False, - self_id: Optional[int] = None, - ) -> _send_msg_ret: ... + self, + *, + message_type: Optional[str] = None, + user_id: Optional[int] = None, + group_id: Optional[int] = None, + message: Message_T, + auto_escape: bool = False, + self_id: Optional[int] = None, + ) -> _send_msg_ret: + ... def delete_msg( - self, *, - message_id: int, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + message_id: int, + self_id: Optional[int] = None, + ) -> None: + ... def get_msg( - self, *, - message_id: int, - self_id: Optional[int] = None, - ) -> _get_msg_ret: ... + self, + *, + message_id: int, + self_id: Optional[int] = None, + ) -> _get_msg_ret: + ... def get_forward_msg( - self, *, - id: str, - self_id: Optional[int] = None, - ) -> _get_forward_msg_ret: ... + self, + *, + id: str, + self_id: Optional[int] = None, + ) -> _get_forward_msg_ret: + ... def send_like( - self, *, - user_id: int, - times: int = 1, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + user_id: int, + times: int = 1, + self_id: Optional[int] = None, + ) -> None: + ... def set_group_kick( - self, *, - group_id: int, - user_id: int, - reject_add_request: bool = False, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + user_id: int, + reject_add_request: bool = False, + self_id: Optional[int] = None, + ) -> None: + ... def set_group_ban( - self, *, - group_id: int, - user_id: int, - duration: int = 30 * 60, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + user_id: int, + duration: int = 30 * 60, + self_id: Optional[int] = None, + ) -> None: + ... def set_group_anonymous_ban( - self, *, - group_id: int, - anonymous: Optional[Dict[str, Any]] = None, - anonymous_flag: Optional[str] = None, - duration: int = 30 * 60, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + anonymous: Optional[Dict[str, Any]] = None, + anonymous_flag: Optional[str] = None, + duration: int = 30 * 60, + self_id: Optional[int] = None, + ) -> None: + ... def set_group_whole_ban( - self, *, - group_id: int, - enable: bool = True, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + enable: bool = True, + self_id: Optional[int] = None, + ) -> None: + ... def set_group_admin( - self, *, - group_id: int, - user_id: int, - enable: bool = True, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + user_id: int, + enable: bool = True, + self_id: Optional[int] = None, + ) -> None: + ... def set_group_anonymous( - self, *, - group_id: int, - enable: bool = True, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + enable: bool = True, + self_id: Optional[int] = None, + ) -> None: + ... def set_group_card( - self, *, - group_id: int, - user_id: int, - card: str = '', - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + user_id: int, + card: str = '', + self_id: Optional[int] = None, + ) -> None: + ... def set_group_name( - self, *, - group_id: int, - group_name: str, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + group_name: str, + self_id: Optional[int] = None, + ) -> None: + ... def set_group_leave( - self, *, - group_id: int, - is_dismiss: bool = False, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + is_dismiss: bool = False, + self_id: Optional[int] = None, + ) -> None: + ... def set_group_special_title( - self, *, - group_id: int, - user_id: int, - special_title: str = '', - duration: int = -1, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + group_id: int, + user_id: int, + special_title: str = '', + duration: int = -1, + self_id: Optional[int] = None, + ) -> None: + ... def set_friend_add_request( - self, *, - flag: str, - approve: bool = True, - remark: str = '', - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + flag: str, + approve: bool = True, + remark: str = '', + self_id: Optional[int] = None, + ) -> None: + ... def set_group_add_request( - self, *, - flag: str, - sub_type: str, - approve: bool = True, - reason: str = '', - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + flag: str, + sub_type: str, + approve: bool = True, + reason: str = '', + self_id: Optional[int] = None, + ) -> None: + ... def get_login_info( - self, *, - self_id: Optional[int] = None, - ) -> _get_login_info_ret: ... + self, + *, + self_id: Optional[int] = None, + ) -> _get_login_info_ret: + ... def get_stranger_info( - self, *, - user_id: int, - no_cache: bool = False, - self_id: Optional[int] = None, - ) -> _get_stranger_info_ret: ... + self, + *, + user_id: int, + no_cache: bool = False, + self_id: Optional[int] = None, + ) -> _get_stranger_info_ret: + ... def get_friend_list( - self, *, - self_id: Optional[int] = None, - ) -> List[_get_friend_list_ret]: ... + self, + *, + self_id: Optional[int] = None, + ) -> List[_get_friend_list_ret]: + ... def get_group_info( - self, *, - group_id: int, - no_cache: bool = False, - self_id: Optional[int] = None, - ) -> _get_group_info_ret: ... + self, + *, + group_id: int, + no_cache: bool = False, + self_id: Optional[int] = None, + ) -> _get_group_info_ret: + ... def get_group_list( - self, *, - self_id: Optional[int] = None, - ) -> List[_get_group_list_ret]: ... + self, + *, + self_id: Optional[int] = None, + ) -> List[_get_group_list_ret]: + ... def get_group_member_info( - self, *, - group_id: int, - user_id: int, - no_cache: bool = False, - self_id: Optional[int] = None, - ) -> _get_group_member_info_ret: ... + self, + *, + group_id: int, + user_id: int, + no_cache: bool = False, + self_id: Optional[int] = None, + ) -> _get_group_member_info_ret: + ... def get_group_member_list( - self, *, - group_id: int, - self_id: Optional[int] = None, - ) -> List[_get_group_member_list_ret]: ... + self, + *, + group_id: int, + self_id: Optional[int] = None, + ) -> List[_get_group_member_list_ret]: + ... def get_group_honor_info( - self, *, - group_id: int, - type: str, - self_id: Optional[int] = None, - ) -> _get_group_honor_info_ret: ... + self, + *, + group_id: int, + type: str, + self_id: Optional[int] = None, + ) -> _get_group_honor_info_ret: + ... def get_cookies( - self, *, - domain: str = '', - self_id: Optional[int] = None, - ) -> _get_cookies_ret: ... + self, + *, + domain: str = '', + self_id: Optional[int] = None, + ) -> _get_cookies_ret: + ... def get_csrf_token( - self, *, - self_id: Optional[int] = None, - ) -> _get_csrf_token_ret: ... + self, + *, + self_id: Optional[int] = None, + ) -> _get_csrf_token_ret: + ... def get_credentials( - self, *, - domain: str = '', - self_id: Optional[int] = None, - ) -> _get_credentials_ret: ... + self, + *, + domain: str = '', + self_id: Optional[int] = None, + ) -> _get_credentials_ret: + ... def get_record( - self, *, - file: str, - out_format: str, - self_id: Optional[int] = None, - ) -> _get_record_ret: ... + self, + *, + file: str, + out_format: str, + self_id: Optional[int] = None, + ) -> _get_record_ret: + ... def get_image( - self, *, - file: str, - self_id: Optional[int] = None, - ) -> _get_image_ret: ... + self, + *, + file: str, + self_id: Optional[int] = None, + ) -> _get_image_ret: + ... def can_send_image( - self, *, - self_id: Optional[int] = None, - ) -> _can_send_image_ret: ... + self, + *, + self_id: Optional[int] = None, + ) -> _can_send_image_ret: + ... def can_send_record( - self, *, - self_id: Optional[int] = None, - ) -> _can_send_record_ret: ... + self, + *, + self_id: Optional[int] = None, + ) -> _can_send_record_ret: + ... def get_status( - self, *, - self_id: Optional[int] = None, - ) -> _get_status_ret: ... + self, + *, + self_id: Optional[int] = None, + ) -> _get_status_ret: + ... def get_version_info( - self, *, - self_id: Optional[int] = None, - ) -> _get_version_info_ret: ... + self, + *, + self_id: Optional[int] = None, + ) -> _get_version_info_ret: + ... def set_restart( - self, *, - delay: int = 0, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + delay: int = 0, + self_id: Optional[int] = None, + ) -> None: + ... def clean_cache( - self, *, - self_id: Optional[int] = None, - ) -> None: ... + self, + *, + self_id: Optional[int] = None, + ) -> None: + ... diff --git a/aiocqhttp/api_impl.py b/aiocqhttp/api_impl.py index 551b3cd..ff8b508 100644 --- a/aiocqhttp/api_impl.py +++ b/aiocqhttp/api_impl.py @@ -126,8 +126,7 @@ class WebSocketReverseApi(AsyncApi): """ def __init__(self, connected_api_clients: Dict[str, Websocket], - connected_event_clients: Set[Websocket], - timeout_sec: float): + connected_event_clients: Set[Websocket], timeout_sec: float): super().__init__() self._api_clients = connected_api_clients self._event_clients = connected_event_clients @@ -211,7 +210,7 @@ async def call_action(self, action: str, **params) -> Any: def _is_websocket_url(url: str) -> bool: - """Check if URL is a WebSocket URL.""" + """判断 ``url`` 是否为 WebSocket URL。""" return url and (url.startswith('ws://') or url.startswith('wss://')) @@ -226,7 +225,8 @@ def __init__(self, ws_url: str, access_token: Optional[str], timeout_sec: float, event_handler: Optional[Callable]): super().__init__() if not websockets: - raise ImportError("websockets package is required for forward WebSocket support") + raise ImportError( + "websockets package is required for forward WebSocket support") self._ws_url = ws_url self._access_token = access_token @@ -250,13 +250,15 @@ def _schedule_auto_connect(self) -> None: loop = None if not loop: - self._logger.warning('No event loop available for forward WebSocket auto-connect') + self._logger.warning( + 'No event loop available for forward WebSocket auto-connect') return try: self._auto_connect_task = loop.create_task(self._auto_connect()) except Exception as e: - self._logger.error(f'Failed to schedule forward WebSocket auto-connect: {e}') + self._logger.error( + f'Failed to schedule forward WebSocket auto-connect: {e}') async def _auto_connect(self) -> None: try: @@ -273,11 +275,7 @@ async def call_action(self, action: str, **params) -> Any: future = asyncio.get_event_loop().create_future() self._response_futures[echo] = future - request = { - 'action': action, - 'params': params, - 'echo': echo - } + request = {'action': action, 'params': params, 'echo': echo} try: await self._connection.send(json.dumps(request, ensure_ascii=False)) @@ -316,25 +314,24 @@ async def _connect(self): self._connection = await websockets.connect( self._ws_url, additional_headers=headers, - open_timeout=self._timeout_sec - ) + open_timeout=self._timeout_sec) except Exception as e: - if "additional_headers" not in str(e) and "open_timeout" not in str(e): + if "additional_headers" not in str( + e) and "open_timeout" not in str(e): raise try: self._connection = await websockets.connect( self._ws_url, headers=headers, - open_timeout=self._timeout_sec - ) + open_timeout=self._timeout_sec) except Exception as e2: - if "headers" not in str(e2) and "open_timeout" not in str(e2): + if "headers" not in str(e2) and "open_timeout" not in str( + e2): raise self._connection = await websockets.connect( self._ws_url, extra_headers=headers, - timeout=self._timeout_sec - ) + timeout=self._timeout_sec) self._running = True asyncio.create_task(self._message_loop()) self._logger.info(f'Forward WebSocket connected to {self._ws_url}') @@ -351,7 +348,8 @@ async def _message_loop(self): data = json.loads(message) await self._handle_message(data) except json.JSONDecodeError: - self._logger.warning(f'Received invalid JSON: {message}') + self._logger.warning( + f'Received invalid JSON: {message}') except websockets.exceptions.ConnectionClosed: self._logger.info('Forward WebSocket connection closed') break @@ -369,11 +367,13 @@ async def _handle_message(self, data: Dict[str, Any]): future.set_result(data) # OneBot events have 'post_type' field elif 'post_type' in data and self._event_handler: + async def _run_handler(): try: await self._event_handler(data) except Exception as e: self._logger.error(f'Error handling event: {e}') + asyncio.create_task(_run_handler()) async def close(self): @@ -388,7 +388,8 @@ class SyncWrapperApi(SyncApi): 封装 `AsyncApi` 对象,使其可同步地调用。 """ - def __init__(self, async_api: AsyncApi, + def __init__(self, + async_api: AsyncApi, loop: Optional[asyncio.AbstractEventLoop] = None): """ `async_api` 参数为 `AsyncApi` 对象,`loop` 参数为用来执行 API diff --git a/aiocqhttp/message.py b/aiocqhttp/message.py index 3faab01..6782810 100644 --- a/aiocqhttp/message.py +++ b/aiocqhttp/message.py @@ -8,7 +8,6 @@ from .typing import Message_T - __pdoc__ = {} @@ -173,6 +172,7 @@ def __radd__(self, other: Any) -> 'Message': __pdoc__['MessageSegment.__radd__'] = True if sys.version_info >= (3, 9, 0): + def __or__(self, other): raise NotImplementedError @@ -205,12 +205,12 @@ def image(file: str, # NOTE: destruct parameter is not part of the onebot v11 std. return MessageSegment(type_='image', data=_remove_optional({ - 'file': file, - 'type': _optionally_strfy(type), - 'cache': _optionally_strfy(cache), - 'proxy': _optionally_strfy(proxy), - 'timeout': _optionally_strfy(timeout), - 'destruct': _optionally_strfy(destruct), + 'file': file, + 'type': _optionally_strfy(type), + 'cache': _optionally_strfy(cache), + 'proxy': _optionally_strfy(proxy), + 'timeout': _optionally_strfy(timeout), + 'destruct': _optionally_strfy(destruct), })) @staticmethod @@ -268,8 +268,8 @@ def poke(type_: str, id_: int) -> 'MessageSegment': """戳一戳。""" return MessageSegment(type_='poke', data={ - 'type': type_, - 'id': str(id_), + 'type': type_, + 'id': str(id_), }) @staticmethod @@ -372,17 +372,17 @@ def node(id_: int) -> 'MessageSegment': return MessageSegment(type_='node', data={'id': str(id_)}) @staticmethod - def node_custom(user_id: int, - nickname: str, + def node_custom(user_id: int, nickname: str, content: Message_T) -> 'MessageSegment': """合并转发自定义节点。""" if not isinstance(content, (str, MessageSegment, Message)): content = Message(content) - return MessageSegment(type_='node', data={ - 'user_id': str(user_id), - 'nickname': nickname, - 'content': str(content), - }) + return MessageSegment(type_='node', + data={ + 'user_id': str(user_id), + 'nickname': nickname, + 'content': str(content), + }) @staticmethod def xml(data: str) -> 'MessageSegment': diff --git a/docs/getting-started.md b/docs/getting-started.md index c72aac0..eaa7b20 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -20,7 +20,13 @@ pip install aiocqhttp pip install aiocqhttp[all] ``` -这将会额外安装 `ujson`。 +这将会额外安装 `ujson` 和正向 WebSocket 所需的 `websockets`。 + +如果只需要正向 WebSocket 支持,可使用: + +```bash +pip install aiocqhttp[forward-ws] +``` ## 最小实例 @@ -76,6 +82,24 @@ Running on http://127.0.0.1:8080 (CTRL + C to quit) 最后重启 CQHTTP。 +### 使用正向 WebSocket + +修改 `bot.py` 中创建 `bot` 对象部分的代码为: + +```python +bot = CQHttp(api_root='ws://127.0.0.1:6700/') +``` + +这里 `127.0.0.1:6700` 应根据情况改为 OneBot 监听的 WebSocket 地址和端口。 + +如果 OneBot 配置中启用了鉴权,还需要传入 `access_token`: + +```python +bot = CQHttp(api_root='ws://127.0.0.1:6700/', access_token='your_token') +``` + +然后在 OneBot 配置中启用正向 WebSocket 并确保监听地址可访问,最后重启 OneBot。 + ### 使用 HTTP 修改 `bot.py` 中创建 `bot` 对象部分的代码为: diff --git a/scripts/gen_cqhttp_api_stub.py b/scripts/gen_cqhttp_api_stub.py index 84da135..3a96b48 100644 --- a/scripts/gen_cqhttp_api_stub.py +++ b/scripts/gen_cqhttp_api_stub.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from typing import List, Optional, Tuple - TypeStr = str type_mappings = { @@ -46,7 +45,8 @@ class ApiReturn: is_array: bool @property - def _base(self): return f'_{self.action}_ret' + def _base(self): + return f'_{self.action}_ret' @property def var_name(self): @@ -78,8 +78,8 @@ def _get_param_specs(self): if self.params is not None: params = 'self, *,\n' params += '\n'.join(f'{p.render()},' for p in self.params) - arg_docs = '\n'.join(f'{p.name}: {p.description}' - for p in self.params) + arg_docs = '\n'.join( + f'{p.name}: {p.description}' for p in self.params) if self.ret is not None: ret = self.ret.var_name return params, arg_docs, ret @@ -119,8 +119,8 @@ def create_params(param_block: str): rows = re.findall(r'^\|([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|', param_block, re.MULTILINE) else: # ^| 字段名 | 数据类型 | 说明 | - rows = re.findall(r'^\|([^|]+)\|([^|]+)\|([^|]+)\|', - param_block, re.MULTILINE) + rows = re.findall(r'^\|([^|]+)\|([^|]+)\|([^|]+)\|', param_block, + re.MULTILINE) for row in islice(rows, 2, None): name = row[0].split()[0].strip(' `') # `xxx` 或 `yyy` type_ = type_mappings[row[1].strip()] @@ -155,11 +155,12 @@ def create_ret(action: str, ret_block: str): first = True ret = None # there might be multiple tables - for table_block in re.findall(r'\|\s*字段名.+?(?=(?=\n\n)|(?=$))', - ret_block, re.DOTALL): + for table_block in re.findall(r'\|\s*字段名.+?(?=(?=\n\n)|(?=$))', ret_block, + re.DOTALL): fields = [] - for row in islice(re.findall(r'^\|([^|]+)\|([^|]+)\|([^|]+)\|', - table_block, re.MULTILINE), 2, None): + for row in islice( + re.findall(r'^\|([^|]+)\|([^|]+)\|([^|]+)\|', table_block, + re.MULTILINE), 2, None): name = row[0].strip(' `') if name == '……': name = '#__there_might_be_more_fields_below' @@ -205,13 +206,13 @@ def create_apis(fn: str) -> List[Api]: template_path = path.join(path.dirname(__file__), 'api.pyi.template') with open(template_path) as template_in, open(sys.argv[2], 'w') as of: apis = create_apis(sys.argv[1]) - api_returns_38 = '\n\n'.join(api.ret.render_definition_38() - for api in apis if api.ret is not None) - api_returns_37 = '\n'.join(api.ret.render_definition_37() - for api in apis if api.ret is not None) + api_returns_38 = '\n\n'.join( + api.ret.render_definition_38() for api in apis if api.ret is not None) + api_returns_37 = '\n'.join( + api.ret.render_definition_37() for api in apis if api.ret is not None) api_methods = '\n\n'.join(api.render_definition() for api in apis) - api_methods_async = '\n\n'.join(api.render_definition_async() - for api in apis) + api_methods_async = '\n\n'.join( + api.render_definition_async() for api in apis) api_methods_sync = '\n\n'.join(api.render_definition_sync() for api in apis) of.write(template_in.read().format( api_returns_38=indent(api_returns_38, ' ' * 4), From b9c7fea3b9565209d5991cc49c285949716698b0 Mon Sep 17 00:00:00 2001 From: a0gent Date: Mon, 2 Feb 2026 10:51:22 +0800 Subject: [PATCH 4/4] Update aiocqhttp/api_impl.py Co-authored-by: Ju4tCode <42488585+yanyongyu@users.noreply.github.com> --- aiocqhttp/api_impl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiocqhttp/api_impl.py b/aiocqhttp/api_impl.py index ff8b508..b301492 100644 --- a/aiocqhttp/api_impl.py +++ b/aiocqhttp/api_impl.py @@ -211,7 +211,7 @@ async def call_action(self, action: str, **params) -> Any: def _is_websocket_url(url: str) -> bool: """判断 ``url`` 是否为 WebSocket URL。""" - return url and (url.startswith('ws://') or url.startswith('wss://')) + return url.startswith(('ws://', 'wss://')) class WebSocketForwardApi(AsyncApi):