aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock148
-rw-r--r--bot/cogs/antispam.py4
-rw-r--r--bot/cogs/defcon.py2
-rw-r--r--bot/cogs/duck_pond.py2
-rw-r--r--bot/cogs/error_handler.py4
-rw-r--r--bot/cogs/help_channels.py2
-rw-r--r--bot/cogs/moderation/management.py10
-rw-r--r--bot/cogs/moderation/scheduler.py10
-rw-r--r--bot/cogs/moderation/silence.py2
-rw-r--r--bot/cogs/stats.py4
-rw-r--r--bot/cogs/utils.py4
-rw-r--r--bot/cogs/watchchannels/talentpool.py2
-rw-r--r--bot/cogs/watchchannels/watchchannel.py14
-rw-r--r--bot/constants.py3
-rw-r--r--bot/decorators.py2
-rw-r--r--bot/pagination.py4
-rw-r--r--bot/utils/__init__.py4
-rw-r--r--bot/utils/messages.py2
-rw-r--r--bot/utils/redis_dict.py136
-rw-r--r--config-default.yml2
-rw-r--r--docker-compose.yml4
-rw-r--r--tests/bot/utils/test_redis_dict.py189
23 files changed, 460 insertions, 96 deletions
diff --git a/Pipfile b/Pipfile
index 14c9ef926..40ae52761 100644
--- a/Pipfile
+++ b/Pipfile
@@ -23,9 +23,11 @@ colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"}
statsd = "~=3.3"
feedparser = "~=5.2"
beautifulsoup4 = "~=4.9"
+redis = "~=3.5"
[dev-packages]
coverage = "~=5.0"
+fakeredis = "~=1.4"
flake8 = "~=3.7"
flake8-annotations = "~=2.0"
flake8-bugbear = "~=20.1"
diff --git a/Pipfile.lock b/Pipfile.lock
index 4e7050a13..414f4a053 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "64620e7e825c74fd3010821fb30843b19f5dafb2b5a1f6eafedc0a5febd99b69"
+ "sha256": "8ec71e9c46d52bf3b8c72939519e993715c79b4bc9e6ad164c1cf88951dc48b4"
},
"pipfile-spec": 6,
"requires": {
@@ -52,10 +52,10 @@
},
"aiormq": {
"hashes": [
- "sha256:286e0b0772075580466e45f98f051b9728a9316b9c36f0c14c7bc1409be375b0",
- "sha256:7ed7d6df6b57af7f8bce7d1ebcbdfc32b676192e46703e81e9e217316e56b5bd"
+ "sha256:41a9d4eb17db805f30ed172f3f609fe0c2b16657fb15b1b67df19d251dd93c0d",
+ "sha256:7c19477a9450824cb79f9949fd238f4148e2c0dca67756a2868863c387209f04"
],
- "version": "==3.2.1"
+ "version": "==3.2.2"
},
"alabaster": {
"hashes": [
@@ -305,39 +305,39 @@
},
"more-itertools": {
"hashes": [
- "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c",
- "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"
+ "sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be",
+ "sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982"
],
"index": "pypi",
- "version": "==8.2.0"
+ "version": "==8.3.0"
},
"multidict": {
"hashes": [
- "sha256:317f96bc0950d249e96d8d29ab556d01dd38888fbe68324f46fd834b430169f1",
- "sha256:42f56542166040b4474c0c608ed051732033cd821126493cf25b6c276df7dd35",
- "sha256:4b7df040fb5fe826d689204f9b544af469593fb3ff3a069a6ad3409f742f5928",
- "sha256:544fae9261232a97102e27a926019100a9db75bec7b37feedd74b3aa82f29969",
- "sha256:620b37c3fea181dab09267cd5a84b0f23fa043beb8bc50d8474dd9694de1fa6e",
- "sha256:6e6fef114741c4d7ca46da8449038ec8b1e880bbe68674c01ceeb1ac8a648e78",
- "sha256:7774e9f6c9af3f12f296131453f7b81dabb7ebdb948483362f5afcaac8a826f1",
- "sha256:85cb26c38c96f76b7ff38b86c9d560dea10cf3459bb5f4caf72fc1bb932c7136",
- "sha256:a326f4240123a2ac66bb163eeba99578e9d63a8654a59f4688a79198f9aa10f8",
- "sha256:ae402f43604e3b2bc41e8ea8b8526c7fa7139ed76b0d64fc48e28125925275b2",
- "sha256:aee283c49601fa4c13adc64c09c978838a7e812f85377ae130a24d7198c0331e",
- "sha256:b51249fdd2923739cd3efc95a3d6c363b67bbf779208e9f37fd5e68540d1a4d4",
- "sha256:bb519becc46275c594410c6c28a8a0adc66fe24fef154a9addea54c1adb006f5",
- "sha256:c2c37185fb0af79d5c117b8d2764f4321eeb12ba8c141a95d0aa8c2c1d0a11dd",
- "sha256:dc561313279f9d05a3d0ffa89cd15ae477528ea37aa9795c4654588a3287a9ab",
- "sha256:e439c9a10a95cb32abd708bb8be83b2134fa93790a4fb0535ca36db3dda94d20",
- "sha256:fc3b4adc2ee8474cb3cd2a155305d5f8eda0a9c91320f83e55748e1fcb68f8e3"
- ],
- "version": "==4.7.5"
+ "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a",
+ "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000",
+ "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2",
+ "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507",
+ "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5",
+ "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7",
+ "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d",
+ "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463",
+ "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19",
+ "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3",
+ "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b",
+ "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c",
+ "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87",
+ "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7",
+ "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430",
+ "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255",
+ "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d"
+ ],
+ "version": "==4.7.6"
},
"ordered-set": {
"hashes": [
- "sha256:a7bfa858748c73b096e43db14eb23e2bc714a503f990c89fac8fab9b0ee79724"
+ "sha256:a31008c57f9c9776b12eb8841b1f61d1e4d70dfbbe8875ccfa2403c54af3d51b"
],
- "version": "==3.1.1"
+ "version": "==4.0.1"
},
"packaging": {
"hashes": [
@@ -418,10 +418,10 @@
},
"pytz": {
"hashes": [
- "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
- "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
+ "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed",
+ "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"
],
- "version": "==2019.3"
+ "version": "==2020.1"
},
"pyyaml": {
"hashes": [
@@ -440,6 +440,14 @@
"index": "pypi",
"version": "==5.3.1"
},
+ "redis": {
+ "hashes": [
+ "sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242",
+ "sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251"
+ ],
+ "index": "pypi",
+ "version": "==3.5.2"
+ },
"requests": {
"hashes": [
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
@@ -450,11 +458,11 @@
},
"sentry-sdk": {
"hashes": [
- "sha256:23808d571d2461a4ce3784ec12bbee5bdb8c026c143fe79d36cef8a6d653e71f",
- "sha256:bb90a4e19c7233a580715fc986cc44be2c48fc10b31e71580a2037e1c94b6950"
+ "sha256:0e5e947d0f7a969314aa23669a94a9712be5a688ff069ff7b9fc36c66adc160c",
+ "sha256:799a8bf76b012e3030a881be00e97bc0b922ce35dde699c6537122b751d80e2c"
],
"index": "pypi",
- "version": "==0.14.3"
+ "version": "==0.14.4"
},
"six": {
"hashes": [
@@ -595,10 +603,10 @@
"develop": {
"appdirs": {
"hashes": [
- "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
- "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
+ "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
+ "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
],
- "version": "==1.4.3"
+ "version": "==1.4.4"
},
"attrs": {
"hashes": [
@@ -657,12 +665,13 @@
],
"version": "==0.3.0"
},
- "entrypoints": {
+ "fakeredis": {
"hashes": [
- "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
- "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
+ "sha256:4d170886865a91dbc8b7f8cbd4e5d488f4c5f2f25dfae127f001617bbe9e8f97",
+ "sha256:647b2593d349d9d4e566c8dadb2e4c71ba35be5bdc4f1f7ac2d565a12a965053"
],
- "version": "==0.3"
+ "index": "pypi",
+ "version": "==1.4.1"
},
"filelock": {
"hashes": [
@@ -673,11 +682,11 @@
},
"flake8": {
"hashes": [
- "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb",
- "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"
+ "sha256:6c1193b0c3f853ef763969238f6c81e9e63ace9d024518edc020d5f1d6d93195",
+ "sha256:ea6623797bf9a52f4c9577d780da0bb17d65f870213f7b5bcc9fca82540c31d5"
],
"index": "pypi",
- "version": "==3.7.9"
+ "version": "==3.8.1"
},
"flake8-annotations": {
"hashes": [
@@ -743,10 +752,10 @@
},
"identify": {
"hashes": [
- "sha256:2bb8760d97d8df4408f4e805883dad26a2d076f04be92a10a3e43f09c6060742",
- "sha256:faffea0fd8ec86bb146ac538ac350ed0c73908326426d387eded0bcc9d077522"
+ "sha256:23c18d97bb50e05be1a54917ee45cc61d57cb96aedc06aabb2b02331edf0dbf0",
+ "sha256:88ed90632023e52a6495749c6732e61e08ec9f4f04e95484a5c37b9caf40283c"
],
- "version": "==1.4.14"
+ "version": "==1.4.15"
},
"mccabe": {
"hashes": [
@@ -771,18 +780,18 @@
},
"pre-commit": {
"hashes": [
- "sha256:487c675916e6f99d355ec5595ad77b325689d423ef4839db1ed2f02f639c9522",
- "sha256:c0aa11bce04a7b46c5544723aedf4e81a4d5f64ad1205a30a9ea12d5e81969e1"
+ "sha256:5559e09afcac7808933951ffaf4ff9aac524f31efbc3f24d021540b6c579813c",
+ "sha256:703e2e34cbe0eedb0d319eff9f7b83e2022bb5a3ab5289a6a8841441076514d0"
],
"index": "pypi",
- "version": "==2.2.0"
+ "version": "==2.4.0"
},
"pycodestyle": {
"hashes": [
- "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
- "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
+ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
+ "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
],
- "version": "==2.5.0"
+ "version": "==2.6.0"
},
"pydocstyle": {
"hashes": [
@@ -793,10 +802,10 @@
},
"pyflakes": {
"hashes": [
- "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
- "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
+ "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
+ "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
],
- "version": "==2.1.1"
+ "version": "==2.2.0"
},
"pyyaml": {
"hashes": [
@@ -815,6 +824,14 @@
"index": "pypi",
"version": "==5.3.1"
},
+ "redis": {
+ "hashes": [
+ "sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242",
+ "sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251"
+ ],
+ "index": "pypi",
+ "version": "==3.5.2"
+ },
"six": {
"hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
@@ -829,12 +846,19 @@
],
"version": "==2.0.0"
},
+ "sortedcontainers": {
+ "hashes": [
+ "sha256:974e9a32f56b17c1bac2aebd9dcf197f3eb9cd30553c5852a3187ad162e1a03a",
+ "sha256:d9e96492dd51fae31e60837736b38fe42a187b5404c16606ff7ee7cd582d4c60"
+ ],
+ "version": "==2.1.0"
+ },
"toml": {
"hashes": [
- "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
- "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
+ "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
+ "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
],
- "version": "==0.10.0"
+ "version": "==0.10.1"
},
"unittest-xml-reporting": {
"hashes": [
@@ -846,10 +870,10 @@
},
"virtualenv": {
"hashes": [
- "sha256:5021396e8f03d0d002a770da90e31e61159684db2859d0ba4850fbea752aa675",
- "sha256:ac53ade75ca189bc97b6c1d9ec0f1a50efe33cbf178ae09452dcd9fd309013c1"
+ "sha256:b4c14d4d73a0c23db267095383c4276ef60e161f94fde0427f2f21a0132dde74",
+ "sha256:fd0e54dec8ac96c1c7c87daba85f0a59a7c37fe38748e154306ca21c73244637"
],
- "version": "==20.0.18"
+ "version": "==20.0.20"
}
}
}
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py
index d63acbc4a..0bcca578d 100644
--- a/bot/cogs/antispam.py
+++ b/bot/cogs/antispam.py
@@ -94,7 +94,7 @@ class DeletionContext:
await modlog.send_log_message(
icon_url=Icons.filtering,
colour=Colour(Colours.soft_red),
- title=f"Spam detected!",
+ title="Spam detected!",
text=mod_alert_message,
thumbnail=last_message.author.avatar_url_as(static_format="png"),
channel_id=Channels.mod_alerts,
@@ -130,7 +130,7 @@ class AntiSpam(Cog):
body += "\n\n**The cog has been unloaded.**"
await self.mod_log.send_log_message(
- title=f"Error: AntiSpam configuration validation failed!",
+ title="Error: AntiSpam configuration validation failed!",
text=body,
ping_everyone=True,
icon_url=Icons.token_removed,
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index 56fca002a..f4cb0aa58 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -81,7 +81,7 @@ class Defcon(Cog):
else:
self.enabled = False
self.days = timedelta(days=0)
- log.info(f"DEFCON disabled")
+ log.info("DEFCON disabled")
await self.update_channel_topic()
diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py
index 1f84a0609..37d1786a2 100644
--- a/bot/cogs/duck_pond.py
+++ b/bot/cogs/duck_pond.py
@@ -117,7 +117,7 @@ class DuckPond(Cog):
avatar_url=message.author.avatar_url
)
except discord.HTTPException:
- log.exception(f"Failed to send an attachment to the webhook")
+ log.exception("Failed to send an attachment to the webhook")
await message.add_reaction("✅")
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
index b2f4c59f6..16790c769 100644
--- a/bot/cogs/error_handler.py
+++ b/bot/cogs/error_handler.py
@@ -173,7 +173,7 @@ class ErrorHandler(Cog):
await ctx.invoke(*help_command)
self.bot.stats.incr("errors.missing_required_argument")
elif isinstance(e, errors.TooManyArguments):
- await ctx.send(f"Too many arguments provided.")
+ await ctx.send("Too many arguments provided.")
await ctx.invoke(*help_command)
self.bot.stats.incr("errors.too_many_arguments")
elif isinstance(e, errors.BadArgument):
@@ -213,7 +213,7 @@ class ErrorHandler(Cog):
if isinstance(e, bot_missing_errors):
ctx.bot.stats.incr("errors.bot_permission_error")
await ctx.send(
- f"Sorry, it looks like I don't have the permissions or roles I need to do that."
+ "Sorry, it looks like I don't have the permissions or roles I need to do that."
)
elif isinstance(e, (InWhitelistCheckFailure, errors.NoPrivateMessage)):
ctx.bot.stats.incr("errors.wrong_channel_or_dm_error")
diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py
index 1bd1f9d68..a20fe2b05 100644
--- a/bot/cogs/help_channels.py
+++ b/bot/cogs/help_channels.py
@@ -391,7 +391,7 @@ class HelpChannels(Scheduler, commands.Cog):
self.in_use_category = await self.try_get_channel(constants.Categories.help_in_use)
self.dormant_category = await self.try_get_channel(constants.Categories.help_dormant)
except discord.HTTPException:
- log.exception(f"Failed to get a category; cog will be removed")
+ log.exception("Failed to get a category; cog will be removed")
self.bot.remove_cog(self.qualified_name)
async def init_cog(self) -> None:
diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py
index 250a24247..82ec6b0d9 100644
--- a/bot/cogs/moderation/management.py
+++ b/bot/cogs/moderation/management.py
@@ -49,8 +49,8 @@ class ModManagement(commands.Cog):
async def infraction_edit(
self,
ctx: Context,
- infraction_id: t.Union[int, allowed_strings("l", "last", "recent")],
- duration: t.Union[Expiry, allowed_strings("p", "permanent"), None],
+ infraction_id: t.Union[int, allowed_strings("l", "last", "recent")], # noqa: F821
+ duration: t.Union[Expiry, allowed_strings("p", "permanent"), None], # noqa: F821
*,
reason: str = None
) -> None:
@@ -83,14 +83,14 @@ class ModManagement(commands.Cog):
"actor__id": ctx.author.id,
"ordering": "-inserted_at"
}
- infractions = await self.bot.api_client.get(f"bot/infractions", params=params)
+ infractions = await self.bot.api_client.get("bot/infractions", params=params)
if infractions:
old_infraction = infractions[0]
infraction_id = old_infraction["id"]
else:
await ctx.send(
- f":x: Couldn't find most recent infraction; you have never given an infraction."
+ ":x: Couldn't find most recent infraction; you have never given an infraction."
)
return
else:
@@ -224,7 +224,7 @@ class ModManagement(commands.Cog):
) -> None:
"""Send a paginated embed of infractions for the specified user."""
if not infractions:
- await ctx.send(f":warning: No infractions could be found for that query.")
+ await ctx.send(":warning: No infractions could be found for that query.")
return
lines = tuple(
diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py
index dc42bee2e..012432e60 100644
--- a/bot/cogs/moderation/scheduler.py
+++ b/bot/cogs/moderation/scheduler.py
@@ -91,7 +91,7 @@ class InfractionScheduler(Scheduler):
log.trace(f"Applying {infr_type} infraction #{id_} to {user}.")
# Default values for the confirmation message and mod log.
- confirm_msg = f":ok_hand: applied"
+ confirm_msg = ":ok_hand: applied"
# Specifying an expiry for a note or warning makes no sense.
if infr_type in ("note", "warning"):
@@ -154,7 +154,7 @@ class InfractionScheduler(Scheduler):
self.schedule_task(infraction["id"], infraction)
except discord.HTTPException as e:
# Accordingly display that applying the infraction failed.
- confirm_msg = f":x: failed to apply"
+ confirm_msg = ":x: failed to apply"
expiry_msg = ""
log_content = ctx.author.mention
log_title = "failed to apply"
@@ -281,7 +281,7 @@ class InfractionScheduler(Scheduler):
log.warning(f"Failed to pardon {infr_type} infraction #{id_} for {user}.")
else:
- confirm_msg = f":ok_hand: pardoned"
+ confirm_msg = ":ok_hand: pardoned"
log_title = "pardoned"
log.info(f"Pardoned {infr_type} infraction #{id_} for {user}.")
@@ -353,7 +353,7 @@ class InfractionScheduler(Scheduler):
)
except discord.Forbidden:
log.warning(f"Failed to deactivate infraction #{id_} ({type_}): bot lacks permissions.")
- log_text["Failure"] = f"The bot lacks permissions to do this (role hierarchy?)"
+ log_text["Failure"] = "The bot lacks permissions to do this (role hierarchy?)"
log_content = mod_role.mention
except discord.HTTPException as e:
log.exception(f"Failed to deactivate infraction #{id_} ({type_})")
@@ -402,7 +402,7 @@ class InfractionScheduler(Scheduler):
# Send a log message to the mod log.
if send_log:
- log_title = f"expiration failed" if "Failure" in log_text else "expired"
+ log_title = "expiration failed" if "Failure" in log_text else "expired"
user = self.bot.get_user(user_id)
avatar = user.avatar_url_as(static_format="png") if user else None
diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py
index 1ef3967a9..25febfa51 100644
--- a/bot/cogs/moderation/silence.py
+++ b/bot/cogs/moderation/silence.py
@@ -91,7 +91,7 @@ class Silence(commands.Cog):
await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).")
await asyncio.sleep(duration*60)
- log.info(f"Unsilencing channel after set delay.")
+ log.info("Unsilencing channel after set delay.")
await ctx.invoke(self.unsilence)
@commands.command(aliases=("unhush",))
diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py
index d253db913..e088c2b87 100644
--- a/bot/cogs/stats.py
+++ b/bot/cogs/stats.py
@@ -59,7 +59,7 @@ class Stats(Cog):
if member.guild.id != Guild.id:
return
- self.bot.stats.gauge(f"guild.total_members", len(member.guild.members))
+ self.bot.stats.gauge("guild.total_members", len(member.guild.members))
@Cog.listener()
async def on_member_leave(self, member: Member) -> None:
@@ -67,7 +67,7 @@ class Stats(Cog):
if member.guild.id != Guild.id:
return
- self.bot.stats.gauge(f"guild.total_members", len(member.guild.members))
+ self.bot.stats.gauge("guild.total_members", len(member.guild.members))
@Cog.listener()
async def on_member_update(self, _before: Member, after: Member) -> None:
diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py
index 89d556f58..f76daedac 100644
--- a/bot/cogs/utils.py
+++ b/bot/cogs/utils.py
@@ -253,8 +253,8 @@ class Utils(Cog):
async def send_pep_zero(self, ctx: Context) -> None:
"""Send information about PEP 0."""
pep_embed = Embed(
- title=f"**PEP 0 - Index of Python Enhancement Proposals (PEPs)**",
- description=f"[Link](https://www.python.org/dev/peps/)"
+ title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**",
+ description="[Link](https://www.python.org/dev/peps/)"
)
pep_embed.set_thumbnail(url=ICON_URL)
pep_embed.add_field(name="Status", value="Active")
diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py
index ad0c51fa6..68b220233 100644
--- a/bot/cogs/watchchannels/talentpool.py
+++ b/bot/cogs/watchchannels/talentpool.py
@@ -61,7 +61,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):
return
if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles):
- await ctx.send(f":x: Nominating staff members, eh? Here's a cookie :cookie:")
+ await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:")
return
if not await self.fetch_user_cache():
diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py
index 479820444..643cd46e4 100644
--- a/bot/cogs/watchchannels/watchchannel.py
+++ b/bot/cogs/watchchannels/watchchannel.py
@@ -82,7 +82,7 @@ class WatchChannel(metaclass=CogABCMeta):
exc = self._consume_task.exception()
if exc:
self.log.exception(
- f"The message queue consume task has failed with:",
+ "The message queue consume task has failed with:",
exc_info=exc
)
return False
@@ -146,7 +146,7 @@ class WatchChannel(metaclass=CogABCMeta):
try:
data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params)
except ResponseCodeError as err:
- self.log.exception(f"Failed to fetch the watched users from the API", exc_info=err)
+ self.log.exception("Failed to fetch the watched users from the API", exc_info=err)
return False
self.watched_users = defaultdict(dict)
@@ -173,7 +173,7 @@ class WatchChannel(metaclass=CogABCMeta):
self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue")
await asyncio.sleep(BigBrotherConfig.log_delay)
- self.log.trace(f"Started consuming the message queue")
+ self.log.trace("Started consuming the message queue")
# If the previous consumption Task failed, first consume the existing comsumption_queue
if not self.consumption_queue:
@@ -208,7 +208,7 @@ class WatchChannel(metaclass=CogABCMeta):
await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed)
except discord.HTTPException as exc:
self.log.exception(
- f"Failed to send a message to the webhook",
+ "Failed to send a message to the webhook",
exc_info=exc
)
@@ -254,7 +254,7 @@ class WatchChannel(metaclass=CogABCMeta):
)
except discord.HTTPException as exc:
self.log.exception(
- f"Failed to send an attachment to the webhook",
+ "Failed to send an attachment to the webhook",
exc_info=exc
)
@@ -326,13 +326,13 @@ class WatchChannel(metaclass=CogABCMeta):
def cog_unload(self) -> None:
"""Takes care of unloading the cog and canceling the consumption task."""
- self.log.trace(f"Unloading the cog")
+ self.log.trace("Unloading the cog")
if self._consume_task and not self._consume_task.done():
self._consume_task.cancel()
try:
self._consume_task.result()
except asyncio.CancelledError as e:
self.log.exception(
- f"The consume task was canceled. Messages may be lost.",
+ "The consume task was canceled. Messages may be lost.",
exc_info=e
)
diff --git a/bot/constants.py b/bot/constants.py
index fd280e9de..01e8ac3a3 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -199,6 +199,9 @@ class Bot(metaclass=YAMLGetter):
prefix: str
token: str
sentry_dsn: str
+ redis_host: str
+ redis_port: int
+
class Filter(metaclass=YAMLGetter):
section = "filter"
diff --git a/bot/decorators.py b/bot/decorators.py
index 306f0830c..3c904cf7c 100644
--- a/bot/decorators.py
+++ b/bot/decorators.py
@@ -121,7 +121,7 @@ def locked() -> Callable:
embed = Embed()
embed.colour = Colour.red()
- log.debug(f"User tried to invoke a locked command.")
+ log.debug("User tried to invoke a locked command.")
embed.description = (
"You're already using this command. Please wait until it is done before you use it again."
)
diff --git a/bot/pagination.py b/bot/pagination.py
index b0c4b70e2..2aa3590ba 100644
--- a/bot/pagination.py
+++ b/bot/pagination.py
@@ -147,7 +147,7 @@ class LinePaginator(Paginator):
if not lines:
if exception_on_empty_embed:
- log.exception(f"Pagination asked for empty lines iterable")
+ log.exception("Pagination asked for empty lines iterable")
raise EmptyPaginatorEmbed("No lines to paginate")
log.debug("No lines to add to paginator, adding '(nothing to display)' message")
@@ -357,7 +357,7 @@ class ImagePaginator(Paginator):
if not pages:
if exception_on_empty_embed:
- log.exception(f"Pagination asked for empty image list")
+ log.exception("Pagination asked for empty image list")
raise EmptyPaginatorEmbed("No images to paginate")
log.debug("No images to add to paginator, adding '(no images to display)' message")
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 9b32e515d..5ce383bf2 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -2,6 +2,10 @@ from abc import ABCMeta
from discord.ext.commands import CogMeta
+from bot.utils.redis_dict import RedisDict
+
+__all__ = ['RedisDict', 'CogABCMeta']
+
class CogABCMeta(CogMeta, ABCMeta):
"""Metaclass for ABCs meant to be implemented as Cogs."""
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
index e969ee590..de8e186f3 100644
--- a/bot/utils/messages.py
+++ b/bot/utils/messages.py
@@ -100,7 +100,7 @@ async def send_attachments(
log.warning(f"{failure_msg} with status {e.status}.")
if link_large and large:
- desc = f"\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)
+ desc = "\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large)
embed = Embed(description=desc)
embed.set_footer(text="Attachments exceed upload size limit.")
diff --git a/bot/utils/redis_dict.py b/bot/utils/redis_dict.py
new file mode 100644
index 000000000..47905314a
--- /dev/null
+++ b/bot/utils/redis_dict.py
@@ -0,0 +1,136 @@
+from __future__ import annotations
+
+import json
+from collections.abc import MutableMapping
+from enum import Enum
+from typing import Dict, List, Optional, Tuple, Union
+
+import redis as redis_py
+
+from bot import constants
+
+ValidRedisKey = Union[str, int, float]
+JSONSerializableType = Optional[Union[str, float, bool, Dict, List, Tuple, Enum]]
+
+
+class RedisDict(MutableMapping):
+ """
+ A dictionary interface for a Redis database.
+
+ Objects created by this class should mostly behave like a normal dictionary,
+ but will store all the data in our Redis database for persistence between restarts.
+
+ Redis is limited to simple types, so to allow you to store collections like lists
+ and dictionaries, we JSON deserialize every value. That means that it will not be possible
+ to store complex objects, only stuff like strings, numbers, and collections of strings and numbers.
+ """
+
+ _namespaces = []
+ _redis = redis_py.Redis(
+ host=constants.Bot.redis_host,
+ port=constants.Bot.redis_port,
+ ) # Can be overridden for testing
+
+ def __init__(self, namespace: Optional[str] = None) -> None:
+ """Initialize the RedisDict with the right namespace."""
+ super().__init__()
+ self._has_custom_namespace = namespace is not None
+
+ if self._has_custom_namespace:
+ self._set_namespace(namespace)
+ else:
+ self.namespace = "global"
+
+ def _set_namespace(self, namespace: str) -> None:
+ """Try to set the namespace, but do not permit collisions."""
+ while namespace in self._namespaces:
+ namespace = namespace + "_"
+
+ self._namespaces.append(namespace)
+ self._namespace = namespace
+
+ def __set_name__(self, owner: object, attribute_name: str) -> None:
+ """
+ Set the namespace to Class.attribute_name.
+
+ Called automatically when this class is constructed inside a class as an attribute, as long as
+ no custom namespace is provided to the constructor.
+ """
+ if not self._has_custom_namespace:
+ self._set_namespace(f"{owner.__name__}.{attribute_name}")
+
+ def __repr__(self) -> str:
+ """Return a beautiful representation of this object instance."""
+ return f"RedisDict(namespace={self._namespace!r})"
+
+ def __eq__(self, other: RedisDict) -> bool:
+ """Check equality between two RedisDicts."""
+ return self.items() == other.items() and self._namespace == other._namespace
+
+ def __ne__(self, other: RedisDict) -> bool:
+ """Check inequality between two RedisDicts."""
+ return self.items() != other.items() or self._namespace != other._namespace
+
+ def __setitem__(self, key: ValidRedisKey, value: JSONSerializableType):
+ """Store an item in the Redis cache."""
+ # JSON serialize the value before storing it.
+ json_value = json.dumps(value)
+ self._redis.hset(self._namespace, key, json_value)
+
+ def __getitem__(self, key: ValidRedisKey):
+ """Get an item from the Redis cache."""
+ value = self._redis.hget(self._namespace, key)
+
+ if value:
+ return json.loads(value)
+
+ def __delitem__(self, key: ValidRedisKey):
+ """Delete an item from the Redis cache."""
+ self._redis.hdel(self._namespace, key)
+
+ def __contains__(self, key: ValidRedisKey):
+ """Check if a key exists in the Redis cache."""
+ return self._redis.hexists(self._namespace, key)
+
+ def __iter__(self):
+ """Iterate all the items in the Redis cache."""
+ keys = self._redis.hkeys(self._namespace)
+ return iter([key.decode('utf-8') for key in keys])
+
+ def __len__(self):
+ """Return the number of items in the Redis cache."""
+ return self._redis.hlen(self._namespace)
+
+ def copy(self) -> Dict:
+ """Convert to dict and return."""
+ return dict(self.items())
+
+ def clear(self) -> None:
+ """Deletes the entire hash from the Redis cache."""
+ self._redis.delete(self._namespace)
+
+ def get(self, key: ValidRedisKey, default: Optional[JSONSerializableType] = None) -> JSONSerializableType:
+ """Get the item, but provide a default if not found."""
+ if key in self:
+ return self[key]
+ else:
+ return default
+
+ def pop(self, key: ValidRedisKey, default: Optional[JSONSerializableType] = None) -> JSONSerializableType:
+ """Get the item, remove it from the cache, and provide a default if not found."""
+ value = self.get(key, default)
+ del self[key]
+ return value
+
+ def popitem(self) -> JSONSerializableType:
+ """Get the last item added to the cache."""
+ key = list(self.keys())[-1]
+ return self.pop(key)
+
+ def setdefault(self, key: ValidRedisKey, default: Optional[JSONSerializableType] = None) -> JSONSerializableType:
+ """Try to get the item. If the item does not exist, set it to `default` and return that."""
+ value = self.get(key)
+
+ if value is None:
+ self[key] = default
+ return default
diff --git a/config-default.yml b/config-default.yml
index 83ea59016..722afa41b 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -2,6 +2,8 @@ bot:
prefix: "!"
token: !ENV "BOT_TOKEN"
sentry_dsn: !ENV "BOT_SENTRY_DSN"
+ redis_host: "redis"
+ redis_port: 6379
stats:
statsd_host: "graphite"
diff --git a/docker-compose.yml b/docker-compose.yml
index 11deceae8..1bcf1008e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,6 +12,9 @@ services:
POSTGRES_PASSWORD: pysite
POSTGRES_USER: pysite
+ redis:
+ image: redis:5.0.9
+
web:
image: pythondiscord/site:latest
command: ["run", "--debug"]
@@ -41,6 +44,7 @@ services:
tty: true
depends_on:
- web
+ - redis
environment:
BOT_TOKEN: ${BOT_TOKEN}
BOT_API_KEY: badbot13m0n8f570f942013fc818f234916ca531
diff --git a/tests/bot/utils/test_redis_dict.py b/tests/bot/utils/test_redis_dict.py
new file mode 100644
index 000000000..f422887ce
--- /dev/null
+++ b/tests/bot/utils/test_redis_dict.py
@@ -0,0 +1,189 @@
+import unittest
+
+import fakeredis
+from redis import DataError
+
+from bot.utils import RedisDict
+
+redis_server = fakeredis.FakeServer()
+RedisDict._redis = fakeredis.FakeStrictRedis(server=redis_server)
+
+
+class RedisDictTests(unittest.TestCase):
+ """Tests the RedisDict class from utils.redis_dict.py."""
+
+ redis = RedisDict()
+
+ def test_class_attribute_namespace(self):
+ """Test that RedisDict creates a namespace automatically for class attributes."""
+ self.assertEqual(self.redis._namespace, "RedisDictTests.redis")
+
+ def test_custom_namespace(self):
+ """Test that users can set a custom namespaces which never collide."""
+ test_cases = (
+ (RedisDict("firedog")._namespace, "firedog"),
+ (RedisDict("firedog")._namespace, "firedog_"),
+ (RedisDict("firedog")._namespace, "firedog__"),
+ )
+
+ for test_case, result in test_cases:
+ self.assertEqual(test_case, result)
+
+ def test_custom_namespace_takes_precedence(self):
+ """Test that custom namespaces take precedence over class attribute ones."""
+ class LemonJuice:
+ citrus = RedisDict("citrus")
+ watercat = RedisDict()
+
+ test_class = LemonJuice()
+ self.assertEqual(test_class.citrus._namespace, "citrus")
+ self.assertEqual(test_class.watercat._namespace, "LemonJuice.watercat")
+
+ def test_set_get_item(self):
+ """Test that users can set and get items from the RedisDict."""
+ self.redis['favorite_fruit'] = 'melon'
+ self.redis['favorite_number'] = 86
+ self.assertEqual(self.redis['favorite_fruit'], 'melon')
+ self.assertEqual(self.redis['favorite_number'], 86)
+
+ def test_set_item_value_types(self):
+ """Test that setitem rejects values that are not JSON serializable."""
+ with self.assertRaises(TypeError):
+ self.redis['favorite_thing'] = object
+ self.redis['favorite_stuff'] = RedisDict
+
+ def test_set_item_key_types(self):
+ """Test that setitem rejects keys that are not strings, ints or floats."""
+ fruits = ["lemon", "melon", "apple"]
+
+ with self.assertRaises(DataError):
+ self.redis[fruits] = "nice"
+
+ def test_get_method(self):
+ """Test that the .get method works like in a dict."""
+ self.redis['favorite_movie'] = 'Code Jam Highlights'
+
+ self.assertEqual(self.redis.get('favorite_movie'), 'Code Jam Highlights')
+ self.assertEqual(self.redis.get('favorite_youtuber', 'pydis'), 'pydis')
+ self.assertIsNone(self.redis.get('favorite_dog'))
+
+ def test_membership(self):
+ """Test that we can reliably use the `in` operator with our RedisDict."""
+ self.redis['favorite_country'] = "Burkina Faso"
+
+ self.assertIn('favorite_country', self.redis)
+ self.assertNotIn('favorite_dentist', self.redis)
+
+ def test_del_item(self):
+ """Test that users can delete items from the RedisDict."""
+ self.redis['favorite_band'] = "Radiohead"
+ self.assertIn('favorite_band', self.redis)
+
+ del self.redis['favorite_band']
+ self.assertNotIn('favorite_band', self.redis)
+
+ def test_iter(self):
+ """Test that the RedisDict can be iterated."""
+ self.redis.clear()
+ test_cases = (
+ ('favorite_turtle', 'Donatello'),
+ ('second_favorite_turtle', 'Leonardo'),
+ ('third_favorite_turtle', 'Raphael'),
+ )
+ for key, value in test_cases:
+ self.redis[key] = value
+
+ # Test regular iteration
+ for test_case, key in zip(test_cases, self.redis):
+ value = test_case[1]
+ self.assertEqual(self.redis[key], value)
+
+ # Test .items iteration
+ for key, value in self.redis.items():
+ self.assertEqual(self.redis[key], value)
+
+ # Test .keys iteration
+ for test_case, key in zip(test_cases, self.redis.keys()):
+ value = test_case[1]
+ self.assertEqual(self.redis[key], value)
+
+ def test_len(self):
+ """Test that we can get the correct len() from the RedisDict."""
+ self.redis.clear()
+ self.redis['one'] = 1
+ self.redis['two'] = 2
+ self.redis['three'] = 3
+ self.assertEqual(len(self.redis), 3)
+
+ self.redis['four'] = 4
+ self.assertEqual(len(self.redis), 4)
+
+ def test_copy(self):
+ """Test that the .copy method returns a workable dictionary copy."""
+ copy = self.redis.copy()
+ local_copy = dict(self.redis.items())
+ self.assertIs(type(copy), dict)
+ self.assertEqual(copy, local_copy)
+
+ def test_clear(self):
+ """Test that the .clear method removes the entire hash."""
+ self.redis.clear()
+ self.redis['teddy'] = "with me"
+ self.redis['in my dreams'] = "you have a weird hat"
+ self.assertEqual(len(self.redis), 2)
+
+ self.redis.clear()
+ self.assertEqual(len(self.redis), 0)
+
+ def test_pop(self):
+ """Test that we can .pop an item from the RedisDict."""
+ self.redis.clear()
+ self.redis['john'] = 'was afraid'
+
+ self.assertEqual(self.redis.pop('john'), 'was afraid')
+ self.assertEqual(self.redis.pop('pete', 'breakneck'), 'breakneck')
+ self.assertEqual(len(self.redis), 0)
+
+ def test_popitem(self):
+ """Test that we can .popitem an item from the RedisDict."""
+ self.redis.clear()
+ self.redis['john'] = 'the revalator'
+ self.redis['teddy'] = 'big bear'
+
+ self.assertEqual(len(self.redis), 2)
+ self.assertEqual(self.redis.popitem(), 'big bear')
+ self.assertEqual(len(self.redis), 1)
+
+ def test_setdefault(self):
+ """Test that we can .setdefault an item from the RedisDict."""
+ self.redis.clear()
+ self.redis.setdefault('john', 'is yellow and weak')
+ self.assertEqual(self.redis['john'], 'is yellow and weak')
+
+ with self.assertRaises(TypeError):
+ self.redis.setdefault('geisha', object)
+
+ def test_update(self):
+ """Test that we can .update the RedisDict with multiple items."""
+ self.redis.clear()
+ self.redis["reckfried"] = "lona"
+ self.redis["bel air"] = "prince"
+ self.redis.update({
+ "reckfried": "jona",
+ "mega": "hungry, though",
+ })
+
+ result = {
+ "reckfried": "jona",
+ "bel air": "prince",
+ "mega": "hungry, though",
+ }
+ self.assertEqual(self.redis.copy(), result)
+
+ def test_equals(self):
+ """Test that RedisDicts can be compared with == and !=."""
+ new_redis_dict = RedisDict("firedog_the_sequel")
+ new_new_redis_dict = new_redis_dict
+
+ self.assertEqual(new_redis_dict, new_new_redis_dict)
+ self.assertNotEqual(new_redis_dict, self.redis)