diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..8ef9112 --- /dev/null +++ b/bot.py @@ -0,0 +1,345 @@ +import discord +from discord import app_commands +from discord.ext import commands, tasks +import sqlite3 + +DB_FILE = "swearjar.db" +SWEAR_WORDS = {REDACTED FOR CODE REVIEW} +BOT_TOKEN = "REDACTED FOR CODE REVIEW" + +intents = discord.Intents.default() +intents.message_content = True +intents.members = True +intents.guilds = True + + +# ============================== +# BOT CLASS +# ============================== +class SwearJarBot(commands.Bot): + def __init__(self): + super().__init__(command_prefix="!", intents=intents) + self.synced = False + + async def setup_hook(self): + self.remove_command("help") + self.update_status.start() + + async def on_ready(self): + init_db() + if not self.synced: + await self.tree.sync() + self.synced = True + print(f"βœ… Logged in as {self.user} (ID: {self.user.id})") + + @tasks.loop(minutes=10) + async def update_status(self): + """Updates the bot's 'Listening to XXX swear words' every 10 minutes.""" + total = get_global_swear_count() + activity = discord.Activity( + type=discord.ActivityType.listening, + name=f"{total:,} swear words" + ) + await self.change_presence(activity=activity) + + @update_status.before_loop + async def before_update_status(self): + await self.wait_until_ready() + + +bot = SwearJarBot() + + +# ============================== +# DATABASE FUNCTIONS +# ============================== +def init_db(): + conn = sqlite3.connect(DB_FILE) + cur = conn.cursor() + + cur.execute( + """CREATE TABLE IF NOT EXISTS swear_counts ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + count INTEGER DEFAULT 0, + PRIMARY KEY (guild_id, user_id) + )""" + ) + + cur.execute( + """CREATE TABLE IF NOT EXISTS privacy_optouts ( + user_id TEXT PRIMARY KEY + )""" + ) + + conn.commit() + conn.close() + + +def add_swear(guild_id: int, user_id: int, count: int = 1): + if is_user_opted_out(user_id): + return + conn = sqlite3.connect(DB_FILE) + cur = conn.cursor() + cur.execute( + """INSERT INTO swear_counts (guild_id, user_id, count) + VALUES (?, ?, ?) + ON CONFLICT(guild_id, user_id) + DO UPDATE SET count = count + ?""", + (str(guild_id), str(user_id), count, count) + ) + conn.commit() + conn.close() + + +def get_swear_counts(user_id: int): + conn = sqlite3.connect(DB_FILE) + cur = conn.cursor() + cur.execute("SELECT guild_id, count FROM swear_counts WHERE user_id = ?", (str(user_id),)) + rows = cur.fetchall() + conn.close() + total = sum(row[1] for row in rows) + return rows, total + + +def get_top_swearers(limit: int = 10, guild_id: int | None = None, offset: int = 0): + conn = sqlite3.connect(DB_FILE) + cur = conn.cursor() + if guild_id: + cur.execute( + """SELECT user_id, count + FROM swear_counts + WHERE guild_id = ? + ORDER BY count DESC + LIMIT ? OFFSET ?""", + (str(guild_id), limit, offset) + ) + else: + cur.execute( + """SELECT user_id, SUM(count) AS total + FROM swear_counts + GROUP BY user_id + ORDER BY total DESC + LIMIT ? OFFSET ?""", + (limit, offset) + ) + results = cur.fetchall() + conn.close() + return results + + +def get_global_swear_count() -> int: + """Return total swears across all users and servers.""" + conn = sqlite3.connect(DB_FILE) + cur = conn.cursor() + cur.execute("SELECT SUM(count) FROM swear_counts") + result = cur.fetchone() + conn.close() + return result[0] or 0 + + +# ============================== +# PRIVACY +# ============================== +def is_user_opted_out(user_id: int) -> bool: + conn = sqlite3.connect(DB_FILE) + cur = conn.cursor() + cur.execute("SELECT 1 FROM privacy_optouts WHERE user_id = ?", (str(user_id),)) + result = cur.fetchone() + conn.close() + return result is not None + + +def set_user_optout(user_id: int): + conn = sqlite3.connect(DB_FILE) + cur = conn.cursor() + cur.execute("DELETE FROM swear_counts WHERE user_id = ?", (str(user_id),)) + cur.execute("INSERT OR IGNORE INTO privacy_optouts (user_id) VALUES (?)", (str(user_id),)) + conn.commit() + conn.close() + + +def remove_user_optout(user_id: int): + conn = sqlite3.connect(DB_FILE) + cur = conn.cursor() + cur.execute("DELETE FROM privacy_optouts WHERE user_id = ?", (str(user_id),)) + conn.commit() + conn.close() + + +# ============================== +# UTILS +# ============================== +def censor_name(name: str) -> str: + if len(name) <= 3: + return "*" * len(name) + return "*" * (len(name) - 3) + name[-3:] + + +# ============================== +# MESSAGE TRACKING +# ============================== +@bot.event +async def on_message(message: discord.Message): + if message.author.bot or not message.guild: + return + if is_user_opted_out(message.author.id): + return + content = message.content.lower() + if any(word in content for word in SWEAR_WORDS): + add_swear(message.guild.id, message.author.id) + await bot.process_commands(message) + + +# ============================== +# COMMANDS +# ============================== +@bot.tree.command(name="swearjar", description="View your or another user's swear count") +async def swearjar(interaction: discord.Interaction, user: discord.User | None = None): + user = user or interaction.user + if is_user_opted_out(user.id): + await interaction.response.send_message( + f"{user.display_name} has opted out of tracking πŸ”’", ephemeral=True + ) + return + + rows, total = get_swear_counts(user.id) + if not rows: + await interaction.response.send_message( + f"{user.display_name} hasn't been caught swearing yet πŸŽ‰", ephemeral=True + ) + return + + embed = discord.Embed(title=f"πŸͺ£ Swear Jar β€” {user.display_name}", color=discord.Color.blurple()) + for guild_id, count in rows: + g = bot.get_guild(int(guild_id)) + embed.add_field( + name=g.name if g else f"Unknown Server ({guild_id})", value=f"{count:,}", inline=False + ) + embed.add_field(name="Total across all servers", value=f"**{total:,}**", inline=False) + await interaction.response.send_message(embed=embed) + + +# ============================== +# LEADERBOARD +# ============================== +class LeaderboardView(discord.ui.View): + def __init__(self, mode: str): + super().__init__(timeout=60) + self.mode = mode + self.page = 0 + self.page_size = 10 + + async def update_page(self, interaction: discord.Interaction): + offset = self.page * self.page_size + guild_id = interaction.guild.id if self.mode == "guild" else None + results = get_top_swearers(limit=self.page_size, guild_id=guild_id, offset=offset) + embed = format_leaderboard(results, self.mode, self.page) + await interaction.response.edit_message(embed=embed, view=self) + + @discord.ui.button(label="⬅️ Prev", style=discord.ButtonStyle.primary, disabled=True) + async def prev_page(self, interaction: discord.Interaction, button: discord.ui.Button): + if self.page > 0: + self.page -= 1 + button.disabled = self.page == 0 + await self.update_page(interaction) + + @discord.ui.button(label="➑️ Next", style=discord.ButtonStyle.primary) + async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button): + self.page += 1 + await self.update_page(interaction) + + +def format_leaderboard(results, mode="guild", page=0, per_page=10): + if not results: + return discord.Embed(title="No data yet!", color=discord.Color.gold()) + + title = "🌍 Global Swear Leaderboard" if mode == "global" else "🏠 Server Swear Leaderboard" + embed = discord.Embed(title=title, color=discord.Color.gold()) + + desc = "" + for idx, (user_id, count) in enumerate(results, start=1 + page * per_page): + if is_user_opted_out(user_id): + continue + user = bot.get_user(int(user_id)) + name = censor_name(user.name if user else "Unknown") + desc += f"**#{idx}** β€” {name}: `{count:,}`\n" + + embed.description = desc or "No data to show." + embed.set_footer(text=f"Page {page+1}") + return embed + + +@bot.tree.command(name="sweartop", description="Show the top swearers") +async def sweartop(interaction: discord.Interaction, mode: str = "server"): + guild_id = interaction.guild.id if mode.lower() != "global" and interaction.guild else None + results = get_top_swearers(limit=10, guild_id=guild_id) + if not results: + await interaction.response.send_message("No swearing detected yet!", ephemeral=True) + return + + embed = format_leaderboard(results, "guild" if guild_id else "global") + view = LeaderboardView("guild" if guild_id else "global") + await interaction.response.send_message(embed=embed, view=view) + + +# ============================== +# PRIVACY COMMAND +# ============================== +@bot.tree.command(name="privacy", description="View or manage your data privacy settings") +@app_commands.choices(action=[ + app_commands.Choice(name="view", value="view"), + app_commands.Choice(name="delete", value="delete"), + app_commands.Choice(name="reenable", value="reenable"), +]) +async def privacy(interaction: discord.Interaction, action: app_commands.Choice[str]): + user = interaction.user + + if action.value == "view": + opted = is_user_opted_out(user.id) + msg = ("πŸ”’ You are **OPTED OUT** and no data is being tracked." + if opted else + "βœ… You are **OPTED IN** and swear counts are being tracked.") + await interaction.response.send_message(msg, ephemeral=True) + + elif action.value == "delete": + set_user_optout(user.id) + await interaction.response.send_message( + "βœ… Your swear data has been deleted and tracking disabled.", ephemeral=True + ) + + elif action.value == "reenable": + remove_user_optout(user.id) + await interaction.response.send_message( + "πŸ”“ Tracking has been re‑enabled. Welcome back to the jar!", ephemeral=True + ) + + +# ============================== +# HELP MENU +# ============================== +@bot.tree.command(name="help", description="Learn how to use the SwearJar bot") +async def help_command(interaction: discord.Interaction): + embed = discord.Embed( + title="πŸͺ£ SwearJar Help Menu", + description="Here's what I can do:", + color=discord.Color.blurple() + ) + + embed.add_field(name="πŸ’¬ `/swearjar [user]`", value="See your swear totals.", inline=False) + embed.add_field(name="πŸ“Š `/sweartop [server/global]`", value="See the top swearers.", inline=False) + embed.add_field(name="πŸ›‘οΈ `/privacy`", value="Control your data and privacy.", inline=False) + embed.add_field(name="πŸ€– Tracking", value="Automatically tracks certain words (except opted-out users).", inline=False) + + embed.set_footer( + text="SwearJar Bot β€’ Be nice, or pay the jar πŸ’°", + icon_url=bot.user.display_avatar.url if bot.user.avatar else None + ) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + +# ============================== +# RUN +# ============================== +bot.run(BOT_TOKEN) \ No newline at end of file