From 9863d507ae56c3672427875a839709e787a745fe Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:11:24 +0000 Subject: [PATCH 01/27] feat(cloud_server): implement SSL secure websocket from semver2 into semver3 Signed-off-by: GitHub --- scratchattach/eventhandlers/cloud_server.py | 403 +++++++++++++------- 1 file changed, 263 insertions(+), 140 deletions(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 0bda1f84..89edb2df 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -1,6 +1,8 @@ from __future__ import annotations +import ssl +from typing import Any -from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket +from SimpleWebSocketServer import SimpleSSLWebSocketServer, SimpleWebSocketServer, WebSocket from threading import Thread from scratchattach.utils import exceptions import json @@ -10,6 +12,170 @@ from ._base import BaseEventHandler import traceback +class _SaCloudServer(BaseEventHandler): + def __init__(self, + hostname: str, + *, + certfile: str|None = None, + keyfile: str|None = None, + ssl_version: int = ssl.PROTOCOL_TLSv1_2, + ssl_context: ssl.SSLContext|None = None, + port: int, + websocketclass: WebSocket, + length_limit: int|None = None, + allow_non_numeric: bool = True, + whitelisted_projects: list[Any]|None = None, + allow_nonscratch_names: bool = True, + blocked_ips: list[str]|None = None, + sync_players: bool = True, + log_var_sets: bool = True): + + BaseEventHandler.__init__(self) + + self.running = False + self._events = {} # saves event functions called on cloud updates + + self.tw_clients = {} # saves connected clients + self.tw_variables = {} # holds cloud variable states + + self.hostname = hostname + self.port = port + + # server config + self.allow_non_numeric = allow_non_numeric + self.whitelisted_projects = whitelisted_projects + self.length_limit = length_limit + self.allow_nonscratch_names = allow_nonscratch_names + self.blocked_ips = blocked_ips + self.sync_players = sync_players + self.log_var_sets = log_var_sets + + def check_for_ip_ban(self, client): + if ( + client.address[0] in self.blocked_ips + or client.address[0] + ":" + str(client.address[1]) in self.blocked_ips + or client.address in self.blocked_ips + ): + client.sendMessage("You have been banned from this server") + client.close(4002) + print(client.address[0] + ":" + str(client.address[1]), "(IP-banned) was disconnected") + return True + return False + + def active_projects(self): + only_active = {} + for project_id in self.tw_variables: + if self.active_user_ips(project_id) != []: + only_active[project_id] = self.tw_variables[project_id] + return only_active + + def active_user_names(self, project_id): + return [self.tw_clients[user]["username"] for user in self.active_user_ips(project_id)] + + def active_user_ips(self, project_id): + return list(filter(lambda user: str(self.tw_clients[user]["project_id"]) == str(project_id), self.tw_clients)) + + def get_global_vars(self): + return self.tw_variables + + def get_project_vars(self, project_id): + project_id = str(project_id) + if project_id in self.tw_variables: + return self.tw_variables[project_id] + else: + return {} + + def get_var(self, project_id, var_name): + project_id = str(project_id) + var_name = var_name.replace("☁ ", "") + if project_id in self.tw_variables: + if var_name in self.tw_variables[project_id]: + return self.tw_variables[project_id][var_name] + else: + return None + else: + return None + + def set_global_vars(self, data): + for project_id in data: + self.set_project_vars(project_id, data[project_id]) + + def set_project_vars(self, project_id, data, *, user="@server"): + project_id = str(project_id) + self.tw_variables[project_id] = data + for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + client.sendMessage( + "\n".join( + [ + json.dumps( + { + "method": "set", + "project_id": project_id, + "name": "☁ " + varname, + "value": data[varname], + "server": "scratchattach/2.0.0", + "timestamp": time.time() * 1000, + "user": user, + } + ) + for varname in data + ] + ) + ) + + def set_var(self, project_id, var_name, value, *, user="@server", skip_forward=None): + var_name = var_name.replace("☁ ", "") + project_id = str(project_id) + if project_id not in self.tw_variables: + self.tw_variables[project_id] = {} + self.tw_variables[project_id][var_name] = value + + if self.sync_players is True: + for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + if client == skip_forward: + continue + client.sendMessage( + json.dumps( + { + "method": "set", + "project_id": project_id, + "name": "☁ " + var_name, + "value": value, + "timestamp": time.time() * 1000, + "user": user, + } + ) + ) + + def _check_value(self, value): + # Checks if a received cloud value satisfies the server's constraints + if self.length_limit is not None: + if len(str(value)) > self.length_limit: + return False + if self.allow_non_numeric is False: + x = value.replace(".", "") + x = x.replace("-", "") + if not (x.isnumeric() or x == ""): + return False + return True + + def _updater(self): + try: + # Function called when .start() is executed (.start is inherited from BaseEventHandler) + print(f"Serving websocket server: ws://{self.hostname}:{self.port}") + self.serveforever() + except Exception as e: + raise exceptions.WebsocketServerError(str(e)) + + def pause(self): + self.running = False + + def resume(self): + self.running = True + + def stop(self, wait_call_threads: bool = True): + BaseEventHandler.stop(self, wait_call_threads) + self.close() class TwCloudSocket(WebSocket): server: TwCloudServer @@ -181,7 +347,7 @@ def handleClose(self): print("Internal error in handleClose:", e) -class TwCloudServer(SimpleWebSocketServer, BaseEventHandler): +class TwCloudServer(_SaCloudServer, SimpleWebSocketServer): def __init__( self, hostname, @@ -200,154 +366,69 @@ def __init__( blocked_ips = [] SimpleWebSocketServer.__init__(self, hostname, port=port, websocketclass=websocketclass) - BaseEventHandler.__init__(self) - - self.running = False - self._events = {} # saves event functions called on cloud updates - - self.tw_clients = {} # saves connected clients - self.tw_variables = {} # holds cloud variable states - - self.hostname = hostname - self.port = port - - # server config - self.allow_non_numeric = allow_non_numeric - self.whitelisted_projects = whitelisted_projects - self.length_limit = length_limit - self.allow_nonscratch_names = allow_nonscratch_names - self.blocked_ips = blocked_ips - self.sync_players = sync_players - self.log_var_sets = log_var_sets - def check_for_ip_ban(self, client): - if ( - client.address[0] in self.blocked_ips - or client.address[0] + ":" + str(client.address[1]) in self.blocked_ips - or client.address in self.blocked_ips - ): - client.sendMessage("You have been banned from this server") - client.close(4002) - print(client.address[0] + ":" + str(client.address[1]), "(IP-banned) was disconnected") - return True - return False - - def active_projects(self): - only_active = {} - for project_id in self.tw_variables: - if self.active_user_ips(project_id) != []: - only_active[project_id] = self.tw_variables[project_id] - return only_active - - def active_user_names(self, project_id): - return [self.tw_clients[user]["username"] for user in self.active_user_ips(project_id)] - - def active_user_ips(self, project_id): - return list(filter(lambda user: str(self.tw_clients[user]["project_id"]) == str(project_id), self.tw_clients)) - - def get_global_vars(self): - return self.tw_variables - - def get_project_vars(self, project_id): - project_id = str(project_id) - if project_id in self.tw_variables: - return self.tw_variables[project_id] - else: - return {} - - def get_var(self, project_id, var_name): - project_id = str(project_id) - var_name = var_name.replace("☁ ", "") - if project_id in self.tw_variables: - if var_name in self.tw_variables[project_id]: - return self.tw_variables[project_id][var_name] - else: - return None - else: - return None - - def set_global_vars(self, data): - for project_id in data: - self.set_project_vars(project_id, data[project_id]) - - def set_project_vars(self, project_id, data, *, user="@server"): - project_id = str(project_id) - self.tw_variables[project_id] = data - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: - client.sendMessage( - "\n".join( - [ - json.dumps( - { - "method": "set", - "project_id": project_id, - "name": "☁ " + varname, - "value": data[varname], - "server": "scratchattach/2.0.0", - "timestamp": time.time() * 1000, - "user": user, - } - ) - for varname in data - ] - ) - ) - - def set_var(self, project_id, var_name, value, *, user="@server", skip_forward=None): - var_name = var_name.replace("☁ ", "") - project_id = str(project_id) - if project_id not in self.tw_variables: - self.tw_variables[project_id] = {} - self.tw_variables[project_id][var_name] = value - - if self.sync_players is True: - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: - if client == skip_forward: - continue - client.sendMessage( - json.dumps( - { - "method": "set", - "project_id": project_id, - "name": "☁ " + var_name, - "value": value, - "timestamp": time.time() * 1000, - "user": user, - } - ) - ) + _SaCloudServer.__init__(self, + hostname=hostname, + port=port, + websocketclass=websocketclass, + length_limit=length_limit, + allow_non_numeric=allow_non_numeric, + whitelisted_projects=whitelisted_projects, + allow_nonscratch_names=allow_nonscratch_names, + blocked_ips=blocked_ips, + sync_players=sync_players, + log_var_sets=log_var_sets) + +class TwSSLCloudServer(_SaCloudServer, SimpleSSLWebSocketServer): + def __init__( + self, + hostname: str, + *, + certfile=None, + keyfile=None, + ssl_version=ssl.PROTOCOL_TLSv1_2, + ssl_context=None, + port, + websocketclass, + length_limit=None, + allow_non_numeric=True, + whitelisted_projects=None, + allow_nonscratch_names=True, + blocked_ips=None, + sync_players=True, + log_var_sets= True + ): + SimpleSSLWebSocketServer.__init__( + self, + hostname, + port=port, + websocketclass=websocketclass, + certfile=certfile, + keyfile=keyfile, + version=ssl_version, + ssl_context=ssl_context, + ) - def _check_value(self, value): - # Checks if a received cloud value satisfies the server's constraints - if self.length_limit is not None: - if len(str(value)) > self.length_limit: - return False - if self.allow_non_numeric is False: - x = value.replace(".", "") - x = x.replace("-", "") - if not (x.isnumeric() or x == ""): - return False - return True + _SaCloudServer.__init__(self, + hostname=hostname, + port=port, + websocketclass=websocketclass, + length_limit=length_limit, + allow_non_numeric=allow_non_numeric, + whitelisted_projects=whitelisted_projects, + allow_nonscratch_names=allow_nonscratch_names, + blocked_ips=blocked_ips, + sync_players=sync_players, + log_var_sets=log_var_sets) def _updater(self): try: # Function called when .start() is executed (.start is inherited from BaseEventHandler) - print(f"Serving websocket server: ws://{self.hostname}:{self.port}") + print(f"Serving websocket server: wss://{self.hostname}:{self.port}") self.serveforever() except Exception as e: raise exceptions.WebsocketServerError(str(e)) - def pause(self): - self.running = False - - def resume(self): - self.running = True - - def stop(self, wait_call_threads: bool = True): - BaseEventHandler.stop(self, wait_call_threads) - self.close() - - def init_cloud_server( hostname="127.0.0.1", port=8080, @@ -380,3 +461,45 @@ def init_cloud_server( sync_players=sync_players, log_var_sets=log_var_sets, ) + +def init_ssl_cloud_server( + hostname: str = "127.0.0.1", + port: int = 8080, + *, + certfile=None, + keyfile=None, + ssl_version=ssl.PROTOCOL_TLSv1_2, + ssl_context=None, + length_limit=None, + allow_non_numeric=True, + whitelisted_projects=None, + allow_nonscratch_names=True, + blocked_ips=None, + sync_players=True, + log_var_sets=True +) -> TwSSLCloudServer: + """ + Inits a websocket server which can be used with TurboWarp's ?cloud_host URL parameter. + + Prints out the websocket address in the console. + """ + if (certfile is None or keyfile is None) and ssl_context is None: + print("[yellow]WARNING: To init a ssl cloud server, you need provide `certfile` and "+ + "`keyfile` or `ssl_context`.[/]") + + return TwSSLCloudServer( + hostname, + port=port, + websocketclass=TwCloudSocket, + certfile=certfile, + keyfile=keyfile, + ssl_version=ssl_version, + ssl_context=ssl_context, + length_limit=length_limit, + allow_non_numeric=allow_non_numeric, + whitelisted_projects=whitelisted_projects, + allow_nonscratch_names=allow_nonscratch_names, + blocked_ips=blocked_ips, + sync_players=sync_players, + log_var_sets=log_var_sets + ) From 7e8fdddce81fb81e38adfc0d3769fc3c5ed93e7b Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:36:26 +0000 Subject: [PATCH 02/27] fix(cloud_server): expose `init_ssl_cloud_sever` to top-level Signed-off-by: GitHub --- scratchattach/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index a2329989..94805f83 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -1,7 +1,7 @@ from .cloud.cloud import CustomCloud, ScratchCloud, TwCloud, get_cloud, get_scratch_cloud, get_tw_cloud from .cloud._base import BaseCloud, AnyCloud -from .eventhandlers.cloud_server import init_cloud_server +from .eventhandlers.cloud_server import init_cloud_server, init_ssl_cloud_server from .eventhandlers._base import BaseEventHandler from .eventhandlers.filterbot import Filterbot, HardFilter, SoftFilter, SpamFilter from .eventhandlers.cloud_storage import Database From bab29051255a56b8ca7fd1b3f1dc7bcaf10a7b44 Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:58:57 +0000 Subject: [PATCH 03/27] chore(eventhandlers._base): move mixin class to _base - Moved `BaseCloudServer` (Formerly `_SaCloudServer`) to sa.eventhandlers._base Signed-off-by: GitHub --- scratchattach/eventhandlers/_base.py | 175 +++++++++++++++++- scratchattach/eventhandlers/cloud_server.py | 185 ++------------------ 2 files changed, 182 insertions(+), 178 deletions(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 4e4236ac..b8cdb10f 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -1,11 +1,15 @@ from __future__ import annotations +import json +import time +import ssl from abc import ABC, abstractmethod from typing import Optional from collections import defaultdict from threading import Thread, Event from collections.abc import Callable import traceback + from scratchattach.utils.requests import requests from scratchattach.utils import exceptions @@ -41,7 +45,7 @@ def start(self, *, thread=True, ignore_exceptions=True): else: self._thread = None self._updater() - + def call_event(self, event_name, args : list = []): try: # print(f"Calling for {event_name}...") @@ -69,7 +73,7 @@ def call_event(self, event_name, args : list = []): @abstractmethod def _updater(self): pass - + def __del__(self): self.stop() @@ -120,4 +124,169 @@ def inner(function): return inner else: # => the decorator doesn't provide arguments - inner(function) \ No newline at end of file + inner(function) + +class BaseCloudServer(BaseEventHandler): + def __init__(self, + hostname: str, + *, + certfile: str|None = None, + keyfile: str|None = None, + ssl_version: int = ssl.PROTOCOL_TLSv1_2, + ssl_context: ssl.SSLContext|None = None, + port: int, + websocketclass: WebSocket, + length_limit: int|None = None, + allow_non_numeric: bool = True, + whitelisted_projects: list[Any]|None = None, + allow_nonscratch_names: bool = True, + blocked_ips: list[str]|None = None, + sync_players: bool = True, + log_var_sets: bool = True): + + BaseEventHandler.__init__(self) + + self.running = False + self._events = {} # saves event functions called on cloud updates + + self.tw_clients = {} # saves connected clients + self.tw_variables = {} # holds cloud variable states + + self.hostname = hostname + self.port = port + + # server config + self.allow_non_numeric = allow_non_numeric + self.whitelisted_projects = whitelisted_projects + self.length_limit = length_limit + self.allow_nonscratch_names = allow_nonscratch_names + self.blocked_ips = blocked_ips + self.sync_players = sync_players + self.log_var_sets = log_var_sets + + def check_for_ip_ban(self, client): + if ( + client.address[0] in self.blocked_ips + or client.address[0] + ":" + str(client.address[1]) in self.blocked_ips + or client.address in self.blocked_ips + ): + client.sendMessage("You have been banned from this server") + client.close(4002) + print(client.address[0] + ":" + str(client.address[1]), "(IP-banned) was disconnected") + return True + return False + + def active_projects(self): + only_active = {} + for project_id in self.tw_variables: + if self.active_user_ips(project_id) != []: + only_active[project_id] = self.tw_variables[project_id] + return only_active + + def active_user_names(self, project_id): + return [self.tw_clients[user]["username"] for user in self.active_user_ips(project_id)] + + def active_user_ips(self, project_id): + return list(filter(lambda user: str(self.tw_clients[user]["project_id"]) == str(project_id), self.tw_clients)) + + def get_global_vars(self): + return self.tw_variables + + def get_project_vars(self, project_id): + project_id = str(project_id) + if project_id in self.tw_variables: + return self.tw_variables[project_id] + else: + return {} + + def get_var(self, project_id, var_name): + project_id = str(project_id) + var_name = var_name.replace("☁ ", "") + if project_id in self.tw_variables: + if var_name in self.tw_variables[project_id]: + return self.tw_variables[project_id][var_name] + else: + return None + else: + return None + + def set_global_vars(self, data): + for project_id in data: + self.set_project_vars(project_id, data[project_id]) + + def set_project_vars(self, project_id, data, *, user="@server"): + project_id = str(project_id) + self.tw_variables[project_id] = data + for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + client.sendMessage( + "\n".join( + [ + json.dumps( + { + "method": "set", + "project_id": project_id, + "name": "☁ " + varname, + "value": data[varname], + "server": "scratchattach/2.0.0", + "timestamp": time.time() * 1000, + "user": user, + } + ) + for varname in data + ] + ) + ) + + def set_var(self, project_id, var_name, value, *, user="@server", skip_forward=None): + var_name = var_name.replace("☁ ", "") + project_id = str(project_id) + if project_id not in self.tw_variables: + self.tw_variables[project_id] = {} + self.tw_variables[project_id][var_name] = value + + if self.sync_players is True: + for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + if client == skip_forward: + continue + client.sendMessage( + json.dumps( + { + "method": "set", + "project_id": project_id, + "name": "☁ " + var_name, + "value": value, + "timestamp": time.time() * 1000, + "user": user, + } + ) + ) + + def _check_value(self, value): + # Checks if a received cloud value satisfies the server's constraints + if self.length_limit is not None: + if len(str(value)) > self.length_limit: + return False + if self.allow_non_numeric is False: + x = value.replace(".", "") + x = x.replace("-", "") + if not (x.isnumeric() or x == ""): + return False + return True + + def _updater(self): + try: + # Function called when .start() is executed (.start is inherited from BaseEventHandler) + print(f"Serving websocket server: ws://{self.hostname}:{self.port}") + self.serveforever() + except Exception as e: + raise exceptions.WebsocketServerError(str(e)) + + def pause(self): + self.running = False + + def resume(self): + self.running = True + + def stop(self, wait_call_threads: bool = True): + BaseEventHandler.stop(self, wait_call_threads) + self.close() diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 89edb2df..7c4646b0 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -1,181 +1,16 @@ from __future__ import annotations -import ssl -from typing import Any -from SimpleWebSocketServer import SimpleSSLWebSocketServer, SimpleWebSocketServer, WebSocket -from threading import Thread -from scratchattach.utils import exceptions import json import time -from scratchattach.site import cloud_activity -from scratchattach.site.user import User -from ._base import BaseEventHandler +import ssl import traceback -class _SaCloudServer(BaseEventHandler): - def __init__(self, - hostname: str, - *, - certfile: str|None = None, - keyfile: str|None = None, - ssl_version: int = ssl.PROTOCOL_TLSv1_2, - ssl_context: ssl.SSLContext|None = None, - port: int, - websocketclass: WebSocket, - length_limit: int|None = None, - allow_non_numeric: bool = True, - whitelisted_projects: list[Any]|None = None, - allow_nonscratch_names: bool = True, - blocked_ips: list[str]|None = None, - sync_players: bool = True, - log_var_sets: bool = True): - - BaseEventHandler.__init__(self) - - self.running = False - self._events = {} # saves event functions called on cloud updates - - self.tw_clients = {} # saves connected clients - self.tw_variables = {} # holds cloud variable states - - self.hostname = hostname - self.port = port - - # server config - self.allow_non_numeric = allow_non_numeric - self.whitelisted_projects = whitelisted_projects - self.length_limit = length_limit - self.allow_nonscratch_names = allow_nonscratch_names - self.blocked_ips = blocked_ips - self.sync_players = sync_players - self.log_var_sets = log_var_sets - - def check_for_ip_ban(self, client): - if ( - client.address[0] in self.blocked_ips - or client.address[0] + ":" + str(client.address[1]) in self.blocked_ips - or client.address in self.blocked_ips - ): - client.sendMessage("You have been banned from this server") - client.close(4002) - print(client.address[0] + ":" + str(client.address[1]), "(IP-banned) was disconnected") - return True - return False - - def active_projects(self): - only_active = {} - for project_id in self.tw_variables: - if self.active_user_ips(project_id) != []: - only_active[project_id] = self.tw_variables[project_id] - return only_active - - def active_user_names(self, project_id): - return [self.tw_clients[user]["username"] for user in self.active_user_ips(project_id)] - - def active_user_ips(self, project_id): - return list(filter(lambda user: str(self.tw_clients[user]["project_id"]) == str(project_id), self.tw_clients)) - - def get_global_vars(self): - return self.tw_variables - - def get_project_vars(self, project_id): - project_id = str(project_id) - if project_id in self.tw_variables: - return self.tw_variables[project_id] - else: - return {} - - def get_var(self, project_id, var_name): - project_id = str(project_id) - var_name = var_name.replace("☁ ", "") - if project_id in self.tw_variables: - if var_name in self.tw_variables[project_id]: - return self.tw_variables[project_id][var_name] - else: - return None - else: - return None - - def set_global_vars(self, data): - for project_id in data: - self.set_project_vars(project_id, data[project_id]) - - def set_project_vars(self, project_id, data, *, user="@server"): - project_id = str(project_id) - self.tw_variables[project_id] = data - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: - client.sendMessage( - "\n".join( - [ - json.dumps( - { - "method": "set", - "project_id": project_id, - "name": "☁ " + varname, - "value": data[varname], - "server": "scratchattach/2.0.0", - "timestamp": time.time() * 1000, - "user": user, - } - ) - for varname in data - ] - ) - ) - - def set_var(self, project_id, var_name, value, *, user="@server", skip_forward=None): - var_name = var_name.replace("☁ ", "") - project_id = str(project_id) - if project_id not in self.tw_variables: - self.tw_variables[project_id] = {} - self.tw_variables[project_id][var_name] = value - - if self.sync_players is True: - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: - if client == skip_forward: - continue - client.sendMessage( - json.dumps( - { - "method": "set", - "project_id": project_id, - "name": "☁ " + var_name, - "value": value, - "timestamp": time.time() * 1000, - "user": user, - } - ) - ) - - def _check_value(self, value): - # Checks if a received cloud value satisfies the server's constraints - if self.length_limit is not None: - if len(str(value)) > self.length_limit: - return False - if self.allow_non_numeric is False: - x = value.replace(".", "") - x = x.replace("-", "") - if not (x.isnumeric() or x == ""): - return False - return True - - def _updater(self): - try: - # Function called when .start() is executed (.start is inherited from BaseEventHandler) - print(f"Serving websocket server: ws://{self.hostname}:{self.port}") - self.serveforever() - except Exception as e: - raise exceptions.WebsocketServerError(str(e)) - - def pause(self): - self.running = False - - def resume(self): - self.running = True +from SimpleWebSocketServer import SimpleSSLWebSocketServer, SimpleWebSocketServer, WebSocket - def stop(self, wait_call_threads: bool = True): - BaseEventHandler.stop(self, wait_call_threads) - self.close() +from scratchattach.utils import exceptions +from scratchattach.site import cloud_activity +from scratchattach.site.user import User +from ._base import BaseCloudServer class TwCloudSocket(WebSocket): server: TwCloudServer @@ -347,7 +182,7 @@ def handleClose(self): print("Internal error in handleClose:", e) -class TwCloudServer(_SaCloudServer, SimpleWebSocketServer): +class TwCloudServer(BaseCloudServer, SimpleWebSocketServer): def __init__( self, hostname, @@ -367,7 +202,7 @@ def __init__( SimpleWebSocketServer.__init__(self, hostname, port=port, websocketclass=websocketclass) - _SaCloudServer.__init__(self, + BaseCloudServer.__init__(self, hostname=hostname, port=port, websocketclass=websocketclass, @@ -379,7 +214,7 @@ def __init__( sync_players=sync_players, log_var_sets=log_var_sets) -class TwSSLCloudServer(_SaCloudServer, SimpleSSLWebSocketServer): +class TwSSLCloudServer(BaseCloudServer, SimpleSSLWebSocketServer): def __init__( self, hostname: str, @@ -409,7 +244,7 @@ def __init__( ssl_context=ssl_context, ) - _SaCloudServer.__init__(self, + BaseCloudServer.__init__(self, hostname=hostname, port=port, websocketclass=websocketclass, From 5cd379d93f7a6f2c4ba747bd2e4d3d790894f05c Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:38:01 +0000 Subject: [PATCH 04/27] fatal(eventhandlers._base): missing imports Signed-off-by: GitHub --- scratchattach/eventhandlers/_base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index b8cdb10f..79a59edb 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -4,12 +4,14 @@ import time import ssl from abc import ABC, abstractmethod -from typing import Optional +from typing import Optional, Any from collections import defaultdict from threading import Thread, Event from collections.abc import Callable import traceback +from SimpleWebSocketServer import WebSocket + from scratchattach.utils.requests import requests from scratchattach.utils import exceptions From 295029761060d4b4c55941196f3897d64aeb034e Mon Sep 17 00:00:00 2001 From: Boss-1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:40:15 +0000 Subject: [PATCH 05/27] export cloud server types (cherry picked from commit 8d30697d3da04382e2a42db868135f2a69e467e7) Signed-off-by: GitHub --- .gitignore | 9 ++++++++ scratchattach/__init__.py | 48 ++++++++++++++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 0fcda0ef..b407c3f9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,12 @@ identifier.sqlite obfuscated.sb3 .pytest_cache .ruff_cache + +.coverage +certfile.pem +coverage.xml +keyfile.pem +.test/test.py +.test/teststststs.py +.test/ws_client_test.py +tests/test_tw_cloud_debug_compliance.py diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index 94805f83..aa5c74b7 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -1,15 +1,33 @@ -from .cloud.cloud import CustomCloud, ScratchCloud, TwCloud, get_cloud, get_scratch_cloud, get_tw_cloud +from .cloud.cloud import ( + CustomCloud, + ScratchCloud, + TwCloud, + get_cloud, + get_scratch_cloud, + get_tw_cloud, +) from .cloud._base import BaseCloud, AnyCloud -from .eventhandlers.cloud_server import init_cloud_server, init_ssl_cloud_server +from .eventhandlers.cloud_server import ( + init_cloud_server, + init_ssl_cloud_server, + TwCloudSocket, + TwCloudServer, + TwSSLCloudServer, +) from .eventhandlers._base import BaseEventHandler from .eventhandlers.filterbot import Filterbot, HardFilter, SoftFilter, SpamFilter from .eventhandlers.cloud_storage import Database from .eventhandlers.combine import MultiEventHandler from .other.other_apis import * - -# from .other.project_json_capabilities import ProjectBody, get_empty_project_pb, get_pb_from_dict, read_sb3_file, download_asset +# from .other.project_json_capabilities import ( +# ProjectBody, +# get_empty_project_pb, +# get_pb_from_dict, +# read_sb3_file, +# download_asset, +# ) from .utils.encoder import Encoding from .utils.enums import Languages, TTSVoices from .utils.exceptions import ( @@ -27,11 +45,29 @@ from .site.cloud_activity import CloudActivity from .site.forum import ForumPost, ForumTopic, get_topic, get_topic_list, youtube_link_to_scratch from .site.project import Project, get_project, search_projects, explore_projects -from .site.session import Session, login, login_by_id, login_by_session_string, login_by_io, login_by_file, login_from_browser +from .site.session import ( + Session, + login, + login_by_id, + login_by_session_string, + login_by_io, + login_by_file, + login_from_browser, +) from .site.studio import Studio, get_studio, search_studios, explore_studios from .site.classroom import Classroom, get_classroom from .site.user import User, get_user, Rank from .site._base import BaseSiteComponent -from .site.browser_cookies import Browser, ANY, FIREFOX, CHROME, CHROMIUM, VIVALDI, EDGE, EDGE_DEV, SAFARI +from .site.browser_cookies import ( + Browser, + ANY, + FIREFOX, + CHROME, + CHROMIUM, + VIVALDI, + EDGE, + EDGE_DEV, + SAFARI, +) from . import editor From a1423ceb9b921e1ce899e814cd1e3d91b907bc77 Mon Sep 17 00:00:00 2001 From: Boss-1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:04:12 +0000 Subject: [PATCH 06/27] fix(sa.eventhandlers): clean up ### sa.eventhandlers._base - changed list comp to generator comp in set_project_vars() and set_var() - reimplemented attribute type hints into BaseCloudServer. since type hints are inhierted, there is no need to restate them in child classes of BaseCloudServer. ### sa.eventhandlers.cloud_server - added type hitns to __init__ of BaseCloudServer child classes - added type hints to init_cloud_server and init_ssl_cloud_server - revert changing warnings.warn to print in 9863d50 Signed-off-by: GitHub --- scratchattach/eventhandlers/_base.py | 18 +++- scratchattach/eventhandlers/cloud_server.py | 92 +++++++++++---------- 2 files changed, 62 insertions(+), 48 deletions(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 79a59edb..c20a7cae 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -129,6 +129,18 @@ def inner(function): inner(function) class BaseCloudServer(BaseEventHandler): + hostname: str + port: int + tw_clients: dict[tuple[str, int], dict[str, Any]] + tw_variables: dict[str, dict[str, Any]] + allow_non_numeric: bool + whitelisted_projects: Optional[list[str]] + length_limit: Optional[int] + allow_nonscratch_names: bool + blocked_ips: list[str] + sync_players: bool + log_var_sets: bool + def __init__(self, hostname: str, *, @@ -137,7 +149,7 @@ def __init__(self, ssl_version: int = ssl.PROTOCOL_TLSv1_2, ssl_context: ssl.SSLContext|None = None, port: int, - websocketclass: WebSocket, + websocketclass: type[WebSocket], length_limit: int|None = None, allow_non_numeric: bool = True, whitelisted_projects: list[Any]|None = None, @@ -219,7 +231,7 @@ def set_global_vars(self, data): def set_project_vars(self, project_id, data, *, user="@server"): project_id = str(project_id) self.tw_variables[project_id] = data - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + for client in (self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)): client.sendMessage( "\n".join( [ @@ -247,7 +259,7 @@ def set_var(self, project_id, var_name, value, *, user="@server", skip_forward=N self.tw_variables[project_id][var_name] = value if self.sync_players is True: - for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]: + for client in (self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)): if client == skip_forward: continue client.sendMessage( diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 7c4646b0..ef98696c 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -4,6 +4,8 @@ import time import ssl import traceback +from typing import Any +import warnings from SimpleWebSocketServer import SimpleSSLWebSocketServer, SimpleWebSocketServer, WebSocket @@ -185,17 +187,17 @@ def handleClose(self): class TwCloudServer(BaseCloudServer, SimpleWebSocketServer): def __init__( self, - hostname, + hostname: str, *, - port, - websocketclass, - length_limit=None, - allow_non_numeric=True, - whitelisted_projects=None, - allow_nonscratch_names=True, - blocked_ips=None, - sync_players=True, - log_var_sets=True, + port: int, + websocketclass: type[WebSocket], + length_limit: int | None = None, + allow_non_numeric: bool = True, + whitelisted_projects: list[Any] | None = None, + allow_nonscratch_names: bool = True, + blocked_ips: list[str] | None = None, + sync_players: bool = True, + log_var_sets: bool = True, ): if blocked_ips is None: blocked_ips = [] @@ -219,19 +221,19 @@ def __init__( self, hostname: str, *, - certfile=None, - keyfile=None, - ssl_version=ssl.PROTOCOL_TLSv1_2, - ssl_context=None, - port, - websocketclass, - length_limit=None, - allow_non_numeric=True, - whitelisted_projects=None, - allow_nonscratch_names=True, - blocked_ips=None, - sync_players=True, - log_var_sets= True + certfile: str | None = None, + keyfile: str | None = None, + ssl_version: int = ssl.PROTOCOL_TLSv1_2, + ssl_context: ssl.SSLContext | None = None, + port: int, + websocketclass: type[WebSocket], + length_limit: int | None = None, + allow_non_numeric: bool = True, + whitelisted_projects: list[Any] | None = None, + allow_nonscratch_names: bool = True, + blocked_ips: list[str] | None = None, + sync_players: bool = True, + log_var_sets: bool = True ): SimpleSSLWebSocketServer.__init__( self, @@ -265,16 +267,16 @@ def _updater(self): raise exceptions.WebsocketServerError(str(e)) def init_cloud_server( - hostname="127.0.0.1", - port=8080, + hostname: str="127.0.0.1", + port: int=8080, *, - length_limit=None, - allow_non_numeric=True, - whitelisted_projects=None, - allow_nonscratch_names=True, - blocked_ips=None, - sync_players=True, - log_var_sets=True, + length_limit: int | None = None, + allow_non_numeric: bool = True, + whitelisted_projects: list[Any] | None = None, + allow_nonscratch_names: bool = True, + blocked_ips: list[str] | None = None, + sync_players: bool = True, + log_var_sets: bool = True, ): """ Inits a websocket server which can be used with TurboWarp's ?cloud_host URL parameter. @@ -301,17 +303,17 @@ def init_ssl_cloud_server( hostname: str = "127.0.0.1", port: int = 8080, *, - certfile=None, - keyfile=None, - ssl_version=ssl.PROTOCOL_TLSv1_2, - ssl_context=None, - length_limit=None, - allow_non_numeric=True, - whitelisted_projects=None, - allow_nonscratch_names=True, - blocked_ips=None, - sync_players=True, - log_var_sets=True + certfile: str | None = None, + keyfile: str | None = None, + ssl_version: int = ssl.PROTOCOL_TLSv1_2, + ssl_context: ssl.SSLContext | None = None, + length_limit: int | None = None, + allow_non_numeric: bool = True, + whitelisted_projects: list[Any] | None = None, + allow_nonscratch_names: bool = True, + blocked_ips: list[str] | None = None, + sync_players: bool = True, + log_var_sets: bool = True ) -> TwSSLCloudServer: """ Inits a websocket server which can be used with TurboWarp's ?cloud_host URL parameter. @@ -319,8 +321,8 @@ def init_ssl_cloud_server( Prints out the websocket address in the console. """ if (certfile is None or keyfile is None) and ssl_context is None: - print("[yellow]WARNING: To init a ssl cloud server, you need provide `certfile` and "+ - "`keyfile` or `ssl_context`.[/]") + warnings.warn("WARNING: To init a ssl cloud server, you need provide `certfile` and "+ + "`keyfile` or `ssl_context`.") return TwSSLCloudServer( hostname, From 8d5aa20674f0e99fdb237939fd62969a9767cfd0 Mon Sep 17 00:00:00 2001 From: Boss-1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:08:30 +0000 Subject: [PATCH 07/27] fix(sa.eventhandlers._base): ensure blocked_ips is always some list Signed-off-by: GitHub --- scratchattach/eventhandlers/_base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index c20a7cae..f7da1bdf 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -158,6 +158,9 @@ def __init__(self, sync_players: bool = True, log_var_sets: bool = True): + if blocked_ips is None: + blocked_ips = [] + BaseEventHandler.__init__(self) self.running = False From 70ffb368d0a9b663f1213b24beda69dfb115799c Mon Sep 17 00:00:00 2001 From: Boss-1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:30:43 +0000 Subject: [PATCH 08/27] feat: expose BaseCloudServer Signed-off-by: GitHub --- scratchattach/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scratchattach/__init__.py b/scratchattach/__init__.py index aa5c74b7..acdcc8ec 100644 --- a/scratchattach/__init__.py +++ b/scratchattach/__init__.py @@ -15,7 +15,7 @@ TwCloudServer, TwSSLCloudServer, ) -from .eventhandlers._base import BaseEventHandler +from .eventhandlers._base import BaseEventHandler, BaseCloudServer from .eventhandlers.filterbot import Filterbot, HardFilter, SoftFilter, SpamFilter from .eventhandlers.cloud_storage import Database from .eventhandlers.combine import MultiEventHandler From 92cc74819f58c79304fad18a122590b9fc3f9ed3 Mon Sep 17 00:00:00 2001 From: Boss-1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:36:47 +0000 Subject: [PATCH 09/27] fatal: BaseCloudServer should not have ssl-related stuff Signed-off-by: GitHub --- scratchattach/eventhandlers/_base.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index f7da1bdf..38a1a9d5 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -144,10 +144,6 @@ class BaseCloudServer(BaseEventHandler): def __init__(self, hostname: str, *, - certfile: str|None = None, - keyfile: str|None = None, - ssl_version: int = ssl.PROTOCOL_TLSv1_2, - ssl_context: ssl.SSLContext|None = None, port: int, websocketclass: type[WebSocket], length_limit: int|None = None, From 1924636287894eb63448e20de74f641defa1cbb1 Mon Sep 17 00:00:00 2001 From: Boss-1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:37:39 +0000 Subject: [PATCH 10/27] docstrings Signed-off-by: GitHub --- scratchattach/eventhandlers/_base.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 38a1a9d5..351dbf54 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -129,15 +129,31 @@ def inner(function): inner(function) class BaseCloudServer(BaseEventHandler): + """ + Base class for all sa cloud servers. + + If you are developing a custom cloud server with sa, please inherit from this class + and change up the methods as needed. + """ + hostname: str + "IP address or domain name of the host to bind to." port: int + "Port to bind to." tw_clients: dict[tuple[str, int], dict[str, Any]] + "Dict of client information." tw_variables: dict[str, dict[str, Any]] + "Dict of existing cloud variables." allow_non_numeric: bool - whitelisted_projects: Optional[list[str]] - length_limit: Optional[int] + "Whether or not non-numeric charecters are allowed in cloud variable values." + whitelisted_projects: list[str] | None + "Optional list of whitelisted projects." + length_limit: int | None + "Optional limit on the length of cloud variable values." allow_nonscratch_names: bool + "Whether or not usernames that do not exist on scratch are allowed." blocked_ips: list[str] + "List of blocked IP addresses." sync_players: bool log_var_sets: bool From 2a5dfc97900b6cc02979b76dc79782a1af099510 Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:00:56 -0500 Subject: [PATCH 11/27] resolve https://github.com/TimMcCool/scratchattach/pull/723#discussion_r3919298084 Co-authored-by: TheCommCraft <79996518+TheCommCraft@users.noreply.github.com> Signed-off-by: Boss_1s <95505913+Boss-1s@users.noreply.github.com> --- scratchattach/eventhandlers/cloud_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index ef98696c..12c4bf53 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -321,7 +321,7 @@ def init_ssl_cloud_server( Prints out the websocket address in the console. """ if (certfile is None or keyfile is None) and ssl_context is None: - warnings.warn("WARNING: To init a ssl cloud server, you need provide `certfile` and "+ + warnings.warn("To init a ssl cloud server, you need provide `certfile` and "+ "`keyfile` or `ssl_context`.") return TwSSLCloudServer( From 5b0ff2260a5083cb6cd063bb08fe792391aef23f Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:04:10 -0500 Subject: [PATCH 12/27] resolve Signed-off-by: Boss_1s <95505913+Boss-1s@users.noreply.github.com> --- scratchattach/eventhandlers/_base.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 351dbf54..07c56f31 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -137,15 +137,15 @@ class BaseCloudServer(BaseEventHandler): """ hostname: str - "IP address or domain name of the host to bind to." + "IP address or domain name of the host to bind the server to." port: int - "Port to bind to." + "Port to bind the server to." tw_clients: dict[tuple[str, int], dict[str, Any]] - "Dict of client information." + "Dictionary containing client information." tw_variables: dict[str, dict[str, Any]] - "Dict of existing cloud variables." + "Dictionary containing existing cloud variables." allow_non_numeric: bool - "Whether or not non-numeric charecters are allowed in cloud variable values." + "Whether or not non-numeric characters are allowed in cloud variable values." whitelisted_projects: list[str] | None "Optional list of whitelisted projects." length_limit: int | None @@ -162,13 +162,14 @@ def __init__(self, *, port: int, websocketclass: type[WebSocket], - length_limit: int|None = None, + length_limit: int | None = None, allow_non_numeric: bool = True, - whitelisted_projects: list[Any]|None = None, + whitelisted_projects: list[Any] | None = None, allow_nonscratch_names: bool = True, - blocked_ips: list[str]|None = None, + blocked_ips: list[str] | None = None, sync_players: bool = True, - log_var_sets: bool = True): + log_var_sets: bool = True + ): if blocked_ips is None: blocked_ips = [] From b80c260531e8f01d48147444f31d68501ef8f457 Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:05:34 -0500 Subject: [PATCH 13/27] revert Signed-off-by: Boss_1s <95505913+Boss-1s@users.noreply.github.com> --- .gitignore | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.gitignore b/.gitignore index b407c3f9..0fcda0ef 100644 --- a/.gitignore +++ b/.gitignore @@ -14,12 +14,3 @@ identifier.sqlite obfuscated.sb3 .pytest_cache .ruff_cache - -.coverage -certfile.pem -coverage.xml -keyfile.pem -.test/test.py -.test/teststststs.py -.test/ws_client_test.py -tests/test_tw_cloud_debug_compliance.py From f81bce7aff3877197d0f41ccc51e48a78fc5cf02 Mon Sep 17 00:00:00 2001 From: TheCommCraft <79996518+TheCommCraft@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:36:03 +0200 Subject: [PATCH 14/27] apply changes --- scratchattach/eventhandlers/_base.py | 114 ++++++++----- scratchattach/eventhandlers/cloud_server.py | 169 ++++++++++++-------- 2 files changed, 171 insertions(+), 112 deletions(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 07c56f31..2ad44c9a 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -15,6 +15,7 @@ from scratchattach.utils.requests import requests from scratchattach.utils import exceptions + class BaseEventHandler(ABC): _events: defaultdict[str, list[Callable]] _threaded_events: defaultdict[str, list[Callable]] @@ -48,7 +49,7 @@ def start(self, *, thread=True, ignore_exceptions=True): self._thread = None self._updater() - def call_event(self, event_name, args : list = []): + def call_event(self, event_name, args: list = []): try: # print(f"Calling for {event_name}...") if event_name in self._threaded_events: @@ -62,15 +63,13 @@ def call_event(self, event_name, args : list = []): func(*args) except Exception as e: if self.ignore_exceptions: - print( - f"Warning: Caught error in event '{event_name}' - Full error below" - ) + print(f"Warning: Caught error in event '{event_name}' - Full error below") try: traceback.print_exc() except Exception: print(e) else: - raise(e) + raise (e) @abstractmethod def _updater(self): @@ -114,6 +113,7 @@ def event(self, function=None, *, thread=False): """ Decorator function. Adds an event. """ + def inner(function): # called directly if the decorator provides arguments if thread is True: @@ -128,6 +128,7 @@ def inner(function): # => the decorator doesn't provide arguments inner(function) + class BaseCloudServer(BaseEventHandler): """ Base class for all sa cloud servers. @@ -146,7 +147,7 @@ class BaseCloudServer(BaseEventHandler): "Dictionary containing existing cloud variables." allow_non_numeric: bool "Whether or not non-numeric characters are allowed in cloud variable values." - whitelisted_projects: list[str] | None + whitelisted_projects: set[str] | None "Optional list of whitelisted projects." length_limit: int | None "Optional limit on the length of cloud variable values." @@ -157,18 +158,18 @@ class BaseCloudServer(BaseEventHandler): sync_players: bool log_var_sets: bool - def __init__(self, - hostname: str, - *, - port: int, - websocketclass: type[WebSocket], - length_limit: int | None = None, - allow_non_numeric: bool = True, - whitelisted_projects: list[Any] | None = None, - allow_nonscratch_names: bool = True, - blocked_ips: list[str] | None = None, - sync_players: bool = True, - log_var_sets: bool = True + def __init__( + self, + hostname: str, + *, + port: int, + length_limit: int | None = None, + allow_non_numeric: bool = True, + whitelisted_projects: list[Any] | None = None, + allow_nonscratch_names: bool = True, + blocked_ips: list[str] | None = None, + sync_players: bool = True, + log_var_sets: bool = True, ): if blocked_ips is None: @@ -176,9 +177,6 @@ def __init__(self, BaseEventHandler.__init__(self) - self.running = False - self._events = {} # saves event functions called on cloud updates - self.tw_clients = {} # saves connected clients self.tw_variables = {} # holds cloud variable states @@ -187,7 +185,9 @@ def __init__(self, # server config self.allow_non_numeric = allow_non_numeric - self.whitelisted_projects = whitelisted_projects + self.whitelisted_projects = ( + {str(i) for i in whitelisted_projects} if whitelisted_projects else None + ) self.length_limit = length_limit self.allow_nonscratch_names = allow_nonscratch_names self.blocked_ips = blocked_ips @@ -216,22 +216,25 @@ def active_projects(self): def active_user_names(self, project_id): return [self.tw_clients[user]["username"] for user in self.active_user_ips(project_id)] - def active_user_ips(self, project_id): - return list(filter(lambda user: str(self.tw_clients[user]["project_id"]) == str(project_id), self.tw_clients)) + def active_user_ips(self, project_id: Any): + project_id = str(project_id) + return [ + user + for user in self.tw_clients + if str(self.tw_clients[user]["project_id"]) == project_id + ] def get_global_vars(self): return self.tw_variables - def get_project_vars(self, project_id): + def get_project_vars(self, project_id: Any): project_id = str(project_id) - if project_id in self.tw_variables: - return self.tw_variables[project_id] - else: - return {} + return self.tw_variables.get(project_id, {}) - def get_var(self, project_id, var_name): + def get_var(self, project_id: Any, var_name: str, *, no_prefix: bool = False): project_id = str(project_id) - var_name = var_name.replace("☁ ", "") + if not no_prefix: + var_name = "☁ " + var_name.removeprefix("☁ ") if project_id in self.tw_variables: if var_name in self.tw_variables[project_id]: return self.tw_variables[project_id][var_name] @@ -240,13 +243,26 @@ def get_var(self, project_id, var_name): else: return None - def set_global_vars(self, data): - for project_id in data: - self.set_project_vars(project_id, data[project_id]) - - def set_project_vars(self, project_id, data, *, user="@server"): + def set_global_vars( + self, + data: dict[str, dict[str, Any]], + no_prefix: bool = False, + ): + for project_id, project_data in data.items(): + self.set_project_vars(project_id, project_data, no_prefix=no_prefix) + + def set_project_vars( + self, + project_id: Any, + data: dict[str, Any], + *, + user: str = "@server", + no_prefix: bool = False, + ): project_id = str(project_id) - self.tw_variables[project_id] = data + if not no_prefix: + data = {"☁ " + key.removeprefix("☁ "): value for key, value in data.items()} + self.tw_variables[project_id].update(data) for client in (self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)): client.sendMessage( "\n".join( @@ -255,9 +271,9 @@ def set_project_vars(self, project_id, data, *, user="@server"): { "method": "set", "project_id": project_id, - "name": "☁ " + varname, + "name": varname, "value": data[varname], - "server": "scratchattach/2.0.0", + "server": "scratchattach/3", "timestamp": time.time() * 1000, "user": user, } @@ -267,15 +283,27 @@ def set_project_vars(self, project_id, data, *, user="@server"): ) ) - def set_var(self, project_id, var_name, value, *, user="@server", skip_forward=None): - var_name = var_name.replace("☁ ", "") + def set_var( + self, + project_id: Any, + var_name: str, + value: Any, + *, + user: str = "@server", + skip_forward=None, + no_prefix: bool = False, + ): + if not no_prefix: + var_name = "☁ " + var_name.removeprefix("☁ ") project_id = str(project_id) if project_id not in self.tw_variables: self.tw_variables[project_id] = {} self.tw_variables[project_id][var_name] = value if self.sync_players is True: - for client in (self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)): + for client in ( + self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id) + ): if client == skip_forward: continue client.sendMessage( @@ -283,7 +311,7 @@ def set_var(self, project_id, var_name, value, *, user="@server", skip_forward=N { "method": "set", "project_id": project_id, - "name": "☁ " + var_name, + "name": var_name, "value": value, "timestamp": time.time() * 1000, "user": user, diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 12c4bf53..28528715 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -1,4 +1,5 @@ from __future__ import annotations +from scratchattach.site.typed_dicts import CloudActivityDict import json import time @@ -14,24 +15,27 @@ from scratchattach.site.user import User from ._base import BaseCloudServer + class TwCloudSocket(WebSocket): server: TwCloudServer def handle_set(self, data: dict): # cloud variable set received # check if project_id is in whitelisted projects (if there's a list of whitelisted projects) - if self.server.whitelisted_projects is not None: - if data["project_id"] not in self.server.whitelisted_projects: - self.close(4002) - if self.server.log_var_sets: - print( - self.address[0] + ":" + str(self.address[1]), - "tried to set a var on non-whitelisted project and was disconnected, project:", - data["project_id"], - "user:", - data["user"], - ) - return + if ( + self.server.whitelisted_projects is not None + and str(data["project_id"]) not in self.server.whitelisted_projects + ): + self.close(4002) + if self.server.log_var_sets: + print( + self.address[0] + ":" + str(self.address[1]), + "tried to set a var on non-whitelisted project and was disconnected, project:", + data["project_id"], + "user:", + data["user"], + ) + return # check if value is valid if not self.server._check_value(data["value"]): if self.server.log_var_sets: @@ -46,8 +50,15 @@ def handle_set(self, data: dict): "user:", data["user"], ) - self.server.set_var(data["project_id"], data["name"], data["value"], user=data["user"], skip_forward=self) - send_to_clients = { + self.server.set_var( + data["project_id"], + data["name"], + data["value"], + user=data["user"], + skip_forward=self, + no_prefix=True, + ) + send_to_clients: CloudActivityDict = { "method": "set", "user": data["user"], "project_id": data["project_id"], @@ -56,46 +67,53 @@ def handle_set(self, data: dict): "timestamp": round(time.time() * 1000), "server": "scratchattach/2.0.0", } + # TODO: Add a cloud to the activity dict (possibly some kind of adapter) # raise event _a = cloud_activity.CloudActivity(timestamp=time.time() * 1000) - data["name"] = data["name"].replace("☁ ", "") _a._update_from_dict(send_to_clients) self.server.call_event("on_set", [_a, self]) def handle_handshake(self, data: dict): # check if handshake is valid if not "user" in data: - print(self.address[0] + ":" + str(self.address[1]), "tried to handshake without providing a username") + print( + self.address[0] + ":" + str(self.address[1]), + "tried to handshake without providing a username", + ) self.close(4002) return if not "project_id" in data: - print(self.address[0] + ":" + str(self.address[1]), "tried to handshake without providing a project_id") + print( + self.address[0] + ":" + str(self.address[1]), + "tried to handshake without providing a project_id", + ) self.close(4002) return # check if project_id is in username is allowed - if self.server.allow_nonscratch_names is False: - if not User(username=data["user"]).does_exist(): - print( - self.address[0] + ":" + str(self.address[1]), - "tried to handshake using a username not existing on Scratch, project:", - data["project_id"], - "user:", - data["user"], - ) - self.close(4002) - return + if not self.server.allow_nonscratch_names and not User(username=data["user"]).does_exist(): + print( + self.address[0] + ":" + str(self.address[1]), + "tried to handshake using a username not existing on Scratch, project:", + data["project_id"], + "user:", + data["user"], + ) + self.close(4002) + return # check if project_id is in whitelisted projects (if there's a list of whitelisted projects) - if self.server.whitelisted_projects is not None: - if str(data["project_id"]) not in self.server.whitelisted_projects: - self.close(4002) - print( - self.address[0] + ":" + str(self.address[1]), - "tried to handshake on a non-whitelisted project:", - data["project_id"], - "user:", - data["user"], - ) - return + if ( + self.server.whitelisted_projects is not None + and str(data["project_id"]) not in self.server.whitelisted_projects + ): + self.close(4002) + print( + self.address[0] + ":" + str(self.address[1]), + "tried to handshake on a non-whitelisted project:", + data["project_id"], + "user:", + data["user"], + ) + return # register handshake in users list (save username and project_id) print( self.address[0] + ":" + str(self.address[1]), @@ -159,7 +177,11 @@ def handleConnected(self): return print(self.address[0] + ":" + str(self.address[1]), "connected") - self.server.tw_clients[self.address] = {"client": self, "username": None, "project_id": None} + self.server.tw_clients[self.address] = { + "client": self, + "username": None, + "project_id": None, + } # raise event self.server.call_event("on_connect", [self]) except Exception as e: @@ -204,17 +226,20 @@ def __init__( SimpleWebSocketServer.__init__(self, hostname, port=port, websocketclass=websocketclass) - BaseCloudServer.__init__(self, - hostname=hostname, - port=port, - websocketclass=websocketclass, - length_limit=length_limit, - allow_non_numeric=allow_non_numeric, - whitelisted_projects=whitelisted_projects, - allow_nonscratch_names=allow_nonscratch_names, - blocked_ips=blocked_ips, - sync_players=sync_players, - log_var_sets=log_var_sets) + BaseCloudServer.__init__( + self, + hostname=hostname, + port=port, + websocketclass=websocketclass, + length_limit=length_limit, + allow_non_numeric=allow_non_numeric, + whitelisted_projects=whitelisted_projects, + allow_nonscratch_names=allow_nonscratch_names, + blocked_ips=blocked_ips, + sync_players=sync_players, + log_var_sets=log_var_sets, + ) + class TwSSLCloudServer(BaseCloudServer, SimpleSSLWebSocketServer): def __init__( @@ -233,7 +258,7 @@ def __init__( allow_nonscratch_names: bool = True, blocked_ips: list[str] | None = None, sync_players: bool = True, - log_var_sets: bool = True + log_var_sets: bool = True, ): SimpleSSLWebSocketServer.__init__( self, @@ -246,17 +271,19 @@ def __init__( ssl_context=ssl_context, ) - BaseCloudServer.__init__(self, - hostname=hostname, - port=port, - websocketclass=websocketclass, - length_limit=length_limit, - allow_non_numeric=allow_non_numeric, - whitelisted_projects=whitelisted_projects, - allow_nonscratch_names=allow_nonscratch_names, - blocked_ips=blocked_ips, - sync_players=sync_players, - log_var_sets=log_var_sets) + BaseCloudServer.__init__( + self, + hostname=hostname, + port=port, + websocketclass=websocketclass, + length_limit=length_limit, + allow_non_numeric=allow_non_numeric, + whitelisted_projects=whitelisted_projects, + allow_nonscratch_names=allow_nonscratch_names, + blocked_ips=blocked_ips, + sync_players=sync_players, + log_var_sets=log_var_sets, + ) def _updater(self): try: @@ -266,9 +293,10 @@ def _updater(self): except Exception as e: raise exceptions.WebsocketServerError(str(e)) + def init_cloud_server( - hostname: str="127.0.0.1", - port: int=8080, + hostname: str = "127.0.0.1", + port: int = 8080, *, length_limit: int | None = None, allow_non_numeric: bool = True, @@ -299,6 +327,7 @@ def init_cloud_server( log_var_sets=log_var_sets, ) + def init_ssl_cloud_server( hostname: str = "127.0.0.1", port: int = 8080, @@ -313,7 +342,7 @@ def init_ssl_cloud_server( allow_nonscratch_names: bool = True, blocked_ips: list[str] | None = None, sync_players: bool = True, - log_var_sets: bool = True + log_var_sets: bool = True, ) -> TwSSLCloudServer: """ Inits a websocket server which can be used with TurboWarp's ?cloud_host URL parameter. @@ -321,8 +350,10 @@ def init_ssl_cloud_server( Prints out the websocket address in the console. """ if (certfile is None or keyfile is None) and ssl_context is None: - warnings.warn("To init a ssl cloud server, you need provide `certfile` and "+ - "`keyfile` or `ssl_context`.") + warnings.warn( + "To init a ssl cloud server, you need provide `certfile` and " + + "`keyfile` or `ssl_context`." + ) return TwSSLCloudServer( hostname, @@ -338,5 +369,5 @@ def init_ssl_cloud_server( allow_nonscratch_names=allow_nonscratch_names, blocked_ips=blocked_ips, sync_players=sync_players, - log_var_sets=log_var_sets + log_var_sets=log_var_sets, ) From e905eb82db84e6e16bbb7f478b697183344bf27e Mon Sep 17 00:00:00 2001 From: Boss-1s <95505913+Boss-1s@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:21:15 +0000 Subject: [PATCH 15/27] . Signed-off-by: GitHub --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index 0fcda0ef..b407c3f9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,12 @@ identifier.sqlite obfuscated.sb3 .pytest_cache .ruff_cache + +.coverage +certfile.pem +coverage.xml +keyfile.pem +.test/test.py +.test/teststststs.py +.test/ws_client_test.py +tests/test_tw_cloud_debug_compliance.py From 16b58b769341f98185ed936472ef339543f8e27d Mon Sep 17 00:00:00 2001 From: Boss-1s <95505913+Boss-1s@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:22:17 +0000 Subject: [PATCH 16/27] replace all references to scratchattach/2.0.0 Signed-off-by: GitHub --- scratchattach/eventhandlers/cloud_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 28528715..55e5c373 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -65,7 +65,7 @@ def handle_set(self, data: dict): "name": data["name"], "value": data["value"], "timestamp": round(time.time() * 1000), - "server": "scratchattach/2.0.0", + "server": "scratchattach/3", } # TODO: Add a cloud to the activity dict (possibly some kind of adapter) # raise event @@ -134,7 +134,7 @@ def handle_handshake(self, data: dict): "project_id": data["project_id"], "name": "☁ " + varname, "value": self.server.tw_variables[str(data["project_id"])][varname], - "server": "scratchattach/2.0.0", + "server": "scratchattach/3", } ) for varname in self.server.get_project_vars(str(data["project_id"])) From 624319082570e8462a9b87393b1374bca7785c51 Mon Sep 17 00:00:00 2001 From: Boss-1s <95505913+Boss-1s@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:25:10 +0000 Subject: [PATCH 17/27] fatal: stale assignment to nonexistent arg websocketclass Signed-off-by: GitHub --- scratchattach/eventhandlers/cloud_server.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 55e5c373..583164e0 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -230,7 +230,6 @@ def __init__( self, hostname=hostname, port=port, - websocketclass=websocketclass, length_limit=length_limit, allow_non_numeric=allow_non_numeric, whitelisted_projects=whitelisted_projects, @@ -275,7 +274,6 @@ def __init__( self, hostname=hostname, port=port, - websocketclass=websocketclass, length_limit=length_limit, allow_non_numeric=allow_non_numeric, whitelisted_projects=whitelisted_projects, From 65a4ddd781f464b734ec44a1482a485e931cfc2c Mon Sep 17 00:00:00 2001 From: TheCommCraft <79996518+TheCommCraft@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:23:33 +0200 Subject: [PATCH 18/27] rename `skip_forward` and annotate type --- scratchattach/eventhandlers/_base.py | 4 ++-- scratchattach/eventhandlers/cloud_events.py | 2 +- scratchattach/eventhandlers/cloud_server.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 2ad44c9a..4ca931bd 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -290,7 +290,7 @@ def set_var( value: Any, *, user: str = "@server", - skip_forward=None, + skip_broadcast_for: WebSocket | None = None, no_prefix: bool = False, ): if not no_prefix: @@ -304,7 +304,7 @@ def set_var( for client in ( self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id) ): - if client == skip_forward: + if client == skip_broadcast_for: continue client.sendMessage( json.dumps( diff --git a/scratchattach/eventhandlers/cloud_events.py b/scratchattach/eventhandlers/cloud_events.py index 64b3281a..3d89e64c 100644 --- a/scratchattach/eventhandlers/cloud_events.py +++ b/scratchattach/eventhandlers/cloud_events.py @@ -63,7 +63,7 @@ def _updater(self): # continue cloud_activity_dict = cast(CloudActivityDict, data) cloud_activity_dict["variable_name"] = cloud_activity_dict["name"] - cloud_activity_dict["name"] = cloud_activity_dict["variable_name"].replace("☁ ", "") + cloud_activity_dict["name"] = cloud_activity_dict["variable_name"].removeprefix("☁ ") _a._update_from_dict(cloud_activity_dict) # print(f"sending event {_a}") self.call_event(f"on_{_a.type}", [_a]) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index 583164e0..e000f525 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -55,7 +55,7 @@ def handle_set(self, data: dict): data["name"], data["value"], user=data["user"], - skip_forward=self, + skip_broadcast_for=self, no_prefix=True, ) send_to_clients: CloudActivityDict = { @@ -64,7 +64,7 @@ def handle_set(self, data: dict): "project_id": data["project_id"], "name": data["name"], "value": data["value"], - "timestamp": round(time.time() * 1000), + "timestamp": round(number=time.time() * 1000), "server": "scratchattach/3", } # TODO: Add a cloud to the activity dict (possibly some kind of adapter) @@ -132,7 +132,7 @@ def handle_handshake(self, data: dict): { "method": "set", "project_id": data["project_id"], - "name": "☁ " + varname, + "name": varname, "value": self.server.tw_variables[str(data["project_id"])][varname], "server": "scratchattach/3", } From b77df0047272eb15ed1e458ef2ed368ad310f4ec Mon Sep 17 00:00:00 2001 From: TheCommCraft <79996518+TheCommCraft@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:12:56 +0200 Subject: [PATCH 19/27] use `serveonce` instead of `serveforever` --- scratchattach/eventhandlers/_base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 4ca931bd..fa36cb2b 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -335,7 +335,8 @@ def _updater(self): try: # Function called when .start() is executed (.start is inherited from BaseEventHandler) print(f"Serving websocket server: ws://{self.hostname}:{self.port}") - self.serveforever() + while self.running: + self.serveonce() except Exception as e: raise exceptions.WebsocketServerError(str(e)) From 3b968885310a53f595ed92fc7940cbf08bebb972 Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:47:04 -0500 Subject: [PATCH 20/27] prevent 100% CPU usage Signed-off-by: Boss_1s <95505913+Boss-1s@users.noreply.github.com> --- scratchattach/eventhandlers/_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index fa36cb2b..8c94689e 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -337,6 +337,7 @@ def _updater(self): print(f"Serving websocket server: ws://{self.hostname}:{self.port}") while self.running: self.serveonce() + time.sleep(0.01) except Exception as e: raise exceptions.WebsocketServerError(str(e)) From 1b34b7cc15531fca4e2d10d035a3f8a9359f859c Mon Sep 17 00:00:00 2001 From: Boss_1s <95505913+Boss-1s@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:32:43 -0500 Subject: [PATCH 21/27] revert 3b96888 Co-authored-by: TheCommCraft <79996518+TheCommCraft@users.noreply.github.com> Signed-off-by: Boss_1s <95505913+Boss-1s@users.noreply.github.com> --- scratchattach/eventhandlers/_base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 8c94689e..fa36cb2b 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -337,7 +337,6 @@ def _updater(self): print(f"Serving websocket server: ws://{self.hostname}:{self.port}") while self.running: self.serveonce() - time.sleep(0.01) except Exception as e: raise exceptions.WebsocketServerError(str(e)) From 14d1074920ea5b9998d8a5c88b372c7e7be06da1 Mon Sep 17 00:00:00 2001 From: Boss-1s <95505913+Boss-1s@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:15:31 +0000 Subject: [PATCH 22/27] server can be TwCloudServer or TwSSLCloudServer Signed-off-by: GitHub --- scratchattach/eventhandlers/cloud_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index e000f525..d97045bf 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -17,7 +17,7 @@ class TwCloudSocket(WebSocket): - server: TwCloudServer + server: TwCloudServer | TwSSLCloudServer def handle_set(self, data: dict): # cloud variable set received From 0f921da64adcdd6fdc00d2ecafe78e574b81cf70 Mon Sep 17 00:00:00 2001 From: Boss-1s <95505913+Boss-1s@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:19:51 +0000 Subject: [PATCH 23/27] remove invalid cloudactivitydict values + temp fill-in for cloud key Signed-off-by: GitHub --- scratchattach/eventhandlers/cloud_server.py | 30 ++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index d97045bf..fe052635 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -13,6 +13,7 @@ from scratchattach.utils import exceptions from scratchattach.site import cloud_activity from scratchattach.site.user import User +from scratchattach.cloud.cloud import CustomCloud from ._base import BaseCloudServer @@ -60,14 +61,35 @@ def handle_set(self, data: dict): ) send_to_clients: CloudActivityDict = { "method": "set", - "user": data["user"], "project_id": data["project_id"], "name": data["name"], "value": data["value"], - "timestamp": round(number=time.time() * 1000), - "server": "scratchattach/3", + # TODO: Add a cloud to the activity dict (possibly some kind of adapter) + # NOTE: this is just a temporary fill-in + "cloud": CustomCloud( + project_id=data["project_id"], + cloud_host=f"ws://{self.server.hostname}:{self.server.port}", + username=data["user"], + length_limit=self.server.length_limit, + allow_non_numeric=self.server.allow_non_numeric, + _session=None, + header=None, + cookie=None, + origin=None, + print_connect_messages=True, + ) if self.server.__dict__.get("ssl_context", None) else CustomCloud( + project_id=data["project_id"], + cloud_host=f"wss://{self.server.hostname}:{self.server.port}", + username=data["user"], + length_limit=self.server.length_limit, + allow_non_numeric=self.server.allow_non_numeric, + _session=None, + header=None, + cookie=None, + origin=None, + print_connect_messages=True, + ), } - # TODO: Add a cloud to the activity dict (possibly some kind of adapter) # raise event _a = cloud_activity.CloudActivity(timestamp=time.time() * 1000) _a._update_from_dict(send_to_clients) From c8a76c936e1841a4e5d10356b92a2ad72ba7a20a Mon Sep 17 00:00:00 2001 From: Boss-1s <95505913+Boss-1s@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:11:45 +0000 Subject: [PATCH 24/27] feat: link_cloud Signed-off-by: GitHub --- scratchattach/eventhandlers/_base.py | 9 +++- scratchattach/eventhandlers/cloud_server.py | 50 +++++++++------------ 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index fa36cb2b..acbc06cf 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -12,6 +12,7 @@ from SimpleWebSocketServer import WebSocket +import scratchattach.cloud._base as sa from scratchattach.utils.requests import requests from scratchattach.utils import exceptions @@ -142,9 +143,9 @@ class BaseCloudServer(BaseEventHandler): port: int "Port to bind the server to." tw_clients: dict[tuple[str, int], dict[str, Any]] - "Dictionary containing client information." + "Dictionary containing information on connected clients." tw_variables: dict[str, dict[str, Any]] - "Dictionary containing existing cloud variables." + "Dictionary containing states and data for existing cloud variables." allow_non_numeric: bool "Whether or not non-numeric characters are allowed in cloud variable values." whitelisted_projects: set[str] | None @@ -163,6 +164,7 @@ def __init__( hostname: str, *, port: int, + linked_cloud: sa.AnyCloud[str|int] | None = None, length_limit: int | None = None, allow_non_numeric: bool = True, whitelisted_projects: list[Any] | None = None, @@ -194,6 +196,8 @@ def __init__( self.sync_players = sync_players self.log_var_sets = log_var_sets + self.linked_cloud = linked_cloud if linked_cloud else sa.DummyCloud() + def check_for_ip_ban(self, client): if ( client.address[0] in self.blocked_ips @@ -313,6 +317,7 @@ def set_var( "project_id": project_id, "name": var_name, "value": value, + "server": "scratchattach/3", "timestamp": time.time() * 1000, "user": user, } diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index fe052635..df284b21 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -13,14 +13,14 @@ from scratchattach.utils import exceptions from scratchattach.site import cloud_activity from scratchattach.site.user import User -from scratchattach.cloud.cloud import CustomCloud +from scratchattach.cloud import BaseCloud, DummyCloud, AnyCloud from ._base import BaseCloudServer class TwCloudSocket(WebSocket): server: TwCloudServer | TwSSLCloudServer - def handle_set(self, data: dict): + def handle_set(self, data: dict[Any, Any]): # cloud variable set received # check if project_id is in whitelisted projects (if there's a list of whitelisted projects) if ( @@ -65,37 +65,20 @@ def handle_set(self, data: dict): "name": data["name"], "value": data["value"], # TODO: Add a cloud to the activity dict (possibly some kind of adapter) - # NOTE: this is just a temporary fill-in - "cloud": CustomCloud( - project_id=data["project_id"], - cloud_host=f"ws://{self.server.hostname}:{self.server.port}", - username=data["user"], - length_limit=self.server.length_limit, - allow_non_numeric=self.server.allow_non_numeric, - _session=None, - header=None, - cookie=None, - origin=None, - print_connect_messages=True, - ) if self.server.__dict__.get("ssl_context", None) else CustomCloud( - project_id=data["project_id"], - cloud_host=f"wss://{self.server.hostname}:{self.server.port}", - username=data["user"], - length_limit=self.server.length_limit, - allow_non_numeric=self.server.allow_non_numeric, - _session=None, - header=None, - cookie=None, - origin=None, - print_connect_messages=True, - ), + "cloud": self.server.linked_cloud, } # raise event - _a = cloud_activity.CloudActivity(timestamp=time.time() * 1000) + _a = cloud_activity.CloudActivity( + username=data["user"], + var=data["name"], + value=data["value"], + timestamp=time.time() * 1000, + cloud=self.server.linked_cloud + ) _a._update_from_dict(send_to_clients) self.server.call_event("on_set", [_a, self]) - def handle_handshake(self, data: dict): + def handle_handshake(self, data: dict[Any, Any]): # check if handshake is valid if not "user" in data: print( @@ -163,7 +146,7 @@ def handle_handshake(self, data: dict): ] ) ) - self.sendMessage("This server uses @TimMcCool's scratchattach 2.0.0") + self.sendMessage("This server uses @TimMcCool's scratchattach v3 library.") # raise event self.server.call_event("on_handshake", [data["user"], data["project_id"], self]) @@ -187,7 +170,6 @@ def handleMessage(self): self.address[0] + ":" + str(self.address[1]), "sent a message without providing a valid method (set, handshake)", ) - except Exception as e: print("Internal error in handleMessage:", e, traceback.format_exc()) @@ -242,6 +224,7 @@ def __init__( blocked_ips: list[str] | None = None, sync_players: bool = True, log_var_sets: bool = True, + link_cloud: AnyCloud[str|int] | None = None, ): if blocked_ips is None: blocked_ips = [] @@ -259,6 +242,7 @@ def __init__( blocked_ips=blocked_ips, sync_players=sync_players, log_var_sets=log_var_sets, + linked_cloud=link_cloud if link_cloud else DummyCloud(), ) @@ -280,6 +264,7 @@ def __init__( blocked_ips: list[str] | None = None, sync_players: bool = True, log_var_sets: bool = True, + link_cloud: AnyCloud[str|int] | None = None, ): SimpleSSLWebSocketServer.__init__( self, @@ -303,6 +288,7 @@ def __init__( blocked_ips=blocked_ips, sync_players=sync_players, log_var_sets=log_var_sets, + linked_cloud=link_cloud if link_cloud else DummyCloud() ) def _updater(self): @@ -325,6 +311,7 @@ def init_cloud_server( blocked_ips: list[str] | None = None, sync_players: bool = True, log_var_sets: bool = True, + link_cloud: AnyCloud[str|int] | None = None, ): """ Inits a websocket server which can be used with TurboWarp's ?cloud_host URL parameter. @@ -345,6 +332,7 @@ def init_cloud_server( blocked_ips=blocked_ips, sync_players=sync_players, log_var_sets=log_var_sets, + link_cloud=link_cloud ) @@ -363,6 +351,7 @@ def init_ssl_cloud_server( blocked_ips: list[str] | None = None, sync_players: bool = True, log_var_sets: bool = True, + link_cloud: AnyCloud[str|int] | None = None, ) -> TwSSLCloudServer: """ Inits a websocket server which can be used with TurboWarp's ?cloud_host URL parameter. @@ -390,4 +379,5 @@ def init_ssl_cloud_server( blocked_ips=blocked_ips, sync_players=sync_players, log_var_sets=log_var_sets, + link_cloud=link_cloud ) From cb00837deaacf8534a4cc4aa3a9afa7d9561aa0a Mon Sep 17 00:00:00 2001 From: TheCommCraft <79996518+TheCommCraft@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:02:30 +0200 Subject: [PATCH 25/27] add cloud server adapter --- scratchattach/cloud/_base.py | 204 ++++++++++++++++-- scratchattach/eventhandlers/_base.py | 70 +++--- scratchattach/eventhandlers/cloud_recorder.py | 4 +- scratchattach/eventhandlers/cloud_server.py | 15 +- 4 files changed, 222 insertions(+), 71 deletions(-) diff --git a/scratchattach/cloud/_base.py b/scratchattach/cloud/_base.py index ed8aa85b..b4e158a7 100644 --- a/scratchattach/cloud/_base.py +++ b/scratchattach/cloud/_base.py @@ -1,4 +1,6 @@ from __future__ import annotations +import queue +import threading import traceback import json @@ -11,6 +13,7 @@ from collections.abc import Iterator from scratchattach.cloud import cloud as cloud_module +from scratchattach.eventhandlers import cloud_server if TYPE_CHECKING: from _typeshed import SupportsRead @@ -91,7 +94,9 @@ def set_var(self, variable: str, value: T, *, max_retries: int = 2) -> None: """ @abstractmethod - def set_vars(self, var_value_dict: dict[str, T], *, intelligent_waits: bool = True, max_retries: int = 2): + def set_vars( + self, var_value_dict: dict[str, T], *, intelligent_waits: bool = True, max_retries: int = 2 + ): """ Sets multiple cloud variables at once (works for an unlimited amount of variables). @@ -104,11 +109,13 @@ def set_vars(self, var_value_dict: dict[str, T], *, intelligent_waits: bool = Tr """ @abstractmethod - def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> T: + def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> T | None: pass @abstractmethod - def get_all_vars(self, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> dict[str, T]: + def get_all_vars( + self, *, recorder_initial_values: Optional[dict[str, Any]] = None + ) -> dict[str, T]: pass def events(self) -> CloudEvents: @@ -124,10 +131,16 @@ def requests( ) -> CloudRequests: used_cloud_vars = used_cloud_vars or ["1", "2", "3", "4", "5", "6", "7", "8", "9"] return CloudRequests( - self, used_cloud_vars=used_cloud_vars, no_packet_loss=no_packet_loss, respond_order=respond_order, debug=debug + self, + used_cloud_vars=used_cloud_vars, + no_packet_loss=no_packet_loss, + respond_order=respond_order, + debug=debug, ) - def storage(self, *, no_packet_loss: bool = False, used_cloud_vars: Optional[list[str]] = None) -> CloudStorage: + def storage( + self, *, no_packet_loss: bool = False, used_cloud_vars: Optional[list[str]] = None + ) -> CloudStorage: used_cloud_vars = used_cloud_vars or ["1", "2", "3", "4", "5", "6", "7", "8", "9"] return CloudStorage(self, used_cloud_vars=used_cloud_vars, no_packet_loss=no_packet_loss) @@ -136,7 +149,7 @@ def create_event_stream(self) -> EventStream: pass -class DummyCloud(AnyCloud[Any]): +class DummyCloud(AnyCloud[T]): class DummyEventStream(EventStream): def read(self, length=...): return iter(()) @@ -159,13 +172,17 @@ def _enforce_ratelimit(self, *, n: int) -> None: def set_var(self, variable: str, value: T, *, max_retries: int = 2) -> None: pass - def set_vars(self, var_value_dict: dict[str, T], *, intelligent_waits: bool = True, max_retries: int = 2): + def set_vars( + self, var_value_dict: dict[str, T], *, intelligent_waits: bool = True, max_retries: int = 2 + ): pass - def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> Any: - pass + def get_var(self, var, *, recorder_initial_values: Optional[dict[str, T]] = None) -> T | None: + return None - def get_all_vars(self, *, recorder_initial_values: Optional[dict[str, Any]] = None) -> dict[str, Any]: + def get_all_vars( + self, *, recorder_initial_values: Optional[dict[str, T]] = None + ) -> dict[str, T]: return {} @@ -197,9 +214,12 @@ def __init__(self, cloud: BaseCloud): try: self.source_cloud.connect() except exceptions.CloudConnectionError: - warnings.warn("Initial cloud connection attempt failed, retrying...", exceptions.UnexpectedWebsocketEventWarning) + warnings.warn( + "Initial cloud connection attempt failed, retrying...", + exceptions.UnexpectedWebsocketEventWarning, + ) self.packets_left = [] - + def wait_before_reconnect(self): if time.time() - self.most_recent_reconnection_time > self.RECENT_RECONNECT_TIME_DELTA: self.recent_reconnect_count = 0 @@ -243,12 +263,16 @@ def read(self, amount: int = -1) -> Iterator[dict[str, Any]]: while not done: # print("Getting data...") try: - self.receive_new(not recv_once, timeout=timeout_end - time.time() if has_timeout else None) + self.receive_new( + not recv_once, timeout=timeout_end - time.time() if has_timeout else None + ) while (not has_timeout or time.time() < timeout_end) and ( (recv_once and self.packets_left) or (not recv_once and i < recv_at_least) ): if not self.packets_left and not recv_once: - self.receive_new(timeout=timeout_end - time.time() if has_timeout else None) + self.receive_new( + timeout=timeout_end - time.time() if has_timeout else None + ) if not self.packets_left: continue i += 1 @@ -276,7 +300,7 @@ def close(self) -> None: self.source_cloud.disconnect() -class BaseCloud(AnyCloud[Union[str, int]]): +class BaseCloud(AnyCloud[Union[str, int, float]]): """ Base class for a project's cloud variables. Represents a cloud. @@ -323,7 +347,9 @@ def __init__(self, *, project_id: Optional[Union[int, str]] = None, _session=Non # Required internal attributes that every object representing a cloud needs to have (no matter what cloud is represented): self._session = _session - self.active_connection = False # whether a connection to a cloud variable server is currently established + self.active_connection = ( + False # whether a connection to a cloud variable server is currently established + ) self.websocket = websocket.WebSocket(sslopt={"cert_reqs": ssl.CERT_NONE}) self.recorder = None # A CloudRecorder object that records cloud activity for the values to be retrieved later, @@ -368,7 +394,9 @@ def _send_recursive(self, data: str, *, current_depth: int = 0, max_depth=0): self._send_recursive(data, current_depth=current_depth + 1, max_depth=max_depth) else: self.active_connection = False - raise exceptions.CloudConnectionError(f"Sending packet failed {max_depth + 1} tries: {data}") + raise exceptions.CloudConnectionError( + f"Sending packet failed {max_depth + 1} tries: {data}" + ) def _send_packet(self, packet, *, max_retries=2): self._send_recursive(json.dumps(packet) + "\n", max_depth=max_retries) @@ -428,7 +456,8 @@ def _assert_valid_value(self, value): def _enforce_ratelimit(self, *, n): # n is the amount of variables being set if ( - (time.time() - self.first_var_set) / (self.var_sets_since_first + 1) > self.ws_longterm_ratelimit + (time.time() - self.first_var_set) / (self.var_sets_since_first + 1) + > self.ws_longterm_ratelimit ): # if the average delay between cloud variable sets has been bigger than the long-term rate-limit, cloud variables can be set fast (wait time smaller than long-term rate limit) again self.var_sets_since_first = 0 self.first_var_set = time.time() @@ -518,19 +547,25 @@ def _ensure_recorder_running( if recorder_initial_values is None and project_id is not None: recorder_initial_values = _get_cloud_var_initial_data_or_none(project_id) recorder_initial_values = recorder_initial_values or {} - self.recorder = recorder = cloud_recorder.CloudRecorder(self, initial_values=recorder_initial_values) + self.recorder = recorder = cloud_recorder.CloudRecorder( + self, initial_values=recorder_initial_values + ) recorder.start() # print("Started recorder.") recorder.received_data.wait(timeout=1) time.sleep(0.01) return recorder - def get_var(self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None): + def get_var( + self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None + ) -> str | int | float | None: var = "☁ " + var.removeprefix("☁ ") recorder = self._ensure_recorder_running(recorder_initial_values=recorder_initial_values) return recorder.get_var(var) - def get_all_vars(self, *, recorder_initial_values: Optional[dict[str, Any]] = None): + def get_all_vars( + self, *, recorder_initial_values: Optional[dict[str, Any]] = None + ) -> dict[str, str | int | float]: recorder = self._ensure_recorder_running(recorder_initial_values=recorder_initial_values) return recorder.get_all_vars() @@ -563,7 +598,9 @@ def _get_cloud_var_initial_data(project_id: Union[str, int]) -> dict[str, Any]: from scratchattach.site import project data: dict[str, Any] = {} - if isinstance((j := project.get_project(project_id).raw_json()), dict) and isinstance(targets := j.get("targets"), list): + if isinstance((j := project.get_project(project_id).raw_json()), dict) and isinstance( + targets := j.get("targets"), list + ): for target in targets: if not isinstance(target, dict): continue @@ -589,3 +626,126 @@ def _get_cloud_var_initial_data_or_none(project_id: Union[str, int]) -> Optional return _get_cloud_var_initial_data(project_id) except Exception: return None + + +class CloudServerAdapter(AnyCloud[str | int | float]): + server: cloud_server.BaseCloudServer + disconnected: threading.Event + project_id: str | int + connected_event_stream_queues: dict[int, queue.Queue[dict[str, Any]]] + + def __init__(self, server: cloud_server.BaseCloudServer, project_id: str | int): + self.server = server + self.disconnected = threading.Event() + self.project_id = project_id + self.connected_event_stream_queues = {} + + @self.server.event + def on_outgoing_packet(event): + for listener in self.connected_event_stream_queues.values(): + listener.put(event) + + def connect(self): + self.active_connection = True + self.disconnected.clear() + + def disconnect(self): + self.active_connection = False + self.disconnected.set() + + def reconnect(self): + self.disconnect() + time.sleep(0.1) + self.connect() + + def _enforce_ratelimit(self, *, n: int) -> None: + pass + + def set_var(self, variable: str, value: str | int | float, *, max_retries: int = 2) -> None: + """ + Sets a cloud variable. + + Args: + variable (str): The name of the cloud variable that should be set (provided without the cloud emoji) + value (Any): The value the cloud variable should be set to + + Kwargs: + max_retries (int) : Maximum number of times to retry setting the var if setting fails before raising an exception + """ + if self.disconnected.is_set(): + return + self.server.set_var( + self.project_id, + variable, + value, + ) + + def set_vars( + self, + var_value_dict: dict[str, str | int | float], + *, + intelligent_waits: bool = True, + max_retries: int = 2, + ): + """ + Sets multiple cloud variables at once (works for an unlimited amount of variables). + + Args: + var_value_dict (dict): variable:value dictionary with the variables / values to set. The dict should like this: {"var1":"value1", "var2":"value2", ...} + + Kwargs: + intelligent_waits (boolean): When enabled, the method will automatically decide how long to wait before performing this cloud variable set, to make sure no rate limits are triggered + max_retries (int) : Maximum number of times to retry setting the var if setting fails before raising an exception + """ + if self.disconnected.is_set(): + return + self.server.set_project_vars(self.project_id, var_value_dict) + + def get_var( + self, var, *, recorder_initial_values: Optional[dict[str, Any]] = None + ) -> str | int | float | None: + return self.server.get_var(self.project_id, var) + + def get_all_vars( + self, *, recorder_initial_values: Optional[dict[str, Any]] = None + ) -> dict[str, str | int | float]: + return self.server.get_project_vars(self.project_id).copy() + + def create_event_stream(self) -> CloudServerAdapterEventStream: + return CloudServerAdapterEventStream(self) + + +class CloudServerAdapterEventStream(EventStream): + adapter: CloudServerAdapter + disconnected: threading.Event + _queue: queue.Queue[dict[str, Any]] + + def __init__(self, adapter: CloudServerAdapter): + self.adapter = adapter + self.disconnected = threading.Event() + self._queue = queue.Queue() + self.adapter.connected_event_stream_queues[id(self)] = self._queue + + def read(self, amount: int = -1) -> Iterator[dict[str, Any]]: + if self.disconnected.is_set() or self.adapter.disconnected.is_set(): + return + end_time = time.time() + self.timeout if self.timeout is not None else None + progress = 0 + while (progress < amount if amount >= 0 else progress == 0) and ( + time.time() < end_time if end_time is not None else True + ): + try: + yield self._queue.get( + self.timeout is None or self.timeout > 0.0, + max(end_time - time.time(), 0) if end_time is not None else None, + ) + except queue.Empty: + pass + + def __del__(self): + self.close() + + def close(self) -> None: + if not self.disconnected.is_set(): + del self.adapter.connected_event_stream_queues[id(self)] + self.disconnected.set() diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index acbc06cf..129e7970 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -12,7 +12,7 @@ from SimpleWebSocketServer import WebSocket -import scratchattach.cloud._base as sa +import scratchattach.cloud._base as cloud_base from scratchattach.utils.requests import requests from scratchattach.utils import exceptions @@ -158,13 +158,13 @@ class BaseCloudServer(BaseEventHandler): "List of blocked IP addresses." sync_players: bool log_var_sets: bool + linked_clouds: dict[str, cloud_base.CloudServerAdapter] def __init__( self, hostname: str, *, port: int, - linked_cloud: sa.AnyCloud[str|int] | None = None, length_limit: int | None = None, allow_non_numeric: bool = True, whitelisted_projects: list[Any] | None = None, @@ -196,7 +196,7 @@ def __init__( self.sync_players = sync_players self.log_var_sets = log_var_sets - self.linked_cloud = linked_cloud if linked_cloud else sa.DummyCloud() + self.linked_clouds = {} def check_for_ip_ban(self, client): if ( @@ -267,25 +267,23 @@ def set_project_vars( if not no_prefix: data = {"☁ " + key.removeprefix("☁ "): value for key, value in data.items()} self.tw_variables[project_id].update(data) + packets = [ + { + "method": "set", + "project_id": project_id, + "name": varname, + "value": data[varname], + "server": "scratchattach/3", + "timestamp": time.time() * 1000, + "user": user, + } + for varname in data + ] + packets_string = "\n".join(json.dumps(packet) for packet in packets) for client in (self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)): - client.sendMessage( - "\n".join( - [ - json.dumps( - { - "method": "set", - "project_id": project_id, - "name": varname, - "value": data[varname], - "server": "scratchattach/3", - "timestamp": time.time() * 1000, - "user": user, - } - ) - for varname in data - ] - ) - ) + client.sendMessage(packets_string) + for packet in packets: + self.call_event("outgoing_packet", [packet]) def set_var( self, @@ -304,25 +302,23 @@ def set_var( self.tw_variables[project_id] = {} self.tw_variables[project_id][var_name] = value + packet = { + "method": "set", + "project_id": project_id, + "name": var_name, + "value": value, + "server": "scratchattach/3", + "timestamp": time.time() * 1000, + "user": user, + } if self.sync_players is True: for client in ( self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id) ): if client == skip_broadcast_for: continue - client.sendMessage( - json.dumps( - { - "method": "set", - "project_id": project_id, - "name": var_name, - "value": value, - "server": "scratchattach/3", - "timestamp": time.time() * 1000, - "user": user, - } - ) - ) + client.sendMessage(json.dumps(packet)) + self.call_event("outgoing_packet", [packet]) def _check_value(self, value): # Checks if a received cloud value satisfies the server's constraints @@ -345,6 +341,12 @@ def _updater(self): except Exception as e: raise exceptions.WebsocketServerError(str(e)) + def get_project_cloud(self, project_id: Any) -> cloud_base.CloudServerAdapter: + project_id = str(project_id) + if project_id not in self.linked_clouds: + self.linked_clouds[project_id] = cloud_base.CloudServerAdapter(self, project_id) + return self.linked_clouds[project_id] + def pause(self): self.running = False diff --git a/scratchattach/eventhandlers/cloud_recorder.py b/scratchattach/eventhandlers/cloud_recorder.py index 1dcaf36e..e556af26 100644 --- a/scratchattach/eventhandlers/cloud_recorder.py +++ b/scratchattach/eventhandlers/cloud_recorder.py @@ -24,12 +24,12 @@ def __init__(self, cloud, *, initial_values: Optional[dict[str, Any]] = None): self.has_data.set() self.event(self.on_set) - def get_var(self, var): + def get_var(self, var) -> Any | None: if var not in self.cloud_values: return None return self.cloud_values[var] - def get_all_vars(self): + def get_all_vars(self) -> dict[str, Any]: return self.cloud_values.copy() def on_set(self, activity: cloud_activity.CloudActivity): diff --git a/scratchattach/eventhandlers/cloud_server.py b/scratchattach/eventhandlers/cloud_server.py index df284b21..e3b4bea9 100644 --- a/scratchattach/eventhandlers/cloud_server.py +++ b/scratchattach/eventhandlers/cloud_server.py @@ -65,15 +65,12 @@ def handle_set(self, data: dict[Any, Any]): "name": data["name"], "value": data["value"], # TODO: Add a cloud to the activity dict (possibly some kind of adapter) - "cloud": self.server.linked_cloud, + "cloud": self.server.get_project_cloud(data["project_id"]), } # raise event _a = cloud_activity.CloudActivity( username=data["user"], - var=data["name"], - value=data["value"], - timestamp=time.time() * 1000, - cloud=self.server.linked_cloud + timestamp=time.time() * 1000 ) _a._update_from_dict(send_to_clients) self.server.call_event("on_set", [_a, self]) @@ -224,7 +221,6 @@ def __init__( blocked_ips: list[str] | None = None, sync_players: bool = True, log_var_sets: bool = True, - link_cloud: AnyCloud[str|int] | None = None, ): if blocked_ips is None: blocked_ips = [] @@ -242,7 +238,6 @@ def __init__( blocked_ips=blocked_ips, sync_players=sync_players, log_var_sets=log_var_sets, - linked_cloud=link_cloud if link_cloud else DummyCloud(), ) @@ -264,7 +259,6 @@ def __init__( blocked_ips: list[str] | None = None, sync_players: bool = True, log_var_sets: bool = True, - link_cloud: AnyCloud[str|int] | None = None, ): SimpleSSLWebSocketServer.__init__( self, @@ -288,7 +282,6 @@ def __init__( blocked_ips=blocked_ips, sync_players=sync_players, log_var_sets=log_var_sets, - linked_cloud=link_cloud if link_cloud else DummyCloud() ) def _updater(self): @@ -311,7 +304,6 @@ def init_cloud_server( blocked_ips: list[str] | None = None, sync_players: bool = True, log_var_sets: bool = True, - link_cloud: AnyCloud[str|int] | None = None, ): """ Inits a websocket server which can be used with TurboWarp's ?cloud_host URL parameter. @@ -332,7 +324,6 @@ def init_cloud_server( blocked_ips=blocked_ips, sync_players=sync_players, log_var_sets=log_var_sets, - link_cloud=link_cloud ) @@ -351,7 +342,6 @@ def init_ssl_cloud_server( blocked_ips: list[str] | None = None, sync_players: bool = True, log_var_sets: bool = True, - link_cloud: AnyCloud[str|int] | None = None, ) -> TwSSLCloudServer: """ Inits a websocket server which can be used with TurboWarp's ?cloud_host URL parameter. @@ -379,5 +369,4 @@ def init_ssl_cloud_server( blocked_ips=blocked_ips, sync_players=sync_players, log_var_sets=log_var_sets, - link_cloud=link_cloud ) From 615cb99437d47b052f5c64948c8b961a494b1d77 Mon Sep 17 00:00:00 2001 From: TheCommCraft <79996518+TheCommCraft@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:20:53 +0200 Subject: [PATCH 26/27] fix circular import --- scratchattach/eventhandlers/_base.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scratchattach/eventhandlers/_base.py b/scratchattach/eventhandlers/_base.py index 129e7970..b2296d1a 100644 --- a/scratchattach/eventhandlers/_base.py +++ b/scratchattach/eventhandlers/_base.py @@ -4,7 +4,7 @@ import time import ssl from abc import ABC, abstractmethod -from typing import Optional, Any +from typing import Optional, Any, TYPE_CHECKING from collections import defaultdict from threading import Thread, Event from collections.abc import Callable @@ -12,7 +12,8 @@ from SimpleWebSocketServer import WebSocket -import scratchattach.cloud._base as cloud_base +if TYPE_CHECKING: + import scratchattach.cloud._base as cloud_base from scratchattach.utils.requests import requests from scratchattach.utils import exceptions @@ -158,7 +159,7 @@ class BaseCloudServer(BaseEventHandler): "List of blocked IP addresses." sync_players: bool log_var_sets: bool - linked_clouds: dict[str, cloud_base.CloudServerAdapter] + linked_clouds: dict[str, "cloud_base.CloudServerAdapter"] def __init__( self, @@ -341,9 +342,10 @@ def _updater(self): except Exception as e: raise exceptions.WebsocketServerError(str(e)) - def get_project_cloud(self, project_id: Any) -> cloud_base.CloudServerAdapter: + def get_project_cloud(self, project_id: Any) -> "cloud_base.CloudServerAdapter": project_id = str(project_id) if project_id not in self.linked_clouds: + from scratchattach.cloud import _base as cloud_base self.linked_clouds[project_id] = cloud_base.CloudServerAdapter(self, project_id) return self.linked_clouds[project_id] From cc66264b6ffc956eb3db1763178531df9388828d Mon Sep 17 00:00:00 2001 From: TheCommCraft <79996518+TheCommCraft@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:24:22 +0200 Subject: [PATCH 27/27] fix second circular import --- scratchattach/cloud/_base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scratchattach/cloud/_base.py b/scratchattach/cloud/_base.py index b4e158a7..77c1c5bf 100644 --- a/scratchattach/cloud/_base.py +++ b/scratchattach/cloud/_base.py @@ -13,9 +13,9 @@ from collections.abc import Iterator from scratchattach.cloud import cloud as cloud_module -from scratchattach.eventhandlers import cloud_server if TYPE_CHECKING: + from scratchattach.eventhandlers import cloud_server from _typeshed import SupportsRead else: T = TypeVar("T") @@ -629,12 +629,12 @@ def _get_cloud_var_initial_data_or_none(project_id: Union[str, int]) -> Optional class CloudServerAdapter(AnyCloud[str | int | float]): - server: cloud_server.BaseCloudServer + server: "cloud_server.BaseCloudServer" disconnected: threading.Event project_id: str | int connected_event_stream_queues: dict[int, queue.Queue[dict[str, Any]]] - def __init__(self, server: cloud_server.BaseCloudServer, project_id: str | int): + def __init__(self, server: "cloud_server.BaseCloudServer", project_id: str | int): self.server = server self.disconnected = threading.Event() self.project_id = project_id