scratchattach 2.1.15b0__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.
Files changed (87) hide show
  1. cli/__about__.py +1 -0
  2. cli/__init__.py +26 -0
  3. cli/cmd/__init__.py +4 -0
  4. cli/cmd/group.py +127 -0
  5. cli/cmd/login.py +60 -0
  6. cli/cmd/profile.py +7 -0
  7. cli/cmd/sessions.py +5 -0
  8. cli/context.py +142 -0
  9. cli/db.py +66 -0
  10. cli/namespace.py +14 -0
  11. {scratchattach/cloud → cloud}/_base.py +112 -87
  12. {scratchattach/cloud → cloud}/cloud.py +16 -16
  13. {scratchattach/editor → editor}/__init__.py +2 -1
  14. {scratchattach/editor → editor}/asset.py +26 -14
  15. {scratchattach/editor → editor}/backpack_json.py +3 -5
  16. {scratchattach/editor → editor}/base.py +2 -4
  17. {scratchattach/editor → editor}/block.py +27 -22
  18. {scratchattach/editor → editor}/blockshape.py +1 -1
  19. {scratchattach/editor → editor}/build_defaulting.py +2 -2
  20. editor/commons.py +145 -0
  21. {scratchattach/editor → editor}/field.py +1 -1
  22. {scratchattach/editor → editor}/inputs.py +6 -3
  23. {scratchattach/editor → editor}/meta.py +10 -7
  24. {scratchattach/editor → editor}/monitor.py +10 -8
  25. {scratchattach/editor → editor}/mutation.py +68 -11
  26. {scratchattach/editor → editor}/pallete.py +1 -3
  27. {scratchattach/editor → editor}/prim.py +4 -0
  28. {scratchattach/editor → editor}/project.py +118 -16
  29. {scratchattach/editor → editor}/sprite.py +25 -15
  30. {scratchattach/editor → editor}/vlb.py +2 -2
  31. {scratchattach/eventhandlers → eventhandlers}/_base.py +1 -0
  32. {scratchattach/eventhandlers → eventhandlers}/cloud_events.py +26 -6
  33. {scratchattach/eventhandlers → eventhandlers}/cloud_recorder.py +4 -4
  34. {scratchattach/eventhandlers → eventhandlers}/cloud_requests.py +139 -54
  35. {scratchattach/eventhandlers → eventhandlers}/cloud_server.py +6 -3
  36. {scratchattach/eventhandlers → eventhandlers}/cloud_storage.py +1 -2
  37. eventhandlers/filterbot.py +163 -0
  38. other/other_apis.py +598 -0
  39. {scratchattach-2.1.15b0.dist-info → scratchattach-3.0.0b1.dist-info}/METADATA +7 -11
  40. scratchattach-3.0.0b1.dist-info/RECORD +79 -0
  41. {scratchattach-2.1.15b0.dist-info → scratchattach-3.0.0b1.dist-info}/WHEEL +1 -1
  42. scratchattach-3.0.0b1.dist-info/entry_points.txt +2 -0
  43. scratchattach-3.0.0b1.dist-info/top_level.txt +7 -0
  44. {scratchattach/site → site}/_base.py +32 -5
  45. site/activity.py +426 -0
  46. {scratchattach/site → site}/alert.py +4 -5
  47. {scratchattach/site → site}/backpack_asset.py +2 -1
  48. {scratchattach/site → site}/classroom.py +80 -73
  49. {scratchattach/site → site}/cloud_activity.py +43 -29
  50. {scratchattach/site → site}/comment.py +86 -100
  51. {scratchattach/site → site}/forum.py +8 -4
  52. site/placeholder.py +132 -0
  53. {scratchattach/site → site}/project.py +228 -122
  54. {scratchattach/site → site}/session.py +156 -71
  55. {scratchattach/site → site}/studio.py +139 -46
  56. site/typed_dicts.py +151 -0
  57. {scratchattach/site → site}/user.py +511 -215
  58. {scratchattach/utils → utils}/commons.py +12 -4
  59. {scratchattach/utils → utils}/encoder.py +7 -4
  60. {scratchattach/utils → utils}/enums.py +1 -0
  61. {scratchattach/utils → utils}/exceptions.py +36 -2
  62. utils/optional_async.py +154 -0
  63. utils/requests.py +306 -0
  64. scratchattach/__init__.py +0 -29
  65. scratchattach/editor/commons.py +0 -273
  66. scratchattach/eventhandlers/filterbot.py +0 -161
  67. scratchattach/other/other_apis.py +0 -284
  68. scratchattach/site/activity.py +0 -382
  69. scratchattach/utils/requests.py +0 -93
  70. scratchattach-2.1.15b0.dist-info/RECORD +0 -66
  71. scratchattach-2.1.15b0.dist-info/top_level.txt +0 -1
  72. {scratchattach/cloud → cloud}/__init__.py +0 -0
  73. {scratchattach/editor → editor}/code_translation/__init__.py +0 -0
  74. {scratchattach/editor → editor}/code_translation/parse.py +0 -0
  75. {scratchattach/editor → editor}/comment.py +0 -0
  76. {scratchattach/editor → editor}/extension.py +0 -0
  77. {scratchattach/editor → editor}/twconfig.py +0 -0
  78. {scratchattach/eventhandlers → eventhandlers}/__init__.py +0 -0
  79. {scratchattach/eventhandlers → eventhandlers}/combine.py +0 -0
  80. {scratchattach/eventhandlers → eventhandlers}/message_events.py +0 -0
  81. {scratchattach/other → other}/__init__.py +0 -0
  82. {scratchattach/other → other}/project_json_capabilities.py +0 -0
  83. {scratchattach-2.1.15b0.dist-info → scratchattach-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
  84. {scratchattach/site → site}/__init__.py +0 -0
  85. {scratchattach/site → site}/browser_cookie3_stub.py +0 -0
  86. {scratchattach/site → site}/browser_cookies.py +0 -0
  87. {scratchattach/utils → utils}/__init__.py +0 -0
@@ -1,14 +1,36 @@
1
1
  """CloudRequests class (threading.Event version)"""
2
2
  from __future__ import annotations
3
3
 
4
- from .cloud_events import CloudEvents
5
- from scratchattach.site import project
6
- from threading import Thread, Event, current_thread
4
+ from threading import Thread, Event, local
7
5
  import time
8
6
  import random
9
7
  import traceback
8
+ import warnings
9
+ from dataclasses import dataclass, field
10
+ from typing import Protocol, Any, runtime_checkable, TypedDict, Union, Optional
11
+ from enum import Enum, auto
12
+
10
13
  from scratchattach.utils.encoder import Encoding
11
14
  from scratchattach.utils import exceptions
15
+ from scratchattach.site import project, cloud_activity
16
+ from scratchattach.cloud import _base
17
+ from .cloud_events import CloudEvents
18
+
19
+ class RequestHandlerThreadInfo(local):
20
+ request_id: str = ""
21
+
22
+ request_handler_thread_info = RequestHandlerThreadInfo()
23
+
24
+ class ErrorInRequest(RuntimeWarning):
25
+ pass
26
+
27
+ class ErrorWithMessage(Exception):
28
+ pass
29
+
30
+ @runtime_checkable
31
+ class RequestHandler(Protocol):
32
+ def __call__(self, *args: str) -> Any:
33
+ pass
12
34
 
13
35
  class Request:
14
36
 
@@ -16,7 +38,17 @@ class Request:
16
38
  Saves a request added to the request handler
17
39
  """
18
40
 
19
- def __init__(self, request_name, *, on_call, cloud_requests, thread=True, enabled=True, response_priority=0, debug=False):
41
+ def __init__(
42
+ self,
43
+ request_name: str,
44
+ *,
45
+ on_call: RequestHandler,
46
+ cloud_requests: CloudRequests,
47
+ thread: bool = True,
48
+ enabled: bool = True,
49
+ response_priority: int = 0,
50
+ debug: bool = False
51
+ ):
20
52
  self.name = request_name
21
53
  self.on_call = on_call
22
54
  self.thread = thread
@@ -25,45 +57,93 @@ class Request:
25
57
  self.cloud_requests = cloud_requests # the corresponding CloudRequests object
26
58
  self.debug = debug or self.cloud_requests.debug
27
59
 
28
- def __call__(self, received_request):
60
+ def __call__(self, received_request: ReceivedRequest):
29
61
  if not self.enabled:
30
62
  self.cloud_requests.call_event("on_disabled_request", [received_request])
63
+ return
31
64
  try:
32
- current_thread().setName(received_request.request_id)
65
+ request_handler_thread_info.request_id = received_request.request_id # Used by .get_requester() / .get_timestamp() as lookup key
33
66
  output = self.on_call(*received_request.arguments)
34
- self.cloud_requests.request_outputs.append({"receive":received_request.timestamp, "request_id":received_request.request_id, "output":output, "priority":self.response_priority})
67
+ self.cloud_requests.request_outputs.append({"receive":received_request.timestamp, "request_id": received_request.request_id, "output":output, "priority":self.response_priority})
68
+ except ErrorWithMessage as e:
69
+ self.cloud_requests.call_event("on_error", [received_request, e])
70
+ output = [i for arg in e.args for i in str(arg).splitlines()]
71
+ self.cloud_requests.request_outputs.append({"receive": received_request.timestamp, "request_id": received_request.request_id, "output": output, "priority": self.response_priority})
35
72
  except Exception as e:
36
73
  self.cloud_requests.call_event("on_error", [received_request, e])
37
74
  if self.cloud_requests.ignore_exceptions:
38
- print(
39
- f"Warning: Caught error in request '{self.name}' - Full error below"
75
+ warnings.warn(
76
+ f"Warning: Caught error in request {self.name!r} - Full error below\n{traceback.format_exc()}",
77
+ ErrorInRequest
40
78
  )
41
- try:
42
- traceback.print_exc()
43
- except Exception:
44
- print(e)
45
79
  else:
46
- print(f"Exception in request '{self.name}':")
80
+ print(f"Exception in request {self.name!r}:")
47
81
  raise(e)
48
82
  if self.debug:
49
83
  traceback_full = traceback.format_exc().splitlines()
50
84
  output = [f"Error in request {self.name}", "Traceback: "]
51
85
  output.extend(traceback_full)
52
- self.cloud_requests.request_outputs.append({"receive":received_request.timestamp, "request_id":received_request.request_id, "output":output, "priority":self.response_priority})
86
+ self.cloud_requests.request_outputs.append({"receive": received_request.timestamp, "request_id": received_request.request_id, "output": output, "priority": self.response_priority})
53
87
  else:
54
- self.cloud_requests.request_outputs.append({"receive":received_request.timestamp, "request_id":received_request.request_id, "output":[f"Error in request {self.name}","Check the Python console"], "priority":self.response_priority})
88
+ self.cloud_requests.request_outputs.append({"receive": received_request.timestamp, "request_id": received_request.request_id, "output": [f"Error in request {self.name}", "Check the Python console"], "priority": self.response_priority})
55
89
  self.cloud_requests.responder_event.set() # Activate the .cloud_requests._responder process so it sends back the data to Scratch
56
90
 
57
- class ReceivedRequest:
91
+ class EmptyRequest(Request):
92
+ def __init__(self):
93
+ pass
94
+
95
+ def __call__(self, received_request):
96
+ raise TypeError("Empty request can not be called.")
58
97
 
59
- def __init__(self, **entries):
60
- self.__dict__.update(entries)
98
+ @dataclass
99
+ class ReceivedRequest:
100
+ request: Request = field(kw_only=True, default_factory=EmptyRequest)
101
+ request_name: str = field(kw_only=True, default="")
102
+ requester: str = field(kw_only=True, default="")
103
+ timestamp: float = field(kw_only=True, default=0.0)
104
+ arguments: list[str] = field(kw_only=True, default_factory=list)
105
+ request_id: str = field(kw_only=True, default="0")
106
+ activity: cloud_activity.CloudActivity = field(kw_only=True)
107
+
108
+ class RespondOrder(Enum):
109
+ FINISH = auto() # {"receive":time.time()*1000, "request_id":"100000000"+str(random.randint(1000, 9999)), "output":data, "priority":priority}
110
+ RECEIVE = auto()
111
+ REQUEST_ID = auto()
112
+ OUTPUT = auto()
113
+ PRIORITY = auto()
114
+
115
+ class RequestOutput(TypedDict):
116
+ receive: float
117
+ request_id: str
118
+ output: Union[str, list[str]]
119
+ priority: int
120
+
121
+ class ResponseMemory(TypedDict):
122
+ rid: str
123
+ packets: dict[int, str]
61
124
 
62
125
  class CloudRequests(CloudEvents):
63
126
 
64
127
  # The CloudRequests class is built upon CloudEvents, similar to how Filterbot is built upon MessageEvents
65
128
 
66
- def __init__(self, cloud, used_cloud_vars=["1", "2", "3", "4", "5", "6", "7", "8", "9"], no_packet_loss=False, respond_order="receive", debug=False):
129
+ _requests: dict[str, Request]
130
+ request_parts: dict[str, list[str]]
131
+ received_requests: list[ReceivedRequest]
132
+ executed_requests: dict[str, ReceivedRequest]
133
+ request_outputs: list[RequestOutput]
134
+ responded_request_ids: list[str]
135
+ packet_memory: list[ResponseMemory]
136
+ _packets_to_resend: list[str]
137
+
138
+ def __init__(
139
+ self,
140
+ cloud: _base.AnyCloud,
141
+ used_cloud_vars: Optional[list[str]] = None,
142
+ no_packet_loss: bool = False,
143
+ respond_order: RespondOrder = RespondOrder.RECEIVE,
144
+ debug = False
145
+ ):
146
+ used_cloud_vars = used_cloud_vars or ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
67
147
  super().__init__(cloud)
68
148
  # Setup
69
149
  self._requests = {}
@@ -95,7 +175,7 @@ class CloudRequests(CloudEvents):
95
175
 
96
176
  self.current_var = 0 # ID of the last set FROM_HOST_ variable (when a response is sent back to Scratch, these are set cyclically)
97
177
  self.credit_check()
98
- self.hard_stop = False # When set to True, all processes will halt immediately without finishing safely (can result in not fully received / responded requests etc.)
178
+ self.hard_stopped = False # When set to True, all processes will halt immediately without finishing safely (can result in not fully received / responded requests etc.)
99
179
 
100
180
  # -- Adding and removing requests --
101
181
 
@@ -103,24 +183,26 @@ class CloudRequests(CloudEvents):
103
183
  """
104
184
  Decorator function. Adds a request to the request handler.
105
185
  """
106
- def inner(function):
186
+ def inner(_function):
187
+ _name = _function.__name__ if name is None else name
107
188
  # called if the decorator provides arguments
108
- self._requests[function.__name__ if name is None else name] = Request(
109
- function.__name__ if name is None else name,
110
- enabled = enabled,
189
+ self._requests[_name] = Request(
190
+ _name,
191
+ enabled=enabled,
111
192
  thread=thread,
112
193
  response_priority=response_priority,
113
- on_call=function,
194
+ on_call=_function,
114
195
  cloud_requests=self,
115
196
  debug=debug
116
197
  )
198
+ return _function
117
199
 
118
200
  if function is None:
119
201
  # => the decorator provides arguments
120
202
  return inner
121
203
  else:
122
204
  # => the decorator doesn't provide arguments
123
- inner(function)
205
+ return inner(function)
124
206
 
125
207
  def add_request(self, function, *, enabled=True, name=None):
126
208
  self.request(enabled=enabled, name=name)(function)
@@ -128,10 +210,10 @@ class CloudRequests(CloudEvents):
128
210
  def remove_request(self, name):
129
211
  try:
130
212
  self._requests.pop(name)
131
- except Exception:
132
- raise ValueError(
213
+ except KeyError as e:
214
+ raise KeyError(
133
215
  f"No request with name {name} found to remove"
134
- )
216
+ ) from e
135
217
 
136
218
  # -- Parse and send back the request output --
137
219
 
@@ -191,7 +273,7 @@ class CloudRequests(CloudEvents):
191
273
  ) or self.no_packet_loss:
192
274
  self.cloud.reconnect()
193
275
 
194
- memory = {"rid":request_id}
276
+ memory = ResponseMemory(rid=request_id, packets={})#{"rid":request_id}
195
277
  remaining_response = str(response)
196
278
  length_limit = self.cloud.length_limit - (len(str(request_id))+6) # the subtrahend is the worst-case length of the "."+numbers after the "."
197
279
 
@@ -210,7 +292,7 @@ class CloudRequests(CloudEvents):
210
292
  iteration_string = "00" + str(i)
211
293
 
212
294
  value_to_send = f"{response_part}.{request_id}{iteration_string}1"
213
- memory[i] = value_to_send
295
+ memory["packets"][i] = value_to_send
214
296
 
215
297
  self._set_FROM_HOST_var(value_to_send)
216
298
 
@@ -221,26 +303,27 @@ class CloudRequests(CloudEvents):
221
303
  self.packet_memory.pop(0)
222
304
  remaining_response = ""
223
305
 
224
- if self.hard_stop: # stop immediately without exiting safely
306
+ if self.hard_stopped: # stop immediately without exiting safely
225
307
  break
226
308
 
227
- def _request_packet_from_memory(self, request_id, packet_id):
309
+ def _request_packet_from_memory(self, request_id: str, packet_id: Union[str, int]):
228
310
  memory = list(filter(lambda x : x["rid"] == request_id, self.packet_memory))
229
311
  if len(memory) > 0:
230
- self._packets_to_resend.append(memory[0][int(packet_id)])
312
+ self._packets_to_resend.append(memory[0]["packets"][int(packet_id)])
231
313
  self.responder_event.set() # activate _responder process
232
314
 
233
315
  # -- Register and handle incoming requests --
234
316
 
235
- def on_set(self, activity):
317
+ def on_set(self, activity: cloud_activity.CloudActivity):
236
318
  """
237
319
  This function is automatically called on cloud activites by the underlying cloud events that this CloudRequests class inherits from
238
320
  It registers incoming cloud activity and (if request.thread is True) runs them directly or (else) adds detected request to the .received_requests list
239
321
  """
240
322
  # Note for contributors: All functions called in this on_set function MUST be executed in threads because this function blocks the cloud events receiver, which is usually not a problem (because of the websocket buffer) but can cause problems in rare cases
241
- if activity.var == "TO_HOST" and "." in activity.value:
323
+ activity_value = str(activity.value)
324
+ if activity.var == "TO_HOST" and "." in activity_value:
242
325
  # Parsing the received request
243
- raw_request, request_id = activity.value.split(".")
326
+ raw_request, request_id = activity_value.split(".")
244
327
 
245
328
  if len(request_id) == 8 and request_id[-1] == "9":
246
329
  # A lost packet was re-requested
@@ -251,7 +334,7 @@ class CloudRequests(CloudEvents):
251
334
  # => The received request has already been answered, meaning this activity has already been received
252
335
  return
253
336
 
254
- if activity.value[0] == "-":
337
+ if activity_value[0] == "-":
255
338
  # => The received request is actually part of a bigger request
256
339
  if not request_id in self.request_parts:
257
340
  self.request_parts[request_id] = []
@@ -273,15 +356,16 @@ class CloudRequests(CloudEvents):
273
356
  # Decode request and parse arguemtns:
274
357
  request = Encoding.decode(raw_request)
275
358
  arguments = request.split("&")
276
- request_name = arguments.pop(0)#
359
+ request_name = arguments.pop(0)
277
360
 
278
361
  # Check if the request is unknown:
279
362
  if request_name not in self._requests:
280
363
  print(
281
- f"Warning: Client received an unknown request called '{request_name}'"
364
+ f"Warning: Client received an unknown request called {request_name!r}"
282
365
  )
283
366
  self.call_event("on_unknown_request", [
284
- ReceivedRequest(request_name=request,
367
+ ReceivedRequest(
368
+ request_name=request,
285
369
  requester=activity.user,
286
370
  timestamp=activity.timestamp,
287
371
  arguments=arguments,
@@ -293,6 +377,7 @@ class CloudRequests(CloudEvents):
293
377
 
294
378
  received_request = ReceivedRequest(
295
379
  request = self._requests[request_name],
380
+ request_name=request_name,
296
381
  requester=activity.user,
297
382
  timestamp=activity.timestamp,
298
383
  arguments=arguments,
@@ -328,7 +413,7 @@ class CloudRequests(CloudEvents):
328
413
 
329
414
  if use_extra_data:
330
415
  Thread(target=self.on_reconnect).start()
331
- if self.hard_stop: # stop immediately without exiting safely
416
+ if self.hard_stopped: # stop immediately without exiting safely
332
417
  break
333
418
 
334
419
  self.executer_event.wait(timeout = 2.5 if use_extra_data else None) # Wait for requests to be received
@@ -343,21 +428,21 @@ class CloudRequests(CloudEvents):
343
428
 
344
429
  while self._packets_to_resend != []:
345
430
  self._set_FROM_HOST_var(self._packets_to_resend.pop(0))
346
- if self.hard_stop: # stop immediately without exiting safely
431
+ if self.hard_stopped: # stop immediately without exiting safely
347
432
  break
348
433
 
349
- while self.request_outputs != []:
350
- if self.respond_order == "finish":
434
+ while self.request_outputs:
435
+ if self.respond_order == RespondOrder.FINISH:
351
436
  output_obj = self.request_outputs.pop(0)
352
437
  else:
353
- output_obj = min(self.request_outputs, key=lambda x : x[self.respond_order])
438
+ output_obj = min(self.request_outputs, key=lambda x : x[self.respond_order.name.lower()])
354
439
  self.request_outputs.remove(output_obj)
355
440
  if output_obj["request_id"] in self.executed_requests:
356
441
  received_request = self.executed_requests.pop(output_obj["request_id"])
357
- self._parse_output(received_request, output_obj["output"], output_obj["request_id"])
442
+ self._parse_output(received_request.request_name, output_obj["output"], output_obj["request_id"])
358
443
  else:
359
444
  self._parse_output("[sent from backend]", output_obj["output"], output_obj["request_id"])
360
- if self.hard_stop: # stop immediately without exiting safely
445
+ if self.hard_stopped: # stop immediately without exiting safely
361
446
  break
362
447
 
363
448
  def on_reconnect(self):
@@ -379,7 +464,7 @@ class CloudRequests(CloudEvents):
379
464
  """
380
465
  Can be used inside a request to get the username that performed the request.
381
466
  """
382
- activity = self.executed_requests[current_thread().name].activity
467
+ activity = self.executed_requests[request_handler_thread_info.request_id].activity
383
468
  if activity.user is None:
384
469
  activity.load_log_data()
385
470
  return activity.user
@@ -388,14 +473,14 @@ class CloudRequests(CloudEvents):
388
473
  """
389
474
  Can be used inside a request to get the timestamp of when the request was received.
390
475
  """
391
- activity = self.executed_requests[current_thread().name].activity
476
+ activity = self.executed_requests[request_handler_thread_info.request_id].activity
392
477
  return activity.timestamp
393
478
 
394
479
  def get_exact_timestamp(self):
395
480
  """
396
481
  Can be used inside a request to get the exact timestamp of when the request was performed.
397
482
  """
398
- activity = self.executed_requests[current_thread().name].activity
483
+ activity = self.executed_requests[request_handler_thread_info.request_id].activity
399
484
  activity.load_log_data()
400
485
  return activity.timestamp
401
486
 
@@ -434,10 +519,10 @@ class CloudRequests(CloudEvents):
434
519
  """
435
520
  Stops the request handler and all associated threads forever. Stops running response sending processes immediately.
436
521
  """
437
- self.hard_stop = True
522
+ self.hard_stopped = True
438
523
  self.stop()
439
524
  time.sleep(0.5)
440
- self.hard_stop = False
525
+ self.hard_stopped = False
441
526
 
442
527
  def credit_check(self):
443
528
  try:
@@ -8,7 +8,7 @@ import time
8
8
  from scratchattach.site import cloud_activity
9
9
  from scratchattach.site.user import User
10
10
  from ._base import BaseEventHandler
11
-
11
+ import traceback
12
12
  class TwCloudSocket(WebSocket):
13
13
 
14
14
  def handleMessage(self):
@@ -19,6 +19,7 @@ class TwCloudSocket(WebSocket):
19
19
  return
20
20
 
21
21
  data = json.loads(self.data)
22
+ print(data)
22
23
 
23
24
  if data["method"] == "set":
24
25
  # cloud variable set received
@@ -90,7 +91,7 @@ class TwCloudSocket(WebSocket):
90
91
  print("Error:", self.address[0]+":"+str(self.address[1]), "sent a message without providing a valid method (set, handshake)")
91
92
 
92
93
  except Exception as e:
93
- print("Internal error in handleMessage:", e)
94
+ print("Internal error in handleMessage:", e, traceback.format_exc())
94
95
 
95
96
  def handleConnected(self):
96
97
  if not self.server.running:
@@ -125,7 +126,9 @@ def init_cloud_server(hostname='127.0.0.1', port=8080, *, thread=True, length_li
125
126
  """
126
127
  class TwCloudServer(SimpleWebSocketServer, BaseEventHandler):
127
128
  def __init__(self, hostname, *, port, websocketclass):
128
- super().__init__(hostname, port=port, websocketclass=websocketclass)
129
+ SimpleWebSocketServer.__init__(self, hostname, port=port, websocketclass=websocketclass)
130
+ BaseEventHandler.__init__(self)
131
+
129
132
  self.running = False
130
133
  self._events = {} # saves event functions called on cloud updates
131
134
 
@@ -105,7 +105,6 @@ class CloudStorage(CloudRequests):
105
105
  return f"Error: Key {key} doesn't exist in database {db_name}"
106
106
 
107
107
  def set(self, db_name, key, value):
108
- print(db_name, key, value, self._databases)
109
108
  return self.get_database(db_name).set(key, value)
110
109
 
111
110
  def keys(self, db_name) -> list:
@@ -133,4 +132,4 @@ class CloudStorage(CloudRequests):
133
132
  Saves the data in the JSON files for all databases in self._databases
134
133
  """
135
134
  for dbname in self._databases:
136
- self._databases[dbname].save_to_json()
135
+ self._databases[dbname].save_to_json()
@@ -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}")