├── assets ├── logo.png └── logo-qqguild.png ├── packages └── nonebot-adapter-qqguild │ ├── nonebot │ └── adapters │ │ └── qqguild │ │ ├── api │ │ ├── __init__.py │ │ ├── utils.py │ │ ├── request.py │ │ ├── client.py │ │ └── model.py │ │ ├── utils.py │ │ ├── __init__.py │ │ ├── store.py │ │ ├── permission.py │ │ ├── config.py │ │ ├── payload.py │ │ ├── exception.py │ │ ├── transformer.py │ │ ├── message.py │ │ ├── bot.py │ │ ├── event.py │ │ └── adapter.py │ ├── README.md │ └── pyproject.toml ├── .github ├── workflows │ ├── ruff.yml │ ├── release-guild.yml │ └── release.yml ├── actions │ └── setup-python │ │ └── action.yml └── dependabot.yml ├── nonebot └── adapters │ └── qq │ ├── models │ ├── __init__.py │ ├── payload.py │ ├── qq.py │ ├── common.py │ └── guild.py │ ├── compat.py │ ├── __init__.py │ ├── store.py │ ├── permission.py │ ├── utils.py │ ├── config.py │ ├── exception.py │ ├── message.py │ ├── event.py │ └── adapter.py ├── .pre-commit-config.yaml ├── .editorconfig ├── LICENSE ├── .devcontainer └── devcontainer.json ├── README.md ├── pyproject.toml └── .gitignore /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonebot/adapter-qq/HEAD/assets/logo.png -------------------------------------------------------------------------------- /assets/logo-qqguild.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nonebot/adapter-qq/HEAD/assets/logo-qqguild.png -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .model import * 2 | from .client import ApiClient as ApiClient 3 | from .handle import API_HANDLERS as API_HANDLERS 4 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/utils.py: -------------------------------------------------------------------------------- 1 | from nonebot.utils import logger_wrapper 2 | 3 | log = logger_wrapper("QQ Guild") 4 | 5 | 6 | def escape(s: str) -> str: 7 | return s.replace("&", "&").replace("<", "<").replace(">", ">") 8 | 9 | 10 | def unescape(s: str) -> str: 11 | return s.replace("<", "<").replace(">", ">").replace("&", "&") 12 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | paths: 9 | - "nonebot/**" 10 | - "packages/**" 11 | - "pyproject.toml" 12 | - "uv.lock" 13 | 14 | jobs: 15 | ruff: 16 | name: Ruff Lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v6 20 | 21 | - name: Run Ruff Lint 22 | uses: astral-sh/ruff-action@v3 23 | -------------------------------------------------------------------------------- /.github/actions/setup-python/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Python 2 | description: Setup Python 3 | 4 | inputs: 5 | python-version: 6 | description: Python version 7 | required: false 8 | default: "3.13" 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - uses: astral-sh/setup-uv@v7 14 | with: 15 | python-version: ${{ inputs.python-version }} 16 | 17 | - run: | 18 | uv sync --all-extras --locked 19 | shell: bash 20 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/__init__.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from .event import * 4 | from .permission import * 5 | from .bot import Bot as Bot 6 | from .utils import log as log 7 | from .adapter import Adapter as Adapter 8 | from .message import Message as Message 9 | from .message import MessageSegment as MessageSegment 10 | 11 | warnings.warn( 12 | 'QQGuild adapter is deprecated. Please use "nonebot-adapter-qq" instead.', 13 | DeprecationWarning, 14 | ) 15 | -------------------------------------------------------------------------------- /.github/workflows/release-guild.yml: -------------------------------------------------------------------------------- 1 | name: Release Guild 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | id-token: write 11 | steps: 12 | - uses: actions/checkout@v6 13 | 14 | - name: Setup Python environment 15 | uses: ./.github/actions/setup-python 16 | 17 | - name: Build and publish Package 18 | run: | 19 | cd packages/nonebot-adapter-qqguild 20 | uv build 21 | uv publish 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | groups: 8 | actions: 9 | patterns: 10 | - "*" 11 | 12 | - package-ecosystem: github-actions 13 | directory: "/.github/actions/setup-python" 14 | schedule: 15 | interval: daily 16 | groups: 17 | actions: 18 | patterns: 19 | - "*" 20 | 21 | - package-ecosystem: devcontainers 22 | directory: "/" 23 | schedule: 24 | interval: daily 25 | groups: 26 | devcontainers: 27 | patterns: 28 | - "*" 29 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .common import * # noqa: F403 2 | from .guild import * # noqa: F403 3 | from .payload import Dispatch as Dispatch 4 | from .payload import Heartbeat as Heartbeat 5 | from .payload import HeartbeatAck as HeartbeatAck 6 | from .payload import Hello as Hello 7 | from .payload import Identify as Identify 8 | from .payload import InvalidSession as InvalidSession 9 | from .payload import Opcode as Opcode 10 | from .payload import Payload as Payload 11 | from .payload import PayloadType as PayloadType 12 | from .payload import Reconnect as Reconnect 13 | from .payload import Resume as Resume 14 | from .payload import WebhookVerify as WebhookVerify 15 | from .qq import * # noqa: F403 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: [pre-commit, prepare-commit-msg] 2 | ci: 3 | autofix_commit_msg: ":rotating_light: auto fix by pre-commit hooks" 4 | autofix_prs: true 5 | autoupdate_branch: master 6 | autoupdate_schedule: monthly 7 | autoupdate_commit_msg: ":arrow_up: auto update by pre-commit hooks" 8 | repos: 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | rev: v0.14.7 11 | hooks: 12 | - id: ruff 13 | args: [--fix, --exit-non-zero-on-fix] 14 | stages: [pre-commit] 15 | - id: ruff-format 16 | stages: [pre-commit] 17 | 18 | - repo: https://github.com/nonebot/nonemoji 19 | rev: v0.1.4 20 | hooks: 21 | - id: nonemoji 22 | stages: [prepare-commit-msg] 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | # The JSON files contain newlines inconsistently 13 | [*.json] 14 | insert_final_newline = ignore 15 | 16 | # Minified JavaScript files shouldn't be changed 17 | [**.min.js] 18 | indent_style = ignore 19 | insert_final_newline = ignore 20 | 21 | # Makefiles always use tabs for indentation 22 | [Makefile] 23 | indent_style = tab 24 | 25 | # Batch files use tabs for indentation 26 | [*.bat] 27 | indent_style = tab 28 | 29 | [*.md] 30 | trim_trailing_whitespace = false 31 | 32 | # Matches the exact files either package.json or .travis.yml 33 | [{package.json,.travis.yml}] 34 | indent_size = 2 35 | 36 | [{*.py,*.pyi}] 37 | indent_size = 4 38 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/compat.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, overload 2 | 3 | from nonebot.compat import PYDANTIC_V2 4 | 5 | __all__ = ("field_validator", "model_validator") 6 | 7 | if PYDANTIC_V2: 8 | from pydantic import field_validator as field_validator 9 | from pydantic import model_validator as model_validator 10 | else: 11 | from pydantic import root_validator, validator 12 | 13 | @overload 14 | def model_validator(*, mode: Literal["before"]): ... 15 | 16 | @overload 17 | def model_validator(*, mode: Literal["after"]): ... 18 | 19 | def model_validator(*, mode: Literal["before", "after"]): 20 | return root_validator(pre=mode == "before", allow_reuse=True) 21 | 22 | def field_validator(field, /, *fields, mode: Literal["before", "after"] = "after"): 23 | return validator(field, *fields, pre=mode == "before", allow_reuse=True) 24 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/__init__.py: -------------------------------------------------------------------------------- 1 | from .adapter import Adapter as Adapter 2 | from .bot import Bot as Bot 3 | from .event import * # noqa: F403 4 | from .exception import ActionFailed as ActionFailed 5 | from .exception import ApiNotAvailable as ApiNotAvailable 6 | from .exception import AuditException as AuditException 7 | from .exception import NetworkError as NetworkError 8 | from .exception import NoLogException as NoLogException 9 | from .exception import QQAdapterException as QQAdapterException 10 | from .exception import RateLimitException as RateLimitException 11 | from .exception import UnauthorizedException as UnauthorizedException 12 | from .message import Message as Message 13 | from .message import MessageSegment as MessageSegment 14 | from .permission import * # noqa: F403 15 | from .utils import escape as escape 16 | from .utils import log as log 17 | from .utils import unescape as unescape 18 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/store.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | if TYPE_CHECKING: 5 | from .event import MessageAuditEvent 6 | 7 | 8 | class AuditResultStore: 9 | def __init__(self) -> None: 10 | self._futures: dict[str, asyncio.Future] = {} 11 | 12 | def add_result(self, result: "MessageAuditEvent"): 13 | audit_id = result.audit_id 14 | if not audit_id: 15 | raise ValueError("audit_id cannot be empty") 16 | if future := self._futures.get(audit_id): 17 | future.set_result(result) 18 | 19 | async def fetch( 20 | self, audit_id: str, timeout: Optional[float] = None 21 | ) -> "MessageAuditEvent": 22 | future = asyncio.get_event_loop().create_future() 23 | self._futures[audit_id] = future 24 | try: 25 | return await asyncio.wait_for(future, timeout) 26 | finally: 27 | del self._futures[audit_id] 28 | 29 | 30 | audit_result = AuditResultStore() 31 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/permission.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nonebot.permission import Permission 4 | 5 | from .event import AtMessageCreateEvent, MessageCreateEvent 6 | 7 | 8 | async def _guild_channel_admin( 9 | event: Union[AtMessageCreateEvent, MessageCreateEvent], 10 | ) -> bool: 11 | return "5" in getattr(event.member, "roles", ()) 12 | 13 | 14 | async def _guild_admin(event: Union[AtMessageCreateEvent, MessageCreateEvent]) -> bool: 15 | return "2" in getattr(event.member, "roles", ()) 16 | 17 | 18 | async def _guild_owner(event: Union[AtMessageCreateEvent, MessageCreateEvent]) -> bool: 19 | return "4" in getattr(event.member, "roles", ()) 20 | 21 | 22 | GUILD_CHANNEL_ADMIN: Permission = Permission(_guild_channel_admin) 23 | """匹配任意子频道管理员聊消息类型事件""" 24 | GUILD_ADMIN: Permission = Permission(_guild_admin) 25 | """匹配任意频道管理员聊消息类型事件""" 26 | GUILD_OWNER: Permission = Permission(_guild_owner) 27 | """匹配任意频道群主群消息类型事件""" 28 | 29 | __all__ = [ 30 | "GUILD_ADMIN", 31 | "GUILD_CHANNEL_ADMIN", 32 | "GUILD_OWNER", 33 | ] 34 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/store.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import TYPE_CHECKING, Dict, Optional 3 | 4 | if TYPE_CHECKING: 5 | from .event import MessageAuditEvent 6 | 7 | 8 | class AuditResultStore: 9 | def __init__(self) -> None: 10 | self._futures: Dict[str, asyncio.Future] = {} 11 | 12 | def add_result(self, result: "MessageAuditEvent"): 13 | audit_id = result.audit_id 14 | if not audit_id: 15 | raise ValueError("audit_id cannot be empty") 16 | if future := self._futures.get(audit_id): 17 | future.set_result(result) 18 | 19 | async def fetch( 20 | self, audit_id: str, timeout: Optional[float] = None 21 | ) -> "MessageAuditEvent": 22 | future = asyncio.get_event_loop().create_future() 23 | self._futures[audit_id] = future 24 | try: 25 | return await asyncio.wait_for(future, timeout) 26 | finally: 27 | del self._futures[audit_id] 28 | 29 | 30 | audit_result = AuditResultStore() 31 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/permission.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nonebot.permission import Permission 4 | 5 | from .event import MessageCreateEvent, AtMessageCreateEvent 6 | 7 | 8 | async def _guild_channel_admin( 9 | event: Union[AtMessageCreateEvent, MessageCreateEvent], 10 | ) -> bool: 11 | return 5 in getattr(event.member, "roles", ()) 12 | 13 | 14 | async def _guild_admin(event: Union[AtMessageCreateEvent, MessageCreateEvent]) -> bool: 15 | return 2 in getattr(event.member, "roles", ()) 16 | 17 | 18 | async def _guild_owner(event: Union[AtMessageCreateEvent, MessageCreateEvent]) -> bool: 19 | return 4 in getattr(event.member, "roles", ()) 20 | 21 | 22 | GUILD_CHANNEL_ADMIN: Permission = Permission(_guild_channel_admin) 23 | """匹配任意子频道管理员聊消息类型事件""" 24 | GUILD_ADMIN: Permission = Permission(_guild_admin) 25 | """匹配任意频道管理员聊消息类型事件""" 26 | GUILD_OWNER: Permission = Permission(_guild_owner) 27 | """匹配任意频道群主群消息类型事件""" 28 | 29 | __all__ = [ 30 | "GUILD_CHANNEL_ADMIN", 31 | "GUILD_ADMIN", 32 | "GUILD_OWNER", 33 | ] 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 NoneBot 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/README.md: -------------------------------------------------------------------------------- 1 |

2 | nonebot-adapter-qqguild 3 |

