├── nonebot └── adapters │ └── villa │ ├── pb │ ├── __init__.py │ ├── robot_event_message_pb2.py │ ├── command_pb2.py │ └── model_pb2.py │ ├── api.py │ ├── __init__.py │ ├── config.py │ ├── permission.py │ ├── utils.py │ ├── exception.py │ ├── payload.py │ ├── event.py │ ├── models.py │ ├── adapter.py │ ├── message.py │ └── bot.py ├── .github ├── FUNDING.yml ├── workflows │ ├── ruff.yml │ └── pypi-publish.yml ├── ISSUE_TEMPLATE │ ├── feature_request.yml │ └── bug_report.yml └── release.yml ├── .vscode └── settings.json ├── .pre-commit-config.yaml ├── LICENSE ├── pyproject.toml ├── README.md └── .gitignore /nonebot/adapters/villa/pb/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://afdian.net/@cherishmoon"] -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "charliermarsh.ruff" 4 | }, 5 | "python.formatting.provider": "none", 6 | } 7 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/api.py: -------------------------------------------------------------------------------- 1 | from warnings import warn 2 | 3 | warn( 4 | "nonebot.adapters.villa.api is DEPRECATED! " 5 | "Please use nonebot.adapters.villa.models instead.", 6 | DeprecationWarning, 7 | stacklevel=2, 8 | ) 9 | from .models import * 10 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/__init__.py: -------------------------------------------------------------------------------- 1 | from .adapter import Adapter as Adapter 2 | from .bot import Bot as Bot 3 | from .event import * 4 | from .exception import ActionFailed as ActionFailed 5 | from .message import ( 6 | Message as Message, 7 | MessageSegment as MessageSegment, 8 | ) 9 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "nonebot/**" 9 | 10 | pull_request: 11 | paths: 12 | - "nonebot/**" 13 | 14 | jobs: 15 | ruff: 16 | name: Ruff Lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Run Ruff Lint 22 | uses: chartboost/ruff-action@v1 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 功能建议 2 | title: "Feature: 功能描述" 3 | description: 提出关于项目新功能的想法 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | id: problem 8 | attributes: 9 | label: 希望能解决的问题 10 | description: 在使用中遇到什么问题而需要新的功能? 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | id: feature 16 | attributes: 17 | label: 描述所需要的功能 18 | description: 请说明需要的功能或解决方法 19 | validations: 20 | required: true -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: 💥 破坏性更新 4 | labels: 5 | - Semver-Major 6 | - breaking-change 7 | - title: ✨ 新特性 8 | labels: 9 | - enhancement 10 | - title: 🐛 BUG修复 11 | labels: 12 | - bug 13 | - title: 📝 文档更新 14 | labels: 15 | - documentation 16 | - title: 💫 杂项 17 | labels: 18 | - "*" 19 | exclude: 20 | labels: 21 | - dependencies -------------------------------------------------------------------------------- /nonebot/adapters/villa/config.py: -------------------------------------------------------------------------------- 1 | from typing import List, Literal, Optional 2 | 3 | from pydantic import BaseModel, Extra, Field 4 | 5 | 6 | class BotInfo(BaseModel): 7 | bot_id: str 8 | bot_secret: str 9 | connection_type: Literal["webhook", "websocket"] = "webhook" 10 | test_villa_id: int = 0 11 | pub_key: str 12 | callback_url: Optional[str] = None 13 | verify_event: bool = True 14 | 15 | 16 | class Config(BaseModel, extra=Extra.ignore): 17 | villa_bots: List[BotInfo] = Field(default_factory=list) 18 | -------------------------------------------------------------------------------- /.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.1.2 11 | hooks: 12 | - id: ruff 13 | args: [--fix, --exit-non-zero-on-fix] 14 | stages: [commit] 15 | - id: ruff-format 16 | 17 | - repo: https://github.com/pre-commit/pre-commit-hooks 18 | rev: v4.4.0 19 | hooks: 20 | - id: end-of-file-fixer 21 | - id: trailing-whitespace 22 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build-n-publish: 11 | name: Build and publish to PyPI 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout source 16 | uses: actions/checkout@master 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: 3.8 22 | 23 | - name: Install pypa/build 24 | run: >- 25 | python -m 26 | pip install 27 | build 28 | --user 29 | - name: Build a binary wheel and a source tarball 30 | run: >- 31 | python -m 32 | build 33 | --sdist 34 | --wheel 35 | --outdir dist/ 36 | . 37 | - name: Publish distribution 📦 to PyPI 38 | uses: pypa/gh-action-pypi-publish@master 39 | with: 40 | password: ${{ secrets.PYPI_API_TOKEN }} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 惜月 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. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug 反馈 2 | title: "Bug: 出现异常" 3 | description: 提交 Bug 反馈以帮助我们改进代码 4 | labels: ["bug"] 5 | body: 6 | - type: input 7 | id: env-python-ver 8 | attributes: 9 | label: Python 版本 10 | description: 填写 Python 版本 11 | placeholder: e.g. 3.8.0 12 | validations: 13 | required: true 14 | 15 | - type: input 16 | id: env-nb-ver 17 | attributes: 18 | label: 版本 19 | description: 填写版本 20 | placeholder: e.g. 0.1.0 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | id: describe 26 | attributes: 27 | label: 描述问题 28 | description: 清晰简洁地说明问题是什么 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | id: reproduction 34 | attributes: 35 | label: 复现步骤 36 | description: 提供能复现此问题的详细操作步骤 37 | placeholder: | 38 | 1. 首先…… 39 | 2. 然后…… 40 | 3. 发生…… 41 | 42 | - type: textarea 43 | id: expected 44 | attributes: 45 | label: 期望的结果 46 | description: 清晰简洁地描述你期望发生的事情 47 | 48 | - type: textarea 49 | id: logs 50 | attributes: 51 | label: 截图或日志 52 | description: 提供有助于诊断问题的任何日志和截图 -------------------------------------------------------------------------------- /nonebot/adapters/villa/pb/robot_event_message_pb2.py: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # source: robot_event_message.proto 3 | # Protobuf Python Version: 4.25.1 4 | """Generated protocol buffer code.""" 5 | from google.protobuf import ( 6 | descriptor as _descriptor, 7 | descriptor_pool as _descriptor_pool, 8 | symbol_database as _symbol_database, 9 | ) 10 | from google.protobuf.internal import builder as _builder 11 | 12 | # @@protoc_insertion_point(imports) 13 | 14 | _sym_db = _symbol_database.Default() 15 | 16 | 17 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 18 | b'\n\x19robot_event_message.proto\x12\x08vila_bot\x1a\x0bmodel.proto"8\n\x11RobotEventMessage\x12#\n\x05\x65vent\x18\x01 \x01(\x0b\x32\x14.vila_bot.RobotEventB6Z4gopkg.mihoyo.com/vila-bot-go/proto/vila_bot;vila_botb\x06proto3', # noqa: E501 19 | ) 20 | 21 | _globals = globals() 22 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 23 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "robot_event_message_pb2", _globals) 24 | if _descriptor._USE_C_DESCRIPTORS is False: 25 | _globals["DESCRIPTOR"]._options = None 26 | _globals[ 27 | "DESCRIPTOR" 28 | ]._serialized_options = b"Z4gopkg.mihoyo.com/vila-bot-go/proto/vila_bot;vila_bot" 29 | _globals["_ROBOTEVENTMESSAGE"]._serialized_start = 52 30 | _globals["_ROBOTEVENTMESSAGE"]._serialized_end = 108 31 | # @@protoc_insertion_point(module_scope) 32 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/permission.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nonebot.permission import Permission 4 | 5 | from .bot import Bot 6 | from .event import AddQuickEmoticonEvent, SendMessageEvent 7 | from .models import RoleType 8 | 9 | 10 | async def is_owner_or_admin( 11 | bot: Bot, 12 | event: Union[SendMessageEvent, AddQuickEmoticonEvent], 13 | ) -> bool: 14 | user_id = event.from_user_id if isinstance(event, SendMessageEvent) else event.uid 15 | user = await bot.get_member(villa_id=event.villa_id, uid=user_id) 16 | return any( 17 | role.role_type in (RoleType.OWNER, RoleType.ADMIN) for role in user.role_list 18 | ) 19 | 20 | 21 | OWNER_OR_ADMIN = Permission(is_owner_or_admin) 22 | """别野房东或管理员权限""" 23 | 24 | 25 | async def is_owner( 26 | bot: Bot, 27 | event: Union[SendMessageEvent, AddQuickEmoticonEvent], 28 | ) -> bool: 29 | user_id = event.from_user_id if isinstance(event, SendMessageEvent) else event.uid 30 | user = await bot.get_member(villa_id=event.villa_id, uid=user_id) 31 | return any(role.role_type == RoleType.OWNER for role in user.role_list) 32 | 33 | 34 | OWNER = Permission(is_owner) 35 | """别野房东权限""" 36 | 37 | 38 | async def is_admin( 39 | bot: Bot, 40 | event: Union[SendMessageEvent, AddQuickEmoticonEvent], 41 | ) -> bool: 42 | user_id = event.from_user_id if isinstance(event, SendMessageEvent) else event.uid 43 | user = await bot.get_member(villa_id=event.villa_id, uid=user_id) 44 | return any(role.role_type == RoleType.ADMIN for role in user.role_list) 45 | 46 | 47 | ADMIN = Permission(is_admin) 48 | """管理员权限""" 49 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/utils.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | import hashlib 3 | import imghdr 4 | import re 5 | from typing import ( 6 | TYPE_CHECKING, 7 | Any, 8 | Awaitable, 9 | Callable, 10 | Dict, 11 | Generic, 12 | Optional, 13 | Type, 14 | TypeVar, 15 | overload, 16 | ) 17 | from typing_extensions import Concatenate, ParamSpec 18 | 19 | from nonebot.utils import logger_wrapper 20 | 21 | if TYPE_CHECKING: 22 | from .bot import Bot 23 | 24 | B = TypeVar("B", bound="Bot") 25 | R = TypeVar("R") 26 | P = ParamSpec("P") 27 | 28 | 29 | log = logger_wrapper("Villa") 30 | 31 | 32 | def pascal_to_snake(string): 33 | result = string[0].lower() 34 | 35 | pattern = re.compile(r"(? Dict[str, Any]: 42 | return {k: v for k, v in data.items() if v is not None} 43 | 44 | 45 | class API(Generic[B, P, R]): 46 | def __init__(self, func: Callable[Concatenate[B, P], Awaitable[R]]) -> None: 47 | self.func = func 48 | 49 | def __set_name__(self, owner: Type[B], name: str) -> None: 50 | self.name = name 51 | 52 | @overload 53 | def __get__(self, obj: None, objtype: Type[B]) -> "API[B, P, R]": 54 | ... 55 | 56 | @overload 57 | def __get__(self, obj: B, objtype: Optional[Type[B]]) -> Callable[P, Awaitable[R]]: 58 | ... 59 | 60 | def __get__( 61 | self, 62 | obj: Optional[B], 63 | objtype: Optional[Type[B]] = None, 64 | ) -> "API[B, P, R] | Callable[P, Awaitable[R]]": 65 | if obj is None: 66 | return self 67 | 68 | return partial(obj.call_api, self.name) # type: ignore 69 | 70 | async def __call__(self, inst: B, *args: P.args, **kwds: P.kwargs) -> R: 71 | return await self.func(inst, *args, **kwds) 72 | 73 | 74 | def get_img_extenion(img_bytes: bytes) -> Optional[str]: 75 | return imghdr.what(None, h=img_bytes) 76 | 77 | 78 | def get_img_md5(img_bytes: bytes) -> str: 79 | return hashlib.md5(img_bytes).hexdigest() 80 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "nonebot-adapter-villa" 3 | version = "1.4.2" 4 | description = "NoneBot2 米游社大别野 Bot 适配器。MiHoYo Villa Bot adapter for nonebot2." 5 | authors = ["CMHopeSunshine <277073121@qq.com>"] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/CMHopeSunshine/nonebot-adapter-villa" 9 | repository = "https://github.com/CMHopeSunshine/nonebot-adapter-villa" 10 | documentation = "https://github.com/CMHopeSunshine/nonebot-adapter-villa" 11 | keywords = ["nonebot", "mihoyo", "bot"] 12 | 13 | packages = [{ include = "nonebot" }] 14 | 15 | [tool.poetry.dependencies] 16 | python = "^3.8" 17 | nonebot2 = "^2.1.2" 18 | rsa = "^4.9" 19 | protobuf = "^4.25.1" 20 | 21 | 22 | [tool.poetry.group.dev.dependencies] 23 | ruff = "^0.1.4" 24 | pre-commit = "^3.1.0" 25 | nonebot2 = { version = "^2.0.0", extras = ["fastapi", "httpx"] } 26 | 27 | 28 | [tool.ruff] 29 | select = [ 30 | "E", 31 | "W", # pycodestyle 32 | "F", # pyflakes 33 | "UP", # pyupgrade 34 | "N", # pep8-naming 35 | "I", # isort 36 | "PYI", # flask8-pyi 37 | "Q", # flake8-quotes 38 | "PTH", # flake8-use-pathlib 39 | "RET", # flake8-return 40 | "RSE", # flake8-raise 41 | "T20", # flake8-print 42 | "PIE", # flake8-pie 43 | "SIM", # flake8-simplify 44 | "ISC", # flake8-implicit-str-concat 45 | "C4", # flake8-comprehensions 46 | "COM", # flake8-commas 47 | "B", # flake8-bugbear 48 | "ASYNC", # flake8-async 49 | ] 50 | ignore = ["E402", "B008", "F403", "F405", "RET505"] 51 | line-length = 88 52 | target-version = "py38" 53 | ignore-init-module-imports = true 54 | 55 | 56 | [tool.ruff.isort] 57 | force-sort-within-sections = true 58 | extra-standard-library = ["typing_extensions"] 59 | force-wrap-aliases = true 60 | combine-as-imports = true 61 | order-by-type = true 62 | relative-imports-order = "closest-to-furthest" 63 | section-order = [ 64 | "future", 65 | "standard-library", 66 | "first-party", 67 | "third-party", 68 | "local-folder", 69 | ] 70 | 71 | [tool.pyright] 72 | pythonVersion = "3.8" 73 | pythonPlatform = "All" 74 | typeCheckingMode = "basic" 75 | reportShadowedImports = false 76 | disableBytesTypePromotions = true 77 | 78 | [build-system] 79 | requires = ["poetry-core"] 80 | build-backend = "poetry.core.masonry.api" 81 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/exception.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional 2 | 3 | from nonebot.exception import ( 4 | ActionFailed as BaseActionFailed, 5 | AdapterException, 6 | ApiNotAvailable as BaseApiNotAvailable, 7 | NetworkError as BaseNetworkError, 8 | NoLogException as BaseNoLogException, 9 | ) 10 | 11 | if TYPE_CHECKING: 12 | from pydantic import BaseModel 13 | 14 | from .api import ApiResponse 15 | 16 | 17 | class VillaAdapterException(AdapterException): 18 | def __init__(self): 19 | super().__init__("Villa") 20 | 21 | 22 | class NoLogException(BaseNoLogException, VillaAdapterException): 23 | pass 24 | 25 | 26 | class ReconnectError(VillaAdapterException): 27 | def __init__(self, payload: Optional["BaseModel"] = None): 28 | super().__init__() 29 | self.payload = payload 30 | 31 | def __repr__(self) -> str: 32 | if self.payload is None: 33 | return "Receive unexpected data, reconnect." 34 | return f"Reconnect because of {self.payload}" 35 | 36 | def __str__(self) -> str: 37 | return self.__repr__() 38 | 39 | 40 | class DisconnectError(VillaAdapterException): 41 | ... 42 | 43 | 44 | class ActionFailed(BaseActionFailed, VillaAdapterException): 45 | def __init__(self, status_code: int, response: "ApiResponse"): 46 | super().__init__() 47 | self.status_code = status_code 48 | self.response = response 49 | 50 | def __repr__(self) -> str: 51 | return ( 52 | f"<{self.__class__.__name__}: {self.status_code}, " 53 | f"retcode={self.response.retcode}, " 54 | f"message={self.response.message}, " 55 | f"data={self.response.data}>" 56 | ) 57 | 58 | def __str__(self): 59 | return self.__repr__() 60 | 61 | 62 | class UnknownServerError(ActionFailed): 63 | def __init__(self, response: "ApiResponse"): 64 | super().__init__(-502, response) 65 | 66 | 67 | class InvalidRequest(ActionFailed): 68 | def __init__(self, response: "ApiResponse"): 69 | super().__init__(-1, response) 70 | 71 | 72 | class InsufficientPermission(ActionFailed): 73 | def __init__(self, response: "ApiResponse"): 74 | super().__init__(10318001, response) 75 | 76 | 77 | class BotNotAdded(ActionFailed): 78 | def __init__(self, response: "ApiResponse"): 79 | super().__init__(10322002, response) 80 | 81 | 82 | class PermissionDenied(ActionFailed): 83 | def __init__(self, response: "ApiResponse"): 84 | super().__init__(10322003, response) 85 | 86 | 87 | class InvalidMemberBotAccessToken(ActionFailed): 88 | def __init__(self, response: "ApiResponse"): 89 | super().__init__(10322004, response) 90 | 91 | 92 | class InvalidBotAuthInfo(ActionFailed): 93 | def __init__(self, response: "ApiResponse"): 94 | super().__init__(10322005, response) 95 | 96 | 97 | class UnsupportedMsgType(ActionFailed): 98 | def __init__(self, response: "ApiResponse"): 99 | super().__init__(10322006, response) 100 | 101 | 102 | class NetworkError(BaseNetworkError, VillaAdapterException): 103 | def __init__(self, msg: Optional[str] = None): 104 | super().__init__() 105 | self.msg: Optional[str] = msg 106 | """错误原因""" 107 | 108 | def __repr__(self): 109 | return f"" 110 | 111 | def __str__(self): 112 | return self.__repr__() 113 | 114 | 115 | class ApiNotAvailable(BaseApiNotAvailable, VillaAdapterException): 116 | def __init__(self, api: str): 117 | super().__init__() 118 | self.api = api 119 | 120 | def __repr__(self): 121 | return f"" 122 | 123 | def __str__(self): 124 | return self.__repr__() 125 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/pb/command_pb2.py: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # source: command.proto 3 | # Protobuf Python Version: 4.25.1 4 | """Generated protocol buffer code.""" 5 | from google.protobuf import ( 6 | descriptor as _descriptor, 7 | descriptor_pool as _descriptor_pool, 8 | symbol_database as _symbol_database, 9 | ) 10 | from google.protobuf.internal import builder as _builder 11 | 12 | # @@protoc_insertion_point(imports) 13 | 14 | _sym_db = _symbol_database.Default() 15 | 16 | 17 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 18 | b'\n\rcommand.proto\x12\x08vila_bot"&\n\nPHeartBeat\x12\x18\n\x10\x63lient_timestamp\x18\x01 \x01(\t"9\n\x0fPHeartBeatReply\x12\x0c\n\x04\x63ode\x18\x01 \x01(\x05\x12\x18\n\x10server_timestamp\x18\x02 \x01(\x04"\xc0\x01\n\x06PLogin\x12\x0b\n\x03uid\x18\x01 \x01(\x04\x12\r\n\x05token\x18\x02 \x01(\t\x12\x10\n\x08platform\x18\x03 \x01(\x05\x12\x0e\n\x06\x61pp_id\x18\x04 \x01(\x05\x12\x11\n\tdevice_id\x18\x05 \x01(\t\x12\x0e\n\x06region\x18\x06 \x01(\t\x12(\n\x04meta\x18\x07 \x03(\x0b\x32\x1a.vila_bot.PLogin.MetaEntry\x1a+\n\tMetaEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"S\n\x0bPLoginReply\x12\x0c\n\x04\x63ode\x18\x01 \x01(\x05\x12\x0b\n\x03msg\x18\x02 \x01(\t\x12\x18\n\x10server_timestamp\x18\x03 \x01(\x04\x12\x0f\n\x07\x63onn_id\x18\x04 \x01(\x04"[\n\x07PLogout\x12\x0b\n\x03uid\x18\x01 \x01(\x04\x12\x10\n\x08platform\x18\x02 \x01(\x05\x12\x0e\n\x06\x61pp_id\x18\x03 \x01(\x05\x12\x11\n\tdevice_id\x18\x04 \x01(\t\x12\x0e\n\x06region\x18\x05 \x01(\t":\n\x0cPLogoutReply\x12\x0c\n\x04\x63ode\x18\x01 \x01(\x05\x12\x0b\n\x03msg\x18\x02 \x01(\t\x12\x0f\n\x07\x63onn_id\x18\x03 \x01(\x04"(\n\x0b\x43ommonReply\x12\x0c\n\x04\x63ode\x18\x01 \x01(\x05\x12\x0b\n\x03msg\x18\x02 \x01(\t"(\n\x08PKickOff\x12\x0c\n\x04\x63ode\x18\x01 \x01(\x05\x12\x0e\n\x06reason\x18\x02 \x01(\t*\xf8\x01\n\x07\x43ommand\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x10\n\x0c\x45XCHANGE_KEY\x10\x01\x12\r\n\tHEARTBEAT\x10\x02\x12\t\n\x05LOGIN\x10\x03\x12\n\n\x06LOGOUT\x10\x04\x12\x12\n\x0eP_EXCHANGE_KEY\x10\x05\x12\x0f\n\x0bP_HEARTBEAT\x10\x06\x12\x0b\n\x07P_LOGIN\x10\x07\x12\x0c\n\x08P_LOGOUT\x10\x08\x12\x0c\n\x08KICK_OFF\x10\x33\x12\x0c\n\x08SHUTDOWN\x10\x34\x12\x0e\n\nP_KICK_OFF\x10\x35\x12\x0e\n\nROOM_ENTER\x10<\x12\x0e\n\nROOM_LEAVE\x10=\x12\x0e\n\nROOM_CLOSE\x10>\x12\x0c\n\x08ROOM_MSG\x10?B6Z4gopkg.mihoyo.com/vila-bot-go/proto/vila_bot;vila_botb\x06proto3', # noqa: E501 19 | ) 20 | 21 | _globals = globals() 22 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 23 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "command_pb2", _globals) 24 | if _descriptor._USE_C_DESCRIPTORS is False: 25 | _globals["DESCRIPTOR"]._options = None 26 | _globals[ 27 | "DESCRIPTOR" 28 | ]._serialized_options = b"Z4gopkg.mihoyo.com/vila-bot-go/proto/vila_bot;vila_bot" 29 | _globals["_PLOGIN_METAENTRY"]._options = None 30 | _globals["_PLOGIN_METAENTRY"]._serialized_options = b"8\001" 31 | _globals["_COMMAND"]._serialized_start = 644 32 | _globals["_COMMAND"]._serialized_end = 892 33 | _globals["_PHEARTBEAT"]._serialized_start = 27 34 | _globals["_PHEARTBEAT"]._serialized_end = 65 35 | _globals["_PHEARTBEATREPLY"]._serialized_start = 67 36 | _globals["_PHEARTBEATREPLY"]._serialized_end = 124 37 | _globals["_PLOGIN"]._serialized_start = 127 38 | _globals["_PLOGIN"]._serialized_end = 319 39 | _globals["_PLOGIN_METAENTRY"]._serialized_start = 276 40 | _globals["_PLOGIN_METAENTRY"]._serialized_end = 319 41 | _globals["_PLOGINREPLY"]._serialized_start = 321 42 | _globals["_PLOGINREPLY"]._serialized_end = 404 43 | _globals["_PLOGOUT"]._serialized_start = 406 44 | _globals["_PLOGOUT"]._serialized_end = 497 45 | _globals["_PLOGOUTREPLY"]._serialized_start = 499 46 | _globals["_PLOGOUTREPLY"]._serialized_end = 557 47 | _globals["_COMMONREPLY"]._serialized_start = 559 48 | _globals["_COMMONREPLY"]._serialized_end = 599 49 | _globals["_PKICKOFF"]._serialized_start = 601 50 | _globals["_PKICKOFF"]._serialized_end = 641 51 | # @@protoc_insertion_point(module_scope) 52 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/payload.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | import struct 3 | from typing import Literal 4 | 5 | from google.protobuf.json_format import MessageToDict, Parse 6 | from pydantic import BaseModel 7 | 8 | from .pb.command_pb2 import ( 9 | PHeartBeat, # type: ignore 10 | PHeartBeatReply, # type: ignore 11 | PKickOff, # type: ignore 12 | PLogin, # type: ignore 13 | PLoginReply, # type: ignore 14 | PLogout, # type: ignore 15 | PLogoutReply, # type: ignore 16 | ) 17 | from .pb.model_pb2 import RobotEvent as PRobotEvent # type: ignore 18 | 19 | 20 | class BizType(IntEnum): 21 | UNKNOWN = 0 22 | EXCHANGE_KEY = 1 23 | HEARTBEAT = 2 24 | LOGIN = 3 25 | LOGOUT = 4 26 | P_EXCHANGE_KEY = 5 27 | P_HEARTBEAT = 6 28 | P_LOGIN = 7 29 | P_LOGOUT = 8 30 | KICK_OFF = 51 31 | SHUTDOWN = 52 32 | P_KICK_OFF = 53 33 | ROOM_ENTER = 60 34 | ROOM_LEAVE = 61 35 | ROOM_CLOSE = 62 36 | ROOM_MSG = 63 37 | EVENT = 30001 38 | 39 | 40 | class Payload(BaseModel): 41 | id: int 42 | flag: Literal[1, 2] 43 | biz_type: BizType 44 | app_id: Literal[104] = 104 45 | body_data: bytes 46 | 47 | @classmethod 48 | def from_bytes(cls, data: bytes): 49 | magic, data_len = struct.unpack(" bytes: 64 | changeable = ( 65 | struct.pack( 66 | " bytes: 93 | return Payload( 94 | id=id, 95 | flag=1, 96 | biz_type=BizType.P_HEARTBEAT, 97 | body_data=Parse(self.json(), PHeartBeat()).SerializeToString(), 98 | ).to_bytes() 99 | 100 | 101 | class HeartBeatReply(BaseModel): 102 | """心跳返回""" 103 | 104 | # 错误码 非0表示失败 105 | code: int = 0 106 | # 服务端时间戳,精确到ms 107 | server_timestamp: int 108 | 109 | @classmethod 110 | def from_proto(cls, content: bytes) -> "HeartBeatReply": 111 | return cls.parse_obj( 112 | MessageToDict( 113 | PHeartBeatReply().FromString(content), 114 | preserving_proto_field_name=True, 115 | use_integers_for_enums=True, 116 | ), 117 | ) 118 | 119 | 120 | class Login(BaseModel): 121 | """登录命令""" 122 | 123 | # 长连接侧唯一id,uint64格式 124 | uid: int 125 | # 用于业务后端验证的token 126 | token: str 127 | # 客户端操作平台枚举 128 | platform: int 129 | # 业务所在客户端应用标识,用于在同一个客户端隔离不同业务的长连接通道。 130 | app_id: int 131 | device_id: str 132 | # # 区域划分字段,通过uid+app_id+platform+region四个字段唯一确定一条长连接 133 | ## region: str 134 | # # 长连内部的扩展字段,是个map 135 | # meta: Dict[str, str] 136 | 137 | def to_bytes_package(self, id: int) -> bytes: 138 | return Payload( 139 | id=id, 140 | flag=1, 141 | biz_type=BizType.P_LOGIN, 142 | body_data=Parse(self.json(), PLogin()).SerializeToString(), 143 | ).to_bytes() 144 | 145 | 146 | class LoginReply(BaseModel): 147 | """登录命令返回""" 148 | 149 | # 错误码 非0表示失败 150 | code: int = 0 151 | # 错误信息 152 | msg: str = "" 153 | # 服务端时间戳,精确到ms 154 | server_timestamp: int 155 | # 唯一连接ID 156 | conn_id: int 157 | 158 | @classmethod 159 | def from_proto(cls, content: bytes) -> "LoginReply": 160 | return cls.parse_obj( 161 | MessageToDict( 162 | PLoginReply().FromString(content), 163 | preserving_proto_field_name=True, 164 | use_integers_for_enums=True, 165 | ), 166 | ) 167 | 168 | 169 | class Logout(BaseModel): 170 | """登出命令字""" 171 | 172 | # 长连接侧唯一id,uint64格式 173 | uid: int 174 | # 客户端操作平台枚举 175 | platform: int 176 | # 业务所在客户端应用标识,用于在同一个客户端隔离不同业务的长连接通道。 177 | app_id: int 178 | # 客户端设备唯一标识 179 | device_id: str 180 | # 区域划分字段,通过uid+app_id+platform+region四个字段唯一确定一条长连接 181 | ## region: str 182 | 183 | def to_bytes_package(self, id: int) -> bytes: 184 | return Payload( 185 | id=id, 186 | flag=1, 187 | biz_type=BizType.P_LOGOUT, 188 | body_data=Parse(self.json(), PLogout()).SerializeToString(), 189 | ).to_bytes() 190 | 191 | 192 | class LogoutReply(BaseModel): 193 | """登出命令返回""" 194 | 195 | # 错误码 非0表示失败 196 | code: int = 0 197 | # 错误信息 198 | msg: str = "" 199 | # 连接id 200 | conn_id: int 201 | 202 | @classmethod 203 | def from_proto(cls, content: bytes) -> "LogoutReply": 204 | return cls.parse_obj( 205 | MessageToDict( 206 | PLogoutReply().FromString(content), 207 | preserving_proto_field_name=True, 208 | use_integers_for_enums=True, 209 | ), 210 | ) 211 | 212 | 213 | class KickOff(BaseModel): 214 | """踢出连接协议""" 215 | 216 | # 踢出原因状态码 217 | code: int = 0 218 | # 状态码对应的文案 219 | reason: str = "" 220 | 221 | @classmethod 222 | def from_proto(cls, content: bytes) -> "KickOff": 223 | return cls.parse_obj( 224 | MessageToDict( 225 | PKickOff().FromString(content), 226 | preserving_proto_field_name=True, 227 | use_integers_for_enums=True, 228 | ), 229 | ) 230 | 231 | 232 | class Shutdown(BaseModel): 233 | """服务关机""" 234 | 235 | 236 | def proto_to_event_data(content: bytes): 237 | return MessageToDict( 238 | PRobotEvent().FromString(content), 239 | preserving_proto_field_name=True, 240 | use_integers_for_enums=True, 241 | ) 242 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/pb/model_pb2.py: -------------------------------------------------------------------------------- 1 | # Generated by the protocol buffer compiler. DO NOT EDIT! 2 | # source: model.proto 3 | # Protobuf Python Version: 4.25.1 4 | """Generated protocol buffer code.""" 5 | from google.protobuf import ( 6 | descriptor as _descriptor, 7 | descriptor_pool as _descriptor_pool, 8 | symbol_database as _symbol_database, 9 | ) 10 | from google.protobuf.internal import builder as _builder 11 | 12 | # @@protoc_insertion_point(imports) 13 | 14 | _sym_db = _symbol_database.Default() 15 | 16 | 17 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 18 | b'\n\x0bmodel.proto\x12\x08vila_bot"\xf8\x02\n\rRobotTemplate\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x65sc\x18\x03 \x01(\t\x12\x0c\n\x04icon\x18\x04 \x01(\t\x12\x31\n\x08\x63ommands\x18\x05 \x03(\x0b\x32\x1f.vila_bot.RobotTemplate.Command\x12>\n\x0f\x63ustom_settings\x18\x06 \x03(\x0b\x32%.vila_bot.RobotTemplate.CustomSetting\x12%\n\x1dis_allowed_add_to_other_villa\x18\x07 \x01(\x08\x1a\x15\n\x05Param\x12\x0c\n\x04\x64\x65sc\x18\x01 \x01(\t\x1aT\n\x07\x43ommand\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x65sc\x18\x02 \x01(\t\x12-\n\x06params\x18\x03 \x03(\x0b\x32\x1d.vila_bot.RobotTemplate.Param\x1a*\n\rCustomSetting\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t"D\n\x05Robot\x12)\n\x08template\x18\x01 \x01(\x0b\x32\x17.vila_bot.RobotTemplate\x12\x10\n\x08villa_id\x18\x02 \x01(\x04"\xc7\x01\n\x10QuoteMessageInfo\x12\x0f\n\x07\x63ontent\x18\x01 \x01(\t\x12\x0f\n\x07msg_uid\x18\x02 \x01(\t\x12\x0f\n\x07send_at\x18\x03 \x01(\x03\x12\x10\n\x08msg_type\x18\x04 \x01(\t\x12\x12\n\nbot_msg_id\x18\x05 \x01(\t\x12\x14\n\x0c\x66rom_user_id\x18\x06 \x01(\x04\x12\x18\n\x10\x66rom_user_id_str\x18\x07 \x01(\t\x12\x1a\n\x12\x66rom_user_nickname\x18\x08 \x01(\t\x12\x0e\n\x06images\x18\t \x03(\t"\xbd\x0f\n\nRobotEvent\x12\x1e\n\x05robot\x18\x01 \x01(\x0b\x32\x0f.vila_bot.Robot\x12,\n\x04type\x18\x02 \x01(\x0e\x32\x1e.vila_bot.RobotEvent.EventType\x12\x34\n\x0b\x65xtend_data\x18\x03 \x01(\x0b\x32\x1f.vila_bot.RobotEvent.ExtendData\x12\x12\n\ncreated_at\x18\x04 \x01(\x03\x12\n\n\x02id\x18\x05 \x01(\t\x12\x0f\n\x07send_at\x18\x06 \x01(\x03\x1a\xcf\x0c\n\nExtendData\x12\x43\n\njoin_villa\x18\x01 \x01(\x0b\x32-.vila_bot.RobotEvent.ExtendData.JoinVillaInfoH\x00\x12G\n\x0csend_message\x18\x02 \x01(\x0b\x32/.vila_bot.RobotEvent.ExtendData.SendMessageInfoH\x00\x12G\n\x0c\x63reate_robot\x18\x03 \x01(\x0b\x32/.vila_bot.RobotEvent.ExtendData.CreateRobotInfoH\x00\x12G\n\x0c\x64\x65lete_robot\x18\x04 \x01(\x0b\x32/.vila_bot.RobotEvent.ExtendData.DeleteRobotInfoH\x00\x12R\n\x12\x61\x64\x64_quick_emoticon\x18\x05 \x01(\x0b\x32\x34.vila_bot.RobotEvent.ExtendData.AddQuickEmoticonInfoH\x00\x12K\n\x0e\x61udit_callback\x18\x06 \x01(\x0b\x32\x31.vila_bot.RobotEvent.ExtendData.AuditCallbackInfoH\x00\x12T\n\x13\x63lick_msg_component\x18\x07 \x01(\x0b\x32\x35.vila_bot.RobotEvent.ExtendData.ClickMsgComponentInfoH\x00\x1a`\n\rJoinVillaInfo\x12\x10\n\x08join_uid\x18\x01 \x01(\x04\x12\x1a\n\x12join_user_nickname\x18\x02 \x01(\t\x12\x0f\n\x07join_at\x18\x03 \x01(\x03\x12\x10\n\x08villa_id\x18\x04 \x01(\x04\x1a\xfd\x01\n\x0fSendMessageInfo\x12\x0f\n\x07\x63ontent\x18\x01 \x01(\t\x12\x14\n\x0c\x66rom_user_id\x18\x02 \x01(\x04\x12\x0f\n\x07send_at\x18\x03 \x01(\x03\x12)\n\x0bobject_name\x18\x04 \x01(\x0e\x32\x14.vila_bot.ObjectName\x12\x0f\n\x07room_id\x18\x05 \x01(\x04\x12\x10\n\x08nickname\x18\x06 \x01(\t\x12\x0f\n\x07msg_uid\x18\x07 \x01(\t\x12\x12\n\nbot_msg_id\x18\x08 \x01(\t\x12\x10\n\x08villa_id\x18\t \x01(\x04\x12-\n\tquote_msg\x18\n \x01(\x0b\x32\x1a.vila_bot.QuoteMessageInfo\x1a#\n\x0f\x43reateRobotInfo\x12\x10\n\x08villa_id\x18\x01 \x01(\x04\x1a#\n\x0f\x44\x65leteRobotInfo\x12\x10\n\x08villa_id\x18\x01 \x01(\x04\x1a\xbc\x01\n\x14\x41\x64\x64QuickEmoticonInfo\x12\x10\n\x08villa_id\x18\x01 \x01(\x04\x12\x0f\n\x07room_id\x18\x02 \x01(\x04\x12\x0b\n\x03uid\x18\x03 \x01(\x04\x12\x13\n\x0b\x65moticon_id\x18\x04 \x01(\r\x12\x10\n\x08\x65moticon\x18\x05 \x01(\t\x12\x0f\n\x07msg_uid\x18\x06 \x01(\t\x12\x11\n\tis_cancel\x18\x07 \x01(\x08\x12\x12\n\nbot_msg_id\x18\x08 \x01(\t\x12\x15\n\remoticon_type\x18\t \x01(\r\x1a\x87\x02\n\x11\x41uditCallbackInfo\x12\x10\n\x08\x61udit_id\x18\x01 \x01(\t\x12\x12\n\nbot_tpl_id\x18\x02 \x01(\t\x12\x10\n\x08villa_id\x18\x03 \x01(\x04\x12\x0f\n\x07room_id\x18\x04 \x01(\x04\x12\x0f\n\x07user_id\x18\x05 \x01(\x04\x12\x14\n\x0cpass_through\x18\x06 \x01(\t\x12S\n\x0c\x61udit_result\x18\x07 \x01(\x0e\x32=.vila_bot.RobotEvent.ExtendData.AuditCallbackInfo.AuditResult"-\n\x0b\x41uditResult\x12\x08\n\x04None\x10\x00\x12\x08\n\x04Pass\x10\x01\x12\n\n\x06Reject\x10\x02\x1a\xa6\x01\n\x15\x43lickMsgComponentInfo\x12\x10\n\x08villa_id\x18\x01 \x01(\x04\x12\x0f\n\x07room_id\x18\x02 \x01(\x04\x12\x14\n\x0c\x63omponent_id\x18\x03 \x01(\t\x12\x0f\n\x07msg_uid\x18\x04 \x01(\t\x12\x0b\n\x03uid\x18\x05 \x01(\x04\x12\x12\n\nbot_msg_id\x18\x06 \x01(\t\x12\x13\n\x0btemplate_id\x18\x07 \x01(\x04\x12\r\n\x05\x65xtra\x18\x08 \x01(\tB\x0c\n\nevent_data"\xa7\x01\n\tEventType\x12\x18\n\x14UnknowRobotEventType\x10\x00\x12\r\n\tJoinVilla\x10\x01\x12\x0f\n\x0bSendMessage\x10\x02\x12\x0f\n\x0b\x43reateRobot\x10\x03\x12\x0f\n\x0b\x44\x65leteRobot\x10\x04\x12\x14\n\x10\x41\x64\x64QuickEmoticon\x10\x05\x12\x11\n\rAuditCallback\x10\x06\x12\x15\n\x11\x43lickMsgComponent\x10\x07*b\n\x08RoomType\x12\x13\n\x0fRoomTypeInvalid\x10\x00\x12\x14\n\x10RoomTypeChatRoom\x10\x01\x12\x14\n\x10RoomTypePostRoom\x10\x02\x12\x15\n\x11RoomTypeSceneRoom\x10\x03*6\n\nObjectName\x12\x14\n\x10UnknowObjectName\x10\x00\x12\x08\n\x04Text\x10\x01\x12\x08\n\x04Post\x10\x02\x42\x36Z4gopkg.mihoyo.com/vila-bot-go/proto/vila_bot;vila_botb\x06proto3', # noqa: E501 19 | ) 20 | 21 | _globals = globals() 22 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 23 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "model_pb2", _globals) 24 | if _descriptor._USE_C_DESCRIPTORS is False: 25 | _globals["DESCRIPTOR"]._options = None 26 | _globals[ 27 | "DESCRIPTOR" 28 | ]._serialized_options = b"Z4gopkg.mihoyo.com/vila-bot-go/proto/vila_bot;vila_bot" 29 | _globals["_ROOMTYPE"]._serialized_start = 2660 30 | _globals["_ROOMTYPE"]._serialized_end = 2758 31 | _globals["_OBJECTNAME"]._serialized_start = 2760 32 | _globals["_OBJECTNAME"]._serialized_end = 2814 33 | _globals["_ROBOTTEMPLATE"]._serialized_start = 26 34 | _globals["_ROBOTTEMPLATE"]._serialized_end = 402 35 | _globals["_ROBOTTEMPLATE_PARAM"]._serialized_start = 251 36 | _globals["_ROBOTTEMPLATE_PARAM"]._serialized_end = 272 37 | _globals["_ROBOTTEMPLATE_COMMAND"]._serialized_start = 274 38 | _globals["_ROBOTTEMPLATE_COMMAND"]._serialized_end = 358 39 | _globals["_ROBOTTEMPLATE_CUSTOMSETTING"]._serialized_start = 360 40 | _globals["_ROBOTTEMPLATE_CUSTOMSETTING"]._serialized_end = 402 41 | _globals["_ROBOT"]._serialized_start = 404 42 | _globals["_ROBOT"]._serialized_end = 472 43 | _globals["_QUOTEMESSAGEINFO"]._serialized_start = 475 44 | _globals["_QUOTEMESSAGEINFO"]._serialized_end = 674 45 | _globals["_ROBOTEVENT"]._serialized_start = 677 46 | _globals["_ROBOTEVENT"]._serialized_end = 2658 47 | _globals["_ROBOTEVENT_EXTENDDATA"]._serialized_start = 873 48 | _globals["_ROBOTEVENT_EXTENDDATA"]._serialized_end = 2488 49 | _globals["_ROBOTEVENT_EXTENDDATA_JOINVILLAINFO"]._serialized_start = 1422 50 | _globals["_ROBOTEVENT_EXTENDDATA_JOINVILLAINFO"]._serialized_end = 1518 51 | _globals["_ROBOTEVENT_EXTENDDATA_SENDMESSAGEINFO"]._serialized_start = 1521 52 | _globals["_ROBOTEVENT_EXTENDDATA_SENDMESSAGEINFO"]._serialized_end = 1774 53 | _globals["_ROBOTEVENT_EXTENDDATA_CREATEROBOTINFO"]._serialized_start = 1776 54 | _globals["_ROBOTEVENT_EXTENDDATA_CREATEROBOTINFO"]._serialized_end = 1811 55 | _globals["_ROBOTEVENT_EXTENDDATA_DELETEROBOTINFO"]._serialized_start = 1813 56 | _globals["_ROBOTEVENT_EXTENDDATA_DELETEROBOTINFO"]._serialized_end = 1848 57 | _globals["_ROBOTEVENT_EXTENDDATA_ADDQUICKEMOTICONINFO"]._serialized_start = 1851 58 | _globals["_ROBOTEVENT_EXTENDDATA_ADDQUICKEMOTICONINFO"]._serialized_end = 2039 59 | _globals["_ROBOTEVENT_EXTENDDATA_AUDITCALLBACKINFO"]._serialized_start = 2042 60 | _globals["_ROBOTEVENT_EXTENDDATA_AUDITCALLBACKINFO"]._serialized_end = 2305 61 | _globals[ 62 | "_ROBOTEVENT_EXTENDDATA_AUDITCALLBACKINFO_AUDITRESULT" 63 | ]._serialized_start = 2260 64 | _globals[ 65 | "_ROBOTEVENT_EXTENDDATA_AUDITCALLBACKINFO_AUDITRESULT" 66 | ]._serialized_end = 2305 67 | _globals["_ROBOTEVENT_EXTENDDATA_CLICKMSGCOMPONENTINFO"]._serialized_start = 2308 68 | _globals["_ROBOTEVENT_EXTENDDATA_CLICKMSGCOMPONENTINFO"]._serialized_end = 2474 69 | _globals["_ROBOTEVENT_EVENTTYPE"]._serialized_start = 2491 70 | _globals["_ROBOTEVENT_EVENTTYPE"]._serialized_end = 2658 71 | # @@protoc_insertion_point(module_scope) 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | nonebot-adapter-villa 3 |

