Skip to content

Commit 30ddd5c

Browse files
authored
Merge branch 'main' into issue-3522
2 parents cc228c3 + 35d9e45 commit 30ddd5c

9 files changed

Lines changed: 307 additions & 114 deletions

File tree

.github/workflows/build-deploy.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616

1717
steps:
1818
- name: Checkout code
19-
uses: actions/checkout@v6
19+
uses: actions/checkout@v7
2020

2121
# The current version (v2) of Docker's build-push action uses
2222
# buildx, which comes with BuildKit features that help us speed
@@ -61,7 +61,7 @@ jobs:
6161
environment: production
6262
steps:
6363
- name: Checkout Kubernetes repository
64-
uses: actions/checkout@v6
64+
uses: actions/checkout@v7
6565
with:
6666
repository: python-discord/infra
6767
path: infra

.github/workflows/lint-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717

1818
steps:
1919
- name: Checkout repository
20-
uses: actions/checkout@v6
20+
uses: actions/checkout@v7
2121

2222
- name: Install uv
2323
uses: astral-sh/setup-uv@v7

.github/workflows/sentry_release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
runs-on: ubuntu-latest
1010
steps:
1111
- name: Checkout code
12-
uses: actions/checkout@v6
12+
uses: actions/checkout@v7
1313

1414
- name: Create a Sentry.io release
1515
uses: getsentry/action-release@v3
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import typing
2+
3+
import aiohttp
4+
from pydis_core.utils.logging import get_logger
5+
6+
from bot.exts.filtering._filter_context import Event, FilterContext
7+
from bot.exts.filtering._filter_lists.filter_list import FilterList, ListType
8+
from bot.exts.filtering._filters.filter import Filter
9+
from bot.exts.filtering._filters.image_hash import ImageHashFilter
10+
from bot.exts.filtering._image_hash import RhodiumAPIError, get_image_hash
11+
from bot.exts.filtering._settings import ActionSettings
12+
13+
if typing.TYPE_CHECKING:
14+
from bot.exts.filtering.filtering import Filtering
15+
16+
log = get_logger(__name__)
17+
18+
_MAX_IMAGE_SIZE = 5_000_000
19+
20+
21+
class ImageHashesList(FilterList[ImageHashFilter]):
22+
"""A list of perceptual image hashes that should trigger filtering when matched."""
23+
24+
name = "image_hash"
25+
26+
def __init__(self, filtering_cog: Filtering):
27+
super().__init__()
28+
filtering_cog.subscribe(self, Event.MESSAGE)
29+
30+
def get_filter_type(self, content: str) -> type[Filter]:
31+
"""Get a subclass of filter matching the filter list and the filter's content."""
32+
return ImageHashFilter
33+
34+
@property
35+
def filter_types(self) -> set[type[Filter]]:
36+
"""Return the types of filters used by this list."""
37+
return {ImageHashFilter}
38+
39+
async def actions_for(
40+
self, ctx: FilterContext
41+
) -> tuple[ActionSettings | None, list[str], dict[ListType, list[Filter]]]:
42+
"""Dispatch the given event to the list's filters, and return actions to take and messages to relay to mods."""
43+
if not ctx.attachments:
44+
return None, [], {}
45+
46+
image_hashes = []
47+
for attachment in ctx.attachments:
48+
if (
49+
attachment.content_type is None
50+
or not attachment.content_type.startswith("image")
51+
or attachment.size > _MAX_IMAGE_SIZE
52+
):
53+
continue
54+
55+
try:
56+
image_hash = await get_image_hash(attachment.url)
57+
except aiohttp.ClientError:
58+
log.exception("Unhandled aiohttp exception while getting image hash")
59+
continue
60+
except RhodiumAPIError as e:
61+
log.exception("Rhodium API error: %s", e)
62+
continue
63+
except TimeoutError:
64+
log.exception("Timed out getting image hash")
65+
continue
66+
67+
image_hashes.append(image_hash)
68+
69+
if not image_hashes:
70+
return None, [], {}
71+
72+
trigger_ctx = ctx.replace(content=image_hashes)
73+
triggers = await self[ListType.DENY].filter_list_result(trigger_ctx)
74+
if not triggers:
75+
return None, [], {ListType.DENY: triggers}
76+
77+
actions = self[ListType.DENY].merge_actions(triggers)
78+
messages = []
79+
for filter_ in triggers:
80+
distance = ctx.filter_info.get(filter_, "?")
81+
messages.append(
82+
f"{filter_.id} (`{filter_.content}` distance `{distance}`)"
83+
f" - {filter_.description or '*No description*'}"
84+
)
85+
return actions, messages, {ListType.DENY: triggers}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import re
2+
3+
from discord.ext.commands import BadArgument
4+
5+
from bot.exts.filtering._filter_context import FilterContext
6+
from bot.exts.filtering._filters.filter import Filter
7+
from bot.exts.filtering._image_hash import HASH_DISTANCE_THRESHOLD, signed_i64_to_u64
8+
9+
_HEX_RE = re.compile(r"^(?:0x)?([0-9a-fA-F]{1,16})$")
10+
11+
12+
class ImageHashFilter(Filter):
13+
"""A filter which matches image perceptual hashes represented as hexadecimal values."""
14+
15+
name = "image_hash"
16+
17+
async def triggered_on(self, ctx: FilterContext) -> bool:
18+
"""Search for a perceptual hash match within a given context of attachment hashes."""
19+
candidate_hash = int(self.content, 16)
20+
21+
for image_hash in ctx.content:
22+
normalized_image_hash = signed_i64_to_u64(image_hash)
23+
distance = int.bit_count(normalized_image_hash ^ candidate_hash)
24+
if distance <= HASH_DISTANCE_THRESHOLD:
25+
ctx.matches.append(f"{normalized_image_hash:016x}")
26+
ctx.filter_info[self] = str(distance)
27+
return True
28+
return False
29+
30+
@classmethod
31+
async def process_input(cls, content: str, description: str) -> tuple[str, str]:
32+
"""
33+
Process the content and description into a form which will work with the filtering.
34+
35+
A BadArgument should be raised if the content can't be used.
36+
"""
37+
match = _HEX_RE.fullmatch(content.strip())
38+
if not match:
39+
raise BadArgument("Image hash content must be hexadecimal (optionally prefixed with `0x`).")
40+
41+
normalized = f"{int(match.group(1), 16):016x}"
42+
return normalized, description

