import traceback import logging from typing import Optional, Union from regex import Pattern import discord import regex from util.sing_util import Utility from discord.ext import bridge, commands from disc_havoc import Havoc import random import string import json import os BOT_CHANIDS = [] # Global storage for pagination data (survives across interactions) PAGINATION_DATA = {} PAGINATION_FILE = "pagination_data.json" def load_pagination_data(): """Load pagination data from JSON file""" global PAGINATION_DATA try: if os.path.exists(PAGINATION_FILE): with open(PAGINATION_FILE, 'r') as f: PAGINATION_DATA = json.load(f) except Exception: PAGINATION_DATA = {} def save_pagination_data(): """Save pagination data to JSON file (excluding non-serializable embeds)""" try: # Create serializable copy without embeds serializable_data = {} for key, value in PAGINATION_DATA.items(): serializable_data[key] = { "pages": value["pages"], "song": value["song"], "artist": value["artist"], "total_pages": value["total_pages"] } with open(PAGINATION_FILE, 'w') as f: json.dump(serializable_data, f) except Exception: pass import logging from typing import Optional, Union from regex import Pattern import discord import regex from util.sing_util import Utility from discord.ext import bridge, commands from disc_havoc import Havoc import random import string BOT_CHANIDS = [] # Global storage for pagination data (survives across interactions) PAGINATION_DATA: dict[str, dict] = {} class LyricsPaginator(discord.ui.View): def __init__(self, pages, song, artist): super().__init__(timeout=None) # No timeout - buttons work permanently self.pages = pages self.song = song self.artist = artist self.current_page = 0 # Generate a unique ID for this paginator instance self.custom_id_base = ''.join(random.choices(string.ascii_letters + string.digits, k=16)) # Pre-generate all embeds to avoid creating them on each page turn self.embeds = [] # URL encode artist and song for the link import urllib.parse encoded_artist = urllib.parse.quote(self.artist, safe='') encoded_song = urllib.parse.quote(self.song, safe='') lyrics_url = f"https://codey.lol/#{encoded_artist}/{encoded_song}" for i, page in enumerate(pages): embed = discord.Embed(title=f"{self.song}", color=discord.Color.blue(), url=lyrics_url) embed.set_author(name=self.artist) embed.description = page # Set footer with just page info for multi-page, or no footer for single page if len(pages) > 1: embed.set_footer(text=f"Page {i + 1} of {len(pages)}", icon_url="https://codey.lol/favicon.ico") self.embeds.append(embed) # Store pagination data for persistence across bot restarts if len(pages) > 1: # Store data (embeds will be recreated as needed since they can't be JSON serialized) serializable_data = { "pages": pages, "song": song, "artist": artist, "total_pages": len(pages) } # Store in memory with embeds for performance PAGINATION_DATA[self.custom_id_base] = { **serializable_data, "embeds": self.embeds } # Save serializable data to file save_pagination_data() def get_view_for_page(self, page_num: int): """Create a view with properly configured buttons for the given page""" view = discord.ui.View(timeout=None) if len(self.pages) > 1: # Previous button prev_button = discord.ui.Button( label="Previous", style=discord.ButtonStyle.secondary, emoji="⬅️", disabled=(page_num == 0), custom_id=f"lyrics_prev:{self.custom_id_base}", row=0 ) # Next button next_button = discord.ui.Button( label="Next", style=discord.ButtonStyle.primary, emoji="➡️", disabled=(page_num == len(self.pages) - 1), custom_id=f"lyrics_next:{self.custom_id_base}", row=0 ) view.add_item(prev_button) view.add_item(next_button) return view class Sing(commands.Cog): """Sing Cog for Havoc""" def __init__(self, bot: Havoc) -> None: self.bot: Havoc = bot self.utility = Utility() global BOT_CHANIDS BOT_CHANIDS = self.bot.BOT_CHANIDS # Inherit self.control_strip_regex: Pattern = regex.compile( r"\x0f|\x1f|\035|\002|\u2064|\x02|(\x03([0-9]{1,2}))|(\x03|\003)(?:\d{1,2}(?:,\d{1,2})?)?", regex.UNICODE, ) # Persistent interactions handled by on_interaction listener @commands.Cog.listener() async def on_interaction(self, interaction: discord.Interaction): """Handle persistent lyrics pagination interactions""" if interaction.type != discord.InteractionType.component: return custom_id = interaction.data.get("custom_id", "") if not (custom_id.startswith("lyrics_prev:") or custom_id.startswith("lyrics_next:")): return try: # Extract the base custom ID and direction if custom_id.startswith("lyrics_prev:"): direction = -1 base_id = custom_id.replace("lyrics_prev:", "") else: # lyrics_next: direction = 1 base_id = custom_id.replace("lyrics_next:", "") # Get pagination data from JSON persistence data = PAGINATION_DATA.get(base_id) if not data: await interaction.response.send_message( "⚠️ **Navigation Expired**\n" "These buttons are from a previous session. " "Use `/s ` or `/sing : ` to get fresh lyrics!", ephemeral=True, ) return # Recreate embeds if they don't exist (loaded from JSON) if "embeds" not in data or not data["embeds"]: paginator = LyricsPaginator(data["pages"], data["song"], data["artist"]) paginator.custom_id_base = base_id data["embeds"] = paginator.embeds # Update in-memory data PAGINATION_DATA[base_id] = data # Get current page from embed footer if not interaction.message or not interaction.message.embeds: await interaction.response.defer() return current_embed = interaction.message.embeds[0] footer_text = current_embed.footer.text if current_embed.footer else "" # Extract current page number (format: "Page X of Y") current_page = 0 if "Page " in footer_text and " of " in footer_text: try: page_part = footer_text.split("Page ")[1].split(" of ")[0] current_page = int(page_part) - 1 except: pass # Calculate new page new_page = current_page + direction total_pages = len(data["pages"]) if new_page < 0 or new_page >= total_pages: await interaction.response.defer() # Just acknowledge, don't change anything return # Create new paginator and get view for new page paginator = LyricsPaginator(data["pages"], data["song"], data["artist"]) paginator.custom_id_base = base_id # Preserve same custom ID view = paginator.get_view_for_page(new_page) # Update the message await interaction.response.edit_message(embed=data["embeds"][new_page], view=view) except Exception as e: try: await interaction.response.send_message(f"Error handling pagination: {str(e)}", ephemeral=True) except: pass def is_spamchan(): # type: ignore """Check if channel is spam chan""" def predicate(ctx): try: if ctx.channel.id not in BOT_CHANIDS: logging.debug("%s not found in %s", ctx.channel.id, BOT_CHANIDS) return ctx.channel.id in BOT_CHANIDS except Exception as e: logging.debug("Exception: %s", str(e)) traceback.print_exc() return False return commands.check(predicate) @bridge.bridge_command(aliases=["sing"]) async def s(self, ctx, *, song: Optional[str] = None) -> None: """ Search for lyrics, format is artist : song. Also reads activity. """ try: with ctx.channel.typing(): activity: Optional[discord.Activity] = None if not song: if not ctx.author.activities: return for _activity in ctx.author.activities: if _activity.type == discord.ActivityType.listening: activity = _activity if not activity: return await ctx.respond( "**Error**: No song specified, no activity found to read." ) # Initial response that we'll edit later message = await ctx.respond("*Searching for lyrics...*") parsed = self.utility.parse_song_input(song, activity) if isinstance(parsed, tuple): (search_artist, search_song, search_subsearch) = parsed # Update the message to show what we're searching for await message.edit(content=f"*Searching for '{search_song}' by {search_artist}...*") search_result: Optional[list] = await self.utility.lyric_search( search_artist, search_song, search_subsearch, is_spam_channel=(ctx.channel.id in BOT_CHANIDS) ) if not search_result: await ctx.respond("ERR: No search result.") return if len(search_result) == 1: # Error response from API error, *_ = search_result[0] return await ctx.respond(error) if not isinstance(search_result[0], tuple): return # Invalid data type ( search_result_artist, search_result_song, search_result_src, search_result_confidence, search_result_time_taken, ) = search_result[0] # First index is a tuple search_result_wrapped: list[str] = search_result[ 1 ] # Second index is the wrapped lyrics search_result_wrapped_short: list[str] = search_result[ 2 ] # Third is short wrapped lyrics # Now all channels get full pagination - removed the early return restriction # Use the appropriate wrapped lyrics based on channel type is_spam_channel = ctx.channel.id in BOT_CHANIDS if is_spam_channel: # Spam channels get full-length lyrics pages = search_result_wrapped.copy() else: # Non-spam channels get shorter lyrics for better UX pages = search_result_wrapped.copy() # For now, still use full but we'll modify limits later # Add source info to last page if pages: pages[-1] += f"\n\n`Found on: {search_result_src}`" # Create and send view with paginator paginator = LyricsPaginator(pages, search_result_song, search_result_artist) view = paginator.get_view_for_page(0) # Edit the existing message to show the lyrics await message.edit(content=None, embed=paginator.embeds[0], view=view) except Exception as e: traceback.print_exc() await ctx.respond(f"ERR: {str(e)}") @commands.user_command(name="Sing") async def sing_context_menu(self, ctx, member: discord.Member) -> None: """ Sing Context Menu Command Args: ctx (Any): Discord context member (discord.Member): Discord member Returns: None """ try: PODY_ID: int = 1172340700663255091 IS_SPAMCHAN: bool = ctx.channel.id in BOT_CHANIDS member_display = ctx.interaction.guild.get_member(member.id).display_name if ( not (ctx.interaction.guild.get_member(member.id).activities) and not member.id == PODY_ID ): return await ctx.respond( f"No activity detected to read for {member_display}.", ephemeral=True, ) member_id: int = member.id # if not(member.id == PODY_ID) else 1234134345497837679 # Use Thomas for Pody! activity: Optional[discord.Activity] = None # Initial response that we'll edit later message = await ctx.respond(f"*Reading activity of {member_display}...*" if IS_SPAMCHAN else "*Processing...*") for _activity in ctx.interaction.guild.get_member(member_id).activities: if _activity.type == discord.ActivityType.listening: activity = _activity parsed: Union[tuple, bool] = self.utility.parse_song_input( song=None, activity=activity ) if not parsed: if IS_SPAMCHAN: return await message.edit(content=f"Could not parse activity of {member_display}.") else: return await ctx.respond( f"Could not parse activity of {member_display}.", ephemeral=True ) if isinstance(parsed, tuple): (search_artist, search_song, search_subsearch) = parsed await message.edit(content=f"*Searching for '{search_song}' by {search_artist}...*") search_result: Optional[list] = await self.utility.lyric_search( search_artist, search_song, search_subsearch, is_spam_channel=(ctx.channel.id in BOT_CHANIDS) ) if not search_result: await ctx.respond("ERR: No search result") return if len(search_result) == 1 and isinstance(search_result[0][0], str): return await ctx.send( "ERR: No search result" ) # Error message from API ( search_result_artist, search_result_song, search_result_src, search_result_confidence, search_result_time_taken, ) = search_result[0] # First index is a tuple search_result_wrapped: list = search_result[ 1 ] # Second index is the wrapped lyrics search_result_wrapped_short: list[str] = search_result[ 2 ] # Third index is shortened lyrics # All channels now get full paginated response # Create paginator for lyrics if not search_result_wrapped: return await ctx.respond("No lyrics found.") # Use the already smartly-wrapped pages from sing_util pages = search_result_wrapped.copy() # Already processed by _smart_lyrics_wrap # Add source info to last page if pages: pages[-1] += f"\n\n`Found on: {search_result_src}`" # Create and send view with paginator paginator = LyricsPaginator(pages, search_result_song, search_result_artist) view = paginator.get_view_for_page(0) # Edit the existing message to show the lyrics await message.edit(content=None, embed=paginator.embeds[0], view=view) except Exception as e: traceback.print_exc() return await ctx.respond(f"ERR: {str(e)}") def setup(bot) -> None: """Run on Cog Load""" load_pagination_data() # Load existing pagination data from file bot.add_cog(Sing(bot))