forked from potsda.mn/mobilizon
4dc2f489e7
Closes #1413 Signed-off-by: Thomas Citharel <tcit@tcit.fr>
400 lines
12 KiB
Elixir
400 lines
12 KiB
Elixir
defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
|
|
@moduledoc """
|
|
Event converter.
|
|
|
|
This module allows to convert events from ActivityStream format to our own
|
|
internal one, and back.
|
|
"""
|
|
|
|
alias Cldr.DateTime.Formatter
|
|
|
|
alias Mobilizon.Actors.Actor
|
|
alias Mobilizon.Addresses.Address
|
|
alias Mobilizon.Events.Categories
|
|
alias Mobilizon.Events.Event, as: EventModel
|
|
alias Mobilizon.Events.EventOptions
|
|
alias Mobilizon.Medias.Media
|
|
|
|
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
|
|
alias Mobilizon.Federation.ActivityStream.Converter.Address, as: AddressConverter
|
|
alias Mobilizon.Federation.ActivityStream.Converter.EventMetadata, as: EventMetadataConverter
|
|
alias Mobilizon.Federation.ActivityStream.Converter.Media, as: MediaConverter
|
|
alias Mobilizon.Service.TimezoneDetector
|
|
alias Mobilizon.Web.Endpoint
|
|
|
|
import Mobilizon.Federation.ActivityPub.Utils, only: [get_url: 1]
|
|
|
|
import Mobilizon.Federation.ActivityStream.Converter.Utils,
|
|
only: [
|
|
fetch_tags: 1,
|
|
fetch_mentions: 1,
|
|
build_tags: 1,
|
|
maybe_fetch_actor_and_attributed_to_id: 1,
|
|
process_pictures: 2,
|
|
get_address: 1,
|
|
fetch_actor: 1,
|
|
visibility_public?: 1
|
|
]
|
|
|
|
import Mobilizon.Service.Metadata.Utils,
|
|
only: [
|
|
datetime_to_string: 3,
|
|
render_address!: 1
|
|
]
|
|
|
|
require Logger
|
|
|
|
@behaviour Converter
|
|
|
|
defimpl Convertible, for: EventModel do
|
|
alias Mobilizon.Federation.ActivityStream.Converter.Event, as: EventConverter
|
|
|
|
defdelegate model_to_as(event), to: EventConverter
|
|
end
|
|
|
|
@online_address_name "Website"
|
|
@banner_picture_name "Banner"
|
|
@ap_public "https://www.w3.org/ns/activitystreams#Public"
|
|
|
|
@doc """
|
|
Converts an AP object data to our internal data structure.
|
|
"""
|
|
@impl Converter
|
|
@spec as_to_model_data(map) :: map() | {:error, atom()}
|
|
def as_to_model_data(object) do
|
|
case maybe_fetch_actor_and_attributed_to_id(object) do
|
|
{:ok, %Actor{id: actor_id}, attributed_to} ->
|
|
address = get_address(object["location"])
|
|
tags = fetch_tags(object["tag"])
|
|
mentions = fetch_mentions(object["tag"])
|
|
visibility = get_visibility(object)
|
|
options = get_options(object, address)
|
|
metadata = get_metdata(object)
|
|
contacts = get_contacts(object)
|
|
|
|
[description: description, picture_id: picture_id, medias: medias] =
|
|
process_pictures(object, actor_id)
|
|
|
|
%{
|
|
title: object["name"],
|
|
description: description,
|
|
organizer_actor_id: actor_id,
|
|
attributed_to_id: if(is_nil(attributed_to), do: nil, else: attributed_to.id),
|
|
picture_id: picture_id,
|
|
medias: medias,
|
|
begins_on: object["startTime"],
|
|
ends_on: object["endTime"],
|
|
category: Categories.get_category(object["category"]),
|
|
visibility: visibility,
|
|
join_options: Map.get(object, "joinMode", "free"),
|
|
local: local?(object["id"]),
|
|
external_participation_url: object["externalParticipationUrl"],
|
|
options: options,
|
|
metadata: metadata,
|
|
# Remove fallback in MBZ 5.x
|
|
status:
|
|
object
|
|
|> Map.get("status", Map.get(object, "ical:status", "CONFIRMED"))
|
|
|> String.downcase(),
|
|
online_address: object |> Map.get("attachment", []) |> get_online_address(),
|
|
phone_address: object["phoneAddress"],
|
|
draft: object["draft"] == true,
|
|
url: object["id"],
|
|
uuid: object["uuid"],
|
|
tags: tags,
|
|
mentions: mentions,
|
|
physical_address_id: if(address, do: address.id, else: nil),
|
|
updated_at: object["updated"],
|
|
publish_at: object["published"],
|
|
language: object["inLanguage"],
|
|
contacts: contacts
|
|
}
|
|
|
|
{:error, err} ->
|
|
{:error, err}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Convert an event struct to an ActivityStream representation.
|
|
"""
|
|
@impl Converter
|
|
@spec model_to_as(EventModel.t()) :: map
|
|
def model_to_as(%EventModel{} = event) do
|
|
{to, cc} =
|
|
if event.visibility == :public,
|
|
do: {[@ap_public], [event.organizer_actor.followers_url]},
|
|
else: {[attributed_to_or_default(event).followers_url], [@ap_public]}
|
|
|
|
participant_count = Mobilizon.Events.count_participant_participants(event.id)
|
|
|
|
%{
|
|
"type" => "Event",
|
|
"to" => to,
|
|
"cc" => cc,
|
|
"attributedTo" => attributed_to_or_default(event).url,
|
|
"name" => event.title,
|
|
"actor" =>
|
|
if(Ecto.assoc_loaded?(event.organizer_actor), do: event.organizer_actor.url, else: nil),
|
|
"uuid" => event.uuid,
|
|
"category" => event.category,
|
|
"content" => event.description,
|
|
"published" => (event.publish_at || event.inserted_at) |> date_to_string(),
|
|
"updated" => event.updated_at |> date_to_string(),
|
|
"mediaType" => "text/html",
|
|
"startTime" => event.begins_on |> shift_tz(event.options.timezone) |> date_to_string(),
|
|
"joinMode" => to_string(event.join_options),
|
|
"externalParticipationUrl" => event.external_participation_url,
|
|
"endTime" => event.ends_on |> shift_tz(event.options.timezone) |> date_to_string(),
|
|
"tag" => event.tags |> build_tags(),
|
|
"maximumAttendeeCapacity" => event.options.maximum_attendee_capacity,
|
|
"remainingAttendeeCapacity" =>
|
|
remaining_attendee_capacity(event.options, participant_count),
|
|
"participantCount" => participant_count,
|
|
"repliesModerationOption" => event.options.comment_moderation,
|
|
"commentsEnabled" => event.options.comment_moderation == :allow_all,
|
|
"anonymousParticipationEnabled" => event.options.anonymous_participation,
|
|
"attachment" => Enum.map(event.metadata, &EventMetadataConverter.metadata_to_as/1),
|
|
"draft" => event.draft,
|
|
# TODO: Remove me in MBZ 5.x
|
|
"ical:status" => event.status |> to_string |> String.upcase(),
|
|
"status" => event.status |> to_string |> String.upcase(),
|
|
"id" => event.url,
|
|
"url" => event.url,
|
|
"inLanguage" => event.language,
|
|
"timezone" => event.options.timezone,
|
|
"contacts" => Enum.map(event.contacts, & &1.url),
|
|
"isOnline" => event.options.is_online,
|
|
"summary" => event_summary(event)
|
|
}
|
|
|> maybe_add_physical_address(event)
|
|
|> maybe_add_event_picture(event)
|
|
|> maybe_add_online_address(event)
|
|
|> maybe_add_inline_media(event)
|
|
end
|
|
|
|
@spec attributed_to_or_default(EventModel.t()) :: Actor.t()
|
|
defp attributed_to_or_default(%EventModel{} = event) do
|
|
if(is_nil(event.attributed_to) or not Ecto.assoc_loaded?(event.attributed_to),
|
|
do: nil,
|
|
else: event.attributed_to
|
|
) ||
|
|
event.organizer_actor
|
|
end
|
|
|
|
# Get only elements that we have in EventOptions
|
|
@spec get_options(map, Address.t() | nil) :: map
|
|
defp get_options(object, address) do
|
|
%{
|
|
maximum_attendee_capacity: object["maximumAttendeeCapacity"],
|
|
anonymous_participation: object["anonymousParticipationEnabled"],
|
|
comment_moderation:
|
|
Map.get(
|
|
object,
|
|
"repliesModerationOption",
|
|
if(Map.get(object, "commentsEnabled", true), do: :allow_all, else: :closed)
|
|
),
|
|
timezone: calculate_timezone(object, address),
|
|
is_online: object["isOnline"] == true
|
|
}
|
|
end
|
|
|
|
defp calculate_timezone(%{"timezone" => timezone}, %Address{geom: geom}) do
|
|
TimezoneDetector.detect(
|
|
timezone,
|
|
geom,
|
|
"Etc/UTC"
|
|
)
|
|
end
|
|
|
|
defp calculate_timezone(_object, nil), do: nil
|
|
|
|
defp calculate_timezone(_object, %Address{geom: nil}), do: nil
|
|
|
|
defp calculate_timezone(_object, %Address{geom: geom}) do
|
|
TimezoneDetector.detect(
|
|
nil,
|
|
geom,
|
|
"Etc/UTC"
|
|
)
|
|
end
|
|
|
|
defp get_metdata(%{"attachment" => attachments}) do
|
|
attachments
|
|
|> Enum.filter(&(&1["type"] == "PropertyValue"))
|
|
|> Enum.map(&EventMetadataConverter.as_to_metadata/1)
|
|
end
|
|
|
|
defp get_metdata(_), do: []
|
|
|
|
defp get_visibility(object),
|
|
do: if(visibility_public?(object["to"]), do: :public, else: :unlisted)
|
|
|
|
@spec date_to_string(DateTime.t() | nil) :: String.t()
|
|
defp date_to_string(nil), do: nil
|
|
defp date_to_string(%DateTime{} = date), do: DateTime.to_iso8601(date)
|
|
|
|
@spec shift_tz(DateTime.t(), String.t() | nil) :: DateTime.t()
|
|
defp shift_tz(%DateTime{} = date, timezone) when is_binary(timezone) do
|
|
DateTime.shift_zone!(date, timezone)
|
|
end
|
|
|
|
defp shift_tz(datetime, _tz), do: datetime
|
|
|
|
defp get_online_address(attachments) do
|
|
Enum.find_value(attachments, fn attachment ->
|
|
case attachment do
|
|
%{
|
|
"type" => "Link",
|
|
"href" => url,
|
|
"mediaType" => "text/html",
|
|
"name" => @online_address_name
|
|
} ->
|
|
url
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end)
|
|
end
|
|
|
|
@spec maybe_add_physical_address(map(), EventModel.t()) :: map()
|
|
defp maybe_add_physical_address(res, %EventModel{
|
|
physical_address: %Address{} = physical_address
|
|
}) do
|
|
Map.put(res, "location", AddressConverter.model_to_as(physical_address))
|
|
end
|
|
|
|
defp maybe_add_physical_address(res, %EventModel{physical_address: _}), do: res
|
|
|
|
@spec maybe_add_event_picture(map(), EventModel.t()) :: map()
|
|
defp maybe_add_event_picture(res, %EventModel{picture: %Media{} = picture}) do
|
|
Map.update(
|
|
res,
|
|
"attachment",
|
|
[],
|
|
&(&1 ++
|
|
[
|
|
picture
|
|
|> MediaConverter.model_to_as()
|
|
|> Map.put("name", @banner_picture_name)
|
|
])
|
|
)
|
|
end
|
|
|
|
defp maybe_add_event_picture(res, %EventModel{picture: _}), do: res
|
|
|
|
@spec maybe_add_online_address(map(), EventModel.t()) :: map()
|
|
defp maybe_add_online_address(res, %EventModel{online_address: online_address})
|
|
when is_binary(online_address) do
|
|
Map.update(
|
|
res,
|
|
"attachment",
|
|
[],
|
|
&(&1 ++
|
|
[
|
|
%{
|
|
"type" => "Link",
|
|
"href" => online_address,
|
|
"mediaType" => "text/html",
|
|
"name" => @online_address_name
|
|
}
|
|
])
|
|
)
|
|
end
|
|
|
|
defp maybe_add_online_address(res, %EventModel{online_address: _}), do: res
|
|
|
|
@spec maybe_add_inline_media(map(), EventModel.t()) :: map()
|
|
defp maybe_add_inline_media(res, %EventModel{media: media}) do
|
|
medias = Enum.map(media, &MediaConverter.model_to_as/1)
|
|
|
|
Map.update(
|
|
res,
|
|
"attachment",
|
|
[],
|
|
&(&1 ++ medias)
|
|
)
|
|
end
|
|
|
|
@spec local?(String.t()) :: boolean()
|
|
defp local?(url) do
|
|
%URI{host: url_domain} = URI.parse(url)
|
|
%URI{host: local_domain} = URI.parse(Endpoint.url())
|
|
url_domain == local_domain
|
|
end
|
|
|
|
@spec get_contacts(map()) :: list(Actor.t())
|
|
defp get_contacts(object) do
|
|
object
|
|
|> Map.get("contacts", [])
|
|
|> Enum.map(&get_contact/1)
|
|
|> Enum.filter(&match?({:ok, _}, &1))
|
|
|> Enum.map(fn {:ok, contact} -> contact end)
|
|
end
|
|
|
|
defp get_contact(contact) do
|
|
contact
|
|
|> get_url()
|
|
|> fetch_actor()
|
|
end
|
|
|
|
@spec remaining_attendee_capacity(map(), integer()) :: integer() | nil
|
|
defp remaining_attendee_capacity(
|
|
%{maximum_attendee_capacity: maximum_attendee_capacity},
|
|
participant_count
|
|
)
|
|
when is_integer(maximum_attendee_capacity) and maximum_attendee_capacity > 0 do
|
|
maximum_attendee_capacity - participant_count
|
|
end
|
|
|
|
defp remaining_attendee_capacity(
|
|
%{maximum_attendee_capacity: _},
|
|
_participant_count
|
|
),
|
|
do: nil
|
|
|
|
def event_summary(%EventModel{
|
|
begins_on: begins_on,
|
|
physical_address: address,
|
|
options: %EventOptions{timezone: timezone},
|
|
language: language
|
|
}) do
|
|
begins_on = build_begins_on(begins_on, timezone)
|
|
|
|
begins_on
|
|
|> datetime_to_string(language || "en", :long)
|
|
|> (&[&1]).()
|
|
|> add_timezone(begins_on)
|
|
|> maybe_build_address(address)
|
|
|> Enum.join(" - ")
|
|
end
|
|
|
|
@spec build_begins_on(DateTime.t(), String.t() | nil) :: DateTime.t()
|
|
defp build_begins_on(begins_on, nil), do: begins_on
|
|
|
|
defp build_begins_on(begins_on, timezone) do
|
|
case DateTime.shift_zone(begins_on, timezone) do
|
|
{:ok, begins_on} -> begins_on
|
|
{:error, _err} -> begins_on
|
|
end
|
|
end
|
|
|
|
defp add_timezone(elements, %DateTime{} = begins_on) do
|
|
elements ++ [Formatter.zone_gmt(begins_on)]
|
|
end
|
|
|
|
@spec maybe_build_address(list(String.t()), Address.t() | nil) :: list(String.t())
|
|
defp maybe_build_address(elements, %Address{} = address) do
|
|
elements ++ [render_address!(address)]
|
|
rescue
|
|
# If the address is not renderable
|
|
e in ArgumentError ->
|
|
require Logger
|
|
Logger.error(Exception.format(:error, e, __STACKTRACE__))
|
|
elements
|
|
end
|
|
|
|
defp maybe_build_address(elements, _address), do: elements
|
|
end
|