MastodonAPI: Support poll notification

This commit is contained in:
Alex Gleason 2021-07-17 20:35:35 -05:00
parent b221d77a6d
commit 0114754db2
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
14 changed files with 262 additions and 21 deletions

View File

@ -552,6 +552,7 @@
mailer: 10, mailer: 10,
transmogrifier: 20, transmogrifier: 20,
scheduled_activities: 10, scheduled_activities: 10,
poll_notifications: 10,
background: 5, background: 5,
remote_fetcher: 2, remote_fetcher: 2,
attachments_cleanup: 1, attachments_cleanup: 1,

View File

@ -72,6 +72,7 @@ def unread_notifications_count(%User{id: user_id}) do
pleroma:emoji_reaction pleroma:emoji_reaction
pleroma:report pleroma:report
reblog reblog
poll
} }
def changeset(%Notification{} = notification, attrs) do def changeset(%Notification{} = notification, attrs) do
@ -379,7 +380,7 @@ defp do_create_notifications(%Activity{} = activity, options) do
notifications = notifications =
Enum.map(potential_receivers, fn user -> Enum.map(potential_receivers, fn user ->
do_send = do_send && user in enabled_receivers do_send = do_send && user in enabled_receivers
create_notification(activity, user, do_send) create_notification(activity, user, do_send: do_send)
end) end)
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
@ -435,15 +436,18 @@ defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do
end end
# TODO move to sql, too. # TODO move to sql, too.
def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do def create_notification(%Activity{} = activity, %User{} = user, opts \\ []) do
unless skip?(activity, user) do do_send = Keyword.get(opts, :do_send, true)
type = Keyword.get(opts, :type, type_from_activity(activity))
unless skip?(activity, user, opts) do
{:ok, %{notification: notification}} = {:ok, %{notification: notification}} =
Multi.new() Multi.new()
|> Multi.insert(:notification, %Notification{ |> Multi.insert(:notification, %Notification{
user_id: user.id, user_id: user.id,
activity: activity, activity: activity,
seen: mark_as_read?(activity, user), seen: mark_as_read?(activity, user),
type: type_from_activity(activity) type: type
}) })
|> Marker.multi_set_last_read_id(user, "notifications") |> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction() |> Repo.transaction()
@ -457,6 +461,26 @@ def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true)
end end
end end
def create_poll_notifications(%Activity{} = activity) do
with %Object{data: %{"type" => "Question", "actor" => actor} = data} <-
Object.normalize(activity) do
voters =
case data do
%{"voters" => voters} when is_list(voters) -> voters
_ -> []
end
notifications =
Enum.map([actor | voters], fn ap_id ->
with %User{} = user <- User.get_by_ap_id(ap_id) do
create_notification(activity, user, type: "poll")
end
end)
{:ok, notifications}
end
end
@doc """ @doc """
Returns a tuple with 2 elements: Returns a tuple with 2 elements:
{notification-enabled receivers, currently disabled receivers (blocking / [thread] muting)} {notification-enabled receivers, currently disabled receivers (blocking / [thread] muting)}
@ -572,8 +596,10 @@ def exclude_thread_muter_ap_ids(ap_ids, %Activity{} = activity) do
Enum.uniq(ap_ids) -- thread_muter_ap_ids Enum.uniq(ap_ids) -- thread_muter_ap_ids
end end
@spec skip?(Activity.t(), User.t()) :: boolean() def skip?(activity, user, opts \\ [])
def skip?(%Activity{} = activity, %User{} = user) do
@spec skip?(Activity.t(), User.t(), Keyword.t()) :: boolean()
def skip?(%Activity{} = activity, %User{} = user, opts) do
[ [
:self, :self,
:invisible, :invisible,
@ -581,17 +607,21 @@ def skip?(%Activity{} = activity, %User{} = user) do
:recently_followed, :recently_followed,
:filtered :filtered
] ]
|> Enum.find(&skip?(&1, activity, user)) |> Enum.find(&skip?(&1, activity, user, opts))
end end
def skip?(_, _), do: false def skip?(_activity, _user, _opts), do: false
@spec skip?(atom(), Activity.t(), User.t()) :: boolean() @spec skip?(atom(), Activity.t(), User.t(), Keyword.t()) :: boolean()
def skip?(:self, %Activity{} = activity, %User{} = user) do def skip?(:self, %Activity{} = activity, %User{} = user, opts) do
activity.data["actor"] == user.ap_id cond do
opts[:type] == "poll" -> false
activity.data["actor"] == user.ap_id -> true
true -> false
end
end end
def skip?(:invisible, %Activity{} = activity, _) do def skip?(:invisible, %Activity{} = activity, _user, _opts) do
actor = activity.data["actor"] actor = activity.data["actor"]
user = User.get_cached_by_ap_id(actor) user = User.get_cached_by_ap_id(actor)
User.invisible?(user) User.invisible?(user)
@ -600,7 +630,8 @@ def skip?(:invisible, %Activity{} = activity, _) do
def skip?( def skip?(
:block_from_strangers, :block_from_strangers,
%Activity{} = activity, %Activity{} = activity,
%User{notification_settings: %{block_from_strangers: true}} = user %User{notification_settings: %{block_from_strangers: true}} = user,
_opts
) do ) do
actor = activity.data["actor"] actor = activity.data["actor"]
follower = User.get_cached_by_ap_id(actor) follower = User.get_cached_by_ap_id(actor)
@ -608,7 +639,12 @@ def skip?(
end end
# To do: consider defining recency in hours and checking FollowingRelationship with a single SQL # To do: consider defining recency in hours and checking FollowingRelationship with a single SQL
def skip?(:recently_followed, %Activity{data: %{"type" => "Follow"}} = activity, %User{} = user) do def skip?(
:recently_followed,
%Activity{data: %{"type" => "Follow"}} = activity,
%User{} = user,
_opts
) do
actor = activity.data["actor"] actor = activity.data["actor"]
Notification.for_user(user) Notification.for_user(user)
@ -618,9 +654,10 @@ def skip?(:recently_followed, %Activity{data: %{"type" => "Follow"}} = activity,
end) end)
end end
def skip?(:filtered, %{data: %{"type" => type}}, _) when type in ["Follow", "Move"], do: false def skip?(:filtered, %{data: %{"type" => type}}, _user, _opts) when type in ["Follow", "Move"],
do: false
def skip?(:filtered, activity, user) do def skip?(:filtered, activity, user, _opts) do
object = Object.normalize(activity, fetch: false) object = Object.normalize(activity, fetch: false)
cond do cond do
@ -638,7 +675,7 @@ def skip?(:filtered, activity, user) do
end end
end end
def skip?(_, _, _), do: false def skip?(_type, _activity, _user, _opts), do: false
def mark_as_read?(activity, target_user) do def mark_as_read?(activity, target_user) do
user = Activity.user_actor(activity) user = Activity.user_actor(activity)

