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