From 67ba7a5847526b965a40415c1b1cf1d347795c17 Mon Sep 17 00:00:00 2001 From: Alek Ratzloff Date: Wed, 1 Jul 2020 12:04:51 -0700 Subject: [PATCH] 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 --- lib/contrib/wordbot.ex | 28 +++++ lib/contrib/wordbot/bot.ex | 35 ++++++ lib/contrib/wordbot/db.ex | 189 +++++++++++++++++++++++++++++++ lib/irc.ex | 1 - lib/module/agent.ex | 2 +- lib/supervisor.ex | 1 + lib/util.ex | 4 + mix.exs | 6 +- mix.lock | 12 ++ omnibot.example.exs | 1 + test/contrib/linkbot_test.exs | 2 +- test/contrib/wordbot/db_test.exs | 54 +++++++++ test/util_test.exs | 2 +- 13 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 lib/contrib/wordbot.ex create mode 100644 lib/contrib/wordbot/bot.ex create mode 100644 lib/contrib/wordbot/db.ex create mode 100644 test/contrib/wordbot/db_test.exs diff --git a/lib/contrib/wordbot.ex b/lib/contrib/wordbot.ex new file mode 100644 index 0000000..b7f2223 --- /dev/null +++ b/lib/contrib/wordbot.ex @@ -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 diff --git a/lib/contrib/wordbot/bot.ex b/lib/contrib/wordbot/bot.ex new file mode 100644 index 0000000..9eb9881 --- /dev/null +++ b/lib/contrib/wordbot/bot.ex @@ -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 diff --git a/lib/contrib/wordbot/db.ex b/lib/contrib/wordbot/db.ex new file mode 100644 index 0000000..ce55c1e --- /dev/null +++ b/lib/contrib/wordbot/db.ex @@ -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 diff --git a/lib/irc.ex b/lib/irc.ex index bad4842..4f1a053 100644 --- a/lib/irc.ex +++ b/lib/irc.ex @@ -1,4 +1,3 @@ -# REWRITE defmodule Omnibot.Irc do require Logger alias Omnibot.Irc.Msg diff --git a/lib/module/agent.ex b/lib/module/agent.ex index a023f76..3e2c97d 100644 --- a/lib/module/agent.ex +++ b/lib/module/agent.ex @@ -18,7 +18,7 @@ defmodule Omnibot.Module.Agent do def update_state(update, timeout \\ 5000), do: Module.Agent.update_state(__MODULE__, update, timeout) - end + end end def start_link(cfg, state, opts) do diff --git a/lib/supervisor.ex b/lib/supervisor.ex index e1af38a..d18d649 100644 --- a/lib/supervisor.ex +++ b/lib/supervisor.ex @@ -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) diff --git a/lib/util.ex b/lib/util.ex index 1e1b41a..251e091 100644 --- a/lib/util.ex +++ b/lib/util.ex @@ -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 diff --git a/mix.exs b/mix.exs index 191b72d..c17db13 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock index 97dc307..b1db375 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, } diff --git a/omnibot.example.exs b/omnibot.example.exs index e16bd43..6041b9d 100644 --- a/omnibot.example.exs +++ b/omnibot.example.exs @@ -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}] diff --git a/test/contrib/linkbot_test.exs b/test/contrib/linkbot_test.exs index 9fcad67..632730e 100644 --- a/test/contrib/linkbot_test.exs +++ b/test/contrib/linkbot_test.exs @@ -1,5 +1,5 @@ defmodule LinkbotTest do - use ExUnit.Case + use ExUnit.Case, async: true alias Omnibot.Contrib.Linkbot diff --git a/test/contrib/wordbot/db_test.exs b/test/contrib/wordbot/db_test.exs new file mode 100644 index 0000000..f8f51e1 --- /dev/null +++ b/test/contrib/wordbot/db_test.exs @@ -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 diff --git a/test/util_test.exs b/test/util_test.exs index 34eec7f..d7921e5 100644 --- a/test/util_test.exs +++ b/test/util_test.exs @@ -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?("")