View File

@ -24,6 +24,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
alias Pleroma.Web.Streamer alias Pleroma.Web.Streamer
alias Pleroma.Web.WebFinger alias Pleroma.Web.WebFinger
alias Pleroma.Workers.BackgroundWorker alias Pleroma.Workers.BackgroundWorker
alias Pleroma.Workers.PollWorker
import Ecto.Query import Ecto.Query
import Pleroma.Web.ActivityPub.Utils import Pleroma.Web.ActivityPub.Utils
@ -284,6 +285,7 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param
{:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity},
{:ok, _actor} <- increase_note_count_if_public(actor, activity), {:ok, _actor} <- increase_note_count_if_public(actor, activity),
_ <- notify_and_stream(activity), _ <- notify_and_stream(activity),
:ok <- maybe_schedule_poll_notifications(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
else else
@ -298,6 +300,11 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param
end end
end end
defp maybe_schedule_poll_notifications(activity) do
PollWorker.schedule_poll_end(activity)
:ok
end
@spec listen(map()) :: {:ok, Activity.t()} | {:error, any()} @spec listen(map()) :: {:ok, Activity.t()} | {:error, any()}
def listen(%{to: to, actor: actor, context: context, object: object} = params) do def listen(%{to: to, actor: actor, context: context, object: object} = params) do
additional = params[:additional] || %{} additional = params[:additional] || %{}

View File

@ -195,7 +195,8 @@ defp notification_type do
"pleroma:chat_mention", "pleroma:chat_mention",
"pleroma:report", "pleroma:report",
"move", "move",
"follow_request" "follow_request",
"poll"
], ],
description: """ description: """
The type of event that resulted in the notification. The type of event that resulted in the notification.