bot/exts/filtering/_filters/unique/image.py

Lines changed: 0 additions & 108 deletions
This file was deleted.

bot/exts/filtering/_image_hash.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
2+
from bot import instance
3+
from bot.constants import Keys, URLs
4+
5+
# Maximum number of seconds to wait for Rhodium API.
6+
_TIMEOUT = 5
7+
# Maximum perceptual hash difference for a positive prediction.
8+
HASH_DISTANCE_THRESHOLD = 4
9+
10+
11+
class RhodiumAPIError(Exception):
12+
"""Exception raised when the Rhodium API returns an error."""
13+
14+
15+
async def get_image_hash(image_url: str) -> int:
16+
"""Return the signed i64 perceptual hash for an image URL from Rhodium."""
17+
async with instance.http_session.post(
18+
url=URLs.rhodium_api,
19+
headers={"Authorization": f"Bearer {Keys.rhodium}"},
20+
json={"url": image_url},
21+
timeout=_TIMEOUT,
22+
) as response:
23+
if response.status != 200:
24+
contents = await response.text()
25+
raise RhodiumAPIError(f"Rhodium API returned status code {response.status}: {contents}")
26+
27+
response_data = await response.json()
28+
return response_data["i64"]
29+
30+
31+
def signed_i64_to_hex(value: int) -> str:
32+
"""Convert a signed 64-bit integer to a normalized lowercase 16-char hexadecimal string."""
33+
return f"{value & ((1 << 64) - 1):016x}"
34+
35+
36+
def signed_i64_to_u64(value: int) -> int:
37+
"""Convert a signed 64-bit integer into its unsigned 64-bit representation."""
38+
return value & ((1 << 64) - 1)

0 commit comments

Comments
 (0)