diff --git a/changelog.d/force-mention-mrf.add b/changelog.d/force-mention-mrf.add
new file mode 100644
index 000000000..46ac14244
--- /dev/null
+++ b/changelog.d/force-mention-mrf.add
@@ -0,0 +1 @@
+Add ForceMention MRF
\ No newline at end of file
diff --git a/config/config.exs b/config/config.exs
index 435387a64..d0496cef8 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -415,6 +415,10 @@ config :pleroma, :mrf_follow_bot, follower_nickname: nil
config :pleroma, :mrf_inline_quote, template: "RT: {url}"
+config :pleroma, :mrf_force_mention,
+ mention_parent: true,
+ mention_quoted: true
+
config :pleroma, :rich_media,
enabled: true,
ignore_hosts: [],
diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md
index 7bba7b26e..89a461b47 100644
--- a/docs/configuration/cheatsheet.md
+++ b/docs/configuration/cheatsheet.md
@@ -161,7 +161,8 @@ To add configuration to your config file, you can copy it from the base config.
* `Pleroma.Web.ActivityPub.MRF.KeywordPolicy`: Rejects or removes from the federated timeline or replaces keywords. (See [`:mrf_keyword`](#mrf_keyword)).
* `Pleroma.Web.ActivityPub.MRF.ForceMentionsInContent`: Forces every mentioned user to be reflected in the post content.
* `Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy`: Forces quote post URLs to be reflected in the message content inline.
- * `Pleroma.Web.ActivityPub.MRF.QuoteToLinkTagPolicy`: Force a Link tag for posts quoting another post. (may break outgoing federation of quote posts with older Pleroma versions)
+ * `Pleroma.Web.ActivityPub.MRF.QuoteToLinkTagPolicy`: Force a Link tag for posts quoting another post. (may break outgoing federation of quote posts with older Pleroma versions).
+ * `Pleroma.Web.ActivityPub.MRF.ForceMention`: Forces posts to include a mention of the author of parent post or the author of quoted post.
* `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo).
* `transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value.
@@ -272,6 +273,10 @@ Notes:
#### :mrf_inline_quote
* `template`: The template to append to the post. `{url}` will be replaced with the actual link to the quoted post. Default: `RT: {url}`
+#### :mrf_force_mention
+* `mention_parent`: Whether to append mention of parent post author
+* `mention_quoted`: Whether to append mention of parent quoted author
+
### :activitypub
* `unfollow_blocked`: Whether blocks result in people getting unfollowed
* `outgoing_blocks`: Whether to federate blocks to other instances
diff --git a/lib/pleroma/web/activity_pub/mrf/force_mention.ex b/lib/pleroma/web/activity_pub/mrf/force_mention.ex
new file mode 100644
index 000000000..3853489fc
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/force_mention.ex
@@ -0,0 +1,59 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2024 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.ForceMention do
+ require Pleroma.Constants
+
+ alias Pleroma.Config
+ alias Pleroma.Object
+ alias Pleroma.User
+
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ defp get_author(url) do
+ with %Object{data: %{"actor" => actor}} <- Object.normalize(url, fetch: false),
+ %User{ap_id: ap_id, nickname: nickname} <- User.get_cached_by_ap_id(actor) do
+ %{"type" => "Mention", "href" => ap_id, "name" => "@#{nickname}"}
+ else
+ _ -> nil
+ end
+ end
+
+ defp prepend_author(tags, _, false), do: tags
+
+ defp prepend_author(tags, nil, _), do: tags
+
+ defp prepend_author(tags, url, _) do
+ actor = get_author(url)
+
+ if not is_nil(actor) do
+ [actor | tags]
+ else
+ tags
+ end
+ end
+
+ @impl true
+ def filter(%{"type" => "Create", "object" => %{"tag" => tag} = object} = activity) do
+ tag =
+ tag
+ |> prepend_author(
+ object["inReplyTo"],
+ Config.get([:mrf_force_mention, :mention_parent, true])
+ )
+ |> prepend_author(
+ object["quoteUrl"],
+ Config.get([:mrf_force_mention, :mention_quoted, true])
+ )
+ |> Enum.uniq()
+
+ {:ok, put_in(activity["object"]["tag"], tag)}
+ end
+
+ @impl true
+ def filter(object), do: {:ok, object}
+
+ @impl true
+ def describe, do: {:ok, %{}}
+end
diff --git a/test/fixtures/minds-invalid-mention-post.json b/test/fixtures/minds-invalid-mention-post.json
new file mode 100644
index 000000000..ea2cb2739
--- /dev/null
+++ b/test/fixtures/minds-invalid-mention-post.json
@@ -0,0 +1 @@
+{"@context":"https://www.w3.org/ns/activitystreams","type":"Note","id":"https://www.minds.com/api/activitypub/users/1198929502760083472/entities/urn:comment:1600926863310458883:0:0:0:1600932467852709903","attributedTo":"https://www.minds.com/api/activitypub/users/1198929502760083472","content":"\u003Ca class=\u0022u-url mention\u0022 href=\u0022https://www.minds.com/lain\u0022 target=\u0022_blank\u0022\u003E@lain\u003C/a\u003E corn syrup.","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://www.minds.com/api/activitypub/users/1198929502760083472/followers","https://lain.com/users/lain"],"tag":[{"type":"Mention","href":"https://www.minds.com/api/activitypub/users/464237775479123984","name":"@lain"}],"url":"https://www.minds.com/newsfeed/1600926863310458883?focusedCommentUrn=urn:comment:1600926863310458883:0:0:0:1600932467852709903","published":"2024-02-04T17:34:03+00:00","inReplyTo":"https://lain.com/objects/36254095-c839-4167-bcc2-b361d5de9198","source":{"content":"@lain corn syrup.","mediaType":"text/plain"}}
\ No newline at end of file
diff --git a/test/fixtures/minds-pleroma-mentioned-post.json b/test/fixtures/minds-pleroma-mentioned-post.json
new file mode 100644
index 000000000..9dfa42c90
--- /dev/null
+++ b/test/fixtures/minds-pleroma-mentioned-post.json
@@ -0,0 +1 @@
+{"@context":["https://www.w3.org/ns/activitystreams","https://lain.com/schemas/litepub-0.1.jsonld",{"@language":"und"}],"actor":"https://lain.com/users/lain","attachment":[],"attributedTo":"https://lain.com/users/lain","cc":["https://lain.com/users/lain/followers"],"content":"which diet is the best for cognitive dissonance","context":"https://lain.com/contexts/98c8a130-e813-4797-8973-600e80114317","conversation":"https://lain.com/contexts/98c8a130-e813-4797-8973-600e80114317","id":"https://lain.com/objects/36254095-c839-4167-bcc2-b361d5de9198","published":"2024-02-04T17:11:23.931890Z","repliesCount":11,"sensitive":null,"source":{"content":"which diet is the best for cognitive dissonance","mediaType":"text/plain"},"summary":"","tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"type":"Note"}
\ No newline at end of file
diff --git a/test/pleroma/web/activity_pub/mrf/force_mention_test.exs b/test/pleroma/web/activity_pub/mrf/force_mention_test.exs
new file mode 100644
index 000000000..b026bab66
--- /dev/null
+++ b/test/pleroma/web/activity_pub/mrf/force_mention_test.exs
@@ -0,0 +1,73 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2024 Pleroma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.ForceMentionTest do
+ use Pleroma.DataCase
+ require Pleroma.Constants
+
+ alias Pleroma.Web.ActivityPub.MRF.ForceMention
+
+ import Pleroma.Factory
+
+ test "adds mention to a reply" do
+ lain =
+ insert(:user, ap_id: "https://lain.com/users/lain", nickname: "lain@lain.com", local: false)
+
+ niobleoum =
+ insert(:user,
+ ap_id: "https://www.minds.com/api/activitypub/users/1198929502760083472",
+ nickname: "niobleoum@minds.com",
+ local: false
+ )
+
+ status = File.read!("test/fixtures/minds-pleroma-mentioned-post.json") |> Jason.decode!()
+
+ status_activity = %{
+ "type" => "Create",
+ "actor" => lain.ap_id,
+ "object" => status
+ }
+
+ Pleroma.Web.ActivityPub.Transmogrifier.handle_incoming(status_activity)
+
+ reply = File.read!("test/fixtures/minds-invalid-mention-post.json") |> Jason.decode!()
+
+ reply_activity = %{
+ "type" => "Create",
+ "actor" => niobleoum.ap_id,
+ "object" => reply
+ }
+
+ {:ok, %{"object" => %{"tag" => tag}}} = ForceMention.filter(reply_activity)
+
+ assert Enum.find(tag, fn %{"href" => href} -> href == lain.ap_id end)
+ end
+
+ test "adds mention to a quote" do
+ user1 = insert(:user, ap_id: "https://misskey.io/users/83ssedkv53")
+ user2 = insert(:user, ap_id: "https://misskey.io/users/7rkrarq81i")
+
+ status = File.read!("test/fixtures/tesla_mock/misskey.io_8vs6wxufd0.json") |> Jason.decode!()
+
+ status_activity = %{
+ "type" => "Create",
+ "actor" => user1.ap_id,
+ "object" => status
+ }
+
+ Pleroma.Web.ActivityPub.Transmogrifier.handle_incoming(status_activity)
+
+ quote_post = File.read!("test/fixtures/quote_post/misskey_quote_post.json") |> Jason.decode!()
+
+ quote_activity = %{
+ "type" => "Create",
+ "actor" => user2.ap_id,
+ "object" => quote_post
+ }
+
+ {:ok, %{"object" => %{"tag" => tag}}} = ForceMention.filter(quote_activity)
+
+ assert Enum.find(tag, fn %{"href" => href} -> href == user1.ap_id end)
+ end
+end