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 tasks
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,
GroovyHelp
]
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
# But we pass all other args (like command_prefix) to the parent
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
# Set status
@@ -47,11 +57,12 @@ class Groovy(commands.Bot):
print(f"{self.user} is ready and online!")
@tasks.loop(seconds=30)
async def inactivity_checker(self):
"""Check for inactive voice connections"""
async def inactivity_checker(self) -> None:
"""Check for inactive voice connections every 30 seconds"""
from cogs.music import util
await util.check_inactivity(self)
@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()

View File

@@ -328,7 +328,7 @@ class music(commands.Cog):
app_commands.Choice(name="Song", value="song"),
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"""
server = ctx.guild
@@ -391,7 +391,7 @@ class music(commands.Cog):
description="Set playback volume",
aliases=['vol', 'v'])
@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"""
server = ctx.guild
@@ -435,7 +435,7 @@ class music(commands.Cog):
description="Apply audio effects to playback",
aliases=['fx', 'filter'])
@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"""
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 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()
@@ -60,7 +64,7 @@ async def main(url, sp):
return []
async def search_song(search):
async def search_song(search: str) -> list[dict[str, Any]]:
with ytdlp.YoutubeDL(ydl_opts) as ydl:
try:
info = ydl.extract_info(f"ytsearch1:{search}", download=False)
@@ -89,7 +93,7 @@ async def search_song(search):
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])
search = ""
@@ -106,7 +110,11 @@ async def spotify_song(url, sp):
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
code = url.split("/")[-1].split("?")[0]
@@ -115,42 +123,36 @@ async def spotify_playlist(url, sp):
results = sp.playlist_tracks(code)['items']
except spotipy.exceptions.SpotifyException:
return []
# Go through the tracks
songs = []
# Go through the tracks and build search queries
songs: list[str | dict[str, Any]] = [] # Explicit type for mypy
for track in results:
search = ""
# Fetch all artists
for artist in track['track']['artists']:
# Add all artists to search
search += f"{artist['name']}, "
# Remove last column
# Remove last comma
search = search[:-2]
search += f" - {track['track']['name']}"
songs.append(search)
#searched_result = search_song(search)
#if searched_result == []:
#continue
#songs.append(searched_result[0])
# Fetch first song's full data
while True:
search_result = await search_song(songs[0])
search_result = await search_song(songs[0]) # type: ignore
if search_result == []:
songs.pop(0)
continue
else:
songs[0] = search_result[0]
songs[0] = search_result[0] # Replace string with dict
break
return songs
async def song_download(url):
async def song_download(url: str) -> list[dict[str, Any]]:
with ytdlp.YoutubeDL(ydl_opts) as ydl:
try:
info = ydl.extract_info(url, download=False)
@@ -180,7 +182,7 @@ async def song_download(url):
return [data]
async def playlist_download(url):
async def playlist_download(url: str) -> list[dict[str, Any]]:
with ytdlp.YoutubeDL(ydl_opts) as ydl:
try:
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
from discord.ext.commands.context import Context
from discord.ext.commands.converter import CommandError
@@ -7,15 +13,29 @@ from . import queue
import asyncio
# 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
author_voice = getattr(ctx.author, "voice")
if author_voice is None:
# Raise exception if user is not in vc
raise CommandError("User is not in voice channel")
# Get user's vc
@@ -25,19 +45,26 @@ async def join_vc(ctx: Context):
# Join or move to the user's vc
if ctx.voice_client is None:
vc = await vc.connect()
vc_client = await vc.connect()
else:
# Safe to ignore type error for now
vc = await ctx.voice_client.move_to(vc)
vc_client = await ctx.voice_client.move_to(vc)
# Update last activity
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):
async def leave_vc(ctx: Context) -> None:
"""
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 ctx.voice_client is None:
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]
# Auto-disconnect if inactive
async def check_inactivity(bot):
"""Background task to check for inactive voice connections"""
# ===================================
# Inactivity Management
# ===================================
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:
current_time = asyncio.get_event_loop().time()
@@ -98,20 +134,34 @@ async def check_inactivity(bot):
print(f"Error in inactivity checker: {e}")
# Update activity timestamp when playing
def update_activity(guild_id):
"""Call this when a song starts playing"""
def update_activity(guild_id: int) -> None:
"""
Update activity timestamp when a song starts playing
Args:
guild_id: Discord guild/server ID
"""
last_activity[guild_id] = asyncio.get_event_loop().time()
# Interactive buttons for queue control
# ===================================
# Queue Display & Controls
# ===================================
class QueueControls(View):
def __init__(self, ctx):
super().__init__(timeout=None) # No timeout allows buttons to stay active longer
"""Interactive buttons for queue control"""
def __init__(self, ctx: Context) -> None:
super().__init__(timeout=None) # No timeout allows buttons to stay active longer
self.ctx = ctx
async def refresh_message(self, interaction: discord.Interaction):
"""Helper to regenerate the embed and edit the message"""
async def refresh_message(self, interaction: discord.Interaction) -> None:
"""
Helper to regenerate the embed and edit the message
Args:
interaction: Discord interaction from button press
"""
try:
# Generate new embed
embed, view = await generate_queue_ui(self.ctx)
@@ -119,10 +169,13 @@ class QueueControls(View):
except Exception as e:
# Fallback if edit fails
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)
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:
await interaction.response.send_message("❌ You must be in the voice channel!", ephemeral=True)
return
@@ -130,11 +183,6 @@ class QueueControls(View):
# Loop logic check
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
await queue.pop(self.ctx.guild.id, True, skip_mode=True)
if self.ctx.voice_client:
@@ -144,35 +192,45 @@ class QueueControls(View):
await self.refresh_message(interaction)
@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 self.refresh_message(interaction)
@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)
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 self.refresh_message(interaction)
@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)
if self.ctx.voice_client and self.ctx.voice_client.is_playing():
self.ctx.voice_client.stop()
await self.refresh_message(interaction)
@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)
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
server = ctx.guild
# Fetch all data
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)
volume = await queue.get_volume(guild_id)
effect = await queue.get_effect(guild_id)
@@ -183,10 +241,10 @@ async def generate_queue_ui(ctx: Context):
# Map loop mode to nicer text
loop_map = {
'off': {'emoji': '⏹️', 'text': 'Off'},
'song': {'emoji': '🔂', 'text': 'Song'},
'queue': {'emoji': '🔁', 'text': 'Queue'}
}
'off': {'emoji': '⏹️', 'text': 'Off'},
'song': {'emoji': '🔂', 'text': 'Song'},
'queue': {'emoji': '🔁', 'text': 'Queue'}
}
loop_info = loop_map.get(loop_mode, loop_map['off'])
loop_emoji = loop_info['emoji']
loop_text = loop_info['text']
@@ -197,11 +255,9 @@ async def generate_queue_ui(ctx: Context):
# Progress Bar Logic
progress_bar = ""
# Only show bar if duration > 0 (prevents weird 00:00 bars)
if duration > 0:
bar_length = 16
filled = int((percentage / 100) * bar_length)
# Ensure filled isn't bigger than length
filled = min(filled, bar_length)
bar_str = '' * filled + '🔘' + '' * (bar_length - filled)
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!"
else:
# Create Hyperlink [Title](URL)
# If no URL exists, link to Discord homepage as fallback or just bold
if url and url.startswith("http"):
song_link = f"[{title}]({url})"
else:
song_link = f"**{title}**"
# CLEARER STATUS LINE:
# Loop: Mode | Effect: Name | Vol: %
description = (
f"## 💿 Now Playing\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)
remaining = (n) - 9 # Approx calculation based on your grabbing logic
remaining = n - 9
if remaining > 0:
embed.set_footer(text=f"Waitlist: {remaining} more songs...")
else:
@@ -251,23 +304,38 @@ async def generate_queue_ui(ctx: Context):
if thumb and isinstance(thumb, str) and thumb.startswith("http"):
embed.set_thumbnail(url=thumb)
elif server.icon:
# Fallback to server icon
embed.set_thumbnail(url=server.icon.url)
view = QueueControls(ctx)
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)
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(
title="🎵 Song Queued",
description=f"**{data['title']}**",
color=discord.Color.green())
title="🎵 Song Queued",
description=f"**{data['title']}**",
color=discord.Color.green()
)
msg.set_thumbnail(url=data['thumbnail'])
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)
# 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:
seconds = int(seconds)
minutes, seconds = divmod(seconds, 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