520cc1b0ca545b6d7eac4e69b486cf7f106ec422
[akkoma] / lib / pleroma / web / activity_pub / utils.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.ActivityPub.Utils do
6 alias Ecto.Changeset
7 alias Ecto.UUID
8 alias Pleroma.Activity
9 alias Pleroma.Notification
10 alias Pleroma.Object
11 alias Pleroma.Repo
12 alias Pleroma.User
13 alias Pleroma.Web
14 alias Pleroma.Web.ActivityPub.Visibility
15 alias Pleroma.Web.AdminAPI.AccountView
16 alias Pleroma.Web.Endpoint
17 alias Pleroma.Web.Router.Helpers
18
19 import Ecto.Query
20
21 require Logger
22 require Pleroma.Constants
23
24 @supported_object_types ["Article", "Note", "Video", "Page", "Question", "Answer", "Audio"]
25 @strip_status_report_states ~w(closed resolved)
26 @supported_report_states ~w(open closed resolved)
27 @valid_visibilities ~w(public unlisted private direct)
28
29 # Some implementations send the actor URI as the actor field, others send the entire actor object,
30 # so figure out what the actor's URI is based on what we have.
31 def get_ap_id(%{"id" => id} = _), do: id
32 def get_ap_id(id), do: id
33
34 def normalize_params(params) do
35 Map.put(params, "actor", get_ap_id(params["actor"]))
36 end
37
38 @spec determine_explicit_mentions(map()) :: map()
39 def determine_explicit_mentions(%{"tag" => tag} = _) when is_list(tag) do
40 Enum.flat_map(tag, fn
41 %{"type" => "Mention", "href" => href} -> [href]
42 _ -> []
43 end)
44 end
45
46 def determine_explicit_mentions(%{"tag" => tag} = object) when is_map(tag) do
47 object
48 |> Map.put("tag", [tag])
49 |> determine_explicit_mentions()
50 end
51
52 def determine_explicit_mentions(_), do: []
53
54 @spec recipient_in_collection(any(), any()) :: boolean()
55 defp recipient_in_collection(ap_id, coll) when is_binary(coll), do: ap_id == coll
56 defp recipient_in_collection(ap_id, coll) when is_list(coll), do: ap_id in coll
57 defp recipient_in_collection(_, _), do: false
58
59 @spec recipient_in_message(User.t(), User.t(), map()) :: boolean()
60 def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params) do
61 addresses = [params["to"], params["cc"], params["bto"], params["bcc"]]
62
63 cond do
64 Enum.any?(addresses, &recipient_in_collection(ap_id, &1)) -> true
65 # if the message is unaddressed at all, then assume it is directly addressed
66 # to the recipient
67 Enum.all?(addresses, &is_nil(&1)) -> true
68 # if the message is sent from somebody the user is following, then assume it
69 # is addressed to the recipient
70 User.following?(recipient, actor) -> true
71 true -> false
72 end
73 end
74
75 defp extract_list(target) when is_binary(target), do: [target]
76 defp extract_list(lst) when is_list(lst), do: lst
77 defp extract_list(_), do: []
78
79 def maybe_splice_recipient(ap_id, params) do
80 need_splice? =
81 !recipient_in_collection(ap_id, params["to"]) &&
82 !recipient_in_collection(ap_id, params["cc"])
83
84 if need_splice? do
85 cc_list = extract_list(params["cc"])
86 Map.put(params, "cc", [ap_id | cc_list])
87 else
88 params
89 end
90 end
91
92 def make_json_ld_header do
93 %{
94 "@context" => [
95 "https://www.w3.org/ns/activitystreams",
96 "#{Web.base_url()}/schemas/litepub-0.1.jsonld",
97 %{
98 "@language" => "und"
99 }
100 ]
101 }
102 end
103
104 def make_date do
105 DateTime.utc_now() |> DateTime.to_iso8601()
106 end
107
108 def generate_activity_id do
109 generate_id("activities")
110 end
111
112 def generate_context_id do
113 generate_id("contexts")
114 end
115
116 def generate_object_id do
117 Helpers.o_status_url(Endpoint, :object, UUID.generate())
118 end
119
120 def generate_id(type) do
121 "#{Web.base_url()}/#{type}/#{UUID.generate()}"
122 end
123
124 def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do
125 fake_create_activity = %{
126 "to" => object["to"],
127 "cc" => object["cc"],
128 "type" => "Create",
129 "object" => object
130 }
131
132 get_notified_from_object(fake_create_activity)
133 end
134
135 def get_notified_from_object(object) do
136 Notification.get_notified_from_activity(%Activity{data: object}, false)
137 end
138
139 def create_context(context) do
140 context = context || generate_id("contexts")
141
142 # Ecto has problems accessing the constraint inside the jsonb,
143 # so we explicitly check for the existed object before insert
144 object = Object.get_cached_by_ap_id(context)
145
146 with true <- is_nil(object),
147 changeset <- Object.context_mapping(context),
148 {:ok, inserted_object} <- Repo.insert(changeset) do
149 inserted_object
150 else
151 _ ->
152 object
153 end
154 end
155
156 @doc """
157 Enqueues an activity for federation if it's local
158 """
159 @spec maybe_federate(any()) :: :ok
160 def maybe_federate(%Activity{local: true} = activity) do
161 if Pleroma.Config.get!([:instance, :federating]) do
162 Pleroma.Web.Federator.publish(activity)
163 end
164
165 :ok
166 end
167
168 def maybe_federate(_), do: :ok
169
170 @doc """
171 Adds an id and a published data if they aren't there,
172 also adds it to an included object
173 """
174 @spec lazy_put_activity_defaults(map(), boolean) :: map()
175 def lazy_put_activity_defaults(map, fake? \\ false)
176
177 def lazy_put_activity_defaults(map, true) do
178 map
179 |> Map.put_new("id", "pleroma:fakeid")
180 |> Map.put_new_lazy("published", &make_date/0)
181 |> Map.put_new("context", "pleroma:fakecontext")
182 |> Map.put_new("context_id", -1)
183 |> lazy_put_object_defaults(true)
184 end
185
186 def lazy_put_activity_defaults(map, _fake?) do
187 %{data: %{"id" => context}, id: context_id} = create_context(map["context"])
188
189 map
190 |> Map.put_new_lazy("id", &generate_activity_id/0)
191 |> Map.put_new_lazy("published", &make_date/0)
192 |> Map.put_new("context", context)
193 |> Map.put_new("context_id", context_id)
194 |> lazy_put_object_defaults(false)
195 end
196
197 # Adds an id and published date if they aren't there.
198 #
199 @spec lazy_put_object_defaults(map(), boolean()) :: map()
200 defp lazy_put_object_defaults(%{"object" => map} = activity, true)
201 when is_map(map) do
202 object =
203 map
204 |> Map.put_new("id", "pleroma:fake_object_id")
205 |> Map.put_new_lazy("published", &make_date/0)
206 |> Map.put_new("context", activity["context"])
207 |> Map.put_new("context_id", activity["context_id"])
208 |> Map.put_new("fake", true)
209
210 %{activity | "object" => object}
211 end
212
213 defp lazy_put_object_defaults(%{"object" => map} = activity, _)
214 when is_map(map) do
215 object =
216 map
217 |> Map.put_new_lazy("id", &generate_object_id/0)
218 |> Map.put_new_lazy("published", &make_date/0)
219 |> Map.put_new("context", activity["context"])
220 |> Map.put_new("context_id", activity["context_id"])
221
222 %{activity | "object" => object}
223 end
224
225 defp lazy_put_object_defaults(activity, _), do: activity
226
227 @doc """
228 Inserts a full object if it is contained in an activity.
229 """
230 def insert_full_object(%{"object" => %{"type" => type} = object_data} = map)
231 when is_map(object_data) and type in @supported_object_types do
232 with {:ok, object} <- Object.create(object_data) do
233 map = Map.put(map, "object", object.data["id"])
234
235 {:ok, map, object}
236 end
237 end
238
239 def insert_full_object(map), do: {:ok, map, nil}
240
241 #### Like-related helpers
242
243 @doc """
244 Returns an existing like if a user already liked an object
245 """
246 @spec get_existing_like(String.t(), map()) :: Activity.t() | nil
247 def get_existing_like(actor, %{data: %{"id" => id}}) do
248 actor
249 |> Activity.Queries.by_actor()
250 |> Activity.Queries.by_object_id(id)
251 |> Activity.Queries.by_type("Like")
252 |> limit(1)
253 |> Repo.one()
254 end
255
256 @spec make_like_data(User.t(), map(), String.t()) :: map()
257 def make_like_data(
258 %User{ap_id: ap_id} = actor,
259 %{data: %{"actor" => object_actor_id, "id" => id}} = object,
260 activity_id
261 ) do
262 object_actor = User.get_cached_by_ap_id(object_actor_id)
263
264 to =
265 if Visibility.is_public?(object) do
266 [actor.follower_address, object.data["actor"]]
267 else
268 [object.data["actor"]]
269 end
270
271 cc =
272 (object.data["to"] ++ (object.data["cc"] || []))
273 |> List.delete(actor.ap_id)
274 |> List.delete(object_actor.follower_address)
275
276 %{
277 "type" => "Like",
278 "actor" => ap_id,
279 "object" => id,
280 "to" => to,
281 "cc" => cc,
282 "context" => object.data["context"]
283 }
284 |> maybe_put("id", activity_id)
285 end
286
287 @spec update_element_in_object(String.t(), list(any), Object.t()) ::
288 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
289 def update_element_in_object(property, element, object) do
290 data =
291 Map.merge(
292 object.data,
293 %{"#{property}_count" => length(element), "#{property}s" => element}
294 )
295
296 object
297 |> Changeset.change(data: data)
298 |> Object.update_and_set_cache()
299 end
300
301 @spec add_like_to_object(Activity.t(), Object.t()) ::
302 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
303 def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do
304 [actor | fetch_likes(object)]
305 |> Enum.uniq()
306 |> update_likes_in_object(object)
307 end
308
309 @spec remove_like_from_object(Activity.t(), Object.t()) ::
310 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
311 def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do
312 object
313 |> fetch_likes()
314 |> List.delete(actor)
315 |> update_likes_in_object(object)
316 end
317
318 defp update_likes_in_object(likes, object) do
319 update_element_in_object("like", likes, object)
320 end
321
322 defp fetch_likes(object) do
323 if is_list(object.data["likes"]) do
324 object.data["likes"]
325 else
326 []
327 end
328 end
329
330 #### Follow-related helpers
331
332 @doc """
333 Updates a follow activity's state (for locked accounts).
334 """
335 @spec update_follow_state_for_all(Activity.t(), String.t()) :: {:ok, Activity} | {:error, any()}
336 def update_follow_state_for_all(
337 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
338 state
339 ) do
340 "Follow"
341 |> Activity.Queries.by_type()
342 |> Activity.Queries.by_actor(actor)
343 |> Activity.Queries.by_object_id(object)
344 |> where(fragment("data->>'state' = 'pending'"))
345 |> update(set: [data: fragment("jsonb_set(data, '{state}', ?)", ^state)])
346 |> Repo.update_all([])
347
348 User.set_follow_state_cache(actor, object, state)
349
350 activity = Activity.get_by_id(activity.id)
351
352 {:ok, activity}
353 end
354
355 def update_follow_state(
356 %Activity{data: %{"actor" => actor, "object" => object}} = activity,
357 state
358 ) do
359 new_data = Map.put(activity.data, "state", state)
360 changeset = Changeset.change(activity, data: new_data)
361
362 with {:ok, activity} <- Repo.update(changeset) do
363 User.set_follow_state_cache(actor, object, state)
364 {:ok, activity}
365 end
366 end
367
368 @doc """
369 Makes a follow activity data for the given follower and followed
370 """
371 def make_follow_data(
372 %User{ap_id: follower_id},
373 %User{ap_id: followed_id} = _followed,
374 activity_id
375 ) do
376 %{
377 "type" => "Follow",
378 "actor" => follower_id,
379 "to" => [followed_id],
380 "cc" => [Pleroma.Constants.as_public()],
381 "object" => followed_id,
382 "state" => "pending"
383 }
384 |> maybe_put("id", activity_id)
385 end
386
387 def fetch_latest_follow(%User{ap_id: follower_id}, %User{ap_id: followed_id}) do
388 "Follow"
389 |> Activity.Queries.by_type()
390 |> where(actor: ^follower_id)
391 # this is to use the index
392 |> Activity.Queries.by_object_id(followed_id)
393 |> order_by([activity], fragment("? desc nulls last", activity.id))
394 |> limit(1)
395 |> Repo.one()
396 end
397
398 #### Announce-related helpers
399
400 @doc """
401 Retruns an existing announce activity if the notice has already been announced
402 """
403 @spec get_existing_announce(String.t(), map()) :: Activity.t() | nil
404 def get_existing_announce(actor, %{data: %{"id" => ap_id}}) do
405 "Announce"
406 |> Activity.Queries.by_type()
407 |> where(actor: ^actor)
408 # this is to use the index
409 |> Activity.Queries.by_object_id(ap_id)
410 |> Repo.one()
411 end
412
413 @doc """
414 Make announce activity data for the given actor and object
415 """
416 # for relayed messages, we only want to send to subscribers
417 def make_announce_data(
418 %User{ap_id: ap_id} = user,
419 %Object{data: %{"id" => id}} = object,
420 activity_id,
421 false
422 ) do
423 %{
424 "type" => "Announce",
425 "actor" => ap_id,
426 "object" => id,
427 "to" => [user.follower_address],
428 "cc" => [],
429 "context" => object.data["context"]
430 }
431 |> maybe_put("id", activity_id)
432 end
433
434 def make_announce_data(
435 %User{ap_id: ap_id} = user,
436 %Object{data: %{"id" => id}} = object,
437 activity_id,
438 true
439 ) do
440 %{
441 "type" => "Announce",
442 "actor" => ap_id,
443 "object" => id,
444 "to" => [user.follower_address, object.data["actor"]],
445 "cc" => [Pleroma.Constants.as_public()],
446 "context" => object.data["context"]
447 }
448 |> maybe_put("id", activity_id)
449 end
450
451 @doc """
452 Make unannounce activity data for the given actor and object
453 """
454 def make_unannounce_data(
455 %User{ap_id: ap_id} = user,
456 %Activity{data: %{"context" => context, "object" => object}} = activity,
457 activity_id
458 ) do
459 object = Object.normalize(object)
460
461 %{
462 "type" => "Undo",
463 "actor" => ap_id,
464 "object" => activity.data,
465 "to" => [user.follower_address, object.data["actor"]],
466 "cc" => [Pleroma.Constants.as_public()],
467 "context" => context
468 }
469 |> maybe_put("id", activity_id)
470 end
471
472 def make_unlike_data(
473 %User{ap_id: ap_id} = user,
474 %Activity{data: %{"context" => context, "object" => object}} = activity,
475 activity_id
476 ) do
477 object = Object.normalize(object)
478
479 %{
480 "type" => "Undo",
481 "actor" => ap_id,
482 "object" => activity.data,
483 "to" => [user.follower_address, object.data["actor"]],
484 "cc" => [Pleroma.Constants.as_public()],
485 "context" => context
486 }
487 |> maybe_put("id", activity_id)
488 end
489
490 @spec add_announce_to_object(Activity.t(), Object.t()) ::
491 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
492 def add_announce_to_object(
493 %Activity{data: %{"actor" => actor}},
494 object
495 ) do
496 announcements = take_announcements(object)
497
498 with announcements <- Enum.uniq([actor | announcements]) do
499 update_element_in_object("announcement", announcements, object)
500 end
501 end
502
503 def add_announce_to_object(_, object), do: {:ok, object}
504
505 @spec remove_announce_from_object(Activity.t(), Object.t()) ::
506 {:ok, Object.t()} | {:error, Ecto.Changeset.t()}
507 def remove_announce_from_object(%Activity{data: %{"actor" => actor}}, object) do
508 with announcements <- List.delete(take_announcements(object), actor) do
509 update_element_in_object("announcement", announcements, object)
510 end
511 end
512
513 defp take_announcements(%{data: %{"announcements" => announcements}} = _)
514 when is_list(announcements),
515 do: announcements
516
517 defp take_announcements(_), do: []
518
519 #### Unfollow-related helpers
520
521 def make_unfollow_data(follower, followed, follow_activity, activity_id) do
522 %{
523 "type" => "Undo",
524 "actor" => follower.ap_id,
525 "to" => [followed.ap_id],
526 "object" => follow_activity.data
527 }
528 |> maybe_put("id", activity_id)
529 end
530
531 #### Block-related helpers
532 @spec fetch_latest_block(User.t(), User.t()) :: Activity.t() | nil
533 def fetch_latest_block(%User{ap_id: blocker_id}, %User{ap_id: blocked_id}) do
534 "Block"
535 |> Activity.Queries.by_type()
536 |> where(actor: ^blocker_id)
537 # this is to use the index
538 |> Activity.Queries.by_object_id(blocked_id)
539 |> order_by([activity], fragment("? desc nulls last", activity.id))
540 |> limit(1)
541 |> Repo.one()
542 end
543
544 def make_block_data(blocker, blocked, activity_id) do
545 %{
546 "type" => "Block",
547 "actor" => blocker.ap_id,
548 "to" => [blocked.ap_id],
549 "object" => blocked.ap_id
550 }
551 |> maybe_put("id", activity_id)
552 end
553
554 def make_unblock_data(blocker, blocked, block_activity, activity_id) do
555 %{
556 "type" => "Undo",
557 "actor" => blocker.ap_id,
558 "to" => [blocked.ap_id],
559 "object" => block_activity.data
560 }
561 |> maybe_put("id", activity_id)
562 end
563
564 #### Create-related helpers
565
566 def make_create_data(params, additional) do
567 published = params.published || make_date()
568
569 %{
570 "type" => "Create",
571 "to" => params.to |> Enum.uniq(),
572 "actor" => params.actor.ap_id,
573 "object" => params.object,
574 "published" => published,
575 "context" => params.context
576 }
577 |> Map.merge(additional)
578 end
579
580 #### Listen-related helpers
581 def make_listen_data(params, additional) do
582 published = params.published || make_date()
583
584 %{
585 "type" => "Listen",
586 "to" => params.to |> Enum.uniq(),
587 "actor" => params.actor.ap_id,
588 "object" => params.object,
589 "published" => published,
590 "context" => params.context
591 }
592 |> Map.merge(additional)
593 end
594
595 #### Flag-related helpers
596 @spec make_flag_data(map(), map()) :: map()
597 def make_flag_data(%{actor: actor, context: context, content: content} = params, additional) do
598 %{
599 "type" => "Flag",
600 "actor" => actor.ap_id,
601 "content" => content,
602 "object" => build_flag_object(params),
603 "context" => context,
604 "state" => "open"
605 }
606 |> Map.merge(additional)
607 end
608
609 def make_flag_data(_, _), do: %{}
610
611 defp build_flag_object(%{account: account, statuses: statuses} = _) do
612 [account.ap_id] ++
613 Enum.map(statuses || [], fn act ->
614 id =
615 case act do
616 %Activity{} = act -> act.data["id"]
617 act when is_map(act) -> act["id"]
618 act when is_binary(act) -> act
619 end
620
621 activity = Activity.get_by_ap_id_with_object(id)
622 actor = User.get_by_ap_id(activity.object.data["actor"])
623
624 %{
625 "type" => "Note",
626 "id" => activity.data["id"],
627 "content" => activity.object.data["content"],
628 "published" => activity.object.data["published"],
629 "actor" => AccountView.render("show.json", %{user: actor})
630 }
631 end)
632 end
633
634 defp build_flag_object(_), do: []
635
636 @doc """
637 Fetches the OrderedCollection/OrderedCollectionPage from `from`, limiting the amount of pages fetched after
638 the first one to `pages_left` pages.
639 If the amount of pages is higher than the collection has, it returns whatever was there.
640 """
641 def fetch_ordered_collection(from, pages_left, acc \\ []) do
642 with {:ok, response} <- Tesla.get(from),
643 {:ok, collection} <- Jason.decode(response.body) do
644 case collection["type"] do
645 "OrderedCollection" ->
646 # If we've encountered the OrderedCollection and not the page,
647 # just call the same function on the page address
648 fetch_ordered_collection(collection["first"], pages_left)
649
650 "OrderedCollectionPage" ->
651 if pages_left > 0 do
652 # There are still more pages
653 if Map.has_key?(collection, "next") do
654 # There are still more pages, go deeper saving what we have into the accumulator
655 fetch_ordered_collection(
656 collection["next"],
657 pages_left - 1,
658 acc ++ collection["orderedItems"]
659 )
660 else
661 # No more pages left, just return whatever we already have
662 acc ++ collection["orderedItems"]
663 end
664 else
665 # Got the amount of pages needed, add them all to the accumulator
666 acc ++ collection["orderedItems"]
667 end
668
669 _ ->
670 {:error, "Not an OrderedCollection or OrderedCollectionPage"}
671 end
672 end
673 end
674
675 #### Report-related helpers
676
677 def update_report_state(%Activity{} = activity, state)
678 when state in @strip_status_report_states do
679 {:ok, stripped_activity} = strip_report_status_data(activity)
680
681 new_data =
682 activity.data
683 |> Map.put("state", state)
684 |> Map.put("object", stripped_activity.data["object"])
685
686 activity
687 |> Changeset.change(data: new_data)
688 |> Repo.update()
689 end
690
691 def update_report_state(%Activity{} = activity, state) when state in @supported_report_states do
692 new_data = Map.put(activity.data, "state", state)
693
694 activity
695 |> Changeset.change(data: new_data)
696 |> Repo.update()
697 end
698
699 def update_report_state(_, _), do: {:error, "Unsupported state"}
700
701 def strip_report_status_data(activity) do
702 [actor | reported_activities] = activity.data["object"]
703 stripped_activities = Enum.map(reported_activities, & &1["id"])
704 new_data = put_in(activity.data, ["object"], [actor | stripped_activities])
705
706 {:ok, %{activity | data: new_data}}
707 end
708
709 def update_activity_visibility(activity, visibility) when visibility in @valid_visibilities do
710 [to, cc, recipients] =
711 activity
712 |> get_updated_targets(visibility)
713 |> Enum.map(&Enum.uniq/1)
714
715 object_data =
716 activity.object.data
717 |> Map.put("to", to)
718 |> Map.put("cc", cc)
719
720 {:ok, object} =
721 activity.object
722 |> Object.change(%{data: object_data})
723 |> Object.update_and_set_cache()
724
725 activity_data =
726 activity.data
727 |> Map.put("to", to)
728 |> Map.put("cc", cc)
729
730 activity
731 |> Map.put(:object, object)
732 |> Activity.change(%{data: activity_data, recipients: recipients})
733 |> Repo.update()
734 end
735
736 def update_activity_visibility(_, _), do: {:error, "Unsupported visibility"}
737
738 defp get_updated_targets(
739 %Activity{data: %{"to" => to} = data, recipients: recipients},
740 visibility
741 ) do
742 cc = Map.get(data, "cc", [])
743 follower_address = User.get_cached_by_ap_id(data["actor"]).follower_address
744 public = Pleroma.Constants.as_public()
745
746 case visibility do
747 "public" ->
748 to = [public | List.delete(to, follower_address)]
749 cc = [follower_address | List.delete(cc, public)]
750 recipients = [public | recipients]
751 [to, cc, recipients]
752
753 "private" ->
754 to = [follower_address | List.delete(to, public)]
755 cc = List.delete(cc, public)
756 recipients = List.delete(recipients, public)
757 [to, cc, recipients]
758
759 "unlisted" ->
760 to = [follower_address | List.delete(to, public)]
761 cc = [public | List.delete(cc, follower_address)]
762 recipients = recipients ++ [follower_address, public]
763 [to, cc, recipients]
764
765 _ ->
766 [to, cc, recipients]
767 end
768 end
769
770 def get_existing_votes(actor, %{data: %{"id" => id}}) do
771 actor
772 |> Activity.Queries.by_actor()
773 |> Activity.Queries.by_type("Create")
774 |> Activity.with_preloaded_object()
775 |> where([a, object: o], fragment("(?)->>'inReplyTo' = ?", o.data, ^to_string(id)))
776 |> where([a, object: o], fragment("(?)->>'type' = 'Answer'", o.data))
777 |> Repo.all()
778 end
779
780 def maybe_put(map, _key, nil), do: map
781 def maybe_put(map, key, value), do: Map.put(map, key, value)
782 end