WIP: Wordbot

Wordbot is a little more complex of a bot module and I've been working
on it here.

Other than wordbot module, a few minor tweaks have been added all around
that don't really affect anything.

Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
This commit is contained in:
2020-07-01 12:04:51 -07:00
parent 871b7771fe
commit 67ba7a5847
13 changed files with 332 additions and 5 deletions

28
lib/contrib/wordbot.ex Normal file
View File

@@ -0,0 +1,28 @@
defmodule Omnibot.Contrib.Wordbot do
use Omnibot.Module.Base
use Supervisor
require Logger
alias Omnibot.Contrib.Wordbot
@default_config wordbot_source: "words.txt", wordbot_db: "wordbot.db"
def start_link(opts) do
Supervisor.start_link(__MODULE__, opts[:cfg], opts)
end
@impl true
def init(cfg) do
children = [
{Task.Supervisor, name: Omnibot.Contrib.Wordbot.Watchers, strategy: :one_for_one},
Wordbot.Db.child_spec(cfg[:wordbot_db]),
{Wordbot.Bot, cfg: cfg, name: Omnibot.Contrib.Wordbot.Bot},
]
Supervisor.init(children, strategy: :one_for_all)
end
def on_msg(irc, msg), do: Wordbot.Bot.on_msg(irc, msg)
def on_channel_msg(irc, channel, nick, msg), do: Wordbot.Bot.on_channel_msg(irc, channel, nick, msg)
end

View File

@@ -0,0 +1,35 @@
defmodule Omnibot.Contrib.Wordbot.Bot do
use Omnibot.Module
alias Omnibot.Contrib.Wordbot
@split_pattern ~r/[\s\b]+/
command "!wordbot", ["leaderboard"] do
Irc.send_to(irc, channel, "leaderboard logic here")
end
@impl true
def on_init(cfg) do
Wordbot.Db.ensure_db()
File.read!(cfg[:wordbot_source])
|> String.split("\n")
end
@impl true
def on_channel_msg(irc, channel, nick, msg) do
words = Regex.split(@split_pattern, msg) |> MapSet.new()
game_words = Wordbot.Db.unmatched_words(channel) |> MapSet.new()
MapSet.intersection(words, game_words)
|> Enum.each(fn word ->
Wordbot.Db.add_score(channel, nick, word, msg)
Irc.send_to(irc, channel, "#{nick}: Congrats! '#{word}' is good for 1 point.")
end)
end
@impl true
def on_join(_irc, _channel, _who) do
# TODO start games
# * Tasks for watching games(?)
end
end

189
lib/contrib/wordbot/db.ex Normal file
View File

