From 3d93ed1a4f2196a1b2dd982079b35347a50cafda Mon Sep 17 00:00:00 2001 From: haumlab <222520099+haumlab@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:25:16 +0100 Subject: [PATCH 1/3] hash --- .DS_Store | Bin 14340 -> 12292 bytes README.md | 15 ++ scamimages/__init__.py | 7 + scamimages/info.json | 22 +++ scamimages/scamimages.py | 371 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 415 insertions(+) create mode 100644 scamimages/__init__.py create mode 100644 scamimages/info.json create mode 100644 scamimages/scamimages.py diff --git a/.DS_Store b/.DS_Store index da5b77c486cb383cc01e83b3909039670c53ab8d..08550bc8d1a336345b5346ffa7bf72afc3cd8a1d 100644 GIT binary patch delta 237 zcmZoEXh~3DU|?W$DortDV9)?EIe-{M3-ADmb_NCo?uiQeq7#4|W}qMgLncEBLlHwN zLn1@U#EZ(44NO>A8G({4lNE$CCN~I)PWDx>T6mRxGdl+h2V)dSbn_k|f8LD`I+!+d zEATLGUap+QGI65%WD^0A%?bimOj?E9K>ciBLkk%4foxVF1_BL`xGSf=W}qMgLmERqLlHwV zLn=ejWI;yd$p$(stc*ZemdOpO8azok`AI+yN7X0(&6@=ozp+hLVD#P0&cVXLm<3cI zwt1hb1=GYy){{L2)Hj<5`15Y&R^VZ*=Vj1iC}5~$C;=K<3?$=$79;|3219C2x?yl~ zer^HKR3Kp52_*2S&dqmmfw|%N&p+DH(~mn6(vjj)Pnwp33}h!c0L^FM1bUzd==c<% z(^G&>FJVYTb-2(E?5e;Hu7`$NVZ^3AOQ(TdjK#p*{1mv~@T)|1AUNo_!4511Mqe6c z=+?u6mthvrl{l0l2kpvhdwxejf)=N0gkuS+u1`gEu>mMZS%EGt0|sdxLm6^V@(8iK zL6t>z*tvVlwnIY@Nk4MPVN)T%P=@RPKF`fUiuo*){Y9lWFA=@XF3AdV4=`JB0|{5; stN~2o@0lm_>*#{Bz=6rOI`TYBkUV4o(lI$hXX508O2V5Pl)f+l0FYkChyVZp diff --git a/README.md b/README.md index 59e6eebd..50b8b164 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Cogs for the [Red-DiscordBot](https://github.com/Cog-Creators/Red-DiscordBot/)-b - [Welcome Logic](#welcome-logic) - [Commands](#commands) - [Roleinfo](#roleinfo) + - [ScamImages](#scamimages) - [Sentry](#sentry) - [Tags](#tags) - [Timeout](#timeout) @@ -96,6 +97,7 @@ A massive thank you to [all who've helped with this project](https://github.com/ - **[Report](#report):** Allows users to report issues. - **[role\_welcome](#role_welcome):** Sends a welcome message when a user is added to a role. - **[Roleinfo](#roleinfo):** Displays info on a role +- **[ScamImages](#scamimages):** Detects known scam images and automatically bans users who post them. - **[Sentry](#sentry):** Send unhandled errors to sentry. - **[Tags](#tags):** Allow user-generated stored messages. - **[Timeout](#timeout):** Manage users' timeout status. @@ -441,6 +443,19 @@ Allows you to view info on a role - `[p]roleinfo ` +### ScamImages + +This cog automatically detects known scam images (e.g. MrBeast giveaway scams) using perceptual hashing and bans users who post them. Admins can manage the image hash database. + +All commands are admin-only. + +- `[p]scamimages add [url]` - Add an image to the database (by URL, attachment, or reply). +- `[p]scamimages remove ` - Remove a hash by its index (see `list`). +- `[p]scamimages list` - List all hashes in the database. +- `[p]scamimages scan` - Manually scan an attached or replied-to image. +- `[p]scamimages logchannel ` - Set the channel for ban alert logging. +- `[p]scamimages clear` - Clear the entire database (requires confirmation). + ### Sentry Send unhandled errors and performance metrics to sentry. diff --git a/scamimages/__init__.py b/scamimages/__init__.py new file mode 100644 index 00000000..064e8be0 --- /dev/null +++ b/scamimages/__init__.py @@ -0,0 +1,7 @@ +from redbot.core.bot import Red + +from .scamimages import ScamImagesCog + + +async def setup(bot: Red): + await bot.add_cog(ScamImagesCog(bot)) diff --git a/scamimages/info.json b/scamimages/info.json new file mode 100644 index 00000000..e4f4171b --- /dev/null +++ b/scamimages/info.json @@ -0,0 +1,22 @@ +{ + "author": [ + "rHomelab" + ], + "short": "Detect known scam images and auto-ban posters", + "description": "Detects known scam images (e.g. MrBeast giveaways) using perceptual hashing and automatically bans users who post them. Admins can manage the image hash database.", + "disabled": false, + "name": "scamimages", + "requirements": [ + "imagehash~=4.3.1", + "Pillow~=11.2.0" + ], + "tags": [ + "anti-scam", + "images", + "moderation", + "prevention", + "scam" + ], + "install_msg": "Usage: `[p]scamimages`", + "min_bot_version": "3.5.1" +} diff --git a/scamimages/scamimages.py b/scamimages/scamimages.py new file mode 100644 index 00000000..c3519051 --- /dev/null +++ b/scamimages/scamimages.py @@ -0,0 +1,371 @@ +"""discord red-bot scam image detection""" + +import logging +from io import BytesIO +from typing import Optional + +import discord +import imagehash +from PIL import Image +from redbot.core import Config, checks, commands +from redbot.core.bot import Red +from redbot.core.utils.menus import close_menu, menu, next_page, prev_page + +log = logging.getLogger("red.rhomelab.scamimages") + +SIMILARITY_THRESHOLD = 10 +MAX_HASH_DISPLAY = 10 + + +class ScamImagesCog(commands.Cog): + """Scam Images Cog""" + + def __init__(self, bot: Red): + self.bot = bot + self.config = Config.get_conf(self, identifier=988776655443322111) + + default_guild_settings = { + "hashes": [], + "logchannel": "", + } + + self.config.register_guild(**default_guild_settings) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + if message.author.bot or not message.guild: + return + if not message.attachments: + return + + image_attachments = [a for a in message.attachments if a.content_type and a.content_type.startswith("image/")] + if not image_attachments: + return + + known_hashes = await self.config.guild(message.guild).hashes() + if not known_hashes: + return + + for attachment in image_attachments: + try: + phash = await self._compute_phash_from_url(attachment.url) + except Exception: + log.debug(f"Failed to compute hash for attachment {attachment.url}", exc_info=True) + continue + + if phash is None: + continue + + for stored_hash_str in known_hashes: + try: + stored_hash = imagehash.hex_to_hash(stored_hash_str) + except Exception: + continue + + if phash - stored_hash <= SIMILARITY_THRESHOLD: + await self._handle_match(message, attachment, phash, stored_hash_str) + return + + async def _handle_match( + self, + message: discord.Message, + attachment: discord.Attachment, + phash: imagehash.ImageHash, + matched_hash: str, + ): + log.info( + "Scam image detected from %s (%s) in %s (%s) - hash match: %s", + message.author, message.author.id, message.guild.name, message.guild.id, str(phash), + ) + + try: + await message.delete() + except discord.Forbidden: + log.warning("Could not delete scam image message from %s", message.author.id) + except discord.NotFound: + pass + + ban_reason = f"Automatic ban: posted a known scam image. Hash: {str(phash)}" + + try: + await message.guild.ban(message.author, reason=ban_reason, delete_message_days=1) + log.info("Banned user %s (%s) for posting scam image", message.author, message.author.id) + except discord.Forbidden: + log.warning("Could not ban user %s - missing permissions", message.author.id) + return + + logchannel_id = await self.config.guild(message.guild).logchannel() + if logchannel_id: + logchannel = message.guild.get_channel(int(logchannel_id)) + if logchannel is not None: + try: + embed = discord.Embed( + title="Scam Image Detected - User Banned", + description=( + f"**User:** {message.author} ({message.author.id})\n" + f"**Channel:** {message.channel.mention}\n" + f"**Matched Hash:** {matched_hash}\n" + f"**Image Hash:** {str(phash)}" + ), + colour=discord.Colour.red(), + ) + if attachment.filename: + embed.add_field(name="Filename", value=attachment.filename) + await logchannel.send(embed=embed) + except discord.Forbidden: + pass + + async def _compute_phash_from_url(self, url: str) -> Optional[imagehash.ImageHash]: + async with self.bot.get_session() as session: + async with session.get(url) as response: + if response.status != 200: + return None + image_data = await response.read() + + try: + image = Image.open(BytesIO(image_data)) + return imagehash.phash(image) + except Exception: + return None + + async def _compute_phash_from_message(self, message: discord.Message) -> list[Optional[imagehash.ImageHash]]: + results = [] + image_attachments = [a for a in message.attachments if a.content_type and a.content_type.startswith("image/")] + for attachment in image_attachments: + phash = await self._compute_phash_from_url(attachment.url) + results.append(phash) + return results + + @checks.admin() + @commands.guild_only() + @commands.group(name="scamimages") # type: ignore + async def _scamimages(self, ctx: commands.GuildContext): + """Scam image detection commands.""" + + @checks.admin() + @commands.guild_only() + @_scamimages.command(name="add") # type: ignore + async def scamimages_add(self, ctx: commands.GuildContext, url: Optional[str] = None): + """Add an image to the scam detection database. + + Provide a URL, attach an image, or reply to a message with an image. + + Examples: + - `[p]scamimages add https://example.com/scam.png` + - `[p]scamimages add` (with an image attached) + - `[p]scamimages add` (reply to a message containing an image) + """ + image_url: Optional[str] = url + + if image_url is None: + if ctx.message.attachments: + image_url = ctx.message.attachments[0].url + elif ctx.message.reference is not None: + replied = ctx.message.reference.resolved + if replied is not None and isinstance(replied, discord.Message) and replied.attachments: + image_url = replied.attachments[0].url + + if image_url is None: + await ctx.send("Please provide an image URL, attach an image, or reply to a message with an image.") + return + + phash = await self._compute_phash_from_url(image_url) + if phash is None: + await ctx.send("Failed to process the image. Make sure the URL is valid and points to an image.") + return + + hash_str = str(phash) + known_hashes = await self.config.guild(ctx.guild).hashes() + + for stored_hash_str in known_hashes: + try: + stored_hash = imagehash.hex_to_hash(stored_hash_str) + except Exception: + continue + if phash - stored_hash <= SIMILARITY_THRESHOLD: + await ctx.send( + f"This image is already in the database (similar to hash `{stored_hash_str}`). " + f"Hamming distance: {phash - stored_hash}" + ) + return + + async with self.config.guild(ctx.guild).hashes() as hashes: + hashes.append(hash_str) + + await ctx.send( + f"**Hash:** `{hash_str}`\n**Total hashes:** {len(known_hashes) + 1}" + ) + log.info("Added scam image hash %s to guild %s (%s)", hash_str, ctx.guild.name, ctx.guild.id) + + @checks.admin() + @commands.guild_only() + @_scamimages.command(name="remove") # type: ignore + async def scamimages_remove(self, ctx: commands.GuildContext, index: int): + """Remove an image hash from the database by its index. + + Use `[p]scamimages list` to see hashes and their indices. + + Example: + - `[p]scamimages remove 3` + """ + hashes = await self.config.guild(ctx.guild).hashes() + if not hashes: + await ctx.send("The scam image database is empty.") + return + + if index < 1 or index > len(hashes): + await ctx.send(f"Invalid index. Use a number between 1 and {len(hashes)}.") + return + + removed = hashes.pop(index - 1) + await self.config.guild(ctx.guild).hashes.set(hashes) + + await ctx.send(f"Removed hash `{removed}` from the database. **Remaining:** {len(hashes)}") + log.info("Removed scam image hash %s from guild %s (%s)", removed, ctx.guild.name, ctx.guild.id) + + @checks.admin() + @commands.guild_only() + @_scamimages.command(name="list") # type: ignore + async def scamimages_list(self, ctx: commands.GuildContext): + """List all image hashes in the scam detection database.""" + hashes = await self.config.guild(ctx.guild).hashes() + + if not hashes: + await ctx.send("The scam image database is empty.") + return + + pages = [] + for i in range(0, len(hashes), MAX_HASH_DISPLAY): + chunk = hashes[i : i + MAX_HASH_DISPLAY] + description = "\n".join(f"**{i + j + 1}.** `{h}`" for j, h in enumerate(chunk)) + embed = discord.Embed( + title=f"Scam Image Database ({len(hashes)} total)", + description=description, + colour=await ctx.embed_colour(), + ) + embed.set_footer(text=f"Threshold: {SIMILARITY_THRESHOLD} | Page {len(pages) + 1}") + pages.append(embed) + + if len(pages) == 1: + await ctx.send(embed=pages[0]) + else: + await menu(ctx, pages=pages, controls={"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page}, timeout=180.0) + + @checks.admin() + @commands.guild_only() + @_scamimages.command(name="logchannel") # type: ignore + async def scamimages_logchannel(self, ctx: commands.GuildContext, channel: discord.TextChannel): + """Set the channel where ban alerts are logged. + + Example: + - `[p]scamimages logchannel #mod-log` + """ + if not channel.permissions_for(ctx.me).send_messages: + await ctx.send("I do not have permission to send messages in that channel.") + return + + await self.config.guild(ctx.guild).logchannel.set(str(channel.id)) + await ctx.message.add_reaction("✅") + + @checks.admin() + @commands.guild_only() + @_scamimages.command(name="scan") # type: ignore + async def scamimages_scan(self, ctx: commands.GuildContext): + """Manually scan a message for scam images. + + Attach an image or reply to a message containing an image. + + Examples: + - `[p]scamimages scan` (with an image attached) + - `[p]scamimages scan` (reply to a message with an image) + """ + target: Optional[discord.Message] = None + + if ctx.message.reference is not None: + target = ctx.message.reference.resolved + if not isinstance(target, discord.Message): + target = None + + if target is None and ctx.message.attachments: + hashes = await self._compute_phash_from_message(ctx.message) + await self._report_scan_results(ctx, hashes) + return + + if target is None: + await ctx.send("Please reply to a message to scan it, or attach an image to your command message.") + return + + if not target.attachments: + await ctx.send("The target message has no attachments.") + return + + hashes = await self._compute_phash_from_message(target) + await self._report_scan_results(ctx, hashes) + + async def _report_scan_results(self, ctx: commands.GuildContext, hashes: list[Optional[imagehash.ImageHash]]): + known_hashes = await self.config.guild(ctx.guild).hashes() + + if not hashes or all(h is None for h in hashes): + await ctx.send("No valid images found to scan.") + return + + matches = [] + for phash in hashes: + if phash is None: + continue + for stored_hash_str in known_hashes: + try: + stored_hash = imagehash.hex_to_hash(stored_hash_str) + except Exception: + continue + dist = phash - stored_hash + if dist <= SIMILARITY_THRESHOLD: + matches.append((str(phash), stored_hash_str, dist)) + + if matches: + description = "\n".join( + f"- Image hash `{ph}` matched stored hash `{sh}` (distance: {d})" for ph, sh, d in matches + ) + embed = discord.Embed( + title="Scam Image Match Found!", + description=description, + colour=discord.Colour.red(), + ) + await ctx.send(embed=embed) + else: + await ctx.send("No scam images detected in the scanned image(s).") + + @checks.admin() + @commands.guild_only() + @_scamimages.command(name="clear") # type: ignore + async def scamimages_clear(self, ctx: commands.GuildContext): + """Clear the entire scam image database for this guild. + + This action is irreversible. + """ + confirm_msg = await ctx.send( + "Are you sure you want to clear the entire scam image database? " + "React with ✅ to confirm or ❌ to cancel." + ) + + await confirm_msg.add_reaction("✅") + await confirm_msg.add_reaction("❌") + + def check(reaction: discord.Reaction, user: discord.User) -> bool: + return user == ctx.author and str(reaction.emoji) in ("✅", "❌") and reaction.message.id == confirm_msg.id + + try: + reaction, _ = await self.bot.wait_for("reaction_add", timeout=30.0, check=check) + except Exception: + await confirm_msg.clear_reactions() + await ctx.send("Timed out. Database was not cleared.") + return + + if str(reaction.emoji) == "✅": + await self.config.guild(ctx.guild).hashes.set([]) + await confirm_msg.clear_reactions() + await ctx.send("Scam image database has been cleared.") + log.info("Scam image database cleared for guild %s (%s)", ctx.guild.name, ctx.guild.id) + else: + await confirm_msg.clear_reactions() + await ctx.send("Cancelled. Database was not cleared.") From e485f7e07b4a1829c843a01937066a30603f6ebf Mon Sep 17 00:00:00 2001 From: haumlab <222520099+b1ume@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:06:24 +0100 Subject: [PATCH 2/3] Reuse HTTP session and tighten config types Switch image fetching to a persistent aiohttp ClientSession with a 15s timeout and close it in cog_unload to avoid creating sessions per request. Normalize guild log channel storage to integer IDs (including defaults and reads/writes), and narrow confirmation timeout handling to asyncio.TimeoutError instead of catching all exceptions. --- scamimages/scamimages.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/scamimages/scamimages.py b/scamimages/scamimages.py index c3519051..f6b81dcb 100644 --- a/scamimages/scamimages.py +++ b/scamimages/scamimages.py @@ -1,9 +1,11 @@ """discord red-bot scam image detection""" +import asyncio import logging from io import BytesIO from typing import Optional +import aiohttp import discord import imagehash from PIL import Image @@ -15,6 +17,7 @@ SIMILARITY_THRESHOLD = 10 MAX_HASH_DISPLAY = 10 +REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=15) class ScamImagesCog(commands.Cog): @@ -23,14 +26,18 @@ class ScamImagesCog(commands.Cog): def __init__(self, bot: Red): self.bot = bot self.config = Config.get_conf(self, identifier=988776655443322111) + self.session = aiohttp.ClientSession() default_guild_settings = { "hashes": [], - "logchannel": "", + "logchannel": 0, } self.config.register_guild(**default_guild_settings) + async def cog_unload(self): + await self.session.close() + @commands.Cog.listener() async def on_message(self, message: discord.Message): if message.author.bot or not message.guild: @@ -96,7 +103,7 @@ async def _handle_match( logchannel_id = await self.config.guild(message.guild).logchannel() if logchannel_id: - logchannel = message.guild.get_channel(int(logchannel_id)) + logchannel = message.guild.get_channel(logchannel_id) if logchannel is not None: try: embed = discord.Embed( @@ -116,11 +123,10 @@ async def _handle_match( pass async def _compute_phash_from_url(self, url: str) -> Optional[imagehash.ImageHash]: - async with self.bot.get_session() as session: - async with session.get(url) as response: - if response.status != 200: - return None - image_data = await response.read() + async with self.session.get(url, timeout=REQUEST_TIMEOUT) as response: + if response.status != 200: + return None + image_data = await response.read() try: image = Image.open(BytesIO(image_data)) @@ -264,7 +270,7 @@ async def scamimages_logchannel(self, ctx: commands.GuildContext, channel: disco await ctx.send("I do not have permission to send messages in that channel.") return - await self.config.guild(ctx.guild).logchannel.set(str(channel.id)) + await self.config.guild(ctx.guild).logchannel.set(channel.id) await ctx.message.add_reaction("✅") @checks.admin() @@ -356,7 +362,7 @@ def check(reaction: discord.Reaction, user: discord.User) -> bool: try: reaction, _ = await self.bot.wait_for("reaction_add", timeout=30.0, check=check) - except Exception: + except asyncio.TimeoutError: await confirm_msg.clear_reactions() await ctx.send("Timed out. Database was not cleared.") return From 66b27094d2d1cb8b44c68371c71ce726670fbcfe Mon Sep 17 00:00:00 2001 From: haumlab <222520099+b1ume@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:08:45 +0100 Subject: [PATCH 3/3] Update info.json --- scamimages/info.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scamimages/info.json b/scamimages/info.json index e4f4171b..70c42743 100644 --- a/scamimages/info.json +++ b/scamimages/info.json @@ -1,6 +1,6 @@ { "author": [ - "rHomelab" + "word" ], "short": "Detect known scam images and auto-ban posters", "description": "Detects known scam images (e.g. MrBeast giveaways) using perceptual hashing and automatically bans users who post them. Admins can manage the image hash database.",