# Three-in-one policy for block, report and unfollow notifications. # Credits for the individual parts: # Yukkuri for the combined block/report MRF # https://gitlab.eientei.org/eientei/pleroma/-/blob/eientei/lib/pleroma/web/activity_pub/mrf/block_bot_policy.ex # NEETzsche for the block MRF with configurable message # https://gitlab.com/soapbox-pub/rebased/-/blob/develop/lib/pleroma/web/activity_pub/mrf/block_notification_policy.ex # Nekobit for the unfollow MRF (nigga nuked his account at the time, linking cached version) # https://eientei.org/notice/AL6nnjih8H6Lco8QoS # Pete for the example of Cachex-based rate limiting # https://freespeechextremist.com/objects/9f24a3e4-2e34-4fcb-a0d1-42229e27da3e defmodule Pleroma.Web.ActivityPub.MRF.HighRollerPolicy do alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Config require Logger @moduledoc "Notify local users upon the block, report or unfollow." @behaviour Pleroma.Web.ActivityPub.MRF.Policy defp is_block_or_unblock(%{"type" => "Block", "object" => object}), do: {true, "blocked", object} defp is_block_or_unblock(%{ "type" => "Undo", "object" => %{"type" => "Block", "object" => object} }), do: {true, "unblocked", object} defp is_block_or_unblock(_), do: {false, nil, nil} defp is_report(%{"type" => "Flag", "object" => objects}) do case objects do [head | tail] when is_binary(head) -> {true, tail, head} s when is_binary(s) -> {true, [], s} _ -> {true, [], nil} end end defp is_report(_), do: {false, [], nil} defp extract_reported_post(post) do case post do %{"id" => id} -> id s when is_binary(s) -> s _ -> nil end end defp is_unfollow(%{ "type" => "Undo", "object" => %{"type" => "Follow", "object" => object} }), do: {true, object} defp is_unfollow(_), do: {false, nil, nil} @impl true def filter(message) do with {:error, _} <- Cachex.stats(:highroller), do: Cachex.start(:highroller, [ stats: true ]) systime = :os.system_time(:seconds) with {true, action, object} <- is_block_or_unblock(message), %User{} = actor <- User.get_cached_by_ap_id(message["actor"]), %User{} = recipient <- User.get_cached_by_ap_id(object), false <- Enum.member?(Config.get([:mrf_high_roller, :actor_blacklist]), message["actor"]), false <- Enum.member?(Config.get([:mrf_high_roller, :domain_blacklist]), URI.parse(message["actor"]).host), true <- recipient.local do {_, actiontime} = Cachex.fetch(:highroller, actor.nickname<>","<>recipient.nickname<>","<>action, fn(_i) -> {:commit, :os.system_time(:seconds)-1} end) {_, globalcount} = Cachex.fetch(:highroller, "global:"<>actor.nickname, fn(_i) -> {:commit, 0} end) blocker = if(Config.get([:mrf_high_roller, :tag_blocking_actor]) && !Enum.member?(Config.get([:mrf_high_roller, :domain_greylist]), URI.parse(message["actor"]).host)) do "@" <> actor.nickname else actor.nickname end replacements = %{ "actor" => blocker, "target" => "@" <> recipient.nickname, "action" => action } msg = Regex.replace( ~r/{([a-z]+)?}/, Config.get([:mrf_high_roller, :block_message]), fn _, match -> replacements[match] end ) if (systime > actiontime && globalcount < Config.get([:mrf_high_roller, :global_threshold])) do Cachex.incr(:highroller, "global:"<>actor.nickname, globalcount+1) Cachex.put(:highroller, actor.nickname<>","<>recipient.nickname<>","<>action, systime+Config.get([:mrf_high_roller, :timeout])) CommonAPI.post(User.get_cached_by_nickname(Config.get([:mrf_high_roller, :user])), %{ status: msg, visibility: Config.get([:mrf_high_roller, :block_visibility]) }) else Logger.warn("Rate-limited incoming block notif! #{inspect(message)}") Cachex.incr(:highroller, "global:"<>actor.nickname, globalcount+1) Cachex.incr(:highroller, actor.nickname<>","<>recipient.nickname<>","<>action, 30*(1+(systime-actiontime))) end end with {true, objects, to} <- is_report(message), %User{} = actor <- User.get_cached_by_ap_id(message["actor"]), %User{} = recipient <- User.get_cached_by_ap_id(to), false <- Enum.member?(Config.get([:mrf_high_roller, :actor_blacklist]), message["actor"]), false <- Enum.member?(Config.get([:mrf_high_roller, :domain_blacklist]), URI.parse(message["actor"]).host), true <- recipient.local do {_, actiontime} = Cachex.fetch(:highroller, actor.nickname<>","<>recipient.nickname<>",report", fn(_i) -> {:commit, :os.system_time(:seconds)-1} end) {_, globalcount} = Cachex.fetch(:highroller, "global:"<>actor.nickname, fn(_i) -> {:commit, 0} end) reporter = if(Config.get([:mrf_high_roller, :tag_reporting_actor]) && !Enum.member?(Config.get([:mrf_high_roller, :domain_greylist]), URI.parse(message["actor"]).host)) do "@" <> actor.nickname else actor.nickname end replacements = %{ "actor" => reporter, "target" => "@" <> recipient.nickname } msg = Regex.replace( ~r/{([a-z]+)?}/, Pleroma.Config.get([:mrf_high_roller, :report_message]), fn _, match -> replacements[match] end ) posts = objects |> Enum.map(&extract_reported_post/1) |> Enum.reject(&is_nil/1) |> Enum.map(fn s -> "- " <> s end) |> Enum.join("\n") |> (fn s -> case s do "" -> "" s -> "\n\nReported objects:\n" <> s end end).() comment = case message["content"] do "" -> "" s when is_binary(s) -> "\n\nReport message:\n" <> s _ -> "" end if (systime > actiontime && globalcount < Config.get([:mrf_high_roller, :global_threshold])) do Cachex.incr(:highroller, "global:"<>actor.nickname, globalcount+1) Cachex.put(:highroller, actor.nickname<>","<>recipient.nickname<>",report", systime+Config.get([:mrf_high_roller, :timeout])) CommonAPI.post(User.get_cached_by_nickname(Config.get([:mrf_high_roller, :user])), %{ status: msg <> comment <> posts, visibility: Config.get([:mrf_high_roller, :report_visibility]) }) else Logger.warn("Rate-limited incoming report notif! #{inspect(message)}") Cachex.incr(:highroller, "global:"<>actor.nickname, globalcount+1) Cachex.incr(:highroller, actor.nickname<>","<>recipient.nickname<>",report", 30*(1+(systime-actiontime))) end end with {true, object} <- is_unfollow(message), %User{} = actor <- User.get_cached_by_ap_id(message["actor"]), %User{} = recipient <- User.get_cached_by_ap_id(object), false <- Enum.member?(Config.get([:mrf_high_roller, :actor_blacklist]), message["actor"]), false <- Enum.member?(Config.get([:mrf_high_roller, :domain_blacklist]), URI.parse(message["actor"]).host), true <- recipient.local do {_, actiontime} = Cachex.fetch(:highroller, actor.nickname<>","<>recipient.nickname<>",unfollow", fn(_i) -> {:commit, :os.system_time(:seconds)-1} end) {_, globalcount} = Cachex.fetch(:highroller, "global:"<>actor.nickname, fn(_i) -> {:commit, 0} end) unfollower = if(Config.get([:mrf_high_roller, :tag_unfollowing_actor]) && !Enum.member?(Config.get([:mrf_high_roller, :domain_greylist]), URI.parse(message["actor"]).host)) do "@" <> actor.nickname else actor.nickname end replacements = %{ "actor" => unfollower, "target" => "@" <> recipient.nickname } msg = Regex.replace( ~r/{([a-z]+)?}/, Pleroma.Config.get([:mrf_high_roller, :unfollow_message]), fn _, match -> replacements[match] end ) if (systime > actiontime && globalcount < Config.get([:mrf_high_roller, :global_threshold])) do Cachex.incr(:highroller, "global:"<>actor.nickname, globalcount+1) Cachex.put(:highroller, actor.nickname<>","<>recipient.nickname<>",unfollow", systime+Config.get([:mrf_high_roller, :timeout])) CommonAPI.post(User.get_cached_by_nickname(Config.get([:mrf_high_roller, :user])), %{ status: msg, visibility: Config.get([:mrf_high_roller, :unfollow_visibility]) }) else Logger.warn("Rate-limited incoming unfollow notif! #{inspect(message)}") Cachex.incr(:highroller, "global:"<>actor.nickname, globalcount+1) Cachex.incr(:highroller, actor.nickname<>","<>recipient.nickname<>",unfollow", 30*(1+(systime-actiontime))) end end {:ok, message} end @impl true def config_description do %{ key: :mrf_high_roller, related_policy: "Pleroma.Web.ActivityPub.MRF.HighRollerPolicy", label: "High Roller Policy", description: "Three-in-one policy for notifying users upon being blocked, unfollowed or reported", children: [ %{ key: :user, type: :string, label: "Account", description: "Account from which notifications would be sent", suggestions: ["blockbot"] }, %{ key: :timeout, type: :integer, label: "Timeout", description: "Timeout (in seconds) between which no new notifications of the same type can be sent", suggestions: [60] }, %{ key: :global_threshold, type: :integer, label: "Global threshold", description: "Global threshold of the actions for the actor", suggestions: [5] }, %{ key: :actor_blacklist, type: {:list, :string}, label: "Actor blacklist", description: "List of actors to skip sending a notification about", suggestions: ["https://freespeechextremist.com/users/p"] }, %{ key: :domain_greylist, type: {:list, :string}, label: "Domain greylist", description: "List of domains to exclude their users from being tagged", suggestions: ["freespeechextremist.com"] }, %{ key: :domain_blacklist, type: {:list, :string}, label: "Domain blacklist", description: "List of domains to skip sending a notification about", suggestions: ["freespeechextremist.com"] }, %{ key: :block_message, type: :string, label: "Block message", description: "The message to send when someone is blocked or unblocked; use {actor}, {target}, and {action} variables", suggestions: ["{target} you have been {action} by {actor}"] }, %{ key: :tag_blocking_actor, type: :boolean, label: "Tag blocking actor", description: "Whether to tag the blocking actor or not" }, %{ key: :block_visibility, type: :string, label: "Block visibility", description: "Visibility of the block notification", suggestions: ["public", "unlisted", "private", "direct"] }, %{ key: :report_message, type: :string, label: "Report message", description: "The message to send when someone is reported; use {actor} and {target} variables", suggestions: ["{target} you have been reported by {actor}"] }, %{ key: :tag_reporting_actor, type: :boolean, label: "Tag reporting actor", description: "Whether to tag the reporting actor or not" }, %{ key: :report_visibility, type: :string, label: "Report visibility", description: "Visibility of the report notification", suggestions: ["public", "unlisted", "private", "direct"] }, %{ key: :unfollow_message, type: :string, label: "Unfollow message", description: "The message to send when someone is unfollowed; use {actor} and {target} variables", suggestions: ["{target} you have been unfollowed by {actor}"] }, %{ key: :tag_unfollowing_actor, type: :boolean, label: "Tag unfollowing actor", description: "Whether to tag the unfollowing actor or not" }, %{ key: :unfollow_visibility, type: :string, label: "Unfollow visibility", description: "Visibility of the unfollow notification", suggestions: ["public", "unlisted", "private", "direct"] } ] } end @impl true def describe, do: {:ok, %{}} end