vrchatapi-async 1.20.8.dev1__py3-none-any.whl → 1.20.8.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vrchatapi/__init__.py +1 -1
- vrchatapi/websocket.py +650 -0
- {vrchatapi_async-1.20.8.dev1.dist-info → vrchatapi_async-1.20.8.dev2.dist-info}/METADATA +47 -1
- {vrchatapi_async-1.20.8.dev1.dist-info → vrchatapi_async-1.20.8.dev2.dist-info}/RECORD +7 -6
- {vrchatapi_async-1.20.8.dev1.dist-info → vrchatapi_async-1.20.8.dev2.dist-info}/WHEEL +0 -0
- {vrchatapi_async-1.20.8.dev1.dist-info → vrchatapi_async-1.20.8.dev2.dist-info}/licenses/LICENSE +0 -0
- {vrchatapi_async-1.20.8.dev1.dist-info → vrchatapi_async-1.20.8.dev2.dist-info}/top_level.txt +0 -0
vrchatapi/__init__.py
CHANGED
vrchatapi/websocket.py
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"""VRChat Websocket (Pipeline) API client.
|
|
2
|
+
|
|
3
|
+
The VRChat Websocket API, also known as "the pipeline", pushes real-time
|
|
4
|
+
updates to the authenticated client (invites, friend requests, friend online
|
|
5
|
+
events, group updates, ...). The connection is receive-only: the client only
|
|
6
|
+
listens for messages.
|
|
7
|
+
|
|
8
|
+
The connection URL is ``wss://pipeline.vrchat.cloud/?authToken=<auth cookie>``
|
|
9
|
+
where ``authToken`` is the ``auth`` cookie obtained by logging in through the
|
|
10
|
+
REST API. A proper ``User-Agent`` header is also required.
|
|
11
|
+
|
|
12
|
+
Most messages are double-encoded: the outer envelope is JSON of the form
|
|
13
|
+
``{"type": "...", "content": "..."}`` and the ``content`` field is itself a
|
|
14
|
+
stringified JSON object. This client automatically unpacks ``content`` into a
|
|
15
|
+
plain ``dict`` (see :class:`VRChatEvent`). The ``see-notification`` and
|
|
16
|
+
``hide-notification`` events carry a plain notification-ID string instead, and
|
|
17
|
+
``clear-notification`` has no content at all.
|
|
18
|
+
|
|
19
|
+
Example::
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
|
|
23
|
+
import vrchatapi
|
|
24
|
+
from vrchatapi.api import authentication_api
|
|
25
|
+
from vrchatapi.websocket import VRChatWebSocket
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def main():
|
|
29
|
+
configuration = vrchatapi.Configuration(username="user", password="pass")
|
|
30
|
+
async with vrchatapi.ApiClient(configuration) as api_client:
|
|
31
|
+
await authentication_api.AuthenticationApi(api_client).get_current_user()
|
|
32
|
+
ws = VRChatWebSocket.from_client(api_client)
|
|
33
|
+
|
|
34
|
+
@ws.on("friend-online")
|
|
35
|
+
async def on_friend_online(event):
|
|
36
|
+
print("friend online:", event.content["user"]["displayName"])
|
|
37
|
+
|
|
38
|
+
await ws.run()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
asyncio.run(main())
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import asyncio
|
|
46
|
+
import inspect
|
|
47
|
+
import json
|
|
48
|
+
import logging
|
|
49
|
+
import uuid
|
|
50
|
+
from typing import Any, Callable, Dict, FrozenSet, List, Optional
|
|
51
|
+
from urllib.parse import quote
|
|
52
|
+
|
|
53
|
+
import aiohttp
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"VRChatWebSocket",
|
|
57
|
+
"VRChatEvent",
|
|
58
|
+
"VRChatWebSocketError",
|
|
59
|
+
"get_auth_cookie",
|
|
60
|
+
"DEFAULT_ENDPOINT",
|
|
61
|
+
"DEFAULT_USER_AGENT",
|
|
62
|
+
"DEFAULT_HEARTBEAT_INTERVAL",
|
|
63
|
+
"WS_EVENT_TYPES",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
DEFAULT_ENDPOINT = "wss://pipeline.vrchat.cloud"
|
|
69
|
+
DEFAULT_USER_AGENT = "vrchatapi-py"
|
|
70
|
+
DEFAULT_HEARTBEAT_INTERVAL = 30.0
|
|
71
|
+
|
|
72
|
+
#: End of stream sentinel pushed onto the shared queue to stop ``async for``.
|
|
73
|
+
_END = object()
|
|
74
|
+
|
|
75
|
+
#: Events whose ``content`` is a plain notification-ID string, not JSON.
|
|
76
|
+
_STRING_CONTENT_EVENTS: FrozenSet[str] = frozenset(
|
|
77
|
+
{"see-notification", "hide-notification"}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
#: Events that carry no ``content`` at all.
|
|
81
|
+
_NO_CONTENT_EVENTS: FrozenSet[str] = frozenset({"clear-notification"})
|
|
82
|
+
|
|
83
|
+
#: All event types documented at https://vrchat.community/websocket.
|
|
84
|
+
WS_EVENT_TYPES: FrozenSet[str] = frozenset(
|
|
85
|
+
{
|
|
86
|
+
# Notification events
|
|
87
|
+
"notification",
|
|
88
|
+
"response-notification",
|
|
89
|
+
"see-notification",
|
|
90
|
+
"hide-notification",
|
|
91
|
+
"clear-notification",
|
|
92
|
+
"notification-v2",
|
|
93
|
+
"notification-v2-update",
|
|
94
|
+
"notification-v2-delete",
|
|
95
|
+
# Friend events
|
|
96
|
+
"friend-add",
|
|
97
|
+
"friend-delete",
|
|
98
|
+
"friend-online",
|
|
99
|
+
"friend-active",
|
|
100
|
+
"friend-offline",
|
|
101
|
+
"friend-update",
|
|
102
|
+
"friend-location",
|
|
103
|
+
# User events
|
|
104
|
+
"user-update",
|
|
105
|
+
"user-location",
|
|
106
|
+
"user-badge-assigned",
|
|
107
|
+
"user-badge-unassigned",
|
|
108
|
+
"content-refresh",
|
|
109
|
+
"economy-update",
|
|
110
|
+
"modified-image-update",
|
|
111
|
+
"instance-queue-joined",
|
|
112
|
+
"instance-queue-ready",
|
|
113
|
+
# Group events
|
|
114
|
+
"group-joined",
|
|
115
|
+
"group-left",
|
|
116
|
+
"group-member-updated",
|
|
117
|
+
"group-role-updated",
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class VRChatWebSocketError(Exception):
|
|
123
|
+
"""Raised for pipeline-level errors (server ``{"err": ...}`` messages)."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, message: str, *, raw: Optional[str] = None) -> None:
|
|
126
|
+
super().__init__(message)
|
|
127
|
+
self.message = message
|
|
128
|
+
self.raw = raw
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class VRChatEvent:
|
|
132
|
+
"""A single decoded message received from the pipeline.
|
|
133
|
+
|
|
134
|
+
Attributes:
|
|
135
|
+
type: The event type, e.g. ``"friend-online"``.
|
|
136
|
+
content: The decoded ``content`` field. Usually a ``dict`` (the
|
|
137
|
+
double-encoded JSON payload); a plain ``str`` for
|
|
138
|
+
``see-notification`` / ``hide-notification``; ``None`` for
|
|
139
|
+
``clear-notification``.
|
|
140
|
+
raw: The complete raw JSON message as received over the wire.
|
|
141
|
+
raw_content: The raw ``content`` field before decoding (``None`` when
|
|
142
|
+
the event carries no content).
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
__slots__ = ("type", "content", "raw", "raw_content")
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
type: str,
|
|
150
|
+
content: Any = None,
|
|
151
|
+
raw: str = "",
|
|
152
|
+
raw_content: Optional[str] = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
self.type = type
|
|
155
|
+
self.content = content
|
|
156
|
+
self.raw = raw
|
|
157
|
+
self.raw_content = raw_content
|
|
158
|
+
|
|
159
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
160
|
+
return (
|
|
161
|
+
f"VRChatEvent(type={self.type!r}, content={self.content!r}, "
|
|
162
|
+
f"raw={self.raw!r}, raw_content={self.raw_content!r})"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_auth_cookie(api_client) -> Optional[str]:
|
|
167
|
+
"""Return the ``auth`` cookie value stored on an ``ApiClient``.
|
|
168
|
+
|
|
169
|
+
The cookie is populated by the login flow (``GET /auth/user``). Returns
|
|
170
|
+
``None`` when the client has not logged in yet.
|
|
171
|
+
"""
|
|
172
|
+
rest_client = getattr(api_client, "rest_client", None)
|
|
173
|
+
cookie_jar = getattr(rest_client, "cookie_jar", None)
|
|
174
|
+
if cookie_jar is None:
|
|
175
|
+
return None
|
|
176
|
+
for cookie in cookie_jar:
|
|
177
|
+
if cookie.name == "auth":
|
|
178
|
+
return cookie.value
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class VRChatWebSocket:
|
|
183
|
+
"""Async client for the VRChat Websocket (Pipeline) API.
|
|
184
|
+
|
|
185
|
+
Events can be consumed two ways, at the same time:
|
|
186
|
+
|
|
187
|
+
* callbacks registered with :meth:`on` / :meth:`on_event`
|
|
188
|
+
(``async def`` handlers that receive a :class:`VRChatEvent`), and
|
|
189
|
+
* by iterating::
|
|
190
|
+
|
|
191
|
+
async for event in ws:
|
|
192
|
+
...
|
|
193
|
+
|
|
194
|
+
The iterator yields every event that the connection delivers and stops
|
|
195
|
+
when the client is closed.
|
|
196
|
+
|
|
197
|
+
Usage::
|
|
198
|
+
|
|
199
|
+
ws = VRChatWebSocket(auth_token="authcookie_...", user_agent="MyApp/1.0 me@example.com")
|
|
200
|
+
|
|
201
|
+
@ws.on("friend-online")
|
|
202
|
+
async def on_friend_online(event):
|
|
203
|
+
print("online:", event.content["user"]["displayName"])
|
|
204
|
+
|
|
205
|
+
await ws.run() # blocks, reconnects automatically
|
|
206
|
+
# or, without auto-reconnect:
|
|
207
|
+
# ws = VRChatWebSocket(..., auto_reconnect=False)
|
|
208
|
+
# await ws.run()
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
auth_token: The ``auth`` cookie value obtained by logging in.
|
|
212
|
+
user_agent: User-Agent header; the pipeline rejects connections
|
|
213
|
+
without a proper one.
|
|
214
|
+
endpoint: Websocket base URL.
|
|
215
|
+
auto_reconnect: When ``True`` (default), :meth:`run` reconnects with
|
|
216
|
+
exponential backoff after the connection drops or the server
|
|
217
|
+
reports an error. When ``False``, :meth:`run` returns once the
|
|
218
|
+
connection is closed.
|
|
219
|
+
reconnect_max_delay: Upper bound (seconds) for the exponential
|
|
220
|
+
reconnect backoff (1s, 2s, 4s, ... capped at this value).
|
|
221
|
+
heartbeat_interval: Interval (seconds) for the application-level
|
|
222
|
+
heartbeat message that keeps the connection alive; ``None``
|
|
223
|
+
disables it. The server is receive-only per the documentation,
|
|
224
|
+
but the official clients (and the website) send heartbeats.
|
|
225
|
+
session: Optional pre-existing ``aiohttp.ClientSession`` to reuse.
|
|
226
|
+
When omitted, the client creates and owns its own session.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
auth_token: str,
|
|
232
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
233
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
234
|
+
auto_reconnect: bool = True,
|
|
235
|
+
reconnect_max_delay: float = 60.0,
|
|
236
|
+
heartbeat_interval: Optional[float] = DEFAULT_HEARTBEAT_INTERVAL,
|
|
237
|
+
session: Optional[aiohttp.ClientSession] = None,
|
|
238
|
+
) -> None:
|
|
239
|
+
if not auth_token:
|
|
240
|
+
raise ValueError("auth_token is required")
|
|
241
|
+
self._auth_token = auth_token
|
|
242
|
+
self._user_agent = user_agent
|
|
243
|
+
self._endpoint = endpoint
|
|
244
|
+
self._auto_reconnect = auto_reconnect
|
|
245
|
+
self._reconnect_max_delay = max(0.0, reconnect_max_delay)
|
|
246
|
+
self._heartbeat_interval = heartbeat_interval
|
|
247
|
+
|
|
248
|
+
self._session = session
|
|
249
|
+
self._owned_session = session is None
|
|
250
|
+
|
|
251
|
+
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
|
252
|
+
self._receiver_task: Optional[asyncio.Task] = None
|
|
253
|
+
self._heartbeat_task: Optional[asyncio.Task] = None
|
|
254
|
+
self._closed = False
|
|
255
|
+
self._queue: "asyncio.Queue[Any]" = asyncio.Queue()
|
|
256
|
+
|
|
257
|
+
#: event-type -> list of async handlers
|
|
258
|
+
self._handlers: Dict[str, List[Callable[..., Any]]] = {}
|
|
259
|
+
self._event_handlers: List[Callable[..., Any]] = []
|
|
260
|
+
self._error_handlers: List[Callable[..., Any]] = []
|
|
261
|
+
self._connect_handlers: List[Callable[..., Any]] = []
|
|
262
|
+
self._disconnect_handlers: List[Callable[..., Any]] = []
|
|
263
|
+
self._reconnect_handlers: List[Callable[..., Any]] = []
|
|
264
|
+
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
# Public API
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def from_client(cls, api_client, **kwargs) -> "VRChatWebSocket":
|
|
271
|
+
"""Build a client from a logged-in ``ApiClient``.
|
|
272
|
+
|
|
273
|
+
Reads the ``auth`` cookie from the client's cookie jar and inherits
|
|
274
|
+
its ``User-Agent``. ``kwargs`` are forwarded to the constructor.
|
|
275
|
+
"""
|
|
276
|
+
auth_token = get_auth_cookie(api_client)
|
|
277
|
+
if auth_token is None:
|
|
278
|
+
raise ValueError(
|
|
279
|
+
"no 'auth' cookie found; log in with the API client first "
|
|
280
|
+
"(e.g. call get_current_user())"
|
|
281
|
+
)
|
|
282
|
+
kwargs.setdefault("user_agent", getattr(api_client, "user_agent", DEFAULT_USER_AGENT))
|
|
283
|
+
return cls(auth_token=auth_token, **kwargs)
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def is_connected(self) -> bool:
|
|
287
|
+
ws = self._ws
|
|
288
|
+
return ws is not None and not ws.closed
|
|
289
|
+
|
|
290
|
+
def on(
|
|
291
|
+
self, event_type: str, handler: Optional[Callable[..., Any]] = None
|
|
292
|
+
) -> Callable[..., Any]:
|
|
293
|
+
"""Register ``handler`` for ``event_type``.
|
|
294
|
+
|
|
295
|
+
Works both directly (``ws.on("x", handler)``) and as a decorator
|
|
296
|
+
(``@ws.on("x")``); in both cases the handler is returned.
|
|
297
|
+
"""
|
|
298
|
+
if handler is None:
|
|
299
|
+
|
|
300
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
301
|
+
self._handlers.setdefault(event_type, []).append(fn)
|
|
302
|
+
return fn
|
|
303
|
+
|
|
304
|
+
return decorator
|
|
305
|
+
self._handlers.setdefault(event_type, []).append(handler)
|
|
306
|
+
return handler
|
|
307
|
+
|
|
308
|
+
def off(self, event_type: str, handler: Callable[..., Any]) -> None:
|
|
309
|
+
"""Remove a previously registered event handler."""
|
|
310
|
+
handlers = self._handlers.get(event_type)
|
|
311
|
+
if handlers is not None and handler in handlers:
|
|
312
|
+
handlers.remove(handler)
|
|
313
|
+
|
|
314
|
+
def on_event(self, handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
315
|
+
"""Register a catch-all handler invoked for every event."""
|
|
316
|
+
self._event_handlers.append(handler)
|
|
317
|
+
return handler
|
|
318
|
+
|
|
319
|
+
def on_error(self, handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
320
|
+
"""Register a handler invoked with an ``Exception`` on pipeline errors."""
|
|
321
|
+
self._error_handlers.append(handler)
|
|
322
|
+
return handler
|
|
323
|
+
|
|
324
|
+
def on_connect(self, handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
325
|
+
"""Register a handler invoked (no arguments) after connecting."""
|
|
326
|
+
self._connect_handlers.append(handler)
|
|
327
|
+
return handler
|
|
328
|
+
|
|
329
|
+
def on_disconnect(self, handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
330
|
+
"""Register a handler invoked (no arguments) after disconnecting."""
|
|
331
|
+
self._disconnect_handlers.append(handler)
|
|
332
|
+
return handler
|
|
333
|
+
|
|
334
|
+
def on_reconnect(self, handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
335
|
+
"""Register a handler invoked with the backoff delay (seconds) before reconnecting."""
|
|
336
|
+
self._reconnect_handlers.append(handler)
|
|
337
|
+
return handler
|
|
338
|
+
|
|
339
|
+
async def connect(self) -> None:
|
|
340
|
+
"""Establish a single connection and start the background receiver.
|
|
341
|
+
|
|
342
|
+
Intended for iterator-style consumption::
|
|
343
|
+
|
|
344
|
+
await ws.connect()
|
|
345
|
+
async for event in ws:
|
|
346
|
+
...
|
|
347
|
+
await ws.close()
|
|
348
|
+
|
|
349
|
+
For automatic reconnection use :meth:`run` instead.
|
|
350
|
+
"""
|
|
351
|
+
if self.is_connected:
|
|
352
|
+
return
|
|
353
|
+
await self._connect_once()
|
|
354
|
+
self._ensure_receiver()
|
|
355
|
+
|
|
356
|
+
async def run(self) -> None:
|
|
357
|
+
"""Run the receive loop until :meth:`close` or (without auto-reconnect)
|
|
358
|
+
until the connection is closed.
|
|
359
|
+
|
|
360
|
+
Connects if needed, dispatches events to callbacks and the shared
|
|
361
|
+
queue, and reconnects with exponential backoff when
|
|
362
|
+
``auto_reconnect`` is enabled.
|
|
363
|
+
"""
|
|
364
|
+
attempt = 0
|
|
365
|
+
while not self._closed:
|
|
366
|
+
if not self.is_connected:
|
|
367
|
+
try:
|
|
368
|
+
await self._connect_once()
|
|
369
|
+
attempt = 0
|
|
370
|
+
except asyncio.CancelledError:
|
|
371
|
+
raise
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
await self._fire(self._error_handlers, exc)
|
|
374
|
+
if not await self._wait_before_reconnect(attempt):
|
|
375
|
+
break
|
|
376
|
+
attempt += 1
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
self._ensure_receiver()
|
|
380
|
+
try:
|
|
381
|
+
await self._receiver_task
|
|
382
|
+
except asyncio.CancelledError:
|
|
383
|
+
if not self._closed:
|
|
384
|
+
raise
|
|
385
|
+
finally:
|
|
386
|
+
await self._teardown()
|
|
387
|
+
|
|
388
|
+
if self._closed or not self._auto_reconnect:
|
|
389
|
+
break
|
|
390
|
+
if not await self._wait_before_reconnect(attempt):
|
|
391
|
+
break
|
|
392
|
+
attempt += 1
|
|
393
|
+
|
|
394
|
+
self._put_end()
|
|
395
|
+
# run() is done for good: release the session we own so connections
|
|
396
|
+
# do not linger. (close() already closed it in that path.)
|
|
397
|
+
if self._owned_session and self._session is not None:
|
|
398
|
+
try:
|
|
399
|
+
await self._session.close()
|
|
400
|
+
except Exception:
|
|
401
|
+
pass
|
|
402
|
+
self._session = None
|
|
403
|
+
|
|
404
|
+
async def close(self) -> None:
|
|
405
|
+
"""Close the connection and stop the receiver; safe to call twice."""
|
|
406
|
+
if self._closed:
|
|
407
|
+
return
|
|
408
|
+
self._closed = True
|
|
409
|
+
self._put_end()
|
|
410
|
+
|
|
411
|
+
heartbeat_task = self._heartbeat_task
|
|
412
|
+
self._heartbeat_task = None
|
|
413
|
+
if (
|
|
414
|
+
heartbeat_task is not None
|
|
415
|
+
and heartbeat_task is not asyncio.current_task()
|
|
416
|
+
and not heartbeat_task.done()
|
|
417
|
+
):
|
|
418
|
+
heartbeat_task.cancel()
|
|
419
|
+
try:
|
|
420
|
+
await heartbeat_task
|
|
421
|
+
except (asyncio.CancelledError, Exception):
|
|
422
|
+
pass
|
|
423
|
+
|
|
424
|
+
receiver_task = self._receiver_task
|
|
425
|
+
if (
|
|
426
|
+
receiver_task is not None
|
|
427
|
+
and receiver_task is not asyncio.current_task()
|
|
428
|
+
and not receiver_task.done()
|
|
429
|
+
):
|
|
430
|
+
receiver_task.cancel()
|
|
431
|
+
try:
|
|
432
|
+
await receiver_task
|
|
433
|
+
except (asyncio.CancelledError, Exception):
|
|
434
|
+
pass
|
|
435
|
+
|
|
436
|
+
ws = self._ws
|
|
437
|
+
self._ws = None
|
|
438
|
+
if ws is not None and not ws.closed:
|
|
439
|
+
try:
|
|
440
|
+
await ws.close()
|
|
441
|
+
except Exception:
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
if self._owned_session and self._session is not None:
|
|
445
|
+
try:
|
|
446
|
+
await self._session.close()
|
|
447
|
+
except Exception:
|
|
448
|
+
pass
|
|
449
|
+
self._session = None
|
|
450
|
+
|
|
451
|
+
# ------------------------------------------------------------------
|
|
452
|
+
# Internals
|
|
453
|
+
# ------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
async def _connect_once(self) -> None:
|
|
456
|
+
if self.is_connected:
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
session = self._session
|
|
460
|
+
if session is None:
|
|
461
|
+
session = aiohttp.ClientSession(trust_env=False)
|
|
462
|
+
self._session = session
|
|
463
|
+
self._owned_session = True
|
|
464
|
+
|
|
465
|
+
url = f"{self._endpoint.rstrip('/')}/?authToken={quote(self._auth_token, safe='')}"
|
|
466
|
+
headers = {"User-Agent": self._user_agent}
|
|
467
|
+
try:
|
|
468
|
+
ws = await session.ws_connect(url, headers=headers)
|
|
469
|
+
except Exception:
|
|
470
|
+
# Do not leak a freshly-created session on a failed connect;
|
|
471
|
+
# close it so callers do not accumulate resources.
|
|
472
|
+
if self._owned_session and session is self._session:
|
|
473
|
+
await session.close()
|
|
474
|
+
self._session = None
|
|
475
|
+
raise
|
|
476
|
+
|
|
477
|
+
self._ws = ws
|
|
478
|
+
if self._heartbeat_interval is not None:
|
|
479
|
+
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
480
|
+
await self._fire(self._connect_handlers)
|
|
481
|
+
|
|
482
|
+
def _ensure_receiver(self) -> None:
|
|
483
|
+
if self._receiver_task is None or self._receiver_task.done():
|
|
484
|
+
self._receiver_task = asyncio.create_task(self._receive_loop())
|
|
485
|
+
|
|
486
|
+
async def _receive_loop(self) -> None:
|
|
487
|
+
ws = self._ws
|
|
488
|
+
try:
|
|
489
|
+
async for message in ws:
|
|
490
|
+
if message.type == aiohttp.WSMsgType.TEXT:
|
|
491
|
+
if not await self._dispatch_message(message.data):
|
|
492
|
+
break
|
|
493
|
+
elif message.type == aiohttp.WSMsgType.ERROR:
|
|
494
|
+
exc = ws.exception()
|
|
495
|
+
raise RuntimeError(f"websocket error: {exc}")
|
|
496
|
+
elif message.type in (
|
|
497
|
+
aiohttp.WSMsgType.CLOSE,
|
|
498
|
+
aiohttp.WSMsgType.CLOSING,
|
|
499
|
+
aiohttp.WSMsgType.CLOSED,
|
|
500
|
+
):
|
|
501
|
+
break
|
|
502
|
+
# BINARY and other frames are ignored.
|
|
503
|
+
except asyncio.CancelledError:
|
|
504
|
+
raise
|
|
505
|
+
except Exception as exc:
|
|
506
|
+
await self._fire(self._error_handlers, exc)
|
|
507
|
+
finally:
|
|
508
|
+
await self._fire(self._disconnect_handlers)
|
|
509
|
+
|
|
510
|
+
async def _dispatch_message(self, raw: str) -> bool:
|
|
511
|
+
"""Decode and dispatch one text frame.
|
|
512
|
+
|
|
513
|
+
Returns ``False`` when the connection should be closed (a server
|
|
514
|
+
error message was received); ``True`` otherwise.
|
|
515
|
+
"""
|
|
516
|
+
try:
|
|
517
|
+
obj = json.loads(raw)
|
|
518
|
+
except (ValueError, TypeError) as exc:
|
|
519
|
+
await self._fire(self._error_handlers, exc)
|
|
520
|
+
return True
|
|
521
|
+
if not isinstance(obj, dict):
|
|
522
|
+
await self._fire(
|
|
523
|
+
self._error_handlers,
|
|
524
|
+
ValueError(f"unexpected pipeline message shape: {type(obj).__name__}"),
|
|
525
|
+
)
|
|
526
|
+
return True
|
|
527
|
+
|
|
528
|
+
# Server error messages look like {"err": "..."} and the connection
|
|
529
|
+
# is closed right after they are sent.
|
|
530
|
+
if "err" in obj:
|
|
531
|
+
await self._fire(
|
|
532
|
+
self._error_handlers, VRChatWebSocketError(str(obj["err"]), raw=raw)
|
|
533
|
+
)
|
|
534
|
+
return False
|
|
535
|
+
|
|
536
|
+
msg_type = obj.get("type")
|
|
537
|
+
raw_content = obj.get("content")
|
|
538
|
+
content: Any = None
|
|
539
|
+
|
|
540
|
+
if msg_type in _NO_CONTENT_EVENTS:
|
|
541
|
+
raw_content = None
|
|
542
|
+
elif msg_type in _STRING_CONTENT_EVENTS:
|
|
543
|
+
content = raw_content
|
|
544
|
+
elif raw_content is not None:
|
|
545
|
+
try:
|
|
546
|
+
content = json.loads(raw_content)
|
|
547
|
+
except (ValueError, TypeError):
|
|
548
|
+
await self._fire(
|
|
549
|
+
self._error_handlers,
|
|
550
|
+
VRChatWebSocketError(
|
|
551
|
+
f"failed to decode content for {msg_type!r}", raw=raw
|
|
552
|
+
),
|
|
553
|
+
)
|
|
554
|
+
return True
|
|
555
|
+
|
|
556
|
+
event = VRChatEvent(
|
|
557
|
+
type=msg_type, content=content, raw=raw, raw_content=raw_content
|
|
558
|
+
)
|
|
559
|
+
for handler in list(self._handlers.get(msg_type, ())):
|
|
560
|
+
await self._invoke(handler, event)
|
|
561
|
+
for handler in list(self._event_handlers):
|
|
562
|
+
await self._invoke(handler, event)
|
|
563
|
+
self._queue.put_nowait(event)
|
|
564
|
+
return True
|
|
565
|
+
|
|
566
|
+
async def _heartbeat_loop(self) -> None:
|
|
567
|
+
try:
|
|
568
|
+
while True:
|
|
569
|
+
await asyncio.sleep(self._heartbeat_interval)
|
|
570
|
+
ws = self._ws
|
|
571
|
+
if ws is None or ws.closed:
|
|
572
|
+
return
|
|
573
|
+
payload = json.dumps(
|
|
574
|
+
{
|
|
575
|
+
"type": "heartbeat",
|
|
576
|
+
"connected": True,
|
|
577
|
+
"nonce": str(uuid.uuid4()),
|
|
578
|
+
}
|
|
579
|
+
)
|
|
580
|
+
try:
|
|
581
|
+
await ws.send_str(payload)
|
|
582
|
+
except Exception:
|
|
583
|
+
return
|
|
584
|
+
except asyncio.CancelledError:
|
|
585
|
+
raise
|
|
586
|
+
|
|
587
|
+
async def _wait_before_reconnect(self, attempt: int) -> bool:
|
|
588
|
+
"""Sleep with backoff before reconnecting; ``False`` stops the loop."""
|
|
589
|
+
if self._closed or not self._auto_reconnect:
|
|
590
|
+
return False
|
|
591
|
+
delay = min(self._reconnect_max_delay, 2.0 ** attempt)
|
|
592
|
+
await self._fire(self._reconnect_handlers, delay)
|
|
593
|
+
try:
|
|
594
|
+
await asyncio.sleep(delay)
|
|
595
|
+
except asyncio.CancelledError:
|
|
596
|
+
return False
|
|
597
|
+
return True
|
|
598
|
+
|
|
599
|
+
async def _teardown(self) -> None:
|
|
600
|
+
heartbeat_task = self._heartbeat_task
|
|
601
|
+
self._heartbeat_task = None
|
|
602
|
+
if (
|
|
603
|
+
heartbeat_task is not None
|
|
604
|
+
and heartbeat_task is not asyncio.current_task()
|
|
605
|
+
and not heartbeat_task.done()
|
|
606
|
+
):
|
|
607
|
+
heartbeat_task.cancel()
|
|
608
|
+
try:
|
|
609
|
+
await heartbeat_task
|
|
610
|
+
except (asyncio.CancelledError, Exception):
|
|
611
|
+
pass
|
|
612
|
+
|
|
613
|
+
ws = self._ws
|
|
614
|
+
self._ws = None
|
|
615
|
+
if ws is not None and not ws.closed:
|
|
616
|
+
try:
|
|
617
|
+
await ws.close()
|
|
618
|
+
except Exception:
|
|
619
|
+
pass
|
|
620
|
+
|
|
621
|
+
def _put_end(self) -> None:
|
|
622
|
+
try:
|
|
623
|
+
self._queue.put_nowait(_END)
|
|
624
|
+
except asyncio.QueueFull: # pragma: no cover - unbounded queue
|
|
625
|
+
pass
|
|
626
|
+
|
|
627
|
+
async def _invoke(self, handler: Callable[..., Any], *args: Any) -> None:
|
|
628
|
+
try:
|
|
629
|
+
result: Any = handler(*args)
|
|
630
|
+
if inspect.isawaitable(result):
|
|
631
|
+
await result
|
|
632
|
+
except asyncio.CancelledError:
|
|
633
|
+
raise
|
|
634
|
+
except Exception:
|
|
635
|
+
logger.exception("Unhandled error in websocket handler %r", handler)
|
|
636
|
+
|
|
637
|
+
async def _fire(
|
|
638
|
+
self, handlers: List[Callable[..., Any]], *args: Any
|
|
639
|
+
) -> None:
|
|
640
|
+
for handler in list(handlers):
|
|
641
|
+
await self._invoke(handler, *args)
|
|
642
|
+
|
|
643
|
+
def __aiter__(self):
|
|
644
|
+
return self
|
|
645
|
+
|
|
646
|
+
async def __anext__(self):
|
|
647
|
+
item = await self._queue.get()
|
|
648
|
+
if item is _END:
|
|
649
|
+
raise StopAsyncIteration
|
|
650
|
+
return item
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vrchatapi-async
|
|
3
|
-
Version: 1.20.8.
|
|
3
|
+
Version: 1.20.8.dev2
|
|
4
4
|
Summary: VRChat API Library for Python (async version)
|
|
5
5
|
Home-page: https://github.com/hi94740/vrchatapi-python-async
|
|
6
6
|
Author: hi94740
|
|
@@ -134,4 +134,50 @@ Compared to [the synchronous SDK](https://github.com/vrchatapi/vrchatapi-python)
|
|
|
134
134
|
## Contributing
|
|
135
135
|
|
|
136
136
|
Contributions are welcome, but do not add features that should be handled by the OpenAPI specification.
|
|
137
|
+
|
|
138
|
+
## Websocket (Pipeline) API
|
|
139
|
+
|
|
140
|
+
The SDK also ships a hand-written async client for VRChat's real-time
|
|
141
|
+
WebSocket (Pipeline) API (`wss://pipeline.vrchat.cloud`), which pushes invites,
|
|
142
|
+
friend requests, friend online/offline events, user and group updates to the
|
|
143
|
+
authenticated client. The connection is receive-only; every message's
|
|
144
|
+
double-encoded `content` field is automatically unpacked into a plain dict.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
import asyncio
|
|
148
|
+
|
|
149
|
+
import vrchatapi
|
|
150
|
+
from vrchatapi.api import authentication_api
|
|
151
|
+
from vrchatapi.websocket import VRChatWebSocket
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def main():
|
|
155
|
+
configuration = vrchatapi.Configuration(username="username", password="password")
|
|
156
|
+
async with vrchatapi.ApiClient(configuration) as api_client:
|
|
157
|
+
api_client.user_agent = "ExampleProgram/0.0.1 my@email.com"
|
|
158
|
+
await authentication_api.AuthenticationApi(api_client).get_current_user()
|
|
159
|
+
|
|
160
|
+
# Reads the auth cookie + User-Agent from the logged-in client.
|
|
161
|
+
ws = VRChatWebSocket.from_client(api_client)
|
|
162
|
+
|
|
163
|
+
@ws.on("friend-online")
|
|
164
|
+
async def on_friend_online(event):
|
|
165
|
+
print("online:", event.content["user"]["displayName"])
|
|
166
|
+
|
|
167
|
+
# Or consume the same stream as an async iterator:
|
|
168
|
+
# async for event in ws:
|
|
169
|
+
# print(event.type, event.content)
|
|
170
|
+
|
|
171
|
+
await ws.run() # blocks; reconnects automatically by default
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`VRChatWebSocket` supports callback registration (`ws.on("friend-online", ...)`
|
|
175
|
+
/ `@ws.on(...)`, a catch-all `ws.on_event(...)`), an async iterator over the
|
|
176
|
+
same event stream, error/connect/disconnect/reconnect hooks, automatic
|
|
177
|
+
reconnection with exponential backoff (`auto_reconnect=False` disables it), and
|
|
178
|
+
a configurable application-level heartbeat (default 30s;
|
|
179
|
+
`heartbeat_interval=None` disables it). See
|
|
180
|
+
[examples/examples-source/websocket.py](examples/examples-source/websocket.py)
|
|
181
|
+
and the [Websocket API reference](https://vrchat.community/websocket) for
|
|
182
|
+
details.
|
|
137
183
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
vrchatapi/__init__.py,sha256=
|
|
1
|
+
vrchatapi/__init__.py,sha256=zJkD8pHWVQnJ6eovsWltVidHFHw-dKO1gTVqNX00gDs,41664
|
|
2
2
|
vrchatapi/api_client.py,sha256=ffvSiscQKq9nF7YSpnKA_Ox15NK51s6BOiBHLevpwJ0,30357
|
|
3
3
|
vrchatapi/api_response.py,sha256=eMxw1mpmJcoGZ3gs9z6jM4oYoZ10Gjk333s9sKxGv7s,652
|
|
4
4
|
vrchatapi/configuration.py,sha256=H5_k_08RDKYqwlSdBbycvvAPVa-9HNM5xWSzGWXMMzg,22847
|
|
5
5
|
vrchatapi/exceptions.py,sha256=Qy7JMK4mOjiA5JiI7myoH2J2vVj2lwnDDcT23oBstYk,6468
|
|
6
6
|
vrchatapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
7
|
vrchatapi/rest.py,sha256=KOQR4uR-LJh5HqgaV96mi2OPqs_Z8qXHEiTRPYoP5ZE,10950
|
|
8
|
+
vrchatapi/websocket.py,sha256=zEnFv_yPWjf2nvLDV8Lio72tSkwmni1Zoa_oGWEXoX0,22767
|
|
8
9
|
vrchatapi/api/__init__.py,sha256=-5ihofpSza-z_y9L694WlqcrDD9GzSLplNm1oYj4QDk,1023
|
|
9
10
|
vrchatapi/api/authentication_api.py,sha256=GMiFBDrf4Ac0zcaBvzhMaKFoQsaq9oXOI3A3bYZxTuA,252974
|
|
10
11
|
vrchatapi/api/avatars_api.py,sha256=SE-BMOUWenKk1oV3C52x1VaNVwIAXOWkDrdr85cLgwY,171358
|
|
@@ -355,8 +356,8 @@ vrchatapi/models/verify_auth_token_result.py,sha256=UzjZTcsVlqBq7MRJUHaObUyyimxU
|
|
|
355
356
|
vrchatapi/models/world.py,sha256=tTmzzwSiaCEdsOcWnNbWGafp0fYuOAV2NosWzIqIyyk,25314
|
|
356
357
|
vrchatapi/models/world_metadata.py,sha256=pgfORW9s0u33WYWenEhMkG_fo43hqrVD3dg_OPPt15A,6868
|
|
357
358
|
vrchatapi/models/world_publish_status.py,sha256=LrcquYDHvMTg_Oo1o8INkCMOY8WU1qO4n13-qbdXleA,7341
|
|
358
|
-
vrchatapi_async-1.20.8.
|
|
359
|
-
vrchatapi_async-1.20.8.
|
|
360
|
-
vrchatapi_async-1.20.8.
|
|
361
|
-
vrchatapi_async-1.20.8.
|
|
362
|
-
vrchatapi_async-1.20.8.
|
|
359
|
+
vrchatapi_async-1.20.8.dev2.dist-info/licenses/LICENSE,sha256=gA1SrmYRSaABwCEgRZH5PJGyhJ3ay2Osp0Xzn63UJrA,1136
|
|
360
|
+
vrchatapi_async-1.20.8.dev2.dist-info/METADATA,sha256=TbdvAph3aC94Y_hACX059NLjr2qWP1U5Ewwk8Zw_nWQ,7844
|
|
361
|
+
vrchatapi_async-1.20.8.dev2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
362
|
+
vrchatapi_async-1.20.8.dev2.dist-info/top_level.txt,sha256=Tjvqz0Z-dyG4IEJVhgJv7cYsvBmXeHYVMDXI-lfpl2o,10
|
|
363
|
+
vrchatapi_async-1.20.8.dev2.dist-info/RECORD,,
|
|
File without changes
|
{vrchatapi_async-1.20.8.dev1.dist-info → vrchatapi_async-1.20.8.dev2.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{vrchatapi_async-1.20.8.dev1.dist-info → vrchatapi_async-1.20.8.dev2.dist-info}/top_level.txt
RENAMED
|
File without changes
|