Initial commit with IRC and bot example.

Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
This commit is contained in:
2020-06-12 17:29:35 -04:00
commit 6340936895
20 changed files with 793 additions and 0 deletions

52
lib/config.ex Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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