This repository has been archived by the owner on Jun 11, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 10
/
macros.ex
435 lines (356 loc) · 11.7 KB
/
macros.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# credo:disable-for-this-file Credo.Check.Refactor.CyclomaticComplexity
defmodule Helix.Story.Model.Step.Macros do
@moduledoc """
Macros for the Step DSL.
You probably don't want to mess with this module directly. Read the Steppable
documentation instead.
"""
import HELL.Macros
alias HELL.Constant
alias HELL.Utils
alias Helix.Entity.Model.Entity
alias Helix.Story.Model.Step
alias Helix.Story.Action.Story, as: StoryAction
alias Helix.Story.Query.Story, as: StoryQuery
alias Helix.Story.Event.Reply.Sent, as: StoryReplySentEvent
alias Helix.Story.Event.Step.ActionRequested, as: StepActionRequestedEvent
defmacro step(name, contact \\ nil, do: block) do
quote location: :keep do
defmodule unquote(name) do
@moduledoc false
require Helix.Story.Model.Step
Helix.Story.Model.Step.register()
defimpl Helix.Story.Model.Steppable do
@moduledoc false
import HELL.Macros
alias Helix.Event
alias Helix.Story.Make.Story, as: StoryMake
@emails Module.get_attribute(__MODULE__, :emails) || %{}
@contact get_contact(unquote(contact), __MODULE__)
@step_name Helix.Story.Model.Step.get_name(unquote(name))
unquote(block)
# Most steps do not have a "restart" option. Those who do must
# manually implement this protocol function.
@doc false
def restart(_step, _, _),
do: raise "Undefined restart handler at #{inspect unquote(__MODULE__)}"
# Catch-all for unhandled events, otherwise any unexpected event would
# thrown an exception here.
@doc false
def handle_event(step, _event, _meta),
do: {:noop, step, []}
@doc false
def format_meta(%{meta: meta}),
do: meta
@doc false
def get_contact(_),
do: @contact
@spec get_replies(Step.t, Step.email_id) ::
[Step.reply_id]
@doc false
# Unlocked replies only
def get_replies(_step, email_id) do
with email = %{} <- Map.get(@emails, email_id, []) do
email.replies
end
end
@spec handle_callback(
{Step.callback_action, [Event.t]}, Entity.id, Step.contact)
::
{:ok, [Event.t]}
defp handle_callback({action, events}, entity_id, contact_id) do
request_action =
StepActionRequestedEvent.new(action, entity_id, contact_id)
{:ok, events ++ [request_action]}
end
@doc false
callback :callback_complete do
{:complete, []}
end
end
end
end
end
@doc """
Generates a callback ready to be executed as a response for some element that
is being listened through `story_listen`.
"""
defmacro callback(
name,
event \\ quote(do: _),
meta \\ quote(do: _),
do: block)
do
quote do
def unquote(name)(var!(event) = unquote(event), meta = unquote(meta)) do
step_entity_id = meta["step_entity_id"] |> Entity.ID.cast!()
step_contact_id = meta["step_contact_id"] |> String.to_existing_atom()
var!(event) # Mark as used
unquote(block)
|> handle_callback(step_entity_id, step_contact_id)
end
end
end
@doc """
Executes a predefined callback (currently only `:complete` is supported).
"""
defmacro story_listen(element_id, events, do: action) do
quote do
callback_name = Utils.concat_atom(:callback_, unquote(action))
story_listen(unquote(element_id), unquote(events), callback_name)
end
end
@doc """
Executes `callback` when `event` happens over `element_id`.
It's a wrapper for `Core.Listener`.
"""
defmacro story_listen(element_id, events, callback) do
# Import `Helix.Core.Listener` only once within the Step context (ENV)
macro = has_macro?(__CALLER__, Helix.Core.Listener)
import_block = macro && [] || quote(do: import Helix.Core.Listener)
quote do
unquote(import_block)
listen unquote(element_id), unquote(events), unquote(callback),
owner_id: var!(step).entity_id,
subscriber: @step_name,
meta: %{
step_entity_id: var!(step).entity_id,
step_contact_id: var!(step).contact
}
end
end
@doc """
Formats the step metadata, automatically handling empty maps or atomizing
existing map keys.
"""
defmacro format_meta(do: block) do
quote do
@doc false
def format_meta(%{meta: empty_map}) when empty_map == %{},
do: %{}
@doc false
def format_meta(%{meta: meta}) do
var!(meta) = HELL.MapUtils.atomize_keys(meta)
unquote(block)
end
end
end
@doc """
Public interface that should be used by the step to point to the next one.
Steps are linked lists. Mind == blown.
"""
defmacro next_step(next_step_module) do
quote do
# unless Code.ensure_compiled?(unquote(next_step_module)) do
# raise "The step #{inspect unquote(next_step_module)} does not exist"
# end
# Verification above is only possible if
# 1 - We manage to verify on a second round of compilation; OR
# 2 - We can force the `step_module` to be compiled first; OR
# 3 - We store each step on a separate file; OR
# 4 - We sort steps.ex from the last to the first step.
# I don't want neither 3 or 4. Waiting for a cool hack on 1 or 2.
@doc """
Returns the next step module name (#{inspect unquote(next_step_module)}).
"""
def next_step(_),
do: Helix.Story.Model.Step.get_name(unquote(next_step_module))
end
end
defmacro email(email_id, opts \\ []) do
prev_emails = get_emails(__CALLER__) || %{}
email = add_email(email_id, opts)
emails = Map.merge(prev_emails, email)
set_emails(__CALLER__, emails)
end
defmacro send_email(step, email_id, email_meta \\ quote(do: %{})) do
emails = get_emails(__CALLER__) || %{}
unless email_exists?(emails, email_id) do
raise \
"cant send email #{inspect email_id} on step " <>
"#{inspect __CALLER__.module}; undefined"
end
quote do
{:ok, events} =
StoryAction.send_email(
unquote(step), unquote(email_id), unquote(email_meta)
)
events
end
end
@doc """
Filters any events (handled by StoryHandler), performing the requested action.
"""
defmacro filter(step, event, meta, opts) do
quote do
@doc false
def handle_event(step = unquote(step), unquote(event), unquote(meta)) do
unquote(
case opts do
[do: block] ->
block
[send: email_id] ->
quote do
event =
send_email \
step,
unquote(email_id),
Keyword.get(unquote(opts), :meta, %{})
{:noop, step, event}
end
:complete ->
quote do
{:complete, step, []}
end
[restart: true, reason: reason, checkpoint: checkpoint] ->
quote do
{{:restart, unquote(reason), unquote(checkpoint)}, step, []}
end
end
)
end
end
end
@doc """
Interface used to declare what should happen when `reply_id` is received.
"""
defmacro on_reply(reply_id, opts) do
# Emails that can receive this reply
emails = get_emails(__CALLER__)
valid_emails = get_emails_with_reply(emails, reply_id)
for email <- valid_emails do
quote do
filter(
step,
%StoryReplySentEvent{
reply: %{id: unquote(reply_id)},
reply_to: unquote(email)
},
_,
unquote(opts)
)
end
end
end
@doc """
This macro is required so the elixir compiler does not complain about the
module attribute not being used.
"""
defmacro contact(contact_name) do
quote do
@contact unquote(contact_name)
end
end
@doc """
Helper (syntactic sugar) for steps that do not generate any data.
"""
defmacro empty_setup do
quote do
@doc false
def setup(_) do
nil
end
end
end
defmacro setup_once(object, identifier, do: block),
do: do_setup_once(object, identifier, [], block)
defmacro setup_once(object, identifier, opts, do: block),
do: do_setup_once(object, identifier, opts, block)
defp do_setup_once(object, id, opts, block) do
fun_name = Utils.concat_atom(:find_, object)
quote do
result =
apply(StoryQuery.Setup, unquote(fun_name), [unquote(id), unquote(opts)])
with nil <- result do
unquote(block)
end
end
end
@spec get_contact(String.t | Constant.t | nil, module :: term) ::
contact :: Step.contact
@doc """
If the given contact is a string or atom, then the `step` explicitly specified
a contact. On the other hand, if it's not a string/atom (defaults to `nil`),
then no contact was specified at the step level. In this case, we'll fall back
to the contact defined for the mission. This is the most common scenario.
"""
def get_contact(contact, _) when is_binary(contact),
do: String.to_atom(contact)
def get_contact(contact, _) when not is_nil(contact),
do: contact
def get_contact(_, step_module) do
mission_contact =
step_module
|> Module.split()
|> Enum.drop(4) # Remove protocol namespace
|> Enum.drop(-1) # Get parent
|> Module.concat()
|> Module.get_attribute(:contact)
if is_nil(mission_contact),
do: raise "No contact for top-level mission at #{inspect step_module}"
get_contact(mission_contact, step_module)
end
@spec add_email(Step.email_id, term) ::
Step.emails
docp """
Given an email id and its options, convert it to the internal format defined
by `Step.emails`, which is a map using `email_id` as lookup key.
"""
defp add_email(email_id, opts) do
metadata = %{
id: email_id,
replies: Utils.ensure_list(opts[:reply]),
locked: Utils.ensure_list(opts[:locked])
}
Map.put(%{}, email_id, metadata)
end
@spec get_emails_with_reply(Step.emails, Step.reply_id) ::
[Step.email_id]
docp """
Helper used to identify all emails that can receive the given `reply_id`.
It is used to generate the `handle_event` filter by the `on_reply` macro,
ensuring that only the subset of (emails that expect reply_id) are pattern
matched against.
"""
defp get_emails_with_reply(emails, reply_id) do
Enum.reduce(emails, [], fn {id, email}, acc ->
cond do
Enum.member?(email.replies, reply_id) ->
acc ++ [id]
Enum.member?(email.locked, reply_id) ->
acc ++ [id]
true ->
acc
end
end)
end
@spec email_exists?(Step.emails, Step.email_id) ::
boolean
docp """
Helper to check whether the given email has been defined
"""
defp email_exists?(emails, email_id),
do: Map.get(emails, email_id, false) && true
@spec get_emails(Macro.Env.t) ::
Step.emails
| nil
docp """
Helper to read the module attribute `emails`
"""
defp get_emails(%Macro.Env{module: module}),
do: Module.get_attribute(module, :emails)
@spec set_emails(Macro.Env.t, Step.emails) ::
:ok
docp """
Helper to set the module attribute `emails`
"""
defp set_emails(%Macro.Env{module: module}, emails),
do: Module.put_attribute(module, :emails, emails)
@spec has_macro?(Macro.Env.t, module) ::
boolean
docp """
Helper that checks whether the given module has already been imported
"""
defp has_macro?(env, macro),
do: Enum.any?(env.macros, fn {module, _} -> module == macro end)
end