Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 82 additions & 14 deletions src/cogs/scam_image_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,18 @@
"drawing",
"drawn",
)
STOCK_MARKET_TERMS = (
"stock",
"share price",
"nyse",
"nasdaq",
"ticker",
"market cap",
"after hours",
"portfolio",
"shares",
"ytd",
)
NSFW_CLASSES = {
"ANUS_EXPOSED",
"BUTTOCKS_EXPOSED",
Expand Down Expand Up @@ -188,8 +200,12 @@ def normalize_text(value: Optional[str]) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip().lower()


def contains_term(text: str, term: str) -> bool:
return re.search(rf"(?<!\w){re.escape(term)}(?!\w)", text) is not None


def contains_any(text: str, terms: Iterable[str]) -> bool:
return any(term in text for term in terms)
return any(contains_term(text, term) for term in terms)


def score_to_level(score: int) -> str:
Expand Down Expand Up @@ -223,7 +239,8 @@ def assess_scam_text(
has_brand = contains_any(normalized, BRAND_TERMS)
has_money = bool(MONEY_RE.search(normalized))
has_hook = bool(HOOK_RE.search(normalized))
has_giveaway = contains_any(normalized, GIVEAWAY_TERMS) or has_money or has_hook
has_reward_language = contains_any(normalized, GIVEAWAY_TERMS)
has_giveaway = has_reward_language or has_money or has_hook
has_strong_action = contains_any(normalized, STRONG_ACTION_TERMS)
has_weak_action = contains_any(normalized, WEAK_ACTION_TERMS)
has_dm_lure = bool(DM_LURE_RE.search(normalized))
Expand Down Expand Up @@ -290,6 +307,19 @@ def assess_scam_text(
score = max(0, score - 25)
reasons.append("looks like a joke/parody without a link, QR code, or account action")

stock_context_signals = sum(contains_term(normalized, term) for term in STOCK_MARKET_TERMS)
if (
stock_context_signals >= 2
and not has_reward_language
and not has_hook
and not has_strong_action
and not has_dm_lure
and not has_off_platform
and not has_qr
):
score = min(score, REVIEW_THRESHOLD - 1)
reasons.append("looks like stock market information without a solicitation")

score = min(score, 100)
if score < REVIEW_THRESHOLD and not reasons:
reasons.append("no strong scam signals found")
Expand Down Expand Up @@ -745,9 +775,7 @@ def build_alert_embed(self, message: nextcord.Message, assessment: ScamAssessmen

embed = nextcord.Embed(
title="Possible Unsafe Message Detected",
description=(
"This was forwarded for human review. The bot did not delete the original message or re-upload media."
),
description="This was forwarded for human review. The bot did not delete the original message.",
color=nextcord.Color.orange(),
)
embed.add_field(name="User", value=f"{author_name} (`{author_id}`)", inline=False)
Expand All @@ -756,9 +784,6 @@ def build_alert_embed(self, message: nextcord.Message, assessment: ScamAssessmen
embed.add_field(name="Reasons", value=clip("\n".join(f"- {reason}" for reason in assessment.reasons), 1000), inline=False)
if original_content:
embed.add_field(name="Original Text", value=clip(original_content, 1000), inline=False)
if assessment.scanned_text:
embed.add_field(name="Scanned Text", value=clip(f"`{assessment.scanned_text}`", 1000), inline=False)

jump_url = getattr(message, "jump_url", None)
if jump_url:
embed.add_field(name="Message", value=f"[Jump to message]({jump_url})", inline=False)
Expand All @@ -768,6 +793,25 @@ def build_alert_embed(self, message: nextcord.Message, assessment: ScamAssessmen

return embed

async def get_forward_files(self, message: nextcord.Message) -> tuple[list[nextcord.File], Optional[str]]:
files = []
preview_filename = None

for attachment in (getattr(message, "attachments", []) or [])[:10]:
if not (is_visual_attachment(attachment) or is_video_attachment(attachment)):
continue

try:
forwarded_file = await attachment.to_file(use_cached=True)
except (AttributeError, nextcord.Forbidden, nextcord.NotFound, nextcord.HTTPException):
continue

files.append(forwarded_file)
if preview_filename is None and is_visual_attachment(attachment):
preview_filename = forwarded_file.filename

return files, preview_filename

@commands.Cog.listener()
async def on_message(self, message: nextcord.Message) -> None:
if not self.is_enabled_for_current_bot():
Expand Down Expand Up @@ -795,14 +839,38 @@ async def on_message(self, message: nextcord.Message) -> None:
return

embed = self.build_alert_embed(message, assessment)
files, preview_filename = await self.get_forward_files(message)
media_urls = visual_urls_from_message(message)
content = "\n".join(media_urls) or None
if preview_filename:
embed.set_image(url=f"attachment://{preview_filename}")

send_kwargs = {
"content": content,
"embed": embed,
"allowed_mentions": nextcord.AllowedMentions.none(),
}
if files:
send_kwargs["files"] = files

try:
await alert_channel.send(
content=None,
embed=embed,
allowed_mentions=nextcord.AllowedMentions.none(),
)
except (nextcord.Forbidden, nextcord.HTTPException) as exc:
await alert_channel.send(**send_kwargs)
except nextcord.Forbidden as exc:
log.warning("Unable to forward message %s: %s", getattr(message, "id", "unknown"), exc)
except nextcord.HTTPException as exc:
if not files:
log.warning("Unable to forward message %s: %s", getattr(message, "id", "unknown"), exc)
return

embed.remove_image()
try:
await alert_channel.send(
content=content,
embed=embed,
allowed_mentions=nextcord.AllowedMentions.none(),
)
except (nextcord.Forbidden, nextcord.HTTPException) as retry_exc:
log.warning("Unable to forward message %s: %s", getattr(message, "id", "unknown"), retry_exc)


def setup(bot: APBot) -> None:
Expand Down
49 changes: 49 additions & 0 deletions tests/test_scam_image_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def attachment(filename="scam.png", content_type="image/png", url="https://cdn.d
proxy_url=None,
size=1024,
read=AsyncMock(return_value=b""),
to_file=AsyncMock(return_value=SimpleNamespace(filename=filename)),
)


Expand Down Expand Up @@ -79,6 +80,25 @@ def test_ocr_mrbeast_claim_scam_alerts_without_message_text():
assert assessment.should_alert is True


def test_stock_price_screenshot_is_not_flagged():
assessment = assess_scam_text(
"""
<@532332835051143168> after roblox kids released
image.png
These are results for roblox stock
Roblox Corp NYSE RBLX 51.53 USD
Closed Jun 18 7:59 PM EDT Disclaimer
NVIDIA Corp After hours 51.70 0.33% 210.69 USD YTD
https://finance.example.com/stock
""",
has_visual_attachment=True,
)

assert assessment.should_alert is False
assert assessment.score < ALERT_THRESHOLD
assert "asks users to claim, verify, log in, scan, or enter sensitive info" not in assessment.reasons


def test_mrbeast_free_money_page_ad_alerts_without_url():
assessment = assess_scam_text(
"Ad MRBEAST everyone that visits our page gets $500 You're eligible for free $500",
Expand Down Expand Up @@ -159,6 +179,9 @@ def test_on_message_forwards_alert_without_ping_or_deletion():
assert review_channel.send.await_args.kwargs["content"] is None
assert review_channel.send.await_args.kwargs["allowed_mentions"].users is False
assert review_channel.send.await_args.kwargs["embed"].title == "Possible Unsafe Message Detected"
assert review_channel.send.await_args.kwargs["files"][0].filename == "scam.png"
assert review_channel.send.await_args.kwargs["embed"].image.url == "attachment://scam.png"
assert all(field.name != "Scanned Text" for field in review_channel.send.await_args.kwargs["embed"].fields)
logs_channel.send.assert_not_awaited()
message_channel.send.assert_not_awaited()
message.delete.assert_not_awaited()
Expand Down Expand Up @@ -273,6 +296,31 @@ def test_direct_image_link_counts_as_visual_message():
assert assessment.should_alert is True


def test_direct_gif_link_is_forwarded_for_preview():
review_channel = SimpleNamespace(id=REVIEW_CHANNEL_ID, send=AsyncMock())
bot = SimpleNamespace(
user=SimpleNamespace(id=next(iter(DEFAULT_ENABLED_BOT_IDS))),
get_channel=lambda channel_id: review_channel if channel_id == REVIEW_CHANNEL_ID else None,
)
cog = ScamImageDetector(bot)
gif_url = "https://tenor.com/view/fake-mrbeast-giveaway-gif-123"
message = SimpleNamespace(
id=999,
guild=SimpleNamespace(id=1),
author=SimpleNamespace(id=111, bot=False),
channel=SimpleNamespace(id=123, name="general"),
content=f"MrBeast giveaway claim $500 verify now {gif_url}",
attachments=[],
embeds=[],
jump_url="https://discord.com/channels/1/123/999",
)

asyncio.run(cog.on_message(message))

review_channel.send.assert_awaited_once()
assert review_channel.send.await_args.kwargs["content"] == gif_url


def test_plain_scam_text_is_forwarded_without_source_channel_marker():
review_channel = SimpleNamespace(id=REVIEW_CHANNEL_ID, send=AsyncMock())
bot = SimpleNamespace(
Expand Down Expand Up @@ -374,6 +422,7 @@ def test_explicit_video_is_forwarded_without_source_channel_marker():
message_channel.send.assert_not_awaited()
logs_channel.send.assert_not_awaited()
review_channel.send.assert_awaited_once()
assert review_channel.send.await_args.kwargs["files"][0].filename == "clip.mp4"


def test_dangerous_message_forwards_to_fixed_review_channel():
Expand Down
Loading