From 06cd177908c0b0d1545c16c626a99b21809742bd Mon Sep 17 00:00:00 2001 From: unknownpedestrian Date: Wed, 19 Nov 2025 01:40:06 -0500 Subject: [PATCH 1/4] Make sure initiating channel is supported type --- bot.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/bot.py b/bot.py index 7a088c2..e5e230f 100644 --- a/bot.py +++ b/bot.py @@ -117,8 +117,51 @@ async def on_ready(): logger.info(f"Shard IDS: {bot.shard_ids}") logger.info(f"Cluster ID: {bot.cluster_id}") + ### Custom Checks ### +## Make sure initiating channel is not a thread channel +def is_channel(): + """ + Custom check to prevent commands from being used in unsupported channels. + + Usage: + @is_channel() + async def command(...): + Returns: + Boolean: True if the command is used in a normal text channel, False otherwise + """ + + async def _predicate(interaction: discord.Interaction): + ch = getattr(interaction, 'channel', None) + # Detect thread channels + is_thread = isinstance(ch, discord.Thread) or getattr(ch, 'type', None) in ( + discord.ChannelType.public_thread, + discord.ChannelType.private_thread, + discord.ChannelType.news_thread, + ) + # Detect direct messages (DMs) + is_dm = isinstance(ch, discord.DMChannel) or getattr(ch, 'type', None) == discord.ChannelType.private + + if is_thread: + try: + await interaction.response.send_message("⚠️ I can't process commands *properly* in message threads, use a `text-channel` or `voice-text-channel` instead.", ephemeral=True) + logger.error(f"{interaction.user} attempted to use a command in a thread") + except Exception as e: + logger.warning(f"Failed to send thread rejection message: {e}") + return False + if is_dm: + try: + await interaction.response.send_message("⚠️ I can't process commands *directly*, invite me to a server and use a `text-channel` instead.") + logger.error(f"{interaction.user} attempted to use a command in a DM") + except Exception as e: + logger.warning(f"Failed to send DM rejection message: {e}") + return False + return True + + return discord.app_commands.check(_predicate) + + # Verify bot permissions in the initiating channel def bot_has_channel_permissions(permissions: discord.Permissions): def predicate(interaction: discord.Interaction): @@ -134,6 +177,7 @@ def predicate(interaction: discord.Interaction): raise discord.app_commands.BotMissingPermissions(missing_permissions=missing_permissions) return discord.app_commands.checks.check(predicate) +# Verify bot is not in maintenance mode def bot_not_in_maintenance(): async def predicate(interaction: discord.Interaction): if STATE_MANAGER.get_maint() and not await bot.is_owner(interaction.user): @@ -142,11 +186,14 @@ async def predicate(interaction: discord.Interaction): return True return discord.app_commands.checks.check(predicate) +### Bot Commands ### + @bot.tree.command( name='play', description="Begin playback of a shoutcast/icecast stream" ) @discord.app_commands.checks.cooldown(rate=1, per=5) +@is_channel() @bot_has_channel_permissions(permissions=discord.Permissions(send_messages=True)) @bot_not_in_maintenance() async def play(interaction: discord.Interaction, url: str, private_stream: bool = False): @@ -165,6 +212,7 @@ async def play(interaction: discord.Interaction, url: str, private_stream: bool description="Remove the bot from the current call" ) @discord.app_commands.checks.cooldown(rate=1, per=5) +@is_channel() @bot_not_in_maintenance() async def leave(interaction: discord.Interaction, force: bool = False): voice_client = interaction.guild.voice_client @@ -201,6 +249,7 @@ async def leave(interaction: discord.Interaction, force: bool = False): description="Send an embed with the current song information to this channel" ) @discord.app_commands.checks.cooldown(rate=1, per=5) +@is_channel() async def song(interaction: discord.Interaction): url = STATE_MANAGER.get_state(interaction.guild.id, 'current_stream_url') if url: @@ -218,6 +267,7 @@ async def song(interaction: discord.Interaction): description="Refresh the stream. Bot will leave and come back. Song updates will start displaying in this channel" ) @discord.app_commands.checks.cooldown(rate=1, per=5) +@is_channel() @bot_has_channel_permissions(permissions=discord.Permissions(send_messages=True)) @bot_not_in_maintenance() async def refresh(interaction: discord.Interaction): @@ -232,6 +282,7 @@ async def refresh(interaction: discord.Interaction): description="Information on how to get support" ) @discord.app_commands.checks.cooldown(rate=1, per=5) +@is_channel() async def support(interaction: discord.Interaction): embed_data = { 'title': "BunBot Support", @@ -264,6 +315,7 @@ async def support(interaction: discord.Interaction): description="Show debug stats & info" ) @discord.app_commands.checks.cooldown(rate=1, per=5) +@is_channel() async def debug(interaction: discord.Interaction, page: int = 0, per_page: int = 10, id: str = ''): resp = [] resp.append("==\tGlobal Info\t==") @@ -316,6 +368,7 @@ async def debug(interaction: discord.Interaction, page: int = 0, per_page: int = description="Toggle maintenance mode! (Bot maintainer only)" ) @discord.app_commands.checks.cooldown(rate=1, per=5) +@is_channel() @bot_has_channel_permissions(permissions=discord.Permissions(send_messages=True)) async def maint(interaction: discord.Interaction, status: bool = True): if await bot.is_owner(interaction.user): @@ -376,6 +429,7 @@ async def maint(interaction: discord.Interaction, status: bool = True): description="Add a radio station to favorites" ) @discord.app_commands.checks.cooldown(rate=1, per=5) +@is_channel() async def set_favorite(interaction: discord.Interaction, url: str, name: str = None): # Check permissions perm_manager = get_permission_manager() @@ -422,6 +476,7 @@ async def set_favorite(interaction: discord.Interaction, url: str, name: str = N description="Play a favorite radio station by number" ) @discord.app_commands.checks.cooldown(rate=1, per=5) +@is_channel() async def play_favorite(interaction: discord.Interaction, number: int): try: favorites_manager = get_favorites_manager() @@ -448,6 +503,7 @@ async def play_favorite(interaction: discord.Interaction, number: int): description="Show favorites with clickable buttons" ) @discord.app_commands.checks.cooldown(rate=1, per=10) +@is_channel() async def favorites(interaction: discord.Interaction): try: favorites_manager = get_favorites_manager() @@ -475,6 +531,7 @@ async def favorites(interaction: discord.Interaction): description="List all favorites (text only, mobile-friendly)" ) @discord.app_commands.checks.cooldown(rate=1, per=5) +@is_channel() async def list_favorites(interaction: discord.Interaction): try: favorites_manager = get_favorites_manager() @@ -492,6 +549,7 @@ async def list_favorites(interaction: discord.Interaction): description="Remove a favorite radio station" ) @discord.app_commands.checks.cooldown(rate=1, per=5) +@is_channel() async def remove_favorite(interaction: discord.Interaction, number: int): # Check permissions perm_manager = get_permission_manager() @@ -543,6 +601,7 @@ async def remove_favorite(interaction: discord.Interaction, number: int): description="Configure which Discord roles can manage favorites" ) @discord.app_commands.checks.cooldown(rate=1, per=5) +@is_channel() async def setup_roles(interaction: discord.Interaction, role: discord.Role = None, permission_level: str = None): # Check permissions perm_manager = get_permission_manager() From efb68ccfe8e94c8f3a081f5cb4b1179ffd4520d8 Mon Sep 17 00:00:00 2001 From: unknownpedestrian Date: Wed, 19 Nov 2025 02:02:57 -0500 Subject: [PATCH 2/4] add global cooldown to some commands --- bot.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot.py b/bot.py index e5e230f..ebd2933 100644 --- a/bot.py +++ b/bot.py @@ -192,7 +192,7 @@ async def predicate(interaction: discord.Interaction): name='play', description="Begin playback of a shoutcast/icecast stream" ) -@discord.app_commands.checks.cooldown(rate=1, per=5) +@discord.app_commands.checks.cooldown(rate=1, per=5, key=None) @is_channel() @bot_has_channel_permissions(permissions=discord.Permissions(send_messages=True)) @bot_not_in_maintenance() @@ -211,7 +211,7 @@ async def play(interaction: discord.Interaction, url: str, private_stream: bool name='leave', description="Remove the bot from the current call" ) -@discord.app_commands.checks.cooldown(rate=1, per=5) +@discord.app_commands.checks.cooldown(rate=1, per=5, key=None) @is_channel() @bot_not_in_maintenance() async def leave(interaction: discord.Interaction, force: bool = False): @@ -266,7 +266,7 @@ async def song(interaction: discord.Interaction): name="refresh", description="Refresh the stream. Bot will leave and come back. Song updates will start displaying in this channel" ) -@discord.app_commands.checks.cooldown(rate=1, per=5) +@discord.app_commands.checks.cooldown(rate=1, per=5, key=None) @is_channel() @bot_has_channel_permissions(permissions=discord.Permissions(send_messages=True)) @bot_not_in_maintenance() @@ -367,7 +367,7 @@ async def debug(interaction: discord.Interaction, page: int = 0, per_page: int = name='maint', description="Toggle maintenance mode! (Bot maintainer only)" ) -@discord.app_commands.checks.cooldown(rate=1, per=5) +@discord.app_commands.checks.cooldown(rate=1, per=5, key=None) @is_channel() @bot_has_channel_permissions(permissions=discord.Permissions(send_messages=True)) async def maint(interaction: discord.Interaction, status: bool = True): From b42c5563641f7f108c960b8fcc1517df1576a0dd Mon Sep 17 00:00:00 2001 From: unknownpedestrian Date: Wed, 19 Nov 2025 04:13:16 -0500 Subject: [PATCH 3/4] Tweak FFmpeg options and loudnorm --- bot.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bot.py b/bot.py index ebd2933..9303d6c 100644 --- a/bot.py +++ b/bot.py @@ -970,13 +970,12 @@ async def play_stream(interaction, url): return False # TRY to Pipe music stream to FFMpeg: - - # We love adhering to SHOUTcast recommended buffer sizes arounder here! yay! - # MARKER BYTES REQUIRED FOR PROPER SYNC! - # 4080 bytes per tick * 8 chunks = 32640 + 8 marker bytes = 32648 bits buffer (8 chunks) - # 4080 bytes per tick * 4 Chunks = 16320 + 4 marker bytes = 16324 bits per analysis (4 chunks) + ## Opus trancoding with loudnorm (12dB LRA) + ## Buffer size: 15Mb + ## Analyze Duration: 5 seconds + ## Allowed Protocols: http,https,tls,pipe try: - music_stream = discord.FFmpegOpusAudio(source=url, options="-analyzeduration 16324 -rtbufsize 32648 -filter:a loudnorm=I=-30:LRA=7:TP=-3 -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 120 -tls_verify 0 -protocol_whitelist http,https,tls,pipe") + music_stream = discord.FFmpegOpusAudio(source=url, options="-rtbufsize 15M -analyzeduration 5000000 -filter:a loudnorm=I=-30:LRA=12:TP=-3 -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 120 -tls_verify 0 -protocol_whitelist http,https,tls,pipe") await asyncio.sleep(1) # Give FFmpeg a moment to start except Exception as e: logger.error(f"Failed to start FFmpeg stream: {e}") From d677198565d1d75bb1db1e81fb60bb050b3f10a4 Mon Sep 17 00:00:00 2001 From: unknownpedestrian Date: Wed, 19 Nov 2025 05:25:05 -0500 Subject: [PATCH 4/4] change wording on incompatible channel denials --- bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot.py b/bot.py index 9303d6c..6c0f24f 100644 --- a/bot.py +++ b/bot.py @@ -145,14 +145,14 @@ async def _predicate(interaction: discord.Interaction): if is_thread: try: - await interaction.response.send_message("⚠️ I can't process commands *properly* in message threads, use a `text-channel` or `voice-text-channel` instead.", ephemeral=True) + await interaction.response.send_message("⚠️ I can't process commands *properly* in message threads, use a `text-channel` (or `voice-text-channel`) instead.", ephemeral=True) logger.error(f"{interaction.user} attempted to use a command in a thread") except Exception as e: logger.warning(f"Failed to send thread rejection message: {e}") return False if is_dm: try: - await interaction.response.send_message("⚠️ I can't process commands *directly*, invite me to a server and use a `text-channel` instead.") + await interaction.response.send_message("⚠️ I can't process commands *directly*, add me to a server and use a `text-channel` instead.") logger.error(f"{interaction.user} attempted to use a command in a DM") except Exception as e: logger.warning(f"Failed to send DM rejection message: {e}")