From 63409368951e8156376ef5a2f58cfb6a465ba85e Mon Sep 17 00:00:00 2001 From: Alek Ratzloff Date: Fri, 12 Jun 2020 17:29:35 -0400 Subject: [PATCH] Initial commit with IRC and bot example. Signed-off-by: Alek Ratzloff --- .formatter.exs | 4 ++ .gitignore | 46 +++++++++++++++++ README.md | 21 ++++++++ config/config.exs | 7 +++ lib/config.ex | 52 +++++++++++++++++++ lib/contrib/fortune.ex | 57 ++++++++++++++++++++ lib/irc.ex | 99 +++++++++++++++++++++++++++++++++++ lib/irc/msg.ex | 109 +++++++++++++++++++++++++++++++++++++++ lib/module_supervisor.ex | 52 +++++++++++++++++++ lib/omnibot.ex | 10 ++++ lib/router.ex | 61 ++++++++++++++++++++++ lib/state.ex | 74 ++++++++++++++++++++++++++ lib/supervisor.ex | 29 +++++++++++ lib/util.ex | 5 ++ mix.exs | 37 +++++++++++++ omnibot.example.exs | 14 +++++ test/config_test.exs | 24 +++++++++ test/irc/msg_test.exs | 76 +++++++++++++++++++++++++++ test/test_helper.exs | 1 + test/util_test.exs | 15 ++++++ 20 files changed, 793 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.exs create mode 100644 lib/config.ex create mode 100644 lib/contrib/fortune.ex create mode 100644 lib/irc.ex create mode 100644 lib/irc/msg.ex create mode 100644 lib/module_supervisor.ex create mode 100644 lib/omnibot.ex create mode 100644 lib/router.ex create mode 100644 lib/state.ex create mode 100644 lib/supervisor.ex create mode 100644 lib/util.ex create mode 100644 mix.exs create mode 100644 omnibot.example.exs create mode 100644 test/config_test.exs create mode 100644 test/irc/msg_test.exs create mode 100644 test/test_helper.exs create mode 100644 test/util_test.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eba3c42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +## Vim +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +## Elixir +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +omnibot-*.tar + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e0c509 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Omnibot + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `omnibot` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:omnibot, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at [https://hexdocs.pm/omnibot](https://hexdocs.pm/omnibot). + diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..1048568 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,7 @@ +use Mix.Config + +config :logger, + backends: [:console], + compile_time_purge_matching: [ + [level_lower_than: :debug] + ] diff --git a/lib/config.ex b/lib/config.ex new file mode 100644 index 0000000..904cb7b --- /dev/null +++ b/lib/config.ex @@ -0,0 +1,52 @@ +defmodule Omnibot.Config do + alias Omnibot.Irc.Msg + + @enforce_keys [:server] + defstruct [ + :server, + nick: "omnibot", + user: "omnibot", + real: "omnibot", + port: 6667, + ssl: false, + modules: [], + module_paths: [] + ] + + @doc ~S""" + Gets all channels that the bot should join via its modules. + """ + def all_channels(cfg) do + Enum.flat_map(cfg.modules, fn {_, [channels: channels]} -> channels end) + |> MapSet.new() + |> MapSet.to_list() + end + + @doc ~S""" + Gets a list of all `{module, mod_cfg}` pairs from the given configuration + that are listening to the given channel. + """ + def channel_modules(cfg, channel) do + cfg.modules + |> Enum.filter(fn {_, cfg} -> Enum.member?(cfg[:channels] || [], channel) end) + end + + def msg_prefix(cfg) do + %Msg.Prefix { + nick: cfg.nick, + user: cfg.user, + } + end + + @doc ~S""" + Make a new message with the given command and parameters using the given + configuration to build the prefix. + """ + def msg(cfg, command, params \\ []) do + %Msg { + prefix: msg_prefix(cfg), + command: command, + params: params, + } + end +end diff --git a/lib/contrib/fortune.ex b/lib/contrib/fortune.ex new file mode 100644 index 0000000..ade079f --- /dev/null +++ b/lib/contrib/fortune.ex @@ -0,0 +1,57 @@ +defmodule Omnibot.Contrib.Fortune do + use GenServer + alias Omnibot.Irc + require Logger + + @fortunes [ + "Reply hazy, try again", + "Excellent Luck", + "Good Luck", + "Average Luck", + "Bad Luck", + "Good news will come to you by mail", + "´_ゝ`", + "タ━━━━━━(゚∀゚)━━━━━━ !!!!", + "You will meet a dark handsome stranger", + "Better not tell you now", + "Outlook good", + "Very Bad Luck", + "Godly Luck", + ] + + ## Client API + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts[:cfg], opts) + end + + def privmsg(module, channel, nick, line) do + GenServer.cast(module, {:privmsg, {channel, nick, line}}) + end + + ## Server callbacks + + @impl true + def init(cfg) do + #Logger.debug("Starting fortune module") + #IO.inspect(self()) + {:ok, cfg} + end + + @impl true + def handle_call(:unload, _from, cfg) do + Logger.info("Unloading") + {:reply, :ok, cfg} + end + + @impl true + def handle_cast({:privmsg, {channel, nick, line}}, cfg) do + if IO.inspect(line) == "!fortune" do + fortune = Enum.random(@fortunes) + reply = "#{nick}: #{fortune}" + Irc.send_to(Irc, channel, reply) + end + + {:noreply, cfg} + end +end diff --git a/lib/irc.ex b/lib/irc.ex new file mode 100644 index 0000000..d332a8e --- /dev/null +++ b/lib/irc.ex @@ -0,0 +1,99 @@ +# REWRITE +defmodule Omnibot.Irc do + require Logger + alias Omnibot.Irc.Msg + alias Omnibot.{Config, State} + use GenServer + + ## Client API + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, :ok, opts) + end + + def send_msg(irc, msg) do + GenServer.cast(irc, {:send_msg, msg}) + end + + def send_msg(irc, command, params) when is_list(params) do + cfg = State.cfg() + GenServer.cast(irc, {:send_msg, Config.msg(cfg, command, params)}) + end + + def send_msg(irc, command, param), do: send_msg(irc, command, [param]) + + def send_to(irc, channel, text), do: send_msg(irc, "PRIVMSG", [channel, text]) + + def join(irc, channel), do: send_msg(irc, "JOIN", channel) + + def part(irc, channel), do: send_msg(irc, "PART", channel) + + def sync_channels(irc), do: GenServer.cast(irc, :sync_channels) + + ## Server callbacks + + @impl true + def init(:ok) do + cfg = State.cfg() + _ssl = cfg.ssl + + {:ok, socket} = + :gen_tcp.connect(to_charlist(cfg.server), cfg.port, [:binary, active: false, packet: :line]) + + # Wait for first message + #{:ok, _} = :gen_tcp.recv(socket, 0) + send_msg(self(), "NICK", cfg.nick) + send_msg(self(), "USER", [cfg.user, "0", "*", cfg.real]) + :inet.setopts(socket, [active: true]) + + {:ok, socket} + end + + defp write(socket, msg) do + msg = String.Chars.to_string(msg) + Logger.debug(">>> #{msg}") + :gen_tcp.send(socket, "#{msg}\r\n") + end + + @impl true + def handle_cast({:send_msg, msg}, socket) do + write(socket, msg) + {:noreply, socket} + end + + @impl true + def handle_cast({:tcp, line}, socket) do + Logger.debug(line) + {:noreply, socket} + end + + @impl true + def handle_cast(:sync_channels, socket) do + cfg = State.cfg() + desired = MapSet.new(Config.all_channels(cfg)) + present = MapSet.new(State.channels()) + to_join = MapSet.difference(desired, present) + |> MapSet.to_list() + to_part = MapSet.difference(present, desired) + |> MapSet.to_list() + + Enum.each(to_join, fn channel -> join(self(), channel) end) + Enum.each(to_part, fn channel -> part(self(), channel) end) + + {:noreply, socket} + end + + @impl true + def handle_info({:tcp, _socket, line}, socket) do + Logger.debug(String.trim(line)) + msg = Msg.parse(line) + + # Send the message to the router + irc = self() + {:ok, _task} = Task.Supervisor.start_child( + Omnibot.RouterSupervisor, + fn -> Omnibot.Router.route(irc, msg) end + ) + {:noreply, socket} + end +end diff --git a/lib/irc/msg.ex b/lib/irc/msg.ex new file mode 100644 index 0000000..ee6f97d --- /dev/null +++ b/lib/irc/msg.ex @@ -0,0 +1,109 @@ +alias Omnibot.Irc +alias Omnibot.Util + +defmodule Omnibot.Irc.Msg do + defmodule Prefix do + defstruct [:nick, :user, :host] + + @prefix_regex ~r/(?[^!]+)(!(?[^@]+)(@(?.+))?)?/ + def parse(prefix) do + cap = Regex.named_captures(@prefix_regex, prefix) + + if cap do + %{ + "nick" => nick, + "user" => user, + "host" => host + } = cap + + %Irc.Msg.Prefix{ + nick: nick, + user: if(user == "", do: nil, else: user), + host: if(host == "", do: nil, else: host) + } + else + nil + end + end + end + + @enforce_keys [:command] + defstruct prefix: nil, command: nil, params: [] + + @msg_regex ~r/ + ^(:(?P[^ ]+)\ )? + (?[a-zA-Z]+|[0-9]{3}) + (?(\ [^: \r\n]+)*) + (?\ :[^\r\n]+)? + /x + + def parse(msg) do + %{ + "prefix" => prefix, + "command" => command, + "params" => params, + "trailing" => trailing + } = Regex.named_captures(@msg_regex, msg) + + prefix = Irc.Msg.Prefix.parse(prefix) + + params = + String.slice(params, 1..-1) + |> String.split(" ") + |> Enum.filter(fn s -> String.length(s) > 0 end) + + trailing = + trailing + |> String.slice(2..-1) + |> Util.string_or_nil() + + params = if trailing, do: params ++ [trailing], else: params + + %Irc.Msg{ + prefix: prefix, + command: command, + params: params, + } + end +end + +defimpl String.Chars, for: Irc.Msg.Prefix do + def to_string(prefix) do + nick = prefix.nick || "" + user = if prefix.user, do: "!#{prefix.user}", else: "" + host = if prefix.host, do: "@#{prefix.host}", else: "" + "#{nick}#{user}#{host}" + end +end + +defimpl String.Chars, for: Irc.Msg do + def to_string(msg) do + prefix = + case String.Chars.to_string(msg.prefix) do + "" -> "" + p -> ":#{p}" + end + + # Figure out where "trailing" parameters begin, e.g. + # ["privmsg", "param", "some message", "with another param"] + # + # becomes + # + # {["privmsg", "param"], ["some message", "with another param"]} + # + {params, trailing} = Enum.split_while(msg.params, fn param -> !String.contains?(param, " ") end) + + # If trailing parameters exist, then join them with a space character and prefix with a colon, + # appending it as another list item to the non-trailing parameter list + # + # If trailing parameters don't exist, don't append anything + params = params ++ + if length(trailing) > 0, + do: [":" <> Enum.join(trailing, " ")], + else: [] + + ([prefix, msg.command] ++ params) + |> Enum.filter(fn n -> String.length(n) > 0 end) + |> Enum.join(" ") + end +end diff --git a/lib/module_supervisor.ex b/lib/module_supervisor.ex new file mode 100644 index 0000000..ca508b1 --- /dev/null +++ b/lib/module_supervisor.ex @@ -0,0 +1,52 @@ +defmodule Omnibot.ModuleSupervisor do + @moduledoc false + + use Supervisor + require Logger + + def start_link(opts \\ []) do + Supervisor.start_link(__MODULE__, opts[:cfg], opts) + end + + @impl true + def init(cfg) do + compile_files(cfg.module_paths || []) + + # Map the modules in the configuration to the children + children = + for mod <- cfg.modules do + case mod do + {name, cfg} -> {name, cfg: cfg, name: name} + name -> {name, cfg: [], name: name} + end + end + + Supervisor.init(children, strategy: :one_for_one) + end + + defp compile_files([]), do: nil + + defp compile_files([{path, opts} | module_paths]) do + case {File.exists?(path), File.dir?(path)} do + {_, true} -> compile_dir(path, opts[:recurse] || false) + {true, false} -> Code.require_file(path) + {_, _} -> Logger.error("module path '#{path}' does not exist, it will not be loaded") + end + + compile_files(module_paths) + end + + defp compile_files([path | module_paths]) do + compile_files([{path, []} | module_paths]) + end + + defp compile_dir(path, recurse) do + files = + File.ls!(path) + |> Enum.map(fn file -> {Path.join(path, file), [recurse: recurse]} end) + |> Enum.filter(fn {file, [recurse: recurse]} -> + (!File.dir?(file) || recurse) && File.exists?(file) + end) + compile_files(files) + end +end diff --git a/lib/omnibot.ex b/lib/omnibot.ex new file mode 100644 index 0000000..b938247 --- /dev/null +++ b/lib/omnibot.ex @@ -0,0 +1,10 @@ +defmodule Omnibot do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + Omnibot.Supervisor.start_link(name: Omnibot.Supervisor) + end +end diff --git a/lib/router.ex b/lib/router.ex new file mode 100644 index 0000000..8fb24ce --- /dev/null +++ b/lib/router.ex @@ -0,0 +1,61 @@ +defmodule Omnibot.Router do + require Logger + alias Omnibot.{Config, Irc, Irc.Msg, State} + + def route(irc, msg) do + case String.upcase(msg.command) do + "PRIVMSG" -> handle(irc, :privmsg, msg) + "JOIN" -> handle(irc, :join, msg) + "KICK" -> handle(irc, :kick, msg) + "PART" -> handle(irc, :part, msg) + "PING" -> handle(irc, :ping, msg) + "001" -> handle(irc, :welcome, msg) + _ -> nil + end + end + + def handle(_irc, :privmsg, msg) do + # TODO : get channel, pass along to modules + [channel | params] = msg.params + line = Enum.join(params, " ") + nick = msg.prefix.nick + + # Find modules that want this message + State.cfg() + |> Config.channel_modules(channel) + |> Enum.each(fn {module, _} -> module.privmsg(module, channel, nick, line) end) + end + + def handle(_irc, :join, %Msg {prefix: %Msg.Prefix{nick: nick}, params: [channel | _]}) do + cfg = State.cfg() + if nick == cfg.nick do + State.add_channel(channel) + end + end + + def handle(irc, :kick, %Msg {params: [channel, who | _]}) do + cfg = State.cfg() + if who == cfg.nick do + State.remove_channel(State, channel) + # sync_channels here because this is a state that we (should) not have put ourselves into + Irc.sync_channels(irc) + end + end + + def handle(_irc, :part, %Msg {prefix: %Msg.Prefix{nick: nick}, params: [channel | _]}) do + cfg = State.cfg() + if nick == cfg.nick do + State.remove_channel(State, channel) + end + end + + def handle(irc, :ping, msg) do + cfg = State.cfg() + reply = Config.msg(cfg, "PONG", msg.params) + Irc.send_msg(irc, reply) + end + + def handle(irc, :welcome, _msg) do + Irc.sync_channels(irc) + end +end diff --git a/lib/state.ex b/lib/state.ex new file mode 100644 index 0000000..dfec1ab --- /dev/null +++ b/lib/state.ex @@ -0,0 +1,74 @@ +defmodule Omnibot.State do + use GenServer + + @enforce_keys [:cfg] + defstruct [:cfg, channels: MapSet.new()] + + ## Client API + + def start_link(opts) do + cfg = opts[:cfg] + GenServer.start_link(__MODULE__, %Omnibot.State{ + cfg: cfg + }, opts) + end + + @doc "Gets the current configuration from the default State process." + def cfg(), do: cfg(__MODULE__) + + @doc "Gets the current configuration from the given State process." + def cfg(state) do + GenServer.call(state, :cfg) + end + + @doc "Gets all channels that the bot is present in from the default State process." + def channels(), do: channels(__MODULE__) + + @doc "Gets all channels that the bot is present in from the given State process." + def channels(state) do + GenServer.call(state, :channels) + end + + @doc "Adds a channel to the list of joined channels of the default State process, if it is not already present." + def add_channel(channel), do: add_channel(__MODULE__, channel) + + @doc "Adds a channel to the list of joined channels of the given State process, if it is not already present." + def add_channel(state, channel) do + GenServer.cast(state, {:add_channel, channel}) + end + + @doc "Removes a channel from the list of joined channels of the default State process, if it exists." + def remove_channel(channel), do: remove_channel(__MODULE__, channel) + + @doc "Removes a channel from the list of joined channels of the given State process, if it exists." + def remove_channel(state, channel) do + GenServer.cast(state, {:remove_channel, channel}) + end + + ## Server API + + @impl true + def init(state) do + {:ok, state} + end + + @impl true + def handle_call(:cfg, _from, state) do + {:reply, state.cfg, state} + end + + @impl true + def handle_call(:channels, _from, state) do + {:reply, state.channels, state} + end + + @impl true + def handle_cast({:add_channel, channel}, state) do + {:noreply, %{state | channels: state.channels |> MapSet.put(channel)}} + end + + @impl true + def handle_cast({:remove_channel, channel}, state) do + {:noreply, %{state | channels: state.channels |> MapSet.delete(channel)}} + end +end diff --git a/lib/supervisor.ex b/lib/supervisor.ex new file mode 100644 index 0000000..ce80002 --- /dev/null +++ b/lib/supervisor.ex @@ -0,0 +1,29 @@ +defmodule Omnibot.Supervisor do + @moduledoc false + + use Supervisor + + def start_link(opts) do + Supervisor.start_link(__MODULE__, :ok, opts) + end + + @impl true + def init(:ok) do + {_, bindings} = Code.eval_file("omnibot.exs") + cfg = bindings[:config] + + children = [ + {Task.Supervisor, name: Omnibot.RouterSupervisor, strategy: :one_for_one}, + {Omnibot.State, cfg: cfg, name: Omnibot.State}, + {Omnibot.Irc, name: Omnibot.Irc}, + {Omnibot.ModuleSupervisor, cfg: cfg, name: Omnibot.ModuleSupervisor} + ] + + # TODO : how to handle config reloading? + # TODO : how to start up modules? + + # :one_for_all here because the RouterSupervisor and IRC server are co-dependent + Supervisor.init(children, strategy: :one_for_all) + end +end + diff --git a/lib/util.ex b/lib/util.ex new file mode 100644 index 0000000..1e1b41a --- /dev/null +++ b/lib/util.ex @@ -0,0 +1,5 @@ +defmodule Omnibot.Util do + def string_empty?(s), do: String.length(s) == 0 + + def string_or_nil(s), do: if(string_empty?(s), do: nil, else: s) +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..b742bff --- /dev/null +++ b/mix.exs @@ -0,0 +1,37 @@ +defmodule Omnibot.MixProject do + use Mix.Project + + def project do + [ + app: :omnibot, + version: "0.1.0", + elixir: "~> 1.10", + start_permanent: Mix.env() == :prod, + deps: deps(), + aliases: aliases(), + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger], + mod: {Omnibot, []} + ] + end + + defp aliases do + [ + c: ["clean", "compile --warnings-as-errors"], + test: ["test --no-start"], + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end +end diff --git a/omnibot.example.exs b/omnibot.example.exs new file mode 100644 index 0000000..d9077fa --- /dev/null +++ b/omnibot.example.exs @@ -0,0 +1,14 @@ +alias Omnibot.Config + +config = %Config { + nick: "omnibot_testing", + server: "irc.bonerjamz.us", + port: 6667, + ssl: false, + + modules: [ + {Omnibot.Contrib.Fortune, channels: ["#idleville"]}, + ], + + module_paths: [{"modules", recurse: true}] +} diff --git a/test/config_test.exs b/test/config_test.exs new file mode 100644 index 0000000..a9e8bed --- /dev/null +++ b/test/config_test.exs @@ -0,0 +1,24 @@ +defmodule ConfigTest do + use ExUnit.Case + + alias Omnibot.Config + + test "config all_channels works correctly" do + cfg = %Config { + server: "test", + modules: [ + {Test, channels: ["#foo", "#bar"]}, + {Test, channels: ["#foo"]}, + {Test, channels: ["#bar"]}, + {Test, channels: ["#baz"]}, + ] + } + + channels = Config.all_channels(cfg) + + assert length(channels) == 3 + assert Enum.any?(channels, fn channel -> channel == "#foo" end) + assert Enum.any?(channels, fn channel -> channel == "#bar" end) + assert Enum.any?(channels, fn channel -> channel == "#baz" end) + end +end diff --git a/test/irc/msg_test.exs b/test/irc/msg_test.exs new file mode 100644 index 0000000..32a8b31 --- /dev/null +++ b/test/irc/msg_test.exs @@ -0,0 +1,76 @@ +alias Omnibot.Irc +alias Omnibot.Msg + +defmodule Omnibot.Irc.MsgTest do + use ExUnit.Case + + # doctest Irc + + test "irc message parsing" do + assert %Irc.Msg{ + prefix: %Irc.Msg.Prefix{nick: "example.com"}, + command: "PRIVMSG", + params: [], + } == Irc.Msg.parse(":example.com PRIVMSG") + + assert %Irc.Msg{ + prefix: %Irc.Msg.Prefix{nick: "example.com"}, + command: "PRIVMSG", + params: ["#channel", "message text"], + } == Irc.Msg.parse(":example.com PRIVMSG #channel :message text") + + assert %Irc.Msg{ + prefix: %Irc.Msg.Prefix{nick: "example.com"}, + command: "PRIVMSG", + params: ["#channel", "message", "text"], + } == Irc.Msg.parse(":example.com PRIVMSG #channel message text") + end + + test "irc message prefix parsing" do + alias Irc.Msg.Prefix + assert Prefix.parse(":example.com") != %Prefix{} + + %Prefix{ + nick: "example.com" + } = Prefix.parse("example.com") + + %Prefix{ + nick: "nick" + } = Prefix.parse("nick") + + %Prefix{ + nick: "nick", + user: "username" + } = Prefix.parse("nick!username") + + %Prefix{ + nick: "nick", + user: "username", + host: "example.com" + } = Prefix.parse("nick!username@example.com") + end + + test "irc message prefix to_string" do + alias Irc.Msg.Prefix + + prefixes = [ + "example.com", + "nick!username", + "nick!username@example.com" + ] + + for prefix <- prefixes, + do: assert(Prefix.parse(prefix) |> to_string() == prefix) + end + + test "irc message to_string" do + alias Irc.Msg + + msgs = [ + ":example.com PRIVMSG #command", + ":example.com PRIVMSG #channel :message text" + ] + + for msg <- msgs, do: assert(Msg.parse(msg) |> to_string() == msg) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/test/util_test.exs b/test/util_test.exs new file mode 100644 index 0000000..34eec7f --- /dev/null +++ b/test/util_test.exs @@ -0,0 +1,15 @@ +alias Omnibot.Util + +defmodule Omnibot.UtilTest do + use ExUnit.Case + + test "string_empty?" do + assert Util.string_empty?("") + assert !Util.string_empty?("asdf") + end + + test "string_or_nil" do + assert Util.string_or_nil("") == nil + assert Util.string_or_nil("asdf") == "asdf" + end +end