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:
black-sliver 2025-01-29 01:42:46 +01:00 committed by GitHub
parent 1ebc9e2ec0
commit 41898ed640
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 44 additions and 16 deletions

View File

@ -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}.",

View File

@ -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

View File

@ -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.