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)