├── .credo.exs ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── coveralls.json ├── examples └── responders │ ├── great_success.ex │ └── ship_it.ex ├── hedwig.png ├── lib ├── hedwig.ex ├── hedwig │ ├── adapter.ex │ ├── adapters │ │ ├── console.ex │ │ ├── console │ │ │ ├── connection.ex │ │ │ ├── reader.ex │ │ │ └── writer.ex │ │ └── test.ex │ ├── message.ex │ ├── responder.ex │ ├── responder │ │ └── supervisor.ex │ ├── responders │ │ ├── help.ex │ │ └── ping.ex │ ├── robot.ex │ ├── robot │ │ └── supervisor.ex │ ├── supervisor.ex │ ├── test │ │ ├── robot_case.ex │ │ └── test_robot.ex │ └── user.ex └── mix │ └── tasks │ └── hedwig.gen.robot.ex ├── mix.exs ├── mix.lock └── test ├── hedwig ├── adapters │ ├── console │ │ └── writer_test.exs │ └── console_test.exs ├── responder_test.exs ├── responders │ ├── help_test.exs │ └── ping_test.exs ├── robot │ └── supervisor_test.exs └── robot_test.exs ├── hedwig_test.exs ├── mix └── tasks │ └── hedwig.gen.robot_test.exs ├── support ├── file_helpers.ex └── test_responder.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "src/", "web/", "apps/"], 25 | excluded: [~r"/_build/", ~r"/deps/"] 26 | }, 27 | # 28 | # If you create your own checks, you must specify the source files for 29 | # them here, so they can be loaded by Credo before running the analysis. 30 | # 31 | requires: [], 32 | # 33 | # If you want to enforce a style guide and need a more traditional linting 34 | # experience, you can change `strict` to `true` below: 35 | # 36 | strict: false, 37 | # 38 | # If you want to use uncolored output by default, you can change `color` 39 | # to `false` below: 40 | # 41 | color: true, 42 | # 43 | # You can customize the parameters of any check by adding a second element 44 | # to the tuple. 45 | # 46 | # To disable a check put `false` as second element: 47 | # 48 | # {Credo.Check.Design.DuplicatedCode, false} 49 | # 50 | checks: [ 51 | {Credo.Check.Consistency.ExceptionNames}, 52 | {Credo.Check.Consistency.LineEndings}, 53 | {Credo.Check.Consistency.ParameterPatternMatching}, 54 | {Credo.Check.Consistency.SpaceAroundOperators}, 55 | {Credo.Check.Consistency.SpaceInParentheses}, 56 | {Credo.Check.Consistency.TabsOrSpaces}, 57 | 58 | # You can customize the priority of any check 59 | # Priority values are: `low, normal, high, higher` 60 | # 61 | {Credo.Check.Design.AliasUsage, priority: :low}, 62 | 63 | # For some checks, you can also set other parameters 64 | # 65 | # If you don't want the `setup` and `test` macro calls in ExUnit tests 66 | # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just 67 | # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. 68 | # 69 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 70 | 71 | # You can also customize the exit_status of each check. 72 | # If you don't want TODO comments to cause `mix credo` to fail, just 73 | # set this value to 0 (zero). 74 | # 75 | {Credo.Check.Design.TagTODO, exit_status: 2}, 76 | {Credo.Check.Design.TagFIXME}, 77 | 78 | {Credo.Check.Readability.FunctionNames}, 79 | {Credo.Check.Readability.LargeNumbers}, 80 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 80}, 81 | {Credo.Check.Readability.ModuleAttributeNames}, 82 | {Credo.Check.Readability.ModuleDoc}, 83 | {Credo.Check.Readability.ModuleNames}, 84 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, 85 | {Credo.Check.Readability.ParenthesesInCondition}, 86 | {Credo.Check.Readability.PredicateFunctionNames}, 87 | {Credo.Check.Readability.PreferImplicitTry}, 88 | {Credo.Check.Readability.RedundantBlankLines}, 89 | {Credo.Check.Readability.StringSigils}, 90 | {Credo.Check.Readability.TrailingBlankLine}, 91 | {Credo.Check.Readability.TrailingWhiteSpace}, 92 | {Credo.Check.Readability.VariableNames}, 93 | {Credo.Check.Readability.Semicolons}, 94 | {Credo.Check.Readability.SpaceAfterCommas}, 95 | 96 | {Credo.Check.Refactor.DoubleBooleanNegation}, 97 | {Credo.Check.Refactor.CondStatements}, 98 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 99 | {Credo.Check.Refactor.FunctionArity}, 100 | {Credo.Check.Refactor.LongQuoteBlocks, false}, 101 | {Credo.Check.Refactor.MatchInCondition}, 102 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 103 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 104 | {Credo.Check.Refactor.Nesting}, 105 | {Credo.Check.Refactor.PipeChainStart}, 106 | {Credo.Check.Refactor.UnlessWithElse}, 107 | 108 | {Credo.Check.Warning.BoolOperationOnSameValues}, 109 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck}, 110 | {Credo.Check.Warning.IExPry}, 111 | {Credo.Check.Warning.IoInspect}, 112 | {Credo.Check.Warning.LazyLogging}, 113 | {Credo.Check.Warning.OperationOnSameValues}, 114 | {Credo.Check.Warning.OperationWithConstantResult}, 115 | {Credo.Check.Warning.UnusedEnumOperation}, 116 | {Credo.Check.Warning.UnusedFileOperation}, 117 | {Credo.Check.Warning.UnusedKeywordOperation}, 118 | {Credo.Check.Warning.UnusedListOperation}, 119 | {Credo.Check.Warning.UnusedPathOperation}, 120 | {Credo.Check.Warning.UnusedRegexOperation}, 121 | {Credo.Check.Warning.UnusedStringOperation}, 122 | {Credo.Check.Warning.UnusedTupleOperation}, 123 | {Credo.Check.Warning.RaiseInsideRescue}, 124 | 125 | # Controversial and experimental checks (opt-in, just remove `, false`) 126 | # 127 | {Credo.Check.Refactor.ABCSize, false}, 128 | {Credo.Check.Refactor.AppendSingleItem, false}, 129 | {Credo.Check.Refactor.VariableRebinding, false}, 130 | {Credo.Check.Warning.MapGetUnsafePass, false}, 131 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 132 | 133 | # Deprecated checks (these will be deleted after a grace period) 134 | # 135 | {Credo.Check.Readability.Specs, false}, 136 | {Credo.Check.Warning.NameRedeclarationByAssignment, false}, 137 | {Credo.Check.Warning.NameRedeclarationByCase, false}, 138 | {Credo.Check.Warning.NameRedeclarationByDef, false}, 139 | {Credo.Check.Warning.NameRedeclarationByFn, false}, 140 | 141 | # Custom checks can be created using `mix credo.gen.check`. 142 | # 143 | ] 144 | } 145 | ] 146 | } 147 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | .envrc 6 | .iex.exs 7 | /config/test.exs 8 | /config/dev.exs 9 | /config/prod.exs 10 | /doc 11 | /mnesia 12 | /log 13 | /test/tmp 14 | /cover -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | matrix: 3 | include: 4 | - otp_release: 18.3 5 | elixir: 1.3 6 | - otp_release: 19.3 7 | elixir: 1.3 8 | - otp_release: 18.3 9 | elixir: 1.4 10 | - otp_release: 19.3 11 | elixir: 1.4 12 | - otp_release: 20.2 13 | elixir: 1.4 14 | - otp_release: 19.3 15 | elixir: 1.5 16 | - otp_release: 20.2 17 | elixir: 1.5 18 | - otp_release: 20.2 19 | elixir: 1.6 20 | sudo: false 21 | after_script: 22 | - MIX_ENV=test mix coveralls.travis 23 | - mix credo 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.0.1 (2018-01-26) 4 | 5 | - Clean up warnings 6 | 7 | ## v1.0.0 (2016-11-20) 8 | 9 | - Improvements 10 | - Handle disconnects with `handle_disconnect/2` in the robot module. See docs 11 | for details. 12 | - Responders are now `GenServer`s. 13 | 14 | - Backwards Incompatible Changes 15 | - The `user` field on `Hedwig.Message` is now a `Hedwig.User` struct. 16 | This should aid in consistency across adapters. 17 | - `after_connect/1` is now `handle_connect/1`. See the docs for details. 18 | - Adapters should now call `Hedwig.Robot.handle_in/2` rather than `handle_message` 19 | for incoming messages. See the docs for details. 20 | - `Hedwig.Registry` has been removed. Alternatives are outlined in the README. 21 | - `GreatSuccess` and `ShipIt` responders have been moved in the `examples` 22 | directory and no longer shipped with Hedwig. 23 | 24 | ## v1.0.0-rc.4 (2016-04-17) 25 | 26 | - Breaking Changes 27 | - The `Panzy` responder has been removed. You will need to remove it from your 28 | bot's list of responders (if you had previously had it configured). 29 | - The `adapter` field has been removed from the `Hedwig.Message` struct. 30 | - Robots are now a proper `GenServer`. 31 | 32 | Diff: https://github.com/hedwig-im/hedwig/compare/v1.0.0-rc3...v1.0.0-rc.4 33 | 34 | ## v1.0.0-rc3 (2016-02-04) 35 | 36 | Diff: https://github.com/hedwig-im/hedwig/compare/v1.0.0-rc2...v1.0.0-rc3 37 | 38 | ## v1.0.0-rc2 (2016-02-04) 39 | 40 | Diff: https://github.com/hedwig-im/hedwig/compare/v1.0.0-rc1...v1.0.0-rc2 41 | 42 | ## v1.0.0-rc1 (2016-01-08) 43 | 44 | Diff: https://github.com/hedwig-im/hedwig/compare/v1.0.0-rc0...v1.0.0-rc1 45 | 46 | ## v1.0.0-rc0 (2015-12-19) 47 | 48 | Major rewrite and breaking changes. See the diff below for details. 49 | 50 | Diff: https://github.com/hedwig-im/hedwig/compare/v0.3.0...v1.0.0-rc0 51 | 52 | ## v0.3.0 (2015-10-16) 53 | 54 | - Improvements 55 | - Documentation Improvements 56 | - Added `Hedwig.Stanza.presence/2` 57 | - Increased timeout in `Hedwig.Conn` to `30_000` ms. 58 | 59 | Diff: https://github.com/hedwig-im/hedwig/compare/v0.2.0...v0.3.0 60 | 61 | ## v0.2.0 (2015-08-09) 62 | 63 | - Improvements 64 | 65 | - `Hedwig.whereis/1` can be used to return the `pid` of a client by the `jid` 66 | - Clients are now supervised via `:simple_one_for_one` and can be 67 | started/stopped via `Hedwig.start_client/1` and `Hedwig.stop_client/1` 68 | - Supports inband registration via `Stanza.set_inband_register/2` 69 | - Supports subscribing to a PubSub node via `Stanza.subscribe/3` 70 | 71 | - Backwards Incompatible Changes 72 | 73 | - Clients are no longer configured via `config.exs`. Instead you must now manage 74 | starting/stopping clients via `Hedwig.start_client/1` and `Hedwig.stop_client/1` 75 | 76 | Release Diff: https://github.com/scrogson/hedwig/compare/v0.1.0...v0.2.0 77 | 78 | ## v0.1.0 (2015-01-04) 79 | 80 | - Bug Fixes 81 | - Default `type` for a `presence` stanza is now `nil` 82 | - Default `type` for a `message` stanza is now `normal` 83 | - Feature negotiation is now handled a second time if the connection is upgraded to TLS. 84 | 85 | - Improvements 86 | 87 | - Authentication has been cleaned up and allows you to configure your preferred auth mechanism. 88 | - Support `ANONYMOUS` auth mechanism. 89 | - `Stanza.iq/{2,3}` - `iq` stanzas can now be sent to a specified `jid`. 90 | - `Stanza.get_roster/0` to fetch the client's roster. 91 | - `Stanza.get_vcard/1` to fetch the vcard of a specified `jid`. 92 | - `Stanza.disco_info/1` to discover features and capabilities of a server or client. 93 | - `Stanza.disco_items/1` to discover features and capabilities of a server or client. 94 | - `Stanza.presence/1` to allow a client to become `unavailable`. 95 | - `JID` now implements `String.Chars.to_string/1` protocol. 96 | - `ignore_from_self?` option to allow stanzas to be processed for messages sent by the client. Defaults to `false`. 97 | - Clients can now be stopped cleanly. Send a message of `{:stop, reason}` and the client will send an `unavailable` presence and close the stream. 98 | - Stanza parsing is now more robust. Parses into appropriate structs and includes a `payload` key for access to the `raw` parsed data structure. 99 | 100 | Release Diff: https://github.com/scrogson/hedwig/compare/v0.0.3...v0.1.0 101 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hedwig 2 | 3 | > An Adapter-based Bot Framework for Elixir Applications 4 | 5 | [![Build Status](https://travis-ci.org/hedwig-im/hedwig.svg?branch=master)](https://travis-ci.org/hedwig-im/hedwig) 6 | [![Coverage Status](https://coveralls.io/repos/hedwig-im/hedwig/badge.svg?branch=master&service=github)](https://coveralls.io/github/hedwig-im/hedwig?branch=master) 7 | 8 | ![Hedwig](https://raw.githubusercontent.com/hedwig-im/hedwig/master/hedwig.png) 9 | 10 | Hedwig is a chat bot, highly inspired by GitHub's [Hubot](https://hubot.github.com/). 11 | 12 | See the [online documentation](https://hexdocs.pm/hedwig) for more information. 13 | 14 | Hedwig was designed for 2 use-cases: 15 | 16 | 1. A single, stand-alone OTP application. 17 | 2. Included as a dependency of other OTP applications (or an umbrella). 18 | 19 | You can spawn multiple bots at run-time with different configurations. 20 | 21 | ## Adapters 22 | 23 | - [XMPP](https://github.com/hedwig-im/hedwig_xmpp) (Official) 24 | - [Slack](https://github.com/hedwig-im/hedwig_slack) (Official) 25 | - [Flowdock](https://github.com/supernullset/hedwig_flowdock) 26 | 27 | Check out [enilsen16/awesome-hedwig](https://github.com/enilsen16/awesome-hedwig) for a curated list of adapters, responders, and other resources. 28 | 29 | ## Getting started 30 | 31 | Hedwig ships with a console adapter to get you up and running quickly. It's 32 | great for testing how your bot will respond to the messages it receives. 33 | 34 | To add Hedwig to an existing Elixir application, add `:hedwig` to your list of 35 | dependencies in your `mix.exs` file: 36 | 37 | ```elixir 38 | defp deps do 39 | [{:hedwig, "~> 1.0"}] 40 | end 41 | ``` 42 | 43 | Update your applications list to include `:hedwig`. This will ensure that the 44 | Hedwig application, along with it's supervision tree is started when you start 45 | your application. 46 | 47 | ```elixir 48 | def applications do 49 | [applications: [:hedwig]] 50 | end 51 | ``` 52 | 53 | Fetch the dependencies: 54 | 55 | ``` 56 | $ mix deps.get 57 | ``` 58 | 59 | ## Create a robot module 60 | 61 | Hedwig provides a convenient mix task to help you generate a basic robot module. 62 | 63 | Run the following and follow the prompts: 64 | 65 | ``` 66 | $ mix hedwig.gen.robot 67 | 68 | Welcome to the Hedwig Robot Generator! 69 | 70 | Let's get started. 71 | 72 | What would you like to name your bot?: alfred 73 | 74 | Available adapters 75 | 76 | 1. Hedwig.Adapters.Console 77 | 78 | Please select an adapter: 1 79 | 80 | * creating lib/alfred 81 | * creating lib/alfred/robot.ex 82 | * updating config/config.exs 83 | 84 | Don't forget to add your new robot to your supervision tree 85 | (typically in lib/alfred.ex): 86 | 87 | worker(Alfred.Robot, []) 88 | ``` 89 | 90 | ```elixir 91 | defmodule Alfred.Robot do 92 | use Hedwig.Robot, otp_app: :alfred 93 | 94 | ... 95 | end 96 | ``` 97 | 98 | ## Configuration 99 | 100 | The generator will automatically generate a default configuration in 101 | `config/config.exs`. You will need to customize it further depending on the 102 | adapter you will use. 103 | 104 | This is mainly to setup the module to be compiled along with the adapter. An 105 | adapter can inject functionality into your module if needed. 106 | 107 | ```elixir 108 | # config/config.exs 109 | 110 | config :alfred, Alfred.Robot, 111 | adapter: Hedwig.Adapters.Console, 112 | name: "alfred", 113 | aka: "/", 114 | responders: [ 115 | {Hedwig.Responders.Help, []}, 116 | {Hedwig.Responders.Ping, []} 117 | ] 118 | ``` 119 | 120 | ### Start a bot. 121 | 122 | You can start your bot as part of your application's supervision tree or by 123 | using the supervision tree provided by Hedwig. 124 | 125 | ### Starting as part of your supervision tree: 126 | 127 | ```elixir 128 | # add this to the list of your supervisor's children 129 | worker(Alfred.Robot, []) 130 | ``` 131 | 132 | ### Trying out the console adapter: 133 | 134 | ``` 135 | mix run --no-halt 136 | 137 | Hedwig Console - press Ctrl+C to exit. 138 | 139 | The console adapter is useful for quickly verifying how your 140 | bot will respond based on the current installed responders. 141 | 142 | scrogson> alfred help 143 | alfred> alfred help - Displays all help commands that match . 144 | alfred help - Displays all of the help commands that alfred knows about. 145 | alfred: ping - Responds with 'pong' 146 | scrogson> 147 | ``` 148 | 149 | ### Starting bots manually: 150 | 151 | ```elixir 152 | # Start the bot via the module. The configuration options will be read in from 153 | # config.exs 154 | {:ok, pid} = Hedwig.start_robot(Alfred.Robot) 155 | 156 | # You can also pass in a list of options that will override the configuration 157 | # provided in config.exs (except for the adapter as that is compiled into the 158 | # module). 159 | {:ok, pid} = Hedwig.start_robot(Alfred.Robot, [name: "jeeves"]) 160 | ``` 161 | 162 | ### Registering your robot process 163 | 164 | If you want to start, stop, and send messages to your bot without keeping track 165 | of its `pid`, you can register your robot in the `handle_connect/1` callback in 166 | your robot module like so: 167 | 168 | ```elixir 169 | defmodule Alfred.Robot do 170 | use Hedwig.Robot, otp_app: :alfred 171 | 172 | def handle_connect(%{name: name} = state) do 173 | if :undefined == :global.whereis_name(name) do 174 | :yes = :global.register_name(name, self()) 175 | end 176 | {:ok, state} 177 | end 178 | end 179 | ``` 180 | 181 | Process registration via `Process.register/2` is simple. However, since the name 182 | can only be an atom it may not work for all use-cases. If you are using the same 183 | module for many robots, you'll need to reach for something more flexible like: 184 | 185 | * [global](http://erlang.org/doc/man/global.html) 186 | * [gproc](https://github.com/uwiger/gproc) 187 | * [swarm](https://github.com/bitwalker/swarm) 188 | 189 | #### Finding your robot 190 | 191 | ```elixir 192 | # Start the robot 193 | Hedwig.start_robot(Alfred.Robot) 194 | # Get the pid of the robot by name 195 | pid = :global.whereis_name("alfred") 196 | 197 | # Start a new robot with a different name 198 | Hedwig.start_robot(Alfred.Robot, [name: "jeeves"]) 199 | # Get the pid 200 | pid = :global.whereis_name("jeeves") 201 | # Stop the robot 202 | Hedwig.stop_robot(pid) 203 | ``` 204 | 205 | ## Sending Messages 206 | 207 | ```elixir 208 | # Get the pid of the robot 209 | pid = :global.whereis_name("alfred") 210 | 211 | # Create a Hedwig message 212 | msg = %Hedwig.Message{ 213 | type: "groupchat", 214 | room: "my_room@example.com", 215 | text: "hello world" 216 | } 217 | 218 | # Send the message 219 | Hedwig.Robot.send(pid, msg) 220 | ``` 221 | 222 | ## Building Responders 223 | 224 | Responders are processes that will handle incoming messages. 225 | 226 | All that's needed is to `use Hedwig.Responder` and use the `hear/2`, or 227 | `respond/2` macros to define a pattern to listen for and how to respond in 228 | the block when a message matches. 229 | 230 | Here is an example: 231 | 232 | ```elixir 233 | defmodule MyApp.Responders.GreatSuccess do 234 | @moduledoc """ 235 | Borat, Great Success! 236 | 237 | Replies with a random link to a Borat image when a message contains 238 | 'great success'. 239 | """ 240 | 241 | use Hedwig.Responder 242 | 243 | @links [ 244 | "http://mjanja.co.ke/wordpress/wp-content/uploads/2013/09/borat_great_success.jpg", 245 | "http://s2.quickmeme.com/img/13/1324dfd733535e58dba70264e6d05c9b70346204d2cacef65abef9c702746d1c.jpg", 246 | "https://www.youtube.com/watch?v=r13riaRKGo0" 247 | ] 248 | 249 | @usage """ 250 | (great success) - Replies with a random Borat image. 251 | """ 252 | hear ~r/great success(!)?/i, msg do 253 | reply msg, random(@links) 254 | end 255 | end 256 | ``` 257 | 258 | ## Hear vs. Respond 259 | 260 | The two responder macros are use for different reasons: 261 | 262 | * `hear` - matches messages containing the regular expression 263 | * `respond` - matches only when prefixed by your robot's configured `name` or `aka` value. 264 | 265 | ## Testing responders: 266 | 267 | Hedwig ships with a ExUnit-based module sepecifically made to test responders: `Hedwig.RobotCase`. 268 | 269 | In order to test the above responder, you need to create an ExUnit test case: 270 | 271 | ```elixir 272 | 273 | # test/my_app/responders/great_success_test.exs 274 | 275 | defmodule MyApp.Responders.GreatSuccessTest do 276 | use Hedwig.RobotCase 277 | 278 | @tag start_robot: true, name: "alfred", responders: [{MyApp.Responders.GreatSuccess, []}] 279 | test "great success - responds with a borat url", %{adapter: adapter, msg: msg} do 280 | send adapter, {:message, %{msg | text: "great success"}} 281 | assert_receive {:message, %{text: text}} 282 | assert String.contains?(text, "http") 283 | end 284 | end 285 | ``` 286 | 287 | To run the tests, use `mix test` 288 | 289 | ## @usage 290 | 291 | The `@usage` module attribute works nicely with `Hedwig.Responders.Help`. If you 292 | install the help handler, your bot will listen for ` help` 293 | and respond with a message containing all of the installed handlers `@usage` 294 | text. 295 | 296 | ## License 297 | 298 | The MIT License (MIT) 299 | 300 | Copyright (c) 2015 Sonny Scroggin 301 | 302 | Permission is hereby granted, free of charge, to any person obtaining a copy 303 | of this software and associated documentation files (the "Software"), to deal 304 | in the Software without restriction, including without limitation the rights 305 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 306 | copies of the Software, and to permit persons to whom the Software is 307 | furnished to do so, subject to the following conditions: 308 | 309 | The above copyright notice and this permission notice shall be included in all 310 | copies or substantial portions of the Software. 311 | 312 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 313 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 314 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 315 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 316 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 317 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 318 | SOFTWARE. 319 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "default_stop_words": [ 3 | "defmodule", 4 | "defrecord", 5 | "defimpl", 6 | "defexception", 7 | "defprotocol", 8 | "defstruct", 9 | "def.+(.+\\\\.+).+do", 10 | "^\\s+use\\s+" 11 | ], 12 | 13 | "custom_stop_words": [ 14 | ], 15 | 16 | "coverage_options": { 17 | "treat_no_relevant_lines_as_covered": true, 18 | "output_dir": "cover/", 19 | "minimum_coverage": 90 20 | }, 21 | 22 | "skip_files": [ 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /examples/responders/great_success.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Responders.GreatSuccess do 2 | @moduledoc false 3 | 4 | use Hedwig.Responder 5 | 6 | @links [ 7 | "http://mjanja.co.ke/wordpress/wp-content/uploads/2013/09/borat_great_success.jpg", 8 | "http://s2.quickmeme.com/img/13/1324dfd733535e58dba70264e6d05c9b70346204d2cacef65abef9c702746d1c.jpg", 9 | "https://www.youtube.com/watch?v=r13riaRKGo0" 10 | ] 11 | 12 | @usage """ 13 | great success - Displays a random Borat image. 14 | """ 15 | hear ~r/great success(!)?/i, msg do 16 | reply msg, random(@links) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /examples/responders/ship_it.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Responders.ShipIt do 2 | @moduledoc false 3 | 4 | use Hedwig.Responder 5 | 6 | @squirrels [ 7 | "http://img.skitch.com/20100714-d6q52xajfh4cimxr3888yb77ru.jpg", 8 | "https://img.skitch.com/20111026-r2wsngtu4jftwxmsytdke6arwd.png", 9 | "http://cl.ly/1i0s1r3t2s2G3P1N3t3M/Screen_Shot_2011-10-27_at_9.36.45_AM.png", 10 | "http://shipitsquirrel.github.com/images/squirrel.png" 11 | ] 12 | 13 | @usage """ 14 | ship it - Display a motivation squirrel 15 | """ 16 | hear ~r/ship it/i, msg do 17 | send msg, random(@squirrels) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /hedwig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hedwig-im/hedwig/2f0a996675a9dd4f9a2ae410d6ce29ba435d2159/hedwig.png -------------------------------------------------------------------------------- /lib/hedwig.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig do 2 | @moduledoc """ 3 | Hedwig Application 4 | 5 | ## Starting a robot 6 | 7 | {:ok, pid} = Hedwig.start_robot(MyApp.Robot, name: "alfred") 8 | 9 | ## Stopping a robot 10 | 11 | Hedwig.stop_client(pid) 12 | """ 13 | 14 | use Application 15 | 16 | @doc false 17 | def start(_type, _args) do 18 | Hedwig.Supervisor.start_link() 19 | end 20 | 21 | @doc """ 22 | Starts a robot with the given configuration. 23 | """ 24 | def start_robot(robot, opts \\ []) do 25 | Supervisor.start_child(Hedwig.Robot.Supervisor, [robot, opts]) 26 | end 27 | 28 | @doc """ 29 | Stops a robot with the given PID. 30 | """ 31 | def stop_robot(pid) do 32 | Supervisor.terminate_child(Hedwig.Robot.Supervisor, pid) 33 | end 34 | 35 | @doc """ 36 | List all robots. 37 | """ 38 | def which_robots do 39 | Supervisor.which_children(Hedwig.Robot.Supervisor) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/hedwig/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Adapter do 2 | @moduledoc """ 3 | Hedwig Adapter Behaviour 4 | 5 | An adapter is the interface to the service your bot runs on. To implement an 6 | adapter you will need to translate messages from the service to the 7 | `Hedwig.Message` struct and call `Hedwig.Robot.handle_in(robot, msg)`. 8 | """ 9 | 10 | @doc false 11 | defmacro __using__(_opts) do 12 | quote do 13 | import Kernel, except: [send: 2] 14 | 15 | @behaviour Hedwig.Adapter 16 | use GenServer 17 | 18 | def send(pid, %Hedwig.Message{} = msg) do 19 | GenServer.cast(pid, {:send, msg}) 20 | end 21 | 22 | def reply(pid, %Hedwig.Message{} = msg) do 23 | GenServer.cast(pid, {:reply, msg}) 24 | end 25 | 26 | def emote(pid, %Hedwig.Message{} = msg) do 27 | GenServer.cast(pid, {:emote, msg}) 28 | end 29 | 30 | @doc false 31 | def start_link(robot, opts) do 32 | Hedwig.Adapter.start_link(__MODULE__, opts) 33 | end 34 | 35 | @doc false 36 | def stop(pid, timeout \\ 5000) do 37 | ref = Process.monitor(pid) 38 | Process.exit(pid, :normal) 39 | receive do 40 | {:DOWN, ^ref, _, _, _} -> :ok 41 | after 42 | timeout -> exit(:timeout) 43 | end 44 | :ok 45 | end 46 | 47 | @doc false 48 | defmacro __before_compile__(_env) do 49 | :ok 50 | end 51 | 52 | defoverridable [__before_compile__: 1, send: 2, reply: 2, emote: 2] 53 | end 54 | end 55 | 56 | @doc false 57 | def start_link(module, opts) do 58 | GenServer.start_link(module, {self(), opts}) 59 | end 60 | 61 | @type robot :: pid 62 | @type state :: term 63 | @type opts :: any 64 | @type msg :: Hedwig.Message.t 65 | 66 | @callback send(pid, msg) :: term 67 | @callback reply(pid, msg) :: term 68 | @callback emote(pid, msg) :: term 69 | end 70 | -------------------------------------------------------------------------------- /lib/hedwig/adapters/console.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Adapters.Console do 2 | @moduledoc """ 3 | Hedwig Console Adapter 4 | 5 | The console adapter is useful for testing out responders without a remote 6 | chat service. 7 | 8 | config :my_app, MyApp.Robot, 9 | adapter: Hedwig.Adapters.Console, 10 | ... 11 | 12 | Start your application with `mix run --no-halt` and you will have a console 13 | interface to your bot. 14 | """ 15 | use Hedwig.Adapter 16 | alias Hedwig.Adapters.Console.Connection 17 | 18 | @doc false 19 | def init({robot, opts}) do 20 | {:ok, conn} = Connection.start(opts) 21 | Kernel.send(self(), :connected) 22 | {:ok, %{conn: conn, opts: opts, robot: robot}} 23 | end 24 | 25 | @doc false 26 | def handle_cast({:send, msg}, %{conn: conn} = state) do 27 | Kernel.send(conn, {:reply, msg}) 28 | {:noreply, state} 29 | end 30 | 31 | @doc false 32 | def handle_cast({:reply, %{user: user, text: text} = msg}, %{conn: conn} = state) do 33 | Kernel.send(conn, {:reply, %{msg | text: "#{user}: #{text}"}}) 34 | {:noreply, state} 35 | end 36 | 37 | @doc false 38 | def handle_cast({:emote, msg}, %{conn: conn} = state) do 39 | Kernel.send(conn, {:reply, msg}) 40 | {:noreply, state} 41 | end 42 | 43 | @doc false 44 | def handle_info({:message, %{"text" => text, "user" => user}}, %{robot: robot} = state) do 45 | msg = %Hedwig.Message{ 46 | ref: make_ref(), 47 | robot: robot, 48 | text: text, 49 | type: "chat", 50 | user: user 51 | } 52 | 53 | Hedwig.Robot.handle_in(robot, msg) 54 | 55 | {:noreply, state} 56 | end 57 | 58 | def handle_info(:connected, %{robot: robot} = state) do 59 | :ok = Hedwig.Robot.handle_connect(robot) 60 | {:noreply, state} 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/hedwig/adapters/console/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Adapters.Console.Connection do 2 | @moduledoc false 3 | use GenServer 4 | 5 | alias Hedwig.Adapters.Console.{Connection, Reader, Writer} 6 | 7 | defstruct name: nil, owner: nil, reader: nil, user: nil, writer: nil 8 | 9 | def start(opts) do 10 | name = Keyword.get(opts, :name) 11 | user = Keyword.get(opts, :user, get_system_user()) 12 | 13 | GenServer.start(__MODULE__, {self(), name, user}) 14 | end 15 | 16 | def init({owner, name, user}) do 17 | GenServer.cast(self(), :after_init) 18 | {:ok, %Connection{name: name, owner: owner, user: user}} 19 | end 20 | 21 | def handle_cast(:after_init, %{name: name, user: user} = state) do 22 | {:ok, writer} = Writer.start_link(name) 23 | {:ok, reader} = Reader.start_link(user) 24 | {:noreply, %{state | reader: reader, writer: writer}} 25 | end 26 | 27 | def handle_info({:reply, text}, %{writer: writer} = state) do 28 | Writer.puts(writer, text) 29 | {:noreply, state} 30 | end 31 | 32 | @doc false 33 | def handle_info({:message, "clear"}, %{writer: writer} = state) do 34 | Writer.clear(writer) 35 | {:noreply, state} 36 | end 37 | 38 | def handle_info({:message, text}, %{owner: owner, user: user} = state) do 39 | Kernel.send(owner, {:message, %{"text" => text, "user" => user}}) 40 | {:noreply, state} 41 | end 42 | 43 | defp get_system_user do 44 | "whoami" 45 | |> System.cmd([]) 46 | |> elem(0) 47 | |> String.trim() 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/hedwig/adapters/console/reader.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Adapters.Console.Reader do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | def start_link(user) do 7 | GenServer.start_link(__MODULE__, {self(), user}) 8 | end 9 | 10 | def init({owner, user}) do 11 | GenServer.cast(self(), :get_io) 12 | {:ok, {owner, user}} 13 | end 14 | 15 | def handle_cast(:get_io, {owner, user}) do 16 | me = self() 17 | Task.start fn -> send(me, get_io(user)) end 18 | {:noreply, {owner, user}} 19 | end 20 | 21 | def handle_info(:eof, state) do 22 | {:stop, :normal, state} 23 | end 24 | 25 | def handle_info({:error, :terminated}, state) do 26 | {:stop, :normal, state} 27 | end 28 | 29 | def handle_info({ref, _msg}, state) when is_reference(ref) do 30 | {:noreply, state} 31 | end 32 | 33 | def handle_info(text, {owner, user}) when is_binary(text) do 34 | Kernel.send(owner, {:message, String.trim(text)}) 35 | Process.sleep(200) 36 | GenServer.cast(self(), :get_io) 37 | 38 | {:noreply, {owner, user}} 39 | end 40 | 41 | defp prompt(name) do 42 | [:white, :bright, name, "> ", :normal, :default_color] 43 | end 44 | 45 | defp get_io(name) do 46 | name 47 | |> prompt() 48 | |> IO.ANSI.format() 49 | |> IO.gets() 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/hedwig/adapters/console/writer.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Adapters.Console.Writer do 2 | @moduledoc false 3 | use GenServer 4 | 5 | def start_link(name) do 6 | GenServer.start_link(__MODULE__, {self(), name}) 7 | end 8 | 9 | def puts(pid, msg) do 10 | GenServer.cast(pid, {:puts, msg}) 11 | end 12 | 13 | def clear(pid) do 14 | GenServer.cast(pid, :clear) 15 | end 16 | 17 | def init({owner, name}) do 18 | GenServer.cast(self(), :after_init) 19 | {:ok, {owner, name}} 20 | end 21 | 22 | def handle_cast(:after_init, state) do 23 | clear_screen() 24 | display_banner() 25 | {:noreply, state} 26 | end 27 | 28 | def handle_cast(:clear, {owner, name}) do 29 | clear_screen() 30 | {:noreply, {owner, name}} 31 | end 32 | 33 | def handle_cast({:puts, msg}, {owner, name}) do 34 | handle_result(msg, name) 35 | {:noreply, {owner, name}} 36 | end 37 | 38 | defp print(message) do 39 | message 40 | |> IO.ANSI.format() 41 | |> IO.puts() 42 | end 43 | 44 | defp handle_result(msg, name) do 45 | print prompt(name) ++ [:normal, :default_color, msg.text] 46 | end 47 | 48 | defp prompt(name) do 49 | [:yellow, name, "> ", :default_color] 50 | end 51 | 52 | defp clear_screen do 53 | print [:clear, :home] 54 | end 55 | 56 | defp display_banner do 57 | print """ 58 | Hedwig Console - press Ctrl+C to exit. 59 | 60 | The console adapter is useful for quickly verifying how your 61 | bot will respond based on the current installed responders 62 | 63 | """ 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/hedwig/adapters/test.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Adapters.Test do 2 | @moduledoc false 3 | 4 | use Hedwig.Adapter 5 | 6 | def init({robot, opts}) do 7 | GenServer.cast(self(), :after_init) 8 | {:ok, %{conn: nil, opts: opts, robot: robot}} 9 | end 10 | 11 | def handle_cast(:after_init, %{robot: robot} = state) do 12 | Hedwig.Robot.handle_connect(robot) 13 | {:noreply, state} 14 | end 15 | 16 | def handle_cast({:send, msg}, %{conn: conn} = state) do 17 | Kernel.send(conn, {:message, msg}) 18 | {:noreply, state} 19 | end 20 | 21 | def handle_cast({:reply, %{text: text, user: user} = msg}, %{conn: conn} = state) do 22 | Kernel.send(conn, {:message, %{msg | text: "#{user}: #{text}"}}) 23 | {:noreply, state} 24 | end 25 | 26 | def handle_cast({:emote, %{text: text} = msg}, %{conn: conn} = state) do 27 | Kernel.send(conn, {:message, %{msg | text: "* #{text}"}}) 28 | {:noreply, state} 29 | end 30 | 31 | def handle_info({:message, msg}, %{robot: robot} = state) do 32 | msg = %Hedwig.Message{robot: robot, text: msg.text, user: msg.user} 33 | Hedwig.Robot.handle_in(robot, msg) 34 | {:noreply, state} 35 | end 36 | 37 | def handle_info(msg, %{robot: robot} = state) do 38 | Hedwig.Robot.handle_in(robot, msg) 39 | {:noreply, state} 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/hedwig/message.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Message do 2 | @moduledoc """ 3 | Hedwig Message 4 | """ 5 | 6 | @type matches :: list | map 7 | @type private :: map 8 | @type ref :: reference 9 | @type robot :: pid 10 | @type room :: binary 11 | @type text :: binary 12 | @type type :: binary 13 | @type user :: Hedwig.User.t 14 | 15 | @type t :: %__MODULE__{ 16 | matches: matches, 17 | private: private, 18 | ref: ref, 19 | robot: robot, 20 | room: room, 21 | text: text, 22 | type: type, 23 | user: user 24 | } 25 | 26 | defstruct matches: nil, 27 | private: %{}, 28 | ref: nil, 29 | robot: nil, 30 | room: nil, 31 | text: nil, 32 | type: nil, 33 | user: %Hedwig.User{} 34 | end 35 | -------------------------------------------------------------------------------- /lib/hedwig/responder.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Responder do 2 | @moduledoc ~S""" 3 | Base module for building responders. 4 | 5 | A responder is a module which setups up handlers for hearing and responding 6 | to incoming messages. 7 | 8 | ## Hearing & Responding 9 | 10 | Hedwig can hear messages said in a room or respond to messages directly 11 | addressed to it. Both methods take a regular expression, the message and a block 12 | to execute when there is a match. For example: 13 | 14 | hear ~r/(hi|hello)/i, msg do 15 | # your code here 16 | end 17 | 18 | respond ~r/help$/i, msg do 19 | # your code here 20 | end 21 | 22 | ## Using captures 23 | 24 | Responders support regular expression captures. It supports both normal 25 | captures and named captures. When a message matches, captures are handled 26 | automatically and added to the message's `:matches` key. 27 | 28 | Accessing the captures depends on the type of capture used in the responder's 29 | regex. If named captures are used, captures will be available by the name, 30 | otherwise it will be available by an index, starting with 0. 31 | 32 | 33 | ### Example: 34 | 35 | # with indexed captures 36 | hear ~r/i like (\w+), msg do 37 | emote msg, "likes #{msg.matches[1]} too!" 38 | end 39 | 40 | # with named captures 41 | hear ~r/i like (?\w+), msg do 42 | emote msg, "likes #{msg.matches["subject"]} too!" 43 | end 44 | """ 45 | 46 | defmacro __using__(_opts) do 47 | quote location: :keep do 48 | import unquote(__MODULE__) 49 | import Kernel, except: [send: 2] 50 | 51 | Module.register_attribute __MODULE__, :hear, accumulate: true 52 | Module.register_attribute __MODULE__, :respond, accumulate: true 53 | Module.register_attribute __MODULE__, :usage, accumulate: true 54 | 55 | @before_compile unquote(__MODULE__) 56 | end 57 | end 58 | 59 | def start_link(module, {aka, name, opts, robot}) do 60 | GenServer.start_link(module, {aka, name, opts, robot}) 61 | end 62 | 63 | @doc """ 64 | Sends a message via the underlying adapter. 65 | 66 | ## Example 67 | 68 | send msg, "Hello there!" 69 | """ 70 | def send(%Hedwig.Message{robot: robot} = msg, text) do 71 | Hedwig.Robot.send(robot, %{msg | text: text}) 72 | end 73 | 74 | @doc """ 75 | Send a reply message via the underlying adapter. 76 | 77 | ## Example 78 | 79 | reply msg, "Hello there!" 80 | """ 81 | def reply(%Hedwig.Message{robot: robot} = msg, text) do 82 | Hedwig.Robot.reply(robot, %{msg | text: text}) 83 | end 84 | 85 | @doc """ 86 | Send an emote message via the underlying adapter. 87 | 88 | ## Example 89 | 90 | emote msg, "goes and hides" 91 | """ 92 | def emote(%Hedwig.Message{robot: robot} = msg, text) do 93 | Hedwig.Robot.emote(robot, %{msg | text: text}) 94 | end 95 | 96 | @doc """ 97 | Returns a random item from a list or range. 98 | 99 | ## Example 100 | 101 | send msg, random(["apples", "bananas", "carrots"]) 102 | """ 103 | def random(list) do 104 | :rand.seed(:exsplus, :os.timestamp) 105 | Enum.random(list) 106 | end 107 | 108 | @doc false 109 | def dispatch(msg, responders) do 110 | Enum.map(responders, fn {_, pid, _, _} -> 111 | GenServer.cast(pid, {:dispatch, msg}) 112 | end) 113 | end 114 | 115 | @doc """ 116 | Matches messages based on the regular expression. 117 | 118 | ## Example 119 | 120 | hear ~r/hello/, msg do 121 | # code to handle the message 122 | end 123 | """ 124 | defmacro hear(regex, msg, state \\ Macro.escape(%{}), do: block) do 125 | name = unique_name(:hear) 126 | quote do 127 | @hear {unquote(regex), unquote(name)} 128 | @doc false 129 | def unquote(name)(unquote(msg), unquote(state)) do 130 | unquote(block) 131 | end 132 | end 133 | end 134 | 135 | @doc """ 136 | Setups up an responder that will match when a message is prefixed with the bot's name. 137 | 138 | ## Example 139 | 140 | # Give our bot's name is "alfred", this responder 141 | # would match for a message with the following text: 142 | # "alfred hello" 143 | respond ~r/hello/, msg do 144 | # code to handle the message 145 | end 146 | """ 147 | defmacro respond(regex, msg, state \\ Macro.escape(%{}), do: block) do 148 | name = unique_name(:respond) 149 | quote do 150 | @respond {unquote(regex), unquote(name)} 151 | @doc false 152 | def unquote(name)(unquote(msg), unquote(state)) do 153 | unquote(block) 154 | end 155 | end 156 | end 157 | 158 | defp unique_name(type) do 159 | String.to_atom("#{type}_#{System.unique_integer([:positive, :monotonic])}") 160 | end 161 | 162 | @doc false 163 | def respond_pattern(pattern, name, aka) do 164 | pattern 165 | |> Regex.source 166 | |> rewrite_source(name, aka) 167 | |> Regex.compile!(Regex.opts(pattern)) 168 | end 169 | 170 | defp rewrite_source(source, name, nil) do 171 | "^\\s*[@]?#{name}[:,]?\\s*(?:#{source})" 172 | end 173 | defp rewrite_source(source, name, aka) do 174 | [a, b] = if String.length(name) > String.length(aka), do: [name, aka], else: [aka, name] 175 | "^\\s*[@]?(?:#{a}[:,]?|#{b}[:,]?)\\s*(?:#{source})" 176 | end 177 | 178 | defmacro __before_compile__(_env) do 179 | quote location: :keep do 180 | @doc false 181 | def usage(name) do 182 | import String 183 | Enum.map(@usage, &(&1 |> trim |> replace("hedwig", name))) 184 | end 185 | 186 | def init({aka, name, opts, robot}) do 187 | :ok = GenServer.cast(self(), :compile_responders) 188 | 189 | {:ok, %{ 190 | aka: aka, 191 | name: name, 192 | opts: opts, 193 | responders: [], 194 | robot: robot}} 195 | end 196 | 197 | def handle_cast(:compile_responders, %{aka: aka, name: name} = state) do 198 | {:noreply, %{state | responders: compile_responders(name, aka)}} 199 | end 200 | 201 | def handle_cast({:dispatch, msg}, state) do 202 | {:noreply, dispatch_responders(msg, state)} 203 | end 204 | 205 | defp dispatch_responders(msg, %{responders: responders} = state) do 206 | Enum.reduce responders, state, fn responder, new_state -> 207 | case dispatch_responder(responder, msg, new_state) do 208 | :ok -> 209 | new_state 210 | {:ok, new_state} -> 211 | new_state 212 | end 213 | end 214 | end 215 | 216 | defp dispatch_responder({regex, fun}, %{text: text} = msg, state) do 217 | if Regex.match?(regex, text) do 218 | msg = %{msg | matches: find_matches(regex, text)} 219 | apply(__MODULE__, fun, [msg, state]) 220 | else 221 | :ok 222 | end 223 | end 224 | 225 | defp find_matches(regex, text) do 226 | case Regex.names(regex) do 227 | [] -> 228 | matches = Regex.run(regex, text) 229 | Enum.reduce(Enum.with_index(matches), %{}, fn {match, index}, acc -> 230 | Map.put(acc, index, match) 231 | end) 232 | _ -> 233 | Regex.named_captures(regex, text) 234 | end 235 | end 236 | 237 | defp compile_responders(name, aka) do 238 | responders = for {regex, fun} <- @respond do 239 | regex = Hedwig.Responder.respond_pattern(regex, name, aka) 240 | {regex, fun} 241 | end 242 | 243 | List.flatten([@hear, responders]) 244 | end 245 | end 246 | end 247 | end 248 | -------------------------------------------------------------------------------- /lib/hedwig/responder/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Responder.Supervisor do 2 | @moduledoc false 3 | 4 | def start_link do 5 | import Supervisor.Spec, warn: false 6 | 7 | children = [ 8 | worker(Hedwig.Responder, [], restart: :transient) 9 | ] 10 | 11 | Supervisor.start_link(children, strategy: :simple_one_for_one) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/hedwig/responders/help.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Responders.Help do 2 | @moduledoc """ 3 | The Help Responder. 4 | 5 | This responder is responsible for displaying the usage for all installed 6 | responders. 7 | 8 | ## Installation 9 | 10 | Add this responder to your bot's list of responders: 11 | 12 | responders: [ 13 | {Hedwig.Responders.Help, []} 14 | ] 15 | 16 | ## Usage 17 | 18 | You can invoke this responder by mentioning your bot's name followed by 'help' 19 | (hedwig help). Your bot will reply back with a list of usage text for each 20 | responder installed. 21 | 22 | ## Searching help 23 | 24 | If you include a search term, your bot will only respond with help that matches 25 | (hedwig help ). 26 | """ 27 | 28 | use Hedwig.Responder 29 | 30 | @usage """ 31 | hedwig help - Displays all of the help commands that hedwig knows about. 32 | """ 33 | respond ~r/help$/, msg, state do 34 | send msg, display_usage(state) 35 | end 36 | 37 | @usage """ 38 | hedwig help - Displays all help commands that match . 39 | """ 40 | respond ~r/help (?.*)/, msg, state do 41 | send msg, search(state, msg.matches["query"]) 42 | end 43 | 44 | defp display_usage(state) do 45 | state 46 | |> all_usage() 47 | |> Enum.reverse() 48 | |> Enum.map_join("\n", &(&1)) 49 | end 50 | 51 | defp search(state, query) do 52 | state 53 | |> all_usage() 54 | |> Enum.reverse() 55 | |> Enum.filter(&(String.match?(&1, ~r/(#{query})/i))) 56 | |> Enum.map_join("\n", &(&1)) 57 | end 58 | 59 | defp all_usage(%{name: name, robot: robot}) do 60 | responders = Hedwig.Robot.responders(robot) 61 | Enum.reduce responders, [], fn {mod, _opts}, acc -> 62 | mod.usage(name) ++ acc 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/hedwig/responders/ping.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Responders.Ping do 2 | @moduledoc """ 3 | Responds to 'ping' with 'pong' 4 | """ 5 | 6 | use Hedwig.Responder 7 | 8 | @usage """ 9 | hedwig: ping - Responds with 'pong' 10 | """ 11 | respond ~r/ping$/i, msg do 12 | reply msg, "pong" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/hedwig/robot.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Robot do 2 | @moduledoc """ 3 | Defines a robot. 4 | 5 | Robots receive messages from a chat source (XMPP, Slack, Console, etc), and 6 | dispatch them to matching responders. See the documentation for 7 | `Hedwig.Responder` for details on responders. 8 | 9 | When used, the robot expects the `:otp_app` as option. The `:otp_app` should 10 | point to an OTP application that has the robot configuration. For example, 11 | the robot: 12 | 13 | defmodule MyApp.Robot do 14 | use Hedwig.Robot, otp_app: :my_app 15 | end 16 | 17 | Could be configured with: 18 | 19 | config :my_app, MyApp.Robot, 20 | adapter: Hedwig.Adapters.Console, 21 | name: "alfred" 22 | 23 | Most of the configuration that goes into the `config` is specific to the 24 | adapter. Be sure to check the documentation for the adapter in use for all 25 | of the available options. 26 | 27 | ## Robot configuration 28 | 29 | * `adapter` - the adapter module name. 30 | * `name` - the name the robot will respond to. 31 | * `aka` - an alias the robot will respond to. 32 | * `log_level` - the level to use when logging output. 33 | * `responders` - a list of responders specified in the following format: 34 | `{module, kwlist}`. 35 | """ 36 | 37 | defstruct adapter: nil, 38 | aka: nil, 39 | name: "", 40 | opts: [], 41 | responder_sup: nil, 42 | responders: [] 43 | 44 | defmacro __using__(opts) do 45 | quote location: :keep, bind_quoted: [opts: opts] do 46 | use GenServer 47 | require Logger 48 | 49 | {otp_app, adapter, robot_config} = 50 | Hedwig.Robot.Supervisor.parse_config(__MODULE__, opts) 51 | 52 | @adapter adapter 53 | @before_compile adapter 54 | @config robot_config 55 | @log_level robot_config[:log_level] || :debug 56 | @otp_app otp_app 57 | 58 | def start_link(opts \\ []) do 59 | Hedwig.start_robot(__MODULE__, opts) 60 | end 61 | 62 | def stop(robot) do 63 | Hedwig.stop_robot(robot) 64 | end 65 | 66 | def config(opts \\ []) do 67 | Hedwig.Robot.Supervisor.config(__MODULE__, @otp_app, opts) 68 | end 69 | 70 | def log(msg) do 71 | Logger.unquote(@log_level)(fn -> 72 | msg 73 | end, []) 74 | end 75 | 76 | def __adapter__, do: @adapter 77 | 78 | def init({robot, opts}) do 79 | opts = robot.config(opts) 80 | aka = Keyword.get(opts, :aka) 81 | name = Keyword.get(opts, :name) 82 | {responders, opts} = Keyword.pop(opts, :responders, []) 83 | 84 | unless responders == [] do 85 | GenServer.cast(self(), {:install_responders, responders}) 86 | end 87 | 88 | {:ok, adapter} = @adapter.start_link(robot, opts) 89 | {:ok, responder_sup} = Hedwig.Responder.Supervisor.start_link() 90 | 91 | {:ok, %Hedwig.Robot{ 92 | adapter: adapter, 93 | aka: aka, 94 | name: name, 95 | opts: opts, 96 | responder_sup: responder_sup, 97 | responders: responders}} 98 | end 99 | 100 | def handle_connect(state) do 101 | {:ok, state} 102 | end 103 | 104 | def handle_disconnect(_reason, state) do 105 | {:reconnect, state} 106 | end 107 | 108 | def handle_in(%Hedwig.Message{} = msg, state) do 109 | {:dispatch, msg, state} 110 | end 111 | def handle_in(_msg, state) do 112 | {:noreply, state} 113 | end 114 | 115 | def handle_call(:name, _from, %{name: name} = state) do 116 | {:reply, name, state} 117 | end 118 | 119 | def handle_call(:responders, _from, %{responders: responders} = state) do 120 | {:reply, responders, state} 121 | end 122 | 123 | def handle_call(:handle_connect, _from, state) do 124 | case handle_connect(state) do 125 | {:ok, state} -> 126 | {:reply, :ok, state} 127 | {:stop, reason, state} -> 128 | {:stop, reason, state} 129 | end 130 | end 131 | 132 | def handle_call({:handle_disconnect, reason}, _from, state) do 133 | case handle_disconnect(reason, state) do 134 | {:reconnect, state} -> 135 | {:reply, :reconnect, state} 136 | {:reconnect, timer, state} -> 137 | {:reply, {:reconnect, timer}, state} 138 | {:disconnect, reason, state} -> 139 | {:stop, reason, {:disconnect, reason}, state} 140 | end 141 | end 142 | 143 | def handle_cast({:send, msg}, %{adapter: adapter} = state) do 144 | @adapter.send(adapter, msg) 145 | {:noreply, state} 146 | end 147 | 148 | def handle_cast({:reply, msg}, %{adapter: adapter} = state) do 149 | @adapter.reply(adapter, msg) 150 | {:noreply, state} 151 | end 152 | 153 | def handle_cast({:emote, msg}, %{adapter: adapter} = state) do 154 | @adapter.emote(adapter, msg) 155 | {:noreply, state} 156 | end 157 | 158 | def handle_cast({:handle_in, msg}, %{responder_sup: sup} = state) do 159 | case handle_in(msg, state) do 160 | {:dispatch, %Hedwig.Message{} = msg, state} -> 161 | responders = Supervisor.which_children(sup) 162 | Hedwig.Responder.dispatch(msg, responders) 163 | {:noreply, state} 164 | 165 | {:dispatch, _msg, state} -> 166 | log_incorrect_return(:dispatch) 167 | {:noreply, state} 168 | 169 | {fun, {%Hedwig.Message{} = msg, text}, state} when fun in [:send, :reply, :emote] -> 170 | apply(Hedwig.Responder, fun, [msg, text]) 171 | {:noreply, state} 172 | 173 | {fun, {_msg, _text}, state} when fun in [:send, :reply, :emote] -> 174 | log_incorrect_return(fun) 175 | {:noreply, state} 176 | 177 | {:noreply, state} -> 178 | {:noreply, state} 179 | end 180 | end 181 | 182 | def handle_cast({:install_responders, responders}, %{aka: aka, name: name} = state) do 183 | for {module, opts} <- responders do 184 | args = [module, {aka, name, opts, self()}] 185 | Supervisor.start_child(state.responder_sup, args) 186 | end 187 | {:noreply, state} 188 | end 189 | 190 | def handle_info(msg, state) do 191 | {:noreply, state} 192 | end 193 | 194 | def terminate(_reason, _state) do 195 | :ok 196 | end 197 | 198 | def code_change(_old, state, _extra) do 199 | {:ok, state} 200 | end 201 | 202 | defp log_incorrect_return(atom) do 203 | Logger.warn """ 204 | #{inspect atom} return value from `handle_in/2` only works with `%Hedwig.Message{}` structs. 205 | """ 206 | end 207 | 208 | defoverridable [ 209 | {:handle_connect, 1}, 210 | {:handle_disconnect, 2}, 211 | {:handle_in, 2}, 212 | {:terminate, 2}, 213 | {:code_change, 3}, 214 | {:handle_info, 2} 215 | ] 216 | end 217 | end 218 | 219 | @doc false 220 | def start_link(robot, opts) do 221 | GenServer.start_link(robot, {robot, opts}) 222 | end 223 | 224 | @doc """ 225 | Send a message via the robot. 226 | """ 227 | def send(pid, msg) do 228 | GenServer.cast(pid, {:send, msg}) 229 | end 230 | 231 | @doc """ 232 | Send a reply message via the robot. 233 | """ 234 | def reply(pid, msg) do 235 | GenServer.cast(pid, {:reply, msg}) 236 | end 237 | 238 | @doc """ 239 | Send an emote message via the robot. 240 | """ 241 | def emote(pid, msg) do 242 | GenServer.cast(pid, {:emote, msg}) 243 | end 244 | 245 | @doc """ 246 | Get the name of the robot. 247 | """ 248 | def name(pid) do 249 | GenServer.call(pid, :name) 250 | end 251 | 252 | @doc """ 253 | Get the list of the robot's responders. 254 | """ 255 | def responders(pid) do 256 | GenServer.call(pid, :responders) 257 | end 258 | 259 | @doc """ 260 | Invokes a user defined `handle_in/2` function, if defined. 261 | 262 | This function should be called by an adapter when a message arrives but 263 | should be handled by the user module. 264 | 265 | Returning `{:dispatch, msg, state}` will dispatch the message 266 | to all installed responders. 267 | 268 | Returning `{:send, {msg, text}, state}`, `{:reply, {msg, text}, state}`, 269 | or `{:emote, {msg, text}, state}` will send the message directly to the 270 | adapter without dispatching to any responders. 271 | 272 | Returning `{:noreply, state}` will ignore the message. 273 | """ 274 | @spec handle_in(pid, any) :: :ok 275 | def handle_in(robot, msg) do 276 | GenServer.cast(robot, {:handle_in, msg}) 277 | end 278 | 279 | @doc """ 280 | Invokes a user defined `handle_connect/1` function, if defined. 281 | 282 | If the user has defined an `handle_connect/1` in the robot module, it will be 283 | called with the robot's state. It is expected that the function return 284 | `{:ok, state}` or `{:stop, reason, state}`. 285 | """ 286 | @spec handle_connect(pid, integer) :: :ok 287 | def handle_connect(robot, timeout \\ 5000) do 288 | GenServer.call(robot, :handle_connect, timeout) 289 | end 290 | 291 | @doc """ 292 | Invokes a user defined `handle_disconnect/1` function, if defined. 293 | 294 | If the user has defined an `handle_disconnect/1` in the robot module, it will be 295 | called with the robot's state. It is expected that the function return 296 | `{:reconnect, state}` `{:reconnect, integer, state}`, or `{:disconnect, reason, state}`. 297 | """ 298 | @spec handle_disconnect(pid, any, integer) :: :reconnect | {:reconnect, integer} | {:disconnect, any} 299 | def handle_disconnect(robot, reason, timeout \\ 5000) do 300 | GenServer.call(robot, {:handle_disconnect, reason}, timeout) 301 | end 302 | end 303 | -------------------------------------------------------------------------------- /lib/hedwig/robot/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Robot.Supervisor do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | 6 | def start_link(opts \\ []) do 7 | Supervisor.start_link(__MODULE__, [], opts) 8 | end 9 | 10 | def config(robot, otp_app, opts) do 11 | if robot_config = Application.get_env(otp_app, robot) do 12 | robot_config 13 | |> Keyword.put(:otp_app, otp_app) 14 | |> Keyword.put(:robot, robot) 15 | |> Keyword.merge(opts) 16 | else 17 | raise ArgumentError, 18 | "configuration for #{inspect robot} not specified in #{inspect otp_app} environment" 19 | end 20 | end 21 | 22 | def parse_config(robot, opts) do 23 | otp_app = Keyword.fetch!(opts, :otp_app) 24 | robot_config = Application.get_env(otp_app, robot, []) 25 | adapter = opts[:adapter] || robot_config[:adapter] 26 | 27 | unless adapter do 28 | raise ArgumentError, "missing `:adapter` configuration for " <> 29 | "#{inspect otp_app}, #{inspect robot}" 30 | end 31 | 32 | unless Code.ensure_loaded?(adapter) do 33 | raise ArgumentError, "adapter #{inspect adapter} was not compiled, " <> 34 | "ensure it is correct and it is included as a " <> 35 | "project dependency." 36 | end 37 | 38 | {otp_app, adapter, robot_config} 39 | end 40 | 41 | def init(_) do 42 | children = [ 43 | worker(Hedwig.Robot, [], restart: :transient) 44 | ] 45 | 46 | supervise(children, strategy: :simple_one_for_one) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/hedwig/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Supervisor do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | 6 | def start_link do 7 | Supervisor.start_link(__MODULE__, :ok, name: Hedwig.Supervisor) 8 | end 9 | 10 | def init(:ok) do 11 | children = [ 12 | supervisor(Hedwig.Robot.Supervisor, [[name: Hedwig.Robot.Supervisor]]), 13 | ] 14 | 15 | supervise(children, strategy: :one_for_one) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/hedwig/test/robot_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.RobotCase do 2 | use ExUnit.CaseTemplate 3 | 4 | @moduledoc false 5 | @robot Hedwig.TestRobot 6 | @default_responders [{Hedwig.Responders.Help, []}, {TestResponder, []}] 7 | 8 | using do 9 | quote do 10 | import unquote(__MODULE__) 11 | @robot Hedwig.TestRobot 12 | end 13 | end 14 | 15 | setup tags do 16 | if tags[:start_robot] do 17 | robot = Map.get(tags, :robot, @robot) 18 | name = Map.get(tags, :name, "hedwig") 19 | responders = Map.get(tags, :responders, @default_responders) 20 | 21 | config = [name: name, aka: "/", responders: responders] 22 | 23 | Application.put_env(:hedwig, robot, config) 24 | {:ok, pid} = Hedwig.start_robot(robot, config) 25 | adapter = update_robot_adapter(pid) 26 | 27 | on_exit fn -> Hedwig.stop_robot(pid) end 28 | 29 | msg = %Hedwig.Message{robot: pid, text: "", user: "testuser"} 30 | 31 | {:ok, %{robot: pid, adapter: adapter, msg: msg}} 32 | else 33 | {:ok, tags} 34 | end 35 | end 36 | 37 | def update_robot_adapter(robot) do 38 | test_process = self() 39 | adapter = :sys.get_state(robot).adapter 40 | :sys.replace_state(adapter, fn state -> %{state | conn: test_process} end) 41 | 42 | adapter 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/hedwig/test/test_robot.ex: -------------------------------------------------------------------------------- 1 | Code.ensure_compiled(Hedwig.Adapters.Test) 2 | 3 | defmodule Hedwig.TestRobot do 4 | use Hedwig.Robot, otp_app: :hedwig, adapter: Hedwig.Adapters.Test 5 | 6 | @moduledoc false 7 | 8 | def handle_connect(%{name: name} = state) do 9 | if :undefined == :global.whereis_name(name) do 10 | :yes = :global.register_name(name, self()) 11 | end 12 | 13 | {:ok, state} 14 | end 15 | 16 | def handle_disconnect(:error, state), 17 | do: {:disconnect, :normal, state} 18 | def handle_disconnect(:reconnect, state), 19 | do: {:reconnect, state} 20 | def handle_disconnect({:reconnect, timer}, state), 21 | do: {:reconnect, timer, state} 22 | 23 | def handle_in(%Hedwig.Message{} = msg, state) do 24 | {:dispatch, msg, state} 25 | end 26 | 27 | def handle_in({:ping, from}, state) do 28 | Kernel.send(from, :pong) 29 | {:noreply, state} 30 | end 31 | 32 | def handle_in(msg, state) do 33 | super(msg, state) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/hedwig/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.User do 2 | @moduledoc ~S""" 3 | Module defining a `User` struct for `Hedwig.Message`. 4 | """ 5 | 6 | @type t :: %__MODULE__{ 7 | id: binary, 8 | name: binary 9 | } 10 | 11 | defstruct id: nil, name: nil 12 | end 13 | -------------------------------------------------------------------------------- /lib/mix/tasks/hedwig.gen.robot.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Hedwig.Gen.Robot do 2 | use Mix.Task 3 | 4 | import Mix.Generator 5 | 6 | @shortdoc "Generate a new robot" 7 | 8 | @moduledoc """ 9 | Generates a new robot. 10 | 11 | The robot will be placed in the `lib` directory. 12 | 13 | ## Examples 14 | 15 | mix hedwig.gen.robot 16 | mix hedwig.gen.robot --name alfred --robot Custom.Module 17 | 18 | ## Command line options 19 | 20 | * `--name` - the name your robot will respond to 21 | * `--aka` - an alias your robot will respond to 22 | * `--robot` - the robot to generate (defaults to `YourApp.Robot`) 23 | 24 | """ 25 | @switches [aka: :string, name: :string, robot: :string] 26 | 27 | @doc false 28 | def run(argv) do 29 | 30 | if Mix.Project.umbrella? do 31 | Mix.raise "cannot run task hedwig.gen.robot from umbrella application" 32 | end 33 | 34 | config = Mix.Project.config 35 | 36 | {opts, _argv, _} = OptionParser.parse(argv, switches: @switches) 37 | 38 | app = config[:app] 39 | deps = config[:deps] 40 | 41 | Mix.shell.info [:clear, :home, """ 42 | Welcome to the Hedwig Robot Generator! 43 | 44 | Let's get started. 45 | """] 46 | 47 | aka = opts[:aka] || "/" 48 | name = opts[:name] || prompt_for_name() 49 | robot = opts[:robot] || default_robot(app) 50 | adapter = get_adapter_module(deps) 51 | 52 | underscored = Macro.underscore(robot) 53 | file = Path.join("lib", underscored) <> ".ex" 54 | 55 | robot = Module.concat([robot]) 56 | 57 | opts = [adapter: adapter, aka: aka, app: app, name: name, robot: robot] 58 | 59 | create_directory Path.dirname(file) 60 | create_file file, robot_template(opts) 61 | 62 | case File.read "config/config.exs" do 63 | {:ok, contents} -> 64 | Mix.shell.info [:green, "* updating ", :reset, "config/config.exs"] 65 | File.write! "config/config.exs", 66 | String.replace(contents, "use Mix.Config", config_template(opts)) 67 | {:error, _} -> 68 | create_file "config/config.exs", config_template(opts) 69 | end 70 | 71 | Mix.shell.info """ 72 | 73 | Don't forget to add your new robot to your supervision tree 74 | (typically in lib/#{app}.ex): 75 | 76 | worker(#{inspect robot}, []) 77 | """ 78 | end 79 | 80 | defp default_robot(app) do 81 | app 82 | |> alias_module 83 | |> Module.concat(Robot) 84 | end 85 | 86 | defp alias_module(app) do 87 | case Application.get_env(app, :app_namespace, app) do 88 | ^app -> app |> to_string |> Macro.camelize 89 | mod -> mod |> inspect 90 | end 91 | end 92 | 93 | defp available_adapters(deps) do 94 | deps 95 | |> all_modules 96 | |> Kernel.++(hedwig_modules()) 97 | |> Enum.uniq 98 | |> Enum.filter(&implements_adapter?/1) 99 | |> Enum.with_index 100 | |> Enum.reduce(%{}, fn {adapter, index}, acc -> 101 | Map.put(acc, index + 1, adapter) 102 | end) 103 | end 104 | 105 | defp all_modules(deps) do 106 | Enum.reduce(deps, [], &load_and_get_modules/2) 107 | end 108 | 109 | defp load_and_get_modules({app, _}, acc) do 110 | load_and_get_modules(app, acc) 111 | end 112 | defp load_and_get_modules({app, _, _}, acc) do 113 | load_and_get_modules(app, acc) 114 | end 115 | defp load_and_get_modules(app, acc) do 116 | Application.load(app) 117 | case :application.get_key(app, :modules) do 118 | {:ok, modules} -> 119 | modules ++ acc 120 | _ -> 121 | acc 122 | end 123 | end 124 | 125 | defp hedwig_modules do 126 | Application.load(:hedwig) 127 | {:ok, modules} = :application.get_key(:hedwig, :modules) 128 | modules 129 | end 130 | 131 | defp implements_adapter?(module) do 132 | case get_in(module.module_info(), [:attributes, :behaviour]) do 133 | nil -> false 134 | mods -> Hedwig.Adapter in mods 135 | end 136 | end 137 | 138 | defp get_adapter_module(deps) do 139 | adapters = available_adapters(deps) 140 | {selection, _} = adapters |> prompt_for_adapter |> Integer.parse 141 | adapters[selection] 142 | end 143 | 144 | defp prompt_for_name do 145 | "What would you like to name your bot?:" 146 | |> Mix.shell.prompt 147 | |> String.trim 148 | end 149 | 150 | defp prompt_for_adapter(adapters) do 151 | adapters = Enum.map(adapters, &format_adapter/1) 152 | Mix.shell.info ["Available adapters\n\n", adapters] 153 | Mix.shell.prompt("Please select an adapter:") 154 | end 155 | 156 | defp format_adapter({index, mod}) do 157 | [inspect(index), ". ", :bright, :blue, 158 | inspect(mod), :normal, :default_color, "\n"] 159 | end 160 | 161 | embed_template :robot, """ 162 | defmodule <%= inspect @robot %> do 163 | use Hedwig.Robot, otp_app: <%= inspect @app %> 164 | 165 | def handle_connect(%{name: name} = state) do 166 | if :undefined == :global.whereis_name(name) do 167 | :yes = :global.register_name(name, self()) 168 | end 169 | 170 | {:ok, state} 171 | end 172 | 173 | def handle_disconnect(_reason, state) do 174 | {:reconnect, 5000, state} 175 | end 176 | 177 | def handle_in(%Hedwig.Message{} = msg, state) do 178 | {:dispatch, msg, state} 179 | end 180 | 181 | def handle_in(_msg, state) do 182 | {:noreply, state} 183 | end 184 | end 185 | """ 186 | 187 | embed_template :config, """ 188 | use Mix.Config 189 | 190 | config <%= inspect @app %>, <%= inspect @robot %>, 191 | adapter: <%= inspect @adapter %>, 192 | name: <%= inspect @name %>, 193 | aka: <%= inspect @aka %>, 194 | responders: [ 195 | {Hedwig.Responders.Help, []}, 196 | {Hedwig.Responders.Ping, []} 197 | ] 198 | """ 199 | end 200 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Mixfile do 2 | use Mix.Project 3 | 4 | @version "1.0.1" 5 | 6 | def project do 7 | [app: :hedwig, 8 | version: @version, 9 | elixir: "~> 1.2", 10 | docs: docs(), 11 | deps: deps(), 12 | package: package(), 13 | name: "Hedwig", 14 | elixirc_paths: elixirc_paths(Mix.env), 15 | description: "An adapter-based chat bot framework", 16 | source_url: "https://github.com/hedwig-im/hedwig", 17 | homepage_url: "https://github.com/hedwig-im/hedwig", 18 | test_coverage: [tool: ExCoveralls], 19 | preferred_cli_env: [ 20 | "coveralls": :test, 21 | "coveralls.html": :test, 22 | "coveralls.detail": :test, 23 | "coveralls.post": :test]] 24 | end 25 | 26 | def application do 27 | [applications: [:logger], 28 | mod: {Hedwig, []}] 29 | end 30 | 31 | defp docs do 32 | [extras: docs_extras(), 33 | main: "readme"] 34 | end 35 | 36 | defp docs_extras do 37 | ["README.md"] 38 | end 39 | 40 | defp deps do 41 | [{:excoveralls, "~> 0.7.2", only: :test}, 42 | {:ex_doc, "~> 0.16.3", only: :dev}, 43 | {:credo, "~> 0.8", only: [:dev, :test], runtime: false} 44 | ] 45 | end 46 | 47 | defp elixirc_paths(:test), do: ["lib", "test/support"] 48 | defp elixirc_paths(_), do: ["lib"] 49 | 50 | defp package do 51 | [files: ["lib", "mix.exs", "README*", "LICENSE*"], 52 | maintainers: ["Sonny Scroggin"], 53 | licenses: ["MIT"], 54 | links: %{ 55 | "GitHub" => "https://github.com/hedwig-im/hedwig", 56 | "Docs" => "https://hexdocs.pm/hedwig" 57 | }] 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [], [], "hexpm"}, 2 | "certifi": {:hex, :certifi, "2.0.0", "a0c0e475107135f76b8c1d5bc7efb33cd3815cb3cf3dea7aefdd174dabead064", [:rebar3], [], "hexpm"}, 3 | "credo": {:hex, :credo, "0.8.10", "261862bb7363247762e1063713bb85df2bbd84af8d8610d1272cd9c1943bba63", [], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [:mix], [], "hexpm"}, 5 | "ex_doc": {:hex, :ex_doc, "0.16.3", "cd2a4cfe5d26e37502d3ec776702c72efa1adfa24ed9ce723bb565f4c30bd31a", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "excoveralls": {:hex, :excoveralls, "0.7.2", "f69ede8c122ccd3b60afc775348a53fc8c39fe4278aee2f538f0d81cc5e7ff3a", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "hackney": {:hex, :hackney, "1.9.0", "51c506afc0a365868469dcfc79a9d0b94d896ec741cfd5bd338f49a5ec515bfe", [:rebar3], [{: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"}, 9 | "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "jsx": {:hex, :jsx, "2.8.2", "7acc7d785b5abe8a6e9adbde926a24e481f29956dd8b4df49e3e4e7bcc92a018", [:mix, :rebar3], [], "hexpm"}, 11 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [], []}, 12 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [], []}, 13 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], []}, 14 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [], [], "hexpm"}} 15 | -------------------------------------------------------------------------------- /test/hedwig/adapters/console/writer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Adapters.Console.WriterTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureIO 5 | alias Hedwig.Adapters.Console.Writer 6 | 7 | test "console connection prints a banner" do 8 | output = capture_and_normalize_io fn -> 9 | Writer.start_link("hedwig") 10 | Process.sleep(10) 11 | end 12 | 13 | assert output =~ "Hedwig Console - press Ctrl+C to exit." 14 | assert output =~ "The console adapter is useful for quickly verifying how your" 15 | assert output =~ "bot will respond based on the current installed responders" 16 | end 17 | 18 | test "puts/2" do 19 | output = capture_and_normalize_io fn -> 20 | {:ok, pid} = Writer.start_link("hedwig") 21 | 22 | msg = %Hedwig.Message{text: "hello"} 23 | Writer.puts(pid, msg) 24 | Process.sleep(10) 25 | end 26 | 27 | assert output =~ "hedwig> hello" 28 | end 29 | 30 | test "clear/1" do 31 | output = capture_io fn -> 32 | {:ok, pid} = Writer.start_link("hedwig") 33 | 34 | Writer.clear(pid) 35 | Process.sleep(10) 36 | end 37 | 38 | assert output =~ "\e[2J\e[H\e[0m" 39 | end 40 | 41 | defp capture_and_normalize_io(fun) do 42 | fun |> capture_io() |> strip_ansi() 43 | end 44 | 45 | defp strip_ansi(string), do: Regex.replace(~r/\e\[[^m]+m/, string, "") 46 | end 47 | -------------------------------------------------------------------------------- /test/hedwig/adapters/console_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Adapters.ConsoleTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureIO 5 | alias Hedwig.Adapters.Console 6 | 7 | test "console handles messages from the connection" do 8 | capture_io fn -> 9 | {:ok, adapter} = Hedwig.Adapter.start_link(Console, name: "hedwig", user: "testuser") 10 | 11 | handle_connect() 12 | # Simulate an incoming message from the connection process 13 | msg = {:message, %{"text" => "ping", "user" => "testuser"}} 14 | send(adapter, msg) 15 | assert_receive {:"$gen_cast", {:handle_in, %Hedwig.Message{text: "ping", user: "testuser"}}} 16 | end 17 | end 18 | 19 | describe "sending messages to the connection process" do 20 | test "send/2" do 21 | capture_io fn -> 22 | {:ok, adapter} = Hedwig.Adapter.start_link(Console, name: "hedwig", user: "testuser") 23 | 24 | handle_connect() 25 | # replace the adapter's connection pid to the test process 26 | replace_connection_pid(adapter) 27 | 28 | msg = %Hedwig.Message{text: "pong", user: "testuser"} 29 | Console.send(adapter, msg) 30 | 31 | assert_receive {:reply, ^msg} 32 | end 33 | end 34 | 35 | test "reply/2 includes the reply user's name" do 36 | capture_io fn -> 37 | {:ok, adapter} = Hedwig.Adapter.start_link(Console, name: "hedwig", user: "testuser") 38 | 39 | handle_connect() 40 | # replace the adapter's connection pid to the test process 41 | replace_connection_pid(adapter) 42 | 43 | msg = %Hedwig.Message{text: "pong", user: "testuser"} 44 | Console.reply(adapter, msg) 45 | 46 | assert_receive {:reply, %Hedwig.Message{text: "testuser: pong"}} 47 | end 48 | end 49 | 50 | test "emote/2" do 51 | capture_io fn -> 52 | {:ok, adapter} = Hedwig.Adapter.start_link(Console, name: "hedwig", user: "testuser") 53 | 54 | handle_connect() 55 | # replace the adapter's connection pid to the test process 56 | replace_connection_pid(adapter) 57 | 58 | msg = %Hedwig.Message{text: "pong", user: "testuser"} 59 | Console.emote(adapter, msg) 60 | 61 | assert_receive {:reply, ^msg} 62 | end 63 | end 64 | end 65 | 66 | defp handle_connect do 67 | receive do 68 | {:"$gen_call", from, :handle_connect} -> 69 | GenServer.reply(from, :ok) 70 | end 71 | end 72 | 73 | defp replace_connection_pid(adapter) do 74 | test_process = self() 75 | :sys.replace_state(adapter, fn state -> %{state | conn: test_process} end) 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/hedwig/responder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.ResponderTest do 2 | use Hedwig.RobotCase 3 | 4 | alias Hedwig.Responder 5 | 6 | test "respond_pattern" do 7 | assert Responder.respond_pattern(~r/hey there/i, "alfred", nil) == 8 | ~r/^\s*[@]?alfred[:,]?\s*(?:hey there)/i 9 | 10 | assert Responder.respond_pattern(~r/this\s*should\s*escape/i, "alfred", nil) == 11 | ~r/^\s*[@]?alfred[:,]?\s*(?:this\s*should\s*escape)/i 12 | 13 | assert Responder.respond_pattern(~r/this\s*should\s*escape/i, "alfred", "/") == 14 | ~r/^\s*[@]?(?:alfred[:,]?|\/[:,]?)\s*(?:this\s*should\s*escape)/i 15 | end 16 | 17 | @tag start_robot: true, name: "alfred" 18 | test "responding to messages", %{adapter: adapter, msg: msg} do 19 | send adapter, {:message, %{msg | text: "this is a test"}} 20 | assert_receive {:message, %{text: "did someone say test?"}} 21 | 22 | send adapter, {:message, %{msg | text: "alfred do you hear me?"}} 23 | assert_receive {:message, %{text: "testuser: loud and clear!"}} 24 | 25 | send adapter, {:message, %{msg | text: "i love cats"}} 26 | assert_receive {:message, %{text: "testuser: then why don't you marry cats!?"}} 27 | 28 | send adapter, {:message, %{msg | text: "i like pie"}} 29 | assert_receive {:message, %{text: "* likes pie too!"}} 30 | 31 | send adapter, {:message, %{msg | text: "randomness"}} 32 | assert_receive {:message, %{text: text}} 33 | assert text in 1..1000 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/hedwig/responders/help_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Responders.HelpTest do 2 | use Hedwig.RobotCase 3 | 4 | @tag start_robot: true, name: "alfred" 5 | test "help - displays the usage for all installed responders", %{adapter: adapter, msg: msg} do 6 | send adapter, {:message, %{msg | text: "alfred help"}} 7 | assert_receive {:message, %{text: text}} 8 | assert String.contains?(text, "Displays all help commands that match ") 9 | end 10 | 11 | @tag start_robot: true, name: "alfred" 12 | test "help - displays the usage for responders that match query", %{adapter: adapter, msg: msg} do 13 | send adapter, {:message, %{msg | text: "alfred help test"}} 14 | assert_receive {:message, %{text: text}} 15 | assert text == "(this is a test) - did someone say test?" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/hedwig/responders/ping_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Responders.PingTest do 2 | use Hedwig.RobotCase 3 | 4 | @tag start_robot: true, name: "alfred", responders: [{Hedwig.Responders.Ping, []}] 5 | test "ping responds with pong", %{adapter: adapter, msg: msg} do 6 | send adapter, {:message, %{msg | text: "alfred ping"}} 7 | assert_receive {:message, %{text: "testuser: pong"}} 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/hedwig/robot/supervisor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.Robot.SupervisorTest do 2 | use ExUnit.Case 3 | 4 | test "missing otp config" do 5 | assert_raise ArgumentError, fn -> 6 | Hedwig.Robot.Supervisor.config(NoSuchApp.Robot, :no_such_app, []) 7 | end 8 | end 9 | 10 | test "parse config (ok)" do 11 | opts = [otp_app: :alfred, adapter: Hedwig.Adapters.Console] 12 | result = Hedwig.Robot.Supervisor.parse_config(Alfred.Robot, opts) 13 | assert result == {:alfred, Hedwig.Adapters.Console, []} 14 | end 15 | 16 | test "parse config (missing adapter keyword)" do 17 | opts = [otp_app: :alfred] 18 | assert_raise ArgumentError, fn -> 19 | Hedwig.Robot.Supervisor.parse_config(Alfred.Robot, opts) 20 | end 21 | end 22 | 23 | test "parse config (missing adapter code)" do 24 | opts = [otp_app: :alfred, adapter: Hedwig.Adapters.NoSuchAdapter] 25 | assert_raise ArgumentError, fn -> 26 | Hedwig.Robot.Supervisor.parse_config(Alfred.Robot, opts) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/hedwig/robot_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.RobotTest do 2 | use ExUnit.Case 3 | use Hedwig.RobotCase 4 | 5 | @tag start_robot: true, name: "alfred" 6 | test "name/1 returns the name of the robot", %{robot: robot} do 7 | assert "alfred" = Hedwig.Robot.name(robot) 8 | end 9 | 10 | @tag start_robot: true, responders: [{TestResponder, []}] 11 | test "responders/1 returns the list of configured responders", %{robot: robot} do 12 | assert [{TestResponder, []}] = Hedwig.Robot.responders(robot) 13 | end 14 | 15 | @tag start_robot: true 16 | test "handle_connect/1", %{robot: robot} do 17 | assert ^robot = :global.whereis_name("hedwig") 18 | end 19 | 20 | @tag start_robot: true 21 | test "handle_disconnect/1", %{robot: robot} do 22 | import Hedwig.Robot 23 | assert :reconnect == handle_disconnect(robot, :reconnect) 24 | assert {:reconnect, 5000} == handle_disconnect(robot, {:reconnect, 5000}) 25 | assert {:disconnect, :normal} == handle_disconnect(robot, :error) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/hedwig_test.exs: -------------------------------------------------------------------------------- 1 | defmodule HedwigTest do 2 | use Hedwig.RobotCase 3 | 4 | @tag start_robot: true 5 | test "list started robots", %{robot: pid} do 6 | assert [{_id, ^pid, _type, [Hedwig.Robot]}] = Hedwig.which_robots() 7 | end 8 | 9 | @tag start_robot: true, name: "codsworth" 10 | test "find a robot by name", %{robot: pid} do 11 | assert :undefined == :global.whereis_name("hedwig") 12 | assert ^pid = :global.whereis_name("codsworth") 13 | end 14 | 15 | @tag start_robot: true 16 | test "handle_in/2", %{robot: pid} do 17 | Hedwig.Robot.handle_in(pid, {:ping, self()}) 18 | assert_receive :pong 19 | end 20 | 21 | @tag start_robot: true 22 | test "stop_robot/1", %{robot: pid} do 23 | Hedwig.stop_robot(pid) 24 | refute Process.alive?(pid) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/mix/tasks/hedwig.gen.robot_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Hedwig.Gen.RobotTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureIO 5 | import Hedwig.FileHelpers 6 | import Mix.Tasks.Hedwig.Gen.Robot, only: [run: 1] 7 | 8 | test "generates a new robot" do 9 | in_tmp fn _ -> 10 | capture_io("1", fn -> run ["--name", "alfred"] end) 11 | 12 | assert_file "lib/hedwig/robot.ex", """ 13 | defmodule Hedwig.Robot do 14 | use Hedwig.Robot, otp_app: :hedwig 15 | """ 16 | 17 | assert_file "lib/hedwig/robot.ex", """ 18 | def handle_connect(%{name: name} = state) do 19 | """ 20 | 21 | assert_file "lib/hedwig/robot.ex", """ 22 | def handle_disconnect(_reason, state) do 23 | """ 24 | 25 | assert_file "config/config.exs", """ 26 | use Mix.Config 27 | 28 | config :hedwig, Hedwig.Robot, 29 | adapter: Hedwig.Adapters.Console, 30 | name: "alfred", 31 | aka: "/", 32 | """ 33 | end 34 | end 35 | 36 | test "generates a new robot with existing config file" do 37 | in_tmp fn _ -> 38 | File.mkdir_p! "config" 39 | File.write! "config/config.exs", """ 40 | # Hello 41 | use Mix.Config 42 | # World 43 | """ 44 | 45 | capture_io("1", fn -> run ["--name", "alfred", "--robot", "Robot"] end) 46 | 47 | assert_file "config/config.exs", """ 48 | # Hello 49 | use Mix.Config 50 | 51 | config :hedwig, Robot, 52 | adapter: Hedwig.Adapters.Console, 53 | name: "alfred", 54 | aka: "/", 55 | responders: [ 56 | {Hedwig.Responders.Help, []}, 57 | {Hedwig.Responders.Ping, []} 58 | ] 59 | 60 | # World 61 | """ 62 | end 63 | end 64 | 65 | 66 | test "generates a new namespaced robot" do 67 | in_tmp fn _ -> 68 | capture_io("1", fn -> run ["--name", "alfred", "--robot", "MyApp.Robot"] end) 69 | assert_file "lib/my_app/robot.ex", "defmodule MyApp.Robot do" 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/support/file_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Hedwig.FileHelpers do 2 | import ExUnit.Assertions 3 | 4 | @doc """ 5 | Returns the `tmp_path` for tests. 6 | """ 7 | def tmp_path do 8 | Path.expand("../tmp", __DIR__) 9 | end 10 | 11 | @doc """ 12 | Executes the given function in a temp directory 13 | tailored for this test case and test. 14 | """ 15 | defmacro in_tmp(fun) do 16 | path = Path.join([tmp_path(), "#{__CALLER__.module}", "#{elem(__CALLER__.function, 0)}"]) 17 | quote do 18 | path = unquote(path) 19 | File.rm_rf!(path) 20 | File.mkdir_p!(path) 21 | File.cd!(path, fn -> unquote(fun).(path) end) 22 | end 23 | end 24 | 25 | @doc """ 26 | Asserts a file was generated. 27 | """ 28 | def assert_file(file) do 29 | assert File.regular?(file), "Expected #{file} to exist, but does not" 30 | end 31 | 32 | @doc """ 33 | Asserts a file was generated and that it matches a given pattern. 34 | """ 35 | def assert_file(file, callback) when is_function(callback, 1) do 36 | assert_file(file) 37 | callback.(File.read!(file)) 38 | end 39 | 40 | def assert_file(file, match) do 41 | assert_file file, &(assert &1 =~ match) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/support/test_responder.ex: -------------------------------------------------------------------------------- 1 | defmodule TestResponder do 2 | use Hedwig.Responder 3 | 4 | @usage """ 5 | (this is a test) - did someone say test? 6 | """ 7 | hear ~r/this is a test/i, msg do 8 | send msg, "did someone say test?" 9 | end 10 | 11 | @usage """ 12 | (i love ) - Relies with "then why don't you marry !?" 13 | """ 14 | hear ~r/i love (\w+)/i, msg do 15 | reply msg, "then why don't you marry #{msg.matches[1]}!?" 16 | end 17 | 18 | @usage """ 19 | (i like ) - Emotes "likes too!" 20 | """ 21 | hear ~r/i like (?\w+)/i, msg do 22 | emote msg, "likes #{msg.matches["subject"]} too!" 23 | end 24 | 25 | @usage """ 26 | hedwig do you hear me? - Replies with "loud and clear!" 27 | """ 28 | respond ~r/do you hear me\?/i, msg do 29 | reply msg, "loud and clear!" 30 | end 31 | 32 | @usage """ 33 | a random example 34 | """ 35 | hear ~r/randomness/i, msg do 36 | send msg, random(Enum.to_list(1..1000)) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------