From 5f79dc18cfb7acfddaa35e8c89a33f041df6e220 Mon Sep 17 00:00:00 2001 From: Adam Zaninovich Date: Wed, 13 Sep 2017 16:18:43 -0700 Subject: [PATCH 1/2] initial pass at simplifying the interface for making a custom adapter --- lib/alice/adapter.ex | 115 ++++++++++++++++++++++++++++++---- lib/alice/adapters/console.ex | 22 +++---- lib/alice/adapters/noop.ex | 36 +++++------ lib/alice/adapters/test.ex | 19 +++--- 4 files changed, 133 insertions(+), 59 deletions(-) diff --git a/lib/alice/adapter.ex b/lib/alice/adapter.ex index b5796f3..aa1ce38 100644 --- a/lib/alice/adapter.ex +++ b/lib/alice/adapter.ex @@ -7,32 +7,86 @@ defmodule Alice.Adapter do `Alice.Message` struct and call `Alice.Bot.handle_message(bot, msg)`. """ - @type bot :: pid - @type state :: term - @type opts :: any - @type msg :: Alice.Message.t + @typedoc "The pid of the bot process" + @type bot :: pid - @callback reply(bot, msg) :: term + @typedoc "The state for the adapter. Normally a Map, but can be anything" + @type state :: any - @doc false + @typedoc "Initialization options for the adapter" + @type options :: keyword + + @typedoc "An Alice.Message struct" + @type msg :: Alice.Message.t + + @doc """ + Invoked when the adapter starts. Use this callback to connect to + your service and initialize any state that the adapter needs to + keep track of. + + In case of successful start, this callback must return a tuple + where the first element is `:ok` and the second is the state. + + Returning `{:stop, reason}` will cause `start_link/3` to return + `{:error, reason}` and the process to exit with reason `reason` + without entering the loop or calling `terminate/2`. + """ + @callback connect(bot, options) :: + {:ok, state} | + {:stop, reason :: any} + + @doc """ + This is an optional callback that may be implemented if you need to + do any work after a successful connection. + """ + @callback handle_connected(state) :: {:ok, state} + + @doc """ + This callback is required and handles outgoing messages from the bot + process to your service. This is the appropriate place to translate + the `Alice.Message` struct into a message format that your service + understands and send the message to the external service. + """ + @callback handle_outgoing(msg, state) :: {:ok, state} + + @doc """ + This callback is required and handles incoming messages from your + external service to a bot instance. This is the appropriate place + to translate a message from your service into an `Alice.Message` + struct. + + Returning `{:ok, msg, state}` will send the message to the bot + instance to be dispatched to handlers. + + Returning `{:noreply, state}` will effectively ignore the message + and nothing will be dispatched to the handlers. + """ + @callback handle_incoming(ext_msg, state) :: {:ok, msg, state} + | {:noreply, state} + + @doc "start an adapter" def start_link(adapter_module, bot_pid, opts) do GenServer.start_link(adapter_module, {bot_pid, opts}) end + @doc "send an outgoing message through an adapter" + def reply(adapter_pid, %Alice.Message{} = msg) do + GenServer.cast(adapter_pid, {:outgoing, msg}) + end + @doc false defmacro __using__(_opts) do quote do use GenServer @behaviour Alice.Adapter - def reply(pid, %Alice.Message{} = msg) do - GenServer.cast(pid, {:reply, msg}) - end + @doc false + def handle_connected(state), do: {:ok, state} @doc false - def stop(bot, timeout \\ 5000) do - ref = Process.monitor(bot) - Process.exit(bot, :normal) + def stop(adapter, timeout \\ 5000) do + ref = Process.monitor(adapter) + Process.exit(adapter, :normal) receive do {:DOWN, ^ref, _, _, _} -> :ok after @@ -41,7 +95,42 @@ defmodule Alice.Adapter do :ok end - defoverridable [reply: 2] + @doc false + def init({bot, opts}) do + with {:ok, state} <- connect(bot, opts) do + send(self(), :connected) + {:ok, {bot, state}} + else + {:exit, reason} -> {:exit, reason} + end + end + + @doc false + def handle_cast({:outgoing, %Alice.Message{} = msg}, {bot, state}) do + {:ok, state} = handle_outgoing(msg, state) + {:noreply, {bot, state}} + end + + @doc false + def handle_info(:connected, {bot, state}) do + :ok = Alice.Bot.handle_connect(bot) + {:ok, state} = handle_connected(state) + {:noreply, {bot, state}} + end + def handle_info({:incoming, ext_msg}, {bot, state}) do + state = case handle_incoming(ext_msg, state) do + {:ok, %Alice.Message{} = msg, state} -> + Alice.Bot.handle_in(bot, msg) + state + {:noreply, state} -> state + end + {:noreply, {bot, state}} + end + + defoverridable [connect: 2, + handle_connected: 1, + handle_outgoing: 2, + handle_incoming: 2] end end end diff --git a/lib/alice/adapters/console.ex b/lib/alice/adapters/console.ex index 8d427f2..a9fed9c 100644 --- a/lib/alice/adapters/console.ex +++ b/lib/alice/adapters/console.ex @@ -16,27 +16,19 @@ defmodule Alice.Adapters.Console do use Alice.Adapter alias Alice.Adapters.Console.Connection - @doc false - def init({bot, opts}) do + def connect(bot, opts) do {:ok, conn} = Connection.start(opts) - send(self(), :connected) {:ok, %{conn: conn, opts: opts, bot: bot}} end - @doc false - def handle_cast({:reply, msg}, %{conn: conn} = state) do - send(conn, {:reply, msg}) - {:noreply, state} + def handle_outgoing(%Alice.Message{text: text}, %{conn: conn} = state) do + send(conn, {:reply, text}) + {:ok, state} end - @doc false - def handle_info(:connected, %{bot: bot} = state) do - :ok = Alice.Bot.handle_connect(bot) - {:noreply, state} - end - def handle_info({:message, msg}, %{bot: bot} = state) do - Alice.Bot.handle_in(bot, make_msg(bot, msg)) - {:noreply, state} + def handle_incoming(ext_msg, %{bot: bot} = state) do + msg = make_msg(bot, ext_msg) + {:ok, msg, state} end defp make_msg(bot, %{"text" => text, "user" => user}) do diff --git a/lib/alice/adapters/noop.ex b/lib/alice/adapters/noop.ex index a21fbc3..0d5bfe4 100644 --- a/lib/alice/adapters/noop.ex +++ b/lib/alice/adapters/noop.ex @@ -2,7 +2,9 @@ defmodule Alice.Adapters.NoOp do @moduledoc """ Alice No Op Adapter - The console adapter doesn't do anything. + The NoOp adapter doesn't do anything other than logging. It may be useful + for testing and debugging. It is also a decent example of the simplest + possible Alice adapter. config :my_app, MyApp.Bot, adapter: Alice.Adapters.NoOp, @@ -13,35 +15,31 @@ defmodule Alice.Adapters.NoOp do """ use Alice.Adapter - @doc false - def init({bot, _opts}) do - send(self(), :connected) + def connect(bot, _opts) do {:ok, bot} end - @doc false - def handle_cast({:reply, msg}, bot) do - IO.puts "got reply message #{inspect msg} from bot #{inspect bot}" - {:noreply, bot} + def handle_connect(bot) do + IO.puts "NoOp connected" + {:ok, bot} end - @doc false - def handle_info(:connected, bot) do - :ok = Alice.Bot.handle_connect(bot) - IO.puts "NoOp connected" - {:noreply, bot} + def handle_outgoing(msg, bot) do + IO.puts "got outgoing message #{inspect msg} from bot #{inspect bot}" + {:ok, bot} end - def handle_info({:message, msg}, bot) do - IO.puts "received message #{inspect msg} for bot #{inspect bot}" - Alice.Bot.handle_in(bot, make_msg(bot, msg)) - {:noreply, bot} + + def handle_incoming(text, bot) do + IO.puts "received message #{inspect text} for bot #{inspect bot}" + msg = make_msg(bot, text) + {:ok, msg, bot} end - defp make_msg(bot, msg) do + defp make_msg(bot, text) do %Alice.Message{ ref: make_ref(), bot: bot, - text: msg, + text: text, room: "noop", adapter: {__MODULE__, self()}, type: "chat", diff --git a/lib/alice/adapters/test.ex b/lib/alice/adapters/test.ex index be7275e..5d54305 100644 --- a/lib/alice/adapters/test.ex +++ b/lib/alice/adapters/test.ex @@ -3,26 +3,21 @@ defmodule Alice.Adapters.Test do use Alice.Adapter - def init({bot, opts}) do - GenServer.cast(self(), :after_init) + def init(bot, opts) do {:ok, %{conn: nil, opts: opts, bot: bot}} end - def handle_cast(:after_init, %{bot: bot} = state) do - Alice.Bot.handle_connect(bot) - {:noreply, state} - end - - def handle_cast({:reply, msg}, %{conn: conn} = state) do + def handle_outgoing(%Alice.Message{} = msg, %{conn: conn} = state) do send(conn, {:reply, msg}) - {:noreply, state} + {:ok, state} end - def handle_info({:message, msg}, %{bot: bot} = state) do + def handle_incoming(msg, %{bot: bot} = state) do msg = %Alice.Message{bot: bot, adapter: {__MODULE__, self()}, text: msg.text, user: msg.user} - Alice.Bot.handle_in(bot, msg) - {:noreply, state} + {:ok, msg, state} end + + # ? def handle_info(msg, %{bot: bot} = state) do Alice.Bot.handle_in(bot, msg) {:noreply, state} From 4f0f5219602f9ac1818add74db61d6cd1c2f31c2 Mon Sep 17 00:00:00 2001 From: Adam Zaninovich Date: Sat, 27 Jan 2018 13:02:59 -0800 Subject: [PATCH 2/2] tests passing --- lib/alice/adapter.ex | 12 +++--- lib/alice/adapters/test.ex | 7 ++-- lib/alice/bot.ex | 35 ++++++++--------- lib/alice/test/bot_case.ex | 56 +++++++++++++++++----------- test/alice/adapters/console_test.exs | 49 ++++++++++++------------ test/alice/bot_test.exs | 5 ++- test/alice/handler_test.exs | 4 +- 7 files changed, 91 insertions(+), 77 deletions(-) diff --git a/lib/alice/adapter.ex b/lib/alice/adapter.ex index aa1ce38..1c8c274 100644 --- a/lib/alice/adapter.ex +++ b/lib/alice/adapter.ex @@ -61,8 +61,9 @@ defmodule Alice.Adapter do Returning `{:noreply, state}` will effectively ignore the message and nothing will be dispatched to the handlers. """ - @callback handle_incoming(ext_msg, state) :: {:ok, msg, state} - | {:noreply, state} + @callback handle_incoming(ext_msg :: any, state) :: + {:ok, msg, state} | + {:noreply, state} @doc "start an adapter" def start_link(adapter_module, bot_pid, opts) do @@ -80,7 +81,7 @@ defmodule Alice.Adapter do use GenServer @behaviour Alice.Adapter - @doc false + @doc "Default implementation for `handle_connected`" def handle_connected(state), do: {:ok, state} @doc false @@ -127,10 +128,7 @@ defmodule Alice.Adapter do {:noreply, {bot, state}} end - defoverridable [connect: 2, - handle_connected: 1, - handle_outgoing: 2, - handle_incoming: 2] + defoverridable handle_connected: 1 end end end diff --git a/lib/alice/adapters/test.ex b/lib/alice/adapters/test.ex index 5d54305..ecd4f2b 100644 --- a/lib/alice/adapters/test.ex +++ b/lib/alice/adapters/test.ex @@ -3,7 +3,7 @@ defmodule Alice.Adapters.Test do use Alice.Adapter - def init(bot, opts) do + def connect(bot, opts) do {:ok, %{conn: nil, opts: opts, bot: bot}} end @@ -17,9 +17,8 @@ defmodule Alice.Adapters.Test do {:ok, msg, state} end - # ? - def handle_info(msg, %{bot: bot} = state) do + def handle_info({:message, msg}, {bot, state}) do Alice.Bot.handle_in(bot, msg) - {:noreply, state} + {:noreply, {bot, state}} end end diff --git a/lib/alice/bot.ex b/lib/alice/bot.ex index b55bd90..5df0f39 100644 --- a/lib/alice/bot.ex +++ b/lib/alice/bot.ex @@ -152,7 +152,18 @@ defmodule Alice.Bot do defp register_adapters(opts) do {adapters, opts} = Keyword.pop(opts, :adapters, []) - GenServer.cast(self(), {:register_adapters, adapters, opts}) + adapters = for adapter <- ensure_adapter(adapters) do + case adapter do + {name, mod} -> + {:ok, pid} = Supervisor.start_child(Alice.Adapter.Supervisor, [mod, self(), opts]) + ProcessUtils.register_eventually(pid, name) + mod + mod when is_atom(mod) -> + {:ok, _pid} = Supervisor.start_child(Alice.Adapter.Supervisor, [mod, self(), opts]) + mod + end + end + GenServer.cast(self(), {:update_adapters, adapters}) {adapters, opts} end @@ -179,7 +190,8 @@ defmodule Alice.Bot do Alice.Handler.Supervisor |> Supervisor.which_children() |> Enum.map(fn({_,pid,_,_}) -> - {_,[{_,{mod,_,_}}|_]} = Process.info(pid, :dictionary) + {:dictionary, info} = Process.info(pid, :dictionary) + {mod, _, _} = Keyword.get(info, :"$initial_call") {mod, pid} end) {:reply, handler_processes, state} @@ -202,8 +214,8 @@ defmodule Alice.Bot do end def handle_cast({:reply, msg}, state) do - {adapter_mod, adapter_pid} = msg.adapter - adapter_mod.reply(adapter_pid, msg) + {_mod, pid} = msg.adapter + Alice.Adapter.reply(pid, msg) {:noreply, state} end def handle_cast({:handle_in, msg}, state) do @@ -232,22 +244,11 @@ defmodule Alice.Bot do handlers = ensure_builtin_handlers(handlers) for handler <- handlers do {:ok, pid} = Supervisor.start_child(Alice.Handler.Supervisor, [handler, {name, self()}]) - ProcessUtils.register_eventually(pid, handler) + # ProcessUtils.register_eventually(pid, handler) end {:noreply, %{state | handlers: handlers}} end - def handle_cast({:register_adapters, adapters, opts}, state) do - adapters = for adapter <- ensure_adapter(adapters) do - case adapter do - {name, mod} -> - {:ok, pid} = Supervisor.start_child(Alice.Adapter.Supervisor, [mod, self(), opts]) - ProcessUtils.register_eventually(pid, name) - mod - mod when is_atom(mod) -> - {:ok, _pid} = Supervisor.start_child(Alice.Adapter.Supervisor, [mod, self(), opts]) - mod - end - end + def handle_cast({:update_adapters, adapters}, state) do {:noreply, %{state | adapters: adapters}} end diff --git a/lib/alice/test/bot_case.ex b/lib/alice/test/bot_case.ex index bdd3e77..2c448c8 100644 --- a/lib/alice/test/bot_case.ex +++ b/lib/alice/test/bot_case.ex @@ -13,32 +13,46 @@ defmodule Alice.BotCase do setup tags do if tags[:start_bot] do - bot = Map.get(tags, :bot, @bot) - name = Map.get(tags, :name, "alice") - handlers = Map.get(tags, :handlers, [TestHandler]) - adapters = Map.get(tags, :adapters, [{TestAdapter, TestAdapter}]) - config = [name: name, handlers: handlers, adapters: adapters] - Application.put_env(:alice, bot, config) - {:ok, bot_pid} = Alice.start_bot(bot, config) - Process.register(bot_pid, Alice.TestBot) - test_adapter = update_bot_adapter(bot_pid) - - on_exit fn -> Alice.stop_bot(bot_pid) end - - user = %Alice.User{id: "user_id", name: "user_name"} - msg = %Alice.Message{bot: bot_pid, adapter: {TestAdapter, Process.whereis(TestAdapter)}, text: "", user: user} - - {:ok, %{bot: bot_pid, adapter: test_adapter, msg: msg}} + {:ok, start_test_bot(tags)} else :ok end end - def update_bot_adapter(bot_pid) do - test_process = self() - [adapter_mod] = :sys.get_state(bot_pid).adapters - :sys.replace_state(adapter_mod, fn state -> %{state | conn: test_process} end) + def start_test_bot(options) do + bot = Map.get(options, :bot, @bot) + name = Map.get(options, :name, "alice") + handlers = Map.get(options, :handlers, [TestHandler]) + adapters = Map.get(options, :adapters, [{options.test, TestAdapter}]) + config = [name: name, handlers: handlers, adapters: adapters] + Application.put_env(:alice, bot, config) + {:ok, bot_pid} = Alice.start_bot(bot, config) + adapter_pid = update_bot_adapter(options.test, bot_pid) + + on_exit fn -> + Alice.stop_bot(bot_pid) + end + + user = %Alice.User{id: "user_id", name: "user_name"} + msg = %Alice.Message{bot: bot_pid, adapter: {options.test, adapter_pid}, text: "", user: user} - adapter_mod + %{bot: bot_pid, adapter: adapter_pid, msg: msg} + end + + @doc """ + Alters the running Adapter process' state setting the test process as the + Connection + """ + def update_bot_adapter(adapter_name, bot_pid) do + test_process = self() + adapter_pid = Process.whereis(adapter_name) + :sys.replace_state(adapter_pid, fn + ({^bot_pid, state}) -> {bot_pid, Map.put(state, :conn, test_process)} + ({pid, state}) -> + IO.puts "OOPS" + {pid, state} + end) + + adapter_pid end end diff --git a/test/alice/adapters/console_test.exs b/test/alice/adapters/console_test.exs index fc5cd27..d8ff081 100644 --- a/test/alice/adapters/console_test.exs +++ b/test/alice/adapters/console_test.exs @@ -1,39 +1,40 @@ defmodule Alice.Adapters.ConsoleTest do use ExUnit.Case - import ExUnit.CaptureIO + # import ExUnit.CaptureIO alias Alice.Adapters.Console - test "console handles messages from the connection" do - capture_io fn -> - {:ok, adapter} = start_adapter(self(), name: "alice", user: "testuser") - - msg = {:message, %{"text" => "ping", "user" => "testuser"}} - - send(adapter, msg) - assert_receive {:"$gen_cast", {:handle_in, %Alice.Message{text: "ping", user: %Alice.User{name: "testuser"}}}} - end - end - - test "sending messages to the connection process" do - capture_io fn -> - {:ok, adapter_pid} = start_adapter(self(), name: "alice", user: "testuser") - - test_process = self() - adapter = :sys.replace_state(adapter_pid, fn state -> %{state | conn: test_process} end) - msg = %Alice.Message{text: "pong", user: "testuser"} - - Console.reply(adapter.conn, msg) - assert_receive {:"$gen_cast", {:reply, ^msg}} - end - end + # test "console handles messages from the connection" do + # {:ok, adapter} = start_adapter(self(), name: "alice", user: "testuser") + # + # msg = {:message, %{"text" => "ping", "user" => "testuser"}} + # + # send(adapter, msg) + # assert_receive {:"$gen_cast", {:handle_in, %Alice.Message{text: "ping", user: %Alice.User{name: "testuser"}}}} + # end + + # test "sending messages to the connection process" do + # capture_io fn -> + # {:ok, adapter_pid} = start_adapter(self(), name: "alice", user: "testuser") + # + # test_process = self() + # adapter = :sys.replace_state(adapter_pid, fn state -> %{state | conn: test_process} end) + # msg = %Alice.Message{text: "pong", user: "testuser"} + # + # Console.reply(adapter.conn, msg) + # assert_receive {:"$gen_cast", {:reply, ^msg}} + # end + # end defp start_adapter(pid, opts) do {:ok, adapter_pid} = Supervisor.start_child(Alice.Adapter.Supervisor, [Console, pid, opts]) + IO.puts "started adapter #{inspect adapter_pid}" on_exit fn -> + IO.puts "stopping adapter #{inspect adapter_pid}" Supervisor.terminate_child(Alice.Adapter.Supervisor, adapter_pid) end handle_connect() + IO.puts "handled connect #{inspect adapter_pid}" {:ok, adapter_pid} end diff --git a/test/alice/bot_test.exs b/test/alice/bot_test.exs index af738a4..3a827c6 100644 --- a/test/alice/bot_test.exs +++ b/test/alice/bot_test.exs @@ -1,5 +1,5 @@ defmodule Alice.BotTest do - use Alice.BotCase, async: false + use Alice.BotCase test "new/5" do bot = Alice.Bot.new(:name, :adapters, :handlers, :opts) @@ -33,7 +33,8 @@ defmodule Alice.BotTest do Alice.Handler.Supervisor |> Supervisor.which_children() |> Enum.map(fn({_,pid,_,_}) -> - {_,[{_,{mod,_,_}}|_]} = Process.info(pid, :dictionary) + {:dictionary, info} = Process.info(pid, :dictionary) + {mod, _, _} = Keyword.get(info, :"$initial_call") {mod, pid} end) assert processes == Alice.Bot.handler_processes(bot) diff --git a/test/alice/handler_test.exs b/test/alice/handler_test.exs index 7c97709..79dba22 100644 --- a/test/alice/handler_test.exs +++ b/test/alice/handler_test.exs @@ -23,9 +23,9 @@ defmodule Alice.HandlerTest do assert_receive {:reply, %{text: "route test received"}} end - @tag start_bot: true + @tag start_bot: true, name: "bot" test "it responds to commands", %{adapter: adapter, msg: msg} do - send adapter, {:message, %{msg | text: "alice test command"}} + send adapter, {:message, %{msg | text: "bot test command"}} assert_receive {:reply, %{text: "command test received"}} end