Skip to content

Commit 8c6d683

Browse files
swfarnsworthjb3
andauthored
New UniqueFilter for images associated with compromised accounts. (#3519)
* New UniqueFilter for images associated with compromised accounts. Currently, the perceptual hashes of known images are hard-coded, but this is a temporary solution. * Use `asyncio.to_thread` to make expensive actions awaitable. `Image.open` and `imagehash.phash` are now awaitable. * Handle case that `attachment.content_type` is None * Handle (and ignore) exceptions from reading file attachments. * Only consider attachments less than 30mb * Add comments describing the image associated with each perceptual hash. * Use Rhodium API instead of PIL, imagehash to load and hash images, removing them as dependencies. * Change attachment limit to 5mb * Store Rhodium API URL in constants.py; use http session from the bot instance. * refactoring hash matching logic * Add logging for exceptions * Add two new perceptual hashes * Add Rhodium API key to Keys constant * Misc changes to image filter implementation - Switch to using Keys constant for Rhodium API - Increase timeout to 5 seconds - Implement new RhodiumAPIError - Switch to sending JSON to the API - Handle errors more gracefully and return the error detail - Handle regular ClientErrors - Handle TimeoutError with the correct exception type * Use log.exception instead of log.error --------- Co-authored-by: Joe Banks <joe@jb3.dev>
1 parent 8ca62c7 commit 8c6d683

2 files changed

Lines changed: 95 additions & 0 deletions

File tree

bot/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,8 @@ class _URLs(_BaseURLs):
465465

466466
site_logs_view: str = "https://pythondiscord.com/staff/bot/logs"
467467

468+
rhodium_api: str = "https://rhodium.python-discord.workers.dev/"
469+
468470

469471
URLs = _URLs()
470472

@@ -593,6 +595,7 @@ class _Keys(EnvConfig, env_prefix="api_keys_"):
593595

594596
github: str = ""
595597
site_api: str = ""
598+
rhodium: str = ""
596599

597600

598601
Keys = _Keys()
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
2+
import aiohttp
3+
4+
from bot import instance
5+
from bot.constants import Keys, URLs
6+
from bot.exts.filtering._filter_context import Event, FilterContext
7+
from bot.exts.filtering._filters.filter import UniqueFilter
8+
from bot.log import get_logger
9+
10+
log = get_logger(__name__)
11+
12+
# Maximum perceptual hash difference for positive predictions
13+
_THRESHOLD = 4
14+
# Maximum number of seconds to wait for Rhodium API
15+
_TIMEOUT = 5
16+
17+
_KNOWN_IMAGE_HASHES = [
18+
# A camera-taken image of a tweet attributed to @MrBeast about the purported launch of a crypto casino;
19+
# there is a URL in the image that varies by instance
20+
219481626328303491,
21+
# An image saying "Activate Code for Bonus!"
22+
6997610946676476306,
23+
# An image saying "Withdrawal Success!"
24+
-9135984495352994088,
25+
# A collage of four images, the first being a purported tweet from Elon Musk about the opening a crypto casino,
26+
# and the rest of similar character to the previous two
27+
231962884035511073,
28+
# Text centered on a background of a field and sky, the text saying "I've helped 15+ people earn ...
29+
# in stock market and crypto trading"
30+
360569449461317633,
31+
]
32+
33+
34+
def _is_match(image_hash: int) -> bool:
35+
return any(
36+
int.bit_count(image_hash ^ candidate_hash) <= _THRESHOLD
37+
for candidate_hash in _KNOWN_IMAGE_HASHES
38+
)
39+
40+
class RhodiumAPIError(Exception):
41+
"""Exception raised when the Rhodium API returns an error."""
42+
43+
44+
async def _get_hash(image_url: str) -> int:
45+
async with instance.http_session.post(
46+
url=URLs.rhodium_api,
47+
headers={"Authorization": f"Bearer {Keys.rhodium}"},
48+
json={"url": image_url},
49+
timeout=_TIMEOUT,
50+
) as response:
51+
if response.status != 200:
52+
contents = await response.text()
53+
54+
raise RhodiumAPIError(f"Rhodium API returned status code {response.status}: {contents}")
55+
56+
response_data = await response.json()
57+
return response_data["i64"]
58+
59+
60+
class ImageFilter(UniqueFilter):
61+
"""Filter messages that contain an image attachment whose perceptual hash matches images associated with scams."""
62+
63+
name = "image"
64+
events = (Event.MESSAGE, )
65+
66+
async def triggered_on(self, ctx: FilterContext) -> bool:
67+
"""Return whether the message has an attached image that is known to be posted by compromised accounts."""
68+
log.trace("Entering image filter")
69+
for attachment in ctx.attachments:
70+
if (
71+
attachment.content_type is None
72+
or not attachment.content_type.startswith("image")
73+
or attachment.size > 5e6 # 5mb
74+
):
75+
continue
76+
77+
try:
78+
image_hash = await _get_hash(attachment.url)
79+
except aiohttp.ClientError:
80+
log.exception("Unhandled aiohttp exception while getting image hash")
81+
return False
82+
except RhodiumAPIError as e:
83+
log.exception("Rhodium API error: %s", e)
84+
return False
85+
except TimeoutError:
86+
log.exception("Timed out getting image hash")
87+
return False
88+
89+
if _is_match(image_hash):
90+
return True
91+
92+
return False

0 commit comments

Comments
 (0)