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 CHANGED
@@ -14,7 +14,7 @@
14
14
  """ # noqa: E501
15
15
 
16
16
 
17
- __version__ = "1.20.8dev1"
17
+ __version__ = "1.20.8dev2"
18
18
 
19
19
  # Define package exports
20
20
  __all__ = [
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.dev1
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=Ad8SpuIhtLo-iPeEMQGGdxdAgDvqoy3wmW3QQB9_4rc,41664
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.dev1.dist-info/licenses/LICENSE,sha256=gA1SrmYRSaABwCEgRZH5PJGyhJ3ay2Osp0Xzn63UJrA,1136
359
- vrchatapi_async-1.20.8.dev1.dist-info/METADATA,sha256=vBm-fT85zBlN_vLMOKcOjmnvaZedUsmuXjf1805klJ8,5923
360
- vrchatapi_async-1.20.8.dev1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
361
- vrchatapi_async-1.20.8.dev1.dist-info/top_level.txt,sha256=Tjvqz0Z-dyG4IEJVhgJv7cYsvBmXeHYVMDXI-lfpl2o,10
362
- vrchatapi_async-1.20.8.dev1.dist-info/RECORD,,
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,,