diff options
author | 2024-03-26 17:12:34 +0200 | |
---|---|---|
committer | 2024-03-26 17:12:34 +0200 | |
commit | 04d0537b6d5f1235ff459910bab67e047c29ad23 (patch) | |
tree | fbd85c165b2e4098453c051a3cb20aa7df448ac9 | |
parent | Ask for confirmation when banning members with elevated roles (#2316) (diff) | |
parent | Merge branch 'main' into phishing_button (diff) |
Merge pull request #2669 from python-discord/phishing_button
Phishing button
-rw-r--r-- | bot/exts/filtering/_filter_context.py | 2 | ||||
-rw-r--r-- | bot/exts/filtering/_filter_lists/domain.py | 4 | ||||
-rw-r--r-- | bot/exts/filtering/_filter_lists/invite.py | 3 | ||||
-rw-r--r-- | bot/exts/filtering/_filters/invite.py | 10 | ||||
-rw-r--r-- | bot/exts/filtering/_ui/ui.py | 134 |
5 files changed, 145 insertions, 8 deletions
diff --git a/bot/exts/filtering/_filter_context.py b/bot/exts/filtering/_filter_context.py index 31cea0bfd..fbc91a80a 100644 --- a/bot/exts/filtering/_filter_context.py +++ b/bot/exts/filtering/_filter_context.py @@ -11,6 +11,7 @@ from discord import DMChannel, Embed, Member, Message, StageChannel, TextChannel from bot.utils.message_cache import MessageCache if typing.TYPE_CHECKING: + from bot.exts.filtering._filter_lists import FilterList from bot.exts.filtering._filters.filter import Filter from bot.exts.utils.snekbox._io import FileAttachment @@ -51,6 +52,7 @@ class FilterContext: filter_info: dict[Filter, str] = field(default_factory=dict) # Additional info from a filter. messages_deletion: bool = False # Whether the messages were deleted. Can't upload deletion log otherwise. blocked_exts: set[str] = field(default_factory=set) # Any extensions blocked (used for snekbox) + potential_phish: dict[FilterList, set[str]] = field(default_factory=dict) # Additional actions to perform additional_actions: list[Callable[[FilterContext], Coroutine]] = field(default_factory=list) related_messages: set[Message] = field(default_factory=set) # Deletion will include these. diff --git a/bot/exts/filtering/_filter_lists/domain.py b/bot/exts/filtering/_filter_lists/domain.py index 091fd14e0..6b1a568bc 100644 --- a/bot/exts/filtering/_filter_lists/domain.py +++ b/bot/exts/filtering/_filter_lists/domain.py @@ -56,6 +56,10 @@ class DomainsList(FilterList[DomainFilter]): triggers = await self[ListType.DENY].filter_list_result(new_ctx) ctx.notification_domain = new_ctx.notification_domain + unknown_urls = urls - {filter_.content for filter_ in triggers} + if unknown_urls: + ctx.potential_phish[self] = unknown_urls + actions = None messages = [] if triggers: diff --git a/bot/exts/filtering/_filter_lists/invite.py b/bot/exts/filtering/_filter_lists/invite.py index cfc22c56e..b43e1bb7c 100644 --- a/bot/exts/filtering/_filter_lists/invite.py +++ b/bot/exts/filtering/_filter_lists/invite.py @@ -145,6 +145,9 @@ class InviteList(FilterList[InviteFilter]): blocked_invites |= unknown_invites ctx.matches += {match[0] for match in matches if refined_invites.get(match.group("invite")) in blocked_invites} ctx.alert_embeds += (self._guild_embed(invite) for invite in blocked_invites.values() if invite) + if unknown_invites: + ctx.potential_phish[self] = set(unknown_invites) + messages = self[ListType.DENY].format_messages(triggered) messages += [ f"`{code} - {invite.guild.id}`" if invite else f"`{code}`" for code, invite in unknown_invites.items() diff --git a/bot/exts/filtering/_filters/invite.py b/bot/exts/filtering/_filters/invite.py index 799a302b9..d3a2621f0 100644 --- a/bot/exts/filtering/_filters/invite.py +++ b/bot/exts/filtering/_filters/invite.py @@ -1,3 +1,5 @@ +import re + from discord import NotFound from discord.ext.commands import BadArgument from pydis_core.utils.regex import DISCORD_INVITE @@ -33,8 +35,12 @@ class InviteFilter(Filter): """ match = DISCORD_INVITE.fullmatch(content) if not match or not match.group("invite"): - raise BadArgument(f"`{content}` is not a valid Discord invite.") - invite_code = match.group("invite") + if not re.fullmatch(r"\S+", content): + raise BadArgument(f"`{content}` is not a valid Discord invite.") + invite_code = content + else: + invite_code = match.group("invite") + try: invite = await bot.instance.fetch_invite(invite_code) except NotFound: diff --git a/bot/exts/filtering/_ui/ui.py b/bot/exts/filtering/_ui/ui.py index a656553b2..d5b70e944 100644 --- a/bot/exts/filtering/_ui/ui.py +++ b/bot/exts/filtering/_ui/ui.py @@ -2,14 +2,14 @@ from __future__ import annotations import re from abc import ABC, abstractmethod -from collections.abc import Callable, Coroutine, Iterable +from collections.abc import Callable, Coroutine from enum import EnumMeta from functools import partial from typing import Any, TypeVar, get_origin import discord -from discord import Embed, Interaction -from discord.ext.commands import Context, Converter +from discord import Embed, Interaction, Member, User +from discord.ext.commands import BadArgument, Context, Converter from discord.ui.select import MISSING as SELECT_MISSING, SelectOption from discord.utils import escape_markdown from pydis_core.site_api import ResponseCodeError @@ -19,9 +19,10 @@ from pydis_core.utils.members import get_or_fetch_member import bot from bot.constants import Colours -from bot.exts.filtering._filter_context import FilterContext +from bot.exts.filtering._filter_context import Event, FilterContext from bot.exts.filtering._filter_lists import FilterList from bot.exts.filtering._utils import FakeContext, normalize_type +from bot.utils.lock import lock_arg from bot.utils.messages import format_channel, format_user, upload_log log = get_logger(__name__) @@ -82,7 +83,7 @@ async def _build_alert_message_content(ctx: FilterContext, current_message_lengt return alert_content -async def build_mod_alert(ctx: FilterContext, triggered_filters: dict[FilterList, Iterable[str]]) -> Embed: +async def build_mod_alert(ctx: FilterContext, triggered_filters: dict[FilterList, list[str]]) -> Embed: """Build an alert message from the filter context.""" embed = Embed(color=Colours.soft_orange) embed.set_thumbnail(url=ctx.author.display_avatar.url) @@ -531,12 +532,94 @@ class DeleteConfirmationView(discord.ui.View): await interaction.response.edit_message(content="🚫 Operation canceled.", view=None) +class PhishConfirmationView(discord.ui.View): + """Confirmation buttons for whether the alert was for a phishing attempt.""" + + def __init__( + self, mod: Member, offender: User | Member | None, phishing_content: str, target_filter_list: FilterList + ): + super().__init__() + self.mod = mod + self.offender = offender + self.phishing_content = phishing_content + self.target_filter_list = target_filter_list + + async def interaction_check(self, interaction: Interaction) -> bool: + """Only allow interactions from the command invoker.""" + return interaction.user.id == self.mod.id + + @discord.ui.button(label="Do it", style=discord.ButtonStyle.green) + async def confirm(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Auto-ban the user and add the phishing content to the appropriate filter list as an auto-ban filter.""" + await interaction.response.edit_message(view=None) + + if self.offender: + compban_command = bot.instance.get_command("compban") + if not compban_command: + await interaction.followup.send(':warning: Could not find the command "compban".') + else: + ctx = FakeContext(interaction.message, interaction.channel, compban_command, author=self.mod) + await compban_command(ctx, self.offender) + + compf_command = bot.instance.get_command("compfilter") + if not compf_command: + message = ':warning: Could not find the command "compfilter".' + await interaction.followup.send(message) + else: + ctx = FakeContext(interaction.message, interaction.channel, compf_command) + try: + await compf_command(ctx, self.target_filter_list.name, self.phishing_content) + except BadArgument as e: + await interaction.followup.send(f":x: Could not add the filter: {e}") + + + @discord.ui.button(label="Cancel") + async def cancel(self, interaction: Interaction, button: discord.ui.Button) -> None: + """Cancel the operation.""" + new_message_content = f"~~{interaction.message.content}~~ 🚫 Operation canceled." + await interaction.response.edit_message(content=new_message_content, view=None) + +class PhishHandlingButton(discord.ui.Button): + """ + A button that handles a phishing attempt. + + When pressed, ask for confirmation. + If confirmed, comp-ban the offending user, and add the appropriate domain or invite as an auto-ban filter. + """ + + def __init__(self, offender: User | Member | None, phishing_content: str, target_filter_list: FilterList): + super().__init__(emoji="🎣") + self.offender = offender + self.phishing_content = phishing_content + self.target_filter_list = target_filter_list + + @lock_arg("phishing", "interaction", lambda interaction: interaction.message.id) + async def callback(self, interaction: Interaction) -> Any: + """Ask for confirmation for handling the phish.""" + message_content = f"{interaction.user.mention} Is this a phishing attempt? " + if self.offender: + message_content += f"The user {self.offender.mention} will be comp-banned, and " + else: + message_content += "The user was not found, but " + message_content += ( + f"`{escape_markdown(self.phishing_content)}` will be added as an auto-ban filter to the " + f"denied *{self.target_filter_list.name}s* list." + ) + confirmation_view = PhishConfirmationView( + interaction.user, self.offender, self.phishing_content, self.target_filter_list + ) + await interaction.response.send_message(message_content, view=confirmation_view) + + class AlertView(discord.ui.View): """A view providing info about the offending user.""" - def __init__(self, ctx: FilterContext): + def __init__(self, ctx: FilterContext, triggered_filters: dict[FilterList, list[str]] | None = None): super().__init__(timeout=ALERT_VIEW_TIMEOUT) self.ctx = ctx + phishing_content, target_filter_list = self._extract_potential_phish(triggered_filters) + if phishing_content: + self.add_item(PhishHandlingButton(ctx.author, phishing_content, target_filter_list)) @discord.ui.button(label="ID") async def user_id(self, interaction: Interaction, button: discord.ui.Button) -> None: @@ -570,3 +653,42 @@ class AlertView(discord.ui.View): await interaction.response.defer() fake_ctx = FakeContext(interaction.message, interaction.channel, command, author=interaction.user) await command(fake_ctx, self.ctx.author) + + def _extract_potential_phish( + self, triggered_filters: dict[FilterList, list[str]] | None + ) -> tuple[str, FilterList | None]: + """ + Check if the alert is potentially for phishing. + + If it is, return the phishing content and the filter list to add it to. + Otherwise, return an empty string and None. + + A potential phish is a message event where a single invite or domain is found, and nothing else. + Everyone filters are an exception. + """ + if self.ctx.event != Event.MESSAGE or not self.ctx.potential_phish: + return "", None + + if triggered_filters: + for filter_list, messages in triggered_filters.items(): + if messages and (filter_list.name != "unique" or len(messages) > 1 or "everyone" not in messages[0]): + return "", None + + encountered = False + content = "" + target_filter_list = None + for filter_list, content_list in self.ctx.potential_phish.items(): + if len(content_list) > 1: + return "", None + if content_list: + content = next(iter(content_list)) + if filter_list.name == "domain" and "discord" in content: # Leave invites to the invite filterlist. + continue + if encountered: + return "", None + target_filter_list = filter_list + encountered = True + + if encountered: + return content, target_filter_list + return "", None |