├── 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 |
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 |
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 |
--------------------------------------------------------------------------------