# Five-in-one policy for block, report, unfollow and follow reject/remove 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 # Phnt for force follower removal MRF # https://git.fluffytail.org/phnt/pleroma/commit/1c801703297bf41ca5bca84af9e47b514aba995b defmodule Pleroma.Web.ActivityPub.MRF.HighRollerPolicy do alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Config alias Pleroma.Activity require Logger @moduledoc "Notify local users upon the block, report or unfollow." @behaviour Pleroma.Web.ActivityPub.MRF.Policy defp check_cache(message, actor, recepient, action) do with {:error, _} <- Cachex.stats(:highroller), do: Cachex.start(:highroller, [ stats: true ]) systime = :os.system_time(:seconds) {_, actiontime} = Cachex.fetch(:highroller, actor<>","<>recepient<>","<>action, fn(_i) -> {:commit, :os.system_time(:seconds)-1} end) {_, globalcount} = Cachex.fetch(:highroller, "global:"<>actor, fn(_i) -> {:commit, 0} end) if (systime > actiontime && globalcount < Config.get([:mrf_high_roller, :global_threshold])) do Cachex.incr(:highroller, "global:"<>actor, globalcount+1) Cachex.put(:highroller, actor<>","<>recepient<>","<>action, systime+Config.get([:mrf_high_roller, :timeout])) true else Logger.warning("Rate-limited incoming "<>action<>" notif! #{inspect(message)}") Cachex.incr(:highroller, "global:"<>actor, globalcount+1) Cachex.incr(:highroller, actor<>","<>recepient<>","<>action, 30*(1+(systime-actiontime))) false end end defp check_action(%{"type" => "Reject", "object" => object}) do activity = Activity.normalize(object) case activity.data do %{"type" => "Follow", "state" => "accept"} -> {true, "follow_remove", Config.get([:mrf_high_roller, :follow_remove_message]), Config.get([:mrf_high_roller, :follow_remove_visibility])} %{"type" => "Follow", "state" => "pending"} -> {true, "follow_reject", Config.get([:mrf_high_roller, :follow_reject_message]), Config.get([:mrf_high_roller, :follow_reject_visibility])} _ -> {false, nil, nil, nil} end end defp check_action(message) do case message do %{"type" => "Block"} -> {true, "block", Config.get([:mrf_high_roller, :block_message]), Config.get([:mrf_high_roller, :block_visibility])} %{"type" => "Flag"} -> {true, "report", Config.get([:mrf_high_roller, :report_message]), Config.get([:mrf_high_roller, :report_visibility])} %{"type" => "Undo", "object" => %{"type" => "Block"}} -> {true, "unblock", Config.get([:mrf_high_roller, :unblock_message]), Config.get([:mrf_high_roller, :unblock_visibility])} %{"type" => "Undo", "object" => %{"type" => "Follow"}} -> {true, "unfollow", Config.get([:mrf_high_roller, :unfollow_message]), Config.get([:mrf_high_roller, :unfollow_visibility])} _ -> {false, nil, nil, nil} end end 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 check_recepient(action, message) when action == "report" do with {true, _, to} <- is_report(message) do User.get_cached_by_ap_id(to) end end defp check_recepient(_, %{"type" => "Undo", "object" => %{"object" => object}}) do User.get_cached_by_ap_id(object) end defp check_recepient(_, %{"type" => "Reject", "to" => to}) do User.get_cached_by_ap_id(to) end defp check_recepient(_, %{"object" => object}) do User.get_cached_by_ap_id(object) end defp check_recepient(_, _) do nil end defp check_tag(host, actor, action) do mention = case action do "block" -> Config.get([:mrf_high_roller, :tag_blocking_actor]) "unblock" -> Config.get([:mrf_high_roller, :tag_unblocking_actor]) "report" -> Config.get([:mrf_high_roller, :tag_reporting_actor]) "unfollow" -> Config.get([:mrf_high_roller, :tag_unfollowing_actor]) "follow_remove" -> Config.get([:mrf_high_roller, :tag_follow_remove_actor]) "follow_reject" -> Config.get([:mrf_high_roller, :tag_follow_reject_actor]) _ -> false end if(mention && !Enum.member?(Config.get([:mrf_high_roller, :domain_greylist]), host)) do "@" <> actor.nickname else actor.nickname end end defp extract_reported_post(post) do case post do %{"id" => id} -> id s when is_binary(s) -> s _ -> nil end end @impl true def filter(%{"type" => type} = message) when type in ["Block", "Undo", "Flag", "Reject"] do with {true, action, template, visibility} <- check_action(message), %User{} = actor <- User.get_cached_by_ap_id(message["actor"]), %User{} = recepient <- check_recepient(action, message), tag <- check_tag(URI.parse(message["actor"]).host, actor, action), 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 <- recepient.local, true <- check_cache(message, actor.nickname, recepient.nickname, action) do replacements = %{ "actor" => tag, "target" => "@" <> recepient.nickname } msg = Regex.replace( ~r/{([a-z]+)?}/, template, fn _, match -> replacements[match] end ) info = with {true, objects, _} <- is_report(message) do 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 comment <> posts else _ -> "" end CommonAPI.post(User.get_cached_by_nickname(Config.get([:mrf_high_roller, :user])), %{ status: msg <> info, visibility: visibility }) end {:ok, message} end @impl true def filter(message), do: {:ok, message} @impl true def config_description do %{ key: :mrf_high_roller, related_policy: "Pleroma.Web.ActivityPub.MRF.HighRollerPolicy", label: "High Roller Policy", description: "Five-in-one policy for notifying users upon being blocked, unfollowed, reported, having their follow rejected or force-removed", 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; use {actor} and {target} variables", suggestions: ["{target} you have been blocked 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: :unblock_message, type: :string, label: "Unblock message", description: "The message to send when someone is blocked or unblocked; use {actor} and {target} variables", suggestions: ["{target} you have been unblocked by {actor}"] }, %{ key: :tag_unblocking_actor, type: :boolean, label: "Tag unblocking actor", description: "Whether to tag the unblocking actor or not" }, %{ key: :unblock_visibility, type: :string, label: "Unblock visibility", description: "Visibility of the unblock 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"] }, %{ key: :follow_remove_message, type: :string, label: "Follower removal message", description: "The message to send when someone is being removed from followers; use {actor} and {target} variables", suggestions: ["{target} you have been removed from followers by {actor}"] }, %{ key: :tag_follow_remove_actor, type: :boolean, label: "Tag actor removing follower", description: "Whether to tag the actor removing followers or not" }, %{ key: :follow_remove_visibility, type: :string, label: "Follower removal visibility", description: "Visibility of the follower removal notification", suggestions: ["public", "unlisted", "private", "direct"] }, %{ key: :follow_reject_message, type: :string, label: "Follower rejection message", description: "The message to send when someone is being rejectd from followers; use {actor} and {target} variables", suggestions: ["{target} your follow request has been rejected by {actor}"] }, %{ key: :tag_follow_reject_actor, type: :boolean, label: "Tag actor removing follower", description: "Whether to tag the actor removing followers or not" }, %{ key: :follow_reject_visibility, type: :string, label: "Follower rejection visibility", description: "Visibility of the follower rejection notification", suggestions: ["public", "unlisted", "private", "direct"] } ] } end @impl true def describe, do: {:ok, %{}} end