From 40f02448bf7c208402bbc0a9fb188af3f397420c Mon Sep 17 00:00:00 2001 From: Zach Kemp Date: Sat, 14 Oct 2017 18:02:18 -0700 Subject: [PATCH 1/3] ex 1.6 formatting; better tests --- .tool-versions | 2 +- config/test.exs | 1 + lib/slack.ex | 33 +++++++---- lib/slack/api.ex | 18 +++--- lib/slack/behaviours.ex | 8 +-- lib/slack/bot.ex | 37 ++++++------ lib/slack/bot/event_handler.ex | 5 +- lib/slack/bot/message_tracker.ex | 21 ++++--- lib/slack/bot/outbox.ex | 6 +- lib/slack/bot/receiver.ex | 17 +++--- lib/slack/bot/socket.ex | 7 ++- lib/slack/bot/supervisor.ex | 21 ++++--- lib/slack/bot/timer.ex | 14 ----- lib/slack/bot_registry.ex | 4 +- lib/slack/console.ex | 22 ++++--- lib/slack/console/pub_sub.ex | 79 +++++++++++++++++-------- lib/slack/console/socket.ex | 6 +- lib/slack/responders/default.ex | 10 ++-- mix.exs | 5 +- mix.lock | 3 + test/bot_test.exs | 44 +++++--------- test/slack/bot/message_tracker_test.exs | 44 ++++++++++++++ test/slack/bot/outbox_test.exs | 21 +++++++ test/slack/bot/receiver_test.exs | 20 +++++++ test/slack/console/pubsub_test.exs | 9 ++- test/test_helper.exs | 61 +++++++++++++++++++ 26 files changed, 356 insertions(+), 162 deletions(-) delete mode 100644 lib/slack/bot/timer.ex create mode 100644 test/slack/bot/message_tracker_test.exs create mode 100644 test/slack/bot/outbox_test.exs create mode 100644 test/slack/bot/receiver_test.exs diff --git a/.tool-versions b/.tool-versions index 03d31ca..3da98d1 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ erlang 20.0 -elixir 1.5.1-otp-20 +elixir 1.5.2-otp-20 diff --git a/config/test.exs b/config/test.exs index ad504d5..bf886f9 100644 --- a/config/test.exs +++ b/config/test.exs @@ -2,6 +2,7 @@ use Mix.Config config :slack, use_console: true, + print_to_console: true, default_channel: "console", bots: ( for {name, msg} <- [{"frogbot", "ribbit"}, {"toadbot", "croak"}, {"owlbot", "hoot"}], diff --git a/lib/slack.ex b/lib/slack.ex index c035d56..25aaa58 100644 --- a/lib/slack.ex +++ b/lib/slack.ex @@ -2,7 +2,7 @@ defmodule Slack do use Application def start(_type, _args) do - Slack.Supervisor.start_link + Slack.Supervisor.start_link() end def default_channel do @@ -20,19 +20,21 @@ defmodule Slack.Supervisor do def init(_arg) do registry_spec = supervisor(Registry, [[keys: :unique, name: Slack.BotRegistry]]) - bot_specs = bot_configs() - |> Task.async_stream(&bot_config_to_spec/1) - |> Enum.map(fn ({:ok, {%{name: bot} = config, data}}) -> - supervisor(Slack.Bot.Supervisor, [config, data], id: {bot, Slack.Bot.Supervisor}) - end) + bot_specs = + bot_configs() + |> Task.async_stream(&bot_config_to_spec/1) + |> Enum.map(fn {:ok, {%{name: bot} = config, data}} -> + supervisor(Slack.Bot.Supervisor, [config, data], id: {bot, Slack.Bot.Supervisor}) + end) children = [registry_spec | bot_specs] - children = if use_console?() do - [supervisor(Slack.Console, [])|children] - else - children - end + children = + if use_console?() do + [supervisor(Slack.Console, []) | children] + else + children + end supervise(children, strategy: :one_for_one) end @@ -51,9 +53,16 @@ defmodule Slack.Supervisor do end defp bot_configs do + defaults = forced_bot_config_values() :slack |> Application.get_env(:bots, []) - |> Enum.map(&struct(Slack.Bot.Config, &1)) + |> Enum.map(&struct(Slack.Bot.Config, &1 |> Map.merge(defaults))) + end + + defp forced_bot_config_values do + if Application.get_env(:slack, :all_bots_use_console), + do: %{socket_client: Slack.Console.Socket, api_client: Slack.Console.APIClient}, + else: %{} end defp bot_config_to_spec(conf) do diff --git a/lib/slack/api.ex b/lib/slack/api.ex index 5d6b9a4..4540d7a 100644 --- a/lib/slack/api.ex +++ b/lib/slack/api.ex @@ -2,12 +2,14 @@ defmodule Slack.API do @behaviour Slack.Behaviours.API @api_root "https://slack.com/api" - @methods %{auth: "rtm.start", - channels: "channels.list", - groups: "groups.list", - join_channel: "channels.join", - leave_channel: "channels.leave"} - @json_headers ["Content-Type": "application/json", "Accepts": "application/json"] + @methods %{ + auth: "rtm.start", + channels: "channels.list", + groups: "groups.list", + join_channel: "channels.join", + leave_channel: "channels.leave" + } + @json_headers ["Content-Type": "application/json", Accepts: "application/json"] def auth_request(token, _internal_name \\ nil) do post_method(:auth, %{token: token}) @@ -40,9 +42,9 @@ defmodule Slack.API do end defp post(path) do - case HTTPotion.post("#{@api_root}/#{path}", [headers: @json_headers]) do + case HTTPotion.post("#{@api_root}/#{path}", headers: @json_headers) do %{status_code: 200, body: body} -> {:ok, Poison.decode!(body)} - %{status_code: s} -> {:error, s} + %{status_code: s} -> {:error, s} end end end diff --git a/lib/slack/behaviours.ex b/lib/slack/behaviours.ex index b9273ec..a6d6503 100644 --- a/lib/slack/behaviours.ex +++ b/lib/slack/behaviours.ex @@ -1,8 +1,8 @@ defmodule Slack.Behaviours.API do - @callback auth_request(token :: binary(), internal_name :: binary() | nil) :: Map.t - @callback join_channel(channel :: binary(), token :: String.t) :: Map.t - @callback list_groups(token :: binary()) :: Map.t - @callback list_channels(token :: binary()) :: Map.t + @callback auth_request(token :: binary(), internal_name :: binary() | nil) :: Map.t() + @callback join_channel(channel :: binary(), token :: String.t()) :: Map.t() + @callback list_groups(token :: binary()) :: Map.t() + @callback list_channels(token :: binary()) :: Map.t() defmacro __using__(_) do quote location: :keep do diff --git a/lib/slack/bot.ex b/lib/slack/bot.ex index 8685d8f..a4d77d3 100644 --- a/lib/slack/bot.ex +++ b/lib/slack/bot.ex @@ -14,23 +14,23 @@ defmodule Slack.Bot do @typedoc """ Tuple of {workspace, name} """ - @type bot_name :: {String.t, String.t} + @type bot_name :: {String.t(), String.t()} defmodule Config do @enforce_keys [:workspace] - defstruct [ - id: nil, # Usually set by the result of an API call - name: nil, - socket_client: Socket.Web, - api_client: Slack.API, - workspace: nil, - token: nil, - ribbit_msg: nil, - responder: nil, - keywords: %{}, - ping_frequency: 10_000, - rate_limit: 1 # messages per second - ] + # Usually set by the result of an API call + # messages per second + defstruct id: nil, + name: nil, + socket_client: Socket.Web, + api_client: Slack.API, + workspace: nil, + token: nil, + ribbit_msg: nil, + responder: nil, + keywords: %{}, + ping_frequency: 10000, + rate_limit: 1 end alias Slack.Bot.Config @@ -62,7 +62,7 @@ defmodule Slack.Bot do Slack.Bot.say("frogbot", "Hello, world", "ABCDEF123") #=> :ok (message sent to channel given by channel id) """ - @spec say(bot_name, binary | nil, binary | nil) :: :ok + @spec say(bot_name, String.t | nil, String.t | nil) :: :ok def say(name, text, _channel \\ nil) def say(_, nil, _), do: :ok @@ -83,7 +83,7 @@ defmodule Slack.Bot do end # TODO: update for multi-tenancy - @spec get_channel_id(bot_name, String.t) :: String.t | :error + @spec get_channel_id(bot_name, String.t()) :: String.t() | :error defp get_channel_id(name, channel_name) do Agent.get(registry_key(name, :channels), fn map -> map @@ -106,7 +106,10 @@ defmodule Slack.Bot do {:ok, struct(Config, config)} end - @spec handle_cast(:ping | {:event, map()} | {:mod_config, map()}, %Config{}) :: {:noreply, %Config{}} + @spec handle_cast(:ping | {:event, map()} | {:mod_config, map()}, %Config{}) :: { + :noreply, + %Config{} + } @impl true def handle_cast(:ping, config) do ping!(config.name) diff --git a/lib/slack/bot/event_handler.ex b/lib/slack/bot/event_handler.ex index 20ad517..d8a7d0d 100644 --- a/lib/slack/bot/event_handler.ex +++ b/lib/slack/bot/event_handler.ex @@ -1,13 +1,14 @@ defmodule Slack.Bot.EventHandler do import Slack.BotRegistry - @spec handle(map | {:ping} | nil, Slack.Bot.bot_name) :: {:ok, pid} + @spec handle(map | {:ping} | nil, Slack.Bot.bot_name()) :: {:ok, pid} def handle(nil, _), do: nil + def handle(event, bot_server) do Task.start(__MODULE__, :go, [event, bot_server]) end - @spec go(map | {:ping} | nil, Slack.Bot.bot_name) :: :ok + @spec go(map | {:ping} | nil, Slack.Bot.bot_name()) :: :ok def go(event, bot_server) do GenServer.cast(registry_key(bot_server, Slack.Bot), {:event, event}) end diff --git a/lib/slack/bot/message_tracker.ex b/lib/slack/bot/message_tracker.ex index cabd009..1de6921 100644 --- a/lib/slack/bot/message_tracker.ex +++ b/lib/slack/bot/message_tracker.ex @@ -12,18 +12,18 @@ defmodule Slack.Bot.MessageTracker do import Slack.BotRegistry defmodule State do - defstruct messages: %{}, counter: 1, ping_ref: nil, name: nil, ping_freq: 10_000 + defstruct messages: %{}, counter: 1, ping_ref: nil, name: nil, ping_freq: 10000 end - @ping_ms 10_000 + @ping_ms 10000 - @spec start_link(Slack.Bot.bot_name, integer) :: GenServer.on_start + @spec start_link(Slack.Bot.bot_name(), integer) :: GenServer.on_start() def start_link(name, ping_freq \\ @ping_ms) do GenServer.start_link(__MODULE__, {name, ping_freq}, name: registry_key(name, __MODULE__)) end @impl true - @spec init({Slack.Bot.bot_name, integer}) :: {:ok, %S{}} + @spec init({Slack.Bot.bot_name(), integer}) :: {:ok, %S{}} def init({name, ping_freq}) do {:ok, %S{ping_ref: reset_ping_timer(ping_freq), name: name, ping_freq: ping_freq}} end @@ -44,17 +44,24 @@ defmodule Slack.Bot.MessageTracker do @impl true def handle_call({:push, payload}, _from, %S{} = s) do counter = s.counter + 1 - new_ping_ref = if payload[:type] == "ping", do: s.ping_ref, else: reset_ping_timer(s.ping_freq, s.ping_ref) + + new_ping_ref = + if payload[:type] == "ping", do: s.ping_ref, else: reset_ping_timer(s.ping_freq, s.ping_ref) { :reply, {:ok, counter}, - %S{s | messages: Map.put(s.messages, counter, payload), counter: counter , ping_ref: new_ping_ref} + %S{ + s + | messages: Map.put(s.messages, counter, payload), + counter: counter, + ping_ref: new_ping_ref + } } end @impl true - def handle_call({:reply, id, payload}, _from, %S{} = s) do + def handle_call({:reply, id, _payload}, _from, %S{} = s) do {:reply, :ok, %S{s | messages: Map.delete(s.messages, id)}} end diff --git a/lib/slack/bot/outbox.ex b/lib/slack/bot/outbox.ex index 7ed5639..90133bb 100644 --- a/lib/slack/bot/outbox.ex +++ b/lib/slack/bot/outbox.ex @@ -3,7 +3,11 @@ defmodule Slack.Bot.Outbox do import Slack.BotRegistry def start_link(name, rate_limit \\ 1000) do - GenServer.start_link(__MODULE__, {:ok, name, rate_limit}, name: registry_key(name, __MODULE__)) + GenServer.start_link( + __MODULE__, + {:ok, name, rate_limit}, + name: registry_key(name, __MODULE__) + ) end @impl true diff --git a/lib/slack/bot/receiver.ex b/lib/slack/bot/receiver.ex index c90eb1a..2b9ccff 100644 --- a/lib/slack/bot/receiver.ex +++ b/lib/slack/bot/receiver.ex @@ -9,7 +9,7 @@ defmodule Slack.Bot.Receiver do @doc """ Recursively streams packets from the websocket to the Bot controller """ - @spec start_link(Slack.Bot.bot_name, pid, module) :: {:ok, pid} + @spec start_link(Slack.Bot.bot_name(), pid, module) :: {:ok, pid} def start_link(bot, sock, client) do Task.start_link(recv_task(bot, sock, client)) end @@ -18,14 +18,15 @@ defmodule Slack.Bot.Receiver do fn -> recv(bot_server, socket, client_module) end end - @spec recv(Slack.Bot.bot_name, pid, module) :: any + @spec recv(Slack.Bot.bot_name(), pid, module) :: any defp recv(bot_server, socket, client_module) do - event = case client_module.recv(socket) do - {:ok, {:text, body}} -> Poison.decode!(body) - {:ok, {:ping, _}} -> {:ping} - :ok -> nil - e -> raise "Something went wrong: #{inspect e}" - end + event = + case client_module.recv(socket) do + {:ok, {:text, body}} -> Poison.decode!(body) + {:ok, {:ping, _}} -> {:ping} + :ok -> nil + e -> raise "Something went wrong: #{inspect(e)}" + end _ = Slack.Bot.EventHandler.handle(event, bot_server) recv(bot_server, socket, client_module) diff --git a/lib/slack/bot/socket.ex b/lib/slack/bot/socket.ex index 8a4f96e..9153aa2 100644 --- a/lib/slack/bot/socket.ex +++ b/lib/slack/bot/socket.ex @@ -10,7 +10,11 @@ defmodule Slack.Bot.Socket do use GenServer def start_link(name, ws_url, client) do - GenServer.start_link(__MODULE__, {:ok, ws_url, client, name}, name: registry_key(name, __MODULE__)) + GenServer.start_link( + __MODULE__, + {:ok, ws_url, client, name}, + name: registry_key(name, __MODULE__) + ) end # CALLBACKS @@ -25,6 +29,7 @@ defmodule Slack.Bot.Socket do {:reply, outcome, {socket, client}} end + @impl true def handle_call(:socket_pid, _from, {socket, _} = s) do {:reply, socket, s} end diff --git a/lib/slack/bot/supervisor.ex b/lib/slack/bot/supervisor.ex index bdd15f4..98cd75b 100644 --- a/lib/slack/bot/supervisor.ex +++ b/lib/slack/bot/supervisor.ex @@ -14,10 +14,10 @@ defmodule Slack.Bot.Supervisor do Agent.start_link(fn -> channels end, name: registry_key(name, :channels)) children = [ - worker(Bot, [name, Map.merge(c, %{id: uid, name: name})]), - worker(Bot.Socket, [name, ws_url, c.socket_client]), - worker(Bot.MessageTracker, [name, c.ping_frequency || 10_000]), - worker(Bot.Outbox, [name, c.rate_limit]) + worker(Bot, [name, Map.merge(c, %{id: uid, name: name})]), + worker(Bot.Socket, [name, ws_url, c.socket_client]), + worker(Bot.MessageTracker, [name, c.ping_frequency || 10000]), + worker(Bot.Outbox, [name, c.rate_limit]) ] supervise(children, strategy: :rest_for_one) @@ -26,17 +26,16 @@ defmodule Slack.Bot.Supervisor do def init_api_calls(client, token, name) do %{ "self" => %{"id" => uid}, - "url" => ws_url + "url" => ws_url } = response = client.auth_request(token, name) - channels = response["channels"] |> Enum.filter(&(&1["is_member"])) + channels = response["channels"] |> Enum.filter(& &1["is_member"]) private_channels = token |> client.list_groups |> Map.get("groups", []) - channels_by_name = Enum.reduce( - channels ++ private_channels, - %{}, - fn (%{"name" => name} = c, acc) -> Map.put(acc, name, c) end - ) + channels_by_name = + Enum.reduce(channels ++ private_channels, %{}, fn %{"name" => name} = c, acc -> + Map.put(acc, name, c) + end) {uid, ws_url, channels_by_name} end diff --git a/lib/slack/bot/timer.ex b/lib/slack/bot/timer.ex deleted file mode 100644 index 2062b30..0000000 --- a/lib/slack/bot/timer.ex +++ /dev/null @@ -1,14 +0,0 @@ -defmodule Slack.Bot.Timer do - # ping every x seconds - # Usually `ref` will be Slack.Bot (controller server) - def ping_fn(ref, sleep \\ 10_000) do - fn -> ping_stream(ref, sleep) end - end - - defp ping_stream(ref, interval \\ 10_000) do - interval - |> Stream.interval - |> Stream.each(fn _ -> GenServer.cast(ref, :ping) end) - |> Stream.run - end -end diff --git a/lib/slack/bot_registry.ex b/lib/slack/bot_registry.ex index 436cb4b..c18a453 100644 --- a/lib/slack/bot_registry.ex +++ b/lib/slack/bot_registry.ex @@ -7,6 +7,8 @@ defmodule Slack.BotRegistry do end def lookup(key) do - Registry.lookup(__MODULE__, key) |> List.first + __MODULE__ + |> Registry.lookup(key) + |> List.first() end end diff --git a/lib/slack/console.ex b/lib/slack/console.ex index e7fc00f..97fec49 100644 --- a/lib/slack/console.ex +++ b/lib/slack/console.ex @@ -11,9 +11,12 @@ defmodule Slack.Console do use Supervisor def init(_args) do - Supervisor.init([ - {Slack.Console.PubSub, []} - ], strategy: :one_for_one) + Supervisor.init( + [ + {Slack.Console.PubSub, []} + ], + strategy: :one_for_one + ) end def start_link do @@ -24,14 +27,15 @@ defmodule Slack.Console do Slack.Console.PubSub.message(msg) end - def print(c,u,p), do: print(nil, c, u, p) + def print(c, u, p), do: print(nil, c, u, p) def print(_ws, _channel, _uid, nil), do: :ok + def print(workspace, channel, uid, text) do - if Application.get_env(:slack, :print_to_console), do: - [:yellow, "[#{workspace}|#{channel}]", :cyan, "[#{uid}] ", :green, text] - |> IO.ANSI.format - |> IO.chardata_to_string - |> IO.puts + if Application.get_env(:slack, :print_to_console), + do: [:yellow, "[#{workspace}|#{channel}]", :cyan, "[#{uid}] ", :green, text] + |> IO.ANSI.format() + |> IO.chardata_to_string() + |> IO.puts() end end diff --git a/lib/slack/console/pub_sub.ex b/lib/slack/console/pub_sub.ex index 4db8911..80fe85a 100644 --- a/lib/slack/console/pub_sub.ex +++ b/lib/slack/console/pub_sub.ex @@ -6,7 +6,13 @@ defmodule Slack.Console.PubSub do require Logger def child_spec(_args) do - %{id: __MODULE__, start: {__MODULE__, :start_link, []}, restart: :permanent, shutdown: 5000, type: :worker} + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, []}, + restart: :permanent, + shutdown: 5000, + type: :worker + } end def start_link do @@ -23,7 +29,12 @@ defmodule Slack.Console.PubSub do end def message({workspace, channel, message}) do - broadcast(workspace, channel, %{"text" => message, "channel" => channel, "type" => "message"}, nil) + broadcast( + workspace, + channel, + %{"text" => message, "channel" => channel, "type" => "message"}, + nil + ) end def message(message) do @@ -42,12 +53,18 @@ defmodule Slack.Console.PubSub do end @impl true - def handle_call({:subscribe, workspace, channel, socket, user_key}, _from, {channels, workspaces}) do + def handle_call({:subscribe, workspace, channel, socket, user_key}, _from, { + channels, + workspaces + }) do # TODO: raise error if changing workspaces (shouldn't happen) new_workspaces = Map.put(workspaces, socket, workspace) - new_channels = Map.update(channels, {workspace, channel}, %{socket => user_key}, fn (ch) -> - Map.put(ch, socket, user_key) - end) + + new_channels = + Map.update(channels, {workspace, channel}, %{socket => user_key}, fn ch -> + Map.put(ch, socket, user_key) + end) + {:reply, :ok, {new_channels, new_workspaces}} end @@ -58,35 +75,49 @@ defmodule Slack.Console.PubSub do end @impl true - @spec handle_cast({:broadcast, String.t, String.t, map, pid}, {map, map}) :: {:noreply, {map, map}} - def handle_cast({:broadcast, workspace, channel, unencoded_message, from_socket} = m, {channels, _} = state) do - ts = System.os_time(:microseconds) / 10_000_00 + @spec handle_cast({:broadcast, String.t(), String.t(), map, pid}, {map, map}) :: { + :noreply, + {map, map} + } + def handle_cast( + {:broadcast, workspace, channel, unencoded_message, from_socket}, + {channels, _} = state + ) do + ts = System.os_time(:microseconds) / 1_000_000 channel_key = {workspace, channel} - uid = channels[channel_key][from_socket] || "console user" - message = unencoded_message |> Map.merge(%{"user" => uid, "ts" => "#{ts}"}) |> Poison.encode! - text = unencoded_message["text"] + uid = channels[channel_key][from_socket] || "console user" + + message = + unencoded_message |> Map.merge(%{"user" => uid, "ts" => "#{ts}"}) |> Poison.encode!() + + text = unencoded_message["text"] Slack.Console.print(workspace, channel, uid, text) - queues = channels |> Map.get(channel_key, %{}) - |> Map.keys - |> Enum.filter(fn - ^from_socket -> false - _ -> true - end) + + queues = + channels + |> Map.get(channel_key, %{}) + |> Map.keys() + |> Enum.filter(fn + ^from_socket -> false + _ -> true + end) {:ok, _} = Task.start(fn -> Enum.each(queues, fn q -> send(q, {:push, message}) end) end) send_receipt(unencoded_message, from_socket) {:noreply, state} end - defp send_receipt(_msg, nil), do: nil # was not sent by a bot + # was not sent by a bot + defp send_receipt(_msg, nil), do: nil defp send_receipt(msg, from_socket) do - receipt = Map.merge(msg, %{ - "ts" => :os.system_time / 1_000_000_000, - "ok" => true, - "reply_to" => msg["id"] - }) + receipt = + Map.merge(msg, %{ + "ts" => :os.system_time() / 1_000_000_000, + "ok" => true, + "reply_to" => msg["id"] + }) Queue.push(from_socket, Poison.encode!(receipt)) end diff --git a/lib/slack/console/socket.ex b/lib/slack/console/socket.ex index 82e0a41..47d1a43 100644 --- a/lib/slack/console/socket.ex +++ b/lib/slack/console/socket.ex @@ -13,14 +13,14 @@ defmodule Slack.Console.Socket do end defp do_connect!(workspace, unique_key) do - {:ok, q} = Queue.start_link + {:ok, q} = Queue.start_link() Slack.Console.PubSub.subscribe(workspace, "console", q, unique_key) q end def recv(socket) do # ping freq is 10_000 - case Queue.pop(socket, 11_000) do + case Queue.pop(socket, 11000) do {:error, _} = e -> Logger.error({socket, e} |> inspect) val -> {:ok, {:text, val}} end @@ -36,7 +36,7 @@ defmodule Slack.Console.Socket do # outgoing, but simulate pong response immediately defp handle_payload(%{"type" => "ping", "id" => id} = msg, socket) do - Queue.push(socket, %{"reply_to" => id, "type" => "pong"} |> Poison.encode!) + Queue.push(socket, %{"reply_to" => id, "type" => "pong"} |> Poison.encode!()) Slack.Console.PubSub.broadcast("__pings__", msg, socket) end diff --git a/lib/slack/responders/default.ex b/lib/slack/responders/default.ex index b4d5dbc..57a8ab6 100644 --- a/lib/slack/responders/default.ex +++ b/lib/slack/responders/default.ex @@ -1,8 +1,8 @@ defmodule Slack.Responders.Default do alias Slack.Bot.Config - require Logger - @spec respond(Slack.Bot.bot_name, map, %Config{}) :: any - def respond({_ws, uname} = name, %{"text" => t} = msg, %Config{} = config) do + + @spec respond(Slack.Bot.bot_name(), map, %Config{}) :: any + def respond({_ws, uname}, %{"text" => t} = msg, %Config{} = config) do if contains_username?(t, [uname, "<@#{config.id}>"]) do try_echo(uname, msg, config) end @@ -16,15 +16,17 @@ defmodule Slack.Responders.Default do defp try_echo(name, %{"text" => t, "user" => _user, "channel" => c}, %Config{} = config) do mention = "<@#{config.id}>" + if String.starts_with?(t, "#{name} echo ") || String.starts_with?(t, "#{mention} echo ") do say(config.name, t |> String.split(" echo ", parts: 2) |> Enum.at(1), c) else say(config.name, config.ribbit_msg, c) end end + defp try_echo(_, _, _), do: nil defp contains_username?(msg, names) do - Enum.any?(names, &(String.contains?(msg, &1))) + Enum.any?(names, &String.contains?(msg, &1)) end end diff --git a/mix.exs b/mix.exs index 61f9958..2e514a6 100644 --- a/mix.exs +++ b/mix.exs @@ -3,11 +3,13 @@ defmodule Slack.Mixfile do def project do [app: :slack, - version: "0.7.1", + version: "0.7.2", elixir: "~> 1.5", elixirc_paths: elixirc_paths(Mix.env), build_embedded: Mix.env == :prod, start_permanent: Mix.env == :prod, + test_coverage: [tool: ExCoveralls], + preferred_cli_env: ["coveralls": :test, "coveralls.detail": :test, "coveralls.post": :test, "coveralls.html": :test], dialyzer: [plt_add_apps: [:poison], flags: [:unmatched_returns, :error_handling, :race_conditions, :underspecs]], deps: deps()] end @@ -44,6 +46,7 @@ defmodule Slack.Mixfile do {:credo, "~> 0.8", only: [:dev, :test]}, {:ex_doc, "~> 0.16", only: :dev, runtime: false}, {:queue, "~> 0.1.0", github: "zvkemp/ex-queues"}, + {:excoveralls, "~> 0.7", only: :test} ] end end diff --git a/mix.lock b/mix.lock index a9467c6..a0ff2c5 100644 --- a/mix.lock +++ b/mix.lock @@ -5,11 +5,14 @@ "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [], [], "hexpm"}, "earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [], [], "hexpm"}, "ex_doc": {:hex, :ex_doc, "0.17.1", "39f777415e769992e6732d9589dc5846ea587f01412241f4a774664c746affbb", [], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, + "excoveralls": {:hex, :excoveralls, "0.7.4", "3d84b2f15a0e593159f74b19f83794b464b34817183d27965bdc6c462de014f9", [], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], [], "hexpm"}, "hackney": {:hex, :hackney, "1.9.0", "51c506afc0a365868469dcfc79a9d0b94d896ec741cfd5bd338f49a5ec515bfe", [], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, "httpotion": {:hex, :httpotion, "3.0.3", "17096ea1a7c0b2df74509e9c15a82b670d66fc4d66e6ef584189f63a9759428d", [:mix], [{:ibrowse, "~> 4.4", [hex: :ibrowse, repo: "hexpm", optional: false]}], "hexpm"}, "ibrowse": {:hex, :ibrowse, "4.4.0", "2d923325efe0d2cb09b9c6a047b2835a5eda69d8a47ed6ff8bc03628b764e991", [:rebar3], [], "hexpm"}, "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, + "jsx": {:hex, :jsx, "2.8.2", "7acc7d785b5abe8a6e9adbde926a24e481f29956dd8b4df49e3e4e7bcc92a018", [], [], "hexpm"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [], [], "hexpm"}, "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [], [], "hexpm"}, "mix_test_watch": {:hex, :mix_test_watch, "0.5.0", "2c322d119a4795c3431380fca2bca5afa4dc07324bd3c0b9f6b2efbdd99f5ed3", [:mix], [{:fs, "~> 0.9.1", [hex: :fs, repo: "hexpm", optional: false]}], "hexpm"}, diff --git a/test/bot_test.exs b/test/bot_test.exs index 39ebf0a..89d92ae 100644 --- a/test/bot_test.exs +++ b/test/bot_test.exs @@ -4,24 +4,6 @@ defmodule Slack.BotTest.Integration do alias Slack.Bot.{MessageTracker} - @doc """ - proxies a subscription; forwards decoded JSON back to exunit for better - pattern matching on messages - """ - def subscribe_to_json(workspace, channel) do - me = self() - user_key = Base.encode64(:crypto.strong_rand_bytes(5)) - pid = spawn_link(fn -> json_recv(me) end) - Slack.Console.PubSub.subscribe(workspace, channel, pid, user_key) - end - - defp json_recv(forward_to_pid) do - receive do - {:push, json} -> send(forward_to_pid, {:json, Poison.decode!(json)}) - end - json_recv(forward_to_pid) - end - setup_all do [name, token] = [6, 9] |> Enum.map(&(:crypto.strong_rand_bytes(&1) |> Base.encode64)) @@ -37,12 +19,12 @@ defmodule Slack.BotTest.Integration do } Slack.Supervisor.start_bot(config) - {:ok, %{config: config |> Map.merge(%{ name: {"exunit", name}})}} + {:ok, %{config: config |> Map.merge(%{name: {"exunit", name}})}} end setup %{config: %{token: _token}} = context do - subscribe_to_json("exunit", "console") - subscribe_to_json("exunit", "__pings__") + TestHelpers.subscribe_to_json("exunit", "console") + TestHelpers.subscribe_to_json("exunit", "__pings__") {:ok, context} end @@ -52,38 +34,42 @@ defmodule Slack.BotTest.Integration do test "manual pings", %{config: %{name: name}} do Slack.Bot.ping!(name) - assert_receive({:json, data}, 25) # first automatic ping would not have been received yet - assert_receive({:json, _}, 120) # automatic ping + assert_receive({:json, %{"type" => "ping"}}, 25) # first automatic ping would not have been received yet + assert_receive({:json, %{"type" => "ping"}}, 120) # automatic ping end - test "say with default channel", %{config: %{ name: name, token: _token }} do + test "say with default channel", %{config: %{name: name, token: _token}} do channel = "console" message = "hey there" Slack.Bot.say(name, message, channel) + # tracks the message (fetching state here so it doesn't get cleaned up after message is received, see assertion below) + state = GenServer.call(registry_key(name, MessageTracker), :current) + + # pubsub broadcasts to subscribers assert_receive({:json, %{ "type" => "message", "text" => ^message, "channel" => ^channel, "id" => msg_id }}) - # tracks the message - state = GenServer.call(registry_key(name, MessageTracker), :current) - assert message == state.messages[msg_id][:text] + + assert ^message = state.messages[msg_id][:text] + :timer.sleep(15) # TODO: replace this wait with something more deterministic # cleans up the message upon server receipt assert %{messages: %{}} = GenServer.call(registry_key(name, MessageTracker), :current) end test "multitenancy: responses are segregated by workspace", _config do - subscribe_to_json("workspace-a", "console") + TestHelpers.subscribe_to_json("workspace-a", "console") Slack.Console.say({"workspace-a", "console", "Hey there frogbot"}) assert_receive({:json, %{"text" => "Hey there frogbot", "user" => "console user"}}) assert_receive({:json, %{"text" => "ribbit-workspace-a", "user" => "/user/frogbot"}}) refute_receive({:json, %{"text" => "ribbit-workspace-b", "user" => "/user/frogbot"}}) - subscribe_to_json("workspace-b", "console") + TestHelpers.subscribe_to_json("workspace-b", "console") Slack.Console.say({"workspace-b", "console", "Hey there toadbot"}) refute_receive({:json, %{"text" => "croak-workspace-a", "user" => "/user/toadbot"}}) assert_receive({:json, %{"text" => "croak-workspace-b", "user" => "/user/toadbot"}}) diff --git a/test/slack/bot/message_tracker_test.exs b/test/slack/bot/message_tracker_test.exs new file mode 100644 index 0000000..785ab7b --- /dev/null +++ b/test/slack/bot/message_tracker_test.exs @@ -0,0 +1,44 @@ +defmodule Slack.Bot.MessageTrackerTest do + use ExUnit.Case + import Slack.BotRegistry + + setup do + bot = TestHelpers.new_bot_name() + ping_frequency = 100 + {:ok, server} = Slack.Bot.MessageTracker.start_link(bot, ping_frequency) + TestMessageForwarder.start_as(bot, Slack.Bot) + {:ok, %{bot_name: bot, server: server}} + end + + describe "pings" do + test "automatic pings" do + assert_receive({Slack.Bot, :ping}, 110) + assert_receive({Slack.Bot, :ping}, 110) + assert_receive({Slack.Bot, :ping}, 110) + end + + test "ping timer is reset when outgoing messages are sent", %{server: server} do + assert_receive({Slack.Bot, :ping}, 110) + :timer.sleep(30) + GenServer.call(server, {:push, %{type: "message", message: "msg"}}) + refute_receive({Slack.Bot, :ping}, 90) + assert_receive({Slack.Bot, :ping}, 110) + end + end + + describe "message tracking" do + test "outgoing messages are counted", %{server: server} do + assert_receive({Slack.Bot, :ping}, 110) + {:ok, counter} = GenServer.call(server, {:push, %{type: "message", message: "msg"}}) + assert %{messages: messages} = GenServer.call(server, :current) + assert %{^counter => %{message: "msg", type: "message"}} = messages + end + + test "incoming replies are acknowledged", %{server: server} do + assert_receive({Slack.Bot, :ping}, 110) + {:ok, counter} = GenServer.call(server, {:push, %{type: "message", message: "msg"}}) + GenServer.call(server, {:reply, counter, %{"reply_to" => counter}}) + assert %{messages: %{}} = GenServer.call(server, :current) + end + end +end diff --git a/test/slack/bot/outbox_test.exs b/test/slack/bot/outbox_test.exs new file mode 100644 index 0000000..ba95b02 --- /dev/null +++ b/test/slack/bot/outbox_test.exs @@ -0,0 +1,21 @@ +defmodule Slack.Bot.OutboxTest do + use ExUnit.Case + + setup do + bot = TestHelpers.new_bot_name() + role = Slack.Bot.Socket + TestMessageForwarder.start_as(bot, role) + rate_limit = 200 + {:ok, server} = Slack.Bot.Outbox.start_link(bot, rate_limit) + {:ok, %{server: server, bot: bot, rate_limit: rate_limit, role: role}} + end + + test "limits outgoing messages to one per {rate limit}", %{role: role, server: server, rate_limit: rate_limit} do + GenServer.cast(server, {:push, "foo"}) + GenServer.cast(server, {:push, "bar"}) + + assert_receive({role, {:push, "foo"}}) + refute_receive({_, {:push, "bar"}}, rate_limit) + assert_receive({role, {:push, "bar"}}, rate_limit * 2) + end +end diff --git a/test/slack/bot/receiver_test.exs b/test/slack/bot/receiver_test.exs new file mode 100644 index 0000000..322fae0 --- /dev/null +++ b/test/slack/bot/receiver_test.exs @@ -0,0 +1,20 @@ +defmodule Slack.Bot.ReceiverTest do + use ExUnit.Case + + setup do + bot = TestHelpers.new_bot_name + {:ok, queue} = Queue.start_link + {:ok, server} = Slack.Bot.Receiver.start_link(bot, queue, Slack.Console.Socket) + TestMessageForwarder.start_as(bot, Slack.Bot) + {:ok, %{queue: queue, server: server, bot: bot}} + end + + test "received messages are decoded and forwarded to the bot as an event", %{ + queue: queue, + server: server, + bot: bot + } do + Queue.push(queue, "{\"hello\": \"world\"}") + assert_receive({Slack.Bot, {:event, %{"hello" => "world"}}}) + end +end diff --git a/test/slack/console/pubsub_test.exs b/test/slack/console/pubsub_test.exs index 662fe6c..fb44fcb 100644 --- a/test/slack/console/pubsub_test.exs +++ b/test/slack/console/pubsub_test.exs @@ -3,19 +3,18 @@ defmodule Slack.Console.PubSubTest do setup do {:ok, pid} = GenServer.start_link(Slack.Console.PubSub, :ok) - {:ok, %{pid: pid}} end - test "subscribing", config do + test "subscribing", _config do Slack.Console.PubSub.subscribe("workspace-1", "channel-1", self(), "user-1") Slack.Console.PubSub.message({"workspace-1", "channel-1", "foo"}) - assert_receive({:push, json}, 50) + assert_receive({:push, _}, 50) end - test "alternate workspaces don't trigger push", config do + test "alternate workspaces don't trigger push", _config do Slack.Console.PubSub.subscribe("workspace-1", "channel-1", self(), "user-1") Slack.Console.PubSub.message({"workspace-2", "channel-1", "foo"}) - refute_receive({:push, json}, 50) + refute_receive({:push, _}, 50) end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..d7d28cf 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,62 @@ ExUnit.start() + +defmodule TestHelpers do + def new_bot_name do + fn -> :crypto.strong_rand_bytes(16) end + |> Stream.repeatedly + |> Enum.take(2) + |> List.to_tuple + end + + @doc """ + proxies a subscription; forwards decoded JSON back to exunit for better + pattern matching on messages + """ + def subscribe_to_json(workspace, channel) do + me = self() + user_key = Base.encode64(:crypto.strong_rand_bytes(5)) + {:ok, pid} = TestMessageForwarder.start_as( + {workspace, user_key}, + :json, + fn {:push, json} -> Poison.decode!(json) end + ) + Slack.Console.PubSub.subscribe(workspace, channel, pid, user_key) + end +end + +defmodule TestMessageForwarder do + use GenServer + import Slack.BotRegistry + + @spec start_as(Slack.Bot.bot_name, atom, function | nil) :: GenServer.on_start() + def start_as(name, role, mapping_fun \\ default_mapping_fun) do + start_link(registry_key(name, role), self(), role, mapping_fun) + end + + @spec start_link(any, pid, atom, function) :: GenServer.on_start() + def start_link(name, exunit, role, mapping_fun) do + GenServer.start_link(__MODULE__, {exunit, role, mapping_fun}, name: name) + end + + @impl true + def init(config), do: {:ok, config} + + @impl true + def handle_cast(msg, {exunit, role, mapping_fun} = config) do + send(exunit, {role, mapping_fun.(msg)}) + {:noreply, config} + end + + @impl true + def handle_call(msg, _from, config) do + GenServer.reply(_from, :ok) + handle_cast(msg, config) + end + + @impl true + def handle_info(msg, config) do + handle_cast(msg, config) + end + + defp default_mapping_fun(), do: &(&1) +end From 9dfe845cc5743f76e297b14d6f032d79e185a295 Mon Sep 17 00:00:00 2001 From: Zach Kemp Date: Sun, 15 Oct 2017 07:18:10 -0700 Subject: [PATCH 2/3] clean up docs --- README.md | 57 +++++++++++++++++++++++++++++------------- config/dev.exs.example | 18 ++++++------- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 7ba3cae..2240995 100644 --- a/README.md +++ b/README.md @@ -2,41 +2,62 @@ # Slack Bot Server -Work-in-progress multi-bot server for Slack. Each bot starts its own supervision tree with a number of workers to handle -message receipt tracking, pings, and websocket client connections. +Multi-bot server frameworkk for Slack. See [zvkemp/frog_and_toad](https://github.com/zvkemp/frog_and_toad) for an implementation example. -A small number of Slack API methods are also present. +- Run multiple bots in a single VM, with server-side interaction and coordinated replies +- Run the same bots on multiple workspaces ## Installation -Clone the repo, modify the example config, and run `iex -S mix`. `Slack.start_bot("botname")` starts the bot, or uncomment +Clone the repo, modify the example config, and run `iex -S mix`. the `mod` line in `mix.exs` to start all bots on run. -#### New, as of 0.2: - -You can now configure a bot like this: +You can configure a bot like this: ```elixir config :slack, - use_console: true, # starts the console supervisor - default_channel: "console", + use_console: true, # simulate a local slack instance + print_to_console: true, # print console messages to the local tty (disabled in test) + default_channel: "CHANNELID", bots: [ - %{name: "frogbot", - token: "fake-token-obvs", + %{name: "frogbot", + workspace: "frog-and-toad", + socket_client: Slack.Console.Socket, # Remove this line to test with a real Slack channel + api_client: Slack.Console.APIClient, # Remove this line to test with a real Slack channel + token: "frogbot-local-token", # Replace with real API token ribbit_msg: "ribbit", - responder: Slack.Bot.DefaultResponder, - keywords: %{ "hello" => "Hey there!" }, - socket_client: Slack.Console.Socket, - api_client: Slack.Console.APIClient + responder: Slack.Responders.Default } ] ``` -The `Console` modules are intended to simulate a local slack channel running inside `iex`, which is useful for developing responder modules. +## Components + +Each bot maintains its own supervision tree, comprised of the following servers: + +- `Slack.Bot.Supervisor` + - `Slack.Bot` - provides the main interface to control the bot's responses + - `Slack.Bot.Socket` - coordinates data transfer over the socket (either a websocket or the dev/test console queue) + - `Slack.Bot.MessageTracker` - tracks sent messages and acknowledges receipts from the remote end. Sends a ping if no messages have been sent for 10 seconds. + - `Slack.Bot.Outbox` - rate-limiter for outgoing messages + +Each bot process is registered using via tuples in the form of `{:via, Slack.BotRegistry, {"name", role_module}}`, where `role_module` is one of the above genserver modules. + +The `Console` modules are intended to simulate a local slack channel running inside `iex`, which is useful for developing responder modules. In the config: + +```elixir +use_console: true, # starts the Slack.Console supervision tree +print_to_console: true # print console messages to the local terminal +``` + +Use `Slack.console.say({"workspace_name", "channel_name", "message here"})` to act as a non-bot (human?) user. -To send a message as a regular 'user', use `Slack.Console.say/1`. Note that bots need to be configured individually to use the console clients (otherwise they will attempt to post to slack). Other than network latency, all other behavior conforms to real-life, including ping scheduling and deferral, rate limiting, and reply counting. +## Responders + +Customization is done via the `responder: Slack.Responders.Default` config. For an example of a more robust responder, see [the responder from zvkemp/frog_and_toad](https://github.com/zvkemp/frog_and_toad/blob/master/lib/frog_and_toad/responder.ex). + ## Work In Progress: -- Pluggable response modules (currently they don't do much out of the box; I have some experiments that I haven't yet committed to this repo). +- Implement a Responder behaviour diff --git a/config/dev.exs.example b/config/dev.exs.example index 3ce4d62..cf2b0f7 100644 --- a/config/dev.exs.example +++ b/config/dev.exs.example @@ -5,14 +5,12 @@ config :slack, print_to_console: true, # print console messages to the local tty (disabled in test) default_channel: "CHANNELID", bots: [ - %{ name: "bot_a", - token: "BOT_ACCESS_TOKEN (starting with xoxb-)", - ribbit_msg: "ribbit", # used in default responder - responder: Slack.Responders.Default - }, - %{ name: "bot_b", - token: "BOT_ACCESS_TOKEN (starting with xoxb-)", - ribbit_msg: "ribbit", # used in default responder - responder: Slack.Responders.Default - } + %{name: "frogbot", + workspace: "frog-and-toad", + socket_client: Slack.Console.Socket, # Remove this line to test with a real Slack channel + api_client: Slack.Console.APIClient, # Remove this line to test with a real Slack channel + token: "frogbot-local-token", # Replace with real API token + ribbit_msg: "ribbit", + responder: Slack.Responders.Default + } ] From 1f0db6263fcd7f9826350b6425dbfe60d8e3317f Mon Sep 17 00:00:00 2001 From: Zach Kemp Date: Sun, 15 Oct 2017 10:36:23 -0700 Subject: [PATCH 3/3] move test helpers to compiled directory --- lib/slack/bot.ex | 6 +- test/bot_test.exs | 2 +- test/slack/bot/message_tracker_test.exs | 21 +++---- test/slack/bot/outbox_test.exs | 1 + test/slack/bot/receiver_test.exs | 2 + test/support/slack_test_client.ex | 53 ----------------- test/support/slack_test_helpers.ex | 23 ++++++++ test/support/slack_test_message_forwarder.ex | 36 ++++++++++++ test/test_helper.exs | 61 -------------------- 9 files changed, 78 insertions(+), 127 deletions(-) delete mode 100644 test/support/slack_test_client.ex create mode 100644 test/support/slack_test_helpers.ex create mode 100644 test/support/slack_test_message_forwarder.ex diff --git a/lib/slack/bot.ex b/lib/slack/bot.ex index a4d77d3..4181808 100644 --- a/lib/slack/bot.ex +++ b/lib/slack/bot.ex @@ -17,6 +17,7 @@ defmodule Slack.Bot do @type bot_name :: {String.t(), String.t()} defmodule Config do + @moduledoc false @enforce_keys [:workspace] # Usually set by the result of an API call # messages per second @@ -29,13 +30,14 @@ defmodule Slack.Bot do ribbit_msg: nil, responder: nil, keywords: %{}, - ping_frequency: 10000, + ping_frequency: 10_000, rate_limit: 1 + @type t :: %Config{} end alias Slack.Bot.Config - @spec start_link(bot_name, %Config{}) :: GenServer.on_start() + @spec start_link(bot_name, Config.t) :: GenServer.on_start() def start_link(name, config) do GenServer.start_link(__MODULE__, config, name: registry_key(name, __MODULE__)) end diff --git a/test/bot_test.exs b/test/bot_test.exs index 89d92ae..d61e44d 100644 --- a/test/bot_test.exs +++ b/test/bot_test.exs @@ -2,7 +2,7 @@ defmodule Slack.BotTest.Integration do use ExUnit.Case, async: true import Slack.BotRegistry - alias Slack.Bot.{MessageTracker} + alias Slack.{Bot.MessageTracker, TestHelpers} setup_all do [name, token] = [6, 9] |> Enum.map(&(:crypto.strong_rand_bytes(&1) |> Base.encode64)) diff --git a/test/slack/bot/message_tracker_test.exs b/test/slack/bot/message_tracker_test.exs index 785ab7b..3ef4a8f 100644 --- a/test/slack/bot/message_tracker_test.exs +++ b/test/slack/bot/message_tracker_test.exs @@ -1,41 +1,42 @@ defmodule Slack.Bot.MessageTrackerTest do use ExUnit.Case import Slack.BotRegistry + alias Slack.{TestHelpers, TestMessageForwarder, Bot} setup do bot = TestHelpers.new_bot_name() ping_frequency = 100 - {:ok, server} = Slack.Bot.MessageTracker.start_link(bot, ping_frequency) - TestMessageForwarder.start_as(bot, Slack.Bot) + {:ok, server} = Bot.MessageTracker.start_link(bot, ping_frequency) + TestMessageForwarder.start_as(bot, Bot) {:ok, %{bot_name: bot, server: server}} end describe "pings" do test "automatic pings" do - assert_receive({Slack.Bot, :ping}, 110) - assert_receive({Slack.Bot, :ping}, 110) - assert_receive({Slack.Bot, :ping}, 110) + assert_receive({Bot, :ping}, 110) + assert_receive({Bot, :ping}, 110) + assert_receive({Bot, :ping}, 110) end test "ping timer is reset when outgoing messages are sent", %{server: server} do - assert_receive({Slack.Bot, :ping}, 110) + assert_receive({Bot, :ping}, 110) :timer.sleep(30) GenServer.call(server, {:push, %{type: "message", message: "msg"}}) - refute_receive({Slack.Bot, :ping}, 90) - assert_receive({Slack.Bot, :ping}, 110) + refute_receive({Bot, :ping}, 90) + assert_receive({Bot, :ping}, 110) end end describe "message tracking" do test "outgoing messages are counted", %{server: server} do - assert_receive({Slack.Bot, :ping}, 110) + assert_receive({Bot, :ping}, 110) {:ok, counter} = GenServer.call(server, {:push, %{type: "message", message: "msg"}}) assert %{messages: messages} = GenServer.call(server, :current) assert %{^counter => %{message: "msg", type: "message"}} = messages end test "incoming replies are acknowledged", %{server: server} do - assert_receive({Slack.Bot, :ping}, 110) + assert_receive({Bot, :ping}, 110) {:ok, counter} = GenServer.call(server, {:push, %{type: "message", message: "msg"}}) GenServer.call(server, {:reply, counter, %{"reply_to" => counter}}) assert %{messages: %{}} = GenServer.call(server, :current) diff --git a/test/slack/bot/outbox_test.exs b/test/slack/bot/outbox_test.exs index ba95b02..8d8b916 100644 --- a/test/slack/bot/outbox_test.exs +++ b/test/slack/bot/outbox_test.exs @@ -1,5 +1,6 @@ defmodule Slack.Bot.OutboxTest do use ExUnit.Case + alias Slack.{TestHelpers, TestMessageForwarder} setup do bot = TestHelpers.new_bot_name() diff --git a/test/slack/bot/receiver_test.exs b/test/slack/bot/receiver_test.exs index 322fae0..975a057 100644 --- a/test/slack/bot/receiver_test.exs +++ b/test/slack/bot/receiver_test.exs @@ -1,6 +1,8 @@ defmodule Slack.Bot.ReceiverTest do use ExUnit.Case + alias Slack.{TestHelpers, TestMessageForwarder} + setup do bot = TestHelpers.new_bot_name {:ok, queue} = Queue.start_link diff --git a/test/support/slack_test_client.ex b/test/support/slack_test_client.ex deleted file mode 100644 index dd5d533..0000000 --- a/test/support/slack_test_client.ex +++ /dev/null @@ -1,53 +0,0 @@ -defmodule SlackTestClient do - # NOTE: This has more or less been replaced by Slack.Console.APIClient - @moduledoc false - @behaviour Slack.Behaviours.API - - def auth_request(token, _internal_name) do - %{"url" => "ws://test.host/#{token}", - "self" => %{"id" => "ID#{token}"}, - "channels" => []} - end - - def join_channel(channel_name, token) do - %{} - end - - def list_groups(_), do: %{"groups" => []} -end - -# See Bot Integration Tests -defmodule SocketTestClient do - defstruct [:host, :options, :incoming] - - def connect!(host, opts) do - { :ok, agent } = Agent.start_link(fn -> [] end) - %SocketTestClient{ host: host, options: opts, incoming: agent } - end - - def recv(%{ incoming: pid } = socket) do - :timer.sleep(5) # small artificial latency - - case Agent.get(pid, &List.first(&1)) do - nil -> recv(socket) - data -> - Agent.update(pid, fn ([_|xs]) -> xs end) - { :ok, { :text, data }} - end - end - - def send!(%SocketTestClient{ options: opts, incoming: pid } = _socket, {:text, payload}) do - "/" <> token = opts[:path] - { :ok, json } = Poison.decode(payload) - data = { :test_payload, token, json } - { :ok, reply } = %{ "reply_to" => json["id"] } |> Poison.encode - - # confirms receipt with the test process; allows assert_receive calls - send(Process.whereis(:"RECV:#{token}"), data) - Agent.update(pid, fn (xs) -> [reply|xs] end) - end - - def register_test_receiver(pid, token) do - Process.register(pid, :"RECV:#{token}") - end -end diff --git a/test/support/slack_test_helpers.ex b/test/support/slack_test_helpers.ex new file mode 100644 index 0000000..c5a52ea --- /dev/null +++ b/test/support/slack_test_helpers.ex @@ -0,0 +1,23 @@ +defmodule Slack.TestHelpers do + def new_bot_name do + fn -> :crypto.strong_rand_bytes(16) end + |> Stream.repeatedly + |> Enum.take(2) + |> List.to_tuple + end + + @doc """ + proxies a subscription; forwards decoded JSON back to exunit for better + pattern matching on messages + """ + def subscribe_to_json(workspace, channel) do + me = self() + user_key = Base.encode64(:crypto.strong_rand_bytes(5)) + {:ok, pid} = Slack.TestMessageForwarder.start_as( + {workspace, user_key}, + :json, + fn {:push, json} -> Poison.decode!(json) end + ) + Slack.Console.PubSub.subscribe(workspace, channel, pid, user_key) + end +end diff --git a/test/support/slack_test_message_forwarder.ex b/test/support/slack_test_message_forwarder.ex new file mode 100644 index 0000000..748ba58 --- /dev/null +++ b/test/support/slack_test_message_forwarder.ex @@ -0,0 +1,36 @@ +defmodule Slack.TestMessageForwarder do + use GenServer + import Slack.BotRegistry + + @spec start_as(Slack.Bot.bot_name, atom, function | nil) :: GenServer.on_start() + def start_as(name, role, mapping_fun \\ default_mapping_fun) do + start_link(registry_key(name, role), self(), role, mapping_fun) + end + + @spec start_link(any, pid, atom, function) :: GenServer.on_start() + def start_link(name, exunit, role, mapping_fun) do + GenServer.start_link(__MODULE__, {exunit, role, mapping_fun}, name: name) + end + + @impl true + def init(config), do: {:ok, config} + + @impl true + def handle_cast(msg, {exunit, role, mapping_fun} = config) do + send(exunit, {role, mapping_fun.(msg)}) + {:noreply, config} + end + + @impl true + def handle_call(msg, _from, config) do + GenServer.reply(_from, :ok) + handle_cast(msg, config) + end + + @impl true + def handle_info(msg, config) do + handle_cast(msg, config) + end + + defp default_mapping_fun(), do: &(&1) +end diff --git a/test/test_helper.exs b/test/test_helper.exs index d7d28cf..869559e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,62 +1 @@ ExUnit.start() - -defmodule TestHelpers do - def new_bot_name do - fn -> :crypto.strong_rand_bytes(16) end - |> Stream.repeatedly - |> Enum.take(2) - |> List.to_tuple - end - - @doc """ - proxies a subscription; forwards decoded JSON back to exunit for better - pattern matching on messages - """ - def subscribe_to_json(workspace, channel) do - me = self() - user_key = Base.encode64(:crypto.strong_rand_bytes(5)) - {:ok, pid} = TestMessageForwarder.start_as( - {workspace, user_key}, - :json, - fn {:push, json} -> Poison.decode!(json) end - ) - Slack.Console.PubSub.subscribe(workspace, channel, pid, user_key) - end -end - -defmodule TestMessageForwarder do - use GenServer - import Slack.BotRegistry - - @spec start_as(Slack.Bot.bot_name, atom, function | nil) :: GenServer.on_start() - def start_as(name, role, mapping_fun \\ default_mapping_fun) do - start_link(registry_key(name, role), self(), role, mapping_fun) - end - - @spec start_link(any, pid, atom, function) :: GenServer.on_start() - def start_link(name, exunit, role, mapping_fun) do - GenServer.start_link(__MODULE__, {exunit, role, mapping_fun}, name: name) - end - - @impl true - def init(config), do: {:ok, config} - - @impl true - def handle_cast(msg, {exunit, role, mapping_fun} = config) do - send(exunit, {role, mapping_fun.(msg)}) - {:noreply, config} - end - - @impl true - def handle_call(msg, _from, config) do - GenServer.reply(_from, :ok) - handle_cast(msg, config) - end - - @impl true - def handle_info(msg, config) do - handle_cast(msg, config) - end - - defp default_mapping_fun(), do: &(&1) -end