defmodule Glific.Flows.Case do
@moduledoc """
The Case object which encapsulates one category in a given node.
"""
alias __MODULE__
use Ecto.Schema
alias Glific.{
Enums.FlowCase,
Flows,
Messages.Message
}
alias Glific.Flows.{
Category,
FlowContext,
Localization
}
alias Pow.Ecto.Schema.Changeset
@required_fields [:uuid, :type, :arguments, :category_uuid]
@type t() :: %__MODULE__{
uuid: Ecto.UUID.t() | nil,
type: String.t() | nil,
arguments: [String.t()],
category_uuid: Ecto.UUID.t() | nil,
category: Category.t() | nil
}
embedded_schema do
field :uuid, Ecto.UUID
field :name, :string
field :type, FlowCase
field :arguments, {:array, :string}, default: []
field :parsed_arguments, :map
field :category_uuid, Ecto.UUID
embeds_one :category, Category
end
@doc """
Process a json structure from flow editor to the Glific data types
"""
@spec process(map(), map(), any) :: {Case.t(), map()}
def process(json, uuid_map, _object \\ nil) do
Flows.check_required_fields(json, @required_fields)
# Check that the category_uuid exists, if not raise an error
if !Map.has_key?(uuid_map, json["category_uuid"]),
do: raise(ArgumentError, message: "Category ID does not exist for Case: #{json["uuid"]}")
c = %Case{
uuid: json["uuid"],
category_uuid: json["category_uuid"],
# type: (if json["type"] == "has_any_word", do: "has_multiple", else: json["type"]),
type: json["type"],
arguments: json["arguments"]
}
c = update_parsed_arguments(c, json["arguments"])
{c, Map.put(uuid_map, c.uuid, {:case, c})}
end
# Update the parsed_arguments field of the case
@spec update_parsed_arguments(Case.t(), [String.t()]) :: Case.t()
defp update_parsed_arguments(%{type: type} = flow_case, arguments)
when type in ["has_multiple", "has_any_word", "has_all_words"] do
parsed_arguments =
arguments
|> hd()
|> Glific.make_set()
Map.put(flow_case, :parsed_arguments, parsed_arguments)
end
defp update_parsed_arguments(flow_case, _arguments), do: flow_case
@doc """
Validate a case
"""
@spec validate(Case.t(), Keyword.t(), map(), boolean()) :: Keyword.t()
def validate(_case, errors, _flow, wait) when is_nil(wait), do: errors
def validate(%{arguments: arguments} = _case, errors, flow, _wait) when arguments != [] do
wait_for_response_words =
arguments
|> List.first()
|> String.split(", ")
|> Enum.reduce(MapSet.new(), &MapSet.put(&2, Glific.string_clean(&1)))
|> MapSet.delete(nil)
flow_keywords =
Glific.Flows.flow_keywords_map(flow.organization_id)["published"]
|> Map.keys()
|> Enum.reduce(MapSet.new(), &MapSet.put(&2, &1))
if MapSet.disjoint?(flow_keywords, wait_for_response_words) do
errors
else
used_flow_keywords = MapSet.intersection(flow_keywords, wait_for_response_words)
Enum.reduce(
used_flow_keywords,
errors,
&(&2 ++ [flowContext: "\"#{&1}\" has already been used as a keyword for a flow"])
)
end
end
def validate(_case, errors, _flow, _wait), do: errors
@spec strip(any()) :: String.t()
defp strip(msgs) when is_list(msgs),
do: msgs |> hd() |> strip()
defp strip(%{body: body} = _msg),
do: strip(body)
defp strip(msg) when is_binary(msg),
do: msg |> String.trim() |> String.downcase()
defp strip(_msg), do: ""
@text_types [:text, :quick_reply, :list]
@text_fns [
"has_number_eq",
"has_number_between",
"has_number",
"has_any_word",
"has_phrase",
"has_only_phrase",
"has_only_text",
"has_all_words",
"has_multiple",
"has_phone",
"has_email",
"has_pattern",
"has_beginning",
"has_intent",
"has_top_intent"
]
@doc """
Execute a case, given a message.
This is the only execute function which has a different signature, since
it just consumes one message at a time and executes it against a predefined function
It also returns a boolean, rather than a tuple
"""
@spec execute(Case.t(), FlowContext.t(), Message.t()) :: boolean()
def execute(flow_case, context, msg) do
translated_arguments = Localization.get_translated_case_arguments(context, flow_case)
Map.put(flow_case, :arguments, translated_arguments)
|> update_parsed_arguments(translated_arguments)
|> do_execute(context, msg)
end
@spec do_execute(Case.t(), FlowContext.t(), Message.t()) :: boolean()
defp do_execute(%{type: "has_number_eq"} = c, _context, %{type: type} = msg)
when type in @text_types,
do: strip(c.arguments) == strip(msg)
defp do_execute(%{type: "has_number_between"} = c, _context, %{type: type} = msg)
when type in @text_types do
[low, high] = c.arguments
# convert all 3 parameters to number
[low, high, body] = Enum.map([low, high, msg.body], &Glific.parse_maybe_number/1)
# ensure no errors
if Enum.all?([low, high, body], &(&1 != :error)) do
[low, high, body] = Enum.map([low, high, body], &elem(&1, 1))
body >= low && body <= high
else
false
end
end
defp do_execute(%{type: "has_number"}, _context, %{type: type} = msg)
when type in @text_types,
do: String.contains?(msg.clean_body, Enum.to_list(0..9) |> Enum.map(&Integer.to_string/1))
defp do_execute(%{type: "has_any_word"} = c, _context, %{type: type} = msg)
when type in @text_types do
str = msg |> strip() |> Glific.make_set([",", ";", " "])
!MapSet.disjoint?(str, c.parsed_arguments)
end
defp do_execute(%{type: "has_phrase"} = c, _context, %{type: type} = msg)
when type in @text_types,
do: String.contains?(strip(msg), strip(c.arguments))
defp do_execute(%{type: ctype} = c, _context, %{type: type} = msg)
when ctype in ["has_only_phrase", "has_only_text"] and type in @text_types,
do: strip(c.arguments) == strip(msg)
defp do_execute(%{type: "has_all_words"} = c, _context, %{type: type} = msg)
when type in @text_types do
str = msg |> strip() |> Glific.make_set([",", ";", " "])
c.parsed_arguments |> MapSet.subset?(str)
end
defp do_execute(%{type: "has_multiple"} = c, _context, %{type: type} = msg)
when type in @text_types,
do:
msg.body
|> Glific.make_set()
|> MapSet.subset?(c.parsed_arguments)
defp do_execute(%{type: "has_phone"} = _c, _context, %{type: type} = msg)
when type in @text_types do
phone = strip(msg)
case ExPhoneNumber.parse(phone, "IN") do
{:ok, phone_number} -> ExPhoneNumber.is_valid_number?(phone_number)
_ -> false
end
end
defp do_execute(%{type: "has_email"} = _c, _context, %{type: type} = msg)
when type in @text_types do
email = strip(msg)
case Changeset.validate_email(email) do
:ok -> true
_ -> false
end
end
defp do_execute(%{type: "has_pattern"} = c, _context, %{type: type} = msg)
when type in @text_types,
do:
c.arguments
|> strip()
|> Regex.compile!()
|> Regex.match?(strip(msg))
defp do_execute(%{type: "has_beginning"} = c, _context, %{type: type} = msg)
when type in @text_types do
msg
|> strip()
|> String.starts_with?(strip(c.arguments))
end
defp do_execute(%{type: ctype} = c, _context, %{type: type} = msg)
when type in @text_types and
ctype in ["has_intent", "has_top_intent"] do
[intent, confidence] = c.arguments
# always prepend a 0 to the string, in case it is something like ".9",
# this also works with "0.9"
confidence = String.to_float("0" <> confidence)
if intent == "all",
# any intent is fine, we are only interested in the confidence level
do: msg.extra.confidence >= confidence,
else: msg.extra.intent == intent && msg.extra.confidence >= confidence
end
# for all the above functions, if we encounter in a non-text context, return false
defp do_execute(%{type: ctype}, _context, %{type: type})
when ctype in @text_fns and type not in @text_types,
do: false
defp do_execute(%{type: "has_group"} = c, _context, msg) do
[_group_id, group_label] = c.arguments
group_label in Map.get(msg.extra, :contact_groups, [])
end
defp do_execute(%{type: "has_category"}, _context, _msg), do: true
defp do_execute(%{type: "has_location"}, _context, msg),
do: msg.type == :location
defp do_execute(%{type: "has_media"}, _context, msg),
do: Flows.is_media_type?(msg.type)
defp do_execute(%{type: "has_audio"}, _context, msg),
do: msg.type == :audio
defp do_execute(%{type: "has_video"}, _context, msg),
do: msg.type == :video
defp do_execute(%{type: "has_image"}, _context, msg),
do: msg.type == :image
defp do_execute(%{type: "has_file"}, _context, msg),
do: msg.type == :document
defp do_execute(c, _context, msg),
do:
raise(UndefinedFunctionError,
message:
"Function not implemented for cases of case type: #{c.type}, message type: #{msg.type}"
)
end