diff --git a/apps/discord-bot/.gitignore b/apps/discord-bot/.gitignore index fee10802..dc29836a 100644 --- a/apps/discord-bot/.gitignore +++ b/apps/discord-bot/.gitignore @@ -1,2 +1,3 @@ .env -/core/apps/discord-bot/.venv* \ No newline at end of file +/core/apps/discord-bot/.venv* +*.log \ No newline at end of file diff --git a/apps/discord-bot/README.md b/apps/discord-bot/README.md index ef042630..2b02e305 100644 --- a/apps/discord-bot/README.md +++ b/apps/discord-bot/README.md @@ -1,3 +1,75 @@ -### Discord Bot -for venv run source .venv/bin/activate if get error about pip commands not found -(hi alex) \ No newline at end of file +# Discord Bot + +## Prerequisites + +Before you begin, make sure you have: + +* **Python 3.8+** installed and on your PATH +* **Git** (optional if you clone the repo) + +*All commands assume you are in the `core/apps/discord-bot/` directory.* + +## 1. Create a Virtual Environment + +```bash +python -m venv .venv +``` + +## 2. Activate the Environment + +### macOS / Linux + +```bash +source .venv/bin/activate +``` + +### Windows (PowerShell) + +```powershell +.\.venv\Scripts\Activate.ps1 +``` + +*(Your prompt should now be prefixed with `(.venv)`.)* + +## 3. Install Dependencies + +```bash +# Upgrade pip +python -m pip install --upgrade pip + +# Install runtime dependencies +pip install -r requirements.txt +``` + +## 4. Configure Environment Variables + +Create a file named `.env` in this directory with the following content: + +``` +DISCORD_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE +``` + +## 5. Run the Bot + +With the venv active, start your bot: + +```bash +python main.py +``` + +You should see something like: + +``` +Bot: SwampHackr is ready to go! +``` + +--- + +> If you ever switch to a fresh terminal session, re-activate the venv before running any commands: +> +> ```bash +> source .venv/bin/activate # (macOS/Linux) +> # or +> .\.venv\Scripts\Activate.ps1 # (Windows) +> ``` + diff --git a/apps/discord-bot/cogs/anti_spam.py b/apps/discord-bot/cogs/anti_spam.py new file mode 100644 index 00000000..11de678b --- /dev/null +++ b/apps/discord-bot/cogs/anti_spam.py @@ -0,0 +1,134 @@ +import discord +from discord.ext import commands +import re +from typing import List, Pattern, Set +from utils.messaging import send_channel_message, send_dm + + + +# Regular expressions for detecting spam patterns +SPAM_PATTERNS: List[Pattern] = [ + # Pattern for detecting ticket sales + re.compile(r""" + (?i) + \bselling\b + .{0,60}? + (tickets?|passes?|spots?|seats?) + (.{0,20}?\bfor\b.{0,20}?\$?\d{2,5})? + """, re.VERBOSE), + + # Pattern for detecting subleasing advertisements + re.compile(r""" + (?i) + \b(sublease|subleasing|sublet|lease)\b + (.{0,20}?\bfor\b.{0,20}?\$?\d{2,5})? + """, re.VERBOSE), + + # Pattern for detecting "DM if interested" messages + re.compile(r""" + (?i) + dm\s+ + (me\s+)?(if\s+)?interested\b + """, re.VERBOSE), +] + + +class AntiSpam(commands.Cog): + """A cog that detects and handles spam messages in Discord channels. + + This cog monitors all messages in the server and automatically removes + messages that match predefined spam patterns. It also notifies users + when their messages are removed. + """ + + def __init__(self, bot: commands.Bot) -> None: + """Initialize the AntiSpam cog + + Args: + bot: Discord bot instance + """ + self.bot: commands.Bot = bot + self.ignore_channels: Set[int] = set() + + + def is_spam(self, message: discord.Message) -> bool: + """Check if a message matches any spam patterns + + Args: + message: Discord message to check + + Returns: + bool: True if the message is determined to be spam, False otherwise + """ + content: str = message.content + + for pattern in SPAM_PATTERNS: + if pattern.search(content): + return True + + # TODO: Implement additional spam detection methods: + # - Check for repeated messages + # - Detect excessive mentions + # - Check for suspicious links + # - Monitor message frequency + + return False + + async def handle_spam(self, message: discord.Message) -> None: + """Handle a detected spam message + + This method: + 1. Deletes the spam message + 2. Sends a notification in the channel by calling send_channel_message + 3. Sends a DM to the user explaining why their message was removed by calling send_dm + + Args: + message: The spam message to handle + + Note: + If the bot lacks permissions to delete messages or send DMs, + the error will be logged but not raised. + """ + try: + await message.delete() + content: str = f"{message.author.mention} Your message has been deleted for potential scam/spam." + await send_channel_message(message.channel, content, delete_after=5) + + content = ( + f"Your message was automatically deleted for potential scam/spam content:\n" + f"```{message.content}```\n" + f"If you believe this was a mistake, please contact server staff for review." + ) + await send_dm(message.author, content) + except discord.Forbidden: + print("Bot lacks permissions to delete messages") + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Event listener for new messages + + This method is called for every message sent in the server. + 1. Ignores messages from bots + 2. Ignores messages in ignored channels + 3. Calls is_spam function to determine if message is spam + + Args: + message: The message that triggered the event + """ + if message.author.bot: + return + + if message.channel.id in self.ignore_channels: + return + + if self.is_spam(message): + await self.handle_spam(message) + + +async def setup(bot: commands.Bot) -> None: + """Add the AntiSpam cog to the bot + + Args: + bot: Discord bot instance + """ + await bot.add_cog(AntiSpam(bot)) diff --git a/apps/discord-bot/cogs/general.py b/apps/discord-bot/cogs/general.py new file mode 100644 index 00000000..9fdf5112 --- /dev/null +++ b/apps/discord-bot/cogs/general.py @@ -0,0 +1,137 @@ +from discord.ext import commands +from discord import app_commands +import discord +from typing import Optional, Literal + + +class General(commands.Cog): + """A cog containing general utility commands for the server + + This cog includes commands for: + - Basic server interactions + - Role management + - Fun commands + """ + + def __init__(self, bot: commands.Bot) -> None: + """Initialize the General cog + + Args: + bot: Discord bot instance + """ + self.bot: commands.Bot = bot + + @commands.command() + async def test(self, ctx: commands.Context) -> None: + """Send a test message + + Args: + ctx: The command context + """ + await ctx.send("Testing") + + @app_commands.command( + name="role", + description="Assign or remove a role from yourself" + ) + @app_commands.describe( + action="Whether to assign or remove the role", + role="The role to assign or remove" + ) + async def manage_role( + self, + interaction: discord.Interaction, + action: Literal["assign", "remove"], + role: discord.Role, + member: discord.Member + ) -> None: + """Manage roles for the user who triggered the command + + Args: + interaction: The interaction that triggered this command + action: Whether to assign or remove the role + role: The role to assign or remove + + Note: + This command will: + 1. Check if the user already has/doesn't have the role + 2. Assign or remove the role if conditions are met + 3. Send appropriate feedback messages + """ + staff_role = discord.utils.get(interaction.guild.roles, name="Staff") + + if not staff_role or staff_role not in interaction.user.roles: + await interaction.response.send_message( + "You don't have permission to use this command.", + ephemeral=True + ) + return + + member = await interaction.guild.fetch_member(member.id) + + + has_role = role in member.roles + + if action == "assign": + if has_role: + await interaction.response.send_message( + f"{member.mention} already has the **{role.name}** role.", + ephemeral=True + ) + return + + try: + await member.add_roles(role) + await interaction.response.send_message( + f"Assigned **{role.name}** to {member.mention}.", + ephemeral=True + ) + # Send a followup message that will be deleted after 5 seconds + await interaction.followup.send( + f"{interaction.user.mention} assigned **{role.name}** role to {member.mention}.", + delete_after=5 + ) + except discord.Forbidden: + await interaction.response.send_message( + "I don't have permission to assign roles!", + ephemeral=True + ) + elif action == "remove": + if not has_role: + await interaction.response.send_message( + f"{member.mention} does not have the **{role.name}** role.", + ephemeral=True + ) + return + + try: + await member.remove_roles(role) + await interaction.response.send_message( + f"Removed **{role.name}** from {member.mention}.", + ephemeral=True + ) + # Send a followup message that will be deleted after 5 seconds + await interaction.followup.send( + f"{interaction.user.mention} removed **{role.name}** role from {member.mention}.", + delete_after=5 + ) + except discord.Forbidden: + await interaction.response.send_message( + "I don't have permission to remove roles!", + ephemeral=True + ) + else: + # This will not be reached but just wanted to show add and remove for commands + await interaction.response.send_message( + "Invalid action. Please use 'assign' or 'remove'.", + ephemeral=True + ) + + +async def setup(bot: commands.Bot) -> None: + """Add the General cog to the bot + + Args: + bot: Discord bot instance + """ + await bot.add_cog(General(bot)) \ No newline at end of file diff --git a/apps/discord-bot/main.py b/apps/discord-bot/main.py index 4a83c50a..4768d3bc 100644 --- a/apps/discord-bot/main.py +++ b/apps/discord-bot/main.py @@ -3,6 +3,98 @@ import logging from dotenv import load_dotenv import os +import pathlib +import asyncio +from typing import Optional + +# Set up logging configuration +log_dir: pathlib.Path = pathlib.Path(__file__).parent / 'logs' +handler: logging.FileHandler = logging.FileHandler( + log_dir / 'discord-bot.log', + encoding='utf-8', + mode='w' +) +logging.basicConfig( + level=logging.DEBUG, + handlers=[handler], +) + +# Load environment variables load_dotenv() -token = os.getenv('DISCORD_TOKEN') +token: Optional[str] = os.getenv('DISCORD_TOKEN') + +# Configure bot intents +# Note: These must be enabled in both code and Discord Developer Portal +intents: discord.Intents = discord.Intents.default() +intents.message_content = True +intents.members = True + +# Initialize bot with command prefix and intents +bot: commands.Bot = commands.Bot(command_prefix='!', intents=intents) + +# for testing purposes + + +@bot.event +async def on_ready() -> None: + """Event triggered when the bot is ready and connected to Discord + + This event: + 1. Prints a ready message to console + 2. Logs the ready status to the log file + """ + await bot.tree.sync() + print(f"Bot: {bot.user} is ready to go.") + logging.info(f"Bot: {bot.user} is ready to go.") + + +async def load_cogs() -> None: + """Load all cog modules from the cogs directory + + This function: + 1. Finds all .py files in the cogs directory + 2. Attempts to load each as a cog + 3. Logs and prints success or failure for each cog + + Note: + Cogs must be valid Python modules with a setup function + """ + print("Starting to load cogs...") + cogs_dir: pathlib.Path = pathlib.Path(__file__).parent / "cogs" + print(f"Looking for cogs in: {cogs_dir}") + + if not cogs_dir.exists(): + print(f"ERROR: Cogs directory does not exist at {cogs_dir}") + return + + files: list[str] = os.listdir(cogs_dir) + print(f"Found files in cogs directory: {files}") + + for filename in files: + if filename.endswith(".py"): + try: + await bot.load_extension(f"cogs.{filename[:-3]}") + print(f"Successfully loaded cog: {filename[:-3]}") + logging.info(f"Loaded cog: {filename[:-3]}") + except Exception as e: + print(f"Failed to load cog {filename[:-3]}: {str(e)}") + logging.error(f"Failed to load cog {filename[:-3]}: {e}") + print("Finished loading cogs") + + +async def main() -> None: + """Main function for the bot + + This function calls functions to load cogs and start the bot given the api token from environment variables + """ + await load_cogs() + await bot.start(token) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logging.info("Bot shutting down...") + print("Bot shutting down...") diff --git a/apps/discord-bot/utils/messaging.py b/apps/discord-bot/utils/messaging.py new file mode 100644 index 00000000..747ebcc1 --- /dev/null +++ b/apps/discord-bot/utils/messaging.py @@ -0,0 +1,46 @@ +import discord +from typing import Union, Optional + + +async def send_channel_message( + channel: discord.TextChannel, + content: str, + delete_after: Optional[int] = None +) -> None: + """Send a message to a Discord channel with optional auto-deletion after some time + + Args: + channel: Discord text channel to send the message to + content: Message content to send + delete_after: Number of seconds after which to delete the message, or None to keep it + + Note: + If the bot lacks permissions to send messages, the error will be logged but not raised + """ + try: + await channel.send(content, delete_after=delete_after) + except discord.Forbidden: + print(f"[Error] Missing permissions to send message in #{channel.name}") + except discord.HTTPException as e: + print(f"[Error] Failed to send message in #{channel.name}: {e}") + + +async def send_dm( + user: Union[discord.User, discord.Member], + content: str +) -> None: + """Send a direct message to a Discord user + + Args: + user: Discord user or member to send the DM to + content: The message content to send + + Note: + If the bot cannot send DMs to the user, the error will be logged but not raised + """ + try: + await user.send(content) + except discord.Forbidden: + print(f"Cannot DM {user}") + except discord.HTTPException as e: + print(f"Failed to send DM: {e}")