Bladeren bron

Moving BotMessage and CogSetting to separate files

master
Rocketsoup 4 jaren geleden
bovenliggende
commit
3d2a1df391
6 gewijzigde bestanden met toevoegingen van 565 en 550 verwijderingen
  1. 1
    1
      .gitignore
  2. 297
    0
      rocketbot/botmessage.py
  3. 10
    544
      rocketbot/cogs/basecog.py
  4. 229
    0
      rocketbot/cogsetting.py
  5. 4
    4
      rocketbot/collections.py
  6. 24
    1
      rocketbot/utils.py

+ 1
- 1
.gitignore Bestand weergeven

@@ -119,4 +119,4 @@ dmypy.json
119 119
 
120 120
 # Rocketbot stuff
121 121
 /config.py
122
-/state/**
122
+/config/**

+ 297
- 0
rocketbot/botmessage.py Bestand weergeven

@@ -0,0 +1,297 @@
1
+"""
2
+Classes for crafting messages from the bot. Content can change as information
3
+changes, and mods can perform actions on the message via emoji reactions.
4
+"""
5
+from datetime import datetime
6
+from discord import Guild, Message, PartialEmoji, TextChannel
7
+
8
+from config import CONFIG
9
+from rocketbot.storage import ConfigKey, Storage
10
+from rocketbot.utils import bot_log
11
+
12
+class BotMessageReaction:
13
+	"""
14
+	A possible reaction to a bot message that will trigger an action. The list
15
+	of available reactions will be listed at the end of a BotMessage. When a
16
+	mod reacts to the message with the emote, something can happen.
17
+
18
+	If the reaction is disabled, reactions will not register. The description
19
+	will still show up in the message, but no emoji is shown. This can be used
20
+	to explain why an action is no longer available.
21
+	"""
22
+	def __init__(self, emoji: str, is_enabled: bool, description: str):
23
+		self.emoji = emoji
24
+		self.is_enabled = is_enabled
25
+		self.description = description
26
+
27
+	def __eq__(self, other):
28
+		return other is not None and \
29
+			other.emoji == self.emoji and \
30
+			other.is_enabled == self.is_enabled and \
31
+			other.description == self.description
32
+
33
+	@classmethod
34
+	def standard_set(cls,
35
+			did_delete: bool = None,
36
+			message_count: int = 1,
37
+			did_kick: bool = None,
38
+			did_ban: bool = None,
39
+			user_count: int = 1) -> list:
40
+		"""
41
+		Convenience factory for generating any of the three most common
42
+		commands: delete message(s), kick user(s), and ban user(s). All
43
+		arguments are optional. Resulting list can be passed directly to
44
+		`BotMessage.set_reactions()`.
45
+
46
+		Params
47
+		- did_delete     Whether the message(s) have been deleted. Pass True or
48
+		                 False if this applies, omit to leave out delete action.
49
+		- message_count  How many messages there are. Used for pluralizing
50
+		                 description. Defaults to 1. Omit if n/a.
51
+		- did_kick       Whether the user(s) have been kicked. Pass True or
52
+		                 False if this applies, omit to leave out kick action.
53
+		- did_ban        Whether the user(s) have been banned. Pass True or
54
+		                 False if this applies, omit to leave out ban action.
55
+		- user_count     How many users there are. Used for pluralizing
56
+		                 description. Defaults to 1. Omit if n/a.
57
+		"""
58
+		reactions = []
59
+		if did_delete is not None:
60
+			if did_delete:
61
+				reactions.append(BotMessageReaction(
62
+					CONFIG['trash_emoji'],
63
+					False,
64
+					'Message deleted' if message_count == 1 else 'Messages deleted'))
65
+			else:
66
+				reactions.append(BotMessageReaction(
67
+					CONFIG['trash_emoji'],
68
+					True,
69
+					'Delete message' if message_count == 1 else 'Delete messages'))
70
+		if did_kick is not None:
71
+			if did_ban is not None and did_ban:
72
+				# Don't show kick option at all if we also banned
73
+				pass
74
+			elif did_kick:
75
+				reactions.append(BotMessageReaction(
76
+					CONFIG['kick_emoji'],
77
+					False,
78
+					'User kicked' if user_count == 1 else 'Users kicked'))
79
+			else:
80
+				reactions.append(BotMessageReaction(
81
+					CONFIG['kick_emoji'],
82
+					True,
83
+					'Kick user' if user_count == 1 else 'Kick users'))
84
+		if did_ban is not None:
85
+			if did_ban:
86
+				reactions.append(BotMessageReaction(
87
+					CONFIG['ban_emoji'],
88
+					False,
89
+					'User banned' if user_count == 1 else 'Users banned'))
90
+			else:
91
+				reactions.append(BotMessageReaction(
92
+					CONFIG['ban_emoji'],
93
+					True,
94
+					'Ban user' if user_count == 1 else 'Ban users'))
95
+		return reactions
96
+
97
+class BotMessage:
98
+	"""
99
+	Holds state for a bot-generated message. A message is composed, sent via
100
+	`BaseCog.post_message()`, and can later be updated.
101
+
102
+	A message consists of a type (e.g. info, warning), text, optional quoted
103
+	text (such as the content of a flagged message), and an optional list of
104
+	actions that can be taken via a mod reacting to the message.
105
+	"""
106
+
107
+	TYPE_DEFAULT = 0
108
+	TYPE_INFO = 1
109
+	TYPE_MOD_WARNING = 2
110
+	TYPE_SUCCESS = 3
111
+	TYPE_FAILURE = 4
112
+
113
+	def __init__(self,
114
+			guild: Guild,
115
+			text: str,
116
+			type: int = TYPE_DEFAULT, # pylint: disable=redefined-builtin
117
+			context = None,
118
+			reply_to: Message = None):
119
+		self.guild = guild
120
+		self.text = text
121
+		self.type = type
122
+		self.context = context
123
+		self.quote = None
124
+		self.source_cog = None  # Set by `BaseCog.post_message()`
125
+		self.__posted_text = None  # last text posted, to test for changes
126
+		self.__posted_emoji = set()
127
+		self.__message = None  # Message
128
+		self.__reply_to = reply_to
129
+		self.__reactions = []  # BotMessageReaction[]
130
+
131
+	def is_sent(self) -> bool:
132
+		"""
133
+		Returns whether this message has been sent to the guild. This may
134
+		continue returning False even after calling BaseCog.post_message if
135
+		the guild has no configured warning channel.
136
+		"""
137
+		return self.__message is not None
138
+
139
+	def message_id(self):
140
+		'Returns the Message id or None if not sent.'
141
+		return self.__message.id if self.__message else None
142
+
143
+	def message_sent_at(self) -> datetime:
144
+		'Returns when the message was sent or None if not sent.'
145
+		return self.__message.created_at if self.__message else None
146
+
147
+	def has_reactions(self) -> bool:
148
+		'Whether this message has any reactions defined.'
149
+		return len(self.__reactions) > 0
150
+
151
+	async def set_text(self, new_text: str) -> None:
152
+		"""
153
+		Replaces the text of this message. If the message has been sent, it will
154
+		be updated.
155
+		"""
156
+		self.text = new_text
157
+		await self.update_if_sent()
158
+
159
+	async def set_reactions(self, reactions: list) -> None:
160
+		"""
161
+		Replaces all BotMessageReactions with a new list. If the message has
162
+		been sent, it will be updated.
163
+		"""
164
+		if reactions == self.__reactions:
165
+			# No change
166
+			return
167
+		self.__reactions = reactions.copy() if reactions is not None else []
168
+		await self.update_if_sent()
169
+
170
+	async def add_reaction(self, reaction: BotMessageReaction) -> None:
171
+		"""
172
+		Adds one BotMessageReaction to this message. If a reaction already
173
+		exists for the given emoji it is replaced with the new one. If the
174
+		message has been sent, it will be updated.
175
+		"""
176
+		# Alias for update. Makes for clearer intent.
177
+		await self.update_reaction(reaction)
178
+
179
+	async def update_reaction(self, reaction: BotMessageReaction) -> None:
180
+		"""
181
+		Updates or adds a BotMessageReaction. If the message has been sent, it
182
+		will be updated.
183
+		"""
184
+		found = False
185
+		for i, existing in enumerate(self.__reactions):
186
+			if existing.emoji == reaction.emoji:
187
+				if reaction == self.__reactions[i]:
188
+					# No change
189
+					return
190
+				self.__reactions[i] = reaction
191
+				found = True
192
+				break
193
+		if not found:
194
+			self.__reactions.append(reaction)
195
+		await self.update_if_sent()
196
+
197
+	async def remove_reaction(self, reaction_or_emoji) -> None:
198
+		"""
199
+		Removes a reaction. Can pass either a BotMessageReaction or just the
200
+		emoji string. If the message has been sent, it will be updated.
201
+		"""
202
+		for i, existing in enumerate(self.__reactions):
203
+			if (isinstance(reaction_or_emoji, str) and existing.emoji == reaction_or_emoji) or \
204
+				(isinstance(reaction_or_emoji, BotMessageReaction) and \
205
+					existing.emoji == reaction_or_emoji.emoji):
206
+				self.__reactions.pop(i)
207
+				await self.update_if_sent()
208
+				return
209
+
210
+	def reaction_for_emoji(self, emoji) -> BotMessageReaction:
211
+		"""
212
+		Finds the BotMessageReaction for the given emoji or None if not found.
213
+		Accepts either a PartialEmoji or str.
214
+		"""
215
+		for reaction in self.__reactions:
216
+			if isinstance(emoji, PartialEmoji) and reaction.emoji == emoji.name:
217
+				return reaction
218
+			if isinstance(emoji, str) and reaction.emoji == emoji:
219
+				return reaction
220
+		return None
221
+
222
+	async def update_if_sent(self) -> None:
223
+		"""
224
+		Updates the text and/or reactions on a message if it was sent to
225
+		the guild, otherwise does nothing. Does not need to be called by
226
+		BaseCog subclasses.
227
+		"""
228
+		if self.__message:
229
+			await self.update()
230
+
231
+	async def update(self) -> None:
232
+		"""
233
+		Sends or updates an already sent message based on BotMessage state.
234
+		Does not need to be called by BaseCog subclasses.
235
+		"""
236
+		content: str = self.__formatted_message()
237
+		if self.__message:
238
+			if content != self.__posted_text:
239
+				await self.__message.edit(content=content)
240
+				self.__posted_text = content
241
+		else:
242
+			if self.__reply_to:
243
+				self.__message = await self.__reply_to.reply(content=content, mention_author=False)
244
+				self.__posted_text = content
245
+			else:
246
+				channel_id = Storage.get_config_value(self.guild, ConfigKey.WARNING_CHANNEL_ID)
247
+				if channel_id is None:
248
+					bot_log(self.guild, None, '\u0007No warning channel set! No warning issued.')
249
+					return
250
+				channel: TextChannel = self.guild.get_channel(channel_id)
251
+				if channel is None:
252
+					bot_log(self.guild, None, '\u0007Configured warning channel does not exist!')
253
+					return
254
+				self.__message = await channel.send(content=content)
255
+				self.__posted_text = content
256
+		emoji_to_remove = self.__posted_emoji.copy()
257
+		for reaction in self.__reactions:
258
+			if reaction.is_enabled:
259
+				if reaction.emoji not in self.__posted_emoji:
260
+					await self.__message.add_reaction(reaction.emoji)
261
+					self.__posted_emoji.add(reaction.emoji)
262
+				if reaction.emoji in emoji_to_remove:
263
+					emoji_to_remove.remove(reaction.emoji)
264
+		for emoji in emoji_to_remove:
265
+			await self.__message.clear_reaction(emoji)
266
+			if emoji in self.__posted_emoji:
267
+				self.__posted_emoji.remove(emoji)
268
+
269
+	def __formatted_message(self) -> str:
270
+		s: str = ''
271
+
272
+		if self.type == self.TYPE_INFO:
273
+			s += CONFIG['info_emoji'] + ' '
274
+		elif self.type == self.TYPE_MOD_WARNING:
275
+			mention: str = Storage.get_config_value(self.guild, ConfigKey.WARNING_MENTION)
276
+			if mention:
277
+				s += mention + ' '
278
+			s += CONFIG['warning_emoji'] + ' '
279
+		elif self.type == self.TYPE_SUCCESS:
280
+			s += CONFIG['success_emoji'] + ' '
281
+		elif self.type == self.TYPE_FAILURE:
282
+			s += CONFIG['failure_emoji'] + ' '
283
+
284
+		s += self.text
285
+
286
+		if self.quote:
287
+			s += f'\n\n> {self.quote}'
288
+
289
+		if len(self.__reactions) > 0:
290
+			s += '\n\nAvailable actions:'
291
+			for reaction in self.__reactions:
292
+				if reaction.is_enabled:
293
+					s += f'\n     {reaction.emoji} {reaction.description}'
294
+				else:
295
+					s += f'\n     {reaction.description}'
296
+
297
+		return s

+ 10
- 544
rocketbot/cogs/basecog.py Bestand weergeven

@@ -2,353 +2,16 @@
2 2
 Base cog class and helper classes.
3 3
 """
4 4
 from datetime import datetime, timedelta
5
-from discord import Guild, Member, Message, PartialEmoji, RawReactionActionEvent, TextChannel
5
+from discord import Guild, Member, Message, RawReactionActionEvent
6 6
 from discord.abc import GuildChannel
7 7
 from discord.ext import commands
8 8
 
9 9
 from config import CONFIG
10
+from rocketbot.botmessage import BotMessage, BotMessageReaction
11
+from rocketbot.cogsetting import CogSetting
10 12
 from rocketbot.collections import AgeBoundDict
11
-from rocketbot.storage import ConfigKey, Storage
12
-
13
-class BotMessageReaction:
14
-	"""
15
-	A possible reaction to a bot message that will trigger an action. The list
16
-	of available reactions will be listed at the end of a BotMessage. When a
17
-	mod reacts to the message with the emote, something can happen.
18
-
19
-	If the reaction is disabled, reactions will not register. The description
20
-	will still show up in the message, but no emoji is shown. This can be used
21
-	to explain why an action is no longer available.
22
-	"""
23
-	def __init__(self, emoji: str, is_enabled: bool, description: str):
24
-		self.emoji = emoji
25
-		self.is_enabled = is_enabled
26
-		self.description = description
27
-
28
-	def __eq__(self, other):
29
-		return other is not None and \
30
-			other.emoji == self.emoji and \
31
-			other.is_enabled == self.is_enabled and \
32
-			other.description == self.description
33
-
34
-	@classmethod
35
-	def standard_set(cls,
36
-			did_delete: bool = None,
37
-			message_count: int = 1,
38
-			did_kick: bool = None,
39
-			did_ban: bool = None,
40
-			user_count: int = 1) -> list:
41
-		"""
42
-		Convenience factory for generating any of the three most common
43
-		commands: delete message(s), kick user(s), and ban user(s). All
44
-		arguments are optional. Resulting list can be passed directly to
45
-		`BotMessage.set_reactions()`.
46
-
47
-		Params
48
-		- did_delete     Whether the message(s) have been deleted. Pass True or
49
-		                 False if this applies, omit to leave out delete action.
50
-		- message_count  How many messages there are. Used for pluralizing
51
-		                 description. Defaults to 1. Omit if n/a.
52
-		- did_kick       Whether the user(s) have been kicked. Pass True or
53
-		                 False if this applies, omit to leave out kick action.
54
-		- did_ban        Whether the user(s) have been banned. Pass True or
55
-		                 False if this applies, omit to leave out ban action.
56
-		- user_count     How many users there are. Used for pluralizing
57
-		                 description. Defaults to 1. Omit if n/a.
58
-		"""
59
-		reactions = []
60
-		if did_delete is not None:
61
-			if did_delete:
62
-				reactions.append(BotMessageReaction(
63
-					CONFIG['trash_emoji'],
64
-					False,
65
-					'Message deleted' if message_count == 1 else 'Messages deleted'))
66
-			else:
67
-				reactions.append(BotMessageReaction(
68
-					CONFIG['trash_emoji'],
69
-					True,
70
-					'Delete message' if message_count == 1 else 'Delete messages'))
71
-		if did_kick is not None:
72
-			if did_ban is not None and did_ban:
73
-				# Don't show kick option at all if we also banned
74
-				pass
75
-			elif did_kick:
76
-				reactions.append(BotMessageReaction(
77
-					CONFIG['kick_emoji'],
78
-					False,
79
-					'User kicked' if user_count == 1 else 'Users kicked'))
80
-			else:
81
-				reactions.append(BotMessageReaction(
82
-					CONFIG['kick_emoji'],
83
-					True,
84
-					'Kick user' if user_count == 1 else 'Kick users'))
85
-		if did_ban is not None:
86
-			if did_ban:
87
-				reactions.append(BotMessageReaction(
88
-					CONFIG['ban_emoji'],
89
-					False,
90
-					'User banned' if user_count == 1 else 'Users banned'))
91
-			else:
92
-				reactions.append(BotMessageReaction(
93
-					CONFIG['ban_emoji'],
94
-					True,
95
-					'Ban user' if user_count == 1 else 'Ban users'))
96
-		return reactions
97
-
98
-class BotMessage:
99
-	"""
100
-	Holds state for a bot-generated message. A message is composed, sent via
101
-	`BaseCog.post_message()`, and can later be updated.
102
-
103
-	A message consists of a type (e.g. info, warning), text, optional quoted
104
-	text (such as the content of a flagged message), and an optional list of
105
-	actions that can be taken via a mod reacting to the message.
106
-	"""
107
-
108
-	TYPE_DEFAULT = 0
109
-	TYPE_INFO = 1
110
-	TYPE_MOD_WARNING = 2
111
-	TYPE_SUCCESS = 3
112
-	TYPE_FAILURE = 4
113
-
114
-	def __init__(self,
115
-			guild: Guild,
116
-			text: str,
117
-			type: int = TYPE_DEFAULT,
118
-			context = None,
119
-			reply_to: Message = None):
120
-		self.guild = guild
121
-		self.text = text
122
-		self.type = type
123
-		self.context = context
124
-		self.quote = None
125
-		self.source_cog = None  # Set by `BaseCog.post_message()`
126
-		self.__posted_text = None  # last text posted, to test for changes
127
-		self.__posted_emoji = set()
128
-		self.__message = None  # Message
129
-		self.__reply_to = reply_to
130
-		self.__reactions = []  # BotMessageReaction[]
131
-
132
-	def is_sent(self) -> bool:
133
-		"""
134
-		Returns whether this message has been sent to the guild. This may
135
-		continue returning False even after calling BaseCog.post_message if
136
-		the guild has no configured warning channel.
137
-		"""
138
-		return self.__message is not None
139
-
140
-	def message_id(self):
141
-		'Returns the Message id or None if not sent.'
142
-		return self.__message.id if self.__message else None
143
-
144
-	def message_sent_at(self) -> datetime:
145
-		'Returns when the message was sent or None if not sent.'
146
-		return self.__message.created_at if self.__message else None
147
-
148
-	def has_reactions(self) -> bool:
149
-		'Whether this message has any reactions defined.'
150
-		return len(self.__reactions) > 0
151
-
152
-	async def set_text(self, new_text: str) -> None:
153
-		"""
154
-		Replaces the text of this message. If the message has been sent, it will
155
-		be updated.
156
-		"""
157
-		self.text = new_text
158
-		await self.update_if_sent()
159
-
160
-	async def set_reactions(self, reactions: list) -> None:
161
-		"""
162
-		Replaces all BotMessageReactions with a new list. If the message has
163
-		been sent, it will be updated.
164
-		"""
165
-		if reactions == self.__reactions:
166
-			# No change
167
-			return
168
-		self.__reactions = reactions.copy() if reactions is not None else []
169
-		await self.update_if_sent()
170
-
171
-	async def add_reaction(self, reaction: BotMessageReaction) -> None:
172
-		"""
173
-		Adds one BotMessageReaction to this message. If a reaction already
174
-		exists for the given emoji it is replaced with the new one. If the
175
-		message has been sent, it will be updated.
176
-		"""
177
-		# Alias for update. Makes for clearer intent.
178
-		await self.update_reaction(reaction)
179
-
180
-	async def update_reaction(self, reaction: BotMessageReaction) -> None:
181
-		"""
182
-		Updates or adds a BotMessageReaction. If the message has been sent, it
183
-		will be updated.
184
-		"""
185
-		found = False
186
-		for i, existing in enumerate(self.__reactions):
187
-			if existing.emoji == reaction.emoji:
188
-				if reaction == self.__reactions[i]:
189
-					# No change
190
-					return
191
-				self.__reactions[i] = reaction
192
-				found = True
193
-				break
194
-		if not found:
195
-			self.__reactions.append(reaction)
196
-		await self.update_if_sent()
197
-
198
-	async def remove_reaction(self, reaction_or_emoji) -> None:
199
-		"""
200
-		Removes a reaction. Can pass either a BotMessageReaction or just the
201
-		emoji string. If the message has been sent, it will be updated.
202
-		"""
203
-		for i, existing in enumerate(self.__reactions):
204
-			if (isinstance(reaction_or_emoji, str) and existing.emoji == reaction_or_emoji) or \
205
-				(isinstance(reaction_or_emoji, BotMessageReaction) and \
206
-					existing.emoji == reaction_or_emoji.emoji):
207
-				self.__reactions.pop(i)
208
-				await self.update_if_sent()
209
-				return
210
-
211
-	def reaction_for_emoji(self, emoji) -> BotMessageReaction:
212
-		"""
213
-		Finds the BotMessageReaction for the given emoji or None if not found.
214
-		Accepts either a PartialEmoji or str.
215
-		"""
216
-		for reaction in self.__reactions:
217
-			if isinstance(emoji, PartialEmoji) and reaction.emoji == emoji.name:
218
-				return reaction
219
-			if isinstance(emoji, str) and reaction.emoji == emoji:
220
-				return reaction
221
-		return None
222
-
223
-	async def update_if_sent(self) -> None:
224
-		"""
225
-		Updates the text and/or reactions on a message if it was sent to
226
-		the guild, otherwise does nothing. Does not need to be called by
227
-		BaseCog subclasses.
228
-		"""
229
-		if self.__message:
230
-			await self.update()
231
-
232
-	async def update(self) -> None:
233
-		"""
234
-		Sends or updates an already sent message based on BotMessage state.
235
-		Does not need to be called by BaseCog subclasses.
236
-		"""
237
-		content: str = self.__formatted_message()
238
-		if self.__message:
239
-			if content != self.__posted_text:
240
-				await self.__message.edit(content=content)
241
-				self.__posted_text = content
242
-		else:
243
-			if self.__reply_to:
244
-				self.__message = await self.__reply_to.reply(content=content, mention_author=False)
245
-				self.__posted_text = content
246
-			else:
247
-				channel_id = Storage.get_config_value(self.guild, ConfigKey.WARNING_CHANNEL_ID)
248
-				if channel_id is None:
249
-					BaseCog.log(self.guild, '\u0007No warning channel set! No warning issued.')
250
-					return
251
-				channel: TextChannel = self.guild.get_channel(channel_id)
252
-				if channel is None:
253
-					BaseCog.log(self.guild, '\u0007Configured warning channel does not exist!')
254
-					return
255
-				self.__message = await channel.send(content=content)
256
-				self.__posted_text = content
257
-		emoji_to_remove = self.__posted_emoji.copy()
258
-		for reaction in self.__reactions:
259
-			if reaction.is_enabled:
260
-				if reaction.emoji not in self.__posted_emoji:
261
-					await self.__message.add_reaction(reaction.emoji)
262
-					self.__posted_emoji.add(reaction.emoji)
263
-				if reaction.emoji in emoji_to_remove:
264
-					emoji_to_remove.remove(reaction.emoji)
265
-		for emoji in emoji_to_remove:
266
-			await self.__message.clear_reaction(emoji)
267
-			if emoji in self.__posted_emoji:
268
-				self.__posted_emoji.remove(emoji)
269
-
270
-	def __formatted_message(self) -> str:
271
-		s: str = ''
272
-
273
-		if self.type == self.TYPE_INFO:
274
-			s += CONFIG['info_emoji'] + ' '
275
-		elif self.type == self.TYPE_MOD_WARNING:
276
-			mention: str = Storage.get_config_value(self.guild, ConfigKey.WARNING_MENTION)
277
-			if mention:
278
-				s += mention + ' '
279
-			s += CONFIG['warning_emoji'] + ' '
280
-		elif self.type == self.TYPE_SUCCESS:
281
-			s += CONFIG['success_emoji'] + ' '
282
-		elif self.type == self.TYPE_FAILURE:
283
-			s += CONFIG['failure_emoji'] + ' '
284
-
285
-		s += self.text
286
-
287
-		if self.quote:
288
-			s += f'\n\n> {self.quote}'
289
-
290
-		if len(self.__reactions) > 0:
291
-			s += '\n\nAvailable actions:'
292
-			for reaction in self.__reactions:
293
-				if reaction.is_enabled:
294
-					s += f'\n     {reaction.emoji} {reaction.description}'
295
-				else:
296
-					s += f'\n     {reaction.description}'
297
-
298
-		return s
299
-
300
-class CogSetting:
301
-	"""
302
-	Describes a configuration setting for a guild that can be edited by the
303
-	mods of those guilds. BaseCog can generate "get" and "set" commands
304
-	automatically, reducing the boilerplate of generating commands manually.
305
-	Offers simple validation rules.
306
-	"""
307
-	def __init__(self,
308
-			name: str,
309
-			datatype,
310
-			brief: str = None,
311
-			description: str = None,
312
-			usage: str = None,
313
-			min_value = None,
314
-			max_value = None,
315
-			enum_values: set = None):
316
-		"""
317
-		Params:
318
-		- name         Setting identifier. Must follow variable naming
319
-		               conventions.
320
-		- datatype     Datatype of the setting. E.g. int, float, str
321
-		- brief        Description of the setting, starting with lower case.
322
-		               Will be inserted into phrases like "Sets <brief>" and
323
-					   "Gets <brief".
324
-		- description  Long-form description. Min, max, and enum values will be
325
-		               appended to the end, so does not need to include these.
326
-		- usage        Description of the value argument in a set command, e.g.
327
-		               "<maxcount:int>"
328
-		- min_value    Smallest allowable value. Must be of the same datatype as
329
-		               the value. None for no minimum.
330
-		- max_value    Largest allowable value. None for no maximum.
331
-		- enum_values  Set of allowed values. None if unconstrained.
332
-		"""
333
-		self.name = name
334
-		self.datatype = datatype
335
-		self.brief = brief
336
-		self.description = description or ''  # Can't be None
337
-		self.usage = usage
338
-		self.min_value = min_value
339
-		self.max_value = max_value
340
-		self.enum_values = enum_values
341
-		if self.enum_values or self.min_value is not None or self.max_value is not None:
342
-			self.description += '\n'
343
-		if self.enum_values:
344
-			allowed_values = '`' + ('`, `'.join(enum_values)) + '`'
345
-			self.description += f'\nAllowed values: {allowed_values}'
346
-		if self.min_value is not None:
347
-			self.description += f'\nMin value: {self.min_value}'
348
-		if self.max_value is not None:
349
-			self.description += f'\nMax value: {self.max_value}'
350
-		if self.usage is None:
351
-			self.usage = f'<{self.name}>'
13
+from rocketbot.storage import Storage
14
+from rocketbot.utils import bot_log
352 15
 
353 16
 class BaseCog(commands.Cog):
354 17
 	"""
@@ -419,212 +82,16 @@ class BaseCog(commands.Cog):
419 82
 		will be raised if the new value does not pass validation specified in
420 83
 		the CogSetting.
421 84
 		"""
422
-		if setting.min_value is not None and new_value < setting.min_value:
423
-			raise ValueError(f'{setting.name} must be at least {setting.min_value}')
424
-		if setting.max_value is not None and new_value > setting.max_value:
425
-			raise ValueError(f'{setting.name} must be no more than {setting.max_value}')
426
-		if setting.enum_values and new_value not in setting.enum_values:
427
-			raise ValueError(f'{setting.name} must be one of {setting.enum_values}')
85
+		setting.validate_value(new_value)
428 86
 		key = f'{cls.__name__}.{setting.name}'
429 87
 		Storage.set_config_value(guild, key, new_value)
430 88
 
431 89
 	@commands.Cog.listener()
432 90
 	async def on_ready(self):
433 91
 		'Event listener'
434
-		self.__set_up_setting_commands()
435
-
436
-	def __set_up_setting_commands(self):
437
-		"""
438
-		Sets up commands for editing all registered cog settings. This method
439
-		only runs once.
440
-		"""
441
-		if self.are_settings_setup:
442
-			return
443
-		self.are_settings_setup = True
444
-
445
-		# See if the cog has a command group. Currently only supporting one max.
446
-		group: commands.core.Group = None
447
-		for member_name in dir(self):
448
-			member = getattr(self, member_name)
449
-			if isinstance(member, commands.core.Group):
450
-				group = member
451
-				break
452
-
453
-		for setting in self.settings:
454
-			if setting.name == 'enabled' or setting.name == 'is_enabled':
455
-				self.__make_enable_disable_commands(setting, group)
456
-			else:
457
-				self.__make_getter_setter_commands(setting, group)
458
-
459
-	def __make_getter_setter_commands(self,
460
-			setting: CogSetting,
461
-			group: commands.core.Group) -> None:
462
-		"""
463
-		Creates a "get..." and "set..." command for the given setting and
464
-		either registers them as subcommands under the given command group or
465
-		under the bot if `None`.
466
-		"""
467
-		# Manually constructing equivalent of:
468
-		# 	@commands.command()
469
-		# 	@commands.has_permissions(ban_members=True)
470
-		# 	@commands.guild_only()
471
-		# 	async def getvar(self, context):
472
-		async def getter(self, context):
473
-			await self.__get_setting_command(context, setting)
474
-		async def setter_int(self, context, new_value: int):
475
-			await self.__set_setting_command(context, new_value, setting)
476
-		async def setter_float(self, context, new_value: float):
477
-			await self.__set_setting_command(context, new_value, setting)
478
-		async def setter_str(self, context, new_value: str):
479
-			await self.__set_setting_command(context, new_value, setting)
480
-		async def setter_bool(self, context, new_value: bool):
481
-			await self.__set_setting_command(context, new_value, setting)
482
-
483
-		setter = None
484
-		if setting.datatype == int:
485
-			setter = setter_int
486
-		elif setting.datatype == float:
487
-			setter = setter_float
488
-		elif setting.datatype == str:
489
-			setter = setter_str
490
-		elif setting.datatype == bool:
491
-			setter = setter_bool
492
-		else:
493
-			raise RuntimeError(f'Datatype {setting.datatype} unsupported')
494
-
495
-		get_command = commands.Command(
496
-			getter,
497
-			name=f'get{setting.name}',
498
-			brief=f'Shows {setting.brief}',
499
-			description=setting.description,
500
-			checks=[
501
-				commands.has_permissions(ban_members=True),
502
-				commands.guild_only(),
503
-			])
504
-		set_command = commands.Command(
505
-			setter,
506
-			name=f'set{setting.name}',
507
-			brief=f'Sets {setting.brief}',
508
-			description=setting.description,
509
-			usage=setting.usage,
510
-			checks=[
511
-				commands.has_permissions(ban_members=True),
512
-				commands.guild_only(),
513
-			])
514
-
515
-		# Passing `cog` in init gets ignored and set to `None` so set after.
516
-		# This ensures the callback is passed `self`.
517
-		get_command.cog = self
518
-		set_command.cog = self
519
-
520
-		if group:
521
-			group.add_command(get_command)
522
-			group.add_command(set_command)
523
-		else:
524
-			self.bot.add_command(get_command)
525
-			self.bot.add_command(set_command)
526
-
527
-	def __make_enable_disable_commands(self,
528
-			setting: CogSetting,
529
-			group: commands.core.Group) -> None:
530
-		"""
531
-		Creates "enable" and "disable" commands.
532
-		"""
533
-		async def enabler(self, context):
534
-			await self.__enable_command(context, setting)
535
-		async def disabler(self, context):
536
-			await self.__disable_command(context, setting)
537
-
538
-		enable_command = commands.Command(
539
-			enabler,
540
-			name='enable',
541
-			brief=f'Enables {setting.brief}',
542
-			description=setting.description,
543
-			checks=[
544
-				commands.has_permissions(ban_members=True),
545
-				commands.guild_only(),
546
-			])
547
-		disable_command = commands.Command(
548
-			disabler,
549
-			name='disable',
550
-			brief=f'Disables {setting.brief}',
551
-			description=setting.description,
552
-			checks=[
553
-				commands.has_permissions(ban_members=True),
554
-				commands.guild_only(),
555
-			])
556
-
557
-		enable_command.cog = self
558
-		disable_command.cog = self
559
-
560
-		if group:
561
-			group.add_command(enable_command)
562
-			group.add_command(disable_command)
563
-		else:
564
-			self.bot.add_command(enable_command)
565
-			self.bot.add_command(disable_command)
566
-
567
-	async def __set_setting_command(self, context, new_value, setting) -> None:
568
-		setting_name = setting.name
569
-		if context.command.parent:
570
-			setting_name = f'{context.command.parent.name}.{setting_name}'
571
-		if setting.min_value is not None and new_value < setting.min_value:
572
-			await context.message.reply(
573
-				f'{CONFIG["failure_emoji"]} `{setting_name}` must be >= {setting.min_value}',
574
-				mention_author=False)
575
-			return
576
-		if setting.max_value is not None and new_value > setting.max_value:
577
-			await context.message.reply(
578
-				f'{CONFIG["failure_emoji"]} `{setting_name}` must be <= {setting.max_value}',
579
-				mention_author=False)
580
-			return
581
-		if setting.enum_values is not None and new_value not in setting.enum_values:
582
-			allowed_values = '`' + ('`, `'.join(setting.enum_values)) + '`'
583
-			await context.message.reply(
584
-				f'{CONFIG["failure_emoji"]} `{setting_name}` must be one of {allowed_values}',
585
-				mention_author=False)
586
-			return
587
-		key = f'{self.__class__.__name__}.{setting.name}'
588
-		Storage.set_config_value(context.guild, key, new_value)
589
-		await context.message.reply(
590
-			f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
591
-			mention_author=False)
592
-		await self.on_setting_updated(context.guild, setting)
593
-		self.log(context.guild, f'{context.author.name} set {key} to {new_value}')
594
-
595
-	async def __get_setting_command(self, context, setting) -> None:
596
-		setting_name = setting.name
597
-		if context.command.parent:
598
-			setting_name = f'{context.command.parent.name}.{setting_name}'
599
-		key = f'{self.__class__.__name__}.{setting.name}'
600
-		value = Storage.get_config_value(context.guild, key)
601
-		if value is None:
602
-			value = self.get_cog_default(setting.name)
603
-			await context.message.reply(
604
-				f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`',
605
-				mention_author=False)
606
-		else:
607
-			await context.message.reply(
608
-				f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
609
-				mention_author=False)
610
-
611
-	async def __enable_command(self, context, setting) -> None:
612
-		key = f'{self.__class__.__name__}.{setting.name}'
613
-		Storage.set_config_value(context.guild, key, True)
614
-		await context.message.reply(
615
-			f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.',
616
-			mention_author=False)
617
-		await self.on_setting_updated(context.guild, setting)
618
-		self.log(context.guild, f'{context.author.name} enabled {self.__class__.__name__}')
619
-
620
-	async def __disable_command(self, context, setting) -> None:
621
-		key = f'{self.__class__.__name__}.{setting.name}'
622
-		Storage.set_config_value(context.guild, key, False)
623
-		await context.message.reply(
624
-			f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.',
625
-			mention_author=False)
626
-		await self.on_setting_updated(context.guild, setting)
627
-		self.log(context.guild, f'{context.author.name} disabled {self.__class__.__name__}')
92
+		if not self.are_settings_setup:
93
+			self.are_settings_setup = True
94
+			CogSetting.set_up_all(self, self.bot, self.settings)
628 95
 
629 96
 	async def on_setting_updated(self, guild: Guild, setting: CogSetting) -> None:
630 97
 		"""
@@ -713,5 +180,4 @@ class BaseCog(commands.Cog):
713 180
 		"""
714 181
 		Writes a message to the console. Intended for significant events only.
715 182
 		"""
716
-		now = datetime.now()
717
-		print(f'[{now.strftime("%Y-%m-%dT%H:%M:%S")}|{cls.__name__}|{guild.name}] {message}')
183
+		bot_log(guild, cls, message)

+ 229
- 0
rocketbot/cogsetting.py Bestand weergeven

@@ -0,0 +1,229 @@
1
+"""
2
+A guild configuration setting available for editing via bot commands.
3
+"""
4
+from discord.ext import commands
5
+from discord.ext.commands import Bot, Cog, Command, Context, Group
6
+
7
+from config import CONFIG
8
+from rocketbot.storage import Storage
9
+from rocketbot.utils import first_command_group
10
+
11
+class CogSetting:
12
+	"""
13
+	Describes a configuration setting for a guild that can be edited by the
14
+	mods of those guilds. BaseCog can generate "get" and "set" commands
15
+	automatically, reducing the boilerplate of generating commands manually.
16
+	Offers simple validation rules.
17
+	"""
18
+	def __init__(self,
19
+			name: str,
20
+			datatype,
21
+			brief: str = None,
22
+			description: str = None,
23
+			usage: str = None,
24
+			min_value = None,
25
+			max_value = None,
26
+			enum_values: set = None):
27
+		"""
28
+		Params:
29
+		- name         Setting identifier. Must follow variable naming
30
+		               conventions.
31
+		- datatype     Datatype of the setting. E.g. int, float, str
32
+		- brief        Description of the setting, starting with lower case.
33
+		               Will be inserted into phrases like "Sets <brief>" and
34
+					   "Gets <brief".
35
+		- description  Long-form description. Min, max, and enum values will be
36
+		               appended to the end, so does not need to include these.
37
+		- usage        Description of the value argument in a set command, e.g.
38
+		               "<maxcount:int>"
39
+		- min_value    Smallest allowable value. Must be of the same datatype as
40
+		               the value. None for no minimum.
41
+		- max_value    Largest allowable value. None for no maximum.
42
+		- enum_values  Set of allowed values. None if unconstrained.
43
+		"""
44
+		self.name = name
45
+		self.datatype = datatype
46
+		self.brief = brief
47
+		self.description = description or ''  # Can't be None
48
+		self.usage = usage
49
+		self.min_value = min_value
50
+		self.max_value = max_value
51
+		self.enum_values = enum_values
52
+		if self.enum_values or self.min_value is not None or self.max_value is not None:
53
+			self.description += '\n'
54
+		if self.enum_values:
55
+			allowed_values = '`' + ('`, `'.join(enum_values)) + '`'
56
+			self.description += f'\nAllowed values: {allowed_values}'
57
+		if self.min_value is not None:
58
+			self.description += f'\nMin value: {self.min_value}'
59
+		if self.max_value is not None:
60
+			self.description += f'\nMax value: {self.max_value}'
61
+		if self.usage is None:
62
+			self.usage = f'<{self.name}>'
63
+
64
+	def validate_value(self, new_value) -> None:
65
+		"""
66
+		Checks if a value is legal for this setting. Raises a ValueError if not.
67
+		"""
68
+		if self.min_value is not None and new_value < self.min_value:
69
+			raise ValueError(f'`{self.name}` must be >= {self.min_value}')
70
+		if self.max_value is not None and new_value > self.max_value:
71
+			raise ValueError(f'`{self.name}` must be <= {self.max_value}')
72
+		if self.enum_values is not None and new_value not in self.enum_values:
73
+			allowed_values = '`' + ('`, `'.join(self.enum_values)) + '`'
74
+			raise ValueError(f'`{self.name}` must be one of {allowed_values}')
75
+
76
+	def set_up(self, cog: Cog, bot: Bot, group: Group) -> None:
77
+		"""
78
+		Sets up getter and setter commands for this setting. This should
79
+		usually only be called by BaseCog.
80
+		"""
81
+		if self.name in ('enabled', 'is_enabled'):
82
+			(group or bot).add_command(self.__make_enable_command(cog))
83
+			(group or bot).add_command(self.__make_disable_command(cog))
84
+		else:
85
+			(group or bot).add_command(self.__make_getter_command(cog))
86
+			(group or bot).add_command(self.__make_setter_command(cog))
87
+
88
+	def __make_getter_command(self, cog: Cog) -> Command:
89
+		setting = self
90
+		async def getter(cog: Cog, context: Context) -> None:
91
+			setting_name = setting.name
92
+			if context.command.parent:
93
+				setting_name = f'{context.command.parent.name}.{setting_name}'
94
+			key = f'{cog.__class__.__name__}.{setting.name}'
95
+			value = Storage.get_config_value(context.guild, key)
96
+			if value is None:
97
+				value = cog.get_cog_default(setting.name)
98
+				await context.message.reply(
99
+					f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`',
100
+					mention_author=False)
101
+			else:
102
+				await context.message.reply(
103
+					f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
104
+					mention_author=False)
105
+		command = Command(
106
+			getter,
107
+			name=f'get{setting.name}',
108
+			brief=f'Shows {setting.brief}',
109
+			description=setting.description,
110
+			checks=[
111
+				commands.has_permissions(ban_members=True),
112
+				commands.guild_only(),
113
+			])
114
+		command.cog = cog
115
+		return command
116
+
117
+	def __make_setter_command(self, cog: Cog) -> Command:
118
+		setting: CogSetting = self
119
+		async def setter_common(cog: Cog, context: Context, new_value) -> None:
120
+			try:
121
+				setting.validate_value(new_value)
122
+			except ValueError as ve:
123
+				await context.message.reply(
124
+					f'{CONFIG["failure_emoji"]} {ve}',
125
+					mention_author=False)
126
+				return
127
+			setting_name = setting.name
128
+			if context.command.parent:
129
+				setting_name = f'{context.command.parent.name}.{setting_name}'
130
+			key = f'{cog.__class__.__name__}.{setting.name}'
131
+			Storage.set_config_value(context.guild, key, new_value)
132
+			await context.message.reply(
133
+				f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
134
+				mention_author=False)
135
+			await cog.on_setting_updated(context.guild, setting)
136
+			cog.log(context.guild, f'{context.author.name} set {key} to {new_value}')
137
+
138
+		async def setter_int(cog, context, new_value: int):
139
+			await setter_common(cog, context, new_value)
140
+		async def setter_float(cog, context, new_value: float):
141
+			await setter_common(cog, context, new_value)
142
+		async def setter_str(cog, context, new_value: str):
143
+			await setter_common(cog, context, new_value)
144
+		async def setter_bool(cog, context, new_value: bool):
145
+			await setter_common(cog, context, new_value)
146
+
147
+		setter = None
148
+		if setting.datatype == int:
149
+			setter = setter_int
150
+		elif setting.datatype == float:
151
+			setter = setter_float
152
+		elif setting.datatype == str:
153
+			setter = setter_str
154
+		elif setting.datatype == bool:
155
+			setter = setter_bool
156
+		else:
157
+			raise ValueError(f'Datatype {setting.datatype} unsupported')
158
+
159
+		command = Command(
160
+			setter,
161
+			name=f'set{setting.name}',
162
+			brief=f'Sets {setting.brief}',
163
+			description=setting.description,
164
+			usage=setting.usage,
165
+			checks=[
166
+				commands.has_permissions(ban_members=True),
167
+				commands.guild_only(),
168
+			])
169
+		# Passing `cog` in init gets ignored and set to `None` so set after.
170
+		# This ensures the callback is passed `self`.
171
+		command.cog = cog
172
+		return command
173
+
174
+	def __make_enable_command(self, cog: Cog) -> Command:
175
+		setting: CogSetting = self
176
+		async def enabler(cog: Cog, context: Context) -> None:
177
+			key = f'{cog.__class__.__name__}.{setting.name}'
178
+			Storage.set_config_value(context.guild, key, True)
179
+			await context.message.reply(
180
+				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.',
181
+				mention_author=False)
182
+			await cog.on_setting_updated(context.guild, setting)
183
+			cog.log(context.guild, f'{context.author.name} enabled {cog.__class__.__name__}')
184
+
185
+		command = Command(
186
+			enabler,
187
+			name='enable',
188
+			brief=f'Enables {setting.brief}',
189
+			description=setting.description,
190
+			checks=[
191
+				commands.has_permissions(ban_members=True),
192
+				commands.guild_only(),
193
+			])
194
+		command.cog = cog
195
+		return command
196
+
197
+	def __make_disable_command(self, cog: Cog) -> Command:
198
+		setting: CogSetting = self
199
+		async def disabler(cog: Cog, context: Context) -> None:
200
+			key = f'{cog.__class__.__name__}.{setting.name}'
201
+			Storage.set_config_value(context.guild, key, False)
202
+			await context.message.reply(
203
+				f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.',
204
+				mention_author=False)
205
+			await cog.on_setting_updated(context.guild, setting)
206
+			cog.log(context.guild, f'{context.author.name} disabled {cog.__class__.__name__}')
207
+
208
+		command = Command(
209
+			disabler,
210
+			name='disable',
211
+			brief=f'Disables {setting.brief}',
212
+			description=setting.description,
213
+			checks=[
214
+				commands.has_permissions(ban_members=True),
215
+				commands.guild_only(),
216
+			])
217
+		command.cog = cog
218
+		return command
219
+
220
+	@classmethod
221
+	def set_up_all(cls, cog: Cog, bot: Bot, settings: list) -> None:
222
+		"""
223
+		Sets up editing commands for a list of CogSettings and adds them to a
224
+		cog. If the cog has a command Group, commands will be added to it.
225
+		Otherwise they will be added at the top level.
226
+		"""
227
+		group: Group = first_command_group(cog)
228
+		for setting in settings:
229
+			setting.set_up(cog, bot, group)

+ 4
- 4
rocketbot/collections.py Bestand weergeven

@@ -2,11 +2,11 @@
2 2
 Subclasses of list, set, and dict with special behaviors.
3 3
 """
4 4
 
5
-from abc import ABC, abstractmethod
5
+from abc import ABCMeta, abstractmethod
6 6
 
7 7
 # Abstract collections
8 8
 
9
-class AbstractMutableList(list, ABC):
9
+class AbstractMutableList(list, metaclass=ABCMeta):
10 10
 	"""
11 11
 	Abstract list with hooks for custom logic before and after mutation
12 12
 	operations.
@@ -86,7 +86,7 @@ class AbstractMutableList(list, ABC):
86 86
 		self.post_mutate()
87 87
 		return ret_val
88 88
 
89
-class AbstractMutableSet(set, ABC):
89
+class AbstractMutableSet(set, metaclass=ABCMeta):
90 90
 	"""
91 91
 	Abstract set with hooks for custom logic before and after mutation
92 92
 	operations.
@@ -171,7 +171,7 @@ class AbstractMutableSet(set, ABC):
171 171
 		self.post_mutate()
172 172
 		return ret_val
173 173
 
174
-class AbstractMutableDict(dict, ABC):
174
+class AbstractMutableDict(dict, metaclass=ABCMeta):
175 175
 	"""
176 176
 	Abstract dict with hooks for custom logic before and after mutation
177 177
 	operations.

+ 24
- 1
rocketbot/utils.py Bestand weergeven

@@ -2,7 +2,9 @@
2 2
 General utility functions.
3 3
 """
4 4
 import re
5
-from datetime import timedelta
5
+from datetime import datetime, timedelta
6
+from discord import Guild
7
+from discord.ext.commands import Cog, Group
6 8
 
7 9
 def parse_timedelta(s: str) -> timedelta:
8 10
 	"""
@@ -54,3 +56,24 @@ def describe_timedelta(td: timedelta, max_components: int = 2) -> str:
54 56
 	if len(components) > max_components:
55 57
 		components = components[0:max_components]
56 58
 	return ' '.join(components)
59
+
60
+def first_command_group(cog: Cog) -> Group:
61
+	"""
62
+	Returns the first command Group found in a cog.
63
+	"""
64
+	for member_name in dir(cog):
65
+		member = getattr(cog, member_name)
66
+		if isinstance(member, Group):
67
+			return member
68
+	return None
69
+
70
+def bot_log(guild: Guild, cog_class, message: str) -> None:
71
+	"""
72
+	Logs a message to stdout with time, cog, and guild info.
73
+	"""
74
+	now = datetime.now() # local
75
+	s = f'[{now.strftime("%Y-%m-%dT%H:%M:%S")}|'
76
+	s += f'{cog_class.__name__}|' if cog_class else '-|'
77
+	s += f'{guild.name}] ' if guild else '-] '
78
+	s += message
79
+	print(s)

Laden…
Annuleren
Opslaan