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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
50
lib/module/gen_server.ex
Normal file
50
lib/module/gen_server.ex
Normal file
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user