4 | 5 |
6 | 7 | # NoneBot-Adapter-QQGuild 8 | 9 | _✨ QQ 频道协议适配 ✨_ 10 | 11 |
12 | 13 | > [!IMPORTANT] 14 | > 本适配器已停止支持,请使用 [NoneBot Adapter QQ](https://github.com/nonebot/adapter-qq) 适配器。 15 | 16 | ## 配置 17 | 18 | 修改 NoneBot 配置文件 `.env` 或者 `.env.*`。 19 | 20 | ### Driver 21 | 22 | 参考 [driver](https://nonebot.dev/docs/appendices/config#driver) 配置项,添加 `ForwardDriver` 支持。 23 | 24 | 如: 25 | 26 | ```dotenv 27 | DRIVER=~httpx+~websockets 28 | DRIVER=~aiohttp 29 | ``` 30 | 31 | ### QQGUILD_IS_SANDBOX 32 | 33 | 是否为沙盒模式,默认为 `False`。 34 | 35 | ```dotenv 36 | QQGUILD_IS_SANDBOX=true 37 | ``` 38 | 39 | ### QQGUILD_BOTS 40 | 41 | 配置机器人帐号,如: 42 | 43 | ```dotenv 44 | QQGUILD_BOTS=' 45 | [ 46 | { 47 | "id": "xxx", 48 | "token": "xxx", 49 | "secret": "xxx", 50 | "intent": { 51 | "guild_messages": true, 52 | "at_messages": false 53 | } 54 | } 55 | ] 56 | ' 57 | ``` 58 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | id-token: write 13 | contents: write 14 | steps: 15 | - uses: actions/checkout@v6 16 | 17 | - name: Setup Python environment 18 | uses: ./.github/actions/setup-python 19 | 20 | - name: Get Version 21 | id: version 22 | run: | 23 | echo "VERSION=$(uv version --short)" >> $GITHUB_OUTPUT 24 | echo "TAG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT 25 | echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 26 | 27 | - name: Check Version 28 | if: steps.version.outputs.VERSION != steps.version.outputs.TAG_VERSION 29 | run: exit 1 30 | 31 | - name: Build and publish Package 32 | run: | 33 | uv build 34 | uv publish 35 | 36 | - name: Publish Package to GitHub 37 | run: | 38 | gh release upload --clobber ${{ steps.version.outputs.TAG_NAME }} dist/*.tar.gz dist/*.whl 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/api/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Dict 3 | 4 | from .model import MessageSend 5 | 6 | 7 | def parse_send_message(data: Dict[str, Any]) -> Dict[str, Any]: 8 | model_data = MessageSend(**data).dict(exclude_none=True) 9 | if file_image := model_data.pop("file_image", None): 10 | # 使用 multipart/form-data 11 | multipart_files: Dict[str, Any] = {"file_image": ("file_image", file_image)} 12 | multipart_data: Dict[str, Any] = {} 13 | for k, v in model_data.items(): 14 | if isinstance(v, (dict, list)): 15 | # 当字段类型为对象或数组时需要将字段序列化为 JSON 字符串后进行调用 16 | # https://bot.q.qq.com/wiki/develop/api/openapi/message/post_messages.html#content-type 17 | multipart_files[k] = ( 18 | k, 19 | json.dumps({k: v}).encode("utf-8"), 20 | "application/json", 21 | ) 22 | else: 23 | multipart_data[k] = v 24 | params = {"files": multipart_files, "data": multipart_data} 25 | else: 26 | params = {"json": model_data} 27 | return params 28 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/config.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple, Optional 2 | 3 | from pydantic import Field, HttpUrl, BaseModel 4 | 5 | 6 | class Intents(BaseModel): 7 | guilds: bool = True 8 | guild_members: bool = True 9 | guild_messages: bool = False 10 | guild_message_reactions: bool = True 11 | direct_message: bool = False 12 | message_audit: bool = True 13 | forum_event: bool = False 14 | audio_action: bool = False 15 | at_messages: bool = True 16 | 17 | def to_int(self): 18 | return ( 19 | self.guilds << 0 20 | | self.guild_members << 1 21 | | self.guild_messages << 9 22 | | self.guild_message_reactions << 10 23 | | self.direct_message << 12 24 | | self.message_audit << 27 25 | | self.forum_event << 28 26 | | self.audio_action << 29 27 | | self.at_messages << 30 28 | ) 29 | 30 | 31 | class BotInfo(BaseModel): 32 | id: str = Field(alias="id") 33 | token: str = Field(alias="token") 34 | secret: str = Field(alias="secret") 35 | shard: Optional[Tuple[int, int]] = None 36 | intent: Intents = Field(default_factory=Intents) 37 | 38 | 39 | class Config(BaseModel): 40 | qqguild_is_sandbox: bool = False 41 | qqguild_api_base: HttpUrl = Field("https://api.sgroup.qq.com/") 42 | qqguild_sandbox_api_base: HttpUrl = Field("https://sandbox.api.sgroup.qq.com") 43 | qqguild_bots: List[BotInfo] = Field(default_factory=list) 44 | 45 | class Config: 46 | extra = "ignore" 47 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Ubuntu", 3 | "image": "mcr.microsoft.com/devcontainers/base:ubuntu", 4 | "features": { 5 | "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {}, 6 | "ghcr.io/meaningful-ooo/devcontainer-features/fish:2": {} 7 | }, 8 | "postCreateCommand": "uv sync --all-extras && uv run pre-commit install", 9 | "customizations": { 10 | "vscode": { 11 | "settings": { 12 | "python.analysis.diagnosticMode": "workspace", 13 | "[python]": { 14 | "editor.defaultFormatter": "charliermarsh.ruff", 15 | "editor.codeActionsOnSave": { 16 | "source.fixAll.ruff": "explicit", 17 | "source.organizeImports": "explicit" 18 | } 19 | }, 20 | "[javascript]": { 21 | "editor.defaultFormatter": "esbenp.prettier-vscode" 22 | }, 23 | "[html]": { 24 | "editor.defaultFormatter": "esbenp.prettier-vscode" 25 | }, 26 | "[typescript]": { 27 | "editor.defaultFormatter": "esbenp.prettier-vscode" 28 | }, 29 | "[javascriptreact]": { 30 | "editor.defaultFormatter": "esbenp.prettier-vscode" 31 | }, 32 | "[typescriptreact]": { 33 | "editor.defaultFormatter": "esbenp.prettier-vscode" 34 | }, 35 | "files.exclude": { 36 | "**/__pycache__": true 37 | }, 38 | "files.watcherExclude": { 39 | "**/target/**": true, 40 | "**/__pycache__": true 41 | } 42 | }, 43 | "extensions": [ 44 | "ms-python.python", 45 | "ms-python.vscode-pylance", 46 | "charliermarsh.ruff", 47 | "EditorConfig.EditorConfig", 48 | "esbenp.prettier-vscode" 49 | ] 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/utils.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable 2 | from functools import partial 3 | from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar, overload 4 | from typing_extensions import Concatenate, ParamSpec 5 | 6 | from nonebot.utils import logger_wrapper 7 | 8 | if TYPE_CHECKING: 9 | from .bot import Bot 10 | 11 | B = TypeVar("B", bound="Bot") 12 | R = TypeVar("R") 13 | P = ParamSpec("P") 14 | 15 | log = logger_wrapper("QQ") 16 | 17 | 18 | def escape(s: str) -> str: 19 | return s.replace("&", "&").replace("<", "<").replace(">", ">") 20 | 21 | 22 | def unescape(s: str) -> str: 23 | return s.replace("<", "<").replace(">", ">").replace("&", "&") 24 | 25 | 26 | def exclude_none(data: dict[str, Any]) -> dict[str, Any]: 27 | return {k: v for k, v in data.items() if v is not None} 28 | 29 | 30 | class API(Generic[B, P, R]): 31 | def __init__(self, func: Callable[Concatenate[B, P], Awaitable[R]]) -> None: 32 | self.func = func 33 | 34 | def __set_name__(self, owner: type[B], name: str) -> None: 35 | self.name = name 36 | 37 | @overload 38 | def __get__(self, obj: None, objtype: type[B]) -> "API[B, P, R]": ... 39 | 40 | @overload 41 | def __get__( 42 | self, obj: B, objtype: Optional[type[B]] 43 | ) -> Callable[P, Awaitable[R]]: ... 44 | 45 | def __get__( 46 | self, obj: Optional[B], objtype: Optional[type[B]] = None 47 | ) -> "API[B, P, R] | Callable[P, Awaitable[R]]": 48 | if obj is None: 49 | return self 50 | 51 | return partial(obj.call_api, self.name) # type: ignore 52 | 53 | async def __call__(self, inst: B, *args: P.args, **kwds: P.kwargs) -> R: 54 | return await self.func(inst, *args, **kwds) 55 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/api/request.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import TYPE_CHECKING, Any, Dict 3 | 4 | from nonebot.drivers import Request 5 | from nonebot.adapters.qqguild.exception import ( 6 | ActionFailed, 7 | NetworkError, 8 | AuditException, 9 | ApiNotAvailable, 10 | RateLimitException, 11 | UnauthorizedException, 12 | QQGuildAdapterException, 13 | ) 14 | 15 | from .model import * 16 | 17 | if TYPE_CHECKING: 18 | from nonebot.adapters.qqguild.bot import Bot 19 | from nonebot.adapters.qqguild.adapter import Adapter 20 | 21 | 22 | async def _request(adapter: "Adapter", bot: "Bot", request: Request) -> Any: 23 | try: 24 | data = await adapter.request(request) 25 | if data.status_code == 201 or data.status_code == 202: 26 | if data.content and (content := json.loads(data.content)): 27 | audit_id = ( 28 | content.get("data", {}) 29 | .get("message_audit", {}) 30 | .get("audit_id", None) 31 | ) 32 | if audit_id: 33 | raise AuditException(audit_id) 34 | raise ActionFailed(data) 35 | elif 200 <= data.status_code < 300: 36 | return data.content and json.loads(data.content) 37 | elif data.status_code == 401: 38 | raise UnauthorizedException(data) 39 | elif data.status_code in (404, 405): 40 | raise ApiNotAvailable 41 | elif data.status_code == 429: 42 | raise RateLimitException(data) 43 | else: 44 | raise ActionFailed(data) 45 | except QQGuildAdapterException: 46 | raise 47 | except Exception as e: 48 | raise NetworkError("API request failed") from e 49 | 50 | 51 | def _exclude_none(data: Dict[str, Any]) -> Dict[str, Any]: 52 | return {k: v for k, v in data.items() if v is not None} 53 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nonebot-adapter-qqguild" 3 | version = "0.4.0" 4 | description = "QQ Guild adapter for nonebot2" 5 | authors = [{ name = "yanyongyu", email = "yyy@nonebot.dev" }] 6 | requires-python = ">=3.8, <4.0" 7 | readme = "README.md" 8 | license = "MIT" 9 | keywords = ["bot", "qq", "qqbot", "qqguild"] 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Framework :: Robot Framework", 13 | "Framework :: Robot Framework :: Library", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python :: 3", 16 | ] 17 | dependencies = [ 18 | "pydantic >=1.9.0, <2.0.0", 19 | "nonebot2 >=2.1.0, <3.0.0", 20 | "typing-extensions >=4.4.0, <5.0.0", 21 | ] 22 | 23 | [project.urls] 24 | Homepage = "https://github.com/nonebot/adapter-qq/tree/master/packages/nonebot-adapter-qqguild" 25 | Repository = "https://github.com/nonebot/adapter-qq" 26 | Documentation = "https://github.com/nonebot/adapter-qq#readme" 27 | 28 | [dependency-groups] 29 | dev = [ 30 | "isort >=5.10.1, <6.0.0", 31 | "black >=23.1.0, <24.0.0", 32 | "nonemoji >=0.1.3, <0.2.0", 33 | "pre-commit >=3.3.0, <4.0.0", 34 | "ruff >=0.0.282, <0.1.0", 35 | ] 36 | 37 | [tool.uv.build-backend] 38 | module-root = "" 39 | module-name = "nonebot.adapters.qqguild" 40 | 41 | [build-system] 42 | requires = ["uv_build >=0.9.0, <0.10.0"] 43 | build-backend = "uv_build" 44 | 45 | [tool.black] 46 | line-length = 88 47 | include = '\.pyi?$' 48 | extend-exclude = ''' 49 | ''' 50 | 51 | [tool.isort] 52 | profile = "black" 53 | line_length = 88 54 | length_sort = true 55 | skip_gitignore = true 56 | force_sort_within_sections = true 57 | extra_standard_library = ["typing_extensions"] 58 | 59 | [tool.ruff] 60 | line-length = 88 61 | 62 | [tool.ruff.lint] 63 | select = ["E", "W", "F", "UP", "C", "T", "Q"] 64 | ignore = ["E402", "F403", "F405", "C901", "UP037"] 65 | 66 | [tool.pyright] 67 | pythonPlatform = "All" 68 | pythonVersion = "3.8" 69 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/config.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from nonebot.compat import PYDANTIC_V2, ConfigDict 4 | from pydantic import BaseModel, Field, HttpUrl 5 | 6 | 7 | class Intents(BaseModel): 8 | guilds: bool = True 9 | guild_members: bool = True 10 | guild_messages: bool = False 11 | guild_message_reactions: bool = True 12 | direct_message: bool = False 13 | open_forum_event: bool = False 14 | audio_live_member: bool = False 15 | c2c_group_at_messages: bool = False 16 | interaction: bool = False 17 | message_audit: bool = True 18 | forum_event: bool = False 19 | audio_action: bool = False 20 | at_messages: bool = True 21 | 22 | if PYDANTIC_V2: 23 | model_config: ConfigDict = ConfigDict(extra="forbid") 24 | else: 25 | 26 | class Config: 27 | extra = "forbid" 28 | 29 | def to_int(self): 30 | return ( 31 | self.guilds << 0 32 | | self.guild_members << 1 33 | | self.guild_messages << 9 34 | | self.guild_message_reactions << 10 35 | | self.direct_message << 12 36 | | self.open_forum_event << 18 37 | | self.audio_live_member << 19 38 | | self.c2c_group_at_messages << 25 39 | | self.interaction << 26 40 | | self.message_audit << 27 41 | | self.forum_event << 28 42 | | self.audio_action << 29 43 | | self.at_messages << 30 44 | ) 45 | 46 | 47 | class BotInfo(BaseModel): 48 | id: str = Field(alias="id") 49 | token: str = Field(alias="token") 50 | secret: str = Field(alias="secret") 51 | shard: Optional[tuple[int, int]] = None 52 | intent: Intents = Field(default_factory=Intents) 53 | use_websocket: bool = True 54 | 55 | 56 | class Config(BaseModel): 57 | qq_is_sandbox: bool = False 58 | qq_api_base: HttpUrl = Field("https://api.sgroup.qq.com/") # type: ignore 59 | qq_sandbox_api_base: HttpUrl = Field("https://sandbox.api.sgroup.qq.com") # type: ignore 60 | qq_auth_base: HttpUrl = Field("https://bots.qq.com/app/getAppAccessToken") # type: ignore 61 | qq_verify_webhook: bool = True 62 | qq_bots: list[BotInfo] = Field(default_factory=list) 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | nonebot-adapter-qq 3 |

4 | 5 |
6 | 7 | # NoneBot-Adapter-QQ 8 | 9 | _✨ QQ 协议适配 ✨_ 10 | 11 |
12 | 13 | ## 配置 14 | 15 | 修改 NoneBot 配置文件 `.env` 或者 `.env.*`。 16 | 17 | ### Driver 18 | 19 | 如果使用 WebSocket 连接方式,请参考 [driver](https://nonebot.dev/docs/appendices/config#driver) 配置项,添加 `HTTPClient` 和 `WebSocketClient` 支持。如: 20 | 21 | ```dotenv 22 | DRIVER=~httpx+~websockets 23 | DRIVER=~aiohttp 24 | ``` 25 | 26 | 如果使用 WebHook 连接方式,则添加 `ASGIServer` 支持。如: 27 | 28 | ```dotenv 29 | DRIVER=~fastapi 30 | ``` 31 | 32 | ### QQ_IS_SANDBOX 33 | 34 | 是否为沙盒模式,默认为 `False`。 35 | 36 | ```dotenv 37 | QQ_IS_SANDBOX=true 38 | ``` 39 | 40 | ### QQ_BOTS 41 | 42 | 配置机器人帐号 `id` `token` `secret`,intent 需要根据机器人类型以及需要的事件进行配置。 43 | 44 | #### Webhook / WebSocket 45 | 46 | 通过配置项 `use_websocket` 来选择是否启用 WebSocket 连接,当前默认为 `True`。如果关闭 WebSocket 连接方式,则可以通过 WebHook 方式来连接机器人,请在 QQ 开放平台中配置机器人回调地址:`https://host:port/qq/webhook`。 47 | 48 | #### Intent 49 | 50 | Intent 仅对 WebSocket 连接方式生效。以下为所有 Intent 配置项以及默认值: 51 | 52 | ```json 53 | { 54 | "guilds": true, 55 | "guild_members": true, 56 | "guild_messages": false, 57 | "guild_message_reactions": true, 58 | "direct_message": false, 59 | "open_forum_event": false, 60 | "audio_live_member": false, 61 | "c2c_group_at_messages": false, 62 | "interaction": false, 63 | "message_audit": true, 64 | "forum_event": false, 65 | "audio_action": false, 66 | "at_messages": true 67 | } 68 | ``` 69 | 70 | #### 示例 71 | 72 | 私域频道机器人示例 73 | 74 | ```dotenv 75 | QQ_BOTS=' 76 | [ 77 | { 78 | "id": "xxx", 79 | "token": "xxx", 80 | "secret": "xxx", 81 | "intent": { 82 | "guild_messages": true, 83 | "at_messages": false 84 | }, 85 | "use_websocket": false 86 | } 87 | ] 88 | ' 89 | ``` 90 | 91 | 公域群机器人示例 92 | 93 | ```dotenv 94 | QQ_BOTS=' 95 | [ 96 | { 97 | "id": "xxx", 98 | "token": "xxx", 99 | "secret": "xxx", 100 | "intent": { 101 | "c2c_group_at_messages": true 102 | }, 103 | "use_websocket": false 104 | } 105 | ] 106 | ' 107 | ``` 108 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/payload.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from typing import Tuple, Union 3 | from typing_extensions import Literal, Annotated 4 | 5 | from pydantic import Extra, Field, BaseModel 6 | 7 | from .transformer import AliasExportTransformer 8 | 9 | 10 | class Opcode(IntEnum): 11 | DISPATCH = 0 12 | HEARTBEAT = 1 13 | IDENTIFY = 2 14 | RESUME = 6 15 | RECONNECT = 7 16 | INVALID_SESSION = 9 17 | HELLO = 10 18 | HEARTBEAT_ACK = 11 19 | 20 | 21 | class Payload(AliasExportTransformer, BaseModel): 22 | class Config: 23 | extra = Extra.allow 24 | allow_population_by_field_name = True 25 | 26 | 27 | class Dispatch(Payload): 28 | opcode: Literal[Opcode.DISPATCH] = Field(Opcode.DISPATCH, alias="op") 29 | data: dict = Field(alias="d") 30 | sequence: int = Field(alias="s") 31 | type: str = Field(alias="t") 32 | 33 | 34 | class Heartbeat(Payload): 35 | opcode: Literal[Opcode.HEARTBEAT] = Field(Opcode.HEARTBEAT, alias="op") 36 | data: int = Field(alias="d") 37 | 38 | 39 | class IdentifyData(BaseModel, extra=Extra.allow): 40 | token: str 41 | intents: int 42 | shard: Tuple[int, int] 43 | properties: dict 44 | 45 | 46 | class Identify(Payload): 47 | opcode: Literal[Opcode.IDENTIFY] = Field(Opcode.IDENTIFY, alias="op") 48 | data: IdentifyData = Field(alias="d") 49 | 50 | 51 | class ResumeData(BaseModel, extra=Extra.allow): 52 | token: str 53 | session_id: str 54 | seq: int 55 | 56 | 57 | class Resume(Payload): 58 | opcode: Literal[Opcode.RESUME] = Field(Opcode.RESUME, alias="op") 59 | data: ResumeData = Field(alias="d") 60 | 61 | 62 | class Reconnect(Payload): 63 | opcode: Literal[Opcode.RECONNECT] = Field(Opcode.RECONNECT, alias="op") 64 | 65 | 66 | class InvalidSession(Payload): 67 | opcode: Literal[Opcode.INVALID_SESSION] = Field(Opcode.INVALID_SESSION, alias="op") 68 | 69 | 70 | class HelloData(BaseModel, extra=Extra.allow): 71 | heartbeat_interval: int 72 | 73 | 74 | class Hello(Payload): 75 | opcode: Literal[Opcode.HELLO] = Field(Opcode.HELLO, alias="op") 76 | data: HelloData = Field(alias="d") 77 | 78 | 79 | class HeartbeatAck(Payload): 80 | opcode: Literal[Opcode.HEARTBEAT_ACK] = Field(Opcode.HEARTBEAT_ACK, alias="op") 81 | 82 | 83 | PayloadType = Union[ 84 | Annotated[ 85 | Union[Dispatch, Reconnect, InvalidSession, Hello, HeartbeatAck], 86 | Field(discriminator="opcode"), 87 | ], 88 | Payload, 89 | ] 90 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/exception.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional 3 | 4 | from nonebot.drivers import Response 5 | from nonebot.exception import AdapterException 6 | from nonebot.exception import ActionFailed as BaseActionFailed 7 | from nonebot.exception import NetworkError as BaseNetworkError 8 | from nonebot.exception import NoLogException as BaseNoLogException 9 | from nonebot.exception import ApiNotAvailable as BaseApiNotAvailable 10 | 11 | from .store import audit_result 12 | 13 | 14 | class QQGuildAdapterException(AdapterException): 15 | def __init__(self): 16 | super().__init__("qqguild") 17 | 18 | 19 | class NoLogException(BaseNoLogException, QQGuildAdapterException): 20 | pass 21 | 22 | 23 | class ActionFailed(BaseActionFailed, QQGuildAdapterException): 24 | def __init__(self, response: Response): 25 | self.status_code: int = response.status_code 26 | self.code: Optional[int] = None 27 | self.message: Optional[str] = None 28 | self.data: Optional[dict] = None 29 | if response.content: 30 | body = json.loads(response.content) 31 | self._prepare_body(body) 32 | 33 | def __repr__(self) -> str: 34 | return ( 35 | f"" 37 | ) 38 | 39 | def __str__(self): 40 | return self.__repr__() 41 | 42 | def _prepare_body(self, body: dict): 43 | self.code = body.get("code", None) 44 | self.message = body.get("message", None) 45 | self.data = body.get("data", None) 46 | 47 | 48 | class UnauthorizedException(ActionFailed): 49 | pass 50 | 51 | 52 | class RateLimitException(ActionFailed): 53 | pass 54 | 55 | 56 | class NetworkError(BaseNetworkError, QQGuildAdapterException): 57 | def __init__(self, msg: Optional[str] = None): 58 | super().__init__() 59 | self.msg: Optional[str] = msg 60 | """错误原因""" 61 | 62 | def __repr__(self): 63 | return f"" 64 | 65 | def __str__(self): 66 | return self.__repr__() 67 | 68 | 69 | class ApiNotAvailable(BaseApiNotAvailable, QQGuildAdapterException): 70 | pass 71 | 72 | 73 | class AuditException(QQGuildAdapterException): 74 | """消息审核""" 75 | 76 | def __init__(self, audit_id: str): 77 | super().__init__() 78 | self.audit_id = audit_id 79 | 80 | async def get_audit_result(self, timeout: Optional[float] = None): 81 | """获取审核结果""" 82 | return await audit_result.fetch(self.audit_id, timeout) 83 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "nonebot-adapter-qq" 3 | version = "1.6.6" 4 | description = "QQ adapter for nonebot2" 5 | authors = [{ name = "yanyongyu", email = "yyy@nonebot.dev" }] 6 | requires-python = ">=3.9, <4" 7 | readme = "README.md" 8 | license = "MIT" 9 | keywords = ["bot", "qq", "qqbot", "qqguild"] 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Framework :: Robot Framework", 13 | "Framework :: Robot Framework :: Library", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python :: 3", 16 | ] 17 | dependencies = [ 18 | "yarl >=1.9.0, <2", 19 | "nonebot2 >=2.2.1, <3", 20 | "cryptography >=43.0.3, <47.0.0", 21 | "typing-extensions >=4.4.0, <5.0.0", 22 | "pydantic >=1.10.0, <3.0.0, !=2.5.0, !=2.5.1", 23 | ] 24 | 25 | [project.urls] 26 | Homepage = "https://github.com/nonebot/adapter-qq" 27 | Repository = "https://github.com/nonebot/adapter-qq" 28 | Documentation = "https://github.com/nonebot/adapter-qq#readme" 29 | 30 | [dependency-groups] 31 | dev = [ 32 | "ruff >=0.14.0, <0.15.0", 33 | "nonemoji >=0.1.3, <0.2.0", 34 | "pre-commit >=4.0.0, <5.0.0", 35 | ] 36 | 37 | [tool.uv.build-backend] 38 | module-root = "" 39 | module-name = "nonebot.adapters.qq" 40 | 41 | [build-system] 42 | requires = ["uv_build >=0.9.0, <0.10.0"] 43 | build-backend = "uv_build" 44 | 45 | [tool.ruff] 46 | line-length = 88 47 | 48 | [tool.ruff.lint] 49 | select = [ 50 | "F", # Pyflakes 51 | "W", # pycodestyle warnings 52 | "E", # pycodestyle errors 53 | "I", # isort 54 | "UP", # pyupgrade 55 | "ASYNC", # flake8-async 56 | "C4", # flake8-comprehensions 57 | "T10", # flake8-debugger 58 | "T20", # flake8-print 59 | "PYI", # flake8-pyi 60 | "PT", # flake8-pytest-style 61 | "Q", # flake8-quotes 62 | "RUF", # Ruff-specific rules 63 | ] 64 | ignore = [ 65 | "E402", # module-import-not-at-top-of-file 66 | "UP037", # quoted-annotation 67 | "RUF001", # ambiguous-unicode-character-string 68 | "RUF002", # ambiguous-unicode-character-docstring 69 | "RUF003", # ambiguous-unicode-character-comment 70 | ] 71 | 72 | [tool.ruff.lint.isort] 73 | force-sort-within-sections = true 74 | extra-standard-library = ["typing_extensions"] 75 | 76 | [tool.ruff.lint.flake8-pytest-style] 77 | fixture-parentheses = false 78 | mark-parentheses = false 79 | 80 | [tool.ruff.lint.pyupgrade] 81 | keep-runtime-typing = true 82 | 83 | [tool.pyright] 84 | pythonVersion = "3.9" 85 | pythonPlatform = "All" 86 | defineConstant = { PYDANTIC_V2 = true } 87 | 88 | typeCheckingMode = "standard" 89 | reportShadowedImports = false 90 | disableBytesTypePromotions = true 91 | reportIncompatibleMethodOverride = false 92 | reportIncompatibleVariableOverride = false 93 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/exception.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional 3 | 4 | from nonebot.drivers import Response 5 | from nonebot.exception import ActionFailed as BaseActionFailed 6 | from nonebot.exception import AdapterException 7 | from nonebot.exception import ApiNotAvailable as BaseApiNotAvailable 8 | from nonebot.exception import NetworkError as BaseNetworkError 9 | from nonebot.exception import NoLogException as BaseNoLogException 10 | 11 | from .store import audit_result 12 | 13 | 14 | class QQAdapterException(AdapterException): 15 | def __init__(self): 16 | super().__init__("qq") 17 | 18 | 19 | class NoLogException(BaseNoLogException, QQAdapterException): 20 | pass 21 | 22 | 23 | class ActionFailed(BaseActionFailed, QQAdapterException): 24 | def __init__(self, response: Response): 25 | self.response = response 26 | 27 | self.body: Optional[dict] = None 28 | if response.content: 29 | try: 30 | self.body = json.loads(response.content) 31 | except Exception: 32 | pass 33 | 34 | @property 35 | def status_code(self) -> int: 36 | return self.response.status_code 37 | 38 | @property 39 | def code(self) -> Optional[int]: 40 | return None if self.body is None else self.body.get("code", None) 41 | 42 | @property 43 | def message(self) -> Optional[str]: 44 | return None if self.body is None else self.body.get("message", None) 45 | 46 | @property 47 | def data(self) -> Optional[dict]: 48 | return None if self.body is None else self.body.get("data", None) 49 | 50 | @property 51 | def trace_id(self) -> Optional[str]: 52 | return self.response.headers.get("X-Tps-trace-ID", None) 53 | 54 | def __repr__(self) -> str: 55 | args = ("code", "message", "data", "trace_id") 56 | return ( 57 | f"" 60 | ) 61 | 62 | def __str__(self): 63 | return self.__repr__() 64 | 65 | 66 | class UnauthorizedException(ActionFailed): 67 | pass 68 | 69 | 70 | class RateLimitException(ActionFailed): 71 | pass 72 | 73 | 74 | class NetworkError(BaseNetworkError, QQAdapterException): 75 | def __init__(self, msg: Optional[str] = None): 76 | super().__init__() 77 | self.msg: Optional[str] = msg 78 | """错误原因""" 79 | 80 | def __repr__(self): 81 | return f"" 82 | 83 | def __str__(self): 84 | return self.__repr__() 85 | 86 | 87 | class ApiNotAvailable(BaseApiNotAvailable, QQAdapterException): 88 | pass 89 | 90 | 91 | class AuditException(QQAdapterException): 92 | """消息审核""" 93 | 94 | def __init__(self, audit_id: str): 95 | super().__init__() 96 | self.audit_id = audit_id 97 | 98 | async def get_audit_result(self, timeout: Optional[float] = None): 99 | """获取审核结果""" 100 | return await audit_result.fetch(self.audit_id, timeout) 101 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/transformer.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Union, Optional 2 | 3 | from pydantic import BaseModel 4 | 5 | if TYPE_CHECKING: 6 | from pydantic.typing import DictStrAny, MappingIntStrAny, AbstractSetIntStr 7 | 8 | 9 | class ExcludeNoneTransformer(BaseModel): 10 | def dict( 11 | self, 12 | *, 13 | include: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None, 14 | exclude: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None, 15 | by_alias: bool = False, 16 | skip_defaults: Optional[bool] = None, 17 | exclude_unset: bool = False, 18 | exclude_defaults: bool = False, 19 | **kwargs: Any, 20 | ) -> "DictStrAny": 21 | return super().dict( 22 | include=include, 23 | exclude=exclude, 24 | by_alias=by_alias, 25 | skip_defaults=skip_defaults, 26 | exclude_unset=exclude_unset, 27 | exclude_defaults=exclude_defaults, 28 | exclude_none=True, 29 | ) 30 | 31 | 32 | class BoolToIntTransformer(BaseModel): 33 | def dict( 34 | self, 35 | *, 36 | include: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None, 37 | exclude: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None, 38 | by_alias: bool = False, 39 | skip_defaults: Optional[bool] = None, 40 | exclude_unset: bool = False, 41 | exclude_defaults: bool = False, 42 | exclude_none: bool = False, 43 | ) -> "DictStrAny": 44 | data = super().dict( 45 | include=include, 46 | exclude=exclude, 47 | by_alias=by_alias, 48 | skip_defaults=skip_defaults, 49 | exclude_unset=exclude_unset, 50 | exclude_defaults=exclude_defaults, 51 | exclude_none=exclude_none, 52 | ) 53 | for key, value in data.items(): 54 | if isinstance(value, bool): 55 | data[key] = int(value) 56 | return data 57 | 58 | 59 | class IntToStrTransformer(BaseModel): 60 | def dict( 61 | self, 62 | *, 63 | include: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None, 64 | exclude: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None, 65 | by_alias: bool = False, 66 | skip_defaults: Optional[bool] = None, 67 | exclude_unset: bool = False, 68 | exclude_defaults: bool = False, 69 | exclude_none: bool = False, 70 | ) -> "DictStrAny": 71 | data = super().dict( 72 | include=include, 73 | exclude=exclude, 74 | by_alias=by_alias, 75 | skip_defaults=skip_defaults, 76 | exclude_unset=exclude_unset, 77 | exclude_defaults=exclude_defaults, 78 | exclude_none=exclude_none, 79 | ) 80 | for key, value in data.items(): 81 | if isinstance(value, int): 82 | data[key] = str(value) 83 | return data 84 | 85 | 86 | class AliasExportTransformer(BaseModel): 87 | def dict( 88 | self, 89 | *, 90 | include: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None, 91 | exclude: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None, 92 | skip_defaults: Optional[bool] = None, 93 | exclude_unset: bool = False, 94 | exclude_defaults: bool = False, 95 | exclude_none: bool = False, 96 | **kwargs: Any, 97 | ) -> "DictStrAny": 98 | return super().dict( 99 | include=include, 100 | exclude=exclude, 101 | by_alias=True, 102 | skip_defaults=skip_defaults, 103 | exclude_unset=exclude_unset, 104 | exclude_defaults=exclude_defaults, 105 | exclude_none=exclude_none, 106 | ) 107 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/models/payload.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from typing import Annotated, Optional, Union 3 | from typing_extensions import Literal 4 | 5 | from nonebot.compat import PYDANTIC_V2, ConfigDict 6 | from pydantic import BaseModel, Field 7 | 8 | PAYLOAD_FIELD_ALIASES = {"opcode": "op", "data": "d", "sequence": "s", "type": "t"} 9 | 10 | 11 | class Opcode(IntEnum): 12 | DISPATCH = 0 13 | HEARTBEAT = 1 14 | IDENTIFY = 2 15 | RESUME = 6 16 | RECONNECT = 7 17 | INVALID_SESSION = 9 18 | HELLO = 10 19 | HEARTBEAT_ACK = 11 20 | HTTP_CALLBACK_ACK = 12 21 | WEBHOOK_VERIFY = 13 22 | 23 | 24 | class Payload(BaseModel): 25 | if PYDANTIC_V2: 26 | model_config: ConfigDict = ConfigDict( 27 | extra="allow", 28 | populate_by_name=True, 29 | alias_generator=lambda x: PAYLOAD_FIELD_ALIASES.get(x, x), 30 | ) 31 | else: 32 | 33 | class Config: 34 | extra = "allow" 35 | allow_population_by_field_name = True 36 | 37 | @classmethod 38 | def alias_generator(cls, string: str) -> str: 39 | return PAYLOAD_FIELD_ALIASES.get(string, string) 40 | 41 | 42 | class Dispatch(Payload): 43 | opcode: Literal[Opcode.DISPATCH] = Field(Opcode.DISPATCH) 44 | data: dict 45 | sequence: Optional[int] = None 46 | type: str 47 | id: Optional[str] = None 48 | 49 | 50 | class Heartbeat(Payload): 51 | opcode: Literal[Opcode.HEARTBEAT] = Field(Opcode.HEARTBEAT) 52 | data: int 53 | 54 | 55 | class IdentifyData(BaseModel): 56 | token: str 57 | intents: int 58 | shard: tuple[int, int] 59 | properties: dict 60 | 61 | if PYDANTIC_V2: 62 | model_config: ConfigDict = ConfigDict(extra="allow") 63 | else: 64 | 65 | class Config: 66 | extra = "allow" 67 | 68 | 69 | class Identify(Payload): 70 | opcode: Literal[Opcode.IDENTIFY] = Field(Opcode.IDENTIFY) 71 | data: IdentifyData 72 | 73 | 74 | class ResumeData(BaseModel): 75 | token: str 76 | session_id: str 77 | seq: int 78 | 79 | if PYDANTIC_V2: 80 | model_config: ConfigDict = ConfigDict(extra="allow") 81 | else: 82 | 83 | class Config: 84 | extra = "allow" 85 | 86 | 87 | class Resume(Payload): 88 | opcode: Literal[Opcode.RESUME] = Field(Opcode.RESUME) 89 | data: ResumeData 90 | 91 | 92 | class Reconnect(Payload): 93 | opcode: Literal[Opcode.RECONNECT] = Field(Opcode.RECONNECT) 94 | 95 | 96 | class InvalidSession(Payload): 97 | opcode: Literal[Opcode.INVALID_SESSION] = Field(Opcode.INVALID_SESSION) 98 | 99 | 100 | class HelloData(BaseModel): 101 | heartbeat_interval: int 102 | 103 | if PYDANTIC_V2: 104 | model_config: ConfigDict = ConfigDict(extra="allow") 105 | else: 106 | 107 | class Config: 108 | extra = "allow" 109 | 110 | 111 | class Hello(Payload): 112 | opcode: Literal[Opcode.HELLO] = Field(Opcode.HELLO) 113 | data: HelloData 114 | 115 | 116 | class HeartbeatAck(Payload): 117 | opcode: Literal[Opcode.HEARTBEAT_ACK] = Field(Opcode.HEARTBEAT_ACK) 118 | 119 | 120 | class HTTPCallbackAck(Payload): 121 | opcode: Literal[Opcode.HTTP_CALLBACK_ACK] = Field(Opcode.HTTP_CALLBACK_ACK) 122 | data: int 123 | 124 | 125 | class WebhookVerifyData(BaseModel): 126 | plain_token: str 127 | event_ts: str 128 | 129 | if PYDANTIC_V2: 130 | model_config: ConfigDict = ConfigDict(extra="allow") 131 | else: 132 | 133 | class Config: 134 | extra = "allow" 135 | 136 | 137 | class WebhookVerify(Payload): 138 | opcode: Literal[Opcode.WEBHOOK_VERIFY] = Field(Opcode.WEBHOOK_VERIFY) 139 | data: WebhookVerifyData 140 | 141 | 142 | PayloadType = Union[ 143 | Annotated[ 144 | Union[Dispatch, Reconnect, InvalidSession, Hello, HeartbeatAck, WebhookVerify], 145 | Field(discriminator="opcode"), 146 | ], 147 | Payload, 148 | ] 149 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/models/qq.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Literal, Optional 3 | from urllib.parse import urlparse 4 | 5 | from pydantic import BaseModel 6 | 7 | from nonebot.adapters.qq.compat import field_validator 8 | 9 | 10 | class FriendAuthor(BaseModel): 11 | id: str 12 | user_openid: str 13 | union_openid: Optional[str] = None 14 | 15 | 16 | class GroupMemberAuthor(BaseModel): 17 | id: str 18 | member_openid: str 19 | union_openid: Optional[str] = None 20 | 21 | 22 | class Attachment(BaseModel): 23 | content_type: str 24 | filename: Optional[str] = None 25 | height: Optional[int] = None 26 | width: Optional[int] = None 27 | size: Optional[int] = None 28 | url: Optional[str] = None 29 | 30 | @field_validator("url", mode="after") 31 | def check_url(cls, v: str): 32 | if v and not urlparse(v).hostname: 33 | return f"https://{v}" 34 | return v 35 | 36 | 37 | class Media(BaseModel): 38 | file_info: str 39 | 40 | 41 | class QQMessage(BaseModel): 42 | id: str 43 | content: str 44 | timestamp: str 45 | attachments: Optional[list[Attachment]] = None 46 | 47 | 48 | class PostC2CMessagesReturn(BaseModel): 49 | id: Optional[str] = None 50 | timestamp: Optional[datetime] = None 51 | 52 | 53 | class PostGroupMessagesReturn(BaseModel): 54 | id: Optional[str] = None 55 | timestamp: Optional[datetime] = None 56 | 57 | 58 | class PostC2CFilesReturn(BaseModel): 59 | file_uuid: Optional[str] = None 60 | file_info: Optional[str] = None 61 | ttl: Optional[int] = None 62 | 63 | 64 | class PostGroupFilesReturn(BaseModel): 65 | file_uuid: Optional[str] = None 66 | file_info: Optional[str] = None 67 | ttl: Optional[int] = None 68 | 69 | 70 | class GroupMember(BaseModel): 71 | member_openid: str 72 | join_timestamp: datetime 73 | 74 | 75 | class PostGroupMembersReturn(BaseModel): 76 | members: list[GroupMember] 77 | next_index: Optional[int] = None 78 | 79 | 80 | class MessageActionButton(BaseModel): 81 | template_id: Literal["1", "10"] = "1" # 待废弃字段!!! 82 | callback_data: Optional[str] = None 83 | feedback: Optional[bool] = None # 反馈按钮(赞踩按钮) 84 | tts: Optional[bool] = None # TTS 语音播放按钮 85 | re_generate: Optional[bool] = None # 重新生成按钮 86 | stop_generate: Optional[bool] = None # 停止生成按钮 87 | 88 | 89 | class PromptAction(BaseModel): 90 | type: Literal[2] = 2 91 | 92 | 93 | class PromptRenderData(BaseModel): 94 | label: str 95 | style: Literal[2] = 2 96 | 97 | 98 | class PromptButton(BaseModel): 99 | render_data: PromptRenderData 100 | action: PromptAction 101 | 102 | 103 | class PromptRow(BaseModel): 104 | buttons: list[PromptButton] 105 | 106 | 107 | class PromptContent(BaseModel): 108 | rows: list[PromptRow] 109 | 110 | 111 | class PromptKeyboardModel(BaseModel): 112 | content: PromptContent 113 | 114 | 115 | class MessagePromptKeyboard(BaseModel): 116 | keyboard: PromptKeyboardModel 117 | 118 | 119 | class MessageStream(BaseModel): 120 | state: Literal[1, 10, 11, 20] 121 | """1: 正文生成中, 10: 正文生成结束, 11: 引志消息生成中, 20: 引导消息生成结束。""" 122 | id: Optional[str] = None 123 | """第一条不用填写,第二条需要填写第一个分片返回的 msgID""" 124 | index: int 125 | """从 1 开始""" 126 | reset: Optional[bool] = None 127 | """只能用于流式消息没有发送完成时,reset 时 index 需要从 0 开始,需要填写流式 id""" 128 | 129 | 130 | __all__ = [ 131 | "Attachment", 132 | "FriendAuthor", 133 | "GroupMember", 134 | "GroupMemberAuthor", 135 | "Media", 136 | "MessageActionButton", 137 | "MessagePromptKeyboard", 138 | "MessageStream", 139 | "PostC2CFilesReturn", 140 | "PostC2CMessagesReturn", 141 | "PostGroupFilesReturn", 142 | "PostGroupMembersReturn", 143 | "PostGroupMessagesReturn", 144 | "PromptAction", 145 | "PromptButton", 146 | "PromptContent", 147 | "PromptKeyboardModel", 148 | "PromptRenderData", 149 | "PromptRow", 150 | "QQMessage", 151 | ] 152 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/models/common.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Literal, Optional 3 | from urllib.parse import urlparse 4 | 5 | from pydantic import BaseModel 6 | 7 | from nonebot.adapters.qq.compat import field_validator 8 | 9 | 10 | # Message Attachment 11 | class MessageAttachment(BaseModel): 12 | url: str 13 | 14 | @field_validator("url", mode="after") 15 | @classmethod 16 | def check_url(cls, v: str): 17 | if v and not urlparse(v).hostname: 18 | return f"https://{v}" 19 | return v 20 | 21 | 22 | # Message Embed 23 | class MessageEmbedThumbnail(BaseModel): 24 | url: str 25 | 26 | 27 | class MessageEmbedField(BaseModel): 28 | name: Optional[str] = None 29 | 30 | 31 | class MessageEmbed(BaseModel): 32 | title: Optional[str] = None 33 | description: Optional[str] = None 34 | prompt: str 35 | thumbnail: Optional[MessageEmbedThumbnail] = None 36 | fields: Optional[list[MessageEmbedField]] = None 37 | 38 | 39 | # Message Ark 40 | class MessageArkObjKv(BaseModel): 41 | key: Optional[str] = None 42 | value: Optional[str] = None 43 | 44 | 45 | class MessageArkObj(BaseModel): 46 | obj_kv: Optional[list[MessageArkObjKv]] = None 47 | 48 | 49 | class MessageArkKv(BaseModel): 50 | key: Optional[str] = None 51 | value: Optional[str] = None 52 | obj: Optional[list[MessageArkObj]] = None 53 | 54 | 55 | class MessageArk(BaseModel): 56 | template_id: Optional[int] = None 57 | kv: Optional[list[MessageArkKv]] = None 58 | 59 | 60 | # Message Reference 61 | class MessageReference(BaseModel): 62 | message_id: str 63 | ignore_get_message_error: Optional[bool] = None 64 | 65 | 66 | # Message Markdown 67 | class MessageMarkdownParams(BaseModel): 68 | key: Optional[str] = None 69 | values: Optional[list[str]] = None 70 | 71 | 72 | class MessageMarkdown(BaseModel): 73 | template_id: Optional[int] = None 74 | custom_template_id: Optional[str] = None 75 | params: Optional[list[MessageMarkdownParams]] = None 76 | content: Optional[str] = None 77 | 78 | 79 | # Message Keyboard 80 | class Permission(BaseModel): 81 | type: Optional[int] = None 82 | specify_role_ids: Optional[list[str]] = None 83 | specify_user_ids: Optional[list[str]] = None 84 | 85 | 86 | class Action(BaseModel): 87 | type: Optional[int] = None 88 | permission: Optional[Permission] = None 89 | data: Optional[str] = None 90 | reply: Optional[bool] = None 91 | enter: Optional[bool] = None 92 | anchor: Optional[int] = None 93 | unsupport_tips: Optional[str] = None 94 | click_limit: Optional[int] = None # deprecated 95 | at_bot_show_channel_list: Optional[bool] = None # deprecated 96 | 97 | 98 | class RenderData(BaseModel): 99 | label: Optional[str] = None 100 | visited_label: Optional[str] = None 101 | style: Optional[int] = None 102 | 103 | 104 | class Button(BaseModel): 105 | id: Optional[str] = None 106 | render_data: Optional[RenderData] = None 107 | action: Optional[Action] = None 108 | 109 | 110 | class InlineKeyboardRow(BaseModel): 111 | buttons: Optional[list[Button]] = None 112 | 113 | 114 | class InlineKeyboard(BaseModel): 115 | rows: Optional[list[InlineKeyboardRow]] = None 116 | 117 | 118 | class MessageKeyboard(BaseModel): 119 | id: Optional[str] = None 120 | content: Optional[InlineKeyboard] = None 121 | 122 | 123 | # Message Audit Event 124 | class MessageAudited(BaseModel): 125 | audit_id: str 126 | message_id: Optional[str] = None 127 | user_openid: Optional[str] = None 128 | group_openid: Optional[str] = None 129 | guild_id: Optional[str] = None 130 | channel_id: Optional[str] = None 131 | audit_time: datetime 132 | create_time: Optional[datetime] = None 133 | seq_in_channel: Optional[str] = None 134 | 135 | 136 | # Interaction Event 137 | class ButtonInteractionContent(BaseModel): 138 | user_id: Optional[str] = None 139 | message_id: Optional[str] = None 140 | feature_id: Optional[str] = None 141 | button_id: Optional[str] = None 142 | button_data: Optional[str] = None 143 | checked: Optional[int] = None 144 | feedback_opt: Optional[str] = None 145 | 146 | 147 | class ButtonInteractionData(BaseModel): 148 | resolved: ButtonInteractionContent 149 | 150 | 151 | class ButtonInteraction(BaseModel): 152 | id: str 153 | type: Literal[11, 12, 13] 154 | version: int 155 | timestamp: str 156 | scene: str 157 | chat_type: int 158 | guild_id: Optional[str] = None 159 | channel_id: Optional[str] = None 160 | user_openid: Optional[str] = None 161 | group_openid: Optional[str] = None 162 | group_member_openid: Optional[str] = None 163 | application_id: str 164 | data: ButtonInteractionData 165 | 166 | 167 | __all__ = [ 168 | "Action", 169 | "Button", 170 | "ButtonInteraction", 171 | "ButtonInteractionContent", 172 | "ButtonInteractionData", 173 | "InlineKeyboard", 174 | "InlineKeyboardRow", 175 | "MessageArk", 176 | "MessageArkKv", 177 | "MessageArkObj", 178 | "MessageArkObjKv", 179 | "MessageAttachment", 180 | "MessageAudited", 181 | "MessageEmbed", 182 | "MessageEmbedField", 183 | "MessageEmbedThumbnail", 184 | "MessageKeyboard", 185 | "MessageMarkdown", 186 | "MessageMarkdownParams", 187 | "MessageReference", 188 | "Permission", 189 | "RenderData", 190 | ] 191 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/message.py: -------------------------------------------------------------------------------- 1 | import re 2 | from io import BytesIO 3 | from pathlib import Path 4 | from typing_extensions import override 5 | from typing import Type, Union, Iterable, Optional, overload 6 | 7 | from nonebot.adapters import Message as BaseMessage 8 | from nonebot.adapters import MessageSegment as BaseMessageSegment 9 | 10 | from .utils import escape, unescape 11 | from .api import Message as GuildMessage 12 | from .api import MessageArk, MessageEmbed, MessageReference 13 | 14 | 15 | class MessageSegment(BaseMessageSegment["Message"]): 16 | @classmethod 17 | @override 18 | def get_message_class(cls) -> Type["Message"]: 19 | return Message 20 | 21 | @staticmethod 22 | def ark(ark: MessageArk) -> "Ark": 23 | return Ark("ark", data={"ark": ark}) 24 | 25 | @staticmethod 26 | def embed(embed: MessageEmbed) -> "Embed": 27 | return Embed("embed", data={"embed": embed}) 28 | 29 | @staticmethod 30 | def emoji(id: str) -> "Emoji": 31 | return Emoji("emoji", data={"id": id}) 32 | 33 | @staticmethod 34 | def image(url: str) -> "Attachment": 35 | return Attachment("attachment", data={"url": url}) 36 | 37 | @staticmethod 38 | def file_image(data: Union[bytes, BytesIO, Path]) -> "LocalImage": 39 | if isinstance(data, BytesIO): 40 | data = data.getvalue() 41 | elif isinstance(data, Path): 42 | data = data.read_bytes() 43 | return LocalImage("file_image", data={"content": data}) 44 | 45 | @staticmethod 46 | def mention_user(user_id: int) -> "MentionUser": 47 | return MentionUser("mention_user", {"user_id": str(user_id)}) 48 | 49 | @staticmethod 50 | def mention_channel(channel_id: int) -> "MentionChannel": 51 | return MentionChannel("mention_channel", {"channel_id": str(channel_id)}) 52 | 53 | @staticmethod 54 | def mention_everyone() -> "MentionEveryone": 55 | return MentionEveryone("mention_everyone", {}) 56 | 57 | @overload 58 | @staticmethod 59 | def reference(reference: MessageReference) -> "Reference": ... 60 | 61 | @overload 62 | @staticmethod 63 | def reference( 64 | reference: str, ignore_error: Optional[bool] = None 65 | ) -> "Reference": ... 66 | 67 | @staticmethod 68 | def reference( 69 | reference: Union[str, MessageReference], ignore_error: Optional[bool] = None 70 | ) -> "Reference": 71 | if isinstance(reference, MessageReference): 72 | return Reference("reference", data={"reference": reference}) 73 | 74 | return Reference( 75 | "reference", 76 | data={ 77 | "reference": MessageReference( 78 | message_id=reference, ignore_get_message_error=ignore_error 79 | ) 80 | }, 81 | ) 82 | 83 | @staticmethod 84 | def text(content: str) -> "Text": 85 | return Text("text", {"text": content}) 86 | 87 | @override 88 | def is_text(self) -> bool: 89 | return self.type == "text" 90 | 91 | 92 | class Text(MessageSegment): 93 | @override 94 | def __str__(self) -> str: 95 | return escape(self.data["text"]) 96 | 97 | 98 | class Emoji(MessageSegment): 99 | @override 100 | def __str__(self) -> str: 101 | return f"" 102 | 103 | 104 | class MentionUser(MessageSegment): 105 | @override 106 | def __str__(self) -> str: 107 | return f"<@{self.data['user_id']}>" 108 | 109 | 110 | class MentionEveryone(MessageSegment): 111 | @override 112 | def __str__(self) -> str: 113 | return "@everyone" 114 | 115 | 116 | class MentionChannel(MessageSegment): 117 | @override 118 | def __str__(self) -> str: 119 | return f"<#{self.data['channel_id']}>" 120 | 121 | 122 | class Attachment(MessageSegment): 123 | @override 124 | def __str__(self) -> str: 125 | return f"" 126 | 127 | 128 | class Embed(MessageSegment): 129 | @override 130 | def __str__(self) -> str: 131 | return f"" 132 | 133 | 134 | class Ark(MessageSegment): 135 | @override 136 | def __str__(self) -> str: 137 | return f"" 138 | 139 | 140 | class LocalImage(MessageSegment): 141 | @override 142 | def __str__(self) -> str: 143 | return "" 144 | 145 | 146 | class Reference(MessageSegment): 147 | @override 148 | def __str__(self) -> str: 149 | return "" 150 | 151 | 152 | class Message(BaseMessage[MessageSegment]): 153 | @classmethod 154 | @override 155 | def get_segment_class(cls) -> Type[MessageSegment]: 156 | return MessageSegment 157 | 158 | @override 159 | def __add__( 160 | self, other: Union[str, MessageSegment, Iterable[MessageSegment]] 161 | ) -> "Message": 162 | return super().__add__( 163 | MessageSegment.text(other) if isinstance(other, str) else other 164 | ) 165 | 166 | @override 167 | def __radd__( 168 | self, other: Union[str, MessageSegment, Iterable[MessageSegment]] 169 | ) -> "Message": 170 | return super().__radd__( 171 | MessageSegment.text(other) if isinstance(other, str) else other 172 | ) 173 | 174 | @staticmethod 175 | @override 176 | def _construct(msg: str) -> Iterable[MessageSegment]: 177 | text_begin = 0 178 | for embed in re.finditer( 179 | r"\<(?P(?:@|#|emoji:))!?(?P\w+?)\>", 180 | msg, 181 | ): 182 | content = msg[text_begin : embed.pos + embed.start()] 183 | if content: 184 | yield Text("text", {"text": unescape(content)}) 185 | text_begin = embed.pos + embed.end() 186 | if embed.group("type") == "@": 187 | yield MentionUser("mention_user", {"user_id": embed.group("id")}) 188 | elif embed.group("type") == "#": 189 | yield MentionChannel( 190 | "mention_channel", {"channel_id": embed.group("id")} 191 | ) 192 | else: 193 | yield Emoji("emoji", {"id": embed.group("id")}) 194 | content = msg[text_begin:] 195 | if content: 196 | yield Text("text", {"text": unescape(msg[text_begin:])}) 197 | 198 | @classmethod 199 | def from_guild_message(cls, message: GuildMessage) -> "Message": 200 | msg = Message() 201 | if message.mention_everyone: 202 | msg.append(MessageSegment.mention_everyone()) 203 | if message.content: 204 | msg.extend(Message(message.content)) 205 | if message.attachments: 206 | msg.extend( 207 | Attachment("attachment", data={"url": seg.url}) 208 | for seg in message.attachments 209 | if seg.url 210 | ) 211 | if message.embeds: 212 | msg.extend(Embed("embed", data={"embed": seg}) for seg in message.embeds) 213 | if message.ark: 214 | msg.append(Ark("ark", data={"ark": message.ark})) 215 | return msg 216 | 217 | def extract_content(self) -> str: 218 | return "".join( 219 | str(seg) 220 | for seg in self 221 | if seg.type 222 | in ("text", "emoji", "mention_user", "mention_everyone", "mention_channel") 223 | ) 224 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env.* 2 | example 3 | 4 | # Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,macos,windows,linux 5 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode,macos,windows,linux 6 | 7 | ### Linux ### 8 | *~ 9 | 10 | # temporary files which can be created if a process still has a handle open of a deleted file 11 | .fuse_hidden* 12 | 13 | # KDE directory preferences 14 | .directory 15 | 16 | # Linux trash folder which might appear on any partition or disk 17 | .Trash-* 18 | 19 | # .nfs files are created when an open file is removed but is still being accessed 20 | .nfs* 21 | 22 | ### macOS ### 23 | # General 24 | .DS_Store 25 | .AppleDouble 26 | .LSOverride 27 | 28 | # Icon must end with two \r 29 | Icon 30 | 31 | # Thumbnails 32 | ._* 33 | 34 | # Files that might appear in the root of a volume 35 | .DocumentRevisions-V100 36 | .fseventsd 37 | .Spotlight-V100 38 | .TemporaryItems 39 | .Trashes 40 | .VolumeIcon.icns 41 | .com.apple.timemachine.donotpresent 42 | 43 | # Directories potentially created on remote AFP share 44 | .AppleDB 45 | .AppleDesktop 46 | Network Trash Folder 47 | Temporary Items 48 | .apdisk 49 | 50 | ### Python ### 51 | # Byte-compiled / optimized / DLL files 52 | __pycache__/ 53 | *.py[cod] 54 | *$py.class 55 | 56 | # C extensions 57 | *.so 58 | 59 | # Distribution / packaging 60 | .Python 61 | build/ 62 | develop-eggs/ 63 | dist/ 64 | downloads/ 65 | eggs/ 66 | .eggs/ 67 | lib/ 68 | lib64/ 69 | parts/ 70 | sdist/ 71 | var/ 72 | wheels/ 73 | share/python-wheels/ 74 | *.egg-info/ 75 | .installed.cfg 76 | *.egg 77 | MANIFEST 78 | 79 | # PyInstaller 80 | # Usually these files are written by a python script from a template 81 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 82 | *.manifest 83 | *.spec 84 | 85 | # Installer logs 86 | pip-log.txt 87 | pip-delete-this-directory.txt 88 | 89 | # Unit test / coverage reports 90 | htmlcov/ 91 | .tox/ 92 | .nox/ 93 | .coverage 94 | .coverage.* 95 | .cache 96 | nosetests.xml 97 | coverage.xml 98 | *.cover 99 | *.py,cover 100 | .hypothesis/ 101 | .pytest_cache/ 102 | cover/ 103 | 104 | # Translations 105 | *.mo 106 | *.pot 107 | 108 | # Django stuff: 109 | *.log 110 | local_settings.py 111 | db.sqlite3 112 | db.sqlite3-journal 113 | 114 | # Flask stuff: 115 | instance/ 116 | .webassets-cache 117 | 118 | # Scrapy stuff: 119 | .scrapy 120 | 121 | # Sphinx documentation 122 | docs/_build/ 123 | 124 | # PyBuilder 125 | .pybuilder/ 126 | target/ 127 | 128 | # Jupyter Notebook 129 | .ipynb_checkpoints 130 | 131 | # IPython 132 | profile_default/ 133 | ipython_config.py 134 | 135 | # pyenv 136 | # For a library or package, you might want to ignore these files since the code is 137 | # intended to run in multiple environments; otherwise, check them in: 138 | # .python-version 139 | 140 | # pipenv 141 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 142 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 143 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 144 | # install all needed dependencies. 145 | #Pipfile.lock 146 | 147 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 148 | __pypackages__/ 149 | 150 | # Celery stuff 151 | celerybeat-schedule 152 | celerybeat.pid 153 | 154 | # SageMath parsed files 155 | *.sage.py 156 | 157 | # Environments 158 | .env 159 | .venv 160 | env/ 161 | venv/ 162 | ENV/ 163 | env.bak/ 164 | venv.bak/ 165 | 166 | # Spyder project settings 167 | .spyderproject 168 | .spyproject 169 | 170 | # Rope project settings 171 | .ropeproject 172 | 173 | # mkdocs documentation 174 | /site 175 | 176 | # mypy 177 | .mypy_cache/ 178 | .dmypy.json 179 | dmypy.json 180 | 181 | # Pyre type checker 182 | .pyre/ 183 | 184 | # pytype static type analyzer 185 | .pytype/ 186 | 187 | # Cython debug symbols 188 | cython_debug/ 189 | 190 | ### VisualStudioCode ### 191 | .vscode/* 192 | # !.vscode/settings.json 193 | # !.vscode/tasks.json 194 | # !.vscode/launch.json 195 | # !.vscode/extensions.json 196 | *.code-workspace 197 | 198 | # Local History for Visual Studio Code 199 | .history/ 200 | 201 | ### VisualStudioCode Patch ### 202 | # Ignore all local history of files 203 | .history 204 | .ionide 205 | 206 | # Support for Project snippet scope 207 | !.vscode/*.code-snippets 208 | 209 | ### Windows ### 210 | # Windows thumbnail cache files 211 | Thumbs.db 212 | Thumbs.db:encryptable 213 | ehthumbs.db 214 | ehthumbs_vista.db 215 | 216 | # Dump file 217 | *.stackdump 218 | 219 | # Folder config file 220 | [Dd]esktop.ini 221 | 222 | # Recycle Bin used on file shares 223 | $RECYCLE.BIN/ 224 | 225 | # Windows Installer files 226 | *.cab 227 | *.msi 228 | *.msix 229 | *.msm 230 | *.msp 231 | 232 | # Windows shortcuts 233 | *.lnk 234 | 235 | ### Node ### 236 | # Logs 237 | logs 238 | *.log 239 | npm-debug.log* 240 | yarn-debug.log* 241 | yarn-error.log* 242 | lerna-debug.log* 243 | .pnpm-debug.log* 244 | 245 | # Diagnostic reports (https://nodejs.org/api/report.html) 246 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 247 | 248 | # Runtime data 249 | pids 250 | *.pid 251 | *.seed 252 | *.pid.lock 253 | 254 | # Directory for instrumented libs generated by jscoverage/JSCover 255 | lib-cov 256 | 257 | # Coverage directory used by tools like istanbul 258 | coverage 259 | *.lcov 260 | 261 | # nyc test coverage 262 | .nyc_output 263 | 264 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 265 | .grunt 266 | 267 | # Bower dependency directory (https://bower.io/) 268 | bower_components 269 | 270 | # node-waf configuration 271 | .lock-wscript 272 | 273 | # Compiled binary addons (https://nodejs.org/api/addons.html) 274 | build/Release 275 | 276 | # Dependency directories 277 | node_modules/ 278 | jspm_packages/ 279 | 280 | # Snowpack dependency directory (https://snowpack.dev/) 281 | web_modules/ 282 | 283 | # TypeScript cache 284 | *.tsbuildinfo 285 | 286 | # Optional npm cache directory 287 | .npm 288 | 289 | # Optional eslint cache 290 | .eslintcache 291 | 292 | # Microbundle cache 293 | .rpt2_cache/ 294 | .rts2_cache_cjs/ 295 | .rts2_cache_es/ 296 | .rts2_cache_umd/ 297 | 298 | # Optional REPL history 299 | .node_repl_history 300 | 301 | # Output of 'npm pack' 302 | *.tgz 303 | 304 | # Yarn Integrity file 305 | .yarn-integrity 306 | 307 | # dotenv environment variables file 308 | .env 309 | .env.test 310 | .env.production 311 | 312 | # parcel-bundler cache (https://parceljs.org/) 313 | .cache 314 | .parcel-cache 315 | 316 | # Next.js build output 317 | .next 318 | out 319 | 320 | # Nuxt.js build / generate output 321 | .nuxt 322 | dist 323 | 324 | # Gatsby files 325 | .cache/ 326 | # Comment in the public line in if your project uses Gatsby and not Next.js 327 | # https://nextjs.org/blog/next-9-1#public-directory-support 328 | # public 329 | 330 | # vuepress build output 331 | .vuepress/dist 332 | 333 | # Serverless directories 334 | .serverless/ 335 | 336 | # FuseBox cache 337 | .fusebox/ 338 | 339 | # DynamoDB Local files 340 | .dynamodb/ 341 | 342 | # TernJS port file 343 | .tern-port 344 | 345 | # Stores VSCode versions used for testing VSCode extensions 346 | .vscode-test 347 | 348 | # yarn v2 349 | .yarn/cache 350 | .yarn/unplugged 351 | .yarn/build-state.yml 352 | .yarn/install-state.gz 353 | .pnp.* 354 | 355 | ### Node Patch ### 356 | # Serverless Webpack directories 357 | .webpack/ 358 | 359 | # Optional stylelint cache 360 | .stylelintcache 361 | 362 | # SvelteKit build / generate output 363 | .svelte-kit 364 | 365 | # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode,macos,windows,linux,node 366 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/bot.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import override 2 | from typing import TYPE_CHECKING, Any, Dict, Union, Optional 3 | 4 | from nonebot.message import handle_event 5 | 6 | from nonebot.adapters import Bot as BaseBot 7 | 8 | from .utils import log 9 | from .config import BotInfo 10 | from .api import User, ApiClient 11 | from .message import Message, MessageSegment 12 | from .event import Event, MessageEvent, DirectMessageCreateEvent 13 | 14 | if TYPE_CHECKING: 15 | from .adapter import Adapter 16 | 17 | 18 | async def _check_reply(bot: "Bot", event: MessageEvent) -> None: 19 | """检查消息中存在的回复,赋值 `event.reply`, `event.to_me`。 20 | 21 | 参数: 22 | bot: Bot 对象 23 | event: MessageEvent 对象 24 | """ 25 | if event.message_reference is None: 26 | return 27 | try: 28 | event.reply = await bot.get_message_of_id( 29 | channel_id=event.channel_id, # type: ignore 30 | message_id=event.message_reference.message_id, # type: ignore 31 | ) 32 | if event.reply.message.author.id == bot.self_info.id: # type: ignore 33 | event.to_me = True 34 | except Exception as e: 35 | log("WARNING", f"Error when getting message reply info: {repr(e)}", e) 36 | 37 | 38 | def _check_at_me(bot: "Bot", event: MessageEvent): 39 | if event.mentions is not None and bot.self_info.id in [ 40 | user.id for user in event.mentions 41 | ]: 42 | event.to_me = True 43 | 44 | def _is_at_me_seg(segment: MessageSegment) -> bool: 45 | return segment.type == "mention_user" and segment.data.get("user_id") == str( 46 | bot.self_info.id 47 | ) 48 | 49 | message = event.get_message() 50 | 51 | # ensure message is not empty 52 | if not message: 53 | message.append(MessageSegment.text("")) 54 | 55 | deleted = False 56 | if _is_at_me_seg(message[0]): 57 | message.pop(0) 58 | deleted = True 59 | if message and message[0].type == "text": 60 | message[0].data["text"] = message[0].data["text"].lstrip("\xa0").lstrip() 61 | if not message[0].data["text"]: 62 | del message[0] 63 | 64 | if not deleted: 65 | # check the last segment 66 | i = -1 67 | last_msg_seg = message[i] 68 | if ( 69 | last_msg_seg.type == "text" 70 | and not last_msg_seg.data["text"].strip() 71 | and len(message) >= 2 72 | ): 73 | i -= 1 74 | last_msg_seg = message[i] 75 | 76 | if _is_at_me_seg(last_msg_seg): 77 | deleted = True 78 | del message[i:] 79 | 80 | if not message: 81 | message.append(MessageSegment.text("")) 82 | 83 | 84 | class Bot(BaseBot, ApiClient): 85 | @override 86 | def __init__(self, adapter: "Adapter", self_id: str, bot_info: BotInfo): 87 | super().__init__(adapter, self_id) 88 | self.bot_info: BotInfo = bot_info 89 | self._session_id: Optional[str] = None 90 | self._self_info: Optional[User] = None 91 | self._sequence: Optional[int] = None 92 | 93 | @property 94 | def ready(self) -> bool: 95 | return self._session_id is not None 96 | 97 | @property 98 | def session_id(self) -> str: 99 | if self._session_id is None: 100 | raise RuntimeError(f"Bot {self.self_id} is not connected!") 101 | return self._session_id 102 | 103 | @session_id.setter 104 | def session_id(self, session_id: str) -> None: 105 | self._session_id = session_id 106 | 107 | @property 108 | def self_info(self) -> User: 109 | if self._self_info is None: 110 | raise RuntimeError(f"Bot {self.bot_info} is not connected!") 111 | return self._self_info 112 | 113 | @self_info.setter 114 | def self_info(self, self_info: User) -> None: 115 | self._self_info = self_info 116 | 117 | @property 118 | def has_sequence(self) -> bool: 119 | return self._sequence is not None 120 | 121 | @property 122 | def sequence(self) -> int: 123 | if self._sequence is None: 124 | raise RuntimeError(f"Bot {self.bot_info.id} is not connected!") 125 | return self._sequence 126 | 127 | @sequence.setter 128 | def sequence(self, sequence: int) -> None: 129 | self._sequence = sequence 130 | 131 | def clear(self) -> None: 132 | self._session_id = None 133 | self._sequence = None 134 | 135 | async def handle_event(self, event: Event) -> None: 136 | if isinstance(event, MessageEvent): 137 | await _check_reply(self, event) 138 | _check_at_me(self, event) 139 | await handle_event(self, event) 140 | 141 | @staticmethod 142 | def _extract_send_message( 143 | message: Union[str, Message, MessageSegment], 144 | ) -> Dict[str, Any]: 145 | message = MessageSegment.text(message) if isinstance(message, str) else message 146 | message = message if isinstance(message, Message) else Message(message) 147 | 148 | kwargs = {} 149 | content = message.extract_content() or None 150 | kwargs["content"] = content 151 | if embed := (message["embed"] or None): 152 | kwargs["embed"] = embed[-1].data["embed"] 153 | if ark := (message["ark"] or None): 154 | kwargs["ark"] = ark[-1].data["ark"] 155 | if image := (message["attachment"] or None): 156 | kwargs["image"] = image[-1].data["url"] 157 | if file_image := (message["file_image"] or None): 158 | kwargs["file_image"] = file_image[-1].data["content"] 159 | if markdown := (message["markdown"] or None): 160 | kwargs["markdown"] = markdown[-1].data["markdown"] 161 | if reference := (message["reference"] or None): 162 | kwargs["message_reference"] = reference[-1].data["reference"] 163 | 164 | return kwargs 165 | 166 | async def send_to_dms( 167 | self, 168 | guild_id: int, 169 | message: Union[str, Message, MessageSegment], 170 | msg_id: Optional[int] = None, 171 | ) -> Any: 172 | return await self.post_dms_messages( 173 | guild_id=guild_id, 174 | msg_id=msg_id, # type: ignore 175 | **self._extract_send_message(message=message), 176 | ) 177 | 178 | async def send_to( 179 | self, 180 | channel_id: int, 181 | message: Union[str, Message, MessageSegment], 182 | msg_id: Optional[int] = None, 183 | ) -> Any: 184 | return await self.post_messages( 185 | channel_id=channel_id, 186 | msg_id=msg_id, # type: ignore 187 | **self._extract_send_message(message=message), 188 | ) 189 | 190 | @override 191 | async def send( 192 | self, 193 | event: Event, 194 | message: Union[str, Message, MessageSegment], 195 | **kwargs, 196 | ) -> Any: 197 | if not isinstance(event, MessageEvent) or not event.channel_id or not event.id: 198 | raise RuntimeError("Event cannot be replied to!") 199 | 200 | if isinstance(event, DirectMessageCreateEvent): 201 | # 私信需要使用 post_dms_messages 202 | # https://bot.q.qq.com/wiki/develop/api/openapi/dms/post_dms_messages.html#%E5%8F%91%E9%80%81%E7%A7%81%E4%BF%A1 203 | return await self.send_to_dms( 204 | guild_id=event.guild_id, # type: ignore 205 | message=message, 206 | msg_id=event.id, # type: ignore 207 | ) 208 | else: 209 | return await self.send_to( 210 | channel_id=event.channel_id, 211 | message=message, 212 | msg_id=event.id, # type: ignore 213 | ) 214 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/api/client.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List, Union, Literal, Optional 2 | 3 | from .model import * 4 | 5 | if TYPE_CHECKING: 6 | 7 | class ApiClient: 8 | async def get_guild(self, *, guild_id: int) -> Guild: ... 9 | 10 | async def me(self) -> User: ... 11 | 12 | async def guilds( 13 | self, 14 | *, 15 | before: Optional[str] = ..., 16 | after: Optional[str] = ..., 17 | limit: Optional[float] = ..., 18 | ) -> List[Guild]: ... 19 | 20 | async def get_channels(self, *, guild_id: int) -> List[Channel]: ... 21 | 22 | async def post_channels( 23 | self, 24 | *, 25 | guild_id: int, 26 | name: str = ..., 27 | type: int = ..., 28 | sub_type: int = ..., 29 | position: Optional[int] = ..., 30 | parent_id: Optional[int] = ..., 31 | private_type: Optional[int] = ..., 32 | private_user_ids: Optional[List[int]] = ..., 33 | ) -> List[Channel]: ... 34 | 35 | async def get_channel(self, *, channel_id: int) -> Channel: ... 36 | 37 | async def patch_channel( 38 | self, 39 | *, 40 | channel_id: int, 41 | name: Optional[str] = ..., 42 | type: Optional[int] = ..., 43 | sub_type: Optional[int] = ..., 44 | position: Optional[int] = ..., 45 | parent_id: Optional[int] = ..., 46 | private_type: Optional[int] = ..., 47 | ) -> Channel: ... 48 | 49 | async def delete_channel(self, *, channel_id: int) -> None: ... 50 | 51 | async def get_members( 52 | self, 53 | *, 54 | guild_id: int, 55 | after: Optional[str] = ..., 56 | limit: Optional[float] = ..., 57 | ) -> List[Member]: ... 58 | 59 | async def get_member(self, *, guild_id: int, user_id: int) -> Member: ... 60 | 61 | async def delete_member( 62 | self, 63 | *, 64 | guild_id: int, 65 | user_id: int, 66 | add_blacklist: Optional[bool] = ..., 67 | delete_history_msg_days: Optional[Literal[-1, 0, 3, 7, 15, 30]] = ..., 68 | ) -> None: ... 69 | 70 | async def get_guild_roles(self, *, guild_id: int) -> GetGuildRolesReturn: ... 71 | 72 | async def post_guild_role( 73 | self, 74 | *, 75 | guild_id: int, 76 | name: str = ..., 77 | color: Optional[float] = ..., 78 | hoist: Optional[float] = ..., 79 | ) -> PostGuildRoleReturn: ... 80 | 81 | async def patch_guild_role( 82 | self, 83 | *, 84 | guild_id: int, 85 | role_id: int, 86 | name: Optional[str] = ..., 87 | color: Optional[float] = ..., 88 | hoist: Optional[float] = ..., 89 | ) -> PatchGuildRoleReturn: ... 90 | 91 | async def delete_guild_role(self, *, guild_id: int, role_id: int) -> None: ... 92 | 93 | async def put_guild_member_role( 94 | self, *, guild_id: int, role_id: int, user_id: int, id: Optional[str] = ... 95 | ) -> None: ... 96 | 97 | async def delete_guild_member_role( 98 | self, *, guild_id: int, role_id: int, user_id: int, id: Optional[str] = ... 99 | ) -> None: ... 100 | 101 | async def get_channel_permissions( 102 | self, *, channel_id: int, user_id: int 103 | ) -> ChannelPermissions: ... 104 | 105 | async def put_channel_permissions( 106 | self, 107 | *, 108 | channel_id: int, 109 | user_id: int, 110 | add: Optional[str] = ..., 111 | remove: Optional[str] = ..., 112 | ) -> None: ... 113 | 114 | async def get_channel_roles_permissions( 115 | self, *, channel_id: int, role_id: int 116 | ) -> ChannelPermissions: ... 117 | 118 | async def put_channel_roles_permissions( 119 | self, 120 | *, 121 | channel_id: int, 122 | role_id: int, 123 | add: Optional[str] = ..., 124 | remove: Optional[str] = ..., 125 | ) -> None: ... 126 | 127 | async def get_message_of_id( 128 | self, *, channel_id: int, message_id: str 129 | ) -> MessageGet: ... 130 | 131 | async def delete_message( 132 | self, 133 | *, 134 | channel_id: int, 135 | message_id: str, 136 | hidetip: bool = False, 137 | ) -> None: ... 138 | 139 | async def post_messages( 140 | self, 141 | *, 142 | channel_id: int, 143 | content: Optional[str] = ..., 144 | embed: Optional[MessageEmbed] = ..., 145 | ark: Optional[MessageArk] = ..., 146 | message_reference: Optional[MessageReference] = ..., 147 | image: Optional[str] = ..., 148 | file_image: Optional[bytes] = ..., 149 | markdown: Optional[MessageMarkdown] = ..., 150 | msg_id: Optional[str] = ..., 151 | ) -> Message: ... 152 | 153 | async def post_dms( 154 | self, *, recipient_id: str = ..., source_guild_id: str = ... 155 | ) -> DMS: ... 156 | 157 | async def post_dms_messages( 158 | self, 159 | *, 160 | guild_id: int, 161 | content: Optional[str] = ..., 162 | embed: Optional[MessageEmbed] = ..., 163 | ark: Optional[MessageArk] = ..., 164 | message_reference: Optional[MessageReference] = ..., 165 | image: Optional[str] = ..., 166 | file_image: Optional[bytes] = ..., 167 | markdown: Optional[MessageMarkdown] = ..., 168 | msg_id: Optional[str] = ..., 169 | ) -> Message: ... 170 | 171 | async def patch_guild_mute( 172 | self, 173 | *, 174 | guild_id: int, 175 | mute_end_timestamp: Optional[str] = ..., 176 | mute_seconds: Optional[str] = ..., 177 | ) -> None: ... 178 | 179 | async def patch_guild_member_mute( 180 | self, 181 | *, 182 | guild_id: int, 183 | user_id: int, 184 | mute_end_timestamp: Optional[str] = ..., 185 | mute_seconds: Optional[str] = ..., 186 | ) -> None: ... 187 | 188 | async def post_guild_announces( 189 | self, *, guild_id: int, message_id: str = ..., channel_id: str = ... 190 | ) -> None: ... 191 | 192 | async def delete_guild_announces( 193 | self, *, guild_id: int, message_id: str 194 | ) -> None: ... 195 | 196 | async def post_channel_announces( 197 | self, *, channel_id: int, message_id: str = ... 198 | ) -> Announces: ... 199 | 200 | async def delete_channel_announces( 201 | self, *, channel_id: int, message_id: str 202 | ) -> None: ... 203 | 204 | async def get_schedules( 205 | self, *, channel_id: int, since: Optional[int] = ... 206 | ) -> List[Schedule]: ... 207 | 208 | async def post_schedule( 209 | self, 210 | *, 211 | channel_id: int, 212 | name: str = ..., 213 | description: Optional[str] = ..., 214 | start_timestamp: int = ..., 215 | end_timestamp: int = ..., 216 | creator: Optional[Member] = ..., 217 | jump_channel_id: Optional[int] = ..., 218 | remind_type: str = ..., 219 | ) -> Schedule: ... 220 | 221 | async def get_schedule( 222 | self, *, channel_id: int, schedule_id: int 223 | ) -> Schedule: ... 224 | 225 | async def patch_schedule( 226 | self, 227 | *, 228 | channel_id: int, 229 | schedule_id: int, 230 | name: Optional[str] = ..., 231 | description: Optional[str] = ..., 232 | start_timestamp: Optional[int] = ..., 233 | end_timestamp: Optional[int] = ..., 234 | creator: Optional[Member] = ..., 235 | jump_channel_id: Optional[int] = ..., 236 | remind_type: Optional[str] = ..., 237 | ) -> Schedule: ... 238 | 239 | async def delete_schedule( 240 | self, *, channel_id: int, schedule_id: int 241 | ) -> None: ... 242 | 243 | async def audio_control( 244 | self, 245 | *, 246 | channel_id: int, 247 | audio_url: Optional[str] = ..., 248 | text: Optional[str] = ..., 249 | status: Optional[int] = ..., 250 | ) -> None: ... 251 | 252 | async def get_guild_api_permission( 253 | self, *, guild_id: int 254 | ) -> List[APIPermission]: ... 255 | 256 | async def post_api_permission_demand( 257 | self, 258 | *, 259 | guild_id: int, 260 | channel_id: Optional[str] = ..., 261 | api_identify: Optional[APIPermissionDemandIdentify] = ..., 262 | desc: Optional[str] = ..., 263 | ) -> List[APIPermissionDemand]: ... 264 | 265 | async def url_get(self) -> UrlGetReturn: ... 266 | 267 | async def shard_url_get(self) -> ShardUrlGetReturn: ... 268 | 269 | async def put_message_reaction( 270 | self, *, channel_id: int, message_id: str, type: int, id: str 271 | ) -> None: ... 272 | 273 | async def delete_own_message_reaction( 274 | self, *, channel_id: int, message_id: str, type: int, id: str 275 | ) -> None: ... 276 | 277 | async def put_pins_message( 278 | self, *, channel_id: int, message_id: str 279 | ) -> None: ... 280 | 281 | async def delete_pins_message( 282 | self, *, channel_id: int, message_id: str 283 | ) -> None: ... 284 | 285 | async def get_pins_message(self, *, channel_id: int) -> PinsMessage: ... 286 | 287 | async def get_threads_list( 288 | self, *, channel_id: int 289 | ) -> GetThreadsListReturn: ... 290 | 291 | async def get_thread( 292 | self, *, channel_id: int, thread_id: str 293 | ) -> GetThreadReturn: ... 294 | 295 | async def put_thread( 296 | self, 297 | *, 298 | channel_id: int, 299 | title: str, 300 | content: Union[str, RichText], 301 | format: PutThreadFormat, 302 | ) -> PutThreadReturn: ... 303 | 304 | async def delete_thread(self, *, channel_id: int, thread_id: str) -> None: ... 305 | 306 | else: 307 | 308 | class ApiClient: ... 309 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/event.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing_extensions import override 3 | from typing import Dict, Type, Tuple, Optional 4 | 5 | from nonebot.utils import escape_tag 6 | 7 | from nonebot.adapters import Event as BaseEvent 8 | 9 | from .message import Message 10 | from .api import Message as GuildMessage 11 | from .api import ( 12 | User, 13 | Guild, 14 | Member, 15 | Channel, 16 | ) 17 | from .api import ( 18 | RichText, 19 | ForumPost, 20 | ForumReply, 21 | MessageGet, 22 | ForumObject, 23 | ForumThread, 24 | MessageDelete, 25 | MessageAudited, 26 | MessageReaction, 27 | ForumAuditResult, 28 | ) 29 | 30 | 31 | class EventType(str, Enum): 32 | # Init Event 33 | READY = "READY" 34 | RESUMED = "RESUMED" 35 | 36 | # GUILDS 37 | GUILD_CREATE = "GUILD_CREATE" 38 | GUILD_UPDATE = "GUILD_UPDATE" 39 | GUILD_DELETE = "GUILD_DELETE" 40 | CHANNEL_CREATE = "CHANNEL_CREATE" 41 | CHANNEL_UPDATE = "CHANNEL_UPDATE" 42 | CHANNEL_DELETE = "CHANNEL_DELETE" 43 | 44 | # GUILD_MEMBERS 45 | GUILD_MEMBER_ADD = "GUILD_MEMBER_ADD" 46 | GUILD_MEMBER_UPDATE = "GUILD_MEMBER_UPDATE" 47 | GUILD_MEMBER_REMOVE = "GUILD_MEMBER_REMOVE" 48 | 49 | # GUILD_MESSAGES 50 | MESSAGE_CREATE = "MESSAGE_CREATE" 51 | MESSAGE_DELETE = "MESSAGE_DELETE" 52 | 53 | # GUILD_MESSAGE_REACTIONS 54 | MESSAGE_REACTION_ADD = "MESSAGE_REACTION_ADD" 55 | MESSAGE_REACTION_REMOVE = "MESSAGE_REACTION_REMOVE" 56 | 57 | # DIRECT_MESSAGE 58 | DIRECT_MESSAGE_CREATE = "DIRECT_MESSAGE_CREATE" 59 | DIRECT_MESSAGE_DELETE = "DIRECT_MESSAGE_DELETE" 60 | 61 | # MESSAGE_AUDIT 62 | MESSAGE_AUDIT_PASS = "MESSAGE_AUDIT_PASS" 63 | MESSAGE_AUDIT_REJECT = "MESSAGE_AUDIT_REJECT" 64 | 65 | # FORUM_EVENT 66 | FORUM_THREAD_CREATE = "FORUM_THREAD_CREATE" 67 | FORUM_THREAD_UPDATE = "FORUM_THREAD_UPDATE" 68 | FORUM_THREAD_DELETE = "FORUM_THREAD_DELETE" 69 | FORUM_POST_CREATE = "FORUM_POST_CREATE" 70 | FORUM_POST_DELETE = "FORUM_POST_DELETE" 71 | FORUM_REPLY_CREATE = "FORUM_REPLY_CREATE" 72 | FORUM_REPLY_DELETE = "FORUM_REPLY_DELETE" 73 | FORUM_PUBLISH_AUDIT_RESULT = "FORUM_PUBLISH_AUDIT_RESULT" 74 | 75 | # AUDIO_ACTION 76 | AUDIO_START = "AUDIO_START" 77 | AUDIO_FINISH = "AUDIO_FINISH" 78 | AUDIO_ON_MIC = "AUDIO_ON_MIC" 79 | AUDIO_OFF_MIC = "AUDIO_OFF_MIC" 80 | 81 | # AT_MESSAGES 82 | AT_MESSAGE_CREATE = "AT_MESSAGE_CREATE" 83 | PUBLIC_MESSAGE_DELETE = "PUBLIC_MESSAGE_DELETE" 84 | 85 | 86 | class Event(BaseEvent): 87 | __type__: EventType 88 | 89 | @override 90 | def get_event_name(self) -> str: 91 | return self.__type__ 92 | 93 | @override 94 | def get_event_description(self) -> str: 95 | return escape_tag(str(self.dict())) 96 | 97 | @override 98 | def get_message(self) -> Message: 99 | raise ValueError("Event has no message!") 100 | 101 | @override 102 | def get_user_id(self) -> str: 103 | raise ValueError("Event has no context!") 104 | 105 | @override 106 | def get_session_id(self) -> str: 107 | raise ValueError("Event has no context!") 108 | 109 | @override 110 | def is_tome(self) -> bool: 111 | return False 112 | 113 | 114 | # Meta Event 115 | class MetaEvent(Event): 116 | @override 117 | def get_type(self) -> str: 118 | return "meta_event" 119 | 120 | 121 | class ReadyEvent(MetaEvent): 122 | __type__ = EventType.READY 123 | version: int 124 | session_id: str 125 | user: User 126 | shard: Tuple[int, int] 127 | 128 | 129 | class ResumedEvent(MetaEvent): 130 | __type__ = EventType.RESUMED 131 | 132 | 133 | # Guild Event 134 | class GuildEvent(Event, Guild): 135 | op_user_id: str 136 | 137 | @override 138 | def get_type(self) -> str: 139 | return "notice" 140 | 141 | 142 | class GuildCreateEvent(GuildEvent): 143 | __type__ = EventType.GUILD_CREATE 144 | 145 | 146 | class GuildUpdateEvent(GuildEvent): 147 | __type__ = EventType.GUILD_UPDATE 148 | 149 | 150 | class GuildDeleteEvent(GuildEvent): 151 | __type__ = EventType.GUILD_DELETE 152 | 153 | 154 | # Channel Event 155 | class ChannelEvent(Event, Channel): 156 | op_user_id: str 157 | 158 | @override 159 | def get_type(self) -> str: 160 | return "notice" 161 | 162 | 163 | class ChannelCreateEvent(ChannelEvent): 164 | __type__ = EventType.CHANNEL_CREATE 165 | 166 | 167 | class ChannelUpdateEvent(ChannelEvent): 168 | __type__ = EventType.CHANNEL_UPDATE 169 | 170 | 171 | class ChannelDeleteEvent(ChannelEvent): 172 | __type__ = EventType.CHANNEL_DELETE 173 | 174 | 175 | # Guild Member Event 176 | class GuildMemberEvent(Event, Member): 177 | guild_id: str 178 | op_user_id: str 179 | 180 | @override 181 | def get_type(self) -> str: 182 | return "notice" 183 | 184 | @override 185 | def get_user_id(self) -> str: 186 | return str(self.user.id) # type: ignore 187 | 188 | @override 189 | def get_event_description(self) -> str: 190 | return escape_tag( 191 | f"Notice {getattr(self.user, 'username', None)}" 192 | f"@[Guild:{self.guild_id}] Roles:{self.roles}" 193 | ) 194 | 195 | @override 196 | def get_session_id(self) -> str: 197 | return str(self.user.id) # type: ignore 198 | 199 | 200 | class GuildMemberAddEvent(GuildMemberEvent): 201 | __type__ = EventType.GUILD_MEMBER_ADD 202 | 203 | 204 | class GuildMemberUpdateEvent(GuildMemberEvent): 205 | __type__ = EventType.GUILD_MEMBER_UPDATE 206 | 207 | 208 | class GuildMemberRemoveEvent(GuildMemberEvent): 209 | __type__ = EventType.GUILD_MEMBER_REMOVE 210 | 211 | 212 | # Message Event 213 | class MessageEvent(Event, GuildMessage): 214 | to_me: bool = False 215 | 216 | reply: Optional[MessageGet] = None 217 | """ 218 | :说明: 消息中提取的回复消息,内容为 ``get_message_of_id`` API 返回结果 219 | 220 | :类型: ``Optional[MessageGet]`` 221 | """ 222 | 223 | @override 224 | def get_type(self) -> str: 225 | return "message" 226 | 227 | @override 228 | def get_user_id(self) -> str: 229 | return str(self.author.id) # type: ignore 230 | 231 | @override 232 | def get_session_id(self) -> str: 233 | return str(self.author.id) # type: ignore 234 | 235 | @override 236 | def get_event_description(self) -> str: 237 | return escape_tag( 238 | f"Message {self.id} from " 239 | f"{getattr(self.author, 'username', None)}" 240 | f"@[Guild:{self.guild_id}/{self.channel_id}] " 241 | f"Roles:{getattr(self.member, 'roles', None)}: {self.get_message()}" 242 | ) 243 | 244 | @override 245 | def get_message(self) -> Message: 246 | if not hasattr(self, "_message"): 247 | setattr(self, "_message", Message.from_guild_message(self)) 248 | return getattr(self, "_message") 249 | 250 | @override 251 | def is_tome(self) -> bool: 252 | return self.to_me 253 | 254 | 255 | class MessageCreateEvent(MessageEvent): 256 | __type__ = EventType.MESSAGE_CREATE 257 | 258 | 259 | class MessageDeleteEvent(Event, MessageDelete): 260 | __type__ = EventType.MESSAGE_DELETE 261 | 262 | @override 263 | def get_type(self) -> str: 264 | return "notice" 265 | 266 | 267 | class AtMessageCreateEvent(MessageEvent): 268 | __type__ = EventType.AT_MESSAGE_CREATE 269 | to_me: bool = True 270 | 271 | 272 | class PublicMessageDeleteEvent(MessageDeleteEvent): 273 | __type__ = EventType.PUBLIC_MESSAGE_DELETE 274 | 275 | 276 | class DirectMessageCreateEvent(MessageEvent): 277 | __type__ = EventType.DIRECT_MESSAGE_CREATE 278 | to_me: bool = True 279 | 280 | @override 281 | def get_event_description(self) -> str: 282 | return escape_tag( 283 | f"Message {self.id} from " 284 | f"{getattr(self.author, 'username', None)}: {self.get_message()}" 285 | ) 286 | 287 | 288 | class DirectMessageDeleteEvent(MessageDeleteEvent): 289 | __type__ = EventType.DIRECT_MESSAGE_DELETE 290 | 291 | 292 | # Message Audit Event 293 | class MessageAuditEvent(Event, MessageAudited): 294 | @override 295 | def get_type(self) -> str: 296 | return "notice" 297 | 298 | 299 | class MessageAuditPassEvent(MessageAuditEvent): 300 | __type__ = EventType.MESSAGE_AUDIT_PASS 301 | 302 | 303 | class MessageAuditRejectEvent(MessageAuditEvent): 304 | __type__ = EventType.MESSAGE_AUDIT_REJECT 305 | 306 | 307 | # Message Reaction Event 308 | class MessageReactionEvent(Event, MessageReaction): 309 | @override 310 | def get_type(self) -> str: 311 | return "notice" 312 | 313 | @override 314 | def get_user_id(self) -> str: 315 | return str(self.user_id) 316 | 317 | @override 318 | def get_session_id(self) -> str: 319 | return str(self.user_id) 320 | 321 | 322 | class MessageReactionAddEvent(MessageReactionEvent): 323 | __type__ = EventType.MESSAGE_REACTION_ADD 324 | 325 | 326 | class MessageReactionRemoveEvent(MessageReactionEvent): 327 | __type__ = EventType.MESSAGE_REACTION_REMOVE 328 | 329 | 330 | class ForumEvent(Event, ForumObject): 331 | @override 332 | def get_type(self) -> str: 333 | return "notice" 334 | 335 | @override 336 | def get_user_id(self) -> str: 337 | return str(self.author_id) 338 | 339 | @override 340 | def get_session_id(self) -> str: 341 | return f"forum_{self.author_id}" 342 | 343 | 344 | class ForumThreadEvent(ForumEvent, ForumThread[RichText]): ... 345 | 346 | 347 | class ForumThreadCreateEvent(ForumThreadEvent): 348 | __type__ = EventType.FORUM_THREAD_CREATE 349 | 350 | 351 | class ForumThreadUpdateEvent(ForumThreadEvent): 352 | __type__ = EventType.FORUM_THREAD_UPDATE 353 | 354 | 355 | class ForumThreadDeleteEvent(ForumThreadEvent): 356 | __type__ = EventType.FORUM_THREAD_DELETE 357 | 358 | 359 | class ForumPostEvent(ForumEvent, ForumPost): ... 360 | 361 | 362 | class ForumPostCreateEvent(ForumPostEvent): 363 | __type__ = EventType.FORUM_POST_CREATE 364 | 365 | 366 | class ForumPostDeleteEvent(ForumPostEvent): 367 | __type__ = EventType.FORUM_POST_DELETE 368 | 369 | 370 | class ForumReplyEvent(ForumEvent, ForumReply): ... 371 | 372 | 373 | class ForumReplyCreateEvent(ForumReplyEvent): 374 | __type__ = EventType.FORUM_REPLY_CREATE 375 | 376 | 377 | class ForumReplyDeleteEvent(ForumReplyEvent): 378 | __type__ = EventType.FORUM_REPLY_DELETE 379 | 380 | 381 | class ForumPublishAuditResult(ForumEvent, ForumAuditResult): 382 | __type__ = EventType.FORUM_PUBLISH_AUDIT_RESULT 383 | 384 | 385 | # TODO: Audio Event 386 | 387 | event_classes: Dict[str, Type[Event]] = { 388 | EventType.READY.value: ReadyEvent, 389 | EventType.RESUMED.value: ResumedEvent, 390 | EventType.GUILD_CREATE.value: GuildCreateEvent, 391 | EventType.GUILD_DELETE.value: GuildDeleteEvent, 392 | EventType.GUILD_UPDATE.value: GuildUpdateEvent, 393 | EventType.CHANNEL_CREATE.value: ChannelCreateEvent, 394 | EventType.CHANNEL_DELETE.value: ChannelDeleteEvent, 395 | EventType.CHANNEL_UPDATE.value: ChannelUpdateEvent, 396 | EventType.GUILD_MEMBER_ADD.value: GuildMemberAddEvent, 397 | EventType.GUILD_MEMBER_UPDATE.value: GuildMemberUpdateEvent, 398 | EventType.GUILD_MEMBER_REMOVE.value: GuildMemberRemoveEvent, 399 | EventType.MESSAGE_CREATE.value: MessageCreateEvent, 400 | EventType.MESSAGE_DELETE.value: MessageDeleteEvent, 401 | EventType.AT_MESSAGE_CREATE.value: AtMessageCreateEvent, 402 | EventType.PUBLIC_MESSAGE_DELETE.value: PublicMessageDeleteEvent, 403 | EventType.DIRECT_MESSAGE_CREATE.value: DirectMessageCreateEvent, 404 | EventType.DIRECT_MESSAGE_DELETE.value: DirectMessageDeleteEvent, 405 | EventType.MESSAGE_AUDIT_PASS.value: MessageAuditPassEvent, 406 | EventType.MESSAGE_AUDIT_REJECT.value: MessageAuditRejectEvent, 407 | EventType.MESSAGE_REACTION_ADD.value: MessageReactionAddEvent, 408 | EventType.MESSAGE_REACTION_REMOVE.value: MessageReactionRemoveEvent, 409 | EventType.FORUM_THREAD_CREATE.value: ForumThreadCreateEvent, 410 | EventType.FORUM_THREAD_UPDATE.value: ForumThreadUpdateEvent, 411 | EventType.FORUM_THREAD_DELETE.value: ForumThreadDeleteEvent, 412 | EventType.FORUM_POST_CREATE.value: ForumPostCreateEvent, 413 | EventType.FORUM_POST_DELETE.value: ForumPostDeleteEvent, 414 | EventType.FORUM_REPLY_CREATE.value: ForumReplyCreateEvent, 415 | EventType.FORUM_REPLY_DELETE.value: ForumReplyDeleteEvent, 416 | EventType.FORUM_PUBLISH_AUDIT_RESULT.value: ForumPublishAuditResult, 417 | } 418 | 419 | __all__ = [ 420 | "EventType", 421 | "Event", 422 | "GuildEvent", 423 | "GuildCreateEvent", 424 | "GuildUpdateEvent", 425 | "GuildDeleteEvent", 426 | "ChannelEvent", 427 | "ChannelCreateEvent", 428 | "ChannelUpdateEvent", 429 | "ChannelDeleteEvent", 430 | "GuildMemberEvent", 431 | "GuildMemberAddEvent", 432 | "GuildMemberUpdateEvent", 433 | "GuildMemberRemoveEvent", 434 | "MessageEvent", 435 | "MessageCreateEvent", 436 | "MessageDeleteEvent", 437 | "AtMessageCreateEvent", 438 | "PublicMessageDeleteEvent", 439 | "DirectMessageCreateEvent", 440 | "DirectMessageDeleteEvent", 441 | "MessageAuditEvent", 442 | "MessageAuditPassEvent", 443 | "MessageAuditRejectEvent", 444 | "MessageReactionEvent", 445 | "MessageReactionAddEvent", 446 | "MessageReactionRemoveEvent", 447 | ] 448 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/models/guild.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import IntEnum 3 | import json 4 | from typing import Generic, Optional, TypeVar, Union 5 | 6 | from nonebot.compat import PYDANTIC_V2, model_fields, type_validate_python 7 | from pydantic import BaseModel 8 | 9 | from nonebot.adapters.qq.compat import field_validator, model_validator 10 | 11 | from .common import MessageArk, MessageAttachment, MessageEmbed, MessageReference 12 | 13 | if PYDANTIC_V2: 14 | GenericModel = BaseModel 15 | else: 16 | from pydantic.generics import GenericModel 17 | 18 | T = TypeVar("T") 19 | 20 | 21 | # Guild 22 | class Guild(BaseModel): 23 | id: str 24 | name: str 25 | icon: str 26 | owner_id: str 27 | owner: bool 28 | member_count: int 29 | max_members: int 30 | description: str 31 | joined_at: datetime 32 | 33 | 34 | # User 35 | class User(BaseModel): 36 | id: str 37 | username: Optional[str] = None 38 | avatar: Optional[str] = None 39 | bot: Optional[bool] = None 40 | union_openid: Optional[str] = None 41 | union_user_account: Optional[str] = None 42 | 43 | 44 | # Channel 45 | class ChannelType(IntEnum): 46 | TEXT = 0 # 文字子频道 47 | # x = 1 # 保留,不可用 48 | VOICE = 2 # 语音子频道 49 | # x = 3 # 保留,不可用 50 | GROUP = 4 # 子频道分组 51 | LIVE = 10005 # 直播子频道 52 | APP = 10006 # 应用子频道 53 | DISCUSSION = 10007 # 论坛子频道 54 | 55 | 56 | class ChannelSubType(IntEnum): 57 | TALK = 0 # 闲聊 58 | POST = 1 # 公告 59 | CHEAT = 2 # 攻略 60 | BLACK = 3 # 开黑 61 | 62 | 63 | class PrivateType(IntEnum): 64 | PUBLIC = 0 # 公开频道 65 | ADMIN = 1 # 管理员和群主可见 66 | SPECIFIED_USER = 2 # 群主管理员+指定成员,可使用 修改子频道权限接口 指定成员 67 | 68 | 69 | class SpeakPermission(IntEnum): 70 | INVALID = 0 # 无效类型 71 | EVERYONE = 1 # 所有人 72 | ADMIN = 2 # 群主管理员+指定成员,可使用 修改子频道权限接口 指定成员 73 | 74 | 75 | class Channel(BaseModel): 76 | id: str 77 | guild_id: str 78 | name: str 79 | type: Union[ChannelType, int] 80 | sub_type: Union[ChannelSubType, int] 81 | position: int 82 | parent_id: Optional[str] = None 83 | owner_id: Optional[str] = None 84 | private_type: Optional[Union[PrivateType, int]] = None 85 | speak_permission: Optional[Union[SpeakPermission, int]] = None 86 | application_id: Optional[str] = None 87 | permissions: Optional[int] = None 88 | 89 | 90 | # Member 91 | class Member(BaseModel): 92 | user: Optional[User] = None 93 | nick: Optional[str] = None 94 | roles: Optional[list[str]] = None 95 | joined_at: datetime 96 | 97 | 98 | class GetRoleMembersReturn(BaseModel): 99 | data: list[Member] 100 | next: str 101 | 102 | 103 | # Role 104 | class Role(BaseModel): 105 | id: str 106 | name: str 107 | color: int 108 | hoist: bool 109 | number: int 110 | member_limit: int 111 | 112 | 113 | class GetGuildRolesReturn(BaseModel): 114 | guild_id: str 115 | roles: list[Role] 116 | role_num_limit: int 117 | 118 | 119 | class PostGuildRoleReturn(BaseModel): 120 | role_id: str 121 | role: Role 122 | 123 | 124 | class PatchGuildRoleReturn(BaseModel): 125 | guild_id: str 126 | role_id: str 127 | role: Role 128 | 129 | 130 | # Channel Permission 131 | class ChannelPermissions(BaseModel): 132 | channel_id: str 133 | user_id: Optional[str] = None 134 | role_id: Optional[str] = None 135 | permissions: int 136 | 137 | 138 | # Message 139 | class Message(BaseModel): 140 | id: str 141 | channel_id: str 142 | guild_id: str 143 | content: Optional[str] = None 144 | timestamp: Optional[datetime] = None 145 | edited_timestamp: Optional[datetime] = None 146 | mention_everyone: Optional[bool] = None 147 | author: User 148 | attachments: Optional[list[MessageAttachment]] = None 149 | embeds: Optional[list[MessageEmbed]] = None 150 | mentions: Optional[list[User]] = None 151 | member: Optional[Member] = None 152 | ark: Optional[MessageArk] = None 153 | seq: Optional[int] = None 154 | seq_in_channel: Optional[str] = None 155 | message_reference: Optional[MessageReference] = None 156 | src_guild_id: Optional[str] = None 157 | 158 | 159 | # Message Delete Event 160 | class MessageDelete(BaseModel): 161 | message: Message 162 | op_user: User 163 | 164 | 165 | # Message Setting 166 | class MessageSetting(BaseModel): 167 | disable_create_dm: bool 168 | disable_push_msg: bool 169 | channel_ids: list[str] 170 | channel_push_max_num: int 171 | 172 | 173 | # DMS 174 | class DMS(BaseModel): 175 | guild_id: Optional[str] = None 176 | channel_id: Optional[str] = None 177 | create_time: Optional[datetime] = None 178 | 179 | 180 | # Announce 181 | class RecommendChannel(BaseModel): 182 | channel_id: Optional[str] = None 183 | introduce: Optional[str] = None 184 | 185 | 186 | class Announces(BaseModel): 187 | guild_id: Optional[str] = None 188 | channel_id: Optional[str] = None 189 | message_id: Optional[str] = None 190 | announces_type: Optional[int] = None 191 | recommend_channels: Optional[list[RecommendChannel]] = None 192 | 193 | 194 | # Pins 195 | class PinsMessage(BaseModel): 196 | guild_id: str 197 | channel_id: str 198 | message_ids: list[str] 199 | 200 | 201 | # Schedule 202 | class RemindType(IntEnum): 203 | NO_REMIND = 0 204 | REMIND_START = 1 205 | REMIND_5_MIN = 2 206 | REMIND_15_MIN = 3 207 | REMIND_30_MIN = 4 208 | REMIND_60_MIN = 5 209 | 210 | 211 | class Schedule(BaseModel): 212 | id: str 213 | name: str 214 | description: Optional[str] = None 215 | start_timestamp: datetime 216 | end_timestamp: datetime 217 | creator: Optional[Member] = None 218 | jump_channel_id: Optional[str] = None 219 | remind_type: Optional[Union[RemindType, int]] = None 220 | 221 | 222 | # Emoji Reaction 223 | class EmojiType(IntEnum): 224 | SYSTEM = 1 225 | CUSTOM = 2 226 | 227 | 228 | class Emoji(BaseModel): 229 | id: str 230 | type: int 231 | 232 | 233 | class ReactionTargetType(IntEnum): 234 | MESSAGE = 0 235 | FEED = 1 236 | COMMENT = 2 237 | REPLY = 3 238 | 239 | 240 | class ReactionTarget(BaseModel): 241 | id: str 242 | type: Union[ReactionTargetType, str] 243 | 244 | 245 | class MessageReaction(BaseModel): 246 | user_id: str 247 | guild_id: str 248 | channel_id: str 249 | target: ReactionTarget 250 | emoji: Emoji 251 | 252 | 253 | class GetReactionUsersReturn(BaseModel): 254 | users: list[User] 255 | cookie: str 256 | is_end: bool 257 | 258 | 259 | # Audio 260 | class AudioStatus(IntEnum): 261 | START = 0 262 | PAUSE = 1 263 | RESUME = 2 264 | STOP = 3 265 | 266 | 267 | class AudioControl(BaseModel): 268 | audio_url: Optional[str] = None 269 | text: Optional[str] = None 270 | status: Union[AudioStatus, int] 271 | 272 | 273 | class AudioAction(BaseModel): 274 | guild_id: str 275 | channel_id: str 276 | audio_url: Optional[str] = None 277 | text: Optional[str] = None 278 | 279 | 280 | # Forum 281 | class ElemType(IntEnum): 282 | UNSUPPORTED = 0 283 | TEXT = 1 284 | IMAGE = 2 285 | VIDEO = 3 286 | URL = 4 287 | 288 | 289 | class TextProps(BaseModel): 290 | font_bold: Optional[bool] = None 291 | italic: Optional[bool] = None 292 | underline: Optional[bool] = None 293 | 294 | 295 | class TextElem(BaseModel): 296 | text: str 297 | props: Optional[TextProps] = None 298 | 299 | 300 | class PlatImage(BaseModel): 301 | url: Optional[str] = None 302 | width: Optional[int] = None 303 | height: Optional[int] = None 304 | image_id: Optional[str] = None 305 | 306 | 307 | class ImageElem(BaseModel): 308 | plat_image: Optional[PlatImage] = None 309 | third_url: Optional[str] = None 310 | width_percent: Optional[float] = None 311 | 312 | 313 | class VideoElem(BaseModel): 314 | third_url: str 315 | 316 | 317 | class URLElem(BaseModel): 318 | url: str 319 | desc: Optional[str] = None 320 | 321 | 322 | class Elem(BaseModel): 323 | type: ElemType 324 | text: Optional[TextElem] = None 325 | image: Optional[ImageElem] = None 326 | video: Optional[VideoElem] = None 327 | url: Optional[URLElem] = None 328 | 329 | @model_validator(mode="before") 330 | @classmethod 331 | def infer_type(cls, values: dict): 332 | if values.get("type") is not None: 333 | return values 334 | 335 | if values.get("text") is not None: 336 | values["type"] = ElemType.TEXT 337 | elif values.get("image") is not None: 338 | values["type"] = ElemType.IMAGE 339 | elif values.get("video") is not None: 340 | values["type"] = ElemType.VIDEO 341 | elif values.get("url") is not None: 342 | values["type"] = ElemType.URL 343 | else: 344 | # Unsupported element type 345 | values["type"] = ElemType.UNSUPPORTED 346 | 347 | return values 348 | 349 | 350 | class Alignment(IntEnum): 351 | LEFT = 0 352 | MIDDLE = 1 353 | RIGHT = 2 354 | 355 | 356 | class ParagraphProps(BaseModel): 357 | alignment: Optional[Alignment] = None 358 | 359 | 360 | class Paragraph(BaseModel): 361 | elems: list[Elem] 362 | props: Optional[ParagraphProps] = None 363 | 364 | 365 | class RichText(BaseModel): 366 | paragraphs: Optional[list[Paragraph]] = None 367 | 368 | 369 | class ThreadObjectInfo(BaseModel): 370 | thread_id: str 371 | content: RichText 372 | date_time: datetime 373 | 374 | @field_validator("content", mode="before") 375 | @classmethod 376 | def parse_content(cls, v): 377 | if isinstance(v, str): 378 | return type_validate_python(RichText, json.loads(v)) 379 | return v 380 | 381 | 382 | _T_Title = TypeVar("_T_Title", str, RichText) 383 | 384 | 385 | class ThreadInfo(ThreadObjectInfo, GenericModel, Generic[_T_Title]): 386 | # 事件推送拿到的title实际上是RichText的JSON字符串,而API调用返回的title是普通文本 387 | title: _T_Title 388 | 389 | @field_validator("title", mode="before") 390 | @classmethod 391 | def parse_title(cls, v): 392 | if ( 393 | isinstance(v, str) 394 | and next(f for f in model_fields(cls) if f.name == "title").annotation 395 | is RichText 396 | ): 397 | return type_validate_python(RichText, json.loads(v)) 398 | return v 399 | 400 | 401 | class ForumSourceInfo(BaseModel): 402 | guild_id: str 403 | channel_id: str 404 | author_id: str 405 | 406 | 407 | class Thread(ForumSourceInfo, GenericModel, Generic[_T_Title]): 408 | thread_info: ThreadInfo[_T_Title] 409 | 410 | 411 | class PostInfo(ThreadObjectInfo): 412 | post_id: str 413 | 414 | 415 | class Post(ForumSourceInfo): 416 | post_info: PostInfo 417 | 418 | 419 | class ReplyInfo(ThreadObjectInfo): 420 | post_id: str 421 | reply_id: str 422 | 423 | 424 | class Reply(ForumSourceInfo): 425 | reply_info: ReplyInfo 426 | 427 | 428 | class ForumAuditType(IntEnum): 429 | THREAD = 1 430 | POST = 2 431 | REPLY = 3 432 | 433 | 434 | class ForumAuditResult(ForumSourceInfo): 435 | thread_id: str 436 | post_id: str 437 | reply_id: str 438 | type: ForumAuditType 439 | result: Optional[int] = None 440 | err_msg: Optional[str] = None 441 | 442 | 443 | class GetThreadsListReturn(BaseModel): 444 | threads: list[Thread[str]] 445 | is_finish: bool 446 | 447 | 448 | class GetThreadReturn(BaseModel): 449 | thread: Thread[str] 450 | 451 | 452 | class PutThreadReturn(BaseModel): 453 | task_id: str 454 | create_time: datetime 455 | 456 | 457 | # API Permission 458 | class APIPermission(BaseModel): 459 | path: str 460 | method: str 461 | desc: Optional[str] = None 462 | auth_status: bool 463 | 464 | 465 | class APIPermissionDemandIdentify(BaseModel): 466 | path: Optional[str] = None 467 | method: Optional[str] = None 468 | 469 | 470 | class APIPermissionDemand(BaseModel): 471 | guild_id: str 472 | channel_id: str 473 | api_identify: APIPermissionDemandIdentify 474 | title: str 475 | desc: str 476 | 477 | 478 | class GetGuildAPIPermissionReturn(BaseModel): 479 | apis: list[APIPermission] 480 | 481 | 482 | # WebSocket 483 | class UrlGetReturn(BaseModel): 484 | url: str 485 | 486 | 487 | class SessionStartLimit(BaseModel): 488 | total: int 489 | remaining: int 490 | reset_after: int 491 | max_concurrency: int 492 | 493 | 494 | class ShardUrlGetReturn(BaseModel): 495 | url: str 496 | shards: int 497 | session_start_limit: SessionStartLimit 498 | 499 | 500 | __all__ = [ 501 | "DMS", 502 | "APIPermission", 503 | "APIPermissionDemand", 504 | "APIPermissionDemandIdentify", 505 | "Alignment", 506 | "Announces", 507 | "AudioAction", 508 | "AudioControl", 509 | "AudioStatus", 510 | "Channel", 511 | "ChannelPermissions", 512 | "ChannelSubType", 513 | "ChannelType", 514 | "Elem", 515 | "ElemType", 516 | "Emoji", 517 | "EmojiType", 518 | "ForumAuditResult", 519 | "ForumAuditType", 520 | "ForumSourceInfo", 521 | "GetGuildAPIPermissionReturn", 522 | "GetGuildRolesReturn", 523 | "GetReactionUsersReturn", 524 | "GetRoleMembersReturn", 525 | "GetThreadReturn", 526 | "GetThreadsListReturn", 527 | "Guild", 528 | "ImageElem", 529 | "Member", 530 | "Message", 531 | "MessageDelete", 532 | "MessageReaction", 533 | "MessageSetting", 534 | "Paragraph", 535 | "ParagraphProps", 536 | "PatchGuildRoleReturn", 537 | "PinsMessage", 538 | "Post", 539 | "PostGuildRoleReturn", 540 | "PostInfo", 541 | "PrivateType", 542 | "PutThreadReturn", 543 | "ReactionTarget", 544 | "ReactionTargetType", 545 | "RecommendChannel", 546 | "RemindType", 547 | "Reply", 548 | "ReplyInfo", 549 | "RichText", 550 | "Role", 551 | "Schedule", 552 | "SessionStartLimit", 553 | "ShardUrlGetReturn", 554 | "SpeakPermission", 555 | "TextElem", 556 | "TextProps", 557 | "Thread", 558 | "ThreadInfo", 559 | "ThreadObjectInfo", 560 | "URLElem", 561 | "UrlGetReturn", 562 | "User", 563 | "VideoElem", 564 | ] 565 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/adapter.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | import asyncio 4 | from typing_extensions import override 5 | from typing import Any, List, Tuple, Optional 6 | 7 | from pydantic import parse_raw_as 8 | from nonebot.utils import escape_tag 9 | from nonebot.exception import WebSocketClosed 10 | from nonebot.drivers import ( 11 | URL, 12 | Driver, 13 | Request, 14 | WebSocket, 15 | HTTPClientMixin, 16 | WebSocketClientMixin, 17 | ) 18 | 19 | from nonebot.adapters import Adapter as BaseAdapter 20 | 21 | from .bot import Bot 22 | from .utils import log 23 | from .api import API_HANDLERS 24 | from .store import audit_result 25 | from .config import Config, BotInfo 26 | from .exception import ApiNotAvailable 27 | from .event import Event, ReadyEvent, MessageAuditEvent, event_classes 28 | from .payload import ( 29 | Hello, 30 | Resume, 31 | Payload, 32 | Dispatch, 33 | Identify, 34 | Heartbeat, 35 | Reconnect, 36 | PayloadType, 37 | HeartbeatAck, 38 | InvalidSession, 39 | ) 40 | 41 | RECONNECT_INTERVAL = 3.0 42 | 43 | 44 | class Adapter(BaseAdapter): 45 | @override 46 | def __init__(self, driver: Driver, **kwargs: Any): 47 | super().__init__(driver, **kwargs) 48 | self.qqguild_config: Config = Config(**self.config.dict()) 49 | self.tasks: List["asyncio.Task"] = [] 50 | self.api_base: Optional[URL] = None 51 | self.setup() 52 | 53 | @classmethod 54 | @override 55 | def get_name(cls) -> str: 56 | return "QQ Guild" 57 | 58 | def setup(self) -> None: 59 | if not isinstance(self.driver, HTTPClientMixin): 60 | raise RuntimeError( 61 | f"Current driver {self.config.driver} does not support " 62 | "http client requests! " 63 | "QQ Guild Adapter need a HTTPClient Driver to work." 64 | ) 65 | if not isinstance(self.driver, WebSocketClientMixin): 66 | raise RuntimeError( 67 | f"Current driver {self.config.driver} does not support " 68 | "websocket client! " 69 | "QQ Guild Adapter need a WebSocketClient Driver to work." 70 | ) 71 | self.driver.on_startup(self.startup) 72 | self.driver.on_shutdown(self.shutdown) 73 | 74 | async def startup(self) -> None: 75 | log( 76 | "DEBUG", 77 | ( 78 | "QQ Guild run in sandbox mode: " 79 | f"{self.qqguild_config.qqguild_is_sandbox}" 80 | ), 81 | ) 82 | 83 | try: 84 | self.api_base = self.get_api_base() 85 | except Exception as e: 86 | log("ERROR", "Failed to parse QQ Guild api base url", e) 87 | raise 88 | 89 | log("DEBUG", f"QQ Guild api base url: {escape_tag(str(self.api_base))}") 90 | 91 | for bot in self.qqguild_config.qqguild_bots: 92 | self.tasks.append(asyncio.create_task(self.run_bot(bot))) 93 | 94 | async def shutdown(self) -> None: 95 | for task in self.tasks: 96 | if not task.done(): 97 | task.cancel() 98 | 99 | async def run_bot(self, bot_info: BotInfo) -> None: 100 | bot = Bot(self, bot_info.id, bot_info) 101 | try: 102 | gateway_info = await bot.shard_url_get() 103 | if not gateway_info.url: 104 | raise ValueError("Failed to get gateway url") 105 | ws_url = URL(gateway_info.url) 106 | except Exception as e: 107 | log( 108 | "ERROR", 109 | "Failed to get gateway info.", 110 | e, 111 | ) 112 | return 113 | 114 | remain = ( 115 | gateway_info.session_start_limit 116 | and gateway_info.session_start_limit.remaining 117 | ) 118 | if remain and remain <= 0: 119 | log( 120 | "ERROR", 121 | "Failed to establish connection to QQ Guild " 122 | "because of session start limit.\n" 123 | f"{escape_tag(repr(gateway_info))}", 124 | ) 125 | return 126 | 127 | if bot_info.shard is not None: 128 | self.tasks.append( 129 | asyncio.create_task(self._forward_ws(bot, ws_url, bot_info.shard)) 130 | ) 131 | return 132 | 133 | shards = gateway_info.shards or 1 134 | for i in range(shards): 135 | self.tasks.append( 136 | asyncio.create_task(self._forward_ws(bot, ws_url, (i, shards))) 137 | ) 138 | await asyncio.sleep( 139 | gateway_info.session_start_limit 140 | and gateway_info.session_start_limit.max_concurrency 141 | or 1 142 | ) 143 | 144 | async def _forward_ws(self, bot: Bot, ws_url: URL, shard: Tuple[int, int]) -> None: 145 | request = Request( 146 | "GET", 147 | ws_url, 148 | timeout=30.0, 149 | ) 150 | heartbeat_task: Optional["asyncio.Task"] = None 151 | 152 | while True: 153 | try: 154 | async with self.websocket(request) as ws: 155 | log( 156 | "DEBUG", 157 | ( 158 | "WebSocket Connection to " 159 | f"{escape_tag(str(ws_url))} established" 160 | ), 161 | ) 162 | 163 | try: 164 | # hello 165 | heartbeat_interval = await self._hello(ws) 166 | if not heartbeat_interval: 167 | await asyncio.sleep(RECONNECT_INTERVAL) 168 | continue 169 | 170 | # identify/resume 171 | result = await self._authenticate(bot, ws, shard) 172 | if not result: 173 | await asyncio.sleep(RECONNECT_INTERVAL) 174 | continue 175 | 176 | # start heartbeat 177 | heartbeat_task = asyncio.create_task( 178 | self._heartbeat(ws, bot, heartbeat_interval) 179 | ) 180 | 181 | # process events 182 | await self._loop(bot, ws) 183 | except WebSocketClosed as e: 184 | log( 185 | "ERROR", 186 | "WebSocket Closed", 187 | e, 188 | ) 189 | except Exception as e: 190 | log( 191 | "ERROR", 192 | ( 193 | "" 194 | "Error while process data from websocket " 195 | f"{escape_tag(str(ws_url))}. Trying to reconnect..." 196 | "" 197 | ), 198 | e, 199 | ) 200 | finally: 201 | if heartbeat_task: 202 | heartbeat_task.cancel() 203 | heartbeat_task = None 204 | self.bot_disconnect(bot) 205 | 206 | except Exception as e: 207 | log( 208 | "ERROR", 209 | ( 210 | "" 211 | "Error while setup websocket to " 212 | f"{escape_tag(str(ws_url))}. Trying to reconnect..." 213 | "" 214 | ), 215 | e, 216 | ) 217 | 218 | await asyncio.sleep(RECONNECT_INTERVAL) 219 | 220 | async def _hello(self, ws: WebSocket): 221 | """接收并处理服务器的 Hello 事件""" 222 | try: 223 | payload = await self.receive_payload(ws) 224 | assert isinstance(payload, Hello), ( 225 | f"Received unexpected payload: {payload!r}" 226 | ) 227 | return payload.data.heartbeat_interval 228 | except Exception as e: 229 | log( 230 | "ERROR", 231 | ( 232 | "" 233 | "Error while receiving server hello event" 234 | "" 235 | ), 236 | e, 237 | ) 238 | 239 | async def _authenticate(self, bot: Bot, ws: WebSocket, shard: Tuple[int, int]): 240 | """鉴权连接""" 241 | if not bot.ready: 242 | payload = Identify.parse_obj( 243 | { 244 | "data": { 245 | "token": self.get_authorization(bot.bot_info), 246 | "intents": bot.bot_info.intent.to_int(), 247 | "shard": list(shard), 248 | "properties": { 249 | "$os": sys.platform, 250 | "$sdk": "NoneBot2", 251 | }, 252 | }, 253 | } 254 | ) 255 | else: 256 | payload = Resume.parse_obj( 257 | { 258 | "data": { 259 | "token": self.get_authorization(bot.bot_info), 260 | "session_id": bot.session_id, 261 | "seq": bot.sequence, 262 | } 263 | } 264 | ) 265 | 266 | try: 267 | await ws.send(json.dumps(payload.dict())) 268 | except Exception as e: 269 | log( 270 | "ERROR", 271 | ( 272 | "Error while sending " + "Identify" 273 | if isinstance(payload, Identify) 274 | else "Resume" + " event" 275 | ), 276 | e, 277 | ) 278 | return 279 | 280 | ready_event = None 281 | if not bot.ready: 282 | # https://bot.q.qq.com/wiki/develop/api/gateway/reference.html#_2-%E9%89%B4%E6%9D%83%E8%BF%9E%E6%8E%A5 283 | # 鉴权成功之后,后台会下发一个 Ready Event 284 | payload = await self.receive_payload(ws) 285 | assert isinstance(payload, Dispatch), ( 286 | f"Received unexpected payload: {payload!r}" 287 | ) 288 | bot.sequence = payload.sequence 289 | ready_event = self.payload_to_event(payload) 290 | assert isinstance(ready_event, ReadyEvent), ( 291 | f"Received unexpected event: {ready_event!r}" 292 | ) 293 | bot.session_id = ready_event.session_id 294 | bot.self_info = ready_event.user 295 | 296 | # only connect for single shard 297 | if bot.self_id not in self.bots: 298 | self.bot_connect(bot) 299 | log( 300 | "INFO", 301 | f"Bot {escape_tag(bot.self_id)} connected", 302 | ) 303 | 304 | if ready_event: 305 | asyncio.create_task(bot.handle_event(ready_event)) 306 | 307 | return True 308 | 309 | async def _heartbeat(self, ws: WebSocket, bot: Bot, heartbeat_interval: int): 310 | """心跳""" 311 | while True: 312 | if bot.has_sequence: 313 | log("TRACE", f"Heartbeat {bot.sequence}") 314 | payload = Heartbeat.parse_obj({"data": bot.sequence}) 315 | try: 316 | await ws.send(json.dumps(payload.dict())) 317 | except Exception: 318 | pass 319 | await asyncio.sleep(heartbeat_interval / 1000) 320 | 321 | async def _loop(self, bot: Bot, ws: WebSocket): 322 | """接收并处理事件""" 323 | while True: 324 | payload = await self.receive_payload(ws) 325 | log( 326 | "TRACE", 327 | f"Received payload: {escape_tag(repr(payload))}", 328 | ) 329 | if isinstance(payload, Dispatch): 330 | bot.sequence = payload.sequence 331 | try: 332 | event = self.payload_to_event(payload) 333 | except Exception as e: 334 | log( 335 | "WARNING", 336 | f"Failed to parse event {escape_tag(repr(payload))}", 337 | e, 338 | ) 339 | else: 340 | if isinstance(event, MessageAuditEvent): 341 | audit_result.add_result(event) 342 | asyncio.create_task(bot.handle_event(event)) 343 | elif isinstance(payload, HeartbeatAck): 344 | log("TRACE", "Heartbeat ACK") 345 | continue 346 | elif isinstance(payload, Reconnect): 347 | log( 348 | "WARNING", 349 | "Received reconnect event from server. Try to reconnect...", 350 | ) 351 | break 352 | elif isinstance(payload, InvalidSession): 353 | bot.clear() 354 | log( 355 | "ERROR", 356 | "Received invalid session event from server. Try to reconnect...", 357 | ) 358 | break 359 | else: 360 | log( 361 | "WARNING", 362 | f"Unknown payload from server: {escape_tag(repr(payload))}", 363 | ) 364 | 365 | def get_api_base(self) -> URL: 366 | if self.qqguild_config.qqguild_is_sandbox: 367 | return URL(self.qqguild_config.qqguild_sandbox_api_base) 368 | else: 369 | return URL(self.qqguild_config.qqguild_api_base) 370 | 371 | def get_authorization(self, bot: BotInfo) -> str: 372 | return f"Bot {bot.id}.{bot.token}" 373 | 374 | async def receive_payload(self, ws: WebSocket) -> Payload: 375 | return parse_raw_as(PayloadType, await ws.receive()) 376 | 377 | @classmethod 378 | def payload_to_event(cls, payload: Dispatch) -> Event: 379 | EventClass = event_classes.get(payload.type, None) 380 | if not EventClass: 381 | log("WARNING", f"Unknown payload type: {payload.type}") 382 | event = Event.parse_obj(payload.data) 383 | event.__type__ = payload.type # type: ignore 384 | return event 385 | return EventClass.parse_obj(payload.data) 386 | 387 | @override 388 | async def _call_api(self, bot: Bot, api: str, **data: Any) -> Any: 389 | log("DEBUG", f"Calling API {api}") 390 | api_handler = API_HANDLERS.get(api, None) 391 | if api_handler is None: 392 | raise ApiNotAvailable 393 | return await api_handler(self, bot, **data) 394 | -------------------------------------------------------------------------------- /packages/nonebot-adapter-qqguild/nonebot/adapters/qqguild/api/model.py: -------------------------------------------------------------------------------- 1 | import json 2 | from enum import IntEnum 3 | from datetime import datetime 4 | from typing import List, Generic, Literal, TypeVar, Optional 5 | 6 | from pydantic.generics import GenericModel 7 | from pydantic import BaseModel, validator, root_validator 8 | 9 | 10 | class Guild(BaseModel): 11 | id: Optional[int] = None 12 | name: Optional[str] = None 13 | icon: Optional[str] = None 14 | owner_id: Optional[int] = None 15 | owner: Optional[bool] = None 16 | member_count: Optional[int] = None 17 | max_members: Optional[int] = None 18 | description: Optional[str] = None 19 | joined_at: Optional[str] = None 20 | 21 | 22 | class User(BaseModel): 23 | id: Optional[int] = None 24 | username: Optional[str] = None 25 | avatar: Optional[str] = None 26 | bot: Optional[bool] = None 27 | union_openid: Optional[str] = None 28 | union_user_account: Optional[str] = None 29 | 30 | 31 | class ChannelCreate(BaseModel): 32 | name: str 33 | type: int 34 | sub_type: int 35 | position: Optional[int] = None 36 | parent_id: Optional[int] = None 37 | private_type: Optional[int] = None 38 | private_user_ids: Optional[List[int]] = None 39 | 40 | 41 | class Channel(BaseModel): 42 | id: Optional[int] = None 43 | guild_id: Optional[int] = None 44 | name: Optional[str] = None 45 | type: Optional[int] = None 46 | sub_type: Optional[int] = None 47 | position: Optional[int] = None 48 | parent_id: Optional[str] = None 49 | owner_id: Optional[int] = None 50 | private_type: Optional[int] = None 51 | speak_permission: Optional[int] = None 52 | application_id: Optional[str] = None 53 | 54 | 55 | class ChannelUpdate(BaseModel): 56 | name: Optional[str] = None 57 | type: Optional[int] = None 58 | sub_type: Optional[int] = None 59 | position: Optional[int] = None 60 | parent_id: Optional[int] = None 61 | private_type: Optional[int] = None 62 | 63 | 64 | class Member(BaseModel): 65 | user: Optional[User] = None 66 | nick: Optional[str] = None 67 | roles: Optional[List[int]] = None 68 | joined_at: Optional[datetime] = None 69 | 70 | 71 | class DeleteMemberBody(BaseModel): 72 | add_blacklist: Optional[bool] = None 73 | delete_history_msg_days: Optional[Literal[-1, 0, 3, 7, 15, 30]] = None 74 | 75 | 76 | class Role(BaseModel): 77 | id: Optional[int] = None 78 | name: Optional[str] = None 79 | color: Optional[int] = None 80 | hoist: Optional[int] = None 81 | number: Optional[int] = None 82 | member_limit: Optional[int] = None 83 | 84 | 85 | class GetGuildRolesReturn(BaseModel): 86 | guild_id: Optional[str] = None 87 | roles: Optional[List[Role]] = None 88 | role_num_limit: Optional[str] = None 89 | 90 | 91 | class PostGuildRoleBody(BaseModel): 92 | name: str 93 | color: Optional[float] = None 94 | hoist: Optional[float] = None 95 | 96 | 97 | class GuildRole(BaseModel): 98 | id: Optional[int] = None 99 | name: Optional[str] = None 100 | color: Optional[float] = None 101 | hoist: Optional[float] = None 102 | number: Optional[float] = None 103 | member_limit: Optional[float] = None 104 | 105 | 106 | class PostGuildRoleReturn(BaseModel): 107 | role_id: Optional[str] = None 108 | role: Optional[GuildRole] = None 109 | 110 | 111 | class PatchGuildRoleBody(BaseModel): 112 | name: Optional[str] = None 113 | color: Optional[float] = None 114 | hoist: Optional[float] = None 115 | 116 | 117 | class PatchGuildRoleReturn(BaseModel): 118 | guild_id: Optional[str] = None 119 | role_id: Optional[str] = None 120 | role: Optional[GuildRole] = None 121 | 122 | 123 | class PutGuildMemberRoleBody(BaseModel): 124 | id: Optional[str] = None 125 | 126 | 127 | class DeleteGuildMemberRoleBody(BaseModel): 128 | id: Optional[str] = None 129 | 130 | 131 | class ChannelPermissions(BaseModel): 132 | channel_id: Optional[int] = None 133 | user_id: Optional[int] = None 134 | role_id: Optional[int] = None 135 | permissions: Optional[str] = None 136 | 137 | 138 | class PutChannelPermissionsBody(BaseModel): 139 | add: Optional[str] = None 140 | remove: Optional[str] = None 141 | 142 | 143 | class PutChannelRolesPermissionsBody(BaseModel): 144 | add: Optional[str] = None 145 | remove: Optional[str] = None 146 | 147 | 148 | class MessageAttachment(BaseModel): 149 | url: Optional[str] = None 150 | 151 | 152 | class MessageEmbedThumbnail(BaseModel): 153 | url: Optional[str] = None 154 | 155 | 156 | class MessageEmbedField(BaseModel): 157 | name: Optional[str] = None 158 | 159 | 160 | class MessageEmbed(BaseModel): 161 | title: Optional[str] = None 162 | prompt: Optional[str] = None 163 | thumbnail: Optional[MessageEmbedThumbnail] = None 164 | fields: Optional[List[MessageEmbedField]] = None 165 | 166 | 167 | class MessageArkObjKv(BaseModel): 168 | key: Optional[str] = None 169 | value: Optional[str] = None 170 | 171 | 172 | class MessageArkObj(BaseModel): 173 | obj_kv: Optional[List[MessageArkObjKv]] = None 174 | 175 | 176 | class MessageArkKv(BaseModel): 177 | key: Optional[str] = None 178 | value: Optional[str] = None 179 | obj: Optional[List[MessageArkObj]] = None 180 | 181 | 182 | class MessageArk(BaseModel): 183 | template_id: Optional[int] = None 184 | kv: Optional[List[MessageArkKv]] = None 185 | 186 | 187 | class MessageReference(BaseModel): 188 | message_id: Optional[str] = None 189 | ignore_get_message_error: Optional[bool] = None 190 | 191 | 192 | class MessageMarkdownParams(BaseModel): 193 | key: Optional[str] 194 | values: Optional[List[str]] 195 | 196 | 197 | class MessageMarkdown(BaseModel): 198 | template_id: Optional[int] 199 | custom_template_id: Optional[str] 200 | params: Optional[MessageMarkdownParams] 201 | content: Optional[str] 202 | 203 | 204 | class Permission(BaseModel): 205 | type: Optional[int] = None 206 | specify_role_ids: Optional[List[str]] = None 207 | specify_user_ids: Optional[List[str]] = None 208 | 209 | 210 | class Action(BaseModel): 211 | type: Optional[int] = None 212 | permission: Optional[Permission] = None 213 | click_limit: Optional[int] = None 214 | data: Optional[str] = None 215 | at_bot_show_channel_list: Optional[bool] = None 216 | 217 | 218 | class RenderData(BaseModel): 219 | label: Optional[str] = None 220 | visited_label: Optional[str] = None 221 | style: Optional[int] = None 222 | 223 | 224 | class Button(BaseModel): 225 | id: Optional[str] = None 226 | render_data: Optional[RenderData] = None 227 | action: Optional[Action] = None 228 | 229 | 230 | class InlineKeyboardRow(BaseModel): 231 | buttons: Optional[List[Button]] = None 232 | 233 | 234 | class InlineKeyboard(BaseModel): 235 | rows: Optional[InlineKeyboardRow] = None 236 | 237 | 238 | class MessageKeyboard(BaseModel): 239 | id: Optional[str] = None 240 | content: Optional[InlineKeyboard] = None 241 | 242 | 243 | class Message(BaseModel): 244 | id: Optional[str] = None 245 | channel_id: Optional[int] = None 246 | guild_id: Optional[int] = None 247 | content: Optional[str] = None 248 | timestamp: Optional[datetime] = None 249 | edited_timestamp: Optional[datetime] = None 250 | mention_everyone: Optional[bool] = None 251 | author: Optional[User] = None 252 | attachments: Optional[List[MessageAttachment]] = None 253 | embeds: Optional[List[MessageEmbed]] = None 254 | mentions: Optional[List[User]] = None 255 | member: Optional[Member] = None 256 | ark: Optional[MessageArk] = None 257 | seq: Optional[int] = None 258 | seq_in_channel: Optional[str] = None 259 | message_reference: Optional[MessageReference] = None 260 | src_guild_id: Optional[str] = None 261 | 262 | 263 | class MessageGet(BaseModel): 264 | message: Optional[Message] = None 265 | 266 | 267 | class MessageDelete(BaseModel): 268 | message: Optional[Message] = None 269 | op_user: Optional[User] = None 270 | 271 | 272 | class MessageSend(BaseModel): 273 | content: Optional[str] = None 274 | embed: Optional[MessageEmbed] = None 275 | ark: Optional[MessageArk] = None 276 | markdown: Optional[MessageMarkdown] = None 277 | message_reference: Optional[MessageReference] = None 278 | keyboard: Optional[MessageKeyboard] = None 279 | image: Optional[str] = None 280 | msg_id: Optional[str] = None 281 | file_image: Optional[bytes] = None 282 | 283 | 284 | class PostDmsBody(BaseModel): 285 | recipient_id: str 286 | source_guild_id: str 287 | 288 | 289 | class PatchGuildMuteBody(BaseModel): 290 | mute_end_timestamp: Optional[str] = None 291 | mute_seconds: Optional[str] = None 292 | 293 | 294 | class PatchGuildMemberMuteBody(BaseModel): 295 | mute_end_timestamp: Optional[str] = None 296 | mute_seconds: Optional[str] = None 297 | 298 | 299 | class PostGuildAnnouncesBody(BaseModel): 300 | message_id: str 301 | channel_id: str 302 | 303 | 304 | class PostChannelAnnouncesBody(BaseModel): 305 | message_id: str 306 | 307 | 308 | class Announces(BaseModel): 309 | guild_id: Optional[int] = None 310 | channel_id: Optional[int] = None 311 | message_id: Optional[str] = None 312 | 313 | 314 | class GetSchedulesBody(BaseModel): 315 | since: Optional[int] = None 316 | 317 | 318 | class ScheduleCreate(BaseModel): 319 | name: str 320 | description: Optional[str] = None 321 | start_timestamp: int 322 | end_timestamp: int 323 | creator: Optional[Member] = None 324 | jump_channel_id: Optional[int] = None 325 | remind_type: str 326 | 327 | 328 | class Schedule(BaseModel): 329 | id: Optional[int] = None 330 | name: Optional[str] = None 331 | description: Optional[str] = None 332 | start_timestamp: Optional[int] = None 333 | end_timestamp: Optional[int] = None 334 | creator: Optional[Member] = None 335 | jump_channel_id: Optional[int] = None 336 | remind_type: Optional[str] = None 337 | 338 | 339 | class ScheduleUpdate(BaseModel): 340 | name: Optional[str] = None 341 | description: Optional[str] = None 342 | start_timestamp: Optional[int] = None 343 | end_timestamp: Optional[int] = None 344 | creator: Optional[Member] = None 345 | jump_channel_id: Optional[int] = None 346 | remind_type: Optional[str] = None 347 | 348 | 349 | class AudioControl(BaseModel): 350 | audio_url: Optional[str] = None 351 | text: Optional[str] = None 352 | status: Optional[int] = None 353 | 354 | 355 | class APIPermissionDemandIdentify(BaseModel): 356 | path: Optional[str] = None 357 | name: Optional[str] = None 358 | 359 | 360 | class PostApiPermissionDemandBody(BaseModel): 361 | channel_id: Optional[str] = None 362 | api_identify: Optional[APIPermissionDemandIdentify] = None 363 | desc: Optional[str] = None 364 | 365 | 366 | class UrlGetReturn(BaseModel): 367 | url: Optional[str] = None 368 | 369 | 370 | class SessionStartLimit(BaseModel): 371 | total: Optional[int] = None 372 | remaining: Optional[int] = None 373 | reset_after: Optional[int] = None 374 | max_concurrency: Optional[int] = None 375 | 376 | 377 | class ShardUrlGetReturn(BaseModel): 378 | url: Optional[str] = None 379 | shards: Optional[int] = None 380 | session_start_limit: Optional[SessionStartLimit] = None 381 | 382 | 383 | class PinsMessage(BaseModel): 384 | guild_id: Optional[int] = None 385 | channel_id: Optional[int] = None 386 | message_ids: Optional[List[str]] = None 387 | 388 | 389 | class MessageAudited(BaseModel): 390 | audit_id: Optional[str] = None 391 | message_id: Optional[str] = None 392 | guild_id: Optional[str] = None 393 | channel_id: Optional[str] = None 394 | audit_time: Optional[datetime] = None 395 | create_time: Optional[datetime] = None 396 | seq_in_channel: Optional[str] = None 397 | 398 | 399 | class DMS(BaseModel): 400 | guild_id: Optional[int] = None 401 | channel_id: Optional[str] = None 402 | create_time: Optional[datetime] = None 403 | 404 | 405 | class Emoji(BaseModel): 406 | id: Optional[str] = None 407 | type: Optional[int] = None 408 | 409 | 410 | class ReactionTarget(BaseModel): 411 | id: Optional[str] = None 412 | type: Optional[str] = None 413 | 414 | 415 | class MessageReaction(BaseModel): 416 | user_id: Optional[int] = None 417 | guild_id: Optional[int] = None 418 | channel_id: Optional[int] = None 419 | target: Optional[ReactionTarget] = None 420 | emoji: Optional[Emoji] = None 421 | 422 | 423 | class APIPermission(BaseModel): 424 | path: Optional[str] = None 425 | method: Optional[str] = None 426 | desc: Optional[str] = None 427 | auth_status: Optional[int] = None 428 | 429 | 430 | class APIPermissionDemand(BaseModel): 431 | guild_id: Optional[int] = None 432 | channel_id: Optional[int] = None 433 | api_identify: Optional[APIPermissionDemandIdentify] = None 434 | title: Optional[str] = None 435 | desc: Optional[str] = None 436 | 437 | 438 | class ElemType(IntEnum): 439 | UNSUPPORTED = 0 440 | TEXT = 1 441 | IMAGE = 2 442 | VIDEO = 3 443 | URL = 4 444 | 445 | 446 | class TextProps(BaseModel): 447 | font_bold: Optional[bool] = None 448 | italic: Optional[bool] = None 449 | underline: Optional[bool] = None 450 | 451 | 452 | class TextElem(BaseModel): 453 | text: str 454 | props: Optional[TextProps] = None 455 | 456 | 457 | class ImageElem(BaseModel): 458 | third_url: str 459 | width_percent: Optional[float] = None 460 | 461 | 462 | class VideoElem(BaseModel): 463 | third_url: str 464 | 465 | 466 | class URLElem(BaseModel): 467 | url: str 468 | desc: Optional[str] = None 469 | 470 | 471 | class Alignment(IntEnum): 472 | LEFT = 0 473 | MIDDLE = 1 474 | RIGHT = 2 475 | 476 | 477 | class ParagraphProps(BaseModel): 478 | alignment: Optional[Alignment] = None 479 | 480 | 481 | class Elem(BaseModel): 482 | type: ElemType 483 | text: Optional[TextElem] = None 484 | image: Optional[ImageElem] = None 485 | video: Optional[VideoElem] = None 486 | url: Optional[URLElem] = None 487 | 488 | @root_validator(pre=True, allow_reuse=True) 489 | def infer_type(cls, values: dict): 490 | if values.get("type") is not None: 491 | return values 492 | 493 | if values.get("text") is not None: 494 | values["type"] = ElemType.TEXT 495 | elif values.get("image") is not None: 496 | values["type"] = ElemType.IMAGE 497 | elif values.get("video") is not None: 498 | values["type"] = ElemType.VIDEO 499 | elif values.get("url") is not None: 500 | values["type"] = ElemType.URL 501 | else: 502 | values["type"] = ElemType.UNSUPPORTED 503 | 504 | return values 505 | 506 | 507 | class Paragraph(BaseModel): 508 | elems: List[Elem] 509 | props: Optional[ParagraphProps] 510 | 511 | 512 | class RichText(BaseModel): 513 | paragraphs: List[Paragraph] 514 | 515 | 516 | class ForumObjectInfo(BaseModel): 517 | thread_id: str 518 | content: RichText 519 | date_time: datetime 520 | 521 | @validator("content", pre=True, allow_reuse=True) 522 | def parse_content(cls, v): 523 | if isinstance(v, str): 524 | return RichText.parse_raw(v, content_type="json") 525 | return v 526 | 527 | 528 | class ForumObject(BaseModel): 529 | guild_id: int 530 | channel_id: int 531 | author_id: int 532 | 533 | 534 | _T_Title = TypeVar("_T_Title", str, RichText) 535 | 536 | 537 | class ForumThreadInfo(ForumObjectInfo, GenericModel, Generic[_T_Title]): 538 | # 事件推送拿到的title实际上是RichText的JSON字符串,而API调用返回的title是普通文本 539 | title: _T_Title 540 | 541 | @validator("title", pre=True, allow_reuse=True) 542 | def parse_title(cls, v): 543 | if isinstance(v, str) and cls.__fields__["title"].type_ is RichText: 544 | return RichText.parse_raw(v, content_type="json") 545 | return v 546 | 547 | 548 | class ForumThread(ForumObject, GenericModel, Generic[_T_Title]): 549 | thread_info: ForumThreadInfo[_T_Title] 550 | 551 | 552 | class ForumPostInfo(ForumObjectInfo): 553 | post_id: str 554 | 555 | 556 | class ForumPost(ForumObject): 557 | post_info: ForumPostInfo 558 | 559 | 560 | class ForumReplyInfo(ForumObjectInfo): 561 | post_id: str 562 | reply_id: str 563 | 564 | 565 | class ForumReply(ForumObject): 566 | reply_info: ForumReplyInfo 567 | 568 | 569 | class ForumAuditType(IntEnum): 570 | PUBLISH_THREAD = 1 571 | PUBLISH_POST = 2 572 | PUBLISH_REPLY = 3 573 | 574 | 575 | class ForumAuditResult(ForumObject): 576 | thread_id: int 577 | post_id: int 578 | reply_id: int 579 | type: ForumAuditType 580 | result: Optional[int] = None 581 | err_msg: Optional[str] = None 582 | 583 | 584 | class GetThreadsListReturn(BaseModel): 585 | threads: List[ForumThread[str]] 586 | is_finish: bool 587 | 588 | 589 | class GetThreadReturn(BaseModel): 590 | thread: ForumThread[str] 591 | 592 | 593 | class PutThreadFormat(IntEnum): 594 | TEXT = 1 595 | HTML = 2 596 | MARKDOWN = 3 597 | JSON = 4 598 | 599 | 600 | class PutThreadBody(BaseModel): 601 | title: str 602 | content: str 603 | format: PutThreadFormat 604 | 605 | @validator("content", pre=True, allow_reuse=True) 606 | def convert_content(cls, v): 607 | if not isinstance(v, str): 608 | if isinstance(v, BaseModel): 609 | return v.json() 610 | else: 611 | return json.dumps(v) 612 | return v 613 | 614 | 615 | class PutThreadReturn(BaseModel): 616 | task_id: int 617 | create_time: datetime 618 | 619 | 620 | __all__ = [ 621 | "Guild", 622 | "User", 623 | "ChannelCreate", 624 | "Channel", 625 | "ChannelUpdate", 626 | "Member", 627 | "DeleteMemberBody", 628 | "Role", 629 | "GetGuildRolesReturn", 630 | "PostGuildRoleBody", 631 | "GuildRole", 632 | "PostGuildRoleReturn", 633 | "PatchGuildRoleBody", 634 | "PatchGuildRoleReturn", 635 | "PutGuildMemberRoleBody", 636 | "DeleteGuildMemberRoleBody", 637 | "ChannelPermissions", 638 | "PutChannelPermissionsBody", 639 | "PutChannelRolesPermissionsBody", 640 | "MessageAttachment", 641 | "MessageEmbedThumbnail", 642 | "MessageEmbedField", 643 | "MessageEmbed", 644 | "MessageArkObjKv", 645 | "MessageArkObj", 646 | "MessageArkKv", 647 | "MessageArk", 648 | "MessageReference", 649 | "MessageMarkdownParams", 650 | "MessageMarkdown", 651 | "Permission", 652 | "Action", 653 | "RenderData", 654 | "Button", 655 | "InlineKeyboardRow", 656 | "InlineKeyboard", 657 | "MessageKeyboard", 658 | "Message", 659 | "MessageGet", 660 | "MessageDelete", 661 | "MessageSend", 662 | "PostDmsBody", 663 | "PatchGuildMuteBody", 664 | "PatchGuildMemberMuteBody", 665 | "PostGuildAnnouncesBody", 666 | "PostChannelAnnouncesBody", 667 | "Announces", 668 | "GetSchedulesBody", 669 | "ScheduleCreate", 670 | "Schedule", 671 | "ScheduleUpdate", 672 | "AudioControl", 673 | "APIPermissionDemandIdentify", 674 | "PostApiPermissionDemandBody", 675 | "UrlGetReturn", 676 | "SessionStartLimit", 677 | "ShardUrlGetReturn", 678 | "PinsMessage", 679 | "MessageAudited", 680 | "DMS", 681 | "Emoji", 682 | "ReactionTarget", 683 | "MessageReaction", 684 | "APIPermission", 685 | "APIPermissionDemand", 686 | "ElemType", 687 | "TextProps", 688 | "TextElem", 689 | "ImageElem", 690 | "VideoElem", 691 | "URLElem", 692 | "Alignment", 693 | "ParagraphProps", 694 | "Elem", 695 | "Paragraph", 696 | "RichText", 697 | "ForumObject", 698 | "ForumObjectInfo", 699 | "ForumThreadInfo", 700 | "ForumThread", 701 | "ForumPostInfo", 702 | "ForumPost", 703 | "ForumReplyInfo", 704 | "ForumReply", 705 | "ForumAuditType", 706 | "ForumAuditResult", 707 | "GetThreadsListReturn", 708 | "GetThreadReturn", 709 | "PutThreadFormat", 710 | "PutThreadBody", 711 | "PutThreadReturn", 712 | ] 713 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/message.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from dataclasses import dataclass 3 | from io import BytesIO 4 | from pathlib import Path 5 | import re 6 | from typing import TYPE_CHECKING, Literal, Optional, TypedDict, Union, overload 7 | from typing_extensions import Self, override 8 | 9 | from nonebot.compat import type_validate_python 10 | 11 | from nonebot.adapters import Message as BaseMessage 12 | from nonebot.adapters import MessageSegment as BaseMessageSegment 13 | 14 | from .models import Attachment as QQAttachment 15 | from .models import Message as GuildMessage 16 | from .models import ( 17 | MessageActionButton, 18 | MessageArk, 19 | MessageEmbed, 20 | MessageKeyboard, 21 | MessageMarkdown, 22 | MessagePromptKeyboard, 23 | MessageReference, 24 | MessageStream, 25 | QQMessage, 26 | ) 27 | from .utils import escape, unescape 28 | 29 | 30 | class MessageSegment(BaseMessageSegment["Message"]): 31 | @classmethod 32 | @override 33 | def get_message_class(cls) -> type["Message"]: 34 | return Message 35 | 36 | @staticmethod 37 | def text(content: str) -> "Text": 38 | return Text("text", {"text": content}) 39 | 40 | @staticmethod 41 | def emoji(id: str) -> "Emoji": 42 | return Emoji("emoji", data={"id": id}) 43 | 44 | @staticmethod 45 | def mention_user(user_id: str) -> "MentionUser": 46 | return MentionUser("mention_user", {"user_id": str(user_id)}) 47 | 48 | @staticmethod 49 | def mention_channel(channel_id: str) -> "MentionChannel": 50 | return MentionChannel("mention_channel", {"channel_id": str(channel_id)}) 51 | 52 | @staticmethod 53 | def mention_everyone() -> "MentionEveryone": 54 | return MentionEveryone("mention_everyone", {}) 55 | 56 | @staticmethod 57 | def image(url: str) -> "Attachment": 58 | return Attachment("image", data={"url": url}) 59 | 60 | @staticmethod 61 | def file_image(data: Union[bytes, BytesIO, Path]) -> "LocalAttachment": 62 | if isinstance(data, BytesIO): 63 | data = data.getvalue() 64 | elif isinstance(data, Path): 65 | data = data.read_bytes() 66 | return LocalAttachment("file_image", data={"content": data}) 67 | 68 | @staticmethod 69 | def audio(url: str) -> "Attachment": 70 | return Attachment("audio", data={"url": url}) 71 | 72 | @staticmethod 73 | def file_audio(data: Union[bytes, BytesIO, Path]) -> "LocalAttachment": 74 | if isinstance(data, BytesIO): 75 | data = data.getvalue() 76 | elif isinstance(data, Path): 77 | data = data.read_bytes() 78 | return LocalAttachment("file_audio", data={"content": data}) 79 | 80 | @staticmethod 81 | def video(url: str) -> "Attachment": 82 | return Attachment("video", data={"url": url}) 83 | 84 | @staticmethod 85 | def file_video(data: Union[bytes, BytesIO, Path]) -> "LocalAttachment": 86 | if isinstance(data, BytesIO): 87 | data = data.getvalue() 88 | elif isinstance(data, Path): 89 | data = data.read_bytes() 90 | return LocalAttachment("file_video", data={"content": data}) 91 | 92 | @staticmethod 93 | def file(url: str) -> "Attachment": 94 | return Attachment("file", data={"url": url}) 95 | 96 | @staticmethod 97 | def file_file(data: Union[bytes, BytesIO, Path]) -> "LocalAttachment": 98 | if isinstance(data, BytesIO): 99 | data = data.getvalue() 100 | elif isinstance(data, Path): 101 | data = data.read_bytes() 102 | return LocalAttachment("file_file", data={"content": data}) 103 | 104 | @staticmethod 105 | def ark(ark: MessageArk) -> "Ark": 106 | return Ark("ark", data={"ark": ark}) 107 | 108 | @staticmethod 109 | def embed(embed: MessageEmbed) -> "Embed": 110 | return Embed("embed", data={"embed": embed}) 111 | 112 | @staticmethod 113 | def markdown(markdown: Union[str, MessageMarkdown]) -> "Markdown": 114 | return Markdown( 115 | "markdown", 116 | data={ 117 | "markdown": ( 118 | MessageMarkdown(content=markdown) 119 | if isinstance(markdown, str) 120 | else markdown 121 | ) 122 | }, 123 | ) 124 | 125 | @staticmethod 126 | def keyboard(keyboard: MessageKeyboard) -> "Keyboard": 127 | return Keyboard("keyboard", data={"keyboard": keyboard}) 128 | 129 | @overload 130 | @staticmethod 131 | def reference(reference: MessageReference) -> "Reference": ... 132 | 133 | @overload 134 | @staticmethod 135 | def reference( 136 | reference: str, ignore_error: Optional[bool] = None 137 | ) -> "Reference": ... 138 | 139 | @staticmethod 140 | def reference( 141 | reference: Union[str, MessageReference], ignore_error: Optional[bool] = None 142 | ) -> "Reference": 143 | if isinstance(reference, MessageReference): 144 | return Reference("reference", data={"reference": reference}) 145 | 146 | return Reference( 147 | "reference", 148 | data={ 149 | "reference": MessageReference( 150 | message_id=reference, ignore_get_message_error=ignore_error 151 | ) 152 | }, 153 | ) 154 | 155 | @staticmethod 156 | def stream( 157 | state: Literal[1, 10, 11, 20], 158 | _id: Optional[str], 159 | index: int, 160 | reset: Optional[bool] = None, 161 | ) -> "Stream": 162 | _data = { 163 | "state": state, 164 | "index": index, 165 | } 166 | if _id is not None: 167 | _data["id"] = _id 168 | 169 | if reset is not None: 170 | _data["reset"] = reset 171 | 172 | return Stream("stream", data={"stream": MessageStream(**_data)}) 173 | 174 | @staticmethod 175 | def prompt_keyboard(prompt_keyboard: MessagePromptKeyboard) -> "PromptKeyboard": 176 | return PromptKeyboard( 177 | "prompt_keyboard", data={"prompt_keyboard": prompt_keyboard} 178 | ) 179 | 180 | @staticmethod 181 | def action_button(action_button: MessageActionButton) -> "ActionButton": 182 | return ActionButton("action_button", data={"action_button": action_button}) 183 | 184 | @override 185 | def __add__( 186 | self, other: Union[str, "MessageSegment", Iterable["MessageSegment"]] 187 | ) -> "Message": 188 | return Message(self) + ( 189 | MessageSegment.text(other) if isinstance(other, str) else other 190 | ) 191 | 192 | @override 193 | def __radd__( 194 | self, other: Union[str, "MessageSegment", Iterable["MessageSegment"]] 195 | ) -> "Message": 196 | return ( 197 | MessageSegment.text(other) if isinstance(other, str) else Message(other) 198 | ) + self 199 | 200 | @override 201 | def is_text(self) -> bool: 202 | return self.type == "text" 203 | 204 | @classmethod 205 | @override 206 | def _validate(cls, value) -> Self: 207 | if isinstance(value, cls): 208 | return value 209 | if isinstance(value, MessageSegment): 210 | raise ValueError(f"Type {type(value)} can not be converted to {cls}") 211 | if not isinstance(value, dict): 212 | raise ValueError(f"Expected dict for MessageSegment, got {type(value)}") 213 | if "type" not in value: 214 | raise ValueError( 215 | f"Expected dict with 'type' for MessageSegment, got {value}" 216 | ) 217 | _type = value["type"] 218 | if _type not in SEGMENT_TYPE_MAP: 219 | raise ValueError(f"Invalid MessageSegment type: {_type}") 220 | segment_type = SEGMENT_TYPE_MAP[_type] 221 | 222 | # casting value to subclass of MessageSegment 223 | if cls is MessageSegment: 224 | return type_validate_python(segment_type, value) 225 | # init segment instance directly if type matched 226 | if cls is segment_type: 227 | return segment_type(type=_type, data=value.get("data", {})) 228 | raise ValueError(f"Segment type {_type!r} can not be converted to {cls}") 229 | 230 | 231 | class _TextData(TypedDict): 232 | text: str 233 | 234 | 235 | @dataclass 236 | class Text(MessageSegment): 237 | if TYPE_CHECKING: 238 | data: _TextData 239 | 240 | @override 241 | def __str__(self) -> str: 242 | return escape(self.data["text"]) 243 | 244 | 245 | class _EmojiData(TypedDict): 246 | id: str 247 | 248 | 249 | @dataclass 250 | class Emoji(MessageSegment): 251 | if TYPE_CHECKING: 252 | data: _EmojiData 253 | 254 | @override 255 | def __str__(self) -> str: 256 | return f"" 257 | 258 | 259 | class _MentionUserData(TypedDict): 260 | user_id: str 261 | 262 | 263 | @dataclass 264 | class MentionUser(MessageSegment): 265 | if TYPE_CHECKING: 266 | data: _MentionUserData 267 | 268 | @override 269 | def __str__(self) -> str: 270 | return f"<@{self.data['user_id']}>" 271 | 272 | 273 | class _MentionChannelData(TypedDict): 274 | channel_id: str 275 | 276 | 277 | @dataclass 278 | class MentionChannel(MessageSegment): 279 | if TYPE_CHECKING: 280 | data: _MentionChannelData 281 | 282 | @override 283 | def __str__(self) -> str: 284 | return f"<#{self.data['channel_id']}>" 285 | 286 | 287 | class _MentionEveryoneData(TypedDict): 288 | pass 289 | 290 | 291 | @dataclass 292 | class MentionEveryone(MessageSegment): 293 | if TYPE_CHECKING: 294 | data: _MentionEveryoneData 295 | 296 | @override 297 | def __str__(self) -> str: 298 | return "@everyone" 299 | 300 | 301 | class _AttachmentData(TypedDict): 302 | url: str 303 | 304 | 305 | @dataclass 306 | class Attachment(MessageSegment): 307 | if TYPE_CHECKING: 308 | data: _AttachmentData 309 | 310 | @override 311 | def __str__(self) -> str: 312 | return f"" 313 | 314 | 315 | class _LocalAttachmentData(TypedDict): 316 | content: bytes 317 | 318 | 319 | @dataclass 320 | class LocalAttachment(MessageSegment): 321 | if TYPE_CHECKING: 322 | data: _LocalAttachmentData 323 | 324 | @override 325 | def __str__(self) -> str: 326 | return f"" 327 | 328 | 329 | class _EmbedData(TypedDict): 330 | embed: MessageEmbed 331 | 332 | 333 | @dataclass 334 | class Embed(MessageSegment): 335 | if TYPE_CHECKING: 336 | data: _EmbedData 337 | 338 | @override 339 | def __str__(self) -> str: 340 | return f"" 341 | 342 | @classmethod 343 | @override 344 | def _validate(cls, value) -> Self: 345 | instance = super()._validate(value) 346 | if "embed" not in instance.data: 347 | raise ValueError( 348 | f"Expected dict with 'embed' in 'data' for Embed, got {value}" 349 | ) 350 | if not isinstance(embed := instance.data["embed"], MessageEmbed): 351 | instance.data["embed"] = type_validate_python(MessageEmbed, embed) 352 | return instance 353 | 354 | 355 | class _ArkData(TypedDict): 356 | ark: MessageArk 357 | 358 | 359 | @dataclass 360 | class Ark(MessageSegment): 361 | if TYPE_CHECKING: 362 | data: _ArkData 363 | 364 | @override 365 | def __str__(self) -> str: 366 | return f"" 367 | 368 | @classmethod 369 | @override 370 | def _validate(cls, value) -> Self: 371 | instance = super()._validate(value) 372 | if "ark" not in instance.data: 373 | raise ValueError(f"Expected dict with 'ark' in 'data' for Ark, got {value}") 374 | if not isinstance(ark := instance.data["ark"], MessageArk): 375 | instance.data["ark"] = type_validate_python(MessageArk, ark) 376 | return instance 377 | 378 | 379 | class _ReferenceData(TypedDict): 380 | reference: MessageReference 381 | 382 | 383 | @dataclass 384 | class Reference(MessageSegment): 385 | if TYPE_CHECKING: 386 | data: _ReferenceData 387 | 388 | @override 389 | def __str__(self) -> str: 390 | return f"" 391 | 392 | @classmethod 393 | @override 394 | def _validate(cls, value) -> Self: 395 | instance = super()._validate(value) 396 | if "reference" not in instance.data: 397 | raise ValueError( 398 | f"Expected dict with 'reference' in 'data' for Reference, got {value}" 399 | ) 400 | if not isinstance(reference := instance.data["reference"], MessageReference): 401 | instance.data["reference"] = type_validate_python( 402 | MessageReference, reference 403 | ) 404 | return instance 405 | 406 | 407 | class _MarkdownData(TypedDict): 408 | markdown: MessageMarkdown 409 | 410 | 411 | @dataclass 412 | class Markdown(MessageSegment): 413 | if TYPE_CHECKING: 414 | data: _MarkdownData 415 | 416 | @override 417 | def __str__(self) -> str: 418 | return f"" 419 | 420 | @classmethod 421 | @override 422 | def _validate(cls, value) -> Self: 423 | instance = super()._validate(value) 424 | if "markdown" not in instance.data: 425 | raise ValueError( 426 | f"Expected dict with 'markdown' in 'data' for Markdown, got {value}" 427 | ) 428 | if not isinstance(markdown := instance.data["markdown"], MessageMarkdown): 429 | instance.data["markdown"] = type_validate_python(MessageMarkdown, markdown) 430 | return instance 431 | 432 | 433 | class _KeyboardData(TypedDict): 434 | keyboard: MessageKeyboard 435 | 436 | 437 | @dataclass 438 | class Keyboard(MessageSegment): 439 | if TYPE_CHECKING: 440 | data: _KeyboardData 441 | 442 | @override 443 | def __str__(self) -> str: 444 | return f"" 445 | 446 | @classmethod 447 | @override 448 | def _validate(cls, value) -> Self: 449 | instance = super()._validate(value) 450 | if "keyboard" not in instance.data: 451 | raise ValueError( 452 | f"Expected dict with 'keyboard' in 'data' for Keyboard, got {value}" 453 | ) 454 | if not isinstance(keyboard := instance.data["keyboard"], MessageKeyboard): 455 | instance.data["keyboard"] = type_validate_python(MessageKeyboard, keyboard) 456 | return instance 457 | 458 | 459 | SEGMENT_TYPE_MAP: dict[str, type[MessageSegment]] = { 460 | "text": Text, 461 | "emoji": Emoji, 462 | "mention_user": MentionUser, 463 | "mention_channel": MentionChannel, 464 | "mention_everyone": MentionEveryone, 465 | "image": Attachment, 466 | "file_image": LocalAttachment, 467 | "audio": Attachment, 468 | "file_audio": LocalAttachment, 469 | "video": Attachment, 470 | "file_video": LocalAttachment, 471 | "file": Attachment, 472 | "file_file": LocalAttachment, 473 | "ark": Ark, 474 | "embed": Embed, 475 | "markdown": Markdown, 476 | "keyboard": Keyboard, 477 | "reference": Reference, 478 | } 479 | 480 | 481 | class _ActionButtonData(TypedDict): 482 | action_button: MessageActionButton 483 | 484 | 485 | @dataclass 486 | class ActionButton(MessageSegment): 487 | if TYPE_CHECKING: 488 | data: _ActionButtonData 489 | 490 | @override 491 | def __str__(self) -> str: 492 | return f"" 493 | 494 | 495 | class _PromptKeyboardData(TypedDict): 496 | prompt_keyboard: MessagePromptKeyboard 497 | 498 | 499 | @dataclass 500 | class PromptKeyboard(MessageSegment): 501 | if TYPE_CHECKING: 502 | data: _PromptKeyboardData 503 | 504 | @override 505 | def __str__(self) -> str: 506 | return f"" 507 | 508 | 509 | class _StreamData(TypedDict): 510 | stream: MessageStream 511 | 512 | 513 | @dataclass 514 | class Stream(MessageSegment): 515 | if TYPE_CHECKING: 516 | data: _StreamData 517 | 518 | @override 519 | def __str__(self) -> str: 520 | return f"" 521 | 522 | 523 | class Message(BaseMessage[MessageSegment]): 524 | @classmethod 525 | @override 526 | def get_segment_class(cls) -> type[MessageSegment]: 527 | return MessageSegment 528 | 529 | @override 530 | def __add__( 531 | self, other: Union[str, MessageSegment, Iterable[MessageSegment]] 532 | ) -> Self: 533 | return super().__add__( 534 | MessageSegment.text(other) if isinstance(other, str) else other 535 | ) 536 | 537 | @override 538 | def __radd__( 539 | self, other: Union[str, MessageSegment, Iterable[MessageSegment]] 540 | ) -> Self: 541 | return super().__radd__( 542 | MessageSegment.text(other) if isinstance(other, str) else other 543 | ) 544 | 545 | @staticmethod 546 | @override 547 | def _construct(msg: str) -> Iterable[MessageSegment]: 548 | text_begin = 0 549 | for embed in re.finditer( 550 | r"\<(?P(?:@|#|emoji:))!?(?P\w+?)\>", 551 | msg, 552 | ): 553 | content = msg[text_begin : embed.pos + embed.start()] 554 | if content: 555 | yield Text("text", {"text": unescape(content)}) 556 | text_begin = embed.pos + embed.end() 557 | if embed.group("type") == "@": 558 | yield MentionUser("mention_user", {"user_id": embed.group("id")}) 559 | elif embed.group("type") == "#": 560 | yield MentionChannel( 561 | "mention_channel", {"channel_id": embed.group("id")} 562 | ) 563 | else: 564 | yield Emoji("emoji", {"id": embed.group("id")}) 565 | content = msg[text_begin:] 566 | if content: 567 | yield Text("text", {"text": unescape(msg[text_begin:])}) 568 | 569 | @classmethod 570 | def from_guild_message(cls, message: GuildMessage) -> Self: 571 | msg = cls() 572 | if message.mention_everyone: 573 | msg.append(MessageSegment.mention_everyone()) 574 | if message.content: 575 | msg.extend(Message(message.content)) 576 | if message.attachments: 577 | msg.extend( 578 | MessageSegment.image(seg.url) for seg in message.attachments if seg.url 579 | ) 580 | if message.embeds: 581 | msg.extend(Embed("embed", data={"embed": seg}) for seg in message.embeds) 582 | if message.ark: 583 | msg.append(Ark("ark", data={"ark": message.ark})) 584 | return msg 585 | 586 | @classmethod 587 | def from_qq_message(cls, message: QQMessage) -> Self: 588 | msg = cls() 589 | if message.content: 590 | msg.extend(Message(message.content)) 591 | if message.attachments: 592 | 593 | def content_type(seg: QQAttachment): 594 | ct = seg.content_type.split("/", maxsplit=1)[0] 595 | if ct in {"image", "audio", "file", "video"}: 596 | return ct 597 | return "file" 598 | 599 | msg.extend( 600 | Attachment( 601 | content_type(seg), 602 | data={"url": seg.url}, 603 | ) 604 | for seg in message.attachments 605 | if seg.url 606 | ) 607 | return msg 608 | 609 | def extract_content(self, escape_text: bool = True) -> str: 610 | return "".join( 611 | seg.data["text"] if not escape_text and seg.type == "text" else str(seg) 612 | for seg in self 613 | if seg.type 614 | in ("text", "emoji", "mention_user", "mention_everyone", "mention_channel") 615 | ) 616 | 617 | @override 618 | def extract_plain_text(self) -> str: 619 | return "".join(seg.data["text"] for seg in self if seg.is_text()) 620 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/event.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Optional, TypeVar, cast 4 | from typing_extensions import override 5 | 6 | from nonebot.utils import escape_tag 7 | 8 | from nonebot.adapters import Event as BaseEvent 9 | 10 | from .message import Message 11 | from .models import ( 12 | AudioAction, 13 | ButtonInteraction, 14 | Channel, 15 | ForumAuditResult, 16 | ForumSourceInfo, 17 | FriendAuthor, 18 | GroupMemberAuthor, 19 | Guild, 20 | Member, 21 | MessageAudited, 22 | MessageDelete, 23 | MessageReaction, 24 | Post, 25 | QQMessage, 26 | Reply, 27 | RichText, 28 | Thread, 29 | User, 30 | ) 31 | from .models import Message as GuildMessage 32 | 33 | E = TypeVar("E", bound="Event") 34 | 35 | 36 | class EventType(str, Enum): 37 | # Init Event 38 | READY = "READY" 39 | RESUMED = "RESUMED" 40 | 41 | # GUILDS 42 | GUILD_CREATE = "GUILD_CREATE" 43 | GUILD_UPDATE = "GUILD_UPDATE" 44 | GUILD_DELETE = "GUILD_DELETE" 45 | CHANNEL_CREATE = "CHANNEL_CREATE" 46 | CHANNEL_UPDATE = "CHANNEL_UPDATE" 47 | CHANNEL_DELETE = "CHANNEL_DELETE" 48 | 49 | # GUILD_MEMBERS 50 | GUILD_MEMBER_ADD = "GUILD_MEMBER_ADD" 51 | GUILD_MEMBER_UPDATE = "GUILD_MEMBER_UPDATE" 52 | GUILD_MEMBER_REMOVE = "GUILD_MEMBER_REMOVE" 53 | 54 | # GUILD_MESSAGES 55 | MESSAGE_CREATE = "MESSAGE_CREATE" 56 | MESSAGE_DELETE = "MESSAGE_DELETE" 57 | 58 | # GUILD_MESSAGE_REACTIONS 59 | MESSAGE_REACTION_ADD = "MESSAGE_REACTION_ADD" 60 | MESSAGE_REACTION_REMOVE = "MESSAGE_REACTION_REMOVE" 61 | 62 | # DIRECT_MESSAGE 63 | DIRECT_MESSAGE_CREATE = "DIRECT_MESSAGE_CREATE" 64 | DIRECT_MESSAGE_DELETE = "DIRECT_MESSAGE_DELETE" 65 | 66 | # OPEN_FORUMS_EVENT 67 | OPEN_FORUM_THREAD_CREATE = "OPEN_FORUM_THREAD_CREATE" 68 | OPEN_FORUM_THREAD_UPDATE = "OPEN_FORUM_THREAD_UPDATE" 69 | OPEN_FORUM_THREAD_DELETE = "OPEN_FORUM_THREAD_DELETE" 70 | OPEN_FORUM_POST_CREATE = "OPEN_FORUM_POST_CREATE" 71 | OPEN_FORUM_POST_DELETE = "OPEN_FORUM_POST_DELETE" 72 | OPEN_FORUM_REPLY_CREATE = "OPEN_FORUM_REPLY_CREATE" 73 | OPEN_FORUM_REPLY_DELETE = "OPEN_FORUM_REPLY_DELETE" 74 | 75 | # AUDIO_OR_LIVE_CHANNEL_MEMBER 76 | AUDIO_OR_LIVE_CHANNEL_MEMBER_ENTER = "AUDIO_OR_LIVE_CHANNEL_MEMBER_ENTER" 77 | AUDIO_OR_LIVE_CHANNEL_MEMBER_EXIT = "AUDIO_OR_LIVE_CHANNEL_MEMBER_EXIT" 78 | 79 | # C2C_GROUP_AT_MESSAGES 80 | C2C_MESSAGE_CREATE = "C2C_MESSAGE_CREATE" 81 | GROUP_AT_MESSAGE_CREATE = "GROUP_AT_MESSAGE_CREATE" 82 | 83 | # INTERACTION 84 | INTERACTION_CREATE = "INTERACTION_CREATE" 85 | 86 | # MESSAGE_AUDIT 87 | MESSAGE_AUDIT_PASS = "MESSAGE_AUDIT_PASS" 88 | MESSAGE_AUDIT_REJECT = "MESSAGE_AUDIT_REJECT" 89 | 90 | # FORUM_EVENT 91 | FORUM_THREAD_CREATE = "FORUM_THREAD_CREATE" 92 | FORUM_THREAD_UPDATE = "FORUM_THREAD_UPDATE" 93 | FORUM_THREAD_DELETE = "FORUM_THREAD_DELETE" 94 | FORUM_POST_CREATE = "FORUM_POST_CREATE" 95 | FORUM_POST_DELETE = "FORUM_POST_DELETE" 96 | FORUM_REPLY_CREATE = "FORUM_REPLY_CREATE" 97 | FORUM_REPLY_DELETE = "FORUM_REPLY_DELETE" 98 | FORUM_PUBLISH_AUDIT_RESULT = "FORUM_PUBLISH_AUDIT_RESULT" 99 | 100 | # AUDIO_ACTION 101 | AUDIO_START = "AUDIO_START" 102 | AUDIO_FINISH = "AUDIO_FINISH" 103 | AUDIO_ON_MIC = "AUDIO_ON_MIC" 104 | AUDIO_OFF_MIC = "AUDIO_OFF_MIC" 105 | 106 | # AT_MESSAGES 107 | AT_MESSAGE_CREATE = "AT_MESSAGE_CREATE" 108 | PUBLIC_MESSAGE_DELETE = "PUBLIC_MESSAGE_DELETE" 109 | 110 | # FRIEND_ROBOT_EVENT 111 | FRIEND_ADD = "FRIEND_ADD" 112 | FRIEND_DEL = "FRIEND_DEL" 113 | C2C_MSG_REJECT = "C2C_MSG_REJECT" 114 | C2C_MSG_RECEIVE = "C2C_MSG_RECEIVE" 115 | 116 | # GROUP_ROBOT_EVENT 117 | GROUP_ADD_ROBOT = "GROUP_ADD_ROBOT" 118 | GROUP_DEL_ROBOT = "GROUP_DEL_ROBOT" 119 | GROUP_MSG_REJECT = "GROUP_MSG_REJECT" 120 | GROUP_MSG_RECEIVE = "GROUP_MSG_RECEIVE" 121 | 122 | 123 | class Event(BaseEvent): 124 | __type__: EventType 125 | 126 | # event id from payload id 127 | event_id: Optional[str] = None 128 | 129 | @override 130 | def get_event_name(self) -> str: 131 | return self.__type__ 132 | 133 | @override 134 | def get_event_description(self) -> str: 135 | return escape_tag(str(self.dict())) 136 | 137 | @override 138 | def get_message(self) -> Message: 139 | raise ValueError("Event has no message!") 140 | 141 | @override 142 | def get_user_id(self) -> str: 143 | raise ValueError("Event has no context!") 144 | 145 | @override 146 | def get_session_id(self) -> str: 147 | raise ValueError("Event has no context!") 148 | 149 | @override 150 | def is_tome(self) -> bool: 151 | return False 152 | 153 | 154 | EVENT_CLASSES: dict[str, type[Event]] = {} 155 | 156 | 157 | def register_event_class(event_class: type[E]) -> type[E]: 158 | EVENT_CLASSES[event_class.__type__.value] = event_class 159 | return event_class 160 | 161 | 162 | # Meta Event 163 | class MetaEvent(Event): 164 | @override 165 | def get_type(self) -> str: 166 | return "meta_event" 167 | 168 | 169 | @register_event_class 170 | class ReadyEvent(MetaEvent): 171 | __type__ = EventType.READY 172 | version: int 173 | session_id: str 174 | user: User 175 | shard: tuple[int, int] 176 | 177 | 178 | @register_event_class 179 | class ResumedEvent(MetaEvent): 180 | __type__ = EventType.RESUMED 181 | 182 | 183 | # Notice Event 184 | class NoticeEvent(Event): 185 | @override 186 | def get_type(self) -> str: 187 | return "notice" 188 | 189 | 190 | # Guild Event 191 | class GuildEvent(NoticeEvent, Guild): 192 | op_user_id: str 193 | 194 | 195 | @register_event_class 196 | class GuildCreateEvent(GuildEvent): 197 | __type__ = EventType.GUILD_CREATE 198 | 199 | 200 | @register_event_class 201 | class GuildUpdateEvent(GuildEvent): 202 | __type__ = EventType.GUILD_UPDATE 203 | 204 | 205 | @register_event_class 206 | class GuildDeleteEvent(GuildEvent): 207 | __type__ = EventType.GUILD_DELETE 208 | 209 | 210 | # Channel Event 211 | class ChannelEvent(NoticeEvent, Channel): 212 | op_user_id: str 213 | 214 | 215 | @register_event_class 216 | class ChannelCreateEvent(ChannelEvent): 217 | __type__ = EventType.CHANNEL_CREATE 218 | 219 | 220 | @register_event_class 221 | class ChannelUpdateEvent(ChannelEvent): 222 | __type__ = EventType.CHANNEL_UPDATE 223 | 224 | 225 | @register_event_class 226 | class ChannelDeleteEvent(ChannelEvent): 227 | __type__ = EventType.CHANNEL_DELETE 228 | 229 | 230 | # Guild Member Event 231 | class GuildMemberEvent(NoticeEvent, Member): 232 | guild_id: str 233 | op_user_id: str 234 | 235 | @override 236 | def get_user_id(self) -> str: 237 | return self.user.id # type: ignore 238 | 239 | @override 240 | def get_event_description(self) -> str: 241 | return escape_tag( 242 | f"Notice {getattr(self.user, 'username', None)}" 243 | f"@[Guild:{self.guild_id}] Roles:{self.roles}" 244 | ) 245 | 246 | @override 247 | def get_session_id(self) -> str: 248 | return f"guild_{self.guild_id}_{self.user.id}" # type: ignore 249 | 250 | 251 | @register_event_class 252 | class GuildMemberAddEvent(GuildMemberEvent): 253 | __type__ = EventType.GUILD_MEMBER_ADD 254 | 255 | 256 | @register_event_class 257 | class GuildMemberUpdateEvent(GuildMemberEvent): 258 | __type__ = EventType.GUILD_MEMBER_UPDATE 259 | 260 | 261 | @register_event_class 262 | class GuildMemberRemoveEvent(GuildMemberEvent): 263 | __type__ = EventType.GUILD_MEMBER_REMOVE 264 | 265 | 266 | # Message Event 267 | class MessageEvent(Event): 268 | to_me: bool = False 269 | 270 | @override 271 | def get_type(self) -> str: 272 | return "message" 273 | 274 | @override 275 | def is_tome(self) -> bool: 276 | return self.to_me 277 | 278 | 279 | class GuildMessageEvent(MessageEvent, GuildMessage): 280 | reply: Optional[GuildMessage] = None 281 | """ 282 | :说明: 消息中提取的回复消息,内容为 ``get_message_of_id`` API 返回结果 283 | 284 | :类型: ``Optional[GuildMessage]`` 285 | """ 286 | 287 | @override 288 | def get_user_id(self) -> str: 289 | return self.author.id 290 | 291 | @override 292 | def get_session_id(self) -> str: 293 | return f"guild_{self.guild_id}_channel_{self.channel_id}_{self.author.id}" 294 | 295 | @override 296 | def get_event_description(self) -> str: 297 | return escape_tag( 298 | f"Message {self.id} from " 299 | f"{getattr(self.author, 'username', None)}" 300 | f"@[Guild:{self.guild_id}/{self.channel_id}] " 301 | f"Roles:{getattr(self.member, 'roles', None)}: {self.get_message()!r}" 302 | ) 303 | 304 | @override 305 | def get_message(self) -> Message: 306 | if not hasattr(self, "_message"): 307 | setattr(self, "_message", Message.from_guild_message(self)) 308 | return getattr(self, "_message") 309 | 310 | 311 | @register_event_class 312 | class MessageCreateEvent(GuildMessageEvent): 313 | __type__ = EventType.MESSAGE_CREATE 314 | 315 | 316 | @register_event_class 317 | class MessageDeleteEvent(NoticeEvent, MessageDelete): 318 | __type__ = EventType.MESSAGE_DELETE 319 | 320 | @override 321 | def get_session_id(self) -> str: 322 | return ( 323 | f"guild_{self.message.guild_id}_" 324 | f"channel_{self.message.channel_id}_{self.op_user.id}" 325 | ) 326 | 327 | 328 | @register_event_class 329 | class AtMessageCreateEvent(GuildMessageEvent): 330 | __type__ = EventType.AT_MESSAGE_CREATE 331 | 332 | to_me: bool = True 333 | 334 | 335 | @register_event_class 336 | class PublicMessageDeleteEvent(MessageDeleteEvent): 337 | __type__ = EventType.PUBLIC_MESSAGE_DELETE 338 | 339 | 340 | @register_event_class 341 | class DirectMessageCreateEvent(GuildMessageEvent): 342 | __type__ = EventType.DIRECT_MESSAGE_CREATE 343 | 344 | to_me: bool = True 345 | 346 | @override 347 | def get_event_description(self) -> str: 348 | return escape_tag( 349 | f"Message {self.id} from " 350 | f"{getattr(self.author, 'username', None)}: {self.get_message()!r}" 351 | ) 352 | 353 | 354 | @register_event_class 355 | class DirectMessageDeleteEvent(MessageDeleteEvent): 356 | __type__ = EventType.DIRECT_MESSAGE_DELETE 357 | 358 | 359 | class QQMessageEvent(MessageEvent, QQMessage): 360 | _reply_seq: int = 0 361 | 362 | @override 363 | def get_message(self) -> Message: 364 | if not hasattr(self, "_message"): 365 | setattr(self, "_message", Message.from_qq_message(self)) 366 | return getattr(self, "_message") 367 | 368 | 369 | @register_event_class 370 | class C2CMessageCreateEvent(QQMessageEvent): 371 | __type__ = EventType.C2C_MESSAGE_CREATE 372 | 373 | author: FriendAuthor 374 | to_me: bool = True 375 | 376 | @override 377 | def get_user_id(self) -> str: 378 | return self.author.user_openid 379 | 380 | @override 381 | def get_session_id(self) -> str: 382 | return f"friend_{self.author.user_openid}" 383 | 384 | @override 385 | def get_event_description(self) -> str: 386 | return escape_tag( 387 | f"Message {self.id} from {self.author.id}: {self.get_message()!r}" 388 | ) 389 | 390 | 391 | @register_event_class 392 | class GroupAtMessageCreateEvent(QQMessageEvent): 393 | __type__ = EventType.GROUP_AT_MESSAGE_CREATE 394 | 395 | author: GroupMemberAuthor 396 | group_openid: str 397 | to_me: bool = True 398 | 399 | @override 400 | def get_message(self) -> Message: 401 | # tmp fix to remove space before text due to at not in content 402 | msg = Message.from_qq_message(self) 403 | if msg and msg[0].type == "text": 404 | msg[0].data["text"] = msg[0].data["text"].lstrip() 405 | if not hasattr(self, "_message"): 406 | setattr(self, "_message", msg) 407 | return getattr(self, "_message") 408 | 409 | @override 410 | def get_user_id(self) -> str: 411 | return self.author.member_openid 412 | 413 | @override 414 | def get_session_id(self) -> str: 415 | return f"group_{self.group_openid}_{self.author.member_openid}" 416 | 417 | @override 418 | def get_event_description(self) -> str: 419 | return escape_tag( 420 | f"Message {self.id} from " 421 | f"{self.author.member_openid}@[Group:{self.group_openid}]: " 422 | f"{self.get_message()!r}" 423 | ) 424 | 425 | 426 | @register_event_class 427 | class InteractionCreateEvent(NoticeEvent, ButtonInteraction): 428 | __type__ = EventType.INTERACTION_CREATE 429 | 430 | @override 431 | def get_user_id(self) -> str: 432 | if self.chat_type == 0: 433 | return cast(str, self.data.resolved.user_id) 434 | elif self.chat_type == 1: 435 | return cast(str, self.group_member_openid) 436 | elif self.chat_type == 2: 437 | return cast(str, self.user_openid) 438 | raise ValueError(f"Unknown chat_type: {self.chat_type}") 439 | 440 | @override 441 | def get_session_id(self) -> str: 442 | if self.chat_type == 0: 443 | return ( 444 | f"guild_{self.guild_id}_channel_{self.channel_id}" 445 | f"_{self.data.resolved.user_id}" 446 | ) 447 | elif self.chat_type == 1: 448 | return f"group_{self.group_openid}_{self.group_member_openid}" 449 | elif self.chat_type == 2: 450 | return f"friend_{self.user_openid}" 451 | raise ValueError(f"Unknown chat_type: {self.chat_type}") 452 | 453 | 454 | # Message Audit Event 455 | class MessageAuditEvent(NoticeEvent, MessageAudited): ... 456 | 457 | 458 | @register_event_class 459 | class MessageAuditPassEvent(MessageAuditEvent): 460 | __type__ = EventType.MESSAGE_AUDIT_PASS 461 | 462 | 463 | @register_event_class 464 | class MessageAuditRejectEvent(MessageAuditEvent): 465 | __type__ = EventType.MESSAGE_AUDIT_REJECT 466 | 467 | 468 | # Message Reaction Event 469 | class MessageReactionEvent(NoticeEvent, MessageReaction): 470 | @override 471 | def get_user_id(self) -> str: 472 | return self.user_id 473 | 474 | @override 475 | def get_session_id(self) -> str: 476 | return f"guild_{self.guild_id}_channel_{self.channel_id}_{self.user_id}" 477 | 478 | 479 | @register_event_class 480 | class MessageReactionAddEvent(MessageReactionEvent): 481 | __type__ = EventType.MESSAGE_REACTION_ADD 482 | 483 | 484 | @register_event_class 485 | class MessageReactionRemoveEvent(MessageReactionEvent): 486 | __type__ = EventType.MESSAGE_REACTION_REMOVE 487 | 488 | 489 | # Audio Event 490 | class AudioEvent(NoticeEvent, AudioAction): ... 491 | 492 | 493 | @register_event_class 494 | class AudioStartEvent(AudioEvent): 495 | __type__ = EventType.AUDIO_START 496 | 497 | 498 | @register_event_class 499 | class AudioFinishEvent(AudioEvent): 500 | __type__ = EventType.AUDIO_FINISH 501 | 502 | 503 | @register_event_class 504 | class AudioOnMicEvent(AudioEvent): 505 | __type__ = EventType.AUDIO_ON_MIC 506 | 507 | 508 | @register_event_class 509 | class AudioOffMicEvent(AudioEvent): 510 | __type__ = EventType.AUDIO_OFF_MIC 511 | 512 | 513 | # Forum Event 514 | class ForumEvent(NoticeEvent, ForumSourceInfo): 515 | @override 516 | def get_user_id(self) -> str: 517 | return self.author_id 518 | 519 | @override 520 | def get_session_id(self) -> str: 521 | return f"guild_{self.guild_id}_channel_{self.channel_id}_{self.author_id}" 522 | 523 | 524 | class ForumThreadEvent(ForumEvent, Thread[RichText]): ... 525 | 526 | 527 | @register_event_class 528 | class ForumThreadCreateEvent(ForumThreadEvent): 529 | __type__ = EventType.FORUM_THREAD_CREATE 530 | 531 | 532 | @register_event_class 533 | class ForumThreadUpdateEvent(ForumThreadEvent): 534 | __type__ = EventType.FORUM_THREAD_UPDATE 535 | 536 | 537 | @register_event_class 538 | class ForumThreadDeleteEvent(ForumThreadEvent): 539 | __type__ = EventType.FORUM_THREAD_DELETE 540 | 541 | 542 | class ForumPostEvent(ForumEvent, Post): ... 543 | 544 | 545 | @register_event_class 546 | class ForumPostCreateEvent(ForumPostEvent): 547 | __type__ = EventType.FORUM_POST_CREATE 548 | 549 | 550 | @register_event_class 551 | class ForumPostDeleteEvent(ForumPostEvent): 552 | __type__ = EventType.FORUM_POST_DELETE 553 | 554 | 555 | class ForumReplyEvent(ForumEvent, Reply): ... 556 | 557 | 558 | @register_event_class 559 | class ForumReplyCreateEvent(ForumReplyEvent): 560 | __type__ = EventType.FORUM_REPLY_CREATE 561 | 562 | 563 | @register_event_class 564 | class ForumReplyDeleteEvent(ForumReplyEvent): 565 | __type__ = EventType.FORUM_REPLY_DELETE 566 | 567 | 568 | @register_event_class 569 | class ForumPublishAuditResult(ForumEvent, ForumAuditResult): 570 | __type__ = EventType.FORUM_PUBLISH_AUDIT_RESULT 571 | 572 | 573 | class OpenForumEvent(NoticeEvent, ForumSourceInfo): 574 | @override 575 | def get_user_id(self) -> str: 576 | return self.author_id 577 | 578 | @override 579 | def get_session_id(self) -> str: 580 | return f"guild_{self.guild_id}_channel_{self.channel_id}_{self.author_id}" 581 | 582 | 583 | @register_event_class 584 | class OpenForumThreadCreateEvent(OpenForumEvent): 585 | __type__ = EventType.OPEN_FORUM_THREAD_CREATE 586 | 587 | 588 | @register_event_class 589 | class OpenForumThreadUpdateEvent(OpenForumEvent): 590 | __type__ = EventType.OPEN_FORUM_THREAD_UPDATE 591 | 592 | 593 | @register_event_class 594 | class OpenForumThreadDeleteEvent(OpenForumEvent): 595 | __type__ = EventType.OPEN_FORUM_THREAD_DELETE 596 | 597 | 598 | @register_event_class 599 | class OpenForumPostCreateEvent(OpenForumEvent): 600 | __type__ = EventType.OPEN_FORUM_POST_CREATE 601 | 602 | 603 | @register_event_class 604 | class OpenForumPostDeleteEvent(OpenForumEvent): 605 | __type__ = EventType.OPEN_FORUM_POST_DELETE 606 | 607 | 608 | @register_event_class 609 | class OpenForumReplyCreateEvent(OpenForumEvent): 610 | __type__ = EventType.OPEN_FORUM_REPLY_CREATE 611 | 612 | 613 | @register_event_class 614 | class OpenForumReplyDeleteEvent(OpenForumEvent): 615 | __type__ = EventType.OPEN_FORUM_REPLY_DELETE 616 | 617 | 618 | # Friend Robot Event 619 | class FriendRobotEvent(NoticeEvent): 620 | timestamp: datetime 621 | openid: str 622 | 623 | @override 624 | def get_user_id(self) -> str: 625 | return self.openid 626 | 627 | @override 628 | def get_session_id(self) -> str: 629 | return f"friend_{self.openid}" 630 | 631 | 632 | @register_event_class 633 | class FriendAddEvent(FriendRobotEvent): 634 | __type__ = EventType.FRIEND_ADD 635 | 636 | 637 | @register_event_class 638 | class FriendDelEvent(FriendRobotEvent): 639 | __type__ = EventType.FRIEND_DEL 640 | 641 | 642 | @register_event_class 643 | class C2CMsgRejectEvent(FriendRobotEvent): 644 | __type__ = EventType.C2C_MSG_REJECT 645 | 646 | 647 | @register_event_class 648 | class C2CMsgReceiveEvent(FriendRobotEvent): 649 | __type__ = EventType.C2C_MSG_RECEIVE 650 | 651 | 652 | # Group Robot Event 653 | class GroupRobotEvent(NoticeEvent): 654 | timestamp: datetime 655 | group_openid: str 656 | op_member_openid: str 657 | 658 | @override 659 | def get_user_id(self) -> str: 660 | return self.op_member_openid 661 | 662 | @override 663 | def get_session_id(self) -> str: 664 | return f"group_{self.group_openid}_{self.op_member_openid}" 665 | 666 | 667 | @register_event_class 668 | class GroupAddRobotEvent(GroupRobotEvent): 669 | __type__ = EventType.GROUP_ADD_ROBOT 670 | 671 | 672 | @register_event_class 673 | class GroupDelRobotEvent(GroupRobotEvent): 674 | __type__ = EventType.GROUP_DEL_ROBOT 675 | 676 | 677 | @register_event_class 678 | class GroupMsgRejectEvent(GroupRobotEvent): 679 | __type__ = EventType.GROUP_MSG_REJECT 680 | 681 | 682 | @register_event_class 683 | class GroupMsgReceiveEvent(GroupRobotEvent): 684 | __type__ = EventType.GROUP_MSG_RECEIVE 685 | 686 | 687 | __all__ = [ 688 | "EVENT_CLASSES", 689 | "AtMessageCreateEvent", 690 | "AudioEvent", 691 | "AudioFinishEvent", 692 | "AudioOffMicEvent", 693 | "AudioOnMicEvent", 694 | "AudioStartEvent", 695 | "C2CMessageCreateEvent", 696 | "C2CMsgReceiveEvent", 697 | "C2CMsgRejectEvent", 698 | "ChannelCreateEvent", 699 | "ChannelDeleteEvent", 700 | "ChannelEvent", 701 | "ChannelUpdateEvent", 702 | "DirectMessageCreateEvent", 703 | "DirectMessageDeleteEvent", 704 | "Event", 705 | "EventType", 706 | "ForumEvent", 707 | "ForumPostCreateEvent", 708 | "ForumPostDeleteEvent", 709 | "ForumPostEvent", 710 | "ForumPublishAuditResult", 711 | "ForumReplyCreateEvent", 712 | "ForumReplyDeleteEvent", 713 | "ForumReplyEvent", 714 | "ForumThreadCreateEvent", 715 | "ForumThreadDeleteEvent", 716 | "ForumThreadEvent", 717 | "ForumThreadUpdateEvent", 718 | "FriendAddEvent", 719 | "FriendDelEvent", 720 | "FriendRobotEvent", 721 | "GroupAddRobotEvent", 722 | "GroupAtMessageCreateEvent", 723 | "GroupDelRobotEvent", 724 | "GroupMsgReceiveEvent", 725 | "GroupMsgRejectEvent", 726 | "GroupRobotEvent", 727 | "GuildCreateEvent", 728 | "GuildDeleteEvent", 729 | "GuildEvent", 730 | "GuildMemberAddEvent", 731 | "GuildMemberEvent", 732 | "GuildMemberRemoveEvent", 733 | "GuildMemberUpdateEvent", 734 | "GuildMessageEvent", 735 | "GuildUpdateEvent", 736 | "InteractionCreateEvent", 737 | "MessageAuditEvent", 738 | "MessageAuditPassEvent", 739 | "MessageAuditRejectEvent", 740 | "MessageCreateEvent", 741 | "MessageDeleteEvent", 742 | "MessageEvent", 743 | "MessageReactionAddEvent", 744 | "MessageReactionEvent", 745 | "MessageReactionRemoveEvent", 746 | "MetaEvent", 747 | "NoticeEvent", 748 | "OpenForumEvent", 749 | "OpenForumPostCreateEvent", 750 | "OpenForumPostDeleteEvent", 751 | "OpenForumReplyCreateEvent", 752 | "OpenForumReplyDeleteEvent", 753 | "OpenForumThreadCreateEvent", 754 | "OpenForumThreadDeleteEvent", 755 | "OpenForumThreadUpdateEvent", 756 | "PublicMessageDeleteEvent", 757 | "QQMessageEvent", 758 | "ReadyEvent", 759 | "ResumedEvent", 760 | ] 761 | -------------------------------------------------------------------------------- /nonebot/adapters/qq/adapter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import binascii 3 | import json 4 | import sys 5 | from typing import Any, Literal, Optional, Union, cast 6 | from typing_extensions import override 7 | 8 | from cryptography.exceptions import InvalidSignature 9 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey 10 | from nonebot.compat import PYDANTIC_V2, type_validate_json, type_validate_python 11 | from nonebot.drivers import ( 12 | URL, 13 | ASGIMixin, 14 | Driver, 15 | HTTPClientMixin, 16 | HTTPServerSetup, 17 | Request, 18 | Response, 19 | WebSocket, 20 | WebSocketClientMixin, 21 | ) 22 | from nonebot.exception import WebSocketClosed 23 | from nonebot.utils import escape_tag 24 | 25 | from nonebot import get_plugin_config 26 | from nonebot.adapters import Adapter as BaseAdapter 27 | 28 | from .bot import Bot 29 | from .config import BotInfo, Config 30 | from .event import EVENT_CLASSES, Event, MessageAuditEvent, ReadyEvent 31 | from .exception import ApiNotAvailable 32 | from .models import ( 33 | Dispatch, 34 | Heartbeat, 35 | HeartbeatAck, 36 | Hello, 37 | Identify, 38 | InvalidSession, 39 | Payload, 40 | PayloadType, 41 | Reconnect, 42 | Resume, 43 | WebhookVerify, 44 | ) 45 | from .store import audit_result 46 | from .utils import API, log 47 | 48 | RECONNECT_INTERVAL = 3.0 49 | 50 | 51 | class Adapter(BaseAdapter): 52 | @override 53 | def __init__(self, driver: Driver, **kwargs: Any): 54 | super().__init__(driver, **kwargs) 55 | 56 | self.qq_config: Config = get_plugin_config(Config) 57 | 58 | self.tasks: set["asyncio.Task"] = set() 59 | self.setup() 60 | 61 | @classmethod 62 | @override 63 | def get_name(cls) -> str: 64 | return "QQ" 65 | 66 | def setup(self) -> None: 67 | if not isinstance(self.driver, HTTPClientMixin): 68 | raise RuntimeError( 69 | f"Current driver {self.config.driver} does not support " 70 | "http client requests! " 71 | "QQ Adapter need a HTTPClient Driver to work." 72 | ) 73 | 74 | if any(bot.use_websocket for bot in self.qq_config.qq_bots) and not isinstance( 75 | self.driver, WebSocketClientMixin 76 | ): 77 | raise RuntimeError( 78 | f"Current driver {self.config.driver} does not support " 79 | "websocket client! " 80 | "QQ Adapter need a WebSocketClient Driver to work." 81 | ) 82 | 83 | if not all( 84 | bot.use_websocket for bot in self.qq_config.qq_bots 85 | ) and not isinstance(self.driver, ASGIMixin): 86 | raise RuntimeError( 87 | f"Current driver {self.config.driver} does not support " 88 | "ASGI server! " 89 | "QQ Adapter need a ASGI Driver to receive webhook." 90 | ) 91 | self.on_ready(self.startup) 92 | self.driver.on_shutdown(self.shutdown) 93 | 94 | async def startup(self) -> None: 95 | log("DEBUG", f"QQ run in sandbox mode: {self.qq_config.qq_is_sandbox}") 96 | 97 | try: 98 | api_base = self.get_api_base() 99 | except Exception as e: 100 | log("ERROR", "Failed to parse QQ api base url", e) 101 | raise 102 | 103 | log("DEBUG", f"QQ api base url: {escape_tag(str(api_base))}") 104 | 105 | if isinstance(self.driver, ASGIMixin): 106 | self.setup_http_server( 107 | HTTPServerSetup( 108 | URL("/qq/"), 109 | "POST", 110 | f"{self.get_name()} Root Webhook", 111 | self._handle_http, 112 | ), 113 | ) 114 | self.setup_http_server( 115 | HTTPServerSetup( 116 | URL("/qq/webhook"), 117 | "POST", 118 | f"{self.get_name()} Webhook", 119 | self._handle_http, 120 | ), 121 | ) 122 | self.setup_http_server( 123 | HTTPServerSetup( 124 | URL("/qq/webhook/"), 125 | "POST", 126 | f"{self.get_name()} Webhook Slash", 127 | self._handle_http, 128 | ), 129 | ) 130 | 131 | for bot in self.qq_config.qq_bots: 132 | if not bot.use_websocket: 133 | continue 134 | task = asyncio.create_task(self.run_bot_websocket(bot)) 135 | task.add_done_callback(self.tasks.discard) 136 | self.tasks.add(task) 137 | 138 | async def shutdown(self) -> None: 139 | for task in self.tasks: 140 | if not task.done(): 141 | task.cancel() 142 | 143 | await asyncio.gather( 144 | *(asyncio.wait_for(task, timeout=10) for task in self.tasks), 145 | return_exceptions=True, 146 | ) 147 | 148 | async def run_bot_websocket(self, bot_info: BotInfo) -> None: 149 | bot = Bot(self, bot_info.id, bot_info) 150 | 151 | # get sharded gateway url 152 | try: 153 | gateway_info = await bot.shard_url_get() 154 | ws_url = URL(gateway_info.url) 155 | except Exception as e: 156 | log( 157 | "ERROR", 158 | "Failed to get gateway info.", 159 | e, 160 | ) 161 | return 162 | 163 | if (remain := gateway_info.session_start_limit.remaining) and remain <= 0: 164 | log( 165 | "ERROR", 166 | "Failed to establish connection to QQ " 167 | "because of session start limit.\n" 168 | f"{escape_tag(repr(gateway_info))}", 169 | ) 170 | return 171 | 172 | # start connection in single shard mode 173 | if bot_info.shard is not None: 174 | task = asyncio.create_task(self._forward_ws(bot, ws_url, bot_info.shard)) 175 | task.add_done_callback(self.tasks.discard) 176 | self.tasks.add(task) 177 | return 178 | 179 | # start connection in sharding mode 180 | shards = gateway_info.shards or 1 181 | for i in range(shards): 182 | task = asyncio.create_task(self._forward_ws(bot, ws_url, (i, shards))) 183 | task.add_done_callback(self.tasks.discard) 184 | self.tasks.add(task) 185 | # wait for session start concurrency limit 186 | await asyncio.sleep(gateway_info.session_start_limit.max_concurrency or 1) 187 | 188 | async def _forward_ws(self, bot: Bot, ws_url: URL, shard: tuple[int, int]) -> None: 189 | # ws setup request 190 | request = Request( 191 | "GET", 192 | ws_url, 193 | timeout=30.0, 194 | ) 195 | 196 | heartbeat_task: Optional["asyncio.Task"] = None 197 | 198 | while True: 199 | try: 200 | async with self.websocket(request) as ws: 201 | log( 202 | "DEBUG", 203 | ( 204 | "WebSocket Connection to " 205 | f"{escape_tag(str(ws_url))} established" 206 | ), 207 | ) 208 | 209 | try: 210 | # hello 211 | heartbeat_interval = await self._hello(bot, ws) 212 | if heartbeat_interval is None: 213 | await asyncio.sleep(RECONNECT_INTERVAL) 214 | continue 215 | 216 | # identify/resume 217 | result = await self._authenticate(bot, ws, shard) 218 | if not result: 219 | await asyncio.sleep(RECONNECT_INTERVAL) 220 | continue 221 | 222 | # start heartbeat 223 | heartbeat_task = asyncio.create_task( 224 | self._heartbeat(bot, ws, heartbeat_interval) 225 | ) 226 | 227 | # process events 228 | await self._loop(bot, ws) 229 | except WebSocketClosed as e: 230 | log( 231 | "ERROR", 232 | "WebSocket Closed", 233 | e, 234 | ) 235 | except Exception as e: 236 | log( 237 | "ERROR", 238 | ( 239 | "" 240 | "Error while process data from websocket " 241 | f"{escape_tag(str(ws_url))}. Trying to reconnect..." 242 | "" 243 | ), 244 | e, 245 | ) 246 | finally: 247 | if heartbeat_task: 248 | heartbeat_task.cancel() 249 | heartbeat_task = None 250 | if bot.self_id in self.bots: 251 | self.bot_disconnect(bot) 252 | 253 | except Exception as e: 254 | log( 255 | "ERROR", 256 | ( 257 | "" 258 | "Error while setup websocket to " 259 | f"{escape_tag(str(ws_url))}. Trying to reconnect..." 260 | "" 261 | ), 262 | e, 263 | ) 264 | 265 | await asyncio.sleep(RECONNECT_INTERVAL) 266 | 267 | async def _hello(self, bot: Bot, ws: WebSocket) -> Optional[int]: 268 | """接收并处理服务器的 Hello 事件""" 269 | try: 270 | payload = await self.receive_payload(bot, ws) 271 | assert isinstance(payload, Hello), ( 272 | f"Received unexpected payload: {payload!r}" 273 | ) 274 | return payload.data.heartbeat_interval 275 | except Exception as e: 276 | log( 277 | "ERROR", 278 | ( 279 | "" 280 | "Error while receiving server hello event" 281 | "" 282 | ), 283 | e, 284 | ) 285 | 286 | async def _authenticate( 287 | self, bot: Bot, ws: WebSocket, shard: tuple[int, int] 288 | ) -> Optional[Literal[True]]: 289 | """鉴权连接""" 290 | if not bot.ready: 291 | payload = type_validate_python( 292 | Identify, 293 | { 294 | "data": { 295 | "token": await bot._get_authorization_header(), 296 | "intents": bot.bot_info.intent.to_int(), 297 | "shard": shard, 298 | "properties": { 299 | "$os": sys.platform, 300 | "$language": f"python {sys.version}", 301 | "$sdk": "NoneBot2", 302 | }, 303 | } 304 | }, 305 | ) 306 | else: 307 | payload = type_validate_python( 308 | Resume, 309 | { 310 | "data": { 311 | "token": await bot._get_authorization_header(), 312 | "session_id": bot.session_id, 313 | "seq": bot.sequence, 314 | } 315 | }, 316 | ) 317 | 318 | try: 319 | await ws.send(self.payload_to_json(payload)) 320 | except Exception as e: 321 | log( 322 | "ERROR", 323 | "Error while sending " 324 | + ("Identify" if isinstance(payload, Identify) else "Resume") 325 | + " event", 326 | e, 327 | ) 328 | return 329 | 330 | ready_event = None 331 | if not bot.ready: 332 | # https://bot.q.qq.com/wiki/develop/api/gateway/reference.html#_2-%E9%89%B4%E6%9D%83%E8%BF%9E%E6%8E%A5 333 | # 鉴权成功之后,后台会下发一个 Ready Event 334 | payload = await self.receive_payload(bot, ws) 335 | if isinstance(payload, InvalidSession): 336 | log( 337 | "WARNING", 338 | "Received invalid session event from server. Try to reconnect...", 339 | ) 340 | return 341 | elif not isinstance(payload, Dispatch): 342 | log( 343 | "ERROR", 344 | "Received unexpected payload while authenticating: " 345 | f"{escape_tag(repr(payload))}", 346 | ) 347 | return 348 | 349 | ready_event = self.payload_to_event(payload) 350 | if not isinstance(ready_event, ReadyEvent): 351 | log( 352 | "ERROR", 353 | "Received unexpected event while authenticating: " 354 | f"{escape_tag(repr(ready_event))}", 355 | ) 356 | return 357 | 358 | bot.on_ready(ready_event) 359 | 360 | # only connect for single shard 361 | if bot.self_id not in self.bots: 362 | self.bot_connect(bot) 363 | log( 364 | "INFO", 365 | f"Bot {escape_tag(bot.self_id)} connected", 366 | ) 367 | 368 | if ready_event: 369 | task = asyncio.create_task(bot.handle_event(ready_event)) 370 | task.add_done_callback(self.tasks.discard) 371 | self.tasks.add(task) 372 | 373 | return True 374 | 375 | async def _heartbeat(self, bot: Bot, ws: WebSocket, heartbeat_interval: int): 376 | """心跳""" 377 | while True: 378 | if bot.ready: 379 | log("TRACE", f"Heartbeat {bot.sequence}") 380 | payload = type_validate_python(Heartbeat, {"data": bot.sequence}) 381 | try: 382 | await ws.send(self.payload_to_json(payload)) 383 | except Exception as e: 384 | log("WARNING", "Error while sending heartbeat, Ignored!", e) 385 | await asyncio.sleep(heartbeat_interval / 1000) 386 | 387 | async def _loop(self, bot: Bot, ws: WebSocket): 388 | """接收并处理事件""" 389 | while True: 390 | payload = await self.receive_payload(bot, ws) 391 | log( 392 | "TRACE", 393 | f"Received payload: {escape_tag(repr(payload))}", 394 | ) 395 | if isinstance(payload, Dispatch): 396 | self.dispatch_event(bot, payload) 397 | elif isinstance(payload, HeartbeatAck): 398 | log("TRACE", "Heartbeat ACK") 399 | continue 400 | elif isinstance(payload, Reconnect): 401 | log( 402 | "WARNING", 403 | "Received reconnect event from server. Try to reconnect...", 404 | ) 405 | break 406 | elif isinstance(payload, InvalidSession): 407 | bot.reset() 408 | log( 409 | "ERROR", 410 | "Received invalid session event from server. Try to reconnect...", 411 | ) 412 | break 413 | else: 414 | log( 415 | "WARNING", 416 | f"Unknown payload from server: {escape_tag(repr(payload))}", 417 | ) 418 | 419 | async def receive_payload(self, bot: Bot, ws: WebSocket) -> Payload: 420 | return self.data_to_payload(bot, await ws.receive()) 421 | 422 | async def _handle_http(self, request: Request) -> Response: 423 | bot_id = request.headers.get("X-Bot-Appid") 424 | if not bot_id: 425 | log("WARNING", "Missing X-Bot-Appid header in request") 426 | return Response(403, content="Missing X-Bot-Appid header") 427 | elif bot_id in self.bots: 428 | bot = cast(Bot, self.bots[bot_id]) 429 | elif bot_info := next( 430 | (bot_info for bot_info in self.qq_config.qq_bots if bot_info.id == bot_id), 431 | None, 432 | ): 433 | bot = Bot(self, bot_id, bot_info) 434 | else: 435 | log("ERROR", f"Bot {bot_id} not found") 436 | return Response(403, content="Bot not found") 437 | 438 | if request.content is None: 439 | return Response(400, content="Missing request content") 440 | 441 | try: 442 | payload = self.data_to_payload(bot, request.content) 443 | except Exception as e: 444 | log( 445 | "ERROR", 446 | "Error while parsing data from webhook", 447 | e, 448 | ) 449 | return Response(400, content="Invalid request content") 450 | 451 | log( 452 | "TRACE", 453 | f"Received payload: {escape_tag(repr(payload))}", 454 | ) 455 | if isinstance(payload, WebhookVerify): 456 | log("INFO", "Received qq webhook verify request") 457 | return self._webhook_verify(bot, payload) 458 | 459 | if self.qq_config.qq_verify_webhook and ( 460 | response := self._check_signature(bot, request) 461 | ): 462 | return response 463 | 464 | # ensure bot self info 465 | if not bot._self_info: 466 | bot.self_info = await bot.me() 467 | 468 | if bot.self_id not in self.bots: 469 | self.bot_connect(bot) 470 | 471 | if isinstance(payload, Dispatch): 472 | self.dispatch_event(bot, payload) 473 | 474 | return Response(200) 475 | 476 | def _get_ed25519_key(self, bot: Bot) -> Ed25519PrivateKey: 477 | secret = bot.bot_info.secret.encode() 478 | seed = secret 479 | while len(seed) < 32: 480 | seed += secret 481 | seed = seed[:32] 482 | return Ed25519PrivateKey.from_private_bytes(seed) 483 | 484 | def _webhook_verify(self, bot: Bot, payload: WebhookVerify) -> Response: 485 | plain_token = payload.data.plain_token 486 | event_ts = payload.data.event_ts 487 | 488 | try: 489 | private_key = self._get_ed25519_key(bot) 490 | except Exception as e: 491 | log("ERROR", "Failed to create private key", e) 492 | return Response(500, content="Failed to create private key") 493 | 494 | msg = f"{event_ts}{plain_token}".encode() 495 | try: 496 | signature = private_key.sign(msg) 497 | signature_hex = binascii.hexlify(signature).decode() 498 | except Exception as e: 499 | log("ERROR", "Failed to sign message", e) 500 | return Response(500, content="Failed to sign message") 501 | 502 | return Response( 503 | 200, 504 | content=json.dumps( 505 | {"plain_token": plain_token, "signature": signature_hex} 506 | ), 507 | ) 508 | 509 | def _check_signature(self, bot: Bot, request: Request) -> Optional[Response]: 510 | signature = request.headers.get("X-Signature-Ed25519") 511 | timestamp = request.headers.get("X-Signature-Timestamp") 512 | if not signature or not timestamp: 513 | log("WARNING", "Missing signature or timestamp in request") 514 | return Response(403, content="Missing signature or timestamp") 515 | 516 | if request.content is None: 517 | return Response(400, content="Missing request content") 518 | 519 | try: 520 | private_key = self._get_ed25519_key(bot) 521 | public_key = private_key.public_key() 522 | except Exception as e: 523 | log("ERROR", "Failed to create public key", e) 524 | return Response(500, content="Failed to create public key") 525 | 526 | signature = binascii.unhexlify(signature) 527 | if len(signature) != 64 or signature[63] & 224 != 0: 528 | log("WARNING", "Invalid signature in request") 529 | return Response(403, content="Invalid signature") 530 | 531 | body = ( 532 | request.content.encode() 533 | if isinstance(request.content, str) 534 | else request.content 535 | ) 536 | msg = timestamp.encode() + body 537 | try: 538 | public_key.verify(signature, msg) 539 | except InvalidSignature: 540 | log("WARNING", "Invalid signature in request") 541 | return Response(403, content="Invalid signature") 542 | except Exception as e: 543 | log("ERROR", "Failed to verify signature", e) 544 | return Response(403, content="Failed to verify signature") 545 | 546 | def get_auth_base(self) -> URL: 547 | return URL(str(self.qq_config.qq_auth_base)) 548 | 549 | def get_api_base(self) -> URL: 550 | if self.qq_config.qq_is_sandbox: 551 | return URL(str(self.qq_config.qq_sandbox_api_base)) 552 | else: 553 | return URL(str(self.qq_config.qq_api_base)) 554 | 555 | @staticmethod 556 | def data_to_payload(bot: Bot, data: Union[str, bytes]) -> Payload: 557 | payload = type_validate_json(PayloadType, data) 558 | if isinstance(payload, Dispatch): 559 | bot.on_dispatch(payload) 560 | return payload 561 | 562 | @staticmethod 563 | def payload_to_json(payload: Payload) -> str: 564 | if PYDANTIC_V2: 565 | return payload.model_dump_json(by_alias=True) 566 | 567 | return payload.json(by_alias=True) 568 | 569 | def dispatch_event(self, bot: Bot, payload: Dispatch): 570 | try: 571 | event = self.payload_to_event(payload) 572 | except Exception as e: 573 | log( 574 | "WARNING", 575 | f"Failed to parse event {escape_tag(repr(payload))}", 576 | e, 577 | ) 578 | else: 579 | if isinstance(event, MessageAuditEvent): 580 | audit_result.add_result(event) 581 | task = asyncio.create_task(bot.handle_event(event)) 582 | task.add_done_callback(self.tasks.discard) 583 | self.tasks.add(task) 584 | 585 | @staticmethod 586 | def payload_to_event(payload: Dispatch) -> Event: 587 | EventClass = EVENT_CLASSES.get(payload.type, None) 588 | if EventClass is None: 589 | log("WARNING", f"Unknown payload type: {payload.type}") 590 | event = type_validate_python( 591 | Event, {"event_id": payload.id, **payload.data} 592 | ) 593 | event.__type__ = payload.type # type: ignore 594 | return event 595 | return type_validate_python( 596 | EventClass, {"event_id": payload.id, **payload.data} 597 | ) 598 | 599 | @override 600 | async def _call_api(self, bot: Bot, api: str, **data: Any) -> Any: 601 | log("DEBUG", f"Bot {bot.bot_info.id} calling API {api}") 602 | api_handler: Optional[API] = getattr(bot.__class__, api, None) 603 | if api_handler is None: 604 | raise ApiNotAvailable 605 | return await api_handler(bot, **data) 606 | --------------------------------------------------------------------------------