├── test ├── test_helper.exs ├── spoonbot_test.exs └── commands_test.exs ├── .gitignore ├── mix.lock ├── lib ├── spoonbot │ ├── commands.ex │ └── bridges │ │ └── irc.ex └── spoonbot.ex ├── spoonbot.exs ├── mix.exs ├── config └── config.exs └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /test/spoonbot_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SpoonbotTest do 2 | use ExUnit.Case 3 | 4 | test "the command function" do 5 | Spoonbot.command "test", &(&1) 6 | { _, func } = Commands.find("test") 7 | assert func.("test") == "test" 8 | end 9 | end -------------------------------------------------------------------------------- /test/commands_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ComandsTest do 2 | use ExUnit.Case 3 | 4 | test "add and find" do 5 | Commands.add({ ~r/test/, &(&1) }) 6 | { regex, text } = Commands.find("test") 7 | assert text.("test") == "test" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"cowboy": {:git, "git://github.com/extend/cowboy.git", "903594bb8709db4fa98697ecf8adbdccabf64a83", []}, 2 | "cowlib": {:git, "git://github.com/extend/cowlib.git", "2e0fc55f71bfeb543fd2218abed85890ff1e3e82", [ref: "0.5.0"]}, 3 | "plug": {:package, "0.4.3"}, 4 | "ranch": {:git, "git://github.com/extend/ranch.git", "5df1f222f94e08abdcab7084f5e13027143cc222", [ref: "0.9.0"]}} 5 | -------------------------------------------------------------------------------- /lib/spoonbot/commands.ex: -------------------------------------------------------------------------------- 1 | defmodule Commands do 2 | @name { :global, __MODULE__ } 3 | 4 | def start_link do 5 | Agent.start_link( fn-> MapSet.new end, name: @name ) 6 | end 7 | 8 | def add(command) do 9 | Agent.update(@name, fn(set) -> MapSet.put(set, command) end) 10 | end 11 | 12 | def find(phrase) do 13 | set = Agent.get(@name, fn(set) -> set end) 14 | Enum.find(set, fn ({ pattern, _ }) -> Regex.match?(pattern, phrase) end) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spoonbot.exs: -------------------------------------------------------------------------------- 1 | import Spoonbot 2 | 3 | command "funky", &("#{&1}: cold medina!") 4 | 5 | command "say (.*)", fn (speaker, args) -> 6 | "#{speaker}: #{Enum.at(args, 0)}" 7 | end 8 | 9 | command "heya", fn (speaker) -> 10 | greetings = [ 11 | "yo", "backatcha", "aight", "hi", "g'day", 12 | ] 13 | greeting = Enum.at greetings, round(:rand.uniform ((Enum.count greetings) -1)) 14 | "#{greeting} #{speaker}" 15 | end 16 | 17 | command "die", &("No, you die #{&1}!") 18 | command "quit", &("Haha, after you #{&1}!") 19 | -------------------------------------------------------------------------------- /lib/spoonbot.ex: -------------------------------------------------------------------------------- 1 | defmodule Spoonbot do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | import Supervisor.Spec, warn: false 6 | children = [] 7 | opts = [strategy: :one_for_one, name: Spoonbot.Supervisor] 8 | 9 | Commands.start_link 10 | spawn(Bridge.IRC, :run, []) 11 | Code.require_file("spoonbot.exs") 12 | 13 | Supervisor.start_link(children, opts) 14 | end 15 | 16 | def command(phrase, func) do 17 | { :ok, pattern } = Regex.compile(phrase) 18 | Commands.add({ pattern, func }) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Spoonbot.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ app: :spoonbot, 6 | version: "0.0.1", 7 | elixir: ">= 0.13.3", 8 | deps: deps() ] 9 | end 10 | 11 | # Configuration for the OTP application 12 | # 13 | # Type `mix help compile.app` for more information 14 | def application do 15 | [ applications: [], 16 | mod: { Spoonbot, [] } 17 | ] 18 | end 19 | 20 | # Returns the list of dependencies in the format: 21 | # { :foobar, git: "https://github.com/elixir-lang/foobar.git", tag: "0.1" } 22 | # 23 | # To specify particular versions, regardless of the tag, do: 24 | # { :barbat, "~> 0.1", github: "elixir-lang/barbat" } 25 | defp deps do 26 | [] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application and 2 | # its dependencies. It must return a keyword list containing the 3 | # application name and have as value another keyword list with 4 | # the application key-value pairs. 5 | 6 | # Note this configuration is loaded before any dependency and is 7 | # restricted to this project. If another project depends on this 8 | # project, this file won't be loaded nor affect the parent project. 9 | 10 | # You can customize the configuration path by setting :config_path 11 | # in your mix.exs file. For example, you can emulate configuration 12 | # per environment by setting: 13 | # 14 | # config_path: "config/#{Mix.env}.exs" 15 | # 16 | # Changing any file inside the config directory causes the whole 17 | # project to be recompiled. 18 | 19 | # Sample configuration: 20 | # 21 | # [dep1: [key: :value], 22 | # dep2: [key: :value]] 23 | 24 | 25 | [ spoonbot: 26 | [ conf: [ { "banks.freenode.net", 6667, "spoonbot" }, { "#polyhack" } ] ] 27 | ] 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Spoonbot - an IRC bot written in Elixir with a simple command syntax. 2 | 3 | ## Install 4 | 5 | You will need Elixir 0.13.3 or more recent installed. See http://elixir-lang.org/getting_started/1.html 6 | 7 | ## Make your own 8 | 9 | Clone spoonbot's repo. 10 | 11 | Open config/config.exs 12 | 13 | ``` 14 | [ spoonbot: 15 | [ conf: [ { "banks.freenode.net", 6667, "spoonbot" }, { "#polyhack" } ] ] 16 | ] 17 | 18 | ``` 19 | Configure the conf variable with the address of the IRC server, its port, the client name, and the connected channel. 20 | 21 | Start your bot. 22 | 23 | ``` 24 | elixir --sname spoonbot -S mix run --no-halt 25 | ``` 26 | 27 | When the bot appears in the channel speak to it: 28 | 29 | ``` 30 | 4:09 PM <•nicholasf> spoonbot: heya 31 | 4:09 PM aight nicholasf 32 | 33 | ``` 34 | 35 | Open spoonbot.exs and see how you can write Spoonbot commands. 36 | 37 | ``` 38 | import Spoonbot 39 | 40 | command "pattern", fn(speaker) -> 41 | #logic 42 | #return a string holding the bot's response 43 | end 44 | ``` 45 | The simplest command takes a phrase for recognition then, followed by a comma, an anonymous function 46 | with one argument - the name of the speaker in the IRC chatroom. It should return a string to appear in the chatroom. 47 | 48 | If you want to parse the bot's input pass in a string that can be compiled into a Regex. Then your function will take two arguments, the second for the arguments parsed from the regex. 49 | 50 | ``` 51 | command "say (.*)", fn (speaker, args) -> 52 | Enum.at(args, 0) 53 | end 54 | ``` 55 | 56 | ``` 57 | 6:45 PM spoonbot: say something or other 58 | 6:45 PM something or other 59 | ```` 60 | 61 | Connect to the running spoonbot Erlang Node and hot load a new command remotely. 62 | 63 | ``` 64 | ♪ spoonbot git:(master) ✗ iex --sname bark 65 | Erlang/OTP 17 [erts-6.0] [source] [64-bit] [smp:8:8] [async-threads:10] [hipe] [kernel-poll:false] 66 | 67 | Interactive Elixir (0.13.3) - press Ctrl+C to exit (type h() ENTER for help) 68 | iex(bark@argo)1> Node.connect :spoonbot@argo 69 | true 70 | iex(bark@argo)2> c "lib/spoonbot.ex" 71 | [Spoonbot] 72 | iex(bark@argo)2> c "lib/spoonbot/commands.ex" 73 | [Commands] 74 | iex(bark@argo)3> import Spoonbot 75 | nil 76 | iex(bark@argo)4> command "mirror me", &(String.reverse(&1)) 77 | :ok 78 | 79 | ``` 80 | 81 | The command will be ready in the bot. 82 | 83 | Add more commands in spoonbot.exs or build your own exs file and parse it in the remote node: 84 | 85 | ``` 86 | iex(bark@argo)4> Code.require_file("alternate_commands.exs") 87 | ``` 88 | -------------------------------------------------------------------------------- /lib/spoonbot/bridges/irc.ex: -------------------------------------------------------------------------------- 1 | 2 | defmodule Bridge.IRC do 3 | 4 | def run() do 5 | { server, port, nickname } = Enum.at(config, 0) 6 | { :ok, socket } = :gen_tcp.connect(:erlang.binary_to_list(server), port, [:binary, {:active, false}]) 7 | :ok = transmit(socket, "NICK #{nickname}") 8 | :ok = transmit(socket, "USER #{nickname} #{server} #{bot_name} :#{bot_name}") 9 | do_listen(socket) 10 | end 11 | 12 | def do_listen(socket) do 13 | ping = ~r/\APING/ 14 | motd_end = ~r/\/MOTD/ 15 | msg = ~r/PRIVMSG spoonbot/ 16 | { channel_name } = channel 17 | { :ok, invoker } = Regex.compile("PRIVMSG #{channel_name} :#{bot_name}:") 18 | 19 | case :gen_tcp.recv(socket, 0) do 20 | { :ok, data } -> 21 | IO.puts data 22 | 23 | if Regex.match?(motd_end, data), do: join_channel(socket) 24 | if Regex.match?(ping, data), do: pong(socket, data) 25 | 26 | if Regex.match?(invoker, data) do 27 | bits = String.split(data, ":#{bot_name}:") 28 | phrase = String.strip(Enum.at bits, 1) 29 | command = Commands.find(phrase) 30 | if command do 31 | { pattern, func } = command 32 | args = Regex.scan(pattern, phrase, capture: :all_but_first) 33 | speaker_name = speaker(Enum.at bits, 0) 34 | args = Enum.filter(args, &((Enum.count &1) > 0)) 35 | 36 | if (Enum.count(args) > 0) do 37 | result = func.(speaker_name, Enum.at(args, 0)) 38 | else 39 | result = func.(speaker_name) 40 | end 41 | 42 | say(socket, result) 43 | end 44 | end 45 | 46 | do_listen(socket) 47 | { :error, :closed } -> 48 | IO.puts "The client closed the connection..." 49 | end 50 | end 51 | 52 | def transmit(socket, msg) do 53 | IO.puts "sending #{msg}" 54 | :gen_tcp.send(socket, "#{msg} \r\n") 55 | end 56 | 57 | def say(socket, msg) do 58 | responder = fn 59 | { channel } -> transmit(socket, "PRIVMSG #{channel} :#{msg}") 60 | { channel, password } -> transmit(socket, "PRIVMSG #{channel} :#{msg}") 61 | end 62 | 63 | responder.(channel) 64 | end 65 | 66 | def join_channel(socket) do 67 | joiner = fn 68 | { channel } -> transmit(socket, "JOIN #{ channel }") 69 | { channel, password } -> transmit(socket, "JOIN #{ channel } #{ password }") 70 | end 71 | 72 | joiner.(channel) 73 | end 74 | 75 | def pong(socket, data) do 76 | server = Enum.at(Regex.split(~r/\s/, data), 1) 77 | transmit(socket, "PONG #{ server }") 78 | end 79 | 80 | def config do 81 | { :ok, config } = :application.get_env(:spoonbot, :conf) 82 | config 83 | end 84 | 85 | def bot_name do 86 | { _, _, name } = Enum.at(config, 0) 87 | name 88 | end 89 | 90 | def channel do 91 | Enum.at(config, 1) 92 | end 93 | 94 | def speaker(irc_fragment) do 95 | bits = String.split(irc_fragment, "!") 96 | str = Enum.at bits, 0 97 | String.slice(str, 1..-1) 98 | end 99 | end 100 | --------------------------------------------------------------------------------