@@ -0,0 +1,189 @@
defmodule Omnibot.Contrib.Wordbot.Db do
alias Omnibot.Util
# SQL for creating the new database
@database_sql ~S"""
CREATE TABLE IF NOT EXISTS game (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
start INTEGER NOT NULL,
end INTEGER NOT NULL,
channel VARCHAR(40) NOT NULL
);
CREATE TABLE IF NOT EXISTS word (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
game INTEGER NOT NULL,
word VARCHAR(40) NOT NULL,
FOREIGN KEY (game) REFERENCES game(id),
UNIQUE(game, word)
);
CREATE TABLE IF NOT EXISTS score (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
game INTEGER NOT NULL,
word INTEGER NOT NULL,
user VARCHAR(40) NOT NULL,
line VARCHAR(1024) NOT NULL,
FOREIGN KEY (game) REFERENCES game(id),
FOREIGN KEY (word) REFERENCES word(id),
UNIQUE(game, word)
);
"""
def ensure_db() do
Sqlitex.Server.exec(__MODULE__, @database_sql)
end
@doc "Gets the words for the current round in the given channel."
def words(channel) do
id = game_id!(channel)
{:ok, rows} =
Sqlitex.Server.query(__MODULE__, "SELECT word FROM word WHERE game = ?1", bind: [id])
for row <- rows, do: row[:word]
end
@doc """
Starts a new round for the given channel in the database.
## Options
* `:end_early` - if `true`, this will create a new game even though there is
a game already running. Default is false.
## Returns
* `:ok` if a new round was started.
* `{:error, :game_running}` if a game couldn't be started because one was already running.
"""
def start_round(channel, words, duration, opts \\ []) do
end_early = Access.get(opts, :end_early, false)
if !game_active?(channel) or end_early do
start = Util.now_unix()
end_ = start + duration
with {:ok, _} <-
Sqlitex.Server.query(
__MODULE__,
"INSERT INTO game (start, end, channel) VALUES (?1, ?2, ?3)",
bind: [start, end_, channel]
),
{:ok, game_id} = game_id(channel) do
Enum.each(
words,
fn word ->
{:ok, _} =
Sqlitex.Server.query(__MODULE__, "INSERT INTO word (game, word) VALUES(?1, ?2)",
bind: [game_id, word]
)
end
)
end
:ok
else
{:error, :game_running}
end
end
def game_active?(channel) do
case game_id(channel) do
{:ok, _id} -> true
{:error, :no_game} -> false
end
end
def child_spec(wordbot_db) do
%{
id: Sqlitex.Server,
start: {Sqlitex.Server, :start_link, [wordbot_db, [name: __MODULE__]]}
}
end
@doc """
Gets the ID of the currently running game.
## Returns
* {:ok, id} on success
* {:error, :no_game} when no game is running for this channel
"""
def game_id(channel) do
now = Util.now_unix()
id =
with {:ok, rows} <-
Sqlitex.Server.query(
__MODULE__,
"SELECT id FROM game WHERE channel = ?1 AND end > ?2 ORDER BY id DESC",
bind: [channel, now]
),
[id | _] <- Enum.map(rows, & &1[:id]),
do: id
case id do
[] -> {:error, :no_game}
id -> {:ok, id}
end
end
def game_id!(channel) do
{:ok, id} = game_id(channel)
id
end
def last_game_id(channel) do
id =
with {:ok, rows} <-
Sqlitex.Server.query(
__MODULE__,
"SELECT id FROM game WHERE channel = ?1 ORDER BY id DESC",
bind: [channel]
),
[id | _] <- for(row <- rows, do: row[:id]),
do: id
case id do
[] -> {:error, :no_game}
id -> {:ok, id}
end
end
def add_score(channel, user, word, line) do
id = game_id!(channel)
{:ok, _} = Sqlitex.Server.query(
__MODULE__,
"""
INSERT INTO score (game, word, user, line)
VALUES (?1, (SELECT word.id FROM word WHERE game = ?1 AND word = ?2), ?3, ?4)
""", bind: [id, word, user, line])
end
def scores(channel) do
id = game_id!(channel)
{:ok, rows} = Sqlitex.Server.query(
__MODULE__,
"""
SELECT user, COUNT(score.id) AS score FROM score
JOIN game ON score.game = game.id
WHERE game.id = ?1
GROUP BY user
""",
bind: [id]
)
Enum.map(rows, &Map.new/1)
end
@doc "Gets all words that have not been scored on from the given channel."
def unmatched_words(channel) do
id = game_id!(channel)
{:ok, rows} = Sqlitex.Server.query(
__MODULE__,
"""
SELECT word FROM word
WHERE word.game = ?1
AND id NOT IN (SELECT score.word FROM score WHERE game = ?1)
""",
bind: [id]
)
Enum.map(rows, &(&1[:word]))
end
end

View File

@@ -1,4 +1,3 @@
# REWRITE
defmodule Omnibot.Irc do
require Logger
alias Omnibot.Irc.Msg

View File

@@ -2,6 +2,7 @@ defmodule Omnibot.Supervisor do
@moduledoc false
use Supervisor
require IEx
def start_link(opts) do
Supervisor.start_link(__MODULE__, :ok, opts)

View File

@@ -2,4 +2,8 @@ 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)
def now_unix, do: now_unix("Etc/UTC")
def now_unix(tz), do: DateTime.now!(tz) |> DateTime.to_unix()
end

View File

@@ -29,6 +29,10 @@ defmodule Omnibot.MixProject do
# Run "mix help deps" to learn about dependencies.
defp deps do
[{:tesla, "~> 1.3.0"}]
# TODO : figure out how to make contrib modules optional (umbrella project?) and enable specific requirements
[
{:tesla, "~> 1.3.0"}, # Used by Omnibot.Contrib.Linkbot
{:sqlitex, "~> 1.7"}, # Used by Omnibot.Contrib.Wordbot
]
end
end

