aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar kwzrd <[email protected]>2021-03-31 19:56:34 +0200
committerGravatar kwzrd <[email protected]>2021-03-31 19:56:34 +0200
commit25b1a72735fc6d638b0263ef8b55b9d9cf62e7af (patch)
treeaa1bf64fe2e53ee4080081ad52fda4294a85ac06
parentMerge: changes from 'upstream/main' & conflict resolution (diff)
parentMerge pull request #1491 from python-discord/fix/dmrelay (diff)
Merge: changes from 'upstream/main'
Lockfile conflict resolved by re-locking on the merged Pipfile.
-rw-r--r--.github/CODEOWNERS18
-rw-r--r--Pipfile6
-rw-r--r--Pipfile.lock105
-rw-r--r--bot/constants.py6
-rw-r--r--bot/exts/help_channels/_caches.py15
-rw-r--r--bot/exts/help_channels/_channel.py87
-rw-r--r--bot/exts/help_channels/_cog.py63
-rw-r--r--bot/exts/help_channels/_message.py43
-rw-r--r--bot/exts/help_channels/_stats.py13
-rw-r--r--bot/exts/info/information.py10
-rw-r--r--bot/exts/moderation/dm_relay.py160
-rw-r--r--bot/utils/services.py9
-rw-r--r--config-default.yml15
-rw-r--r--tests/bot/utils/test_services.py4
14 files changed, 282 insertions, 272 deletions
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 634bb4bca..1df05e990 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -4,14 +4,14 @@
**/bot/exts/moderation/*silence.py @MarkKoz
bot/exts/info/codeblock/** @MarkKoz
bot/exts/utils/extensions.py @MarkKoz
-bot/exts/utils/snekbox.py @MarkKoz @Akarys42
+bot/exts/utils/snekbox.py @MarkKoz @Akarys42 @jb3
bot/exts/help_channels/** @MarkKoz @Akarys42
-bot/exts/moderation/** @Akarys42 @mbaruh @Den4200 @ks129
-bot/exts/info/** @Akarys42 @Den4200
-bot/exts/info/information.py @mbaruh
-bot/exts/filters/** @mbaruh
+bot/exts/moderation/** @Akarys42 @mbaruh @Den4200 @ks129 @jb3
+bot/exts/info/** @Akarys42 @Den4200 @jb3
+bot/exts/info/information.py @mbaruh @jb3
+bot/exts/filters/** @mbaruh @jb3
bot/exts/fun/** @ks129
-bot/exts/utils/** @ks129
+bot/exts/utils/** @ks129 @jb3
bot/exts/recruitment/** @wookie184
# Rules
@@ -30,9 +30,9 @@ tests/bot/exts/test_cogs.py @MarkKoz
tests/** @Akarys42
# CI & Docker
-.github/workflows/** @MarkKoz @Akarys42 @SebastiaanZ @Den4200
-Dockerfile @MarkKoz @Akarys42 @Den4200
-docker-compose.yml @MarkKoz @Akarys42 @Den4200
+.github/workflows/** @MarkKoz @Akarys42 @SebastiaanZ @Den4200 @jb3
+Dockerfile @MarkKoz @Akarys42 @Den4200 @jb3
+docker-compose.yml @MarkKoz @Akarys42 @Den4200 @jb3
# Tools
Pipfile* @Akarys42
diff --git a/Pipfile b/Pipfile
index 86add29cb..7fab198f3 100644
--- a/Pipfile
+++ b/Pipfile
@@ -9,12 +9,14 @@ aiodns = "~=2.0"
aiohttp = "~=3.7"
aioping = "~=0.3.1"
aioredis = "~=1.3.1"
+arrow = "~=1.0.3"
"async-rediscache[fakeredis]" = "~=0.1.2"
beautifulsoup4 = "~=4.9"
colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"}
coloredlogs = "~=14.0"
deepdiff = "~=4.0"
"discord.py" = "~=1.6.0"
+emoji = "~=0.6"
feedparser = "~=5.2"
fuzzywuzzy = "~=0.17"
lxml = "~=4.4"
@@ -27,11 +29,10 @@ requests = "~=2.22"
sentry-sdk = "~=0.19"
sphinx = "~=2.2"
statsd = "~=3.3"
-arrow = "~=0.17"
-emoji = "~=0.6"
[dev-packages]
coverage = "~=5.0"
+coveralls = "~=2.1"
flake8 = "~=3.8"
flake8-annotations = "~=2.0"
flake8-bugbear = "~=20.1"
@@ -42,7 +43,6 @@ flake8-tidy-imports = "~=4.0"
flake8-todo = "~=0.7"
pep8-naming = "~=0.9"
pre-commit = "~=2.1"
-coveralls = "~=2.1"
[requires]
python_version = "3.8"
diff --git a/Pipfile.lock b/Pipfile.lock
index 240e2542e..cbec48ef0 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "0f60e21b90fbc90c75f5978e15ed584f7cab7cb358d24c0f1d6b132fbc8b1907"
+ "sha256": "91b5639198b35740611e7ac923cfc262e5897b8cbc3ca243dc98335705804ba7"
},
"pipfile-spec": 6,
"requires": {
@@ -108,11 +108,11 @@
},
"arrow": {
"hashes": [
- "sha256:e098abbd9af3665aea81bdd6c869e93af4feb078e98468dd351c383af187aac5",
- "sha256:ff08d10cda1d36c68657d6ad20d74fbea493d980f8b2d45344e00d6ed2bf6ed4"
+ "sha256:3515630f11a15c61dcb4cdd245883270dd334c83f3e639824e65a4b79cc48543",
+ "sha256:399c9c8ae732270e1aa58ead835a79a40d7be8aa109c579898eb41029b5a231d"
],
"index": "pypi",
- "version": "==0.17.0"
+ "version": "==1.0.3"
},
"async-rediscache": {
"extras": [
@@ -289,55 +289,50 @@
},
"hiredis": {
"hashes": [
- "sha256:06a039208f83744a702279b894c8cf24c14fd63c59cd917dcde168b79eef0680",
- "sha256:0a909bf501459062aa1552be1461456518f367379fdc9fdb1f2ca5e4a1fdd7c0",
- "sha256:18402d9e54fb278cb9a8c638df6f1550aca36a009d47ecf5aa263a38600f35b0",
- "sha256:1e4cbbc3858ec7e680006e5ca590d89a5e083235988f26a004acf7244389ac01",
- "sha256:23344e3c2177baf6975fbfa361ed92eb7d36d08f454636e5054b3faa7c2aff8a",
- "sha256:289b31885b4996ce04cadfd5fc03d034dce8e2a8234479f7c9e23b9e245db06b",
- "sha256:2c1c570ae7bf1bab304f29427e2475fe1856814312c4a1cf1cd0ee133f07a3c6",
- "sha256:2c227c0ed371771ffda256034427320870e8ea2e4fd0c0a618c766e7c49aad73",
- "sha256:3bb9b63d319402cead8bbd9dd55dca3b667d2997e9a0d8a1f9b6cc274db4baee",
- "sha256:3ef2183de67b59930d2db8b8e8d4d58e00a50fcc5e92f4f678f6eed7a1c72d55",
- "sha256:43b8ed3dbfd9171e44c554cb4acf4ee4505caa84c5e341858b50ea27dd2b6e12",
- "sha256:47bcf3c5e6c1e87ceb86cdda2ee983fa0fe56a999e6185099b3c93a223f2fa9b",
- "sha256:5263db1e2e1e8ae30500cdd75a979ff99dcc184201e6b4b820d0de74834d2323",
- "sha256:5b1451727f02e7acbdf6aae4e06d75f66ee82966ff9114550381c3271a90f56c",
- "sha256:6996883a8a6ff9117cbb3d6f5b0dcbbae6fb9e31e1a3e4e2f95e0214d9a1c655",
- "sha256:6c96f64a54f030366657a54bb90b3093afc9c16c8e0dfa29fc0d6dbe169103a5",
- "sha256:7332d5c3e35154cd234fd79573736ddcf7a0ade7a986db35b6196b9171493e75",
- "sha256:7885b6f32c4a898e825bb7f56f36a02781ac4a951c63e4169f0afcf9c8c30dfb",
- "sha256:7b0f63f10a166583ab744a58baad04e0f52cfea1ac27bfa1b0c21a48d1003c23",
- "sha256:819f95d4eba3f9e484dd115ab7ab72845cf766b84286a00d4ecf76d33f1edca1",
- "sha256:8968eeaa4d37a38f8ca1f9dbe53526b69628edc9c42229a5b2f56d98bb828c1f",
- "sha256:89ebf69cb19a33d625db72d2ac589d26e936b8f7628531269accf4a3196e7872",
- "sha256:8daecd778c1da45b8bd54fd41ffcd471a86beed3d8e57a43acf7a8d63bba4058",
- "sha256:955ba8ea73cf3ed8bd2f963b4cb9f8f0dcb27becd2f4b3dd536fd24c45533454",
- "sha256:964f18a59f5a64c0170f684c417f4fe3e695a536612e13074c4dd5d1c6d7c882",
- "sha256:969843fbdfbf56cdb71da6f0bdf50f9985b8b8aeb630102945306cf10a9c6af2",
- "sha256:996021ef33e0f50b97ff2d6b5f422a0fe5577de21a8873b58a779a5ddd1c3132",
- "sha256:9e9c9078a7ce07e6fce366bd818be89365a35d2e4b163268f0ca9ba7e13bb2f6",
- "sha256:a04901757cb0fb0f5602ac11dda48f5510f94372144d06c2563ba56c480b467c",
- "sha256:a7bf1492429f18d205f3a818da3ff1f242f60aa59006e53dee00b4ef592a3363",
- "sha256:aa0af2deb166a5e26e0d554b824605e660039b161e37ed4f01b8d04beec184f3",
- "sha256:abfb15a6a7822f0fae681785cb38860e7a2cb1616a708d53df557b3d76c5bfd4",
- "sha256:b253fe4df2afea4dfa6b1fa8c5fef212aff8bcaaeb4207e81eed05cb5e4a7919",
- "sha256:b27f082f47d23cffc4cf1388b84fdc45c4ef6015f906cd7e0d988d9e35d36349",
- "sha256:b33aea449e7f46738811fbc6f0b3177c6777a572207412bbbf6f525ffed001ae",
- "sha256:b44f9421c4505c548435244d74037618f452844c5d3c67719d8a55e2613549da",
- "sha256:bcc371151d1512201d0214c36c0c150b1dc64f19c2b1a8c9cb1d7c7c15ebd93f",
- "sha256:c2851deeabd96d3f6283e9c6b26e0bfed4de2dc6fb15edf913e78b79fc5909ed",
- "sha256:cdfd501c7ac5b198c15df800a3a34c38345f5182e5f80770caf362bccca65628",
- "sha256:d2c0caffa47606d6d7c8af94ba42547bd2a441f06c74fd90a1ffe328524a6c64",
- "sha256:dcb2db95e629962db5a355047fb8aefb012df6c8ae608930d391619dbd96fd86",
- "sha256:e0eeb9c112fec2031927a1745788a181d0eecbacbed941fc5c4f7bc3f7b273bf",
- "sha256:e154891263306200260d7f3051982774d7b9ef35af3509d5adbbe539afd2610c",
- "sha256:e2e023a42dcbab8ed31f97c2bcdb980b7fbe0ada34037d87ba9d799664b58ded",
- "sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390",
- "sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298"
+ "sha256:04026461eae67fdefa1949b7332e488224eac9e8f2b5c58c98b54d29af22093e",
+ "sha256:04927a4c651a0e9ec11c68e4427d917e44ff101f761cd3b5bc76f86aaa431d27",
+ "sha256:07bbf9bdcb82239f319b1f09e8ef4bdfaec50ed7d7ea51a56438f39193271163",
+ "sha256:09004096e953d7ebd508cded79f6b21e05dff5d7361771f59269425108e703bc",
+ "sha256:0adea425b764a08270820531ec2218d0508f8ae15a448568109ffcae050fee26",
+ "sha256:0b39ec237459922c6544d071cdcf92cbb5bc6685a30e7c6d985d8a3e3a75326e",
+ "sha256:0d5109337e1db373a892fdcf78eb145ffb6bbd66bb51989ec36117b9f7f9b579",
+ "sha256:0f41827028901814c709e744060843c77e78a3aca1e0d6875d2562372fcb405a",
+ "sha256:11d119507bb54e81f375e638225a2c057dda748f2b1deef05c2b1a5d42686048",
+ "sha256:1233e303645f468e399ec906b6b48ab7cd8391aae2d08daadbb5cad6ace4bd87",
+ "sha256:139705ce59d94eef2ceae9fd2ad58710b02aee91e7fa0ccb485665ca0ecbec63",
+ "sha256:1f03d4dadd595f7a69a75709bc81902673fa31964c75f93af74feac2f134cc54",
+ "sha256:240ce6dc19835971f38caf94b5738092cb1e641f8150a9ef9251b7825506cb05",
+ "sha256:294a6697dfa41a8cba4c365dd3715abc54d29a86a40ec6405d677ca853307cfb",
+ "sha256:3d55e36715ff06cdc0ab62f9591607c4324297b6b6ce5b58cb9928b3defe30ea",
+ "sha256:3dddf681284fe16d047d3ad37415b2e9ccdc6c8986c8062dbe51ab9a358b50a5",
+ "sha256:3f5f7e3a4ab824e3de1e1700f05ad76ee465f5f11f5db61c4b297ec29e692b2e",
+ "sha256:508999bec4422e646b05c95c598b64bdbef1edf0d2b715450a078ba21b385bcc",
+ "sha256:5d2a48c80cf5a338d58aae3c16872f4d452345e18350143b3bf7216d33ba7b99",
+ "sha256:5dc7a94bb11096bc4bffd41a3c4f2b958257085c01522aa81140c68b8bf1630a",
+ "sha256:65d653df249a2f95673976e4e9dd7ce10de61cfc6e64fa7eeaa6891a9559c581",
+ "sha256:7492af15f71f75ee93d2a618ca53fea8be85e7b625e323315169977fae752426",
+ "sha256:7f0055f1809b911ab347a25d786deff5e10e9cf083c3c3fd2dd04e8612e8d9db",
+ "sha256:807b3096205c7cec861c8803a6738e33ed86c9aae76cac0e19454245a6bbbc0a",
+ "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a",
+ "sha256:87c7c10d186f1743a8fd6a971ab6525d60abd5d5d200f31e073cd5e94d7e7a9d",
+ "sha256:8b42c0dc927b8d7c0eb59f97e6e34408e53bc489f9f90e66e568f329bff3e443",
+ "sha256:a00514362df15af041cc06e97aebabf2895e0a7c42c83c21894be12b84402d79",
+ "sha256:a39efc3ade8c1fb27c097fd112baf09d7fd70b8cb10ef1de4da6efbe066d381d",
+ "sha256:a4ee8000454ad4486fb9f28b0cab7fa1cd796fc36d639882d0b34109b5b3aec9",
+ "sha256:a7928283143a401e72a4fad43ecc85b35c27ae699cf5d54d39e1e72d97460e1d",
+ "sha256:adf4dd19d8875ac147bf926c727215a0faf21490b22c053db464e0bf0deb0485",
+ "sha256:ae8427a5e9062ba66fc2c62fb19a72276cf12c780e8db2b0956ea909c48acff5",
+ "sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048",
+ "sha256:b84f29971f0ad4adaee391c6364e6f780d5aae7e9226d41964b26b49376071d0",
+ "sha256:c39c46d9e44447181cd502a35aad2bb178dbf1b1f86cf4db639d7b9614f837c6",
+ "sha256:cb2126603091902767d96bcb74093bd8b14982f41809f85c9b96e519c7e1dc41",
+ "sha256:dcef843f8de4e2ff5e35e96ec2a4abbdf403bd0f732ead127bd27e51f38ac298",
+ "sha256:e3447d9e074abf0e3cd85aef8131e01ab93f9f0e86654db7ac8a3f73c63706ce",
+ "sha256:f52010e0a44e3d8530437e7da38d11fb822acfb0d5b12e9cd5ba655509937ca0",
+ "sha256:f8196f739092a78e4f6b1b2172679ed3343c39c61a3e9d722ce6fcf1dac2824a"
],
- "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
- "version": "==1.1.0"
+ "markers": "python_version >= '3.6'",
+ "version": "==2.0.0"
},
"humanfriendly": {
"hashes": [
@@ -1043,11 +1038,11 @@
},
"identify": {
"hashes": [
- "sha256:1cfb05b578de996677836d5a2dde14b3dffde313cf7d2b3e793a0787a36e26dd",
- "sha256:9cc5f58996cd359b7b72f0a5917d8639de5323917e6952a3bfbf36301b576f40"
+ "sha256:43cb1965e84cdd247e875dec6d13332ef5be355ddc16776396d98089b9053d87",
+ "sha256:c7c0f590526008911ccc5ceee6ed7b085cbc92f7b6591d0ee5913a130ad64034"
],
"markers": "python_full_version >= '3.6.1'",
- "version": "==2.2.1"
+ "version": "==2.2.2"
},
"idna": {
"hashes": [
diff --git a/bot/constants.py b/bot/constants.py
index 467a4a2c4..bcf246e72 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -402,7 +402,6 @@ class Channels(metaclass=YAMLGetter):
python_events: int
python_news: int
reddit: int
- user_event_announcements: int
dev_contrib: int
dev_core: int
@@ -414,7 +413,6 @@ class Channels(metaclass=YAMLGetter):
cooldown: int
attachment_log: int
- dm_log: int
message_log: int
mod_log: int
user_log: int
@@ -466,7 +464,6 @@ class Webhooks(metaclass=YAMLGetter):
big_brother: int
dev_log: int
- dm_log: int
duck_pond: int
incidents_archive: int
reddit: int
@@ -593,7 +590,8 @@ class HelpChannels(metaclass=YAMLGetter):
enable: bool
claim_minutes: int
cmd_whitelist: List[int]
- idle_minutes: int
+ idle_minutes_claimant: int
+ idle_minutes_others: int
deleted_idle_minutes: int
max_available: int
max_total_channels: int
diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py
index 4cea385b7..e741fd20f 100644
--- a/bot/exts/help_channels/_caches.py
+++ b/bot/exts/help_channels/_caches.py
@@ -8,12 +8,15 @@ claim_times = RedisCache(namespace="HelpChannels.claim_times")
# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]]
claimants = RedisCache(namespace="HelpChannels.help_channel_claimants")
+# Stores the timestamp of the last message from the claimant of a help channel
+# RedisCache[discord.TextChannel.id, UtcPosixTimestamp]
+claimant_last_message_times = RedisCache(namespace="HelpChannels.claimant_last_message_times")
+
+# This cache maps a help channel to the timestamp of the last non-claimant message.
+# This cache being empty for a given help channel indicates the question is unanswered.
+# RedisCache[discord.TextChannel.id, UtcPosixTimestamp]
+non_claimant_last_message_times = RedisCache(namespace="HelpChannels.non_claimant_last_message_times")
+
# This cache maps a help channel to original question message in same channel.
# RedisCache[discord.TextChannel.id, discord.Message.id]
question_messages = RedisCache(namespace="HelpChannels.question_messages")
-
-# This cache maps a help channel to whether it has had any
-# activity other than the original claimant. True being no other
-# activity and False being other activity.
-# RedisCache[discord.TextChannel.id, bool]
-unanswered = RedisCache(namespace="HelpChannels.unanswered")
diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py
index 224214b00..2837bc7c5 100644
--- a/bot/exts/help_channels/_channel.py
+++ b/bot/exts/help_channels/_channel.py
@@ -1,8 +1,11 @@
import logging
import typing as t
-from datetime import datetime, timedelta
+from datetime import timedelta
+from enum import Enum
+import arrow
import discord
+from arrow import Arrow
import bot
from bot import constants
@@ -15,6 +18,17 @@ MAX_CHANNELS_PER_CATEGORY = 50
EXCLUDED_CHANNELS = (constants.Channels.cooldown,)
+class ClosingReason(Enum):
+ """All possible closing reasons for help channels."""
+
+ COMMAND = "command"
+ LATEST_MESSSAGE = "auto.latest_message"
+ CLAIMANT_TIMEOUT = "auto.claimant_timeout"
+ OTHER_TIMEOUT = "auto.other_timeout"
+ DELETED = "auto.deleted"
+ CLEANUP = "auto.cleanup"
+
+
def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]:
"""Yield the text channels of the `category` in an unsorted manner."""
log.trace(f"Getting text channels in the category '{category}' ({category.id}).")
@@ -25,23 +39,68 @@ def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[disco
yield channel
-async def get_idle_time(channel: discord.TextChannel) -> t.Optional[int]:
+async def get_closing_time(channel: discord.TextChannel, init_done: bool) -> t.Tuple[Arrow, ClosingReason]:
"""
- Return the time elapsed, in seconds, since the last message sent in the `channel`.
+ Return the time at which the given help `channel` should be closed along with the reason.
- Return None if the channel has no messages.
+ `init_done` is True if the cog has finished loading and False otherwise.
+
+ The time is calculated as follows:
+
+ * If `init_done` is True or the cached time for the claimant's last message is unavailable,
+ add the configured `idle_minutes_claimant` to the time the most recent message was sent.
+ * If the help session is empty (see `is_empty`), do the above but with `deleted_idle_minutes`.
+ * If either of the above is attempted but the channel is completely empty, close the channel
+ immediately.
+ * Otherwise, retrieve the times of the claimant's and non-claimant's last messages from the
+ cache. Add the configured `idle_minutes_claimant` and idle_minutes_others`, respectively, and
+ choose the time which is furthest in the future.
"""
- log.trace(f"Getting the idle time for #{channel} ({channel.id}).")
+ log.trace(f"Getting the closing time for #{channel} ({channel.id}).")
+
+ is_empty = await _message.is_empty(channel)
+ if is_empty:
+ idle_minutes_claimant = constants.HelpChannels.deleted_idle_minutes
+ else:
+ idle_minutes_claimant = constants.HelpChannels.idle_minutes_claimant
+
+ claimant_time = await _caches.claimant_last_message_times.get(channel.id)
+
+ # The current session lacks messages, the cog is still starting, or the cache is empty.
+ if is_empty or not init_done or claimant_time is None:
+ msg = await _message.get_last_message(channel)
+ if not msg:
+ log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages, closing now.")
+ return Arrow.min, ClosingReason.DELETED
+
+ # Use the greatest offset to avoid the possibility of prematurely closing the channel.
+ time = Arrow.fromdatetime(msg.created_at) + timedelta(minutes=idle_minutes_claimant)
+ return time, ClosingReason.LATEST_MESSSAGE
+
+ claimant_time = Arrow.utcfromtimestamp(claimant_time)
+ others_time = await _caches.non_claimant_last_message_times.get(channel.id)
+
+ if others_time:
+ others_time = Arrow.utcfromtimestamp(others_time)
+ else:
+ # The help session hasn't received any answers (messages from non-claimants) yet.
+ # Set to min value so it isn't considered when calculating the closing time.
+ others_time = Arrow.min
- msg = await _message.get_last_message(channel)
- if not msg:
- log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.")
- return None
+ # Offset the cached times by the configured values.
+ others_time += timedelta(minutes=constants.HelpChannels.idle_minutes_others)
+ claimant_time += timedelta(minutes=idle_minutes_claimant)
- idle_time = (datetime.utcnow() - msg.created_at).seconds
+ # Use the time which is the furthest into the future.
+ if claimant_time >= others_time:
+ closing_time = claimant_time
+ reason = ClosingReason.CLAIMANT_TIMEOUT
+ else:
+ closing_time = others_time
+ reason = ClosingReason.OTHER_TIMEOUT
- log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.")
- return idle_time
+ log.trace(f"#{channel} ({channel.id}) should be closed at {closing_time} due to {reason}.")
+ return closing_time, reason
async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]:
@@ -50,8 +109,8 @@ async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]:
claimed_timestamp = await _caches.claim_times.get(channel_id)
if claimed_timestamp:
- claimed = datetime.utcfromtimestamp(claimed_timestamp)
- return datetime.utcnow() - claimed
+ claimed = Arrow.utcfromtimestamp(claimed_timestamp)
+ return arrow.utcnow() - claimed
def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool:
diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py
index 1c730dce9..18457f6a5 100644
--- a/bot/exts/help_channels/_cog.py
+++ b/bot/exts/help_channels/_cog.py
@@ -2,9 +2,10 @@ import asyncio
import logging
import random
import typing as t
-from datetime import datetime, timezone
+from datetime import timedelta
from operator import attrgetter
+import arrow
import discord
import discord.abc
from discord.ext import commands
@@ -43,7 +44,9 @@ class HelpChannels(commands.Cog):
In Use Category
* Contains all channels which are occupied by someone needing help
- * Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle
+ * Channel moves to dormant category after
+ - `constants.HelpChannels.idle_minutes_other` minutes since the last user message, or
+ - `constants.HelpChannels.idle_minutes_claimant` minutes since the last claimant message.
* Command can prematurely mark a channel as dormant
* Channel claimant is allowed to use the command
* Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist`
@@ -70,7 +73,7 @@ class HelpChannels(commands.Cog):
self.channel_queue: asyncio.Queue[discord.TextChannel] = None
self.name_queue: t.Deque[str] = None
- self.last_notification: t.Optional[datetime] = None
+ self.last_notification: t.Optional[arrow.Arrow] = None
# Asyncio stuff
self.queue_tasks: t.List[asyncio.Task] = []
@@ -112,11 +115,13 @@ class HelpChannels(commands.Cog):
self.bot.stats.incr("help.claimed")
- # Must use a timezone-aware datetime to ensure a correct POSIX timestamp.
- timestamp = datetime.now(timezone.utc).timestamp()
- await _caches.claim_times.set(message.channel.id, timestamp)
+ # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time.
+ timestamp = arrow.Arrow.fromdatetime(message.created_at).timestamp()
- await _caches.unanswered.set(message.channel.id, True)
+ await _caches.claim_times.set(message.channel.id, timestamp)
+ await _caches.claimant_last_message_times.set(message.channel.id, timestamp)
+ # Delete to indicate that the help session has yet to receive an answer.
+ await _caches.non_claimant_last_message_times.delete(message.channel.id)
# Not awaited because it may indefinitely hold the lock while waiting for a channel.
scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}")
@@ -187,7 +192,7 @@ class HelpChannels(commands.Cog):
# Don't use a discord.py check because the check needs to fail silently.
if await self.close_check(ctx):
log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.")
- await self.unclaim_channel(ctx.channel, is_auto=False)
+ await self.unclaim_channel(ctx.channel, closed_on=_channel.ClosingReason.COMMAND)
async def get_available_candidate(self) -> discord.TextChannel:
"""
@@ -233,7 +238,7 @@ class HelpChannels(commands.Cog):
elif missing < 0:
log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.")
for channel in channels[:abs(missing)]:
- await self.unclaim_channel(channel)
+ await self.unclaim_channel(channel, closed_on=_channel.ClosingReason.CLEANUP)
async def init_categories(self) -> None:
"""Get the help category objects. Remove the cog if retrieval fails."""
@@ -293,26 +298,23 @@ class HelpChannels(commands.Cog):
"""
log.trace(f"Handling in-use channel #{channel} ({channel.id}).")
- if not await _message.is_empty(channel):
- idle_seconds = constants.HelpChannels.idle_minutes * 60
- else:
- idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60
-
- time_elapsed = await _channel.get_idle_time(channel)
+ closing_time, closed_on = await _channel.get_closing_time(channel, self.init_task.done())
- if time_elapsed is None or time_elapsed >= idle_seconds:
+ # Closing time is in the past.
+ # Add 1 second due to POSIX timestamps being lower resolution than datetime objects.
+ if closing_time < (arrow.utcnow() + timedelta(seconds=1)):
log.info(
- f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds "
- f"and will be made dormant."
+ f"#{channel} ({channel.id}) is idle past {closing_time} "
+ f"and will be made dormant. Reason: {closed_on.value}"
)
- await self.unclaim_channel(channel)
+ await self.unclaim_channel(channel, closed_on=closed_on)
else:
# Cancel the existing task, if any.
if has_task:
self.scheduler.cancel(channel.id)
- delay = idle_seconds - time_elapsed
+ delay = (closing_time - arrow.utcnow()).seconds
log.info(
f"#{channel} ({channel.id}) is still active; "
f"scheduling it to be moved after {delay} seconds."
@@ -356,7 +358,7 @@ class HelpChannels(commands.Cog):
_stats.report_counts()
@lock.lock_arg(f"{NAMESPACE}.unclaim", "channel")
- async def unclaim_channel(self, channel: discord.TextChannel, *, is_auto: bool = True) -> None:
+ async def unclaim_channel(self, channel: discord.TextChannel, *, closed_on: _channel.ClosingReason) -> None:
"""
Unclaim an in-use help `channel` to make it dormant.
@@ -364,7 +366,7 @@ class HelpChannels(commands.Cog):
Remove the cooldown role from the channel claimant if they have no other channels claimed.
Cancel the scheduled cooldown role removal task.
- Set `is_auto` to True if the channel was automatically closed or False if manually closed.
+ `closed_on` is the reason that the channel was closed. See _channel.ClosingReason for possible values.
"""
claimant_id = await _caches.claimants.get(channel.id)
_unclaim_channel = self._unclaim_channel
@@ -375,9 +377,14 @@ class HelpChannels(commands.Cog):
decorator = lock.lock_arg(f"{NAMESPACE}.unclaim", "claimant_id", wait=True)
_unclaim_channel = decorator(_unclaim_channel)
- return await _unclaim_channel(channel, claimant_id, is_auto)
+ return await _unclaim_channel(channel, claimant_id, closed_on)
- async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, is_auto: bool) -> None:
+ async def _unclaim_channel(
+ self,
+ channel: discord.TextChannel,
+ claimant_id: int,
+ closed_on: _channel.ClosingReason
+ ) -> None:
"""Actual implementation of `unclaim_channel`. See that for full documentation."""
await _caches.claimants.delete(channel.id)
@@ -393,12 +400,12 @@ class HelpChannels(commands.Cog):
await _cooldown.remove_cooldown_role(claimant)
await _message.unpin(channel)
- await _stats.report_complete_session(channel.id, is_auto)
+ await _stats.report_complete_session(channel.id, closed_on)
await self.move_to_dormant(channel)
# Cancel the task that makes the channel dormant only if called by the close command.
# In other cases, the task is either already done or not-existent.
- if not is_auto:
+ if closed_on == _channel.ClosingReason.COMMAND:
self.scheduler.cancel(channel.id)
async def move_to_in_use(self, channel: discord.TextChannel) -> None:
@@ -410,7 +417,7 @@ class HelpChannels(commands.Cog):
category_id=constants.Categories.help_in_use,
)
- timeout = constants.HelpChannels.idle_minutes * 60
+ timeout = constants.HelpChannels.idle_minutes_claimant * 60
log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.")
self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel))
@@ -428,7 +435,7 @@ class HelpChannels(commands.Cog):
if not _channel.is_excluded_channel(message.channel):
await self.claim_channel(message)
else:
- await _message.check_for_answer(message)
+ await _message.update_message_caches(message)
@commands.Cog.listener()
async def on_message_delete(self, msg: discord.Message) -> None:
diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py
index 36388f9bd..afd698ffe 100644
--- a/bot/exts/help_channels/_message.py
+++ b/bot/exts/help_channels/_message.py
@@ -1,9 +1,10 @@
import logging
import textwrap
import typing as t
-from datetime import datetime
+import arrow
import discord
+from arrow import Arrow
import bot
from bot import constants
@@ -28,7 +29,7 @@ For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_UR
AVAILABLE_TITLE = "Available help channel"
-AVAILABLE_FOOTER = f"Closes after {constants.HelpChannels.idle_minutes} minutes of inactivity or when you send !close."
+AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close."
DORMANT_MSG = f"""
This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \
@@ -42,25 +43,27 @@ through our guide for **[asking a good question]({ASKING_GUIDE_URL})**.
"""
-async def check_for_answer(message: discord.Message) -> None:
- """Checks for whether new content in a help channel comes from non-claimants."""
+async def update_message_caches(message: discord.Message) -> None:
+ """Checks the source of new content in a help channel and updates the appropriate cache."""
channel = message.channel
# Confirm the channel is an in use help channel
if is_in_category(channel, constants.Categories.help_in_use):
- log.trace(f"Checking if #{channel} ({channel.id}) has been answered.")
+ log.trace(f"Checking if #{channel} ({channel.id}) has had a reply.")
- # Check if there is an entry in unanswered
- if await _caches.unanswered.contains(channel.id):
- claimant_id = await _caches.claimants.get(channel.id)
- if not claimant_id:
- # The mapping for this channel doesn't exist, we can't do anything.
- return
+ claimant_id = await _caches.claimants.get(channel.id)
+ if not claimant_id:
+ # The mapping for this channel doesn't exist, we can't do anything.
+ return
- # Check the message did not come from the claimant
- if claimant_id != message.author.id:
- # Mark the channel as answered
- await _caches.unanswered.set(channel.id, False)
+ # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time.
+ timestamp = Arrow.fromdatetime(message.created_at).timestamp()
+
+ # Overwrite the appropriate last message cache depending on the author of the message
+ if message.author.id == claimant_id:
+ await _caches.claimant_last_message_times.set(channel.id, timestamp)
+ else:
+ await _caches.non_claimant_last_message_times.set(channel.id, timestamp)
async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]:
@@ -125,12 +128,12 @@ async def dm_on_open(message: discord.Message) -> None:
)
-async def notify(channel: discord.TextChannel, last_notification: t.Optional[datetime]) -> t.Optional[datetime]:
+async def notify(channel: discord.TextChannel, last_notification: t.Optional[Arrow]) -> t.Optional[Arrow]:
"""
Send a message in `channel` notifying about a lack of available help channels.
- If a notification was sent, return the `datetime` at which the message was sent. Otherwise,
- return None.
+ If a notification was sent, return the time at which the message was sent.
+ Otherwise, return None.
Configuration:
@@ -144,7 +147,7 @@ async def notify(channel: discord.TextChannel, last_notification: t.Optional[dat
log.trace("Notifying about lack of channels.")
if last_notification:
- elapsed = (datetime.utcnow() - last_notification).seconds
+ elapsed = (arrow.utcnow() - last_notification).seconds
minimum_interval = constants.HelpChannels.notify_minutes * 60
should_send = elapsed >= minimum_interval
else:
@@ -167,7 +170,7 @@ async def notify(channel: discord.TextChannel, last_notification: t.Optional[dat
allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles)
)
- return message.created_at
+ return Arrow.fromdatetime(message.created_at)
except Exception:
# Handle it here cause this feature isn't critical for the functionality of the system.
log.exception("Failed to send notification about lack of dormant channels!")
diff --git a/bot/exts/help_channels/_stats.py b/bot/exts/help_channels/_stats.py
index b8778e7d9..eb34e75e1 100644
--- a/bot/exts/help_channels/_stats.py
+++ b/bot/exts/help_channels/_stats.py
@@ -22,21 +22,20 @@ def report_counts() -> None:
log.warning(f"Couldn't find category {name!r} to track channel count stats.")
-async def report_complete_session(channel_id: int, is_auto: bool) -> None:
+async def report_complete_session(channel_id: int, closed_on: _channel.ClosingReason) -> None:
"""
Report stats for a completed help session channel `channel_id`.
- Set `is_auto` to True if the channel was automatically closed or False if manually closed.
+ `closed_on` is the reason why the channel was closed. See `_channel.ClosingReason` for possible reasons.
"""
- caller = "auto" if is_auto else "command"
- bot.instance.stats.incr(f"help.dormant_calls.{caller}")
+ bot.instance.stats.incr(f"help.dormant_calls.{closed_on.value}")
in_use_time = await _channel.get_in_use_time(channel_id)
if in_use_time:
bot.instance.stats.timing("help.in_use_time", in_use_time)
- unanswered = await _caches.unanswered.get(channel_id)
- if unanswered:
+ non_claimant_last_message_time = await _caches.non_claimant_last_message_times.get(channel_id)
+ if non_claimant_last_message_time is None:
bot.instance.stats.incr("help.sessions.unanswered")
- elif unanswered is not None:
+ else:
bot.instance.stats.incr("help.sessions.answered")
diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py
index c54ca96bf..0555544ce 100644
--- a/bot/exts/info/information.py
+++ b/bot/exts/info/information.py
@@ -6,7 +6,7 @@ from collections import defaultdict
from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union
import fuzzywuzzy
-from discord import Colour, Embed, Guild, Message, Role
+from discord import AllowedMentions, Colour, Embed, Guild, Message, Role
from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role
from bot import constants
@@ -447,9 +447,9 @@ class Information(Cog):
def add_content(title: str, content: str) -> None:
paginator.add_line(f'== {title} ==\n')
- # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution.
- # we hope it's not close to 2000
- paginator.add_line(content.replace('```', '`` `'))
+ # Replace backticks as it breaks out of code blocks.
+ # An invisible character seemed to be the most reasonable solution. We hope it's not close to 2000.
+ paginator.add_line(content.replace('`', '`\u200b'))
paginator.close_page()
if message.content:
@@ -468,7 +468,7 @@ class Information(Cog):
add_content(title, transformer(item))
for page in paginator.pages:
- await ctx.send(page)
+ await ctx.send(page, allowed_mentions=AllowedMentions.none())
@raw.command()
async def json(self, ctx: Context, message: Message) -> None:
diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py
index 6d081741c..1d2206e27 100644
--- a/bot/exts/moderation/dm_relay.py
+++ b/bot/exts/moderation/dm_relay.py
@@ -1,132 +1,72 @@
import logging
-from typing import Optional
import discord
-from async_rediscache import RedisCache
-from discord import Color
-from discord.ext import commands
-from discord.ext.commands import Cog
+from discord.ext.commands import Cog, Context, command, has_any_role
-from bot import constants
from bot.bot import Bot
-from bot.converters import UserMentionOrID
-from bot.utils.checks import in_whitelist_check
-from bot.utils.messages import send_attachments
-from bot.utils.webhooks import send_webhook
+from bot.constants import Emojis, MODERATION_ROLES
+from bot.utils.services import send_to_paste_service
log = logging.getLogger(__name__)
class DMRelay(Cog):
- """Relay direct messages to and from the bot."""
-
- # RedisCache[str, t.Union[discord.User.id, discord.Member.id]]
- dm_cache = RedisCache()
+ """Inspect messages sent to the bot."""
def __init__(self, bot: Bot):
self.bot = bot
- self.webhook_id = constants.Webhooks.dm_log
- self.webhook = None
- self.bot.loop.create_task(self.fetch_webhook())
-
- @commands.command(aliases=("reply",))
- async def send_dm(self, ctx: commands.Context, member: Optional[UserMentionOrID], *, message: str) -> None:
- """
- Allows you to send a DM to a user from the bot.
-
- If `member` is not provided, it will send to the last user who DM'd the bot.
-
- This feature should be used extremely sparingly. Use ModMail if you need to have a serious
- conversation with a user. This is just for responding to extraordinary DMs, having a little
- fun with users, and telling people they are DMing the wrong bot.
-
- NOTE: This feature will be removed if it is overused.
- """
- if not member:
- user_id = await self.dm_cache.get("last_user")
- member = ctx.guild.get_member(user_id) if user_id else None
-
- # If we still don't have a Member at this point, give up
- if not member:
- log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.")
- await ctx.message.add_reaction("❌")
+
+ @command(aliases=("relay", "dr"))
+ async def dmrelay(self, ctx: Context, user: discord.User, limit: int = 100) -> None:
+ """Relays the direct message history between the bot and given user."""
+ log.trace(f"Relaying DMs with {user.name} ({user.id})")
+
+ if user.bot:
+ await ctx.send(f"{Emojis.cross_mark} No direct message history with bots.")
return
- if member.id == self.bot.user.id:
- log.debug("Not sending message to bot user")
- return await ctx.send("🚫 I can't send messages to myself!")
-
- try:
- await member.send(message)
- except discord.errors.Forbidden:
- log.debug("User has disabled DMs.")
- await ctx.message.add_reaction("❌")
- else:
- await ctx.message.add_reaction("✅")
- self.bot.stats.incr("dm_relay.dm_sent")
-
- async def fetch_webhook(self) -> None:
- """Fetches the webhook object, so we can post to it."""
- await self.bot.wait_until_guild_available()
-
- try:
- self.webhook = await self.bot.fetch_webhook(self.webhook_id)
- except discord.HTTPException:
- log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`")
-
- @Cog.listener()
- async def on_message(self, message: discord.Message) -> None:
- """Relays the message's content and attachments to the dm_log channel."""
- # Only relay DMs from humans
- if message.author.bot or message.guild or self.webhook is None:
+ output = ""
+ async for msg in user.history(limit=limit, oldest_first=True):
+ created_at = msg.created_at.strftime(r"%Y-%m-%d %H:%M")
+
+ # Metadata (author, created_at, id)
+ output += f"{msg.author} [{created_at}] ({msg.id}): "
+
+ # Content
+ if msg.content:
+ output += msg.content + "\n"
+
+ # Embeds
+ if (embeds := len(msg.embeds)) > 0:
+ output += f"<{embeds} embed{'s' if embeds > 1 else ''}>\n"
+
+ # Attachments
+ attachments = "\n".join(a.url for a in msg.attachments)
+ if attachments:
+ output += attachments + "\n"
+
+ if not output:
+ await ctx.send(f"{Emojis.cross_mark} No direct message history with {user.mention}.")
+ return
+
+ metadata = (
+ f"User: {user} ({user.id})\n"
+ f"Channel ID: {user.dm_channel.id}\n\n"
+ )
+
+ paste_link = await send_to_paste_service(metadata + output, extension="txt")
+
+ if paste_link is None:
+ await ctx.send(f"{Emojis.cross_mark} Failed to upload output to hastebin.")
return
- if message.clean_content:
- await send_webhook(
- webhook=self.webhook,
- content=message.clean_content,
- username=f"{message.author.display_name} ({message.author.id})",
- avatar_url=message.author.avatar_url
- )
- await self.dm_cache.set("last_user", message.author.id)
- self.bot.stats.incr("dm_relay.dm_received")
-
- # Handle any attachments
- if message.attachments:
- try:
- await send_attachments(
- message,
- self.webhook,
- username=f"{message.author.display_name} ({message.author.id})"
- )
- except (discord.errors.Forbidden, discord.errors.NotFound):
- e = discord.Embed(
- description=":x: **This message contained an attachment, but it could not be retrieved**",
- color=Color.red()
- )
- await send_webhook(
- webhook=self.webhook,
- embed=e,
- username=f"{message.author.display_name} ({message.author.id})",
- avatar_url=message.author.avatar_url
- )
- except discord.HTTPException:
- log.exception("Failed to send an attachment to the webhook")
-
- async def cog_check(self, ctx: commands.Context) -> bool:
+ await ctx.send(paste_link)
+
+ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
- checks = [
- await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx),
- in_whitelist_check(
- ctx,
- channels=[constants.Channels.dm_log],
- redirect=None,
- fail_silently=True,
- )
- ]
- return all(checks)
+ return await has_any_role(*MODERATION_ROLES).predicate(ctx)
def setup(bot: Bot) -> None:
- """Load the DMRelay cog."""
+ """Load the DMRelay cog."""
bot.add_cog(DMRelay(bot))
diff --git a/bot/utils/services.py b/bot/utils/services.py
index 5949c9e48..db9c93d0f 100644
--- a/bot/utils/services.py
+++ b/bot/utils/services.py
@@ -47,7 +47,14 @@ async def send_to_paste_service(contents: str, *, extension: str = "") -> Option
continue
elif "key" in response_json:
log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.")
- return URLs.paste_service.format(key=response_json['key']) + extension
+
+ paste_link = URLs.paste_service.format(key=response_json['key']) + extension
+
+ if extension == '.py':
+ return paste_link
+
+ return paste_link + "?noredirect"
+
log.warning(
f"Got unexpected JSON response from paste service: {response_json}\n"
f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
diff --git a/config-default.yml b/config-default.yml
index 502f0f861..1b5ef42fe 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -150,7 +150,6 @@ guild:
python_events: &PYEVENTS_CHANNEL 729674110270963822
python_news: &PYNEWS_CHANNEL 704372456592506880
reddit: &REDDIT_CHANNEL 458224812528238616
- user_event_announcements: &USER_EVENT_A 592000283102674944
# Development
dev_contrib: &DEV_CONTRIB 635950537262759947
@@ -169,7 +168,6 @@ guild:
# Logs
attachment_log: &ATTACH_LOG 649243850006855680
- dm_log: 653713721625018428
message_log: &MESSAGE_LOG 467752170159079424
mod_log: &MOD_LOG 282638479504965634
user_log: 528976905546760203
@@ -287,7 +285,6 @@ guild:
webhooks:
big_brother: 569133704568373283
dev_log: 680501655111729222
- dm_log: 654567640664244225
duck_pond: 637821475327311927
incidents_archive: 720671599790915702
python_news: &PYNEWS_WEBHOOK 704381182279942324
@@ -324,7 +321,6 @@ filter:
- *MOD_LOG
- *STAFF_LOUNGE
- *TALENT_POOL
- - *USER_EVENT_A
role_whitelist:
- *ADMINS_ROLE
@@ -469,8 +465,12 @@ help_channels:
cmd_whitelist:
- *HELPERS_ROLE
- # Allowed duration of inactivity before making a channel dormant
- idle_minutes: 30
+ # Allowed duration of inactivity by claimant before making a channel dormant
+ idle_minutes_claimant: 30
+
+ # Allowed duration of inactivity by others before making a channel dormant
+ # `idle_minutes_claimant` must also be met, before a channel is closed
+ idle_minutes_others: 10
# Allowed duration of inactivity when channel is empty (due to deleted messages)
# before message making a channel dormant
@@ -481,7 +481,7 @@ help_channels:
# Maximum number of channels across all 3 categories
# Note Discord has a hard limit of 50 channels per category, so this shouldn't be > 50
- max_total_channels: 32
+ max_total_channels: 42
# Prefix for help channel names
name_prefix: 'help-'
@@ -513,7 +513,6 @@ duck_pond:
- *PYEVENTS_CHANNEL
- *MAILING_LISTS
- *REDDIT_CHANNEL
- - *USER_EVENT_A
- *DUCK_POND
- *CHANGE_LOG
- *STAFF_ANNOUNCEMENTS
diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py
index 1b48f6560..3b71022db 100644
--- a/tests/bot/utils/test_services.py
+++ b/tests/bot/utils/test_services.py
@@ -30,9 +30,9 @@ class PasteTests(unittest.IsolatedAsyncioTestCase):
"""Url with specified extension is returned on successful requests."""
key = "paste_key"
test_cases = (
- (f"https://paste_service.com/{key}.txt", "txt"),
+ (f"https://paste_service.com/{key}.txt?noredirect", "txt"),
(f"https://paste_service.com/{key}.py", "py"),
- (f"https://paste_service.com/{key}", ""),
+ (f"https://paste_service.com/{key}?noredirect", ""),
)
response = MagicMock(
json=AsyncMock(return_value={"key": key})