Explorar el Código

Persisted state renamed to config. Storage class now handles transient values too.

tags/1.0.1
Rocketsoup hace 4 años
padre
commit
26d47aae52
Se han modificado 7 ficheros con 173 adiciones y 65 borrados
  1. 5
    5
      cogs/basecog.py
  2. 5
    5
      cogs/configcog.py
  3. 59
    0
      cogs/crosspostcog.py
  4. 2
    2
      cogs/generalcog.py
  5. 6
    6
      cogs/joinraidcog.py
  6. 1
    5
      config.py.sample
  7. 95
    42
      storage.py

+ 5
- 5
cogs/basecog.py Ver fichero

1
 from discord import Guild, Message, TextChannel
1
 from discord import Guild, Message, TextChannel
2
 from discord.ext import commands
2
 from discord.ext import commands
3
 
3
 
4
-from storage import StateKey, Storage
4
+from storage import ConfigKey, Storage
5
 import json
5
 import json
6
 
6
 
7
 class BaseCog(commands.Cog):
7
 class BaseCog(commands.Cog):
15
 		given guild. If no warning channel is configured no action is taken.
15
 		given guild. If no warning channel is configured no action is taken.
16
 		Returns the Message if successful or None if not.
16
 		Returns the Message if successful or None if not.
17
 		"""
17
 		"""
18
-		channel_id = Storage.get_state_value(guild, StateKey.WARNING_CHANNEL_ID)
18
+		channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
19
 		if channel_id is None:
19
 		if channel_id is None:
20
 			cls.guild_trace(guild, 'No warning channel set! No warning issued.')
20
 			cls.guild_trace(guild, 'No warning channel set! No warning issued.')
21
 			return None
21
 			return None
23
 		if channel is None:
23
 		if channel is None:
24
 			cls.guild_trace(guild, 'Configured warning channel does not exist!')
24
 			cls.guild_trace(guild, 'Configured warning channel does not exist!')
25
 			return None
25
 			return None
26
-		mention: str = Storage.get_state_value(guild, StateKey.WARNING_MENTION)
26
+		mention: str = Storage.get_config_value(guild, ConfigKey.WARNING_MENTION)
27
 		text: str = message
27
 		text: str = message
28
 		if mention is not None:
28
 		if mention is not None:
29
 			text = f'{mention} {text}'
29
 			text = f'{mention} {text}'
37
 		mentions if necessary.
37
 		mentions if necessary.
38
 		"""
38
 		"""
39
 		text: str = new_text
39
 		text: str = new_text
40
-		mention: str = Storage.get_state_value(
40
+		mention: str = Storage.get_config_value(
41
 			warn_message.guild,
41
 			warn_message.guild,
42
-			StateKey.WARNING_MENTION)
42
+			ConfigKey.WARNING_MENTION)
43
 		if mention is not None:
43
 		if mention is not None:
44
 			text = f'{mention} {text}'
44
 			text = f'{mention} {text}'
45
 		await warn_message.edit(content=text)
45
 		await warn_message.edit(content=text)

+ 5
- 5
cogs/configcog.py Ver fichero

1
 from discord import Guild, TextChannel
1
 from discord import Guild, TextChannel
2
 from discord.ext import commands
2
 from discord.ext import commands
3
-from storage import StateKey, Storage
3
+from storage import ConfigKey, Storage
4
 from cogs.basecog import BaseCog
4
 from cogs.basecog import BaseCog
5
 
5
 
6
 class ConfigCog(BaseCog):
6
 class ConfigCog(BaseCog):
32
 		'Command handler'
32
 		'Command handler'
33
 		guild: Guild = context.guild
33
 		guild: Guild = context.guild
34
 		channel: TextChannel = context.channel
34
 		channel: TextChannel = context.channel
35
-		Storage.set_state_value(guild, StateKey.WARNING_CHANNEL_ID,
35
+		Storage.set_config_value(guild, ConfigKey.WARNING_CHANNEL_ID,
36
 			context.channel.id)
36
 			context.channel.id)
