# app.py - Hugging Face Spaces Docker + Persistent Storage Edition import asyncio import json import logging import os import random import re import signal import sqlite3 import sys import time from contextlib import asynccontextmanager from datetime import timedelta from pathlib import Path from typing import Optional import aiosqlite import discord from discord import app_commands from discord.ext import commands, tasks # ============================================================ # CONFIGURATION (Reads from HF Space Environment Variables) # ============================================================ # Persistent storage path provided by Hugging Face Spaces STORAGE_PATH = Path(os.environ.get("HF_PERSISTENT_STORAGE", "/data")) DB_PATH = str(STORAGE_PATH / "database.db") BACKUP_PATH = str(STORAGE_PATH / "backup.db") TOKEN = os.environ.get("DISCORD_TOKEN", "") def _parse_id_set(env_var: str) -> set[int]: """Parse comma-separated IDs from environment variable.""" raw = os.environ.get(env_var, "") if not raw: return set() ids = set() for part in raw.split(","): part = part.strip() if part.isdigit(): ids.add(int(part)) return ids OWNER_USER_IDS = _parse_id_set("OWNER_USER_IDS") ALLOWED_GUILD_IDS = _parse_id_set("ALLOWED_GUILD_IDS") BUTTON_REFRESH_SECONDS = 1.0 GIVEAWAY_DESCRIPTION = ( "You must follow the rules to be eligible to win.\n" "You can't be rewarded if you complete the requested requirements after the giveaway ends.\n" "Joining multiple times or abusing the bot may disqualify you." ) logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", stream=sys.stdout, ) log = logging.getLogger("giveaway-bot") # ============================================================ # STARTUP VALIDATION # ============================================================ def validate_environment(): """Validate all required configuration before starting.""" errors = [] if not TOKEN: errors.append( "DISCORD_TOKEN is not set. " "Add it as a secret in your HF Space settings." ) if not OWNER_USER_IDS: errors.append( "OWNER_USER_IDS is not set or empty. " "Add it as a secret in your HF Space settings " "(comma-separated Discord user IDs)." ) if not STORAGE_PATH.exists(): errors.append( f"Persistent storage path '{STORAGE_PATH}' does not exist. " "Make sure Persistent Storage is enabled in your HF Space settings." ) elif not os.access(STORAGE_PATH, os.W_OK): errors.append( f"Persistent storage path '{STORAGE_PATH}' is not writable. " "Check file permissions on your HF Space volume." ) if errors: for err in errors: log.critical(err) raise SystemExit(1) log.info("Environment validated successfully.") log.info(" Storage path : %s", STORAGE_PATH) log.info(" DB path : %s", DB_PATH) log.info(" Backup path : %s", BACKUP_PATH) log.info(" Owner UIDs : %s", OWNER_USER_IDS) log.info(" Allowed Guilds: %s", ALLOWED_GUILD_IDS or "(owner-only)") # ============================================================ # UTILS # ============================================================ def utc_now() -> float: return time.time() def parse_duration(raw: str) -> timedelta: text = raw.strip().lower() if not re.fullmatch(r"(?:\s*\d+[smhd]\s*)+", text): raise ValueError("Use a duration like 30s, 10m, 2h, 1d, or 1h30m.") total_seconds = 0 for value, unit in re.findall(r"(\d+)([smhd])", text): value = int(value) if unit == "s": total_seconds += value elif unit == "m": total_seconds += value * 60 elif unit == "h": total_seconds += value * 3600 elif unit == "d": total_seconds += value * 86400 if total_seconds <= 0: raise ValueError("Duration must be greater than zero.") return timedelta(seconds=total_seconds) def parse_message_id(raw: str) -> int: raw = raw.strip() if raw.isdigit(): return int(raw) match = re.search(r"\/(\d+)\/(\d+)\/(\d+)", raw) if match: return int(match.group(3)) raise ValueError("Provide a valid message ID or message link.") def safe_json_list(value: Optional[str]) -> list: if not value: return [] try: data = json.loads(value) return data if isinstance(data, list) else [] except Exception: return [] def format_user_list(user_ids: list, max_shown: int = 20) -> str: if not user_ids: return "No winners." mentions = [] for uid in user_ids[:max_shown]: try: mentions.append(f"<@{int(uid)}>") except Exception: continue if len(user_ids) > max_shown: mentions.append(f"... and {len(user_ids) - max_shown} more") return ", ".join(mentions) def error_embed(text: str) -> discord.Embed: return discord.Embed(description=f"❌ {text}", color=discord.Color.red()) def success_embed(text: str) -> discord.Embed: return discord.Embed(description=f"✅ {text}", color=discord.Color.green()) async def send_interaction_message( interaction: discord.Interaction, embed: discord.Embed, *, ephemeral: bool = True, ): try: if interaction.response.is_done(): await interaction.followup.send(embed=embed, ephemeral=ephemeral) else: await interaction.response.send_message(embed=embed, ephemeral=ephemeral) except Exception: pass # ============================================================ # DATABASE (Fixed async context manager pattern) # ============================================================ SCHEMA = """ CREATE TABLE IF NOT EXISTS giveaways ( id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id INTEGER NOT NULL, channel_id INTEGER NOT NULL, message_id INTEGER UNIQUE, title TEXT NOT NULL, winner_count INTEGER NOT NULL CHECK (winner_count > 0), ends_at REAL NOT NULL, required_roles TEXT NOT NULL DEFAULT '[]', creator_id INTEGER NOT NULL, created_at REAL NOT NULL, ended INTEGER NOT NULL DEFAULT 0, participant_count INTEGER NOT NULL DEFAULT 0, winners TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS entries ( giveaway_id INTEGER NOT NULL REFERENCES giveaways(id) ON DELETE CASCADE, user_id INTEGER NOT NULL, joined_at REAL NOT NULL, PRIMARY KEY (giveaway_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_entries_giveaway ON entries(giveaway_id); CREATE INDEX IF NOT EXISTS idx_giveaways_active ON giveaways(ended, ends_at); CREATE INDEX IF NOT EXISTS idx_giveaways_message ON giveaways(message_id); """ class Database: def __init__(self): self._write_lock = asyncio.Lock() self._restore_lock = asyncio.Lock() @asynccontextmanager async def _connect(self, path: Optional[str] = None): conn = await aiosqlite.connect(path or DB_PATH, timeout=30) try: await conn.execute("PRAGMA journal_mode=WAL;") await conn.execute("PRAGMA synchronous=NORMAL;") await conn.execute("PRAGMA foreign_keys=ON;") await conn.execute("PRAGMA busy_timeout=10000;") conn.row_factory = sqlite3.Row yield conn finally: await conn.close() def _integrity_check(self, path: str) -> bool: conn = None try: conn = sqlite3.connect(path) cursor = conn.execute("PRAGMA integrity_check;") result = cursor.fetchone() return bool(result and str(result[0]).lower() == "ok") except Exception: return False finally: if conn: try: conn.close() except Exception: pass def _sqlite_backup(self, source_path: str, destination_path: str): source = sqlite3.connect(source_path) destination = sqlite3.connect(destination_path) try: source.backup(destination) finally: try: destination.close() except Exception: pass try: source.close() except Exception: pass def _remove_db_files(self): for suffix in ("", "-wal", "-shm"): path = DB_PATH + suffix try: os.remove(path) except FileNotFoundError: pass except Exception: log.exception("Failed removing database file: %s", path) async def initialize(self): if not os.path.exists(DB_PATH): if os.path.exists(BACKUP_PATH) and await asyncio.to_thread( self._integrity_check, BACKUP_PATH ): log.info("database.db missing. Restoring from backup.db.") await asyncio.to_thread(self._sqlite_backup, BACKUP_PATH, DB_PATH) else: log.info("Creating fresh database.db.") self._remove_db_files() async with self._connect(): pass else: if not await asyncio.to_thread(self._integrity_check, DB_PATH): await self._handle_corruption() await self._create_schema() await self.backup_now() log.info("Database initialized successfully.") async def _handle_corruption(self): log.error("database.db failed integrity check. Attempting recovery.") stamp = int(time.time()) if os.path.exists(DB_PATH): try: os.replace(DB_PATH, str(STORAGE_PATH / f"corrupt_database_{stamp}.db")) except Exception: self._remove_db_files() for suffix in ("-wal", "-shm"): try: os.remove(DB_PATH + suffix) except FileNotFoundError: pass except Exception: pass if os.path.exists(BACKUP_PATH) and await asyncio.to_thread( self._integrity_check, BACKUP_PATH ): log.info("Restoring database.db from backup.db.") await asyncio.to_thread(self._sqlite_backup, BACKUP_PATH, DB_PATH) else: log.warning("No valid backup.db found. Creating fresh database.db.") async with self._connect(): pass async def _create_schema(self): async with self._write_lock: async with self._connect() as conn: await conn.executescript(SCHEMA) await conn.commit() async def backup_now(self) -> bool: try: if not os.path.exists(DB_PATH): return False if not await asyncio.to_thread(self._integrity_check, DB_PATH): log.warning("Skipping backup: database.db failed integrity check.") return False await asyncio.to_thread(self._sqlite_backup, DB_PATH, BACKUP_PATH) return True except Exception: log.exception("Backup failed.") return False async def fetch_one(self, query: str, params: tuple = ()) -> Optional[dict]: async with self._connect() as conn: cursor = await conn.execute(query, params) row = await cursor.fetchone() return dict(row) if row else None async def fetch_all(self, query: str, params: tuple = ()) -> list[dict]: async with self._connect() as conn: cursor = await conn.execute(query, params) rows = await cursor.fetchall() return [dict(row) for row in rows] async def create_giveaway( self, guild_id: int, channel_id: int, title: str, winner_count: int, ends_at: float, required_roles: list[int], creator_id: int, ) -> int: async with self._write_lock: async with self._connect() as conn: cursor = await conn.execute( """INSERT INTO giveaways ( guild_id, channel_id, message_id, title, winner_count, ends_at, required_roles, creator_id, created_at, ended, participant_count, winners ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, 0, 0, '[]')""", (guild_id, channel_id, title, winner_count, ends_at, json.dumps(required_roles), creator_id, utc_now()), ) await conn.commit() return int(cursor.lastrowid) async def delete_giveaway(self, giveaway_id: int): async with self._write_lock: async with self._connect() as conn: await conn.execute("DELETE FROM giveaways WHERE id = ?", (giveaway_id,)) await conn.commit() async def bind_message(self, giveaway_id: int, message_id: int): async with self._write_lock: async with self._connect() as conn: await conn.execute( "UPDATE giveaways SET message_id = ? WHERE id = ?", (message_id, giveaway_id), ) await conn.commit() async def get_giveaway(self, giveaway_id: int) -> Optional[dict]: return await self.fetch_one("SELECT * FROM giveaways WHERE id = ?", (giveaway_id,)) async def get_giveaway_by_message(self, message_id: int) -> Optional[dict]: return await self.fetch_one("SELECT * FROM giveaways WHERE message_id = ?", (message_id,)) async def add_entry(self, giveaway_id: int, user_id: int) -> tuple[bool, int, str]: async with self._write_lock: async with self._connect() as conn: cursor = await conn.execute( "SELECT ended, ends_at, participant_count FROM giveaways WHERE id = ?", (giveaway_id,), ) row = await cursor.fetchone() if not row: return False, 0, "missing" ended = bool(row["ended"]) ends_at = float(row["ends_at"]) participant_count = int(row["participant_count"]) if ended or ends_at <= utc_now(): return False, participant_count, "ended" cursor = await conn.execute( "INSERT OR IGNORE INTO entries (giveaway_id, user_id, joined_at) VALUES (?, ?, ?)", (giveaway_id, user_id, utc_now()), ) if cursor.rowcount == 0: await conn.rollback() return False, participant_count, "already" await conn.execute( "UPDATE giveaways SET participant_count = participant_count + 1 WHERE id = ?", (giveaway_id,), ) await conn.commit() return True, participant_count + 1, "ok" async def get_due_active_ids(self) -> list[int]: rows = await self.fetch_all( "SELECT id FROM giveaways WHERE ended = 0 AND ends_at <= ?", (utc_now(),) ) return [int(row["id"]) for row in rows] async def active_giveaways_for_views(self) -> list[dict]: return await self.fetch_all( "SELECT id, participant_count FROM giveaways WHERE ended = 0 AND ends_at > ?", (utc_now(),), ) async def _random_winners_conn( self, conn: aiosqlite.Connection, giveaway_id: int, limit: int, exclude_user_ids: list[int], ) -> list[int]: limit = int(limit) if limit <= 0: return [] exclude_user_ids = list({int(u) for u in exclude_user_ids if u}) if not exclude_user_ids: cursor = await conn.execute( "SELECT user_id FROM entries WHERE giveaway_id = ? ORDER BY RANDOM() LIMIT ?", (giveaway_id, limit), ) else: await conn.execute( "CREATE TEMP TABLE IF NOT EXISTS temp_excluded_winners (user_id INTEGER PRIMARY KEY)" ) await conn.execute("DELETE FROM temp_excluded_winners") await conn.executemany( "INSERT OR IGNORE INTO temp_excluded_winners (user_id) VALUES (?)", [(u,) for u in exclude_user_ids], ) cursor = await conn.execute( """SELECT e.user_id FROM entries AS e WHERE e.giveaway_id = ? AND NOT EXISTS (SELECT 1 FROM temp_excluded_winners AS x WHERE x.user_id = e.user_id) ORDER BY RANDOM() LIMIT ?""", (giveaway_id, limit), ) rows = await cursor.fetchall() return [int(row["user_id"]) for row in rows] async def end_giveaway_and_choose_winners(self, giveaway_id: int) -> Optional[dict]: async with self._write_lock: async with self._connect() as conn: cursor = await conn.execute("SELECT * FROM giveaways WHERE id = ?", (giveaway_id,)) row = await cursor.fetchone() if not row: return None giveaway = dict(row) if giveaway["ended"]: return {"already": True, "giveaway": giveaway} existing = safe_json_list(giveaway.get("winners")) winners = await self._random_winners_conn(conn, giveaway_id, giveaway["winner_count"], existing) await conn.execute( "UPDATE giveaways SET ended = 1, winners = ? WHERE id = ?", (json.dumps(winners), giveaway_id), ) await conn.commit() giveaway["ended"] = 1 giveaway["winners"] = json.dumps(winners) return {"already": False, "giveaway": giveaway, "winners": winners} async def reroll_giveaway(self, giveaway_id: int) -> Optional[dict]: async with self._write_lock: async with self._connect() as conn: cursor = await conn.execute("SELECT * FROM giveaways WHERE id = ?", (giveaway_id,)) row = await cursor.fetchone() if not row: return None giveaway = dict(row) if not giveaway["ended"]: return {"not_ended": True, "giveaway": giveaway} existing = safe_json_list(giveaway.get("winners")) new_winners = await self._random_winners_conn(conn, giveaway_id, giveaway["winner_count"], existing) if not new_winners: return {"not_ended": False, "giveaway": giveaway, "new_winners": [], "all_winners": existing} combined = list(existing) for w in new_winners: if w not in combined: combined.append(w) await conn.execute( "UPDATE giveaways SET winners = ? WHERE id = ?", (json.dumps(combined), giveaway_id), ) await conn.commit() giveaway["winners"] = json.dumps(combined) return {"not_ended": False, "giveaway": giveaway, "new_winners": new_winners, "all_winners": combined} # ============================================================ # BOT SETUP # ============================================================ intents = discord.Intents.default() intents.members = True db = Database() class GiveawayBot(commands.Bot): def __init__(self): super().__init__(command_prefix="!", intents=intents, help_command=None) async def setup_hook(self): await db.initialize() bot = GiveawayBot() def guild_is_allowed(guild: Optional[discord.Guild]) -> bool: if not guild: return False if guild.id in ALLOWED_GUILD_IDS: return True return guild.owner_id in OWNER_USER_IDS def owner_only(): async def predicate(interaction: discord.Interaction) -> bool: if interaction.guild is None: raise app_commands.CheckFailure("This command can only be used in a server.") if interaction.user.id not in OWNER_USER_IDS: raise app_commands.CheckFailure("This command is private.") if not guild_is_allowed(interaction.guild): raise app_commands.CheckFailure("This bot is not authorized in this server.") return True return app_commands.check(predicate) # ============================================================ # EMBED BUILDING # ============================================================ def build_giveaway_embed(giveaway: dict, *, ended: bool = False) -> discord.Embed: embed = discord.Embed(title=giveaway["title"], description=GIVEAWAY_DESCRIPTION, color=discord.Color.blurple()) ends_at = int(giveaway["ends_at"]) if ended: embed.add_field(name="Status", value=f"Ended ", inline=False) else: embed.add_field(name="Ends", value=f"", inline=False) roles = safe_json_list(giveaway.get("required_roles")) if roles: embed.add_field(name="Required role(s)", value=" ".join(f"<@&{r}>" for r in roles), inline=False) embed.add_field(name="Winners", value=str(giveaway["winner_count"]), inline=True) embed.add_field(name="Entries", value=str(giveaway["participant_count"]), inline=True) if ended: winners = safe_json_list(giveaway.get("winners")) embed.add_field(name="Winner(s)", value=format_user_list(winners) if winners else "No eligible entries.", inline=False) embed.set_footer(text="Join once. You cannot unjoin. You must hold required role(s) when joining.") return embed # ============================================================ # VIEWS / BUTTONS # ============================================================ class GiveawayView(discord.ui.View): def __init__(self, giveaway_id: int, count: int = 0): super().__init__(timeout=None) self.giveaway_id = int(giveaway_id) button = discord.ui.Button( label=f"{count} | Join", style=discord.ButtonStyle.success, custom_id=f"giveaway_join:{self.giveaway_id}", emoji="🎉", ) button.callback = self.join_button self.add_item(button) async def join_button(self, interaction: discord.Interaction): try: if interaction.guild is None: await send_interaction_message(interaction, error_embed("This button only works inside a server.")) return if interaction.message is None: await send_interaction_message(interaction, error_embed("This giveaway message is invalid.")) return giveaway = await db.get_giveaway(self.giveaway_id) if not giveaway: await send_interaction_message(interaction, error_embed("This giveaway no longer exists.")) return if giveaway.get("message_id") != interaction.message.id: await send_interaction_message(interaction, error_embed("This giveaway card is no longer valid.")) return if giveaway["ended"] or giveaway["ends_at"] <= utc_now(): await send_interaction_message(interaction, error_embed("This giveaway has ended.")) return member = interaction.user if not isinstance(member, discord.Member): await send_interaction_message(interaction, error_embed("Could not verify your server roles.")) return required_roles = safe_json_list(giveaway.get("required_roles")) if required_roles: member_role_ids = {role.id for role in member.roles} missing = [r for r in required_roles if r not in member_role_ids] if missing: await send_interaction_message( interaction, error_embed(f"You need these role(s) to join:\n{' '.join(f'<@&{r}>' for r in missing)}"), ) return inserted, _, status = await db.add_entry(self.giveaway_id, member.id) if status == "missing": await send_interaction_message(interaction, error_embed("This giveaway no longer exists.")) elif status == "ended": await send_interaction_message(interaction, error_embed("This giveaway has ended.")) elif status == "already": await send_interaction_message(interaction, error_embed("You already joined this giveaway.")) else: await send_interaction_message(interaction, success_embed("You joined the giveaway!")) await queue_button_refresh(interaction.client, self.giveaway_id) except Exception: log.exception("Button join failed.") await send_interaction_message(interaction, error_embed("Something went wrong while joining.")) class EndedGiveawayView(discord.ui.View): def __init__(self, giveaway_id: int, count: int = 0): super().__init__(timeout=None) self.add_item(discord.ui.Button( label=f"{count} | Ended", style=discord.ButtonStyle.secondary, custom_id=f"giveaway_join:{int(giveaway_id)}", disabled=True, emoji="🔒", )) ACTIVE_VIEWS: dict[int, GiveawayView] = {} def register_giveaway_view(client: discord.Client, giveaway_id: int, count: int) -> GiveawayView: view = ACTIVE_VIEWS.get(giveaway_id) if view is None: view = GiveawayView(giveaway_id, count) ACTIVE_VIEWS[giveaway_id] = view client.add_view(view) else: if view.children: view.children[0].label = f"{count} | Join" return view # ============================================================ # CHANNEL / MESSAGE HELPERS # ============================================================ async def get_channel_safe(guild: discord.Guild, channel_id: int): if guild is None: return None channel = None if hasattr(guild, "get_channel_or_thread"): channel = guild.get_channel_or_thread(channel_id) else: channel = guild.get_channel(channel_id) if channel: return channel try: return await guild.fetch_channel(channel_id) except Exception: return None async def safe_fetch_message(client: discord.Client, giveaway: dict): guild = client.get_guild(giveaway["guild_id"]) if not guild: return None channel = await get_channel_safe(guild, giveaway["channel_id"]) if not channel: return None try: return await channel.fetch_message(giveaway["message_id"]) except Exception: return None # ============================================================ # BUTTON REFRESH / DEBOUNCE # ============================================================ _button_last_update: dict[int, float] = {} _button_update_tasks: dict[int, asyncio.Task] = {} async def refresh_button_message(client: discord.Client, giveaway_id: int): try: giveaway = await db.get_giveaway(giveaway_id) if not giveaway or giveaway["ended"]: return message = await safe_fetch_message(client, giveaway) if not message: return view = register_giveaway_view(client, giveaway_id, giveaway["participant_count"]) await message.edit(view=view) except Exception: log.exception("Failed refreshing giveaway button.") async def queue_button_refresh(client: discord.Client, giveaway_id: int): now = time.monotonic() last = _button_last_update.get(giveaway_id, 0.0) if now - last >= BUTTON_REFRESH_SECONDS: _button_last_update[giveaway_id] = now await refresh_button_message(client, giveaway_id) return if giveaway_id in _button_update_tasks: return delay = BUTTON_REFRESH_SECONDS - (now - last) async def delayed_refresh(): await asyncio.sleep(delay) _button_update_tasks.pop(giveaway_id, None) _button_last_update[giveaway_id] = time.monotonic() await refresh_button_message(client, giveaway_id) _button_update_tasks[giveaway_id] = asyncio.create_task(delayed_refresh()) # ============================================================ # ANNOUNCE / FINALIZE # ============================================================ async def announce_winners(giveaway: dict, winners: list[int], *, title: str): guild = bot.get_guild(giveaway["guild_id"]) if not guild: return channel = await get_channel_safe(guild, giveaway["channel_id"]) if not channel: return if winners: all_winners = safe_json_list(giveaway.get("winners")) content = ( f"🎉 **{title}**: {giveaway['title']}\n" f"Winner(s): {format_user_list(winners)}\n" f"Total winner(s) recorded: {len(all_winners)}" ) else: content = f"🎉 **{title}**: {giveaway['title']}\nNo eligible entries were available." try: await channel.send(content) except Exception: log.exception("Failed sending winner announcement.") async def refresh_message_ended(giveaway_id: int): try: giveaway = await db.get_giveaway(giveaway_id) if not giveaway: return message = await safe_fetch_message(bot, giveaway) if not message: return embed = build_giveaway_embed(giveaway, ended=True) view = EndedGiveawayView(giveaway_id, giveaway["participant_count"]) ACTIVE_VIEWS.pop(giveaway_id, None) await message.edit(embed=embed, view=view) except Exception: log.exception("Failed updating ended giveaway message.") async def process_end(giveaway_id: int) -> Optional[dict]: result = await db.end_giveaway_and_choose_winners(giveaway_id) if not result or result.get("already"): return result giveaway = await db.get_giveaway(giveaway_id) if not giveaway: return result guild = bot.get_guild(giveaway["guild_id"]) if guild and guild_is_allowed(guild): await announce_winners(giveaway, result["winners"], title="Giveaway ended") await refresh_message_ended(giveaway_id) return result async def process_reroll(giveaway_id: int) -> Optional[dict]: result = await db.reroll_giveaway(giveaway_id) if not result or result.get("not_ended"): return result giveaway = await db.get_giveaway(giveaway_id) if giveaway: await announce_winners(giveaway, result["new_winners"], title="Giveaway reroll") await refresh_message_ended(giveaway_id) return result # ============================================================ # SLASH COMMANDS # ============================================================ @bot.tree.command(name="giveaway", description="Create a giveaway. Owner UID only.") @app_commands.guild_only() @owner_only() @app_commands.describe( name="Giveaway title", amount="Number of winners", duration="Duration: 30s, 10m, 2h, 1d, 1h30m", role1="Optional required role 1", role2="Optional required role 2", role3="Optional required role 3", role4="Optional required role 4", role5="Optional required role 5", ) @app_commands.rename(duration="time") async def giveaway_cmd( interaction: discord.Interaction, name: app_commands.Range[str, 1, 200], amount: app_commands.Range[int, 1, 100], duration: str, role1: Optional[discord.Role] = None, role2: Optional[discord.Role] = None, role3: Optional[discord.Role] = None, role4: Optional[discord.Role] = None, role5: Optional[discord.Role] = None, ): await interaction.response.defer(ephemeral=True, thinking=True) channel = interaction.channel if not isinstance(channel, discord.abc.Messageable): await interaction.followup.send(embed=error_embed("Giveaways can only be created in a text channel."), ephemeral=True) return me = interaction.guild.me if hasattr(channel, "permissions_for"): perms = channel.permissions_for(me) if not (perms.view_channel and perms.send_messages and perms.embed_links): await interaction.followup.send( embed=error_embed("I need View Channel, Send Messages, and Embed Links in this channel."), ephemeral=True ) return try: parsed_duration = parse_duration(duration) except ValueError as e: await interaction.followup.send(embed=error_embed(str(e)), ephemeral=True) return role_ids = [] for role in (role1, role2, role3, role4, role5): if role and role.id not in role_ids: role_ids.append(role.id) ends_at = utc_now() + parsed_duration.total_seconds() giveaway_id = await db.create_giveaway( guild_id=interaction.guild.id, channel_id=channel.id, title=name, winner_count=amount, ends_at=ends_at, required_roles=role_ids, creator_id=interaction.user.id, ) view = register_giveaway_view(bot, giveaway_id, 0) temp = { "title": name, "ends_at": ends_at, "winner_count": amount, "participant_count": 0, "required_roles": json.dumps(role_ids), "winners": "[]", } embed = build_giveaway_embed(temp, ended=False) try: message = await channel.send(embed=embed, view=view) except Exception: await db.delete_giveaway(giveaway_id) log.exception("Failed sending giveaway message.") await interaction.followup.send(embed=error_embed("I could not send the giveaway message."), ephemeral=True) return await db.bind_message(giveaway_id, message.id) await interaction.followup.send(embed=success_embed(f"Giveaway created: {message.jump_url}"), ephemeral=True) @bot.tree.command(name="end", description="End a giveaway now. Owner UID only.") @app_commands.guild_only() @owner_only() @app_commands.describe(id="Giveaway message ID or message link") async def end_cmd(interaction: discord.Interaction, id: str): await interaction.response.defer(ephemeral=True, thinking=True) try: message_id = parse_message_id(id) except ValueError as e: await interaction.followup.send(embed=error_embed(str(e)), ephemeral=True) return giveaway = await db.get_giveaway_by_message(message_id) if not giveaway or giveaway["guild_id"] != interaction.guild.id: await interaction.followup.send(embed=error_embed("No giveaway found for that message in this server."), ephemeral=True) return if giveaway["ended"]: await interaction.followup.send(embed=error_embed("That giveaway has already ended."), ephemeral=True) return result = await process_end(giveaway["id"]) if not result: await interaction.followup.send(embed=error_embed("Could not end that giveaway."), ephemeral=True) return if result.get("already"): await interaction.followup.send(embed=error_embed("That giveaway has already ended."), ephemeral=True) return await interaction.followup.send( embed=success_embed(f"Giveaway ended.\nWinner(s): {format_user_list(result.get('winners', []))}"), ephemeral=True ) @bot.tree.command(name="reroll", description="Reroll a finished giveaway. Owner UID only.") @app_commands.guild_only() @owner_only() @app_commands.describe(id="Giveaway message ID or message link") async def reroll_cmd(interaction: discord.Interaction, id: str): await interaction.response.defer(ephemeral=True, thinking=True) try: message_id = parse_message_id(id) except ValueError as e: await interaction.followup.send(embed=error_embed(str(e)), ephemeral=True) return giveaway = await db.get_giveaway_by_message(message_id) if not giveaway or giveaway["guild_id"] != interaction.guild.id: await interaction.followup.send(embed=error_embed("No giveaway found for that message in this server."), ephemeral=True) return if not giveaway["ended"]: await interaction.followup.send(embed=error_embed("End the giveaway first before rerolling."), ephemeral=True) return result = await process_reroll(giveaway["id"]) if not result: await interaction.followup.send(embed=error_embed("Could not reroll that giveaway."), ephemeral=True) return if result.get("not_ended"): await interaction.followup.send(embed=error_embed("End the giveaway first before rerolling."), ephemeral=True) return new_winners = result.get("new_winners", []) if not new_winners: await interaction.followup.send( embed=error_embed("No new eligible entries were available for reroll.\nPrevious winners are excluded."), ephemeral=True ) return await interaction.followup.send( embed=success_embed(f"Reroll complete.\nNew winner(s): {format_user_list(new_winners)}"), ephemeral=True ) # Hide commands from non-owners as much as Discord allows for _cmd_name in ("giveaway", "end", "reroll"): _cmd = bot.tree.get_command(_cmd_name) if _cmd is not None: _cmd.default_member_permissions = discord.Permissions.none() # ============================================================ # ERROR HANDLER # ============================================================ @bot.tree.error async def app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError): if isinstance(error, app_commands.CheckFailure): msg = "You are not authorized to use this command." elif isinstance(error, app_commands.CommandInvokeError): msg = str(error.original) if error.original else "Unexpected command error." else: msg = str(error) await send_interaction_message(interaction, error_embed(msg)) # ============================================================ # PRIVATE SYNC / GUILD ENFORCEMENT # ============================================================ async def enforce_allowed_guilds(): for guild in list(bot.guilds): if not guild_is_allowed(guild): log.info("Leaving unauthorized guild: %s (%s)", guild.name, guild.id) try: await guild.leave() except Exception: log.exception("Failed leaving unauthorized guild: %s", guild.id) async def load_active_views(): rows = await db.active_giveaways_for_views() for row in rows: register_giveaway_view(bot, int(row["id"]), int(row["participant_count"])) async def sync_private_commands(): allowed_guilds = [g for g in bot.guilds if guild_is_allowed(g)] for guild in allowed_guilds: try: bot.tree.copy_global_to(guild=guild) except Exception: log.exception("Failed copying commands to guild %s", guild.id) bot.tree.clear_commands(guild=None) try: await bot.tree.sync(guild=None) except Exception: log.exception("Failed clearing global commands.") for guild in allowed_guilds: try: synced = await bot.tree.sync(guild=guild) permissions = {} for cmd in synced: permissions[cmd.id] = [ app_commands.AppCommandPermissions( id=uid, type=app_commands.AppCommandPermissionsType.user, permission=True ) for uid in OWNER_USER_IDS ] if permissions: await guild.edit_command_permissions(permissions) except Exception: log.exception("Failed syncing/locking commands for guild %s", guild.id) # ============================================================ # BACKGROUND TASKS # ============================================================ @tasks.loop(seconds=20) async def expire_loop(): try: due_ids = await db.get_due_active_ids() for gid in due_ids: try: await process_end(gid) except Exception: log.exception("Failed ending giveaway %s", gid) except Exception: log.exception("Expire loop failure.") @expire_loop.before_loop async def before_expire_loop(): await bot.wait_until_ready() @tasks.loop(minutes=5) async def backup_loop(): try: await db.backup_now() except Exception: log.exception("Backup loop failure.") @backup_loop.before_loop async def before_backup_loop(): await bot.wait_until_ready() # ============================================================ # EVENTS # ============================================================ @bot.event async def on_ready(): log.info("Logged in as %s (%s)", bot.user, bot.user.id) try: await load_active_views() await enforce_allowed_guilds() await sync_private_commands() except Exception: log.exception("Startup setup failed.") if not expire_loop.is_running(): expire_loop.start() if not backup_loop.is_running(): backup_loop.start() @bot.event async def on_guild_join(guild: discord.Guild): if not guild_is_allowed(guild): log.info("Joined unauthorized guild %s (%s). Leaving.", guild.name, guild.id) try: await guild.leave() except Exception: log.exception("Failed leaving unauthorized guild on join.") # ============================================================ # GRACEFUL SHUTDOWN FOR DOCKER # ============================================================ def _shutdown_handler(signum, frame): log.info("Received shutdown signal (%s). Closing gracefully...", signum) asyncio.get_event_loop().create_task(bot.close()) signal.signal(signal.SIGTERM, _shutdown_handler) signal.signal(signal.SIGINT, _shutdown_handler) # ============================================================ # RUN # ============================================================ if __name__ == "__main__": validate_environment() bot.run(TOKEN)