| |
| 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 |
|
|
| |
| |
| |
|
|
| |
| 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") |
|
|
|
|
| |
| |
| |
|
|
| 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)") |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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} |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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 <t:{ends_at}:R>", inline=False) |
| else: |
| embed.add_field(name="Ends", value=f"<t:{ends_at}:R> • <t:{ends_at}: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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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_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()) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| @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 |
| ) |
|
|
|
|
| |
| 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() |
|
|
|
|
| |
| |
| |
|
|
| @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)) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| |
| |
|
|
| @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() |
|
|
|
|
| |
| |
| |
|
|
| @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.") |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| validate_environment() |
| bot.run(TOKEN) |