aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar kwzrd <[email protected]>2020-04-17 13:05:04 +0100
committerGravatar GitHub <[email protected]>2020-04-17 13:05:04 +0100
commit9e2448df919c1e6b895af8d679a7997c147308a3 (patch)
tree63738db5332eb75d447b8a3238b78ff301620389
parent(Syncer Tests): Replaced wrong side effect (diff)
parentRemove `.md` from anti-malware whitelist (diff)
Merge branch 'master' into syncer-timeout-fix
-rw-r--r--Pipfile3
-rw-r--r--Pipfile.lock59
-rw-r--r--bot/__init__.py2
-rw-r--r--bot/__main__.py16
-rw-r--r--bot/async_stats.py39
-rw-r--r--bot/bot.py20
-rw-r--r--bot/cogs/antispam.py1
-rw-r--r--bot/cogs/bot.py17
-rw-r--r--bot/cogs/defcon.py17
-rw-r--r--bot/cogs/error_handler.py14
-rw-r--r--bot/cogs/filtering.py2
-rw-r--r--bot/cogs/free.py103
-rw-r--r--bot/cogs/help_channels.py787
-rw-r--r--bot/cogs/moderation/modlog.py8
-rw-r--r--bot/cogs/stats.py107
-rw-r--r--bot/cogs/tags.py3
-rw-r--r--bot/cogs/token_remover.py2
-rw-r--r--bot/cogs/webhook_remover.py2
-rw-r--r--bot/constants.py35
-rw-r--r--bot/resources/elements.json120
-rw-r--r--bot/utils/scheduling.py22
-rw-r--r--config-default.yml55
22 files changed, 1253 insertions, 181 deletions
diff --git a/Pipfile b/Pipfile
index 04cc98427..e7fb61957 100644
--- a/Pipfile
+++ b/Pipfile
@@ -19,7 +19,8 @@ requests = "~=2.22"
more_itertools = "~=8.2"
sentry-sdk = "~=0.14"
coloredlogs = "~=14.0"
-colorama = {version = "~=0.4.3", sys_platform = "== 'win32'"}
+colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"}
+statsd = "~=3.3"
[dev-packages]
coverage = "~=5.0"
diff --git a/Pipfile.lock b/Pipfile.lock
index ad9a3173a..19e03bda4 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "2d3ba484e8467a115126b2ba39fa5f36f103ea455477813dd658797875c79cc9"
+ "sha256": "10636aef5a07f17bd00608df2cc5214fcbfe3de4745cdeea7a076b871754620a"
},
"pipfile-spec": 6,
"requires": {
@@ -87,18 +87,18 @@
},
"beautifulsoup4": {
"hashes": [
- "sha256:05fd825eb01c290877657a56df4c6e4c311b3965bda790c613a3d6fb01a5462a",
- "sha256:9fbb4d6e48ecd30bcacc5b63b94088192dcda178513b2ae3c394229f8911b887",
- "sha256:e1505eeed31b0f4ce2dbb3bc8eb256c04cc2b3b72af7d551a4ab6efd5cbe5dae"
+ "sha256:594ca51a10d2b3443cbac41214e12dbb2a1cd57e1a7344659849e2e20ba6a8d8",
+ "sha256:a4bbe77fd30670455c5296242967a123ec28c37e9702a8a81bd2f20a4baf0368",
+ "sha256:d4e96ac9b0c3a6d3f0caae2e4124e6055c5dcafde8e2f831ff194c104f0775a0"
],
- "version": "==4.8.2"
+ "version": "==4.9.0"
},
"certifi": {
"hashes": [
- "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
- "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
+ "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
+ "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"
],
- "version": "==2019.11.28"
+ "version": "==2020.4.5.1"
},
"cffi": {
"hashes": [
@@ -167,10 +167,10 @@
},
"discord-py": {
"hashes": [
- "sha256:7424be26b07b37ecad4404d9383d685995a0e0b3df3f9c645bdd3a4d977b83b4"
+ "sha256:406871b06d86c3dc49fba63238519f28628dac946fef8a0e22988ff58ec05580"
],
"index": "pypi",
- "version": "==1.3.2"
+ "version": "==1.3.3"
},
"docutils": {
"hashes": [
@@ -393,17 +393,10 @@
},
"pyparsing": {
"hashes": [
- "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f",
- "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"
+ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
+ "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
],
- "version": "==2.4.6"
- },
- "pyreadline": {
- "hashes": [
- "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"
- ],
- "markers": "sys_platform == 'win32'",
- "version": "==2.1"
+ "version": "==2.4.7"
},
"python-dateutil": {
"hashes": [
@@ -524,6 +517,14 @@
],
"version": "==1.1.4"
},
+ "statsd": {
+ "hashes": [
+ "sha256:c610fb80347fca0ef62666d241bce64184bd7cc1efe582f9690e045c25535eaa",
+ "sha256:e3e6db4c246f7c59003e51c9720a51a7f39a396541cb9b147ff4b14d15b5dd1f"
+ ],
+ "index": "pypi",
+ "version": "==3.3.0"
+ },
"urllib3": {
"hashes": [
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
@@ -717,11 +718,11 @@
},
"flake8-tidy-imports": {
"hashes": [
- "sha256:5b6e75cec6d751e66534c522fbdce7dac1c2738b1216b0f6b10453995932e188",
- "sha256:cf26fbb3ab31a398f265d53b6f711d80006450c19221e41b2b7b0e0b14ac39c5"
+ "sha256:62059ca07d8a4926b561d392cbab7f09ee042350214a25cf12823384a45d27dd",
+ "sha256:c30b40337a2e6802ba3bb611c26611154a27e94c53fc45639e3e282169574fd3"
],
"index": "pypi",
- "version": "==4.0.1"
+ "version": "==4.1.0"
},
"flake8-todo": {
"hashes": [
@@ -732,10 +733,10 @@
},
"identify": {
"hashes": [
- "sha256:a7577a1f55cee1d21953a5cf11a3c839ab87f5ef909a4cba6cf52ed72b4c6059",
- "sha256:ab246293e6585a1c6361a505b68d5b501a0409310932b7de2c2ead667b564d89"
+ "sha256:2bb8760d97d8df4408f4e805883dad26a2d076f04be92a10a3e43f09c6060742",
+ "sha256:faffea0fd8ec86bb146ac538ac350ed0c73908326426d387eded0bcc9d077522"
],
- "version": "==1.4.13"
+ "version": "==1.4.14"
},
"mccabe": {
"hashes": [
@@ -835,10 +836,10 @@
},
"virtualenv": {
"hashes": [
- "sha256:87831f1070534b636fea2241dd66f3afe37ac9041bcca6d0af3215cdcfbf7d82",
- "sha256:f3128d882383c503003130389bf892856341c1da12c881ae24d6358c82561b55"
+ "sha256:00cfe8605fb97f5a59d52baab78e6070e72c12ca64f51151695407cc0eb8a431",
+ "sha256:c8364ec469084046c779c9a11ae6340094e8a0bf1d844330fc55c1cefe67c172"
],
- "version": "==20.0.13"
+ "version": "==20.0.17"
}
}
}
diff --git a/bot/__init__.py b/bot/__init__.py
index c9dbc3f40..2dd4af225 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -33,7 +33,7 @@ log_format = logging.Formatter(format_string)
log_file = Path("logs", "bot.log")
log_file.parent.mkdir(exist_ok=True)
-file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7)
+file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8")
file_handler.setFormatter(log_format)
root_log = logging.getLogger()
diff --git a/bot/__main__.py b/bot/__main__.py
index 8c3ae02e3..3aa36bfc0 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -5,9 +5,8 @@ import sentry_sdk
from discord.ext.commands import when_mentioned_or
from sentry_sdk.integrations.logging import LoggingIntegration
-from bot import patches
+from bot import constants, patches
from bot.bot import Bot
-from bot.constants import Bot as BotConfig
sentry_logging = LoggingIntegration(
level=logging.DEBUG,
@@ -15,12 +14,12 @@ sentry_logging = LoggingIntegration(
)
sentry_sdk.init(
- dsn=BotConfig.sentry_dsn,
+ dsn=constants.Bot.sentry_dsn,
integrations=[sentry_logging]
)
bot = Bot(
- command_prefix=when_mentioned_or(BotConfig.prefix),
+ command_prefix=when_mentioned_or(constants.Bot.prefix),
activity=discord.Game(name="Commands: !help"),
case_insensitive=True,
max_messages=10_000,
@@ -47,9 +46,8 @@ bot.load_extension("bot.cogs.verification")
# Feature cogs
bot.load_extension("bot.cogs.alias")
bot.load_extension("bot.cogs.defcon")
-bot.load_extension("bot.cogs.eval")
bot.load_extension("bot.cogs.duck_pond")
-bot.load_extension("bot.cogs.free")
+bot.load_extension("bot.cogs.eval")
bot.load_extension("bot.cogs.information")
bot.load_extension("bot.cogs.jams")
bot.load_extension("bot.cogs.moderation")
@@ -58,6 +56,7 @@ bot.load_extension("bot.cogs.reddit")
bot.load_extension("bot.cogs.reminders")
bot.load_extension("bot.cogs.site")
bot.load_extension("bot.cogs.snekbox")
+bot.load_extension("bot.cogs.stats")
bot.load_extension("bot.cogs.sync")
bot.load_extension("bot.cogs.tags")
bot.load_extension("bot.cogs.token_remover")
@@ -66,8 +65,11 @@ bot.load_extension("bot.cogs.watchchannels")
bot.load_extension("bot.cogs.webhook_remover")
bot.load_extension("bot.cogs.wolfram")
+if constants.HelpChannels.enable:
+ bot.load_extension("bot.cogs.help_channels")
+
# Apply `message_edited_at` patch if discord.py did not yet release a bug fix.
if not hasattr(discord.message.Message, '_handle_edited_timestamp'):
patches.message_edited_at.apply_patch()
-bot.run(BotConfig.token)
+bot.run(constants.Bot.token)
diff --git a/bot/async_stats.py b/bot/async_stats.py
new file mode 100644
index 000000000..58a80f528
--- /dev/null
+++ b/bot/async_stats.py
@@ -0,0 +1,39 @@
+import asyncio
+import socket
+
+from statsd.client.base import StatsClientBase
+
+
+class AsyncStatsClient(StatsClientBase):
+ """An async transport method for statsd communication."""
+
+ def __init__(
+ self,
+ loop: asyncio.AbstractEventLoop,
+ host: str = 'localhost',
+ port: int = 8125,
+ prefix: str = None
+ ):
+ """Create a new client."""
+ family, _, _, _, addr = socket.getaddrinfo(
+ host, port, socket.AF_INET, socket.SOCK_DGRAM)[0]
+ self._addr = addr
+ self._prefix = prefix
+ self._loop = loop
+ self._transport = None
+
+ async def create_socket(self) -> None:
+ """Use the loop.create_datagram_endpoint method to create a socket."""
+ self._transport, _ = await self._loop.create_datagram_endpoint(
+ asyncio.DatagramProtocol,
+ family=socket.AF_INET,
+ remote_addr=self._addr
+ )
+
+ def _send(self, data: str) -> None:
+ """Start an async task to send data to statsd."""
+ self._loop.create_task(self._async_send(data))
+
+ async def _async_send(self, data: str) -> None:
+ """Send data to the statsd server using the async transport."""
+ self._transport.sendto(data.encode('ascii'), self._addr)
diff --git a/bot/bot.py b/bot/bot.py
index 950ac6751..6dd5ba896 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -8,8 +8,8 @@ import aiohttp
import discord
from discord.ext import commands
-from bot import api
-from bot import constants
+from bot import DEBUG_MODE, api, constants
+from bot.async_stats import AsyncStatsClient
log = logging.getLogger('bot')
@@ -33,6 +33,16 @@ class Bot(commands.Bot):
self._resolver = None
self._guild_available = asyncio.Event()
+ statsd_url = constants.Stats.statsd_host
+
+ if DEBUG_MODE:
+ # Since statsd is UDP, there are no errors for sending to a down port.
+ # For this reason, setting the statsd host to 127.0.0.1 for development
+ # will effectively disable stats.
+ statsd_url = "127.0.0.1"
+
+ self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot")
+
def add_cog(self, cog: commands.Cog) -> None:
"""Adds a "cog" to the bot and logs the operation."""
super().add_cog(cog)
@@ -50,7 +60,7 @@ class Bot(commands.Bot):
super().clear()
async def close(self) -> None:
- """Close the Discord connection and the aiohttp session, connector, and resolver."""
+ """Close the Discord connection and the aiohttp session, connector, statsd client, and resolver."""
await super().close()
await self.api_client.close()
@@ -64,9 +74,13 @@ class Bot(commands.Bot):
if self._resolver:
await self._resolver.close()
+ if self.stats._transport:
+ await self.stats._transport.close()
+
async def login(self, *args, **kwargs) -> None:
"""Re-create the connector and set up sessions before logging into Discord."""
self._recreate()
+ await self.stats.create_socket()
await super().login(*args, **kwargs)
def _recreate(self) -> None:
diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py
index baa6b9459..d63acbc4a 100644
--- a/bot/cogs/antispam.py
+++ b/bot/cogs/antispam.py
@@ -182,6 +182,7 @@ class AntiSpam(Cog):
# which contains the reason for why the message violated the rule and
# an iterable of all members that violated the rule.
if result is not None:
+ self.bot.stats.incr(f"mod_alerts.{rule_name}")
reason, members, relevant_messages = result
full_reason = f"`{rule_name}` rule: {reason}"
diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py
index 7b66b48c2..a6929b431 100644
--- a/bot/cogs/bot.py
+++ b/bot/cogs/bot.py
@@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command, group
from bot.bot import Bot
from bot.cogs.token_remover import TokenRemover
-from bot.constants import Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
+from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs
from bot.decorators import with_role
from bot.utils.messages import wait_for_deletion
@@ -26,14 +26,6 @@ class BotCog(Cog, name="Bot"):
# Stores allowed channels plus epoch time since last call.
self.channel_cooldowns = {
- Channels.help_0: 0,
- Channels.help_1: 0,
- Channels.help_2: 0,
- Channels.help_3: 0,
- Channels.help_4: 0,
- Channels.help_5: 0,
- Channels.help_6: 0,
- Channels.help_7: 0,
Channels.python_discussion: 0,
}
@@ -231,9 +223,14 @@ class BotCog(Cog, name="Bot"):
If poorly formatted code is detected, send the user a helpful message explaining how to do
properly formatted Python syntax highlighting codeblocks.
"""
+ is_help_channel = (
+ getattr(msg.channel, "category", None)
+ and msg.channel.category.id in (Categories.help_available, Categories.help_in_use)
+ )
parse_codeblock = (
(
- msg.channel.id in self.channel_cooldowns
+ is_help_channel
+ or msg.channel.id in self.channel_cooldowns
or msg.channel.id in self.channel_whitelist
)
and not msg.author.bot
diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py
index cc0f79fe8..56fca002a 100644
--- a/bot/cogs/defcon.py
+++ b/bot/cogs/defcon.py
@@ -104,6 +104,7 @@ class Defcon(Cog):
log.exception(f"Unable to send rejection message to user: {member}")
await member.kick(reason="DEFCON active, user is too new")
+ self.bot.stats.incr("defcon.leaves")
message = (
f"{member} (`{member.id}`) was denied entry because their account is too new."
@@ -125,6 +126,19 @@ class Defcon(Cog):
async def _defcon_action(self, ctx: Context, days: int, action: Action) -> None:
"""Providing a structured way to do an defcon action."""
+ try:
+ response = await self.bot.api_client.get('bot/bot-settings/defcon')
+ data = response['data']
+
+ if "enable_date" in data and action is Action.DISABLED:
+ enabled = datetime.fromisoformat(data["enable_date"])
+
+ delta = datetime.now() - enabled
+
+ self.bot.stats.timing("defcon.enabled", delta)
+ except Exception:
+ pass
+
error = None
try:
await self.bot.api_client.put(
@@ -135,6 +149,7 @@ class Defcon(Cog):
# TODO: retrieve old days count
'days': days,
'enabled': action is not Action.DISABLED,
+ 'enable_date': datetime.now().isoformat()
}
}
)
@@ -145,6 +160,8 @@ class Defcon(Cog):
await ctx.send(self.build_defcon_msg(action, error))
await self.send_defcon_log(action, ctx.author, error)
+ self.bot.stats.gauge("defcon.threshold", days)
+
@defcon_group.command(name='enable', aliases=('on', 'e'))
@with_role(Roles.admins, Roles.owners)
async def enable_command(self, ctx: Context) -> None:
diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py
index 6a622d2ce..dae283c6a 100644
--- a/bot/cogs/error_handler.py
+++ b/bot/cogs/error_handler.py
@@ -171,19 +171,25 @@ class ErrorHandler(Cog):
if isinstance(e, errors.MissingRequiredArgument):
await ctx.send(f"Missing required argument `{e.param.name}`.")
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.invoke(*help_command)
+ self.bot.stats.incr("errors.too_many_arguments")
elif isinstance(e, errors.BadArgument):
await ctx.send(f"Bad argument: {e}\n")
await ctx.invoke(*help_command)
+ self.bot.stats.incr("errors.bad_argument")
elif isinstance(e, errors.BadUnionArgument):
await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```")
+ self.bot.stats.incr("errors.bad_union_argument")
elif isinstance(e, errors.ArgumentParsingError):
await ctx.send(f"Argument parsing error: {e}")
+ self.bot.stats.incr("errors.argument_parsing_error")
else:
await ctx.send("Something about your input seems off. Check the arguments:")
await ctx.invoke(*help_command)
+ self.bot.stats.incr("errors.other_user_input_error")
@staticmethod
async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None:
@@ -205,10 +211,12 @@ 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."
)
elif isinstance(e, (InChannelCheckFailure, errors.NoPrivateMessage)):
+ ctx.bot.stats.incr("errors.wrong_channel_or_dm_error")
await ctx.send(e)
@staticmethod
@@ -217,16 +225,20 @@ class ErrorHandler(Cog):
if e.status == 404:
await ctx.send("There does not seem to be anything matching your query.")
log.debug(f"API responded with 404 for command {ctx.command}")
+ ctx.bot.stats.incr("errors.api_error_404")
elif e.status == 400:
content = await e.response.json()
log.debug(f"API responded with 400 for command {ctx.command}: %r.", content)
await ctx.send("According to the API, your request is malformed.")
+ ctx.bot.stats.incr("errors.api_error_400")
elif 500 <= e.status < 600:
await ctx.send("Sorry, there seems to be an internal issue with the API.")
log.warning(f"API responded with {e.status} for command {ctx.command}")
+ ctx.bot.stats.incr("errors.api_internal_server_error")
else:
await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).")
log.warning(f"Unexpected API response for command {ctx.command}: {e.status}")
+ ctx.bot.stats.incr(f"errors.api_error_{e.status}")
@staticmethod
async def handle_unexpected_error(ctx: Context, e: errors.CommandError) -> None:
@@ -236,6 +248,8 @@ class ErrorHandler(Cog):
f"```{e.__class__.__name__}: {e}```"
)
+ ctx.bot.stats.incr("errors.unexpected")
+
with push_scope() as scope:
scope.user = {
"id": ctx.author.id,
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
index 3f3dbb853..6a703f5a1 100644
--- a/bot/cogs/filtering.py
+++ b/bot/cogs/filtering.py
@@ -207,6 +207,8 @@ class Filtering(Cog):
log.debug(message)
+ self.bot.stats.incr(f"filters.{filter_name}")
+
additional_embeds = None
additional_embeds_msg = None
diff --git a/bot/cogs/free.py b/bot/cogs/free.py
deleted file mode 100644
index 33b55e79a..000000000
--- a/bot/cogs/free.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import logging
-from datetime import datetime
-from operator import itemgetter
-
-from discord import Colour, Embed, Member, utils
-from discord.ext.commands import Cog, Context, command
-
-from bot.bot import Bot
-from bot.constants import Categories, Channels, Free, STAFF_ROLES
-from bot.decorators import redirect_output
-
-log = logging.getLogger(__name__)
-
-TIMEOUT = Free.activity_timeout
-RATE = Free.cooldown_rate
-PER = Free.cooldown_per
-
-
-class Free(Cog):
- """Tries to figure out which help channels are free."""
-
- PYTHON_HELP_ID = Categories.python_help
-
- @command(name="free", aliases=('f',))
- @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES)
- async def free(self, ctx: Context, user: Member = None, seek: int = 2) -> None:
- """
- Lists free help channels by likeliness of availability.
-
- seek is used only when this command is invoked in a help channel.
- You cannot override seek without mentioning a user first.
-
- When seek is 2, we are avoiding considering the last active message
- in a channel to be the one that invoked this command.
-
- When seek is 3 or more, a user has been mentioned on the assumption
- that they asked if the channel is free or they asked their question
- in an active channel, and we want the message before that happened.
- """
- free_channels = []
- python_help = utils.get(ctx.guild.categories, id=self.PYTHON_HELP_ID)
-
- if user is not None and seek == 2:
- seek = 3
- elif not 0 < seek < 10:
- seek = 3
-
- # Iterate through all the help channels
- # to check latest activity
- for channel in python_help.channels:
- # Seek further back in the help channel
- # the command was invoked in
- if channel.id == ctx.channel.id:
- messages = await channel.history(limit=seek).flatten()
- msg = messages[seek - 1]
- # Otherwise get last message
- else:
- msg = await channel.history(limit=1).next() # noqa: B305
-
- inactive = (datetime.utcnow() - msg.created_at).seconds
- if inactive > TIMEOUT:
- free_channels.append((inactive, channel))
-
- embed = Embed()
- embed.colour = Colour.blurple()
- embed.title = "**Looking for a free help channel?**"
-
- if user is not None:
- embed.description = f"**Hey {user.mention}!**\n\n"
- else:
- embed.description = ""
-
- # Display all potentially inactive channels
- # in descending order of inactivity
- if free_channels:
- # Sort channels in descending order by seconds
- # Get position in list, inactivity, and channel object
- # For each channel, add to embed.description
- sorted_channels = sorted(free_channels, key=itemgetter(0), reverse=True)
-
- for (inactive, channel) in sorted_channels[:3]:
- minutes, seconds = divmod(inactive, 60)
- if minutes > 59:
- hours, minutes = divmod(minutes, 60)
- embed.description += f"{channel.mention} **{hours}h {minutes}m {seconds}s** inactive\n"
- else:
- embed.description += f"{channel.mention} **{minutes}m {seconds}s** inactive\n"
-
- embed.set_footer(text="Please confirm these channels are free before posting")
- else:
- embed.description = (
- "Doesn't look like any channels are available right now. "
- "You're welcome to check for yourself to be sure. "
- "If all channels are truly busy, please be patient "
- "as one will likely be available soon."
- )
-
- await ctx.send(embed=embed)
-
-
-def setup(bot: Bot) -> None:
- """Load the Free cog."""
- bot.add_cog(Free())
diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py
new file mode 100644
index 000000000..692bb234c
--- /dev/null
+++ b/bot/cogs/help_channels.py
@@ -0,0 +1,787 @@
+import asyncio
+import inspect
+import json
+import logging
+import random
+import typing as t
+from collections import deque
+from contextlib import suppress
+from datetime import datetime
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot import constants
+from bot.bot import Bot
+from bot.utils.checks import with_role_check
+from bot.utils.scheduling import Scheduler
+
+log = logging.getLogger(__name__)
+
+ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/"
+MAX_CHANNELS_PER_CATEGORY = 50
+
+AVAILABLE_TOPIC = """
+This channel is available. Feel free to ask a question in order to claim this channel!
+"""
+
+IN_USE_TOPIC = """
+This channel is currently in use. If you'd like to discuss a different problem, please claim a new \
+channel from the Help: Available category.
+"""
+
+DORMANT_TOPIC = """
+This channel is temporarily archived. If you'd like to ask a question, please use one of the \
+channels in the Help: Available category.
+"""
+
+AVAILABLE_MSG = f"""
+This help channel is now **available**, which means that you can claim it by simply typing your \
+question into it. Once claimed, the channel will move into the **Python Help: Occupied** category, \
+and will be yours until it has been inactive for {constants.HelpChannels.idle_minutes} minutes. When \
+that happens, it will be set to **dormant** and moved into the **Help: Dormant** category.
+
+You may claim a new channel once every {constants.HelpChannels.claim_minutes} minutes. If you \
+currently cannot send a message in this channel, it means you are on cooldown and need to wait.
+
+Try to write the best question you can by providing a detailed description and telling us what \
+you've tried already. For more information on asking a good question, \
+check out our guide on [asking good questions]({ASKING_GUIDE_URL}).
+"""
+
+DORMANT_MSG = f"""
+This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \
+category at the bottom of the channel list. It is no longer possible to send messages in this \
+channel until it becomes available again.
+
+If your question wasn't answered yet, you can claim a new help channel from the \
+**Help: Available** category by simply asking your question again. Consider rephrasing the \
+question to maximize your chance of getting a good answer. If you're not sure how, have a look \
+through our guide for [asking a good question]({ASKING_GUIDE_URL}).
+"""
+
+AVAILABLE_EMOJI = "✅"
+IN_USE_EMOJI = "⌛"
+NAME_SEPARATOR = "|"
+
+
+class TaskData(t.NamedTuple):
+ """Data for a scheduled task."""
+
+ wait_time: int
+ callback: t.Awaitable
+
+
+class HelpChannels(Scheduler, commands.Cog):
+ """
+ Manage the help channel system of the guild.
+
+ The system is based on a 3-category system:
+
+ Available Category
+
+ * Contains channels which are ready to be occupied by someone who needs help
+ * Will always contain `constants.HelpChannels.max_available` channels; refilled automatically
+ from the pool of dormant channels
+ * Prioritise using the channels which have been dormant for the longest amount of time
+ * If there are no more dormant channels, the bot will automatically create a new one
+ * If there are no dormant channels to move, helpers will be notified (see `notify()`)
+ * When a channel becomes available, the dormant embed will be edited to show `AVAILABLE_MSG`
+
+ 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
+ * Command can prematurely mark a channel as dormant
+ * Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist`
+ * When a channel becomes dormant, an embed with `DORMANT_MSG` will be sent
+
+ Dormant Category
+
+ * Contains channels which aren't in use
+ * Channels are used to refill the Available category
+
+ Help channels are named after the chemical elements in `bot/resources/elements.json`.
+ """
+
+ def __init__(self, bot: Bot):
+ super().__init__()
+
+ self.bot = bot
+ self.help_channel_claimants: (
+ t.Dict[discord.TextChannel, t.Union[discord.Member, discord.User]]
+ ) = {}
+
+ # Categories
+ self.available_category: discord.CategoryChannel = None
+ self.in_use_category: discord.CategoryChannel = None
+ self.dormant_category: discord.CategoryChannel = None
+
+ # Queues
+ self.channel_queue: asyncio.Queue[discord.TextChannel] = None
+ self.name_queue: t.Deque[str] = None
+
+ self.name_positions = self.get_names()
+ self.last_notification: t.Optional[datetime] = None
+
+ # Asyncio stuff
+ self.queue_tasks: t.List[asyncio.Task] = []
+ self.ready = asyncio.Event()
+ self.on_message_lock = asyncio.Lock()
+ self.init_task = self.bot.loop.create_task(self.init_cog())
+
+ # Stats
+ self.claim_times = {}
+
+ def cog_unload(self) -> None:
+ """Cancel the init task and scheduled tasks when the cog unloads."""
+ log.trace("Cog unload: cancelling the init_cog task")
+ self.init_task.cancel()
+
+ log.trace("Cog unload: cancelling the channel queue tasks")
+ for task in self.queue_tasks:
+ task.cancel()
+
+ self.cancel_all()
+
+ def create_channel_queue(self) -> asyncio.Queue:
+ """
+ Return a queue of dormant channels to use for getting the next available channel.
+
+ The channels are added to the queue in a random order.
+ """
+ log.trace("Creating the channel queue.")
+
+ channels = list(self.get_category_channels(self.dormant_category))
+ random.shuffle(channels)
+
+ log.trace("Populating the channel queue with channels.")
+ queue = asyncio.Queue()
+ for channel in channels:
+ queue.put_nowait(channel)
+
+ return queue
+
+ async def create_dormant(self) -> t.Optional[discord.TextChannel]:
+ """
+ Create and return a new channel in the Dormant category.
+
+ The new channel will sync its permission overwrites with the category.
+
+ Return None if no more channel names are available.
+ """
+ log.trace("Getting a name for a new dormant channel.")
+
+ try:
+ name = self.name_queue.popleft()
+ except IndexError:
+ log.debug("No more names available for new dormant channels.")
+ return None
+
+ log.debug(f"Creating a new dormant channel named {name}.")
+ return await self.dormant_category.create_text_channel(name)
+
+ def create_name_queue(self) -> deque:
+ """Return a queue of element names to use for creating new channels."""
+ log.trace("Creating the chemical element name queue.")
+
+ used_names = self.get_used_names()
+
+ log.trace("Determining the available names.")
+ available_names = (name for name in self.name_positions if name not in used_names)
+
+ log.trace("Populating the name queue with names.")
+ return deque(available_names)
+
+ async def dormant_check(self, ctx: commands.Context) -> bool:
+ """Return True if the user is the help channel claimant or passes the role check."""
+ if self.help_channel_claimants.get(ctx.channel) == ctx.author:
+ log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.")
+ self.bot.stats.incr("help.dormant_invoke.claimant")
+ return True
+
+ log.trace(f"{ctx.author} is not the help channel claimant, checking roles.")
+ role_check = with_role_check(ctx, *constants.HelpChannels.cmd_whitelist)
+
+ if role_check:
+ self.bot.stats.incr("help.dormant_invoke.staff")
+
+ return role_check
+
+ @commands.command(name="dormant", aliases=["close"], enabled=False)
+ async def dormant_command(self, ctx: commands.Context) -> None:
+ """
+ Make the current in-use help channel dormant.
+
+ Make the channel dormant if the user passes the `dormant_check`,
+ delete the message that invoked this,
+ and reset the send permissions cooldown for the user who started the session.
+ """
+ log.trace("dormant command invoked; checking if the channel is in-use.")
+ if ctx.channel.category == self.in_use_category:
+ if await self.dormant_check(ctx):
+ with suppress(KeyError):
+ del self.help_channel_claimants[ctx.channel]
+
+ with suppress(discord.errors.HTTPException, discord.errors.NotFound):
+ await self.reset_claimant_send_permission(ctx.channel)
+
+ await self.move_to_dormant(ctx.channel, "command")
+ self.cancel_task(ctx.channel.id)
+ else:
+ log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel")
+
+ async def get_available_candidate(self) -> discord.TextChannel:
+ """
+ Return a dormant channel to turn into an available channel.
+
+ If no channel is available, wait indefinitely until one becomes available.
+ """
+ log.trace("Getting an available channel candidate.")
+
+ try:
+ channel = self.channel_queue.get_nowait()
+ except asyncio.QueueEmpty:
+ log.info("No candidate channels in the queue; creating a new channel.")
+ channel = await self.create_dormant()
+
+ if not channel:
+ log.info("Couldn't create a candidate channel; waiting to get one from the queue.")
+ await self.notify()
+ channel = await self.wait_for_dormant_channel()
+
+ return channel
+
+ @staticmethod
+ def get_clean_channel_name(channel: discord.TextChannel) -> str:
+ """Return a clean channel name without status emojis prefix."""
+ prefix = constants.HelpChannels.name_prefix
+ try:
+ # Try to remove the status prefix using the index of the channel prefix
+ name = channel.name[channel.name.index(prefix):]
+ log.trace(f"The clean name for `{channel}` is `{name}`")
+ except ValueError:
+ # If, for some reason, the channel name does not contain "help-" fall back gracefully
+ log.info(f"Can't get clean name as `{channel}` does not follow the `{prefix}` naming convention.")
+ name = channel.name
+
+ return name
+
+ @staticmethod
+ 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}).")
+
+ # This is faster than using category.channels because the latter sorts them.
+ for channel in category.guild.channels:
+ if channel.category_id == category.id and isinstance(channel, discord.TextChannel):
+ yield channel
+
+ @staticmethod
+ def get_names() -> t.List[str]:
+ """
+ Return a truncated list of prefixed element names.
+
+ The amount of names is configured with `HelpChannels.max_total_channels`.
+ The prefix is configured with `HelpChannels.name_prefix`.
+ """
+ count = constants.HelpChannels.max_total_channels
+ prefix = constants.HelpChannels.name_prefix
+
+ log.trace(f"Getting the first {count} element names from JSON.")
+
+ with Path("bot/resources/elements.json").open(encoding="utf-8") as elements_file:
+ all_names = json.load(elements_file)
+
+ if prefix:
+ return [prefix + name for name in all_names[:count]]
+ else:
+ return all_names[:count]
+
+ def get_used_names(self) -> t.Set[str]:
+ """Return channel names which are already being used."""
+ log.trace("Getting channel names which are already being used.")
+
+ names = set()
+ for cat in (self.available_category, self.in_use_category, self.dormant_category):
+ for channel in self.get_category_channels(cat):
+ names.add(self.get_clean_channel_name(channel))
+
+ if len(names) > MAX_CHANNELS_PER_CATEGORY:
+ log.warning(
+ f"Too many help channels ({len(names)}) already exist! "
+ f"Discord only supports {MAX_CHANNELS_PER_CATEGORY} in a category."
+ )
+
+ log.trace(f"Got {len(names)} used names: {names}")
+ return names
+
+ @classmethod
+ async def get_idle_time(cls, channel: discord.TextChannel) -> t.Optional[int]:
+ """
+ Return the time elapsed, in seconds, since the last message sent in the `channel`.
+
+ Return None if the channel has no messages.
+ """
+ log.trace(f"Getting the idle time for #{channel} ({channel.id}).")
+
+ msg = await cls.get_last_message(channel)
+ if not msg:
+ log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.")
+ return None
+
+ idle_time = (datetime.utcnow() - msg.created_at).seconds
+
+ log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.")
+ return idle_time
+
+ @staticmethod
+ async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]:
+ """Return the last message sent in the channel or None if no messages exist."""
+ log.trace(f"Getting the last message in #{channel} ({channel.id}).")
+
+ try:
+ return await channel.history(limit=1).next() # noqa: B305
+ except discord.NoMoreItems:
+ log.debug(f"No last message available; #{channel} ({channel.id}) has no messages.")
+ return None
+
+ async def init_available(self) -> None:
+ """Initialise the Available category with channels."""
+ log.trace("Initialising the Available category with channels.")
+
+ channels = list(self.get_category_channels(self.available_category))
+ missing = constants.HelpChannels.max_available - len(channels)
+
+ log.trace(f"Moving {missing} missing channels to the Available category.")
+
+ for _ in range(missing):
+ await self.move_to_available()
+
+ async def init_categories(self) -> None:
+ """Get the help category objects. Remove the cog if retrieval fails."""
+ log.trace("Getting the CategoryChannel objects for the help categories.")
+
+ try:
+ self.available_category = await self.try_get_channel(
+ constants.Categories.help_available
+ )
+ 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")
+ self.bot.remove_cog(self.qualified_name)
+
+ async def init_cog(self) -> None:
+ """Initialise the help channel system."""
+ log.trace("Waiting for the guild to be available before initialisation.")
+ await self.bot.wait_until_guild_available()
+
+ log.trace("Initialising the cog.")
+ await self.init_categories()
+ await self.reset_send_permissions()
+
+ self.channel_queue = self.create_channel_queue()
+ self.name_queue = self.create_name_queue()
+
+ log.trace("Moving or rescheduling in-use channels.")
+ for channel in self.get_category_channels(self.in_use_category):
+ await self.move_idle_channel(channel, has_task=False)
+
+ # Prevent the command from being used until ready.
+ # The ready event wasn't used because channels could change categories between the time
+ # the command is invoked and the cog is ready (e.g. if move_idle_channel wasn't called yet).
+ # This may confuse users. So would potentially long delays for the cog to become ready.
+ self.dormant_command.enabled = True
+
+ await self.init_available()
+
+ log.info("Cog is ready!")
+ self.ready.set()
+
+ self.report_stats()
+
+ def report_stats(self) -> None:
+ """Report the channel count stats."""
+ total_in_use = sum(1 for _ in self.get_category_channels(self.in_use_category))
+ total_available = sum(1 for _ in self.get_category_channels(self.available_category))
+ total_dormant = sum(1 for _ in self.get_category_channels(self.dormant_category))
+
+ self.bot.stats.gauge("help.total.in_use", total_in_use)
+ self.bot.stats.gauge("help.total.available", total_available)
+ self.bot.stats.gauge("help.total.dormant", total_dormant)
+
+ def is_dormant_message(self, message: t.Optional[discord.Message]) -> bool:
+ """Return True if the contents of the `message` match `DORMANT_MSG`."""
+ if not message or not message.embeds:
+ return False
+
+ embed = message.embeds[0]
+ return message.author == self.bot.user and embed.description.strip() == DORMANT_MSG.strip()
+
+ async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None:
+ """
+ Make the `channel` dormant if idle or schedule the move if still active.
+
+ If `has_task` is True and rescheduling is required, the extant task to make the channel
+ dormant will first be cancelled.
+ """
+ log.trace(f"Handling in-use channel #{channel} ({channel.id}).")
+
+ idle_seconds = constants.HelpChannels.idle_minutes * 60
+ time_elapsed = await self.get_idle_time(channel)
+
+ if time_elapsed is None or time_elapsed >= idle_seconds:
+ log.info(
+ f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds "
+ f"and will be made dormant."
+ )
+
+ await self.move_to_dormant(channel, "auto")
+ else:
+ # Cancel the existing task, if any.
+ if has_task:
+ self.cancel_task(channel.id)
+
+ data = TaskData(idle_seconds - time_elapsed, self.move_idle_channel(channel))
+
+ log.info(
+ f"#{channel} ({channel.id}) is still active; "
+ f"scheduling it to be moved after {data.wait_time} seconds."
+ )
+
+ self.schedule_task(channel.id, data)
+
+ async def move_to_available(self) -> None:
+ """Make a channel available."""
+ log.trace("Making a channel available.")
+
+ channel = await self.get_available_candidate()
+ log.info(f"Making #{channel} ({channel.id}) available.")
+
+ await self.send_available_message(channel)
+
+ log.trace(f"Moving #{channel} ({channel.id}) to the Available category.")
+
+ await channel.edit(
+ name=f"{AVAILABLE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}",
+ category=self.available_category,
+ sync_permissions=True,
+ topic=AVAILABLE_TOPIC,
+ )
+
+ log.trace(
+ f"Ensuring that all channels in `{self.available_category}` have "
+ f"synchronized permissions after moving `{channel}` into it."
+ )
+ await self.ensure_permissions_synchronization(self.available_category)
+ self.report_stats()
+
+ async def move_to_dormant(self, channel: discord.TextChannel, caller: str) -> None:
+ """
+ Make the `channel` dormant.
+
+ A caller argument is provided for metrics.
+ """
+ log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.")
+
+ await channel.edit(
+ name=self.get_clean_channel_name(channel),
+ category=self.dormant_category,
+ sync_permissions=True,
+ topic=DORMANT_TOPIC,
+ )
+
+ self.bot.stats.incr(f"help.dormant_calls.{caller}")
+
+ if channel.id in self.claim_times:
+ claimed = self.claim_times[channel.id]
+ in_use_time = datetime.now() - claimed
+ self.bot.stats.timing("help.in_use_time", in_use_time)
+
+ log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.")
+
+ log.trace(f"Sending dormant message for #{channel} ({channel.id}).")
+ embed = discord.Embed(description=DORMANT_MSG)
+ await channel.send(embed=embed)
+
+ log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.")
+ self.channel_queue.put_nowait(channel)
+ self.report_stats()
+
+ async def move_to_in_use(self, channel: discord.TextChannel) -> None:
+ """Make a channel in-use and schedule it to be made dormant."""
+ log.info(f"Moving #{channel} ({channel.id}) to the In Use category.")
+
+ await channel.edit(
+ name=f"{IN_USE_EMOJI}{NAME_SEPARATOR}{self.get_clean_channel_name(channel)}",
+ category=self.in_use_category,
+ sync_permissions=True,
+ topic=IN_USE_TOPIC,
+ )
+
+ timeout = constants.HelpChannels.idle_minutes * 60
+
+ log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.")
+ data = TaskData(timeout, self.move_idle_channel(channel))
+ self.schedule_task(channel.id, data)
+ self.report_stats()
+
+ async def notify(self) -> None:
+ """
+ Send a message notifying about a lack of available help channels.
+
+ Configuration:
+
+ * `HelpChannels.notify` - toggle notifications
+ * `HelpChannels.notify_channel` - destination channel for notifications
+ * `HelpChannels.notify_minutes` - minimum interval between notifications
+ * `HelpChannels.notify_roles` - roles mentioned in notifications
+ """
+ if not constants.HelpChannels.notify:
+ return
+
+ log.trace("Notifying about lack of channels.")
+
+ if self.last_notification:
+ elapsed = (datetime.utcnow() - self.last_notification).seconds
+ minimum_interval = constants.HelpChannels.notify_minutes * 60
+ should_send = elapsed >= minimum_interval
+ else:
+ should_send = True
+
+ if not should_send:
+ log.trace("Notification not sent because it's too recent since the previous one.")
+ return
+
+ try:
+ log.trace("Sending notification message.")
+
+ channel = self.bot.get_channel(constants.HelpChannels.notify_channel)
+ mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles)
+
+ message = await channel.send(
+ f"{mentions} A new available help channel is needed but there "
+ f"are no more dormant ones. Consider freeing up some in-use channels manually by "
+ f"using the `{constants.Bot.prefix}dormant` command within the channels."
+ )
+
+ self.bot.stats.incr("help.out_of_channel_alerts")
+
+ self.last_notification = 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!")
+
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """Move an available channel to the In Use category and replace it with a dormant one."""
+ if message.author.bot:
+ return # Ignore messages sent by bots.
+
+ channel = message.channel
+ if channel.category and channel.category.id != constants.Categories.help_available:
+ return # Ignore messages outside the Available category.
+
+ log.trace("Waiting for the cog to be ready before processing messages.")
+ await self.ready.wait()
+
+ log.trace("Acquiring lock to prevent a channel from being processed twice...")
+ async with self.on_message_lock:
+ log.trace(f"on_message lock acquired for {message.id}.")
+
+ if channel.category and channel.category.id != constants.Categories.help_available:
+ log.debug(
+ f"Message {message.id} will not make #{channel} ({channel.id}) in-use "
+ f"because another message in the channel already triggered that."
+ )
+ return
+
+ await self.move_to_in_use(channel)
+ await self.revoke_send_permissions(message.author)
+ # Add user with channel for dormant check.
+ self.help_channel_claimants[channel] = message.author
+
+ self.bot.stats.incr("help.claimed")
+
+ self.claim_times[channel.id] = datetime.now()
+
+ log.trace(f"Releasing on_message lock for {message.id}.")
+
+ # Move a dormant channel to the Available category to fill in the gap.
+ # This is done last and outside the lock because it may wait indefinitely for a channel to
+ # be put in the queue.
+ await self.move_to_available()
+
+ @staticmethod
+ async def ensure_permissions_synchronization(category: discord.CategoryChannel) -> None:
+ """
+ Ensure that all channels in the `category` have their permissions synchronized.
+
+ This method mitigates an issue we have yet to find the cause for: Every so often, a channel in the
+ `Help: Available` category gets in a state in which it will no longer synchronizes its permissions
+ with the category. To prevent that, we iterate over the channels in the category and edit the channels
+ that are observed to be in such a state. If no "out of sync" channels are observed, this method will
+ not make API calls and should be fairly inexpensive to run.
+ """
+ for channel in category.channels:
+ if not channel.permissions_synced:
+ log.info(f"The permissions of channel `{channel}` were out of sync with category `{category}`.")
+ await channel.edit(sync_permissions=True)
+
+ async def update_category_permissions(
+ self, category: discord.CategoryChannel, member: discord.Member, **permissions
+ ) -> None:
+ """
+ Update the permissions of the given `member` for the given `category` with `permissions` passed.
+
+ After updating the permissions for the member in the category, this helper function will call the
+ `ensure_permissions_synchronization` method to ensure that all channels are still synchronizing their
+ permissions with the category. It's currently unknown why some channels get "out of sync", but this
+ hopefully mitigates the issue.
+ """
+ log.trace(f"Updating permissions for `{member}` in `{category}` with {permissions}.")
+ await category.set_permissions(member, **permissions)
+
+ log.trace(f"Ensuring that all channels in `{category}` are synchronized after permissions update.")
+ await self.ensure_permissions_synchronization(category)
+
+ async def reset_send_permissions(self) -> None:
+ """Reset send permissions for members with it set to False in the Available category."""
+ log.trace("Resetting send permissions in the Available category.")
+
+ for member, overwrite in self.available_category.overwrites.items():
+ if isinstance(member, discord.Member) and overwrite.send_messages is False:
+ log.trace(f"Resetting send permissions for {member} ({member.id}).")
+
+ # We don't use the permissions helper function here as we may have to reset multiple overwrites
+ # and we don't want to enforce the permissions synchronization in each iteration.
+ await self.available_category.set_permissions(member, overwrite=None)
+
+ log.trace(f"Ensuring channels in `Help: Available` are synchronized after permissions reset.")
+ await self.ensure_permissions_synchronization(self.available_category)
+
+ async def reset_claimant_send_permission(self, channel: discord.TextChannel) -> None:
+ """Reset send permissions in the Available category for the help `channel` claimant."""
+ log.trace(f"Attempting to find claimant for #{channel.name} ({channel.id}).")
+ try:
+ member = self.help_channel_claimants[channel]
+ except KeyError:
+ log.trace(f"Channel #{channel.name} ({channel.id}) not in claimant cache, permissions unchanged.")
+ return
+
+ log.trace(f"Resetting send permissions for {member} ({member.id}).")
+ await self.update_category_permissions(self.available_category, member, overwrite=None)
+ # Ignore missing task when claim cooldown has passed but the channel still isn't dormant.
+ self.cancel_task(member.id, ignore_missing=True)
+
+ async def revoke_send_permissions(self, member: discord.Member) -> None:
+ """
+ Disallow `member` to send messages in the Available category for a certain time.
+
+ The time until permissions are reinstated can be configured with
+ `HelpChannels.claim_minutes`.
+ """
+ log.trace(
+ f"Revoking {member}'s ({member.id}) send message permissions in the Available category."
+ )
+
+ await self.update_category_permissions(self.available_category, member, send_messages=False)
+
+ # Cancel the existing task, if any.
+ # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner).
+ self.cancel_task(member.id, ignore_missing=True)
+
+ timeout = constants.HelpChannels.claim_minutes * 60
+ callback = self.update_category_permissions(self.available_category, member, overwrite=None)
+
+ log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.")
+ self.schedule_task(member.id, TaskData(timeout, callback))
+
+ async def send_available_message(self, channel: discord.TextChannel) -> None:
+ """Send the available message by editing a dormant message or sending a new message."""
+ channel_info = f"#{channel} ({channel.id})"
+ log.trace(f"Sending available message in {channel_info}.")
+
+ embed = discord.Embed(description=AVAILABLE_MSG)
+
+ msg = await self.get_last_message(channel)
+ if self.is_dormant_message(msg):
+ log.trace(f"Found dormant message {msg.id} in {channel_info}; editing it.")
+ await msg.edit(embed=embed)
+ else:
+ log.trace(f"Dormant message not found in {channel_info}; sending a new message.")
+ await channel.send(embed=embed)
+
+ async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel:
+ """Attempt to get or fetch a channel and return it."""
+ log.trace(f"Getting the channel {channel_id}.")
+
+ channel = self.bot.get_channel(channel_id)
+ if not channel:
+ log.debug(f"Channel {channel_id} is not in cache; fetching from API.")
+ channel = await self.bot.fetch_channel(channel_id)
+
+ log.trace(f"Channel #{channel} ({channel_id}) retrieved.")
+ return channel
+
+ async def wait_for_dormant_channel(self) -> discord.TextChannel:
+ """Wait for a dormant channel to become available in the queue and return it."""
+ log.trace("Waiting for a dormant channel.")
+
+ task = asyncio.create_task(self.channel_queue.get())
+ self.queue_tasks.append(task)
+ channel = await task
+
+ log.trace(f"Channel #{channel} ({channel.id}) finally retrieved from the queue.")
+ self.queue_tasks.remove(task)
+
+ return channel
+
+ async def _scheduled_task(self, data: TaskData) -> None:
+ """Await the `data.callback` coroutine after waiting for `data.wait_time` seconds."""
+ try:
+ log.trace(f"Waiting {data.wait_time} seconds before awaiting callback.")
+ await asyncio.sleep(data.wait_time)
+
+ # Use asyncio.shield to prevent callback from cancelling itself.
+ # The parent task (_scheduled_task) will still get cancelled.
+ log.trace("Done waiting; now awaiting the callback.")
+ await asyncio.shield(data.callback)
+ finally:
+ if inspect.iscoroutine(data.callback):
+ log.trace("Explicitly closing coroutine.")
+ data.callback.close()
+
+
+def validate_config() -> None:
+ """Raise a ValueError if the cog's config is invalid."""
+ log.trace("Validating config.")
+ total = constants.HelpChannels.max_total_channels
+ available = constants.HelpChannels.max_available
+
+ if total == 0 or available == 0:
+ raise ValueError("max_total_channels and max_available and must be greater than 0.")
+
+ if total < available:
+ raise ValueError(
+ f"max_total_channels ({total}) must be greater than or equal to max_available "
+ f"({available})."
+ )
+
+ if total > MAX_CHANNELS_PER_CATEGORY:
+ raise ValueError(
+ f"max_total_channels ({total}) must be less than or equal to "
+ f"{MAX_CHANNELS_PER_CATEGORY} due to Discord's limit on channels per category."
+ )
+
+
+def setup(bot: Bot) -> None:
+ """Load the HelpChannels cog."""
+ try:
+ validate_config()
+ except ValueError as e:
+ log.error(f"HelpChannels cog will not be loaded due to misconfiguration: {e}")
+ else:
+ bot.add_cog(HelpChannels(bot))
diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py
index c63b4bab9..beef7a8ef 100644
--- a/bot/cogs/moderation/modlog.py
+++ b/bot/cogs/moderation/modlog.py
@@ -15,7 +15,7 @@ from discord.ext.commands import Cog, Context
from discord.utils import escape_markdown
from bot.bot import Bot
-from bot.constants import Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
+from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs
from bot.utils.time import humanize_delta
log = logging.getLogger(__name__)
@@ -188,6 +188,12 @@ class ModLog(Cog, name="ModLog"):
self._ignored[Event.guild_channel_update].remove(before.id)
return
+ # Two channel updates are sent for a single edit: 1 for topic and 1 for category change.
+ # TODO: remove once support is added for ignoring multiple occurrences for the same channel.
+ help_categories = (Categories.help_available, Categories.help_dormant, Categories.help_in_use)
+ if after.category and after.category.id in help_categories:
+ return
+
diff = DeepDiff(before, after)
changes = []
done = []
diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py
new file mode 100644
index 000000000..d253db913
--- /dev/null
+++ b/bot/cogs/stats.py
@@ -0,0 +1,107 @@
+import string
+from datetime import datetime
+
+from discord import Member, Message, Status
+from discord.ext.commands import Bot, Cog, Context
+
+from bot.constants import Channels, Guild, Stats as StatConf
+
+
+CHANNEL_NAME_OVERRIDES = {
+ Channels.off_topic_0: "off_topic_0",
+ Channels.off_topic_1: "off_topic_1",
+ Channels.off_topic_2: "off_topic_2",
+ Channels.staff_lounge: "staff_lounge"
+}
+
+ALLOWED_CHARS = string.ascii_letters + string.digits + "_"
+
+
+class Stats(Cog):
+ """A cog which provides a way to hook onto Discord events and forward to stats."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.last_presence_update = None
+
+ @Cog.listener()
+ async def on_message(self, message: Message) -> None:
+ """Report message events in the server to statsd."""
+ if message.guild is None:
+ return
+
+ if message.guild.id != Guild.id:
+ return
+
+ reformatted_name = message.channel.name.replace('-', '_')
+
+ if CHANNEL_NAME_OVERRIDES.get(message.channel.id):
+ reformatted_name = CHANNEL_NAME_OVERRIDES.get(message.channel.id)
+
+ reformatted_name = "".join(char for char in reformatted_name if char in ALLOWED_CHARS)
+
+ stat_name = f"channels.{reformatted_name}"
+ self.bot.stats.incr(stat_name)
+
+ # Increment the total message count
+ self.bot.stats.incr("messages")
+
+ @Cog.listener()
+ async def on_command_completion(self, ctx: Context) -> None:
+ """Report completed commands to statsd."""
+ command_name = ctx.command.qualified_name.replace(" ", "_")
+
+ self.bot.stats.incr(f"commands.{command_name}")
+
+ @Cog.listener()
+ async def on_member_join(self, member: Member) -> None:
+ """Update member count stat on member join."""
+ if member.guild.id != Guild.id:
+ return
+
+ self.bot.stats.gauge(f"guild.total_members", len(member.guild.members))
+
+ @Cog.listener()
+ async def on_member_leave(self, member: Member) -> None:
+ """Update member count stat on member leave."""
+ if member.guild.id != Guild.id:
+ return
+
+ self.bot.stats.gauge(f"guild.total_members", len(member.guild.members))
+
+ @Cog.listener()
+ async def on_member_update(self, _before: Member, after: Member) -> None:
+ """Update presence estimates on member update."""
+ if after.guild.id != Guild.id:
+ return
+
+ if self.last_presence_update:
+ if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout:
+ return
+
+ self.last_presence_update = datetime.now()
+
+ online = 0
+ idle = 0
+ dnd = 0
+ offline = 0
+
+ for member in after.guild.members:
+ if member.status is Status.online:
+ online += 1
+ elif member.status is Status.dnd:
+ dnd += 1
+ elif member.status is Status.idle:
+ idle += 1
+ elif member.status is Status.offline:
+ offline += 1
+
+ self.bot.stats.gauge("guild.status.online", online)
+ self.bot.stats.gauge("guild.status.idle", idle)
+ self.bot.stats.gauge("guild.status.do_not_disturb", dnd)
+ self.bot.stats.gauge("guild.status.offline", offline)
+
+
+def setup(bot: Bot) -> None:
+ """Load the stats cog."""
+ bot.add_cog(Stats(bot))
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
index a6e5952ff..9ba33d7e0 100644
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -207,6 +207,9 @@ class Tags(Cog):
"time": time.time(),
"channel": ctx.channel.id
}
+
+ self.bot.stats.incr(f"tags.usages.{tag['title'].replace('-', '_')}")
+
await wait_for_deletion(
await ctx.send(embed=Embed.from_dict(tag['embed'])),
[ctx.author.id],
diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py
index 421ad23e2..6721f0e02 100644
--- a/bot/cogs/token_remover.py
+++ b/bot/cogs/token_remover.py
@@ -93,6 +93,8 @@ class TokenRemover(Cog):
channel_id=Channels.mod_alerts,
)
+ self.bot.stats.incr("tokens.removed_tokens")
+
@classmethod
def find_token_in_message(cls, msg: Message) -> t.Optional[str]:
"""Return a seemingly valid token found in `msg` or `None` if no token is found."""
diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py
index 49692113d..1b5c3f821 100644
--- a/bot/cogs/webhook_remover.py
+++ b/bot/cogs/webhook_remover.py
@@ -54,6 +54,8 @@ class WebhookRemover(Cog):
channel_id=Channels.mod_alerts
)
+ self.bot.stats.incr("tokens.removed_webhooks")
+
@Cog.listener()
async def on_message(self, msg: Message) -> None:
"""Check if a Discord webhook URL is in `message`."""
diff --git a/bot/constants.py b/bot/constants.py
index 549e69c8f..2add028e7 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -350,12 +350,21 @@ class CleanMessages(metaclass=YAMLGetter):
message_limit: int
+class Stats(metaclass=YAMLGetter):
+ section = "bot"
+ subsection = "stats"
+
+ presence_update_timeout: int
+ statsd_host: str
+
class Categories(metaclass=YAMLGetter):
section = "guild"
subsection = "categories"
- python_help: int
+ help_available: int
+ help_in_use: int
+ help_dormant: int
class Channels(metaclass=YAMLGetter):
@@ -373,14 +382,6 @@ class Channels(metaclass=YAMLGetter):
dev_core: int
dev_log: int
esoteric: int
- help_0: int
- help_1: int
- help_2: int
- help_3: int
- help_4: int
- help_5: int
- help_6: int
- help_7: int
helpers: int
message_log: int
meta: int
@@ -531,6 +532,22 @@ class Free(metaclass=YAMLGetter):
cooldown_per: float
+class HelpChannels(metaclass=YAMLGetter):
+ section = 'help_channels'
+
+ enable: bool
+ claim_minutes: int
+ cmd_whitelist: List[int]
+ idle_minutes: int
+ max_available: int
+ max_total_channels: int
+ name_prefix: str
+ notify: bool
+ notify_channel: int
+ notify_minutes: int
+ notify_roles: List[int]
+
+
class Mention(metaclass=YAMLGetter):
section = 'mention'
diff --git a/bot/resources/elements.json b/bot/resources/elements.json
new file mode 100644
index 000000000..2dc9b6fd6
--- /dev/null
+++ b/bot/resources/elements.json
@@ -0,0 +1,120 @@
+[
+ "hydrogen",
+ "helium",
+ "lithium",
+ "beryllium",
+ "boron",
+ "carbon",
+ "nitrogen",
+ "oxygen",
+ "fluorine",
+ "neon",
+ "sodium",
+ "magnesium",
+ "aluminium",
+ "silicon",
+ "phosphorus",
+ "sulfur",
+ "chlorine",
+ "argon",
+ "potassium",
+ "calcium",
+ "scandium",
+ "titanium",
+ "vanadium",
+ "chromium",
+ "manganese",
+ "iron",
+ "cobalt",
+ "nickel",
+ "copper",
+ "zinc",
+ "gallium",
+ "germanium",
+ "arsenic",
+ "selenium",
+ "bromine",
+ "krypton",
+ "rubidium",
+ "strontium",
+ "yttrium",
+ "zirconium",
+ "niobium",
+ "molybdenum",
+ "technetium",
+ "ruthenium",
+ "rhodium",
+ "palladium",
+ "silver",
+ "cadmium",
+ "indium",
+ "tin",
+ "antimony",
+ "tellurium",
+ "iodine",
+ "xenon",
+ "caesium",
+ "barium",
+ "lanthanum",
+ "cerium",
+ "praseodymium",
+ "neodymium",
+ "promethium",
+ "samarium",
+ "europium",
+ "gadolinium",
+ "terbium",
+ "dysprosium",
+ "holmium",
+ "erbium",
+ "thulium",
+ "ytterbium",
+ "lutetium",
+ "hafnium",
+ "tantalum",
+ "tungsten",
+ "rhenium",
+ "osmium",
+ "iridium",
+ "platinum",
+ "gold",
+ "mercury",
+ "thallium",
+ "lead",
+ "bismuth",
+ "polonium",
+ "astatine",
+ "radon",
+ "francium",
+ "radium",
+ "actinium",
+ "thorium",
+ "protactinium",
+ "uranium",
+ "neptunium",
+ "plutonium",
+ "americium",
+ "curium",
+ "berkelium",
+ "californium",
+ "einsteinium",
+ "fermium",
+ "mendelevium",
+ "nobelium",
+ "lawrencium",
+ "rutherfordium",
+ "dubnium",
+ "seaborgium",
+ "bohrium",
+ "hassium",
+ "meitnerium",
+ "darmstadtium",
+ "roentgenium",
+ "copernicium",
+ "nihonium",
+ "flerovium",
+ "moscovium",
+ "livermorium",
+ "tennessine",
+ "oganesson"
+]
diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py
index 5760ec2d4..8b778a093 100644
--- a/bot/utils/scheduling.py
+++ b/bot/utils/scheduling.py
@@ -51,20 +51,32 @@ class Scheduler(metaclass=CogABCMeta):
self._scheduled_tasks[task_id] = task
log.debug(f"{self.cog_name}: scheduled task #{task_id} {id(task)}.")
- def cancel_task(self, task_id: t.Hashable) -> None:
- """Unschedule the task identified by `task_id`."""
+ def cancel_task(self, task_id: t.Hashable, ignore_missing: bool = False) -> None:
+ """
+ Unschedule the task identified by `task_id`.
+
+ If `ignore_missing` is True, a warning will not be sent if a task isn't found.
+ """
log.trace(f"{self.cog_name}: cancelling task #{task_id}...")
task = self._scheduled_tasks.get(task_id)
if not task:
- log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).")
+ if not ignore_missing:
+ log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).")
return
- task.cancel()
del self._scheduled_tasks[task_id]
+ task.cancel()
log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.")
+ def cancel_all(self) -> None:
+ """Unschedule all known tasks."""
+ log.debug(f"{self.cog_name}: unscheduling all tasks")
+
+ for task_id in self._scheduled_tasks.copy():
+ self.cancel_task(task_id, ignore_missing=True)
+
def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None:
"""
Delete the task and raise its exception if one exists.
@@ -98,6 +110,6 @@ class Scheduler(metaclass=CogABCMeta):
# Log the exception if one exists.
if exception:
log.error(
- f"{self.cog_name}: error in task #{task_id} {id(scheduled_task)}!",
+ f"{self.cog_name}: error in task #{task_id} {id(done_task)}!",
exc_info=exception
)
diff --git a/config-default.yml b/config-default.yml
index a9578d9bb..f2b0bfa9f 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -3,6 +3,10 @@ bot:
token: !ENV "BOT_TOKEN"
sentry_dsn: !ENV "BOT_SENTRY_DSN"
+ stats:
+ statsd_host: "graphite"
+ presence_update_timeout: 300
+
cooldowns:
# Per channel, per tag.
tags: 60
@@ -111,7 +115,9 @@ guild:
id: 267624335836053506
categories:
- python_help: 356013061213126657
+ help_available: 691405807388196926
+ help_in_use: 696958401460043776
+ help_dormant: 691405908919451718
channels:
announcements: 354619224620138496
@@ -138,16 +144,6 @@ guild:
off_topic_1: 463035241142026251
off_topic_2: 463035268514185226
- # Python Help
- help_0: 303906576991780866
- help_1: 303906556754395136
- help_2: 303906514266226689
- help_3: 439702951246692352
- help_4: 451312046647148554
- help_5: 454941769734422538
- help_6: 587375753306570782
- help_7: 587375768556797982
-
# Special
bot_commands: &BOT_CMD 267659945086812160
esoteric: 470884583684964352
@@ -479,7 +475,6 @@ anti_malware:
- '.mp3'
- '.wav'
- '.ogg'
- - '.md'
reddit:
@@ -512,6 +507,42 @@ mention:
message_timeout: 300
reset_delay: 5
+help_channels:
+ enable: true
+
+ # Minimum interval before allowing a certain user to claim a new help channel
+ claim_minutes: 15
+
+ # Roles which are allowed to use the command which makes channels dormant
+ cmd_whitelist:
+ - *HELPERS_ROLE
+
+ # Allowed duration of inactivity before making a channel dormant
+ idle_minutes: 30
+
+ # Maximum number of channels to put in the available category
+ max_available: 2
+
+ # 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
+
+ # Prefix for help channel names
+ name_prefix: 'help-'
+
+ # Notify if more available channels are needed but there are no more dormant ones
+ notify: true
+
+ # Channel in which to send notifications
+ notify_channel: *HELPERS
+
+ # Minimum interval between helper notifications
+ notify_minutes: 15
+
+ # Mention these roles in notifications
+ notify_roles:
+ - *HELPERS_ROLE
+
redirect_output:
delete_invocation: true
delete_delay: 15