View File

@@ -1,4 +1,16 @@
%{
"certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm"},
"esqlite": {:hex, :esqlite, "0.4.1", "ba5d0bab6b9c8432ffe1bf12fee8e154a50f1c3c40eadc3a9c870c23ca94d961", [:rebar3], [], "hexpm"},
"hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
"sqlitex": {:hex, :sqlitex, "1.7.1", "022d477aab2ae999c43ae6fbd1782ff1457e0e95c251c7b5fa6f7b7b102040ff", [:mix], [{:decimal, "~> 1.7", [hex: :decimal, repo: "hexpm", optional: false]}, {:esqlite, "~> 0.4", [hex: :esqlite, repo: "hexpm", optional: false]}], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm"},
"tesla": {:hex, :tesla, "1.3.3", "26ae98627af5c406584aa6755ab5fc96315d70d69a24dd7f8369cfcb75094a45", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"},
"tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm"},
}

View File

@@ -9,6 +9,7 @@ config = %Config {
modules: [
{Omnibot.Contrib.Linkbot, channels: :all},
{Omnibot.Contrib.Fortune, channels: ["#idleville"]},
{Omnibot.Contrib.Wordbot, channels: ["#idleville"]},
],
#module_paths: [{"modules", recurse: true}]

View File

@@ -1,5 +1,5 @@
defmodule LinkbotTest do
use ExUnit.Case
use ExUnit.Case, async: true
alias Omnibot.Contrib.Linkbot

View File

@@ -0,0 +1,54 @@
defmodule WordbotDbTest do
use ExUnit.Case
alias Omnibot.Contrib.Wordbot
setup do
start_supervised!(Wordbot.Db.child_spec(":memory:"))
Wordbot.Db.ensure_db()
:ok
end
test "game starts round correctly" do
:ok = Wordbot.Db.start_round("test", [], 60)
assert Wordbot.Db.game_id!("test") == 1
:ok = Wordbot.Db.start_round("foo", [], 60)
assert Wordbot.Db.game_id!("foo") == 2
{:error, :game_running} = Wordbot.Db.start_round("foo", [], 60)
:ok = Wordbot.Db.start_round("foo", [], 60, end_early: true)
assert Wordbot.Db.game_id!("foo") == 3
end
test "game keeps track of words" do
:ok = Wordbot.Db.start_round("test", ~w(a b c d), 60)
assert Wordbot.Db.words("test") == ~w(a b c d)
:ok = Wordbot.Db.start_round("foo", ~w(e f g h), 60)
assert Wordbot.Db.words("foo") == ~w(e f g h)
end
test "game keeps track of scores" do
:ok = Wordbot.Db.start_round("test", ~w(a b c d), 60)
Wordbot.Db.add_score("test", "user1", "a", "this is a line")
Wordbot.Db.add_score("test", "user1", "b", "this is b line")
Wordbot.Db.add_score("test", "user2", "c", "this is b line")
scores = Wordbot.Db.scores("test")
assert Enum.member?(scores, %{user: "user1", score: 2})
assert Enum.member?(scores, %{user: "user2", score: 1})
:ok = Wordbot.Db.start_round("test", ~w(a b c d), 60, end_early: true)
scores = Wordbot.Db.scores("test")
assert scores == []
end
test "game keeps track of unmatched words" do
:ok = Wordbot.Db.start_round("test", ~w(a b c d), 60)
assert Wordbot.Db.unmatched_words("test") == ~w(a b c d)
Wordbot.Db.add_score("test", "user1", "a", "this is a line")
Wordbot.Db.add_score("test", "user1", "b", "this is a line")
Wordbot.Db.add_score("test", "user1", "d", "this is a line")
assert Wordbot.Db.unmatched_words("test") == ~w(c)
end
end

View File

@@ -1,7 +1,7 @@
alias Omnibot.Util
defmodule Omnibot.UtilTest do
use ExUnit.Case
use ExUnit.Case, async: true
test "string_empty?" do
assert Util.string_empty?("")