Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 68 additions & 10 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*, 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}")
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):
Expand All @@ -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):
Expand All @@ -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)
@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()
async def play(interaction: discord.Interaction, url: str, private_stream: bool = False):
Expand All @@ -164,7 +211,8 @@ 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):
voice_client = interaction.guild.voice_client
Expand Down Expand Up @@ -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:
Expand All @@ -217,7 +266,8 @@ 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()
async def refresh(interaction: discord.Interaction):
Expand All @@ -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",
Expand Down Expand Up @@ -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==")
Expand Down Expand Up @@ -315,7 +367,8 @@ 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):
if await bot.is_owner(interaction.user):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -911,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}")
Expand Down