├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.yml │ └── bug_report.yml ├── release.yml └── workflows │ └── pypi-publish.yml ├── .vscode └── settings.json ├── villa ├── typing.py ├── __init__.py ├── store.py ├── utils.py ├── log.py ├── exception.py ├── handle.py ├── models.py ├── event.py ├── message.py └── bot.py ├── example ├── single_bot.py ├── example.py ├── vercel.py ├── multiple_bots.py ├── handle_func.py └── send_message.py ├── .pre-commit-config.yaml ├── LICENSE ├── pyproject.toml ├── README.md ├── .gitignore └── poetry.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://afdian.net/@cherishmoon"] -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "ms-python.black-formatter" 4 | }, 5 | "python.formatting.provider": "none", 6 | "black-formatter.args": [ 7 | "--preview" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /villa/typing.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Callable, TypeVar, Union 2 | 3 | from .event import Event 4 | 5 | T_Event = TypeVar("T_Event", bound=Event) 6 | 7 | 8 | T_Handler = Union[Callable[[T_Event], Any], Callable[[T_Event], Awaitable[Any]]] 9 | T_Func = Callable[..., Any] 10 | -------------------------------------------------------------------------------- /villa/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import ( 2 | Bot as Bot, 3 | on_shutdown as on_shutdown, 4 | on_startup as on_startup, 5 | run_bots as run_bots, 6 | ) 7 | from .event import * 8 | from .log import logger as logger 9 | from .store import ( 10 | get_app as get_app, 11 | get_bot as get_bot, 12 | get_bots as get_bots, 13 | ) 14 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /example/single_bot.py: -------------------------------------------------------------------------------- 1 | from villa import Bot 2 | 3 | bot = Bot( 4 | bot_id="your_bot_id", # 你的bot_id 5 | bot_secret="your_bot_secret", # 你的bot_secret 6 | pub_key="your_pub_key", # 你的bot的pub_key 7 | callback_url="your_callback_url_endpoint", # 你的bot的回调地址 8 | api_timeout=10, # api超时时间,单位秒 9 | wait_util_complete=False, # 是否等待处理函数全部完成后再返回响应,默认为False 10 | ) 11 | # 初始化Bot 12 | 13 | if __name__ == "__main__": 14 | bot.run(host="127.0.0.1", port=13350, log_level="DEBUG") 15 | # 使用fastapi+uvicorn来启动Bot 16 | # host: host地址 17 | # port: 端口,要和你的回调地址对上 18 | # log_level: 日志等级 19 | -------------------------------------------------------------------------------- /example/example.py: -------------------------------------------------------------------------------- 1 | from villa import Bot 2 | from villa.event import SendMessageEvent 3 | 4 | bot = Bot( 5 | bot_id="your_bot_id", 6 | bot_secret="your_bot_secret", 7 | pub_key="......", 8 | callback_url="your_callback_url", 9 | ) 10 | # 初始化Bot,填写你的bot_id、密钥以及回调地址 11 | 12 | 13 | @bot.on_keyword("hello") 14 | async def hello_handler(event: SendMessageEvent): 15 | await event.send("world!") 16 | # 一个简单的处理函数,向你的Bot发送包含`hello`的消息时,它将会回复你`world`! 17 | 18 | 19 | @bot.on_startswith("你好") 20 | async def hello_handler_cn(event: SendMessageEvent): 21 | await event.send("世界!") 22 | # 同样,向你的Bot发送以`你好`开头的消息时,它将会回复你`你好呀!` 23 | 24 | 25 | if __name__ == "__main__": 26 | bot.run(host="127.0.0.1", port=13350) 27 | # 启动bot,注意,port端口号要和你的回调地址对上 28 | -------------------------------------------------------------------------------- /example/vercel.py: -------------------------------------------------------------------------------- 1 | """ 2 | 使用vercel的serverless进行部署的示例 3 | vercel支持FastAPI的serverless部署,只需要暴露fastapi的app实例 4 | 这里仅给出主体代码,具体部署方法请参考vercel官方文档 5 | """ 6 | from villa import Bot 7 | from villa.event import SendMessageEvent 8 | from villa.store import get_app 9 | 10 | bot = Bot( 11 | bot_id="your_bot_id", 12 | bot_secret="your_bot_secret", 13 | pub_key="-----BEGIN PUBLIC KEY-----\nyour_pub_key\n-----END PUBLIC KEY-----\n", 14 | callback_url="your_callback_url_endpoint", 15 | # wait_util_complete=True 如果serverless服务商不支持后台任务,则需要将该参数设为True 16 | ) 17 | # 初始化Bot,填写你的bot_id、密钥、pub_key以及回调地址 18 | 19 | 20 | @bot.on_keyword("hello") 21 | async def hello_handler(event: SendMessageEvent): 22 | await event.send("world!(send by serverless)") 23 | # 一个简单的关键词回复处理函数 24 | 25 | 26 | app = get_app() 27 | # 获取fastapi的app实例 28 | 29 | bot.init_app(app) 30 | # 将bot注册到app中 31 | -------------------------------------------------------------------------------- /.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/psf/black 10 | rev: 23.3.0 11 | hooks: 12 | - id: black 13 | args: [--preview] 14 | stages: [commit] 15 | 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: v0.0.277 18 | hooks: 19 | - id: ruff 20 | args: [--fix, --exit-non-zero-on-fix] 21 | stages: [commit] 22 | 23 | - repo: https://github.com/pre-commit/pre-commit-hooks 24 | rev: v4.4.0 25 | hooks: 26 | - id: end-of-file-fixer 27 | - id: trailing-whitespace 28 | -------------------------------------------------------------------------------- /.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 }} -------------------------------------------------------------------------------- /villa/store.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, TYPE_CHECKING 2 | 3 | from fastapi import FastAPI 4 | 5 | if TYPE_CHECKING: 6 | from .bot import Bot 7 | 8 | _bots: Dict[str, "Bot"] = {} 9 | _app: FastAPI = FastAPI() 10 | 11 | 12 | def get_bot(bot_id: Optional[str] = None) -> Optional["Bot"]: 13 | """获取一个 Bot 实例 14 | 15 | 如果没有 Bot,则返回 None。 16 | 如果没有指定 bot_id,则返回第一个 Bot 实例。 17 | 18 | 返回: 19 | _type_: Bot 20 | """ 21 | if not _bots: 22 | return None 23 | if bot_id is None: 24 | return _bots[list(_bots.keys())[0]] 25 | return _bots.get(bot_id) 26 | 27 | 28 | def get_bots() -> Dict[str, "Bot"]: 29 | """获取所有 Bot 实例""" 30 | return _bots 31 | 32 | 33 | def store_bot(bot: "Bot") -> None: 34 | if bot.bot_id in _bots: 35 | raise ValueError(f"Bot {bot.bot_id} already in bots") 36 | _bots[bot.bot_id] = bot 37 | 38 | 39 | def get_app() -> FastAPI: 40 | """获取 FastAPI 实例""" 41 | return _app 42 | 43 | 44 | __all__ = ["get_bot", "get_bots", "store_bot", "get_app"] 45 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /example/multiple_bots.py: -------------------------------------------------------------------------------- 1 | from villa import Bot, run_bots 2 | from villa.event import SendMessageEvent 3 | 4 | bot_1 = Bot( 5 | bot_id="your_bot_id_1", 6 | bot_secret="your_bot_secret_1", 7 | pub_key="-----BEGIN PUBLIC KEY-----\nyour_pub_key_1\n-----END PUBLIC KEY-----\n", 8 | callback_url="your_callback_url_endpoint_1", 9 | ) 10 | bot_2 = Bot( 11 | bot_id="your_bot_id_2", 12 | bot_secret="your_bot_secret_2", 13 | pub_key="-----BEGIN PUBLIC KEY-----\nyour_pub_key_2\n-----END PUBLIC KEY-----\n", 14 | callback_url="your_callback_url_endpoint_2", 15 | ) 16 | # 初始化多个Bot 17 | 18 | 19 | @bot_1.on_message() 20 | async def bot_1_handler(event: SendMessageEvent): 21 | """只属于 bot_1 的消息处理函数""" 22 | ... 23 | 24 | 25 | @bot_2.on_message() 26 | async def bot_2_handler(event: SendMessageEvent): 27 | """只属于 bot_2 的消息处理函数""" 28 | ... 29 | 30 | 31 | @bot_1.on_message() 32 | @bot_2.on_message() 33 | async def bot_1_and_2_handler(event: SendMessageEvent): 34 | """同时属于 bot_1 和 bot_2 的消息处理函数""" 35 | ... 36 | 37 | 38 | if __name__ == "__main__": 39 | run_bots(bots=[bot_1, bot_2], host="127.0.0.1", port=13350, log_level="DEBUG") 40 | # 使用run_bots来启动多个Bot 41 | -------------------------------------------------------------------------------- /.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: 提供有助于诊断问题的任何日志和截图 -------------------------------------------------------------------------------- /villa/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from functools import partial, wraps 3 | import re 4 | from typing import Callable, Coroutine, TypeVar 5 | from typing_extensions import ParamSpec 6 | 7 | P = ParamSpec("P") 8 | R = TypeVar("R") 9 | 10 | 11 | def escape_tag(s: str) -> str: 12 | """用于记录带颜色日志时转义 `` 类型特殊标签 13 | 14 | 参考: [loguru color 标签](https://loguru.readthedocs.io/en/stable/api/logger.html#color) 15 | 16 | 参数: 17 | s: 需要转义的字符串 18 | """ 19 | return re.sub(r"\s]*)>", r"\\\g<0>", s) 20 | 21 | 22 | def run_sync(call: Callable[P, R]) -> Callable[P, Coroutine[None, None, R]]: 23 | """一个用于包装 sync function 为 async function 的装饰器 24 | 25 | 参数: 26 | call: 被装饰的同步函数 27 | """ 28 | 29 | @wraps(call) 30 | async def _wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 31 | loop = asyncio.get_running_loop() 32 | pfunc = partial(call, *args, **kwargs) 33 | return await loop.run_in_executor(None, pfunc) 34 | 35 | return _wrapper 36 | 37 | 38 | # def format_pub_key(pub_key: str) -> str: 39 | # """格式化公钥字符串 40 | 41 | # 参数: 42 | # pub_key: 公钥字符串 43 | # """ 44 | # pub_key = pub_key.strip() 45 | # if pub_key.startswith("-----BEGIN PUBLIC KEY-----"): 46 | # pub_key = pub_key[26:] 47 | # if pub_key.endswith("-----END PUBLIC KEY-----"): 48 | # pub_key = pub_key[:-24] 49 | # pub_key = pub_key.replace(" ", "\n") 50 | # if pub_key[0] != "\n": 51 | # pub_key = "\n" + pub_key 52 | # if pub_key[-1] != "\n": 53 | # pub_key += "\n" 54 | # return "-----BEGIN PUBLIC KEY-----" + pub_key + "-----END PUBLIC KEY-----\n" 55 | -------------------------------------------------------------------------------- /villa/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from typing import TYPE_CHECKING 4 | 5 | import loguru 6 | 7 | if TYPE_CHECKING: 8 | from loguru import Logger, Record 9 | 10 | logger: "Logger" = loguru.logger 11 | 12 | 13 | class LoguruHandler(logging.Handler): 14 | """logging 与 loguru 之间的桥梁,将 logging 的日志转发到 loguru。""" 15 | 16 | def emit(self, record: logging.LogRecord): 17 | try: 18 | level = logger.level(record.levelname).name 19 | except ValueError: 20 | level = record.levelno 21 | 22 | frame, depth = logging.currentframe(), 2 23 | while frame and frame.f_code.co_filename == logging.__file__: 24 | frame = frame.f_back 25 | depth += 1 26 | 27 | logger.opt(depth=depth, exception=record.exc_info).log( 28 | level, 29 | record.getMessage(), 30 | ) 31 | 32 | 33 | def default_filter(record: "Record"): 34 | """默认的日志过滤器,根据 `config.log_level` 配置改变日志等级。""" 35 | log_level = record["extra"].get("villa_log_level", "INFO") 36 | levelno = logger.level(log_level).no if isinstance(log_level, str) else log_level 37 | return record["level"].no >= levelno 38 | 39 | 40 | default_format: str = ( 41 | "{time:MM-DD HH:mm:ss} " 42 | "[{level}] " 43 | "{name} | " 44 | "{message}" 45 | ) 46 | """默认日志格式""" 47 | 48 | 49 | def _log_patcher(record: "loguru.Record"): 50 | record["name"] = record["name"].split(".")[0] # type: ignore 51 | 52 | 53 | logger.remove() 54 | logger_id = logger.add( 55 | sys.stdout, 56 | level=0, 57 | diagnose=True, 58 | backtrace=False, 59 | filter=default_filter, 60 | format=default_format, 61 | ) 62 | -------------------------------------------------------------------------------- /example/handle_func.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from villa import Bot 4 | from villa.event import AddQuickEmoticonEvent, Event, JoinVillaEvent, SendMessageEvent 5 | 6 | bot = Bot( 7 | bot_id="your_bot_id", 8 | bot_secret="your_bot_secret", 9 | pub_key="-----BEGIN PUBLIC KEY-----\nyour_pub_key\n-----END PUBLIC KEY-----\n", 10 | callback_url="your_callback_url_endpoint", 11 | ) 12 | 13 | """通过bot上的各种处理器来处理事件""" 14 | 15 | 16 | @bot.on_event(SendMessageEvent, AddQuickEmoticonEvent, JoinVillaEvent) 17 | async def event_handler( 18 | event: Union[SendMessageEvent, AddQuickEmoticonEvent, JoinVillaEvent], 19 | ): 20 | """处理指定事件""" 21 | ... 22 | 23 | 24 | @bot.on_event(Event) 25 | async def all_event_handler(event: Event): 26 | """也可以写基类事件来达到处理所有事件的目的""" 27 | ... 28 | 29 | 30 | @bot.on_message() 31 | async def message_handler(event: SendMessageEvent): 32 | """处理消息事件""" 33 | ... 34 | 35 | 36 | @bot.on_message(block=True, priority=1) 37 | async def message_handler2(event: SendMessageEvent): 38 | """所有处理器都有两个参数: 39 | block(是否阻止更低优先级的处理函数执行) 40 | priority(优先级,数字越小优先级越高)""" 41 | ... 42 | 43 | 44 | @bot.on_keyword("hello", "Hello", "HELLO") 45 | async def keyword_handler(event: SendMessageEvent): 46 | """处理包含这些关键词的消息事件""" 47 | ... 48 | 49 | 50 | @bot.on_startswith("Hello", "hello", prefix={"/", ""}) 51 | async def startswith_handler(event: SendMessageEvent): 52 | """ 53 | 处理以这些关键词开头的消息事件 54 | 55 | prefix为可选参数,用于指定消息开头的前缀 56 | 57 | 例如在这里话就会所有以Hello、/Hello、hello、/hello开头的消息事件 58 | """ 59 | 60 | 61 | @bot.on_endswith("world", "World", "WORLD") 62 | async def endswith_handler(event: SendMessageEvent): 63 | """处理以这些关键词结尾的消息事件""" 64 | ... 65 | 66 | 67 | @bot.on_regex(r"hello\s+world") 68 | async def regex_handler(event: SendMessageEvent): 69 | """处理与正则匹配的消息事件""" 70 | ... 71 | 72 | 73 | """注意:无论是on_keyword、on_startswith、on_endswith还是on_regex 74 | 都只对事件消息的纯文本部分进行匹配,不包括@、图片等其他内容""" 75 | 76 | if __name__ == "__main__": 77 | bot.run(host="127.0.0.1", port=13350, log_level="DEBUG") 78 | -------------------------------------------------------------------------------- /villa/exception.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from .models import ApiResponse 4 | 5 | if TYPE_CHECKING: 6 | from .handle import EventHandler 7 | 8 | 9 | class ActionFailed(Exception): 10 | def __init__(self, status_code: int, response: ApiResponse): 11 | self.status_code = status_code 12 | self.response = response 13 | 14 | def __repr__(self) -> str: 15 | return ( 16 | f"<{self.__class__.__name__}: {self.status_code}, " 17 | f"retcode={self.response.retcode}, " 18 | f"message={self.response.message}, " 19 | f"data={self.response.data}>" 20 | ) 21 | 22 | def __str__(self): 23 | return self.__repr__() 24 | 25 | 26 | class UnknownServerError(ActionFailed): 27 | def __init__(self, response: ApiResponse): 28 | super().__init__(-502, response) 29 | 30 | 31 | class InvalidRequest(ActionFailed): 32 | def __init__(self, response: ApiResponse): 33 | super().__init__(-1, response) 34 | 35 | 36 | class InsufficientPermission(ActionFailed): 37 | def __init__(self, response: ApiResponse): 38 | super().__init__(10318001, response) 39 | 40 | 41 | class BotNotAdded(ActionFailed): 42 | def __init__(self, response: ApiResponse): 43 | super().__init__(10322002, response) 44 | 45 | 46 | class PermissionDenied(ActionFailed): 47 | def __init__(self, response: ApiResponse): 48 | super().__init__(10322003, response) 49 | 50 | 51 | class InvalidMemberBotAccessToken(ActionFailed): 52 | def __init__(self, response: ApiResponse): 53 | super().__init__(10322004, response) 54 | 55 | 56 | class InvalidBotAuthInfo(ActionFailed): 57 | def __init__(self, response: ApiResponse): 58 | super().__init__(10322005, response) 59 | 60 | 61 | class UnsupportedMsgType(ActionFailed): 62 | def __init__(self, response: ApiResponse): 63 | super().__init__(10322006, response) 64 | 65 | 66 | class StopPropagation(Exception): 67 | def __init__(self, handler: "EventHandler", *args) -> None: 68 | self.handler = handler 69 | super().__init__(*args) 70 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "villa" 3 | version = "0.8.0" 4 | description = "米游社大别野Bot Python SDK。MiHoYo Villa Bot Python SDK." 5 | authors = ["CMHopeSunshine <277073121@qq.com>"] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/CMHopeSunshine/villa-py" 9 | repository = "https://github.com/CMHopeSunshine/villa-py" 10 | documentation = "https://github.com/CMHopeSunshine/villa-py" 11 | keywords = ["mihoyo", "bot", "villa"] 12 | 13 | [tool.poetry.dependencies] 14 | python = "^3.8" 15 | fastapi = {extras = ["uvicorn"], version = "^0.96.0"} 16 | uvicorn = "^0.22.0" 17 | httpx = "^0.24.1" 18 | loguru = "^0.7.0" 19 | rsa = "^4.9" 20 | 21 | 22 | [tool.poetry.group.dev.dependencies] 23 | ruff = "^0.0.277" 24 | black = "^23.1.0" 25 | pre-commit = "^3.1.0" 26 | 27 | [tool.black] 28 | line-length = 88 29 | include = '\.pyi?$' 30 | extend-exclude = ''' 31 | ''' 32 | skip-string-normalization = true 33 | target-version = ["py38", "py39", "py310", "py311"] 34 | 35 | [tool.ruff] 36 | select = [ 37 | "E", "W", # pycodestyle 38 | "F", # pyflakes 39 | "UP", # pyupgrade 40 | "N", # pep8-naming 41 | "I", # isort 42 | "PYI", # flask8-pyi 43 | "Q", # flake8-quotes 44 | "PTH", # flake8-use-pathlib 45 | "RET", # flake8-return 46 | "RSE", # flake8-raise 47 | "T20", # flake8-print 48 | "PIE", # flake8-pie 49 | "SIM", # flake8-simplify 50 | "ISC", # flake8-implicit-str-concat 51 | "C4", # flake8-comprehensions 52 | "COM", # flake8-commas 53 | "A", # flake8-builtins 54 | "B", # flake8-bugbear 55 | "ASYNC" # flake8-async 56 | ] 57 | ignore = ["E402", "B008", "F403", "F405", "B005", "N818"] 58 | line-length = 88 59 | target-version = "py38" 60 | ignore-init-module-imports = true 61 | 62 | 63 | [tool.ruff.flake8-builtins] 64 | builtins-ignorelist = ["id", "type", "format"] 65 | 66 | [tool.ruff.isort] 67 | force-sort-within-sections = true 68 | extra-standard-library = ["typing_extensions"] 69 | force-wrap-aliases = true 70 | combine-as-imports = true 71 | order-by-type = false 72 | relative-imports-order = "closest-to-furthest" 73 | section-order = ["future", "standard-library", "first-party", "local-folder", "third-party"] 74 | 75 | [tool.ruff.pycodestyle] 76 | ignore-overlong-task-comments = true 77 | max-doc-length = 120 78 | 79 | 80 | [build-system] 81 | requires = ["poetry-core"] 82 | build-backend = "poetry.core.masonry.api" 83 | -------------------------------------------------------------------------------- /example/send_message.py: -------------------------------------------------------------------------------- 1 | from villa import Bot 2 | from villa.event import SendMessageEvent 3 | from villa.message import Message, MessageSegment 4 | 5 | bot = Bot( 6 | bot_id="your_bot_id", 7 | bot_secret="your_bot_secret", 8 | pub_key="-----BEGIN PUBLIC KEY-----\nyour_pub_key\n-----END PUBLIC KEY-----\n", 9 | callback_url="your_callback_url_endpoint", 10 | ) 11 | 12 | 13 | @bot.on_keyword("hello") 14 | async def keyword_handler(event: SendMessageEvent): 15 | await event.send("world!") # 对事件进行快捷回复 16 | await event.send("world!", quote_message=True) # 并对事件进行引用回复 17 | await event.send("world!", mention_sender=True) # 并@消息发送者 18 | 19 | 20 | @bot.on_keyword("你好") 21 | async def keyword_handler2(event: SendMessageEvent): 22 | # 各种消息段可以通过 + 进行拼接 23 | msg = ( 24 | MessageSegment.image( 25 | "https://www.miyoushe.com/_nuxt/img/miHoYo_Game.2457753.png", 26 | ) 27 | + MessageSegment.mention_all() 28 | ) 29 | 30 | # 也可以通过 += 进行拼接 31 | msg = MessageSegment.plain_text("开头文字") # 纯文本 32 | msg += MessageSegment.quote(event.msg_uid, event.send_at) # 引用消息 33 | msg += MessageSegment.room_link( 34 | villa_id=event.villa_id, 35 | room_id=event.room_id, 36 | ) # 房间链接 37 | msg += MessageSegment.mention_user( 38 | event.from_user_id, 39 | event.content.user.name, 40 | ) # @用户 41 | msg += MessageSegment.mention_all() # @全体成员 42 | msg += MessageSegment.image( 43 | "https://www.miyoushe.com/_nuxt/img/miHoYo_Game.2457753.png", 44 | ) # 图片 45 | msg += MessageSegment.link("https://www.miyoushe.com/") # 链接 46 | msg += MessageSegment.badge( 47 | "https://upload-bbs.mihoyo.com/vila_bot/bbs_origin_badge.png", 48 | "徽标", 49 | "https://mihoyo.com", 50 | ) # 消息下方带徽标 51 | msg += MessageSegment.preview_link( 52 | icon_url="https://www.bilibili.com/favicon.ico", 53 | image_url="https://i2.hdslb.com/bfs/archive/21b82856df6b8a2ae759dddac66e2c79d41fe6bc.jpg@672w_378h_1c_!web-home-common-cover.avif", 54 | is_internal_link=False, 55 | title="崩坏3第一偶像爱酱", 56 | content="「海的女儿」——《崩坏3》S级律者角色「死生之律者」宣传PV", 57 | url="https://www.bilibili.com/video/BV1Mh4y1M79t?spm_id_from=333.1007.tianma.2-2-5.click", 58 | source_name="哔哩哔哩", 59 | ) # 预览链接(卡片) 60 | 61 | # 也可以用 Message 进行链式调用 62 | msg = ( 63 | Message("开头文字") # 纯文本 64 | .quote(event.msg_uid, event.send_at) # 引用消息 65 | .room_link(event.villa_id, event.room_id) # 房间链接 66 | .mention_user(event.from_user_id, event.content.user.name) # @用户 67 | .mention_all() # @全体成员 68 | .image("https://www.miyoushe.com/_nuxt/img/miHoYo_Game.2457753.png") # 图片 69 | .link("https://www.miyoushe.com/") # 链接 70 | .badge( 71 | "https://upload-bbs.mihoyo.com/vila_bot/bbs_origin_badge.png", 72 | "徽标", 73 | "https://mihoyo.com", 74 | ) # 消息下方带徽标 75 | .plain_text("结尾文字") # 纯文本 76 | ) 77 | 78 | # 可以转发米游社社区中的帖子 79 | msg = MessageSegment.post("https://www.miyoushe.com/ys/article/40391314") 80 | 81 | # 注意: 82 | # 帖子只能单独发送,和其他消息段时将被忽略 83 | # 如果在单次消息内,同时发送多张图片,或者和其他消息段拼接,那么图片在web端将看不见 84 | 85 | # 发送消息 86 | await event.send(msg) 87 | # 或者 88 | await bot.send(villa_id=event.villa_id, room_id=event.room_id, message=msg) 89 | 90 | 91 | if __name__ == "__main__": 92 | bot.run(host="127.0.0.1", port=13350, log_level="DEBUG") 93 | -------------------------------------------------------------------------------- /villa/handle.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | from typing import Optional, Tuple, Type 4 | 5 | from .event import Event, SendMessageEvent 6 | from .exception import StopPropagation 7 | from .log import logger 8 | from .typing import T_Handler 9 | from .utils import run_sync 10 | 11 | from pydantic import BaseModel 12 | 13 | 14 | class EventHandler(BaseModel): 15 | event_type: Tuple[Type[Event], ...] 16 | func: T_Handler 17 | priority: int = 1 18 | block: bool = False 19 | 20 | startswith: Optional[Tuple[str, ...]] = None 21 | endswith: Optional[Tuple[str, ...]] = None 22 | keywords: Optional[Tuple[str, ...]] = None 23 | regex: Optional[re.Pattern] = None 24 | 25 | def __str__(self) -> str: 26 | return ( 27 | f"Handler(func={self.func.__name__}, priority={self.priority}, " 28 | f"block={self.block})" 29 | ) 30 | 31 | def __repr__(self) -> str: 32 | return super().__str__() 33 | 34 | def _check(self, event: Event) -> bool: 35 | """检查事件是否满足处理函数运行条件""" 36 | if isinstance(event, self.event_type): 37 | if isinstance(event, SendMessageEvent): 38 | if self.startswith is not None and not event.message.startswith( 39 | self.startswith, 40 | ): 41 | logger.opt(colors=True).trace( 42 | f"{event.get_event_name()} not startswith " 43 | f"\"{'|'.join(self.startswith)}\" of {self}, pass", 44 | ) 45 | return False 46 | if self.endswith is not None and not event.message.endswith( 47 | self.endswith, 48 | ): 49 | logger.opt(colors=True).trace( 50 | f"{event.get_event_name()} not endswith " 51 | f"\"{'|'.join(self.endswith)}\" of {self}, pass", 52 | ) 53 | return False 54 | if self.keywords is not None and not any( 55 | keyword in event.message for keyword in self.keywords 56 | ): 57 | logger.opt(colors=True).trace( 58 | f"{event.get_event_name()} not contains " 59 | f"\"{'|'.join(self.keywords)}\" of {self}, pass", 60 | ) 61 | return False 62 | if self.regex is not None and not event.message.match(self.regex): 63 | logger.opt(colors=True).trace( 64 | f"{event.get_event_name()} not match " 65 | f'"{self.regex}" of {self}, pass', 66 | ) 67 | return False 68 | return True 69 | return False 70 | 71 | async def _run(self, event: Event): 72 | """运行事件处理器""" 73 | if not self._check(event): 74 | return 75 | try: 76 | logger.opt(colors=True).info( 77 | f"{event.get_event_name()} will be handled by {self}", 78 | ) 79 | if asyncio.iscoroutinefunction(self.func): 80 | await self.func(event) 81 | else: 82 | await run_sync(self.func)(event) 83 | if self.block: 84 | raise StopPropagation(handler=self) 85 | except StopPropagation as e: 86 | raise e 87 | except Exception as e: 88 | logger.opt(colors=True, exception=e).error( 89 | f"Error when running {self} for {event.get_event_name()}", 90 | ) 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Villa 4 | 5 | _✨ 米游社大别野Bot Python SDK ✨_ 6 | 7 | 8 | license 9 | 10 | version 11 | python 12 | 13 | pypi download 14 | 15 | 16 | wakatime 17 | 18 | 19 | ruff 20 | 21 | 22 |
23 | 24 | # 重要通知 25 | 26 | 因作者时间和精力问题,本 SDK 将暂停更新,**强烈建议**使用 [NoneBot2](https://github.com/nonebot/nonebot2)+ [nonebot-adapter-villa](https://github.com/CMHopeSunshine/nonebot-adapter-villa) 来开发大别野 Bot,NoneBot 有着丰富的功能和生态,以及完善的文档和众多插件供学习,对大别野的支持也是最新的,强烈建议各位开发者使用 NoneBot 替换本 SDK。 27 | 28 | ## 特性 29 | 30 | - 基于`FastAPI`和`Pydantic`,异步优先、快速、高性能! 31 | - 完整的类型注解支持,便于开发。 32 | - 便捷的消息构造和发送方法。 33 | - 完整的消息段和API支持。 34 | - `Serverless`云函数支持。 35 | - More ~~想不出来了~~ 36 | 37 | ## 安装 38 | 39 | - 使用 pip: `pip install villa` 40 | - 使用 poetry: `poetry add villa` 41 | - 使用 pdm: `pdm add villa` 42 | 43 | ## 快速开始 44 | 45 | 你需要一个[米游社大别野](https://dby.miyoushe.com/chat)的 Bot,可前往大别野[「机器人开发者社区」](https://dby.miyoushe.com/chat/463/20020)(ID: `OpenVilla`)申请,取得`bot_id`、`bot_secret`和`pub_key`。 46 | 47 | ```python 48 | from villa import Bot 49 | from villa.event import SendMessageEvent 50 | 51 | bot = Bot( 52 | bot_id="your_bot_id", 53 | bot_secret="your_bot_secret", 54 | pub_key="-----BEGIN PUBLIC KEY-----\nyour_pub_key\n-----END PUBLIC KEY-----\n", 55 | callback_url="your_callback_url_endpoint", 56 | ) 57 | # 初始化Bot,填写你的bot_id、密钥、pub_key以及回调地址endpoint 58 | # 举例:若申请时提供的回调地址为https://域名/callback,这里的callback_url就填`/callback` 59 | 60 | @bot.on_startswith("hello") 61 | async def handler(event: SendMessageEvent): 62 | await event.send("world!") 63 | # 一个简单的处理函数,向你的Bot发送包含`hello`关键词的消息,它将会回复你`world`! 64 | 65 | 66 | if __name__ == "__main__": 67 | bot.run(host="127.0.0.1", port=13350) 68 | # 启动bot,注意,port端口号要和你所使用的回调地址端口对上 69 | ``` 70 | 71 | 72 | ## 示例 73 | 74 | 详见 [example](https://github.com/CMHopeSunshine/villa-py/tree/main/example) 文件夹: 75 | 76 | - [单 Bot 运行](https://github.com/CMHopeSunshine/villa-py/blob/main/example/single_bot.py) 77 | - [多 Bot 运行](https://github.com/CMHopeSunshine/villa-py/blob/main/example/multiple_bots.py) 78 | - [处理器介绍](https://github.com/CMHopeSunshine/villa-py/blob/main/example/handle_func.py) 79 | - [消息发送方法](https://github.com/CMHopeSunshine/villa-py/blob/main/example/send_message.py) 80 | - [vercel serverless 部署](https://github.com/CMHopeSunshine/villa-py/blob/main/example/vercel.py) 81 | 82 | ## 交流、建议和反馈 83 | 84 | > 注意:本SDK并非官方SDK 85 | 86 | 大别野 Bot 和本 SDK 均为开发测试中,如遇问题请提出 [issue](https://github.com/CMHopeSunshine/villa-py/issues) ,感谢支持! 87 | 88 | 也欢迎来我的大别野[「尘世闲游」]((https://dby.miyoushe.com/chat/1047/21652))(ID: `wgiJNaU`)进行交流~ 89 | 90 | ## 相关项目 91 | 92 | - [NoneBot2](https://github.com/nonebot/nonebot2) 非常好用的 Python 跨平台机器人框架! 93 | - [nonebot-adapter-villa](https://github.com/CMHopeSunshine/nonebot-adapter-villa) NoneBot2 的大别野 Bot 适配器。 94 | - [Herta-villa-SDK](https://github.com/MingxuanGame/Herta-villa-SDK) 另一个大别野 Python SDK。 95 | -------------------------------------------------------------------------------- /villa/models.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, IntEnum 2 | import inspect 3 | import json 4 | import sys 5 | from typing import Any, Dict, List, Literal, Optional, Union 6 | 7 | from pydantic import BaseModel, Field, validator 8 | 9 | 10 | class ApiResponse(BaseModel): 11 | retcode: int 12 | message: str 13 | data: Any 14 | 15 | 16 | class BotAuth(BaseModel): 17 | bot_id: str 18 | bot_secret: str 19 | 20 | 21 | # http事件回调部分 22 | # see https://webstatic.mihoyo.com/vila/bot/doc/callback.html 23 | class Command(BaseModel): 24 | name: str 25 | desc: Optional[str] = None 26 | 27 | 28 | class Template(BaseModel): 29 | id: str 30 | name: str 31 | desc: Optional[str] = None 32 | icon: str 33 | commands: Optional[List[Command]] = None 34 | 35 | 36 | class Robot(BaseModel): 37 | villa_id: int 38 | template: Template 39 | 40 | 41 | class QuoteMessage(BaseModel): 42 | content: str 43 | msg_uid: str 44 | bot_msg_id: Optional[str] 45 | send_at: int 46 | msg_type: str 47 | from_user_id: int 48 | from_user_nickname: str 49 | from_user_id_str: str 50 | 51 | 52 | ## 鉴权部分 53 | ## see https://webstatic.mihoyo.com/vila/bot/doc/auth_api/ 54 | class BotMemberAccessInfo(BaseModel): 55 | uid: int 56 | villa_id: int 57 | member_access_token: str 58 | bot_tpl_id: str 59 | 60 | 61 | class CheckMemberBotAccessTokenReturn(BaseModel): 62 | access_info: BotMemberAccessInfo 63 | member: "Member" 64 | 65 | 66 | # 大别野部分 67 | # see https://webstatic.mihoyo.com/vila/bot/doc/villa_api/ 68 | class Villa(BaseModel): 69 | villa_id: int 70 | name: str 71 | villa_avatar_url: str 72 | onwer_uid: int 73 | is_official: bool 74 | introduce: str 75 | category_id: int 76 | tags: List[str] 77 | 78 | 79 | # 用户部分 80 | # see https://webstatic.mihoyo.com/vila/bot/doc/member_api/ 81 | class MemberBasic(BaseModel): 82 | uid: int 83 | nickname: str 84 | introduce: str 85 | avatar: int 86 | avatar_url: str 87 | 88 | 89 | class Member(BaseModel): 90 | basic: MemberBasic 91 | role_id_list: List[int] 92 | joined_at: int 93 | role_list: List["MemberRole"] 94 | 95 | 96 | class MemberListReturn(BaseModel): 97 | list: List[Member] # noqa: A003 98 | next_offset: int 99 | 100 | 101 | # 消息部分 102 | # see https://webstatic.mihoyo.com/vila/bot/doc/message_api/ 103 | class MentionType(IntEnum): 104 | ALL = 1 105 | PART = 2 106 | 107 | def __repr__(self) -> str: 108 | return self.name 109 | 110 | 111 | class MentionedRobot(BaseModel): 112 | type: Literal["mentioned_robot"] = "mentioned_robot" 113 | bot_id: str 114 | 115 | bot_name: str = Field(exclude=True) 116 | 117 | 118 | class MentionedUser(BaseModel): 119 | type: Literal["mentioned_user"] = "mentioned_user" 120 | user_id: str 121 | 122 | user_name: str = Field(exclude=True) 123 | 124 | 125 | class MentionedAll(BaseModel): 126 | type: Literal["mention_all"] = "mention_all" 127 | 128 | show_text: str = Field(exclude=True) 129 | 130 | 131 | class VillaRoomLink(BaseModel): 132 | type: Literal["villa_room_link"] = "villa_room_link" 133 | villa_id: str 134 | room_id: str 135 | 136 | room_name: str = Field(exclude=True) 137 | 138 | 139 | class Link(BaseModel): 140 | type: Literal["link"] = "link" 141 | url: str 142 | requires_bot_access_token: bool 143 | 144 | show_text: str = Field(exclude=True) 145 | 146 | 147 | class TextEntity(BaseModel): 148 | offset: int 149 | length: int 150 | entity: Union[MentionedRobot, MentionedUser, MentionedAll, VillaRoomLink, Link] 151 | 152 | 153 | class ImageSize(BaseModel): 154 | width: int 155 | height: int 156 | 157 | 158 | class Image(BaseModel): 159 | url: str 160 | size: Optional[ImageSize] = None 161 | file_size: Optional[int] = None 162 | 163 | 164 | class PreviewLink(BaseModel): 165 | icon_url: str 166 | image_url: str 167 | is_internal_link: bool 168 | title: str 169 | content: str 170 | url: str 171 | source_name: str 172 | 173 | 174 | class Badge(BaseModel): 175 | icon_url: str 176 | text: str 177 | url: str 178 | 179 | 180 | class PostMessageContent(BaseModel): 181 | post_id: str 182 | 183 | @validator("post_id") 184 | @classmethod 185 | def __deal_post_id(cls, v: str): 186 | s = v.split("/")[-1] 187 | if s.isdigit(): 188 | return s 189 | raise ValueError(f"Invalid post_id: {v}, post_id must be a number.") 190 | 191 | 192 | class TextMessageContent(BaseModel): 193 | text: str 194 | entities: List[TextEntity] = Field(default_factory=list) 195 | images: Optional[List[Image]] = None 196 | preview_link: Optional[PreviewLink] = None 197 | badge: Optional[Badge] = None 198 | 199 | 200 | class ImageMessageContent(Image): 201 | pass 202 | 203 | 204 | class MentionedInfo(BaseModel): 205 | type: MentionType 206 | user_id_list: List[str] = Field(default_factory=list, alias="userIdList") 207 | 208 | 209 | class QuoteInfo(BaseModel): 210 | quoted_message_id: str 211 | quoted_message_send_time: int 212 | original_message_id: str 213 | original_message_send_time: int 214 | 215 | 216 | class User(BaseModel): 217 | portrait_uri: str = Field(alias="portraitUri") 218 | extra: Dict[str, Any] 219 | name: str 220 | alias: str 221 | id: str 222 | portrait: str 223 | 224 | @validator("extra", pre=True) 225 | @classmethod 226 | def extra_str_to_dict(cls, v: Any): 227 | if isinstance(v, str): 228 | return json.loads(v) 229 | return v 230 | 231 | 232 | class Trace(BaseModel): 233 | visual_room_version: str 234 | app_version: str 235 | action_type: int 236 | bot_msg_id: str 237 | client: str 238 | env: str 239 | rong_sdk_version: str 240 | 241 | 242 | class MessageContentInfo(BaseModel): 243 | content: Union[TextMessageContent, ImageMessageContent, PostMessageContent] 244 | mentioned_info: Optional[MentionedInfo] = Field(default=None, alias="mentionedInfo") 245 | quote: Optional[QuoteInfo] = None 246 | 247 | 248 | class MessageContentInfoGet(MessageContentInfo): 249 | user: User 250 | trace: Optional[Trace] = None 251 | 252 | 253 | # 房间部分 254 | # see https://webstatic.mihoyo.com/vila/bot/doc/room_api/ 255 | class Room(BaseModel): 256 | room_id: int 257 | room_name: str 258 | room_type: "RoomType" 259 | group_id: int 260 | room_default_notify_type: Optional["RoomDefaultNotifyType"] = None 261 | send_msg_auth_range: Optional["SendMsgAuthRange"] = None 262 | 263 | 264 | class RoomType(str, Enum): 265 | CHAT = "BOT_PLATFORM_ROOM_TYPE_CHAT_ROOM" 266 | POST = "BOT_PLATFORM_ROOM_TYPE_POST_ROOM" 267 | SCENE = "BOT_PLATFORM_ROOM_TYPE_SCENE_ROOM" 268 | LIVE = "BOT_PLATFORM_ROOM_TYPE_LIVE_ROOM" 269 | INVALID = "BOT_PLATFORM_ROOM_TYPE_INVALID" 270 | 271 | def __repr__(self) -> str: 272 | return self.name 273 | 274 | 275 | class RoomDefaultNotifyType(str, Enum): 276 | NOTIFY = "BOT_PLATFORM_DEFAULT_NOTIFY_TYPE_NOTIFY" 277 | IGNORE = "BOT_PLATFORM_DEFAULT_NOTIFY_TYPE_IGNORE" 278 | INVALID = "BOT_PLATFORM_DEFAULT_NOTIFY_TYPE_INVALID" 279 | 280 | def __repr__(self) -> str: 281 | return self.name 282 | 283 | 284 | class SendMsgAuthRange(BaseModel): 285 | is_all_send_msg: bool 286 | roles: List[int] 287 | 288 | 289 | class GroupRoom(BaseModel): 290 | group_id: int 291 | group_name: str 292 | room_list: List[Room] 293 | 294 | 295 | class ListRoomType(IntEnum): 296 | CHAT = 1 297 | POST = 2 298 | SCENE = 3 299 | 300 | def __repr__(self) -> str: 301 | return self.name 302 | 303 | 304 | class CreateRoomType(IntEnum): 305 | CHAT = 1 306 | POST = 2 307 | SCENE = 3 308 | 309 | def __repr__(self) -> str: 310 | return self.name 311 | 312 | 313 | class CreateRoomDefaultNotifyType(IntEnum): 314 | NOTIFY = 1 315 | IGNORE = 2 316 | 317 | def __repr__(self) -> str: 318 | return self.name 319 | 320 | 321 | class Group(BaseModel): 322 | group_id: int 323 | group_name: str 324 | 325 | 326 | class RoomSort(BaseModel): 327 | room_id: int 328 | group_id: int 329 | 330 | 331 | # 身份组部分 332 | # see https://webstatic.mihoyo.com/vila/bot/doc/role_api/ 333 | class MemberRole(BaseModel): 334 | id: int 335 | name: str 336 | villa_id: int 337 | color: str 338 | web_color: str 339 | permissions: Optional[List["Permission"]] = None 340 | role_type: "RoleType" 341 | 342 | 343 | class PermissionDetail(BaseModel): 344 | key: str 345 | name: str 346 | describe: str 347 | 348 | 349 | class MemberRoleDetail(BaseModel): 350 | id: int 351 | name: str 352 | color: str 353 | villa_id: int 354 | role_type: "RoleType" 355 | member_num: int 356 | permissions: Optional[List[PermissionDetail]] = None 357 | 358 | 359 | class RoleType(str, Enum): 360 | ALL_MEMBER = "MEMBER_ROLE_TYPE_ALL_MEMBER" 361 | ADMIN = "MEMBER_ROLE_TYPE_ADMIN" 362 | OWNER = "MEMBER_ROLE_TYPE_OWNER" 363 | CUSTOM = "MEMBER_ROLE_TYPE_CUSTOM" 364 | UNKNOWN = "MEMBER_ROLE_TYPE_UNKNOWN" 365 | 366 | def __repr__(self) -> str: 367 | return self.name 368 | 369 | 370 | class Permission(str, Enum): 371 | MENTION_ALL = "mention_all" 372 | RECALL_MESSAGE = "recall_message" 373 | PIN_MESSAGE = "pin_message" 374 | MANAGE_MEMBER_ROLE = "manage_member_role" 375 | EDIT_VILLA_INFO = "edit_villa_info" 376 | MANAGE_GROUP_AND_ROOM = "manage_group_and_room" 377 | VILLA_SILENCE = "villa_silence" 378 | BLACK_OUT = "black_out" 379 | HANDLE_APPLY = "handle_apply" 380 | MANAGE_CHAT_ROOM = "manage_chat_room" 381 | VIEW_DATA_BOARD = "view_data_board" 382 | MANAGE_CUSTOM_EVENT = "manage_custom_event" 383 | LIVE_ROOM_ORDER = "live_room_order" 384 | MANAGE_SPOTLIGHT_COLLECTION = "manage_spotlight_collection" 385 | 386 | def __repr__(self) -> str: 387 | return self.name 388 | 389 | 390 | class Color(str, Enum): 391 | GREY = "#6173AB" 392 | PINK = "#F485D8" 393 | RED = "#F47884" 394 | ORANGE = "#FFA54B" 395 | GREEN = "#7ED321" 396 | BLUE = "#59A1EA" 397 | PURPLE = "#977EE1" 398 | 399 | 400 | # 表态表情部分 401 | # see https://webstatic.mihoyo.com/vila/bot/doc/emoticon_api/ 402 | class Emoticon(BaseModel): 403 | emoticon_id: int 404 | describe_text: str 405 | icon: str 406 | 407 | 408 | # 审核部分 409 | # see https://webstatic.mihoyo.com/vila/bot/doc/audit_api/audit.html 410 | class ContentType(str, Enum): 411 | TEXT = "AuditContentTypeText" 412 | IMAGE = "AuditContentTypeImage" 413 | 414 | 415 | for _, obj in inspect.getmembers(sys.modules[__name__]): 416 | if inspect.isclass(obj) and issubclass(obj, BaseModel): 417 | obj.update_forward_refs() 418 | 419 | 420 | __all__ = [ 421 | "ApiResponse", 422 | "BotAuth", 423 | "Command", 424 | "Template", 425 | "Robot", 426 | "QuoteMessage", 427 | "BotMemberAccessInfo", 428 | "CheckMemberBotAccessTokenReturn", 429 | "Villa", 430 | "MemberBasic", 431 | "Member", 432 | "MemberListReturn", 433 | "MentionType", 434 | "MentionedRobot", 435 | "MentionedUser", 436 | "MentionedAll", 437 | "VillaRoomLink", 438 | "Link", 439 | "TextEntity", 440 | "TextMessageContent", 441 | "ImageMessageContent", 442 | "PostMessageContent", 443 | "MentionedInfo", 444 | "QuoteInfo", 445 | "User", 446 | "Trace", 447 | "ImageSize", 448 | "Image", 449 | "PreviewLink", 450 | "Badge", 451 | "MessageContentInfo", 452 | "Room", 453 | "RoomType", 454 | "RoomDefaultNotifyType", 455 | "SendMsgAuthRange", 456 | "GroupRoom", 457 | "ListRoomType", 458 | "CreateRoomType", 459 | "CreateRoomDefaultNotifyType", 460 | "Group", 461 | "RoomSort", 462 | "MemberRole", 463 | "PermissionDetail", 464 | "MemberRoleDetail", 465 | "RoleType", 466 | "Permission", 467 | "Color", 468 | "Emoticon", 469 | "ContentType", 470 | ] 471 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /villa/event.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | import json 3 | from typing import Any, Dict, Literal, Optional, Union 4 | 5 | from .message import Message, MessageSegment 6 | from .models import MessageContentInfoGet, QuoteMessage, Robot 7 | from .store import get_bot 8 | from .utils import escape_tag 9 | 10 | from pydantic import BaseModel, root_validator 11 | 12 | 13 | class EventType(IntEnum): 14 | """事件类型""" 15 | 16 | JoinVilla = 1 17 | SendMessage = 2 18 | CreateRobot = 3 19 | DeleteRobot = 4 20 | AddQuickEmoticon = 5 21 | AuditCallback = 6 22 | 23 | 24 | class AuditResult(IntEnum): 25 | """审核结果类型""" 26 | 27 | Compatibility = 0 28 | """兼容""" 29 | Pass = 1 30 | """通过""" 31 | Reject = 2 32 | """驳回""" 33 | 34 | 35 | class Event(BaseModel): 36 | """Villa 事件基类""" 37 | 38 | robot: Robot 39 | """用户机器人访问凭证""" 40 | type: EventType 41 | """事件类型""" 42 | id: str 43 | """事件 id""" 44 | created_at: int 45 | """事件创建时间""" 46 | send_at: int 47 | """事件回调时间""" 48 | 49 | @property 50 | def bot_id(self) -> str: 51 | """机器人ID""" 52 | return self.robot.template.id 53 | 54 | def get_event_name(self) -> str: 55 | return f"[{self.__class__.__name__}]" 56 | 57 | def get_event_description(self) -> str: 58 | return escape_tag(str(self.dict())) 59 | 60 | 61 | class JoinVillaEvent(Event): 62 | """新用户加入大别野事件 63 | 64 | see https://webstatic.mihoyo.com/vila/bot/doc/callback.html###JoinVilla""" 65 | 66 | type: Literal[EventType.JoinVilla] = EventType.JoinVilla 67 | join_uid: int 68 | """用户ID""" 69 | join_user_nickname: str 70 | """用户昵称""" 71 | join_at: int 72 | """用户加入时间的时间戳""" 73 | 74 | @property 75 | def villa_id(self) -> int: 76 | """大别野ID""" 77 | return self.robot.villa_id 78 | 79 | def get_event_description(self) -> str: 80 | return escape_tag( 81 | ( 82 | f"User(nickname={self.join_user_nickname}," 83 | f"id={self.join_uid}) join Villa(id={self.villa_id})" 84 | ), 85 | ) 86 | 87 | 88 | class SendMessageEvent(Event): 89 | """用户@机器人发送消息事件 90 | 91 | see https://webstatic.mihoyo.com/vila/bot/doc/callback.html###SendMessage""" 92 | 93 | type: Literal[EventType.SendMessage] = EventType.SendMessage 94 | content: MessageContentInfoGet 95 | """消息内容""" 96 | from_user_id: int 97 | """发送者ID""" 98 | send_at: int 99 | """发送时间的时间戳""" 100 | room_id: int 101 | """房间ID""" 102 | object_name: int 103 | """目前只支持文本类型消息""" 104 | nickname: str 105 | """用户昵称""" 106 | msg_uid: str 107 | """消息ID""" 108 | bot_msg_id: Optional[str] 109 | """如果被回复的消息从属于机器人,则该字段不为空字符串""" 110 | quote_msg: Optional[QuoteMessage] 111 | """回调消息引用消息的基础信息""" 112 | 113 | message: Message 114 | """事件消息""" 115 | 116 | @property 117 | def villa_id(self) -> int: 118 | """大别野ID""" 119 | return self.robot.villa_id 120 | 121 | def get_event_description(self) -> str: 122 | return escape_tag( 123 | ( 124 | f"Message(id={self.msg_uid}) was sent from" 125 | f" User(nickname={self.nickname},id={self.from_user_id}) in" 126 | f" Room(id={self.room_id}) of Villa(id={self.villa_id})," 127 | f" content={repr(self.message)}" 128 | ), 129 | ) 130 | 131 | @root_validator(pre=True) 132 | @classmethod 133 | def _(cls, data: Dict[str, Any]): 134 | if not data.get("content"): 135 | return data 136 | msg = Message() 137 | data["content"] = json.loads(data["content"]) 138 | msg_content_info = data["content"] 139 | if quote := msg_content_info.get("quote"): 140 | msg.append( 141 | MessageSegment.quote( 142 | message_id=quote["quoted_message_id"], 143 | message_send_time=quote["quoted_message_send_time"], 144 | ), 145 | ) 146 | 147 | content = msg_content_info["content"] 148 | text = content["text"] 149 | entities = content["entities"] 150 | if not entities: 151 | return Message(MessageSegment.plain_text(text)) 152 | text = text.encode("utf-16") 153 | last_offset: int = 0 154 | last_length: int = 0 155 | for entity in entities: 156 | end_offset: int = last_offset + last_length 157 | offset: int = entity["offset"] 158 | length: int = entity["length"] 159 | entity_detail = entity["entity"] 160 | if offset != end_offset: 161 | msg.append( 162 | MessageSegment.plain_text( 163 | text[((end_offset + 1) * 2) : ((offset + 1) * 2)].decode( 164 | "utf-16", 165 | ), 166 | ), 167 | ) 168 | entity_text = text[(offset + 1) * 2 : (offset + length + 1) * 2].decode( 169 | "utf-16", 170 | ) 171 | if entity_detail["type"] == "mentioned_robot": 172 | entity_detail["bot_name"] = entity_text.lstrip("@")[:-1] 173 | msg.append( 174 | MessageSegment.mention_robot( 175 | entity_detail["bot_id"], 176 | entity_detail["bot_name"], 177 | ), 178 | ) 179 | elif entity_detail["type"] == "mentioned_user": 180 | entity_detail["user_name"] = entity_text.lstrip("@")[:-1] 181 | msg.append( 182 | MessageSegment.mention_user( 183 | int(entity_detail["user_id"]), 184 | data["villa_id"], 185 | ), 186 | ) 187 | elif entity_detail["type"] == "mention_all": 188 | entity_detail["show_text"] = entity_text.lstrip("@")[:-1] 189 | msg.append(MessageSegment.mention_all(entity_detail["show_text"])) 190 | elif entity_detail["type"] == "villa_room_link": 191 | entity_detail["room_name"] = entity_text.lstrip("#")[:-1] 192 | msg.append( 193 | MessageSegment.room_link( 194 | int(entity_detail["villa_id"]), 195 | int(entity_detail["room_id"]), 196 | ), 197 | ) 198 | else: 199 | entity_detail["show_text"] = entity_text 200 | msg.append(MessageSegment.link(entity_detail["url"], entity_text)) 201 | last_offset = offset 202 | last_length = length 203 | end_offset = last_offset + last_length 204 | if last_text := text[(end_offset + 1) * 2 :].decode("utf-16"): 205 | msg.append(MessageSegment.plain_text(last_text)) 206 | data["message"] = msg 207 | return data 208 | 209 | async def send( 210 | self, 211 | message: Union[str, MessageSegment, Message], 212 | mention_sender: bool = False, 213 | quote_message: bool = False, 214 | ) -> str: 215 | """对事件进行快速发送消息 216 | 217 | 参数: 218 | message: 消息内容 219 | mention_sender: 是否@发送者. 默认为 False. 220 | quote_message: 是否引用事件消息. 默认为 False. 221 | 222 | 异常: 223 | ValueError: 找不到 Bot 实例 224 | 225 | 返回: 226 | str: 消息 ID 227 | """ 228 | if (bot := get_bot(self.bot_id)) is None: 229 | raise ValueError(f"Bot {self.bot_id} not found. Cannot send message.") 230 | if isinstance(message, (str, MessageSegment)): 231 | message = Message(message) 232 | if mention_sender: 233 | message.insert( 234 | 0, 235 | MessageSegment.mention_user( 236 | self.from_user_id, 237 | self.content.user.name, 238 | self.villa_id, 239 | ), 240 | ) 241 | if quote_message: 242 | message.append(MessageSegment.quote(self.msg_uid, self.send_at)) 243 | return await bot.send(self.villa_id, self.room_id, message) 244 | 245 | 246 | class CreateRobotEvent(Event): 247 | """大别野添加机器人实例事件 248 | 249 | see https://webstatic.mihoyo.com/vila/bot/doc/callback.html###CreateRobot""" 250 | 251 | type: Literal[EventType.CreateRobot] = EventType.CreateRobot 252 | villa_id: int 253 | """大别野ID""" 254 | 255 | def get_event_description(self) -> str: 256 | return escape_tag( 257 | f"Bot(id={self.bot_id}) was added to Villa(id={self.villa_id})", 258 | ) 259 | 260 | 261 | class DeleteRobotEvent(Event): 262 | """大别野删除机器人实例事件 263 | 264 | see https://webstatic.mihoyo.com/vila/bot/doc/callback.html###DeleteRobot""" 265 | 266 | type: Literal[EventType.DeleteRobot] = EventType.DeleteRobot 267 | villa_id: int 268 | """大别野ID""" 269 | 270 | def get_event_description(self) -> str: 271 | return escape_tag( 272 | f"Bot(id={self.bot_id}) was removed from Villa(id={self.villa_id})", 273 | ) 274 | 275 | 276 | class AddQuickEmoticonEvent(Event): 277 | """用户使用表情回复消息表态事件 278 | 279 | see https://webstatic.mihoyo.com/vila/bot/doc/callback.html#AddQuickEmoticon""" 280 | 281 | type: Literal[EventType.AddQuickEmoticon] = EventType.AddQuickEmoticon 282 | villa_id: int 283 | """大别野ID""" 284 | room_id: int 285 | """房间ID""" 286 | uid: int 287 | """发送表情的用户ID""" 288 | emoticon_id: int 289 | """表情ID""" 290 | emoticon: str 291 | """表情内容""" 292 | msg_uid: str 293 | """被回复的消息 id""" 294 | bot_msg_id: Optional[str] 295 | """如果被回复的消息从属于机器人,则该字段不为空字符串""" 296 | is_cancel: bool = False 297 | """是否是取消表情""" 298 | 299 | def get_event_description(self) -> str: 300 | return escape_tag( 301 | ( 302 | f"Emoticon(name={self.emoticon}, id={self.emoticon_id}) " 303 | f"was {'removed from' if self.is_cancel else 'added to'} " 304 | f"Message(id={self.msg_uid}) by User(id={self.uid}) in " 305 | f"Room(id=Villa(id={self.room_id}) of Villa(id={self.villa_id})" 306 | ), 307 | ) 308 | 309 | async def send( 310 | self, 311 | message: Union[str, MessageSegment, Message], 312 | mention_sender: bool = False, 313 | quote_message: bool = False, 314 | ) -> str: 315 | """对事件进行快速发送消息 316 | 317 | 参数: 318 | message: 消息内容 319 | mention_sender: 是否@发送者. 默认为 False. 320 | quote_message: 是否引用事件消息. 默认为 False. 321 | 322 | 异常: 323 | ValueError: 找不到 Bot 实例 324 | 325 | 返回: 326 | str: 消息 ID 327 | """ 328 | if (bot := get_bot(self.bot_id)) is None: 329 | raise ValueError(f"Bot {self.bot_id} not found. Cannot send message.") 330 | if isinstance(message, (str, MessageSegment)): 331 | message = Message(message) 332 | if mention_sender: 333 | message.insert( 334 | 0, 335 | MessageSegment.mention_user(self.uid, None, self.villa_id), 336 | ) 337 | if quote_message: 338 | message.append(MessageSegment.quote(self.msg_uid, self.send_at)) 339 | return await bot.send(self.villa_id, self.room_id, message) 340 | 341 | 342 | class AuditCallbackEvent(Event): 343 | """审核结果回调事件 344 | 345 | see https://webstatic.mihoyo.com/vila/bot/doc/callback.html#AuditCallback""" 346 | 347 | type: Literal[EventType.AuditCallback] = EventType.AuditCallback 348 | audit_id: str 349 | """审核事件 id""" 350 | bot_tpl_id: str 351 | """机器人 id""" 352 | villa_id: int 353 | """大别野 ID""" 354 | room_id: int 355 | """房间 id(和审核接口调用方传入的值一致)""" 356 | user_id: int 357 | """用户 id(和审核接口调用方传入的值一致)""" 358 | pass_through: str 359 | """透传数据(和审核接口调用方传入的值一致)""" 360 | audit_result: AuditResult 361 | """审核结果""" 362 | 363 | def get_event_description(self) -> str: 364 | return escape_tag( 365 | ( 366 | f"Audit(id={self.audit_id},result={self.audit_result}) of " 367 | f"User(id={self.user_id}) in Room(id={self.room_id}) of " 368 | f"Villa(id={self.villa_id})" 369 | ), 370 | ) 371 | 372 | 373 | event_classes = Union[ 374 | JoinVillaEvent, 375 | SendMessageEvent, 376 | CreateRobotEvent, 377 | DeleteRobotEvent, 378 | AddQuickEmoticonEvent, 379 | AuditCallbackEvent, 380 | ] 381 | 382 | 383 | def pre_handle_event(payload: Dict[str, Any]): 384 | if (event_type := EventType._value2member_map_.get(payload["type"])) is None: 385 | raise ValueError( 386 | f"Unknown event type: {payload['type']} data={escape_tag(str(payload))}", 387 | ) 388 | event_name = event_type.name 389 | if event_name not in payload["extend_data"]["EventData"]: 390 | raise ValueError("Cannot find event data for event type: {event_name}") 391 | payload |= payload["extend_data"]["EventData"][event_name] 392 | payload.pop("extend_data") 393 | return payload 394 | 395 | 396 | __all__ = [ 397 | "Event", 398 | "JoinVillaEvent", 399 | "SendMessageEvent", 400 | "CreateRobotEvent", 401 | "DeleteRobotEvent", 402 | "AddQuickEmoticonEvent", 403 | "AuditCallbackEvent", 404 | ] 405 | -------------------------------------------------------------------------------- /villa/message.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | import re 3 | from typing import Iterable, Iterator, List, Literal, Optional, overload, Tuple, Union 4 | from typing_extensions import Self 5 | 6 | from pydantic import BaseModel, Field, root_validator 7 | from pydantic.utils import get_args # type: ignore 8 | 9 | MessageType = Literal[ 10 | "text", 11 | "mention_user", 12 | "mention_all", 13 | "mention_robot", 14 | "room_link", 15 | "link", 16 | "image", 17 | "quote", 18 | "post", 19 | "preview_link", 20 | "badge", 21 | ] 22 | 23 | 24 | class MessageSegment(ABC, BaseModel): 25 | type: MessageType 26 | """消息段基类""" 27 | 28 | @staticmethod 29 | def plain_text(text: str) -> "Text": 30 | return Text(content=text) 31 | 32 | @staticmethod 33 | def mention_robot(bot_id: str, bot_name: str) -> "MentionRobot": 34 | return MentionRobot(bot_id=bot_id, bot_name=bot_name) 35 | 36 | @staticmethod 37 | def mention_user( 38 | user_id: int, 39 | user_name: Optional[str] = None, 40 | villa_id: Optional[int] = None, 41 | ) -> "MentionUser": 42 | return MentionUser(user_id=user_id, user_name=user_name, villa_id=villa_id) 43 | 44 | @staticmethod 45 | def mention_all(show_text: str = "全体成员") -> "MentionAll": 46 | return MentionAll(show_text=show_text) 47 | 48 | @staticmethod 49 | def room_link(villa_id: int, room_id: int) -> "RoomLink": 50 | return RoomLink(villa_id=villa_id, room_id=room_id) 51 | 52 | @staticmethod 53 | def link( 54 | url: str, 55 | show_text: Optional[str] = None, 56 | requires_bot_access_token: bool = False, 57 | ) -> "Link": 58 | return Link( 59 | url=url, 60 | show_text=show_text or url, 61 | requires_bot_access_token=requires_bot_access_token, 62 | ) 63 | 64 | @staticmethod 65 | def post(post_id: str) -> "Post": 66 | return Post(post_id=post_id) 67 | 68 | @staticmethod 69 | def image( 70 | url: str, 71 | width: Optional[int] = None, 72 | height: Optional[int] = None, 73 | file_size: Optional[int] = None, 74 | ) -> "Image": 75 | return Image(url=url, width=width, height=height, file_size=file_size) 76 | 77 | @staticmethod 78 | def quote(message_id: str, message_send_time: int) -> "Quote": 79 | return Quote( 80 | quoted_message_id=message_id, 81 | quoted_message_send_time=message_send_time, 82 | original_message_id=message_id, 83 | original_message_send_time=message_send_time, 84 | ) 85 | 86 | @staticmethod 87 | def preview_link( 88 | icon_url: str, 89 | image_url: str, 90 | is_internal_link: bool, 91 | title: str, 92 | content: str, 93 | url: str, 94 | source_name: str, 95 | ) -> "PreviewLink": 96 | return PreviewLink( 97 | icon_url=icon_url, 98 | image_url=image_url, 99 | is_internal_link=is_internal_link, 100 | title=title, 101 | content=content, 102 | url=url, 103 | source_name=source_name, 104 | ) 105 | 106 | @staticmethod 107 | def badge(icon_url: str, text: str, url: str) -> "Badge": 108 | return Badge(icon_url=icon_url, text=text, url=url) 109 | 110 | def __add__(self, other: Union[str, "MessageSegment", "Message"]) -> "Message": 111 | if isinstance(other, str): 112 | return Message([self, MessageSegment.plain_text(other)]) 113 | if isinstance(other, MessageSegment): 114 | return Message([self, other]) 115 | if isinstance(other, Message): 116 | return Message([self, *other.__root__]) 117 | raise TypeError(f"unsupported type: {type(other)}") 118 | 119 | def __radd__(self, other: Union[str, "MessageSegment", "Message"]) -> "Message": 120 | return self.__add__(other) 121 | 122 | def __iadd__(self, other: Union[str, "MessageSegment", "Message"]) -> "Message": 123 | return self.__add__(other) 124 | 125 | 126 | class Text(MessageSegment): 127 | """文本消息段""" 128 | 129 | type: Literal["text"] = Field(default="text", repr=False) 130 | content: str 131 | 132 | 133 | class MentionRobot(MessageSegment): 134 | """@机器人消息段""" 135 | 136 | type: Literal["mention_robot"] = Field(default="mention_robot", repr=False) 137 | bot_id: str 138 | bot_name: str 139 | 140 | 141 | class MentionUser(MessageSegment): 142 | """@用户消息段""" 143 | 144 | type: Literal["mention_user"] = Field(default="mention_user", repr=False) 145 | user_id: int 146 | user_name: Optional[str] = None 147 | villa_id: Optional[int] = None 148 | 149 | @root_validator 150 | @classmethod 151 | def _(cls, values): 152 | if values.get("user_name") is None and values.get("villa_id") is None: 153 | raise ValueError("user_name和villa_id必须至少有一个不为None") 154 | return values 155 | 156 | 157 | class MentionAll(MessageSegment): 158 | """@全体成员消息段""" 159 | 160 | type: Literal["mention_all"] = Field(default="mention_all", repr=False) 161 | show_text: str = "全体成员" 162 | 163 | 164 | class RoomLink(MessageSegment): 165 | """房间链接消息段""" 166 | 167 | type: Literal["room_link"] = Field(default="room_link", repr=False) 168 | villa_id: int 169 | room_id: int 170 | 171 | 172 | class Link(MessageSegment): 173 | """链接消息段""" 174 | 175 | type: Literal["link"] = Field(default="link", repr=False) 176 | url: str 177 | show_text: str 178 | requires_bot_access_token: bool 179 | 180 | 181 | class Image(MessageSegment): 182 | """图片消息段""" 183 | 184 | type: Literal["image"] = Field(default="image", repr=False) 185 | url: str 186 | width: Optional[int] = None 187 | height: Optional[int] = None 188 | file_size: Optional[int] = None 189 | 190 | 191 | class Quote(MessageSegment): 192 | """引用消息段""" 193 | 194 | type: Literal["quote"] = Field(default="quote", repr=False) 195 | quoted_message_id: str 196 | quoted_message_send_time: int 197 | original_message_id: str 198 | original_message_send_time: int 199 | 200 | 201 | class Post(MessageSegment): 202 | """帖子消息段""" 203 | 204 | type: Literal["post"] = Field(default="post", repr=False) 205 | post_id: str 206 | 207 | 208 | class PreviewLink(MessageSegment): 209 | """预览链接(卡片)消息段""" 210 | 211 | type: Literal["preview_link"] = Field(default="preview_link", repr=False) 212 | icon_url: str 213 | image_url: str 214 | is_internal_link: bool 215 | title: str 216 | content: str 217 | url: str 218 | source_name: str 219 | 220 | 221 | class Badge(MessageSegment): 222 | """徽标消息段 223 | 224 | 用于在消息下方显示徽标,不支持单独发送""" 225 | 226 | type: Literal["badge"] = Field(default="badge", repr=False) 227 | icon_url: str 228 | text: str 229 | url: str 230 | 231 | 232 | class Message(BaseModel): 233 | __root__: List[MessageSegment] = Field(default_factory=list) 234 | 235 | def __init__( 236 | self, 237 | message: Union[ 238 | str, 239 | MessageSegment, 240 | Iterable[MessageSegment], 241 | "Message", 242 | None, 243 | ] = None, 244 | ): 245 | """消息类 246 | 247 | 参数: 248 | message: 消息内容. 默认为 None. 249 | 250 | 异常: 251 | TypeError: 不支持该类型的消息 252 | """ 253 | if message is None: 254 | message = [] 255 | elif isinstance(message, str): 256 | message = Text(content=message) 257 | if isinstance(message, MessageSegment): 258 | message = [message] 259 | elif isinstance(message, Message): 260 | message = message.__root__ 261 | elif not isinstance(message, Iterable): 262 | raise TypeError(f"unsupported type: {type(message)}") 263 | super().__init__(__root__=message) 264 | 265 | def plain_text(self, content: str) -> Self: 266 | """纯文本消息 267 | 268 | 参数: 269 | content: 文本内容 270 | 271 | 返回: 272 | Self: 消息对象 273 | 274 | 用法: 275 | ```python 276 | message = Message().text("hello world") 277 | ``` 278 | """ 279 | self.__root__.append(Text(content=content)) 280 | return self 281 | 282 | def mention_user( 283 | self, 284 | user_id: int, 285 | user_name: Optional[str] = None, 286 | villa_id: Optional[int] = None, 287 | ) -> Self: 288 | """提及(@at)消息 289 | 290 | 参数: 291 | villa_id: 要提及的用户所在的大别野ID 292 | user_id: 要提及的用户ID 293 | 294 | 返回: 295 | Self: 消息对象 296 | """ 297 | self.__root__.append( 298 | MentionUser(user_id=user_id, user_name=user_name, villa_id=villa_id), 299 | ) 300 | return self 301 | 302 | def mention_all(self) -> Self: 303 | """提及(@at)全体成员消息 304 | 305 | 返回: 306 | Self: 消息对象 307 | """ 308 | self.__root__.append(MentionAll()) 309 | return self 310 | 311 | def mention_robot(self, bot_id: str, bot_name: str) -> Self: 312 | """提及(@at)机器人消息 313 | 314 | 参数: 315 | bot_id: 机器人ID 316 | bot_name: 机器人名称 317 | 318 | 返回: 319 | Self: 消息对象 320 | """ 321 | self.__root__.append(MentionRobot(bot_id=bot_id, bot_name=bot_name)) 322 | return self 323 | 324 | def room_link(self, villa_id: int, room_id: int) -> Self: 325 | """房间链接消息 326 | 327 | 参数: 328 | villa_id: 大别野ID 329 | room_id: 房间ID 330 | 331 | 返回: 332 | Self: 消息对象 333 | """ 334 | self.__root__.append(RoomLink(villa_id=villa_id, room_id=room_id)) 335 | return self 336 | 337 | def link( 338 | self, 339 | url: str, 340 | show_text: Optional[str] = None, 341 | requires_bot_access_token: bool = False, 342 | ) -> Self: 343 | """说明 344 | 345 | 详细说明 346 | 347 | 参数: 348 | url: 链接地址 349 | show_text: 链接显示的文本. 为 None 时与地址保持一致. 350 | requires_bot_access_token: 访问时是否需要带上含有用户信息的token. 351 | 352 | 返回: 353 | Self: 返回说明 354 | """ 355 | self.__root__.append( 356 | Link( 357 | url=url, 358 | show_text=show_text or url, 359 | requires_bot_access_token=requires_bot_access_token, 360 | ), 361 | ) 362 | return self 363 | 364 | def image( 365 | self, 366 | url: str, 367 | width: Optional[int] = None, 368 | height: Optional[int] = None, 369 | file_size: Optional[int] = None, 370 | ) -> Self: 371 | """图片消息 372 | 373 | 参数: 374 | url: 图片链接 375 | width: 图片宽度. 默认为 None. 376 | height: 图片高度. 默认为 None. 377 | file_size: 图片大小. 默认为 None. 378 | 379 | 返回: 380 | Self: 消息对象 381 | """ 382 | self.__root__.append( 383 | Image(url=url, width=width, height=height, file_size=file_size), 384 | ) 385 | return self 386 | 387 | def quote(self, message_id: str, message_send_time: int) -> Self: 388 | """引用消息 389 | 390 | 参数: 391 | message_id: 被引用的消息ID 392 | message_send_time: 被引用消息的发送时间 393 | 394 | 返回: 395 | Self: 消息对象 396 | """ 397 | self.__root__.append( 398 | Quote( 399 | quoted_message_id=message_id, 400 | quoted_message_send_time=message_send_time, 401 | original_message_id=message_id, 402 | original_message_send_time=message_send_time, 403 | ), 404 | ) 405 | return self 406 | 407 | def post(self, post_id: str) -> Self: 408 | """帖子转发消息 409 | 410 | 参数: 411 | post_id: 帖子ID 412 | 413 | 返回: 414 | Self: 消息对象 415 | """ 416 | self.__root__.append(Post(post_id=post_id)) 417 | return self 418 | 419 | def preview_link( 420 | self, 421 | icon_url: str, 422 | image_url: str, 423 | is_internal_link: bool, 424 | title: str, 425 | content: str, 426 | url: str, 427 | source_name: str, 428 | ) -> Self: 429 | """预览链接(卡片)消息 430 | 431 | 参数: 432 | icon_url: 参数说明 433 | image_url: 参数说明 434 | is_internal_link: 参数说明 435 | title: 参数说明 436 | content: 参数说明 437 | url: 参数说明 438 | source_name: 参数说明 439 | 440 | 返回: 441 | Self: 返回说明 442 | """ 443 | self.__root__.append( 444 | PreviewLink( 445 | icon_url=icon_url, 446 | image_url=image_url, 447 | is_internal_link=is_internal_link, 448 | title=title, 449 | content=content, 450 | url=url, 451 | source_name=source_name, 452 | ), 453 | ) 454 | return self 455 | 456 | def badge(self, icon_url: str, text: str, url: str) -> Self: 457 | self.__root__.append(Badge(icon_url=icon_url, text=text, url=url)) 458 | return self 459 | 460 | def insert(self, index: int, segment: Union[str, MessageSegment]): 461 | """在指定位置插入消息段 462 | 463 | 参数: 464 | index: 插入位置 465 | segment: 消息段 466 | 467 | 返回: 468 | Self: 消息对象 469 | """ 470 | if isinstance(segment, str): 471 | segment = Text(content=segment) 472 | self.__root__.insert(index, segment) 473 | 474 | def append(self, segment: Union[str, MessageSegment]): 475 | """在消息末尾添加消息段 476 | 477 | 参数: 478 | segment: 消息段 479 | 480 | 返回: 481 | Self: 消息对象 482 | """ 483 | if isinstance(segment, str): 484 | segment = Text(content=segment) 485 | self.__root__.append(segment) 486 | 487 | def get_plain_text(self) -> str: 488 | """获取纯文本消息内容""" 489 | return "".join( 490 | [segment.content for segment in self.__root__ if isinstance(segment, Text)], 491 | ) 492 | 493 | def __contains__(self, item: str) -> bool: 494 | """检查消息的纯文本内容是否包含指定字符串""" 495 | return item in self.get_plain_text() 496 | 497 | def __len__(self) -> int: 498 | return len(self.__root__) 499 | 500 | def __add__(self, other: Union[str, "MessageSegment", "Message"]) -> "Message": 501 | result = self.copy(deep=True) 502 | if isinstance(other, str): 503 | other = Text(content=other) 504 | if isinstance(other, MessageSegment): 505 | result.__root__.append(other) 506 | elif isinstance(other, Message): 507 | result.__root__.extend(other.__root__) 508 | else: 509 | raise TypeError(f"unsupported type: {type(other)}") 510 | return result 511 | 512 | def __radd__(self, other: Union[str, "MessageSegment", "Message"]) -> "Message": 513 | return self.__add__(other) 514 | 515 | def __iadd__(self, other: Union[str, "MessageSegment", "Message"]) -> Self: 516 | if isinstance(other, str): 517 | other = Text(content=other) 518 | if isinstance(other, MessageSegment): 519 | self.__root__.append(other) 520 | elif isinstance(other, Message): 521 | self.__root__.extend(other.__root__) 522 | else: 523 | raise TypeError(f"unsupported type: {type(other)}") 524 | return self 525 | 526 | def __iter__(self) -> Iterator[MessageSegment]: 527 | return iter(self.__root__) 528 | 529 | def __repr__(self) -> str: 530 | return f"Message({repr(self.__root__)})" 531 | 532 | def has_segment_type(self, segment_type: MessageType) -> bool: 533 | """判断消息是否包含指定类型的消息段 534 | 535 | 参数: 536 | segment_type: 消息段类型 537 | 538 | 返回: 539 | bool: 是否包含指定类型的消息段 540 | """ 541 | return any(seg.type == segment_type for seg in self.__root__) 542 | 543 | def startswith(self, text: Union[str, Tuple[str, ...]]) -> bool: 544 | """判断消息的纯文本部分是否以指定字符串开头 545 | 546 | 参数: 547 | text: 指定字符串 548 | 549 | 返回: 550 | bool: 是否以指定字符串开头 551 | """ 552 | return self.get_plain_text().startswith(text) 553 | 554 | def endswith(self, text: Union[str, Tuple[str, ...]]) -> bool: 555 | """判断消息的纯文本部分是否以指定字符串结尾 556 | 557 | 参数: 558 | text: 指定字符串 559 | 560 | 返回: 561 | bool: 是否以指定字符串结尾 562 | """ 563 | return self.get_plain_text().endswith(text) 564 | 565 | def match(self, pattern: Union[str, re.Pattern]) -> Optional[re.Match]: 566 | """使用正则表达式匹配消息的纯文本部分 567 | 568 | 参数: 569 | pattern: 正则表达式 570 | 571 | 返回: 572 | Optional[re.Match]: 匹配结果 573 | """ 574 | return re.match(pattern, self.get_plain_text()) 575 | 576 | def search(self, pattern: Union[str, re.Pattern]) -> Optional[re.Match]: 577 | """使用正则表达式搜索消息的纯文本部分 578 | 579 | 参数: 580 | pattern: 正则表达式 581 | 582 | 返回: 583 | Optional[re.Match]: 匹配结果 584 | """ 585 | return re.search(pattern, self.get_plain_text()) 586 | 587 | @overload 588 | def __getitem__(self, __args: int) -> MessageSegment: 589 | """ 590 | 参数: 591 | __args: 索引 592 | 593 | 返回: 594 | MessageSegment: 第 `__args` 个消息段 595 | """ 596 | ... 597 | 598 | @overload 599 | def __getitem__(self, __args: slice) -> "Message": 600 | """ 601 | 参数: 602 | __args: 切片 603 | 604 | 返回: 605 | Message: 消息切片 `__args` 606 | """ 607 | ... 608 | 609 | @overload 610 | def __getitem__(self, __args: MessageType) -> "Message": 611 | """ 612 | 参数: 613 | __args: 消息类型 614 | 615 | 返回: 616 | Message: 消息段类型为 `__args` 的消息 617 | """ 618 | ... 619 | 620 | @overload 621 | def __getitem__(self, __args: Tuple[MessageType, slice]) -> "Message": 622 | """ 623 | 参数: 624 | __args: 消息段类型, 切片 625 | 626 | 返回: 627 | Message: 消息段类型为 `__args` 的消息切片 628 | """ 629 | ... 630 | 631 | @overload 632 | def __getitem__(self, __args: Tuple[Literal["text"], int]) -> Optional[Text]: 633 | """ 634 | 参数: 635 | __args: text消息段 636 | 637 | 返回: 638 | Text: 消息段类型为text的第 `__args[1]` 个消息段 639 | """ 640 | ... 641 | 642 | @overload 643 | def __getitem__( 644 | self, 645 | __args: Tuple[Literal["mention_user"], int], 646 | ) -> Optional[MentionUser]: 647 | """ 648 | 参数: 649 | __args: mention_user消息段 650 | 651 | 返回: 652 | MentionUser: 消息段类型为mention_user的第 `__args[1]` 个消息段 653 | """ 654 | ... 655 | 656 | @overload 657 | def __getitem__( 658 | self, 659 | __args: Tuple[Literal["mention_all"], int], 660 | ) -> Optional[MentionAll]: 661 | """ 662 | 参数: 663 | __args: mention_all消息段 664 | 665 | 返回: 666 | MentionAll: 消息段类型为mention_all的第 `__args[1]` 个消息段 667 | """ 668 | ... 669 | 670 | @overload 671 | def __getitem__( 672 | self, 673 | __args: Tuple[Literal["mention_robot"], int], 674 | ) -> Optional[MentionRobot]: 675 | """ 676 | 参数: 677 | __args: mention_robot消息段 678 | 679 | 返回: 680 | MentionRobot: 消息段类型为mention_robot的第 `__args[1]` 个消息段 681 | """ 682 | ... 683 | 684 | @overload 685 | def __getitem__( 686 | self, 687 | __args: Tuple[Literal["room_link"], int], 688 | ) -> Optional[RoomLink]: 689 | """ 690 | 参数: 691 | __args: room_link消息段 692 | 693 | 返回: 694 | RoomLink: 消息段类型为room_link的第 `__args[1]` 个消息段 695 | """ 696 | ... 697 | 698 | @overload 699 | def __getitem__(self, __args: Tuple[Literal["link"], int]) -> Optional[Link]: 700 | """ 701 | 参数: 702 | __args: link消息段 703 | 704 | 返回: 705 | Link: 消息段类型为link的第 `__args[1]` 个消息段 706 | """ 707 | ... 708 | 709 | @overload 710 | def __getitem__(self, __args: Tuple[Literal["image"], int]) -> Optional[Image]: 711 | """ 712 | 参数: 713 | __args: image消息段 714 | 715 | 返回: 716 | Image: 消息段类型为image的第 `__args[1]` 个消息段 717 | """ 718 | ... 719 | 720 | @overload 721 | def __getitem__(self, __args: Tuple[Literal["quote"], int]) -> Optional[Quote]: 722 | """ 723 | 参数: 724 | __args: quote消息段 725 | 726 | 返回: 727 | Quote: 消息段类型为quote的第 `__args[1]` 个消息段 728 | """ 729 | ... 730 | 731 | @overload 732 | def __getitem__(self, __args: Tuple[Literal["post"], int]) -> Optional[Post]: 733 | """ 734 | 参数: 735 | __args: post消息段 736 | 737 | 返回: 738 | Post: 消息段类型为post的第 `__args[1]` 个消息段 739 | """ 740 | ... 741 | 742 | @overload 743 | def __getitem__( 744 | self, 745 | __args: Tuple[Literal["preview_link"], int], 746 | ) -> Optional[PreviewLink]: 747 | """ 748 | 参数: 749 | __args: preview_link消息段 750 | 751 | 返回: 752 | PreviewLink: 消息段类型为preview_link的第 `__args[1]` 个消息段 753 | """ 754 | ... 755 | 756 | @overload 757 | def __getitem__(self, __args: Tuple[Literal["badge"], int]) -> Optional[Badge]: 758 | """ 759 | 参数: 760 | __args: badge消息段 761 | 762 | 返回: 763 | Badge: 消息段类型为badge的第 `__args[1]` 个消息段 764 | """ 765 | ... 766 | 767 | def __getitem__( 768 | self, 769 | args: Union[int, slice, MessageType, Tuple[MessageType, Union[int, slice]]], 770 | ) -> Union[MessageSegment, "Message", None]: 771 | arg1, arg2 = args if isinstance(args, tuple) else (args, None) 772 | if isinstance(arg1, int) and arg2 is None: 773 | return self.__root__.__getitem__(arg1) 774 | if isinstance(arg1, slice) and arg2 is None: 775 | return Message(self.__root__.__getitem__(arg1)) 776 | if isinstance(arg1, str) and arg1 in get_args(MessageType): # type: ignore 777 | if arg2 is None: 778 | return Message([seg for seg in self.__root__ if seg.type == arg1]) 779 | if isinstance(arg2, int): 780 | try: 781 | return [seg for seg in self.__root__ if seg.type == arg1][arg2] 782 | except IndexError: 783 | return None 784 | elif isinstance(arg2, slice): 785 | return Message([seg for seg in self.__root__ if seg.type == arg1][arg2]) 786 | else: 787 | raise ValueError("Incorrect arguments to slice") 788 | else: 789 | raise ValueError("Incorrect arguments to slice") 790 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "anyio" 5 | version = "3.7.1" 6 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, 11 | {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, 12 | ] 13 | 14 | [package.dependencies] 15 | exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} 16 | idna = ">=2.8" 17 | sniffio = ">=1.1" 18 | 19 | [package.extras] 20 | doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] 21 | test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] 22 | trio = ["trio (<0.22)"] 23 | 24 | [[package]] 25 | name = "black" 26 | version = "23.7.0" 27 | description = "The uncompromising code formatter." 28 | optional = false 29 | python-versions = ">=3.8" 30 | files = [ 31 | {file = "black-23.7.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587"}, 32 | {file = "black-23.7.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f"}, 33 | {file = "black-23.7.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be"}, 34 | {file = "black-23.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc"}, 35 | {file = "black-23.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995"}, 36 | {file = "black-23.7.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2"}, 37 | {file = "black-23.7.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd"}, 38 | {file = "black-23.7.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a"}, 39 | {file = "black-23.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926"}, 40 | {file = "black-23.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad"}, 41 | {file = "black-23.7.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f"}, 42 | {file = "black-23.7.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3"}, 43 | {file = "black-23.7.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6"}, 44 | {file = "black-23.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a"}, 45 | {file = "black-23.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320"}, 46 | {file = "black-23.7.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9"}, 47 | {file = "black-23.7.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3"}, 48 | {file = "black-23.7.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087"}, 49 | {file = "black-23.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91"}, 50 | {file = "black-23.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491"}, 51 | {file = "black-23.7.0-py3-none-any.whl", hash = "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96"}, 52 | {file = "black-23.7.0.tar.gz", hash = "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb"}, 53 | ] 54 | 55 | [package.dependencies] 56 | click = ">=8.0.0" 57 | mypy-extensions = ">=0.4.3" 58 | packaging = ">=22.0" 59 | pathspec = ">=0.9.0" 60 | platformdirs = ">=2" 61 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 62 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 63 | 64 | [package.extras] 65 | colorama = ["colorama (>=0.4.3)"] 66 | d = ["aiohttp (>=3.7.4)"] 67 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 68 | uvloop = ["uvloop (>=0.15.2)"] 69 | 70 | [[package]] 71 | name = "certifi" 72 | version = "2023.5.7" 73 | description = "Python package for providing Mozilla's CA Bundle." 74 | optional = false 75 | python-versions = ">=3.6" 76 | files = [ 77 | {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, 78 | {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, 79 | ] 80 | 81 | [[package]] 82 | name = "cfgv" 83 | version = "3.3.1" 84 | description = "Validate configuration and produce human readable error messages." 85 | optional = false 86 | python-versions = ">=3.6.1" 87 | files = [ 88 | {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, 89 | {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, 90 | ] 91 | 92 | [[package]] 93 | name = "click" 94 | version = "8.1.4" 95 | description = "Composable command line interface toolkit" 96 | optional = false 97 | python-versions = ">=3.7" 98 | files = [ 99 | {file = "click-8.1.4-py3-none-any.whl", hash = "sha256:2739815aaa5d2c986a88f1e9230c55e17f0caad3d958a5e13ad0797c166db9e3"}, 100 | {file = "click-8.1.4.tar.gz", hash = "sha256:b97d0c74955da062a7d4ef92fadb583806a585b2ea81958a81bd72726cbb8e37"}, 101 | ] 102 | 103 | [package.dependencies] 104 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 105 | 106 | [[package]] 107 | name = "colorama" 108 | version = "0.4.6" 109 | description = "Cross-platform colored terminal text." 110 | optional = false 111 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 112 | files = [ 113 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 114 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 115 | ] 116 | 117 | [[package]] 118 | name = "distlib" 119 | version = "0.3.6" 120 | description = "Distribution utilities" 121 | optional = false 122 | python-versions = "*" 123 | files = [ 124 | {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, 125 | {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, 126 | ] 127 | 128 | [[package]] 129 | name = "exceptiongroup" 130 | version = "1.1.2" 131 | description = "Backport of PEP 654 (exception groups)" 132 | optional = false 133 | python-versions = ">=3.7" 134 | files = [ 135 | {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, 136 | {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, 137 | ] 138 | 139 | [package.extras] 140 | test = ["pytest (>=6)"] 141 | 142 | [[package]] 143 | name = "fastapi" 144 | version = "0.96.1" 145 | description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" 146 | optional = false 147 | python-versions = ">=3.7" 148 | files = [ 149 | {file = "fastapi-0.96.1-py3-none-any.whl", hash = "sha256:22d773ce95f14f04f8f37a0c8998fc163e67af83b65510d2879de6cbaaa10215"}, 150 | {file = "fastapi-0.96.1.tar.gz", hash = "sha256:5c1d243030e63089ccfc0aec69c2da6d619943917727e8e82ee502358d5119bf"}, 151 | ] 152 | 153 | [package.dependencies] 154 | pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" 155 | starlette = ">=0.27.0,<0.28.0" 156 | 157 | [package.extras] 158 | all = ["email-validator (>=1.1.1)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] 159 | dev = ["pre-commit (>=2.17.0,<3.0.0)", "ruff (==0.0.138)", "uvicorn[standard] (>=0.12.0,<0.21.0)"] 160 | doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pyyaml (>=5.3.1,<7.0.0)", "typer-cli (>=0.0.13,<0.0.14)", "typer[all] (>=0.6.1,<0.8.0)"] 161 | test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==23.1.0)", "coverage[toml] (>=6.5.0,<8.0)", "databases[sqlite] (>=0.3.2,<0.7.0)", "email-validator (>=1.1.1,<2.0.0)", "flask (>=1.1.2,<3.0.0)", "httpx (>=0.23.0,<0.24.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.982)", "orjson (>=3.2.1,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "peewee (>=3.13.3,<4.0.0)", "pytest (>=7.1.3,<8.0.0)", "python-jose[cryptography] (>=3.3.0,<4.0.0)", "python-multipart (>=0.0.5,<0.0.7)", "pyyaml (>=5.3.1,<7.0.0)", "ruff (==0.0.138)", "sqlalchemy (>=1.3.18,<1.4.43)", "types-orjson (==3.6.2)", "types-ujson (==5.7.0.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0,<6.0.0)"] 162 | 163 | [[package]] 164 | name = "filelock" 165 | version = "3.12.2" 166 | description = "A platform independent file lock." 167 | optional = false 168 | python-versions = ">=3.7" 169 | files = [ 170 | {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"}, 171 | {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"}, 172 | ] 173 | 174 | [package.extras] 175 | docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 176 | testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] 177 | 178 | [[package]] 179 | name = "h11" 180 | version = "0.14.0" 181 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 182 | optional = false 183 | python-versions = ">=3.7" 184 | files = [ 185 | {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, 186 | {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, 187 | ] 188 | 189 | [[package]] 190 | name = "httpcore" 191 | version = "0.17.3" 192 | description = "A minimal low-level HTTP client." 193 | optional = false 194 | python-versions = ">=3.7" 195 | files = [ 196 | {file = "httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"}, 197 | {file = "httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888"}, 198 | ] 199 | 200 | [package.dependencies] 201 | anyio = ">=3.0,<5.0" 202 | certifi = "*" 203 | h11 = ">=0.13,<0.15" 204 | sniffio = "==1.*" 205 | 206 | [package.extras] 207 | http2 = ["h2 (>=3,<5)"] 208 | socks = ["socksio (==1.*)"] 209 | 210 | [[package]] 211 | name = "httpx" 212 | version = "0.24.1" 213 | description = "The next generation HTTP client." 214 | optional = false 215 | python-versions = ">=3.7" 216 | files = [ 217 | {file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"}, 218 | {file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"}, 219 | ] 220 | 221 | [package.dependencies] 222 | certifi = "*" 223 | httpcore = ">=0.15.0,<0.18.0" 224 | idna = "*" 225 | sniffio = "*" 226 | 227 | [package.extras] 228 | brotli = ["brotli", "brotlicffi"] 229 | cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] 230 | http2 = ["h2 (>=3,<5)"] 231 | socks = ["socksio (==1.*)"] 232 | 233 | [[package]] 234 | name = "identify" 235 | version = "2.5.24" 236 | description = "File identification library for Python" 237 | optional = false 238 | python-versions = ">=3.7" 239 | files = [ 240 | {file = "identify-2.5.24-py2.py3-none-any.whl", hash = "sha256:986dbfb38b1140e763e413e6feb44cd731faf72d1909543178aa79b0e258265d"}, 241 | {file = "identify-2.5.24.tar.gz", hash = "sha256:0aac67d5b4812498056d28a9a512a483f5085cc28640b02b258a59dac34301d4"}, 242 | ] 243 | 244 | [package.extras] 245 | license = ["ukkonen"] 246 | 247 | [[package]] 248 | name = "idna" 249 | version = "3.4" 250 | description = "Internationalized Domain Names in Applications (IDNA)" 251 | optional = false 252 | python-versions = ">=3.5" 253 | files = [ 254 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 255 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 256 | ] 257 | 258 | [[package]] 259 | name = "loguru" 260 | version = "0.7.0" 261 | description = "Python logging made (stupidly) simple" 262 | optional = false 263 | python-versions = ">=3.5" 264 | files = [ 265 | {file = "loguru-0.7.0-py3-none-any.whl", hash = "sha256:b93aa30099fa6860d4727f1b81f8718e965bb96253fa190fab2077aaad6d15d3"}, 266 | {file = "loguru-0.7.0.tar.gz", hash = "sha256:1612053ced6ae84d7959dd7d5e431a0532642237ec21f7fd83ac73fe539e03e1"}, 267 | ] 268 | 269 | [package.dependencies] 270 | colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} 271 | win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} 272 | 273 | [package.extras] 274 | dev = ["Sphinx (==5.3.0)", "colorama (==0.4.5)", "colorama (==0.4.6)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v0.990)", "pre-commit (==3.2.1)", "pytest (==6.1.2)", "pytest (==7.2.1)", "pytest-cov (==2.12.1)", "pytest-cov (==4.0.0)", "pytest-mypy-plugins (==1.10.1)", "pytest-mypy-plugins (==1.9.3)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.2.0)", "tox (==3.27.1)", "tox (==4.4.6)"] 275 | 276 | [[package]] 277 | name = "mypy-extensions" 278 | version = "1.0.0" 279 | description = "Type system extensions for programs checked with the mypy type checker." 280 | optional = false 281 | python-versions = ">=3.5" 282 | files = [ 283 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 284 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 285 | ] 286 | 287 | [[package]] 288 | name = "nodeenv" 289 | version = "1.8.0" 290 | description = "Node.js virtual environment builder" 291 | optional = false 292 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 293 | files = [ 294 | {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, 295 | {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, 296 | ] 297 | 298 | [package.dependencies] 299 | setuptools = "*" 300 | 301 | [[package]] 302 | name = "packaging" 303 | version = "23.1" 304 | description = "Core utilities for Python packages" 305 | optional = false 306 | python-versions = ">=3.7" 307 | files = [ 308 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 309 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 310 | ] 311 | 312 | [[package]] 313 | name = "pathspec" 314 | version = "0.11.1" 315 | description = "Utility library for gitignore style pattern matching of file paths." 316 | optional = false 317 | python-versions = ">=3.7" 318 | files = [ 319 | {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, 320 | {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, 321 | ] 322 | 323 | [[package]] 324 | name = "platformdirs" 325 | version = "3.8.1" 326 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 327 | optional = false 328 | python-versions = ">=3.7" 329 | files = [ 330 | {file = "platformdirs-3.8.1-py3-none-any.whl", hash = "sha256:cec7b889196b9144d088e4c57d9ceef7374f6c39694ad1577a0aab50d27ea28c"}, 331 | {file = "platformdirs-3.8.1.tar.gz", hash = "sha256:f87ca4fcff7d2b0f81c6a748a77973d7af0f4d526f98f308477c3c436c74d528"}, 332 | ] 333 | 334 | [package.extras] 335 | docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 336 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] 337 | 338 | [[package]] 339 | name = "pre-commit" 340 | version = "3.3.3" 341 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 342 | optional = false 343 | python-versions = ">=3.8" 344 | files = [ 345 | {file = "pre_commit-3.3.3-py2.py3-none-any.whl", hash = "sha256:10badb65d6a38caff29703362271d7dca483d01da88f9d7e05d0b97171c136cb"}, 346 | {file = "pre_commit-3.3.3.tar.gz", hash = "sha256:a2256f489cd913d575c145132ae196fe335da32d91a8294b7afe6622335dd023"}, 347 | ] 348 | 349 | [package.dependencies] 350 | cfgv = ">=2.0.0" 351 | identify = ">=1.0.0" 352 | nodeenv = ">=0.11.1" 353 | pyyaml = ">=5.1" 354 | virtualenv = ">=20.10.0" 355 | 356 | [[package]] 357 | name = "pyasn1" 358 | version = "0.5.0" 359 | description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" 360 | optional = false 361 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" 362 | files = [ 363 | {file = "pyasn1-0.5.0-py2.py3-none-any.whl", hash = "sha256:87a2121042a1ac9358cabcaf1d07680ff97ee6404333bacca15f76aa8ad01a57"}, 364 | {file = "pyasn1-0.5.0.tar.gz", hash = "sha256:97b7290ca68e62a832558ec3976f15cbf911bf5d7c7039d8b861c2a0ece69fde"}, 365 | ] 366 | 367 | [[package]] 368 | name = "pydantic" 369 | version = "1.10.11" 370 | description = "Data validation and settings management using python type hints" 371 | optional = false 372 | python-versions = ">=3.7" 373 | files = [ 374 | {file = "pydantic-1.10.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ff44c5e89315b15ff1f7fdaf9853770b810936d6b01a7bcecaa227d2f8fe444f"}, 375 | {file = "pydantic-1.10.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6c098d4ab5e2d5b3984d3cb2527e2d6099d3de85630c8934efcfdc348a9760e"}, 376 | {file = "pydantic-1.10.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16928fdc9cb273c6af00d9d5045434c39afba5f42325fb990add2c241402d151"}, 377 | {file = "pydantic-1.10.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0588788a9a85f3e5e9ebca14211a496409cb3deca5b6971ff37c556d581854e7"}, 378 | {file = "pydantic-1.10.11-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e9baf78b31da2dc3d3f346ef18e58ec5f12f5aaa17ac517e2ffd026a92a87588"}, 379 | {file = "pydantic-1.10.11-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:373c0840f5c2b5b1ccadd9286782852b901055998136287828731868027a724f"}, 380 | {file = "pydantic-1.10.11-cp310-cp310-win_amd64.whl", hash = "sha256:c3339a46bbe6013ef7bdd2844679bfe500347ac5742cd4019a88312aa58a9847"}, 381 | {file = "pydantic-1.10.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:08a6c32e1c3809fbc49debb96bf833164f3438b3696abf0fbeceb417d123e6eb"}, 382 | {file = "pydantic-1.10.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a451ccab49971af043ec4e0d207cbc8cbe53dbf148ef9f19599024076fe9c25b"}, 383 | {file = "pydantic-1.10.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b02d24f7b2b365fed586ed73582c20f353a4c50e4be9ba2c57ab96f8091ddae"}, 384 | {file = "pydantic-1.10.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f34739a89260dfa420aa3cbd069fbcc794b25bbe5c0a214f8fb29e363484b66"}, 385 | {file = "pydantic-1.10.11-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e297897eb4bebde985f72a46a7552a7556a3dd11e7f76acda0c1093e3dbcf216"}, 386 | {file = "pydantic-1.10.11-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d185819a7a059550ecb85d5134e7d40f2565f3dd94cfd870132c5f91a89cf58c"}, 387 | {file = "pydantic-1.10.11-cp311-cp311-win_amd64.whl", hash = "sha256:4400015f15c9b464c9db2d5d951b6a780102cfa5870f2c036d37c23b56f7fc1b"}, 388 | {file = "pydantic-1.10.11-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2417de68290434461a266271fc57274a138510dca19982336639484c73a07af6"}, 389 | {file = "pydantic-1.10.11-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:331c031ba1554b974c98679bd0780d89670d6fd6f53f5d70b10bdc9addee1713"}, 390 | {file = "pydantic-1.10.11-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8268a735a14c308923e8958363e3a3404f6834bb98c11f5ab43251a4e410170c"}, 391 | {file = "pydantic-1.10.11-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:44e51ba599c3ef227e168424e220cd3e544288c57829520dc90ea9cb190c3248"}, 392 | {file = "pydantic-1.10.11-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d7781f1d13b19700b7949c5a639c764a077cbbdd4322ed505b449d3ca8edcb36"}, 393 | {file = "pydantic-1.10.11-cp37-cp37m-win_amd64.whl", hash = "sha256:7522a7666157aa22b812ce14c827574ddccc94f361237ca6ea8bb0d5c38f1629"}, 394 | {file = "pydantic-1.10.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc64eab9b19cd794a380179ac0e6752335e9555d214cfcb755820333c0784cb3"}, 395 | {file = "pydantic-1.10.11-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8dc77064471780262b6a68fe67e013298d130414d5aaf9b562c33987dbd2cf4f"}, 396 | {file = "pydantic-1.10.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe429898f2c9dd209bd0632a606bddc06f8bce081bbd03d1c775a45886e2c1cb"}, 397 | {file = "pydantic-1.10.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:192c608ad002a748e4a0bed2ddbcd98f9b56df50a7c24d9a931a8c5dd053bd3d"}, 398 | {file = "pydantic-1.10.11-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ef55392ec4bb5721f4ded1096241e4b7151ba6d50a50a80a2526c854f42e6a2f"}, 399 | {file = "pydantic-1.10.11-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:41e0bb6efe86281623abbeeb0be64eab740c865388ee934cd3e6a358784aca6e"}, 400 | {file = "pydantic-1.10.11-cp38-cp38-win_amd64.whl", hash = "sha256:265a60da42f9f27e0b1014eab8acd3e53bd0bad5c5b4884e98a55f8f596b2c19"}, 401 | {file = "pydantic-1.10.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:469adf96c8e2c2bbfa655fc7735a2a82f4c543d9fee97bd113a7fb509bf5e622"}, 402 | {file = "pydantic-1.10.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6cbfbd010b14c8a905a7b10f9fe090068d1744d46f9e0c021db28daeb8b6de1"}, 403 | {file = "pydantic-1.10.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abade85268cc92dff86d6effcd917893130f0ff516f3d637f50dadc22ae93999"}, 404 | {file = "pydantic-1.10.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9738b0f2e6c70f44ee0de53f2089d6002b10c33264abee07bdb5c7f03038303"}, 405 | {file = "pydantic-1.10.11-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:787cf23e5a0cde753f2eabac1b2e73ae3844eb873fd1f5bdbff3048d8dbb7604"}, 406 | {file = "pydantic-1.10.11-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:174899023337b9fc685ac8adaa7b047050616136ccd30e9070627c1aaab53a13"}, 407 | {file = "pydantic-1.10.11-cp39-cp39-win_amd64.whl", hash = "sha256:1954f8778489a04b245a1e7b8b22a9d3ea8ef49337285693cf6959e4b757535e"}, 408 | {file = "pydantic-1.10.11-py3-none-any.whl", hash = "sha256:008c5e266c8aada206d0627a011504e14268a62091450210eda7c07fabe6963e"}, 409 | {file = "pydantic-1.10.11.tar.gz", hash = "sha256:f66d479cf7eb331372c470614be6511eae96f1f120344c25f3f9bb59fb1b5528"}, 410 | ] 411 | 412 | [package.dependencies] 413 | typing-extensions = ">=4.2.0" 414 | 415 | [package.extras] 416 | dotenv = ["python-dotenv (>=0.10.4)"] 417 | email = ["email-validator (>=1.0.3)"] 418 | 419 | [[package]] 420 | name = "pyyaml" 421 | version = "6.0" 422 | description = "YAML parser and emitter for Python" 423 | optional = false 424 | python-versions = ">=3.6" 425 | files = [ 426 | {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 427 | {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, 428 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, 429 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, 430 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, 431 | {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, 432 | {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, 433 | {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, 434 | {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, 435 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, 436 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, 437 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, 438 | {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, 439 | {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, 440 | {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, 441 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, 442 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, 443 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, 444 | {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, 445 | {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, 446 | {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, 447 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, 448 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, 449 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, 450 | {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, 451 | {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, 452 | {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, 453 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, 454 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, 455 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, 456 | {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, 457 | {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, 458 | {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, 459 | {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, 460 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, 461 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, 462 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, 463 | {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, 464 | {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, 465 | {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, 466 | ] 467 | 468 | [[package]] 469 | name = "rsa" 470 | version = "4.9" 471 | description = "Pure-Python RSA implementation" 472 | optional = false 473 | python-versions = ">=3.6,<4" 474 | files = [ 475 | {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, 476 | {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, 477 | ] 478 | 479 | [package.dependencies] 480 | pyasn1 = ">=0.1.3" 481 | 482 | [[package]] 483 | name = "ruff" 484 | version = "0.0.277" 485 | description = "An extremely fast Python linter, written in Rust." 486 | optional = false 487 | python-versions = ">=3.7" 488 | files = [ 489 | {file = "ruff-0.0.277-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:3250b24333ef419b7a232080d9724ccc4d2da1dbbe4ce85c4caa2290d83200f8"}, 490 | {file = "ruff-0.0.277-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:3e60605e07482183ba1c1b7237eca827bd6cbd3535fe8a4ede28cbe2a323cb97"}, 491 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7baa97c3d7186e5ed4d5d4f6834d759a27e56cf7d5874b98c507335f0ad5aadb"}, 492 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:74e4b206cb24f2e98a615f87dbe0bde18105217cbcc8eb785bb05a644855ba50"}, 493 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:479864a3ccd8a6a20a37a6e7577bdc2406868ee80b1e65605478ad3b8eb2ba0b"}, 494 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:468bfb0a7567443cec3d03cf408d6f562b52f30c3c29df19927f1e0e13a40cd7"}, 495 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f32ec416c24542ca2f9cc8c8b65b84560530d338aaf247a4a78e74b99cd476b4"}, 496 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:14a7b2f00f149c5a295f188a643ac25226ff8a4d08f7a62b1d4b0a1dc9f9b85c"}, 497 | {file = "ruff-0.0.277-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9879f59f763cc5628aa01c31ad256a0f4dc61a29355c7315b83c2a5aac932b5"}, 498 | {file = "ruff-0.0.277-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f612e0a14b3d145d90eb6ead990064e22f6f27281d847237560b4e10bf2251f3"}, 499 | {file = "ruff-0.0.277-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:323b674c98078be9aaded5b8b51c0d9c424486566fb6ec18439b496ce79e5998"}, 500 | {file = "ruff-0.0.277-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3a43fbe026ca1a2a8c45aa0d600a0116bec4dfa6f8bf0c3b871ecda51ef2b5dd"}, 501 | {file = "ruff-0.0.277-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:734165ea8feb81b0d53e3bf523adc2413fdb76f1264cde99555161dd5a725522"}, 502 | {file = "ruff-0.0.277-py3-none-win32.whl", hash = "sha256:88d0f2afb2e0c26ac1120e7061ddda2a566196ec4007bd66d558f13b374b9efc"}, 503 | {file = "ruff-0.0.277-py3-none-win_amd64.whl", hash = "sha256:6fe81732f788894a00f6ade1fe69e996cc9e485b7c35b0f53fb00284397284b2"}, 504 | {file = "ruff-0.0.277-py3-none-win_arm64.whl", hash = "sha256:2d4444c60f2e705c14cd802b55cd2b561d25bf4311702c463a002392d3116b22"}, 505 | {file = "ruff-0.0.277.tar.gz", hash = "sha256:2dab13cdedbf3af6d4427c07f47143746b6b95d9e4a254ac369a0edb9280a0d2"}, 506 | ] 507 | 508 | [[package]] 509 | name = "setuptools" 510 | version = "68.0.0" 511 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 512 | optional = false 513 | python-versions = ">=3.7" 514 | files = [ 515 | {file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"}, 516 | {file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"}, 517 | ] 518 | 519 | [package.extras] 520 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 521 | testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 522 | testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] 523 | 524 | [[package]] 525 | name = "sniffio" 526 | version = "1.3.0" 527 | description = "Sniff out which async library your code is running under" 528 | optional = false 529 | python-versions = ">=3.7" 530 | files = [ 531 | {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, 532 | {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, 533 | ] 534 | 535 | [[package]] 536 | name = "starlette" 537 | version = "0.27.0" 538 | description = "The little ASGI library that shines." 539 | optional = false 540 | python-versions = ">=3.7" 541 | files = [ 542 | {file = "starlette-0.27.0-py3-none-any.whl", hash = "sha256:918416370e846586541235ccd38a474c08b80443ed31c578a418e2209b3eef91"}, 543 | {file = "starlette-0.27.0.tar.gz", hash = "sha256:6a6b0d042acb8d469a01eba54e9cda6cbd24ac602c4cd016723117d6a7e73b75"}, 544 | ] 545 | 546 | [package.dependencies] 547 | anyio = ">=3.4.0,<5" 548 | typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} 549 | 550 | [package.extras] 551 | full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] 552 | 553 | [[package]] 554 | name = "tomli" 555 | version = "2.0.1" 556 | description = "A lil' TOML parser" 557 | optional = false 558 | python-versions = ">=3.7" 559 | files = [ 560 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 561 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 562 | ] 563 | 564 | [[package]] 565 | name = "typing-extensions" 566 | version = "4.7.1" 567 | description = "Backported and Experimental Type Hints for Python 3.7+" 568 | optional = false 569 | python-versions = ">=3.7" 570 | files = [ 571 | {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, 572 | {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, 573 | ] 574 | 575 | [[package]] 576 | name = "uvicorn" 577 | version = "0.22.0" 578 | description = "The lightning-fast ASGI server." 579 | optional = false 580 | python-versions = ">=3.7" 581 | files = [ 582 | {file = "uvicorn-0.22.0-py3-none-any.whl", hash = "sha256:e9434d3bbf05f310e762147f769c9f21235ee118ba2d2bf1155a7196448bd996"}, 583 | {file = "uvicorn-0.22.0.tar.gz", hash = "sha256:79277ae03db57ce7d9aa0567830bbb51d7a612f54d6e1e3e92da3ef24c2c8ed8"}, 584 | ] 585 | 586 | [package.dependencies] 587 | click = ">=7.0" 588 | h11 = ">=0.8" 589 | 590 | [package.extras] 591 | standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] 592 | 593 | [[package]] 594 | name = "virtualenv" 595 | version = "20.23.1" 596 | description = "Virtual Python Environment builder" 597 | optional = false 598 | python-versions = ">=3.7" 599 | files = [ 600 | {file = "virtualenv-20.23.1-py3-none-any.whl", hash = "sha256:34da10f14fea9be20e0fd7f04aba9732f84e593dac291b757ce42e3368a39419"}, 601 | {file = "virtualenv-20.23.1.tar.gz", hash = "sha256:8ff19a38c1021c742148edc4f81cb43d7f8c6816d2ede2ab72af5b84c749ade1"}, 602 | ] 603 | 604 | [package.dependencies] 605 | distlib = ">=0.3.6,<1" 606 | filelock = ">=3.12,<4" 607 | platformdirs = ">=3.5.1,<4" 608 | 609 | [package.extras] 610 | docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 611 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezer (>=0.4.6)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.8)", "time-machine (>=2.9)"] 612 | 613 | [[package]] 614 | name = "win32-setctime" 615 | version = "1.1.0" 616 | description = "A small Python utility to set file creation time on Windows" 617 | optional = false 618 | python-versions = ">=3.5" 619 | files = [ 620 | {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, 621 | {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, 622 | ] 623 | 624 | [package.extras] 625 | dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] 626 | 627 | [metadata] 628 | lock-version = "2.0" 629 | python-versions = "^3.8" 630 | content-hash = "5fc158e4199178325ea7983c0df2496f176df75922972ceea0ba08dd53bc817f" 631 | -------------------------------------------------------------------------------- /villa/bot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | from collections import defaultdict 4 | import hashlib 5 | import hmac 6 | from itertools import product 7 | import re 8 | from typing import Any, DefaultDict, Dict, List, Literal, Optional, Set, Type, Union 9 | from urllib.parse import urlencode, urlparse 10 | 11 | from .event import Event, event_classes, pre_handle_event, SendMessageEvent 12 | from .exception import ( 13 | ActionFailed, 14 | BotNotAdded, 15 | InsufficientPermission, 16 | InvalidBotAuthInfo, 17 | InvalidMemberBotAccessToken, 18 | InvalidRequest, 19 | PermissionDenied, 20 | StopPropagation, 21 | UnknownServerError, 22 | UnsupportedMsgType, 23 | ) 24 | from .handle import EventHandler 25 | from .log import _log_patcher, logger 26 | from .message import ( 27 | Image as ImageSegment, 28 | Link as LinkSegment, 29 | MentionAll as MentionAllSegment, 30 | MentionRobot as MentionRobotSegment, 31 | MentionUser as MentionUserSegment, 32 | Message, 33 | MessageSegment, 34 | RoomLink as RoomLinkSegment, 35 | Text as TextSegment, 36 | ) 37 | from .models import * 38 | from .store import get_app, get_bot, store_bot 39 | from .typing import T_Func, T_Handler 40 | from .utils import escape_tag 41 | 42 | from fastapi import BackgroundTasks, FastAPI, Request 43 | from fastapi.responses import JSONResponse 44 | import httpx 45 | from httpx._types import TimeoutTypes 46 | from pydantic import parse_obj_as 47 | import rsa 48 | import uvicorn 49 | 50 | 51 | class Bot: 52 | """Villa Bot""" 53 | 54 | _event_handlers: DefaultDict[int, List[EventHandler]] = defaultdict(list) 55 | """事件处理函数""" 56 | _client: httpx.AsyncClient 57 | bot_id: str 58 | """机器人 Id""" 59 | bot_secret: str 60 | """机器人密钥""" 61 | callback_endpoint: Optional[str] = None 62 | """事件回调地址""" 63 | wait_util_complete: bool = False 64 | """是否等待事件处理全部完成后再响应""" 65 | _bot_info: Optional[Robot] = None 66 | """机器人信息""" 67 | 68 | def __init__( 69 | self, 70 | bot_id: str, 71 | bot_secret: str, 72 | pub_key: str, 73 | callback_url: Optional[str] = None, 74 | wait_util_complete: bool = False, 75 | api_timeout: TimeoutTypes = 10, 76 | verify_event: bool = True, 77 | ): 78 | """初始化一个 Bot 实例 79 | 80 | 参数: 81 | bot_id: 机器人 ID 82 | bot_secret: 机器人密钥 83 | pub_key: 机器人 pub_key 84 | callback_url: 事件回调地址 85 | wait_util_complete: 是否等待事件处理完成后响应 86 | api_timeout: API 调用超时时间 87 | verify_event: 是否对事件进行验证 88 | """ 89 | _pub_key = pub_key.encode() 90 | self.bot_id = bot_id 91 | self.bot_secret = bot_secret 92 | self.pub_key = rsa.PublicKey.load_pkcs1_openssl_pem(_pub_key) 93 | self.bot_secret_encrypt = hmac.new( 94 | _pub_key, 95 | bot_secret.encode(), 96 | hashlib.sha256, 97 | ).hexdigest() 98 | self.verify_event = verify_event 99 | if callback_url is not None: 100 | self.callback_endpoint = urlparse(callback_url).path or "/" 101 | self.wait_util_complete = wait_util_complete 102 | self._client = httpx.AsyncClient( 103 | base_url="https://bbs-api.miyoushe.com/vila/api/bot/platform/", 104 | timeout=api_timeout, 105 | ) 106 | store_bot(self) 107 | 108 | @property 109 | def nickname(self) -> str: 110 | """Bot 昵称""" 111 | if self._bot_info is None: 112 | raise ValueError(f"Bot {self.bot_id} not connected") 113 | return self._bot_info.template.name 114 | 115 | @property 116 | def avatar_icon(self) -> str: 117 | """Bot 头像地址""" 118 | if self._bot_info is None: 119 | raise ValueError(f"Bot {self.bot_id} not connected") 120 | return self._bot_info.template.icon 121 | 122 | @property 123 | def commands(self) -> Optional[List[Command]]: 124 | """Bot 预设命令列表""" 125 | if self._bot_info is None: 126 | raise ValueError(f"Bot {self.bot_id} not connected") 127 | return self._bot_info.template.commands 128 | 129 | @property 130 | def description(self) -> Optional[str]: 131 | """Bot 介绍""" 132 | if self._bot_info is None: 133 | raise ValueError(f"Bot {self.bot_id} not connected") 134 | return self._bot_info.template.desc 135 | 136 | @property 137 | def current_villa_id(self) -> int: 138 | """Bot 最后收到的事件的大别野 ID""" 139 | if self._bot_info is None: 140 | raise ValueError(f"Bot {self.bot_id} not connected") 141 | return self._bot_info.villa_id 142 | 143 | def on_event( 144 | self, 145 | *event_type: Type[Event], 146 | block: bool = False, 147 | priority: int = 1, 148 | ): 149 | """注册一个事件处理函数 150 | 151 | 当事件属于 event_type 中的任意一个时,执行处理函数。 152 | 153 | 参数: 154 | *event_type: 事件类型列表. 155 | block: 是否阻止更低优先级的处理函数执行. 默认为 False. 156 | priority: 优先级. 默认为 1. 157 | """ 158 | 159 | def _decorator(func: T_Handler) -> T_Handler: 160 | self._event_handlers[priority].append( 161 | EventHandler( 162 | event_type=event_type, 163 | func=func, 164 | block=block, 165 | priority=priority, 166 | ), 167 | ) 168 | return func 169 | 170 | return _decorator 171 | 172 | def on_message(self, block: bool = False, priority: int = 1): 173 | """注册一个消息事件处理函数 174 | 175 | 当事件属于 SendMessageEvent 消息事件时,执行处理函数。 176 | 177 | 参数: 178 | block: 是否阻止更低优先级的处理函数执行. 默认为 False. 179 | priority: 优先级. 默认为 1. 180 | """ 181 | 182 | def _decorator(func: T_Handler) -> T_Handler: 183 | self._event_handlers[priority].append( 184 | EventHandler( 185 | event_type=(SendMessageEvent,), 186 | func=func, 187 | block=block, 188 | priority=priority, 189 | ), 190 | ) 191 | return func 192 | 193 | return _decorator 194 | 195 | def on_startswith( 196 | self, 197 | *startswith: str, 198 | prefix: Union[str, Set[str], None] = None, 199 | block: bool = False, 200 | priority: int = 1, 201 | ): 202 | """注册一个消息事件处理函数 203 | 204 | 当事件属于 SendMessageEvent 消息事件且纯文本部分以指定字符串开头时,执行处理函数 205 | 206 | 参数: 207 | *startswith: 字符串列表. 208 | prefix: 字符串前缀. 可以是字符串或字符串集合. 默认为 "". 209 | block: 是否阻止更低优先级的处理函数执行. 默认为 False. 210 | priority: 优先级. 默认为 1. 211 | """ 212 | if prefix is None: 213 | prefix = {""} 214 | if isinstance(prefix, str): 215 | prefix = {prefix} 216 | startswith = tuple({p + s for p, s in list(product(prefix, startswith))}) 217 | 218 | def _decorator(func: T_Handler) -> T_Handler: 219 | self._event_handlers[priority].append( 220 | EventHandler( 221 | event_type=(SendMessageEvent,), 222 | func=func, 223 | block=block, 224 | priority=priority, 225 | startswith=startswith or None, 226 | ), 227 | ) 228 | return func 229 | 230 | return _decorator 231 | 232 | def on_endswith(self, *endswith: str, block: bool = False, priority: int = 1): 233 | """注册一个消息事件处理函数 234 | 235 | 当事件属于 SendMessageEvent 消息事件且纯文本部分以指定字符串结尾时,执行处理函数 236 | 237 | 参数: 238 | *endswith: 字符串列表. 239 | block: 是否阻止更低优先级的处理函数执行. 默认为 False. 240 | priority: 优先级. 默认为 1. 241 | """ 242 | 243 | def _decorator(func: T_Handler) -> T_Handler: 244 | self._event_handlers[priority].append( 245 | EventHandler( 246 | event_type=(SendMessageEvent,), 247 | func=func, 248 | block=block, 249 | priority=priority, 250 | endswith=endswith or None, 251 | ), 252 | ) 253 | return func 254 | 255 | return _decorator 256 | 257 | def on_keyword(self, *keywords: str, block: bool = False, priority: int = 1): 258 | """注册一个消息事件处理函数 259 | 260 | 当事件属于 SendMessageEvent 消息事件且纯文本部分包含指定关键词时,执行处理函数。 261 | 262 | 参数: 263 | *keywords: 关键词列表. 264 | block: 是否阻止更低优先级的处理函数执行. 默认为 False. 265 | priority: 优先级. 默认为 1. 266 | """ 267 | 268 | def _decorator(func: T_Handler) -> T_Handler: 269 | self._event_handlers[priority].append( 270 | EventHandler( 271 | event_type=(SendMessageEvent,), 272 | func=func, 273 | block=block, 274 | priority=priority, 275 | keywords=keywords or None, 276 | ), 277 | ) 278 | return func 279 | 280 | return _decorator 281 | 282 | def on_regex( 283 | self, 284 | pattern: Union[str, re.Pattern], 285 | block: bool = False, 286 | priority: int = 1, 287 | ): 288 | """注册一个消息事件处理函数 289 | 290 | 当事件属于 SendMessageEvent 消息事件且纯文本部分与正则表达式匹配时,执行处理函数 291 | 292 | 参数: 293 | pattern: 正则表达式. 294 | block: 是否阻止更低优先级的处理函数执行. 默认为 False. 295 | priority: 优先级. 默认为 1. 296 | """ 297 | if isinstance(pattern, str): 298 | pattern = re.compile(pattern) 299 | 300 | def _decorator(func: T_Handler) -> T_Handler: 301 | self._event_handlers[priority].append( 302 | EventHandler( 303 | event_type=(SendMessageEvent,), 304 | func=func, 305 | block=block, 306 | priority=priority, 307 | regex=pattern, 308 | ), 309 | ) 310 | return func 311 | 312 | return _decorator 313 | 314 | async def send( 315 | self, 316 | villa_id: int, 317 | room_id: int, 318 | message: Union[str, Message, MessageSegment], 319 | ) -> str: 320 | """发送消息 321 | 322 | 参数: 323 | villa_id: 大别野 ID 324 | room_id: 房间 ID 325 | message: 消息内容 326 | 327 | 返回: 328 | str: 消息 ID 329 | """ 330 | if isinstance(message, str): 331 | message = MessageSegment.plain_text(message) 332 | if isinstance(message, MessageSegment): 333 | message = Message(message) 334 | content_info = await self._parse_message_content(message) 335 | if isinstance(content_info.content, TextMessageContent): 336 | object_name = "MHY:Text" 337 | elif isinstance(content_info.content, ImageMessageContent): 338 | object_name = "MHY:Image" 339 | else: 340 | object_name = "MHY:Post" 341 | return await self.send_message( 342 | villa_id=villa_id, 343 | room_id=room_id, 344 | object_name=object_name, 345 | msg_content=content_info.json(by_alias=True, exclude_none=True), 346 | ) 347 | 348 | async def check_member_bot_access_token( 349 | self, 350 | token: str, 351 | villa_id: Optional[int] = None, 352 | ) -> CheckMemberBotAccessTokenReturn: 353 | """校验用户机器人访问凭证,并返回用户信息 354 | 355 | 参数: 356 | token: 用户机器人访问凭证 357 | villa_id: 大别野 ID. 默认为 None. 358 | 359 | 返回: 360 | CheckMemberBotAccessTokenReturn: 用户信息 361 | """ 362 | return CheckMemberBotAccessTokenReturn.parse_obj( 363 | await self._request( 364 | "GET", 365 | "checkMemberBotAccessToken", 366 | villa_id, 367 | json={"token": token}, 368 | ), 369 | ) 370 | 371 | async def get_villa(self, villa_id: int) -> Villa: 372 | """获取大别野信息 373 | 374 | 参数: 375 | villa_id: 大别野 ID 376 | 377 | 返回: 378 | Villa: 大别野信息 379 | """ 380 | return Villa.parse_obj( 381 | (await self._request("GET", "getVilla", villa_id, json={}))["villa"], 382 | ) 383 | 384 | async def get_member(self, villa_id: int, uid: int) -> Member: 385 | """获取用户信息 386 | 387 | 参数: 388 | villa_id: 大别野 389 | uid: 用户 ID 390 | 391 | 返回: 392 | Member: 用户详情 393 | """ 394 | return Member.parse_obj( 395 | ( 396 | await self._request( 397 | "GET", 398 | "getMember", 399 | villa_id, 400 | json={"uid": uid}, 401 | ) 402 | )["member"], 403 | ) 404 | 405 | async def get_villa_members( 406 | self, 407 | villa_id: int, 408 | offset: int, 409 | size: int, 410 | ) -> MemberListReturn: 411 | """获取大别野成员列表 412 | 413 | 参数: 414 | villa_id: 大别野 ID 415 | offset: 偏移量 416 | size: 分页大小 417 | 418 | 返回: 419 | MemberListReturn: 大别野成员列表信息 420 | """ 421 | return MemberListReturn.parse_obj( 422 | await self._request( 423 | "GET", 424 | "getVillaMembers", 425 | villa_id, 426 | json={"offset": offset, "size": size}, 427 | ), 428 | ) 429 | 430 | async def delete_villa_member(self, villa_id: int, uid: int) -> None: 431 | """踢出大别野用户 432 | 433 | 参数: 434 | villa_id: 大别野 ID 435 | uid: 用户 ID 436 | """ 437 | await self._request( 438 | "POST", 439 | "deleteVillaMember", 440 | villa_id, 441 | json={"uid": uid}, 442 | ) 443 | 444 | async def pin_message( 445 | self, 446 | villa_id: int, 447 | msg_uid: str, 448 | is_cancel: bool, 449 | room_id: int, 450 | send_at: int, 451 | ) -> None: 452 | """置顶消息 453 | 454 | 参数: 455 | villa_id: 大别野 ID 456 | msg_uid: 消息 ID 457 | is_cancel: 是否取消置顶 458 | room_id: 房间 ID 459 | send_at: 消息发送时间 460 | """ 461 | await self._request( 462 | "POST", 463 | "pinMessage", 464 | villa_id, 465 | json={ 466 | "msg_uid": msg_uid, 467 | "is_cancel": is_cancel, 468 | "room_id": room_id, 469 | "send_at": send_at, 470 | }, 471 | ) 472 | 473 | async def recall_message( 474 | self, 475 | villa_id: int, 476 | msg_uid: str, 477 | room_id: int, 478 | msg_time: int, 479 | ) -> None: 480 | """撤回消息 481 | 482 | 参数: 483 | villa_id: 大别野 ID 484 | msg_uid: 消息 ID 485 | room_id: 房间 ID 486 | msg_time: 消息发送时间 487 | """ 488 | await self._request( 489 | "POST", 490 | "recallMessage", 491 | villa_id, 492 | json={"msg_uid": msg_uid, "msg_time": msg_time, "room_id": room_id}, 493 | ) 494 | 495 | async def send_message( 496 | self, 497 | villa_id: int, 498 | room_id: int, 499 | object_name: str, 500 | msg_content: Union[str, MessageContentInfo], 501 | ) -> str: 502 | """发送消息 503 | 504 | 参数: 505 | villa_id: 大别野 ID 506 | room_id: 房间 ID 507 | object_name: 消息类型 508 | msg_content: 将 MsgContentInfo 结构体序列化后的字符串 509 | 510 | 返回: 511 | str: 消息 ID 512 | """ 513 | if isinstance(msg_content, MessageContentInfo): 514 | content = msg_content.json(by_alias=True, exclude_none=True) 515 | else: 516 | content = msg_content 517 | return ( 518 | await self._request( 519 | "POST", 520 | "sendMessage", 521 | villa_id, 522 | json={ 523 | "room_id": room_id, 524 | "object_name": object_name, 525 | "msg_content": content, 526 | }, 527 | ) 528 | )["bot_msg_id"] 529 | 530 | async def create_group(self, villa_id: int, group_name: str) -> int: 531 | """创建分组 532 | 533 | 参数: 534 | villa_id: 大别野 ID 535 | group_name: 分组名称 536 | 537 | 返回: 538 | int: 分组 ID 539 | """ 540 | return ( 541 | await self._request( 542 | "POST", 543 | "createGroup", 544 | villa_id, 545 | json={ 546 | "group_name": group_name, 547 | }, 548 | ) 549 | )["group_id"] 550 | 551 | async def edit_group(self, villa_id: int, group_id: int, group_name: str) -> None: 552 | """编辑分组 553 | 554 | 参数: 555 | villa_id: 大别野 ID 556 | group_id: 分组 ID 557 | group_name: 分组名称 558 | """ 559 | await self._request( 560 | "POST", 561 | "editGroup", 562 | villa_id, 563 | json={"group_id": group_id, "group_name": group_name}, 564 | ) 565 | 566 | async def delete_group(self, villa_id: int, group_id: int) -> None: 567 | """删除分组 568 | 569 | 参数: 570 | villa_id: 大别野 ID 571 | group_id: 分组 ID 572 | """ 573 | await self._request( 574 | "POST", 575 | "deleteGroup", 576 | villa_id, 577 | json={"group_id": group_id}, 578 | ) 579 | 580 | async def get_group_list(self, villa_id: int) -> List[Group]: 581 | """获取分组列表 582 | 583 | 参数: 584 | villa_id: 大别野 ID 585 | 586 | 返回: 587 | List[Group]: 分组列表 588 | """ 589 | return parse_obj_as( 590 | List[Group], 591 | (await self._request("GET", "getGroupList", villa_id, json={}))["list"], 592 | ) 593 | 594 | async def sort_group_list(self, villa_id: int, group_ids: List[int]) -> None: 595 | """分组列表排序 596 | 597 | 参数: 598 | villa_id: 大别野 ID 599 | group_ids: 分组 ID 排序 600 | """ 601 | await self._request( 602 | "POST", 603 | "sortGroupList", 604 | villa_id, 605 | json={"villa_id": villa_id, "group_ids": group_ids}, 606 | ) 607 | 608 | async def edit_room(self, villa_id: int, room_id: int, room_name: str) -> None: 609 | """编辑房间 610 | 611 | 参数: 612 | villa_id: 大别野 ID 613 | room_id: 房间 ID 614 | room_name: 房间名称 615 | """ 616 | await self._request( 617 | "POST", 618 | "editRoom", 619 | villa_id, 620 | json={"room_id": room_id, "room_name": room_name}, 621 | ) 622 | 623 | async def delete_room(self, villa_id: int, room_id: int) -> None: 624 | """删除房间 625 | 626 | 参数: 627 | villa_id: 大别野 ID 628 | room_id: 房间 ID 629 | """ 630 | await self._request( 631 | "POST", 632 | "deleteRoom", 633 | villa_id, 634 | json={"room_id": room_id}, 635 | ) 636 | 637 | async def get_room(self, villa_id: int, room_id: int) -> Room: 638 | """获取房间信息 639 | 640 | 参数: 641 | villa_id: 大别野 ID 642 | room_id: 房间 ID 643 | 644 | 返回: 645 | Room: 房间详情 646 | """ 647 | return Room.parse_obj( 648 | ( 649 | await self._request( 650 | "GET", 651 | "getRoom", 652 | villa_id, 653 | json={"room_id": room_id}, 654 | ) 655 | )["room"], 656 | ) 657 | 658 | async def get_villa_group_room_list(self, villa_id: int) -> List[GroupRoom]: 659 | """获取房间列表信息 660 | 661 | 参数: 662 | villa_id: 大别野 ID 663 | 664 | 返回: 665 | GroupRoom: 房间列表 666 | """ 667 | return parse_obj_as( 668 | List[GroupRoom], 669 | ( 670 | await self._request( 671 | "GET", 672 | "getVillaGroupRoomList", 673 | villa_id, 674 | json={}, 675 | ) 676 | )["list"], 677 | ) 678 | 679 | async def sort_room_list(self, villa_id: int, room_list: List[RoomSort]) -> None: 680 | """房间列表排序 681 | 682 | 参数: 683 | villa_id: 大别野 ID 684 | room_list: 期望的排序列表 685 | """ 686 | await self._request( 687 | "POST", 688 | "sortRoomList", 689 | villa_id, 690 | json={ 691 | "villa_id": villa_id, 692 | "room_list": [room.dict() for room in room_list], 693 | }, 694 | ) 695 | 696 | async def operate_member_to_role( 697 | self, 698 | villa_id: int, 699 | role_id: int, 700 | uid: int, 701 | is_add: bool, 702 | ) -> None: 703 | """向身份组操作用户 704 | 705 | 参数: 706 | villa_id: 大别野 ID 707 | role_id: 身份组 ID 708 | uid: 用户 ID 709 | is_add: 是否添加用户 710 | """ 711 | await self._request( 712 | "POST", 713 | "operateMemberToRole", 714 | villa_id, 715 | json={"role_id": role_id, "uid": uid, "is_add": is_add}, 716 | ) 717 | 718 | async def create_member_role( 719 | self, 720 | villa_id: int, 721 | name: str, 722 | color: Color, 723 | permissions: List[Permission], 724 | ) -> int: 725 | """创建身份组 726 | 727 | 参数: 728 | villa_id: 大别野 ID 729 | name: 身份组名称 730 | color: 身份组颜色 731 | permissions: 权限列表 732 | 733 | 返回: 734 | int: 身份组 ID 735 | """ 736 | return ( 737 | await self._request( 738 | "POST", 739 | "createMemberRole", 740 | villa_id, 741 | json={"name": name, "color": str(color), "permissions": permissions}, 742 | ) 743 | )["id"] 744 | 745 | async def edit_member_role( 746 | self, 747 | villa_id: int, 748 | role_id: int, 749 | name: str, 750 | color: Color, 751 | permissions: List[Permission], 752 | ) -> None: 753 | """编辑身份组 754 | 755 | 参数: 756 | villa_id: 大别野 ID 757 | role_id: 身份组 ID 758 | name: 身份组名称 759 | color: 身份组颜色 760 | permissions: 权限列表 761 | """ 762 | await self._request( 763 | "POST", 764 | "editMemberRole", 765 | villa_id, 766 | json={ 767 | "id": role_id, 768 | "name": name, 769 | "color": str(color), 770 | "permissions": permissions, 771 | }, 772 | ) 773 | 774 | async def delete_member_role(self, villa_id: int, role_id: int) -> None: 775 | """删除身份组 776 | 777 | 参数: 778 | villa_id: 大别野 ID 779 | role_id: 身份组 ID 780 | """ 781 | await self._request( 782 | "POST", 783 | "deleteMemberRole", 784 | villa_id, 785 | json={"id": role_id}, 786 | ) 787 | 788 | async def get_member_role_info( 789 | self, 790 | villa_id: int, 791 | role_id: int, 792 | ) -> MemberRoleDetail: 793 | """获取身份组 794 | 795 | 参数: 796 | villa_id: 大别野 ID 797 | role_id: 身份组 ID 798 | 799 | 返回: 800 | MemberRoleDetail: 身份组详情 801 | """ 802 | return MemberRoleDetail.parse_obj( 803 | ( 804 | await self._request( 805 | "GET", 806 | "getMemberRoleInfo", 807 | villa_id, 808 | json={"role_id": role_id}, 809 | ) 810 | )["role"], 811 | ) 812 | 813 | async def get_villa_member_roles(self, villa_id: int) -> List[MemberRoleDetail]: 814 | """获取大别野下所有身份组 815 | 816 | 参数: 817 | villa_id: 大别野 ID 818 | 819 | 返回: 820 | List[MemberRoleDetail]: 身份组列表 821 | """ 822 | return parse_obj_as( 823 | List[MemberRoleDetail], 824 | ( 825 | await self._request( 826 | "GET", 827 | "getVillaMemberRoles", 828 | villa_id, 829 | json={}, 830 | ) 831 | )["list"], 832 | ) 833 | 834 | async def get_all_emoticons(self) -> List[Emoticon]: 835 | """获取全量表情 836 | 837 | 参数: 838 | villa_id: 参数说明 839 | 840 | 返回: 841 | List[Emoticon]: 表情列表 842 | """ 843 | return parse_obj_as( 844 | List[Emoticon], 845 | ( 846 | await self._request( 847 | "GET", 848 | "getAllEmoticons", 849 | None, 850 | json={}, 851 | ) 852 | )["list"], 853 | ) 854 | 855 | async def audit( 856 | self, 857 | villa_id: int, 858 | audit_content: str, 859 | uid: int, 860 | pass_through: Optional[str] = None, 861 | room_id: Optional[int] = None, 862 | content_type: ContentType = ContentType.TEXT, 863 | ) -> int: 864 | """审核 865 | 866 | 审核用户配置内容是否合规,调用成功后会返回审核事件id(audit_id)。审核结果会通过回调接口异步通知。 867 | 868 | 参数: 869 | villa_id: 大别野 ID 870 | audit_content: 待审核内容 871 | pass_through: 透传信息,该字段会在审核结果回调时携带给开发者,选填 872 | room_id: 房间 id,选填 873 | uid: 用户 id, 选填明 874 | 875 | 返回: 876 | int: 审核事件 ID 877 | """ 878 | return ( 879 | await self._request( 880 | "POST", 881 | "audit", 882 | villa_id, 883 | json={ 884 | "audit_content": audit_content, 885 | "pass_through": pass_through, 886 | "room_id": room_id, 887 | "uid": uid, 888 | "content_type": content_type, 889 | }, 890 | ) 891 | )["audit_id"] 892 | 893 | async def transfer_image(self, villa_id: int, url: str) -> str: 894 | """将非米游社的三方图床图片转存到米游社官方图床 895 | 896 | 参数: 897 | url: 三方图床的图片链接 898 | 899 | 返回: 900 | str: 新的米游社官方图床的图片链接 901 | """ 902 | return ( 903 | await self._request( 904 | "POST", 905 | "transferImage", 906 | villa_id, 907 | json={ 908 | "url": url, 909 | }, 910 | ) 911 | )["new_url"] 912 | 913 | def _get_headers(self, villa_id: Optional[int] = None) -> Dict[str, str]: 914 | """获取鉴权请求头 915 | 916 | 参数: 917 | villa_id: 大别野 ID,部分无需 918 | 919 | 返回: 920 | Dict[str, str]: 请求头 921 | """ 922 | return { 923 | "x-rpc-bot_id": self.bot_id, 924 | "x-rpc-bot_secret": self.bot_secret_encrypt, 925 | "x-rpc-bot_villa_id": str(villa_id) if villa_id else "", 926 | } 927 | 928 | async def _request( 929 | self, 930 | method: Literal["GET", "POST"], 931 | api: str, 932 | villa_id: Optional[int], 933 | json: Dict[str, Any], 934 | **kwargs, 935 | ) -> Any: 936 | """请求 API 937 | 938 | 参数: 939 | method: 请求方法 940 | api: API 名称 941 | villa_id: 大别野 ID 942 | json: JSON请求体 943 | 944 | 异常: 945 | ActionFailed: 动作失败 946 | e: 其他请求异常 947 | 948 | 返回: 949 | Any: 返回结果 950 | """ 951 | logger.opt(colors=True).debug( 952 | f"{self.bot_id} | Calling API {api}", 953 | ) 954 | try: 955 | resp = await self._client.request( 956 | method=method, 957 | url=api, 958 | headers=self._get_headers(villa_id), 959 | json=json, 960 | **kwargs, 961 | ) 962 | resp = ApiResponse.parse_raw(resp.content) 963 | if resp.retcode == 0: 964 | return resp.data 965 | if resp.retcode == -502: 966 | raise UnknownServerError(resp) 967 | if resp.retcode == -1: 968 | raise InvalidRequest(resp) 969 | if resp.retcode == 10318001: 970 | raise InsufficientPermission(resp) 971 | if resp.retcode == 10322002: 972 | raise BotNotAdded(resp) 973 | if resp.retcode == 10322003: 974 | raise PermissionDenied(resp) 975 | if resp.retcode == 10322004: 976 | raise InvalidMemberBotAccessToken(resp) 977 | if resp.retcode == 10322005: 978 | raise InvalidBotAuthInfo(resp) 979 | if resp.retcode == 10322006: 980 | raise UnsupportedMsgType(resp) 981 | raise ActionFailed(resp.retcode, resp) 982 | except Exception as e: 983 | raise e 984 | 985 | def _verify_signature( 986 | self, 987 | body: str, 988 | bot_sign: str, 989 | ): 990 | sign = base64.b64decode(bot_sign) 991 | sign_data = urlencode( 992 | {"body": body.rstrip("\n"), "secret": self.bot_secret}, 993 | ).encode() 994 | try: 995 | rsa.verify(sign_data, sign, self.pub_key) 996 | except rsa.VerificationError: 997 | return False 998 | return True 999 | 1000 | async def _close_client(self) -> None: 1001 | """关闭 HTTP Client""" 1002 | await self._client.aclose() 1003 | 1004 | async def _handle_event(self, event: Event): 1005 | """处理事件 1006 | 1007 | 参数: 1008 | event: 事件 1009 | """ 1010 | is_handled = False 1011 | for priority in sorted(self._event_handlers.keys()): 1012 | try: 1013 | await asyncio.gather( 1014 | *[ 1015 | handler._run(event) 1016 | for handler in self._event_handlers[priority] 1017 | ], 1018 | ) 1019 | is_handled = True 1020 | except StopPropagation as e: 1021 | logger.opt(colors=True).debug( 1022 | f"{event.get_event_name()} stop handled by {e.handler}", 1023 | ) 1024 | break 1025 | if is_handled: 1026 | logger.opt(colors=True).success( 1027 | f"{event.get_event_name()} handle completed", 1028 | ) 1029 | 1030 | async def _parse_message_content(self, message: Message) -> MessageContentInfo: 1031 | """解析消息内容""" 1032 | if quote := message["quote", 0]: 1033 | quote = QuoteInfo(**quote.dict()) 1034 | 1035 | if badge := message["badge", 0]: 1036 | badge = Badge(**badge.dict()) 1037 | 1038 | if preview_link := message["preview_link", 0]: 1039 | preview_link = PreviewLink(**preview_link.dict()) 1040 | 1041 | post = message["post", 0] 1042 | 1043 | if images_msg := (message["image"] or None): # type: ignore 1044 | images_msg: List[ImageSegment] 1045 | images = [ 1046 | Image( 1047 | url=seg.url, 1048 | size=( 1049 | ImageSize(width=seg.width, height=seg.height) 1050 | if seg.width and seg.height 1051 | else None 1052 | ), 1053 | file_size=seg.file_size, 1054 | ) 1055 | for seg in images_msg 1056 | ] 1057 | else: 1058 | images = None 1059 | 1060 | def cal_len(x): 1061 | return len(x.encode("utf-16")) // 2 - 1 1062 | 1063 | message_text = "" 1064 | message_offset = 0 1065 | entities: List[TextEntity] = [] 1066 | mentioned = MentionedInfo(type=MentionType.PART) 1067 | for seg in message: # type: ignore 1068 | try: 1069 | if seg.type in ("quote", "image", "post", "preview_link", "badge"): 1070 | continue 1071 | if isinstance(seg, TextSegment): 1072 | seg_text = seg.content 1073 | length = cal_len(seg_text) 1074 | elif isinstance(seg, MentionAllSegment): 1075 | seg_text = f"@{seg.show_text} " 1076 | length = cal_len(seg_text) 1077 | entities.append( 1078 | TextEntity( 1079 | offset=message_offset, 1080 | length=length, 1081 | entity=MentionedAll(show_text=seg.show_text), 1082 | ), 1083 | ) 1084 | mentioned.type = MentionType.ALL 1085 | elif isinstance(seg, MentionRobotSegment): 1086 | seg_text = f"@{seg.bot_name} " 1087 | length = cal_len(seg_text) 1088 | entities.append( 1089 | TextEntity( 1090 | offset=message_offset, 1091 | length=length, 1092 | entity=MentionedRobot( 1093 | bot_id=seg.bot_id, 1094 | bot_name=seg.bot_name, 1095 | ), 1096 | ), 1097 | ) 1098 | mentioned.user_id_list.append(seg.bot_id) 1099 | elif isinstance(seg, MentionUserSegment): 1100 | # 需要调用API获取被@的用户的昵称 1101 | if not seg.user_name and seg.villa_id: 1102 | user = await self.get_member( 1103 | villa_id=seg.villa_id, 1104 | uid=seg.user_id, 1105 | ) 1106 | seg_text = f"@{user.basic.nickname} " 1107 | seg.user_name = user.basic.nickname 1108 | else: 1109 | seg_text = f"@{seg.user_name} " 1110 | length = cal_len(seg_text) 1111 | entities.append( 1112 | TextEntity( 1113 | offset=message_offset, 1114 | length=length, 1115 | entity=MentionedUser( 1116 | user_id=str(seg.user_id), 1117 | user_name=seg.user_name, # type: ignore 1118 | ), 1119 | ), 1120 | ) 1121 | mentioned.user_id_list.append(str(seg.user_id)) 1122 | elif isinstance(seg, RoomLinkSegment): 1123 | # 需要调用API获取房间的名称 1124 | room = await self.get_room( 1125 | villa_id=seg.villa_id, 1126 | room_id=seg.room_id, 1127 | ) 1128 | seg_text = f"#{room.room_name} " 1129 | length = cal_len(seg_text) 1130 | entities.append( 1131 | TextEntity( 1132 | offset=message_offset, 1133 | length=length, 1134 | entity=VillaRoomLink( 1135 | villa_id=str(seg.villa_id), 1136 | room_id=str(seg.room_id), 1137 | room_name=room.room_name, 1138 | ), 1139 | ), 1140 | ) 1141 | else: 1142 | seg: LinkSegment 1143 | seg_text = seg.show_text 1144 | length = cal_len(seg_text) 1145 | entities.append( 1146 | TextEntity( 1147 | offset=message_offset, 1148 | length=length, 1149 | entity=Link( 1150 | url=seg.url, 1151 | show_text=seg.show_text, 1152 | requires_bot_access_token=seg.requires_bot_access_token, 1153 | ), 1154 | ), 1155 | ) 1156 | message_offset += length 1157 | message_text += seg_text 1158 | except Exception as e: 1159 | logger.opt(exception=e).warning("error when parse message content") 1160 | 1161 | if not (mentioned.type == MentionType.ALL and mentioned.user_id_list): 1162 | mentioned = None 1163 | 1164 | if not (message_text or entities): 1165 | if images: 1166 | if len(images) > 1: 1167 | content = TextMessageContent( 1168 | text="\u200b", 1169 | images=images, 1170 | preview_link=preview_link, 1171 | badge=badge, 1172 | ) 1173 | else: 1174 | content = ImageMessageContent(**images[0].dict()) 1175 | elif preview_link: 1176 | content = TextMessageContent( 1177 | text="\u200b", 1178 | preview_link=preview_link, 1179 | badge=badge, 1180 | ) 1181 | elif post: 1182 | content = PostMessageContent(post_id=post.post_id) 1183 | else: 1184 | raise ValueError("message content is empty") 1185 | else: 1186 | content = TextMessageContent( 1187 | text=message_text, 1188 | entities=entities, 1189 | images=images, 1190 | preview_link=preview_link, 1191 | badge=badge, 1192 | ) 1193 | 1194 | return MessageContentInfo(content=content, mentionedInfo=mentioned, quote=quote) 1195 | 1196 | def init_app(self, app: FastAPI): 1197 | if self.callback_endpoint is not None: 1198 | logger.opt(colors=True).info(f"Initializing Bot {self.bot_id}...") 1199 | logger.opt(colors=True).debug( 1200 | ( 1201 | f"With Secret: {self.bot_secret} " 1202 | f"and Callback Endpoint: {self.callback_endpoint}" 1203 | ), 1204 | ) 1205 | app.post(self.callback_endpoint, status_code=200)(handle_event) 1206 | app.on_event("shutdown")(self._close_client) 1207 | else: 1208 | logger.opt(colors=True).warning( 1209 | f"Bot {self.bot_id} missing callback url endpoint.", 1210 | ) 1211 | 1212 | def run( 1213 | self, 1214 | host: str = "127.0.0.1", 1215 | port: int = 13350, 1216 | log_level: str = "INFO", 1217 | **kwargs, 1218 | ): 1219 | """启动机器人. 1220 | 1221 | 参数: 1222 | host: HOST 地址. 默认为 "127.0.0.1". 1223 | port: 端口号. 默认为 13350. 1224 | log_level: 日志等级. 默认为 "INFO". 1225 | """ 1226 | run_bots(bots=[self], host=host, port=port, log_level=log_level, **kwargs) 1227 | 1228 | 1229 | def run_bots( 1230 | bots: List[Bot], 1231 | host: str = "127.0.0.1", 1232 | port: int = 13350, 1233 | log_level: str = "INFO", 1234 | **kwargs, 1235 | ): 1236 | """启动多个机器人. 1237 | 1238 | 参数: 1239 | bots: 机器人列表. 1240 | host: HOST 地址. 默认为 "127.0.0.1". 1241 | port: 端口号. 默认为 13350. 1242 | log_level: 日志等级. 默认为 "INFO". 1243 | """ 1244 | logger.configure(extra={"villa_log_level": log_level}, patcher=_log_patcher) 1245 | logger.success("Starting Villa...") 1246 | fastapi_kwargs = { 1247 | k.lstrip("fastapi_"): v for k, v in kwargs.items() if k.startswith("fastapi_") 1248 | } 1249 | uvicorn_kwargs = { 1250 | k.lstrip("uvicorn_"): v for k, v in kwargs.items() if k.startswith("uvicorn_") 1251 | } 1252 | app = get_app() 1253 | for key, value in fastapi_kwargs.items(): 1254 | setattr(app, key, value) 1255 | for bot in bots: 1256 | bot.init_app(app) 1257 | uvicorn.run( 1258 | app, 1259 | host=host, 1260 | port=port, 1261 | log_config={ 1262 | "version": 1, 1263 | "disable_existing_loggers": False, 1264 | "handlers": { 1265 | "default": { 1266 | "class": "villa.log.LoguruHandler", 1267 | }, 1268 | }, 1269 | "loggers": { 1270 | "uvicorn.error": {"handlers": ["default"], "level": "INFO"}, 1271 | "uvicorn.access": { 1272 | "handlers": ["default"], 1273 | "level": "INFO", 1274 | }, 1275 | }, 1276 | }, 1277 | **uvicorn_kwargs, 1278 | ) 1279 | 1280 | 1281 | async def handle_event( 1282 | data: Dict[str, Any], 1283 | request: Request, 1284 | backgroud_tasks: BackgroundTasks, 1285 | ) -> JSONResponse: 1286 | """处理事件""" 1287 | if not (payload_data := data.get("event", None)): 1288 | logger.warning(f"Received invalid data: {escape_tag(str(data))}") 1289 | return JSONResponse( 1290 | status_code=415, 1291 | content={"retcode": 415, "msg": "Invalid data"}, 1292 | ) 1293 | try: 1294 | event = parse_obj_as(event_classes, pre_handle_event(payload_data)) 1295 | if (bot := get_bot(event.bot_id)) is None: 1296 | raise ValueError(f"Bot {event.bot_id} not found") 1297 | if bot.verify_event and ( 1298 | (bot_sign := request.headers.get("x-rpc-bot_sign")) is None 1299 | or not bot._verify_signature((await request.body()).decode(), bot_sign) 1300 | ): 1301 | logger.opt(colors=True).warning( 1302 | ( 1303 | f"Bot {bot.bot_id} " 1304 | f"received invalid signature: {bot_sign}" 1305 | ), 1306 | ) 1307 | return JSONResponse( 1308 | status_code=401, 1309 | content={"retcode": 401, "msg": "Invalid signature"}, 1310 | ) 1311 | bot._bot_info = event.robot 1312 | logger.opt(colors=True).success( 1313 | ( 1314 | f"{bot.bot_id}" 1315 | f" | [{event.__class__.__name__}]: " 1316 | f"{event.get_event_description()}" 1317 | ), 1318 | ) 1319 | except Exception as e: 1320 | logger.opt(exception=e).warning( 1321 | f"Failed to parse payload {escape_tag(str(payload_data))}", 1322 | ) 1323 | return JSONResponse( 1324 | status_code=415, 1325 | content={"retcode": 415, "msg": "Invalid data"}, 1326 | ) 1327 | else: 1328 | if bot.wait_util_complete: 1329 | await bot._handle_event(event=event) 1330 | else: 1331 | backgroud_tasks.add_task(bot._handle_event, event=event) 1332 | return JSONResponse(status_code=200, content={"retcode": 0, "message": "success"}) 1333 | 1334 | 1335 | def on_startup(func: T_Func): 1336 | """让函数在 APP 启动时运行 1337 | 1338 | 参数: 1339 | func: 无参函数 1340 | """ 1341 | get_app().on_event("startup")(func) 1342 | 1343 | 1344 | def on_shutdown(func: T_Func): 1345 | """让函数在 APP 终止前运行 1346 | 1347 | 参数: 1348 | func: 无参函数 1349 | """ 1350 | get_app().on_event("shutdown")(func) 1351 | --------------------------------------------------------------------------------