Skip to content

Commit

Permalink
Merge pull request #9 from zvkemp/cleanup
Browse files Browse the repository at this point in the history
ex 1.6 formatting; better tests
  • Loading branch information
zvkemp authored Oct 15, 2017
2 parents cbbe9f4 + 1f0db62 commit 0f8ce62
Show file tree
Hide file tree
Showing 30 changed files with 409 additions and 245 deletions.
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
erlang 20.0
elixir 1.5.1-otp-20
elixir 1.5.2-otp-20
57 changes: 39 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 8 additions & 10 deletions config/dev.exs.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
1 change: 1 addition & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}],
Expand Down
33 changes: 21 additions & 12 deletions lib/slack.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
18 changes: 10 additions & 8 deletions lib/slack/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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
8 changes: 4 additions & 4 deletions lib/slack/behaviours.ex
Original file line number Diff line number Diff line change
@@ -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
Expand Down
41 changes: 23 additions & 18 deletions lib/slack/bot.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,30 @@ 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
@moduledoc false
@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: 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
Expand All @@ -62,7 +64,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
Expand All @@ -83,7 +85,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
Expand All @@ -106,7 +108,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)
Expand Down
5 changes: 3 additions & 2 deletions lib/slack/bot/event_handler.ex
Original file line number Diff line number Diff line change
@@ -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
Expand Down
21 changes: 14 additions & 7 deletions lib/slack/bot/message_tracker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
6 changes: 5 additions & 1 deletion lib/slack/bot/outbox.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 0f8ce62

Please sign in to comment.