View File

@ -50,6 +50,7 @@ def index(conn, %{account_id: account_id} = params) do
favourite favourite
move move
pleroma:emoji_reaction pleroma:emoji_reaction
poll
} }
def index(%{assigns: %{user: user}} = conn, params) do def index(%{assigns: %{user: user}} = conn, params) do
params = params =

View File

@ -112,6 +112,9 @@ def render(
"move" -> "move" ->
put_target(response, activity, reading_user, %{}) put_target(response, activity, reading_user, %{})
"poll" ->
put_status(response, activity, reading_user, status_render_opts)
"pleroma:emoji_reaction" -> "pleroma:emoji_reaction" ->
response response
|> put_status(parent_activity_fn.(), reading_user, status_render_opts) |> put_status(parent_activity_fn.(), reading_user, status_render_opts)

View File

@ -26,7 +26,7 @@ defmodule Pleroma.Web.Push.Subscription do
end end
# credo:disable-for-next-line Credo.Check.Readability.MaxLineLength # credo:disable-for-next-line Credo.Check.Readability.MaxLineLength
@supported_alert_types ~w[follow favourite mention reblog pleroma:chat_mention pleroma:emoji_reaction]a @supported_alert_types ~w[follow favourite mention reblog poll pleroma:chat_mention pleroma:emoji_reaction]a
defp alerts(%{data: %{alerts: alerts}}) do defp alerts(%{data: %{alerts: alerts}}) do
alerts = Map.take(alerts, @supported_alert_types) alerts = Map.take(alerts, @supported_alert_types)

View File

@ -0,0 +1,43 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.PollWorker do
@moduledoc """
Generates notifications when a poll ends.
"""
use Pleroma.Workers.WorkerHelper, queue: "poll_notifications"
alias Pleroma.Activity
alias Pleroma.Notification
alias Pleroma.Object
@impl Oban.Worker
def perform(%Job{args: %{"op" => "poll_end", "activity_id" => activity_id}}) do
with %Activity{} = activity <- find_poll_activity(activity_id) do
Notification.create_poll_notifications(activity)
end
end
defp find_poll_activity(activity_id) do
with nil <- Activity.get_by_id(activity_id) do
{:error, :poll_activity_not_found}
end
end
def schedule_poll_end(%Activity{data: %{"type" => "Create"}, id: activity_id} = activity) do
with %Object{data: %{"type" => "Question", "closed" => closed}} <- Object.normalize(activity),
{:ok, end_time} <- NaiveDateTime.from_iso8601(closed) do
%{
op: "poll_end",
activity_id: activity_id
}
|> new(scheduled_at: end_time)
|> Oban.insert()
else
_ -> {:error, activity}
end
end
def schedule_poll_end(activity), do: {:error, activity}
end

View File

@ -0,0 +1,49 @@
defmodule Pleroma.Repo.Migrations.AddPollToNotificationsEnum do
use Ecto.Migration
@disable_ddl_transaction true
def up do
"""
alter type notification_type add value 'poll'
"""
|> execute()
end
def down do
alter table(:notifications) do
modify(:type, :string)
end
"""
delete from notifications where type = 'poll'
"""
|> execute()
"""
drop type if exists notification_type
"""
|> execute()
"""
create type notification_type as enum (
'follow',
'follow_request',
'mention',
'move',
'pleroma:emoji_reaction',
'pleroma:chat_mention',
'reblog',
'favourite',
'pleroma:report'
)
"""
|> execute()
"""
alter table notifications
alter column type type notification_type using (type::notification_type)
"""
|> execute()
end
end

View File

@ -129,6 +129,19 @@ test "does not create a notification for subscribed users if status is a reply"
end end
end end
test "create_poll_notifications/1" do
[user1, user2, user3, _, _] = insert_list(5, :user)
question = insert(:question, user: user1)
activity = insert(:question_activity, question: question)
{:ok, _, _} = CommonAPI.vote(user2, question, [0])
{:ok, _, _} = CommonAPI.vote(user3, question, [1])
{:ok, notifications} = Notification.create_poll_notifications(activity)
assert [user1.id, user3.id, user2.id] == Enum.map(notifications, & &1.user_id)
end
describe "CommonApi.post/2 notification-related functionality" do describe "CommonApi.post/2 notification-related functionality" do
test_with_mock "creates but does NOT send notification to blocker user", test_with_mock "creates but does NOT send notification to blocker user",
Push, Push,

View File

@ -18,6 +18,7 @@ defmodule Pleroma.Web.CommonAPITest do
alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
alias Pleroma.Workers.PollWorker
import Pleroma.Factory import Pleroma.Factory
import Mock import Mock
@ -43,6 +44,12 @@ test "it posts a poll" do
assert object.data["type"] == "Question" assert object.data["type"] == "Question"
assert object.data["oneOf"] |> length() == 2 assert object.data["oneOf"] |> length() == 2
assert_enqueued(
worker: PollWorker,
args: %{op: "poll_end", activity_id: activity.id},
scheduled_at: NaiveDateTime.from_iso8601!(object.data["closed"])
)
end end
end end

View File

@ -15,6 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
alias Pleroma.Workers.ScheduledActivityWorker
import Pleroma.Factory import Pleroma.Factory
@ -685,11 +686,11 @@ test "scheduled poll", %{conn: conn} do
|> json_response_and_validate_schema(200) |> json_response_and_validate_schema(200)
assert {:ok, %{id: activity_id}} = assert {:ok, %{id: activity_id}} =
perform_job(Pleroma.Workers.ScheduledActivityWorker, %{ perform_job(ScheduledActivityWorker, %{
activity_id: scheduled_id activity_id: scheduled_id
}) })
assert Repo.all(Oban.Job) == [] refute_enqueued(worker: ScheduledActivityWorker)
object = object =
Activity Activity

View File

@ -196,6 +196,27 @@ test "EmojiReact notification" do
test_notifications_rendering([notification], user, [expected]) test_notifications_rendering([notification], user, [expected])
end end
test "Poll notification" do
user = insert(:user)
activity = insert(:question_activity, user: user)
{:ok, [notification]} = Notification.create_poll_notifications(activity)
expected = %{
id: to_string(notification.id),
pleroma: %{is_seen: false, is_muted: false},
type: "poll",
account:
AccountView.render("show.json", %{
user: user,
for: user
}),
status: StatusView.render("show.json", %{activity: activity, for: user}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], user, [expected])
end
test "Report notification" do test "Report notification" do
reporting_user = insert(:user) reporting_user = insert(:user)
reported_user = insert(:user) reported_user = insert(:user)

View File

@ -201,6 +201,36 @@ def tombstone_factory do
} }
end end
def question_factory(attrs \\ %{}) do
user = attrs[:user] || insert(:user)
data = %{
"id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(),
"type" => "Question",
"actor" => user.ap_id,
"attachment" => [],
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => [user.follower_address],
"context" => Pleroma.Web.ActivityPub.Utils.generate_context_id(),
"oneOf" => [
%{
"type" => "Note",
"name" => "chocolate",
"replies" => %{"totalItems" => 0, "type" => "Collection"}
},
%{
"type" => "Note",
"name" => "vanilla",
"replies" => %{"totalItems" => 0, "type" => "Collection"}
}
]
}
%Pleroma.Object{
data: merge_attributes(data, Map.get(attrs, :data, %{}))
}
end
def direct_note_activity_factory do def direct_note_activity_factory do
dm = insert(:direct_note) dm = insert(:direct_note)
@ -350,6 +380,33 @@ def report_activity_factory(attrs \\ %{}) do
} }
end end
def question_activity_factory(attrs \\ %{}) do
user = attrs[:user] || insert(:user)
question = attrs[:question] || insert(:question, user: user)
data_attrs = attrs[:data_attrs] || %{}
attrs = Map.drop(attrs, [:user, :question, :data_attrs])
data =
%{
"id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(),
"type" => "Create",
"actor" => question.data["actor"],
"to" => question.data["to"],
"object" => question.data["id"],
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
"context" => question.data["context"]
}
|> Map.merge(data_attrs)
%Pleroma.Activity{
data: data,
actor: data["actor"],
recipients: data["to"]
}
|> Map.merge(attrs)
end
def oauth_app_factory do def oauth_app_factory do
%Pleroma.Web.OAuth.App{ %Pleroma.Web.OAuth.App{
client_name: sequence(:client_name, &"Some client #{&1}"), client_name: sequence(:client_name, &"Some client #{&1}"),