Page MenuHomePhabricator

No OneTemporary

diff --git a/lib/irc/admin_handler.ex b/lib/irc/admin_handler.ex
index 282f3c2..a462789 100644
--- a/lib/irc/admin_handler.ex
+++ b/lib/irc/admin_handler.ex
@@ -1,41 +1,41 @@
defmodule Nola.Irc.AdminHandler do
@moduledoc """
# admin
!op
op; requiert admin
"""
def irc_doc, do: nil
def start_link(client) do
GenServer.start_link(__MODULE__, [client])
end
def init([client]) do
ExIRC.Client.add_handler client, self
:ok = IRC.register("op")
{:ok, client}
end
- def handle_info({:irc, :trigger, "op", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}, sender: sender}}, client) do
+ def handle_info({:irc, :trigger, "op", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}, sender: sender}}, client) do
if IRC.admin?(sender) do
m.replyfun.({:mode, "+o"})
else
m.replyfun.({:kick, "non"})
end
{:noreply, client}
end
def handle_info({:joined, chan, sender}, client) do
if IRC.admin?(sender) do
ExIRC.Client.mode(client, chan, "+o", sender.nick)
end
{:noreply, client}
end
def handle_info(msg, client) do
{:noreply, client}
end
end
diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex
index 9bb09ed..037b7d6 100644
--- a/lib/irc/connection.ex
+++ b/lib/irc/connection.ex
@@ -1,521 +1,521 @@
defmodule IRC.Connection do
require Logger
use Ecto.Schema
@moduledoc """
# IRC Connection
Provides a nicer abstraction over ExIRC's handlers.
## Start connections
```
IRC.Connection.start_link(host: "irc.random.sh", port: 6697, nick: "pouetbot", channels: ["#dev"])
## PubSub topics
* `account` -- accounts change
* {:account_change, old_account_id, new_account_id} # Sent when account merged
* {:accounts, [{:account, network, channel, nick, account_id}] # Sent on bot join
* {:account, network, nick, account_id} # Sent on user join
* `message` -- aill messages (without triggers)
* `message:private` -- all messages without a channel
* `message:#CHANNEL` -- all messages within `#CHANNEL`
* `triggers` -- all triggers
* `trigger:TRIGGER` -- any message with a trigger `TRIGGER`
- ## Replying to %IRC.Message{}
+ ## Replying to %Nola.Message{}
- Each `IRC.Message` comes with a dedicated `replyfun`, to which you only have to pass either:
+ Each `Nola.Message` comes with a dedicated `replyfun`, to which you only have to pass either:
"""
def irc_doc, do: nil
@min_backoff :timer.seconds(5)
@max_backoff :timer.seconds(2*60)
embedded_schema do
field :network, :string
field :host, :string
field :port, :integer
field :nick, :string
field :user, :string
field :name, :string
field :pass, :string
field :tls, :boolean, default: false
field :channels, {:array, :string}, default: []
end
defmodule Supervisor do
use DynamicSupervisor
def start_link() do
DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__)
end
def start_child(%IRC.Connection{} = conn) do
spec = %{id: conn.id, start: {IRC.Connection, :start_link, [conn]}, restart: :transient}
DynamicSupervisor.start_child(__MODULE__, spec)
end
@impl true
def init(_init_arg) do
DynamicSupervisor.init(
strategy: :one_for_one,
max_restarts: 10,
max_seconds: 1
)
end
end
def changeset(params) do
import Ecto.Changeset
%__MODULE__{id: EntropyString.large_id()}
|> cast(params, [:network, :host, :port, :nick, :user, :name, :pass, :channels, :tls])
|> validate_required([:host, :port, :nick, :user, :name])
|> apply_action(:insert)
end
def to_tuple(%__MODULE__{} = conn) do
{conn.id, conn.network, conn.host, conn.port, conn.nick, conn.user, conn.name, conn.pass, conn.tls, conn.channels, nil}
end
def from_tuple({id, network, host, port, nick, user, name, pass, tls, channels, _}) do
%__MODULE__{id: id, network: network, host: host, port: port, nick: nick, user: user, name: name, pass: pass, tls: tls, channels: channels}
end
## -- MANAGER API
def setup() do
:dets.open_file(dets(), [])
end
def dets(), do: to_charlist(Nola.data_path("/connections.dets"))
def lookup(id) do
case :dets.lookup(dets(), id) do
[object | _] -> from_tuple(object)
_ -> nil
end
end
def connections() do
:dets.foldl(fn(object, acc) -> [from_tuple(object) | acc] end, [], dets())
end
def start_all() do
for conn <- connections(), do: {conn, IRC.Connection.Supervisor.start_child(conn)}
end
def get_network(network, channel \\ nil) do
spec = [{{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_},
[{:==, :"$1", {:const, network}}], [:"$_"]}]
results = Enum.map(:dets.select(dets(), spec), fn(object) -> from_tuple(object) end)
if channel do
Enum.find(results, fn(conn) -> Enum.member?(conn.channels, channel) end)
else
List.first(results)
end
end
def get_host_nick(host, port, nick) do
spec = [{{:_, :_, :"$1", :"$2", :"$3", :_, :_, :_, :_, :_, :_},
[{:andalso,
{:andalso, {:==, :"$1", {:const, host}}, {:==, :"$2", {:const, port}}},
{:==, :"$3", {:const, nick}}}],
[:"$_"]}
]
case :dets.select(dets(), spec) do
[object] -> from_tuple(object)
[] -> nil
end
end
def delete_connection(%__MODULE__{id: id} = conn) do
:dets.delete(dets(), id)
stop_connection(conn)
:ok
end
def start_connection(%__MODULE__{} = conn) do
IRC.Connection.Supervisor.start_child(conn)
end
def stop_connection(%__MODULE__{id: id}) do
case :global.whereis_name(id) do
pid when is_pid(pid) ->
GenServer.stop(pid, :normal)
_ -> :error
end
end
def add_connection(opts) do
case changeset(opts) do
{:ok, conn} ->
if existing = get_host_nick(conn.host, conn.port, conn.nick) do
{:error, {:existing, conn}}
else
:dets.insert(dets(), to_tuple(conn))
IRC.Connection.Supervisor.start_child(conn)
end
error -> error
end
end
def update_connection(connection) do
:dets.insert(dets(), to_tuple(connection))
end
def start_link(conn) do
GenServer.start_link(__MODULE__, [conn], name: {:global, conn.id})
end
def broadcast_message(net, chan, message) do
dispatch("conn", {:broadcast, net, chan, message}, IRC.ConnectionPubSub)
end
def broadcast_message(list, message) when is_list(list) do
for {net, chan} <- list do
broadcast_message(net, chan, message)
end
end
def privmsg(channel, line) do
GenServer.cast(__MODULE__, {:privmsg, channel, line})
end
def init([conn]) do
Logger.metadata(conn: conn.id)
backoff = :backoff.init(@min_backoff, @max_backoff)
|> :backoff.type(:jitter)
{:ok, %{client: nil, backoff: backoff, conn: conn, connected_server: nil, connected_port: nil, network: conn.network}, {:continue, :connect}}
end
@triggers %{
"!" => :bang,
"+" => :plus,
"-" => :minus,
"?" => :query,
"." => :dot,
"~" => :tilde,
"@" => :at,
"++" => :plus_plus,
"--" => :minus_minus,
"!!" => :bang_bang,
"??" => :query_query,
".." => :dot_dot,
"~~" => :tilde_tilde,
"@@" => :at_at
}
def handle_continue(:connect, state) do
client_opts = []
|> Keyword.put(:network, state.conn.network)
{:ok, _} = Registry.register(IRC.ConnectionPubSub, "conn", [])
client = if state.client && Process.alive?(state.client) do
Logger.info("Reconnecting client")
state.client
else
Logger.info("Connecting")
{:ok, client} = ExIRC.Client.start_link(debug: false)
ExIRC.Client.add_handler(client, self())
client
end
opts = [{:nodelay, true}]
conn_fun = if state.conn.tls, do: :connect_ssl!, else: :connect!
apply(ExIRC.Client, conn_fun, [client, to_charlist(state.conn.host), state.conn.port, opts])
{:noreply, %{state | client: client}}
end
def handle_info(:disconnected, state) do
{delay, backoff} = :backoff.fail(state.backoff)
Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms")
Process.send_after(self(), :connect, delay)
{:noreply, %{state | backoff: backoff}}
end
def handle_info(:connect, state) do
{:noreply, state, {:continue, :connect}}
end
def handle_cast({:privmsg, channel, line}, state) do
irc_reply(state, {channel, nil}, line)
{:noreply, state}
end
# Connection successful
def handle_info({:connected, server, port}, state) do
Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}")
{_, backoff} = :backoff.succeed(state.backoff)
ExIRC.Client.logon(state.client, state.conn.pass || "", state.conn.nick, state.conn.user, state.conn.name)
{:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}}
end
# Logon successful
def handle_info(:logged_in, state) do
Logger.info("#{inspect(self())} Logged in")
{_, backoff} = :backoff.succeed(state.backoff)
Enum.map(state.conn.channels, &ExIRC.Client.join(state.client, &1))
{:noreply, %{state | backoff: backoff}}
end
# ISUP
def handle_info({:isup, network}, state) when is_binary(network) do
Nola.UserTrack.clear_network(state.network)
if network != state.network do
Logger.warn("Possibly misconfigured network: #{network} != #{state.network}")
end
{:noreply, state}
end
# Been kicked
def handle_info({:kicked, _sender, chan, _reason}, state) do
ExIRC.Client.join(state.client, chan)
{:noreply, state}
end
# Received something in a channel
def handle_info({:received, text, sender, chan}, state) do
user = if user = Nola.UserTrack.find_by_nick(state.network, sender.nick) do
user
else
Logger.error("Could not lookup user for message: #{inspect {state.network, chan, sender.nick}}")
user = Nola.UserTrack.joined(chan, sender, [])
ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user..
user
end
if !user do
ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user..
Logger.error("Could not lookup user nor create it for message: #{inspect {state.network, chan, sender.nick}}")
else
if !Map.get(user.options, :puppet) do
reply_fun = fn(text) -> irc_reply(state, {chan, sender}, text) end
account = Nola.Account.lookup(sender)
- message = %IRC.Message{id: FlakeId.get(), transport: :irc, at: NaiveDateTime.utc_now(), text: text, network: state.network,
+ message = %Nola.Message{id: FlakeId.get(), transport: :irc, at: NaiveDateTime.utc_now(), text: text, network: state.network,
account: account, sender: sender, channel: chan, replyfun: reply_fun,
trigger: extract_trigger(text)}
message = case Nola.UserTrack.messaged(message) do
:ok -> message
{:ok, message} -> message
end
publish(message, ["#{message.network}/#{chan}:messages"])
end
end
{:noreply, state}
end
# Received a private message
def handle_info({:received, text, sender}, state) do
reply_fun = fn(text) -> irc_reply(state, {sender.nick, sender}, text) end
account = Nola.Account.lookup(sender)
- message = %IRC.Message{id: FlakeId.get(), transport: :irc, text: text, network: state.network, at: NaiveDateTime.utc_now(),
+ message = %Nola.Message{id: FlakeId.get(), transport: :irc, text: text, network: state.network, at: NaiveDateTime.utc_now(),
account: account, sender: sender, replyfun: reply_fun, trigger: extract_trigger(text)}
message = case Nola.UserTrack.messaged(message) do
:ok -> message
{:ok, message} -> message
end
publish(message, ["messages:private", "#{message.network}/#{account.id}:messages"])
{:noreply, state}
end
## -- Broadcast
def handle_info({:broadcast, net, account = %Nola.Account{}, message}, state) do
if net == state.conn.network do
user = Nola.UserTrack.find_by_account(net, account)
if user do
irc_reply(state, {user.nick, nil}, message)
end
end
{:noreply, state}
end
def handle_info({:broadcast, net, chan, message}, state) do
if net == state.conn.network && Enum.member?(state.conn.channels, chan) do
irc_reply(state, {chan, nil}, message)
end
{:noreply, state}
end
## -- UserTrack
def handle_info({:joined, channel}, state) do
ExIRC.Client.who(state.client, channel)
{:noreply, state}
end
def handle_info({:who, channel, whos}, state) do
accounts = Enum.map(whos, fn(who = %ExIRC.Who{nick: nick, operator?: operator}) ->
priv = if operator, do: [:operator], else: []
# Don't touch -- on WHO the bot joined, not the users.
Nola.UserTrack.joined(channel, who, priv, false)
account = Nola.Account.lookup(who)
if account do
{:account, who.network, channel, who.nick, account.id}
end
end)
|> Enum.filter(fn(x) -> x end)
dispatch("account", {:accounts, accounts})
{:noreply, state}
end
def handle_info({:quit, reason, sender}, state) do
Nola.UserTrack.quitted(sender, reason)
{:noreply, state}
end
def handle_info({:joined, channel, sender}, state) do
Nola.UserTrack.joined(channel, sender, [])
account = Nola.Account.lookup(sender)
if account do
dispatch("account", {:account, sender.network, channel, sender.nick, account.id})
end
{:noreply, state}
end
def handle_info({:kicked, nick, _by, channel, _reason}, state) do
Nola.UserTrack.parted(state.network, channel, nick)
{:noreply, state}
end
def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do
Nola.UserTrack.parted(state.network, channel, nick)
{:noreply, state}
end
def handle_info({:mode, [channel, mode, nick]}, state) do
track_mode(state.network, channel, nick, mode)
{:noreply, state}
end
def handle_info({:nick_changed, old_nick, new_nick}, state) do
Nola.UserTrack.renamed(state.network, old_nick, new_nick)
{:noreply, state}
end
def handle_info(unhandled, client) do
Logger.debug("unhandled: #{inspect unhandled}")
{:noreply, client}
end
def publish(pub), do: publish(pub, [])
- def publish(m = %IRC.Message{trigger: nil}, keys) do
+ def publish(m = %Nola.Message{trigger: nil}, keys) do
dispatch(["messages"] ++ keys, {:irc, :text, m})
end
- def publish(m = %IRC.Message{trigger: t = %IRC.Trigger{trigger: trigger}}, keys) do
+ def publish(m = %Nola.Message{trigger: t = %Nola.Trigger{trigger: trigger}}, keys) do
dispatch(["triggers", "#{m.network}/#{m.channel}:triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m})
end
def publish_event(net, event = %{type: _}) when is_binary(net) do
event = event
|> Map.put(:at, NaiveDateTime.utc_now())
|> Map.put(:network, net)
dispatch("#{net}:events", {:irc, :event, event})
end
def publish_event({net, chan}, event = %{type: type}) do
event = event
|> Map.put(:at, NaiveDateTime.utc_now())
|> Map.put(:network, net)
|> Map.put(:channel, chan)
dispatch("#{net}/#{chan}:events", {:irc, :event, event})
end
def dispatch(keys, content, sub \\ Nola.PubSub)
def dispatch(key, content, sub) when is_binary(key), do: dispatch([key], content, sub)
def dispatch(keys, content, sub) when is_list(keys) do
Logger.debug("dispatch #{inspect keys} = #{inspect content}")
for key <- keys do
spawn(fn() -> Registry.dispatch(sub, key, fn h ->
for {pid, _} <- h, do: send(pid, content)
end) end)
end
end
#
# Triggers
#
def triggers, do: @triggers
for {trigger, name} <- @triggers do
def extract_trigger(unquote(trigger)<>text) do
text = String.strip(text)
[trigger | args] = String.split(text, " ")
- %IRC.Trigger{type: unquote(name), trigger: String.downcase(trigger), args: args}
+ %Nola.Trigger{type: unquote(name), trigger: String.downcase(trigger), args: args}
end
end
def extract_trigger(_), do: nil
#
# IRC Replies
#
# irc_reply(ExIRC.Client pid, {channel or nick, ExIRC.Sender}, binary | replies
# replies :: {:kick, reason} | {:kick, nick, reason} | {:mode, mode, nick}
defp irc_reply(state = %{client: client, network: network}, {target, _}, text) when is_binary(text) or is_list(text) do
lines = IRC.splitlong(text)
|> Enum.map(fn(x) -> if(is_list(x), do: x, else: String.split(x, "\n")) end)
|> List.flatten()
outputs = for line <- lines do
ExIRC.Client.msg(client, :privmsg, target, line)
- {:irc, :out, %IRC.Message{id: FlakeId.get(), transport: :irc, network: network,
+ {:irc, :out, %Nola.Message{id: FlakeId.get(), transport: :irc, network: network,
channel: target, text: line, sender: %ExIRC.SenderInfo{nick: state.conn.nick}, at: NaiveDateTime.utc_now(), meta: %{self: true}}}
end
for f <- outputs, do: dispatch(["irc:outputs", "#{network}/#{target}:outputs"], f)
end
defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:kick, reason}) do
ExIRC.Client.kick(client, target, nick, reason)
end
defp irc_reply(%{client: client}, {target, _}, {:kick, nick, reason}) do
ExIRC.Client.kick(client, target, nick, reason)
end
defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:mode, mode}) do
ExIRC.Client.mode(%{client: client}, target, mode, nick)
end
defp irc_reply(%{client: client}, target, {:mode, mode, nick}) do
ExIRC.Client.mode(client, target, mode, nick)
end
defp irc_reply(%{client: client}, target, {:channel_mode, mode}) do
ExIRC.Client.mode(client, target, mode)
end
defp track_mode(network, channel, nick, "+o") do
Nola.UserTrack.change_privileges(network, channel, nick, {[:operator], []})
:ok
end
defp track_mode(network, channel, nick, "-o") do
Nola.UserTrack.change_privileges(network, channel, nick, {[], [:operator]})
:ok
end
defp track_mode(network, channel, nick, "+v") do
Nola.UserTrack.change_privileges(network, channel, nick, {[:voice], []})
:ok
end
defp track_mode(network, channel, nick, "-v") do
Nola.UserTrack.change_privileges(network, channel, nick, {[], [:voice]})
:ok
end
defp track_mode(network, channel, nick, mode) do
Logger.warn("Unhandled track_mode: #{inspect {nick, mode}}")
:ok
end
defp server(%{conn: %{host: host, port: port}}) do
host <> ":" <> to_string(port)
end
end
diff --git a/lib/irc/irc.ex b/lib/irc/irc.ex
index 93525e4..a1d97a2 100644
--- a/lib/irc/irc.ex
+++ b/lib/irc/irc.ex
@@ -1,79 +1,59 @@
defmodule IRC do
- defmodule Message do
- @derive {Poison.Encoder, except: [:replyfun]}
- defstruct [:id,
- :text,
- {:transport, :irc},
- :network,
- :account,
- :sender,
- :channel,
- :trigger,
- :replyfun,
- :at,
- {:meta, %{}}
- ]
- end
- defmodule Trigger do
- @derive Poison.Encoder
- defstruct [:type, :trigger, :args]
- end
-
def send_message_as(account, network, channel, text, force_puppet \\ false) do
connection = IRC.Connection.get_network(network)
if connection && (force_puppet || IRC.PuppetConnection.whereis(account, connection)) do
IRC.PuppetConnection.start_and_send_message(account, connection, channel, text)
else
user = Nola.UserTrack.find_by_account(network, account)
nick = if(user, do: user.nick, else: account.name)
IRC.Connection.broadcast_message(network, channel, "<#{nick}> #{text}")
end
end
def register(key) do
case Registry.register(Nola.PubSub, key, []) do
{:ok, _} -> :ok
error -> error
end
end
def admin?(%Message{sender: sender}), do: admin?(sender)
def admin?(%{nick: nick, user: user, host: host}) do
for {n, u, h} <- Nola.IRC.env(:admins, []) do
admin_part_match?(n, nick) && admin_part_match?(u, user) && admin_part_match?(h, host)
end
|> Enum.any?
end
defp admin_part_match?(:_, _), do: true
defp admin_part_match?(a, a), do: true
defp admin_part_match?(_, _), do: false
@max_chars 440
def splitlong(string, max_chars \\ 440)
def splitlong(string, max_chars) when is_list(string) do
Enum.map(string, fn(s) -> splitlong(s, max_chars) end)
|> List.flatten()
end
def splitlong(string, max_chars) do
string
|> String.codepoints
|> Enum.chunk_every(max_chars)
|> Enum.map(&Enum.join/1)
end
def splitlong_with_prefix(string, prefix, max_chars \\ 440) do
prefix = "#{prefix} "
max_chars = max_chars - (length(String.codepoints(prefix)))
string
|> String.codepoints
|> Enum.chunk_every(max_chars)
|> Enum.map(fn(line) -> prefix <> Enum.join(line) end)
end
end
diff --git a/lib/irc/puppet_connection.ex b/lib/irc/puppet_connection.ex
index 75a06f3..2604876 100644
--- a/lib/irc/puppet_connection.ex
+++ b/lib/irc/puppet_connection.ex
@@ -1,238 +1,238 @@
defmodule IRC.PuppetConnection do
require Logger
@min_backoff :timer.seconds(5)
@max_backoff :timer.seconds(2*60)
@max_idle :timer.hours(12)
@env Mix.env
defmodule Supervisor do
use DynamicSupervisor
def start_link() do
DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__)
end
def start_child(%Nola.Account{id: account_id}, %IRC.Connection{id: connection_id}) do
spec = %{id: {account_id, connection_id}, start: {IRC.PuppetConnection, :start_link, [account_id, connection_id]}, restart: :transient}
DynamicSupervisor.start_child(__MODULE__, spec)
end
@impl true
def init(_init_arg) do
DynamicSupervisor.init(
strategy: :one_for_one,
max_restarts: 10,
max_seconds: 1
)
end
end
def whereis(account = %Nola.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}) do
{:global, name} = name(account_id, connection_id)
case :global.whereis_name(name) do
:undefined -> nil
pid -> pid
end
end
def send_message(account = %Nola.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}, channel, text) do
GenServer.cast(name(account_id, connection_id), {:send_message, self(), channel, text})
end
def start_and_send_message(account = %Nola.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}, channel, text) do
{:global, name} = name(account_id, connection_id)
pid = whereis(account, connection)
pid = if !pid do
case IRC.PuppetConnection.Supervisor.start_child(account, connection) do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
end
else
pid
end
GenServer.cast(pid, {:send_message, self(), channel, text})
end
def start(account = %Nola.Account{}, connection = %IRC.Connection{}) do
IRC.PuppetConnection.Supervisor.start_child(account, connection)
end
def start_link(account_id, connection_id) do
GenServer.start_link(__MODULE__, [account_id, connection_id], name: name(account_id, connection_id))
end
def name(account_id, connection_id) do
{:global, {PuppetConnection, account_id, connection_id}}
end
def init([account_id, connection_id]) do
account = %Nola.Account{} = Nola.Account.get(account_id)
connection = %IRC.Connection{} = IRC.Connection.lookup(connection_id)
Logger.metadata(puppet_conn: account.id <> "@" <> connection.id)
backoff = :backoff.init(@min_backoff, @max_backoff)
|> :backoff.type(:jitter)
idle = :erlang.send_after(@max_idle, self, :idle)
{:ok, %{client: nil, backoff: backoff, idle: idle, connected: false, buffer: [], channels: [], connection_id: connection_id, account_id: account_id, connected_server: nil, connected_port: nil, network: connection.network}, {:continue, :connect}}
end
def handle_continue(:connect, state) do
#ipv6 = if @env == :prod do
# subnet = Nola.Subnet.assign(state.account_id)
# Nola.Account.put_meta(Nola.Account.get(state.account_id), "subnet", subnet)
# ip = Pfx.host(subnet, 1)
# {:ok, ipv6} = :inet_parse.ipv6_address(to_charlist(ip))
# System.cmd("add-ip6", [ip])
# ipv6
#end
conn = IRC.Connection.lookup(state.connection_id)
client_opts = []
|> Keyword.put(:network, conn.network)
client = if state.client && Process.alive?(state.client) do
Logger.info("Reconnecting client")
state.client
else
Logger.info("Connecting")
{:ok, client} = ExIRC.Client.start_link(debug: false)
ExIRC.Client.add_handler(client, self())
client
end
base_opts = [
{:nodelay, true}
]
#{ip, opts} = case {ipv6, :inet_res.resolve(to_charlist(conn.host), :in, :aaaa)} do
# {ipv6, {:ok, {:dns_rec, _dns_header, _query, rrs = [{:dns_rr, _, _, _, _, _, _, _, _, _} | _], _, _}}} ->
# ip = rrs
# |> Enum.map(fn({:dns_rr, _, :aaaa, :in, _, _, ipv6, _, _, _}) -> ipv6 end)
# |> Enum.shuffle()
# |> List.first()
# opts = [
# :inet6,
# {:ifaddr, ipv6}
# ]
# {ip, opts}
# _ ->
{ip, opts} = {to_charlist(conn.host), []}
#end
conn_fun = if conn.tls, do: :connect_ssl!, else: :connect!
apply(ExIRC.Client, conn_fun, [client, ip, conn.port, base_opts ++ opts])
{:noreply, %{state | client: client}}
end
def handle_continue(:connected, state) do
state = Enum.reduce(Enum.reverse(state.buffer), state, fn(b, state) ->
{:noreply, state} = handle_cast(b, state)
state
end)
{:noreply, %{state | buffer: []}}
end
def handle_cast(cast = {:send_message, _pid, _channel, _text}, state = %{connected: false, buffer: buffer}) do
{:noreply, %{state | buffer: [cast | buffer]}}
end
def handle_cast({:send_message, pid, channel, text}, state = %{connected: true}) do
channels = if !Enum.member?(state.channels, channel) do
ExIRC.Client.join(state.client, channel)
[channel | state.channels]
else
state.channels
end
ExIRC.Client.msg(state.client, :privmsg, channel, text)
meta = %{puppet: true, from: pid}
account = Nola.Account.get(state.account_id)
nick = make_nick(state)
sender = %ExIRC.SenderInfo{network: state.network, nick: suffix_nick(nick), user: nick, host: "puppet."}
reply_fun = fn(text) ->
IRC.Connection.broadcast_message(state.network, channel, text)
end
- message = %IRC.Message{id: FlakeId.get(), at: NaiveDateTime.utc_now(), text: text, network: state.network, account: account, sender: sender, channel: channel, replyfun: reply_fun, trigger: IRC.Connection.extract_trigger(text), meta: meta}
+ message = %Nola.Message{id: FlakeId.get(), at: NaiveDateTime.utc_now(), text: text, network: state.network, account: account, sender: sender, channel: channel, replyfun: reply_fun, trigger: IRC.Connection.extract_trigger(text), meta: meta}
message = case Nola.UserTrack.messaged(message) do
:ok -> message
{:ok, message} -> message
end
IRC.Connection.publish(message, ["#{message.network}/#{channel}:messages"])
idle = if length(state.buffer) == 0 do
:erlang.cancel_timer(state.idle)
:erlang.send_after(@max_idle, self(), :idle)
else
state.idle
end
{:noreply, %{state | idle: idle, channels: channels}}
end
def handle_info(:idle, state) do
ExIRC.Client.quit(state.client, "Puppet was idle for too long")
ExIRC.Client.stop!(state.client)
{:stop, :normal, state}
end
def handle_info(:disconnected, state) do
{delay, backoff} = :backoff.fail(state.backoff)
Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms")
Process.send_after(self(), :connect, delay)
{:noreply, %{state | connected: false, backoff: backoff}}
end
def handle_info(:connect, state) do
{:noreply, state, {:continue, :connect}}
end
# Connection successful
def handle_info({:connected, server, port}, state) do
Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}")
{_, backoff} = :backoff.succeed(state.backoff)
base_nick = make_nick(state)
ExIRC.Client.logon(state.client, "", suffix_nick(base_nick), base_nick, "#{base_nick}'s puppet")
{:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}}
end
# Logon successful
def handle_info(:logged_in, state) do
Logger.info("#{inspect(self())} Logged in")
{_, backoff} = :backoff.succeed(state.backoff)
# Create an UserTrack entry for the client so it's authenticated to the right account_id already.
Nola.UserTrack.connected(state.network, suffix_nick(make_nick(state)), make_nick(state), "puppet.", state.account_id, %{puppet: true})
{:noreply, %{state | backoff: backoff}}
end
# ISUP
def handle_info({:isup, network}, state) do
{:noreply, %{state | network: network, connected: true}, {:continue, :connected}}
end
# Been kicked
def handle_info({:kicked, _sender, chan, _reason}, state) do
{:noreply, %{state | channels: state.channels -- [chan]}}
end
def handle_info(_info, state) do
{:noreply, state}
end
def make_nick(state) do
account = Nola.Account.get(state.account_id)
user = Nola.UserTrack.find_by_account(state.network, account)
base_nick = if(user, do: user.nick, else: account.name)
clean_nick = case String.split(base_nick, ":", parts: 2) do
["@"<>nick, _] -> nick
[nick] -> nick
end
clean_nick
end
if Mix.env == :dev do
def suffix_nick(nick), do: "#{nick}[d]"
else
def suffix_nick(nick), do: "#{nick}[p]"
end
end
diff --git a/lib/matrix/room.ex b/lib/matrix/room.ex
index 757aad0..299d7cc 100644
--- a/lib/matrix/room.ex
+++ b/lib/matrix/room.ex
@@ -1,196 +1,196 @@
defmodule Nola.Matrix.Room do
require Logger
alias Nola.Matrix
alias Polyjuice.Client
import Matrix, only: [client: 0, client: 1, user_name: 1, myself?: 1]
defmodule Supervisor do
use DynamicSupervisor
def start_link() do
DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__)
end
def start_child(room_id) do
spec = %{id: room_id, start: {Nola.Matrix.Room, :start_link, [room_id]}, restart: :transient}
DynamicSupervisor.start_child(__MODULE__, spec)
end
@impl true
def init(_init_arg) do
DynamicSupervisor.init(
strategy: :one_for_one,
max_restarts: 10,
max_seconds: 1
)
end
end
def start(room_id) do
__MODULE__.Supervisor.start_child(room_id)
end
def start_link(room_id) do
GenServer.start_link(__MODULE__, [room_id], name: name(room_id))
end
def start_and_send_matrix_event(room_id, event) do
pid = if pid = whereis(room_id) do
pid
else
case __MODULE__.start(room_id) do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
:ignore -> nil
end
end
if(pid, do: send(pid, {:matrix_event, event}))
end
def whereis(room_id) do
{:global, name} = name(room_id)
case :global.whereis_name(name) do
:undefined -> nil
pid -> pid
end
end
def name(room_id) do
{:global, {__MODULE__, room_id}}
end
def init([room_id]) do
case Matrix.lookup_room(room_id) do
{:ok, state} ->
Logger.metadata(matrix_room: room_id)
{:ok, _} = Registry.register(Nola.PubSub, "#{state.network}:events", plugin: __MODULE__)
for t <- ["messages", "triggers", "outputs", "events"] do
{:ok, _} = Registry.register(Nola.PubSub, "#{state.network}/#{state.channel}:#{t}", plugin: __MODULE__)
end
state = state
|> Map.put(:id, room_id)
Logger.info("Started Matrix room #{room_id}")
{:ok, state, {:continue, :update_state}}
error ->
Logger.info("Received event for nonexistent room #{inspect room_id}: #{inspect error}")
:ignore
end
end
def handle_continue(:update_state, state) do
{:ok, s} = Client.Room.get_state(client(), state.id)
members = Enum.reduce(s, [], fn(s, acc) ->
if s["type"] == "m.room.member" do
if s["content"]["membership"] == "join" do
[s["user_id"] | acc]
else
# XXX: The user left, remove from Nola.Memberships ?
acc
end
else
acc
end
end)
|> Enum.filter(& &1)
for m <- members, do: Nola.UserTrack.joined(state.id, %{network: "matrix", nick: m, user: m, host: "matrix."}, [], true)
accounts = Nola.UserTrack.channel(state.network, state.channel)
|> Enum.filter(& &1)
|> Enum.map(fn(tuple) -> Nola.UserTrack.User.from_tuple(tuple).account end)
|> Enum.uniq()
|> Enum.each(fn(account_id) ->
introduce_irc_account(account_id, state)
end)
{:noreply, state}
end
def handle_info({:irc, :text, message}, state), do: handle_irc(message, state)
def handle_info({:irc, :out, message}, state), do: handle_irc(message, state)
def handle_info({:irc, :trigger, _, message}, state), do: handle_irc(message, state)
def handle_info({:irc, :event, event}, state), do: handle_irc(event, state)
def handle_info({:matrix_event, event}, state) do
if myself?(event.user_id) do
{:noreply, state}
else
handle_matrix(event, state)
end
end
- def handle_irc(message = %IRC.Message{account: account}, state) do
+ def handle_irc(message = %Nola.Message{account: account}, state) do
unless Map.get(message.meta, :puppet) && Map.get(message.meta, :from) == self() do
opts = if Map.get(message.meta, :self) || is_nil(account) do
[]
else
mxid = Matrix.get_or_create_matrix_user(account.id)
[user_id: mxid]
end
Client.Room.send_message(client(opts),state.id, message.text)
end
{:noreply, state}
end
def handle_irc(%{type: :join, account_id: account_id}, state) do
introduce_irc_account(account_id, state)
{:noreply, state}
end
def handle_irc(%{type: quit_or_part, account_id: account_id}, state) when quit_or_part in [:quit, :part] do
mxid = Matrix.get_or_create_matrix_user(account_id)
Client.Room.leave(client(user_id: mxid), state.id)
{:noreply, state}
end
def handle_irc(event, state) do
Logger.warn("Skipped irc event #{inspect event}")
{:noreply, state}
end
def handle_matrix(event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "join"}}, state) do
_account = get_account(event, state)
Nola.UserTrack.joined(state.id, %{network: "matrix", nick: user_id, user: user_id, host: "matrix."}, [], true)
{:noreply, state}
end
def handle_matrix(event = %{type: "m.room.member", user_id: user_id, content: %{"membership" => "leave"}}, state) do
Nola.UserTrack.parted(state.id, %{network: "matrix", nick: user_id})
{:noreply, state}
end
def handle_matrix(event = %{type: "m.room.message", user_id: user_id, content: %{"msgtype" => "m.text", "body" => text}}, state) do
IRC.send_message_as(get_account(event, state), state.network, state.channel, text, true)
{:noreply, state}
end
def handle_matrix(event, state) do
Logger.warn("Skipped matrix event #{inspect event}")
{:noreply, state}
end
def get_account(%{user_id: user_id}, %{id: id}) do
Nola.Account.find_by_nick("matrix", user_id)
end
defp introduce_irc_account(account_id, state) do
mxid = Matrix.get_or_create_matrix_user(account_id)
account = Nola.Account.get(account_id)
user = Nola.UserTrack.find_by_account(state.network, account)
base_nick = if(user, do: user.nick, else: account.name)
case Client.Profile.put_displayname(client(user_id: mxid), base_nick) do
:ok -> :ok
error ->
Logger.warn("Failed to update profile for #{mxid}: #{inspect error}")
end
case Client.Room.join(client(user_id: mxid), state.id) do
{:ok, _} -> :ok
error ->
Logger.warn("Failed to join room for #{mxid}: #{inspect error}")
end
:ok
end
end
diff --git a/lib/nola/account.ex b/lib/nola/account.ex
index 06ca993..31e237c 100644
--- a/lib/nola/account.ex
+++ b/lib/nola/account.ex
@@ -1,263 +1,263 @@
defmodule Nola.Account do
alias Nola.UserTrack.User
@moduledoc """
Account registry....
Maps a network predicate:
* `{net, {:nick, nickname}}`
* `{net, {:account, account}}`
* `{net, {:mask, user@host}}`
to an unique identifier, that can be shared over multiple networks.
If a predicate cannot be found for an existing account, a new account will be made in the database.
To link two existing accounts from different network onto a different one, a merge operation is provided.
"""
# FIXME: Ensure uniqueness of name?
@derive {Poison.Encoder, except: [:token]}
defstruct [:id, :name, :token]
@type t :: %__MODULE__{id: id(), name: String.t()}
@type id :: String.t()
defimpl Inspect, for: __MODULE__ do
import Inspect.Algebra
def inspect(%{id: id, name: name}, opts) do
concat(["#Nola.Account[", id, " ", name, "]"])
end
end
def file(base) do
to_charlist(Nola.data_path() <> "/account_#{base}.dets")
end
defp from_struct(%__MODULE__{id: id, name: name, token: token}) do
{id, name, token}
end
defp from_tuple({id, name, token}) do
%__MODULE__{id: id, name: name, token: token}
end
def start_link() do
GenServer.start_link(__MODULE__, [], [name: __MODULE__])
end
def init(_) do
{:ok, accounts} = :dets.open_file(file("db"), [])
{:ok, meta} = :dets.open_file(file("meta"), [])
{:ok, predicates} = :dets.open_file(file("predicates"), [{:type, :set}])
{:ok, %{accounts: accounts, meta: meta, predicates: predicates}}
end
def get(id) do
case :dets.lookup(file("db"), id) do
[account] -> from_tuple(account)
_ -> nil
end
end
def get_by_name(name) do
spec = [{{:_, :"$1", :_}, [{:==, :"$1", {:const, name}}], [:"$_"]}]
case :dets.select(file("db"), spec) do
[account] -> from_tuple(account)
_ -> nil
end
end
def get_meta(%__MODULE__{id: id}, key, default \\ nil) do
case :dets.lookup(file("meta"), {id, key}) do
[{_, value}] -> (value || default)
_ -> default
end
end
@spec find_meta_accounts(String.t()) :: [{account :: t(), value :: String.t()}, ...]
@doc "Find all accounts that have a meta of `key`."
def find_meta_accounts(key) do
spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$2", {:const, key}}], [{{:"$1", :"$3"}}]}]
for {id, val} <- :dets.select(file("meta"), spec), do: {get(id), val}
end
@doc "Find an account given a specific meta `key` and `value`."
@spec find_meta_account(String.t(), String.t()) :: t() | nil
def find_meta_account(key, value) do
#spec = [{{{:"$1", :"$2"}, :"$3"}, [:andalso, {:==, :"$2", {:const, key}}, {:==, :"$3", {:const, value}}], [:"$1"]}]
spec = [{{{:"$1", :"$2"}, :"$3"}, [{:andalso, {:==, :"$2", {:const, key}}, {:==, {:const, value}, :"$3"}}], [:"$1"]}]
case :dets.select(file("meta"), spec) do
[id] -> get(id)
_ -> nil
end
end
def get_all_meta(%__MODULE__{id: id}) do
spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}]
:dets.select(file("meta"), spec)
end
def put_user_meta(account = %__MODULE__{}, key, value) do
put_meta(account, "u:"<>key, value)
end
def put_meta(%__MODULE__{id: id}, key, value) do
:dets.insert(file("meta"), {{id, key}, value})
end
def delete_meta(%__MODULE__{id: id}, key) do
:dets.delete(file("meta"), {id, key})
end
def all_accounts() do
:dets.traverse(file("db"), fn(obj) -> {:continue, from_tuple(obj)} end)
end
def all_predicates() do
:dets.traverse(file("predicates"), fn(obj) -> {:continue, obj} end)
end
def all_meta() do
:dets.traverse(file("meta"), fn(obj) -> {:continue, obj} end)
end
def merge_account(old_id, new_id) do
if old_id != new_id do
spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, old_id}}], [:"$1"]}]
predicates = :dets.select(file("predicates"), spec)
for pred <- predicates, do: :ok = :dets.insert(file("predicates"), {pred, new_id})
spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, old_id}}], [{{:"$2", :"$3"}}]}]
metas = :dets.select(file("meta"), spec)
for {k,v} <- metas do
:dets.delete(file("meta"), {{old_id, k}})
:ok = :dets.insert(file("meta"), {{new_id, k}, v})
end
:dets.delete(file("db"), old_id)
Nola.Membership.merge_account(old_id, new_id)
Nola.UserTrack.merge_account(old_id, new_id)
IRC.Connection.dispatch("account", {:account_change, old_id, new_id})
IRC.Connection.dispatch("conn", {:account_change, old_id, new_id})
end
:ok
end
@doc "Find an account by a logged in user"
def find_by_nick(network, nick) do
do_lookup(%ExIRC.SenderInfo{nick: nick, network: network}, false)
end
@doc "Always find an account by nickname, even if offline. Uses predicates and then account name."
def find_always_by_nick(network, chan, nick) do
with \
nil <- find_by_nick(network, nick),
nil <- do_lookup(%User{network: network, nick: nick}, false),
nil <- get_by_name(nick)
do
nil
else
%__MODULE__{} = account ->
memberships = Nola.Membership.of_account(account)
if Enum.any?(memberships, fn({net, ch}) -> (net == network) or (chan && chan == ch) end) do
account
else
nil
end
end
end
def find(something) do
do_lookup(something, false)
end
def lookup(something, make_default \\ true) do
account = do_lookup(something, make_default)
if account && Map.get(something, :nick) do
IRC.Connection.dispatch("account", {:account_auth, Map.get(something, :nick), account.id})
end
account
end
def handle_info(_, state) do
{:noreply, state}
end
def handle_cast(_, state) do
{:noreply, state}
end
def handle_call(_, _, state) do
{:noreply, state}
end
def terminate(_, state) do
for {_, dets} <- state do
:dets.sync(dets)
:dets.close(dets)
end
end
- defp do_lookup(message = %IRC.Message{account: account_id}, make_default) when is_binary(account_id) do
+ defp do_lookup(message = %Nola.Message{account: account_id}, make_default) when is_binary(account_id) do
get(account_id)
end
defp do_lookup(sender = %ExIRC.Who{}, make_default) do
if user = Nola.UserTrack.find_by_nick(sender) do
lookup(user, make_default)
else
#FIXME this will never work with continued lookup by other methods as Who isn't compatible
lookup_by_nick(sender, :dets.lookup(file("predicates"), {sender.network,{:nick, sender.nick}}), make_default)
end
end
defp do_lookup(sender = %ExIRC.SenderInfo{}, make_default) do
lookup(Nola.UserTrack.find_by_nick(sender), make_default)
end
defp do_lookup(user = %User{account: id}, make_default) when is_binary(id) do
get(id)
end
defp do_lookup(user = %User{network: server, nick: nick}, make_default) do
lookup_by_nick(user, :dets.lookup(file("predicates"), {server,{:nick, nick}}), make_default)
end
defp do_lookup(nil, _) do
nil
end
defp lookup_by_nick(_, [{_, id}], _make_default) do
get(id)
end
defp lookup_by_nick(user, _, make_default) do
#authenticate_by_host(user)
if make_default, do: new_account(user), else: nil
end
def new_account(nick) do
id = EntropyString.large_id()
:dets.insert(file("db"), {id, nick, EntropyString.token()})
get(id)
end
def new_account(%{nick: nick, network: server}) do
id = EntropyString.large_id()
:dets.insert(file("db"), {id, nick, EntropyString.token()})
:dets.insert(file("predicates"), {{server, {:nick, nick}}, id})
get(id)
end
def update_account_name(account = %__MODULE__{id: id}, name) do
account = %__MODULE__{account | name: name}
:dets.insert(file("db"), from_struct(account))
get(id)
end
def get_predicates(%__MODULE__{} = account) do
spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}]
:dets.select(file("predicates"), spec)
end
end
diff --git a/lib/nola/message.ex b/lib/nola/message.ex
new file mode 100644
index 0000000..4ceb9b9
--- /dev/null
+++ b/lib/nola/message.ex
@@ -0,0 +1,23 @@
+defmodule Message do
+ @moduledoc """
+ Well, a message!
+
+ """
+
+ @derive {Poison.Encoder, except: [:replyfun]}
+
+ defstruct [
+ :id,
+ :text,
+ {:transport, :irc},
+ :network,
+ :account,
+ :sender,
+ :channel,
+ :trigger,
+ :replyfun,
+ :at,
+ {:meta, %{}}
+ ]
+
+end
diff --git a/lib/nola/trigger.ex b/lib/nola/trigger.ex
new file mode 100644
index 0000000..b6502c3
--- /dev/null
+++ b/lib/nola/trigger.ex
@@ -0,0 +1,12 @@
+defmodule Trigger do
+ @moduledoc "A `Nola.Message` parsed command/trigger."
+
+ @derive Poison.Encoder
+
+ defstruct [
+ :type,
+ :trigger,
+ :args
+ ]
+
+end
diff --git a/lib/nola/user_track.ex b/lib/nola/user_track.ex
index 2a051f9..720fb58 100644
--- a/lib/nola/user_track.ex
+++ b/lib/nola/user_track.ex
@@ -1,329 +1,329 @@
defmodule Nola.UserTrack do
@moduledoc """
User Track DB & Utilities
"""
@ets Nola.UserTrack.Storage
# {uuid, network, nick, nicks, privilege_map}
# Privilege map:
# %{"#channel" => [:operator, :voice]
defmodule Storage do
def delete(id) do
op(fn(ets) -> :ets.delete(ets, id) end)
end
def insert(tuple) do
op(fn(ets) -> :ets.insert(ets, tuple) end)
end
def clear_network(network) do
op(fn(ets) ->
spec = [
{{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_, :_},
[
{:==, :"$1", {:const, network}}
], [:"$_"]}
]
:ets.match_delete(ets, spec)
end)
end
def op(fun) do
GenServer.call(__MODULE__, {:op, fun})
end
def start_link do
GenServer.start_link(__MODULE__, [], [name: __MODULE__])
end
def init([]) do
ets = :ets.new(__MODULE__, [:set, :named_table, :protected, {:read_concurrency, true}])
{:ok, ets}
end
def handle_call({:op, fun}, _from, ets) do
returned = try do
{:ok, fun.(ets)}
rescue
rescued -> {:error, rescued}
catch
rescued -> {:error, rescued}
end
{:reply, returned, ets}
end
def terminate(_reason, ets) do
:ok
end
end
defmodule Id, do: use EntropyString
defmodule User do
defstruct [:id, :account, :network, :nick, {:nicks, []}, :username, :host, :realname, {:privileges, %{}}, {:last_active, %{}}, {:options, %{}}]
def to_tuple(u = %__MODULE__{}) do
{u.id || Nola.UserTrack.Id.large_id, u.network, u.account, String.downcase(u.nick), u.nick, u.nicks || [], u.username, u.host, u.realname, u.privileges, u.last_active, u.options}
end
#tuple size: 11
def from_tuple({id, network, account, _downcased_nick, nick, nicks, username, host, realname, privs, last_active, opts}) do
struct = %__MODULE__{id: id, account: account, network: network, nick: nick, nicks: nicks, username: username, host: host, realname: realname, privileges: privs, last_active: last_active, options: opts}
end
end
def find_by_account(%Nola.Account{id: id}) do
#iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end)
spec = [
{{:_, :_, :"$2", :_, :_, :_, :_, :_, :_, :_, :_, :_},
[
{:==, :"$2", {:const, id}}
], [:"$_"]}
]
results = :ets.select(@ets, spec)
|> Enum.filter(& &1)
for obj <- results, do: User.from_tuple(obj)
end
def find_by_account(network, nil) do
nil
end
def find_by_account(network, %Nola.Account{id: id}) do
#iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end)
spec = [
{{:_, :"$1", :"$2", :_, :_, :_, :_, :_, :_, :_, :_, :_},
[
{:andalso, {:==, :"$1", {:const, network}},
{:==, :"$2", {:const, id}}}
], [:"$_"]}
]
case :ets.select(@ets, spec) do
results = [_r | _] ->
result = results
|> Enum.reject(fn({_, net, _, _, _, _, _, _, _, _, actives, opts}) -> network != "matrix" && net == "matrix" end)
|> Enum.reject(fn({_, net, _, _, _, _, _, _, _, _, actives, opts}) -> network != "telegram" && net == "telegram" end)
|> Enum.reject(fn({_, _, _, _, _, _, _, _, _, _, actives, opts}) -> network not in ["matrix", "telegram"] && Map.get(opts, :puppet) end)
|> Enum.sort_by(fn({_, _, _, _, _, _, _, _, _, _, actives, _}) ->
Map.get(actives, nil)
end, {:desc, NaiveDateTime})
|> List.first
if result, do: User.from_tuple(result)
_ -> nil
end
end
def clear_network(network) do
Storage.clear_network(network)
end
def merge_account(old_id, new_id) do
#iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end)
spec = [
{{:_, :_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_},
[
{:==, :"$1", {:const, old_id}}
], [:"$_"]}
]
Enum.each(:ets.select(@ets, spec), fn({id, net, _, downcased_nick, nick, nicks, username, host, realname, privs, active, opts}) ->
Storage.op(fn(ets) ->
:ets.insert(@ets, {id, net, new_id, downcased_nick, nick, nicks, username, host, realname, privs, active, opts})
end)
end)
end
def find_by_nick(%ExIRC.Who{network: network, nick: nick}) do
find_by_nick(network, nick)
end
def find_by_nick(%ExIRC.SenderInfo{network: network, nick: nick}) do
find_by_nick(network, nick)
end
def find_by_nick(network, nick) do
case :ets.match(@ets, {:"$1", network, :_, String.downcase(nick), :_, :_, :_, :_, :_, :_, :_, :_}) do
[[id] | _] -> lookup(id)
_ ->
nil
end
end
def to_list, do: :ets.tab2list(@ets)
def lookup(id) do
case :ets.lookup(@ets, id) do
[] -> nil
[tuple] -> User.from_tuple(tuple)
end
end
def operator?(network, channel, nick) do
if user = find_by_nick(network, nick) do
privs = Map.get(user.privileges, channel, [])
Enum.member?(privs, :admin) || Enum.member?(privs, :operator)
else
false
end
end
def channel(network, channel) do
Enum.filter(to_list(), fn({_, network, _, _, _, _, _, _, _, channels, _, _}) ->
Map.get(channels, channel)
end)
end
# TODO
def connected(network, nick, user, host, account_id, opts \\ %{}) do
if account = Nola.Account.get(account_id) do
user = if user = find_by_nick(network, nick) do
user
else
user = %User{id: Nola.UserTrack.Id.large_id, account: account_id, network: network, nick: nick, username: user, host: host, privileges: %{}, options: opts}
Storage.op(fn(ets) ->
:ets.insert(ets, User.to_tuple(user))
end)
user
end
IRC.Connection.publish_event(network, %{type: :connect, user_id: user.id, account_id: user.account})
:ok
else
:error
end
end
def joined(c, s), do: joined(c,s,[])
def joined(channel, sender=%{nick: nick, user: uname, host: host}, privileges, touch \\ true) do
privileges = if IRC.admin?(sender) do
privileges ++ [:admin]
else privileges end
user = if user = find_by_nick(sender.network, nick) do
%User{user | username: uname, host: host, privileges: Map.put(user.privileges || %{}, channel, privileges)}
else
user = %User{id: Nola.UserTrack.Id.large_id, network: sender.network, nick: nick, username: uname, host: host, privileges: %{channel => privileges}}
account = Nola.Account.lookup(user).id
user = %User{user | account: account}
end
user = touch_struct(user, channel)
if touch && user.account do
Nola.Membership.touch(user.account, sender.network, channel)
end
Storage.op(fn(ets) ->
:ets.insert(ets, User.to_tuple(user))
end)
IRC.Connection.publish_event({sender.network, channel}, %{type: :join, user_id: user.id, account_id: user.account})
user
end
#def joined(network, channel, nick, privileges) do
# user = if user = find_by_nick(network, nick) do
# %User{user | privileges: Map.put(user.privileges, channel, privileges)}
# else
# %User{nick: nick, privileges: %{channel => privileges}}
# end
#
# Storage.op(fn(ets) ->
# :ets.insert(ets, User.to_tuple(user))
# end)
#end
- def messaged(%IRC.Message{network: network, account: account, channel: chan, sender: %{nick: nick}} = m) do
+ def messaged(%Nola.Message{network: network, account: account, channel: chan, sender: %{nick: nick}} = m) do
{user, account} = if user = find_by_nick(network, nick) do
{touch_struct(user, chan), account || Nola.Account.lookup(user)}
else
user = %User{network: network, nick: nick, privileges: %{}}
account = Nola.Account.lookup(user)
{%User{user | account: account.id}, account}
end
Storage.insert(User.to_tuple(user))
if chan, do: Nola.Membership.touch(account, network, chan)
if !m.account do
- {:ok, %IRC.Message{m | account: account}}
+ {:ok, %Nola.Message{m | account: account}}
else
:ok
end
end
def renamed(network, old_nick, new_nick) do
if user = find_by_nick(network, old_nick) do
old_account = Nola.Account.lookup(user)
user = %User{user | nick: new_nick, nicks: [old_nick|user.nicks]}
account = Nola.Account.lookup(user, false) || old_account
user = %User{user | nick: new_nick, account: account.id, nicks: [old_nick|user.nicks]}
Storage.insert(User.to_tuple(user))
channels = for {channel, _} <- user.privileges, do: channel
IRC.Connection.publish_event(network, %{type: :nick, user_id: user.id, account_id: account.id, nick: new_nick, old_nick: old_nick})
end
end
def change_privileges(network, channel, nick, {add, remove}) do
if user = find_by_nick(network, nick) do
privs = Map.get(user.privileges, channel)
privs = Enum.reduce(add, privs, fn(priv, acc) -> [priv|acc] end)
privs = Enum.reduce(remove, privs, fn(priv, acc) -> List.delete(acc, priv) end)
user = %User{user | privileges: Map.put(user.privileges, channel, privs)}
Storage.insert(User.to_tuple(user))
IRC.Connection.publish_event({network, channel}, %{type: :privileges, user_id: user.id, account_id: user.account, added: add, removed: remove})
end
end
# XXX: Reason
def parted(channel, %{network: network, nick: nick}) do
parted(network, channel, nick)
end
def parted(network, channel, nick) do
if user = find_by_nick(network, nick) do
if user.account do
Nola.Membership.touch(user.account, network, channel)
end
privs = Map.delete(user.privileges, channel)
lasts = Map.delete(user.last_active, channel)
if Enum.count(privs) > 0 do
user = %User{user | privileges: privs}
Storage.insert(User.to_tuple(user))
IRC.Connection.publish_event({network, channel}, %{type: :part, user_id: user.id, account_id: user.account, reason: nil})
else
IRC.Connection.publish_event(network, %{type: :quit, user_id: user.id, account_id: user.account, reason: "Left all known channels"})
Storage.delete(user.id)
end
end
end
def quitted(sender, reason) do
if user = find_by_nick(sender.network, sender.nick) do
if user.account do
for {channel, _} <- user.privileges do
Nola.Membership.touch(user.account, sender.network, channel)
end
IRC.Connection.publish_event(sender.network, %{type: :quit, user_id: user.id, account_id: user.account, reason: reason})
end
Storage.delete(user.id)
end
end
defp touch_struct(user = %User{last_active: last_active}, channel) do
now = NaiveDateTime.utc_now()
last_active = last_active
|> Map.put(channel, now)
|> Map.put(nil, now)
%User{user | last_active: last_active}
end
defp userchans(%{privileges: privileges}) do
for({chan, _} <- privileges, do: chan)
end
end
diff --git a/lib/plugins/account.ex b/lib/plugins/account.ex
index 96405bb..20abab7 100644
--- a/lib/plugins/account.ex
+++ b/lib/plugins/account.ex
@@ -1,188 +1,188 @@
defmodule Nola.Plugins.Account do
@moduledoc """
# Account
* **account** Get current account id and token
* **auth `<account-id>` `<token>`** Authenticate and link the current nickname to an account
* **auth** list authentications methods
* **whoami** list currently authenticated users
* **web** get a one-time login link to web
* **enable-telegram** Link a Telegram account
* **enable-sms** Link a SMS number
* **enable-untappd** Link a Untappd account
* **set-name** set account name
* **setusermeta puppet-nick `<nick>`** Set puppet IRC nickname
"""
def irc_doc, do: @moduledoc
def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
def init(_) do
{:ok, _} = Registry.register(Nola.PubSub, "messages:private", [])
{:ok, nil}
end
- def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "help"}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "help"}}, state) do
text = [
"account: show current account and auth token",
"auth: show authentications methods",
"whoami: list authenticated users",
"set-name <name>: set account name",
"web: login to web",
"enable-sms | disable-sms: enable/change or disable sms",
"enable-telegram: link/change telegram",
"enable-untappd: link untappd account",
"getmeta: show meta datas",
"setusermeta: set user meta",
]
m.replyfun.(text)
{:noreply, state}
end
- def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "auth"}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "auth"}}, state) do
spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}]
predicates = :dets.select(Nola.Account.file("predicates"), spec)
text = for {net, {key, value}} <- predicates, do: "#{net}: #{to_string(key)}: #{value}"
m.replyfun.(text)
{:noreply, state}
end
- def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "whoami"}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "whoami"}}, state) do
users = for user <- Nola.UserTrack.find_by_account(m.account) do
chans = Enum.map(user.privileges, fn({chan, _}) -> chan end)
|> Enum.join(" ")
"#{user.network} - #{user.nick}!#{user.username}@#{user.host} - #{chans}"
end
m.replyfun.(users)
{:noreply, state}
end
- def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "account"}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "account"}}, state) do
account = Nola.Account.lookup(m.sender)
text = ["Account Id: #{account.id}",
"Authenticate to this account from another network: \"auth #{account.id} #{account.token}\" to the other bot!"]
m.replyfun.(text)
{:noreply, state}
end
- def handle_info({:irc, :text, m = %IRC.Message{sender: sender, text: "auth"<>_}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{sender: sender, text: "auth"<>_}}, state) do
#account = Nola.Account.lookup(m.sender)
case String.split(m.text, " ") do
["auth", id, token] ->
join_account(m, id, token)
_ ->
m.replyfun.("Invalid parameters")
end
{:noreply, state}
end
- def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "set-name "<>name}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{account: account, text: "set-name "<>name}}, state) do
Nola.Account.update_account_name(account, name)
m.replyfun.("Name changed: #{name}")
{:noreply, state}
end
- def handle_info({:irc, :text, m = %IRC.Message{text: "disable-sms"}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{text: "disable-sms"}}, state) do
if Nola.Account.get_meta(m.account, "sms-number") do
Nola.Account.delete_meta(m.account, "sms-number")
m.replfyun.("SMS disabled.")
else
m.replyfun.("SMS already disabled.")
end
{:noreply, state}
end
- def handle_info({:irc, :text, m = %IRC.Message{text: "web"}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{text: "web"}}, state) do
auth_url = Untappd.auth_url()
login_url = Nola.AuthToken.new_url(m.account.id, nil)
m.replyfun.("-> " <> login_url)
{:noreply, state}
end
- def handle_info({:irc, :text, m = %IRC.Message{text: "enable-sms"}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{text: "enable-sms"}}, state) do
code = String.downcase(EntropyString.small_id())
Nola.Account.put_meta(m.account, "sms-validation-code", code)
Nola.Account.put_meta(m.account, "sms-validation-target", m.network)
number = Nola.IRC.Sms.my_number()
text = "To enable or change your number for SMS messaging, please send:"
<> " \"enable #{code}\" to #{number}"
m.replyfun.(text)
{:noreply, state}
end
- def handle_info({:irc, :text, m = %IRC.Message{text: "enable-telegram"}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{text: "enable-telegram"}}, state) do
code = String.downcase(EntropyString.small_id())
Nola.Account.delete_meta(m.account, "telegram-id")
Nola.Account.put_meta(m.account, "telegram-validation-code", code)
Nola.Account.put_meta(m.account, "telegram-validation-target", m.network)
text = "To enable or change your number for telegram messaging, please open #{Nola.Telegram.my_path()} and send:"
<> " \"/enable #{code}\""
m.replyfun.(text)
{:noreply, state}
end
- def handle_info({:irc, :text, m = %IRC.Message{text: "enable-untappd"}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{text: "enable-untappd"}}, state) do
auth_url = Untappd.auth_url()
login_url = Nola.AuthToken.new_url(m.account.id, {:external_redirect, auth_url})
m.replyfun.(["To link your Untappd account, open this URL:", login_url])
{:noreply, state}
end
- def handle_info({:irc, :text, m = %IRC.Message{text: "getmeta"<>_}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{text: "getmeta"<>_}}, state) do
result = case String.split(m.text, " ") do
["getmeta"] ->
for {k, v} <- Nola.Account.get_all_meta(m.account) do
case k do
"u:"<>key -> "(user) #{key}: #{v}"
key -> "#{key}: #{v}"
end
end
["getmeta", key] ->
value = Nola.Account.get_meta(m.account, key)
text = if value do
"#{key}: #{value}"
else
"#{key} is not defined"
end
_ ->
"usage: getmeta [key]"
end
m.replyfun.(result)
{:noreply, state}
end
- def handle_info({:irc, :text, m = %IRC.Message{text: "setusermeta"<>_}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{text: "setusermeta"<>_}}, state) do
result = case String.split(m.text, " ") do
["setusermeta", key, value] ->
Nola.Account.put_user_meta(m.account, key, value)
"ok"
_ ->
"usage: setusermeta <key> <value>"
end
m.replyfun.(result)
{:noreply, state}
end
def handle_info(_, state) do
{:noreply, state}
end
defp join_account(m, id, token) do
old_account = Nola.Account.lookup(m.sender)
new_account = Nola.Account.get(id)
if new_account && token == new_account.token do
case Nola.Account.merge_account(old_account.id, new_account.id) do
:ok ->
if old_account.id == new_account.id do
m.replyfun.("Already authenticated, but hello")
else
m.replyfun.("Accounts merged!")
end
_ -> m.replyfun.("Something failed :(")
end
else
m.replyfun.("Invalid token")
end
end
end
diff --git a/lib/plugins/alcoolog.ex b/lib/plugins/alcoolog.ex
index cc56c4f..41b5a4f 100644
--- a/lib/plugins/alcoolog.ex
+++ b/lib/plugins/alcoolog.ex
@@ -1,1229 +1,1229 @@
defmodule Nola.Plugins.Alcoolog do
require Logger
@moduledoc """
# [alcoolog]({{context_path}}/alcoolog)
* **!santai `<cl | (calc)>` `<degrés d'alcool> [annotation]`**: enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`.
* **!santai `<cl | (calc)>` `<beer name>`**: enregistre un nouveau verre de `cl` de la bière `beer name`, et checkin sur Untappd.com.
* **!moar `[cl]` : enregistre un verre équivalent au dernier !santai.
* **-santai**: annule la dernière entrée d'alcoolisme.
* **.alcoolisme**: état du channel en temps réel.
* **.alcoolisme `<semaine | Xj>`**: points par jour, sur X j.
* **!alcoolisme `[pseudo]`**: affiche les points d'alcoolisme.
* **!alcoolisme `[pseudo]` `<semaine | Xj>`**: affiche les points d'alcoolisme par jour sur X j.
* **+alcoolisme `<h|f>` `<poids en kg>` `[facteur de perte en mg/l (10, 15, 20, 25)]`**: Configure votre profil d'alcoolisme.
* **.sobre**: affiche quand la sobriété frappera sur le chan.
* **!sobre `[pseudo]`**: affiche quand la sobriété frappera pour `[pseudo]`.
* **!sobrepour `<date>`**: affiche tu pourras être sobre pour `<date>`, et si oui, combien de volumes d'alcool peuvent encore être consommés.
* **!alcoolog**: ([voir]({{context_path}}/alcoolog)) lien pour voir l'état/statistiques et historique de l'alcoolémie du channel.
* **!alcool `<cl>` `<degrés>`**: donne le nombre d'unités d'alcool dans `<cl>` à `<degrés>°`.
* **!soif**: c'est quand l'apéro ?
1 point = 1 volume d'alcool.
Annotation: champ libre!
---
## `!txt`s
* status utilisateur: `alcoolog.user_(sober|legal|legalhigh|high|toohigh|sick)(|_rising)`
* mauvaises boissons: `alcoolog.drink_(negative|zero|negative)`
* santo: `alcoolog.santo`
* santai: `alcoolog.santai`
* plus gros, moins gros: `alcoolog.(fatter|thinner)`
"""
def irc_doc, do: @moduledoc
def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
# tuple dets: {nick, date, volumes, current_alcohol_level, nom, commentaire}
# tuple ets: {{nick, date}, volumes, current, nom, commentaire}
# tuple meta dets: {nick, map}
# %{:weight => float, :sex => true(h),false(f)}
@pubsub ~w(account)
@pubsub_triggers ~w(santai moar again bis santo santeau alcoolog sobre sobrepour soif alcoolisme alcool)
@default_user_meta %{weight: 77.4, sex: true, loss_factor: 15}
def data_state() do
dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist
dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist
%{dets: dets_filename, meta: dets_meta_filename, ets: __MODULE__.ETS}
end
def init(_) do
triggers = for(t <- @pubsub_triggers, do: "trigger:"<>t)
for sub <- @pubsub ++ triggers do
{:ok, _} = Registry.register(Nola.PubSub, sub, plugin: __MODULE__)
end
dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist
{:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}])
ets = :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}])
dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist
{:ok, meta} = :dets.open_file(dets_meta_filename, [{:type,:set}])
traverse_fun = fn(obj, dets) ->
case obj do
object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment} ->
date = naive
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_unix()
new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()}
:dets.delete_object(dets, object)
:dets.insert(dets, new)
:ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()})
dets
object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment, meta} ->
date = naive
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_unix()
new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()}
:dets.delete_object(dets, object)
:dets.insert(dets, new)
:ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()})
dets
object = {nick, date, volumes, active, cl, deg, name, comment, meta} ->
:ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, meta})
dets
_ ->
dets
end
end
:dets.foldl(traverse_fun, dets, dets)
:dets.sync(dets)
state = %{dets: dets, meta: meta, ets: ets}
{:ok, state}
end
@eau ["santo", "santeau"]
- def handle_info({:irc, :trigger, santeau, m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) when santeau in @eau do
+ def handle_info({:irc, :trigger, santeau, m = %Nola.Message{trigger: %Nola.Trigger{args: _, type: :bang}}}, state) when santeau in @eau do
Nola.Plugins.Txt.reply_random(m, "alcoolog.santo")
{:noreply, state}
end
- def handle_info({:irc, :trigger, "soif", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "soif", m = %Nola.Message{trigger: %Nola.Trigger{args: _, type: :bang}}}, state) do
now = DateTime.utc_now()
|> Timex.Timezone.convert("Europe/Paris")
apero = format_duration_from_now(%DateTime{now | hour: 18, minute: 0, second: 0}, false)
day_of_week = Date.day_of_week(now)
{txt, apero?} = cond do
now.hour >= 0 && now.hour < 6 ->
{["apéro tardif ? Je dis OUI ! SANTAI !"], true}
now.hour >= 6 && now.hour < 12 ->
if day_of_week >= 6 do
{["de l'alcool pour le petit dej ? le week-end, pas de problème !"], true}
else
{["C'est quand même un peu tôt non ? Prochain apéro #{apero}"], false}
end
now.hour >= 12 && (now.hour < 14) ->
{["oui! c'est l'apéro de midi! (et apéro #{apero})",
"tu peux attendre #{apero} ou y aller, il est midi !"
], true}
now.hour == 17 ->
{[
"ÇA APPROCHE !!! Apéro #{apero}",
"BIENTÔT !!! Apéro #{apero}",
"achetez vite les teilles, apéro dans #{apero}!",
"préparez les teilles, apéro dans #{apero}!"
], false}
now.hour >= 14 && now.hour < 18 ->
weekend = if day_of_week >= 6 do
" ... ou maintenant en fait, c'est le week-end!"
else
""
end
{["tiens bon! apéro #{apero}#{weekend}",
"courage... apéro dans #{apero}#{weekend}",
"pas encore :'( apéro dans #{apero}#{weekend}"
], false}
true ->
{[
"C'EST L'HEURE DE L'APÉRO !!! SANTAIIIIIIIIIIII !!!!"
], true}
end
txt = txt
|> Enum.shuffle()
|> Enum.random()
m.replyfun.(txt)
stats = get_full_statistics(state, m.account.id)
if !apero? && stats.active > 0.1 do
m.replyfun.("(... ou continue en fait, je suis pas ta mère !)")
end
{:noreply, state}
end
- def handle_info({:irc, :trigger, "sobrepour", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "sobrepour", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do
args = Enum.join(args, " ")
{:ok, now} = DateTime.now("Europe/Paris", Tzdata.TimeZoneDatabase)
time = case args do
"demain " <> time ->
{h, m} = case String.split(time, [":", "h"]) do
[hour, ""] ->
IO.puts ("h #{inspect hour}")
{h, _} = Integer.parse(hour)
{h, 0}
[hour, min] when min != "" ->
{h, _} = Integer.parse(hour)
{m, _} = Integer.parse(min)
{h, m}
[hour] ->
IO.puts ("h #{inspect hour}")
{h, _} = Integer.parse(hour)
{h, 0}
_ -> {0, 0}
end
secs = ((60*60)*24)
day = DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase)
%DateTime{day | hour: h, minute: m, second: 0}
"après demain " <> time ->
secs = 2*((60*60)*24)
DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase)
datetime ->
case Timex.Parse.DateTime.Parser.parse(datetime, "{}") do
{:ok, dt} -> dt
_ -> nil
end
end
if time do
meta = get_user_meta(state, m.account.id)
stats = get_full_statistics(state, m.account.id)
duration = round(DateTime.diff(time, now)/60.0)
IO.puts "diff #{inspect duration} sober in #{inspect stats.sober_in}"
if duration < stats.sober_in do
int = stats.sober_in - duration
m.replyfun.("désolé, aucune chance! tu seras sobre #{format_minute_duration(int)} après!")
else
remaining = duration - stats.sober_in
if remaining < 30 do
m.replyfun.("moins de 30 minutes de sobriété, c'est impossible de boire plus")
else
loss_per_minute = ((meta.loss_factor/100)/60)
remaining_gl = (remaining-30)*loss_per_minute
m.replyfun.("marge de boisson: #{inspect remaining} minutes, #{remaining_gl} g/l")
end
end
end
{:noreply, state}
end
- def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :plus}}}, state) do
+ def handle_info({:irc, :trigger, "alcoolog", m = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :plus}}}, state) do
{:ok, token} = Nola.Token.new({:alcoolog, :index, m.sender.network, m.channel})
url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel), token)
m.replyfun.("-> #{url}")
{:noreply, state}
end
- def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "alcoolog", m = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :bang}}}, state) do
url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel))
m.replyfun.("-> #{url}")
{:noreply, state}
end
- def handle_info({:irc, :trigger, "alcool", m = %IRC.Message{trigger: %IRC.Trigger{args: args = [cl, deg], type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "alcool", m = %Nola.Message{trigger: %Nola.Trigger{args: args = [cl, deg], type: :bang}}}, state) do
{cl, _} = Util.float_paparse(cl)
{deg, _} = Util.float_paparse(deg)
points = Alcool.units(cl, deg)
meta = get_user_meta(state, m.account.id)
k = if meta.sex, do: 0.7, else: 0.6
weight = meta.weight
gl = (10*points)/(k*weight)
duration = round(gl/((meta.loss_factor/100)/60))+30
sober_in_s = if duration > 0 do
duration = Timex.Duration.from_minutes(duration)
Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized)
else
""
end
m.replyfun.("Il y a #{Float.round(points+0.0, 4)} unités d'alcool dans #{cl}cl à #{deg}° (#{Float.round(gl + 0.0, 4)} g/l, #{sober_in_s})")
{:noreply, state}
end
- def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: [cl, deg | comment], type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "santai", m = %Nola.Message{trigger: %Nola.Trigger{args: [cl, deg | comment], type: :bang}}}, state) do
santai(m, state, cl, deg, comment)
{:noreply, state}
end
@moar [
"{{message.sender.nick}}: la même donc ?",
"{{message.sender.nick}}: et voilà la petite sœur !"
]
- def handle_info({:irc, :trigger, "bis", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "bis", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do
handle_info({:irc, :trigger, "moar", m}, state)
end
- def handle_info({:irc, :trigger, "again", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "again", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do
handle_info({:irc, :trigger, "moar", m}, state)
end
- def handle_info({:irc, :trigger, "moar", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "moar", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do
case get_statistics_for_nick(state, m.account.id) do
{_, obj = {_, _date, _points, _active, cl, deg, _name, comment, _meta}} ->
cl = case args do
[cls] ->
case Util.float_paparse(cls) do
{cl, _} -> cl
_ -> cl
end
_ -> cl
end
moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.()
santai(m, state, cl, deg, comment, auto_set: true)
{_, obj = {_, date, points, _last_active, type, descr}} ->
case Regex.named_captures(~r/^(?<cl>\d+[.]\d+)cl\s+(?<deg>\d+[.]\d+)°$/, type) do
nil -> m.replyfun.("suce")
u ->
moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.()
santai(m, state, u["cl"], u["deg"], descr, auto_set: true)
end
_ -> nil
end
{:noreply, state}
end
defp santai(m, state, cl, deg, comment, options \\ []) do
comment = cond do
comment == [] -> nil
is_binary(comment) -> comment
comment == nil -> nil
true -> Enum.join(comment, " ")
end
{cl, cl_extra} = case {Util.float_paparse(cl), cl} do
{{cl, extra}, _} -> {cl, extra}
{:error, "("<>_} ->
try do
{:ok, result} = Abacus.eval(cl)
{result, nil}
rescue
_ -> {nil, "cl: invalid calc expression"}
end
{:error, _} -> {nil, "cl: invalid value"}
end
{deg, comment, auto_set, beer_id} = case Util.float_paparse(deg) do
{deg, _} -> {deg, comment, Keyword.get(options, :auto_set, false), nil}
:error ->
beername = if(comment, do: "#{deg} #{comment}", else: deg)
case Untappd.search_beer(beername, limit: 1) do
{:ok, %{"response" => %{"beers" => %{"count" => count, "items" => [%{"beer" => beer, "brewery" => brewery} | _]}}}} ->
{Map.get(beer, "beer_abv"), "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")}", true, Map.get(beer, "bid")}
_ ->
{deg, "could not find beer", false, nil}
end
end
cond do
cl == nil -> m.replyfun.(cl_extra)
deg == nil -> m.replyfun.(comment)
cl >= 500 || deg >= 100 -> Nola.Plugins.Txt.reply_random(m, "alcoolog.drink_toohuge")
cl == 0 || deg == 0 -> Nola.Plugins.Txt.reply_random(m, "alcoolog.drink_zero")
cl < 0 || deg < 0 -> Nola.Plugins.Txt.reply_random(m, "alcoolog.drink_negative")
true ->
points = Alcool.units(cl, deg)
now = m.at || DateTime.utc_now()
|> DateTime.to_unix(:millisecond)
user_meta = get_user_meta(state, m.account.id)
name = "#{cl}cl #{deg}°"
old_stats = get_full_statistics(state, m.account.id)
meta = %{}
meta = Map.put(meta, "timestamp", now)
meta = Map.put(meta, "weight", user_meta.weight)
meta = Map.put(meta, "sex", user_meta.sex)
:ok = :dets.insert(state.dets, {m.account.id, now, points, if(old_stats, do: old_stats.active, else: 0), cl, deg, name, comment, meta})
true = :ets.insert(state.ets, {{m.account.id, now}, points, if(old_stats, do: old_stats.active, else: 0),cl, deg, name, comment, meta})
#sante = @santai |> Enum.map(fn(s) -> String.trim(String.upcase(s)) end) |> Enum.shuffle() |> Enum.random()
sante = Nola.Plugins.Txt.random("alcoolog.santai")
k = if user_meta.sex, do: 0.7, else: 0.6
weight = user_meta.weight
peak = Float.round((10*points||0.0)/(k*weight), 4)
stats = get_full_statistics(state, m.account.id)
sober_add = if old_stats && Map.get(old_stats || %{}, :sober_in) do
mins = round(stats.sober_in - old_stats.sober_in)
" [+#{mins}m]"
else
""
end
nonow = DateTime.utc_now()
sober = nonow |> DateTime.add(round(stats.sober_in*60), :second)
|> Timex.Timezone.convert("Europe/Paris")
at = if nonow.day == sober.day do
{:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr")
detail
else
{:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr")
detail
end
up = if stats.active_drinks > 1 do
" " <> Enum.join(for(_ <- 1..stats.active_drinks, do: "▲")) <> ""
else
""
end
since_str = if stats.since && stats.since_min > 180 do
"(depuis: #{stats.since_s}) "
else
""
end
msg = fn(nick, extra) ->
"#{sante} #{nick} #{extra}#{up} #{format_points(points)} @#{stats.active}g/l [+#{peak} g/l]"
<> " (15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) #{since_str}(sobriété #{at} (dans #{stats.sober_in_s})#{sober_add}) !"
<> " (aujourd'hui #{stats.daily_volumes} points - #{stats.daily_gl} g/l)"
end
meta = if beer_id do
Map.put(meta, "untappd:beer_id", beer_id)
else
meta
end
if beer_id do
spawn(fn() ->
case Untappd.maybe_checkin(m.account, beer_id) do
{:ok, body} ->
badges = get_in(body, ["badges", "items"])
if badges != [] do
badges_s = Enum.map(badges, fn(badge) -> Map.get(badge, "badge_name") end)
|> Enum.filter(fn(b) -> b end)
|> Enum.intersperse(", ")
|> Enum.join("")
badge = if(length(badges) > 1, do: "badges", else: "badge")
m.replyfun.("\\O/ Unlocked untappd #{badge}: #{badges_s}")
end
:ok
{:error, {:http_error, error}} when is_integer(error) -> m.replyfun.("Checkin to Untappd failed: #{to_string(error)}")
{:error, {:http_error, error}} -> m.replyfun.("Checkin to Untappd failed: #{inspect error}")
_ -> :error
end
end)
end
local_extra = if auto_set do
if comment do
" #{comment} (#{cl}cl @ #{deg}°)"
else
"#{cl}cl @ #{deg}°"
end
else
""
end
m.replyfun.(msg.(m.sender.nick, local_extra))
notify = Nola.Membership.notify_channels(m.account) -- [{m.network,m.channel}]
for {net, chan} <- notify do
user = Nola.UserTrack.find_by_account(net, m.account)
nick = if(user, do: user.nick, else: m.account.name)
extra = " " <> present_type(name, comment) <> ""
IRC.Connection.broadcast_message(net, chan, msg.(nick, extra))
end
miss = cond do
points <= 0.6 -> :small
stats.active30m >= 2.9 && stats.active30m < 3 -> :miss3
stats.active30m >= 1.9 && stats.active30m < 2 -> :miss2
stats.active30m >= 0.9 && stats.active30m < 1 -> :miss1
stats.active30m >= 0.45 && stats.active30m < 0.5 -> :miss05
stats.active30m >= 0.20 && stats.active30m < 0.20 -> :miss025
stats.active30m >= 3 && stats.active1h < 3.15 -> :small3
stats.active30m >= 2 && stats.active1h < 2.15 -> :small2
stats.active30m >= 1.5 && stats.active1h < 1.5 -> :small15
stats.active30m >= 1 && stats.active1h < 1.15 -> :small1
stats.active30m >= 0.5 && stats.active1h <= 0.51 -> :small05
stats.active30m >= 0.25 && stats.active30m <= 0.255 -> :small025
true -> nil
end
if miss do
miss = Nola.Plugins.Txt.random("alcoolog.#{to_string(miss)}")
if miss do
for {net, chan} <- Nola.Membership.notify_channels(m.account) do
user = Nola.UserTrack.find_by_account(net, m.account)
nick = if(user, do: user.nick, else: m.account.name)
IRC.Connection.broadcast_message(net, chan, "#{nick}: #{miss}")
end
end
end
end
end
- def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "santai", m = %Nola.Message{trigger: %Nola.Trigger{args: _, type: :bang}}}, state) do
m.replyfun.("!santai <cl> <degrés> [commentaire]")
{:noreply, state}
end
def get_all_stats() do
Nola.Account.all_accounts()
|> Enum.map(fn(account) -> {account.id, get_full_statistics(account.id)} end)
|> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end)
|> Enum.sort_by(fn({_, status}) -> status.active end, &>/2)
end
def get_channel_statistics(account, network, nil) do
Nola.Membership.expanded_members_or_friends(account, network, nil)
|> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end)
|> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end)
|> Enum.sort_by(fn({_, status}) -> status.active end, &>/2)
end
def get_channel_statistics(_, network, channel), do: get_channel_statistics(network, channel)
def get_channel_statistics(network, channel) do
Nola.Membership.expanded_members(network, channel)
|> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end)
|> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end)
|> Enum.sort_by(fn({_, status}) -> status.active end, &>/2)
end
@spec since() :: %{Nola.Account.id() => DateTime.t()}
@doc "Returns the last time the user was at 0 g/l"
def since() do
:ets.foldr(fn({{acct, timestamp_or_date}, _vol, current, _cl, _deg, _name, _comment, _m}, acc) ->
if !Map.get(acc, acct) && current == 0 do
date = Util.to_date_time(timestamp_or_date)
Map.put(acc, acct, date)
else
acc
end
end, %{}, __MODULE__.ETS)
end
def get_full_statistics(nick) do
get_full_statistics(data_state(), nick)
end
defp get_full_statistics(state, nick) do
case get_statistics_for_nick(state, nick) do
{count, {_, last_at, last_points, last_active, last_cl, last_deg, last_type, last_descr, _meta}} ->
{active, active_drinks} = current_alcohol_level(state, nick)
{_, m30} = alcohol_level_rising(state, nick)
{rising, m15} = alcohol_level_rising(state, nick, 15)
{_, m5} = alcohol_level_rising(state, nick, 5)
{_, h1} = alcohol_level_rising(state, nick, 60)
trend = if rising do
"▲"
else
"▼"
end
user_state = cond do
active <= 0.0 -> :sober
active <= 0.25 -> :low
active <= 0.50 -> :legal
active <= 1.0 -> :legalhigh
active <= 2.5 -> :high
active < 3 -> :toohigh
true -> :sick
end
rising_file_key = if rising, do: "_rising", else: ""
txt_file = "alcoolog." <> "user_" <> to_string(user_state) <> rising_file_key
user_status = Nola.Plugins.Txt.random(txt_file)
meta = get_user_meta(state, nick)
minutes_til_sober = h1/((meta.loss_factor/100)/60)
minutes_til_sober = cond do
active < 0 -> 0
m15 < 0 -> 15
m30 < 0 -> 30
h1 < 0 -> 60
minutes_til_sober > 0 ->
Float.round(minutes_til_sober+60)
true -> 0
end
duration = Timex.Duration.from_minutes(minutes_til_sober)
sober_in_s = if minutes_til_sober > 0 do
Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized)
else
nil
end
since = if active > 0 do
since()
|> Map.get(nick)
end
since_diff = if since, do: Timex.diff(DateTime.utc_now(), since, :minutes)
since_duration = if since, do: Timex.Duration.from_minutes(since_diff)
since_s = if since, do: Timex.Format.Duration.Formatter.lformat(since_duration, "fr", :humanized)
{total_volumes, total_gl} = user_stats(state, nick)
%{active: active, last_at: last_at, last_cl: last_cl, last_deg: last_deg, last_points: last_points, last_type: last_type, last_descr: last_descr,
trend_symbol: trend,
active5m: m5, active15m: m15, active30m: m30, active1h: h1,
rising: rising,
active_drinks: active_drinks,
user_status: user_status,
daily_gl: total_gl, daily_volumes: total_volumes,
sober_in: minutes_til_sober, sober_in_s: sober_in_s,
since: since, since_min: since_diff, since_s: since_s,
}
_ ->
nil
end
end
- def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :dot}}}, state) do
+ def handle_info({:irc, :trigger, "sobre", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :dot}}}, state) do
nicks = Nola.Membership.expanded_members_or_friends(m.account, m.network, m.channel)
|> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end)
|> Enum.filter(fn({_nick, status}) -> status && status.sober_in && status.sober_in > 0 end)
|> Enum.sort_by(fn({_, status}) -> status.sober_in end, &</2)
|> Enum.map(fn({nick, stats}) ->
now = DateTime.utc_now()
sober = now |> DateTime.add(round(stats.sober_in*60), :second)
|> Timex.Timezone.convert("Europe/Paris")
at = if now.day == sober.day do
{:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr")
detail
else
{:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr")
detail
end
"#{nick} sobre #{at} (dans #{stats.sober_in_s})"
end)
|> Enum.intersperse(", ")
|> Enum.join("")
|> (fn(line) ->
case line do
"" -> "tout le monde est sobre......."
line -> line
end
end).()
|> m.replyfun.()
{:noreply, state}
end
- def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "sobre", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do
account = case args do
[nick] -> Nola.Account.find_always_by_nick(m.network, m.channel, nick)
[] -> m.account
end
if account do
user = Nola.UserTrack.find_by_account(m.network, account)
nick = if(user, do: user.nick, else: account.name)
stats = get_full_statistics(state, account.id)
if stats && stats.sober_in > 0 do
now = DateTime.utc_now()
sober = now |> DateTime.add(round(stats.sober_in*60), :second)
|> Timex.Timezone.convert("Europe/Paris")
at = if now.day == sober.day do
{:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr")
detail
else
{:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr")
detail
end
m.replyfun.("#{nick} sera sobre #{at} (dans #{stats.sober_in_s})!")
else
m.replyfun.("#{nick} est déjà sobre. aidez le !")
end
else
m.replyfun.("inconnu")
end
{:noreply, state}
end
- def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :dot}}}, state) do
+ def handle_info({:irc, :trigger, "alcoolisme", m = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :dot}}}, state) do
nicks = Nola.Membership.expanded_members_or_friends(m.account, m.network, m.channel)
|> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end)
|> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end)
|> Enum.sort_by(fn({_, status}) -> status.active end, &>/2)
|> Enum.map(fn({nick, status}) ->
trend_symbol = if status.active_drinks > 1 do
Enum.join(for(_ <- 1..status.active_drinks, do: status.trend_symbol))
else
status.trend_symbol
end
since_str = if status.since_min > 180 do
"depuis: #{status.since_s} | "
else
""
end
"#{nick} #{status.user_status} #{trend_symbol} #{Float.round(status.active, 4)} g/l [#{since_str}sobre dans: #{status.sober_in_s}]"
end)
|> Enum.intersperse(", ")
|> Enum.join("")
msg = if nicks == "" do
"wtf?!?! personne n'a bu!"
else
nicks
end
m.replyfun.(msg)
{:noreply, state}
end
- def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [time], type: :dot}}}, state) do
+ def handle_info({:irc, :trigger, "alcoolisme", m = %Nola.Message{trigger: %Nola.Trigger{args: [time], type: :dot}}}, state) do
time = case time do
"semaine" -> 7
string ->
case Integer.parse(string) do
{time, "j"} -> time
{time, "J"} -> time
_ -> nil
end
end
if time do
aday = time*((24 * 60)*60)
now = DateTime.utc_now()
before = now
|> DateTime.add(-aday, :second)
|> DateTime.to_unix(:millisecond)
over_time_stats(before, time, m, state)
else
m.replyfun.(".alcooolisme semaine|Xj")
end
{:noreply, state}
end
def user_over_time(account, count) do
user_over_time(data_state(), account, count)
end
def user_over_time(state, account, count) do
delay = count*((24 * 60)*60)
now = DateTime.utc_now()
before = DateTime.utc_now()
|> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase)
|> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase)
|> DateTime.to_unix(:millisecond)
#[
# {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_},
# [{:andalso, {:==, :"$1", :"$1"}, {:<, :"$2", {:const, 3000}}}], [:lol]}
#]
match = [{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_},
[{:andalso, {:>, :"$2", {:const, before}}, {:==, :"$1", {:const, account.id}}}], [:"$_"]}
]
:ets.select(state.ets, match)
|> Enum.reduce(Map.new, fn({{_, ts}, vol, _, _, _, _, _, _}, acc) ->
date = DateTime.from_unix!(ts, :millisecond)
|> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase)
date = if date.hour <= 8 do
DateTime.add(date, -(60*(60*(date.hour+1))), :second, Tzdata.TimeZoneDatabase)
else
date
end
|> DateTime.to_date()
Map.put(acc, date, Map.get(acc, date, 0) + vol)
end)
end
def user_over_time_gl(account, count) do
state = data_state()
meta = get_user_meta(state, account.id)
delay = count*((24 * 60)*60)
now = DateTime.utc_now()
before = DateTime.utc_now()
|> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase)
|> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase)
|> DateTime.to_unix(:millisecond)
#[
# {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_},
# [{:andalso, {:==, :"$1", :"$1"}, {:<, :"$2", {:const, 3000}}}], [:lol]}
#]
match = [{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_},
[{:andalso, {:>, :"$2", {:const, before}}, {:==, :"$1", {:const, account.id}}}], [:"$_"]}
]
:ets.select(state.ets, match)
|> Enum.reduce(Map.new, fn({{_, ts}, vol, _, _, _, _, _, _}, acc) ->
date = DateTime.from_unix!(ts, :millisecond)
|> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase)
date = if date.hour <= 8 do
DateTime.add(date, -(60*(60*(date.hour+1))), :second, Tzdata.TimeZoneDatabase)
else
date
end
|> DateTime.to_date()
weight = meta.weight
k = if meta.sex, do: 0.7, else: 0.6
gl = (10*vol)/(k*weight)
Map.put(acc, date, Map.get(acc, date, 0) + gl)
end)
end
defp over_time_stats(before, j, m, state) do
#match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _, _, _, _}) when date > before -> obj end)
match = [{{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_},
[{:>, :"$1", {:const, before}}], [:"$_"]}
]
# tuple ets: {{nick, date}, volumes, current, nom, commentaire}
members = Nola.Membership.members_or_friends(m.account, m.network, m.channel)
drinks = :ets.select(state.ets, match)
|> Enum.filter(fn({{account, _}, _, _, _, _, _, _, _}) -> Enum.member?(members, account) end)
|> Enum.sort_by(fn({{_, ts}, _, _, _, _, _, _, _}) -> ts end, &>/2)
top = Enum.reduce(drinks, %{}, fn({{nick, _}, vol, _, _, _, _, _, _}, acc) ->
all = Map.get(acc, nick, 0)
Map.put(acc, nick, all + vol)
end)
|> Enum.sort_by(fn({_nick, count}) -> count end, &>/2)
|> Enum.map(fn({nick, count}) ->
account = Nola.Account.get(nick)
user = Nola.UserTrack.find_by_account(m.network, account)
nick = if(user, do: user.nick, else: account.name)
"#{nick}: #{Float.round(count, 4)}"
end)
|> Enum.intersperse(", ")
m.replyfun.("sur #{j} jours: #{top}")
{:noreply, state}
end
- def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :plus}}}, state) do
+ def handle_info({:irc, :trigger, "alcoolisme", m = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :plus}}}, state) do
meta = get_user_meta(state, m.account.id)
hf = if meta.sex, do: "h", else: "f"
m.replyfun.("+alcoolisme sexe: #{hf} poids: #{meta.weight} facteur de perte: #{meta.loss_factor}")
{:noreply, state}
end
- def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [h, weight | rest], type: :plus}}}, state) do
+ def handle_info({:irc, :trigger, "alcoolisme", m = %Nola.Message{trigger: %Nola.Trigger{args: [h, weight | rest], type: :plus}}}, state) do
h = case h do
"h" -> true
"f" -> false
_ -> nil
end
weight = case Util.float_paparse(weight) do
{weight, _} -> weight
_ -> nil
end
{factor} = case rest do
[factor] ->
case Util.float_paparse(factor) do
{float, _} -> {float}
_ -> {@default_user_meta.loss_factor}
end
_ -> {@default_user_meta.loss_factor}
end
if h == nil || weight == nil do
m.replyfun.("paramètres invalides")
else
old_meta = get_user_meta(state, m.account.id)
meta = Map.merge(@default_user_meta, %{sex: h, weight: weight, loss_factor: factor})
put_user_meta(state, m.account.id, meta)
cond do
old_meta.weight < meta.weight ->
Nola.Plugins.Txt.reply_random(m, "alcoolog.fatter")
old_meta.weight == meta.weight ->
m.replyfun.("aucun changement!")
true ->
Nola.Plugins.Txt.reply_random(m, "alcoolog.thinner")
end
end
{:noreply, state}
end
- def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :minus}}}, state) do
+ def handle_info({:irc, :trigger, "santai", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :minus}}}, state) do
case get_statistics_for_nick(state, m.account.id) do
{_, obj = {_, date, points, _last_active, _cl, _deg, type, descr, _meta}} ->
:dets.delete_object(state.dets, obj)
:ets.delete(state.ets, {m.account.id, date})
m.replyfun.("supprimé: #{m.sender.nick} #{points} #{type} #{descr}")
Nola.Plugins.Txt.reply_random(m, "alcoolog.delete")
notify = Nola.Membership.notify_channels(m.account) -- [{m.network,m.channel}]
for {net, chan} <- notify do
user = Nola.UserTrack.find_by_account(net, m.account)
nick = if(user, do: user.nick, else: m.account.name)
IRC.Connection.broadcast_message(net, chan, "#{nick} -santai #{points} #{type} #{descr}")
end
{:noreply, state}
_ ->
{:noreply, state}
end
end
- def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "alcoolisme", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do
{account, duration} = case args do
[nick | rest] -> {Nola.Account.find_always_by_nick(m.network, m.channel, nick), rest}
[] -> {m.account, []}
end
if account do
duration = case duration do
["semaine"] -> 7
[j] ->
case Integer.parse(j) do
{j, "j"} -> j
_ -> nil
end
_ -> nil
end
user = Nola.UserTrack.find_by_account(m.network, account)
nick = if(user, do: user.nick, else: account.name)
if duration do
if duration > 90 do
m.replyfun.("trop gros, ça rentrera pas")
else
# duration stats
stats = user_over_time(state, account, duration)
|> Enum.sort_by(fn({k,_v}) -> k end, {:asc, Date})
|> Enum.map(fn({date, count}) ->
"#{date.day}: #{Float.round(count, 2)}"
end)
|> Enum.intersperse(", ")
|> Enum.join("")
if stats == "" do
m.replyfun.("alcoolisme a zéro sur #{duration}j :/")
else
m.replyfun.("alcoolisme de #{nick}, #{duration} derniers jours: #{stats}")
end
end
else
if stats = get_full_statistics(state, account.id) do
trend_symbol = if stats.active_drinks > 1 do
Enum.join(for(_ <- 1..stats.active_drinks, do: stats.trend_symbol))
else
stats.trend_symbol
end
# TODO: Lookup nick for account_id
msg = "#{nick} #{stats.user_status} "
<> (if stats.active > 0 || stats.active15m > 0 || stats.active30m > 0 || stats.active1h > 0, do: ": #{trend_symbol} #{Float.round(stats.active, 4)}g/l ", else: "")
<> (if stats.active30m > 0 || stats.active1h > 0, do: "(15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) ", else: "")
<> (if stats.sober_in > 0, do: "— Sobre dans #{stats.sober_in_s} ", else: "")
<> (if stats.since && stats.since_min > 180, do: "— Paitai depuis #{stats.since_s} ", else: "")
<> "— Dernier verre: #{present_type(stats.last_type, stats.last_descr)} [#{Float.round(stats.last_points+0.0, 4)}] "
<> "#{format_duration_from_now(stats.last_at)} "
<> (if stats.daily_volumes > 0, do: "— Aujourd'hui: #{stats.daily_volumes} #{stats.daily_gl}g/l", else: "")
m.replyfun.(msg)
else
m.replyfun.("honteux mais #{nick} n'a pas l'air alcoolique du tout. /kick")
end
end
else
m.replyfun.("je ne connais pas cet utilisateur")
end
{:noreply, state}
end
# Account merge
def handle_info({:account_change, old_id, new_id}, state) do
spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}]
Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) ->
Logger.debug("alcolog/account_change:: merging #{old_id} -> #{new_id}")
rename_object_owner(table, state.ets, obj, old_id, new_id)
end)
case :dets.lookup(state.meta, {:meta, old_id}) do
[{_, meta}] ->
:dets.delete(state.meta, {:meta, old_id})
:dets.insert(state.meta, {{:meta, new_id}, meta})
_ ->
:ok
end
{:noreply, state}
end
def terminate(_, state) do
for dets <- [state.dets, state.meta] do
:dets.sync(dets)
:dets.close(dets)
end
end
defp rename_object_owner(table, ets, object = {old_id, date, volume, current, cl, deg, name, comment, meta}, old_id, new_id) do
:dets.delete_object(table, object)
:ets.delete(ets, {old_id, date})
:dets.insert(table, {new_id, date, volume, current, cl, deg, name, comment, meta})
:ets.insert(ets, {{new_id, date}, volume, current, cl, deg, name, comment, meta})
end
# Account: move from nick to account id
def handle_info({:accounts, accounts}, state) do
#for x={:account, _, _, _, _} <- accounts, do: handle_info(x, state)
#{:noreply, state}
mapping = Enum.reduce(accounts, Map.new, fn({:account, _net, _chan, nick, account_id}, acc) ->
Map.put(acc, String.downcase(nick), account_id)
end)
spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [], [:"$_"]}]
Logger.debug("accounts:: mappings #{inspect mapping}")
Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj = {nick, _date, _vol, _cur, _cl, _deg, _name, _comment, _meta}) ->
#Logger.debug("accounts:: item #{inspect(obj)}")
if new_id = Map.get(mapping, nick) do
Logger.debug("alcolog/accounts:: merging #{nick} -> #{new_id}")
rename_object_owner(table, state.ets, obj, nick, new_id)
end
end)
{:noreply, state}
end
def handle_info({:account, _net, _chan, nick, account_id}, state) do
nick = String.downcase(nick)
spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}]
Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) ->
Logger.debug("alcoolog/account:: merging #{nick} -> #{account_id}")
rename_object_owner(table, state.ets, obj, nick, account_id)
end)
case :dets.lookup(state.meta, {:meta, nick}) do
[{_, meta}] ->
:dets.delete(state.meta, {:meta, nick})
:dets.insert(state.meta, {{:meta, account_id}, meta})
_ ->
:ok
end
{:noreply, state}
end
def handle_info(t, state) do
Logger.debug("#{__MODULE__}: unhandled info #{inspect t}")
{:noreply, state}
end
def nick_history(account) do
spec = [
{{{:"$1", :_}, :_, :_, :_, :_, :_, :_, :_},
[{:==, :"$1", {:const, account.id}}],
[:"$_"]}
]
:ets.select(data_state().ets, spec)
end
defp get_statistics_for_nick(state, account_id) do
qvc = :dets.lookup(state.dets, account_id)
|> Enum.sort_by(fn({_, ts, _, _, _, _, _, _, _}) -> ts end, &</2)
count = Enum.reduce(qvc, 0, fn({_nick, _ts, points, _active, _cl, _deg, _type, _descr, _meta}, acc) -> acc + (points||0) end)
last = List.last(qvc) || nil
{count, last}
end
def present_type(type, descr) when descr in [nil, ""], do: "#{type}"
def present_type(type, description), do: "#{type} (#{description})"
def format_points(int) when is_integer(int) and int > 0 do
"+#{Integer.to_string(int)}"
end
def format_points(int) when is_integer(int) and int < 0 do
Integer.to_string(int)
end
def format_points(int) when is_float(int) and int > 0 do
"+#{Float.to_string(Float.round(int,4))}"
end
def format_points(int) when is_float(int) and int < 0 do
Float.to_string(Float.round(int,4))
end
def format_points(0), do: "0"
def format_points(0.0), do: "0"
defp format_relative_timestamp(timestamp) do
alias Timex.Format.DateTime.Formatters
alias Timex.Timezone
date = timestamp
|> DateTime.from_unix!(:millisecond)
|> Timezone.convert("Europe/Paris")
{:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr")
{:ok, detail} = Formatters.Default.lformat(date, " ({h24}:{m})", "fr")
relative <> detail
end
defp put_user_meta(state, account_id, meta) do
:dets.insert(state.meta, {{:meta, account_id}, meta})
:ok
end
defp get_user_meta(%{meta: meta}, account_id) do
case :dets.lookup(meta, {:meta, account_id}) do
[{{:meta, _}, meta}] ->
Map.merge(@default_user_meta, meta)
_ ->
@default_user_meta
end
end
# Calcul g/l actuel:
# 1. load user meta
# 2. foldr ets
# for each object
# get_current_alcohol
# ((object g/l) - 0,15/l/60)* minutes_since_drink
# if minutes_since_drink < 10, reduce g/l (?!)
# acc + current_alcohol
# stop folding when ?
#
def user_stats(account) do
user_stats(data_state(), account.id)
end
defp user_stats(state = %{ets: ets}, account_id) do
meta = get_user_meta(state, account_id)
aday = (10 * 60)*60
now = DateTime.utc_now()
before = now
|> DateTime.add(-aday, :second)
|> DateTime.to_unix(:millisecond)
#match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end)
match = [
{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_},
[
{:>, :"$2", {:const, before}},
{:"=:=", {:const, account_id}, :"$1"}
], [:"$_"]}
]
# tuple ets: {{nick, date}, volumes, current, nom, commentaire}
drinks = :ets.select(ets, match)
# {date, single_peak}
total_volume = Enum.reduce(drinks, 0.0, fn({{_, date}, volume, _, _, _, _, _, _}, acc) ->
acc + volume
end)
k = if meta.sex, do: 0.7, else: 0.6
weight = meta.weight
gl = (10*total_volume)/(k*weight)
{Float.round(total_volume + 0.0, 4), Float.round(gl + 0.0, 4)}
end
defp alcohol_level_rising(state, account_id, minutes \\ 30) do
{now, _} = current_alcohol_level(state, account_id)
soon_date = DateTime.utc_now
|> DateTime.add(minutes*60, :second)
{soon, _} = current_alcohol_level(state, account_id, soon_date)
soon = cond do
soon < 0 -> 0.0
true -> soon
end
#IO.puts "soon #{soon_date} - #{inspect soon} #{inspect now}"
{soon > now, Float.round(soon+0.0, 4)}
end
defp current_alcohol_level(state = %{ets: ets}, account_id, now \\ nil) do
meta = get_user_meta(state, account_id)
aday = ((24*7) * 60)*60
now = if now do
now
else
DateTime.utc_now()
end
before = now
|> DateTime.add(-aday, :second)
|> DateTime.to_unix(:millisecond)
#match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end)
match = [
{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_},
[
{:>, :"$2", {:const, before}},
{:"=:=", {:const, account_id}, :"$1"}
], [:"$_"]}
]
# tuple ets: {{nick, date}, volumes, current, nom, commentaire}
drinks = :ets.select(ets, match)
|> Enum.sort_by(fn({{_, date}, _, _, _, _, _, _, _}) -> date end, &</2)
# {date, single_peak}
{all, last_drink_at, gl, active_drinks} = Enum.reduce(drinks, {0.0, nil, [], 0}, fn({{_, date}, volume, _, _, _, _, _, _}, {all, last_at, acc, active_drinks}) ->
k = if meta.sex, do: 0.7, else: 0.6
weight = meta.weight
peak = (10*volume)/(k*weight)
date = case date do
ts when is_integer(ts) -> DateTime.from_unix!(ts, :millisecond)
date = %NaiveDateTime{} -> DateTime.from_naive!(date, "Etc/UTC")
date = %DateTime{} -> date
end
last_at = last_at || date
mins_since = round(DateTime.diff(now, date)/60.0)
#IO.puts "Drink: #{inspect({date, volume})} - mins since: #{inspect mins_since} - last drink at #{inspect last_at}"
# Apply loss since `last_at` on `all`
#
all = if last_at do
mins_since_last = round(DateTime.diff(date, last_at)/60.0)
loss = ((meta.loss_factor/100)/60)*(mins_since_last)
#IO.puts "Applying last drink loss: from #{all}, loss of #{inspect loss} (mins since #{inspect mins_since_first})"
cond do
(all-loss) > 0 -> all - loss
true -> 0.0
end
else
all
end
#IO.puts "Applying last drink current before drink: #{inspect all}"
if mins_since < 30 do
per_min = (peak)/30.0
current = (per_min*mins_since)
#IO.puts "Applying current drink 30m: from #{peak}, loss of #{inspect per_min}/min (mins since #{inspect mins_since})"
{all + current, date, [{date, current} | acc], active_drinks + 1}
else
{all + peak, date, [{date, peak} | acc], active_drinks}
end
end)
#IO.puts "last drink #{inspect last_drink_at}"
mins_since_last = if last_drink_at do
round(DateTime.diff(now, last_drink_at)/60.0)
else
0
end
# Si on a déjà bu y'a déjà moins 15 minutes (big up le binge drinking), on applique plus de perte
level = if mins_since_last > 15 do
loss = ((meta.loss_factor/100)/60)*(mins_since_last)
Float.round(all - loss, 4)
else
all
end
#IO.puts "\n LEVEL #{inspect level}\n\n\n\n"
cond do
level < 0 -> {0.0, 0}
true -> {level, active_drinks}
end
end
defp format_duration_from_now(date, with_detail \\ true) do
date = if is_integer(date) do
date = DateTime.from_unix!(date, :millisecond)
|> Timex.Timezone.convert("Europe/Paris")
else
Util.to_naive_date_time(date)
end
now = DateTime.utc_now()
|> Timex.Timezone.convert("Europe/Paris")
{:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(date, "({h24}:{m})", "fr")
mins_since = round(DateTime.diff(now, date)/60.0)
if ago = format_minute_duration(mins_since) do
word = if mins_since > 0 do
"il y a "
else
"dans "
end
word <> ago <> if(with_detail, do: " #{detail}", else: "")
else
"maintenant #{detail}"
end
end
defp format_minute_duration(minutes) do
sober_in_s = if (minutes != 0) do
duration = Timex.Duration.from_minutes(minutes)
Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized)
else
nil
end
end
end
diff --git a/lib/plugins/boursorama.ex b/lib/plugins/boursorama.ex
index 77977a5..025a250 100644
--- a/lib/plugins/boursorama.ex
+++ b/lib/plugins/boursorama.ex
@@ -1,58 +1,58 @@
defmodule Nola.Plugins.Boursorama do
def irc_doc() do
"""
# bourses
Un peu comme [finance](#finance), mais en un peu mieux, et un peu moins bien.
Source: [boursorama.com](https://boursorama.com)
* **!caca40** affiche l'état du cac40
"""
end
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
@cac40_url "https://www.boursorama.com/bourse/actions/palmares/france/?france_filter%5Bmarket%5D=1rPCAC&france_filter%5Bsector%5D=&france_filter%5Bvariation%5D=50002&france_filter%5Bperiod%5D=1&france_filter%5Bfilter%5D="
def init(_) do
regopts = [plugin: __MODULE__]
{:ok, _} = Registry.register(Nola.PubSub, "trigger:cac40", regopts)
{:ok, _} = Registry.register(Nola.PubSub, "trigger:caca40", regopts)
{:ok, nil}
end
- def handle_info({:irc, :trigger, cac, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) when cac in ["cac40", "caca40"] do
+ def handle_info({:irc, :trigger, cac, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}}}, state) when cac in ["cac40", "caca40"] do
case HTTPoison.get(@cac40_url, [], []) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
html = Floki.parse(body)
board = Floki.find(body, "div.c-tradingboard")
cac40 = Floki.find(board, ".c-tradingboard__main > .c-tradingboard__infos")
instrument = Floki.find(cac40, ".c-instrument")
last = Floki.find(instrument, "span[data-ist-last]")
|> Floki.text()
|> String.replace(" ", "")
variation = Floki.find(instrument, "span[data-ist-variation]")
|> Floki.text()
sign = case variation do
"-"<>_ -> "▼"
"+" -> "▲"
_ -> ""
end
m.replyfun.("caca40: #{sign} #{variation} #{last}")
{:error, %HTTPoison.Response{status_code: code}} ->
m.replyfun.("caca40: erreur http #{code}")
_ ->
m.replyfun.("caca40: erreur http")
end
end
end
diff --git a/lib/plugins/calc.ex b/lib/plugins/calc.ex
index e58e1b1..2ff6cb4 100644
--- a/lib/plugins/calc.ex
+++ b/lib/plugins/calc.ex
@@ -1,37 +1,37 @@
defmodule Nola.Plugins.Calc do
@moduledoc """
# calc
* **!calc `<expression>`**: évalue l'expression mathématique `<expression>`.
"""
def irc_doc, do: @moduledoc
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_) do
{:ok, _} = Registry.register(Nola.PubSub, "trigger:calc", [plugin: __MODULE__])
{:ok, nil}
end
- def handle_info({:irc, :trigger, "calc", message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: expr_list}}}, state) do
+ def handle_info({:irc, :trigger, "calc", message = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: expr_list}}}, state) do
expr = Enum.join(expr_list, " ")
result = try do
case Abacus.eval(expr) do
{:ok, result} -> result
error -> inspect(error)
end
rescue
error -> if(error[:message], do: "#{error.message}", else: "erreur")
end
message.replyfun.("#{message.sender.nick}: #{expr} = #{result}")
{:noreply, state}
end
def handle_info(msg, state) do
{:noreply, state}
end
end
diff --git a/lib/plugins/coronavirus.ex b/lib/plugins/coronavirus.ex
index 31483aa..afd8a33 100644
--- a/lib/plugins/coronavirus.ex
+++ b/lib/plugins/coronavirus.ex
@@ -1,172 +1,172 @@
defmodule Nola.Plugins.Coronavirus do
require Logger
NimbleCSV.define(CovidCsv, separator: ",", escape: "\"")
@moduledoc """
# Corona Virus
Données de [Johns Hopkins University](https://github.com/CSSEGISandData/COVID-19) et mises à jour a peu près tous les jours.
* `!coronavirus [France | Country]`: :-)
* `!coronavirus`: top 10 confirmés et non guéris
* `!coronavirus confirmés`: top 10 confirmés
* `!coronavirus morts`: top 10 morts
* `!coronavirus soignés`: top 10 soignés
"""
def irc_doc, do: @moduledoc
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_) do
{:ok, _} = Registry.register(Nola.PubSub, "trigger:coronavirus", [plugin: __MODULE__])
{:ok, nil, {:continue, :init}}
:ignore
end
def handle_continue(:init, _) do
date = Date.add(Date.utc_today(), -2)
{data, _} = fetch_data(%{}, date)
{data, next} = fetch_data(data)
:timer.send_after(next, :update)
{:noreply, %{data: data}}
end
def handle_info(:update, state) do
{data, next} = fetch_data(state.data)
:timer.send_after(next, :update)
{:noreply, %{data: data}}
end
- def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :bang, args: args}}}, state) when args in [
+ def handle_info({:irc, :trigger, "coronavirus", m = %Nola.Message{trigger: %{type: :bang, args: args}}}, state) when args in [
[], ["morts"], ["confirmés"], ["soignés"], ["malades"], ["n"], ["nmorts"], ["nsoignés"], ["nconfirmés"]] do
{field, name} = case args do
["confirmés"] -> {:confirmed, "confirmés"}
["morts"] -> {:deaths, "morts"}
["soignés"] -> {:recovered, "soignés"}
["nmorts"] -> {:new_deaths, "nouveaux morts"}
["nconfirmés"] -> {:new_confirmed, "nouveaux confirmés"}
["n"] -> {:new_current, "nouveaux malades"}
["nsoignés"] -> {:new_recovered, "nouveaux soignés"}
_ -> {:current, "malades"}
end
IO.puts("FIELD #{inspect field}")
field_evol = String.to_atom("new_#{field}")
sorted = state.data
|> Enum.filter(fn({_, %{region: region}}) -> region == true end)
|> Enum.map(fn({location, data}) -> {location, Map.get(data, field, 0), Map.get(data, field_evol, 0)} end)
|> Enum.sort_by(fn({_,count,_}) -> count end, &>=/2)
|> Enum.take(10)
|> Enum.with_index()
|> Enum.map(fn({{location, count, evol}, index}) ->
ev = if String.starts_with?(name, "nouveaux") do
""
else
" (#{Util.plusminus(evol)})"
end
"##{index+1}: #{location} #{count}#{ev}"
end)
|> Enum.intersperse(" - ")
|> Enum.join()
m.replyfun.("CORONAVIRUS TOP10 #{name}: " <> sorted)
{:noreply, state}
end
- def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :bang, args: location}}}, state) do
+ def handle_info({:irc, :trigger, "coronavirus", m = %Nola.Message{trigger: %{type: :bang, args: location}}}, state) do
location = Enum.join(location, " ") |> String.downcase()
if data = Map.get(state.data, location) do
m.replyfun.("coronavirus: #{location}: "
<> "#{data.current} malades (#{Util.plusminus(data.new_current)}), "
<> "#{data.confirmed} confirmés (#{Util.plusminus(data.new_confirmed)}), "
<> "#{data.deaths} morts (#{Util.plusminus(data.new_deaths)}), "
<> "#{data.recovered} soignés (#{Util.plusminus(data.new_recovered)}) (@ #{data.update})")
end
{:noreply, state}
end
- def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :query, args: location}}}, state) do
+ def handle_info({:irc, :trigger, "coronavirus", m = %Nola.Message{trigger: %{type: :query, args: location}}}, state) do
m.replyfun.("https://github.com/CSSEGISandData/COVID-19")
{:noreply, state}
end
# 1. Try to fetch data for today
# 2. Fetch yesterday if no results
defp fetch_data(current_data, date \\ nil) do
now = Date.utc_today()
url = fn(date) ->
"https://github.com/CSSEGISandData/COVID-19/raw/master/csse_covid_19_data/csse_covid_19_daily_reports/#{date}.csv"
end
request_date = date || now
Logger.debug("Coronavirus check date: #{inspect request_date}")
{:ok, date_s} = Timex.format({request_date.year, request_date.month, request_date.day}, "%m-%d-%Y", :strftime)
cur_url = url.(date_s)
Logger.debug "Fetching URL #{cur_url}"
case HTTPoison.get(cur_url, [], follow_redirect: true) do
{:ok, %HTTPoison.Response{status_code: 200, body: csv}} ->
# Parse CSV update data
data = csv
|> CovidCsv.parse_string()
|> Enum.reduce(%{}, fn(line, acc) ->
case line do
# FIPS,Admin2,Province_State,Country_Region,Last_Update,Lat,Long_,Confirmed,Deaths,Recovered,Active,Combined_Key
#0FIPS,Admin2,Province_State,Country_Region,Last_Update,Lat,Long_,Confirmed,Deaths,Recovered,Active,Combined_Key,Incidence_Rate,Case-Fatality_Ratio
[_, _, state, region, update, _lat, _lng, confirmed, deaths, recovered, _active, _combined_key, _incidence_rate, _fatality_ratio] ->
state = String.downcase(state)
region = String.downcase(region)
confirmed = String.to_integer(confirmed)
deaths = String.to_integer(deaths)
recovered = String.to_integer(recovered)
current = (confirmed - recovered) - deaths
entry = %{update: update, confirmed: confirmed, deaths: deaths, recovered: recovered, current: current, region: region}
region_entry = Map.get(acc, region, %{update: nil, confirmed: 0, deaths: 0, recovered: 0, current: 0})
region_entry = %{
update: region_entry.update || update,
confirmed: region_entry.confirmed + confirmed,
deaths: region_entry.deaths + deaths,
current: region_entry.current + current,
recovered: region_entry.recovered + recovered,
region: true
}
changes = if old = Map.get(current_data, region) do
%{
new_confirmed: region_entry.confirmed - old.confirmed,
new_current: region_entry.current - old.current,
new_deaths: region_entry.deaths - old.deaths,
new_recovered: region_entry.recovered - old.recovered,
}
else
%{new_confirmed: 0, new_current: 0, new_deaths: 0, new_recovered: 0}
end
region_entry = Map.merge(region_entry, changes)
acc = Map.put(acc, region, region_entry)
acc = if state && state != "" do
Map.put(acc, state, entry)
else
acc
end
other ->
Logger.info("Coronavirus line failed: #{inspect line}")
acc
end
end)
Logger.info "Updated coronavirus database"
{data, :timer.minutes(60)}
{:ok, %HTTPoison.Response{status_code: 404}} ->
Logger.debug "Corona 404 #{cur_url}"
date = Date.add(date || now, -1)
fetch_data(current_data, date)
other ->
Logger.error "Coronavirus: Update failed #{inspect other}"
{current_data, :timer.minutes(5)}
end
end
end
diff --git a/lib/plugins/correction.ex b/lib/plugins/correction.ex
index 067f468..b50733b 100644
--- a/lib/plugins/correction.ex
+++ b/lib/plugins/correction.ex
@@ -1,59 +1,59 @@
defmodule Nola.Plugins.Correction do
@moduledoc """
# correction
* `s/pattern/replace` replace `pattern` by `replace` in the last matching message
"""
def irc_doc, do: @moduledoc
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_) do
{:ok, _} = Registry.register(Nola.PubSub, "messages", [plugin: __MODULE__])
{:ok, _} = Registry.register(Nola.PubSub, "triggers", [plugin: __MODULE__])
{:ok, %{}}
end
# Trigger fallback
- def handle_info({:irc, :trigger, _, m = %IRC.Message{}}, state) do
+ def handle_info({:irc, :trigger, _, m = %Nola.Message{}}, state) do
{:noreply, correction(m, state)}
end
- def handle_info({:irc, :text, m = %IRC.Message{}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{}}, state) do
{:noreply, correction(m, state)}
end
def correction(m, state) do
history = Map.get(state, key(m), [])
if String.starts_with?(m.text, "s/") do
case String.split(m.text, "/") do
["s", match, replace | _] ->
case Regex.compile(match) do
{:ok, reg} ->
repl = Enum.find(history, fn(m) -> Regex.match?(reg, m.text) end)
if repl do
new_text = String.replace(repl.text, reg, replace)
m.replyfun.("correction: <#{repl.sender.nick}> #{new_text}")
end
_ ->
m.replyfun.("correction: invalid regex")
end
_ -> m.replyfun.("correction: invalid regex format")
end
state
else
history = if length(history) > 100 do
{_, history} = List.pop_at(history, 99)
[m | history]
else
[m | history]
end
Map.put(state, key(m), history)
end
end
defp key(%{network: net, channel: chan}), do: "#{net}/#{chan}"
end
diff --git a/lib/plugins/gpt.ex b/lib/plugins/gpt.ex
index 1171d19..f89bec1 100644
--- a/lib/plugins/gpt.ex
+++ b/lib/plugins/gpt.ex
@@ -1,259 +1,259 @@
defmodule Nola.Plugins.Gpt do
require Logger
import Nola.Plugins.TempRefHelper
def irc_doc() do
"""
# OpenAI GPT
Uses OpenAI's GPT-3 API to bring natural language prompts to your IRC channel.
_prompts_ are pre-defined prompts and parameters defined in the bot' CouchDB.
_Runs_ (results of the inference of a _prompt_) are also stored in CouchDB and
may be resumed.
* **!gpt** list GPT prompts
* **!gpt `[prompt]` `<prompt or args>`** run a prompt
* **+gpt `[short ref|run id]` `<prompt or args>`** continue a prompt
* **?gpt offensive `<content>`** is content offensive ?
* **?gpt show `[short ref|run id]`** run information and web link
* **?gpt `[prompt]`** prompt information and web link
"""
end
@couch_db "bot-plugin-openai-prompts"
@couch_run_db "bot-plugin-gpt-history"
@trigger "gpt"
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
defstruct [:temprefs]
def get_result(id) do
Couch.get(@couch_run_db, id)
end
def get_prompt(id) do
Couch.get(@couch_db, id)
end
def init(_) do
regopts = [plugin: __MODULE__]
{:ok, _} = Registry.register(Nola.PubSub, "trigger:#{@trigger}", regopts)
{:ok, %__MODULE__{temprefs: new_temp_refs()}}
end
- def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [prompt | args]}}}, state) do
+ def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: [prompt | args]}}}, state) do
case Couch.get(@couch_db, prompt) do
{:ok, prompt} -> {:noreply, prompt(m, prompt, Enum.join(args, " "), state)}
{:error, :not_found} ->
m.replyfun.("gpt: prompt '#{prompt}' does not exists")
{:noreply, state}
error ->
Logger.info("gpt: prompt load error: #{inspect error}")
m.replyfun.("gpt: database error")
{:noreply, state}
end
end
- def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do
+ def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: []}}}, state) do
case Couch.get(@couch_db, "_all_docs") do
{:ok, %{"rows" => []}} -> m.replyfun.("gpt: no prompts available")
{:ok, %{"rows" => prompts}} ->
prompts = prompts |> Enum.map(fn(prompt) -> Map.get(prompt, "id") end) |> Enum.join(", ")
m.replyfun.("gpt: prompts: #{prompts}")
error ->
Logger.info("gpt: prompt load error: #{inspect error}")
m.replyfun.("gpt: database error")
end
{:noreply, state}
end
- def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :plus, args: [ref_or_id | args]}}}, state) do
+ def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :plus, args: [ref_or_id | args]}}}, state) do
id = lookup_temp_ref(ref_or_id, state.temprefs, ref_or_id)
case Couch.get(@couch_run_db, id) do
{:ok, run} ->
Logger.debug("+gpt run: #{inspect run}")
{:noreply, continue_prompt(m, run, Enum.join(args, " "), state)}
{:error, :not_found} ->
m.replyfun.("gpt: ref or id not found or expired: #{inspect ref_or_id} (if using short ref, try using full id)")
{:noreply, state}
error ->
Logger.info("+gpt: run load error: #{inspect error}")
m.replyfun.("gpt: database error")
{:noreply, state}
end
end
- def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: ["offensive" | text]}}}, state) do
+ def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :query, args: ["offensive" | text]}}}, state) do
text = Enum.join(text, " ")
{moderate?, moderation} = moderation(text, m.account.id)
reply = cond do
moderate? -> "⚠️ #{Enum.join(moderation, ", ")}"
!moderate? && moderation -> "👍"
!moderate? -> "☠️ error"
end
m.replyfun.(reply)
{:noreply, state}
end
- def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: ["show", ref_or_id]}}}, state) do
+ def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :query, args: ["show", ref_or_id]}}}, state) do
id = lookup_temp_ref(ref_or_id, state.temprefs, ref_or_id)
url = if m.channel do
NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, m.network, NolaWeb.format_chan(m.channel), id)
else
NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, id)
end
m.replyfun.("→ #{url}")
{:noreply, state}
end
- def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: [prompt]}}}, state) do
+ def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :query, args: [prompt]}}}, state) do
url = if m.channel do
NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, m.network, NolaWeb.format_chan(m.channel), prompt)
else
NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, prompt)
end
m.replyfun.("→ #{url}")
{:noreply, state}
end
def handle_info(info, state) do
Logger.debug("gpt: unhandled info: #{inspect info}")
{:noreply, state}
end
defp continue_prompt(msg, run, content, state) do
prompt_id = Map.get(run, "prompt_id")
prompt_rev = Map.get(run, "prompt_rev")
original_prompt = case Couch.get(@couch_db, prompt_id, rev: prompt_rev) do
{:ok, prompt} -> prompt
_ -> nil
end
if original_prompt do
continue_prompt = %{"_id" => prompt_id,
"_rev" => prompt_rev,
"type" => Map.get(original_prompt, "type"),
"parent_run_id" => Map.get(run, "_id"),
"openai_params" => Map.get(run, "request") |> Map.delete("prompt")}
continue_prompt = if prompt_string = Map.get(original_prompt, "continue_prompt") do
full_text = get_in(run, ~w(request prompt)) <> "\n" <> Map.get(run, "response")
continue_prompt
|> Map.put("prompt", prompt_string)
|> Map.put("prompt_format", "liquid")
|> Map.put("prompt_liquid_variables", %{"previous" => full_text})
else
prompt_content_tag = if content != "", do: " {{content}}", else: ""
string = get_in(run, ~w(request prompt)) <> "\n" <> Map.get(run, "response") <> prompt_content_tag
continue_prompt
|> Map.put("prompt", string)
|> Map.put("prompt_format", "liquid")
end
prompt(msg, continue_prompt, content, state)
else
msg.replyfun.("gpt: cannot continue this prompt: original prompt not found #{prompt_id}@v#{prompt_rev}")
state
end
end
defp prompt(msg, prompt = %{"type" => "completions", "prompt" => prompt_template}, content, state) do
Logger.debug("gpt:prompt/4 #{inspect prompt}")
prompt_text = case Map.get(prompt, "prompt_format", "liquid") do
"liquid" -> Tmpl.render(prompt_template, msg, Map.merge(Map.get(prompt, "prompt_liquid_variables", %{}), %{"content" => content}))
"norender" -> prompt_template
end
args = Map.get(prompt, "openai_params")
|> Map.put("prompt", prompt_text)
|> Map.put("user", msg.account.id)
{moderate?, moderation} = moderation(content, msg.account.id)
if moderate?, do: msg.replyfun.("⚠️ offensive input: #{Enum.join(moderation, ", ")}")
Logger.debug("GPT: request #{inspect args}")
case OpenAi.post("/v1/completions", args) do
{:ok, %{"choices" => [%{"text" => text, "finish_reason" => finish_reason} | _], "usage" => usage, "id" => gpt_id, "created" => created}} ->
text = String.trim(text)
{o_moderate?, o_moderation} = moderation(text, msg.account.id)
if o_moderate?, do: msg.replyfun.("🚨 offensive output: #{Enum.join(o_moderation, ", ")}")
msg.replyfun.(text)
doc = %{"id" => FlakeId.get(),
"prompt_id" => Map.get(prompt, "_id"),
"prompt_rev" => Map.get(prompt, "_rev"),
"network" => msg.network,
"channel" => msg.channel,
"nick" => msg.sender.nick,
"account_id" => (if msg.account, do: msg.account.id),
"request" => args,
"response" => text,
"message_at" => msg.at,
"reply_at" => DateTime.utc_now(),
"gpt_id" => gpt_id,
"gpt_at" => created,
"gpt_usage" => usage,
"type" => "completions",
"parent_run_id" => Map.get(prompt, "parent_run_id"),
"moderation" => %{"input" => %{flagged: moderate?, categories: moderation},
"output" => %{flagged: o_moderate?, categories: o_moderation}
}
}
Logger.debug("Saving result to couch: #{inspect doc}")
{id, ref, temprefs} = case Couch.post(@couch_run_db, doc) do
{:ok, id, _rev} ->
{ref, temprefs} = put_temp_ref(id, state.temprefs)
{id, ref, temprefs}
error ->
Logger.error("Failed to save to Couch: #{inspect error}")
{nil, nil, state.temprefs}
end
stop = cond do
finish_reason == "stop" -> ""
finish_reason == "length" -> " — truncated"
true -> " — #{finish_reason}"
end
ref_and_prefix = if Map.get(usage, "completion_tokens", 0) == 0 do
"GPT had nothing else to say :( ↪ #{ref || "✗"}"
else
" ↪ #{ref || "✗"}"
end
msg.replyfun.(ref_and_prefix <>
stop <>
" — #{Map.get(usage, "total_tokens", 0)}" <>
" (#{Map.get(usage, "prompt_tokens", 0)}/#{Map.get(usage, "completion_tokens", 0)}) tokens" <>
" — #{id || "save failed"}")
%__MODULE__{state | temprefs: temprefs}
{:error, atom} when is_atom(atom) ->
Logger.error("gpt error: #{inspect atom}")
msg.replyfun.("gpt: ☠️ #{to_string(atom)}")
state
error ->
Logger.error("gpt error: #{inspect error}")
msg.replyfun.("gpt: ☠️ ")
state
end
end
defp moderation(content, user_id) do
case OpenAi.post("/v1/moderations", %{"input" => content, "user" => user_id}) do
{:ok, %{"results" => [%{"flagged" => true, "categories" => categories} | _]}} ->
cat = categories
|> Enum.filter(fn({_key, value}) -> value end)
|> Enum.map(fn({key, _}) -> key end)
{true, cat}
{:ok, moderation} ->
Logger.debug("gpt: moderation: not flagged, #{inspect moderation}")
{false, true}
error ->
Logger.error("gpt: moderation error: #{inspect error}")
{false, false}
end
end
end
diff --git a/lib/plugins/logger.ex b/lib/plugins/logger.ex
index 77bee8b..3d643d3 100644
--- a/lib/plugins/logger.ex
+++ b/lib/plugins/logger.ex
@@ -1,71 +1,71 @@
defmodule Nola.Plugins.Logger do
require Logger
@couch_db "bot-logs"
def irc_doc(), do: nil
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init([]) do
regopts = [plugin: __MODULE__]
{:ok, _} = Registry.register(Nola.PubSub, "triggers", regopts)
{:ok, _} = Registry.register(Nola.PubSub, "messages", regopts)
{:ok, _} = Registry.register(Nola.PubSub, "messages:telegram", regopts)
{:ok, _} = Registry.register(Nola.PubSub, "irc:outputs", regopts)
{:ok, _} = Registry.register(Nola.PubSub, "messages:private", regopts)
{:ok, nil}
end
def handle_info({:irc, :trigger, _, m}, state) do
{:noreply, log(m, state)}
end
def handle_info({:irc, :text, m}, state) do
{:noreply, log(m, state)}
end
def handle_info(info, state) do
Logger.debug("logger_plugin: unhandled info: #{info}")
{:noreply, state}
end
def log(entry, state) do
case Couch.post(@couch_db, format_to_db(entry)) do
{:ok, id, _rev} ->
Logger.debug("logger_plugin: saved: #{inspect id}")
state
error ->
Logger.error("logger_plugin: save failed: #{inspect error}")
end
rescue
e ->
Logger.error("logger_plugin: rescued processing for #{inspect entry}: #{inspect e}")
Logger.error(Exception.format(:error, e, __STACKTRACE__))
state
catch
e, b ->
Logger.error("logger_plugin: catched processing for #{inspect entry}: #{inspect e}")
Logger.error(Exception.format(e, b, __STACKTRACE__))
state
end
- def format_to_db(msg = %IRC.Message{id: id}) do
+ def format_to_db(msg = %Nola.Message{id: id}) do
msg
|> Poison.encode!()
|> Map.drop("id")
%{"_id" => id || FlakeId.get(),
"type" => "irc.message/v1",
"object" => msg}
end
def format_to_db(anything) do
%{"_id" => FlakeId.get(),
"type" => "object",
"object" => anything}
end
end
diff --git a/lib/plugins/outline.ex b/lib/plugins/outline.ex
index 1f1c1e1..ba8314d 100644
--- a/lib/plugins/outline.ex
+++ b/lib/plugins/outline.ex
@@ -1,108 +1,108 @@
defmodule Nola.Plugins.Outline do
@moduledoc """
# outline auto-link
Envoie un lien vers Outline quand un lien est envoyé.
* **!outline `<url>`** crée un lien outline pour `<url>`.
* **+outline `<host>`** active outline pour `<host>`.
* **-outline `<host>`** désactive outline pour `<host>`.
"""
def short_irc_doc, do: false
def irc_doc, do: @moduledoc
require Logger
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
defstruct [:file, :hosts]
def init([]) do
regopts = [plugin: __MODULE__]
{:ok, _} = Registry.register(Nola.PubSub, "trigger:outline", regopts)
{:ok, _} = Registry.register(Nola.PubSub, "messages", regopts)
file = Path.join(Nola.data_path, "/outline.txt")
hosts = case File.read(file) do
{:error, :enoent} ->
[]
{:ok, lines} ->
String.split(lines, "\n", trim: true)
end
{:ok, %__MODULE__{file: file, hosts: hosts}}
end
- def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :plus, args: [host]}}}, state) do
+ def handle_info({:irc, :trigger, "outline", message = %Nola.Message{trigger: %Nola.Trigger{type: :plus, args: [host]}}}, state) do
state = %{state | hosts: [host | state.hosts]}
save(state)
message.replyfun.("ok")
{:noreply, state}
end
- def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :minus, args: [host]}}}, state) do
+ def handle_info({:irc, :trigger, "outline", message = %Nola.Message{trigger: %Nola.Trigger{type: :minus, args: [host]}}}, state) do
state = %{state | hosts: List.delete(state.hosts, host)}
save(state)
message.replyfun.("ok")
{:noreply, state}
end
- def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [url]}}}, state) do
+ def handle_info({:irc, :trigger, "outline", message = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: [url]}}}, state) do
line = "-> #{outline(url)}"
message.replyfun.(line)
end
- def handle_info({:irc, :text, message = %IRC.Message{text: text}}, state) do
+ def handle_info({:irc, :text, message = %Nola.Message{text: text}}, state) do
String.split(text)
|> Enum.map(fn(word) ->
if String.starts_with?(word, "http://") || String.starts_with?(word, "https://") do
uri = URI.parse(word)
if uri.scheme && uri.host do
if Enum.any?(state.hosts, fn(host) -> String.ends_with?(uri.host, host) end) do
outline_url = outline(word)
line = "-> #{outline_url}"
message.replyfun.(line)
end
end
end
end)
{:noreply, state}
end
def handle_info(msg, state) do
{:noreply, state}
end
def save(state = %{file: file, hosts: hosts}) do
string = Enum.join(hosts, "\n")
File.write(file, string)
end
def outline(url) do
unexpanded = "https://outline.com/#{url}"
headers = [
{"User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:77.0) Gecko/20100101 Firefox/77.0"},
{"Accept", "*/*"},
{"Accept-Language", "en-US,en;q=0.5"},
{"Origin", "https://outline.com"},
{"DNT", "1"},
{"Referer", unexpanded},
{"Pragma", "no-cache"},
{"Cache-Control", "no-cache"}
]
params = %{"source_url" => url}
case HTTPoison.get("https://api.outline.com/v3/parse_article", headers, params: params) do
{:ok, %HTTPoison.Response{status_code: 200, body: json}} ->
body = Poison.decode!(json)
if Map.get(body, "success") do
code = get_in(body, ["data", "short_code"])
"https://outline.com/#{code}"
else
unexpanded
end
error ->
Logger.info("outline.com error: #{inspect error}")
unexpanded
end
end
end
diff --git a/lib/plugins/preums.ex b/lib/plugins/preums.ex
index 505ce7f..83d99cd 100644
--- a/lib/plugins/preums.ex
+++ b/lib/plugins/preums.ex
@@ -1,276 +1,276 @@
defmodule Nola.Plugins.Preums do
@moduledoc """
# preums !!!
* `!preums`: affiche le preums du jour
* `.preums`: stats des preums
"""
# WIP Scores
# L'idée c'est de donner un score pour mettre un peu de challenge en pénalisant les preums faciles.
#
# Un preums ne vaut pas 1 point, mais plutôt 0.10 ou 0.05, et on arrondi au plus proche. C'est un jeu sur le long
# terme. Un gros bonus pourrait apporter beaucoup de points.
#
# Il faudrait ces données:
# - moyenne des preums
# - activité récente du channel et par nb actifs d'utilisateurs
# (aggréger memberships+usertrack last_active ?)
# (faire des stats d'activité habituelle (un peu a la pisg) ?)
# - preums consécutifs
#
# Malus:
# - est proche de la moyenne en faible activité
# - trop consécutif de l'utilisateur sauf si activité
#
# Bonus:
# - plus le preums est éloigné de la moyenne
# - après 18h double
# - plus l'activité est élévée, exponentiel selon la moyenne
# - derns entre 4 et 6 (pourrait être adapté selon les stats d'activité)
#
# WIP Badges:
# - derns
# - streaks
# - faciles
# - ?
require Logger
@perfects [~r/preum(s|)/i]
# dets {{chan, day = {yyyy, mm, dd}}, nick, now, perfect?, text}
def all(dets) do
:dets.foldl(fn(i, acc) -> [i|acc] end, [], dets)
end
def all(dets, channel) do
fun = fn({{chan, date}, account_id, time, perfect, text}, acc) ->
if channel == chan do
[%{date: date, account_id: account_id, time: time, perfect: perfect, text: text} | acc]
else
acc
end
end
:dets.foldl(fun, [], dets)
end
def topnicks(dets, channel, options \\ []) do
sort_elem = case Keyword.get(options, :sort_by, :score) do
:score -> 1
:count -> 0
end
fun = fn(x = {{chan, date}, account_id, time, perfect, text}, acc) ->
if (channel == nil and chan) or (channel == chan) do
{count, points} = Map.get(acc, account_id, {0, 0})
score = score(chan, account_id, time, perfect, text)
Map.put(acc, account_id, {count + 1, points + score})
else
acc
end
end
:dets.foldl(fun, %{}, dets)
|> Enum.sort_by(fn({_account_id, value}) -> elem(value, sort_elem) end, &>=/2)
end
def irc_doc, do: @moduledoc
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def dets do
(Nola.data_path() <> "/preums.dets") |> String.to_charlist()
end
def init([]) do
regopts = [plugin: __MODULE__]
{:ok, _} = Registry.register(Nola.PubSub, "account", regopts)
{:ok, _} = Registry.register(Nola.PubSub, "messages", regopts)
{:ok, _} = Registry.register(Nola.PubSub, "triggers", regopts)
{:ok, dets} = :dets.open_file(dets(), [{:repair, :force}])
Util.ets_mutate_select_each(:dets, dets, [{:"$1", [], [:"$1"]}], fn(table, obj) ->
{key, nick, now, perfect, text} = obj
case key do
{{net, {bork,chan}}, date} ->
:dets.delete(table, key)
nick = if Nola.Account.get(nick) do
nick
else
if acct = Nola.Account.find_always_by_nick(net, nil, nick) do
acct.id
else
nick
end
end
:dets.insert(table, { { {net,chan}, date }, nick, now, perfect, text})
{{_net, nil}, _} ->
:dets.delete(table, key)
{{net, chan}, date} ->
if !Nola.Account.get(nick) do
if acct = Nola.Account.find_always_by_nick(net, chan, nick) do
:dets.delete(table, key)
:dets.insert(table, { { {net,chan}, date }, acct.id, now, perfect, text})
end
end
_ ->
Logger.debug("DID NOT FIX: #{inspect key}")
end
end)
{:ok, %{dets: dets}}
end
# Latest
- def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "preums", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}}}, state) do
channelkey = {m.network, m.channel}
state = handle_preums(m, state)
tz = timezone(channelkey)
{:ok, now} = DateTime.now(tz, Tzdata.TimeZoneDatabase)
date = {now.year, now.month, now.day}
key = {channelkey, date}
chan_cache = Map.get(state, channelkey, %{})
item = if i = Map.get(chan_cache, date) do
i
else
case :dets.lookup(state.dets, key) do
[item = {^key, _account_id, _now, _perfect, _text}] -> item
_ -> nil
end
end
if item do
{_, account_id, date, _perfect, text} = item
h = "#{date.hour}:#{date.minute}:#{date.second}"
account = Nola.Account.get(account_id)
user = Nola.UserTrack.find_by_account(m.network, account)
nick = if(user, do: user.nick, else: account.name)
m.replyfun.("preums: #{nick} à #{h}: “#{text}”")
end
{:noreply, state}
end
# Stats
- def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :dot}}}, state) do
+ def handle_info({:irc, :trigger, "preums", m = %Nola.Message{trigger: %Nola.Trigger{type: :dot}}}, state) do
channel = {m.network, m.channel}
state = handle_preums(m, state)
top = topnicks(state.dets, channel, sort_by: :score)
|> Enum.map(fn({account_id, {count, score}}) ->
account = Nola.Account.get(account_id)
user = Nola.UserTrack.find_by_account(m.network, account)
nick = if(user, do: user.nick, else: account.name)
"#{nick}: #{score} (#{count})"
end)
|> Enum.intersperse(", ")
|> Enum.join("")
msg = unless top == "" do
"top preums: #{top}"
else
"vous êtes tous nuls"
end
m.replyfun.(msg)
{:noreply, state}
end
# Help
- def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :query}}}, state) do
+ def handle_info({:irc, :trigger, "preums", m = %Nola.Message{trigger: %Nola.Trigger{type: :query}}}, state) do
state = handle_preums(m, state)
msg = "!preums - preums du jour, .preums top preumseurs"
m.replymsg.(msg)
{:noreply, state}
end
# Trigger fallback
- def handle_info({:irc, :trigger, _, m = %IRC.Message{}}, state) do
+ def handle_info({:irc, :trigger, _, m = %Nola.Message{}}, state) do
state = handle_preums(m, state)
{:noreply, state}
end
# Message fallback
- def handle_info({:irc, :text, m = %IRC.Message{}}, state) do
+ def handle_info({:irc, :text, m = %Nola.Message{}}, state) do
{:noreply, handle_preums(m, state)}
end
# Account
def handle_info({:account_change, old_id, new_id}, state) do
spec = [{{:_, :"$1", :_, :_, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}]
Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) ->
rename_object_owner(table, obj, new_id)
end)
{:noreply, state}
end
# Account: move from nick to account id
# FIXME: Doesn't seem to work.
def handle_info({:accounts, accounts}, state) do
for x={:account, _net, _chan, _nick, _account_id} <- accounts do
handle_info(x, state)
end
{:noreply, state}
end
def handle_info({:account, _net, _chan, nick, account_id}, state) do
nick = String.downcase(nick)
spec = [{{:_, :"$1", :_, :_, :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}]
Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) ->
Logger.debug("account:: merging #{nick} -> #{account_id}")
rename_object_owner(table, obj, account_id)
end)
{:noreply, state}
end
def handle_info(_, dets) do
{:noreply, dets}
end
defp rename_object_owner(table, object = {key, _, now, perfect, time}, new_id) do
:dets.delete_object(table, key)
:dets.insert(table, {key, new_id, now, perfect, time})
end
defp timezone(channel) do
env = Application.get_env(:nola, Nola.Plugins.Preums, [])
channels = Keyword.get(env, :channels, %{})
channel_settings = Map.get(channels, channel, [])
default = Keyword.get(env, :default_tz, "Europe/Paris")
Keyword.get(channel_settings, :tz, default) || default
end
- defp handle_preums(%IRC.Message{channel: nil}, state) do
+ defp handle_preums(%Nola.Message{channel: nil}, state) do
state
end
- defp handle_preums(m = %IRC.Message{text: text, sender: sender}, state) do
+ defp handle_preums(m = %Nola.Message{text: text, sender: sender}, state) do
channel = {m.network, m.channel}
tz = timezone(channel)
{:ok, now} = DateTime.now(tz, Tzdata.TimeZoneDatabase)
date = {now.year, now.month, now.day}
key = {channel, date}
chan_cache = Map.get(state, channel, %{})
unless i = Map.get(chan_cache, date) do
case :dets.lookup(state.dets, key) do
[item = {^key, _nick, _now, _perfect, _text}] ->
# Preums lost, but wasn't cached
Map.put(state, channel, %{date => item})
[] ->
# Preums won!
perfect? = Enum.any?(@perfects, fn(perfect) -> Regex.match?(perfect, text) end)
item = {key, m.account.id, now, perfect?, text}
:dets.insert(state.dets, item)
:dets.sync(state.dets)
Map.put(state, channel, %{date => item})
{:error, _} = error ->
Logger.error("#{__MODULE__} dets lookup failed: #{inspect error}")
state
end
else
state
end
end
def score(_chan, _account, _time, _perfect, _text) do
1
end
end
diff --git a/lib/plugins/quatre_cent_vingt.ex b/lib/plugins/quatre_cent_vingt.ex
index 254f5ce..6b3cc46 100644
--- a/lib/plugins/quatre_cent_vingt.ex
+++ b/lib/plugins/quatre_cent_vingt.ex
@@ -1,149 +1,149 @@
defmodule Nola.Plugins.QuatreCentVingt do
require Logger
@moduledoc """
# 420
* **!420**: recorde un nouveau 420.
* **!420*x**: recorde un nouveau 420*x (*2 = 840, ...) (à vous de faire la multiplication).
* **!420 pseudo**: stats du pseudo.
"""
@achievements %{
1 => ["[le premier… il faut bien commencer un jour]"],
10 => ["T'en es seulement à 10 ? ╭∩╮(Ο_Ο)╭∩╮"],
42 => ["Bravo, et est-ce que autant de pétards t'on aidés à trouver la Réponse ? ٩(- ̮̮̃-̃)۶ [42]"],
100 => ["°º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸ 100 °º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸"],
115 => [" ۜ\(סּںסּَ` )/ۜ 115!!"]
}
@emojis [
"\\o/",
"~o~",
"~~o∞~~",
"*\\o/*",
"**\\o/**",
"*ô*",
]
@coeffs Range.new(1, 100)
def irc_doc, do: @moduledoc
def start_link, do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
def init(_) do
for coeff <- @coeffs do
{:ok, _} = Registry.register(Nola.PubSub, "trigger:#{420*coeff}", [plugin: __MODULE__])
end
{:ok, _} = Registry.register(Nola.PubSub, "account", [plugin: __MODULE__])
dets_filename = (Nola.data_path() <> "/420.dets") |> String.to_charlist
{:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag},{:repair,:force}])
{:ok, dets}
:ignore
end
for coeff <- @coeffs do
qvc = to_string(420 * coeff)
- def handle_info({:irc, :trigger, unquote(qvc), m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :bang}}}, dets) do
+ def handle_info({:irc, :trigger, unquote(qvc), m = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :bang}}}, dets) do
{count, last} = get_statistics_for_nick(dets, m.account.id)
count = count + unquote(coeff)
text = achievement_text(count)
now = DateTime.to_unix(DateTime.utc_now())-1 # this is ugly
for i <- Range.new(1, unquote(coeff)) do
:ok = :dets.insert(dets, {m.account.id, now+i})
end
last_s = if last do
last_s = format_relative_timestamp(last)
" (le dernier était #{last_s})"
else
""
end
m.replyfun.("#{m.sender.nick} 420 +#{unquote(coeff)} #{text}#{last_s}")
{:noreply, dets}
end
end
- def handle_info({:irc, :trigger, "420", m = %IRC.Message{trigger: %IRC.Trigger{args: [nick], type: :bang}}}, dets) do
+ def handle_info({:irc, :trigger, "420", m = %Nola.Message{trigger: %Nola.Trigger{args: [nick], type: :bang}}}, dets) do
account = Nola.Account.find_by_nick(m.network, nick)
if account do
text = case get_statistics_for_nick(dets, m.account.id) do
{0, _} -> "#{nick} n'a jamais !420 ... honte à lui."
{count, last} ->
last_s = format_relative_timestamp(last)
"#{nick} 420: total #{count}, le dernier #{last_s}"
end
m.replyfun.(text)
else
m.replyfun.("je connais pas de #{nick}")
end
{:noreply, dets}
end
# Account
def handle_info({:account_change, old_id, new_id}, dets) do
spec = [{{:"$1", :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}]
Util.ets_mutate_select_each(:dets, dets, spec, fn(table, obj) ->
rename_object_owner(table, obj, new_id)
end)
{:noreply, dets}
end
# Account: move from nick to account id
def handle_info({:accounts, accounts}, dets) do
for x={:account, _net, _chan, _nick, _account_id} <- accounts do
handle_info(x, dets)
end
{:noreply, dets}
end
def handle_info({:account, _net, _chan, nick, account_id}, dets) do
nick = String.downcase(nick)
spec = [{{:"$1", :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}]
Util.ets_mutate_select_each(:dets, dets, spec, fn(table, obj) ->
Logger.debug("account:: merging #{nick} -> #{account_id}")
rename_object_owner(table, obj, account_id)
end)
{:noreply, dets}
end
def handle_info(_, dets) do
{:noreply, dets}
end
defp rename_object_owner(table, object = {_, at}, account_id) do
:dets.delete_object(table, object)
:dets.insert(table, {account_id, at})
end
defp format_relative_timestamp(timestamp) do
alias Timex.Format.DateTime.Formatters
alias Timex.Timezone
date = timestamp
|> DateTime.from_unix!
|> Timezone.convert("Europe/Paris")
{:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr")
{:ok, detail} = Formatters.Default.lformat(date, " ({h24}:{m})", "fr")
relative <> detail
end
defp get_statistics_for_nick(dets, acct) do
qvc = :dets.lookup(dets, acct) |> Enum.sort
count = Enum.reduce(qvc, 0, fn(_, acc) -> acc + 1 end)
{_, last} = List.last(qvc) || {nil, nil}
{count, last}
end
@achievements_keys Map.keys(@achievements)
defp achievement_text(count) when count in @achievements_keys do
Enum.random(Map.get(@achievements, count))
end
defp achievement_text(count) do
emoji = Enum.random(@emojis)
"#{emoji} [#{count}]"
end
end
diff --git a/lib/plugins/radio_france.ex b/lib/plugins/radio_france.ex
index a2a1c7b..d95c54a 100644
--- a/lib/plugins/radio_france.ex
+++ b/lib/plugins/radio_france.ex
@@ -1,133 +1,133 @@
defmodule Nola.Plugins.RadioFrance do
require Logger
def irc_doc() do
"""
# radio france
Qu'est ce qu'on écoute sur radio france ?
* **!radiofrance `[station]`, !rf `[station]`**
* **!fip, !inter, !info, !bleu, !culture, !musique, !fip `[sous-station]`, !bleu `[région]`**
"""
end
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
@trigger "radiofrance"
@shortcuts ~w(fip inter info bleu culture musique)
def init(_) do
regopts = [plugin: __MODULE__]
{:ok, _} = Registry.register(Nola.PubSub, "trigger:radiofrance", regopts)
{:ok, _} = Registry.register(Nola.PubSub, "trigger:rf", regopts)
for s <- @shortcuts do
{:ok, _} = Registry.register(Nola.PubSub, "trigger:#{s}", regopts)
end
{:ok, nil}
end
- def handle_info({:irc, :trigger, "rf", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) do
+ def handle_info({:irc, :trigger, "rf", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang}}}, state) do
handle_info({:irc, :trigger, "radiofrance", m}, state)
end
- def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do
+ def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: []}}}, state) do
m.replyfun.("radiofrance: précisez la station!")
{:noreply, state}
end
- def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do
+ def handle_info({:irc, :trigger, @trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, state) do
now(args_to_station(args), m)
{:noreply, state}
end
- def handle_info({:irc, :trigger, trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) when trigger in @shortcuts do
+ def handle_info({:irc, :trigger, trigger, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, state) when trigger in @shortcuts do
now(args_to_station([trigger | args]), m)
{:noreply, state}
end
defp args_to_station(args) do
args
|> Enum.map(&unalias/1)
|> Enum.map(&String.downcase/1)
|> Enum.join("_")
end
def handle_info(info, state) do
Logger.debug("unhandled info: #{inspect info}")
{:noreply, state}
end
defp now(station, m) when is_binary(station) do
case HTTPoison.get(np_url(station), [], []) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
json = Poison.decode!(body)
song? = !!get_in(json, ["now", "song"])
station = reformat_station_name(get_in(json, ["now", "stationName"]))
now_title = get_in(json, ["now", "firstLine", "title"])
now_subtitle = get_in(json, ["now", "secondLine", "title"])
next_title = get_in(json, ["next", "firstLine", "title"])
next_subtitle = get_in(json, ["next", "secondLine", "title"])
next_song? = !!get_in(json, ["next", "song"])
next_at = get_in(json, ["next", "startTime"])
now = format_title(song?, now_title, now_subtitle)
prefix = if song?, do: "🎶", else: "🎤"
m.replyfun.("#{prefix} #{station}: #{now}")
next = format_title(song?, next_title, next_subtitle)
if next do
next_prefix = if next_at do
next_date = DateTime.from_unix!(next_at)
in_seconds = DateTime.diff(next_date, DateTime.utc_now())
in_minutes = ceil(in_seconds / 60)
if in_minutes >= 5 do
if next_song?, do: "#{in_minutes}m 🔜", else: "dans #{in_minutes} minutes:"
else
if next_song?, do: "🔜", else: "suivi de:"
end
else
if next_song?, do: "🔜", else: "à suivre:"
end
m.replyfun.("#{next_prefix} #{next}")
end
{:error, %HTTPoison.Response{status_code: 404}} ->
m.replyfun.("radiofrance: la radio \"#{station}\" n'existe pas")
{:error, %HTTPoison.Response{status_code: code}} ->
m.replyfun.("radiofrance: erreur http #{code}")
_ ->
m.replyfun.("radiofrance: ça n'a pas marché, rip")
end
end
defp np_url(station), do: "https://www.radiofrance.fr/api/v2.0/stations/#{station}/live"
defp unalias("inter"), do: "franceinter"
defp unalias("info"), do: "franceinfo"
defp unalias("bleu"), do: "francebleu"
defp unalias("culture"), do: "franceculture"
defp unalias("musique"), do: "francemusique"
defp unalias(station), do: station
defp format_title(_, nil, nil) do
nil
end
defp format_title(true, title, artist) do
[artist, title] |> Enum.filter(& &1) |> Enum.join(" - ")
end
defp format_title(false, show, section) do
[show, section] |> Enum.filter(& &1) |> Enum.join(": ")
end
defp reformat_station_name(station) do
station
|> String.replace("france", "france ")
|> String.replace("_", " ")
end
end
diff --git a/lib/plugins/seen.ex b/lib/plugins/seen.ex
index cdebd59..045702c 100644
--- a/lib/plugins/seen.ex
+++ b/lib/plugins/seen.ex
@@ -1,59 +1,59 @@
defmodule Nola.Plugins.Seen do
@moduledoc """
# seen
* **!seen `<nick>`**
"""
def irc_doc, do: @moduledoc
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init([]) do
regopts = [plugin: __MODULE__]
{:ok, _} = Registry.register(Nola.PubSub, "triggers", regopts)
{:ok, _} = Registry.register(Nola.PubSub, "messages", regopts)
dets_filename = (Nola.data_path() <> "/seen.dets") |> String.to_charlist()
{:ok, dets} = :dets.open_file(dets_filename, [])
{:ok, %{dets: dets}}
end
- def handle_info({:irc, :trigger, "seen", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [nick]}}}, state) do
+ def handle_info({:irc, :trigger, "seen", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: [nick]}}}, state) do
witness(m, state)
m.replyfun.(last_seen(m.channel, nick, state))
{:noreply, state}
end
def handle_info({:irc, :trigger, _, m}, state) do
witness(m, state)
{:noreply, state}
end
def handle_info({:irc, :text, m}, state) do
witness(m, state)
{:noreply, state}
end
- defp witness(%IRC.Message{channel: channel, text: text, sender: %{nick: nick}}, %{dets: dets}) do
+ defp witness(%Nola.Message{channel: channel, text: text, sender: %{nick: nick}}, %{dets: dets}) do
:dets.insert(dets, {{channel, nick}, DateTime.utc_now(), text})
:ok
end
defp last_seen(channel, nick, %{dets: dets}) do
case :dets.lookup(dets, {channel, nick}) do
[{_, date, text}] ->
diff = round(DateTime.diff(DateTime.utc_now(), date)/60)
cond do
diff >= 30 ->
duration = Timex.Duration.from_minutes(diff)
format = Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized)
"#{nick} a parlé pour la dernière fois il y a #{format}: “#{text}”"
true -> "#{nick} est là..."
end
[] ->
"je ne connais pas de #{nick}"
end
end
end
diff --git a/lib/plugins/sms.ex b/lib/plugins/sms.ex
index afc1eb1..a3b7b7d 100644
--- a/lib/plugins/sms.ex
+++ b/lib/plugins/sms.ex
@@ -1,165 +1,165 @@
defmodule Nola.Plugins.Sms do
@moduledoc """
## sms
* **!sms `<nick>` `<message>`** envoie un SMS.
"""
def short_irc_doc, do: false
def irc_doc, do: @moduledoc
require Logger
def incoming(from, "enable "<>key) do
key = String.trim(key)
account = Nola.Account.find_meta_account("sms-validation-code", String.downcase(key))
if account do
net = Nola.Account.get_meta(account, "sms-validation-target")
Nola.Account.put_meta(account, "sms-number", from)
Nola.Account.delete_meta(account, "sms-validation-code")
Nola.Account.delete_meta(account, "sms-validation-number")
Nola.Account.delete_meta(account, "sms-validation-target")
IRC.Connection.broadcast_message(net, account, "SMS Number #{from} added!")
send_sms(from, "Yay! Number linked to account #{account.name}")
end
end
def incoming(from, message) do
account = Nola.Account.find_meta_account("sms-number", from)
if account do
reply_fun = fn(text) ->
send_sms(from, text)
end
trigger_text = if Enum.any?(IRC.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(message, trigger) end) do
message
else
"!"<>message
end
- message = %IRC.Message{
+ message = %Nola.Message{
id: FlakeId.get(),
transport: :sms,
network: "sms",
channel: nil,
text: message,
account: account,
sender: %ExIRC.SenderInfo{nick: account.name},
replyfun: reply_fun,
trigger: IRC.Connection.extract_trigger(trigger_text)
}
Logger.debug("converted sms to message: #{inspect message}")
IRC.Connection.publish(message, ["messages:sms"])
message
end
end
def my_number() do
Keyword.get(Application.get_env(:nola, :sms, []), :number, "+33000000000")
end
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def path() do
account = Keyword.get(Application.get_env(:nola, :sms), :account)
"https://eu.api.ovh.com/1.0/sms/#{account}"
end
def path(rest) do
Path.join(path(), rest)
end
def send_sms(number, text) do
url = path("/virtualNumbers/#{my_number()}/jobs")
body = %{
"message" => text,
"receivers" => [number],
#"senderForResponse" => true,
#"noStopClause" => true,
"charset" => "UTF-8",
"coding" => "8bit"
} |> Poison.encode!()
headers = [{"content-type", "application/json"}] ++ sign("POST", url, body)
options = []
case HTTPoison.post(url, body, headers, options) do
{:ok, %HTTPoison.Response{status_code: 200}} -> :ok
{:ok, %HTTPoison.Response{status_code: code} = resp} ->
Logger.error("SMS Error: #{inspect resp}")
{:error, code}
{:error, error} -> {:error, error}
end
end
def init([]) do
{:ok, _} = Registry.register(Nola.PubSub, "trigger:sms", [plugin: __MODULE__])
:ok = register_ovh_callback()
{:ok, %{}}
:ignore
end
- def handle_info({:irc, :trigger, "sms", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [nick | text]}}}, state) do
+ def handle_info({:irc, :trigger, "sms", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: [nick | text]}}}, state) do
with \
{:tree, false} <- {:tree, m.sender.nick == "Tree"},
{_, %Nola.Account{} = account} <- {:account, Nola.Account.find_always_by_nick(m.network, m.channel, nick)},
{_, number} when not is_nil(number) <- {:number, Nola.Account.get_meta(account, "sms-number")}
do
text = Enum.join(text, " ")
sender = if m.channel do
"#{m.channel} <#{m.sender.nick}> "
else
"<#{m.sender.nick}> "
end
case send_sms(number, sender<>text) do
:ok -> m.replyfun.("sent!")
{:error, error} -> m.replyfun.("not sent, error: #{inspect error}")
end
else
{:tree, _} -> m.replyfun.("Tree: va en enfer")
{:account, _} -> m.replyfun.("#{nick} not known")
{:number, _} -> m.replyfun.("#{nick} have not enabled sms")
end
{:noreply, state}
end
def handle_info(msg, state) do
{:noreply, state}
end
defp register_ovh_callback() do
url = path()
body = %{
"callBack" =>NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback),
"smsResponse" => %{
"cgiUrl" => NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback),
"responseType" => "cgi"
}
} |> Poison.encode!()
headers = [{"content-type", "application/json"}] ++ sign("PUT", url, body)
options = []
case HTTPoison.put(url, body, headers, options) do
{:ok, %HTTPoison.Response{status_code: 200}} ->
:ok
error -> error
end
end
defp sign(method, url, body) do
ts = DateTime.utc_now() |> DateTime.to_unix()
as = env(:app_secret)
ck = env(:consumer_key)
sign = Enum.join([as, ck, String.upcase(method), url, body, ts], "+")
sign_hex = :crypto.hash(:sha, sign) |> Base.encode16(case: :lower)
headers = [{"X-OVH-Application", env(:app_key)}, {"X-OVH-Timestamp", ts},
{"X-OVH-Signature", "$1$"<>sign_hex}, {"X-Ovh-Consumer", ck}]
end
def parse_number(num) do
{:error, :todo}
end
defp env() do
Application.get_env(:nola, :sms)
end
defp env(key) do
Keyword.get(env(), key)
end
end
diff --git a/lib/plugins/tell.ex b/lib/plugins/tell.ex
index 43da9e7..bc1f24e 100644
--- a/lib/plugins/tell.ex
+++ b/lib/plugins/tell.ex
@@ -1,106 +1,106 @@
defmodule Nola.Plugins.Tell do
use GenServer
@moduledoc """
# Tell
* **!tell `<nick>` `<message>`**: tell `message` to `nick` when they reconnect.
"""
def irc_doc, do: @moduledoc
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def dets do
(Nola.data_path() <> "/tell.dets") |> String.to_charlist()
end
def tell(m, target, message) do
GenServer.cast(__MODULE__, {:tell, m, target, message})
end
def init([]) do
regopts = [plugin: __MODULE__]
{:ok, _} = Registry.register(Nola.PubSub, "account", regopts)
{:ok, _} = Registry.register(Nola.PubSub, "trigger:tell", regopts)
{:ok, dets} = :dets.open_file(dets(), [type: :bag])
{:ok, %{dets: dets}}
end
def handle_cast({:tell, m, target, message}, state) do
do_tell(state, m, target, message)
{:noreply, state}
end
- def handle_info({:irc, :trigger, "tell", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [target | message]}}}, state) do
+ def handle_info({:irc, :trigger, "tell", m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: [target | message]}}}, state) do
do_tell(state, m, target, message)
{:noreply, state}
end
def handle_info({:account, network, channel, nick, account_id}, state) do
messages = :dets.lookup(state.dets, {network, channel, account_id})
if messages != [] do
strs = Enum.map(messages, fn({_, from, message, at}) ->
account = Nola.Account.get(from)
user = Nola.UserTrack.find_by_account(network, account)
fromnick = if user, do: user.nick, else: account.name
"#{nick}: <#{fromnick}> #{message}"
end)
Enum.each(strs, fn(s) -> IRC.Connection.broadcast_message(network, channel, s) end)
:dets.delete(state.dets, {network, channel, account_id})
end
{:noreply, state}
end
def handle_info({:account_change, old_id, new_id}, state) do
#:ets.fun2ms(fn({ {_net, _chan, target_id}, from_id, _, _} = obj) when (target_id == old_id) or (from_id == old_id) -> obj end)
spec = [{{{:"$1", :"$2", :"$3"}, :"$4", :_, :_}, [{:orelse, {:==, :"$3", {:const, old_id}}, {:==, :"$4", {:const, old_id}}}], [:"$_"]}]
Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) ->
case obj do
{ {net, chan, ^old_id}, from_id, message, at } = obj ->
:dets.delete(obj)
:dets.insert(table, {{net, chan, new_id}, from_id, message, at})
{key, ^old_id, message, at} = obj ->
:dets.delete(table, obj)
:dets.insert(table, {key, new_id, message, at})
_ -> :ok
end
end)
{:noreply, state}
end
def handle_info(info, state) do
{:noreply, state}
end
def terminate(_, state) do
:dets.close(state.dets)
:ok
end
defp do_tell(state, m, nick_target, message) do
target = Nola.Account.find_always_by_nick(m.network, m.channel, nick_target)
message = Enum.join(message, " ")
with \
{:target, %Nola.Account{} = target} <- {:target, target},
{:same, false} <- {:same, target.id == m.account.id},
target_user = Nola.UserTrack.find_by_account(m.network, target),
target_nick = if(target_user, do: target_user.nick, else: target.name),
present? = if(target_user, do: Map.has_key?(target_user.last_active, m.channel)),
{:absent, true, _} <- {:absent, !present?, target_nick},
{:message, message} <- {:message, message}
do
obj = { {m.network, m.channel, target.id}, m.account.id, message, NaiveDateTime.utc_now()}
:dets.insert(state.dets, obj)
m.replyfun.("will tell to #{target_nick}")
else
{:same, _} -> m.replyfun.("are you so stupid that you need a bot to tell yourself things ?")
{:target, _} -> m.replyfun.("#{nick_target} unknown")
{:absent, _, nick} -> m.replyfun.("#{nick} is here, tell yourself!")
{:message, _} -> m.replyfun.("can't tell without a message")
end
end
end
diff --git a/lib/plugins/untappd.ex b/lib/plugins/untappd.ex
index e1731bd..e409172 100644
--- a/lib/plugins/untappd.ex
+++ b/lib/plugins/untappd.ex
@@ -1,66 +1,66 @@
defmodule Nola.Plugins.Untappd do
def irc_doc() do
"""
# [Untappd](https://untappd.com)
* `!beer <beer name>` Information about the first beer matching `<beer name>`
* `?beer <beer name>` List the 10 firsts beer matching `<beer name>`
_Note_: The best way to search is always "Brewery Name + Beer Name", such as "Dogfish 60 Minute".
Link your Untappd account to the bot (for automated checkins on [alcoolog](#alcoolog), ...) with the `enable-untappd` command, in private.
"""
end
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_) do
{:ok, _} = Registry.register(Nola.PubSub, "trigger:beer", [plugin: __MODULE__])
{:ok, %{}}
end
- def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %{type: :bang, args: args}}}, state) do
+ def handle_info({:irc, :trigger, _, m = %Nola.Message{trigger: %{type: :bang, args: args}}}, state) do
case Untappd.search_beer(Enum.join(args, " "), limit: 1) do
{:ok, %{"response" => %{"beers" => %{"count" => count, "items" => [result | _]}}}} ->
%{"beer" => beer, "brewery" => brewery} = result
description = Map.get(beer, "beer_description")
|> String.replace("\n", " ")
|> String.replace("\r", " ")
|> String.trim()
beer_s = "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")} - #{Map.get(beer, "beer_abv")}°"
city = get_in(brewery, ["location", "brewery_city"])
location = [Map.get(brewery, "brewery_type"), city, Map.get(brewery, "country_name")]
|> Enum.filter(fn(x) -> x end)
|> Enum.join(", ")
extra = "#{Map.get(beer, "beer_style")} - IBU: #{Map.get(beer, "beer_ibu")} - #{location}"
m.replyfun.([beer_s, extra, description])
err ->
m.replyfun.("Error")
end
{:noreply, state}
end
- def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %{type: :query, args: args}}}, state) do
+ def handle_info({:irc, :trigger, _, m = %Nola.Message{trigger: %{type: :query, args: args}}}, state) do
case Untappd.search_beer(Enum.join(args, " ")) do
{:ok, %{"response" => %{"beers" => %{"count" => count, "items" => results}}}} ->
beers = for %{"beer" => beer, "brewery" => brewery} <- results do
"#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")} - #{Map.get(beer, "beer_abv")}°"
end
|> Enum.intersperse(", ")
|> Enum.join("")
m.replyfun.("#{count}. #{beers}")
err ->
m.replyfun.("Error")
end
{:noreply, state}
end
def handle_info(info, state) do
{:noreply, state}
end
end
diff --git a/lib/plugins/user_mention.ex b/lib/plugins/user_mention.ex
index f26f1d6..e7c7420 100644
--- a/lib/plugins/user_mention.ex
+++ b/lib/plugins/user_mention.ex
@@ -1,52 +1,52 @@
defmodule Nola.Plugins.UserMention do
@moduledoc """
# mention
* **@`<nick>` `<message>`**: notifie si possible le nick immédiatement via Telegram, SMS, ou équivalent à `!tell`.
"""
require Logger
def short_irc_doc, do: false
def irc_doc, do: @moduledoc
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_) do
{:ok, _} = Registry.register(Nola.PubSub, "triggers", plugin: __MODULE__)
{:ok, nil}
end
- def handle_info({:irc, :trigger, nick, message = %IRC.Message{sender: sender, account: account, network: network, channel: channel, trigger: %IRC.Trigger{type: :at, args: content}}}, state) do
+ def handle_info({:irc, :trigger, nick, message = %Nola.Message{sender: sender, account: account, network: network, channel: channel, trigger: %Nola.Trigger{type: :at, args: content}}}, state) do
nick = nick
|> String.trim(":")
|> String.trim(",")
target = Nola.Account.find_always_by_nick(network, channel, nick)
if target do
telegram = Nola.Account.get_meta(target, "telegram-id")
sms = Nola.Account.get_meta(target, "sms-number")
text = "#{channel} <#{sender.nick}> #{Enum.join(content, " ")}"
cond do
telegram ->
Nola.Telegram.send_message(telegram, "`#{channel}` <**#{sender.nick}**> #{Enum.join(content, " ")}")
sms ->
case Nola.Plugins.Sms.send_sms(sms, text) do
{:error, code} -> message.replyfun("#{sender.nick}: erreur #{code} (sms)")
end
true ->
Nola.Plugins.Tell.tell(message, nick, content)
end
else
message.replyfun.("#{nick} m'est inconnu")
end
{:noreply, state}
end
def handle_info(_, state) do
{:noreply, state}
end
end
diff --git a/lib/plugins/wikipedia.ex b/lib/plugins/wikipedia.ex
index caef306..47b14da 100644
--- a/lib/plugins/wikipedia.ex
+++ b/lib/plugins/wikipedia.ex
@@ -1,90 +1,90 @@
defmodule Nola.Plugins.Wikipedia do
require Logger
@moduledoc """
# wikipédia
* **!wp `<recherche>`**: retourne le premier résultat de la `<recherche>` Wikipedia
* **!wp**: un article Wikipédia au hasard
"""
def irc_doc, do: @moduledoc
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_) do
{:ok, _} = Registry.register(Nola.PubSub, "trigger:wp", [plugin: __MODULE__])
{:ok, nil}
end
- def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do
+ def handle_info({:irc, :trigger, _, message = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: []}}}, state) do
irc_random(message)
{:noreply, state}
end
- def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do
+ def handle_info({:irc, :trigger, _, message = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, state) do
irc_search(Enum.join(args, " "), message)
{:noreply, state}
end
def handle_info(info, state) do
{:noreply, state}
end
defp irc_search("", message), do: irc_random(message)
defp irc_search(query, message) do
params = %{
"action" => "query",
"list" => "search",
"srsearch" => String.strip(query),
"srlimit" => 1,
}
case query_wikipedia(params) do
{:ok, %{"query" => %{"search" => [item | _]}}} ->
title = item["title"]
url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_")
msg = "Wikipédia: #{title} — #{url}"
message.replyfun.(msg)
_ ->
nil
end
end
defp irc_random(message) do
params = %{
"action" => "query",
"generator" => "random",
"grnnamespace" => 0,
"prop" => "info"
}
case query_wikipedia(params) do
{:ok, %{"query" => %{"pages" => map = %{}}}} ->
[{_, item}] = Map.to_list(map)
title = item["title"]
url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_")
msg = "Wikipédia: #{title} — #{url}"
message.replyfun.(msg)
_ ->
nil
end
end
defp query_wikipedia(params) do
url = "https://fr.wikipedia.org/w/api.php"
params = params
|> Map.put("format", "json")
|> Map.put("utf8", "")
case HTTPoison.get(url, [], params: params) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body)
{:ok, %HTTPoison.Response{status_code: 400, body: body}} ->
Logger.error "Wikipedia HTTP 400: #{inspect body}"
{:error, "http 400"}
error ->
Logger.error "Wikipedia http error: #{inspect error}"
{:error, "http client error"}
end
end
end
diff --git a/lib/plugins/wolfram_alpha.ex b/lib/plugins/wolfram_alpha.ex
index 02c1c51..120af16 100644
--- a/lib/plugins/wolfram_alpha.ex
+++ b/lib/plugins/wolfram_alpha.ex
@@ -1,47 +1,47 @@
defmodule Nola.Plugins.WolframAlpha do
use GenServer
require Logger
@moduledoc """
# wolfram alpha
* **`!wa <requête>`** lance `<requête>` sur WolframAlpha
"""
def irc_doc, do: @moduledoc
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_) do
{:ok, _} = Registry.register(Nola.PubSub, "trigger:wa", [plugin: __MODULE__])
{:ok, nil}
end
- def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: query}}}, state) do
+ def handle_info({:irc, :trigger, _, m = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: query}}}, state) do
query = Enum.join(query, " ")
params = %{
"appid" => Keyword.get(Application.get_env(:nola, :wolframalpha, []), :app_id, "NO_APP_ID"),
"units" => "metric",
"i" => query
}
url = "https://www.wolframalpha.com/input/?i=" <> URI.encode(query)
case HTTPoison.get("http://api.wolframalpha.com/v1/result", [], [params: params]) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
m.replyfun.(["#{query} -> #{body}", url])
{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
error = case {code, body} do
{501, b} -> "input invalide: #{body}"
{code, error} -> "erreur #{code}: #{body || ""}"
end
m.replyfun.("wa: #{error}")
{:error, %HTTPoison.Error{reason: reason}} ->
m.replyfun.("wa: erreur http: #{to_string(reason)}")
_ ->
m.replyfun.("wa: erreur http")
end
{:noreply, state}
end
end
diff --git a/lib/plugins/youtube.ex b/lib/plugins/youtube.ex
index e23fd45..39bf03d 100644
--- a/lib/plugins/youtube.ex
+++ b/lib/plugins/youtube.ex
@@ -1,104 +1,104 @@
defmodule Nola.Plugins.YouTube do
require Logger
@moduledoc """
# youtube
* **!yt `<recherche>`**, !youtube `<recherche>`: retourne le premier résultat de la `<recherche>` YouTube
"""
defstruct client: nil
def irc_doc, do: @moduledoc
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init([]) do
for t <- ["trigger:yt", "trigger:youtube"], do: {:ok, _} = Registry.register(Nola.PubSub, t, [plugin: __MODULE__])
{:ok, %__MODULE__{}}
end
- def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do
+ def handle_info({:irc, :trigger, _, message = %Nola.Message{trigger: %Nola.Trigger{type: :bang, args: args}}}, state) do
irc_search(Enum.join(args, " "), message)
{:noreply, state}
end
def handle_info(info, state) do
{:noreply, state}
end
defp irc_search(query, message) do
case search(query) do
{:ok, %{"items" => [item | _]}} ->
url = "https://youtube.com/watch?v=" <> item["id"]
snippet = item["snippet"]
duration = item["contentDetails"]["duration"] |> String.replace("PT", "") |> String.downcase
date = snippet["publishedAt"]
|> DateTime.from_iso8601()
|> elem(1)
|> Timex.format("{relative}", :relative)
|> elem(1)
info_line = "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}"
<> " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes,"
<> " #{item["statistics"]["dislikeCount"]} dislikes"
message.replyfun.("#{snippet["title"]} — #{url}")
message.replyfun.(info_line)
{:error, error} ->
message.replyfun.("Erreur YouTube: "<>error)
_ ->
nil
end
end
defp search(query) do
query = query
|> String.strip
key = Application.get_env(:nola, :youtube)[:api_key]
params = %{
"key" => key,
"maxResults" => 1,
"part" => "id",
"safeSearch" => "none",
"type" => "video",
"q" => query,
}
url = "https://www.googleapis.com/youtube/v3/search"
case HTTPoison.get(url, [], params: params) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{:ok, json} = Jason.decode(body)
item = List.first(json["items"])
if item do
video_id = item["id"]["videoId"]
params = %{
"part" => "snippet,contentDetails,statistics",
"id" => video_id,
"key" => key
}
headers = []
options = [params: params]
case HTTPoison.get("https://www.googleapis.com/youtube/v3/videos", [], options) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
Jason.decode(body)
{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
Logger.error "YouTube HTTP #{code}: #{inspect body}"
{:error, "http #{code}"}
error ->
Logger.error "YouTube http error: #{inspect error}"
:error
end
else
:error
end
{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
Logger.error "YouTube HTTP #{code}: #{inspect body}"
{:error, "http #{code}"}
error ->
Logger.error "YouTube http error: #{inspect error}"
:error
end
end
end
diff --git a/lib/telegram/room.ex b/lib/telegram/room.ex
index cc10e90..8e95ca8 100644
--- a/lib/telegram/room.ex
+++ b/lib/telegram/room.ex
@@ -1,188 +1,188 @@
defmodule Nola.TelegramRoom do
require Logger
@behaviour Telegram.ChatBot
alias Telegram.Api
@couch "bot-telegram-rooms"
def rooms(), do: rooms(:with_docs)
@spec rooms(:with_docs | :ids) :: [Map.t | integer( )]
def rooms(:with_docs) do
case Couch.get(@couch, :all_docs, include_docs: true) do
{:ok, %{"rows" => rows}} -> {:ok, for(%{"doc" => doc} <- rows, do: doc)}
error = {:error, _} -> error
end
end
def rooms(:ids) do
case Couch.get(@couch, :all_docs) do
{:ok, %{"rows" => rows}} -> {:ok, for(%{"id" => id} <- rows, do: id)}
error = {:error, _} -> error
end
end
def room(id, opts \\ []) do
Couch.get(@couch, id, opts)
end
# TODO: Create couch
def setup() do
:ok
end
def after_start() do
for id <- room(:ids), do: Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, Integer.parse(id) |> elem(0))
end
@impl Telegram.ChatBot
def init(id) when is_integer(id) and id < 0 do
token = Keyword.get(Application.get_env(:nola, :telegram, []), :key)
{:ok, chat} = Api.request(token, "getChat", chat_id: id)
Logger.metadata(transport: :telegram, id: id, telegram_room_id: id)
tg_room = case room(id) do
{:ok, tg_room = %{"network" => _net, "channel" => _chan}} -> tg_room
{:error, :not_found} ->
[net, chan] = String.split(chat["title"], "/", parts: 2)
{net, chan} = case IRC.Connection.get_network(net, chan) do
%IRC.Connection{} -> {net, chan}
_ -> {nil, nil}
end
{:ok, _id, _rev} = Couch.post(@couch, %{"_id" => id, "network" => net, "channel" => nil})
{:ok, tg_room} = room(id)
tg_room
end
%{"network" => net, "channel" => chan} = tg_room
Logger.info("Starting ChatBot for room #{id} \"#{chat["title"]}\" #{inspect tg_room}")
irc_plumbed = if net && chan do
{:ok, _} = Registry.register(Nola.PubSub, "#{net}/#{chan}:messages", plugin: __MODULE__)
{:ok, _} = Registry.register(Nola.PubSub, "#{net}/#{chan}:triggers", plugin: __MODULE__)
{:ok, _} = Registry.register(Nola.PubSub, "#{net}/#{chan}:outputs", plugin: __MODULE__)
true
else
Logger.warn("Did not found telegram match for #{id} \"#{chat["title"]}\"")
false
end
{:ok, %{id: id, net: net, chan: chan, irc: irc_plumbed}}
end
def init(id) do
Logger.error("telegram_room: bad id (not room id)", transport: :telegram, id: id, telegram_room_id: id)
:ignoree
end
defp find_or_create_meta_account(from = %{"id" => user_id}, state) do
if account = Nola.Account.find_meta_account("telegram-id", user_id) do
account
else
first_name = Map.get(from, "first_name")
last_name = Map.get(from, "last_name")
name = [first_name, last_name]
|> Enum.filter(& &1)
|> Enum.join(" ")
username = Map.get(from, "username", first_name)
account = username
|> Nola.Account.new_account()
|> Nola.Account.update_account_name(name)
|> Nola.Account.put_meta("telegram-id", user_id)
Logger.info("telegram_room: created account #{account.id} for telegram user #{user_id}")
account
end
end
def handle_update(%{"message" => %{"from" => from = %{"id" => user_id}, "text" => text}}, _token, state) do
account = find_or_create_meta_account(from, state)
connection = IRC.Connection.get_network(state.net)
IRC.send_message_as(account, state.net, state.chan, text, true)
{:ok, state}
end
def handle_update(data = %{"message" => %{"from" => from = %{"id" => user_id}, "location" => %{"latitude" => lat, "longitude" => lon}}}, _token, state) do
account = find_or_create_meta_account(from, state)
connection = IRC.Connection.get_network(state.net)
IRC.send_message_as(account, state.net, state.chan, "@ #{lat}, #{lon}", true)
{:ok, state}
end
for type <- ~w(photo voice video document animation) do
def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do
upload(unquote(type), data, token, state)
end
end
def handle_update(update, token, state) do
{:ok, state}
end
def handle_info({:irc, _, _, message}, state) do
handle_info({:irc, nil, message}, state)
end
- def handle_info({:irc, _, message = %IRC.Message{sender: %{nick: nick}, text: text}}, state) do
+ def handle_info({:irc, _, message = %Nola.Message{sender: %{nick: nick}, text: text}}, state) do
if Map.get(message.meta, :from) == self() do
else
body = if Map.get(message.meta, :self), do: text, else: "<#{nick}> #{text}"
Nola.Telegram.send_message(state.id, body)
end
{:ok, state}
end
def handle_info(info, state) do
Logger.info("UNhandled #{inspect info}")
{:ok, state}
end
defp upload(_type, %{"message" => m = %{"chat" => %{"id" => chat_id}, "from" => from = %{"id" => user_id}}}, token, state) do
account = find_or_create_meta_account(from, state)
if account do
{content, type} = cond do
m["photo"] -> {m["photo"], "photo"}
m["voice"] -> {m["voice"], "voice message"}
m["video"] -> {m["video"], "video"}
m["document"] -> {m["document"], "file"}
m["animation"] -> {m["animation"], "gif"}
end
file = if is_list(content) && Enum.count(content) > 1 do
Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2)
|> List.first()
else
content
end
file_id = file["file_id"]
file_unique_id = file["file_unique_id"]
text = if(m["caption"], do: m["caption"] <> " ", else: "")
spawn(fn() ->
with \
{:ok, file} <- Telegram.Api.request(token, "getFile", file_id: file_id),
path = "https://api.telegram.org/file/bot#{token}/#{file["file_path"]}",
{:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path),
<<smol_body::binary-size(20), _::binary>> = body,
{:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}),
bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket),
ext = Path.extname(file["file_path"]),
s3path = "#{account.id}/#{file_unique_id}#{ext}",
s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type),
{:ok, _} <- ExAws.request(s3req)
do
path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}"
txt = "#{type}: #{text}#{path}"
connection = IRC.Connection.get_network(state.net)
IRC.send_message_as(account, state.net, state.chan, txt, true)
else
error ->
Telegram.Api.request(token, "sendMessage", chat_id: chat_id, text: "File upload failed, sorry.")
Logger.error("Failed upload from Telegram: #{inspect error}")
end
end)
{:ok, state}
end
end
end
diff --git a/lib/telegram/telegram.ex b/lib/telegram/telegram.ex
index dd23146..9a2812d 100644
--- a/lib/telegram/telegram.ex
+++ b/lib/telegram/telegram.ex
@@ -1,233 +1,233 @@
defmodule Nola.Telegram do
require Logger
@behaviour Telegram.ChatBot
def my_path() do
"https://t.me/beauttebot"
end
def send_message(id, text, md2 \\ false) do
md = if md2, do: "MarkdownV2", else: "Markdown"
token = Keyword.get(Application.get_env(:nola, :telegram, []), :key)
Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, id)
Telegram.Api.request(token, "sendMessage", chat_id: id, text: text, parse_mode: "Markdown")
end
@impl Telegram.ChatBot
def init(chat_id) when chat_id < 0 do
{:ok, state} = Nola.TelegramRoom.init(chat_id)
{:ok, %{room_state: state}}
end
def init(chat_id) do
Logger.info("Telegram session starting: #{chat_id}")
account = Nola.Account.find_meta_account("telegram-id", chat_id)
account_id = if account, do: account.id
{:ok, %{account: account_id}}
end
@impl Telegram.ChatBot
def handle_update(update, token, %{room_state: room_state}) do
{:ok, room_state} = Nola.TelegramRoom.handle_update(update, token, room_state)
{:ok, %{room_state: room_state}}
end
def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/start"<>_}}, _token, state) do
text = "*Welcome to beautte!*\n\nQuery the bot on IRC and say \"enable-telegram\" to continue."
send_message(m["chat"]["id"], text)
{:ok, %{account: nil}}
end
def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/enable"<>_}}, _token, state) do
key = case String.split(text, " ") do
["/enable", key | _] -> key
_ -> "nil"
end
#Handled message "1247435154:AAGnSSCnySn0RuVxy_SUcDEoOX_rbF6vdq0" %{"message" =>
# %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"},
# "date" => 1591027272, "entities" =>
# [%{"length" => 7, "offset" => 0, "type" => "bot_command"}],
# "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"},
# "message_id" => 11, "text" => "/enable salope"}, "update_id" => 764148578}
account = Nola.Account.find_meta_account("telegram-validation-code", String.downcase(key))
text = if account do
net = Nola.Account.get_meta(account, "telegram-validation-target")
Nola.Account.put_meta(account, "telegram-id", m["chat"]["id"])
Nola.Account.put_meta(account, "telegram-username", m["chat"]["username"])
Nola.Account.put_meta(account, "telegram-username", m["chat"]["username"])
Nola.Account.delete_meta(account, "telegram-validation-code")
Nola.Account.delete_meta(account, "telegram-validation-target")
IRC.Connection.broadcast_message(net, account, "Telegram #{m["chat"]["username"]} account added!")
"Yay! Linked to account **#{account.name}**."
else
"Token invalid"
end
send_message(m["chat"]["id"], text)
{:ok, %{account: account.id}}
end
#[debug] Unhandled update: %{"message" =>
# %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"},
# "date" => 1591096015,
# "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"},
# "message_id" => 29,
# "photo" => [
# %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADbQADZVMBAAEaBA",
# "file_size" => 9544, "file_unique_id" => "AQADRv09JF0AA2VTAQAB", "height" => 95, "width" => 320},
# %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADeAADZFMBAAEaBA",
# "file_size" => 21420, "file_unique_id" => "AQADRv09JF0AA2RTAQAB", "height" => 148, "width" => 501}]},
# "update_id" => 218161546}
for type <- ~w(photo voice video document animation) do
def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do
start_upload(unquote(type), data, token, state)
end
end
#[debug] Unhandled update: %{"callback_query" =>
# %{
# "chat_instance" => "-7948978714441865930", "data" => "evolu.net/#dmz",
# "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"},
# "id" => "8913804780149600",
# "message" => %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"},
# "date" => 1591098553, "from" => %{"first_name" => "devbeautte", "id" => 1293058838, "is_bot" => true, "username" => "devbeauttebot"},
# "message_id" => 62,
# "reply_markup" => %{"inline_keyboard" => [[%{"callback_data" => "random/#", "text" => "random/#"},
# %{"callback_data" => "evolu.net/#dmz", "text" => "evolu.net/#dmz"}]]},
# "text" => "Where should I send the file?"}
# }
# , "update_id" => 218161568}
#def handle_update(t, %{"callback_query" => cb = %{"data" => "resend", "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}) do
#end
def handle_update(%{"callback_query" => cb = %{"data" => "start-upload:"<>target, "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}, t, state) do
account = Nola.Account.find_meta_account("telegram-id", chat_id)
if account do
target = case String.split(target, "/") do
["everywhere"] -> Nola.Membership.of_account(account)
[net, chan] -> [{net, chan}]
end
Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Processing...", reply_markup: %{})
{content, type} = cond do
op["photo"] -> {op["photo"], ""}
op["voice"] -> {op["voice"], " a voice message"}
op["video"] -> {op["video"], ""}
op["document"] -> {op["document"], ""}
op["animation"] -> {op["animation"], ""}
end
file = if is_list(content) && Enum.count(content) > 1 do
Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2)
|> List.first()
else
content
end
file_id = file["file_id"]
file_unique_id = file["file_unique_id"]
text = if(op["caption"], do: ": "<> op["caption"] <> "", else: "")
resend = %{"inline_keyboard" => [ [%{"text" => "re-share", "callback_data" => "resend"}] ]}
spawn(fn() ->
with \
{:ok, file} <- Telegram.Api.request(t, "getFile", file_id: file_id),
path = "https://api.telegram.org/file/bot#{t}/#{file["file_path"]}",
{:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path),
<<smol_body::binary-size(20), _::binary>> = body,
{:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}),
bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket),
ext = Path.extname(file["file_path"]),
s3path = "#{account.id}/#{file_unique_id}#{ext}",
Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "*Uploading...*", reply_markup: %{}, parse_mode: "MarkdownV2"),
s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type),
{:ok, _} <- ExAws.request(s3req)
do
path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}"
sent = for {net, chan} <- target do
txt = "sent#{type}#{text} #{path}"
IRC.send_message_as(account, net, chan, txt)
"#{net}/#{chan}"
end
if caption = op["caption"], do: as_irc_message(chat_id, caption, account)
text = "Sent on " <> Enum.join(sent, ", ") <> " !"
Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "_Sent!_", reply_markup: %{}, parse_mode: "MarkdownV2")
else
error ->
Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Something failed.", reply_markup: %{}, parse_mode: "MarkdownV2")
Logger.error("Failed upload from Telegram: #{inspect error}")
end
end)
end
{:ok, state}
end
def handle_update(%{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}, "text" => text}}, _, state) do
account = Nola.Account.find_meta_account("telegram-id", id)
if account do
as_irc_message(id, text, account)
end
{:ok, state}
end
def handle_update(m, _, state) do
Logger.debug("Unhandled update: #{inspect m}")
{:ok, state}
end
@impl Telegram.ChatBot
def handle_info(info, %{room_state: room_state}) do
{:ok, room_state} = Nola.TelegramRoom.handle_info(info, room_state)
{:ok, %{room_state: room_state}}
end
def handle_info(_info, state) do
{:ok, state}
end
defp as_irc_message(id, text, account) do
reply_fun = fn(text) -> send_message(id, text) end
trigger_text = cond do
String.starts_with?(text, "/") ->
"/"<>text = text
"!"<>text
Enum.any?(IRC.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(text, trigger) end) ->
text
true ->
"!"<>text
end
- message = %IRC.Message{
+ message = %Nola.Message{
id: FlakeId.get(),
transport: :telegram,
network: "telegram",
channel: nil,
text: text,
account: account,
sender: %ExIRC.SenderInfo{nick: account.name},
replyfun: reply_fun,
trigger: IRC.Connection.extract_trigger(trigger_text),
at: nil
}
IRC.Connection.publish(message, ["messages:private", "messages:telegram", "telegram/#{account.id}:messages"])
message
end
defp start_upload(_type, %{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}}}, token, state) do
account = Nola.Account.find_meta_account("telegram-id", id)
if account do
text = if(m["text"], do: m["text"], else: nil)
targets = Nola.Membership.of_account(account)
|> Enum.map(fn({net, chan}) -> "#{net}/#{chan}" end)
|> Enum.map(fn(i) -> %{"text" => i, "callback_data" => "start-upload:#{i}"} end)
kb = if Enum.count(targets) > 1 do
[%{"text" => "everywhere", "callback_data" => "start-upload:everywhere"}] ++ targets
else
targets
end
|> Enum.chunk_every(2)
keyboard = %{"inline_keyboard" => kb}
Telegram.Api.request(token, "sendMessage", chat_id: id, text: "Where should I send this file?", reply_markup: keyboard, reply_to_message_id: m["message_id"], parse_mode: "MarkdownV2")
end
{:ok, state}
end
end
diff --git a/lib/tmpl.ex b/lib/tmpl.ex
index 62c2a46..e4489ac 100644
--- a/lib/tmpl.ex
+++ b/lib/tmpl.ex
@@ -1,124 +1,124 @@
defmodule Tmpl do
require Logger
defmodule Filter do
use Liquex.Filter
def repeat(text, val, _) do
String.duplicate(text, val)
end
def rrepeat(text, max, _) do
String.duplicate(text, :random.uniform(max))
end
def rrepeat(text, var) do
rrepeat(text, 20, var)
end
def bold(text, %{variables: variables}) do
unless Map.get(variables, "_no_format") || Map.get(variables, "_no_bold") do
<<2>> <> text <> <<2>>
else
text
end
end
@colors [:white, :black, :blue, :green, :red, :brown, :purple, :orange, :yellow, :light_green, :cyan, :light_blue, :pink, :grey, :light_grey]
for {color, index} <- Enum.with_index(@colors) do
code = 48+index
def color_code(unquote(color)) do
unquote(code)
end
def unquote(color)(text, %{variables: variables}) do
unless Map.get(variables, "_no_format") || Map.get(variables, "_no_colors") do
<<3, unquote(code)>> <> text <> <<3>>
else
text
end
end
end
def account_nick(%{"id" => id, "name" => name}, %{variables: %{"message" => %{"network" => network}}}) do
if user = Nola.UserTrack.find_by_account(network, %Nola.Account{id: id}) do
user.nick
else
name
end
end
def account_nick(val, ctx) do
"{{account_nick}}"
end
end
- def render(template, msg = %IRC.Message{}, context \\ %{}, safe \\ true) do
+ def render(template, msg = %Nola.Message{}, context \\ %{}, safe \\ true) do
do_render(template, Map.put(context, "message", msg), safe)
end
defp do_render(template, context, safe) when is_binary(template) do
case Liquex.parse(template) do
{:ok, template_ast} ->
do_render(template_ast, context, safe)
{:error, err, pos} ->
Logger.debug("Liquid error: #{pos} - #{inspect template}")
"[liquid ast error (at #{pos}): #{inspect err}]"
end
end
defp do_render(template_ast, context, safe) when is_list(template_ast) do
context = Liquex.Context.new(mapify(context, safe))
|> Map.put(:filter_module, Tmpl.Filter)
{content, _context} = Liquex.render(template_ast, context)
to_string(content)
rescue
e ->
Logger.error("Liquid error: #{inspect e}")
"[liquid rendering error]"
end
defp mapify(struct = %{__struct__: _}, safe) do
mapify(Map.from_struct(struct), safe)
end
defp mapify(map = %{}, safe) do
map
|> Enum.reduce(Map.new, fn({k,v}, acc) ->
k = to_string(k)
if safe?(k, safe) do
if v = mapify(v, safe) do
Map.put(acc, k, v)
else
acc
end
else
acc
end
end)
end
defp mapify(fun, _) when is_function(fun) do
nil
end
defp mapify(atom, _) when is_atom(atom) do
to_string(atom)
end
defp mapify(v, _) do
v
end
defp safe?(_, false) do
true
end
defp safe?("token", true), do: false
defp safe?("password", true), do: false
defp safe?(_, true), do: true
end
diff --git a/lib/web/live/chat_live.html.heex b/lib/web/live/chat_live.html.heex
index 29cd6a1..470604f 100644
--- a/lib/web/live/chat_live.html.heex
+++ b/lib/web/live/chat_live.html.heex
@@ -1,91 +1,91 @@
<div class="chat" data-turbo="false">
<div class="py-4 px-4 bg-gradient-to-b from-black to-gray-900">
<div class="grid grid-cols-2">
<h1 class="text-gray-50 tracking-tight font-extrabold text-xl">
<%= @network %>
<span class="font-bold"><%= @chan %></span>
</h1>
<div class="text-right">
<a href="/" class="text-gray-400"><%= @account_id %></a>
</div>
</div>
</div>
<div class="body">
<div class="log">
<p class="disconnected text-center text-6xl tracking-tight font-extrabold text-red-800 w-full my-24 mx-auto overflow-y-auto">
Disconnected <span class="text-mono">:'(</span>
</p>
<p class="phx-errored text-center text-6xl tracking-tight font-extrabold text-red-800 w-full my-24 mx-auto overflow-y-auto">
Oh no error <span class="text-mono">>:(</span>
</p>
<ul class="pt-4 pl-4">
<%= for message <- @backlog do %>
- <%= if is_map(message) && Map.get(message, :__struct__) == IRC.Message do %>
+ <%= if is_map(message) && Map.get(message, :__struct__) == Nola.Message do %>
<li class="flex gap-2 place-items-center message"
data-account-id={message.account.id}>
<NolaWeb.MessageComponent.content
message={message}
self={message.account.id == @account_id}
text={message.text}
/>
</li>
<% end %>
<%= if is_binary(message) do %>
<li class="notice"><%= message %></li>
<% end %>
<%= if is_map(message) && Map.get(message, :type) do %>
<li class="flex gap-2 place-items-center event">
<NolaWeb.Component.naive_date_time_utc datetime={message.at} format="time-24-with-seconds" />
<span class="inline-block font-bold flex-none cursor-default text-gray-700">*&nbsp;*&nbsp;*</span>
<span class="inline-block flex-grow cursor-default text-gray-700">
<NolaWeb.EventComponent.content event={message}
self={@users[message.user_id] && @users[message.user_id].account == @account_id}
user={@users[message.user_id]}
/>
</span>
</li>
<% end %>
<% end %>
</ul>
</div>
<aside>
<%= for {_, user} <- @users do %>
<details class="user dropdown">
<summary><%= user.nick %></summary>
<div class="content">
<h3 class="text-xl font-bold"><%= user.nick %></h3>
<ul class="mt-4 space-y-2">
<li class="">User: <span class="font-bold"><%= user.username %></span></li>
<li class="">Name: <%= user.realname || user.nick %></li>
<li class="">Host: <span class="font-mono"><%= user.host %></span></li>
</ul>
<div class="mt-4 font-xs text-gray-300 text-center">
UID: <%= user.id %>
<br />
AID: <%= user.account %>
</div>
</div>
</details>
<% end %>
</aside>
</div>
<.form let={f} id={"form-#{@counter}"} for={:message} phx-submit="send" class="w-full px-4 pt-4">
<div>
<div class="mt-1 flex rounded-md shadow-sm border border-gray-300">
<%= text_input f, :text, class: "focus:ring-indigo-500 focus:border-indigo-500 block w-full border rounded-md pl-4 sm:text-sm border-gray-300", autofocus: true, 'phx-hook': "AutoFocus", autocomplete: "off", placeholder: "Don't be shy, say something…" %>
<%= submit content_tag(:span, "Send"), class: "-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"%>
</div>
</div>
</.form>
</div>

File Metadata

Mime Type
text/x-diff
Expires
Fri, Mar 14, 4:49 PM (1 d, 17 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
33620
Default Alt Text
(216 KB)

Event Timeline