First
[anni] / static / modules / high_roller_policy.ex
1 # Three-in-one policy for block, report and unfollow notifications.
2 # Credits for the individual parts:
3 # Yukkuri for the combined block/report MRF
4 # https://gitlab.eientei.org/eientei/pleroma/-/blob/eientei/lib/pleroma/web/activity_pub/mrf/block_bot_policy.ex
5 # NEETzsche for the block MRF with configurable message
6 # https://gitlab.com/soapbox-pub/rebased/-/blob/develop/lib/pleroma/web/activity_pub/mrf/block_notification_policy.ex
7 # Nekobit for the unfollow MRF (nigga nuked his account at the time, linking cached version)
8 # https://eientei.org/notice/AL6nnjih8H6Lco8QoS
9 # Pete for the example of Cachex-based rate limiting
10 # https://freespeechextremist.com/objects/9f24a3e4-2e34-4fcb-a0d1-42229e27da3e
11
12 defmodule Pleroma.Web.ActivityPub.MRF.HighRollerPolicy do
13   alias Pleroma.User
14   alias Pleroma.Web.CommonAPI
15   alias Pleroma.Config
16
17   require Logger
18
19   @moduledoc "Notify local users upon the block, report or unfollow."
20   @behaviour Pleroma.Web.ActivityPub.MRF.Policy
21
22   defp is_block_or_unblock(%{"type" => "Block", "object" => object}),
23     do: {true, "blocked", object}
24
25   defp is_block_or_unblock(%{
26          "type" => "Undo",
27          "object" => %{"type" => "Block", "object" => object}
28        }),
29        do: {true, "unblocked", object}
30
31   defp is_block_or_unblock(_), do: {false, nil, nil}
32
33   defp is_report(%{"type" => "Flag", "object" => objects}) do
34     case objects do
35       [head | tail] when is_binary(head) -> {true, tail, head}
36       s when is_binary(s) -> {true, [], s}
37       _ -> {true, [], nil}
38     end
39   end
40
41   defp is_report(_), do: {false, [], nil}
42
43   defp extract_reported_post(post) do
44     case post do
45       %{"id" => id} -> id
46       s when is_binary(s) -> s
47       _ -> nil
48     end
49   end
50
51   defp is_unfollow(%{
52          "type" => "Undo",
53          "object" => %{"type" => "Follow", "object" => object}
54        }),
55        do: {true, object}
56
57   defp is_unfollow(_), do: {false, nil, nil}
58
59   @impl true
60   def filter(message) do
61     with {:error, _} <- Cachex.stats(:highroller), do: Cachex.start(:highroller, [ stats: true ])
62     systime = :os.system_time(:seconds)
63
64     with {true, action, object} <- is_block_or_unblock(message),
65          %User{} = actor <- User.get_cached_by_ap_id(message["actor"]),
66          %User{} = recipient <- User.get_cached_by_ap_id(object),
67          false <- Enum.member?(Config.get([:mrf_high_roller, :actor_blacklist]), message["actor"]),
68          false <- Enum.member?(Config.get([:mrf_high_roller, :domain_blacklist]), URI.parse(message["actor"]).host),
69          true <- recipient.local do
70
71       {_, actiontime} = Cachex.fetch(:highroller, actor.nickname<>","<>recipient.nickname<>","<>action, fn(_i) -> {:commit, :os.system_time(:seconds)-1} end)
72       {_, globalcount} = Cachex.fetch(:highroller, "global:"<>actor.nickname, fn(_i) -> {:commit, 0} end)
73
74       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
75         "@" <> actor.nickname
76       else
77         actor.nickname
78       end
79
80       replacements = %{
81         "actor" => blocker,
82         "target" => "@" <> recipient.nickname,
83         "action" => action
84       }
85
86       msg =
87         Regex.replace(
88           ~r/{([a-z]+)?}/,
89           Config.get([:mrf_high_roller, :block_message]),
90           fn _, match ->
91             replacements[match]
92           end
93         )
94
95       if (systime > actiontime && globalcount < Config.get([:mrf_high_roller, :global_threshold])) do
96         Cachex.incr(:highroller, "global:"<>actor.nickname, globalcount+1)
97         Cachex.put(:highroller, actor.nickname<>","<>recipient.nickname<>","<>action, systime+Config.get([:mrf_high_roller, :timeout]))
98         CommonAPI.post(User.get_cached_by_nickname(Config.get([:mrf_high_roller, :user])), %{
99             status: msg,
100             visibility: Config.get([:mrf_high_roller, :block_visibility])
101         })
102       else
103         Logger.warn("Rate-limited incoming block notif!  #{inspect(message)}")
104         Cachex.incr(:highroller, "global:"<>actor.nickname, globalcount+1)
105         Cachex.incr(:highroller, actor.nickname<>","<>recipient.nickname<>","<>action, 30*(1+(systime-actiontime)))
106       end
107     end
108
109     with {true, objects, to} <- is_report(message),
110          %User{} = actor <- User.get_cached_by_ap_id(message["actor"]),
111          %User{} = recipient <- User.get_cached_by_ap_id(to),
112          false <- Enum.member?(Config.get([:mrf_high_roller, :actor_blacklist]), message["actor"]),
113          false <- Enum.member?(Config.get([:mrf_high_roller, :domain_blacklist]), URI.parse(message["actor"]).host),
114          true <- recipient.local do
115
116       {_, actiontime} = Cachex.fetch(:highroller, actor.nickname<>","<>recipient.nickname<>",report", fn(_i) -> {:commit, :os.system_time(:seconds)-1} end)
117       {_, globalcount} = Cachex.fetch(:highroller, "global:"<>actor.nickname, fn(_i) -> {:commit, 0} end)
118
119       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
120         "@" <> actor.nickname
121       else
122         actor.nickname
123       end
124
125       replacements = %{
126         "actor" => reporter,
127         "target" => "@" <> recipient.nickname
128       }
129
130       msg =
131         Regex.replace(
132           ~r/{([a-z]+)?}/,
133           Pleroma.Config.get([:mrf_high_roller, :report_message]),
134           fn _, match ->
135             replacements[match]
136           end
137         )
138
139       posts =
140         objects
141         |> Enum.map(&extract_reported_post/1)
142         |> Enum.reject(&is_nil/1)
143         |> Enum.map(fn s -> "- " <> s end)
144         |> Enum.join("\n")
145         |> (fn s ->
146               case s do
147                 "" -> ""
148                 s -> "\n\nReported objects:\n" <> s
149               end
150             end).()
151
152       comment =
153         case message["content"] do
154           "" -> ""
155           s when is_binary(s) -> "\n\nReport message:\n" <> s
156           _ -> ""
157         end
158
159       if (systime > actiontime && globalcount < Config.get([:mrf_high_roller, :global_threshold])) do
160         Cachex.incr(:highroller, "global:"<>actor.nickname, globalcount+1)
161         Cachex.put(:highroller, actor.nickname<>","<>recipient.nickname<>",report", systime+Config.get([:mrf_high_roller, :timeout]))
162         CommonAPI.post(User.get_cached_by_nickname(Config.get([:mrf_high_roller, :user])), %{
163             status: msg <> comment <> posts,
164             visibility: Config.get([:mrf_high_roller, :report_visibility])
165         })
166       else
167         Logger.warn("Rate-limited incoming report notif!  #{inspect(message)}")
168         Cachex.incr(:highroller, "global:"<>actor.nickname, globalcount+1)
169         Cachex.incr(:highroller, actor.nickname<>","<>recipient.nickname<>",report", 30*(1+(systime-actiontime)))
170       end
171     end
172
173     with {true, object} <- is_unfollow(message),
174          %User{} = actor <- User.get_cached_by_ap_id(message["actor"]),
175          %User{} = recipient <- User.get_cached_by_ap_id(object),
176          false <- Enum.member?(Config.get([:mrf_high_roller, :actor_blacklist]), message["actor"]),
177          false <- Enum.member?(Config.get([:mrf_high_roller, :domain_blacklist]), URI.parse(message["actor"]).host),
178          true <- recipient.local do
179
180       {_, actiontime} = Cachex.fetch(:highroller, actor.nickname<>","<>recipient.nickname<>",unfollow", fn(_i) -> {:commit, :os.system_time(:seconds)-1} end)
181       {_, globalcount} = Cachex.fetch(:highroller, "global:"<>actor.nickname, fn(_i) -> {:commit, 0} end)
182
183       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
184         "@" <> actor.nickname
185       else
186         actor.nickname
187       end
188
189       replacements = %{
190         "actor" => unfollower,
191         "target" => "@" <> recipient.nickname
192       }
193
194       msg =
195         Regex.replace(
196           ~r/{([a-z]+)?}/,
197           Pleroma.Config.get([:mrf_high_roller, :unfollow_message]),
198           fn _, match ->
199             replacements[match]
200           end
201         )
202
203       if (systime > actiontime && globalcount < Config.get([:mrf_high_roller, :global_threshold])) do
204         Cachex.incr(:highroller, "global:"<>actor.nickname, globalcount+1)
205         Cachex.put(:highroller, actor.nickname<>","<>recipient.nickname<>",unfollow", systime+Config.get([:mrf_high_roller, :timeout]))
206         CommonAPI.post(User.get_cached_by_nickname(Config.get([:mrf_high_roller, :user])), %{
207             status: msg,
208             visibility: Config.get([:mrf_high_roller, :unfollow_visibility])
209         })
210       else
211         Logger.warn("Rate-limited incoming unfollow notif!  #{inspect(message)}")
212         Cachex.incr(:highroller, "global:"<>actor.nickname, globalcount+1)
213         Cachex.incr(:highroller, actor.nickname<>","<>recipient.nickname<>",unfollow", 30*(1+(systime-actiontime)))
214       end
215     end
216
217     {:ok, message}
218   end
219
220   @impl true
221   def config_description do
222     %{
223       key: :mrf_high_roller,
224       related_policy: "Pleroma.Web.ActivityPub.MRF.HighRollerPolicy",
225       label: "High Roller Policy",
226       description: "Three-in-one policy for notifying users upon being blocked, unfollowed or reported",
227       children: [
228         %{
229           key: :user,
230           type: :string,
231           label: "Account",
232           description: "Account from which notifications would be sent",
233           suggestions: ["blockbot"]
234         },
235         %{
236           key: :timeout,
237           type: :integer,
238           label: "Timeout",
239           description: "Timeout (in seconds) between which no new notifications of the same type can be sent",
240           suggestions: [60]
241         },
242         %{
243           key: :global_threshold,
244           type: :integer,
245           label: "Global threshold",
246           description: "Global threshold of the actions for the actor",
247           suggestions: [5]
248         },
249         %{
250           key: :actor_blacklist,
251           type: {:list, :string},
252           label: "Actor blacklist",
253           description: "List of actors to skip sending a notification about",
254           suggestions: ["https://freespeechextremist.com/users/p"]
255         },
256         %{
257           key: :domain_greylist,
258           type: {:list, :string},
259           label: "Domain greylist",
260           description: "List of domains to exclude their users from being tagged",
261           suggestions: ["freespeechextremist.com"]
262         },
263         %{
264           key: :domain_blacklist,
265           type: {:list, :string},
266           label: "Domain blacklist",
267           description: "List of domains to skip sending a notification about",
268           suggestions: ["freespeechextremist.com"]
269         },
270         %{
271           key: :block_message,
272           type: :string,
273           label: "Block message",
274           description:
275             "The message to send when someone is blocked or unblocked; use {actor}, {target}, and {action} variables",
276           suggestions: ["{target} you have been {action} by {actor}"]
277         },
278         %{
279           key: :tag_blocking_actor,
280           type: :boolean,
281           label: "Tag blocking actor",
282           description: "Whether to tag the blocking actor or not"
283         },
284         %{
285           key: :block_visibility,
286           type: :string,
287           label: "Block visibility",
288           description: "Visibility of the block notification",
289           suggestions: ["public", "unlisted", "private", "direct"]
290         },
291         %{
292           key: :report_message,
293           type: :string,
294           label: "Report message",
295           description:
296             "The message to send when someone is reported; use {actor} and {target} variables",
297           suggestions: ["{target} you have been reported by {actor}"]
298         },
299         %{
300           key: :tag_reporting_actor,
301           type: :boolean,
302           label: "Tag reporting actor",
303           description: "Whether to tag the reporting actor or not"
304         },
305         %{
306           key: :report_visibility,
307           type: :string,
308           label: "Report visibility",
309           description: "Visibility of the report notification",
310           suggestions: ["public", "unlisted", "private", "direct"]
311         },
312         %{
313           key: :unfollow_message,
314           type: :string,
315           label: "Unfollow message",
316           description:
317             "The message to send when someone is unfollowed; use {actor} and {target} variables",
318           suggestions: ["{target} you have been unfollowed by {actor}"]
319         },
320         %{
321           key: :tag_unfollowing_actor,
322           type: :boolean,
323           label: "Tag unfollowing actor",
324           description: "Whether to tag the unfollowing actor or not"
325         },
326         %{
327           key: :unfollow_visibility,
328           type: :string,
329           label: "Unfollow visibility",
330           description: "Visibility of the unfollow notification",
331           suggestions: ["public", "unlisted", "private", "direct"]
332         }
333       ]
334     }
335   end
336
337   @impl true
338   def describe, do: {:ok, %{}}
339 end