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

4
.formatter.exs Normal file
View File

@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

46
.gitignore vendored Normal file
View 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
View 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
View 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
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

37
mix.exs Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
ExUnit.start()

15
test/util_test.exs Normal file
View 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