37
 		await context.message.reply(
37
 		await context.message.reply(
38
 			f'Warning channel updated to {channel.mention}.',
38
 			f'Warning channel updated to {channel.mention}.',
45
 	async def config_getwarningchannel(self, context: commands.Context) -> None:
45
 	async def config_getwarningchannel(self, context: commands.Context) -> None:
46
 		'Command handler'
46
 		'Command handler'
47
 		guild: Guild = context.guild
47
 		guild: Guild = context.guild
48
-		channel_id = Storage.get_state_value(guild, StateKey.WARNING_CHANNEL_ID)
48
+		channel_id = Storage.get_config_value(guild, ConfigKey.WARNING_CHANNEL_ID)
49
 		if channel_id is None:
49
 		if channel_id is None:
50
 			await context.message.reply(
50
 			await context.message.reply(
51
 				'No warning channel is configured.',
51
 				'No warning channel is configured.',
67
 	)
67
 	)
68
 	async def config_setwarningmention(self, context: commands.Context, mention: str = None) -> None:
68
 	async def config_setwarningmention(self, context: commands.Context, mention: str = None) -> None:
69
 		guild: Guild = context.guild
69
 		guild: Guild = context.guild
70
-		Storage.set_state_value(guild, StateKey.WARNING_MENTION, mention)
70
+		Storage.set_config_value(guild, ConfigKey.WARNING_MENTION, mention)
71
 		if mention is None:
71
 		if mention is None:
72
 			await context.message.reply(
72
 			await context.message.reply(
73
 				'Warning messages will not tag anyone.',
73
 				'Warning messages will not tag anyone.',
85
 	)
85
 	)
86
 	async def config_getwarningmention(self, context: commands.Context) -> None:
86
 	async def config_getwarningmention(self, context: commands.Context) -> None:
87
 		guild: Guild = context.guild
87
 		guild: Guild = context.guild
88
-		mention: str = Storage.get_state_value(guild, StateKey.WARNING_MENTION)
88
+		mention: str = Storage.get_config_value(guild, ConfigKey.WARNING_MENTION)
89
 		if mention is None:
89
 		if mention is None:
90
 			await context.message.reply(
90
 			await context.message.reply(
91
 				'No warning mention configured.',
91
 				'No warning mention configured.',

+ 59
- 0
cogs/crosspostcog.py Ver fichero

1
+from discord import Guild, Message
1
 from cogs.basecog import BaseCog
2
 from cogs.basecog import BaseCog
2
 
3
 
4
+class CrossPostCog(BaseCog):
5
+	class SpamContext:
6
+		def __init__(self, member):
7
+			self.member = member
8
+			self.is_warned = False
9
+
10
+	STATE_KEY_RECENT_MESSAGES = "crosspost_recent_messages"
11
+	STATE_KEY_SPAM_CONTEXT = "crosspost_spam_context"
12
+
13
+	def __init__(self, bot):
14
+		super().__init__(bot)
15
+		self.max_recent_messages = 20
16
+		self.messages_per_user = 3
17
+		self.min_message_length = 10
18
+
19
+	def __record_message(self, message: Message) -> None:
20
+		recent_messages = Storage.get_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES) or []
21
+		recent_messages.append(message)
22
+		while len(recent_messages) > self.max_recent_messages:
23
+			recent_messages.pop(0)
24
+		Storage.set_state_value(message.guild, self.STATE_KEY_RECENT_MESSAGES, recent_messages)
25
+
26
+	def __check_for_spam(self, guild: Guild) -> None:
27
+		recent_messages = Storage.get_state_value(guild, self.STATE_KEY_RECENT_MESSAGES)
28
+		if recent_messages is None:
29
+			return
30
+		user_id_to_count = {}
31
+		spamming_members = set()
32
+		for message in recent_messages:
33
+			content = message.content
34
+			if len(content) < self.min_message_length:
35
+				continue
36
+			key = str(message.author.id) + '|' + hash(content)
37
+			count = (user_id_to_count.get(key) or 0) + 1
38
+			user_id_to_count[key] = count
39
+			if count >= self.messages_per_user:
40
+				spamming_members.add(message.author)
41
+		for member in spamming_members:
42
+			context = self.__spam_context_for_user(member)
43
+
44
+	def __spam_context_for_user(self,
45
+			member: Member,
46
+			create_if_missing: bool = False) -> SpamContext:
47
+		spam_lookup = Storage.get_state_value(member.guild, self.STATE_KEY_SPAM_CONTEXT) or {}
48
+		context = spam_lookup.get(member.id)
49
+		if context:
50
+			return context
51
+		if not create_if_missing:
52
+			return None
53
+		context = SpamContext(member)
54
+		spam_lookup[member.id] = context
55
+		Storage.set_state_value(member.guild, self.STATE_KEY_SPAM_CONTEXT, spam_lookup)
56
+		return context
57
+
58
+	@commands.Cog.listener()
59
+	def on_message(self, message: Message):
60
+		self.__record_message(message)
61
+		self.__check_for_spam(message.guild)

+ 2
- 2
cogs/generalcog.py Ver fichero

1
 from discord.ext import commands
1
 from discord.ext import commands
2
 from cogs.basecog import BaseCog
2
 from cogs.basecog import BaseCog
3
-from storage import StateKey, Storage
3
+from storage import ConfigKey, Storage
4
 
4
 
5
 class GeneralCog(BaseCog):
5
 class GeneralCog(BaseCog):
6
 	def __init__(self, bot: commands.Bot):
6
 	def __init__(self, bot: commands.Bot):
24
 	@commands.has_permissions(ban_members=True)
24
 	@commands.has_permissions(ban_members=True)
25
 	@commands.guild_only()
25
 	@commands.guild_only()
26
 	async def testwarn(self, context):
26
 	async def testwarn(self, context):
27
-		if Storage.get_state_value(context.guild, StateKey.WARNING_CHANNEL_ID) is None:
27
+		if Storage.get_config_value(context.guild, ConfigKey.WARNING_CHANNEL_ID) is None:
28
 			await context.message.reply(
28
 			await context.message.reply(
29
 				'No warning channel set!',
29
 				'No warning channel set!',
30
 				mention_author=False)
30
 				mention_author=False)

+ 6
- 6
cogs/joinraidcog.py Ver fichero

253
         """
253
         """
254
         Returns the join rate configured for this guild.
254
         Returns the join rate configured for this guild.
255
         """
255
         """
256
-        count: int = Storage.get_state_value(guild, self.STATE_KEY_RAID_COUNT) \
256
+        count: int = Storage.get_config_value(guild, self.STATE_KEY_RAID_COUNT) \
257
             or CONFIG['joinWarningCount']
257
             or CONFIG['joinWarningCount']
258
-        seconds: float = Storage.get_state_value(guild, self.STATE_KEY_RAID_SECONDS) \
258
+        seconds: float = Storage.get_config_value(guild, self.STATE_KEY_RAID_SECONDS) \
259
             or CONFIG['joinWarningSeconds']
259
             or CONFIG['joinWarningSeconds']
260
         return (count, seconds)
260
         return (count, seconds)
261
 
261
 
263
         """
263
         """
264
         Returns whether join raid detection is enabled in this guild.
264
         Returns whether join raid detection is enabled in this guild.
265
         """
265
         """
266
-        return Storage.get_state_value(guild, self.STATE_KEY_ENABLED) or False
266
+        return Storage.get_config_value(guild, self.STATE_KEY_ENABLED) or False
267
 
267
 
268
     # -- Commands -----------------------------------------------------------
268
     # -- Commands -----------------------------------------------------------
269
 
269
 
285
     async def joinraid_enable(self, context: commands.Context):
285
     async def joinraid_enable(self, context: commands.Context):
286
         'Command handler'
286
         'Command handler'
287
         guild = context.guild
287
         guild = context.guild
288
-        Storage.set_state_value(guild, self.STATE_KEY_ENABLED, True)
288
+        Storage.set_config_value(guild, self.STATE_KEY_ENABLED, True)
289
         # TODO: Startup tracking if necessary
289
         # TODO: Startup tracking if necessary
290
         await context.message.reply(
290
         await context.message.reply(
291
             '✅ ' + self.__describe_raid_settings(guild, force_enabled_status=True),
291
             '✅ ' + self.__describe_raid_settings(guild, force_enabled_status=True),
299
     async def joinraid_disable(self, context: commands.Context):
299
     async def joinraid_disable(self, context: commands.Context):
300
         'Command handler'
300
         'Command handler'
301
         guild = context.guild
301
         guild = context.guild
302
-        Storage.set_state_value(guild, self.STATE_KEY_ENABLED, False)
302
+        Storage.set_config_value(guild, self.STATE_KEY_ENABLED, False)
303
         # TODO: Tear down tracking if necessary
303
         # TODO: Tear down tracking if necessary
304
         await context.message.reply(
304
         await context.message.reply(
305
             '✅ ' + self.__describe_raid_settings(guild, force_enabled_status=True),
305
             '✅ ' + self.__describe_raid_settings(guild, force_enabled_status=True),
330
                 f'⚠️ `seconds` must be > 0',
330
                 f'⚠️ `seconds` must be > 0',
331
                 mention_author=False)
331
                 mention_author=False)
332
             return
332
             return
333
-        Storage.set_state_values(guild, {
333
+        Storage.set_config_values(guild, {
334
             self.STATE_KEY_RAID_COUNT: join_count,
334
             self.STATE_KEY_RAID_COUNT: join_count,
335
             self.STATE_KEY_RAID_SECONDS: seconds,
335
             self.STATE_KEY_RAID_SECONDS: seconds,
336
         })
336
         })

+ 1
- 5
config.py.sample Ver fichero

1
 # Copy this file to config.py and fill in necessary values
1
 # Copy this file to config.py and fill in necessary values
2
 CONFIG = {
2
 CONFIG = {
3
 	'clientToken': 'token',
3
 	'clientToken': 'token',
4
-	'dbHost': 'localhost',
5
-	'dbUser': 'username',
6
-	'dbPassword': 'password',
7
-	'dbDatabase': 'databasename',
8
 	'joinWarningCount': 5,
4
 	'joinWarningCount': 5,
9
 	'joinWarningSeconds': 5,
5
 	'joinWarningSeconds': 5,
10
 	'commandPrefix': '$rb_',
6
 	'commandPrefix': '$rb_',
12
 	'kickEmoji': '👢',
8
 	'kickEmoji': '👢',
13
 	'banEmojiName': 'no_entry_sign',
9
 	'banEmojiName': 'no_entry_sign',
14
 	'banEmoji': '🚫',
10
 	'banEmoji': '🚫',
15
-	'statePath': 'state/',
11
+	'configPath': 'config/',
16
 }
12
 }

+ 95
- 42
storage.py Ver fichero

5
 
5
 
6
 from config import CONFIG
6
 from config import CONFIG
7
 
7
 
8
-class StateKey:
8
+class ConfigKey:
9
 	WARNING_CHANNEL_ID = 'warning_channel_id'
9
 	WARNING_CHANNEL_ID = 'warning_channel_id'
10
 	WARNING_MENTION = 'warning_mention'
10
 	WARNING_MENTION = 'warning_mention'
11
 
11
 
12
 class Storage:
12
 class Storage:
13
 	"""
13
 	"""
14
-	Static class for managing persisted bot state.
14
+	Static class for managing persisted bot configuration and transient state
15
+	on a per-guild basis.
15
 	"""
16
 	"""
16
 
17
 
17
-	# discord.Guild.id -> dict
18
+	# -- Transient state management -----------------------------------------
19
+
18
 	__guild_id_to_state = {}
20
 	__guild_id_to_state = {}
19
 
21
 
20
 	@classmethod
22
 	@classmethod
21
 	def get_state(cls, guild: Guild) -> dict:
23
 	def get_state(cls, guild: Guild) -> dict:
22
 		"""
24
 		"""
23
-		Returns all persisted state for the given guild.
25
+		Returns transient state for the given guild. This state is not preserved
26
+		if the bot is restarted.
24
 		"""
27
 		"""
25
 		state: dict = cls.__guild_id_to_state.get(guild.id)
28
 		state: dict = cls.__guild_id_to_state.get(guild.id)
26
-		if state is not None:
27
-			# Already in memory
28
-			return state
29
-		# Load from disk if possible
30
-		cls.__trace(f'No loaded state for guild {guild.id}. Attempting to ' +
31
-			'load from disk.')
32
-		state = cls.__load_guild_state(guild)
33
 		if state is None:
29
 		if state is None:
34
-			return {}
35
-		cls.__guild_id_to_state[guild.id] = state
30
+			state = {}
31
+			cls.__guild_id_to_state[guild.id] = state
36
 		return state
32
 		return state
37
 
33
 
38
 	@classmethod
34
 	@classmethod
39
 	def get_state_value(cls, guild: Guild, key: str):
35
 	def get_state_value(cls, guild: Guild, key: str):
40
 		"""
36
 		"""
41
-		Returns a persisted state value stored under the given key. Returns
42
-		None if not present.
37
+		Returns a state value for the given guild and key, or `None` if not set.
43
 		"""
38
 		"""
44
 		return cls.get_state(guild).get(key)
39
 		return cls.get_state(guild).get(key)
45
 
40
 
46
 	@classmethod
41
 	@classmethod
47
 	def set_state_value(cls, guild: Guild, key: str, value) -> None:
42
 	def set_state_value(cls, guild: Guild, key: str, value) -> None:
48
 		"""
43
 		"""
49
-		Adds the given key-value pair to the persisted state for the given
50
-		Guild. If `value` is `None` the key will be removed from persisted
51
-		state.
44
+		Updates a transient value associated with the given guild and key name.
45
+		A value of `None` removes any previous value for that key.
52
 		"""
46
 		"""
53
 		cls.set_state_values(guild, { key: value })
47
 		cls.set_state_values(guild, { key: value })
54
 
48
 
55
 	@classmethod
49
 	@classmethod
56
 	def set_state_values(cls, guild: Guild, vars: dict) -> None:
50
 	def set_state_values(cls, guild: Guild, vars: dict) -> None:
57
 		"""
51
 		"""
58
-		Merges the given `vars` dict with the saved state for the given guild
59
-		and saves it to disk. `vars` must be JSON-encodable or a ValueError will
60
-		be raised. Keys with associated values of `None` will be removed from the
61
-		state.
52
+		Merges in a set of key-value pairs into the transient state for the
53
+		given guild. Any pairs with a value of `None` will be removed from the
54
+		transient state.
62
 		"""
55
 		"""
63
 		if vars is None or len(vars) == 0:
56
 		if vars is None or len(vars) == 0:
64
 			return
57
 			return
65
 		state: dict = cls.get_state(guild)
58
 		state: dict = cls.get_state(guild)
59
+		for key, value in vars.items():
60
+			if value is None:
61
+				del state[key]
62
+			else:
63
+				state[key] = value
64
+		# XXX: Superstitious. Should update by ref already but saw weirdness once.
65
+		cls.__guild_id_to_state[guild.id] = state
66
+
67
+	# -- Persisted configuration management ---------------------------------
68
+
69
+	# discord.Guild.id -> dict
70
+	__guild_id_to_config = {}
71
+
72
+	@classmethod
73
+	def get_config(cls, guild: Guild) -> dict:
74
+		"""
75
+		Returns all persisted configuration for the given guild.
76
+		"""
77
+		config: dict = cls.__guild_id_to_config.get(guild.id)
78
+		if config is not None:
79
+			# Already in memory
80
+			return config
81
+		# Load from disk if possible
82
+		cls.__trace(f'No loaded config for guild {guild.id}. Attempting to ' +
83
+			'load from disk.')
84
+		config = cls.__read_guild_config(guild)
85
+		if config is None:
86
+			return {}
87
+		cls.__guild_id_to_config[guild.id] = config
88
+		return config
89
+
90
+	@classmethod
91
+	def get_config_value(cls, guild: Guild, key: str):
92
+		"""
93
+		Returns a persisted guild config value stored under the given key.
94
+		Returns `None` if not present.
95
+		"""
96
+		return cls.get_config(guild).get(key)
97
+
98
+	@classmethod
99
+	def set_config_value(cls, guild: Guild, key: str, value) -> None:
100
+		"""
101
+		Adds/updates the given key-value pair to the persisted config for the
102
+		given Guild. If `value` is `None` the key will be removed from persisted
103
+		config.
104
+		"""
105
+		cls.set_config_values(guild, { key: value })
106
+
107
+	@classmethod
108
+	def set_config_values(cls, guild: Guild, vars: dict) -> None:
109
+		"""
110
+		Merges the given `vars` dict with the saved config for the given guild
111
+		and writes it to disk. `vars` must be JSON-encodable or a `ValueError`
112
+		will be raised. Keys with associated values of `None` will be removed
113
+		from the persisted config.
114
+		"""
115
+		if vars is None or len(vars) == 0:
116
+			return
117
+		config: dict = cls.get_config(guild)
66
 		try:
118
 		try:
67
 			json.dumps(vars)
119
 			json.dumps(vars)
68
 		except:
120
 		except:
69
 			raise ValueError(f'vars not JSON encodable - {vars}')
121
 			raise ValueError(f'vars not JSON encodable - {vars}')
70
 		for key, value in vars.items():
122
 		for key, value in vars.items():
71
 			if value is None:
123
 			if value is None:
72
-				del state[key]
124
+				del config[key]
73
 			else:
125
 			else:
74
-				state[key] = value
75
-		cls.__save_guild_state(guild, state)
126
+				config[key] = value
127
+		cls.__write_guild_config(guild, config)
76
 
128
 
77
 	@classmethod
129
 	@classmethod
78
-	def __save_guild_state(cls, guild: Guild, state: dict) -> None:
130
+	def __write_guild_config(cls, guild: Guild, config: dict) -> None:
79
 		"""
131
 		"""
80
-		Saves state for a guild to a JSON file on disk.
132
+		Saves config for a guild to a JSON file on disk.
81
 		"""
133
 		"""
82
-		path: str = cls.__guild_path(guild)
83
-		cls.__trace(f'Saving state for guild {guild.id} to {path}')
84
-		cls.__trace(f'state = {state}')
134
+		path: str = cls.__guild_config_path(guild)
135
+		cls.__trace(f'Saving config for guild {guild.id} to {path}')
136
+		cls.__trace(f'config = {config}')
85
 		with open(path, 'w') as file:
137
 		with open(path, 'w') as file:
86
 			# Pretty printing to make more legible for debugging
138
 			# Pretty printing to make more legible for debugging
87
 			# Sorting keys to help with diffs
139
 			# Sorting keys to help with diffs
88
-			json.dump(state, file, indent='\t', sort_keys=True)
140
+			json.dump(config, file, indent='\t', sort_keys=True)
89
 		cls.__trace('State saved')
141
 		cls.__trace('State saved')
90
 
142
 
91
 	@classmethod
143
 	@classmethod
92
-	def __load_guild_state(cls, guild: Guild) -> dict:
144
+	def __read_guild_config(cls, guild: Guild) -> dict:
93
 		"""
145
 		"""
94
-		Loads state for a guild from a JSON file on disk, or None if not found.
146
+		Loads config for a guild from a JSON file on disk, or `None` if not
147
+		found.
95
 		"""
148
 		"""
96
-		path: str = cls.__guild_path(guild)
149
+		path: str = cls.__guild_config_path(guild)
97
 		if not exists(path):
150
 		if not exists(path):
98
-			cls.__trace(f'No state on disk for guild {guild.id}. Returning None.')
151
+			cls.__trace(f'No config on disk for guild {guild.id}. Returning None.')
99
 			return None
152
 			return None
100
-		cls.__trace(f'Loading state from disk for guild {guild.id}')
153
+		cls.__trace(f'Loading config from disk for guild {guild.id}')
101
 		with open(path, 'r') as file:
154
 		with open(path, 'r') as file:
102
-			state = json.load(file)
155
+			config = json.load(file)
103
 		cls.__trace('State loaded')
156
 		cls.__trace('State loaded')
104
-		return state
157
+		return config
105
 
158
 
106
 	@classmethod
159
 	@classmethod
107
-	def __guild_path(cls, guild: Guild) -> str:
160
+	def __guild_config_path(cls, guild: Guild) -> str:
108
 		"""
161
 		"""
109
-		Returns the JSON file path where guild state should be written.
162
+		Returns the JSON file path where guild config should be written.
110
 		"""
163
 		"""
111
-		config_value: str = CONFIG['statePath']
164
+		config_value: str = CONFIG['configPath']
112
 		path: str = config_value if config_value.endswith('/') else f'{config_value}/'
165
 		path: str = config_value if config_value.endswith('/') else f'{config_value}/'
113
 		return f'{path}guild_{guild.id}.json'
166
 		return f'{path}guild_{guild.id}.json'
114
 
167
 
115
 	@classmethod
168
 	@classmethod
116
 	def __trace(cls, message: str) -> None:
169
 	def __trace(cls, message: str) -> None:
117
-		print(f'{Storage.__name__}: {message}')
170
+		print(f'{cls.__name__}: {message}')

Loading…
Cancelar
Guardar