MultiServer: implement NoText and deprecate uncompressed Websocket connections (#4540)
* MultiServer: add NoText tag and handling * MultiServer: deprecate and warn for uncompressed connections * MultiServer: fix missing space in no compression warning
This commit is contained in:
parent
1ebc9e2ec0
commit
41898ed640
|
@ -28,9 +28,11 @@ ModuleUpdate.update()
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
import ssl
|
import ssl
|
||||||
|
from NetUtils import ServerConnection
|
||||||
|
|
||||||
import websockets
|
|
||||||
import colorama
|
import colorama
|
||||||
|
import websockets
|
||||||
|
from websockets.extensions.permessage_deflate import PerMessageDeflate
|
||||||
try:
|
try:
|
||||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||||
from pony.orm.dbapiprovider import OperationalError
|
from pony.orm.dbapiprovider import OperationalError
|
||||||
|
@ -119,13 +121,14 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
||||||
|
|
||||||
class Client(Endpoint):
|
class Client(Endpoint):
|
||||||
version = Version(0, 0, 0)
|
version = Version(0, 0, 0)
|
||||||
tags: typing.List[str] = []
|
tags: typing.List[str]
|
||||||
remote_items: bool
|
remote_items: bool
|
||||||
remote_start_inventory: bool
|
remote_start_inventory: bool
|
||||||
no_items: bool
|
no_items: bool
|
||||||
no_locations: bool
|
no_locations: bool
|
||||||
|
no_text: bool
|
||||||
|
|
||||||
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
|
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
|
||||||
super().__init__(socket)
|
super().__init__(socket)
|
||||||
self.auth = False
|
self.auth = False
|
||||||
self.team = None
|
self.team = None
|
||||||
|
@ -175,6 +178,7 @@ class Context:
|
||||||
"compatibility": int}
|
"compatibility": int}
|
||||||
# team -> slot id -> list of clients authenticated to slot.
|
# team -> slot id -> list of clients authenticated to slot.
|
||||||
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
||||||
|
endpoints: list[Client]
|
||||||
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||||
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
||||||
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
||||||
|
@ -364,18 +368,28 @@ class Context:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def broadcast_all(self, msgs: typing.List[dict]):
|
def broadcast_all(self, msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||||
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
data = self.dumper(msgs)
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
endpoints = (
|
||||||
|
endpoint
|
||||||
|
for endpoint in self.endpoints
|
||||||
|
if endpoint.auth and not (msg_is_text and endpoint.no_text)
|
||||||
|
)
|
||||||
|
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
||||||
|
|
||||||
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
||||||
self.logger.info("Notice (all): %s" % text)
|
self.logger.info("Notice (all): %s" % text)
|
||||||
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
||||||
|
|
||||||
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||||
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
data = self.dumper(msgs)
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
endpoints = (
|
||||||
|
endpoint
|
||||||
|
for endpoint in itertools.chain.from_iterable(self.clients[team].values())
|
||||||
|
if not (msg_is_text and endpoint.no_text)
|
||||||
|
)
|
||||||
|
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
||||||
|
|
||||||
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msgs = self.dumper(msgs)
|
||||||
|
@ -389,13 +403,13 @@ class Context:
|
||||||
await on_client_disconnected(self, endpoint)
|
await on_client_disconnected(self, endpoint)
|
||||||
|
|
||||||
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
||||||
if not client.auth:
|
if not client.auth or client.no_text:
|
||||||
return
|
return
|
||||||
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||||
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
||||||
|
|
||||||
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
||||||
if not client.auth:
|
if not client.auth or client.no_text:
|
||||||
return
|
return
|
||||||
async_start(self.send_msgs(client,
|
async_start(self.send_msgs(client,
|
||||||
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
||||||
|
@ -760,7 +774,7 @@ class Context:
|
||||||
self.on_new_hint(team, slot)
|
self.on_new_hint(team, slot)
|
||||||
for slot, hint_data in concerns.items():
|
for slot, hint_data in concerns.items():
|
||||||
if recipients is None or slot in recipients:
|
if recipients is None or slot in recipients:
|
||||||
clients = self.clients[team].get(slot)
|
clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, []))
|
||||||
if not clients:
|
if not clients:
|
||||||
continue
|
continue
|
||||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
||||||
|
@ -819,7 +833,7 @@ def update_aliases(ctx: Context, team: int):
|
||||||
async_start(ctx.send_encoded_msgs(client, cmd))
|
async_start(ctx.send_encoded_msgs(client, cmd))
|
||||||
|
|
||||||
|
|
||||||
async def server(websocket, path: str = "/", ctx: Context = None):
|
async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None:
|
||||||
client = Client(websocket, ctx)
|
client = Client(websocket, ctx)
|
||||||
ctx.endpoints.append(client)
|
ctx.endpoints.append(client)
|
||||||
|
|
||||||
|
@ -910,6 +924,10 @@ async def on_client_joined(ctx: Context, client: Client):
|
||||||
"If your client supports it, "
|
"If your client supports it, "
|
||||||
"you may have additional local commands you can list with /help.",
|
"you may have additional local commands you can list with /help.",
|
||||||
{"type": "Tutorial"})
|
{"type": "Tutorial"})
|
||||||
|
if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions):
|
||||||
|
ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! "
|
||||||
|
"It may stop working in the future. If you are a player, please report this to the "
|
||||||
|
"client's developer.")
|
||||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1803,7 +1821,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||||
ctx.clients[team][slot].append(client)
|
ctx.clients[team][slot].append(client)
|
||||||
client.version = args['version']
|
client.version = args['version']
|
||||||
client.tags = args['tags']
|
client.tags = args['tags']
|
||||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
|
||||||
|
# set NoText for old PopTracker clients that predate the tag to save traffic
|
||||||
|
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
||||||
connected_packet = {
|
connected_packet = {
|
||||||
"cmd": "Connected",
|
"cmd": "Connected",
|
||||||
"team": client.team, "slot": client.slot,
|
"team": client.team, "slot": client.slot,
|
||||||
|
@ -1876,6 +1896,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||||
client.tags = args["tags"]
|
client.tags = args["tags"]
|
||||||
if set(old_tags) != set(client.tags):
|
if set(old_tags) != set(client.tags):
|
||||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||||
|
client.no_text = "NoText" in client.tags or (
|
||||||
|
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
||||||
|
)
|
||||||
ctx.broadcast_text_all(
|
ctx.broadcast_text_all(
|
||||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
|
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
|
||||||
f"from {old_tags} to {client.tags}.",
|
f"from {old_tags} to {client.tags}.",
|
||||||
|
|
|
@ -5,7 +5,8 @@ import enum
|
||||||
import warnings
|
import warnings
|
||||||
from json import JSONEncoder, JSONDecoder
|
from json import JSONEncoder, JSONDecoder
|
||||||
|
|
||||||
import websockets
|
if typing.TYPE_CHECKING:
|
||||||
|
from websockets import WebSocketServerProtocol as ServerConnection
|
||||||
|
|
||||||
from Utils import ByValue, Version
|
from Utils import ByValue, Version
|
||||||
|
|
||||||
|
@ -151,7 +152,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
||||||
|
|
||||||
|
|
||||||
class Endpoint:
|
class Endpoint:
|
||||||
socket: websockets.WebSocketServerProtocol
|
socket: "ServerConnection"
|
||||||
|
|
||||||
def __init__(self, socket):
|
def __init__(self, socket):
|
||||||
self.socket = socket
|
self.socket = socket
|
||||||
|
|
|
@ -47,6 +47,9 @@ Packets are simple JSON lists in which any number of ordered network commands ca
|
||||||
|
|
||||||
An object can contain the "class" key, which will tell the content data type, such as "Version" in the following example.
|
An object can contain the "class" key, which will tell the content data type, such as "Version" in the following example.
|
||||||
|
|
||||||
|
Websocket connections should support per-message compression. Uncompressed connections are deprecated and may stop
|
||||||
|
working in the future.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```javascript
|
```javascript
|
||||||
[{"cmd": "RoomInfo", "version": {"major": 0, "minor": 1, "build": 3, "class": "Version"}, "tags": ["WebHost"], ... }]
|
[{"cmd": "RoomInfo", "version": {"major": 0, "minor": 1, "build": 3, "class": "Version"}, "tags": ["WebHost"], ... }]
|
||||||
|
@ -745,6 +748,7 @@ Tags are represented as a list of strings, the common client tags follow:
|
||||||
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
|
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
|
||||||
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||||
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||||
|
| NoText | Indicates the client does not want to receive text messages, improving performance if not needed. |
|
||||||
|
|
||||||
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
|
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
|
||||||
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
|
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
|
||||||
|
|
Loading…
Reference in New Issue