Files
discord-havoc/cogs/sing.py

429 lines
17 KiB
Python
Raw Normal View History

2025-02-13 14:51:35 -05:00
import traceback
import logging
from typing import Optional, Union
2025-02-14 07:42:32 -05:00
from regex import Pattern
2025-02-13 14:51:35 -05:00
import discord
import regex
from util.sing_util import Utility
from discord.ext import bridge, commands
2025-02-15 08:36:45 -05:00
from disc_havoc import Havoc
2025-10-08 15:45:47 -04:00
import random
import string
import json
import os
2025-02-15 08:36:45 -05:00
2025-02-13 14:51:35 -05:00
BOT_CHANIDS = []
2025-10-08 15:45:47 -04:00
# 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
2025-04-17 14:35:56 -04:00
2025-02-13 14:51:35 -05:00
class Sing(commands.Cog):
"""Sing Cog for Havoc"""
2025-04-17 14:35:56 -04:00
2025-02-15 08:36:45 -05:00
def __init__(self, bot: Havoc) -> None:
self.bot: Havoc = bot
2025-02-13 14:51:35 -05:00
self.utility = Utility()
global BOT_CHANIDS
2025-04-17 14:35:56 -04:00
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,
)
2025-10-08 15:45:47 -04:00
# 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 <song>` or `/sing <artist> : <song>` 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
2025-04-17 14:35:56 -04:00
def is_spamchan(): # type: ignore
2025-02-13 14:51:35 -05:00
"""Check if channel is spam chan"""
2025-04-17 14:35:56 -04:00
2025-02-13 14:51:35 -05:00
def predicate(ctx):
try:
2025-04-26 21:59:46 -04:00
if ctx.channel.id not in BOT_CHANIDS:
2025-02-13 14:51:35 -05:00
logging.debug("%s not found in %s", ctx.channel.id, BOT_CHANIDS)
return ctx.channel.id in BOT_CHANIDS
2025-04-26 21:59:46 -04:00
except Exception as e:
logging.debug("Exception: %s", str(e))
2025-02-13 14:51:35 -05:00
traceback.print_exc()
return False
2025-04-17 14:35:56 -04:00
return commands.check(predicate)
@bridge.bridge_command(aliases=["sing"])
async def s(self, ctx, *, song: Optional[str] = None) -> None:
2025-02-13 14:51:35 -05:00
"""
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:
2025-02-15 08:36:45 -05:00
activity = _activity
2025-04-17 14:35:56 -04:00
2025-02-13 14:51:35 -05:00
if not activity:
2025-04-17 14:35:56 -04:00
return await ctx.respond(
"**Error**: No song specified, no activity found to read."
)
2025-06-08 08:53:27 -04:00
2025-10-08 15:45:47 -04:00
# Initial response that we'll edit later
message = await ctx.respond("*Searching for lyrics...*")
2025-04-17 14:35:56 -04:00
2025-02-15 08:36:45 -05:00
parsed = self.utility.parse_song_input(song, activity)
2025-03-01 07:56:47 -05:00
2025-02-15 08:36:45 -05:00
if isinstance(parsed, tuple):
(search_artist, search_song, search_subsearch) = parsed
2025-10-08 15:45:47 -04:00
# Update the message to show what we're searching for
await message.edit(content=f"*Searching for '{search_song}' by {search_artist}...*")
2025-04-17 14:35:56 -04:00
search_result: Optional[list] = await self.utility.lyric_search(
2025-10-08 15:45:47 -04:00
search_artist, search_song, search_subsearch, is_spam_channel=(ctx.channel.id in BOT_CHANIDS)
2025-04-17 14:35:56 -04:00
)
2025-02-24 06:21:56 -05:00
if not search_result:
await ctx.respond("ERR: No search result.")
return
2025-04-17 14:35:56 -04:00
2025-02-13 14:51:35 -05:00
if len(search_result) == 1:
# Error response from API
error, *_ = search_result[0]
return await ctx.respond(error)
2025-02-15 08:36:45 -05:00
if not isinstance(search_result[0], tuple):
2025-04-17 14:35:56 -04:00
return # Invalid data type
2025-02-15 08:36:45 -05:00
(
2025-04-17 14:35:56 -04:00
search_result_artist,
search_result_song,
search_result_src,
search_result_confidence,
search_result_time_taken,
2025-05-15 15:49:28 -04:00
) = search_result[0] # First index is a tuple
2025-04-17 14:35:56 -04:00
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
2025-10-08 15:45:47 -04:00
# 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
2025-04-17 14:35:56 -04:00
2025-10-08 15:45:47 -04:00
# 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)
2025-02-13 14:51:35 -05:00
except Exception as e:
traceback.print_exc()
2025-02-24 06:21:56 -05:00
await ctx.respond(f"ERR: {str(e)}")
2025-04-17 14:35:56 -04:00
2025-02-13 14:51:35 -05:00
@commands.user_command(name="Sing")
async def sing_context_menu(self, ctx, member: discord.Member) -> None:
"""
Sing Context Menu Command
2025-04-17 14:35:56 -04:00
2025-02-13 14:51:35 -05:00
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
2025-04-17 14:35:56 -04:00
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,
)
2025-05-15 15:49:28 -04:00
member_id: int = member.id # if not(member.id == PODY_ID) else 1234134345497837679 # Use Thomas for Pody!
2025-02-13 14:51:35 -05:00
activity: Optional[discord.Activity] = None
2025-10-08 15:45:47 -04:00
# Initial response that we'll edit later
message = await ctx.respond(f"*Reading activity of {member_display}...*" if IS_SPAMCHAN else "*Processing...*")
2025-02-13 14:51:35 -05:00
for _activity in ctx.interaction.guild.get_member(member_id).activities:
if _activity.type == discord.ActivityType.listening:
2025-02-15 08:36:45 -05:00
activity = _activity
2025-10-08 15:45:47 -04:00
2025-04-17 14:35:56 -04:00
parsed: Union[tuple, bool] = self.utility.parse_song_input(
song=None, activity=activity
)
2025-02-13 14:51:35 -05:00
if not parsed:
2025-10-08 15:45:47 -04:00
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
)
2025-04-17 14:35:56 -04:00
2025-02-15 08:36:45 -05:00
if isinstance(parsed, tuple):
(search_artist, search_song, search_subsearch) = parsed
2025-10-08 15:45:47 -04:00
await message.edit(content=f"*Searching for '{search_song}' by {search_artist}...*")
2025-04-17 14:35:56 -04:00
search_result: Optional[list] = await self.utility.lyric_search(
2025-10-08 15:45:47 -04:00
search_artist, search_song, search_subsearch, is_spam_channel=(ctx.channel.id in BOT_CHANIDS)
2025-04-17 14:35:56 -04:00
)
2025-02-24 06:21:56 -05:00
if not search_result:
await ctx.respond("ERR: No search result")
return
2025-04-17 14:35:56 -04:00
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,
2025-05-15 15:49:28 -04:00
) = search_result[0] # First index is a tuple
2025-04-17 14:35:56 -04:00
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
2025-10-08 15:45:47 -04:00
# 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}`"
2025-02-15 08:36:45 -05:00
2025-10-08 15:45:47 -04:00
# 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)
2025-02-13 14:51:35 -05:00
except Exception as e:
traceback.print_exc()
return await ctx.respond(f"ERR: {str(e)}")
2025-04-17 14:35:56 -04:00
2025-02-13 14:51:35 -05:00
def setup(bot) -> None:
"""Run on Cog Load"""
2025-10-08 15:45:47 -04:00
load_pagination_data() # Load existing pagination data from file
2025-02-13 14:51:35 -05:00
bot.add_cog(Sing(bot))