Procházet zdrojové kódy

Adding new CogSetting to make guild config easier to implement

tags/1.0.1
Rocketsoup před 4 roky
rodič
revize
1d674f0708
4 změnil soubory, kde provedl 230 přidání a 60 odebrání
  1. 171
    4
      cogs/basecog.py
  2. 55
    52
      cogs/urlspamcog.py
  3. 3
    3
      config.py.sample
  4. 1
    1
      rocketbot.py

+ 171
- 4
cogs/basecog.py Zobrazit soubor

@@ -277,9 +277,39 @@ class BotMessage:
277 277
 
278 278
 		return s
279 279
 
280
+class CogSetting:
281
+	def __init__(self,
282
+			name: str,
283
+			brief: str = None,
284
+			description: str = None,
285
+			usage: str = None,
286
+			min_value = None,
287
+			max_value = None,
288
+			enum_values: set = None):
289
+		self.name = name
290
+		self.brief = brief
291
+		self.description = description or ''  # XXX: Can't be None
292
+		self.usage = usage
293
+		self.min_value = min_value
294
+		self.max_value = max_value
295
+		self.enum_values = enum_values
296
+		if self.enum_values or self.min_value is not None or self.max_value is not None:
297
+			self.description += '\n'
298
+		if self.enum_values:
299
+			allowed_values = '`' + ('`, `'.join(enum_values)) + '`'
300
+			self.description += f'\nAllowed values: {allowed_values}'
301
+		if self.min_value is not None:
302
+			self.description += f'\nMin value: {self.min_value}'
303
+		if self.max_value is not None:
304
+			self.description += f'\nMax value: {self.max_value}'
305
+		if self.usage is None:
306
+			self.usage = f'<{self.name}>'
307
+
280 308
 class BaseCog(commands.Cog):
281 309
 	def __init__(self, bot):
282 310
 		self.bot = bot
311
+		self.are_settings_setup = False
312
+		self.settings = []
283 313
 
284 314
 	# Config
285 315
 
@@ -295,6 +325,147 @@ class BaseCog(commands.Cog):
295 325
 			return None
296 326
 		return cog.get(key)
297 327
 