4 | 5 |
6 | 7 | # NoneBot-Adapter-Villa 8 | 9 | _✨ 大别野 协议适配 ✨_ 10 | 11 | 12 | license 13 | 14 | version 15 | python 16 | 17 | pypi download 18 | 19 | 20 | wakatime 21 | 22 | 23 | ruff 24 | 25 | 26 |
27 | 28 | ## 安装 29 | 30 | 在 `nb create` 创建项目时选择 `Villa` 适配器 31 | 32 | 或在现有 `NoneBot2` 项目目录下使用脚手架安装: 33 | 34 | ``` 35 | nb adapter install nonebot-adapter-villa 36 | ``` 37 | 38 | ## 配置 39 | 40 | 修改 NoneBot 配置文件 `.env` 或者 `.env.*`。 41 | 42 | 配置 Bot 帐号列表,每个 bot 有 3 个必填配置,可前往[「大别野开放平台」](https://open.miyoushe.com/#/login)申请,取得以下配置: 43 | 44 | - `bot_id`: 机器人 id,以`bot_`开头 45 | - `bot_secret`: 机器人密钥 46 | - `pub_key`: 加密和验证所需的 pub_key (请使用开放平台中的复制按钮而不是手动复制) 47 | - `test_villa_id`: Bot 未上线时填写调试别野的 id,已上线可不填或填 `0` 48 | 49 | 此外,还要根据连接方式填写额外配置: 50 | 51 | - `connection_type`: 连接方式,填写 `webhook` 或 `websocket`,默认为 `webhook` 52 | 53 | ### Webhook 连接 54 | 55 | - `callback_url`: http 回调地址 endpoint,例如开放平台中回调地址是`http://域名/your/callback/url`,那么配置里的 `callback_url` 填写 `/your/callback/url` 56 | - `verify_event`:是否对回调事件签名进行验证,默认为 `True` 57 | 58 | 使用 webhook 方式连接时,驱动器需要 `ReverseDriver` 和 `ForwardDriver`,参考 [driver](https://v2.nonebot.dev/docs/next/advanced/driver#%E9%A9%B1%E5%8A%A8%E5%99%A8%E7%B1%BB%E5%9E%8B) 配置项。 59 | 60 | webhook 配置完整示例: 61 | 62 | ```dotenv 63 | DRIVER=~fastapi+~httpx 64 | VILLA_BOTS=' 65 | [ 66 | { 67 | "bot_id": "bot_123456789", 68 | "bot_secret": "abc123def456", 69 | "pub_key": "-----BEGIN PUBLIC KEY-----\nyour_pub_key\n-----END PUBLIC KEY-----\n", 70 | "connection_type": "webhook", 71 | "callback_url": "/your/callback/url", 72 | "verify_event": true 73 | } 74 | ] 75 | ' 76 | ``` 77 | 78 | ### Websocket 连接 (官方测试中) 79 | 80 | 使用 websocket 方式连接时,驱动器需要 `ForwardDriver`,参考 [driver](https://v2.nonebot.dev/docs/next/advanced/driver#%E9%A9%B1%E5%8A%A8%E5%99%A8%E7%B1%BB%E5%9E%8B) 配置项。 81 | 82 | websocket 配置完整示例: 83 | 84 | ```dotenv 85 | DRIVER=~httpx+~websockets 86 | VILLA_BOTS=' 87 | [ 88 | { 89 | "bot_id": "bot_123456789", 90 | "bot_secret": "abc123def456", 91 | "pub_key": "-----BEGIN PUBLIC KEY-----\nyour_pub_key\n-----END PUBLIC KEY-----\n", 92 | "connection_type": "websocket", 93 | "test_villa_id": 0 94 | } 95 | ] 96 | ' 97 | ``` 98 | 99 | ## 已支持消息段 100 | 101 | - `MessageSegment.text`: 纯文本 102 | + 米游社自带表情也是用text来发送,以[表情名]格式,例如:`MessageSegment.text("[爱心]")` 103 | + 支持样式,,可叠加: 104 | + `bold`: 加粗 105 | + `italic`: 斜体 106 | + `underline`: 下划线 107 | + `strikethrough`: 删除线 108 | + 例如:`MessageSegment.text("加粗", blod=True) + MessageSegment.text("斜体", italic=True)` 109 | - `MessageSegment.mention_robot`: @机器人 110 | - `MessageSegment.mention_user`: @用户 111 | + `user_name` 和 `villa_id` 必须给出其中之一,给 `villa_id` 时,调用 api 来获取用户名 112 | - `MessageSegment.mention_all`: @全体成员 113 | - `MessageSegment.room_link`: #房间跳转链接 114 | - `MessageSegment.link`: 超链接 115 | + 使用 link 的话链接能够点击进行跳转,使用 text 的话不能点击 116 | + 字段 `show_text` 是指链接显示的文字,但若指定了该字段,Web 端大别野会无法正常跳转 117 | + 字段 `requires_bot_access_token` 为 True 时,跳转链接会带上含有用户信息的 token 118 | - `MessageSegment.quote`: 引用(回复)消息 119 | + 不能**单独**使用,要与其他消息段一起使用 120 | - `MessageSegment.image`: URL 图片 121 | + 图片 url 需为米哈游官方图床 url 122 | + 非官方图床 url 可以通过 `Bot.transfer_image` 接口转换为官方图床 url 123 | + 本地图片可以通过 `Bot.upload_image` 接口来上传图片,使用返回结果的 url 来发送 124 | + 一条消息只能发送一张图片,多张图片拼接时,只会发送最后一张 125 | + 与其他消息段拼接时,将无法在 web 端显示出来 126 | - `MessageSegment.post`: 米游社帖子 127 | + 只能单独发送,与其他消息段拼接时将会被忽略 128 | - `MessageSegment.preview_link`: 预览链接(卡片) 129 | + 该消息段未在官方文档公开 130 | + 无法在 web 端显示出来 131 | - `MessageSegment.badge`: 消息徽标(消息下方的可链接跳转的下标) 132 | + 该消息段未在官方文档公开 133 | + 不能**单独**使用,要与其他消息段一起使用 134 | + 无法在 web 端显示出来 135 | - 消息组件,有两种构造方式: 136 | + `MessageSegment.components`:传入若干个 component,适配器会自动根据组件显示的文本长度来计算组件面板布局(推荐) 137 | + `MessageSegment.panel`:传入一个组件模板 ID 或自己构造好的自定义组件面板布局 `Panel` 对象 138 | 139 | 140 | 141 | 以下是一个简单的插件示例,展示各种消息段: 142 | 143 | ```python 144 | from nonebot import on_command 145 | from nonebot.params import CommandArg 146 | from nonebot.adapters.villa import MessageSegment, SendMessageEvent, Message 147 | 148 | matcher = on_command("test") 149 | 150 | 151 | @matcher.handle() 152 | async def _(event: SendMessageEvent, args: Message = CommandArg()): 153 | arg = args.extract_plain_text().strip() 154 | if arg == "纯文本": 155 | msg = MessageSegment.text(text="这是一段纯文本") 156 | elif arg == "艾特bot": 157 | msg = MessageSegment.mention_robot( 158 | bot_id=event.robot.template.id, bot_name=event.robot.template.name 159 | ) 160 | elif arg == "艾特我": 161 | msg = MessageSegment.mention_user( 162 | user_id=event.from_user_id, villa_id=event.villa_id 163 | ) 164 | elif arg == "艾特全体": 165 | msg = MessageSegment.mention_all() 166 | elif arg == "房间链接": 167 | msg = MessageSegment.room_link(villa_id=event.villa_id, room_id=event.room_id) 168 | elif arg == "超链接": 169 | msg = MessageSegment.link( 170 | url="https://www.baidu.com", show_text="百度", requires_bot_access_token=False 171 | ) 172 | elif arg == "引用消息": 173 | msg = MessageSegment.quote(event.msg_uid, event.send_at) + MessageSegment.text( 174 | text="引用原消息" 175 | ) 176 | elif arg == "图片": 177 | msg = MessageSegment.image( 178 | url="https://www.miyoushe.com/_nuxt/img/miHoYo_Game.2457753.png" 179 | ) 180 | elif arg == "帖子": 181 | msg = MessageSegment.post( 182 | post_id="https://www.miyoushe.com/ys/article/40391314" 183 | ) 184 | elif arg == "预览链接": 185 | msg = MessageSegment.preview_link( 186 | icon_url="https://www.bilibili.com/favicon.ico", 187 | image_url="https://i2.hdslb.com/bfs/archive/21b82856df6b8a2ae759dddac66e2c79d41fe6bc.jpg@672w_378h_1c_!web-home-common-cover.avif", 188 | is_internal_link=False, 189 | title="崩坏3第一偶像爱酱", 190 | content="「海的女儿」——《崩坏3》S级律者角色「死生之律者」宣传PV", 191 | url="https://www.bilibili.com/video/BV1Mh4y1M79t?spm_id_from=333.1007.tianma.2-2-5.click", 192 | source_name="哔哩哔哩", 193 | ) 194 | elif arg == "徽标消息": 195 | msg = MessageSegment.badge( 196 | icon_url="https://upload-bbs.mihoyo.com/vila_bot/bbs_origin_badge.png", 197 | text="徽标", 198 | url="https://mihoyo.com", 199 | ) + MessageSegment.text(text="带有徽标的消息") 200 | else: 201 | return 202 | 203 | await matcher.finish(msg) 204 | 205 | ``` 206 | 使用命令`@bot /test 纯文本`时,bot会回复`这是一段纯文本` 207 | 208 | 209 | 关于消息组件的详细介绍: 210 | 211 | ```python 212 | from nonebot.adapters.villa.message import MessageSegment 213 | from nonebot.adapters.villa.models import CallbackButton, InputButton, LinkButton, Panel 214 | 215 | # 目前有三种按钮组件,每个组件都必须有 id 和 text 字段 216 | # id字段用于标识组件,必须是唯一的,text字段用于显示组件的文本 217 | # need_callback字段如果设为True,表示点击按钮后会触发ClickMsgComponentEvent事件回调 218 | # extra字段用于开发者自定义,可以在事件回调中获取到 219 | 220 | # 文本按钮 221 | # 用户点击后,会将按钮的 input 字段内容填充到用户的输入框中 222 | input_button = InputButton( 223 | id="1", 224 | text="文本按钮", 225 | input="/文本", 226 | ) 227 | 228 | # 回调按钮 229 | # need_callback恒为True,点击按钮后会触发ClickMsgComponentEvent事件回调 230 | callback_button = CallbackButton( 231 | id="2", 232 | text="回调按钮", 233 | ) 234 | 235 | # 链接按钮 236 | # 用户点击后,会跳转到按钮的 link 字段所指定的链接 237 | # need_token字段为True时,会在链接中附带token参数,用于验证用户身份 238 | link_button = LinkButton( 239 | id="3", 240 | text="链接按钮", 241 | link="https://www.baidu.com", 242 | need_token=True, 243 | ) 244 | 245 | # 构造消息段 246 | com_msg = MessageSegment.components( 247 | input_button, 248 | callback_button, 249 | link_button, 250 | ) 251 | 252 | # 适配器会自动根据每个组件的text字段的长度,自动调整按钮的排版 253 | # 如果需要自定义排版,可以使用MessageSegment.panel方法 254 | # 自己构造好Panel对象,传入 255 | panel = Panel(mid_component_group_list=[[input_button, callback_button], [link_button]]) 256 | com_msg = MessageSegment.panel(panel) 257 | 258 | # 如果有预先设置好的消息组件模板 ID,可以直接使用 259 | template_id = 123456 260 | com_msg = MessageSegment.panel(template_id) 261 | # 模板通过 Bot.create_component_template 接口来创建 262 | 263 | # 如果有多个 MessageSegment.panel 相加,则只会发送最后一个 264 | ``` 265 | 266 | 267 | ## 交流、建议和反馈 268 | 269 | 如遇问题请提出 [issue](https://github.com/CMHopeSunshine/nonebot-adapter-villa/issues) ,感谢支持! 270 | 271 | 欢迎来开发者的大别野[「尘世闲游」]((https://dby.miyoushe.com/chat/1047/21652))(ID: `wgiJNaU`)进行交流~ 272 | 273 | ## 相关项目 274 | 275 | - [NoneBot2](https://github.com/nonebot/nonebot2): 非常好用的 Python 跨平台机器人框架! 276 | - [villa-py](https://github.com/CMHopeSunshine/villa-py): 大别野 Bot Python SDK(暂时停更)。 277 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ----- Project ----- 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/python,node,visualstudiocode,jetbrains,macos,windows,linux 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,node,visualstudiocode,jetbrains,macos,windows,linux 5 | 6 | ### JetBrains ### 7 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 8 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 9 | 10 | # User-specific stuff 11 | .idea/**/workspace.xml 12 | .idea/**/tasks.xml 13 | .idea/**/usage.statistics.xml 14 | .idea/**/dictionaries 15 | .idea/**/shelf 16 | 17 | # AWS User-specific 18 | .idea/**/aws.xml 19 | 20 | # Generated files 21 | .idea/**/contentModel.xml 22 | 23 | # Sensitive or high-churn files 24 | .idea/**/dataSources/ 25 | .idea/**/dataSources.ids 26 | .idea/**/dataSources.local.xml 27 | .idea/**/sqlDataSources.xml 28 | .idea/**/dynamic.xml 29 | .idea/**/uiDesigner.xml 30 | .idea/**/dbnavigator.xml 31 | 32 | # Gradle 33 | .idea/**/gradle.xml 34 | .idea/**/libraries 35 | 36 | # Gradle and Maven with auto-import 37 | # When using Gradle or Maven with auto-import, you should exclude module files, 38 | # since they will be recreated, and may cause churn. Uncomment if using 39 | # auto-import. 40 | # .idea/artifacts 41 | # .idea/compiler.xml 42 | # .idea/jarRepositories.xml 43 | # .idea/modules.xml 44 | # .idea/*.iml 45 | # .idea/modules 46 | # *.iml 47 | # *.ipr 48 | 49 | # CMake 50 | cmake-build-*/ 51 | 52 | # Mongo Explorer plugin 53 | .idea/**/mongoSettings.xml 54 | 55 | # File-based project format 56 | *.iws 57 | 58 | # IntelliJ 59 | out/ 60 | 61 | # mpeltonen/sbt-idea plugin 62 | .idea_modules/ 63 | 64 | # JIRA plugin 65 | atlassian-ide-plugin.xml 66 | 67 | # Cursive Clojure plugin 68 | .idea/replstate.xml 69 | 70 | # SonarLint plugin 71 | .idea/sonarlint/ 72 | 73 | # Crashlytics plugin (for Android Studio and IntelliJ) 74 | com_crashlytics_export_strings.xml 75 | crashlytics.properties 76 | crashlytics-build.properties 77 | fabric.properties 78 | 79 | # Editor-based Rest Client 80 | .idea/httpRequests 81 | 82 | # Android studio 3.1+ serialized cache file 83 | .idea/caches/build_file_checksums.ser 84 | 85 | ### JetBrains Patch ### 86 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 87 | 88 | # *.iml 89 | # modules.xml 90 | # .idea/misc.xml 91 | # *.ipr 92 | 93 | # Sonarlint plugin 94 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 95 | .idea/**/sonarlint/ 96 | 97 | # SonarQube Plugin 98 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 99 | .idea/**/sonarIssues.xml 100 | 101 | # Markdown Navigator plugin 102 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 103 | .idea/**/markdown-navigator.xml 104 | .idea/**/markdown-navigator-enh.xml 105 | .idea/**/markdown-navigator/ 106 | 107 | # Cache file creation bug 108 | # See https://youtrack.jetbrains.com/issue/JBR-2257 109 | .idea/$CACHE_FILE$ 110 | 111 | # CodeStream plugin 112 | # https://plugins.jetbrains.com/plugin/12206-codestream 113 | .idea/codestream.xml 114 | 115 | # Azure Toolkit for IntelliJ plugin 116 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 117 | .idea/**/azureSettings.xml 118 | 119 | ### Linux ### 120 | *~ 121 | 122 | # temporary files which can be created if a process still has a handle open of a deleted file 123 | .fuse_hidden* 124 | 125 | # KDE directory preferences 126 | .directory 127 | 128 | # Linux trash folder which might appear on any partition or disk 129 | .Trash-* 130 | 131 | # .nfs files are created when an open file is removed but is still being accessed 132 | .nfs* 133 | 134 | ### macOS ### 135 | # General 136 | .DS_Store 137 | .AppleDouble 138 | .LSOverride 139 | 140 | # Icon must end with two \r 141 | Icon 142 | 143 | 144 | # Thumbnails 145 | ._* 146 | 147 | # Files that might appear in the root of a volume 148 | .DocumentRevisions-V100 149 | .fseventsd 150 | .Spotlight-V100 151 | .TemporaryItems 152 | .Trashes 153 | .VolumeIcon.icns 154 | .com.apple.timemachine.donotpresent 155 | 156 | # Directories potentially created on remote AFP share 157 | .AppleDB 158 | .AppleDesktop 159 | Network Trash Folder 160 | Temporary Items 161 | .apdisk 162 | 163 | ### macOS Patch ### 164 | # iCloud generated files 165 | *.icloud 166 | 167 | ### Node ### 168 | # Logs 169 | logs 170 | *.log 171 | npm-debug.log* 172 | yarn-debug.log* 173 | yarn-error.log* 174 | lerna-debug.log* 175 | .pnpm-debug.log* 176 | 177 | # Diagnostic reports (https://nodejs.org/api/report.html) 178 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 179 | 180 | # Runtime data 181 | pids 182 | *.pid 183 | *.seed 184 | *.pid.lock 185 | 186 | # Directory for instrumented libs generated by jscoverage/JSCover 187 | lib-cov 188 | 189 | # Coverage directory used by tools like istanbul 190 | coverage 191 | *.lcov 192 | 193 | # nyc test coverage 194 | .nyc_output 195 | 196 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 197 | .grunt 198 | 199 | # Bower dependency directory (https://bower.io/) 200 | bower_components 201 | 202 | # node-waf configuration 203 | .lock-wscript 204 | 205 | # Compiled binary addons (https://nodejs.org/api/addons.html) 206 | build/Release 207 | 208 | # Dependency directories 209 | node_modules/ 210 | jspm_packages/ 211 | 212 | # Snowpack dependency directory (https://snowpack.dev/) 213 | web_modules/ 214 | 215 | # TypeScript cache 216 | *.tsbuildinfo 217 | 218 | # Optional npm cache directory 219 | .npm 220 | 221 | # Optional eslint cache 222 | .eslintcache 223 | 224 | # Optional stylelint cache 225 | .stylelintcache 226 | 227 | # Microbundle cache 228 | .rpt2_cache/ 229 | .rts2_cache_cjs/ 230 | .rts2_cache_es/ 231 | .rts2_cache_umd/ 232 | 233 | # Optional REPL history 234 | .node_repl_history 235 | 236 | # Output of 'npm pack' 237 | *.tgz 238 | 239 | # Yarn Integrity file 240 | .yarn-integrity 241 | 242 | # dotenv environment variable files 243 | .env 244 | .env.development.local 245 | .env.test.local 246 | .env.production.local 247 | .env.local 248 | 249 | # parcel-bundler cache (https://parceljs.org/) 250 | .cache 251 | .parcel-cache 252 | 253 | # Next.js build output 254 | .next 255 | out 256 | 257 | # Nuxt.js build / generate output 258 | .nuxt 259 | dist 260 | 261 | # Gatsby files 262 | .cache/ 263 | # Comment in the public line in if your project uses Gatsby and not Next.js 264 | # https://nextjs.org/blog/next-9-1#public-directory-support 265 | # public 266 | 267 | # vuepress build output 268 | .vuepress/dist 269 | 270 | # vuepress v2.x temp and cache directory 271 | .temp 272 | 273 | # Docusaurus cache and generated files 274 | .docusaurus 275 | 276 | # Serverless directories 277 | .serverless/ 278 | 279 | # FuseBox cache 280 | .fusebox/ 281 | 282 | # DynamoDB Local files 283 | .dynamodb/ 284 | 285 | # TernJS port file 286 | .tern-port 287 | 288 | # Stores VSCode versions used for testing VSCode extensions 289 | .vscode-test 290 | 291 | # yarn v2 292 | .yarn/cache 293 | .yarn/unplugged 294 | .yarn/build-state.yml 295 | .yarn/install-state.gz 296 | .pnp.* 297 | 298 | ### Node Patch ### 299 | # Serverless Webpack directories 300 | .webpack/ 301 | 302 | # Optional stylelint cache 303 | 304 | # SvelteKit build / generate output 305 | .svelte-kit 306 | 307 | ### Python ### 308 | # Byte-compiled / optimized / DLL files 309 | __pycache__/ 310 | *.py[cod] 311 | *$py.class 312 | 313 | # C extensions 314 | *.so 315 | 316 | # Distribution / packaging 317 | .Python 318 | build/ 319 | develop-eggs/ 320 | dist/ 321 | downloads/ 322 | eggs/ 323 | .eggs/ 324 | lib/ 325 | lib64/ 326 | parts/ 327 | sdist/ 328 | var/ 329 | wheels/ 330 | share/python-wheels/ 331 | *.egg-info/ 332 | .installed.cfg 333 | *.egg 334 | MANIFEST 335 | 336 | # PyInstaller 337 | # Usually these files are written by a python script from a template 338 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 339 | *.manifest 340 | *.spec 341 | 342 | # Installer logs 343 | pip-log.txt 344 | pip-delete-this-directory.txt 345 | 346 | # Unit test / coverage reports 347 | htmlcov/ 348 | .tox/ 349 | .nox/ 350 | .coverage 351 | .coverage.* 352 | nosetests.xml 353 | coverage.xml 354 | *.cover 355 | *.py,cover 356 | .hypothesis/ 357 | .pytest_cache/ 358 | cover/ 359 | 360 | # Translations 361 | *.mo 362 | *.pot 363 | 364 | # Django stuff: 365 | local_settings.py 366 | db.sqlite3 367 | db.sqlite3-journal 368 | 369 | # Flask stuff: 370 | instance/ 371 | .webassets-cache 372 | 373 | # Scrapy stuff: 374 | .scrapy 375 | 376 | # Sphinx documentation 377 | docs/_build/ 378 | 379 | # PyBuilder 380 | .pybuilder/ 381 | target/ 382 | 383 | # Jupyter Notebook 384 | .ipynb_checkpoints 385 | 386 | # IPython 387 | profile_default/ 388 | ipython_config.py 389 | 390 | # pyenv 391 | # For a library or package, you might want to ignore these files since the code is 392 | # intended to run in multiple environments; otherwise, check them in: 393 | # .python-version 394 | 395 | # pipenv 396 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 397 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 398 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 399 | # install all needed dependencies. 400 | #Pipfile.lock 401 | 402 | # poetry 403 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 404 | # This is especially recommended for binary packages to ensure reproducibility, and is more 405 | # commonly ignored for libraries. 406 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 407 | #poetry.lock 408 | 409 | # pdm 410 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 411 | #pdm.lock 412 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 413 | # in version control. 414 | # https://pdm.fming.dev/#use-with-ide 415 | .pdm.toml 416 | 417 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 418 | __pypackages__/ 419 | 420 | # Celery stuff 421 | celerybeat-schedule 422 | celerybeat.pid 423 | 424 | # SageMath parsed files 425 | *.sage.py 426 | 427 | # Environments 428 | .venv 429 | env/ 430 | venv/ 431 | ENV/ 432 | env.bak/ 433 | venv.bak/ 434 | 435 | # Spyder project settings 436 | .spyderproject 437 | .spyproject 438 | 439 | # Rope project settings 440 | .ropeproject 441 | 442 | # mkdocs documentation 443 | /site 444 | 445 | # mypy 446 | .mypy_cache/ 447 | .dmypy.json 448 | dmypy.json 449 | 450 | # Pyre type checker 451 | .pyre/ 452 | 453 | # pytype static type analyzer 454 | .pytype/ 455 | 456 | # Cython debug symbols 457 | cython_debug/ 458 | 459 | # PyCharm 460 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 461 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 462 | # and can be added to the global gitignore or merged into this file. For a more nuclear 463 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 464 | #.idea/ 465 | 466 | ### Python Patch ### 467 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 468 | poetry.toml 469 | 470 | 471 | ### VisualStudioCode ### 472 | .vscode/* 473 | !.vscode/settings.json 474 | !.vscode/tasks.json 475 | !.vscode/launch.json 476 | !.vscode/extensions.json 477 | !.vscode/*.code-snippets 478 | 479 | # Local History for Visual Studio Code 480 | .history/ 481 | 482 | # Built Visual Studio Code Extensions 483 | *.vsix 484 | 485 | ### VisualStudioCode Patch ### 486 | # Ignore all local history of files 487 | .history 488 | .ionide 489 | 490 | ### Windows ### 491 | # Windows thumbnail cache files 492 | Thumbs.db 493 | Thumbs.db:encryptable 494 | ehthumbs.db 495 | ehthumbs_vista.db 496 | 497 | # Dump file 498 | *.stackdump 499 | 500 | # Folder config file 501 | [Dd]esktop.ini 502 | 503 | # Recycle Bin used on file shares 504 | $RECYCLE.BIN/ 505 | 506 | # Windows Installer files 507 | *.cab 508 | *.msi 509 | *.msix 510 | *.msm 511 | *.msp 512 | 513 | # Windows shortcuts 514 | *.lnk 515 | 516 | # End of https://www.toptal.com/developers/gitignore/api/python,node,visualstudiocode,jetbrains,macos,windows,linux 517 | 518 | 519 | # something development 520 | change_role.py 521 | test*.json 522 | test_*.py 523 | example.py 524 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/event.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import IntEnum 3 | import json 4 | from typing import Any, Dict, Literal, Optional, Union 5 | from typing_extensions import Annotated, override 6 | 7 | from nonebot.adapters import Event as BaseEvent 8 | from nonebot.utils import escape_tag 9 | 10 | from pydantic import Field, root_validator 11 | 12 | from .message import Message, MessageSegment 13 | from .models import MessageContentInfoGet, QuoteMessage, Robot 14 | from .utils import pascal_to_snake 15 | 16 | 17 | class EventType(IntEnum): 18 | """事件类型""" 19 | 20 | JoinVilla = 1 21 | SendMessage = 2 22 | CreateRobot = 3 23 | DeleteRobot = 4 24 | AddQuickEmoticon = 5 25 | AuditCallback = 6 26 | ClickMsgComponent = 7 27 | 28 | 29 | class AuditResult(IntEnum): 30 | """审核结果类型""" 31 | 32 | Compatibility = 0 33 | """兼容""" 34 | Pass = 1 35 | """通过""" 36 | Reject = 2 37 | """驳回""" 38 | 39 | 40 | class Event(BaseEvent): 41 | """Villa 事件基类""" 42 | 43 | robot: Robot 44 | """用户机器人访问凭证""" 45 | type: EventType 46 | """事件类型""" 47 | id: str 48 | """事件 id""" 49 | created_at: int 50 | """事件创建时间""" 51 | send_at: int 52 | """事件回调时间""" 53 | 54 | @root_validator(pre=True) 55 | @classmethod 56 | def pre_handle(cls, data: Dict[str, Any]): 57 | extend_data = data.pop("extend_data") 58 | event_type = data["type"] = EventType(data["type"]) 59 | event_name = event_type.name 60 | event_data = extend_data.pop("EventData", extend_data) 61 | if ( 62 | event_name in event_data 63 | or (event_name := pascal_to_snake(event_name)) in event_data 64 | ): 65 | data.update(event_data[event_name]) 66 | else: 67 | raise ValueError(f"Cannot find event data for event type: {event_name}") 68 | 69 | return data 70 | 71 | @property 72 | def bot_id(self) -> str: 73 | """机器人ID""" 74 | return self.robot.template.id 75 | 76 | @property 77 | def time(self) -> datetime: 78 | """事件创建的时间""" 79 | return datetime.fromtimestamp(self.created_at) 80 | 81 | @override 82 | def get_event_name(self) -> str: 83 | return self.type.name 84 | 85 | @override 86 | def get_event_description(self) -> str: 87 | return escape_tag(repr(self.dict())) 88 | 89 | @override 90 | def get_message(self): 91 | raise ValueError("Event has no message!") 92 | 93 | @override 94 | def get_user_id(self) -> str: 95 | raise ValueError("Event has no context!") 96 | 97 | @override 98 | def get_session_id(self) -> str: 99 | return f"{self.robot.villa_id}_{self.id}" 100 | 101 | @override 102 | def is_tome(self) -> bool: 103 | return False 104 | 105 | 106 | class NoticeEvent(Event): 107 | """通知事件""" 108 | 109 | @override 110 | def get_type(self) -> str: 111 | return "notice" 112 | 113 | 114 | class JoinVillaEvent(NoticeEvent): 115 | """新用户加入大别野事件 116 | 117 | see https://webstatic.mihoyo.com/vila/bot/doc/callback.html###JoinVilla""" 118 | 119 | type: Literal[EventType.JoinVilla] = EventType.JoinVilla 120 | join_uid: int 121 | """用户ID""" 122 | join_user_nickname: str 123 | """用户昵称""" 124 | join_at: int 125 | """用户加入时间的时间戳""" 126 | villa_id: int 127 | """大别野 ID""" 128 | 129 | @override 130 | def get_event_description(self) -> str: 131 | return escape_tag( 132 | ( 133 | f"User(nickname={self.join_user_nickname},id={self.join_uid}) " 134 | f"join Villa(id={self.villa_id})" 135 | ), 136 | ) 137 | 138 | @override 139 | def get_user_id(self) -> str: 140 | return str(self.join_uid) 141 | 142 | @override 143 | def get_session_id(self) -> str: 144 | return f"{self.villa_id}_{self.join_uid}" 145 | 146 | 147 | class SendMessageEvent(Event): 148 | """用户@机器人发送消息事件 149 | 150 | see https://webstatic.mihoyo.com/vila/bot/doc/callback.html###SendMessage""" 151 | 152 | type: Literal[EventType.SendMessage] = EventType.SendMessage 153 | content: MessageContentInfoGet 154 | """消息内容""" 155 | from_user_id: int 156 | """发送者ID""" 157 | send_at: int 158 | """发送时间的时间戳""" 159 | room_id: int 160 | """房间ID""" 161 | object_name: int 162 | """目前只支持文本类型消息""" 163 | nickname: str 164 | """用户昵称""" 165 | msg_uid: str 166 | """消息ID""" 167 | bot_msg_id: Optional[str] = None 168 | """如果被回复的消息从属于机器人,则该字段不为空字符串""" 169 | villa_id: int 170 | """大别野 ID""" 171 | quote_msg: Optional[QuoteMessage] = None 172 | """回调消息引用消息的基础信息""" 173 | 174 | to_me: bool = True 175 | """是否和Bot有关""" 176 | message: Message 177 | """事件消息""" 178 | original_message: Message 179 | """事件原始消息""" 180 | 181 | @override 182 | def get_type(self) -> str: 183 | return "message" 184 | 185 | @property 186 | def message_id(self) -> str: 187 | """消息ID""" 188 | return self.msg_uid 189 | 190 | @property 191 | def reply(self) -> Optional[QuoteMessage]: 192 | """消息的回复信息""" 193 | return self.quote_msg 194 | 195 | @override 196 | def get_event_description(self) -> str: 197 | return escape_tag( 198 | ( 199 | f"Message(id={self.msg_uid}) was sent from" 200 | f" User(nickname={self.nickname}, id={self.from_user_id}) in" 201 | f" Room(id={self.room_id}) of Villa(id={self.villa_id})," 202 | f" content={repr(self.message)}" 203 | ), 204 | ) 205 | 206 | @override 207 | def get_message(self) -> Message: 208 | """获取事件消息""" 209 | return self.message 210 | 211 | @override 212 | def is_tome(self) -> bool: 213 | """是否和Bot有关""" 214 | return self.to_me 215 | 216 | @override 217 | def get_user_id(self) -> str: 218 | """获取用户ID""" 219 | return str(self.from_user_id) 220 | 221 | @override 222 | def get_session_id(self) -> str: 223 | """获取会话ID""" 224 | return f"{self.villa_id}_{self.room_id}_{self.from_user_id}" 225 | 226 | @root_validator(pre=True) 227 | @classmethod 228 | def payload_to_event(cls, data: Dict[str, Any]): 229 | if not data.get("content"): 230 | return data 231 | msg = Message() 232 | msg_content_info = data["content"] = json.loads(data["content"]) 233 | # if quote := msg_content_info.get("quote"): 234 | # msg.append( 235 | # MessageSegment.quote( 236 | # message_id=quote["quoted_message_id"], 237 | # message_send_time=quote["quoted_message_send_time"], 238 | # ), 239 | # ) 240 | 241 | content = msg_content_info["content"] 242 | text = content["text"] 243 | entities = content["entities"] 244 | if not entities: 245 | msg.append(MessageSegment.text(text)) 246 | data["message"] = data["original_message"] = msg 247 | return data 248 | text = text.encode("utf-16") 249 | last_offset: int = 0 250 | last_length: int = 0 251 | for entity in entities: 252 | end_offset: int = last_offset + last_length 253 | offset: int = entity["offset"] 254 | length: int = entity["length"] 255 | entity_detail = entity["entity"] 256 | if offset != end_offset: 257 | msg.append( 258 | MessageSegment.text( 259 | text[((end_offset + 1) * 2) : ((offset + 1) * 2)].decode( 260 | "utf-16", 261 | ), 262 | ), 263 | ) 264 | entity_text = text[(offset + 1) * 2 : (offset + length + 1) * 2].decode( 265 | "utf-16", 266 | ) 267 | if entity_detail["type"] == "mentioned_robot": 268 | entity_detail["bot_name"] = entity_text.lstrip("@")[:-1] 269 | msg.append( 270 | MessageSegment.mention_robot( 271 | entity_detail["bot_id"], 272 | entity_detail["bot_name"], 273 | ), 274 | ) 275 | elif entity_detail["type"] == "mentioned_user": 276 | entity_detail["user_name"] = entity_text.lstrip("@")[:-1] 277 | msg.append( 278 | MessageSegment.mention_user( 279 | int(entity_detail["user_id"]), 280 | entity_detail["user_name"], 281 | villa_id=data["villa_id"], 282 | ), 283 | ) 284 | elif entity_detail["type"] == "mention_all": 285 | entity_detail["show_text"] = entity_text.lstrip("@")[:-1] 286 | msg.append(MessageSegment.mention_all(entity_detail["show_text"])) 287 | elif entity_detail["type"] == "villa_room_link": 288 | entity_detail["room_name"] = entity_text.lstrip("#")[:-1] 289 | msg.append( 290 | MessageSegment.room_link( 291 | int(entity_detail["villa_id"]), 292 | int(entity_detail["room_id"]), 293 | ), 294 | ) 295 | elif entity_detail["type"] == "style": 296 | msg.append( 297 | MessageSegment.text( 298 | entity_text, 299 | **{entity_detail["font_style"]: True}, 300 | ), 301 | ) 302 | else: 303 | entity_detail["show_text"] = entity_text 304 | msg.append( 305 | MessageSegment.link( 306 | entity_detail["url"], 307 | entity_detail["show_text"], 308 | ), 309 | ) 310 | last_offset = offset 311 | last_length = length 312 | end_offset = last_offset + last_length 313 | if last_text := text[(end_offset + 1) * 2 :].decode("utf-16"): 314 | msg.append(MessageSegment.text(last_text)) 315 | data["message"] = data["original_message"] = msg 316 | return data 317 | 318 | 319 | MessageEvent = SendMessageEvent 320 | 321 | 322 | class CreateRobotEvent(NoticeEvent): 323 | """大别野添加机器人实例事件 324 | 325 | see https://webstatic.mihoyo.com/vila/bot/doc/callback.html###CreateRobot""" 326 | 327 | type: Literal[EventType.CreateRobot] = EventType.CreateRobot 328 | villa_id: int 329 | """大别野ID""" 330 | 331 | @override 332 | def get_event_description(self) -> str: 333 | return escape_tag( 334 | f"Bot(id={self.bot_id}) was added to Villa(id={self.villa_id})", 335 | ) 336 | 337 | @override 338 | def is_tome(self) -> bool: 339 | return True 340 | 341 | @override 342 | def get_session_id(self) -> str: 343 | return f"{self.villa_id}_{self.bot_id}" 344 | 345 | 346 | class DeleteRobotEvent(NoticeEvent): 347 | """大别野删除机器人实例事件 348 | 349 | see https://webstatic.mihoyo.com/vila/bot/doc/callback.html###DeleteRobot""" 350 | 351 | type: Literal[EventType.DeleteRobot] = EventType.DeleteRobot 352 | villa_id: int 353 | """大别野ID""" 354 | 355 | @override 356 | def get_event_description(self) -> str: 357 | return escape_tag( 358 | f"Bot(id={self.bot_id}) was removed from Villa(id={self.villa_id})", 359 | ) 360 | 361 | @override 362 | def is_tome(self) -> bool: 363 | return True 364 | 365 | @override 366 | def get_session_id(self) -> str: 367 | return f"{self.villa_id}_{self.bot_id}" 368 | 369 | 370 | class AddQuickEmoticonEvent(NoticeEvent): 371 | """用户使用表情回复消息表态事件 372 | 373 | see https://webstatic.mihoyo.com/vila/bot/doc/callback.html#AddQuickEmoticon""" 374 | 375 | type: Literal[EventType.AddQuickEmoticon] = EventType.AddQuickEmoticon 376 | villa_id: int 377 | """大别野ID""" 378 | room_id: int 379 | """房间ID""" 380 | uid: int 381 | """发送表情的用户ID""" 382 | emoticon_id: int 383 | """表情ID""" 384 | emoticon: str 385 | """表情内容""" 386 | msg_uid: str 387 | """被回复的消息 id""" 388 | bot_msg_id: Optional[str] = None 389 | """如果被回复的消息从属于机器人,则该字段不为空字符串""" 390 | is_cancel: bool = False 391 | """是否是取消表情""" 392 | emoticon_type: int 393 | """表情类型""" 394 | 395 | @override 396 | def get_user_id(self) -> str: 397 | return str(self.uid) 398 | 399 | @override 400 | def is_tome(self) -> bool: 401 | return True 402 | 403 | @override 404 | def get_session_id(self) -> str: 405 | return ( 406 | f"{self.villa_id}_{self.room_id}_{self.uid}" 407 | f"_{self.emoticon_id}_{self.is_cancel}" 408 | ) 409 | 410 | @override 411 | def get_event_description(self) -> str: 412 | return escape_tag( 413 | ( 414 | f"Emoticon(name={self.emoticon}, id={self.emoticon_id}) was " 415 | f"{'removed from' if self.is_cancel else 'added to'} " 416 | f"Message(id={self.msg_uid}) by User(id={self.uid}) in " 417 | f"Room(id=Villa(id={self.room_id}) of Villa(id={self.villa_id})" 418 | ), 419 | ) 420 | 421 | 422 | class AuditCallbackEvent(NoticeEvent): 423 | """审核结果回调事件 424 | 425 | see https://webstatic.mihoyo.com/vila/bot/doc/callback.html#AuditCallback""" 426 | 427 | type: Literal[EventType.AuditCallback] = EventType.AuditCallback 428 | audit_id: str 429 | """审核事件 id""" 430 | bot_tpl_id: str 431 | """机器人 id""" 432 | villa_id: int 433 | """大别野 ID""" 434 | room_id: int 435 | """房间 id(和审核接口调用方传入的值一致)""" 436 | user_id: int 437 | """用户 id(和审核接口调用方传入的值一致)""" 438 | pass_through: str 439 | """透传数据(和审核接口调用方传入的值一致)""" 440 | audit_result: AuditResult 441 | """审核结果""" 442 | 443 | @override 444 | def get_user_id(self) -> str: 445 | return str(self.user_id) 446 | 447 | @override 448 | def is_tome(self) -> bool: 449 | return self.bot_id == self.bot_tpl_id 450 | 451 | @override 452 | def get_session_id(self) -> str: 453 | return f"{self.villa_id}_{self.bot_tpl_id}_{self.audit_id}" 454 | 455 | @override 456 | def get_event_description(self) -> str: 457 | return escape_tag( 458 | ( 459 | f"Audit(id={self.audit_id},result={self.audit_result}) of " 460 | f"User(id={self.user_id}) in Room(id={self.room_id}) of " 461 | f"Villa(id={self.villa_id})" 462 | ), 463 | ) 464 | 465 | 466 | class ClickMsgComponentEvent(NoticeEvent): 467 | type: Literal[EventType.ClickMsgComponent] = EventType.ClickMsgComponent 468 | villa_id: int 469 | """大别野 ID""" 470 | room_id: int 471 | """房间 ID""" 472 | uid: int 473 | """用户 ID""" 474 | msg_uid: str 475 | """消息 ID""" 476 | bot_msg_id: Optional[str] = None 477 | """如果被回复的消息从属于机器人,则该字段不为空字符串""" 478 | component_id: str 479 | """机器人自定义的组件id""" 480 | template_id: Optional[int] = None 481 | """如果该组件模板为已创建模板,则template_id不为0""" 482 | extra: str 483 | """机器人自定义透传信息""" 484 | 485 | 486 | event_classes = Annotated[ 487 | Union[ 488 | JoinVillaEvent, 489 | SendMessageEvent, 490 | CreateRobotEvent, 491 | DeleteRobotEvent, 492 | AddQuickEmoticonEvent, 493 | AuditCallbackEvent, 494 | ClickMsgComponentEvent, 495 | ], 496 | Field(discriminator="type"), 497 | ] 498 | 499 | 500 | __all__ = [ 501 | "Event", 502 | "NoticeEvent", 503 | "MessageEvent", 504 | "JoinVillaEvent", 505 | "SendMessageEvent", 506 | "CreateRobotEvent", 507 | "DeleteRobotEvent", 508 | "AddQuickEmoticonEvent", 509 | "AuditCallbackEvent", 510 | ] 511 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum, IntEnum 3 | import inspect 4 | import json 5 | import sys 6 | from typing import Any, Dict, List, Literal, Optional, Union 7 | from typing_extensions import TypeAlias 8 | 9 | from pydantic import BaseModel, Field, validator 10 | 11 | 12 | class ApiResponse(BaseModel): 13 | retcode: int 14 | message: str = "" 15 | data: Any 16 | 17 | class Config: 18 | allow_population_by_field_name = True 19 | fields = {"message": {"alias": "msg"}} 20 | 21 | 22 | class BotAuth(BaseModel): 23 | bot_id: str 24 | bot_secret: str 25 | 26 | 27 | # http事件回调部分 28 | # see https://webstatic.mihoyo.com/vila/bot/doc/callback.html 29 | class CommandParam(BaseModel): 30 | desc: str 31 | 32 | 33 | class Command(BaseModel): 34 | name: str 35 | desc: Optional[str] = None 36 | params: Optional[List[CommandParam]] = None 37 | 38 | 39 | class TemplateCustomSettings(BaseModel): 40 | name: str 41 | url: str 42 | 43 | 44 | class Template(BaseModel): 45 | id: str 46 | name: str 47 | desc: Optional[str] = None 48 | icon: str 49 | commands: Optional[List[Command]] = None 50 | custom_settings: Optional[List[TemplateCustomSettings]] = None 51 | is_allowed_add_to_other_villa: Optional[bool] = None 52 | 53 | 54 | class Robot(BaseModel): 55 | villa_id: int 56 | template: Template 57 | 58 | 59 | class QuoteMessage(BaseModel): 60 | content: str 61 | msg_uid: str 62 | bot_msg_id: Optional[str] = None 63 | send_at: int 64 | msg_type: str 65 | from_user_id: Optional[int] = None 66 | from_user_nickname: Optional[str] = None 67 | from_user_id_str: str 68 | images: Optional[List[str]] = None 69 | 70 | 71 | ## 鉴权部分 72 | ## see https://webstatic.mihoyo.com/vila/bot/doc/auth_api/ 73 | class BotMemberAccessInfo(BaseModel): 74 | uid: int 75 | villa_id: int 76 | member_access_token: str 77 | bot_tpl_id: str 78 | 79 | 80 | class CheckMemberBotAccessTokenReturn(BaseModel): 81 | access_info: BotMemberAccessInfo 82 | member: "Member" 83 | 84 | 85 | # 大别野部分 86 | # see https://webstatic.mihoyo.com/vila/bot/doc/villa_api/ 87 | class Villa(BaseModel): 88 | villa_id: int 89 | name: str 90 | villa_avatar_url: str 91 | onwer_uid: int 92 | is_official: bool 93 | introduce: str 94 | category_id: int 95 | tags: List[str] 96 | 97 | 98 | # 用户部分 99 | # see https://webstatic.mihoyo.com/vila/bot/doc/member_api/ 100 | class MemberBasic(BaseModel): 101 | uid: int 102 | nickname: str 103 | introduce: str 104 | avatar_url: str 105 | 106 | 107 | class Member(BaseModel): 108 | basic: MemberBasic 109 | role_id_list: List[int] 110 | joined_at: datetime 111 | role_list: List["MemberRole"] 112 | 113 | 114 | # 消息部分 115 | # see https://webstatic.mihoyo.com/vila/bot/doc/message_api/ 116 | class MentionType(IntEnum): 117 | ALL = 1 118 | PART = 2 119 | 120 | def __repr__(self) -> str: 121 | return self.name 122 | 123 | 124 | class MentionedRobot(BaseModel): 125 | type: Literal["mentioned_robot"] = Field(default="mentioned_robot", repr=False) 126 | bot_id: str 127 | 128 | bot_name: str = Field(exclude=True) 129 | 130 | 131 | class MentionedUser(BaseModel): 132 | type: Literal["mentioned_user"] = Field(default="mentioned_user", repr=False) 133 | user_id: str 134 | 135 | user_name: Optional[str] = Field(exclude=True) 136 | 137 | 138 | class MentionedAll(BaseModel): 139 | type: Literal["mention_all"] = Field(default="mention_all", repr=False) 140 | 141 | show_text: str = Field(exclude=True) 142 | 143 | 144 | class VillaRoomLink(BaseModel): 145 | type: Literal["villa_room_link"] = Field(default="villa_room_link", repr=False) 146 | villa_id: str 147 | room_id: str 148 | 149 | room_name: Optional[str] = Field(exclude=True) 150 | 151 | 152 | class Link(BaseModel): 153 | type: Literal["link"] = Field(default="link", repr=False) 154 | url: str 155 | requires_bot_access_token: bool 156 | 157 | show_text: str = Field(exclude=True) 158 | 159 | 160 | class TextStyle(BaseModel): 161 | type: Literal["style"] = Field(default="style", repr=False) 162 | font_style: Literal["bold", "italic", "strikethrough", "underline"] 163 | 164 | 165 | class TextEntity(BaseModel): 166 | offset: int 167 | length: int 168 | entity: Union[ 169 | MentionedRobot, 170 | MentionedUser, 171 | MentionedAll, 172 | VillaRoomLink, 173 | Link, 174 | TextStyle, 175 | ] 176 | 177 | 178 | class ImageSize(BaseModel): 179 | width: Optional[int] = None 180 | height: Optional[int] = None 181 | 182 | 183 | class Image(BaseModel): 184 | url: str 185 | size: ImageSize = Field(default_factory=ImageSize) 186 | file_size: Optional[int] = None 187 | 188 | 189 | class PreviewLink(BaseModel): 190 | icon_url: str 191 | image_url: str 192 | is_internal_link: bool 193 | title: str 194 | content: str 195 | url: str 196 | source_name: str 197 | 198 | 199 | class Badge(BaseModel): 200 | icon_url: str 201 | text: str 202 | url: str 203 | 204 | 205 | class PostMessageContent(BaseModel): 206 | post_id: str 207 | 208 | @validator("post_id") 209 | @classmethod 210 | def __deal_post_id(cls, v: str): 211 | s = v.split("/")[-1] 212 | if s.isdigit(): 213 | return s 214 | raise ValueError(f"Invalid post_id: {v}, post_id must be a number.") 215 | 216 | 217 | class TextMessageContent(BaseModel): 218 | text: str 219 | entities: List[TextEntity] = Field(default_factory=list) 220 | images: Optional[List[Image]] = None 221 | preview_link: Optional[PreviewLink] = None 222 | badge: Optional[Badge] = None 223 | 224 | 225 | ImageMessageContent: TypeAlias = Image 226 | 227 | 228 | class MentionedInfo(BaseModel): 229 | type: MentionType 230 | user_id_list: List[str] = Field(default_factory=list, alias="userIdList") 231 | 232 | 233 | class QuoteInfo(BaseModel): 234 | quoted_message_id: str 235 | quoted_message_send_time: int 236 | original_message_id: str 237 | original_message_send_time: int 238 | 239 | 240 | class User(BaseModel): 241 | portrait_uri: str = Field(alias="portraitUri") 242 | extra: Dict[str, Any] 243 | name: str 244 | alias: str 245 | id: str 246 | portrait: str 247 | 248 | @validator("extra", pre=True) 249 | @classmethod 250 | def extra_str_to_dict(cls, v: Any): 251 | if isinstance(v, str): 252 | return json.loads(v) 253 | return v 254 | 255 | 256 | class Component(BaseModel): 257 | id: str 258 | text: str 259 | type: int = 1 260 | need_callback: Optional[bool] = None 261 | extra: str = "" 262 | 263 | 264 | ComponentGroup = List[Component] 265 | 266 | 267 | class Panel(BaseModel): 268 | template_id: Optional[int] = None 269 | small_component_group_list: Optional[List[ComponentGroup]] = None 270 | mid_component_group_list: Optional[List[ComponentGroup]] = None 271 | big_component_group_list: Optional[List[ComponentGroup]] = None 272 | 273 | 274 | class ButtonType(IntEnum): 275 | Callback = 1 276 | Input = 2 277 | Link = 3 278 | 279 | 280 | class Button(Component): 281 | c_type: ButtonType 282 | input: Optional[str] = None 283 | link: Optional[str] = None 284 | need_token: Optional[bool] = None 285 | 286 | 287 | class CallbackButton(Button): 288 | c_type: Literal[ButtonType.Callback] = Field( 289 | default=ButtonType.Callback, 290 | init=False, 291 | ) 292 | need_callback: Literal[True] = True 293 | 294 | 295 | class InputButton(Button): 296 | c_type: Literal[ButtonType.Input] = Field(default=ButtonType.Input, init=False) 297 | input: str 298 | 299 | 300 | class LinkButton(Button): 301 | c_type: Literal[ButtonType.Link] = Field(default=ButtonType.Link, init=False) 302 | link: str 303 | need_token: bool 304 | 305 | 306 | class Trace(BaseModel): 307 | visual_room_version: str 308 | app_version: str 309 | action_type: int 310 | bot_msg_id: str 311 | client: str 312 | env: str 313 | rong_sdk_version: str 314 | 315 | 316 | class MessageContentInfo(BaseModel): 317 | content: Union[TextMessageContent, ImageMessageContent, PostMessageContent] 318 | mentioned_info: Optional[MentionedInfo] = Field(default=None, alias="mentionedInfo") 319 | quote: Optional[QuoteInfo] = None 320 | panel: Optional[Panel] = None 321 | 322 | 323 | class MessageContentInfoGet(MessageContentInfo): 324 | user: User 325 | trace: Optional[Trace] = None 326 | 327 | 328 | # 房间部分 329 | # see https://webstatic.mihoyo.com/vila/bot/doc/room_api/ 330 | class ListRoom(BaseModel): 331 | room_id: int 332 | room_name: str 333 | room_type: "RoomType" 334 | group_id: int 335 | 336 | 337 | class Room(ListRoom): 338 | room_default_notify_type: "RoomDefaultNotifyType" 339 | send_msg_auth_range: "SendMsgAuthRange" 340 | 341 | 342 | class RoomType(str, Enum): 343 | CHAT = "BOT_PLATFORM_ROOM_TYPE_CHAT_ROOM" 344 | POST = "BOT_PLATFORM_ROOM_TYPE_POST_ROOM" 345 | SCENE = "BOT_PLATFORM_ROOM_TYPE_SCENE_ROOM" 346 | LIVE = "BOT_PLATFORM_ROOM_TYPE_LIVE_ROOM" 347 | INVALID = "BOT_PLATFORM_ROOM_TYPE_INVALID" 348 | 349 | def __repr__(self) -> str: 350 | return self.name 351 | 352 | 353 | class RoomDefaultNotifyType(str, Enum): 354 | NOTIFY = "BOT_PLATFORM_DEFAULT_NOTIFY_TYPE_NOTIFY" 355 | IGNORE = "BOT_PLATFORM_DEFAULT_NOTIFY_TYPE_IGNORE" 356 | INVALID = "BOT_PLATFORM_DEFAULT_NOTIFY_TYPE_INVALID" 357 | 358 | def __repr__(self) -> str: 359 | return self.name 360 | 361 | 362 | class SendMsgAuthRange(BaseModel): 363 | is_all_send_msg: bool 364 | roles: List[int] 365 | 366 | 367 | class GroupRoom(BaseModel): 368 | group_id: int 369 | group_name: str 370 | room_list: List[ListRoom] 371 | 372 | 373 | class ListRoomType(IntEnum): 374 | CHAT = 1 375 | POST = 2 376 | SCENE = 3 377 | 378 | def __repr__(self) -> str: 379 | return self.name 380 | 381 | 382 | class CreateRoomType(IntEnum): 383 | CHAT = 1 384 | POST = 2 385 | SCENE = 3 386 | 387 | def __repr__(self) -> str: 388 | return self.name 389 | 390 | 391 | class CreateRoomDefaultNotifyType(IntEnum): 392 | NOTIFY = 1 393 | IGNORE = 2 394 | 395 | def __repr__(self) -> str: 396 | return self.name 397 | 398 | 399 | class Group(BaseModel): 400 | group_id: int 401 | group_name: str 402 | 403 | 404 | # 身份组部分 405 | # see https://webstatic.mihoyo.com/vila/bot/doc/role_api/ 406 | class MemberRole(BaseModel): 407 | id: int 408 | name: str 409 | color: str 410 | role_type: "RoleType" 411 | villa_id: int 412 | member_num: Optional[int] = None 413 | web_color: str 414 | font_color: str 415 | bg_color: str 416 | has_manage_perm: Optional[bool] = None 417 | is_all_room: bool 418 | room_ids: List[int] 419 | color_scheme_id: int 420 | priority: int 421 | permissions: Optional[List["Permission"]] = None 422 | 423 | 424 | class PermissionDetail(BaseModel): 425 | key: str 426 | name: str 427 | describe: str 428 | 429 | 430 | class RoleType(str, Enum): 431 | ALL_MEMBER = "MEMBER_ROLE_TYPE_ALL_MEMBER" 432 | ADMIN = "MEMBER_ROLE_TYPE_ADMIN" 433 | OWNER = "MEMBER_ROLE_TYPE_OWNER" 434 | CUSTOM = "MEMBER_ROLE_TYPE_CUSTOM" 435 | UNKNOWN = "MEMBER_ROLE_TYPE_UNKNOWN" 436 | 437 | def __repr__(self) -> str: 438 | return self.name 439 | 440 | 441 | class Permission(str, Enum): 442 | MENTION_ALL = "mention_all" 443 | RECALL_MESSAGE = "recall_message" 444 | PIN_MESSAGE = "pin_message" 445 | MANAGE_MEMBER_ROLE = "manage_member_role" 446 | EDIT_VILLA_INFO = "edit_villa_info" 447 | MANAGE_GROUP_AND_ROOM = "manage_group_and_room" 448 | VILLA_SILENCE = "villa_silence" 449 | BLACK_OUT = "black_out" 450 | HANDLE_APPLY = "handle_apply" 451 | MANAGE_CHAT_ROOM = "manage_chat_room" 452 | VIEW_DATA_BOARD = "view_data_board" 453 | MANAGE_CUSTOM_EVENT = "manage_custom_event" 454 | LIVE_ROOM_ORDER = "live_room_order" 455 | MANAGE_SPOTLIGHT_COLLECTION = "manage_spotlight_collection" 456 | 457 | def __repr__(self) -> str: 458 | return self.name 459 | 460 | 461 | class Color(str, Enum): 462 | GREY = "#6173AB" 463 | PINK = "#F485D8" 464 | RED = "#F47884" 465 | ORANGE = "#FFA54B" 466 | GREEN = "#7ED321" 467 | BLUE = "#59A1EA" 468 | PURPLE = "#977EE1" 469 | LIGHT_BLUE = "#8F9BBF" # 此颜色为所有人身份组颜色,无法作为创建和编辑身份组的颜色 470 | 471 | 472 | # 表态表情部分 473 | # see https://webstatic.mihoyo.com/vila/bot/doc/emoticon_api/ 474 | class Emoticon(BaseModel): 475 | emoticon_id: int 476 | describe_text: str 477 | icon: str 478 | 479 | 480 | # 审核部分 481 | # see https://webstatic.mihoyo.com/vila/bot/doc/audit_api/audit.html 482 | class ContentType(str, Enum): 483 | TEXT = "AuditContentTypeText" 484 | IMAGE = "AuditContentTypeImage" 485 | 486 | 487 | # 图片部分 488 | # see https://webstatic.mihoyo.com/vila/bot/doc/img_api/upload.html 489 | 490 | 491 | class CallbackVar(BaseModel): 492 | x_extra: str = Field(alias="x:extra") 493 | 494 | 495 | class ImageUploadParams(BaseModel): 496 | accessid: str 497 | callback: str 498 | callback_var: CallbackVar 499 | dir: str 500 | expire: str 501 | host: str 502 | name: str 503 | policy: str 504 | signature: str 505 | x_oss_content_type: str 506 | object_acl: str 507 | content_disposition: str 508 | key: str 509 | success_action_status: str 510 | 511 | def to_upload_data(self) -> Dict[str, Any]: 512 | return { 513 | "x:extra": self.callback_var.x_extra, 514 | "OSSAccessKeyId": self.accessid, 515 | "signature": self.signature, 516 | "success_action_status": self.success_action_status, 517 | "name": self.name, 518 | "callback": self.callback, 519 | "x-oss-content-type": self.x_oss_content_type, 520 | "key": self.key, 521 | "policy": self.policy, 522 | "Content-Disposition": self.content_disposition, 523 | } 524 | 525 | 526 | class UploadImageParamsReturn(BaseModel): 527 | type: str 528 | file_name: str 529 | max_file_size: int 530 | params: ImageUploadParams 531 | 532 | 533 | class ImageUploadResult(BaseModel): 534 | object: str 535 | secret_url: str 536 | url: str 537 | 538 | 539 | # Websocket 部分 540 | # see https://webstatic.mihoyo.com/vila/bot/doc/websocket/websocket_api.html 541 | class WebsocketInfo(BaseModel): 542 | websocket_url: str 543 | uid: int 544 | app_id: int 545 | platform: int 546 | device_id: str 547 | 548 | 549 | for _, obj in inspect.getmembers(sys.modules[__name__]): 550 | if inspect.isclass(obj) and issubclass(obj, BaseModel): 551 | obj.update_forward_refs() 552 | 553 | 554 | __all__ = [ 555 | "ApiResponse", 556 | "BotAuth", 557 | "Command", 558 | "Template", 559 | "Robot", 560 | "QuoteMessage", 561 | "BotMemberAccessInfo", 562 | "CheckMemberBotAccessTokenReturn", 563 | "Villa", 564 | "MemberBasic", 565 | "Member", 566 | "MentionType", 567 | "MentionedRobot", 568 | "MentionedUser", 569 | "MentionedAll", 570 | "VillaRoomLink", 571 | "Link", 572 | "TextStyle", 573 | "TextEntity", 574 | "TextMessageContent", 575 | "ImageMessageContent", 576 | "PostMessageContent", 577 | "MentionedInfo", 578 | "QuoteInfo", 579 | "User", 580 | "Component", 581 | "Panel", 582 | "ButtonType", 583 | "Button", 584 | "CallbackButton", 585 | "InputButton", 586 | "LinkButton", 587 | "Trace", 588 | "ImageSize", 589 | "Image", 590 | "PreviewLink", 591 | "Badge", 592 | "MessageContentInfo", 593 | "MessageContentInfoGet", 594 | "Room", 595 | "RoomType", 596 | "RoomDefaultNotifyType", 597 | "SendMsgAuthRange", 598 | "GroupRoom", 599 | "ListRoomType", 600 | "CreateRoomType", 601 | "CreateRoomDefaultNotifyType", 602 | "Group", 603 | "MemberRole", 604 | "PermissionDetail", 605 | "RoleType", 606 | "Permission", 607 | "Color", 608 | "Emoticon", 609 | "ContentType", 610 | "ImageUploadParams", 611 | "UploadImageParamsReturn", 612 | "WebsocketInfo", 613 | ] 614 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/adapter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import time 4 | from typing import Any, Dict, List, Literal, Optional, cast 5 | from typing_extensions import override 6 | 7 | from nonebot.adapters import Adapter as BaseAdapter 8 | from nonebot.drivers import ( 9 | URL, 10 | Driver, 11 | HTTPClientMixin, 12 | HTTPServerSetup, 13 | Request, 14 | Response, 15 | ReverseMixin, 16 | WebSocket, 17 | WebSocketClientMixin, 18 | ) 19 | from nonebot.exception import WebSocketClosed 20 | from nonebot.utils import escape_tag 21 | 22 | from pydantic import parse_obj_as 23 | 24 | from .bot import Bot 25 | from .config import BotInfo, Config 26 | from .event import ( 27 | Event, 28 | event_classes, 29 | ) 30 | from .exception import ApiNotAvailable, DisconnectError, ReconnectError 31 | from .models import WebsocketInfo 32 | from .payload import ( 33 | BizType, 34 | HeartBeat, 35 | HeartBeatReply, 36 | KickOff, 37 | Login, 38 | LoginReply, 39 | Logout, 40 | LogoutReply, 41 | Payload, 42 | Shutdown, 43 | proto_to_event_data, 44 | ) 45 | from .utils import API, log 46 | 47 | 48 | class Adapter(BaseAdapter): 49 | bots: Dict[str, Bot] 50 | 51 | @override 52 | def __init__(self, driver: Driver, **kwargs: Any): 53 | super().__init__(driver, **kwargs) 54 | self.villa_config: Config = Config(**self.config.dict()) 55 | self.tasks: List[asyncio.Task] = [] 56 | self.ws: Dict[str, WebSocket] = {} 57 | self.base_url: URL = URL("https://bbs-api.miyoushe.com/vila/api/bot/platform") 58 | self._setup() 59 | 60 | @classmethod 61 | @override 62 | def get_name(cls) -> Literal["Villa"]: 63 | return "Villa" 64 | 65 | def _setup(self): 66 | self.driver.on_startup(self._forward_http) 67 | self.driver.on_startup(self._start_forward) 68 | self.driver.on_shutdown(self._stop_forwards) 69 | 70 | async def _forward_http(self): 71 | webhook_bots = [ 72 | bot_info 73 | for bot_info in self.villa_config.villa_bots 74 | if bot_info.connection_type == "webhook" 75 | ] 76 | if webhook_bots and not ( 77 | isinstance(self.driver, ReverseMixin) 78 | and isinstance(self.driver, HTTPClientMixin) 79 | ): 80 | raise RuntimeError( 81 | ( 82 | f"Current driver {self.config.driver}" 83 | "doesn't support connections!" 84 | "Villa Adapter Webhook need a " 85 | "ReverseMixin and HTTPClientMixin to work." 86 | ), 87 | ) 88 | for bot_info in webhook_bots: 89 | if not bot_info.callback_url: 90 | log( 91 | "WARNING", 92 | f"Missing callback url for bot {bot_info.bot_id}, " 93 | "bot will not be connected", 94 | ) 95 | continue 96 | bot = Bot(self, bot_info.bot_id, bot_info) 97 | self.bot_connect(bot) 98 | log("INFO", f"Bot {bot.self_id} connected") 99 | http_setup = HTTPServerSetup( 100 | URL(bot_info.callback_url), 101 | "POST", 102 | f"大别野 {bot_info.bot_id} HTTP", 103 | self._handle_http, 104 | ) 105 | self.setup_http_server(http_setup) 106 | 107 | async def _handle_http(self, request: Request) -> Response: 108 | if data := request.content: 109 | json_data = json.loads(data) 110 | if payload_data := json_data.get("event"): 111 | try: 112 | event = parse_obj_as( 113 | event_classes, 114 | payload_data, 115 | ) 116 | bot_id = event.bot_id 117 | if (bot := self.bots.get(bot_id, None)) is None: 118 | if ( 119 | bot_info := next( 120 | ( 121 | bot 122 | for bot in self.villa_config.villa_bots 123 | if bot.bot_id == bot_id 124 | ), 125 | None, 126 | ) 127 | ) is not None: 128 | bot = Bot( 129 | self, 130 | bot_info.bot_id, 131 | bot_info, 132 | ) 133 | self.bot_connect(bot) 134 | log("INFO", f"Bot {bot.self_id} connected") 135 | else: 136 | log( 137 | "WARNING", 138 | ( 139 | f"Missing bot secret for bot {bot_id}, " 140 | "event will not be handle" 141 | ), 142 | ) 143 | return Response( 144 | 200, 145 | content=json.dumps( 146 | {"retcode": 0, "message": "NoneBot2 Get it!"}, 147 | ), 148 | ) 149 | bot = cast(Bot, bot) 150 | if bot.verify_event and ( 151 | (bot_sign := request.headers.get("x-rpc-bot_sign")) is None 152 | or not bot._verify_signature( 153 | data.decode() if isinstance(data, bytes) else str(data), 154 | bot_sign, 155 | ) 156 | ): 157 | log("WARNING", f"Received invalid signature {bot_sign}.") 158 | return Response(401, content="Invalid Signature") 159 | bot._bot_info = event.robot 160 | except Exception as e: 161 | log( 162 | "WARNING", 163 | f"Failed to parse event {escape_tag(str(payload_data))}", 164 | e, 165 | ) 166 | else: 167 | asyncio.create_task(bot.handle_event(event)) 168 | return Response( 169 | 200, 170 | content=json.dumps({"retcode": 0, "message": "NoneBot2 Get it!"}), 171 | ) 172 | return Response(415, content="Invalid Request Body") 173 | return Response(415, content="Invalid Request Body") 174 | 175 | async def _start_forward(self) -> None: 176 | ws_bots = [ 177 | bot_info 178 | for bot_info in self.villa_config.villa_bots 179 | if bot_info.connection_type == "websocket" 180 | ] 181 | if ws_bots and not ( 182 | isinstance(self.driver, HTTPClientMixin) 183 | and isinstance(self.driver, WebSocketClientMixin) 184 | ): 185 | raise RuntimeError( 186 | f"Current driver {self.config.driver}" 187 | "doesn't support connections!" 188 | "Villa Adapter Websocket need a " 189 | "HTTPClientMixin and WebSocketClientMixin to work.", 190 | ) 191 | for bot_config in ws_bots: 192 | if bot_config.connection_type == "websocket": 193 | bot = Bot(self, bot_config.bot_id, bot_config) 194 | ws_info = await bot.get_websocket_info() 195 | self.tasks.append( 196 | asyncio.create_task( 197 | self._forward_ws(bot, bot_config, ws_info), 198 | ), 199 | ) 200 | 201 | async def _forward_ws( 202 | self, 203 | bot: Bot, 204 | bot_config: BotInfo, 205 | ws_info: WebsocketInfo, 206 | ) -> None: 207 | request = Request(method="GET", url=URL(ws_info.websocket_url), timeout=30.0) 208 | heartbeat_task: Optional["asyncio.Task"] = None 209 | while True: 210 | try: 211 | async with self.websocket(request) as ws: 212 | log( 213 | "DEBUG", 214 | "WebSocket Connection to" 215 | f" {escape_tag(ws_info.websocket_url)} established", 216 | ) 217 | try: 218 | # 登录 219 | result = await self._login(bot, ws, bot_config, ws_info) 220 | if not result: 221 | await asyncio.sleep(3.0) 222 | continue 223 | 224 | # 开启心跳 225 | heartbeat_task = asyncio.create_task( 226 | self._heartbeat(bot, ws), 227 | ) 228 | 229 | # 处理事件 230 | await self._loop(bot, ws) 231 | except DisconnectError as e: 232 | raise e 233 | except ReconnectError as e: 234 | log("ERROR", str(e), e) 235 | except WebSocketClosed as e: 236 | log( 237 | "ERROR", 238 | "WebSocket Closed", 239 | e, 240 | ) 241 | except Exception as e: 242 | log( 243 | "ERROR", 244 | ( 245 | "Error while process data from" 246 | f" websocket {escape_tag(ws_info.websocket_url)}. " 247 | "Trying to reconnect..." 248 | ), 249 | e, 250 | ) 251 | finally: 252 | if bot.self_id in self.bots: 253 | bot._ws_squence = 0 254 | self.ws.pop(bot.self_id) 255 | self.bot_disconnect(bot) 256 | if heartbeat_task: 257 | heartbeat_task.cancel() 258 | heartbeat_task = None 259 | except DisconnectError: 260 | return 261 | except Exception as e: 262 | log( 263 | "ERROR", 264 | ( 265 | "Error while setup websocket to" 266 | f" {escape_tag(ws_info.websocket_url)}. " 267 | "Trying to reconnect..." 268 | ), 269 | e, 270 | ) 271 | await asyncio.sleep(3.0) 272 | 273 | async def _stop_forwards(self) -> None: 274 | await asyncio.gather( 275 | *[ 276 | self._stop_forward(self.bots[bot[0]], bot[1], task) 277 | for bot, task in zip(self.ws.items(), self.tasks) 278 | ], 279 | ) 280 | 281 | async def _stop_forward( 282 | self, 283 | bot: Bot, 284 | ws: WebSocket, 285 | task: "asyncio.Task", 286 | ) -> None: 287 | await self._logout(bot, ws) 288 | await asyncio.sleep(1.0) 289 | if not task.done(): 290 | task.cancel() 291 | 292 | async def _login( 293 | self, 294 | bot: Bot, 295 | ws: WebSocket, 296 | bot_config: BotInfo, 297 | ws_info: WebsocketInfo, 298 | ): 299 | try: 300 | login = Login( 301 | uid=ws_info.uid, 302 | token=str(bot_config.test_villa_id) 303 | + f".{bot.bot_secret_encrypt}.{bot.self_id}", 304 | platform=ws_info.platform, 305 | app_id=ws_info.app_id, 306 | device_id=ws_info.device_id, 307 | ) 308 | log("TRACE", f"Sending Login {escape_tag(repr(login))}") 309 | await ws.send_bytes(login.to_bytes_package(bot._ws_squence)) 310 | bot._ws_squence += 1 311 | 312 | except Exception as e: 313 | log( 314 | "ERROR", 315 | "Error while sending Login", 316 | e, 317 | ) 318 | return None 319 | login_reply = await self.receive_payload(ws) 320 | if not isinstance(login_reply, LoginReply): 321 | log( 322 | "ERROR", 323 | "Received unexpected event while login: " 324 | f"{escape_tag(repr(login_reply))}", 325 | ) 326 | return None 327 | if login_reply.code == 0: 328 | bot.ws_info = ws_info 329 | if bot.self_id not in self.bots: 330 | self.bot_connect(bot) 331 | self.ws[bot.self_id] = ws 332 | log( 333 | "INFO", 334 | f"Bot {escape_tag(bot.self_id)} connected", 335 | ) 336 | return True 337 | return None 338 | 339 | async def _logout(self, bot: Bot, ws: WebSocket): 340 | try: 341 | await ws.send_bytes( 342 | Logout( 343 | uid=bot.ws_info.uid, 344 | platform=bot.ws_info.platform, 345 | app_id=bot.ws_info.app_id, 346 | device_id=bot.ws_info.device_id, 347 | ).to_bytes_package(bot._ws_squence), 348 | ) 349 | bot._ws_squence += 1 350 | except Exception as e: 351 | log("WARNING", "Error while sending logout, Ignored!", e) 352 | 353 | async def _heartbeat(self, bot: Bot, ws: WebSocket): 354 | while True: 355 | await asyncio.sleep(20.0) 356 | timestamp = str(int(time.time() * 1000)) 357 | log("TRACE", f"Heartbeat {timestamp}") 358 | try: 359 | await ws.send_bytes( 360 | HeartBeat(client_timestamp=timestamp).to_bytes_package( 361 | bot._ws_squence, 362 | ), 363 | ) 364 | bot._ws_squence += 1 365 | except Exception as e: 366 | log("WARNING", "Error while sending heartbeat, Ignored!", e) 367 | 368 | async def _loop(self, bot: Bot, ws: WebSocket): 369 | while True: 370 | payload = await self.receive_payload(ws) 371 | if not payload: 372 | raise ReconnectError 373 | if isinstance(payload, HeartBeatReply): 374 | log("TRACE", f"Heartbeat ACK in {payload.server_timestamp}") 375 | continue 376 | if isinstance(payload, (LogoutReply, KickOff)): 377 | if isinstance(payload, KickOff): 378 | log("WARNING", f"Bot {bot.self_id} kicked off by server: {payload}") 379 | raise DisconnectError 380 | log("INFO", f"Bot {bot.self_id} disconnected") 381 | if bot.self_id in self.bots: 382 | self.ws.pop(bot.self_id) 383 | self.bot_disconnect(bot) 384 | if isinstance(payload, Event): 385 | bot._bot_info = payload.robot 386 | asyncio.create_task(bot.handle_event(payload)) 387 | 388 | @staticmethod 389 | async def receive_payload(ws: WebSocket): 390 | payload = Payload.from_bytes(await ws.receive_bytes()) 391 | if payload.biz_type in {BizType.P_LOGIN, BizType.P_LOGOUT, BizType.P_HEARTBEAT}: 392 | if payload.biz_type == BizType.P_LOGIN: 393 | payload = LoginReply.from_proto(payload.body_data) 394 | elif payload.biz_type == BizType.P_LOGOUT: 395 | payload = LogoutReply.from_proto(payload.body_data) 396 | else: 397 | payload = HeartBeatReply.from_proto(payload.body_data) 398 | if payload.code != 0: 399 | if isinstance(payload, LogoutReply): 400 | log("WARNING", f"Error when logout from server: {payload}") 401 | return payload 402 | raise ReconnectError(payload) 403 | elif payload.biz_type == BizType.P_KICK_OFF: 404 | payload = KickOff.from_proto(payload.body_data) 405 | elif payload.biz_type == BizType.SHUTDOWN: 406 | payload = Shutdown() 407 | elif payload.biz_type == BizType.EVENT: 408 | return parse_obj_as( 409 | event_classes, 410 | proto_to_event_data(payload.body_data), 411 | ) 412 | else: 413 | raise ReconnectError 414 | log("TRACE", f"Received payload: {escape_tag(repr(payload))}") 415 | return payload 416 | 417 | @override 418 | async def _call_api(self, bot: Bot, api: str, **data: Any) -> Any: 419 | log("DEBUG", f"Calling API {api}") 420 | log("TRACE", f"With Data {escape_tag(str(data))}") 421 | api_handler: Optional[API] = getattr(bot.__class__, api, None) 422 | if api_handler is None: 423 | raise ApiNotAvailable(api) 424 | return await api_handler(bot, **data) 425 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/message.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING, Iterable, List, Literal, Optional, Type, Union 3 | from typing_extensions import Self, TypedDict, override 4 | 5 | from nonebot.adapters import ( 6 | Message as BaseMessage, 7 | MessageSegment as BaseMessageSegment, 8 | ) 9 | 10 | from .models import ( 11 | Badge, 12 | Component, 13 | Image, 14 | ImageMessageContent, 15 | Link, 16 | MentionedAll, 17 | MentionedRobot, 18 | MentionedUser, 19 | MessageContentInfo, 20 | Panel, 21 | PostMessageContent, 22 | PreviewLink, 23 | QuoteInfo, 24 | TextMessageContent, 25 | VillaRoomLink, 26 | ) 27 | 28 | 29 | class MessageSegment(BaseMessageSegment["Message"]): 30 | @classmethod 31 | @override 32 | def get_message_class(cls) -> Type["Message"]: 33 | return Message 34 | 35 | @override 36 | def __repr__(self) -> str: 37 | return self.__str__() 38 | 39 | @override 40 | def __add__( 41 | self, 42 | other: Union[str, "MessageSegment", Iterable["MessageSegment"]], 43 | ) -> "Message": 44 | return self.get_message_class()(self) + other 45 | 46 | @override 47 | def __radd__( 48 | self, 49 | other: Union[str, "MessageSegment", Iterable["MessageSegment"]], 50 | ) -> "Message": 51 | return self.get_message_class()(self) + other 52 | 53 | @override 54 | def is_text(self) -> bool: 55 | return self.type == "text" 56 | 57 | @staticmethod 58 | def text( 59 | text: str, 60 | bold: bool = False, 61 | italic: bool = False, 62 | strikethrough: bool = False, 63 | underline: bool = False, 64 | ) -> "TextSegment": 65 | """纯文本消息段 66 | 67 | 参数: 68 | text: 文本内容 69 | 70 | 返回: 71 | TextSegment: 消息段对象 72 | """ 73 | return TextSegment( 74 | "text", 75 | { 76 | "text": text, 77 | "bold": bold, 78 | "italic": italic, 79 | "strikethrough": strikethrough, 80 | "underline": underline, 81 | }, 82 | ) 83 | 84 | @staticmethod 85 | def mention_robot(bot_id: str, bot_name: str) -> "MentionRobotSegement": 86 | """@机器人消息段 87 | 88 | 参数: 89 | bot_id: 机器人ID 90 | bot_name: 机器人的名字 91 | 92 | 返回: 93 | MentionRobotSegement: 消息段对象 94 | """ 95 | return MentionRobotSegement( 96 | "mention_robot", 97 | {"mention_robot": MentionedRobot(bot_id=bot_id, bot_name=bot_name)}, 98 | ) 99 | 100 | @staticmethod 101 | def mention_user( 102 | user_id: int, 103 | user_name: Optional[str] = None, 104 | villa_id: Optional[int] = None, 105 | ) -> "MentionUserSegement": 106 | """@用户消息段 107 | 108 | user_name和villa_id必须有其中之一 109 | 110 | 参数: 111 | user_id: 用户ID 112 | user_name: 用户名称 113 | villa_id: 用户所在大别野ID 114 | 115 | 返回: 116 | MentionUserSegement: 消息段对象 117 | """ 118 | if not (user_name or villa_id): 119 | raise ValueError("user_name and villa_id must have one of them") 120 | return MentionUserSegement( 121 | "mention_user", 122 | { 123 | "mention_user": MentionedUser( 124 | user_id=str(user_id), 125 | user_name=user_name, 126 | ), 127 | "villa_id": villa_id, 128 | }, 129 | ) 130 | 131 | @staticmethod 132 | def mention_all(show_text: str = "全体成员") -> "MentionAllSegement": 133 | """@全体成员消息段 134 | 135 | 参数: 136 | show_text: 展示文本. 默认为 "全体成员". 137 | 138 | 返回: 139 | MentionAllSegement: 消息段对象 140 | """ 141 | return MentionAllSegement( 142 | "mention_all", 143 | {"mention_all": MentionedAll(show_text=show_text)}, 144 | ) 145 | 146 | @staticmethod 147 | def room_link( 148 | villa_id: int, 149 | room_id: int, 150 | room_name: Optional[str] = None, 151 | ) -> "RoomLinkSegment": 152 | """房间链接消息段,点击后可以跳转到指定房间 153 | 154 | 参数: 155 | villa_id: 大别野ID 156 | room_id: 房间ID 157 | 158 | 返回: 159 | VillaRoomLinkSegment: 消息段对象 160 | """ 161 | return RoomLinkSegment( 162 | "room_link", 163 | { 164 | "room_link": VillaRoomLink( 165 | villa_id=str(villa_id), 166 | room_id=str(room_id), 167 | room_name=room_name, 168 | ), 169 | }, 170 | ) 171 | 172 | @staticmethod 173 | def link( 174 | url: str, 175 | show_text: Optional[str] = None, 176 | requires_bot_access_token: bool = False, 177 | ) -> "LinkSegment": 178 | """链接消息段,使用该消息段才能让链接可以直接点击进行跳转 179 | 180 | 参数: 181 | url: 链接 182 | show_text: 链接显示的文本 183 | requires_bot_access_token: 跳转链接时是否带上含有用户信息的token 184 | 185 | 返回: 186 | LinkSegment: 消息段对象 187 | """ 188 | return LinkSegment( 189 | "link", 190 | { 191 | "link": Link( 192 | url=url, 193 | show_text=show_text or url, 194 | requires_bot_access_token=requires_bot_access_token, 195 | ), 196 | }, 197 | ) 198 | 199 | @staticmethod 200 | def quote(message_id: str, message_send_time: int) -> "QuoteSegment": 201 | """引用(回复)消息段 202 | 203 | 参数: 204 | message_id: 被引用的消息ID 205 | message_send_time: 被引用的消息发送时间 206 | 207 | 返回: 208 | QuoteSegment: 消息段对象 209 | """ 210 | return QuoteSegment( 211 | "quote", 212 | { 213 | "quote": QuoteInfo( 214 | quoted_message_id=message_id, 215 | quoted_message_send_time=message_send_time, 216 | original_message_id=message_id, 217 | original_message_send_time=message_send_time, 218 | ), 219 | }, 220 | ) 221 | 222 | @staticmethod 223 | def image( 224 | url: str, 225 | width: Optional[int] = None, 226 | height: Optional[int] = None, 227 | file_size: Optional[int] = None, 228 | ) -> "ImageSegment": 229 | """图片消息段 230 | 231 | 参数: 232 | url: 图片链接 233 | width: 图片宽度 234 | height: 图片高度 235 | file_size: 图片大小 236 | 237 | 返回: 238 | ImageSegment: 消息段对象 239 | """ 240 | return ImageSegment( 241 | "image", 242 | { 243 | "image": Image.parse_obj( 244 | { 245 | "url": url, 246 | "size": { 247 | "width": width, 248 | "height": height, 249 | }, 250 | "file_size": file_size, 251 | }, 252 | ), 253 | }, 254 | ) 255 | 256 | @staticmethod 257 | def post(post_id: str) -> "PostSegment": 258 | """帖子转发消息段 259 | 260 | 参数: 261 | post_id: 帖子ID 262 | 263 | 返回: 264 | PostSegment: 消息段对象 265 | """ 266 | return PostSegment("post", {"post": PostMessageContent(post_id=post_id)}) 267 | 268 | @staticmethod 269 | def preview_link( 270 | icon_url: str, 271 | image_url: str, 272 | is_internal_link: bool, 273 | title: str, 274 | content: str, 275 | url: str, 276 | source_name: str, 277 | ) -> "PreviewLinkSegment": 278 | """预览链接(卡片)消息段 279 | 280 | 参数: 281 | icon_url: 图标链接 282 | image_url: 封面链接 283 | is_internal_link: 是否为官方 284 | title: 标题 285 | content: 内容 286 | url: 链接 287 | source_name: 来源 288 | 289 | 返回: 290 | PreviewLinkSegment: 消息段对象 291 | """ 292 | return PreviewLinkSegment( 293 | "preview_link", 294 | { 295 | "preview_link": PreviewLink( 296 | icon_url=icon_url, 297 | image_url=image_url, 298 | is_internal_link=is_internal_link, 299 | title=title, 300 | content=content, 301 | url=url, 302 | source_name=source_name, 303 | ), 304 | }, 305 | ) 306 | 307 | @staticmethod 308 | def badge( 309 | icon_url: str, 310 | text: str, 311 | url: str, 312 | ) -> "BadgeSegment": 313 | """消息下方徽标 314 | 315 | 参数: 316 | icon_url: 图标链接 317 | text: 文本 318 | url: 链接 319 | 320 | 返回: 321 | BadgeSegment: 消息段对象 322 | """ 323 | return BadgeSegment( 324 | "badge", 325 | { 326 | "badge": Badge( 327 | icon_url=icon_url, 328 | text=text, 329 | url=url, 330 | ), 331 | }, 332 | ) 333 | 334 | @staticmethod 335 | def components(*components: Component) -> "ComponentsSegment": 336 | return ComponentsSegment( 337 | "components", 338 | { 339 | "components": list(components), 340 | }, 341 | ) 342 | 343 | @staticmethod 344 | def panel(panel: Union[Panel, int]) -> "PanelSegment": 345 | if isinstance(panel, int): 346 | panel = Panel(template_id=panel) 347 | return PanelSegment( 348 | "panel", 349 | { 350 | "panel": panel, 351 | }, 352 | ) 353 | 354 | 355 | class TextData(TypedDict): 356 | text: str 357 | bold: bool 358 | italic: bool 359 | strikethrough: bool 360 | underline: bool 361 | 362 | 363 | @dataclass 364 | class TextSegment(MessageSegment): 365 | if TYPE_CHECKING: 366 | type: Literal["text"] 367 | data: TextData 368 | 369 | @override 370 | def __str__(self) -> str: 371 | return self.data["text"] 372 | 373 | 374 | class MentionRobotData(TypedDict): 375 | mention_robot: MentionedRobot 376 | 377 | 378 | @dataclass 379 | class MentionRobotSegement(MessageSegment): 380 | if TYPE_CHECKING: 381 | type: Literal["mention_robot"] 382 | data: MentionRobotData 383 | 384 | @override 385 | def __str__(self) -> str: 386 | return f"" 387 | 388 | 389 | class MentionUserData(TypedDict): 390 | mention_user: MentionedUser 391 | villa_id: Optional[int] 392 | 393 | 394 | @dataclass 395 | class MentionUserSegement(MessageSegment): 396 | if TYPE_CHECKING: 397 | type: Literal["mention_user"] 398 | data: MentionUserData 399 | 400 | @override 401 | def __str__(self) -> str: 402 | return f"" 403 | 404 | 405 | class MentionAllData(TypedDict): 406 | mention_all: MentionedAll 407 | 408 | 409 | @dataclass 410 | class MentionAllSegement(MessageSegment): 411 | if TYPE_CHECKING: 412 | type: Literal["mention_all"] 413 | data: MentionAllData 414 | 415 | @override 416 | def __str__(self) -> str: 417 | return f"" 418 | 419 | 420 | class RoomLinkData(TypedDict): 421 | room_link: VillaRoomLink 422 | 423 | 424 | @dataclass 425 | class RoomLinkSegment(MessageSegment): 426 | if TYPE_CHECKING: 427 | type: Literal["room_link"] 428 | data: RoomLinkData 429 | 430 | @override 431 | def __str__(self) -> str: 432 | return f"" 433 | 434 | 435 | class LinkData(TypedDict): 436 | link: Link 437 | 438 | 439 | @dataclass 440 | class LinkSegment(MessageSegment): 441 | if TYPE_CHECKING: 442 | type: Literal["link"] 443 | data: LinkData 444 | 445 | @override 446 | def __str__(self) -> str: 447 | return f"" 448 | 449 | 450 | class ImageData(TypedDict): 451 | image: Image 452 | 453 | 454 | @dataclass 455 | class ImageSegment(MessageSegment): 456 | if TYPE_CHECKING: 457 | type: Literal["image"] 458 | data: ImageData 459 | 460 | @override 461 | def __str__(self) -> str: 462 | return f"" 463 | 464 | 465 | class QuoteData(TypedDict): 466 | quote: QuoteInfo 467 | 468 | 469 | @dataclass 470 | class QuoteSegment(MessageSegment): 471 | if TYPE_CHECKING: 472 | type: Literal["quote"] 473 | data: QuoteData 474 | 475 | @override 476 | def __str__(self) -> str: 477 | return f"" 478 | 479 | 480 | class PostData(TypedDict): 481 | post: PostMessageContent 482 | 483 | 484 | @dataclass 485 | class PostSegment(MessageSegment): 486 | if TYPE_CHECKING: 487 | type: Literal["post"] 488 | data: PostData 489 | 490 | @override 491 | def __str__(self) -> str: 492 | return f"" 493 | 494 | 495 | class PreviewLinkData(TypedDict): 496 | preview_link: PreviewLink 497 | 498 | 499 | @dataclass 500 | class PreviewLinkSegment(MessageSegment): 501 | if TYPE_CHECKING: 502 | type: Literal["preview_link"] 503 | data: PreviewLinkData 504 | 505 | @override 506 | def __str__(self) -> str: 507 | return f"" 508 | 509 | 510 | class BadgeData(TypedDict): 511 | badge: Badge 512 | 513 | 514 | @dataclass 515 | class BadgeSegment(MessageSegment): 516 | if TYPE_CHECKING: 517 | type: Literal["badge"] 518 | data: BadgeData 519 | 520 | @override 521 | def __str__(self) -> str: 522 | return f"" 523 | 524 | 525 | class ComponentsData(TypedDict): 526 | components: List[Component] 527 | 528 | 529 | @dataclass 530 | class ComponentsSegment(MessageSegment): 531 | if TYPE_CHECKING: 532 | type: Literal["components"] 533 | data: ComponentsData 534 | 535 | @override 536 | def __str__(self) -> str: 537 | return f"" 538 | 539 | 540 | class PanelData(TypedDict): 541 | panel: Panel 542 | 543 | 544 | @dataclass 545 | class PanelSegment(MessageSegment): 546 | if TYPE_CHECKING: 547 | type: Literal["panel"] 548 | data: PanelData 549 | 550 | @override 551 | def __str__(self) -> str: 552 | return f"" 553 | 554 | 555 | class Message(BaseMessage[MessageSegment]): 556 | @classmethod 557 | @override 558 | def get_segment_class(cls) -> Type[MessageSegment]: 559 | return MessageSegment 560 | 561 | @override 562 | def __add__( 563 | self, 564 | other: Union[str, MessageSegment, Iterable[MessageSegment]], 565 | ) -> "Message": 566 | return super().__add__( 567 | MessageSegment.text(other) if isinstance(other, str) else other, 568 | ) 569 | 570 | @override 571 | def __radd__( 572 | self, 573 | other: Union[str, MessageSegment, Iterable[MessageSegment]], 574 | ) -> "Message": 575 | return super().__radd__( 576 | MessageSegment.text(other) if isinstance(other, str) else other, 577 | ) 578 | 579 | @staticmethod 580 | @override 581 | def _construct(msg: str) -> Iterable[MessageSegment]: 582 | yield MessageSegment.text(msg) 583 | 584 | @classmethod 585 | def from_message_content_info(cls, content_info: MessageContentInfo) -> Self: 586 | msg = cls() 587 | if content_info.quote: 588 | msg.append( 589 | MessageSegment.quote( 590 | content_info.quote.quoted_message_id, 591 | content_info.quote.quoted_message_send_time, 592 | ), 593 | ) 594 | content = content_info.content 595 | if isinstance(content, TextMessageContent): 596 | if not content.entities: 597 | msg.append(MessageSegment.text(content.text)) 598 | return msg 599 | text = content.text.encode("utf-16") 600 | last_offset: int = 0 601 | last_length: int = 0 602 | for entity in content.entities: 603 | end_offset = last_offset + last_length 604 | offset = entity.offset 605 | length = entity.length 606 | entity_detail = entity.entity 607 | if offset != end_offset: 608 | msg.append( 609 | MessageSegment.text( 610 | text[((end_offset + 1) * 2) : ((offset + 1) * 2)].decode( 611 | "utf-16", 612 | ), 613 | ), 614 | ) 615 | if entity_detail.type == "mentioned_robot": 616 | msg.append( 617 | MessageSegment.mention_robot( 618 | entity_detail.bot_id, 619 | entity_detail.bot_name, 620 | ), 621 | ) 622 | elif entity_detail.type == "mentioned_user": 623 | msg.append( 624 | MessageSegment.mention_user( 625 | int(entity_detail.user_id), 626 | entity_detail.user_name, 627 | ), 628 | ) 629 | elif entity_detail.type == "mention_all": 630 | msg.append(MessageSegment.mention_all(entity_detail.show_text)) 631 | elif entity_detail.type == "villa_room_link": 632 | msg.append( 633 | MessageSegment.room_link( 634 | int(entity_detail.villa_id), 635 | int(entity_detail.room_id), 636 | entity_detail.room_name, 637 | ), 638 | ) 639 | elif entity_detail.type == "style": 640 | msg.append( 641 | MessageSegment.text( 642 | text[ 643 | ((offset + 1) * 2) : ((offset + length + 1) * 2) 644 | ].decode( 645 | "utf-16", 646 | ), 647 | **{entity_detail.font_style: True}, 648 | ), 649 | ) 650 | else: 651 | msg.append( 652 | MessageSegment.link(entity_detail.url, entity_detail.show_text), 653 | ) 654 | last_offset = offset 655 | last_length = length 656 | end_offset = last_offset + last_length 657 | if last_text := text[(end_offset + 1) * 2 :].decode("utf-16"): 658 | msg.append(MessageSegment.text(last_text)) 659 | return msg 660 | elif isinstance(content, ImageMessageContent): 661 | msg.append( 662 | MessageSegment.image( 663 | content.url, 664 | content.size.width if content.size else None, 665 | content.size.height if content.size else None, 666 | content.file_size, 667 | ), 668 | ) 669 | return msg 670 | else: 671 | msg.append(MessageSegment.post(content.post_id)) 672 | return msg 673 | -------------------------------------------------------------------------------- /nonebot/adapters/villa/bot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import hashlib 4 | import hmac 5 | from io import BytesIO 6 | from pathlib import Path 7 | from typing import ( 8 | TYPE_CHECKING, 9 | Any, 10 | AsyncIterator, 11 | Dict, 12 | List, 13 | NoReturn, 14 | Optional, 15 | Tuple, 16 | Union, 17 | cast, 18 | ) 19 | from typing_extensions import override 20 | from urllib.parse import urlencode 21 | 22 | from nonebot.adapters import Bot as BaseBot 23 | from nonebot.drivers import Request, Response 24 | from nonebot.message import handle_event 25 | from nonebot.utils import escape_tag 26 | 27 | from pydantic import parse_obj_as 28 | import rsa 29 | 30 | from .config import BotInfo 31 | from .event import AddQuickEmoticonEvent, Event, SendMessageEvent 32 | from .exception import ( 33 | ActionFailed, 34 | BotNotAdded, 35 | InsufficientPermission, 36 | InvalidBotAuthInfo, 37 | InvalidMemberBotAccessToken, 38 | InvalidRequest, 39 | NetworkError, 40 | PermissionDenied, 41 | UnknownServerError, 42 | UnsupportedMsgType, 43 | ) 44 | from .message import ( 45 | BadgeSegment, 46 | ComponentsSegment, 47 | ImageSegment, 48 | LinkSegment, 49 | MentionAllSegement, 50 | MentionRobotSegement, 51 | MentionUserSegement, 52 | Message, 53 | MessageSegment, 54 | PanelSegment, 55 | PostSegment, 56 | PreviewLinkSegment, 57 | QuoteSegment, 58 | RoomLinkSegment, 59 | TextSegment, 60 | ) 61 | from .models import ( 62 | ApiResponse, 63 | CheckMemberBotAccessTokenReturn, 64 | Color, 65 | Command, 66 | Component, 67 | ContentType, 68 | Emoticon, 69 | Group, 70 | GroupRoom, 71 | ImageMessageContent, 72 | ImageUploadResult, 73 | Link, 74 | Member, 75 | MemberRole, 76 | MentionedAll, 77 | MentionedInfo, 78 | MentionType, 79 | MessageContentInfo, 80 | Panel, 81 | Permission, 82 | PostMessageContent, 83 | Robot, 84 | Room, 85 | TextEntity, 86 | TextMessageContent, 87 | TextStyle, 88 | UploadImageParamsReturn, 89 | Villa, 90 | VillaRoomLink, 91 | WebsocketInfo, 92 | ) 93 | from .utils import API, get_img_extenion, get_img_md5, log 94 | 95 | if TYPE_CHECKING: 96 | from .adapter import Adapter 97 | 98 | 99 | def _check_at_me(bot: "Bot", event: SendMessageEvent): 100 | """检查事件是否和机器人有关,如果有关则设置 to_me 为 True,并删除消息中的 at 信息。 101 | 102 | 参数: 103 | bot: Bot对象 104 | event: 事件 105 | 106 | """ 107 | # 目前只能收到艾特机器人的消息,所以永远为 True 108 | # if ( 109 | # event.content.mentioned_info 110 | # and bot.self_id in event.content.mentioned_info.user_id_list 111 | # ): 112 | # event.to_me = True 113 | 114 | def _is_at_me_seg(segment: MessageSegment) -> bool: 115 | return ( 116 | segment.type == "mention_robot" 117 | and segment.data["mention_robot"].bot_id == bot.self_id 118 | ) 119 | 120 | message = event.get_message() 121 | if not message: 122 | message.append(MessageSegment.text("")) 123 | 124 | deleted = False 125 | if _is_at_me_seg(message[0]): 126 | message.pop(0) 127 | deleted = True 128 | if message and message[0].type == "text": 129 | message[0].data["text"] = message[0].data["text"].lstrip("\xa0").lstrip() 130 | if not message[0].data["text"]: 131 | del message[0] 132 | 133 | if not deleted: 134 | # check the last segment 135 | i = -1 136 | last_msg_seg = message[i] 137 | if ( 138 | last_msg_seg.type == "text" 139 | and not last_msg_seg.data["text"].strip() 140 | and len(message) >= 2 141 | ): 142 | i -= 1 143 | last_msg_seg = message[i] 144 | 145 | if _is_at_me_seg(last_msg_seg): 146 | deleted = True 147 | del message[i:] 148 | 149 | if not message: 150 | message.append(MessageSegment.text("")) 151 | 152 | 153 | class Bot(BaseBot): 154 | """ 155 | 大别野协议 Bot 适配。 156 | """ 157 | 158 | adapter: "Adapter" 159 | 160 | @override 161 | def __init__( 162 | self, 163 | adapter: "Adapter", 164 | self_id: str, 165 | bot_info: BotInfo, 166 | **kwargs: Any, 167 | ) -> None: 168 | super().__init__(adapter, self_id) 169 | self.adapter: Adapter = adapter 170 | self.bot_secret: str = bot_info.bot_secret 171 | self.bot_secret_encrypt = hmac.new( 172 | bot_info.pub_key.encode(), 173 | bot_info.bot_secret.encode(), 174 | hashlib.sha256, 175 | ).hexdigest() 176 | self.pub_key = rsa.PublicKey.load_pkcs1_openssl_pem(bot_info.pub_key.encode()) 177 | self.verify_event = bot_info.verify_event 178 | self.test_villa_id = bot_info.test_villa_id 179 | self._bot_info: Optional[Robot] = None 180 | self._ws_info: Optional[WebsocketInfo] = None 181 | self._ws_squence: int = 0 182 | 183 | @override 184 | def __repr__(self) -> str: 185 | return f"Bot(type={self.type!r}, self_id={self.self_id!r})" 186 | 187 | @override 188 | def __getattr__(self, name: str) -> NoReturn: 189 | raise AttributeError( 190 | f'"{self.__class__.__name__}" object has no attribute "{name}"', 191 | ) 192 | 193 | @property 194 | def nickname(self) -> str: 195 | """Bot 昵称""" 196 | if not self._bot_info: 197 | raise ValueError(f"Bot {self.self_id} hasn't received any events yet.") 198 | return self._bot_info.template.name 199 | 200 | @property 201 | def commands(self) -> Optional[List[Command]]: 202 | """Bot 命令预设命令列表""" 203 | if not self._bot_info: 204 | raise ValueError(f"Bot {self.self_id} hasn't received any events yet.") 205 | return self._bot_info.template.commands 206 | 207 | @property 208 | def description(self) -> Optional[str]: 209 | """Bot 介绍描述""" 210 | if not self._bot_info: 211 | raise ValueError(f"Bot {self.self_id} hasn't received any events yet.") 212 | return self._bot_info.template.desc 213 | 214 | @property 215 | def avatar_icon(self) -> str: 216 | """Bot 头像图标地址""" 217 | if not self._bot_info: 218 | raise ValueError(f"Bot {self.self_id} hasn't received any events yet.") 219 | return self._bot_info.template.icon 220 | 221 | @property 222 | def current_villd_id(self) -> int: 223 | if not self._bot_info: 224 | raise ValueError(f"Bot {self.self_id} hasn't received any events yet.") 225 | return self._bot_info.villa_id 226 | 227 | @property 228 | def ws_info(self) -> WebsocketInfo: 229 | if self._ws_info is None: 230 | raise RuntimeError(f"Bot {self.self_id} is not connected!") 231 | return self._ws_info 232 | 233 | @ws_info.setter 234 | def ws_info(self, ws_info: WebsocketInfo): 235 | self._ws_info = ws_info 236 | 237 | async def handle_event(self, event: Event): 238 | """处理事件""" 239 | if isinstance(event, SendMessageEvent): 240 | _check_at_me(self, event) 241 | await handle_event(self, event) 242 | 243 | def _verify_signature( 244 | self, 245 | body: str, 246 | bot_sign: str, 247 | ) -> bool: 248 | sign = base64.b64decode(bot_sign) 249 | sign_data = urlencode( 250 | {"body": body.rstrip("\n"), "secret": self.bot_secret}, 251 | ).encode() 252 | try: 253 | rsa.verify(sign_data, sign, self.pub_key) 254 | except rsa.VerificationError: 255 | return False 256 | return True 257 | 258 | def get_authorization_header( 259 | self, 260 | villa_id: Optional[int] = None, 261 | ) -> Dict[str, str]: 262 | """Bot 鉴权凭证请求头 263 | 264 | 参数: 265 | villa_id: 大别野ID 266 | 267 | 返回: 268 | Dict[str, str]: 请求头 269 | """ 270 | return { 271 | "x-rpc-bot_id": self.self_id, 272 | "x-rpc-bot_secret": self.bot_secret_encrypt, 273 | "x-rpc-bot_villa_id": str( 274 | villa_id if villa_id is not None else self.test_villa_id, 275 | ), 276 | } 277 | 278 | async def _handle_respnose(self, response: Response) -> Any: 279 | if not response.content: 280 | raise NetworkError("API request error when parsing response") 281 | resp = ApiResponse.parse_raw(response.content) 282 | if resp.retcode == 0: 283 | return resp.data 284 | if resp.retcode == -502: 285 | raise UnknownServerError(resp) 286 | if resp.retcode == -1: 287 | raise InvalidRequest(resp) 288 | if resp.retcode == 10318001: 289 | raise InsufficientPermission(resp) 290 | if resp.retcode == 10322002: 291 | raise BotNotAdded(resp) 292 | if resp.retcode == 10322003: 293 | raise PermissionDenied(resp) 294 | if resp.retcode == 10322004: 295 | raise InvalidMemberBotAccessToken(resp) 296 | if resp.retcode == 10322005: 297 | raise InvalidBotAuthInfo(resp) 298 | if resp.retcode == 10322006: 299 | raise UnsupportedMsgType(resp) 300 | raise ActionFailed(response.status_code, resp) 301 | 302 | async def _request(self, request: Request): 303 | try: 304 | resp = await self.adapter.request(request) 305 | log( 306 | "TRACE", 307 | f"API status_code:{resp.status_code} content: {escape_tag(str(resp.content))}", # noqa: E501 308 | ) 309 | except Exception as e: 310 | raise NetworkError("API request failed") from e 311 | return await self._handle_respnose(resp) 312 | 313 | async def send_to( 314 | self, 315 | villa_id: int, 316 | room_id: int, 317 | message: Union[str, Message, MessageSegment], 318 | ) -> str: 319 | """向指定房间发送消息 320 | 321 | 参数: 322 | villa_id: 大别野 ID 323 | room_id: 房间 ID 324 | message: 消息 325 | 326 | 返回: 327 | str: 消息 ID 328 | """ 329 | message = message if isinstance(message, Message) else Message(message) 330 | content_info = await self.parse_message_content(message) 331 | if isinstance(content_info.content, PostMessageContent): 332 | object_name = "MHY:Post" 333 | elif isinstance(content_info.content, ImageMessageContent): 334 | object_name = "MHY:Image" 335 | else: 336 | object_name = "MHY:Text" 337 | return await self.send_message( 338 | villa_id=villa_id, 339 | room_id=room_id, 340 | object_name=object_name, 341 | msg_content=content_info.json( 342 | by_alias=True, 343 | exclude_none=True, 344 | ensure_ascii=False, 345 | ), 346 | ) 347 | 348 | @override 349 | async def send( 350 | self, 351 | event: Event, 352 | message: Union[str, Message, MessageSegment], 353 | **kwargs: Any, 354 | ) -> str: 355 | """发送消息 356 | 357 | 参数: 358 | event: 事件 359 | message: 消息 360 | mention_sender: 是否@消息发送者. 默认为 False. 361 | quote_message: 是否引用原消息. 默认为 False. 362 | 363 | 异常: 364 | RuntimeError: 事件不是消息事件时抛出 365 | 366 | 返回: 367 | str: 消息ID 368 | """ 369 | if not isinstance(event, (SendMessageEvent, AddQuickEmoticonEvent)): 370 | raise RuntimeError("Event cannot be replied to!") 371 | message = message if isinstance(message, Message) else Message(message) 372 | if kwargs.pop("mention_sender", False) or kwargs.pop("at_sender", False): 373 | message.insert( 374 | 0, 375 | MessageSegment.mention_user( 376 | user_id=int(event.get_user_id()), 377 | user_name=( 378 | event.content.user.name 379 | if isinstance(event, SendMessageEvent) 380 | else None 381 | ), 382 | villa_id=event.villa_id, 383 | ), 384 | ) 385 | if kwargs.pop("quote_message", False) or kwargs.pop("reply_message", False): 386 | message += MessageSegment.quote(event.msg_uid, event.send_at) 387 | return await self.send_to( 388 | villa_id=event.villa_id, 389 | room_id=event.room_id, 390 | message=message, 391 | ) 392 | 393 | async def parse_message_content(self, message: Message) -> MessageContentInfo: 394 | """将适配器的Message对象转为大别野发送所需要的MessageContentInfo对象 395 | 396 | 参数: 397 | message: 消息 398 | 399 | 返回: 400 | MessageContentInfo: 消息内容对象 401 | """ 402 | quote = image = post = badge = preview_link = panel = None 403 | if quote_seg := cast(Optional[List[QuoteSegment]], message["quote"] or None): 404 | quote = quote_seg[-1].data["quote"] 405 | if image_seg := cast( 406 | Optional[List[ImageSegment]], 407 | message["image"] or None, 408 | ): 409 | image = image_seg[-1].data["image"] 410 | if post_seg := cast(Optional[List[PostSegment]], message["post"] or None): 411 | post = post_seg[-1].data["post"] 412 | if badge_seg := cast(Optional[List[BadgeSegment]], message["badge"] or None): 413 | badge = badge_seg[-1].data["badge"] 414 | if preview_link_seg := cast( 415 | Optional[List[PreviewLinkSegment]], 416 | message["preview_link"] or None, 417 | ): 418 | preview_link = preview_link_seg[-1].data["preview_link"] 419 | if panel_seg := cast(Optional[List[PanelSegment]], message["panel"] or None): 420 | panel = panel_seg[-1].data["panel"] 421 | if panel is None: 422 | components: List[Component] = [] 423 | for com in message["components"]: 424 | components.extend(cast(ComponentsSegment, com).data["components"]) 425 | if components: 426 | panel = _parse_components(components) 427 | 428 | def cal_len(x): 429 | return len(x.encode("utf-16")) // 2 - 1 430 | 431 | message = message.exclude("quote", "image", "post", "badge", "preview_link") 432 | message_text = "" 433 | message_offset = 0 434 | entities: List[TextEntity] = [] 435 | mentioned = MentionedInfo(type=MentionType.PART) 436 | for seg in message: 437 | try: 438 | if isinstance(seg, TextSegment): 439 | seg_text = seg.data["text"] 440 | length = cal_len(seg_text) 441 | for style in {"bold", "italic", "underline", "strikethrough"}: 442 | if seg.data[style]: 443 | entities.append( 444 | TextEntity( 445 | offset=message_offset, 446 | length=length, 447 | entity=TextStyle(font_style=style), # type: ignore 448 | ), 449 | ) 450 | elif isinstance(seg, MentionAllSegement): 451 | mention_all: MentionedAll = seg.data["mention_all"] 452 | seg_text = f"@{mention_all.show_text} " 453 | length = cal_len(seg_text) 454 | entities.append( 455 | TextEntity( 456 | offset=message_offset, 457 | length=length, 458 | entity=mention_all, 459 | ), 460 | ) 461 | mentioned.type = MentionType.ALL 462 | elif isinstance(seg, MentionRobotSegement): 463 | mention_robot = seg.data["mention_robot"] 464 | seg_text = f"@{mention_robot.bot_name} " 465 | length = cal_len(seg_text) 466 | entities.append( 467 | TextEntity( 468 | offset=message_offset, 469 | length=length, 470 | entity=mention_robot, 471 | ), 472 | ) 473 | mentioned.user_id_list.append(mention_robot.bot_id) 474 | elif isinstance(seg, MentionUserSegement): 475 | mention_user = seg.data["mention_user"] 476 | if mention_user.user_name is None: 477 | if not seg.data["villa_id"]: 478 | raise ValueError("cannot get user name without villa_id") 479 | # 需要调用API获取被@的用户的昵称 480 | user = await self.get_member( 481 | villa_id=seg.data["villa_id"], 482 | uid=int(mention_user.user_id), 483 | ) 484 | seg_text = f"@{user.basic.nickname} " 485 | mention_user.user_name = user.basic.nickname 486 | else: 487 | seg_text = f"@{mention_user.user_name} " 488 | length = cal_len(seg_text) 489 | entities.append( 490 | TextEntity( 491 | offset=message_offset, 492 | length=length, 493 | entity=mention_user, 494 | ), 495 | ) 496 | mentioned.user_id_list.append(str(mention_user.user_id)) 497 | elif isinstance(seg, RoomLinkSegment): 498 | room_link: VillaRoomLink = seg.data["room_link"] 499 | if room_link.room_name is None: 500 | # 需要调用API获取房间的名称 501 | room = await self.get_room( 502 | villa_id=int(room_link.villa_id), 503 | room_id=int(room_link.room_id), 504 | ) 505 | seg_text = f"#{room.room_name} " 506 | room_link.room_name = room.room_name 507 | else: 508 | seg_text = f"#{room_link.room_name} " 509 | length = cal_len(seg_text) 510 | entities.append( 511 | TextEntity( 512 | offset=message_offset, 513 | length=length, 514 | entity=room_link, 515 | ), 516 | ) 517 | elif isinstance(seg, LinkSegment): 518 | link: Link = seg.data["link"] 519 | seg_text = link.show_text 520 | length = cal_len(seg_text) 521 | entities.append( 522 | TextEntity(offset=message_offset, length=length, entity=link), 523 | ) 524 | else: 525 | continue 526 | message_offset += length 527 | message_text += seg_text 528 | except Exception as e: 529 | log("WARNING", "error when parse message content", e) 530 | 531 | if not (mentioned.type == MentionType.ALL and mentioned.user_id_list): 532 | mentioned = None 533 | 534 | if not (message_text or entities): 535 | if preview_link or badge or panel: 536 | content = TextMessageContent( 537 | text="\u200b", 538 | preview_link=preview_link, 539 | badge=badge, 540 | images=[image] if image else None, 541 | ) 542 | elif image: 543 | content = image 544 | elif post: 545 | content = post 546 | else: 547 | raise ValueError("message content is empty") 548 | else: 549 | content = TextMessageContent( 550 | text=message_text, 551 | entities=entities, 552 | images=[image] if image else None, 553 | preview_link=preview_link, 554 | badge=badge, 555 | ) 556 | 557 | return MessageContentInfo( 558 | content=content, 559 | mentionedInfo=mentioned, 560 | quote=quote, 561 | panel=panel, 562 | ) 563 | 564 | @API 565 | async def check_member_bot_access_token( 566 | self, 567 | *, 568 | villa_id: int, 569 | token: str, 570 | ) -> CheckMemberBotAccessTokenReturn: 571 | request = Request( 572 | method="POST", 573 | url=self.adapter.base_url / "checkMemberBotAccessToken", 574 | headers=self.get_authorization_header( 575 | villa_id, 576 | ), 577 | json={"token": token}, 578 | ) 579 | return parse_obj_as( 580 | CheckMemberBotAccessTokenReturn, 581 | await self._request(request), 582 | ) 583 | 584 | @API 585 | async def get_villa(self, villa_id: int) -> Villa: 586 | request = Request( 587 | method="GET", 588 | url=self.adapter.base_url / "getVilla", 589 | headers=self.get_authorization_header(villa_id), 590 | ) 591 | return parse_obj_as(Villa, (await self._request(request))["villa"]) 592 | 593 | @API 594 | async def get_member( 595 | self, 596 | *, 597 | villa_id: int, 598 | uid: int, 599 | ) -> Member: 600 | request = Request( 601 | method="GET", 602 | url=self.adapter.base_url / "getMember", 603 | headers=self.get_authorization_header(villa_id), 604 | params={"uid": uid}, 605 | ) 606 | return parse_obj_as(Member, (await self._request(request))["member"]) 607 | 608 | @API 609 | async def get_villa_members( 610 | self, 611 | *, 612 | villa_id: int, 613 | offset_str: str = "", 614 | size: int = 10, 615 | ) -> Tuple[List[Member], str]: 616 | request = Request( 617 | method="GET", 618 | url=self.adapter.base_url / "getVillaMembers", 619 | headers=self.get_authorization_header(villa_id), 620 | params={"offset_str": offset_str, "size": size}, 621 | ) 622 | result = await self._request(request) 623 | return parse_obj_as(List[Member], result["list"]), result["next_offset_str"] 624 | 625 | def iter_villa_members( 626 | self, 627 | villa_id: int, 628 | offset_str: str = "", 629 | size: int = 10, 630 | interval: float = 0.3, 631 | ) -> AsyncIterator[List[Member]]: 632 | return VillaMemberIterator( 633 | bot=self, 634 | villa_id=villa_id, 635 | offset_str=offset_str, 636 | size=size, 637 | interval=interval, 638 | ) 639 | 640 | @API 641 | async def delete_villa_member( 642 | self, 643 | *, 644 | villa_id: int, 645 | uid: int, 646 | ) -> None: 647 | request = Request( 648 | method="POST", 649 | url=self.adapter.base_url / "deleteVillaMember", 650 | headers=self.get_authorization_header(villa_id), 651 | json={"uid": uid}, 652 | ) 653 | await self._request(request) 654 | 655 | @API 656 | async def pin_message( 657 | self, 658 | *, 659 | villa_id: int, 660 | msg_uid: str, 661 | is_cancel: bool, 662 | room_id: int, 663 | send_at: int, 664 | ) -> None: 665 | request = Request( 666 | method="POST", 667 | url=self.adapter.base_url / "pinMessage", 668 | headers=self.get_authorization_header(villa_id), 669 | json={ 670 | "msg_uid": msg_uid, 671 | "is_cancel": is_cancel, 672 | "room_id": room_id, 673 | "send_at": send_at, 674 | }, 675 | ) 676 | await self._request(request) 677 | 678 | @API 679 | async def recall_message( 680 | self, 681 | *, 682 | villa_id: int, 683 | msg_uid: str, 684 | room_id: int, 685 | msg_time: int, 686 | ) -> None: 687 | request = Request( 688 | method="POST", 689 | url=self.adapter.base_url / "recallMessage", 690 | headers=self.get_authorization_header(villa_id), 691 | json={"msg_uid": msg_uid, "room_id": room_id, "msg_time": msg_time}, 692 | ) 693 | await self._request(request) 694 | 695 | @API 696 | async def send_message( 697 | self, 698 | *, 699 | villa_id: int, 700 | room_id: int, 701 | object_name: str, 702 | msg_content: Union[str, MessageContentInfo], 703 | ) -> str: 704 | if isinstance(msg_content, MessageContentInfo): 705 | content = msg_content.json( 706 | by_alias=True, 707 | exclude_none=True, 708 | ensure_ascii=False, 709 | ) 710 | else: 711 | content = msg_content 712 | request = Request( 713 | method="POST", 714 | url=self.adapter.base_url / "sendMessage", 715 | headers=self.get_authorization_header(villa_id), 716 | json={ 717 | "room_id": room_id, 718 | "object_name": object_name, 719 | "msg_content": content, 720 | }, 721 | ) 722 | return (await self._request(request))["bot_msg_id"] 723 | 724 | @API 725 | async def create_component_template( 726 | self, 727 | *, 728 | panel: Panel, 729 | ) -> int: 730 | request = Request( 731 | method="POST", 732 | url=self.adapter.base_url / "createComponentTemplate", 733 | headers=self.get_authorization_header(), 734 | json={ 735 | "panel": panel.json(exclude_none=True, ensure_ascii=False), 736 | }, 737 | ) 738 | return (await self._request(request))["template_id"] 739 | 740 | @API 741 | async def create_group( 742 | self, 743 | *, 744 | villa_id: int, 745 | group_name: str, 746 | ) -> int: 747 | request = Request( 748 | method="POST", 749 | url=self.adapter.base_url / "createGroup", 750 | headers=self.get_authorization_header(villa_id), 751 | json={"group_name": group_name}, 752 | ) 753 | return (await self._request(request))["group_id"] 754 | 755 | @API 756 | async def edit_group( 757 | self, 758 | *, 759 | villa_id: int, 760 | group_id: int, 761 | group_name: str, 762 | ) -> None: 763 | request = Request( 764 | method="POST", 765 | url=self.adapter.base_url / "editGroup", 766 | headers=self.get_authorization_header(villa_id), 767 | json={"group_id": group_id, "group_name": group_name}, 768 | ) 769 | await self._request(request) 770 | 771 | @API 772 | async def delete_group( 773 | self, 774 | *, 775 | villa_id: int, 776 | group_id: int, 777 | ) -> None: 778 | request = Request( 779 | method="POST", 780 | url=self.adapter.base_url / "deleteGroup", 781 | headers=self.get_authorization_header(villa_id), 782 | json={"group_id": group_id}, 783 | ) 784 | await self._request(request) 785 | 786 | @API 787 | async def get_group_list(self, *, villa_id: int) -> List[Group]: 788 | request = Request( 789 | method="GET", 790 | url=self.adapter.base_url / "getGroupList", 791 | headers=self.get_authorization_header(villa_id), 792 | ) 793 | return parse_obj_as(List[Group], (await self._request(request))["list"]) 794 | 795 | @API 796 | async def edit_room( 797 | self, 798 | *, 799 | villa_id: int, 800 | room_id: int, 801 | room_name: str, 802 | ) -> None: 803 | request = Request( 804 | method="POST", 805 | url=self.adapter.base_url / "editRoom", 806 | headers=self.get_authorization_header(villa_id), 807 | json={"room_id": room_id, "room_name": room_name}, 808 | ) 809 | await self._request(request) 810 | 811 | @API 812 | async def delete_room( 813 | self, 814 | *, 815 | villa_id: int, 816 | room_id: int, 817 | ) -> None: 818 | request = Request( 819 | method="POST", 820 | url=self.adapter.base_url / "deleteRoom", 821 | headers=self.get_authorization_header(villa_id), 822 | json={"room_id": room_id}, 823 | ) 824 | await self._request(request) 825 | 826 | @API 827 | async def get_room( 828 | self, 829 | *, 830 | villa_id: int, 831 | room_id: int, 832 | ) -> Room: 833 | request = Request( 834 | method="GET", 835 | url=self.adapter.base_url / "getRoom", 836 | headers=self.get_authorization_header(villa_id), 837 | params={"room_id": room_id}, 838 | ) 839 | return parse_obj_as(Room, (await self._request(request))["room"]) 840 | 841 | @API 842 | async def get_villa_group_room_list( 843 | self, 844 | *, 845 | villa_id: int, 846 | ) -> List[GroupRoom]: 847 | request = Request( 848 | method="GET", 849 | url=self.adapter.base_url / "getVillaGroupRoomList", 850 | headers=self.get_authorization_header(villa_id), 851 | ) 852 | return parse_obj_as( 853 | List[GroupRoom], 854 | (await self._request(request))["list"], 855 | ) 856 | 857 | @API 858 | async def operate_member_to_role( 859 | self, 860 | *, 861 | villa_id: int, 862 | role_id: int, 863 | uid: int, 864 | is_add: bool, 865 | ) -> None: 866 | request = Request( 867 | method="POST", 868 | url=self.adapter.base_url / "operateMemberToRole", 869 | headers=self.get_authorization_header(villa_id), 870 | json={"role_id": role_id, "uid": uid, "is_add": is_add}, 871 | ) 872 | await self._request(request) 873 | 874 | @API 875 | async def create_member_role( 876 | self, 877 | *, 878 | villa_id: int, 879 | name: str, 880 | color: Color, 881 | permissions: List[Permission], 882 | ) -> int: 883 | request = Request( 884 | method="POST", 885 | url=self.adapter.base_url / "createMemberRole", 886 | headers=self.get_authorization_header(villa_id), 887 | json={"name": name, "color": str(color), "permissions": permissions}, 888 | ) 889 | return (await self._request(request))["id"] 890 | 891 | @API 892 | async def edit_member_role( 893 | self, 894 | *, 895 | villa_id: int, 896 | role_id: int, 897 | name: str, 898 | color: Color, 899 | permissions: List[Permission], 900 | ) -> None: 901 | request = Request( 902 | method="POST", 903 | url=self.adapter.base_url / "editMemberRole", 904 | headers=self.get_authorization_header(villa_id), 905 | json={ 906 | "id": role_id, 907 | "name": name, 908 | "color": str(color), 909 | "permissions": permissions, 910 | }, 911 | ) 912 | await self._request(request) 913 | 914 | @API 915 | async def delete_member_role( 916 | self, 917 | *, 918 | villa_id: int, 919 | role_id: int, 920 | ) -> None: 921 | request = Request( 922 | method="POST", 923 | url=self.adapter.base_url / "deleteMemberRole", 924 | headers=self.get_authorization_header(villa_id), 925 | json={"id": role_id}, 926 | ) 927 | await self._request(request) 928 | 929 | @API 930 | async def get_member_role_info( 931 | self, 932 | *, 933 | villa_id: int, 934 | role_id: int, 935 | ) -> MemberRole: 936 | request = Request( 937 | method="GET", 938 | url=self.adapter.base_url / "getMemberRoleInfo", 939 | headers=self.get_authorization_header(villa_id), 940 | params={"role_id": role_id}, 941 | ) 942 | return parse_obj_as( 943 | MemberRole, 944 | (await self._request(request))["role"], 945 | ) 946 | 947 | @API 948 | async def get_villa_member_roles( 949 | self, 950 | *, 951 | villa_id: int, 952 | ) -> List[MemberRole]: 953 | request = Request( 954 | method="GET", 955 | url=self.adapter.base_url / "getVillaMemberRoles", 956 | headers=self.get_authorization_header(villa_id), 957 | ) 958 | return parse_obj_as( 959 | List[MemberRole], 960 | (await self._request(request))["list"], 961 | ) 962 | 963 | @API 964 | async def get_all_emoticons(self) -> List[Emoticon]: 965 | request = Request( 966 | method="GET", 967 | url=self.adapter.base_url / "getAllEmoticons", 968 | headers=self.get_authorization_header(), 969 | ) 970 | return parse_obj_as(List[Emoticon], (await self._request(request))["list"]) 971 | 972 | @API 973 | async def audit( 974 | self, 975 | *, 976 | villa_id: int, 977 | audit_content: str, 978 | pass_through: str, 979 | room_id: int, 980 | uid: int, 981 | content_type: ContentType = ContentType.TEXT, 982 | ) -> str: 983 | request = Request( 984 | method="POST", 985 | url=self.adapter.base_url / "audit", 986 | headers=self.get_authorization_header(villa_id), 987 | json={ 988 | "audit_content": audit_content, 989 | "pass_through": pass_through, 990 | "room_id": room_id, 991 | "uid": uid, 992 | "content_type": content_type, 993 | }, 994 | ) 995 | return (await self._request(request))["audit_id"] 996 | 997 | @API 998 | async def transfer_image( 999 | self, 1000 | *, 1001 | url: str, 1002 | ) -> str: 1003 | request = Request( 1004 | method="POST", 1005 | url=self.adapter.base_url / "transferImage", 1006 | headers=self.get_authorization_header(), 1007 | json={ 1008 | "url": url, 1009 | }, 1010 | ) 1011 | return (await self._request(request))["new_url"] 1012 | 1013 | @API 1014 | async def get_upload_image_params( 1015 | self, 1016 | *, 1017 | md5: str, 1018 | ext: str, 1019 | villa_id: Optional[int] = None, 1020 | ) -> UploadImageParamsReturn: 1021 | request = Request( 1022 | method="GET", 1023 | url=self.adapter.base_url / "getUploadImageParams", 1024 | headers=self.get_authorization_header(villa_id), 1025 | params={ 1026 | "md5": md5, 1027 | "ext": ext, 1028 | }, 1029 | ) 1030 | return parse_obj_as(UploadImageParamsReturn, await self._request(request)) 1031 | 1032 | async def upload_image( 1033 | self, 1034 | image: Union[bytes, BytesIO, Path], 1035 | ext: Optional[str] = None, 1036 | villa_id: Optional[int] = None, 1037 | ) -> ImageUploadResult: 1038 | """上传图片 1039 | 1040 | 参数: 1041 | image: 图片内容/路径 1042 | ext: 图片拓展,不填时自动判断 1043 | villa_id: 上传所在的大别野 ID,不传时使用测试别野 ID 1044 | 1045 | 异常: 1046 | ValueError: 无法获取图片拓展名 1047 | 1048 | 返回: 1049 | ImageUploadResult: 上传结果 1050 | """ 1051 | if isinstance(image, Path): 1052 | image = image.read_bytes() 1053 | elif isinstance(image, BytesIO): 1054 | image = image.getvalue() 1055 | img_md5 = get_img_md5(image) 1056 | ext = ext or get_img_extenion(image) 1057 | if ext is None: 1058 | raise ValueError("cannot guess image extension") 1059 | upload_params = await self.get_upload_image_params( 1060 | md5=img_md5, 1061 | ext=ext, 1062 | villa_id=villa_id, 1063 | ) 1064 | request = Request( 1065 | "POST", 1066 | url=upload_params.params.host, 1067 | data=upload_params.params.to_upload_data(), 1068 | files={"file": image}, 1069 | ) 1070 | return parse_obj_as(ImageUploadResult, await self._request(request)) 1071 | 1072 | async def get_websocket_info( 1073 | self, 1074 | ) -> WebsocketInfo: 1075 | request = Request( 1076 | method="GET", 1077 | url=self.adapter.base_url / "getWebsocketInfo", 1078 | headers=self.get_authorization_header(), 1079 | ) 1080 | return parse_obj_as(WebsocketInfo, await self._request(request)) 1081 | 1082 | 1083 | def _parse_components(components: List[Component]) -> Optional[Panel]: 1084 | small_total = [[]] 1085 | mid_total = [[]] 1086 | big_total = [] 1087 | for com in components: 1088 | com_lenght = len(com.text.encode("utf-8")) 1089 | if com_lenght <= 0: 1090 | log("warning", f"component {com.id} text is empty, ignore") 1091 | elif com_lenght <= 6: 1092 | small_total[-1].append(com) 1093 | if len(small_total[-1]) >= 3: 1094 | small_total.append([]) 1095 | elif com_lenght <= 12: 1096 | mid_total[-1].append(com) 1097 | if len(mid_total[-1]) >= 2: 1098 | mid_total.append([]) 1099 | elif com_lenght <= 30: 1100 | big_total.append([com]) 1101 | else: 1102 | log("warning", f"component {com.id} text is too long, ignore") 1103 | if not small_total[-1]: 1104 | small_total.pop() 1105 | small_total = small_total or None 1106 | if not mid_total[-1]: 1107 | mid_total.pop() 1108 | mid_total = mid_total or None 1109 | big_total = big_total or None 1110 | if small_total or mid_total or big_total: 1111 | return Panel( 1112 | small_component_group_list=small_total, 1113 | mid_component_group_list=mid_total, 1114 | big_component_group_list=big_total, 1115 | ) 1116 | return None 1117 | 1118 | 1119 | class VillaMemberIterator: 1120 | def __init__( 1121 | self, 1122 | bot: Bot, 1123 | villa_id: int, 1124 | offset_str: str = "", 1125 | size: int = 10, 1126 | interval: float = 0.3, 1127 | ) -> None: 1128 | self.bot = bot 1129 | self.villa_id = villa_id 1130 | self.offset_str = offset_str 1131 | self.size = size 1132 | self.interval = interval 1133 | 1134 | def __aiter__(self): 1135 | return self 1136 | 1137 | async def __anext__(self) -> List[Member]: 1138 | result, offset_str = await self.bot.get_villa_members( 1139 | villa_id=self.villa_id, 1140 | offset_str=self.offset_str, 1141 | size=self.size, 1142 | ) 1143 | if not result: 1144 | raise StopAsyncIteration 1145 | self.offset_str = offset_str 1146 | await asyncio.sleep(self.interval) 1147 | return result 1148 | --------------------------------------------------------------------------------