-# Three-in-one policy for block, report and unfollow notifications.
+# 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
# 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 is_block_or_unblock(%{"type" => "Block", "object" => object}),
- do: {true, "blocked", object}
+ defp check_cache(message, actor, recepient, action) do
+ with {:error, _} <- Cachex.stats(:highroller), do: Cachex.start(:highroller, [ stats: true ])
+ systime = :os.system_time(:seconds)
- defp is_block_or_unblock(%{
- "type" => "Undo",
- "object" => %{"type" => "Block", "object" => object}
- }),
- do: {true, "unblocked", object}
+ {_, 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 is_block_or_unblock(_), do: {false, nil, nil}
+ 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
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
+ 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 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)
+ defp check_recepient(_, %{"type" => "Undo", "object" => %{"object" => object}}) do
+ User.get_cached_by_ap_id(object)
+ end
- 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
+ defp check_recepient(_, %{"type" => "Reject", "to" => to}) do
+ User.get_cached_by_ap_id(to)
+ end
- {_, 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)
+ defp check_recepient(_, %{"object" => object}) do
+ User.get_cached_by_ap_id(object)
+ 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
+ defp check_recepient(_, _) do
+ nil
+ end
- replacements = %{
- "actor" => blocker,
- "target" => "@" <> recipient.nickname,
- "action" => action
- }
+ 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
- msg =
- Regex.replace(
- ~r/{([a-z]+)?}/,
- Config.get([:mrf_high_roller, :block_message]),
- fn _, match ->
- replacements[match]
- end
- )
+ if(mention && !Enum.member?(Config.get([:mrf_high_roller, :domain_greylist]), host)) do
+ "@" <> actor.nickname
+ else
+ actor.nickname
+ end
+ 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
+ defp extract_reported_post(post) do
+ case post do
+ %{"id" => id} -> id
+ s when is_binary(s) -> s
+ _ -> nil
end
+ end
- with {true, objects, to} <- is_report(message),
+ @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{} = recipient <- User.get_cached_by_ap_id(to),
+ %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 <- 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
+ true <- recepient.local,
+ true <- check_cache(message, actor.nickname, recepient.nickname, action) do
replacements = %{
- "actor" => reporter,
- "target" => "@" <> recipient.nickname
+ "actor" => tag,
+ "target" => "@" <> recepient.nickname
}
msg =
Regex.replace(
~r/{([a-z]+)?}/,
- Pleroma.Config.get([:mrf_high_roller, :report_message]),
+ template,
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]
+ 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
- )
- 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])
- })
+ comment <> posts
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
+
+ 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: "Three-in-one policy for notifying users upon being blocked, unfollowed or reported",
+ 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: "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}"]
+ "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,
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: "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"]
}
]
}