Initial commit with IRC and bot example.
Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
This commit is contained in:
4
.formatter.exs
Normal file
4
.formatter.exs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Used by "mix format"
|
||||||
|
[
|
||||||
|
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||||
|
]
|
||||||
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@@ -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
|
||||||
|
|
||||||
21
README.md
Normal file
21
README.md
Normal file
@@ -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).
|
||||||
|
|
||||||
7
config/config.exs
Normal file
7
config/config.exs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
use Mix.Config
|
||||||
|
|
||||||
|
config :logger,
|
||||||
|
backends: [:console],
|
||||||
|
compile_time_purge_matching: [
|
||||||
|
[level_lower_than: :debug]
|
||||||
|
]
|
||||||
52
lib/config.ex
Normal file
52
lib/config.ex
Normal file
@@ -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
|
||||||
57
lib/contrib/fortune.ex
Normal file
57
lib/contrib/fortune.ex
Normal file
@@ -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
|
||||||
99
lib/irc.ex
Normal file
99
lib/irc.ex
Normal file
@@ -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
|
||||||
109
lib/irc/msg.ex
Normal file
109
lib/irc/msg.ex
Normal file
@@ -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/(?<nick>[^!]+)(!(?<user>[^@]+)(@(?<host>.+))?)?/
|
||||||
|
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<prefix>[^ ]+)\ )?
|
||||||
|
(?<command>[a-zA-Z]+|[0-9]{3})
|
||||||
|
(?<params>(\ [^: \r\n]+)*)
|
||||||
|
(?<trailing>\ :[^\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
|
||||||
52
lib/module_supervisor.ex
Normal file
52
lib/module_supervisor.ex
Normal file
@@ -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
|
||||||
10
lib/omnibot.ex
Normal file
10
lib/omnibot.ex
Normal file
@@ -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
|
||||||
61
lib/router.ex
Normal file
61
lib/router.ex
Normal file
@@ -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
|
||||||
74
lib/state.ex
Normal file
74
lib/state.ex
Normal file
@@ -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
|
||||||
29
lib/supervisor.ex
Normal file
29
lib/supervisor.ex
Normal file
@@ -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
|
||||||
|
|
||||||
5
lib/util.ex
Normal file
5
lib/util.ex
Normal file
@@ -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
|
||||||
37
mix.exs
Normal file
37
mix.exs
Normal file
@@ -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
|
||||||
14
omnibot.example.exs
Normal file
14
omnibot.example.exs
Normal file
@@ -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}]
|
||||||
|
}
|
||||||
24
test/config_test.exs
Normal file
24
test/config_test.exs
Normal file
@@ -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
|
||||||
76
test/irc/msg_test.exs
Normal file
76
test/irc/msg_test.exs
Normal file
@@ -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
|
||||||
1
test/test_helper.exs
Normal file
1
test/test_helper.exs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ExUnit.start()
|
||||||
15
test/util_test.exs
Normal file
15
test/util_test.exs
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user