Skip to content

Commit 0c50327

Browse files
committed
Add Moderator utility for calculating perceptual hash of images
1 parent 6edf663 commit 0c50327

1 file changed

Lines changed: 54 additions & 0 deletions

File tree

bot/exts/filtering/filtering.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from operator import attrgetter
1111
from typing import Literal, get_type_hints
1212

13+
import aiohttp
1314
import arrow
1415
import discord
1516
from async_rediscache import RedisCache
@@ -30,6 +31,7 @@
3031
from bot.exts.filtering._filter_lists import FilterList, ListType, ListTypeConverter, filter_list_types
3132
from bot.exts.filtering._filter_lists.filter_list import AtomicList
3233
from bot.exts.filtering._filters.filter import Filter, UniqueFilter
34+
from bot.exts.filtering._image_hash import RhodiumAPIError, get_image_hash, signed_i64_to_hex
3335
from bot.exts.filtering._settings import ActionSettings
3436
from bot.exts.filtering._settings_types.actions.infraction_and_notification import Infraction
3537
from bot.exts.filtering._ui.filter import (
@@ -64,6 +66,7 @@
6466
HOURS_BETWEEN_NICKNAME_ALERTS = 1
6567
OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=7)
6668
WEEKLY_REPORT_ISO_DAY = 3 # 1=Monday, 7=Sunday
69+
MAX_IMAGE_HASH_SIZE = 5_000_000
6770

6871

6972
def _clean_ban_mentions(mentions: set[str]) -> set[str]:
@@ -654,6 +657,40 @@ async def setting(self, ctx: Context, setting_name: str | None) -> None:
654657
embed.set_author(name=f"Description of the {setting_name} setting")
655658
await ctx.send(embed=embed)
656659

660+
@filter.command(name="imagehash", aliases=("hash",))
661+
async def f_imagehash(self, ctx: Context) -> None:
662+
"""Hash an attached image and return its normalized hexadecimal perceptual hash."""
663+
attachment = self._first_attached_image(ctx.message)
664+
if attachment is None:
665+
await ctx.reply(":x: Attach an image (or reply to a message with an image attachment).")
666+
return
667+
668+
if attachment.size > MAX_IMAGE_HASH_SIZE:
669+
await ctx.reply(":x: The image must be 5 MB or smaller.")
670+
return
671+
672+
try:
673+
image_hash = await get_image_hash(attachment.url)
674+
except aiohttp.ClientError:
675+
log.exception("Unhandled aiohttp exception while getting image hash")
676+
await ctx.reply(":x: Failed to reach the image hashing service.")
677+
return
678+
except RhodiumAPIError as e:
679+
log.exception("Rhodium API error: %s", e)
680+
await ctx.reply(":x: The image hashing service returned an error.")
681+
return
682+
except TimeoutError:
683+
log.exception("Timed out getting image hash")
684+
await ctx.reply(":x: Timed out while hashing the image.")
685+
return
686+
687+
image_hash_hex = signed_i64_to_hex(image_hash)
688+
await ctx.reply(
689+
"Image hash:\n"
690+
f"- hex: `{image_hash_hex}`\n"
691+
f"- signed i64: `{image_hash}`"
692+
)
693+
657694
@filter.command(name="match")
658695
async def f_match(
659696
self, ctx: Context, no_user: bool | None, message: Message | None, *, string: str | None
@@ -1121,6 +1158,23 @@ def _get_filter_by_id(self, id_: int) -> tuple[Filter, FilterList, ListType] | N
11211158
return sublist.filters[id_], filter_list, list_type
11221159
return None
11231160

1161+
@staticmethod
1162+
def _first_attached_image(message: Message) -> discord.Attachment | None:
1163+
"""Return the first image attachment from the invoking or replied-to message."""
1164+
attachments = list(message.attachments)
1165+
1166+
if (
1167+
not attachments
1168+
and message.reference
1169+
and isinstance(message.reference.resolved, Message)
1170+
):
1171+
attachments = list(message.reference.resolved.attachments)
1172+
1173+
for attachment in attachments:
1174+
if attachment.content_type and attachment.content_type.startswith("image"):
1175+
return attachment
1176+
return None
1177+
11241178
async def _add_filter(
11251179
self,
11261180
ctx: Context,

0 commit comments

Comments
 (0)