added type hints all over the project, updated mypy to ignore missing types from libraries

This commit is contained in:
2025-11-29 18:56:44 +00:00
parent 7c6249b120
commit 4ef4bdf309
5 changed files with 211 additions and 85 deletions

25
bot.py
View File

@@ -1,20 +1,30 @@
"""
Groovy-Zilean Bot Class
Main bot implementation with cog loading and background tasks
"""
from typing import Any
from discord.ext import commands from discord.ext import commands
from discord.ext import tasks from discord.ext import tasks
from cogs.music.main import music from cogs.music.main import music
from help import GroovyHelp # Import the new Help Cog from help import GroovyHelp
cogs = [ # List of cogs to load on startup
cogs: list[type[commands.Cog]] = [
music, music,
GroovyHelp GroovyHelp
] ]
class Groovy(commands.Bot): class Groovy(commands.Bot):
def __init__(self, *args, **kwargs): """Custom bot class with automatic cog loading and inactivity checking"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
# We force help_command to None because we are using a custom Cog for it # We force help_command to None because we are using a custom Cog for it
# But we pass all other args (like command_prefix) to the parent # But we pass all other args (like command_prefix) to the parent
super().__init__(*args, help_command=None, **kwargs) super().__init__(*args, help_command=None, **kwargs)
async def on_ready(self): async def on_ready(self) -> None:
import config # Imported here to avoid circular dependencies if any import config # Imported here to avoid circular dependencies if any
# Set status # Set status
@@ -47,11 +57,12 @@ class Groovy(commands.Bot):
print(f"{self.user} is ready and online!") print(f"{self.user} is ready and online!")
@tasks.loop(seconds=30) @tasks.loop(seconds=30)
async def inactivity_checker(self): async def inactivity_checker(self) -> None:
"""Check for inactive voice connections""" """Check for inactive voice connections every 30 seconds"""
from cogs.music import util from cogs.music import util
await util.check_inactivity(self) await util.check_inactivity(self)
@inactivity_checker.before_loop @inactivity_checker.before_loop
async def before_inactivity_checker(self): async def before_inactivity_checker(self) -> None:
"""Wait for bot to be ready before starting inactivity checker"""
await self.wait_until_ready() await self.wait_until_ready()

View File

@@ -328,7 +328,7 @@ class music(commands.Cog):
app_commands.Choice(name="Song", value="song"), app_commands.Choice(name="Song", value="song"),
app_commands.Choice(name="Queue", value="queue") app_commands.Choice(name="Queue", value="queue")
]) ])
async def loop(self, ctx: Context, mode: str = None): async def loop(self, ctx: Context, mode: str | None = None):
"""Toggle between loop modes or set a specific mode""" """Toggle between loop modes or set a specific mode"""
server = ctx.guild server = ctx.guild
@@ -391,7 +391,7 @@ class music(commands.Cog):
description="Set playback volume", description="Set playback volume",
aliases=['vol', 'v']) aliases=['vol', 'v'])
@app_commands.describe(level="Volume level (0-200%, default shows current)") @app_commands.describe(level="Volume level (0-200%, default shows current)")
async def volume(self, ctx: Context, level: int = None): async def volume(self, ctx: Context, level: int | None = None):
"""Set or display the current volume""" """Set or display the current volume"""
server = ctx.guild server = ctx.guild
@@ -435,7 +435,7 @@ class music(commands.Cog):
description="Apply audio effects to playback", description="Apply audio effects to playback",
aliases=['fx', 'filter']) aliases=['fx', 'filter'])
@app_commands.describe(effect_name="The audio effect to apply (leave empty to see list)") @app_commands.describe(effect_name="The audio effect to apply (leave empty to see list)")
async def effect(self, ctx: Context, effect_name: str = None): async def effect(self, ctx: Context, effect_name: str | None = None):
"""Apply or list audio effects""" """Apply or list audio effects"""
server = ctx.guild server = ctx.guild

View File

@@ -1,5 +1,9 @@
# Handles translating urls and search terms """
URL and search query handling for Groovy-Zilean
Translates YouTube, Spotify, SoundCloud URLs and search queries into playable audio
"""
from typing import Any
import yt_dlp as ytdlp import yt_dlp as ytdlp
import spotipy import spotipy
@@ -22,7 +26,7 @@ ydl_opts = {
}, },
} }
async def main(url, sp): async def main(url: str, sp: spotipy.Spotify) -> list[dict[str, Any] | str]:
#url = url.lower() #url = url.lower()
@@ -60,7 +64,7 @@ async def main(url, sp):
return [] return []
async def search_song(search): async def search_song(search: str) -> list[dict[str, Any]]:
with ytdlp.YoutubeDL(ydl_opts) as ydl: with ytdlp.YoutubeDL(ydl_opts) as ydl:
try: try:
info = ydl.extract_info(f"ytsearch1:{search}", download=False) info = ydl.extract_info(f"ytsearch1:{search}", download=False)
@@ -89,7 +93,7 @@ async def search_song(search):
return [data] return [data]
async def spotify_song(url, sp): async def spotify_song(url: str, sp: spotipy.Spotify) -> list[dict[str, Any]]:
track = sp.track(url.split("/")[-1].split("?")[0]) track = sp.track(url.split("/")[-1].split("?")[0])
search = "" search = ""
@@ -106,7 +110,11 @@ async def spotify_song(url, sp):
return await search_song(query) return await search_song(query)
async def spotify_playlist(url, sp): async def spotify_playlist(url: str, sp: spotipy.Spotify) -> list[str | dict[str, Any]]:
"""
Get songs from a Spotify playlist
Returns a mixed list where first item is dict, rest are search strings
"""
# Get the playlist uri code # Get the playlist uri code
code = url.split("/")[-1].split("?")[0] code = url.split("/")[-1].split("?")[0]
@@ -116,41 +124,35 @@ async def spotify_playlist(url, sp):
except spotipy.exceptions.SpotifyException: except spotipy.exceptions.SpotifyException:
return [] return []
# Go through the tracks # Go through the tracks and build search queries
songs = [] songs: list[str | dict[str, Any]] = [] # Explicit type for mypy
for track in results: for track in results:
search = "" search = ""
# Fetch all artists # Fetch all artists
for artist in track['track']['artists']: for artist in track['track']['artists']:
# Add all artists to search # Add all artists to search
search += f"{artist['name']}, " search += f"{artist['name']}, "
# Remove last column # Remove last comma
search = search[:-2] search = search[:-2]
search += f" - {track['track']['name']}" search += f" - {track['track']['name']}"
songs.append(search) songs.append(search)
#searched_result = search_song(search) # Fetch first song's full data
#if searched_result == []:
#continue
#songs.append(searched_result[0])
while True: while True:
search_result = await search_song(songs[0]) search_result = await search_song(songs[0]) # type: ignore
if search_result == []: if search_result == []:
songs.pop(0) songs.pop(0)
continue continue
else: else:
songs[0] = search_result[0] songs[0] = search_result[0] # Replace string with dict
break break
return songs return songs
async def song_download(url): async def song_download(url: str) -> list[dict[str, Any]]:
with ytdlp.YoutubeDL(ydl_opts) as ydl: with ytdlp.YoutubeDL(ydl_opts) as ydl:
try: try:
info = ydl.extract_info(url, download=False) info = ydl.extract_info(url, download=False)
@@ -180,7 +182,7 @@ async def song_download(url):
return [data] return [data]
async def playlist_download(url): async def playlist_download(url: str) -> list[dict[str, Any]]:
with ytdlp.YoutubeDL(ydl_opts) as ydl: with ytdlp.YoutubeDL(ydl_opts) as ydl:
try: try:
info = ydl.extract_info(url, download=False) info = ydl.extract_info(url, download=False)

View File

@@ -1,3 +1,9 @@
"""
Utility functions for Groovy-Zilean music bot
Handles voice channel operations, queue display, and inactivity tracking
"""
from typing import Any
import discord import discord
from discord.ext.commands.context import Context from discord.ext.commands.context import Context
from discord.ext.commands.converter import CommandError from discord.ext.commands.converter import CommandError
@@ -7,15 +13,29 @@ from . import queue
import asyncio import asyncio
# Track last activity time for each server # Track last activity time for each server
last_activity = {} last_activity: dict[int, float] = {}
# Joining/moving to the user's vc in a guild
async def join_vc(ctx: Context):
# ===================================
# Voice Channel Management
# ===================================
async def join_vc(ctx: Context) -> discord.VoiceClient:
"""
Join or move to the user's voice channel
Args:
ctx: Command context
Returns:
The voice client connection
Raises:
CommandError: If user is not in a voice channel
"""
# Get the user's vc # Get the user's vc
author_voice = getattr(ctx.author, "voice") author_voice = getattr(ctx.author, "voice")
if author_voice is None: if author_voice is None:
# Raise exception if user is not in vc
raise CommandError("User is not in voice channel") raise CommandError("User is not in voice channel")
# Get user's vc # Get user's vc
@@ -25,19 +45,26 @@ async def join_vc(ctx: Context):
# Join or move to the user's vc # Join or move to the user's vc
if ctx.voice_client is None: if ctx.voice_client is None:
vc = await vc.connect() vc_client = await vc.connect()
else: else:
# Safe to ignore type error for now vc_client = await ctx.voice_client.move_to(vc)
vc = await ctx.voice_client.move_to(vc)
# Update last activity # Update last activity
last_activity[ctx.guild.id] = asyncio.get_event_loop().time() last_activity[ctx.guild.id] = asyncio.get_event_loop().time()
return vc return vc_client
# Leaving the voice channel of a user async def leave_vc(ctx: Context) -> None:
async def leave_vc(ctx: Context): """
Leave the voice channel and clean up
Args:
ctx: Command context
Raises:
CommandError: If bot is not in VC or user is not in same VC
"""
# If the bot is not in a vc of this server # If the bot is not in a vc of this server
if ctx.voice_client is None: if ctx.voice_client is None:
raise CommandError("I am not in a voice channel") raise CommandError("I am not in a voice channel")
@@ -73,9 +100,18 @@ async def leave_vc(ctx: Context):
del last_activity[ctx.guild.id] del last_activity[ctx.guild.id]
# Auto-disconnect if inactive # ===================================
async def check_inactivity(bot): # Inactivity Management
"""Background task to check for inactive voice connections""" # ===================================
async def check_inactivity(bot: discord.Client) -> None:
"""
Background task to check for inactive voice connections
Auto-disconnects after 5 minutes of inactivity
Args:
bot: The Discord bot instance
"""
try: try:
current_time = asyncio.get_event_loop().time() current_time = asyncio.get_event_loop().time()
@@ -98,20 +134,34 @@ async def check_inactivity(bot):
print(f"Error in inactivity checker: {e}") print(f"Error in inactivity checker: {e}")
# Update activity timestamp when playing def update_activity(guild_id: int) -> None:
def update_activity(guild_id): """
"""Call this when a song starts playing""" Update activity timestamp when a song starts playing
Args:
guild_id: Discord guild/server ID
"""
last_activity[guild_id] = asyncio.get_event_loop().time() last_activity[guild_id] = asyncio.get_event_loop().time()
# Interactive buttons for queue control # ===================================
# Queue Display & Controls
# ===================================
class QueueControls(View): class QueueControls(View):
def __init__(self, ctx): """Interactive buttons for queue control"""
def __init__(self, ctx: Context) -> None:
super().__init__(timeout=None) # No timeout allows buttons to stay active longer super().__init__(timeout=None) # No timeout allows buttons to stay active longer
self.ctx = ctx self.ctx = ctx
async def refresh_message(self, interaction: discord.Interaction): async def refresh_message(self, interaction: discord.Interaction) -> None:
"""Helper to regenerate the embed and edit the message""" """
Helper to regenerate the embed and edit the message
Args:
interaction: Discord interaction from button press
"""
try: try:
# Generate new embed # Generate new embed
embed, view = await generate_queue_ui(self.ctx) embed, view = await generate_queue_ui(self.ctx)
@@ -119,10 +169,13 @@ class QueueControls(View):
except Exception as e: except Exception as e:
# Fallback if edit fails # Fallback if edit fails
if not interaction.response.is_done(): if not interaction.response.is_done():
await interaction.response.send_message("Refreshed, but something went wrong updating the display.", ephemeral=True) await interaction.response.send_message(
"Refreshed, but something went wrong updating the display.",
ephemeral=True
)
@discord.ui.button(label="⏭️ Skip", style=discord.ButtonStyle.primary) @discord.ui.button(label="⏭️ Skip", style=discord.ButtonStyle.primary)
async def skip_button(self, interaction: discord.Interaction, button: Button): async def skip_button(self, interaction: discord.Interaction, button: Button) -> None:
if interaction.user not in self.ctx.voice_client.channel.members: if interaction.user not in self.ctx.voice_client.channel.members:
await interaction.response.send_message("❌ You must be in the voice channel!", ephemeral=True) await interaction.response.send_message("❌ You must be in the voice channel!", ephemeral=True)
return return
@@ -130,11 +183,6 @@ class QueueControls(View):
# Loop logic check # Loop logic check
loop_mode = await queue.get_loop_mode(self.ctx.guild.id) loop_mode = await queue.get_loop_mode(self.ctx.guild.id)
# Logic mimics the command
if loop_mode == 'song':
# Just restart current song effectively but here we assume standard skip behavior for button
pass
# Perform the skip # Perform the skip
await queue.pop(self.ctx.guild.id, True, skip_mode=True) await queue.pop(self.ctx.guild.id, True, skip_mode=True)
if self.ctx.voice_client: if self.ctx.voice_client:
@@ -144,35 +192,45 @@ class QueueControls(View):
await self.refresh_message(interaction) await self.refresh_message(interaction)
@discord.ui.button(label="🔀 Shuffle", style=discord.ButtonStyle.secondary) @discord.ui.button(label="🔀 Shuffle", style=discord.ButtonStyle.secondary)
async def shuffle_button(self, interaction: discord.Interaction, button: Button): async def shuffle_button(self, interaction: discord.Interaction, button: Button) -> None:
await queue.shuffle_queue(self.ctx.guild.id) await queue.shuffle_queue(self.ctx.guild.id)
await self.refresh_message(interaction) await self.refresh_message(interaction)
@discord.ui.button(label="🔁 Loop", style=discord.ButtonStyle.secondary) @discord.ui.button(label="🔁 Loop", style=discord.ButtonStyle.secondary)
async def loop_button(self, interaction: discord.Interaction, button: Button): async def loop_button(self, interaction: discord.Interaction, button: Button) -> None:
current_mode = await queue.get_loop_mode(self.ctx.guild.id) current_mode = await queue.get_loop_mode(self.ctx.guild.id)
new_mode = 'song' if current_mode == 'off' else ('queue' if current_mode == 'song' else 'off') new_mode = 'song' if current_mode == 'off' else ('queue' if current_mode == 'song' else 'off')
await queue.set_loop_mode(self.ctx.guild.id, new_mode) await queue.set_loop_mode(self.ctx.guild.id, new_mode)
await self.refresh_message(interaction) await self.refresh_message(interaction)
@discord.ui.button(label="🗑️ Clear", style=discord.ButtonStyle.danger) @discord.ui.button(label="🗑️ Clear", style=discord.ButtonStyle.danger)
async def clear_button(self, interaction: discord.Interaction, button: Button): async def clear_button(self, interaction: discord.Interaction, button: Button) -> None:
await queue.clear(self.ctx.guild.id) await queue.clear(self.ctx.guild.id)
if self.ctx.voice_client and self.ctx.voice_client.is_playing(): if self.ctx.voice_client and self.ctx.voice_client.is_playing():
self.ctx.voice_client.stop() self.ctx.voice_client.stop()
await self.refresh_message(interaction) await self.refresh_message(interaction)
@discord.ui.button(label="🔄 Refresh", style=discord.ButtonStyle.gray) @discord.ui.button(label="🔄 Refresh", style=discord.ButtonStyle.gray)
async def refresh_button(self, interaction: discord.Interaction, button: Button): async def refresh_button(self, interaction: discord.Interaction, button: Button) -> None:
await self.refresh_message(interaction) await self.refresh_message(interaction)
async def generate_queue_ui(ctx: Context):
async def generate_queue_ui(ctx: Context) -> tuple[discord.Embed, QueueControls]:
"""
Generate the queue embed and controls
Args:
ctx: Command context
Returns:
Tuple of (embed, view) for displaying queue
"""
guild_id = ctx.guild.id guild_id = ctx.guild.id
server = ctx.guild server = ctx.guild
# Fetch all data # Fetch all data
n, songs = await queue.grab_songs(guild_id) n, songs = await queue.grab_songs(guild_id)
current = await queue.get_current_song(guild_id) # Returns title, thumbnail, url current = await queue.get_current_song(guild_id)
loop_mode = await queue.get_loop_mode(guild_id) loop_mode = await queue.get_loop_mode(guild_id)
volume = await queue.get_volume(guild_id) volume = await queue.get_volume(guild_id)
effect = await queue.get_effect(guild_id) effect = await queue.get_effect(guild_id)
@@ -197,11 +255,9 @@ async def generate_queue_ui(ctx: Context):
# Progress Bar Logic # Progress Bar Logic
progress_bar = "" progress_bar = ""
# Only show bar if duration > 0 (prevents weird 00:00 bars)
if duration > 0: if duration > 0:
bar_length = 16 bar_length = 16
filled = int((percentage / 100) * bar_length) filled = int((percentage / 100) * bar_length)
# Ensure filled isn't bigger than length
filled = min(filled, bar_length) filled = min(filled, bar_length)
bar_str = '' * filled + '🔘' + '' * (bar_length - filled) bar_str = '' * filled + '🔘' + '' * (bar_length - filled)
progress_bar = f"\n`{format_time(elapsed)}` {bar_str} `{format_time(duration)}`" progress_bar = f"\n`{format_time(elapsed)}` {bar_str} `{format_time(duration)}`"
@@ -215,14 +271,11 @@ async def generate_queue_ui(ctx: Context):
description = "## 💤 Nothing is playing\nUse `/play` to start the party!" description = "## 💤 Nothing is playing\nUse `/play` to start the party!"
else: else:
# Create Hyperlink [Title](URL) # Create Hyperlink [Title](URL)
# If no URL exists, link to Discord homepage as fallback or just bold
if url and url.startswith("http"): if url and url.startswith("http"):
song_link = f"[{title}]({url})" song_link = f"[{title}]({url})"
else: else:
song_link = f"**{title}**" song_link = f"**{title}**"
# CLEARER STATUS LINE:
# Loop: Mode | Effect: Name | Vol: %
description = ( description = (
f"## 💿 Now Playing\n" f"## 💿 Now Playing\n"
f"### {song_link}\n" f"### {song_link}\n"
@@ -241,7 +294,7 @@ async def generate_queue_ui(ctx: Context):
embed.add_field(name="⏳ Up Next", value=queue_text, inline=False) embed.add_field(name="⏳ Up Next", value=queue_text, inline=False)
remaining = (n) - 9 # Approx calculation based on your grabbing logic remaining = n - 9
if remaining > 0: if remaining > 0:
embed.set_footer(text=f"Waitlist: {remaining} more songs...") embed.set_footer(text=f"Waitlist: {remaining} more songs...")
else: else:
@@ -251,23 +304,38 @@ async def generate_queue_ui(ctx: Context):
if thumb and isinstance(thumb, str) and thumb.startswith("http"): if thumb and isinstance(thumb, str) and thumb.startswith("http"):
embed.set_thumbnail(url=thumb) embed.set_thumbnail(url=thumb)
elif server.icon: elif server.icon:
# Fallback to server icon
embed.set_thumbnail(url=server.icon.url) embed.set_thumbnail(url=server.icon.url)
view = QueueControls(ctx) view = QueueControls(ctx)
return embed, view return embed, view
# The command entry point calls this
async def display_server_queue(ctx: Context, songs, n): async def display_server_queue(ctx: Context, songs: list, n: int) -> None:
"""
Display the server's queue with interactive controls
Args:
ctx: Command context
songs: List of songs in queue
n: Total number of songs
"""
embed, view = await generate_queue_ui(ctx) embed, view = await generate_queue_ui(ctx)
await ctx.send(embed=embed, view=view) await ctx.send(embed=embed, view=view)
# Build a display message for queuing a new song
async def queue_message(ctx: Context, data: dict): async def queue_message(ctx: Context, data: dict[str, Any]) -> None:
"""
Display a message when a song is queued
Args:
ctx: Command context
data: Song data dictionary
"""
msg = discord.Embed( msg = discord.Embed(
title="🎵 Song Queued", title="🎵 Song Queued",
description=f"**{data['title']}**", description=f"**{data['title']}**",
color=discord.Color.green()) color=discord.Color.green()
)
msg.set_thumbnail(url=data['thumbnail']) msg.set_thumbnail(url=data['thumbnail'])
msg.add_field(name="⏱️ Duration", value=format_time(data['duration']), inline=True) msg.add_field(name="⏱️ Duration", value=format_time(data['duration']), inline=True)
@@ -276,9 +344,23 @@ async def queue_message(ctx: Context, data: dict):
await ctx.send(embed=msg) await ctx.send(embed=msg)
# Converts seconds into more readable format
def format_time(seconds): # ===================================
# Utility Functions
# ===================================
def format_time(seconds: int | float) -> str:
"""
Convert seconds into readable time format (MM:SS or HH:MM:SS)
Args:
seconds: Time in seconds
Returns:
Formatted time string
"""
try: try:
seconds = int(seconds)
minutes, seconds = divmod(seconds, 60) minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60) hours, minutes = divmod(minutes, 60)

31
mypy.ini Normal file
View File

@@ -0,0 +1,31 @@
# mypy configuration for groovy-zilean
# Type checking configuration that's practical for a Discord bot
[mypy]
# Python version
python_version = 3.13
# Ignore missing imports for libraries without type stubs
# Discord.py, spotipy, yt-dlp don't have complete type stubs
ignore_missing_imports = True
# Be strict about our own code
# Start lenient, can tighten later
disallow_untyped_defs = False
check_untyped_defs = True
# Too noisy with discord.py
warn_return_any = False
warn_unused_configs = True
# Exclude patterns
exclude = venv/
# Per-module overrides
[mypy-discord.*]
ignore_missing_imports = True
[mypy-spotipy.*]
ignore_missing_imports = True
[mypy-yt_dlp.*]
ignore_missing_imports = True