scratchattach 3.0.0b0__py3-none-any.whl → 3.0.0b1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__about__.py +1 -0
- cli/__init__.py +26 -0
- cli/cmd/__init__.py +4 -0
- cli/cmd/group.py +127 -0
- cli/cmd/login.py +60 -0
- cli/cmd/profile.py +7 -0
- cli/cmd/sessions.py +5 -0
- cli/context.py +142 -0
- cli/db.py +66 -0
- cli/namespace.py +14 -0
- cloud/__init__.py +2 -0
- cloud/_base.py +483 -0
- cloud/cloud.py +183 -0
- editor/__init__.py +22 -0
- editor/asset.py +265 -0
- editor/backpack_json.py +115 -0
- editor/base.py +191 -0
- editor/block.py +584 -0
- editor/blockshape.py +357 -0
- editor/build_defaulting.py +51 -0
- editor/code_translation/__init__.py +0 -0
- editor/code_translation/parse.py +177 -0
- editor/comment.py +80 -0
- editor/commons.py +145 -0
- editor/extension.py +50 -0
- editor/field.py +99 -0
- editor/inputs.py +138 -0
- editor/meta.py +117 -0
- editor/monitor.py +185 -0
- editor/mutation.py +381 -0
- editor/pallete.py +88 -0
- editor/prim.py +174 -0
- editor/project.py +381 -0
- editor/sprite.py +609 -0
- editor/twconfig.py +114 -0
- editor/vlb.py +134 -0
- eventhandlers/__init__.py +0 -0
- eventhandlers/_base.py +101 -0
- eventhandlers/cloud_events.py +130 -0
- eventhandlers/cloud_recorder.py +26 -0
- eventhandlers/cloud_requests.py +544 -0
- eventhandlers/cloud_server.py +249 -0
- eventhandlers/cloud_storage.py +135 -0
- eventhandlers/combine.py +30 -0
- eventhandlers/filterbot.py +163 -0
- eventhandlers/message_events.py +42 -0
- other/__init__.py +0 -0
- other/other_apis.py +598 -0
- other/project_json_capabilities.py +475 -0
- {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/METADATA +1 -1
- scratchattach-3.0.0b1.dist-info/RECORD +79 -0
- scratchattach-3.0.0b1.dist-info/top_level.txt +7 -0
- site/__init__.py +0 -0
- site/_base.py +93 -0
- site/activity.py +426 -0
- site/alert.py +226 -0
- site/backpack_asset.py +119 -0
- site/browser_cookie3_stub.py +17 -0
- site/browser_cookies.py +61 -0
- site/classroom.py +454 -0
- site/cloud_activity.py +121 -0
- site/comment.py +228 -0
- site/forum.py +436 -0
- site/placeholder.py +132 -0
- site/project.py +932 -0
- site/session.py +1323 -0
- site/studio.py +704 -0
- site/typed_dicts.py +151 -0
- site/user.py +1252 -0
- utils/__init__.py +0 -0
- utils/commons.py +263 -0
- utils/encoder.py +161 -0
- utils/enums.py +237 -0
- utils/exceptions.py +277 -0
- utils/optional_async.py +154 -0
- utils/requests.py +306 -0
- scratchattach/__init__.py +0 -37
- scratchattach/__main__.py +0 -93
- scratchattach-3.0.0b0.dist-info/RECORD +0 -8
- scratchattach-3.0.0b0.dist-info/top_level.txt +0 -1
- {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/WHEEL +0 -0
- {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/entry_points.txt +0 -0
- {scratchattach-3.0.0b0.dist-info → scratchattach-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket
|
|
4
|
+
from threading import Thread
|
|
5
|
+
from scratchattach.utils import exceptions
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from scratchattach.site import cloud_activity
|
|
9
|
+
from scratchattach.site.user import User
|
|
10
|
+
from ._base import BaseEventHandler
|
|
11
|
+
import traceback
|
|
12
|
+
class TwCloudSocket(WebSocket):
|
|
13
|
+
|
|
14
|
+
def handleMessage(self):
|
|
15
|
+
if not self.server.running:
|
|
16
|
+
return
|
|
17
|
+
try:
|
|
18
|
+
if self.server.check_for_ip_ban(self):
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
data = json.loads(self.data)
|
|
22
|
+
print(data)
|
|
23
|
+
|
|
24
|
+
if data["method"] == "set":
|
|
25
|
+
# cloud variable set received
|
|
26
|
+
# check if project_id is in whitelisted projects (if there's a list of whitelisted projects)
|
|
27
|
+
if self.server.whitelisted_projects is not None:
|
|
28
|
+
if data["project_id"] not in self.server.whitelisted_projects:
|
|
29
|
+
self.close(4002)
|
|
30
|
+
if self.server.log_var_sets:
|
|
31
|
+
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"])
|
|
32
|
+
return
|
|
33
|
+
# check if value is valid
|
|
34
|
+
if not self.server._check_value(data["value"]):
|
|
35
|
+
if self.server.log_var_sets:
|
|
36
|
+
print(self.address[0]+":"+str(self.address[1]), "sent an invalid var value")
|
|
37
|
+
return
|
|
38
|
+
# perform cloud var and forward to other players
|
|
39
|
+
if self.server.log_var_sets:
|
|
40
|
+
print(self.address[0]+":"+str(self.address[1]), f"set {data['name']} to {data['value']}, project:", str(data["project_id"]), "user:",data["user"])
|
|
41
|
+
self.server.set_var(data["project_id"], data["name"], data["value"], user=data["user"], skip_forward=self)
|
|
42
|
+
send_to_clients = {
|
|
43
|
+
"method" : "set", "user" : data["user"], "project_id" : data["project_id"], "name" : data["name"],
|
|
44
|
+
"value" : data["value"], "timestamp" : round(time.time() * 1000), "server" : "scratchattach/2.0.0",
|
|
45
|
+
}
|
|
46
|
+
# raise event
|
|
47
|
+
_a = cloud_activity.CloudActivity(timestamp=time.time()*1000)
|
|
48
|
+
data["name"] = data["name"].replace("☁ ", "")
|
|
49
|
+
_a._update_from_dict(send_to_clients)
|
|
50
|
+
self.server.call_event("on_set", [_a, self])
|
|
51
|
+
|
|
52
|
+
elif data["method"] == "handshake":
|
|
53
|
+
data = json.loads(self.data)
|
|
54
|
+
# check if handshake is valid
|
|
55
|
+
if not "user" in data:
|
|
56
|
+
print(self.address[0]+":"+str(self.address[1]), "tried to handshake without providing a username")
|
|
57
|
+
self.close(4002)
|
|
58
|
+
return
|
|
59
|
+
if not "project_id" in data:
|
|
60
|
+
print(self.address[0]+":"+str(self.address[1]), "tried to handshake without providing a project_id")
|
|
61
|
+
self.close(4002)
|
|
62
|
+
return
|
|
63
|
+
# check if project_id is in username is allowed
|
|
64
|
+
if self.server.allow_nonscratch_names is False:
|
|
65
|
+
if not User(username=data["user"]).does_exist():
|
|
66
|
+
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"])
|
|
67
|
+
self.close(4002)
|
|
68
|
+
return
|
|
69
|
+
# check if project_id is in whitelisted projects (if there's a list of whitelisted projects)
|
|
70
|
+
if self.server.whitelisted_projects is not None:
|
|
71
|
+
if str(data["project_id"]) not in self.server.whitelisted_projects:
|
|
72
|
+
self.close(4002)
|
|
73
|
+
print(self.address[0]+":"+str(self.address[1]), "tried to handshake on a non-whitelisted project:", data["project_id"], "user:",data["user"])
|
|
74
|
+
return
|
|
75
|
+
# register handshake in users list (save username and project_id)
|
|
76
|
+
print(self.address[0]+":"+str(self.address[1]), "handshaked, project:", data["project_id"], "user:",data["user"])
|
|
77
|
+
self.server.tw_clients[self.address]["username"] = data["user"]
|
|
78
|
+
self.server.tw_clients[self.address]["project_id"] = data["project_id"]
|
|
79
|
+
# send current cloud variable values to the user who handshaked
|
|
80
|
+
self.sendMessage("\n".join([
|
|
81
|
+
json.dumps({
|
|
82
|
+
"method" : "set", "project_id" : data["project_id"], "name" : "☁ "+varname,
|
|
83
|
+
"value" : self.server.tw_variables[str(data["project_id"])][varname], "server" : "scratchattach/2.0.0",
|
|
84
|
+
}) for varname in self.server.get_project_vars(str(data["project_id"]))])
|
|
85
|
+
)
|
|
86
|
+
self.sendMessage("This server uses @TimMcCool's scratchattach 2.0.0")
|
|
87
|
+
# raise event
|
|
88
|
+
self.server.call_event("on_handshake", [data["user"], data["project_id"], self])
|
|
89
|
+
|
|
90
|
+
else:
|
|
91
|
+
print("Error:", self.address[0]+":"+str(self.address[1]), "sent a message without providing a valid method (set, handshake)")
|
|
92
|
+
|
|
93
|
+
except Exception as e:
|
|
94
|
+
print("Internal error in handleMessage:", e, traceback.format_exc())
|
|
95
|
+
|
|
96
|
+
def handleConnected(self):
|
|
97
|
+
if not self.server.running:
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
if self.server.check_for_ip_ban(self):
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
print(self.address[0]+":"+str(self.address[1]), "connected")
|
|
104
|
+
self.server.tw_clients[self.address] = {"client":self, "username":None, "project_id":None}
|
|
105
|
+
# raise event
|
|
106
|
+
self.server.call_event("on_connect", [self])
|
|
107
|
+
except Exception as e:
|
|
108
|
+
print("Internal error in handleConntected:", e)
|
|
109
|
+
|
|
110
|
+
def handleClose(self):
|
|
111
|
+
if not self.server.running:
|
|
112
|
+
return
|
|
113
|
+
try:
|
|
114
|
+
if self.address in self.server.tw_clients:
|
|
115
|
+
# raise event
|
|
116
|
+
self.server.call_event("on_disconnect", [self.server.tw_clients[self.address]["username"], self.server.tw_clients[self.address]["project_id"], self])
|
|
117
|
+
print(self.address[0]+":"+str(self.address[1]), "disconnected")
|
|
118
|
+
except Exception as e:
|
|
119
|
+
print("Internal error in handleClose:", e)
|
|
120
|
+
|
|
121
|
+
def init_cloud_server(hostname='127.0.0.1', port=8080, *, thread=True, length_limit=None, allow_non_numeric=True, whitelisted_projects=None, allow_nonscratch_names=True, blocked_ips=[], sync_players=True, log_var_sets=True):
|
|
122
|
+
"""
|
|
123
|
+
Inits a websocket server which can be used with TurboWarp's ?cloud_host URL parameter.
|
|
124
|
+
|
|
125
|
+
Prints out the websocket address in the console.
|
|
126
|
+
"""
|
|
127
|
+
class TwCloudServer(SimpleWebSocketServer, BaseEventHandler):
|
|
128
|
+
def __init__(self, hostname, *, port, websocketclass):
|
|
129
|
+
SimpleWebSocketServer.__init__(self, hostname, port=port, websocketclass=websocketclass)
|
|
130
|
+
BaseEventHandler.__init__(self)
|
|
131
|
+
|
|
132
|
+
self.running = False
|
|
133
|
+
self._events = {} # saves event functions called on cloud updates
|
|
134
|
+
|
|
135
|
+
self.tw_clients = {} # saves connected clients
|
|
136
|
+
self.tw_variables = {} # holds cloud variable states
|
|
137
|
+
|
|
138
|
+
# server config
|
|
139
|
+
self.allow_non_numeric = allow_non_numeric
|
|
140
|
+
self.whitelisted_projects = whitelisted_projects
|
|
141
|
+
self.length_limit = length_limit
|
|
142
|
+
self.allow_nonscratch_names = allow_nonscratch_names
|
|
143
|
+
self.blocked_ips = blocked_ips
|
|
144
|
+
self.sync_players = sync_players
|
|
145
|
+
self.log_var_sets = log_var_sets
|
|
146
|
+
|
|
147
|
+
def check_for_ip_ban(self, client):
|
|
148
|
+
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:
|
|
149
|
+
client.sendMessage("You have been banned from this server")
|
|
150
|
+
client.close(4002)
|
|
151
|
+
print(client.address[0]+":"+str(client.address[1]), "(IP-banned) was disconnected")
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def active_projects(self):
|
|
156
|
+
only_active = {}
|
|
157
|
+
for project_id in self.tw_variables:
|
|
158
|
+
if self.active_user_ips(project_id) != []:
|
|
159
|
+
only_active[project_id] = self.tw_variables[project_id]
|
|
160
|
+
return only_active
|
|
161
|
+
|
|
162
|
+
def active_user_names(self, project_id):
|
|
163
|
+
return [self.tw_clients[user]["username"] for user in self.active_user_ips(project_id)]
|
|
164
|
+
|
|
165
|
+
def active_user_ips(self, project_id):
|
|
166
|
+
return list(filter(lambda user : str(self.tw_clients[user]["project_id"]) == str(project_id), self.tw_clients))
|
|
167
|
+
|
|
168
|
+
def get_global_vars(self):
|
|
169
|
+
return self.tw_variables
|
|
170
|
+
|
|
171
|
+
def get_project_vars(self, project_id):
|
|
172
|
+
project_id = str(project_id)
|
|
173
|
+
if project_id in self.tw_variables:
|
|
174
|
+
return self.tw_variables[project_id]
|
|
175
|
+
else: return {}
|
|
176
|
+
|
|
177
|
+
def get_var(self, project_id, var_name):
|
|
178
|
+
project_id = str(project_id)
|
|
179
|
+
var_name = var_name.replace("☁ ", "")
|
|
180
|
+
if project_id in self.tw_variables:
|
|
181
|
+
if var_name in self.tw_variables[project_id]:
|
|
182
|
+
return self.tw_variables[project_id][var_name]
|
|
183
|
+
else: return None
|
|
184
|
+
else: return None
|
|
185
|
+
|
|
186
|
+
def set_global_vars(self, data):
|
|
187
|
+
for project_id in data:
|
|
188
|
+
self.set_project_vars(project_id, data[project_id])
|
|
189
|
+
|
|
190
|
+
def set_project_vars(self, project_id, data, *, user="@server"):
|
|
191
|
+
project_id = str(project_id)
|
|
192
|
+
self.tw_variables[project_id] = data
|
|
193
|
+
for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]:
|
|
194
|
+
client.sendMessage("\n".join([
|
|
195
|
+
json.dumps({
|
|
196
|
+
"method" : "set", "project_id" : project_id, "name" : "☁ "+varname,
|
|
197
|
+
"value" : data[varname], "server" : "scratchattach/2.0.0", "timestamp" : time.time()*1000, "user" : user
|
|
198
|
+
}) for varname in data])
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def set_var(self, project_id, var_name, value, *, user="@server", skip_forward=None):
|
|
202
|
+
var_name = var_name.replace("☁ ", "")
|
|
203
|
+
project_id = str(project_id)
|
|
204
|
+
if project_id not in self.tw_variables:
|
|
205
|
+
self.tw_variables[project_id] = {}
|
|
206
|
+
self.tw_variables[project_id][var_name] = value
|
|
207
|
+
|
|
208
|
+
if self.sync_players is True:
|
|
209
|
+
for client in [self.tw_clients[ip]["client"] for ip in self.active_user_ips(project_id)]:
|
|
210
|
+
if client == skip_forward:
|
|
211
|
+
continue
|
|
212
|
+
client.sendMessage(
|
|
213
|
+
json.dumps({
|
|
214
|
+
"method" : "set", "project_id" : project_id, "name" : "☁ "+var_name,
|
|
215
|
+
"value" : value, "timestamp" : time.time()*1000, "user" : user
|
|
216
|
+
})
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _check_value(self, value):
|
|
220
|
+
# Checks if a received cloud value satisfies the server's constraints
|
|
221
|
+
if self.length_limit is not None:
|
|
222
|
+
if len(str(value)) > self.length_limit:
|
|
223
|
+
return False
|
|
224
|
+
if self.allow_non_numeric is False:
|
|
225
|
+
x = value.replace(".", "")
|
|
226
|
+
x = x.replace("-", "")
|
|
227
|
+
if not (x.isnumeric() or x == ""):
|
|
228
|
+
return False
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
def _updater(self):
|
|
232
|
+
try:
|
|
233
|
+
# Function called when .start() is executed (.start is inherited from BaseEventHandler)
|
|
234
|
+
print(f"Serving websocket server: ws://{hostname}:{port}")
|
|
235
|
+
self.serveforever()
|
|
236
|
+
except Exception as e:
|
|
237
|
+
raise exceptions.WebsocketServerError(str(e))
|
|
238
|
+
|
|
239
|
+
def pause(self):
|
|
240
|
+
self.running = False
|
|
241
|
+
|
|
242
|
+
def resume(self):
|
|
243
|
+
self.running = True
|
|
244
|
+
|
|
245
|
+
def stop(self):
|
|
246
|
+
self.running = False
|
|
247
|
+
self.close()
|
|
248
|
+
|
|
249
|
+
return TwCloudServer(hostname, port=port, websocketclass=TwCloudSocket)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""CloudStorage class"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .cloud_requests import CloudRequests
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from threading import Thread
|
|
8
|
+
|
|
9
|
+
class Database:
|
|
10
|
+
|
|
11
|
+
"""
|
|
12
|
+
A Database is a simple key-value storage that stores data in a JSON file saved locally (other database services like MongoDB can be implemented)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, name, *, json_file_path, save_interval=30):
|
|
16
|
+
self.save_event_function = None
|
|
17
|
+
self.set_event_function = None
|
|
18
|
+
self.name = name
|
|
19
|
+
|
|
20
|
+
# Import from JSON file
|
|
21
|
+
if not json_file_path.endswith(".json"):
|
|
22
|
+
json_file_path = json_file_path+".json"
|
|
23
|
+
self.json_file_path = json_file_path
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
with open(json_file_path, 'r') as json_file:
|
|
27
|
+
self.data = json.load(json_file)
|
|
28
|
+
except FileNotFoundError:
|
|
29
|
+
print(f"Creating file {json_file_path}. Your database {name} will be stored there.")
|
|
30
|
+
self.data = {}
|
|
31
|
+
self.save_to_json()
|
|
32
|
+
|
|
33
|
+
if isinstance(self.data , list):
|
|
34
|
+
raise ValueError(
|
|
35
|
+
"Invalid JSON file content: Top-level object must be a dict, not a list"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Start autosaving
|
|
39
|
+
self.save_interval = save_interval
|
|
40
|
+
if self.save_interval is not None:
|
|
41
|
+
Thread(target=self._autosaver).start()
|
|
42
|
+
|
|
43
|
+
def save_to_json(self):
|
|
44
|
+
with open(self.json_file_path, 'w') as json_file:
|
|
45
|
+
json.dump(self.data, json_file, indent=4)
|
|
46
|
+
|
|
47
|
+
if self.save_event_function is not None:
|
|
48
|
+
self.save_event_function()
|
|
49
|
+
|
|
50
|
+
def keys(self) -> list:
|
|
51
|
+
return list(self.data.keys())
|
|
52
|
+
|
|
53
|
+
def get(self, key) -> str:
|
|
54
|
+
if not key in self.data:
|
|
55
|
+
return None
|
|
56
|
+
return self.data[key]
|
|
57
|
+
|
|
58
|
+
def set(self, key, value):
|
|
59
|
+
self.data[key] = value
|
|
60
|
+
|
|
61
|
+
if self.set_event_function is not None:
|
|
62
|
+
self.set_event_function(key, value)
|
|
63
|
+
|
|
64
|
+
def event(self, event_function):
|
|
65
|
+
# Decorator function for adding the on_save event that is called when a save is performed
|
|
66
|
+
if event_function.__name__ == "on_save":
|
|
67
|
+
self.save_event_function = event_function
|
|
68
|
+
if event_function.__name__ == "on_set":
|
|
69
|
+
self.set_event_function = event_function
|
|
70
|
+
|
|
71
|
+
def _autosaver(self):
|
|
72
|
+
# Task autosaving the db. save interval specified in .save_interval attribute
|
|
73
|
+
while True:
|
|
74
|
+
time.sleep(self.save_interval)
|
|
75
|
+
self.save_to_json()
|
|
76
|
+
|
|
77
|
+
class CloudStorage(CloudRequests):
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
A CloudStorage object saves multiple databases and allows the connected Scratch project to access and modify the data of these databases through cloud requests
|
|
81
|
+
|
|
82
|
+
The CloudStorage class is built upon CloudRequests
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, cloud, used_cloud_vars=["1", "2", "3", "4", "5", "6", "7", "8", "9"], no_packet_loss=False):
|
|
86
|
+
super().__init__(cloud, used_cloud_vars=used_cloud_vars, no_packet_loss=no_packet_loss)
|
|
87
|
+
# Setup
|
|
88
|
+
self._databases = {}
|
|
89
|
+
self.request(self.get, thread=False)
|
|
90
|
+
self.request(self.set, thread=False)
|
|
91
|
+
self.request(self.keys, thread=False)
|
|
92
|
+
self.request(self.database_names, thread=False)
|
|
93
|
+
self.request(self.ping, thread=False)
|
|
94
|
+
|
|
95
|
+
def ping(self):
|
|
96
|
+
return "Database backend is running"
|
|
97
|
+
|
|
98
|
+
def get(self, db_name, key) -> str:
|
|
99
|
+
try:
|
|
100
|
+
return self.get_database(db_name).get(key)
|
|
101
|
+
except Exception:
|
|
102
|
+
if self.get_database(db_name) is None:
|
|
103
|
+
return f"Error: Database {db_name} doesn't exist"
|
|
104
|
+
else:
|
|
105
|
+
return f"Error: Key {key} doesn't exist in database {db_name}"
|
|
106
|
+
|
|
107
|
+
def set(self, db_name, key, value):
|
|
108
|
+
return self.get_database(db_name).set(key, value)
|
|
109
|
+
|
|
110
|
+
def keys(self, db_name) -> list:
|
|
111
|
+
try:
|
|
112
|
+
return self.get_database(db_name).keys()
|
|
113
|
+
except Exception:
|
|
114
|
+
return f"Error: Database {db_name} doesn't exist"
|
|
115
|
+
|
|
116
|
+
def databases(self) -> list:
|
|
117
|
+
return list(self._databases.values())
|
|
118
|
+
|
|
119
|
+
def database_names(self) -> list:
|
|
120
|
+
return list(self._databases.keys())
|
|
121
|
+
|
|
122
|
+
def add_database(self, database:Database):
|
|
123
|
+
self._databases[database.name] = database
|
|
124
|
+
|
|
125
|
+
def get_database(self, name) -> Database:
|
|
126
|
+
if name in self._databases:
|
|
127
|
+
return self._databases[name]
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
def save(self):
|
|
131
|
+
"""
|
|
132
|
+
Saves the data in the JSON files for all databases in self._databases
|
|
133
|
+
"""
|
|
134
|
+
for dbname in self._databases:
|
|
135
|
+
self._databases[dbname].save_to_json()
|
eventhandlers/combine.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
class MultiEventHandler:
|
|
4
|
+
|
|
5
|
+
def __init__(self, *handlers):
|
|
6
|
+
self.handlers = handlers
|
|
7
|
+
|
|
8
|
+
def request(self, function, *args, **kwargs):
|
|
9
|
+
for handler in self.handlers:
|
|
10
|
+
handler.request(function, *args, **kwargs)
|
|
11
|
+
|
|
12
|
+
def event(self, function, *args, **kwargs):
|
|
13
|
+
for handler in self.handlers:
|
|
14
|
+
handler.event(function, *args, **kwargs)
|
|
15
|
+
|
|
16
|
+
def start(self, *args, **kwargs):
|
|
17
|
+
for handler in self.handlers:
|
|
18
|
+
handler.start(*args, **kwargs)
|
|
19
|
+
|
|
20
|
+
def stop(self, *args, **kwargs):
|
|
21
|
+
for handler in self.handlers:
|
|
22
|
+
handler.stop(*args, **kwargs)
|
|
23
|
+
|
|
24
|
+
def pause(self, *args, **kwargs):
|
|
25
|
+
for handler in self.handlers:
|
|
26
|
+
handler.pause(*args, **kwargs)
|
|
27
|
+
|
|
28
|
+
def resume(self, *args, **kwargs):
|
|
29
|
+
for handler in self.handlers:
|
|
30
|
+
handler.resume(*args, **kwargs)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""FilterBot class"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from .message_events import MessageEvents
|
|
4
|
+
import time
|
|
5
|
+
from collections import deque
|
|
6
|
+
|
|
7
|
+
class HardFilter:
|
|
8
|
+
|
|
9
|
+
def __init__(self, filter_name="UntitledFilter", *, equals=None, contains=None, author_name=None, project_id=None, profile=None, case_sensitive=False):
|
|
10
|
+
self.equals = equals
|
|
11
|
+
self.contains = contains
|
|
12
|
+
self.author_name = author_name
|
|
13
|
+
self.project_id = project_id
|
|
14
|
+
self.profile = profile
|
|
15
|
+
self.case_sensitive = case_sensitive
|
|
16
|
+
self.filter_name = filter_name
|
|
17
|
+
|
|
18
|
+
def apply(self, content, author_name, source_id):
|
|
19
|
+
text_to_check = content if self.case_sensitive else content.lower()
|
|
20
|
+
if self.equals is not None:
|
|
21
|
+
comparison_equals = self.equals if self.case_sensitive else self.equals.lower()
|
|
22
|
+
if text_to_check == comparison_equals:
|
|
23
|
+
return True
|
|
24
|
+
if self.contains is not None:
|
|
25
|
+
comparison_contains = self.contains if self.case_sensitive else self.contains.lower()
|
|
26
|
+
if comparison_contains in text_to_check:
|
|
27
|
+
return True
|
|
28
|
+
if self.author_name is not None and self.author_name == author_name:
|
|
29
|
+
return True
|
|
30
|
+
if (self.project_id is not None and self.project_id == source_id) or \
|
|
31
|
+
(self.profile is not None and self.profile == source_id):
|
|
32
|
+
return True
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
class SoftFilter(HardFilter):
|
|
36
|
+
def __init__(self, score:float, filter_name="UntitledFilter", *, equals=None, contains=None, author_name=None, project_id=None, profile=None, case_sensitive=False):
|
|
37
|
+
self.score = score
|
|
38
|
+
super().__init__(filter_name, equals=equals, contains=contains, author_name=author_name, project_id=project_id, profile=profile, case_sensitive=case_sensitive)
|
|
39
|
+
|
|
40
|
+
class SpamFilter(HardFilter):
|
|
41
|
+
def __init__(self, filter_name="UntitledFilter", *, equals=None, contains=None, author_name=None, project_id=None, profile=None, case_sensitive=False):
|
|
42
|
+
super().__init__(filter_name, equals=equals, contains=contains, author_name=author_name, project_id=project_id, profile=profile, case_sensitive=case_sensitive)
|
|
43
|
+
self.memory = deque()
|
|
44
|
+
self.retention_period = 300
|
|
45
|
+
|
|
46
|
+
def apply(self, content, author_name, source_id):
|
|
47
|
+
if not super().apply(content, author_name, source_id):
|
|
48
|
+
return False
|
|
49
|
+
current_time = time.time()
|
|
50
|
+
|
|
51
|
+
# Prune old entries from memory
|
|
52
|
+
while self.memory and self.memory[-1]["time"] < current_time - self.retention_period:
|
|
53
|
+
self.memory.pop()
|
|
54
|
+
|
|
55
|
+
content_lower = content.lower()
|
|
56
|
+
# Check for duplicates
|
|
57
|
+
for comment in self.memory:
|
|
58
|
+
if comment["content"].lower() == content_lower:
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
# Add new comment to memory
|
|
62
|
+
self.memory.appendleft({"content": content, "time": current_time})
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
class Filterbot(MessageEvents):
|
|
66
|
+
|
|
67
|
+
# The Filterbot class is built upon MessageEvents, similar to how CloudEvents is built upon CloudEvents
|
|
68
|
+
|
|
69
|
+
def __init__(self, user, *, log_deletions=True):
|
|
70
|
+
super().__init__(user)
|
|
71
|
+
self.hard_filters = []
|
|
72
|
+
self.soft_filters = []
|
|
73
|
+
self.spam_filters = []
|
|
74
|
+
self.log_deletions = log_deletions
|
|
75
|
+
self.event(self.on_message, thread=False)
|
|
76
|
+
self.update_interval = 2
|
|
77
|
+
|
|
78
|
+
def add_filter(self, filter_obj):
|
|
79
|
+
if isinstance(filter_obj, SpamFilter):
|
|
80
|
+
self.spam_filters.append(filter_obj)
|
|
81
|
+
elif isinstance(filter_obj, SoftFilter):
|
|
82
|
+
self.soft_filters.append(filter_obj)
|
|
83
|
+
elif isinstance(filter_obj, HardFilter):
|
|
84
|
+
self.hard_filters.append(filter_obj)
|
|
85
|
+
|
|
86
|
+
def add_f4f_filter(self):
|
|
87
|
+
self.add_filter(HardFilter("(f4f_filter) 'f4f'", contains="f4f"))
|
|
88
|
+
self.add_filter(HardFilter("(f4f_filter) 'follow me'", contains="follow me"))
|
|
89
|
+
self.add_filter(HardFilter("(f4f_filter) 'follow @'", contains="follow @"))
|
|
90
|
+
self.add_filter(HardFilter("(f4f_filter) f 4 f'", contains="f 4 f"))
|
|
91
|
+
self.add_filter(HardFilter("(f4f_filter) 'follow for'", contains="follow for"))
|
|
92
|
+
|
|
93
|
+
def add_ads_filter(self):
|
|
94
|
+
self.add_filter(SoftFilter(1, "(ads_filter) links", contains="scratch.mit.edu/projects/"))
|
|
95
|
+
self.add_filter(SoftFilter(-1, "(ads_filter) feedback", contains="feedback"))
|
|
96
|
+
self.add_filter(HardFilter("(ads_filter) 'check out my'", contains="check out my"))
|
|
97
|
+
self.add_filter(HardFilter("(ads_filter) 'play my'", contains="play my"))
|
|
98
|
+
self.add_filter(SoftFilter(1, "(ads_filter) 'advertis'", contains="advertis"))
|
|
99
|
+
|
|
100
|
+
def add_spam_filter(self):
|
|
101
|
+
self.add_filter(SpamFilter("(spam_filter)", contains=""))
|
|
102
|
+
|
|
103
|
+
def add_genalpha_nonsense_filter(self):
|
|
104
|
+
self.add_filter(HardFilter("(genalpha_nonsene_filter) 'skibidi'", contains="skibidi"))
|
|
105
|
+
self.add_filter(HardFilter("[genalpha_nonsene_filter) 'rizzler'", contains="rizzler"))
|
|
106
|
+
self.add_filter(HardFilter("(genalpha_nonsene_filter) 'fanum tax'", contains="fanum tax"))
|
|
107
|
+
|
|
108
|
+
def on_message(self, message):
|
|
109
|
+
if message.type != "addcomment":
|
|
110
|
+
return
|
|
111
|
+
source_id = None
|
|
112
|
+
content = message.comment_fragment
|
|
113
|
+
if message.comment_type == 0: # project comment
|
|
114
|
+
source_id = message.comment_obj_id
|
|
115
|
+
if self.user._session.connect_project(message.comment_obj_id).author_name != self.user.username:
|
|
116
|
+
return # no permission to delete comments that aren't on our own project
|
|
117
|
+
elif message.comment_type == 1: # profile comment
|
|
118
|
+
source_id = message.comment_obj_title
|
|
119
|
+
if source_id != self.user.username:
|
|
120
|
+
return # no permission to delete messages that are not on our profile
|
|
121
|
+
elif message.comment_type == 2: # studio comment
|
|
122
|
+
return # studio comments aren't handled
|
|
123
|
+
else:
|
|
124
|
+
return
|
|
125
|
+
delete = False
|
|
126
|
+
reason = ""
|
|
127
|
+
|
|
128
|
+
# Apply hard filters
|
|
129
|
+
for hard_filter in self.hard_filters:
|
|
130
|
+
if hard_filter.apply(content, message.actor_username, source_id):
|
|
131
|
+
delete = True
|
|
132
|
+
reason = f"hard filter: {hard_filter.filter_name}"
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
# Apply spam filters
|
|
136
|
+
if not delete:
|
|
137
|
+
for spam_filter in self.spam_filters:
|
|
138
|
+
if spam_filter.apply(content, message.actor_username, source_id):
|
|
139
|
+
delete = True
|
|
140
|
+
reason = f"spam filter: {spam_filter.filter_name}"
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
# Apply soft filters
|
|
144
|
+
if not delete:
|
|
145
|
+
score = 0
|
|
146
|
+
violated_filters = []
|
|
147
|
+
for soft_filter in self.soft_filters:
|
|
148
|
+
if soft_filter.apply(content, message.actor_username, source_id):
|
|
149
|
+
score += soft_filter.score
|
|
150
|
+
violated_filters.append(soft_filter.filter_name)
|
|
151
|
+
if score >= 1:
|
|
152
|
+
delete = True
|
|
153
|
+
reason = f"too many soft filters: {violated_filters}"
|
|
154
|
+
if delete:
|
|
155
|
+
if self.log_deletions:
|
|
156
|
+
print(f"DETECTED: #{message.comment_id} violates {reason}")
|
|
157
|
+
try:
|
|
158
|
+
resp = message.target().delete()
|
|
159
|
+
if self.log_deletions:
|
|
160
|
+
print(f"DELETED: #{message.comment_id} by {message.actor_username!r}: '{content}' with message {resp.content!r} & headers {resp.headers!r}")
|
|
161
|
+
except Exception as e:
|
|
162
|
+
if self.log_deletions:
|
|
163
|
+
print(f"DELETION FAILED: #{message.comment_id} by {message.actor_username!r}: '{content}'; exception: {e}")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""MessageEvents class"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from scratchattach.site import user
|
|
5
|
+
from ._base import BaseEventHandler
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
class MessageEvents(BaseEventHandler):
|
|
9
|
+
"""
|
|
10
|
+
Class that calls events when you receive messages on your Scratch account. Data fetched from Scratch's API.
|
|
11
|
+
"""
|
|
12
|
+
def __init__(self, user, *, update_interval=2):
|
|
13
|
+
super().__init__()
|
|
14
|
+
self.user = user
|
|
15
|
+
self.current_message_count = 0
|
|
16
|
+
self.update_interval = update_interval
|
|
17
|
+
|
|
18
|
+
def _updater(self):
|
|
19
|
+
"""
|
|
20
|
+
A process that listens for cloud activity and executes events on cloud activity
|
|
21
|
+
"""
|
|
22
|
+
self.current_message_count = int(self.user.message_count())
|
|
23
|
+
|
|
24
|
+
self.call_event("on_ready")
|
|
25
|
+
|
|
26
|
+
while True:
|
|
27
|
+
if self.running is False:
|
|
28
|
+
return
|
|
29
|
+
message_count = int(self.user.message_count())
|
|
30
|
+
if message_count != self.current_message_count:
|
|
31
|
+
self.call_event("on_count_change", [int(self.current_message_count), int(message_count)])
|
|
32
|
+
if message_count != 0:
|
|
33
|
+
if message_count < self.current_message_count:
|
|
34
|
+
self.current_message_count = 0
|
|
35
|
+
if self.user._session is not None: # authentication check
|
|
36
|
+
if self.user._session.username == self.user.username: # authorization check
|
|
37
|
+
new_messages = self.user._session.messages(limit=message_count-self.current_message_count)
|
|
38
|
+
for message in new_messages[::-1]:
|
|
39
|
+
self.call_event("on_message", [message])
|
|
40
|
+
self.current_message_count = int(message_count)
|
|
41
|
+
time.sleep(self.update_interval)
|
|
42
|
+
|
other/__init__.py
ADDED
|
File without changes
|