diff --git a/lib/contrib/linkbot.ex b/lib/contrib/linkbot.ex
index 70ebb81..8e0b989 100644
--- a/lib/contrib/linkbot.ex
+++ b/lib/contrib/linkbot.ex
@@ -18,6 +18,11 @@ defmodule Omnibot.Contrib.Linkbot do
plug Tesla.Middleware.FollowRedirects, max_redirects: 10
plug Tesla.Middleware.Compression, format: "gzip"
+ # TODO instead of checking for
exclusively, do this:
+ # 1. check for "meta" tag (in the header) with a "property" attribute of "og:title", and fetch the "content" attribute of that tag
+ # 2. check for meta tag with attribute "name" == "title", and fetch "content" attribute
+ # 3. Fall back to the
+
@title_regex ~r"(?.+)"i
def get_title(url) do
@@ -50,4 +55,5 @@ defmodule Omnibot.Contrib.Linkbot do
|> Enum.map(fn url -> Client.get_title(url) end)
|> Enum.each(fn title -> Irc.send_to(irc, channel, title) end)
end
+
end
diff --git a/lib/contrib/wordbot.ex b/lib/contrib/wordbot.ex
index b7f2223..94dcd1a 100644
--- a/lib/contrib/wordbot.ex
+++ b/lib/contrib/wordbot.ex
@@ -5,7 +5,7 @@ defmodule Omnibot.Contrib.Wordbot do
alias Omnibot.Contrib.Wordbot
- @default_config wordbot_source: "words.txt", wordbot_db: "wordbot.db"
+ @default_config wordbot_source: "words.txt", wordbot_db: "wordbot.db", words_per_round: 300, hours_per_round: 5
def start_link(opts) do
Supervisor.start_link(__MODULE__, opts[:cfg], opts)
diff --git a/lib/contrib/wordbot/bot.ex b/lib/contrib/wordbot/bot.ex
index 9eb9881..94237a7 100644
--- a/lib/contrib/wordbot/bot.ex
+++ b/lib/contrib/wordbot/bot.ex
@@ -1,19 +1,142 @@
defmodule Omnibot.Contrib.Wordbot.Bot do
- use Omnibot.Module
+ use Omnibot.Module.Base
+ use Omnibot.Module.GenServer
- alias Omnibot.Contrib.Wordbot
+ alias Omnibot.{Contrib.Wordbot, Irc, State}
+ require Logger
@split_pattern ~r/[\s\b]+/
+ ## Bot commands
+
command "!wordbot", ["leaderboard"] do
Irc.send_to(irc, channel, "leaderboard logic here")
end
+ ## Client API
+
+ def words() do
+ {words, _watchers} = state()
+ words
+ end
+
+ defp watchers() do
+ {_words, watchers} = state()
+ watchers
+ end
+
+ defp update_watchers(mapping) do
+ update_state(fn {words, watchers} -> {words, apply(mapping, [watchers])} end)
+ end
+
+ defp add_watcher(channel, task) do
+ update_watchers(&Map.put(&1, channel, task))
+ end
+
+ defp delete_watcher(channel) do
+ task = watchers()[channel]
+ update_watchers(&Map.delete(&1, channel))
+ task
+ end
+
+ defp lookup_watcher(channel) do
+ Map.get(watchers(), channel)
+ end
+
+ defp has_watcher?(channel) do
+ case lookup_watcher(channel) do
+ nil -> false
+ task -> Process.alive?(task)
+ end
+ end
+
+ def start_round(irc, channel) do
+ # Get round config
+ cfg = cfg()
+ num_words = cfg[:words_per_round]
+ duration = cfg[:hours_per_round] * 3600
+ # Select words
+ #
+ #
+ # TODO - the words being added to a new round takes a nontrivial amount of time for the default.
+ # this isn't a huge deal, but it does seem to be stopping up the other bots from operating correctly.
+ # figure out why this is the case.
+ #
+ words = Enum.take_random(words(), num_words)
+ # Try to start the round - if it's already running then that's OK
+ case Wordbot.Db.start_round(channel, words, duration) do
+ :ok -> Logger.debug("Started new wordbot round for #{channel}")
+ {:error, :game_running} -> Logger.debug("Wordbot game already running for #{channel}")
+ end
+
+ # Try to start a watcher if there isn't one running
+ if !has_watcher?(channel),
+ do: start_watcher(irc, channel)
+ end
+
+ defp start_watcher(irc, channel) do
+ # Start a watcher for the given channel
+ Logger.debug("Starting wordbot game watcher for #{channel}")
+ # Assert that there isn't a running watcher for the current channel
+ false = has_watcher?(channel)
+ task = Task.Supervisor.async_nolink(
+ Wordbot.Watchers,
+ fn -> watch_game(irc, channel) end,
+ [shutdown: :brutal_kill]
+ )
+ add_watcher(channel, task)
+ end
+
+ defp watch_game(irc, channel) do
+ # Poll every second to check if a game is finished
+ if Wordbot.Db.game_active?(channel) do
+ Process.sleep(1000)
+ watch_game(irc, channel)
+ else
+ finish_round(irc, channel)
+ end
+ end
+
+ def finish_round(irc, channel) do
+ Logger.debug("Finishing wordbot round for #{channel}")
+
+ # Announce scores
+ Irc.send_to(irc, channel, "Game over. Here were the scores:")
+ scores = Wordbot.Db.scores(channel)
+ |> Enum.sort_by(&(&1.score))
+ |> Enum.reverse()
+
+ # Ranking is a little weird because we want to rank people so that having
+ # the same score will give the same ranking, e.g.
+ # 1. user1. 4
+ # 2. user2. 3
+ # 2. user3. 3
+ # 3. user4. 1
+ rankings = scores
+ |> Enum.map(&(&1.score))
+ |> Enum.sort()
+ |> Enum.uniq()
+ |> Enum.reverse()
+ |> Enum.with_index()
+ |> Map.new()
+ |> IO.inspect()
+
+ Enum.each(scores, &Irc.send_to(irc, channel, "#{rankings[&1.score] + 1}. #{&1.user}. #{&1.score}"))
+
+ # Stop the watcher, start new round
+ delete_watcher(channel)
+ start_round(irc, channel)
+ end
+
+ ## Module callbacks
+
@impl true
def on_init(cfg) do
Wordbot.Db.ensure_db()
- File.read!(cfg[:wordbot_source])
- |> String.split("\n")
+ words = File.read!(cfg[:wordbot_source])
+ |> String.split("\n")
+ watchers = %{}
+ {words, watchers}
end
@impl true
@@ -28,8 +151,10 @@ defmodule Omnibot.Contrib.Wordbot.Bot do
end
@impl true
- def on_join(_irc, _channel, _who) do
- # TODO start games
- # * Tasks for watching games(?)
+ def on_join(irc, channel, who) do
+ # Attempt to start a new round
+ if State.cfg().nick == who do
+ start_round(irc, channel)
+ end
end
end
diff --git a/lib/contrib/wordbot/db.ex b/lib/contrib/wordbot/db.ex
index ce55c1e..0495421 100644
--- a/lib/contrib/wordbot/db.ex
+++ b/lib/contrib/wordbot/db.ex
@@ -68,14 +68,15 @@ defmodule Omnibot.Contrib.Wordbot.Db do
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
+
+ # Much faster to just prepare everything in one go rather than enumerating all words
+ pattern = (0 .. length(words))
+ |> Enum.map(&"(?1, ?#{&1 + 1})")
+ |> Enum.join(", ")
+ Sqlitex.Server.query(
+ __MODULE__,
+ "INSERT INTO word (game, word) VALUES #{pattern}",
+ bind: [game_id | words]
)
end
:ok
@@ -158,7 +159,7 @@ defmodule Omnibot.Contrib.Wordbot.Db do
end
def scores(channel) do
- id = game_id!(channel)
+ {:ok, id} = last_game_id(channel)
{:ok, rows} = Sqlitex.Server.query(
__MODULE__,
"""
@@ -172,6 +173,20 @@ defmodule Omnibot.Contrib.Wordbot.Db do
Enum.map(rows, &Map.new/1)
end
+ def leaderboard(channel) do
+ {:ok, rows} = Sqlitex.Server.query(
+ __MODULE__,
+ """
+ SELECT user, COUNT(score.id) AS score FROM score
+ JOIN game ON score.game = game.id
+ WHERE game.channel = ?1
+ GROUP BY user
+ """,
+ bind: [channel]
+ )
+ 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)
diff --git a/lib/module/agent.ex b/lib/module/agent.ex
index 3e2c97d..3c53cf4 100644
--- a/lib/module/agent.ex
+++ b/lib/module/agent.ex
@@ -1,15 +1,12 @@
defmodule Omnibot.Module.Agent do
defmacro __using__([]) do
quote do
- import Omnibot.Module.Agent
alias Omnibot.Module
use Agent
def start_link(opts) do
cfg = opts[:cfg]
state = opts[:state] || on_init(cfg)
-
- IO.inspect({__MODULE__, state})
Module.Agent.start_link(cfg, state, opts)
end
@@ -18,7 +15,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/module/base.ex b/lib/module/base.ex
index 8e0aaea..0bafd3c 100644
--- a/lib/module/base.ex
+++ b/lib/module/base.ex
@@ -44,7 +44,6 @@ defmodule Omnibot.Module.Base do
defp route_msg(irc, msg) do
nick = msg.prefix.nick
-
case String.upcase(msg.command) do
"PRIVMSG" ->
[channel | params] = msg.params
diff --git a/lib/module/gen_server.ex b/lib/module/gen_server.ex
new file mode 100644
index 0000000..0ae03cc
--- /dev/null
+++ b/lib/module/gen_server.ex
@@ -0,0 +1,50 @@
+defmodule Omnibot.Module.GenServer do
+ defmacro __using__([]) do
+ quote do
+ alias Omnibot.Module
+ use GenServer
+
+ def start_link(opts) do
+ cfg = opts[:cfg]
+ state = opts[:state]
+ GenServer.start_link(__MODULE__, {cfg, state}, opts)
+ end
+
+ def cfg() do
+ GenServer.call(__MODULE__, :cfg)
+ end
+
+ def state() do
+ GenServer.call(__MODULE__, :state)
+ end
+
+ def update_state(update) do
+ GenServer.cast(__MODULE__, {:state, update})
+ end
+
+ ## Server callbacks
+
+ @impl GenServer
+ def init({cfg, state}) do
+ state = state || on_init(cfg)
+ {:ok, {cfg, state}}
+ end
+
+ @impl GenServer
+ def handle_call(:cfg, _from, {cfg, state}) do
+ {:reply, cfg, {cfg, state}}
+ end
+
+ @impl GenServer
+ def handle_call(:state, _from, {cfg, state}) do
+ {:reply, state, {cfg, state}}
+ end
+
+ @impl GenServer
+ def handle_cast({:state, update}, {cfg, state}) do
+ {:noreply, {cfg, apply(update, [state])}}
+ end
+ end
+ end
+end
+
diff --git a/lib/util.ex b/lib/util.ex
index 251e091..861bddd 100644
--- a/lib/util.ex
+++ b/lib/util.ex
@@ -6,4 +6,12 @@ defmodule Omnibot.Util do
def now_unix, do: now_unix("Etc/UTC")
def now_unix(tz), do: DateTime.now!(tz) |> DateTime.to_unix()
+
+ @doc """
+ Inserts a zero-width space character inside of a nickname so that it won't
+ create a notification for that user.
+ """
+ def denotify_nick(nick) do
+ Enum.join(nick, "\u200b")
+ end
end