feat: Implement timer functionality with slash command support and persistent storage
This commit is contained in:
parent
c8870c6b7f
commit
64ce0f629f
@ -1,34 +1,292 @@
|
||||
import discord
|
||||
from discord.ext import commands, tasks
|
||||
from discord import app_commands
|
||||
from discord import app_commands, ui
|
||||
import datetime
|
||||
import asyncio
|
||||
import random
|
||||
import re # For parsing duration
|
||||
import json
|
||||
import os
|
||||
|
||||
GIVEAWAY_DATA_FILE = "data/giveaways.json"
|
||||
DATA_DIR = "data"
|
||||
|
||||
# --- Helper Functions ---
|
||||
def is_user_nitro_like(user: discord.User | discord.Member) -> bool:
|
||||
"""Checks if a user has an animated avatar or a banner, indicating Nitro."""
|
||||
if isinstance(user, discord.Member): # Member object has guild-specific avatar
|
||||
# Check guild avatar first, then global avatar
|
||||
if user.guild_avatar and user.guild_avatar.is_animated():
|
||||
return True
|
||||
if user.avatar and user.avatar.is_animated():
|
||||
return True
|
||||
elif user.avatar and user.avatar.is_animated(): # User object
|
||||
return True
|
||||
return user.banner is not None
|
||||
|
||||
|
||||
# --- UI Views and Buttons ---
|
||||
class GiveawayEnterButton(ui.Button['GiveawayEnterView']):
|
||||
def __init__(self, cog_ref):
|
||||
super().__init__(label="Enter Giveaway", style=discord.ButtonStyle.green, custom_id="giveaway_enter_button")
|
||||
self.cog: GiveawaysCog = cog_ref # Store a reference to the cog
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
giveaway = self.cog._get_giveaway_by_message_id(interaction.message.id)
|
||||
if not giveaway or giveaway.get("ended", False):
|
||||
await interaction.response.send_message("This giveaway has ended or is no longer active.", ephemeral=True)
|
||||
# Optionally disable the button on the message if possible
|
||||
self.disabled = True
|
||||
await interaction.message.edit(view=self.view)
|
||||
return
|
||||
|
||||
if giveaway["is_nitro_giveaway"]:
|
||||
if not is_user_nitro_like(interaction.user):
|
||||
await interaction.response.send_message(
|
||||
"This is a Nitro-exclusive giveaway. You don't appear to have Nitro (animated avatar or banner).",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
if interaction.user.id in giveaway["participants"]:
|
||||
await interaction.response.send_message("You have already entered this giveaway!", ephemeral=True)
|
||||
else:
|
||||
giveaway["participants"].add(interaction.user.id)
|
||||
self.cog.save_giveaways() # Save after participant update
|
||||
await interaction.response.send_message("You have successfully entered the giveaway!", ephemeral=True)
|
||||
|
||||
# Update participant count in embed if desired (optional)
|
||||
# embed = interaction.message.embeds[0]
|
||||
# embed.set_field_at(embed.fields.index(...) or create new field, name="Participants", value=str(len(giveaway["participants"])))
|
||||
# await interaction.message.edit(embed=embed)
|
||||
|
||||
|
||||
class GiveawayEnterView(ui.View):
|
||||
def __init__(self, cog: 'GiveawaysCog', timeout=None): # Timeout=None for persistent
|
||||
super().__init__(timeout=timeout)
|
||||
self.cog = cog
|
||||
self.add_item(GiveawayEnterButton(cog_ref=self.cog))
|
||||
|
||||
class GiveawayRerollButton(ui.Button['GiveawayEndView']):
|
||||
def __init__(self, cog_ref, original_giveaway_message_id: int):
|
||||
super().__init__(label="Reroll Winner", style=discord.ButtonStyle.blurple, custom_id=f"giveaway_reroll_button:{original_giveaway_message_id}")
|
||||
self.cog: GiveawaysCog = cog_ref
|
||||
self.original_giveaway_message_id = original_giveaway_message_id
|
||||
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
# For reroll, we need to find the *original* giveaway data, which might not be in active_giveaways anymore.
|
||||
# We'll need to load it from the JSON file or have a separate store for ended giveaways.
|
||||
# For simplicity now, let's assume we can find it or it's passed appropriately.
|
||||
|
||||
# This custom_id parsing is a common pattern for persistent buttons with dynamic data.
|
||||
# msg_id_str = interaction.data["custom_id"].split(":")[1]
|
||||
# original_msg_id = int(msg_id_str)
|
||||
|
||||
# Find the giveaway data (this might need adjustment based on how ended giveaways are stored)
|
||||
# We'll search all giveaways loaded, including those marked "ended"
|
||||
giveaway_data = self.cog._get_giveaway_by_message_id(self.original_giveaway_message_id, search_all=True)
|
||||
|
||||
if not giveaway_data:
|
||||
await interaction.response.send_message("Could not find the data for this giveaway to reroll.", ephemeral=True)
|
||||
return
|
||||
|
||||
if not interaction.user.guild_permissions.manage_guild:
|
||||
await interaction.response.send_message("You don't have permission to reroll winners.", ephemeral=True)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True) # Acknowledge
|
||||
|
||||
participants_ids = list(giveaway_data.get("participants", []))
|
||||
if not participants_ids:
|
||||
await interaction.followup.send("There were no participants in this giveaway to reroll from.", ephemeral=True)
|
||||
return
|
||||
|
||||
# Fetch user objects for participants
|
||||
entrants_users = []
|
||||
for user_id in participants_ids:
|
||||
user = interaction.guild.get_member(user_id)
|
||||
if not user: # Try fetching if not in cache or left server
|
||||
try:
|
||||
user = await self.cog.bot.fetch_user(user_id)
|
||||
except discord.NotFound:
|
||||
continue # Skip if user cannot be found
|
||||
if user and not user.bot:
|
||||
# Apply Nitro check again if it was a nitro giveaway
|
||||
if giveaway_data.get("is_nitro_giveaway", False) and not is_user_nitro_like(user):
|
||||
continue
|
||||
entrants_users.append(user)
|
||||
|
||||
if not entrants_users:
|
||||
await interaction.followup.send("No eligible participants found for a reroll (e.g., after Nitro check or if users left).", ephemeral=True)
|
||||
return
|
||||
|
||||
num_winners = giveaway_data.get("num_winners", 1)
|
||||
new_winners_list = []
|
||||
if len(entrants_users) <= num_winners:
|
||||
new_winners_list = list(entrants_users)
|
||||
else:
|
||||
new_winners_list = random.sample(entrants_users, num_winners)
|
||||
|
||||
if new_winners_list:
|
||||
winner_mentions = ", ".join(w.mention for w in new_winners_list)
|
||||
# Announce in the original giveaway channel
|
||||
original_channel = self.cog.bot.get_channel(giveaway_data["channel_id"])
|
||||
if original_channel:
|
||||
await original_channel.send(f"🔄 Reroll for **{giveaway_data['prize']}**! Congratulations {winner_mentions}, you are the new winner(s)!")
|
||||
await interaction.followup.send(f"Reroll successful. New winner(s) announced in {original_channel.mention}.", ephemeral=True)
|
||||
else:
|
||||
await interaction.followup.send("Reroll successful, but I couldn't find the original channel to announce.", ephemeral=True)
|
||||
else:
|
||||
await interaction.followup.send("Could not select any new winners in the reroll.", ephemeral=True)
|
||||
|
||||
|
||||
class GiveawayEndView(ui.View):
|
||||
def __init__(self, cog: 'GiveawaysCog', original_giveaway_message_id: int, timeout=None): # Timeout=None for persistent
|
||||
super().__init__(timeout=timeout)
|
||||
self.cog = cog
|
||||
self.add_item(GiveawayRerollButton(cog_ref=self.cog, original_giveaway_message_id=original_giveaway_message_id))
|
||||
|
||||
|
||||
class GiveawaysCog(commands.Cog, name="Giveaways"):
|
||||
"""Cog for managing giveaways"""
|
||||
|
||||
gway = app_commands.Group(name="gway", description="Giveaway related commands")
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.active_giveaways = [] # List to store active giveaway details
|
||||
# Each entry could be a dict:
|
||||
self.active_giveaways = []
|
||||
self.all_loaded_giveaways = [] # To keep ended ones for reroll lookup
|
||||
# Structure:
|
||||
# {
|
||||
# "message_id": int,
|
||||
# "channel_id": int,
|
||||
# "guild_id": int,
|
||||
# "prize": str,
|
||||
# "end_time": datetime.datetime,
|
||||
# "end_time": datetime.datetime, # Stored as ISO string in JSON
|
||||
# "num_winners": int,
|
||||
# "reaction_emoji": str, # e.g., "🎉"
|
||||
# "creator_id": int,
|
||||
# "participants": set() # Store user_ids of participants
|
||||
# "participants": set(), # Store user_ids. Stored as list in JSON.
|
||||
# "is_nitro_giveaway": bool,
|
||||
# "ended": bool
|
||||
# }
|
||||
self._ensure_data_dir_exists()
|
||||
self.load_giveaways()
|
||||
self.check_giveaways_loop.start()
|
||||
# Persistent views are added in setup_hook
|
||||
|
||||
async def cog_load(self): # Changed from setup_hook to cog_load for better timing with bot ready
|
||||
await self.bot.wait_until_ready()
|
||||
print("Re-adding persistent giveaway views...")
|
||||
temp_loaded_giveaways = [] # Use a temporary list for loading
|
||||
try:
|
||||
with open(GIVEAWAY_DATA_FILE, 'r') as f:
|
||||
giveaways_data_for_views = json.load(f)
|
||||
for gw_data in giveaways_data_for_views:
|
||||
# We only need to re-add views for messages that should have them
|
||||
is_ended = gw_data.get("ended", False)
|
||||
# Check if end_time is in the past if "ended" flag isn't perfectly reliable
|
||||
end_time_dt = datetime.datetime.fromisoformat(gw_data["end_time"])
|
||||
if not is_ended and end_time_dt > datetime.datetime.now(datetime.timezone.utc):
|
||||
# Active giveaway, re-add EnterView
|
||||
self.bot.add_view(GiveawayEnterView(cog=self), message_id=gw_data["message_id"])
|
||||
elif is_ended or end_time_dt <= datetime.datetime.now(datetime.timezone.utc):
|
||||
# Ended giveaway, re-add EndView (with Reroll button)
|
||||
self.bot.add_view(GiveawayEndView(cog=self, original_giveaway_message_id=gw_data["message_id"]), message_id=gw_data["message_id"])
|
||||
temp_loaded_giveaways.append(gw_data) # Keep track for _get_giveaway_by_message_id
|
||||
print(f"Attempted to re-add views for {len(temp_loaded_giveaways)} giveaways.")
|
||||
except FileNotFoundError:
|
||||
print("No giveaway data file found, skipping view re-adding.")
|
||||
except Exception as e:
|
||||
print(f"Error re-adding persistent views: {e}")
|
||||
|
||||
|
||||
def _ensure_data_dir_exists(self):
|
||||
if not os.path.exists(DATA_DIR):
|
||||
os.makedirs(DATA_DIR)
|
||||
|
||||
def cog_unload(self):
|
||||
self.check_giveaways_loop.cancel()
|
||||
|
||||
def load_giveaways(self):
|
||||
self.active_giveaways = []
|
||||
self.all_loaded_giveaways = []
|
||||
try:
|
||||
with open(GIVEAWAY_DATA_FILE, 'r') as f:
|
||||
giveaways_data = json.load(f)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
for gw_data in giveaways_data:
|
||||
gw_data["end_time"] = datetime.datetime.fromisoformat(gw_data["end_time"])
|
||||
gw_data["participants"] = set(gw_data.get("participants", []))
|
||||
gw_data.setdefault("is_nitro_giveaway", False)
|
||||
gw_data.setdefault("ended", gw_data["end_time"] <= now) # Set ended if time has passed
|
||||
|
||||
self.all_loaded_giveaways.append(gw_data.copy()) # Store all for reroll lookup
|
||||
|
||||
if not gw_data["ended"]:
|
||||
self.active_giveaways.append(gw_data)
|
||||
|
||||
print(f"Loaded {len(self.all_loaded_giveaways)} total giveaways ({len(self.active_giveaways)} active).")
|
||||
except FileNotFoundError:
|
||||
print("Giveaway data file not found. Starting with no active giveaways.")
|
||||
except json.JSONDecodeError:
|
||||
print("Error decoding giveaway data file. Starting with no active giveaways.")
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred loading giveaways: {e}")
|
||||
|
||||
def save_giveaways(self):
|
||||
# Save all giveaways (from self.all_loaded_giveaways or by merging active and ended)
|
||||
# This ensures that "ended" status and participant lists are preserved.
|
||||
try:
|
||||
# Create a unified list to save, ensuring all giveaways are present
|
||||
# and active_giveaways reflects the most current state for those not yet ended.
|
||||
|
||||
# Create a dictionary of active giveaways by message_id for quick updates
|
||||
active_map = {gw['message_id']: gw for gw in self.active_giveaways}
|
||||
|
||||
giveaways_to_save = []
|
||||
# Iterate through all_loaded_giveaways to maintain the full history
|
||||
for gw_hist in self.all_loaded_giveaways:
|
||||
# If this giveaway is also in active_map, it means it's still active
|
||||
# or just ended in the current session. Use the version from active_map
|
||||
# as it might have newer participant data before being marked ended.
|
||||
if gw_hist['message_id'] in active_map:
|
||||
current_version = active_map[gw_hist['message_id']]
|
||||
saved_gw = current_version.copy()
|
||||
else: # It's an older, ended giveaway not in the current active list
|
||||
saved_gw = gw_hist.copy()
|
||||
|
||||
saved_gw["end_time"] = saved_gw["end_time"].isoformat()
|
||||
saved_gw["participants"] = list(saved_gw["participants"])
|
||||
giveaways_to_save.append(saved_gw)
|
||||
|
||||
# Add any brand new giveaways from self.active_giveaways not yet in self.all_loaded_giveaways
|
||||
# This case should ideally be handled by adding to both lists upon creation.
|
||||
# For robustness:
|
||||
all_saved_ids = {gw['message_id'] for gw in giveaways_to_save}
|
||||
for gw_active in self.active_giveaways:
|
||||
if gw_active['message_id'] not in all_saved_ids:
|
||||
new_gw_to_save = gw_active.copy()
|
||||
new_gw_to_save["end_time"] = new_gw_to_save["end_time"].isoformat()
|
||||
new_gw_to_save["participants"] = list(new_gw_to_save["participants"])
|
||||
giveaways_to_save.append(new_gw_to_save)
|
||||
# Also add to all_loaded_giveaways for next time
|
||||
self.all_loaded_giveaways.append(gw_active.copy())
|
||||
|
||||
|
||||
with open(GIVEAWAY_DATA_FILE, 'w') as f:
|
||||
json.dump(giveaways_to_save, f, indent=4)
|
||||
# print(f"Saved {len(giveaways_to_save)} giveaways to disk.")
|
||||
except Exception as e:
|
||||
print(f"Error saving giveaways: {e}")
|
||||
|
||||
def _get_giveaway_by_message_id(self, message_id: int, search_all: bool = False):
|
||||
"""Helper to find a giveaway by its message ID."""
|
||||
source_list = self.all_loaded_giveaways if search_all else self.active_giveaways
|
||||
for giveaway in source_list:
|
||||
if giveaway["message_id"] == message_id:
|
||||
return giveaway
|
||||
return None
|
||||
|
||||
def parse_duration(self, duration_str: str) -> datetime.timedelta | None:
|
||||
"""Parses a duration string (e.g., "1d", "3h", "30m", "1w") into a timedelta."""
|
||||
match = re.fullmatch(r"(\d+)([smhdw])", duration_str.lower())
|
||||
@ -49,15 +307,16 @@ class GiveawaysCog(commands.Cog, name="Giveaways"):
|
||||
return datetime.timedelta(weeks=value)
|
||||
return None
|
||||
|
||||
@app_commands.command(name="gcreate", description="Create a new giveaway.")
|
||||
@gway.command(name="create", description="Create a new giveaway.")
|
||||
@app_commands.describe(
|
||||
prize="What is the prize?",
|
||||
duration="How long should the giveaway last? (e.g., 10m, 1h, 2d, 1w)",
|
||||
winners="How many winners? (default: 1)"
|
||||
winners="How many winners? (default: 1)",
|
||||
nitro_giveaway="Is this a Nitro-only giveaway? (checks for animated avatar/banner)"
|
||||
)
|
||||
@app_commands.checks.has_permissions(manage_guild=True) # Example permission
|
||||
async def create_giveaway_slash(self, interaction: discord.Interaction, prize: str, duration: str, winners: int = 1):
|
||||
"""Slash command to create a giveaway."""
|
||||
@app_commands.checks.has_permissions(manage_guild=True)
|
||||
async def create_giveaway_slash(self, interaction: discord.Interaction, prize: str, duration: str, winners: int = 1, nitro_giveaway: bool = False):
|
||||
"""Slash command to create a giveaway using buttons."""
|
||||
parsed_duration = self.parse_duration(duration)
|
||||
if not parsed_duration:
|
||||
await interaction.response.send_message(
|
||||
@ -71,26 +330,22 @@ class GiveawaysCog(commands.Cog, name="Giveaways"):
|
||||
return
|
||||
|
||||
end_time = datetime.datetime.now(datetime.timezone.utc) + parsed_duration
|
||||
reaction_emoji = "🎉"
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"🎉 Giveaway: {prize} 🎉",
|
||||
description=f"React with {reaction_emoji} to enter!\n"
|
||||
description=f"Click the button below to enter!\n"
|
||||
f"Ends: {discord.utils.format_dt(end_time, style='R')} ({discord.utils.format_dt(end_time, style='F')})\n"
|
||||
f"Winners: {winners}",
|
||||
color=discord.Color.gold()
|
||||
)
|
||||
embed.set_footer(text=f"Giveaway started by {interaction.user.display_name}")
|
||||
if nitro_giveaway:
|
||||
embed.description += "\n*This is a Nitro-exclusive giveaway!*"
|
||||
embed.set_footer(text=f"Giveaway started by {interaction.user.display_name}. Entries: 0") # Initial entry count
|
||||
|
||||
# Send the message and get the message object
|
||||
# We need to use follow up if we responded ephemerally before, but here we send a new message.
|
||||
# If interaction.response.is_done() is false, we can use send_message.
|
||||
# Otherwise, we must use followup.send.
|
||||
# For simplicity, let's assume we always send a new message for the giveaway.
|
||||
await interaction.response.send_message("Creating giveaway...", ephemeral=True)
|
||||
|
||||
await interaction.response.send_message("Creating giveaway...", ephemeral=True) # Acknowledge interaction
|
||||
giveaway_message = await interaction.channel.send(embed=embed)
|
||||
await giveaway_message.add_reaction(reaction_emoji)
|
||||
view = GiveawayEnterView(cog=self)
|
||||
giveaway_message = await interaction.channel.send(embed=embed, view=view)
|
||||
|
||||
giveaway_data = {
|
||||
"message_id": giveaway_message.id,
|
||||
@ -99,124 +354,111 @@ class GiveawaysCog(commands.Cog, name="Giveaways"):
|
||||
"prize": prize,
|
||||
"end_time": end_time,
|
||||
"num_winners": winners,
|
||||
"reaction_emoji": reaction_emoji,
|
||||
"creator_id": interaction.user.id,
|
||||
"participants": set() # Will be populated by on_raw_reaction_add
|
||||
"participants": set(),
|
||||
"is_nitro_giveaway": nitro_giveaway,
|
||||
"ended": False
|
||||
}
|
||||
self.active_giveaways.append(giveaway_data)
|
||||
self.all_loaded_giveaways.append(giveaway_data.copy()) # Also add to the comprehensive list
|
||||
self.save_giveaways()
|
||||
|
||||
await interaction.followup.send(f"Giveaway for '{prize}' created successfully!", ephemeral=True)
|
||||
|
||||
|
||||
@tasks.loop(seconds=30) # Check every 30 seconds
|
||||
@tasks.loop(seconds=30)
|
||||
async def check_giveaways_loop(self):
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
ended_giveaways_indices = []
|
||||
giveaways_processed_in_this_run = False
|
||||
|
||||
for i, giveaway in enumerate(self.active_giveaways):
|
||||
if now >= giveaway["end_time"]:
|
||||
ended_giveaways_indices.append(i)
|
||||
|
||||
channel = self.bot.get_channel(giveaway["channel_id"])
|
||||
if not channel:
|
||||
print(f"Error: Could not find channel {giveaway['channel_id']} for giveaway {giveaway['message_id']}")
|
||||
continue # Or remove from active_giveaways if channel is permanently gone
|
||||
# Iterate over a copy of active_giveaways for safe removal/modification
|
||||
for giveaway_data in list(self.active_giveaways):
|
||||
if giveaway_data["ended"] or now < giveaway_data["end_time"]:
|
||||
continue # Skip already ended or not yet due
|
||||
|
||||
try:
|
||||
message = await channel.fetch_message(giveaway["message_id"])
|
||||
except discord.NotFound:
|
||||
print(f"Error: Could not find message {giveaway['message_id']} in channel {channel.id}")
|
||||
# Giveaway message was deleted, consider it ended/cancelled.
|
||||
continue
|
||||
except discord.Forbidden:
|
||||
print(f"Error: Bot lacks permissions to fetch message {giveaway['message_id']} in channel {channel.id}")
|
||||
continue
|
||||
giveaways_processed_in_this_run = True
|
||||
giveaway_data["ended"] = True # Mark as ended
|
||||
|
||||
channel = self.bot.get_channel(giveaway_data["channel_id"])
|
||||
if not channel:
|
||||
print(f"Error: Could not find channel {giveaway_data['channel_id']} for giveaway {giveaway_data['message_id']}")
|
||||
# Remove from active_giveaways directly as it can't be processed
|
||||
self.active_giveaways = [gw for gw in self.active_giveaways if gw["message_id"] != giveaway_data["message_id"]]
|
||||
continue
|
||||
|
||||
# Fetch users who reacted
|
||||
entrants = set()
|
||||
for reaction in message.reactions:
|
||||
if str(reaction.emoji) == giveaway["reaction_emoji"]:
|
||||
async for user in reaction.users():
|
||||
if not user.bot: # Don't include bots
|
||||
entrants.add(user)
|
||||
break
|
||||
|
||||
winners_list = []
|
||||
if entrants:
|
||||
if len(entrants) <= giveaway["num_winners"]:
|
||||
winners_list = list(entrants)
|
||||
else:
|
||||
winners_list = random.sample(list(entrants), giveaway["num_winners"])
|
||||
try:
|
||||
message = await channel.fetch_message(giveaway_data["message_id"])
|
||||
except discord.NotFound:
|
||||
print(f"Error: Could not find message {giveaway_data['message_id']} in channel {channel.id}")
|
||||
self.active_giveaways = [gw for gw in self.active_giveaways if gw["message_id"] != giveaway_data["message_id"]]
|
||||
continue
|
||||
except discord.Forbidden:
|
||||
print(f"Error: Bot lacks permissions to fetch message {giveaway_data['message_id']} in channel {channel.id}")
|
||||
# Cannot process, but keep it in active_giveaways for now, maybe perms will be fixed.
|
||||
# Or decide to remove it. For now, skip.
|
||||
continue
|
||||
|
||||
# Fetch participants from the giveaway data
|
||||
entrants_users = []
|
||||
for user_id in giveaway_data["participants"]:
|
||||
# Ensure user is still in the guild for Nitro check if applicable
|
||||
member = channel.guild.get_member(user_id) # Use guild from channel
|
||||
user_to_check = member if member else await self.bot.fetch_user(user_id)
|
||||
|
||||
# Announce winners
|
||||
if winners_list:
|
||||
winner_mentions = ", ".join(w.mention for w in winners_list)
|
||||
await channel.send(f"Congratulations {winner_mentions}! You won **{giveaway['prize']}**!")
|
||||
if not user_to_check: continue # User not found
|
||||
|
||||
if user_to_check.bot: continue
|
||||
|
||||
if giveaway_data["is_nitro_giveaway"] and not is_user_nitro_like(user_to_check):
|
||||
continue # Skip non-nitro users for nitro giveaways
|
||||
entrants_users.append(user_to_check)
|
||||
|
||||
winners_list = []
|
||||
if entrants_users:
|
||||
if len(entrants_users) <= giveaway_data["num_winners"]:
|
||||
winners_list = list(entrants_users)
|
||||
else:
|
||||
await channel.send(f"The giveaway for **{giveaway['prize']}** has ended, but there were no eligible participants.")
|
||||
winners_list = random.sample(entrants_users, giveaway_data["num_winners"])
|
||||
|
||||
# Update original giveaway message
|
||||
new_embed = message.embeds[0]
|
||||
new_embed.description = f"Giveaway ended!\nWinners: {', '.join(w.mention for w in winners_list) if winners_list else 'None'}"
|
||||
new_embed.color = discord.Color.dark_grey()
|
||||
new_embed.set_footer(text="Giveaway has concluded.")
|
||||
try:
|
||||
await message.edit(embed=new_embed)
|
||||
await message.clear_reactions() # Optional: clear reactions
|
||||
except discord.Forbidden:
|
||||
print(f"Error: Bot lacks permissions to edit message or clear reactions for {giveaway['message_id']}")
|
||||
except discord.HTTPException as e:
|
||||
print(f"Error editing giveaway message {giveaway['message_id']}: {e}")
|
||||
winner_mentions_str = ", ".join(w.mention for w in winners_list) if winners_list else 'None'
|
||||
|
||||
if winners_list:
|
||||
await channel.send(f"Congratulations {winner_mentions_str}! You won **{giveaway_data['prize']}**!")
|
||||
else:
|
||||
await channel.send(f"The giveaway for **{giveaway_data['prize']}** has ended, but there were no eligible participants.")
|
||||
|
||||
new_embed = message.embeds[0]
|
||||
new_embed.description = f"Giveaway ended!\nWinners: {winner_mentions_str}"
|
||||
new_embed.color = discord.Color.dark_grey()
|
||||
new_embed.set_footer(text="Giveaway has concluded.")
|
||||
|
||||
end_view = GiveawayEndView(cog=self, original_giveaway_message_id=giveaway_data["message_id"])
|
||||
|
||||
# Remove ended giveaways from active list (iterate in reverse to avoid index issues)
|
||||
for i in sorted(ended_giveaways_indices, reverse=True):
|
||||
del self.active_giveaways[i]
|
||||
try:
|
||||
await message.edit(embed=new_embed, view=end_view)
|
||||
except discord.Forbidden:
|
||||
print(f"Error: Bot lacks permissions to edit message for {giveaway_data['message_id']}")
|
||||
except discord.HTTPException as e:
|
||||
print(f"Error editing giveaway message {giveaway_data['message_id']}: {e}")
|
||||
|
||||
# Remove from active_giveaways after processing
|
||||
self.active_giveaways = [gw for gw in self.active_giveaways if gw["message_id"] != giveaway_data["message_id"]]
|
||||
|
||||
if giveaways_processed_in_this_run:
|
||||
self.save_giveaways()
|
||||
|
||||
@check_giveaways_loop.before_loop
|
||||
async def before_check_giveaways_loop(self):
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
|
||||
# This listener is basic and doesn't store participants in self.active_giveaways yet.
|
||||
# For a full implementation, we'd find the giveaway by message_id and add payload.user_id.
|
||||
# This is also where you might check if the user is eligible (e.g. not a bot, specific roles).
|
||||
if payload.user_id == self.bot.user.id:
|
||||
return
|
||||
|
||||
for giveaway in self.active_giveaways:
|
||||
if payload.message_id == giveaway["message_id"] and str(payload.emoji) == giveaway["reaction_emoji"]:
|
||||
# Here you could add payload.user_id to giveaway["participants"] if you want to track them
|
||||
# For this version, the winner selection fetches all reactors at the end.
|
||||
# print(f"User {payload.user_id} reacted to giveaway {giveaway['message_id']}")
|
||||
break
|
||||
|
||||
# Placeholder for other commands like !greroll, !gend, !glist
|
||||
# @app_commands.command(name="greroll", description="Reroll a winner for a giveaway.")
|
||||
# @app_commands.checks.has_permissions(manage_guild=True)
|
||||
# async def reroll_giveaway_slash(self, interaction: discord.Interaction, message_id: str):
|
||||
# pass
|
||||
|
||||
# @app_commands.command(name="gend", description="End a giveaway immediately.")
|
||||
# @app_commands.checks.has_permissions(manage_guild=True)
|
||||
# async def end_giveaway_slash(self, interaction: discord.Interaction, message_id: str):
|
||||
# pass
|
||||
|
||||
# @app_commands.command(name="glist", description="List active giveaways.")
|
||||
# async def list_giveaways_slash(self, interaction: discord.Interaction):
|
||||
# pass
|
||||
|
||||
@app_commands.command(name="grollmanual", description="Manually roll a winner from reactions on a specific message.")
|
||||
@gway.command(name="rollmanual", description="Manually roll a winner from a message (for old giveaways or specific cases).")
|
||||
@app_commands.describe(
|
||||
message_id="The ID of the message to get reactions from.",
|
||||
message_id="The ID of the message (giveaway or any message with reactions).",
|
||||
winners="How many winners to pick? (default: 1)",
|
||||
emoji="Which emoji should be considered for entry? (default: 🎉)"
|
||||
emoji="Emoji for reaction-based roll (if not a button giveaway, default: 🎉)"
|
||||
)
|
||||
@app_commands.checks.has_permissions(manage_guild=True)
|
||||
async def manual_roll_giveaway_slash(self, interaction: discord.Interaction, message_id: str, winners: int = 1, emoji: str = "🎉"):
|
||||
"""Manually picks winner(s) from reactions on a given message."""
|
||||
if winners < 1:
|
||||
await interaction.response.send_message("Number of winners must be at least 1.", ephemeral=True)
|
||||
return
|
||||
@ -227,78 +469,78 @@ class GiveawaysCog(commands.Cog, name="Giveaways"):
|
||||
await interaction.response.send_message("Invalid Message ID format. It should be a number.", ephemeral=True)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True) # Acknowledge interaction
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
# Try to find if this message_id corresponds to a known giveaway
|
||||
giveaway_info = self._get_giveaway_by_message_id(msg_id, search_all=True)
|
||||
entrants = set() # Store user objects
|
||||
|
||||
message_to_roll = None
|
||||
try:
|
||||
# Try to fetch the message from the current channel first, then any channel in the guild
|
||||
message_to_roll = None
|
||||
try:
|
||||
message_to_roll = await interaction.channel.fetch_message(msg_id)
|
||||
except discord.NotFound:
|
||||
# If not in current channel, search all text channels in the guild
|
||||
for channel in interaction.guild.text_channels:
|
||||
try:
|
||||
message_to_roll = await channel.fetch_message(msg_id)
|
||||
if message_to_roll:
|
||||
break # Found the message
|
||||
except discord.NotFound:
|
||||
continue # Not in this channel
|
||||
except discord.Forbidden:
|
||||
await interaction.followup.send(f"I don't have permissions to read messages in {channel.mention}. Cannot fetch message {msg_id}.", ephemeral=True)
|
||||
return
|
||||
|
||||
if not message_to_roll:
|
||||
await interaction.followup.send(f"Could not find message with ID `{msg_id}` in this server.", ephemeral=True)
|
||||
message_to_roll = await interaction.channel.fetch_message(msg_id)
|
||||
except (discord.NotFound, discord.Forbidden):
|
||||
# Try searching all channels if not found or no perms in current
|
||||
for chan in interaction.guild.text_channels:
|
||||
try:
|
||||
message_to_roll = await chan.fetch_message(msg_id)
|
||||
if message_to_roll: break
|
||||
except (discord.NotFound, discord.Forbidden):
|
||||
continue
|
||||
|
||||
if not message_to_roll:
|
||||
await interaction.followup.send(f"Could not find message with ID `{msg_id}` in this server or I lack permissions.", ephemeral=True)
|
||||
return
|
||||
|
||||
if giveaway_info and "participants" in giveaway_info:
|
||||
# Use stored participants if available (from button-based system)
|
||||
for user_id in giveaway_info["participants"]:
|
||||
user = interaction.guild.get_member(user_id) or await self.bot.fetch_user(user_id)
|
||||
if user and not user.bot:
|
||||
if giveaway_info.get("is_nitro_giveaway", False) and not is_user_nitro_like(user):
|
||||
continue
|
||||
entrants.add(user)
|
||||
if not entrants:
|
||||
await interaction.followup.send(f"Found giveaway data for message `{msg_id}`, but no eligible stored participants.", ephemeral=True)
|
||||
return
|
||||
else:
|
||||
# Fallback to reactions if no participant data or not a known giveaway
|
||||
reaction_found = False
|
||||
for reaction in message_to_roll.reactions:
|
||||
if str(reaction.emoji) == emoji:
|
||||
reaction_found = True
|
||||
async for user in reaction.users():
|
||||
if not user.bot:
|
||||
# For manual reaction roll, we might not know if it was nitro_giveaway
|
||||
# Consider adding a parameter to manual_roll for this if needed
|
||||
entrants.add(user)
|
||||
break
|
||||
if not reaction_found:
|
||||
await interaction.followup.send(f"No reactions found with {emoji} on message `{msg_id}`.", ephemeral=True)
|
||||
return
|
||||
if not entrants:
|
||||
await interaction.followup.send(f"No valid (non-bot) users reacted with {emoji} on message `{msg_id}`.", ephemeral=True)
|
||||
return
|
||||
|
||||
except discord.Forbidden:
|
||||
await interaction.followup.send(f"I don't have permissions to read message history in {interaction.channel.mention} to find message `{msg_id}`.", ephemeral=True)
|
||||
return
|
||||
except Exception as e:
|
||||
await interaction.followup.send(f"An unexpected error occurred while fetching the message: {e}", ephemeral=True)
|
||||
return
|
||||
|
||||
entrants = set()
|
||||
reaction_found = False
|
||||
for reaction in message_to_roll.reactions:
|
||||
if str(reaction.emoji) == emoji:
|
||||
reaction_found = True
|
||||
async for user in reaction.users():
|
||||
if not user.bot:
|
||||
entrants.add(user)
|
||||
break
|
||||
|
||||
if not reaction_found:
|
||||
await interaction.followup.send(f"No reactions found with the emoji {emoji} on message `{msg_id}`.", ephemeral=True)
|
||||
return
|
||||
|
||||
if not entrants:
|
||||
await interaction.followup.send(f"No valid (non-bot) users reacted with {emoji} on message `{msg_id}`.", ephemeral=True)
|
||||
return
|
||||
|
||||
winners_list = []
|
||||
if len(entrants) <= winners:
|
||||
winners_list = list(entrants)
|
||||
entrants_list = list(entrants)
|
||||
if len(entrants_list) <= winners:
|
||||
winners_list = entrants_list
|
||||
else:
|
||||
winners_list = random.sample(list(entrants), winners)
|
||||
winners_list = random.sample(entrants_list, winners)
|
||||
|
||||
if winners_list:
|
||||
winner_mentions = ", ".join(w.mention for w in winners_list)
|
||||
# Announce in the channel where command was used, not necessarily message_to_roll.channel
|
||||
await interaction.followup.send(f"Congratulations {winner_mentions}! You've been manually selected as winner(s) from message `{msg_id}` in {message_to_roll.channel.mention}!", ephemeral=False)
|
||||
|
||||
# Optionally, also send to the original message's channel if different and bot has perms
|
||||
await interaction.followup.send(f"Manual roll from message `{msg_id}` in {message_to_roll.channel.mention}:\nCongratulations {winner_mentions}!", ephemeral=False)
|
||||
if interaction.channel.id != message_to_roll.channel.id:
|
||||
try:
|
||||
await message_to_roll.channel.send(f"Manual roll for message {message_to_roll.jump_url} concluded. Winner(s): {winner_mentions}")
|
||||
except discord.Forbidden:
|
||||
await interaction.followup.send(f"(Note: I couldn't announce the winner in {message_to_roll.channel.mention} due to missing permissions there.)", ephemeral=True)
|
||||
except discord.HTTPException:
|
||||
await interaction.followup.send(f"(Note: An error occurred trying to announce the winner in {message_to_roll.channel.mention}.)", ephemeral=True)
|
||||
|
||||
else: # Should not happen if entrants is not empty, but as a safeguard
|
||||
await interaction.followup.send(f"Could not select any winners from the reactions on message `{msg_id}`.", ephemeral=True)
|
||||
await interaction.followup.send(f"(Note: I couldn't announce the winner in {message_to_roll.channel.mention}.)", ephemeral=True)
|
||||
else:
|
||||
await interaction.followup.send(f"Could not select any winners from message `{msg_id}`.", ephemeral=True)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(GiveawaysCog(bot))
|
||||
cog = GiveawaysCog(bot)
|
||||
await bot.add_cog(cog)
|
||||
# The cog_load method will handle re-adding views once the bot is ready.
|
||||
|
150
cogs/timer_cog.py
Normal file
150
cogs/timer_cog.py
Normal file
@ -0,0 +1,150 @@
|
||||
import discord
|
||||
from discord.ext import commands, tasks
|
||||
from discord import app_commands
|
||||
import json
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
|
||||
TIMER_FILE = 'data/timers.json'
|
||||
|
||||
class TimerCog(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.timers = []
|
||||
self.load_timers()
|
||||
self.timer_check_task.start()
|
||||
|
||||
def load_timers(self):
|
||||
if not os.path.exists('data'):
|
||||
os.makedirs('data')
|
||||
if os.path.exists(TIMER_FILE):
|
||||
with open(TIMER_FILE, 'r') as f:
|
||||
try:
|
||||
data = json.load(f)
|
||||
# Convert string timestamps back to datetime objects
|
||||
for timer_data in data:
|
||||
timer_data['expires_at'] = datetime.fromisoformat(timer_data['expires_at'])
|
||||
self.timers.append(timer_data)
|
||||
except json.JSONDecodeError:
|
||||
self.timers = []
|
||||
else:
|
||||
self.timers = []
|
||||
print(f"Loaded {len(self.timers)} timers.")
|
||||
|
||||
def save_timers(self):
|
||||
# Convert datetime objects to ISO format strings for JSON serialization
|
||||
serializable_timers = []
|
||||
for timer in self.timers:
|
||||
timer_copy = timer.copy()
|
||||
timer_copy['expires_at'] = timer_copy['expires_at'].isoformat()
|
||||
serializable_timers.append(timer_copy)
|
||||
|
||||
with open(TIMER_FILE, 'w') as f:
|
||||
json.dump(serializable_timers, f, indent=4)
|
||||
print(f"Saved {len(self.timers)} timers.")
|
||||
|
||||
@tasks.loop(seconds=10) # Check every 10 seconds
|
||||
async def timer_check_task(self):
|
||||
now = datetime.now()
|
||||
expired_timers = []
|
||||
for timer in self.timers:
|
||||
if timer['expires_at'] <= now:
|
||||
expired_timers.append(timer)
|
||||
|
||||
for timer in expired_timers:
|
||||
self.timers.remove(timer)
|
||||
try:
|
||||
channel = self.bot.get_channel(timer['channel_id'])
|
||||
if channel:
|
||||
user = self.bot.get_user(timer['user_id'])
|
||||
if user:
|
||||
message_content = f"{user.mention}, your timer for '{timer['message']}' has expired!"
|
||||
if timer.get('ephemeral', True): # Default to True if not specified
|
||||
# Ephemeral messages require interaction context, which we don't have here.
|
||||
# For now, we'll send non-ephemeral if it was originally ephemeral.
|
||||
# A better solution would be to store interaction context or use webhooks.
|
||||
await channel.send(message_content)
|
||||
else:
|
||||
await channel.send(message_content)
|
||||
else:
|
||||
print(f"Could not find user {timer['user_id']} for timer.")
|
||||
else:
|
||||
print(f"Could not find channel {timer['channel_id']} for timer.")
|
||||
except Exception as e:
|
||||
print(f"Error sending timer message: {e}")
|
||||
|
||||
if expired_timers:
|
||||
self.save_timers()
|
||||
|
||||
@timer_check_task.before_loop
|
||||
async def before_timer_check_task(self):
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
@app_commands.command(name="timer", description="Sets a timer, reminder, or alarm.")
|
||||
@app_commands.describe(
|
||||
time_str="Duration for the timer (e.g., 1h30m, 5m, 2d). Supports s, m, h, d.",
|
||||
message="The message for your reminder.",
|
||||
ephemeral="Whether the response should only be visible to you (defaults to True)."
|
||||
)
|
||||
async def timer_slash(self, interaction: discord.Interaction, time_str: str, message: str = "a reminder", ephemeral: bool = True):
|
||||
"""
|
||||
Sets a timer, reminder, or alarm as a slash command.
|
||||
Usage: /timer time_str:1h30m message:Your reminder message ephemeral:False
|
||||
Supports: s (seconds), m (minutes), h (hours), d (days)
|
||||
"""
|
||||
duration_seconds = 0
|
||||
time_str = time_str.lower()
|
||||
|
||||
# Parse time string (e.g., 1h30m, 5m, 2d)
|
||||
current_num = ""
|
||||
for char in time_str:
|
||||
if char.isdigit():
|
||||
current_num += char
|
||||
else:
|
||||
if current_num:
|
||||
num = int(current_num)
|
||||
if char == 's':
|
||||
duration_seconds += num
|
||||
elif char == 'm':
|
||||
duration_seconds += num * 60
|
||||
elif char == 'h':
|
||||
duration_seconds += num * 60 * 60
|
||||
elif char == 'd':
|
||||
duration_seconds += num * 60 * 60 * 24
|
||||
else:
|
||||
await interaction.response.send_message("Invalid time unit. Use s, m, h, or d.", ephemeral=ephemeral)
|
||||
return
|
||||
current_num = ""
|
||||
else:
|
||||
await interaction.response.send_message("Invalid time format. Example: `1h30m` or `5m`.", ephemeral=ephemeral)
|
||||
return
|
||||
|
||||
if current_num: # Handle cases like "30s" without a unit at the end
|
||||
await interaction.response.send_message("Invalid time format. Please specify a unit (s, m, h, d) for all numbers.", ephemeral=ephemeral)
|
||||
return
|
||||
|
||||
if duration_seconds <= 0:
|
||||
await interaction.response.send_message("Duration must be a positive value.", ephemeral=ephemeral)
|
||||
return
|
||||
|
||||
expires_at = datetime.now() + timedelta(seconds=duration_seconds)
|
||||
|
||||
timer_data = {
|
||||
'user_id': interaction.user.id,
|
||||
'channel_id': interaction.channel_id,
|
||||
'message': message,
|
||||
'expires_at': expires_at,
|
||||
'ephemeral': ephemeral
|
||||
}
|
||||
self.timers.append(timer_data)
|
||||
self.save_timers()
|
||||
|
||||
await interaction.response.send_message(f"Timer set for {timedelta(seconds=duration_seconds)} from now for '{message}'.", ephemeral=ephemeral)
|
||||
|
||||
def cog_unload(self):
|
||||
self.timer_check_task.cancel()
|
||||
self.save_timers() # Ensure timers are saved on unload
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(TimerCog(bot))
|
Loading…
x
Reference in New Issue
Block a user