client.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. from websockets.client import WebSocketClientProtocol, connect as ws_connect
  2. import json
  3. from .avatar import Avatar
  4. from .naf import NAF
  5. from .utils import dataclass, field
  6. @dataclass
  7. class MSG:
  8. channel: int | None = None
  9. id: int | None = None
  10. target: str = ""
  11. cmd: str = ""
  12. data: object = field(default_factory=dict)
  13. @classmethod
  14. def from_json(cls, json_str: str) -> "MSG":
  15. """Parse JSON message.
  16. :param json_str: JSON string
  17. :return: MSG
  18. """
  19. return cls(*json.loads(json_str))
  20. def to_json(self) -> str:
  21. """Convert to JSON string.
  22. :return: JSON string
  23. """
  24. return json.dumps(
  25. [str(self.channel), str(self.id), self.target, self.cmd, self.data], default=lambda o: o.to_obj()
  26. )
  27. __str__ = to_json
  28. class HubsClient:
  29. def __init__(
  30. self,
  31. host: str,
  32. room_id: str,
  33. avatar_id: str = None,
  34. display_name: str = "API Client",
  35. ):
  36. """Hubs room client.
  37. :param host: The host of the room, e.g. "hubs.mozilla.com"
  38. :param room_id: The hub room ID code
  39. :param avatar_id: The avatar ID
  40. :param display_name: The display name for the avatar
  41. """
  42. self.host = host
  43. self.url = f"wss://{host}/socket/websocket?vsn=2.0.0"
  44. self.sock: WebSocketClientProtocol = None
  45. self.mix: dict[int, int] = {}
  46. self.room_id = room_id
  47. self.display_name = display_name
  48. self.avatar_id = avatar_id
  49. self.sid: str = None
  50. avatar_url = avatar_id if avatar_id.startswith("http") else f"https://{host}/api/v1/avatars/{avatar_id}/avatar.gltf"
  51. self.avatar = Avatar(avatar_url=avatar_url)
  52. self.msg_buf: list[MSG] = []
  53. async def send_cmd(self, ch, tgt, cmd, body):
  54. """Send a command to a channel.
  55. :param ch: Channel number
  56. :param tgt: Channel target
  57. :param cmd: Command
  58. :param body: Payload body
  59. """
  60. # increment message index
  61. # hack to get around null, null
  62. self.mix[ch] = ch and (self.mix.get(ch, ch - 1) + 1)
  63. return await self.sock.send(MSG(ch, self.mix[ch], tgt, cmd, body).to_json())
  64. def send8(self, cmd: str, body: dict):
  65. """Send a command on channel 8, resource update.
  66. :param cmd: Command
  67. :param body: Payload body
  68. """
  69. return self.send_cmd(8, f"hub:{self.room_id}", cmd, body)
  70. async def send_naf(self, naf: NAF):
  71. """Send a NAF update.
  72. :param naf: NAF object
  73. """
  74. return await self.send8("naf", {"dataType": "u", "data": naf.to_obj()})
  75. async def send_chat(self, message: str):
  76. """Send a chat message.
  77. :param message: Message to send
  78. """
  79. return await self.send8("message", {"body": message, "type": "chat"})
  80. async def get_message(self) -> MSG:
  81. """Get a message from the socket.
  82. :return: MSG
  83. """
  84. try:
  85. msg = await self.sock.recv()
  86. msg = MSG.from_json(msg)
  87. self.msg_buf.append(msg)
  88. return msg
  89. except TimeoutError:
  90. return None
  91. async def sync(self):
  92. return await self.send_naf(self.avatar)
  93. async def send_heartbeat(self):
  94. """Send a heartbeat."""
  95. return await self.send_cmd(None, "phoenix", "heartbeat", {})
  96. async def join(self):
  97. self.sock = await ws_connect(self.url)
  98. # send first join msg
  99. await self.send_cmd(5, "ret", "phx_join", {"hub_id": self.room_id})
  100. self.sid = (await self.get_message()).data["response"]["session_id"]
  101. # setup profile
  102. await self.send8(
  103. "phx_join",
  104. {
  105. "profile": {
  106. "avatarId": self.avatar_id,
  107. "displayName": self.display_name,
  108. },
  109. "push_subscription_endpoint": None,
  110. "auth_token": None,
  111. "perms_token": None,
  112. "context": {"mobile": False, "embed": False, "hmd": False},
  113. "hub_invite_id": None,
  114. },
  115. )
  116. self.sessinfo = (await self.get_message()).data["response"]
  117. # enter room
  118. await self.send8(
  119. "events:entered",
  120. {
  121. "isNewDaily": False,
  122. "isNewMonthly": False,
  123. "isNewDayWindow": False,
  124. "isNewMonthWindow": False,
  125. "initialOccupantCount": 0,
  126. "entryDisplayType": "Headless",
  127. "userAgent": "Python",
  128. },
  129. )
  130. self.avatar.owner_id = self.sid
  131. await self.sync()
  132. async def close(self):
  133. """Close the connection."""
  134. await self.sock.close()
  135. self.sock = None
  136. self.sid = None
  137. self.msg_buf = []
  138. self.mix = {}