328
+	def add_setting(self, setting: CogSetting) -> None:
329
+		"""
330
+		Called by a subclass in __init__ to register a mod-configurable
331
+		guild setting. A "get" and "set" command will be generated. If the cog
332
+		has a command group it will be detected automatically and the commands
333
+		added to that. Otherwise the commands will be added at the top level.
334
+		"""
335
+		self.settings.append(setting)
336
+
337
+	@classmethod
338
+	def get_guild_setting(cls,
339
+			guild: Guild,
340
+			setting: CogSetting,
341
+			use_cog_default_if_not_set: bool = True):
342
+		"""
343
+		Returns the configured value for a setting for the given guild. If no
344
+		setting is configured the default for the cog will be returned,
345
+		unless the optional `use_cog_default_if_not_set` is `False`, then
346
+		`None` will be returned.
347
+		"""
348
+		key = f'{cls.__name__}.{setting.name}'
349
+		value = Storage.get_config_value(guild, key)
350
+		if value is None and use_cog_default_if_not_set:
351
+			value = cls.get_cog_default(setting.name)
352
+		return value
353
+
354
+	@classmethod
355
+	def set_guild_setting(cls,
356
+			guild: Guild,
357
+			setting: CogSetting,
358
+			new_value) -> None:
359
+		"""
360
+		Manually sets a setting for the given guild. BaseCog creates "get" and
361
+		"set" commands for guild administrators to configure values themselves,
362
+		but this method can be used for hidden settings from code.
363
+		"""
364
+		key = f'{cls.__name__}.{setting.name}'
365
+		Storage.set_config_value(guild, key, new_value)
366
+
367
+	@commands.Cog.listener()
368
+	async def on_ready(self):
369
+		if self.are_settings_setup:
370
+			return
371
+		group: commands.core.Group = None
372
+		for member_name in dir(self):
373
+			member = getattr(self, member_name)
374
+			if isinstance(member, commands.core.Group):
375
+				group = member
376
+				break
377
+		lookup = {}
378
+		for setting in self.settings:
379
+			# Manually constructing equivalent of:
380
+			# 	@commands.command(
381
+			# 		brief='Posts a test warning in the configured warning channel.'
382
+			# 	)
383
+			# 	@commands.has_permissions(ban_members=True)
384
+			# 	@commands.guild_only()
385
+			# 	async def get/setvar(self, context, ...):
386
+			async def _set_setting(self, context, new_value):
387
+				s = lookup[context.command.name]
388
+				await self.__set_setting(context, new_value, s)
389
+			async def _get_setting(self, context):
390
+				s = lookup[context.command.name]
391
+				await self.__get_setting(context, s)
392
+
393
+			set_command = commands.Command(
394
+				_set_setting,
395
+				name=f'set{setting.name}',
396
+				brief=f'Sets {setting.brief}',
397
+				description=setting.description,
398
+				usage=setting.usage,
399
+				checks=[
400
+					commands.has_permissions(ban_members=True),
401
+					commands.guild_only(),
402
+				])
403
+			# XXX: Passing `cog` in init gets ignored and set to `None`.
404
+			set_command.cog = self
405
+			get_command = commands.Command(
406
+				_get_setting,
407
+				name=f'get{setting.name}',
408
+				brief=f'Shows {setting.brief}',
409
+				description=setting.description,
410
+				checks=[
411
+					commands.has_permissions(ban_members=True),
412
+					commands.guild_only(),
413
+				])
414
+			get_command.cog = self
415
+
416
+			if group:
417
+				group.add_command(get_command)
418
+				group.add_command(set_command)
419
+			else:
420
+				self.bot.add_command(get_command)
421
+				self.bot.add_command(set_command)
422
+
423
+			lookup[set_command.name] = setting
424
+			lookup[get_command.name] = setting
425
+		self.are_settings_setup = True
426
+
427
+	async def __set_setting(self, context, new_value, setting) -> None:
428
+		setting_name = setting.name
429
+		if context.command.parent:
430
+			setting_name = f'{context.command.parent.name}.{setting_name}'
431
+		if setting.min_value is not None and new_value < setting.min_value:
432
+			await context.message.reply(
433
+				f'{CONFIG["failure_emoji"]} `{setting_name}` must be >= {setting.min_value}',
434
+				mention_author=False)
435
+			return
436
+		if setting.max_value is not None and new_value > setting.max_value:
437
+			await context.message.reply(
438
+				f'{CONFIG["failure_emoji"]} `{setting_name}` must be <= {setting.max_value}',
439
+				mention_author=False)
440
+			return
441
+		if setting.enum_values is not None and new_value not in setting.enum_values:
442
+			allowed_values = '`' + ('`, `'.join(setting.enum_values)) + '`'
443
+			await context.message.reply(
444
+				f'{CONFIG["failure_emoji"]} `{setting_name}` must be one of {allowed_values}',
445
+				mention_author=False)
446
+			return
447
+		key = f'{self.__class__.__name__}.{setting.name}'
448
+		Storage.set_config_value(context.guild, key, new_value)
449
+		await context.message.reply(
450
+			f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
451
+			mention_author=False)
452
+
453
+	async def __get_setting(self, context, setting) -> None:
454
+		setting_name = setting.name
455
+		if context.command.parent:
456
+			setting_name = f'{context.command.parent.name}.{setting_name}'
457
+		key = f'{self.__class__.__name__}.{setting.name}'
458
+		value = Storage.get_config_value(context.guild, key)
459
+		if value is None:
460
+			value = self.get_cog_default(setting.name)
461
+			await context.message.reply(
462
+				f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`',
463
+				mention_author=False)
464
+		else:
465
+			await context.message.reply(
466
+				f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
467
+				mention_author=False)
468
+
298 469
 	# Bot message handling
299 470
 
300 471
 	@classmethod
@@ -442,7 +613,3 @@ class BaseCog(commands.Cog):
442 613
 	def log(cls, guild: Guild, message) -> None:
443 614
 		now = datetime.now()
444 615
 		print(f'[{now.strftime("%Y-%m-%dT%H:%M:%S")}|{cls.__name__}|{guild.name}] {message}')
445
-
446
-	@classmethod
447
-	def deprecated_guild_trace(cls, guild: Guild, message: str) -> None:
448
-		print(f'[guild {guild.id}|{guild.name}] {message}')

+ 55
- 52
cogs/urlspamcog.py Zobrazit soubor

@@ -3,7 +3,7 @@ from discord.ext import commands
3 3
 import re
4 4
 from datetime import timedelta
5 5
 
6
-from cogs.basecog import BaseCog, BotMessage, BotMessageReaction
6
+from cogs.basecog import BaseCog, BotMessage, BotMessageReaction, CogSetting
7 7
 from config import CONFIG
8 8
 from storage import Storage
9 9
 
@@ -15,19 +15,35 @@ class URLSpamContext:
15 15
 		self.is_banned = False
16 16
 
17 17
 class URLSpamCog(BaseCog):
18
-	CONFIG_KEY_EARLY_URL_TIMEOUT = "urlspam_early_url_timeout"
19
-	CONFIG_KEY_EARLY_URL_ACTION = "urlspam_early_url_action"
18
+	SETTING_ACTION = CogSetting('action',
19
+			brief='action to take on spam',
20
+			description='The action to take on detected URL spam.',
21
+			enum_values=set(['nothing', 'modwarn', 'delete', 'kick', 'ban']))
22
+	SETTING_JOIN_AGE = CogSetting('joinage',
23
+			brief='seconds since member joined',
24
+			description='The minimum seconds since the user joined the ' + \
25
+				'server before they can post URLs. URLs posted by users ' + \
26
+				'who joined too recently will be flagged. Keep in mind ' + \
27
+				'many servers have a minimum 10 minute cooldown before ' + \
28
+				'new members can say anything. Setting to 0 effectively ' + \
29
+				'disables URL spam detection.',
30
+			usage='<seconds:int>',
31
+			min_value=0)
20 32
 
21 33
 	def __init__(self, bot):
22 34
 		super().__init__(bot)
35
+		self.add_setting(URLSpamCog.SETTING_ACTION)
36
+		self.add_setting(URLSpamCog.SETTING_JOIN_AGE)
23 37
 
24
-	def __early_url_timeout(self, guild: Guild) -> int:
25
-		return Storage.get_config_value(guild, self.CONFIG_KEY_EARLY_URL_TIMEOUT) or \
26
-			self.get_cog_default('early_url_timeout')
27
-
28
-	def __early_url_action(self, guild: Guild) -> str:
29
-		return Storage.get_config_value(guild, self.CONFIG_KEY_EARLY_URL_ACTION) or \
30
-			self.get_cog_default('early_url_action')
38
+	@commands.group(
39
+		brief='Manages URL spam detection',
40
+	)
41
+	@commands.has_permissions(ban_members=True)
42
+	@commands.guild_only()
43
+	async def urlspam(self, context: commands.Context):
44
+		'Command group'
45
+		if context.invoked_subcommand is None:
46
+			await context.send_help()
31 47
 
32 48
 	@commands.Cog.listener()
33 49
 	async def on_message(self, message: Message):
@@ -38,69 +54,54 @@ class URLSpamCog(BaseCog):
38 54
 				message.content is None:
39 55
 			return
40 56
 
41
-		action = self.__early_url_action(message.guild)
57
+		action = self.get_guild_setting(message.guild, self.SETTING_ACTION)
58
+		min_join_age = timedelta(seconds=self.get_guild_setting(message.guild, self.SETTING_JOIN_AGE))
42 59
 		if action == 'nothing':
43 60
 			return
44 61
 		if not self.__contains_url(message.content):
45 62
 			return
46 63
 		join_age = message.created_at - message.author.joined_at
47 64
 		join_age_str = self.__format_timedelta(join_age)
48
-		if join_age.total_seconds() < self.__early_url_timeout(message.guild):
65
+		if join_age < min_join_age:
66
+			context = URLSpamContext(message)
67
+			needs_attention = False
49 68
 			if action == 'modwarn':
50
-				bm = BotMessage(
51
-					message.guild,
52
-					f'User {message.author.mention} posted a URL ' + \
53
-					f'{join_age_str} after joining.',
54
-					type = BotMessage.TYPE_MOD_WARNING,
55
-					context = URLSpamContext(message))
56
-				bm.quote = message.content
57
-				await bm.add_reaction(BotMessageReaction(CONFIG['trash_emoji'], True, 'Delete message'))
58
-				await bm.add_reaction(BotMessageReaction(CONFIG['kick_emoji'], True, 'Kick user'))
59
-				await bm.add_reaction(BotMessageReaction(CONFIG['ban_emoji'], True, 'Ban user'))
60
-				await self.post_message(bm)
69
+				needs_attention = True
61 70
 				self.log(message.guild, f'New user {message.author.name} ' + \
62 71
 					f'({message.author.id}) posted URL. Mods alerted.')
63 72
 			elif action == 'delete':
64 73
 				await message.delete()
65
-				bm = BotMessage(
66
-					message.guild,
67
-					f'User {message.author.mention} posted a URL ' + \
68
-					f'{join_age_str} after joining. Message deleted.',
69
-					type = BotMessage.TYPE_INFO,
70
-					context = URLSpamContext(message))
71
-				bm.quote = message.content
72
-				await bm.add_reaction(BotMessageReaction(CONFIG['kick_emoji'], True, 'Kick user'))
73
-				await bm.add_reaction(BotMessageReaction(CONFIG['ban_emoji'], True, 'Ban user'))
74
-				await self.post_message(bm)
74
+				context.is_deleted = True
75 75
 				self.log(message.guild, f'New user {message.author.name} ' + \
76 76
 					f'({message.author.id}) posted URL. Message deleted.')
77 77
 			elif action == 'kick':
78
+				await message.delete()
79
+				context.is_deleted = True
78 80
 				await message.author.kick(reason='User posted a link ' + \
79 81
 					f'{join_age_str} after joining')
80
-				bm = BotMessage(
81
-					message.guild,
82
-					f'User {message.author.mention} posted a URL ' + \
83
-					f'{join_age_str} after joining. Kicked by bot.',
84
-					type = BotMessage.TYPE_INFO,
85
-					context = URLSpamContext(message))
86
-				bm.quote = message.content
87
-				await bm.add_reaction(BotMessageReaction(CONFIG['ban_emoji'], True, 'Ban user'))
88
-				await self.post_message(bm)
82
+				context.is_kicked = True
89 83
 				self.log(message.guild, f'New user {message.author.name} ' + \
90 84
 					f'({message.author.id}) posted URL. User kicked.')
91 85
 			elif action == 'ban':
92 86
 				await message.author.ban(reason='User posted a link ' + \
93 87
 					f'{join_age_str} after joining', delete_message_days=1)
94
-				bm = BotMessage(
95
-					message.guild,
96
-					f'User {message.author.mention} posted a URL ' + \
97
-					f'{join_age_str} after joining. Banned by bot.',
98
-					type = BotMessage.TYPE_INFO,
99
-					context = URLSpamContext(message))
100
-				bm.quote = message.content
101
-				await self.post_message(bm)
88
+				context.is_deleted = True
89
+				context.is_kicked = True
90
+				context.is_banned = True
102 91
 				self.log(message.guild, f'New user {message.author.name} ' + \
103 92
 					f'({message.author.id}) posted URL. User banned.')
93
+			bm = BotMessage(
94
+					message.guild,
95
+					f'User {message.author.mention} posted a URL ' + \
96
+					f'{join_age_str} after joining.',
97
+					type = BotMessage.TYPE_MOD_WARNING if needs_attention else BotMessage.TYPE_INFO,
98
+					context = context)
99
+			bm.quote = message.content
100
+			await bm.set_reactions(BotMessageReaction.standard_set(
101
+				did_delete=context.is_deleted,
102
+				did_kick=context.is_kicked,
103
+				did_ban=context.is_banned))
104
+			await self.post_message(bm)
104 105
 
105 106
 	async def on_mod_react(self,
106 107
 			bot_message: BotMessage,
@@ -137,11 +138,13 @@ class URLSpamCog(BaseCog):
137 138
 			did_kick=context.is_kicked,
138 139
 			did_ban=context.is_banned))
139 140
 
140
-	def __contains_url(self, text: str) -> bool:
141
+	@classmethod
142
+	def __contains_url(cls, text: str) -> bool:
141 143
 		p = re.compile(r'http(?:s)?://[^\s]+')
142 144
 		return p.search(text) is not None
143 145
 
144
-	def __format_timedelta(self, timespan: timedelta) -> str:
146
+	@classmethod
147
+	def __format_timedelta(cls, timespan: timedelta) -> str:
145 148
 		parts = []
146 149
 		d = timespan.days
147 150
 		h = timespan.seconds // 3600

+ 3
- 3
config.py.sample Zobrazit soubor

@@ -1,6 +1,6 @@
1 1
 # Copy this file to config.py and fill in necessary values
2 2
 CONFIG = {
3
-	'__config_version': 1,
3
+	'__config_version': 2,
4 4
 	'client_token': 'token',
5 5
 	'command_prefix': '$rb_',
6 6
 	'kick_emoji': '👢',
@@ -24,8 +24,8 @@ CONFIG = {
24 24
 			'min_message_length': 0,
25 25
 		},
26 26
 		'URLSpamCog': {
27
-			'early_url_timeout': 900,  # Should be > 10m due to Discord-imposed waiting period
28
-			'early_url_action': 'modwarn', # "nothing" | "modwarn" | "delete" | "kick" | "ban"
27
+			'joinage': 900,  # Should be > 600 due to Discord-imposed waiting period
28
+			'action': 'nothing',  # "nothing" | "modwarn" | "delete" | "kick" | "ban"
29 29
 		},
30 30
 	},
31 31
 }

+ 1
- 1
rocketbot.py Zobrazit soubor

@@ -16,7 +16,7 @@ from cogs.joinraidcog import JoinRaidCog
16 16
 from cogs.patterncog import PatternCog
17 17
 from cogs.urlspamcog import URLSpamCog
18 18
 
19
-CURRENT_CONFIG_VERSION = 1
19
+CURRENT_CONFIG_VERSION = 2
20 20
 if (CONFIG.get('__config_version') or 0) < CURRENT_CONFIG_VERSION:
21 21
     raise RuntimeError('config.py format may be outdated. Review ' +
22 22
         'config.py.sample, update the "__config_version" field to ' +

Načítá se…
Zrušit
Uložit