diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..33f7c49 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +DISCORD_TOKEN= +BOT_PREFIX=! + +GUILD_MD= +GUILD_DCO= + +COMIC_REVIEW_CHANNEL_MD= +COMIC_REVIEW_CHANNEL_DCO= + +REVIEW_REACTION_EMOJI_MD= +REVIEW_REACTION_EMOJI_DCO= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4c49bd7..510a76b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .env +__pycache__/ +*.db \ No newline at end of file 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 index 38d994e..5917d4b 100644 --- a/cogs/review.py +++ b/cogs/review.py @@ -1,48 +1,59 @@ import discord from discord.ext import commands from discord import Embed, Forbidden +from discord.utils import escape_markdown import config import re import sqlite3 - - -class ReviewCog(commands.Cog) : - def __init__(self, bot) : +import logging + +# Constants +logger = logging.getLogger(__name__) +max_content_length = 1990 + +# Sticky messages +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." + ), +) + +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." + "```" +) +# Regex pattern to match section headers (## or bold headers) +section_header_pattern = re.compile( + r"##\s*.+\s*" # Comic name header + r"\*\*year and writer:\*\*.+?" + r"\*\*rating:\*\*.+?" + r"\*\*review:\*\*.+", + re.IGNORECASE | re.DOTALL, +) + + +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.conn = sqlite3.connect(config.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(""" @@ -64,32 +75,35 @@ def __init__(self, bot) : """) 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}" - ) + async def make_forwarded_content(self, message, markdown_author_name): + """Function that alters original review content for forwarding (cutting message up to fit character limit, adding OP credit)""" + return f"Review from **{markdown_author_name}**:\n\n{message.content}" + + async def forward_review( + self, message, destination_review_channel_id, markdown_author_name + ): + """Function that forwards the review""" - # 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) + target_channel = self.bot.get_channel(destination_review_channel_id) if not target_channel: - return - + return None + # Modify message before forwarding - modified_review = self.make_forwarded_content(message) - + modified_review = await self.make_forwarded_content( + message, markdown_author_name + ) + # 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) - + mirrored = await target_channel.send( + content=modified_review, files=files if files else None + ) + # Store ID mapping self.cursor.execute( """ @@ -97,59 +111,71 @@ async def forward_review(self, message, out_review_channel_id): (original_id, mirrored_channel_id, mirrored_id) VALUES (?, ?, ?) """, - (message.id, target_channel.id, mirrored.id) + (message.id, target_channel.id, mirrored.id), ) self.conn.commit() + return mirrored + + async def postprocess_review(self, message, emoji): + """Function that does standard review operations (resend sticky message, add reaction)""" + # 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 == format_message: + await msg.delete() + if msg.embeds: + embed = msg.embeds[0] + if embed.title == review_instruction_embed.title: + await msg.delete() + + # Send sticky embeds at bottom + await message.channel.send(embed=review_instruction_embed) + await message.channel.send(content=format_message) + + # Add reaction to passed messages + await message.add_reaction(emoji) + # ====================================================================================================================================================================================== - # Listener for new reviews @commands.Cog.listener() - async def on_message(self, message) : - + async def on_message(self, message): + """Listener for new reviews""" + # Ignore bot messages - if message.author.bot : + 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) + origin_guild_id = config.guild_MD + origin_review_channel_id = config.comic_review_channel_MD + origin_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) + destination_guild_id = config.guild_DCO + destination_review_channel_id = config.comic_review_channel_DCO + destination_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) + origin_guild_id = config.guild_DCO + origin_review_channel_id = config.comic_review_channel_DCO + origin_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) + destination_guild_id = config.guild_MD + destination_review_channel_id = config.comic_review_channel_MD + destination_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: + + # Ignore messages not sent in review channel + if message.channel.id != origin_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): + if not section_header_pattern.search(message.content): # Try to DM the user before deleting the message try: # Reason for deletion @@ -162,113 +188,80 @@ async def on_message(self, message) : # Send reason for deletion await message.author.send( - f"Hey {message.author.display_name},\n\n{reason}\n" + f"Hey {message.author.display_name},\n\n{reason}" ) # 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] + for i in range(0, len(content), max_content_length): + text = content[i : i + max_content_length] await message.author.send(f"```\n{text}\n```") - - # User has DMs disabled or blocked the bot + + # 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) + await self.postprocess_review(message, origin_emoji) # Create a thread for discussion first_line = message.content.strip().split("\n", 1)[0] - comic_name = first_line.replace("##", "").strip() + comic_name = first_line.lstrip("#").strip() + markdown_author_name = escape_markdown(message.author.display_name) thread = await message.create_thread( name=f"Review: {comic_name} by {message.author.display_name}", - auto_archive_duration=4320 # 3 days + auto_archive_duration=4320, # 3 days ) await thread.send( - f"Thread for discussing **{comic_name}**, reviewed by {message.author.display_name}!" + f"Thread for discussing **{comic_name}**, reviewed by {markdown_author_name}!\n\n" + "Are continuing with your review in the thread? **React to your own message with** 📨 to forward your thread messages to the other server!" ) - + # =============================================================================================================================================================== - # End of code for homeserver, beginning of code of out server + # End of code for originserver, beginning of code of destination 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 + destination_guild = self.bot.get_guild(destination_guild_id) + try: + await destination_guild.fetch_ban(message.author) + # User is banned + return - # Forward review - await self.forward_review(message, out_review_channel_id) + except discord.NotFound: + # User is not banned + pass - # 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: + except discord.Forbidden: + logger.warning("Bot cannot access the ban list for guild %s.", destination_guild.id) 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() + except discord.HTTPException: + logger.exception("Failed to check ban status for user %s.", message.author.id) + return + + # Forward review + mirrored_review = await self.forward_review( + message, destination_review_channel_id, markdown_author_name + ) - # 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) + if mirrored_review is None: + return - # Add reaction to passed messages - await mirrored_review.add_reaction(out_emoji) + await self.postprocess_review(mirrored_review, destination_emoji) # Create a thread for discussion - thread_out = await mirrored_review.create_thread( + thread_destination = await mirrored_review.create_thread( name=f"Review: {comic_name} by {message.author.display_name}", - auto_archive_duration=4320 # 3 days + auto_archive_duration=4320, # 3 days ) - - await thread_out.send( - f"Thread for discussing **{comic_name}**, reviewed by {message.author.display_name}!" + + await thread_destination.send( + f"Thread for discussing **{comic_name}**, reviewed by {markdown_author_name}!" ) # Store thread mapping @@ -278,52 +271,40 @@ async def on_message(self, message) : (original_thread_id, mirrored_thread_id, owner_id) VALUES (?, ?, ?) """, - ( - thread.id, - thread_out.id, - message.author.id - ) + (thread.id, thread_destination.id, message.author.id), ) self.conn.commit() # ====================================================================================================================================================================================== - # Listener for edits of reviews @commands.Cog.listener() async def on_raw_message_edit(self, payload): + """Listener for edits of reviews""" # 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: + if payload.guild_id is None: return # Get new message - try: - after = await channel.fetch_message(payload.message_id) - except discord.NotFound: - return + after = payload.message # 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 + origin_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 + origin_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: + if after.channel.id != origin_review_channel_id: return # Retrieve data from db for following executions @@ -333,9 +314,9 @@ async def on_raw_message_edit(self, payload): FROM forward_reviews WHERE original_id = ? """, - (after.id,) + (after.id,), ) - + result = self.cursor.fetchone() # Ignore if original review has no mirror if result is None: @@ -344,7 +325,7 @@ async def on_raw_message_edit(self, payload): 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) @@ -352,14 +333,35 @@ async def on_raw_message_edit(self, payload): return # Edit mirrored message + markdown_author_name = escape_markdown(after.author.display_name) await mirrored.edit( - content=self.make_forwarded_content(after) + content=await self.make_forwarded_content(after, markdown_author_name) ) # ====================================================================================================================================================================================== - # Listener for deletion of reviews @commands.Cog.listener() async def on_raw_message_delete(self, payload): + """Listener for deletion of reviews""" + + # Ignore DMs + if payload.guild_id is None: + return + + # Ignore deletions not in review threads + channel = self.bot.get_channel(payload.channel_id) + if channel is None: + channel = await self.bot.fetch_channel(payload.channel_id) + + if isinstance(channel, discord.Thread): + review_channel_id = channel.parent_id + else: + review_channel_id = channel.id + + if review_channel_id not in ( + config.comic_review_channel_MD, + config.comic_review_channel_DCO, + ): + return # Retrieve data from db for following executions self.cursor.execute( @@ -368,19 +370,16 @@ async def on_raw_message_delete(self, payload): FROM forward_reviews WHERE original_id = ? """, - (payload.message_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 + channel_id, mirrored_id = result # Delete mirrored review + channel = self.bot.get_channel(channel_id) try: message = await channel.fetch_message(mirrored_id) await message.delete() @@ -389,10 +388,10 @@ async def on_raw_message_delete(self, payload): # Delete db entry from db self.cursor.execute( - "DELETE FROM forward_reviews WHERE original_id = ?", - (payload.message_id,) + "DELETE FROM forward_reviews WHERE original_id = ?", (payload.message_id,) ) self.conn.commit() - -async def setup(bot: commands.Bot) : + + +async def setup(bot: commands.Bot): await bot.add_cog(ReviewCog(bot)) diff --git a/cogs/threads.py b/cogs/threads.py index b10990e..a489535 100644 --- a/cogs/threads.py +++ b/cogs/threads.py @@ -1,25 +1,26 @@ import discord from discord.ext import commands from discord import Embed, Forbidden +from discord.utils import escape_markdown import config import re import sqlite3 +# Constants +max_content_length = 1990 -class ThreadCog(commands.Cog) : - def __init__(self, bot) : + +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(""" @@ -40,32 +41,32 @@ def __init__(self, bot) : """) 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}" - ) + async def make_forwarded_content(self, message): + """Function that alters original message content for forwarding (cutting message up to fit character limit, adding OP credit)""" + markdown_author_name = escape_markdown(message.author.display_name) + return f"Message from **{markdown_author_name}**:\n\n{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) + async def forward_content(self, message, destination_thread_id): + """Function that forwards the message""" + + target_channel = self.bot.get_channel(destination_thread_id) if not target_channel: return - + # Modify message before forwarding - modified_review = self.make_forwarded_content(message) - + modified_review = await 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) - + mirrored = await target_channel.send( + content=modified_review, files=files if files else None + ) + # Store ID mapping self.cursor.execute( """ @@ -73,30 +74,43 @@ async def forward_content(self, message, out_thread_id): (original_thread_message_id, mirrored_thread_message_id) VALUES (?, ?) """, - (message.id, mirrored.id) + (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 : + async def on_raw_reaction_add(self, payload): + """Listener for forwarding thread messages""" + + # Ignore messages in DMs + if payload.guild_id is None: return - if not isinstance(message.channel, discord.Thread): + + # Ignore all reactions except for trigger reaction + if str(payload.emoji) != config.thread_forward_emoji: return - if message.guild is None: + + # Get channel and message + channel = self.bot.get_channel(payload.channel_id) + if channel is None: + return + + message = await channel.fetch_message(payload.message_id) + + # Ignore bot messages, messages not sent in a thread + if message.author.bot: + return + if not isinstance(message.channel, discord.Thread): 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 + origin_review_channel_id = config.comic_review_channel_MD + destination_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 + origin_review_channel_id = config.comic_review_channel_DCO + destination_guild_id = config.guild_MD else: return @@ -104,9 +118,9 @@ async def on_message(self, message) : 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: + + # Ignore messages not sent in review channel threads + if parent_channel_id != origin_review_channel_id: return # Retrieve data from db for following executions @@ -116,78 +130,75 @@ async def on_message(self, message) : FROM forward_threads WHERE original_thread_id = ? """, - (message.channel.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 + # Ignore all thread messages and reactions not sent by review OP if message.author.id != owner_id: return + if payload.user_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: + destination_guild = self.bot.get_guild(destination_guild_id) + if destination_guild: try: - ban = await out_guild.fetch_ban(message.author) + await destination_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 + + # Check if message has already been forwarded. If so, don't forward again + self.cursor.execute( + """ + SELECT 1 + FROM forward_thread_messages + WHERE original_thread_message_id = ? + """, + (message.id,), ) - # Review message does not pass format - #if not pattern.search(message.content): + if self.cursor.fetchone() is not None: + return # Forward review await self.forward_content(message, mirrored_thread_id) + # Add reaction to message to confirm forwarding + await message.add_reaction(config.thread_forward_conrfirmation_emoji) + # ====================================================================================================================================================================================== - # Listener for edits of forwarded thread messages @commands.Cog.listener() async def on_raw_message_edit(self, payload): + """Listener for edits of forwarded thread messages""" # 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: + if payload.guild_id is None: return # Get new message - try: - after = await channel.fetch_message(payload.message_id) - except discord.NotFound: - return + after = payload.message # 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 + origin_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 + origin_review_channel_id = config.comic_review_channel_DCO else: return @@ -198,9 +209,9 @@ async def on_raw_message_edit(self, payload): 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: + + # Ignore edits not sent in review channel threads + if parent_channel_id != origin_review_channel_id: return # Retrieve data from db's for following executions @@ -210,13 +221,13 @@ async def on_raw_message_edit(self, payload): FROM forward_thread_messages WHERE original_thread_message_id = ? """, - (payload.message_id,) + (payload.message_id,), ) - + result = self.cursor.fetchone() # Ignore if message has no mirror if result is None: - return + return mirrored_id = result[0] self.cursor.execute( @@ -225,35 +236,53 @@ async def on_raw_message_edit(self, payload): FROM forward_threads WHERE original_thread_id = ? """, - (payload.channel_id,) + (payload.channel_id,), ) - + result = self.cursor.fetchone() if result is None: - return + return mirrored_thread_id = result[0] # Get mirror thread - mirror_channel = self.bot.get_channel(mirrored_thread_id) - if mirror_channel is None: + if payload.guild_id is None: return - + # Find original mirrored message + mirror_channel = self.bot.get_channel(mirrored_thread_id) 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) - ) + await mirrored.edit(content=await self.make_forwarded_content(after)) # ====================================================================================================================================================================================== - # Listener for deletion of forwarded thread messages @commands.Cog.listener() async def on_raw_message_delete(self, payload): + """Listener for deletion of forwarded thread messages""" + + # Ignore DMs + if payload.guild_id is None: + return + # Ignore deletions not in review threads + channel = self.bot.get_channel(payload.channel_id) + if channel is None: + channel = await self.bot.fetch_channel(payload.channel_id) + + if isinstance(channel, discord.Thread): + review_channel_id = channel.parent_id + else: + review_channel_id = channel.id + + if review_channel_id not in ( + config.comic_review_channel_MD, + config.comic_review_channel_DCO, + ): + return + # Retrieve data from db's for following executions self.cursor.execute( """ @@ -261,13 +290,13 @@ async def on_raw_message_delete(self, payload): FROM forward_thread_messages WHERE original_thread_message_id = ? """, - (payload.message_id,) + (payload.message_id,), ) - + result = self.cursor.fetchone() # Ignore if message has no mirror if result is None: - return + return mirrored_id = result[0] self.cursor.execute( @@ -276,20 +305,16 @@ async def on_raw_message_delete(self, payload): FROM forward_threads WHERE original_thread_id = ? """, - (payload.channel_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 + mirrored_thread_id = result[0] # Delete mirrored review + mirror_channel = self.bot.get_channel(mirrored_thread_id) try: message = await mirror_channel.fetch_message(mirrored_id) await message.delete() @@ -299,9 +324,10 @@ async def on_raw_message_delete(self, payload): # Delete db entry from db self.cursor.execute( "DELETE FROM forward_thread_messages WHERE original_thread_message_id = ?", - (payload.message_id,) + (payload.message_id,), ) self.conn.commit() - -async def setup(bot: commands.Bot) : + + +async def setup(bot: commands.Bot): await bot.add_cog(ThreadCog(bot)) diff --git a/config.py b/config.py index 2fca53c..29cd39f 100644 --- a/config.py +++ b/config.py @@ -6,9 +6,14 @@ TOKEN = os.getenv("DISCORD_TOKEN") BOT_PREFIX = os.getenv("BOT_PREFIX", "~") +reviews_db = "forward_reviews.db" +threads_db = "forward_threads.db" + 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)) +thread_forward_emoji = "📨" +thread_forward_conrfirmation_emoji = "✅" \ No newline at end of file diff --git a/main.py b/main.py index 74a6e52..684dc9b 100644 --- a/main.py +++ b/main.py @@ -1,42 +1,55 @@ import discord from discord.ext import commands from config import * +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) + class Amalgam(commands.Bot): async def setup_hook(self): initial_extensions = [ - #add cogs here like this: - "cogs.review", - "cogs.threads", + # add cogs here like this: + "cogs.review", + "cogs.threads", ] for extension in initial_extensions: await self.load_extension(extension) -intents = discord.Intents.all() +intents = discord.Intents.none() +intents.guilds = True +intents.messages = True +intents.message_content = True +intents.emojis_and_stickers = True +intents.reactions = True -get_pre = lambda bot, message: BOT_PREFIX +bot = Amalgam(command_prefix=BOT_PREFIX, intents=intents, max_messages=16) -bot = Amalgam( - command_prefix=get_pre, intents=intents, max_messages=16 -) @bot.event async def on_connect(): - print("Loaded Discord") + logging.info("Connected to 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")) + logging.info( + "Logged in as %s (%s)", + bot.user.name, + bot.user.id, + ) print("------") + @bot.check async def globally_block_dms(ctx): return ctx.guild is not None + bot.run(TOKEN, reconnect=True)