From 3bf675d24957ee5dbe4788a190ac1b8db44e0a9c Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:09:35 +0200 Subject: [PATCH 01/17] Initial base implementation for the `!tunnel` command (with default least active off-topic channel selection) --- bot/exts/utils/tunnel.py | 107 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 bot/exts/utils/tunnel.py diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py new file mode 100644 index 0000000000..e1b7645573 --- /dev/null +++ b/bot/exts/utils/tunnel.py @@ -0,0 +1,107 @@ +from operator import itemgetter + +import discord +from discord.ext import commands +from discord.ext.commands import BadArgument + +from bot.bot import Bot +from bot.constants import Channels + +CHANNEL_IDS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) + + +class Tunnel(commands.Cog): + """Enables conversation redirection between channels.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + self.channel_id_to_timestamp: dict[int, float] = dict.fromkeys(CHANNEL_IDS, 0) + + for channel_id in CHANNEL_IDS: + channel = bot.get_channel(channel_id) + if channel is None: + continue + + if not isinstance(channel, discord.TextChannel): + raise AssertionError + + last_message = channel.last_message + if last_message is None: + continue + + self.channel_id_to_timestamp[channel_id] = last_message.created_at.timestamp() + + @commands.command() + async def tunnel( + self, + ctx: commands.Context, + destination_channel: discord.TextChannel | None, + source_channel: discord.TextChannel | None, + ) -> None: + """Creates a tunnel.""" + if ctx.guild is None: + raise AssertionError + + if destination_channel is None: + least_active_channel_id = self.get_least_active_channel_id(ctx.channel.id) + least_active_channel = await ctx.guild.fetch_channel(least_active_channel_id) + if not isinstance(least_active_channel, discord.TextChannel): + raise AssertionError + + destination_channel = least_active_channel + + if source_channel is None: + if not isinstance(ctx.channel, discord.TextChannel): + raise BadArgument( + f"The current channel of type '{ctx.channel.type}' is not a valid source channel " + "and no explicit source channel was provided" + ) + + source_channel = ctx.channel + + if not isinstance(ctx.author, discord.Member): + raise AssertionError + + if not source_channel.permissions_for(ctx.author).send_messages: + raise BadArgument(f"You don't have permission to send messages in {source_channel.jump_url}") + if not destination_channel.permissions_for(ctx.author).send_messages: + raise BadArgument(f"You don't have permission to send messages in {destination_channel.jump_url}") + + if source_channel.id == destination_channel.id: + raise BadArgument("Source and destination channels cannot be the same") + + source_message_template = "➡️ Conversation tunneled to {location}" + destination_message_template = "↩️ Conversation tunneled from {location}" + + source_message = await source_channel.send( + content=source_message_template.format(location=destination_channel.jump_url) + ) + destination_message = await destination_channel.send( + content=destination_message_template.format(location=source_message.jump_url) + ) + await source_message.edit(content=source_message_template.format(location=destination_message.jump_url)) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Determines least active off-topic channel to default to.""" + if message.channel.id not in CHANNEL_IDS: + return + + self.channel_id_to_timestamp[message.channel.id] = message.created_at.timestamp() + + def get_least_active_channel_id(self, current_channel_id: int) -> int: + """Gets least active off-topic channel.""" + channel_id, _ = min( + [ + (channel_id, timestamp) + for channel_id, timestamp in self.channel_id_to_timestamp.items() + if channel_id != current_channel_id + ], + key=itemgetter(1), + ) + return channel_id + + +async def setup(bot: Bot) -> None: + """Load the Tunnel cog.""" + await bot.add_cog(Tunnel(bot)) From a9debc22e3c871e8599c541504d9fc91694e7c72 Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:04:54 +0300 Subject: [PATCH 02/17] Move timestamp initialization to cog_load --- bot/exts/utils/tunnel.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index e1b7645573..d5578ec538 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -17,8 +17,12 @@ def __init__(self, bot: Bot) -> None: self.bot = bot self.channel_id_to_timestamp: dict[int, float] = dict.fromkeys(CHANNEL_IDS, 0) + async def cog_load(self) -> None: + """Initialize channel timestamps.""" + await self.bot.wait_until_guild_available() + for channel_id in CHANNEL_IDS: - channel = bot.get_channel(channel_id) + channel = self.bot.get_channel(channel_id) if channel is None: continue From 672296d93b9177089a52baf473eed39d3d6038c7 Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:05:58 +0300 Subject: [PATCH 03/17] remove isinstance assert from the timestamp initializer --- bot/exts/utils/tunnel.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index d5578ec538..496f505fdd 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -26,9 +26,6 @@ async def cog_load(self) -> None: if channel is None: continue - if not isinstance(channel, discord.TextChannel): - raise AssertionError - last_message = channel.last_message if last_message is None: continue From 2dc0dd42a1b90657c721cfc65331e4fb20cbc5cc Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:08:19 +0300 Subject: [PATCH 04/17] Use fetch_message to get the latest message in a channel as per the channel.last_message docs' suggestion channel.last_message: "For a slightly more reliable method of fetching the last message, consider using either history or fetch_message with the last_message_id attribute." --- bot/exts/utils/tunnel.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index 496f505fdd..668459952b 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -26,7 +26,11 @@ async def cog_load(self) -> None: if channel is None: continue - last_message = channel.last_message + last_message_id = channel.last_message_id + if last_message_id is None: + continue + + last_message = await channel.fetch_message(last_message_id) if last_message is None: continue From 0178ca9f9853f9e45b29525e075a04931e9afc5e Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:09:06 +0300 Subject: [PATCH 05/17] use bot.fetch_channel instead of bot.get_channel to avoid a channel missing from the cache on load --- bot/exts/utils/tunnel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index 668459952b..1e5e91d4b2 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -22,7 +22,7 @@ async def cog_load(self) -> None: await self.bot.wait_until_guild_available() for channel_id in CHANNEL_IDS: - channel = self.bot.get_channel(channel_id) + channel = await self.bot.fetch_channel(channel_id) if channel is None: continue From 1622551a38133d50064ff699386a31ec9a0b3f91 Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:11:13 +0300 Subject: [PATCH 06/17] Get rid of await self.bot.wait_until_guild_available() in cog_load since it doesn't appear to be super necessary here --- bot/exts/utils/tunnel.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index 1e5e91d4b2..6add26e5ab 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -19,8 +19,6 @@ def __init__(self, bot: Bot) -> None: async def cog_load(self) -> None: """Initialize channel timestamps.""" - await self.bot.wait_until_guild_available() - for channel_id in CHANNEL_IDS: channel = await self.bot.fetch_channel(channel_id) if channel is None: From 0e3c12a53691dea9e6279e5708d7d94c7ae582f4 Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:12:15 +0300 Subject: [PATCH 07/17] Get rid of assertions --- bot/exts/utils/tunnel.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index 6add26e5ab..c73e0a5110 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -42,15 +42,9 @@ async def tunnel( source_channel: discord.TextChannel | None, ) -> None: """Creates a tunnel.""" - if ctx.guild is None: - raise AssertionError - if destination_channel is None: least_active_channel_id = self.get_least_active_channel_id(ctx.channel.id) least_active_channel = await ctx.guild.fetch_channel(least_active_channel_id) - if not isinstance(least_active_channel, discord.TextChannel): - raise AssertionError - destination_channel = least_active_channel if source_channel is None: @@ -62,9 +56,6 @@ async def tunnel( source_channel = ctx.channel - if not isinstance(ctx.author, discord.Member): - raise AssertionError - if not source_channel.permissions_for(ctx.author).send_messages: raise BadArgument(f"You don't have permission to send messages in {source_channel.jump_url}") if not destination_channel.permissions_for(ctx.author).send_messages: From ba6850e111c446c18b4344b9b7665fbcce2c82cf Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:17:56 +0300 Subject: [PATCH 08/17] Apply suggestion from @jb3 Co-authored-by: Joe Banks --- bot/exts/utils/tunnel.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index c73e0a5110..e2c896bcb8 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -85,15 +85,10 @@ async def on_message(self, message: discord.Message) -> None: def get_least_active_channel_id(self, current_channel_id: int) -> int: """Gets least active off-topic channel.""" - channel_id, _ = min( - [ - (channel_id, timestamp) - for channel_id, timestamp in self.channel_id_to_timestamp.items() - if channel_id != current_channel_id - ], - key=itemgetter(1), + return min( + (channel for channel in self.channel_id_to_timestamp if channel != current_channel_id), + key=self.channel_id_to_timestamp.get ) - return channel_id async def setup(bot: Bot) -> None: From 411a1de7cdf301acf0cc8796b18c62d03ee452fe Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:22:16 +0300 Subject: [PATCH 09/17] remove unnecessary itemgetter import and use a lambda with [] instead of .get (for typing purposes) --- bot/exts/utils/tunnel.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index e2c896bcb8..9406e72392 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -1,5 +1,3 @@ -from operator import itemgetter - import discord from discord.ext import commands from discord.ext.commands import BadArgument @@ -87,7 +85,7 @@ def get_least_active_channel_id(self, current_channel_id: int) -> int: """Gets least active off-topic channel.""" return min( (channel for channel in self.channel_id_to_timestamp if channel != current_channel_id), - key=self.channel_id_to_timestamp.get + key=lambda c: self.channel_id_to_timestamp[c] ) From faba64ed37c8e8d203d07c2d8d462e75fa9ee170 Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:27:39 +0300 Subject: [PATCH 10/17] make the tunnel command guild-only --- bot/exts/utils/tunnel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index 9406e72392..b006182db8 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -1,6 +1,6 @@ import discord from discord.ext import commands -from discord.ext.commands import BadArgument +from discord.ext.commands import BadArgument, guild_only from bot.bot import Bot from bot.constants import Channels @@ -33,6 +33,7 @@ async def cog_load(self) -> None: self.channel_id_to_timestamp[channel_id] = last_message.created_at.timestamp() @commands.command() + @guild_only() async def tunnel( self, ctx: commands.Context, From 55909cdbe4391d5a3b6737e91385b1997fb8091d Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:29:58 +0300 Subject: [PATCH 11/17] Remove source_channel as an argument and set ctx.channel as the current permanent source_channel --- bot/exts/utils/tunnel.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index b006182db8..ff2985cbc2 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -38,7 +38,6 @@ async def tunnel( self, ctx: commands.Context, destination_channel: discord.TextChannel | None, - source_channel: discord.TextChannel | None, ) -> None: """Creates a tunnel.""" if destination_channel is None: @@ -46,14 +45,7 @@ async def tunnel( least_active_channel = await ctx.guild.fetch_channel(least_active_channel_id) destination_channel = least_active_channel - if source_channel is None: - if not isinstance(ctx.channel, discord.TextChannel): - raise BadArgument( - f"The current channel of type '{ctx.channel.type}' is not a valid source channel " - "and no explicit source channel was provided" - ) - - source_channel = ctx.channel + source_channel = ctx.channel if not source_channel.permissions_for(ctx.author).send_messages: raise BadArgument(f"You don't have permission to send messages in {source_channel.jump_url}") From 324332f829e68b936814dd41d5a0c5ede24d72a4 Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:34:34 +0300 Subject: [PATCH 12/17] Use "continued" instead of "tunneled" for clarity and include the author id (as a ping) in the message as well (to prevent/detect potential abuse) --- bot/exts/utils/tunnel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index ff2985cbc2..74dabd1169 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -55,8 +55,8 @@ async def tunnel( if source_channel.id == destination_channel.id: raise BadArgument("Source and destination channels cannot be the same") - source_message_template = "➡️ Conversation tunneled to {location}" - destination_message_template = "↩️ Conversation tunneled from {location}" + source_message_template = f"➡️ Conversation continued at {{location}} (by <@{ctx.author.id}>)" + destination_message_template = f"↩️ Conversation continued from {{location}} (by <@{ctx.author.id}>)" source_message = await source_channel.send( content=source_message_template.format(location=destination_channel.jump_url) From 762ff5835cef7c23ceb8defc8fbbefb27e3b2327 Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:49:52 +0300 Subject: [PATCH 13/17] Switch to using snowflake_time to retreive the time of the last message in a channel (from its id) --- bot/exts/utils/tunnel.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index 74dabd1169..3084b1945c 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -26,12 +26,9 @@ async def cog_load(self) -> None: if last_message_id is None: continue - last_message = await channel.fetch_message(last_message_id) - if last_message is None: - continue - - self.channel_id_to_timestamp[channel_id] = last_message.created_at.timestamp() + self.channel_id_to_timestamp[channel_id] = discord.utils.snowflake_time(last_message_id).timestamp() + @commands.cooldown(1, 10, commands.BucketType.member) @commands.command() @guild_only() async def tunnel( From e205b7a8b8d4fc9125a855e3bae4436d5e6ec61d Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:50:02 +0300 Subject: [PATCH 14/17] formatting --- bot/exts/utils/tunnel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index 3084b1945c..2823c82510 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -75,7 +75,7 @@ def get_least_active_channel_id(self, current_channel_id: int) -> int: """Gets least active off-topic channel.""" return min( (channel for channel in self.channel_id_to_timestamp if channel != current_channel_id), - key=lambda c: self.channel_id_to_timestamp[c] + key=lambda c: self.channel_id_to_timestamp[c], ) From 88cd5049a08a717a21f46b9cf94848daa7e51f41 Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:22:52 +0300 Subject: [PATCH 15/17] Delegate the bulk of the work for checking which ot channel has the least recent message to `.last_message_id` and check the timestamp via `discord.utils.snowflake_time` --- bot/exts/utils/tunnel.py | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index 2823c82510..5b798395f3 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -5,7 +5,7 @@ from bot.bot import Bot from bot.constants import Channels -CHANNEL_IDS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) +CHANNEL_IDS: tuple[int, ...] = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) class Tunnel(commands.Cog): @@ -13,20 +13,17 @@ class Tunnel(commands.Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - self.channel_id_to_timestamp: dict[int, float] = dict.fromkeys(CHANNEL_IDS, 0) + self.channels: list[discord.TextChannel] = [] async def cog_load(self) -> None: """Initialize channel timestamps.""" + self.channels = [] for channel_id in CHANNEL_IDS: channel = await self.bot.fetch_channel(channel_id) if channel is None: continue - last_message_id = channel.last_message_id - if last_message_id is None: - continue - - self.channel_id_to_timestamp[channel_id] = discord.utils.snowflake_time(last_message_id).timestamp() + self.channels.append(channel) @commands.cooldown(1, 10, commands.BucketType.member) @commands.command() @@ -63,19 +60,11 @@ async def tunnel( ) await source_message.edit(content=source_message_template.format(location=destination_message.jump_url)) - @commands.Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Determines least active off-topic channel to default to.""" - if message.channel.id not in CHANNEL_IDS: - return - - self.channel_id_to_timestamp[message.channel.id] = message.created_at.timestamp() - - def get_least_active_channel_id(self, current_channel_id: int) -> int: + def get_least_active_channel(self, current_channel: discord.TextChannel) -> discord.TextChannel: """Gets least active off-topic channel.""" return min( - (channel for channel in self.channel_id_to_timestamp if channel != current_channel_id), - key=lambda c: self.channel_id_to_timestamp[c], + (c for c in self.channels if c != current_channel), + key=lambda c: discord.utils.snowflake_time(c.last_message_id), ) From 56bd024d62acf190890fb1e92c5005661861f0ad Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:26:09 +0300 Subject: [PATCH 16/17] Some leftover renamings and refactoring from previous commit, now using `self.get_least_active_channel` (instead of `_id`). Also ask for a raw channel string as the argument so we can explicitly attempt to convert it to a text channel, otherwise using something like `discord.guild.GuildChannel` for `destination_channel` makes this command be interpreted as a tag and if `discord.TextChannel` is used, if the destination channel cannot be converted to an actual `discord.TextChannel`, it will silently fail that step and simply set `destination_channel` to `None` (then follow the normal logic flow of this function) --- bot/exts/utils/tunnel.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index 5b798395f3..682e66e136 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -31,13 +31,14 @@ async def cog_load(self) -> None: async def tunnel( self, ctx: commands.Context, - destination_channel: discord.TextChannel | None, + destination_channel_raw: str | None, ) -> None: """Creates a tunnel.""" - if destination_channel is None: - least_active_channel_id = self.get_least_active_channel_id(ctx.channel.id) - least_active_channel = await ctx.guild.fetch_channel(least_active_channel_id) + if destination_channel_raw is None: + least_active_channel = self.get_least_active_channel(ctx.channel) destination_channel = least_active_channel + else: + destination_channel = await commands.TextChannelConverter().convert(ctx, destination_channel_raw) source_channel = ctx.channel From 94f918b44ec7146b3e67e9335dbd65d6026d4592 Mon Sep 17 00:00:00 2001 From: Matiiss <83066658+Matiiss@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:26:16 +0300 Subject: [PATCH 17/17] simplify equality comparison --- bot/exts/utils/tunnel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/tunnel.py b/bot/exts/utils/tunnel.py index 682e66e136..3057028478 100644 --- a/bot/exts/utils/tunnel.py +++ b/bot/exts/utils/tunnel.py @@ -47,7 +47,7 @@ async def tunnel( if not destination_channel.permissions_for(ctx.author).send_messages: raise BadArgument(f"You don't have permission to send messages in {destination_channel.jump_url}") - if source_channel.id == destination_channel.id: + if source_channel == destination_channel: raise BadArgument("Source and destination channels cannot be the same") source_message_template = f"➡️ Conversation continued at {{location}} (by <@{ctx.author.id}>)"