Polish off wordbot implementation, add Omnibot.Module.GenServer

Wordbot implementation now uses the new Omnibot.Module.GenServer module,
which uses a GenServer instead of an Agent. This way, the module can
receive messages and makes storage a little easier.

Beyond that, minor changes all around.

Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
This commit is contained in:
2020-07-02 16:23:38 -07:00
parent 67ba7a5847
commit 7f8a886550
8 changed files with 222 additions and 22 deletions

View File

@@ -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 <title> 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>.+)</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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)