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> + @title_regex ~r"<title>(?<title>.+)"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