diff --git a/README.md b/README.md new file mode 100644 index 0000000..e956edc --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# Amalgam + +A Discord bot that mirrors comic book reviews between two servers, keeping both communities in sync. + +## What It Does + +When a member posts a review in the review channel, Amalgam: + +- Validates it against the required format — invalid posts are deleted and the author is DM'd their original content to fix +- Creates a discussion thread on the review in both servers simultaneously +- Forwards the review to the partner server +- Keeps a sticky format guide pinned at the bottom of both channels at all times +- Propagates edits and deletions across both servers +- Forwards thread replies from the original reviewer +- Respects bans — a user banned in the destination server won't have their review forwarded + +## Review Format + +Posts that don't match this structure are deleted. The author receives a DM with their original text so they can repost. + +``` +## Comic Name +**Year and writer:** +**Rating:** x/10 +**Review:** Your thoughts here. Use ||spoiler tags|| for spoilers. +**MU/DCUI link:** (optional) +``` + +All four main fields are required. The MU/DCUI link is optional. The pattern is matched case-insensitively. + +## Setup + +1. **Clone the repository** + + ```bash + git clone https://github.com/BowGaming/Amalgam.git + cd Amalgam + ``` + +2. **Install dependencies** + + ```bash + pip install discord.py python-dotenv + ``` + +3. **Create a `.env` file** in the project root — see [Configuration](#configuration) below. + +4. **Run the bot** + + ```bash + python main.py + ``` + +## Configuration + +All configuration is read from a `.env` file at startup. + +| Variable | Required | Description | +|---|---|---| +| `DISCORD_TOKEN` | Yes | Bot token from the Discord Developer Portal | +| `BOT_PREFIX` | No | Command prefix — defaults to `~` | +| `GUILD_MD` | Yes | Server ID for the first server (MD) | +| `GUILD_DCO` | Yes | Server ID for the second server (DCO) | +| `COMIC_REVIEW_CHANNEL_MD` | Yes | Channel ID for the review channel in MD | +| `COMIC_REVIEW_CHANNEL_DCO` | Yes | Channel ID for the review channel in DCO | +| `REVIEW_REACTION_EMOJI_MD` | Yes | Emoji ID for the reaction added to reviews in MD | +| `REVIEW_REACTION_EMOJI_DCO` | Yes | Emoji ID for the reaction added to reviews in DCO | + +**Example `.env`:** + +```env +# Bot credentials +DISCORD_TOKEN=your-token-here +BOT_PREFIX=~ + +# Server IDs (right-click server → Copy Server ID) +GUILD_MD=123456789012345678 +GUILD_DCO=987654321098765432 + +# Review channel IDs +COMIC_REVIEW_CHANNEL_MD=111111111111111111 +COMIC_REVIEW_CHANNEL_DCO=222222222222222222 + +# Custom emoji IDs (right-click emoji → Copy ID) +REVIEW_REACTION_EMOJI_MD=333333333333333333 +REVIEW_REACTION_EMOJI_DCO=444444444444444444 +``` + +## Bot Permissions + +Grant these permissions in both servers when adding the bot: + +- Read Messages / View Channels +- Send Messages +- Manage Messages +- Add Reactions +- Create Public Threads +- Send Messages in Threads +- Read Message History +- Ban Members + +In the Discord Developer Portal, enable both **Server Members Intent** and **Message Content Intent** under Privileged Gateway Intents. + +## Requirements + +- Python 3.10+ +- discord.py 2.x +- python-dotenv diff --git a/cogs/review.py b/cogs/review.py new file mode 100644 index 0000000..38d994e --- /dev/null +++ b/cogs/review.py @@ -0,0 +1,398 @@ +import discord +from discord.ext import commands +from discord import Embed, Forbidden +import config +import re +import sqlite3 + + +class ReviewCog(commands.Cog) : + def __init__(self, bot) : + self.bot = bot + + self.guild_id_MD = config.guild_MD + self.guild_id_DCO = config.guild_DCO + + # Max character length for messages. Messages get cut up in pieces if exceeds the below value + self.max_content_length = 1990 + + # Sticky messages + self.review_instruction_embed = Embed( + title="**How to Post Reviews**", + description=( + "Please follow the format below when writing your review.\n" + "Your message will be deleted if it doesn't follow the format.\n\n" + "**Notes:**\n" + "- Only post reviews for full runs, collected editions, or one-shots. Single issue reviews are allowed in threads (see below).\n" + "- If you want to post single issue reviews, please make a review post for a full run/collected edition, then post your single issue reviews in the thread. You can edit the main post as you progress reading.\n" + "- As these reviews are meant for people who haven't read the comic yet, please use spoiler brackets ``||like this||`` if you want to include spoilers in your review. Not using spoiler brackets on spoilers may lead to your review being removed." + ), + ) + + self.format_message = ( + "```\n" + "## Comic Name\n" + "**Year and writer:**\n" + "**Rating:** x/10\n" + "**Review:** A few words about your thoughts on the comic and why you gave it that rating. You could include details such as the length of the book, quality of the art, required background reading, etc. Make sure to use spoiler brackets ||like this|| for any spoilers you want to include.\n" + "**MU/DCUI link:** (optional) A link to the comic on Marvel Unlimited or DC Universe Infinite. This is not mandatory for the format." + "```" + ) + + # Open database + self.conn = sqlite3.connect("forward_reviews.db") + self.cursor = self.conn.cursor() + + # Create tables if missing + # forward_reviews stores all db info needed to forward, edit and delete reviews + self.cursor.execute(""" + CREATE TABLE IF NOT EXISTS forward_reviews ( + original_id INTEGER PRIMARY KEY, + mirrored_channel_id INTEGER NOT NULL, + mirrored_id INTEGER NOT NULL + ) + """) + self.conn.commit() + + # forward_threads stores all db info needed to determine what original thread belongs to what mirrored thread. This info is used in cog threads.py + self.cursor.execute(""" + CREATE TABLE IF NOT EXISTS forward_threads ( + original_thread_id INTEGER PRIMARY KEY, + mirrored_thread_id INTEGER NOT NULL, + owner_id INTEGER NOT NULL + ) + """) + self.conn.commit() + + # Function that alters original review content for forwarding (cutting message up to fit character limit, adding OP credit) + def make_forwarded_content(self, message): + return ( + f"Review from **{message.author.display_name}**:\n\n" + f"{message.content}" + ) + + # Function that forwards the review + async def forward_review(self, message, out_review_channel_id): + + target_channel = self.bot.get_channel(out_review_channel_id) + if not target_channel: + return + + # Modify message before forwarding + modified_review = self.make_forwarded_content(message) + + # Download attachments + files = [] + for attachment in message.attachments: + file = await attachment.to_file() + files.append(file) + + # Send message with attachments + mirrored = await target_channel.send(content=modified_review, files=files if files else None) + + # Store ID mapping + self.cursor.execute( + """ + INSERT INTO forward_reviews + (original_id, mirrored_channel_id, mirrored_id) + VALUES (?, ?, ?) + """, + (message.id, target_channel.id, mirrored.id) + ) + self.conn.commit() + + # ====================================================================================================================================================================================== + # Listener for new reviews + @commands.Cog.listener() + async def on_message(self, message) : + + # Ignore bot messages + if message.author.bot : + return + + # Ignore DMs (globally_block_dms only gates commands, not listeners) + if message.guild is None: + return + + # Assign variables based on in which server the original review is sent. Also ignore if review is not sent in MD or DCO + if message.guild.id == self.guild_id_MD: + home_guild_id = config.guild_MD + home_review_channel_id = config.comic_review_channel_MD + home_emoji = self.bot.get_emoji(config.review_reaction_emoji_MD) + + out_guild_id = config.guild_DCO + out_review_channel_id = config.comic_review_channel_DCO + out_emoji = self.bot.get_emoji(config.review_reaction_emoji_DCO) + + elif message.guild.id == self.guild_id_DCO: + home_guild_id = config.guild_DCO + home_review_channel_id = config.comic_review_channel_DCO + home_emoji = self.bot.get_emoji(config.review_reaction_emoji_DCO) + + out_guild_id = config.guild_MD + out_review_channel_id = config.comic_review_channel_MD + out_emoji = self.bot.get_emoji(config.review_reaction_emoji_MD) + else: + return + + # Ignore messages not sent in review channel + if message.channel.id != home_review_channel_id: + return + + # Regex pattern to match section headers (## or bold headers) + pattern = re.compile( + r"##\s*.+\s*" # Comic name header + r"\*\*year and writer:\*\*.+?" + r"\*\*rating:\*\*.+?" + r"\*\*review:\*\*.+", + re.IGNORECASE | re.DOTALL + ) + + # Review message does not pass format + if not pattern.search(message.content): + # Try to DM the user before deleting the message + try: + # Reason for deletion + reason = ( + "Your review post was removed because it doesn't follow the required format.\n" + "Please make sure to follow the provided format.\n" + "If you keep experiencing issues, try copying the format or a previous review, and fill in your own information.\n\n" + "Here’s what you wrote so you can easily copy and fix it:" + ) + + # Send reason for deletion + await message.author.send( + f"Hey {message.author.display_name},\n\n{reason}\n" + ) + + # Send original message so user can copy and fix (note: messages deleted by automod will NOT be DMed to user!) + content = message.content + for i in range(0, len(content), self.max_content_length): + text = content[i:i + self.max_content_length] + await message.author.send(f"```\n{text}\n```") + + # User has DMs disabled or blocked the bot + except Forbidden: + pass + + # Delete review message + await message.delete() + return + + # Remove previous embed messages from bot to keep latest at bottom + async for msg in message.channel.history(limit=5): + if msg.author == self.bot.user: + if msg.content == self.format_message: + await msg.delete() + if msg.embeds: + embed = msg.embeds[0] + if embed.title == self.review_instruction_embed.title: + await msg.delete() + + # Send sticky embeds at bottom + await message.channel.send(embed=self.review_instruction_embed) + await message.channel.send(content=self.format_message) + + # Add reaction to passed messages + await message.add_reaction(home_emoji) + + # Create a thread for discussion + first_line = message.content.strip().split("\n", 1)[0] + comic_name = first_line.replace("##", "").strip() + + thread = await message.create_thread( + name=f"Review: {comic_name} by {message.author.display_name}", + auto_archive_duration=4320 # 3 days + ) + + await thread.send( + f"Thread for discussing **{comic_name}**, reviewed by {message.author.display_name}!" + ) + + # =============================================================================================================================================================== + # End of code for homeserver, beginning of code of out server + + # Check if user is banned in other server. If so, don't forward + out_guild = self.bot.get_guild(out_guild_id) + if out_guild: + try: + ban = await out_guild.fetch_ban(message.author) + # User is banned + return + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + pass + + # Forward review + await self.forward_review(message, out_review_channel_id) + + # Retrieve data from db for following executions + self.cursor.execute( + """ + SELECT mirrored_channel_id, mirrored_id + FROM forward_reviews + WHERE original_id = ? + """, + (message.id,) + ) + + result = self.cursor.fetchone() + if result is None: + return + channel_id, mirrored_id = result + out_channel = self.bot.get_channel(channel_id) + if out_channel is None: + return + mirrored_review = await out_channel.fetch_message(mirrored_id) + + # Remove previous embed messages from bot to keep latest at bottom in out server + async for msg in out_channel.history(limit=5): + if msg.author == self.bot.user: + if msg.content == self.format_message: + await msg.delete() + if msg.embeds: + embed = msg.embeds[0] + if embed.title == self.review_instruction_embed.title: + await msg.delete() + + # Send sticky embeds at bottom in out server + await out_channel.send(embed=self.review_instruction_embed) + await out_channel.send(content=self.format_message) + + # Add reaction to passed messages + await mirrored_review.add_reaction(out_emoji) + + # Create a thread for discussion + thread_out = await mirrored_review.create_thread( + name=f"Review: {comic_name} by {message.author.display_name}", + auto_archive_duration=4320 # 3 days + ) + + await thread_out.send( + f"Thread for discussing **{comic_name}**, reviewed by {message.author.display_name}!" + ) + + # Store thread mapping + self.cursor.execute( + """ + INSERT INTO forward_threads + (original_thread_id, mirrored_thread_id, owner_id) + VALUES (?, ?, ?) + """, + ( + thread.id, + thread_out.id, + message.author.id + ) + ) + self.conn.commit() + + # ====================================================================================================================================================================================== + # Listener for edits of reviews + @commands.Cog.listener() + async def on_raw_message_edit(self, payload): + + # Ignore if content wasn't edited + if "content" not in payload.data: + return + + # Ignore edits in DMs + channel = self.bot.get_channel(payload.channel_id) + if channel is None: + return + + # Get new message + try: + after = await channel.fetch_message(payload.message_id) + except discord.NotFound: + return + + # Ignore edits by bot + if after.author.bot: + return + + # Ignore DMs (get_channel can return a cached DMChannel, which is not None) + if after.guild is None: + return + + # Assign variables based on in which server the edit is done. Also ignore if edit is not in MD or DCO + if after.guild.id == self.guild_id_MD: + home_review_channel_id = config.comic_review_channel_MD + elif after.guild.id == self.guild_id_DCO: + home_review_channel_id = config.comic_review_channel_DCO + else: + return + + # Ignore edits not made in review channel + if after.channel.id != home_review_channel_id: + return + + # Retrieve data from db for following executions + self.cursor.execute( + """ + SELECT mirrored_channel_id, mirrored_id + FROM forward_reviews + WHERE original_id = ? + """, + (after.id,) + ) + + result = self.cursor.fetchone() + # Ignore if original review has no mirror + if result is None: + return + channel_id, mirrored_id = result + mirror_channel = self.bot.get_channel(channel_id) + if mirror_channel is None: + return + + # Find original mirrored message + try: + mirrored = await mirror_channel.fetch_message(mirrored_id) + except discord.NotFound: + return + + # Edit mirrored message + await mirrored.edit( + content=self.make_forwarded_content(after) + ) + + # ====================================================================================================================================================================================== + # Listener for deletion of reviews + @commands.Cog.listener() + async def on_raw_message_delete(self, payload): + + # Retrieve data from db for following executions + self.cursor.execute( + """ + SELECT mirrored_channel_id, mirrored_id + FROM forward_reviews + WHERE original_id = ? + """, + (payload.message_id,) + ) + result = self.cursor.fetchone() + # Ignore if original review has no mirror + if result is None: + return + channel_id, mirrored_id = result + + channel = self.bot.get_channel(channel_id) + if channel is None: + return + + # Delete mirrored review + try: + message = await channel.fetch_message(mirrored_id) + await message.delete() + except discord.NotFound: + pass + + # Delete db entry from db + self.cursor.execute( + "DELETE FROM forward_reviews WHERE original_id = ?", + (payload.message_id,) + ) + self.conn.commit() + +async def setup(bot: commands.Bot) : + await bot.add_cog(ReviewCog(bot)) diff --git a/cogs/threads.py b/cogs/threads.py new file mode 100644 index 0000000..b10990e --- /dev/null +++ b/cogs/threads.py @@ -0,0 +1,307 @@ +import discord +from discord.ext import commands +from discord import Embed, Forbidden +import config +import re +import sqlite3 + + +class ThreadCog(commands.Cog) : + def __init__(self, bot) : + self.bot = bot + + self.guild_id_MD = config.guild_MD + self.guild_id_DCO = config.guild_DCO + + # Max character length for messages. Messages get cut up in pieces if exceeds the below value + self.max_content_length = 1990 + + # Open database + self.conn = sqlite3.connect("forward_reviews.db") + self.cursor = self.conn.cursor() + + # Create tables if missing + # forward_threads stores all db info needed to determine what original thread belongs to what mirrored thread. This info is gathered in cog review.py + self.cursor.execute(""" + CREATE TABLE IF NOT EXISTS forward_threads ( + original_thread_id INTEGER PRIMARY KEY, + mirrored_thread_id INTEGER NOT NULL, + owner_id INTEGER NOT NULL + ) + """) + self.conn.commit() + + # forward_thread_messages stores all db info needed to determine what original thread belongs to what mirrored thread + self.cursor.execute(""" + CREATE TABLE IF NOT EXISTS forward_thread_messages ( + original_thread_message_id INTEGER PRIMARY KEY, + mirrored_thread_message_id INTEGER NOT NULL + ) + """) + self.conn.commit() + + # Function that alters original message content for forwarding (cutting message up to fit character limit, adding OP credit) + def make_forwarded_content(self, message): + return ( + f"Message from **{message.author.display_name}**:\n\n" + f"{message.content}" + ) + + # Function that forwards the message + async def forward_content(self, message, out_thread_id): + + target_channel = self.bot.get_channel(out_thread_id) + if not target_channel: + return + + # Modify message before forwarding + modified_review = self.make_forwarded_content(message) + + # Download attachments + files = [] + for attachment in message.attachments: + file = await attachment.to_file() + files.append(file) + + # Send message with attachments + mirrored = await target_channel.send(content=modified_review, files=files if files else None) + + # Store ID mapping + self.cursor.execute( + """ + INSERT INTO forward_thread_messages + (original_thread_message_id, mirrored_thread_message_id) + VALUES (?, ?) + """, + (message.id, mirrored.id) + ) + self.conn.commit() + + # ====================================================================================================================================================================================== + # Listener for new thread messages + @commands.Cog.listener() + async def on_message(self, message) : + + # Ignore bot messages, messages not sent in a thread, messages sent in DMs + if message.author.bot : + return + if not isinstance(message.channel, discord.Thread): + return + if message.guild is None: + return + + # Assign variables based on in which server the thread message is sent. Also ignore if thread message is not sent in MD or DCO + if message.guild.id == self.guild_id_MD: + home_review_channel_id = config.comic_review_channel_MD + out_guild_id = config.guild_DCO + elif message.guild.id == self.guild_id_DCO: + home_review_channel_id = config.comic_review_channel_DCO + out_guild_id = config.guild_MD + else: + return + + parent_channel = message.channel.parent + if parent_channel is None: + return + parent_channel_id = parent_channel.id + + # Ignore messages not sent in review channel threads + if parent_channel_id != home_review_channel_id: + return + + # Retrieve data from db for following executions + self.cursor.execute( + """ + SELECT mirrored_thread_id, owner_id + FROM forward_threads + WHERE original_thread_id = ? + """, + (message.channel.id,) + ) + + result = self.cursor.fetchone() + # Stop if no mirrored thread exists + if result is None: + return + mirrored_thread_id, owner_id = result + out_channel = self.bot.get_channel(mirrored_thread_id) + + # Ignore all thread messages not sent by review OP + if message.author.id != owner_id: + return + + # Check if user is banned in other server. If so, don't continue + out_guild = self.bot.get_guild(out_guild_id) + if out_guild: + try: + ban = await out_guild.fetch_ban(message.author) + # User is banned + return + except (discord.NotFound, discord.Forbidden, discord.HTTPException): + pass + + # Regex pattern, NEED TO ADJUST! (also not in use right now) + pattern = re.compile( + r"##\s*.+\s*" # Comic name header + r"\*\*year and writer:\*\*.+?" + r"\*\*rating:\*\*.+?" + r"\*\*review:\*\*.+", + re.IGNORECASE | re.DOTALL + ) + + # Review message does not pass format + #if not pattern.search(message.content): + + # Forward review + await self.forward_content(message, mirrored_thread_id) + + # ====================================================================================================================================================================================== + # Listener for edits of forwarded thread messages + @commands.Cog.listener() + async def on_raw_message_edit(self, payload): + + # Ignore if content wasn't edited + if "content" not in payload.data: + return + + # Ignore edits in DMs + channel = self.bot.get_channel(payload.channel_id) + if channel is None: + return + + # Get new message + try: + after = await channel.fetch_message(payload.message_id) + except discord.NotFound: + return + + # Ignore edits by bot + if after.author.bot: + return + + # Ignore DMs (get_channel can return a cached DMChannel, which is not None) + if after.guild is None: + return + + # Assign variables based on in which server the edit is done. Also ignore if edit is not in MD or DCO + if after.guild.id == self.guild_id_MD: + home_review_channel_id = config.comic_review_channel_MD + elif after.guild.id == self.guild_id_DCO: + home_review_channel_id = config.comic_review_channel_DCO + else: + return + + # Ignore edits if not sent in thread + if not isinstance(after.channel, discord.Thread): + return + parent_channel = after.channel.parent + if parent_channel is None: + return + parent_channel_id = parent_channel.id + + # Ignore edits not sent in review channel threads + if parent_channel_id != home_review_channel_id: + return + + # Retrieve data from db's for following executions + self.cursor.execute( + """ + SELECT mirrored_thread_message_id + FROM forward_thread_messages + WHERE original_thread_message_id = ? + """, + (payload.message_id,) + ) + + result = self.cursor.fetchone() + # Ignore if message has no mirror + if result is None: + return + mirrored_id = result[0] + + self.cursor.execute( + """ + SELECT mirrored_thread_id + FROM forward_threads + WHERE original_thread_id = ? + """, + (payload.channel_id,) + ) + + result = self.cursor.fetchone() + if result is None: + return + mirrored_thread_id = result[0] + + # Get mirror thread + mirror_channel = self.bot.get_channel(mirrored_thread_id) + if mirror_channel is None: + return + + # Find original mirrored message + try: + mirrored = await mirror_channel.fetch_message(mirrored_id) + except discord.NotFound: + return + + # Edit mirrored message + await mirrored.edit( + content=self.make_forwarded_content(after) + ) + + # ====================================================================================================================================================================================== + # Listener for deletion of forwarded thread messages + @commands.Cog.listener() + async def on_raw_message_delete(self, payload): + + # Retrieve data from db's for following executions + self.cursor.execute( + """ + SELECT mirrored_thread_message_id + FROM forward_thread_messages + WHERE original_thread_message_id = ? + """, + (payload.message_id,) + ) + + result = self.cursor.fetchone() + # Ignore if message has no mirror + if result is None: + return + mirrored_id = result[0] + + self.cursor.execute( + """ + SELECT mirrored_thread_id + FROM forward_threads + WHERE original_thread_id = ? + """, + (payload.channel_id,) + ) + + result = self.cursor.fetchone() + if result is None: + return + mirrored_thread_id = result[0] + + # Get mirror thread + mirror_channel = self.bot.get_channel(mirrored_thread_id) + if mirror_channel is None: + return + + # Delete mirrored review + try: + message = await mirror_channel.fetch_message(mirrored_id) + await message.delete() + except discord.NotFound: + pass + + # Delete db entry from db + self.cursor.execute( + "DELETE FROM forward_thread_messages WHERE original_thread_message_id = ?", + (payload.message_id,) + ) + self.conn.commit() + +async def setup(bot: commands.Bot) : + await bot.add_cog(ThreadCog(bot)) diff --git a/config.py b/config.py new file mode 100644 index 0000000..2fca53c --- /dev/null +++ b/config.py @@ -0,0 +1,14 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +TOKEN = os.getenv("DISCORD_TOKEN") +BOT_PREFIX = os.getenv("BOT_PREFIX", "~") + +guild_MD = int(os.getenv("GUILD_MD", 0)) +guild_DCO = int(os.getenv("GUILD_DCO", 0)) +comic_review_channel_MD = int(os.getenv("COMIC_REVIEW_CHANNEL_MD", 0)) +comic_review_channel_DCO = int(os.getenv("COMIC_REVIEW_CHANNEL_DCO", 0)) +review_reaction_emoji_MD = int(os.getenv("REVIEW_REACTION_EMOJI_MD", 0)) +review_reaction_emoji_DCO = int(os.getenv("REVIEW_REACTION_EMOJI_DCO", 0)) diff --git a/main.py b/main.py new file mode 100644 index 0000000..74a6e52 --- /dev/null +++ b/main.py @@ -0,0 +1,42 @@ +import discord +from discord.ext import commands +from config import * + +class Amalgam(commands.Bot): + async def setup_hook(self): + initial_extensions = [ + #add cogs here like this: + "cogs.review", + "cogs.threads", + ] + + for extension in initial_extensions: + await self.load_extension(extension) + + +intents = discord.Intents.all() + +get_pre = lambda bot, message: BOT_PREFIX + +bot = Amalgam( + command_prefix=get_pre, intents=intents, max_messages=16 +) + +@bot.event +async def on_connect(): + print("Loaded Discord") + +@bot.event +async def on_ready(): + print("------") + print("Logged in as") + print(bot.user.name) + print(bot.user.id) + print(discord.utils.utcnow().strftime("%d/%m/%Y %I:%M:%S:%f")) + print("------") + +@bot.check +async def globally_block_dms(ctx): + return ctx.guild is not None + +bot.run(TOKEN, reconnect=True)