├── test ├── test_helper.exs └── hedwig_slack_test.exs ├── .gitignore ├── mix.lock ├── mix.exs ├── LICENSE.md ├── config └── config.exs ├── lib ├── hedwig_slack.ex └── hedwig_slack │ └── connection.ex └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | -------------------------------------------------------------------------------- /test/hedwig_slack_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HedwigSlackTest do 2 | use ExUnit.Case 3 | doctest HedwigSlack 4 | 5 | test "the truth" do 6 | assert 1 + 1 == 2 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"cowlib": {:hex, :cowlib, "1.3.0"}, 2 | "gproc": {:hex, :gproc, "0.5.0"}, 3 | "gun": {:hex, :gun, "1.0.0-pre.1"}, 4 | "hedwig": {:git, "https://github.com/hedwig-im/hedwig.git", "18273f14fb8a6d5869551f0c7a3d2eebb2b3662a", []}, 5 | "poison": {:hex, :poison, "2.1.0"}, 6 | "ranch": {:hex, :ranch, "1.1.0"}} 7 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule HedwigSlack.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :hedwig_slack, 6 | version: "0.0.1", 7 | elixir: "~> 1.1", 8 | build_embedded: Mix.env == :prod, 9 | start_permanent: Mix.env == :prod, 10 | deps: deps] 11 | end 12 | 13 | def application do 14 | [applications: [:logger, :gun, :hedwig, :poison]] 15 | end 16 | 17 | defp deps do 18 | [{:gun, "1.0.0-pre.1"}, 19 | {:hedwig, github: "hedwig-im/hedwig"}, 20 | {:poison, "~> 2.0"}] 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Sonny Scroggin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :hedwig_slack, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:hedwig_slack, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/hedwig_slack.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Adapters.Slack do 2 | use Hedwig.Adapter 3 | 4 | alias Hedwig.Adapters.Slack.Connection 5 | 6 | defmodule State do 7 | defstruct conn: nil, 8 | opts: nil, 9 | robot: nil, 10 | users: %{}, 11 | channels: %{} 12 | end 13 | 14 | def init({robot, opts}) do 15 | {:ok, conn} = Connection.start_link(opts) 16 | {:ok, %State{conn: conn, opts: opts, robot: robot}} 17 | end 18 | 19 | def handle_cast({:send, msg}, %{conn: conn} = state) do 20 | Connection.ws_send(conn, slack_message(msg)) 21 | {:noreply, state} 22 | end 23 | 24 | def handle_cast({:reply, %{user: user, text: text} = msg}, %{conn: conn, users: users} = state) do 25 | msg = %{msg | text: "<@#{user}|#{users[user]["name"]}>: #{text}"} 26 | Connection.ws_send(conn, slack_message(msg)) 27 | {:noreply, state} 28 | end 29 | 30 | def handle_cast({:emote, msg}, %{conn: conn} = state) do 31 | Connection.ws_send(conn, slack_message(msg)) 32 | {:noreply, state} 33 | end 34 | 35 | def handle_info(%{"type" => "message"} = msg, %{conn: conn, robot: robot} = state) do 36 | msg = %Hedwig.Message{ 37 | ref: make_ref(), 38 | room: msg["channel"], 39 | text: msg["text"], 40 | type: msg["type"], 41 | user: msg["user"] 42 | } 43 | if msg.text do 44 | Hedwig.Robot.handle_message(robot, msg) 45 | end 46 | {:noreply, state} 47 | end 48 | 49 | def handle_info(%{"type" => "channel_created", "channel" => _channel}, state) do 50 | {:noreply, state} 51 | end 52 | 53 | def handle_info(%{"type" => "channel_joined", "channel" => _channel}, state) do 54 | {:noreply, state} 55 | end 56 | 57 | def handle_info(%{"type" => "message", "subtype" => "channel_join"}, state) do 58 | {:noreply, state} 59 | end 60 | 61 | def handle_info({:user, %{"id" => id} = user}, %{users: users} = state) do 62 | {:noreply, %{state | users: Map.put(users, id, user)}} 63 | end 64 | 65 | def handle_info(:connection_ready, %{robot: robot} = state) do 66 | Hedwig.Robot.after_connect(robot) 67 | {:noreply, state} 68 | end 69 | 70 | def handle_info(msg, state) do 71 | IO.inspect msg 72 | {:noreply, state} 73 | end 74 | 75 | defp slack_message(%Hedwig.Message{room: room, text: text, type: type}) do 76 | %{channel: room, 77 | text: text, 78 | type: type} 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/hedwig_slack/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Adapters.Slack.Connection do 2 | use GenServer 3 | 4 | require Logger 5 | 6 | @endpoint "https://slack.com/api/rtm.start" 7 | 8 | defmodule State do 9 | defstruct conn: nil, 10 | host: nil, 11 | next_id: 1, 12 | owner: nil, 13 | path: nil, 14 | port: nil, 15 | ref: nil, 16 | token: nil, 17 | query: %{}, 18 | server_data: %{}, 19 | reconnect_url: nil 20 | end 21 | 22 | def start_link(opts) do 23 | GenServer.start_link(__MODULE__, {self, opts}) 24 | end 25 | 26 | def ws_send(pid, msg) do 27 | GenServer.call(pid, {:ws_send, msg}) 28 | end 29 | 30 | def init({owner, opts}) do 31 | %URI{host: host, port: port, path: path, query: query} = 32 | URI.parse(opts[:endpoint] || @endpoint) 33 | 34 | {:ok, conn} = :gun.open(to_char_list(host), port) 35 | 36 | :ok = GenServer.cast(self(), :rtm_start) 37 | 38 | {:ok, %State{ 39 | conn: conn, 40 | host: host, 41 | owner: owner, 42 | port: port, 43 | path: path, 44 | token: opts[:token], 45 | query: query}} 46 | end 47 | 48 | def handle_call({:ws_send, msg}, _from, %{conn: conn, next_id: id} = state) do 49 | msg = msg |> Map.put(:id, id) |> Poison.encode!() 50 | :ok = :gun.ws_send(conn, {:text, msg}) 51 | {:reply, :ok, %{state | next_id: id + 1}} 52 | end 53 | 54 | def handle_cast(:rtm_start, %{conn: conn} = state) do 55 | ref = :gun.get(conn, rtm_path(state)) 56 | {:ok, body} = :gun.await_body(conn, ref) 57 | decoded = Poison.decode!(body) 58 | 59 | for user <- decoded["users"] do 60 | send(state.owner, {:user, user}) 61 | end 62 | 63 | :ok = GenServer.cast(self(), :ws_upgrade) 64 | {:noreply, %{state | ref: ref, server_data: decoded}} 65 | end 66 | 67 | def handle_cast(:ws_upgrade, %{conn: conn, server_data: %{"url" => url}} = state) do 68 | %URI{host: host, path: path} = URI.parse(url) 69 | :ok = :gun.close(conn) 70 | {:ok, conn} = :gun.open(to_char_list(host), 443) 71 | ref = :gun.ws_upgrade(conn, to_char_list(path)) 72 | {:noreply, %{state | conn: conn, ref: ref}} 73 | end 74 | 75 | def handle_info({:gun_response, _conn, _ref, _is_fin, _status, headers}, state) do 76 | {:noreply, state} 77 | end 78 | 79 | def handle_info({:gun_ws_upgrade, _conn, :ok, _headers}, state) do 80 | {:noreply, state} 81 | end 82 | 83 | def handle_info({:gun_ws, conn, {:text, data}}, %{conn: conn} = state) do 84 | send(self(), {:handle_data, Poison.decode!(data)}) 85 | {:noreply, state} 86 | end 87 | 88 | def handle_info({:handle_data, %{"type" => "reconnect_url", "url" => url} = msg}, state) do 89 | {:noreply, %{state | reconnect_url: url}} 90 | end 91 | 92 | def handle_info({:handle_data, data}, state) do 93 | handle_data(data, state.owner) 94 | {:noreply, state} 95 | end 96 | 97 | def handle_info({:gun_down, _conn, :http, _reason, _, _} = msg, state) do 98 | IO.inspect msg 99 | {:noreply, state} 100 | end 101 | 102 | def handle_info({:gun_down, _conn, :ws, _reason, _, _} = msg, state) do 103 | IO.inspect msg 104 | {:noreply, state} 105 | end 106 | 107 | def handle_info({:gun_up, _conn, :http}, state) do 108 | {:noreply, state} 109 | end 110 | 111 | def handle_info(msg, state) do 112 | IO.inspect msg 113 | {:noreply, state} 114 | end 115 | 116 | defp handle_data(%{"type" => "hello"}, owner) do 117 | send(owner, :connection_ready) 118 | Logger.info "Connected Successfully!" 119 | end 120 | 121 | defp handle_data(data, owner) do 122 | send(owner, data) 123 | end 124 | 125 | defp rtm_path(%{path: path, query: nil, token: token}), do: 126 | '#{path}?token=#{token}' 127 | defp rtm_path(%{path: path, query: query, token: token}), do: 128 | '#{path}?#{URI.encode_query(Map.put(query, token: token))}' 129 | end 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hedwig Slack Adapter 2 | 3 | > A Slack Adapter for [Hedwig](https://github.com/hedwig-im/hedwig) 4 | 5 | ## Getting started 6 | 7 | Let's generate a new Elixir application with a supervision tree: 8 | 9 | ``` 10 | λ mix new alfred --sup 11 | * creating README.md 12 | * creating .gitignore 13 | * creating mix.exs 14 | * creating config 15 | * creating config/config.exs 16 | * creating lib 17 | * creating lib/alfred.ex 18 | * creating test 19 | * creating test/test_helper.exs 20 | * creating test/alfred_test.exs 21 | 22 | Your Mix project was created successfully. 23 | You can use "mix" to compile it, test it, and more: 24 | 25 | cd alfred 26 | mix test 27 | 28 | Run "mix help" for more commands. 29 | ``` 30 | 31 | Change into our new application directory: 32 | 33 | ``` 34 | λ cd alfred 35 | ``` 36 | 37 | Add `hedwig_slack` to your list of dependencies in `mix.exs`: 38 | 39 | ```elixir 40 | def deps do 41 | [{:hedwig_slack, github: "hedwig-im/hedwig_slack"}] 42 | end 43 | ``` 44 | 45 | Ensure `hedwig_slack` is started before your application: 46 | 47 | ```elixir 48 | def application do 49 | [applications: [:hedwig_slack]] 50 | end 51 | ``` 52 | 53 | ### Generate our robot 54 | 55 | ``` 56 | λ mix hedwig.gen.robot 57 | 58 | Welcome to the Hedwig Robot Generator! 59 | 60 | Let's get started. 61 | 62 | What would you like to name your bot?: alfred 63 | 64 | Available adapters 65 | 66 | 1. Hedwig.Adapters.Slack 67 | 2. Hedwig.Adapters.Console 68 | 3. Hedwig.Adapters.Test 69 | 70 | Please select an adapter: 1 71 | 72 | * creating lib/alfred 73 | * creating lib/alfred/robot.ex 74 | * updating config/config.exs 75 | 76 | Don't forget to add your new robot to your supervision tree 77 | (typically in lib/alfred.ex): 78 | 79 | worker(Alfred.Robot, []) 80 | ``` 81 | 82 | ### Supervise our robot 83 | 84 | We'll want Alfred to be supervised and started when we start our application. 85 | Let's add it to our supervision tree. Open up `lib/alfred.ex` and add the 86 | following to the `children` list: 87 | 88 | ```elixir 89 | worker(Alfred.Robot, []) 90 | ``` 91 | 92 | ### Configuration 93 | 94 | The next thing we need to do is configure our bot for our XMPP server. Open up 95 | `config/config.exs` and let's take a look at what was generated for us: 96 | 97 | ```elixir 98 | use Mix.Config 99 | 100 | config :alfred, Alfred.Robot, 101 | adapter: Hedwig.Adapters.Slack, 102 | name: "alfred", 103 | aka: "/", 104 | responders: [ 105 | {Hedwig.Responders.Help, []}, 106 | {Hedwig.Responders.Panzy, []}, 107 | {Hedwig.Responders.GreatSuccess, []}, 108 | {Hedwig.Responders.ShipIt, []} 109 | ] 110 | ``` 111 | 112 | So we have the `adapter`, `name`, `aka`, and `responders` set. The `adapter` is 113 | the module responsible for handling all of the Slack details like connecting and 114 | sending and receiving messages over the network. The `name` is the name that our 115 | bot will respond to. The `aka` (also known as) field is optional, but it allows 116 | us to address our bot with an alias. By default, this alias is set to `/`. 117 | 118 | Finally we have `responders`. Responders are modules that provide functions that 119 | match on the messages that get sent to our bot. We'll discuss this further in 120 | a bit. 121 | 122 | We'll need to provide a few more things in order for us to connect to our Slack 123 | server. We'll need to provide our bot's API key as well as a list of rooms we 124 | want our bot to join once connected. Let's see what that looks like: 125 | 126 | ```elixir 127 | use Mix.Config 128 | 129 | config :alfred, Alfred.Robot, 130 | adapter: Hedwig.Adapters.Slack, 131 | name: "alfred", 132 | aka: "/", 133 | # fill in the appropriate API token for your bot 134 | token: "some api token", 135 | # for now, you can invite your bot to a channel in slack and it will join 136 | # automatically 137 | rooms: [], 138 | responders: [ 139 | {Hedwig.Responders.Help, []}, 140 | {Hedwig.Responders.Panzy, []}, 141 | {Hedwig.Responders.GreatSuccess, []}, 142 | {Hedwig.Responders.ShipIt, []} 143 | ] 144 | ``` 145 | 146 | Great! We're ready to start our bot. From the root of our application, let's run 147 | the following: 148 | 149 | ``` 150 | λ mix run --no-halt 151 | ``` 152 | 153 | This will start our application along with our bot. Our bot should connect to 154 | Slack and join the rooms it's in based on its Slack integration. From there, we 155 | can chat with our bot in any Slack client. 156 | 157 | Since we have the `Help` responder installed, we can say `alfred help` and we 158 | should see a list of usage for all of the installed responders. 159 | 160 | ## What's next? 161 | 162 | Well, that's it for now. Make sure to read the [Hedwig Documentation](http://hexdocs.pm/hedwig) for more 163 | details on writing responders and other exciting things! 164 | 165 | ## LICENSE 166 | 167 | Copyright (c) 2016, Sonny Scroggin. 168 | 169 | Hedwig Slack source code is licensed under the [MIT License](https://github.com/hedwig-im/hedwig_slack/blob/master/LICENSE.md). 170 | --------------------------------------------------------------------------------