Bladeren bron

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

tags/1.0.1
Rocketsoup 4 jaren geleden
bovenliggende
commit
26d47aae52
7 gewijzigde bestanden met toevoegingen van 173 en 65 verwijderingen
  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 Bestand weergeven

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

+ 5
- 5
cogs/configcog.py Bestand weergeven

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

+ 59
- 0
cogs/crosspostcog.py Bestand weergeven

@@ -1,2 +1,61 @@
1
+from discord import Guild, Message
1 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 Bestand weergeven

@@ -1,6 +1,6 @@
1 1
 from discord.ext import commands
2 2
 from cogs.basecog import BaseCog
3
-from storage import StateKey, Storage
3
+from storage import ConfigKey, Storage
4 4
 
5 5
 class GeneralCog(BaseCog):
6 6
 	def __init__(self, bot: commands.Bot):
@@ -24,7 +24,7 @@ class GeneralCog(BaseCog):
24 24
 	@commands.has_permissions(ban_members=True)
25 25
 	@commands.guild_only()
26 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 28
 			await context.message.reply(
29 29
 				'No warning channel set!',
30 30
 				mention_author=False)

+ 6
- 6
cogs/joinraidcog.py Bestand weergeven

@@ -253,9 +253,9 @@ class JoinRaidCog(BaseCog):
253 253
         """
254 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 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 259
             or CONFIG['joinWarningSeconds']
260 260
         return (count, seconds)
261 261
 
@@ -263,7 +263,7 @@ class JoinRaidCog(BaseCog):
263 263
         """
264 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 268
     # -- Commands -----------------------------------------------------------
269 269
 
@@ -285,7 +285,7 @@ class JoinRaidCog(BaseCog):
285 285
     async def joinraid_enable(self, context: commands.Context):
286 286
         'Command handler'
287 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 289
         # TODO: Startup tracking if necessary
290 290
         await context.message.reply(
291 291
             '✅ ' + self.__describe_raid_settings(guild, force_enabled_status=True),
@@ -299,7 +299,7 @@ class JoinRaidCog(BaseCog):
299 299
     async def joinraid_disable(self, context: commands.Context):
300 300
         'Command handler'
301 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 303
         # TODO: Tear down tracking if necessary
304 304
         await context.message.reply(
305 305
             '✅ ' + self.__describe_raid_settings(guild, force_enabled_status=True),
@@ -330,7 +330,7 @@ class JoinRaidCog(BaseCog):
330 330
                 f'⚠️ `seconds` must be > 0',
331 331
                 mention_author=False)
332 332
             return
333
-        Storage.set_state_values(guild, {
333
+        Storage.set_config_values(guild, {
334 334
             self.STATE_KEY_RAID_COUNT: join_count,
335 335
             self.STATE_KEY_RAID_SECONDS: seconds,
336 336
         })

+ 1
- 5
config.py.sample Bestand weergeven

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

+ 95
- 42
storage.py Bestand weergeven

@@ -5,113 +5,166 @@ from discord import Guild
5 5
 
6 6
 from config import CONFIG
7 7
 
8
-class StateKey:
8
+class ConfigKey:
9 9
 	WARNING_CHANNEL_ID = 'warning_channel_id'
10 10
 	WARNING_MENTION = 'warning_mention'
11 11
 
12 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 20
 	__guild_id_to_state = {}
19 21
 
20 22
 	@classmethod
21 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 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 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 32
 		return state
37 33
 
38 34
 	@classmethod
39 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 39
 		return cls.get_state(guild).get(key)
45 40
 
46 41
 	@classmethod
47 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 47
 		cls.set_state_values(guild, { key: value })
54 48
 
55 49
 	@classmethod
56 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 56
 		if vars is None or len(vars) == 0:
64 57
 			return
65 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 118
 		try:
67 119
 			json.dumps(vars)
68 120
 		except:
69 121
 			raise ValueError(f'vars not JSON encodable - {vars}')
70 122
 		for key, value in vars.items():
71 123
 			if value is None:
72
-				del state[key]
124
+				del config[key]
73 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 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 137
 		with open(path, 'w') as file:
86 138
 			# Pretty printing to make more legible for debugging
87 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 141
 		cls.__trace('State saved')
90 142
 
91 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 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 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 154
 		with open(path, 'r') as file:
102
-			state = json.load(file)
155
+			config = json.load(file)
103 156
 		cls.__trace('State loaded')
104
-		return state
157
+		return config
105 158
 
106 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 165
 		path: str = config_value if config_value.endswith('/') else f'{config_value}/'
113 166
 		return f'{path}guild_{guild.id}.json'
114 167
 
115 168
 	@classmethod
116 169
 	def __trace(cls, message: str) -> None:
117
-		print(f'{Storage.__name__}: {message}')
170
+		print(f'{cls.__name__}: {message}')

Laden…
Annuleren
Opslaan