From 89ea4bb44bebf9599f6fefbe65caa8df4b7c9715 Mon Sep 17 00:00:00 2001 From: CMHopeSunshine <277073121@qq.com> Date: Mon, 20 Nov 2023 11:56:07 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E5=AE=9E=E9=AA=8C=E6=80=A7?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20Websocket[beta]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nonebot/adapters/villa/adapter.py | 445 ++++++++++++++++++---------- nonebot/adapters/villa/bot.py | 43 ++- nonebot/adapters/villa/config.py | 29 +- nonebot/adapters/villa/event.py | 18 +- nonebot/adapters/villa/exception.py | 21 ++ nonebot/adapters/villa/message.py | 4 +- nonebot/adapters/villa/models.py | 43 ++- nonebot/adapters/villa/payload.py | 208 +++++++++++++ poetry.lock | 46 ++- pyproject.toml | 1 + vila_bot_proto.py | 321 ++++++++++++++++++++ 11 files changed, 946 insertions(+), 233 deletions(-) create mode 100644 nonebot/adapters/villa/payload.py create mode 100644 vila_bot_proto.py diff --git a/nonebot/adapters/villa/adapter.py b/nonebot/adapters/villa/adapter.py index ea39789..ec4f631 100644 --- a/nonebot/adapters/villa/adapter.py +++ b/nonebot/adapters/villa/adapter.py @@ -1,26 +1,43 @@ import asyncio import json -from typing import Any, List, Literal, Optional, cast +import time +from typing import Any, Dict, List, Literal, Optional, cast from typing_extensions import override from nonebot.adapters import Adapter as BaseAdapter from nonebot.drivers import ( URL, Driver, - ForwardDriver, + HTTPClientMixin, HTTPServerSetup, Request, Response, - ReverseDriver, + ReverseMixin, + WebSocket, + WebSocketClientMixin, ) +from nonebot.exception import WebSocketClosed from nonebot.utils import escape_tag from pydantic import parse_obj_as from .bot import Bot -from .config import Config -from .event import event_classes, pre_handle_event -from .exception import ApiNotAvailable +from .config import BotInfo, Config +from .event import Event, event_classes, pre_handle_event +from .exception import ApiNotAvailable, DisconnectError, ReconnectError +from .models import WebsocketInfo +from .payload import ( + BizType, + HeartBeat, + HeartBeatReply, + KickOff, + Login, + LoginReply, + Logout, + LogoutReply, + Payload, + Shutdown, +) from .utils import API, log @@ -30,6 +47,7 @@ def __init__(self, driver: Driver, **kwargs: Any): super().__init__(driver, **kwargs) self.villa_config: Config = Config(**self.config.dict()) self.tasks: List[asyncio.Task] = [] + self.ws: Dict[str, WebSocket] = {} self.base_url: URL = URL("https://bbs-api.miyoushe.com/vila/api/bot/platform") self._setup() @@ -39,34 +57,46 @@ def get_name(cls) -> Literal["Villa"]: return "Villa" def _setup(self): - # ReverseDriver用于接收回调事件,ForwardDriver用于调用API - if not ( - isinstance(self.driver, ReverseDriver) - and isinstance(self.driver, ForwardDriver) + self.driver.on_startup(self._forward_http) + self.driver.on_startup(self._start_forward) + self.driver.on_shutdown(self._stop_forward) + + async def _forward_http(self): + webhook_bots = [ + bot_info + for bot_info in self.villa_config.villa_bots + if bot_info.connection_type == "webhook" + ] + if webhook_bots and not ( + isinstance(self.driver, ReverseMixin) + and isinstance(self.driver, HTTPClientMixin) ): raise RuntimeError( ( - f"Current driver {self.config.driver} doesn't support connections!" - "Villa Adapter need a ReverseDriver and ForwardDriver to work." + f"Current driver {self.config.driver}" + "doesn't support connections!" + "Villa Adapter Webhook need a " + "ReverseMixin and HTTPClientMixin to work." ), ) - self.driver.on_startup(self._forward_http) - # self.driver.on_startup(self._start_forward) - # self.driver.on_shutdown(self._stop_forward) - - async def _forward_http(self): - for bot_info in self.villa_config.villa_bots: - if bot_info.callback_url: - bot = Bot(self, bot_info.bot_id, bot_info) - self.bot_connect(bot) - log("INFO", f"Bot {bot.self_id} connected") - http_setup = HTTPServerSetup( - URL(bot_info.callback_url), - "POST", - f"大别野 {bot_info.bot_id} HTTP", - self._handle_http, + for bot_info in webhook_bots: + if not bot_info.callback_url: + log( + "WARNING", + f"Missing callback url for bot {bot_info.bot_id}, " + "bot will not be connected", ) - self.setup_http_server(http_setup) + continue + bot = Bot(self, bot_info.bot_id, bot_info) + self.bot_connect(bot) + log("INFO", f"Bot {bot.self_id} connected") + http_setup = HTTPServerSetup( + URL(bot_info.callback_url), + "POST", + f"大别野 {bot_info.bot_id} HTTP", + self._handle_http, + ) + self.setup_http_server(http_setup) async def _handle_http(self, request: Request) -> Response: if data := request.content: @@ -133,142 +163,233 @@ async def _handle_http(self, request: Request) -> Response: return Response(415, content="Invalid Request Body") return Response(415, content="Invalid Request Body") - # async def _start_forward(self) -> None: - # for bot_info in self.villa_config.villa_bots: - # if bot_info.ws_url: - # bot = Bot(self, bot_info.bot_id, bot_info) - # self.bot_connect(bot) - # log("INFO", f"Bot {bot.self_id} connected") - # self.tasks.append( - # asyncio.create_task( - # self._forward_ws(URL(bot_info.ws_url), bot_info.ws_secret), - # ), - # ) + async def _start_forward(self) -> None: + ws_bots = [ + bot_info + for bot_info in self.villa_config.villa_bots + if bot_info.connection_type == "websocket" + ] + if ws_bots and not ( + isinstance(self.driver, HTTPClientMixin) + and isinstance(self.driver, WebSocketClientMixin) + ): + raise RuntimeError( + f"Current driver {self.config.driver}" + "doesn't support connections!" + "Villa Adapter Websocket need a " + "HTTPClientMixin and WebSocketClientMixin to work.", + ) + for bot_config in ws_bots: + if bot_config.connection_type == "websocket": + bot = Bot(self, bot_config.bot_id, bot_config) + ws_info = await bot.get_websocket_info() + self.tasks.append( + asyncio.create_task( + self._forward_ws(bot, bot_config, ws_info), + ), + ) + + async def _forward_ws( + self, + bot: Bot, + bot_config: BotInfo, + ws_info: WebsocketInfo, + ) -> None: + request = Request(method="GET", url=URL(ws_info.websocket_url), timeout=30) + heartbeat_task: Optional["asyncio.Task"] = None + while True: + try: + async with self.websocket(request) as ws: + log( + "DEBUG", + "WebSocket Connection to" + f" {escape_tag(ws_info.websocket_url)} established", + ) + try: + # 登录 + result = await self._login(bot, ws, bot_config, ws_info) + if not result: + await asyncio.sleep(3.0) + continue + + # 开启心跳 + heartbeat_task = asyncio.create_task( + self._heartbeat(bot, ws), + ) + + # 处理事件 + await self._loop(bot, ws) + except DisconnectError: + log("DEBUG", "Disconnected from server") + break + except ReconnectError as e: + log("ERROR", str(e), e) + except WebSocketClosed as e: + log( + "ERROR", + "WebSocket Closed", + e, + ) + except Exception as e: + log( + "ERROR", + ( # noqa: E501 + "Error while process data from" + f" websocket {escape_tag(ws_info.websocket_url)}. " + "Trying to reconnect..." + ), + e, + ) + finally: + if bot.self_id in self.bots: + bot._ws_squence = 0 + self.ws.pop(bot.self_id) + self.bot_disconnect(bot) + if heartbeat_task: + heartbeat_task.cancel() + heartbeat_task = None + except Exception as e: + log( + "ERROR", + ( + "Error while setup websocket to" + f" {escape_tag(ws_info.websocket_url)}. " + "Trying to reconnect..." + ), + e, + ) + await asyncio.sleep(3.0) + + async def _stop_forward(self) -> None: + for bot, task in zip(self.ws.items(), self.tasks): + await self._logout(self.bots[bot[0]], bot[1]) # type: ignore + if not task.done(): + task.cancel() + + async def _login( + self, + bot: Bot, + ws: WebSocket, + bot_config: BotInfo, + ws_info: WebsocketInfo, + ): + try: + login = Login( + ws_info.websocket_conn_uid, + str(bot_config.test_villa_id or 0) + + f".{bot_config.bot_secret}.{bot_config.bot_id}", + ws_info.platform, + ws_info.app_id, + ws_info.device_id, + ) + await ws.send_bytes(login.to_bytes_package(bot._ws_squence)) + bot._ws_squence += 1 + + except Exception as e: + log( + "ERROR", + "Error while sending Login", + e, + ) + return None + login_reply = await self.receive_payload(ws) + if not isinstance(login_reply, LoginReply): + log( + "ERROR", + "Received unexpected event while login: " + f"{escape_tag(repr(login_reply))}", + ) + return None + if login_reply.code == 0: + bot.ws_info = ws_info + if bot.self_id not in self.bots: + self.bot_connect(bot) + self.ws[bot.self_id] = ws + log( + "INFO", + f"Bot {escape_tag(bot.self_id)} connected", + ) + return True + return None + + async def _logout(self, bot: Bot, ws: WebSocket): + try: + await ws.send_bytes( + Logout( + uid=bot.ws_info.websocket_conn_uid, + platform=bot.ws_info.platform, + app_id=bot.ws_info.app_id, + device_id=bot.ws_info.device_id, + ).to_bytes_package(bot._ws_squence), + ) + bot._ws_squence += 1 + except Exception as e: + log("WARNING", "Error while sending logout, Ignored!", e) + login_reply = await self.receive_payload(ws) + if not isinstance(login_reply, LogoutReply): + log( + "ERROR", + "Received unexpected event while logout: " + f"{escape_tag(repr(login_reply))}", + ) + if bot.self_id in self.bots: + self.ws.pop(bot.self_id) + self.bot_disconnect(bot) + raise DisconnectError + + async def _heartbeat(self, bot: Bot, ws: WebSocket): + while True: + await asyncio.sleep(20.0) + timestamp = str(int(time.time() * 1000)) + log("TRACE", f"Heartbeat {timestamp}") + try: + await ws.send_bytes( + HeartBeat(timestamp).to_bytes_package(bot._ws_squence), + ) + bot._ws_squence += 1 + except Exception as e: + log("WARNING", "Error while sending heartbeat, Ignored!", e) - # async def _forward_ws(self, url: URL, secret: Optional[str] = None): - # if secret is not None: - # request = Request("GET", url, headers={"ws-secret": secret}, timeout=30) - # else: - # request = Request("GET", url, timeout=30) - # bot: Optional[Bot] = None - # while True: - # try: - # async with self.websocket(request) as ws: - # log( - # "DEBUG", - # f"WebSocket Connection to {escape_tag(str(url))} established", - # ) - # try: - # while True: - # data = await ws.receive() - # json_data = json.loads(data) - # if payload_data := json_data.get("event"): - # try: - # event = parse_obj_as( - # event_classes, - # pre_handle_event(payload_data), - # ) - # bot_id = event.bot_id - # if (bot := self.bots.get(bot_id, None)) is None: # type: ignore # noqa: E501 - # if ( - # bot_info := next( - # ( - # bot - # for bot in self.villa_config.villa_bots # noqa: E501 - # if bot.bot_id == bot_id - # ), - # None, - # ) - # ) is not None: - # bot = Bot( - # self, - # bot_info.bot_id, - # bot_info, - # ) - # self.bot_connect(bot) - # log( - # "INFO", - # f"Bot {bot.self_id} connected", - # ) - # else: - # log( - # "WARNING", - # ( - # "Missing bot secret for bot" - # f" {bot_id}, event won't be" - # " handle" - # ), - # ) - # await ws.send( - # json.dumps( - # { - # "retcode": 0, - # "message": "NoneBot2 Get it!", - # }, - # ), - # ) - # bot = cast(Bot, bot) - # bot._bot_info = event.robot - # except Exception as e: - # log( - # "WARNING", - # ( - # "Failed to parse event " - # f"{escape_tag(str(payload_data))}" - # ), - # e, - # ) - # else: - # asyncio.create_task(bot.handle_event(event)) - # await ws.send( - # json.dumps( - # {"retcode": 0, "message": "NoneBot2 Get it!"}, - # ), - # ) - # else: - # await ws.send( - # json.dumps( - # { - # "retcode": -100, - # "message": "Invalid Request Body", - # }, - # ), - # ) - # except WebSocketClosed as e: - # log( - # "ERROR", - # "WebSocket Closed", - # e, - # ) - # except Exception as e: - # log( - # "ERROR", - # ( # noqa: E501 - # "Error while process data from" - # f" websocket {escape_tag(str(url))}. Trying to" - # " reconnect..." - # ), - # e, - # ) - # finally: - # if bot: - # self.bot_disconnect(bot) - # except Exception as e: - # log( - # "ERROR", - # ( - # "Error while setup websocket to" - # f" {escape_tag(str(url))}. Trying to reconnect..." - # ), - # e, - # ) - # await asyncio.sleep(3.0) + async def _loop(self, bot: Bot, ws: WebSocket): + while True: + payload = await self.receive_payload(ws) + if not payload: + raise ReconnectError + log( + "TRACE", + f"Received payload: {escape_tag(repr(payload))}", + ) + if isinstance(payload, HeartBeatReply): + log("TRACE", f"Heartbeat ACK in {payload.server_timestamp}") + continue + if isinstance(payload, (LogoutReply, KickOff)): + raise DisconnectError + if isinstance(payload, Event): + bot._bot_info = payload.robot + asyncio.create_task(bot.handle_event(payload)) - # async def _stop_forward(self) -> None: - # for task in self.tasks: - # if not task.done(): - # task.cancel() + @staticmethod + async def receive_payload(ws: WebSocket): + payload = Payload.from_bytes(await ws.receive_bytes()) + if payload.biz_type in {BizType.P_LOGIN, BizType.P_LOGOUT, BizType.P_HEARTBEAT}: + if payload.biz_type == BizType.P_LOGIN: + payload = LoginReply.FromString(payload.body_data) + elif payload.biz_type == BizType.P_LOGOUT: + payload = LogoutReply.FromString(payload.body_data) + else: + payload = HeartBeatReply.FromString(payload.body_data) + if payload.code != 0: + raise ReconnectError(payload) + return payload + elif payload.biz_type == BizType.P_KICK_OFF: + return KickOff.FromString(payload.body_data) + elif payload.biz_type == BizType.SHUTDOWN: + return Shutdown() + elif payload.biz_type == BizType.EVENT: + event_data = json.loads(payload.body_data).get("event") + return parse_obj_as(event_classes, pre_handle_event(event_data)) + else: + raise ReconnectError @override async def _call_api(self, bot: Bot, api: str, **data: Any) -> Any: diff --git a/nonebot/adapters/villa/bot.py b/nonebot/adapters/villa/bot.py index 3fc55db..cc2a141 100644 --- a/nonebot/adapters/villa/bot.py +++ b/nonebot/adapters/villa/bot.py @@ -87,6 +87,7 @@ UploadImageParamsReturn, Villa, VillaRoomLink, + WebsocketInfo, ) from .utils import API, get_img_extenion, get_img_md5, log @@ -187,6 +188,8 @@ def __init__( self.pub_key = rsa.PublicKey.load_pkcs1_openssl_pem(bot_info.pub_key.encode()) self.verify_event = bot_info.verify_event self._bot_info: Optional[Robot] = None + self._ws_info: Optional[WebsocketInfo] = None + self._ws_squence: int = 0 @override def __repr__(self) -> str: @@ -232,6 +235,16 @@ def current_villd_id(self) -> int: raise ValueError(f"Bot {self.self_id} hasn't received any events yet.") return self._bot_info.villa_id + @property + def ws_info(self) -> WebsocketInfo: + if self._ws_info is None: + raise RuntimeError(f"Bot {self.self_id} is not connected!") + return self._ws_info + + @ws_info.setter + def ws_info(self, ws_info: WebsocketInfo): + self._ws_info = ws_info + async def handle_event(self, event: Event): """处理事件""" if isinstance(event, SendMessageEvent): @@ -425,15 +438,7 @@ async def parse_message_content(self, message: Message) -> MessageContentInfo: def cal_len(x): return len(x.encode("utf-16")) // 2 - 1 - message = message.exclude( - "quote", - "image", - "post", - "badge", - "preview_link", - "components", - "panel", - ) + message = message.exclude("quote", "image", "post", "badge", "preview_link") message_text = "" message_offset = 0 entities: List[TextEntity] = [] @@ -537,7 +542,7 @@ def cal_len(x): mentioned = None if not (message_text or entities): - if preview_link or badge or panel: + if preview_link or badge: content = TextMessageContent( text="\u200b", preview_link=preview_link, @@ -1074,11 +1079,21 @@ async def upload_image( ) return parse_obj_as(ImageUploadResult, await self._request(request)) + async def get_websocket_info( + self, + ) -> WebsocketInfo: + request = Request( + method="GET", + url=self.adapter.base_url / "getWebsocketInfo", + headers=self.get_authorization_header(), + ) + return parse_obj_as(WebsocketInfo, await self._request(request)) + def _parse_components(components: List[Component]) -> Optional[Panel]: small_total = [[]] mid_total = [[]] - big_total = [[]] + big_total = [] for com in components: com_lenght = len(com.text.encode("utf-8")) if com_lenght <= 0: @@ -1092,9 +1107,7 @@ def _parse_components(components: List[Component]) -> Optional[Panel]: if len(mid_total[-1]) >= 2: mid_total.append([]) elif com_lenght <= 30: - big_total[-1].append(com) - if len(big_total[-1]) >= 1: - big_total.append([]) + big_total[-1].append([com]) else: log("warning", f"component {com.id} text is too long, ignore") if not small_total[-1]: @@ -1103,8 +1116,6 @@ def _parse_components(components: List[Component]) -> Optional[Panel]: if not mid_total[-1]: mid_total.pop() mid_total = mid_total or None - if not big_total[-1]: - big_total.pop() big_total = big_total or None if small_total or mid_total or big_total: return Panel( diff --git a/nonebot/adapters/villa/config.py b/nonebot/adapters/villa/config.py index 1326bd5..2cd4127 100644 --- a/nonebot/adapters/villa/config.py +++ b/nonebot/adapters/villa/config.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import List, Literal, Optional from pydantic import BaseModel, Extra, Field @@ -6,34 +6,11 @@ class BotInfo(BaseModel): bot_id: str bot_secret: str + connection_type: Literal["webhook", "websocket"] = "webhook" + test_villa_id: Optional[int] = None pub_key: str callback_url: Optional[str] = None verify_event: bool = True - # ws_url: Optional[str] = None - # ws_secret: Optional[str] = None - - # @validator("pub_key") - # @classmethod - # def format_pub_key(cls, v: str): - # v = v.strip() - # if v.startswith("-----BEGIN PUBLIC KEY-----"): - # v = v[26:] - # if v.endswith("-----END PUBLIC KEY-----"): - # v = v[:-24] - # v = v.replace(" ", "\n") - # if v[0] != "\n": - # v = "\n" + v - # if v[-1] != "\n": - # v += "\n" - # return "-----BEGIN PUBLIC KEY-----" + v + "-----END PUBLIC KEY-----\n" - - # # 不能同时存在 callback_url 和 ws_url - # @root_validator - # @classmethod - # def check_url(cls, values): - # if values.get("callback_url") and values.get("ws_url"): - # raise ValueError("callback_url and ws_url cannot exist at the same time") - # return values class Config(BaseModel, extra=Extra.ignore): diff --git a/nonebot/adapters/villa/event.py b/nonebot/adapters/villa/event.py index 999f5aa..8601227 100644 --- a/nonebot/adapters/villa/event.py +++ b/nonebot/adapters/villa/event.py @@ -115,11 +115,8 @@ class JoinVillaEvent(NoticeEvent): """用户昵称""" join_at: int """用户加入时间的时间戳""" - - @property - def villa_id(self) -> int: - """大别野ID""" - return self.robot.villa_id + villa_id: int + """大别野 ID""" @override def get_event_description(self) -> str: @@ -161,6 +158,8 @@ class SendMessageEvent(MessageEvent): """消息ID""" bot_msg_id: Optional[str] = None """如果被回复的消息从属于机器人,则该字段不为空字符串""" + villa_id: int + """大别野 ID""" quote_msg: Optional[QuoteMessage] = None """回调消息引用消息的基础信息""" @@ -181,11 +180,6 @@ def reply(self) -> Optional[QuoteMessage]: """消息的回复信息""" return self.quote_msg - @property - def villa_id(self) -> int: - """大别野ID""" - return self.robot.villa_id - @override def get_event_description(self) -> str: return escape_tag( @@ -380,6 +374,8 @@ class AddQuickEmoticonEvent(NoticeEvent): """如果被回复的消息从属于机器人,则该字段不为空字符串""" is_cancel: bool = False """是否是取消表情""" + emoticon_type: int + """表情类型""" @override def get_user_id(self) -> str: @@ -466,7 +462,7 @@ class ClickMsgComponentEvent(NoticeEvent): """如果被回复的消息从属于机器人,则该字段不为空字符串""" component_id: str """机器人自定义的组件id""" - template_id: str + template_id: int """如果该组件模板为已创建模板,则template_id不为0""" extra: str """机器人自定义透传信息""" diff --git a/nonebot/adapters/villa/exception.py b/nonebot/adapters/villa/exception.py index eae5c7e..7d016f0 100644 --- a/nonebot/adapters/villa/exception.py +++ b/nonebot/adapters/villa/exception.py @@ -9,6 +9,8 @@ ) if TYPE_CHECKING: + from betterproto import Message + from .api import ApiResponse @@ -21,8 +23,27 @@ class NoLogException(BaseNoLogException, VillaAdapterException): pass +class ReconnectError(VillaAdapterException): + def __init__(self, payload: Optional["Message"] = None): + super().__init__() + self.payload = payload + + def __repr__(self) -> str: + if self.payload is None: + return "Receive unexpected data, reconnect." + return f"Reconnect because of {self.payload}" + + def __str__(self) -> str: + return self.__repr__() + + +class DisconnectError(VillaAdapterException): + ... + + class ActionFailed(BaseActionFailed, VillaAdapterException): def __init__(self, status_code: int, response: "ApiResponse"): + super().__init__() self.status_code = status_code self.response = response diff --git a/nonebot/adapters/villa/message.py b/nonebot/adapters/villa/message.py index 7b9b1d7..d2d9e3c 100644 --- a/nonebot/adapters/villa/message.py +++ b/nonebot/adapters/villa/message.py @@ -331,11 +331,11 @@ def badge( ) @staticmethod - def components(*components: Component) -> "ComponentsSegment": + def components(components: List[Component]) -> "ComponentsSegment": return ComponentsSegment( "components", { - "components": list(components), + "components": components, }, ) diff --git a/nonebot/adapters/villa/models.py b/nonebot/adapters/villa/models.py index 4aad9f2..88c5d28 100644 --- a/nonebot/adapters/villa/models.py +++ b/nonebot/adapters/villa/models.py @@ -25,9 +25,19 @@ class BotAuth(BaseModel): # http事件回调部分 # see https://webstatic.mihoyo.com/vila/bot/doc/callback.html +class CommandParam(BaseModel): + desc: str + + class Command(BaseModel): name: str desc: Optional[str] = None + params: Optional[List[CommandParam]] = None + + +class TemplateCustomSettings(BaseModel): + name: str + url: str class Template(BaseModel): @@ -36,6 +46,8 @@ class Template(BaseModel): desc: Optional[str] = None icon: str commands: Optional[List[Command]] = None + custom_settings: Optional[List[TemplateCustomSettings]] = None + is_allowed_add_to_other_villa: Optional[bool] = None class Robot(BaseModel): @@ -245,14 +257,10 @@ def extra_str_to_dict(cls, v: Any): return v -class ComponentType(IntEnum): - Button = 1 - - class Component(BaseModel): id: str text: str - type: ComponentType = Field(default=ComponentType.Button, init=False) + type: int = 1 need_callback: Optional[bool] = None extra: str = "" @@ -275,9 +283,9 @@ class ButtonType(IntEnum): class Button(Component): c_type: ButtonType - input: Optional[str] - link: Optional[str] - need_token: Optional[bool] + input: Optional[str] = None + link: Optional[str] = None + need_token: Optional[bool] = None class CallbackButton(Button): @@ -286,23 +294,17 @@ class CallbackButton(Button): init=False, ) need_callback: Literal[True] = True - input: Optional[str] = Field(default=None, init=False) - link: Optional[str] = Field(default=None, init=False) - need_token: Optional[bool] = Field(default=None, init=False) class InputButton(Button): c_type: Literal[ButtonType.Input] = Field(default=ButtonType.Input, init=False) input: str - link: Optional[str] = Field(default=None, init=False) - need_token: Optional[bool] = Field(default=None, init=False) class LinkButton(Button): c_type: Literal[ButtonType.Link] = Field(default=ButtonType.Link, init=False) link: str - need_token: bool = False - input: Optional[str] = Field(default=None, init=False) + need_token: bool class Trace(BaseModel): @@ -541,6 +543,16 @@ class ImageUploadResult(BaseModel): url: str +# Websocket 部分 +# see https://webstatic.mihoyo.com/vila/bot/doc/websocket/websocket_api.html +class WebsocketInfo(BaseModel): + websocket_url: str + websocket_conn_uid: int + app_id: int + platform: int + device_id: str + + for _, obj in inspect.getmembers(sys.modules[__name__]): if inspect.isclass(obj) and issubclass(obj, BaseModel): obj.update_forward_refs() @@ -607,4 +619,5 @@ class ImageUploadResult(BaseModel): "ContentType", "ImageUploadParams", "UploadImageParamsReturn", + "WebsocketInfo", ] diff --git a/nonebot/adapters/villa/payload.py b/nonebot/adapters/villa/payload.py new file mode 100644 index 0000000..5fcebc2 --- /dev/null +++ b/nonebot/adapters/villa/payload.py @@ -0,0 +1,208 @@ +from dataclasses import dataclass +from enum import IntEnum +import struct +from typing import Dict, Literal + +import betterproto +from pydantic import BaseModel + + +class BizType(IntEnum): + UNKNOWN = 0 + EXCHANGE_KEY = 1 + HEARTBEAT = 2 + LOGIN = 3 + LOGOUT = 4 + P_EXCHANGE_KEY = 5 + P_HEARTBEAT = 6 + P_LOGIN = 7 + P_LOGOUT = 8 + KICK_OFF = 51 + SHUTDOWN = 52 + P_KICK_OFF = 53 + ROOM_ENTER = 60 + ROOM_LEAVE = 61 + ROOM_CLOSE = 62 + ROOM_MSG = 63 + EVENT = 30001 + + +class Payload(BaseModel): + # magic: Literal[0xBABEFACE] = 0xBABEFACE + # data_len: int + # header_len: int + id: int + flag: Literal[1, 2] + biz_type: BizType + app_id: Literal[104] = 104 + body_data: bytes + + @classmethod + def from_bytes(cls, data: bytes): + magic, data_len = struct.unpack(" bytes: + changeable = ( + struct.pack( + " bytes: + return Payload( + id=id, + flag=2, + biz_type=BizType.P_HEARTBEAT, + body_data=self.SerializeToString(), + ).to_bytes() + + +@dataclass +class HeartBeatReply(betterproto.Message): + """心跳返回""" + + # 错误码 非0表示失败 + code: int = betterproto.int32_field(1) + # 服务端时间戳,精确到ms + server_timestamp: int = betterproto.uint64_field(2) + + +@dataclass +class Login(betterproto.Message): + """登录命令""" + + # 长连接侧唯一id,uint64格式 + uid: int = betterproto.uint64_field(1) + # 用于业务后端验证的token + token: str = betterproto.string_field(2) + # 客户端操作平台枚举 + platform: int = betterproto.int32_field(3) + # 业务所在客户端应用标识,用于在同一个客户端隔离不同业务的长连接通道。 + app_id: int = betterproto.int32_field(4) + device_id: str = betterproto.string_field(5) + # 区域划分字段,通过uid+app_id+platform+region四个字段唯一确定一条长连接 + region: str = betterproto.string_field(6) + # 长连内部的扩展字段,是个map + meta: Dict[str, str] = betterproto.map_field( + 7, + betterproto.TYPE_STRING, + betterproto.TYPE_STRING, + ) + + def to_bytes_package(self, id: int) -> bytes: + return Payload( + id=id, + flag=2, + biz_type=BizType.P_LOGIN, + body_data=self.SerializeToString(), + ).to_bytes() + + +@dataclass +class LoginReply(betterproto.Message): + """登录命令返回""" + + # 错误码 非0表示失败 + code: int = betterproto.int32_field(1) + # 错误信息 + msg: str = betterproto.string_field(2) + # 服务端时间戳,精确到ms + server_timestamp: int = betterproto.uint64_field(3) + # 唯一连接ID + conn_id: int = betterproto.uint64_field(4) + + +@dataclass +class Logout(betterproto.Message): + """登出命令字""" + + # 长连接侧唯一id,uint64格式 + uid: int = betterproto.uint64_field(1) + # 客户端操作平台枚举 + platform: int = betterproto.int32_field(2) + # 业务所在客户端应用标识,用于在同一个客户端隔离不同业务的长连接通道。 + app_id: int = betterproto.int32_field(3) + # 客户端设备唯一标识 + device_id: str = betterproto.string_field(4) + # 区域划分字段,通过uid+app_id+platform+region四个字段唯一确定一条长连接 + region: str = betterproto.string_field(5) + + def to_bytes_package(self, id: int) -> bytes: + return Payload( + id=id, + flag=2, + biz_type=BizType.P_LOGOUT, + body_data=self.SerializeToString(), + ).to_bytes() + + +@dataclass +class LogoutReply(betterproto.Message): + """登出命令返回""" + + # 错误码 非0表示失败 + code: int = betterproto.int32_field(1) + # 错误信息 + msg: str = betterproto.string_field(2) + # 连接id + conn_id: int = betterproto.uint64_field(3) + + +@dataclass +class CommonReply(betterproto.Message): + """通用返回""" + + # 错误码 非0表示失败 + code: int = betterproto.int32_field(1) + # 错误信息 + msg: str = betterproto.string_field(2) + + +@dataclass +class KickOff(betterproto.Message): + """踢出连接协议""" + + # 踢出原因状态码 + code: int = betterproto.int32_field(1) + # 状态码对应的文案 + reason: str = betterproto.string_field(2) + + +@dataclass +class Shutdown(betterproto.Message): + """服务关机""" diff --git a/poetry.lock b/poetry.lock index 461a57a..19113f6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -21,6 +21,23 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)"] test = ["anyio[trio]", "coverage[toml] (>=7)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (>=0.22)"] +[[package]] +name = "betterproto" +version = "1.2.5" +description = "A better Protobuf / gRPC generator & library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "betterproto-1.2.5.tar.gz", hash = "sha256:74a3ab34646054f674d236d1229ba8182dc2eae86feb249b8590ef496ce9803d"}, +] + +[package.dependencies] +grpclib = "*" +stringcase = "*" + +[package.extras] +compiler = ["black", "jinja2", "protobuf"] + [[package]] name = "certifi" version = "2023.7.22" @@ -128,6 +145,23 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1 testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] typing = ["typing-extensions (>=4.8)"] +[[package]] +name = "grpclib" +version = "0.4.6" +description = "Pure-Python gRPC implementation for asyncio" +optional = false +python-versions = ">=3.7" +files = [ + {file = "grpclib-0.4.6.tar.gz", hash = "sha256:595d05236ca8b8f8e433f5bf6095e6354c1d8777d003ddaf5288efa9611e3fd6"}, +] + +[package.dependencies] +h2 = ">=3.1.0,<5" +multidict = "*" + +[package.extras] +protobuf = ["protobuf (>=3.20.0)"] + [[package]] name = "h11" version = "0.14.0" @@ -706,6 +740,16 @@ typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\"" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] +[[package]] +name = "stringcase" +version = "1.2.0" +description = "String case converter." +optional = false +python-versions = "*" +files = [ + {file = "stringcase-1.2.0.tar.gz", hash = "sha256:48a06980661908efe8d9d34eab2b6c13aefa2163b3ced26972902e3bdfd87008"}, +] + [[package]] name = "tomli" version = "2.0.1" @@ -1090,4 +1134,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "a279670fa2ab9a4b4b9e5eb9b92284d59674d00a0f2a26daf9ffb9c8103f633a" +content-hash = "a1364c7ee0f6b0236096636a45c867b878bfe6f3df0a0859908ac5c55d874df5" diff --git a/pyproject.toml b/pyproject.toml index ab1bebf..be45d70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ packages = [{ include = "nonebot" }] python = "^3.8" nonebot2 = "^2.1.2" rsa = "^4.9" +betterproto = "^1.2.5" [tool.poetry.group.dev.dependencies] diff --git a/vila_bot_proto.py b/vila_bot_proto.py new file mode 100644 index 0000000..597f25b --- /dev/null +++ b/vila_bot_proto.py @@ -0,0 +1,321 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# sources: command.proto, model.proto, robot_event_message.proto +# plugin: python-betterproto +from dataclasses import dataclass +from typing import Dict, List + +import betterproto + + +class Command(betterproto.Enum): + UNKNOWN = 0 + EXCHANGE_KEY = 1 + HEARTBEAT = 2 + LOGIN = 3 + LOGOUT = 4 + P_EXCHANGE_KEY = 5 + P_HEARTBEAT = 6 + P_LOGIN = 7 + P_LOGOUT = 8 + KICK_OFF = 51 + SHUTDOWN = 52 + P_KICK_OFF = 53 + ROOM_ENTER = 60 + ROOM_LEAVE = 61 + ROOM_CLOSE = 62 + ROOM_MSG = 63 + + +class RoomType(betterproto.Enum): + RoomTypeInvalid = 0 + RoomTypeChatRoom = 1 + RoomTypePostRoom = 2 + RoomTypeSceneRoom = 3 + + +class ObjectName(betterproto.Enum): + UnknowObjectName = 0 + Text = 1 + Post = 2 + + +class RobotEventEventType(betterproto.Enum): + UnknowRobotEventType = 0 + JoinVilla = 1 + SendMessage = 2 + CreateRobot = 3 + DeleteRobot = 4 + AddQuickEmoticon = 5 + AuditCallback = 6 + ClickMsgComponent = 7 + + +class RobotEventExtendDataAuditCallbackInfoAuditResult(betterproto.Enum): + None_ = 0 + Pass = 1 + Reject = 2 + + +@dataclass +class PHeartBeat(betterproto.Message): + """心跳请求命令字""" + + # 客户端时间戳,精确到ms + client_timestamp: str = betterproto.string_field(1) + + +@dataclass +class PHeartBeatReply(betterproto.Message): + """心跳返回""" + + # 错误码 非0表示失败 + code: int = betterproto.int32_field(1) + # 服务端时间戳,精确到ms + server_timestamp: int = betterproto.uint64_field(2) + + +@dataclass +class PLogin(betterproto.Message): + """登录命令""" + + # 长连接侧唯一id,uint64格式 + uid: int = betterproto.uint64_field(1) + # 用于业务后端验证的token + token: str = betterproto.string_field(2) + # 客户端操作平台枚举 + platform: int = betterproto.int32_field(3) + # 业务所在客户端应用标识,用于在同一个客户端隔离不同业务的长连接通道。 + app_id: int = betterproto.int32_field(4) + device_id: str = betterproto.string_field(5) + # 区域划分字段,通过uid+app_id+platform+region四个字段唯一确定一条长连接 + region: str = betterproto.string_field(6) + # 长连内部的扩展字段,是个map + meta: Dict[str, str] = betterproto.map_field( + 7, + betterproto.TYPE_STRING, + betterproto.TYPE_STRING, + ) + + +@dataclass +class PLoginReply(betterproto.Message): + """登录命令返回""" + + # 错误码 非0表示失败 + code: int = betterproto.int32_field(1) + # 错误信息 + msg: str = betterproto.string_field(2) + # 服务端时间戳,精确到ms + server_timestamp: int = betterproto.uint64_field(3) + # 唯一连接ID + conn_id: int = betterproto.uint64_field(4) + + +@dataclass +class PLogout(betterproto.Message): + """登出命令字""" + + # 长连接侧唯一id,uint64格式 + uid: int = betterproto.uint64_field(1) + # 客户端操作平台枚举 + platform: int = betterproto.int32_field(2) + # 业务所在客户端应用标识,用于在同一个客户端隔离不同业务的长连接通道。 + app_id: int = betterproto.int32_field(3) + # 客户端设备唯一标识 + device_id: str = betterproto.string_field(4) + # 区域划分字段,通过uid+app_id+platform+region四个字段唯一确定一条长连接 + region: str = betterproto.string_field(5) + + +@dataclass +class PLogoutReply(betterproto.Message): + """登出命令返回""" + + # 错误码 非0表示失败 + code: int = betterproto.int32_field(1) + # 错误信息 + msg: str = betterproto.string_field(2) + # 连接id + conn_id: int = betterproto.uint64_field(3) + + +@dataclass +class CommonReply(betterproto.Message): + """通用返回""" + + # 错误码 非0表示失败 + code: int = betterproto.int32_field(1) + # 错误信息 + msg: str = betterproto.string_field(2) + + +@dataclass +class PKickOff(betterproto.Message): + """踢出连接协议""" + + # 踢出原因状态码 + code: int = betterproto.int32_field(1) + # 状态码对应的文案 + reason: str = betterproto.string_field(2) + + +@dataclass +class RobotTemplate(betterproto.Message): + id: str = betterproto.string_field(1) + name: str = betterproto.string_field(2) + desc: str = betterproto.string_field(3) + icon: str = betterproto.string_field(4) + commands: List["RobotTemplateCommand"] = betterproto.message_field(5) + custom_settings: List["RobotTemplateCustomSetting"] = betterproto.message_field(6) + is_allowed_add_to_other_villa: bool = betterproto.bool_field(7) + + +@dataclass +class RobotTemplateParam(betterproto.Message): + desc: str = betterproto.string_field(1) + + +@dataclass +class RobotTemplateCommand(betterproto.Message): + name: str = betterproto.string_field(1) + desc: str = betterproto.string_field(2) + params: List["RobotTemplateParam"] = betterproto.message_field(3) + + +@dataclass +class RobotTemplateCustomSetting(betterproto.Message): + name: str = betterproto.string_field(1) + url: str = betterproto.string_field(2) + + +@dataclass +class Robot(betterproto.Message): + template: "RobotTemplate" = betterproto.message_field(1) + villa_id: int = betterproto.uint64_field(2) + + +@dataclass +class QuoteMessageInfo(betterproto.Message): + content: str = betterproto.string_field(1) + msg_uid: str = betterproto.string_field(2) + send_at: int = betterproto.int64_field(3) + msg_type: str = betterproto.string_field(4) + bot_msg_id: str = betterproto.string_field(5) + from_user_id: int = betterproto.uint64_field(6) + from_user_id_str: str = betterproto.string_field(7) + from_user_nickname: str = betterproto.string_field(8) + + +@dataclass +class RobotEvent(betterproto.Message): + robot: "Robot" = betterproto.message_field(1) + type: "RobotEventEventType" = betterproto.enum_field(2) + extend_data: "RobotEventExtendData" = betterproto.message_field(3) + created_at: int = betterproto.int64_field(4) + id: str = betterproto.string_field(5) + send_at: int = betterproto.int64_field(6) + + +@dataclass +class RobotEventExtendData(betterproto.Message): + join_villa: "RobotEventExtendDataJoinVillaInfo" = betterproto.message_field( + 1, + group="event_data", + ) + send_message: "RobotEventExtendDataSendMessageInfo" = betterproto.message_field( + 2, + group="event_data", + ) + create_robot: "RobotEventExtendDataCreateRobotInfo" = betterproto.message_field( + 3, + group="event_data", + ) + delete_robot: "RobotEventExtendDataDeleteRobotInfo" = betterproto.message_field( + 4, + group="event_data", + ) + add_quick_emoticon: ( + "RobotEventExtendDataAddQuickEmoticonInfo" + ) = betterproto.message_field(5, group="event_data") + audit_callback: "RobotEventExtendDataAuditCallbackInfo" = betterproto.message_field( + 6, + group="event_data", + ) + click_msg_component: ( + "RobotEventExtendDataClickMsgComponentInfo" + ) = betterproto.message_field(7, group="event_data") + + +@dataclass +class RobotEventExtendDataJoinVillaInfo(betterproto.Message): + join_uid: int = betterproto.uint64_field(1) + join_user_nickname: str = betterproto.string_field(2) + join_at: int = betterproto.int64_field(3) + villa_id: int = betterproto.uint64_field(4) + + +@dataclass +class RobotEventExtendDataSendMessageInfo(betterproto.Message): + content: str = betterproto.string_field(1) + from_user_id: int = betterproto.uint64_field(2) + send_at: int = betterproto.int64_field(3) + object_name: "ObjectName" = betterproto.enum_field(4) + room_id: int = betterproto.uint64_field(5) + nickname: str = betterproto.string_field(6) + msg_uid: str = betterproto.string_field(7) + bot_msg_id: str = betterproto.string_field(8) + villa_id: int = betterproto.uint64_field(9) + quote_msg: "QuoteMessageInfo" = betterproto.message_field(10) + + +@dataclass +class RobotEventExtendDataCreateRobotInfo(betterproto.Message): + villa_id: int = betterproto.uint64_field(1) + + +@dataclass +class RobotEventExtendDataDeleteRobotInfo(betterproto.Message): + villa_id: int = betterproto.uint64_field(1) + + +@dataclass +class RobotEventExtendDataAddQuickEmoticonInfo(betterproto.Message): + villa_id: int = betterproto.uint64_field(1) + room_id: int = betterproto.uint64_field(2) + uid: int = betterproto.uint64_field(3) + emoticon_id: int = betterproto.uint32_field(4) + emoticon: str = betterproto.string_field(5) + msg_uid: str = betterproto.string_field(6) + is_cancel: bool = betterproto.bool_field(7) + bot_msg_id: str = betterproto.string_field(8) + emoticon_type: int = betterproto.uint32_field(9) + + +@dataclass +class RobotEventExtendDataAuditCallbackInfo(betterproto.Message): + audit_id: str = betterproto.string_field(1) + bot_tpl_id: str = betterproto.string_field(2) + villa_id: int = betterproto.uint64_field(3) + room_id: int = betterproto.uint64_field(4) + user_id: int = betterproto.uint64_field(5) + pass_through: str = betterproto.string_field(6) + audit_result: ( + "RobotEventExtendDataAuditCallbackInfoAuditResult" + ) = betterproto.enum_field(7) + + +@dataclass +class RobotEventExtendDataClickMsgComponentInfo(betterproto.Message): + villa_id: int = betterproto.uint64_field(1) + room_id: int = betterproto.uint64_field(2) + component_id: str = betterproto.string_field(3) + msg_uid: str = betterproto.string_field(4) + uid: int = betterproto.uint64_field(5) + bot_msg_id: str = betterproto.string_field(6) + template_id: int = betterproto.uint64_field(7) + extra: str = betterproto.string_field(8) + + +@dataclass +class RobotEventMessage(betterproto.Message): + event: "RobotEvent" = betterproto.message_field(1)