-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsettings.py
More file actions
492 lines (438 loc) · 22.8 KB
/
settings.py
File metadata and controls
492 lines (438 loc) · 22.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
import asyncio
import logging
import traceback
import discord
from aiomysql import IntegrityError
from discord.ext import commands
from common import show_not_executable
class Setting():
"""
An object that represents a setting and its state.
Please don't directly instantiate this.
If you want to add settings, use settings.add_category.
Methods
-------
enabled
Returns if the setting is enabled for the specified guild.
Attributes
----------
states : dict(int:bool)
A mapping of guild id to setting state for that guild.
description : str
The setting's description. Provided by the Category's settingdescmapping.
unusablewith : Union[str, list]
Settings this setting conflicts with. Provided by the Category's unusablewithmapping.
permission : str
The permission this setting requires, as a string. Must be a valid discord.Permission e.g manage_guild.
"""
__slots__ = ("states", "description", "name", "unusablewith", "category", "permission")
def __init__(self, category, name, states, permission):
self.states = states
self.description = category.settingdescmapping[name]
self.name = name
logger = logging.getLogger(category.logger_name)
try:
self.unusablewith = category.unusablewithmapping[name]
except KeyError:
logger.warning(f"Setting '{self.name}' doesn't have an entry in the parent Category's 'unusablewithmapping'!")
logger.warning("Defaulting to None.")
self.unusablewith = None
self.category = category
self.permission = permission
#add this setting as an attr of category
#one can access it via 'bot.settings.<category>.<setting>'
setattr(category, name.strip().replace(" ", "_"), self)
logger.info(f"Registered setting {name}")
def enabled(self, guild_id:int):
"""
enabled(guild_id:int)
Returns a boolean specifying whether this setting is enabled in the specified guild.
Returns
-------
True
The setting is enabled.
False
The setting is disabled.
None
The setting's state couldn't be determined.
"""
if not self.category.ready:
logger = logging.getLogger(self.category.logger_name)
logger.warning(f"{self.name}.enabled was called before its parent category was ready!")
logger.warning("This may cause issues. Consider awaiting Category.wait_ready before anything that depends on setting states.")
try:
return self.states[guild_id]
except:
return None
async def _update_cached_state(self, ctx:commands.Context):
self.states[ctx.guild.id] = not self.states[ctx.guild.id]
async def _update_database_state(self, ctx:commands.Context):
await self.category.bot.db.exec("update config set enabled=%s where guild_id=%s and category=%s and setting=%s", (not self.states[ctx.guild.id], ctx.guild.id, self.category.name, self.name.replace("_", " ")))
async def toggle(self, ctx:commands.Context):
"""
Flips this setting's state in both the database and cache.
"""
logging.getLogger(self.category.logger_name).debug(f"Toggling setting {self.category.name}.{self.name}")
await self._update_database_state(ctx)
await self._update_cached_state(ctx)
class Category():
"""
An object that represents a collection of Settings.
Please don't directly instantiate this.
Use the `add_category` method instead.
Attributes
----------
ready
Whether settings are ready to be used. False until fill_cache has completed.
Warning:
If False, the behavior of calls to Setting.enabled() is unpredictable.
Those calls could return None or even result in an AttributeError depending on the initialization state of the setting.
"""
__slots__ = ("_ready", "settingdescmapping", "unusablewithmapping", "name", "filling", "logger_name", "bot", "permissionmapping", "raw_data", "__dict__")
def __init__(self, constructor, name, settingdescmapping, unusablewithmapping, permissionmapping):
self._ready = False
self.settingdescmapping = settingdescmapping
self.unusablewithmapping = unusablewithmapping
#make category accessible through 'bot.settings.<category>'
setattr(constructor, name, self)
self.name = name
self.filling = False
self.logger_name = f"settings.{name}"
self.bot = constructor.bot
self.permissionmapping = permissionmapping
self.raw_data = {}
asyncio.create_task(self._fill_cache())
@property
def ready(self):
return self._ready
async def wait_ready(self):
"""
Waits until settings are ready to be used.
"""
while not self.ready:
await asyncio.sleep(0.01)
def get_setting(self, name):
"""
Gets a Setting by name.
"""
return getattr(self, name.strip().replace(" ", "_"), None)
def _get_initial_state(self, setting):
"""
Gets the initial state of a setting.
"""
if setting['enabled'] is not None:
return bool(setting['enabled'])
return False
async def _add_to_db(self, name, total_guilds):
"""
Attempts to add a setting to the database.
"""
known_good_guilds = []
logger = logging.getLogger(self.logger_name)
#Get the list of guilds that we're certain we currently have settings in.
if self.raw_data:
known_good_guilds = [i['guild_id'] for i in self.raw_data if i['setting'] == name]
for guild in total_guilds:
#If this guild is one we know we have a setting state in already, skip it.
if guild.id in known_good_guilds:
continue
try:
logger.debug(f"State not found for setting {name} in guild {guild.id}, adding it to database")
await self.bot.db.exec('insert into config values(%s, %s, %s, %s)', (guild.id, self.name, name, False))
except IntegrityError as exc:
logger.debug("We already have a setting state saved for this guild")
continue
#Equivalent to the result of SELECT * FROM config WHERE setting={name}, category={self.name}, guild_id={guild.id}
self.raw_data.append({'setting':name, 'category':self.name, 'guild_id':guild.id, 'enabled':False})
logger.debug("Added raw data for the new setting entry.")
async def _fill_cache(self):
"""
Fill a Category's settings cache with data.
"""
try:
logger = logging.getLogger(self.logger_name)
logger.debug(f"Waiting to fill cache for category {self.name} until bot is ready.")
await self.bot.wait_until_ready()
logger.info(f"Filling cache for category {self.name}...")
self.filling = True
guilds = self.bot.guilds #stop state population from breaking if guilds change while filling cache
#step 1: get data for each setting, add settings to db if needed
logger.debug(f"Getting raw settings data for category {self.name}")
self.raw_data = await self.bot.db.exec('select * from config where category=%s order by setting', (self.name))
if self.raw_data is None:
self.raw_data = []
if not isinstance(self.raw_data, list):
self.raw_data = [self.raw_data]
logger.debug(self.raw_data)
logger.info("Validating setting states...")
#step 2: ensure each setting has an entry in the database for each guild
for name in list(self.settingdescmapping):
if self.get_setting(name):
delattr(self, name.replace(" ", "_"))
logger.debug(f"Removed {name} from setting set")
await self._add_to_db(name, guilds)
#step 3: for each setting, get initial state and register the setting
states = {}
logger.debug("Populating setting states...")
for index, setting in enumerate(self.raw_data):
logger.debug(f"Processing entry {setting}")
try:
if self.permissionmapping:
permission = self.permissionmapping[setting['setting']]
else:
permission = None
except KeyError:
logger.info(f"Setting '{setting['setting']}' was not included in permissionmapping for category '{self.name}'! Assuming a permission value of None.")
permission = None
try:
self.settingdescmapping[setting['setting']]
except KeyError:
logger.warning(f"Setting '{setting['setting']}' was removed from its parent Category but is still in the database.")
logger.warning(f"Removing it.")
try:
await self.bot.db.exec("delete from config where category=%s and setting=%s", (self.name, setting['setting']))
except:
logger.warning("Setting was already removed from the database.")
else:
logger.warning("Removed that setting.")
continue
logger.debug("Getting initial setting state.")
states[setting['guild_id']] = self._get_initial_state(setting)
#if we've finished populating list of states for a setting...
#(we are on the last element of 'data' or the next element isn't for the same setting)
if index+1 == len(self.raw_data) or self.raw_data[index+1]['setting'] != setting['setting']:
#create new Setting, it automatically sets itself as an attr of this category
Setting(self, setting['setting'], states, permission)
states = {}
logger.info("Done filling settings cache.")
self._ready = True
self.filling = False
del self.raw_data
except (KeyboardInterrupt, asyncio.exceptions.CancelledError):
pass
except:
traceback.print_exc()
logger.error(f"An error occurred while filling the setting cache for category {self.name}!")
logger.error("Settings in this category will not be registered.")
self._ready = None
del self.raw_data
return
async def _prepare_conflict_string(self, conflicts):
"""
Returns a string describing conflicting settings.
"""
q = "'"
if not isinstance(conflicts, list):
return f"{q}*{conflicts}*{q}"
return self.bot.strings["CONFLICT_STRING_MULTIPLE"].format(', '.join([f'{q}*{i}*{q}' for i in conflicts[:-1]]), conflicts[-1])
async def _resolve_conflicts(self, ctx, setting):
"""
Resolves conflicts between settings.
"""
logger = logging.getLogger(self.logger_name)
resolved = []
if not setting.unusablewith:
logger.debug("Setting does not have conflicts.")
return ""
#Make sure unusablewith is a list.
setting.unusablewith = [setting.unusablewith] if not isinstance(setting.unusablewith, list) else setting.unusablewith
for conflict in setting.unusablewith:
logger.debug(f"Setting conflicts with '{conflict}'")
#Conflicting setting enabled? Disable it.
conflict_name = conflict
conflict = self.get_setting(conflict)
if not conflict:
logger.warning(f"The setting '{setting.name}' has a conflicting setting listed that doesn't exist!")
logger.warning(f"The conflicting setting name is '{conflict_name}'.")
logger.warning("Consider removing that conflict from the setting's unusablewithmapping.")
continue
if conflict.enabled(ctx.guild.id):
logger.debug("Conflicting setting is enabled. Disabling it.")
await conflict.toggle(ctx)
#conflict was replaced with a Setting. Only add the name to our list of resolved conflicts.
resolved.append(conflict.name)
logger.debug("Added the conflicting setting to resolved conflicts.")
#How many conflicts did we resolve?
length = len(resolved) if isinstance(resolved, list) else 1
logger.debug(f"Resolved {length} setting conflict{'s' if length != 1 else ''}")
#If we only resolved one conflict, don't wrap it in a list
if length == 1 and isinstance(resolved, list):
resolved = resolved[0]
if not resolved:
return ""
logger.debug("Notifying user of conflict resolution")
resolved = await self._prepare_conflict_string(resolved)
if length == 1:
return self.bot.strings["SETTING_AUTOMATICALLY_DISABLED"].format(resolved)
return self.bot.strings["SETTINGS_AUTOMATICALLY_DISABLED"].format(resolved)
def normalize_permission(self, permission):
"""Makes a permission name more human readable. Replaces \'guild\' with \'server\', capitalizes words, swaps underscores for spaces."""
return permission.replace("guild", "server").replace("_", " ").title()
async def _show_setting_list(self, ctx):
if self.name != "general":
title = self.bot.strings["CURRENTSETTINGS_TITLE"].format(self.name)
else:
title = self.bot.strings["GENERAL_CATEGORY"]
embed = self.bot.core.ThemedEmbed(title=title)
for setting in [self.get_setting(i) for i in list(self.settingdescmapping)]:
if setting.unusablewith:
#String with conflicts, shown below setting name.
#Example: "Cannot be used at the same time as 'a', 'b', 'c'"
unusablewithwarning = self.bot.strings["SETTING_UNUSABLE_WITH"].format(await self._prepare_conflict_string(setting.unusablewith))
else:
unusablewithwarning = ""
if setting.permission:
#Required permission for a setting (if applicable)
#Shown below the setting name and conflicts
#Example: "Requires the *Manage Server* permission"
perms = self.bot.strings["SETTING_REQUIRES_PERMISSION"].format(self.normalize_permission(setting.permission))
else:
perms = ""
#Add the setting name and description.
embed.add_field(name=self.bot.strings["SETTING_ENTRY_NAME"].format(discord.utils.remove_markdown(setting.description.capitalize()), setting.name), value=f"{self.bot.strings['SETTING_CURRENTLY_DISABLED'] if not setting.states[ctx.guild.id] else self.bot.strings['SETTING_CURRENTLY_ENABLED']}\n{unusablewithwarning}{perms}", inline=True)
embed.set_footer(text=self.bot.strings["CURRENTSETTINGS_FOOTER"])
return await ctx.send(embed=embed)
async def config(self, ctx, name=None):
"""Toggles the specified setting. Settings are off by default. Lists settings in this Category if nothing's specified."""
#setting not specified? show list of settings
logger = logging.getLogger(self.logger_name)
if not name:
logger.debug("Setting name not provided, showing setting list")
return await self._show_setting_list(ctx)
logger.debug(f"Attempting to get setting '{name}'")
setting = self.get_setting(name)
if not setting:
logger.debug("Unknown setting")
return await ctx.send(self.bot.strings["UNKNOWN_SETTING"])
if setting.permission:
logger.debug(f"This setting requires {setting.permission}")
if not getattr(ctx.channel.permissions_for(ctx.author), setting.permission):
logger.debug("User does not have permission to change setting")
return await ctx.send(self.bot.strings["SETTING_PERMISSION_DENIED"].format(self.normalize_permission(setting.permission)))
logger.debug("User has permission to change setting")
try:
logger.debug("Toggling setting state")
#update setting state
await setting.toggle(ctx)
#check for conflicts and resolve them
logger.debug("Resolving setting conflicts")
unusablewithmessage = await self._resolve_conflicts(ctx, setting)
except:
await self.bot.core.send_traceback()
await ctx.send(self.bot.strings["SETTING_ERROR"])
return await self.bot.core.send_debug(ctx)
await ctx.send(embed=discord.Embed(title=self.bot.strings["SETTING_CHANGED_TITLE"], description=f"**{self.bot.strings['SETTING_DISABLED'] if not setting.enabled(ctx.guild.id) else self.bot.strings['SETTING_ENABLED']}** *{setting.description}*.\n{unusablewithmessage}", color=self.bot.config['theme_color']).set_footer(text=f"{self.bot.strings['SETTING_ENABLED_FOOTER'] if setting.enabled(ctx.guild.id) else self.bot.strings['SETTING_DISABLED_FOOTER']}"))
class settings():
"""
A simple interface for adding setting toggles to modules.
"""
#should we even use __slots__ if we're adding __dict__
__slots__ = ("bot", "logger", "categorynames", "__dict__")
def __init__(self, bot):
"""
Parameters
----------
bot : discord.ext.commands.Bot
The main Bot instance.
"""
self.bot = bot
logging.getLogger("settings").info(f"Settings module initialized.")
self.categorynames = []
def add_category(self, category, settingdescmapping, unusablewithmapping, permissionmapping):
"""
A helper method for creating a new Category instance. It allows a category to register as an attribute of the main settings instance.
After this returns and the Category's 'ready' attribute is set to True, you can check the value of settings using `bot.settings.<category>.<setting>.enabled()`.
Consider awaiting 'Category.wait_ready' before any Setting.enabled() call.
Parameters
----------
category : str
The name of the category. This will be used to view and toggle settings. (think `config <category> <setting>`)
settingdescmapping : dict(str:str)
A mapping of setting name to description.
Descriptions show up when viewing and toggling settings.
Example:
{'a':'spam'}
Setting 'a' has the description 'spam'.
Toggling 'a' will show 'Enabled/Disabled *spam*.'
Viewing settings for the category will show 'spam (a) \nEnabled/Disabled'
unusablewithmapping : dict(str:Union[list, str]=None)
A mapping of setting name to names of settings that conflict.
This allows the settings system to detect and resolve conflicts.
This must be a dict with setting names as keys.
There are three different types used to declare conflicts (or lack thereof):
None
No conflict.
List
More than 1 conflict.
Str
1 conflict.
Example:
{'a':['spam', 'eggs'], 'b':'a'}
Setting 'a' conflicts with settings 'spam' and 'eggs'.
Setting 'b' conflicts with setting 'a'.
permissionmapping : dict(str:str)
A mapping of setting name to permission name.
Use this to control access to settings in the category.
Map a setting name to None to make it available to everyone.
Note that channel permission overrides apply.
Example:
{'a':'manage_guild', 'b':None}
Setting 'a' requires the 'Manage Server' permission.
Setting 'b' doesn't require any permissions.
"""
logger = logging.getLogger("settings")
logger.info(f"Registering category '{category}`...")
#Does a Category with this name already exist??
if getattr(self, category, None) != None:
logger.warning("----")
logger.warning(f"add_category was called twice for category '{category}'!!")
logger.warning("Don't try to update a category after creation. Doing so may break stuff.")
logger.warning("Seeing this message after reloading a module? Add a check for bot.init_finished in __init__.")
logger.warning("----")
return
try:
Category(self, category, settingdescmapping, unusablewithmapping, permissionmapping)
except Exception as e:
logger.error(f"Category registration failed for category '{category}'!")
raise e
self.categorynames.append(category)
logger.info(f"Category '{category}' registered.")
def _prepare_category_string(self):
if self.categorynames:
return "\n".join([f"`{i}`" for i in self.categorynames])
else:
return "None"
#THIS DOCSTRING DOES NOT SHOW UP IN THE HELP COMMAND
#See cogs/config.py for the actual command.
async def config(self, ctx, category:str=None, *, setting:str):
"""
A command that changes settings.
"""
logger = logging.getLogger("settings")
#figure out what category we're using
if not category:
available = self._prepare_category_string()
return await ctx.send(self.bot.strings["CATEGORY_NOT_SPECIFIED"].format(available))
try:
category = getattr(self, category)
except AttributeError:
available = self._prepare_category_string()
return await ctx.send(self.bot.strings["UNKNOWN_CATEGORY"].format(available))
try:
if category.ready == None:
logger.error(f"It looks like cache fill for {category.name} failed!")
logger.error("Please report this issue and attach log files to the report.")
await ctx.send(self.bot.strings["CATEGORY_REGISTRATION_ERROR"])
elif category.ready == False:
logger.error(f"It looks like cache filling for category {category.name} is happening way too late!!!")
logger.error("please report this issue to tk421.")
logger.error("waiting until cache fill is complete...")
await ctx.send(self.bot.strings["CATEGORY_NOT_READY"])
await category.wait_ready()
logger.error("cache fill complete, continuing :)")
await category.config(ctx, setting)
except AttributeError: #category wasn't configured properly
traceback.print_exc()
return await ctx.send(self.bot.strings["CATEGORY_INVALID"])
if __name__ == "__main__":
show_not_executable()