diff --git a/src/cogs/scam_image_detector.py b/src/cogs/scam_image_detector.py index 18d7114..e5dc450 100644 --- a/src/cogs/scam_image_detector.py +++ b/src/cogs/scam_image_detector.py @@ -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", @@ -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"(? 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: @@ -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)) @@ -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") @@ -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) @@ -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) @@ -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(): @@ -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: diff --git a/tests/test_scam_image_detector.py b/tests/test_scam_image_detector.py index a1de2f6..97ec5cd 100644 --- a/tests/test_scam_image_detector.py +++ b/tests/test_scam_image_detector.py @@ -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)), ) @@ -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", @@ -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() @@ -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( @@ -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():