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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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)